feat(称号系统): 新增称号管理功能与抽奖效果集成
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 39s

- 新增系统称号模板与效果配置表及相关CRUD接口
- 实现用户称号分配与抽奖效果应用逻辑
- 优化抽奖接口支持用户ID参数以应用称号效果
- 新增称号管理前端页面与分配功能
- 修复Windows时区错误与JSON字段初始化问题
- 移除无用管理接口代码并更新文档说明
This commit is contained in:
邹方成 2025-11-16 11:37:40 +08:00
parent 81e2fb5a75
commit 8141a47690
48 changed files with 4419 additions and 426 deletions

View File

@ -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
确认后我将实施前端下拉改造与表单说明文本,并保持后端兼容。

View File

@ -0,0 +1,53 @@
## 问题概览
- 缺失模块:`@/mock/temp/commentDetail` 导致 TS2307src/components/business/comment-widget/index.vue:46
- 事件类型不匹配:`emit('search', ...)` 未在 `defineEmits` 中声明载荷activity-search、player-search
- 缺少类型声明:`crypto-js/md5` 导致 TS7016login
- 返回类型不一致:登录接口不含 `refreshToken` 导致 TS2339login
- 表格数据类型为 `unknown[]` 与组件期望 `Record<string, any>[]` 不匹配guild、banner、product
## 修复方案(不改接口协议,最小改动)
- 缺失模块补齐:新增 `web/admin/src/mock/temp/commentDetail.ts`,导出 `Comment` 类型与 `commentList: Ref<Comment[]>`
- 使 `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<Api.Common.PaginatedResponse<...>>`,使 `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<BannerItem>``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<string, any>[]"`(不推荐,先按注解方案处理)。
## 具体改动明细
- 新增 `src/mock/temp/commentDetail.ts`
- 导出 `export interface Comment { id:number; author:string; content:string; timestamp:string; replies: Comment[] }`
- `export const commentList = ref<Comment[]>([])`(保持与组件 `comments.value.push(...)` 一致)。
- 更新事件声明:
- `activity-search.vue` `defineEmits<Emits>()` 中将 `(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<T>`,视图层的 `apiFn` 直接返回该类型,减少重复 `then` 转换与类型注解。
- 为常见搜索子组件抽取 `Emits` 模板,避免事件签名遗漏。

View File

@ -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增强健壮性。确认后我将按上述步骤实施并提交更改、完成打包与验证。

View File

@ -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"}` 或按你的要求填充。
确认后我将立即修复代码、重新初始化并验证页面数据。

View File

@ -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: <Operations>, menu_id: <Titles> }`
- 验证后端数据
- `GET /api/admin/system_titles?page=1&page_size=20`,请求头加 `Authorization: Bearer <token>`
- 期望:`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 条。
确认后我将按照以上步骤执行初始化与验证,确保页面数据正常展示。

View File

@ -0,0 +1,46 @@
## 字段与行为详解(以 effect_type=6 双倍概率为例)
- 目标IDtarget_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:163internal/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` 进行绑定映射。

View File

@ -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
## 回滚方案
- 路由注册保留注释备份;如发现遗漏使用,按文件恢复并重新注册路由
## 下一步请求
- 若确认以上清理项与步骤,开始实施清理并提交差异,附带验证报告与引用检索证明

View File

@ -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`

View File

@ -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% = 1002倍 = 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说明与示例。
* 测试用例清单与通过报告。
* 运营使用指引:效果配置、叠加策略与风险提示。

View File

@ -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`,并且将该值与用户标识作为消息输入的一部分来固定采样过程;承诺和收据的扩展确保服务端无法事后调参。

View File

@ -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=取最大MAX1=累加并封顶STACK_CAPPED2=按排序首个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叠加,最终乘积≤封顶
- 领取限流:同周期重复领取被拒绝,计数与时间正确更新
- 并发:高并发领取与抽奖下无重复发放与越界叠加
## 验收标准
- 多头衔并存下,五类权益+双倍概率在对应事件正确触发与叠加、封顶生效
- 管理端与用户端流程完整、接口稳定、文档齐备
- 概率与倍数验证通过,随机性与公平性符合预期
- 审计信息可追踪(在现有表结构前提下)
请确认以上“设计方案与开发计划”,确认后我将按里程碑开始实现。

View File

@ -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`)。
* 事务与外键检查切换保障迁移原子性与顺序删除/创建。

View File

