#!/usr/bin/env bash set -euo pipefail METADATA_ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_ROOT="$(cd "$METADATA_ROOT_DIR/../.." && pwd)" RUNTIME_DIR="$PROJECT_ROOT/.dev-runtime/metadata" log() { printf '[metadata] %s\n' "$*" } fail() { printf '[metadata] %s\n' "$*" >&2 exit 1 } load_config() { [[ -f "$METADATA_ROOT_DIR/versions.lock" ]] || fail "缺少 versions.lock" set -a # shellcheck disable=SC1091 source "$METADATA_ROOT_DIR/versions.lock" if [[ -f "$METADATA_ROOT_DIR/.env.local" ]]; then # shellcheck disable=SC1091 source "$METADATA_ROOT_DIR/.env.local" fi set +a : "${NETEASE_SOURCE_TYPE:?}" : "${NETEASE_DIR_REL:?}" : "${NETEASE_HOST:?}" : "${NETEASE_PORT:?}" : "${QQ_SOURCE_TYPE:?}" : "${QQ_REPO_URL:?}" : "${QQ_REF:?}" : "${QQ_DIR_REL:?}" : "${QQ_HOST:?}" : "${QQ_PORT:?}" : "${QQ_DEFAULT_UPSTREAM_PORT:?}" : "${SEARCH_SMOKE_KEYWORDS:?}" : "${SEARCH_SMOKE_LIMIT:?}" NETEASE_DIR="$METADATA_ROOT_DIR/$NETEASE_DIR_REL" QQ_DIR="$METADATA_ROOT_DIR/$QQ_DIR_REL" if [[ "$NETEASE_SOURCE_TYPE" == "npm" ]]; then : "${NETEASE_PACKAGE_NAME:?}" : "${NETEASE_PACKAGE_VERSION:?}" elif [[ "$NETEASE_SOURCE_TYPE" == "git" ]]; then : "${NETEASE_REPO_URL:?}" : "${NETEASE_REF:?}" else fail "不支持的 NETEASE_SOURCE_TYPE: $NETEASE_SOURCE_TYPE" fi if [[ "$QQ_SOURCE_TYPE" == "git" ]]; then : "${QQ_REPO_URL:?}" : "${QQ_REF:?}" else fail "不支持的 QQ_SOURCE_TYPE: $QQ_SOURCE_TYPE" fi } ensure_command() { command -v "$1" >/dev/null 2>&1 || fail "当前环境缺少命令: $1" } ensure_runtime_dir() { mkdir -p "$RUNTIME_DIR" } pid_file_for() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1" } log_file_for() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1" } pid_from_file() { local pid_file="$1" [[ -f "$pid_file" ]] || return 1 local pid pid="$(tr -d '[:space:]' < "$pid_file")" [[ -n "$pid" ]] || return 1 printf '%s\n' "$pid" } is_pid_running() { local pid="$1" kill -0 "$pid" 2>/dev/null } cleanup_stale_pid_file() { local pid_file="$1" if pid="$(pid_from_file "$pid_file" 2>/dev/null)"; then if ! is_pid_running "$pid"; then rm -f "$pid_file" fi elif [[ -f "$pid_file" ]]; then rm -f "$pid_file" fi } active_pid_from_file() { local pid_file="$1" cleanup_stale_pid_file "$pid_file" if pid="$(pid_from_file "$pid_file" 2>/dev/null)" && is_pid_running "$pid"; then printf '%s\n' "$pid" return 0 fi return 1 } ensure_service_source() { local service_name="$1" local service_dir="$2" [[ -d "$service_dir" ]] || fail "$service_name 源码目录不存在: $service_dir,请先执行 bootstrap.sh" [[ -f "$service_dir/package.json" ]] || fail "$service_name 缺少 package.json: $service_dir" [[ -d "$service_dir/node_modules" ]] || fail "$service_name 依赖未安装: $service_dir/node_modules" } wait_for_url() { local service_name="$1" local pid_file="$2" local url="$3" local timeout_seconds="${4:-30}" local elapsed=0 while (( elapsed < timeout_seconds )); do if python3 - "$url" <<'PY' import sys import urllib.request url = sys.argv[1] try: with urllib.request.urlopen(url, timeout=1) as response: status = getattr(response, "status", 200) raise SystemExit(0 if status < 500 else 1) except urllib.error.HTTPError as exc: raise SystemExit(0 if exc.code < 500 else 1) except Exception: raise SystemExit(1) PY then return 0 fi if ! active_pid_from_file "$pid_file" >/dev/null 2>&1; then fail "$service_name 在就绪前已退出,请检查日志" fi sleep 1 elapsed=$((elapsed + 1)) done fail "$service_name 在 ${timeout_seconds}s 内未就绪: $url" } stop_service_process() { local service_name="$1" local pid_file="$2" if ! pid="$(active_pid_from_file "$pid_file" 2>/dev/null)"; then log "$service_name 未运行" return 0 fi log "停止 $service_name (PID $pid)" kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true local attempt=0 while (( attempt < 10 )); do if ! is_pid_running "$pid"; then rm -f "$pid_file" log "$service_name 已停止" return 0 fi sleep 1 attempt=$((attempt + 1)) done kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true rm -f "$pid_file" log "$service_name 已强制停止" } clone_or_prepare_repo() { local service_name="$1" local repo_url="$2" local ref="$3" local service_dir="$4" mkdir -p "$(dirname "$service_dir")" if [[ -d "$service_dir/.git" ]]; then local current_ref current_ref="$(git -C "$service_dir" rev-parse HEAD)" if [[ "$current_ref" == "$ref" ]]; then log "$service_name 源码已存在,revision 已匹配: $current_ref" else log "$service_name 源码已存在,保留当前工作区: $service_dir" log "$service_name 当前 revision: $current_ref" log "$service_name 锁定 revision: $ref" fi return 0 fi if [[ -e "$service_dir" ]]; then fail "$service_name 目标路径已存在但不是 git 仓库: $service_dir" fi log "拉取 $service_name 源码到 $service_dir" git clone "$repo_url" "$service_dir" git -C "$service_dir" checkout "$ref" } prepare_npm_wrapper_dir() { local service_name="$1" local package_name="$2" local package_version="$3" local service_dir="$4" mkdir -p "$service_dir" cat > "$service_dir/package.json" < "$compat_file" <<'EOF' const axios = require('axios'); module.exports = async (ctx, next) => { const keywords = (ctx.query.keywords || ctx.query.keyword || '').trim(); const limit = Math.max(1, Number(ctx.query.limit) || 10); const page = Math.max(1, Number(ctx.query.page) || 1); if (!keywords) { ctx.status = 400; ctx.body = { error: 'keywords is required', }; return; } try { const response = await axios.post( 'https://u.y.qq.com/cgi-bin/musicu.fcg', { comm: { ct: '19', cv: '1859', uin: '0', }, req: { method: 'DoSearchForQQMusicDesktop', module: 'music.search.SearchCgiService', param: { grp: 1, num_per_page: limit, page_num: page, query: keywords, search_type: 0, }, }, }, { timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.8,en;q=0.5', 'Content-Type': 'application/json;charset=utf-8', 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'same-origin', }, }, ); const songPayload = (((response.data || {}).req || {}).data || {}).body || {}; ctx.status = 200; ctx.body = { data: { song: songPayload.song || { list: [], }, }, }; } catch (error) { ctx.status = error.response?.status || 502; ctx.body = { error: 'qq search failed', detail: error.response?.data || error.message, }; } await next(); }; EOF python3 - "$router_file" "$index_file" <<'PY' from pathlib import Path import sys router_path = Path(sys.argv[1]) index_path = Path(sys.argv[2]) router_source = router_path.read_text() route_line = "router.get('/search', context.compatSearch);\n" route_anchor = "router.get('/getHotkey', context.getHotKey);\n" if route_line not in router_source: if route_anchor not in router_source: raise SystemExit("QQ router anchor not found") router_source = router_source.replace( route_anchor, route_anchor + "\n" + route_line, 1, ) router_path.write_text(router_source) index_source = index_path.read_text() require_line = "const compatSearch = require('./compatSearch');\n" require_anchor = "const getHotKey = require('./getHotkey');\n" if require_line not in index_source: if require_anchor not in index_source: raise SystemExit("QQ context require anchor not found") index_source = index_source.replace( require_anchor, require_anchor + require_line, 1, ) export_line = "\tcompatSearch,\n" export_anchor = "\tgetHotKey,\n" if export_line not in index_source: if export_anchor not in index_source: raise SystemExit("QQ context export anchor not found") index_source = index_source.replace( export_anchor, export_anchor + export_line, 1, ) index_path.write_text(index_source) PY }