# 七日签到奖励架构

本文定义 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，不默认投递。
