Herald

架构

Rust 后端(六边形架构)+ React 前端(TanStack 全家桶),单体部署,所有组件跑在一个进程里。

Rust 后端(六边形架构)+ React 前端(TanStack 全家桶),单体部署,所有组件跑在一个进程里。

目录结构

backend/
├── entity/              # SeaORM 实体定义(数据库表映射)
├── domain/              # 领域层 — 纯业务逻辑,零外部依赖
├── infra/               # 基础设施层 — 数据库、Redis、第三方 API 的具体实现
├── infra-creem/         # Creem 支付集成
├── infra-stripe/        # Stripe 支付集成
├── infra-shopify/       # Shopify 集成
├── core/                # 组装层 — 依赖注入、ApplicationService Builder
├── api/                 # 主 API crate(Axum 路由注册、中间件、AppState)
├── api-base/            # 共享 API 工具(AppState 定义、通用 HTTP 工具)
├── api-billing/         # 计费相关 handler(积分账户、Entitlement 映射、订阅、支付、发票、webhook)
├── api-admin/           # 管理后台 handler(用户管理、角色、权限定义)
├── api-auth/            # 认证 handler(注册、登录、密码重置)
├── api-ext/             # 外部 API handler(API Key 认证,供第三方调用)
├── api-oauth/           # OAuth handler(GitHub/Google/微信登录)
├── api-points/          # 积分 handler(余额查询、消费、充值)
├── worker/              # 后台任务(积分过期、积分预发放、发票逾期标记)
├── app/                 # 入口(main.rs、数据库迁移)
├── sdk/                 # 发布给第三方用的 Rust SDK crate
├── test-db/             # 测试数据库工具(testcontainers)
├── test-support/        # 测试辅助
└── integration-tests/   # 集成测试

frontend/                # React 管理前端
docker/                  # Dockerfile

技术栈

后端:Rust 2024 edition + Axum 0.8 + SeaORM 1.1 + sqlx 0.8 + PostgreSQL 16+ + Redis + Tokio。 前端:React 19 + TypeScript + TanStack Router/Query/Form + Tailwind CSS v4 + Vite 7。

选型理由不是凑热门,而是每项都有具体约束:

  • Axum 而不是 actix-web:tokio 原生,和 tower 中间件生态无缝衔接,trait-based handler 写起来更干净。
  • SeaORM + sqlx 并存:SeaORM 处理常规 CRUD(entity crate 自动生成),sqlx 处理复杂查询和迁移。两套连接池指向同一个 PostgreSQL,SeaORM 底层用的就是 sqlx。
  • Redis:session 存储、权限缓存、限流(Redis Functions)、幂等键。用自建的 RedisConnectionManager 包装,测试时自动切到 DB 1 隔离数据。
  • TanStack Router:类型安全的文件路由,$realmId 这类路径参数直接推导类型。比 react-router 的运行时匹配靠谱。
  • API 类型生成:后端用 utoipa 导出 OpenAPI JSON,前端用 @hey-api/openapi-ts 生成 TypeScript client 和类型。后端接口变了,npm run generate-api 一条命令同步。

核心流程

一个典型请求的路径:

HTTP Request
  → Axum Router(路由匹配)
  → inject_identity 中间件(从 session cookie 或 Bearer token 解析用户身份)
  → Handler(提取 AppState、解析请求体)
  → Domain Service(业务逻辑,通过 trait 调用 Repository)
  → Repository 实现(infra 层,操作 PostgreSQL 或 Redis)
  → SeaORM Entity / sqlx 查询
  → PostgreSQL

Handler 不直接写 SQL。它调 Domain Service 的方法,Service 通过 trait(port)抽象数据访问,infra 层提供具体实现。这是六边形架构的核心约束:domain/ crate 的 Cargo.toml 里引入了 sea-orm、sqlx 等依赖,但仅用于错误类型转换(From impl),不直接操作数据库或发起 HTTP 请求。

权限检查走的另一条线:Handler → RedisPermissionChecker → Redis 缓存 → PostgreSQL(缓存 miss 时)。权限模型使用 resource:action 对(如 product:read),通过 realm_idclient_id 确定作用域。action 有层级关系:manage 覆盖 viewcreatemanage 本身;createview 只覆盖自身。

模块说明

entity — 数据库表映射

SeaORM 自动生成的实体定义。覆盖用户、角色、权限、订阅、积分、支付、发票等。每个 .rs 文件对应一张表,包含列定义、关系、默认值。

这个 crate 没有业务逻辑,纯粹的 ORM 映射层。改表结构时先写迁移 SQL(backend/app/migrations/),然后重新生成 entity。

domain — 领域层

纯业务逻辑。Cargo.toml 依赖以基础库为主(serde、uuid、chrono、bcrypt 等),但也引入了 sea-orm、sqlx、redis、reqwest、axum——这些是为了实现 From<ExternalError> 错误转换,domain 层本身不直接操作数据库或发起 HTTP 请求。

