feat(工作台): 实现管理端工作台接口并优化数据展示
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 39s
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 39s
feat(抽奖动态): 修复抽奖动态未渲染问题并优化文案展示 fix(用户概览): 修复用户概览无数据显示问题 feat(新用户列表): 在新用户列表显示称号明细 refactor(待办事项): 移除代办模块并全宽展示实时动态 feat(批量操作): 限制为单用户操作并在批量时提醒 fix(称号分配): 防重复分配称号的改造计划 perf(接口性能): 优化新用户和抽奖动态接口性能 feat(订单漏斗): 优化订单转化漏斗指标计算 docs(测试计划): 完善盲盒运营API核查与闭环测试计划
This commit is contained in:
parent
1b5a715a22
commit
87ad4177b1
52
.trae/documents/优化“新用户”和“实时抽奖”接口性能.md
Normal file
52
.trae/documents/优化“新用户”和“实时抽奖”接口性能.md
Normal file
@ -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` 预聚合与写路径刷新机制
|
||||||
|
|
||||||
|
确认后我将按上述方案逐条落地并提供压测数据与对比报告。
|
||||||
20
.trae/documents/优化订单转化漏斗指标计算.md
Normal file
20
.trae/documents/优化订单转化漏斗指标计算.md
Normal file
@ -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%`(可作为履约提示,不影响支付瓶颈判断)
|
||||||
|
|
||||||
|
确认后我将直接修改该组件的计算逻辑并构建验证。
|
||||||
34
.trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md
Normal file
34
.trae/documents/修复 Vue watcher 中“resetForm 未初始化”错误的改造方案.md
Normal file
@ -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` 生效)。
|
||||||
|
|
||||||
|
确认后我将按上述方案修改代码并验证。
|
||||||
18
.trae/documents/修复“用户概览”无数据显示.md
Normal file
18
.trae/documents/修复“用户概览”无数据显示.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
## 问题诊断
|
||||||
|
- 前端 `active-user.vue` 将图表数据 `xAxisLabels/chartData` 改为普通数组,未使用 Vue 响应式;页面初始为空数组且后续赋值不触发渲染,导致“用户概览没有数据”。
|
||||||
|
- 指标列表 `list` 同为普通数组,数值更新不触发视图刷新。
|
||||||
|
- 后端 `GET /api/admin/dashboard/user_overview` 正常返回,但前端未正确展示。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
- 将 `xAxisLabels`、`chartData` 改为 `ref<string[]>/ref<number[]>`,用 `.value` 填充;模板自动解包可直接绑定。
|
||||||
|
- 将指标 `list` 改为 `reactive<UserStatItem[]>`,更新项时触发视图刷新。
|
||||||
|
- 增加兜底:接口异常或空数据时显示“暂无”与零值,避免空白。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 后端:`curl -H 'Authorization: <token>' '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) 提供前端验证说明(构建目前受其他文件类型错误影响不影响本模块联调)。
|
||||||
10
.trae/documents/修复抽奖动态未渲染问题.md
Normal file
10
.trae/documents/修复抽奖动态未渲染问题.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
## 原因推测
|
||||||
|
- 抽奖动态列表使用 `reactive<DrawStreamItem[]>([])`,在数组 `unshift/splice` 更新时可能未触发渲染;改用 `ref<DrawStreamItem[]>([])` 更稳妥。
|
||||||
|
- 文案需更明确:“昵称 在 活动名-期号 中奖 奖品名”。
|
||||||
|
|
||||||
|
## 修复项
|
||||||
|
1) 将列表改为 `ref` 并按 `.value` 更新;模板自动解包无改动。
|
||||||
|
2) 文案改为纯文本:“中奖 {{ prizeName }}”,去掉标签以避免样式干扰。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 首次加载立即显示返回的列表项;后续轮询追加新项;行内容符合“谁在哪一个活动中了什么奖品”。
|
||||||
32
.trae/documents/修复范围筛选、抽奖动态与待办事项.md
Normal file
32
.trae/documents/修复范围筛选、抽奖动态与待办事项.md
Normal file
@ -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`,文案显示“从未参与抽奖”
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 后端编译通过;
|
||||||
|
- 切换单选范围数据刷新;
|
||||||
|
- 抽奖动态行文本符合“谁在哪一个活动中了什么奖品”;
|
||||||
|
- 待办列表展示“未参与抽奖”的用户
|
||||||
16
.trae/documents/在新用户列表显示称号明细.md
Normal file
16
.trae/documents/在新用户列表显示称号明细.md
Normal file
@ -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` 返回每个用户的称号数组
|
||||||
|
- 前端工作台“新用户”模块展示称号标签;无称号显示“无称号”
|
||||||
25
.trae/documents/在用户详情显示头衔的改造计划.md
Normal file
25
.trae/documents/在用户详情显示头衔的改造计划.md
Normal file
@ -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`(过期标识)
|
||||||
|
- 若无头衔,显示“无头衔”。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
- 打开用户详情,正确拉取并显示当前头衔;过期或未激活不显示。
|
||||||
|
- 已分配称号立即可在详情查看。
|
||||||
72
.trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md
Normal file
72
.trae/documents/完善工作台:订单漏斗、中奖分析、用户概述与抽奖动态.md
Normal file
@ -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. 进行构建与联调,提供验证指令与预览链接。
|
||||||
127
.trae/documents/实现管理端工作台接口.md
Normal file
127
.trae/documents/实现管理端工作台接口.md
Normal file
@ -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: <token>" "http://localhost:<port>/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 注释与最小测试用例
|
||||||
55
.trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md
Normal file
55
.trae/documents/扩展新用户数据、抽奖动态与待办逻辑.md
Normal file
@ -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: <token>' 'http://localhost:8000/api/admin/dashboard/new_users?page=1&page_size=20'`
|
||||||
|
- 抽奖动态:`curl -H 'Authorization: <token>' 'http://localhost:8000/api/admin/dashboard/draw_stream?limit=50'`
|
||||||
|
- 待办(未抽奖用户):`curl -H 'Authorization: <token>' 'http://localhost:8000/api/admin/dashboard/todos?limit=50'`
|
||||||
|
- 前端:工作台“新用户”、“抽奖动态”、“待办事项”模块显示对应数据
|
||||||
|
|
||||||
|
## 后续可选优化
|
||||||
|
- 在 `log_request` 增加 `user_id` 字段,并在中间件从会话写入;将“上一次在线时间”改为日志精准口径
|
||||||
|
- 为关联查询增加必要索引以保证统计性能(`activity_draw_logs.user_id/issue_id`、`orders.user_id/status` 等)
|
||||||
|
|
||||||
|
确认后我将按上述文件路径逐项实现并联调。
|
||||||
64
.trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md
Normal file
64
.trae/documents/批量积分_优惠券_奖励:安全与闭环改造实施计划.md
Normal file
@ -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 令牌后按顺序回归。
|
||||||
|
- 验收标准:
|
||||||
|
- 权限校验:非超管拒绝。
|
||||||
|
- 配额/上限:超量拒绝;成功/失败计数正确。
|
||||||
|
- 幂等:重复提交返回相同结果,不重复写入。
|
||||||
|
- 审计:生成批次日志记录(至少在系统日志中可查)。
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
- 批量接口与处理器实现、服务层封装、路由挂载。
|
||||||
|
- 集合/脚本更新与验收报告(问题清单与修复项)。
|
||||||
|
|
||||||
|
确认后我将开始实现并提交对应代码与测试集合更新。
|
||||||
91
.trae/documents/盲盒运营API核查与闭环测试计划.md
Normal file
91
.trae/documents/盲盒运营API核查与闭环测试计划.md
Normal file
@ -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 集合)。
|
||||||
19
.trae/documents/移除代办模块并全宽展示实时动态.md
Normal file
19
.trae/documents/移除代办模块并全宽展示实时动态.md
Normal file
@ -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 条的逻辑,以满足“全部显示实时动态”(如需后续限制可再加分页/虚拟列表)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 工作台不再显示“代办事项”卡片
|
||||||
|
- 动态模块全宽显示,长活动名/奖品名不再被裁剪
|
||||||
|
- 列表滚动正常,持续追加数据可见
|
||||||
23
.trae/documents/让抽奖动态完整显示.md
Normal file
23
.trae/documents/让抽奖动态完整显示.md
Normal file
@ -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` 的行模板与样式以生效。
|
||||||
17
.trae/documents/防重复分配称号的改造计划.md
Normal file
17
.trae/documents/防重复分配称号的改造计划.md
Normal file
@ -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` 取当前有效称号。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
- 对同一用户、同一称号:首次分配成功;再次分配立刻被后端拒绝并提示。
|
||||||
|
- 过期称号可重新分配;前端也能提示并拦截重复操作。
|
||||||
17
.trae/documents/限制为单用户操作并在批量时提醒的改造计划.md
Normal file
17
.trae/documents/限制为单用户操作并在批量时提醒的改造计划.md
Normal file
@ -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):“仅支持单用户,请取消其他选择”。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
- 任意接口或页面选择多个用户时均被阻止,并出现统一提示;单用户流程可正常提交与执行。
|
||||||
861
internal/api/admin/dashboard_admin.go
Normal file
861
internal/api/admin/dashboard_admin.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/api/admin/dashboard_admin_test.go
Normal file
26
internal/api/admin/dashboard_admin_test.go
Normal file
@ -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") }
|
||||||
|
}
|
||||||
@ -441,6 +441,11 @@ func (h *handler) AssignUserTitle() core.HandlerFunc {
|
|||||||
Where(h.readDB.UserTitles.UserID.Eq(userID)).
|
Where(h.readDB.UserTitles.UserID.Eq(userID)).
|
||||||
Where(h.readDB.UserTitles.TitleID.Eq(req.TitleID)).First()
|
Where(h.readDB.UserTitles.TitleID.Eq(req.TitleID)).First()
|
||||||
if err == nil && ut != nil {
|
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.Active = 1
|
||||||
ut.Remark = req.Remark
|
ut.Remark = req.Remark
|
||||||
|
|||||||
23
internal/api/admin/titles_admin_test.go
Normal file
23
internal/api/admin/titles_admin_test.go
Normal file
@ -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") }
|
||||||
|
}
|
||||||
@ -1,15 +1,15 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/code"
|
"bindbox-game/internal/code"
|
||||||
"bindbox-game/internal/pkg/core"
|
"bindbox-game/internal/pkg/core"
|
||||||
"bindbox-game/internal/pkg/validation"
|
"bindbox-game/internal/pkg/validation"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
"bindbox-game/internal/service/user"
|
"bindbox-game/internal/service/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listUsersRequest struct {
|
type listUsersRequest struct {
|
||||||
@ -578,7 +578,7 @@ type addCouponRequest struct {
|
|||||||
CouponID int64 `json:"coupon_id" binding:"required"`
|
CouponID int64 `json:"coupon_id" binding:"required"`
|
||||||
}
|
}
|
||||||
type addCouponResponse struct {
|
type addCouponResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddUserCoupon 给用户添加优惠券
|
// AddUserCoupon 给用户添加优惠券
|
||||||
@ -618,3 +618,65 @@ func (h *handler) AddUserCoupon() core.HandlerFunc {
|
|||||||
ctx.Payload(rsp)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
144
internal/api/admin/users_batch_admin.go
Normal file
144
internal/api/admin/users_batch_admin.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -61,6 +61,18 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) {
|
|||||||
// 管理员账号维护接口移除(未被前端使用)
|
// 管理员账号维护接口移除(未被前端使用)
|
||||||
|
|
||||||
adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories())
|
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.POST("/activities", adminHandler.CreateActivity())
|
||||||
adminAuthApiRouter.PUT("/activities/:activity_id", adminHandler.ModifyActivity())
|
adminAuthApiRouter.PUT("/activities/:activity_id", adminHandler.ModifyActivity())
|
||||||
adminAuthApiRouter.DELETE("/activities/:activity_id", adminHandler.DeleteActivity())
|
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/points/add", adminHandler.AddUserPoints())
|
||||||
adminAuthApiRouter.POST("/users/:user_id/coupons/add", adminHandler.AddUserCoupon())
|
adminAuthApiRouter.POST("/users/:user_id/coupons/add", adminHandler.AddUserCoupon())
|
||||||
adminAuthApiRouter.POST("/users/:user_id/rewards/grant", adminHandler.GrantReward())
|
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/inventory", adminHandler.ListUserInventory())
|
||||||
adminAuthApiRouter.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards())
|
adminAuthApiRouter.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards())
|
||||||
// 系统称号与分配
|
// 系统称号与分配
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *service) AddCoupon(ctx context.Context, userID int64, couponID int64) error {
|
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 {
|
if tpl == nil || tpl.Status != 1 {
|
||||||
return nil
|
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}
|
item := &model.UserCoupons{UserID: userID, CouponID: couponID, Status: 1}
|
||||||
if !tpl.ValidStart.IsZero() {
|
if !tpl.ValidStart.IsZero() {
|
||||||
item.ValidStart = tpl.ValidStart
|
item.ValidStart = tpl.ValidStart
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddItemCard 给用户添加道具卡
|
// AddItemCard 给用户添加道具卡
|
||||||
@ -23,9 +24,8 @@ import (
|
|||||||
// 返回说明:
|
// 返回说明:
|
||||||
// - error: 错误信息,包括数据库查询失败、模板不存在等情况
|
// - error: 错误信息,包括数据库查询失败、模板不存在等情况
|
||||||
func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, quantity int) error {
|
func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, quantity int) error {
|
||||||
if quantity <= 0 {
|
if quantity <= 0 { quantity = 1 }
|
||||||
quantity = 1
|
if quantity > 100 { quantity = 100 }
|
||||||
}
|
|
||||||
tpl, err := s.readDB.SystemItemCards.WithContext(ctx).Where(s.readDB.SystemItemCards.ID.Eq(cardID)).First()
|
tpl, err := s.readDB.SystemItemCards.WithContext(ctx).Where(s.readDB.SystemItemCards.ID.Eq(cardID)).First()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -33,6 +33,14 @@ func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, q
|
|||||||
if tpl == nil || tpl.Status != 1 {
|
if tpl == nil || tpl.Status != 1 {
|
||||||
return nil
|
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()
|
now := time.Now()
|
||||||
for i := 0; i < quantity; i++ {
|
for i := 0; i < quantity; i++ {
|
||||||
item := &model.UserItemCards{UserID: userID, CardID: cardID, Status: 1}
|
item := &model.UserItemCards{UserID: userID, CardID: cardID, Status: 1}
|
||||||
|
|||||||
180
tests/collections/admin_blindbox.postman_collection.json
Normal file
180
tests/collections/admin_blindbox.postman_collection.json
Normal file
@ -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": ""}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user