24 KiB
七日签到奖励架构
本文定义 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 推断时区。
总体架构
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 校验。
后台管理
后台菜单:
活动管理
- 每日任务
- 注册奖励
- 成就配置
- 七日签到
建议新增权限:
| 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 对前端暴露:
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
更新配置请求:
{
"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 访问:
GET /api/v1/activities/seven-day-checkin
POST /api/v1/activities/seven-day-checkin/sign
查询签到面板
GET /api/v1/activities/seven-day-checkin
Authorization: Bearer <access_token>
响应 data:
{
"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 |
配置不可发放 |
执行签到
POST /api/v1/activities/seven-day-checkin/sign
Authorization: Bearer <access_token>
请求:
{
"command_id": "cmd_checkin_550e8400"
}
规则:
command_id由客户端按本次点击动作生成,同一次重试必须复用。- gateway 不补
command_id,只裁剪空白并透传当前用户 ID。 - activity-service 决定今天是第几天、是否已签到、应发哪个资源组。
- 同一用户同一 UTC 日期最多一个签到 claim。
- 同一
command_id重试且 payload 一致,返回同一 claim 结果。
响应 data:
{
"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 增加:
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:
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 后必须运行:
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 |
更新时间 |
唯一键:
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 |
创建时间 |
唯一键:
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 |
更新时间 |
唯一键:
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 |
更新时间 |
唯一键:
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) 保证同一轮同一天位不会被并发重复发放。
签到流程
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
关键事务边界:
- Prepare 事务只创建或复用
pendingclaim,不更新连续签到账户。 - wallet-service 发放资源组使用稳定
wallet_command_id,重复调用必须幂等。 - Finalize 事务在 wallet 成功后更新
user_seven_day_checkin_accounts和 claim 状态。 - 如果 wallet 成功但 finalize 失败,用户重试同一
command_id时 wallet 返回幂等结果,activity 再完成 finalize。 - 如果 wallet 失败,claim 标记
failed,账户不前进;同一command_id重试应重新尝试发放该 claim,后台补偿也只能重试同一个 claim。
这样做的原因是签到连续天数必须和奖励成功保持一致。不能出现 activity 已经把用户推进到第 3 天,但第 3 天资源组没有发出的状态。
查询流程
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 直接耦合私有通知实现。
实现顺序
- 文档和契约:确认本架构后修改
api/proto/activity/v1/activity.proto,运行make proto。 - activity-service:增加 domain/service/repository/gRPC server 和 MySQL initdb 表。
- wallet 集成:复用
GrantResourceGroup,补齐七日签到发放 reason/source 和测试。 - gateway-service:增加签到查询和签到 HTTP,接入 activity client,并按资源组 ID 补齐展示 DTO。
- server/admin:增加后台模块、路由、权限、菜单 migration、审计和 activity 管理 client。
- Admin 前端:增加
/activities/seven-day-checkin页面,复用资源组选择器。 - 补偿:增加 pending/failed claim 重试批处理;由 cron-service 触发 activity-service owner 批处理。
- 验证:补 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 和代码实现后运行:
make proto
make test
go test ./...
涉及后台菜单 migration 或配置时额外运行:
docker compose config
待确认产品项
- 是否需要补签。当前架构明确首版不支持。
- 第 7 天之后是否循环回第 1 天。当前架构按循环处理。
- 中断后是否重置到第 1 天。当前架构按重置处理。
- 是否需要领取记录导出。当前只设计分页查询。
- 是否需要发奖成功后的 App 活动消息。当前只预留 outbox,不默认投递。