Commit 2472b4e0aef5a8bb8b7c224bbc969ec9f301421a

Authored by yibo
1 parent dcf597fe

chore: 迁入 shadiao-2d 的工程配套(schema 门禁、本地管理员、域名部署、文档与运行手册)

- tools/scripts:schema-check / schema-update(CI 加 schema:check),voko-dev-user(本地管理员 upsert)
- infra/domains:GoDaddy DNS + Nginx + 证书部署工具原样迁入;voko-alpha.json 为草案(主机未定,不执行)
- docs:database.md、local-development.md、runbooks 索引与骨架验收手册;AGENTS.md 指向运行手册
- ecosystem.config.cjs 采用 shadiao-2d 的 PM2 进程默认参数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011X363N3XYVt41WUtnjtDZH
.gitlab-ci.yml
... ... @@ -19,3 +19,4 @@ check:
19 19 script:
20 20 - pnpm lint
21 21 - pnpm test
  22 + - pnpm schema:check
... ...
AGENTS.md
... ... @@ -12,16 +12,17 @@ VOKO Agent 世界(voko-world)。pnpm 11 + Turbo monorepo,Node &gt;=24,ESM J
12 12 | @voko/worker | 定时任务与 Webhook 投递(与 server 同目录,独立进程) | — |
13 13 | @voko/web | 员工端与 `/admin` 后台,`/api` → 9201 | 5201 |
14 14  
15   -(以上 app 在 W1 初始化步骤 3、4 建立。)
  15 +
16 16  
17 17 ## 命令
18 18  
19 19 - `pnpm install`;`pnpm mysql:up` 启动本地 MySQL 并确保 `voko` 库存在(与 shadiao-2d 共用同一个本地 MySQL 服务也可以,库名不同)。
20   -- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test(schema:check 在建立 voko-server 后加入)。
  20 +- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test → schema:check。
21 21  
22 22 ## 文档入口
23 23  
24 24 - 产品、设计、计划:`docs/voko/README.md`。
  25 +- 执行启动、数据库、验收或部署任务时,先从[运行目标索引](docs/runbooks/README.md)进入对应手册。
25 26 - 新增或修改可执行流程时,必须新增或更新运行手册(`docs/runbooks/`)并登记到索引。只增加脚本或 `package.json` 命令不算完成。
26 27  
27 28 ## 边界(ESLint 强制)
... ...
README.md
... ... @@ -14,16 +14,8 @@ pnpm lint &amp;&amp; pnpm test
14 14  
15 15 开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。
16 16  
17   -## 第一次跑起来(骨架)
  17 +## 文档
18 18  
19   -```bash
20   -cp apps/voko-server/.env.example apps/voko-server/.env # 填 SMTP;DATABASE_URL 留空即用本地 voko 库
21   -pnpm mysql:up
22   -pnpm --filter @voko/server db:init # 只对空库执行
23   -pnpm --filter @voko/server dev # API: http://localhost:9201/healthz
24   -pnpm --filter @voko/web dev # 前端: http://localhost:5201
25   -```
26   -
27   -1. 在前端注册一个账号,再执行 `pnpm --filter @voko/server admin:grant 你的邮箱` 把它设为管理员。
28   -2. 用另一个邮箱注册并提交加入申请,用管理员账号在 `/admin` 审核。
29   -3. 真实链路验收:`VOKO_TEST_EMAIL=... VOKO_ADMIN_EMAIL=... VOKO_ADMIN_PASSWORD=... pnpm --filter @voko/server verify:skeleton`
  19 +- 本地开发:`docs/local-development.md`
  20 +- 数据库:`docs/database.md`
  21 +- 运行手册索引:`docs/runbooks/README.md`
... ...
docs/database.md 0 → 100644
  1 +# 数据库结构与迁移
  2 +
  3 +VOKO 使用独立 MySQL 8.4 库(本地默认 `voko`),不连接 shadiao-2d 的 `idearun` / `idearun_visual_game` / `visual_game_stage`。时间列 `DATETIME(3)` 存 UTC,列名与字段统一 snake_case。
  4 +
  5 +## 结构事实源
  6 +
  7 +- `packages/*/src/db/tables/*.cjs`:当前完整结构,`apps/voko-server/src/db/schema.js` 汇总为 `vokoTargetTables`。
  8 +- `apps/voko-server/src/db/migrations/*.js`:已存在的库如何从一个版本变到下一个版本。历史表 `voko_schema_migrations`。
  9 +- 服务启动不执行 DDL。改表必须同时改 `tables/*.cjs` 和新增一个 migration。
  10 +
  11 +## 命令
  12 +
  13 +| 目的 | 命令 | 只能用于 |
  14 +| --- | --- | --- |
  15 +| 新空库建表 | `pnpm --filter @voko/server db:init` | 确认没有任何表的空库 |
  16 +| 执行未执行的 migration | `pnpm --filter @voko/server db:migrate` | 已 init 的库 |
  17 +| 查看状态 | `pnpm --filter @voko/server db:status` | 已 init 的库 |
  18 +
  19 +三个命令都读取 `apps/voko-server/.env` 的 `DATABASE_URL`,或用 `--env <文件>` 指定;都未设置时连本地开发库 `voko`。
  20 +
  21 +## 纪律
  22 +
  23 +- migration 只在合入主干之后,才允许在 stage / 生产等共享库执行;开发只连本地库。
  24 +- 唯一性、非空、状态与字段组合一律写成 DB 约束(UNIQUE / NOT NULL / CHECK),不在应用层先查再写。
  25 +- 禁止对共享库执行 init;禁止在共享库上手工改结构。