关键子模块:

模块职责
authentication登录、注册、session 管理
authorizationRBAC 权限模型(角色、策略、权限定义)
audit审计事件模型、事件采集(用户管理、RBAC 变更、认证事件)
billing积分账户目录、Entitlement 映射、订阅投影、支付 webhook 处理
points积分账户(按积分账户分池)、充值、消费、过期、幂等
payment_attempt统一支付尝试(抽象不同支付渠道)
purchase购买履约(一次性充值 or 订阅开通)
realm租户管理
realm_config租户配置(支付渠道密钥等)
legal用户协议/隐私政策版本化、用户同意记录、账户自助注销
client第三方应用管理
client_appClient App 实体与逻辑
client_api_keysAPI Key 管理
oauthOAuth 提供商配置
user用户实体与查询
user_totpTOTP 二次认证
totp_key_managementTOTP 密钥管理
rbac_initRealm 初始化时创建默认角色和权限
dashboard仪表盘数据聚合
common共享领域工具类型
security_constants安全相关常量定义

每个子模块内部有 ports/(trait 定义)、entities/(领域实体)、service.rs(业务逻辑)。Ports 是 Repository trait,定义了 find_by_idsaveupdate 这类接口,但不关心底层是 PostgreSQL 还是内存。

infra — 基础设施实现

domain 层 trait 的具体实现。PostgresXxxRepository 命名,一个 trait 对应一个实现。

除了数据库仓库,还有:

  • redis/RedisConnectionManager,连接池 + 测试隔离
  • authorization/RedisPermissionChecker,权限缓存
  • billing/ — 发票 PDF 生成(IronPress)、加密密钥管理

支付渠道客户端是独立的 crate(infra-creeminfra-stripeinfra-shopify),不放在主 infra 里。原因是避免引入不需要的 provider SDK 依赖——如果你只用 Stripe,不会把 Shopify 的 SDK 也编译进去。

core — 组装层

做两件事:

  1. ApplicationService Builder:把 domain services 和 infra repositories 组装到一起。Builder 模式,依次注入 database、redis、permission_checker,最后 .build() 产出 ApplicationService
  2. re-export:把 herald_domain 重导出为 domainherald_entityentityherald_infrainfrastructure。方便其他 crate 直接 use herald_core::domain::xxx

api 系列 — HTTP 接口

8 个 crate 组成 API 层:

Crate职责认证方式
api主入口:路由注册、中间件编排、Swagger UI、OpenAPI spec 合并、审计日志查询、用户协议/隐私政策与同意记录
api-baseAppState 定义(共享给所有 api 子 crate)
api-auth注册、登录、密码重置、邮箱验证session
api-admin用户 CRUD、角色管理、权限定义管理session + inject_identity
api-billing积分账户目录、Entitlement 映射、订阅投影、支付 webhook(Stripe/Creem)、发票、一次性购买混合
api-oauthOAuth 登录(GitHub/Google/微信)、Device Code Grant(RFC 8628)、OAuth 配置管理混合
api-ext第三方 API:权限检查、订阅查询、积分余额和消费,按 API Key 绑定的 Client App 隔离API Key
api-points积分余额、交易历史、消费、充值session 或 API Key

api crate 的 create_api_routes() 是路由注册的唯一入口。它把子 crate 的路由 nest 到统一前缀下,挂上 inject_identity 中间件。每个子 crate 独立定义自己的 ApiDoc(utoipa OpenApi spec),最后在 build_openapi_spec() 里 merge 成一份完整的 OpenAPI 文档。

拆成多个 crate 是为了编译速度。api-billing 最重(webhook 处理、类型定义),改一个支付渠道的 handler 不应该触发 api-auth 重编译。

worker — 后台任务

定时执行的循环任务,和 API server 跑在同一个进程里:

  • 积分过期:每小时扫描过期积分,批量标记为已过期
  • 积分预发放:每 5 分钟扫描即将到期的积分发放计划(订阅续费、免费周期),在生效时间前提前预发,吸收 webhook 与调度延迟。这是性能优化而非正确性保障——即使该任务不运行,余额查询与消费也会在请求路径同步补发已到期权益
  • 发票逾期标记:每小时扫描未支付发票,标记逾期状态

WorkerConfig 接受泛型 R: InvoiceRepository,方便测试时注入 mock。实际生产用 PostgresInvoiceRepository

app — 入口

