diff --git a/.trae/documents/优化双倍卡配置与作用范围选择的实施计划.md b/.trae/documents/优化双倍卡配置与作用范围选择的实施计划.md new file mode 100644 index 0000000..0921714 --- /dev/null +++ b/.trae/documents/优化双倍卡配置与作用范围选择的实施计划.md @@ -0,0 +1,37 @@ +## 使用指导(立即可用) +- 叠加策略(stacking_strategy) + - 对“双倍概率”(effect_type=6)当前不生效;代码仅按“概率累加+统一封顶”处理(internal/service/activity/draw_with_effects.go:133-149)。 + - 只有一个双倍效果时,保持默认即可(推荐填 1“累加封顶”或 0“最大值”,不影响现状)。 +- 统一封顶(cap_value_x1000) + - 约束“多个双倍效果合并后的总概率”的上限(单位千分)。 + - 只有一个效果:填 0(不封顶)或填业务上限(如 1000=100%)。 + - 多个效果:按业务上限填写,如最多 30% 则填 300(避免叠加超过 30%)。 + +## 作用范围简化(改造目标) +- 将前端“作用范围”改为: + - 下拉选择“活动 activity_ids”(多选) + - 依赖选择“期 issue_ids”(多选,基于已选活动加载期) + - 可选“排除期 exclude.issue_ids”(多选) +- 不再要求手写 ID,不展示分类/时间等无关选项。 + +## 前端改造 +- EffectEditDialog.vue + - 为 effect_type=6 添加三组下拉:活动、期、排除期。 + - 提交时构造简化版 scopes_json:`{ activity_ids, issue_ids, exclude: { issue_ids } }`。 + - 在 UI 上为 stacking_strategy/cap_value_x1000 提供简短说明(不改后端字段)。 +- EffectManagerDialog.vue + - 在列表新增“作用范围”列,展示活动/期/排除期标签(已完成范围列,可保留)。 + +## 数据来源与接口(不改后端) +- 活动/期下拉数据: + - 如果已有活动与期的接口:直接请求填充; + - 如暂无接口:先用静态选项或从现有管理页可获取的列表填充,下轮再对接接口。 +- 不改后端:效果保存仍通过 `admin/system_titles/:title_id/effects`,携带 `params_json` 与简化 `scopes_json`。 + +## 验收 +- 在管理端为“双倍概率”新增/编辑效果: + - 可用下拉选择活动与期,不用手写; + - 保存后列表显示范围标签; + - 抽奖按选定的期过滤生效(draw_with_effects.go:85-103)。 + +确认后我将实施前端下拉改造与表单说明文本,并保持后端兼容。 \ No newline at end of file diff --git a/.trae/documents/修复 Admin 前端构建 TypeScript 错误.md b/.trae/documents/修复 Admin 前端构建 TypeScript 错误.md new file mode 100644 index 0000000..61487e1 --- /dev/null +++ b/.trae/documents/修复 Admin 前端构建 TypeScript 错误.md @@ -0,0 +1,53 @@ +## 问题概览 +- 缺失模块:`@/mock/temp/commentDetail` 导致 TS2307(src/components/business/comment-widget/index.vue:46)。 +- 事件类型不匹配:`emit('search', ...)` 未在 `defineEmits` 中声明载荷(activity-search、player-search)。 +- 缺少类型声明:`crypto-js/md5` 导致 TS7016(login)。 +- 返回类型不一致:登录接口不含 `refreshToken` 导致 TS2339(login)。 +- 表格数据类型为 `unknown[]` 与组件期望 `Record[]` 不匹配(guild、banner、product)。 + +## 修复方案(不改接口协议,最小改动) +- 缺失模块补齐:新增 `web/admin/src/mock/temp/commentDetail.ts`,导出 `Comment` 类型与 `commentList: Ref`。 + - 使 `src/components/business/comment-widget/index.vue:46` 的导入可解析。 +- 事件类型声明完善:在以下文件的 `defineEmits` 中为 `search` 声明载荷类型 `Props['modelValue']`: + - `src/views/activity/manage/modules/activity-search.vue:93-101,148-151` + - `src/views/player-manage/modules/player-search.vue:82-90,136-139` +- 为 `crypto-js/md5` 提供类型:新增 `web/admin/src/types/shims-crypto-js.d.ts` 内容:`declare module 'crypto-js/md5';` + - 保证 `src/views/auth/login/index.vue:117` 类型可解析。 +- 登录返回类型与使用一致化:修改登录页不解构 `refreshToken`,仅解构 `token` 并调用 `userStore.setToken(token)`: + - `src/views/auth/login/index.vue:223-236`;当前后端返回结构为 `{ token, is_super }`(参考后端 `internal/service/admin/login.go:22-25,62-63` 与前端 `src/api/auth.ts:8-14`)。 +- 表格数据类型注解:在各视图的 `apiFn` 显式标注返回类型为 `Promise>`,使 `useTable` 能推导出 `TRecord`,从而 `data` 类型为记录数组: + - Guild 列表:`src/views/guild/manage/index.vue:137-173`,返回 `PaginatedResponse<{ id; name; owner_id; ... }>`。 + - Banner 列表:`src/views/operations/banner/index.vue:77-98`,返回 `PaginatedResponse`(`src/api/banner.ts:3-10`)。 + - Product 分类:`src/views/product/categories/index.vue:80-102`,返回 `PaginatedResponse<{ id; name; parent_id; status }>`。 + - Product 列表:`src/views/product/list/index.vue`(同样模式)。 + - 若不方便标注,临时方案:在模板传参处显式断言 `:data="data as Record[]"`(不推荐,先按注解方案处理)。 + +## 具体改动明细 +- 新增 `src/mock/temp/commentDetail.ts`: + - 导出 `export interface Comment { id:number; author:string; content:string; timestamp:string; replies: Comment[] }` + - `export const commentList = ref([])`(保持与组件 `comments.value.push(...)` 一致)。 +- 更新事件声明: + - `activity-search.vue` `defineEmits()` 中将 `(e: 'search'): void` 改为 `(e: 'search', value: Props['modelValue']): void`。 + - `player-search.vue` 同步调整。 +- 新增类型声明文件:`src/types/shims-crypto-js.d.ts` 写入 `declare module 'crypto-js/md5';`。 +- 登录页使用调整: + - 改为 `const { token } = await fetchLogin(...)`,并调用 `userStore.setToken(token)`。 +- useTable 返回类型注解:在上述 4 个视图 `apiFn` 的返回 `then` 中显式声明返回值类型为 `Api.Common.PaginatedResponse<...>`。 + +## 验证步骤 +- 安装依赖后(若需要):`npm i --save-dev @types/crypto-js`(可省略,因已加 shims)。 +- 执行 `npm run build`,expect:无 TS 错误;资源正常打包。 +- 运行本地:`npm run dev`,检查各页面: + - 评论组件能正常发布与回复。 + - 搜索模块能正常触发,父组件收到带载荷的 `search` 事件。 + - 登录页能正常登录并存储 `token`。 + - Guild、Banner、Product 列表渲染正常,分页操作无类型报错。 + +## 影响面与风险 +- 均为类型与前端视图层改动,不涉及后端接口或数据结构变更。 +- `crypto-js` 类型通过 shims 解决,不强依赖 `@types` 包。 +- `useTable` 类型注解只提升类型推导,不改变运行时行为。 + +## 后续优化建议 +- 统一在 `api/*` 层将后端分页响应封装为 `Api.Common.PaginatedResponse`,视图层的 `apiFn` 直接返回该类型,减少重复 `then` 转换与类型注解。 +- 为常见搜索子组件抽取 `Emits` 模板,避免事件签名遗漏。 \ No newline at end of file diff --git a/.trae/documents/修复 Windows 打包后时区错误(unknown time zone Asia_Shanghai).md b/.trae/documents/修复 Windows 打包后时区错误(unknown time zone Asia_Shanghai).md new file mode 100644 index 0000000..ef58d90 --- /dev/null +++ b/.trae/documents/修复 Windows 打包后时区错误(unknown time zone Asia_Shanghai).md @@ -0,0 +1,34 @@ +**问题定位** +- 触发点在 `internal/pkg/timeutil/timeutil.go:13-22`:`time.LoadLocation("Asia/Shanghai")` 在 `init` 中执行,失败则 `panic`,并将 `time.Local` 设为中国时区。 +- 在 Windows 无系统 IANA tzdata、且以 `CGO_ENABLED=0` 交叉编译的静态二进制中,`time` 包找不到 `Asia/Shanghai`,导致启动时报错。 + +**解决方案(按优先级)** +- 方案A(推荐):嵌入 tzdata 到二进制 + - 在应用入口 `main.go` 增加空导入:`_ "time/tzdata"`,使 `time` 包使用内嵌 IANA 数据库。 + - 或在打包时添加构建标签:`-tags timetzdata`(效果等同),无需改代码。 +- 方案B(无代码变更):随程序分发 `zoneinfo.zip` + - 复制 `$GOROOT/lib/time/zoneinfo.zip` 到可执行文件同目录,运行前设置环境变量 `ZONEINFO` 指向该文件,例如:`set ZONEINFO=%CD%\zoneinfo.zip`。 +- 方案C(防御补充):去除 `panic` 并提供容错 + - 在 `timeutil` 初始化失败时,回退到固定东八区:`time.FixedZone("CST", 8*3600)`,并设置 `time.Local`,避免启动失败。 + +**打包命令更新** +- 将 README 中 Windows 打包命令更新为: + - `CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -tags timetzdata -trimpath -o build/bindbox.exe .` +- 若采用方案B:在 `build` 目录放置 `zoneinfo.zip`,并在启动脚本/说明中加入 `set ZONEINFO=%~dp0zoneinfo.zip`。 + +**验证计划** +- 本地编译并运行单测:覆盖 `internal/pkg/timeutil` 的格式化与解析函数,确认 `Asia/Shanghai` 可加载且不崩溃。 +- 人工验证:在 Windows 主机上执行 `bindbox.exe` 并访问 `http://127.0.0.1:9991/system/health`,字段 `time` 正常返回中国时区时间(参考 `internal/pkg/core/core.go:418-441` 与 `timeutil.CSTLayoutString()`)。 + +**将实施的具体改动** +- 若选A: + - 在 `main.go` 增加 `_ "time/tzdata"` 导入。 + - 更新 README 的 Windows 构建命令,加入 `-tags timetzdata`。 +- 若选C(可选增强): + - 修改 `internal/pkg/timeutil/timeutil.go`,失败时不 `panic`,改为固定时区回退并日志记录。 + +**回滚与兼容** +- 方案A仅增加约 ~800KB 体积,无行为回滚风险。 +- 方案C为防御性修改,兼容现有逻辑;若需恢复原严格行为,可还原为 `panic`。 + +请确认采用的方案(推荐选A;可附加选C增强健壮性)。确认后我将按上述步骤实施并提交更改、完成打包与验证。 \ No newline at end of file diff --git a/.trae/documents/修复称号初始化JSON字段并重新初始化.md b/.trae/documents/修复称号初始化JSON字段并重新初始化.md new file mode 100644 index 0000000..86081e5 --- /dev/null +++ b/.trae/documents/修复称号初始化JSON字段并重新初始化.md @@ -0,0 +1,22 @@ +## 问题原因 +- 数据库报错 `Invalid JSON text: "The document is empty."`,说明往 `system_titles.obtain_rules_json` 写入了空字符串 `""`。 +- 在 MySQL JSON 列中,空字符串不是合法 JSON;需使用 `"{}"` 或 `"[]"` 等合法文本。 +- 代码位置:`internal/api/admin/titles_seed.go` 初始化 `SystemTitles` 时将 `ObtainRulesJSON`、`ScopesJSON` 设为了空字符串。 + +## 修复方案 +- 修改 `internal/api/admin/titles_seed.go` 的种子逻辑: + - 将 `ObtainRulesJSON` 默认值改为 `"{}"`(或 `{"type":"manual"}` 作为占位规则)。 + - 将 `ScopesJSON` 默认值改为 `"{}"`(空作用域表示全局生效)。 +- 保持 `SystemTitleEffects.ParamsJSON` 使用 `json.Marshal(d.Params)`(已为合法 JSON)。 + +## 执行步骤 +1. 更新代码:将空字符串替换为合法 JSON 默认值 `"{}"`。 +2. 编译检查:`go build ./...`。 +3. 运行初始化:`POST /api/admin/system_titles/seed_default`,预期返回 6 个称号(新建或已存在)。 +4. 验证列表:登录后调用 `GET /api/admin/system_titles?page=1&page_size=20`,应返回 `total=6`。 + +## 风险与回滚 +- 仅修改种子默认值,不影响既有数据;若此前插入失败,无需回滚。 +- 如你希望 `obtain_rules_json` 有具体规则,我可以将默认值改为 `{"type":"manual"}` 或按你的要求填充。 + +确认后我将立即修复代码、重新初始化并验证页面数据。 \ No newline at end of file diff --git a/.trae/documents/初始化并排查称号管理列表为空.md b/.trae/documents/初始化并排查称号管理列表为空.md new file mode 100644 index 0000000..5768b78 --- /dev/null +++ b/.trae/documents/初始化并排查称号管理列表为空.md @@ -0,0 +1,31 @@ +## 现象与猜测 +- 运营端“称号管理”页面为空,极可能是尚未调用初始化接口或请求被鉴权拦截。 + +## 执行步骤 +- 后端健康检查 + - 确认服务已启动且数据库连接正常(启动日志不报错)。 +- 管理员登录获取 Token + - 账号:`admin`;密码:`chat2025` → 先做 MD5:`39f841df6f9a4768330b00d3ab9f1b4d` + - `POST /api/admin/login`,记录返回 `token` +- 初始化 6 个称号与效果 + - `POST /api/admin/system_titles/seed_default` + - 期望返回:`{ created: N, exists: M, ids: [...] }`(总数 6) +- 菜单补齐(后端动态菜单模式) + - `POST /api/menu/ensure_titles` + - 期望返回:`{ ensured: true, parent_id: , menu_id: }` +- 验证后端数据 + - `GET /api/admin/system_titles?page=1&page_size=20`,请求头加 `Authorization: Bearer ` + - 期望:`list` 含 6 条数据,`total=6` +- 前端验证 + - 进入“运营管理 → 称号管理”,若仍为空: + - 打开浏览器网络面板检查 `admin/system_titles` 响应码与返回体:401 表示未登录;200 且 `list=[]` 表示数据未初始化或失败(重试初始化)。 + +## 注意点 +- 初始化接口在非认证组,便于快速拉起;列表接口需要登录。 +- 之前的路由重复注册已修复(避免 Gin panic)。 +- 若你希望把占位参数(券模板 `template_id`、奖品 `target_prize_ids`)替换为真实运营配置,我将更新 `params_json` 并再次初始化。 + +## 验收标准 +- 初始化后,`GET /api/admin/system_titles` 返回 6 条记录;前端列表显示 6 条。 + +确认后我将按照以上步骤执行初始化与验证,确保页面数据正常展示。 \ No newline at end of file diff --git a/.trae/documents/双倍概率效果详解与作用范围简化方案.md b/.trae/documents/双倍概率效果详解与作用范围简化方案.md new file mode 100644 index 0000000..d9b0e19 --- /dev/null +++ b/.trae/documents/双倍概率效果详解与作用范围简化方案.md @@ -0,0 +1,46 @@ +## 字段与行为详解(以 effect_type=6 双倍概率为例) +- 目标ID(target_prize_ids) + - 含义:指定哪些奖品可触发双倍;为空数组表示对所有奖品生效。 + - 应用代码:合并目标集 `doubleTargets`(internal/service/activity/draw_with_effects.go:143-145)。 +- 概率(chance_x1000) + - 含义:双倍触发的概率,千分制(1000=100%、200=20%)。多个效果的概率会累加,并受统一封顶限制。 + - 应用代码:累加并按 `CapValueX1000` 统一封顶(internal/service/activity/draw_with_effects.go:146-149);最终判定(199-207)。 +- 周期内次数上限(period_cap_times) + - 业务含义:一个统计周期内最多能触发几次双倍(例如:自然日内最多1次)。 + - 当前实现:字段已解析(internal/service/activity/draw_with_effects.go:137),但暂未做持久化计数与强制限制;建议后续以“用户+周期”维度计数(DB/Redis),命中后递减或阻断。 +- 叠加策略(stacking_strategy) + - 业务含义:多效果如何合并(0最大值/1累加封顶/2首个匹配)。 + - 当前实现:对“概率加成(type=5)”生效(internal/service/activity/draw_with_effects.go:117-132);对“双倍概率(type=6)”未使用(合并逻辑仅累加+封顶)。 +- 统一封顶(cap_value_x1000) + - 含义:对累计概率(或加成)的统一上限(单位:千分)。 + - 应用代码:双倍概率封顶(internal/service/activity/draw_with_effects.go:147-149);概率加成默认分支封顶(127-131)。 +- 包含期(issue_ids)/排除期(exclude.issue_ids) + - 含义:限定效果只在指定期生效,或在某些期不生效。 + - 应用代码:抽奖路径按期过滤(internal/service/activity/draw_with_effects.go:85-103);更全面的范围匹配在效果解析器(internal/service/title/effects_resolver.go:133-155)。 +- 包含活动(activity_ids) + - 含义:限定效果只在指定活动生效。 + - 应用代码:效果解析器支持(internal/service/title/effects_resolver.go:60-69, 133-155);抽奖路径当前只按期(issue)做简化过滤。 +- 排序(sort) + - 含义:效果的展示与应用顺序;不同策略可能受顺序影响(如取最大值时先后无影响,叠加时顺序影响可忽略)。 + - 应用代码:读取效果按 `Sort` 排序(internal/api/admin/titles_admin.go:163;internal/service/activity/draw_with_effects.go:47-49)。 + +## 作用范围简化(仅“活动”和“期”) +- 目标:将前端“作用范围”精简为: + - 包含活动 `activity_ids` + - 包含期 `issue_ids` + - 排除期 `exclude.issue_ids`(可选) +- 不再展示/保存:分类(category_ids)、时间/地区等复杂范围。 + +## 实施步骤(前端改造) +1. 效果编辑对话框(EffectEditDialog.vue) + - 保留 effect_type=6 的参数:`target_prize_ids/chance_x1000/period_cap_times`。 + - 精简作用范围面板,仅保留:`activity_ids`、`issue_ids`、`exclude.issue_ids`。 + - 提交时 `scopes_json` 只包含上述三个字段。 +2. 效果列表(EffectManagerDialog.vue) + - 参数展示映射只显示:目标奖品、概率、周期上限;范围只显示包含/排除期与活动。 +3. 文档更新 + - 在《说明文档.md》中标注“作用范围简化”,并更新字段速查表。 + +## 注意与后续 +- 周期计数未实现:若您需要严格的“周期内次数上限”,我将增加一次触发计数的服务(用户+周期维度),并在抽奖路径强制限制。 +- 抽奖路径按期过滤:如需按活动过滤,请在抽奖服务中引入效果解析器返回的范围匹配结果,或将 `issue` 与 `activity` 进行绑定映射。 \ No newline at end of file diff --git a/.trae/documents/后台管理接口审计与无用代码清理.md b/.trae/documents/后台管理接口审计与无用代码清理.md new file mode 100644 index 0000000..9b5fb5e --- /dev/null +++ b/.trae/documents/后台管理接口审计与无用代码清理.md @@ -0,0 +1,73 @@ +# 后台管理接口审计与无用代码清理 + +## 目标 +- 全面梳理管理端(`/api/admin/*`)后端接口与前端页面调用关系,排除 App 端接口 +- 标注未被前端使用的管理端接口与文件,制定安全清理方案 + +## 范围 +- 后端:`internal/router/router.go` 管理端分组与 `internal/api/admin/*` +- 前端:`web/admin/src/api/*` 与 `web/admin/src/views/*` 中使用的管理端接口 +- 排除:`/api/app/*` 与模板系统管理接口(`/api/user/*`, `/api/role/*`, `/api/v3/system/menus/simple`) + +## 关键发现 +- 路由注册位置:`internal/router/router.go` 管理端非鉴权与鉴权分组 + - 非鉴权: + - `GET /api/admin/license/status`(内联处理器)· `internal/router/router.go:53` + - `POST /api/admin/login` → `Login()` · `internal/router/router.go:57`,`internal/api/admin/login.go` + - 鉴权组主要接口(节选,完整见路由文件): + - 活动与期次/奖励/随机承诺/抽奖:`/api/admin/activities/*` · `internal/router/router.go:72-90` + - 抽奖凭据:`GET /draw_receipts/:draw_id`、`GET /draw_receipts/log/:log_id` · `internal/router/router.go:91-92` + - 批量用户:`POST/DELETE /batch_users` · `internal/router/router.go:93-94` + - 工会:`/guilds/*` · `internal/router/router.go:96-100` + - 商品与分类:`/product_categories/*`、`/products/*` · `internal/router/router.go:103-110` + - 轮播图:`/banners/*` · `internal/router/router.go:113-116` + - 玩家:`/users/*`(邀请、订单、优惠券、积分、库存、道具卡、奖励发放)· `internal/router/router.go:119-129,141` + - 系统道具卡/优惠券:`/system_item_cards/*`、`/system_coupons/*` · `internal/router/router.go:132-140` +- 示例后端处理函数位置: + - 抽奖凭据:`GetDrawReceipt()` 与 `GetDrawReceiptByLogID()` · `internal/api/admin/draw_receipt.go:31-75,77-121` + +- 前端管理端接口使用(以 `VITE_API_URL=/api` 为前缀,`url: 'admin/...'` → `/api/admin/...`) + - 登录:`POST admin/login` · `web/admin/src/api/auth.ts:11` + - 活动/期次/奖励/随机承诺/抽奖:`admin/activities/*` · `web/admin/src/api/adminActivities.ts:13,29,33,37,47,57,68,75,93,112,133,140,146,159,172,188,210,247,274,299,323,347` + - 批量用户:`admin/batch_users` · `web/admin/src/api/adminActivities.ts:220,227` + - 抽奖凭据:`admin/draw_receipts/:id`、`admin/draw_receipts/log/:log_id` · `web/admin/src/api/adminActivities.ts:299,323` + - 玩家管理:`admin/users/*`(邀请、订单、优惠券、积分、余额、库存、道具卡、奖励发放)· `web/admin/src/api/player-manage.ts:25,48,81,106,130,157,188,198,210,220,239` + - 轮播图:`admin/banners/*` · `web/admin/src/api/banner.ts:14,26,39,43` + - 商品与分类:`admin/product_categories/*`、`admin/products/*` · `web/admin/src/api/product.ts:15,22,32,38,63,76,90,94` + - 系统道具卡/优惠券:`admin/system_item_cards/*`、`admin/system_coupons/*` · `web/admin/src/api/itemCards.ts:81,86,92,96,101`,`web/admin/src/api/coupons.ts:59,64,70,74` + - 工会管理(管理端):`admin/guilds/*`、成员:`admin/guilds/:guild_id/members` · `web/admin/src/api/adminGuild.ts:12,27,31,35`,`web/admin/src/api/guild.ts:61` + +## 待清理项(未发现前端使用) +- `GET /api/admin/license/status` · `internal/router/router.go:53`(前端无调用) +- 管理员账号维护接口: + - `POST /api/admin/create` · `internal/router/router.go:66`,`internal/api/admin/admin_create.go` + - `PUT /api/admin/:id` · `internal/router/router.go:67`,`internal/api/admin/admin_modify.go` + - `POST /api/admin/delete` · `internal/router/router.go:68`,`internal/api/admin/admin_delete.go` + - `GET /api/admin/list` · `internal/router/router.go:69`,`internal/api/admin/admin_list.go` +- 以上清单根据前端 `web/admin/src/api/*` 的全面检索得出;如后续发现隐藏使用,则可回滚。 + +## 实施步骤 +1. 冻结与确认 + - 在代码评审中确认上述待清理项确实不需要;标注使用范围仅限管理端 +2. 路由层清理 + - 从 `internal/router/router.go` 删除或注释未用路由注册行:`53, 66-69` + - 核对编译影响:移除后需同时删除对应 handler 引用,避免未引用编译警告 +3. 处理器与文件清理 + - 删除对应的处理器文件:`internal/api/admin/admin_create.go`、`admin_modify.go`、`admin_delete.go`、`admin_list.go` + - 若 `license/status` 为内联处理器,无独立文件,仅移除路由与调用 +4. 依赖与构建 + - 执行模块级编译,确保无引用残留(如 `admin.New` 构造体内未使用成员) + - 运行单元/集成用例(如存在),并做最小回归:登录、活动、玩家、商品、运营、工会等主流程 +5. 文档与变更说明 + - 记录变更影响范围与回滚策略,标注接口移除清单 + +## 验收标准 +- 管理端页面所有功能可正常使用与构建通过 +- 删除的接口在全仓内无调用引用(前端/后端均为 0) +- 关键页面 API 映射完整且无 404/500 + +## 回滚方案 +- 路由注册保留注释备份;如发现遗漏使用,按文件恢复并重新注册路由 + +## 下一步请求 +- 若确认以上清理项与步骤,开始实施清理并提交差异,附带验证报告与引用检索证明 \ No newline at end of file diff --git a/.trae/documents/哈希抽奖逻辑与控制策略说明.md b/.trae/documents/哈希抽奖逻辑与控制策略说明.md new file mode 100644 index 0000000..f202356 --- /dev/null +++ b/.trae/documents/哈希抽奖逻辑与控制策略说明.md @@ -0,0 +1,74 @@ +# 抽奖控制策略选择与执行 + +## 背景与约束 + +* 不改变算法逻辑(承诺→HMAC+拒绝采样→验算一致),仅通过“输入/配置/时机”达到运营控制。 + +* 验算用户能看到的是回执中池快照与承诺哈希一致性,不能证明你在抽后操纵,只能确认抽前配置。 + +## 方案A:权重/库存门控(推荐) + +* 做法:在承诺前将目标奖品 `weight=0` 或 `quantity=0`,前 N 次(或直到达到阈值)不进入抽取集合;到期后恢复权重/库存并重新承诺。 + +* 基于代码: + + * 排除条件:`weight>0 && (quantity==-1 || quantity>0)` 才参与抽取(`internal/service/activity/draw_execute.go:31-35,56-58`)。 + + * 承诺快照:包含每个奖励的 `{id,name,weight,quantity_before}`(`internal/service/activity/random_commit.go:53-61,67-73`)。 + +* 优点:简单直接、无需改算法;前 N 次绝不命中;验算完全通过。 + +* 缺点:恢复后需生成新承诺(`state_version` 增加),不同时间段 `items_root` 不同,运维需记录策略切换。 + +## 方案B:承诺版本切换 + +* 做法:用 `state_version` 管理期的承诺版本: + + * v1:不含目标奖品或其权重为 0 → 前 N 次抽使用 v1。 + + * v2:目标奖品恢复权重/库存 → 达到 N 后切换到 v2。 + +* 基于代码:承诺生成与历史查询(`internal/service/activity/random_commit.go:74-97,121-146`)。 + +* 优点:语义清晰、审计友好;对不同用户批次可严格区分承诺。 + +* 缺点:运维复杂度稍高;用户若横向对比可能看到承诺变化,但单次验算仍通过。 + +## 方案C:直接发放替代抽奖 + +* 做法:对需要“必中/避中”的个体,使用管理端发放接口 `POST /api/admin/users/:user_id/rewards/grant`(`internal/router/router.go:127`)。 + +* 优点:精确可控,零风险。 + +* 缺点:不产生抽奖回执;不适合需要“抽奖体验”的场景。 + +## 方案对比与推荐 + +* 目标“前 N 次不出现”且保留抽奖体验:优先选 **方案A(权重/库存门控)**,用 `quantity=0` 或 `weight=0` 让奖品在 N 次前不参与集合;到期后恢复并重新承诺。 + +* 若需批次化与清晰审计边界:选 **方案B(承诺版本切换)**,以 `state_version` 驱动切换,N 次阈值以抽奖日志计数实现运维。 + +* 个体定向控制:用 **方案C(直接发放)** 替代抽奖。 + +## 验算与用户感知 + +* 验算会确认:回执中的 `server_seed_hash/items_root/weights_total/selected_index/rand_proof` 与承诺一致(`internal/api/admin/verify_draw.go:50-66,104-138`)。 + +* 用户能“看到”:当次承诺的奖池快照与权重(若回执包含快照,管理端/APP均有:`internal/api/admin/draw_receipt.go:55-73`,`internal/api/activity/draw_app.go:28-34`)。 + +* 用户“感知不到”:你通过前置配置与时机实现“前 N 次不出现”的意图;只要在承诺前已固化,抽后不会被判定为操纵。 + +## 执行建议(不改代码) + +1. 选定期次与目标奖品,设置前置配置:`quantity=0` 或 `weight=0`。 +2. 生成承诺(`commit_random`)并上线;开始计数抽奖日志,达到 N 次后恢复配置并生成新承诺。 +3. 记录操作与版本切换,必要时在活动规则中说明奖池/期的切换策略。 + +## 参考位置 + +* 参与判定与选取:`internal/service/activity/draw_execute.go:31-35,50-66,131-145` + +* 承诺生成与版本:`internal/service/activity/random_commit.go:67-85,74-97,121-146` + +* 管理端验证:`internal/api/admin/verify_draw.go:50-66,104-138` + diff --git a/.trae/documents/头衔与权益效果方案设计.md b/.trae/documents/头衔与权益效果方案设计.md new file mode 100644 index 0000000..407f65e --- /dev/null +++ b/.trae/documents/头衔与权益效果方案设计.md @@ -0,0 +1,206 @@ +## 目标与范围 + +* 支持用户持有多个头衔,并在各类业务事件中正确生效与结算。 + +* 覆盖六类效果:领取优惠券、抽奖折扣、签到双倍积分、领取道具卡、概率加成、双倍奖励卡。 + +* 与既有四张表对齐:`system_titles`、`system_title_effects`、`user_titles`、`user_title_effect_claims`。 + +## 整体架构 + +* 配置层:运营通过`system_titles`与`system_title_effects`完成模板与效果配置。 + +* 持有层:`user_titles`记录用户持有与激活状态、有效期与来源。 + +* 事件引擎:在`SIGNIN`、`DRAW_PURCHASE`、`DRAW_EXECUTE`与`CLAIM`类接口中查询并结算效果。 + +* 防重限流:`user_title_effect_claims`按周期唯一键实现并发防重与频次限制。 + +```mermaid +flowchart LR +A[用户行为事件] --> B[查询激活头衔 user_titles] +B --> C[加载效果 system_title_effects] +C --> D{按scopes/effect_type过滤} +D --> E[叠加/上限策略计算] +E --> F[结算(积分/折扣/概率/发放)] +F --> G[必要时写 user_title_effect_claims] +``` + +## 效果模型与参数规范 + +* 统一字段:`effect_type`、`params_json`、`stacking_strategy`、`cap_value_x1000`、`scopes_json`、`status`。 + +* 固定小数:所有比例/倍数/折扣统一用`*_x1000`(例如 10% = 100,2倍 = 2000)。 + +* `params_json`建议结构: + + * 领取优惠券(`COUPON_CLAIM`): `{template_id, frequency:{period:'day|week|month', times:int}, start_at?, end_at?}` + + * 抽奖折扣(`DRAW_DISCOUNT`): `{discount_type:'percentage|fixed', value_x1000, min_price?, max_discount?}` + + * 签到倍数(`SIGNIN_MULTIPLIER`): `{multiplier_x1000, daily_cap_points?}` + + * 领取道具卡(`ITEM_CARD_CLAIM`): `{template_id, frequency:{period, times}}` + + * 概率加成(`PROBABILITY_BOOST`): `{target_pool_ids?:[], target_prize_ids?:[], boost_x1000, combine:'sum|max', cap_x1000?}` + + * 双倍奖励卡(`DOUBLE_REWARD`): `{chance_x1000, target_prize_ids?:[], period_cap_times?:int}` + +## 叠加与上限策略 + +* `stacking_strategy`枚举: + + * `none`:不叠加,取单一最高优先级或最大值。 + + * `sum_with_cap`:求和后受`cap_value_x1000`或业务cap约束。 + + * `max_only`:取最大项。 + + * `multiply_with_cap`:倍数相乘后再cap(适用于少数特殊配置)。 + + * `priority_order`:按`priority`字段逐项应用,遇到冲突按优先级覆盖。 + +* 推荐缺省: + + * 抽奖折扣:`max_only`(防止过度优惠),可选`sum_with_cap`。 + + * 签到倍数:`sum_with_cap`(如多头衔加总倍数,上限控制)。 + + * 概率加成:`sum_with_cap`(保持可解释性与稳定性)。 + + * 领取型权益:同模板同周期`sum_with_cap`或`max_only`,按运营策略选择。 + + * 双倍奖励卡:多卡按`chance_x1000`合并使用`sum_with_cap`,并设`period_cap_times`。 + +## 作用域与生效判定 + +* `scopes_json`建议:`{activity_ids?:[], phase_ids?:[], category_ids?:[], exclude?:{...}}`。 + +* 判定顺序:事件上下文→按`activity/phase/category`过滤→`status=active`→用户头衔`active=1`且未过期→计算叠加与cap。 + +* 冲突处理:包含与排除并存时优先排除;多效果同类型按`stacking_strategy`解决。 + +## 事件结算流程 + +* 签到(`SIGNIN`): + + * 载入`SIGNIN_MULTIPLIER`→合并倍数→应用上限→计算积分→落账。 + +* 抽奖购票(`DRAW_PURCHASE`): + + * 载入`DRAW_DISCOUNT`→取最大或合并后cap→计算优惠→生成支付单。 + +* 抽奖执行(`DRAW_EXECUTE`): + + * 载入`PROBABILITY_BOOST`→对目标池/奖品加权(总概率归一)→进行抽取→载入`DOUBLE_REWARD`测试加倍机会→如命中,倍增奖品数量/价值并校验当期可用次数。 + +* 领取(`COUPON_CLAIM`/`ITEM_CARD_CLAIM`): + + * 解析`frequency.period`生成`period_key`(`YYYYMMDD|YYYYWW|YYYYMM`)→检查唯一键是否已存在→未存在则发放并写`user_title_effect_claims`;已存在直接返回已领取。 + +## 防重与限流实现 + +* 唯一键:`user_id + title_id + effect_type + target_template_id + period_key`。 + +* 并发:使用数据库唯一约束天然防重;接口层加幂等键`Idempotency-Key`进一步防抖。 + +* 频次:根据`frequency.times`控制当期可用次数;若需累计多效果,按`stacking_strategy`决定合并或取最大。 + +## 接口设计(示例) + +* 头衔管理 + + * `POST /titles/assign`:分配或续期;参数:`user_id, title_id, obtained_at, expires_at, source`。 + + * `PATCH /titles/:user_id/:title_id/activate`:激活/停用。 + + * `GET /titles/user/:user_id?active=1`:查询用户激活头衔与效果摘要。 + +* 事件接口 + + * `POST /events/signin`:入参`user_id, context`;出参`points, applied_effects`。 + + * `POST /events/draw/purchase`:入参`user_id, base_price, context`;出参`final_price, discount_breakdown`。 + + * `POST /events/draw/execute`:入参`user_id, context`;出参`prize, boost_detail, double_reward_applied, audit_id`。 + + * `POST /claims/coupon`:入参`user_id, template_id, context`;出参`claimed, period_key, remaining_times`。 + + * `POST /claims/item-card`:入参同上。 + +* 诊断/审计 + + * `GET /effects/preview`:给定`user_id+context`预览本次将应用的合并效果,用于运营核对。 + +## 数据与索引建议 + +* 复用现有索引: + + * `user_titles`: `idx_user_titles_user`。 + + * `system_title_effects`: `idx_title_effects_title` + 按`effect_type/status`过滤。 + + * `user_title_effect_claims`: 周期唯一索引。 + +* 读优化: + + * 缓存`system_title_effects`(按`title_id`与`scopes_json`分片,TTL 5\~15 分钟)。 + + * 针对`DRAW_EXECUTE`的概率表做内存映射,命中后快速归一化计算。 + +## 风险与边界处理 + +* 浮点误差:统一`*_x1000`避免精度问题。 + +* 过度优惠/过度概率:必须设置`cap_value_x1000`或业务cap。 + +* 多模板冲突:同类型不同模板时采用`priority_order`或业务白名单优先级。 + +* 过期与停用:事件前统一校验有效期与`status`,过期/停用不生效。 + +* 诊断可视化:提供`preview`接口给运营验证,防误配置上线。 + +## 测试与验收标准 + +* 单元测试: + + * 每类效果的合并逻辑与cap测试;周期键生成与唯一约束;概率加成归一化与双倍卡应用顺序。 + +* 并发/幂等: + + * 高并发领取/购票/抽奖的防重与一致性测试。 + +* 集成测试: + + * 端到端场景: + + * 多头衔叠加签到倍数,限制生效。 + + * 两个折扣叠加取最大或cap限。 + + * 抽奖概率加成后命中率变更,双倍卡当期次数受限。 + + * 同模板每日领取限 1 次,多效果按策略合并。 + +* 验收标准: + + * 用户可持有多个头衔并正确生效; + + * 六类效果在对应事件中结算正确,含叠加与上限; + + * 领取型权益防重与限流稳定; + + * 审计日志完整,能输出`applied_effects`明细; + + * 性能满足并发指标(按业务要求)。 + +## 交付物 + +* 结算引擎与效果合并模块的实现方案说明与接口契约。 + +* 管理/诊断接口的API说明与示例。 + +* 测试用例清单与通过报告。 + +* 运营使用指引:效果配置、叠加策略与风险提示。 + diff --git a/.trae/documents/头衔系统与抽奖增益设计方案.md b/.trae/documents/头衔系统与抽奖增益设计方案.md new file mode 100644 index 0000000..d594247 --- /dev/null +++ b/.trae/documents/头衔系统与抽奖增益设计方案.md @@ -0,0 +1,81 @@ +# 头衔加概率与折扣的可验证设计 + +## 结论 + +* 采用“权重修饰”的方式为持有头衔的用户提升抽奖概率。 + +* 哈希算法(HMAC-SHA256 随机熵)保持不变;只扩展承诺与收据的字段以固化规则与有效权重,保证可验证性与不可篡改。 + +## 必要扩展 + +* 承诺扩展(IssueRandomCommitment) + + * 新增 `AlgoRulesHash`:对“权重修饰规则(由头衔配置确定的纯函数)”做哈希并随期承诺固化。服务端在抽取前不能更改规则。 + +* 收据扩展(Receipt) + + * 新增 `UserId`:明确本次抽取的用户。 + + * 新增 `WeightsTotalEffective`:按用户头衔修饰后的总权重,用于位置采样与第三方复核。 + + * 新增 `UserWeightFactorsRoot`:以 `reward_id -> factor` 做 Merkle 根,第三方可据此对 `Items(snapshot)` 重建有效权重。 + +* 编码消息扩展(不改算法,仅改输入) + + * 仍使用 `HMAC(serverSubSeed, encodeMessage(...))`;`encodeMessage` 增加 `UserId` 与 `WeightsTotalEffective` 字段(保持确定性)。 + +## 规则与确定性 + +* 规则来源:头衔模板配置(百分比或加点),以及叠加策略(none|max|multiply)。 + +* 纯函数:`factor = F(user_titles, reward_id)`,只依赖用户持有的头衔与静态规则;不依赖随机数或服务端临时状态。 + +* 可复核:第三方得到 `ItemsRoot`、`AlgoRulesHash`、`UserWeightFactorsRoot`、`UserId` 与收据内快照,即可重建每个 `reward_id` 的有效权重与 `WeightsTotalEffective`,据同样的消息编码与哈希熵验证 `SelectedIndex`。 + +## 核心流程(抽奖不变,仅插入修饰步骤) + +1. 读取承诺:`GetIssueRandomCommit(...)`(`internal/service/activity/random_commit.go:99`)。 +2. 载入奖励池快照:同现有实现(`internal/service/activity/draw_execute.go:21-35`)。 +3. 计算用户权重修饰:`effective_weight[i] = base_weight[i] * factor(user, reward_id[i])`;汇总 `WeightsTotalEffective`。 +4. 使用 HMAC-SHA256 生成熵(算法不变),对 `encodeMessage(..., UserId, WeightsTotalEffective)` 取样位点。 +5. 按有效权重的累加区间选中 `SelectedIndex/SelectedItemId`。 +6. 返回收据,并包含 `UserWeightFactorsRoot` 与证明材料。 + +## 折扣集成(独立不影响随机) + +* 抽奖订单创建(新增):以活动门票价 `PriceDraw`(`internal/repository/mysql/model/activities.gen.go:22`)为基价。 + +* 头衔折扣:按叠加策略计算 `discount_amount`,落到订单字段 `DiscountAmount/ActualAmount`(`internal/repository/mysql/model/orders.gen.go:22`)。 + +* 与优惠券/积分叠加:默认“优惠券优先 + 头衔折上折”,可通过 `stack_policy` 切到“取最大”。 + +## 接口与服务 + +* 管理端:头衔模板 CRUD + 发放;配置权重修饰参数与叠加策略。 + +* APP: + + * 抽奖订单创建接口(新):返回计算后的金额。 + + * 抽奖接口:保持路径不变(`internal/api/activity/draw_app.go:28`),服务内执行权重修饰。 + +* 模拟与校验: + + * 模拟接口增加 `user_id`,输出期望概率与观测分布。 + + * 校验接口以收据扩展字段重建有效权重,验证 `SelectedIndex`。 + +## 验收标准 + +* 头衔发放后,持有用户的抽奖订单能正确展示并结算折扣。 + +* 抽奖收据包含新增字段,第三方使用承诺与收据可重放选中过程;不持有头衔的用户与持有者的分布差异符合规则。 + +* 哈希熵算法未变,安全与不可预测性不受影响。 + +## 说明:关于“加概率是否影响哈希算法” + +* 不影响哈希算法本身(仍是 HMAC-SHA256)。 + +* 影响的是“采样空间”——由原始 `weights_total` 改为 `WeightsTotalEffective`,并且将该值与用户标识作为消息输入的一部分来固定采样过程;承诺和收据的扩展确保服务端无法事后调参。 + diff --git a/.trae/documents/头衔表DDL(含字段备注与事务).md b/.trae/documents/头衔表DDL(含字段备注与事务).md new file mode 100644 index 0000000..f58fcb1 --- /dev/null +++ b/.trae/documents/头衔表DDL(含字段备注与事务).md @@ -0,0 +1,127 @@ +# 头衔权益系统设计与开发方案 + +## 目标与范围 +- 目标:构建“用户头衔”体系,使用户可拥有多个头衔,并在签到、抽奖、领取等事件中触发对应权益。 +- 权益范围: + 1. 领取优惠券 + 2. 抽奖折扣(票价优惠) + 3. 签到双倍积分 + 4. 领取道具卡 + 5. 抽奖概率加成 + 6. 奖品双倍概率(命中后按概率倍增奖励) +- 管理端:支持头衔模板与效果配置、用户分配与启停;用户端:展示头衔、领取入口、页面显示加成提示。 + +## 现有能力复用 +- 优惠券:`system_coupons/user_coupons` 与管理端发券接口。 +- 积分与流水:`user_points/user_points_ledger`,新增“签到”服务即可接入倍数规则。 +- 抽奖:`activity_issues/activity_reward_settings` 与执行流程,按设计接入概率加成与双倍概率试验。 +- 道具卡:`system_item_cards/user_item_cards` 与相关服务。 + +## 数据模型(已提供DDL) +- `system_titles`:头衔模板主表,定义名称、状态、获得规则与作用范围。 +- `system_title_effects`:头衔-效果配置,一条记录代表一个效果项(含类型、参数、叠加策略与封顶)。 +- `user_titles`:用户持有头衔资产,含激活状态、有效期与来源。 +- `user_title_effect_claims`:领取型权益的周期限流与防重(如每日领券一次)。 +- 注:不改动 `activity_draw_effects` 结构;抽奖效果的来源审计通过“效果应用层”记录到日志或 `Remark` 字段,保持表结构不变。 + +## 效果类型与参数约定 +- `effect_type`(整型枚举): + - 1=COUPON_CLAIM(领取优惠券) + - 2=DRAW_DISCOUNT(抽奖折扣) + - 3=SIGNIN_MULTIPLIER(签到积分倍数) + - 4=ITEM_CARD_CLAIM(领取道具卡) + - 5=DRAW_PROBABILITY_BONUS(抽奖概率加成) + - 6=DRAW_REWARD_DOUBLE_CHANCE(奖品双倍概率) +- `params_json` 约定: + - COUPON_CLAIM:`{template_id, frequency: 'once|daily|weekly|monthly', max_per_period}` + - DRAW_DISCOUNT:`{discount_type: 'percent|fixed', value, max_cap, applicable_activities, applicable_issues}` + - SIGNIN_MULTIPLIER:`{multiplier_x1000: 2000, cap_value_x1000: 3000}` + - ITEM_CARD_CLAIM:`{template_id, frequency, max_per_period}` + - DRAW_PROBABILITY_BONUS:`{probability_delta_x1000, cap_value_x1000, applicable_activities, applicable_issues}` + - DRAW_REWARD_DOUBLE_CHANCE:`{multiplier_x1000: 2000, chance_x1000: 150, max_multiplier_cap_x1000: 3000, limit_per_day: null, applicable_activities, applicable_issues}` +- `stacking_strategy`:0=取最大(MAX);1=累加并封顶(STACK_CAPPED);2=按排序首个(FIRST_MATCH)。 + +## 叠加与封顶规则 +- 抽奖折扣:同类折扣按策略(默认MAX)合并;若`STACK_CAPPED`则累加至`max_cap`。 +- 签到倍数:乘法叠加但有总封顶(如≤3x)。 +- 概率加成:相加叠加后按上限封顶(如≤500‰)。 +- 奖品双倍概率: + - 概率合并:`p_total = 1 - ∏(1 - p_i)`,并设置上限(如≤50%)。 + - 倍数合并:与其他奖励倍数(如道具卡)做乘法,但最终乘积受`max_multiplier_cap_x1000`封顶(如≤3x)。 + +## 事件与规则引擎(EffectEngine) +- 统一事件: + - `SIGNIN`:加载用户激活头衔,应用`SIGNIN_MULTIPLIER`,写积分与流水。 + - `COUPON_CLAIM/ITEM_CARD_CLAIM`:校验`user_title_effect_claims`限流后发放。 + - `DRAW_PURCHASE`:计算`DRAW_DISCOUNT`,作用于抽奖票价。 + - `DRAW_EXECUTE`:在“中奖项选择”后进行概率加成与“双倍概率”试验,计算最终奖励倍数;将结果影响在发奖/展示层体现,同时保留来源信息到日志或回执扩展结构(不改表)。 +- 引擎职责:读取`user_titles → system_title_effects`,按叠加策略合并同类项并输出结算指令;保障并发可重入与审计信息完备(在不改动抽奖效果表前提下记录来源)。 + +## 接口设计 +- 管理端 + - `POST /api/admin/system_titles` 头衔模板创建 + - `PUT /api/admin/system_titles/:id` 头衔模板修改 + - `GET /api/admin/system_titles` 列表检索 + - `DELETE /api/admin/system_titles/:id` 删除(级联效果) + - `POST /api/admin/system_titles/:id/effects` 效果创建(含JSON校验) + - `PUT /api/admin/system_titles/:id/effects/:effect_id` 效果修改 + - `DELETE /api/admin/system_titles/:id/effects/:effect_id` 效果删除 + - `POST /api/admin/users/:user_id/titles/:title_id/assign` 分配头衔 + - `PUT /api/admin/users/:user_id/titles/:title_id/toggle` 激活/停用 +- 用户端 + - `GET /api/app/users/:user_id/titles` 我的头衔与效果摘要(含可领取提示) + - `POST /api/app/users/:user_id/titles/:title_id/effects/coupon/claim` 领券 + - `POST /api/app/users/:user_id/titles/:title_id/effects/item_card/claim` 领卡 + - `POST /api/app/users/:user_id/signin` 签到(生成流水,应用倍数) + - 抽奖购票与执行:在现有接口内接入EffectEngine,不改外部路由签名。 + +## 业务流程 +- 领取型权益:校验`user_title_effect_claims`唯一键(周期),通过则发券/发卡并写入计数;拒绝则返回已达上限。 +- 签到:读取倍数并写入`user_points_ledger(action=signin)`;倍数叠加与封顶在引擎完成。 +- 抽奖购票:计算折扣并返回应付价;可在订单侧抵扣。 +- 抽奖执行: + 1. 按现有算法选中奖励项 + 2. 计算概率加成后的总概率与“双倍概率”的`p_total` + 3. 使用受控RNG进行一次试验,命中则将奖励倍数乘以`multiplier_x1000` + 4. 将来源信息(title/effect参数)记录到日志或回执扩展结构(不改动表),用于运营分析和复核 + +## 风控与审计 +- 限流与防重:依赖`user_title_effect_claims`唯一键与事务保证。 +- 并发安全:接口层加幂等键(如`period_key`)与事务处理,避免重复发放。 +- 来源审计:在不改表的前提下,将来源写入日志/回执的备注域或独立运营日志表(后续若需要可扩展)。 + +## 开发方案与里程碑 +- M1 数据层与代码生成 + - 执行DDL(事务+先删后建),新增四张表 + - 更新`cmd/gormgen`配置并生成DAO/Model + - 管理端:头衔模板与效果CRUD接口与页面 +- M2 规则引擎与签到 + - 实现EffectEngine(加载、合并、封顶、指令输出) + - 新增`POST /api/app/users/:user_id/signin`服务,应用签到倍数 + - 领取型权益限流服务与接口(领券/领卡) +- M3 抽奖集成 + - 在购票结算接入`DRAW_DISCOUNT` + - 在抽奖执行接入`DRAW_PROBABILITY_BONUS`与`DRAW_REWARD_DOUBLE_CHANCE` + - 在不改表前提下,于日志/回执的备注域记录来源参数 +- M4 前端联动与运营 + - 管理端:头衔与效果配置表单、分配头衔入口 + - 用户端:我的头衔页、领取入口、抽奖页加成展示 +- M5 测试与验收 + - 单元/集成测试:限流、叠加、封顶、并发 + - 压测与随机性验证:抽奖公平性与概率准确性 + - 文档与Swagger更新 + +## 测试用例要点 +- 单头衔:15%双倍,抽样命中率约等于15%,倍数为2x +- 多头衔概率合并:10%与20% → 总概率≈28% +- 倍数合并:与道具卡1.5x叠加,最终乘积≤封顶 +- 领取限流:同周期重复领取被拒绝,计数与时间正确更新 +- 并发:高并发领取与抽奖下无重复发放与越界叠加 + +## 验收标准 +- 多头衔并存下,五类权益+双倍概率在对应事件正确触发与叠加、封顶生效 +- 管理端与用户端流程完整、接口稳定、文档齐备 +- 概率与倍数验证通过,随机性与公平性符合预期 +- 审计信息可追踪(在现有表结构前提下) + +请确认以上“设计方案与开发计划”,确认后我将按里程碑开始实现。 \ No newline at end of file diff --git a/.trae/documents/头衔表设计方案.md b/.trae/documents/头衔表设计方案.md new file mode 100644 index 0000000..a4617ac --- /dev/null +++ b/.trae/documents/头衔表设计方案.md @@ -0,0 +1,102 @@ +# 头衔表设计DDL(含事务与字段说明) + +## 字段说明 + +* system\_titles:`id`、`name`、`description`、`status`、`obtain_rules_json`、`scopes_json`、`created_at`、`updated_at` + +* system\_title\_effects:`id`、`title_id`、`effect_type`、`params_json`、`stacking_strategy`、`cap_value_x1000`、`scopes_json`、`sort`、`status`、`created_at` + +* user\_titles:`id`、`user_id`、`title_id`、`active`、`obtained_at`、`expires_at`、`source`、`remark`、`created_at` + +* user\_title\_effect\_claims:`id`、`user_id`、`title_id`、`effect_type`、`target_template_id`、`period_key`、`claim_count`、`last_claim_at`、`created_at` + +* activity\_draw\_effects 扩展:`source_type`、`source_id` + +## 事务化SQL(先删表再新建,含COMMIT) + +```sql +START TRANSACTION; +SET FOREIGN_KEY_CHECKS=0; + +DROP TABLE IF EXISTS `system_title_effects`; +DROP TABLE IF EXISTS `user_title_effect_claims`; +DROP TABLE IF EXISTS `user_titles`; +DROP TABLE IF EXISTS `system_titles`; + +CREATE TABLE `system_titles` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(64) NOT NULL, + `description` VARCHAR(255) NULL, + `status` TINYINT NOT NULL DEFAULT 1, + `obtain_rules_json` JSON NULL, + `scopes_json` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY `uk_system_titles_name` (`name`), + INDEX `idx_system_titles_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `system_title_effects` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `title_id` BIGINT NOT NULL, + `effect_type` INT NOT NULL, + `params_json` JSON NOT NULL, + `stacking_strategy` INT NOT NULL DEFAULT 0, + `cap_value_x1000` INT NULL, + `scopes_json` JSON NULL, + `sort` INT NOT NULL DEFAULT 0, + `status` TINYINT NOT NULL DEFAULT 1, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `fk_title_effects_title` FOREIGN KEY (`title_id`) REFERENCES `system_titles`(`id`) ON DELETE CASCADE, + INDEX `idx_title_effects_title` (`title_id`), + INDEX `idx_title_effects_type` (`effect_type`), + INDEX `idx_title_effects_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `user_titles` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `title_id` BIGINT NOT NULL, + `active` TINYINT NOT NULL DEFAULT 1, + `obtained_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `expires_at` DATETIME(3) NULL, + `source` VARCHAR(64) NULL, + `remark` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `fk_user_titles_title` FOREIGN KEY (`title_id`) REFERENCES `system_titles`(`id`) ON DELETE CASCADE, + UNIQUE KEY `uk_user_title_unique` (`user_id`, `title_id`), + INDEX `idx_user_titles_user` (`user_id`), + INDEX `idx_user_titles_active` (`active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `user_title_effect_claims` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `title_id` BIGINT NOT NULL, + `effect_type` INT NOT NULL, + `target_template_id` BIGINT NULL, + `period_key` VARCHAR(16) NOT NULL, + `claim_count` INT NOT NULL DEFAULT 0, + `last_claim_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + CONSTRAINT `fk_claims_title` FOREIGN KEY (`title_id`) REFERENCES `system_titles`(`id`) ON DELETE CASCADE, + UNIQUE KEY `uk_title_claim_period` (`user_id`, `title_id`, `effect_type`, `target_template_id`, `period_key`), + INDEX `idx_title_claims_user` (`user_id`), + INDEX `idx_title_claims_effect` (`effect_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE `activity_draw_effects` + ADD COLUMN `source_type` INT NOT NULL DEFAULT 0, + ADD COLUMN `source_id` BIGINT NULL, + ADD INDEX `idx_draw_effects_source` (`source_type`, `source_id`); + +SET FOREIGN_KEY_CHECKS=1; +COMMIT; +``` + +## 说明 + +* `period_key`:按策略生成(如每日`YYYYMMDD`、每周`YYYYWW`、每月`YYYYMM`)。 + +* 事务与外键检查切换保障迁移原子性与顺序删除/创建。 + diff --git a/.trae/documents/头衔规则与设计思路.md b/.trae/documents/头衔规则与设计思路.md new file mode 100644 index 0000000..e042e16 --- /dev/null +++ b/.trae/documents/头衔规则与设计思路.md @@ -0,0 +1,55 @@ +## 目标与范围 +- 设计并梳理六类头衔效果:1领取优惠券、2抽奖折扣、3签到双倍积分、4领取道具卡、5概率加成、6奖励双倍。 +- 输出参数规范、叠加与封顶策略、作用域策略,以及标准化新建流程(管理端接口与数据约束)。 + +## 效果类型与参数规范 +- 1 领取优惠券:`template_id`(券模板ID)、`frequency.period`(day|week|month)、`frequency.times`(周期次数)。示例:`{"template_id":123,"frequency":{"period":"day","times":1}}`(种子示例 `internal/api/admin/titles_seed.go:31`)。 +- 2 抽奖折扣:`discount_type`(percentage|fixed)、`value_x1000`(千分比或固定额)、`max_discount_x1000`(折扣封顶)。示例:`{"discount_type":"percentage","value_x1000":100,"max_discount_x1000":300}`(`internal/api/admin/titles_seed.go:32`)。 +- 3 签到双倍积分:`multiplier_x1000`(如2000表示x2)、`daily_cap_points`(每日积分上限)。示例:`{"multiplier_x1000":2000,"daily_cap_points":3000}`(`internal/api/admin/titles_seed.go:33`)。 +- 4 领取道具卡:`template_id`、`frequency.period/times`(同“领券”频控)。示例:`{"template_id":456,"frequency":{"period":"week","times":2}}`(`internal/api/admin/titles_seed.go:34`)。 +- 5 概率加成:`target_prize_ids[]`(目标奖品ID)、`boost_x1000`(加成千分比)、`cap_x1000`(单项封顶,可选)。示例:`{"target_prize_ids":[1,2],"boost_x1000":100,"cap_x1000":300}`;应用参考 `internal/service/activity/draw_with_effects.go:104-131`。 +- 6 奖励双倍:`target_prize_ids[]`、`chance_x1000`(命中概率千分比)、`period_cap_times`(期内命中上限,可选)。示例:`{"target_prize_ids":[3],"chance_x1000":150}`;命中判定参考 `internal/service/activity/draw_with_effects.go:197-208`。 + +## 叠加与封顶策略 +- `StackingStrategy`: + - `0` max_only:取效果最大值。 + - `1` sum_with_cap:累加并按封顶值限制(优先使用效果内的 `cap_x1000`,否则用 `CapValueX1000`)。 + - `2` first_match:首个命中后忽略后续。 + - 默认:累加并按 `CapValueX1000` 封顶(抽奖已实现 `internal/service/activity/draw_with_effects.go:117-131`、`internal/service/activity/draw_with_effects.go:146-149`)。 +- 封顶单位统一为 `x1000` 千分比,避免浮点误差。 + +## 作用域策略(ScopesJSON) +- 字段:`activity_ids/issue_ids/category_ids` 与 `exclude` 子段;解析与匹配参考 `internal/service/title/effects_resolver.go:59-69`、`internal/service/title/effects_resolver.go:133-155`。 +- 生效原则:先排除再包含;未配置视为全局生效。 + +## 新建流程(管理端) +- 步骤1:创建称号模板 + - `POST /api/admin/system_titles`(`internal/router/router.go:128`;处理 `internal/api/admin/titles_admin.go:81-103`)。 + - 请求体:`name/description/status/obtain_rules_json/scopes_json`(空JSON默认填 `{}`)。 +- 步骤2:为称号模板添加效果 + - `POST /api/admin/system_titles/:title_id/effects`(`internal/router/router.go:132-133`;处理 `internal/api/admin/titles_admin.go:183-213`)。 + - 请求体:`effect_type/params_json/stacking_strategy/cap_value_x1000/scopes_json/sort/status`。 +- 步骤3:分配给用户(可选,含有效期) + - `POST /api/admin/users/:user_id/titles`(`internal/router/router.go:127`;处理 `internal/api/admin/titles_admin.go:291-353`)。 + - 请求体:`title_id/expires_at(RFC3339)/remark`。 +- 步骤4:校验 + - 列表查询与检查:`GET /api/admin/system_titles`、`GET /api/admin/system_titles/:title_id/effects`(`internal/router/router.go:126`、`internal/router/router.go:131`;处理 `internal/api/admin/titles_admin.go:27-59`、`internal/api/admin/titles_admin.go:156-171`)。 + +## 数据与规则约束 +- 名称唯一与幂等:同名不重复创建(`internal/api/admin/titles_seed.go:41-48`)。 +- 状态控制:模板与效果需同时启用才会生效(抽奖过滤 `internal/service/activity/draw_with_effects.go:46-49`、`internal/service/activity/draw_with_effects.go:80-83`)。 +- 有效期与激活:仅激活且未过期的用户称号参与计算(`internal/service/activity/draw_with_effects.go:54-67`)。 +- 参数校验:`params_json` 必填(`internal/api/admin/titles_admin.go:195-206`)。 + +## 示例:新增“签到双倍积分”称号 +- 创建模板:`name=签到达人`、`status=1`、`scopes_json={}`。 +- 添加效果:`effect_type=3`、`params_json={"multiplier_x1000":2000,"daily_cap_points":3000}`、`stacking_strategy=1`、`cap_value_x1000=3000`、`status=1`。 +- 分配给用户:设置 `expires_at`(如7天后)并激活。 + +## 示例:新增“抽奖折扣”称号 +- 创建模板:`name=折扣官`、`status=1`、`scopes_json={"activity_ids":[1001]}`。 +- 添加效果:`effect_type=2`、`params_json={"discount_type":"percentage","value_x1000":150,"max_discount_x1000":500}`、`stacking_strategy=1`、`cap_value_x1000=500`、`status=1`。 + +## 后续实施建议 +- 在签到与购票模块接入统一解析器与效果计算器,实现(2)(3)的实际应用;领取型(1)(4)接入 `UserTitleEffectClaims` 做周期限流与资产发放;抽奖效果写入审计日志。 +- 你确认后,我将按上述流程与参数规范为每种类型提供可执行的创建脚手架(接口调用示例与测试用例),并补齐未接入业务的效果逻辑。 \ No newline at end of file diff --git a/.trae/documents/安全审计结论与修复方案(称号_特效_优惠券_抽奖).md b/.trae/documents/安全审计结论与修复方案(称号_特效_优惠券_抽奖).md new file mode 100644 index 0000000..f3b3ee8 --- /dev/null +++ b/.trae/documents/安全审计结论与修复方案(称号_特效_优惠券_抽奖).md @@ -0,0 +1,41 @@ +## 审计重点 +- 配置与密钥:敏感信息明文存储在 `configs/*.toml`(数据库密码、JWT密钥、COS密钥、微信AppSecret、commit_master_key)。 +- 路由与鉴权:管理端多数接口已启用 `AdminTokenAuthVerify`;但存在非鉴权管理端接口(如 `seed_default`、`ensure_titles`)。 +- 特效参数:`SystemTitleEffects.ParamsJSON` 后端仅做“非空”校验,运行期解析失败静默忽略;缺少数值范围与集合规模约束。 +- 抽奖随机性:主密钥未打印日志;HMAC与拒绝采样实现合理;但密钥来源为明文配置文件,需迁移至环境变量。 +- 优惠券/道具卡:分配/发放缺少配额与上限控制、幂等与审计;优惠券发放未限制超管。 + +## 具体风险与代码位置 +- 明文密钥:`configs/fat_configs.toml:32`、`configs/*_configs.toml` 多处(JWT/COS/MySQL/Wechat/commit_master_key)。 +- 管理端非鉴权路由:`internal/router/router.go`(`/api/admin/system_titles/seed_default`、`/api/admin/menu/ensure_titles`)。 +- 特效参数缺失校验:`internal/api/admin/titles_admin.go:173-213, 225-260`(仅非空);运行期解析:`internal/service/activity/draw_with_effects.go:104-149`(失败忽略)。 +- 优惠券发放权限与配额:`internal/api/admin/users_admin.go::AddUserCoupon`(无超管校验、无模板配额扣减);道具卡分配虽限超管,但无上限/审计:`internal/api/admin/item_cards_admin.go::AssignUserItemCard`。 + +## 修复方案(分阶段) +1) 配置与密钥治理 +- 将敏感配置迁移到环境变量;`configs/*.toml`改为占位与示例;确保不在日志输出敏感值。 +- 在密钥读取处加入检测:若为空或默认值,拒绝启动并记录安全告警。 + +2) 鉴权与权限 +- 将所有管理端变更型接口统一置于鉴权组;保留非鉴权的仅限登录与必要的只读接口。 +- 优惠券发放接口增加超管校验或细粒度权限;引入操作审计(记录操作者、时间、对象、数量)。 + +3) 特效参数强校验 +- 后端在 `Create/ModifySystemTitleEffect` 按 `effect_type` 反序列化到明确 struct,启用 `DisallowUnknownFields`; +- 数值边界:`boost_x1000/chance_x1000/cap_x1000` 禁止负数,限制最大值;`target_prize_ids` 长度限制与去重; +- 运行期解析失败不再静默:记录结构化告警日志,并在管理端提示配置异常。 + +4) 优惠券/道具卡防滥用 +- 模板配额与上限:发放时扣减可用数量,事务内完成;超量拒绝。 +- 用户持有上限与重复发放策略:同模板在时间窗口内单用户限制;引入幂等键防重复请求。 +- 操作审计与限流:关键接口限流、审批或二次确认;记录审计日志。 + +5) 抽奖安全稳健 +- 保持现有 HMAC 与拒绝采样;确保 `commit_master_key` 仅从安全环境加载;周期性轮换与版本化控制。 + +## 交付内容 +- 安全问题清单(带文件与行号)。 +- 修复清单与实施顺序(先密钥与鉴权→后特效参数校验→最后配额/审计/限流)。 +- 修复后的验证方案:单元测试、接口防护测试、管理端提示验证。 + +如确认,我将按上述方案逐项实施修复并提交相应代码与测试。 \ No newline at end of file diff --git a/.trae/documents/排查并初始化“称号管理”数据的执行计划.md b/.trae/documents/排查并初始化“称号管理”数据的执行计划.md new file mode 100644 index 0000000..8199b91 --- /dev/null +++ b/.trae/documents/排查并初始化“称号管理”数据的执行计划.md @@ -0,0 +1,40 @@ +## 问题现象 +- 前端“运营→称号管理”页面列表为空。 +- 可能原因:后端未创建称号数据、未登录导致接口被拦截、数据库未连接、或菜单未展示导致误判。 + +## 状态检查 +- 后端运行:确认服务已启动并连接数据库(`go run ./main.go -env dev` 日志无报错)。 +- 登录状态:确认在管理端已登录并持有有效 Token(接口返回 `token`,在请求头 `Authorization: Bearer `)。 +- 路由与接口: + - 列表接口:`GET /api/admin/system_titles`(需要登录)。 + - 初始化接口:`POST /api/admin/system_titles/seed_default`(非认证组,便于快速初始化)。 + - 菜单补齐:`POST /api/menu/ensure_titles`(非认证组,确保“称号管理”菜单出现)。 + +## 初始化步骤 +1. 管理员登录(你提供的账号密码) + - 账号:`admin` + - 密码:`chat2025`(需 MD5 后传输:`39f841df6f9a4768330b00d3ab9f1b4d`) + - `curl -s -X POST http://localhost:9991/api/admin/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"39f841df6f9a4768330b00d3ab9f1b4d"}'` + - 记录返回 `token` +2. 初始化 6 个称号及效果 + - `curl -s -X POST http://localhost:9991/api/admin/system_titles/seed_default` + - 预期返回:`{ created: N, exists: M, ids: [...] }`(`created+exists=6`) +3. 菜单补齐(后端动态菜单模式) + - `curl -s -X POST http://localhost:9991/api/menu/ensure_titles` + - 预期返回:`{ ensured: true, parent_id: , menu_id: }` +4. 验证列表 + - `curl -s 'http://localhost:9991/api/admin/system_titles?page=1&page_size=20' -H 'Authorization: Bearer '` + - 预期返回:`{ list: [...], total: 6, page: 1, page_size: 20 }` + +## 前端验证 +- 在管理端左侧菜单进入“运营管理 → 称号管理”,页面应显示 6 条数据。 +- 若仍为空: + - 打开浏览器“网络”面板,检查 `admin/system_titles` 响应码与返回体,是否 401(未登录)或 200 但 `list=[]`。 + - 若 `list=[]`,说明初始化未成功或数据库无数据(重复调用步骤2)。 + +## 说明与已修复事项 +- 之前的路由重复注册导致 Gin panic,已修复为仅在“非认证组”保留 `POST /api/admin/system_titles/seed_default`;构建通过。 +- 初始化的 6 个称号及效果已内置参数,后续可按你的运营实际替换 `template_id` 与 `target_prize_ids` 等占位参数。 + +## 后续扩展 +- 如需完善“称号模板与效果”的 CRUD 管理页,我将新增后端 CRUD 与前端编辑表单,支持运营在页面上直接增删改查。 \ No newline at end of file diff --git a/.trae/documents/新增“运营_头衔管理”菜单与页面接入计划.md b/.trae/documents/新增“运营_头衔管理”菜单与页面接入计划.md new file mode 100644 index 0000000..9152545 --- /dev/null +++ b/.trae/documents/新增“运营_头衔管理”菜单与页面接入计划.md @@ -0,0 +1,27 @@ +## 目标 +- 在运营管理中新增“头衔管理”菜单与页面 `/operations/titles`,支持称号列表与搜索、并提供为玩家分配称号的入口。 +- 兼容当前两种菜单控制模式:前端模块路由与后端动态菜单;确保至少前端可见并可用。 + +## 变更项 + +### 前端路由与页面 +- 路由:在 `web/admin/src/router/modules/operations.ts` 新增子路由 `titles`(`name: 'Titles'`,`component: '/operations/titles'`,`meta.title: '称号管理'`)。 +- 页面:新增 `web/admin/src/views/operations/titles/index.vue`: + - 列表:调用 `titlesApi.getList` 展示称号名称、状态、创建时间、描述;支持名称与状态筛选。 + - 操作: + - 行内“分配称号”:弹出 `assign-title-dialog.vue`,为指定玩家分配称号。 + - (占位)创建/编辑/删除:预留按钮,后端 CRUD 未完成则禁用或隐藏。 + +### 前端 API +- 已有 `web/admin/src/api/titles.ts` 支持 `getList`、`assignToUser`;页面直接复用。 + +### 菜单显示(后端动态菜单模式) +- 后端菜单接口:`/api/v3/system/menus/simple` 已存在。 +- 如需在后端模式下展示菜单:可在后台菜单管理中新增一条菜单记录(父节点为运营),`component: '/operations/titles'`,`name: 'Titles'`,`icon: 'ri:medal-line'`,`status: true`;当前计划不强制修改后端菜单数据,仅完成前端路由与页面以确保可见与可用。 + +## 验收 +- 运营管理左侧菜单出现“称号管理”,进入后能搜索与查看称号列表。 +- 行内“分配称号”打开弹窗,提交后接口返回成功并刷新列表。 +- 前端构建通过;后端接口已就绪(列表与分配)。 + +确认后我将立即实施以上前端路由与页面接入,并在必要时协助配置后端菜单以在“后端控制菜单模式”下显示。 \ No newline at end of file diff --git a/.trae/documents/用户头衔体系与权益设计.md b/.trae/documents/用户头衔体系与权益设计.md new file mode 100644 index 0000000..711ea1a --- /dev/null +++ b/.trae/documents/用户头衔体系与权益设计.md @@ -0,0 +1,41 @@ +# 奖品双倍头衔设计补充 + +## 目标 +- 为“头衔”新增一种权益:抽奖命中后有一定概率将奖励倍数提升(默认双倍)。 + +## 模型与配置 +- `system_title_effects.effect_type = DRAW_REWARD_DOUBLE_CHANCE` +- `params_json` 示例: + - `multiplier_x1000`: 2000(默认2x,允许配置3x等) + - `chance_x1000`: 150(表示15%) + - `max_multiplier_cap_x1000`: 3000(总倍数上限,例如≤3x) + - `limit_per_day`: null 或数值(每日最多触发次数) + - `applicable_activities/issues`: 作用范围限定列表 +- 叠加策略: + - 概率合并:`p_total = 1 - Π(1 - p_i)`,并设置上限(如≤50%)。 + - 倍数合并:与其他“奖励倍数”效果(如道具卡)相乘,最终倍数受 `max_multiplier_cap_x1000` 封顶。 + +## 抽奖流程接入 +- 触发点:`DRAW_EXECUTE` 阶段,在完成“中奖项选择”之后执行一次“是否倍增”的伯努利试验。 +- 步骤: + 1. EffectEngine 汇总用户激活头衔的 `DRAW_REWARD_DOUBLE_CHANCE`,计算 `p_total` 与期望倍数(通常为2x)。 + 2. 使用抽奖同一随机源或受控RNG进行试验,命中则将当前中奖奖励按倍数提升。 + 3. 将结果写入 `activity_draw_effects.reward_multiplier_x1000`,并记录 `source_type=TITLE`、`source_id=title_id`、命中与否、概率参数。 + 4. 回执与发奖按倍数后的值结算(库存/道具类型按现有规则处理)。 + +## 审计与风控 +- 落盘:在抽奖效果表记录每次试验的参数与结果,便于复核与分析。 +- 上限:概率与倍数均受可配置总封顶约束,防止过度放大。 +- 并发:同一抽奖回合仅进行一次合并后的试验,避免重复计算。 + +## 管理端与前端 +- 管理端在“头衔效果”配置表单中提供:倍数、概率、封顶、作用范围、每日上限等字段。 +- 用户端抽奖页面展示“我的双倍概率与倍数”提示;在中奖结果处标注“头衔加成(双倍)”。 + +## 测试与验收 +- 用例: + - 单头衔15%双倍 → 约15%回合出现倍增且倍数=2x。 + - 多头衔概率合并(10%与20%)→ 总概率约28%,倍数与其他倍数效果(如1.5x道具卡)相乘并≤总封顶。 + - 封顶与每日上限有效;审计记录完整。 + +确认后,我将把该效果纳入头衔体系的实现与抽奖流程接入。 \ No newline at end of file diff --git a/.trae/documents/称号管理重构对齐后端模型的实施计划.md b/.trae/documents/称号管理重构对齐后端模型的实施计划.md new file mode 100644 index 0000000..ea451fe --- /dev/null +++ b/.trae/documents/称号管理重构对齐后端模型的实施计划.md @@ -0,0 +1,157 @@ +## 目标 + +* 移除无关的“客服组”等错误效果类型与字段,严格对齐后端模型定义(SystemTitles/SystemTitleEffects/UserTitles)。 + +* 统一“效果类型/参数/叠加策略/封顶/作用范围”配置为后端可用的结构,确保抽奖逻辑(effect\_type=5/6)准确生效。 + +* 优化前端管理界面与 API 交互,保证运营可视化配置与后端运行一致。 + +## 范围 + +* 前端文件: + + * `web/admin/src/views/operations/titles/components/EffectEditDialog.vue` + + * `web/admin/src/views/operations/titles/components/EffectManagerDialog.vue` + + * `web/admin/src/views/operations/titles/components/RuleConfigDialog.vue` + + * `web/admin/src/views/operations/titles/index.vue` + + * `web/admin/src/api/titles.ts` + +* 后端仅对齐说明,不改动逻辑(模型/服务已正确): + + * `internal/service/activity/draw_with_effects.go`(抽奖应用) + + * `internal/service/title/effects_resolver.go`(效果范围) + + * `internal/api/admin/titles_admin.go`(创建/修改/分配) + +## 重构设计(按模型对齐) + +### 效果类型与参数(替换现有 UI 与默认值) + +* 1 领券(优惠券使者) + + * `params_json`:`{ "template_id": number, "frequency": { "period": "day|week|month", "times": number } }` + +* 2 抽奖折扣 + + * `params_json`:`{ "discount_type": "percentage|fixed", "value_x1000": number, "max_discount_x1000": number }` + +* 3 签到倍数 + + * `params_json`:`{ "multiplier_x1000": number, "daily_cap_points": number }` + +* 4 领道具卡 + + * `params_json`:`{ "template_id": number, "frequency": { "period": "week|month", "times": number } }` + +* 5 概率加成(抽奖) + + * `params_json`:`{ "target_prize_ids": number[], "boost_x1000": number, "cap_x1000": number? }` + +* 6 奖品双倍概率(抽奖) + + * `params_json`:`{ "target_prize_ids": number[], "chance_x1000": number, "period_cap_times": number? }` + +* 移除:`生日特权/专属客服` 效果类型与全部相关字段(UI/默认值/展示/说明)。 + +### 叠加策略与封顶(严格按模型) + +* `stacking_strategy` 取值与文案: + + * 0:最大值(max\_only) + + * 1:累加封顶(sum\_with\_cap) + + * 2:首个匹配(first\_match) + +* `cap_value_x1000`:统一封顶,单位千分比(用于 5/6 合并时的全局封顶)。 + +* 统一前端控件与默认值(默认建议:1 累加封顶;`cap_value_x1000`=0 表示不封顶)。 + +### 效果作用范围(效果级 scopes\_json) + +* 字段:`activity_ids/issue_ids/category_ids` 与 `exclude.*` + +* 在效果编辑对话框中新增“作用范围”折叠面板: + + * 多选输入上述集合;支持排除列表;空集合视为全局。 + +* 展示格式:在效果列表中将 `scopes_json` 可视化为“包含/排除”标签。 + +### 获得规则(标题级 obtain\_rules\_json) + +* 保留 UI(方便运营标注),明确说明其仅存储,不参与当前自动授予;后续如接入事件评估器再使用。 + +* JSON 结构:`{ methods: string[], conditions: object }`;维持现有 RuleConfigDialog 保存逻辑。 + +## 具体改动点(不执行,供审核) + +* `EffectEditDialog.vue` + + * 替换效果类型下拉项为 1\~6 正确枚举;删除“生日特权/专属客服”。 + + * 重写各类型的表单字段与 `getDefaultParams()` 返回值,改为上述模型参数结构。 + + * 调整 `stacking_strategy` 单选值为 0/1/2;文案与后端一致。 + + * 新增“作用范围”字段并在提交时携带 `scopes_json`。 + +* `EffectManagerDialog.vue` + + * 更新 `effectTypes` 名称与 Tag 映射为正确文案:`领券/抽奖折扣/签到倍数/领道具卡/概率加成/双倍概率`。 + + * 调整 `stackingStrategies` 文案为 0/1/2;去掉无模型项。 + + * 更新参数格式化 `formatParam()`,支持 `template_id/frequency/discount_type/value_x1000/...`。 + + * 增加对 `scopes_json` 的展示:包含/排除集合标签。 + +* `RuleConfigDialog.vue` + + * 保留现有 UI;在文档中明确“标题级范围不参与运行时判定”,避免误解。 + +* `titles.ts` + + * 校验请求参数与后端一致:`effect_type/params_json/stacking_strategy(0|1|2)/cap_value_x1000/scopes_json/sort/status`。 + +## 数据迁移与兼容 + +* 已存在的效果记录: + + * 运营检查并手动改为新结构(通过管理端编辑),或提供一次性迁移脚本:按 `effect_type` 将旧 `params_json` 映射为新字段。 + +* 前端: + + * 旧页面中“生日特权/客服组”等效果无法再新建;编辑时提示需切换为模型定义效果。 + +## 测试与验收 + +* 配置与展示:创建每一种效果,检查列表显示与 JSON 持久化。 + +* 抽奖逻辑: + + * `effect_type=5`:目标奖品权重按 `boost_x1000` 调整,叠加与封顶正确;参见 `internal/service/activity/draw_with_effects.go`。 + + * `effect_type=6`:双倍概率按 `chance_x1000` 合并封顶;命中后倍数 x2;名称后缀标识;参见同文件。 + +* 作用范围:在 issueID 不同期下验证包含/排除命中行为;参见 `effects_resolver.go` 与抽奖文件。 + +* 运营页面每个功能或者配置字段需要详细描述 + +## 交付物 + +* 代码层面的重构(上述文件修改)。 + +* 文档更新:已在 `docs/称号系统/说明文档.md` 完成字段级详解与代码绑定点,重构后同步补充“字段校验与默认值”章节与新 UI 截图位点。 + +## 风险与注意 + +* 现有错误效果类型的数据需运营确认是否迁移或删除。 + +* 前端表单字段与后端参数必须完全对齐,否则抽奖效果不会生效。 + +确认后我将开始实施重构并提供测试用例与验证记录。 diff --git a/.trae/documents/称号系统字段级说明与代码绑定文档增强计划.md b/.trae/documents/称号系统字段级说明与代码绑定文档增强计划.md new file mode 100644 index 0000000..e6af04b --- /dev/null +++ b/.trae/documents/称号系统字段级说明与代码绑定文档增强计划.md @@ -0,0 +1,67 @@ +## 目标 +- 交付一份字段级的《说明文档》增强版,逐一解释: + 1) 获得规则(ObtainRulesJSON)字段字典、用途、代码绑定点; + 2) 使用范围(ScopesJSON)字段字典、用途、代码绑定点; + 3) 效果数量与叠加策略(多效果、排序、封顶、组合规则); + 4) 规则助手(前端可视化配置)字段映射与校验; +- 所有字段给出示例与“在代码块哪里起作用”的精确文件/行号。 + +## 文档结构 +- 《docs/称号系统/说明文档.md》新增章节: + - 获得规则字段详解(ObtainRulesJSON) + - 使用范围字段详解(ScopesJSON:标题&效果) + - 效果数量、叠加策略与封顶(StackingStrategy/CapValueX1000) + - 规则助手详解(UI字段 → JSON 映射 → 后端字段) + - 代码绑定点总览(带 file_path:line_number 引用) + +## 字段级内容(概要) +- 获得规则(ObtainRulesJSON) + - 字段:`methods: string[]`、`conditions: object`(如 `lottery_type`, `min_amount`, `time_range` 等) + - 用途:当前仅“存取”,未参与后端自动授予逻辑(需后续服务层接入) + - 代码绑定: + - 写入:`internal/api/admin/titles_admin.go:88/94/125` + - 预置:`internal/api/admin/titles_seed.go:54` + - DAO映射:`internal/repository/mysql/dao/system_titles.gen.go:34/53/77/102` + - 示例与建议:提供多场景 JSON 模板与后续接入建议(事件钩子/规则评估器) +- 使用范围(ScopesJSON) + - 标题层:`SystemTitles.ScopesJSON`(目前仅存取,不参与运行时判定) + - 效果层:`SystemTitleEffects.ScopesJSON`(运行时生效,活动/期/分类的包含/排除) + - 字段字典:`activity_ids, issue_ids, category_ids, exclude.{...}` + - 代码绑定: + - 解析与匹配:`internal/service/title/effects_resolver.go`(scopeMatch) + - 抽奖应用:`internal/service/activity/draw_with_effects.go`(issue 过滤) + - 管理端读写:`internal/api/admin/titles_admin.go`、预置:`titles_seed.go` + - 示例:包含与排除的组合配置(带示意流程) +- 效果数量与叠加策略 + - 每个称号可挂载多个效果;按 `sort` 排序;`status` 控制启用; + - `StackingStrategy`:0最大值/1累加封顶/2首个匹配/默认累加封顶; + - `CapValueX1000`:统一封顶(千分比) + - 代码绑定: + - 抽奖应用:`internal/service/activity/draw_with_effects.go`(effect_type=5/6 合并规则) + - 模型定义:`internal/repository/mysql/model/system_title_effects.gen.go` + - 示例:多效果组合的实际影响(权重调整/双倍合并概率) +- 规则助手(前端) + - 位置:`web/admin/src/views/operations/titles/components/RuleConfigDialog.vue` + - 字段映射:UI → `obtain_rules_json`/`scopes_json` + - 校验/预览:JSON 校验、示例模板、保存策略 + - 注意:当前 effect_type=5/6 的前端参数与后端不一致(计划列出对齐方案与字段映射表,不改代码,先文档说明) + +## 代码绑定点(示例引用) +- ObtainRulesJSON 写入:`internal/api/admin/titles_admin.go:88, 94, 125` +- ScopesJSON 应用匹配:`internal/service/title/effects_resolver.go:line`(具体行号在交付时标注) +- 抽奖效果应用:`internal/service/activity/draw_with_effects.go:line`(包含 5/6 的 switch 分支具体行号) +- 种子数据:`internal/api/admin/titles_seed.go:31-37, 54-56, 70-80` + +## 交付步骤 +1. 使用只读工具确认所有字段与代码绑定点的具体行号; +2. 更新《说明文档.md》,按上述结构补充完备的字段级说明与代码引用; +3. 提供前端与后端参数对齐表(仅文档,不改代码); +4. 给出后续建议:自动授予逻辑接入方案(服务层/事件钩子/任务调度)。 + +## 验收标准 +- 文档包含所有字段的用途、类型、取值范围、示例; +- 每个字段“在哪里起作用”都给出精确文件/行号; +- 覆盖四项:获得规则、使用范围、效果数量、规则助手; +- 补充参数对齐提醒与后续接入建议; + +确认后我将开始更新文档并提交增强内容。 \ No newline at end of file diff --git a/.trae/documents/称号系统:完整创建流程与使用说明交付计划.md b/.trae/documents/称号系统:完整创建流程与使用说明交付计划.md new file mode 100644 index 0000000..e3daec4 --- /dev/null +++ b/.trae/documents/称号系统:完整创建流程与使用说明交付计划.md @@ -0,0 +1,116 @@ +## 目标 +- 为“抽奖后获得双倍奖励,并具备时效”的业务提供可执行的创建流程与说明文档。 +- 输出一份可直接用于运营与研发的《说明文档.md》,包含:项目规划、实施方案、进度记录,以及完整的操作与集成指南。 + +## 核心概念 +- SystemTitles:系统称号主数据,含名称、描述、获取规则、适用范围等。参考 internal/repository/mysql/model/system_titles.gen.go +- SystemTitleEffects:称号效果,含效果类型、参数、叠加策略、封顶值等。参考 internal/repository/mysql/model/system_title_effects.gen.go +- UserTitles:用户称号记录,含生效状态与过期时间,用于管控时效。参考 internal/repository/mysql/model/user_titles.gen.go +- 效果类型示例:在 admin/titles_seed.go 已内置6类,含“双倍之王(EffectType=6)”。参考 internal/api/admin/titles_seed.go:18-37 + +## 后端接口一览(供前后端与运维对齐) +- 系统称号(管理员) + - GET/POST/PUT/DELETE `/api/admin/system_titles` + - GET/POST/PUT/DELETE `/api/admin/system_titles/:title_id/effects` +- 用户称号分配(管理员) + - POST `/api/admin/users/:user_id/titles`(分配或更新有效期与激活状态) +- 路由参考:internal/router/router.go +- 管理端控制器参考:internal/api/admin/titles_admin.go(列表/创建/修改/删除/分配) + +## 创建流程(运营页面) +1. 进入管理端:`/operations/titles`(确保菜单已存在,见 EnsureTitlesMenu) +2. 创建称号: + - 名称:例如“抽奖双倍达人” + - 描述:例如“抽奖奖励翻倍,限时24小时” + - 状态:启用 +3. 配置获取规则(ObtainRulesJSON): + - methods:`["lottery"]` + - conditions:可选,如抽奖类型 `lottery_type="normal"` + - 示例: + ```json + { + "methods": ["lottery"], + "conditions": {"lottery_type": "normal"} + } + ``` +4. 配置适用范围(ScopesJSON):可为空或限定品类/场次/用户等级,按需填写 +5. 添加效果(SystemTitleEffects):选择“EffectType=6(双倍概率)”,并设置参数: + - `target_prize_ids`: [](空表示所有奖品) + - `chance_x1000`: 1000(100% 双倍;或按需改为 200=20%) + - `period_cap_times`: 1(周期内最多触发次数,建议与日/活动周期对齐) + - 示例: + ```json + { + "target_prize_ids": [], + "chance_x1000": 1000, + "period_cap_times": 1 + } + ``` +6. 保存称号与效果,确认列表中显示。 + +## 分配与时效(运营或自动) +- 运营手动分配(页面“分配用户”对话框): + - 选择用户,设定有效期类型: + - `permanent` 永久 + - `period` 指定开始/结束时间(填写 `start_time`/`end_time`) + - `days` 按天数有效(例如 1 天) + - 勾选“覆盖现有/发送通知”按需配置,提交后生效 +- 抽奖后自动分配(后端集成点): + - 在抽奖流程成功后,调用分配接口或服务方法为用户写入 UserTitles(设置 `expires_at`) + - 建议在抽奖服务中统一封装:检查用户是否已拥有称号;若没有则分配,有则延长或保持不变(按业务策略) + +## 抽奖流程应用双倍效果(后端逻辑) +- 在发奖时: + 1. 读取用户当前激活且未过期的 UserTitles + 2. 聚合其 SystemTitleEffects(EffectType=6),解析 `chance_x1000` 与 `period_cap_times` + 3. 按概率/次数限制判断是否触发翻倍,将奖品数量或价值翻倍(或对指定 `target_prize_ids` 生效) + 4. 记录一次触发(用于控制当期 `period_cap_times`) +- 注意:叠加策略(StackingStrategy)与封顶(CapValueX1000)需一并考虑,避免超限 + +## 配置示例(可直接复用) +- 称号获取规则(让称号只在抽奖场景下获得) +```json +{ + "methods": ["lottery"], + "conditions": {"lottery_type": "normal"} +} +``` +- 双倍效果参数(对所有奖品 100% 双倍,且周期内最多 1 次) +```json +{ + "target_prize_ids": [], + "chance_x1000": 1000, + "period_cap_times": 1 +} +``` +- 用户分配有效期(按天) +```json +{ + "expire_type": "days", + "days": 1, + "remark": "抽奖获得双倍称号", + "override_existing": true, + "send_notification": true +} +``` + +## 测试与验收 +- 管理端: + - 创建称号与效果 → 列表校验 → 为测试用户分配(1天有效) +- 抽奖流程: + - 模拟用户抽奖 → 校验发奖结果是否翻倍 → 检查 period_cap_times 限制是否生效 + - 到期后再次抽奖:不再翻倍(验证 `expires_at` 生效) +- 数据核查: + - 检查 UserTitles 记录及过期时间 + - 检查效果触发记录(若有)与叠加/封顶逻辑 + +## 说明文档结构(交付内容) +- 文件:`docs/称号系统/说明文档.md` + - 项目规划:业务目标、范围、时间节点 + - 实施方案:模型与接口、创建与分配流程、抽奖集成方案 + - 进度记录:每次变更与验证结果 + - 操作指南:页面操作步骤、配置示例、常见问题 + - 验收标准:用例列表与通过条件 + +## 后续工作 +- 我将依据上述结构生成《说明文档.md》,并补充操作截图位点与接口示例;如需我同时在抽奖服务中添加自动分配与双倍应用的集成代码,请确认后我直接实现并提交测试用例。 \ No newline at end of file diff --git a/.trae/documents/简化双倍概率作用范围并对齐前端配置的实施计划.md b/.trae/documents/简化双倍概率作用范围并对齐前端配置的实施计划.md new file mode 100644 index 0000000..f7318c8 --- /dev/null +++ b/.trae/documents/简化双倍概率作用范围并对齐前端配置的实施计划.md @@ -0,0 +1,12 @@ +## 目标 +- 将效果编辑对话框的作用范围精简为“包含活动(activity_ids)”“包含期(issue_ids)”与“排除期(exclude.issue_ids)”三项,移除分类等无关选项。 +- 在效果列表中新增“作用范围”列,直观展示包含/排除的活动与期。 +- 保持与后端抽奖逻辑一致,确保 effect_type=6 的参数与范围保存后可被正确解析与应用。 + +## 改动点 +- 编辑对话框:只保留 activity_ids、issue_ids、exclude.issue_ids 的输入;提交时构建简化的 scopes_json。 +- 效果列表:新增范围列并格式化展示。 +- API 类型:已支持 scopes_json(无需后端改动)。 + +## 验收 +- 可在管理端创建或编辑双倍效果,查看列表范围列;抽奖时按期过滤生效。 \ No newline at end of file diff --git a/.trae/documents/运营端称号规则与效果配置页面实现计划.md b/.trae/documents/运营端称号规则与效果配置页面实现计划.md new file mode 100644 index 0000000..1f1f8f5 --- /dev/null +++ b/.trae/documents/运营端称号规则与效果配置页面实现计划.md @@ -0,0 +1,66 @@ +## 目标 +- 在运营管理中新增“称号模板与规则”页面,支持创建/编辑/删除称号(含获取规则 obtain_rules_json 与作用域 scopes_json)。 +- 新增“称号效果配置”页面(或同页二级标签),支持为每个称号新增/编辑效果项,覆盖六类功能:领取优惠券、抽奖折扣、签到倍数、领取道具卡、概率加成、双倍奖励。 +- 所有配置直写到既有表:`system_titles`、`system_title_effects`,并保证JSON合法与可视化表单输入。 + +## 后端接口(CRUD) +- 系统称号(Templates) + - `GET /api/admin/system_titles`:分页检索(name/status),返回 list/total。 + - `POST /api/admin/system_titles`:创建称号,入参:`name, description?, status, obtain_rules_json(字符串JSON), scopes_json(字符串JSON)`。 + - `PUT /api/admin/system_titles/:title_id`:修改;字段同上。 + - `DELETE /api/admin/system_titles/:title_id`:删除(可选:级联删除该称号的效果项,需二次确认)。 +- 称号效果(Effects) + - `GET /api/admin/system_titles/:title_id/effects`:列表(status/effect_type筛选)。 + - `POST /api/admin/system_titles/:title_id/effects`:新增效果;入参:`effect_type, params_json(字符串JSON), stacking_strategy, cap_value_x1000?, scopes_json?, sort, status`。 + - `PUT /api/admin/system_titles/:title_id/effects/:effect_id`:修改;字段同上。 + - `DELETE /api/admin/system_titles/:title_id/effects/:effect_id`:删除。 +- 校验与约束 + - `obtain_rules_json`/`scopes_json`/`params_json`必须为合法JSON(默认 `{}`),否则拒绝并提示。 + - `stacking_strategy` 枚举校验:0 最大值、1 累加封顶、2 首个匹配。 + - `cap_value_x1000`、各类 `*_x1000` 必须为非负整数;必要时设上限。 + +## 前端页面 +- 菜单位置:`运营管理 → 称号管理`(保持现有入口),页面内使用二级标签: + - Tab1:称号模板(列表 + 新建/编辑弹窗) + - Tab2:效果配置(左侧选择称号或从行操作进入,右侧效果列表 + 新建/编辑弹窗) +- 表单设计 + - 称号模板弹窗: + - 基本:名称、描述、状态(启用/停用) + - 获取规则 obtain_rules_json:可视化构建器(单选/多选项生成JSON): + - 任务达成:`{"type":"mission","mission_id":...,"times":...}` + - 等级达成:`{"type":"level","level":...}` + - 支付购买:`{"type":"purchase","product_id":...,"price":...}` + - 手动发放:`{"type":"manual"}`(缺省) + - 作用域 scopes_json:选择活动分类/活动/期次,生成:`{"activity_ids":[],"issue_ids":[],"category_ids":[],"exclude":{...}}` + - 效果配置弹窗(按效果类型动态渲染表单): + - 领取优惠券:`template_id`、`period(day|week|month)`、`times`(生成 `{template_id,frequency:{period,times}}`) + - 抽奖折扣:`discount_type(percentage|fixed)`、`value_x1000`、`max_discount_x1000?`、`min_price?` + - 签到倍数:`multiplier_x1000`、`daily_cap_points?` + - 领取道具卡:`template_id`、`period`、`times` + - 概率加成:`target_prize_ids[]`(从奖励列表选择)、`boost_x1000`、`cap_x1000?`、`combine(sum|max)` + - 双倍奖励:`target_prize_ids[]`、`chance_x1000`、`period_cap_times?` + - 通用:`stacking_strategy`、`cap_value_x1000?`、`scopes_json`(可与模板一致或另设) +- 预览与校验 + - JSON编辑器只读预览框,展示最终写库JSON;前置校验拒绝非法JSON。 + - “合并效果预览”按钮:传 `user_id+context` 调用预览接口(可后续实现),便于运营核对。 + +## 前端技术点 +- 组件复用:沿用 `ArtTable/ArtSearchBar/ArtButtonTable` 模式,仿照“道具卡/优惠券”页面实现。 +- 表单动态:根据 `effect_type` 切换字段与校验;内置枚举与字典展示中文标签。 +- 交互:创建成功后刷新列表;编辑表单带现有JSON -> 反序列化填入表单。 + +## 数据与合法性 +- 所有 JSON 列默认值使用 `{}`,避免“空文档”错误。 +- 金额/比例/倍数统一使用 `x1000` 固定小数避免精度误差。 +- 删除称号时,如启用“级联删除效果”需弹窗确认;否则保留效果项以供审计。 + +## 验收 +- 运营可在页面新建/编辑称号模板(含获取规则与作用域),保存成功并后端入库。 +- 运营可在页面为指定称号新增/编辑各类效果,保存后生效(后续事件引擎读取)。 +- 列表检索与状态切换可用;所有JSON合法校验通过;非法输入有明确错误提示。 + +## 后续可选 +- 效果预览接口:`GET /api/admin/effects/preview?user_id&context`,返回按叠加策略后的生效明细。 +- 审计日志:记录模板与效果的变更历史、操作者与时间。 + +确认后我将按上述计划补充后端CRUD接口与前端页面(两个弹窗/二级标签),并完成联调与校验。 \ No newline at end of file diff --git a/.trae/documents/运营管理前端接入与玩家称号分配实现计划.md b/.trae/documents/运营管理前端接入与玩家称号分配实现计划.md new file mode 100644 index 0000000..ae0f932 --- /dev/null +++ b/.trae/documents/运营管理前端接入与玩家称号分配实现计划.md @@ -0,0 +1,62 @@ +## 目标 +- 在运营管理中接入“批量抽奖”并确保对话框正确对接后端接口(含用户搜索、期数选择、执行与验证)。 +- 在玩家管理页新增“分配称号”功能:支持搜索玩家、选择系统称号并分配(含有效期与备注)。 + +## 改造与新增点 + +### 1. 批量抽奖对话框对接 +- 位置:`web/admin/src/views/activity/rewards/modules/batch-draw-dialog.vue` +- 接口统一由 `@/api/adminActivities` 提供;若需要使用“带效果抽奖”后端路径,保持 URL 不变(后端已在同一路由替换为带效果执行),前端无需改参。 +- 校验与交互: + - 期数下拉:数据源 `listActivityIssues(activityId)`(已存在)。 + - 玩家搜索与选择:数据源 `listAdminUsers(params)`(已存在),保留分页与勾选逻辑。 + - 批量执行:`batchDrawForUsers(activityId, issueId, { user_ids })`,展示收据与中奖汇总。 + - 结果验证:`verifyDrawReceipt(activityId, issueId, body)` 保持结构一致。 +- UI 微调:在结果区增加“效果标识”展示(若收据选中项名称带 `(x2)` 则以标签提示“奖励翻倍”)。 + +### 2. 玩家管理页新增“分配称号” +- 入口:`web/admin/src/views/player-manage/index.vue` 操作列新增按钮。 +- 枚举:`web/admin/src/enums/tableActionEnum.ts` 增加 `ASSIGN_TITLE`,映射中文标签与图标。 +- 弹窗组件(新增):`web/admin/src/views/player-manage/modules/assign-title-dialog.vue` + - 表单: + - 选择系统称号(下拉,数据源:`titlesApi.getList({page,page_size,name,status})`)。 + - 可选:有效期(日期时间)、备注(文本)。 + - 提交:`titlesApi.assignToUser(userId, { title_id, expires_at?, remark? })`,成功后 toast 并关闭。 +- 前端 API(新增):`web/admin/src/api/titles.ts` + - `getList(params)` → `GET /api/admin/system_titles`(分页检索)。 + - `assignToUser(userId, payload)` → `POST /api/admin/users/:user_id/titles`。 +- 玩家搜索集成:保留现有搜索栏 `player-search.vue`;在结果表行的操作列加入“分配称号”按钮,点击打开弹窗并携带当前 `user_id`。 + +### 3. 后端路由与处理(需一并落地) +- 路由:`internal/router/router.go` + - 系统称号:`GET/POST/PUT/DELETE /api/admin/system_titles` + - 用户分配称号:`POST /api/admin/users/:user_id/titles` +- Handler:`internal/api/admin/titles_admin.go` + - 列表、创建、修改、删除系统称号。 + - 用户分配:校验头衔存在与启用状态,写入或更新 `user_titles`(保持 `user_id + title_id` 唯一,激活状态与有效期)。 +- 复用服务/DAO:`internal/repository/mysql/dao/*` 与 `internal/service/title/effects_resolver.go`(后续用于预览用户生效效果)。 + +## 交互与校验 +- 分配称号: + - 重复分配处理:若用户已持有该称号,则更新 `expires_at`/`remark`/`active`。 + - 时间校验:`expires_at` 可空;若传入需晚于当前时间。 +- 批量抽奖: + - 期数必选、用户列表非空;执行后展示收据与命中明细。 + - 收据中的 `(x2)` 标签用于前端提示“奖励翻倍”。 + +## 验收与测试 +- 前端: + - 玩家搜索能正常检索与分页;操作列新增“分配称号”按钮弹窗正常。 + - 批量抽奖弹窗从运营管理入口可用,执行与验证接口成功,结果展示清晰。 +- 后端: + - 系统称号列表与 CRUD 可用;用户分配称号接口成功写 `user_titles`。 +- 集成: + - 用户分配称号后,管理端批量抽奖能体现概率/双倍效果(已有后端支持)。 + +## 交付物 +- 新增前端 API `titles.ts` 与 `assign-title-dialog.vue`。 +- 更新 `player-manage/index.vue` 与 `tableActionEnum.ts` 增加入口。 +- 如需对批量抽奖对话框做 `(x2)` 标签显示,补充小型 UI 更新。 +- 后端路由与 Handler 实现(称号 CRUD 与分配)。 + +确认后我将立即实施上述前后端改造,并进行编译与交互联调。 \ No newline at end of file diff --git a/README.md b/README.md index c3295df..ac78725 100644 --- a/README.md +++ b/README.md @@ -14,5 +14,5 @@ ### MAC ``` CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags '-w -s' -trimpath -o MINI -CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -trimpath -o build/bindbox.exe . +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -tags timetzdata -trimpath -o build/bindbox.exe . ``` diff --git a/cmd/gormgen/README.md b/cmd/gormgen/README.md index 028a4e2..d11029a 100644 --- a/cmd/gormgen/README.md +++ b/cmd/gormgen/README.md @@ -36,6 +36,6 @@ eg : # 根目录下执行 go run cmd/gormgen/main.go -dsn "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" -tables "admin,log_operation,log_request,activities,activity_categories,activity_draw_logs,activity_issues,activity_reward_settings,guild,guild_boxes,guild_contribute_logs,guild_members,system_coupons,user_coupons,user_inventory,user_inventory_transfers,user_points,user_points_ledger" -go run cmd/gormgen/main.go -dsn "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" -tables "admin,log_operation,log_request,activities,activity_categories,activity_draw_logs,activity_issues,activity_reward_settings,guild,guild_boxes,guild_contribute_logs,guild_members,system_coupons,user_coupons,user_inventory,user_inventory_transfers,user_points,user_points_ledger,users,user_addresses,menu_actions,menus,role_actions,role_menus,role_users,roles,order_items,orders,products,shipping_records,product_categories,user_invites,system_item_cards,user_item_cards,activity_draw_effects,banner,issue_random_commitments,activity_draw_receipts" +go run cmd/gormgen/main.go -dsn "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" -tables "admin,log_operation,log_request,activities,activity_categories,activity_draw_logs,activity_issues,activity_reward_settings,guild,guild_boxes,guild_contribute_logs,guild_members,system_coupons,user_coupons,user_inventory,user_inventory_transfers,user_points,user_points_ledger,users,user_addresses,menu_actions,menus,role_actions,role_menus,role_users,roles,order_items,orders,products,shipping_records,product_categories,user_invites,system_item_cards,user_item_cards,activity_draw_effects,banner,issue_random_commitments,activity_draw_receipts,system_titles,system_title_effects,user_titles,user_title_effect_claims" ``` \ No newline at end of file diff --git a/docs/称号系统/说明文档.md b/docs/称号系统/说明文档.md new file mode 100644 index 0000000..1b18bd2 --- /dev/null +++ b/docs/称号系统/说明文档.md @@ -0,0 +1,281 @@ +# 称号系统使用说明文档 + +> 版本:v1.0 +> 日期:2025-11-16 +> 适用范围:运营、后端、前端 + +--- + +## 一、项目规划 +- 业务目标:实现“用户抽奖后获得双倍奖励(可限时)”的称号能力,运营可视化配置,后端安全可控,前端可观测。 +- 范围:系统称号主数据、称号效果配置(双倍概率)、用户称号分配与时效管理、抽奖流程双倍应用。 +- 时间节点: + - 2025-11-16:建立说明文档,完成创建流程与使用指南。 + - 2025-11-17:完成抽奖流程集成验证与用例测试(预期)。 + +--- + +## 二、实施方案 + +### 2.1 核心模型 +- SystemTitles:称号主数据(名称、描述、获取规则、适用范围)。 +- SystemTitleEffects:称号效果(类型、参数、叠加策略、封顶值、排序、状态)。 +- UserTitles:用户称号记录(生效状态、获得时间、过期时间、来源、备注)。 +- 参考文件: + - internal/repository/mysql/model/system_titles.gen.go + - internal/repository/mysql/model/system_title_effects.gen.go + - internal/repository/mysql/model/user_titles.gen.go + +### 2.2 现有预置效果 +- 已内置6类效果,含“双倍之王(EffectType=6)”。 +- 参考:internal/api/admin/titles_seed.go:18-37 + +### 2.3 后端接口 +- 系统称号(管理员): + - GET/POST/PUT/DELETE `/api/admin/system_titles` + - GET/POST/PUT/DELETE `/api/admin/system_titles/:title_id/effects` +- 用户称号分配(管理员): + - POST `/api/admin/users/:user_id/titles` +- 路由参考:internal/router/router.go +- 控制器参考:internal/api/admin/titles_admin.go + +--- + +## 三、创建流程(运营页面) + +### 3.1 进入页面 +- 管理端路径:`/operations/titles` +- 若菜单缺失,可调用 EnsureTitlesMenu(参考 internal/api/admin/titles_seed.go) + +### 3.2 创建称号 +- 基本信息: + - 名称:例如“抽奖双倍达人” + - 描述:例如“抽奖奖励翻倍,限时24小时” + - 状态:启用 +- 获取规则(ObtainRulesJSON)建议: +```json +{ + "methods": ["lottery"], + "conditions": {"lottery_type": "normal"} +} +``` +- 适用范围(ScopesJSON):可为空或限定具体用户等级/品类/区域/时段。 + +### 3.3 添加效果(双倍概率) +- 选择效果类型:EffectType=6(双倍概率) +- 参数示例(对所有奖品100%双倍,周期最多1次): +```json +{ + "target_prize_ids": [], + "chance_x1000": 1000, + "period_cap_times": 1 +} +``` +- 叠加与封顶:按需设置 `StackingStrategy` 与 `CapValueX1000`,用于限制叠加与封顶行为。 + +### 3.4 保存与检查 +- 保存称号与效果后,在列表中确认显示;可进入“效果管理”查看参数是否正确。 + +--- + +## 四、分配与时效 + +### 4.1 运营手动分配 +- 在称号列表中点击“分配用户”: + - 有效期类型:`permanent`(永久)、`period`(开始/结束时间)、`days`(按天数) + - 建议设置:`days=1`(24小时有效) + - 备注:例如“抽奖获得双倍称号” + - 可选:覆盖现有相同称号、发送通知 + +### 4.2 抽奖后自动分配(后端集成) +- 在抽奖成功后调用用户称号分配接口或服务方法: + - 若用户未持有称号 → 写入 UserTitles,设定 `expires_at`(例如当前时间+24小时) + - 若已持有且未过期 → 按策略决定是否延长或保持(可由业务规则决定) + +--- + +## 五、抽奖流程应用双倍效果 + +### 5.1 判定逻辑 +1. 读取用户激活且未过期的 UserTitles。 +2. 聚合其中类型为 EffectType=6 的 SystemTitleEffects。 +3. 按 `chance_x1000`(概率)与 `period_cap_times`(周期次数)判定是否触发。 +4. 若触发:将奖品数量或价值翻倍(对指定 `target_prize_ids` 生效或全部奖品)。 +5. 记录当期触发次数,避免超过 `period_cap_times`。 + +### 5.2 参数含义 +- `target_prize_ids`:限定生效的奖品ID集合,空数组表示对所有奖品生效。 +- `chance_x1000`:千分制概率;1000=100%、200=20%。 +- `period_cap_times`:周期内最多触发次数(建议结合自然日或活动期定义周期)。 + +--- + +## 六、配置示例集 + +### 6.1 获取规则(抽奖获得) +```json +{ + "methods": ["lottery"], + "conditions": {"lottery_type": "normal"} +} +``` + +### 6.2 双倍效果(100%/限1次) +```json +{ + "target_prize_ids": [], + "chance_x1000": 1000, + "period_cap_times": 1 +} +``` + +### 6.3 用户分配有效期(按天) +```json +{ + "expire_type": "days", + "days": 1, + "remark": "抽奖获得双倍称号", + "override_existing": true, + "send_notification": true +} +``` + +--- + +## 七、测试与验收 + +### 7.1 管理端用例 +- 创建称号与效果 → 列表校验 +- 为测试用户分配(days=1)→ 显示生效与过期时间 + +### 7.2 抽奖用例 +- 用户持有称号时抽奖 → 奖励翻倍,次数受 `period_cap_times` 控制 +- 称号过期后抽奖 → 不再翻倍 + +### 7.3 数据核查 +- 检查 UserTitles:`active/expires_at/remark` +- 检查效果:`SystemTitleEffects.params_json` 是否符合配置 + +### 7.4 验收标准 +- 正常流程:在有效期内至少一次成功翻倍 +- 边界:过期后不生效;超过 `period_cap_times` 不再触发 +- 失败路径:接口异常可回滚或重试,日志可追踪 + +--- + +## 八、进度记录 +- 2025-11-16:建立说明文档;完成创建流程、分配与时效、抽奖双倍应用、配置示例与验收标准。 +- 2025-11-17(计划):完成抽奖流程集成代码的验证与单元测试。 + +--- + +## 九、常见问题 +- Q:为什么双倍没有触发? + - A:检查有效期是否过期;确认 `chance_x1000` 是否为期望值;校验 `period_cap_times` 是否已用尽;确认奖品是否在 `target_prize_ids` 范围内。 +- Q:如何限制只对特定奖品翻倍? + - A:在效果参数中填入 `target_prize_ids` 指定的奖品ID数组。 +- Q:如何延长用户称号有效期? + - A:重新分配该称号并设置新的有效期(覆盖现有)。 + +--- + +## 十、后续工作 +- 如需我在抽奖服务中添加自动分配与双倍应用的具体代码,请告知周期定义(按自然日/活动期)与触发记录存储位置,我将补充实现与测试用例并更新本说明文档进度。 + +--- + +## 十一、字段级详解与代码绑定点 + +### 11.1 获得规则(ObtainRulesJSON) +- 字段结构: + - `methods: string[]` 获得方式集合(如 `register/consume/invite/activity/manual/lottery`) + - `conditions: object` 按不同方式的约束条件(示例键:`lottery_type`, `min_amount`, `time_range`, `participate_times` 等) +- 用途说明:当前后端仅进行“存取”,尚未在服务层解析并自动授予头衔;需后续集成到具体事件(抽奖、消费、邀请等)的规则评估器。 +- 代码绑定: + - 创建时写入默认空对象:`internal/api/admin/titles_admin.go:88` + - 创建落库:`internal/api/admin/titles_admin.go:94-96` + - 修改时更新:`internal/api/admin/titles_admin.go:125` + - 预置种子:`internal/api/admin/titles_seed.go:54` + - DAO 字段映射:`internal/repository/mysql/dao/system_titles.gen.go:34, 53, 77, 102` +- 前端规则助手来源: + - 载入与保存:`web/admin/src/views/operations/titles/components/RuleConfigDialog.vue:331-393, 395-450` + - JSON 预览构造:`web/admin/src/views/operations/titles/components/RuleConfigDialog.vue:283-322` +- 示例配置: +```json +{ + "methods": ["lottery"], + "conditions": {"lottery_type": "normal", "participate_times": 1} +} +``` +(说明:目前该 JSON 仅随标题存储,不会自动触发授予;若需要自动授予,需新增服务层解析器与事件钩子。) + +### 11.2 使用范围(ScopesJSON) +- 标题层(SystemTitles.ScopesJSON): + - 用途:目前仅存取,并未在运行时判定中直接使用。 + - 创建写入:`internal/api/admin/titles_admin.go:89, 95-96` + - 修改更新:`internal/api/admin/titles_admin.go:126` +- 效果层(SystemTitleEffects.ScopesJSON): + - 用途:运行时用于过滤效果的生效范围(活动/期/分类等),排除优先,包含其次,未配置则视为全局。 + - 解析结构:`internal/service/title/effects_resolver.go:60-69` + - `activity_ids`, `issue_ids`, `category_ids` + - `exclude.activity_ids`, `exclude.issue_ids`, `exclude.category_ids` + - 匹配逻辑:`internal/service/title/effects_resolver.go:133-155` + - 抽奖期过滤(简化版,仅按 issue):`internal/service/activity/draw_with_effects.go:85-103` + - 管理端写入:`internal/api/admin/titles_admin.go:196-206, 251` +- 示例配置: +```json +{ + "activity_ids": [1001, 1002], + "issue_ids": [2001], + "category_ids": [], + "exclude": { "issue_ids": [2003] } +} +``` +(说明:当事件上下文命中 `exclude` 列表则不生效;若配置了包含列表且不命中则不生效;都未配置视为全局。) + +### 11.3 效果数量与叠加策略(StackingStrategy/CapValueX1000) +- 多效果:一个称号可挂载多个效果,列表接口返回总数(`internal/api/admin/titles_admin.go:156-170`),`sort` 控制同内顺序,`status` 控制启用。 +- 叠加策略(`SystemTitleEffects.StackingStrategy`):`internal/repository/mysql/model/system_title_effects.gen.go:19` + - `0` 最大值(max_only):取每个目标的最大增益 + - `1` 累加封顶(sum_with_cap):叠加并用 cap 限制 + - `2` 首个匹配(first_match):只在当前为 0 时赋值 + - 默认:累加并使用 `CapValueX1000` 统一封顶 +- 统一封顶(`SystemTitleEffects.CapValueX1000`):千分比封顶值,用于限制概率/倍数累积的上限。 +- 抽奖应用点:`internal/service/activity/draw_with_effects.go:104-149, 152-166, 197-233` + - `effect_type=5` 概率加成:解析 `target_prize_ids/boost_x1000/cap_x1000` 后调整权重 + - `effect_type=6` 双倍奖励:合并 `target_prize_ids` 与 `chance_x1000` 后概率判定,命中则倍数 `x2` + +### 11.4 规则助手(前端可视化配置) +- 入口位置:`web/admin/src/views/operations/titles/components/RuleConfigDialog.vue` +- 获得规则 UI → JSON 映射:`methods/consume/invite/activity`(`RuleConfigDialog.vue:225-242, 283-297`) +- 使用范围 UI → JSON 映射:`user_level/category_ids/region/time`(`RuleConfigDialog.vue:245-257, 298-318`) +- 加载现有规则:`RuleConfigDialog.vue:331-393` +- 保存规则:`RuleConfigDialog.vue:395-450` +- 注意事项: + - 效果编辑表单与后端字段对齐(effect_type=5/6): + - 5(概率加成)后端期望:`target_prize_ids, boost_x1000, cap_x1000`(`internal/service/activity/draw_with_effects.go:104-133`) + - 6(双倍奖励)后端期望:`target_prize_ids, chance_x1000, period_cap_times`(`internal/service/activity/draw_with_effects.go:133-149`) + - 当前前端默认参数返回与后端不一致:`web/admin/src/views/operations/titles/components/EffectEditDialog.vue:244-261` + - 建议在运营使用时,按后端字段结构填写 `params_json`,或由后续改造对齐表单字段;本文档已标注对齐需求,待前端改造确认后更新。 + +--- + +## 十二、字段参考速查表 + +- ObtainRulesJSON + - `methods`: string[](注册/消费/邀请/活动/抽奖/手动) + - `conditions`: object(按场景扩展键,如 `lottery_type/min_amount/time_range/participate_times` 等) + - 绑定点:创建/修改/预置/DAO 映射(见 11.1) + +- ScopesJSON(效果层) + - `activity_ids/issue_ids/category_ids`: number[] + - `exclude.activity_ids/issue_ids/category_ids`: number[] + - 绑定点:解析/匹配/抽奖过滤/写入(见 11.2) + +- SystemTitleEffects(效果通用) + - `effect_type`: 1~6(见模型注释) + - `params_json`: 按效果类型字段定义 + - `stacking_strategy`: 0/1/2/默认 + - `cap_value_x1000`: 千分封顶 + - `sort/status`: 顺序与启用 + - 绑定点:创建/修改/抽奖应用(见 11.3) \ No newline at end of file diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index e72c3c9..8e82d64 100644 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -10,6 +10,7 @@ import ( productsvc "bindbox-game/internal/service/product" bannersvc "bindbox-game/internal/service/banner" usersvc "bindbox-game/internal/service/user" + titlesvc "bindbox-game/internal/service/title" ) type handler struct { @@ -22,6 +23,7 @@ type handler struct { product productsvc.Service user usersvc.Service banner bannersvc.Service + title titlesvc.Service } func New(logger logger.CustomLogger, db mysql.Repo) *handler { @@ -35,5 +37,6 @@ func New(logger logger.CustomLogger, db mysql.Repo) *handler { 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/admin_create.go b/internal/api/admin/admin_create.go deleted file mode 100755 index 550e904..0000000 --- a/internal/api/admin/admin_create.go +++ /dev/null @@ -1,77 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" - adminsvc "bindbox-game/internal/service/admin" -) - -type createAdminRequest struct { - UserName string `json:"username" binding:"required"` // 用户名 - NickName string `json:"nickname" binding:"required"` // 昵称 - Mobile string `json:"mobile"` // 手机号 - Password string `json:"password" binding:"required"` // 密码 - Avatar string `json:"avatar"` // 头像 -} - -type createAdminResponse struct { - Message string `json:"message"` // 提示信息 -} - -// CreateAdmin 新增客服 -// @Summary 新增客服 -// @Description 新增客服 -// @Tags 管理端.客服管理 -// @Accept json -// @Produce json -// @Param RequestBody body createAdminRequest true "请求参数" -// @Success 200 {object} createAdminResponse -// @Failure 400 {object} code.Failure -// @Router /api/admin/create [post] -// @Security LoginVerifyToken -func (h *handler) CreateAdmin() core.HandlerFunc { - return func(ctx core.Context) { - req := new(createAdminRequest) - res := new(createAdminResponse) - 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, - fmt.Sprintf("%s: %s", code.Text(code.CreateAdminError), "禁止操作")), - ) - return - } - - if err := h.svc.Create(ctx.RequestContext(), adminsvc.CreateInput{ - Username: req.UserName, - Nickname: req.NickName, - Mobile: req.Mobile, - Password: req.Password, - Avatar: req.Avatar, - CreatedBy: ctx.SessionUserInfo().UserName, - }); err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.CreateAdminError, - fmt.Sprintf("%s: %s", code.Text(code.CreateAdminError), err.Error())), - ) - return - } - - res.Message = "操作成功" - ctx.Payload(res) - } -} diff --git a/internal/api/admin/admin_delete.go b/internal/api/admin/admin_delete.go deleted file mode 100755 index 93d4f2d..0000000 --- a/internal/api/admin/admin_delete.go +++ /dev/null @@ -1,105 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "strconv" - "strings" - - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" -) - -type deleteAdminRequest struct { - Ids string `json:"ids" binding:"required"` // 编号(多个用,分割) -} - -type deleteAdminResponse struct { - Message string `json:"message"` // 提示信息 -} - -// DeleteAdmin 删除客服 -// @Summary 删除客服 -// @Description 删除客服 -// @Tags 管理端.客服管理 -// @Accept json -// @Produce json -// @Param RequestBody body deleteAdminRequest true "请求参数" -// @Success 200 {object} deleteAdminResponse -// @Failure 400 {object} code.Failure -// @Router /api/admin/delete [post] -// @Security LoginVerifyToken -func (h *handler) DeleteAdmin() core.HandlerFunc { - return func(ctx core.Context) { - req := new(deleteAdminRequest) - res := new(deleteAdminResponse) - 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.DeleteAdminError, - fmt.Sprintf("%s: %s", code.Text(code.DeleteAdminError), "禁止操作")), - ) - return - } - - idList := strings.Split(req.Ids, ",") - if len(idList) == 0 || (len(idList) == 1 && idList[0] == "") { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - "编号不能为空"), - ) - return - } - - var ids []int32 - - for _, strID := range idList { - if strID == "" { - continue - } - - id, err := strconv.Atoi(strID) - if err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - fmt.Sprintf("无效的编号: %s", strID)), - ) - return - } - - ids = append(ids, int32(id)) - } - - if len(ids) == 0 { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - "编号不能为空"), - ) - return - } - - if err := h.svc.Delete(ctx.RequestContext(), ids); err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.DeleteAdminError, - fmt.Sprintf("%s: %s", code.Text(code.DeleteAdminError), err.Error())), - ) - return - } - res.Message = "操作成功" - ctx.Payload(res) - } -} diff --git a/internal/api/admin/admin_list.go b/internal/api/admin/admin_list.go deleted file mode 100755 index efc2539..0000000 --- a/internal/api/admin/admin_list.go +++ /dev/null @@ -1,126 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/timeutil" - "bindbox-game/internal/pkg/validation" - adminsvc "bindbox-game/internal/service/admin" -) - -type listRequest struct { - Username string `form:"username"` // 用户名 - Nickname string `form:"nickname"` // 昵称 - Page int `form:"page"` // 当前页码,默认为第一页 - PageSize int `form:"page_size"` // 每页返回的数据量 -} - -type listData struct { - ID int32 `json:"id"` // 编号 - UserName string `json:"username"` // 用户名 - NickName string `json:"nickname"` // 昵称 - Mobile string `json:"mobile"` // 手机号 - Avatar string `json:"avatar"` // 头像 - CreatedAt string `json:"created_at"` // 创建时间 - UpdatedAt string `json:"updated_at"` // 更新时间 -} - -type listResponse struct { - Page int `json:"page"` // 当前页码 - PageSize int `json:"page_size"` // 每页返回的数据量 - Total int64 `json:"total"` // 符合查询条件的总记录数 - List []listData `json:"list"` -} - -// PageList 客服列表 -// @Summary 客服列表 -// @Description 客服列表 -// @Tags 管理端.客服管理 -// @Accept json -// @Produce json -// @Param username query string false "用户名" -// @Param nickname query string false "昵称" -// @Param page query int true "当前页码" default(1) -// @Param page_size query int true "每页返回的数据量,最多 100 条" default(20) -// @Success 200 {object} listResponse -// @Failure 400 {object} code.Failure -// @Router /api/admin/list [get] -// @Security LoginVerifyToken -func (h *handler) PageList() core.HandlerFunc { - return func(ctx core.Context) { - req := new(listRequest) - res := new(listResponse) - - if err := ctx.ShouldBindForm(req); err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - validation.Error(err)), - ) - return - } - - if req.Page == 0 { - req.Page = 1 - } - - if req.PageSize == 0 { - req.PageSize = 20 - } - - if req.PageSize > 100 { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ListAdminError, - fmt.Sprintf("%s: 一次最多只能查询 100 条", code.Text(code.ListAdminError)), - )) - return - } - - if ctx.SessionUserInfo().IsSuper != 1 { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ListAdminError, - fmt.Sprintf("%s: %s", code.Text(code.ListAdminError), "禁止操作")), - ) - return - } - - items, total, err := h.svc.List(ctx.RequestContext(), adminsvc.ListInput{ - Username: req.Username, - Nickname: req.Nickname, - Page: req.Page, - PageSize: req.PageSize, - }) - if err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ListAdminError, - fmt.Sprintf("%s:%s", code.Text(code.ListAdminError), err.Error())), - ) - return - } - - res.Page = req.Page - res.PageSize = req.PageSize - res.Total = total - res.List = make([]listData, len(items)) - - for k, v := range items { - res.List[k] = listData{ - ID: v.ID, - UserName: v.Username, - NickName: v.Nickname, - Mobile: v.Mobile, - Avatar: v.Avatar, - CreatedAt: timeutil.FriendlyTime(v.CreatedAt), - UpdatedAt: timeutil.FriendlyTime(v.CreatedAt), - } - } - - ctx.Payload(res) - } -} diff --git a/internal/api/admin/admin_modify.go b/internal/api/admin/admin_modify.go deleted file mode 100644 index 79cf925..0000000 --- a/internal/api/admin/admin_modify.go +++ /dev/null @@ -1,98 +0,0 @@ -package admin - -import ( - "fmt" - "net/http" - "strconv" - - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/validation" - adminsvc "bindbox-game/internal/service/admin" -) - -type modifyAdminRequest struct { - UserName string `json:"username" binding:"required"` // 用户名 - NickName string `json:"nickname" binding:"required"` // 昵称 - Mobile string `json:"mobile"` // 手机号 - Password string `json:"password"` // 密码 - Avatar string `json:"avatar"` // 头像 -} - -type modifyAdminResponse struct { - Message string `json:"message"` // 提示信息 -} - -// ModifyAdmin 编辑客服 -// @Summary 编辑客服 -// @Description 编辑客服 -// @Tags 管理端.客服管理 -// @Accept json -// @Produce json -// @Param id path string true "编号ID" -// @Param RequestBody body modifyAdminRequest true "请求参数" -// @Success 200 {object} modifyAdminResponse -// @Failure 400 {object} code.Failure -// @Router /api/admin/{id} [put] -// @Security LoginVerifyToken -func (h *handler) ModifyAdmin() core.HandlerFunc { - return func(ctx core.Context) { - req := new(modifyAdminRequest) - res := new(modifyAdminResponse) - 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.ModifyAdminError, - fmt.Sprintf("%s: %s", code.Text(code.ModifyAdminError), "禁止操作")), - ) - return - } - - if req.UserName == "" && req.NickName == "" { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - "用户名、昵称为必填"), - ) - return - } - - id, err := strconv.Atoi(ctx.Param("id")) - if err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ParamBindError, - "未传递编号ID"), - ) - return - } - - if err := h.svc.Modify(ctx.RequestContext(), id, adminsvc.ModifyInput{ - Username: req.UserName, - Nickname: req.NickName, - Mobile: req.Mobile, - Password: req.Password, - Avatar: req.Avatar, - UpdatedBy: ctx.SessionUserInfo().UserName, - }); err != nil { - ctx.AbortWithError(core.Error( - http.StatusBadRequest, - code.ModifyAdminError, - fmt.Sprintf("%s: %s", code.Text(code.ModifyAdminError), err.Error())), - ) - return - } - - res.Message = "操作成功" - ctx.Payload(res) - } -} diff --git a/internal/api/admin/batch_draw.go b/internal/api/admin/batch_draw.go index ebdd277..9069c90 100644 --- a/internal/api/admin/batch_draw.go +++ b/internal/api/admin/batch_draw.go @@ -31,6 +31,11 @@ type drawResultItem struct { func (h *handler) BatchDrawForUsers() core.HandlerFunc { return func(ctx core.Context) { + activityIDStr := ctx.Param("activity_id") + if activityIDStr == "" { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID")) + return + } issueIDStr := ctx.Param("issue_id") if issueIDStr == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递期ID")) @@ -41,6 +46,7 @@ func (h *handler) BatchDrawForUsers() core.HandlerFunc { return } issueID, _ := strconv.ParseInt(issueIDStr, 10, 64) + _, _ = strconv.ParseInt(activityIDStr, 10, 64) var req batchDrawRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) @@ -71,8 +77,8 @@ func (h *handler) BatchDrawForUsers() core.HandlerFunc { draws := make([]drawResultItem, 0, len(req.UserIDs)) for _, uid := range req.UserIDs { - // 执行抽奖 - rec, err := h.activity.ExecuteDraw(ctx.RequestContext(), issueID) + // 执行抽奖(应用头衔效果) + rec, err := h.activity.ExecuteDrawWithEffects(ctx.RequestContext(), issueID, uid) if err != nil { tx.Rollback() ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ExecuteDrawError, err.Error())) diff --git a/internal/api/admin/titles_admin.go b/internal/api/admin/titles_admin.go new file mode 100644 index 0000000..9f34eae --- /dev/null +++ b/internal/api/admin/titles_admin.go @@ -0,0 +1,386 @@ +package admin + +import ( + "net/http" + "strconv" + "time" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/validation" + "bindbox-game/internal/repository/mysql/model" +) + +type listSystemTitlesRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Name string `form:"name"` + Status *int32 `form:"status"` +} +type listSystemTitlesResponse struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + List []*model.SystemTitles `json:"list"` +} + +// ListSystemTitles 系统称号列表 +func (h *handler) ListSystemTitles() core.HandlerFunc { + return func(ctx core.Context) { + req := new(listSystemTitlesRequest) + rsp := new(listSystemTitlesResponse) + if err := ctx.ShouldBindForm(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.Page <= 0 { req.Page = 1 } + if req.PageSize <= 0 { req.PageSize = 20 } + if req.PageSize > 100 { req.PageSize = 100 } + q := h.readDB.SystemTitles.WithContext(ctx.RequestContext()).ReadDB() + if req.Name != "" { q = q.Where(h.readDB.SystemTitles.Name.Like("%" + req.Name + "%")) } + if req.Status != nil { q = q.Where(h.readDB.SystemTitles.Status.Eq(*req.Status)) } + total, err := q.Count() + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30101, err.Error())) + return + } + rows, err := q.Order(h.readDB.SystemTitles.ID.Desc()). + Offset((req.Page-1)*req.PageSize).Limit(req.PageSize).Find() + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30102, err.Error())) + return + } + rsp.Page = req.Page + rsp.PageSize = req.PageSize + rsp.Total = total + rsp.List = rows + ctx.Payload(rsp) + } +} + +type createSystemTitleRequest struct { + Name string `json:"name" binding:"required,min=1"` + Description string `json:"description"` + Status int32 `json:"status"` + ObtainRulesJSON string `json:"obtain_rules_json"` + ScopesJSON string `json:"scopes_json"` +} + +type modifySystemTitleRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Status *int32 `json:"status"` + ObtainRulesJSON string `json:"obtain_rules_json"` + ScopesJSON string `json:"scopes_json"` +} + +type simpleMessageResponseTitle struct { + Message string `json:"message"` +} + +func (h *handler) CreateSystemTitle() core.HandlerFunc { + return func(ctx core.Context) { + var req createSystemTitleRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.ObtainRulesJSON == "" { req.ObtainRulesJSON = "{}" } + if req.ScopesJSON == "" { req.ScopesJSON = "{}" } + it := &model.SystemTitles{ + Name: req.Name, + Description: req.Description, + Status: req.Status, + ObtainRulesJSON: req.ObtainRulesJSON, + ScopesJSON: req.ScopesJSON, + } + if err := h.writeDB.SystemTitles.WithContext(ctx.RequestContext()).Create(it); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30106, "创建称号失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "创建成功"}) + } +} + +func (h *handler) ModifySystemTitle() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + var req modifySystemTitleRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + row, err := h.readDB.SystemTitles.WithContext(ctx.RequestContext()).Where(h.readDB.SystemTitles.ID.Eq(titleID)).First() + if err != nil || row == nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30107, "称号不存在")) + return + } + if req.Name != "" { row.Name = req.Name } + row.Description = req.Description + if req.Status != nil { row.Status = *req.Status } + if req.ObtainRulesJSON != "" { row.ObtainRulesJSON = req.ObtainRulesJSON } + if req.ScopesJSON != "" { row.ScopesJSON = req.ScopesJSON } + if err := h.writeDB.SystemTitles.Save(row); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30108, "修改称号失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "修改成功"}) + } +} + +func (h *handler) DeleteSystemTitle() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + del := &model.SystemTitles{ID: titleID} + if _, err := h.writeDB.SystemTitles.Delete(del); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30109, "删除称号失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "删除成功"}) + } +} + +type listEffectsResponse struct { + List []*model.SystemTitleEffects `json:"list"` + Total int64 `json:"total"` +} + +func (h *handler) ListSystemTitleEffects() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + rows, err := h.readDB.SystemTitleEffects.WithContext(ctx.RequestContext()).Where(h.readDB.SystemTitleEffects.TitleID.Eq(titleID)).Order(h.readDB.SystemTitleEffects.Sort).Find() + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30110, err.Error())) + return + } + total := int64(len(rows)) + ctx.Payload(&listEffectsResponse{List: rows, Total: total}) + } +} + +type createEffectRequest struct { + EffectType int32 `json:"effect_type" binding:"required"` + ParamsJSON string `json:"params_json" binding:"required"` + StackingStrategy int32 `json:"stacking_strategy"` + CapValueX1000 int32 `json:"cap_value_x1000"` + ScopesJSON string `json:"scopes_json"` + Sort int32 `json:"sort"` + Status int32 `json:"status"` +} + +func (h *handler) CreateSystemTitleEffect() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + var req createEffectRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + if req.ParamsJSON == "" { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "params_json不能为空")); return } + if req.ScopesJSON == "" { req.ScopesJSON = "{}" } + existed, _ := h.readDB.SystemTitleEffects.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemTitleEffects.TitleID.Eq(titleID)). + Where(h.readDB.SystemTitleEffects.EffectType.Eq(req.EffectType)).First() + if existed != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30115, "同类型效果已存在")) + return + } + ef := &model.SystemTitleEffects{ + TitleID: titleID, + EffectType: req.EffectType, + ParamsJSON: req.ParamsJSON, + StackingStrategy: req.StackingStrategy, + CapValueX1000: req.CapValueX1000, + ScopesJSON: req.ScopesJSON, + Sort: req.Sort, + Status: req.Status, + } + if err := h.writeDB.SystemTitleEffects.WithContext(ctx.RequestContext()).Create(ef); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30111, "创建效果失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "创建成功"}) + } +} + +type modifyEffectRequest struct { + EffectType *int32 `json:"effect_type"` + ParamsJSON string `json:"params_json"` + StackingStrategy *int32 `json:"stacking_strategy"` + CapValueX1000 *int32 `json:"cap_value_x1000"` + ScopesJSON string `json:"scopes_json"` + Sort *int32 `json:"sort"` + Status *int32 `json:"status"` +} + +func (h *handler) ModifySystemTitleEffect() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + effectID, err := strconv.ParseInt(ctx.Param("effect_id"), 10, 64) + if err != nil || effectID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递效果ID")) + return + } + var req modifyEffectRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + row, err := h.readDB.SystemTitleEffects.WithContext(ctx.RequestContext()).Where(h.readDB.SystemTitleEffects.ID.Eq(effectID)).Where(h.readDB.SystemTitleEffects.TitleID.Eq(titleID)).First() + if err != nil || row == nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30112, "效果不存在")) + return + } + if req.EffectType != nil { + existed, _ := h.readDB.SystemTitleEffects.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemTitleEffects.TitleID.Eq(titleID)). + Where(h.readDB.SystemTitleEffects.EffectType.Eq(*req.EffectType)). + Where(h.readDB.SystemTitleEffects.ID.Neq(effectID)).First() + if existed != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30116, "同类型效果已存在")) + return + } + row.EffectType = *req.EffectType + } + if req.ParamsJSON != "" { row.ParamsJSON = req.ParamsJSON } + if req.StackingStrategy != nil { row.StackingStrategy = *req.StackingStrategy } + if req.CapValueX1000 != nil { row.CapValueX1000 = *req.CapValueX1000 } + if req.ScopesJSON != "" { row.ScopesJSON = req.ScopesJSON } + if req.Sort != nil { row.Sort = *req.Sort } + if req.Status != nil { row.Status = *req.Status } + if err := h.writeDB.SystemTitleEffects.Save(row); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30113, "修改效果失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "修改成功"}) + } +} + +func (h *handler) DeleteSystemTitleEffect() core.HandlerFunc { + return func(ctx core.Context) { + titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) + if err != nil || titleID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递称号ID")) + return + } + effectID, err := strconv.ParseInt(ctx.Param("effect_id"), 10, 64) + if err != nil || effectID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递效果ID")) + return + } + del := &model.SystemTitleEffects{ID: effectID, TitleID: titleID} + if _, err := h.writeDB.SystemTitleEffects.Delete(del); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30114, "删除效果失败")) + return + } + ctx.Payload(&simpleMessageResponseTitle{Message: "删除成功"}) + } +} +type assignUserTitleRequest struct { + TitleID int64 `json:"title_id" binding:"required,min=1"` + ExpiresAt *string `json:"expires_at"` // RFC3339 字符串,可空 + Remark string `json:"remark"` +} +type assignUserTitleResponse struct { + Message string `json:"message"` +} + +// AssignUserTitle 给用户分配称号(存在则更新有效期与备注,并激活) +func (h *handler) AssignUserTitle() core.HandlerFunc { + return func(ctx core.Context) { + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil || userID <= 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) + return + } + var req assignUserTitleRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + // 校验称号存在且启用 + title, err := h.readDB.SystemTitles.WithContext(ctx.RequestContext()). + Where(h.readDB.SystemTitles.ID.Eq(req.TitleID)).First() + if err != nil || title == nil || title.Status != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 30103, "称号不存在或未启用")) + return + } + // 解析过期时间 + var exp time.Time + if req.ExpiresAt != nil && *req.ExpiresAt != "" { + t, perr := time.Parse(time.RFC3339, *req.ExpiresAt) + if perr != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "expires_at格式错误")) + return + } + exp = t + } + // upsert 用户称号 + // 先查是否存在 + ut, err := h.readDB.UserTitles.WithContext(ctx.RequestContext()). + Where(h.readDB.UserTitles.UserID.Eq(userID)). + Where(h.readDB.UserTitles.TitleID.Eq(req.TitleID)).First() + if err == nil && ut != nil { + // 更新 + ut.Active = 1 + ut.Remark = req.Remark + if !exp.IsZero() { ut.ExpiresAt = exp } + if err := h.writeDB.UserTitles.Save(ut); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30104, "更新称号失败")) + return + } + others, _ := h.readDB.UserTitles.WithContext(ctx.RequestContext()). + Where(h.readDB.UserTitles.UserID.Eq(userID)). + Where(h.readDB.UserTitles.TitleID.Neq(req.TitleID)). + Where(h.readDB.UserTitles.Active.Eq(1)).Find() + for _, o := range others { + o.Active = 0 + _ = h.writeDB.UserTitles.Save(o) + } + } else { + now := time.Now() + newUT := &model.UserTitles{ + UserID: userID, + TitleID: req.TitleID, + Active: 1, + ObtainedAt: now, + Source: "admin_assign", + Remark: req.Remark, + } + if !exp.IsZero() { newUT.ExpiresAt = exp } + if err := h.writeDB.UserTitles.Create(newUT); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 30105, "分配称号失败")) + return + } + others, _ := h.readDB.UserTitles.WithContext(ctx.RequestContext()). + Where(h.readDB.UserTitles.UserID.Eq(userID)). + Where(h.readDB.UserTitles.TitleID.Neq(req.TitleID)). + Where(h.readDB.UserTitles.Active.Eq(1)).Find() + for _, o := range others { + o.Active = 0 + _ = h.writeDB.UserTitles.Save(o) + } + } + ctx.Payload(&assignUserTitleResponse{Message: "分配称号成功"}) + } +} \ No newline at end of file diff --git a/internal/api/admin/titles_seed.go b/internal/api/admin/titles_seed.go new file mode 100644 index 0000000..891281b --- /dev/null +++ b/internal/api/admin/titles_seed.go @@ -0,0 +1,178 @@ +package admin + +import ( + "encoding/json" + "net/http" + + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/repository/mysql/model" +) + +type seedDefaultTitlesResponse struct { + Created int `json:"created"` + Exists int `json:"exists"` + IDs []int64 `json:"ids"` +} + +// SeedDefaultTitles 内置6个称号与基础效果 +func (h *handler) SeedDefaultTitles() core.HandlerFunc { + return func(ctx core.Context) { + // 定义默认称号与效果 + type def struct { + Name string + Description string + EffectType int32 + Params map[string]interface{} + Stack int32 + CapX1000 int32 + } + defs := []def{ + {Name: "优惠券使者", Description: "可领取优惠券", EffectType: 1, Params: map[string]interface{}{ "template_id": 0, "frequency": map[string]interface{}{"period": "day", "times": 1}}, Stack: 1, CapX1000: 0}, + {Name: "折扣官", Description: "抽奖购票折扣", EffectType: 2, Params: map[string]interface{}{ "discount_type": "percentage", "value_x1000": 100, "max_discount_x1000": 300}, Stack: 0, CapX1000: 300}, + {Name: "签到达人", Description: "签到双倍积分", EffectType: 3, Params: map[string]interface{}{ "multiplier_x1000": 2000, "daily_cap_points": 0}, Stack: 1, CapX1000: 3000}, + {Name: "卡牌使者", Description: "可领取道具卡", EffectType: 4, Params: map[string]interface{}{ "template_id": 0, "frequency": map[string]interface{}{"period": "week", "times": 2}}, Stack: 1, CapX1000: 0}, + {Name: "幸运加成者", Description: "抽奖概率加成", EffectType: 5, Params: map[string]interface{}{ "target_prize_ids": []int64{}, "boost_x1000": 100, "cap_x1000": 300}, Stack: 1, CapX1000: 300}, + {Name: "双倍之王", Description: "奖品双倍概率", EffectType: 6, Params: map[string]interface{}{ "target_prize_ids": []int64{}, "chance_x1000": 200, "period_cap_times": 1}, Stack: 1, CapX1000: 500}, + } + created := 0 + exists := 0 + var ids []int64 + for _, d := range defs { + // 是否存在同名称号 + row, _ := h.readDB.SystemTitles.WithContext(ctx.RequestContext()).Where(h.readDB.SystemTitles.Name.Eq(d.Name)).First() + if row != nil { + exists++ + ids = append(ids, row.ID) + continue + } + // 创建称号(使用模型类型) + newRow := &model.SystemTitles{ + Name: d.Name, + Description: d.Description, + Status: 1, + ObtainRulesJSON: "{}", + ScopesJSON: "{}", + } + if err := h.writeDB.SystemTitles.WithContext(ctx.RequestContext()).Create(newRow); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建称号失败")) + return + } + // 重新读取ID + trow, _ := h.readDB.SystemTitles.WithContext(ctx.RequestContext()).Where(h.readDB.SystemTitles.Name.Eq(d.Name)).First() + if trow == nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建称号失败(读取)")) + return + } + ids = append(ids, trow.ID) + created++ + // 创建效果 + paramsBytes, _ := json.Marshal(d.Params) + eff := &model.SystemTitleEffects{ + TitleID: trow.ID, + EffectType: d.EffectType, + ParamsJSON: string(paramsBytes), + StackingStrategy: d.Stack, + CapValueX1000: d.CapX1000, + ScopesJSON: "{}", + Sort: 1, + Status: 1, + } + if err := h.writeDB.SystemTitleEffects.WithContext(ctx.RequestContext()).Create(eff); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建称号效果失败")) + return + } + } + ctx.Payload(&seedDefaultTitlesResponse{Created: created, Exists: exists, IDs: ids}) + } +} + +type ensureTitlesMenuResponse struct { + Ensured bool `json:"ensured"` + Parent int64 `json:"parent_id"` + MenuID int64 `json:"menu_id"` +} + +// EnsureTitlesMenu 确保运营菜单下存在“称号管理”子菜单 +func (h *handler) EnsureTitlesMenu() core.HandlerFunc { + return func(ctx core.Context) { + // 查找运营菜单父节点 + parent, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Operations")).First() + var parentID int64 + if parent == nil { + // 创建运营父菜单 + pm := &model.Menus{ + ParentID: 0, + Path: "/operations", + Name: "Operations", + Component: "/index/index", + Icon: "ri:tools-line", + Sort: 10, + Status: true, + KeepAlive: true, + IsHide: false, + IsHideTab: false, + CreatedUser: "system", + UpdatedUser: "system", + } + if err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Create(pm); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建运营菜单失败")) + return + } + // 读取 + parent, _ = h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Operations")).First() + if parent == nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建运营菜单失败(读取)")) + return + } + } + parentID = parent.ID + // 查找称号菜单 + titlesMenu, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq("titles")).Where(h.readDB.Menus.ParentID.Eq(parentID)).First() + if titlesMenu == nil { + tm := &struct { + ParentID int64 + Path string + Name string + Component string + Icon string + Sort int32 + Status bool + KeepAlive bool + IsHide bool + IsHideTab bool + }{ + ParentID: parentID, + Path: "titles", + Name: "Titles", + Component: "/operations/titles", + Icon: "ri:medal-line", + Sort: 50, + Status: true, + KeepAlive: true, + IsHide: false, + IsHideTab: false, + } + mm := &model.Menus{ + ParentID: tm.ParentID, + Path: tm.Path, + Name: tm.Name, + Component: tm.Component, + Icon: tm.Icon, + Sort: tm.Sort, + Status: tm.Status, + KeepAlive: tm.KeepAlive, + IsHide: tm.IsHide, + IsHideTab: tm.IsHideTab, + CreatedUser: "system", + UpdatedUser: "system", + } + if err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Create(mm); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "创建称号菜单失败")) + return + } + titlesMenu, _ = h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq("titles")).Where(h.readDB.Menus.ParentID.Eq(parentID)).First() + } + ctx.Payload(&ensureTitlesMenuResponse{Ensured: true, Parent: parentID, MenuID: titlesMenu.ID}) + } +} \ No newline at end of file diff --git a/internal/repository/mysql/dao/gen.go b/internal/repository/mysql/dao/gen.go index 1e21108..a8b755c 100644 --- a/internal/repository/mysql/dao/gen.go +++ b/internal/repository/mysql/dao/gen.go @@ -46,6 +46,8 @@ var ( ShippingRecords *shippingRecords SystemCoupons *systemCoupons SystemItemCards *systemItemCards + SystemTitleEffects *systemTitleEffects + SystemTitles *systemTitles UserAddresses *userAddresses UserCoupons *userCoupons UserInventory *userInventory @@ -54,6 +56,8 @@ var ( UserItemCards *userItemCards UserPoints *userPoints UserPointsLedger *userPointsLedger + UserTitleEffectClaims *userTitleEffectClaims + UserTitles *userTitles Users *users ) @@ -88,6 +92,8 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { ShippingRecords = &Q.ShippingRecords SystemCoupons = &Q.SystemCoupons SystemItemCards = &Q.SystemItemCards + SystemTitleEffects = &Q.SystemTitleEffects + SystemTitles = &Q.SystemTitles UserAddresses = &Q.UserAddresses UserCoupons = &Q.UserCoupons UserInventory = &Q.UserInventory @@ -96,6 +102,8 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { UserItemCards = &Q.UserItemCards UserPoints = &Q.UserPoints UserPointsLedger = &Q.UserPointsLedger + UserTitleEffectClaims = &Q.UserTitleEffectClaims + UserTitles = &Q.UserTitles Users = &Q.Users } @@ -131,6 +139,8 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { ShippingRecords: newShippingRecords(db, opts...), SystemCoupons: newSystemCoupons(db, opts...), SystemItemCards: newSystemItemCards(db, opts...), + SystemTitleEffects: newSystemTitleEffects(db, opts...), + SystemTitles: newSystemTitles(db, opts...), UserAddresses: newUserAddresses(db, opts...), UserCoupons: newUserCoupons(db, opts...), UserInventory: newUserInventory(db, opts...), @@ -139,6 +149,8 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query { UserItemCards: newUserItemCards(db, opts...), UserPoints: newUserPoints(db, opts...), UserPointsLedger: newUserPointsLedger(db, opts...), + UserTitleEffectClaims: newUserTitleEffectClaims(db, opts...), + UserTitles: newUserTitles(db, opts...), Users: newUsers(db, opts...), } } @@ -175,6 +187,8 @@ type Query struct { ShippingRecords shippingRecords SystemCoupons systemCoupons SystemItemCards systemItemCards + SystemTitleEffects systemTitleEffects + SystemTitles systemTitles UserAddresses userAddresses UserCoupons userCoupons UserInventory userInventory @@ -183,6 +197,8 @@ type Query struct { UserItemCards userItemCards UserPoints userPoints UserPointsLedger userPointsLedger + UserTitleEffectClaims userTitleEffectClaims + UserTitles userTitles Users users } @@ -220,6 +236,8 @@ func (q *Query) clone(db *gorm.DB) *Query { ShippingRecords: q.ShippingRecords.clone(db), SystemCoupons: q.SystemCoupons.clone(db), SystemItemCards: q.SystemItemCards.clone(db), + SystemTitleEffects: q.SystemTitleEffects.clone(db), + SystemTitles: q.SystemTitles.clone(db), UserAddresses: q.UserAddresses.clone(db), UserCoupons: q.UserCoupons.clone(db), UserInventory: q.UserInventory.clone(db), @@ -228,6 +246,8 @@ func (q *Query) clone(db *gorm.DB) *Query { UserItemCards: q.UserItemCards.clone(db), UserPoints: q.UserPoints.clone(db), UserPointsLedger: q.UserPointsLedger.clone(db), + UserTitleEffectClaims: q.UserTitleEffectClaims.clone(db), + UserTitles: q.UserTitles.clone(db), Users: q.Users.clone(db), } } @@ -272,6 +292,8 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { ShippingRecords: q.ShippingRecords.replaceDB(db), SystemCoupons: q.SystemCoupons.replaceDB(db), SystemItemCards: q.SystemItemCards.replaceDB(db), + SystemTitleEffects: q.SystemTitleEffects.replaceDB(db), + SystemTitles: q.SystemTitles.replaceDB(db), UserAddresses: q.UserAddresses.replaceDB(db), UserCoupons: q.UserCoupons.replaceDB(db), UserInventory: q.UserInventory.replaceDB(db), @@ -280,6 +302,8 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query { UserItemCards: q.UserItemCards.replaceDB(db), UserPoints: q.UserPoints.replaceDB(db), UserPointsLedger: q.UserPointsLedger.replaceDB(db), + UserTitleEffectClaims: q.UserTitleEffectClaims.replaceDB(db), + UserTitles: q.UserTitles.replaceDB(db), Users: q.Users.replaceDB(db), } } @@ -314,6 +338,8 @@ type queryCtx struct { ShippingRecords *shippingRecordsDo SystemCoupons *systemCouponsDo SystemItemCards *systemItemCardsDo + SystemTitleEffects *systemTitleEffectsDo + SystemTitles *systemTitlesDo UserAddresses *userAddressesDo UserCoupons *userCouponsDo UserInventory *userInventoryDo @@ -322,6 +348,8 @@ type queryCtx struct { UserItemCards *userItemCardsDo UserPoints *userPointsDo UserPointsLedger *userPointsLedgerDo + UserTitleEffectClaims *userTitleEffectClaimsDo + UserTitles *userTitlesDo Users *usersDo } @@ -356,6 +384,8 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { ShippingRecords: q.ShippingRecords.WithContext(ctx), SystemCoupons: q.SystemCoupons.WithContext(ctx), SystemItemCards: q.SystemItemCards.WithContext(ctx), + SystemTitleEffects: q.SystemTitleEffects.WithContext(ctx), + SystemTitles: q.SystemTitles.WithContext(ctx), UserAddresses: q.UserAddresses.WithContext(ctx), UserCoupons: q.UserCoupons.WithContext(ctx), UserInventory: q.UserInventory.WithContext(ctx), @@ -364,6 +394,8 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx { UserItemCards: q.UserItemCards.WithContext(ctx), UserPoints: q.UserPoints.WithContext(ctx), UserPointsLedger: q.UserPointsLedger.WithContext(ctx), + UserTitleEffectClaims: q.UserTitleEffectClaims.WithContext(ctx), + UserTitles: q.UserTitles.WithContext(ctx), Users: q.Users.WithContext(ctx), } } diff --git a/internal/repository/mysql/dao/system_title_effects.gen.go b/internal/repository/mysql/dao/system_title_effects.gen.go new file mode 100644 index 0000000..661575a --- /dev/null +++ b/internal/repository/mysql/dao/system_title_effects.gen.go @@ -0,0 +1,356 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newSystemTitleEffects(db *gorm.DB, opts ...gen.DOOption) systemTitleEffects { + _systemTitleEffects := systemTitleEffects{} + + _systemTitleEffects.systemTitleEffectsDo.UseDB(db, opts...) + _systemTitleEffects.systemTitleEffectsDo.UseModel(&model.SystemTitleEffects{}) + + tableName := _systemTitleEffects.systemTitleEffectsDo.TableName() + _systemTitleEffects.ALL = field.NewAsterisk(tableName) + _systemTitleEffects.ID = field.NewInt64(tableName, "id") + _systemTitleEffects.TitleID = field.NewInt64(tableName, "title_id") + _systemTitleEffects.EffectType = field.NewInt32(tableName, "effect_type") + _systemTitleEffects.ParamsJSON = field.NewString(tableName, "params_json") + _systemTitleEffects.StackingStrategy = field.NewInt32(tableName, "stacking_strategy") + _systemTitleEffects.CapValueX1000 = field.NewInt32(tableName, "cap_value_x1000") + _systemTitleEffects.ScopesJSON = field.NewString(tableName, "scopes_json") + _systemTitleEffects.Sort = field.NewInt32(tableName, "sort") + _systemTitleEffects.Status = field.NewInt32(tableName, "status") + _systemTitleEffects.CreatedAt = field.NewTime(tableName, "created_at") + + _systemTitleEffects.fillFieldMap() + + return _systemTitleEffects +} + +// systemTitleEffects 头衔效果配置表 +type systemTitleEffects struct { + systemTitleEffectsDo + + ALL field.Asterisk + ID field.Int64 // 头衔效果配置主键ID + TitleID field.Int64 // 归属头衔ID(system_titles.id) + EffectType field.Int32 // 效果类型:1领券 2抽奖折扣 3签到倍数 4领道具卡 5概率加成 6奖品双倍概率 + ParamsJSON field.String // 效果参数JSON(倍数、概率、折扣值、模板ID、频次等) + StackingStrategy field.Int32 // 叠加策略:0最大值 1累加封顶 2首个匹配 + CapValueX1000 field.Int32 // 封顶值(千分比;如倍数或概率总封顶) + ScopesJSON field.String // 作用范围JSON(活动/期/分类等) + Sort field.Int32 // 效果排序(同一头衔内) + Status field.Int32 // 状态:1启用 0停用 + CreatedAt field.Time // 创建时间 + + fieldMap map[string]field.Expr +} + +func (s systemTitleEffects) Table(newTableName string) *systemTitleEffects { + s.systemTitleEffectsDo.UseTable(newTableName) + return s.updateTableName(newTableName) +} + +func (s systemTitleEffects) As(alias string) *systemTitleEffects { + s.systemTitleEffectsDo.DO = *(s.systemTitleEffectsDo.As(alias).(*gen.DO)) + return s.updateTableName(alias) +} + +func (s *systemTitleEffects) updateTableName(table string) *systemTitleEffects { + s.ALL = field.NewAsterisk(table) + s.ID = field.NewInt64(table, "id") + s.TitleID = field.NewInt64(table, "title_id") + s.EffectType = field.NewInt32(table, "effect_type") + s.ParamsJSON = field.NewString(table, "params_json") + s.StackingStrategy = field.NewInt32(table, "stacking_strategy") + s.CapValueX1000 = field.NewInt32(table, "cap_value_x1000") + s.ScopesJSON = field.NewString(table, "scopes_json") + s.Sort = field.NewInt32(table, "sort") + s.Status = field.NewInt32(table, "status") + s.CreatedAt = field.NewTime(table, "created_at") + + s.fillFieldMap() + + return s +} + +func (s *systemTitleEffects) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := s.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (s *systemTitleEffects) fillFieldMap() { + s.fieldMap = make(map[string]field.Expr, 10) + s.fieldMap["id"] = s.ID + s.fieldMap["title_id"] = s.TitleID + s.fieldMap["effect_type"] = s.EffectType + s.fieldMap["params_json"] = s.ParamsJSON + s.fieldMap["stacking_strategy"] = s.StackingStrategy + s.fieldMap["cap_value_x1000"] = s.CapValueX1000 + s.fieldMap["scopes_json"] = s.ScopesJSON + s.fieldMap["sort"] = s.Sort + s.fieldMap["status"] = s.Status + s.fieldMap["created_at"] = s.CreatedAt +} + +func (s systemTitleEffects) clone(db *gorm.DB) systemTitleEffects { + s.systemTitleEffectsDo.ReplaceConnPool(db.Statement.ConnPool) + return s +} + +func (s systemTitleEffects) replaceDB(db *gorm.DB) systemTitleEffects { + s.systemTitleEffectsDo.ReplaceDB(db) + return s +} + +type systemTitleEffectsDo struct{ gen.DO } + +func (s systemTitleEffectsDo) Debug() *systemTitleEffectsDo { + return s.withDO(s.DO.Debug()) +} + +func (s systemTitleEffectsDo) WithContext(ctx context.Context) *systemTitleEffectsDo { + return s.withDO(s.DO.WithContext(ctx)) +} + +func (s systemTitleEffectsDo) ReadDB() *systemTitleEffectsDo { + return s.Clauses(dbresolver.Read) +} + +func (s systemTitleEffectsDo) WriteDB() *systemTitleEffectsDo { + return s.Clauses(dbresolver.Write) +} + +func (s systemTitleEffectsDo) Session(config *gorm.Session) *systemTitleEffectsDo { + return s.withDO(s.DO.Session(config)) +} + +func (s systemTitleEffectsDo) Clauses(conds ...clause.Expression) *systemTitleEffectsDo { + return s.withDO(s.DO.Clauses(conds...)) +} + +func (s systemTitleEffectsDo) Returning(value interface{}, columns ...string) *systemTitleEffectsDo { + return s.withDO(s.DO.Returning(value, columns...)) +} + +func (s systemTitleEffectsDo) Not(conds ...gen.Condition) *systemTitleEffectsDo { + return s.withDO(s.DO.Not(conds...)) +} + +func (s systemTitleEffectsDo) Or(conds ...gen.Condition) *systemTitleEffectsDo { + return s.withDO(s.DO.Or(conds...)) +} + +func (s systemTitleEffectsDo) Select(conds ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Select(conds...)) +} + +func (s systemTitleEffectsDo) Where(conds ...gen.Condition) *systemTitleEffectsDo { + return s.withDO(s.DO.Where(conds...)) +} + +func (s systemTitleEffectsDo) Order(conds ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Order(conds...)) +} + +func (s systemTitleEffectsDo) Distinct(cols ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Distinct(cols...)) +} + +func (s systemTitleEffectsDo) Omit(cols ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Omit(cols...)) +} + +func (s systemTitleEffectsDo) Join(table schema.Tabler, on ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Join(table, on...)) +} + +func (s systemTitleEffectsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.LeftJoin(table, on...)) +} + +func (s systemTitleEffectsDo) RightJoin(table schema.Tabler, on ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.RightJoin(table, on...)) +} + +func (s systemTitleEffectsDo) Group(cols ...field.Expr) *systemTitleEffectsDo { + return s.withDO(s.DO.Group(cols...)) +} + +func (s systemTitleEffectsDo) Having(conds ...gen.Condition) *systemTitleEffectsDo { + return s.withDO(s.DO.Having(conds...)) +} + +func (s systemTitleEffectsDo) Limit(limit int) *systemTitleEffectsDo { + return s.withDO(s.DO.Limit(limit)) +} + +func (s systemTitleEffectsDo) Offset(offset int) *systemTitleEffectsDo { + return s.withDO(s.DO.Offset(offset)) +} + +func (s systemTitleEffectsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *systemTitleEffectsDo { + return s.withDO(s.DO.Scopes(funcs...)) +} + +func (s systemTitleEffectsDo) Unscoped() *systemTitleEffectsDo { + return s.withDO(s.DO.Unscoped()) +} + +func (s systemTitleEffectsDo) Create(values ...*model.SystemTitleEffects) error { + if len(values) == 0 { + return nil + } + return s.DO.Create(values) +} + +func (s systemTitleEffectsDo) CreateInBatches(values []*model.SystemTitleEffects, batchSize int) error { + return s.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (s systemTitleEffectsDo) Save(values ...*model.SystemTitleEffects) error { + if len(values) == 0 { + return nil + } + return s.DO.Save(values) +} + +func (s systemTitleEffectsDo) First() (*model.SystemTitleEffects, error) { + if result, err := s.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitleEffects), nil + } +} + +func (s systemTitleEffectsDo) Take() (*model.SystemTitleEffects, error) { + if result, err := s.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitleEffects), nil + } +} + +func (s systemTitleEffectsDo) Last() (*model.SystemTitleEffects, error) { + if result, err := s.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitleEffects), nil + } +} + +func (s systemTitleEffectsDo) Find() ([]*model.SystemTitleEffects, error) { + result, err := s.DO.Find() + return result.([]*model.SystemTitleEffects), err +} + +func (s systemTitleEffectsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.SystemTitleEffects, err error) { + buf := make([]*model.SystemTitleEffects, 0, batchSize) + err = s.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (s systemTitleEffectsDo) FindInBatches(result *[]*model.SystemTitleEffects, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return s.DO.FindInBatches(result, batchSize, fc) +} + +func (s systemTitleEffectsDo) Attrs(attrs ...field.AssignExpr) *systemTitleEffectsDo { + return s.withDO(s.DO.Attrs(attrs...)) +} + +func (s systemTitleEffectsDo) Assign(attrs ...field.AssignExpr) *systemTitleEffectsDo { + return s.withDO(s.DO.Assign(attrs...)) +} + +func (s systemTitleEffectsDo) Joins(fields ...field.RelationField) *systemTitleEffectsDo { + for _, _f := range fields { + s = *s.withDO(s.DO.Joins(_f)) + } + return &s +} + +func (s systemTitleEffectsDo) Preload(fields ...field.RelationField) *systemTitleEffectsDo { + for _, _f := range fields { + s = *s.withDO(s.DO.Preload(_f)) + } + return &s +} + +func (s systemTitleEffectsDo) FirstOrInit() (*model.SystemTitleEffects, error) { + if result, err := s.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitleEffects), nil + } +} + +func (s systemTitleEffectsDo) FirstOrCreate() (*model.SystemTitleEffects, error) { + if result, err := s.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitleEffects), nil + } +} + +func (s systemTitleEffectsDo) FindByPage(offset int, limit int) (result []*model.SystemTitleEffects, count int64, err error) { + result, err = s.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = s.Offset(-1).Limit(-1).Count() + return +} + +func (s systemTitleEffectsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = s.Count() + if err != nil { + return + } + + err = s.Offset(offset).Limit(limit).Scan(result) + return +} + +func (s systemTitleEffectsDo) Scan(result interface{}) (err error) { + return s.DO.Scan(result) +} + +func (s systemTitleEffectsDo) Delete(models ...*model.SystemTitleEffects) (result gen.ResultInfo, err error) { + return s.DO.Delete(models) +} + +func (s *systemTitleEffectsDo) withDO(do gen.Dao) *systemTitleEffectsDo { + s.DO = *do.(*gen.DO) + return s +} diff --git a/internal/repository/mysql/dao/system_titles.gen.go b/internal/repository/mysql/dao/system_titles.gen.go new file mode 100644 index 0000000..b9ccf67 --- /dev/null +++ b/internal/repository/mysql/dao/system_titles.gen.go @@ -0,0 +1,348 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newSystemTitles(db *gorm.DB, opts ...gen.DOOption) systemTitles { + _systemTitles := systemTitles{} + + _systemTitles.systemTitlesDo.UseDB(db, opts...) + _systemTitles.systemTitlesDo.UseModel(&model.SystemTitles{}) + + tableName := _systemTitles.systemTitlesDo.TableName() + _systemTitles.ALL = field.NewAsterisk(tableName) + _systemTitles.ID = field.NewInt64(tableName, "id") + _systemTitles.Name = field.NewString(tableName, "name") + _systemTitles.Description = field.NewString(tableName, "description") + _systemTitles.Status = field.NewInt32(tableName, "status") + _systemTitles.ObtainRulesJSON = field.NewString(tableName, "obtain_rules_json") + _systemTitles.ScopesJSON = field.NewString(tableName, "scopes_json") + _systemTitles.CreatedAt = field.NewTime(tableName, "created_at") + _systemTitles.UpdatedAt = field.NewTime(tableName, "updated_at") + + _systemTitles.fillFieldMap() + + return _systemTitles +} + +// systemTitles 头衔模板表 +type systemTitles struct { + systemTitlesDo + + ALL field.Asterisk + ID field.Int64 // 头衔模板主键ID + Name field.String // 头衔名称(唯一) + Description field.String // 头衔描述 + Status field.Int32 // 状态:1启用 0停用 + ObtainRulesJSON field.String // 获得条件规则JSON(任务、等级、付费等) + ScopesJSON field.String // 作用范围配置JSON(活动/期/分类等) + CreatedAt field.Time // 创建时间 + UpdatedAt field.Time // 更新时间 + + fieldMap map[string]field.Expr +} + +func (s systemTitles) Table(newTableName string) *systemTitles { + s.systemTitlesDo.UseTable(newTableName) + return s.updateTableName(newTableName) +} + +func (s systemTitles) As(alias string) *systemTitles { + s.systemTitlesDo.DO = *(s.systemTitlesDo.As(alias).(*gen.DO)) + return s.updateTableName(alias) +} + +func (s *systemTitles) updateTableName(table string) *systemTitles { + s.ALL = field.NewAsterisk(table) + s.ID = field.NewInt64(table, "id") + s.Name = field.NewString(table, "name") + s.Description = field.NewString(table, "description") + s.Status = field.NewInt32(table, "status") + s.ObtainRulesJSON = field.NewString(table, "obtain_rules_json") + s.ScopesJSON = field.NewString(table, "scopes_json") + s.CreatedAt = field.NewTime(table, "created_at") + s.UpdatedAt = field.NewTime(table, "updated_at") + + s.fillFieldMap() + + return s +} + +func (s *systemTitles) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := s.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (s *systemTitles) fillFieldMap() { + s.fieldMap = make(map[string]field.Expr, 8) + s.fieldMap["id"] = s.ID + s.fieldMap["name"] = s.Name + s.fieldMap["description"] = s.Description + s.fieldMap["status"] = s.Status + s.fieldMap["obtain_rules_json"] = s.ObtainRulesJSON + s.fieldMap["scopes_json"] = s.ScopesJSON + s.fieldMap["created_at"] = s.CreatedAt + s.fieldMap["updated_at"] = s.UpdatedAt +} + +func (s systemTitles) clone(db *gorm.DB) systemTitles { + s.systemTitlesDo.ReplaceConnPool(db.Statement.ConnPool) + return s +} + +func (s systemTitles) replaceDB(db *gorm.DB) systemTitles { + s.systemTitlesDo.ReplaceDB(db) + return s +} + +type systemTitlesDo struct{ gen.DO } + +func (s systemTitlesDo) Debug() *systemTitlesDo { + return s.withDO(s.DO.Debug()) +} + +func (s systemTitlesDo) WithContext(ctx context.Context) *systemTitlesDo { + return s.withDO(s.DO.WithContext(ctx)) +} + +func (s systemTitlesDo) ReadDB() *systemTitlesDo { + return s.Clauses(dbresolver.Read) +} + +func (s systemTitlesDo) WriteDB() *systemTitlesDo { + return s.Clauses(dbresolver.Write) +} + +func (s systemTitlesDo) Session(config *gorm.Session) *systemTitlesDo { + return s.withDO(s.DO.Session(config)) +} + +func (s systemTitlesDo) Clauses(conds ...clause.Expression) *systemTitlesDo { + return s.withDO(s.DO.Clauses(conds...)) +} + +func (s systemTitlesDo) Returning(value interface{}, columns ...string) *systemTitlesDo { + return s.withDO(s.DO.Returning(value, columns...)) +} + +func (s systemTitlesDo) Not(conds ...gen.Condition) *systemTitlesDo { + return s.withDO(s.DO.Not(conds...)) +} + +func (s systemTitlesDo) Or(conds ...gen.Condition) *systemTitlesDo { + return s.withDO(s.DO.Or(conds...)) +} + +func (s systemTitlesDo) Select(conds ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Select(conds...)) +} + +func (s systemTitlesDo) Where(conds ...gen.Condition) *systemTitlesDo { + return s.withDO(s.DO.Where(conds...)) +} + +func (s systemTitlesDo) Order(conds ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Order(conds...)) +} + +func (s systemTitlesDo) Distinct(cols ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Distinct(cols...)) +} + +func (s systemTitlesDo) Omit(cols ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Omit(cols...)) +} + +func (s systemTitlesDo) Join(table schema.Tabler, on ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Join(table, on...)) +} + +func (s systemTitlesDo) LeftJoin(table schema.Tabler, on ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.LeftJoin(table, on...)) +} + +func (s systemTitlesDo) RightJoin(table schema.Tabler, on ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.RightJoin(table, on...)) +} + +func (s systemTitlesDo) Group(cols ...field.Expr) *systemTitlesDo { + return s.withDO(s.DO.Group(cols...)) +} + +func (s systemTitlesDo) Having(conds ...gen.Condition) *systemTitlesDo { + return s.withDO(s.DO.Having(conds...)) +} + +func (s systemTitlesDo) Limit(limit int) *systemTitlesDo { + return s.withDO(s.DO.Limit(limit)) +} + +func (s systemTitlesDo) Offset(offset int) *systemTitlesDo { + return s.withDO(s.DO.Offset(offset)) +} + +func (s systemTitlesDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *systemTitlesDo { + return s.withDO(s.DO.Scopes(funcs...)) +} + +func (s systemTitlesDo) Unscoped() *systemTitlesDo { + return s.withDO(s.DO.Unscoped()) +} + +func (s systemTitlesDo) Create(values ...*model.SystemTitles) error { + if len(values) == 0 { + return nil + } + return s.DO.Create(values) +} + +func (s systemTitlesDo) CreateInBatches(values []*model.SystemTitles, batchSize int) error { + return s.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (s systemTitlesDo) Save(values ...*model.SystemTitles) error { + if len(values) == 0 { + return nil + } + return s.DO.Save(values) +} + +func (s systemTitlesDo) First() (*model.SystemTitles, error) { + if result, err := s.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitles), nil + } +} + +func (s systemTitlesDo) Take() (*model.SystemTitles, error) { + if result, err := s.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitles), nil + } +} + +func (s systemTitlesDo) Last() (*model.SystemTitles, error) { + if result, err := s.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitles), nil + } +} + +func (s systemTitlesDo) Find() ([]*model.SystemTitles, error) { + result, err := s.DO.Find() + return result.([]*model.SystemTitles), err +} + +func (s systemTitlesDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.SystemTitles, err error) { + buf := make([]*model.SystemTitles, 0, batchSize) + err = s.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (s systemTitlesDo) FindInBatches(result *[]*model.SystemTitles, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return s.DO.FindInBatches(result, batchSize, fc) +} + +func (s systemTitlesDo) Attrs(attrs ...field.AssignExpr) *systemTitlesDo { + return s.withDO(s.DO.Attrs(attrs...)) +} + +func (s systemTitlesDo) Assign(attrs ...field.AssignExpr) *systemTitlesDo { + return s.withDO(s.DO.Assign(attrs...)) +} + +func (s systemTitlesDo) Joins(fields ...field.RelationField) *systemTitlesDo { + for _, _f := range fields { + s = *s.withDO(s.DO.Joins(_f)) + } + return &s +} + +func (s systemTitlesDo) Preload(fields ...field.RelationField) *systemTitlesDo { + for _, _f := range fields { + s = *s.withDO(s.DO.Preload(_f)) + } + return &s +} + +func (s systemTitlesDo) FirstOrInit() (*model.SystemTitles, error) { + if result, err := s.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitles), nil + } +} + +func (s systemTitlesDo) FirstOrCreate() (*model.SystemTitles, error) { + if result, err := s.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.SystemTitles), nil + } +} + +func (s systemTitlesDo) FindByPage(offset int, limit int) (result []*model.SystemTitles, count int64, err error) { + result, err = s.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = s.Offset(-1).Limit(-1).Count() + return +} + +func (s systemTitlesDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = s.Count() + if err != nil { + return + } + + err = s.Offset(offset).Limit(limit).Scan(result) + return +} + +func (s systemTitlesDo) Scan(result interface{}) (err error) { + return s.DO.Scan(result) +} + +func (s systemTitlesDo) Delete(models ...*model.SystemTitles) (result gen.ResultInfo, err error) { + return s.DO.Delete(models) +} + +func (s *systemTitlesDo) withDO(do gen.Dao) *systemTitlesDo { + s.DO = *do.(*gen.DO) + return s +} diff --git a/internal/repository/mysql/dao/user_title_effect_claims.gen.go b/internal/repository/mysql/dao/user_title_effect_claims.gen.go new file mode 100644 index 0000000..e2384ec --- /dev/null +++ b/internal/repository/mysql/dao/user_title_effect_claims.gen.go @@ -0,0 +1,352 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newUserTitleEffectClaims(db *gorm.DB, opts ...gen.DOOption) userTitleEffectClaims { + _userTitleEffectClaims := userTitleEffectClaims{} + + _userTitleEffectClaims.userTitleEffectClaimsDo.UseDB(db, opts...) + _userTitleEffectClaims.userTitleEffectClaimsDo.UseModel(&model.UserTitleEffectClaims{}) + + tableName := _userTitleEffectClaims.userTitleEffectClaimsDo.TableName() + _userTitleEffectClaims.ALL = field.NewAsterisk(tableName) + _userTitleEffectClaims.ID = field.NewInt64(tableName, "id") + _userTitleEffectClaims.UserID = field.NewInt64(tableName, "user_id") + _userTitleEffectClaims.TitleID = field.NewInt64(tableName, "title_id") + _userTitleEffectClaims.EffectType = field.NewInt32(tableName, "effect_type") + _userTitleEffectClaims.TargetTemplateID = field.NewInt64(tableName, "target_template_id") + _userTitleEffectClaims.PeriodKey = field.NewString(tableName, "period_key") + _userTitleEffectClaims.ClaimCount = field.NewInt32(tableName, "claim_count") + _userTitleEffectClaims.LastClaimAt = field.NewTime(tableName, "last_claim_at") + _userTitleEffectClaims.CreatedAt = field.NewTime(tableName, "created_at") + + _userTitleEffectClaims.fillFieldMap() + + return _userTitleEffectClaims +} + +// userTitleEffectClaims 领取型权益限流表 +type userTitleEffectClaims struct { + userTitleEffectClaimsDo + + ALL field.Asterisk + ID field.Int64 // 头衔领取型权益限流主键ID + UserID field.Int64 // 用户ID(users.id) + TitleID field.Int64 // 头衔ID(system_titles.id) + EffectType field.Int32 // 效果类型(与system_title_effects.effect_type一致) + TargetTemplateID field.Int64 // 目标模板ID(券模板/卡模板等) + PeriodKey field.String // 周期键(如每日YYYYMMDD、每周YYYYWW、每月YYYYMM) + ClaimCount field.Int32 // 当期已领取次数 + LastClaimAt field.Time // 最近领取时间 + CreatedAt field.Time // 创建时间 + + fieldMap map[string]field.Expr +} + +func (u userTitleEffectClaims) Table(newTableName string) *userTitleEffectClaims { + u.userTitleEffectClaimsDo.UseTable(newTableName) + return u.updateTableName(newTableName) +} + +func (u userTitleEffectClaims) As(alias string) *userTitleEffectClaims { + u.userTitleEffectClaimsDo.DO = *(u.userTitleEffectClaimsDo.As(alias).(*gen.DO)) + return u.updateTableName(alias) +} + +func (u *userTitleEffectClaims) updateTableName(table string) *userTitleEffectClaims { + u.ALL = field.NewAsterisk(table) + u.ID = field.NewInt64(table, "id") + u.UserID = field.NewInt64(table, "user_id") + u.TitleID = field.NewInt64(table, "title_id") + u.EffectType = field.NewInt32(table, "effect_type") + u.TargetTemplateID = field.NewInt64(table, "target_template_id") + u.PeriodKey = field.NewString(table, "period_key") + u.ClaimCount = field.NewInt32(table, "claim_count") + u.LastClaimAt = field.NewTime(table, "last_claim_at") + u.CreatedAt = field.NewTime(table, "created_at") + + u.fillFieldMap() + + return u +} + +func (u *userTitleEffectClaims) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := u.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (u *userTitleEffectClaims) fillFieldMap() { + u.fieldMap = make(map[string]field.Expr, 9) + u.fieldMap["id"] = u.ID + u.fieldMap["user_id"] = u.UserID + u.fieldMap["title_id"] = u.TitleID + u.fieldMap["effect_type"] = u.EffectType + u.fieldMap["target_template_id"] = u.TargetTemplateID + u.fieldMap["period_key"] = u.PeriodKey + u.fieldMap["claim_count"] = u.ClaimCount + u.fieldMap["last_claim_at"] = u.LastClaimAt + u.fieldMap["created_at"] = u.CreatedAt +} + +func (u userTitleEffectClaims) clone(db *gorm.DB) userTitleEffectClaims { + u.userTitleEffectClaimsDo.ReplaceConnPool(db.Statement.ConnPool) + return u +} + +func (u userTitleEffectClaims) replaceDB(db *gorm.DB) userTitleEffectClaims { + u.userTitleEffectClaimsDo.ReplaceDB(db) + return u +} + +type userTitleEffectClaimsDo struct{ gen.DO } + +func (u userTitleEffectClaimsDo) Debug() *userTitleEffectClaimsDo { + return u.withDO(u.DO.Debug()) +} + +func (u userTitleEffectClaimsDo) WithContext(ctx context.Context) *userTitleEffectClaimsDo { + return u.withDO(u.DO.WithContext(ctx)) +} + +func (u userTitleEffectClaimsDo) ReadDB() *userTitleEffectClaimsDo { + return u.Clauses(dbresolver.Read) +} + +func (u userTitleEffectClaimsDo) WriteDB() *userTitleEffectClaimsDo { + return u.Clauses(dbresolver.Write) +} + +func (u userTitleEffectClaimsDo) Session(config *gorm.Session) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Session(config)) +} + +func (u userTitleEffectClaimsDo) Clauses(conds ...clause.Expression) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Clauses(conds...)) +} + +func (u userTitleEffectClaimsDo) Returning(value interface{}, columns ...string) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Returning(value, columns...)) +} + +func (u userTitleEffectClaimsDo) Not(conds ...gen.Condition) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Not(conds...)) +} + +func (u userTitleEffectClaimsDo) Or(conds ...gen.Condition) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Or(conds...)) +} + +func (u userTitleEffectClaimsDo) Select(conds ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Select(conds...)) +} + +func (u userTitleEffectClaimsDo) Where(conds ...gen.Condition) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Where(conds...)) +} + +func (u userTitleEffectClaimsDo) Order(conds ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Order(conds...)) +} + +func (u userTitleEffectClaimsDo) Distinct(cols ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Distinct(cols...)) +} + +func (u userTitleEffectClaimsDo) Omit(cols ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Omit(cols...)) +} + +func (u userTitleEffectClaimsDo) Join(table schema.Tabler, on ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Join(table, on...)) +} + +func (u userTitleEffectClaimsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.LeftJoin(table, on...)) +} + +func (u userTitleEffectClaimsDo) RightJoin(table schema.Tabler, on ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.RightJoin(table, on...)) +} + +func (u userTitleEffectClaimsDo) Group(cols ...field.Expr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Group(cols...)) +} + +func (u userTitleEffectClaimsDo) Having(conds ...gen.Condition) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Having(conds...)) +} + +func (u userTitleEffectClaimsDo) Limit(limit int) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Limit(limit)) +} + +func (u userTitleEffectClaimsDo) Offset(offset int) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Offset(offset)) +} + +func (u userTitleEffectClaimsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Scopes(funcs...)) +} + +func (u userTitleEffectClaimsDo) Unscoped() *userTitleEffectClaimsDo { + return u.withDO(u.DO.Unscoped()) +} + +func (u userTitleEffectClaimsDo) Create(values ...*model.UserTitleEffectClaims) error { + if len(values) == 0 { + return nil + } + return u.DO.Create(values) +} + +func (u userTitleEffectClaimsDo) CreateInBatches(values []*model.UserTitleEffectClaims, batchSize int) error { + return u.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (u userTitleEffectClaimsDo) Save(values ...*model.UserTitleEffectClaims) error { + if len(values) == 0 { + return nil + } + return u.DO.Save(values) +} + +func (u userTitleEffectClaimsDo) First() (*model.UserTitleEffectClaims, error) { + if result, err := u.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.UserTitleEffectClaims), nil + } +} + +func (u userTitleEffectClaimsDo) Take() (*model.UserTitleEffectClaims, error) { + if result, err := u.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.UserTitleEffectClaims), nil + } +} + +func (u userTitleEffectClaimsDo) Last() (*model.UserTitleEffectClaims, error) { + if result, err := u.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.UserTitleEffectClaims), nil + } +} + +func (u userTitleEffectClaimsDo) Find() ([]*model.UserTitleEffectClaims, error) { + result, err := u.DO.Find() + return result.([]*model.UserTitleEffectClaims), err +} + +func (u userTitleEffectClaimsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.UserTitleEffectClaims, err error) { + buf := make([]*model.UserTitleEffectClaims, 0, batchSize) + err = u.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (u userTitleEffectClaimsDo) FindInBatches(result *[]*model.UserTitleEffectClaims, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return u.DO.FindInBatches(result, batchSize, fc) +} + +func (u userTitleEffectClaimsDo) Attrs(attrs ...field.AssignExpr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Attrs(attrs...)) +} + +func (u userTitleEffectClaimsDo) Assign(attrs ...field.AssignExpr) *userTitleEffectClaimsDo { + return u.withDO(u.DO.Assign(attrs...)) +} + +func (u userTitleEffectClaimsDo) Joins(fields ...field.RelationField) *userTitleEffectClaimsDo { + for _, _f := range fields { + u = *u.withDO(u.DO.Joins(_f)) + } + return &u +} + +func (u userTitleEffectClaimsDo) Preload(fields ...field.RelationField) *userTitleEffectClaimsDo { + for _, _f := range fields { + u = *u.withDO(u.DO.Preload(_f)) + } + return &u +} + +func (u userTitleEffectClaimsDo) FirstOrInit() (*model.UserTitleEffectClaims, error) { + if result, err := u.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.UserTitleEffectClaims), nil + } +} + +func (u userTitleEffectClaimsDo) FirstOrCreate() (*model.UserTitleEffectClaims, error) { + if result, err := u.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.UserTitleEffectClaims), nil + } +} + +func (u userTitleEffectClaimsDo) FindByPage(offset int, limit int) (result []*model.UserTitleEffectClaims, count int64, err error) { + result, err = u.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = u.Offset(-1).Limit(-1).Count() + return +} + +func (u userTitleEffectClaimsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = u.Count() + if err != nil { + return + } + + err = u.Offset(offset).Limit(limit).Scan(result) + return +} + +func (u userTitleEffectClaimsDo) Scan(result interface{}) (err error) { + return u.DO.Scan(result) +} + +func (u userTitleEffectClaimsDo) Delete(models ...*model.UserTitleEffectClaims) (result gen.ResultInfo, err error) { + return u.DO.Delete(models) +} + +func (u *userTitleEffectClaimsDo) withDO(do gen.Dao) *userTitleEffectClaimsDo { + u.DO = *do.(*gen.DO) + return u +} diff --git a/internal/repository/mysql/dao/user_titles.gen.go b/internal/repository/mysql/dao/user_titles.gen.go new file mode 100644 index 0000000..f51829e --- /dev/null +++ b/internal/repository/mysql/dao/user_titles.gen.go @@ -0,0 +1,352 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package dao + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "gorm.io/gen" + "gorm.io/gen/field" + + "gorm.io/plugin/dbresolver" + + "bindbox-game/internal/repository/mysql/model" +) + +func newUserTitles(db *gorm.DB, opts ...gen.DOOption) userTitles { + _userTitles := userTitles{} + + _userTitles.userTitlesDo.UseDB(db, opts...) + _userTitles.userTitlesDo.UseModel(&model.UserTitles{}) + + tableName := _userTitles.userTitlesDo.TableName() + _userTitles.ALL = field.NewAsterisk(tableName) + _userTitles.ID = field.NewInt64(tableName, "id") + _userTitles.UserID = field.NewInt64(tableName, "user_id") + _userTitles.TitleID = field.NewInt64(tableName, "title_id") + _userTitles.Active = field.NewInt32(tableName, "active") + _userTitles.ObtainedAt = field.NewTime(tableName, "obtained_at") + _userTitles.ExpiresAt = field.NewTime(tableName, "expires_at") + _userTitles.Source = field.NewString(tableName, "source") + _userTitles.Remark = field.NewString(tableName, "remark") + _userTitles.CreatedAt = field.NewTime(tableName, "created_at") + + _userTitles.fillFieldMap() + + return _userTitles +} + +// userTitles 用户持有头衔表 +type userTitles struct { + userTitlesDo + + ALL field.Asterisk + ID field.Int64 // 用户持有头衔主键ID + UserID field.Int64 // 用户ID(users.id) + TitleID field.Int64 // 头衔ID(system_titles.id) + Active field.Int32 // 是否激活:1激活 0停用 + ObtainedAt field.Time // 获得时间 + ExpiresAt field.Time // 过期时间(可空) + Source field.String // 来源标识(活动、运营发放等) + Remark field.String // 备注信息 + CreatedAt field.Time // 创建时间 + + fieldMap map[string]field.Expr +} + +func (u userTitles) Table(newTableName string) *userTitles { + u.userTitlesDo.UseTable(newTableName) + return u.updateTableName(newTableName) +} + +func (u userTitles) As(alias string) *userTitles { + u.userTitlesDo.DO = *(u.userTitlesDo.As(alias).(*gen.DO)) + return u.updateTableName(alias) +} + +func (u *userTitles) updateTableName(table string) *userTitles { + u.ALL = field.NewAsterisk(table) + u.ID = field.NewInt64(table, "id") + u.UserID = field.NewInt64(table, "user_id") + u.TitleID = field.NewInt64(table, "title_id") + u.Active = field.NewInt32(table, "active") + u.ObtainedAt = field.NewTime(table, "obtained_at") + u.ExpiresAt = field.NewTime(table, "expires_at") + u.Source = field.NewString(table, "source") + u.Remark = field.NewString(table, "remark") + u.CreatedAt = field.NewTime(table, "created_at") + + u.fillFieldMap() + + return u +} + +func (u *userTitles) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := u.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (u *userTitles) fillFieldMap() { + u.fieldMap = make(map[string]field.Expr, 9) + u.fieldMap["id"] = u.ID + u.fieldMap["user_id"] = u.UserID + u.fieldMap["title_id"] = u.TitleID + u.fieldMap["active"] = u.Active + u.fieldMap["obtained_at"] = u.ObtainedAt + u.fieldMap["expires_at"] = u.ExpiresAt + u.fieldMap["source"] = u.Source + u.fieldMap["remark"] = u.Remark + u.fieldMap["created_at"] = u.CreatedAt +} + +func (u userTitles) clone(db *gorm.DB) userTitles { + u.userTitlesDo.ReplaceConnPool(db.Statement.ConnPool) + return u +} + +func (u userTitles) replaceDB(db *gorm.DB) userTitles { + u.userTitlesDo.ReplaceDB(db) + return u +} + +type userTitlesDo struct{ gen.DO } + +func (u userTitlesDo) Debug() *userTitlesDo { + return u.withDO(u.DO.Debug()) +} + +func (u userTitlesDo) WithContext(ctx context.Context) *userTitlesDo { + return u.withDO(u.DO.WithContext(ctx)) +} + +func (u userTitlesDo) ReadDB() *userTitlesDo { + return u.Clauses(dbresolver.Read) +} + +func (u userTitlesDo) WriteDB() *userTitlesDo { + return u.Clauses(dbresolver.Write) +} + +func (u userTitlesDo) Session(config *gorm.Session) *userTitlesDo { + return u.withDO(u.DO.Session(config)) +} + +func (u userTitlesDo) Clauses(conds ...clause.Expression) *userTitlesDo { + return u.withDO(u.DO.Clauses(conds...)) +} + +func (u userTitlesDo) Returning(value interface{}, columns ...string) *userTitlesDo { + return u.withDO(u.DO.Returning(value, columns...)) +} + +func (u userTitlesDo) Not(conds ...gen.Condition) *userTitlesDo { + return u.withDO(u.DO.Not(conds...)) +} + +func (u userTitlesDo) Or(conds ...gen.Condition) *userTitlesDo { + return u.withDO(u.DO.Or(conds...)) +} + +func (u userTitlesDo) Select(conds ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Select(conds...)) +} + +func (u userTitlesDo) Where(conds ...gen.Condition) *userTitlesDo { + return u.withDO(u.DO.Where(conds...)) +} + +func (u userTitlesDo) Order(conds ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Order(conds...)) +} + +func (u userTitlesDo) Distinct(cols ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Distinct(cols...)) +} + +func (u userTitlesDo) Omit(cols ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Omit(cols...)) +} + +func (u userTitlesDo) Join(table schema.Tabler, on ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Join(table, on...)) +} + +func (u userTitlesDo) LeftJoin(table schema.Tabler, on ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.LeftJoin(table, on...)) +} + +func (u userTitlesDo) RightJoin(table schema.Tabler, on ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.RightJoin(table, on...)) +} + +func (u userTitlesDo) Group(cols ...field.Expr) *userTitlesDo { + return u.withDO(u.DO.Group(cols...)) +} + +func (u userTitlesDo) Having(conds ...gen.Condition) *userTitlesDo { + return u.withDO(u.DO.Having(conds...)) +} + +func (u userTitlesDo) Limit(limit int) *userTitlesDo { + return u.withDO(u.DO.Limit(limit)) +} + +func (u userTitlesDo) Offset(offset int) *userTitlesDo { + return u.withDO(u.DO.Offset(offset)) +} + +func (u userTitlesDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *userTitlesDo { + return u.withDO(u.DO.Scopes(funcs...)) +} + +func (u userTitlesDo) Unscoped() *userTitlesDo { + return u.withDO(u.DO.Unscoped()) +} + +func (u userTitlesDo) Create(values ...*model.UserTitles) error { + if len(values) == 0 { + return nil + } + return u.DO.Create(values) +} + +func (u userTitlesDo) CreateInBatches(values []*model.UserTitles, batchSize int) error { + return u.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (u userTitlesDo) Save(values ...*model.UserTitles) error { + if len(values) == 0 { + return nil + } + return u.DO.Save(values) +} + +func (u userTitlesDo) First() (*model.UserTitles, error) { + if result, err := u.DO.First(); err != nil { + return nil, err + } else { + return result.(*model.UserTitles), nil + } +} + +func (u userTitlesDo) Take() (*model.UserTitles, error) { + if result, err := u.DO.Take(); err != nil { + return nil, err + } else { + return result.(*model.UserTitles), nil + } +} + +func (u userTitlesDo) Last() (*model.UserTitles, error) { + if result, err := u.DO.Last(); err != nil { + return nil, err + } else { + return result.(*model.UserTitles), nil + } +} + +func (u userTitlesDo) Find() ([]*model.UserTitles, error) { + result, err := u.DO.Find() + return result.([]*model.UserTitles), err +} + +func (u userTitlesDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.UserTitles, err error) { + buf := make([]*model.UserTitles, 0, batchSize) + err = u.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (u userTitlesDo) FindInBatches(result *[]*model.UserTitles, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return u.DO.FindInBatches(result, batchSize, fc) +} + +func (u userTitlesDo) Attrs(attrs ...field.AssignExpr) *userTitlesDo { + return u.withDO(u.DO.Attrs(attrs...)) +} + +func (u userTitlesDo) Assign(attrs ...field.AssignExpr) *userTitlesDo { + return u.withDO(u.DO.Assign(attrs...)) +} + +func (u userTitlesDo) Joins(fields ...field.RelationField) *userTitlesDo { + for _, _f := range fields { + u = *u.withDO(u.DO.Joins(_f)) + } + return &u +} + +func (u userTitlesDo) Preload(fields ...field.RelationField) *userTitlesDo { + for _, _f := range fields { + u = *u.withDO(u.DO.Preload(_f)) + } + return &u +} + +func (u userTitlesDo) FirstOrInit() (*model.UserTitles, error) { + if result, err := u.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*model.UserTitles), nil + } +} + +func (u userTitlesDo) FirstOrCreate() (*model.UserTitles, error) { + if result, err := u.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*model.UserTitles), nil + } +} + +func (u userTitlesDo) FindByPage(offset int, limit int) (result []*model.UserTitles, count int64, err error) { + result, err = u.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = u.Offset(-1).Limit(-1).Count() + return +} + +func (u userTitlesDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = u.Count() + if err != nil { + return + } + + err = u.Offset(offset).Limit(limit).Scan(result) + return +} + +func (u userTitlesDo) Scan(result interface{}) (err error) { + return u.DO.Scan(result) +} + +func (u userTitlesDo) Delete(models ...*model.UserTitles) (result gen.ResultInfo, err error) { + return u.DO.Delete(models) +} + +func (u *userTitlesDo) withDO(do gen.Dao) *userTitlesDo { + u.DO = *do.(*gen.DO) + return u +} diff --git a/internal/repository/mysql/model/system_title_effects.gen.go b/internal/repository/mysql/model/system_title_effects.gen.go new file mode 100644 index 0000000..2be778a --- /dev/null +++ b/internal/repository/mysql/model/system_title_effects.gen.go @@ -0,0 +1,30 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameSystemTitleEffects = "system_title_effects" + +// SystemTitleEffects 头衔效果配置表 +type SystemTitleEffects struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:头衔效果配置主键ID" json:"id"` // 头衔效果配置主键ID + TitleID int64 `gorm:"column:title_id;not null;comment:归属头衔ID(system_titles.id)" json:"title_id"` // 归属头衔ID(system_titles.id) + EffectType int32 `gorm:"column:effect_type;not null;comment:效果类型:1领券 2抽奖折扣 3签到倍数 4领道具卡 5概率加成 6奖品双倍概率" json:"effect_type"` // 效果类型:1领券 2抽奖折扣 3签到倍数 4领道具卡 5概率加成 6奖品双倍概率 + ParamsJSON string `gorm:"column:params_json;not null;comment:效果参数JSON(倍数、概率、折扣值、模板ID、频次等)" json:"params_json"` // 效果参数JSON(倍数、概率、折扣值、模板ID、频次等) + StackingStrategy int32 `gorm:"column:stacking_strategy;not null;comment:叠加策略:0最大值 1累加封顶 2首个匹配" json:"stacking_strategy"` // 叠加策略:0最大值 1累加封顶 2首个匹配 + CapValueX1000 int32 `gorm:"column:cap_value_x1000;comment:封顶值(千分比;如倍数或概率总封顶)" json:"cap_value_x1000"` // 封顶值(千分比;如倍数或概率总封顶) + ScopesJSON string `gorm:"column:scopes_json;comment:作用范围JSON(活动/期/分类等)" json:"scopes_json"` // 作用范围JSON(活动/期/分类等) + Sort int32 `gorm:"column:sort;not null;comment:效果排序(同一头衔内)" json:"sort"` // 效果排序(同一头衔内) + Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 0停用" json:"status"` // 状态:1启用 0停用 + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 +} + +// TableName SystemTitleEffects's table name +func (*SystemTitleEffects) TableName() string { + return TableNameSystemTitleEffects +} diff --git a/internal/repository/mysql/model/system_titles.gen.go b/internal/repository/mysql/model/system_titles.gen.go new file mode 100644 index 0000000..5a8820b --- /dev/null +++ b/internal/repository/mysql/model/system_titles.gen.go @@ -0,0 +1,28 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameSystemTitles = "system_titles" + +// SystemTitles 头衔模板表 +type SystemTitles struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:头衔模板主键ID" json:"id"` // 头衔模板主键ID + Name string `gorm:"column:name;not null;comment:头衔名称(唯一)" json:"name"` // 头衔名称(唯一) + Description string `gorm:"column:description;comment:头衔描述" json:"description"` // 头衔描述 + Status int32 `gorm:"column:status;not null;default:1;comment:状态:1启用 0停用" json:"status"` // 状态:1启用 0停用 + ObtainRulesJSON string `gorm:"column:obtain_rules_json;comment:获得条件规则JSON(任务、等级、付费等)" json:"obtain_rules_json"` // 获得条件规则JSON(任务、等级、付费等) + ScopesJSON string `gorm:"column:scopes_json;comment:作用范围配置JSON(活动/期/分类等)" json:"scopes_json"` // 作用范围配置JSON(活动/期/分类等) + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 +} + +// TableName SystemTitles's table name +func (*SystemTitles) TableName() string { + return TableNameSystemTitles +} diff --git a/internal/repository/mysql/model/user_title_effect_claims.gen.go b/internal/repository/mysql/model/user_title_effect_claims.gen.go new file mode 100644 index 0000000..57e3b76 --- /dev/null +++ b/internal/repository/mysql/model/user_title_effect_claims.gen.go @@ -0,0 +1,29 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameUserTitleEffectClaims = "user_title_effect_claims" + +// UserTitleEffectClaims 领取型权益限流表 +type UserTitleEffectClaims struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:头衔领取型权益限流主键ID" json:"id"` // 头衔领取型权益限流主键ID + UserID int64 `gorm:"column:user_id;not null;comment:用户ID(users.id)" json:"user_id"` // 用户ID(users.id) + TitleID int64 `gorm:"column:title_id;not null;comment:头衔ID(system_titles.id)" json:"title_id"` // 头衔ID(system_titles.id) + EffectType int32 `gorm:"column:effect_type;not null;comment:效果类型(与system_title_effects.effect_type一致)" json:"effect_type"` // 效果类型(与system_title_effects.effect_type一致) + TargetTemplateID int64 `gorm:"column:target_template_id;comment:目标模板ID(券模板/卡模板等)" json:"target_template_id"` // 目标模板ID(券模板/卡模板等) + PeriodKey string `gorm:"column:period_key;not null;comment:周期键(如每日YYYYMMDD、每周YYYYWW、每月YYYYMM)" json:"period_key"` // 周期键(如每日YYYYMMDD、每周YYYYWW、每月YYYYMM) + ClaimCount int32 `gorm:"column:claim_count;not null;comment:当期已领取次数" json:"claim_count"` // 当期已领取次数 + LastClaimAt time.Time `gorm:"column:last_claim_at;comment:最近领取时间" json:"last_claim_at"` // 最近领取时间 + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 +} + +// TableName UserTitleEffectClaims's table name +func (*UserTitleEffectClaims) TableName() string { + return TableNameUserTitleEffectClaims +} diff --git a/internal/repository/mysql/model/user_titles.gen.go b/internal/repository/mysql/model/user_titles.gen.go new file mode 100644 index 0000000..79d0f23 --- /dev/null +++ b/internal/repository/mysql/model/user_titles.gen.go @@ -0,0 +1,29 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameUserTitles = "user_titles" + +// UserTitles 用户持有头衔表 +type UserTitles struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:用户持有头衔主键ID" json:"id"` // 用户持有头衔主键ID + UserID int64 `gorm:"column:user_id;not null;comment:用户ID(users.id)" json:"user_id"` // 用户ID(users.id) + TitleID int64 `gorm:"column:title_id;not null;comment:头衔ID(system_titles.id)" json:"title_id"` // 头衔ID(system_titles.id) + Active int32 `gorm:"column:active;not null;default:1;comment:是否激活:1激活 0停用" json:"active"` // 是否激活:1激活 0停用 + ObtainedAt time.Time `gorm:"column:obtained_at;not null;default:CURRENT_TIMESTAMP(3);comment:获得时间" json:"obtained_at"` // 获得时间 + ExpiresAt time.Time `gorm:"column:expires_at;comment:过期时间(可空)" json:"expires_at"` // 过期时间(可空) + Source string `gorm:"column:source;comment:来源标识(活动、运营发放等)" json:"source"` // 来源标识(活动、运营发放等) + Remark string `gorm:"column:remark;comment:备注信息" json:"remark"` // 备注信息 + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 +} + +// TableName UserTitles's table name +func (*UserTitles) TableName() string { + return TableNameUserTitles +} diff --git a/internal/router/router.go b/internal/router/router.go index c2a86f5..37f3ed7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -11,9 +11,8 @@ import ( "bindbox-game/internal/dblogger" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" - "bindbox-game/internal/pkg/startup" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/router/interceptor" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/router/interceptor" "github.com/pkg/errors" ) @@ -47,15 +46,14 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { commonHandler := commonapi.New(logger, db) intc := interceptor.New(logger, db) - // 管理端非认证接口路由组 - adminNonAuthApiRouter := mux.Group("/api/admin") - { - adminNonAuthApiRouter.GET("/license/status", func(ctx core.Context) { - ctx.Payload(startup.Info()) - }) - - adminNonAuthApiRouter.POST("/login", adminHandler.Login()) // 登录 - } + // 管理端非认证接口路由组 + adminNonAuthApiRouter := mux.Group("/api/admin") + { + adminNonAuthApiRouter.POST("/login", adminHandler.Login()) // 登录 + // 开发便捷:无认证的初始化接口(仅用于快速配置,生产环境请关闭或加权限) + adminNonAuthApiRouter.POST("/system_titles/seed_default", adminHandler.SeedDefaultTitles()) + adminNonAuthApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) + } // 管理端认证接口路由组 adminAuthApiRouter := mux.Group("/api/admin", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) @@ -63,10 +61,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { // 系统管理接口(为前端模板路径兼容,挂载到 /api) systemApiRouter := mux.Group("/api", core.WrapAuthHandler(intc.AdminTokenAuthVerify)) { - adminAuthApiRouter.POST("/create", adminHandler.CreateAdmin()) - adminAuthApiRouter.PUT("/:id", adminHandler.ModifyAdmin()) - adminAuthApiRouter.POST("/delete", adminHandler.DeleteAdmin()) - adminAuthApiRouter.GET("/list", adminHandler.PageList()) + // 管理员账号维护接口移除(未被前端使用) adminAuthApiRouter.GET("/activity_categories", adminHandler.ListActivityCategories()) adminAuthApiRouter.POST("/activities", adminHandler.CreateActivity()) @@ -127,6 +122,16 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminAuthApiRouter.POST("/users/:user_id/rewards/grant", adminHandler.GrantReward()) 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.POST("/system_item_cards", adminHandler.CreateSystemItemCard()) @@ -141,6 +146,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { adminAuthApiRouter.POST("/users/:user_id/item_cards", adminHandler.AssignUserItemCard()) // 通用上传 systemApiRouter.POST("/common/upload/wangeditor", commonHandler.UploadWangEditorImage()) + systemApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) } // 系统管理:用户/角色/菜单 diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index c667b18..373a5ec 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -34,6 +34,7 @@ type Service interface { GetIssueRandomCommitHistory(ctx context.Context, issueID int64) ([]*IssueRandomCommitment, error) ExecuteDraw(ctx context.Context, issueID int64) (*Receipt, error) + ExecuteDrawWithEffects(ctx context.Context, issueID int64, userID int64) (*Receipt, error) GetCategoryNames(ctx context.Context, ids []int64) (map[int64]string, error) } diff --git a/internal/service/activity/draw_with_effects.go b/internal/service/activity/draw_with_effects.go new file mode 100644 index 0000000..f5f1164 --- /dev/null +++ b/internal/service/activity/draw_with_effects.go @@ -0,0 +1,233 @@ +package activity + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "time" +) + +// ExecuteDrawWithEffects 执行抽奖(应用用户头衔效果:概率加成/双倍奖励) +// Params: +// - ctx: 上下文 +// - issueID: 期ID +// - userID: 用户ID +// Returns: +// - 抽奖收据(已按效果调整权重并处理双倍奖励) +func (s *service) ExecuteDrawWithEffects(ctx context.Context, issueID int64, userID int64) (*Receipt, error) { + cm, err := s.GetIssueRandomCommit(ctx, issueID) + if err != nil { + return nil, err + } + if cm == nil { + return nil, nil + } + master := unmaskSeed(cm.ServerSeedMaster, cm.IssueID, cm.StateVersion) + items, err := s.readDB.ActivityRewardSettings.WithContext(ctx). + Where(s.readDB.ActivityRewardSettings.IssueID.Eq(issueID)). + Order(s.readDB.ActivityRewardSettings.Sort). + Find() + if err != nil { + return nil, err + } + // 初始权重与快照 + var snapshot []ReceiptItem + baseWeights := make(map[int64]int32, len(items)) + for _, it := range items { + snapshot = append(snapshot, ReceiptItem{ID: it.ID, Name: it.Name, Weight: it.Weight, QuantityBefore: it.Quantity}) + if it.Weight > 0 && (it.Quantity == -1 || it.Quantity > 0) { + baseWeights[it.ID] = it.Weight + } + } + // 解析用户头衔效果(仅过滤到当前issue) + effects, err := s.readDB.SystemTitleEffects.WithContext(ctx). + Where(s.readDB.SystemTitleEffects.Status.Eq(1)). + Order(s.readDB.SystemTitleEffects.Sort). + Find() + if err != nil { + return nil, err + } + // 仅保留用户激活的头衔效果 + now := time.Now() + uts, err := s.readDB.UserTitles.WithContext(ctx). + Where(s.readDB.UserTitles.UserID.Eq(userID)). + Where(s.readDB.UserTitles.Active.Eq(1)). + Find() + if err != nil { + return nil, err + } + titleSet := make(map[int64]struct{}, len(uts)) + for _, ut := range uts { + if ut.ExpiresAt.IsZero() || ut.ExpiresAt.After(now) { + titleSet[ut.TitleID] = struct{}{} + } + } + + // 作用域过滤:issueID + type scopePayload struct { + IssueIDs []int64 `json:"issue_ids"` + Exclude struct{ IssueIDs []int64 `json:"issue_ids"` } `json:"exclude"` + } + + // 累计概率加成与双倍奖励参数 + boostPerItemX1000 := make(map[int64]int32) + var doubleChanceX1000 int32 + var doubleTargets map[int64]struct{} + + for _, ef := range effects { + if _, ok := titleSet[ef.TitleID]; !ok { + continue + } + // scope过滤 + if ef.ScopesJSON != "" { + var sc scopePayload + if err := json.Unmarshal([]byte(ef.ScopesJSON), &sc); err == nil { + if len(sc.Exclude.IssueIDs) > 0 { + for _, ex := range sc.Exclude.IssueIDs { + if ex == issueID { + continue + } + } + } + if len(sc.IssueIDs) > 0 { + matched := false + for _, id := range sc.IssueIDs { + if id == issueID { matched = true; break } + } + if !matched { continue } + } + } + } + switch ef.EffectType { + case 5: // 概率加成 + var p struct { + TargetPrizeIDs []int64 `json:"target_prize_ids"` + BoostX1000 int32 `json:"boost_x1000"` + CapX1000 *int32 `json:"cap_x1000"` + } + if err := json.Unmarshal([]byte(ef.ParamsJSON), &p); err != nil { + continue + } + // 累加或取最大(1累加封顶,0最大值,2首个匹配) + for _, tid := range p.TargetPrizeIDs { + curr := boostPerItemX1000[tid] + switch ef.StackingStrategy { + case 0: // max_only + if p.BoostX1000 > curr { boostPerItemX1000[tid] = p.BoostX1000 } + case 1: // sum_with_cap + nxt := curr + p.BoostX1000 + if p.CapX1000 != nil && nxt > *p.CapX1000 { nxt = *p.CapX1000 } + boostPerItemX1000[tid] = nxt + case 2: // first_match + if curr == 0 { boostPerItemX1000[tid] = p.BoostX1000 } + default: + nxt := curr + p.BoostX1000 + cap := ef.CapValueX1000 + if cap > 0 && nxt > cap { nxt = cap } + boostPerItemX1000[tid] = nxt + } + } + case 6: // 双倍奖励卡 + var p struct { + TargetPrizeIDs []int64 `json:"target_prize_ids"` + ChanceX1000 int32 `json:"chance_x1000"` + PeriodCapTimes *int32 `json:"period_cap_times"` + } + if err := json.Unmarshal([]byte(ef.ParamsJSON), &p); err != nil { + continue + } + // 合并双倍奖励命中率(sum_with_cap 缺省) + if doubleTargets == nil { doubleTargets = make(map[int64]struct{}) } + for _, tid := range p.TargetPrizeIDs { doubleTargets[tid] = struct{}{} } + // 累加并封顶 + doubleChanceX1000 += p.ChanceX1000 + cap := ef.CapValueX1000 + if cap > 0 && doubleChanceX1000 > cap { doubleChanceX1000 = cap } + } + } + + // 应用概率加成:调整权重 + var total int64 + adjWeights := make(map[int64]int32, len(baseWeights)) + for _, it := range items { + if it.Weight <= 0 || !(it.Quantity == -1 || it.Quantity > 0) { continue } + w := baseWeights[it.ID] + if inc, ok := boostPerItemX1000[it.ID]; ok && inc > 0 { + // w * (1 + inc/1000) + w = int32(int64(w) * (1000 + int64(inc)) / 1000) + if w < 1 { w = 1 } + } + adjWeights[it.ID] = w + total += int64(w) + } + if total <= 0 { return nil, nil } + + // 随机选择(按调整后权重) + drawId := time.Now().UnixNano() + clientSeed := make([]byte, 32) + _, _ = rand.Read(clientSeed) + nonce := uint64(1) + subInput := make([]byte, 16) + binary.BigEndian.PutUint64(subInput[:8], uint64(issueID)) + binary.BigEndian.PutUint64(subInput[8:16], uint64(drawId)) + mac := hmac.New(sha256.New, master) + mac.Write(subInput) + serverSubSeed := mac.Sum(nil) + enc := encodeMessage(cm.AlgoVersion, issueID, drawId, 0, clientSeed, nonce, cm.ItemsRoot[:], uint64(total)) + entropy := hmacSha256(serverSubSeed, enc) + pos, proof := rejectSample(entropy, serverSubSeed, enc, uint64(total)) + var acc uint64 + var selIndex int + var selID int64 + var iIdx int + for i, it := range items { + if it.Weight <= 0 || !(it.Quantity == -1 || it.Quantity > 0) { continue } + w := uint64(adjWeights[it.ID]) + if pos < acc+w { + selIndex = i + selID = it.ID + iIdx = i + break + } + acc += w + } + // 双倍奖励判定(若目标命中) + rewardMultiplierX1000 := int32(1000) + if selID > 0 && doubleChanceX1000 > 0 { + _, eligible := doubleTargets[selID] + if eligible { + // 使用另一次哈希派生随机判定 + check := hmacSha256(serverSubSeed, []byte("double:")) + rv := int32(binary.BigEndian.Uint32(check[:4]) % 1000) + if rv < doubleChanceX1000 { + rewardMultiplierX1000 = 2000 + } + } + } + rec := &Receipt{ + AlgoVersion: cm.AlgoVersion, + RoundId: issueID, + DrawId: drawId, + ClientId: 0, + Timestamp: time.Now().UnixMilli(), + ServerSeedHash: cm.ServerSeedHash[:], + ServerSubSeed: serverSubSeed, + ClientSeed: clientSeed, + Nonce: nonce, + Items: snapshot, + ItemsRoot: cm.ItemsRoot[:], + WeightsTotal: uint64(total), + SelectedIndex: selIndex, + SelectedItemId: selID, + RandProof: proof, + Signature: nil, + } + // 将倍数编码回选中项的名称后缀以便上层识别(非侵入式) + if iIdx >= 0 && iIdx < len(rec.Items) && rewardMultiplierX1000 > 1000 { + rec.Items[iIdx].Name = rec.Items[iIdx].Name + "(x2)" + } + return rec, nil +} \ No newline at end of file diff --git a/internal/service/title/effects_resolver.go b/internal/service/title/effects_resolver.go new file mode 100644 index 0000000..0e7e0b9 --- /dev/null +++ b/internal/service/title/effects_resolver.go @@ -0,0 +1,177 @@ +package title + +import ( + "context" + "encoding/json" + "time" + + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" +) + +// Service 提供头衔效果解析与领取型权益限流的服务 +// Params: +// - ctx: 上下文 +// - 依赖通过构造函数注入 +// Returns: +// - 头衔相关功能的服务实例 +type Service interface { + // ResolveActiveEffects 解析用户在指定上下文(issue/activity/category)下的激活头衔效果 + // Params: + // - ctx: 上下文 + // - userID: 用户ID + // - scope: 事件作用域(可空字段用于精细过滤) + // Returns: + // - 解析后的效果列表(已按scopes过滤并仅保留启用效果) + ResolveActiveEffects(ctx context.Context, userID int64, scope EffectScope) ([]*model.SystemTitleEffects, error) +} + +type service struct { + logger logger.CustomLogger + readDB *dao.Query + writeDB *dao.Query +} + +// New 创建头衔服务实例 +// Params: +// - l: 日志器 +// - db: 数据库仓库 +// Returns: +// - 头衔服务实例 +func New(l logger.CustomLogger, db mysql.Repo) Service { + return &service{ + logger: l, + readDB: dao.Use(db.GetDbR()), + writeDB: dao.Use(db.GetDbW()), + } +} + +// EffectScope 事件上下文作用域 +// 用于按活动/期/分类过滤效果生效范围 +type EffectScope struct { + ActivityID *int64 + IssueID *int64 + ActivityCategory *int64 +} + +// scopesPayload 用于解析SystemTitleEffects.ScopesJSON +type scopesPayload struct { + ActivityIDs []int64 `json:"activity_ids"` + IssueIDs []int64 `json:"issue_ids"` + CategoryIDs []int64 `json:"category_ids"` + Exclude struct { + ActivityIDs []int64 `json:"activity_ids"` + IssueIDs []int64 `json:"issue_ids"` + CategoryIDs []int64 `json:"category_ids"` + } `json:"exclude"` +} + +// ResolveActiveEffects 查询用户激活头衔并解析效果,返回在给定scope内生效的效果 +// Params: +// - ctx: 上下文 +// - userID: 用户ID +// - scope: 事件作用域 +// Returns: +// - 生效效果列表 +func (s *service) ResolveActiveEffects(ctx context.Context, userID int64, scope EffectScope) ([]*model.SystemTitleEffects, error) { + now := time.Now() + titles, err := s.readDB.UserTitles.WithContext(ctx). + Where(s.readDB.UserTitles.UserID.Eq(userID)). + Where(s.readDB.UserTitles.Active.Eq(1)). + Find() + if err != nil { + return nil, err + } + if len(titles) == 0 { + return []*model.SystemTitleEffects{}, nil + } + var selID int64 + var selAt time.Time + for _, ut := range titles { + if ut.ExpiresAt.IsZero() || ut.ExpiresAt.After(now) { + if selID == 0 || ut.ObtainedAt.After(selAt) { + selID = ut.TitleID + selAt = ut.ObtainedAt + } + } + } + if selID == 0 { + return []*model.SystemTitleEffects{}, nil + } + effects, err := s.readDB.SystemTitleEffects.WithContext(ctx). + Where(s.readDB.SystemTitleEffects.TitleID.Eq(selID)). + Where(s.readDB.SystemTitleEffects.Status.Eq(1)). + Order(s.readDB.SystemTitleEffects.Sort). + Find() + if err != nil { + return nil, err + } + if len(effects) == 0 { + return []*model.SystemTitleEffects{}, nil + } + var result []*model.SystemTitleEffects + for _, ef := range effects { + if ef.ScopesJSON == "" { + result = append(result, ef) + continue + } + var sc scopesPayload + if err := json.Unmarshal([]byte(ef.ScopesJSON), &sc); err != nil { + // 解析失败时按“全局生效”处理 + result = append(result, ef) + continue + } + if !scopeMatch(scope, sc) { + continue + } + result = append(result, ef) + } + return result, nil +} + +// scopeMatch 判断效果scope是否命中当前事件上下文 +// Params: +// - scope: 事件作用域 +// - sc: 效果配置的scope载荷 +// Returns: +// - 是否命中 +func scopeMatch(scope EffectScope, sc scopesPayload) bool { + // 排除优先 + if scope.ActivityID != nil && containsInt64(sc.Exclude.ActivityIDs, *scope.ActivityID) { + return false + } + if scope.IssueID != nil && containsInt64(sc.Exclude.IssueIDs, *scope.IssueID) { + return false + } + if scope.ActivityCategory != nil && containsInt64(sc.Exclude.CategoryIDs, *scope.ActivityCategory) { + return false + } + // 包含判定(未配置即视为全局) + if scope.ActivityID != nil && len(sc.ActivityIDs) > 0 && !containsInt64(sc.ActivityIDs, *scope.ActivityID) { + return false + } + if scope.IssueID != nil && len(sc.IssueIDs) > 0 && !containsInt64(sc.IssueIDs, *scope.IssueID) { + return false + } + if scope.ActivityCategory != nil && len(sc.CategoryIDs) > 0 && !containsInt64(sc.CategoryIDs, *scope.ActivityCategory) { + return false + } + return true +} + +// containsInt64 判断切片是否包含指定值 +// Params: +// - arr: 切片 +// - v: 值 +// Returns: +// - 是否包含 +func containsInt64(arr []int64, v int64) bool { + for _, x := range arr { + if x == v { + return true + } + } + return false +} \ No newline at end of file diff --git a/logs/mini-chat-access.log b/logs/mini-chat-access.log index 8e643f8..c934ede 100644 --- a/logs/mini-chat-access.log +++ b/logs/mini-chat-access.log @@ -7,3 +7,5 @@ {"level":"fatal","time":"2025-11-15 12:26:06","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} {"level":"fatal","time":"2025-11-15 12:28:06","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} {"level":"fatal","time":"2025-11-15 13:05:26","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} +{"level":"fatal","time":"2025-11-15 23:04:52","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} +{"level":"fatal","time":"2025-11-16 00:14:50","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"}