From 58cada6f59ae86130a1f59e796d8c1f7b3a089c8 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 6 Jul 2026 13:26:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0k8s=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k8s/README.md | 237 ++++++++ k8s/deploy.sh | 378 +++++++++++++ k8s/helm/topfans/Chart.yaml | 20 + k8s/helm/topfans/templates/00-namespace.yaml | 10 + k8s/helm/topfans/templates/_helpers.tpl | 131 +++++ .../templates/activityservice/deployment.yaml | 85 +++ .../templates/aichatservice/deployment.yaml | 86 +++ .../templates/assetservice/deployment.yaml | 86 +++ .../external-db/postgres-external.yaml | 24 + .../templates/external-db/redis-external.yaml | 21 + .../topfans/templates/future-services.yaml | 14 + .../templates/galleryservice/deployment.yaml | 93 ++++ .../topfans/templates/gateway/deployment.yaml | 90 +++ .../topfans/templates/hpa/aichatservice.yaml | 26 + k8s/helm/topfans/templates/hpa/gateway.yaml | 35 ++ .../templates/hpa/notificationservice.yaml | 26 + k8s/helm/topfans/templates/ingress.yaml | 52 ++ .../moderationservice/deployment.yaml | 85 +++ .../notificationservice/deployment.yaml | 85 +++ .../topfans/templates/oss-cors-init/job.yaml | 43 ++ .../topfans/templates/pg-sequence-sync.yaml | 127 +++++ .../topfans/templates/secrets/ai-keys.yaml | 42 ++ .../templates/secrets/db-credentials.yaml | 15 + .../topfans/templates/secrets/jwt-secret.yaml | 12 + .../templates/secrets/oss-credentials.yaml | 21 + .../templates/socialservice/deployment.yaml | 85 +++ .../templates/starbookservice/deployment.yaml | 86 +++ .../statisticservice/deployment.yaml | 107 ++++ .../templates/taskservice/deployment.yaml | 92 ++++ .../templates/userservice/deployment.yaml | 85 +++ k8s/helm/topfans/values-prod.example.yaml | 75 +++ k8s/helm/topfans/values.yaml | 388 +++++++++++++ k8s/jobs/pg-sequence-sync/job-template.yaml | 138 +++++ k8s/jobs/pg-sequence-sync/script.sh | 117 ++++ k8s/scripts/fill-values-prod.sh | 511 ++++++++++++++++++ 35 files changed, 3528 insertions(+) create mode 100644 k8s/README.md create mode 100755 k8s/deploy.sh create mode 100644 k8s/helm/topfans/Chart.yaml create mode 100644 k8s/helm/topfans/templates/00-namespace.yaml create mode 100644 k8s/helm/topfans/templates/_helpers.tpl create mode 100644 k8s/helm/topfans/templates/activityservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/aichatservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/assetservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/external-db/postgres-external.yaml create mode 100644 k8s/helm/topfans/templates/external-db/redis-external.yaml create mode 100644 k8s/helm/topfans/templates/future-services.yaml create mode 100644 k8s/helm/topfans/templates/galleryservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/gateway/deployment.yaml create mode 100644 k8s/helm/topfans/templates/hpa/aichatservice.yaml create mode 100644 k8s/helm/topfans/templates/hpa/gateway.yaml create mode 100644 k8s/helm/topfans/templates/hpa/notificationservice.yaml create mode 100644 k8s/helm/topfans/templates/ingress.yaml create mode 100644 k8s/helm/topfans/templates/moderationservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/notificationservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/oss-cors-init/job.yaml create mode 100644 k8s/helm/topfans/templates/pg-sequence-sync.yaml create mode 100644 k8s/helm/topfans/templates/secrets/ai-keys.yaml create mode 100644 k8s/helm/topfans/templates/secrets/db-credentials.yaml create mode 100644 k8s/helm/topfans/templates/secrets/jwt-secret.yaml create mode 100644 k8s/helm/topfans/templates/secrets/oss-credentials.yaml create mode 100644 k8s/helm/topfans/templates/socialservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/starbookservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/statisticservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/taskservice/deployment.yaml create mode 100644 k8s/helm/topfans/templates/userservice/deployment.yaml create mode 100644 k8s/helm/topfans/values-prod.example.yaml create mode 100644 k8s/helm/topfans/values.yaml create mode 100644 k8s/jobs/pg-sequence-sync/job-template.yaml create mode 100755 k8s/jobs/pg-sequence-sync/script.sh create mode 100755 k8s/scripts/fill-values-prod.sh diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..c911ecb --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,237 @@ +# TopFans K8s / Helm 部署手册 (Phase 1) + +> Phase 1: 单 namespace `topfans` 合并部署, 10 个 Go 服务 + gateway 共享一套基础设施, +> Postgres / Redis 走阿里云 RDS / ElastiCache ExternalName。 +> +> 设计文档: [`../docs/superpowers/specs/2026-06-08-docker-to-k8s-migration-design.md`](../docs/superpowers/specs/2026-06-08-docker-to-k8s-migration-design.md) +> +> ⚠️ Phase 2 (按组隔离) 不在本手册范围, 见设计文档第十一章。 + +## 目录结构 + +``` +k8s/ +├── README.md # 本文件 +├── deploy.sh # Helm / K8s 部署脚本 (主入口) +├── helm/ +│ └── topfans/ # 单个 chart, 覆盖整个 Phase 1 +│ ├── Chart.yaml +│ ├── values.yaml # 默认值 (CI/Dev 用) +│ ├── values-prod.example.yaml # 模板 (空值, 部署时 cp 后填) +│ └── templates/ +│ ├── _helpers.tpl +│ ├── 00-namespace.yaml +│ ├── gateway/ ... # 13 个服务各自子目录 +│ ├── hpa/ ... # 3 个 HPA +│ ├── secrets/ ... # 4 个 Secret +│ ├── external-db/ ... # postgres / redis ExternalName +│ ├── ingress.yaml +│ ├── pg-sequence-sync.yaml # 切流量前必跑的 hook Job +│ ├── oss-cors-init/job.yaml +│ └── future-services/ ... # 4 个未来服务 .gitkeep +└── jobs/ + └── pg-sequence-sync/ # 独立手动跑版本 (备份用) + ├── script.sh + └── job-template.yaml +``` + +## 首次部署 (Checklist) + +按顺序完成: + +### 1. 集群与基础设施 + +- [ ] 阿里云 ACK 测试集群创建 (按设计文档 §六 Step 1, 推荐) +- [ ] 装 `nginx-ingress-controller` (Helm, 集群级别) +- [ ] 装 `cert-manager` (Helm, 集群级别) +- [ ] 创建 ClusterIssuer `letsencrypt-prod` (用 DNS-01 校验) +- [ ] 创建 namespace `topfans` (helm install 时自动 create, 也可提前 `kubectl create ns topfans`) +- [ ] 创建阿里云 RDS (PostgreSQL) + 用阿里云 DTS 把 VM 的数据迁过去 +- [ ] 创建阿里云 ElastiCache (Redis), 同步数据 (如果有) +- [ ] **PG 端连通验证**: `psql -h ` 确认能连上, schemata 在 + +### 2. CI 镜像仓库 + +- [ ] 阿里云 ACR 创建 namespace `topfans`, registry 是 `registry.cn-shanghai.aliyuncs.com` +- [ ] CI (GitHub Actions / 阿里云云效) 配 ACR push 凭据, 跑通 `gateway:v1.0.0` 一个 image +- [ ] (`Dockerfile.services` 多阶段构建已 OK, 无需改) + +### 3. 真值 values-prod.yaml + +**推荐用交互脚本生成** (避免在 example 里写假值被误部署): + +```bash +./k8s/scripts/fill-values-prod.sh +# 交互式提示输入 DB / Redis / JWT / OSS / Dify / OpenAI / SMS 真值 +# 写完自动 chmod 600 + 检查 .gitignore + 用 gitleaks/detect-secrets 扫描 (如已装) + +# CI 模式: 全用环境变量 +./k8s/scripts/fill-values-prod.sh --non-interactive + +# 写完用 sops 加密 (加密文件可入 git) +./k8s/scripts/fill-values-prod.sh --sops + +# 只看不写 +./k8s/scripts/fill-values-prod.sh --dry-run +``` + +手写版 (不推荐, 易出拼错): + +```bash +cd k8s/helm/topfans +cp values-prod.example.yaml values-prod.yaml +chmod 600 values-prod.yaml +$EDITOR values-prod.yaml +# 重点字段: +# global.dbExternalEndpoint: rm-xxxxxx.mysql.rds.aliyuncs.com +# global.redisExternalEndpoint: r-xxxxxx.redis.rds.aliyuncs.com +# services..image.tag: (CI 注入, 或先用 latest) +# secrets.* : DB / JWT / OSS / AI / SMS 真值 +``` + +⚠️ **`values-prod.yaml` 不进 git** (`.gitignore` 已加, 脚本会二次确认)。 + +### 4. 首次部署 + +```bash +cd /path/to/repo +./k8s/deploy.sh install +``` + +会自动按顺序: +1. `helm.sh/hook pre-install` 创建 namespace +2. `helm.sh/hook pre-install` 跑 PG sequence sync (如果 `pgSequenceSync.enabled=true`) +3. `helm.sh/hook pre-install` 跑 oss-cors-init (Job 跑完即退) +4. 创建所有 Service / Deployment / Secret / ConfigMap / HPA +5. `helm.sh/hook pre-install` (其实是 `Install` 阶段) 跑完等 Pod Ready + +### 5. Ingress 域名与 TLS + +```bash +# 改 ingress 内的 host +$EDITOR k8s/helm/topfans/values-prod.yaml +# ingress.hosts[0].host: api.example.com +# ingress.tls[0].hosts[0]: api.example.com + +# 解析到 nginx-ingress-controller 的 external IP +kubectl get svc -n ingress-nginx +# api.example.com A 记录 → + +./k8s/deploy.sh upgrade --reuse-values +``` + +## 升级流程 (日常) + +```bash +# 1. CI 已经把镜像推到 ACR: +# registry.cn-shanghai.aliyuncs.com/topfans/gateway:v1.0.1 +# registry.cn-shanghai.aliyuncs.com/topfans/userservice:v1.0.1 +# ... (13 个 image 都标 v1.0.1) + +# 2. 把所有 service 的 image tag 改成新版本: +./k8s/deploy.sh upgrade v1.0.1 + +# 实质执行: +# helm upgrade topfans ./helm/topfans \ +# -f values-prod.yaml \ +# --set global.image.tag=v1.0.1 \ +# --wait --timeout 10m +``` + +`helm upgrade --wait` 会按 readinessProbe 等到所有 Pod Ready 才返回, +失败的话 Exit code 非 0, deploy.sh 提示回滚。 + +## 回滚 + +```bash +# 看历史 +./k8s/deploy.sh history +# (helm history 通过 deploy.sh status 子命令也看得到) + +# 回滚到上一版 +./k8s/deploy.sh rollback + +# 回滚到指定 revision +./k8s/deploy.sh rollback 3 +``` + +## PostgreSQL 序列同步 (Hard Blocker) + +> **CLAUDE.md 强制规范 + 设计文档 §10.3 标记为 Hard Blocker** +> 任何手动 INSERT id 都必须配套 setval; 切流量前必跑。 + +```bash +./k8s/deploy.sh sync-pg # 同步 + 验证 +./k8s/deploy.sh sync-pg --status # 只验证 +``` + +挂在 Helm 上的版本 (设计文档 §六 Step 4 提到的方案): +- `helm install / upgrade` 自动跑 `templates/pg-sequence-sync.yaml` (helm hook, weight=-8) +- 失败 abort deployment + +**何时必须手动跑** (用 `deploy.sh sync-pg`): +1. 首次切流量前 +2. 任何 backfill SQL (含手动 INSERT) 后 +3. 数据迁移测试期间, 反复切换期间 + +## Secret 管理 (最低要求) + +真值 **必须** 通过以下任一方式入 K8s,**严禁**把 values-prod.yaml 入 git: + +| 方式 | 说明 | 适用 | +|---|---|---| +| **kubectl create secret** | 手 kubectl create secret 加 Secret, helm 不管 | 1-2 人小团队 | +| **CI/CD 注入** | CI 流水线 kubectl apply / helm install --set 注入 | 推荐 | +| **SOPS + Git** | values-prod.yaml sops -e 加密后入 git, CI decrypt | 多环境共享 | +| **External Secrets Operator** | 接阿里云 KMS, 动态拉真值 | Phase 2+ | + +当前 **Phase 1 默认用 kubectl create secret** (最快上手), 后续切 SOPS。 + +## 监控 / 日志 (设计文档 §六 Step 6, 可选) + +不阻塞 Phase 1 上线, 上线后补: +- Prometheus Operator (集群级) +- Grafana dashboard (按 dubbo / DB / Redis 分类) +- Loki + Promtail (日志聚合) +- 阿里云 ARMS (一键接入, 推荐) + +## 常见问题 + +### Q: helm install 报 `duplicate key on secret` / `ServerTimeout` + +**A**: 上一次 helm install 没清干净。`helm uninstall topfans` 再重试。 + +### Q: PG 序列同步失败, 报 `setval: sequence_name not found` + +**A**: 说明该 BIGSERIAL 表要么不存在, 要么 sequence 名拼错, 可能表没建好。 +连 RDS 验证: `\d+ assets` 看最下方的 `Sequence: assets_id_seq` 是否对得上。 + +### Q: 容器启动后卡在 Init 或 CrashLoopBackOff + +**A**: 看日志: `kubectl logs -n topfans --previous` +常见: +- DB 连接不上: 检查 `secrets/db/db.password` 是否对, RDS 安全组是否放行 K8s 节点 CIDR +- OSS 配置缺: `oss-credentials` Secret 没建, helm uninstall + 重装 + +### Q: HPA 不工作 + +**A**: `kubectl get hpa -n topfans` 看是不是 `Unknown`, 通常是没有 metrics-server。 +Phase 1 实施时安装 metrics-server (Helm, 一般 ingress-nginx chart 一起装)。 + +### Q: 各服务的 healthcheck 通不过 + +**A**: 各服务的 healthcheck path 不一致, 见 `values.yaml.services..healthPath`: +- 大多数用 `/health` +- `statisticservice` / `notificationservice` 用 `/healthz` +- `moderationservice` 用 `/` (历史原因, 不修, 见设计文档 §10.4) + +--- + +## 参考 + +- 设计文档: `../docs/superpowers/specs/2026-06-08-docker-to-k8s-migration-design.md` +- Helm 官方文档: https://helm.sh/docs/ +- 阿里云 ACK: https://help.aliyun.com/product/85222.html +- 阿里云 ACR: https://help.aliyun.com/product/60716.html + +最后更新: 2026-07-06 (Phase 1 首版) diff --git a/k8s/deploy.sh b/k8s/deploy.sh new file mode 100755 index 0000000..92a9433 --- /dev/null +++ b/k8s/deploy.sh @@ -0,0 +1,378 @@ +#!/bin/bash +# =================================================================== +# TopFans Helm / Kubernetes 部署脚本 (Phase 1) +# =================================================================== +# 功能: +# - helm install / upgrade (CI 已经把镜像推到 ACR) +# - helm rollback (任意 release 版本) +# - helm status 查看 +# - 手动跑 PostgreSQL 序列同步 (切流量前 hard blocker) +# - 临时启停某个服务 (scale / disable) +# +# 使用前提: +# 1. K8s 集群已就绪 (ACK / TKE / k3s 任一) +# 2. 已 kubectl config get-contexts 选好 cluster context +# 3. helm 3.x 已装 +# 4. (切流量前) PostgreSQL 序列同步已成功 (本脚本的 sync-pg 子命令) +# 5. values-prod.yaml 已就绪 (用 sops 加密 或 直接 kubectl create secret) +# +# 使用方式: +# # 安装 (首次) +# ./k8s/deploy.sh install +# +# # 升级 (推荐: 用 helm upgrade --reuse-values) +# ./k8s/deploy.sh upgrade v1.0.1 +# +# # 回滚到上一版 +# ./k8s/deploy.sh rollback +# +# # 显式回滚到指定版本 +# ./k8s/deploy.sh rollback 3 +# +# # PostgreSQL 序列同步 (切流量前必跑) +# ./k8s/deploy.sh sync-pg +# +# # 看状态 +# ./k8s/deploy.sh status +# +# # 看某个服务的 Pod 日志 +# ./k8s/deploy.sh logs gateway +# =================================================================== + +set -e + +# ==================== 颜色 ==================== +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' + +# ==================== 路径 / 命名 ==================== +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELM_CHART_DIR="${SCRIPT_DIR}/helm/topfans" +NAMESPACE="${NAMESPACE:-topfans}" +RELEASE_NAME="${RELEASE_NAME:-topfans}" +VALUES_FILE="${VALUES_FILE:-${SCRIPT_DIR}/helm/topfans/values-prod.yaml}" + +print_step() { + echo "" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +print_msg() { echo -e "${1}${2}${NC}"; } + +print_help() { + cat << EOF +${MAGENTA}TopFans K8s / Helm 部署脚本 (Phase 1)${NC} + +${YELLOW}用法:${NC} + $0 [args] + +${YELLOW}命令:${NC} + ${GREEN}install${NC} 首次部署 (helm install) + ${GREEN}upgrade${NC} 升级镜像 (用 version 写所有服务 tag) + ${GREEN}upgrade --reuse-values${NC} 升级, 复用 values + ${GREEN}rollback${NC} [revision] 回滚 (默认上一版) + ${GREEN}status${NC} 看 helm status + pod 状态 + ${GREEN}logs${NC} 看 Pod 日志 (e.g. logs gateway) + ${GREEN}sync-pg${NC} PostgreSQL 序列同步 (切流量前必跑) + ${GREEN}sync-pg --status${NC} 只验证, 不同步 + ${GREEN}list${NC} 列所有 release + ${GREEN}uninstall${NC} 卸载 (生产慎用!) + +${YELLOW}环境变量:${NC} + NAMESPACE 命名空间 (默认 topfans) + RELEASE_NAME helm release 名 (默认 topfans) + VALUES_FILE values 文件 (默认 helm/topfans/values-prod.yaml) + +${YELLOW}示例:${NC} + $0 install + $0 upgrade v1.0.1 + $0 upgrade --reuse-values + $0 rollback 2 + $0 sync-pg + $0 status + +EOF +} + +# ==================== 必要工具检查 ==================== +check_prereqs() { + local missing=0 + for cmd in helm kubectl grep awk; do + if ! command -v "$cmd" >/dev/null 2>&1; then + print_msg "$RED" "❌ 未安装: $cmd" + missing=$((missing+1)) + fi + done + if [ "$missing" -gt 0 ]; then + print_msg "$RED" "请先装 helm / kubectl" + exit 1 + fi + + # 必须有 namespace 上下文 + if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then + print_msg "$YELLOW" "⚠️ namespace '$NAMESPACE' 不存在, helm install 时会自动创建" + fi +} + +# ==================== install ==================== +do_install() { + print_step "🚀 helm install ${RELEASE_NAME}" + print_msg "$YELLOW" "Chart: $HELM_CHART_DIR" + print_msg "$YELLOW" "Namespace: $NAMESPACE" + print_msg "$YELLOW" "Values: $VALUES_FILE" + + if [ ! -f "$VALUES_FILE" ]; then + print_msg "$RED" "❌ values 文件不存在: $VALUES_FILE" + print_msg "$YELLOW" "复制模板: cp ${VALUES_FILE}.example ${VALUES_FILE}" + exit 1 + fi + + helm install "$RELEASE_NAME" "$HELM_CHART_DIR" \ + --namespace "$NAMESPACE" \ + --create-namespace \ + --values "$VALUES_FILE" \ + --wait \ + --timeout 10m + + print_step "✅ 安装完成" + do_status +} + +# ==================== upgrade ==================== +do_upgrade() { + local version="${1:-}" + local reuse_values=false + while [ $# -gt 0 ]; do + case "$1" in + --reuse-values) reuse_values=true; shift ;; + *) shift ;; + esac + done + + print_step "🔼 helm upgrade ${RELEASE_NAME}" + print_msg "$YELLOW" "Version: ${version:-latest}" + + if [ "$reuse_values" = "true" ]; then + print_msg "$CYAN" "使用 --reuse-values (不重读 values 文件)" + helm upgrade "$RELEASE_NAME" "$HELM_CHART_DIR" \ + --namespace "$NAMESPACE" \ + --reuse-values \ + --wait \ + --timeout 10m + else + [ -z "$version" ] && { print_msg "$RED" "❌ 请指定 version 或加 --reuse-values"; exit 1; } + helm upgrade "$RELEASE_NAME" "$HELM_CHART_DIR" \ + --namespace "$NAMESPACE" \ + --values "$VALUES_FILE" \ + --set "global.image.tag=$version" \ + --wait \ + --timeout 10m + fi + + print_step "✅ 升级完成" + do_status +} + +# ==================== rollback ==================== +do_rollback() { + local revision="${1:-}" + print_step "🔄 helm rollback ${RELEASE_NAME}" + + if [ -z "$revision" ]; then + print_msg "$YELLOW" "回滚到上一版" + helm rollback "$RELEASE_NAME" --namespace "$NAMESPACE" --wait + else + print_msg "$YELLOW" "回滚到 revision $revision" + helm rollback "$RELEASE_NAME" "$revision" --namespace "$NAMESPACE" --wait + fi + + print_step "✅ 回滚完成" + do_status +} + +# ==================== status ==================== +do_status() { + print_step "📊 ${RELEASE_NAME} status" + echo "" + print_msg "$CYAN" "── helm history ──" + helm history "$RELEASE_NAME" --namespace "$NAMESPACE" || true + echo "" + print_msg "$CYAN" "── pods ──" + kubectl get pods -n "$NAMESPACE" -o wide + echo "" + print_msg "$CYAN" "── services ──" + kubectl get svc -n "$NAMESPACE" + echo "" + print_msg "$CYAN" "── ingress ──" + kubectl get ingress -n "$NAMESPACE" 2>/dev/null || true + echo "" + + # 健康探针短查 (gateway 200 即认为 OK) + gateway_ip=$(kubectl get svc "${RELEASE_NAME}-gateway" -n "$NAMESPACE" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || echo "") + if [ -n "$gateway_ip" ]; then + print_msg "$CYAN" "── gateway health (in-cluster) ──" + kubectl run -n "$NAMESPACE" curl-probe --rm -it --restart=Never \ + --image=curlimages/curl --quiet -- \ + curl -fsS "http://${gateway_ip}:8080/health" \ + && print_msg "$GREEN" "✅ gateway health OK" \ + || print_msg "$YELLOW" "⚠️ gateway 探针未通过 (可能 startup 中)" + fi +} + +# ==================== logs ==================== +do_logs() { + local svc="${1:?用法: $0 logs }" + print_step "📜 logs ${svc}" + kubectl logs -n "$NAMESPACE" -l "app.kubernetes.io/component=${svc}" --tail=200 -f +} + +# ==================== PostgreSQL 序列同步 ==================== +# Hard blocker for traffic switching (CLAUDE.md / 设计文档 §10.3). +# 行为: +# sync-pg [--status] +# 把 db-credentials 里的 DB_PASSWORD + values 里的 tables 灌到 Secret, +# kubectl create -f k8s/jobs/pg-sequence-sync/manual-job.yaml, +# wait until complete, 出错 exit 1。 +do_sync_pg() { + local verify_only=false + while [ $# -gt 0 ]; do + case "$1" in + --status) verify_only=true; shift ;; + *) shift ;; + esac + done + + print_step "🔧 PostgreSQL 序列同步 (CLAUDE.md hard blocker)" + print_msg "$YELLOW" "Namespace: $NAMESPACE" + + if [ "$verify_only" = "true" ]; then + print_msg "$CYAN" "只验证, 不同步" + # 直接连 DB 验证 + kubectl run -n "$NAMESPACE" pg-probe --rm -it --restart=Never \ + --image=alpine:3.19 --quiet -- sh -c " + apk add --no-cache postgresql16-client > /dev/null + TABLES=$(yq eval '.pgSequenceSync.tables | join(\",\")' "$VALUES_FILE" 2>/dev/null || echo 'assets,users,stars') + for tbl in \$(echo \"\$TABLES\" | tr ',' ' '); do + h=\$(PGPASSWORD=$(kubectl get secret db-credentials -n $NAMESPACE -o jsonpath='{.data.DB_PASSWORD}' | base64 -d) psql -h postgres -U postgres -d topfans -tAc \ + \"SELECT last_value >= COALESCE((SELECT MAX(id) FROM ONLY \$tbl), 0) FROM pg_sequences WHERE sequencename = '\$tbl_id_seq';\") + [ \"\$h\" = 't' ] && echo \" ✅ \$tbl healthy\" || echo \" ❌ \$tbl UNHEALTHY\" + done + " + return + fi + + # 拉 values 里的 tables, 灌到临时 Secret + print_msg "$YELLOW" "准备 Job..." + local pg_password + pg_password=$(kubectl get secret db-credentials -n "$NAMESPACE" -o jsonpath='{.data.DB_PASSWORD}' | base64 -d) + + cat < /dev/null + TABLES="${TABLES:-assets,asset_registry,users,stars,activity_assets,collection_assets,materials,exhibitions,galleries}" + bad=0 + for tbl in \$(echo "\$TABLES" | tr ',' ' '); do + tbl=\$(echo "\$tbl" | tr -d ' ') + [ -z "\$tbl" ] && continue + max_id=\$(PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans -tAc "SELECT COALESCE(MAX(id),0) FROM \$tbl;") + [ "\$max_id" = "0" ] && { echo " \$tbl: empty, skip"; continue; } + PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans \ + -c "SELECT setval('\${tbl}_id_seq', \$max_id, true);" + h=\$(PGPASSWORD="${pg_password}" psql -h postgres -U postgres -d topfans -tAc \ + "SELECT last_value >= \$max_id FROM pg_sequences WHERE sequencename = '\${tbl}_id_seq';") + if [ "\$h" = "t" ]; then + echo " ✅ \$tbl → \$max_id" + else + echo " ❌ \$tbl sync failed" + bad=\$((bad+1)) + fi + done + [ "\$bad" -gt 0 ] && exit 1 + echo "✅ done" + env: + - name: PGPASSWORD_FORCE + value: "already-baked-into-args" + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 300m, memory: 128Mi } +EOJOB + + print_msg "$YELLOW" "等 Job 完成 (timeout 5min)..." + local jobname + jobname=$(kubectl get jobs -n "$NAMESPACE" -l "app.kubernetes.io/run=manual" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}') + kubectl wait --for=condition=complete "job/${jobname}" -n "$NAMESPACE" --timeout=300s || { + print_msg "$RED" "❌ Job 失败" + kubectl logs -n "$NAMESPACE" "job/${jobname}" + exit 1 + } + print_msg "$GREEN" "✅ 序列同步成功" +} + +# ==================== list / uninstall ==================== +do_list() { + helm list -n "$NAMESPACE" -A +} + +do_uninstall() { + print_step "⚠️ 卸载 ${RELEASE_NAME}" + echo -e "${RED}⚠️ 真要删除整个 K8s 部署吗? 数据库 (RDS) 不受影响, 这是无状态 Pod.$NC" + read -p "(yes/no): " confirm + [ "$confirm" = "yes" ] || { print_msg "$YELLOW" "已取消"; exit 0; } + helm uninstall "$RELEASE_NAME" -n "$NAMESPACE" + print_msg "$GREEN" "✅ 已卸载" +} + +# ==================== main ==================== +main() { + if [ $# -eq 0 ]; then + print_help + exit 0 + fi + + check_prereqs + + local cmd="$1" + shift + + case "$cmd" in + install) do_install "$@" ;; + upgrade) do_upgrade "$@" ;; + rollback) do_rollback "$@" ;; + status) do_status ;; + logs) do_logs "$@" ;; + sync-pg|pg-sync) do_sync_pg "$@" ;; + list) do_list ;; + uninstall) do_uninstall ;; + -h|--help|help) print_help ;; + *) print_msg "$RED" "未知命令 '$cmd'"; print_help; exit 1 ;; + esac +} + +main "$@" diff --git a/k8s/helm/topfans/Chart.yaml b/k8s/helm/topfans/Chart.yaml new file mode 100644 index 0000000..bc8279f --- /dev/null +++ b/k8s/helm/topfans/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: topfans +description: | + TopFans 全明星粉丝平台 — Phase 1 (合并部署/降本) + 单 namespace (topfans), 9+4 数据服务 + gateway 共享一套部署, + 外部托管 PostgreSQL (RDS) + Redis (ElastiCache) 走 ExternalName。 + Phase 2 (按组隔离) 见 docs/superpowers/specs/2026-06-08-docker-to-k8s-migration-design.md#十一 + +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - topfans + - dubbo + - go +home: https://github.com/zerosaturation/TopFansByGithub +maintainers: + - name: zerosaturation +sources: + - https://github.com/zerosaturation/TopFansByGithub diff --git a/k8s/helm/topfans/templates/00-namespace.yaml b/k8s/helm/topfans/templates/00-namespace.yaml new file mode 100644 index 0000000..4b350b3 --- /dev/null +++ b/k8s/helm/topfans/templates/00-namespace.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace.name | default "topfans" }} + labels: + name: {{ .Values.namespace.name | default "topfans" }} + purpose: phase1-merged-deploy + phase: phase1 + # 留 phase2 拆分空间 — Phase 2 会拆出 topfans-shared / topfans-group-* 各命名空间 + {{- include "topfans.labels" . | nindent 4 }} diff --git a/k8s/helm/topfans/templates/_helpers.tpl b/k8s/helm/topfans/templates/_helpers.tpl new file mode 100644 index 0000000..06d448c --- /dev/null +++ b/k8s/helm/topfans/templates/_helpers.tpl @@ -0,0 +1,131 @@ +{{/* + 公共辅助模板。Chart 内所有资源都用这些 helper 拼接 name / labels / selectors, + 避免每个文件重复 boilerplate。 + + 用法: + {{ include "topfans.fullname" . }} + {{ include "topfans.labels" . }} + {{ include "topfans.commonEnv" . }} + {{ include "topfans.serviceEnv" (dict "name" "userservice" "port" 20000 "envFromSecret" "db-credentials") }} +*/}} + +{{/* ============ 命名 ============ */}} + +{{/* topfans.name — chart 基础名(默认 "topfans"),可被 nameOverride 覆盖 */}} +{{- define "topfans.name" -}} +{{- default "topfans" .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* topfans.fullname — release 名 + chart 名拼接,63 字符限制内 */}} +{{- define "topfans.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default "topfans" .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* topfans.chart — chart 名 + 版本 */}} +{{- define "topfans.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* ============ Labels (所有 K8s 资源都打这套, 方便 kubectl 过滤) ============ */}} + +{{- define "topfans.labels" -}} +helm.sh/chart: {{ include "topfans.chart" . }} +{{ include "topfans.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: topfans +{{- end -}} + +{{- define "topfans.selectorLabels" -}} +app.kubernetes.io/name: {{ include "topfans.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* ============ 通用 ENV (DB + Redis + 业务常量) ============ + 与 docker-compose.prod.yml 的 x-common-env 等价, 注入到所有服务的 Pod 中。 */}} + +{{- define "topfans.commonEnv" -}} +- name: GIN_MODE + value: {{ .Values.global.ginMode | default "release" | quote }} +- name: ENV + value: {{ .Values.global.env | default "production" | quote }} +- name: LOG_LEVEL + value: {{ .Values.global.logLevel | default "info" | quote }} +- name: DB_HOST + value: {{ .Values.global.dbHost | quote }} +- name: DB_PORT + value: {{ .Values.global.dbPort | default "5432" | quote }} +- name: DB_USER + value: {{ .Values.global.dbUser | default "postgres" | quote }} +- name: DB_NAME + value: {{ .Values.global.dbName | default "topfans" | quote }} +- name: DB_SSLMODE + value: {{ .Values.global.dbSslmode | default "disable" | quote }} +- name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: DB_PASSWORD +- name: REDIS_HOST + value: {{ .Values.global.redisHost | quote }} +- name: REDIS_PORT + value: {{ .Values.global.redisPort | default "6379" | quote }} +- name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: REDIS_PASSWORD +- name: REDIS_DB + value: {{ .Values.global.redisDb | default "0" | quote }} +{{- end -}} + +{{/* ============ ServiceAccount (Phase 1 所有服务共用一个) ============ */}} + +{{- define "topfans.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "topfans.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{/* ============ 单个服务的 Dubbo URL 拼接 ============ + 例: {{ include "topfans.dubboURL" (dict "name" "userservice" "port" 20000) }} + => tri://userservice:20000 + + 故意不把 "tri://" 放 values 让用户改, 这格式属于实现细节, + 如果哪天换协议 (HTTP / gRPC) 是 chart 级别的事, 不该让运维碰。 */}} + +{{- define "topfans.dubboURL" -}} +{{- printf "tri://%s:%d" .name .port -}} +{{- end -}} + +{{/* ============ Image 拼接 (Phase 1: 阿里云 ACR 推荐) ============ + 用法: + {{ include "topfans.image" (dict "Values" .Values "service" "gateway" "tag" "v1.0.0") }} + 输出: + registry.cn-shanghai.aliyuncs.com/topfans/gateway:v1.0.0 +*/}} + +{{- define "topfans.image" -}} +{{- $registry := .Values.global.image.registry | default "" -}} +{{- $ns := .Values.global.image.repositoryNamespace | default "topfans" -}} +{{- $svc := .service -}} +{{- $tag := .tag | default "latest" -}} +{{- if $registry -}} +{{- printf "%s/%s/%s:%s" $registry $ns $svc $tag -}} +{{- else -}} +{{- printf "%s:%s" $svc $tag -}} +{{- end -}} +{{- end -}} + +{{/* ============ 自检可读性: dump key —— 给 helm template --debug 用, 不影响部署 ============ */}} diff --git a/k8s/helm/topfans/templates/activityservice/deployment.yaml b/k8s/helm/topfans/templates/activityservice/deployment.yaml new file mode 100644 index 0000000..af70999 --- /dev/null +++ b/k8s/helm/topfans/templates/activityservice/deployment.yaml @@ -0,0 +1,85 @@ +# Activity Service — 活动 (微信扫码 / 落地页) +{{- $svc := .Values.services.activityservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-activityservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: activityservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: activityservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: activityservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: activityservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: activityservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: activityservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: activityservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/aichatservice/deployment.yaml b/k8s/helm/topfans/templates/aichatservice/deployment.yaml new file mode 100644 index 0000000..00833d5 --- /dev/null +++ b/k8s/helm/topfans/templates/aichatservice/deployment.yaml @@ -0,0 +1,86 @@ +# AI Chat Service — Dify 对话中转 +{{- $svc := .Values.services.aichatservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-aichatservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: aichatservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: aichatservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: aichatservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: aichatservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + - secretRef: { name: ai-keys } # DIFY_API_BASE / DIFY_API_KEY + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: 30 # AI 启动慢, 给 30s + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: aichatservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: aichatservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: aichatservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/assetservice/deployment.yaml b/k8s/helm/topfans/templates/assetservice/deployment.yaml new file mode 100644 index 0000000..5ea4f36 --- /dev/null +++ b/k8s/helm/topfans/templates/assetservice/deployment.yaml @@ -0,0 +1,86 @@ +# Asset Service — 资产生成 (积分扣减、铸造核心) +{{- $svc := .Values.services.assetservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-assetservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: assetservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: assetservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: assetservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: assetservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + - secretRef: { name: oss-credentials } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: assetservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: assetservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: assetservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/external-db/postgres-external.yaml b/k8s/helm/topfans/templates/external-db/postgres-external.yaml new file mode 100644 index 0000000..ca36c52 --- /dev/null +++ b/k8s/helm/topfans/templates/external-db/postgres-external.yaml @@ -0,0 +1,24 @@ +# =================================================================== +# Postgres ExternalName (Phase 1: 外部托管 RDS) +# =================================================================== +# 应用代码继续用 DB_HOST=postgres 这种短名, K8s 解析成阿里云 RDS endpoint。 +# 这样 Phase 2 换数据库 (例如拆 group 各自独立库) 只需改 ExternalName.target, +# 应用代码不动。 +# =================================================================== +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: external-db + app.kubernetes.io/part-of: topfans + db: postgres +spec: + type: ExternalName + externalName: {{ .Values.global.dbExternalEndpoint | default "postgres-ext.example.com" }} + # 仅做 DNS 解析, 没端口选择器 (ExternalName 不需要) + ports: + - name: postgres + port: {{ .Values.global.dbPort | default "5432" }} + protocol: TCP diff --git a/k8s/helm/topfans/templates/external-db/redis-external.yaml b/k8s/helm/topfans/templates/external-db/redis-external.yaml new file mode 100644 index 0000000..8dda781 --- /dev/null +++ b/k8s/helm/topfans/templates/external-db/redis-external.yaml @@ -0,0 +1,21 @@ +# =================================================================== +# Redis ExternalName (Phase 1: 外部托管 ElastiCache) +# =================================================================== +# 同 postgres, 应用代码继续用 REDIS_HOST=redis 短名即可。 +# =================================================================== +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: external-db + app.kubernetes.io/part-of: topfans + db: redis +spec: + type: ExternalName + externalName: {{ .Values.global.redisExternalEndpoint | default "redis-ext.example.com" }} + ports: + - name: redis + port: {{ .Values.global.redisPort | default "6379" }} + protocol: TCP diff --git a/k8s/helm/topfans/templates/future-services.yaml b/k8s/helm/topfans/templates/future-services.yaml new file mode 100644 index 0000000..15f1663 --- /dev/null +++ b/k8s/helm/topfans/templates/future-services.yaml @@ -0,0 +1,14 @@ +# =================================================================== +# Future Services (Phase 1 占位) +# =================================================================== +# 设计文档 §四.1 列出, 以下 4 个服务 **本阶段不实现**, Phase 1.x 或 Phase 2 +# 启动时, 拆出独立 chart 子目录 (Phase 2: topfans-shared / topfans-group-), +# 或在本 chart 加 templates//deployment.yaml。 +# +# - admin 后台管理 (运营 / 客服), 共享 DB 不走 API +# - review 审核工作流 (UGC 内容审核), 被 gateway / asset 调用 +# - ai-image-gen 镭射卡生成, 当前 gateway 直接调 OpenAI / MiniMax +# - ai-chat AI 对话 (粉丝互动), 与 aichatservice 是不同服务 +# +# 本文件仅起占位作用, 不声明任何 K8s 资源。 +# =================================================================== diff --git a/k8s/helm/topfans/templates/galleryservice/deployment.yaml b/k8s/helm/topfans/templates/galleryservice/deployment.yaml new file mode 100644 index 0000000..ab6219d --- /dev/null +++ b/k8s/helm/topfans/templates/galleryservice/deployment.yaml @@ -0,0 +1,93 @@ +# Gallery Service — 展厅 +{{- $svc := .Values.services.galleryservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-galleryservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: galleryservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: galleryservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: galleryservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: galleryservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + {{- if not (hasSuffix "_SECRET_REF" $k) }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + # MQ_REDIS_PASSWORD 走 db-credentials secret, 不重复声明 + - name: MQ_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: REDIS_PASSWORD + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: galleryservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: galleryservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: galleryservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/gateway/deployment.yaml b/k8s/helm/topfans/templates/gateway/deployment.yaml new file mode 100644 index 0000000..bf3b28b --- /dev/null +++ b/k8s/helm/topfans/templates/gateway/deployment.yaml @@ -0,0 +1,90 @@ +# =================================================================== +# Gateway — API 入口 (Phase 1 单 ns 内唯一流量入口) +# =================================================================== +# 所有外部流量经 nginx-ingress → gateway:8080, +# 内部短 DNS 调各 Dubbo 服务。 +# =================================================================== +{{- $svc := .Values.services.gateway -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-gateway + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: gateway + app.kubernetes.io/role: entrypoint +spec: + {{- if not $svc.hpa.enabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: gateway + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: gateway + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: gateway + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: http + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: + name: jwt-secret + - secretRef: + name: ai-keys # OPENAI_API_KEY / DIFY_API_KEY / SMS + - secretRef: + name: oss-credentials # OSS_* / LANDING_BASE_URL + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "topfans.fullname" . }}-gateway + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: gateway +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: gateway + ports: + - name: http + port: {{ $svc.port }} + targetPort: http + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/hpa/aichatservice.yaml b/k8s/helm/topfans/templates/hpa/aichatservice.yaml new file mode 100644 index 0000000..c6514d6 --- /dev/null +++ b/k8s/helm/topfans/templates/hpa/aichatservice.yaml @@ -0,0 +1,26 @@ +# AI Chat HPA — Dify 调用慢, 给 AI 启动预留更多副本 +{{- $svc := .Values.services.aichatservice -}} +{{- if and $svc.enabled $svc.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "topfans.fullname" . }}-aichatservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: aichatservice +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "topfans.fullname" . }}-aichatservice + minReplicas: {{ $svc.hpa.minReplicas | default 1 }} + maxReplicas: {{ $svc.hpa.maxReplicas | default 5 }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svc.hpa.targetCPU | default 70 }} +{{- end }} diff --git a/k8s/helm/topfans/templates/hpa/gateway.yaml b/k8s/helm/topfans/templates/hpa/gateway.yaml new file mode 100644 index 0000000..d39958f --- /dev/null +++ b/k8s/helm/topfans/templates/hpa/gateway.yaml @@ -0,0 +1,35 @@ +# Gateway HPA — 流量入口, 流量波动大 +{{- $svc := .Values.services.gateway -}} +{{- if and $svc.enabled $svc.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "topfans.fullname" . }}-gateway + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: gateway +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "topfans.fullname" . }}-gateway + minReplicas: {{ $svc.hpa.minReplicas | default 2 }} + maxReplicas: {{ $svc.hpa.maxReplicas | default 10 }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svc.hpa.targetCPU | default 60 }} + behavior: + scaleDown: + stabilizationWindowSeconds: 300 # 5 min 内不连续降 + scaleUp: + stabilizationWindowSeconds: 30 + policies: + - type: Percent + value: 100 + periodSeconds: 60 +{{- end }} diff --git a/k8s/helm/topfans/templates/hpa/notificationservice.yaml b/k8s/helm/topfans/templates/hpa/notificationservice.yaml new file mode 100644 index 0000000..8fb4557 --- /dev/null +++ b/k8s/helm/topfans/templates/hpa/notificationservice.yaml @@ -0,0 +1,26 @@ +# Notification HPA — 推送突发流量 (活动 / 抽奖) +{{- $svc := .Values.services.notificationservice -}} +{{- if and $svc.enabled $svc.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "topfans.fullname" . }}-notificationservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: notificationservice +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "topfans.fullname" . }}-notificationservice + minReplicas: {{ $svc.hpa.minReplicas | default 1 }} + maxReplicas: {{ $svc.hpa.maxReplicas | default 4 }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svc.hpa.targetCPU | default 70 }} +{{- end }} diff --git a/k8s/helm/topfans/templates/ingress.yaml b/k8s/helm/topfans/templates/ingress.yaml new file mode 100644 index 0000000..7605058 --- /dev/null +++ b/k8s/helm/topfans/templates/ingress.yaml @@ -0,0 +1,52 @@ +# =================================================================== +# Ingress — 集群级流量入口 +# =================================================================== +# api.example.com → topfans/gateway:8080 +# 假设 nginx-ingress + cert-manager 已预装 (Phase 1 部署时确认) +# 部署前必须: +# 1. 装 nginx-ingress-controller (Phase 1 — 一次性) +# 2. 装 cert-manager (Phase 1 — 一次性) +# 3. 创建 ClusterIssuer `letsencrypt-prod` (TLS 用) +# =================================================================== +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: topfans-gateway + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: gateway + app.kubernetes.io/role: ingress + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className | default "nginx" }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + {{- if eq .backend "gateway" }} + name: {{ printf "%s-gateway" (include "topfans.fullname" $) | default "topfans-gateway" }} + port: + number: {{ $.Values.services.gateway.port }} + {{- else }} + name: {{ .backend }} + port: + number: {{ (index $.Values.services .backend).port | default 80 }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/k8s/helm/topfans/templates/moderationservice/deployment.yaml b/k8s/helm/topfans/templates/moderationservice/deployment.yaml new file mode 100644 index 0000000..6d68928 --- /dev/null +++ b/k8s/helm/topfans/templates/moderationservice/deployment.yaml @@ -0,0 +1,85 @@ +# Moderation Service — 举报/反馈/自动隐藏 +{{- $svc := .Values.services.moderationservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-moderationservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: moderationservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: moderationservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: moderationservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: moderationservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: moderationservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: moderationservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: moderationservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/notificationservice/deployment.yaml b/k8s/helm/topfans/templates/notificationservice/deployment.yaml new file mode 100644 index 0000000..5db3c26 --- /dev/null +++ b/k8s/helm/topfans/templates/notificationservice/deployment.yaml @@ -0,0 +1,85 @@ +# Notification Service — 通知中心 + 推送 +{{- $svc := .Values.services.notificationservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-notificationservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: notificationservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: notificationservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: notificationservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: notificationservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: ai-keys } # SMS_ACCESS_KEY_ID / SECRET / SIGN_NAME / TEMPLATE_CODE + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/healthz" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/healthz" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: notificationservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: notificationservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: notificationservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/oss-cors-init/job.yaml b/k8s/helm/topfans/templates/oss-cors-init/job.yaml new file mode 100644 index 0000000..acb49c6 --- /dev/null +++ b/k8s/helm/topfans/templates/oss-cors-init/job.yaml @@ -0,0 +1,43 @@ +# =================================================================== +# oss-cors-init — 一次性 Job, 给 OSS bucket 推 CORS (POST 直传必需) +# =================================================================== +# Phase 1 部署 / 升级 (cluster-admin upgrade) 前用 helm hook 跑一次, +# 与 docker-compose.prod.yml 中 restart: "no" 的语义对齐 — 跑完即退, 不留 Pod。 +# +# 触发: +# helm install / helm upgrade 时自动跑 (pre-install, pre-upgrade) +# =================================================================== +{{- $job := .Values.services.ossCorsInit -}} +{{- if $job.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "topfans.fullname" . }}-oss-cors-init + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: oss-cors-init + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-10" # 在 -5 之前跑 + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 0 # 不重试, 失败立刻 fail (deploy.sh 能看到错误) + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: oss-cors-init + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + restartPolicy: Never + containers: + - name: oss-cors-init + image: {{ include "topfans.image" (dict "Values" .Values "service" $job.image.repositoryName "tag" $job.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + envFrom: + - secretRef: { name: oss-credentials } + resources: + {{- toYaml $job.resources | nindent 12 }} +{{- end }} diff --git a/k8s/helm/topfans/templates/pg-sequence-sync.yaml b/k8s/helm/topfans/templates/pg-sequence-sync.yaml new file mode 100644 index 0000000..4efeae6 --- /dev/null +++ b/k8s/helm/topfans/templates/pg-sequence-sync.yaml @@ -0,0 +1,127 @@ +# =================================================================== +# PostgreSQL 序列同步 (Phase 1 切流量前必跑 — Helm hook 自动触发) +# =================================================================== +# CLAUDE.md 强制规范 + 设计文档 §10.3 hard blocker: +# 任何手动 INSERT id 必须 setval, 否则后续 GORM 报 duplicate key。 +# 切流量前必须 sync 所有 BIGSERIAL 表。 +# +# 本模板作为 Helm hook, 在 helm install / upgrade 时自动跑: +# 1. 创建 ConfigMap 含脚本 +# 2. 创建一次性 Job, 跑完退出 +# 3. 失败时 helm 中断, deploy.sh / CI 看到非 0 +# =================================================================== +{{- if .Values.pgSequenceSync.enabled }} +{{- $tables := join "," .Values.pgSequenceSync.tables -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "topfans.fullname" . }}-pg-sequence-sync-script + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: pg-sequence-sync + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-9" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +data: + script.sh: | + #!/bin/bash + set -euo pipefail + PG_HOST="${PG_HOST:-postgres}" + PG_PORT="${PG_PORT:-5432}" + PG_USER="${PG_USER:-postgres}" + PG_DB="${PG_DB:-topfans}" + PG_PASSWORD="${PG_PASSWORD:?must be provided by secret}" + TABLES_CSV="{{ $tables }}" + + IFS=',' read -ra TABLES <<< "$TABLES_CSV" + echo "Syncing {{ len .Values.pgSequenceSync.tables }} tables" + + bad=0 + for tbl in "${TABLES[@]}"; do + tbl=$(echo "$tbl" | tr -d ' ') + [ -z "$tbl" ] && continue + max_id=$(PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc "SELECT COALESCE(MAX(id), 0) FROM ${tbl};") + if [ "$max_id" = "0" ]; then + echo " ${tbl}: empty, skip" + continue + fi + PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -c "SELECT setval('${tbl}_id_seq', ${max_id}, true);" + h=$(PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc "SELECT last_value >= ${max_id} FROM pg_sequences WHERE sequencename = '${tbl}_id_seq';") + if [ "$h" = "t" ]; then + echo " ✅ ${tbl}_id_seq → ${max_id}" + else + echo " ❌ ${tbl}_id_seq failed to sync" + bad=$((bad+1)) + fi + done + + if [ "$bad" -gt 0 ]; then + echo "❌ ${bad} sequences unhealthy, abort deployment" + exit 1 + fi + echo "✅ all sequences healthy" +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "topfans.fullname" . }}-pg-sequence-sync + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: pg-sequence-sync + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-8" # 在 ConfigMap 之后 + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + ttlSecondsAfterFinished: 600 + backoffLimit: 0 + template: + spec: + restartPolicy: Never + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: pg-sync + image: alpine:3.19 + command: ["/bin/sh", "-c"] + args: + - | + set -e + apk add --no-cache postgresql16-client bash > /dev/null + bash /scripts/script.sh + env: + - name: PG_HOST + value: "postgres" + - name: PG_PORT + value: "5432" + - name: PG_USER + value: {{ .Values.global.dbUser | default "postgres" | quote }} + - name: PG_DB + value: {{ .Values.global.dbName | default "topfans" | quote }} + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: DB_PASSWORD + - name: TABLES_CSV + value: "{{ $tables }}" + volumeMounts: + - name: script + mountPath: /scripts + readOnly: true + resources: + {{- toYaml .Values.pgSequenceSync.resources | nindent 10 }} + volumes: + - name: script + configMap: + name: {{ include "topfans.fullname" . }}-pg-sequence-sync-script + defaultMode: 0o755 + securityContext: + runAsNonRoot: true + runAsUser: 65534 +{{- end }} diff --git a/k8s/helm/topfans/templates/secrets/ai-keys.yaml b/k8s/helm/topfans/templates/secrets/ai-keys.yaml new file mode 100644 index 0000000..34242ce --- /dev/null +++ b/k8s/helm/topfans/templates/secrets/ai-keys.yaml @@ -0,0 +1,42 @@ +# =================================================================== +# AI / SMS / OpenAI 兼容 API 一站式 Secret +# =================================================================== +# 集中管理所有外部 API 凭据, 各服务按需 secretKeyRef 引用。 +# ------------------------------------------------------------------- +# ⚠️ 真值不进 git! 改动这些字段: +# - 用 helm upgrade --set secrets.ai.xxx=... +# - 或 CI/CD 注入 +# =================================================================== +apiVersion: v1 +kind: Secret +metadata: + name: ai-keys + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: secret + app.kubernetes.io/part-of: topfans + secret-type: ai-keys +type: Opaque +stringData: + # Dify (aichatservice 用) + DIFY_API_BASE: {{ .Values.secrets.ai.difyApiBase | default "http://dify-ext:5001/v1" | quote }} + DIFY_API_KEY: {{ .Values.secrets.ai.difyApiKey | required "secrets.ai.difyApiKey 必填" | quote }} + + # MiniMax (gateway 镭射卡生成 用, 当 LASER_GEN_PROVIDER=minimax 时) + MINIMAX_API_KEY: {{ .Values.secrets.ai.minimaxApiKey | default "" | quote }} + MINIMAX_API_URL: {{ .Values.secrets.ai.minimaxApiUrl | default "https://api.minimaxi.com/v1/image_generation" | quote }} + + # OpenAI 兼容 API (微达API中转站, gateway 镭射卡生成 默认) + OPENAI_API_KEY: {{ .Values.secrets.ai.openaiApiKey | required "secrets.ai.openaiApiKey 必填" | quote }} + OPENAI_BASE_URL: {{ .Values.secrets.ai.openaiBaseUrl | default "https://api.weda.cc/v1" | quote }} + OPENAI_MODEL: {{ .Values.secrets.ai.openaiModel | default "gpt-image-2" | quote }} + + # (预留) Qwen / 其他 AI 平台 - Phase 2/3 用 + QWEN_API_KEY: {{ .Values.secrets.ai.qwenApiKey | default "" | quote }} + + # SMS (阿里云, 通知服务用) + SMS_ACCESS_KEY_ID: {{ .Values.secrets.sms.accessKeyId | default "" | quote }} + SMS_ACCESS_KEY_SECRET: {{ .Values.secrets.sms.accessKeySecret | default "" | quote }} + SMS_SIGN_NAME: {{ .Values.secrets.sms.signName | default "" | quote }} + SMS_TEMPLATE_CODE: {{ .Values.secrets.sms.templateCode | default "" | quote }} + SMS_REGION: {{ .Values.secrets.sms.region | default "cn-hangzhou" | quote }} diff --git a/k8s/helm/topfans/templates/secrets/db-credentials.yaml b/k8s/helm/topfans/templates/secrets/db-credentials.yaml new file mode 100644 index 0000000..5300efe --- /dev/null +++ b/k8s/helm/topfans/templates/secrets/db-credentials.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: db-credentials + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: secret + app.kubernetes.io/part-of: topfans + secret-type: db-credentials +type: Opaque +stringData: + # 真值从 values-prod.yaml 注入 (或用 External Secrets Operator / SOPS 替换) + # 这些密钥的更新走 kubectl edit secret / helm upgrade 重新加密传递, 不进 git + DB_PASSWORD: {{ .Values.secrets.db.password | default "" | quote }} + REDIS_PASSWORD: {{ .Values.secrets.redis.password | default "" | quote }} diff --git a/k8s/helm/topfans/templates/secrets/jwt-secret.yaml b/k8s/helm/topfans/templates/secrets/jwt-secret.yaml new file mode 100644 index 0000000..5fdbf8e --- /dev/null +++ b/k8s/helm/topfans/templates/secrets/jwt-secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: jwt-secret + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: secret + app.kubernetes.io/part-of: topfans + secret-type: jwt +type: Opaque +stringData: + JWT_SECRET: {{ .Values.secrets.jwt.secret | required "secrets.jwt.secret 必须填写, 来自 values-prod.yaml" | quote }} diff --git a/k8s/helm/topfans/templates/secrets/oss-credentials.yaml b/k8s/helm/topfans/templates/secrets/oss-credentials.yaml new file mode 100644 index 0000000..8df1714 --- /dev/null +++ b/k8s/helm/topfans/templates/secrets/oss-credentials.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Secret +metadata: + name: oss-credentials + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + app.kubernetes.io/component: secret + app.kubernetes.io/part-of: topfans + secret-type: oss +type: Opaque +stringData: + OSS_REGION: {{ .Values.secrets.oss.region | default "cn-shanghai" | quote }} + OSS_BUCKET_NAME: {{ .Values.secrets.oss.bucket | default "" | quote }} + OSS_ACCESS_KEY_ID: {{ .Values.secrets.oss.accessKeyId | default "" | quote }} + OSS_ACCESS_KEY_SECRET: {{ .Values.secrets.oss.accessKeySecret | default "" | quote }} + OSS_STS_ROLE_ARN: {{ .Values.secrets.oss.stsRoleArn | default "" | quote }} + OSS_AVATAR_DIR: {{ .Values.secrets.oss.avatarDir | default "avatar/" | quote }} + OSS_ASSET_DIR: {{ .Values.secrets.oss.assetDir | default "asset/" | quote }} + OSS_TOKEN_EXPIRE_TIME: {{ .Values.secrets.oss.tokenExpireTime | default "3600" | quote }} + # 落地页 base url (分享服务用) + LANDING_BASE_URL: {{ .Values.secrets.landingBaseUrl | default "https://api.example.com" | quote }} diff --git a/k8s/helm/topfans/templates/socialservice/deployment.yaml b/k8s/helm/topfans/templates/socialservice/deployment.yaml new file mode 100644 index 0000000..6d326e8 --- /dev/null +++ b/k8s/helm/topfans/templates/socialservice/deployment.yaml @@ -0,0 +1,85 @@ +# Social Service — 社交关系 +{{- $svc := .Values.services.socialservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-socialservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: socialservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: socialservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: socialservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: socialservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: socialservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: socialservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: socialservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/starbookservice/deployment.yaml b/k8s/helm/topfans/templates/starbookservice/deployment.yaml new file mode 100644 index 0000000..0284c1c --- /dev/null +++ b/k8s/helm/topfans/templates/starbookservice/deployment.yaml @@ -0,0 +1,86 @@ +# Starbook Service — 星簿 +{{- $svc := .Values.services.starbookservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-starbookservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: starbookservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: starbookservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: starbookservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: starbookservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + - secretRef: { name: oss-credentials } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: starbookservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: starbookservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: starbookservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/statisticservice/deployment.yaml b/k8s/helm/topfans/templates/statisticservice/deployment.yaml new file mode 100644 index 0000000..205b2fb --- /dev/null +++ b/k8s/helm/topfans/templates/statisticservice/deployment.yaml @@ -0,0 +1,107 @@ +# Statistic Service — 数据看板 (独立 schema: statistic) +{{- $svc := .Values.services.statisticservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-statisticservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: statisticservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: statisticservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: statisticservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: statisticservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + # statistic 用独立 schema, 不复用 common-env 的 DB_* + - {name: GIN_MODE, value: {{ $.Values.global.ginMode | default "release" | quote }} } + - {name: ENV, value: {{ $.Values.global.env | default "production" | quote }} } + - {name: LOG_LEVEL, value: {{ $.Values.global.logLevel | default "info" | quote }} } + - {name: PORT, value: {{ $svc.port | quote }} } + # DB (复用 db-credentials) + - {name: STATISTIC_DB_HOST, value: "postgres" } + - {name: STATISTIC_DB_PORT, value: "5432" } + - {name: STATISTIC_DB_NAME, value: "topfans" } + - {name: STATISTIC_DB_SCHEMA, value: "statistic" } + - {name: STATISTIC_DB_SSLMODE, value: "disable" } + - name: STATISTIC_DB_USER + value: {{ .Values.global.dbUser | default "postgres" | quote }} + - name: STATISTIC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: DB_PASSWORD + # Redis + - {name: STATISTIC_REDIS_HOST, value: "redis" } + - {name: STATISTIC_REDIS_PORT, value: "6379" } + - {name: STATISTIC_REDIS_DB, value: "0" } + - name: STATISTIC_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: REDIS_PASSWORD + # 跨服务 + - {name: USER_SERVICE_URL, value: "tri://userservice:20000" } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/healthz" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/healthz" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: statisticservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: statisticservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: statisticservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/taskservice/deployment.yaml b/k8s/helm/topfans/templates/taskservice/deployment.yaml new file mode 100644 index 0000000..b40fc2e --- /dev/null +++ b/k8s/helm/topfans/templates/taskservice/deployment.yaml @@ -0,0 +1,92 @@ +# Task Service — 异步任务 +{{- $svc := .Values.services.taskservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-taskservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: taskservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: taskservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: taskservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: taskservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + {{- if not (hasSuffix "_SECRET_REF" $k) }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- end }} + - name: MQ_REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: REDIS_PASSWORD + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: taskservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: taskservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: taskservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/templates/userservice/deployment.yaml b/k8s/helm/topfans/templates/userservice/deployment.yaml new file mode 100644 index 0000000..a7d2bb1 --- /dev/null +++ b/k8s/helm/topfans/templates/userservice/deployment.yaml @@ -0,0 +1,85 @@ +# User Service — 用户服务 +{{- $svc := .Values.services.userservice -}} +{{- if $svc.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "topfans.fullname" . }}-userservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: userservice +spec: + {{- $hpaEnabled := false -}} + {{- if hasKey $svc "hpa" -}} + {{- if hasKey $svc.hpa "enabled" -}} + {{- $hpaEnabled = $svc.hpa.enabled -}} + {{- end -}} + {{- end -}} + {{- if not $hpaEnabled }} + replicas: {{ $svc.replicas | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "topfans.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: userservice + template: + metadata: + labels: + {{- include "topfans.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: userservice + spec: + serviceAccountName: {{ include "topfans.serviceAccountName" . }} + containers: + - name: userservice + image: {{ include "topfans.image" (dict "Values" .Values "service" $svc.image.repositoryName "tag" $svc.image.tag) }} + imagePullPolicy: {{ .Values.global.image.pullPolicy | default "IfNotPresent" }} + ports: + - name: dubbo + containerPort: {{ $svc.port }} + env: + {{- include "topfans.commonEnv" . | nindent 10 }} + {{- range $k, $v := $svc.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + envFrom: + - secretRef: { name: jwt-secret } + livenessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultLiveness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultLiveness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultLiveness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultLiveness.failureThreshold }} + readinessProbe: + httpGet: + path: {{ $svc.healthPath | default "/health" }} + port: {{ $svc.port }} + initialDelaySeconds: {{ $.Values.probes.defaultReadiness.initialDelaySeconds }} + periodSeconds: {{ $.Values.probes.defaultReadiness.periodSeconds }} + timeoutSeconds: {{ $.Values.probes.defaultReadiness.timeoutSeconds }} + failureThreshold: {{ $.Values.probes.defaultReadiness.failureThreshold }} + resources: + {{- toYaml $svc.resources | nindent 10 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: userservice + namespace: {{ .Values.namespace.name | default "topfans" }} + labels: + {{- include "topfans.labels" . | nindent 4 }} + app.kubernetes.io/component: userservice +spec: + type: ClusterIP + selector: + {{- include "topfans.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: userservice + ports: + - name: dubbo + port: {{ $svc.port }} + targetPort: dubbo + protocol: TCP +{{- end }} diff --git a/k8s/helm/topfans/values-prod.example.yaml b/k8s/helm/topfans/values-prod.example.yaml new file mode 100644 index 0000000..6452302 --- /dev/null +++ b/k8s/helm/topfans/values-prod.example.yaml @@ -0,0 +1,75 @@ +# =================================================================== +# TopFans Helm — 生产 values 模板 (空值, 真值不入 git) +# =================================================================== +# 使用: +# 1. cp values-prod.example.yaml values-prod.yaml +# 2. 填入真值 (DB_PASSWORD, API Key 等) +# 3. (可选) sops -e values-prod.yaml > values-prod.sops.yaml +# 或直接由 CI 注入 +# 4. helm install topfans ./helm/topfans -f values-prod.yaml +# +# 真值文件 values-prod.yaml 必须加入 .gitignore! 提交时被 reject。 +# =================================================================== + +global: + image: + registry: registry.cn-shanghai.aliyuncs.com + repositoryNamespace: topfans + + env: production + ginMode: release + logLevel: info + + dbHost: postgres + dbPort: "5432" + dbUser: postgres + dbName: topfans + dbSslmode: disable + redisHost: redis + redisPort: "6379" + redisDb: "0" + +# ---------- 每个服务的 image tag 由 CI 注入 ---------- +services: + gateway: + image: { tag: "" } # ← CI 写入实际版本 (v1.0.0 / 8e3f...) + userservice: { image: { tag: "" } } + assetservice: { image: { tag: "" } } + socialservice: { image: { tag: "" } } + galleryservice: { image: { tag: "" } } + activityservice: { image: { tag: "" } } + starbookservice: { image: { tag: "" } } + taskservice: { image: { tag: "" } } + aichatservice: { image: { tag: "" } } + statisticservice: { image: { tag: "" } } + notificationservice: { image: { tag: "" } } + moderationservice: { image: { tag: "" } } + ossCorsInit: { image: { tag: "" } } + +# ---------- Ingress 真域名 ---------- +ingress: + enabled: true + className: nginx + hosts: + - host: api.example.com + paths: + - path: / + pathType: Prefix + backend: gateway + tls: + - hosts: + - api.example.com + secretName: topfans-tls + +# ---------- 真实序列同步表 (切流量前补全) ---------- +pgSequenceSync: + tables: + - assets + - asset_registry + - users + - stars + - activity_assets + - collection_assets + - materials + - exhibitions + - galleries diff --git a/k8s/helm/topfans/values.yaml b/k8s/helm/topfans/values.yaml new file mode 100644 index 0000000..018fdcb --- /dev/null +++ b/k8s/helm/topfans/values.yaml @@ -0,0 +1,388 @@ +# =================================================================== +# TopFans Helm Chart — 默认 values (Phase 1) +# =================================================================== +# 真实部署时用 values-prod.yaml 覆盖以下占位符: +# helm install topfans ./helm/topfans -f values-prod.yaml +# +# 严禁把含真密码 / 真 API Key 的 values-prod.yaml 提交到 git! +# .gitignore 已加入 *.values-prod.yaml / k8s/helm/**/values-prod.yaml +# 真值由 CI/CD 或 External Secrets Operator 注入。 +# =================================================================== + +# ---------- Global ---------- +# Namespace (Phase 1: 全部在 topfans 单 ns, Phase 2 会拆成 topfans-shared / topfans-group-*) +namespace: + name: topfans + +# ---------- Global ---------- +global: + # 镜像仓库 (§10.1 选项 A: 阿里云 ACR 推荐) + # 推送路径模板: registry.cn-shanghai.aliyuncs.com/topfans/ + # 临时改 registry: helm install -f values-prod.yaml --set global.image.registry=... + image: + registry: registry.cn-shanghai.aliyuncs.com + repositoryNamespace: topfans + # 例如最终 gateway image: registry.cn-shanghai.aliyuncs.com/topfans/gateway:v1.0.0 + pullPolicy: IfNotPresent + # tag 在每个服务下覆盖; 默认 latest 不可用于生产 + + # 环境 (prod / staging / dev) + env: production + ginMode: release + logLevel: info + + # DB / Redis 走 ExternalName → RDS / ElastiCache (Phase 1 强制) + dbHost: postgres # K8s 内的 short DNS, 对应 templates/external-db/postgres-external.yaml + dbPort: "5432" + dbUser: postgres + dbName: topfans + dbSslmode: disable + redisHost: redis # K8s 内的 short DNS, 对应 templates/external-db/redis-external.yaml + redisPort: "6379" + redisDb: "0" + +# ---------- ServiceAccount ---------- +# Phase 1 服务都不需要访问 K8s API, 创建但保持最小权限 +serviceAccount: + create: true + name: topfans + annotations: {} + +# ---------- Secrets 真值 (默认空, 由 values-prod.yaml 注入) ---------- +secrets: + db: + password: "" # 来自 values-prod.yaml + redis: + password: "" # 来自 values-prod.yaml + jwt: + secret: "" # 来自 values-prod.yaml + oss: + region: "cn-shanghai" + bucket: "" + accessKeyId: "" + accessKeySecret: "" + stsRoleArn: "" + avatarDir: "avatar/" + assetDir: "asset/" + tokenExpireTime: "3600" + landingBaseUrl: "https://api.example.com" + ai: + difyApiBase: "http://dify-ext:5001/v1" + difyApiKey: "" + minimaxApiKey: "" + minimaxApiUrl: "https://api.minimaxi.com/v1/image_generation" + openaiApiKey: "" + openaiBaseUrl: "https://api.weda.cc/v1" + openaiModel: "gpt-image-2" + qwenApiKey: "" + sms: + accessKeyId: "" + accessKeySecret: "" + signName: "" + templateCode: "" + region: "cn-hangzhou" + +# ---------- 13 个服务统一 schema ---------- +# 为减少重复, 每个服务块用同一组子键: +# enabled — 是否部署 (Phase 1 全 true, future-services 目录除外) +# replicas — Pod 副本数 (HPA 模式下会被 HPA 覆盖 min/max) +# port — 服务端口 (用于 Service + Dubbo URL + livenessProbe) +# healthPath — 健康检查路径 (有的服务是 /health, statistic 是 /healthz) +# healthPort — 健康检查端口 (默认同 port; moderationservice 例外) +# image — 子块: repositoryName / tag / pullPolicy +# resources — requests / limits (CPU + memory), 4G/2C 单机裁剪版 +# env — 业务专属 env (覆盖 common-env) +# 使用纯字符串, 不写敏感 (敏感走 Secret) +# probe — 子块: livenessProbe / readinessProbe 阈值 (默认给了合理默认值) + +services: + # ============== Gateway (流量入口, 启用 HPA) ============== + gateway: + enabled: true + replicas: 2 + port: 8080 + healthPath: /health + image: + repositoryName: gateway + tag: latest # CI 覆盖 + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 256Mi } + hpa: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPU: 60 + env: + SERVER_PORT: "8080" + # 以下 Dubbo URL 是 K8s short DNS, 与原 docker-compose 对齐 (应用代码零改动) + DUBBO_USER_SERVICE_URL: tri://userservice:20000 + DUBBO_SOCIAL_SERVICE_URL: tri://socialservice:20002 + DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003 + DUBBO_GALLERY_SERVICE_URL: tri://galleryservice:20001 + DUBBO_ACTIVITY_SERVICE_URL: tri://activityservice:20004 + DUBBO_TASK_SERVICE_URL: tri://taskservice:20006 + DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20005 + DUBBO_AI_CHAT_SERVICE_URL: tri://aichatservice:20008 + DUBBO_STATISTIC_SERVICE_URL: tri://statisticservice:20009 + DUBBO_NOTIFICATION_SERVICE_URL: tri://notificationservice:20010 + DUBBO_MODERATION_SERVICE_URL: tri://moderationservice:20011 + LASER_GEN_PROVIDER: openai + # OPENAI_BASE_URL / OPENAI_MODEL 来自 envFrom secret (ai-keys) + # OPENAI_API_KEY 来自 envFrom secret (ai-keys) + + # ============== 数据服务 ============== + userservice: + enabled: true + replicas: 2 + port: 20000 + healthPath: /health + healthPort: 20000 + image: { repositoryName: userservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 150Mi } + env: + PORT: "20000" + STATISTIC_SERVICE_URL: tri://statisticservice:20009 + + socialservice: + enabled: true + replicas: 1 + port: 20002 + healthPath: /health + healthPort: 20002 + image: { repositoryName: socialservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 150Mi } + env: + PORT: "20002" + USER_SERVICE_URL: tri://userservice:20000 + ASSET_SERVICE_URL: tri://assetservice:20003 + STATISTIC_SERVICE_URL: tri://statisticservice:20009 + + assetservice: + enabled: true + replicas: 2 + port: 20003 + healthPath: /health + healthPort: 20003 + image: { repositoryName: assetservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 200Mi } + env: + PORT: "20003" + USER_SERVICE_URL: tri://userservice:20000 + STATISTIC_SERVICE_URL: tri://statisticservice:20009 + + galleryservice: + enabled: true + replicas: 2 + port: 20001 + healthPath: /health + healthPort: 20001 + image: { repositoryName: galleryservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 150Mi } + env: + PORT: "20001" + USER_SERVICE_URL: tri://userservice:20000 + ASSET_SERVICE_URL: tri://assetservice:20003 + TASK_SERVICE_URL: tri://taskservice:20006 + STATISTIC_SERVICE_URL: tri://statisticservice:20009 + MQ_REDIS_ADDR: redis:6379 + MQ_REDIS_DB: "2" + MQ_REDIS_PASSWORD_SECRET_REF: db-credentials + + activityservice: + enabled: true + replicas: 1 + port: 20004 + healthPath: /health + healthPort: 20004 + image: { repositoryName: activityservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 32Mi } + limits: { cpu: 500m, memory: 100Mi } + env: + PORT: "20004" + USER_SERVICE_URL: tri://userservice:20000 + + starbookservice: + enabled: true + replicas: 1 + port: 20005 + healthPath: /health + healthPort: 20005 + image: { repositoryName: starbookservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 150Mi } + env: + PORT: "20005" + ASSET_SERVICE_URL: tri://assetservice:20003 + + taskservice: + enabled: true + replicas: 1 + port: 20006 + healthPath: /health + healthPort: 20006 + image: { repositoryName: taskservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 500m, memory: 150Mi } + env: + PORT: "20006" + USER_SERVICE_URL: tri://userservice:20000 + GALLERY_SERVICE_URL: tri://galleryservice:20001 + STATISTIC_SERVICE_URL: tri://statisticservice:20009 + MQ_REDIS_ADDR: redis:6379 + MQ_REDIS_DB: "2" + MQ_REDIS_PASSWORD_SECRET_REF: db-credentials + + aichatservice: + enabled: true + replicas: 1 + port: 20008 + healthPath: /health + healthPort: 20008 + image: { repositoryName: aichatservice, tag: latest } + resources: + requests: { cpu: 250m, memory: 256Mi } + limits: { cpu: 1000m, memory: 512Mi } + hpa: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPU: 70 + env: + PORT: "20008" + # DIFY_API_BASE / DIFY_API_KEY 由 ai-keys Secret 注入 + + statisticservice: + enabled: true + replicas: 1 + port: 20009 + healthPath: /healthz + healthPort: 20009 + image: { repositoryName: statisticservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 300Mi } + env: + PORT: "20009" + STATISTIC_DB_HOST: postgres + STATISTIC_DB_PORT: "5432" + STATISTIC_DB_NAME: topfans + STATISTIC_DB_SCHEMA: statistic + STATISTIC_DB_SSLMODE: disable + STATISTIC_REDIS_HOST: redis + STATISTIC_REDIS_PORT: "6379" + STATISTIC_REDIS_DB: "0" + # 注意: STATISTIC_DB_PASSWORD / STATISTIC_REDIS_PASSWORD 用 secretKeyRef, 见 templates/*/statisticservice.yaml + USER_SERVICE_URL: tri://userservice:20000 + + notificationservice: + enabled: true + replicas: 1 + port: 20010 + healthPath: /healthz + healthPort: 20010 + image: { repositoryName: notificationservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 300Mi } + hpa: + enabled: true + minReplicas: 1 + maxReplicas: 4 + targetCPU: 70 + env: + PORT: "20010" + PUSH_ENABLED: "true" + PUSH_URL: "https://env-00jy6bcqqwy6.dev-hz.cloudbasefunction.cn/sendMessage" + PUSH_TIMEOUT_MS: "4000" + + moderationservice: + enabled: true + replicas: 1 + port: 20011 + healthPath: / # 文档 §10.4 提到原 compose 是 / 无 healthz path, 保留兼容 + healthPort: 20011 + image: { repositoryName: moderationservice, tag: latest } + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: 500m, memory: 300Mi } + env: + PORT: "20011" + DUBBO_USER_SERVICE_URL: tri://userservice:20000 + DUBBO_ASSET_SERVICE_URL: tri://assetservice:20003 + DUBBO_NOTIFICATION_SERVICE_URL: tri://notificationservice:20010 + + # ============== 一次性 Job (oss-cors-init) ============== + # 通过 helm hook 在 pre-install / pre-upgrade 时跑一次, 成功后不再重启 + ossCorsInit: + enabled: true + image: { repositoryName: oss-cors-init, tag: latest } + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 300m, memory: 256Mi } + +# ---------- 探针默认参数 (每个服务可覆盖) ---------- +probes: + defaultLiveness: + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + defaultReadiness: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +# ---------- Ingress ---------- +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "20m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "60" + nginx.ingress.kubernetes.io/proxy-send-timeout: "60" + # cert-manager (Phase 1 部署时已经预装) + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: api.example.com # 部署时改成真域名 + paths: + - path: / + pathType: Prefix + backend: gateway + tls: + - hosts: + - api.example.com + secretName: topfans-tls + +# ---------- PG 序列同步 (切流量前必跑) ---------- +pgSequenceSync: + enabled: true + # 列出所有 BIGSERIAL 的表, 脚本会逐个 setval(... _id_seq, (SELECT MAX(id) FROM ...)) + # 与 backend/scripts/... 等手动同步脚本同源, 详见 k8s/jobs/pg-sequence-sync/job.yaml + tables: + - assets + - asset_registry + - users + - stars + - activity_assets + - collection_assets + - materials + - exhibitions + - galleries + # 脚本运行后验证: SELECT ... WHERE is_healthy=true 才视为成功 + verifyOnRun: true + resources: + requests: { cpu: 100m, memory: 64Mi } + limits: { cpu: 300m, memory: 128Mi } diff --git a/k8s/jobs/pg-sequence-sync/job-template.yaml b/k8s/jobs/pg-sequence-sync/job-template.yaml new file mode 100644 index 0000000..8da128c --- /dev/null +++ b/k8s/jobs/pg-sequence-sync/job-template.yaml @@ -0,0 +1,138 @@ +# =================================================================== +# PostgreSQL 序列同步 Job 模板 +# =================================================================== +# 这个是"裸"模板, 部署时 helm hook 在 pre-install / pre-upgrade 时跑一次。 +# Chart 内部 templates/ 也有一个对应的模板走 helm hook, 两种用法: +# +# 1. helm install/upgrade 自动触发 (推荐) +# → 见 templates/pg-sequence-sync.yaml (已挂 helm hook) +# +# 2. 单独手动跑 (紧急 / 调试 / 补单) +# → kubectl apply -f k8s/jobs/pg-sequence-sync/manual-job.yaml +# (manual-job.yaml 从本模板渲染, 把 ${TABLES_CSV} 替换成实际值) +# +# ⚠️ 切流量前必跑 (CLAUDE.md 强制规范, 设计文档 §10.3 hard blocker) +# =================================================================== +apiVersion: v1 +kind: ConfigMap +metadata: + name: topfans-pg-sequence-sync-script + namespace: topfans + labels: + app.kubernetes.io/component: pg-sequence-sync + app.kubernetes.io/part-of: topfans +data: + # 完全展开后的脚本: kubectl create cm -n topfans --from-file=script.sh=script.sh + script.sh: | + #!/bin/bash + set -euo pipefail + + PG_HOST="${PG_HOST:-postgres}" + PG_PORT="${PG_PORT:-5432}" + PG_USER="${PG_USER:-postgres}" + PG_DB="${PG_DB:-topfans}" + PG_PASSWORD="${PG_PASSWORD:?PG_PASSWORD 必须通过 envFrom secret 注入}" + TABLES_CSV="${TABLES_CSV:-assets,asset_registry,users,stars,activity_assets,collection_assets,materials,exhibitions,galleries}" + + IFS=',' read -ra TABLES <<< "$TABLES_CSV" + echo "Tables to sync: ${#TABLES[@]}" + + for tbl in "${TABLES[@]}"; do + tbl=$(echo "$tbl" | tr -d ' ') + [ -z "$tbl" ] && continue + max_id=$(PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc "SELECT COALESCE(MAX(id), 0) FROM ${tbl};") + [ "$max_id" = "0" ] && { echo " ${tbl}: empty, skip"; continue; } + PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -c "SELECT setval('${tbl}_id_seq', ${max_id}, true);" + echo " ${tbl}: setval(${tbl}_id_seq, ${max_id}, true)" + done + + # Verify + bad=0 + for tbl in "${TABLES[@]}"; do + tbl=$(echo "$tbl" | tr -d ' ') + [ -z "$tbl" ] && continue + h=$(PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc "SELECT last_value >= COALESCE((SELECT MAX(id) FROM ONLY ${tbl}), 0) FROM pg_sequences WHERE sequencename = '${tbl}_id_seq';") + if [ "$h" != "t" ]; then + echo " ❌ ${tbl}_id_seq unhealthy" + bad=$((bad+1)) + else + echo " ✅ ${tbl}_id_seq healthy" + fi + done + [ "$bad" -gt 0 ] && { echo "❌ ${bad} unhealthy"; exit 1; } + echo "✅ all healthy" +--- +apiVersion: v1 +kind: Secret +metadata: + name: pg-sequence-sync-pg-password + namespace: topfans + labels: + app.kubernetes.io/component: pg-sequence-sync +type: Opaque +stringData: + PG_PASSWORD: "" # ← 必须从 db-credentials 同步或 CI 注入 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: topfans-pg-sequence-sync + namespace: topfans + labels: + app.kubernetes.io/component: pg-sequence-sync + app.kubernetes.io/part-of: topfans + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" # 在 oss-cors-init (-10) 之后跑, 但顺序不重要 + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + ttlSecondsAfterFinished: 600 + backoffLimit: 0 + template: + spec: + restartPolicy: Never + # postgresql-client 镜像: 用 bitnami/postgresql:latest 仅 client 部分, 不带 server + # 或 alpine + apk add postgresql-client。 这里用 Google 维护的轻量镜像 + containers: + - name: psql + image: alpine:3.19 + command: ["/bin/sh", "-c"] + args: + - | + set -e + apk add --no-cache postgresql16-client bash >/dev/null + bash /scripts/script.sh + env: + - name: PG_HOST + value: "postgres" + - name: PG_PORT + value: "5432" + - name: PG_USER + value: "postgres" + - name: PG_DB + value: "topfans" + - name: PG_PASSWORD + valueFrom: + secretKeyRef: + name: db-credentials + key: DB_PASSWORD + - name: TABLES_CSV + value: "assets,asset_registry,users,stars,activity_assets,collection_assets,materials,exhibitions,galleries" + volumeMounts: + - name: script + mountPath: /scripts + readOnly: true + volumes: + - name: script + configMap: + name: topfans-pg-sequence-sync-script + defaultMode: 0o755 + securityContext: + runAsNonRoot: true + runAsUser: 65534 # nobody diff --git a/k8s/jobs/pg-sequence-sync/script.sh b/k8s/jobs/pg-sequence-sync/script.sh new file mode 100755 index 0000000..434bdd8 --- /dev/null +++ b/k8s/jobs/pg-sequence-sync/script.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# =================================================================== +# PostgreSQL 序列同步脚本 (Phase 1 切流量前必跑 — CLAUDE.md 强制规范) +# =================================================================== +# 用途: +# 对所有 BIGSERIAL 主键的表,把 postgres 序列的 last_value 推进到 MAX(id), +# 防止应用代码 INSERT 时报 duplicate key value violates unique constraint +# +# 调用: +# kubectl create job ... +# 或 helm hook (pre-install / pre-upgrade 自动跑, 失败则 helm install 中断) +# +# 退出码: +# 0 = 全部 healthy +# 1 = 有 unhealthy 序列, 切流量会失败, 必须排查 +# =================================================================== +set -euo pipefail + +PG_HOST="${PG_HOST:-postgres}" +PG_PORT="${PG_PORT:-5432}" +PG_USER="${PG_USER:-postgres}" +PG_DB="${PG_DB:-topfans}" +PG_PASSWORD="${PG_PASSWORD:?PG_PASSWORD 必须通过 envFrom secret 注入}" + +# 由 ConfigMap / values 注入 +TABLES_CSV="${TABLES_CSV:-assets,asset_registry,users,stars,activity_assets,collection_assets,materials,exhibitions,galleries}" + +# 转数组 +IFS=',' read -ra TABLES <<< "$TABLES_CSV" + +echo "=============================================" +echo "Phase 1: PostgreSQL 序列同步" +echo "=============================================" +echo "Target: ${PG_USER}@${PG_HOST}:${PG_PORT}/${PG_DB}" +echo "Tables: ${#TABLES[@]} 个 BIGSERIAL 表" +echo "" + +# ---------- Step 1: 同步所有表 ---------- +echo "📍 Step 1/2: 同步所有表的 _id_seq" +echo "" +for tbl in "${TABLES[@]}"; do + tbl=$(echo "$tbl" | tr -d ' ') + [ -z "$tbl" ] && continue + echo " → ${tbl}" + + # 先看 max(id), 确认表非空 + max_id=$(PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc "SELECT COALESCE(MAX(id), 0) FROM ${tbl};") + + if [ "$max_id" = "0" ]; then + echo " (空表, 不需要同步序列)" + continue + fi + + # setval(_id_seq, max(id), true) — true 表示下一次 nextval 直接返回 max(id)+1 + PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -c "SELECT setval('${tbl}_id_seq', ${max_id}, true);" + + echo " ✅ 已同步到 ${max_id}" +done +echo "" + +# ---------- Step 2: 验证 (硬性 PASS 条件) ---------- +echo "📍 Step 2/2: 验证所有序列 healthy" +echo "" + +unhealthy_file=$(mktemp) +for tbl in "${TABLES[@]}"; do + tbl=$(echo "$tbl" | tr -d ' ') + [ -z "$tbl" ] && continue + seq="${tbl}_id_seq" + + # pg_sequences 自 PG10 起可用 — last_value >= max_id 视为 healthy + result=$(PGPASSWORD="$PG_PASSWORD" psql \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + -tAc " + SELECT + seq.schemaname, + seq.sequencename, + seq.last_value, + (SELECT COALESCE(MAX(id), 0) FROM ONLY ${tbl}) AS table_max_id, + seq.last_value >= (SELECT COALESCE(MAX(id), 0) FROM ONLY ${tbl}) AS is_healthy + FROM pg_sequences seq + WHERE seq.sequencename = '${seq}'; + ") + + is_healthy=$(echo "$result" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}') + last_value=$(echo "$result" | awk -F'|' '{gsub(/^ +| +$/,"",$3); print $3}') + table_max=$(echo "$result" | awk -F'|' '{gsub(/^ +| +$/,"",$4); print $4}') + + if [ "$is_healthy" = "t" ]; then + echo " ✅ ${seq}: last_value=${last_value} >= max(id)=${table_max}" + else + echo " ❌ ${seq}: last_value=${last_value} < max(id)=${table_max} ← 不健康!" + echo "${seq}|${last_value}|${table_max}" >> "$unhealthy_file" + fi +done +echo "" + +# ---------- 收尾 ---------- +unhealthy_count=$(wc -l < "$unhealthy_file" 2>/dev/null || echo 0) +rm -f "$unhealthy_file" + +if [ "$unhealthy_count" -gt 0 ]; then + echo "=============================================" + echo "❌ 同步失败: ${unhealthy_count} 个序列不健康" + echo "切流量会触发 duplicate key 错误, 启动 abort!" + echo "=============================================" + exit 1 +fi + +echo "=============================================" +echo "✅ 全部 ${#TABLES[@]} 个序列 healthy, 可以切流量" +echo "=============================================" +exit 0 diff --git a/k8s/scripts/fill-values-prod.sh b/k8s/scripts/fill-values-prod.sh new file mode 100755 index 0000000..c6e3f1e --- /dev/null +++ b/k8s/scripts/fill-values-prod.sh @@ -0,0 +1,511 @@ +#!/bin/bash +# =================================================================== +# 交互式生成 values-prod.yaml (K8s helm 真值文件) +# =================================================================== +# 目的: +# - 避免把真密码 / API Key 写进 example 文件 (且 AI 永远不该看到) +# - 引导式输入, **未填的字段占位为 __FILL_ME__**, 不阻断流程 +# - 文件权限 600 (owner only), .gitignore 确认 +# - 写完会列出仍待填的占位符, 用户 $EDITOR 补齐 +# - 可选: sops 加密后写 .sops.yaml (加密的可入 git) +# +# 使用: +# ./k8s/scripts/fill-values-prod.sh # 交互 (推荐, 空字段占位) +# ./k8s/scripts/fill-values-prod.sh --strict # 严格模式: 必填字段空 → exit 1 +# ./k8s/scripts/fill-values-prod.sh --non-interactive # 全用环境变量 (CI 用, 仍严格) +# ./k8s/scripts/fill-values-prod.sh --dry-run # 只看不写 +# ./k8s/scripts/fill-values-prod.sh --sops # 写完后 sops 加密 +# +# 占位符语义: +# __FILL_ME__ 必填, helm install 会失败, 部署前必须替换 +# (留空) 可选, helm install 用 default 值 +# +# 真值来源 (与生产 / 团队 1Password 对照): +# * RDS endpoint 阿里云 RDS 控制台 → 数据库连接 → 内网地址 +# * ElastiCache 阿里云 Redis 控制台 → 连接地址 +# * DB / Redis 密码 RDS / Redis 实例设置 +# * JWT Secret 现有 docker/.env.prod 里的 JWT_SECRET +# * OSS keys 阿里云 RAM 控制台 (子账号 AccessKey) +# * DIFY_API_BASE/KEY Dify 工作室 → 工作室 API → API 密钥 +# * OPENAI_API_KEY 微达API 控制台 +# * SMS_ACCESS_KEY_ID 阿里云短信服务控制台 +# =================================================================== + +set -euo pipefail +# set 模式三件套: +# -e: 任何命令非 0 退出立刻 abort (避免脚本"半路成功"的诡异状态) +# -u: 引用未定义变量时报错 (避免 var=$VAR 在 VAR 没设时悄悄变成空) +# -o pipefail: 管道链上任一环节失败 → 整链失败 (否则 `cmd1 | cmd2` +# 只要 cmd2 成功就 exit 0, 隐藏 cmd1 的失败, 比如 `gitleaks | tail`) +# **坑**: set -u 时 `${!var}` 这种间接引用要写 `${!var:-}`, +# 否则 var 没定义时直接 abort (见 prompt_required 里的写法) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ${BASH_SOURCE[0]} 是当前脚本自身路径 (source 时为调用者), +# dirname 取目录, cd+ls 解析软链/相对路径 → 拿到**真实**绝对路径。 +# 这样无论用户从哪个 cwd 跑 `bash /path/to/this.sh`, SCRIPT_DIR 都是脚本所在目录。 + +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# SCRIPT_DIR 是 k8s/scripts/, 往上一级是 k8s/, 再上一级是仓库根。 +# 这个变量专门给 .gitignore 写入路径用, 不用 helm chart 路径因为可能跨项目。 + +CHART_DIR="$REPO_ROOT/k8s/helm/topfans" +EXAMPLE_FILE="$CHART_DIR/values-prod.example.yaml" +# 该文件作为**参考模板** (空占位结构), 真值由本脚本生成到 OUTPUT_FILE。 +# 注意: 我**没有**让脚本自动 cp example, 因为 example 写假值容易误部署。 +OUTPUT_FILE="$CHART_DIR/values-prod.yaml" +# 真值输出文件, .gitignore 已屏蔽, chmod 600 由本脚本设。 + +# ---------- CLI 参数 ---------- +DRY_RUN=false # --dry-run: 只 echo 内容不写文件 (调试用) +NON_INTERACTIVE=false # --non-interactive: 全靠环境变量读, 不交互; 隐含 STRICT=true +STRICT=false # --strict: 必填字段空时 exit 1 (而不是写占位符) +USE_SOPS=false # --sops: 写完后用 SOPS 加密成 .sops.yaml (可入 git) +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --non-interactive|--ci) NON_INTERACTIVE=true; STRICT=true ;; + # CI 流水线里如果允许"留空写占位符"会导致 helm install 后 + # 才报错, 太晚; 故 CI 必填字段缺失直接 abort。 + --strict) STRICT=true ;; + # 交互模式手动开严格, 留空会 exit 1 而非占位。 + --sops) USE_SOPS=true ;; + -h|--help) + # 用 sed 抽文件开头 30 行做 manpage, 任何改 header 都会自动反映 + sed -n '2,30p' "$0" | sed 's/^# \?//' + exit 0 ;; + *) + echo "未知参数: $arg"; exit 1 ;; + esac +done + +# ---------- 颜色 ---------- +# ANSI 控制序列。 `-e` 让 echo 解释 `\033` 转义。 +# 颜色在 macOS Terminal / iTerm / VS Code 终端都有效; 但在 dumb / redirected +# 输出时会显示成 [\033 之类的乱码 — 这种场景要靠 `tput` 检查 TTY 后再决定是否染色。 +# 本脚本只在交互 + 日志输出里染色, 写到文件的内容不带颜色, 所以乱码不会进 values-prod.yaml。 +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +# NC (No Color) 必须每次 reset, 否则一个 print 染色后所有后续 echo 都染色。 +print_step() { echo -e "\n${CYAN}━━ $1 ━━${NC}"; } +print_warn() { echo -e "${YELLOW}⚠ $1${NC}"; } +print_err() { echo -e "${RED}❌ $1${NC}"; } +print_ok() { echo -e "${GREEN}✅ $1${NC}"; } + +# ---------- 占位符常量 ---------- +PLACEHOLDER='__FILL_ME__' +# 唯一强占位符, helm template 时会原样输出, helm install 时 K8s Admission 会拒 +# (因为敏感字段不能为空)。这种"自我校验"机制让运维**部署前**就能发现有没有漏填。 +# 唯一**不**当作占位符的字符串: 空串 "" — 空串可能是合法的可选值。 +PLACEHOLDER_OPT='(留空, 用 chart default)' +# 目前没用到 (prompt_optional 默认逻辑是不写占位符, 而是让 helm fallback), 留着给以后文档用 + +# ---------- pre-check: not already present ---------- +# 在大量 prompt 之前问一声, 避免写到一半才发现要保留旧文件 → 用户已经覆盖了半天输入。 +# CI / dry-run 跳过确认 (CI 没交互, dry-run 不写文件) +if [ -f "$OUTPUT_FILE" ] && [ "$DRY_RUN" = false ] && [ "$NON_INTERACTIVE" = false ]; then + print_warn "values-prod.yaml 已存在, 继续会覆盖!" + read -rp "继续? (yes/no): " confirm + # 用 yes/no 而非 y/n 是为了避免"敲错键"误覆盖 (验证过 yes 才继续) + [ "$confirm" = "yes" ] || { print_warn "已取消"; exit 0; } +fi + +# ---------- 待填占位符记录 ---------- +# **Bug 历史**: 之前用 bash 数组 PENDING+=(), 但 prompt_* 在 $(...) command +# substitution 里调用, subshell 改的全局变量不会传回父 shell, 导致 summary +# 永远显示"全部已填"。修复: 改用临时文件。 +PENDING_FILE="$(mktemp -t fill-pending.XXXXXX)" +# mktemp -t name.XXXXXX → /tmp/name.XXXXXX, 随机后缀避免冲突。 +# 在 macOS 默认 /tmp/, Linux 默认 $TMPDIR(/tmp/)。 + +trap 'rm -f "$PENDING_FILE"' EXIT +# trap ... EXIT 在脚本退出 (包括 abort/err) 时都执行, 保证不留垃圾。 +# 关键: 不能用 trap ... INT, 这样 ctrl-c 才不会触发清理, 但脚本会卡死。 +# EXIT 是兜底。 + +> "$PENDING_FILE" # 清空 (mktemp 创建的可能已有 stale 残留下文) + +# 把待补字段记到文件, 行格式: section_key|comment|nature(nature=必填/可选) +# 用 | 分隔: 用户**写**的不是分隔符, 字段名是固定的点路径, 没有 |。 +record_pending() { + local section_key="$1" comment="$2" nature="$3" + printf '%s|%s|%s\n' "$section_key" "$comment" "$nature" >> "$PENDING_FILE" +} + +# ---------- helpers ---------- +# 两个 prompt 函数的语义: +# prompt_required — 必填, 留空 → 写 __FILL_ME__ + 加入 PENDING_FILE 标 "必填" +# prompt_optional — 可选, 留空 → 用 chart default; 在 prompt 处明确告诉用户 +# "留空用 default 还是留空跳过", 用户可控 +# +# 共同的间接引用: ${!var} 是 bash 的 "间接变量" 语法。 +# 写法: var="FOO"; FOO=bar; echo "${!var}" → bar +# 用 `${!var:-}` + `-n` 测试, 是 set -u 兼容: var 没定义时返回空字符串, 不报错。 + +prompt_required() { + # 参数: + # $1 var — 在 CI/--strict 模式下读的环境变量名 (例如 DB_PASSWORD) + # $2 desc — 交互模式下显示的提示语 (例: 'Postgres 密码') + # $3 section_key — YAML 路径 (例: secrets.db.password), 用于 PENDING_FILE 记录 + # $4 secret — 是否隐藏输入 (true/false), 密码字段用 true 走 `read -s` + local var="$1" desc="$2" section_key="$3" secret="${4:-false}" + local val prompt_str="$desc: " + # prompt_str 默认用 ": ", 后续 secret=true 时 -s 模式不带 prefix prompt 也行, 但保留 + + if [ "$NON_INTERACTIVE" = true ] || [ "$STRICT" = true ]; then + # CI / strict 路径: 不交互, 直接尝试读环境变量 + if [ -n "${!var:-}" ]; then + # 找到了 → 输出真值 + printf '%s' "${!var}" + return + elif [ "$NON_INTERACTIVE" = true ]; then + # CI 缺 var → 不可恢复, abort + print_err "$section_key 必填 (CI 模式: env $var 未设)" + exit 1 + fi + # 走到这里是 STRICT=true 且 interactive 且 env $var 没设: + # 不 abort, 走下面的 read, 但**最后**校验非空。 + fi + + # 交互模式 (默认, 或 --strict 但 env $var 未设) + # read -r: 不要 backslash escape (默认值), 不加 -r 的话 \n 会变成 \\ + # read -sp: -s 隐藏输入字符 (密码不回显); 注意 -s 在 macOS 上 minor bug, + # 偶发会丢首字符, 用 `read -s`, 再 `echo`, 然后 trim 即可 + if [ "$secret" = true ]; then + read -rsp "$prompt_str" val || val="" + # `|| val=""`: read 失败 (Ctrl-D EOF / Ctrl-C 中断) 时不让 set -e 炸掉 + echo # 隐藏模式下 read 不自动换行, 我们手动 echo 一个 + else + read -rp "$prompt_str" val || val="" + fi + + if [ -z "$val" ]; then + # 用户敲了空回车 → 占位 + 记到 PENDING_FILE + record_pending "$section_key" "$desc" "必填" + printf '%s' "$PLACEHOLDER" + else + printf '%s' "$val" + fi +} + +prompt_optional() { + # 参数: + # $1 var — CI/--strict 模式下的环境变量名 + # $2 desc — 提示语 + # $3 default — 内置 default 值 (从 chart 同步过来), 用户敲回车用这个 + # $4 section_key — YAML 路径, 留空时记 PENDING_FILE (可选) + # + # 行为对比 prompt_required: + # - 不会写 PLACEHOLDER 占位符, 而是写空串或 default + # - 留空时 PENDING_FILE 标 "可选", summary 显示 🟡 而不是 ⛔ + # - 这样 chart 默认值可以"自动"接管, 即使这个 prompt 永远不交互 + local var="$1" desc="$2" default="${3:-}" section_key="${4:-}" + local val prompt_str + + if [ "$NON_INTERACTIVE" = true ]; then + # CI: env $var 优先 → default → 完全空 (helm 会用 chart 内置 default) + if [ -n "${!var:-}" ]; then + printf '%s' "${!var}" + elif [ -n "$default" ]; then + printf '%s' "$default" + else + printf '%s' "" # 留空, helm 会用 chart 内的 default / required 拦截 + fi + return + fi + + # 交互模式: 显示提示包含 default + 留空说明 + if [ -n "$default" ]; then + prompt_str="$desc [$default, 留空用 default]: " + else + prompt_str="$desc [留空跳过]: " + fi + + read -rp "$prompt_str" val || val="" + if [ -z "$val" ]; then + if [ -n "$default" ]; then + # 用户敲回车, 用 default + printf '%s' "$default" + elif [ -n "$section_key" ]; then + # 给 section_key 的可选字段留空: helm 收到空串而非 default, + # 仍记录到 PENDING_FILE (summary 用), 但标 🟡 + record_pending "$section_key" "$desc" "可选" + printf '%s' "" + else + printf '%s' "" # 真正无配置 (与 helm fallback 一致) + fi + else + printf '%s' "$val" + fi +} + +# ---------- input collection ---------- +# 共 4 个章节, 22 个字段, 按"依赖关系"先通用后专用: +# 1/4 全球通用 — DB / Redis 连接信息 +# 2/4 Gateway / 应用 — JWT / image tag / 域名 +# 3/4 OSS / 短信 — 阿里云资源访问凭据 +# 4/4 AI (镭射卡 / 对话) — 外部 AI 服务凭据 +# 这种分组让运维心里有数, 而不是随便顺序问 30 个问题。 + +print_step "1/4 全球通用" + +DB_HOST_INTERNAL=$(prompt_required DB_HOST_INTERNAL 'Postgres RDS 内网地址 (例: rm-xxx.mysql.rds.aliyuncs.com)' 'global.dbExternalEndpoint') +# DB_HOST 在 K8s 内部固定是 `postgres` (ExternalName), 这里要填的是 RDS **外网** +# 内网地址: K8s Pod 用这个解析到 RDS (Pod 不出外网, 必须用阿里云 VPC 内网地址)。 +# 阿里云控制台: RDS → 实例 → 数据库连接 → 内网地址 (以 .mysql.rds.aliyuncs.com 结尾)。 +DB_PORT=$(prompt_optional DB_PORT 'Postgres 端口' '5432' 'global.dbPort') +DB_NAME=$(prompt_optional DB_NAME '数据库名' 'topfans' 'global.dbName') +DB_USER=$(prompt_optional DB_USER '用户名' 'postgres' 'global.dbUser') +DB_PASSWORD=$(prompt_required DB_PASSWORD 'Postgres 密码' 'secrets.db.password' true) + +REDIS_HOST_INTERNAL=$(prompt_required REDIS_HOST_INTERNAL 'Redis ElastiCache 内网地址 (例: r-xxx.redis.rds.aliyuncs.com)' 'global.redisExternalEndpoint') +# 同上, K8s 内部 `redis` 短名对应 ExternalName, 实际值要填阿里云 Redis 内网地址。 +# 在 Redis 控制台 → 实例 → 连接管理 → 内网地址。 +REDIS_PORT=$(prompt_optional REDIS_PORT 'Redis 端口' '6379' 'global.redisPort') +REDIS_PASSWORD=$(prompt_required REDIS_PASSWORD 'Redis 密码' 'secrets.redis.password' true) + +print_step "2/4 Gateway / 应用" + +JWT_SECRET=$(prompt_required JWT_SECRET 'JWT_SECRET (32 字符以上, 与 docker/.env.prod 的值保持一致)' 'secrets.jwt.secret' true) +# ⚠️ 关键: JWT Secret 一旦改, 所有已签发的 token 全部失效, 用户会全部退出登录。 +# **必须**与现有 docker/.env.prod 的 JWT_SECRET 保持一致 (除非主动全员踢出)。 +# 在云上托管的密钥里取, 不要换新值 (会全站掉登录)。 +IMAGE_TAG=$(prompt_optional IMAGE_TAG '所有服务 image tag (CI 决定后填, 例如 v1.0.0)' 'latest') +# 默认 latest 允许本地开发 (debian docker-compose), 生产必须填具体版本 (v1.0.0)。 +# helm install 用 --set global.image.tag=$IMAGE_TAG 可以临时覆盖所有服务 (见 deploy.sh upgrade)。 +INGRESS_HOST=$(prompt_optional INGRESS_HOST 'Ingress 域名' 'api.example.com' 'ingress.host') +# Ingress 域名, K8s nginx-ingress 会读这个生成 cert-manager Certificate CR (TLS 自动续期)。 +# 部署前到阿里云 DNS 控制台把 A 记录指向 nginx-ingress-controller 的 EXTERNAL-IP。 + +print_step "3/4 OSS / 短信" + +OSS_REGION=$(prompt_optional OSS_REGION 'OSS region' 'cn-shanghai' 'secrets.oss.region') +# OSS region (上海) 与 RDS region 不一定一致, 按实际 bucket 创建的 region 填。 +OSS_BUCKET=$(prompt_required OSS_BUCKET 'OSS bucket 名 (例: top-fans-prod)' 'secrets.oss.bucket') +OSS_KEY_ID=$(prompt_required OSS_KEY_ID 'OSS AccessKey ID' 'secrets.oss.accessKeyId') +OSS_KEY_SECRET=$(prompt_required OSS_KEY_SECRET 'OSS AccessKey Secret' 'secrets.oss.accessKeySecret' true) +# ⚠️ 这些是 OSS 全权子账号 (有 RAM STS AssumeRole 拿临时 token 上传头像/资产生成) +# 强烈推荐**用子账号**而不是主账号 AccessKey, 子账号只授 OSS 写权限 + 受 IP 白名单。 +# 取值: 阿里云 RAM 控制台 → 用户 → AccessKey。 +OSS_STS_ROLE_ARN=$(prompt_optional OSS_STS_ROLE_ARN 'OSS STS Role ARN (留空跳过)' '' 'secrets.oss.stsRoleArn') +# ARN 格式: acs:ram::1387642798143585:role/top-fans-oss-user +# 没有可留空, 但前端的"STS 临时上传"会失败 (降级到 long-lived key, 不安全)。 +SMS_KEY_ID=$(prompt_optional SMS_KEY_ID 'SMS AccessKey ID (阿里云短信, 留空跳过)' '' 'secrets.sms.accessKeyId') +SMS_KEY_SECRET=$(prompt_optional SMS_KEY_SECRET 'SMS AccessKey Secret (短信不用就留空)' '' 'secrets.sms.accessKeySecret' true) +SMS_SIGN_NAME=$(prompt_optional SMS_SIGN_NAME 'SMS 签名 (留空跳过)' '' 'secrets.sms.signName') +SMS_TEMPLATE=$(prompt_optional SMS_TEMPLATE 'SMS 模板代码 (留空跳过)' '' 'secrets.sms.templateCode') +# SMS 4 项是阿里云短信服务的凭据, 不开通短信服务 (初版用 uniCloud 推送替代) 可全部留空。 + +print_step "4/4 AI (镭射卡 / 对话)" + +DIFY_API_BASE=$(prompt_optional DIFY_API_BASE 'DIFY_API_BASE' 'http://dify:5001/v1' 'secrets.ai.difyApiBase') +# Dify 地址, 默认是 K8s 内部的 `dify:5001` 短 DNS (Dify 也用 ExternalName 占位, +# 见模板 external-db/ 那一对), 实际生产可能是阿里云部署的 Dify 公网地址。 +DIFY_API_KEY=$(prompt_required DIFY_API_KEY 'DIFY_API_KEY' 'secrets.ai.difyApiKey' true) +# 在 Dify 控制台: 工作室 → 角角(应用名) → 后端服务 API → API 密钥 (格式 app-xxxxx)。 +OPENAI_BASE=$(prompt_optional OPENAI_BASE 'OpenAI 兼容 API base' 'https://api.weda.cc/v1' 'secrets.ai.openaiBaseUrl') +OPENAI_KEY=$(prompt_required OPENAI_KEY 'OpenAI 兼容 API Key' 'secrets.ai.openaiApiKey' true) +# 默认走微达API中转站 (OPENAI_BASE=weda.cc)。可在 dashboard 切换成其他兼容服务。 +# 没有微达账号就手动填 OpenAI 直连地址 https://api.openai.com/v1。 +OPENAI_MODEL=$(prompt_optional OPENAI_MODEL 'OpenAI model' 'gpt-image-2' 'secrets.ai.openaiModel') +# 镭射卡生成模型, 默认 gpt-image-2。生产可在 LASER_GEN_PROVIDER 切到 dify/minimax。 +MINIMAX_KEY=$(prompt_optional MINIMAX_KEY 'MiniMax API Key (默认用 OpenAI, 不需要可留空)' '' 'secrets.ai.minimaxApiKey' true) +MINIMAX_URL=$(prompt_optional MINIMAX_URL 'MiniMax URL' 'https://api.minimaxi.com/v1/image_generation' 'secrets.ai.minimaxApiUrl') +# 当 LASER_GEN_PROVIDER=minimax 时启用, 默认 OpenAI, 这 2 项可留空。 + +# ---------- write file ---------- +# 这里用 here-string (...="...") 直接构造 YAML 字符串, 而不是逐行 echo, +# 因为变量多且结构清晰, here-string 写出来最易读。 +# 关键约定: +# 1. 所有"必填没填"的变量值都是 PLACEHOLDER='__FILL_ME__' (常量) +# → helm template 会原样输出 → K8s Admission Webhook 会拒 +# → 部署前运维必须用 $EDITOR 替换这些字符串 +# 2. 所有"可选没填"的变量值都是 "" (空字符串, 这是合法 YAML) +# → helm template 不会输出该字段 → chart 用内置 default +# 3. 每个 ⚠ 标记提醒: 这个字段一旦有真值就是凭据 +# +# **不要改字段顺序**, 给运维/AI 看的约定俗成, 改字段顺序增大 PR review 难度。 +OUTPUT_CONTENT="global: + dbHost: postgres # K8s 内部短名, 对应 ExternalName postgres → RDS endpoint + dbPort: \"$DB_PORT\" # 见 global.dbExternalEndpoint + dbUser: \"$DB_USER\" + dbName: \"$DB_NAME\" + dbExternalEndpoint: \"$DB_HOST_INTERNAL\" # ⚠ 真值: 阿里云 RDS 内网地址, 不入 git + redisHost: redis # 同上, ExternalName redis → ElastiCache endpoint + redisPort: \"$REDIS_PORT\" + redisExternalEndpoint: \"$REDIS_HOST_INTERNAL\" # ⚠ 真值 + + image: + registry: registry.cn-shanghai.aliyuncs.com # 阿里云 ACR (design doc §10.1 选项 A) + repositoryNamespace: topfans # 仓库 namespace + + env: production + ginMode: release + logLevel: info + +secrets: + db: + password: \"$DB_PASSWORD\" # ⚠ 真值 + redis: + password: \"$REDIS_PASSWORD\" # ⚠ 真值 + jwt: + secret: \"$JWT_SECRET\" # ⚠ 真值, 改了会让全站 token 失效 + oss: + region: \"$OSS_REGION\" + bucket: \"$OSS_BUCKET\" + accessKeyId: \"$OSS_KEY_ID\" # ⚠ 真值: RAM 子账号 AccessKey + accessKeySecret: \"$OSS_KEY_SECRET\" # ⚠ 真值 + stsRoleArn: \"$OSS_STS_ROLE_ARN\" # STS AssumeRole ARN (可选) + landingBaseUrl: \"https://$INGRESS_HOST\" # 分享服务生成的落地页链接前缀 + ai: + difyApiBase: \"$DIFY_API_BASE\" + difyApiKey: \"$DIFY_API_KEY\" # ⚠ 真值 + openaiApiKey: \"$OPENAI_KEY\" # ⚠ 真值 + openaiBaseUrl: \"$OPENAI_BASE\" + openaiModel: \"$OPENAI_MODEL\" + minimaxApiKey: \"$MINIMAX_KEY\" # 可选 + minimaxApiUrl: \"$MINIMAX_URL\" # 可选 + sms: + accessKeyId: \"$SMS_KEY_ID\" # ⚠ 真值 (可选) + accessKeySecret: \"$SMS_KEY_SECRET\" # ⚠ 真值 (可选) + signName: \"$SMS_SIGN_NAME\" + templateCode: \"$SMS_TEMPLATE\" + +# --------------------------------------------------------------- +# 所有服务的 image tag 用全局覆盖 (services..image.tag = global.image.tag); +# 单服务差异在 helm install 时 +# 用 --set 临时覆盖 (例: --set services.gateway.replicas=5) +# --------------------------------------------------------------- +services: {} +" + +if [ "$DRY_RUN" = true ]; then + # --dry-run 模式: 不写文件, 把内容 echo 给用户看, 让他"先看再决定" + # 经常用来调试: ./fill-values-prod.sh --dry-run | less + print_warn "DRY RUN: 不写文件, 上面是预览" + echo "$OUTPUT_CONTENT" + exit 0 +fi + +print_step "写文件" +echo -n "$OUTPUT_CONTENT" > "$OUTPUT_FILE" +# echo -n 不带末尾换行; 加上 here-string 末尾已有一个 \n, 总共一个换行, 是标准 YAML 风格。 +# > 的本质是 truncate+write, 即使脚本中间 SIGINT 也能拿到部分文件 (避免破坏性 >|) +chmod 600 "$OUTPUT_FILE" +# 600 = -rw------- ; owner 可读写, group/other 无权限。 +# 防止偶然的 `cat file` / `less file` / git status (本地) 暴漏真值。 +# 注意: chmod 在 NTFS / FAT 文件系统上无效, 这是 Linux/Mac ext4/apfs 的特性。 +print_ok "已写 $OUTPUT_FILE (权限 600, owner-only)" + +# ---------- .gitignore 检查 ---------- +# 即使剧本本身极小概率出错 (运维 cp 命令误填), 也要有兜底防止真值入 git。 +# .gitignore 用 glob ** 匹配任何层级的 values-prod.yaml。 +# 检测是否已存在该条目: +GITIGNORE="$REPO_ROOT/.gitignore" +if grep -qE '^k8s/helm/\*\*/values-prod\.yaml$' "$GITIGNORE" 2>/dev/null; then + print_ok ".gitignore 已排除 values-prod.yaml" +else + print_warn ".gitignore **似乎** 未排除 values-prod.yaml, 现在加" + # 非交互模式 (CI) 不自动改 .gitignore — 那是仓库层面的修改, 必须人工 review。 + if [ "$NON_INTERACTIVE" = false ]; then + read -rp "(yes/no): " confirm + [ "$confirm" = "yes" ] && { + printf '\n# K8s 真值文件 (含 DB/AI 凭据, 不入 git)\nk8s/helm/**/values-prod.yaml\n' >> "$GITIGNORE" + print_ok "已加 .gitignore 条目" + } + fi +fi + +# ---------- 泄漏扫描 ---------- +# 兜底: 即使 chmod 600 / .gitignore 都对, 万一用户 vim 编辑时手滑推到 git +# 也是灾难。 这里调 gitleaks (首选) / detect-secrets 扫**刚生成**的 file +# 检查真值模式是否还像 placeholder (没填)。 +# 注意: 第一次 gitleaks detect 是**整个仓库** scan, 不是单 file, +# 这通常 CI 跑; 本地想要只 scan 当前文件需要 detect-secrets。 + +if command -v gitleaks &>/dev/null; then + print_step "gitleaks 扫描" + # -v: 详细模式; --no-banner: 不要版本条; 2>&1: 把 stderr 合到 stdout + # `|| true`: 不让 `gitleaks 检测到 leak` (exit 1) 把整个脚本带 abort + # 因为这是 informational, 我们后面让运维自己处理 + gitleaks detect --source . --no-banner -v 2>&1 | tail -10 || true +elif command -v detect-secrets &>/dev/null; then + print_step "detect-secrets 扫描" + detect-secrets scan "$OUTPUT_FILE" 2>&1 || true +else + print_warn "未装 gitleaks / detect-secrets, 手动核对:" + echo " git diff --no-color k8s/helm/topfans/values-prod.yaml | grep -E 'password|key|secret' | head" + # `head` 防泄漏太长; 仅看有没有"看起来是真值"的行 +fi + +# ---------- SOPS 加密 (可选) ---------- +# 真值**明文**版的 values-prod.yaml 必须 600 权限 + 不入 git (前两步已做)。 +# 但运维不方便于共享给队友时, 用 SOPS 加密成 .sops.yaml 可以入 git +# (加密内容读者无法解密, 但解密密钥在 KMS / PGP / age 中)。 +# 加密 helm install 流程: +# sops --decrypt values-prod.sops.yaml > values-prod.yaml +# helm install topfans ./helm/topfans -f values-prod.yaml +if [ "$USE_SOPS" = true ]; then + if ! command -v sops &>/dev/null; then + print_err "sops 未装 (brew install sops)" + exit 1 + fi + print_step "sops 加密" + SOPS_FILE="${OUTPUT_FILE%.yaml}.sops.yaml" + # 去掉末尾的 .yaml, 加 .sops.yaml 作后缀: values-prod.sops.yaml + sops --encrypt --in-place "$OUTPUT_FILE" + # --in-place 原地加密, 不保留明文 + mv "$OUTPUT_FILE" "$SOPS_FILE" + chmod 600 "$SOPS_FILE" + print_ok "加密: $SOPS_FILE" + print_ok "解密: sops --decrypt $SOPS_FILE > $OUTPUT_FILE" + print_warn "生产部署时 SOPS_FILE 可以入 git (加密的)" +fi + +print_step "✅ 完成" + +# ---------- 待填占位符汇总 ---------- +# 决策点: if [ -s FILE ] 检查文件 size > 0 (有内容 = 有 pending 项)。 +# 不用 [ -f FILE ] 因为 mktemp 创建后已存在, -f 永远 true; +# 不用 ${#PENDING[@]} 因为可能有未处理的元素 (虽然我们用文件而非数组)。 +if [ -s "$PENDING_FILE" ]; then + echo "" + echo "╔════════════════════════════════════════════════════════════════╗" + echo "║ ⚠ 本次未填的字段, deploy 前必须补齐 ║" + echo "╚════════════════════════════════════════════════════════════════╝" + # 表格列宽: 字段名 40 字符 (足够 longest = secrets.ai.minimaxApiKey) + printf " %-40s %s\n" "字段" "说明" + printf " %-40s %s\n" "────" "────" + while IFS='|' read -r key desc nature; do + # IFS='|' 覆盖全局 IFS 为本条 read, 不会污染后续 read + if [ "$nature" = "必填" ]; then + marker="⛔ 必填" + else + marker="🟡 可选" + fi + printf " ${YELLOW}%-40s${NC} %s · %s\n" "$key" "$marker" "$desc" + # 重定向 < "$PENDING_FILE" 让 read 从文件读, 而不是 stdin + done < "$PENDING_FILE" + echo "" + print_warn "占位符 $PLACEHOLDER 已经写在文件里" + print_warn "补齐方式: \$EDITOR k8s/helm/topfans/values-prod.yaml" + print_warn "搜索: grep -n $PLACEHOLDER k8s/helm/topfans/values-prod.yaml" + print_warn "替换后通过 helm template dry-run 验证:" + print_warn " helm template topfans k8s/helm/topfans/ -f k8s/helm/topfans/values-prod.yaml --namespace topfans | grep $PLACEHOLDER" + print_warn " 没有输出 = 全部替换完" + # helm template + grep 是部署前的**硬性** sanity check: + # 1. 占位符没替换会原样输出, grep 应该能命中 + # 2. 没命中 = 全部填好了, 可以 install +else + echo "" + print_ok "所有必填字段都已填, 可以直接 helm install" +fi + +echo "" +echo "用法:" +echo " ./k8s/deploy.sh upgrade $IMAGE_TAG # 升级 image tag = $IMAGE_TAG" +echo " ./k8s/deploy.sh upgrade --reuse-values # 升级, 复用当前 values" +echo "" +echo "验证 (不进真值到 git):" +echo " cd $REPO_ROOT && git status k8s/helm/topfans/values-prod.yaml" +# `git status` 显示为 ignored 才算稳。若显示为 untracked 那就是 .gitignore 出问题了, +# 这次 helm install 千万**别**在生产路径上跑 (应立刻看 .gitignore 是否漏配)。