# Service Foundation Technical Design

本文档定义 `user-service`、`wallet-service`、`activity-service` 从占位骨架变成可正常开发服务前，必须补齐的基础架构。它只定义服务底座，不展开具体业务功能。

## Current State

当前三个服务的基础成熟度不一致：

| Service | Current State | Main Gap |
| --- | --- | --- |
| `user-service` | 只有 gRPC 进程骨架和 health | 缺 proto、service 层、storage、错误模型、用户库 |
| `wallet-service` | 已注册 `wallet/v1` gRPC stub | 缺账务 domain、ledger storage、幂等、错误映射 |
| `activity-service` | 只有 gRPC 进程骨架和 health | 缺 proto、consumer 边界、storage、事件处理骨架 |

补齐顺序不能按文件数量排，应该按跨服务依赖排：

1. 统一 gRPC 错误模型。
2. 补 `user-service` 身份基础。
3. 补 `wallet-service` 账务基础。
4. 补 `activity-service` 消费和活动基础。

## Non Goals

本阶段不做：

- 不实现完整用户资料、关系链、头像昵称体系。
- 不实现真实三方 provider SDK 细节。
- 不实现复杂钱包风控、充值提现、冻结解冻。
- 不实现完整活动规则引擎。
- 不引入重型 DI 框架。
- 不让 gateway、room、im 直接 import 这些服务的 `internal` 包。

## Service Baseline Layout

三个服务都应该收敛到同一种内部结构。

```text
services/<service>/
  cmd/server/main.go
  configs/
    config.yaml
    config.docker.yaml
    config.tencent.example.yaml
  deploy/mysql/initdb/
    001_<service>.sql
  internal/
    app/
      app.go
    config/
      config.go
    domain/
    service/
    storage/
      mysql/
    transport/
      grpc/
        server.go
    integration/
```

目录职责：

| Directory | Responsibility |
| --- | --- |
| `app` | 只做依赖装配、生命周期、health 状态切换 |
| `config` | 只做配置加载和默认值，不做连接创建 |
| `domain` | 领域实体、值对象、领域错误，不依赖 gRPC/MySQL |
| `service` | 用例编排，调用 repository 和外部 integration |
| `storage/mysql` | MySQL repository 实现 |
| `transport/grpc` | protobuf 入参出参转换、错误映射、server 注册 |
| `integration` | 调用其他服务或消费外部事件的 client/consumer |

`transport/grpc` 不能直接写复杂业务规则。业务规则进 `service` 或 `domain`。

## Shared Config Baseline

每个服务的配置至少包含：

```yaml
service_name: user-service
node_id: user-local
grpc_addr: ":13005"
mysql_dsn: "root:root@tcp(mysql:3306)/hyapp_user?parseTime=true"
mysql_auto_migrate: false
```

按服务扩展：

`user-service`：

```yaml
jwt:
  issuer: "hyapp"
  access_token_ttl_sec: 604800
  refresh_token_ttl_sec: 2592000
  signing_alg: "HS256"
  signing_secret: "dev-secret"
```

`wallet-service`：

```yaml
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: false
```

`activity-service`：

```yaml
consumer:
  room_outbox_poll_interval_ms: 1000
  batch_size: 100
```

线上真实密码、密钥和云资源地址只能通过环境或密钥系统注入，不能写进真实配置。

## gRPC Error Model

内部服务不要返回 HTTP envelope。gRPC 成功时返回 protobuf response，失败时返回：

```text
gRPC status code + google.rpc.ErrorInfo.reason
```

gateway 负责把内部错误映射成外部：

```json
{
  "code": "AUTH_FAILED",
  "message": "authentication failed",
  "request_id": "req_xxx",
  "data": null
}
```

### Standard Reasons

| Reason | gRPC Code | HTTP Mapping |
| --- | --- | --- |
| `INVALID_ARGUMENT` | `InvalidArgument` | `400` |
| `NOT_FOUND` | `NotFound` | `404` |
| `CONFLICT` | `AlreadyExists` or `FailedPrecondition` | `409` |
| `UNAUTHORIZED` | `Unauthenticated` | `401` |
| `PERMISSION_DENIED` | `PermissionDenied` | `403` |
| `UNAVAILABLE` | `Unavailable` | `503` |
| `INTERNAL_ERROR` | `Internal` | `500` |

Auth 专用 reason：

