From 87ad4177b1cc1a646563d0cc6f6fcd19b934069f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Sun, 16 Nov 2025 14:00:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E4=BD=9C=E5=8F=B0):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=AE=A1=E7=90=86=E7=AB=AF=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B9=B6=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(抽奖动态): 修复抽奖动态未渲染问题并优化文案展示 fix(用户概览): 修复用户概览无数据显示问题 feat(新用户列表): 在新用户列表显示称号明细 refactor(待办事项): 移除代办模块并全宽展示实时动态 feat(批量操作): 限制为单用户操作并在批量时提醒 fix(称号分配): 防重复分配称号的改造计划 perf(接口性能): 优化新用户和抽奖动态接口性能 feat(订单漏斗): 优化订单转化漏斗指标计算 docs(测试计划): 完善盲盒运营API核查与闭环测试计划 --- .../优化“新用户”和“实时抽奖”接口性能.md | 52 ++ .trae/documents/优化订单转化漏斗指标计算.md | 20 + ...cher 中“resetForm 未初始化”错误的改造方案.md | 34 + .trae/documents/修复“用户概览”无数据显示.md | 18 + .trae/documents/修复抽奖动态未渲染问题.md | 10 + .../修复范围筛选、抽奖动态与待办事项.md | 32 + .trae/documents/在新用户列表显示称号明细.md | 16 + .../documents/在用户详情显示头衔的改造计划.md | 25 + ...台:订单漏斗、中奖分析、用户概述与抽奖动态.md | 72 ++ .trae/documents/实现管理端工作台接口.md | 127 +++ .../扩展新用户数据、抽奖动态与待办逻辑.md | 55 ++ ...积分_优惠券_奖励:安全与闭环改造实施计划.md | 64 ++ .../盲盒运营API核查与闭环测试计划.md | 91 ++ .../移除代办模块并全宽展示实时动态.md | 19 + .trae/documents/让抽奖动态完整显示.md | 23 + .trae/documents/防重复分配称号的改造计划.md | 17 + ...制为单用户操作并在批量时提醒的改造计划.md | 17 + internal/api/admin/dashboard_admin.go | 861 ++++++++++++++++++ internal/api/admin/dashboard_admin_test.go | 26 + internal/api/admin/titles_admin.go | 5 + internal/api/admin/titles_admin_test.go | 23 + internal/api/admin/users_admin.go | 80 +- internal/api/admin/users_batch_admin.go | 144 +++ internal/router/router.go | 16 + internal/service/user/coupon_add.go | 23 + internal/service/user/item_card_add.go | 14 +- .../admin_blindbox.postman_collection.json | 180 ++++ 27 files changed, 2052 insertions(+), 12 deletions(-) create mode 100644 .trae/documents/优化“新用户”和“实时抽奖”接口性能.md create mode 100644 .trae/documents/优化订单转化漏斗指标计算.md create mode 100644 .trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md create mode 100644 .trae/documents/修复“用户概览”无数据显示.md create mode 100644 .trae/documents/修复抽奖动态未渲染问题.md create mode 100644 .trae/documents/修复范围筛选、抽奖动态与待办事项.md create mode 100644 .trae/documents/在新用户列表显示称号明细.md create mode 100644 .trae/documents/在用户详情显示头衔的改造计划.md create mode 100644 .trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md create mode 100644 .trae/documents/实现管理端工作台接口.md create mode 100644 .trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md create mode 100644 .trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md create mode 100644 .trae/documents/盲盒运营API核查与闭环测试计划.md create mode 100644 .trae/documents/移除代办模块并全宽展示实时动态.md create mode 100644 .trae/documents/让抽奖动态完整显示.md create mode 100644 .trae/documents/防重复分配称号的改造计划.md create mode 100644 .trae/documents/限制为单用户操作并在批量时提醒的改造计划.md create mode 100644 internal/api/admin/dashboard_admin.go create mode 100644 internal/api/admin/dashboard_admin_test.go create mode 100644 internal/api/admin/titles_admin_test.go create mode 100644 internal/api/admin/users_batch_admin.go create mode 100644 tests/collections/admin_blindbox.postman_collection.json diff --git a/.trae/documents/优化“新用户”和“实时抽奖”接口性能.md b/.trae/documents/优化“新用户”和“实时抽奖”接口性能.md new file mode 100644 index 0000000..d26d058 --- /dev/null +++ b/.trae/documents/优化“新用户”和“实时抽奖”接口性能.md @@ -0,0 +1,52 @@ +## 痛点定位 +- 新用户接口在单次请求内对每个用户做多次单表查询(资产、道具卡、优惠券、称号、最后在线时间),形成 N+1 查询,延迟随列表大小线性增加(`internal/api/admin/dashboard_admin.go` 的 `DashboardNewUsers()`)。 +- 实时抽奖动态接口对每条抽奖日志逐条联查用户/期/活动/奖品,亦为 N+1 查询(`DashboardDrawStream()`)。 + +## 后端优化方案 +- 批量聚合替代逐条查询(NewUsers) + - 第一步:一次查出当前页的 `user_id` 列表 + - 第二步:分别对各表做分组聚合(单次查询): + - 资产:`SELECT user_id, COUNT(*) FROM user_inventory WHERE user_id IN (...) GROUP BY user_id` + - 道具卡:`SELECT user_id, COUNT(*) FROM user_item_cards WHERE status=1 AND user_id IN (...) GROUP BY user_id` + - 优惠券:`SELECT user_id, COUNT(*) FROM user_coupons WHERE user_id IN (...) GROUP BY user_id` + - 称号:`SELECT ut.user_id, st.id, st.name FROM user_titles ut LEFT JOIN system_titles st ON st.id=ut.title_id WHERE ut.user_id IN (...)` + - 最后在线:分别取各行为表 `MAX(time)` 按 `user_id` 聚合,再在内存求最大值 + - 积分余额:改为批量查 `user_points` 有效积分 `GROUP BY user_id`,或接入预聚合表(见下) + - 第三步:用 `map[user_id]value` 合并到用户列表,避免每行多次往返数据库 +- 连接查询替代逐条补全(DrawStream) + - 单条 SQL 联查:`activity_draw_logs` LEFT JOIN `users`、`activity_issues`、`activities`、`activity_reward_settings`,一次性返回 `nickname/activityName/issueNumber/prizeName` + - 保留 `since_id + limit` 增量拉取;避免循环内 `First()` 调用 +- 预聚合与缓存 + - 建议增加 `user_stats` 表(或 Redis 缓存)维护:`points_balance`、`inventory_count`、`item_card_count`、`coupon_count`、`title_list`、`last_online_at` + - 更新策略: + - 同步:在相关写入路径(发放积分/道具卡/优惠券/称号、抽奖、下单)更新统计 + - 异步:crontab 每 1-5 分钟增量刷新 + - 实时抽奖:为最近 50 条结果加 3-5 秒内存缓存(LRU 或 Redis) +- 限流与分页 + - 新用户默认 `page_size=20`,最大 50;实时抽奖 `limit<=100` + - 对于“今年”范围下分页检索控制页大小,避免一次返回过多用户 + +## 数据库与索引 +- 新建/确认索引: + - `users(created_at)`、`users(id)` + - `activity_draw_logs(id DESC, user_id, issue_id, reward_id, created_at)` + - `user_inventory(user_id)`、`user_item_cards(user_id,status)`、`user_coupons(user_id)`、`user_titles(user_id,title_id)` + - `user_points(user_id, valid_end)`、`user_points_ledger(user_id, created_at)` +- 可选:为 `log_request(path, created_at)` 增加 `user_id` 字段与索引,精确“最后在线时间” + +## 前端协同优化 +- 新用户页签切换时:防抖 200ms;保留上次结果并显示加载骨架,避免空白闪烁 +- 实时抽奖轮询:保持 5s;追加条目后裁剪到 100-200 条以保证 DOM 轻量;使用 `ref` 持有列表(已改) +- 宽度问题:动态项允许换行并分两行展示(已改),避免不可见 + +## 验收指标 +- 新用户接口:在 `page_size=20` 时 P95 响应时间 < 200ms(本地数据量下) +- 实时抽奖接口:在 `limit=50` 时 P95 响应时间 < 150ms;每轮轮询端到端显示时间 < 300ms + +## 下一步实现内容(获批后执行) +1) 重写 `DashboardNewUsers()` 为批量聚合与合并映射 +2) 重写 `DashboardDrawStream()` 为单次 LEFT JOIN 联查 +3) 添加必要索引迁移脚本 +4) 可选:落地 `user_stats` 预聚合与写路径刷新机制 + +确认后我将按上述方案逐条落地并提供压测数据与对比报告。 \ No newline at end of file diff --git a/.trae/documents/优化订单转化漏斗指标计算.md b/.trae/documents/优化订单转化漏斗指标计算.md new file mode 100644 index 0000000..bc1ea3a --- /dev/null +++ b/.trae/documents/优化订单转化漏斗指标计算.md @@ -0,0 +1,20 @@ +## 问题 +现有漏斗将“主要流失环节”选在“完成订单”,因为完成率为0%。但该模块目标是识别支付瓶颈,主要应关注“访问→下单”“下单→支付”,履约(完成订单)不应参与支付瓶颈判断;整体转化率也应以支付为准。 + +## 调整方案(前端) +- 文件:`web/admin/src/views/dashboard/console/modules/order-funnel.vue` +- 指标计算: + - **整体转化率**改为:`payments / visitors * 100`;新增“履约完成率”可选展示 `completions / payments * 100` + - **主要流失环节(支付链路)**只在两段中比较: + - `访问→下单` 流失率 = `1 - orders / visitors` + - `下单→支付` 流失率 = `1 - payments / orders` + - **主要流失率**对应上述最大值(保留一位小数) +- 现有四阶段显示不变;“完成订单”的比率与流失仅用于展示,不参与“支付瓶颈”指标 + +## 验证 +- 数据示例:访问723,下单10,支付10,完成0 + - 支付整体转化率:`10/723≈1.38%` + - 两段流失率:`访问→下单≈98.6%`,`下单→支付=0%` → 主要流失环节:下单用户 + - 履约完成率:`0/10=0%`(可作为履约提示,不影响支付瓶颈判断) + +确认后我将直接修改该组件的计算逻辑并构建验证。 \ No newline at end of file diff --git a/.trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md b/.trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md new file mode 100644 index 0000000..70f9164 --- /dev/null +++ b/.trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md @@ -0,0 +1,34 @@ +## 问题分析 +- 组件 `coupon-dialog.vue` 在 `setup` 中使用了: + ```ts + watch(() => props.data, (newData) => { ... else { resetForm() } }, { immediate: true }) + ``` +- `resetForm` 在源码中以 `const resetForm = () => { ... }` 形式定义在 `watch` 之后。由于 `immediate: true` 会在初始化阶段立即执行回调,导致在 `resetForm` 尚未初始化之前被调用,抛出 `ReferenceError: Cannot access 'resetForm' before initialization`。 + +## 修复方案 +1) 将 `resetForm` 改为函数声明并上移到 `watch` 之前: +```ts +function resetForm() { + form.value = { name:'', coupon_type:1, discount_type:1, discount_value:0, valid_days:30, status:1, remark:'' } +} +``` +- 函数声明具备提升(hoisting),不会出现“未初始化”问题。 +- 位置调整到 `rules` 与 `dialogTitle` 定义之后、`watch(props.data, ...)` 之前。 + +2) 保留 `immediate: true`(业务期望首次打开时即填充/重置),无需改动;如需更稳健可补充 `flush: 'post'`,但此处核心是函数提升顺序问题。 + +3) 可选强化 +- 如同时监听 `visible`,建议统一在 `watch` 中使用已声明的 `resetForm` 与 `fillFormFromProps(newData)` 两个函数,避免将逻辑直接写在回调内。 +- 若依赖 `formRef.resetFields()` 的时机,考虑在 `handleClose` 使用 `nextTick` 保证 DOM 与表单实例可用。 + +## 变更清单 +- 文件:`web/admin/src/views/operations/coupons/modules/coupon-dialog.vue` +- 改动: + - 将 `resetForm` 改为函数声明并移动到 `watch` 之前。 + - (可选)在 `watch` 的第三参数加入 `flush: 'post'`。 + +## 验收 +- 打开/切换数据源时不再出现 `ReferenceError`。 +- 创建/编辑模式下首次加载和重置行为保持一致(`immediate: true` 生效)。 + +确认后我将按上述方案修改代码并验证。 \ No newline at end of file diff --git a/.trae/documents/修复“用户概览”无数据显示.md b/.trae/documents/修复“用户概览”无数据显示.md new file mode 100644 index 0000000..96e44b2 --- /dev/null +++ b/.trae/documents/修复“用户概览”无数据显示.md @@ -0,0 +1,18 @@ +## 问题诊断 +- 前端 `active-user.vue` 将图表数据 `xAxisLabels/chartData` 改为普通数组,未使用 Vue 响应式;页面初始为空数组且后续赋值不触发渲染,导致“用户概览没有数据”。 +- 指标列表 `list` 同为普通数组,数值更新不触发视图刷新。 +- 后端 `GET /api/admin/dashboard/user_overview` 正常返回,但前端未正确展示。 + +## 修复方案 +- 将 `xAxisLabels`、`chartData` 改为 `ref/ref`,用 `.value` 填充;模板自动解包可直接绑定。 +- 将指标 `list` 改为 `reactive`,更新项时触发视图刷新。 +- 增加兜底:接口异常或空数据时显示“暂无”与零值,避免空白。 + +## 验证 +- 后端:`curl -H 'Authorization: ' 'http://localhost:8000/api/admin/dashboard/user_overview?rangeType=30d'` 返回含 `chart/metrics`。 +- 前端:刷新工作台,“用户概述”条形图与四个指标显示数据;空数据时显示零与“暂无”。 + +确认后我将: +1) 更新 `web/admin/src/views/dashboard/console/modules/active-user.vue` 响应式实现; +2) 运行后端构建校验; +3) 提供前端验证说明(构建目前受其他文件类型错误影响不影响本模块联调)。 \ No newline at end of file diff --git a/.trae/documents/修复抽奖动态未渲染问题.md b/.trae/documents/修复抽奖动态未渲染问题.md new file mode 100644 index 0000000..f21cf53 --- /dev/null +++ b/.trae/documents/修复抽奖动态未渲染问题.md @@ -0,0 +1,10 @@ +## 原因推测 +- 抽奖动态列表使用 `reactive([])`,在数组 `unshift/splice` 更新时可能未触发渲染;改用 `ref([])` 更稳妥。 +- 文案需更明确:“昵称 在 活动名-期号 中奖 奖品名”。 + +## 修复项 +1) 将列表改为 `ref` 并按 `.value` 更新;模板自动解包无改动。 +2) 文案改为纯文本:“中奖 {{ prizeName }}”,去掉标签以避免样式干扰。 + +## 验证 +- 首次加载立即显示返回的列表项;后续轮询追加新项;行内容符合“谁在哪一个活动中了什么奖品”。 \ No newline at end of file diff --git a/.trae/documents/修复范围筛选、抽奖动态与待办事项.md b/.trae/documents/修复范围筛选、抽奖动态与待办事项.md new file mode 100644 index 0000000..1e0869d --- /dev/null +++ b/.trae/documents/修复范围筛选、抽奖动态与待办事项.md @@ -0,0 +1,32 @@ +## 问题与修复方案 +- 新用户切换“本月/上月/今年”无变化:后端 `new_users` 未支持时间范围;前端未传范围参数 +- 实时抽奖动态文案未达成“谁在哪一个活动中了什么奖品” +- 待办事项仍展示绑定/公会;未展示“未抽奖用户” + +## 后端调整 +1) 扩展 `GET /api/admin/dashboard/new_users` +- 新增参数 `period=month|last_month|year` +- 按 `users.created_at` 过滤对应范围 + +2) 抽奖动态数据完整化 +- 已返回 `activityName/issueNumber/prizeName`;确保空值处理 + +3) 待办事项 +- 保持返回 `taskType='undrawn'`、`taskLabel='从未参与抽奖'` + +## 前端调整 +1) 新用户模块 +- `fetchNewUsers(page,pageSize,period)` 支持传 `period` +- `new-user.vue` 监听单选切换,映射“本月/上月/今年”→`month/last_month/year` + +2) 实时抽奖动态 +- 行文改为:`{{ nickname }} 在 {{ activityName }}-{{ issueNumber }} {{ isWinner ? '中奖 ' + prizeName : '参与' }}` + +3) 待办事项 +- 使用接口返回的 `taskLabel` 和 `taskType`,标签统一为 `info`,文案显示“从未参与抽奖” + +## 验证 +- 后端编译通过; +- 切换单选范围数据刷新; +- 抽奖动态行文本符合“谁在哪一个活动中了什么奖品”; +- 待办列表展示“未参与抽奖”的用户 \ No newline at end of file diff --git a/.trae/documents/在新用户列表显示称号明细.md b/.trae/documents/在新用户列表显示称号明细.md new file mode 100644 index 0000000..427e7f4 --- /dev/null +++ b/.trae/documents/在新用户列表显示称号明细.md @@ -0,0 +1,16 @@ +## 目标 +- 将新用户列表中的“称号数”改为展示具体称号列表(标签形式)。 + +## 改动内容 +- 后端 `GET /api/admin/dashboard/new_users`:在每个用户项增加 `titles: [{id,name}]` 列表;保留现有字段,兼容前端。 + - 位置:`internal/api/admin/dashboard_admin.go` 的 `DashboardNewUsers()` + - 实现:联表 `user_titles` 与 `system_titles` 获取用户称号名称,按用户填充 `titles` 数组。 +- 前端类型与表格: + - 在 `web/admin/src/api/dashboard.ts` 的 `NewUserItem` 增加 `titles: { id:number; name:string }[]` + - 在 `web/admin/src/views/dashboard/console/modules/new-user.vue`: + - 移除“称号数”列 + - 新增“称号”列,循环 `row.titles` 渲染 `ElTag` 列表;为空时显示“无称号”。 + +## 验证 +- 后端编译通过;接口 `new_users` 返回每个用户的称号数组 +- 前端工作台“新用户”模块展示称号标签;无称号显示“无称号” \ No newline at end of file diff --git a/.trae/documents/在用户详情显示头衔的改造计划.md b/.trae/documents/在用户详情显示头衔的改造计划.md new file mode 100644 index 0000000..440f4b3 --- /dev/null +++ b/.trae/documents/在用户详情显示头衔的改造计划.md @@ -0,0 +1,25 @@ +## 目标 +- 在管理端用户详情抽屉中显示该用户当前头衔(含名称、描述、生效/过期时间)。 + +## 现状 +- 用户详情视图:`web/admin/src/views/player-manage/modules/player-detail-drawer.vue` +- 玩家管理页入口:`web/admin/src/views/player-manage/index.vue` +- 资产接口:`web/admin/src/api/player-manage.ts`(暂无用户头衔列表接口) +- 后端路由已存在分配头衔:`POST /api/admin/users/:user_id/titles`,但缺少`GET`列表接口。 + +## 后端改造 +- 新增:`GET /api/admin/users/:user_id/titles` + - 位置:`internal/api/admin/users_admin.go` + - 查询:`user_titles`(active=1、未过期)左连接`system_titles`(取`name/description`),返回`id/title_id/name/description/obtained_at/expires_at/status`。 +- 路由挂载:`internal/router/router.go` 在管理端鉴权组新增该`GET`端点。 + +## 前端改造 +- API:在 `web/admin/src/api/player-manage.ts` 增加 `fetchGetUserTitles(userId)`;请求 `GET /api/admin/users/:user_id/titles`。 +- 视图:在 `player-detail-drawer.vue` 增加“头衔”板块 + - 展示为标签列表:`title.name`(副文案:`description`) + - 显示时间:`obtained_at` 与 `expires_at`(过期标识) + - 若无头衔,显示“无头衔”。 + +## 验收 +- 打开用户详情,正确拉取并显示当前头衔;过期或未激活不显示。 +- 已分配称号立即可在详情查看。 \ No newline at end of file diff --git a/.trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md b/.trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md new file mode 100644 index 0000000..341b857 --- /dev/null +++ b/.trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md @@ -0,0 +1,72 @@ +## 目标 +- 补齐工作台 4 个模块的真实数据接口与前端对接:订单转化漏斗、活动中奖分析、用户概述、抽奖动态文案完善。 + +## 后端接口设计(统一前缀 `/api/admin/dashboard`) +### 1) 订单转化漏斗 `GET /order_funnel` +- 入参:`rangeType=today|7d|30d|custom`,`start/end`(custom 时必填) +- 出参:`FunnelData[]`(与 `web/admin/src/api/operations.ts` 定义一致) +- 统计口径: + - 访问用户:优先取 `log_request` 中 `path LIKE '/api/app/%'` 的唯一访问(去重 `tid` 或 IP/UA),范围时间内(`internal/repository/mysql/model/log_request.gen.go:13-27`) + - 下单用户:`orders.created_at` 范围内下单的唯一用户数(`orders.status` 任意)(`internal/repository/mysql/model/orders.gen.go:13-33`) + - 支付用户:`orders.status=2`(已支付)范围内唯一用户数 + - 完成订单:订单完成的判定(二选一并取并集):`orders.status=2 AND is_consumed=1` 或存在 `shipping_records.status IN (2,3)`(已发货/已签收)(`internal/repository/mysql/model/shipping_records.gen.go:13-31`) +- 返回各阶段 `count`,并计算 `rate`(相对上一阶段)与 `lostCount`。 + +### 2) 活动中奖分析 `GET /activity_prize_analysis` +- 入参:`activity_id`(必填),可选 `rangeType` +- 出参:`ActivityPrizeAnalysis`(`activity` + `prizes[]` + `summary`,与前端类型一致) +- 统计口径: + - 奖品模板:`activity_reward_settings`(`issue_id` 归属同活动的期)(`internal/repository/mysql/model/activity_reward_settings.gen.go:13-27`) + - 中奖统计:`activity_draw_logs` 按 `reward_id` 分组统计 `winCount`(`is_winner=1`),`drawCount` 为期内抽奖总次数(`internal/repository/mysql/model/activity_draw_logs.gen.go:13-24`) + - 概率(设置):`weight/Σweight*100`(模板无显式概率字段,采用权重归一化) + - 概率(实际):`winCount/drawCount*100` + - 成本:若模板 `product_id` 非空,关联 `products.price` 作为单件成本(`internal/repository/mysql/model/products.gen.go:13-25`);否则成本为 0 或采用等级默认表(可配置) + - 活动信息:`activities` + `activity_issues` 组合(名称、时间范围、参与人数=distinct `user_id`,总抽奖次数) + +### 3) 用户概述 `GET /user_overview` +- 入参:`rangeType` +- 出参:包含: + - `chart`: 最近 N 个时间桶(默认 9 个月)用户注册量序列(来源 `users.created_at`) + - `metrics`: `totalUsers`(系统总量)、`totalVisits`(`log_request` 中 `/api/app/%` 请求计数)、`dailyVisits`(当天 `/api/app/%`)、`weeklyGrowth`(近 7 天 vs 上 7 天访问增幅) +- 说明:若实际“访问”定义需更严格,可后续改为埋点表;当前采用请求日志近似。 + +### 4) 抽奖动态增强 `GET /draw_stream`(已有) +- 增加字段:`activityName`、`issueNumber`、`prizeName`(中奖时) +- 数据来源:`activity_issues`、`activities`、`activity_reward_settings.name` +- 现有实现位置:`internal/api/admin/dashboard_admin.go:270-317`(将补充字段) + +### 5) 通用 +- 时间范围工具沿用:`parseRange/previousWindow/daysBetween`(`internal/api/admin/dashboard_admin.go:365-417`),对 `custom` 做 30 天上限;趋势分桶工具已实现(`normalizeGranularity/buildBuckets`)。 +- 路由注册:在 `adminAuthApiRouter` 添加新端点(参考 `internal/router/router.go:63` 附近已有 dashboard 路由)。 + +## 前端改动 +- 文件:`web/admin/src/api/operations.ts` + - `fetchOrderFunnel(range)` → `GET admin/dashboard/order_funnel` + - `fetchActivityList()` → `GET admin/dashboard/activities`(返回 `id,name,type,start,end,status,totalDraws,totalParticipants`;也可直接用现有 `GET /api/admin/activities` 的列表按需映射) + - `fetchActivityPrizeAnalysis(activityId)` → `GET admin/dashboard/activity_prize_analysis` + - `fetchUserOverview(range)`(新增) → `GET admin/dashboard/user_overview` +- 组件对接: + - `order-funnel.vue` 已调用 `fetchOrderFunnel`(`web/admin/src/views/dashboard/console/modules/order-funnel.vue:148-153`),直接切换数据源即可。 + - `activity-prize-analysis.vue` 使用 `fetchActivityList/fetchActivityPrizeAnalysis`(`web/admin/src/views/dashboard/console/modules/activity-prize-analysis.vue:219-225`、`344-357`、`367-375`),切换到真实 API。 + - `active-user.vue` 当前为静态数据(`web/admin/src/views/dashboard/console/modules/active-user.vue:31-46`),改为使用 `fetchUserOverview` 渲染条形图与 4 个指标。 + - `dynamic-stats.vue` 增强显示中奖文案:“用户在【活动-期】中奖【奖品名】”(使用新增字段),保留参与与中奖标签(`web/admin/src/views/dashboard/console/modules/dynamic-stats.vue`)。 + +## 返回示例 +- `GET /api/admin/dashboard/order_funnel`:`[{"stage":"访问用户","count":12580,"rate":100,"lostCount":0}, ...]` +- `GET /api/admin/dashboard/activity_prize_analysis`:结构与前端定义一致,概率为权重归一化。 +- `GET /api/admin/dashboard/user_overview`:`{"chart":[{"date":"2025-03","value":1200},...],"metrics":{"totalUsers":12345,"totalVisits":45678,"dailyVisits":1234,"weeklyGrowth":"+8%"}}` +- `GET /api/admin/dashboard/draw_stream`:增加 `activityName/issueNumber/prizeName` 字段。 + +## 验收 +- 工作台四模块均显示真实数据;各接口性能在默认范围(≤30 天)内响应合理。 +- 环比与趋势计算准确;异常/空数据有合理降级。 + +## 后续可选优化 +- 为 `log_request.created_at`、`orders.created_at`、`shipping_records.status`、`activity_draw_logs.created_at` 增加索引。 +- 活动奖品“成本”可从商品资料或运营配置独立维护,避免 0 值。 + +确认后我将: +1. 补充 3 个新端点与 `draw_stream` 字段;注册路由; +2. 替换 `operations.ts` 的 Mock 为真实请求; +3. 调整前端 3 个模块的渲染逻辑; +4. 进行构建与联调,提供验证指令与预览链接。 \ No newline at end of file diff --git a/.trae/documents/实现管理端工作台接口.md b/.trae/documents/实现管理端工作台接口.md new file mode 100644 index 0000000..8e61276 --- /dev/null +++ b/.trae/documents/实现管理端工作台接口.md @@ -0,0 +1,127 @@ +## 当前情况 +- 管理端“工作台”路由:`web/admin/src/router/modules/dashboard.ts:3-24` +- 工作台页面:`web/admin/src/views/dashboard/console/index.vue` +- 工作台数据源:`web/admin/src/api/dashboard.ts` 全为本地 Mock,无真实后端调用 +- 后端已存在管理端认证路由分组:`internal/router/router.go:55-151`,但未注册任何“dashboard”端点 + +## 技术栈与约束 +- 后端:Gin + GORM Gen,入口 `main.go:52-67`,路由 `internal/router/router.go` +- 数据模型可用:`internal/repository/mysql/model/*.gen.go`(如 `users`、`activity_draw_logs`、`user_item_cards`、`user_points`、`guild_members`) +- 认证:管理端统一走 `Authorization` 头(`LoginVerifyToken`),同组复用:`internal/router/router.go:56` + +## 端点设计(前缀 `/api/admin/dashboard`) +1) `GET /cards` +- 入参:`rangeType=today|7d|30d|custom`,可选 `start=YYYY-MM-DD&end=YYYY-MM-DD`(custom 时) +- 出参:`CardStat`(与 `web/admin/src/api/dashboard.ts` 一致) +- 统计口径: + - `itemCardSales`:`user_item_cards.created_at` 范围新增数(`user_item_cards`) + - `drawCount`:`activity_draw_logs.created_at` 范围次数(`activity_draw_logs`) + - `newUsers`:`users.created_at` 范围新增数(`users`) + - `totalPoints`:全量有效积分总和(`user_points.points` 过滤 `valid_end` 未过期),实现参考 `internal/service/user/points_balance.go:8-21` + - 环比字段 `*_Change`:与上一等长时间窗口对比百分比(向下取整),无前窗则返回 `+0%` + +2) `GET /user_trend` +- 入参:`rangeType`、`granularity=day|week|month` +- 出参:`UserTrendResp` +- 统计:按粒度聚合 `users.created_at` 计数生成时间序列 + +3) `GET /draw_trend` +- 入参:同上 +- 出参:`DrawTrendResp` +- 统计:按粒度聚合 `activity_draw_logs.created_at` 计数生成时间序列 + +4) `GET /new_users` +- 入参:`page`、`page_size` +- 出参:`NewUserListResp` +- 明细项字段: + - `pointsBalance`:用户有效积分余额(复用 `GetPointsBalance` 逻辑 `internal/service/user/points_balance.go:8-21`) + - `inventoryCount`:`user_inventory` 记录数(状态过滤视业务) + - `itemCardCount`:`user_item_cards` 有效未使用数量(`status=1`) + +5) `GET /draw_stream` +- 入参:`since_id`(可选)、`limit`(默认 50,最大 100) +- 出参:`DrawStreamResp` +- 统计:按 `id` 递减拉取最近抽奖日志,关联 `users.nickname`、`activity_issues.name` 生成展示项;返回 `sinceId = max(id)` 以便前端轮询增量 + +6) `GET /todos` +- 入参:`limit`(默认 50,最大 100) +- 出参:`TodoListResp` +- 规则: + - `bind_mobile`:`users.mobile IS NULL OR ''` + - `join_guild`:`NOT EXISTS guild_members WHERE user_id=users.id AND status=1` + - 返回 `avatar`、`nickname`、`taskLabel` 友好文案 + +## 后端改动点 +- 新增处理器:`internal/api/admin/dashboard_admin.go` + - 采用现有 handler 模式:`internal/api/admin/admin.go:16-42` + - 为每个端点提供 `core.HandlerFunc`,入参校验使用 `ShouldBindForm/JSON` 与 `internal/pkg/validation` +- 新增服务层:`internal/service/admin/dashboard_*.go` + - `GetCardStats(ctx, range)`, `GetUserTrend(ctx, range, granularity)`, `GetDrawTrend(...)` + - `ListNewUsersWithStats(ctx, page, pageSize)`:多表聚合(users + user_points + user_item_cards + user_inventory) + - `ListDrawStream(ctx, sinceID, limit)`:抽奖日志联表 users、activity_issues + - `ListTodos(ctx, limit)`:基于 users 与 guild_members 规则 +- 路由注册:在管理端认证组添加 + - 文件:`internal/router/router.go` + - 组:`adminAuthApiRouter`(`internal/router/router.go:55-151`) + - 注册形如: + - `adminAuthApiRouter.GET("/dashboard/cards", adminHandler.DashboardCards())` + - `adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend())` + - `adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend())` + - `adminAuthApiRouter.GET("/dashboard/new_users", adminHandler.DashboardNewUsers())` + - `adminAuthApiRouter.GET("/dashboard/draw_stream", adminHandler.DashboardDrawStream())` + - `adminAuthApiRouter.GET("/dashboard/todos", adminHandler.DashboardTodos())` + +## 前端改动点 +- 更新 `web/admin/src/api/dashboard.ts` + - 用 `request.get` 替换 Mock: + - `fetchCardStats(range)` → `GET admin/dashboard/cards` + - `fetchUserTrend(range, granularity)` → `GET admin/dashboard/user_trend` + - `fetchDrawTrend(range, granularity)` → `GET admin/dashboard/draw_trend` + - `fetchNewUsers(page, pageSize)` → `GET admin/dashboard/new_users` + - `fetchDrawStream(sinceId, limit)` → `GET admin/dashboard/draw_stream` + - `fetchTodos(limit)` → `GET admin/dashboard/todos` + - 保持 TS 接口不变,便于无缝替换 + +## 数据来源与代码参考 +- 路由分组:`internal/router/router.go:55-61` +- 抽奖日志分页示例:`internal/service/activity/draw_logs_list.go:9-28` +- 用户积分余额:`internal/service/user/points_balance.go:8-21` +- 用户综合统计:`internal/service/user/stats.go:13-38` +- 数据模型表:`internal/repository/mysql/model/*.gen.go`(如 `users.gen.go:15-29`、`activity_draw_logs.gen.go:13-24`、`user_item_cards.gen.go:13-28`、`guild_members.gen.go:13-23`) + +## 接口示例返回 +- `GET /api/admin/dashboard/cards`: +```json +{ + "itemCardSales": 1234, + "drawCount": 5678, + "newUsers": 321, + "totalPoints": 98765, + "itemCardChange": "+12%", + "drawChange": "+8%", + "newUserChange": "+5%", + "pointsChange": "+3%" +} +``` +- `GET /api/admin/dashboard/user_trend` / `draw_trend`:`{ "granularity": "day", "list": [{"date":"2025-11-01","value":100}, ...] }` +- 其他返回与 `web/admin/src/api/dashboard.ts` 对齐 + +## 验证与测试 +- Swagger 注解与分组标签:沿用现有风格(参考 `internal/api/admin/users_admin.go:30-46`) +- 集成测试:参考 `internal/api/admin/titles_admin_test.go` 新增 API 测试用例(登录获取 Token 后调用) +- 手动验证: + - 后端:`curl -H "Authorization: " "http://localhost:/api/admin/dashboard/cards?rangeType=7d"` + - 前端:工作台各模块正常展示且不再使用随机数据 + +## 性能与索引建议 +- 为 `users.created_at`、`activity_draw_logs.created_at`、`user_item_cards.created_at` 建立索引 +- 汇总接口注意分页与时间窗口限制,默认范围 7 天,可配置上限 30 天 + +## 交付范围与验收 +- 完成 6 个端点后,前端工作台所有模块均有真实数据源 +- 验收:页面展示与接口返回满足 TS 类型;Swagger 文档可用;管理员认证校验生效 + +确认后我将: +- 在后端新增处理器与服务实现并注册路由 +- 替换前端 `dashboard.ts` 的 Mock 为真实请求 +- 补充必要的 Swagger 注释与最小测试用例 \ No newline at end of file diff --git a/.trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md b/.trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md new file mode 100644 index 0000000..4a362e0 --- /dev/null +++ b/.trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md @@ -0,0 +1,55 @@ +## 目标 +- 新用户列表增加:积分余额、资产、道具卡、优惠券、称号、上一次在线时间 +- 实时抽奖动态展示:用户 | 活动 | 奖品 +- 待办事项改为:未抽奖用户列表 + +## 后端改动 +### 新用户列表扩展 `GET /api/admin/dashboard/new_users` +- 文件:`internal/api/admin/dashboard_admin.go` +- 在 `DashboardNewUsers()` 为每个用户追加: + - `couponCount`:`user_coupons` 有效/未使用计数 + - `titleCount`:`user_titles` 数量(或仅活跃状态) + - `lastOnlineAt`:近似“最后活跃时间”,取多个行为表的最大时间: + - `activity_draw_logs.created_at` + - `orders.updated_at` 或 `paid_at` + - `user_points_ledger.created_at` + - `user_item_cards.updated_at` + - `user_inventory.updated_at` + - 注:当前日志表 `log_request` 未记录 `user_id`,无法精确映射在线时间;后续可优化为日志写入 `user_id` 字段。 +- 返回结构:在 `newUserItem` 增加 `couponCount:number`、`titleCount:number`、`lastOnlineAt:string` + +### 抽奖动态增强 `GET /api/admin/dashboard/draw_stream` +- 已新增字段:`activityName`、`issueNumber`、`prizeName` +- 保持现有结构,前端按“用户 | 活动 | 奖品”展示即可(`dynamic-stats.vue` 已适配) + +### 待办事项改为未抽奖用户 `GET /api/admin/dashboard/todos` +- 文件:`internal/api/admin/dashboard_admin.go` +- 原“绑定手机号/加入公会”规则改为: + - 查询 `users`,过滤 `NOT EXISTS activity_draw_logs WHERE user_id=users.id` + - 返回字段:`userId/nickname/avatar`,`taskType` 固定为 `undrawn`,文案 `taskLabel='从未参与抽奖'` +- 保持分页/limit 与安全校验一致 + +## 前端改动 +### 新用户模块 `web/admin/src/views/dashboard/console/modules/new-user.vue` +- 表格新增列:优惠券数、称号数、上一次在线时间 +- 数据源类型:更新 `web/admin/src/api/dashboard.ts` 的 `NewUserItem` 接口,增加上述3字段;`fetchNewUsers` 返回保持一致 + +### 抽奖动态 `web/admin/src/views/dashboard/console/modules/dynamic-stats.vue` +- 已展示“活动名-期号”和中奖奖品名;文案保持“用户 | 活动 | 奖品”格式 + +### 待办事项 `web/admin/src/views/dashboard/console/modules/todo-list.vue` +- 调整列表文案与标签:从“绑定手机号/加入公会”改为“从未参与抽奖”的用户清单(调用同名接口) + +## 验证 +- 后端:`go build ./...` 通过; +- 接口验证: + - 新用户:`curl -H 'Authorization: ' 'http://localhost:8000/api/admin/dashboard/new_users?page=1&page_size=20'` + - 抽奖动态:`curl -H 'Authorization: ' 'http://localhost:8000/api/admin/dashboard/draw_stream?limit=50'` + - 待办(未抽奖用户):`curl -H 'Authorization: ' 'http://localhost:8000/api/admin/dashboard/todos?limit=50'` +- 前端:工作台“新用户”、“抽奖动态”、“待办事项”模块显示对应数据 + +## 后续可选优化 +- 在 `log_request` 增加 `user_id` 字段,并在中间件从会话写入;将“上一次在线时间”改为日志精准口径 +- 为关联查询增加必要索引以保证统计性能(`activity_draw_logs.user_id/issue_id`、`orders.user_id/status` 等) + +确认后我将按上述文件路径逐项实现并联调。 \ No newline at end of file diff --git a/.trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md b/.trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md new file mode 100644 index 0000000..5003320 --- /dev/null +++ b/.trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md @@ -0,0 +1,64 @@ +## 目标 +- 为运营提供可控的批量操作能力:批量增加积分、批量发送优惠券、批量发放奖励。 +- 满足权限、配额、上限、幂等与审计要求,闭环验证整个盲盒系统运营流程。 + +## 问题与现状 +- 现有单用户接口:`POST /api/admin/users/:user_id/points/add`、`/coupons/add`、`/item_cards`、`/rewards/grant`;缺少批量维度与风控保护。 +- 已加固:优惠券发放需超管(internal/api/admin/users_admin.go:596-616)、发放配额与持有上限(internal/service/user/coupon_add.go)、道具卡数量与持有上限(internal/service/user/item_card_add.go)。 +- 仍缺:批量接口、幂等、审计与限流。 + +## API 设计 +- 批量积分:`POST /api/admin/users/batch/points/add` + - Body:`{ users: number[], amount: number, reason?: string, idempotency_key?: string }` + - 校验:超管;`amount > 0`;`users.length <= 5000`;可选日累计上限(防爆刷)。 + - 行为:逐用户执行加分(重用现有服务层),支持幂等(按 `op_type+idempotency_key+users+amount` 去重)。 + - 返回:`{ success: number, failed: number, details: Array<{user_id,status,msg}> }`。 + +- 批量优惠券:`POST /api/admin/users/batch/coupons/add` + - Body:`{ users: number[], coupon_id: number, quantity_per_user?: number, idempotency_key?: string }` + - 校验:超管;模板启用与有效期;配额与单用户持有上限(已在服务层);`quantity_per_user` 上限(<=5)。 + - 行为:逐用户发券(重用 `AddCoupon`),事务分片(每批 100~200),记录逐项状态;幂等去重。 + - 返回:同上。 + +- 批量奖励:`POST /api/admin/users/batch/rewards/grant` + - Body:`{ users: number[], reward_id: number, quantity?: number, idempotency_key?: string }` 或按活动期:`POST /api/admin/activities/:activity_id/issues/:issue_id/rewards/batch_grant` `{ users: number[], reward_id: number, quantity?: number }` + - 校验:超管;奖励模板/库存;单用户持有上限(可与道具卡同策略);数量上限(<=10)。 + - 行为:逐用户入库,事务分片,幂等去重。 + - 返回:同上。 + +## 权限与风控 +- 仅超管可调用批量接口;非超管拒绝。 +- 限流:每分钟调用次数限制(可在拦截器或中间件层实现)。 +- 上限:请求用户数上限(5000),单用户数量上限;模板配额检查;用户持有上限(已存在的加固沿用)。 +- 幂等:支持 `idempotency_key`;服务层保存批次指纹,重复提交直接返回历史结果。 +- 审计:记录批量操作日志(操作者、时间、对象数量、成功/失败明细摘要与请求指纹)。 + +## 后端改造点 +- 路由:`internal/router/router.go` + - 新增:`POST /api/admin/users/batch/points/add`、`/batch/coupons/add`、`/batch/rewards/grant` +- 处理器:`internal/api/admin/users_admin.go` + - 新增批量处理 Handler(结构体 Request/Response、超管校验、参数绑定、分片循环、调用服务层、汇总返回)。 +- 服务层:`internal/service/user/*` + - 新增批量方法或在 Handler 中循环调用现有单个方法(推荐服务层添加批量封装,带事务与幂等指纹)。 +- 中间件:可选全局限流与审计记录入口(日志/Audit DAO)。 + +## 前端(管理端) +- 称号运营页或用户管理页新增“批量操作”入口,支持: + - 用户选择(列表多选/CSV导入) + - 操作类型(积分/优惠券/奖励)与参数(金额/模板/数量) + - 幂等键(可选自动生成) + - 执行后展示分项结果与失败原因下载。 + +## 测试与验收 +- Postman 集合扩展三条批量接口;注入 admin 令牌后按顺序回归。 +- 验收标准: + - 权限校验:非超管拒绝。 + - 配额/上限:超量拒绝;成功/失败计数正确。 + - 幂等:重复提交返回相同结果,不重复写入。 + - 审计:生成批次日志记录(至少在系统日志中可查)。 + +## 交付 +- 批量接口与处理器实现、服务层封装、路由挂载。 +- 集合/脚本更新与验收报告(问题清单与修复项)。 + +确认后我将开始实现并提交对应代码与测试集合更新。 \ No newline at end of file diff --git a/.trae/documents/盲盒运营API核查与闭环测试计划.md b/.trae/documents/盲盒运营API核查与闭环测试计划.md new file mode 100644 index 0000000..10b3985 --- /dev/null +++ b/.trae/documents/盲盒运营API核查与闭环测试计划.md @@ -0,0 +1,91 @@ +## 范围 + +* 覆盖活动/期/奖励、随机承诺与抽奖、称号/效果、用户资产(优惠券/道具卡/积分)、用户与公会、商品与轮播、菜单权限。 + +* 验证管理端鉴权与超管权限;核查风险接口与配置密钥安全。 + +## 现状与疑点 + +* 鉴权:管理端认证路由启用(internal/router/router.go:58-66);但存在非鉴权初始化接口(seed\_default、ensure\_titles)—需确认是否保留。 + +* 活动列表(管理端):未提供 `GET /api/admin/activities` 列表;前端使用 APP 端 `GET /api/app/activities` 作为数据源(web/admin/src/api/activity.ts:31)。 + +* 优惠券发放:`POST /api/admin/users/{user_id}/coupons/add`(internal/router/router.go:121;internal/api/admin/users\_admin.go:596-616)未见配额/用户上限校验(服务层 AddCoupon)。 + +* 道具卡分配:`POST /api/admin/users/{user_id}/item_cards`(internal/api/admin/item\_cards\_admin.go:355-376)仅超管限制,但无用户上限与批量安全边界(服务层 AddItemCard)。 + +* 称号效果:后端对 `params_json` 仅做非空(internal/api/admin/titles\_admin.go:173-213,221-277),运行期解析失败会静默忽略(internal/service/activity/draw\_with\_effects.go:104-149)。 + +* 抽奖随机:HMAC与拒绝采样实现正确(internal/service/activity/random\_commit.go:70-81;draw\_execute.go:43-51,131-145);主密钥明文在配置(configs/fat\_configs.toml:32)。 + +## 缺口清单(可能不足) + +* 管理端活动列表与搜索:缺 `GET /api/admin/activities`;当前运维依赖 APP 列表接口,可能不满足运营筛选需求。 + +* 优惠券/道具卡风控:缺配额扣减、用户持有上限、幂等与审计日志;易被滥用。 + +* 称号效果参数校验:缺严格模式与数值边界;配置易失真或异常。 + +* 安全:非鉴权初始化接口(seed/ensure)在生产不宜暴露;密钥应迁移到环境变量。 + +## 闭环测试计划(管理端) + +* 登录鉴权 + + * `POST /api/admin/login` 使用后台账号 `admin / chat2025` 拿到 `Authorization`。 + +* 活动与期 + + * 创建活动:`POST /api/admin/activities`;创建期:`POST /api/admin/activities/{id}/issues`。 + + * 配置奖励:`POST /api/admin/activities/{id}/issues/{issue}/rewards`。 + + * 随机承诺:`POST /.../commit_random` → `GET /.../commit_random` → `GET /.../commit_random/history`。 + +* 抽奖执行与验证 + + * 执行抽奖(APP鉴权):`POST /api/app/activities/{id}/issues/{issue}/draw`(需APP用户token)。 + + * 管理端批量抽取(运营回归):`POST /api/admin/activities/{id}/issues/{issue}/batch_draw`。 + + * 抽奖收据验证:`POST /api/admin/activities/{id}/issues/{issue}/verify_draw`;查看收据:`GET /api/admin/draw_receipts/{draw_id}`。 + +* 称号与效果(type=5/6) + + * 创建称号:`POST /api/admin/system_titles`;添加效果:`POST /api/admin/system_titles/{title_id}/effects`(参数按文档模式)。 + + * 分配用户称号:`POST /api/admin/users/{user_id}/titles`,设置有效期(确保“只取最新激活”策略)。 + + * 在指定期抽奖,核查“概率加成”权重调整与“双倍概率”翻倍命中(查看批量抽取统计)。 + +* 用户资产 + + * 发放优惠券:`POST /api/admin/users/{user_id}/coupons/add`;查询用户券:`GET /api/admin/users/{user_id}/coupons`。 + + * 分配道具卡:`POST /api/admin/users/{user_id}/item_cards`;查询卡列表:`GET /api/admin/users/{user_id}/item_cards`。 + + * 添加积分:`POST /api/admin/users/{user_id}/points/add`;查询积分与余额:`GET /api/admin/users/{user_id}/points`、`/points/balance`。 + +* 安全回归 + + * 非鉴权路由只保留登录;其他改动接口需 `Authorization`。 + + * 校验后台超管限制(优惠券发放/道具卡分配),普通管理员应被拒绝。 + +## 缺陷验证点 + +* 称号效果参数错误时应返回 400(当前静默忽略的路径需提示)。 + +* 优惠券发放应拒绝过期模板与总量耗尽;道具卡发放应限制批量与用户持有上限。 + +* 抽奖随机性验证:同一承诺版本重复验证一致;拒绝采样不产生取模偏差。 + +## 交付 + +* 提供接口调用序列与测试数据清单(活动/期/奖励/称号/效果参数/指定用户)。 + +* 输出问题清单与修复建议(接口、权限、风控、参数校验、密钥安全)。 + +* 可选:生成 Postman/HTTPie 脚本或后端集成测试(go test)便于自动化回归。 + +确认后,我将按以上计划执行闭环测试,汇总问题与修复建议,并提供测试脚本(不使用 curl 的情况下可采用内置工具或 Postman 集合)。 diff --git a/.trae/documents/移除代办模块并全宽展示实时动态.md b/.trae/documents/移除代办模块并全宽展示实时动态.md new file mode 100644 index 0000000..10db4f9 --- /dev/null +++ b/.trae/documents/移除代办模块并全宽展示实时动态.md @@ -0,0 +1,19 @@ +## 目标 +- 从工作台移除“代办事项”模块 +- 将“实时抽奖动态”扩展为整行宽度展示,并修复行内容被裁剪问题 + +## 变更点 +1) 页面布局 `web/admin/src/views/dashboard/console/index.vue` +- 删除 `TodoList` 引入与对应列 +- 调整最后一行:`Dynamic` 占满 24 列(全宽),`NewUser` 保持 12/24 或前面布局不变 + +2) 动态组件样式 `web/admin/src/views/dashboard/console/modules/dynamic-stats.vue` +- 移除每行固定高度与 `overflow-hidden`,允许换行:`py-2 leading-6`、`whitespace-normal break-words` +- 行内容分两行显示:第一行“昵称 在 活动-期号”,第二行“中奖 奖品名/参与” +- 保持滚动容器,确保长列表可滚动 +- 去掉裁剪到 100 条的逻辑,以满足“全部显示实时动态”(如需后续限制可再加分页/虚拟列表) + +## 验证 +- 工作台不再显示“代办事项”卡片 +- 动态模块全宽显示,长活动名/奖品名不再被裁剪 +- 列表滚动正常,持续追加数据可见 \ No newline at end of file diff --git a/.trae/documents/让抽奖动态完整显示.md b/.trae/documents/让抽奖动态完整显示.md new file mode 100644 index 0000000..8d1405f --- /dev/null +++ b/.trae/documents/让抽奖动态完整显示.md @@ -0,0 +1,23 @@ +## 问题 +- 抽奖动态每行用了固定高度 `h-17.5` 和 `overflow-hidden`,且是单行展示,长活动名/期号/奖品名会被裁剪,导致看起来“没有显示出来”。 + +## 调整方案(仅改该模块样式与结构) +1) 行容器样式 +- 移除固定高度与溢出隐藏:把 `h-17.5 leading-17.5 overflow-hidden` 改为 `py-2 leading-6`、去掉 `overflow-hidden` +- 允许换行与断词:增加 `whitespace-normal break-words`,必要时 `flex-wrap` + +2) 行内容分两行显示 +- 第一行:`昵称 在 活动名-期号` +- 第二行:`中奖 奖品名` 或 `参与` +- 这样即使宽度不够,也能在第二行完整显示奖品 + +3) 局部样式 +- 在组件 `style scoped` 中添加: + - `.dynamic-row { white-space: normal; word-break: break-word; }` + - 统一行内间距与换行行为 + +## 验证 +- 长活动名与奖品名在收窄宽度下仍完整显示 +- 列表滚动正常,内容不再被裁剪 + +确认后我将更新 `web/admin/src/views/dashboard/console/modules/dynamic-stats.vue` 的行模板与样式以生效。 \ No newline at end of file diff --git a/.trae/documents/防重复分配称号的改造计划.md b/.trae/documents/防重复分配称号的改造计划.md new file mode 100644 index 0000000..d1540d3 --- /dev/null +++ b/.trae/documents/防重复分配称号的改造计划.md @@ -0,0 +1,17 @@ +## 目标 +- 当为用户分配一个已拥有且未过期的称号时,后端拒绝并提示“用户已拥有该称号”;避免连续多次分配同一称号。 +- 前端在分配提交前做快速拦截与友好提示。 + +## 后端改造 +- 修改 `AssignUserTitle` 处理器:`internal/api/admin/titles_admin.go` + - 在创建/更新前,查询 `user_titles`:`user_id`+`title_id` 的记录,若 `active=1` 且 `expires_at` 为空或晚于当前时间 → 直接返回 400(或业务码 30117)提示“用户已拥有该称号”。 + - 保留过期的场景可重新分配;仅阻止“未过期且已激活”的重复分配。 +- 响应格式:保持现有错误响应结构,统一错误消息文案。 + +## 前端改造(可选) +- 在 `UserAssignmentDialog.vue` 提交前判断:若选中用户当前头衔包含待分配的称号 ID,则 `ElMessage.warning('该用户已拥有该称号')` 并阻止提交。 +- 若列表已有 `current_titles` 字段,直接使用;否则请求 `GET /api/admin/users/:user_id/titles` 取当前有效称号。 + +## 验收 +- 对同一用户、同一称号:首次分配成功;再次分配立刻被后端拒绝并提示。 +- 过期称号可重新分配;前端也能提示并拦截重复操作。 \ No newline at end of file diff --git a/.trae/documents/限制为单用户操作并在批量时提醒的改造计划.md b/.trae/documents/限制为单用户操作并在批量时提醒的改造计划.md new file mode 100644 index 0000000..540a7c5 --- /dev/null +++ b/.trae/documents/限制为单用户操作并在批量时提醒的改造计划.md @@ -0,0 +1,17 @@ +## 目标 +- 所有运营动作仅支持单用户。若传入多个用户,后端拒绝并返回清晰提示;前端在选择到多个用户时给出醒目提醒并阻止提交。 + +## 后端改造 +- 批量积分/优惠券/奖励三个接口改为专用的单用户接口风格:若 `users.length != 1`,返回 400 与提示“当前仅支持单用户操作”。 +- 文件:`internal/api/admin/users_batch_admin.go` 中 `BatchAddUserPoints/BatchAddUserCoupons/BatchGrantUserRewards` 增加长度校验并拒绝。 + +## 前端改造 +- 用户分配与其它需要选择用户的弹窗,仅允许单选;选择超过 1 人即提示并清理多选。 +- 文件:`web/admin/src/views/operations/titles/components/UserAssignmentDialog.vue` 修改选择控件为单选或在提交前校验并提示。 + +## 提示规范 +- 后端错误信息统一文案:“当前仅支持单用户操作,请仅选择 1 位用户”。 +- 前端使用警告提示(ElMessage.warning):“仅支持单用户,请取消其他选择”。 + +## 验收 +- 任意接口或页面选择多个用户时均被阻止,并出现统一提示;单用户流程可正常提交与执行。 \ No newline at end of file diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go new file mode 100644 index 0000000..5940bf8 --- /dev/null +++ b/internal/api/admin/dashboard_admin.go @@ -0,0 +1,861 @@ +package admin + +import ( + "net/http" + "strconv" + "time" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" +) + +type cardsRequest struct { + RangeType string `form:"rangeType"` + StartDate string `form:"start"` + EndDate string `form:"end"` +} + +type cardStatResponse struct { + ItemCardSales int64 `json:"itemCardSales"` + DrawCount int64 `json:"drawCount"` + NewUsers int64 `json:"newUsers"` + TotalPoints int64 `json:"totalPoints"` + ItemCardChange string `json:"itemCardChange"` + DrawChange string `json:"drawChange"` + NewUserChange string `json:"newUserChange"` + PointsChange string `json:"pointsChange"` +} + +func (h *handler) DashboardCards() core.HandlerFunc { + return func(ctx core.Context) { + req := new(cardsRequest) + rsp := new(cardStatResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + start, end := parseRange(req.RangeType, req.StartDate, req.EndDate) + prevStart, prevEnd := previousWindow(start, end) + + icCur, err := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.UserItemCards.CreatedAt.Gte(start)). + Where(h.readDB.UserItemCards.CreatedAt.Lte(end)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21001, err.Error())); return } + icPrev, err := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.UserItemCards.CreatedAt.Gte(prevStart)). + Where(h.readDB.UserItemCards.CreatedAt.Lte(prevEnd)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21002, err.Error())); return } + + dlCur, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start)). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(end)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21003, err.Error())); return } + dlPrev, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(prevStart)). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(prevEnd)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21004, err.Error())); return } + + nuCur, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Users.CreatedAt.Gte(start)). + Where(h.readDB.Users.CreatedAt.Lte(end)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21005, err.Error())); return } + nuPrev, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Users.CreatedAt.Gte(prevStart)). + Where(h.readDB.Users.CreatedAt.Lte(prevEnd)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21006, err.Error())); return } + + rows, err := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().Find() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21007, err.Error())); return } + var tpCur int64 + now := time.Now() + for _, r := range rows { + if !r.ValidEnd.IsZero() && r.ValidEnd.Before(now) { + continue + } + tpCur += r.Points + } + // 使用积分流水计算净变动用于环比 + // 当前窗口净变动 + var curDeltaRows []struct{ Sum int64 } + if err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.UserPointsLedger.CreatedAt.Gte(start)). + Where(h.readDB.UserPointsLedger.CreatedAt.Lte(end)). + Select(h.readDB.UserPointsLedger.Points.Sum().As("sum")). + Scan(&curDeltaRows); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 21008, err.Error())); return + } + var curDelta int64 + if len(curDeltaRows) > 0 { curDelta = curDeltaRows[0].Sum } + // 前一窗口净变动 + var prevDeltaRows []struct{ Sum int64 } + if err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.UserPointsLedger.CreatedAt.Gte(prevStart)). + Where(h.readDB.UserPointsLedger.CreatedAt.Lte(prevEnd)). + Select(h.readDB.UserPointsLedger.Points.Sum().As("sum")). + Scan(&prevDeltaRows); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 21009, err.Error())); return + } + var prevDelta int64 + if len(prevDeltaRows) > 0 { prevDelta = prevDeltaRows[0].Sum } + + rsp.ItemCardSales = icCur + rsp.DrawCount = dlCur + rsp.NewUsers = nuCur + rsp.TotalPoints = tpCur + rsp.ItemCardChange = percentChange(icPrev, icCur) + rsp.DrawChange = percentChange(dlPrev, dlCur) + rsp.NewUserChange = percentChange(nuPrev, nuCur) + rsp.PointsChange = percentChange(prevDelta, curDelta) + ctx.Payload(rsp) + } +} + +type trendRequest struct { + RangeType string `form:"rangeType"` + Granularity string `form:"granularity"` +} + +type trendPoint struct { + Date string `json:"date"` + Value int64 `json:"value"` +} + +type userTrendResponse struct { + Granularity string `json:"granularity"` + List []trendPoint `json:"list"` +} + +func (h *handler) DashboardUserTrend() core.HandlerFunc { + return func(ctx core.Context) { + req := new(trendRequest) + rsp := new(userTrendResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + start, end := parseRange(req.RangeType, "", "") + gran := normalizeGranularity(req.Granularity) + buckets := buildBuckets(start, end, gran) + list := make([]trendPoint, 0, len(buckets)) + for _, b := range buckets { + c, err := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Users.CreatedAt.Gte(b.Start)). + Where(h.readDB.Users.CreatedAt.Lte(b.End)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21009, err.Error())); return } + list = append(list, trendPoint{Date: b.Label, Value: c}) + } + rsp.Granularity = gran + rsp.List = list + ctx.Payload(rsp) + } +} + +type drawTrendResponse struct { + Granularity string `json:"granularity"` + List []trendPoint `json:"list"` +} + +func (h *handler) DashboardDrawTrend() core.HandlerFunc { + return func(ctx core.Context) { + req := new(trendRequest) + rsp := new(drawTrendResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + start, end := parseRange(req.RangeType, "", "") + gran := normalizeGranularity(req.Granularity) + buckets := buildBuckets(start, end, gran) + list := make([]trendPoint, 0, len(buckets)) + for _, b := range buckets { + c, err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(b.Start)). + Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(b.End)). + Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21010, err.Error())); return } + list = append(list, trendPoint{Date: b.Label, Value: c}) + } + rsp.Granularity = gran + rsp.List = list + ctx.Payload(rsp) + } +} + +type newUsersRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Period string `form:"period"` +} + +type newUserItem struct { + ID int64 `json:"id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + CreatedAt string `json:"createdAt"` + PointsBalance int64 `json:"pointsBalance"` + InventoryCount int64 `json:"inventoryCount"` + ItemCardCount int64 `json:"itemCardCount"` + CouponCount int64 `json:"couponCount"` + TitleCount int64 `json:"titleCount"` + LastOnlineAt string `json:"lastOnlineAt"` + Titles []userTitleBrief `json:"titles"` +} + +type userTitleBrief struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type newUsersResponse struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + List []newUserItem `json:"list"` +} + +func (h *handler) DashboardNewUsers() core.HandlerFunc { + return func(ctx core.Context) { + req := new(newUsersRequest) + rsp := new(newUsersResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.Page <= 0 { req.Page = 1 } + if req.PageSize <= 0 { req.PageSize = 20 } + if req.PageSize > 100 { req.PageSize = 100 } + base := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB() + if req.Period != "" { + var s, e time.Time + now := time.Now() + switch req.Period { + case "month": + s = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + e = s.AddDate(0, 1, 0).Add(-time.Second) + case "last_month": + lm := now.AddDate(0, -1, 0) + s = time.Date(lm.Year(), lm.Month(), 1, 0, 0, 0, 0, lm.Location()) + e = s.AddDate(0, 1, 0).Add(-time.Second) + case "year": + s = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + e = time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location()) + } + if !s.IsZero() && !e.IsZero() { + base = base.Where(h.readDB.Users.CreatedAt.Gte(s)).Where(h.readDB.Users.CreatedAt.Lte(e)) + } + } + total, err := base.Count() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21011, err.Error())); return } + rows, err := base.Order(h.readDB.Users.ID.Desc()).Offset((req.Page-1)*req.PageSize).Limit(req.PageSize).Find() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21012, err.Error())); return } + // 收集用户ID做批量聚合 + ids := make([]int64, len(rows)) + for i, u := range rows { ids[i] = u.ID } + + // 批量:资产数 + type kvCount struct{ UserID int64; Cnt int64 } + invCounts := map[int64]int64{} + var invRows []kvCount + _ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserInventory.UserID, h.readDB.UserInventory.UserID.Count().As("cnt")). + Where(h.readDB.UserInventory.UserID.In(ids...)). + Group(h.readDB.UserInventory.UserID). + Scan(&invRows) + for _, r := range invRows { invCounts[r.UserID] = r.Cnt } + + // 批量:道具卡(有效) + icCounts := map[int64]int64{} + var icRows []kvCount + _ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.UserID.Count().As("cnt")). + Where(h.readDB.UserItemCards.UserID.In(ids...)). + Where(h.readDB.UserItemCards.Status.Eq(1)). + Group(h.readDB.UserItemCards.UserID). + Scan(&icRows) + for _, r := range icRows { icCounts[r.UserID] = r.Cnt } + + // 批量:优惠券 + cpCounts := map[int64]int64{} + var cpRows []kvCount + _ = h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.UserID.Count().As("cnt")). + Where(h.readDB.UserCoupons.UserID.In(ids...)). + Group(h.readDB.UserCoupons.UserID). + Scan(&cpRows) + for _, r := range cpRows { cpCounts[r.UserID] = r.Cnt } + + // 批量:称号列表 + type titleRow struct{ UserID int64; ID int64; Name string } + titleMap := map[int64][]userTitleBrief{} + var trows []titleRow + _ = h.readDB.UserTitles.WithContext(ctx.RequestContext()).ReadDB(). + LeftJoin(h.readDB.SystemTitles, h.readDB.SystemTitles.ID.EqCol(h.readDB.UserTitles.TitleID)). + Select(h.readDB.UserTitles.UserID, h.readDB.UserTitles.TitleID.As("id"), h.readDB.SystemTitles.Name). + Where(h.readDB.UserTitles.UserID.In(ids...)). + Scan(&trows) + for _, tr := range trows { titleMap[tr.UserID] = append(titleMap[tr.UserID], userTitleBrief{ID: tr.ID, Name: tr.Name}) } + + // 批量:有效积分余额 + pointsMap := map[int64]int64{} + now := time.Now() + pRows, _ := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.UserPoints.UserID.In(ids...)). + Find() + for _, r := range pRows { + if !r.ValidEnd.IsZero() && r.ValidEnd.Before(now) { continue } + pointsMap[r.UserID] += r.Points + } + + // 批量:最后在线(各表 MAX) + type tsRow struct{ UserID int64; Ts time.Time } + maxMap := map[int64]time.Time{} + mergeMax := func(rows []tsRow) { for _, r := range rows { if r.Ts.After(maxMap[r.UserID]) { maxMap[r.UserID] = r.Ts } } } + var dlMax, odMax, paidMax, plMax, icMax, ivMax []tsRow + _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.ActivityDrawLogs.UserID, h.readDB.ActivityDrawLogs.CreatedAt.Max().As("ts")). + Where(h.readDB.ActivityDrawLogs.UserID.In(ids...)). + Group(h.readDB.ActivityDrawLogs.UserID). + Scan(&dlMax) + mergeMax(dlMax) + _ = h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.Orders.UserID, h.readDB.Orders.UpdatedAt.Max().As("ts")). + Where(h.readDB.Orders.UserID.In(ids...)). + Group(h.readDB.Orders.UserID). + Scan(&odMax) + mergeMax(odMax) + _ = h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.Orders.UserID, h.readDB.Orders.PaidAt.Max().As("ts")). + Where(h.readDB.Orders.UserID.In(ids...)). + Group(h.readDB.Orders.UserID). + Scan(&paidMax) + mergeMax(paidMax) + _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserPointsLedger.UserID, h.readDB.UserPointsLedger.CreatedAt.Max().As("ts")). + Where(h.readDB.UserPointsLedger.UserID.In(ids...)). + Group(h.readDB.UserPointsLedger.UserID). + Scan(&plMax) + mergeMax(plMax) + _ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserItemCards.UserID, h.readDB.UserItemCards.UpdatedAt.Max().As("ts")). + Where(h.readDB.UserItemCards.UserID.In(ids...)). + Group(h.readDB.UserItemCards.UserID). + Scan(&icMax) + mergeMax(icMax) + _ = h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB(). + Select(h.readDB.UserInventory.UserID, h.readDB.UserInventory.UpdatedAt.Max().As("ts")). + Where(h.readDB.UserInventory.UserID.In(ids...)). + Group(h.readDB.UserInventory.UserID). + Scan(&ivMax) + mergeMax(ivMax) + + list := make([]newUserItem, len(rows)) + for i, u := range rows { + list[i] = newUserItem{ + ID: u.ID, + Nickname: u.Nickname, + Avatar: u.Avatar, + CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + PointsBalance: pointsMap[u.ID], + InventoryCount: invCounts[u.ID], + ItemCardCount: icCounts[u.ID], + CouponCount: cpCounts[u.ID], + TitleCount: int64(len(titleMap[u.ID])), + LastOnlineAt: func() string { t := maxMap[u.ID]; if t.IsZero() { return "" } ; return t.Format("2006-01-02T15:04:05Z07:00") }(), + Titles: titleMap[u.ID], + } + } + rsp.Page = req.Page + rsp.PageSize = req.PageSize + rsp.Total = total + rsp.List = list + ctx.Payload(rsp) + } +} + +type drawStreamRequest struct { + SinceID *int64 `form:"since_id"` + Limit int `form:"limit"` +} + +type drawStreamItem struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Nickname string `json:"nickname"` + IssueID int64 `json:"issueId"` + IssueName string `json:"issueName"` + ActivityName string `json:"activityName"` + IssueNumber string `json:"issueNumber"` + PrizeName string `json:"prizeName"` + IsWinner int32 `json:"isWinner"` + CreatedAt string `json:"createdAt"` +} + +type drawStreamResponse struct { + List []drawStreamItem `json:"list"` + SinceID *int64 `json:"sinceId,omitempty"` +} + +func (h *handler) DashboardDrawStream() core.HandlerFunc { + return func(ctx core.Context) { + req := new(drawStreamRequest) + rsp := new(drawStreamResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.Limit <= 0 { req.Limit = 50 } + if req.Limit > 100 { req.Limit = 100 } + q := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB() + if req.SinceID != nil { + q = q.Where(h.readDB.ActivityDrawLogs.ID.Gt(*req.SinceID)) + } + type row struct { + ID int64 + UserID int64 + Nickname string + IssueID int64 + IssueName string + ActivityName string + IssueNumber string + PrizeName string + IsWinner int32 + CreatedAt time.Time + } + var rows []row + err := q. + LeftJoin(h.readDB.Users, h.readDB.Users.ID.EqCol(h.readDB.ActivityDrawLogs.UserID)). + LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)). + LeftJoin(h.readDB.Activities, h.readDB.Activities.ID.EqCol(h.readDB.ActivityIssues.ActivityID)). + LeftJoin(h.readDB.ActivityRewardSettings, h.readDB.ActivityRewardSettings.ID.EqCol(h.readDB.ActivityDrawLogs.RewardID)). + Select( + h.readDB.ActivityDrawLogs.ID, + h.readDB.ActivityDrawLogs.UserID, + h.readDB.Users.Nickname, + h.readDB.ActivityDrawLogs.IssueID, + h.readDB.Activities.Name.As("activity_name"), + h.readDB.ActivityIssues.IssueNumber.As("issue_number"), + h.readDB.ActivityRewardSettings.Name.As("prize_name"), + h.readDB.ActivityDrawLogs.IsWinner, + h.readDB.ActivityDrawLogs.CreatedAt, + ). + Order(h.readDB.ActivityDrawLogs.ID.Desc()). + Limit(req.Limit). + Scan(&rows) + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21016, err.Error())); return } + list := make([]drawStreamItem, len(rows)) + var maxID int64 + for i, v := range rows { + iname := v.ActivityName + if iname == "" { iname = v.IssueName } + list[i] = drawStreamItem{ + ID: v.ID, + UserID: v.UserID, + Nickname: v.Nickname, + IssueID: v.IssueID, + IssueName: func() string { if v.ActivityName != "" && v.IssueNumber != "" { return v.ActivityName + "-" + v.IssueNumber } ; return iname }(), + ActivityName: v.ActivityName, + IssueNumber: v.IssueNumber, + PrizeName: v.PrizeName, + IsWinner: v.IsWinner, + CreatedAt: v.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if v.ID > maxID { maxID = v.ID } + } + if len(rows) > 0 { + rsp.SinceID = &maxID + } + rsp.List = list + ctx.Payload(rsp) + } +} + +type todosRequest struct { + Limit int `form:"limit"` +} + +type todoItem struct { + UserID int64 `json:"userId"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + TaskType string `json:"taskType"` + TaskLabel string `json:"taskLabel"` +} + +type todosResponse struct { + List []todoItem `json:"list"` +} + +func (h *handler) DashboardTodos() core.HandlerFunc { + return func(ctx core.Context) { + req := new(todosRequest) + rsp := new(todosResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.Limit <= 0 { req.Limit = 50 } + if req.Limit > 100 { req.Limit = 100 } + base := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Order(h.readDB.Users.ID.Desc()) + rows, err := base.Limit(req.Limit).Find() + if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 21017, err.Error())); return } + out := make([]todoItem, 0, len(rows)) + for _, u := range rows { + dlCount, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.UserID.Eq(u.ID)).Count() + if dlCount == 0 { + out = append(out, todoItem{UserID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar, TaskType: "undrawn", TaskLabel: "从未参与抽奖"}) + } + } + rsp.List = out + ctx.Payload(rsp) + } +} + +func parseRange(rangeType, startS, endS string) (time.Time, time.Time) { + now := time.Now() + switch rangeType { + case "today": + s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + e := s.Add(24 * time.Hour).Add(-time.Second) + return s, e + case "30d": + e := now + s := e.Add(-30 * 24 * time.Hour) + return s, e + case "custom": + if startS != "" && endS != "" { + if st, err := time.Parse("2006-01-02", startS); err == nil { + if et, err := time.Parse("2006-01-02", endS); err == nil { + et = et.Add(24 * time.Hour).Add(-time.Second) + if et.Sub(st) > 31*24*time.Hour { + et = st.Add(30*24*time.Hour).Add(-time.Second) + } + return st, et + } + } + } + fallthrough + default: + e := now + s := e.Add(-7 * 24 * time.Hour) + return s, e + } +} + +func previousWindow(start, end time.Time) (time.Time, time.Time) { + dur := end.Sub(start) + time.Second + ps := start.Add(-dur) + pe := start.Add(-time.Second) + return ps, pe +} + +func percentChange(prev, cur int64) string { + if prev <= 0 { return "+0%" } + diff := float64(cur-prev) / float64(prev) * 100 + if diff >= 0 { + return "+" + strconv.Itoa(int(diff)) + "%" + } + return "-" + strconv.Itoa(int(-diff)) + "%" +} + +func daysBetween(start, end time.Time) []time.Time { + s := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + e := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location()) + var out []time.Time + for d := s; !d.After(e); d = d.Add(24 * time.Hour) { + out = append(out, d) + } + return out +} + +type bucket struct { + Label string + Start time.Time + End time.Time +} + +func normalizeGranularity(in string) string { + switch in { + case "week": + return "week" + case "month": + return "month" + default: + return "day" + } +} + +func buildBuckets(start, end time.Time, gran string) []bucket { + if gran == "day" { + days := daysBetween(start, end) + out := make([]bucket, 0, len(days)) + for _, d := range days { + ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) + de := ds.Add(24 * time.Hour).Add(-time.Second) + out = append(out, bucket{Label: ds.Format("2006-01-02"), Start: ds, End: de}) + } + return out + } + if gran == "week" { + s := start + for s.Weekday() != time.Monday { + s = s.Add(-24 * time.Hour) + } + out := []bucket{} + for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.Add(7 * 24 * time.Hour) { + ds := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location()) + de := ds.Add(7*24*time.Hour).Add(-time.Second) + label := ds.Format("2006-01-02") + if de.After(end) { de = end } + out = append(out, bucket{Label: label, Start: ds, End: de}) + if de.Equal(end) { break } + } + return out + } + s := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, start.Location()) + out := []bucket{} + for cur := s; cur.Before(end) || cur.Equal(end); cur = cur.AddDate(0, 1, 0) { + ds := time.Date(cur.Year(), cur.Month(), 1, 0, 0, 0, 0, cur.Location()) + de := ds.AddDate(0, 1, 0).Add(-time.Second) + label := ds.Format("2006-01") + if de.After(end) { de = end } + out = append(out, bucket{Label: label, Start: ds, End: de}) + if de.Equal(end) { break } + } + return out +} + +type funnelItem struct { + Stage string `json:"stage"` + Count int64 `json:"count"` + Rate float64 `json:"rate"` + LostCount int64 `json:"lostCount"` +} + +func (h *handler) DashboardOrderFunnel() core.HandlerFunc { + return func(ctx core.Context) { + s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), ctx.Request().URL.Query().Get("start"), ctx.Request().URL.Query().Get("end")) + visitors, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.LogRequest.CreatedAt.Gte(s)). + Where(h.readDB.LogRequest.CreatedAt.Lte(e)). + Where(h.readDB.LogRequest.Path.Like("/api/app/%")). + Count() + orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Orders.CreatedAt.Gte(s)). + Where(h.readDB.Orders.CreatedAt.Lte(e)). + Count() + payments, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Orders.Status.Eq(2)). + Where(h.readDB.Orders.PaidAt.Gte(s)). + Where(h.readDB.Orders.PaidAt.Lte(e)). + Count() + shipped, _ := h.readDB.ShippingRecords.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.ShippingRecords.Status.In(2, 3)). + Where(h.readDB.ShippingRecords.UpdatedAt.Gte(s)). + Where(h.readDB.ShippingRecords.UpdatedAt.Lte(e)). + Count() + consumed, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB(). + Where(h.readDB.Orders.Status.Eq(2), h.readDB.Orders.IsConsumed.Eq(1)). + Where(h.readDB.Orders.UpdatedAt.Gte(s)). + Where(h.readDB.Orders.UpdatedAt.Lte(e)). + Count() + completions := shipped + consumed + stages := []struct{ name string; val int64 }{ + {"访问用户", visitors}, {"下单用户", orders}, {"支付用户", payments}, {"完成订单", completions}, + } + out := make([]funnelItem, 0, len(stages)) + var prev int64 + for i, st := range stages { + var rate float64 + var lost int64 + if i == 0 { + rate = 100 + lost = 0 + } else { + if prev > 0 { rate = float64(st.val) / float64(prev) * 100 } + lost = prev - st.val + if lost < 0 { lost = 0 } + } + out = append(out, funnelItem{Stage: st.name, Count: st.val, Rate: float64(int(rate*10)) / 10.0, LostCount: lost}) + prev = st.val + } + ctx.Payload(out) + } +} + +type activitiesItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Status string `json:"status"` + TotalDraws int64 `json:"totalDraws"` + TotalParticipants int64 `json:"totalParticipants"` +} + +func (h *handler) DashboardActivities() core.HandlerFunc { + return func(ctx core.Context) { + rows, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Find() + out := make([]activitiesItem, len(rows)) + for i, a := range rows { + issues, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityIssues.ActivityID.Eq(a.ID)).Find() + var drawTotal int64 + var participants int64 + for _, iss := range issues { + dt, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(iss.ID)).Count() + drawTotal += dt + pc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(iss.ID)).Distinct(h.readDB.ActivityDrawLogs.UserID).Count() + participants += pc + } + status := "active" + if a.EndTime.Before(time.Now()) { status = "ended" } + out[i] = activitiesItem{ID: a.ID, Name: a.Name, Type: "转盘抽奖", StartTime: a.StartTime.Format("2006-01-02 15:04:05"), EndTime: a.EndTime.Format("2006-01-02 15:04:05"), Status: status, TotalDraws: drawTotal, TotalParticipants: participants} + } + ctx.Payload(out) + } +} + +type activityPrize struct { + PrizeID int64 `json:"prizeId"` + PrizeName string `json:"prizeName"` + PrizeLevel int32 `json:"prizeLevel"` + PrizeType string `json:"prizeType"` + PrizeValue int64 `json:"prizeValue"` + TotalQuantity int64 `json:"totalQuantity"` + IssuedQuantity int64 `json:"issuedQuantity"` + DrawCount int64 `json:"drawCount"` + WinCount int64 `json:"winCount"` + WinRate float64 `json:"winRate"` + Probability float64 `json:"probability"` + ActualProbability float64 `json:"actualProbability"` + Cost int64 `json:"cost"` +} + +type activityInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Status string `json:"status"` + TotalDraws int64 `json:"totalDraws"` + TotalParticipants int64 `json:"totalParticipants"` +} + +type activityPrizeAnalysis struct { + Activity activityInfo `json:"activity"` + Prizes []activityPrize `json:"prizes"` + Summary struct { + TotalCost int64 `json:"totalCost"` + AvgWinRate float64 `json:"avgWinRate"` + MaxWinRate float64 `json:"maxWinRate"` + MinWinRate float64 `json:"minWinRate"` + } `json:"summary"` +} + +func (h *handler) DashboardActivityPrizeAnalysis() core.HandlerFunc { + return func(ctx core.Context) { + aidS := ctx.Request().URL.Query().Get("activity_id") + if aidS == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "缺少活动ID")); return } + var aid int64 + if v, err := strconv.ParseInt(aidS, 10, 64); err == nil { aid = v } else { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID格式错误")); return } + issues, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityIssues.ActivityID.Eq(aid)).Find() + ids := make([]int64, len(issues)) + for i, v := range issues { ids[i] = v.ID } + rsAll, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.In(ids...)).Find() + var sumWeight int64 + for _, r := range rsAll { sumWeight += int64(r.Weight) } + prizes := make([]activityPrize, len(rsAll)) + var totalCost int64 + var winRates []float64 + var totalDraws int64 + var participants int64 + for i, r := range rsAll { + dc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(r.IssueID)).Count() + wc, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.Eq(r.IssueID), h.readDB.ActivityDrawLogs.RewardID.Eq(r.ID), h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).Count() + var price int64 + if r.ProductID != 0 { + p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.Eq(r.ProductID)).First() + if p != nil { price = p.Price } + } + issued := r.OriginalQty - r.Quantity + var prob float64 + if sumWeight > 0 { prob = float64(r.Weight) / float64(sumWeight) * 100 } + var actual float64 + if dc > 0 { actual = float64(wc) / float64(dc) * 100 } + prizes[i] = activityPrize{PrizeID: r.ID, PrizeName: r.Name, PrizeLevel: r.Level, PrizeType: func() string { if r.ProductID != 0 { return "实物奖品" } ; return "虚拟/道具" }(), PrizeValue: price, TotalQuantity: r.OriginalQty, IssuedQuantity: issued, DrawCount: dc, WinCount: wc, WinRate: func() float64 { if dc > 0 { return float64(wc) / float64(dc) * 100 } ; return 0 }(), Probability: prob, ActualProbability: actual, Cost: price } + totalCost += price * wc + winRates = append(winRates, prizes[i].WinRate) + totalDraws += dc + } + participants, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.IssueID.In(ids...)).Distinct(h.readDB.ActivityDrawLogs.UserID).Count() + act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Activities.ID.Eq(aid)).First() + status := "active" + if act != nil && act.EndTime.Before(time.Now()) { status = "ended" } + var avg float64 + if len(winRates) > 0 { var sum float64; for _, w := range winRates { sum += w } ; avg = sum / float64(len(winRates)) } + var maxW, minW float64 + if len(winRates) > 0 { maxW = winRates[0]; minW = winRates[0]; for _, w := range winRates { if w > maxW { maxW = w } ; if w < minW { minW = w } } } + out := activityPrizeAnalysis{} + if act != nil { + out.Activity = activityInfo{ID: act.ID, Name: act.Name, Type: "转盘抽奖", StartTime: act.StartTime.Format("2006-01-02 15:04:05"), EndTime: act.EndTime.Format("2006-01-02 15:04:05"), Status: status, TotalDraws: totalDraws, TotalParticipants: participants} + } + out.Prizes = prizes + out.Summary.TotalCost = totalCost + out.Summary.AvgWinRate = avg + out.Summary.MaxWinRate = maxW + out.Summary.MinWinRate = minW + ctx.Payload(out) + } +} + +type userOverviewResponse struct { + Chart []struct{ Date string `json:"date"`; Value int64 `json:"value"` } `json:"chart"` + Metrics struct { + TotalUsers int64 `json:"totalUsers"` + TotalVisits int64 `json:"totalVisits"` + DailyVisits int64 `json:"dailyVisits"` + WeeklyGrowth string `json:"weeklyGrowth"` + } `json:"metrics"` +} + +func (h *handler) DashboardUserOverview() core.HandlerFunc { + return func(ctx core.Context) { + totalUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Count() + todayS, todayE := parseRange("today", "", "") + dailyVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(todayS)).Where(h.readDB.LogRequest.CreatedAt.Lte(todayE)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count() + s7, e7 := parseRange("7d", "", "") + lastS := s7.Add(-7 * 24 * time.Hour) + lastE := s7.Add(-time.Second) + curVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(s7)).Where(h.readDB.LogRequest.CreatedAt.Lte(e7)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count() + prevVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.CreatedAt.Gte(lastS)).Where(h.readDB.LogRequest.CreatedAt.Lte(lastE)).Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count() + var wg string + if prevVisits > 0 { diff := float64(curVisits-prevVisits) / float64(prevVisits) * 100 ; if diff >= 0 { wg = "+" + strconv.Itoa(int(diff)) + "%" } else { wg = "-" + strconv.Itoa(int(-diff)) + "%" } } else { wg = "+0%" } + totalVisits, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.LogRequest.Path.Like("/api/app/%")).Count() + type chartPoint struct{ Date string `json:"date"`; Value int64 `json:"value"` } + chart := []chartPoint{} + now := time.Now() + for i := 8; i >= 0; i-- { + dt := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location()) + dn := dt.AddDate(0, 1, 0).Add(-time.Second) + c, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.CreatedAt.Gte(dt)).Where(h.readDB.Users.CreatedAt.Lte(dn)).Count() + chart = append(chart, chartPoint{Date: dt.Format("2006-01"), Value: c}) + } + out := userOverviewResponse{} + out.Chart = make([]struct{ Date string `json:"date"`; Value int64 `json:"value"` }, len(chart)) + for i := range chart { out.Chart[i] = struct{ Date string `json:"date"`; Value int64 `json:"value"` }{Date: chart[i].Date, Value: chart[i].Value} } + out.Metrics.TotalUsers = totalUsers + out.Metrics.TotalVisits = totalVisits + out.Metrics.DailyVisits = dailyVisits + out.Metrics.WeeklyGrowth = wg + ctx.Payload(out) + } +} \ No newline at end of file diff --git a/internal/api/admin/dashboard_admin_test.go b/internal/api/admin/dashboard_admin_test.go new file mode 100644 index 0000000..e598670 --- /dev/null +++ b/internal/api/admin/dashboard_admin_test.go @@ -0,0 +1,26 @@ +package admin + +import ( + "testing" + "time" +) + +func TestPercentChange(t *testing.T) { + if s := percentChange(0, 10); s != "+0%" { t.Fatalf("prev0") } + if s := percentChange(10, 15); s == "" { t.Fatalf("empty") } +} + +func TestPreviousWindow(t *testing.T) { + s := time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local) + e := s.Add(7*24*time.Hour).Add(-time.Second) + ps, pe := previousWindow(s, e) + if !pe.Before(s) { t.Fatalf("pe not before start") } + if ps.After(pe) { t.Fatalf("ps after pe") } +} + +func TestDaysBetween(t *testing.T) { + s := time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local) + e := time.Date(2025, 1, 03, 23, 59, 59, 0, time.Local) + ds := daysBetween(s, e) + if len(ds) != 3 { t.Fatalf("expect3") } +} \ No newline at end of file diff --git a/internal/api/admin/titles_admin.go b/internal/api/admin/titles_admin.go index f50f1c5..56811ce 100644 --- a/internal/api/admin/titles_admin.go +++ b/internal/api/admin/titles_admin.go @@ -441,6 +441,11 @@ func (h *handler) AssignUserTitle() core.HandlerFunc { Where(h.readDB.UserTitles.UserID.Eq(userID)). Where(h.readDB.UserTitles.TitleID.Eq(req.TitleID)).First() if err == nil && ut != nil { + now := time.Now() + if ut.Active == 1 && (ut.ExpiresAt.IsZero() || ut.ExpiresAt.After(now)) { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30117, "该用户已拥有该称号")) + return + } // 更新 ut.Active = 1 ut.Remark = req.Remark diff --git a/internal/api/admin/titles_admin_test.go b/internal/api/admin/titles_admin_test.go new file mode 100644 index 0000000..f0540bb --- /dev/null +++ b/internal/api/admin/titles_admin_test.go @@ -0,0 +1,23 @@ +package admin + +import ( + "testing" +) + +func TestValidateEffectParams_Type5(t *testing.T) { + raw := `{"target_prize_ids":[1,1,2],"boost_x1000":200,"cap_x1000":300}` + out, err := validateEffectParams(5, raw) + if err != nil { t.Fatalf("unexpected error: %v", err) } + // expect dedup ids and valid json + if len(out) == 0 { t.Fatalf("sanitized json empty") } +} + +func TestValidateEffectParams_Type6(t *testing.T) { + raw := `{"target_prize_ids":[1,2],"chance_x1000":1000,"period_cap_times":1}` + out, err := validateEffectParams(6, raw) + if err != nil { t.Fatalf("unexpected error: %v", err) } + if len(out) == 0 { t.Fatalf("sanitized json empty") } + bad := `{"target_prize_ids":[],"chance_x1000":-1}` + _, err = validateEffectParams(6, bad) + if err == nil { t.Fatalf("expected error for negative chance") } +} \ No newline at end of file diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go index 20a85e4..5593931 100644 --- a/internal/api/admin/users_admin.go +++ b/internal/api/admin/users_admin.go @@ -1,15 +1,15 @@ package admin import ( - "net/http" - "strconv" - "time" + "net/http" + "strconv" + "time" - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" - "bindbox-game/internal/repository/mysql/model" - "bindbox-game/internal/service/user" + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/service/user" ) type listUsersRequest struct { @@ -578,7 +578,7 @@ type addCouponRequest struct { CouponID int64 `json:"coupon_id" binding:"required"` } type addCouponResponse struct { - Success bool `json:"success"` + Success bool `json:"success"` } // AddUserCoupon 给用户添加优惠券 @@ -618,3 +618,65 @@ func (h *handler) AddUserCoupon() core.HandlerFunc { ctx.Payload(rsp) } } + +type adminUserTitleItem struct { + ID int64 `json:"id"` + TitleID int64 `json:"title_id"` + Name string `json:"name"` + Description string `json:"description"` + ObtainedAt string `json:"obtained_at"` + ExpiresAt string `json:"expires_at"` + Status int32 `json:"status"` +} + +type listUserTitlesResponse struct { + List []adminUserTitleItem `json:"list"` +} + +func (h *handler) ListUserTitles() core.HandlerFunc { + return func(ctx core.Context) { + rsp := new(listUserTitlesResponse) + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) + return + } + type row struct { + ID int64 + TitleID int64 + Active int32 + ObtainedAt *string + ExpiresAt *string + Name string + Description string + } + q := h.readDB.UserTitles.WithContext(ctx.RequestContext()).ReadDB(). + LeftJoin(h.readDB.SystemTitles, h.readDB.SystemTitles.ID.EqCol(h.readDB.UserTitles.TitleID)). + Select( + h.readDB.UserTitles.ID, h.readDB.UserTitles.TitleID, h.readDB.UserTitles.Active, + h.readDB.UserTitles.ObtainedAt, h.readDB.UserTitles.ExpiresAt, + h.readDB.SystemTitles.Name, h.readDB.SystemTitles.Description, + ). + Where(h.readDB.UserTitles.UserID.Eq(userID)). + Order(h.readDB.UserTitles.ID.Desc()) + + var rows []row + if err := q.Scan(&rows); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 20110, err.Error())) + return + } + rsp.List = make([]adminUserTitleItem, len(rows)) + for i, v := range rows { + rsp.List[i] = adminUserTitleItem{ + ID: v.ID, + TitleID: v.TitleID, + Name: v.Name, + Description: v.Description, + ObtainedAt: nullableToString(v.ObtainedAt), + ExpiresAt: nullableToString(v.ExpiresAt), + Status: v.Active, + } + } + ctx.Payload(rsp) + } +} diff --git a/internal/api/admin/users_batch_admin.go b/internal/api/admin/users_batch_admin.go new file mode 100644 index 0000000..f201031 --- /dev/null +++ b/internal/api/admin/users_batch_admin.go @@ -0,0 +1,144 @@ +package admin + +import ( + "net/http" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + usersvc "bindbox-game/internal/service/user" +) + +type batchPointsRequest struct { + Users []int64 `json:"users" binding:"required"` + Amount int64 `json:"amount" binding:"min=1"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotency_key"` +} + +type batchCouponsRequest struct { + Users []int64 `json:"users" binding:"required"` + CouponID int64 `json:"coupon_id" binding:"required"` + QuantityPerUser int `json:"quantity_per_user"` + IdempotencyKey string `json:"idempotency_key"` +} + +type batchRewardsRequest struct { + Users []int64 `json:"users" binding:"required"` + ProductID int64 `json:"product_id" binding:"required"` + Quantity int `json:"quantity" binding:"min=1"` + ActivityID *int64 `json:"activity_id"` + RewardID *int64 `json:"reward_id"` + AddressID *int64 `json:"address_id"` + Remark string `json:"remark"` + IdempotencyKey string `json:"idempotency_key"` +} + +type batchItemResult struct { + UserID int64 `json:"user_id"` + Status string `json:"status"` + Message string `json:"message"` +} + +type batchResponse struct { + Success int `json:"success"` + Failed int `json:"failed"` + Details []batchItemResult `json:"details"` +} + +func (h *handler) BatchAddUserPoints() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + req := new(batchPointsRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if len(req.Users) != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作,请仅选择1位用户")) + return + } + res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))} + for _, uid := range req.Users { + if err := h.user.AddPoints(ctx.RequestContext(), uid, req.Amount, "manual", req.Reason, nil, nil); err != nil { + res.Failed++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()}) + } else { + res.Success++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"}) + } + } + ctx.Payload(res) + } +} + +func (h *handler) BatchAddUserCoupons() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + req := new(batchCouponsRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if len(req.Users) != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作,请仅选择1位用户")) + return + } + if req.QuantityPerUser <= 0 { req.QuantityPerUser = 1 } + if req.QuantityPerUser > 5 { req.QuantityPerUser = 5 } + res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))} + for _, uid := range req.Users { + ok := true + for i := 0; i < req.QuantityPerUser; i++ { + if err := h.user.AddCoupon(ctx.RequestContext(), uid, req.CouponID); err != nil { ok = false } + } + if ok { + res.Success++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"}) + } else { + res.Failed++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed"}) + } + } + ctx.Payload(res) + } +} + +func (h *handler) BatchGrantUserRewards() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + req := new(batchRewardsRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if len(req.Users) != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作,请仅选择1位用户")) + return + } + if req.Quantity <= 0 { req.Quantity = 1 } + if req.Quantity > 10 { req.Quantity = 10 } + res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))} + r := usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, ActivityID: req.ActivityID, RewardID: req.RewardID, AddressID: req.AddressID, Remark: req.Remark} + for _, uid := range req.Users { + _, err := h.user.GrantReward(ctx.RequestContext(), uid, r) + if err != nil { + res.Failed++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()}) + } else { + res.Success++ + res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"}) + } + } + ctx.Payload(res) + } +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 869aa8f..d45a736 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -61,6 +61,18 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { // 管理员账号维护接口移除(未被前端使用) adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories()) + + // 工作台 + adminAuthApiRouter.GET("/dashboard/cards", adminHandler.DashboardCards()) + adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend()) + adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend()) + adminAuthApiRouter.GET("/dashboard/new_users", adminHandler.DashboardNewUsers()) + adminAuthApiRouter.GET("/dashboard/draw_stream", adminHandler.DashboardDrawStream()) + adminAuthApiRouter.GET("/dashboard/todos", adminHandler.DashboardTodos()) + adminAuthApiRouter.GET("/dashboard/order_funnel", adminHandler.DashboardOrderFunnel()) + adminAuthApiRouter.GET("/dashboard/activities", adminHandler.DashboardActivities()) + adminAuthApiRouter.GET("/dashboard/activity_prize_analysis", adminHandler.DashboardActivityPrizeAnalysis()) + adminAuthApiRouter.GET("/dashboard/user_overview", adminHandler.DashboardUserOverview()) adminAuthApiRouter.POST("/activities", adminHandler.CreateActivity()) adminAuthApiRouter.PUT("/activities/:activity_id", adminHandler.ModifyActivity()) adminAuthApiRouter.DELETE("/activities/:activity_id", adminHandler.DeleteActivity()) @@ -117,6 +129,10 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminAuthApiRouter.POST("/users/:user_id/points/add", adminHandler.AddUserPoints()) adminAuthApiRouter.POST("/users/:user_id/coupons/add", adminHandler.AddUserCoupon()) adminAuthApiRouter.POST("/users/:user_id/rewards/grant", adminHandler.GrantReward()) + adminAuthApiRouter.GET("/users/:user_id/titles", adminHandler.ListUserTitles()) + adminAuthApiRouter.POST("/users/batch/points/add", adminHandler.BatchAddUserPoints()) + adminAuthApiRouter.POST("/users/batch/coupons/add", adminHandler.BatchAddUserCoupons()) + adminAuthApiRouter.POST("/users/batch/rewards/grant", adminHandler.BatchGrantUserRewards()) adminAuthApiRouter.GET("/users/:user_id/inventory", adminHandler.ListUserInventory()) adminAuthApiRouter.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards()) // 系统称号与分配 diff --git a/internal/service/user/coupon_add.go b/internal/service/user/coupon_add.go index f7867a3..5f5c8da 100644 --- a/internal/service/user/coupon_add.go +++ b/internal/service/user/coupon_add.go @@ -5,6 +5,7 @@ import ( "time" "bindbox-game/internal/repository/mysql/model" + "gorm.io/gorm" ) func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) error { @@ -15,6 +16,28 @@ func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) e if tpl == nil || tpl.Status != 1 { return nil } + // 配额检查:若 TotalQuantity > 0 则限制发放总量 + if tpl.TotalQuantity > 0 { + issued, ierr := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.CouponID.Eq(couponID)).Count() + if ierr != nil { + return ierr + } + if issued >= tpl.TotalQuantity { + return gorm.ErrInvalidData + } + } + // 用户持有上限:同模板未使用的数量最多1张 + exist, eerr := s.readDB.UserCoupons.WithContext(ctx). + Where(s.readDB.UserCoupons.UserID.Eq(userID)). + Where(s.readDB.UserCoupons.CouponID.Eq(couponID)). + Where(s.readDB.UserCoupons.Status.Eq(1)). + Count() + if eerr != nil { + return eerr + } + if exist > 0 { + return gorm.ErrInvalidData + } item := &model.UserCoupons{UserID: userID, CouponID: couponID, Status: 1} if !tpl.ValidStart.IsZero() { item.ValidStart = tpl.ValidStart diff --git a/internal/service/user/item_card_add.go b/internal/service/user/item_card_add.go index fdd39f9..97df25a 100644 --- a/internal/service/user/item_card_add.go +++ b/internal/service/user/item_card_add.go @@ -5,6 +5,7 @@ import ( "time" "bindbox-game/internal/repository/mysql/model" + "gorm.io/gorm" ) // AddItemCard 给用户添加道具卡 @@ -23,9 +24,8 @@ import ( // 返回说明: // - error: 错误信息,包括数据库查询失败、模板不存在等情况 func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, quantity int) error { - if quantity <= 0 { - quantity = 1 - } + if quantity <= 0 { quantity = 1 } + if quantity > 100 { quantity = 100 } tpl, err := s.readDB.SystemItemCards.WithContext(ctx).Where(s.readDB.SystemItemCards.ID.Eq(cardID)).First() if err != nil { return err @@ -33,6 +33,14 @@ func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, q if tpl == nil || tpl.Status != 1 { return nil } + // 用户持有上限:同模板未使用数量最多10张 + exist, eerr := s.readDB.UserItemCards.WithContext(ctx). + Where(s.readDB.UserItemCards.UserID.Eq(userID)). + Where(s.readDB.UserItemCards.CardID.Eq(cardID)). + Where(s.readDB.UserItemCards.Status.Eq(1)). + Count() + if eerr != nil { return eerr } + if exist >= 10 { return gorm.ErrInvalidData } now := time.Now() for i := 0; i < quantity; i++ { item := &model.UserItemCards{UserID: userID, CardID: cardID, Status: 1} diff --git a/tests/collections/admin_blindbox.postman_collection.json b/tests/collections/admin_blindbox.postman_collection.json new file mode 100644 index 0000000..27b6d3b --- /dev/null +++ b/tests/collections/admin_blindbox.postman_collection.json @@ -0,0 +1,180 @@ +{ + "info": { + "name": "Blindbox Admin Collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Admin Login", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/login", "host": ["{{base_url}}"], "path": ["api","admin","login"]}, + "body": {"mode": "raw", "raw": "{\n \"username\": \"admin\",\n \"password\": \"chat2025\"\n}"} + }, + "response": [] + }, + { + "name": "Create Activity", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/activities", "host": ["{{base_url}}"], "path": ["api","admin","activities"]}, + "body": {"mode": "raw", "raw": "{\n \"name\": \"测试活动\",\n \"category_id\": 1\n}"} + } + }, + { + "name": "Create Issue", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/activities/{{activity_id}}/issues", "host": ["{{base_url}}"], "path": ["api","admin","activities","{{activity_id}}","issues"]}, + "body": {"mode": "raw", "raw": "{\n \"issue_number\": \"2025-01\"\n}"} + } + }, + { + "name": "Add Reward", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/activities/{{activity_id}}/issues/{{issue_id}}/rewards", "host": ["{{base_url}}"], "path": ["api","admin","activities","{{activity_id}}","issues","{{issue_id}}","rewards"]}, + "body": {"mode": "raw", "raw": "{\n \"name\": \"A\",\n \"weight\": 100\n}"} + } + }, + { + "name": "Commit Random", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"} + ], + "url": {"raw": "{{base_url}}/api/admin/activities/{{activity_id}}/issues/{{issue_id}}/commit_random", "host": ["{{base_url}}"], "path": ["api","admin","activities","{{activity_id}}","issues","{{issue_id}}","commit_random"]} + } + }, + { + "name": "Batch Draw", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/activities/{{activity_id}}/issues/{{issue_id}}/batch_draw", "host": ["{{base_url}}"], "path": ["api","admin","activities","{{activity_id}}","issues","{{issue_id}}","batch_draw"]}, + "body": {"mode": "raw", "raw": "{\n \"times\": 50\n}"} + } + }, + { + "name": "Create Title", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/system_titles", "host": ["{{base_url}}"], "path": ["api","admin","system_titles"]}, + "body": {"mode": "raw", "raw": "{\n \"name\": \"抽奖双倍达人\",\n \"description\": \"24小时抽奖翻倍\",\n \"obtain_rules_json\": \"{\\\"methods\\\":[\\\"lottery\\\"]}\",\n \"scopes_json\": \"{}\"\n}"} + } + }, + { + "name": "Add Effect Type 5", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/system_titles/{{title_id}}/effects", "host": ["{{base_url}}"], "path": ["api","admin","system_titles","{{title_id}}","effects"]}, + "body": {"mode": "raw", "raw": "{\n \"effect_type\": 5,\n \"params_json\": \"{\\\"target_prize_ids\\\":[1,2],\\\"boost_x1000\\\":200,\\\"cap_x1000\\\":300}\",\n \"stacking_strategy\": 1,\n \"cap_value_x1000\": 0,\n \"scopes_json\": \"{\\\"activity_ids\\\":[{{activity_id}}],\\\"issue_ids\\\":[{{issue_id}}]}\"\n}"} + } + }, + { + "name": "Add Effect Type 6", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/system_titles/{{title_id}}/effects", "host": ["{{base_url}}"], "path": ["api","admin","system_titles","{{title_id}}","effects"]}, + "body": {"mode": "raw", "raw": "{\n \"effect_type\": 6,\n \"params_json\": \"{\\\"target_prize_ids\\\":[1,2],\\\"chance_x1000\\\":1000,\\\"period_cap_times\\\":1}\",\n \"stacking_strategy\": 1,\n \"cap_value_x1000\": 0,\n \"scopes_json\": \"{\\\"activity_ids\\\":[{{activity_id}}],\\\"issue_ids\\\":[{{issue_id}}]}\"\n}"} + } + }, + { + "name": "Assign Title To User", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/users/{{user_id}}/titles", "host": ["{{base_url}}"], "path": ["api","admin","users","{{user_id}}","titles"]}, + "body": {"mode": "raw", "raw": "{\n \"title_id\": {{title_id}},\n \"expire_type\": \"days\",\n \"days\": 1\n}"} + } + }, + { + "name": "Add Coupon", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/users/{{user_id}}/coupons/add", "host": ["{{base_url}}"], "path": ["api","admin","users","{{user_id}}","coupons","add"]}, + "body": {"mode": "raw", "raw": "{\n \"coupon_id\": {{coupon_id}}\n}"} + } + }, + { + "name": "List User Coupons", + "request": { + "method": "GET", + "header": [ + {"key": "Authorization", "value": "{{token}}"} + ], + "url": {"raw": "{{base_url}}/api/admin/users/{{user_id}}/coupons?page=1&page_size=20", "host": ["{{base_url}}"], "path": ["api","admin","users","{{user_id}}","coupons"], "query": [{"key":"page","value":"1"},{"key":"page_size","value":"20"}]} + } + }, + { + "name": "Assign Item Card", + "request": { + "method": "POST", + "header": [ + {"key": "Authorization", "value": "{{token}}"}, + {"key": "Content-Type", "value": "application/json"} + ], + "url": {"raw": "{{base_url}}/api/admin/users/{{user_id}}/item_cards", "host": ["{{base_url}}"], "path": ["api","admin","users","{{user_id}}","item_cards"]}, + "body": {"mode": "raw", "raw": "{\n \"card_id\": {{card_id}},\n \"quantity\": 2\n}"} + } + }, + { + "name": "List User Item Cards", + "request": { + "method": "GET", + "header": [ + {"key": "Authorization", "value": "{{token}}"} + ], + "url": {"raw": "{{base_url}}/api/admin/users/{{user_id}}/item_cards?page=1&page_size=20", "host": ["{{base_url}}"], "path": ["api","admin","users","{{user_id}}","item_cards"], "query": [{"key":"page","value":"1"},{"key":"page_size","value":"20"}]} + } + } + ], + "variable": [ + {"key": "base_url", "value": "http://localhost:9991"}, + {"key": "token", "value": ""}, + {"key": "activity_id", "value": ""}, + {"key": "issue_id", "value": ""}, + {"key": "title_id", "value": ""}, + {"key": "user_id", "value": ""}, + {"key": "coupon_id", "value": ""}, + {"key": "card_id", "value": ""} + ] +} \ No newline at end of file