🎫 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.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"one-api/common"
|
"one-api/common"
|
||||||
"one-api/model"
|
"one-api/model"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -126,6 +127,10 @@ func AddRedemption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
var keys []string
|
var keys []string
|
||||||
for i := 0; i < redemption.Count; i++ {
|
for i := 0; i < redemption.Count; i++ {
|
||||||
key := common.GetUUID()
|
key := common.GetUUID()
|
||||||
@@ -135,6 +140,7 @@ func AddRedemption(c *gin.Context) {
|
|||||||
Key: key,
|
Key: key,
|
||||||
CreatedTime: common.GetTimestamp(),
|
CreatedTime: common.GetTimestamp(),
|
||||||
Quota: redemption.Quota,
|
Quota: redemption.Quota,
|
||||||
|
ExpiredTime: redemption.ExpiredTime,
|
||||||
}
|
}
|
||||||
err = cleanRedemption.Insert()
|
err = cleanRedemption.Insert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,12 +197,18 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if statusOnly != "" {
|
if statusOnly == "" {
|
||||||
cleanRedemption.Status = redemption.Status
|
if err := validateExpiredTime(redemption.ExpiredTime); err != nil {
|
||||||
} else {
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
// If you add more fields, please also update redemption.Update()
|
// If you add more fields, please also update redemption.Update()
|
||||||
cleanRedemption.Name = redemption.Name
|
cleanRedemption.Name = redemption.Name
|
||||||
cleanRedemption.Quota = redemption.Quota
|
cleanRedemption.Quota = redemption.Quota
|
||||||
|
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
||||||
|
}
|
||||||
|
if statusOnly != "" {
|
||||||
|
cleanRedemption.Status = redemption.Status
|
||||||
}
|
}
|
||||||
err = cleanRedemption.Update()
|
err = cleanRedemption.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -213,3 +225,27 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Redemption struct {
|
|||||||
Count int `json:"count" gorm:"-:all"` // only for api request
|
Count int `json:"count" gorm:"-:all"` // only for api request
|
||||||
UsedUserId int `json:"used_user_id"`
|
UsedUserId int `json:"used_user_id"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
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) {
|
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 {
|
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
||||||
return errors.New("该兑换码已被使用")
|
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
|
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Update Make sure your token's fields is completed, because this will update non-zero values
|
||||||
func (redemption *Redemption) Update() error {
|
func (redemption *Redemption) Update() error {
|
||||||
var err 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,3 +187,9 @@ func DeleteRedemptionById(id int) (err error) {
|
|||||||
}
|
}
|
||||||
return redemption.Delete()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
redemptionRoute.GET("/:id", controller.GetRedemption)
|
redemptionRoute.GET("/:id", controller.GetRedemption)
|
||||||
redemptionRoute.POST("/", controller.AddRedemption)
|
redemptionRoute.POST("/", controller.AddRedemption)
|
||||||
redemptionRoute.PUT("/", controller.UpdateRedemption)
|
redemptionRoute.PUT("/", controller.UpdateRedemption)
|
||||||
|
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
|
||||||
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
|
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
|
||||||
}
|
}
|
||||||
logRoute := apiRouter.Group("/log")
|
logRoute := apiRouter.Group("/log")
|
||||||
|
|||||||
@@ -59,7 +59,16 @@ function renderTimestamp(timestamp) {
|
|||||||
const RedemptionsTable = () => {
|
const RedemptionsTable = () => {
|
||||||
const { t } = useTranslation();
|
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) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +111,7 @@ const RedemptionsTable = () => {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (text, record, index) => {
|
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>;
|
return <div>{renderTimestamp(text)}</div>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('过期时间'),
|
||||||
|
dataIndex: 'expired_time',
|
||||||
|
render: (text) => {
|
||||||
|
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('兑换人ID'),
|
title: t('兑换人ID'),
|
||||||
dataIndex: 'used_user_id',
|
dataIndex: 'used_user_id',
|
||||||
@@ -158,8 +174,7 @@ const RedemptionsTable = () => {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// 动态添加启用/禁用按钮
|
if (record.status === 1 && !isExpired(record)) {
|
||||||
if (record.status === 1) {
|
|
||||||
moreMenuItems.push({
|
moreMenuItems.push({
|
||||||
node: 'item',
|
node: 'item',
|
||||||
name: t('禁用'),
|
name: t('禁用'),
|
||||||
@@ -169,7 +184,7 @@ const RedemptionsTable = () => {
|
|||||||
manageRedemption(record.id, 'disable', record);
|
manageRedemption(record.id, 'disable', record);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else if (!isExpired(record)) {
|
||||||
moreMenuItems.push({
|
moreMenuItems.push({
|
||||||
node: 'item',
|
node: 'item',
|
||||||
name: t('启用'),
|
name: t('启用'),
|
||||||
@@ -436,7 +451,7 @@ const RedemptionsTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRow = (record, index) => {
|
const handleRow = (record, index) => {
|
||||||
if (record.status !== 1) {
|
if (record.status !== 1 || isExpired(record)) {
|
||||||
return {
|
return {
|
||||||
style: {
|
style: {
|
||||||
background: 'var(--semi-color-disabled-border)',
|
background: 'var(--semi-color-disabled-border)',
|
||||||
@@ -459,39 +474,66 @@ const RedemptionsTable = () => {
|
|||||||
<Divider margin="12px" />
|
<Divider margin="12px" />
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
<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
|
<Button
|
||||||
theme='light'
|
type='danger'
|
||||||
type='primary'
|
icon={<IconDelete />}
|
||||||
icon={<IconPlus />}
|
className="!rounded-full w-full sm:w-auto"
|
||||||
className="!rounded-full w-full md:w-auto"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingRedemption({
|
Modal.confirm({
|
||||||
id: undefined,
|
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('添加兑换码')}
|
{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('复制所选兑换码到剪贴板')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1655,5 +1655,9 @@
|
|||||||
"设置保存失败": "Settings save failed",
|
"设置保存失败": "Settings save failed",
|
||||||
"已新增 {{count}} 个模型:{{list}}": "Added {{count}} models: {{list}}",
|
"已新增 {{count}} 个模型:{{list}}": "Added {{count}} models: {{list}}",
|
||||||
"未发现新增模型": "No new models were added",
|
"未发现新增模型": "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)"
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Card,
|
Card,
|
||||||
Tag,
|
Tag,
|
||||||
|
Form,
|
||||||
|
DatePicker,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
@@ -40,9 +42,10 @@ const EditRedemption = (props) => {
|
|||||||
name: '',
|
name: '',
|
||||||
quota: 100000,
|
quota: 100000,
|
||||||
count: 1,
|
count: 1,
|
||||||
|
expired_time: 0,
|
||||||
};
|
};
|
||||||
const [inputs, setInputs] = useState(originInputs);
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
const { name, quota, count } = inputs;
|
const { name, quota, count, expired_time } = inputs;
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
props.handleClose();
|
props.handleClose();
|
||||||
@@ -85,6 +88,9 @@ const EditRedemption = (props) => {
|
|||||||
localInputs.count = parseInt(localInputs.count);
|
localInputs.count = parseInt(localInputs.count);
|
||||||
localInputs.quota = parseInt(localInputs.quota);
|
localInputs.quota = parseInt(localInputs.quota);
|
||||||
localInputs.name = name;
|
localInputs.name = name;
|
||||||
|
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
|
||||||
|
localInputs.expired_time = 0;
|
||||||
|
}
|
||||||
let res;
|
let res;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
res = await API.put(`/api/redemption/`, {
|
res = await API.put(`/api/redemption/`, {
|
||||||
@@ -220,6 +226,25 @@ const EditRedemption = (props) => {
|
|||||||
required={!isEdit}
|
required={!isEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user