# 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// cmd/server/main.go configs/ config.yaml config.docker.yaml config.tencent.example.yaml deploy/mysql/initdb/ 001_.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` | | `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` 端口启动。