#Requires -Version 5.1 param( [string]$RepoUrl = 'http://git.1080p.hk/WuYongJie/YouYiDianNotificationCenter.git', [string]$Branch = 'main', [string]$Commit = '', [string]$BuildServer = 'root@192.168.40.206', [string]$ProductionServer = 'root@192.168.58.20', [string]$RemoteBuildDir = '/root/youyidian-notification-center-build', [string]$RegistryHost = '192.168.40.206:5000', [string]$ImageName = 'youyidian-notification-center', [switch]$DryRun ) $ErrorActionPreference = 'Stop' function ConvertTo-ShellSingleQuoted([string]$Value) { return "'" + $Value.Replace("'", "'\''") + "'" } $remoteScript = @' set -Eeuo pipefail REPO_URL=__REPO_URL__ BRANCH=__BRANCH__ COMMIT_INPUT=__COMMIT__ BUILD_DIR=__BUILD_DIR__ PRODUCTION_SERVER=__PRODUCTION_SERVER__ REGISTRY_HOST=__REGISTRY_HOST__ IMAGE_NAME=__IMAGE_NAME__ DRY_RUN=__DRY_RUN__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } for command in git docker curl ssh base64; do command -v "$command" >/dev/null 2>&1 || fail "构建机缺少命令: $command" done curl -fsS "http://127.0.0.1:${REGISTRY_HOST##*:}/v2/" >/dev/null \ || fail '构建机内部镜像仓库不可用' ssh -o BatchMode=yes -o ConnectTimeout=10 "$PRODUCTION_SERVER" 'true' \ || fail '构建机无法连接生产机' if [ ! -d "$BUILD_DIR/.git" ]; then rm -rf "$BUILD_DIR" git clone "$REPO_URL" "$BUILD_DIR" fi cd "$BUILD_DIR" git remote set-url origin "$REPO_URL" git fetch --prune origin "$BRANCH" target="origin/$BRANCH" if [ -n "$COMMIT_INPUT" ]; then target="$COMMIT_INPUT" fi git rev-parse --verify "${target}^{commit}" >/dev/null if [ "$DRY_RUN" = 1 ]; then ok "预检通过,目标提交: $(git rev-parse "${target}^{commit}")" exit 0 fi git checkout --detach "$target" git reset --hard "$target" git clean -fdx commit_sha="$(git rev-parse HEAD)" short_sha="$(git rev-parse --short=12 HEAD)" image_tag="production-${short_sha}" local_commit="localhost:${REGISTRY_HOST##*:}/${IMAGE_NAME}:${image_tag}" local_latest="localhost:${REGISTRY_HOST##*:}/${IMAGE_NAME}:production-latest" remote_commit="${REGISTRY_HOST}/${IMAGE_NAME}:${image_tag}" base_image="localhost:${REGISTRY_HOST##*:}/yyd-base/node:22-alpine" log "构建提交 ${commit_sha}" docker pull "$base_image" >/dev/null \ || fail "内部基础镜像不可用: $base_image" docker pull "$local_latest" >/dev/null 2>&1 || true build_args=(--build-arg "BASE_IMAGE=$base_image") if docker image inspect "$local_latest" >/dev/null 2>&1; then build_args+=(--cache-from "$local_latest") fi DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \ -t "$local_commit" \ -t "$local_latest" \ . docker push "$local_commit" docker push "$local_latest" ok "镜像已推送: $remote_commit" if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$PRODUCTION_SERVER" \ "docker pull '$remote_commit'"; then log '生产机无法直接拉取,改用 SSH 传输镜像' docker save "$local_commit" | ssh -o BatchMode=yes "$PRODUCTION_SERVER" docker load ssh -o BatchMode=yes "$PRODUCTION_SERVER" \ "docker tag '$local_commit' '$remote_commit'" fi production_script="$(cat <<'PRODUCTION' set -Eeuo pipefail target=/mnt/PoolOne/apps/youyidian-notification-center backup_root=/mnt/PoolOne/apps/youyidian-notification-center-backups cd "$target" test -f .env test -f docker-compose.yml mkdir -p "$backup_root" stamp="$(date +%Y%m%d%H%M%S)" cp -a .env "$backup_root/env-before-git-release-$stamp" chmod 600 "$backup_root/env-before-git-release-$stamp" cp -a docker-compose.yml "$backup_root/docker-compose-before-git-release-$stamp.yml" chmod 600 "$backup_root/docker-compose-before-git-release-$stamp.yml" material_env=/mnt/PoolOne/apps/youyidian-3d/.env test -f "$material_env" cp -a "$material_env" "$backup_root/material-env-before-git-release-$stamp" chmod 600 "$backup_root/material-env-before-git-release-$stamp" # 盘点任务通过统一 H5 免登入口打开,必须显式保留 /inventory 白名单;不放宽为全站路径。 python3 - <<'PY' from pathlib import Path import json import secrets def parse_env_value(raw: str) -> str: value = raw.strip() if value.startswith('"') and value.endswith('"'): return json.loads(value) if value.startswith("'") and value.endswith("'"): return value[1:-1] return value compose_path = Path('docker-compose.yml') compose_text = compose_path.read_text(encoding='utf-8') compose_marker = ' YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON}' compose_required = [ ' YYD_NOTIFICATION_DIRECTORY_URL: ${YYD_NOTIFICATION_DIRECTORY_URL:-https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve}', ' YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET}', ' YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY}', ] if compose_text.count(compose_marker) != 1: raise SystemExit('通知中心 Compose 项目配置锚点不唯一,拒绝修改') compose_changed = False for line in compose_required: if compose_text.count(line) > 1: raise SystemExit('通知中心 Compose 环境映射重复,拒绝修改') if line in compose_text: continue variable_name = line.strip().split(':', 1)[0] if any(candidate.strip().startswith(variable_name + ':') for candidate in compose_text.splitlines()): raise SystemExit(f'通知中心 Compose {variable_name} 存在不同映射,拒绝猜测性修改') compose_text = compose_text.replace(compose_marker, compose_marker + '\n' + line, 1) compose_changed = True if compose_changed: compose_path.write_text(compose_text, encoding='utf-8') env_path = Path('.env') lines = env_path.read_text(encoding='utf-8').splitlines() def env_values(source_lines): result = {} for line in source_lines: if '=' not in line or line.lstrip().startswith('#'): continue key, raw = line.split('=', 1) result[key.strip()] = parse_env_value(raw) return result def upsert_env(source_lines, updates): output = [] seen = set() for line in source_lines: if '=' in line and not line.lstrip().startswith('#'): key = line.split('=', 1)[0].strip() if key in updates: output.append(key + '=' + str(updates[key])) seen.add(key) continue output.append(line) for key, value in updates.items(): if key not in seen: output.append(key + '=' + str(value)) return output existing_env = env_values(lines) admin_api_secret = ( existing_env.get('YYD_NOTIFICATION_ADMIN_API_SECRET') or existing_env.get('YYD_NOTIFICATION_ADMIN_TOKEN') or secrets.token_hex(32) ) directory_url = ( existing_env.get('YYD_NOTIFICATION_DIRECTORY_URL') or 'https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve' ) config_encryption_key = ( existing_env.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') or secrets.token_hex(32) ) projects_index = next( ( index for index, line in enumerate(lines) if not line.lstrip().startswith('#') and line.startswith('YYD_NOTIFICATION_PROJECTS_JSON=') ), None, ) if projects_index is None: raise SystemExit('通知中心缺少 YYD_NOTIFICATION_PROJECTS_JSON,拒绝放宽或猜测免登配置') key, raw_value = lines[projects_index].split('=', 1) projects = json.loads(parse_env_value(raw_value)) if not isinstance(projects, list): raise SystemExit('通知中心项目配置不是数组,拒绝修改') fixed_asset = next( (item for item in projects if isinstance(item, dict) and item.get('code') == 'fixed_asset'), None, ) if not isinstance(fixed_asset, dict): raise SystemExit('通知中心缺少 fixed_asset 项目配置,拒绝修改') login = fixed_asset.get('login') if isinstance(login, dict) and login.get('enabled') is True: prefixes = login.get('allowedRedirectPathPrefixes') if not isinstance(prefixes, list): raise SystemExit('fixed_asset 免登白名单格式无效,拒绝修改') normalized = [str(prefix).strip() for prefix in prefixes if str(prefix).strip()] if '/inventory' not in normalized: normalized.append('/inventory') login['allowedRedirectPathPrefixes'] = normalized lines[projects_index] = key + '=' + json.dumps( json.dumps(projects, ensure_ascii=False, separators=(',', ':')), ensure_ascii=False, ) env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') env_path.chmod(0o600) lines = upsert_env(lines, { 'YYD_NOTIFICATION_DIRECTORY_URL': directory_url, 'YYD_NOTIFICATION_ADMIN_API_SECRET': admin_api_secret, 'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY': config_encryption_key, }) env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') env_path.chmod(0o600) material_env_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env') material_lines = material_env_path.read_text(encoding='utf-8').splitlines() material_lines = upsert_env(material_lines, { 'YYD_NOTIFICATION_CENTER_ADMIN_URL': 'https://sc.jiansun.vip/notification-center/admin', 'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET': admin_api_secret, 'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS': '8000', }) material_env_path.write_text('\n'.join(material_lines) + '\n', encoding='utf-8') material_env_path.chmod(0o600) print('fixed_asset 免登回跳白名单已核对 /inventory') PY if docker container inspect youyidian-notification-center >/dev/null 2>&1; then was_running="$(docker inspect -f '{{.State.Running}}' youyidian-notification-center)" if [ "$was_running" = true ]; then docker pause youyidian-notification-center >/dev/null cleanup_unpause() { docker unpause youyidian-notification-center >/dev/null 2>&1 || true } trap cleanup_unpause EXIT INT TERM fi data_backup="$backup_root/notification-data-before-git-release-$stamp" mkdir -p "$data_backup" # 整体复制可同时保存 notifications.db、-journal、-wal 与 -shm。 if ! docker cp youyidian-notification-center:/app/data/. "$data_backup/" || ! test -s "$data_backup/notifications.db"; then fail_message='通知中心 SQLite 备份失败,拒绝重建生产容器' if [ "$was_running" = true ]; then docker unpause youyidian-notification-center >/dev/null || true trap - EXIT INT TERM fi printf '[ERR] %s\n' "$fail_message" >&2 exit 1 fi if [ "$was_running" = true ]; then docker unpause youyidian-notification-center >/dev/null trap - EXIT INT TERM fi fi old_image="$(docker inspect -f '{{.Config.Image}}' youyidian-notification-center 2>/dev/null || true)" if [ -n "$old_image" ] && docker image inspect "$old_image" >/dev/null 2>&1; then docker tag "$old_image" youyidian-notification-center:rollback-before-release fi docker tag "$REMOTE_IMAGE" "youyidian-notification-center:$IMAGE_TAG" YYD_NOTIFICATION_IMAGE_TAG="$IMAGE_TAG" docker compose up -d --no-build --force-recreate healthy=0 for _ in $(seq 1 30); do if curl -fsS --max-time 3 http://127.0.0.1:3088/health >/tmp/yyd-notification-health.json; then healthy=1 break fi sleep 2 done if [ "$healthy" != 1 ]; then docker compose logs --tail 120 if docker image inspect youyidian-notification-center:rollback-before-release >/dev/null 2>&1; then YYD_NOTIFICATION_IMAGE_TAG=rollback-before-release docker compose up -d --no-build --force-recreate fi exit 1 fi public_healthy=0 for _ in $(seq 1 15); do if curl -fsS --max-time 5 https://sc.jiansun.vip/notification-center/health >/tmp/yyd-notification-public-health.json; then public_healthy=1 break fi sleep 2 done if [ "$public_healthy" != 1 ]; then echo '通知中心公网健康检查持续失败,保留本机健康的新容器以便排查反向代理。' >&2 exit 1 fi printf '%s' "$COMMIT_SHA" > DEPLOYED_COMMIT cat /tmp/yyd-notification-public-health.json printf '\nDEPLOYED_COMMIT=' cat DEPLOYED_COMMIT printf '\nCONTAINER_IMAGE=' docker inspect -f '{{.Config.Image}}' youyidian-notification-center printf '\n' PRODUCTION )" production_script_base64="$(printf '%s\n' "$production_script" | base64 | tr -d '\n')" ssh -o BatchMode=yes "$PRODUCTION_SERVER" \ "printf '%s' '$production_script_base64' | base64 -d | REMOTE_IMAGE='$remote_commit' IMAGE_TAG='$image_tag' COMMIT_SHA='$commit_sha' bash -s" ok "通知中心已部署到 ${commit_sha}" '@ $replacements = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl '__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch '__COMMIT__' = ConvertTo-ShellSingleQuoted $Commit '__BUILD_DIR__' = ConvertTo-ShellSingleQuoted $RemoteBuildDir '__PRODUCTION_SERVER__' = ConvertTo-ShellSingleQuoted $ProductionServer '__REGISTRY_HOST__' = ConvertTo-ShellSingleQuoted $RegistryHost '__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName '__DRY_RUN__' = if ($DryRun) { '1' } else { '0' } } foreach ($entry in $replacements.GetEnumerator()) { $remoteScript = $remoteScript.Replace($entry.Key, $entry.Value) } $remoteScript = $remoteScript.Replace("`r`n", "`n") # Freeze production-script bytes locally before crossing PowerShell, Bash, and SSH. $productionBlockPatternText = '(?ms)^production_script="\$\(cat <<' + [char]39 + 'PRODUCTION' + [char]39 + '\n(?.*?)\nPRODUCTION\n\)"\n\nproduction_script_base64=.*?$' $productionBlockPattern = [regex]$productionBlockPatternText $productionBlockMatch = $productionBlockPattern.Match($remoteScript) if (-not $productionBlockMatch.Success) { throw 'Cannot extract the production deployment script; release aborted.' } $productionScriptBytes = [System.Text.Encoding]::UTF8.GetBytes( $productionBlockMatch.Groups['body'].Value + "`n" ) $productionScriptBase64 = [Convert]::ToBase64String($productionScriptBytes) $productionBlockReplacement = "production_script_base64=$(ConvertTo-ShellSingleQuoted $productionScriptBase64)" $remoteScript = $remoteScript.Remove( $productionBlockMatch.Index, $productionBlockMatch.Length ).Insert($productionBlockMatch.Index, $productionBlockReplacement) $localTemp = Join-Path ([System.IO.Path]::GetTempPath()) ("yyd-notification-deploy-{0}.sh" -f [guid]::NewGuid().ToString('N')) $remoteTemp = "/tmp/$(Split-Path $localTemp -Leaf)" try { [System.IO.File]::WriteAllText( $localTemp, $remoteScript, [System.Text.UTF8Encoding]::new($false) ) & scp -o BatchMode=yes -o ConnectTimeout=10 $localTemp "${BuildServer}:$remoteTemp" if ($LASTEXITCODE -ne 0) { throw "通知中心发布脚本上传失败,退出码: $LASTEXITCODE" } & ssh -o BatchMode=yes -o ConnectTimeout=10 $BuildServer "chmod 700 '$remoteTemp' && bash '$remoteTemp'; code=`$?; rm -f '$remoteTemp'; exit `$code" if ($LASTEXITCODE -ne 0) { throw "通知中心构建机发布失败,退出码: $LASTEXITCODE" } } finally { Remove-Item -LiteralPath $localTemp -Force -ErrorAction SilentlyContinue }