Przeglądaj źródła

🎫 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 8 miesięcy temu
rodzic
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>