From 6c359839ccf2283f14181a3f0a6b821bf376c016 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Fri, 13 Jun 2025 20:51:20 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AB=20feat:=20Enhance=20redemption=20c?= =?UTF-8?q?ode=20expiry=20handling=20&=20improve=20UI=20responsiveness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend • Introduced `validateExpiredTime` helper in `controller/redemption.go`; reused in both Add & Update endpoints to enforce “expiry time must not be earlier than now”, eliminating duplicated checks • Removed `RedemptionCodeStatusExpired` constant and all related references – expiry is now determined exclusively by the `expired_time` field for simpler, safer state management • Simplified `DeleteInvalidRedemptions`: deletes codes that are `used` / `disabled` or `enabled` but already expired, without relying on extra status codes • Controller no longer mutates `status` when listing or fetching redemption codes; clients derive expiry status from timestamp Frontend • Added reusable `isExpired` helper in `RedemptionsTable.js`; leveraged for: – status rendering (orange “Expired” tag) – action-menu enable/disable logic – row styling • Removed duplicated inline expiry logic, improving readability and performance • Adjusted toolbar layout: on small screens the “Clear invalid codes” button now wraps onto its own line, while “Add” & “Copy” remain grouped Result The codebase is now more maintainable, secure, and performant with no redundant constants, centralized validation, and cleaner UI behaviour across devices. --- controller/redemption.go | 42 ++++++- model/redemption.go | 12 +- router/api-router.go | 1 + web/src/components/table/RedemptionsTable.js | 110 +++++++++++++------ web/src/i18n/locales/en.json | 6 +- web/src/pages/Redemption/EditRedemption.js | 27 ++++- 6 files changed, 158 insertions(+), 40 deletions(-) diff --git a/controller/redemption.go b/controller/redemption.go index a7e09a8a..50620597 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -5,6 +5,7 @@ import ( "one-api/common" "one-api/model" "strconv" + "errors" "github.com/gin-gonic/gin" ) @@ -126,6 +127,10 @@ func AddRedemption(c *gin.Context) { }) return } + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } var keys []string for i := 0; i < redemption.Count; i++ { key := common.GetUUID() @@ -135,6 +140,7 @@ func AddRedemption(c *gin.Context) { Key: key, CreatedTime: common.GetTimestamp(), Quota: redemption.Quota, + ExpiredTime: redemption.ExpiredTime, } err = cleanRedemption.Insert() if err != nil { @@ -191,12 +197,18 @@ func UpdateRedemption(c *gin.Context) { }) return } - if statusOnly != "" { - cleanRedemption.Status = redemption.Status - } else { + if statusOnly == "" { + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } // If you add more fields, please also update redemption.Update() cleanRedemption.Name = redemption.Name cleanRedemption.Quota = redemption.Quota + cleanRedemption.ExpiredTime = redemption.ExpiredTime + } + if statusOnly != "" { + cleanRedemption.Status = redemption.Status } err = cleanRedemption.Update() if err != nil { @@ -213,3 +225,27 @@ func UpdateRedemption(c *gin.Context) { }) return } + +func DeleteInvalidRedemption(c *gin.Context) { + rows, err := model.DeleteInvalidRedemptions() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +func validateExpiredTime(expired int64) error { + if expired != 0 && expired < common.GetTimestamp() { + return errors.New("过期时间不能早于当前时间") + } + return nil +} diff --git a/model/redemption.go b/model/redemption.go index 89c4ac8c..bf237668 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -21,6 +21,7 @@ type Redemption struct { Count int `json:"count" gorm:"-:all"` // only for api request UsedUserId int `json:"used_user_id"` DeletedAt gorm.DeletedAt `gorm:"index"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 } func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) { @@ -131,6 +132,9 @@ func Redeem(key string, userId int) (quota int, err error) { if redemption.Status != common.RedemptionCodeStatusEnabled { return errors.New("该兑换码已被使用") } + if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { + return errors.New("该兑换码已过期") + } err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error if err != nil { return err @@ -162,7 +166,7 @@ func (redemption *Redemption) SelectUpdate() error { // Update Make sure your token's fields is completed, because this will update non-zero values func (redemption *Redemption) Update() error { var err error - err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time").Updates(redemption).Error + err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error return err } @@ -183,3 +187,9 @@ func DeleteRedemptionById(id int) (err error) { } return redemption.Delete() } + +func DeleteInvalidRedemptions() (int64, error) { + now := common.GetTimestamp() + result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{}) + return result.RowsAffected, result.Error +} diff --git a/router/api-router.go b/router/api-router.go index 0ab8be7f..851e9193 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -126,6 +126,7 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute.GET("/:id", controller.GetRedemption) redemptionRoute.POST("/", controller.AddRedemption) redemptionRoute.PUT("/", controller.UpdateRedemption) + redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) redemptionRoute.DELETE("/:id", controller.DeleteRedemption) } logRoute := apiRouter.Group("/log") diff --git a/web/src/components/table/RedemptionsTable.js b/web/src/components/table/RedemptionsTable.js index 7d70f179..e11a4657 100644 --- a/web/src/components/table/RedemptionsTable.js +++ b/web/src/components/table/RedemptionsTable.js @@ -59,7 +59,16 @@ function renderTimestamp(timestamp) { const RedemptionsTable = () => { const { t } = useTranslation(); - const renderStatus = (status) => { + const isExpired = (rec) => { + return rec.status === 1 && rec.expired_time !== 0 && rec.expired_time < Math.floor(Date.now() / 1000); + }; + + const renderStatus = (status, record) => { + if (isExpired(record)) { + return ( + }>{t('已过期')} + ); + } switch (status) { case 1: return ( @@ -102,7 +111,7 @@ const RedemptionsTable = () => { dataIndex: 'status', key: 'status', render: (text, record, index) => { - return
{renderStatus(text)}
; + return
{renderStatus(text, record)}
; }, }, { @@ -125,6 +134,13 @@ const RedemptionsTable = () => { return
{renderTimestamp(text)}
; }, }, + { + title: t('过期时间'), + dataIndex: 'expired_time', + render: (text) => { + return
{text === 0 ? t('永不过期') : renderTimestamp(text)}
; + }, + }, { title: t('兑换人ID'), dataIndex: 'used_user_id', @@ -158,8 +174,7 @@ const RedemptionsTable = () => { } ]; - // 动态添加启用/禁用按钮 - if (record.status === 1) { + if (record.status === 1 && !isExpired(record)) { moreMenuItems.push({ node: 'item', name: t('禁用'), @@ -169,7 +184,7 @@ const RedemptionsTable = () => { manageRedemption(record.id, 'disable', record); }, }); - } else { + } else if (!isExpired(record)) { moreMenuItems.push({ node: 'item', name: t('启用'), @@ -436,7 +451,7 @@ const RedemptionsTable = () => { }; const handleRow = (record, index) => { - if (record.status !== 1) { + if (record.status !== 1 || isExpired(record)) { return { style: { background: 'var(--semi-color-disabled-border)', @@ -459,39 +474,66 @@ const RedemptionsTable = () => {
-
+
+
+ + +
-
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 98de048b..3b523cbf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1655,5 +1655,9 @@ "设置保存失败": "Settings save failed", "已新增 {{count}} 个模型:{{list}}": "Added {{count}} models: {{list}}", "未发现新增模型": "No new models were added", - "令牌用于API访问认证,可以设置额度限制和模型权限。": "Tokens are used for API access authentication, and can set quota limits and model permissions." + "令牌用于API访问认证,可以设置额度限制和模型权限。": "Tokens are used for API access authentication, and can set quota limits and model permissions.", + "清除失效兑换码": "Clear invalid redemption codes", + "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?", + "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.", + "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)" } \ No newline at end of file diff --git a/web/src/pages/Redemption/EditRedemption.js b/web/src/pages/Redemption/EditRedemption.js index e720f90c..a3664d0c 100644 --- a/web/src/pages/Redemption/EditRedemption.js +++ b/web/src/pages/Redemption/EditRedemption.js @@ -20,6 +20,8 @@ import { Typography, Card, Tag, + Form, + DatePicker, } from '@douyinfe/semi-ui'; import { IconCreditCard, @@ -40,9 +42,10 @@ const EditRedemption = (props) => { name: '', quota: 100000, count: 1, + expired_time: 0, }; const [inputs, setInputs] = useState(originInputs); - const { name, quota, count } = inputs; + const { name, quota, count, expired_time } = inputs; const handleCancel = () => { props.handleClose(); @@ -85,6 +88,9 @@ const EditRedemption = (props) => { localInputs.count = parseInt(localInputs.count); localInputs.quota = parseInt(localInputs.quota); localInputs.name = name; + if (localInputs.expired_time === null || localInputs.expired_time === undefined) { + localInputs.expired_time = 0; + } let res; if (isEdit) { res = await API.put(`/api/redemption/`, { @@ -220,6 +226,25 @@ const EditRedemption = (props) => { required={!isEdit} />
+
+ {t('过期时间')} + { + if (value === null || value === undefined) { + handleInputChange('expired_time', 0); + } else { + const timestamp = Math.floor(value.getTime() / 1000); + handleInputChange('expired_time', timestamp); + } + }} + size="large" + className="!rounded-lg w-full" + /> +