feat: add subscription redemption support to redemption codes

Redemption codes can now redeem either quota (existing) or a subscription
plan (new). The Redemption model gains Type (1=quota, 2=subscription) and
PlanId fields. When Type=2, Redeem() calls CreateUserSubscriptionFromPlanTx
to activate the plan, reusing all existing subscription logic (purchase
limits, group upgrade, quota reset, expiry). Old codes default to Type=0
which behaves identically to Type=1 (quota).
This commit is contained in:
zizi 2026-05-19 01:10:14 +08:00
parent e6bced5dc2
commit 0a0616a370
15 changed files with 284 additions and 41 deletions

View File

@ -248,6 +248,11 @@ const (
RedemptionCodeStatusUsed = 3 // also don't use 0
)
const (
RedemptionTypeQuota = 1 // 兑换额度
RedemptionTypeSubscription = 2 // 兑换订阅
)
const (
ChannelStatusUnknown = 0
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!

View File

@ -83,6 +83,22 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return
}
// 校验兑换类型
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
return
}
if redemption.Type == common.RedemptionTypeSubscription {
if redemption.PlanId <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
return
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
return
}
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
@ -94,8 +110,10 @@ func AddRedemption(c *gin.Context) {
UserId: c.GetInt("id"),
Name: redemption.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Type: redemption.Type,
Quota: redemption.Quota,
PlanId: redemption.PlanId,
CreatedTime: common.GetTimestamp(),
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
@ -150,10 +168,28 @@ func UpdateRedemption(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
}
// 校验兑换类型
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
return
}
if redemption.Type == common.RedemptionTypeSubscription {
if redemption.PlanId <= 0 {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
return
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
return
}
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime
cleanRedemption.Type = redemption.Type
cleanRedemption.PlanId = redemption.PlanId
}
if statusOnly != "" {
cleanRedemption.Status = redemption.Status

View File

@ -1104,7 +1104,7 @@ func TopUp(c *gin.Context) {
common.ApiError(c, err)
return
}
quota, err := model.Redeem(req.Key, id)
result, err := model.Redeem(req.Key, id)
if err != nil {
if errors.Is(err, model.ErrRedeemFailed) {
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
@ -1116,7 +1116,7 @@ func TopUp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": quota,
"data": result,
})
}

View File

@ -69,6 +69,9 @@ const (
MsgRedemptionFailed = "redemption.failed"
MsgRedemptionNotProvided = "redemption.not_provided"
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
MsgRedemptionTypeInvalid = "redemption.type_invalid"
MsgRedemptionPlanRequired = "redemption.plan_required"
MsgRedemptionPlanNotFound = "redemption.plan_not_found"
)
// User related messages

View File

@ -59,6 +59,9 @@ redemption.expired: "This redemption code has expired"
redemption.failed: "Redemption failed, please try again later"
redemption.not_provided: "Redemption code not provided"
redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
redemption.type_invalid: "Invalid redemption code type"
redemption.plan_required: "Subscription redemption code must specify a plan"
redemption.plan_not_found: "The subscription plan associated with this redemption code does not exist"
# User messages
user.password_login_disabled: "Password login has been disabled by administrator"

View File

@ -60,6 +60,9 @@ redemption.expired: "该兑换码已过期"
redemption.failed: "兑换失败,请稍后重试"
redemption.not_provided: "未提供兑换码"
redemption.expire_time_invalid: "过期时间不能早于当前时间"
redemption.type_invalid: "无效的兑换码类型"
redemption.plan_required: "订阅类型兑换码必须指定套餐"
redemption.plan_not_found: "兑换码关联的订阅套餐不存在"
# User messages
user.password_login_disabled: "管理员关闭了密码登录"

View File

@ -60,6 +60,9 @@ redemption.expired: "該兌換碼已過期"
redemption.failed: "兌換失敗,請稍後重試"
redemption.not_provided: "未提供兌換碼"
redemption.expire_time_invalid: "過期時間不能早於當前時間"
redemption.type_invalid: "無效的兌換碼類型"
redemption.plan_required: "訂閱類型兌換碼必須指定套餐"
redemption.plan_not_found: "兌換碼關聯的訂閱套餐不存在"
# User messages
user.password_login_disabled: "管理員關閉了密碼登錄"

View File

