706 lines
24 KiB
Markdown
706 lines
24 KiB
Markdown
# 七日签到奖励架构
|
||
|
||
本文定义 App 七日签到奖励的产品规则、服务边界、后台管理、接口契约、数据模型和落地顺序。七日签到属于活动事实,签到状态和发奖状态由 `activity-service` 拥有;奖励内容统一引用 `wallet-service` 资源组,不能把资源明细复制进签到配置。
|
||
|
||
## 目标
|
||
|
||
- 后台 `活动管理` 下新增二级菜单 `七日签到`。
|
||
- 后台可以配置 1 到 7 天的奖励资源组,每天必须选择一个 `resource_group_id`。
|
||
- 后台可以打开或关闭七日签到;关闭后 App 只读状态,不允许新签到。
|
||
- App 提供七日签到面板查询接口和签到接口。
|
||
- 每个用户按 UTC 自然日每日最多签到一次。
|
||
- 连续签到按 7 天为一轮:第 7 天签到后,下一次签到开启新一轮第 1 天。
|
||
- 中断连续签到后下一次签到从第 1 天重新开始。
|
||
- 签到奖励发放必须通过 `wallet-service.GrantResourceGroup`,资源组中可以包含金币、钻石和装扮权益。
|
||
- 签到事实、奖励发放状态和幂等命令必须可审计、可恢复、可重试。
|
||
|
||
## 非目标
|
||
|
||
- 首版不做补签、签到卡、广告补领或客户端本地时区签到。
|
||
- 首版不支持多套并行签到活动;每个 `app_code` 同一时间只有一套七日签到配置。
|
||
- 首版不做人群定向、地区定向、新老用户差异奖励。
|
||
- 首版不让客户端上报连续天数、奖励内容、资源组明细或服务端时间。
|
||
- 首版不把七日签到建成每日任务。每日任务依赖事实事件进度,签到是用户主动命令和每日幂等发奖。
|
||
- 首版不把签到奖励拆成金币字段;所有奖励都只表达为资源组。
|
||
|
||
## 服务边界
|
||
|
||
| 模块 | 责任 | 不负责 |
|
||
| --- | --- | --- |
|
||
| `gateway-service` | App HTTP 入口、鉴权、业务 envelope、生成并透传 `request_id`、调用 activity gRPC、按需向 wallet 查询资源组展示信息 | 不判断连续天数,不写签到表,不发资源组 |
|
||
| `activity-service` | 七日签到配置、配置版本、用户连续签到状态、签到 claim、发奖幂等、失败重试状态 | 不拥有资源组内容,不直接修改钱包余额或用户权益 |
|
||
| `wallet-service` | 资源组 catalog、资源组有效性校验、`GrantResourceGroup` 原子展开资源和钱包资产、钱包/资源 outbox | 不判断用户今天能否签到,不保存签到连续天数 |
|
||
| `server/admin` | 后台菜单、权限、后台 HTTP API、操作审计、调用 activity 管理 RPC,并复用资源组选择能力 | 不直接写 activity/wallet 业务库,不复制 app 服务内部包 |
|
||
| Admin 前端 | `活动管理 > 七日签到` 页面、开关、7 行资源组选择、保存发布、领取记录列表 | 不自行拼资源组明细,不绕过后台权限 |
|
||
| `notice-service` | 后续如需私有通知,从 activity outbox 消费签到奖励事实 | 不参与签到主链路 |
|
||
|
||
核心原则:
|
||
|
||
- `activity-service` 是七日签到状态 owner。
|
||
- 资源组内容永远以 `wallet-service` 为准;签到配置只保存 `resource_group_id` 和发放时必要快照。
|
||
- 发奖是房间外副作用,必须通过幂等命令调用 wallet-service。
|
||
- 所有日期、周期、刷新时间使用 UTC,不读取服务器本地时区、用户手机时区或 IP 推断时区。
|
||
|
||
## 总体架构
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
AdminFE["admin 前端\n活动管理 > 七日签到"] --> AdminServer["server/admin\n七日签到后台 API"]
|
||
AdminServer --> AdminCheckIn["activity-service\nAdminSevenDayCheckInService"]
|
||
AdminServer --> WalletRead["wallet-service\n资源组查询"]
|
||
|
||
App["App 七日签到页"] --> Gateway["gateway-service\n/api/v1"]
|
||
Gateway --> CheckInSvc["activity-service\nSevenDayCheckInService"]
|
||
Gateway --> WalletGroup["wallet-service\n资源组展示查询"]
|
||
|
||
CheckInSvc --> ActivityDB[("hyapp_activity\ncheckin tables")]
|
||
CheckInSvc --> WalletGrant["wallet-service\nGrantResourceGroup"]
|
||
WalletGrant --> WalletDB[("hyapp_wallet\nresource grants / wallet ledger")]
|
||
CheckInSvc --> ActivityOutbox[("activity_outbox\noptional reward event")]
|
||
```
|
||
|
||
## 产品规则
|
||
|
||
### 签到周期
|
||
|
||
签到日使用 UTC 日期字符串 `yyyy-mm-dd`:
|
||
|
||
| 场景 | 结果 |
|
||
| --- | --- |
|
||
| 用户从未签到 | 今天签到为第 1 天 |
|
||
| 上次签到日是今天 | 返回已签到,不重复发奖 |
|
||
| 上次签到日是昨天,且上次是第 1 到第 6 天 | 今天签到为下一天 |
|
||
| 上次签到日是昨天,且上次是第 7 天 | 今天开启新一轮第 1 天 |
|
||
| 上次签到日早于昨天 | 连续中断,今天重新从第 1 天开始 |
|
||
|
||
第 7 天之后不保持在第 7 天;下一次有效签到必须进入新一轮第 1 天,避免一个用户无限领取第 7 天奖励。
|
||
|
||
### 配置版本
|
||
|
||
后台每次保存 7 天奖励或开关都生成新的 `config_version`:
|
||
|
||
- App 查询总是展示当前配置版本。
|
||
- 用户开始一轮签到时绑定当时的 `config_version`。
|
||
- 同一轮后续签到继续使用该轮绑定版本,避免运营改配置后用户第 3 天突然看到另一套奖励。
|
||
- 第 7 天完成后下一轮读取最新 enabled 配置版本。
|
||
- 如果旧版本对应资源组后来被 wallet 下架,发奖时以 wallet-service 校验结果为准,失败 claim 进入可重试/待处理状态。
|
||
|
||
配置版本不是历史兼容负担,而是发奖审计和用户体验一致性的最小事实。
|
||
|
||
### 开关
|
||
|
||
`enabled=false` 时:
|
||
|
||
- App 查询返回当前配置和 `can_sign=false, reason=disabled`。
|
||
- 新的签到命令被拒绝。
|
||
- 已经创建的 pending/failed claim 仍允许后台或服务端补偿重试,避免开关关闭导致已接受签到无法收尾。
|
||
- 后台可以继续编辑 7 天奖励并保存新版本。
|
||
|
||
### 奖励
|
||
|
||
- 每天奖励只允许一个资源组 ID。
|
||
- 资源组必须由后台从资源组列表选择;不允许手写资源 ID、金币数量、钻石数量或有效期。
|
||
- 资源组内容展示由 wallet-service 查询并返回,activity-service 只保存 ID 和发放时返回的 `grant_id`。
|
||
- 发放时调用 `wallet-service.GrantResourceGroup`,`grant_source=seven_day_checkin`,`reason=seven_day_checkin`。
|
||
- wallet-service 在发放事务内重新校验资源组 active、资源 active、钱包资产合法;gateway 和 admin 的前端筛选不能替代 owner 校验。
|
||
|
||
## 后台管理
|
||
|
||
后台菜单:
|
||
|
||
```text
|
||
活动管理
|
||
- 每日任务
|
||
- 注册奖励
|
||
- 成就配置
|
||
- 七日签到
|
||
```
|
||
|
||
建议新增权限:
|
||
|
||
| Permission | Kind | Usage |
|
||
| --- | --- | --- |
|
||
| `seven-day-checkin:view` | `menu` | 查看七日签到配置和领取记录 |
|
||
| `seven-day-checkin:update` | `button` | 更新开关和 7 天奖励资源组 |
|
||
|
||
建议菜单:
|
||
|
||
| 字段 | 值 |
|
||
| --- | --- |
|
||
| title | `七日签到` |
|
||
| code | `seven-day-checkin` |
|
||
| path | `/activities/seven-day-checkin` |
|
||
| icon | `event_available` |
|
||
| permission_code | `seven-day-checkin:view` |
|
||
| sort | `72` |
|
||
|
||
### 页面结构
|
||
|
||
页面保留两个 Tab:
|
||
|
||
| Tab | 内容 |
|
||
| --- | --- |
|
||
| `配置` | 顶部开关、7 行奖励配置、当前版本、更新时间、保存按钮 |
|
||
| `领取记录` | 用户、签到日、第几天、轮次、配置版本、资源组、状态、wallet grant id、失败原因 |
|
||
|
||
配置 Tab 字段:
|
||
|
||
| 字段 | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| 开关 | 是 | 保存到 `enabled` |
|
||
| 第 1 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 2 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 3 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 4 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 5 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 6 天资源组 | 是 | `resource_group_id > 0` |
|
||
| 第 7 天资源组 | 是 | `resource_group_id > 0` |
|
||
|
||
领取记录筛选:
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `keyword` | 用户 ID、展示 ID 或昵称,由 `server/admin` 解析成 user_id |
|
||
| `status` | `pending` / `granted` / `failed` |
|
||
| `checkin_day` | UTC 日期 |
|
||
| `day_index` | 1 到 7 |
|
||
|
||
### 后台 API
|
||
|
||
`server/admin` 对前端暴露:
|
||
|
||
```http
|
||
GET /api/v1/admin/activity/seven-day-checkin/config
|
||
PUT /api/v1/admin/activity/seven-day-checkin/config
|
||
GET /api/v1/admin/activity/seven-day-checkin/claims
|
||
```
|
||
|
||
更新配置请求:
|
||
|
||
```json
|
||
{
|
||
"enabled": true,
|
||
"rewards": [
|
||
{"day_index": 1, "resource_group_id": 101},
|
||
{"day_index": 2, "resource_group_id": 102},
|
||
{"day_index": 3, "resource_group_id": 103},
|
||
{"day_index": 4, "resource_group_id": 104},
|
||
{"day_index": 5, "resource_group_id": 105},
|
||
{"day_index": 6, "resource_group_id": 106},
|
||
{"day_index": 7, "resource_group_id": 107}
|
||
]
|
||
}
|
||
```
|
||
|
||
规则:
|
||
|
||
- `rewards` 必须刚好包含 1 到 7 天,不能缺失、重复或越界。
|
||
- `resource_group_id` 必须大于 0。
|
||
- Admin 前端资源组选择器复用现有资源组列表能力,只展示 active 资源组。
|
||
- `server/admin` 必须写操作审计,记录 `before_json`、`after_json`、操作人、request_id。
|
||
- `activity-service` 保存配置时只保存资源组 ID;资源组是否 active 由 wallet-service 在选择展示和最终发放时确认。
|
||
|
||
## App 接口
|
||
|
||
App 只通过 gateway HTTP 访问:
|
||
|
||
```http
|
||
GET /api/v1/activities/seven-day-checkin
|
||
POST /api/v1/activities/seven-day-checkin/sign
|
||
```
|
||
|
||
### 查询签到面板
|
||
|
||
```http
|
||
GET /api/v1/activities/seven-day-checkin
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
响应 data:
|
||
|
||
```json
|
||
{
|
||
"enabled": true,
|
||
"can_sign": true,
|
||
"reason": "eligible",
|
||
"cycle_no": 3,
|
||
"current_day_index": 2,
|
||
"next_day_index": 3,
|
||
"checkin_day": "2026-05-16",
|
||
"last_checkin_day": "2026-05-15",
|
||
"config_version": 8,
|
||
"server_time_ms": 1778889600000,
|
||
"next_refresh_at_ms": 1778976000000,
|
||
"rewards": [
|
||
{
|
||
"day_index": 1,
|
||
"resource_group_id": 101,
|
||
"status": "granted",
|
||
"resource_group": {}
|
||
},
|
||
{
|
||
"day_index": 2,
|
||
"resource_group_id": 102,
|
||
"status": "granted",
|
||
"resource_group": {}
|
||
},
|
||
{
|
||
"day_index": 3,
|
||
"resource_group_id": 103,
|
||
"status": "available",
|
||
"resource_group": {}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`resource_group` 由 gateway 按 `resource_group_id` 调 wallet-service 查询后嵌入,结构沿用现有资源组 HTTP DTO。activity-service gRPC 只需要返回资源组 ID 和用户签到状态。
|
||
|
||
`current_day_index` 表示本轮最近已完成的天数,从未签到时返回 `0`。`next_day_index` 表示今天如果签到会领取第几天奖励。
|
||
|
||
`reason` 建议枚举:
|
||
|
||
| reason | 含义 |
|
||
| --- | --- |
|
||
| `eligible` | 今天可签到 |
|
||
| `disabled` | 活动关闭 |
|
||
| `not_configured` | 未配置完整 7 天奖励 |
|
||
| `already_signed` | 今天已签到 |
|
||
| `pending_reward` | 今天签到已接受,奖励仍在处理中 |
|
||
| `invalid_config` | 配置不可发放 |
|
||
|
||
### 执行签到
|
||
|
||
```http
|
||
POST /api/v1/activities/seven-day-checkin/sign
|
||
Authorization: Bearer <access_token>
|
||
```
|
||
|
||
请求:
|
||
|
||
```json
|
||
{
|
||
"command_id": "cmd_checkin_550e8400"
|
||
}
|
||
```
|
||
|
||
规则:
|
||
|
||
- `command_id` 由客户端按本次点击动作生成,同一次重试必须复用。
|
||
- gateway 不补 `command_id`,只裁剪空白并透传当前用户 ID。
|
||
- activity-service 决定今天是第几天、是否已签到、应发哪个资源组。
|
||
- 同一用户同一 UTC 日期最多一个签到 claim。
|
||
- 同一 `command_id` 重试且 payload 一致,返回同一 claim 结果。
|
||
|
||
响应 data:
|
||
|
||
```json
|
||
{
|
||
"claim_id": "checkin_claim_123",
|
||
"cycle_no": 3,
|
||
"checkin_day": "2026-05-16",
|
||
"day_index": 3,
|
||
"resource_group_id": 103,
|
||
"status": "granted",
|
||
"wallet_grant_id": "resource_grant_456",
|
||
"granted_at_ms": 1778918500000,
|
||
"server_time_ms": 1778918500000
|
||
}
|
||
```
|
||
|
||
## 内部 gRPC 契约
|
||
|
||
建议在 `api/proto/activity/v1/activity.proto` 增加:
|
||
|
||
```proto
|
||
message SevenDayCheckInReward {
|
||
int32 day_index = 1;
|
||
int64 resource_group_id = 2;
|
||
}
|
||
|
||
message SevenDayCheckInConfig {
|
||
string app_code = 1;
|
||
bool enabled = 2;
|
||
int64 config_version = 3;
|
||
repeated SevenDayCheckInReward rewards = 4;
|
||
int64 updated_by_admin_id = 5;
|
||
int64 created_at_ms = 6;
|
||
int64 updated_at_ms = 7;
|
||
}
|
||
|
||
message SevenDayCheckInRewardStatus {
|
||
int32 day_index = 1;
|
||
int64 resource_group_id = 2;
|
||
string status = 3;
|
||
string claim_id = 4;
|
||
string wallet_grant_id = 5;
|
||
int64 granted_at_ms = 6;
|
||
}
|
||
|
||
message GetSevenDayCheckInStatusRequest {
|
||
RequestMeta meta = 1;
|
||
int64 user_id = 2;
|
||
}
|
||
|
||
message GetSevenDayCheckInStatusResponse {
|
||
bool enabled = 1;
|
||
bool can_sign = 2;
|
||
string reason = 3;
|
||
int64 cycle_no = 4;
|
||
int32 current_day_index = 5;
|
||
int32 next_day_index = 6;
|
||
string checkin_day = 7;
|
||
string last_checkin_day = 8;
|
||
int64 config_version = 9;
|
||
repeated SevenDayCheckInRewardStatus rewards = 10;
|
||
int64 server_time_ms = 11;
|
||
int64 next_refresh_at_ms = 12;
|
||
}
|
||
|
||
message SignSevenDayCheckInRequest {
|
||
RequestMeta meta = 1;
|
||
int64 user_id = 2;
|
||
string command_id = 3;
|
||
}
|
||
|
||
message SignSevenDayCheckInResponse {
|
||
string claim_id = 1;
|
||
int64 cycle_no = 2;
|
||
string checkin_day = 3;
|
||
int32 day_index = 4;
|
||
int64 resource_group_id = 5;
|
||
string status = 6;
|
||
string wallet_grant_id = 7;
|
||
int64 granted_at_ms = 8;
|
||
int64 server_time_ms = 9;
|
||
}
|
||
|
||
service SevenDayCheckInService {
|
||
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
|
||
rpc SignSevenDayCheckIn(SignSevenDayCheckInRequest) returns (SignSevenDayCheckInResponse);
|
||
}
|
||
```
|
||
|
||
后台管理 RPC:
|
||
|
||
```proto
|
||
message GetSevenDayCheckInConfigRequest {
|
||
RequestMeta meta = 1;
|
||
}
|
||
|
||
message GetSevenDayCheckInConfigResponse {
|
||
SevenDayCheckInConfig config = 1;
|
||
}
|
||
|
||
message UpdateSevenDayCheckInConfigRequest {
|
||
RequestMeta meta = 1;
|
||
bool enabled = 2;
|
||
repeated SevenDayCheckInReward rewards = 3;
|
||
int64 operator_admin_id = 4;
|
||
}
|
||
|
||
message UpdateSevenDayCheckInConfigResponse {
|
||
SevenDayCheckInConfig config = 1;
|
||
}
|
||
|
||
message SevenDayCheckInClaim {
|
||
string claim_id = 1;
|
||
string command_id = 2;
|
||
int64 user_id = 3;
|
||
int64 cycle_no = 4;
|
||
string checkin_day = 5;
|
||
int32 day_index = 6;
|
||
int64 config_version = 7;
|
||
int64 resource_group_id = 8;
|
||
string wallet_command_id = 9;
|
||
string wallet_grant_id = 10;
|
||
string status = 11;
|
||
string failure_reason = 12;
|
||
int64 signed_at_ms = 13;
|
||
int64 granted_at_ms = 14;
|
||
int64 created_at_ms = 15;
|
||
int64 updated_at_ms = 16;
|
||
}
|
||
|
||
message ListSevenDayCheckInClaimsRequest {
|
||
RequestMeta meta = 1;
|
||
string status = 2;
|
||
int64 user_id = 3;
|
||
string checkin_day = 4;
|
||
int32 day_index = 5;
|
||
int32 page = 6;
|
||
int32 page_size = 7;
|
||
}
|
||
|
||
message ListSevenDayCheckInClaimsResponse {
|
||
repeated SevenDayCheckInClaim claims = 1;
|
||
int64 total = 2;
|
||
}
|
||
|
||
service AdminSevenDayCheckInService {
|
||
rpc GetSevenDayCheckInConfig(GetSevenDayCheckInConfigRequest) returns (GetSevenDayCheckInConfigResponse);
|
||
rpc UpdateSevenDayCheckInConfig(UpdateSevenDayCheckInConfigRequest) returns (UpdateSevenDayCheckInConfigResponse);
|
||
rpc ListSevenDayCheckInClaims(ListSevenDayCheckInClaimsRequest) returns (ListSevenDayCheckInClaimsResponse);
|
||
}
|
||
```
|
||
|
||
修改 proto 后必须运行:
|
||
|
||
```bash
|
||
make proto
|
||
go test ./...
|
||
```
|
||
|
||
## 数据模型
|
||
|
||
所有表位于 `hyapp_activity`,字段使用 UTC epoch milliseconds 和 UTC 日期字符串。
|
||
|
||
### `seven_day_checkin_configs`
|
||
|
||
当前配置入口。
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `app_code` | App 租户,主键 |
|
||
| `enabled` | 活动开关 |
|
||
| `config_version` | 当前配置版本 |
|
||
| `updated_by_admin_id` | 最近操作人 |
|
||
| `created_at_ms` | 创建时间 |
|
||
| `updated_at_ms` | 更新时间 |
|
||
|
||
### `seven_day_checkin_rewards`
|
||
|
||
当前版本 7 天奖励读模型。
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `app_code` | App 租户 |
|
||
| `day_index` | 1 到 7 |
|
||
| `config_version` | 当前版本 |
|
||
| `resource_group_id` | wallet-service 资源组 ID |
|
||
| `created_at_ms` | 创建时间 |
|
||
| `updated_at_ms` | 更新时间 |
|
||
|
||
唯一键:
|
||
|
||
```text
|
||
PRIMARY KEY(app_code, day_index)
|
||
```
|
||
|
||
### `seven_day_checkin_config_versions`
|
||
|
||
配置版本快照。
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `app_code` | App 租户 |
|
||
| `config_version` | 版本号 |
|
||
| `enabled` | 当时开关 |
|
||
| `snapshot_json` | 7 天奖励资源组快照 |
|
||
| `created_by_admin_id` | 操作人 |
|
||
| `created_at_ms` | 创建时间 |
|
||
|
||
唯一键:
|
||
|
||
```text
|
||
PRIMARY KEY(app_code, config_version)
|
||
```
|
||
|
||
### `user_seven_day_checkin_accounts`
|
||
|
||
用户连续签到账户。
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `app_code` | App 租户 |
|
||
| `user_id` | 用户 ID |
|
||
| `cycle_no` | 当前轮次,从 1 开始 |
|
||
| `cycle_start_day` | 本轮第 1 天 UTC 日期 |
|
||
| `last_checkin_day` | 最近成功签到 UTC 日期 |
|
||
| `last_day_index` | 最近成功签到是第几天 |
|
||
| `config_version` | 本轮绑定配置版本 |
|
||
| `updated_at_ms` | 更新时间 |
|
||
|
||
唯一键:
|
||
|
||
```text
|
||
PRIMARY KEY(app_code, user_id)
|
||
```
|
||
|
||
### `seven_day_checkin_claims`
|
||
|
||
每次签到和资源组发放事实。
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `claim_id` | 主键 |
|
||
| `command_id` | 客户端签到动作幂等键 |
|
||
| `app_code` | App 租户 |
|
||
| `user_id` | 用户 ID |
|
||
| `cycle_no` | 轮次 |
|
||
| `checkin_day` | UTC 签到日 |
|
||
| `day_index` | 1 到 7 |
|
||
| `config_version` | 发奖使用的配置版本 |
|
||
| `resource_group_id` | 发奖资源组快照 |
|
||
| `wallet_command_id` | 调用 wallet-service 的幂等键 |
|
||
| `wallet_grant_id` | wallet-service 返回的资源组 grant ID |
|
||
| `status` | `pending` / `granted` / `failed` |
|
||
| `failure_reason` | 失败原因 |
|
||
| `signed_at_ms` | 签到命令接受时间 |
|
||
| `granted_at_ms` | 奖励发放完成时间 |
|
||
| `created_at_ms` | 创建时间 |
|
||
| `updated_at_ms` | 更新时间 |
|
||
|
||
唯一键:
|
||
|
||
```text
|
||
PRIMARY KEY(app_code, claim_id)
|
||
UNIQUE(app_code, command_id)
|
||
UNIQUE(app_code, user_id, checkin_day)
|
||
UNIQUE(app_code, user_id, cycle_no, day_index)
|
||
```
|
||
|
||
`UNIQUE(app_code, user_id, checkin_day)` 保证自然日最多一次;`UNIQUE(app_code, user_id, cycle_no, day_index)` 保证同一轮同一天位不会被并发重复发放。
|
||
|
||
## 签到流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant App
|
||
participant Gateway
|
||
participant Activity as activity-service
|
||
participant DB as activity MySQL
|
||
participant Wallet as wallet-service
|
||
|
||
App->>Gateway: POST /activities/seven-day-checkin/sign(command_id)
|
||
Gateway->>Activity: SignSevenDayCheckIn(user_id, command_id)
|
||
Activity->>DB: 锁 config + user account,计算 checkin_day/day_index
|
||
Activity->>DB: INSERT claim pending(wallet_command_id)
|
||
Activity->>Wallet: GrantResourceGroup(wallet_command_id, resource_group_id)
|
||
Wallet-->>Activity: grant_id
|
||
Activity->>DB: 锁 claim + account,更新 account 和 claim=granted
|
||
Activity-->>Gateway: granted
|
||
Gateway-->>App: OK
|
||
```
|
||
|
||
关键事务边界:
|
||
|
||
1. Prepare 事务只创建或复用 `pending` claim,不更新连续签到账户。
|
||
2. wallet-service 发放资源组使用稳定 `wallet_command_id`,重复调用必须幂等。
|
||
3. Finalize 事务在 wallet 成功后更新 `user_seven_day_checkin_accounts` 和 claim 状态。
|
||
4. 如果 wallet 成功但 finalize 失败,用户重试同一 `command_id` 时 wallet 返回幂等结果,activity 再完成 finalize。
|
||
5. 如果 wallet 失败,claim 标记 `failed`,账户不前进;同一 `command_id` 重试应重新尝试发放该 claim,后台补偿也只能重试同一个 claim。
|
||
|
||
这样做的原因是签到连续天数必须和奖励成功保持一致。不能出现 activity 已经把用户推进到第 3 天,但第 3 天资源组没有发出的状态。
|
||
|
||
## 查询流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant App
|
||
participant Gateway
|
||
participant Activity as activity-service
|
||
participant Wallet as wallet-service
|
||
|
||
App->>Gateway: GET /activities/seven-day-checkin
|
||
Gateway->>Activity: GetSevenDayCheckInStatus(user_id)
|
||
Activity-->>Gateway: config ids + user status
|
||
Gateway->>Wallet: GetResourceGroup for visible reward ids
|
||
Wallet-->>Gateway: resource group DTOs
|
||
Gateway-->>App: envelope(data)
|
||
```
|
||
|
||
查询只读 activity 配置、用户账户和最近 claims,不实时扫描钱包流水。资源组展示信息由 gateway 使用 wallet 查询结果补齐。
|
||
|
||
## 幂等和并发
|
||
|
||
- `command_id` 是客户端点击动作幂等键。
|
||
- `wallet_command_id` 建议格式:`seven_day_checkin:{claim_id}`。
|
||
- 同一 `command_id` 重试返回同一 claim。
|
||
- 同一用户同一 `checkin_day` 并发请求只能创建一个 claim。
|
||
- 同一用户同一 `cycle_no + day_index` 只能有一个 granted/pending claim。
|
||
- claim payload 冲突时返回 `CONFLICT`,不能静默重写历史 claim。
|
||
- 查询接口不产生签到事实。
|
||
|
||
## 时间规则
|
||
|
||
- `checkin_day` 按 UTC 日期计算。
|
||
- `next_refresh_at_ms` 是下一天 UTC 00:00:00.000 的 epoch milliseconds。
|
||
- 所有业务时间字段使用 `*_ms`。
|
||
- 领取记录筛选的日期字段是 UTC 日期,不接受本地时区字符串。
|
||
- MySQL DSN、Docker `TZ` 和数据库默认时区继续保持 UTC。
|
||
|
||
## 错误和状态
|
||
|
||
| 状态或错误 | 场景 | 处理 |
|
||
| --- | --- | --- |
|
||
| `disabled` | 活动关闭 | 查询可见,签到拒绝 |
|
||
| `not_configured` | 未配置完整 7 天奖励 | 查询提示不可签,签到拒绝 |
|
||
| `already_signed` | 当天已有 granted/pending claim | 返回已有状态 |
|
||
| `pending` | claim 已创建,奖励未完成 | 可重试或后台补偿 |
|
||
| `failed` | wallet 发放失败或配置不可发放 | 记录原因,账户不前进 |
|
||
| `CONFLICT` | 同一 command_id payload 不一致 | 返回冲突 |
|
||
| `INVALID_ARGUMENT` | command_id 空、day_index 越界、资源组 ID 无效 | 返回参数错误 |
|
||
|
||
## Outbox 和通知
|
||
|
||
签到发奖成功后,activity-service 可以写 `activity_outbox`:
|
||
|
||
| Event | Payload |
|
||
| --- | --- |
|
||
| `SevenDayCheckInRewardGranted` | `claim_id`、`user_id`、`cycle_no`、`checkin_day`、`day_index`、`resource_group_id`、`wallet_grant_id` |
|
||
| `SevenDayCheckInRewardFailed` | `claim_id`、`user_id`、`checkin_day`、`failure_reason` |
|
||
|
||
首版不要求实时私信通知。后续如果产品需要奖励到账通知,由 `notice-service` 消费 activity outbox 并投递,不让 activity-service 直接耦合私有通知实现。
|
||
|
||
## 实现顺序
|
||
|
||
1. 文档和契约:确认本架构后修改 `api/proto/activity/v1/activity.proto`,运行 `make proto`。
|
||
2. activity-service:增加 domain/service/repository/gRPC server 和 MySQL initdb 表。
|
||
3. wallet 集成:复用 `GrantResourceGroup`,补齐七日签到发放 reason/source 和测试。
|
||
4. gateway-service:增加签到查询和签到 HTTP,接入 activity client,并按资源组 ID 补齐展示 DTO。
|
||
5. server/admin:增加后台模块、路由、权限、菜单 migration、审计和 activity 管理 client。
|
||
6. Admin 前端:增加 `/activities/seven-day-checkin` 页面,复用资源组选择器。
|
||
7. 补偿:增加 pending/failed claim 重试批处理;由 cron-service 触发 activity-service owner 批处理。
|
||
8. 验证:补 UTC 边界、并发幂等、wallet 失败、配置版本切换和后台权限测试。
|
||
|
||
## 测试要求
|
||
|
||
至少覆盖:
|
||
|
||
| 场景 | 验证 |
|
||
| --- | --- |
|
||
| UTC 当天首次签到 | 创建 claim,发资源组,账户推进到第 1 天 |
|
||
| 同一天重复签到 | 不重复发奖,返回已有 claim |
|
||
| 连续 7 天签到 | day_index 从 1 到 7,下一天回到第 1 天并 cycle_no + 1 |
|
||
| 中断一天后签到 | day_index 重置为 1,新 cycle_no |
|
||
| UTC 边界 | `23:59:59.999` 和 `00:00:00.000` 分属不同 checkin_day |
|
||
| 开关关闭 | 查询可见但不能签到 |
|
||
| 配置缺失 | 不能打开或不能签到 |
|
||
| 修改配置版本 | 已开始轮次继续旧版本,新轮次使用新版本 |
|
||
| wallet GrantResourceGroup 失败 | claim failed,账户不前进,可重试 |
|
||
| finalize 失败后重试 | wallet 幂等返回后 activity 完成 finalize |
|
||
| admin 权限 | 无 `seven-day-checkin:update` 不能保存配置 |
|
||
|
||
涉及 proto 和代码实现后运行:
|
||
|
||
```bash
|
||
make proto
|
||
make test
|
||
go test ./...
|
||
```
|
||
|
||
涉及后台菜单 migration 或配置时额外运行:
|
||
|
||
```bash
|
||
docker compose config
|
||
```
|
||
|
||
## 待确认产品项
|
||
|
||
1. 是否需要补签。当前架构明确首版不支持。
|
||
2. 第 7 天之后是否循环回第 1 天。当前架构按循环处理。
|
||
3. 中断后是否重置到第 1 天。当前架构按重置处理。
|
||
4. 是否需要领取记录导出。当前只设计分页查询。
|
||
5. 是否需要发奖成功后的 App 活动消息。当前只预留 outbox,不默认投递。
|