311 lines
9.4 KiB
Bash
311 lines
9.4 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):
|
|
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):
|
|
project['login'] = old_login
|
|
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'],
|
|
),
|
|
]
|
|
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
|
|
|
|
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=(',', ':'))
|
|
),
|
|
'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': '',
|
|
}
|
|
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
|