| Reason | gRPC Code | HTTP Mapping |
| --- | --- | --- |
| `AUTH_FAILED` | `Unauthenticated` | `401` |
| `PASSWORD_ALREADY_SET` | `AlreadyExists` | `409` |
| `USER_DISABLED` | `PermissionDenied` | `403` |
| `SESSION_EXPIRED` | `Unauthenticated` | `401` |
| `SESSION_REVOKED` | `Unauthenticated` | `401` |

User 专用 reason：

| Reason | gRPC Code | HTTP Mapping |
| --- | --- | --- |
| `INVALID_INVITE_CODE` | `InvalidArgument` | `400` |
| `COUNTRY_CHANGE_COOLDOWN` | `FailedPrecondition` | `409` |
| `COUNTRY_CHANGE_IDENTITY_LOCKED` | `PermissionDenied` | `403` |
| `USER_PROFILE_REQUIRED` | `FailedPrecondition` | `403` |

Wallet 专用 reason：

| Reason | gRPC Code | HTTP Mapping |
| --- | --- | --- |
| `INSUFFICIENT_BALANCE` | `FailedPrecondition` | `409` |
| `DUPLICATE_BILLING_COMMAND` | `AlreadyExists` | `409` |
| `LEDGER_CONFLICT` | `Aborted` | `409` |

Activity 专用 reason：

| Reason | gRPC Code | HTTP Mapping |
| --- | --- | --- |
| `RULE_NOT_ACTIVE` | `FailedPrecondition` | `409` |
| `EVENT_ALREADY_CONSUMED` | `AlreadyExists` | `409` |
| `REWARD_PENDING` | `FailedPrecondition` | `409` |

### Error Implementation Boundary

错误码统一由 `pkg/xerr` 的 catalog 注册，领域层仍不能 import gRPC 包。

目标结构：

```go
type Error struct {
    Reason  string
    Message string
}
```

`transport/grpc` 负责把 `xerr.Error` 转成 gRPC status，并附带 `google.rpc.ErrorInfo`。gRPC code 保持少量 canonical code；业务码只放在 `ErrorInfo.reason` 和 HTTP envelope `code`。

新增业务错误码时必须同时在 `xerr` catalog 注册：

- gRPC canonical code。
- HTTP status。
- App 可见 public code。
- App 可见 public message。
- 是否可重试。

不要在 service 层直接返回 `status.Error(...)`。

## Request Meta

每个新 proto service 都必须有 meta，最少字段：

```protobuf
message RequestMeta {
  string request_id = 1;
  string caller = 2;
  string gateway_node_id = 3;
  int64 sent_at_ms = 4;
  string app_code = 10;
}
```

身份相关接口可以扩展：

```protobuf
string device_id = 5;
string client_ip = 6;
string user_agent = 7;
```

`request_id` 是追踪字段，不是幂等字段。

`app_code` 是多 App 租户键。gateway 必须在外部 HTTP 入口根据包名、平台或 JWT claims 解析出 `app_code`，再写入所有内部 gRPC `RequestMeta`。服务端 repository 查询、唯一键和幂等键都必须带 `app_code`，不能只按 `user_id`、`room_id` 或 `command_id` 判断唯一。

幂等字段必须独立命名：

```protobuf
string command_id = 2;
string idempotency_key = 3;
```

## user-service Foundation

`user-service` 首先承接身份与用户主数据。

### Proto

必须新增：

```text
api/proto/user/v1/auth.proto
api/proto/user/v1/user.proto
```

`auth.proto` 承载：

- `LoginPassword`
- `LoginThirdParty`
- `SetPassword`
- `RefreshToken`
- `Logout`

`user.proto` 首版只放基础查询：

- `GetUser`
- `BatchGetUsers`

不要一开始把资料编辑、关系链、黑名单全塞进去。

### Internal Packages

```text
internal/domain/user
internal/domain/auth
internal/service/auth
internal/service/user
internal/storage/mysql
internal/transport/grpc
```

### MySQL Tables

首批表：

```text
apps
users
user_invite_codes
user_invite_relations
user_invite_validity_policies
user_invite_recharge_progress
user_invite_event_consumption
user_invite_counters
password_accounts
third_party_identities
auth_sessions
login_audit
user_country_change_logs
user_outbox
```

原则：