... ...
docs/local-development.md 0 → 100644
  1 +# 本地开发
  2 +
  3 +| 进程 | 地址 |
  4 +| --- | --- |
  5 +| voko-server(API) | http://localhost:9201,健康检查 `/healthz` |
  6 +| voko-worker | 无端口,日志里每分钟一次心跳 |
  7 +| voko-web(员工端 + `/admin`) | http://localhost:5201,`/api` 代理到 9201 |
  8 +
  9 +## 第一次启动
  10 +
  11 +```bash
  12 +nvm use && corepack enable && pnpm install
  13 +cp apps/voko-server/.env.example apps/voko-server/.env # 已有 .env 则跳过
  14 +pnpm mysql:up # 本地 MySQL,确保 voko 库存在
  15 +pnpm --filter @voko/server db:init # 只对空库
  16 +pnpm dev:user admin@example.test admin123 # 本地管理员(只用于本地库)
  17 +pnpm --filter @voko/server dev
  18 +pnpm --filter @voko/web dev
  19 +pnpm --filter @voko/server worker # 需要时
  20 +```
  21 +
  22 +本地 MySQL 可以与 shadiao-2d 共用同一个 Docker MySQL 服务(`pnpm mysql:up` 发现端口已开且口令可用时只确保 `voko` 库存在,不再起新容器)。
  23 +
  24 +## 配置来源
  25 +
  26 +`apps/voko-server/.env` 的 SMTP(service@voko.world)与大模型配置沿用 shadiao-2d visual-game-server 的同名变量;`AUTH_TOKEN_SECRET` 为 VOKO 单独生成,不与 Visual Game 共用;`DATABASE_URL` 留空即连本地 `voko`。
  27 +
  28 +## 本地管理员
  29 +
  30 +`pnpm dev:user <邮箱> <密码> [显示名] [admin|operator|user]` 对同邮箱 upsert,并重置密码与角色。只能用于本地开发库,禁止用于共享库。已在网页注册的账号可用 `pnpm --filter @voko/server admin:grant <邮箱>` 提升为管理员。
... ...
docs/runbooks/README.md 0 → 100644
  1 +# 运行目标索引
  2 +
  3 +本索引只负责把执行目标路由到一份运行手册。进入手册后使用其中的命令和完成判据,不从文件名猜流程。新增命令型流程时,必须建立独立运行手册并在这里加一条入口。
  4 +
  5 +## 本地开发
  6 +
  7 +- 启动、停止或排查本地应用:[本地开发](../local-development.md)。
  8 +- 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。
  9 +- 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md)。
  10 +
  11 +## 部署
  12 +
  13 +- 域名、Nginx 与证书:[域名部署](../../infra/domains/README.md)(`voko-alpha.json` 为草案,主机确认前不执行)。
... ...
docs/runbooks/verify-skeleton.md 0 → 100644
  1 +# 骨架真实链路验收
  2 +
  3 +验证注册、加入申请、运营审核、审核通知邮件、积分记账与幂等在真实 MySQL 与真实 SMTP 上可用。
  4 +
  5 +## 前提
  6 +
  7 +- voko-server 已启动,`apps/voko-server/.env` 配置了真实 SMTP。
  8 +- 一个能收信的邮箱 `VOKO_TEST_EMAIL`(脚本用 plus 地址注册新用户,例如 `you+voko-skeleton-…@gmail.com`)。
  9 +- 一个管理员账号:本地用 `pnpm dev:user`,共享环境用 `admin:grant`。
  10 +
  11 +## 执行
  12 +
  13 +```bash
  14 +VOKO_TEST_EMAIL=you@gmail.com \
  15 +VOKO_ADMIN_EMAIL=admin@example.test \
  16 +VOKO_ADMIN_PASSWORD=admin123 \
  17 +pnpm --filter @voko/server verify:skeleton
  18 +```
  19 +
  20 +## 完成判据
  21 +
  22 +- 输出 `"status": "PASS"`,15 项检查全部通过;测试邮箱收到验证码邮件与"邀请已就绪"邮件。
  23 +- 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。
  24 +- 脚本结束只删除本次创建的用户、申请、会话、验证码、邮件日志和积分流水。
... ...
ecosystem.config.cjs
1   -// PM2:voko-server 与 voko-worker 两个常驻进程。新增或调整后执行 `pm2 save`。
  1 +// PM2:本地常驻或主机部署时的进程定义。新增、删除或重新注册后执行 `pm2 save`。
  2 +const path = require('node:path')
  3 +
  4 +const repoRoot = __dirname
  5 +
  6 +const processDefaults = {
  7 + autorestart: true,
  8 + exec_mode: 'fork',
  9 + instances: 1,
  10 + kill_timeout: 10000,
  11 + max_restarts: 10,
  12 + merge_logs: true,
  13 + min_uptime: '10s',
  14 + restart_delay: 2000,
  15 + time: true,
  16 + watch: false,
  17 +}
  18 +
