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