hyapp-server/docs/service-foundation-technical-design.md
2026-05-09 13:36:41 +08:00

607 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: 1800
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=Local"
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` |
| `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/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-error-model.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` 端口启动。