2 19 module.exports = {
3 20 apps: [
4   - { name: 'voko-server', cwd: 'apps/voko-server', script: 'src/main.js', interpreter: 'node' },
5   - { name: 'voko-worker', cwd: 'apps/voko-server', script: 'src/voko-worker-main.js', interpreter: 'node' },
  21 + {
  22 + ...processDefaults,
  23 + name: 'voko-server',
  24 + cwd: path.join(repoRoot, 'apps', 'voko-server'),
  25 + script: './src/main.js',
  26 + interpreter: 'node',
  27 + },
  28 + {
  29 + ...processDefaults,
  30 + name: 'voko-worker',
  31 + cwd: path.join(repoRoot, 'apps', 'voko-server'),
  32 + script: './src/voko-worker-main.js',
  33 + interpreter: 'node',
  34 + },
6 35 ],
7 36 }
... ...
infra/domains/README.md 0 → 100644
  1 +# 域名部署(从 shadiao-2d 搬入)
  2 +
  3 +> VOKO 说明:`environments/voko-alpha.json` 是**草案**,假设 Alpha 与 Visual Game Stage 共用 `52.33.173.48` 这台主机(独立目录 `/srv/voko-alpha`、独立 PM2 进程、独立数据库)。主机与数据库方案确认前不要执行。公开 API 域名 `api.voko.world`(`/v1` 前缀)需要在 `visual-game-nginx.js` 里增加对应 location,W5 部署前完成。
  4 +
  5 +## 原文
  6 +
  7 +
  8 +本目录负责把一组 Visual Game 域名部署到指定服务器。部署入口统一使用 Node.js,完成 GoDaddy
  9 +DNS、Nginx、Let's Encrypt 证书和真实 HTTPS 验证。
  10 +
  11 +## 目录结构
  12 +
  13 +```text
  14 +infra/domains/
  15 +├── .env # GoDaddy PAT,本地密钥,不提交
  16 +├── deploy.js # 统一部署入口
  17 +├── godaddy-dns.js # GoDaddy v3 DNS 写入
  18 +├── visual-game-nginx.js # Visual Game Nginx 配置生成
  19 +└── environments/
  20 + ├── visual-game-stage-voko.json
  21 + └── visual-game-stage-voko-standard.json
  22 +```
  23 +
  24 +环境差异只写在 `environments/*.json`。DNS、Nginx 和证书流程禁止复制到环境专用脚本。
  25 +
  26 +## 配置 GoDaddy 凭据
  27 +
  28 +打开 <https://developer.godaddy.com/personal-access-token>,创建 Personal Access Token:
  29 +
  30 +1. Name 填写 `visual-game-dns-deploy`。
  31 +2. Expiration 选择可接受的有效期。
  32 +3. Scopes 只选择 `domains.domain:read` 和 `domains.dns:update`。
  33 +4. Token 生成后立即复制;GoDaddy 不会再次显示该值。
  34 +
  35 +在 `infra/domains/.env` 写入:
  36 +
  37 +```dotenv
  38 +GODADDY_PAT=<完整 Token>
  39 +```
  40 +
  41 +执行:
  42 +
  43 +```bash
  44 +chmod 600 infra/domains/.env
  45 +```
  46 +
  47 +`.env` 已被仓库根 `.gitignore` 忽略。禁止把 Token 写进环境 JSON、服务器应用配置、命令参数或提交记录。
  48 +
  49 +## 部署 Stage Voko 域名
  50 +
  51 +在仓库根目录执行:
  52 +
  53 +```bash
  54 +node infra/domains/deploy.js infra/domains/environments/visual-game-stage-voko.json
  55 +```
  56 +
  57 +该环境配置对应:
  58 +
  59 +- 玩家端:`https://game.voko.world`
  60 +- Admin:`https://admin-game.voko.world`
  61 +- Studio:`https://studio-game.voko.world`
  62 +- 服务器:`52.33.173.48`
  63 +
  64 +Stage 规范域名使用独立环境配置:
  65 +
  66 +- 玩家端:`https://stage-game.voko.world`
  67 +- Admin:`https://admin-stage-game.voko.world`
  68 +- Studio:`https://studio-stage-game.voko.world`
  69 +
  70 +部署命令:
  71 +
  72 +```bash
  73 +node infra/domains/deploy.js infra/domains/environments/visual-game-stage-voko-standard.json
  74 +```
  75 +
  76 +脚本按以下顺序执行:
  77 +
  78 +1. GoDaddy A 记录与目标 IP 一致时保持不变;不一致时替换为环境配置中的 IP。
  79 +2. 在目标服务器安装仅支持 HTTP 和 ACME challenge 的 Nginx 配置。
  80 +3. 使用服务器现有 acme.sh 为三个域名签发同一张证书。
  81 +4. 把证书安装到环境配置指定的稳定目录,并登记 Nginx reload 命令。
  82 +5. 安装 HTTPS Nginx 配置并 reload Nginx。
  83 +6. 真实访问三个 HTTPS 首页和玩家端 `/healthz`;任一响应不是 200 时部署失败。
  84 +
  85 +脚本失败后直接退出,不回滚已经成功的 DNS 记录。修复错误后重新执行同一条命令;已经正确的 DNS
  86 +记录会显示为 `kept`,acme.sh 会继续使用已经登记的证书状态。
  87 +
  88 +## 新增其他环境
  89 +
  90 +复制现有环境配置:
  91 +
  92 +```bash
  93 +cp infra/domains/environments/visual-game-stage-voko.json \
  94 + infra/domains/environments/<新环境>.json
  95 +```
  96 +
  97 +必须修改以下配置:
  98 +
  99 +- `environment`:环境名称。
  100 +- `public_ip`:三个 A 记录指向的公网 IP。
  101 +- `ssh`:服务器地址、用户和本机 SSH 私钥。
  102 +- `application_root`:服务器当前 release 的稳定软链接。
  103 +- `backend_origin`:Nginx 代理的 Visual Game API 地址。
  104 +- `dns.zone`:GoDaddy 托管的根域名。
  105 +- `domains`:game、admin、studio 的完整域名、DNS record name 和前端 dist 目录。
  106 +- `nginx.site_name`:服务器上的独立 Nginx site 名称。
  107 +- `nginx.root_variable`:该 site 独占的 Nginx map 变量名。
  108 +- `nginx.certificate_directory`:该域名组独占的证书安装目录。
  109 +- `nginx.acme_webroot`:HTTP-01 challenge 目录。
  110 +
  111 +目标服务器必须已经安装并运行 Nginx、acme.sh 和 Visual Game,且公网 80、443 端口可访问。
  112 +域名必须使用 GoDaddy 权威 Nameserver,否则 GoDaddy API 中的记录不会参与公网解析。
  113 +
  114 +修改完成后执行:
  115 +
  116 +```bash
  117 +node infra/domains/deploy.js infra/domains/environments/<新环境>.json
  118 +```
  119 +
  120 +部署完成的判定标准是脚本输出三个 `HTTPS 200`、一个 `API 200` 和 `域名部署完成`。
  121 +
  122 +## 证书续签
  123 +
  124 +`deploy.js` 使用 acme.sh 的 `--install-cert` 保存证书安装路径和 `systemctl reload nginx`。服务器的
  125 +acme.sh 定时任务进入续签窗口后会重新签发证书、覆盖稳定路径中的 `key.pem` 与 `fullchain.pem`,随后
  126 +reload Nginx。Nginx 禁止直接读取 acme.sh 内部证书目录。
