YouYiDianNotificationCenter/scripts/deploy-material-host.sh

372 lines
12 KiB
Bash

#!/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)
)
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'
),
'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',
'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