main.rs 的启动流程,顺序固定:

  1. 解析命令行参数。--export-openapi <path> 可以导出 OpenAPI JSON 然后退出(CI 里前端构建前用到)
  2. 加载配置(HERALD_CONFIG 环境变量或 config.toml
  3. 初始化 tracing 日志
  4. 连接 PostgreSQL(SeaORM 连接池,参数从配置读取)
  5. 执行数据库迁移(sqlx migrate)
  6. 连接 Redis 并做 health check
  7. 初始化积分过期服务(points repository + expiration service)
  8. 启动 API server(herald_api::run_with_config,内部再初始化所有 service 并绑定 Axum 路由)
  9. 启动 Worker(定时任务循环)

8 和 9 并发运行,tokio::select! 等待任意一个结束或收到 shutdown 信号(Ctrl+C 或 SIGTERM)。

sdk — Rust SDK

发布给第三方应用的 Rust crate。封装了 /api/ext/ 下的所有接口:

  • check_permission — 权限检查(带 moka 本地缓存,自动失效)
  • get_subscription — 订阅状态查询,返回 entitlement_key
  • get_balance / consume_points — 积分查询和消费

构造函数接收 base_urlapi_key,所有请求自动带上 X-API-Key header。权限检查有本地缓存(moka),5 分钟 TTL,token 过期时批量清除关联缓存。

API Key 绑定到一个 Client App。默认绑定 admin-api-client 的 Key 是 realm 级 Key,可以访问同一 realm 下所有 Client App 的外部 API 资源;绑定普通 Client App 的 Key 只能访问该 Client App 的权限检查、订阅和积分资源。api-ext 在处理订阅、积分和权限检查时都会校验这个范围;API Key 认证即使命中缓存,也会重新检查绑定 Client App 是否仍启用,因此禁用 Client App 会立即阻断它的 API Key。

frontend — React 管理后台

基于 TanStack Router 的文件路由,路由结构直接看 frontend/src/routes/ 目录:

$realmId/
├── auth/          # 登录、注册、邮箱验证
├── user/          # 用户个人中心(资料、安全、积分、订阅、发票)
├── manage/        # 管理后台(用户、角色、权限、Entitlement 映射、计费、积分、审计日志、Client App、设置)
├── points/        # 积分余额和交易历史
├── subscription/  # 用户订阅状态
└── device/        # Device Code 授权页面

API 调用全部走 frontend/src/lib/api-generated/ 里自动生成的 client。后端接口变更后,跑 npm run generate-api(先导出 OpenAPI JSON,再跑 openapi-ts 生成 TypeScript 类型)重新生成。

TanStack Query 管理服务端状态,TanStack Form + Zod 处理表单验证,Radix UI 提供无障碍的底层组件(Dialog、Select、Tabs 等),Tailwind CSS v4 做样式。

数据库迁移

按时间戳命名,放在 backend/app/migrations/。启动时自动执行(sqlx::migrate!),不需要手动跑脚本。

用户协议、隐私政策与账户注销

Herald 把「知情同意」和「被遗忘权」做成了可配置的标准能力,而不是写死在代码里。这样每个 Realm 可以决定:用平台提供的默认条款,还是用自己的条款;用户注册/登录时如何同意;用户如何自助注销账户。

协议版本化

平台维护一份默认的《用户协议》和《隐私政策》模板。每个 Realm 可以选择:

  • 用默认模板:不做事,直接继承平台默认条款。
  • 自定义覆盖:Realm Admin 在 Settings → Legal 里编辑并发布自己的版本。发布后,该 Realm 的用户看到的是自定义版本,且版本号递增。
  • 回退默认:如果 Realm 不想再用自己的版本,可以回退。回退不是删掉旧版本,而是把当前默认模板的内容快照成一个新的自定义版本发布,因此版本号继续递增,已同意的用户需要重新同意。

所有版本都保存在 legal_agreement_version 表里,realm_id 为空表示平台默认模板,非空表示 Realm 自定义。每次发布都会产生新版本并立即生效,历史版本也保留,方便审计时证明「某用户在某个时间点同意的是哪个版本」。

三段式同意闸门

用户在三个地方会被要求同意当前生效的协议版本:

  1. 注册时:注册表单里有同意复选框,未勾选不能提交。注册成功后,系统记录用户同意了当前版本。
  2. 登录时:登录提交即表示同意当前生效版本。如果用户上次同意后协议又更新了,登录会跳转到重新同意页面。
  3. 协议更新后:已登录用户访问核心功能时,如果协议版本变了,会弹出重新同意提示。同意则继续,拒绝则只能退出登录或注销账户。

用户的同意记录绑定到具体版本 ID,而不是「最新版」这种模糊概念。这样审计时能精确追溯。

账户自助注销(软删除)

普通用户可以在个人中心的安全设置里自助注销账户。注销不是立刻从数据库删掉所有数据,而是:

  • 账户状态变成「已注销」,不能再登录;
  • 邮箱、昵称、密码等个人身份信息被匿名化,无法再识别到具体个人;
  • 所有登录会话立即失效;
  • OAuth 绑定、TOTP 二次认证配置被清除;
  • 生效中的订阅被联动取消;
  • 保留账户骨架和审计、合规所需的最小数据。

一次性购买(如积分包)已经交付的不会退款。注销后没有宽限期,也不支持自助恢复。

On this page