@ -17,7 +17,9 @@ type Redemption struct {
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Type int `json:"type" gorm:"default:1"` // 1=额度 2=订阅
Quota int `json:"quota" gorm:"default:100"`
PlanId int `json:"plan_id" gorm:"default:0"` // Type=2 时关联的订阅套餐
CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request
@ -112,12 +114,19 @@ func GetRedemptionById(id int) (*Redemption, error) {
return &redemption, err
}
func Redeem(key string, userId int) (quota int, err error) {
// RedeemResult 兑换结果
type RedeemResult struct {
Type int // 1=额度 2=订阅
Quota int // Type=1 时返回兑换的额度
PlanId int // Type=2 时返回套餐ID
}
func Redeem(key string, userId int) (*RedeemResult, error) {
if key == "" {
return 0, errors.New("未提供兑换码")
return nil, errors.New("未提供兑换码")
}
if userId == 0 {
return 0, errors.New("无效的 user id")
return nil, errors.New("无效的 user id")
}
redemption := &Redemption{}
@ -126,7 +135,7 @@ func Redeem(key string, userId int) (quota int, err error) {
keyCol = `"key"`
}
common.RandomSleep()
err = DB.Transaction(func(tx *gorm.DB) error {
err := DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
if err != nil {
return errors.New("无效的兑换码")
@ -137,10 +146,29 @@ func Redeem(key string, userId int) (quota int, err error) {
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
switch redemption.Type {
case common.RedemptionTypeQuota, 0:
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil {
return err
}
case common.RedemptionTypeSubscription:
plan, planErr := GetSubscriptionPlanById(redemption.PlanId)
if planErr != nil {
return errors.New("兑换码关联的订阅套餐不存在")
}
if !plan.Enabled {
return errors.New("兑换码关联的订阅套餐已禁用")
}
_, subErr := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redemption")
if subErr != nil {
return subErr
}
default:
return errors.New("无效的兑换码类型")
}
redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed
redemption.UsedUserId = userId
@ -149,10 +177,19 @@ func Redeem(key string, userId int) (quota int, err error) {
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
return 0, ErrRedeemFailed
return nil, ErrRedeemFailed
}
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil
result := &RedeemResult{Type: redemption.Type}
switch redemption.Type {
case common.RedemptionTypeSubscription:
result.PlanId = redemption.PlanId
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码兑换订阅套餐套餐ID %d兑换码ID %d", redemption.PlanId, redemption.Id))
default:
result.Quota = redemption.Quota
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
}
return result, nil
}
func (redemption *Redemption) Insert() error {
@ -169,7 +206,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", "expired_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time", "type", "plan_id").Updates(redemption).Error
return err
}

View File

@ -28,7 +28,7 @@ import {
import { DataTableColumnHeader } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES, REDEMPTION_TYPE } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
@ -135,6 +135,36 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
return value.includes(String(statusValue))
},
},
{
accessorKey: 'type',
meta: { label: t('Type') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Type')} />
),
cell: ({ row }) => {
const type = row.getValue('type') as number
if (type === REDEMPTION_TYPE.SUBSCRIPTION) {
return (
<StatusBadge
label={t('Subscription')}
variant='info'
copyable={false}
/>
)
}
return (
<StatusBadge
label={t('Quota')}
variant='neutral'
copyable={false}
/>
)
},
filterFn: (row, id, value) => {
const typeValue = row.getValue(id) as number
return value.includes(String(typeValue || 1))
},
},
{
id: 'code',
accessorKey: 'key',

View File

@ -34,6 +34,13 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@ -44,8 +51,9 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { DateTimePicker } from '@/components/datetime-picker'
import { getAdminPlans } from '@/features/subscriptions/api'
import { createRedemption, updateRedemption, getRedemption } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import { SUCCESS_MESSAGES, REDEMPTION_TYPE } from '../constants'
import {
getRedemptionFormSchema,
type RedemptionFormValues,
@ -71,12 +79,27 @@ export function RedemptionsMutateDrawer({
const isUpdate = !!currentRow
const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [plans, setPlans] = useState<{ id: number; title: string }[]>([])
const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)),
defaultValues: REDEMPTION_FORM_DEFAULT_VALUES,
})
const watchType = form.watch('type')
const isSubscription = watchType === REDEMPTION_TYPE.SUBSCRIPTION
// Load subscription plans when drawer opens
useEffect(() => {
if (open) {
getAdminPlans().then((res) => {
if (res.success && res.data) {
setPlans(res.data.map((p) => ({ id: p.id, title: p.title })))
}
})
}
}, [open])
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
@ -192,33 +215,102 @@ export function RedemptionsMutateDrawer({
<FormField
control={form.control}
name='quota_dollars'
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormLabel>{t('Redemption Type')}</FormLabel>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select type')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={String(REDEMPTION_TYPE.QUOTA)}>
{t('Quota')}
</SelectItem>
<SelectItem value={String(REDEMPTION_TYPE.SUBSCRIPTION)}>
{t('Subscription')}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
{isSubscription
? t('Redeem to activate a subscription plan')
: t('Redeem to add quota to wallet')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isSubscription && (
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{isSubscription && (
<FormField
control={form.control}
name='plan_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Subscription Plan')}</FormLabel>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a plan')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.title}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t('The subscription plan to activate when redeemed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='expired_time'

View File

@ -29,6 +29,11 @@ export const REDEMPTION_STATUS = {
USED: 3,
} as const
export const REDEMPTION_TYPE = {
QUOTA: 1,
SUBSCRIPTION: 2,
} as const
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
(value) => String(value)
) as `${number}`[]

View File

@ -36,7 +36,9 @@ export function getRedemptionFormSchema(t: TFunction) {
.string()
.min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID)
.max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID),
type: z.number().default(1),
quota_dollars: z.number().min(0, t('Quota must be a positive number')),
plan_id: z.number().default(0),
expired_time: z.date().optional(),
count: z
.number()
@ -48,7 +50,9 @@ export function getRedemptionFormSchema(t: TFunction) {
export type RedemptionFormValues = {
name: string
type: number
quota_dollars: number
plan_id: number
expired_time?: Date
count?: number
}
@ -59,7 +63,9 @@ export type RedemptionFormValues = {
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
name: '',
type: 1,
quota_dollars: 10,
plan_id: 0,
expired_time: undefined,
count: 1,
}
@ -76,7 +82,9 @@ export function transformFormDataToPayload(
): RedemptionFormData {
return {
name: data.name,
type: data.type,
quota: parseQuotaFromDollars(data.quota_dollars),
plan_id: data.plan_id,
expired_time: data.expired_time
? Math.floor(data.expired_time.getTime() / 1000)
: 0,
@ -92,7 +100,9 @@ export function transformRedemptionToFormDefaults(
): RedemptionFormValues {
return {
name: redemption.name,
type: redemption.type || 1,
quota_dollars: quotaUnitsToDollars(redemption.quota),
plan_id: redemption.plan_id || 0,
expired_time:
redemption.expired_time > 0
? new Date(redemption.expired_time * 1000)

View File

@ -28,7 +28,9 @@ export const redemptionSchema = z.object({
name: z.string(),
key: z.string(),
status: z.number(), // 1: enabled, 2: disabled, 3: used
type: z.number(), // 1: quota, 2: subscription
quota: z.number(),
plan_id: z.number(), // only when type=2
created_time: z.number(),
redeemed_time: z.number(),
expired_time: z.number(), // 0 for never expires
@ -72,7 +74,9 @@ export interface SearchRedemptionsParams {
export interface RedemptionFormData {
id?: number
name: string
type: number // 1: quota, 2: subscription
quota: number
plan_id: number // only when type=2
expired_time: number
count?: number // Only for create
status?: number // Only for status update

View File

@ -23,6 +23,8 @@ import { getSelf } from '@/lib/api'
import { formatQuota } from '@/lib/format'
import { redeemTopupCode } from '../api'
const REDEMPTION_TYPE_SUBSCRIPTION = 2
// ============================================================================
// Redemption Hook
// ============================================================================
@ -41,12 +43,18 @@ export function useRedemption() {
const response = await redeemTopupCode({ key: code })
if (response.success && response.data) {
const quotaAdded = response.data
toast.success(
i18next.t('Redemption successful! Added: {{quota}}', {
quota: formatQuota(quotaAdded),
})
)
const result = response.data
if (result.type === REDEMPTION_TYPE_SUBSCRIPTION) {
toast.success(
i18next.t('Redemption successful! Subscription plan activated')
)
} else {
toast.success(
i18next.t('Redemption successful! Added: {{quota}}', {
quota: formatQuota(result.quota),
})
)
}
await getSelf()
return true
}

View File

@ -33,7 +33,11 @@ export interface ApiResponse<T = unknown> {
* Standard API response types
*/
export type TopupInfoResponse = ApiResponse<TopupInfo>
export type RedemptionResponse = ApiResponse<number>
export type RedemptionResponse = ApiResponse<{
type: number // 1: quota, 2: subscription
quota: number // type=1
plan_id: number // type=2
}>
export type AmountResponse = ApiResponse<string>
export type PaymentResponse = ApiResponse<Record<string, unknown>> & {
url?: string