From 4d6e61f77dc99ec76c7cf352df62abb4282386c5 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 2 Aug 2026 00:09:58 +0800 Subject: [PATCH] docs(server): cleanup ai context --- apps/server/.gitignore | 1 + apps/server/docs/.gitkeep | 0 apps/server/docs/ai-context/README.md | 91 -- apps/server/docs/ai-context/account-ban.md | 61 -- .../docs/ai-context/account-deletion.md | 184 ---- .../docs/ai-context/admin-flux-grants.md | 106 -- .../docs/ai-context/architecture-overview.md | 163 --- apps/server/docs/ai-context/auth-and-oidc.md | 307 ------ .../docs/ai-context/billing-architecture.md | 115 --- .../config-and-naming-conventions.md | 166 ---- .../docs/ai-context/data-model-and-state.md | 237 ----- .../docs/ai-context/email-auth-resend.md | 78 -- apps/server/docs/ai-context/flux-meter.md | 114 --- .../docs/ai-context/langfuse-tracing.md | 111 --- .../ai-context/llm-router-codex-followups.md | 111 --- .../docs/ai-context/metrics-ownership.md | 289 ------ .../ai-context/observability-conventions.md | 293 ------ .../docs/ai-context/observability-metrics.md | 180 ---- .../product-analytics-dashboard-setup.md | 366 ------- .../product-analytics-instrumentation.md | 939 ------------------ .../ai-context/redis-boundaries-and-pubsub.md | 180 ---- apps/server/docs/ai-context/stripe-pricing.md | 137 --- .../docs/ai-context/transport-and-routes.md | 286 ------ .../verifications/account-deletion.md | 132 --- .../verifications/admin-flux-grants.md | 58 -- .../verifications/admin-user-balance-ban.md | 61 -- .../ai-context/verifications/email-auth.md | 85 -- .../flux-unbilled-exploit-fix.md | 127 --- .../flux-unbilled-reconciliation.md | 195 ---- .../verifications/langfuse-tracing.md | 138 --- .../ai-context/verifications/llm-router.md | 153 --- .../posthog-forwarding-and-pageview.md | 26 - .../verifications/product-analytics-smoke.md | 413 -------- .../ai-context/verifications/streaming-tts.md | 231 ----- .../docs/ai-context/workers-and-runtime.md | 122 --- ...-15-llm-router-replacement-requirements.md | 275 ----- ...at-llm-tts-router-replacing-knoway-plan.md | 816 --------------- 37 files changed, 1 insertion(+), 7346 deletions(-) create mode 100644 apps/server/.gitignore create mode 100644 apps/server/docs/.gitkeep delete mode 100644 apps/server/docs/ai-context/README.md delete mode 100644 apps/server/docs/ai-context/account-ban.md delete mode 100644 apps/server/docs/ai-context/account-deletion.md delete mode 100644 apps/server/docs/ai-context/admin-flux-grants.md delete mode 100644 apps/server/docs/ai-context/architecture-overview.md delete mode 100644 apps/server/docs/ai-context/auth-and-oidc.md delete mode 100644 apps/server/docs/ai-context/billing-architecture.md delete mode 100644 apps/server/docs/ai-context/config-and-naming-conventions.md delete mode 100644 apps/server/docs/ai-context/data-model-and-state.md delete mode 100644 apps/server/docs/ai-context/email-auth-resend.md delete mode 100644 apps/server/docs/ai-context/flux-meter.md delete mode 100644 apps/server/docs/ai-context/langfuse-tracing.md delete mode 100644 apps/server/docs/ai-context/llm-router-codex-followups.md delete mode 100644 apps/server/docs/ai-context/metrics-ownership.md delete mode 100644 apps/server/docs/ai-context/observability-conventions.md delete mode 100644 apps/server/docs/ai-context/observability-metrics.md delete mode 100644 apps/server/docs/ai-context/product-analytics-dashboard-setup.md delete mode 100644 apps/server/docs/ai-context/product-analytics-instrumentation.md delete mode 100644 apps/server/docs/ai-context/redis-boundaries-and-pubsub.md delete mode 100644 apps/server/docs/ai-context/stripe-pricing.md delete mode 100644 apps/server/docs/ai-context/transport-and-routes.md delete mode 100644 apps/server/docs/ai-context/verifications/account-deletion.md delete mode 100644 apps/server/docs/ai-context/verifications/admin-flux-grants.md delete mode 100644 apps/server/docs/ai-context/verifications/admin-user-balance-ban.md delete mode 100644 apps/server/docs/ai-context/verifications/email-auth.md delete mode 100644 apps/server/docs/ai-context/verifications/flux-unbilled-exploit-fix.md delete mode 100644 apps/server/docs/ai-context/verifications/flux-unbilled-reconciliation.md delete mode 100644 apps/server/docs/ai-context/verifications/langfuse-tracing.md delete mode 100644 apps/server/docs/ai-context/verifications/llm-router.md delete mode 100644 apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md delete mode 100644 apps/server/docs/ai-context/verifications/product-analytics-smoke.md delete mode 100644 apps/server/docs/ai-context/verifications/streaming-tts.md delete mode 100644 apps/server/docs/ai-context/workers-and-runtime.md delete mode 100644 apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md delete mode 100644 apps/server/docs/plans/2026-05-15-001-feat-llm-tts-router-replacing-knoway-plan.md diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 000000000..373aeb637 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1 @@ +docs/ai-context diff --git a/apps/server/docs/.gitkeep b/apps/server/docs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md deleted file mode 100644 index 7bf727bd7..000000000 --- a/apps/server/docs/ai-context/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# AIRI Server AI Context - -这组文档面向后续 AI / 开发者协作,目标是让人快速回答四个问题: - -1. 服务端是怎么启动和组装的 -2. 每条 API / WS 请求最终落到哪个服务 -3. 哪些状态以 Postgres 为真相源,哪些只是缓存或派生数据 -4. 计费、充值、事件分发这些高风险链路有哪些约束 - -## 文档索引 - -- `architecture-overview.md` - - 入口、依赖注入、应用装配、核心边界 -- `transport-and-routes.md` - - HTTP / WebSocket 接口面、路由到服务映射、鉴权与中间件 -- `data-model-and-state.md` - - 主要表、状态归属、缓存边界(事件队列层已拆掉) -- `workers-and-runtime.md` - - 单 `api` role、无后台 loop、运行时约束(admin grant / Stripe webhook 等都同步在请求线程) -- `redis-boundaries-and-pubsub.md` - - Redis key / channel 收口、Pub/Sub 边界、运行时校验约束 -- `config-and-naming-conventions.md` - - `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO -- `billing-architecture.md` - - 计费链路专项说明,重点看 Flux ledger / Stripe 幂等 -- `stripe-pricing.md` - - Flux 充值定价以 Stripe Product/Price 为单一真相源,多币种 / 缓存 / 运营操作 -- `flux-meter.md` - - Sub-Flux 计量服务(TTS/STT 等)的债务账本机制与复用指南 -- `observability-conventions.md` - - traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界,SemconvStability 迁移、Counter priming、Dashboard 变量陷阱 -- `observability-metrics.md` - - 全量 metric 目录(按域分组:HTTP / Auth / Engagement / Revenue / GenAI / Email / Rate limit / Runtime),含名字、类型、Labels、落点 -- `metrics-ownership.md` - - 指标分层规则:什么走 Grafana / 什么走 PostHog / 什么是 Postgres truth;含 7 题判定 Checklist、PostHog 事件命名约定、当前指标归属总表、PostHog 接入路线图 -- `product-analytics-instrumentation.md` - - 面向社区 / 产品问题的埋点补充方案:上手激活、Provider 配置、TTS 音色、语音输入、反馈、看板和异常播报 -- `product-analytics-dashboard-setup.md` - - 产品分析看板落地说明:PostHog insights、Grafana 产品事件面板、告警表达式;不含 Discord / QQ 同步和日报 / 周报脚本 -- `auth-and-oidc.md` - - 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录 -- `email-auth-resend.md` - - Resend 接入、Better Auth 四个邮件 callback、范围 / 决策 / 不做项 -- `account-deletion.md` - - 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型 -- `admin-flux-grants.md` - - Admin 批量发 FLUX(活动赠送):单一同步 POST,无 batch 表无后台 loop,`adminGuard` 邮箱白名单 + 可选 `idempotencyKey` -- `account-ban.md` - - Admin 授权改 role-based(better-auth `admin` 插件,删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生端点(`user.banned`),`resolveRequestAuth` + userinfo guard 做 OIDC JWT 热路径立即生效;改余额(`setFlux`)保留自建;含 disabledPaths 端点收口与「dashboard 为何不上」的决策 -- `verifications/email-auth.md` - - 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据 -- `verifications/account-deletion.md` - - 账号注销端到端验证:what's verified(schema/typecheck/units)和 what's pending(live DB + Resend + Stripe trace) -- `verifications/admin-flux-grants.md` - - Admin 同步发 FLUX 路径:同步 grant / dry-run / adminGuard 拒绝(架构刚从 batch 切换到同步,待重新实测) -- `verifications/flux-unbilled-exploit-fix.md` - - Unpaid-usage exploit 修补(commit `7267b0d6b`)的代码层验证 + 残余 gap(TTS flux-meter 未适配 partial-debit)+ follow-up 清单 -- `verifications/flux-unbilled-reconciliation.md` - - 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议 -- `verifications/admin-user-balance-ban.md` - - Admin role 鉴权 / 封禁热路径闸 / 改余额:role adminGuard、resolveRequestAuth+userinfo 封禁、setFlux 的真实 PGlite/Hono 执行证据(含 flux-grants 集成测试走 role),及 better-auth admin 端点本身待端到端实测 -- `verifications/product-analytics-smoke.md` - - 产品分析埋点上线冒烟清单:PostHog journey events、Postgres TTS metadata、Grafana Product Analytics row、Prometheus label 安全边界 - -## 快速结论 - -- `apps/server/src/app.ts` 是唯一的 API 应用装配入口。 -- 服务端采用 `Hono + injeca + Drizzle + Redis + better-auth`。 -- 路由层整体较薄,业务逻辑主要在 `src/services/`。 -- **Postgres 是所有余额与计费状态的唯一真相源**,Redis 只做缓存、KV、Pub/Sub。计费链路不再使用 Redis Streams。 -- WebSocket 只用于聊天同步,跨实例广播依赖 Redis Pub/Sub。 -- 对外 LLM 能力不是本地推理,而是转发到配置里的 gateway,再按 usage / fallback rate 扣 Flux。 - -## 修改代码前建议先看 - -- 改 API 入口或新增依赖:先看 `architecture-overview.md` -- 改某个接口行为:先看 `transport-and-routes.md` -- 改表结构、缓存或幂等:先看 `data-model-and-state.md` -- 想加任何"异步副作用 / 后台 loop":先看 `workers-and-runtime.md` 的"运行时修改建议" -- 改 Redis key、Pub/Sub 边界:先看 `redis-boundaries-and-pubsub.md` -- 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md` -- 改扣费、充值、Stripe:先看 `billing-architecture.md` -- 改 Flux 充值价格 / 多币种 / Stripe Product/Price:先看 `stripe-pricing.md` -- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md` -- 加新 metric / 找当前 metric 全量列表:先看 `observability-metrics.md` -- 决定新指标该走 Grafana 还是 PostHog:先看 `metrics-ownership.md` -- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md` -- 改邮件 service / Better Auth 邮件 callback:先看 `email-auth-resend.md` -- 改账号注销 / 业务 service 的 `deleteAllForUser`:先看 `account-deletion.md` -- 改 admin 发 FLUX 路径:先看 `admin-flux-grants.md` -- 改 TTS / STT / embedding 等 sub-Flux 计量:先看 `flux-meter.md` diff --git a/apps/server/docs/ai-context/account-ban.md b/apps/server/docs/ai-context/account-ban.md deleted file mode 100644 index 984d9f495..000000000 --- a/apps/server/docs/ai-context/account-ban.md +++ /dev/null @@ -1,61 +0,0 @@ -# Admin Access (Role) + Account Ban + Balance Override - -服务端的 admin 能力统一在 better-auth 内置 `admin` 插件的 **role** 体系下,不再用 `ADMIN_EMAILS` 环境变量白名单。 - -## 授权模型:role-based - -- `auth.ts` 启用 `admin({ adminRoles: ['admin'] })`。它给 `user` 表加 `role / banned / banReason / banExpires`,给 `session` 加 `impersonatedBy`(schema 手写进 `schemas/accounts.ts`,字段名与插件一致,迁移 `drizzle/0013_naive_groot.sql`)。 -- 自建的 `/api/admin/*` 路由用 `middlewares/admin-guard.ts` 的 `adminGuard`:读 `c.get('user').role`,命中 `'admin'`(支持逗号分隔多角色)才放行,否则 401(无 user)/ 403(无 admin role)。 -- **没有 env 白名单,也没有自动 seed**。第一个 admin 手动设:`UPDATE "user" SET role = 'admin' WHERE email = '...';`。在那之前没人能访问任何 admin 端点(自建的和 better-auth 的都不行)。 - -## better-auth admin 端点(收敛后的 ban/unban 在这里) - -- 账号封禁/解封用 better-auth 原生端点:`POST /api/auth/admin/ban-user`、`/api/auth/admin/unban-user`(body 用 `userId`,可带 `banReason` / `banExpiresIn`)。调用者需要 admin role(插件内部 `hasPermission` 校验)。 -- ban 会写 `user.banned = true` 并 `deleteSessions(userId)`。`banExpires` 到点后,插件在下次登录的 `session.create.before` 自动翻回 `banned = false`。 -- **危险端点用 `disabledPaths` 关掉**(`auth.ts`):`create-user / update-user / set-role / set-user-password / remove-user / impersonate-user / stop-impersonating`。只留读 + ban/unban + session 管理子集(list-users / ban-user / unban-user / list-user-sessions / revoke-user-session(s) / get-user / has-permission)。 -- `set-role` 也关了:role 授予走手动 DB,不开放 HTTP 提权面。 - -## 封禁怎么「立即生效」 - -admin 插件的封禁强制只在 `session.create.before`(拦新登录)。但 stage-web / electron / pocket 热路径带的是 oauthProvider 签的无状态 RS256 JWT,`resolveJWTAccessToken` 只验签 + `findUserById`,**不建 session、不查 session 行**,插件那个钩子根本不触发。 - -所以热路径的封禁判断自己做,落在 `resolveRequestAuth`(所有传输层唯一鉴权入口:`sessionMiddleware` / 两个 WebSocket / OIDC `get-session`): - -- 解析出 user 后调 `isUserBannedNow(user)`(`libs/request-auth.ts`),命中返回 `null` → 上层当未鉴权(401)。 -- `isUserBannedNow` 读的是 `user.banned`(`findUserById` 已经把整行 user 带回来了,**零额外查询**),并判 `banExpires`:过期的 ban 当未封禁。 -- `findUserById` 的 TS 返回类型是 better-auth 基础 User,不含插件字段,但运行时整行都在 → `request-auth.ts` 有一处带 `// NOTICE:` 的 widen cast 拿回 `banned`。 - -另外 `/api/auth/oauth2/userinfo` 单独加了一道 guard(`routes/auth/index.ts`):`/api/auth/*` 绕过 `sessionMiddleware`,而 userinfo 只验签就返 profile,所以这里用 `resolveSessionIgnoringBan` + `isUserBannedNow` 拦被封用户的有效 JWT。`/oauth2/introspect` 要 confidential client(一方 client 全 public),无可达调用方,不补。 - -**封禁时撤销 OAuth 凭据**(codex review 发现并修):admin 插件 `banUser` 只删 session,留着 `oauth_refresh_token` / `oauth_access_token`。oauthProvider 的 `/oauth2/token` refresh grant(`@better-auth/oauth-provider/dist/index.mjs:718`)加载 user 但**不查 `banned`**,所以被封用户本可用现存 refresh token 换一个全新 JWT。那个新 JWT 在所有资源路径仍被 `isUserBannedNow` 挡住(拿不到实际访问),但为了从源头断掉,`auth.ts` 加了 `databaseHooks.user.update.after`:检测 `banned=true` 时删该用户的 oauth refresh/access token 行(`banUser` 通过 `updateUser` 写 banned,触发此 hook)。这样 refresh grant 自然失败。 - -## 余额设定(无 better-auth 对应物,保留自建) - -改余额是 flux 领域操作,better-auth admin 没有,保留为自建路由 `POST /api/admin/users/balance`,用 `adminGuard`(role)守。 - -- `AdminUsersService.setBalance` 把 selector(email | userId 二选一,`requireSingleSelector` 强校验)解析成 userId(`resolveUserByIdOrEmail`),再委托 `BillingService.setFlux`。 -- `setFlux` 单事务锁 `user_flux` 行 → 改余额 → 写 `flux_transaction`(type `admin_set`,方向 + before/after + issuedBy 进 metadata)→ 提交后 `redis.del` 失效缓存(不是写新值,避免参与 credit/debit 已有的跨操作 cache 竞态,详见下)。 - -flux-grants(`/api/admin/flux-grants`)和 router-config(`/api/admin/config/router`)同理:保留自建,鉴权从 `adminGuard(env)` 换成 role-based `adminGuard`。 - -## 相关文件 - -- `src/libs/auth.ts` — `admin()` 插件 + `disabledPaths` -- `src/schemas/accounts.ts` — user/session 上的 admin 插件字段 -- `src/middlewares/admin-guard.ts` — role-based `adminGuard` -- `src/libs/request-auth.ts` — `isUserBannedNow` + 热路径封禁闸 -- `src/routes/auth/index.ts` — `/oauth2/userinfo` 封禁 guard -- `src/routes/admin/users/index.ts` — `POST /balance`(自建) -- `src/services/domain/admin/users/index.ts` — `setBalance` 编排 -- `src/services/domain/billing/billing-service.ts` — `setFlux` - -## 余额缓存竞态(预先存在) - -`creditFlux` / `debitFlux` / `setFlux` 的 Redis 写都在事务提交后、行锁外,无版本号。多实例并发下较慢的旧 SET 可能后到覆盖新值。这不是本次引入的,`setFlux` 用 `redis.del`(失效,对齐 `FluxService.deleteAllForUser`)而非 SET,至少不往里添 stale SET。彻底修需要给三个写统一加版本化缓存写,超出范围。`getFlux` 是 cache-aside,stale 窗口下次 miss 自愈,Postgres 始终是真相。 - -## 已知边界 / 取舍 - -- **第一个 admin 必须手动设 role**(删了 `ADMIN_EMAILS`)。首次部署/新环境要手动 `UPDATE "user" SET role='admin'` -- **ban 是 userId 单维**:账号在时 userId ban 已堵死一切登录方式(邮箱、所有 OAuth 都 resolve 到同一 user)。不覆盖「账号被删后用同邮箱/OAuth 重注册」——被封用户登不进去也删不了号,该洞只在 admin 主动删号后出现 -- **dashboard 没上**:`@better-auth/infra` 的 `dash()` 是闭源 SaaS(`dash.better-auth.com`)的服务端 SDK,会把认证/用户数据外发第三方、`/dash/execute-adapter` 远程驱动 DB,且 `DashOptions` 只有 `activityTracking`、塞不进我们的 flux 业务页面。决定:业务管理(flux / grants / router config)将来自建 admin UI,user/session 管理直接调 better-auth admin 端点,不引入外部 SaaS -- admin 插件的 ban-user 端点 + `session.create.before` 登录拦截属于库行为,未跑真实 better-auth 登录流端到端验(靠源码确认 + 我们的热路径闸有真实执行覆盖)。详见 `verifications/admin-user-balance-ban.md` diff --git a/apps/server/docs/ai-context/account-deletion.md b/apps/server/docs/ai-context/account-deletion.md deleted file mode 100644 index aa43737b9..000000000 --- a/apps/server/docs/ai-context/account-deletion.md +++ /dev/null @@ -1,184 +0,0 @@ -# Account Deletion - -User-requested account deletion. Auth identity is hard-deleted; business records are soft-deleted (preserved with `deleted_at`) for audit/compliance. - -## 决策摘要 - -| 决策点 | 选择 | 理由 | -|---|---|---| -| `apps/server/src/schemas/accounts.ts` | **不动** | better-auth `auth:generate` 自动产物。修改会被下次生成覆盖 | -| Auth 表 (user/session/account/oauth\*/verification) | **hard delete + cascade** | 跟着 user 一起 cascade 干净。无审计价值,留着只是 dangling auth state | -| 业务表 (flux\*/stripe\*/character\*/providers/chats) | **soft delete (deleted_at)** | 审计、合规、debug 需要保留"这条记录原属于哪个 user" | -| 业务表对 user.id 的 FK | **drop FK constraint,保留裸 userId 列** | better-auth hard-delete user 时不会被 cascade 干掉。跟 `llm_request_log` 现有做法一致 | -| llm_request_log | **不参与软删,独立 retention** | 高并发写入,本就无 FK;保留期由独立 retention job 决定(合规) | -| 删除流程 | **better-auth 内建邮件确认** | `user.deleteUser.sendDeleteAccountVerification` + token 回调,开箱即用 | -| 误删恢复 | **不支持** | 用户认知中"删除即不可逆"。要恢复就重新注册(同 email 没问题,user 行已删,唯一约束释放) | -| Stripe 订阅 | **立即 cancel,不退款(v1)** | 简单、对内部记账影响最小。条款需注明。后续可改 | -| Flux 余额 | **清零(userFlux.deletedAt)** | 同上。后续可补退款逻辑 | - -## 流程 - -``` -用户在 settings/account 点 Delete - ↓ -POST /api/auth/delete-user (Bearer) ← better-auth - ↓ -sendDeleteAccountVerification → Resend ← 我们的 EmailService - ↓ 用户收邮件,点链接 -GET /api/auth/delete-user/callback?token=... ← better-auth 验 token - ↓ token 有效 -beforeDelete(user) ← UserDeletionService.softDeleteAll(userId) - ├─ stripe (priority 10): stripeService.deleteAllForUser - │ → Stripe API cancel + 4 张 stripe_* 表打 deletedAt - ├─ flux (priority 20): fluxService.deleteAllForUser - │ → userFlux 打 deletedAt + redis cache 失效 - ├─ providers (priority 30): providerService.deleteAllForUser - │ → userProviderConfigs 打 deletedAt - ├─ characters (priority 30): characterService.deleteAllForUser - │ → character / likes / bookmarks 打 deletedAt - └─ chats (priority 30): chatService.deleteAllForUser - → chats / messages 打 deletedAt - ↓ -internalAdapter.deleteUser(userId) ← user 行真删 - ↓ Postgres FK cascade -session/account/oauth_client/oauth_*_token/oauth_consent ← 真删 - ↓ -重定向到 callbackURL -``` - -## 架构:service own 自己的删除语义 - -每个业务 service 自己 own `deleteAllForUser(userId)` 方法 —— 删除该 user scope 下所有相关数据的能力跟 service 的其他 CRUD 方法住在一起。`UserDeletionService` 只是个**调度器**:按 priority 串行调用各 service 的方法,throw 中止。 - -依赖图: - -``` -auth ──depends on──► userDeletionService ──depends on──► [stripeService, fluxService, ...] - │ - └─ 内部仅持有 { name, priority, softDelete } 列表, - softDelete 是对 service.deleteAllForUser 的 thin wrapper -``` - -auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。 - -```ts -// apps/server/src/services/domain/user-deletion/types.ts -export interface UserDeletionHandler { - name: string - /** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */ - priority: number - softDelete: (ctx: UserDeletionContext) => Promise -} - -export interface UserDeletionService { - register: (handler: UserDeletionHandler) => void - softDeleteAll: (input: { userId: string, reason: UserDeletionReason }) => Promise -} -``` - -装配在 `app.ts` 一处完成(每个 service 一行 `register`)。不分 transaction:每个 service 方法自己管 db/外部调用,**Stripe 这种没法 rollback 的副作用必须最先做**(priority 最小),失败就抛错中止后续 service 调用 + better-auth 的 user 删除,用户重试即可(idempotent:Stripe sub 已 cancel 的再 cancel 是 no-op;deletedAt 已设置的再 update 是 no-op)。 - -## 加新业务模块的步骤 - -1. 在该 service 加 `async deleteAllForUser(userId: string)` 方法 -2. 在 `app.ts` 的 `userDeletionService` build 里加一行 `service.register({...})` -3. 完成 - -不需要:写新文件、改 service 接口、改 auth.ts、改 types.ts。 - -## 各业务 service 的 deleteAllForUser - -| Service | priority | 内容 | 依赖 | -|---|---|---|---| -| **stripeService** | 10 | (1) 查 stripeSubscription where userId=? and status=active;(2) Stripe API `subscriptions.cancel(id, { prorate: false })`;(3) 4 张 `stripe_*` 表 update deletedAt=now() | DB, Stripe SDK (optional) | -| **fluxService** | 20 | (1) `update userFlux set deletedAt=now() where userId=?`;(2) `redis del flux:balance:{userId}`;(3) **不动** flux_transaction(账本审计) | DB, Redis | -| **providerService** | 30 | `update userProviderConfigs set deletedAt=now() where ownerId=?` | DB | -| **characterService** | 30 | (1) `character set deletedAt=now() where ownerId=? or creatorId=?`;(2) `characterLikes/Bookmarks set deletedAt=now() where userId=?` | DB | -| **chatService** | 30 | 按 `chat.type` 分支:① `private`/`bot` 整 chat soft-delete + 该 user 发的 message soft-delete;② `group`/`channel` 只硬删该 user 的 `chat_members` 行,**user 发的 message 保留**给其他 member 维持对话上下文(sender 通过"user 行 hard-delete + senderId bare text 无 FK"自然匿名化,UI 拿 senderId lookup 不到 user 时渲染为 "Deleted User") | DB | -| llm_request_log | 不参与 | 独立 retention job 处理 | — | - -## 业务查询的软删过滤 - -**所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描: - -- `apps/server/src/services/domain/flux.ts` — getBalance / readBalance -- `apps/server/src/services/domain/characters.ts` — listCharacters -- `apps/server/src/services/domain/providers.ts` — listProviderConfigs -- `apps/server/src/services/domain/chats.ts` — listChats / listMessages -- `apps/server/src/services/domain/billing/billing-service.ts` — invoice / sub 查询 - -写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。 - -## Failure 模型 - -| 阶段失败 | 行为 | 后果 | -|---|---|---| -| sendDeleteAccountVerification | better-auth 抛 500 | 用户重试 | -| token 验失败/过期 | better-auth 返 404 | 用户重新发起 | -| Stripe handler 抛错 | 整个 beforeDelete 中止 → user 不删 | DB 状态保持原样,Stripe sub 状态可能已 cancel(罕见),下次重试 idempotent | -| Flux/其他 handler 抛错 | 同上中止 → user 不删 | 已经 cancel 的 Stripe sub 不会回滚(Stripe API 不支持 un-cancel),用户得重新订阅。**记录到 deletion_failure_log**(telemetry / sentry alert) | -| user 真删后 afterDelete 抛错 | user 已删,session 已 revoke,已无法回滚 | 仅 log,不影响用户体验 | - -**没有补偿事务**。Multi-step soft-delete 失败的处置策略是:失败即中止,依赖 idempotency 让重试干净。 - -## Idempotency - -- better-auth 的 verification token 一次性消费(`deleteVerificationByIdentifier`),点链接两次第二次会 404 -- handler 全部用 `update where deletedAt is null` 守卫,重跑无副作用 -- Stripe `subscriptions.cancel` 对已 cancel 的 sub 返回 200(idempotent by spec) - -## 群聊匿名化("Deleted User") - -群聊场景下 `messages.senderId` 故意是 **bare `text` 列没有 FK**,所以: - -- better-auth hard-delete `user` 行后,`messages.senderId='abc123'` 字符串还在,但 `select * from "user" where id='abc123'` 空集 -- name / email / avatar 全部跟 user 行一起没了 -- senderId 还能 group by(同一 user 发的 message 仍可识别为同一来源),但**反查不到任何 PII** -- UI 路径:渲染 message sender 时 user lookup miss → 显示 "Deleted User" / "[已注销]" - -**chatService.deleteAllForUser 不需要主动改 senderId**,schema "bare text + 无 FK + auth user 行 hard-delete" 这三件事联合产出匿名化效果。 - -## 第三方 OAuth provider 端 - -better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 google/github 登录方式的关联),oauth_* 表通过 FK cascade 删干净。**第三方 OAuth provider 那边的 grant 不主动撤销** —— 跟 Stripe / Slack / Discord 等业界默认一致。User 真要彻底清,应该去 OAuth provider 自己的 dashboard(如 google.com/security)撤。 - -如果未来出现严格 GDPR 需求,可以加 best-effort 调 Google `/o/oauth2/revoke?token=...` —— 但需要保留 refresh token,且 endpoint 本身就是 best-effort。 - -## 不做项 (v1) - -- ❌ 软删 → hard delete reaper job(业务表保留无限期,等首次清理需求驱动;llm_request_log 已有独立 retention) -- ❌ 误删恢复(用户认知中删除即终态;UI 必须文案警示) -- ❌ Stripe / Flux 退款(条款里写明,后续按需补) -- ❌ 删除事件外发 Webhook / Slack 通知(用 telemetry 替代) -- ❌ Admin 手动触发 delete(后续 admin panel 任务) -- ❌ 主动撤销第三方 OAuth provider 端的 grant(业界默认不做,user 自助撤) - -## 相关代码索引 - -- 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts` -- Auth schema (不改): `apps/server/src/schemas/accounts.ts` -- Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`) -- Email service: `apps/server/src/services/adapters/email.ts` (extend interface + Resend impl) -- Deletion scheduler: `apps/server/src/services/domain/user-deletion/` (registry only, no domain logic) -- 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts` -- UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO) -- UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue` -- i18n: `packages/i18n/src/locales/{en,zh}/settings/account.yaml` - -## Verification - -实测路径见 `docs/ai/context/verifications/account-deletion.md`(待补)。 - -最小路径: - -1. 注册 user A -2. 创建一个 character,给 5 flux,订阅 active sub(mock Stripe) -3. UI 点 Delete → 收邮件 → 点链接 -4. 验证: - - `select * from "user" where email='A'` 空 - - `select * from session where user_id='A'` 空 - - `select * from user_flux where user_id='A'` deleted_at 非空 - - `select * from character where owner_id='A'` deleted_at 非空 - - `select * from stripe_subscription where user_id='A'` deleted_at 非空,Stripe API 端 sub status=canceled - - `select * from flux_transaction where user_id='A'` 仍存在(账本审计) -5. 重新用 email A 注册成功(unique 约束已释放) diff --git a/apps/server/docs/ai-context/admin-flux-grants.md b/apps/server/docs/ai-context/admin-flux-grants.md deleted file mode 100644 index 4c53da5fc..000000000 --- a/apps/server/docs/ai-context/admin-flux-grants.md +++ /dev/null @@ -1,106 +0,0 @@ -# Admin Flux Grants - -Admin 一次性给若干用户发 FLUX(Beta 致谢、补偿、运营赠送等)的接口。整个流程**单一同步 HTTP 调用**搞定,没有 batch 表、没有状态机、没有后台 loop。 - -## 1. 背景 - -旧设计是 `flux_grant_batch` + `flux_grant_batch_recipient` 两张表 + 状态机 + 异步处理 + retry 端点 + advisory-lock poller,~800 行代码。实际产品里 admin 发放频率"几周一次、几十个用户",过度工程。简化为: - -- 一个 `POST /api/admin/flux-grants` 接口 -- 同步处理:resolve emails → 顺序调 `creditFlux` → 返回每条的 outcome -- 审计走 `flux_transaction` 表(`type='promo'`、`metadata.description` / `metadata.idempotencyKey`) -- 失败处理:admin 看响应里的 `failed[]`,自己再发一次(用 `idempotencyKey` 防止已成功的部分被双发) - -## 2. 路由 - -`POST /api/admin/flux-grants?dryRun=true|false` - -Auth:`authGuard` + `adminGuard`(`ADMIN_EMAILS` allowlist + 验证邮箱)。 - -Body: - -```text -{ - description: string, // 1..500 chars; 写入 flux_transaction.metadata.description - amount: number, // 1..MAX_GRANT_AMOUNT_PER_USER (10_000), 单人发放数量 - emails: string[], // 1..MAX_EMAILS_PER_GRANT (200) 个 email - idempotencyKey?: string, // 可选,最长 100 chars。提供后每个 recipient 的 - // requestId = `flux-grant:${idempotencyKey}:${userId}`, - // 重发同 (key, recipients) 是 no-op;不提供则每次 grant - // 都会重发。 -} -``` - -dry-run 响应: - -```text -{ preview: { totalEmails, willGrant, willSkip: { notFound, userDeleted, duplicateInInput }, totalFluxToIssue, samples } } -``` - -实发响应: - -```text -{ - summary: { totalEmails, willGrant, willSkip, totalFluxToIssue, samples }, - result: { - granted: [{ email, userId, fluxTransactionId, balanceAfter }], - skipped: [{ email, reason: 'duplicate_in_input' | 'not_found' | 'user_deleted' }], - failed: [{ email, userId, error }], // creditFlux 抛错时进这里 - }, -} -``` - -## 3. 处理流程(同步) - -1. 路由层 valibot 校验 body -2. `service.resolveEmails(emails)`: - - 输入小写化后 `IN (...)` 查 `user.email`(不能 wrap `LOWER()`,会 break unique index → seq scan) - - 命中 user 后再查 `user_flux.deletedAt` - - 重复输入按出现顺序首条留下,后续标 `duplicate_in_input` -3. 对每个 `status='pending'` 的 recipient 顺序调 `BillingService.creditFlux({ userId, amount, type: 'promo', requestId, description, source: 'admin_promo', auditMetadata })` -4. 抛错记到 `result.failed[]`,循环继续;成功记到 `result.granted[]` -5. HTTP 返回完整 `result` - -没有 sleep / throttle —— 200 个 recipient × 20–50ms 单条 ≈ 4–10s,安心进 LB 30s 超时窗口。如果以后真的需要更大批量,先评估是否值得拆,再决定加 cap 还是引入异步。 - -## 4. 失败 / 恢复 - -| 故障 | 表现 | 恢复 | -|---|---|---| -| 单条 recipient `creditFlux` 抛错(DB blip 等) | 出现在 `result.failed[]` | admin 看响应,自己再发一次相同请求;如果用了 `idempotencyKey`,已 granted 的不会被双发,只重试 failed 的 | -| 整个请求超 LB 超时 | 客户端看到超时,部分 recipient 已扣账 | admin 用同 `idempotencyKey` 重发,已成功的直接幂等跳过 | -| Operator 输错邮箱 / 数量 | 先用 `?dryRun=true` 看 preview | 改完再去掉 dryRun | - -## 5. 审计 - -- 每条成功 grant 在 `flux_transaction` 写一行(`type='promo'`、`metadata.description`、`metadata.issuedByUserId`、可选 `metadata.idempotencyKey`) -- 没有专门的 admin 报表;用 `/api/v1/flux/history` 或直接 SQL 按 `metadata->>'description'` / `metadata->>'idempotencyKey'` 查 - -```sql --- 看某次 grant 实际发了多少 -SELECT user_id, amount, balance_after, created_at - FROM flux_transaction - WHERE metadata->>'idempotencyKey' = 'beta-2026-q2' - ORDER BY created_at; -``` - -## 6. 实现位置 - -- 路由:[`apps/server/src/routes/admin/flux-grants/index.ts`](apps/server/src/routes/admin/flux-grants/index.ts) -- Service:[`apps/server/src/services/domain/admin/flux-grants/index.ts`](apps/server/src/services/domain/admin/flux-grants/index.ts) -- 单测:[`apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts`](apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts) -- adminGuard:[`apps/server/src/middlewares/admin-guard.ts`](apps/server/src/middlewares/admin-guard.ts) -- 数据库:**没有**专门的表;唯一持久化是 `flux_transaction` ledger -- 已废弃:`flux_grant_batch` / `flux_grant_batch_recipient`(drizzle migration `0011_superb_lady_deathstrike.sql` 删表) - -## 7. 不做 - -- 不做 batch 状态机 / retry endpoint —— 同步响应里已经有 failed 列表,admin 看到失败就自己再发 -- 不做异步处理 / 后台 loop —— 200 用户上限完全可以塞进一个 HTTP 请求 -- 不做 dashboard 展示 —— 直接查 `flux_transaction` 即可 -- 不做高并发 / 大批量 —— 这是 admin 工具不是 bulk import;超 200 就让 admin 拆请求 - -## 8. 已知不足 - -- **无 admin-side 失败留痕**:`failed[]` 只在 HTTP 响应里返回一次,admin 关掉浏览器就没了。如果将来发现需要"上次失败的那批"持久化,再单独加一张 `admin_grant_attempt_log` 之类的,不要把它做回 batch 表。 -- **`emails` 上限 200 是经验估算**:单 `creditFlux` 假设 20–50ms。如果实际生产数据显示更慢,下调上限。 diff --git a/apps/server/docs/ai-context/architecture-overview.md b/apps/server/docs/ai-context/architecture-overview.md deleted file mode 100644 index 25f5fa4e5..000000000 --- a/apps/server/docs/ai-context/architecture-overview.md +++ /dev/null @@ -1,163 +0,0 @@ -# Server Architecture Overview - -## 一句话总结 - -`apps/server` 是一个基于 `Hono` 的 Node 服务端,负责认证、角色/聊天/Provider 配置、Flux 余额、Stripe 充值和面向 gateway 的 LLM 代理。整体模式是: - -- 路由层负责参数校验、鉴权、错误映射 -- 服务层负责业务逻辑和数据库事务 -- `Postgres` 负责持久化与账本真相 -- `Redis` 负责缓存、配置 KV、Pub/Sub(不再使用 Streams) -- `injeca` 负责把这些依赖组装成一个可启动应用 - -## 入口与装配 - -核心入口在 `src/app.ts`: - -- `createApp()` - - 初始化 logger - - 解析环境变量 - - 初始化 OpenTelemetry - - 建立 Postgres / Redis 连接 - - 执行数据库迁移 - - 构建各个 service - - 注册路由和中间件 -- `runApiServer()` - - 启动 HTTP 服务 - - 注入 WebSocket - - 绑定 `uncaughtException` / `unhandledRejection` - -CLI 入口在 `src/bin/run.ts`,只有一种角色: - -- `api`(HTTP/WS;没有常驻后台 loop,也没有 fire-and-forget 异步任务。admin flux grant 在 POST 请求线程内同步处理完返回;详见 `workers-and-runtime.md`) - -## 依赖注入结构 - -`app.ts` 使用 `injeca.provide()` 注册依赖,依赖关系大致如下: - -- 基础设施 - - `env` - - `otel` - - `db` - - `redis` - - `configKV` -- 服务 - - `auth` - - `characterService` - - `providerService` - - `chatService` - - `stripeService` - - `fluxTransactionService` - - `fluxService` - - `requestLogService` - - `billingService` - - `adminFluxGrantsService` - - `ttsMeter` - - `userDeletionService` - - `emailService` - -这个装配顺序说明了几个事实: - -- `billingService` 依赖 `db + redis` -- `fluxService` 只读余额,不承担余额写入职责 -- `auth` 直接绑定数据库 schema,不是外部独立服务 - -## 应用层边界 - -### 1. HTTP / WS 传输层 - -在 `src/routes/` 和 `src/middlewares/`: - -- 参数校验使用 `valibot` -- 用户身份来自 `sessionMiddleware` 和 `authGuard` -- 业务异常统一抛 `ApiError` -- 全局 `onError` 转成标准 JSON 错误响应 - -### 2. 业务服务层 - -在 `src/services/`: - -- `characters.ts` -- `chats.ts` -- `providers.ts` -- `flux.ts` -- `billing-service.ts` -- `stripe.ts` - -这里是主要改动面。大多数业务改动都不应该直接写进 route handler。 - -### 3. 持久化层 - -在 `src/schemas/`: - -- Drizzle schema 基本覆盖了所有核心表 -- 数据迁移由 `@proj-airi/server-schema` 提供 -- `app.ts` 启动时会执行迁移 - -## 中间件与通用约束 - -全局中间件链路大致是: - -1. `/api/*` 启用 CORS -2. `hono/logger` -3. 可选的 `otelMiddleware` -4. `sessionMiddleware` -5. `bodyLimit(1MB)` -6. 各 route 的局部 guard - -需要记住的行为: - -- WebSocket `/ws/chat` 在 `bodyLimit` 之前注册 -- `sessionMiddleware` 不会阻断匿名请求,只是往 context 填 `user/session` -- `authGuard` 才会真正返回 401 -- `rate-limit.ts` 目前是**内存限流**,不是分布式限流 - -## 错误模型 - -统一错误类型在 `src/utils/error.ts`: - -- `ApiError(statusCode, errorCode, message, details)` - -约定: - -- 业务层可以直接抛 `ApiError` -- 未知异常会被包装成 `500 INTERNAL_SERVER_ERROR` -- 参数错误、权限错误、余额不足都已有明确 helper - -## 关键设计取舍 - -### Flux 读写分离 - -- `FluxService` - - 面向读取 - - Redis cache-aside - - 新用户首次读取时初始化余额 -- `BillingService` - - 面向写入 - - debitFlux / credit 方法:事务内同步更新余额并写 `flux_transaction` ledger;事务提交后 best-effort 刷 Redis 余额缓存 - -这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`。 - -### LLM/TTS 路由在进程内,而不是本地 provider 编排 - -`/api/v1/openai` 由 `services/domain/llm-router` 读取 `LLM_ROUTER_CONFIG` 后按 upstream 链路 + key rotator 直接调 provider(OpenRouter、Azure Speech、阿里云 DashScope、火山引擎 等),不再依赖外部 knoway sidecar。因此: - -- 服务端关心的是鉴权、限流、计费、日志、观测、上游路由与 key 健康 -- 具体模型协议翻译由 `services/domain/llm-router` 与 `services/adapters/tts` 的 adapter 完成 - -### Redis 有多种职责,但都不是余额真相源 - -Redis 在这里同时承担: - -- Flux 余额缓存 -- 运行时配置 KV -- WebSocket 跨实例广播 Pub/Sub -- Sub-Flux 计量债务账本(TTS 字符等,TTL 抹零,详见 `flux-meter.md`) -- TTS voices 上游响应缓存 - -但余额真相仍然在 Postgres。Redis Streams 已全部移除,详见 `redis-boundaries-and-pubsub.md` 的 NOTICE。 - -## 当前值得注意的实现信号 - -- `/api/v1/openai` 当前开放:`POST /chat/completions`、`POST /chat/completion`、`POST /audio/speech`、`GET /audio/voices`。`handleTranscription` 路由尚未挂载。 -- `flux_grant_batch` schema 已被简化版 `admin-flux-grants` 取代,代码 + schema 都已清理。`drizzle/0011_open_unus.sql` 是 drop migration(`DROP TABLE flux_grant_batch / flux_grant_batch_recipient CASCADE`,顺带清掉 6 个 index)。这条 DDL 是不可逆破坏,需要操作员在合适的部署窗口手动 `pnpm db:push` 推到 prod;只要 prod DB 还没 apply 0011,回滚 server image 不会丢数据。 diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md deleted file mode 100644 index 3ae104876..000000000 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ /dev/null @@ -1,307 +0,0 @@ -# 认证与 OIDC Provider - -## 一句话总结 - -Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC Provider(Authorization Server)**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。客户端直接持有 OIDC access token,并通过服务端统一的 Bearer 解析链路完成鉴权与 session 查询。 - -## 架构角色 - -``` -┌─────────────────────────────────┐ -│ 社交登录 IdP (Google, GitHub) │ -└──────────────┬──────────────────┘ - ↓ OAuth 2.0 -┌──────────────────────────────────────────────────┐ -│ AIRI Server (better-auth OIDC Provider) │ -│ │ -│ /api/auth/oauth2/authorize ← PKCE 授权 │ -│ /api/auth/oauth2/token ← Code 换 Token │ -│ /api/auth/oidc/electron-callback ← 回调中继页 │ -│ /api/auth/sign-in/social ← 社交登录入口 │ -│ /sign-in ← 登录选择页 │ -└──────────────┬──────────────────┬────────────────┘ - ↓ ↓ - ┌──────────┐ ┌──────────────┐ - │ Stage Web │ │ Stage Electron│ - │ /auth/ │ │ 127.0.0.1: │ - │ callback │ │ {port}/ │ - └──────────┘ │ callback │ - └──────────────┘ -``` - -## 核心组件 - -### Server 端 - -| 文件 | 职责 | -|------|------| -| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 | -| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、token auth 辅助路由、electron callback、well-known metadata、better-auth catch-all | -| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback | -| `src/routes/oidc/token-auth.ts` | Bearer token 辅助路由:`get-session`、`sign-out`、`list-sessions` | -| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) | -| `src/utils/origin.ts` | 可信来源配置:`localhost`、`127.0.0.1`、`airi.moeru.ai`、`capacitor://localhost` | -| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema) | -| `src/libs/request-auth.ts` | 统一鉴权解析:优先读 better-auth session,再回退到受信任 OIDC access token | - -### Client 端 - -| 文件 | 职责 | -|------|------| -| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 | -| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`applyOIDCTokens()` 持久化 token、`fetchSession()` 同步会话、自动刷新调度 | -| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user`、`session`、`token`、`refreshToken` 到 localStorage | -| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()`、`generateCodeChallenge()`、`generateState()` | -| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → 持久化 access token → `fetchSession()` → 跳转首页 | -| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 | - -### Trusted Clients - -| Client | ID 环境变量 | redirect_uri | 类型 | -|--------|------------|--------------|------| -| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web | -| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native | -| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native | - -### 环境变量 - -``` -# 社交 Provider -AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET -AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET -# Apple optional;启用时四项必须一起配置 -AUTH_APPLE_CLIENT_ID, AUTH_APPLE_TEAM_ID -AUTH_APPLE_KEY_ID, AUTH_APPLE_PRIVATE_KEY_PEM -# iOS 原生 Sign in with Apple;逗号分隔,每项必须与一个 Xcode target 的 Bundle ID 一致 -AUTH_APPLE_APP_BUNDLE_IDENTIFIERS=ai.moeru.airi-pocket,ai.moeru.airi-pro - -# OIDC Trusted Clients(均 optional,不配则不注册) -# Web and Pocket are public clients (no secret, PKCE only) -OIDC_CLIENT_ID_WEB -OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON -OIDC_CLIENT_ID_POCKET -``` - -### iOS 原生 Sign in with Apple - -iOS 使用 `ASAuthorizationAppleIDProvider` 获取 Apple identity token,然后直接调用 Better Auth 的社交登录接口;不要先请求授权 URL,也不需要打开系统浏览器: - -```http -POST /api/auth/sign-in/social -Content-Type: application/json - -{ - "provider": "apple", - "idToken": { - "token": "", - "nonce": "" - } -} -``` - -首次授权时,客户端可以额外传入 Apple 原生回调给出的姓名;email 由服务端从 identity token claim 读取: - -```json -{ - "provider": "apple", - "idToken": { - "token": "", - "nonce": "", - "user": { - "name": { - "firstName": "", - "lastName": "" - } - } - } -} -``` - -Better Auth 验证 token 的签名、issuer、Bundle ID audience allowlist 和可选 nonce 后,直接返回 session,不会返回 Apple 登录 URL: - -```json -{ - "redirect": false, - "token": "", - "user": {} -} -``` - -客户端后续可将返回的 session token 作为 `Authorization: Bearer ` 调用业务 API。Apple 只在首次授权提供姓名,并可能只在首次授权提供 email;服务端会保留首次写入的 email,后续 token 未携带 email 时使用 Apple `sub` 生成不可投递的 placeholder 以解析已绑定账号。 - -## Token 层次 - -| Token | 用途 | 存储位置 | 生命周期 | -|-------|------|---------|---------| -| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 | -| OIDC Access Token (JWT) | 实际的 API 鉴权凭证(Bearer) | localStorage `auth/v1/token` | 1 小时 TTL,自包含,不存数据库 | -| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期,rotation 机制 | -| Session 对象 | UI / API 所需的用户态快照 | `fetchSession()` 后保存在 auth store | 跟随 access token 可解析结果 | - -**为什么现在可以直接用 OIDC access token?** 因为服务端的 `resolveRequestAuth()` 已经统一支持两条路径:先走 `auth.api.getSession()` 解析 better-auth session;如果没有 session,再用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间,然后通过 `findUserById()` 补齐用户信息。对业务路由来说,拿到的仍然是统一的 `{ user, session }` 结构。 - -**测试环境登录绕过:** 设置 `TEST_AUTH_TOKEN` 后,业务 API 可以直接带 `Authorization: Bearer $TEST_AUTH_TOKEN` 进入 `resolveRequestAuth()`,无需走 UI 登录或 better-auth session。默认虚拟用户为 `test-user / test@example.com / Test User`,可用 `TEST_AUTH_USER_ID`、`TEST_AUTH_USER_EMAIL`、`TEST_AUTH_USER_NAME`、`TEST_AUTH_USER_ROLE` 覆盖;需要访问 `/api/admin/*` 时把 `TEST_AUTH_USER_ROLE=admin`。该 token 只接入业务鉴权链路,不改变 `/api/auth/*` better-auth 登录/OIDC 端点;生产环境保持 unset。 - -**JWT 签发条件:** 前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`),oauthProvider 据此签发 JWT 而非 opaque token。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。 - -**撤销策略:** JWT 1 小时 TTL + refresh token rotation。signout 时撤销 refresh token,JWT 等自然过期。不使用 denylist 或 Redis。 - -**为什么不用 cookie?** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。 - -## 登录流程 - -### Web 完整流程 - -``` -Client (localhost:5173) Server (localhost:3000) Social IdP - │ │ │ - 1. signInOIDC() │ │ - 构建 PKCE (verifier + challenge) │ │ - 存 sessionStorage │ │ - window.location → │ │ - │ │ │ - 2. GET /api/auth/oauth2/authorize │ │ - ?response_type=code │ │ - &client_id=airi-stage-web │ │ - &redirect_uri=localhost:5173/auth/callback │ │ - &code_challenge=xxx │ │ - &provider=github │ │ - │ │ │ - │ 3. 用户未登录 │ - │ 302 → /sign-in?...所有 OIDC 参数... │ - │ │ │ - │ 4. /sign-in 看到 provider=github │ - │ 重建 callbackURL = /api/auth/oauth2/authorize?... │ - │ 302 → /api/auth/sign-in/social │ - │ ?provider=github │ - │ &callbackURL={OIDC authorize URL} │ - │ │ │ - │ ──────── 302 to GitHub ───────────────► │ - │ │ 5. 用户授权 - │ │ ◄──────── callback ────────────────── │ - │ │ │ - │ 6. better-auth 创建 user + session(server cookie) │ - │ 302 → callbackURL(= OIDC authorize) │ - │ │ │ - │ 7. /api/auth/oauth2/authorize │ - │ 用户已有 session → 签发 authorization code │ - │ 302 → redirect_uri?code=xxx&state=xxx │ - │ │ │ - 8. /auth/callback │ │ - consumeFlowState() 恢复 PKCE │ │ - 验证 state 防 CSRF │ │ - │ │ │ - 9. POST /api/auth/oauth2/token ──────────────► │ │ - (code + code_verifier + client_id + resource) │ │ - ◄──── { access_token (JWT), refresh_token } ─ │ │ - │ │ │ - 10. GET /api/auth/get-session ─────────────────► │ │ - (Bearer: access_token) │ │ - ◄──── { user, session } ──────────────────── │ │ - │ │ │ - 11. 写入 authStore → 跳转首页 │ │ -``` - -**关键设计:callbackURL 传递 OIDC 参数** - -`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type`、`client_id`、`redirect_uri`、`code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server session,OIDC 流程继续签发 code。 - -### Electron 特殊处理 - -Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`: -- 固定端口范围:19721-19725,按顺序尝试 -- 收到回调后立即关闭 server -- 5 分钟超时安全机制 - -**服务端回调中继**: - -Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。 - -好处: -- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL -- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL) -- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *` - -端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`。 - -### Bearer 鉴权解析 - -服务端通过 `src/libs/request-auth.ts` 解析请求头: - -1. 先调用 `auth.api.getSession({ headers })`,支持标准 better-auth session / cookie / Bearer session token -2. 如果没有命中,读取 `Authorization: Bearer ` -3. 使用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间 -4. 从 JWT `sub` claim 提取 userId,调用 `findUserById()` 补齐用户信息 -5. 构造统一的 `{ user, session }` - -JWT access token 由 oauthProvider 签发,条件是前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`)。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。 - -这样业务中间件和路由层不需要关心 token 来自 better-auth session 还是 OIDC JWT access token。 - -### 自动 Token 刷新 - -客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token`(`grant_type=refresh_token`),刷新后直接覆盖本地 access token。页面重载后从 localStorage 恢复刷新调度: - -- `auth/v1/oidc-client-id` — 客户端 ID -- `auth/v1/oidc-client-secret` — 客户端 Secret -- `auth/v1/oidc-token-expiry` — Token 过期时间戳 - -### provider 参数直通 - -客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。 - -## 路由注册顺序 - -Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要: - -1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider) -2. `USE /api/auth/*` — rate limiter(IP 限流) -3. `.route('/api/auth', createOIDCTokenAuthRoute(deps))` — token auth 辅助路由(`/get-session`、`/sign-out`、`/list-sessions`) -4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继 -5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata -6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery -7. `['POST', 'GET'] /api/auth/*` — **catch-all**,将所有其他请求转发给 `auth.handler()` - -自定义 auth 路由注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize` 和 `/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。 - -## 踩坑记录 - -### better-auth redirect_uri 精确匹配 - -better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**(`authorize.mjs`): - -```javascript -client.redirectUrls.find(url => url === ctx.query.redirect_uri) -``` - -RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。 - -### better-auth cookie 与 Bearer 共存 - -better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。 - -### skipStateCookieCheck - -Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。 - -### better-auth internalAdapter - -`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`。 - -### OIDC 流程中断:callbackURL 必须指回 authorize - -社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`。 - -## 修改指南 - -- 新增 OIDC client → `src/libs/auth.ts` 的 `buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts` -- 改登录页 → `src/utils/sign-in-page.ts`(HTML),或 `src/routes/auth/index.ts` 的 `/sign-in` 路由 -- 改认证中间件 → `src/app.ts` 的 session middleware -- 改 trusted origins → `src/utils/origin.ts` -- 改 Bearer 鉴权解析 → `src/libs/request-auth.ts`(JWT 本地验签,依赖 jose + JWKS) -- 改 token auth 辅助路由 → `src/routes/oidc/token-auth.ts` -- 改回调中继 → `src/routes/oidc/electron-callback.ts` -- 改 Auth 路由结构 → `src/routes/auth/index.ts` -- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie -- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts` 和 `packages/stage-ui/src/libs/auth-oidc.ts` -- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts` diff --git a/apps/server/docs/ai-context/billing-architecture.md b/apps/server/docs/ai-context/billing-architecture.md deleted file mode 100644 index b7bce0c4a..000000000 --- a/apps/server/docs/ai-context/billing-architecture.md +++ /dev/null @@ -1,115 +0,0 @@ -# Billing Architecture - -## 架构概述 - -`apps/server` 的计费链:**Postgres 是唯一账本真相源,所有余额写操作(debit / credit)和 ledger 行写入都在同一个 DB 事务里完成**。Redis 只承担余额读缓存。不再使用 Redis Stream / 后台 consumer 处理计费副作用。 - -### 数据模型 - -- **`user_flux`** — 用户余额快照(单行/用户) -- **`flux_transaction`** — append-only 账务流水(type: credit / debit / initial / promo, amount, balanceBefore, balanceAfter, requestId, metadata) - - partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`,DB 层幂等防重 -- **`llm_request_log`** — 每个 LLM/TTS 请求的可观测记录(model / status / duration / fluxConsumed / token 用量) - -### debitFlux 链路 - -`BillingService.consumeFluxForLLM()` 调用 `debitFlux()`,单个事务内: - -1. 若有 `requestId`,先查 `flux_transaction` 是否已存在同 `(userId, requestId)` 行 → 命中则直接返回历史结果,不再扣费、不写新行(幂等回放) -2. `SELECT user_flux FOR UPDATE` 锁行 -3. 检查余额(不足返回 402) -4. 更新 `user_flux.flux` -5. `INSERT INTO flux_transaction (...)`,把扣费金额、token 用量、source 写进 metadata -6. 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存(失败仅 warn 日志) - -### credit 链路 - -`creditFlux()` / `creditFluxFromStripeCheckout()` / `creditFluxFromInvoice()` 全部在事务内同步: - -- claim 行(Stripe 路径)/ 幂等查 `flux_transaction`(admin 路径) -- 锁 `user_flux` 行 → 加额 → 更新 -- 写 `flux_transaction` -- 事务提交后 `redis.set` 更新缓存 - -Stripe 路径靠 `stripe_checkout_session.fluxCredited` / `stripe_invoice.fluxCredited` 标志做对象级幂等;admin 路径靠 `(userId, requestId)` 唯一索引做幂等。 - -### LLM 请求日志 - -OpenAI route (`routes/openai/v1/index.ts`) 在 `consumeFluxForLLM` 完成后调用 `requestLogService.logRequest(...)` 同步写 `llm_request_log`。失败被记为 warn 日志,不阻断已经返回给用户的响应(流式响应已发出,错误兜不回来;非流式情况下 debit 已扣,request log 丢失也只是观测层面的损失)。 - -`llm_request_log` 没有 FK,没有二级索引,单纯追加;写入成本可以忽略。 - -### 进程角色 - -只有 `api` 一个 role(`src/bin/run.ts`),且没有任何"常驻后台 loop"或"fire-and-forget 异步任务"。所有写路径(包括 admin flux grant)都在请求线程内完成;多实例安全靠 `(userId, requestId)` 幂等索引。详见 [`workers-and-runtime.md`](workers-and-runtime.md)。 - -### Stripe 定价 - -Flux 充值定价完全由 Stripe Product/Price 管理,详见 [stripe-pricing.md](stripe-pricing.md)。 - -### Sub-Flux 计量服务(债务账本) - -TTS 字符、STT 秒等单价 < 1 Flux 的服务通过 `FluxMeter` 累计零头,跨阈值才下扣,避免短请求被向上取整为 1 Flux。详见 [flux-meter.md](flux-meter.md)。 - -## 关键服务 - -### BillingService (`services/domain/billing/billing-service.ts`) - -所有余额写操作的唯一入口: - -- **`consumeFluxForLLM()`** — LLM 请求扣费包装;事务内 `lock → check → update → insert ledger`,提交后刷 Redis 缓存;带 `requestId` 时支持幂等回放 -- **`creditFlux()`** — 通用充值(admin promo / 普通 credit);幂等 -- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,按 session 幂等 -- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,按 invoice 幂等 - -### FluxService (`services/domain/flux.ts`) - -只负责读操作: - -- **`getFlux()`** — Redis cache-aside 读(miss → DB → 填充 Redis),新用户自动初始化 -- **`updateStripeCustomerId()`** - -### Redis 职责边界 - -Redis **不是**余额真相源,仅用于: - -- `getFlux()` 读缓存(丢失无影响) -- 配置 KV -- WebSocket 广播 - -不再使用 Redis Streams 做计费链路。 - -## 实现状态 - -| Phase | 状态 | 关键点 | -|-------|------|--------| -| 1. DB-first 账本 | ✅ | `flux_transaction` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 | -| 2. 同步事务 ledger 写入 | ✅ | debit / credit 在单一事务内同时改余额和写 ledger,不再有 stream consumer | -| 3. Stripe 幂等 | ✅ | checkout + invoice 事务内幂等检查 | -| 4. LLM 计费优化 | ⚠️ | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback | -| 5. 单进程部署 | ✅ | 只剩 `api` role;admin flux grant 在 POST 请求线程内同步执行,没有后台 loop | -| 6. 幂等防重 | ✅ | `flux_transaction` partial unique index on `(userId, requestId)` + 事务内回放命中检查 | - -### 已删除 - -- `flux-write-back.ts` — 定时回写补偿机制 -- `FluxService.consumeFlux()` / `addFlux()` — 写操作集中到 BillingService -- `llm_request_log.settled` — 无消费者 -- `outbox_events` 表及 outbox-dispatcher 进程 -- `cache-sync-consumer` 进程角色 -- **Redis Stream `billing-events` + `worker` role + `billing-consumer-handler`** — 异步副作用全部回收到事务内同步执行;不再有“事务提交了但 XADD 失败 → ledger 丢行”的窗口 -- 相关 env:`BILLING_EVENTS_STREAM` / `BILLING_EVENTS_CONSUMER_NAME` / `BILLING_EVENTS_BATCH_SIZE` / `BILLING_EVENTS_BLOCK_MS` / `BILLING_EVENTS_MIN_IDLE_MS` - -## 剩余 TODO - -### LLM 计费精度 - -- [ ] **tiktoken fallback** — gateway 未返回 usage 时用 tiktoken 从 request messages + response body 自算 token 数 -- [x] **消除静默失败** — non-streaming: debit 失败直接抛错阻断响应;streaming: 已发送无法撤回,改为 error 级别日志 + 记录 requestId 便于追查 - -## 明确不做 - -- 不引入 Kafka / RabbitMQ -- 不拆成多个独立 repo -- 不做预扣模式(无法准确估算 LLM 响应 token 数) -- 不再为“异步副作用”单独拉一个 worker 进程;事务内同步搞定就够了。如果以后真有阻塞型耗时副作用,单独评估时再说 diff --git a/apps/server/docs/ai-context/config-and-naming-conventions.md b/apps/server/docs/ai-context/config-and-naming-conventions.md deleted file mode 100644 index 64ba82ebe..000000000 --- a/apps/server/docs/ai-context/config-and-naming-conventions.md +++ /dev/null @@ -1,166 +0,0 @@ -# Config And Naming Conventions - -## 目标 - -这篇文档收口三类容易逐步漂移的约定: - -- `configKV` 的默认值和读取语义 -- Redis key / channel 的命名规则 -- HTTP route 的资源命名规则 - -这些约定不是“代码风格建议”,而是为了减少: - -- 默认值写两份导致的配置漂移 -- Redis key 命名混用导致的排障成本 -- HTTP route 语义不稳定导致的版本化困难 - -## `configKV` 约定 - -### 单一真相源 - -`src/services/adapters/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源: - -- 配置值的运行时校验 -- 配置值的默认值 -- Redis 中的序列化 / 反序列化 shape - -这意味着: - -- 默认值必须定义在 `ConfigEntrySchemas` -- 业务代码不要再写第二份 `?? defaultValue` -- `configKV.get()` / `configKV.getOrThrow()` 应直接依赖 schema 默认值 - -### 读取语义 - -- `getOptional(key)` - - 用于“这个 key 合法地可以不存在”的场景 - - 对 required key,未配置时返回 `null` - - 对带 schema 默认值的 key,返回默认值 -- `getOrThrow(key)` - - 用于“缺失就是配置错误”的场景 - - required key 未配置时抛 `CONFIG_NOT_SET` -- `get(key)` - - 是 `getOrThrow(key)` 的别名 - - 默认用于业务代码 - -### 禁止事项 - -- 不要给 `getOptional` 增加调用点默认值参数,例如 `getOptional(key, fallback)` -- 不要同时在 schema 和调用侧维护两份默认值 -- 不要绕过 `configKV` 直接从 Redis 读配置 - -### 当前例子 - -推荐: - -```ts -const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS') -const maxCheckoutAmount = await configKV.get('MAX_CHECKOUT_AMOUNT_CENTS') -``` - -不推荐: - -```ts -const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1 -const maxCheckoutAmount = (await configKV.getOptional('MAX_CHECKOUT_AMOUNT_CENTS')) ?? 1_000_000 -``` - -## Redis key / channel 命名 - -### 命名规则 - -Redis key 和 channel 统一采用“分段命名”,推荐使用冒号 `:` 作为分隔符: - -```txt -{scope}:{id}:{resource} -{scope}:{id}:{subscope}:{subid}:{resource} -lock:{domain}:{id} -config:{key} -``` - -推荐例子: - -```txt -user:{userId}:flux -config:{key} -chat:{userId}:broadcast -lock:user:{userId}:flux -``` - -不推荐例子: - -```txt -flux:{userId} -chat:broadcast:{userId} -userFlux:{userId} -userUidFlux1 -``` - -### 设计原则 - -- 前缀表达 namespace,而不是随手缩写 -- 真实 Redis key 不要出现 `1`、`2` 这种占位编号 -- 参数占位编号只用于文档里的模板名,不用于运行时 key -- key / channel 必须通过 helper 收口,不要在业务代码里散落模板字符串 - -### 文档里的模板命名 - -如果要在文档中表示“这个 key 有几个参数位”,可以用编号描述模板: - -- `userUserId1Flux` -- `configKey1` -- `lockDomain1Id1` - -但最终真实 key 仍然必须是: - -```txt -user:{userId}:flux -config:{key} -lock:{domain}:{id} -``` - -## HTTP route 命名 - -### 资源命名原则 - -- 优先使用复数资源名 -- 从属资源优先挂在父资源下 -- 当前登录用户资源优先使用 `me` - -推荐: - -```txt -/api/v1/flux -/api/v1/flux/history -``` - -不推荐: - -```txt -/api/user/flux -/api/flux -``` - -### 版本化约束 - -如果某类 HTTP API 需要长期稳定对外契约,优先在一个明确子树下版本化,例如: - -```txt -/api/v1/... -/api/v1/openai/... -``` - -不要让“部分资源版本化、部分资源裸挂”长期并存而没有说明。 - -## TODO - -- Replace DB-derived HTTP request schemas for characters/providers/chats with explicit DTO schemas. -- Move ownership and membership authorization rules behind actor-aware service APIs instead of splitting them across routes and services. -- Stabilize HTTP response shapes so services no longer leak raw Drizzle returning arrays to routes. -- Encode chat member invariants in schema validation and map those failures to 4xx API errors. -- Split the OpenAI compat route and chat WebSocket handler into smaller modules so transport code stops owning orchestration complexity. -- 把 `apps/server` 中现有 Redis key / channel 继续向 helper 收口,避免业务代码里散落模板字符串。 -- 统一把旧式 key 命名迁移到分段命名风格,优先处理 Flux cache、chat broadcast、lock key。 -- 给 `configKV` 增补一份“哪些配置属于 infra、哪些属于运营策略”的清单,避免继续模糊放置位置。 -- 把所有 `configKV.getOptional(...) ?? defaultValue` 模式清理掉,默认值统一回到 `ConfigEntrySchemas`。 -- 评估是否继续沿用统一 `/api/v1/*` 版本树,还是为兼容 API 与业务 API 引入更明确的子域分隔。 diff --git a/apps/server/docs/ai-context/data-model-and-state.md b/apps/server/docs/ai-context/data-model-and-state.md deleted file mode 100644 index 4e981d821..000000000 --- a/apps/server/docs/ai-context/data-model-and-state.md +++ /dev/null @@ -1,237 +0,0 @@ -# Data Model And State - -## 真相源原则 - -这套服务端最关键的状态归属如下: - -- `Postgres` - - 用户认证数据 - - 角色、聊天、Provider 配置 - - Flux 余额与账本 - - Stripe 业务镜像 - - LLM 请求日志 -- `Redis` - - Flux 余额缓存 - - 服务配置 KV - - 聊天跨实例广播 (Pub/Sub) - - Sub-Flux 计量债务账本(TTS 字符等,详见 `flux-meter.md`) - - TTS voices 上游响应缓存 - -如果要判断”改哪个地方才算真的改成功”,大多数场景答案都是 Postgres。Redis Streams 已全部移除,没有”计费事件队列”这层抽象。 - -## 主要表分组 - -### 认证 - -- `user` -- `session` -- `account` -- `verification` - -来源文件: - -- `src/schemas/accounts.ts` - -说明: - -- `better-auth` 直接用这组表 -- 由 `pnpm -F @proj-airi/server auth:generate` 自动产物,手改会被覆盖 - -### 角色与用户交互 - -- `characters` -- `character_covers` -- `avatar_model` -- `character_capabilities` -- `character_i18n` -- `character_prompts` -- `user_character_likes` -- `user_character_bookmarks` - -来源文件: - -- `src/schemas/characters.ts` -- `src/schemas/user-character.ts` - -说明: - -- 角色实体采用软删除 -- 点赞与收藏通过中间表建模 -- 计数值冗余保存在 `characters` 表上 - -### 聊天 - -- `chats` -- `chat_members` -- `messages` -- `media` -- `stickers` -- `sticker_packs` - -来源文件: - -- `src/schemas/chats.ts` - -说明: - -- `messages.seq` 是会话内顺序字段 -- 写消息时通过 `SELECT ... FOR UPDATE` 锁 chat 以串行生成 seq -- `senderId` 是宽松字段,不强制外键 - -### Provider 配置 - -- `user_provider_configs` -- `system_provider_configs` - -来源文件: - -- `src/schemas/providers.ts` - -说明: - -- 运行时查询时会把系统配置和用户配置拼接成一个结果集 -- `config` 是 `jsonb` - -### Flux / 账本 - -- `user_flux` -- `flux_transaction` - -来源文件: - -- `src/schemas/flux.ts` -- `src/schemas/flux-transaction.ts` - -职责边界: - -- `user_flux` - - 当前余额快照(单行/用户) -- `flux_transaction` - - append-only 账本流水(type: credit / debit / initial / promo) - - 同时承担系统真相源和用户可见历史,`/api/v1/flux/history` 直接读这张表 - -关键约束: - -- `flux_transaction` 对 `(userId, requestId) WHERE requestId IS NOT NULL` 有部分唯一索引 -- 用来做扣费 / 充值幂等(含 admin promo grant 的 `idempotencyKey`) - -### Stripe 业务镜像 - -- `stripe_customer` -- `stripe_checkout_session` -- `stripe_subscription` -- `stripe_invoice` - -来源文件: - -- `src/schemas/stripe.ts` - -说明: - -- 这些表是 Stripe 状态的本地镜像 -- 真正的余额变化仍由 `billingService` 写入 `user_flux + flux_transaction` -- `fluxCredited` 字段用于避免重复入账 - -### LLM 请求日志 - -- `llm_request_log` - -来源文件: - -- `src/schemas/llm-request-log.ts` - -说明: - -- 只做追加写入 -- 明确不加 user 外键,以避免高并发写入的额外约束成本 - -## 服务与状态写入边界 - -### `createFluxService()` - -负责: - -- 余额读取 -- 新用户首次读取时初始化 `user_flux` -- Redis cache-aside - -不负责: - -- 扣费 -- 充值 -- transaction 写入 - -### `createBillingService()` - -负责: - -- 所有余额写操作 -- DB 事务 -- debitFlux / credit 方法:事务内 lock → check → update `user_flux` → insert `flux_transaction` ledger -- 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存 - -这是所有 Flux 写路径应收敛到的中心。 - -### `createStripeService()` - -负责: - -- Stripe 实体 upsert - -不负责: - -- 最终 Flux 入账 - -真正入账通过 `billingService.creditFluxFromStripeCheckout()` 或相关 credit 方法完成。 - -## Redis 中的数据类型 - -### Flux 缓存 - -- key: `flux:` -- value: 字符串化整数 - -写入来源: - -- `fluxService.getFlux()` cache miss 后回填 -- `billingService` 余额事务提交后 best-effort `redis.set` 直接更新(API 进程内同步) - -### 配置 KV - -- key: `config:` - -由 `config-kv.ts` 管理,支持: - -- 数值 -- 字符串 -- `FLUX_PACKAGES` JSON - -### 聊天跨实例广播 - -- channel: `chat:broadcast:` - -## 幂等与并发控制 - -### 余额并发 - -`billingService` 在事务中: - -1. (可选)按 `(userId, requestId)` 命中 ledger → 命中即返回,跳过余下步骤 -2. `SELECT user_flux FOR UPDATE` -3. 计算新余额 -4. 写 `user_flux` + 写 `flux_transaction` ledger -5. 事务提交后 best-effort `redis.set` - -这保证同一用户余额更新是串行化的,并且 ledger 行与余额变更在同一原子提交里。 - -### Stripe 幂等 - -主要依赖: - -- `stripe_checkout_session.fluxCredited` -- `stripe_invoice.fluxCredited` -- `flux_transaction(userId, requestId)` 唯一约束 - -## 现有代码中的结构信号 - -- `src/schemas/flux-grant-batch.ts` 是已废弃的旧 admin batch 设计 schema,没有被 `app.ts` 装配也没有 migration 在用,是 dead code,改这块前直接删除。当前 admin 发 FLUX 走 `/api/admin/flux-grants` 同步路径,不写新表。 diff --git a/apps/server/docs/ai-context/email-auth-resend.md b/apps/server/docs/ai-context/email-auth-resend.md deleted file mode 100644 index f0a4f863d..000000000 --- a/apps/server/docs/ai-context/email-auth-resend.md +++ /dev/null @@ -1,78 +0,0 @@ -# Email auth via Resend (apps/server + apps/ui-server-auth) - -Status: in progress -Last updated: 2026-04-27 - -## Goal - -1. 接入 **Resend** 作为 `apps/server` 的统一邮件发送 service。 -2. 把 Better Auth 的四个邮件回调接好: - - `emailVerification.sendVerificationEmail`(注册后验证邮箱) - - `emailAndPassword.sendResetPassword`(忘记密码) - - `user.changeEmail.sendChangeEmailVerification`(改邮箱) - - `magicLink.sendMagicLink`(passwordless 登录,启用 plugin) -3. 在 `apps/ui-server-auth` 加上邮箱注册 / 邮箱密码登录 / 忘记密码 / 重置密码 等界面。 - -## 用户路径(必须端到端跑通) - -只有这两条本期要 ship: - -1. **注册路径**:用户敲 `/sign-up` → 填邮箱 + 密码 → 提交 → 进 `verify-email` 提示页 → 收邮件点链接 → `verify-email?token=...` 落地页提示成功 → 跳 `/sign-in`。 -2. **忘记密码路径**:用户在 `/sign-in` 点 "Forgot password" → 进 `/forgot-password` 输邮箱 → 提交 → 提示已发送 → 用户点邮件链接 → `/reset-password?token=...` 输新密码 → 跳 `/sign-in`。 -3. **常规邮箱登录**:`/sign-in` 输邮箱 + 密码 → 走 OIDC `loginPage` 流程把用户登入,返回上游 `/oauth/authorize`。 - -服务端为 magic link / change email 接好回调(避免功能闭包不齐一半),但前端 UI 留待后续。Service 拒绝静默吞错——发送失败要走错误响应让 Better Auth 把错抛回前端。 - -## 范围明确 - -In: - -- `apps/server/src/services/adapters/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。 -- `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。 -- `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。 -- `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。 -- `apps/ui-server-auth/src/pages`:扩 `sign-in.vue`;新增 `sign-up.vue`、`verify-email.vue`、`forgot-password.vue`、`reset-password.vue`。 -- `apps/ui-server-auth/src/modules/sign-in.ts` 同级补 `email-password.ts` 处理 emailPassword sign-in/up + forgot/reset 的真实调用。 -- `packages/i18n`:新增 auth.signUp / verifyEmail / forgotPassword / resetPassword 字段。 - -Out: - -- Magic link 前端 UI(`magic-link-sent.vue` / sign-in 上的 "Email me a link" 入口)。 -- Change email 前端流程(账号设置页里发起、点击新邮箱链接验证)。 -- 自定义 SMTP fallback / 多 provider 抽象。本期只接 Resend,但 service 接口签名留 provider 替换余地。 -- 邮箱 / 邮件模板的 i18n(先英文一个版本,后续补)。 - -## 关键决策 - -- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom`(`@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。 -- **触发邮件的位置**:Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。 -- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放独立部署的 `apps/ui-server-auth`。`API_SERVER_URL` 是 server 自身(如 `https://api.airi.build`),ui-server-auth 是同站点的另一域(如 `https://accounts.airi.build/ui`);两者通过 trustedOrigins 互信。链接组装规则: - - Verify email:`/verify-email?token=` - - Reset password:`/reset-password?token=` - - 由 `getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 ``,避免硬编码。 -- **`requireEmailVerification: true` 开启的副作用**:现存历史用户(尚未验证)将在下次登录被拦截。**社交登录(Google/GitHub)默认 `emailVerified=true`**,不受影响。需要在 sign-up 后端响应中带 `requiresEmailVerification` 标志,前端据此跳到 `verify-email` 提示页。 -- **OIDC `loginPage: '/sign-in'` 不变**:sign-in 加表单后仍然走 `oauth/authorize → /sign-in?... → 登录成功 → callbackURL 回 oauth/authorize`,不破坏现有流程。 - -## 假设 / 待验证 - -- `resend` SDK ESM-only?需在加包后 `pnpm typecheck` 验证(unverified)。 -- `better-auth/plugins/magic-link` 可与 `oauthProvider` 共存(unverified,但插件是独立 endpoint,不冲突)。 -- ui-server-auth 在 dev 下走 `http://localhost:5173`,与 `apps/server` 不同源。`server` 已在 `getAuthTrustedOrigins` 把 dev origin 加进来。 - -## 验证计划 - -每条用户路径要落一份验证记录到 `docs/ai/context/verifications/email-auth-.md`: - -1. `email-auth-signup.md`:dev 环境注册一次,列出真实 curl / 浏览器步骤、Resend dashboard 命中、点链接落地页结果。 -2. `email-auth-forgot.md`:忘记密码同上。 -3. `email-auth-signin-email-password.md`:emailPassword sign-in 完整 OIDC 闭环。 - -未跑过这三条 = 默认 unverified,不能声明完成。 - -## 不做(明确说"以后") - -- 邮件 i18n(仅英文) -- 邮件模板真实视觉设计(先用最小可读模板) -- Resend webhook(bounce / complaint 回调)接入 -- 邮件审计日志写入 `request_log` 表 -- Magic link 前端 UI 与 change-email 前端 UI diff --git a/apps/server/docs/ai-context/flux-meter.md b/apps/server/docs/ai-context/flux-meter.md deleted file mode 100644 index f901a23f1..000000000 --- a/apps/server/docs/ai-context/flux-meter.md +++ /dev/null @@ -1,114 +0,0 @@ -# Flux Meter(债务账本计费) - -## 背景 - -Flux 是整数计费单位。但部分服务(TTS 字符、STT 秒、embedding token 等)的单价远小于 1 Flux:例如 TTS 当前定价 `FLUX_PER_1K_CHARS_TTS = 2`,意味着 1 Flux ≈ 500 字符。 - -最初的实现采用 `max(MIN_CHARGE_TTS, ceil(chars/1000 * rate))`,每个 TTS 请求都被向上取整为至少 1 Flux。这在前端把一整轮 Agent 回复切成 N 个短句分发的场景下极不公平:100 字的回复被切 10 段 = 10 Flux,而单次发完只要 1 Flux。 - -## 决策 - -实现一层通用的"债务账本",把不到 1 Flux 的零头存在 Redis,跨请求累计,攒够整数 Flux 才下扣。 - -### 为什么不是 sessionID / turnId 聚合 - -考虑过让前端给每轮对话发一个 turnId,服务端按 turn 聚合后结算。否决理由: - -1. **前端改造成本**:要生成 turnId、改 OpenAI 兼容请求 header、加 `finalize` 信号、处理崩溃路径 -2. **预扣边界混乱**:一轮总字符数事先未知,余额检查要按"最坏情况"或"已累计 + 当前"估算,余额刚好够时容易在中途 402,把一轮对话切两半(前半段有声音、后半段没) -3. **结算依赖客户端信号**:finalize 不发就要 keyspace notification + worker 兜底,多实例下要选主或幂等 -4. **欠账上限不可控**:一轮可以有几千字,欠账可能多 Flux - -债务账本不依赖任何业务边界:每次精确扣,欠账恒定 < 1 Flux(< `unitsPerFlux` 个单位),TTL 到期抹零,对账简单。 - -### 为什么不是时间窗口 - -5 分钟聚合也能解决"短请求被高估"问题,但需要 cron / 懒扣双机制处理窗口边界,且窗口跨越用户会话时语义诡异。债务账本没有"窗口"概念,纯量化累计。 - -## 数据流 - -``` -┌──────────┐ units ┌─────────────┐ -│ route │──────────▶│ FluxMeter │ -│(handleX) │ │ accumulate()│ -└──────────┘ └─────┬───────┘ - │ Lua: INCRBY + 阈值判断 + DECRBY - ▼ - ┌──────────┐ - │ Redis │ flux-meter:{name}:{userId}:debt - └─────┬────┘ - │ 跨阈值 → fluxToDebit > 0 - ▼ - ┌──────────────────────┐ - │ BillingService │ - │ consumeFluxForLLM() │ ← 走原有 debitFlux + Stream - └──────────────────────┘ -``` - -### Lua 脚本(原子) - -```lua -local debt = redis.call('INCRBY', key, units) -redis.call('EXPIRE', key, ttl) -if debt >= unitsPerFlux then - local flux = math.floor(debt / unitsPerFlux) - redis.call('DECRBY', key, flux * unitsPerFlux) - return {flux, debt - flux*unitsPerFlux} -end -return {0, debt} -``` - -INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实例并发请求同一用户安全。 - -## API - -`packages/server/src/services/domain/billing/flux-meter.ts` - -- `createFluxMeter(redis, billingService, { name, resolveRuntime })` → meter 实例 - - `resolveRuntime: () => Promise<{ unitsPerFlux, debtTtlSeconds }>` **每次调用都执行**,不做进程内缓存。多实例部署下任一实例改配置,其它实例下一次请求立即生效。 - - 配置缺失不会让 `createApp` 启动阶段挂,只会在首个 TTS 请求时抛错,配合 route-level `configGuard` 产生 per-request 503,不会连带 chat/auth/stripe 一起挂。 -- `meter.assertCanAfford(userId, newUnits, currentBalance)` — 请求前余额校验,不足直接 throw 402 -- `meter.accumulate({ userId, units, currentBalance, requestId, metadata })` — 累加并按需结算,返回 `{ fluxDebited, debtAfter, balanceAfter }`。billing debit 抛错时,已结算的 units 会被 INCRBY 回滚到债务账本,保证不漏账。 -- `meter.peekDebt(userId)` — 读当前未结算字符数(运维/调试用) - -## 复用指南 - -任何"消耗单位 < 1 Flux"的服务都应该走债务账本,不要重复实现"单请求最低消费"。 - -### 已接入 - -| 服务 | name | unitsPerFlux 来源 | -|---|---|---| -| TTS | `tts` | `1000 / FLUX_PER_1K_CHARS_TTS`(在 `app.ts` 装配时计算) | - -### 推荐接入 - -| 服务 | name | unitsPerFlux 示意 | -|---|---|---| -| STT 转录 | `stt` | 60 秒 = 1 Flux | -| Embedding | `embedding` | 10000 token = 1 Flux | -| 自营小模型 chat | `llm-mini` | 视定价而定 | - -**不适用**:每次调用本身就 ≥ 1 Flux 的服务(如图像生成)。直接 `consumeFluxForLLM` 即可,套一层 meter 反而降低可读性。 - -### 接入步骤 - -1. 在 `services/adapters/config-kv.ts` 加费率/TTL 配置项 -2. 在 `app.ts` 用 `injeca.provide` 注册新 meter,注入对应路由 / 服务 -3. 在路由中:先 `assertCanAfford`,调上游成功后 `accumulate` -4. 加单测覆盖:累计跨阈值、empty input、余额不足 - -## Tradeoff & 已知局限 - -- **欠账上限**:每用户每 meter 最多欠 `unitsPerFlux - 1` 个单位(< 1 Flux),TTL 到期抹零。这部分给用户。 - - 想严格不欠账:加 settler worker 监听 `__keyevent@0__:expired` 在过期时强制结算到下一个整 Flux。当前不做,量化损失太小。 -- **审计粒度变粗**:`flux_transaction` 一条记录可能对应多次请求,description 为 `_request`(如 `tts_request`,和 `llm_request` 保持同一命名风格)。具体哪几个 requestId 贡献了这次扣费,靠 OTel span / `request_log` 反查。 -- **预扣不精准**:`assertCanAfford` 按"当前累计 + 这次 units"算,无法预知后续请求。极端情况下用户余额从够到不够之间会有几次请求成功(最多欠 < 1 Flux),可接受。 -- **TTL 重置**:每次 accumulate 都 `EXPIRE`,一个长期活跃用户的债务永远不会过期,会一直滚到下次跨阈值。这是期望行为。 - -## 不做 - -- 不做会话 / turn 级聚合(理由见上) -- 不做 keyspace notification 兜底结算(量化损失可忽略) -- 不在第一版支持 meter 间组合扣费(一次请求消耗多种资源) -- 不为 meter 单独建 transaction 表(`flux_transaction` 已够用) diff --git a/apps/server/docs/ai-context/langfuse-tracing.md b/apps/server/docs/ai-context/langfuse-tracing.md deleted file mode 100644 index 5d8f789d8..000000000 --- a/apps/server/docs/ai-context/langfuse-tracing.md +++ /dev/null @@ -1,111 +0,0 @@ -# Langfuse LLM-native 观测接入 - -逐条 prompt 级 trace + 评测 + 成本归因到用户/会话。阶段 1(chat completion + TTS speech)已实现并验证,见下「已实现」与 `verifications/langfuse-tracing.md`。 - -## 目标 - -1. 逐条 prompt trace:每次 `/api/v1/openai/chat/completions` 的 input messages、output、model 完整可读。 -2. 评测 eval:dataset、人工标注、LLM-as-judge。 -3. 成本归因:按 user 和按 conversation/session 切分 token 与成本。 - -## 为什么直接 Langfuse,不先用 Grafana 顶 - -Grafana 栈(Prometheus/Tempo/Loki)已经管好 ops 聚合层,继续不动:rate、latency、聚合 token 吞吐/消耗、按模型 flux、错误、fallback、上游健康。这层完整。 - -上面三个目标里 Grafana 栈的实际能力: - -- 逐条 prompt trace:Tempo 能塞 span,但 trace 是 head-sampled,采样比 < 1 直接丢 prompt;span 属性有体积上限,长 prompt 被截;没有把 prompt 当一等对象读/对比/标注的界面。 -- eval:零能力,dataset / 标注队列 / LLM-as-judge 全得自己造。 -- 按 user/session 成本:Prometheus 按 userId/sessionId 做 label 会高基数爆炸,做不了。按 user 的成本原料其实在 `llm_request_log` 表里,用 SQL 能查,跟 Grafana 无关。 - -这三件事里正文采集是最重的活,两边都得从零写,先做 Grafana 一点不省。真正能省的只有 eval 和 per-user/session 成本,而这俩在 Grafana 上等于手搓一个更差的 Langfuse,迁移时全扔。所以不走「先 Grafana 再 Langfuse」,直接 Langfuse 补 LLM-native 这一层。 - -边界划分: - -- Grafana 栈:ops 指标,不动。 -- Langfuse:逐条 prompt trace + 正文 + eval + user/session 成本归因。 - -## 选型:Langfuse v5 = OpenTelemetry SpanProcessor - -Langfuse v5 JS SDK 基于 OpenTelemetry,`@langfuse/otel` 的 `LangfuseSpanProcessor` 就是一个 OTel SpanProcessor。AIRI 这里没有把它挂到现有 NodeSDK 上,而是起一个独立 `NodeTracerProvider` 并通过 `setLangfuseTracerProvider()` 只给 `@langfuse/tracing` 使用。 - -- Grafana 出口:`OTLPTraceExporter` → Grafana Cloud Tempo。 -- Langfuse 出口:独立 provider 上的 `LangfuseSpanProcessor` → Langfuse Cloud。 -- 两者共享 OTel context/trace id,但不共享 SpanProcessor,避免 prompt/completion 正文进 Grafana Tempo。 - -不走「复用 OTLP exporter 指向 Langfuse」的原因:① 目标里有 eval,eval 只能走 Langfuse SDK/API,OTLP 解决不了;② 现有 span 用 `airi.gen_ai.*` 自定义 attribute key,Langfuse 不认,得改成它认的 generation 字段。一套 SDK 把 trace + 正文 + 成本 + eval 全包,不维护两条上报路。 - -需要的包:`@langfuse/tracing`、`@langfuse/otel`。 - -## 部署形态与脱敏决策 - -- 形态:Langfuse Cloud。 -- 脱敏:不脱敏。prompt/completion 正文全量出境到 Langfuse 托管,已确认可接受。 -- 凭据:`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` / `LANGFUSE_BASE_URL`(SDK 默认变量名,带下划线),走 Railway env secret,本地走 `.env.local`。instrumentation.ts 和网关 route 都直读 `process.env`,**不进 `env.ts` 的 valibot schema**。原因:instrumentation 是 preload(在 env 解析之前跑),且这是部署开关(类比 NODE_ENV)不是服务依赖,不需要进 DI。 - -## Session 来源决策 - -网关 `openai/v1/index.ts` 是无状态 OpenAI 兼容代理,本身没有 conversation/session 概念,每请求只有 `c.get('user')` 的 userId 和一个临时 `nanoid()` requestId。`chats` 表存在但跟这条代理路径零关联(那是另一套 `/chats` API)。 - -所以 session id 必须客户端传。决策:客户端传对话 id,网关读出来当 Langfuse `sessionId`。 - -落地点几乎免费:stage-ui 的 `packages/stage-ui/src/libs/providers/providers/official/shared.ts` 已有 `withCredentials()` fetch 包装器在每请求注入 `Authorization` header。在同一处加一个 `x-airi-session-id` header 即可,不用碰 `@xsai` 的 body 透传。stage-web 和 stage-tamagotchi 都复用这个 provider,改一处两端生效。 - -改端范围仅此一处。telegram-bot 自带 provider,不经 server 网关,不在范围内。 - -## 成本归因方案 - -- USD 真金成本:交给 Langfuse 按 model 定价表自动算(传 `usageDetails` 的 input/output tokens 即可)。风险见下。 -- flux 业务成本:作为自定义字段放 generation 的 `metadata`(flux 不是货币,不塞 `costDetails`)。 -- 这样业务成本(flux)和真金成本(USD)都在,按 user / session 都能切。 - -## 已实现(阶段 1:chat completion) - -### `apps/server/instrumentation.ts` - -- `langfuseEnabled = !!LANGFUSE_PUBLIC_KEY && !!LANGFUSE_SECRET_KEY`,与 `otlpEndpoint` **独立门控**。两者任一开启就启动 NodeSDK;只配一个不会让另一个静默 no-op。 -- OTLP 的 trace exporter / metric reader / log processor 仅在 `otlpEndpoint` 存在时挂(条件 spread),NodeSDK 的 `spanProcessors` 数组只装 OTLP 的 `BatchSpanProcessor`。 -- Langfuse 起独立 `NodeTracerProvider`(`langfuseProvider`),其上挂 `LangfuseSpanProcessor`(`exportMode: 'batched'`),再 `setLangfuseTracerProvider(langfuseProvider)` 让 `startObservation` 路由到它。 -- `shouldExportSpan`:只放行带 `langfuse.*` 属性的 span(SDK 创建的 generation 都带 `langfuse.observation.type` 等)。这是防御性兜底,独立 provider 本来就只见自己的 span。 -- 独立 provider 显式 `AlwaysOnSampler`:调低 `OTEL_TRACES_SAMPLING_RATIO` 给 Grafana 降量,不会丢 Langfuse generation,逐条 prompt 捕获保持完整。 -- SIGTERM:`Promise.all([sdk.shutdown(), langfuseProvider?.shutdown()])`。`sdk.shutdown()` 不会 drain 独立 provider,必须显式 shutdown,否则最后一批 generation 在部署重启时丢失。 - -### `apps/server/src/routes/openai/v1/index.ts`(`handleCompletion`) - -`langfuseEnabled` 门控(只读一次,非每请求):读 `process.env.LANGFUSE_TRACING_ACTIVE`,这个 sentinel 由 instrumentation.ts 在 `setLangfuseTracerProvider()` 成功**之后**才置 `'1'`。**禁用时不创建 generation** —— 这很关键:Langfuse 关闭时没调 `setLangfuseTracerProvider`,`startObservation` 会 fallback 到全局 provider,正文就会漏进 OTLP/Grafana。gate 绑定真实 provider 状态(单一真相在 instrumentation.ts),而不是在 route 里独立再判一次 key —— 避免将来改 instrumentation 的开关条件时两处 desync 导致正文漏到错误后端。 - -output(给 eval 用,要可读):非流式 = `responseBody`;流式 = 从 SSE delta 解析出的 assistant 正文(`extractSseDeltaText` 逐行解析 `choices[0].delta.content`,**不是**原始 `data: {...}` 框架),硬上界 1M 字符防止长输出 × 高并发占内存。 - -generation 形态: - -- `startObservation('chat.completion', { input: body.messages, model: requestModel, metadata: { requestId, stream } }, { asType: 'generation' })`。 -- 身份:`generation.otelSpan.setAttribute('langfuse.user.id', user.id)`;有 `x-airi-session-id` header 时再 set `langfuse.session.id`。这两个 compat 属性被平台提升为 trace 级,支撑按 user/session 归因。 -- output:非流式 = `responseBody`;流式 = 后台累积的 assistant 正文。 -- usageDetails:`{ input: promptTokens, output: completionTokens }`,复用计费已提取的 usage。 -- metadata:保留 `{ requestId, stream }`,完成时补 `{ fluxConsumed }`。 -- 生命周期:5 个退出分支各 `generation?.update(...)` + `generation?.end()` 恰好一次 —— router throw catch、`!response.ok`、流式 interrupted(finally)、流式 completed(finally)、非流式。错误分支标 `level: 'ERROR'` + `statusMessage`。流式 generation 在后台 async IIFE 的 finally 里结束,跟 `span.end()` 对齐,不在 response 返回时提前结束。 - -### `apps/server/src/routes/openai/v1/index.ts`(`handleTTS`) - -- `startTtsGeneration({ input: { text, voice, speed, responseFormat }, model, requestId, userId, sessionId })` 创建 `tts.speech` generation。 -- 不缓冲二进制 audio 到 Langfuse;成功 output 只记录 `{ contentType }`。 -- usageDetails 使用 `{ input: inputChars }`,flux 作为 metadata 记录。 -- router throw、上游非 2xx、billing/Redis failure 都会 `fail(...)` 并 end;成功在 `ttsMeter.accumulate()` 后 `succeed(...)`。 - -### `packages/stage-ui/src/libs/providers/providers/official/shared.ts` - -- `withCredentials()` 除 `Authorization` 外,会在 Pinia 已初始化且有 active chat session 时注入 `x-airi-session-id`。 -- stage-web / stage-tamagotchi / stage-pocket 复用 official provider 的请求都会带同一个会话 id;匿名或非 chat 上下文没有 active session 时自动退化为 user-only trace。 - -### `apps/server/src/libs/env.ts` - -未改 —— LANGFUSE_* 故意不进 valibot schema(见「部署形态与脱敏决策」)。 - -## 验证 - -见 [`verifications/langfuse-tracing.md`](./verifications/langfuse-tracing.md)。Langfuse Cloud(project airi)回读到 `chat.completion` GENERATION,完整带 input(messages)/ output / model / usageDetails / userId / sessionId / metadata,trace 与 generation 共享 traceId。当前代码路径的 typecheck、targeted Vitest、eslint 通过。 - -## 待办(后续阶段) - -- **staging 真实端到端**:`.env.local` 的 DB/Redis/OTLP 全指生产,本地起真实 server 会连生产。在 staging(指向非生产)起 server 发一次真实 chat 请求补全 HTTP 路径端到端。 -- **model 定价匹配**:网关 model 是解析后的路由名,能否命中 Langfuse 定价表算 USD 待实测;命不中就配自定义定价或传 `costDetails`。flux 已放 metadata。 diff --git a/apps/server/docs/ai-context/llm-router-codex-followups.md b/apps/server/docs/ai-context/llm-router-codex-followups.md deleted file mode 100644 index 14451288f..000000000 --- a/apps/server/docs/ai-context/llm-router-codex-followups.md +++ /dev/null @@ -1,111 +0,0 @@ -# LLM/TTS router codex review — deferred follow-ups - -Codex independent review (2026-05-15) on commit `1bb0aab2f` returned 12 -findings. Applied 3 HIGH inline (see commits after `1bb0aab2f`). The 9 -remaining findings are deferred — each evaluated through the AGENTS.md -"外部建议 3 问过滤" and tracked here per "拒绝时留痕" rule. - -## Applied inline (HIGH severity) - -1. **Client abort signal not threaded into `llmRouter.route()`** — fixed in - `apps/server/src/routes/openai/v1/index.ts`. `c.req.raw.signal` now flows - into both the router and the legacy `fetch()` fallback. Prevents the - "client disconnects but upstream keeps generating + burning paid quota" - leak. -2. **Failed upstream response bodies not drained** — fixed in - `apps/server/src/services/domain/llm-router/router.ts`. Every non-2xx fallback - path now calls `response.body?.cancel()` before continuing. Prevents - socket-pool exhaustion under fallback storms. -3. **SSML voice attribute injection** — fixed in - `apps/server/src/services/adapters/tts/azure.ts`. Voice id is - regex-validated (`^[a-z0-9-]+$/i`) before SSML interpolation; invalid - values throw `BAD_REQUEST`. Prevents attribute-context breakout under - the server's Azure credential. - -## Deferred — rationale per finding - -### #4: in-flight `loadFresh()` can repopulate cache after `invalidate()` - -**Severity**: MEDIUM. **Decision**: defer to v1.x. -**Rationale**: race window is bounded by the Pub/Sub-driven invalidate -firing during a concurrent TTL-driven reload. In practice the operator -revokes a key, the active in-flight load was already reading the -*pre-revocation* config and so writes back the old key state. Worst case -the stale config lives until next TTL expiry (5s). Acceptable for v1 -given the 5s SLO target — not a security hole, just a propagation hiccup. -Fix shape (generation counter on `loadFresh`) is well-known and cheap; do -it the next time someone touches `config-loader.ts`. - -### #5: no single-flight on concurrent cache miss - -**Severity**: MEDIUM. **Decision**: defer. -**Rationale**: configKV's underlying Redis read is single-digit-ms in -practice; even a 10-request stampede is 10 cheap reads. The plan's -adversarial reviewer flagged this; we accepted because the cure -(promise-dedup) adds state with its own race window and rarely matters -at airi's current QPS. Revisit if `airi.gen_ai.gateway.config.reload` -spikes after a Pub/Sub burst. - -### #6: `fullChainTimeoutMs` not enforced as a hard cap - -**Severity**: MEDIUM. **Decision**: apply in next iteration. -**Rationale**: legit bug — per-attempt × N keys can exceed the configured -60s cap. Worth fixing but requires a route-level abort controller that -composes with the per-attempt one; not a 5-minute change. Track here so -it doesn't get lost. - -### #7: 200-with-error-envelope not detected - -**Severity**: MEDIUM. **Decision**: reject for v1. -**Rationale**: codex flagged this without citing real evidence of any -v1 provider doing it. The four configured providers (OpenRouter, Azure, -DashScope, Volcengine) return proper HTTP status codes for errors. Adding -body-shape detection across LLM + 3 TTS adapters adds complexity that -guards against a hypothetical. "外部建议 3 问过滤" (c): would create -duplicated parsing logic in every adapter. Revisit if a provider is added -that does return 200-with-error. - -### #8: incomplete plaintext zeroization in `encryptKey()` - -**Severity**: MEDIUM. **Decision**: partial accept — fix in next iteration. -**Rationale**: codex is right that `encryptKey` leaves `plaintextBytes` -uncleared. Fix is `try/finally { plaintextBytes.fill(0) }` — apply in -v1.x. Note: rendered `Authorization: Bearer ` strings cannot be -zeroized once V8 has interned them as JS strings; that limitation is -fundamental and worth documenting in `envelope-crypto.ts` JSDoc. - -### #9: provider API keys via argv in `seed-router-config.ts` - -**Severity**: MEDIUM. **Decision**: apply. -**Rationale**: legit security issue — keys appear in `ps` output and -shell history. Switch to env var input (`OPENROUTER_KEY` etc.) and -remove the `--openrouter-key` flag. Will apply in next iteration to the -seed script; the script is operator-only and runs locally so impact is -bounded but the principle is right. - -### #10: `e2e-llm-router.ts` debug fetch logs auth header prefix - -**Severity**: MEDIUM. **Decision**: apply now (trivial). -**Rationale**: legit. The 30-char prefix can identify accounts. Fix shape: -replace `${auth.slice(0, 30)}...` with ``. Apply in next commit. - -### #11: identical `current` / `previous` master key not rejected - -**Severity**: LOW. **Decision**: apply. -**Rationale**: defensive guard, ~3 lines. Will apply in next iteration. - -### #12: Pub/Sub handler unbounded JSON parse - -**Severity**: LOW. **Decision**: defer. -**Rationale**: Redis is on the trusted private network in our deployment -model; HMAC was already deferred from the plan (P2 finding from -ce-doc-review). Size guard is small but its security value is contingent -on the same untrusted-Redis threat we explicitly accepted. Mark as -follow-up when HMAC is added in the same iteration. - -## Tracked - -- v1.x cleanup pass (issues #6, #8, #9, #10, #11): bundled commit before - next ship. -- v1.x or v2 (issues #4, #5, #7, #12): revisit when ops data shows the - underlying assumption broke. diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md deleted file mode 100644 index d79692bf1..000000000 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ /dev/null @@ -1,289 +0,0 @@ -# Metrics Ownership - -这份文档定义 AIRI 团队的指标分层规则:什么指标该走 Grafana / Prometheus(OTel server-side),什么该走 PostHog(frontend/external product analytics),同名指标怎么处理。落地这份是为了避免后期"同一个 KPI 三处不同数"的漂移。 - -## 总原则 - -工具职责正交,**互补不互替**: - -| 层 | 工具 | 关键属性 | -|---|---|---| -| **System / API observability** | Grafana Cloud + Prometheus + OTel | 系统健康、延迟、错误率、SRE on-call 告警 | -| **Product analytics** | PostHog Cloud | 前端用户行为、漏斗、retention、cohort、A/B、feature adoption | -| **Financial truth source** | Postgres (`flux_transaction` / Stripe webhook 持久化) | 收入与扣费 ledger,任何展示都视作近似 | -| **LLM-native observability** | Langfuse Cloud(已接入:chat completion + TTS speech) | 逐条 prompt/completion trace、TTS text trace、token/字符用量、按 user/session 成本归因、eval。真实 staging HTTP E2E 与 model 定价匹配待做 | - -**业界没有权威的判定 framework**(参见下方"参考来源"),这份文档落实成项目内的可执行规则。 - -## 7 题判定 Checklist - -每条新增指标依次问这 7 个问题: - -| # | 问题 | 偏 Grafana | 偏 PostHog | -|---|------|-----------|------------| -| 1 | 超阈值需要**分钟级 on-call 告警**? | ✓ | | -| 2 | 主要读者是 **SRE / 后端工程师**,不是 PM? | ✓ | | -| 3 | 需要跟 **trace / log join**(分布式 debug)? | ✓ | | -| 4 | 含义依赖**用户身份 / session**("哪个用户做了什么")? | | ✓ | -| 5 | 消费场景是**漏斗 / retention cohort / A/B test**? | | ✓ | -| 6 | 会被 **CEO / PM 在周会 OKR review** 看? | | ✓ | -| 7 | 采集点在**前端页面**(pricing page、onboarding)? | (拿不到) | ✓ | - -**裁决规则**: - -- ≥4 个偏一侧 → 那一侧 -- 平局 → 两边都放,但**指定唯一 truth side**(见下文) -- 如果一个指标 7 题答下来很纠结,多半是**指标定义本身没拆干净**——应该拆成两个不同的指标,分别归到两边,而不是混合归属 - -## Truth Side(重复指标处理) - -业界没有银弹(PostHog 官方在 [issue #43633](https://github.com/posthog/posthog/issues/43633) 也承认 dual-emit 没有统一 pattern)。我们的做法:**接受两边数字差异,dashboard 上标注语义不同**。 - -### Truth side 指定原则 - -| 指标类型 | Truth side | 理由 | -|---|---|---| -| 计费 ledger(每一分钱可审计) | **Postgres** | Grafana / PostHog 都视作近似展示,争议查 SQL | -| HTTP / WS / DB / Stripe webhook **计数** | **Grafana**(OTel counter) | 系统事件,PostHog 看不到 | -| 用户去重 DAU / WAU / retention | **Postgres → Grafana** for server truth; **PostHog** for frontend journey | Server 不直接发 PostHog;后端事实查 `product_events` / `user.last_seen_at` | -| 收入展示(MRR / ARR / churn revenue) | **Postgres → 两边展示** | 真相在 Postgres,Grafana 取系统侧切片(panel-30),PostHog 取用户维度切片 | -| LLM token / cost(聚合速率、按模型) | **Grafana**(OTel counter) | 系统侧聚合,SRE 视角 | -| LLM 逐条 prompt / completion / TTS text / eval / 按 user-session 成本 | **Langfuse** | 已接入 chat completion + TTS speech,正文级 trace + eval | -| 用户行为漏斗各步骤 | **PostHog** for frontend steps; **Postgres/Grafana** for server steps | Server-side facts 不在请求路径发 PostHog | - -### Better Auth session table 与活跃用户 - -`user_active_sessions`(COUNT(\*))和 `user_distinct_active`(COUNT(DISTINCT user_id))共享同一张 `session` 表: - -- **Better Auth 每次 sign-in / 每次 OIDC access-token 颁发都新建一条 session row,从不主动 GC 过期 row**——因为 `oauth_access_token.session_id` FK 指向 session(`apps/server/src/libs/auth.ts:513` 注释) -- 实战观察:~80K `user_active_sessions` 对应实际只有几百 distinct user。比例 5+ 就该考虑加 session GC cron 或缩短 Better Auth `expiresIn` -- 永远展示 `user_distinct_active` 给非工程师看(PM、运营);`user_active_sessions` 留给工程师 debug - -### Dashboard 标注规则 - -两边都展示的指标,**必须**在 Grafana panel description 和 PostHog insight description 里: - -1. 注明 truth side("Truth: Postgres `flux_transaction` 表" / "Truth: PostHog 事件去重") -2. 注明本侧统计的语义差异(如 "Grafana 这里是 session 计数,不去重;PostHog 那边是 user 去重 DAU") -3. 如果两边数字差异预期 > 10%,写明合理范围 - -## PostHog 事件命名约定 - -格式:`_`,全部 `snake_case`。 - -| 约定 | 示例 | -|---|---| -| 名词在前,动词过去式在后 | `pricing_page_viewed`、`plan_selected`、`payment_completed` | -| 一律 past tense | `signup_completed` 不是 `complete_signup` | -| 不带产品 / 模块前缀 | `chat_session_started` 不是 `airi_chat_session_started` | -| 不带技术细节前缀 | `model_switched` 不是 `frontend_model_switched` | -| properties 用 `snake_case` | `{ plan_id, price_usd, checkout_session_id }` | -| 跟外部系统串联的 ID 用原平台命名 | `stripe_customer_id`、`stripe_subscription_id`、`checkout_session_id` | - -`distinctId` 在登录后必须调 `posthog.identify(userId)`,userId 用 Better Auth 的 user id(跟 server 里的 `c.get('user').id` 一致)。AIRI server 不直接接入 `posthog-node`,后端事实事件写入 Postgres `product_events` 并通过 `airi_product_events_total` 暴露到 Grafana。前端 wiring 由 `useSharedAnalyticsStore.initialize()` 自动处理,不需要每个 caller 手动 identify。 - -参考来源:[PostHog: 5 events all teams should track](https://posthog.com/blog/events-you-should-track-with-posthog)。 - -## Grafana 指标命名约定 - -沿用现有 [`observability-conventions.md`](./observability-conventions.md) 不再重复,关键约束: - -- OTel semconv 优先(`http_*` / `db_*` / `gen_ai_*`),匹配不上才放 `airi.*` 命名空间 -- counter 一律 `_total` 后缀,histogram 一律 `_seconds_bucket` / `_bytes_bucket` -- label 基数受控(route pattern 而非 URL,model name 而非 prompt) - -## 当前指标归属总表 - -### Grafana / Prometheus(系统侧) - -来源:`apps/server/src/otel/index.ts` 全量列表见 [`observability-metrics.md`](./observability-metrics.md)。Dashboard 配置在 [`apps/server/otel/grafana/dashboards/build.ts`](../../otel/grafana/dashboards/build.ts)。 - -| 域 | 代表性指标 | Truth | 备注 | -|---|---|---|---| -| HTTP | `http_server_request_duration_seconds_*` | Grafana | OTel 标准 | -| WS | `ws_users_online` / `ws_connections_active` / `ws_messages_*_total` | Grafana | `ws_users_online` 是 Redis Pub/Sub channel 去重后的集群在线用户数;连接数仍用于排查多标签页和连接泄漏 | -| LLM | `gen_ai_client_operation_count_total` / `gen_ai_client_first_token_duration_seconds` | Grafana | | -| Billing | `airi_billing_flux_unbilled_total` | Grafana | **告警必须**:`increase(airi_billing_flux_unbilled_total[5m]) > 0` | -| Auth | `user_active_sessions` / `user_distinct_active` | Postgres → Grafana 派生 | 集群级 gauge,用 `avg()` 不要 `sum()`。两个一起看:`user_active_sessions` = `COUNT(*)`(session row 数,会膨胀), `user_distinct_active` = `COUNT(DISTINCT user_id)`(真实活跃用户数)| -| Stripe | `airi_stripe_revenue_minor_unit_total` / `stripe_events_total` | Postgres → 两边展示 | Grafana 是系统侧 webhook 计数 | -| Runtime | `v8js_memory_*` / `nodejs_eventloop_delay_*` | Grafana | per `service_instance_id` | -| Rate-limit | `airi_rate_limit_blocked_total` | Grafana | in-memory per replica | - -### PostHog(前端 / 外部数据源,产品侧) - -已接入: -- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 动态 adapter 初始化;web / desktop / pocket / auth / docs 共用一个 project key,以 `app_surface` 区分运行端。 -- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHog;LLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。 -- 前端 identity:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()` -- 平台统一写入 `app_surface`;`entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property。 - -已埋点: - -| 域 | 事件 | 来源 | 落点 | Truth | -|---|---|---|---|---| -| 付费漏斗 | `pricing_page_viewed` / `plan_selected` / `checkout_started` | 前端 | `packages/stage-pages/src/pages/settings/flux.vue` | PostHog | -| 付费漏斗终点 | `payment_completed` | 后端 webhook | `product_events` + Grafana,并 best-effort 转发 PostHog | Postgres | -| Activation / Retention | `first_model_selected` / `model_switched` | 前端(consciousness store watcher) | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog | -| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog | -| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog | -| Conversation controls | `chat_session_selected` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog | -| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog | -| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog | -| Churn | `subscription_cancelled`(带 cancellation_reason) | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres | -| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog | -| 兼容指标 | `first_message_sent` | 前端 | 仍有生产者,仅供历史 dashboard;新激活口径使用 `chat_activation_succeeded` | PostHog | -| 聊天轮次 | `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 前端 core runtime | 以 `conversation_id` / `round_id` / `turn_index` 关联;成功和失败各有唯一终点事件 | PostHog | -| 注册 UI | `signup_form_completed` | auth SPA | 匿名表单完成信号,不计作注册事实 | PostHog | -| 注册事实 | `signup_completed` | Better Auth user create hook | 仅服务端生产,以 Better Auth user id 识别 | Postgres + PostHog | - -待埋点(API 已在 `use-analytics.ts` 暴露但调用点未接入): - -| 域 | 事件 | 状态 | -|---|---|---| -| Retention | `voice_mode_activated` | 需要先在 hearing store 加显式 `enableVoiceMode` action — 当前 hearing 没有单一"用户主动启用"那一刻的 trigger,被动监听 + 录音 action 不构成 user intent 信号 | -| Feature adoption | `flux_image_generated` | 等图片生成 feature 上线 | - -### 双展示指标(同名两边都有) - -| 指标 | Grafana | PostHog | Truth | 语义差异 | -|---|---|---|---|---| -| 活跃用户数 | `user_active_rolling` / `user_distinct_active` | DAU = 前端 journey 去重 distinctId | **Postgres/Grafana** for server truth | Grafana 是服务端可验证活跃,PostHog 是前端产品旅程 | -| Checkout 完成数 | `stripe_checkout_completed_total` + `product_events.payment_completed` | Stripe source connector / offline import | **Postgres** | Grafana 是 webhook 计数,PostHog 是产品漏斗展示 | -| LLM 请求 | `gen_ai_client_operation_count_total` | `chat_session_started` 等 | **Grafana**(系统计数) | PostHog 是用户维度切片,会少于 Grafana(PostHog 只覆盖 logged-in user) | - -## PostHog 接入路线图 - -落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实(注册、支付、订阅),per-request 路径仍只写 Postgres/Grafana。 - -### 阶段 1(P0 — 付费漏斗 + activation) - -所有运行端共用根目录 `posthog.config.ts`(单一 project key,`app_surface` super property 区分端)。初始化实况: - -```ts -import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config' - -// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24': -// SPA 路由切换自动发 $pageview / $pageleave,页面浏览不再手动埋。 -posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG }) -// 登录后(stage 端在 analytics store,auth 端在 profile.vue) -posthog.identify(user.id) -// 在 flux.vue -posthog.capture('pricing_page_viewed', { plan_period, source }) -``` - -`apps/stage-tamagotchi`(Electron renderer): - -```ts -// NOTICE: Electron CSP 下普通 import 会静默失效,必须用 full bundle。 -// 参考:https://posthog.com/tutorials/electron-analytics -import posthog from 'posthog-js/dist/module.full.no-external.js' - -posthog.init(import.meta.env.VITE_POSTHOG_KEY, { - api_host: 'https://t.airi.build', - autocapture: false, // 桌面应用没有传统 URL 路由,手动控制 -}) -``` - -埋点事件清单(P0): - -- 前端:`pricing_page_viewed`、`plan_selected`、`checkout_started`、`signup_form_completed`(ui-server-auth 匿名邮箱表单里程碑)、`first_model_selected` -- 后端:`product_events` 是事实账本;其中业务事实白名单(`signup_completed`、`payment_completed`、`subscription_started/renewed/cancelled`)由 product-events 服务经 posthog-node 转发一份到 PostHog(`apps/server/src/services/domain/product-events.ts`,distinctId = Better Auth user id)。LLM / TTS 等 per-request 事件不转发 - -PostHog UI 配两个 funnel: - -- **付费漏斗** (7d 窗口):`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发) -- **激活漏斗** (14d 窗口):`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed` - -### 阶段 2(P1 — retention / feature adoption / churn) - -埋点事件清单:`character_created`、`voice_mode_activated`、`chat_session_started`、`model_switched`、`flux_image_generated`、`subscription_cancelled`。 - -PostHog UI 配 cohort: - -- **D7 Retention by voice mode**:第一次 session 用了 `voice_mode_activated` 的用户 vs 没用的,看 D7/D30 retention 差异 -- **Churn 14d**:过去 14d 没有 `chat_session_started` 的付费用户,作为召回 cohort - -### Stripe → PostHog 集成路径 - -**不在 server webhook 里手动 capture**: - -| 路径 | 用途 | -|---|---| -| PostHog Stripe **source connector** | MRR / ARR / churn revenue dashboard(PostHog 原生 Revenue analytics) | -| 离线导入 `product_events.payment_completed`(可选) | 漏斗终点 event,跟前端 `checkout_started` 串联 | - -不要在 API 请求路径同步发 PostHog:PostHog 网络尾延迟会污染 auth / billing / chat route latency。需要 person-level funnel endpoint 时,用后台导入或 connector,不阻塞用户请求。 - -## 5xx Triage 路径 - -Dashboard 上 follow 这条 panel 链可以从"出事了"一路 drill 到"哪个 trace 是真凶": - -1. **panel-4 `5xx Rate %`**(Row 1)— 数字 / gauge 颜色变红,说明出事 -2. **panel-94 `Errors by Route`**(HTTP row)— "什么时候开始的、哪些 route / status 在失败" -3. **panel-91 `Warn / Error Logs`**(Logs row)— 实际 warn/error 消息,里面有 `trace_id` field 可点 → Tempo 看完整 trace 回放 - -### Tempo / Loki derived fields 配置(一次性) - -panel-91 的 `trace_id` 字段必须配 Grafana Cloud Loki datasource 的 **Derived fields** 才能跳 Tempo。这不在 dashboard JSON 范围内,是 datasource 级配置: - -- **Grafana Cloud** → Connections → Data sources → 选 `grafanacloud-projairi-logs`(Loki)→ Derived fields -- 添加: - - **Name**: `trace_id` - - **Type**: Regex in label or value - - **Regex**: `"trace_id":"([a-f0-9]+)"`(匹配我们 logger 的 JSON 输出) - - **URL**: 留空 - - **Internal link**: ✓,datasource 选 `grafanacloud-projairi-traces`(Tempo) -- 同样手法可加 `req` (request id) → 配 internal link 回 Loki 自身,按 requestId filter - -配置完之后日志面板里 `trace_id` 会变成蓝色可点,直接跳 Tempo waterfall。这一步配置只做一次,新加 panel 自动享有。 - -## Grafana Alert SOP - -Alert rules **不放在** `apps/server/otel/grafana/dashboards/build.ts` 里——Grafana Cloud 用 Unified Alerting,rule 在 Grafana UI 或 alerting API 管理,跟 dashboard JSON 解耦。这一节维护我们应该配的 alert rule,新加 rule 时同步更新这里。 - -### P0 — page on-call(PagerDuty / Slack on-call channel) - -| Alert | Query | Threshold | Notes | -|---|---|---|---| -| **Flux Unbilled leak** | `increase(airi_billing_flux_unbilled_total[5m])` | `> 0` for 5m | 收入直接漏;分 `reason` label 看是 `partial_debit_drained`(用户余额耗尽,预期)还是 `debit_failed`(DB / 真异常)。后者更急 | -| **5xx Rate spike** | `100 * sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count[5m]))` | `> 5%` for 10m | 跟 panel-4 阈值对齐 | -| **Email Failure spike** | `100 * sum(rate(airi_email_failures_total[5m])) / clamp_min(sum(rate(airi_email_send_total[5m])) + sum(rate(airi_email_failures_total[5m])), 1)` | `> 5%` for 10m | Resend / DNS / 黑名单挂了会阻塞注册流程 | - -### P1 — notify only(Slack ops channel,不分页) - -| Alert | Query | Threshold | Notes | -|---|---|---|---| -| **WS Connections cliff** | `sum(ws_connections_active)` | drop to 0 for 5m | 全断说明部署 / LB 异常 | -| **DB Pool exhaustion** | `max by (service_instance_id) (db_client_connection_count)` | `>= DB_POOL_MAX - 1` for 5m | 哪个 instance 满了 | -| **Heap > 85%** | `100 * sum by (service_instance_id) (v8js_memory_heap_used_bytes) / sum by (service_instance_id) (v8js_memory_heap_limit_bytes)` | `> 85%` for 15m | 内存泄漏前兆 | -| **Stripe webhook fail** | `increase(stripe_events_total{event_type="payment_intent.payment_failed"}[1h])` | `> 10` per hour | 支付链路问题 | - -### 配置入口 - -Grafana Cloud → Alerts & IRM → Alert rules → New alert rule。把上面 query 粘进 PromQL editor,threshold 按表设置,labels 加 `severity=p0|p1`,notification policy 按 severity 路由到 PagerDuty 或 Slack。 - -每加一条 alert,**更新这张表**——alert 没在文档里登记 = 不知道为什么 page、不知道 owner、不知道历史阈值改动。 - -## 何时打破规则 - -这份文档定的是**默认值**,不是法律。下列情况可以打破: - -- **系统指标也需要给 PM 看**(如 LLM provider 可用性影响产品决策)→ Grafana truth + 周期性 export 给 PostHog dashboard 展示 -- **产品指标需要分钟级告警**(如付费转化突然归零)→ Grafana alert 监 Stripe webhook 计数,PostHog truth 不变 -- **A/B test 影响系统指标**(如新 LLM router 影响延迟)→ feature flag 同时打到两边,Grafana panel 按 flag value 分线展示 - -打破规则的指标必须在 dashboard description 里说明,**不要静默打破**。 - -## 参考来源 - -业界没有权威 framework,下列来源是这份文档的依据: - -- [PostHog Product Metrics Handbook](https://posthog.com/handbook/product/metrics) — PostHog 自己的内部分层 -- [PostHog issue #43633](https://github.com/posthog/posthog/issues/43633) — dual-emit 问题的工程承认 -- [Honeycomb Observability 2.0](https://www.honeycomb.io/blog/time-to-version-observability-signs-point-to-yes) — "消除工具边界"的少数派立场 -- [Reforge: North Star Metrics](https://www.reforge.com/blog/north-star-metrics) — leading vs lagging 区分 -- [DEV: Metrics for 500 Engineers with Linear + Grafana + PostHog](https://dev.to/johalputt/how-to-set-up-developer-metrics-for-500-engineers-using-linear-20-grafana-110-and-posthog-30-3l73) — 与我们结构最接近的公开案例 -- [PostHog: Stripe payment platform](https://posthog.com/docs/revenue-analytics/payment-platforms/stripe) — Stripe 集成路径官方文档 -- [PostHog: Electron analytics](https://posthog.com/tutorials/electron-analytics) — Electron renderer 接入要点 -- [Google SRE Book: Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) — Four Golden Signals -- [Stripe: Essential SaaS Metrics](https://stripe.com/resources/more/essential-saas-metrics) — 收入侧指标定义 diff --git a/apps/server/docs/ai-context/observability-conventions.md b/apps/server/docs/ai-context/observability-conventions.md deleted file mode 100644 index 86e28c882..000000000 --- a/apps/server/docs/ai-context/observability-conventions.md +++ /dev/null @@ -1,293 +0,0 @@ -# Observability Conventions - -这份约定定义 AIRI 服务端新增 trace / metric attributes 时应该遵守的命名规则,目标是减少自定义前缀扩散,并让 Grafana / Tempo / Loki 查询尽量对齐 OpenTelemetry 语义约定。 - -## 总原则 - -- 能直接映射到 OpenTelemetry semantic conventions 的字段,优先使用标准字段。 -- 不能映射到标准字段、但确实属于 AIRI 业务语义的字段,统一放到 `airi.*` 命名空间下。 -- 不要新增新的顶级前缀,例如 `llm.*`、`gateway.*`、`telegram.*` 之类的 attribute key。 -- span name、event name、metric name 不等于 attribute key;是否迁移它们要单独评估兼容性。 -- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) 引用。 - -## 标准字段优先级 - -### GenAI - -优先使用: - -- `GEN_AI_ATTR_OPERATION_NAME` -- `GEN_AI_ATTR_REQUEST_MODEL` -- `GEN_AI_ATTR_USAGE_INPUT_TOKENS` -- `GEN_AI_ATTR_USAGE_OUTPUT_TOKENS` -- `SERVER_ATTR_ADDRESS` -- `SERVER_ATTR_PORT` - -适用场景: - -- chat completion -- embeddings -- 其他能明确归类到 GenAI 上游调用的请求 - -注意: - -- 当前 OpenTelemetry GenAI semantic conventions 仍处于 `Development` 状态,因此只在“语义明确匹配”时采用。 -- 没有明确标准归属的字段不要硬塞进 `gen_ai.*`。 - -### Database / Redis - -优先使用: - -- `db.system.name` -- `db.operation.name` -- `db.namespace` -- `db.query.text` -- `db.response.status_code` -- `server.address` -- `server.port` - -Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重复造一套并行命名。 - -## AIRI 自定义字段 - -以下场景使用 `airi.*`: - -- 计费或余额语义 -- 仅 AIRI 内部存在的流式控制字段 -- 临时调试但仍需要进入可观测系统的业务字段 - -当前 attribute 示例: - -- `AIRI_ATTR_BILLING_FLUX_CONSUMED` -- `AIRI_ATTR_GEN_AI_STREAM` -- `AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED` -- `AIRI_ATTR_GEN_AI_OPERATION_KIND` -- `AIRI_ATTR_GEN_AI_INPUT_MESSAGES` -- `AIRI_ATTR_GEN_AI_INPUT_TEXT` -- `AIRI_ATTR_GEN_AI_OUTPUT_TEXT` - -当前 `airi.*` metric 命名空间(Prom 系列名见 [`observability-metrics.md`](./observability-metrics.md)): - -- 计费:`airi.billing.flux.consumed` / `.credited` / `.unbilled` / `.tts.chars` / `.tts.preflight_rejections` -- 收入:`airi.stripe.revenue` -- 邮件:`airi.email.send` / `.failures` / `.duration` -- 限流:`airi.rate_limit.blocked` -- GenAI:`airi.gen_ai.stream.interrupted` - -## Metric Name 策略 - -`apps/server` 的 LLM gateway metric 现在全部用标准 `gen_ai.client.*` semconv 名 + AIRI `airi.billing.*` 计费名。旧的 `llm.request.*` / `llm.tokens.*` / `flux.consumed` 字面名都已经迁移完,请**不要在新代码或 reviewer 建议里复活**它们 —— 代码里 const 命名(如 `METRIC_FLUX_CONSUMED`)保留是历史 identifier,对应的字面值已经是 `airi.billing.flux.consumed`,以字面值为准。 - -新增或重命名 metric 时遵守: - -- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。**先确认 dashboard / alerts 是否在跑这条 series**,再决定是否重命名。 -- 重命名一定要走兼容迁移:先双发新旧两条 series,留出窗口给消费方切换,再删旧的;不要在普通功能改动里直接重命名。 -- 完整 metric 清单(含 Prometheus 系列名)维护在 [`observability-metrics.md`](./observability-metrics.md)。新增任何 metric 都要同步更新那份文档。 - -## Grafana / Prometheus 查询策略 - -面板和告警查询优先依赖 metric labels,对齐我们已经统一的 attributes。 - -### GenAI 面板应该查什么 - -优先使用这些 Prometheus label: - -- `gen_ai_request_model` -- `gen_ai_operation_name` -- `airi_gen_ai_operation_kind` -- `http_response_status_code` - -说明: - -- Prometheus 暴露时会把 attribute key 里的 `.` 转成 `_`,所以 `gen_ai.request.model` 会变成 `gen_ai_request_model`。 -- `gen_ai_operation_name` 适合 chat、embeddings 这类有明确 semconv 的操作。 -- `airi_gen_ai_operation_kind` 适合当前没有明确 semconv 的 AIRI 自定义操作类型,例如 `tts`、`asr`。 - -### 不再新增使用的旧查询维度 - -新增 dashboard、录制规则、告警时,不要再新增依赖这些旧 label: - -- `model` -- `type` - -旧面板可以渐进迁移,不要求一次性全部替换,但新改动必须直接使用新标签。 - -### 当前已落地的 dashboard 例子 - -[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询: - -- Request rate by model: `gen_ai_request_model` -- Request rate by operation: `gen_ai_operation_name` + `airi_gen_ai_operation_kind` -- Latency by model: `gen_ai_request_model` -- Flux consumed by model: `gen_ai_request_model` -- Token throughput by model: `gen_ai_request_model` - -如果未来新增本地 dashboard 或新的 cloud dashboard,默认按这一套 label 维度来。 - -## Span Name 策略 - -span name 目前允许保留业务可读格式,例如: - -- `llm.gateway.chat` -- `llm.gateway.tts` -- `llm.gateway.asr` - -原因: - -- span name 主要服务于人工浏览和局部检索。 -- 语义筛选应优先依赖 attributes,而不是依赖 span name 文本。 - -如果未来统一 span name,也应保证查询主要依赖 `gen_ai.*` / `db.*` / `airi.*` attributes。 - -## 修改前检查 - -新增 observability 字段前,先问自己: - -1. 这个字段能否映射到已有 OTel semconv? -2. 如果不能,它是否明确属于 AIRI 业务语义? -3. 如果属于 AIRI,是否应该挂到 `airi.*`,而不是新造顶级前缀? -4. 我改的是 attribute key 还是 metric name / span name? -5. 如果是 metric name,是否已经评估 Prometheus / Grafana / alerting 兼容性? -6. 如果要改 dashboard,我是否优先用了 `gen_ai_request_model`、`gen_ai_operation_name`、`airi_gen_ai_operation_kind`,而不是旧的 `model` / `type`? - -## 当前参考实现 - -- [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) -- [apps/server/src/routes/v1completions.ts](/apps/server/src/routes/v1completions.ts) -- [apps/server/src/otel/index.ts](/apps/server/src/otel/index.ts) -- [services/telegram-bot/src/llm/actions.ts](/services/telegram-bot/src/llm/actions.ts) -- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts) - -## SemconvStability 迁移说明 - -`@opentelemetry/instrumentation-http` 0.215+ 默认 OLD semconv(`http.server.duration` in ms),不是 STABLE 名。AIRI 在 [apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 顶部强制 `OTEL_SEMCONV_STABILITY_OPT_IN=http`(仅 STABLE)。 - -| Semconv 模式 | 发哪些 series | 我们用 | -|---|---|---| -| OLD(默认)| `http.server.duration` (ms)、`http.client.duration` (ms)、attr 用 `http.method` / `http.status_code` | ❌ | -| STABLE(`=http`)| `http.server.request.duration` (s)、`http.client.request.duration` (s)、attr 用 `http.request.method` / `http.response.status_code` | ✅ | -| 双发(`=http/dup`)| 上面两套都发 | 仅在有外部 OLD-name 消费者待迁移时启用 | - -**为什么直接 STABLE-only**: - -- grep 整仓库零 OLD-name 引用 -- Dashboard 与服务代码 checked in 在一起,无外部 dashboard -- 迁移没有自然终点,OLD 系列不显式清理就一直占 storage -- 双发会让每条 HTTP 请求 cardinality 翻倍 - -**何时切回 `dup`**:将来如果有别的 service 主动 scrape 本 server 的 OLD-name 系列,临时切几周完成迁移即可。 - -## Multi-Replica 注意事项 - -服务跑在 Railway 上有 ≥2 个副本(见 [workers-and-runtime.md](./workers-and-runtime.md)),所有 metric 设计必须显式考虑跨副本聚合。 - -### `service.instance.id` 必须设 - -[apps/server/instrumentation.ts](/apps/server/instrumentation.ts) 在 resource 上注入 `service.instance.id`,按优先级取 `RAILWAY_REPLICA_ID` → `SERVER_INSTANCE_ID` → `randomUUID()`(带 warn 日志,提示 ops 系列会随重启 churn)。`HOSTNAME` 曾经在 fallback 链里但 Railway 没文档化它是否 per-replica 唯一,所以踢出去了;需要跨重启稳定时显式设 `SERVER_INSTANCE_ID`。 - -**没设的后果**:两个副本的所有 metric series label tuple 完全一致(`service_name` + `deployment_environment` 一样),Prometheus 收到时按规则丢一条 / collapse 系列,结果一个副本完全消失。 - -加新 metric 时不用做任何事——只要从 `meter` 创建出来,instance id 自动随 resource 一起带上。 - -### 按 instrument 类型的副本安全表 - -| 类型 | 副本安全? | 聚合方式 | 备注 | -|---|---|---|---| -| `Counter` | ✅ | `sum(rate(x[5m]))` | 每副本本地累加,`rate()` 自动处理重启 | -| `Histogram` | ✅ | `histogram_quantile(0.95, sum by (le, ...) (rate(x_bucket[5m])))` | 每副本本地 bucket,`sum by (le)` 合并 | -| `ObservableGauge`(**per-replica 状态**,如 `ws.connections.active`) | ✅ | `sum(x)` | callback 读本地 registry,所有副本求和 = 集群总量 | -| `ObservableGauge`(**cluster-wide 状态**,如 `user.active_sessions` / `ws.users.online`) | ⚠️ | `max(x)` 或 `avg(x)` | 所有副本读同一份外部状态(DB / Redis),sum 会乘以副本数 | -| `UpDownCounter` | ⚠️ | 看场景 | 必须保证 `+1` 和 `-1` 在**同一副本**触发;否则单副本永久 +N 另一副本永久 -N | - -### `UpDownCounter` 红线 - -只在以下情况用: -- `+1` 和对应的 `-1` 都在**同一请求生命周期**或**同一进程的局部状态机**里发生(典型:`http.server.active_requests` —— 请求开始 +1,结束 -1,必在同一副本) -- 不依赖任何外部 TTL / GC / 异步过期 - -如果存在「TTL 自然过期」「跨实例资源转移」「依赖 webhook 异步触发 -1」之类的情况,**不要用 UpDownCounter**。改用: -- `ObservableGauge` 从权威存储(DB / Redis)按 callback 读真实值,dashboard 用 `max()` / `avg()` 聚合 -- 或者只保留对应的 `Counter`("created" + "deleted"),让 dashboard 自己算差值 - -历史教训:`user.active_sessions` 最早是 UpDownCounter,登录 +1 / 登出 -1。但 Better Auth 的 session TTL 过期不会调 delete hook,counter 单实例就漂;多副本登录在 A、登出在 B 直接撕裂。改成 `ObservableGauge` 后由 [apps/server/src/app.ts](/apps/server/src/app.ts) 的 `registerActiveSessionsGauge` 通过 `SELECT COUNT(*) FROM session WHERE expires_at > NOW()` 在 scrape 时按需查 DB,带 10s 内存缓存避免 hammer。 - -三个 DB-backed user gauge 的语义区分(都是 cluster-wide,dashboard 用 `max()` / `avg()`): - -| Metric | 含义 | 来源 | 注册位置 | -|---|---|---|---| -| `user.active_sessions` | 当前未过期的 session **行数**(含 OIDC token 刷新产生的行,会膨胀) | `COUNT(*) FROM session WHERE expires_at > now()` | `registerActiveSessionsGauge` | -| `user.distinct_active` | 当前持有 ≥1 个未过期 session 的**去重用户数**("此刻在线") | `COUNT(DISTINCT user_id) FROM session WHERE expires_at > now()` | `registerDistinctActiveUsersGauge` | -| `user.active_rolling` | 滚动窗口去重活跃用户 DAU/WAU/MAU("近 N 天回来过"),按 `window="24h"\|"7d"\|"30d"` 打 label | `COUNT(*) FILTER (WHERE last_seen_at > now()-window) FROM user` | `registerRollingActiveUsersGauge` | - -`user.active_rolling` 用 `user.last_seen_at`(登录 + 每次 OIDC token 刷新约每小时 touch 一次,是 per-user 的「最后活跃」时间戳),不依赖 session 是否过期,所以能回答「本周回来过多少人」。一次 query 用三个 `FILTER` 把三个窗口算完,缓存 60s(窗口变化慢且要全表扫 `user`,TTL 比 session gauge 长)。 - -### Dashboard 查询模板 - -加新 panel 时按这个清单核对: - -| 数据语义 | PromQL 模板 | -|---|---| -| 业务事件速率(Counter) | `sum(rate(x_total{...}[$__rate_interval]))` | -| 按 label 切分速率 | `sum by (