... ...
infra/domains/deploy.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +import fs from 'node:fs'
  3 +import os from 'node:os'
  4 +import path from 'node:path'
  5 +import { spawnSync } from 'node:child_process'
  6 +import { fileURLToPath } from 'node:url'
  7 +
  8 +import { setGodaddyARecord } from './godaddy-dns.js'
  9 +import { renderVisualGameHttpConfig, renderVisualGameHttpsConfig } from './visual-game-nginx.js'
  10 +
  11 +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
  12 +
  13 +const config = readDeploymentConfig(process.argv)
  14 +const pat = readGodaddyPat(config.dns.credential_file)
  15 +
  16 +for (const domain of config.domains) {
  17 + const result = await setGodaddyARecord({
  18 + pat,
  19 + zone: config.dns.zone,
  20 + name: domain.record_name,
  21 + address: config.public_ip,
  22 + ttl: config.dns.ttl,
  23 + })
  24 + console.log(`DNS ${result.action}: ${result.hostname} -> ${result.address}`)
  25 +}
  26 +
  27 +prepareRemoteDirectories(config)
  28 +installNginxConfig(config, renderVisualGameHttpConfig(config))
  29 +issueCertificate(config)
  30 +installNginxConfig(config, renderVisualGameHttpsConfig(config))
  31 +await verifyDeployment(config)
  32 +
  33 +console.log('域名部署完成')
  34 +for (const domain of config.domains) console.log(`${domain.role}: https://${domain.hostname}`)
  35 +
  36 +function readDeploymentConfig(argv) {
  37 + const configPath = path.resolve(repositoryRoot, argv[2])
  38 + return JSON.parse(fs.readFileSync(configPath, 'utf8'))
  39 +}
  40 +
  41 +function readGodaddyPat(credentialFile) {
  42 + const filePath = path.resolve(repositoryRoot, credentialFile)
  43 + const content = fs.readFileSync(filePath, 'utf8').trim()
  44 + return content.slice('GODADDY_PAT='.length)
  45 +}
  46 +
  47 +function prepareRemoteDirectories(deployment) {
  48 + runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.acme_webroot])
  49 + runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.certificate_directory])
  50 + if (deployment.nginx.layout === 'sites') {
  51 + runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-available'])
  52 + runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-enabled'])
  53 + }
  54 +}
  55 +
  56 +function installNginxConfig(deployment, content) {
  57 + const upload = `/tmp/${deployment.nginx.site_name}`
  58 + runRemote(deployment, 'tee', [upload], content)
  59 + installNginxSite(deployment, upload)
  60 + runRemote(deployment, 'sudo', ['nginx', '-t'])
  61 + runRemote(deployment, 'sudo', ['systemctl', 'reload', 'nginx'])
  62 + runRemote(deployment, 'rm', [upload])
  63 +}
  64 +
  65 +function installNginxSite(deployment, upload) {
  66 + switch (deployment.nginx.layout) {
  67 + case 'sites': {
  68 + const available = `/etc/nginx/sites-available/${deployment.nginx.site_name}`
  69 + const enabled = `/etc/nginx/sites-enabled/${deployment.nginx.site_name}`
  70 + runRemote(deployment, 'sudo', ['install', '-m', '644', upload, available])
  71 + runRemote(deployment, 'sudo', ['ln', '-sfn', available, enabled])
  72 + return
  73 + }
  74 + case 'conf.d':
  75 + runRemote(deployment, 'sudo', [
  76 + 'install',
  77 + '-m',
  78 + '644',
  79 + upload,
  80 + `/etc/nginx/conf.d/${deployment.nginx.site_name}.conf`,
  81 + ])
  82 + return
  83 + default:
  84 + throw new Error(`不支持的 nginx layout:${deployment.nginx.layout}`)
  85 + }
  86 +}
  87 +
  88 +function issueCertificate(deployment) {
  89 + const domains = []
  90 + for (const domain of deployment.domains) domains.push('-d', domain.hostname)
  91 + runRemote(deployment, 'sudo', [
  92 + '/root/.acme.sh/acme.sh',
  93 + '--issue',
  94 + '--server',
  95 + 'letsencrypt',
  96 + '--webroot',
  97 + deployment.nginx.acme_webroot,
  98 + ...domains,
  99 + ])
  100 + runRemote(deployment, 'sudo', [
  101 + '/root/.acme.sh/acme.sh',
  102 + '--install-cert',
  103 + '-d',
  104 + deployment.domains[0].hostname,
  105 + '--key-file',
  106 + `${deployment.nginx.certificate_directory}/key.pem`,
  107 + '--fullchain-file',
  108 + `${deployment.nginx.certificate_directory}/fullchain.pem`,
  109 + '--reloadcmd',
  110 + 'systemctl reload nginx',
  111 + ])
  112 +}
  113 +
  114 +async function verifyDeployment(deployment) {
  115 + for (const domain of deployment.domains) {
  116 + const response = await fetch(`https://${domain.hostname}/`, { redirect: 'manual' })
  117 + if (response.status !== 200) throw new Error(`${domain.hostname} 首页返回 ${response.status}`)
  118 + console.log(`HTTPS 200: ${domain.hostname}`)
  119 + }
  120 + const health = await fetch(`https://${deployment.domains[0].hostname}/healthz`)
  121 + if (health.status !== 200) throw new Error(`${deployment.domains[0].hostname}/healthz 返回 ${health.status}`)
  122 + console.log(`API 200: ${deployment.domains[0].hostname}/healthz`)
  123 +}
  124 +
  125 +function runRemote(deployment, command, args, input) {
  126 + const identityFile = expandHome(deployment.ssh.identity_file)
  127 + const destination = `${deployment.ssh.user}@${deployment.ssh.host}`
  128 + const remoteCommand = [command, ...args].map(quoteRemoteArgument).join(' ')
  129 + const result = spawnSync('ssh', [
  130 + '-i', identityFile,
  131 + '-o', 'IdentitiesOnly=yes',
  132 + '-o', 'StrictHostKeyChecking=yes',
  133 + destination,
  134 + remoteCommand,
  135 + ], {
  136 + cwd: repositoryRoot,
  137 + encoding: 'utf8',
  138 + input,
  139 + maxBuffer: 10 * 1024 * 1024,
  140 + })
  141 + if (result.status !== 0) {
  142 + throw new Error(`远程命令失败: ${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`)
  143 + }
  144 + return result.stdout.trim()
  145 +}
  146 +
  147 +function quoteRemoteArgument(value) {
  148 + return `'${value.replaceAll("'", "'\\''")}'`
  149 +}
  150 +
  151 +function expandHome(filePath) {
  152 + if (!filePath.startsWith('~/')) return filePath
  153 + return path.join(os.homedir(), filePath.slice(2))
  154 +}