- `apps` 是 App 注册表，保存 `package_name/platform -> app_code` 映射，由 gateway 解析入口使用。
- `users` 是用户主记录，包含注册资料和注册来源快照；不保存 credential、密码明文或 refresh token 原文。
- `user_invite_codes` 保存用户自己的可分享邀请码，按 `app_code + code` 唯一。
- `user_invite_relations` 保存注册邀请关系事实，按 `app_code + invited_user_id` 保证一个用户最多一个邀请人。
- `user_invite_validity_policies` 保存有效邀请策略，默认累计充值 `80000` `COIN`，但必须允许后台按 App/区域配置。
- `user_invite_recharge_progress` 保存被邀请用户累计充值进度和是否已成为有效邀请。
- `user_invite_event_consumption` 保存钱包充值事件消费幂等，避免 wallet outbox 重投导致重复累计。
- `user_invite_counters` 保存我的页和后台列表使用的邀请计数 read model，包含 `invite_count` 和 `valid_invite_count`。
- `password_accounts` 保存已登录用户手动设置后的密码 hash，不承载独立账号体系。
- `third_party_identities` 保存 provider 绑定。
- `auth_sessions` 保存 refresh token hash 和会话状态。
- `login_audit` 不影响主链路成功与否。
- `user_country_change_logs` 保存国家修改审计，并作为滚动 30 天冷却判断来源。
- `user_outbox` 保存 `UserRegistered`、`UserInvited`、`UserInviteBecameValid` 等用户领域事件，供奖励、活动、消息和风控异步消费。

邀请统计规则：

- `invite_count` 表示成功填写邀请码并绑定关系的人数，来源是 `user_invite_relations`。
- `valid_invite_count` 表示被邀请用户累计成功充值金币达到有效邀请策略门槛的人数，默认门槛是 `80000` `COIN`。
- 有效邀请只消费 `wallet-service` 的充值事实事件；钱包不理解邀请关系，user-service invite module 不读取钱包余额作为判断依据。
- 后台修改有效邀请门槛只影响后续达成判断；已达成的有效邀请事实不自动重算。

### Readiness

`ready=true` 需要：

- gRPC listener 已启动。
- MySQL ping 成功。
- token signing config 有效。

`user-service` 不应该依赖 room、wallet、im 的 ready。

## wallet-service Foundation

`wallet-service` 首先承接送礼扣费的账务语义。

### Proto

当前已有：

```text
api/proto/wallet/v1/wallet.proto
```

需要检查并补齐：

- `DebitGift`
- 请求内必须有 `command_id` 或 `idempotency_key`
- 响应内必须有 `billing_receipt_id`

后续再追加：

- `CreditLuckyReward`
- `GetBalance`
- `Freeze`
- `Unfreeze`

### Internal Packages

```text
internal/domain/wallet
internal/domain/ledger
internal/service/wallet
internal/storage/mysql
internal/transport/grpc
```

### MySQL Tables

首批表：

```text
wallet_accounts
wallet_transactions
wallet_entries
wallet_outbox
wallet_gift_prices
```

账务规则：

- 钱包所有账户、交易、分录、礼物价格和 outbox 都必须带 `app_code`；同一个 `command_id` 在不同 App 下互不冲突。
- 钱包余额不能只存在内存。
- 每次账务动作必须写 transaction 和 entries。
- 每个账务命令必须通过 `wallet_transactions.app_code + command_id` 幂等。
- 跨服务事件必须先写 `wallet_outbox`，再由 worker 投递。
- 幂等命中必须返回原始 `billing_receipt_id`，不能重复扣费。

### Readiness

`ready=true` 需要：

- gRPC listener 已启动。
- MySQL ping 成功。
- ledger repository 可写或至少可执行轻量事务探测。

`wallet-service` 不依赖 room-service ready。room 调 wallet 是调用方依赖，不是被调用方依赖。

## activity-service Foundation

`activity-service` 首先承接房间 outbox 事件消费，不进入 Room Cell 主链路。

### Proto

建议新增：

```text
api/proto/activity/v1/activity.proto
```

首版 proto 可以只定义：

- `PingActivity`
- `GetActivityStatus`

活动消费本身可以先不走 gRPC，而是通过 room outbox / Redis Stream / MQ 接入。不要为了有 gRPC 而把事件消费伪装成同步 RPC。

### Internal Packages

```text
internal/domain/activity
internal/service/activity
internal/consumer/room
internal/storage/mysql
internal/transport/grpc
```

### MySQL Tables

首批通用表：

```text
activities
activity_event_consumption
activity_outbox
```

具体活动再建自己的业务表。例如幸运礼物使用独立规则、奖池、抽奖记录表。

消费规则：

- 消费 room outbox 事件必须幂等。
- 消费幂等键必须带 `app_code`，避免不同 App 的同名 `event_id` 互相阻断。
- 消费位点不能只存在内存。
- 处理失败不能阻塞 Room Cell。
- 活动结算失败要进入 activity 自己的 outbox 或 retry 表。

