# 通用抽奖引擎技术文档

本文档描述当前服务端通用抽奖能力的代码结构、运行口径和转盘落地方式。抽奖通用能力目前由 `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 gRPC；H5 页面还需要 gateway 包装 | `services/gateway-service/internal/transport/http/activityapi` |
| 后台管理页面 | 当前只有 gRPC 后台能力，未接 admin web 模块 | `server/admin` |