... ...
infra/domains/environments/voko-alpha.json 0 → 100644
  1 +{
  2 + "environment": "voko-alpha",
  3 + "frontend_mode": "static",
  4 + "public_ip": "52.33.173.48",
  5 + "ssh": {
  6 + "host": "52.33.173.48",
  7 + "user": "ubuntu",
  8 + "identity_file": "~/.ssh/SGP.pem"
  9 + },
  10 + "application_root": "/srv/voko-alpha/current",
  11 + "backend_origin": "http://127.0.0.1:9201",
  12 + "dns": {
  13 + "zone": "voko.world",
  14 + "credential_file": "infra/domains/.env",
  15 + "ttl": 600
  16 + },
  17 + "domains": [
  18 + {
  19 + "role": "web",
  20 + "hostname": "alpha.voko.world",
  21 + "record_name": "alpha",
  22 + "frontend_directory": "apps/voko-web/dist"
  23 + }
  24 + ],
  25 + "nginx": {
  26 + "layout": "sites",
  27 + "site_name": "voko-alpha",
  28 + "root_variable": "voko_alpha_root",
  29 + "certificate_directory": "/etc/nginx/ssl/voko.world",
  30 + "acme_webroot": "/var/www/visual-game-acme"
  31 + }
  32 +}
... ...
infra/domains/godaddy-dns.js 0 → 100644
  1 +const GODADDY_DNS_API = 'https://api.godaddy.com/v3/domains/zones'
  2 +
  3 +export async function setGodaddyARecord({ pat, zone, name, address, ttl }) {
  4 + const records = await listARecords({ pat, zone, name })
  5 + if (records.length === 1 && records[0].data === address && records[0].ttl === ttl) {
  6 + return { hostname: `${name}.${zone}`, action: 'kept', address }
  7 + }
  8 +
  9 + for (const record of records) {
  10 + await godaddyRequest({
  11 + pat,
  12 + method: 'DELETE',
  13 + url: `${GODADDY_DNS_API}/${zone}/dns-records/${record.recordId}`,
  14 + body: null,
  15 + })
  16 + }
  17 +
  18 + await godaddyRequest({
  19 + pat,
  20 + method: 'POST',
  21 + url: `${GODADDY_DNS_API}/${zone}/dns-records`,
  22 + body: { type: 'A', name, data: address, ttl },
  23 + })
  24 +
  25 + let action = 'created'
  26 + if (records.length > 0) action = 'replaced'
  27 + return { hostname: `${name}.${zone}`, action, address }
  28 +}
  29 +
  30 +async function listARecords({ pat, zone, name }) {
  31 + const url = new URL(`${GODADDY_DNS_API}/${zone}/dns-records`)
  32 + url.searchParams.set('type', 'A')
  33 + url.searchParams.set('name', name)
  34 + url.searchParams.set('pageSize', '100')
  35 + const response = await godaddyRequest({ pat, method: 'GET', url: url.href, body: null })
  36 + if (response === null || typeof response !== 'object' || !Array.isArray(response.items)) {
  37 + throw new Error(`GoDaddy DNS 响应缺少 items: ${name}.${zone}`)
  38 + }
  39 + return response.items
  40 +}
  41 +
  42 +async function godaddyRequest({ pat, method, url, body }) {
  43 + const headers = { authorization: `Bearer ${pat}`, accept: 'application/json' }
  44 + const request = { method, headers }
  45 + if (body !== null) {
  46 + headers['content-type'] = 'application/json'
  47 + request.body = JSON.stringify(body)
  48 + }
  49 +
  50 + const response = await fetch(url, request)
  51 + const text = await response.text()
  52 + if (!response.ok) {
  53 + throw new Error(`GoDaddy ${method} ${url} 返回 ${response.status}: ${text}`)
  54 + }
  55 + if (text.length === 0) return null
  56 + return JSON.parse(text)
  57 +}
