570 lines
14 KiB
Markdown
570 lines
14 KiB
Markdown
# 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` |
|
||
|
||
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`,但领域层仍不能 import gRPC 包。
|
||
|
||
目标结构:
|
||
|
||
```go
|
||
type Error struct {
|
||
Reason string
|
||
Message string
|
||
}
|
||
```
|
||
|
||
`transport/grpc` 负责把 `xerr.Error` 转成 gRPC status,并附带 `google.rpc.ErrorInfo`。
|
||
|
||
不要在 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
|
||
password_accounts
|
||
third_party_identities
|
||
auth_sessions
|
||
login_audit
|
||
user_country_change_logs
|
||
```
|
||
|
||
原则:
|
||
|
||
- `apps` 是 App 注册表,保存 `package_name/platform -> app_code` 映射,由 gateway 解析入口使用。
|
||
- `users` 是用户主记录,包含注册资料和注册来源快照;不保存 credential、密码明文或 refresh token 原文。
|
||
- `password_accounts` 保存已登录用户手动设置后的密码 hash,不承载独立账号体系。
|
||
- `third_party_identities` 保存 provider 绑定。
|
||
- `auth_sessions` 保存 refresh token hash 和会话状态。
|
||
- `login_audit` 不影响主链路成功与否。
|
||
- `user_country_change_logs` 保存国家修改审计,并作为滚动 30 天冷却判断来源。
|
||
|
||
### 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` 端口启动。
|