diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93742df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# 构建上下文瘦身 + 防密钥泄漏(P5-prep Task 1)。 +# apps/mobile 是 Flutter 工程(4.5GB,含 build/ 产物),不参与服务端/前端镜像。 +apps/mobile +# 依赖与构建产物:镜像内一律重新 install + build(锁文件冻结,产物可复现)。 +**/node_modules +**/dist +**/coverage +**/.turbo +# 密钥:.env* 严禁进入镜像层(example 文件无密钥,但镜像也不需要)。 +.env +.env.* +!.env.example +!.env.production.example-cn +!.env.production.example-ov +# VCS / 文档 / 测试工件:镜像不需要。 +.git +.gitattributes +.gitignore +.dockerignore +docs +**/test-results +**/playwright-report +**/*.md +!apps/admin/SOURCE.md +!packages/embed-loader/README-embed.md diff --git a/.env.production.example-cn b/.env.production.example-cn new file mode 100644 index 0000000..8c04d0c --- /dev/null +++ b/.env.production.example-cn @@ -0,0 +1,68 @@ +# .env.production.example-cn —— 国内(cn)生产环境变量全量清单(P5-prep Task 1)。 +# 复制为 infra/deploy 同目录下的 .env.production-cn(与 docker-compose.cn.yml 同层, +# env_file 相对路径即此处),逐项填值。矿来源 = services/packages 源码 process.env +# 全量审计(测试专用的 ADMIN_* 仅 seed 用;IT_USERS/EMBED_POLL_* 为开发/前端项不列)。 +# 【安全】本文件只放占位符;真实文件不入 git、不进镜像(.dockerignore 拦截)。 + +# ── 数据库 / 缓存(1Panel 既有容器,见 infra/deploy/README-1Panel.md §2/§3)── +# host 用 1Panel 容器名(compose 挂 1panel-network)或宿主机 IP。 +# 库 photo_cn:utf8mb4;库级校对 utf8mb4_0900_bin(MySQL ≥8.0.30,低版本回退 +# utf8mb4_bin,见 D12——docs/plans/2026-09-17-P1a-后端核心链路.md)。账号/token +# 大小写敏感语义依赖它,勿改 _ci。connection_limit=10 是应用侧池上限约定。 +DATABASE_URL_CN=mysql://photo:CHANGE_ME@1Panel-mysql-容器名或IP:3306/photo_cn?connection_limit=10 +REDIS_URL_CN=redis://1Panel-redis-容器名或IP:6379 +# sync 进程双库直连:cn 侧容器还要能网络可达 ov 库(跨地域公网 + 白名单 cn 服务器 +# 出口 IP;生产建议 MySQL SSL 或隧道,P5 G 关口实测定案)。sync 进程(compose 服务 +# sync)读取;api-cn/worker 不读此键。 +DATABASE_URL_OV=mysql://photo_ov:CHANGE_ME@OV服务器IP:3306/photo_ov?connection_limit=10 + +# ── 对象存储(真实阿里云 OSS 私有桶,P5/G1 冒烟脚本验证后填)── +# endpoint 用「外网 Endpoint」(api-cn 服务器上传;worker 处理读原文件)。 +# bucket 私有读写:一切访问走 SigV4 现签 URL(SIGNED_URL_TTL_SECONDS)。 +S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +S3_REGION=cn-hangzhou +S3_ACCESS_KEY=CHANGE_ME_LTAI +S3_SECRET_KEY=CHANGE_ME +S3_BUCKET=photo-live-prod +# 现签 GET(viewer 图片)/ PUT(上传)凭证有效期秒(uploads/support.ts)。 +SIGNED_URL_TTL_SECONDS=900 +# 单文件上传上限字节(uploads/support.ts;50MB 与契约 UPLOAD_MAX_BYTES 建议值一致)。 +UPLOAD_MAX_BYTES=52428800 + +# ── 会话 / 域名 / 网络 ── +# 会话 TTL 秒(auth.service;30 天 = 2592000)。 +AUTH_SESSION_TTL_SECONDS=2592000 +# CORS 放行 origin 逗号清单(configure-app.ts)。生产 = viewer/admin 的 https 域名 +# 两形(有无 www);admin 与 viewer 同域走网关同源时可省。示例: +# CORS_ORIGINS=https://photo.example.cn,https://www.photo.example.cn +CORS_ORIGINS=https://CHANGE_ME_cn域名 +# api-cn 容器内监听端口(main-cn.ts;compose 内固定 8080,健康检查同端口)。 +PORT=8080 + +# ── 同步(cn→ov outbox + 对账 + 统计回传;服务间 HMAC 密钥两端一致)── +# SYNC_INTERNAL_KEY:x-sync-signature 的 HMAC-SHA256 密钥(internal-sync.ts)。 +# 强随机 ≥32 字节:openssl rand -hex 32 生成;cn 的 sync 进程与 ov 的 api-ov +# (inbox + emergency 通道)必须同值。泄漏 = 可注入内容/紧急停用,妥善保管。 +SYNC_INTERNAL_KEY=CHANGE_ME_openssl_rand_hex_32 +# sync 进程两翼 API 基址(不含路径;容器名/域名均可,走 https 时 OV_API_URL 用域名)。 +CN_API_URL=http://api-cn:8080 +OV_API_URL=https://CHANGE_ME_ov域名 +# 节奏参数(D11 建议初值,P5 实测冻结):批 100 条 / 推送 2s / 对账 5min / 回传 60s。 +SYNC_BATCH_SIZE=100 +SYNC_PUSH_INTERVAL_MS=2000 +SYNC_RECONCILE_INTERVAL_MS=300000 +STAT_RETURN_INTERVAL_MS=60000 + +# ── 用户源同步(user-sync 调度,仅 cn 装配;缺省见注释)── +# 定时同步间隔毫秒(缺省 300000=5min;user-sync.service.ts)。 +SYNC_USERS_INTERVAL_MS=300000 +# 每 N 批强制全量对账(游标复位;缺省 10)。 +RECONCILE_EVERY_N_RUNS=10 + +# ── 首次部署引导(seed-admin 角色;seed-admin.ts 读,生产可移除)── +ADMIN_ACCOUNT=admin +ADMIN_PASSWORD=CHANGE_ME_强密码 + +# ── 日志 ── +# pino 级别(logger-options/worker/sync;生产建议 info,排障临时 debug)。 +LOG_LEVEL=info diff --git a/.env.production.example-ov b/.env.production.example-ov new file mode 100644 index 0000000..edcded9 --- /dev/null +++ b/.env.production.example-ov @@ -0,0 +1,59 @@ +# .env.production.example-ov —— 海外(ov)生产环境变量全量清单(P5-prep Task 1)。 +# 复制为与 docker-compose-ov.yml 同目录的 .env.production-ov。ov 栈只跑 +# api-ov + web 两个容器(inbox/emergency 内嵌于 api-ov,sync 进程在 cn 侧—— +# 架构核实见 services/api/src/app-ov.module.ts 与 services/sync/src/main.ts)。 +# 【安全】只放占位符;真实文件不入 git、不进镜像。 + +# ── 数据库 / 缓存(OV 服务器上的 1Panel MySQL/Redis 容器)── +# 库 photo_ov:utf8mb4,库级校对 utf8mb4_0900_bin(MySQL ≥8.0.30,低版本回退 +# utf8mb4_bin,同 cn 的 D12 注意项;publicToken/对象键大小写语义依赖它)。 +# 注意:此库需放行 cn 服务器出口 IP 的 3306 访问(sync 进程直连取 +# outbox_stat_events/副本计数),生产建议 SSL/隧道 + 白名单最小化。 +DATABASE_URL_OV=mysql://photo_ov:CHANGE_ME@1Panel-mysql-容器名或IP:3306/photo_ov?connection_limit=10 +# ov 互动限速(likes/visits/downloads/embed)+ WS 广播合并用 Redis(ov-redis.module)。 +REDIS_URL_OV=redis://1Panel-redis-容器名或IP:6379 + +# ── 对象存储(与 cn 同一 OSS 私有桶:ov 只现签 GET 读 URL,无上传)── +# endpoint 用 OSS 「跨区域/外网」Endpoint,ov 服务器可达即可。 +S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +S3_REGION=cn-hangzhou +S3_ACCESS_KEY=CHANGE_ME_LTAI +S3_SECRET_KEY=CHANGE_ME +S3_BUCKET=photo-live-prod +# 现签 GET(公开照片 thumb/browse/banner、下载原图)凭证有效期秒。 +SIGNED_URL_TTL_SECONDS=900 + +# ── 域名 / 网络 ── +# CORS 放行 origin(ov viewer 域名两形;viewer 与 API 同域走网关时可省)。 +CORS_ORIGINS=https://CHANGE_ME_ov域名 +# api-ov 容器内监听端口(main-ov.ts 读 PORT_OV;compose 内固定 8081)。 +PORT_OV=8081 + +# ── 内部通道(inbox + emergency 共用 InternalSyncGuard 的 HMAC 密钥)── +# 与 cn 侧 SYNC_INTERNAL_KEY 同值(cn 侧 sync 发送/对账签名,ov 侧验签; +# emergency 紧急停用通道也用它——调用样例见 infra/deploy/RUNBOOK.md)。 +SYNC_INTERNAL_KEY=CHANGE_ME_openssl_rand_hex_32 + +# ── 公开读 / 互动参数(契约默认可 env 覆盖;D07/D10/D11 建议值)── +# 热门排序快照 TTL 毫秒(public-ov;缺省 30000)。 +HOT_SORT_TTL_MS=30000 +# 点赞:批次幂等窗口小时 / 单会话窗口累计 delta 上限 / 单 IP 窗口累计上限 / +# likes.burst 实时合并毫秒(0=不合并)。缺省 24 / 500 / 2000 / 500。 +LIKE_BATCH_WINDOW_HOURS=24 +LIKE_SESSION_WINDOW_DELTA=500 +LIKE_IP_WINDOW_DELTA=2000 +LIKE_BURST_MERGE_MS=500 +# 浏览:去重窗口分钟 / requestId 幂等保留分钟(≥窗口)/ 单 visitor+IP 每窗请求上限。 +VISIT_WINDOW_MINUTES=30 +VISIT_REQUEST_TTL_MINUTES=35 +VISIT_WINDOW_REQUESTS=60 +# 公开照片下载:单 IP 每固定窗(60s 常量 DOWNLOAD_WINDOW_SECONDS)请求上限。 +DOWNLOAD_WINDOW_REQUESTS=120 +# 嵌入组件公开端点限速:slotToken+IP 每固定窗请求上限 / 窗口秒(轮询 30s/次 + 重载突发)。 +EMBED_WINDOW_REQUESTS=120 +EMBED_WINDOW_SECONDS=60 +# 互动流水清理调度间隔分钟(0=停用;超窗口 like_batches/visit_* 行回收,累计总数永不清)。 +CLEANUP_INTERVAL_MINUTES=60 + +# ── 日志 ── +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore index 4e7e42c..7b39c41 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ blob-report/ .env .env.* !.env.example +# P5-prep 生产变量清单模板(占位符,无真实密钥;真实 .env.production-* 永不入库) +!.env.production.example-cn +!.env.production.example-ov *.log .DS_Store *.tsbuildinfo diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..df27dd2 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# docker/entrypoint.sh —— photo-live-server 单镜像多角色入口(P5-prep Task 1)。 +# 用法:docker run photo-live-server[:tag] [args] +# api-cn node services/api/dist/main-cn.js(国内 API:写路径 + 公开读) +# api-ov node services/api/dist/main-ov.js(海外 API:副本读 + 互动 + inbox) +# worker node services/worker/dist/main.js(sharp 变体流水线 + 发布事务) +# sync node services/sync/dist/main.js(outbox 发送 + 对账 + 统计回传,双库直连) +# migrate-deploy prisma migrate deploy(区域参数见下;一次性 docker run --rm 使用) +# seed-admin 首次部署引导:写入/确认 admin 账号(ADMIN_ACCOUNT/ADMIN_PASSWORD env) +# migrate-deploy 区域定位(cn 库 / ov 库是两份独立 schema,各自迁移): +# migrate-deploy cn | ov | all —— 显式参数优先,缺省读 MIGRATE_REGION env,再缺省 all。 +# cn 侧服务器通常只需 cn;但 cn 侧 sync 进程直连 ov 库,ov 库迁移由 ov 侧执行—— +# all 仅供演练环境(单机双库仿真)使用。 +set -e + +ROLE="${1:-api-cn}" +if [ "$#" -gt 0 ]; then shift; fi + +case "$ROLE" in + api-cn) + exec node services/api/dist/main-cn.js + ;; + api-ov) + exec node services/api/dist/main-ov.js + ;; + worker) + exec node services/worker/dist/main.js + ;; + sync) + exec node services/sync/dist/main.js + ;; + seed-admin) + exec node services/api/dist/scripts/seed-admin.js + ;; + migrate-deploy) + REGION="${1:-${MIGRATE_REGION:-all}}" + echo "[entrypoint] migrate-deploy region=${REGION}" + case "$REGION" in + cn|all) + echo "[entrypoint] prisma migrate deploy (db-cn: DATABASE_URL_CN)" + prisma migrate deploy --schema=packages/db-cn/prisma/schema.prisma + ;; + esac + case "$REGION" in + ov|all) + echo "[entrypoint] prisma migrate deploy (db-ov: DATABASE_URL_OV)" + prisma migrate deploy --schema=packages/db-ov/prisma/schema.prisma + ;; + esac + echo "[entrypoint] migrate-deploy done." + ;; + *) + echo "[entrypoint] unknown role: $ROLE" >&2 + echo "usage: entrypoint.sh {api-cn|api-ov|worker|sync|migrate-deploy [cn|ov|all]|seed-admin}" >&2 + exit 64 + ;; +esac diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile new file mode 100644 index 0000000..085fcc2 --- /dev/null +++ b/docker/server.Dockerfile @@ -0,0 +1,113 @@ +# syntax=docker/dockerfile:1 +# docker/server.Dockerfile —— photo-live-server 单镜像多角色(P5-prep Task 1)。 +# 决策(docs/plans/2026-09-21-P5-准备件.md Task 1):单镜像 photo-live-server, +# CMD 区分 api-cn / api-ov / worker / sync / migrate-deploy / seed-admin, +# 减少 1Panel 维护面(一个 tar 导入,多角色复用)。 +# +# 构建:docker build -f docker/server.Dockerfile -t photo-live-server: . +# = git short sha + 日期(见 infra/deploy/README-1Panel.md 版本约定)。 +# +# 阶段: +# build 全量 install(锁文件冻结)→ prisma generate 双库 → 逐包 tsc 构建 +# (显式依赖序代替 turbo:镜像内无需 turbo 二进制,链路更短更确定) +# prod-deps 仅生产依赖的 workspace install(--prod,供 runtime 复用清单与结构) +# runtime node:20-alpine + openssl(prisma musl 引擎依赖)/curl(healthcheck) +# + 全局 prisma@5.22.0(migrate-deploy 角色;版本对齐 pnpm-lock.yaml) +# + prisma generate 双库(db-cn 生成到 pnpm store 内 .prisma,db-ov +# 生成到包内 generated/——运行时本阶段重建,避免拷贝易碎的 store 深路径) +# +# sharp 在 alpine(musl) 的预编译二进制随 optionalDependencies 分发(0.33+ 无需 +# 编译);根 package.json pnpm.onlyBuiltDependencies 已放行其 install 脚本。若 +# 未来 sharp 版本在 musl 失败,回退方案:两阶段 base 换 node:20-slim(debian, +# glibc 预编译),无需其它改动。 + +# ── 阶段 1:构建 ── +FROM node:20-alpine AS build +WORKDIR /app +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +# 先拷全部 workspace 清单 → install 层缓存(源码变动不失效)。 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/admin/package.json apps/admin/ +COPY apps/viewer/package.json apps/viewer/ +COPY packages/contracts/package.json packages/contracts/ +COPY packages/db-cn/package.json packages/db-cn/ +COPY packages/db-ov/package.json packages/db-ov/ +COPY packages/embed-loader/package.json packages/embed-loader/ +COPY packages/storage/package.json packages/storage/ +COPY services/api/package.json services/api/ +COPY services/sync/package.json services/sync/ +COPY services/worker/package.json services/worker/ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile +# 源码(.dockerignore 已剔除 node_modules/dist/.env/apps/mobile/docs)。 +COPY packages packages +COPY services services +# prisma generate 双库:db-ov 的 src/index.ts 转出口 generated/client(gitignore +# 工件,必须先于 tsc);generate 不需要真实 DATABASE_URL_*,migrate deploy 才需要。 +RUN pnpm --filter @photo/db-cn --filter @photo/db-ov exec prisma generate +# 逐包构建(依赖序显式;服务端不需要构建 apps/viewer、apps/admin——那是 web 镜像的事)。 +RUN pnpm --filter @photo/contracts run build \ + && pnpm --filter @photo/db-cn run build \ + && pnpm --filter @photo/db-ov run build \ + && pnpm --filter @photo/storage run build \ + && pnpm --filter @photo/api-cn --filter @photo/worker --filter @photo/sync run build + +# ── 阶段 2:生产依赖 ── +FROM node:20-alpine AS prod-deps +WORKDIR /app +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/admin/package.json apps/admin/ +COPY apps/viewer/package.json apps/viewer/ +COPY packages/contracts/package.json packages/contracts/ +COPY packages/db-cn/package.json packages/db-cn/ +COPY packages/db-ov/package.json packages/db-ov/ +COPY packages/embed-loader/package.json packages/embed-loader/ +COPY packages/storage/package.json packages/storage/ +COPY services/api/package.json services/api/ +COPY services/sync/package.json services/sync/ +COPY services/worker/package.json services/worker/ +# --prod:剔除 jest/ts-node/playwright 等开发依赖;@prisma/client 走 optional +# 引擎依赖仍在(.prisma 生成产物由 runtime 阶段的 prisma generate 重建)。 +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --prod + +# ── 阶段 3:运行时 ── +FROM node:20-alpine +# openssl:prisma 查询引擎 musl 构建链接 openssl3;curl:compose healthcheck; +# tini 未装——compose 侧统一 init: true 承担 PID 1 信号转发。 +RUN apk add --no-cache openssl curl +# 全局 prisma CLI(migrate-deploy 角色)。版本必须与 pnpm-lock.yaml 的 +# prisma@5.22.0 一致,避免 schema/engine 行为漂移。npm 显式重试参数:构建机到 +# registry 偶发 ECONNRESET,npm 默认不重试会硬失败(pnpm 自带重试无此问题)。 +RUN --mount=type=cache,target=/root/.npm \ + npm install -g --fetch-retries=5 --fetch-retry-mintimeout=20000 \ + --fetch-retry-maxtimeout=120000 prisma@5.22.0 +ENV NODE_ENV=production +WORKDIR /app +# workspace 结构(清单 + 各包 node_modules 符号链接,pnpm 相对链接布局完整保留)。 +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-deps /app/packages ./packages +COPY --from=prod-deps /app/services ./services +# 构建产物。 +COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist +COPY --from=build /app/packages/db-cn/dist ./packages/db-cn/dist +COPY --from=build /app/packages/db-ov/dist ./packages/db-ov/dist +COPY --from=build /app/packages/storage/dist ./packages/storage/dist +COPY --from=build /app/services/api/dist ./services/api/dist +COPY --from=build /app/services/worker/dist ./services/worker/dist +COPY --from=build /app/services/sync/dist ./services/sync/dist +# prisma schema + 迁移(migrate-deploy 角色与 generate 都在包内 prisma/ 目录)。 +COPY packages/db-cn/prisma ./packages/db-cn/prisma +COPY packages/db-ov/prisma ./packages/db-ov/prisma +# 双库 client 生成:db-cn → pnpm store 内 .prisma/client;db-ov → 包内 generated/ +# (运行时镜像内重建,产物即最终布局;docker build 网络可下载 musl 引擎)。 +RUN prisma generate --schema=packages/db-cn/prisma/schema.prisma \ + && prisma generate --schema=packages/db-ov/prisma/schema.prisma +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# 缺省角色 api-cn;compose 用 command: 覆盖(api-ov/worker/sync/migrate-deploy)。 +CMD ["api-cn"] diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile new file mode 100644 index 0000000..d5edd76 --- /dev/null +++ b/docker/web.Dockerfile @@ -0,0 +1,66 @@ +# syntax=docker/dockerfile:1 +# docker/web.Dockerfile —— photo-live-web 前端网关镜像(P5-prep Task 1)。 +# nginx:alpine 承载 viewer(/,history 路由)+ admin(/admin/,hash 路由) +# + /v1 反代 API(API_UPSTREAM env,含 /v1/realtime WS 升级) +# + /embed/v1/ 与 /assets/ 内容寻址长缓存(见 infra/deploy/nginx.conf.template)。 +# +# 构建(VITE_* 为构建期变量——viewer/admin 的 API 基址在产物内固化): +# docker build -f docker/web.Dockerfile \ +# --build-arg VITE_VIEWER_BASE=https://viewer.example.com \ +# --build-arg VITE_EMBED_ORIGIN=https://viewer.example.com \ +# -t photo-live-web: . +# VITE_API_BASE 缺省空串(同源 /v1,经本镜像 nginx 反代)——cn/ov 两地域共用 +# 同一构建参数集,API 差异全部由运行时 API_UPSTREAM 承担。 + +# ── 阶段 1:构建 ── +FROM node:20-alpine AS build +WORKDIR /app +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/admin/package.json apps/admin/ +COPY apps/viewer/package.json apps/viewer/ +COPY packages/contracts/package.json packages/contracts/ +COPY packages/db-cn/package.json packages/db-cn/ +COPY packages/db-ov/package.json packages/db-ov/ +COPY packages/embed-loader/package.json packages/embed-loader/ +COPY packages/storage/package.json packages/storage/ +COPY services/api/package.json services/api/ +COPY services/sync/package.json services/sync/ +COPY services/worker/package.json services/worker/ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile +COPY packages packages +COPY apps apps +# 构建期 VITE 变量(进程 env 优先级高于 apps/*/.env* 文件): +# VITE_API_BASE viewer API 基址;空串=同源相对路径(生产网关形态,缺省)。 +# VITE_BASE_URL admin 资源前缀=/admin/(nginx 子路径挂载;hash 路由)。 +# VITE_VIEWER_BASE / VITE_EMBED_ORIGIN admin「分享卡/嵌入代码」拼 URL 用, +# 随真实域名构建时传入(缺省 dev 兜底仅作占位)。 +ARG VITE_API_BASE="" +ARG VITE_BASE_URL=/admin/ +ARG VITE_VERSION=0.1.0 +ARG VITE_VIEWER_BASE=http://127.0.0.1:5175 +ARG VITE_EMBED_ORIGIN=http://127.0.0.1:5175 +ENV VITE_API_BASE=${VITE_API_BASE} \ + VITE_BASE_URL=${VITE_BASE_URL} \ + VITE_VERSION=${VITE_VERSION} \ + VITE_VIEWER_BASE=${VITE_VIEWER_BASE} \ + VITE_EMBED_ORIGIN=${VITE_EMBED_ORIGIN} +# embed-loader 先构建并同步进 viewer publicDir(loader.js 内容寻址路径 /embed/v1/), +# 随后 viewer / admin 构建(contracts 是两者的类型/运行时依赖,先出 dist)。 +RUN pnpm --filter @photo/contracts run build \ + && pnpm --filter @photo/embed-loader run build \ + && pnpm --filter @photo/embed-loader run sync \ + && pnpm --filter @photo/viewer run build \ + && pnpm --filter @photo/admin run build + +# ── 阶段 2:nginx ── +FROM nginx:alpine +# 官方镜像启动时 envsubst 渲染 /etc/nginx/templates/*.template → conf.d; +# API_UPSTREAM 由 compose environment 注入(缺省指向 cn API 容器名)。 +ENV API_UPSTREAM=http://api-cn:8080 +COPY infra/deploy/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/apps/viewer/dist /usr/share/nginx/html/viewer +COPY --from=build /app/apps/admin/dist /usr/share/nginx/html/admin +EXPOSE 80 diff --git a/infra/deploy/README-1Panel.md b/infra/deploy/README-1Panel.md new file mode 100644 index 0000000..c2b458f --- /dev/null +++ b/infra/deploy/README-1Panel.md @@ -0,0 +1,155 @@ +# README-1Panel —— photo-live 生产部署指南(1Panel 单机双地域) + +> P5-prep 产物(无凭证前置批):本指南只描述操作步骤与占位值,不含任何真实密钥。 +> 目标形态:**cn 服务器**(1Panel:MySQL/Redis/OpenResty + photo-live-cn 编排) +> 与 **ov 服务器**(1Panel:MySQL/Redis/OpenResty + photo-live-ov 编排)。 +> 同步架构(已从代码核实):sync 独立进程**只跑 cn 侧**,双库直连 + 双 API 调用; +> ov 侧 inbox(内容接收)与 emergency(紧急停用)内嵌在 api-ov 进程内。 + +| 产物 | 说明 | +| --- | --- | +| `docker/server.Dockerfile` | 单镜像 `photo-live-server` 多角色:api-cn / api-ov / worker / sync / migrate-deploy / seed-admin | +| `docker/web.Dockerfile` | `photo-live-web`:nginx 承载 viewer(/)+admin(/admin/)+/v1 反代+静态长缓存 | +| `docker-compose.cn.yml` / `docker-compose-ov.yml` | 两地域编排(本目录) | +| `.env.production.example-cn` / `-ov` | 变量全量清单(仓库根,复制后填值) | +| `RUNBOOK.md` | 发布/迁移/回滚/紧急停用运行手册 | + +--- + +## 1. 镜像导入(两条路径任选) + +**版本约定**:tag = `git短sha-日期`(如 `12059d9-20260921`),同一 tag 双镜像配套, +回滚即换 tag(见 RUNBOOK.md)。 + +### 路径 A:本地构建 → tar 上传 → 1Panel 导入(无镜像仓库时) + +```bash +# 本地(仓库根)构建;tag 按上方约定替换 +docker build -f docker/server.Dockerfile -t photo-live-server:12059d9-20260921 . +docker build -f docker/web.Dockerfile \ + --build-arg VITE_VIEWER_BASE=https://你的viewer域名 \ + --build-arg VITE_EMBED_ORIGIN=https://你的viewer域名 \ + -t photo-live-web:12059d9-20260921 . + +docker save photo-live-server:12059d9-20260921 | gzip > photo-live-server.tar.gz +docker save photo-live-web:12059d9-20260921 | gzip > photo-live-web.tar.gz +# 上传两台服务器后,1Panel → 容器 → 镜像 → 导入(选择 tar.gz) +``` + +> web 镜像的 `VITE_VIEWER_BASE`/`VITE_EMBED_ORIGIN` 是**构建期**变量(admin 分享卡 +> 与嵌入代码拼 URL 用);cn/ov 两地域各用各自域名构建一次。漏传只影响分享卡外链, +> 不影响 API 路由(API 基址=同源 /v1,由运行时 `API_UPSTREAM` 决定)。 + +### 路径 B:推镜像仓库 → 服务器 pull(有仓库时) + +1Panel → 容器 → 仓库 → 创建(阿里云 ACR 个人版即可)→ 之后编排文件里 image 直接写 +`registry.cn-hangzhou.aliyuncs.com/你的命名空间/photo-live-server:tag`,或在服务器 +`docker pull` 后 `docker tag` 成编排里用的名字。 + +## 2. MySQL 建库 / 授权(两台各执行对应段) + +1Panel → 数据库 → MySQL(8.0+)→ 创建数据库。**库名/字符集**: + +```sql +-- cn 服务器(库名 photo_cn) +CREATE DATABASE photo_cn CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; +CREATE USER 'photo'@'%' IDENTIFIED BY '强密码'; +GRANT ALL PRIVILEGES ON photo_cn.* TO 'photo'@'%'; +FLUSH PRIVILEGES; + +-- ov 服务器(库名 photo_ov) +CREATE DATABASE photo_ov CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin; +CREATE USER 'photo_ov'@'%' IDENTIFIED BY '强密码'; +GRANT ALL PRIVILEGES ON photo_ov.* TO 'photo_ov'@'%'; +FLUSH PRIVILEGES; +``` + +- `utf8mb4_0900_bin`(大小写敏感)是账号/token/publicToken 语义的前提,需 + **MySQL ≥ 8.0.30**(决策 D12:先在两台 ECS 上 `SELECT VERSION();` 登记精确版本; + 若低于 8.0.30,改用 `utf8mb4_bin`(同样大小写敏感),并重跑语义相关验收)。 +- 授权 host:`'%'` 便于容器网段接入;生产更稳妥做法是限定网段 + (`'172.%'` 容器网 + cn 服务器出口 IP)。**ov 库必须额外放行 cn 服务器出口 IP + 的 3306**(sync 进程直连 ov 库),建议叠加 SSL 或隧道(P5 实测定案)。 + +## 3. Redis 连接 + +1Panel → 数据库 → Redis → 创建(默认库即可)。连接串二选一: + +- **容器网络互连(推荐)**:photo-live 编排与 1Panel 的 Redis 同在 + `1panel-network`,host 直接写 Redis **容器名**(`docker ps` 查名,如 + `1Panel-redis-xxxx`),端口写容器内端口 6379。 +- **宿主机 IP 直连**:host 写宿主机内网 IP;1Panel 应用端口默认只绑 127.0.0.1, + 需在 1Panel 端口设置放开监听地址或改绑 0.0.0.0(注意防火墙)。 + +填入 env:cn `REDIS_URL_CN`;ov `REDIS_URL_OV`。 + +## 4. Compose 编排(1Panel → 容器 → 编排 → 创建编排) + +1. 文件管理器在 1Panel 数据目录旁新建目录(示例 `/opt/photo-live/`),上传: + - `docker-compose.cn.yml`(cn)/ `docker-compose-ov.yml`(ov) + - 由仓库根 `.env.production.example-cn`/`-ov` 复制改名的 + `.env.production-cn`/`.env.production-ov`(**与 compose 同目录**,已填值) +2. 1Panel → 容器 → 编排 → 创建编排 → 路径选择该目录(或直接粘贴 compose 内容, + 再把 env 文件放到 1Panel 显示的编排目录内)。 +3. 默认编排挂**外部网络 `1panel-network`**(1Panel 应用网络,容器名互通 MySQL/ + Redis)。若你的 1Panel 网络名不同(`docker network ls` 核对),改 compose 末段 + `name:`;若用宿主机 IP 连接,把 networks 整段换成 `photo-net: {}` 并在 env 里 + 用 IP 形态的连接串。 +4. 启动前确认镜像 tag:默认 `photo-live-server:latest`/`photo-live-web:latest`; + 建议在 1Panel 编排的环境变量里固定 `SERVER_IMAGE`/`WEB_IMAGE` 为具体版本 tag。 + +## 5. OpenResty 网站 + 证书反代到 web 容器 + +1Panel → 网站 → 创建网站 → 反向代理: + +- cn:主域名 `photo.example.cn`(viewer)→ 代理 `http://127.0.0.1:8088`; + 管理端 `admin.photo.example.cn` → 同一后端 `http://127.0.0.1:8088`(admin 走 + hash 路由 `/admin/` 路径,也可与 viewer 同域直接用 `photo.example.cn/admin/`)。 +- ov:`photo.example.com` → `http://127.0.0.1:8089`。 +- 证书:网站设置 → HTTPS → 申请/绑定证书,开启「强制 HTTPS」。 +- OpenResty 侧注意: + - **不要重复缓存** `/v1/`(API 动态;图片 URL 本身是带签名的 OSS 直链,不经过 + 这层);静态资源缓存已在 photo-live-web 容器内配置好。 + - WebSocket(`/v1/realtime`)透传:1Panel 反代默认透传 Upgrade 头;若自建 + OpenResty 配置,需保留 `proxy_set_header Upgrade/Connection`(参考 + `nginx.conf.template` 的 /v1/ 段)。 + - 若 web 端口改绑了非回环地址,注意防火墙只放行 OpenResty 出口。 + +## 6. 迁移执行(migrate-deploy 角色,一次性容器) + +```bash +# cn 服务器:迁移 cn 库(env 文件与 compose 同目录;--rm 用完即删) +docker run --rm --env-file /opt/photo-live/.env.production-cn \ + --network 1panel-network \ + photo-live-server:12059d9-20260921 migrate-deploy cn + +# ov 服务器:迁移 ov 库 +docker run --rm --env-file /opt/photo-live/.env.production-ov \ + --network 1panel-network \ + photo-live-server:12059d9-20260921 migrate-deploy ov + +# 首次引导 admin 账号(cn;幂等,已存在则跳过) +docker run --rm --env-file /opt/photo-live/.env.production-cn \ + --network 1panel-network \ + photo-live-server:12059d9-20260921 seed-admin +``` + +`migrate-deploy` 支持区域参数 `cn | ov | all`(缺省 all——单机双库演练用;生产两台 +各跑各的)。Prisma migrate deploy 只向前应用未执行过的迁移,已在 +RUNBOOK.md 固化为发布步骤与回滚原则。 + +## 7. 首次部署顺序(cn 与 ov 并行各自的 1-2,汇合于 3-6) + +1. **DB 就绪**:两台各建库/授权(§2)+ Redis(§3)。 +2. **镜像导入**:两台各自导入 server+web 镜像(§1)。 +3. **迁移**:cn `migrate-deploy cn` → ov `migrate-deploy ov`(§6)。 +4. **起栈**:ov 先 `docker-compose-ov.yml` up(api-ov + web-ov)→ cn + `docker-compose.cn.yml` up(api-cn + worker + sync + web)。sync 依赖 ov API + 与 ov 库可达,故 cn 栈最后起。 +5. **域名/证书**:OpenResty 反代 + HTTPS(§5),DNS 解析两地域各自接入。 +6. **引导与演练**:cn `seed-admin`(§6)→ 管理端登录 → 建测试相册 → + `scripts/prod-smoke.sh <账号> <密码>` 四点冒烟 + (健康/登录/上传/公开读,ov 未部署时 `--ov` 跳过 ov 段)→ 观察同步水位 + (sync 日志 `outbox batch pushed`)→ 清理测试数据(软删照片即可)。 + +日常发布/回滚见 `RUNBOOK.md`。 diff --git a/infra/deploy/docker-compose-ov.yml b/infra/deploy/docker-compose-ov.yml new file mode 100644 index 0000000..bb9456d --- /dev/null +++ b/infra/deploy/docker-compose-ov.yml @@ -0,0 +1,58 @@ +# infra/deploy/docker-compose-ov.yml —— 海外(ov)生产编排(P5-prep Task 1)。 +# 只跑两个容器:api-ov(ov 公开读/互动/WS + sync-inbox + emergency 通道,全部 +# 内嵌——核实自 services/api/src/app-ov.module.ts)与 web-ov(nginx 网关)。 +# 【无 sync 进程】:cn→ov 内容同步的发送器/对账/统计回传全在 cn 侧 +# (docker-compose.cn.yml 的 sync 服务),跨地域经公网连 ov 的 API + MySQL。 +# +# 使用:同目录放 .env.production-ov(模板见仓库根 .env.production.example-ov); +# 网络与 1Panel 复用说明同 docker-compose.cn.yml 头注。 + +name: photo-live-ov + +x-logging: &default-logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + +services: + api-ov: + image: ${SERVER_IMAGE:-photo-live-server:latest} + command: ["api-ov"] + init: true + env_file: .env.production-ov + environment: + PORT_OV: "8081" # main-ov.ts 读 PORT_OV;容器内固定 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1:8081/v1/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + logging: *default-logging + networks: [photo-net] + + web-ov: + image: ${WEB_IMAGE:-photo-live-web:latest} + init: true + environment: + API_UPSTREAM: http://api-ov:8081 + ports: + # 同 cn:默认只绑回环供同机 OpenResty 反代(ov 侧主机端口用 8089 区分)。 + - "127.0.0.1:8089:80" + restart: unless-stopped + depends_on: + api-ov: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + logging: *default-logging + networks: [photo-net] + +networks: + photo-net: + # 说明同 docker-compose.cn.yml(1Panel 网络名按实际调整;或改宿主机 IP 连接)。 + name: 1panel-network + external: true diff --git a/infra/deploy/docker-compose.cn.yml b/infra/deploy/docker-compose.cn.yml new file mode 100644 index 0000000..542a7a5 --- /dev/null +++ b/infra/deploy/docker-compose.cn.yml @@ -0,0 +1,88 @@ +# infra/deploy/docker-compose.cn.yml —— 国内(cn)生产编排(P5-prep Task 1)。 +# 角色:api-cn(写路径 API)/ worker(sharp 变体流水线 + 发布事务 outbox)/ +# sync(outbox→ov 推送 + 对账 + 统计回传,双库直连)/ web(nginx 网关)。 +# 架构核实:sync 是独立进程跑在 cn 侧(services/sync/src/main.ts 双库连接); +# ov 侧无任何 sync 进程(inbox 内嵌 api-ov,见 docker-compose-ov.yml)。 +# +# 使用(1Panel 容器→编排→新建编排,或 docker compose -f 本文件 up -d): +# 1. 同目录放 .env.production-cn(模板见仓库根 .env.production.example-cn)。 +# 2. 镜像已导入(README-1Panel.md §1);SERVER_IMAGE/WEB_IMAGE 可用环境变量 +# 覆盖 tag(回滚 = 指回旧 tag 后 up -d,见 RUNBOOK.md)。 +# 3. 网络默认挂外部 1panel-network 以便用容器名直连 1Panel 的 MySQL/Redis; +# 若你的 1Panel 网络名不同,或打算用宿主机 IP + 端口连接,把下方 networks +# 整段换成普通桥接网络(photo-net: {})并同步改 .env 里的连接串 host。 +# +# 首次部署顺序(迁移先于 API):README-1Panel.md §7。 + +name: photo-live-cn + +x-server-image: &server-image ${SERVER_IMAGE:-photo-live-server:latest} +x-logging: &default-logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + +services: + api-cn: + image: *server-image + command: ["api-cn"] + init: true + env_file: .env.production-cn + environment: + PORT: "8080" # 容器内固定;对外经 web 反代或 OpenResty + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1:8080/v1/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + logging: *default-logging + networks: [photo-net] + + worker: + image: *server-image + command: ["worker"] + init: true + env_file: .env.production-cn + # 无 HTTP 端口:健康面由 restart 策略 + 日志监控承担(RUNBOOK.md 故障速查)。 + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + sync: + image: *server-image + command: ["sync"] + init: true + env_file: .env.production-cn + # 需要额外可达:OV_API_URL(公网域名)与 DATABASE_URL_OV(ov MySQL,跨地域)。 + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + web: + image: ${WEB_IMAGE:-photo-live-web:latest} + init: true + environment: + API_UPSTREAM: http://api-cn:8080 # /v1 反代目标(含 WS /v1/realtime) + ports: + # 只绑本机回环:1Panel OpenResty(同宿主机)反代到 127.0.0.1:8088; + # OpenResty 在其它机器时改为 "8088:80" 并配防火墙白名单。 + - "127.0.0.1:8088:80" + restart: unless-stopped + depends_on: + api-cn: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + logging: *default-logging + networks: [photo-net] + +networks: + photo-net: + # 复用 1Panel 应用网络(其名通常为 1panel-network):容器名互通 MySQL/Redis。 + # 若网络名不同:name 改实际名。若走宿主机 IP 连接:整段替换为 `photo-net: {}`。 + name: 1panel-network + external: true diff --git a/infra/deploy/nginx.conf.template b/infra/deploy/nginx.conf.template new file mode 100644 index 0000000..e306923 --- /dev/null +++ b/infra/deploy/nginx.conf.template @@ -0,0 +1,78 @@ +# infra/deploy/nginx.conf.template —— photo-live-web nginx 配置模板。 +# 官方 nginx 镜像启动脚本对 /etc/nginx/templates/*.template 执行 envsubst 并 +# 输出到 /etc/nginx/conf.d/。模板变量仅 ${API_UPSTREAM}(compose environment +# 注入);envsubst 只替换已定义的环境变量,$uri/$host 等 nginx 变量不受影响。 +# +# 路由布局(viewer history 路由 / admin hash 路由,路由模式核实自 +# apps/viewer/src/router/index.ts createWebHistory 与 apps/admin/src/router/ +# index.ts createWebHashHistory): +# / viewer SPA(try_files 回退 index.html) +# /admin/ admin 静态(hash 路由无需 history 回退;try_files 仅兜底) +# /assets/ viewer vite 内容寻址产物 → immutable 一年 +# /admin/assets/ admin vite 内容寻址产物 → immutable 一年 +# /embed/v1/ 嵌入 loader.js(版本目录即内容版本)→ immutable 一年; +# /embed/:slotToken 是 viewer SPA 路由,走 / 的回退,不被本条误捕 +# /v1/ 反代 API_UPSTREAM(HTTP + WS /v1/realtime 升级透传) + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html/viewer; + index index.html; + + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml; + + # /v1 → API 容器(viewer/admin 前端均为同源相对路径请求)。 + location /v1/ { + proxy_pass ${API_UPSTREAM}; + 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; + # WS(/v1/realtime)升级;普通 HTTP 请求不受影响。 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 75s; + proxy_send_timeout 75s; + } + + # admin(hash 路由:静态直达即工作;try_files 兜底异常直链)。 + location ^~ /admin/ { + alias /usr/share/nginx/html/admin/; + try_files $uri $uri/ /admin/index.html; + } + # admin 内容寻址资源:immutable(注意 alias 指到 assets/ 本身:location 前缀 + # /admin/assets/ 被剥去后拼到 alias 末尾,多剥一段就 404——实测坑位)。 + location ^~ /admin/assets/ { + alias /usr/share/nginx/html/admin/assets/; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # 嵌入 loader(版本化路径 /embed/v1/loader.js,apps/viewer/public 静态携带)。 + location ^~ /embed/v1/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # viewer 内容寻址资源:immutable。 + location ^~ /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # viewer SPA history 路由回退。 + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package.json b/package.json index 56fddaf..33baf25 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,13 @@ "globals": "^16.0.0" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "//": "onlyBuiltDependencies:pnpm 10 默认拦截依赖 install 脚本。esbuild 原有(vite 二进制);P5-prep 增补镜像/生产所需——sharp(变体流水线预编译物校验)、prisma/@prisma/engines/@prisma/client(迁移与 generate 引擎下载,供 docker/server.Dockerfile 与 migrate-deploy 角色)。", + "onlyBuiltDependencies": [ + "esbuild", + "sharp", + "prisma", + "@prisma/engines", + "@prisma/client" + ] } }