... ...
infra/domains/visual-game-nginx.js 0 → 100644
  1 +export function renderVisualGameHttpConfig(config) {
  2 + return `${renderFrontendMap(config)}
  3 +
  4 +server {
  5 + listen 80;
  6 + listen [::]:80;
  7 + server_name
  8 +${renderServerNames(config)};
  9 +
  10 + location ^~ /.well-known/acme-challenge/ {
  11 + root ${config.nginx.acme_webroot};
  12 + }
  13 +
  14 + location / {
  15 + return 301 https://$host$request_uri;
  16 + }
  17 +}
  18 +`
  19 +}
  20 +
  21 +export function renderVisualGameHttpsConfig(config) {
  22 + return `${renderFrontendMap(config)}
  23 +
  24 +server {
  25 + listen 80;
  26 + listen [::]:80;
  27 + server_name
  28 +${renderServerNames(config)};
  29 +
  30 + location ^~ /.well-known/acme-challenge/ {
  31 + root ${config.nginx.acme_webroot};
  32 + }
  33 +
  34 + location / {
  35 + return 301 https://$host$request_uri;
  36 + }
  37 +}
  38 +
  39 +server {
  40 + listen 443 ssl http2;
  41 + listen [::]:443 ssl http2;
  42 + server_name
  43 +${renderServerNames(config)};
  44 +
  45 + ssl_certificate ${config.nginx.certificate_directory}/fullchain.pem;
  46 + ssl_certificate_key ${config.nginx.certificate_directory}/key.pem;
  47 + ssl_protocols TLSv1.2 TLSv1.3;
  48 + ssl_session_cache shared:${config.nginx.root_variable}_ssl:10m;
  49 + ssl_session_timeout 1d;
  50 + ssl_session_tickets off;
  51 +
  52 + client_max_body_size 36m;
  53 +
  54 + location = /healthz {
  55 + proxy_pass ${config.backend_origin}/healthz;
  56 + proxy_http_version 1.1;
  57 + proxy_set_header Host $host;
  58 + proxy_set_header X-Real-IP $remote_addr;
  59 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  60 + proxy_set_header X-Forwarded-Proto $scheme;
  61 + }
  62 +
  63 + location ^~ /api/ {
  64 + proxy_pass ${config.backend_origin};
  65 + proxy_http_version 1.1;
  66 + proxy_set_header Host $host;
  67 + proxy_set_header X-Real-IP $remote_addr;
  68 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  69 + proxy_set_header X-Forwarded-Proto $scheme;
  70 + proxy_buffering off;
  71 + proxy_request_buffering off;
  72 + proxy_read_timeout 300s;
  73 + proxy_send_timeout 300s;
  74 + }
  75 +
  76 + location = /ws/asr {
  77 + proxy_pass ${config.backend_origin};
  78 + proxy_http_version 1.1;
  79 + proxy_set_header Upgrade $http_upgrade;
  80 + proxy_set_header Connection "upgrade";
  81 + proxy_set_header Host $host;
  82 + proxy_set_header X-Real-IP $remote_addr;
  83 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  84 + proxy_set_header X-Forwarded-Proto $scheme;
  85 + proxy_read_timeout 60s;
  86 + proxy_send_timeout 60s;
  87 + }
  88 +
  89 +${renderFrontendLocation(config)}
  90 +}
  91 +`
  92 +}
  93 +
  94 +function renderFrontendMap(config) {
  95 + const entries = config.domains.map((domain) => ` ${domain.hostname} ${frontendTarget(config, domain)};`)
  96 + return `map $host $${config.nginx.root_variable} {
  97 + hostnames;
  98 +${entries.join('\n')}
  99 +}`
  100 +}
  101 +
  102 +function frontendTarget(config, domain) {
  103 + switch (config.frontend_mode) {
  104 + case 'static':
  105 + return `${config.application_root}/${domain.frontend_directory}`
  106 + case 'proxy':
  107 + return domain.frontend_origin
  108 + default:
  109 + throw new Error(`不支持的 frontend_mode:${config.frontend_mode}`)
  110 + }
  111 +}
  112 +
  113 +function renderFrontendLocation(config) {
  114 + switch (config.frontend_mode) {
  115 + case 'static':
  116 + return ` root $${config.nginx.root_variable};
  117 + index index.html;
  118 +
  119 + location / {
  120 + try_files $uri $uri/ /index.html;
  121 + }`
  122 + case 'proxy':
  123 + return ` location / {
  124 + proxy_pass $${config.nginx.root_variable};
  125 + proxy_http_version 1.1;
  126 + proxy_set_header Host $host;
  127 + proxy_set_header X-Real-IP $remote_addr;
  128 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  129 + proxy_set_header X-Forwarded-Proto $scheme;
  130 + proxy_set_header Upgrade $http_upgrade;
  131 + proxy_set_header Connection "upgrade";
  132 + }`
  133 + default:
  134 + throw new Error(`不支持的 frontend_mode:${config.frontend_mode}`)
  135 + }
  136 +}
  137 +
  138 +function renderServerNames(config) {
  139 + return config.domains.map((domain) => ` ${domain.hostname}`).join('\n')
  140 +}
... ...
package.json
... ... @@ -16,7 +16,10 @@
16 16 "build": "turbo run build",
17 17 "test": "turbo run test",
18 18 "test:all": "vitest run",
19   - "lint": "eslint ."
  19 + "lint": "eslint .",
  20 + "schema:check": "node tools/scripts/schema-check.js",
  21 + "schema:update": "node tools/scripts/schema-update.js",
  22 + "dev:user": "node tools/scripts/voko-dev-user.js"
