Skip to content

Commit 2ecb04b

Browse files
dengzhaofunclaude
andauthored
Add cdkey module — batch management + redemption + admin UI (#16)
Introduces the redemption-code ("CDKey") subsystem so operators can issue one-off KOL/event codes and shared universal codes that players exchange for item rewards. This closes the gap between the existing item/exchange modules and real-world distribution: shop defines products, item grants the goods, but until now there was no way to hand a player a string that unlocks a reward. Server (apps/server) - schema/cdkey.ts: four tables — cdkey_batches (activity config), cdkey_codes (the strings; 1 row for universal, N for unique), cdkey_user_states (per-user counter for universal perUserLimit), cdkey_redemption_logs (immutable audit + idempotency via UNIQUE(org, source, source_id)) - modules/cdkey: service, admin routes (/api/cdkey/*), client routes (/api/client/cdkey/redeem) with HMAC via clientCredentialService, zod validators with .openapi() metadata, typed ModuleError subclasses - lib/cdkey-code.ts: readable 32-char alphabet code generator (no O/0/I/1/L) - Idempotent redeem flow: pending log pre-insert → JOIN resolve → atomic counter/code-state consumption → itemService.grantItems → success log. Failure paths compensate and backfill resolved batchId/codeId on the log so failed attempts stay queryable. Admin (apps/admin) - routes/_dashboard/cdkey: list, create (universal vs unique form branches + reward editor), detail page with Detail / Codes / Logs tabs - hooks/use-cdkey.ts + lib/types/cdkey.ts - AppSidebar: new "CDKey" nav entry - en.json / zh.json: 38 new i18n keys Migration - drizzle/0011_dazzling_giant_man.sql — 4 tables, indexes, FKs Tests: 14/14 service-layer tests pass (universal limits, unique occupy, idempotency replay, revoke, invalid code, batch inactive/expired, universalCode conflict). End-to-end smoke through the admin UI (register → org → two batches → redeem → tabs reflect state) verified. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 37c02f4 commit 2ecb04b

23 files changed

Lines changed: 9458 additions & 2 deletions

File tree

apps/admin/messages/en.json

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"common_edit": "Edit",
9393
"common_delete": "Delete",
9494
"common_cancel": "Cancel",
95+
"common_add": "Add",
9596
"common_save_changes": "Save Changes",
9697
"common_done": "Done",
9798
"common_copy": "Copy",
@@ -540,5 +541,43 @@
540541
"link_add_param": "Add param",
541542
"link_pending_badge": "Pending module",
542543
"link_action": "Click action",
543-
"link_param_hint_uuid": "Must be a UUID"
544+
"link_param_hint_uuid": "Must be a UUID",
545+
546+
"nav_cdkey": "CDKey",
547+
"cdkey_title": "CDKey Batches",
548+
"cdkey_new_batch": "New Batch",
549+
"cdkey_no_batches": "No batches yet",
550+
"cdkey_code_type": "Code type",
551+
"cdkey_code_type_universal": "Universal",
552+
"cdkey_code_type_unique": "Unique",
553+
"cdkey_total_limit": "Total limit",
554+
"cdkey_per_user_limit": "Per-user limit",
555+
"cdkey_redeemed": "Redeemed",
556+
"cdkey_reward": "Reward",
557+
"cdkey_universal_code": "Universal code",
558+
"cdkey_initial_count": "Initial count",
559+
"cdkey_starts_at": "Starts at",
560+
"cdkey_ends_at": "Ends at",
561+
"cdkey_batch_created": "Batch created",
562+
"cdkey_failed_create": "Failed to create batch",
563+
"cdkey_failed_load": "Failed to load CDKey data",
564+
"cdkey_tab_detail": "Detail",
565+
"cdkey_tab_codes": "Codes",
566+
"cdkey_tab_logs": "Logs",
567+
"cdkey_generate_more": "Generate more",
568+
"cdkey_generate_count": "Count",
569+
"cdkey_generate_success": "Codes generated",
570+
"cdkey_export_csv": "Export CSV",
571+
"cdkey_revoke": "Revoke",
572+
"cdkey_revoked": "Code revoked",
573+
"cdkey_no_codes": "No codes yet",
574+
"cdkey_no_logs": "No redemption logs yet",
575+
"cdkey_code": "Code",
576+
"cdkey_redeemed_by": "Redeemed by",
577+
"cdkey_redeemed_at": "Redeemed at",
578+
"cdkey_log_source": "Source",
579+
"cdkey_log_idempotency": "Idempotency key",
580+
"cdkey_log_result": "Result",
581+
"cdkey_log_reason": "Reason",
582+
"cdkey_optional_universal_code": "Leave blank to auto-generate a unique string."
544583
}

apps/admin/messages/zh.json

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"common_edit": "编辑",
9393
"common_delete": "删除",
9494
"common_cancel": "取消",
95+
"common_add": "添加",
9596
"common_save_changes": "保存更改",
9697
"common_done": "完成",
9798
"common_copy": "复制",
@@ -540,5 +541,43 @@
540541
"link_add_param": "添加参数",
541542
"link_pending_badge": "模块待实现",
542543
"link_action": "跳转动作",
543-
"link_param_hint_uuid": "必须是 UUID"
544+
"link_param_hint_uuid": "必须是 UUID",
545+
546+
"nav_cdkey": "兑换码",
547+
"cdkey_title": "兑换码批次",
548+
"cdkey_new_batch": "新建批次",
549+
"cdkey_no_batches": "暂无批次",
550+
"cdkey_code_type": "码类型",
551+
"cdkey_code_type_universal": "通用码",
552+
"cdkey_code_type_unique": "一次性码",
553+
"cdkey_total_limit": "总次数上限",
554+
"cdkey_per_user_limit": "每人上限",
555+
"cdkey_redeemed": "已领取",
556+
"cdkey_reward": "奖励",
557+
"cdkey_universal_code": "通用码字符串",
558+
"cdkey_initial_count": "初始数量",
559+
"cdkey_starts_at": "开始时间",
560+
"cdkey_ends_at": "结束时间",
561+
"cdkey_batch_created": "批次已创建",
562+
"cdkey_failed_create": "创建批次失败",
563+
"cdkey_failed_load": "加载兑换码数据失败",
564+
"cdkey_tab_detail": "详情",
565+
"cdkey_tab_codes": "码列表",
566+
"cdkey_tab_logs": "领取记录",
567+
"cdkey_generate_more": "追加生成",
568+
"cdkey_generate_count": "数量",
569+
"cdkey_generate_success": "已生成",
570+
"cdkey_export_csv": "导出 CSV",
571+
"cdkey_revoke": "作废",
572+
"cdkey_revoked": "码已作废",
573+
"cdkey_no_codes": "暂无码",
574+
"cdkey_no_logs": "暂无领取记录",
575+
"cdkey_code": "",
576+
"cdkey_redeemed_by": "领取人",
577+
"cdkey_redeemed_at": "领取时间",
578+
"cdkey_log_source": "来源",
579+
"cdkey_log_idempotency": "幂等 Key",
580+
"cdkey_log_result": "结果",
581+
"cdkey_log_reason": "原因",
582+
"cdkey_optional_universal_code": "留空则自动生成"
544583
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
MessagesSquare,
1111
Package,
1212
ShoppingCart,
13+
Ticket,
1314
} from "lucide-react"
1415

1516
import {
@@ -34,6 +35,7 @@ function getNavItems() {
3435
{ title: m.nav_checkin(), to: "/check-in" as const, icon: CalendarCheck },
3536
{ title: m.nav_item(), to: "/item" as const, icon: Package },
3637
{ title: m.nav_exchange(), to: "/exchange" as const, icon: ArrowLeftRight },
38+
{ title: m.nav_cdkey(), to: "/cdkey" as const, icon: Ticket },
3739
{ title: m.nav_shop(), to: "/shop" as const, icon: ShoppingCart },
3840
{ title: m.nav_mail(), to: "/mail" as const, icon: Mail },
3941
{ title: m.nav_banner(), to: "/banner" as const, icon: GalleryHorizontal },

apps/admin/src/hooks/use-cdkey.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
2+
3+
import { api } from "#/lib/api-client"
4+
import type {
5+
CdkeyBatch,
6+
CdkeyCode,
7+
CdkeyRedemptionLog,
8+
CreateBatchInput,
9+
UpdateBatchInput,
10+
} from "#/lib/types/cdkey"
11+
12+
const BATCHES_KEY = ["cdkey-batches"] as const
13+
14+
// ─── Batches ──────────────────────────────────────────────────────
15+
16+
export function useCdkeyBatches() {
17+
return useQuery({
18+
queryKey: BATCHES_KEY,
19+
queryFn: () => api.get<{ items: CdkeyBatch[] }>("/api/cdkey/batches"),
20+
select: (data) => data.items,
21+
})
22+
}
23+
24+
export function useCdkeyBatch(key: string) {
25+
return useQuery({
26+
queryKey: [...BATCHES_KEY, key],
27+
queryFn: () => api.get<CdkeyBatch>(`/api/cdkey/batches/${key}`),
28+
enabled: !!key,
29+
})
30+
}
31+
32+
export function useCreateCdkeyBatch() {
33+
const qc = useQueryClient()
34+
return useMutation({
35+
mutationFn: (input: CreateBatchInput) =>
36+
api.post<CdkeyBatch>("/api/cdkey/batches", input),
37+
onSuccess: () => qc.invalidateQueries({ queryKey: BATCHES_KEY }),
38+
})
39+
}
40+
41+
export function useUpdateCdkeyBatch() {
42+
const qc = useQueryClient()
43+
return useMutation({
44+
mutationFn: ({ key, ...input }: UpdateBatchInput & { key: string }) =>
45+
api.patch<CdkeyBatch>(`/api/cdkey/batches/${key}`, input),
46+
onSuccess: () => qc.invalidateQueries({ queryKey: BATCHES_KEY }),
47+
})
48+
}
49+
50+
export function useDeleteCdkeyBatch() {
51+
const qc = useQueryClient()
52+
return useMutation({
53+
mutationFn: (key: string) => api.delete(`/api/cdkey/batches/${key}`),
54+
onSuccess: () => qc.invalidateQueries({ queryKey: BATCHES_KEY }),
55+
})
56+
}
57+
58+
// ─── Codes ─────────────────────────────────────────────────────────
59+
60+
export function useCdkeyCodes(
61+
batchId: string,
62+
opts: { status?: string; limit?: number; offset?: number } = {},
63+
) {
64+
const params = new URLSearchParams()
65+
if (opts.status) params.set("status", opts.status)
66+
if (opts.limit != null) params.set("limit", String(opts.limit))
67+
if (opts.offset != null) params.set("offset", String(opts.offset))
68+
const qs = params.toString() ? `?${params}` : ""
69+
return useQuery({
70+
queryKey: ["cdkey-codes", batchId, opts],
71+
queryFn: () =>
72+
api.get<{ items: CdkeyCode[]; total: number }>(
73+
`/api/cdkey/batches/${batchId}/codes${qs}`,
74+
),
75+
enabled: !!batchId,
76+
})
77+
}
78+
79+
export function useGenerateCdkeyCodes() {
80+
const qc = useQueryClient()
81+
return useMutation({
82+
mutationFn: ({ batchId, count }: { batchId: string; count: number }) =>
83+
api.post<{ generated: number }>(
84+
`/api/cdkey/batches/${batchId}/codes/generate`,
85+
{ count },
86+
),
87+
onSuccess: (_, vars) => {
88+
qc.invalidateQueries({ queryKey: ["cdkey-codes", vars.batchId] })
89+
qc.invalidateQueries({ queryKey: BATCHES_KEY })
90+
},
91+
})
92+
}
93+
94+
export function useRevokeCdkeyCode() {
95+
const qc = useQueryClient()
96+
return useMutation({
97+
mutationFn: (codeId: string) =>
98+
api.patch<CdkeyCode>(`/api/cdkey/codes/${codeId}/revoke`),
99+
onSuccess: () => qc.invalidateQueries({ queryKey: ["cdkey-codes"] }),
100+
})
101+
}
102+
103+
// ─── Logs ──────────────────────────────────────────────────────────
104+
105+
export function useCdkeyLogs(
106+
batchId: string,
107+
opts: { status?: string; limit?: number; offset?: number } = {},
108+
) {
109+
const params = new URLSearchParams()
110+
if (opts.status) params.set("status", opts.status)
111+
if (opts.limit != null) params.set("limit", String(opts.limit))
112+
if (opts.offset != null) params.set("offset", String(opts.offset))
113+
const qs = params.toString() ? `?${params}` : ""
114+
return useQuery({
115+
queryKey: ["cdkey-logs", batchId, opts],
116+
queryFn: () =>
117+
api.get<{ items: CdkeyRedemptionLog[]; total: number }>(
118+
`/api/cdkey/batches/${batchId}/logs${qs}`,
119+
),
120+
enabled: !!batchId,
121+
})
122+
}

apps/admin/src/lib/types/cdkey.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { ItemEntry } from "./item"
2+
3+
export type CdkeyCodeType = "universal" | "unique"
4+
5+
export interface CdkeyBatch {
6+
id: string
7+
organizationId: string
8+
alias: string | null
9+
name: string
10+
description: string | null
11+
codeType: CdkeyCodeType
12+
reward: ItemEntry[]
13+
totalLimit: number | null
14+
perUserLimit: number
15+
totalRedeemed: number
16+
startsAt: string | null
17+
endsAt: string | null
18+
isActive: boolean
19+
metadata: Record<string, unknown> | null
20+
createdAt: string
21+
updatedAt: string
22+
}
23+
24+
export interface CdkeyCode {
25+
id: string
26+
organizationId: string
27+
batchId: string
28+
code: string
29+
status: string
30+
redeemedBy: string | null
31+
redeemedAt: string | null
32+
createdAt: string
33+
}
34+
35+
export interface CdkeyRedemptionLog {
36+
id: string
37+
organizationId: string
38+
endUserId: string
39+
batchId: string
40+
codeId: string | null
41+
code: string
42+
source: string
43+
sourceId: string
44+
status: string
45+
failReason: string | null
46+
reward: ItemEntry[] | null
47+
createdAt: string
48+
}
49+
50+
export interface CreateBatchInput {
51+
name: string
52+
alias?: string | null
53+
description?: string | null
54+
codeType: CdkeyCodeType
55+
reward: ItemEntry[]
56+
totalLimit?: number | null
57+
perUserLimit?: number
58+
startsAt?: string | null
59+
endsAt?: string | null
60+
isActive?: boolean
61+
metadata?: Record<string, unknown> | null
62+
universalCode?: string
63+
initialCount?: number
64+
}
65+
66+
export interface UpdateBatchInput {
67+
name?: string
68+
alias?: string | null
69+
description?: string | null
70+
reward?: ItemEntry[]
71+
totalLimit?: number | null
72+
perUserLimit?: number
73+
startsAt?: string | null
74+
endsAt?: string | null
75+
isActive?: boolean
76+
metadata?: Record<string, unknown> | null
77+
}

0 commit comments

Comments
 (0)