Ver código fonte

🎫 feat: Enhance redemption code expiry handling & improve UI responsiveness

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.
Apple\Apple 10 meses atrás
pai
commit
6c359839cc

+ 39 - 3
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
+}

+ 11 - 1
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
+}

+ 1 - 0
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")

+ 76 - 34
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 (
+        <Tag color='orange' size='large' shape='circle' prefixIcon={<Minus size={14} />}>{t('已过期')}</Tag>
+      );
+    }
     switch (status) {
       case 1:
         return (
@@ -102,7 +111,7 @@ const RedemptionsTable = () => {
       dataIndex: 'status',
       key: 'status',
       render: (text, record, index) => {
-        return <div>{renderStatus(text)}</div>;
+        return <div>{renderStatus(text, record)}</div>;
       },
     },
     {
@@ -125,6 +134,13 @@ const RedemptionsTable = () => {
         return <div>{renderTimestamp(text)}</div>;
       },
     },
+    {
+      title: t('过期时间'),
+      dataIndex: 'expired_time',
+      render: (text) => {
+        return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
+      },
+    },
     {
       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 = () => {
       <Divider margin="12px" />
 
       <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+        <div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
+          <div className="flex gap-2 w-full sm:w-auto">
+            <Button
+              theme='light'
+              type='primary'
+              icon={<IconPlus />}
+              className="!rounded-full w-full sm:w-auto"
+              onClick={() => {
+                setEditingRedemption({
+                  id: undefined,
+                });
+                setShowEdit(true);
+              }}
+            >
+              {t('添加兑换码')}
+            </Button>
+            <Button
+              type='warning'
+              icon={<IconCopy />}
+              className="!rounded-full w-full sm:w-auto"
+              onClick={async () => {
+                if (selectedKeys.length === 0) {
+                  showError(t('请至少选择一个兑换码!'));
+                  return;
+                }
+                let keys = '';
+                for (let i = 0; i < selectedKeys.length; i++) {
+                  keys +=
+                    selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
+                }
+                await copyText(keys);
+              }}
+            >
+              {t('复制所选兑换码到剪贴板')}
+            </Button>
+          </div>
           <Button
-            theme='light'
-            type='primary'
-            icon={<IconPlus />}
-            className="!rounded-full w-full md:w-auto"
+            type='danger'
+            icon={<IconDelete />}
+            className="!rounded-full w-full sm:w-auto"
             onClick={() => {
-              setEditingRedemption({
-                id: undefined,
+              Modal.confirm({
+                title: t('确定清除所有失效兑换码?'),
+                content: t('将删除已使用、已禁用及过期的兑换码,此操作不可撤销。'),
+                onOk: async () => {
+                  setLoading(true);
+                  const res = await API.delete('/api/redemption/invalid');
+                  const { success, message, data } = res.data;
+                  if (success) {
+                    showSuccess(t('已删除 {{count}} 条失效兑换码', { count: data }));
+                    await refresh();
+                  } else {
+                    showError(message);
+                  }
+                  setLoading(false);
+                },
               });
-              setShowEdit(true);
-            }}
-          >
-            {t('添加兑换码')}
-          </Button>
-          <Button
-            type='warning'
-            icon={<IconCopy />}
-            className="!rounded-full w-full md:w-auto"
-            onClick={async () => {
-              if (selectedKeys.length === 0) {
-                showError(t('请至少选择一个兑换码!'));
-                return;
-              }
-              let keys = '';
-              for (let i = 0; i < selectedKeys.length; i++) {
-                keys +=
-                  selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
-              }
-              await copyText(keys);
             }}
           >
-            {t('复制所选兑换码到剪贴板')}
+            {t('清除失效兑换码')}
           </Button>
         </div>
 

+ 5 - 1
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)"
 }

+ 26 - 1
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}
                   />
                 </div>
+                <div>
+                  <Text strong className="block mb-2">{t('过期时间')}</Text>
+                  <DatePicker
+                    type="dateTime"
+                    placeholder={t('选择过期时间(可选,留空为永久)')}
+                    showClear
+                    value={expired_time ? new Date(expired_time * 1000) : null}
+                    onChange={(value) => {
+                      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"
+                  />
+                </div>
               </div>
             </Card>