20 23 },
21 24 "devDependencies": {
22 25 "@drama/db": "workspace:*",
... ...
tools/scripts/schema-check.js 0 → 100644
  1 +/**
  2 + * Valibot schema 快照 CI gate(架构红线 §10.2「锁死数据契约」)。
  3 + *
  4 + * 行为三分支(对应任务 §8 测试要求):
  5 + * - contracts 缺席(无法 import `@drama/contracts` 或其无 `dumpSchemas`)→ 打印跳过日志,退出 0。
  6 + * (contracts 未就绪时不阻塞 Wave A 启动。)
  7 + * - 已提交快照与当前 `dumpSchemas()` 输出一致 → 退出 0。
  8 + * - 漂移 → 打印 diff 摘要,退出非 0。
  9 + *
  10 + * 本脚本不持有任何 schema 定义——快照与 `dumpSchemas` 由 `@drama/contracts` 提供。
  11 + * 可被单测复用:导出 `runSchemaCheck({ loadSchemas, readSnapshot, logger })`,
  12 + * CLI 入口在文件末尾用真实 loader 调用它。
  13 + *
  14 + * @typedef {object} SchemaCheckDeps
  15 + * @property {() => Promise<Record<string, unknown> | null>} loadSchemas 取当前 schema 快照对象;contracts 缺席返回 null。
  16 + * @property {() => string | null} readSnapshot 读已提交快照文本;不存在返回 null。
  17 + * @property {{ log: (msg: string) => void, error: (msg: string) => void }} [logger]
  18 + *
  19 + * @typedef {object} SchemaCheckResult
  20 + * @property {0 | 1} code 退出码。
  21 + * @property {'skipped' | 'match' | 'drift' | 'no-snapshot'} status
  22 + */
  23 +
  24 +import path from 'node:path'
  25 +import fs from 'node:fs'
  26 +import { fileURLToPath } from 'node:url'
  27 +
  28 +const here = path.dirname(fileURLToPath(import.meta.url))
  29 +const REPO_ROOT = path.resolve(here, '../..')
  30 +const SNAPSHOT_PATH = path.join(REPO_ROOT, 'packages/contracts/schema-snapshot.json')
  31 +
  32 +/**
  33 + * 纯函数核心:依赖全注入,便于单测三分支。
  34 + * @param {SchemaCheckDeps} deps
  35 + * @returns {Promise<SchemaCheckResult>}
  36 + */
  37 +export async function runSchemaCheck(deps) {
  38 + const logger = deps.logger || { log: console.log, error: console.error }
  39 +
  40 + const current = await deps.loadSchemas()
  41 + if (current == null) {
  42 + logger.log('[schema:check] @drama/contracts 未就位(缺 dumpSchemas),优雅跳过。')
  43 + return { code: 0, status: 'skipped' }
  44 + }
  45 +
  46 + const currentText = stableStringify(current)
  47 + const committed = deps.readSnapshot()
  48 +
  49 + if (committed == null) {
  50 + logger.log(
  51 + '[schema:check] 未找到已提交快照。首次请将 dumpSchemas() 输出写入 packages/contracts/schema-snapshot.json。',
  52 + )
  53 + return { code: 0, status: 'no-snapshot' }
  54 + }
  55 +
  56 + if (normalize(committed) === normalize(currentText)) {
  57 + logger.log('[schema:check] schema 快照一致,无漂移。')
  58 + return { code: 0, status: 'match' }
  59 + }
  60 +
  61 + logger.error('[schema:check] 检测到 schema 漂移:当前 dumpSchemas() 与已提交快照不一致。')
  62 + logger.error('[schema:check] 若为预期变更,请更新 packages/contracts/schema-snapshot.json 并提交。')
  63 + return { code: 1, status: 'drift' }
  64 +}
  65 +
  66 +/**
  67 + * 稳定序列化:键排序,保证快照对键顺序不敏感(避免伪漂移)。
  68 + * @param {unknown} value
  69 + * @returns {string}
  70 + */
  71 +export function stableStringify(value) {
  72 + return JSON.stringify(sortKeys(value), null, 2)
  73 +}
  74 +
  75 +/** @param {unknown} v @returns {unknown} */
  76 +function sortKeys(v) {
  77 + if (Array.isArray(v)) return v.map(sortKeys)
  78 + if (v && typeof v === 'object') {
  79 + /** @type {Record<string, unknown>} */
  80 + const out = {}
  81 + for (const k of Object.keys(/** @type {Record<string, unknown>} */ (v)).sort()) {
  82 + out[k] = sortKeys(/** @type {Record<string, unknown>} */ (v)[k])
  83 + }
  84 + return out
  85 + }
  86 + return v
  87 +}
  88 +
  89 +/** @param {string} text @returns {string} */
  90 +function normalize(text) {
  91 + return text.replace(/\r\n/g, '\n').trim()
  92 +}
  93 +
  94 +/**
  95 + * 真实 loader:尝试动态 import `@drama/contracts` 的 `dumpSchemas`。
  96 + * 任何解析失败(包未发布 / 无导出)都视为「缺席」,返回 null。
  97 + * @returns {Promise<Record<string, unknown> | null>}
  98 + */
  99 +async function loadContractsSchemas() {
  100 + try {
  101 + // @ts-ignore — @drama/contracts 是可选依赖:未就位时 import 抛错并走 catch 返 null(设计如此)。
  102 + const mod = await import('@drama/contracts')
  103 + if (typeof mod.dumpSchemas !== 'function') return null
  104 + return await mod.dumpSchemas()
  105 + } catch {
  106 + try {
  107 + const mod = await import(pathToFileUrl(path.join(REPO_ROOT, 'packages/contracts/src/index.js')))
  108 + if (typeof mod.dumpSchemas !== 'function') return null
  109 + return await mod.dumpSchemas()
  110 + } catch {
  111 + return null
  112 + }
  113 + }
  114 +}
  115 +
  116 +/**
  117 + * @param {string} filePath
  118 + * @returns {string}
  119 + */
  120 +function pathToFileUrl(filePath) {
  121 + return new URL(`file://${path.resolve(filePath)}`).href
  122 +}
  123 +
  124 +/** @returns {string | null} */
  125 +function readCommittedSnapshot() {
  126 + try {
  127 + return fs.readFileSync(SNAPSHOT_PATH, 'utf8')
  128 + } catch {
  129 + return null
  130 + }
  131 +}
  132 +
  133 +// CLI 入口:仅当作为脚本直接运行时执行(被 import 作单测时不触发)。
  134 +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  135 + runSchemaCheck({
  136 + loadSchemas: loadContractsSchemas,
  137 + readSnapshot: readCommittedSnapshot,
  138 + }).then((r) => {
  139 + process.exit(r.code)
  140 + })
  141 +}
