#!/usr/bin/env bash set -euo pipefail target=/mnt/PoolOne/apps/youyidian-notification-center archive=/tmp/youyidian-notification-center-release.tar.gz source_env=/mnt/PoolOne/apps/youyidian-3d/.env backup_root=/mnt/PoolOne/apps/youyidian-notification-center-backups test -f "$archive" test -f "$source_env" if tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then echo '安装包包含不安全路径' >&2 exit 1 fi mkdir -p "$target" "$backup_root" stamp="$(date +%Y%m%d%H%M%S)" if test -f "$target/package.json"; then tar -czf "$backup_root/source-$stamp.tar.gz" --exclude=.env --exclude=data -C "$target" . fi if test -f "$target/.env"; then cp -a "$target/.env" "$backup_root/env-$stamp" chmod 600 "$backup_root/env-$stamp" fi if docker container inspect youyidian-notification-center >/dev/null 2>&1; then was_running="$(docker inspect -f '{{.State.Running}}' youyidian-notification-center)" if test "$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-$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 if test "$was_running" = true; then docker unpause youyidian-notification-center >/dev/null fi echo '通知中心 SQLite 备份失败,拒绝部署' >&2 exit 1 fi if test "$was_running" = true; then docker unpause youyidian-notification-center >/dev/null trap - EXIT INT TERM fi fi tar -xzf "$archive" -C "$target" python3 - <<'PY' from pathlib import Path import json import os import secrets import shutil import time def parse_env(path: Path): result = {} if not path.exists(): return result for raw in path.read_text(encoding='utf-8').splitlines(): line = raw.strip() if not line or line.startswith('#') or '=' not in line: continue key, value = line.split('=', 1) key = key.strip() value = value.strip() if value.startswith('"') and value.endswith('"'): try: value = json.loads(value) except Exception: value = value[1:-1] elif value.startswith("'") and value.endswith("'"): value = value[1:-1] result[key] = value return result def quoted(value): return json.dumps(str(value), ensure_ascii=False) source_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env') target_path = Path('/mnt/PoolOne/apps/youyidian-notification-center/.env') source = parse_env(source_path) existing = parse_env(target_path) source_app_key = ( source.get('DINGTALK_WORK_NOTICE_CLIENT_ID') or source.get('DINGTALK_WORK_NOTICE_APP_KEY') or source.get('DINGTALK_CLIENT_ID') or source.get('DINGTALK_APP_KEY') ) source_app_secret = ( source.get('DINGTALK_WORK_NOTICE_CLIENT_SECRET') or source.get('DINGTALK_WORK_NOTICE_APP_SECRET') or source.get('DINGTALK_CLIENT_SECRET') or source.get('DINGTALK_APP_SECRET') ) source_robot_code = source.get('DINGTALK_ROBOT_CODE') source_corp_id = ( source.get('DINGTALK_WORK_NOTICE_CORP_ID') or source.get('DINGTALK_CORP_ID') or source.get('DINGTALK_CORPID') or '' ) existing_credentials = [ existing.get('DINGTALK_APP_KEY'), existing.get('DINGTALK_APP_SECRET'), existing.get('DINGTALK_ROBOT_CODE'), ] if all(existing_credentials): app_key, app_secret, robot_code = existing_credentials elif any(existing_credentials): raise SystemExit('通知中心已有钉钉配置不完整,拒绝从其它项目拼接覆盖') else: if not source_app_key or not source_app_secret or not source_robot_code: raise SystemExit('通知中心首次启动缺少钉钉配置,且安全 bootstrap 来源不完整') app_key, app_secret, robot_code = source_app_key, source_app_secret, source_robot_code corp_id = existing.get('DINGTALK_CORP_ID') or '' if not corp_id and source_corp_id: if source_app_key and source_app_key != app_key: raise SystemExit('通知中心 AppKey 与 bootstrap 来源不一致,拒绝复制 CorpId') corp_id = source_corp_id try: old_projects = json.loads(existing.get('YYD_NOTIFICATION_PROJECTS_JSON', '[]')) except Exception: old_projects = [] by_code = { str(item.get('code')): item for item in old_projects if isinstance(item, dict) } def secret_for(code): old = str(by_code.get(code, {}).get('secret') or '') return old if len(old) >= 24 else secrets.token_hex(32) def project_with_preserved_login(code, name, allowed_hosts, required_redirect_prefixes=()): project = { 'code': code, 'name': name, 'secret': secret_for(code), 'allowedHosts': allowed_hosts, } old_login = by_code.get(code, {}).get('login') if isinstance(old_login, dict): login = dict(old_login) existing_prefixes = login.get('allowedRedirectPathPrefixes') if isinstance(existing_prefixes, list): prefixes = [str(prefix).strip() for prefix in existing_prefixes if str(prefix).strip()] for prefix in required_redirect_prefixes: if prefix not in prefixes: prefixes.append(prefix) login['allowedRedirectPathPrefixes'] = prefixes project['login'] = login old_recipients = by_code.get(code, {}).get('recipients') if isinstance(old_recipients, list): project['recipients'] = list(old_recipients) return project projects = [ project_with_preserved_login( 'material_library', '素材库', ['sc.jiansun.vip'], ), project_with_preserved_login( 'fixed_asset', '固定资产', ['asset.jiansun.vip', 'www.jsgdzc.cn', 'jsgdzc.cn'], [ '/workflows/disposals', '/maintenance', '/my-assets', '/equipment', '/assets', '/notifications', '/dashboard', '/inventory', ], ), project_with_preserved_login( 'furniture_platform', '家具业务系统', ['cp.jiansun.vip'], ), project_with_preserved_login( 'script_monitor', '脚本监控', ['cp.jiansun.vip'], ), ] # 兜底:生产 .env 里已登记、但上面清单未列出的项目原样保留, # 避免重新部署时把手动登记的接入方从初始化配置中覆盖丢失。 known_codes = {item['code'] for item in projects} for item in old_projects: if not isinstance(item, dict): continue code = str(item.get('code') or '').strip() if not code or code in known_codes: continue if len(str(item.get('secret') or '')) < 24: continue projects.append(dict(item)) known_codes.add(code) if any(item.get('login', {}).get('enabled') is True for item in projects) and not corp_id: raise SystemExit('已有项目启用了免登中转,但生产配置缺少 DINGTALK_CORP_ID') try: ticket_ttl = int(existing.get('DINGTALK_LOGIN_TICKET_TTL_SECONDS', '60')) except Exception: ticket_ttl = 60 if ticket_ttl < 30 or ticket_ttl > 60: ticket_ttl = 60 admin_api_secret = ( existing.get('YYD_NOTIFICATION_ADMIN_API_SECRET') or existing.get('YYD_NOTIFICATION_ADMIN_TOKEN') or secrets.token_hex(32) ) directory_url = ( existing.get('YYD_NOTIFICATION_DIRECTORY_URL') or 'https://sc.jiansun.vip/api/internal/notification-center/recipients/resolve' ) config_encryption_key = ( existing.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') or secrets.token_hex(32) ) center_lines = [ 'PORT=3088', 'DINGTALK_APP_KEY=' + quoted(app_key), 'DINGTALK_APP_SECRET=' + quoted(app_secret), 'DINGTALK_ROBOT_CODE=' + quoted(robot_code), 'DINGTALK_CORP_ID=' + quoted(corp_id), 'YYD_NOTIFICATION_PUBLIC_BASE_URL=' + quoted( existing.get('YYD_NOTIFICATION_PUBLIC_BASE_URL') or 'https://sc.jiansun.vip/notification-center' ), 'YYD_NOTIFICATION_DIRECTORY_URL=' + quoted(directory_url), 'DINGTALK_LOGIN_TICKET_TTL_SECONDS=' + str(ticket_ttl), 'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted( json.dumps(projects, ensure_ascii=False, separators=(',', ':')) ), 'YYD_NOTIFICATION_ADMIN_API_SECRET=' + quoted(admin_api_secret), 'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY=' + quoted(config_encryption_key), 'WORKER_ENABLED=true', 'WORKER_INTERVAL_MS=1000', 'WORKER_SEND_MIN_INTERVAL_MS=250', 'READ_STATUS_INTERVAL_MS=5000', ] target_path.write_text('\n'.join(center_lines) + '\n', encoding='utf-8') os.chmod(target_path, 0o600) material_secret = next( item['secret'] for item in projects if item['code'] == 'material_library' ) updates = { 'YYD_NOTIFICATION_CENTER_URL': 'https://sc.jiansun.vip/notification-center', 'YYD_NOTIFICATION_CENTER_PROJECT_CODE': 'material_library', 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET': material_secret, 'YYD_NOTIFICATION_CENTER_TEST_USER_IDS': '', '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', } backup = source_path.with_name( '.env.before-yyd-notification-' + time.strftime('%Y%m%d%H%M%S') ) shutil.copy2(source_path, backup) os.chmod(backup, 0o600) lines = source_path.read_text(encoding='utf-8').splitlines() seen = set() output = [] for line in 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)) source_path.write_text('\n'.join(output) + '\n', encoding='utf-8') os.chmod(source_path, 0o600) print('通知中心与素材库适配配置已写入') PY cd "$target" docker compose build --pull=false 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 test "$healthy" != 1; then docker compose logs --tail 120 exit 1 fi python3 - <<'PY' from pathlib import Path source = Path('/etc/frp/frpc-sc-entry.toml').read_text(encoding='utf-8') header = source.split('[[proxies]]', 1)[0] header = header.replace( '/root/frp/frpc-sc-entry.log', '/root/frp/frpc-notify-entry.log', ) proxy = '''[[proxies]] name = "youyidian-notification-center" type = "tcp" localIP = "127.0.0.1" localPort = 3088 remotePort = 2326 healthCheck.type = "http" healthCheck.path = "/health" healthCheck.intervalSeconds = 15 healthCheck.maxFailed = 3 healthCheck.timeoutSeconds = 5 ''' Path('/etc/frp/frpc-notify-entry.toml').write_text( header.rstrip() + '\n\n' + proxy, encoding='utf-8', ) PY chmod 600 /etc/frp/frpc-notify-entry.toml cat > /etc/systemd/system/frpc-notify-entry.service <<'EOF' [Unit] Description=FRP Client for Youyidian notification center After=network-online.target docker.service Wants=network-online.target Requires=docker.service [Service] Type=simple Restart=always RestartSec=10s LimitNOFILE=65535 ExecStart=/root/frp-upgrade/frp_0.67.0_linux_amd64/frpc -c /etc/frp/frpc-notify-entry.toml StandardOutput=append:/root/frp/frpc-notify-entry.service.log StandardError=append:/root/frp/frpc-notify-entry.service.log [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now frpc-notify-entry.service sleep 3 systemctl is-active --quiet frpc-notify-entry.service rm -f "$archive" curl -fsS http://127.0.0.1:3088/health echo printf 'CENTER_CONTAINER=' docker inspect -f '{{.State.Status}}' youyidian-notification-center printf 'FRP_SERVICE=' systemctl is-active frpc-notify-entry.service