diff --git a/common/constants.go b/common/constants.go index c7d5637c..791cebb3 100644 --- a/common/constants.go +++ b/common/constants.go @@ -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! diff --git a/controller/redemption.go b/controller/redemption.go index 6130a22c..90814359 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -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 diff --git a/controller/user.go b/controller/user.go index 555ef9b3..044e02da 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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, }) } diff --git a/i18n/keys.go b/i18n/keys.go index 6cf5c1bd..e7d48587 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -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 diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 198aa274..783e61e4 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -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" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 45a10a58..95794e6a 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -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: "管理员关闭了密码登录" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index ad5a6eae..487d606e 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -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: "管理員關閉了密碼登錄" diff --git a/model/redemption.go b/model/redemption.go index b0ccb5df..dcf5cc9e 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -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 } diff --git a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx index e464f1fb..d384e250 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-columns.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-columns.tsx @@ -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[] { return value.includes(String(statusValue)) }, }, + { + accessorKey: 'type', + meta: { label: t('Type') }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = row.getValue('type') as number + if (type === REDEMPTION_TYPE.SUBSCRIPTION) { + return ( + + ) + } + return ( + + ) + }, + filterFn: (row, id, value) => { + const typeValue = row.getValue(id) as number + return value.includes(String(typeValue || 1)) + }, + }, { id: 'code', accessorKey: 'key', diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 8b24a50c..6f0f8580 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -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({ 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({ ( - {quotaLabel} - - - field.onChange(parseFloat(e.target.value) || 0) - } - /> - + {t('Redemption Type')} + - {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')} )} /> + {!isSubscription && ( + ( + + {quotaLabel} + + + field.onChange(parseFloat(e.target.value) || 0) + } + /> + + + {tokensOnly + ? t('Enter the quota amount in tokens') + : t('Enter the quota amount in {{currency}}', { + currency: currencyLabel, + })} + + + + )} + /> + )} + + {isSubscription && ( + ( + + {t('Subscription Plan')} + + + {t('The subscription plan to activate when redeemed')} + + + + )} + /> + )} + String(value) ) as `${number}`[] diff --git a/web/default/src/features/redemption-codes/lib/redemption-form.ts b/web/default/src/features/redemption-codes/lib/redemption-form.ts index 97403327..958f9032 100644 --- a/web/default/src/features/redemption-codes/lib/redemption-form.ts +++ b/web/default/src/features/redemption-codes/lib/redemption-form.ts @@ -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) diff --git a/web/default/src/features/redemption-codes/types.ts b/web/default/src/features/redemption-codes/types.ts index 88bf3d56..429fbfcc 100644 --- a/web/default/src/features/redemption-codes/types.ts +++ b/web/default/src/features/redemption-codes/types.ts @@ -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 diff --git a/web/default/src/features/wallet/hooks/use-redemption.ts b/web/default/src/features/wallet/hooks/use-redemption.ts index 30211536..b84f3345 100644 --- a/web/default/src/features/wallet/hooks/use-redemption.ts +++ b/web/default/src/features/wallet/hooks/use-redemption.ts @@ -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 } diff --git a/web/default/src/features/wallet/types.ts b/web/default/src/features/wallet/types.ts index b88befb8..259235bf 100644 --- a/web/default/src/features/wallet/types.ts +++ b/web/default/src/features/wallet/types.ts @@ -33,7 +33,11 @@ export interface ApiResponse { * Standard API response types */ export type TopupInfoResponse = ApiResponse -export type RedemptionResponse = ApiResponse +export type RedemptionResponse = ApiResponse<{ + type: number // 1: quota, 2: subscription + quota: number // type=1 + plan_id: number // type=2 +}> export type AmountResponse = ApiResponse export type PaymentResponse = ApiResponse> & { url?: string