... ...
tools/scripts/schema-update.js 0 → 100644
  1 +import fs from 'node:fs'
  2 +import path from 'node:path'
  3 +import { fileURLToPath, pathToFileURL } from 'node:url'
  4 +import { stableStringify } from './schema-check.js'
  5 +
  6 +const here = path.dirname(fileURLToPath(import.meta.url))
  7 +const repoRoot = path.resolve(here, '../..')
  8 +const snapshotPath = path.join(repoRoot, 'packages/contracts/schema-snapshot.json')
  9 +
  10 +async function main() {
  11 + const contracts = await import(pathToFileURL(path.join(repoRoot, 'packages/contracts/src/index.js')).href)
  12 + if (typeof contracts.dumpSchemas !== 'function') {
  13 + throw new Error('schema-update: packages/contracts does not export dumpSchemas')
  14 + }
  15 + fs.writeFileSync(snapshotPath, `${stableStringify(contracts.dumpSchemas())}\n`)
  16 + console.log(`[schema:update] wrote ${path.relative(repoRoot, snapshotPath)}`)
  17 +}
  18 +
  19 +main().catch((err) => {
  20 + console.error(err instanceof Error ? err.stack : err)
  21 + process.exit(1)
  22 +})
... ...
tools/scripts/voko-dev-user.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +import fs from 'node:fs'
  3 +import { randomBytes, randomUUID, scryptSync } from 'node:crypto'
  4 +import path from 'node:path'
  5 +import { createRequire } from 'node:module'
  6 +import { fileURLToPath } from 'node:url'
  7 +import { devMysqlUrl } from '@drama/db'
  8 +
  9 +const DEFAULT_DATABASE_URL = devMysqlUrl('voko')
  10 +const here = path.dirname(fileURLToPath(import.meta.url))
  11 +const repoRoot = path.resolve(here, '../..')
  12 +const envFile = path.resolve(repoRoot, 'apps/voko-server/.env')
  13 +const requireFromVokoServer = createRequire(new URL('../../apps/voko-server/package.json', import.meta.url))
  14 +const { createDb } = await import(requireFromVokoServer.resolve('@drama/db'))
  15 +
  16 +function hashPassword(password) {
  17 + const salt = randomBytes(16)
  18 + const derived = scryptSync(String(password), salt, 64)
  19 + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`
  20 +}
  21 +
  22 +async function main() {
  23 + const email = process.argv[2] || 'admin@example.test'
  24 + const password = process.argv[3] || 'admin123'
  25 + const displayName = process.argv[4] || 'VOKO Admin'
  26 + const role = process.argv[5] || 'admin'
  27 + if (!['admin', 'operator', 'user'].includes(role)) {
  28 + throw new Error(`voko dev user role 必须是 admin、operator 或 user: ${role}`)
  29 + }
  30 + const databaseUrl = process.env.DATABASE_URL || readDotenv(envFile).DATABASE_URL || DEFAULT_DATABASE_URL
  31 + const db = createDb({ url: databaseUrl, pool: { min: 0, max: 1 } })
  32 + const normalizedEmail = email.trim().toLowerCase()
  33 + const now = new Date()
  34 + const user = {
  35 + id: randomUUID(),
  36 + email: normalizedEmail,
  37 + display_name: displayName,
  38 + status: 'active',
  39 + password_hash: hashPassword(password),
  40 + locale: 'zh',
  41 + email_verified_at: now,
  42 + role,
  43 + created_at: now,
  44 + updated_at: now,
  45 + }
  46 +
  47 + try {
  48 + await db('users').insert(user).onConflict('email').merge({
  49 + display_name: user.display_name,
  50 + status: user.status,
  51 + password_hash: user.password_hash,
  52 + locale: user.locale,
  53 + email_verified_at: db.raw('COALESCE(email_verified_at, ?)', [now]),
  54 + role: user.role,
  55 + updated_at: now,
  56 + })
  57 + console.log(`voko dev user ready: ${normalizedEmail}, role=${role}`)
  58 + } finally {
  59 + await db.destroy()
  60 + }
  61 +}
  62 +
  63 +function readDotenv(file) {
  64 + if (!fs.existsSync(file)) return {}
  65 + const env = {}
  66 + for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
  67 + const line = raw.trim()
  68 + if (!line || line.startsWith('#')) continue
  69 + const separator = line.indexOf('=')
  70 + if (separator < 0) continue
  71 + const key = line.slice(0, separator).trim()
  72 + let value = line.slice(separator + 1).trim()
  73 + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  74 + value = value.slice(1, -1)
  75 + }
  76 + env[key] = value
  77 + }
  78 + return env
  79 +}
  80 +
  81 +main().catch(error => {
  82 + console.error(error)
  83 + process.exit(1)
  84 +})
... ...