hyapp-server/docs/通用抽奖引擎技术文档.md
2026-06-18 17:13:56 +08:00

445 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 通用抽奖引擎技术文档
本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 `activity-service` 持有,钱包入账由 `wallet-service` 持有。幸运礼物已经复用通用权重选择转盘新增独立配置、抽奖记录、RTP 统计、钱包 reason 和后台查询入口。
## 一、边界和结论
| 模块 | Owner | 当前职责 | 不做什么 | 代码位置 |
| --- | --- | --- | --- | --- |
| 通用抽奖引擎 | `activity-service` | 候选归一化、权重随机、奖品类型归一化、RTP 计值归一化 | 不读取数据库、不判断资格、不改奖池、不发奖 | `services/activity-service/internal/domain/lotteryengine/engine.go` |
| 幸运礼物 | `activity-service` | 继续拥有幸运礼物规则、奖池、RTP 窗口、风控、记录和 outbox候选随机改为调用通用引擎 | 不改变原有幸运礼物 RTP 观察和不可支付过滤逻辑 | `services/activity-service/internal/storage/mysql/lucky_gift_repository.go` |
| 转盘 | `activity-service` | 独立规则版本、奖档、奖池、RTP 窗口、抽奖记录、后台统计、金币快路径发奖 | 不复用幸运礼物表;不把道具/装扮价值计入 RTP | `services/activity-service/internal/domain/wheel``internal/service/wheel``internal/storage/mysql/wheel_repository.go` |
| 钱包 reason | `wallet-service` | 新增 `wheel_reward` 入账 reason、交易、分录、钱包 outbox | 不判断转盘中奖资格,不计算 RTP | `services/wallet-service/internal/service/wallet/service.go``internal/storage/mysql/repository.go` |
| Proto/gRPC | `api/proto` + transport | 暴露转盘抽奖和后台配置/记录/统计 RPC | 当前未新增 gateway HTTP 包装 | `api/proto/activity/v1/activity.proto``services/activity-service/internal/transport/grpc/wheel_server.go` |
核心原则:
1. 通用引擎只负责“从已过滤候选里按权重选中一个奖档”。
2. 业务模块必须在调用引擎前完成资格、价格、奖池、风控和预算过滤。
3. RTP 只使用 `rtp_value_coins` 作为分子;转盘道具和装扮无论配置价值多少,运行态都强制为 0。
4. 金币发放必须走钱包独立 reason `wheel_reward`,不能混用 `lucky_gift_reward`
5. 转盘配置、记录和统计使用 `wheel_*` 表,不能写入幸运礼物表。
## 二、代码索引
### 2.1 通用抽奖引擎
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `services/activity-service/internal/domain/lotteryengine/engine.go` | `Candidate` | 引擎唯一输入视图:`ID``Weight``PrizeKind``RTPValueCoins` |
| 同上 | `NormalizeCandidates` | 复制并清洗候选;负权重拒绝;全部权重为 0 时退化为等权随机 |
| 同上 | `SelectWeighted` | 使用 `crypto/rand` 生成随机下标,按权重选择候选 |
| 同上 | `NormalizePrizeKind` | 把 `coin/gift/prop/dress` 及同义值归一化 |
| 同上 | `NormalizeRTPValueCoins` | 金币/礼物保留非负 RTP 价值;`prop/dress` 强制归零 |
| `services/activity-service/internal/domain/lotteryengine/engine_test.go` | 单测 | 覆盖权重选择、全 0 权重兜底、道具/装扮 RTP 归零 |
### 2.2 转盘代码
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `services/activity-service/internal/domain/wheel/wheel.go` | `RuleConfig``Tier``DrawCommand``DrawResult``DrawSummary` | 转盘领域模型 |
| `services/activity-service/internal/service/wheel/config.go` | `NormalizeRuleConfig``ValidateRuleConfig` | 后台配置发布前归一化和硬边界校验 |
| `services/activity-service/internal/service/wheel/service.go` | `Service.Draw``UpsertConfig``GetConfig``ListDraws``GetDrawSummary` | 转盘用例入口 |
| 同上 | `creditCoinRewardFastPath` | 金币奖品同步调用钱包 `CreditWheelReward`,成功后回写 draw、统计和 outbox |
| `services/activity-service/internal/storage/mysql/wheel_repository.go` | `PublishWheelRuleConfig` | 发布不可变规则版本和奖档 |
| 同上 | `ExecuteWheelDraw` | 转盘抽奖主事务幂等、规则锁定、价格校验、RTP 窗口、奖池、记录、统计、outbox |
| 同上 | `executeSingleWheelDraw` | 单次命中奖档、更新 RTP 窗口、写 draw record 和 outbox |
| 同上 | `wheelPayableCandidates` | 按奖池容量和单次 RTP 上限过滤候选;道具/装扮 RTP 价值归零 |
| 同上 | `ListWheelDraws``GetWheelDrawSummary` | 后台抽奖明细和汇总统计读取 |
| 同上 | `MarkWheelDrawsGranted` | 金币发放成功后回写记录、统计和 `activity_outbox` |
| `services/activity-service/internal/storage/mysql/wheel_schema.go` | `ensureWheelTables` | 运行时兜底创建 `wheel_*` 表 |
| `services/activity-service/deploy/mysql/initdb/001_activity_service.sql` | `wheel_*` DDL | 新环境初始化表结构 |
| `services/activity-service/internal/transport/grpc/wheel_server.go` | `WheelServer``AdminWheelServer` | 转盘 App 抽奖和后台配置/统计 gRPC |
| `services/activity-service/internal/app/services.go` | `wheelservice.New` | activity-service 装配转盘 service |
| `services/activity-service/internal/app/grpc_registration.go` | `RegisterWheelServiceServer``RegisterAdminWheelServiceServer` | 注册转盘 gRPC 服务 |
### 2.3 钱包代码
| 文件 | 关键符号 | 说明 |
| --- | --- | --- |
| `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest``CreditWheelReward` | 钱包转盘金币奖品入账 RPC |
| `services/wallet-service/internal/domain/ledger/ledger.go` | `WheelRewardCommand``WheelRewardReceipt` | 钱包 service/repository 间领域命令 |
| `services/wallet-service/internal/service/wallet/service.go` | `CreditWheelReward` | 校验转盘金币奖品入账命令,固定钱包职责边界 |
| `services/wallet-service/internal/storage/mysql/repository.go` | `bizTypeWheelReward``CreditWheelReward` | 交易、账户余额、分录和钱包 outbox 同事务提交 |
| 同上 | `wheelRewardRequestHash` | 保证同一转盘 draw 幂等入账 |
| `services/wallet-service/internal/transport/grpc/server.go` | `CreditWheelReward` | gRPC 请求转换为钱包领域命令 |
### 2.4 Proto 契约
| 文件 | RPC/Message | 说明 |
| --- | --- | --- |
| `api/proto/activity/v1/activity.proto` | `WheelService.ExecuteWheelDraw` | 扣费后执行转盘抽奖 |
| 同上 | `AdminWheelService.GetWheelConfig` | 后台读取当前转盘配置 |
| 同上 | `AdminWheelService.UpsertWheelConfig` | 后台发布新规则版本 |
| 同上 | `AdminWheelService.ListWheelDraws` | 后台查看抽奖明细 |
| 同上 | `AdminWheelService.GetWheelDrawSummary` | 后台查看转盘统计汇总 |
| 同上 | `WheelRuleConfig``WheelPrizeTier` | 转盘规则和奖档配置 |
| 同上 | `WheelDrawMeta``WheelDrawResult``WheelDrawSummary` | 抽奖入参、结果和统计 |
| `api/proto/wallet/v1/wallet.proto` | `CreditWheelRewardRequest``CreditWheelRewardResponse` | 转盘金币发放钱包契约 |
## 三、通用引擎模型
### 3.1 Candidate
`lotteryengine.Candidate` 是通用引擎唯一关心的数据:
| 字段 | 含义 | 规则 |
| --- | --- | --- |
| `ID` | 业务奖档 ID | 引擎只原样返回,业务侧负责保证唯一 |
| `Weight` | 权重 | 负数拒绝;正数参与随机;全部为 0 时每个候选权重按 1 处理 |
| `PrizeKind` | 奖品类型 | 归一化为 `coin/gift/prop/dress` 等标准值 |
| `RTPValueCoins` | 计入 RTP 的金币价值 | 负数按 0`prop/dress` 强制为 0 |
引擎不直接使用 `RewardCoins``RewardID`、库存、奖池余额、用户信息。这些都属于业务模块。
### 3.2 权重选择
流程:
1. 调用方传入已过滤候选。
2. `NormalizeCandidates` 清洗权重、奖品类型和 RTP 价值。
3. `SelectWeighted` 汇总权重。
4.`crypto/rand.Int` 生成 `[0,totalWeight)` 的随机数。
5. 按权重区间返回命中的 `Candidate`
设计原因:
| 设计 | 原因 |
| --- | --- |
| 使用 `crypto/rand` | 高价值奖品不能被时间种子或进程启动顺序预测 |
| 全 0 权重兜底为等权 | 兼容旧配置或临时配置错误,避免整个活动不可抽 |
| 负权重直接失败 | 负权重没有业务语义,继续运行会让概率不可解释 |
| 不在引擎内做 RTP 修正 | RTP 修正依赖奖池、预算、用户风险和活动策略,必须留在业务层 |
## 四、转盘配置模型
### 4.1 RuleConfig
`domain/wheel.RuleConfig` 是一个不可变版本快照:
| 字段 | 含义 |
| --- | --- |
| `WheelID` | 转盘 ID空值归一为 `default` |
| `RuleVersion` | 不可变规则版本,每次发布 +1 |
| `Enabled` | 是否启用 |
| `DrawPriceCoins` | 单抽价格 |
| `TargetRTPPPM` | RTP 目标ppm |
| `PoolRatePPM` | 每笔消耗进入转盘奖池比例ppm |
| `SettlementWindowDraws` | RTP 观察窗口抽数 |
| `InitialPoolCoins` | 初始奖池水位 |
| `PoolReserveCoins` | 奖池保底水位 |
| `MaxSingleRTPPayout` | 单次可支付的 RTP 价值上限 |
| `Tiers` | 奖档列表 |
### 4.2 Tier
| 字段 | 含义 | 特殊规则 |
| --- | --- | --- |
| `TierID` | 奖档 ID | 同一规则版本内必须唯一 |
| `DisplayName` | 展示名称快照 | 只用于后台和前端展示 |
| `RewardType` | `coin/gift/prop/dress` | 会调用通用引擎归一化 |
| `RewardID` | 礼物、道具、装扮资源 ID | 非金币奖品必填 |
| `RewardCount` | 奖励数量 | 小于等于 0 时归一为 1 |
| `RewardCoins` | 金币奖励金额 | `coin` 使用 |
| `RTPValueCoins` | 计入 RTP 的价值 | `prop/dress` 强制为 0 |
| `WeightPPM` | 随机权重 | 当前作为权重值使用,不要求总和等于 1000000 |
| `Enabled` | 是否启用 | 至少需要一个启用奖档 |
| `MetadataJSON` | 展示和发放扩展快照 | 空值或非法 JSON 归一为 `{}` |
### 4.3 配置校验
入口:`services/activity-service/internal/service/wheel/config.go`
规则:
1. `draw_price_coins` 必须大于 0。
2. `target_rtp_ppm``pool_rate_ppm` 不能为负。
3. `settlement_window_draws` 必须大于 0。
4. 奖档不能为空。
5. `tier_id` 必须唯一。
6. `weight_ppm` 不能为负。
7. `coin` 奖档的 `reward_coins` 不能为负。
8. `gift/prop/dress` 必须有 `reward_id`
9. `prop/dress``rtp_value_coins` 在归一化后必须为 0。
10. 至少一个奖档启用。
## 五、转盘抽奖链路
### 5.1 调用前提
转盘抽奖 RPC 是扣费后的服务端事实入口:
| 阶段 | Owner | 说明 |
| --- | --- | --- |
| 扣费 | 调用方 + wallet-service | 先扣用户金币,拿到真实 `coin_spent``paid_at_ms` |
| 抽奖 | activity-service | 使用 `ExecuteWheelDraw` 固化中奖结果和 RTP |
| 金币发放 | wallet-service | `coin` 奖品通过 `CreditWheelReward` 入账 |
| 礼物/道具/装扮发放 | 对应资产 owner | 当前只固化 pending 记录和 outbox 事实,后续接资产发放接口 |
转盘 repository 会校验:
```text
coin_spent == draw_price_coins * draw_count
```
这样避免客户端或调用方伪造 RTP 分母。
### 5.2 主事务
入口:`Repository.ExecuteWheelDraw`
事务内顺序:
1. 归一化 `wheel_id``draw_count`
2.`command_id` 收集已有 draw完整存在则幂等返回。
3. 如果只存在部分子抽,返回冲突,避免重复发奖。
4. 锁定当前启用的转盘规则版本。
5. 校验 `coin_spent` 与当前单价一致。
6. 获取或创建 RTP 窗口。
7. 获取或创建转盘奖池。
8.`draw_count` 拆分每次消耗。
9. 每一抽调用 `executeSingleWheelDraw`
10. 汇总写回奖池净变化。
11. 更新后台统计。
12. 提交事务。
### 5.3 单抽
入口:`executeSingleWheelDraw`
单抽顺序:
1. `wheelPayableCandidates` 过滤可支付候选。
2. 组装 `lotteryengine.Candidate`
3. 调用 `lotteryengine.SelectWeighted` 命中奖档。
4. 用命中奖档的 `RTPValueCoins` 更新奖池和 RTP 窗口。
5. 写入 `wheel_draw_records`
6. 如果奖品需要发放,写入 `activity_outbox`,事件类型为 `WheelRewardSettlement`
7. 返回 `DrawResult`
候选过滤口径:
| 过滤项 | 说明 |
| --- | --- |
| `tier.Enabled` | 未启用奖档不参与 |
| `RTPValueCoins` | `prop/dress` 强制为 0 |
| 奖池容量 | `pool.balance - reserve_floor` |
| 单次上限 | `max_single_rtp_payout` 大于 0 时限制容量 |
注意:道具/装扮 `RTPValueCoins=0`,所以不会因为展示价值占用奖池,也不会提高 RTP 分子。
### 5.4 批量抽奖
当前转盘支持 `draw_count`
| 功能 | 代码 | 说明 |
| --- | --- | --- |
| 子命令 ID | `wheelSubCommandID` | 单抽使用原 command多抽追加 `#000001` 这类后缀 |
| 金额拆分 | 复用 `luckyDrawUnitSpend` | 总金额按次数拆分,余数给前几抽 |
| 幂等聚合 | `collectWheelDrawsByCommand``aggregateWheelDrawResults` | 完整存在则聚合返回 |
| 发放限制 | `creditCoinRewardFastPath` | 当前只对单抽金币做同步快路径,多抽保留 pending/outbox |
## 六、RTP 口径
### 6.1 分子和分母
| 项 | 字段 | 来源 |
| --- | --- | --- |
| RTP 分母 | `coin_spent` / `wager_coins` | 用户实际扣费金币 |
| RTP 分子 | `rtp_value_coins` / `actual_rtp_value_coins` | 命中奖档计入 RTP 的价值 |
| 目标值 | `target_rtp_ppm` | 后台规则配置 |
| 当前值 | `actual_rtp_ppm` | `actual_rtp_value_coins * 1000000 / wager_coins` |
### 6.2 奖品类型口径
| 奖品类型 | 是否可配置 | 是否发放 | 是否计入 RTP |
| --- | --- | --- | --- |
| `coin` | 是 | 钱包 `wheel_reward` | 是,使用 `rtp_value_coins` |
| `gift` | 是 | 待接礼物/资源 owner | 是,使用 `rtp_value_coins` |
| `prop` | 是 | 待接道具 owner | 否,强制 0 |
| `dress` | 是 | 待接装扮 owner | 否,强制 0 |
强制归零的位置:
| 层 | 代码 |
| --- | --- |
| 通用引擎 | `lotteryengine.NormalizeRTPValueCoins` |
| 转盘配置 | `wheel.NormalizeRuleConfig` |
| 转盘仓储 | `normalizeWheelTiers``wheelPayableCandidates` |
| 转盘测试 | `services/activity-service/internal/service/wheel/config_test.go``domain/lotteryengine/engine_test.go` |
## 七、发奖和 outbox
### 7.1 金币奖品
金币奖品路径:
1. `ExecuteWheelDraw` 写入 `wheel_draw_records`,初始状态为 `pending`
2. `Service.Draw` 在事务提交后进入 `creditCoinRewardFastPath`
3. 调用 wallet-service `CreditWheelReward`
4. wallet-service 以 `biz_type=wheel_reward` 写交易、账户、分录和 wallet outbox。
5. activity-service 调用 `MarkWheelDrawsGranted`
6. `MarkWheelDrawsGranted` 同事务更新:
- `wheel_draw_records.reward_status = granted`
- `wheel_draw_records.reward_transaction_id`
- `wheel_draw_stats.pending_draws/granted_draws`
- `activity_outbox.status = delivered`
钱包 reason
| 项 | 值 |
| --- | --- |
| wallet biz type | `wheel_reward` |
| RPC reason | `wheel_reward` |
| command ID | `wheel_reward:<draw_id>` |
| 钱包事件 | `WalletWheelRewardCredited` |
### 7.2 礼物/道具/装扮奖品
当前状态:
1. 抽奖结果会落 `wheel_draw_records`
2. `reward_status``pending`
3. `activity_outbox` 会写 `WheelRewardSettlement`
4. 具体发放 worker 和资产 owner RPC 还没有接入。
后续接入时必须遵守:
1. 不允许在 activity-service 直接改钱包或资产表。
2. 发放成功后调用转盘仓储状态回写方法,保持 draw record、统计和 outbox 一致。
3. 道具/装扮发放成功也不能把展示价值写入 RTP。
4. 礼物如果要计入 RTP必须使用抽奖时固化的 `rtp_value_coins`,不能按发放时礼物价格重新估值。
## 八、后台记录和统计
### 8.1 明细
入口:`AdminWheelService.ListWheelDraws`
仓储:`Repository.ListWheelDraws`
支持筛选:
| 字段 | 说明 |
| --- | --- |
| `wheel_id` | 转盘 ID |
| `user_id` | 用户 ID |
| `status` | `pending/granted/failed` |
| `page/page_size` | 分页,`page_size` 最大 100 |
返回字段包括:
`draw_id``command_id``wheel_id``rule_version``selected_tier_id``reward_type``reward_id``reward_count``reward_coins``rtp_value_coins``reward_status``wallet_transaction_id``rtp_window_index``metadata_json`
### 8.2 汇总
入口:`AdminWheelService.GetWheelDrawSummary`
仓储:`Repository.GetWheelDrawSummary`
统计字段:
| 字段 | 说明 |
| --- | --- |
| `total_draws` | 抽奖次数 |
| `unique_users` | 去重用户数 |
| `total_spent_coins` | 总消耗金币 |
| `total_rtp_value_coins` | 计入 RTP 的返奖价值 |
| `actual_rtp_ppm` | 实际 RTP |
| `pending_draws` | 待发放 |
| `granted_draws` | 已发放 |
| `failed_draws` | 失败 |
当只按 `wheel_id` 汇总时,优先读 `wheel_draw_stats`;带 `user_id/status` 过滤时直接聚合 `wheel_draw_records`
## 九、数据表
| 表 | 用途 | 代码 |
| --- | --- | --- |
| `wheel_rule_versions` | 转盘不可变规则版本 | `wheel_schema.go``001_activity_service.sql` |
| `wheel_prize_tiers` | 每个规则版本的奖档快照 | 同上 |
| `wheel_rtp_windows` | RTP 观察窗口 | `getOpenWheelRTPWindow``createWheelRTPWindow` |
| `wheel_pools` | 转盘奖池水位 | `getOrCreateWheelPool``persistWheelPoolDelta` |
| `wheel_draw_records` | 抽奖事实明细 | `executeSingleWheelDraw``ListWheelDraws` |
| `wheel_draw_stats` | 后台汇总统计 | `updateWheelStats``GetWheelDrawSummary` |
| `wheel_draw_stat_users` | 统计去重用户 | `updateWheelStats` |
| `activity_outbox` | 转盘发奖补偿事实 | `insertWheelRewardOutbox``MarkWheelDrawsGranted` |
| `wallet_transactions` | 钱包交易 | `wallet-service/internal/storage/mysql.Repository.CreditWheelReward` |
| `wallet_outbox` | 钱包余额和奖励事实 | 同上 |
## 十、和幸运礼物的关系
幸运礼物不是直接迁移成转盘,也不是把转盘塞进幸运礼物:
| 项 | 幸运礼物 | 转盘 |
| --- | --- | --- |
| 规则维度 | `pool_id`,房间送礼场景 | `wheel_id`H5/活动转盘场景 |
| 奖品 | 金币返奖 | 金币、礼物、道具、装扮 |
| RTP 口径 | 原幸运礼物基础返奖口径 | `rtp_value_coins`,道具/装扮强制 0 |
| 记录表 | `lucky_draw_records` | `wheel_draw_records` |
| 钱包 reason | `lucky_gift_reward` | `wheel_reward` |
| 通用引擎使用 | 候选随机复用 `lotteryengine.SelectWeighted` | 候选随机和 RTP 归一化都复用 |
幸运礼物保留自己的奖池、风控、用户阶段和 room IM 逻辑。通用引擎只替换了“已过滤候选里的权重随机”这一小段,避免改动原业务行为。
## 十一、扩展新抽奖活动的步骤
新增一个抽奖活动时,不要复制幸运礼物或转盘整套逻辑。按下面顺序接入:
1. 新建独立 domain定义自己的 `RuleConfig``Tier``DrawCommand``DrawResult`
2. 新建 service负责入参归一化、配置校验和跨服务调用。
3. 新建 storage负责规则版本、奖池、RTP 窗口、记录、统计和 outbox。
4. 业务层先过滤资格、预算、库存、奖池和风控。
5. 把过滤后的候选转换成 `lotteryengine.Candidate`
6. 调用 `lotteryengine.SelectWeighted`
7. 用返回候选固化抽奖结果。
8. 如果有金币奖品,在 wallet-service 增加独立 reason不复用已有 reason。
9. 如果有非金币奖品,接对应 owner service 的发放接口。
10. 补 proto、gRPC、表结构、initdb 和测试。
必须明确回答的问题:
| 问题 | 必须落地的位置 |
| --- | --- |
| 奖品有哪些类型 | domain `Tier.RewardType` 和 proto |
| 哪些奖品计入 RTP | `RTPValueCoins` 归一化 |
| 哪个服务发奖 | service 跨服务依赖 |
| 钱包 reason 是什么 | wallet-service domain/service/storage/proto |
| 如何幂等 | command ID、draw ID、钱包 request hash |
| 后台查什么 | draw records 和 summary |
| 失败如何补偿 | activity outbox 或 owner outbox |
## 十二、验证命令
当前相关验证命令:
```bash
PATH=/Users/hy/go/bin:$PATH make proto
go test ./services/activity-service/internal/domain/lotteryengine
go test ./services/activity-service/internal/service/wheel
go test ./services/activity-service/internal/storage/mysql
go test ./services/activity-service/internal/transport/grpc
go test ./services/activity-service/internal/app
go test ./services/wallet-service/internal/service/wallet ./services/wallet-service/internal/storage/mysql ./services/wallet-service/internal/transport/grpc
go test ./services/activity-service/...
```
已知当前仓库全量 `make test` 仍会失败在既有 MifaPay 配置测试:
```text
services/wallet-service/internal/config.TestLoadMifaPayMerchantConfig
mifapay merchant identity mismatch in ../../configs/config.docker.yaml
```
该失败与通用抽奖和转盘改动无关。
## 十三、当前未完成事项
| 事项 | 原因 | 后续接入位置 |
| --- | --- | --- |
| 礼物奖品真实发放 | 需要确认礼物/资源 owner 的发放接口和背包表现 | activity-service 转盘 outbox worker + 对应 owner RPC |
| 道具/装扮真实发放 | 需要确认装扮资源 owner、有效期和背包协议 | activity-service 转盘 outbox worker + resource/wallet owner |
| Gateway HTTP/H5 接口 | 当前只到 activity gRPCH5 页面还需要 gateway 包装 | `services/gateway-service/internal/transport/http/activityapi` |
| 后台管理页面 | 当前只有 gRPC 后台能力,未接 admin web 模块 | `server/admin` |