This commit is contained in:
28
docs/api.md
28
docs/api.md
@@ -739,10 +739,12 @@ Authorization: Bearer <admin_token>
|
|||||||
### 11.7 兑换码管理
|
### 11.7 兑换码管理
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /admin/redemption-codes?page=1&limit=50
|
GET /admin/redemption-codes?page=1&limit=50&status=available&benefit_kind=plan&plan_id=<uuid>&keyword=活动
|
||||||
Authorization: Bearer <admin_token>
|
Authorization: Bearer <admin_token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
筛选参数均可省略:`status` 支持 `available/redeemed/disabled/expired`,`benefit_kind` 支持 `plan/units`,`keyword` 可搜索完整兑换码(精确匹配)、脱敏标识、备注和兑换人。列表响应使用 `Cache-Control: no-store`。
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /admin/redemption-codes
|
POST /admin/redemption-codes
|
||||||
Authorization: Bearer <admin_token>
|
Authorization: Bearer <admin_token>
|
||||||
@@ -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
|
```http
|
||||||
PUT /admin/redemption-codes/{code_id}
|
PUT /admin/redemption-codes/{code_id}
|
||||||
@@ -768,6 +770,28 @@ Content-Type: application/json
|
|||||||
{ "is_active": false }
|
{ "is_active": false }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
已兑换的兑换码保留为权益和审计凭据,不能再修改状态或删除。未兑换兑换码支持单独删除:
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /admin/redemption-codes/{code_id}
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
批量启用、停用或删除最多支持 200 个 ID;已兑换记录自动跳过,响应会返回处理、跳过和未找到数量:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /admin/redemption-codes/batch
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"ids": ["<code_id_1>", "<code_id_2>"],
|
||||||
|
"action": "disable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` 支持 `enable/disable/delete`,所有单条和批量操作都会写入管理员审计日志。
|
||||||
|
|
||||||
### 11.8 邮箱验证开关
|
### 11.8 邮箱验证开关
|
||||||
|
|
||||||
`GET /admin/auth` 和 `PUT /admin/auth` 的配置体包含 `email_verification_required`。该配置独立于 SMTP,修改后立即生效,不需要重启服务。
|
`GET /admin/auth` 和 `PUT /admin/auth` 的配置体包含 `email_verification_required`。该配置独立于 SMTP,修改后立即生效,不需要重启服务。
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
batchUpdateAdminRedemptionCodes,
|
||||||
createAdminRedemptionCodes,
|
createAdminRedemptionCodes,
|
||||||
|
deleteAdminRedemptionCode,
|
||||||
listAdminPlans,
|
listAdminPlans,
|
||||||
listAdminRedemptionCodes,
|
listAdminRedemptionCodes,
|
||||||
updateAdminRedemptionCode,
|
updateAdminRedemptionCode,
|
||||||
type AdminPlanView,
|
type AdminPlanView,
|
||||||
|
type AdminRedemptionBatchAction,
|
||||||
|
type AdminRedemptionCodeFilters,
|
||||||
|
type AdminRedemptionCodeStatus,
|
||||||
type AdminRedemptionCodeView,
|
type AdminRedemptionCodeView,
|
||||||
type GeneratedRedemptionCode,
|
type GeneratedRedemptionCode,
|
||||||
type RedemptionKind,
|
type RedemptionKind,
|
||||||
@@ -17,11 +22,25 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const actionMessage = ref<string | null>(null)
|
||||||
const plans = ref<AdminPlanView[]>([])
|
const plans = ref<AdminPlanView[]>([])
|
||||||
const codes = ref<AdminRedemptionCodeView[]>([])
|
const codes = ref<AdminRedemptionCodeView[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
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<AdminRedemptionCodeFilters>({})
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
benefit_kind: 'plan' as RedemptionKind,
|
benefit_kind: 'plan' as RedemptionKind,
|
||||||
@@ -36,11 +55,21 @@ const generating = ref(false)
|
|||||||
const generated = ref<GeneratedRedemptionCode[]>([])
|
const generated = ref<GeneratedRedemptionCode[]>([])
|
||||||
const generateMessage = ref<string | null>(null)
|
const generateMessage = ref<string | null>(null)
|
||||||
const generateError = ref<string | null>(null)
|
const generateError = ref<string | null>(null)
|
||||||
|
const generatedCopyMessage = ref<string | null>(null)
|
||||||
const updatingId = ref<string | null>(null)
|
const updatingId = ref<string | null>(null)
|
||||||
|
const deletingId = ref<string | null>(null)
|
||||||
|
const batchBusy = ref(false)
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const copiedId = ref<string | null>(null)
|
||||||
const copyMessage = ref<string | null>(null)
|
const copyMessage = ref<string | null>(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 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) {
|
function errorText(err: unknown, fallback: string) {
|
||||||
return err instanceof ApiError ? `[${err.code}] ${err.message}` : fallback
|
return err instanceof ApiError ? `[${err.code}] ${err.message}` : fallback
|
||||||
@@ -50,17 +79,18 @@ async function load(targetPage = page.value) {
|
|||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
selectedIds.value = []
|
||||||
try {
|
try {
|
||||||
const [planResp, codeResp] = await Promise.all([
|
const [planResp, codeResp] = await Promise.all([
|
||||||
listAdminPlans(auth.token),
|
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
|
codes.value = codeResp.codes
|
||||||
total.value = codeResp.total
|
total.value = codeResp.total
|
||||||
page.value = codeResp.page
|
page.value = codeResp.page
|
||||||
if (!form.value.plan_id && plans.value.length > 0) {
|
if (!activePlans.value.some((plan) => plan.id === form.value.plan_id)) {
|
||||||
form.value.plan_id = plans.value[0]?.id ?? ''
|
form.value.plan_id = activePlans.value[0]?.id ?? ''
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(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() {
|
async function generate() {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
generating.value = true
|
generating.value = true
|
||||||
generated.value = []
|
generated.value = []
|
||||||
generateMessage.value = null
|
generateMessage.value = null
|
||||||
generateError.value = null
|
generateError.value = null
|
||||||
|
generatedCopyMessage.value = null
|
||||||
copyMessage.value = null
|
copyMessage.value = null
|
||||||
try {
|
try {
|
||||||
const payload: {
|
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() {
|
async function copyGenerated() {
|
||||||
if (!generatedText.value) return
|
if (!generatedText.value) return
|
||||||
try {
|
generatedCopyMessage.value = await copyText(generatedText.value, '已复制本次生成的全部兑换码')
|
||||||
await navigator.clipboard.writeText(generatedText.value)
|
}
|
||||||
copyMessage.value = '已复制全部兑换码'
|
|
||||||
} catch {
|
async function copyCode(item: AdminRedemptionCodeView) {
|
||||||
copyMessage.value = '自动复制失败,请手动选择文本'
|
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
|
if (!auth.token || item.redeemed_at) return
|
||||||
updatingId.value = item.id
|
updatingId.value = item.id
|
||||||
error.value = null
|
error.value = null
|
||||||
|
actionMessage.value = null
|
||||||
try {
|
try {
|
||||||
await updateAdminRedemptionCode(auth.token, item.id, !item.is_active)
|
const resp = await updateAdminRedemptionCode(auth.token, item.id, !item.is_active)
|
||||||
item.is_active = !item.is_active
|
actionMessage.value = resp.message
|
||||||
|
await reloadAfterMutation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err, '更新兑换码失败')
|
error.value = errorText(err, '更新兑换码失败')
|
||||||
} finally {
|
} 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) {
|
function statusLabel(item: AdminRedemptionCodeView) {
|
||||||
if (item.redeemed_at) return '已兑换'
|
if (item.redeemed_at) return '已兑换'
|
||||||
if (!item.is_active) return '已停用'
|
if (!item.is_active) return '已停用'
|
||||||
@@ -137,6 +266,14 @@ function statusLabel(item: AdminRedemptionCodeView) {
|
|||||||
return '待兑换'
|
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) {
|
function benefitLabel(item: AdminRedemptionCodeView) {
|
||||||
return item.benefit_kind === 'plan' ? item.plan_name ?? '未知套餐' : `${item.units ?? 0} 次`
|
return item.benefit_kind === 'plan' ? item.plan_name ?? '未知套餐' : `${item.units ?? 0} 次`
|
||||||
}
|
}
|
||||||
@@ -148,12 +285,15 @@ onMounted(() => load())
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">兑换码</h2>
|
<h2 class="text-lg font-semibold text-slate-900">兑换码</h2>
|
||||||
<p class="text-sm text-slate-600">生成套餐卡或限时次数卡。完整兑换码只在生成后显示一次。</p>
|
<p class="text-sm text-slate-600">新兑换码会加密保存,可随时查看、复制、筛选和批量管理。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="actionMessage" class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
|
||||||
|
{{ actionMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<div class="text-sm font-medium text-slate-900">生成兑换码</div>
|
<div class="text-sm font-medium text-slate-900">生成兑换码</div>
|
||||||
@@ -168,7 +308,7 @@ onMounted(() => load())
|
|||||||
<label v-if="form.benefit_kind === 'plan'" class="space-y-1">
|
<label v-if="form.benefit_kind === 'plan'" class="space-y-1">
|
||||||
<div class="text-xs font-medium text-slate-600">对应套餐</div>
|
<div class="text-xs font-medium text-slate-600">对应套餐</div>
|
||||||
<select v-model="form.plan_id" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" required>
|
<select v-model="form.plan_id" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" required>
|
||||||
<option v-for="plan in plans" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
<option v-for="plan in activePlans" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-else class="space-y-1">
|
<label v-else class="space-y-1">
|
||||||
@@ -197,57 +337,145 @@ onMounted(() => load())
|
|||||||
</button>
|
</button>
|
||||||
<div v-if="generateError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ generateError }}</div>
|
<div v-if="generateError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ generateError }}</div>
|
||||||
|
|
||||||
<div v-if="generated.length > 0" class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
<div v-if="generated.length > 0" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-amber-950">{{ generateMessage }}</div>
|
<div class="text-sm font-medium text-emerald-950">{{ generateMessage }}</div>
|
||||||
<div class="text-xs text-amber-800">离开或刷新页面后无法再次查看完整兑换码。</div>
|
<div class="text-xs text-emerald-800">下方便于本次批量复制,之后仍可在兑换码记录中查看。</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs text-amber-900" @click="copyGenerated">复制全部</button>
|
<button type="button" class="rounded-md border border-emerald-300 bg-white px-3 py-1.5 text-xs text-emerald-900" @click="copyGenerated">复制全部</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea :value="generatedText" readonly class="mt-3 h-36 w-full rounded-md border border-amber-200 bg-white px-3 py-2 font-mono text-sm text-slate-800"></textarea>
|
<textarea :value="generatedText" readonly class="mt-3 h-36 w-full rounded-md border border-emerald-200 bg-white px-3 py-2 font-mono text-sm text-slate-800"></textarea>
|
||||||
<div v-if="copyMessage" class="mt-2 text-xs text-amber-900">{{ copyMessage }}</div>
|
<div v-if="generatedCopyMessage" class="mt-2 text-xs text-emerald-900">{{ generatedCopyMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="text-sm font-medium text-slate-900">兑换码记录({{ total }})</div>
|
<div>
|
||||||
|
<div class="text-sm font-medium text-slate-900">兑换码记录({{ total }})</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">旧版本生成的兑换码没有保存完整内容,只能继续显示脱敏标识。</div>
|
||||||
|
</div>
|
||||||
<button type="button" class="text-xs text-indigo-600 hover:text-indigo-700" @click="load(page)">刷新</button>
|
<button type="button" class="text-xs text-indigo-600 hover:text-indigo-700" @click="load(page)">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 grid grid-cols-1 gap-3 rounded-lg bg-slate-50 p-4 md:grid-cols-5" @submit.prevent="applyFilters">
|
||||||
|
<label class="space-y-1 md:col-span-2">
|
||||||
|
<div class="text-xs font-medium text-slate-600">关键词</div>
|
||||||
|
<input v-model="filterForm.keyword" type="search" maxlength="100" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" placeholder="完整码、标识、备注或兑换人" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">状态</div>
|
||||||
|
<select v-model="filterForm.status" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="available">待兑换</option>
|
||||||
|
<option value="redeemed">已兑换</option>
|
||||||
|
<option value="disabled">已停用</option>
|
||||||
|
<option value="expired">已过期</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">类型</div>
|
||||||
|
<select v-model="filterForm.benefit_kind" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
<option value="plan">套餐卡</option>
|
||||||
|
<option value="units">次数卡</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">套餐</div>
|
||||||
|
<select v-model="filterForm.plan_id" :disabled="filterForm.benefit_kind === 'units'" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm disabled:bg-slate-100">
|
||||||
|
<option value="">全部套餐</option>
|
||||||
|
<option v-for="plan in plans" :key="plan.id" :value="plan.id">{{ plan.name }}{{ plan.is_active ? '' : '(已停用)' }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-end gap-2 md:col-span-5">
|
||||||
|
<button type="submit" class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">查询</button>
|
||||||
|
<button type="button" class="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700" @click="resetFilters">重置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="selectedCount > 0" class="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2">
|
||||||
|
<span class="mr-2 text-xs font-medium text-indigo-900">已选择 {{ selectedCount }} 条</span>
|
||||||
|
<button type="button" class="rounded bg-white px-3 py-1.5 text-xs text-indigo-700" :disabled="batchBusy" @click="copySelected">复制完整码</button>
|
||||||
|
<button type="button" class="rounded bg-white px-3 py-1.5 text-xs text-emerald-700 disabled:opacity-50" :disabled="batchBusy" @click="runBatch('enable')">批量启用</button>
|
||||||
|
<button type="button" class="rounded bg-white px-3 py-1.5 text-xs text-amber-700 disabled:opacity-50" :disabled="batchBusy" @click="runBatch('disable')">批量停用</button>
|
||||||
|
<button type="button" class="rounded bg-white px-3 py-1.5 text-xs text-rose-700 disabled:opacity-50" :disabled="batchBusy" @click="runBatch('delete')">批量删除</button>
|
||||||
|
<button type="button" class="ml-auto text-xs text-indigo-600" :disabled="batchBusy" @click="selectedIds = []">取消选择</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="copyMessage" class="mt-3 text-xs text-slate-600">{{ copyMessage }}</div>
|
||||||
|
|
||||||
<div v-if="loading" class="mt-4 text-sm text-slate-600">加载中…</div>
|
<div v-if="loading" class="mt-4 text-sm text-slate-600">加载中…</div>
|
||||||
<div v-else-if="codes.length === 0" class="mt-4 text-sm text-slate-600">暂无兑换码</div>
|
<div v-else-if="codes.length === 0" class="mt-4 text-sm text-slate-600">没有符合条件的兑换码</div>
|
||||||
<div v-else class="mt-4 overflow-auto">
|
<div v-else class="mt-4 overflow-auto">
|
||||||
<table class="min-w-full text-left text-sm">
|
<table class="min-w-[1180px] text-left text-sm">
|
||||||
<thead class="text-xs text-slate-500">
|
<thead class="text-xs text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="py-2 pr-4">标识</th><th class="py-2 pr-4">权益</th><th class="py-2 pr-4">时长</th>
|
<th class="w-10 py-2 pr-3">
|
||||||
<th class="py-2 pr-4">状态</th><th class="py-2 pr-4">兑换人</th><th class="py-2 pr-4">截止时间</th>
|
<input type="checkbox" :checked="allPageSelected" aria-label="选择本页兑换码" @change="togglePageSelection" />
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-4">兑换码</th>
|
||||||
|
<th class="py-2 pr-4">权益</th>
|
||||||
|
<th class="py-2 pr-4">时长</th>
|
||||||
|
<th class="py-2 pr-4">状态</th>
|
||||||
|
<th class="py-2 pr-4">兑换人</th>
|
||||||
|
<th class="py-2 pr-4">生成时间</th>
|
||||||
|
<th class="py-2 pr-4">截止时间</th>
|
||||||
|
<th class="py-2 pr-4">备注</th>
|
||||||
<th class="py-2 pr-4">操作</th>
|
<th class="py-2 pr-4">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-slate-700">
|
<tbody class="text-slate-700">
|
||||||
<tr v-for="item in codes" :key="item.id" class="border-t border-slate-100">
|
<tr v-for="item in codes" :key="item.id" class="border-t border-slate-100 align-top">
|
||||||
<td class="py-2 pr-4 font-mono text-xs">{{ item.code_hint }}</td>
|
<td class="py-3 pr-3">
|
||||||
<td class="py-2 pr-4">{{ benefitLabel(item) }}</td>
|
<input v-model="selectedIds" type="checkbox" :value="item.id" :aria-label="`选择 ${item.code ?? item.code_hint}`" />
|
||||||
<td class="py-2 pr-4">{{ item.duration_days }} 天</td>
|
</td>
|
||||||
<td class="py-2 pr-4">{{ statusLabel(item) }}</td>
|
<td class="py-3 pr-4">
|
||||||
<td class="py-2 pr-4">{{ item.redeemed_username ?? '—' }}</td>
|
<div class="flex items-center gap-2">
|
||||||
<td class="py-2 pr-4">{{ item.redeem_before ? new Date(item.redeem_before).toLocaleString() : '不限' }}</td>
|
<code class="whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-xs text-slate-800">{{ item.code ?? item.code_hint }}</code>
|
||||||
<td class="py-2 pr-4">
|
<button v-if="item.code" type="button" class="text-xs text-indigo-600" @click="copyCode(item)">{{ copiedId === item.id ? '已复制' : '复制' }}</button>
|
||||||
<button v-if="!item.redeemed_at" type="button" class="text-xs text-indigo-600 disabled:opacity-50" :disabled="updatingId === item.id" @click="toggleCode(item)">
|
</div>
|
||||||
{{ item.is_active ? '停用' : '启用' }}
|
<div v-if="!item.code" class="mt-1 text-[11px] text-amber-600">历史码无法恢复完整内容</div>
|
||||||
</button>
|
</td>
|
||||||
<span v-else class="text-xs text-slate-400">不可修改</span>
|
<td class="py-3 pr-4">{{ benefitLabel(item) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ item.duration_days }} 天</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<span class="rounded-full px-2 py-1 text-xs" :class="statusClass(item)">{{ statusLabel(item) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">{{ item.redeemed_username ?? '—' }}</td>
|
||||||
|
<td class="whitespace-nowrap py-3 pr-4 text-xs">{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||||
|
<td class="whitespace-nowrap py-3 pr-4 text-xs">{{ item.redeem_before ? new Date(item.redeem_before).toLocaleString() : '不限' }}</td>
|
||||||
|
<td class="max-w-48 py-3 pr-4 text-xs text-slate-500">{{ item.note ?? '—' }}</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<div v-if="!item.redeemed_at" class="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<button type="button" class="text-xs text-indigo-600 disabled:opacity-50" :disabled="updatingId === item.id || deletingId === item.id" @click="toggleCode(item)">
|
||||||
|
{{ item.is_active ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-xs text-rose-600 disabled:opacity-50" :disabled="updatingId === item.id || deletingId === item.id" @click="deleteCode(item)">
|
||||||
|
{{ deletingId === item.id ? '删除中…' : '删除' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-xs text-slate-400">保留记录</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-500">
|
||||||
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page <= 1 || loading" @click="load(page - 1)">上一页</button>
|
<div class="flex items-center gap-2">
|
||||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
<span>每页</span>
|
||||||
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page >= totalPages || loading" @click="load(page + 1)">下一页</button>
|
<select v-model.number="limit" class="rounded border border-slate-200 bg-white px-2 py-1" @change="load(1)">
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span>条</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page <= 1 || loading" @click="load(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page >= totalPages || loading" @click="load(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ export async function updateAuthConfig(
|
|||||||
|
|
||||||
export interface AdminRedemptionCodeView {
|
export interface AdminRedemptionCodeView {
|
||||||
id: string
|
id: string
|
||||||
|
code?: string | null
|
||||||
code_hint: string
|
code_hint: string
|
||||||
benefit_kind: RedemptionKind
|
benefit_kind: RedemptionKind
|
||||||
plan_id?: string | null
|
plan_id?: string | null
|
||||||
@@ -596,6 +597,15 @@ export interface AdminRedemptionCodeView {
|
|||||||
redeemed_username?: string | null
|
redeemed_username?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminRedemptionCodeStatus = 'available' | 'redeemed' | 'disabled' | 'expired'
|
||||||
|
|
||||||
|
export interface AdminRedemptionCodeFilters {
|
||||||
|
status?: AdminRedemptionCodeStatus
|
||||||
|
benefit_kind?: RedemptionKind
|
||||||
|
plan_id?: string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GeneratedRedemptionCode {
|
export interface GeneratedRedemptionCode {
|
||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
@@ -606,8 +616,14 @@ export async function listAdminRedemptionCodes(
|
|||||||
token: string,
|
token: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
|
filters: AdminRedemptionCodeFilters = {},
|
||||||
): Promise<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }> {
|
): Promise<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }> {
|
||||||
const qs = new URLSearchParams({ page: String(page), limit: String(limit) }).toString()
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) })
|
||||||
|
if (filters.status) params.set('status', filters.status)
|
||||||
|
if (filters.benefit_kind) params.set('benefit_kind', filters.benefit_kind)
|
||||||
|
if (filters.plan_id) params.set('plan_id', filters.plan_id)
|
||||||
|
if (filters.keyword?.trim()) params.set('keyword', filters.keyword.trim())
|
||||||
|
const qs = params.toString()
|
||||||
return apiGet<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }>(
|
return apiGet<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }>(
|
||||||
`/api/v1/admin/redemption-codes?${qs}`,
|
`/api/v1/admin/redemption-codes?${qs}`,
|
||||||
token,
|
token,
|
||||||
@@ -646,6 +662,39 @@ export async function updateAdminRedemptionCode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminRedemptionCode(token: string, codeId: string): Promise<{ message: string }> {
|
||||||
|
return apiJson<{ message: string }>(
|
||||||
|
`/api/v1/admin/redemption-codes/${codeId}`,
|
||||||
|
undefined,
|
||||||
|
token,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminRedemptionBatchAction = 'enable' | 'disable' | 'delete'
|
||||||
|
|
||||||
|
export interface AdminRedemptionBatchResponse {
|
||||||
|
message: string
|
||||||
|
action: AdminRedemptionBatchAction
|
||||||
|
requested: number
|
||||||
|
matched: number
|
||||||
|
affected: number
|
||||||
|
skipped_redeemed: number
|
||||||
|
not_found: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchUpdateAdminRedemptionCodes(
|
||||||
|
token: string,
|
||||||
|
ids: string[],
|
||||||
|
action: AdminRedemptionBatchAction,
|
||||||
|
): Promise<AdminRedemptionBatchResponse> {
|
||||||
|
return apiJson<AdminRedemptionBatchResponse>(
|
||||||
|
'/api/v1/admin/redemption-codes/batch',
|
||||||
|
{ ids, action },
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStorageEndpoint {
|
export interface AdminStorageEndpoint {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
9
migrations/011_redemption_code_management.sql
Normal file
9
migrations/011_redemption_code_management.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE redemption_codes
|
||||||
|
ADD COLUMN IF NOT EXISTS code_encrypted TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemption_codes_benefit_created
|
||||||
|
ON redemption_codes(benefit_kind, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemption_codes_plan_created
|
||||||
|
ON redemption_codes(plan_id, created_at DESC)
|
||||||
|
WHERE plan_id IS NOT NULL;
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
use crate::api::envelope::Envelope;
|
use crate::api::envelope::Envelope;
|
||||||
use crate::api::{admin, context};
|
use crate::api::{admin, context};
|
||||||
use crate::error::{AppError, ErrorCode};
|
use crate::error::{AppError, ErrorCode};
|
||||||
|
use crate::services::settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::{header::CACHE_CONTROL, HeaderMap};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::{get, post, put};
|
use axum::routing::{get, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, Postgres, Transaction};
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -25,18 +28,47 @@ pub fn router() -> Router<AppState> {
|
|||||||
"/admin/redemption-codes",
|
"/admin/redemption-codes",
|
||||||
get(list_admin_codes).post(create_codes),
|
get(list_admin_codes).post(create_codes),
|
||||||
)
|
)
|
||||||
.route("/admin/redemption-codes/{code_id}", put(update_code_status))
|
.route("/admin/redemption-codes/batch", post(batch_update_codes))
|
||||||
|
.route(
|
||||||
|
"/admin/redemption-codes/{code_id}",
|
||||||
|
put(update_code_status).delete(delete_code),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PagingQuery {
|
struct AdminCodesQuery {
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
|
status: Option<String>,
|
||||||
|
benefit_kind: Option<String>,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
keyword: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow, Serialize)]
|
#[derive(Debug, FromRow)]
|
||||||
|
struct AdminCodeRow {
|
||||||
|
id: Uuid,
|
||||||
|
code_encrypted: Option<String>,
|
||||||
|
code_hash: String,
|
||||||
|
code_hint: String,
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
plan_name: Option<String>,
|
||||||
|
units: Option<i32>,
|
||||||
|
duration_days: i32,
|
||||||
|
redeem_before: Option<DateTime<Utc>>,
|
||||||
|
is_active: bool,
|
||||||
|
note: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
redeemed_at: Option<DateTime<Utc>>,
|
||||||
|
redeemed_by: Option<Uuid>,
|
||||||
|
redeemed_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
struct AdminCodeView {
|
struct AdminCodeView {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
code: Option<String>,
|
||||||
code_hint: String,
|
code_hint: String,
|
||||||
benefit_kind: String,
|
benefit_kind: String,
|
||||||
plan_id: Option<Uuid>,
|
plan_id: Option<Uuid>,
|
||||||
@@ -64,24 +96,67 @@ async fn list_admin_codes(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: axum_extra::extract::cookie::CookieJar,
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
request_headers: HeaderMap,
|
||||||
Query(query): Query<PagingQuery>,
|
Query(query): Query<AdminCodesQuery>,
|
||||||
) -> Result<Json<Envelope<AdminCodesResponse>>, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let ip = context::client_ip(&headers, addr.ip());
|
let ip = context::client_ip(&request_headers, addr.ip());
|
||||||
let (_jar, _admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
let (_jar, _admin_id) = admin::require_admin(&state, jar, &request_headers, ip).await?;
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
let status = normalize_admin_status(query.status.as_deref())?;
|
||||||
|
let benefit_kind = normalize_benefit_filter(query.benefit_kind.as_deref())?;
|
||||||
|
let keyword = normalize_admin_keyword(query.keyword.as_deref())?;
|
||||||
|
let keyword_pattern = keyword.as_deref().map(contains_pattern);
|
||||||
|
let code_hash = match keyword.as_deref().and_then(normalize_code) {
|
||||||
|
Some(compact) => Some(context::api_key_hash(
|
||||||
|
&compact,
|
||||||
|
&state.config.api_key_pepper,
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM redemption_codes")
|
let total: i64 = sqlx::query_scalar(
|
||||||
.fetch_one(&state.db)
|
r#"
|
||||||
.await
|
SELECT COUNT(*)
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
FROM redemption_codes c
|
||||||
|
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||||
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
WHERE (
|
||||||
|
$1::text IS NULL
|
||||||
|
OR ($1 = 'available' AND r.id IS NULL AND c.is_active = true
|
||||||
|
AND (c.redeem_before IS NULL OR c.redeem_before > NOW()))
|
||||||
|
OR ($1 = 'redeemed' AND r.id IS NOT NULL)
|
||||||
|
OR ($1 = 'disabled' AND r.id IS NULL AND c.is_active = false)
|
||||||
|
OR ($1 = 'expired' AND r.id IS NULL AND c.is_active = true
|
||||||
|
AND c.redeem_before <= NOW())
|
||||||
|
)
|
||||||
|
AND ($2::text IS NULL OR c.benefit_kind = $2)
|
||||||
|
AND ($3::uuid IS NULL OR c.plan_id = $3)
|
||||||
|
AND (
|
||||||
|
$4::text IS NULL
|
||||||
|
OR c.code_hint ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR COALESCE(c.note, '') ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR COALESCE(u.username, '') ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR c.code_hash = $5
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(benefit_kind.as_deref())
|
||||||
|
.bind(query.plan_id)
|
||||||
|
.bind(keyword_pattern.as_deref())
|
||||||
|
.bind(code_hash.as_deref())
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
let codes = sqlx::query_as::<_, AdminCodeView>(
|
let rows = sqlx::query_as::<_, AdminCodeRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
c.id,
|
||||||
|
c.code_encrypted,
|
||||||
|
c.code_hash,
|
||||||
c.code_hint,
|
c.code_hint,
|
||||||
c.benefit_kind,
|
c.benefit_kind,
|
||||||
c.plan_id,
|
c.plan_id,
|
||||||
@@ -99,25 +174,145 @@ async fn list_admin_codes(
|
|||||||
LEFT JOIN plans p ON p.id = c.plan_id
|
LEFT JOIN plans p ON p.id = c.plan_id
|
||||||
LEFT JOIN redemption_records r ON r.code_id = c.id
|
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
WHERE (
|
||||||
|
$1::text IS NULL
|
||||||
|
OR ($1 = 'available' AND r.id IS NULL AND c.is_active = true
|
||||||
|
AND (c.redeem_before IS NULL OR c.redeem_before > NOW()))
|
||||||
|
OR ($1 = 'redeemed' AND r.id IS NOT NULL)
|
||||||
|
OR ($1 = 'disabled' AND r.id IS NULL AND c.is_active = false)
|
||||||
|
OR ($1 = 'expired' AND r.id IS NULL AND c.is_active = true
|
||||||
|
AND c.redeem_before <= NOW())
|
||||||
|
)
|
||||||
|
AND ($2::text IS NULL OR c.benefit_kind = $2)
|
||||||
|
AND ($3::uuid IS NULL OR c.plan_id = $3)
|
||||||
|
AND (
|
||||||
|
$4::text IS NULL
|
||||||
|
OR c.code_hint ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR COALESCE(c.note, '') ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR COALESCE(u.username, '') ILIKE $4 ESCAPE E'\\'
|
||||||
|
OR c.code_hash = $5
|
||||||
|
)
|
||||||
ORDER BY c.created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
LIMIT $1 OFFSET $2
|
LIMIT $6 OFFSET $7
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(benefit_kind.as_deref())
|
||||||
|
.bind(query.plan_id)
|
||||||
|
.bind(keyword_pattern.as_deref())
|
||||||
|
.bind(code_hash.as_deref())
|
||||||
.bind(i64::from(limit))
|
.bind(i64::from(limit))
|
||||||
.bind(i64::from(offset))
|
.bind(i64::from(offset))
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
let codes = rows
|
||||||
success: true,
|
.into_iter()
|
||||||
data: AdminCodesResponse {
|
.map(|row| admin_code_view(&state, row))
|
||||||
codes,
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
page,
|
|
||||||
limit,
|
Ok((
|
||||||
total,
|
[(CACHE_CONTROL, "no-store")],
|
||||||
},
|
Json(Envelope {
|
||||||
}))
|
success: true,
|
||||||
|
data: AdminCodesResponse {
|
||||||
|
codes,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_admin_status(input: Option<&str>) -> Result<Option<&'static str>, AppError> {
|
||||||
|
match input
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"" | "all" => Ok(None),
|
||||||
|
"available" => Ok(Some("available")),
|
||||||
|
"redeemed" => Ok(Some("redeemed")),
|
||||||
|
"disabled" => Ok(Some("disabled")),
|
||||||
|
"expired" => Ok(Some("expired")),
|
||||||
|
_ => Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"status 仅支持 available/redeemed/disabled/expired",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_benefit_filter(input: Option<&str>) -> Result<Option<String>, AppError> {
|
||||||
|
let value = input.map(str::trim).unwrap_or("").to_ascii_lowercase();
|
||||||
|
match value.as_str() {
|
||||||
|
"" | "all" => Ok(None),
|
||||||
|
"plan" | "units" => Ok(Some(value)),
|
||||||
|
_ => Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"benefit_kind 仅支持 plan/units",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_admin_keyword(input: Option<&str>) -> Result<Option<String>, AppError> {
|
||||||
|
let value = input.map(str::trim).unwrap_or("");
|
||||||
|
if value.chars().count() > 100 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"keyword 不能超过 100 字",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((!value.is_empty()).then(|| value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_pattern(value: &str) -> String {
|
||||||
|
let mut escaped = String::with_capacity(value.len() + 2);
|
||||||
|
escaped.push('%');
|
||||||
|
for ch in value.chars() {
|
||||||
|
if matches!(ch, '\\' | '%' | '_') {
|
||||||
|
escaped.push('\\');
|
||||||
|
}
|
||||||
|
escaped.push(ch);
|
||||||
|
}
|
||||||
|
escaped.push('%');
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_code_view(state: &AppState, row: AdminCodeRow) -> Result<AdminCodeView, AppError> {
|
||||||
|
let code = match row.code_encrypted.as_deref() {
|
||||||
|
Some(encrypted) => {
|
||||||
|
let plain = settings::decrypt_secret(state, encrypted)?;
|
||||||
|
let compact = normalize_code(&plain)
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::Internal, "兑换码密文内容格式错误"))?;
|
||||||
|
let expected = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||||
|
if expected != row.code_hash {
|
||||||
|
return Err(AppError::new(ErrorCode::Internal, "兑换码密文与哈希不匹配"));
|
||||||
|
}
|
||||||
|
Some(plain)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AdminCodeView {
|
||||||
|
id: row.id,
|
||||||
|
code,
|
||||||
|
code_hint: row.code_hint,
|
||||||
|
benefit_kind: row.benefit_kind,
|
||||||
|
plan_id: row.plan_id,
|
||||||
|
plan_name: row.plan_name,
|
||||||
|
units: row.units,
|
||||||
|
duration_days: row.duration_days,
|
||||||
|
redeem_before: row.redeem_before,
|
||||||
|
is_active: row.is_active,
|
||||||
|
note: row.note,
|
||||||
|
created_at: row.created_at,
|
||||||
|
redeemed_at: row.redeemed_at,
|
||||||
|
redeemed_by: row.redeemed_by,
|
||||||
|
redeemed_username: row.redeemed_username,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -150,7 +345,7 @@ async fn create_codes(
|
|||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(req): Json<CreateCodesRequest>,
|
Json(req): Json<CreateCodesRequest>,
|
||||||
) -> Result<Json<Envelope<CreateCodesResponse>>, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let ip = context::client_ip(&headers, addr.ip());
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
let quantity = req.quantity.unwrap_or(1);
|
let quantity = req.quantity.unwrap_or(1);
|
||||||
@@ -242,17 +437,19 @@ async fn create_codes(
|
|||||||
let compact = normalize_code(&code)
|
let compact = normalize_code(&code)
|
||||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "生成兑换码格式失败"))?;
|
.ok_or_else(|| AppError::new(ErrorCode::Internal, "生成兑换码格式失败"))?;
|
||||||
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||||
|
let code_encrypted = settings::encrypt_secret(&state, &code)?;
|
||||||
let code_hint = code_hint(&compact);
|
let code_hint = code_hint(&compact);
|
||||||
let id: Uuid = sqlx::query_scalar(
|
let id: Uuid = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO redemption_codes (
|
INSERT INTO redemption_codes (
|
||||||
code_hash, code_hint, benefit_kind, plan_id, units,
|
code_hash, code_encrypted, code_hint, benefit_kind, plan_id, units,
|
||||||
duration_days, redeem_before, note, created_by
|
duration_days, redeem_before, note, created_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(code_hash)
|
.bind(code_hash)
|
||||||
|
.bind(code_encrypted)
|
||||||
.bind(&code_hint)
|
.bind(&code_hint)
|
||||||
.bind(&benefit_kind)
|
.bind(&benefit_kind)
|
||||||
.bind(plan_id)
|
.bind(plan_id)
|
||||||
@@ -297,13 +494,16 @@ async fn create_codes(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok((
|
||||||
success: true,
|
[(CACHE_CONTROL, "no-store")],
|
||||||
data: CreateCodesResponse {
|
Json(Envelope {
|
||||||
message: "兑换码已生成,完整码仅显示本次".to_string(),
|
success: true,
|
||||||
codes: generated,
|
data: CreateCodesResponse {
|
||||||
},
|
message: "兑换码已生成,可随时在管理列表中查看".to_string(),
|
||||||
}))
|
codes: generated,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -331,17 +531,20 @@ async fn update_code_status(
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
let updated =
|
if lock_code_and_check_redeemed(&mut tx, code_id).await? {
|
||||||
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
|
return Err(AppError::new(
|
||||||
.bind(code_id)
|
ErrorCode::InvalidRequest,
|
||||||
.bind(req.is_active)
|
"已兑换的兑换码不能修改状态",
|
||||||
.execute(&mut *tx)
|
));
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
|
|
||||||
if updated.rows_affected() == 0 {
|
|
||||||
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(code_id)
|
||||||
|
.bind(req.is_active)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||||
@@ -372,6 +575,271 @@ async fn update_code_status(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_code(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(code_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
if lock_code_and_check_redeemed(&mut tx, code_id).await? {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"已兑换的兑换码不能删除",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM redemption_codes WHERE id = $1")
|
||||||
|
.bind(code_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "删除兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||||
|
VALUES ($1, 'redemption_code_deleted', 'redemption_code', $2, '{}'::jsonb, $3::inet)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(admin_id)
|
||||||
|
.bind(code_id)
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: MessageResponse {
|
||||||
|
message: "兑换码已删除".to_string(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BatchCodesRequest {
|
||||||
|
ids: Vec<Uuid>,
|
||||||
|
action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct BatchCodesResponse {
|
||||||
|
message: String,
|
||||||
|
action: String,
|
||||||
|
requested: usize,
|
||||||
|
matched: usize,
|
||||||
|
affected: u64,
|
||||||
|
skipped_redeemed: usize,
|
||||||
|
not_found: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum BatchCodeAction {
|
||||||
|
Enable,
|
||||||
|
Disable,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BatchCodeAction {
|
||||||
|
fn parse(value: &str) -> Result<Self, AppError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"enable" => Ok(Self::Enable),
|
||||||
|
"disable" => Ok(Self::Disable),
|
||||||
|
"delete" => Ok(Self::Delete),
|
||||||
|
_ => Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"action 仅支持 enable/disable/delete",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Enable => "enable",
|
||||||
|
Self::Disable => "disable",
|
||||||
|
Self::Delete => "delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Enable => "启用",
|
||||||
|
Self::Disable => "停用",
|
||||||
|
Self::Delete => "删除",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn batch_update_codes(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<BatchCodesRequest>,
|
||||||
|
) -> Result<Json<Envelope<BatchCodesResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
if req.ids.is_empty() || req.ids.len() > 200 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"ids 数量需在 1-200 之间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let action = BatchCodeAction::parse(&req.action)?;
|
||||||
|
let mut ids = req.ids;
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
let requested = ids.len();
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
let existing = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
SELECT id
|
||||||
|
FROM redemption_codes
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
ORDER BY id
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&ids)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换码失败").with_source(err))?;
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let redeemed_ids = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
"SELECT code_id FROM redemption_records WHERE code_id = ANY($1::uuid[])",
|
||||||
|
)
|
||||||
|
.bind(&existing)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换状态失败").with_source(err))?;
|
||||||
|
let redeemed = redeemed_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
let eligible = existing
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| !redeemed.contains(id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let affected = if eligible.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
match action {
|
||||||
|
BatchCodeAction::Enable | BatchCodeAction::Disable => sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE redemption_codes
|
||||||
|
SET is_active = $2, updated_at = NOW()
|
||||||
|
WHERE id = ANY($1::uuid[])
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&eligible)
|
||||||
|
.bind(matches!(action, BatchCodeAction::Enable))
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "批量更新兑换码失败").with_source(err)
|
||||||
|
})?
|
||||||
|
.rows_affected(),
|
||||||
|
BatchCodeAction::Delete => {
|
||||||
|
sqlx::query("DELETE FROM redemption_codes WHERE id = ANY($1::uuid[])")
|
||||||
|
.bind(&eligible)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "批量删除兑换码失败").with_source(err)
|
||||||
|
})?
|
||||||
|
.rows_affected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let skipped_redeemed = redeemed_ids.len();
|
||||||
|
let not_found = requested.saturating_sub(existing.len());
|
||||||
|
let message = format!(
|
||||||
|
"批量{}完成:处理 {} 个,跳过已兑换 {} 个,未找到 {} 个",
|
||||||
|
action.label(),
|
||||||
|
affected,
|
||||||
|
skipped_redeemed,
|
||||||
|
not_found
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (user_id, action, resource_type, details, ip_address)
|
||||||
|
VALUES ($1, 'redemption_codes_batch', 'redemption_code', $2, $3::inet)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(admin_id)
|
||||||
|
.bind(serde_json::json!({
|
||||||
|
"action": action.as_str(),
|
||||||
|
"code_ids": existing,
|
||||||
|
"requested": requested,
|
||||||
|
"affected": affected,
|
||||||
|
"skipped_redeemed": skipped_redeemed,
|
||||||
|
"not_found": not_found,
|
||||||
|
}))
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: BatchCodesResponse {
|
||||||
|
message,
|
||||||
|
action: action.as_str().to_string(),
|
||||||
|
requested,
|
||||||
|
matched: existing.len(),
|
||||||
|
affected,
|
||||||
|
skipped_redeemed,
|
||||||
|
not_found,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn lock_code_and_check_redeemed(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
code_id: Uuid,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let existing =
|
||||||
|
sqlx::query_scalar::<_, Uuid>("SELECT id FROM redemption_codes WHERE id = $1 FOR UPDATE")
|
||||||
|
.bind(code_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换码失败").with_source(err))?;
|
||||||
|
if existing.is_none() {
|
||||||
|
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM redemption_records WHERE code_id = $1)")
|
||||||
|
.bind(code_id)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换状态失败").with_source(err))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RedeemCodeRequest {
|
struct RedeemCodeRequest {
|
||||||
code: String,
|
code: String,
|
||||||
@@ -880,4 +1348,41 @@ mod tests {
|
|||||||
fn code_hint_only_exposes_the_last_group() {
|
fn code_hint_only_exposes_the_last_group() {
|
||||||
assert_eq!(code_hint("IMGABCDEFGHJKMNPQRS"), "IMG-...-PQRS");
|
assert_eq!(code_hint("IMGABCDEFGHJKMNPQRS"), "IMG-...-PQRS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_filters_are_normalized_and_validated() {
|
||||||
|
assert_eq!(normalize_admin_status(Some(" ALL ")).unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_admin_status(Some("Available")).unwrap(),
|
||||||
|
Some("available")
|
||||||
|
);
|
||||||
|
assert!(normalize_admin_status(Some("unknown")).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
normalize_benefit_filter(Some(" PLAN ")).unwrap(),
|
||||||
|
Some("plan".to_string())
|
||||||
|
);
|
||||||
|
assert!(normalize_benefit_filter(Some("gift")).is_err());
|
||||||
|
assert!(normalize_admin_keyword(Some(&"字".repeat(101))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyword_pattern_treats_sql_wildcards_as_literals() {
|
||||||
|
assert_eq!(
|
||||||
|
contains_pattern(r"sale_100%\batch"),
|
||||||
|
r"%sale\_100\%\\batch%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_actions_only_accept_supported_operations() {
|
||||||
|
assert!(matches!(
|
||||||
|
BatchCodeAction::parse(" ENABLE ").unwrap(),
|
||||||
|
BatchCodeAction::Enable
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BatchCodeAction::parse("delete").unwrap(),
|
||||||
|
BatchCodeAction::Delete
|
||||||
|
));
|
||||||
|
assert!(BatchCodeAction::parse("redeem").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user