@ -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` 做周期限流与资产发放;抽奖效果写入审计日志。
- 你确认后,我将按上述流程与参数规范为每种类型提供可执行的创建脚手架(接口调用示例与测试用例),并补齐未接入业务的效果逻辑。

View File

@ -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` 仅从安全环境加载;周期性轮换与版本化控制。
## 交付内容
- 安全问题清单(带文件与行号)。
- 修复清单与实施顺序(先密钥与鉴权→后特效参数校验→最后配额/审计/限流)。
- 修复后的验证方案:单元测试、接口防护测试、管理端提示验证。
如确认,我将按上述方案逐项实施修复并提交相应代码与测试。

View File

@ -0,0 +1,40 @@
## 问题现象
- 前端“运营→称号管理”页面列表为空。
- 可能原因:后端未创建称号数据、未登录导致接口被拦截、数据库未连接、或菜单未展示导致误判。
## 状态检查
- 后端运行:确认服务已启动并连接数据库(`go run ./main.go -env dev` 日志无报错)。
- 登录状态:确认在管理端已登录并持有有效 Token接口返回 `token`,在请求头 `Authorization: Bearer <token>`)。
- 路由与接口:
- 列表接口:`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: <Operations>, menu_id: <Titles> }`
4. 验证列表
- `curl -s 'http://localhost:9991/api/admin/system_titles?page=1&page_size=20' -H 'Authorization: Bearer <token>'`
- 预期返回:`{ 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 与前端编辑表单,支持运营在页面上直接增删改查。

View File

@ -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`;当前计划不强制修改后端菜单数据,仅完成前端路由与页面以确保可见与可用。
## 验收
- 运营管理左侧菜单出现“称号管理”,进入后能搜索与查看称号列表。
- 行内“分配称号”打开弹窗,提交后接口返回成功并刷新列表。
- 前端构建通过;后端接口已就绪(列表与分配)。
确认后我将立即实施以上前端路由与页面接入,并在必要时协助配置后端菜单以在“后端控制菜单模式”下显示。

View File

@ -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道具卡)相乘并≤总封顶。
- 封顶与每日上限有效;审计记录完整。
确认后,我将把该效果纳入头衔体系的实现与抽奖流程接入。

View File

@ -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 截图位点。
## 风险与注意
* 现有错误效果类型的数据需运营确认是否迁移或删除。
* 前端表单字段与后端参数必须完全对齐,否则抽奖效果不会生效。
确认后我将开始实施重构并提供测试用例与验证记录。

View File

@ -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. 给出后续建议:自动授予逻辑接入方案(服务层/事件钩子/任务调度)。
## 验收标准
- 文档包含所有字段的用途、类型、取值范围、示例;
- 每个字段“在哪里起作用”都给出精确文件/行号;
- 覆盖四项:获得规则、使用范围、效果数量、规则助手;
- 补充参数对齐提醒与后续接入建议;
确认后我将开始更新文档并提交增强内容。

View File

@ -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`: 1000100% 双倍;或按需改为 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. 聚合其 SystemTitleEffectsEffectType=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》并补充操作截图位点与接口示例如需我同时在抽奖服务中添加自动分配与双倍应用的集成代码请确认后我直接实现并提交测试用例。

View File

@ -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无需后端改动
## 验收
- 可在管理端创建或编辑双倍效果,查看列表范围列;抽奖时按期过滤生效。

View File

@ -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接口与前端页面两个弹窗/二级标签),并完成联调与校验。

View File

@ -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 与分配)。
确认后我将立即实施上述前后端改造,并进行编译与交互联调。

View File

@ -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 .
```

View File

@ -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"
```

View File

@ -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

View File

@ -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),
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()))

View File

@ -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: "分配称号成功"})
}
}

View File

@ -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})
}
}

View File

@ -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),
}
}

View File

@ -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 // 归属头衔IDsystem_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
}

View File

@ -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
}

View File

@ -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 // 用户IDusers.id
TitleID field.Int64 // 头衔IDsystem_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
}

View File

@ -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 // 用户IDusers.id
TitleID field.Int64 // 头衔IDsystem_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
}

View File

@ -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:归属头衔IDsystem_titles.id" json:"title_id"` // 归属头衔IDsystem_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
}

View File

@ -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
}

View File

@ -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:用户IDusers.id" json:"user_id"` // 用户IDusers.id
TitleID int64 `gorm:"column:title_id;not null;comment:头衔IDsystem_titles.id" json:"title_id"` // 头衔IDsystem_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
}

View File

@ -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:用户IDusers.id" json:"user_id"` // 用户IDusers.id
TitleID int64 `gorm:"column:title_id;not null;comment:头衔IDsystem_titles.id" json:"title_id"` // 头衔IDsystem_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
}

View File

@ -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())
}
// 系统管理:用户/角色/菜单

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"}