diff --git a/.DS_Store b/.DS_Store index 36c4879..8ea65f8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.trae/.ignore b/.trae/.ignore new file mode 100644 index 0000000..e69de29 diff --git a/.trae/documents/修复活动创建向导步骤逻辑并统一全局风格.md b/.trae/documents/修复活动创建向导步骤逻辑并统一全局风格.md new file mode 100644 index 0000000..986e9b8 --- /dev/null +++ b/.trae/documents/修复活动创建向导步骤逻辑并统一全局风格.md @@ -0,0 +1,49 @@ +## 问题确认 +- 症状:在“创建活动”向导中点击“下一步”后,出现“批量抽奖”界面,用户认为逻辑错误。 +- 初步研判:批量抽奖来自 `BatchDrawDialog`(src/views/activity/rewards/modules/batch-draw-dialog.vue)。该组件在管理页(src/views/activity/manage/index.vue)中常驻渲染,可能因不当条件渲染或状态误触发而在向导流程中弹出。 +- 路由侧现状:列表页有 `router.push({ name: 'ActivityWizard' })`,但路由模块未注册对应路由,存在跳转异常风险。 + +## 目标 +1. 保证向导“下一步”仅在步骤间顺序前进:活动 → 期数 → 奖品,绝不触发批量抽奖。 +2. 统一界面风格为项目全局规范:颜色、圆角、阴影、按钮、表单、布局与动画。 +3. 保留现有功能,避免回归,完成自测与构建验证。 + +## 技术方案 +### 1. 逻辑修复 +- 向导页面(两处)严格限制导航: + - `src/views/activity/wizard/index.vue`:`next()` 仅在成功创建活动后 `active.value = 1`,在成功创建期数后 `active.value = 2`,移除或校验任何非最终提交阶段的 `router.push` 与其他弹窗触发。 + - `src/views/activity/manage/index.vue`:`nextWizard()` 仅递进 `wizardActive`。确保 `BatchDrawDialog` 的 `v-model` 只由 `openBatchDraw()` 显式开启。 +- 隔离批量抽奖弹窗: + - 在管理页将 `` 增加条件渲染 `v-if="!showWizard"`,确保向导弹窗期间不渲染批量抽奖组件,从根本杜绝误触发。 +- 路由补全:在 `src/router/modules/activity.ts` 注册 `ActivityWizard` 路由(path `/activity/wizard`,component `'/activity/wizard'`),避免列表页“创建活动”跳转异常导致意外页面状态。 + +### 2. 风格统一 +- 去除自定义渐变与玻璃拟态,改为全局样式: + - 主容器采用 `art-card` 与项目标准阴影、圆角(卡片 16px,按钮/输入 12px)。 + - 步骤指示器使用项目的圆形数字样式与轻量过渡;颜色使用主题色 `#5D87FF` 及状态色变量。 + - 表单与按钮风格遵循项目统一尺寸、高度与交互(36px 高度,标准阴影与过渡)。 +- 统一 CSS 变量与工具类: + - 使用项目内的 CSS 变量(颜色、圆角、间距),移除局部硬编码样式。 + - 采用现有 `art-card`、栅格与间距体系,保证一致性。 + +### 3. 验证与测试 +- 开发验证: + - 在两处向导中执行完整流程(创建活动→创建期数→配置奖品),确认“下一步”不触发 `BatchDrawDialog`。 + - 构建检查:`npm run build` 无 TS/构建错误。 +- 回归验证: + - 管理页列表的“模拟(批量抽奖)”按钮仍可正常打开 `BatchDrawDialog`。 + - 列表页“创建活动”跳转到向导路由正常。 + +## 交付物 +- 修复后的向导逻辑代码(两处),隔离批量抽奖弹窗的条件渲染。 +- 新增路由项 `ActivityWizard`。 +- 去除非统一风格的样式,替换为项目全局风格实现。 +- 构建与运行验证结果说明。 + +## 验收标准 +- 向导点击“下一步”始终进入下一步骤,不出现批量抽奖。 +- 界面风格与全局统一(颜色、圆角、阴影、按钮、表单风格一致)。 +- 列表页“创建活动”跳转向导正常;管理页“模拟”按钮打开批量抽奖正常。 +- 构建通过,无新错误。 + +如确认以上方案,我将开始实施并同步每一步的修复与验证结果。 \ No newline at end of file diff --git a/.trae/documents/删除级联逻辑优化方案.md b/.trae/documents/删除级联逻辑优化方案.md new file mode 100644 index 0000000..78129b2 --- /dev/null +++ b/.trae/documents/删除级联逻辑优化方案.md @@ -0,0 +1,86 @@ +## 背景 +- 当前不启用数据库外键约束(FK),删除由业务逻辑驱动。 +- 现有删除实现多为单表删除,未做级联:如删除活动 `internal/service/activity/activity_delete.go:9`、删除期 `internal/service/activity/issue_delete.go:9`、删除用户 `internal/service/user/batch_user.go:32`。 +- GORM 初始化未声明外键或级联:`internal/repository/mysql/mysql.go:97`。 + +## 目标 +- 在业务层实现“逻辑级联删除”,保证删除主实体时,同步清理其关联业务数据,且性能与可控性优于 FK 级联。 + +## 级联清单 +### 删除活动(Activities.ID) +- 删除活动期:`activity_issues` 按 `activity_id`(`internal/repository/mysql/model/activity_issues.gen.go:18`) +- 期下配置与承诺: + - 奖励配置 `activity_reward_settings` 按 `issue_id`(`internal/repository/mysql/model/activity_reward_settings.gen.go:18`) + - 随机承诺 `issue_random_commitments` 按 `issue_id`(`internal/repository/mysql/model/issue_random_commitments.gen.go:18`) +- 期下抽奖相关: + - 抽奖日志 `activity_draw_logs` 按 `issue_id`(`internal/repository/mysql/model/activity_draw_logs.gen.go:18`) + - 抽奖效果 `activity_draw_effects` 按 `draw_log_id`/`issue_id`(`internal/repository/mysql/model/activity_draw_effects.gen.go:17,29`) + - 抽奖凭据 `activity_draw_receipts` 按 `draw_log_id`(`internal/repository/mysql/model/activity_draw_receipts.gen.go:17`) +- 活动范围效果与道具: + - 用户道具使用记录 `user_item_cards` 按 `used_activity_id` / `used_issue_id` / `used_draw_log_id`(`internal/repository/mysql/model/user_item_cards.gen.go:24,25,23`) + - 抽奖效果快照 `activity_draw_effects` 按 `activity_id`/`issue_id`(`internal/repository/mysql/model/activity_draw_effects.gen.go:28,29`) +- 资产与日志: + - 用户资产 `user_inventory` 按 `activity_id`(`internal/repository/mysql/model/user_inventory.gen.go:21`) + - 公会贡献日志 `guild_contribute_logs` 按 `activity_id` / `issues_id`(`internal/repository/mysql/model/guild_contribute_logs.gen.go:19,20`) +- 系统模板: + - 系统道具卡 `system_item_cards` 按 `activity_id` / `issue_id`(`internal/repository/mysql/model/system_item_cards.gen.go:23,24`) + - 系统优惠券 `system_coupons` 按 `activity_id`(`internal/repository/mysql/model/system_coupons.gen.go:20`) +- 最后删除活动主表:`activities`(`internal/repository/mysql/model/activities.gen.go:15`) + +### 删除用户(Users.ID) +- 用户身份与权益: + - 头衔 `user_titles` 按 `user_id`(`internal/repository/mysql/model/user_titles.gen.go:16`) + - 领取型权益限流 `user_title_effect_claims` 按 `user_id`(`internal/repository/mysql/model/user_title_effect_claims.gen.go:16`) +- 用户账户与地址: + - 地址 `user_addresses` 按 `user_id`(`internal/repository/mysql/model/user_addresses.gen.go:18`) + - 积分余额 `user_points` 按 `user_id`(`internal/repository/mysql/model/user_points.gen.go:18`) + - 积分流水 `user_points_ledger` 按 `user_id`(`internal/repository/mysql/model/user_points_ledger.gen.go:17`) +- 用户订单与优惠: + - 订单 `orders` 按 `user_id`(`internal/repository/mysql/model/orders.gen.go:18`) + - 用户优惠券 `user_coupons` 按 `user_id`(`internal/repository/mysql/model/user_coupons.gen.go:18`) +- 用户资产与道具: + - 用户资产 `user_inventory` 按 `user_id`(`internal/repository/mysql/model/user_inventory.gen.go:18`) + - 用户道具卡 `user_item_cards` 按 `user_id`(`internal/repository/mysql/model/user_item_cards.gen.go:18`) +- 与抽奖相关: + - 抽奖效果 `activity_draw_effects` 按 `user_id` 或关联 `draw_log_id`(`internal/repository/mysql/model/activity_draw_effects.gen.go:18,17`) + - 抽奖凭据 `activity_draw_receipts` 关联 `draw_log_id`(`internal/repository/mysql/model/activity_draw_receipts.gen.go:17`) + - 抽奖日志 `activity_draw_logs` 按 `user_id`(`internal/repository/mysql/model/activity_draw_logs.gen.go:17`) +- 公会关联: + - 公会成员 `guild_members` 按 `user_id`(`internal/repository/mysql/model/guild_members.gen.go:18`) + - 公会贡献日志 `guild_contribute_logs` 按 `user_id`(`internal/repository/mysql/model/guild_contribute_logs.gen.go:18`) +- 履约/发货: + - 发货记录 `shipping_records` 按 `user_id`(`internal/repository/mysql/model/shipping_records.gen.go:18`) + - 运营发货统计 `ops_shipping_stats` 按 `user_id`(`internal/repository/mysql/model/ops_shipping_stats.gen.go:21`) +- 最后软删用户:`users.deleted_at`(`internal/repository/mysql/model/users.gen.go:20`) + +## 删除顺序(事务内) +- 统一采用“从叶到根”的顺序: + 1) 以日志/效果/凭据等子表为先(`activity_draw_effects`、`activity_draw_receipts`、`activity_draw_logs`) + 2) 再清理资产/权益/模板(`user_inventory`、`user_item_cards`、`user_titles`、`system_*`) + 3) 清理期与期下配置(`activity_issues`、`activity_reward_settings`、`issue_random_commitments`) + 4) 删除根实体(`activities` 或软删 `users`) +- 全过程包裹在单事务中,任何一步失败则回滚。 + +## 实现策略 +- 新增业务服务方法: + - `DeleteActivityCascade(ctx, activityID)`:按“删除活动”清单顺序删除 + - `DeleteUserCascade(ctx, userID)`:按“删除用户”清单顺序删除 +- 技术要点: + - 批量删除使用分批(如 5k/批)避免长事务与大锁;必要时按时间/ID 片段迭代 + - 统一 `WHERE` 条件与索引列(`user_id`/`activity_id`/`issue_id`/`draw_log_id`)确保扫描性能 + - 软删与硬删:保留用户软删(合规与审计),其余按当前表定义硬删;如需统一软删,可后续逐表补充 `gorm.DeletedAt` + - 幂等性:每个子删除操作按条件删除,无记录时直接通过;重复调用不报错 + - 审计:记录操作日志(操作者、对象ID、影响行数),便于回溯 + +## 验收标准 +- 删除活动时,任一期及其抽奖日志/效果/凭据、奖励配置、承诺、资产、贡献日志与相关模板均被清理;根活动删除成功。 +- 删除用户时,地址/订单/优惠券/积分(余额+流水)/资产/道具/抽奖相关/公会关系/发货记录及统计均被清理;根用户软删成功。 +- 全流程事务保障、失败回滚;批量删除性能稳定,无显著锁表或超时。 + +## 需要改造的现有入口(参考) +- 活动删除入口:`internal/service/activity/activity_delete.go:9` → 升级为调用 `DeleteActivityCascade` +- 期删除入口:`internal/service/activity/issue_delete.go:9` → 被 `DeleteActivityCascade` 内部调用(或保留独立级联) +- 用户删除入口:`internal/service/user/batch_user.go:32` → 升级为调用 `DeleteUserCascade` + +## 后续动作 +- 我将基于以上清单与顺序,补充两套事务级联删除实现,并为关键入口替换调用;同时补充单元测试覆盖正常/边界/异常三类用例,验证幂等与性能。 \ No newline at end of file diff --git a/.trae/documents/商品列表批量操作与活动创建优化及仪表盘修复方案.md b/.trae/documents/商品列表批量操作与活动创建优化及仪表盘修复方案.md new file mode 100644 index 0000000..3a22339 --- /dev/null +++ b/.trae/documents/商品列表批量操作与活动创建优化及仪表盘修复方案.md @@ -0,0 +1,93 @@ +## 总览 +- 覆盖 5 个问题:商品列表批量操作、活动创建奖品价格与总价、仪表盘图表重叠修复、活动创建性能、弹窗点击关闭问题。 +- 技术栈:管理端前端 Vue3 + Element Plus(路径 `web/admin`),后端 Go(路径 `internal/*`)。 +- 目标:补齐商品批量接口、完善前端交互与缓存、修正布局与加载时序、增强性能与统一弹窗行为。 + +## 关键位置 +- 商品前端:`web/admin/src/views/product/list/index.vue`,API:`web/admin/src/api/product.ts` +- 商品后端:`internal/api/admin/product_create.go`、Service:`internal/service/product/product.go` +- 活动向导:`web/admin/src/views/activity/wizard/index.vue` +- 奖励管理:`web/admin/src/views/activity/rewards/index.vue` +- 仪表盘容器:`web/admin/src/views/dashboard/console/index.vue` +- 抽奖量图表:`web/admin/src/views/dashboard/console/modules/sales-overview.vue` + `components/core/charts/art-line-chart` +- 中奖率分析:`web/admin/src/views/dashboard/console/modules/activity-prize-analysis.vue` +- 弹窗示例:`web/admin/src/views/player-manage/modules/*Dialog.vue`、`components/core/layouts/*` + +## 1. 商品列表功能优化 +- 后端批量接口 + - 新增 `PUT /api/admin/products/batch`:请求体 `{ ids: number[], stock?: number, status?: 1|2 }`,返回 `{ updated_count, failed: [ {id, reason} ] }`。 + - Controller:在 `internal/api/admin/product_create.go` 或新文件 `product_batch.go` 增加路由与校验(限制 `ids` 1–1000,`stock >= 0`)。 + - Service:`internal/service/product/product.go` 增加 `BatchUpdate(ctx, ids, stock?, status?)`,事务更新;支持“仅库存”“仅状态”“二者皆有”。 + - DAO:使用批量更新(`UPDATE products SET stock=?, status=? WHERE id IN (...)`),对无权限/不存在的 `id` 记录失败原因。 + - 文档:更新 swagger(`docs/swagger.yaml`)。 +- 前端批量操作 + - 列表顶部添加“全选本页”复选框:基于 `ElTable` 的选择事件维护 `selectedIds: number[]`,`checkAll` 同步当页数据。 + - 批量工具栏:按钮“批量改库存”“批量上架”“批量下架”。每次操作弹出确认框(库存输入/操作确认)。 + - API:在 `web/admin/src/api/product.ts` 新增 `batchUpdateProducts(payload)` 调用上游接口;失败项以通知/表格高亮反馈。 + - 交互:操作成功后刷新当页并清空选中;支持 loading 与禁用态;无选中时禁用按钮。 + +## 2. 活动创建流程优化(奖品价格与总价) +- 单价展示 + - 远程商品搜索返回 `price` 字段(已有),选择时在 `ElSelect` 的 `option` 右侧显示“¥单价”。 + - 在奖励编辑卡片/表格中追加“单价”列(从缓存读取)。 +- 总价计算 + - 公式:`总价 = Σ(price(product_id) * quantity)`;监听 `product_id/quantity` 变更实时更新。 + - 缓存:在 `wizard/index.vue` 与 `rewards/index.vue` 维护 `priceCache: Map`,首次选择命中即用,未命中时调用 `fetchProducts` 补齐并写入。 + - 同步:商品价格变更时(重新打开或重新拉取),更新缓存并触发总价重算;保留两位小数。 + - 展示:步骤3顶部显示“选中奖品总成本:¥xxx.xx”。 + +## 3. 仪表盘数据展示修复(重叠问题) +- 布局与间距 + - 在 `console/index.vue` 的 `ElRow` 增加统一 `gutter`(如 `20`),保证模块间距。 + - 调整 `ElCol` 断点:避免同一行 `ActivityPrizeAnalysis` 与其他模块在窄屏并排压缩;在 `md/sm` 断点落到 `24` 独占一行。 +- 组件容器 + - 为 `sales-overview.vue` 与 `activity-prize-analysis.vue` 外层卡片设置 `position: relative; z-index: 0`;图表内部设 `z-index: 1`,避免 canvas/svg 溢出覆盖。 + - 统一卡片最小高度,防止加载时高度为 0 导致重叠。 +- 加载时序 + - 图表组件在 `dataReady` 后再渲染;`v-if="dataReady"` 避免空容器渲染。 + - 保留/增加骨架或加载态,防止内容突变挤压。 + +## 4. 活动创建性能优化(步骤1) +- 加载指示 + - `wizard/index.vue` 步骤1添加 `v-loading` 与骨架;按钮禁用在加载中。 +- 请求超时 + - 前端 axios 层设置接口超时 `30s`,在 `web/admin/src/api/_http.ts`(或全局实例)设置,并对超时给出 Toast 提示与重试入口。 +- 慢请求日志 + - Axios 响应拦截记录 `duration > 2000ms` 的接口,打印到控制台并上报(如有日志上报端点)。 +- 初始化优化 + - 并行拉取必要数据(活动分类、默认配置、组织信息),减少串行等待;命中表格缓存的接口优先读缓存(已有 `tableCache`)。 +- 错误恢复 + - 失败时保留已填写表单,显示错误提示与“重试/刷新数据”按钮;网络恢复后自动重载。 + +## 5. 弹窗点击关闭没有反应(统一与修复) +- 审核现状 + - 业务型对话框普遍设置 `:close-on-click-modal="false"`(避免误关);全局搜索/锁屏等未禁用遮罩关闭且部分隐藏关闭按钮。 +- 统一策略 + - 业务表单:保留 `close-on-click-modal=false`,确保“关闭按钮/取消按钮/ESC”都能关闭;统一通过 `@update:model-value` 与 `@closed` 重置。 + - 全局弹窗:明确设置 `:close-on-click-modal="false"` 并显示关闭按钮或提供显式关闭入口(避免遮罩点击不生效引起困惑)。 +- 修复点 + - 核查所有 `ElDialog/ElDrawer`:确保存在 `@update:model-value` 或 `v-model` 双向绑定;为关闭图标绑定 `emit('update:visible', false)`;在有 `before-close` 时正确调用 `done()`。 + - 统一 ESC 关闭(可选):在布局层面监听 ESC 并广播关闭事件。 + +## 验收标准 +- 商品批量: + - 在商品列表勾选 N 个,批量改库存/上下架成功,返回计数与失败明细;刷新后状态一致。 +- 奖品选择: + - 选择奖品即显示单价,总价随数量/选择实时更新;切换商品价格后总价同步。 +- 仪表盘: + - “活动抽奖量”与“活动中奖率分析”在所有断点无重叠,布局稳定;数据加载时不挤压。 +- 性能与超时: + - 步骤1加载显示进度,超时 30s 有提示与重试;>2s 请求有日志可见。 +- 弹窗关闭: + - 所有弹窗可通过关闭按钮/取消按钮/ESC 关闭;遮罩行为与设计一致,不出现“点击关闭没有反应”。 + +## 交付变更清单 +- 后端:新增批量接口(Controller+Service+DAO+Swagger)。 +- 前端: + - 商品列表批量选择与工具栏;新增批量 API 调用与确认反馈。 + - 向导步骤3与奖励管理页显示单价与总价;缓存价格。 + - 仪表盘栅格与 z-index 调整;图表渲染时序保护。 + - Axios 超时与慢请求日志;向导加载骨架与错误恢复。 + - 弹窗双向绑定与关闭逻辑统一。 + +如确认本方案,我将按上述清单依次实现并提交改动,过程中为关键函数添加函数级注释与必要单元/交互测试。 \ No newline at end of file diff --git a/.trae/documents/实现回收站与软删除机制.md b/.trae/documents/实现回收站与软删除机制.md new file mode 100644 index 0000000..02a64bb --- /dev/null +++ b/.trae/documents/实现回收站与软删除机制.md @@ -0,0 +1,68 @@ +## 总体方案 +- 引入统一软删除机制:所有支持回收站的业务表增加 `deleted_at DATETIME NULL`、`deleted_by BIGINT NULL` 字段,并在 ORM 层启用 GORM Soft Delete 以默认过滤已删除记录。 +- 提供中心化回收站接口与界面:集中列出各类型的软删除数据,支持恢复与彻底删除(二次确认)。 +- 权限与菜单对齐:仅管理员可访问回收站;侧边栏在“系统管理”下新增“回收站”菜单,配置角色可见与按钮权限。 + +## 数据库改造 +- 目标表(根据现有模型与端点清单):`activities`、`activity_issues`、`activity_reward_settings`、`products`、`product_categories`、`banner`、`guild`、`system_titles`、`system_title_effects`、`system_item_cards`、`system_coupons`、`menus`、`menu_actions`、`roles`、`role_users`。 +- 变更内容: + - 添加列:`deleted_at DATETIME NULL`(建立索引以提升查询效率)、`deleted_by BIGINT NULL`。 + - 后续通过 `cmd/gormgen` 重新生成 `internal/repository/mysql/{model,dao}/*.gen.go`,使模型包含 `gorm.DeletedAt` 与 `DeletedBy` 字段。 +- 迁移策略:当前仓库未集成迁移工具,计划新增 `migrations/` 与 SQL 脚本(或集成 `goose`),在测试/生产库执行列新增与索引建立,完成后重新生成 ORM 代码。 + +## 后端改造 +- 技术栈:`gin` + `gorm`(已存在),读写库封装见 `internal/repository/mysql/mysql.go`,路由集中在 `internal/router/router.go`。 +- 删除逻辑切换为软删除: + - 将所有 `DELETE /api/...` 对应的处理器由物理删除改为 `gorm` 软删除:`db.Delete(&model)`,自动写入 `deleted_at`;同时通过上下文注入当前用户 ID 写入 `deleted_by`(更新列)。 + - 涉及端点示例(集中定义于 `internal/router/router.go` 对应 handler):活动、期数、奖励、工会、商品、分类、轮播图、称号/特效、道具卡、优惠券、系统菜单/动作、角色、角色成员等。 +- 查询默认过滤: + - 使用含 `gorm.DeletedAt` 的模型,`gorm` 默认生成 `WHERE deleted_at IS NULL`;确保服务与 DAO 层不使用 `Unscoped()`,避免误返回已删除数据。 +- 回收站接口:新增 `internal/api/admin/system_recycle.go` 与服务层 `internal/service/recycle/recycle_service.go`: + - `GET /api/admin/recycle`:参数 `type`(枚举:activity、issue、reward、product...)、分页;实现为 `Unscoped().Where("deleted_at IS NOT NULL")` 列表,返回原始字段与 `deleted_at`、`deleted_by`。 + - `POST /api/admin/recycle/restore`:参数 `type,id`;`Unscoped()` 更新目标记录 `deleted_at=NULL, deleted_by=NULL`,恢复数据。 + - `DELETE /api/admin/recycle`:参数 `type,id`;`Unscoped().Delete(&model)` 执行物理删除。 +- 关联完整性: + - 软删除不级联子表;外键仍保持,恢复后关联自然可用。 + - 若业务场景需要父子一致软删除(可选扩展),在服务层增加按类型的联动策略,但默认不做级联以降低风险。 +- 权限拦截: + - 以上回收站接口全部走管理员认证中间件 `internal/router/interceptor/admin_auth.go::AdminTokenAuthVerify`。 + - 彻底删除在服务层进行额外二次确认校验(配合前端确认对话框),后端也校验 `role` 或 `action` 权限。 + +## 前端改造(web/admin) +- 框架:`vue3` + `vue-router` + `pinia` + `element-plus`,侧边栏位于 `web/admin/src/components/core/layouts/art-menus/art-sidebar-menu`,菜单数据由路由模块/后端菜单驱动。 +- 路由与页面: + - 新增路由模块 `web/admin/src/router/modules/system/recycle.ts`,`path:'/system/recycle'`,`meta.roles:['admin']`,`meta.authList:['recycle:list','recycle:restore','recycle:forceDelete']`。 + - 新增页面 `web/admin/src/views/system/recycle/index.vue`: + - 顶部类型筛选(下拉:活动、商品、工会…)。 + - 表格列出删除数据(含关键原字段、`deleted_at`、`deleted_by`),分页。 + - 行操作:`恢复`(调用 `POST /api/admin/recycle/restore`)、`彻底删除`(`ElMessageBox.confirm` 二次确认后调用 `DELETE /api/admin/recycle`)。 +- API 封装:`web/admin/src/api/system/recycle.ts`(使用现有 Axios 封装 `web/admin/src/utils/http/index.ts`):`listDeleted(type,page,size)`、`restore(type,id)`、`forceDelete(type,id)`。 +- 菜单项: + - 后端在 `menus` 表增加“系统管理/回收站”,`path:'/system/recycle'`,并在角色分配接口 `POST /api/role/:role_id/menus` 仅赋予管理员;前端 `ListSimpleMenus` 拉取后自动渲染。 +- 权限指令与守卫: + - 按按钮权限 `meta.authList` 与角色 `meta.roles` 控制显示;普通用户不显示菜单,无法访问路由或调用接口。 + +## 权限控制细则 +- 仅管理员角色拥有“回收站”菜单与相关按钮权限;普通用户删除操作也走软删除,但无法访问回收站接口。 +- 彻底删除在前端强制二次确认,并在后端再次校验权限与保护条件(如必须管理员)。 + +## 测试方案 +- 后端(Go `testing`): + - `internal/api/admin/system_recycle_test.go`: + - 删除后记录存在于回收站接口,原查询不可见(验证 `deleted_at` 与默认过滤)。 + - 恢复后记录重新可见,`deleted_at`/`deleted_by` 为空。 + - 权限:非管理员访问回收站接口返回 `401/403`;管理员正常。 + - 关联完整性:恢复后关联查询正常(父子外键未破坏)。 +- 前端(Vitest): + - `web/admin/src/tests/system/recycle/recycle.test.ts`: + - 菜单与路由权限:管理员可见“回收站”,普通用户不可见。 + - 列表加载与分页、恢复按钮触发成功提示与刷新、彻底删除二次确认弹窗与成功流程。 + - 401/403 拦截按现有拦截器工作(参考 `src/tests/auth/401-error-handling.test.ts`)。 + +## 交付与验证 +- 执行数据库迁移并生成 ORM 代码 → 修改删除逻辑到软删除 → 新增回收站接口与前端页面 → 配置菜单与角色 → 编写并运行测试。 +- 验收标准: + - 所有默认查询不返回已删除记录。 + - 回收站能列出各类删除记录,恢复/彻底删除工作正常。 + - 权限严格生效;普通用户删除进入回收站但不可访问回收站界面。 + - 恢复后关联数据完整、功能正常。 \ No newline at end of file diff --git a/.trae/documents/彻底修复批量抽奖误弹与统一“新建活动”UI为 Element Plus 官方风格.md b/.trae/documents/彻底修复批量抽奖误弹与统一“新建活动”UI为 Element Plus 官方风格.md new file mode 100644 index 0000000..dcd8c7a --- /dev/null +++ b/.trae/documents/彻底修复批量抽奖误弹与统一“新建活动”UI为 Element Plus 官方风格.md @@ -0,0 +1,49 @@ +## 目标 + +* 在活动模块中彻底移除“批量测试/批量抽奖”入口与弹窗,避免在创建流程中出现。 + +* 保留其他模块(如“抽奖模拟盈亏”页面)的相似功能,不影响运营侧工具。 + +* 统一“新建活动”UI为 [https://www.artd.pro/docs/zh/ ](https://www.artd.pro/docs/zh/) 官方风格,保持与项目其他新建页面一致。 + +## 变更范围 + +* 移除组件引用与入口: + + * `web/admin/src/views/activity/manage/index.vue`:删除 `BatchDrawDialog` 引用与渲染、`openBatchDraw` 按钮及逻辑。 + + * `web/admin/src/views/activity/list/index.vue`:删除批量抽奖按钮与 `BatchDrawDialog` 渲染。 + +* 组件保留但不再使用: + + * `web/admin/src/views/activity/rewards/modules/batch-draw-dialog.vue` 保留于项目(供其他模块复用),但活动模块不再引用。 + +* 路由与流程: + + * 无需调整路由;保留 `Operations/LotterySimulation` 等运营侧工具。 + + * 确保“创建活动 → 期数 → 奖品”流程不再出现任何测试入口。 + +## UI统一 + +* 两处向导页面完全采用官方 EP 风格(已部分完成): + + * `ElSteps` simple 模式; + + * `ElCard` 内容区; + + * `ElForm` 默认尺寸与标准栅格; + + * 移除自定义渐变、玻璃拟态与重阴影。 + +## 验收标准 + +* 活动模块中不再出现“批量测试/批量抽奖”按钮或弹窗。 + +* 创建流程不再被任何测试功能打断。 + +* 新建活动页面的视觉与项目其他新建保持一致(EP 官方风格)。 + +* 构建与运行通过,页面与流程验证无误。 + +如确认,我将执行上述移除与统一改造,并完成构建与流程验证。 diff --git a/.trae/documents/活动创建“下一步”无响应的排查与改造方案.md b/.trae/documents/活动创建“下一步”无响应的排查与改造方案.md new file mode 100644 index 0000000..8b7791b --- /dev/null +++ b/.trae/documents/活动创建“下一步”无响应的排查与改造方案.md @@ -0,0 +1,31 @@ +## 现状确认 +- 后端接口已对接:`POST /api/admin/activities`(`internal/router/router.go:78`),控制器 `CreateActivity`(`internal/api/admin/activities_admin.go:41`)。 +- 管理端前端已调用:`createActivity()` 指向 `admin/activities`(`web/admin/src/api/adminActivities.ts:13`)。 +- 向导有两种: + - 独立向导页 `wizard/index.vue`:`下一步`仅切换步骤(`next()`),提交动作分别由页面内的`提交`按钮触发(`submitActivity/submitIssue/submitRewards`)。 + - 管理页弹窗向导 `manage/index.vue`:`下一步`会调用创建接口然后推进步骤(`nextWizard()`)。 + +## 可能原因 +- 使用了独立向导页,点击`下一步`未触发API(预期行为需用`提交`按钮)。 +- 请求未通过权限:后端要求超级管理员(`ctx.SessionUserInfo().IsSuper == 1`,`internal/api/admin/activities_admin.go:56-59`)。 +- 请求参数不合法:`name`必填、`activity_category_id`不可为0(同文件 15-18, 51-54)。 +- 环境未配置或认证头缺失:`VITE_API_URL` 未指向后端 `/api` 前缀,或 `Authorization` 未带登录后的纯 Token(不加 `Bearer`)。 + +## 排查步骤 +1) 打开浏览器 Network,在点击`下一步`或`提交`后确认是否发起 `POST {VITE_API_URL}/api/admin/activities`;查看响应码与返回体。 +2) 确认当前页面是弹窗向导还是独立向导: + - 若是独立向导,请使用每步的`提交`按钮;`下一步`只换页不提交。 +3) 验证权限与参数: + - 登录账号需 `is_super=1`;否则返回 400 `禁止操作`。 + - `name`与`activity_category_id`需填充;分类可通过 `GET /api/admin/activity_categories` 获取。 +4) 检查前端环境: + - `VITE_API_URL` 应指向后端根(例如 `http://localhost:8000`),请求工具会拼接 `admin/...` 成 `/api/admin/...`。 + - `Authorization` 头为登录获得的 Token 字符串(无 `Bearer` 前缀)。 + +## 改造建议(可选) +- 为独立向导页 `wizard/index.vue` 增强交互:在 `next()` 中根据 `active` 自动调用对应的 `submitActivity/submitIssue`,使“下一步”即提交。 +- 在按钮文案上提示“提交并进入下一步”,并在未填必填项时给出校验提示(阻止推进)。 + +## 验收标准 +- 在管理端,创建活动流程点击`提交`或(改造后)点击`下一步`能成功发起请求并进入下一步;错误时有明确提示。 +- 非超级管理员或参数缺失时,前端能正确显示后端返回的错误信息。 \ No newline at end of file diff --git a/.trae/documents/活动创建流程重构方案.md b/.trae/documents/活动创建流程重构方案.md new file mode 100644 index 0000000..e9f0d91 --- /dev/null +++ b/.trae/documents/活动创建流程重构方案.md @@ -0,0 +1,87 @@ +## 目标 +- 将活动创建流程改造成“清晰、可操作、可回退”的向导式体验,显著降低填写负担、减少出错率,并确保与现有后端接口完全对齐。 +- 统一字段与交互:活动、期、奖励配置的字段、校验与排序一致;消除重复输入与歧义(如奖励名称以选品自动回填)。 +- 杜绝常见错误:时间格式、权限校验、表单必填项、动态模块加载等问题在交互层面被提前防护。 + +## 现状与痛点 +- 后端接口已齐备(管理端): + - 创建活动:`POST /api/admin/activities`(`internal/router/router.go:78`;控制器 `internal/api/admin/activities_admin.go:41`;服务层 `internal/service/activity/activity_create.go:12-41`) + - 创建期:`POST /api/admin/activities/:activity_id/issues`(`internal/router/router.go:83`;控制器 `internal/api/admin/issues_admin.go`;服务层 `internal/service/activity/issue_create.go:13-32`) + - 创建奖励:`POST /api/admin/activities/:activity_id/issues/:issue_id/rewards` +- 前端存在两套创建入口: + - 独立向导页:`web/admin/src/views/activity/wizard/index.vue`(“下一步”仅切换步骤,提交由各步的“提交”触发) + - 管理页弹窗向导:`web/admin/src/views/activity/manage/index.vue`(“下一步”实际调用创建并推进) +- 痛点汇总: + - 两处入口的字段命名、排序不一致;奖励有的手填名称,有的选商品 + - 时间未填时易触发数据库零日期错误(`TRADITIONAL SQL` 模式下) + - “下一步”与“提交”含义不统一,用户易误操作 + - 表格行式编辑密度过高,填写多项参数不友好 + - 动态导入失败来源于 SFC 结构不合法、重复脚本块或未声明变量导致编译失败 + +## 重构设计(交互与结构) +### 向导统一(单入口) +- 入口:统一从“管理页弹窗向导”打开;支持跳转到独立页,但独立页沿用同一套逻辑(避免两套逻辑分歧) +- 步骤: + 1. 基本信息(活动):名称、分类、状态、门票价格、Boss、开始/结束时间 + 2. 期信息(活动期):期号、状态、排序 + 3. 奖励配置:按商品选择与参数设置(权重、数量、原始数量、等级、排序、Boss) + 4. 确认提交:汇总校验与最终提交 +- 导航: + - “下一步”即执行当前步的提交(成功后推进),失败则停留并有明确错误提示 + - “上一步”回退不丢数据;每步保存草稿状态在组件内存 + +### 表单布局与输入体验 +- 两列栅格布局(`ElRow/ElCol`):提升可读性与并行填写效率 +- 奖励配置交互: + - 改为“卡片列表 + 弹窗表单”,卡片上显示商品名称及关键参数;“新增奖励/编辑奖励”在弹窗中填写,分两列布局 + - 商品选择:远程搜索 `admin/products`(`web/admin/src/api/product.ts`),选择后自动回填奖励名称(传递给后端以兼容) + - 支持复制卡片、删除卡片、批量导入(CSV/JSON)作为扩展项(初版可不做批量) +- 字段统一:奖励行统一字段为 `product_id、weight、quantity、original_qty、level、sort、is_boss`;`name`由选品自动补齐,不再手填 +- 权限与提示:检测 `SessionUserInfo.IsSuper`(控制器内已做),前端在 403/400 的返回文案时专门弹出“权限不足”提示 + +### 校验与防错 +- 实时校验规则: + - 活动:名称与分类必填;开始/结束时间支持空,但若为空则前端默认以 `ISO8601` 当前时间传入或后端统一允许 `NULL` + - 期:期号必填;排序与状态为数值校验 + - 奖励:`product_id` 必填;数值项均为非负或正数;`level` 合法枚举 +- 时间防错: + - 方案优先级A:数据库将 `start_time/end_time` 改为可 `NULL` 且默认 `NULL`(推荐); + - 方案优先级B:前端在未选择时默认传当前时间的 `toISOString()`; + - 后端保留 `Omit` 逻辑(`internal/service/activity/activity_create.go:29-35`),避免强行写零日期 + +### 代码结构与可维护性 +- 组件结构:每个步骤独立子组件(`ActivityStepBasic.vue`、`ActivityStepIssue.vue`、`ActivityStepRewards.vue`)+ 容器(`ActivityWizard.vue`) +- 状态管理:使用本地状态(`ref/reactive`)即可;如需跨页保持,用 `pinia` 记录向导草稿 +- API层:沿用 `web/admin/src/api/adminActivities.ts`,奖励提交前统一映射自动补齐 `name` +- 动态导入稳定性: + - 确保每个 SFC 仅一个 `` 和一个 ` - - + + diff --git a/docs/删除操作交互流程整改计划.md b/docs/删除操作交互流程整改计划.md new file mode 100644 index 0000000..e104f49 --- /dev/null +++ b/docs/删除操作交互流程整改计划.md @@ -0,0 +1,290 @@ +# 删除操作交互流程整改计划 + +## 项目背景 + +根据用户要求,需要检查并修改系统中所有删除操作相关的交互流程,确保满足统一的交互规范。 + +## 整改完成情况 ✅ + +### 已完成整改的模块(2025-11-19) + +#### ✅ 活动管理模块 +- **文件**: `/web/admin/src/views/activity/manage/index.vue` +- **修改内容**: 添加删除确认对话框,显示活动名称;添加成功/失败提示,包含具体错误原因 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 活动期数管理模块 +- **文件**: `/web/admin/src/views/activity/issues/index.vue` +- **修改内容**: 添加删除确认对话框,显示期数信息;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 活动奖励管理模块 +- **文件**: `/web/admin/src/views/activity/rewards/index.vue` +- **修改内容**: 添加删除确认对话框,显示奖品名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 商品分类管理模块 +- **文件**: `/web/admin/src/views/product/categories/index.vue` +- **修改内容**: 添加删除确认对话框,显示分类名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 商品列表管理模块 +- **文件**: `/web/admin/src/views/product/list/index.vue` +- **修改内容**: 添加删除确认对话框,显示商品名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 工会管理模块 +- **文件**: `/web/admin/src/views/guild/manage/index.vue` +- **修改内容**: 添加删除确认对话框,显示工会名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ Banner管理模块 +- **文件**: `/web/admin/src/views/operations/banner/index.vue` +- **修改内容**: 添加删除确认对话框,显示Banner标题;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 发货统计管理模块 +- **文件**: `/web/admin/src/views/operations/shipping-stats/index.vue` +- **修改内容**: 添加删除确认对话框,显示商品名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 + +#### ✅ 系统菜单管理模块 +- **文件**: `/web/admin/src/views/system/menu/index.vue` +- **修改内容**: 添加删除确认对话框,显示菜单/权限名称;添加成功/失败提示 +- **测试状态**: ✅ TypeScript编译通过 +- **API更新**: 新增`deleteMenu`和`deleteAuth`函数 +- **BUG修复**: 修复`beforeClose`函数中缺少`done()`调用的问题 + +## 整改规范要求(已实施) + +### 1. 删除前确认 ✅ +- 每个删除操作前必须弹出确认对话框 +- 确认框需明确显示"确定要删除[对象名称]吗?"的提示信息 +- 提供"确定"和"取消"两个操作按钮 +- 确认按钮具有加载状态,防止重复点击 + +### 2. 删除后反馈 ✅ +- 成功删除后显示toast提示"[对象名称]已成功删除" +- 删除失败时显示错误提示"[对象名称]删除失败:原因说明" +- 提示信息需在界面停留3-4秒后自动消失 +- 错误信息包含后端返回的具体原因 + +### 3. 技术实现 ✅ +- 使用Element Plus的ElMessageBox.confirm实现确认对话框 +- 使用ElMessage.success/error实现提示信息 +- 错误信息提取后端返回的具体原因(error.response.data.message) +- 提示时长统一设置为3000-4000毫秒 +- 完善的异常处理,区分用户取消和系统错误 + +## 测试指南 + +### 开发环境测试 +1. 启动开发服务器:`npm run dev` +2. 访问系统:`http://localhost:3008` +3. 测试各模块删除功能,验证以下场景: + - ✅ 删除确认对话框显示正确对象名称 + - ✅ 取消删除操作无提示信息 + - ✅ 成功删除显示成功提示,包含对象名称 + - ✅ 删除失败显示错误提示,包含具体原因 + - ✅ 提示信息3-4秒后自动消失 + +### TypeScript编译验证 ✅ +- 运行 `npx vue-tsc --noEmit` 验证无类型错误 +- 所有组件均通过TypeScript类型检查 + +## 技术实现细节 + +### 统一实现模式 +所有删除功能遵循以下代码模式: + +```typescript +const handleDelete = async (row: DataType): Promise => { + try { + // 获取对象名称用于提示 + const objectName = row.name || row.title || '该数据' + + // 显示确认对话框 + await ElMessageBox.confirm( + `确定要删除"${objectName}"吗?删除后无法恢复`, + '删除确认', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + beforeClose: (action, instance, done) => { + if (action === 'confirm') { + instance.confirmButtonLoading = true + } else { + done() + } + } + } + ) + + // 执行删除操作 + await deleteApiFunction(row.id) + + // 显示成功提示 + ElMessage.success({ + message: `"${objectName}"已成功删除`, + duration: 3000 + }) + + // 刷新数据列表 + loadData() + + } catch (error: any) { + // 用户取消操作 + if (error === 'cancel') { + return + } + + // 显示错误提示 + const errorMessage = error?.response?.data?.message || error.message || '删除失败' + const objectName = row.name || row.title || '该数据' + ElMessage.error({ + message: `"${objectName}"删除失败:${errorMessage}`, + duration: 4000 + }) + } +} +``` + +### API接口新增 +在`/web/admin/src/api/system-manage.ts`中新增: +- `deleteMenu(id: number)` - 删除菜单 +- `deleteAuth(id: number)` - 删除权限 + +## 质量保障 + +### ✅ 代码质量 +- 所有组件均添加详细的函数注释 +- 统一的错误处理机制 +- 完善的类型安全检查 +- 防止重复点击的加载状态 + +### ✅ 用户体验 +- 清晰的确认对话框,避免误操作 +- 友好的成功/失败提示,包含具体对象名称 +- 合理的提示时长,既不会太短也不会干扰用户 +- 错误信息具体明确,便于问题排查 + +### ✅ 技术规范 +- 严格遵循Vue 3 Composition API规范 +- TypeScript类型安全,无编译错误 +- 统一的Element Plus组件使用 +- 标准化的异步处理模式 + +## 🐛 重要BUG修复 + +### 问题描述 +所有删除操作的确认对话框点击"确定"后,**没有调用删除接口**,导致删除功能失效。 + +### 问题原因 +在`beforeClose`函数中,当用户点击确认时,代码设置了`instance.confirmButtonLoading = true`但**没有调用`done()`函数**,导致确认对话框无法关闭,后续的删除操作无法执行。 + +### 修复方案 +在所有删除函数的`beforeClose`函数中,当`action === 'confirm'`时,添加`done()`调用: + +```typescript +beforeClose: (action, instance, done) => { + if (action === 'confirm') { + instance.confirmButtonLoading = true + done() // ← 添加这一行,允许对话框关闭并继续执行删除操作 + } else { + done() + } +} +``` + +### 影响范围 +修复涉及以下9个文件的所有删除函数: +- ✅ 活动管理 (`/web/admin/src/views/activity/manage/index.vue`) +- ✅ 活动期数管理 (`/web/admin/src/views/activity/issues/index.vue`) +- ✅ 活动奖励管理 (`/web/admin/src/views/activity/rewards/index.vue`) +- ✅ 商品分类管理 (`/web/admin/src/views/product/categories/index.vue`) +- ✅ 商品列表管理 (`/web/admin/src/views/product/list/index.vue`) +- ✅ 工会管理 (`/web/admin/src/views/guild/manage/index.vue`) +- ✅ Banner管理 (`/web/admin/src/views/operations/banner/index.vue`) +- ✅ 发货统计管理 (`/web/admin/src/views/operations/shipping-stats/index.vue`) +- ✅ 系统菜单管理 (`/web/admin/src/views/system/menu/index.vue`) + +## 项目完成总结 ✅ + +### 任务完成状态 +- **分析阶段**: ✅ 完成 - 识别了9个需要整改的模块 +- **实施阶段**: ✅ 完成 - 修改了9个Vue组件文件 +- **API补充**: ✅ 完成 - 新增2个API删除函数 +- **类型检查**: ✅ 完成 - 通过TypeScript编译验证 +- **开发测试**: ✅ 完成 - 开发服务器正常运行 + +### 整改成果 +1. **统一交互体验**: 所有删除操作现在具有统一的确认和提示流程 +2. **提升用户体验**: 清晰的确认对话框避免误操作,友好的提示信息提升使用体验 +3. **增强错误处理**: 完善的错误捕获和显示机制,便于问题排查 +4. **代码质量提升**: 标准化的代码模式,增强可维护性 +5. **类型安全保证**: TypeScript类型检查通过,减少运行时错误 + +### 后续建议 +1. **功能测试**: 建议在实际业务场景下测试各模块删除功能 +2. **性能优化**: 可考虑添加批量删除功能提升操作效率 +3. **权限控制**: 建议检查删除操作的权限控制是否完善 +4. **日志记录**: 可考虑添加删除操作的审计日志 + +## 整改实施计划(已执行) + +### 第一阶段:核心功能整改 +1. 活动管理相关删除功能 +2. 商品管理相关删除功能 +3. 工会管理删除功能 + +### 第二阶段:运营配置整改 +1. Banner管理删除功能 +2. 发货统计删除功能优化 + +### 第三阶段:统一优化 +1. 现有功能的标准化检查 +2. 提示文案统一 +3. 时长设置统一 + +## 测试要求 + +### 功能测试 +- 每个删除功能都需要测试确认对话框显示 +- 测试成功删除后的提示信息显示 +- 测试删除失败后的错误提示显示 +- 验证提示信息的自动消失时间 + +### 响应式测试 +- 在不同屏幕尺寸下测试对话框显示效果 +- 验证移动端和桌面端的交互体验 + +### 错误处理测试 +- 模拟网络错误测试失败提示 +- 模拟权限不足场景 +- 模拟数据不存在场景 + +## 交付标准 + +1. 所有删除操作必须符合上述交互规范 +2. 代码修改需添加相应注释说明修改原因 +3. 更新相关文档中的操作说明部分 +4. 提供完整的测试报告 + +## 风险评估 + +### 技术风险 +- 后端API错误信息格式不一致,需要统一处理 +- 部分删除操作可能涉及级联删除,需要明确提示 + +### 业务风险 +- 删除操作增加确认步骤可能影响用户体验 +- 需要确保重要数据的删除操作有充分的警告提示 + +## 时间计划 + +预计整改工作需要2-3个工作日完成,具体安排如下: + +- 第1天:完成核心功能整改 +- 第2天:完成运营配置整改和统一优化 +- 第3天:进行全面测试和文档更新 \ No newline at end of file diff --git a/docs/奖励管理字段拆分/ALIGNMENT_奖励管理字段拆分.md b/docs/奖励管理字段拆分/ALIGNMENT_奖励管理字段拆分.md new file mode 100644 index 0000000..e557ab2 --- /dev/null +++ b/docs/奖励管理字段拆分/ALIGNMENT_奖励管理字段拆分.md @@ -0,0 +1,46 @@ +# 奖励管理字段拆分任务对齐文档 + +## 项目背景 +在奖励管理系统中,发现 `th` 和 `div` 这两个元素存在重复显示的问题,具体表现为: +- 在订单详情页面(`/Users/win/code2025/bindbox_game/web/admin/src/views/orders/list/index.vue` 第97行) +- 在奖励管理页面(`/Users/win/code2025/bindbox_game/web/admin/src/views/activity/rewards/index.vue` 第37行、第117行、第203行) + +## 当前问题 +1. 商品字段和价格信息在同一个表格列中混合显示 +2. 数据绑定逻辑不清晰,影响用户体验 +3. 缺乏独立的字段验证和处理逻辑 + +## 需求理解 +需要将现有的组合字段拆分为两个独立的字段: +1. **商品字段**:专门用于显示或输入商品信息 +2. **价格字段**:专门用于显示或输入价格信息 + +## 任务范围 +### 包含范围 +- 前端UI组件修改 +- 数据绑定逻辑更新 +- 验证逻辑更新 +- 单元测试添加 + +### 不包含范围 +- 后端API重大修改(仅适配前端需求) +- 数据库结构变更(如非必要) + +## 技术约束 +- 前端技术栈:Vue 3 + TypeScript + Element Plus +- 状态管理:使用现有模式 +- 代码风格:遵循项目现有规范 +- 测试框架:使用项目现有测试工具 + +## 疑问澄清 +1. 是否需要修改数据库表结构? +2. 是否需要更新相关的API接口? +3. 价格字段是否需要支持编辑功能? +4. 是否有特定的UI样式要求? + +## 验收标准 +- [ ] 商品和价格字段在UI上清晰区分 +- [ ] 原有功能逻辑保持不变 +- [ ] 数据绑定和验证逻辑正确更新 +- [ ] 通过所有单元测试 +- [ ] 代码符合项目规范 \ No newline at end of file diff --git a/docs/奖励管理字段拆分/CONSENSUS_奖励管理字段拆分.md b/docs/奖励管理字段拆分/CONSENSUS_奖励管理字段拆分.md new file mode 100644 index 0000000..566d9d3 --- /dev/null +++ b/docs/奖励管理字段拆分/CONSENSUS_奖励管理字段拆分.md @@ -0,0 +1,69 @@ +# 奖励管理字段拆分任务共识文档 + +## 明确的需求描述和验收标准 + +### 需求描述 +将奖励管理系统中商品和价格混合显示的字段拆分为两个独立的字段,确保数据展示的清晰性和操作的独立性。 + +### 验收标准 +1. **UI展示标准** + - 商品字段仅显示商品名称和基本信息 + - 价格字段独立显示,格式统一为¥XX.XX + - 两个字段在表格中有明确的分隔和标识 + +2. **功能标准** + - 商品选择功能保持原有逻辑 + - 价格显示精度保持两位小数 + - 批量操作功能不受影响 + +3. **数据标准** + - 数据绑定正确无误 + - 验证逻辑独立且有效 + - 数据传输格式保持一致 + +## 技术实现方案 + +### 前端修改方案 +1. **订单详情页面** (`orders/list/index.vue`) + - 将第97行的商品列拆分为"商品"和"单价"两列 + - 商品列显示商品标题 + - 单价列显示商品单价(从total_amount和quantity计算) + +2. **奖励管理页面** (`activity/rewards/index.vue`) + - 修改第37行的模板显示逻辑 + - 将商品选择器中的价格信息分离 + - 在表格中添加独立的价格列 + +### 数据绑定更新 +- 保持现有的product_id绑定关系 +- 价格数据通过productOptions缓存获取 +- 确保批量操作时的数据一致性 + +### 验证逻辑更新 +- 商品字段:必填验证 +- 价格字段:数值范围验证(≥0) + +## 任务边界限制 + +### 包含范围 +- 前端Vue组件修改 +- TypeScript类型定义更新 +- 单元测试编写 +- 现有API适配 + +### 不包含范围 +- 数据库结构修改 +- 后端业务逻辑重大调整 +- 新增API接口 + +## 技术约束 +- 使用Element Plus组件库 +- 保持响应式设计 +- 遵循项目代码规范 +- 兼容现有主题系统 + +## 确认事项 +1. ✅ 需求边界已明确 +2. ✅ 技术方案已确定 +3. ✅ 验收标准已制定 +4. ✅ 风险点已识别 \ No newline at end of file diff --git a/docs/奖励管理字段拆分/DESIGN_奖励管理字段拆分.md b/docs/奖励管理字段拆分/DESIGN_奖励管理字段拆分.md new file mode 100644 index 0000000..738207f --- /dev/null +++ b/docs/奖励管理字段拆分/DESIGN_奖励管理字段拆分.md @@ -0,0 +1,153 @@ +# 奖励管理字段拆分设计方案 + +## 架构设计 + +### 整体架构图 +```mermaid +graph TD + A[现有系统] --> B[字段拆分模块] + B --> C[订单详情页面] + B --> D[奖励管理页面] + C --> E[商品列] + C --> F[价格列] + D --> G[商品选择器] + D --> H[价格显示列] + + E --> I[商品数据绑定] + F --> J[价格数据绑定] + G --> K[商品选择逻辑] + H --> L[价格计算逻辑] +``` + +### 模块设计 + +#### 1. 订单详情模块 +**位置**: `/Users/win/code2025/bindbox_game/web/admin/src/views/orders/list/index.vue` + +**修改点**: +- 第97行:将``拆分为两列 +- 新增商品列:仅显示商品标题 +- 新增单价列:显示商品单价(通过total_amount/quantity计算) + +**数据结构**: +```typescript +interface OrderItem { + title: string; // 商品标题 + quantity: number; // 数量 + total_amount: number; // 总金额(分) + unit_price: number; // 单价(计算字段) +} +``` + +#### 2. 奖励管理模块 +**位置**: `/Users/win/code2025/bindbox_game/web/admin/src/views/activity/rewards/index.vue` + +**修改点**: +- 第37行:修改模板显示逻辑,分离商品和价格显示 +- 第117-121行:修改商品选择器,分离商品名称和价格显示 +- 第203行:修改表格列定义,添加独立价格列 + +**组件结构**: +```vue + + + + + {{ p.name }} + ¥{{ formatPrice(p.price) }} + + + + + + + +``` + +### 数据流向图 +```mermaid +sequenceDiagram + participant UI as 前端界面 + participant API as API接口 + participant Cache as 价格缓存 + participant DB as 数据库 + + UI->>API: 请求商品列表 + API->>DB: 查询商品数据 + DB-->>API: 返回商品信息 + API-->>UI: 返回商品列表(含价格) + UI->>Cache: 缓存价格信息 + UI->>UI: 渲染商品选择器 + UI->>Cache: 获取商品价格 + UI->>UI: 显示独立的价格列 +``` + +### 接口契约定义 + +#### 商品数据接口 +```typescript +interface Product { + id: number; + name: string; + price: number; // 价格(分) + // 其他商品属性... +} + +interface Reward { + id: number; + name: string; + product_id: number; + product_name?: string; // 商品名称(计算字段) + product_price?: number; // 商品价格(计算字段) + // 其他奖励属性... +} +``` + +### 异常处理策略 +1. **价格计算异常**: 使用默认值0,记录警告日志 +2. **商品数据缺失**: 显示"商品已删除"提示 +3. **缓存失效**: 重新请求商品数据,更新缓存 + +## 核心组件设计 + +### 1. 价格计算工具函数 +```typescript +// 价格格式化工具 +export function formatPrice(price: number): string { + return `¥${(price / 100).toFixed(2)}`; +} + +// 单价计算 +export function calculateUnitPrice(totalAmount: number, quantity: number): number { + if (quantity <= 0) return 0; + return totalAmount / quantity; +} +``` + +### 2. 商品选择组件 +```typescript +interface ProductSelectProps { + modelValue: number; + products: Product[]; + placeholder?: string; +} + +interface ProductSelectEmits { + (e: 'update:modelValue', value: number): void; +} +``` + +### 3. 价格显示组件 +```typescript +interface PriceDisplayProps { + price: number; // 分 + currency?: string; + precision?: number; +} +``` + +## 依赖关系 +- Element Plus UI组件库 +- 项目现有的价格格式化工具 +- 商品数据缓存机制 +- TypeScript类型系统 \ No newline at end of file diff --git a/docs/奖励管理字段拆分/TASK_奖励管理字段拆分.md b/docs/奖励管理字段拆分/TASK_奖励管理字段拆分.md new file mode 100644 index 0000000..6d4b249 --- /dev/null +++ b/docs/奖励管理字段拆分/TASK_奖励管理字段拆分.md @@ -0,0 +1,158 @@ +# 奖励管理字段拆分任务分解 + +## 任务依赖图 +```mermaid +graph TD + A[任务1: 订单详情页面修改] --> D[任务4: 集成测试] + B[任务2: 奖励管理页面修改] --> D + C[任务3: 工具函数和类型定义] --> A + C --> B + D --> E[任务5: 单元测试编写] + E --> F[任务6: 功能验证] +``` + +## 原子任务列表 + +### 任务1: 订单详情页面修改 +**优先级**: 高 +**前置依赖**: 任务3 +**输入契约**: +- 现有订单详情页面代码 +- 商品数据结构定义 +- 价格计算工具函数 + +**输出契约**: +- 拆分的商品列和价格列 +- 更新的模板渲染逻辑 +- 单价计算逻辑 + +**实现约束**: +- 保持原有功能不变 +- 使用Element Plus表格组件 +- 遵循Vue 3 Composition API规范 + +**验收标准**: +- [ ] 商品列仅显示商品标题 +- [ ] 价格列显示正确单价 +- [ ] 计算逻辑准确无误 + +--- + +### 任务2: 奖励管理页面修改 +**优先级**: 高 +**前置依赖**: 任务3 +**输入契约**: +- 现有奖励管理页面代码 +- 商品选择器组件 +- 价格缓存机制 + +**输出契约**: +- 独立的商品选择器 +- 分离的价格显示列 +- 更新的表格列定义 + +**实现约束**: +- 保持批量操作功能 +- 维护现有的筛选和排序功能 +- 兼容现有的商品缓存机制 + +**验收标准**: +- [ ] 商品选择器功能正常 +- [ ] 价格列显示正确 +- [ ] 批量创建功能不受影响 + +--- + +### 任务3: 工具函数和类型定义 +**优先级**: 高 +**前置依赖**: 无 +**输入契约**: +- 项目现有工具函数规范 +- TypeScript类型定义规范 + +**输出契约**: +- 价格格式化工具函数 +- 单价计算函数 +- 更新的TypeScript接口定义 + +**实现约束**: +- 使用TypeScript严格模式 +- 遵循项目命名规范 +- 添加必要的函数注释 + +**验收标准**: +- [ ] 函数类型安全 +- [ ] 边界条件处理 +- [ ] 单元测试覆盖 + +--- + +### 任务4: 集成测试 +**优先级**: 中 +**前置依赖**: 任务1、任务2 +**输入契约**: +- 修改后的页面组件 +- 测试环境配置 + +**输出契约**: +- 页面功能测试报告 +- 性能测试结果 +- 兼容性验证报告 + +**实现约束**: +- 使用项目现有测试框架 +- 覆盖主要用户场景 +- 记录性能指标 + +**验收标准**: +- [ ] 所有功能正常 +- [ ] 性能无明显下降 +- [ ] 无回归问题 + +--- + +### 任务5: 单元测试编写 +**优先级**: 中 +**前置依赖**: 任务4 +**输入契约**: +- 新增的工具函数 +- 修改后的组件逻辑 + +**输出契约**: +- 工具函数单元测试 +- 组件逻辑测试用例 +- 测试覆盖率报告 + +**实现约束**: +- 遵循测试驱动开发原则 +- 覆盖边界条件和异常场景 +- 测试代码可维护性 + +**验收标准**: +- [ ] 测试覆盖率>80% +- [ ] 所有测试通过 +- [ ] 测试代码质量良好 + +--- + +### 任务6: 功能验证 +**优先级**: 高 +**前置依赖**: 任务5 +**输入契约**: +- 完整的代码修改 +- 测试用例和报告 + +**输出契约**: +- 功能验证清单 +- 用户验收测试报告 +- 部署准备清单 + +**实现约束**: +- 按照验收标准逐项验证 +- 记录所有问题和解决方案 +- 准备部署文档 + +**验收标准**: +- [ ] 所有需求已实现 +- [ ] 无已知缺陷 +- [ ] 文档完整准确 \ No newline at end of file diff --git a/docs/开发规范.md b/docs/开发规范.md index 438e482..7ee41d8 100644 --- a/docs/开发规范.md +++ b/docs/开发规范.md @@ -91,6 +91,32 @@ func (h *handler) CreateAdmin() core.HandlerFunc { } ``` +### 9.7 权限矩阵与中间件 +- 管理端所有接口统一要求携带 `Authorization`,经 JWT 校验后方可访问;未登录或令牌无效返回 `401`。 +- 管理端认证组统一前置 `RequireAdminRole` 校验:非超管必须至少绑定一个角色,否则返回 `403`。 +- 敏感接口增加动作级权限校验 `RequireAdminAction(action_mark)`: +- 工会: + - `POST /api/admin/guilds` → `guild:create` + - `PUT /api/admin/guilds/:guild_id` → `guild:modify` + - `DELETE /api/admin/guilds/:guild_id` → `guild:delete` + - `GET /api/admin/guilds/:guild_id` → `guild:view` + - `GET /api/admin/guilds/:guild_id/members` → `guild:view` + - `GET /api/admin/guilds/:guild_id/applications` → `guild:view` + - `POST /api/admin/guilds/:guild_id/applications/:member_id/approve` → `guild:member:approve` + - `POST /api/admin/guilds/:guild_id/applications/:member_id/reject` → `guild:member:reject` + - `DELETE /api/admin/guilds/:guild_id/members/:user_id` → `guild:member:delete` +- 商品:`product:create`、`product:modify`、`product:batch:modify`、`product:delete`、`product:view` +- 轮播图:`banner:create`、`banner:modify`、`banner:delete`、`banner:view` +- 系统称号与效果:`title:view`、`title:create`、`title:modify`、`title:delete`、`title:effect:create`、`title:effect:modify`、`title:effect:delete`、`title:assign` +- 道具卡:`itemcard:create`、`itemcard:modify`、`itemcard:delete`、`itemcard:view` +- 优惠券:`coupon:create`、`coupon:modify`、`coupon:delete`、`coupon:view` +- 订单与退款:`order:view`、`order:modify`、`order:cancel`、`order:consume`、`order:export`、`refund:create`、`refund:view` +- 发货统计:`ops:shipping:view`、`ops:shipping:write` +- 角色-动作分配通过现有接口维护: + - `POST /api/role/:role_id/actions` 分配动作 + - `GET /api/menu/:menu_id/actions` 查看动作 +- 权限校验失败统一返回 `403`,响应结构为标准 `code.Failure`。 + ### 4.2 参数绑定与校验 * **绑定(JSON)**:POST/PUT 等带请求体的接口使用 `ctx.ShouldBindJSON(req)` 绑定到预定义的 `Request` 结构体。 * **绑定(Query)**:GET 查询接口使用 `ctx.ShouldBindForm(req)` 绑定到预定义的 `Request` 结构体(字段使用 `form:"..."` tag)。 diff --git a/internal/api/activity/rewards_app.go b/internal/api/activity/rewards_app.go index b11811c..405ed71 100644 --- a/internal/api/activity/rewards_app.go +++ b/internal/api/activity/rewards_app.go @@ -1,23 +1,25 @@ package app import ( - "net/http" - "strconv" + "encoding/json" + "net/http" + "strconv" - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" ) type rewardItem struct { - ProductID int64 `json:"product_id"` - Name string `json:"name"` - Weight int32 `json:"weight"` - Quantity int64 `json:"quantity"` - OriginalQty int64 `json:"original_qty"` - Level int32 `json:"level"` - Sort int32 `json:"sort"` - IsBoss int32 `json:"is_boss"` + ProductID int64 `json:"product_id"` + Name string `json:"name"` + Weight int32 `json:"weight"` + Quantity int64 `json:"quantity"` + OriginalQty int64 `json:"original_qty"` + Level int32 `json:"level"` + Sort int32 `json:"sort"` + IsBoss int32 `json:"is_boss"` + ProductImage string `json:"product_image"` } type listRewardsResponse struct { @@ -36,36 +38,64 @@ type listRewardsResponse struct { // @Failure 400 {object} code.Failure // @Router /api/app/activities/{activity_id}/issues/{issue_id}/rewards [get] func (h *handler) ListIssueRewards() core.HandlerFunc { - return func(ctx core.Context) { - issueIDStr := ctx.Param("issue_id") - if issueIDStr == "" { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递期ID")) - return - } - if _, err := strconv.ParseInt(issueIDStr, 10, 64); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) - return - } - issueID, _ := strconv.ParseInt(issueIDStr, 10, 64) - items, err := h.activity.ListIssueRewards(ctx.RequestContext(), issueID) - if err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListIssueRewardsError, err.Error())) - return - } - res := new(listRewardsResponse) - res.List = make([]rewardItem, len(items)) - for i, v := range items { - res.List[i] = rewardItem{ - ProductID: v.ProductID, - Name: v.Name, - Weight: v.Weight, - Quantity: v.Quantity, - OriginalQty: v.OriginalQty, - Level: v.Level, - Sort: v.Sort, - IsBoss: v.IsBoss, - } - } - ctx.Payload(res) - } + return func(ctx core.Context) { + issueIDStr := ctx.Param("issue_id") + if issueIDStr == "" { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递期ID")) + return + } + if _, err := strconv.ParseInt(issueIDStr, 10, 64); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + issueID, _ := strconv.ParseInt(issueIDStr, 10, 64) + items, err := h.activity.ListIssueRewards(ctx.RequestContext(), issueID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListIssueRewardsError, err.Error())) + return + } + pidSet := make(map[int64]struct{}) + for _, v := range items { + if v.ProductID > 0 { + pidSet[v.ProductID] = struct{}{} + } + } + imageMap := make(map[int64]string) + if len(pidSet) > 0 { + ids := make([]int64, 0, len(pidSet)) + for id := range pidSet { + ids = append(ids, id) + } + pros, err := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.In(ids...)).Find() + if err == nil { + for _, p := range pros { + first := "" + if p.ImagesJSON != "" { + var arr []string + _ = json.Unmarshal([]byte(p.ImagesJSON), &arr) + if len(arr) > 0 { + first = arr[0] + } + } + imageMap[p.ID] = first + } + } + } + res := new(listRewardsResponse) + res.List = make([]rewardItem, len(items)) + for i, v := range items { + res.List[i] = rewardItem{ + ProductID: v.ProductID, + Name: v.Name, + Weight: v.Weight, + Quantity: v.Quantity, + OriginalQty: v.OriginalQty, + Level: v.Level, + Sort: v.Sort, + IsBoss: v.IsBoss, + ProductImage: imageMap[v.ProductID], + } + } + ctx.Payload(res) + } } diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index 8e82d64..dd72fd2 100644 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -1,42 +1,44 @@ package admin import ( - "bindbox-game/internal/pkg/logger" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/dao" - activitysvc "bindbox-game/internal/service/activity" - adminsvc "bindbox-game/internal/service/admin" - guildsvc "bindbox-game/internal/service/guild" - productsvc "bindbox-game/internal/service/product" - bannersvc "bindbox-game/internal/service/banner" - usersvc "bindbox-game/internal/service/user" - titlesvc "bindbox-game/internal/service/title" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + activitysvc "bindbox-game/internal/service/activity" + adminsvc "bindbox-game/internal/service/admin" + bannersvc "bindbox-game/internal/service/banner" + guildsvc "bindbox-game/internal/service/guild" + productsvc "bindbox-game/internal/service/product" + titlesvc "bindbox-game/internal/service/title" + usersvc "bindbox-game/internal/service/user" ) type handler struct { - logger logger.CustomLogger - writeDB *dao.Query - readDB *dao.Query - svc adminsvc.Service - activity activitysvc.Service - guild guildsvc.Service - product productsvc.Service - user usersvc.Service - banner bannersvc.Service - title titlesvc.Service + logger logger.CustomLogger + writeDB *dao.Query + readDB *dao.Query + repo mysql.Repo + svc adminsvc.Service + activity activitysvc.Service + guild guildsvc.Service + product productsvc.Service + user usersvc.Service + banner bannersvc.Service + title titlesvc.Service } func New(logger logger.CustomLogger, db mysql.Repo) *handler { - return &handler{ - logger: logger, - writeDB: dao.Use(db.GetDbW()), - readDB: dao.Use(db.GetDbR()), - svc: adminsvc.New(logger, db), - activity: activitysvc.New(logger, db), - guild: guildsvc.New(logger, db), - product: productsvc.New(logger, db), - user: usersvc.New(logger, db), - banner: bannersvc.New(logger, db), - title: titlesvc.New(logger, db), - } + return &handler{ + logger: logger, + writeDB: dao.Use(db.GetDbW()), + readDB: dao.Use(db.GetDbR()), + repo: db, + svc: adminsvc.New(logger, db), + activity: activitysvc.New(logger, db), + guild: guildsvc.New(logger, db), + product: productsvc.New(logger, db), + user: usersvc.New(logger, db), + banner: bannersvc.New(logger, db), + title: titlesvc.New(logger, db), + } } diff --git a/internal/api/admin/auth_refresh.go b/internal/api/admin/auth_refresh.go new file mode 100644 index 0000000..7a71199 --- /dev/null +++ b/internal/api/admin/auth_refresh.go @@ -0,0 +1,50 @@ +package admin + +import ( + "net/http" + "time" + + "bindbox-game/configs" + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/jwtoken" + "bindbox-game/internal/pkg/utils" +) + +type refreshResponse struct { + Token string `json:"token"` + ExpiresIn int64 `json:"expires_in"` +} + +// RefreshToken 刷新管理员访问令牌 +// @Summary 管理端令牌刷新 +// @Tags 管理端.登录 +// @Accept json +// @Produce json +// @Success 200 {object} refreshResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/auth/refresh [post] +// @Security LoginVerifyToken +func (h *handler) RefreshToken() core.HandlerFunc { + return func(ctx core.Context) { + auth := ctx.Request().Header.Get("Authorization") + if auth == "" { + ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "未携带令牌")) + return + } + newToken, err := jwtoken.New(configs.Get().JWT.AdminSecret).Refresh(auth) + if err != nil || newToken == "" { + ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "令牌刷新失败")) + return + } + info := ctx.SessionUserInfo() + if info.Id > 0 { + _, _ = h.writeDB.Admin.WithContext(ctx.RequestContext()).Where(h.writeDB.Admin.ID.Eq(int32(info.Id))).Updates(map[string]any{ + "last_login_time": time.Now(), + "last_login_ip": utils.GetIP(ctx.Request()), + "last_login_hash": utils.MD5(newToken), + }) + } + ctx.Payload(refreshResponse{Token: newToken, ExpiresIn: int64(24 * 3600)}) + } +} \ No newline at end of file diff --git a/internal/api/admin/issues_admin.go b/internal/api/admin/issues_admin.go index 2cc6e6d..09cf176 100644 --- a/internal/api/admin/issues_admin.go +++ b/internal/api/admin/issues_admin.go @@ -23,10 +23,11 @@ type listIssuesResponse struct { } type activitysvcIssueData struct { - ID int64 `json:"id"` - IssueNumber string `json:"issue_number"` - Status int32 `json:"status"` - Sort int32 `json:"sort"` + ID int64 `json:"id"` + IssueNumber string `json:"issue_number"` + Status int32 `json:"status"` + Sort int32 `json:"sort"` + PrizeCount int64 `json:"prize_count"` } // ListActivityIssues 查看活动期数 @@ -69,15 +70,21 @@ func (h *handler) ListActivityIssues() core.HandlerFunc { res.Page = req.Page res.PageSize = req.PageSize res.Total = total - res.List = make([]*activitysvcIssueData, len(items)) - for i, v := range items { - res.List[i] = &activitysvcIssueData{ - ID: v.ID, - IssueNumber: v.IssueNumber, - Status: v.Status, - Sort: v.Sort, - } - } + res.List = make([]*activitysvcIssueData, len(items)) + for i, v := range items { + var prizeCount int64 + count, err := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Count() + if err == nil { + prizeCount = count + } + res.List[i] = &activitysvcIssueData{ + ID: v.ID, + IssueNumber: v.IssueNumber, + Status: v.Status, + Sort: v.Sort, + PrizeCount: prizeCount, + } + } ctx.Payload(res) } } diff --git a/internal/api/admin/item_cards_admin.go b/internal/api/admin/item_cards_admin.go index dad930e..1b1e3c9 100644 --- a/internal/api/admin/item_cards_admin.go +++ b/internal/api/admin/item_cards_admin.go @@ -230,19 +230,21 @@ func (h *handler) ModifySystemItemCard() core.HandlerFunc { // @Failure 500 {object} code.Failure "服务器内部错误" // @Router /api/admin/system_item_cards/{item_card_id} [delete] func (h *handler) DeleteSystemItemCard() core.HandlerFunc { - return func(ctx core.Context) { - idStr := ctx.Param("item_card_id") - id, _ := strconv.ParseInt(idStr, 10, 64) - if ctx.SessionUserInfo().IsSuper != 1 { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) - return - } - if _, err := h.writeDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemItemCards.ID.Eq(id)).Delete(); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error())) - return - } - ctx.Payload(simpleMessageResponse{Message: "操作成功"}) - } + return func(ctx core.Context) { + idStr := ctx.Param("item_card_id") + id, _ := strconv.ParseInt(idStr, 10, 64) + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + if _, err := h.writeDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemItemCards.ID.Eq(id)).Updates(set); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error())) + return + } + ctx.Payload(simpleMessageResponse{Message: "操作成功"}) + } } type listItemCardsRequest struct { diff --git a/internal/api/admin/product_batch.go b/internal/api/admin/product_batch.go new file mode 100644 index 0000000..e6f3391 --- /dev/null +++ b/internal/api/admin/product_batch.go @@ -0,0 +1,62 @@ +package admin + +import ( + "net/http" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" +) + +type batchUpdateProductsRequest struct { + IDs []int64 `json:"ids" binding:"required"` + Stock *int64 `json:"stock"` + Status *int32 `json:"status"` +} + +type batchUpdateProductsResponse struct { + UpdatedCount int64 `json:"updated_count"` + Message string `json:"message"` +} + +// BatchUpdateProducts 批量更新商品 +// @Summary 批量更新商品(库存/上下架) +// @Tags 管理端.商品 +// @Accept json +// @Produce json +// @Param RequestBody body batchUpdateProductsRequest true "请求参数" +// @Success 200 {object} batchUpdateProductsResponse +// @Failure 400 {object} code.Failure +// @Router /api/admin/products/batch [put] +// @Security LoginVerifyToken +func (h *handler) BatchUpdateProducts() core.HandlerFunc { + return func(ctx core.Context) { + req := new(batchUpdateProductsRequest) + res := new(batchUpdateProductsResponse) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + if len(req.IDs) == 0 || (req.Stock == nil && req.Status == nil) { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误")) + return + } + if len(req.IDs) > 1000 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "最多支持1000个ID")) + return + } + + updated, err := h.product.BatchUpdate(ctx.RequestContext(), req.IDs, req.Stock, req.Status) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error())) + return + } + res.UpdatedCount = updated + res.Message = "操作成功" + ctx.Payload(res) + } +} \ No newline at end of file diff --git a/internal/api/admin/system_coupons.go b/internal/api/admin/system_coupons.go index 3e3f4fe..f972acd 100644 --- a/internal/api/admin/system_coupons.go +++ b/internal/api/admin/system_coupons.go @@ -132,19 +132,21 @@ func (h *handler) ModifySystemCoupon() core.HandlerFunc { } func (h *handler) DeleteSystemCoupon() core.HandlerFunc { - return func(ctx core.Context) { - idStr := ctx.Param("coupon_id") - id, _ := strconv.ParseInt(idStr, 10, 64) - if ctx.SessionUserInfo().IsSuper != 1 { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) - return - } - if _, err := h.writeDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemCoupons.ID.Eq(id)).Delete(); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error())) - return - } - ctx.Payload(pcSimpleMessage{Message: "操作成功"}) - } + return func(ctx core.Context) { + idStr := ctx.Param("coupon_id") + id, _ := strconv.ParseInt(idStr, 10, 64) + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + if _, err := h.writeDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemCoupons.ID.Eq(id)).Updates(set); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error())) + return + } + ctx.Payload(pcSimpleMessage{Message: "操作成功"}) + } } type listSystemCouponsRequest struct { diff --git a/internal/api/admin/system_menu.go b/internal/api/admin/system_menu.go index c8868e8..29854e2 100644 --- a/internal/api/admin/system_menu.go +++ b/internal/api/admin/system_menu.go @@ -1,11 +1,12 @@ package admin import ( - "net/http" - "strconv" + "net/http" + "strconv" + "time" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/repository/mysql/model" ) type simpleRoute struct { @@ -155,15 +156,17 @@ func (h *handler) ModifyMenu() core.HandlerFunc { } func (h *handler) DeleteMenu() core.HandlerFunc { - return func(ctx core.Context) { - idStr := ctx.Param("menu_id") - id, _ := strconv.ParseInt(idStr, 10, 64) - if _, err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Where(h.writeDB.Menus.ID.Eq(id)).Delete(); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, 10035, err.Error())) - return - } - ctx.Payload(map[string]string{"message": "ok"}) - } + return func(ctx core.Context) { + idStr := ctx.Param("menu_id") + id, _ := strconv.ParseInt(idStr, 10, 64) + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + if _, err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Where(h.writeDB.Menus.ID.Eq(id)).Updates(set); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 10035, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } } func (h *handler) ListMenuActions() core.HandlerFunc { @@ -209,17 +212,19 @@ func (h *handler) CreateMenuActions() core.HandlerFunc { } func (h *handler) DeleteMenuAction() core.HandlerFunc { - return func(ctx core.Context) { - idStr := ctx.Param("menu_id") - id, _ := strconv.ParseInt(idStr, 10, 64) - aidStr := ctx.Param("action_id") - aid, _ := strconv.ParseInt(aidStr, 10, 64) - if _, err := h.writeDB.MenuActions.WithContext(ctx.RequestContext()).Where(h.writeDB.MenuActions.ID.Eq(aid), h.writeDB.MenuActions.MenuID.Eq(id)).Delete(); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, 10039, err.Error())) - return - } - ctx.Payload(map[string]string{"message": "ok"}) - } + return func(ctx core.Context) { + idStr := ctx.Param("menu_id") + id, _ := strconv.ParseInt(idStr, 10, 64) + aidStr := ctx.Param("action_id") + aid, _ := strconv.ParseInt(aidStr, 10, 64) + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + if _, err := h.writeDB.MenuActions.WithContext(ctx.RequestContext()).Where(h.writeDB.MenuActions.ID.Eq(aid), h.writeDB.MenuActions.MenuID.Eq(id)).Updates(set); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 10039, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } } type assignMenusRequest struct { diff --git a/internal/api/admin/system_recycle.go b/internal/api/admin/system_recycle.go new file mode 100644 index 0000000..9498e93 --- /dev/null +++ b/internal/api/admin/system_recycle.go @@ -0,0 +1,63 @@ +package admin + +import ( + "net/http" + "strconv" + + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/service/recycle" +) + +type recycleListResponse struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + List []map[string]any `json:"list"` +} + +func (h *handler) ListRecycle() core.HandlerFunc { + return func(ctx core.Context) { + typ := ctx.RequestInputParams().Get("type") + pageStr := ctx.RequestInputParams().Get("page") + sizeStr := ctx.RequestInputParams().Get("page_size") + page, _ := strconv.Atoi(pageStr) + size, _ := strconv.Atoi(sizeStr) + svc := recycle.NewRaw(h.readDB, h.writeDB, h.repo.GetDbR(), h.repo.GetDbW()) + list, total, err := svc.List(ctx.RequestContext(), typ, page, size) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 19001, err.Error())) + return + } + if page <= 0 { page = 1 } + if size <= 0 { size = 20 } + ctx.Payload(recycleListResponse{Page: page, PageSize: size, Total: total, List: list}) + } +} + +func (h *handler) RestoreRecycle() core.HandlerFunc { + return func(ctx core.Context) { + typ := ctx.RequestInputParams().Get("type") + idStr := ctx.RequestInputParams().Get("id") + id, _ := strconv.ParseInt(idStr, 10, 64) + svc := recycle.NewRaw(h.readDB, h.writeDB, h.repo.GetDbR(), h.repo.GetDbW()) + if err := svc.Restore(ctx.RequestContext(), typ, id); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 19002, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } +} + +func (h *handler) ForceDeleteRecycle() core.HandlerFunc { + return func(ctx core.Context) { + typ := ctx.RequestInputParams().Get("type") + idStr := ctx.RequestInputParams().Get("id") + id, _ := strconv.ParseInt(idStr, 10, 64) + svc := recycle.NewRaw(h.readDB, h.writeDB, h.repo.GetDbR(), h.repo.GetDbW()) + if err := svc.ForceDelete(ctx.RequestContext(), typ, id); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 19003, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } +} \ No newline at end of file diff --git a/internal/api/admin/system_role.go b/internal/api/admin/system_role.go index 76f09ad..6151816 100644 --- a/internal/api/admin/system_role.go +++ b/internal/api/admin/system_role.go @@ -1,12 +1,13 @@ package admin import ( - "net/http" - "strconv" + "net/http" + "strconv" + "time" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" - "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/repository/mysql/model" ) type roleListRequest struct { @@ -162,15 +163,17 @@ func (h *handler) ModifyRole() core.HandlerFunc { } func (h *handler) DeleteRole() core.HandlerFunc { - return func(ctx core.Context) { - idStr := ctx.Param("role_id") - id, _ := strconv.ParseInt(idStr, 10, 64) - if _, err := h.writeDB.Roles.WithContext(ctx.RequestContext()).Where(h.writeDB.Roles.ID.Eq(id)).Delete(); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, 10018, err.Error())) - return - } - ctx.Payload(map[string]string{"message": "ok"}) - } + return func(ctx core.Context) { + idStr := ctx.Param("role_id") + id, _ := strconv.ParseInt(idStr, 10, 64) + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + if _, err := h.writeDB.Roles.WithContext(ctx.RequestContext()).Where(h.writeDB.Roles.ID.Eq(id)).Updates(set); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 10018, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } } type assignUsersRequest struct { @@ -212,16 +215,18 @@ func (h *handler) ListRoleUsers() core.HandlerFunc { } func (h *handler) RemoveRoleUser() core.HandlerFunc { - return func(ctx core.Context) { - roleIDStr := ctx.Param("role_id") - adminIDStr := ctx.Param("admin_id") - roleID, _ := strconv.ParseInt(roleIDStr, 10, 64) - adminID, _ := strconv.ParseInt(adminIDStr, 10, 64) - _, err := h.writeDB.RoleUsers.WithContext(ctx.RequestContext()).Where(h.writeDB.RoleUsers.RoleID.Eq(roleID), h.writeDB.RoleUsers.AdminID.Eq(int32(adminID))).Delete() - if err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, 10023, err.Error())) - return - } - ctx.Payload(map[string]string{"message": "ok"}) - } + return func(ctx core.Context) { + roleIDStr := ctx.Param("role_id") + adminIDStr := ctx.Param("admin_id") + roleID, _ := strconv.ParseInt(roleIDStr, 10, 64) + adminID, _ := strconv.ParseInt(adminIDStr, 10, 64) + uid := int64(ctx.SessionUserInfo().Id) + set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid} + _, err := h.writeDB.RoleUsers.WithContext(ctx.RequestContext()).Where(h.writeDB.RoleUsers.RoleID.Eq(roleID), h.writeDB.RoleUsers.AdminID.Eq(int32(adminID))).Updates(set) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 10023, err.Error())) + return + } + ctx.Payload(map[string]string{"message": "ok"}) + } } diff --git a/internal/repository/mysql/plugin.go b/internal/repository/mysql/plugin.go index 9c3f865..bc14b39 100644 --- a/internal/repository/mysql/plugin.go +++ b/internal/repository/mysql/plugin.go @@ -1,14 +1,14 @@ package mysql import ( - "time" + "time" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/timeutil" - "bindbox-game/internal/pkg/trace" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/timeutil" + "bindbox-game/internal/pkg/trace" - "gorm.io/gorm" - "gorm.io/gorm/utils" + "gorm.io/gorm" + "gorm.io/gorm/utils" ) const ( @@ -24,13 +24,14 @@ func (op *TracePlugin) Name() string { } func (op *TracePlugin) Initialize(db *gorm.DB) (err error) { - // 开始前 - _ = db.Callback().Create().Before("gorm:before_create").Register(callBackBeforeName, before) - _ = db.Callback().Query().Before("gorm:query").Register(callBackBeforeName, before) - _ = db.Callback().Delete().Before("gorm:before_delete").Register(callBackBeforeName, before) - _ = db.Callback().Update().Before("gorm:setup_reflect_value").Register(callBackBeforeName, before) - _ = db.Callback().Row().Before("gorm:row").Register(callBackBeforeName, before) - _ = db.Callback().Raw().Before("gorm:raw").Register(callBackBeforeName, before) + // 开始前 + _ = db.Callback().Create().Before("gorm:before_create").Register(callBackBeforeName, before) + _ = db.Callback().Query().Before("gorm:query").Register(callBackBeforeName, before) + _ = db.Callback().Query().Before("gorm:query").Register("soft_delete:filter", softDeleteFilter) + _ = db.Callback().Delete().Before("gorm:before_delete").Register(callBackBeforeName, before) + _ = db.Callback().Update().Before("gorm:setup_reflect_value").Register(callBackBeforeName, before) + _ = db.Callback().Row().Before("gorm:row").Register(callBackBeforeName, before) + _ = db.Callback().Raw().Before("gorm:raw").Register(callBackBeforeName, before) // 结束后 _ = db.Callback().Create().After("gorm:after_create").Register(callBackAfterName, after) @@ -38,8 +39,8 @@ func (op *TracePlugin) Initialize(db *gorm.DB) (err error) { _ = db.Callback().Delete().After("gorm:after_delete").Register(callBackAfterName, after) _ = db.Callback().Update().After("gorm:after_update").Register(callBackAfterName, after) _ = db.Callback().Row().After("gorm:row").Register(callBackAfterName, after) - _ = db.Callback().Raw().After("gorm:raw").Register(callBackAfterName, after) - return + _ = db.Callback().Raw().After("gorm:raw").Register(callBackAfterName, after) + return } var _ gorm.Plugin = &TracePlugin{} @@ -81,3 +82,41 @@ func after(db *gorm.DB) { return } + +const softDeleteIgnoreKey = "soft_delete_ignore" + +var softDeleteTables = map[string]struct{}{ + "activities": {}, + "activity_issues": {}, + "activity_reward_settings": {}, + "products": {}, + "product_categories": {}, + "banner": {}, + "guild": {}, + "system_titles": {}, + "system_title_effects": {}, + "system_item_cards": {}, + "system_coupons": {}, + "menus": {}, + "menu_actions": {}, + "roles": {}, + "role_users": {}, +} + +func softDeleteFilter(db *gorm.DB) { + v, ok := db.InstanceGet(softDeleteIgnoreKey) + if ok { + b, _ := v.(bool) + if b { + return + } + } + tbl := db.Statement.Table + if tbl == "" { + return + } + if _, ok := softDeleteTables[tbl]; !ok { + return + } + db.Where("deleted_at IS NULL") +} diff --git a/internal/repository/mysql/testrepo_sqlite.go b/internal/repository/mysql/testrepo_sqlite.go new file mode 100644 index 0000000..15b2208 --- /dev/null +++ b/internal/repository/mysql/testrepo_sqlite.go @@ -0,0 +1,23 @@ +package mysql + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type sqliteRepo struct { + DbR *gorm.DB + DbW *gorm.DB +} + +func (d *sqliteRepo) i() {} +func (d *sqliteRepo) GetDbR() *gorm.DB { return d.DbR } +func (d *sqliteRepo) GetDbW() *gorm.DB { return d.DbW } +func (d *sqliteRepo) DbRClose() error { return nil } +func (d *sqliteRepo) DbWClose() error { return nil } + +func NewSQLiteRepoForTest() (Repo, error) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { return nil, err } + return &sqliteRepo{DbR: db, DbW: db}, nil +} \ No newline at end of file diff --git a/internal/router/interceptor/admin_rbac.go b/internal/router/interceptor/admin_rbac.go new file mode 100644 index 0000000..f20a76e --- /dev/null +++ b/internal/router/interceptor/admin_rbac.go @@ -0,0 +1,58 @@ +package interceptor + +import ( + "net/http" + + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/repository/mysql/dao" +) + +func (i *interceptor) RequireAdminRole() core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper == 1 { + return + } + uid := int32(ctx.SessionUserInfo().Id) + cnt, err := dao.Use(i.db.GetDbR()).RoleUsers.WithContext(ctx.RequestContext()).Where(dao.Use(i.db.GetDbR()).RoleUsers.AdminID.Eq(uid)).Count() + if err != nil { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10103, "权限不足")) + return + } + if cnt == 0 { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10103, "权限不足")) + return + } + } +} + +func (i *interceptor) RequireAdminAction(mark string) core.HandlerFunc { + return func(ctx core.Context) { + if ctx.SessionUserInfo().IsSuper == 1 { + return + } + uid := int32(ctx.SessionUserInfo().Id) + roles, err := dao.Use(i.db.GetDbR()).RoleUsers.WithContext(ctx.RequestContext()).Where(dao.Use(i.db.GetDbR()).RoleUsers.AdminID.Eq(uid)).Find() + if err != nil || len(roles) == 0 { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10103, "权限不足")) + return + } + actions, err := dao.Use(i.db.GetDbR()).MenuActions.WithContext(ctx.RequestContext()).Where(dao.Use(i.db.GetDbR()).MenuActions.ActionMark.Eq(mark), dao.Use(i.db.GetDbR()).MenuActions.Status.Is(true)).Find() + if err != nil || len(actions) == 0 { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10103, "权限不足")) + return + } + roleIDs := make([]int64, len(roles)) + for i := range roles { + roleIDs[i] = roles[i].RoleID + } + actionIDs := make([]int64, len(actions)) + for i := range actions { + actionIDs[i] = actions[i].ID + } + cnt, err := dao.Use(i.db.GetDbR()).RoleActions.WithContext(ctx.RequestContext()).Where(dao.Use(i.db.GetDbR()).RoleActions.RoleID.In(roleIDs...), dao.Use(i.db.GetDbR()).RoleActions.ActionID.In(actionIDs...)).Count() + if err != nil || cnt == 0 { + ctx.AbortWithError(core.Error(http.StatusForbidden, 10103, "权限不足")) + return + } + } +} \ No newline at end of file diff --git a/internal/router/interceptor/admin_rbac_test.go b/internal/router/interceptor/admin_rbac_test.go new file mode 100644 index 0000000..0131757 --- /dev/null +++ b/internal/router/interceptor/admin_rbac_test.go @@ -0,0 +1,154 @@ +package interceptor + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/jwtoken" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/proposal" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" +) + +func setupDB(t *testing.T) mysql.Repo { + repo, err := mysql.NewSQLiteRepoForTest() + if err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().AutoMigrate(&model.Admin{}, &model.RoleUsers{}, &model.RoleActions{}, &model.MenuActions{}); err != nil { + t.Fatal(err) + } + return repo +} + +func newLogger(t *testing.T) logger.CustomLogger { + l, err := logger.NewCustomLogger(nil, logger.WithOutputInConsole()) + if err != nil { + t.Fatal(err) + } + return l +} + +func signToken(secret string, info proposal.SessionUserInfo) string { + token, _ := jwtoken.New(secret).Sign(info, time.Hour) + return token +} + +func TestRequireAdminRole_NoToken(t *testing.T) { + repo := setupDB(t) + l := newLogger(t) + intc := New(l, repo) + m, err := core.New(l) + if err != nil { + t.Fatal(err) + } + g := m.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + g.GET("/ping", intc.RequireAdminRole(), func(ctx core.Context) { ctx.Payload(map[string]string{"message": "ok"}) }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/admin/ping", nil) + m.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expect 401 got %d", w.Code) + } +} + +func TestRequireAdminRole_NoRole(t *testing.T) { + repo := setupDB(t) + l := newLogger(t) + intc := New(l, repo) + secret := "testsecret" + os.Setenv("ADMIN_JWT_SECRET", secret) + info := proposal.SessionUserInfo{Id: 1, UserName: "u", IsSuper: 0} + token := signToken(secret, info) + h := token + if err := repo.GetDbR().Create(&model.Admin{ID: 1, Username: "u", Nickname: "n", Avatar: "a", Mobile: "m", Password: "p", IsSuper: 0, LoginStatus: 1, LastLoginTime: time.Now(), LastLoginIP: "0.0.0.0", LastLoginHash: h, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + m, err := core.New(l) + if err != nil { + t.Fatal(err) + } + g := m.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + g.GET("/ping", intc.RequireAdminRole(), func(ctx core.Context) { ctx.Payload(map[string]string{"message": "ok"}) }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/admin/ping", nil) + req.Header.Set("Authorization", token) + m.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expect 403 got %d", w.Code) + } +} + +func TestRequireAdminAction_AllowedByRole(t *testing.T) { + repo := setupDB(t) + l := newLogger(t) + intc := New(l, repo) + secret := "testsecret" + os.Setenv("ADMIN_JWT_SECRET", secret) + info := proposal.SessionUserInfo{Id: 2, UserName: "u2", IsSuper: 0} + token := signToken(secret, info) + h := token + if err := repo.GetDbR().Create(&model.Admin{ID: 2, Username: "u2", Nickname: "n", Avatar: "a", Mobile: "m", Password: "p", IsSuper: 0, LoginStatus: 1, LastLoginTime: time.Now(), LastLoginIP: "0.0.0.0", LastLoginHash: h, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().Create(&model.RoleUsers{RoleID: 10, AdminID: 2, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().Create(&model.MenuActions{ID: 100, MenuID: 1, ActionMark: "guild:create", ActionName: "create", Status: true, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().Create(&model.RoleActions{RoleID: 10, ActionID: 100, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + m, err := core.New(l) + if err != nil { + t.Fatal(err) + } + g := m.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + g.POST("/guilds", intc.RequireAdminRole(), intc.RequireAdminAction("guild:create"), func(ctx core.Context) { ctx.Payload(map[string]string{"message": "ok"}) }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/admin/guilds", nil) + req.Header.Set("Authorization", token) + m.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expect 200 got %d", w.Code) + } +} + +func TestRequireAdminAction_Forbidden(t *testing.T) { + repo := setupDB(t) + l := newLogger(t) + intc := New(l, repo) + secret := "testsecret" + os.Setenv("ADMIN_JWT_SECRET", secret) + info := proposal.SessionUserInfo{Id: 3, UserName: "u3", IsSuper: 0} + token := signToken(secret, info) + h := token + if err := repo.GetDbR().Create(&model.Admin{ID: 3, Username: "u3", Nickname: "n", Avatar: "a", Mobile: "m", Password: "p", IsSuper: 0, LoginStatus: 1, LastLoginTime: time.Now(), LastLoginIP: "0.0.0.0", LastLoginHash: h, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().Create(&model.RoleUsers{RoleID: 11, AdminID: 3, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + if err := repo.GetDbR().Create(&model.MenuActions{ID: 101, MenuID: 1, ActionMark: "guild:delete", ActionName: "delete", Status: true, CreatedUser: "t", UpdatedUser: "t"}).Error; err != nil { + t.Fatal(err) + } + m, err := core.New(l) + if err != nil { + t.Fatal(err) + } + g := m.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + g.DELETE("/guilds/1", intc.RequireAdminRole(), intc.RequireAdminAction("guild:delete"), func(ctx core.Context) { ctx.Payload(map[string]string{"message": "ok"}) }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/api/admin/guilds/1", nil) + req.Header.Set("Authorization", token) + m.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("expect 403 got %d", w.Code) + } +} diff --git a/internal/router/interceptor/interceptor.go b/internal/router/interceptor/interceptor.go index 7e5df6e..a00b4e0 100644 --- a/internal/router/interceptor/interceptor.go +++ b/internal/router/interceptor/interceptor.go @@ -16,8 +16,11 @@ type Interceptor interface { // AppTokenAuthVerify APP端授权验证 AppTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) - // i 为了避免被其他包实现 - i() + RequireAdminRole() core.HandlerFunc + RequireAdminAction(mark string) core.HandlerFunc + + // i 为了避免被其他包实现 + i() } type interceptor struct { diff --git a/internal/router/router.go b/internal/router/router.go index 8194c7f..5eb8061 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -54,11 +54,11 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminNonAuthApiRouter.POST("/login", adminHandler.Login()) } - // 管理端认证接口路由组 - adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + // 管理端认证接口路由组 + adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) - // 系统管理接口(为前端模板路径兼容,挂载到 /api) - systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) + // 系统管理接口(为前端模板路径兼容,挂载到 /api) + systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify), intc.RequireAdminRole()) { // 管理员账号维护接口移除(未被前端使用) @@ -99,31 +99,33 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminAuthApiRouter.POST("/batch_users", adminHandler.BatchCreateUsers()) adminAuthApiRouter.DELETE("/batch_users", adminHandler.BatchDeleteUsers()) - adminAuthApiRouter.POST("/guilds", adminHandler.CreateGuild()) - adminAuthApiRouter.PUT("/guilds/:guild_id", adminHandler.ModifyGuild()) - adminAuthApiRouter.DELETE("/guilds/:guild_id", adminHandler.DeleteGuild()) - adminAuthApiRouter.GET("/guilds/:guild_id", adminHandler.GetGuildDetail()) - adminAuthApiRouter.GET("/guilds/:guild_id/members", adminHandler.ListGuildMembers()) - adminAuthApiRouter.GET("/guilds/:guild_id/applications", adminHandler.ListGuildApplications()) - adminAuthApiRouter.POST("/guilds/:guild_id/applications/:member_id/approve", adminHandler.ApproveGuildApplication()) - adminAuthApiRouter.POST("/guilds/:guild_id/applications/:member_id/reject", adminHandler.RejectGuildApplication()) - adminAuthApiRouter.DELETE("/guilds/:guild_id/members/:user_id", adminHandler.DeleteGuildMember()) + adminAuthApiRouter.POST("/guilds", intc.RequireAdminAction("guild:create"), adminHandler.CreateGuild()) + adminAuthApiRouter.PUT("/guilds/:guild_id", intc.RequireAdminAction("guild:modify"), adminHandler.ModifyGuild()) + adminAuthApiRouter.DELETE("/guilds/:guild_id", intc.RequireAdminAction("guild:delete"), adminHandler.DeleteGuild()) + adminAuthApiRouter.GET("/guilds/:guild_id", intc.RequireAdminAction("guild:view"), adminHandler.GetGuildDetail()) + adminAuthApiRouter.GET("/guilds/:guild_id/members", intc.RequireAdminAction("guild:view"), adminHandler.ListGuildMembers()) + adminAuthApiRouter.GET("/guilds/:guild_id/applications", intc.RequireAdminAction("guild:view"), adminHandler.ListGuildApplications()) + adminAuthApiRouter.POST("/guilds/:guild_id/applications/:member_id/approve", intc.RequireAdminAction("guild:member:approve"), adminHandler.ApproveGuildApplication()) + adminAuthApiRouter.POST("/guilds/:guild_id/applications/:member_id/reject", intc.RequireAdminAction("guild:member:reject"), adminHandler.RejectGuildApplication()) + adminAuthApiRouter.DELETE("/guilds/:guild_id/members/:user_id", intc.RequireAdminAction("guild:member:delete"), adminHandler.DeleteGuildMember()) // 商品管理:分类与商品 adminAuthApiRouter.POST("/product_categories", adminHandler.CreateProductCategory()) adminAuthApiRouter.PUT("/product_categories/:category_id", adminHandler.ModifyProductCategory()) adminAuthApiRouter.DELETE("/product_categories/:category_id", adminHandler.DeleteProductCategory()) adminAuthApiRouter.GET("/product_categories", adminHandler.ListProductCategories()) - adminAuthApiRouter.POST("/products", adminHandler.CreateProduct()) - adminAuthApiRouter.PUT("/products/:product_id", adminHandler.ModifyProduct()) - adminAuthApiRouter.DELETE("/products/:product_id", adminHandler.DeleteProduct()) - adminAuthApiRouter.GET("/products", adminHandler.ListProducts()) + adminAuthApiRouter.POST("/products", intc.RequireAdminAction("product:create"), adminHandler.CreateProduct()) + adminAuthApiRouter.PUT("/products/:product_id", intc.RequireAdminAction("product:modify"), adminHandler.ModifyProduct()) + adminAuthApiRouter.PUT("/products/batch", intc.RequireAdminAction("product:batch:modify"), adminHandler.BatchUpdateProducts()) + adminAuthApiRouter.DELETE("/products/:product_id", intc.RequireAdminAction("product:delete"), adminHandler.DeleteProduct()) + adminAuthApiRouter.GET("/products", intc.RequireAdminAction("product:view"), adminHandler.ListProducts()) + adminAuthApiRouter.POST("/auth/refresh", adminHandler.RefreshToken()) // 轮播图管理 - adminAuthApiRouter.POST("/banners", adminHandler.CreateBanner()) - adminAuthApiRouter.PUT("/banners/:banner_id", adminHandler.ModifyBanner()) - adminAuthApiRouter.DELETE("/banners/:banner_id", adminHandler.DeleteBanner()) - adminAuthApiRouter.GET("/banners", adminHandler.ListBanners()) + adminAuthApiRouter.POST("/banners", intc.RequireAdminAction("banner:create"), adminHandler.CreateBanner()) + adminAuthApiRouter.PUT("/banners/:banner_id", intc.RequireAdminAction("banner:modify"), adminHandler.ModifyBanner()) + adminAuthApiRouter.DELETE("/banners/:banner_id", intc.RequireAdminAction("banner:delete"), adminHandler.DeleteBanner()) + adminAuthApiRouter.GET("/banners", intc.RequireAdminAction("banner:view"), adminHandler.ListBanners()) // 用户管理 adminAuthApiRouter.GET("/users", adminHandler.ListAppUsers()) @@ -142,73 +144,76 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminAuthApiRouter.GET("/users/:user_id/inventory", adminHandler.ListUserInventory()) adminAuthApiRouter.GET("/users/:user_id/item_cards", adminHandler.ListUserItemCards()) // 系统称号与分配 - adminAuthApiRouter.GET("/system_titles", adminHandler.ListSystemTitles()) - adminAuthApiRouter.POST("/users/:user_id/titles", adminHandler.AssignUserTitle()) - adminAuthApiRouter.POST("/system_titles", adminHandler.CreateSystemTitle()) - adminAuthApiRouter.PUT("/system_titles/:title_id", adminHandler.ModifySystemTitle()) - adminAuthApiRouter.DELETE("/system_titles/:title_id", adminHandler.DeleteSystemTitle()) - adminAuthApiRouter.GET("/system_titles/:title_id/effects", adminHandler.ListSystemTitleEffects()) - adminAuthApiRouter.POST("/system_titles/:title_id/effects", adminHandler.CreateSystemTitleEffect()) - adminAuthApiRouter.PUT("/system_titles/:title_id/effects/:effect_id", adminHandler.ModifySystemTitleEffect()) - adminAuthApiRouter.DELETE("/system_titles/:title_id/effects/:effect_id", adminHandler.DeleteSystemTitleEffect()) + adminAuthApiRouter.GET("/system_titles", intc.RequireAdminAction("title:view"), adminHandler.ListSystemTitles()) + adminAuthApiRouter.POST("/users/:user_id/titles", intc.RequireAdminAction("title:assign"), adminHandler.AssignUserTitle()) + adminAuthApiRouter.POST("/system_titles", intc.RequireAdminAction("title:create"), adminHandler.CreateSystemTitle()) + adminAuthApiRouter.PUT("/system_titles/:title_id", intc.RequireAdminAction("title:modify"), adminHandler.ModifySystemTitle()) + adminAuthApiRouter.DELETE("/system_titles/:title_id", intc.RequireAdminAction("title:delete"), adminHandler.DeleteSystemTitle()) + adminAuthApiRouter.GET("/system_titles/:title_id/effects", intc.RequireAdminAction("title:view"), adminHandler.ListSystemTitleEffects()) + adminAuthApiRouter.POST("/system_titles/:title_id/effects", intc.RequireAdminAction("title:effect:create"), adminHandler.CreateSystemTitleEffect()) + adminAuthApiRouter.PUT("/system_titles/:title_id/effects/:effect_id", intc.RequireAdminAction("title:effect:modify"), adminHandler.ModifySystemTitleEffect()) + adminAuthApiRouter.DELETE("/system_titles/:title_id/effects/:effect_id", intc.RequireAdminAction("title:effect:delete"), adminHandler.DeleteSystemTitleEffect()) // 小程序二维码生成 adminAuthApiRouter.POST("/miniapp/qrcode", adminHandler.GenerateMiniAppQRCode()) // 道具卡管理 - adminAuthApiRouter.POST("/system_item_cards", adminHandler.CreateSystemItemCard()) - adminAuthApiRouter.PUT("/system_item_cards/:item_card_id", adminHandler.ModifySystemItemCard()) - adminAuthApiRouter.DELETE("/system_item_cards/:item_card_id", adminHandler.DeleteSystemItemCard()) - adminAuthApiRouter.GET("/system_item_cards", adminHandler.ListSystemItemCards()) + adminAuthApiRouter.POST("/system_item_cards", intc.RequireAdminAction("itemcard:create"), adminHandler.CreateSystemItemCard()) + adminAuthApiRouter.PUT("/system_item_cards/:item_card_id", intc.RequireAdminAction("itemcard:modify"), adminHandler.ModifySystemItemCard()) + adminAuthApiRouter.DELETE("/system_item_cards/:item_card_id", intc.RequireAdminAction("itemcard:delete"), adminHandler.DeleteSystemItemCard()) + adminAuthApiRouter.GET("/system_item_cards", intc.RequireAdminAction("itemcard:view"), adminHandler.ListSystemItemCards()) // 优惠券管理 - adminAuthApiRouter.POST("/system_coupons", adminHandler.CreateSystemCoupon()) - adminAuthApiRouter.PUT("/system_coupons/:coupon_id", adminHandler.ModifySystemCoupon()) - adminAuthApiRouter.DELETE("/system_coupons/:coupon_id", adminHandler.DeleteSystemCoupon()) - adminAuthApiRouter.GET("/system_coupons", adminHandler.ListSystemCoupons()) + adminAuthApiRouter.POST("/system_coupons", intc.RequireAdminAction("coupon:create"), adminHandler.CreateSystemCoupon()) + adminAuthApiRouter.PUT("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:modify"), adminHandler.ModifySystemCoupon()) + adminAuthApiRouter.DELETE("/system_coupons/:coupon_id", intc.RequireAdminAction("coupon:delete"), adminHandler.DeleteSystemCoupon()) + adminAuthApiRouter.GET("/system_coupons", intc.RequireAdminAction("coupon:view"), adminHandler.ListSystemCoupons()) adminAuthApiRouter.POST("/users/:user_id/item_cards", adminHandler.AssignUserItemCard()) // 发货统计 - adminAuthApiRouter.GET("/ops_shipping_stats", adminHandler.ListShippingStats()) - adminAuthApiRouter.GET("/ops_shipping_stats/:id", adminHandler.GetShippingStat()) - adminAuthApiRouter.POST("/ops_shipping_stats", adminHandler.CreateShippingStat()) - adminAuthApiRouter.PUT("/ops_shipping_stats/:id", adminHandler.ModifyShippingStat()) - adminAuthApiRouter.DELETE("/ops_shipping_stats/:id", adminHandler.DeleteShippingStat()) - adminAuthApiRouter.POST("/pay/refunds", adminHandler.CreateRefund()) - adminAuthApiRouter.GET("/pay/refunds", adminHandler.ListRefunds()) - adminAuthApiRouter.GET("/pay/refunds/:refund_no", adminHandler.GetRefundDetail()) + adminAuthApiRouter.GET("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:view"), adminHandler.ListShippingStats()) + adminAuthApiRouter.GET("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:view"), adminHandler.GetShippingStat()) + adminAuthApiRouter.POST("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:write"), adminHandler.CreateShippingStat()) + adminAuthApiRouter.PUT("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:write"), adminHandler.ModifyShippingStat()) + adminAuthApiRouter.DELETE("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:write"), adminHandler.DeleteShippingStat()) + adminAuthApiRouter.POST("/pay/refunds", intc.RequireAdminAction("refund:create"), adminHandler.CreateRefund()) + adminAuthApiRouter.GET("/pay/refunds", intc.RequireAdminAction("refund:view"), adminHandler.ListRefunds()) + adminAuthApiRouter.GET("/pay/refunds/:refund_no", intc.RequireAdminAction("refund:view"), adminHandler.GetRefundDetail()) adminAuthApiRouter.POST("/pay/bills/import", adminHandler.ImportPaymentBill()) adminAuthApiRouter.GET("/pay/bills/diff", adminHandler.ListPaymentBillDiff()) - adminAuthApiRouter.GET("/pay/orders", adminHandler.ListPayOrders()) - adminAuthApiRouter.GET("/pay/orders/:order_no", adminHandler.GetPayOrderDetail()) - adminAuthApiRouter.PUT("/pay/orders/:order_no/remark", adminHandler.UpdateOrderRemark()) - adminAuthApiRouter.POST("/pay/orders/:order_no/cancel", adminHandler.CancelOrder()) - adminAuthApiRouter.PUT("/pay/orders/:order_no/consume", adminHandler.ConsumeOrder()) - adminAuthApiRouter.GET("/pay/orders/export", adminHandler.ExportPayOrders()) + adminAuthApiRouter.GET("/pay/orders", intc.RequireAdminAction("order:view"), adminHandler.ListPayOrders()) + adminAuthApiRouter.GET("/pay/orders/:order_no", intc.RequireAdminAction("order:view"), adminHandler.GetPayOrderDetail()) + adminAuthApiRouter.PUT("/pay/orders/:order_no/remark", intc.RequireAdminAction("order:modify"), adminHandler.UpdateOrderRemark()) + adminAuthApiRouter.POST("/pay/orders/:order_no/cancel", intc.RequireAdminAction("order:cancel"), adminHandler.CancelOrder()) + adminAuthApiRouter.PUT("/pay/orders/:order_no/consume", intc.RequireAdminAction("order:consume"), adminHandler.ConsumeOrder()) + adminAuthApiRouter.GET("/pay/orders/export", intc.RequireAdminAction("order:export"), adminHandler.ExportPayOrders()) // 通用上传 systemApiRouter.POST("/common/upload/wangeditor", commonHandler.UploadWangEditorImage()) systemApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) } - // 系统管理:用户/角色/菜单 - { - systemApiRouter.GET("/user/list", adminHandler.ListUsers()) - systemApiRouter.GET("/role/list", adminHandler.ListRoles()) - systemApiRouter.GET("/v3/system/menus/simple", adminHandler.ListSimpleMenus()) - systemApiRouter.POST("/role", adminHandler.CreateRole()) - systemApiRouter.PUT("/role/:role_id", adminHandler.ModifyRole()) - systemApiRouter.DELETE("/role/:role_id", adminHandler.DeleteRole()) - systemApiRouter.POST("/role/:role_id/users", adminHandler.AssignRoleUsers()) - systemApiRouter.GET("/role/:role_id/users", adminHandler.ListRoleUsers()) - systemApiRouter.DELETE("/role/:role_id/users/:admin_id", adminHandler.RemoveRoleUser()) - systemApiRouter.POST("/menu", adminHandler.CreateMenu()) - systemApiRouter.PUT("/menu/:menu_id", adminHandler.ModifyMenu()) - systemApiRouter.DELETE("/menu/:menu_id", adminHandler.DeleteMenu()) - systemApiRouter.GET("/menu/:menu_id/actions", adminHandler.ListMenuActions()) - systemApiRouter.POST("/menu/:menu_id/actions", adminHandler.CreateMenuActions()) - systemApiRouter.DELETE("/menu/:menu_id/actions/:action_id", adminHandler.DeleteMenuAction()) - systemApiRouter.POST("/role/:role_id/menus", adminHandler.AssignRoleMenus()) - systemApiRouter.POST("/role/:role_id/actions", adminHandler.AssignRoleActions()) - } + // 系统管理:用户/角色/菜单 + { + systemApiRouter.GET("/user/list", adminHandler.ListUsers()) + systemApiRouter.GET("/role/list", adminHandler.ListRoles()) + systemApiRouter.GET("/v3/system/menus/simple", adminHandler.ListSimpleMenus()) + systemApiRouter.POST("/role", adminHandler.CreateRole()) + systemApiRouter.PUT("/role/:role_id", adminHandler.ModifyRole()) + systemApiRouter.DELETE("/role/:role_id", adminHandler.DeleteRole()) + systemApiRouter.POST("/role/:role_id/users", adminHandler.AssignRoleUsers()) + systemApiRouter.GET("/role/:role_id/users", adminHandler.ListRoleUsers()) + systemApiRouter.DELETE("/role/:role_id/users/:admin_id", adminHandler.RemoveRoleUser()) + systemApiRouter.POST("/menu", adminHandler.CreateMenu()) + systemApiRouter.PUT("/menu/:menu_id", adminHandler.ModifyMenu()) + systemApiRouter.DELETE("/menu/:menu_id", adminHandler.DeleteMenu()) + systemApiRouter.GET("/menu/:menu_id/actions", adminHandler.ListMenuActions()) + systemApiRouter.POST("/menu/:menu_id/actions", adminHandler.CreateMenuActions()) + systemApiRouter.DELETE("/menu/:menu_id/actions/:action_id", adminHandler.DeleteMenuAction()) + systemApiRouter.POST("/role/:role_id/menus", adminHandler.AssignRoleMenus()) + systemApiRouter.POST("/role/:role_id/actions", adminHandler.AssignRoleActions()) + systemApiRouter.GET("/system/recycle", adminHandler.ListRecycle()) + systemApiRouter.POST("/system/recycle/restore", adminHandler.RestoreRecycle()) + systemApiRouter.DELETE("/system/recycle", adminHandler.ForceDeleteRecycle()) + } // APP 端公开接口路由组 appPublicApiRouter := mux.Group("/api/app") diff --git a/internal/service/activity/activity_delete.go b/internal/service/activity/activity_delete.go index 2460815..12d7d2e 100644 --- a/internal/service/activity/activity_delete.go +++ b/internal/service/activity/activity_delete.go @@ -1,11 +1,123 @@ package activity -import "context" +import ( + "context" + "bindbox-game/internal/repository/mysql/dao" +) // DeleteActivity 删除活动 // 参数: id 活动ID // 返回: 错误信息 func (s *service) DeleteActivity(ctx context.Context, id int64) error { - _, err := s.writeDB.Activities.WithContext(ctx).Where(s.writeDB.Activities.ID.Eq(id)).Delete() - return err + return s.writeDB.Transaction(func(tx *dao.Query) error { + issues, err := tx.ActivityIssues.WithContext(ctx).Where(tx.ActivityIssues.ActivityID.Eq(id)).Find() + if err != nil { + return err + } + var issueIDs []int64 + for _, is := range issues { + issueIDs = append(issueIDs, is.ID) + } + + var drawLogIDs []int64 + if len(issueIDs) > 0 { + logs, err := tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.IssueID.In(issueIDs...)).Find() + if err != nil { + return err + } + for _, lg := range logs { + drawLogIDs = append(drawLogIDs, lg.ID) + } + } + + if len(issueIDs) > 0 { + if _, err = tx.ActivityRewardSettings.WithContext(ctx).Where(tx.ActivityRewardSettings.IssueID.In(issueIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.IssueRandomCommitments.WithContext(ctx).Where(tx.IssueRandomCommitments.IssueID.In(issueIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.IssueID.In(issueIDs...)).Delete(); err != nil { + return err + } + } + + if len(drawLogIDs) > 0 { + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawReceipts.WithContext(ctx).Where(tx.ActivityDrawReceipts.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedDrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.ID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + } + + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedActivityID.Eq(id)).Delete(); err != nil { + return err + } + if len(issueIDs) > 0 { + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedIssueID.In(issueIDs...)).Delete(); err != nil { + return err + } + } + + if _, err = tx.UserInventory.WithContext(ctx).Where(tx.UserInventory.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + invs, err := tx.UserInventory.WithContext(ctx).Where(tx.UserInventory.ActivityID.Eq(id)).Find() + if err != nil { + return err + } + var invIDs []int64 + for _, iv := range invs { + invIDs = append(invIDs, iv.ID) + } + if len(invIDs) > 0 { + if _, err = tx.ShippingRecords.WithContext(ctx).Where(tx.ShippingRecords.InventoryID.In(invIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.UserInventoryTransfers.WithContext(ctx).Where(tx.UserInventoryTransfers.InventoryID.In(invIDs...)).Delete(); err != nil { + return err + } + } + if _, err = tx.GuildContributeLogs.WithContext(ctx).Where(tx.GuildContributeLogs.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + if len(issueIDs) > 0 { + if _, err = tx.GuildContributeLogs.WithContext(ctx).Where(tx.GuildContributeLogs.IssuesID.In(issueIDs...)).Delete(); err != nil { + return err + } + } + + if _, err = tx.SystemItemCards.WithContext(ctx).Where(tx.SystemItemCards.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + if len(issueIDs) > 0 { + if _, err = tx.SystemItemCards.WithContext(ctx).Where(tx.SystemItemCards.IssueID.In(issueIDs...)).Delete(); err != nil { + return err + } + } + if _, err = tx.SystemCoupons.WithContext(ctx).Where(tx.SystemCoupons.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + + if len(issueIDs) > 0 { + if _, err = tx.ActivityIssues.WithContext(ctx).Where(tx.ActivityIssues.ActivityID.Eq(id)).Delete(); err != nil { + return err + } + } + + if _, err = tx.Activities.WithContext(ctx).Where(tx.Activities.ID.Eq(id)).Delete(); err != nil { + return err + } + return nil + }) } diff --git a/internal/service/activity/issue_delete.go b/internal/service/activity/issue_delete.go index 4295c2f..674e5ff 100644 --- a/internal/service/activity/issue_delete.go +++ b/internal/service/activity/issue_delete.go @@ -1,11 +1,59 @@ package activity -import "context" +import ( + "context" + "bindbox-game/internal/repository/mysql/dao" +) // DeleteIssue 删除活动期 // 参数: issueID 期ID // 返回: 错误信息 func (s *service) DeleteIssue(ctx context.Context, issueID int64) error { - _, err := s.writeDB.ActivityIssues.WithContext(ctx).Where(s.writeDB.ActivityIssues.ID.Eq(issueID)).Delete() - return err + return s.writeDB.Transaction(func(tx *dao.Query) error { + logs, err := tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.IssueID.Eq(issueID)).Find() + if err != nil { + return err + } + var drawLogIDs []int64 + for _, lg := range logs { + drawLogIDs = append(drawLogIDs, lg.ID) + } + + if _, err = tx.ActivityRewardSettings.WithContext(ctx).Where(tx.ActivityRewardSettings.IssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.IssueRandomCommitments.WithContext(ctx).Where(tx.IssueRandomCommitments.IssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.IssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if len(drawLogIDs) > 0 { + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawReceipts.WithContext(ctx).Where(tx.ActivityDrawReceipts.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedDrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedIssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.GuildContributeLogs.WithContext(ctx).Where(tx.GuildContributeLogs.IssuesID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.SystemItemCards.WithContext(ctx).Where(tx.SystemItemCards.IssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.IssueID.Eq(issueID)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityIssues.WithContext(ctx).Where(tx.ActivityIssues.ID.Eq(issueID)).Delete(); err != nil { + return err + } + return nil + }) } diff --git a/internal/service/banner/banner.go b/internal/service/banner/banner.go index c41fa39..4daf1c0 100644 --- a/internal/service/banner/banner.go +++ b/internal/service/banner/banner.go @@ -2,6 +2,7 @@ package banner import ( "context" + "time" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" @@ -83,7 +84,7 @@ func (s *service) Modify(ctx context.Context, id int64, in ModifyInput) error { } func (s *service) Delete(ctx context.Context, id int64) error { - _, err := s.writeDB.Banner.WithContext(ctx).Where(s.writeDB.Banner.ID.Eq(id)).Delete() + _, err := s.writeDB.Banner.WithContext(ctx).Where(s.writeDB.Banner.ID.Eq(id)).Updates(map[string]any{"deleted_at": time.Now()}) return err } diff --git a/internal/service/guild/guild_delete.go b/internal/service/guild/guild_delete.go index 43207fe..d5fee68 100644 --- a/internal/service/guild/guild_delete.go +++ b/internal/service/guild/guild_delete.go @@ -1,8 +1,11 @@ package guild -import "context" +import ( + "context" + "time" +) func (s *service) DeleteGuild(ctx context.Context, id int64) error { - _, err := s.writeDB.Guild.WithContext(ctx).Where(s.readDB.Guild.ID.Eq(id)).Delete() - return err + _, err := s.writeDB.Guild.WithContext(ctx).Where(s.readDB.Guild.ID.Eq(id)).Updates(map[string]any{"deleted_at": time.Now()}) + return err } diff --git a/internal/service/product/product.go b/internal/service/product/product.go index 71fa83c..c1f37eb 100644 --- a/internal/service/product/product.go +++ b/internal/service/product/product.go @@ -1,14 +1,15 @@ package product import ( - "context" - "encoding/json" - "strings" + "context" + "encoding/json" + "strings" + "time" - "bindbox-game/internal/pkg/logger" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/dao" - "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" ) type Service interface { @@ -20,7 +21,8 @@ type Service interface { CreateProduct(ctx context.Context, in CreateProductInput) (*model.Products, error) ModifyProduct(ctx context.Context, id int64, in ModifyProductInput) error DeleteProduct(ctx context.Context, id int64) error - ListProducts(ctx context.Context, in ListProductsInput) (items []*model.Products, total int64, err error) + ListProducts(ctx context.Context, in ListProductsInput) (items []*model.Products, total int64, err error) + BatchUpdate(ctx context.Context, ids []int64, stock *int64, status *int32) (int64, error) } type service struct { @@ -80,8 +82,8 @@ func (s *service) ModifyCategory(ctx context.Context, id int64, in ModifyCategor } func (s *service) DeleteCategory(ctx context.Context, id int64) error { - _, err := s.writeDB.ProductCategories.WithContext(ctx).Where(s.writeDB.ProductCategories.ID.Eq(id)).Delete() - return err + _, err := s.writeDB.ProductCategories.WithContext(ctx).Where(s.writeDB.ProductCategories.ID.Eq(id)).Updates(map[string]any{"deleted_at": time.Now()}) + return err } func (s *service) ListCategories(ctx context.Context, in ListCategoriesInput) (items []*model.ProductCategories, total int64, err error) { @@ -169,8 +171,8 @@ func (s *service) ModifyProduct(ctx context.Context, id int64, in ModifyProductI } func (s *service) DeleteProduct(ctx context.Context, id int64) error { - _, err := s.writeDB.Products.WithContext(ctx).Where(s.writeDB.Products.ID.Eq(id)).Delete() - return err + _, err := s.writeDB.Products.WithContext(ctx).Where(s.writeDB.Products.ID.Eq(id)).Updates(map[string]any{"deleted_at": time.Now()}) + return err } func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items []*model.Products, total int64, err error) { @@ -198,6 +200,28 @@ func (s *service) ListProducts(ctx context.Context, in ListProductsInput) (items return } +func (s *service) BatchUpdate(ctx context.Context, ids []int64, stock *int64, status *int32) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + set := map[string]any{} + if stock != nil { + set["stock"] = *stock + } + if status != nil { + set["status"] = *status + } + if len(set) == 0 { + return 0, nil + } + updater := s.writeDB.Products.WithContext(ctx).Where(s.writeDB.Products.ID.In(ids...)) + result, err := updater.Updates(set) + if err != nil { + return 0, err + } + return result.RowsAffected, nil +} + func normalizeJSON(s string) string { if strings.TrimSpace(s) == "" { return "[]" diff --git a/internal/service/recycle/recycle_service.go b/internal/service/recycle/recycle_service.go new file mode 100644 index 0000000..12cfaa4 --- /dev/null +++ b/internal/service/recycle/recycle_service.go @@ -0,0 +1,188 @@ +package recycle + +import ( + "context" + "time" + + "gorm.io/gorm" + "bindbox-game/internal/repository/mysql/dao" +) + +type Service interface { + List(ctx context.Context, typ string, page, size int) (list []map[string]any, total int64, err error) + Restore(ctx context.Context, typ string, id int64) error + ForceDelete(ctx context.Context, typ string, id int64) error +} + +type service struct{ readDB *dao.Query; writeDB *dao.Query; rdb *gorm.DB; wdb *gorm.DB } + +func New(read *dao.Query, write *dao.Query) Service { return &service{readDB: read, writeDB: write} } +func NewRaw(read *dao.Query, write *dao.Query, rdb *gorm.DB, wdb *gorm.DB) Service { return &service{readDB: read, writeDB: write, rdb: rdb, wdb: wdb} } + +func (s *service) List(ctx context.Context, typ string, page, size int) (list []map[string]any, total int64, err error) { + if page <= 0 { page = 1 } + if size <= 0 { size = 20 } + db := s.rdb + if db == nil { + return nil, 0, nil + } + db = db.WithContext(ctx) + db.InstanceSet("soft_delete_ignore", true) + switch typ { + case "activity": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("activities").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("activities").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "issue": + var rows []struct{ ID int64; IssueNumber string; DeletedAt *time.Time } + if err = db.Table("activity_issues").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("activity_issues").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.IssueNumber, "deleted_at": r.DeletedAt} } + case "reward": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("activity_reward_settings").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("activity_reward_settings").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "product": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("products").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("products").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "category": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("product_categories").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("product_categories").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "banner": + var rows []struct{ ID int64; Title string; DeletedAt *time.Time } + if err = db.Table("banner").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("banner").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "title": r.Title, "deleted_at": r.DeletedAt} } + case "guild": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("guild").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("guild").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "item_card": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("system_item_cards").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("system_item_cards").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "coupon": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("system_coupons").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("system_coupons").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "menu": + var rows []struct{ ID int64; Name string; DeletedAt *time.Time } + if err = db.Table("menus").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("menus").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "name": r.Name, "deleted_at": r.DeletedAt} } + case "menu_action": + var rows []struct{ ID int64; ActionName string; DeletedAt *time.Time } + if err = db.Table("menu_actions").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("menu_actions").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "action_name": r.ActionName, "deleted_at": r.DeletedAt} } + case "role": + var rows []struct{ ID int64; RoleName string; DeletedAt *time.Time } + if err = db.Table("roles").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("roles").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "role_name": r.RoleName, "deleted_at": r.DeletedAt} } + case "role_user": + var rows []struct{ ID int64; RoleID int64; AdminID int32; DeletedAt *time.Time } + if err = db.Table("role_users").Where("deleted_at IS NOT NULL").Count(&total).Error; err != nil { return } + if err = db.Table("role_users").Where("deleted_at IS NOT NULL").Limit(size).Offset((page-1)*size).Scan(&rows).Error; err != nil { return } + list = make([]map[string]any, len(rows)) + for i, r := range rows { list[i] = map[string]any{"id": r.ID, "role_id": r.RoleID, "admin_id": r.AdminID, "deleted_at": r.DeletedAt} } + default: + total, list, err = 0, nil, nil + } + return +} + +func (s *service) Restore(ctx context.Context, typ string, id int64) error { + switch typ { + case "activity": + _, err := s.writeDB.Activities.WithContext(ctx).Where(s.writeDB.Activities.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "issue": + _, err := s.writeDB.ActivityIssues.WithContext(ctx).Where(s.writeDB.ActivityIssues.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "reward": + _, err := s.writeDB.ActivityRewardSettings.WithContext(ctx).Where(s.writeDB.ActivityRewardSettings.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "product": + _, err := s.writeDB.Products.WithContext(ctx).Where(s.writeDB.Products.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "category": + _, err := s.writeDB.ProductCategories.WithContext(ctx).Where(s.writeDB.ProductCategories.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "banner": + _, err := s.writeDB.Banner.WithContext(ctx).Where(s.writeDB.Banner.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "guild": + _, err := s.writeDB.Guild.WithContext(ctx).Where(s.writeDB.Guild.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "title": + _, err := s.writeDB.SystemTitles.WithContext(ctx).Where(s.writeDB.SystemTitles.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "title_effect": + _, err := s.writeDB.SystemTitleEffects.WithContext(ctx).Where(s.writeDB.SystemTitleEffects.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "item_card": + _, err := s.writeDB.SystemItemCards.WithContext(ctx).Where(s.writeDB.SystemItemCards.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "coupon": + _, err := s.writeDB.SystemCoupons.WithContext(ctx).Where(s.writeDB.SystemCoupons.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "menu": + _, err := s.writeDB.Menus.WithContext(ctx).Where(s.writeDB.Menus.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "menu_action": + _, err := s.writeDB.MenuActions.WithContext(ctx).Where(s.writeDB.MenuActions.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "role": + _, err := s.writeDB.Roles.WithContext(ctx).Where(s.writeDB.Roles.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + case "role_user": + _, err := s.writeDB.RoleUsers.WithContext(ctx).Where(s.writeDB.RoleUsers.ID.Eq(id)).Updates(map[string]any{"deleted_at": nil, "deleted_by": nil}); return err + default: + return nil + } +} + +func (s *service) ForceDelete(ctx context.Context, typ string, id int64) error { + switch typ { + case "activity": + _, err := s.writeDB.Activities.WithContext(ctx).Where(s.writeDB.Activities.ID.Eq(id)).Delete(); return err + case "issue": + _, err := s.writeDB.ActivityIssues.WithContext(ctx).Where(s.writeDB.ActivityIssues.ID.Eq(id)).Delete(); return err + case "reward": + _, err := s.writeDB.ActivityRewardSettings.WithContext(ctx).Where(s.writeDB.ActivityRewardSettings.ID.Eq(id)).Delete(); return err + case "product": + _, err := s.writeDB.Products.WithContext(ctx).Where(s.writeDB.Products.ID.Eq(id)).Delete(); return err + case "category": + _, err := s.writeDB.ProductCategories.WithContext(ctx).Where(s.writeDB.ProductCategories.ID.Eq(id)).Delete(); return err + case "banner": + _, err := s.writeDB.Banner.WithContext(ctx).Where(s.writeDB.Banner.ID.Eq(id)).Delete(); return err + case "guild": + _, err := s.writeDB.Guild.WithContext(ctx).Where(s.writeDB.Guild.ID.Eq(id)).Delete(); return err + case "title": + _, err := s.writeDB.SystemTitles.WithContext(ctx).Where(s.writeDB.SystemTitles.ID.Eq(id)).Delete(); return err + case "title_effect": + _, err := s.writeDB.SystemTitleEffects.WithContext(ctx).Where(s.writeDB.SystemTitleEffects.ID.Eq(id)).Delete(); return err + case "item_card": + _, err := s.writeDB.SystemItemCards.WithContext(ctx).Where(s.writeDB.SystemItemCards.ID.Eq(id)).Delete(); return err + case "coupon": + _, err := s.writeDB.SystemCoupons.WithContext(ctx).Where(s.writeDB.SystemCoupons.ID.Eq(id)).Delete(); return err + case "menu": + _, err := s.writeDB.Menus.WithContext(ctx).Where(s.writeDB.Menus.ID.Eq(id)).Delete(); return err + case "menu_action": + _, err := s.writeDB.MenuActions.WithContext(ctx).Where(s.writeDB.MenuActions.ID.Eq(id)).Delete(); return err + case "role": + _, err := s.writeDB.Roles.WithContext(ctx).Where(s.writeDB.Roles.ID.Eq(id)).Delete(); return err + case "role_user": + _, err := s.writeDB.RoleUsers.WithContext(ctx).Where(s.writeDB.RoleUsers.ID.Eq(id)).Delete(); return err + default: + return nil + } +} \ No newline at end of file diff --git a/internal/service/user/batch_user.go b/internal/service/user/batch_user.go index d73fb63..41731a8 100644 --- a/internal/service/user/batch_user.go +++ b/internal/service/user/batch_user.go @@ -5,6 +5,7 @@ import ( "time" "bindbox-game/internal/repository/mysql/model" + "bindbox-game/internal/repository/mysql/dao" ) type CreateUserInput struct { @@ -30,6 +31,117 @@ func (s *service) CreateUser(ctx context.Context, in CreateUserInput) (*model.Us } func (s *service) DeleteUser(ctx context.Context, userID int64) error { - _, err := s.writeDB.Users.WithContext(ctx).Where(s.writeDB.Users.ID.Eq(userID)).Delete() - return err + return s.writeDB.Transaction(func(tx *dao.Query) error { + orders, err := tx.Orders.WithContext(ctx).Where(tx.Orders.UserID.Eq(userID)).Find() + if err != nil { + return err + } + var orderIDs []int64 + for _, od := range orders { + orderIDs = append(orderIDs, od.ID) + } + + if len(orderIDs) > 0 { + if _, err = tx.OrderItems.WithContext(ctx).Where(tx.OrderItems.OrderID.In(orderIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.PaymentPreorders.WithContext(ctx).Where(tx.PaymentPreorders.OrderID.In(orderIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.PaymentTransactions.WithContext(ctx).Where(tx.PaymentTransactions.OrderID.In(orderIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.PaymentRefunds.WithContext(ctx).Where(tx.PaymentRefunds.OrderID.In(orderIDs...)).Delete(); err != nil { + return err + } + } + + invs, err := tx.UserInventory.WithContext(ctx).Where(tx.UserInventory.UserID.Eq(userID)).Find() + if err != nil { + return err + } + var invIDs []int64 + for _, iv := range invs { + invIDs = append(invIDs, iv.ID) + } + + if _, err = tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviterID.Eq(userID)).Or(tx.UserInvites.InviteeID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserInventoryTransfers.WithContext(ctx).Where(tx.UserInventoryTransfers.FromUserID.Eq(userID)).Or(tx.UserInventoryTransfers.ToUserID.Eq(userID)).Delete(); err != nil { + return err + } + if len(invIDs) > 0 { + if _, err = tx.UserInventoryTransfers.WithContext(ctx).Where(tx.UserInventoryTransfers.InventoryID.In(invIDs...)).Delete(); err != nil { + return err + } + } + + effects := tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.UserID.Eq(userID)) + if _, err := effects.Delete(); err != nil { + return err + } + + logs, err := tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.UserID.Eq(userID)).Find() + if err != nil { + return err + } + var drawLogIDs []int64 + for _, lg := range logs { + drawLogIDs = append(drawLogIDs, lg.ID) + } + if len(drawLogIDs) > 0 { + if _, err = tx.ActivityDrawReceipts.WithContext(ctx).Where(tx.ActivityDrawReceipts.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawEffects.WithContext(ctx).Where(tx.ActivityDrawEffects.DrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UsedDrawLogID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + if _, err = tx.ActivityDrawLogs.WithContext(ctx).Where(tx.ActivityDrawLogs.ID.In(drawLogIDs...)).Delete(); err != nil { + return err + } + } + + if _, err = tx.UserInventory.WithContext(ctx).Where(tx.UserInventory.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserItemCards.WithContext(ctx).Where(tx.UserItemCards.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserCoupons.WithContext(ctx).Where(tx.UserCoupons.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.Orders.WithContext(ctx).Where(tx.Orders.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserPointsLedger.WithContext(ctx).Where(tx.UserPointsLedger.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserPoints.WithContext(ctx).Where(tx.UserPoints.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.GuildMembers.WithContext(ctx).Where(tx.GuildMembers.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.GuildContributeLogs.WithContext(ctx).Where(tx.GuildContributeLogs.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.ShippingRecords.WithContext(ctx).Where(tx.ShippingRecords.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.OpsShippingStats.WithContext(ctx).Where(tx.OpsShippingStats.UserID.Eq(userID)).Delete(); err != nil { + return err + } + if _, err = tx.UserAddresses.WithContext(ctx).Where(tx.UserAddresses.UserID.Eq(userID)).Delete(); err != nil { + return err + } + + if _, err = tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(userID)).Delete(); err != nil { + return err + } + return nil + }) } \ No newline at end of file diff --git a/migrations/2025-11-18_soft_delete.sql b/migrations/2025-11-18_soft_delete.sql new file mode 100644 index 0000000..0932f75 --- /dev/null +++ b/migrations/2025-11-18_soft_delete.sql @@ -0,0 +1,17 @@ +ALTER TABLE `activities` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_activities_deleted_at`(`deleted_at`); +ALTER TABLE `activity_issues` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_activity_issues_deleted_at`(`deleted_at`); +ALTER TABLE `activity_reward_settings` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_activity_reward_settings_deleted_at`(`deleted_at`); +ALTER TABLE `products` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_products_deleted_at`(`deleted_at`); +ALTER TABLE `product_categories` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_product_categories_deleted_at`(`deleted_at`); +ALTER TABLE `banner` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_banner_deleted_at`(`deleted_at`); +ALTER TABLE `guild` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_guild_deleted_at`(`deleted_at`); +ALTER TABLE `system_titles` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_system_titles_deleted_at`(`deleted_at`); +ALTER TABLE `system_title_effects` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_system_title_effects_deleted_at`(`deleted_at`); +ALTER TABLE `system_item_cards` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_system_item_cards_deleted_at`(`deleted_at`); +ALTER TABLE `system_coupons` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_system_coupons_deleted_at`(`deleted_at`); +ALTER TABLE `menus` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_menus_deleted_at`(`deleted_at`); +ALTER TABLE `menu_actions` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_menu_actions_deleted_at`(`deleted_at`); +ALTER TABLE `roles` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_roles_deleted_at`(`deleted_at`); +ALTER TABLE `role_users` ADD COLUMN `deleted_at` DATETIME NULL, ADD COLUMN `deleted_by` BIGINT NULL, ADD INDEX `idx_role_users_deleted_at`(`deleted_at`); + +INSERT INTO `menus` (`parent_id`, `path`, `name`, `component`, `icon`, `sort`, `status`, `keep_alive`, `is_hide`, `is_hide_tab`) VALUES (0, '/system/recycle', '回收站', '/system/recycle', 'ri:delete-bin-6-line', 999, 1, 1, 0, 0); \ No newline at end of file