From 49189d346b080b77a286009aa8595d4a32abffb6 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sat, 25 Jul 2026 22:32:31 +0800 Subject: [PATCH] feat: expand redemption code management --- docs/api.md | 28 +- .../src/pages/admin/AdminRedemptionPage.vue | 314 ++++++++-- frontend/src/services/api.ts | 51 +- migrations/011_redemption_code_management.sql | 9 + src/api/redemption.rs | 591 ++++++++++++++++-- 5 files changed, 904 insertions(+), 89 deletions(-) create mode 100644 migrations/011_redemption_code_management.sql diff --git a/docs/api.md b/docs/api.md index 0f872b0..58bd49b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -739,10 +739,12 @@ Authorization: Bearer ### 11.7 兑换码管理 ```http -GET /admin/redemption-codes?page=1&limit=50 +GET /admin/redemption-codes?page=1&limit=50&status=available&benefit_kind=plan&plan_id=&keyword=活动 Authorization: Bearer ``` +筛选参数均可省略:`status` 支持 `available/redeemed/disabled/expired`,`benefit_kind` 支持 `plan/units`,`keyword` 可搜索完整兑换码(精确匹配)、脱敏标识、备注和兑换人。列表响应使用 `Cache-Control: no-store`。 + ```http POST /admin/redemption-codes Authorization: Bearer @@ -758,7 +760,7 @@ Content-Type: application/json } ``` -套餐卡使用 `benefit_kind: "plan"` 并传入 `plan_id`。完整兑换码只在创建响应中返回一次,数据库只保存 HMAC 哈希和脱敏标识。 +套餐卡使用 `benefit_kind: "plan"` 并传入 `plan_id`。新生成的完整兑换码使用 AES-256-GCM 加密保存,管理员之后仍可在列表响应的 `code` 字段查看和复制;HMAC 哈希继续单独用于兑换校验。升级前生成的历史码无法从哈希逆向恢复,因此其 `code` 为 `null`,仍提供 `code_hint`。 ```http PUT /admin/redemption-codes/{code_id} @@ -768,6 +770,28 @@ Content-Type: application/json { "is_active": false } ``` +已兑换的兑换码保留为权益和审计凭据,不能再修改状态或删除。未兑换兑换码支持单独删除: + +```http +DELETE /admin/redemption-codes/{code_id} +Authorization: Bearer +``` + +批量启用、停用或删除最多支持 200 个 ID;已兑换记录自动跳过,响应会返回处理、跳过和未找到数量: + +```http +POST /admin/redemption-codes/batch +Authorization: Bearer +Content-Type: application/json + +{ + "ids": ["", ""], + "action": "disable" +} +``` + +`action` 支持 `enable/disable/delete`,所有单条和批量操作都会写入管理员审计日志。 + ### 11.8 邮箱验证开关 `GET /admin/auth` 和 `PUT /admin/auth` 的配置体包含 `email_verification_required`。该配置独立于 SMTP,修改后立即生效,不需要重启服务。 diff --git a/frontend/src/pages/admin/AdminRedemptionPage.vue b/frontend/src/pages/admin/AdminRedemptionPage.vue index 3fbc12d..63b4a7d 100644 --- a/frontend/src/pages/admin/AdminRedemptionPage.vue +++ b/frontend/src/pages/admin/AdminRedemptionPage.vue @@ -2,11 +2,16 @@ import { computed, onMounted, ref } from 'vue' import { + batchUpdateAdminRedemptionCodes, createAdminRedemptionCodes, + deleteAdminRedemptionCode, listAdminPlans, listAdminRedemptionCodes, updateAdminRedemptionCode, type AdminPlanView, + type AdminRedemptionBatchAction, + type AdminRedemptionCodeFilters, + type AdminRedemptionCodeStatus, type AdminRedemptionCodeView, type GeneratedRedemptionCode, type RedemptionKind, @@ -17,11 +22,25 @@ import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() const loading = ref(true) const error = ref(null) +const actionMessage = ref(null) const plans = ref([]) const codes = ref([]) const total = ref(0) const page = ref(1) -const limit = 50 +const limit = ref(50) + +const filterForm = ref<{ + keyword: string + status: '' | AdminRedemptionCodeStatus + benefit_kind: '' | RedemptionKind + plan_id: string +}>({ + keyword: '', + status: '', + benefit_kind: '', + plan_id: '', +}) +const activeFilters = ref({}) const form = ref({ benefit_kind: 'plan' as RedemptionKind, @@ -36,11 +55,21 @@ const generating = ref(false) const generated = ref([]) const generateMessage = ref(null) const generateError = ref(null) +const generatedCopyMessage = ref(null) const updatingId = ref(null) +const deletingId = ref(null) +const batchBusy = ref(false) +const selectedIds = ref([]) +const copiedId = ref(null) const copyMessage = ref(null) -const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit))) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value))) +const activePlans = computed(() => plans.value.filter((plan) => plan.is_active)) const generatedText = computed(() => generated.value.map((item) => item.code).join('\n')) +const selectedCount = computed(() => selectedIds.value.length) +const allPageSelected = computed( + () => codes.value.length > 0 && codes.value.every((item) => selectedIds.value.includes(item.id)), +) function errorText(err: unknown, fallback: string) { return err instanceof ApiError ? `[${err.code}] ${err.message}` : fallback @@ -50,17 +79,18 @@ async function load(targetPage = page.value) { if (!auth.token) return loading.value = true error.value = null + selectedIds.value = [] try { const [planResp, codeResp] = await Promise.all([ listAdminPlans(auth.token), - listAdminRedemptionCodes(auth.token, targetPage, limit), + listAdminRedemptionCodes(auth.token, targetPage, limit.value, activeFilters.value), ]) - plans.value = planResp.plans.filter((plan) => plan.is_active) + plans.value = planResp.plans codes.value = codeResp.codes total.value = codeResp.total page.value = codeResp.page - if (!form.value.plan_id && plans.value.length > 0) { - form.value.plan_id = plans.value[0]?.id ?? '' + if (!activePlans.value.some((plan) => plan.id === form.value.plan_id)) { + form.value.plan_id = activePlans.value[0]?.id ?? '' } } catch (err) { error.value = errorText(err, '加载兑换码失败') @@ -69,12 +99,30 @@ async function load(targetPage = page.value) { } } +async function applyFilters() { + activeFilters.value = { + status: filterForm.value.status || undefined, + benefit_kind: filterForm.value.benefit_kind || undefined, + plan_id: + filterForm.value.benefit_kind === 'units' ? undefined : filterForm.value.plan_id || undefined, + keyword: filterForm.value.keyword.trim() || undefined, + } + await load(1) +} + +async function resetFilters() { + filterForm.value = { keyword: '', status: '', benefit_kind: '', plan_id: '' } + activeFilters.value = {} + await load(1) +} + async function generate() { if (!auth.token) return generating.value = true generated.value = [] generateMessage.value = null generateError.value = null + generatedCopyMessage.value = null copyMessage.value = null try { const payload: { @@ -106,13 +154,48 @@ async function generate() { } } +async function copyText(text: string, successMessage: string) { + try { + await navigator.clipboard.writeText(text) + return successMessage + } catch { + return '自动复制失败,请手动选择文本' + } +} + async function copyGenerated() { if (!generatedText.value) return - try { - await navigator.clipboard.writeText(generatedText.value) - copyMessage.value = '已复制全部兑换码' - } catch { - copyMessage.value = '自动复制失败,请手动选择文本' + generatedCopyMessage.value = await copyText(generatedText.value, '已复制本次生成的全部兑换码') +} + +async function copyCode(item: AdminRedemptionCodeView) { + if (!item.code) return + copyMessage.value = await copyText(item.code, '兑换码已复制') + copiedId.value = item.id + window.setTimeout(() => { + if (copiedId.value === item.id) copiedId.value = null + }, 1500) +} + +async function copySelected() { + const selected = new Set(selectedIds.value) + const fullCodes = codes.value + .filter((item) => selected.has(item.id) && item.code) + .map((item) => item.code as string) + if (fullCodes.length === 0) { + copyMessage.value = '所选记录均为无法恢复完整内容的历史兑换码' + return + } + copyMessage.value = await copyText( + fullCodes.join('\n'), + `已复制 ${fullCodes.length} 个兑换码${fullCodes.length < selectedCount.value ? ',历史码已跳过' : ''}`, + ) +} + +async function reloadAfterMutation() { + await load(page.value) + if (codes.value.length === 0 && page.value > 1) { + await load(page.value - 1) } } @@ -120,9 +203,11 @@ async function toggleCode(item: AdminRedemptionCodeView) { if (!auth.token || item.redeemed_at) return updatingId.value = item.id error.value = null + actionMessage.value = null try { - await updateAdminRedemptionCode(auth.token, item.id, !item.is_active) - item.is_active = !item.is_active + const resp = await updateAdminRedemptionCode(auth.token, item.id, !item.is_active) + actionMessage.value = resp.message + await reloadAfterMutation() } catch (err) { error.value = errorText(err, '更新兑换码失败') } finally { @@ -130,6 +215,50 @@ async function toggleCode(item: AdminRedemptionCodeView) { } } +async function deleteCode(item: AdminRedemptionCodeView) { + if (!auth.token || item.redeemed_at) return + const displayCode = item.code ?? item.code_hint + if (!window.confirm(`确定删除兑换码 ${displayCode} 吗?删除后无法恢复。`)) return + deletingId.value = item.id + error.value = null + actionMessage.value = null + try { + const resp = await deleteAdminRedemptionCode(auth.token, item.id) + actionMessage.value = resp.message + await reloadAfterMutation() + } catch (err) { + error.value = errorText(err, '删除兑换码失败') + } finally { + deletingId.value = null + } +} + +async function runBatch(action: AdminRedemptionBatchAction) { + if (!auth.token || selectedIds.value.length === 0) return + if ( + action === 'delete' && + !window.confirm(`确定批量删除选中的 ${selectedIds.value.length} 条记录吗?已兑换记录会自动跳过。`) + ) { + return + } + batchBusy.value = true + error.value = null + actionMessage.value = null + try { + const resp = await batchUpdateAdminRedemptionCodes(auth.token, selectedIds.value, action) + actionMessage.value = resp.message + await reloadAfterMutation() + } catch (err) { + error.value = errorText(err, '批量操作失败') + } finally { + batchBusy.value = false + } +} + +function togglePageSelection() { + selectedIds.value = allPageSelected.value ? [] : codes.value.map((item) => item.id) +} + function statusLabel(item: AdminRedemptionCodeView) { if (item.redeemed_at) return '已兑换' if (!item.is_active) return '已停用' @@ -137,6 +266,14 @@ function statusLabel(item: AdminRedemptionCodeView) { return '待兑换' } +function statusClass(item: AdminRedemptionCodeView) { + const status = statusLabel(item) + if (status === '待兑换') return 'bg-emerald-50 text-emerald-700' + if (status === '已兑换') return 'bg-sky-50 text-sky-700' + if (status === '已过期') return 'bg-amber-50 text-amber-700' + return 'bg-slate-100 text-slate-600' +} + function benefitLabel(item: AdminRedemptionCodeView) { return item.benefit_kind === 'plan' ? item.plan_name ?? '未知套餐' : `${item.units ?? 0} 次` } @@ -148,12 +285,15 @@ onMounted(() => load())

兑换码

-

生成套餐卡或限时次数卡。完整兑换码只在生成后显示一次。

+

新兑换码会加密保存,可随时查看、复制、筛选和批量管理。

{{ error }}
+
+ {{ actionMessage }} +
生成兑换码
@@ -168,7 +308,7 @@ onMounted(() => load())