diff --git a/Tiltfile.prod b/Tiltfile.prod new file mode 100644 index 0000000..bc70aac --- /dev/null +++ b/Tiltfile.prod @@ -0,0 +1,23 @@ +# 加载生产环境配置 +docker_compose('docker-compose.prod.yml') + +# 定义服务列表 +# 这些服务涉及到代码编译(Release 模式)或构建(前端),过程较慢 +# 我们将它们设置为手动触发模式,避免开发过程中意外修改文件导致自动触发漫长的重构建 +services = [ + 'data-persistence-service', + 'api-gateway', + 'mock-provider-service', + 'alphavantage-provider-service', + 'tushare-provider-service', + 'finnhub-provider-service', + 'yfinance-provider-service', + 'report-generator-service', + 'workflow-orchestrator-service', + 'frontend' +] + +# 遍历设置触发模式为手动 (Manual) +for name in services: + dc_resource(name, trigger_mode=TRIGGER_MODE_MANUAL) + diff --git a/deploy_to_harbor.sh b/deploy_to_harbor.sh new file mode 100644 index 0000000..ebab1b1 --- /dev/null +++ b/deploy_to_harbor.sh @@ -0,0 +1,339 @@ +#!/bin/bash + +# 遇到错误立即退出 +set -e + +# 配置变量 +REGISTRY="harbor.3prism.ai" +PROJECT="fundamental_analysis" +VERSION="latest" +NAMESPACE="$REGISTRY/$PROJECT" + +# 颜色输出 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}=== 开始构建并推送镜像到 $NAMESPACE ===${NC}" + +# 定义服务列表 +# 格式: "服务名:Dockerfile路径" +# 注意:所有的后端服务现在都使用通用的 docker/Dockerfile.backend.prod +SERVICES=( + "data-persistence-service:docker/Dockerfile.backend.prod" + "api-gateway:docker/Dockerfile.backend.prod" + "alphavantage-provider-service:docker/Dockerfile.backend.prod" + "tushare-provider-service:docker/Dockerfile.backend.prod" + "finnhub-provider-service:docker/Dockerfile.backend.prod" + "yfinance-provider-service:docker/Dockerfile.backend.prod" + "report-generator-service:docker/Dockerfile.backend.prod" + "workflow-orchestrator-service:docker/Dockerfile.backend.prod" + "mock-provider-service:docker/Dockerfile.backend.prod" + "frontend:docker/Dockerfile.frontend.prod" +) + +# 总大小计数器 +TOTAL_SIZE=0 + +for entry in "${SERVICES[@]}"; do + KEY="${entry%%:*}" + DOCKERFILE="${entry#*:}" + IMAGE_NAME="$NAMESPACE/$KEY:$VERSION" + + echo -e "\n${YELLOW}>>> 正在构建 $KEY ...${NC}" + echo "使用 Dockerfile: $DOCKERFILE" + + # 构建镜像 + if [ "$KEY" == "frontend" ]; then + # 前端不需要 SERVICE_NAME build-arg + docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" . + elif [ "$KEY" == "data-persistence-service" ]; then + # 特殊处理 data-persistence-service 的二进制名称差异 + docker build -t "$IMAGE_NAME" --build-arg SERVICE_NAME="data-persistence-service-server" -f "$DOCKERFILE" . + else + # 后端服务需要传递 SERVICE_NAME + docker build -t "$IMAGE_NAME" --build-arg SERVICE_NAME="$KEY" -f "$DOCKERFILE" . + fi + + # 获取镜像大小 (MB) + SIZE_BYTES=$(docker inspect "$IMAGE_NAME" --format='{{.Size}}') + SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1024 / 1024" | bc) + + echo -e "${GREEN}√ $KEY 构建完成. 大小: ${SIZE_MB} MB${NC}" + + # 累加大小 + TOTAL_SIZE=$(echo "$TOTAL_SIZE + $SIZE_BYTES" | bc) + + echo -e "${YELLOW}>>> 正在推送 $KEY 到 Harbor ...${NC}" + docker push "$IMAGE_NAME" +done + +TOTAL_SIZE_MB=$(echo "scale=2; $TOTAL_SIZE / 1024 / 1024" | bc) +echo -e "\n${GREEN}=== 所有镜像处理完成 ===${NC}" +echo -e "${GREEN}总大小: ${TOTAL_SIZE_MB} MB${NC}" + + +# 生成服务器使用的 docker-compose.server.yml +echo -e "\n${YELLOW}>>> 正在生成服务器部署文件 docker-compose.server.yml ...${NC}" + +# 基于 docker-compose.prod.yml 生成,但是替换 build 为 image +# 这里我们直接手动定义,因为解析 yaml 替换比较复杂,且我们清楚结构 + +cat > docker-compose.server.yml </dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + restart: always + + api-gateway: + image: $NAMESPACE/api-gateway:$VERSION + container_name: api-gateway + environment: + SERVER_PORT: 4000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004 + RUST_LOG: info,axum=info + RUST_BACKTRACE: "1" + depends_on: + nats: + condition: service_started + data-persistence-service: + condition: service_healthy + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + mock-provider-service: + image: $NAMESPACE/mock-provider-service:$VERSION + container_name: mock-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8006 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: mock-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + alphavantage-provider-service: + image: $NAMESPACE/alphavantage-provider-service:$VERSION + container_name: alphavantage-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: alphavantage-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + tushare-provider-service: + image: $NAMESPACE/tushare-provider-service:$VERSION + container_name: tushare-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8001 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + TUSHARE_API_URL: http://api.waditu.com + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: tushare-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + finnhub-provider-service: + image: $NAMESPACE/finnhub-provider-service:$VERSION + container_name: finnhub-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8002 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + FINNHUB_API_URL: https://finnhub.io/api/v1 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: finnhub-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + yfinance-provider-service: + image: $NAMESPACE/yfinance-provider-service:$VERSION + container_name: yfinance-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8003 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: yfinance-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + dns: + - 8.8.8.8 + - 8.8.4.4 + restart: always + + report-generator-service: + image: $NAMESPACE/report-generator-service:$VERSION + container_name: report-generator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8004 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + GOTENBERG_URL: http://gotenberg:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + - gotenberg + networks: + - app-network + restart: always + + workflow-orchestrator-service: + image: $NAMESPACE/workflow-orchestrator-service:$VERSION + container_name: workflow-orchestrator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8005 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + gotenberg: + image: gotenberg/gotenberg:8 + container_name: gotenberg + networks: + - app-network + restart: always + + frontend: + image: $NAMESPACE/frontend:$VERSION + container_name: fundamental-frontend + ports: + - "8080:80" # Map host 8080 to container 80 (Nginx) + depends_on: + api-gateway: + condition: service_healthy + networks: + - app-network + restart: always + +volumes: + workflow_data: + pgdata: + nats_data: + +networks: + app-network: +EOF + +echo -e "${GREEN}生成完成: docker-compose.server.yml${NC}" +echo -e "请将此文件复制到远程服务器,并执行: docker-compose -f docker-compose.server.yml up -d" + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bc5a730 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,292 @@ +services: + postgres-db: + image: timescale/timescaledb:2.15.2-pg16 + container_name: fundamental-postgres + command: -c shared_preload_libraries=timescaledb + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: fundamental + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - app-network + restart: always + + nats: + image: nats:2.9 + container_name: fundamental-nats + volumes: + - nats_data:/data + networks: + - app-network + restart: always + + data-persistence-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: data-persistence-service-server + container_name: data-persistence-service + # Note: The binary name in Dockerfile is generic 'app' or we can override entrypoint. + # The Dockerfile entrypoint is /usr/local/bin/app. + environment: + HOST: 0.0.0.0 + PORT: 3000 + DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental + RUST_LOG: info + RUST_BACKTRACE: "1" + SKIP_MIGRATIONS_ON_MISMATCH: "1" + depends_on: + postgres-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + restart: always + + api-gateway: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: api-gateway + container_name: api-gateway + environment: + SERVER_PORT: 4000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004 + RUST_LOG: info,axum=info + RUST_BACKTRACE: "1" + depends_on: + nats: + condition: service_started + data-persistence-service: + condition: service_healthy + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + mock-provider-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: mock-provider-service + container_name: mock-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8006 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: mock-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + alphavantage-provider-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: alphavantage-provider-service + container_name: alphavantage-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: alphavantage-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + tushare-provider-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: tushare-provider-service + container_name: tushare-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8001 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + TUSHARE_API_URL: http://api.waditu.com + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: tushare-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + finnhub-provider-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: finnhub-provider-service + container_name: finnhub-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8002 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + FINNHUB_API_URL: https://finnhub.io/api/v1 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: finnhub-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + yfinance-provider-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: yfinance-provider-service + container_name: yfinance-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8003 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: yfinance-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + dns: + - 8.8.8.8 + - 8.8.4.4 + restart: always + + report-generator-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: report-generator-service + container_name: report-generator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8004 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + GOTENBERG_URL: http://gotenberg:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + - gotenberg + networks: + - app-network + restart: always + + gotenberg: + image: gotenberg/gotenberg:8 + container_name: gotenberg + networks: + - app-network + restart: always + + workflow-orchestrator-service: + build: + context: . + dockerfile: docker/Dockerfile.backend.prod + args: + SERVICE_NAME: workflow-orchestrator-service + container_name: workflow-orchestrator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8005 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + frontend: + build: + context: . + dockerfile: docker/Dockerfile.frontend.prod + container_name: fundamental-frontend + ports: + - "8080:80" # Map host 8080 to container 80 (Nginx) + depends_on: + api-gateway: + condition: service_healthy + networks: + - app-network + restart: always + +volumes: + workflow_data: + pgdata: + nats_data: + +networks: + app-network: + diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..6e0bc3f --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,230 @@ +services: + postgres-db: + image: timescale/timescaledb:2.15.2-pg16 + container_name: fundamental-postgres + command: -c shared_preload_libraries=timescaledb + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: fundamental + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - app-network + restart: always + + nats: + image: nats:2.9 + container_name: fundamental-nats + volumes: + - nats_data:/data + networks: + - app-network + restart: always + + data-persistence-service: + image: harbor.3prism.ai/fundamental_analysis/data-persistence-service:latest + container_name: data-persistence-service + environment: + HOST: 0.0.0.0 + PORT: 3000 + DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental + RUST_LOG: info + RUST_BACKTRACE: "1" + SKIP_MIGRATIONS_ON_MISMATCH: "1" + depends_on: + postgres-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + restart: always + + api-gateway: + image: harbor.3prism.ai/fundamental_analysis/api-gateway:latest + container_name: api-gateway + environment: + SERVER_PORT: 4000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004 + RUST_LOG: info,axum=info + RUST_BACKTRACE: "1" + depends_on: + nats: + condition: service_started + data-persistence-service: + condition: service_healthy + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + + alphavantage-provider-service: + image: harbor.3prism.ai/fundamental_analysis/alphavantage-provider-service:latest + container_name: alphavantage-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8000 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: alphavantage-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + tushare-provider-service: + image: harbor.3prism.ai/fundamental_analysis/tushare-provider-service:latest + container_name: tushare-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8001 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + TUSHARE_API_URL: http://api.waditu.com + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: tushare-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + finnhub-provider-service: + image: harbor.3prism.ai/fundamental_analysis/finnhub-provider-service:latest + container_name: finnhub-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8002 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + FINNHUB_API_URL: https://finnhub.io/api/v1 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: finnhub-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + yfinance-provider-service: + image: harbor.3prism.ai/fundamental_analysis/yfinance-provider-service:latest + container_name: yfinance-provider-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8003 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: yfinance-provider-service + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + dns: + - 8.8.8.8 + - 8.8.4.4 + restart: always + + report-generator-service: + image: harbor.3prism.ai/fundamental_analysis/report-generator-service:latest + container_name: report-generator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8004 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + GOTENBERG_URL: http://gotenberg:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + - gotenberg + networks: + - app-network + restart: always + + workflow-orchestrator-service: + image: harbor.3prism.ai/fundamental_analysis/workflow-orchestrator-service:latest + container_name: workflow-orchestrator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8005 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + networks: + - app-network + restart: always + + gotenberg: + image: gotenberg/gotenberg:8 + container_name: gotenberg + networks: + - app-network + restart: always + + frontend: + image: harbor.3prism.ai/fundamental_analysis/frontend:latest + container_name: fundamental-frontend + ports: + - "28080:80" # Map host 28080 to container 80 (Nginx) + depends_on: + api-gateway: + condition: service_healthy + networks: + - app-network + restart: always + +volumes: + workflow_data: + pgdata: + nats_data: + +networks: + app-network: diff --git a/docker/Dockerfile.backend.prod b/docker/Dockerfile.backend.prod new file mode 100644 index 0000000..817f811 --- /dev/null +++ b/docker/Dockerfile.backend.prod @@ -0,0 +1,67 @@ +# 1. Build Stage +FROM rust:1.90-bookworm as builder +ARG SERVICE_NAME +WORKDIR /usr/src/app + +# Copy the entire workspace +COPY . . + +# Build the specific service in release mode +ENV SQLX_OFFLINE=true +RUN cargo build --release --bin ${SERVICE_NAME} + +# Prepare runtime assets directory +RUN mkdir -p /app/assets + +# Conditionally copy potential asset folders if they exist for the service +# We use a shell loop or explicit checks. Docker COPY doesn't support conditionals well. +# So we do it in the builder stage using shell. + +# 1. Migrations (e.g., data-persistence-service) +RUN if [ -d "services/${SERVICE_NAME}/migrations" ]; then \ + mkdir -p /app/assets/migrations && \ + cp -r services/${SERVICE_NAME}/migrations/* /app/assets/migrations/; \ + fi + +# 2. Templates (e.g., report-generator-service) +RUN if [ -d "services/${SERVICE_NAME}/templates" ]; then \ + mkdir -p /app/assets/templates && \ + cp -r services/${SERVICE_NAME}/templates/* /app/assets/templates/; \ + fi + +# 2.1 Cookies (e.g., report-generator-service) +RUN if [ -f "services/${SERVICE_NAME}/cookies.txt" ]; then \ + cp services/${SERVICE_NAME}/cookies.txt /app/assets/cookies.txt; \ + fi + +# 3. Config folder (root level, needed by some services like data-persistence) +# We copy it to a specific location. +RUN cp -r config /app/config + +# 4. Service Kit Mirror (needed by data-persistence-service build usually, but maybe runtime?) +# It was needed for build. Runtime usually doesn't need it unless it compiles code at runtime. + +# 2. Runtime Stage +FROM debian:bookworm-slim +ARG SERVICE_NAME +ENV TZ=Asia/Shanghai + +# Install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binary +COPY --from=builder /usr/src/app/target/release/${SERVICE_NAME} /usr/local/bin/app + +# Copy prepared assets +COPY --from=builder /app/assets /app/ +COPY --from=builder /app/config /app/config + +# Set the binary as the entrypoint +ENTRYPOINT ["/usr/local/bin/app"] + diff --git a/docker/Dockerfile.builder b/docker/Dockerfile.builder new file mode 100644 index 0000000..5800dde --- /dev/null +++ b/docker/Dockerfile.builder @@ -0,0 +1,13 @@ +FROM rust:1.90-bookworm +WORKDIR /usr/src/app + +# Copy the entire workspace +COPY . . + +# Set SQLX offline mode to avoid needing a running DB during build +ENV SQLX_OFFLINE=true + +# Build the entire workspace in release mode +# This compiles all crates in the workspace at once +RUN cargo build --release --workspace + diff --git a/docker/Dockerfile.dist b/docker/Dockerfile.dist new file mode 100644 index 0000000..1768f8d --- /dev/null +++ b/docker/Dockerfile.dist @@ -0,0 +1,25 @@ +FROM debian:bookworm-slim +ENV TZ=Asia/Shanghai + +# Install minimal runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libssl3 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# The build context is expected to be prepared by the deployment script +# It should contain: +# - app (the binary) +# - config/ (if needed) +# - assets/ (if needed) + +COPY . . + +# Ensure the binary is executable +RUN chmod +x /app/app + +ENTRYPOINT ["/app/app"] + diff --git a/docker/Dockerfile.frontend.prod b/docker/Dockerfile.frontend.prod new file mode 100644 index 0000000..29841c1 --- /dev/null +++ b/docker/Dockerfile.frontend.prod @@ -0,0 +1,24 @@ +# 1. Build Stage +FROM node:20-slim AS builder +WORKDIR /app + +# Environment variables for build time +# ENV NODE_ENV=production <- REMOVED: This causes npm ci to skip devDependencies (tsc, vite) +# These must match the Nginx proxy paths +ENV VITE_API_TARGET=/api +ENV NEXT_PUBLIC_BACKEND_URL=/api/v1 + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ . +RUN npm run build + +# 2. Runtime Stage +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker/nginx.prod.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] + diff --git a/docker/nginx.prod.conf b/docker/nginx.prod.conf new file mode 100644 index 0000000..95fb99d --- /dev/null +++ b/docker/nginx.prod.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the backend + # Matches /api/v1/..., /api/context/..., etc. + location /api/ { + proxy_pass http://api-gateway:4000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Proxy specific endpoints that are at root level in api-gateway + location /health { + proxy_pass http://api-gateway:4000/health; + proxy_set_header Host $host; + } + + location /tasks/ { + proxy_pass http://api-gateway:4000/tasks/; + proxy_set_header Host $host; + } +} + diff --git a/frontend/src/api/schema.gen.ts b/frontend/src/api/schema.gen.ts index b35dc01..7b9c983 100644 --- a/frontend/src/api/schema.gen.ts +++ b/frontend/src/api/schema.gen.ts @@ -231,6 +231,7 @@ export const DataSourceProvider = z.enum([ "Finnhub", "Alphavantage", "Yfinance", + "Mock", ]); export const DataSourceConfig = z.object({ api_key: z.union([z.string(), z.null()]).optional(), diff --git a/frontend/src/pages/config/DataSourceTab.tsx b/frontend/src/pages/config/DataSourceTab.tsx index ec8bc20..843f725 100644 --- a/frontend/src/pages/config/DataSourceTab.tsx +++ b/frontend/src/pages/config/DataSourceTab.tsx @@ -1,5 +1,5 @@ import { useDataSources, useUpdateDataSources, useTestDataSource, useRegisteredProviders } from "@/hooks/useConfig" -import { DataSourceConfig } from "@/types/config" +import { DataSourceConfig, DataSourceProvider, DataSourceProviders } from "@/types/config" import { useToast } from "@/hooks/use-toast" import { DynamicConfigForm } from "@/components/config/DynamicConfigForm" @@ -97,19 +97,29 @@ export function DataSourceTab() { {providersMetadata.map(meta => { // Find existing config or create default const configEntry = dataSources ? (dataSources as Record)[meta.id] : undefined; - - // Default config structure. - // Note: We default 'provider' field to the ID from metadata. - // Backend expects specific enum values for 'provider', but currently our IDs match (lowercase/uppercase handling needed?) - // The backend DataSourceProvider enum is PascalCase (Tushare), but IDs are likely lowercase (tushare). - // However, DataSourceConfig.provider is an enum. - // We might need to map ID to Enum if strict. - // For now, assuming the backend persistence can handle the string or we just store it. - // Actually, the 'provider' field in DataSourceConfig is DataSourceProvider enum. - // Let's hope the JSON deserialization handles "tushare" -> Tushare. + + // We know that meta.id must be a valid DataSourceProvider because the backend + // only registers providers that are part of the enum system. + // However, meta.id comes as lowercase (e.g., "tushare") while the Enum expects PascalCase (e.g., "Tushare"). + // To maintain strict type safety and follow the Single Source of Truth, + // we need to cast or map it correctly. + // Since we cannot change the backend serialization easily without breaking other things, + // and we must respect the Zod schema, we try to match it case-insensitively to the Enum. + + let providerEnum = Object.values(DataSourceProviders).find( + (p) => p.toLowerCase() === meta.id.toLowerCase() + ); + + if (!providerEnum) { + console.warn(`Provider ID '${meta.id}' from metadata does not match any known DataSourceProvider enum.`); + // Fallback or skip? If we skip, the user can't configure it. + // If we cast forcefully, Zod might reject it on save. + // Let's attempt to use it as is but cast to satisfy TS, acknowledging the risk if it doesn't match. + providerEnum = meta.id as DataSourceProvider; + } const config = (configEntry || { - provider: meta.id, // This might need capitalization adjustment + provider: providerEnum, enabled: false, // We init other fields as empty, they will be filled by DynamicConfigForm }) as DataSourceConfig; diff --git a/scripts/deploy_to_harbor.sh b/scripts/deploy_to_harbor.sh index 8eff7f2..57ba8c8 100755 --- a/scripts/deploy_to_harbor.sh +++ b/scripts/deploy_to_harbor.sh @@ -6,7 +6,7 @@ set -e # 配置变量 REGISTRY="harbor.3prism.ai" PROJECT="fundamental_analysis" -VERSION="latest" # 或者使用 $(date +%Y%m%d%H%M%S) 生成时间戳版本 +VERSION="latest" NAMESPACE="$REGISTRY/$PROJECT" # 颜色输出 @@ -15,70 +15,154 @@ YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color -echo -e "${GREEN}=== 开始构建并推送镜像到 $NAMESPACE ===${NC}" +# 清理工作目录函数 +function cleanup { + echo -e "\n${YELLOW}>>> 清理临时文件...${NC}" + rm -rf ./dist_bin + rm -rf ./temp_build_context + # 尝试删除构建容器(如果存在) + docker rm -f fundamental-builder-extract 2>/dev/null || true +} +trap cleanup EXIT -# 定义服务列表及其 Dockerfile 路径 -# 格式: "服务名:Dockerfile路径" -SERVICES=( - "data-persistence-service:services/data-persistence-service/Dockerfile" - "api-gateway:services/api-gateway/Dockerfile" - "alphavantage-provider-service:services/alphavantage-provider-service/Dockerfile" - "tushare-provider-service:services/tushare-provider-service/Dockerfile" - "finnhub-provider-service:services/finnhub-provider-service/Dockerfile" - "yfinance-provider-service:services/yfinance-provider-service/Dockerfile" - "report-generator-service:services/report-generator-service/Dockerfile" - "frontend:frontend/Dockerfile.prod" +echo -e "${GREEN}=== 开始优化的构建部署流程 ===${NC}" +echo -e "目标仓库: $NAMESPACE" + +# ========================================== +# 阶段 1: 全局构建 (Build Once) +# ========================================== +echo -e "\n${YELLOW}>>> [阶段 1/3] 全局构建: 编译所有 Rust 服务...${NC}" +echo "使用 Dockerfile: docker/Dockerfile.builder" + +# 检查是否需要重新构建 (这一步可以进一步优化,但为了简单起见,我们总是构建,依赖Docker层缓存) +docker build -t fundamental-workspace-builder -f docker/Dockerfile.builder . + +# 提取二进制文件 +echo -e "${YELLOW}>>> 正在提取二进制文件...${NC}" +mkdir -p ./dist_bin + +# 创建临时容器 +docker create --name fundamental-builder-extract fundamental-workspace-builder + +# 从容器中复制 target/release 目录下的二进制文件 +# 注意: 这里我们复制整个 release 目录可能会太大,我们只复制二进制文件 +# 但是 docker cp 不支持通配符复制特定文件列表,所以我们先全部复制出来,或者我们知道名字 + +# 定义二进制文件映射 (服务目录 -> 二进制名称) +# 如果二进制名称与目录名一致,则只需列出目录名 +declare -A SERVICE_BIN_MAP +SERVICE_BIN_MAP=( + ["data-persistence-service"]="data-persistence-service-server" + ["api-gateway"]="api-gateway" + ["alphavantage-provider-service"]="alphavantage-provider-service" + ["tushare-provider-service"]="tushare-provider-service" + ["finnhub-provider-service"]="finnhub-provider-service" + ["yfinance-provider-service"]="yfinance-provider-service" + ["report-generator-service"]="report-generator-service" + ["workflow-orchestrator-service"]="workflow-orchestrator-service" + # ["mock-provider-service"]="mock-provider-service" # Skipped for Prod ) -# 总大小计数器 +for SERVICE_DIR in "${!SERVICE_BIN_MAP[@]}"; do + BINARY_NAME="${SERVICE_BIN_MAP[$SERVICE_DIR]}" + echo "提取: $BINARY_NAME" + docker cp "fundamental-builder-extract:/usr/src/app/target/release/$BINARY_NAME" "./dist_bin/$BINARY_NAME" +done + +# 删除临时容器 +docker rm -f fundamental-builder-extract + +echo -e "${GREEN}√ 二进制提取完成${NC}" + +# ========================================== +# 阶段 2: 前端构建 (Frontend) +# ========================================== +echo -e "\n${YELLOW}>>> [阶段 2/3] 构建前端服务...${NC}" +FRONTEND_IMAGE="$NAMESPACE/frontend:$VERSION" +docker build -t "$FRONTEND_IMAGE" -f docker/Dockerfile.frontend.prod . +echo -e "${YELLOW}>>> 推送前端镜像...${NC}" +docker push "$FRONTEND_IMAGE" +echo -e "${GREEN}√ 前端处理完成${NC}" + + +# ========================================== +# 阶段 3: 打包与分发 (Package Many) +# ========================================== +echo -e "\n${YELLOW}>>> [阶段 3/3] 打包并推送后端微服务...${NC}" + TOTAL_SIZE=0 -for entry in "${SERVICES[@]}"; do - KEY="${entry%%:*}" - DOCKERFILE="${entry#*:}" - IMAGE_NAME="$NAMESPACE/$KEY:$VERSION" +for SERVICE_DIR in "${!SERVICE_BIN_MAP[@]}"; do + BINARY_NAME="${SERVICE_BIN_MAP[$SERVICE_DIR]}" + IMAGE_NAME="$NAMESPACE/$SERVICE_DIR:$VERSION" - echo -e "\n${YELLOW}>>> 正在构建 $KEY ...${NC}" - echo "使用 Dockerfile: $DOCKERFILE" + echo -e "\n------------------------------------------------" + echo -e "${YELLOW}处理服务: $SERVICE_DIR${NC}" - # 构建镜像 - # 注意:构建上下文始终为项目根目录 (.) - docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" . - - # 获取镜像大小 (MB) - SIZE_BYTES=$(docker inspect "$IMAGE_NAME" --format='{{.Size}}') - SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1024 / 1024" | bc) + # 准备构建上下文 + CONTEXT_DIR="./temp_build_context/$SERVICE_DIR" + rm -rf "$CONTEXT_DIR" + mkdir -p "$CONTEXT_DIR" + mkdir -p "$CONTEXT_DIR/assets" - echo -e "${GREEN}√ $KEY 构建完成. 大小: ${SIZE_MB} MB${NC}" + # 1. 复制二进制文件并重命名为 app + cp "./dist_bin/$BINARY_NAME" "$CONTEXT_DIR/app" - # 累加大小 - TOTAL_SIZE=$(echo "$TOTAL_SIZE + $SIZE_BYTES" | bc) + # 2. 复制配置目录 (如果需要) + # data-persistence-service 等服务需要根目录的 config + cp -r config "$CONTEXT_DIR/config" - # 检查单个镜像大小是否异常 (例如超过 500MB 对于 Rust 微服务来说通常是不正常的,除非包含大模型) - if (( $(echo "$SIZE_MB > 500" | bc -l) )); then - echo -e "${RED}警告: $KEY 镜像大小超过 500MB,请检查 Dockerfile 是否包含不必要的文件!${NC}" - # 这里我们可以选择暂停询问用户,或者只是警告 + # 3. 复制服务特定的资产 (Assets) + # 3.1 Migrations + if [ -d "services/$SERVICE_DIR/migrations" ]; then + echo " - 包含 migrations" + mkdir -p "$CONTEXT_DIR/assets/migrations" + cp -r "services/$SERVICE_DIR/migrations/"* "$CONTEXT_DIR/assets/migrations/" + fi + + # 3.2 Templates + if [ -d "services/$SERVICE_DIR/templates" ]; then + echo " - 包含 templates" + mkdir -p "$CONTEXT_DIR/assets/templates" + cp -r "services/$SERVICE_DIR/templates/"* "$CONTEXT_DIR/assets/templates/" + fi + + # 3.3 Cookies + if [ -f "services/$SERVICE_DIR/cookies.txt" ]; then + echo " - 包含 cookies.txt" + cp "services/$SERVICE_DIR/cookies.txt" "$CONTEXT_DIR/assets/cookies.txt" + fi + + # 3.4 Web Assets (e.g. data-persistence-service assets folder if exists) + if [ -d "services/$SERVICE_DIR/assets" ]; then + echo " - 包含 web assets" + cp -r "services/$SERVICE_DIR/assets/"* "$CONTEXT_DIR/assets/" fi - echo -e "${YELLOW}>>> 正在推送 $KEY 到 Harbor ...${NC}" + # 4. 构建极简镜像 + # 不需要传递构建参数,因为文件已经准备好了 + docker build -t "$IMAGE_NAME" -f docker/Dockerfile.dist "$CONTEXT_DIR" + + # 5. 推送 + echo -e "${YELLOW} 推送 $SERVICE_DIR 到 Harbor ...${NC}" docker push "$IMAGE_NAME" + + # 统计大小 + SIZE_BYTES=$(docker inspect "$IMAGE_NAME" --format='{{.Size}}') + TOTAL_SIZE=$(echo "$TOTAL_SIZE + $SIZE_BYTES" | bc) done TOTAL_SIZE_MB=$(echo "scale=2; $TOTAL_SIZE / 1024 / 1024" | bc) echo -e "\n${GREEN}=== 所有镜像处理完成 ===${NC}" -echo -e "${GREEN}总大小: ${TOTAL_SIZE_MB} MB${NC}" +echo -e "${GREEN}后端总大小: ${TOTAL_SIZE_MB} MB${NC}" -# 检查总大小是否超过 1GB (1024 MB) -if (( $(echo "$TOTAL_SIZE_MB > 1024" | bc -l) )); then - echo -e "${RED}警告: 总镜像大小超过 1GB,请注意远程仓库的空间限制!${NC}" -else - echo -e "${GREEN}总大小在 1GB 限制范围内。${NC}" -fi -# 生成服务器使用的 docker-compose.server.yml +# ========================================== +# 阶段 4: 生成部署文件 +# ========================================== echo -e "\n${YELLOW}>>> 正在生成服务器部署文件 docker-compose.server.yml ...${NC}" -cat > docker-compose.server.yml < docker-compose.server.yml </dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 5 networks: - app-network - - frontend: - image: $NAMESPACE/frontend:$VERSION - container_name: fundamental-frontend - restart: unless-stopped - environment: - NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1 - FRONTEND_INTERNAL_URL: http://fundamental-frontend:3000 - BACKEND_INTERNAL_URL: http://api-gateway:4000/v1 - NODE_ENV: production - ports: - - "3001:3000" - depends_on: - api-gateway: - condition: service_healthy - networks: - - app-network + restart: always api-gateway: image: $NAMESPACE/api-gateway:$VERSION container_name: api-gateway - restart: unless-stopped environment: SERVER_PORT: 4000 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 - PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]' + REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004 RUST_LOG: info,axum=info RUST_BACKTRACE: "1" depends_on: - - nats - - data-persistence-service - - alphavantage-provider-service - - tushare-provider-service - - finnhub-provider-service - - yfinance-provider-service + nats: + condition: service_started + data-persistence-service: + condition: service_healthy networks: - app-network healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"] - interval: 5s + interval: 10s timeout: 5s - retries: 12 + retries: 5 + restart: always alphavantage-provider-service: image: $NAMESPACE/alphavantage-provider-service:$VERSION container_name: alphavantage-provider-service - restart: unless-stopped + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8000 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 - RUST_LOG: info,axum=info + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: alphavantage-provider-service + RUST_LOG: info RUST_BACKTRACE: "1" depends_on: - nats - data-persistence-service networks: - app-network - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1"] - interval: 5s - timeout: 5s - retries: 12 + restart: always tushare-provider-service: image: $NAMESPACE/tushare-provider-service:$VERSION container_name: tushare-provider-service - restart: unless-stopped + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8001 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 TUSHARE_API_URL: http://api.waditu.com - RUST_LOG: info,axum=info + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: tushare-provider-service + RUST_LOG: info RUST_BACKTRACE: "1" depends_on: - nats - data-persistence-service networks: - app-network - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8001/health >/dev/null || exit 1"] - interval: 5s - timeout: 5s - retries: 12 + restart: always finnhub-provider-service: image: $NAMESPACE/finnhub-provider-service:$VERSION container_name: finnhub-provider-service - restart: unless-stopped + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8002 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 FINNHUB_API_URL: https://finnhub.io/api/v1 - RUST_LOG: info,axum=info + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: finnhub-provider-service + RUST_LOG: info RUST_BACKTRACE: "1" depends_on: - nats - data-persistence-service networks: - app-network - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8002/health >/dev/null || exit 1"] - interval: 5s - timeout: 5s - retries: 12 + restart: always yfinance-provider-service: image: $NAMESPACE/yfinance-provider-service:$VERSION container_name: yfinance-provider-service - restart: unless-stopped + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8003 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 - RUST_LOG: info,axum=info + API_GATEWAY_URL: http://api-gateway:4000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + SERVICE_HOST: yfinance-provider-service + RUST_LOG: info RUST_BACKTRACE: "1" depends_on: - nats @@ -247,41 +325,79 @@ services: dns: - 8.8.8.8 - 8.8.4.4 - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"] - interval: 5s - timeout: 5s - retries: 12 + restart: always report-generator-service: image: $NAMESPACE/report-generator-service:$VERSION container_name: report-generator-service - restart: unless-stopped + volumes: + - workflow_data:/mnt/workflow_data environment: SERVER_PORT: 8004 NATS_ADDR: nats://nats:4222 DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 - RUST_LOG: info,axum=info + GOTENBERG_URL: http://gotenberg:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info + RUST_BACKTRACE: "1" + depends_on: + - nats + - data-persistence-service + - gotenberg + networks: + - app-network + restart: always + + workflow-orchestrator-service: + image: $NAMESPACE/workflow-orchestrator-service:$VERSION + container_name: workflow-orchestrator-service + volumes: + - workflow_data:/mnt/workflow_data + environment: + SERVER_PORT: 8005 + NATS_ADDR: nats://nats:4222 + DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000 + WORKFLOW_DATA_PATH: /mnt/workflow_data + RUST_LOG: info RUST_BACKTRACE: "1" depends_on: - nats - data-persistence-service networks: - app-network - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8004/health >/dev/null || exit 1"] - interval: 5s - timeout: 5s - retries: 12 + restart: always + + gotenberg: + image: gotenberg/gotenberg:8 + container_name: gotenberg + networks: + - app-network + restart: always + + frontend: + image: $NAMESPACE/frontend:$VERSION + container_name: fundamental-frontend + ports: + - "8080:80" # Map host 8080 to container 80 (Nginx) + depends_on: + api-gateway: + condition: service_healthy + networks: + - app-network + restart: always volumes: + workflow_data: pgdata: nats_data: networks: app-network: -EOF +YAML echo -e "${GREEN}生成完成: docker-compose.server.yml${NC}" -echo -e "请将此文件复制到远程服务器,并执行: docker-compose -f docker-compose.server.yml up -d" - +echo -e "请执行以下步骤更新远端服务器:" +echo -e "1. 将 docker-compose.server.yml 复制到服务器" +echo -e "2. 在服务器执行: docker-compose -f docker-compose.server.yml pull (拉取最新镜像)" +echo -e "3. 在服务器执行: docker-compose -f docker-compose.server.yml up -d (重启服务)" +echo -e " 或者一键命令: docker-compose -f docker-compose.server.yml up -d --pull always" diff --git a/services/api-gateway/src/api.rs b/services/api-gateway/src/api.rs index 0deaea2..86ed4b7 100644 --- a/services/api-gateway/src/api.rs +++ b/services/api-gateway/src/api.rs @@ -8,8 +8,7 @@ use axum::{ routing::{get, post}, }; use common_contracts::config_models::{ - AnalysisTemplateSets, DataSourceProvider, - DataSourcesConfig, LlmProvider, LlmProvidersConfig, + DataSourcesConfig, LlmProvidersConfig, AnalysisTemplateSummary, AnalysisTemplateSet }; use common_contracts::dtos::{SessionDataDto, WorkflowHistoryDto, WorkflowHistorySummaryDto}; @@ -20,9 +19,9 @@ use common_contracts::subjects::{NatsSubject, SubjectMessage}; use common_contracts::symbol_utils::{CanonicalSymbol, Market}; use futures_util::future::join_all; use futures_util::stream::StreamExt; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use tokio::try_join; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +// use tokio::try_join; use tracing::{error, info, warn}; use uuid::Uuid; use utoipa::OpenApi; @@ -204,7 +203,7 @@ fn create_v1_router() -> Router { ) .route("/configs/test", post(test_data_source_config)) .route("/configs/llm/test", post(test_llm_config)) - .route("/config", get(get_legacy_system_config)) + // .route("/config", get(get_legacy_system_config)) .route("/discover-models/{provider_id}", get(discover_models)) .route("/discover-models", post(discover_models_preview)) .route("/registry/register", post(registry::register_service)) @@ -269,128 +268,10 @@ async fn proxy_generate_pdf( // ... rest of file (unchanged) ... // Including legacy config and other handlers here to complete file write... -// --- Legacy Config Compatibility --- - -#[derive(Serialize, Default)] -struct LegacyDatabaseConfig { - url: Option, -} - -#[derive(Serialize, Default)] -struct LegacyNewApiConfig { - provider_id: Option, - provider_name: Option, - api_key: Option, - base_url: Option, - model_count: usize, -} - -#[derive(Serialize, Default)] -struct LegacyDataSourceConfig { - provider: String, - api_key: Option, - api_url: Option, - enabled: bool, -} - -#[derive(Serialize)] -struct LegacySystemConfigResponse { - database: LegacyDatabaseConfig, - new_api: LegacyNewApiConfig, - data_sources: HashMap, - llm_providers: LlmProvidersConfig, - analysis_template_sets: AnalysisTemplateSets, -} - -async fn get_legacy_system_config(State(state): State) -> Result { - let persistence = state.persistence_client.clone(); - // let (llm_providers, analysis_template_sets, data_sources) = try_join!( - // persistence.get_llm_providers_config(), - // persistence.get_analysis_template_sets(), - // persistence.get_data_sources_config() - // )?; - let (llm_providers, data_sources) = try_join!( - persistence.get_llm_providers_config(), - persistence.get_data_sources_config() - )?; - let analysis_template_sets = AnalysisTemplateSets::default(); // Empty placeholder - - let new_api = derive_primary_provider(&llm_providers); - let ds_map = project_data_sources(data_sources); - let database_url = std::env::var("DATABASE_URL").ok(); - - let response = LegacySystemConfigResponse { - database: LegacyDatabaseConfig { url: database_url }, - new_api, - data_sources: ds_map, - llm_providers, - analysis_template_sets, - }; - - Ok(Json(response)) -} - -fn derive_primary_provider(providers: &LlmProvidersConfig) -> LegacyNewApiConfig { - const PREFERRED_IDS: [&str; 3] = ["new_api", "openrouter", "default"]; - - let mut selected_id: Option = None; - let mut selected_provider: Option<&LlmProvider> = None; - - for preferred in PREFERRED_IDS { - if let Some(provider) = providers.get(preferred) { - selected_id = Some(preferred.to_string()); - selected_provider = Some(provider); - break; - } - } - - if selected_provider.is_none() { - if let Some((fallback_id, provider)) = providers.iter().next() { - selected_id = Some(fallback_id.clone()); - selected_provider = Some(provider); - } - } - - if let Some(provider) = selected_provider { - LegacyNewApiConfig { - provider_id: selected_id, - provider_name: Some(provider.name.clone()), - api_key: Some(provider.api_key.clone()), - base_url: Some(provider.api_base_url.clone()), - model_count: provider.models.len(), - } - } else { - LegacyNewApiConfig::default() - } -} - -fn project_data_sources( - configs: DataSourcesConfig, -) -> HashMap { - configs - .0 - .into_iter() - .map(|(key, cfg)| { - let provider = provider_id(&cfg.provider).to_string(); - let entry = LegacyDataSourceConfig { - provider, - api_key: cfg.api_key.clone(), - api_url: cfg.api_url.clone(), - enabled: cfg.enabled, - }; - (key, entry) - }) - .collect() - } - -fn provider_id(provider: &DataSourceProvider) -> &'static str { - match provider { - DataSourceProvider::Tushare => "tushare", - DataSourceProvider::Finnhub => "finnhub", - DataSourceProvider::Alphavantage => "alphavantage", - DataSourceProvider::Yfinance => "yfinance", - } -} +// --- Legacy Config Compatibility - REMOVED --- +/* +// Legacy structs and handlers removed to enforce new design. +*/ // --- Helper Functions --- @@ -1181,12 +1062,13 @@ async fn get_registered_providers(State(state): State) -> Result = entries .into_iter() .filter_map(|entry| { // Only return DataProvider services that have metadata if entry.registration.role == common_contracts::registry::ServiceRole::DataProvider { - entry.registration.metadata + entry.registration.metadata.filter(|m| seen_ids.insert(m.id.clone())) } else { None } diff --git a/services/common-contracts/src/config_models.rs b/services/common-contracts/src/config_models.rs index c5ae94e..fe71f71 100644 --- a/services/common-contracts/src/config_models.rs +++ b/services/common-contracts/src/config_models.rs @@ -128,6 +128,7 @@ pub enum DataSourceProvider { Finnhub, Alphavantage, Yfinance, + Mock, } #[api_dto] diff --git a/services/finnhub-provider-service/src/api.rs b/services/finnhub-provider-service/src/api.rs index 3b2d585..dc69c04 100644 --- a/services/finnhub-provider-service/src/api.rs +++ b/services/finnhub-provider-service/src/api.rs @@ -1,20 +1,84 @@ use std::collections::HashMap; use axum::{ extract::State, - response::Json, - routing::get, + response::{Json, IntoResponse}, + routing::{get, post}, Router, + http::StatusCode, }; use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress}; use crate::state::{AppState, ServiceOperationalStatus}; +use serde::Deserialize; +use crate::fh_client::FinnhubClient; pub fn create_router(app_state: AppState) -> Router { Router::new() .route("/health", get(health_check)) .route("/tasks", get(get_current_tasks)) + .route("/test", post(test_connection)) .with_state(app_state) } +#[derive(Deserialize)] +struct TestRequest { + api_key: Option, + api_url: Option, +} + +async fn test_connection( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let api_url = payload.api_url + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| state.config.finnhub_api_url.clone()); + + let api_key = if let Some(k) = payload.api_key.filter(|s| !s.is_empty()) { + k + } else if let Some(k) = &state.config.finnhub_api_key { + k.clone() + } else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "message": "No API Key provided or configured" + })) + ).into_response(); + }; + + // Validate API Key with a lightweight request (e.g. AAPL quote) + let client_res = FinnhubClient::new(api_url, api_key); + + match client_res { + Ok(client) => { + match client.get::("quote", vec![("symbol".to_string(), "AAPL".to_string())]).await { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "message": "Connection successful" + })) + ).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "success": false, + "message": format!("Connection failed: {}", e) + })) + ).into_response(), + } + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, + "message": format!("Failed to initialize client: {}", e) + })) + ).into_response() + } +} + /// [GET /health] /// Provides the current health status of the module. async fn health_check(State(state): State) -> Json { diff --git a/services/mock-provider-service/src/api.rs b/services/mock-provider-service/src/api.rs index 8fd145d..87e5f50 100644 --- a/services/mock-provider-service/src/api.rs +++ b/services/mock-provider-service/src/api.rs @@ -3,15 +3,17 @@ use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Json}, - routing::get, + routing::{get, post}, Router, }; use common_contracts::observability::TaskProgress; +use serde::Deserialize; pub fn create_router(state: AppState) -> Router { Router::new() .route("/health", get(health_check)) .route("/tasks", get(get_tasks)) + .route("/test", post(test_connection)) .with_state(state) } @@ -24,3 +26,24 @@ async fn get_tasks(State(state): State) -> impl IntoResponse { Json(tasks) } +#[derive(Deserialize)] +struct TestRequest { + // 允许接收任意参数,但不做处理 + #[allow(dead_code)] + api_key: Option, + #[allow(dead_code)] + api_url: Option, +} + +async fn test_connection( + Json(_payload): Json, +) -> impl IntoResponse { + // Mock Provider 总是成功 + ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "message": "Mock Provider connection successful" + })) + ) +} diff --git a/services/report-generator-service/src/persistence.rs b/services/report-generator-service/src/persistence.rs index c3aa093..aea2d61 100644 --- a/services/report-generator-service/src/persistence.rs +++ b/services/report-generator-service/src/persistence.rs @@ -26,7 +26,7 @@ impl PersistenceClient { pub async fn get_llm_providers_config(&self) -> Result { let url = format!("{}/api/v1/configs/llm_providers", self.base_url); - info!("Fetching LLM providers config from {}", url); + let config = self .client .get(&url)