feat:增加k8s部署

This commit is contained in:
zerosaturation 2026-07-06 13:26:46 +08:00
parent dcb4e451a1
commit 58cada6f59
35 changed files with 3528 additions and 0 deletions

237
k8s/README.md Normal file
View File

@ -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 <rds-endpoint>` 确认能连上, 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.<service>.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 记录 → <EXTERNAL-IP>
./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 <pod> --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.<svc>.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 首版)

378
k8s/deploy.sh Executable file
View File

@ -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 <command> [args]
${YELLOW}命令:${NC}
${GREEN}install${NC} 首次部署 (helm install)
${GREEN}upgrade${NC} <version> 升级镜像 (用 version 写所有服务 tag)
${GREEN}upgrade --reuse-values${NC} 升级, 复用 values
${GREEN}rollback${NC} [revision] 回滚 (默认上一版)
${GREEN}status${NC} 看 helm status + pod 状态
${GREEN}logs${NC} <service> 看 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 <service>}"
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 <<EOJOB | kubectl apply -n "$NAMESPACE" -f -
apiVersion: batch/v1
kind: Job
metadata:
name: topfans-pg-sequence-sync-manual-$(date +%s)
namespace: $NAMESPACE
labels:
app.kubernetes.io/component: pg-sequence-sync
app.kubernetes.io/part-of: topfans
app.kubernetes.io/run: manual
spec:
ttlSecondsAfterFinished: 600
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: pg-sync
image: alpine:3.19
command: ["/bin/sh", "-c"]
args:
- |
apk add --no-cache postgresql16-client bash > /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 "$@"

View File

@ -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

View File

@ -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 }}

View File

@ -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 , ============ */}}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,14 @@
# ===================================================================
# Future Services (Phase 1 占位)
# ===================================================================
# 设计文档 §四.1 列出, 以下 4 个服务 **本阶段不实现**, Phase 1.x 或 Phase 2
# 启动时, 拆出独立 chart 子目录 (Phase 2: topfans-shared / topfans-group-<name>),
# 或在本 chart 加 templates/<service>/deployment.yaml。
#
# - admin 后台管理 (运营 / 客服), 共享 DB 不走 API
# - review 审核工作流 (UGC 内容审核), 被 gateway / asset 调用
# - ai-image-gen 镭射卡生成, 当前 gateway 直接调 OpenAI / MiniMax
# - ai-chat AI 对话 (粉丝互动), 与 aichatservice 是不同服务
#
# 本文件仅起占位作用, 不声明任何 K8s 资源。
# ===================================================================

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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/<service>
# 临时改 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 }

View File

@ -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

View File

@ -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

511
k8s/scripts/fill-values-prod.sh Executable file
View File

@ -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.<x>.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 是否漏配)。