diff --git a/apps/admin/.nuxt/manifest/latest.json b/apps/admin/.nuxt/manifest/latest.json index bb8c54cd..8b6d1600 100644 --- a/apps/admin/.nuxt/manifest/latest.json +++ b/apps/admin/.nuxt/manifest/latest.json @@ -1 +1 @@ -{"id":"dev","timestamp":1782613791473} \ No newline at end of file +{"id":"dev","timestamp":1782719244117} \ No newline at end of file diff --git a/apps/admin/.nuxt/manifest/meta/dev.json b/apps/admin/.nuxt/manifest/meta/dev.json index 50863fbe..9b82d903 100644 --- a/apps/admin/.nuxt/manifest/meta/dev.json +++ b/apps/admin/.nuxt/manifest/meta/dev.json @@ -1 +1 @@ -{"id":"dev","timestamp":1782613791473,"prerendered":[]} \ No newline at end of file +{"id":"dev","timestamp":1782719244117,"prerendered":[]} \ No newline at end of file diff --git a/apps/admin/.nuxt/nitro.json b/apps/admin/.nuxt/nitro.json index 469379ac..731eb3f9 100644 --- a/apps/admin/.nuxt/nitro.json +++ b/apps/admin/.nuxt/nitro.json @@ -1,5 +1,5 @@ { - "date": "2026-06-28T02:29:52.480Z", + "date": "2026-06-29T07:47:25.178Z", "preset": "nitro-dev", "framework": { "name": "nuxt", @@ -9,9 +9,9 @@ "nitro": "2.13.4" }, "dev": { - "pid": 21165, + "pid": 9287, "workerAddress": { - "socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-21165-1-1-132.sock" + "socketPath": "/var/folders/1w/40ft_rjj7qx8xn21kb1lj51r0000gn/T/nitro-worker-9287-1-1-5267.sock" } } } \ No newline at end of file diff --git a/apps/admin/.nuxt/nuxt.d.ts b/apps/admin/.nuxt/nuxt.d.ts index fba75790..60fc7d9a 100644 --- a/apps/admin/.nuxt/nuxt.d.ts +++ b/apps/admin/.nuxt/nuxt.d.ts @@ -1,5 +1,5 @@ -/// /// +/// /// /// /// diff --git a/apps/admin/.nuxt/nuxt.lock b/apps/admin/.nuxt/nuxt.lock new file mode 100644 index 00000000..e81b5075 --- /dev/null +++ b/apps/admin/.nuxt/nuxt.lock @@ -0,0 +1,9 @@ +{ + "pid": 9287, + "startedAt": 1782719243662, + "command": "dev", + "cwd": "/Users/liwei/work/go/yunyan/yunyan-sas/apps/admin", + "port": 3000, + "hostname": "::1", + "url": "http://[::1]:3000" +} \ No newline at end of file diff --git a/apps/admin/.nuxt/nuxt.node.d.ts b/apps/admin/.nuxt/nuxt.node.d.ts index 4c46b465..e33601ff 100644 --- a/apps/admin/.nuxt/nuxt.node.d.ts +++ b/apps/admin/.nuxt/nuxt.node.d.ts @@ -1,5 +1,5 @@ -/// /// +/// /// /// /// diff --git a/apps/admin/.nuxt/tailwind/postcss.mjs b/apps/admin/.nuxt/tailwind/postcss.mjs index 8937378f..97782c39 100644 --- a/apps/admin/.nuxt/tailwind/postcss.mjs +++ b/apps/admin/.nuxt/tailwind/postcss.mjs @@ -1,4 +1,4 @@ -// generated by the @nuxtjs/tailwindcss module at 6/28/2026, 10:29:51 AM +// generated by the @nuxtjs/tailwindcss module at 6/29/2026, 5:27:44 PM import "@nuxtjs/tailwindcss/config-ctx" import configMerger from "@nuxtjs/tailwindcss/merger"; diff --git a/apps/admin/app/layouts/default.vue b/apps/admin/app/layouts/default.vue index 899dfb68..1fe825e4 100644 --- a/apps/admin/app/layouts/default.vue +++ b/apps/admin/app/layouts/default.vue @@ -178,6 +178,7 @@ const menuGroups = [ { label: '系统管理', items: [ + { id: 'systemconfig', label: '系统配置', to: '/systemconfig', icon: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4' }, { id: 'apps', label: '应用注册', to: '/apps', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, { id: 'statlog', label: '统计日志', to: '/statlog', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' }, { id: 'admin', label: '后台账号', to: '/accounts', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, diff --git a/apps/admin/app/pages/appconfig.vue b/apps/admin/app/pages/appconfig.vue new file mode 100644 index 00000000..da5c9686 --- /dev/null +++ b/apps/admin/app/pages/appconfig.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/apps/admin/app/pages/apps.vue b/apps/admin/app/pages/apps.vue index ba387ec5..48f56578 100644 --- a/apps/admin/app/pages/apps.vue +++ b/apps/admin/app/pages/apps.vue @@ -36,6 +36,7 @@
+ @@ -367,6 +368,11 @@ async function saveApp() { } } +function openAppConfig(a: AppItem) { + // 跳转到该应用的服务配置页(微信登录 / 支付 / 推送 / 基础信息) + router.push({ path: '/appconfig', query: { id: a.id, name: a.name || ('应用#' + a.id), region: a.region || '' } }) +} + function delApp(id: number) { if (!window.confirm(`删除注册应用 #${id} ?(不影响应用自身的库)`)) return consoleApi('apps/del', { id }) diff --git a/apps/admin/app/pages/systemconfig.vue b/apps/admin/app/pages/systemconfig.vue new file mode 100644 index 00000000..4289e850 --- /dev/null +++ b/apps/admin/app/pages/systemconfig.vue @@ -0,0 +1,514 @@ + + + + + diff --git a/apps/services/dump.rdb b/apps/services/dump.rdb index d4e6437b..3f86b71a 100644 Binary files a/apps/services/dump.rdb and b/apps/services/dump.rdb differ diff --git a/apps/services/log/console.log b/apps/services/log/console.log index 87508c53..4893656f 100644 --- a/apps/services/log/console.log +++ b/apps/services/log/console.log @@ -10,3 +10,12 @@ 2026/06/29 10:01:40.888 INFO console.stat: 统计快照消费已启动 subject=stats.event stream=STATS durable=console-stats 2026/06/29 10:01:40.888 INFO console: HTTP 服务监听 :8080(静态目录 ./consoleweb) 2026/06/29 10:01:40.888 DEBUG 启动模块【console】 +2026/06/29 23:27:46.394 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/29 23:44:06.400 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/29 23:56:46.411 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 03:18:09.111 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 03:18:39.119 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 03:19:09.123 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 07:05:56.071 ERROR console/model_device_cache.go:76 [module.console] deviceCache refreshFactorys load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 07:06:26.079 ERROR console/model_device_cache.go:105 [module.console] deviceCache refreshProducts load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host +2026/06/30 07:39:03.753 ERROR console/model_device_cache.go:148 [module.console] deviceCache refreshProductVersions load err:failed to connect to `user=postgres.zqwfawlmlxjpgqlyigii database=postgres`: hostname resolving error: lookup aws-1-us-west-2.pooler.supabase.com: no such host diff --git a/apps/services/sys/bytedance/audomodel/sys_test.go b/apps/services/sys/bytedance/audomodel/sys_test.go index d4ecbf5d..df0d4b78 100644 --- a/apps/services/sys/bytedance/audomodel/sys_test.go +++ b/apps/services/sys/bytedance/audomodel/sys_test.go @@ -2,10 +2,10 @@ package audomodel_test import ( "context" - "yunyan/sys/bytedance/audomodel" - "yunyan/sys/bytedance/translate" "fmt" "testing" + "yunyan/sys/bytedance/audomodel" + "yunyan/sys/bytedance/translate" ) func TestRecognizeFlashByURL(t *testing.T) { @@ -20,7 +20,7 @@ func TestRecognizeFlashByURL(t *testing.T) { if err != nil { t.Fatalf("初始化失败: %v", err) } - statusCode, logID, contexts, err := sys.RecognizeFlash("liwei1da0", "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com/User/2019585175758831616/ExternalAudio/2026/03/24/stCRmukfdIcs.mp3", false, "ja-JP") + statusCode, logID, contexts, err := sys.RecognizeFlash("liwei1da0", "https://yunyan-deepsound.oss-cn-shanghai.aliyuncs.com/LocalAudio/2026/06/23/PHDXtMXhhNqZ.wav", false, "es-ES") if err != nil { fmt.Printf("识别失败: %v (code=%s, logid=%s)\n", err, statusCode, logID) return @@ -42,8 +42,8 @@ func TestRecognizeFlashByURL(t *testing.T) { tests = append(tests, item.Content) } // 映射ASR语言到火山翻译SDK的标准语言代码 - formLang := mapASRLangToTranslateLang("ja-JP") - tolLang := mapASRLangToTranslateLang("en-US") + formLang := mapASRLangToTranslateLang("es-ES") + tolLang := mapASRLangToTranslateLang("") // 目标语言为中文 results, err := sys.Translate(context.Background(), formLang, tolLang, tests) fmt.Printf("翻译成功: results=%v, err=%v\n", results, err) } diff --git a/deploy/voitrans/env/env.example b/deploy/voitrans/env/env.example index 6cdeedb1..127d551b 100644 --- a/deploy/voitrans/env/env.example +++ b/deploy/voitrans/env/env.example @@ -3,6 +3,8 @@ # 依赖的 etcd/mysql/redis/nats 在 DOCKER_NETWORK 网络内,用容器名连接。 # ── 镜像 / 容器 ── +# TAG = 当前运行的镜像版本。dev 固定 dev-latest;prod 部署成功后由 prod-deploy.sh 自动写回为本次版本号(如 0.2.1), +# 所以服务器上 cat .env 的这一行就是当前实际运行版本,便于排查是否环境/版本问题。 TAG=dev-latest PLATFORM=linux/amd64 DOCKER_NETWORK=1panel-network diff --git a/deploy/voitrans/prod-build.sh b/deploy/voitrans/prod-build.sh index 5f3ab09a..c1619a56 100755 --- a/deploy/voitrans/prod-build.sh +++ b/deploy/voitrans/prod-build.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # voitrans 生产环境:仅「构建带版本号的镜像并推送」,不部署。 # -# 业务五服务(gateway/home/api/mcp/timer)打成单镜像 voitrans-a11。 -# 同时推送 : 与 :latest 两个 tag。部署用 ./prod-deploy.sh <区域> 。 +# 业务五服务(gateway/home/api/mcp/timer)打成单镜像 voitrans-a11(所有应用共用此镜像)。 +# 同时推送 : 与 :latest 两个 tag。部署用 ./prod-deploy.sh <应用> <区域> 。 # # 用法(在 deploy/voitrans/ 下): -# ./prod-build.sh # version 必须形如 X.Y.Z,如 0.1.1 +# ./prod-build.sh # 只要版本号(应用共用同一镜像,构建不分应用);version 必须形如 X.Y.Z,如 0.1.1 set -euo pipefail SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -41,4 +41,5 @@ docker push "$IMG_VER" docker push "$IMG_LATEST" green "✓ 已推送 $IMG_VER 与 $IMG_LATEST" -echo " 部署:./prod-deploy.sh $VERSION" +echo " 部署:./prod-deploy.sh <应用> $VERSION" +echo " 例:./prod-deploy.sh voitrans shanghai $VERSION" diff --git a/deploy/voitrans/prod-deploy.sh b/deploy/voitrans/prod-deploy.sh index 30624607..83e7c2d4 100755 --- a/deploy/voitrans/prod-deploy.sh +++ b/deploy/voitrans/prod-deploy.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash -# voitrans 生产环境:部署到指定区域(选择区域 + 镜像版本 tag),支持回滚。 +# 生产环境部署:选择「应用 + 区域 + 镜像版本 tag」部署,支持回滚。 # # 镜像由 ./prod-build.sh 预先构建推送;本脚本只做「远端拉取 + 重启」。 -# 仅做镜像部署:服务器上的 docker-compose.yml / .env / confs/ 由你自行维护 -# (照着仓库里的 env/env.example、confs/*.yaml.example 在服务器上放好真实文件),本脚本不上传任何配置。 -# 各区域一套服务器(见下方 region_profile),各区域共用同一镜像 voitrans-a11。 +# 所有应用共用同一镜像 voitrans-a11 与本目录的模板,仅「远端运行目录」按应用区分(见下方 APPS)。 +# 各区域一套服务器(见下方 region_profile)。 +# 仅做镜像部署:服务器上的 docker-compose.yml / .env / confs/ 全部由你自行维护,本脚本只下发 *.example 模板、绝不覆盖真实文件 +# (首次照同步来的 docker-compose.yml.example / env.example / confs/*.yaml.example 在各应用远端目录复制成真实文件并填值)。 # # 用法(在 deploy/voitrans/ 下): -# ./prod-deploy.sh <区域> # 部署指定版本,如 ./prod-deploy.sh shanghai 0.1.1 -# ./prod-deploy.sh <区域> rollback # 回滚到上一次部署版本(与当前版本来回切换) -# 区域: shanghai | japan | singapore +# ./prod-deploy.sh # 不带参数 → 交互式引导:逐步选「应用 / 区域 / 版本」,部署前确认 +# ./prod-deploy.sh <应用> <区域> # 直接部署,如 ./prod-deploy.sh voitrans shanghai 0.1.1 +# ./prod-deploy.sh <应用> <区域> rollback # 回滚到上一次部署版本(与当前版本来回切换) +# ./prod-deploy.sh <区域> # 省略应用 → 默认首个应用(兼容旧写法) +# ./prod-deploy.sh -h # 查看用法 +# 应用: 见 APPS(默认 voitrans) 区域: shanghai | japan | singapore set -euo pipefail SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -19,59 +23,209 @@ green() { printf "\033[32m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*"; } # ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)── -IMAGE_NAME="voitrans-a11" +IMAGE_NAME="voitrans-a11" # 共用镜像:所有应用都用这一个镜像 REGISTRY="docker-registry.ideapsound.com" REGISTRY_USER="admin" REGISTRY_PASS="li13451234" DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务器一致) # ╔══════════════════════════════════════════════════════════════════════╗ -# ║ 各区域部署目标 —— 写死在此(本文件纳入 git)。公开仓库前清空 HOST。 ║ -# ║ 新增区域:在此加一段 case 填 DEPLOY_HOST ║ +# ║ 应用列表 —— 共用镜像 voitrans-a11 与本目录模板,仅「远端部署目录」按应用区分。 ║ +# ║ 格式: "key|显示名|远端部署目录" ║ +# ║ 新增应用:在此加一行(远端目录在目标服务器上自行备好 .env/confs/compose)。 ║ # ╚══════════════════════════════════════════════════════════════════════╝ -region_profile() { - DEPLOY_PORT=22 - DEPLOY_USER=root - DEPLOY_KEY=~/.ssh/id_rsa - DEPLOY_DIR="/opt/yunyan/voitrans" # 远端部署目录(其中 docker-compose.yml/.env/confs 由你自己维护) +APPS=( + "voitrans|Voitrans|/home/work/voitrans-a11" + "lumi|Lumi|/home/work/lumi-a11" + "echomeet|EchoMeet|/home/work/echomeet-a11" + "deapvoice|DeapVoice|/home/work/deapvoice-a11" + "deapsound|DeapSound|/home/work/deapsound-a11" + "allhelp|AllHelp|/home/work/allhelp-a11" + "deepglass|DeepGlass|/home/work/deepglass-a11" +) +app_keys() { local e k; for e in "${APPS[@]}"; do IFS='|' read -r k _ <<<"$e"; printf '%s\n' "$k"; done; } +app_field() { # $1=key $2=字段序号(2=显示名 3=远端目录) + local e k n r + for e in "${APPS[@]}"; do + IFS='|' read -r k n r <<<"$e" + [ "$k" = "$1" ] || continue + case "$2" in 2) printf '%s' "$n" ;; 3) printf '%s' "$r" ;; esac + return 0 + done + return 1 +} +is_app() { app_field "$1" 2 >/dev/null 2>&1; } +DEFAULT_APP="${APPS[0]%%|*}" # 省略应用时的默认(首个);用参数展开取首行 key,避开 head 截断管道触发 SIGPIPE + +# ╔══════════════════════════════════════════════════════════════════════╗ +# ║ 各区域部署目标 —— 每区域一整套 HOST/PORT/USER/KEY(IP 与 SSH 私钥各不相同)。║ +# ║ 写死在此(本文件纳入 git)。公开仓库前清空 HOST/KEY。 ║ +# ║ 新增区域:在 region_profile 加一段 case,并把区域名加进 REGIONS 数组。 ║ +# ╚══════════════════════════════════════════════════════════════════════╝ +REGIONS=(shanghai japan singapore) # 可选环境/区域(交互菜单按此顺序展示) + +region_profile() { # 远端部署目录由「应用」决定(见 APPS),此处只管各区域服务器连接信息 case "$REGION" in - shanghai) DEPLOY_HOST="" ;; # ← 上海生产服务器 IP(必填) - japan) DEPLOY_HOST="" ;; # ← 日本生产服务器 IP(必填) - singapore) DEPLOY_HOST="" ;; # ← 新加坡生产服务器 IP(必填) - *) red "✗ 未知区域: $REGION(应为 shanghai|japan|singapore)"; exit 1 ;; + shanghai) + DEPLOY_HOST="139.224.212.237" # ← 上海生产服务器 IP(必填) + DEPLOY_PORT=22 + DEPLOY_USER=root + DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem # ← 上海服务器 SSH 私钥(各区域不同) + ;; + japan) + DEPLOY_HOST="8.216.34.101" # ← 日本生产服务器 IP(必填) + DEPLOY_PORT=22 + DEPLOY_USER=root + DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_japan.pem # ← 日本服务器 SSH 私钥(各区域不同) + ;; + singapore) + DEPLOY_HOST="47.84.198.24" # ← 新加坡生产服务器 IP(必填) + DEPLOY_PORT=22 + DEPLOY_USER=root + DEPLOY_KEY=/Users/liwei/Documents/key/alibaba_singapore.pem # ← 新加坡服务器 SSH 私钥(各区域不同) + ;; + *) red "✗ 未知区域: $REGION(应为 ${REGIONS[*]// /|})"; exit 1 ;; esac } -# ── 参数 ── -REGION="${1:-}"; ARG2="${2:-}" -[ -n "$REGION" ] && [ -n "$ARG2" ] || { red "用法: ./prod-deploy.sh "; exit 1; } +# 取某区域的目标主机(供交互菜单展示),不污染当前环境变量 +region_host_of() { ( REGION="$1" region_profile >/dev/null 2>&1; printf '%s' "${DEPLOY_HOST:-}" ); } + +# ── 用法 ── +usage() { + blue "生产部署(共用镜像 ${IMAGE_NAME},仅远端目录按应用区分)" + cat >&2 < <环境/区域> + 应用 : $(app_keys | paste -sd '/' -) + 环境/区域 : ${REGIONS[*]// /, } + tag : 由 ./prod-build.sh 预先构建推送的版本号,如 0.1.1 + rollback : 回滚到上一次部署版本(与当前版本来回切换) + +示例: + ./prod-deploy.sh voitrans shanghai 0.1.1 # 部署 voitrans 0.1.1 到上海 + ./prod-deploy.sh voitrans japan rollback # voitrans 日本回滚到上一版本 + ./prod-deploy.sh shanghai 0.1.1 # 省略应用 → 默认首个应用(${DEFAULT_APP:-?}) + +提示: 不带参数(或参数不全)运行将进入交互式引导,逐步选择应用 / 环境 / 版本。 +EOF +} + +# ── 参数解析:<应用> <区域> ;省略应用兼容旧式 <区域> (默认首个应用)── +case "${1:-}" in -h|--help|help) usage; exit 0 ;; esac + +ARGS=("$@") +APP_KEY=""; REGION=""; ARG2=""; INTERACTIVE=0 + +if [ "${#ARGS[@]}" -gt 0 ] && is_app "${ARGS[0]:-}"; then + APP_KEY="${ARGS[0]}"; REGION="${ARGS[1]:-}"; ARG2="${ARGS[2]:-}" +else + REGION="${ARGS[0]:-}"; ARG2="${ARGS[1]:-}" # 旧式:省略应用 +fi + +# 缺任一项就进交互(仅当不是「旧式两参数」这种完整非交互调用) +enter_interactive() { [ "$INTERACTIVE" = "1" ] && return 0; INTERACTIVE=1; usage; echo >&2; } + +# ① 选择应用 +if [ -z "$APP_KEY" ]; then + if [ -n "$REGION" ] && [ -n "$ARG2" ]; then + APP_KEY="$DEFAULT_APP" # 旧式两参数:默认首个应用,不打断 + else + enter_interactive + blue "① 选择应用:" + i=1 + while IFS= read -r k; do + printf " %d) %-12s → 远端目录 %s\n" "$i" "$k" "$(app_field "$k" 3)" >&2 + i=$((i+1)) + done < <(app_keys) + napp="$(app_keys | wc -l | tr -d ' ')" + printf " 请输入序号 [1-%s]: " "$napp" >&2 + read -r sel || { red "✗ 已取消"; exit 1; } + case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac + { [ "$sel" -ge 1 ] && [ "$sel" -le "$napp" ]; } || { red "✗ 序号超出范围"; exit 1; } + APP_KEY="$(app_keys | sed -n "${sel}p")" + green " ✓ 应用: $APP_KEY" + fi +fi +is_app "$APP_KEY" || { red "✗ 未知应用: $APP_KEY(应为 $(app_keys | paste -sd '/' -))"; exit 1; } + +# 解析应用 → 远端部署目录(镜像与模板共用 voitrans 这套) +APP_DISPLAY="$(app_field "$APP_KEY" 2)" +DEPLOY_DIR="$(app_field "$APP_KEY" 3)" + +# ② 选择环境 / 区域 +if [ -z "$REGION" ]; then + enter_interactive + blue "② 选择部署环境 / 区域:" + i=1 + for r in "${REGIONS[@]}"; do + h="$(region_host_of "$r")" + printf " %d) %-10s → %s\n" "$i" "$r" "${h:-(未配置 IP,需先在脚本 region_profile 填)}" >&2 + i=$((i+1)) + done + printf " 请输入序号 [1-%d]: " "${#REGIONS[@]}" >&2 + read -r sel || { red "✗ 已取消"; exit 1; } + case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac + { [ "$sel" -ge 1 ] && [ "$sel" -le "${#REGIONS[@]}" ]; } || { red "✗ 序号超出范围"; exit 1; } + REGION="${REGIONS[$((sel-1))]}" + green " ✓ 环境: $REGION" +fi + +# ③ 选择版本 / 动作 +if [ -z "$ARG2" ]; then + enter_interactive + echo >&2 + blue "③ 选择版本(输入由 prod-build.sh 推送的 tag,或输入 rollback 回滚):" + printf " tag 或 rollback: " >&2 + read -r ARG2 || { red "✗ 已取消"; exit 1; } + [ -n "$ARG2" ] || { red "✗ 未输入版本"; exit 1; } +fi + if [ "$ARG2" = "rollback" ]; then MODE="rollback"; NEW_TAG=""; else MODE="deploy"; NEW_TAG="$ARG2"; fi +# deploy 模式校验 tag:去空白 + 非空 + 合法字符(避免空/空格 tag 让远端 docker 报 invalid reference format) +if [ "$MODE" = "deploy" ]; then + NEW_TAG="$(printf '%s' "$NEW_TAG" | tr -d '[:space:]')" + [ -n "$NEW_TAG" ] || { red "✗ 未指定版本 tag —— deploy 必须带版本号(如 0.1.1,需先 ./prod-build.sh 0.1.1 构建推送);回滚请用 rollback"; exit 1; } + case "$NEW_TAG" in + *[!A-Za-z0-9._-]*) red "✗ 非法版本 tag: '$NEW_TAG'(只能含字母数字与 . _ -)"; exit 1 ;; + esac +fi + region_profile -: "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填 DEPLOY_HOST}" +: "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 IP}" +: "${DEPLOY_KEY:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 SSH 私钥}" + +# 交互模式下,部署前二次确认(带全参数的非交互/CI 调用不打断) +if [ "$INTERACTIVE" = "1" ]; then + echo >&2 + blue "即将部署 → 应用: $APP_DISPLAY($APP_KEY) 镜像: $IMAGE_NAME 环境: $REGION 目标: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 动作: $MODE${NEW_TAG:+ $NEW_TAG}" + printf "确认执行?[y/N]: " >&2 + read -r yn || true + case "$yn" in [yY]|[yY][eE][sS]) ;; *) red "已取消"; exit 0 ;; esac +fi _ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; } _scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; } _dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; } -# 同步「模板 + compose」(只传模板,绝不覆盖你维护的真实 .env / confs/*.yaml) -blue ">>> [$REGION] 同步模板与 compose → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" +# 同步「模板」(只传 *.example,绝不覆盖你在服务器维护的真实 docker-compose.yml / .env / confs/*.yaml) +blue ">>> [$APP_KEY/$REGION] 同步模板 → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" _ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'" -_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml")" +_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml.example")" _scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEPLOY_DIR/confs/")" _scp "$SVC_DIR/env/env.example" "$(_dest "$DEPLOY_DIR/env.example")" -# 远端拉取并重启(不上传真实配置):tag 历史存服务器上独立的 .deploy_tag,不动你维护的 .env。 -blue ">>> [$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" +# 远端拉取并重启(不上传真实配置):版本历史存独立的 .deploy_tag;部署成功后会把当前 TAG 写回 .env 的 TAG 行(仅这一行)。 +blue ">>> [$APP_KEY/$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" _ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" <<'REMOTE' set -e DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8" [ -f "$DIR/.env" ] || { echo "✗ 缺少 $DIR/.env(请照 env.example 在服务器填好真实 .env)"; exit 1; } [ -f "$DIR/confs/gateway.yaml" ] || { echo "✗ 缺少 $DIR/confs/*.yaml(请照 confs/*.yaml.example 在服务器填好)"; exit 1; } -[ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml"; exit 1; } +[ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml(请照 docker-compose.yml.example 复制为 docker-compose.yml)"; exit 1; } -STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,不动用户的 .env) +STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,记录版本历史) PREVF="$DIR/.deploy_tag.prev" # 上一次部署 tag CUR_TAG="$([ -f "$STATE" ] && cat "$STATE" || true)" CUR_PREV="$([ -f "$PREVF" ] && cat "$PREVF" || true)" @@ -87,13 +241,19 @@ fi echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" docker pull "$IMG_PREFIX:$FINAL_TAG" -# REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),不改写用户的 .env +# REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),确保拉到本次版本 cd "$DIR" && REGISTRY="$REG" TAG="$FINAL_TAG" docker compose --env-file .env up -d docker image prune -f +# 记录当前运行版本:① 写回 .env 的 TAG 行(只改这一行,便于 cat .env 查看)② 维护 .deploy_tag 历史 +if grep -q '^TAG=' "$DIR/.env"; then + sed -i "s/^TAG=.*/TAG=$FINAL_TAG/" "$DIR/.env" +else + printf 'TAG=%s\n' "$FINAL_TAG" >> "$DIR/.env" +fi echo "$FINAL_TAG" > "$STATE" [ -n "$FINAL_PREV" ] && echo "$FINAL_PREV" > "$PREVF" || true -echo "已部署版本 TAG=$FINAL_TAG (上一次=${FINAL_PREV:-无})" +echo "已部署版本 TAG=$FINAL_TAG (上一次=${FINAL_PREV:-无});已写回 .env 的 TAG 行" REMOTE -green "✓ voitrans [$REGION] $MODE 完成" +green "✓ [$APP_KEY/$REGION] $MODE 完成"