#!/usr/bin/env bash set -euo pipefail APP_NAME="${APP_NAME:-aiapi-market}" INPUT_IMAGE="${IMAGE-}" INPUT_IMAGE_TAR="${IMAGE_TAR-}" IMAGE="${IMAGE:-loongmoon/aiapi-market:latest}" IMAGE_TAR="${IMAGE_TAR:-}" PORT="${PORT:-3000}" INSTALL_DIR="${INSTALL_DIR:-/opt/${APP_NAME}}" UPDATE_EXISTING_COMPOSE="${UPDATE_EXISTING_COMPOSE:-false}" EXISTING_APP_SERVICE="${EXISTING_APP_SERVICE:-new-api}" TIMEZONE="${TIMEZONE:-Asia/Shanghai}" PUBLIC_HOST="${PUBLIC_HOST:-localhost}" NO_PROXY="${NO_PROXY:-localhost,127.0.0.1,::1}" no_proxy="${no_proxy:-localhost,127.0.0.1,::1}" export NO_PROXY no_proxy INPUT_FRONTEND_THEME="${FRONTEND_THEME-}" INPUT_SYSTEM_NAME="${SYSTEM_NAME-}" INPUT_UPSTREAM_CHANNEL_NAME="${UPSTREAM_CHANNEL_NAME-}" INPUT_UPSTREAM_BASE_URL="${UPSTREAM_BASE_URL-}" INPUT_UPSTREAM_API_KEY="${UPSTREAM_API_KEY-}" INPUT_UPSTREAM_FALLBACK_GROUP="${UPSTREAM_FALLBACK_GROUP-}" INPUT_UPSTREAM_MARKUP_RATE="${UPSTREAM_MARKUP_RATE-}" INPUT_UPSTREAM_LOCAL_RECHARGE_RATIO="${UPSTREAM_LOCAL_RECHARGE_RATIO-}" INPUT_UPSTREAM_RECHARGE_RATIO="${UPSTREAM_RECHARGE_RATIO-}" INPUT_UPSTREAM_DEFAULT_MODELS="${UPSTREAM_DEFAULT_MODELS-}" FRONTEND_THEME="${FRONTEND_THEME:-default}" SYSTEM_NAME="${SYSTEM_NAME:-AI API}" UPSTREAM_CHANNEL_NAME="${UPSTREAM_CHANNEL_NAME:-AIAPI-Market-Upstream}" UPSTREAM_BASE_URL="${UPSTREAM_BASE_URL:-https://aiapi.market}" UPSTREAM_API_KEY="${UPSTREAM_API_KEY:-}" UPSTREAM_FALLBACK_GROUP="${UPSTREAM_FALLBACK_GROUP:-aiapi}" UPSTREAM_MARKUP_RATE="${UPSTREAM_MARKUP_RATE:-1.15}" UPSTREAM_LOCAL_RECHARGE_RATIO="${UPSTREAM_LOCAL_RECHARGE_RATIO:-1}" UPSTREAM_RECHARGE_RATIO="${UPSTREAM_RECHARGE_RATIO:-}" UPSTREAM_DEFAULT_MODELS="${UPSTREAM_DEFAULT_MODELS:-gpt-4o-mini}" UPSTREAM_PLACEHOLDER_API_KEY="__AIAPI_MARKET_FILL_API_KEY__" COMPOSE_FILE="${INSTALL_DIR}/docker-compose.yml" ENV_FILE="${INSTALL_DIR}/.env" log() { printf '\033[1;34m[aiapi-market]\033[0m %s\n' "$*" } warn() { printf '\033[1;33m[aiapi-market]\033[0m %s\n' "$*" } fail() { printf '\033[1;31m[aiapi-market]\033[0m %s\n' "$*" >&2 exit 1 } need_cmd() { command -v "$1" >/dev/null 2>&1 } random_secret() { if need_cmd openssl; then openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}" else LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null; then return fi as_root mkdir -p "${INSTALL_DIR}" if [ "$(id -u)" -ne 0 ]; then as_root chown "$(id -u):$(id -g)" "${INSTALL_DIR}" fi } install_docker_if_needed() { if need_cmd docker; then return fi if [ "$(uname -s)" != "Linux" ]; then fail "未检测到 Docker。请先安装 Docker Desktop 后重试。" fi warn "未检测到 Docker,将尝试自动安装 Docker Engine。" need_cmd curl || fail "未检测到 curl,无法自动安装 Docker。请先安装 curl 或 Docker。" curl -fsSL https://get.docker.com | as_root sh if ! need_cmd docker; then fail "Docker 安装后仍不可用,请检查系统环境。" fi } compose_cmd() { if docker compose version >/dev/null 2>&1; then docker compose "$@" elif need_cmd docker-compose; then docker-compose "$@" else fail "未检测到 Docker Compose。请安装 Docker Compose plugin 后重试。" fi } ensure_docker_running() { if ! docker info >/dev/null 2>&1; then fail "Docker daemon 未运行,请启动 Docker 后重试。" fi } write_env_file() { if [ -f "${ENV_FILE}" ]; then log "检测到已有 ${ENV_FILE},沿用现有配置。" return fi local postgres_password redis_password session_secret admin_password postgres_password="$(random_secret 32)" redis_password="$(random_secret 32)" session_secret="$(random_secret 48)" admin_password="$(random_secret 16)" umask 077 cat >"${ENV_FILE}" <>"${ENV_FILE}" grep -q '^SYSTEM_NAME=' "${ENV_FILE}" || printf 'SYSTEM_NAME=%s\n' "$(shell_quote "${SYSTEM_NAME}")" >>"${ENV_FILE}" grep -q '^UPSTREAM_CHANNEL_NAME=' "${ENV_FILE}" || printf 'UPSTREAM_CHANNEL_NAME=%s\n' "$(shell_quote "${UPSTREAM_CHANNEL_NAME}")" >>"${ENV_FILE}" grep -q '^UPSTREAM_BASE_URL=' "${ENV_FILE}" || printf 'UPSTREAM_BASE_URL=%s\n' "$(shell_quote "${UPSTREAM_BASE_URL}")" >>"${ENV_FILE}" grep -q '^UPSTREAM_API_KEY=' "${ENV_FILE}" || printf 'UPSTREAM_API_KEY=%s\n' "$(shell_quote "${UPSTREAM_API_KEY}")" >>"${ENV_FILE}" grep -q '^UPSTREAM_FALLBACK_GROUP=' "${ENV_FILE}" || printf 'UPSTREAM_FALLBACK_GROUP=%s\n' "$(shell_quote "${UPSTREAM_FALLBACK_GROUP}")" >>"${ENV_FILE}" grep -q '^UPSTREAM_MARKUP_RATE=' "${ENV_FILE}" || printf 'UPSTREAM_MARKUP_RATE=%s\n' "${UPSTREAM_MARKUP_RATE}" >>"${ENV_FILE}" grep -q '^UPSTREAM_LOCAL_RECHARGE_RATIO=' "${ENV_FILE}" || printf 'UPSTREAM_LOCAL_RECHARGE_RATIO=%s\n' "${UPSTREAM_LOCAL_RECHARGE_RATIO}" >>"${ENV_FILE}" grep -q '^UPSTREAM_RECHARGE_RATIO=' "${ENV_FILE}" || printf 'UPSTREAM_RECHARGE_RATIO=%s\n' "${UPSTREAM_RECHARGE_RATIO}" >>"${ENV_FILE}" grep -q '^UPSTREAM_DEFAULT_MODELS=' "${ENV_FILE}" || printf 'UPSTREAM_DEFAULT_MODELS=%s\n' "$(shell_quote "${UPSTREAM_DEFAULT_MODELS}")" >>"${ENV_FILE}" grep -q '^AIAPI_MARKET_IMAGE=' "${ENV_FILE}" || printf 'AIAPI_MARKET_IMAGE=%s\n' "$(shell_quote "${IMAGE}")" >>"${ENV_FILE}" grep -q '^AIAPI_MARKET_IMAGE_REPOSITORY=' "${ENV_FILE}" || printf 'AIAPI_MARKET_IMAGE_REPOSITORY=%s\n' "$(shell_quote "loongmoon/aiapi-market")" >>"${ENV_FILE}" grep -q '^AIAPI_MARKET_IMAGE_TAG=' "${ENV_FILE}" || printf 'AIAPI_MARKET_IMAGE_TAG=%s\n' "$(shell_quote "latest")" >>"${ENV_FILE}" grep -q '^AIAPI_MARKET_IMAGE_DIGEST=' "${ENV_FILE}" || printf "AIAPI_MARKET_IMAGE_DIGEST=''\n" >>"${ENV_FILE}" grep -q '^AIAPI_MARKET_UPDATE_COMMAND=' "${ENV_FILE}" || printf 'AIAPI_MARKET_UPDATE_COMMAND=%s\n' "$(shell_quote "cd ${INSTALL_DIR} && docker compose pull && docker compose up -d")" >>"${ENV_FILE}" } set_env_value() { local key="$1" local value="$2" local tmp_file tmp_file="$(mktemp)" awk -v key="${key}" -v value="${value}" ' BEGIN { done = 0 } $0 ~ "^" key "=" { if (!done) { print key "=" value done = 1 } next } { print } END { if (!done) { print key "=" value } } ' "${ENV_FILE}" >"${tmp_file}" mv "${tmp_file}" "${ENV_FILE}" } apply_input_overrides() { if [ -n "${INPUT_FRONTEND_THEME}" ]; then FRONTEND_THEME="${INPUT_FRONTEND_THEME}" set_env_value "FRONTEND_THEME" "$(shell_quote "${FRONTEND_THEME}")" fi if [ -n "${INPUT_SYSTEM_NAME}" ]; then SYSTEM_NAME="${INPUT_SYSTEM_NAME}" set_env_value "SYSTEM_NAME" "$(shell_quote "${SYSTEM_NAME}")" fi if [ -n "${INPUT_UPSTREAM_CHANNEL_NAME}" ]; then UPSTREAM_CHANNEL_NAME="${INPUT_UPSTREAM_CHANNEL_NAME}" set_env_value "UPSTREAM_CHANNEL_NAME" "$(shell_quote "${UPSTREAM_CHANNEL_NAME}")" fi if [ -n "${INPUT_UPSTREAM_BASE_URL}" ]; then UPSTREAM_BASE_URL="${INPUT_UPSTREAM_BASE_URL}" set_env_value "UPSTREAM_BASE_URL" "$(shell_quote "${UPSTREAM_BASE_URL}")" fi if [ -n "${INPUT_UPSTREAM_API_KEY}" ]; then UPSTREAM_API_KEY="${INPUT_UPSTREAM_API_KEY}" set_env_value "UPSTREAM_API_KEY" "$(shell_quote "${UPSTREAM_API_KEY}")" fi if [ -n "${INPUT_UPSTREAM_FALLBACK_GROUP}" ]; then UPSTREAM_FALLBACK_GROUP="${INPUT_UPSTREAM_FALLBACK_GROUP}" set_env_value "UPSTREAM_FALLBACK_GROUP" "$(shell_quote "${UPSTREAM_FALLBACK_GROUP}")" fi if [ -n "${INPUT_UPSTREAM_MARKUP_RATE}" ]; then UPSTREAM_MARKUP_RATE="${INPUT_UPSTREAM_MARKUP_RATE}" set_env_value "UPSTREAM_MARKUP_RATE" "${UPSTREAM_MARKUP_RATE}" fi if [ -n "${INPUT_UPSTREAM_LOCAL_RECHARGE_RATIO}" ]; then UPSTREAM_LOCAL_RECHARGE_RATIO="${INPUT_UPSTREAM_LOCAL_RECHARGE_RATIO}" set_env_value "UPSTREAM_LOCAL_RECHARGE_RATIO" "${UPSTREAM_LOCAL_RECHARGE_RATIO}" fi if [ -n "${INPUT_UPSTREAM_RECHARGE_RATIO}" ]; then UPSTREAM_RECHARGE_RATIO="${INPUT_UPSTREAM_RECHARGE_RATIO}" set_env_value "UPSTREAM_RECHARGE_RATIO" "${UPSTREAM_RECHARGE_RATIO}" fi if [ -n "${INPUT_UPSTREAM_DEFAULT_MODELS}" ]; then UPSTREAM_DEFAULT_MODELS="${INPUT_UPSTREAM_DEFAULT_MODELS}" set_env_value "UPSTREAM_DEFAULT_MODELS" "$(shell_quote "${UPSTREAM_DEFAULT_MODELS}")" fi } apply_image_overrides() { if [ -n "${INPUT_IMAGE}" ]; then IMAGE="${INPUT_IMAGE}" set_env_value "IMAGE" "$(shell_quote "${IMAGE}")" fi if [ -n "${INPUT_IMAGE_TAR}" ]; then IMAGE_TAR="${INPUT_IMAGE_TAR}" fi } write_compose_file() { cat >"${COMPOSE_FILE}" <<'EOF' services: app: image: ${IMAGE} restart: unless-stopped command: --log-dir /app/logs ports: - "${PORT}:3000" volumes: - app_data:/data - app_logs:/app/logs environment: SQL_DSN: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} REDIS_CONN_STRING: redis://:${REDIS_PASSWORD}@redis:6379/0 SESSION_SECRET: ${SESSION_SECRET} TZ: ${TIMEZONE} ERROR_LOG_ENABLED: "true" BATCH_UPDATE_ENABLED: "true" SYNC_FREQUENCY: "60" UPDATE_TASK: "true" AIAPI_MARKET_IMAGE: ${AIAPI_MARKET_IMAGE} AIAPI_MARKET_IMAGE_REPOSITORY: ${AIAPI_MARKET_IMAGE_REPOSITORY} AIAPI_MARKET_IMAGE_TAG: ${AIAPI_MARKET_IMAGE_TAG} AIAPI_MARKET_IMAGE_DIGEST: ${AIAPI_MARKET_IMAGE_DIGEST} AIAPI_MARKET_UPDATE_COMMAND: ${AIAPI_MARKET_UPDATE_COMMAND} depends_on: postgres: condition: service_healthy redis: condition: service_started networks: - aiapi_market healthcheck: test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 5 postgres: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} TZ: ${TIMEZONE} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 12 networks: - aiapi_market redis: image: redis:7-alpine restart: unless-stopped command: ["sh", "-c", "redis-server --appendonly yes --requirepass \"$${REDIS_PASSWORD}\""] environment: REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - redis_data:/data networks: - aiapi_market volumes: app_data: app_logs: postgres_data: redis_data: networks: aiapi_market: driver: bridge EOF } load_env() { set -a # shellcheck disable=SC1090 . "${ENV_FILE}" set +a } derive_image_repository_and_tag() { local image_ref image_without_digest last_part repository tag image_ref="${1:-${IMAGE}}" image_without_digest="${image_ref%@*}" last_part="${image_without_digest##*/}" if printf '%s' "${last_part}" | grep -q ':'; then repository="${image_without_digest%:*}" tag="${image_without_digest##*:}" else repository="${image_without_digest}" tag="latest" fi printf '%s\n%s\n' "${repository}" "${tag}" } refresh_image_metadata() { local image_digest repository tag_pair tag update_command tag_pair="$(derive_image_repository_and_tag "${IMAGE}")" repository="$(printf '%s' "${tag_pair}" | sed -n '1p')" tag="$(printf '%s' "${tag_pair}" | sed -n '2p')" image_digest="$(docker image inspect "${IMAGE}" --format '{{index .RepoDigests 0}}' 2>/dev/null || true)" update_command="cd ${INSTALL_DIR} && docker compose pull && docker compose up -d" set_env_value "AIAPI_MARKET_IMAGE" "$(shell_quote "${IMAGE}")" set_env_value "AIAPI_MARKET_IMAGE_REPOSITORY" "$(shell_quote "${repository}")" set_env_value "AIAPI_MARKET_IMAGE_TAG" "$(shell_quote "${tag}")" set_env_value "AIAPI_MARKET_IMAGE_DIGEST" "$(shell_quote "${image_digest}")" set_env_value "AIAPI_MARKET_UPDATE_COMMAND" "$(shell_quote "${update_command}")" AIAPI_MARKET_IMAGE="${IMAGE}" AIAPI_MARKET_IMAGE_REPOSITORY="${repository}" AIAPI_MARKET_IMAGE_TAG="${tag}" AIAPI_MARKET_IMAGE_DIGEST="${image_digest}" AIAPI_MARKET_UPDATE_COMMAND="${update_command}" export AIAPI_MARKET_IMAGE AIAPI_MARKET_IMAGE_REPOSITORY AIAPI_MARKET_IMAGE_TAG AIAPI_MARKET_IMAGE_DIGEST AIAPI_MARKET_UPDATE_COMMAND } load_image_tar_if_needed() { if [ -z "${IMAGE_TAR}" ]; then return 1 fi if [ ! -f "${IMAGE_TAR}" ]; then fail "指定的镜像包不存在:${IMAGE_TAR}" fi log "加载离线镜像包:${IMAGE_TAR}。" docker load -i "${IMAGE_TAR}" return 0 } patch_existing_compose_file() { if ! need_cmd python3; then fail "更新已有 Compose 部署需要 python3,用于安全修改 docker-compose.yml。" fi if [ ! -f "${COMPOSE_FILE}" ]; then fail "未找到 ${COMPOSE_FILE},无法更新已有 Compose 部署。" fi COMPOSE_FILE="${COMPOSE_FILE}" EXISTING_APP_SERVICE="${EXISTING_APP_SERVICE}" python3 <<'PY' import os from pathlib import Path compose_file = Path(os.environ["COMPOSE_FILE"]) service = os.environ.get("EXISTING_APP_SERVICE", "new-api") lines = compose_file.read_text().splitlines() service_index = None service_indent = None for index, line in enumerate(lines): stripped = line.strip() if stripped == f"{service}:": service_index = index service_indent = len(line) - len(line.lstrip()) break if service_index is None: raise SystemExit(f"service {service!r} not found in {compose_file}") end_index = len(lines) for index in range(service_index + 1, len(lines)): line = lines[index] stripped = line.strip() if not stripped or stripped.startswith("#"): continue indent = len(line) - len(line.lstrip()) if indent <= service_indent: end_index = index break block = lines[service_index:end_index] image_replaced = False environment_index = None for offset, line in enumerate(block): stripped = line.strip() if stripped.startswith("image:"): indent = line[: len(line) - len(line.lstrip())] block[offset] = f"{indent}image: ${{IMAGE}}" image_replaced = True if stripped == "environment:": environment_index = offset if not image_replaced: block.insert(1, " image: ${IMAGE}") env_names = [ "AIAPI_MARKET_IMAGE", "AIAPI_MARKET_IMAGE_REPOSITORY", "AIAPI_MARKET_IMAGE_TAG", "AIAPI_MARKET_IMAGE_DIGEST", "AIAPI_MARKET_UPDATE_COMMAND", ] if environment_index is None: insert_at = 2 if image_replaced else 2 block.insert(insert_at, " environment:") environment_index = insert_at environment_line = block[environment_index] environment_indent = len(environment_line) - len(environment_line.lstrip()) env_end = len(block) for offset in range(environment_index + 1, len(block)): line = block[offset] stripped = line.strip() if not stripped or stripped.startswith("#"): continue indent = len(line) - len(line.lstrip()) if indent <= environment_indent: env_end = offset break env_block = block[environment_index + 1 : env_end] uses_list = any(line.strip().startswith("- ") for line in env_block) existing = set() for line in env_block: stripped = line.strip() if stripped.startswith("- "): key = stripped[2:].split("=", 1)[0].split(":", 1)[0].strip() elif ":" in stripped: key = stripped.split(":", 1)[0].strip() else: continue existing.add(key) additions = [] for name in env_names: if name in existing: continue if uses_list: additions.append(f" - {name}=${{{name}}}") else: additions.append(f" {name}: ${{{name}}}") if additions: block[env_end:env_end] = additions updated = lines[:service_index] + block + lines[end_index:] compose_file.write_text("\n".join(updated) + "\n") PY } update_existing_compose_deployment() { log "更新已有 Compose 部署:${INSTALL_DIR}。" if [ ! -d "${INSTALL_DIR}" ]; then fail "安装目录不存在:${INSTALL_DIR}" fi if [ ! -f "${ENV_FILE}" ]; then umask 077 : >"${ENV_FILE}" fi ensure_env_defaults load_env apply_image_overrides if [ -n "${WEB_HTTP_PORT:-}" ]; then PORT="${WEB_HTTP_PORT}" fi apply_input_overrides set_env_value "IMAGE" "$(shell_quote "${IMAGE}")" cp "${COMPOSE_FILE}" "${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)" patch_existing_compose_file log "拉取并更新 ${EXISTING_APP_SERVICE} 服务镜像:${IMAGE}。" ( cd "${INSTALL_DIR}" if ! load_image_tar_if_needed; then compose_cmd --env-file "${ENV_FILE}" -p "${APP_NAME}" pull "${EXISTING_APP_SERVICE}" fi refresh_image_metadata compose_cmd --env-file "${ENV_FILE}" -p "${APP_NAME}" up -d "${EXISTING_APP_SERVICE}" ) wait_for_http "http://127.0.0.1:${PORT}/api/status" 60 || warn "服务已启动,但健康检查暂未通过,请稍后查看日志。" print_summary } wait_for_http() { local url="$1" local retries="${2:-60}" local i for i in $(seq 1 "${retries}"); do if curl --noproxy '*' --max-time 5 -fsS "${url}" >/dev/null 2>&1; then return 0 fi sleep 2 done return 1 } initialize_system() { local base_url setup_json setup_status base_url="http://127.0.0.1:${PORT}" if ! wait_for_http "${base_url}/api/setup" 90; then warn "服务已启动,但 /api/setup 暂时不可访问。可稍后访问 ${base_url} 完成初始化。" return fi setup_json="$(curl --noproxy '*' --max-time 10 -fsS "${base_url}/api/setup" || true)" setup_status="$(printf '%s' "${setup_json}" | grep -o '"status":[^,}]*' | head -1 | cut -d: -f2 | tr -d ' ')" if [ "${setup_status}" = "true" ]; then log "系统已经初始化,跳过管理员创建。" return fi log "正在初始化管理员账号。" curl --noproxy '*' --max-time 20 -fsS \ -H 'Content-Type: application/json' \ -X POST "${base_url}/api/setup" \ --data-raw "{\"username\":\"${ADMIN_USERNAME}\",\"password\":\"${ADMIN_PASSWORD}\",\"confirmPassword\":\"${ADMIN_PASSWORD}\",\"SelfUseModeEnabled\":${SELF_USE_MODE_ENABLED},\"DemoSiteEnabled\":${DEMO_SITE_ENABLED}}" \ >/tmp/aiapi-market-setup-response.json if ! grep -q '"success":true' /tmp/aiapi-market-setup-response.json; then warn "自动初始化可能未成功,响应如下:" cat /tmp/aiapi-market-setup-response.json warn "可访问 ${base_url}/setup 手动完成初始化。" fi rm -f /tmp/aiapi-market-setup-response.json } api_login() { local base_url="$1" local cookie_file="$2" local payload_file response_file payload_file="$(mktemp)" response_file="$(mktemp)" chmod 600 "${payload_file}" "${response_file}" cat >"${payload_file}" <"${response_file}"; then rm -f "${payload_file}" "${response_file}" return 1 fi if ! grep -q '"success":true' "${response_file}"; then warn "管理员登录失败,无法继续自动配置。" cat "${response_file}" rm -f "${payload_file}" "${response_file}" return 1 fi ADMIN_USER_ID="$(grep -o '"id":[0-9]*' "${response_file}" | head -1 | cut -d: -f2)" if [ -z "${ADMIN_USER_ID}" ]; then ADMIN_USER_ID=1 fi rm -f "${payload_file}" "${response_file}" } api_update_option() { local base_url="$1" local cookie_file="$2" local key="$3" local value="$4" local payload_file response_file payload_file="$(mktemp)" response_file="$(mktemp)" chmod 600 "${payload_file}" "${response_file}" cat >"${payload_file}" <"${response_file}" && grep -q '"success":true' "${response_file}"; then rm -f "${payload_file}" "${response_file}" return 0 fi warn "更新系统配置 ${key} 失败:" cat "${response_file}" || true rm -f "${payload_file}" "${response_file}" return 1 } fetch_upstream_models() { local base_url="$1" local api_key="$2" local models_json models if [ -z "${base_url}" ] || [ -z "${api_key}" ]; then printf '%s' "${UPSTREAM_DEFAULT_MODELS}" return fi models_json="$(curl --noproxy '*' --max-time 20 -fsS \ -H "Authorization: Bearer ${api_key}" \ "${base_url%/}/v1/models" 2>/dev/null || true)" if [ -n "${models_json}" ] && need_cmd python3; then models="$(printf '%s' "${models_json}" | python3 -c 'import json,sys try: data=json.load(sys.stdin) ids=[str(x.get("id","")).strip() for x in data.get("data",[]) if str(x.get("id","")).strip()] print(",".join(ids[:300])) except Exception: print("") ')" if [ -n "${models}" ]; then printf '%s' "${models}" return fi fi printf '%s' "${UPSTREAM_DEFAULT_MODELS}" } find_channel_id_by_name() { local base_url="$1" local cookie_file="$2" local name="$3" local channels_json channels_json="$(curl --noproxy '*' --max-time 20 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ "${base_url}/api/channel/?p=1&page_size=200" 2>/dev/null || true)" if need_cmd python3; then CHANNEL_NAME="${name}" python3 -c 'import json,os,sys try: data=json.load(sys.stdin).get("data",{}) for item in data.get("items",[]): if item.get("name") == os.environ["CHANNEL_NAME"]: print(item.get("id","")) break except Exception: pass ' <"${payload_file}" <"${response_file}" || ! grep -q '"success":true' "${response_file}"; then warn "创建上游渠道失败:" cat "${response_file}" || true rm -f "${payload_file}" "${response_file}" return 1 fi rm -f "${payload_file}" "${response_file}" channel_id="$(find_channel_id_by_name "${base_url}" "${cookie_file}" "${UPSTREAM_CHANNEL_NAME}")" fi if [ -z "${channel_id}" ]; then warn "无法定位上游渠道 ID,跳过分组同步。" return 0 fi protect_upstream_channel "${base_url}" "${cookie_file}" "${channel_id}" || true if [ -n "${UPSTREAM_API_KEY}" ]; then sync_upstream_groups "${base_url}" "${cookie_file}" "${channel_id}" || true else clear_placeholder_upstream_channel_key "${channel_id}" || true sync_upstream_groups "${base_url}" "${cookie_file}" "${channel_id}" || warn "公开分组同步未完成;请登录后台填写 Key 后手动同步。" warn "预置上游渠道 API Key 为空;已尝试使用上游公开分组目录同步。" fi } clear_placeholder_upstream_channel_key() { local channel_id="$1" if [ -z "${channel_id}" ]; then return 0 fi if ! printf '%s' "${channel_id}" | grep -Eq '^[0-9]+$'; then warn "渠道 ID 非数字,跳过清空占位 API Key:${channel_id}" return 0 fi log "正在清空预置上游渠道的占位 API Key。" ( cd "${INSTALL_DIR}" compose_cmd --env-file "${ENV_FILE}" -p "${APP_NAME}" exec -T postgres \ psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ -c "UPDATE channels SET key = '' WHERE id = ${channel_id} AND key = '${UPSTREAM_PLACEHOLDER_API_KEY}';" >/dev/null ) || warn "清空占位 API Key 失败,可登录后台手动清空后填写真实 Key。" } protect_upstream_channel() { local base_url="$1" local cookie_file="$2" local channel_id="$3" local channel_file payload_file response_file if ! need_cmd python3; then warn "未检测到 python3,跳过预置渠道锁定。" return 0 fi channel_file="$(mktemp)" payload_file="$(mktemp)" response_file="$(mktemp)" chmod 600 "${channel_file}" "${payload_file}" "${response_file}" if ! curl --noproxy '*' --max-time 30 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ "${base_url}/api/channel/${channel_id}" >"${channel_file}"; then warn "读取上游渠道详情失败,跳过预置渠道锁定。" rm -f "${channel_file}" "${payload_file}" "${response_file}" return 0 fi if ! python3 - "${channel_file}" "${payload_file}" <<'PY' import json import sys source, target = sys.argv[1], sys.argv[2] with open(source) as f: response = json.load(f) channel = response.get("data") or {} settings = {} raw_settings = channel.get("settings") or "" if isinstance(raw_settings, str) and raw_settings.strip(): try: settings = json.loads(raw_settings) except Exception: settings = {} settings["system_managed"] = True channel["settings"] = json.dumps(settings, ensure_ascii=False, separators=(",", ":")) channel["status"] = 1 channel["key"] = "" with open(target, "w") as f: json.dump(channel, f, ensure_ascii=False, separators=(",", ":")) PY then warn "生成预置渠道锁定请求失败。" rm -f "${channel_file}" "${payload_file}" "${response_file}" return 0 fi log "正在锁定预置上游渠道,防止删除或禁用。" if ! curl --noproxy '*' --max-time 30 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ -H 'Content-Type: application/json' \ -X PUT "${base_url}/api/channel/" \ --data-binary "@${payload_file}" >"${response_file}" || ! grep -q '"success":true' "${response_file}"; then warn "预置渠道锁定失败:" cat "${response_file}" || true fi rm -f "${channel_file}" "${payload_file}" "${response_file}" } ensure_fallback_group_available() { local base_url="$1" local cookie_file="$2" local group_name="$3" local options_file group_ratio_file usable_groups_file group_name="$(printf '%s' "${group_name}" | xargs)" if [ -z "${group_name}" ]; then return 0 fi if ! need_cmd python3; then warn "未检测到 python3,跳过托底分组可用性检查。" return 0 fi options_file="$(mktemp)" group_ratio_file="$(mktemp)" usable_groups_file="$(mktemp)" chmod 600 "${options_file}" "${group_ratio_file}" "${usable_groups_file}" if ! curl --noproxy '*' --max-time 30 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ "${base_url}/api/option/" >"${options_file}"; then warn "读取系统分组配置失败,跳过托底分组检查。" rm -f "${options_file}" "${group_ratio_file}" "${usable_groups_file}" return 0 fi python3 - "${options_file}" "${group_ratio_file}" "${usable_groups_file}" "${group_name}" <<'PY' import json import sys options_path, group_ratio_path, usable_groups_path, group_name = sys.argv[1:5] with open(options_path) as f: response = json.load(f) options = {} for item in response.get("data", []): if isinstance(item, dict) and "key" in item: options[item["key"]] = item.get("value") or "" try: group_ratio = json.loads(options.get("GroupRatio") or "{}") except Exception: group_ratio = {} try: usable_groups = json.loads(options.get("UserUsableGroups") or "{}") except Exception: usable_groups = {} group_ratio.setdefault(group_name, 1) usable_groups.setdefault(group_name, group_name) with open(group_ratio_path, "w") as f: json.dump(group_ratio, f, ensure_ascii=False, separators=(",", ":")) with open(usable_groups_path, "w") as f: json.dump(usable_groups, f, ensure_ascii=False, separators=(",", ":")) PY log "正在确保托底分组可用:${group_name}。" api_update_option "${base_url}" "${cookie_file}" "GroupRatio" "$(cat "${group_ratio_file}")" || true api_update_option "${base_url}" "${cookie_file}" "UserUsableGroups" "$(cat "${usable_groups_file}")" || true rm -f "${options_file}" "${group_ratio_file}" "${usable_groups_file}" } sync_upstream_groups() { local base_url="$1" local cookie_file="$2" local channel_id="$3" local payload_file response_file upstream_recharge_fragment payload_file="$(mktemp)" response_file="$(mktemp)" chmod 600 "${payload_file}" "${response_file}" upstream_recharge_fragment="" if [ -n "${UPSTREAM_RECHARGE_RATIO}" ]; then upstream_recharge_fragment=",\"upstream_recharge_ratio\":${UPSTREAM_RECHARGE_RATIO}" fi cat >"${payload_file}" <"${response_file}" && grep -q '"success":true' "${response_file}"; then log "上游分组同步完成。" refresh_channel_models_from_upstream_mapping "${base_url}" "${cookie_file}" "${channel_id}" || true else warn "上游分组同步未完成,可登录后台在渠道编辑页手动同步。响应:" cat "${response_file}" || true fi rm -f "${payload_file}" "${response_file}" } refresh_channel_models_from_upstream_mapping() { local base_url="$1" local cookie_file="$2" local channel_id="$3" local channel_file payload_file response_file if ! need_cmd python3; then warn "未检测到 python3,跳过渠道模型列表刷新。" return 0 fi channel_file="$(mktemp)" payload_file="$(mktemp)" response_file="$(mktemp)" chmod 600 "${channel_file}" "${payload_file}" "${response_file}" if ! curl --noproxy '*' --max-time 30 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ "${base_url}/api/channel/${channel_id}" >"${channel_file}"; then warn "读取渠道详情失败,跳过渠道模型列表刷新。" rm -f "${channel_file}" "${payload_file}" "${response_file}" return 0 fi if ! python3 - "${channel_file}" "${payload_file}" <<'PY' import json import sys source, target = sys.argv[1], sys.argv[2] with open(source) as f: response = json.load(f) channel = response.get("data") or {} settings = {} raw_settings = channel.get("settings") or channel.get("setting") or "" if isinstance(raw_settings, str) and raw_settings.strip(): try: settings = json.loads(raw_settings) except Exception: settings = {} sync = settings.get("upstream_group_sync") or {} group_models = sync.get("group_models") or {} models = [] seen = set() for model in str(channel.get("models") or "").split(","): model = model.strip() if model and model not in seen: seen.add(model) models.append(model) for values in group_models.values(): if not isinstance(values, list): continue for model in values: model = str(model).strip() if model and model not in seen: seen.add(model) models.append(model) if not models: raise SystemExit(1) channel["models"] = ",".join(models) channel["key"] = "" channel["settings"] = raw_settings with open(target, "w") as f: json.dump(channel, f, ensure_ascii=False, separators=(",", ":")) PY then warn "生成渠道模型刷新请求失败。" rm -f "${channel_file}" "${payload_file}" "${response_file}" return 0 fi log "正在刷新渠道模型列表并重建能力。" if ! curl --noproxy '*' --max-time 30 -fsS \ -c "${cookie_file}" -b "${cookie_file}" \ -H "New-Api-User: ${ADMIN_USER_ID}" \ -H 'Content-Type: application/json' \ -X PUT "${base_url}/api/channel/" \ --data-binary "@${payload_file}" >"${response_file}" || ! grep -q '"success":true' "${response_file}"; then warn "刷新渠道模型列表失败:" cat "${response_file}" || true fi rm -f "${channel_file}" "${payload_file}" "${response_file}" } configure_system() { local base_url cookie_file base_url="http://127.0.0.1:${PORT}" cookie_file="$(mktemp)" chmod 600 "${cookie_file}" if ! api_login "${base_url}" "${cookie_file}"; then rm -f "${cookie_file}" return fi if [ "${FRONTEND_THEME}" = "default" ] || [ "${FRONTEND_THEME}" = "classic" ]; then log "正在设置前端主题:${FRONTEND_THEME}。" api_update_option "${base_url}" "${cookie_file}" "theme.frontend" "${FRONTEND_THEME}" || true else warn "FRONTEND_THEME=${FRONTEND_THEME} 无效,跳过主题设置。" fi if [ -n "${SYSTEM_NAME}" ]; then log "正在设置站点名称:${SYSTEM_NAME}。" api_update_option "${base_url}" "${cookie_file}" "SystemName" "${SYSTEM_NAME}" || true fi ensure_fallback_group_available "${base_url}" "${cookie_file}" "${UPSTREAM_FALLBACK_GROUP}" create_upstream_channel_if_needed "${base_url}" "${cookie_file}" || true rm -f "${cookie_file}" } print_summary() { local access_url summary_admin_username summary_admin_password access_url="http://${PUBLIC_HOST}:${PORT}" summary_admin_username="${ADMIN_USERNAME:-root}" summary_admin_password="${ADMIN_PASSWORD:-沿用已有管理员密码}" cat <