### Readiness

`ready=true` 需要：

- gRPC listener 已启动。
- MySQL ping 成功。
- consumer 依赖可访问。
- 当前节点不在 draining。

如果 consumer 来源还没接入，readiness 不要假装消费健康，只报告 gRPC 和 MySQL。

## Gateway Integration Baseline

gateway 接入这些服务时只做：

- HTTP JSON 入参解析。
- 生成 `request_id` 并透传到内部调用。
- 调内部 gRPC。
- 把 gRPC status + reason 映射为 HTTP envelope。

gateway 不做：

- 密码 hash。
- 钱包余额计算。
- 活动规则判断。
- 用户主数据写入。

建议 client 包：

```text
services/gateway-service/internal/client/user_client.go
services/gateway-service/internal/client/wallet_client.go
```

gateway 不一定直接调用 wallet。当前送礼仍是 gateway -> room -> wallet。

## Storage Strategy

本地和测试：

- Docker MySQL。
- Docker Redis。
- 每个服务独立 schema 或表名前缀。

线上：

- 腾讯云 MySQL。
- 腾讯云 Redis。
- 服务启动不自动迁移线上 schema。

每个服务都需要自己的 MySQL init 目录：

```text
services/user-service/deploy/mysql/initdb/001_user_service.sql
services/room-service/deploy/mysql/initdb/001_room_service.sql
services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql
services/activity-service/deploy/mysql/initdb/001_activity_service.sql
../deploy-platform/deploy/mysql/initdb/006_admin_database.sql
```

本地开发库可以整体清空后重放 initdb；线上 schema 迁移必须用专门 migration，不允许依赖容器 init hook。

`memory` storage 只用于单元测试，不作为本地联调默认路径。

## Health And Lifecycle

三个服务都必须注册标准 gRPC Health Checking Protocol。

基础生命周期：

```text
starting -> serving -> draining -> stopped
```

关闭流程：

1. 标记 draining。
2. health 变成 `NOT_SERVING`。
3. 停止接收新请求。
4. 等待当前请求完成。
5. 关闭 gRPC server。
6. 关闭数据库连接和 consumer。

activity-service 额外需要：

- 停止拉取新事件。
- 等当前批次处理结束。
- 提交或回滚消费位点。

## Test Baseline

每个服务至少需要四类测试。

### Contract Tests

覆盖 proto 契约和 transport 行为：

- 成功 response 字段稳定。
- 业务错误返回正确 gRPC code 和 reason。
- 未知枚举有安全默认行为。

### Service Tests

覆盖 service 层：

- 不依赖真实 gRPC。
- 用隔离 MySQL schema 和生产 repository。
- 验证核心用例和错误分支。

### Storage Tests

覆盖 MySQL repository：

- 唯一键冲突。
- 事务回滚。
- 幂等命中。
- 并发冲突。

### Integration Tests

覆盖跨服务调用：

- gateway -> user。
- room -> wallet。
- room outbox -> activity。

首版可以先只跑单服务集成测试，跨服务集成后续再补。

## Implementation Order

推荐顺序：

1. 写 `docs/gRPC错误模型.md` 或把本文件错误模型抽成独立文档。
2. 扩展 `pkg/xerr`，支持 reason 和 transport 映射。
3. 新增 `api/proto/user/v1/auth.proto` 和 `user.proto`。
4. 补 `user-service` 的 app/service/domain/storage/transport 骨架。
5. 补 `user-service` MySQL schema。
6. gateway 增加 user client 和 `/api/v1/auth/*`。
7. 补 `wallet-service` ledger/idempotency/domain/storage。
8. 补 `wallet-service` MySQL schema。
9. 补 `activity-service` proto、consumer、consumption storage。
10. 补 `activity-service` MySQL schema。

不要同时把三个服务的业务都铺开。先让一个服务具备完整工程闭环，再复制结构到其他服务。

## Acceptance Criteria

一个服务骨架完成的标准：

- 有稳定 protobuf contract。
- 有 gRPC server 注册真实 service。
- 有 domain/service/storage/transport 分层。
- 有 MySQL schema。
- 有 gRPC error reason 映射。
- 有 ready health 检查依赖。
- 有至少一组 service 单测和 transport 错误测试。
- 不依赖其他服务的 `internal` 包。
- 本地 Docker 能以 `13xxx` 端口启动。
