Procházet zdrojové kódy

Merge branch 'sit' into uat

# Conflicts:
#	.env
#	.env.uat
#	Jenkinsfile
#	alien_contract/Dockerfile
#	alien_contract/main.py
#	alien_gateway/Dockerfile
#	alien_gateway/config.py
#	alien_gateway/main.py
#	alien_util/celery_app.py
#	alien_util/redis_client.py
#	test.py
dujian před 1 týdnem
rodič
revize
9e2a4fa8ce

+ 34 - 18
.env.dev

@@ -1,22 +1,38 @@
-SECRET_KEY="your-super-secret-key-change-me"
-ALGORITHM="HS256"
+################################################################################
+# DEV 环境(本地开发)
+# 此文件不入库,由开发者本机维护。模板见 .env.example
+################################################################################
+
+SECRET_KEY=your-super-secret-key-change-me
+ALGORITHM=HS256
 ACCESS_TOKEN_EXPIRE_MINUTES=10080
- # 数据库配置
-DB_USER="root"
-DB_PASSWORD="Alien123456"
-DB_HOST="192.168.2.253"
-DB_PORT=40001
-DB_NAME="alien_sit"
 
-# redis配置
-# REDIS_URL= "redis://:Alien123456@172.31.154.180:30002/0"
-REDIS_URL="redis://:Alien123456@192.168.2.253:40002/0"
+# 数据库
+DB_USER=root
+DB_PASSWORD=Alien123456
+DB_HOST=120.26.186.130
+DB_PORT=30001
+DB_NAME=alien_sit
+
+# Redis(单机模式)
+REDIS_URL=redis://:Alien123456@120.26.186.130:30002/0
+REDIS_SENTINELS=
+
+# 服务端口
+GATEWAY_PORT=33333
+STORE_PORT=8001
+CONTRACT_PORT=8002
+LAWYER_PORT=8004
+
+# 下游服务地址(本地开发各服务在 127.0.0.1 上分端口启动)
+STORE_BASE_URL=http://127.0.0.1:8001
+CONTRACT_BASE_URL=http://127.0.0.1:8002
 
-# 下游服务地址
-STORE_BASE_URL="http://127.0.0.1:8001"# alien_store 服务地址
+# e签宝
+ESIGN_CALLBACK_URL=http://120.26.186.130:33333/api/contract/esign/callback
 
-# 阿里云短信配置
-ALIYUN_SMS_SIGN_NAME_CONTRACT="爱丽恩严大连商务科技"
-ALIYUN_SMS_TEMPLATE_CODE_CONTRACT="SMS_501820309"
-ALIYUN_ACCESS_KEY_ID="LTAI5t77CS9gD7JMkMAjD2vF"
-ALIYUN_ACCESS_KEY_SECRET="jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8"
+# 阿里云短信
+ALIYUN_SMS_SIGN_NAME_CONTRACT=爱丽恩严大连商务科技
+ALIYUN_SMS_TEMPLATE_CODE_CONTRACT=SMS_501820309
+ALIYUN_ACCESS_KEY_ID=LTAI5t77CS9gD7JMkMAjD2vF
+ALIYUN_ACCESS_KEY_SECRET=jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8

+ 59 - 0
.env.example

@@ -0,0 +1,59 @@
+################################################################################
+# Alien Cloud Python - 环境变量模板(入库,不含敏感信息)
+#
+# 使用方法:
+#   1) 复制为 .env.dev / .env.sit / .env.uat(按环境填充)
+#   2) 启动服务前设置 APP_ENV,例如:
+#        export APP_ENV=dev        # 本地开发
+#        docker run -e APP_ENV=uat ...
+#   3) config.py 会自动加载 .env.${APP_ENV}
+################################################################################
+
+# 当前环境标识(dev/sit/uat),通常通过 docker run -e APP_ENV=xxx 注入而非写在文件里
+# APP_ENV=dev
+
+# -------------------- 鉴权 --------------------
+SECRET_KEY=please-change-me
+ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+
+# -------------------- 数据库 --------------------
+DB_USER=root
+DB_PASSWORD=
+DB_HOST=
+DB_PORT=3306
+DB_NAME=
+
+# -------------------- Redis(单机与哨兵二选一) --------------------
+# 单机模式:填 REDIS_URL,留空 REDIS_SENTINELS
+REDIS_URL=
+# 哨兵模式:填 REDIS_SENTINELS,留空 REDIS_URL
+REDIS_SENTINELS=
+REDIS_MASTER_NAME=mymaster
+REDIS_PASSWORD=
+REDIS_DB=0
+REDIS_SENTINEL_USERNAME=
+REDIS_SENTINEL_PASSWORD=
+REDIS_SOCKET_TIMEOUT=0.5
+REDIS_CONNECT_TIMEOUT=1.0
+
+# -------------------- 服务监听端口(容器内) --------------------
+GATEWAY_PORT=33333
+STORE_PORT=8001
+CONTRACT_PORT=8002
+LAWYER_PORT=8004
+
+# -------------------- 下游服务地址(网关代理用) --------------------
+STORE_BASE_URL=http://127.0.0.1:8001
+CONTRACT_BASE_URL=http://127.0.0.1:8002
+
+# -------------------- e签宝回调地址 --------------------
+ESIGN_CALLBACK_URL=
+ESIGN_DEVELOPER_CALLBACK_URL=
+ESIGN_REDIRECT_URL=https://www.esign.cn/
+
+# -------------------- 阿里云短信 --------------------
+ALIYUN_SMS_SIGN_NAME_CONTRACT=
+ALIYUN_SMS_TEMPLATE_CODE_CONTRACT=
+ALIYUN_ACCESS_KEY_ID=
+ALIYUN_ACCESS_KEY_SECRET=

+ 38 - 0
.env.sit

@@ -0,0 +1,38 @@
+################################################################################
+# SIT 环境(集成测试)
+# 此文件不入库,由 CI/CD 或运维注入。模板见 .env.example
+################################################################################
+
+SECRET_KEY=your-super-secret-key-change-me
+ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRE_MINUTES=10080
+
+# 数据库
+DB_USER=root
+DB_PASSWORD=Alien123456
+DB_HOST=120.26.186.130
+DB_PORT=30001
+DB_NAME=alien_sit
+
+# Redis(单机模式)
+REDIS_URL=redis://:Alien123456@172.31.154.180:30002/0
+REDIS_SENTINELS=
+
+# 服务端口
+GATEWAY_PORT=33333
+STORE_PORT=8001
+CONTRACT_PORT=8002
+LAWYER_PORT=8004
+
+# 下游服务地址(容器化部署时由 Jenkinsfile 用 -e 覆盖为容器名)
+STORE_BASE_URL=http://127.0.0.1:8001
+CONTRACT_BASE_URL=http://127.0.0.1:8002
+
+# e签宝
+ESIGN_CALLBACK_URL=http://120.26.186.130:33333/api/contract/esign/callback
+
+# 阿里云短信
+ALIYUN_SMS_SIGN_NAME_CONTRACT=爱丽恩严大连商务科技
+ALIYUN_SMS_TEMPLATE_CODE_CONTRACT=SMS_501820309
+ALIYUN_ACCESS_KEY_ID=LTAI5t77CS9gD7JMkMAjD2vF
+ALIYUN_ACCESS_KEY_SECRET=jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8

+ 32 - 17
.env.uat

@@ -1,26 +1,41 @@
-SECRET_KEY="your-super-secret-key-change-me"
-ALGORITHM="HS256"
+################################################################################
+# UAT 环境
+# 此文件不入库,由 CI/CD 或运维注入。模板见 .env.example
+################################################################################
+
+SECRET_KEY=your-super-secret-key-change-me
+ALGORITHM=HS256
 ACCESS_TOKEN_EXPIRE_MINUTES=10080
- # 数据库配置
-DB_USER="root"
-DB_PASSWORD="Alien123456"
-DB_HOST="ailiendb.mysql.rds.aliyuncs.com"
+
+# 数据库
+DB_USER=root
+DB_PASSWORD=Alien123456
+DB_HOST=ailiendb.mysql.rds.aliyuncs.com
 DB_PORT=53306
-DB_NAME="alien_uat"
+DB_NAME=alien_uat
 
-# redis高可用配置
+# Redis(哨兵高可用模式)
+REDIS_URL=
 REDIS_SENTINELS=192.168.2.251:36379,192.168.2.252:36379,192.168.2.253:36379
 REDIS_MASTER_NAME=mymaster
 REDIS_PASSWORD=my_password_123
-#REDIS_SENTINEL_USERNAME=default
 REDIS_SENTINEL_PASSWORD=
 
-# 下游服务地址(容器部署时由 docker run -e 覆盖为容器名:容器端口)
-STORE_BASE_URL="http://127.0.0.1:8001"# alien_store 服务地址(本地开发用)
-CONTRACT_BASE_URL="http://127.0.0.1:8002"# alien_contract 服务地址(本地开发用)
+# 服务端口(UAT 使用独立端口避开占用)
+GATEWAY_PORT=43333
+STORE_PORT=8001
+CONTRACT_PORT=8002
+LAWYER_PORT=8004
+
+# 下游服务地址(容器化部署时由 Jenkinsfile 用 -e 覆盖为容器名)
+STORE_BASE_URL=http://127.0.0.1:8001
+CONTRACT_BASE_URL=http://127.0.0.1:8002
+
+# e签宝
+ESIGN_CALLBACK_URL=https://uat.ailien.shop/api/contract/esign/callback
 
-# 阿里云短信配置
-ALIYUN_SMS_SIGN_NAME_CONTRACT="爱丽恩严大连商务科技"
-ALIYUN_SMS_TEMPLATE_CODE_CONTRACT="SMS_501820309"
-ALIYUN_ACCESS_KEY_ID="LTAI5t77CS9gD7JMkMAjD2vF"
-ALIYUN_ACCESS_KEY_SECRET="jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8"
+# 阿里云短信
+ALIYUN_SMS_SIGN_NAME_CONTRACT=爱丽恩严大连商务科技
+ALIYUN_SMS_TEMPLATE_CODE_CONTRACT=SMS_501820309
+ALIYUN_ACCESS_KEY_ID=LTAI5t77CS9gD7JMkMAjD2vF
+ALIYUN_ACCESS_KEY_SECRET=jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8

+ 7 - 1
.gitignore

@@ -101,7 +101,11 @@ celerybeat.pid
 *.sage.py
 
 # Environments
+# 私有部署:三个环境的真实配置 .env.dev / .env.sit / .env.uat 入库;
+# 本地个人覆盖文件不入库。
 .env
+.env.local
+.env.*.local
 .venv
 env/
 venv/
@@ -143,4 +147,6 @@ cython_debug/
 .history/
 
 # macOS
-.DS_Store
+.DS_Store
+
+commit_msg.txt

+ 157 - 86
Jenkinsfile

@@ -1,75 +1,101 @@
 pipeline {
   agent any
+
   options {
-    buildDiscarder(logRotator(numToKeepStr: '5'))
+    buildDiscarder(logRotator(numToKeepStr: '10'))
     timestamps()
+    disableConcurrentBuilds()
   }
-  parameters {
-    string(name: 'BRANCH', defaultValue: 'uat', description: '要部署到UAT的分支')
-  }
-
-  environment {
-    // Dockerfile 路径(相对于代码根目录,即 Jenkins workspace)
-    DOCKERFILE_STORE    = "alien_store/Dockerfile"
-    DOCKERFILE_GATEWAY  = "alien_gateway/Dockerfile"
-    DOCKERFILE_CONTRACT = "alien_contract/Dockerfile"
-
-    // 如需推送远端 Registry,填写地址,否则留空
-    REGISTRY      = ""
-    REGISTRY_CRED = "registry-cred-id"
-
-    // 镜像标签统一使用 uat
-    IMAGE_STORE    = "${REGISTRY ? REGISTRY + '/alien_store:uat-' + env.BUILD_NUMBER : 'alien_store:uat'}"
-    IMAGE_GATEWAY  = "${REGISTRY ? REGISTRY + '/alien_gateway:uat-' + env.BUILD_NUMBER : 'alien_gateway:uat'}"
-    IMAGE_CONTRACT = "${REGISTRY ? REGISTRY + '/alien_contract:uat-' + env.BUILD_NUMBER : 'alien_contract:uat'}"
 
-    // 容器名(store 不要再用 esign-uat,会和真正的 e签宝容器撞名)
-    CONTAINER_NAME_STORE    = "alien_store_py-uat"
-    CONTAINER_NAME_GATEWAY  = "alien_gateway_py-uat"
-    CONTAINER_NAME_CONTRACT = "alien_contract_py-uat"
+  // 设计原则:
+  // - 无 parameters 块,所以"Build Now"直接跑、不弹窗
+  // - 环境(APP_ENV) 与 分支(BRANCH) 都从 Jenkins 自动注入的 GIT_BRANCH 推断
+  //   GIT_BRANCH 来自每个 Job 的 SCM 配置(Branch Specifier *​/dev、*​/sit、*​/uat 之一)
+  // - 因此每台机器上 Job 唯一要配的就是 Branch Specifier,三台机器的 Jenkinsfile 内容完全一致
 
-    // 容器内端口(必须与各服务 Dockerfile 中 uvicorn 监听端口一致)
-    PORT_STORE    = "8001"
-    PORT_GATEWAY  = "43333"
-    PORT_CONTRACT = "8002"
+  stages {
 
-    // 使用独立的 UAT 网络
-    DOCKER_NET = "alien_net_uat"
+    stage('Resolve Environment') {
+      // 从 Jenkins 注入的 GIT_BRANCH 推断 APP_ENV
+      // GIT_BRANCH 形如 "origin/uat" / "uat" / "refs/heads/uat",统一去前缀
+      steps {
+        script {
+          def raw = env.GIT_BRANCH ?: env.BRANCH_NAME ?: ''
+          def branch = raw.replaceFirst('^origin/', '').replaceFirst('^refs/heads/', '').trim()
+
+          if (!(branch in ['dev', 'sit', 'uat'])) {
+            error """
+              ============ 无法识别当前部署环境 ============
+              Jenkins 注入的 GIT_BRANCH = '${env.GIT_BRANCH}'
+              解析后的分支名             = '${branch}'
+              期望分支必须是 dev / sit / uat 之一。
+
+              请检查 Job 配置中的 Branch Specifier 是否正确:
+                DEV 服务器  → */dev
+                SIT 服务器  → */sit
+                UAT 服务器  → */uat
+              =============================================
+            """.stripIndent()
+          }
 
-    // UAT 日志目录
-    LOG_ROOT = "/docker/python-uat/logs"
-  }
+          // 把推断结果写回 env,后续 stage 用 env.APP_ENV 而不是 params.APP_ENV
+          env.APP_ENV = branch
+          env.BRANCH  = branch
+
+          // 派生命名规则(容器名/网络/日志/镜像 TAG 全部以 APP_ENV 区分)
+          env.IMAGE_TAG               = "${env.APP_ENV}-${env.BUILD_NUMBER}"
+          env.IMAGE_STORE             = "alien_store:${env.IMAGE_TAG}"
+          env.IMAGE_GATEWAY           = "alien_gateway:${env.IMAGE_TAG}"
+          env.IMAGE_CONTRACT          = "alien_contract:${env.IMAGE_TAG}"
+          env.CONTAINER_NAME_STORE    = "alien_store_py-${env.APP_ENV}"
+          env.CONTAINER_NAME_GATEWAY  = "alien_gateway_py-${env.APP_ENV}"
+          env.CONTAINER_NAME_CONTRACT = "alien_contract_py-${env.APP_ENV}"
+          env.DOCKER_NET              = "alien_net_${env.APP_ENV}"
+          env.LOG_ROOT                = "/docker/python-${env.APP_ENV}/logs"
+        }
+      }
+    }
 
-  stages {
-    // 注意:使用 "Pipeline script from SCM" 模式时,
-    // Jenkins 会自动 checkout 代码到 workspace,不需要在 Jenkinsfile 里再写 Checkout stage。
+    stage('Show Build Info') {
+      steps {
+        echo "============================================================"
+        echo " 部署环境  : ${env.APP_ENV}    (来自 GIT_BRANCH=${env.GIT_BRANCH})"
+        echo " 部署分支  : ${env.BRANCH}"
+        echo " 镜像 TAG  : ${env.IMAGE_TAG}"
+        echo " 容器网络  : ${env.DOCKER_NET}"
+        echo " 日志根目录: ${env.LOG_ROOT}"
+        echo "============================================================"
+      }
+    }
 
-    stage('Build Images') {
+    stage('Load Env Port Mapping') {
+      // 从 .env.${APP_ENV} 解析 GATEWAY_PORT,作为 docker -p 宿主端口映射用
       steps {
         script {
-          // 直接在当前 workspace(Jenkins 已 checkout 的代码根)里构建
-          if (env.REGISTRY?.trim()) {
-            withDockerRegistry(credentialsId: env.REGISTRY_CRED, url: "") {
-              sh "docker build -f ${DOCKERFILE_STORE} -t ${IMAGE_STORE} ."
-              sh "docker build -f ${DOCKERFILE_GATEWAY} -t ${IMAGE_GATEWAY} ."
-              sh "docker build -f ${DOCKERFILE_CONTRACT} -t ${IMAGE_CONTRACT} ."
-            }
-          } else {
-            sh "docker build -f ${DOCKERFILE_STORE} -t ${IMAGE_STORE} ."
-            sh "docker build -f ${DOCKERFILE_GATEWAY} -t ${IMAGE_GATEWAY} ."
-            sh "docker build -f ${DOCKERFILE_CONTRACT} -t ${IMAGE_CONTRACT} ."
+          def envFile = ".env.${env.APP_ENV}"
+          if (!fileExists(envFile)) {
+            error "缺少环境配置文件: ${envFile}(仓库里应该有这份;请检查 .gitignore 是否误屏蔽)"
+          }
+          def gatewayPort = sh(
+            script: "grep -E '^GATEWAY_PORT=' ${envFile} | head -n1 | cut -d= -f2 | tr -d '\"' | tr -d \"'\"",
+            returnStdout: true
+          ).trim()
+          if (!gatewayPort) {
+            gatewayPort = "33333"
           }
+          env.GATEWAY_PORT = gatewayPort
+          echo "从 ${envFile} 解析到 GATEWAY_PORT=${env.GATEWAY_PORT}"
         }
       }
     }
 
-    stage('Push Images') {
-      when { expression { return env.REGISTRY?.trim() as boolean } }
+    stage('Build Images') {
       steps {
-        withDockerRegistry(credentialsId: env.REGISTRY_CRED, url: "") {
-          sh "docker push ${IMAGE_STORE}"
-          sh "docker push ${IMAGE_GATEWAY}"
-          sh "docker push ${IMAGE_CONTRACT}"
+        script {
+          def buildArgs = "--build-arg APP_ENV=${env.APP_ENV}"
+          sh "docker build ${buildArgs} -f alien_store/Dockerfile    -t ${env.IMAGE_STORE}    ."
+          sh "docker build ${buildArgs} -f alien_gateway/Dockerfile  -t ${env.IMAGE_GATEWAY}  ."
+          sh "docker build ${buildArgs} -f alien_contract/Dockerfile -t ${env.IMAGE_CONTRACT} ."
         }
       }
     }
@@ -77,57 +103,102 @@ pipeline {
     stage('Deploy') {
       steps {
         script {
-          echo ">>> UAT 部署镜像: ${IMAGE_STORE} / ${IMAGE_GATEWAY} / ${IMAGE_CONTRACT}"
+          echo ">>> [${env.APP_ENV}] 部署镜像: ${env.IMAGE_STORE} / ${env.IMAGE_GATEWAY} / ${env.IMAGE_CONTRACT}"
           sh """
-            # 创建 UAT 专用网络
-            docker network create ${DOCKER_NET} 2>/dev/null || true
+            set -e
 
-            # 创建日志目录
-            mkdir -p ${LOG_ROOT}/store ${LOG_ROOT}/gateway ${LOG_ROOT}/contract
+            docker network create ${env.DOCKER_NET} 2>/dev/null || true
+            mkdir -p ${env.LOG_ROOT}/store ${env.LOG_ROOT}/gateway ${env.LOG_ROOT}/contract
 
-            # 停止旧容器(不会误删 java 的 gateway-uat / store-uat / 真正的 esign-uat 等)
-            docker rm -f ${CONTAINER_NAME_STORE} ${CONTAINER_NAME_GATEWAY} ${CONTAINER_NAME_CONTRACT} 2>/dev/null || true
+            docker rm -f ${env.CONTAINER_NAME_STORE} ${env.CONTAINER_NAME_GATEWAY} ${env.CONTAINER_NAME_CONTRACT} 2>/dev/null || true
 
-            # 1) 先启动下游:store
-            #    APP_ENV=uat 让 alien_gateway/config.py 加载镜像内打包好的 .env.uat
-            docker run -d --name ${CONTAINER_NAME_STORE} \\
-              --network ${DOCKER_NET} \\
-              -e APP_ENV=uat \\
-              -v ${LOG_ROOT}/store:/app/common/logs/alien_store \\
+            # 1) 下游:store
+            #    APP_ENV=${env.APP_ENV} 让 config.py 加载镜像内的 .env.${env.APP_ENV}
+            docker run -d --name ${env.CONTAINER_NAME_STORE} \\
+              --network ${env.DOCKER_NET} \\
+              -e APP_ENV=${env.APP_ENV} \\
+              -v ${env.LOG_ROOT}/store:/app/common/logs/alien_store \\
               --restart unless-stopped \\
-              ${IMAGE_STORE}
+              ${env.IMAGE_STORE}
 
-            # 2) 再启动下游:contract
-            docker run -d --name ${CONTAINER_NAME_CONTRACT} \\
-              --network ${DOCKER_NET} \\
-              -e APP_ENV=uat \\
-              -v ${LOG_ROOT}/contract:/app/common/logs/alien_contract \\
+            # 2) 下游:contract
+            docker run -d --name ${env.CONTAINER_NAME_CONTRACT} \\
+              --network ${env.DOCKER_NET} \\
+              -e APP_ENV=${env.APP_ENV} \\
+              -v ${env.LOG_ROOT}/contract:/app/common/logs/alien_contract \\
               --restart unless-stopped \\
-              ${IMAGE_CONTRACT}
-
-            # 3) 最后启动网关:gateway(依赖上面两个下游;唯一对外暴露的入口)
-            #    STORE_BASE_URL / CONTRACT_BASE_URL 通过 -e 覆盖 .env.uat 中的本地默认值
-            docker run -d --name ${CONTAINER_NAME_GATEWAY} \\
-              --network ${DOCKER_NET} \\
-              -p ${PORT_GATEWAY}:${PORT_GATEWAY} \\
-              -e APP_ENV=uat \\
-              -e STORE_BASE_URL=http://${CONTAINER_NAME_STORE}:${PORT_STORE} \\
-              -e CONTRACT_BASE_URL=http://${CONTAINER_NAME_CONTRACT}:${PORT_CONTRACT} \\
-              -v ${LOG_ROOT}/gateway:/app/common/logs/alien_gateway \\
+              ${env.IMAGE_CONTRACT}
+
+            # 3) 网关:gateway(唯一对外端口)
+            #    -e GATEWAY_PORT=... 是关键:必须覆盖 Dockerfile 的默认 33333,
+            #    否则容器内 uvicorn 会监听默认端口,导致与宿主机映射端口对不上
+            #    pydantic-settings 也是环境变量优先于 .env 文件
+            docker run -d --name ${env.CONTAINER_NAME_GATEWAY} \\
+              --network ${env.DOCKER_NET} \\
+              -p ${env.GATEWAY_PORT}:${env.GATEWAY_PORT} \\
+              -e APP_ENV=${env.APP_ENV} \\
+              -e GATEWAY_PORT=${env.GATEWAY_PORT} \\
+              -e STORE_BASE_URL=http://${env.CONTAINER_NAME_STORE}:8001 \\
+              -e CONTRACT_BASE_URL=http://${env.CONTAINER_NAME_CONTRACT}:8002 \\
+              -v ${env.LOG_ROOT}/gateway:/app/common/logs/alien_gateway \\
               --restart unless-stopped \\
-              ${IMAGE_GATEWAY}
+              ${env.IMAGE_GATEWAY}
           """
         }
       }
     }
+
+    stage('Smoke Test') {
+      // 注意:Jenkins 本身可能跑在 Docker 容器里,无法直接 curl 宿主机端口。
+      // 这里采取两层验证:
+      //   1) 硬验证:3 个业务容器必须都在 running 状态(不通过则 fail)
+      //   2) 软验证:通过 docker exec 进入 gateway 容器内部跑 /health 自检
+      //      (HTTP 失败只 warn,不让 Pipeline 失败,因为这只代表 Jenkins 的网络位置看不到,
+      //       不代表服务对外不可用,需要人工从其他位置验证)
+      steps {
+        script {
+          sleep(time: 5, unit: 'SECONDS')
+
+          def containers = [
+            env.CONTAINER_NAME_STORE,
+            env.CONTAINER_NAME_CONTRACT,
+            env.CONTAINER_NAME_GATEWAY
+          ]
+          def allRunning = true
+          containers.each { c ->
+            def status = sh(
+              returnStdout: true,
+              script: "docker inspect -f '{{.State.Status}}' ${c} 2>/dev/null || echo missing"
+            ).trim()
+            echo "  ${c}: ${status}"
+            if (status != 'running') {
+              allRunning = false
+              sh "docker logs --tail 50 ${c} || true"
+            }
+          }
+          if (!allRunning) {
+            error "存在容器未处于 running 状态,部署失败"
+          }
+          echo "✓ 3 个业务容器均在 running 状态"
+
+          def healthCmd = """docker exec ${env.CONTAINER_NAME_GATEWAY} python -c 'import urllib.request as u; r=u.urlopen("http://localhost:${env.GATEWAY_PORT}/health", timeout=3); print("HTTP", r.status, r.read(200).decode())'"""
+          def rc = sh(returnStatus: true, script: healthCmd)
+          if (rc == 0) {
+            echo "✓ gateway /health 通过"
+          } else {
+            echo "⚠️ gateway /health 未通过(容器在跑但 HTTP 自检失败,请从外部手动验证:curl http://<host>:${env.GATEWAY_PORT}/health)"
+          }
+        }
+      }
+    }
   }
 
   post {
     success {
-      echo "UAT 环境部署成功!"
+      echo "[${env.APP_ENV}] 环境部署成功!请从外部访问 http://<host>:${env.GATEWAY_PORT}/health 验证"
     }
     failure {
-      echo "UAT 环境部署失败,请检查日志。"
+      echo "[${env.APP_ENV}] 环境部署失败,请检查上面日志。"
     }
     always {
       cleanWs()

+ 40 - 40
alembic/versions/a7d4b3e21c10_create_contract_center_tables.py

@@ -23,20 +23,20 @@ def upgrade() -> None:
     op.execute("DROP TABLE IF EXISTS contract_bundle")
     op.create_table(
         "contract_bundle",
-        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
-        sa.Column("subject_type", sa.String(length=20), nullable=False),
-        sa.Column("subject_id", sa.BigInteger(), nullable=False),
-        sa.Column("subject_name", sa.String(length=120), nullable=False),
-        sa.Column("business_segment", sa.String(length=100), nullable=False),
-        sa.Column("contact_name", sa.String(length=100), nullable=False),
-        sa.Column("contact_phone", sa.String(length=20), nullable=False),
-        sa.Column("ord_id", sa.String(length=40), nullable=False),
-        sa.Column("bundle_type", sa.String(length=50), nullable=False),
-        sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
-        sa.Column("primary_document_id", sa.BigInteger(), nullable=True),
-        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False, comment="合同包唯一标识"),
+        sa.Column("subject_type", sa.String(length=20), nullable=False, comment="签约主体类型(store/lawyer)"),
+        sa.Column("subject_id", sa.BigInteger(), nullable=False, comment="签约主体ID,关联门店/律师业务主键"),
+        sa.Column("subject_name", sa.String(length=120), nullable=False, comment="签约主体名称"),
+        sa.Column("business_segment", sa.String(length=100), nullable=False, comment="业务板块"),
+        sa.Column("contact_name", sa.String(length=100), nullable=False, comment="联系人姓名"),
+        sa.Column("contact_phone", sa.String(length=20), nullable=False, comment="联系人手机号"),
+        sa.Column("ord_id", sa.String(length=40), nullable=False, comment="组织标识(统一社会信用代码)"),
+        sa.Column("bundle_type", sa.String(length=50), nullable=False, comment="合同包类型(STORE_STANDARD/LAWYER_STANDARD)"),
+        sa.Column("status", sa.String(length=20), nullable=False, server_default="未签署", comment="整体状态: 未签署/审核中/已签署"),
+        sa.Column("primary_document_id", sa.BigInteger(), nullable=True, comment="主合同文档ID"),
+        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="创建时间"),
+        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="更新时间"),
+        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False, comment="逻辑删除(0未删/1已删)"),
         sa.PrimaryKeyConstraint("id"),
     )
     op.create_index("idx_contract_bundle_subject", "contract_bundle", ["subject_type", "subject_id"], unique=False)
@@ -44,23 +44,23 @@ def upgrade() -> None:
 
     op.create_table(
         "contract_document",
-        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
-        sa.Column("bundle_id", sa.BigInteger(), nullable=False),
-        sa.Column("contract_type", sa.String(length=50), nullable=False),
-        sa.Column("contract_name", sa.String(length=100), nullable=False),
-        sa.Column("is_primary", sa.Integer(), nullable=False, server_default="0"),
-        sa.Column("status", sa.Integer(), nullable=False, server_default="0"),
-        sa.Column("sign_flow_id", sa.String(length=64), nullable=False),
-        sa.Column("file_id", sa.String(length=64), nullable=False),
-        sa.Column("template_url", mysql.LONGTEXT(), nullable=False),
-        sa.Column("sign_url", mysql.LONGTEXT(), nullable=False),
-        sa.Column("download_url", mysql.LONGTEXT(), nullable=False),
-        sa.Column("signing_time", sa.DateTime(), nullable=True),
-        sa.Column("effective_time", sa.DateTime(), nullable=True),
-        sa.Column("expiry_time", sa.DateTime(), nullable=True),
-        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False, comment="合同文档唯一标识"),
+        sa.Column("bundle_id", sa.BigInteger(), nullable=False, comment="所属合同包ID"),
+        sa.Column("contract_type", sa.String(length=50), nullable=False, comment="合同类型编码(store_agreement/alipay_auth等)"),
+        sa.Column("contract_name", sa.String(length=100), nullable=False, comment="合同展示名称"),
+        sa.Column("is_primary", sa.Integer(), nullable=False, server_default="0", comment="是否主合同(1是/0否)"),
+        sa.Column("status", sa.Integer(), nullable=False, server_default="0", comment="签署状态(0未签署/1已签署)"),
+        sa.Column("sign_flow_id", sa.String(length=64), nullable=False, comment="e签宝签署流程ID"),
+        sa.Column("file_id", sa.String(length=64), nullable=False, comment="e签宝文件ID"),
+        sa.Column("template_url", mysql.LONGTEXT(), nullable=False, comment="合同模板预览URL"),
+        sa.Column("sign_url", mysql.LONGTEXT(), nullable=False, comment="签署链接URL"),
+        sa.Column("download_url", mysql.LONGTEXT(), nullable=False, comment="签署完成后的下载URL"),
+        sa.Column("signing_time", sa.DateTime(), nullable=True, comment="签署时间"),
+        sa.Column("effective_time", sa.DateTime(), nullable=True, comment="合同生效时间"),
+        sa.Column("expiry_time", sa.DateTime(), nullable=True, comment="合同到期时间"),
+        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="创建时间"),
+        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="更新时间"),
+        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False, comment="逻辑删除(0未删/1已删)"),
         sa.ForeignKeyConstraint(["bundle_id"], ["contract_bundle.id"]),
         sa.PrimaryKeyConstraint("id"),
     )
@@ -70,15 +70,15 @@ def upgrade() -> None:
 
     op.create_table(
         "contract_event",
-        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
-        sa.Column("bundle_id", sa.BigInteger(), nullable=True),
-        sa.Column("document_id", sa.BigInteger(), nullable=True),
-        sa.Column("sign_flow_id", sa.String(length=64), nullable=False),
-        sa.Column("event_type", sa.String(length=50), nullable=False),
-        sa.Column("payload_json", mysql.LONGTEXT(), nullable=False),
-        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
-        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False),
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False, comment="事件唯一标识"),
+        sa.Column("bundle_id", sa.BigInteger(), nullable=True, comment="关联合同包ID"),
+        sa.Column("document_id", sa.BigInteger(), nullable=True, comment="关联合同文档ID"),
+        sa.Column("sign_flow_id", sa.String(length=64), nullable=False, comment="e签宝签署流程ID"),
+        sa.Column("event_type", sa.String(length=50), nullable=False, comment="事件类型(esign_callback:{action})"),
+        sa.Column("payload_json", mysql.LONGTEXT(), nullable=False, comment="e签宝回调原始JSON报文"),
+        sa.Column("created_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="创建时间"),
+        sa.Column("updated_time", sa.DateTime(), server_default=sa.text("now()"), nullable=False, comment="更新时间"),
+        sa.Column("delete_flag", sa.Integer(), server_default="0", nullable=False, comment="逻辑删除(0未删/1已删)"),
         sa.PrimaryKeyConstraint("id"),
     )
     op.create_index("idx_contract_event_sign_flow_id", "contract_event", ["sign_flow_id"], unique=False)

+ 86 - 0
alembic/versions/d8a7a6a4581c_add_column_comments_to_contract_tables.py

@@ -0,0 +1,86 @@
+"""add column comments to contract tables
+
+Revision ID: d8a7a6a4581c
+Revises: a7d4b3e21c10
+Create Date: 2026-04-13 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "d8a7a6a4581c"
+down_revision: Union[str, Sequence[str], None] = "a7d4b3e21c10"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+BUNDLE_COLUMNS = [
+    ("id",                   "BIGINT NOT NULL AUTO_INCREMENT", "合同包唯一标识"),
+    ("subject_type",         "VARCHAR(20) NOT NULL",           "签约主体类型(store/lawyer)"),
+    ("subject_id",           "BIGINT NOT NULL",                "签约主体ID,关联门店/律师业务主键"),
+    ("subject_name",         "VARCHAR(120) NOT NULL",          "签约主体名称"),
+    ("business_segment",     "VARCHAR(100) NOT NULL",          "业务板块"),
+    ("contact_name",         "VARCHAR(100) NOT NULL",          "联系人姓名"),
+    ("contact_phone",        "VARCHAR(20) NOT NULL",           "联系人手机号"),
+    ("ord_id",               "VARCHAR(40) NOT NULL",           "组织标识(统一社会信用代码)"),
+    ("bundle_type",          "VARCHAR(50) NOT NULL",           "合同包类型(STORE_STANDARD/LAWYER_STANDARD)"),
+    ("status",               "VARCHAR(20) NOT NULL DEFAULT '未签署'", "整体状态: 未签署/审核中/已签署"),
+    ("primary_document_id",  "BIGINT NULL",                    "主合同文档ID"),
+    ("created_time",         "DATETIME NOT NULL DEFAULT NOW()", "创建时间"),
+    ("updated_time",         "DATETIME NOT NULL DEFAULT NOW()", "更新时间"),
+    ("delete_flag",          "INT NOT NULL DEFAULT 0",         "逻辑删除(0未删/1已删)"),
+]
+
+DOCUMENT_COLUMNS = [
+    ("id",              "BIGINT NOT NULL AUTO_INCREMENT",  "合同文档唯一标识"),
+    ("bundle_id",       "BIGINT NOT NULL",                 "所属合同包ID"),
+    ("contract_type",   "VARCHAR(50) NOT NULL",            "合同类型编码(store_agreement/alipay_auth等)"),
+    ("contract_name",   "VARCHAR(100) NOT NULL",           "合同展示名称"),
+    ("is_primary",      "INT NOT NULL DEFAULT 0",          "是否主合同(1是/0否)"),
+    ("status",          "INT NOT NULL DEFAULT 0",          "签署状态(0未签署/1已签署)"),
+    ("sign_flow_id",    "VARCHAR(64) NOT NULL",            "e签宝签署流程ID"),
+    ("file_id",         "VARCHAR(64) NOT NULL",            "e签宝文件ID"),
+    ("template_url",    "LONGTEXT NOT NULL",               "合同模板预览URL"),
+    ("sign_url",        "LONGTEXT NOT NULL",               "签署链接URL"),
+    ("download_url",    "LONGTEXT NOT NULL",               "签署完成后的下载URL"),
+    ("signing_time",    "DATETIME NULL",                   "签署时间"),
+    ("effective_time",  "DATETIME NULL",                   "合同生效时间"),
+    ("expiry_time",     "DATETIME NULL",                   "合同到期时间"),
+    ("created_time",    "DATETIME NOT NULL DEFAULT NOW()", "创建时间"),
+    ("updated_time",    "DATETIME NOT NULL DEFAULT NOW()", "更新时间"),
+    ("delete_flag",     "INT NOT NULL DEFAULT 0",          "逻辑删除(0未删/1已删)"),
+]
+
+EVENT_COLUMNS = [
+    ("id",            "BIGINT NOT NULL AUTO_INCREMENT",  "事件唯一标识"),
+    ("bundle_id",     "BIGINT NULL",                     "关联合同包ID"),
+    ("document_id",   "BIGINT NULL",                     "关联合同文档ID"),
+    ("sign_flow_id",  "VARCHAR(64) NOT NULL",            "e签宝签署流程ID"),
+    ("event_type",    "VARCHAR(50) NOT NULL",            "事件类型(esign_callback:{action})"),
+    ("payload_json",  "LONGTEXT NOT NULL",               "e签宝回调原始JSON报文"),
+    ("created_time",  "DATETIME NOT NULL DEFAULT NOW()", "创建时间"),
+    ("updated_time",  "DATETIME NOT NULL DEFAULT NOW()", "更新时间"),
+    ("delete_flag",   "INT NOT NULL DEFAULT 0",          "逻辑删除(0未删/1已删)"),
+]
+
+
+def _alter_comments(table: str, columns: list[tuple[str, str, str]]) -> None:
+    for col, definition, comment in columns:
+        op.execute(f"ALTER TABLE `{table}` MODIFY COLUMN `{col}` {definition} COMMENT '{comment}'")
+
+
+def _remove_comments(table: str, columns: list[tuple[str, str, str]]) -> None:
+    for col, definition, _ in columns:
+        op.execute(f"ALTER TABLE `{table}` MODIFY COLUMN `{col}` {definition}")
+
+
+def upgrade() -> None:
+    _alter_comments("contract_bundle", BUNDLE_COLUMNS)
+    _alter_comments("contract_document", DOCUMENT_COLUMNS)
+    _alter_comments("contract_event", EVENT_COLUMNS)
+
+
+def downgrade() -> None:
+    _remove_comments("contract_event", EVENT_COLUMNS)
+    _remove_comments("contract_document", DOCUMENT_COLUMNS)
+    _remove_comments("contract_bundle", BUNDLE_COLUMNS)

+ 8 - 2
alien_contract/Dockerfile

@@ -17,6 +17,12 @@ RUN poetry source add --priority=primary tsinghua https://pypi.tuna.tsinghua.edu
 
 COPY . .
 
-EXPOSE 8002
+ARG APP_ENV=dev
+ENV APP_ENV=${APP_ENV}
 
-CMD ["uvicorn", "alien_contract.main:app", "--host", "0.0.0.0", "--port", "8002"]
+ARG CONTRACT_PORT=8002
+ENV CONTRACT_PORT=${CONTRACT_PORT}
+
+EXPOSE ${CONTRACT_PORT}
+
+CMD ["sh", "-c", "uvicorn alien_contract.main:app --host 0.0.0.0 --port ${CONTRACT_PORT}"]

+ 1 - 0
alien_contract/README.md

@@ -0,0 +1 @@
+e签宝功能

+ 8 - 3
alien_contract/api/router.py

@@ -1,4 +1,4 @@
-from typing import Union
+from typing import Optional, Union, Literal
 
 from fastapi import APIRouter, Depends, Query
 
@@ -26,6 +26,7 @@ async def create_bundle(
     request: BundleCreateRequest,
     service: ContractCenterService = Depends(get_contract_service),
 ) -> Union[BundleCreateResponse, ErrorResponse]:
+    """创建合同包"""
     result = await service.create_bundle(request)
     if not result.get("success"):
         return ErrorResponse(**result)
@@ -34,13 +35,15 @@ async def create_bundle(
 
 @router.get("/bundles", response_model=PaginatedBundleResponse)
 async def list_bundles(
-    subject_type: str = Query(...),
+    subject_type: Literal["store", "lawyer"] = Query(...),
     subject_id: int = Query(..., gt=0),
+    status: Optional[int] = Query(None, ge=0, le=1, description="文档签署状态过滤: 0=未签署, 1=已签署"),
     page: int = Query(1, ge=1),
     page_size: int = Query(10, ge=1, le=100),
     service: ContractCenterService = Depends(get_contract_service),
 ) -> PaginatedBundleResponse:
-    result = await service.list_bundles(subject_type, subject_id, page, page_size)
+    """"查询合同列表"""
+    result = await service.list_bundles(subject_type, subject_id, page, page_size, doc_status=status)
     return PaginatedBundleResponse(**result)
 
 
@@ -49,6 +52,7 @@ async def get_document_detail(
     sign_flow_id: str,
     service: ContractCenterService = Depends(get_contract_service),
 ) -> Union[dict, ErrorResponse]:
+    """查询合同详情"""
     result = await service.get_document_detail(sign_flow_id)
     if not result.get("success", True):
         return ErrorResponse(**result)
@@ -60,6 +64,7 @@ async def esign_callback(
     payload: dict,
     service: ContractCenterService = Depends(get_contract_service),
 ) -> Union[SuccessResponse, ErrorResponse]:
+    """e签宝回调"""
     result = await service.process_esign_callback(payload)
     if not result.get("success"):
         return ErrorResponse(**result)

+ 12 - 11
alien_contract/db/models/bundle.py

@@ -5,16 +5,17 @@ from alien_database.base import Base, AuditMixin
 
 
 class ContractBundle(Base, AuditMixin):
+    """合同包"""
     __tablename__ = "contract_bundle"
 
-    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
-    subject_type: Mapped[str] = mapped_column(String(20))
-    subject_id: Mapped[int] = mapped_column(BigInteger)
-    subject_name: Mapped[str] = mapped_column(String(120))
-    business_segment: Mapped[str] = mapped_column(String(100))
-    contact_name: Mapped[str] = mapped_column(String(100))
-    contact_phone: Mapped[str] = mapped_column(String(20))
-    ord_id: Mapped[str] = mapped_column(String(40))
-    bundle_type: Mapped[str] = mapped_column(String(50))
-    status: Mapped[str] = mapped_column(String(20), default="pending")
-    primary_document_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True, comment="合同包唯一标识")
+    subject_type: Mapped[str] = mapped_column(String(20), comment="签约主体类型(store/lawyer)")
+    subject_id: Mapped[int] = mapped_column(BigInteger, comment="签约主体ID,关联门店/律师业务主键")
+    subject_name: Mapped[str] = mapped_column(String(120), comment="签约主体名称")
+    business_segment: Mapped[str] = mapped_column(String(100), comment="业务板块")
+    contact_name: Mapped[str] = mapped_column(String(100), comment="联系人姓名")
+    contact_phone: Mapped[str] = mapped_column(String(20), comment="联系人手机号")
+    ord_id: Mapped[str] = mapped_column(String(40), comment="组织标识(统一社会信用代码)")
+    bundle_type: Mapped[str] = mapped_column(String(50), comment="合同包类型(STORE_STANDARD/LAWYER_STANDARD)")
+    status: Mapped[str] = mapped_column(String(20), default="未签署", comment="整体状态: 未签署/审核中/已签署")
+    primary_document_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="主合同文档ID")

+ 15 - 14
alien_contract/db/models/document.py

@@ -8,19 +8,20 @@ from alien_database.base import Base, AuditMixin
 
 
 class ContractDocument(Base, AuditMixin):
+    """合同文档"""
     __tablename__ = "contract_document"
 
-    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
-    bundle_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("contract_bundle.id"), index=True)
-    contract_type: Mapped[str] = mapped_column(String(50))
-    contract_name: Mapped[str] = mapped_column(String(100))
-    is_primary: Mapped[int] = mapped_column(Integer, default=0)
-    status: Mapped[int] = mapped_column(Integer, default=0)
-    sign_flow_id: Mapped[str] = mapped_column(String(64), unique=True)
-    file_id: Mapped[str] = mapped_column(String(64))
-    template_url: Mapped[str] = mapped_column(LONGTEXT)
-    sign_url: Mapped[str] = mapped_column(LONGTEXT)
-    download_url: Mapped[str] = mapped_column(LONGTEXT)
-    signing_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    effective_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    expiry_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True, comment="合同文档唯一标识")
+    bundle_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("contract_bundle.id"), index=True, comment="所属合同包ID")
+    contract_type: Mapped[str] = mapped_column(String(50), comment="合同类型编码(store_agreement/alipay_auth等)")
+    contract_name: Mapped[str] = mapped_column(String(100), comment="合同展示名称")
+    is_primary: Mapped[int] = mapped_column(Integer, default=0, comment="是否主合同(1是/0否)")
+    status: Mapped[int] = mapped_column(Integer, default=0, comment="签署状态(0未签署/1已签署)")
+    sign_flow_id: Mapped[str] = mapped_column(String(64), unique=True, comment="e签宝签署流程ID")
+    file_id: Mapped[str] = mapped_column(String(64), comment="e签宝文件ID")
+    template_url: Mapped[str] = mapped_column(LONGTEXT, comment="合同模板预览URL")
+    sign_url: Mapped[str] = mapped_column(LONGTEXT, comment="签署链接URL")
+    download_url: Mapped[str] = mapped_column(LONGTEXT, comment="签署完成后的下载URL")
+    signing_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="签署时间")
+    effective_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="合同生效时间")
+    expiry_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="合同到期时间")

+ 7 - 6
alien_contract/db/models/event.py

@@ -6,11 +6,12 @@ from alien_database.base import Base, AuditMixin
 
 
 class ContractEvent(Base, AuditMixin):
+    """合同事件"""
     __tablename__ = "contract_event"
 
-    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
-    bundle_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
-    document_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
-    sign_flow_id: Mapped[str] = mapped_column(String(64), index=True)
-    event_type: Mapped[str] = mapped_column(String(50))
-    payload_json: Mapped[str] = mapped_column(LONGTEXT)
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True, comment="事件唯一标识")
+    bundle_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="关联合同包ID")
+    document_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="关联合同文档ID")
+    sign_flow_id: Mapped[str] = mapped_column(String(64), index=True, comment="e签宝签署流程ID")
+    event_type: Mapped[str] = mapped_column(String(50), comment="事件类型(esign_callback:{action})")
+    payload_json: Mapped[str] = mapped_column(LONGTEXT, comment="e签宝回调原始JSON报文")

+ 1 - 1
alien_contract/main.py

@@ -19,4 +19,4 @@ async def health():
 if __name__ == "__main__":
     import uvicorn
 
-    uvicorn.run(app, host="0.0.0.0", port=8002)
+    uvicorn.run(app, host="0.0.0.0", port=settings.CONTRACT_PORT)

+ 12 - 6
alien_contract/repositories/contract_repo.py

@@ -68,10 +68,13 @@ class ContractRepository:
         bundles = result.scalars().all()
         return bundles, total
 
-    async def list_documents_by_bundle_ids(self, bundle_ids: list[int]) -> dict[int, list[ContractDocument]]:
+    async def list_documents_by_bundle_ids(self, bundle_ids: list[int], *, doc_status: int | None = None) -> dict[int, list[ContractDocument]]:
         if not bundle_ids:
             return {}
-        stmt = select(ContractDocument).where(ContractDocument.bundle_id.in_(bundle_ids), ContractDocument.delete_flag == 0)
+        conditions = [ContractDocument.bundle_id.in_(bundle_ids), ContractDocument.delete_flag == 0]
+        if doc_status is not None:
+            conditions.append(ContractDocument.status == doc_status)
+        stmt = select(ContractDocument).where(*conditions)
         result = await self.db.execute(stmt)
         documents = result.scalars().all()
         grouped: dict[int, list[ContractDocument]] = {}
@@ -120,13 +123,13 @@ class ContractRepository:
         result = await self.db.execute(stmt)
         statuses = [row[0] for row in result.fetchall()]
         if not statuses:
-            status = "pending"
+            status = "未签署"
         elif all(s == 1 for s in statuses):
-            status = "all_signed"
+            status = "已签署"
         elif any(s == 1 for s in statuses):
-            status = "partially_signed"
+            status = "审核中"
         else:
-            status = "pending"
+            status = "未签署"
         update_stmt = ContractBundle.__table__.update().where(ContractBundle.id == bundle_id).values(status=status)
         await self.db.execute(update_stmt)
         return status
@@ -143,3 +146,6 @@ class ContractRepository:
 
     async def commit(self):
         await self.db.commit()
+
+    async def rollback(self):
+        await self.db.rollback()

+ 0 - 8
alien_contract/schemas/request/contract.py

@@ -12,7 +12,6 @@ class BundleCreateRequest(BaseModel):
     contact_name: str = Field(description="联系人姓名")
     contact_phone: str = Field(description="联系电话")
     ord_id: str = Field(description="统一社会信用代码")
-    bundle_type: str | None = Field(default=None, description="合同包类型")  # LAWYER_STANDARD/STORE_STANDARD
 
     @field_validator("contact_phone")
     @classmethod
@@ -20,10 +19,3 @@ class BundleCreateRequest(BaseModel):
         if not re.fullmatch(r"^1\d{10}$", value):
             raise ValueError("contact_phone 格式错误,应为11位手机号")
         return value
-
-    @field_validator("ord_id")
-    @classmethod
-    def validate_ord_id(cls, value: str) -> str:
-        if not re.fullmatch(r"^[0-9A-Z]{18}$", value):
-            raise ValueError("ord_id 格式错误,应为18位大写字母或数字")
-        return value

+ 43 - 43
alien_contract/schemas/response/contract.py

@@ -1,67 +1,67 @@
 from datetime import datetime
 from typing import Optional, List, Any
 
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 
 
 class ContractDocumentResponse(BaseModel):
-    id: int
-    contract_type: str
-    contract_name: str
-    is_primary: int
-    status: int
-    sign_flow_id: str
-    file_id: str
-    template_url: str
-    sign_url: str
-    download_url: str
-    signing_time: Optional[datetime] = None
-    effective_time: Optional[datetime] = None
-    expiry_time: Optional[datetime] = None
+    id: int = Field(description="合同文档ID")
+    contract_type: str = Field(description="合同类型编码")
+    contract_name: str = Field(description="合同名称")
+    is_primary: int = Field(description="是否主合同,1是0否")
+    status: int = Field(description="签署状态,0未签1已签")
+    sign_flow_id: str = Field(description="e签宝签署流程ID")
+    file_id: str = Field(description="e签宝文件ID")
+    template_url: str = Field(description="合同模板文件链接")
+    sign_url: str = Field(description="签署链接")
+    download_url: str = Field(description="已签文件下载链接")
+    signing_time: Optional[datetime] = Field(default=None, description="签署时间")
+    effective_time: Optional[datetime] = Field(default=None, description="生效时间")
+    expiry_time: Optional[datetime] = Field(default=None, description="到期时间")
 
 
 class ContractBundleResponse(BaseModel):
-    id: int
-    subject_type: str
-    subject_id: int
-    subject_name: str
-    business_segment: str
-    contact_name: str
-    contact_phone: str
-    ord_id: str
-    bundle_type: str
-    status: str
-    primary_document_id: Optional[int] = None
-    documents: List[ContractDocumentResponse]
+    id: int = Field(description="合同包ID")
+    subject_type: str = Field(description="主体类型,store或lawyer")
+    subject_id: int = Field(description="主体ID")
+    subject_name: str = Field(description="主体名称")
+    business_segment: str = Field(description="业务板块")
+    contact_name: str = Field(description="联系人姓名")
+    contact_phone: str = Field(description="联系人手机号")
+    ord_id: str = Field(description="统一社会信用代码")
+    bundle_type: str = Field(description="合同包类型")
+    status: str = Field(description="合同包状态")
+    primary_document_id: Optional[int] = Field(default=None, description="主合同文档ID")
+    documents: List[ContractDocumentResponse] = Field(description="合同文档列表")
 
 
 class BundleCreateResponse(BaseModel):
-    success: bool
-    message: str
-    bundle_id: Optional[int] = None
-    primary_sign_flow_id: Optional[str] = None
-    created_contracts: Optional[List[dict]] = None
+    success: bool = Field(description="是否成功")
+    message: str = Field(description="响应消息")
+    bundle_id: Optional[int] = Field(default=None, description="合同包ID")
+    primary_sign_flow_id: Optional[str] = Field(default=None, description="主合同签署流程ID")
+    created_contracts: Optional[List[dict]] = Field(default=None, description="本次创建的合同列表")
 
 
 class ErrorResponse(BaseModel):
-    success: bool = False
-    message: str
-    raw: Optional[Any] = None
+    success: bool = Field(default=False, description="是否成功")
+    message: str = Field(description="错误消息")
+    raw: Optional[Any] = Field(default=None, description="原始错误数据")
 
 
 class SuccessResponse(BaseModel):
-    code: str
-    msg: str
+    code: str = Field(description="响应代码")
+    msg: str = Field(description="响应消息")
 
 
 class PaginatedBundleResponse(BaseModel):
-    items: List[ContractBundleResponse]
-    total: int
-    page: int
-    page_size: int
-    total_pages: int
+    items: List[ContractBundleResponse] = Field(description="合同包列表")
+    total: int = Field(description="总记录数")
+    page: int = Field(description="当前页码")
+    page_size: int = Field(description="每页条数")
+    total_pages: int = Field(description="总页数")
 
 
 class ModuleStatusResponse(BaseModel):
-    module: str
-    status: str
+    module: str = Field(description="模块名称")
+    status: str = Field(description="模块状态")

+ 95 - 6
alien_contract/services/contract_server.py

@@ -34,6 +34,17 @@ def _init_logger():
 
 logger = _init_logger()
 
+SIGN_SUCCESS_ACTIONS = {
+    "OPERATOR_COMPLETE_SIGN",
+    "SIGN_FLOW_FINISH",
+    "SIGN_FLOW_COMPLETE",
+}
+
+NON_SIGNING_ACTIONS = {
+    "OPERATOR_READ",
+    "OPERATOR_VIEW",
+}
+
 BUNDLE_CONFIGS = {
     "STORE_STANDARD": [
         ("store_agreement", "店铺入驻协议", 1),
@@ -58,7 +69,7 @@ class ContractCenterService:
         self.repo = ContractRepository(db)
 
     async def create_bundle(self, req: BundleCreateRequest) -> dict:
-        bundle_type = req.bundle_type or DEFAULT_BUNDLE_BY_SUBJECT[req.subject_type]
+        bundle_type = DEFAULT_BUNDLE_BY_SUBJECT[req.subject_type]
         configs = BUNDLE_CONFIGS.get(bundle_type)
         if not configs:
             return {"success": False, "message": "不支持的合同包类型", "raw": {"bundle_type": bundle_type}}
@@ -84,10 +95,32 @@ class ContractCenterService:
                 "contact_phone": req.contact_phone,
                 "ord_id": req.ord_id,
                 "bundle_type": bundle_type,
-                "status": "pending",
+                "status": "未签署",
             }
         )
         documents = await self.repo.create_documents(bundle.id, items)
+        for document in documents:
+            try:
+                sign_resp = sign_url(document.sign_flow_id, req.contact_phone)
+                sign_json = json.loads(sign_resp)
+                sign_data = sign_json.get("data") if isinstance(sign_json, dict) else None
+                result_sign_url = sign_data.get("url") if isinstance(sign_data, dict) else None
+            except Exception:
+                await self.repo.rollback()
+                return {
+                    "success": False,
+                    "message": f"{document.contract_name}创建成功但签署链接获取失败",
+                    "raw": {"contract_type": document.contract_type, "sign_flow_id": document.sign_flow_id},
+                }
+            if not result_sign_url:
+                await self.repo.rollback()
+                return {
+                    "success": False,
+                    "message": f"{document.contract_name}创建成功但签署链接缺失",
+                    "raw": {"contract_type": document.contract_type, "sign_flow_id": document.sign_flow_id, "resp": sign_json},
+                }
+            await self.repo.update_document_urls(document.id, sign_url=result_sign_url)
+            document.sign_url = result_sign_url
         primary_doc = next((doc for doc in documents if doc.is_primary == 1), documents[0])
         await self.repo.set_primary_document(bundle.id, primary_doc.id)
         await self.repo.commit()
@@ -103,15 +136,16 @@ class ContractCenterService:
                     "sign_flow_id": d.sign_flow_id,
                     "file_id": d.file_id,
                     "contract_url": d.template_url,
+                    "sign_url": d.sign_url,
                 }
                 for d in documents
             ],
         }
 
-    async def list_bundles(self, subject_type: str, subject_id: int, page: int, page_size: int) -> dict:
+    async def list_bundles(self, subject_type: str, subject_id: int, page: int, page_size: int, *, doc_status: int | None = None) -> dict:
         bundles, total = await self.repo.list_bundles(subject_type, subject_id, page, page_size)
         ids = [b.id for b in bundles]
-        docs_map = await self.repo.list_documents_by_bundle_ids(ids)
+        docs_map = await self.repo.list_documents_by_bundle_ids(ids, doc_status=doc_status)
         items = []
         for b in bundles:
             docs = docs_map.get(b.id, [])
@@ -211,16 +245,69 @@ class ContractCenterService:
     async def process_esign_callback(self, payload: dict) -> dict:
         sign_result = payload.get("signResult")
         sign_flow_id = payload.get("signFlowId")
+        action = payload.get("action")
+        operator_mobile = (
+            payload.get("operator", {})
+            .get("psnAccount", {})
+            .get("accountMobile")
+        )
         if not sign_flow_id:
+            logger.info(
+                "esign_callback_event %s",
+                json.dumps(
+                    {
+                        "result": "ignored",
+                        "reason": "missing_signFlowId",
+                        "action": action,
+                        "sign_result": sign_result,
+                        "operator_mobile": operator_mobile,
+                    },
+                    ensure_ascii=False,
+                ),
+            )
             return {"success": True, "code": "200", "msg": "ignored_missing_signFlowId"}
 
         document, bundle = await self.repo.get_document_and_bundle(sign_flow_id)
         if not document:
+            logger.info(
+                "esign_callback_event %s",
+                json.dumps(
+                    {
+                        "result": "ignored",
+                        "reason": "unknown_signFlowId",
+                        "sign_flow_id": sign_flow_id,
+                        "action": action,
+                        "sign_result": sign_result,
+                        "operator_mobile": operator_mobile,
+                    },
+                    ensure_ascii=False,
+                ),
+            )
             return {"success": True, "code": "200", "msg": "ignored_unknown_signFlowId"}
 
-        await self.repo.create_event(bundle.id, document.id, sign_flow_id, "esign_callback", payload)
+        event_type = f"esign_callback:{action or 'UNKNOWN'}"
+        await self.repo.create_event(bundle.id, document.id, sign_flow_id, event_type[:50], payload)
+
+        mark_signed = bool(sign_result == 2 or (action in SIGN_SUCCESS_ACTIONS and action not in NON_SIGNING_ACTIONS))
+
+        logger.info(
+            "esign_callback_event %s",
+            json.dumps(
+                {
+                    "result": "mark_signed" if mark_signed else "ignored",
+                    "sign_flow_id": sign_flow_id,
+                    "bundle_id": bundle.id,
+                    "document_id": document.id,
+                    "contract_type": document.contract_type,
+                    "action": action,
+                    "sign_result": sign_result,
+                    "operator_mobile": operator_mobile,
+                },
+                ensure_ascii=False,
+            ),
+        )
 
-        if sign_result == 2:
+        if mark_signed:
             ts_ms = payload.get("operateTime") or payload.get("timestamp")
             signing_dt = None
             if ts_ms:
@@ -245,4 +332,6 @@ class ContractCenterService:
             return {"success": True, "code": "200", "msg": "success"}
 
         await self.repo.commit()
+        if action:
+            return {"success": True, "code": "200", "msg": f"ignored_action_{action}"}
         return {"success": True, "code": "200", "msg": f"ignored_signResult_{sign_result}"}

+ 9 - 2
alien_gateway/Dockerfile

@@ -17,6 +17,13 @@ RUN poetry source add --priority=primary tsinghua https://pypi.tuna.tsinghua.edu
 
 COPY . .
 
-EXPOSE 43333
+# 默认 dev 环境,部署时通过 -e APP_ENV=sit/uat 覆盖
+ARG APP_ENV=dev
+ENV APP_ENV=${APP_ENV}
 
-CMD ["uvicorn", "alien_gateway.main:app", "--host", "0.0.0.0", "--port", "43333"]
+ARG GATEWAY_PORT=33333
+ENV GATEWAY_PORT=${GATEWAY_PORT}
+
+EXPOSE ${GATEWAY_PORT}
+
+CMD ["sh", "-c", "uvicorn alien_gateway.main:app --host 0.0.0.0 --port ${GATEWAY_PORT}"]

+ 60 - 32
alien_gateway/config.py

@@ -1,32 +1,39 @@
 from pydantic_settings import BaseSettings, SettingsConfigDict
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional, Tuple
 from urllib.parse import quote
 import os
 
-# 通过环境变量 APP_ENV 选择 .env 文件,默认为 uat
+# 通过环境变量 APP_ENV 选择 .env 文件,默认 dev:
 # - 本地开发:APP_ENV=dev  -> .env.dev
-# - 测试环境:APP_ENV=uat  -> .env.uat(默认)
-_ENV_FILE = f".env.{os.getenv('APP_ENV', 'uat')}"
+# - 测试环境:APP_ENV=sit  -> .env.sit
+# - UAT环境:APP_ENV=uat   -> .env.uat
+APP_ENV = os.getenv("APP_ENV", "dev")
+_ENV_FILE = f".env.{APP_ENV}"
+
 
 class Settings(BaseSettings):
     # 基础配置
     PROJECT_NAME: str = "Alien Cloud Python"
     API_V1_STR: str = "/api/v1"
-    
-    # 鉴权配置 (原 alien-gateway 职责)
-    SECRET_KEY: str = os.getenv("SECRET_KEY")
-    ALGORITHM: str = os.getenv("ALGORITHM")
-    ACCESS_TOKEN_EXPIRE_MINUTES: int = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES")  # 7天
-    
+    APP_ENV: str = APP_ENV
+
+    # 鉴权配置(原 alien-gateway 职责)
+    SECRET_KEY: str = os.getenv("SECRET_KEY", "")
+    ALGORITHM: str = os.getenv("ALGORITHM", "HS256")
+    ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080"))
+
     # 数据库配置
-    DB_USER: str = os.getenv("DB_USER")
-    DB_PASSWORD: str = os.getenv("DB_PASSWORD")
-    DB_HOST: str = os.getenv("DB_HOST")
-    DB_PORT: int = os.getenv("DB_PORT")
-    DB_NAME: str = os.getenv("DB_NAME")
-
-    # Redis Sentinel 高可用配置
-    # 例: REDIS_SENTINELS=192.168.2.251:36379,192.168.2.252:36379,192.168.2.253:36379
+    DB_USER: str = os.getenv("DB_USER", "")
+    DB_PASSWORD: str = os.getenv("DB_PASSWORD", "")
+    DB_HOST: str = os.getenv("DB_HOST", "")
+    DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
+    DB_NAME: str = os.getenv("DB_NAME", "")
+
+    # Redis 单机配置(与 Sentinel 二选一;REDIS_URL 非空时走单机模式)
+    REDIS_URL: str = os.getenv("REDIS_URL", "")
+
+    # Redis Sentinel 高可用配置(REDIS_SENTINELS 非空时走哨兵模式)
+    # 例:REDIS_SENTINELS=192.168.2.251:36379,192.168.2.252:36379,192.168.2.253:36379
     REDIS_SENTINELS: str = os.getenv("REDIS_SENTINELS", "")
     REDIS_MASTER_NAME: str = os.getenv("REDIS_MASTER_NAME", "mymaster")
     REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
@@ -35,25 +42,35 @@ class Settings(BaseSettings):
     REDIS_SENTINEL_PASSWORD: str = os.getenv("REDIS_SENTINEL_PASSWORD", "")
     REDIS_SOCKET_TIMEOUT: float = float(os.getenv("REDIS_SOCKET_TIMEOUT", "0.5"))
     REDIS_CONNECT_TIMEOUT: float = float(os.getenv("REDIS_CONNECT_TIMEOUT", "1.0"))
-    # 下游服务地址
-    STORE_BASE_URL: str = os.getenv("STORE_BASE_URL")  # alien_store 服务地址
-    CONTRACT_BASE_URL: str = os.getenv("CONTRACT_BASE_URL")  # alien_contract 服务地址
 
-    # 阿里云短信配置
-    ALIYUN_SMS_SIGN_NAME_CONTRACT: str = os.getenv("ALIYUN_SMS_SIGN_NAME_CONTRACT")
-    ALIYUN_SMS_TEMPLATE_CODE_CONTRACT: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE_CONTRACT")
-    ALIYUN_ACCESS_KEY_ID: str = os.getenv("ALIYUN_ACCESS_KEY_ID")
-    ALIYUN_ACCESS_KEY_SECRET: str = os.getenv("ALIYUN_ACCESS_KEY_SECRET")
+    # 各服务监听端口(容器内端口;外部映射由 Jenkinsfile / docker-compose 控制)
+    GATEWAY_PORT: int = int(os.getenv("GATEWAY_PORT", "33333"))
+    STORE_PORT: int = int(os.getenv("STORE_PORT", "8001"))
+    CONTRACT_PORT: int = int(os.getenv("CONTRACT_PORT", "8002"))
+    LAWYER_PORT: int = int(os.getenv("LAWYER_PORT", "8004"))
 
+    # 下游服务地址(网关反向代理用)
+    STORE_BASE_URL: str = os.getenv("STORE_BASE_URL", "http://127.0.0.1:8001")
+    CONTRACT_BASE_URL: str = os.getenv("CONTRACT_BASE_URL", "http://127.0.0.1:8002")
 
+    # 阿里云短信配置
+    ALIYUN_SMS_SIGN_NAME_CONTRACT: str = os.getenv("ALIYUN_SMS_SIGN_NAME_CONTRACT", "")
+    ALIYUN_SMS_TEMPLATE_CODE_CONTRACT: str = os.getenv("ALIYUN_SMS_TEMPLATE_CODE_CONTRACT", "")
+    ALIYUN_ACCESS_KEY_ID: str = os.getenv("ALIYUN_ACCESS_KEY_ID", "")
+    ALIYUN_ACCESS_KEY_SECRET: str = os.getenv("ALIYUN_ACCESS_KEY_SECRET", "")
 
     @property
     def SQLALCHEMY_DATABASE_URI(self) -> str:
         return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
 
     @property
-    def REDIS_SENTINEL_NODES(self) -> List[tuple[str, int]]:
-        nodes: List[tuple[str, int]] = []
+    def REDIS_MODE(self) -> str:
+        # standalone(单机) / sentinel(哨兵)
+        return "sentinel" if self.REDIS_SENTINELS.strip() else "standalone"
+
+    @property
+    def REDIS_SENTINEL_NODES(self) -> List[Tuple[str, int]]:
+        nodes: List[Tuple[str, int]] = []
         for item in self.REDIS_SENTINELS.split(","):
             entry = item.strip()
             if not entry:
@@ -75,7 +92,7 @@ class Settings(BaseSettings):
 
     @property
     def REDIS_SENTINEL_URL(self) -> str:
-        # 标准 Sentinel URL:
+        # 标准 Sentinel URL
         # redis+sentinel://[username[:password]@]host1:port1,host2:port2/db?sentinel_master=mymaster
         if self.REDIS_SENTINEL_USERNAME:
             user = quote(self.REDIS_SENTINEL_USERNAME, safe="")
@@ -94,9 +111,6 @@ class Settings(BaseSettings):
     def REDIS_CELERY_SENTINEL_URL(self) -> str:
         # Celery/Kombu Sentinel URL 格式:
         # sentinel://:redis_password@sentinel_host:port/db;...
-        # URL 中的 auth 密码 = Redis 主从节点密码
-        # (Kombu 通过 Sentinel 发现主节点地址后,用此密码向 Redis 主节点认证)
-        # Sentinel 自身的密码(如有)通过 broker_transport_options["sentinel_kwargs"]["password"] 传递
         auth = f":{quote(self.REDIS_PASSWORD, safe='')}@" if self.REDIS_PASSWORD else ""
         return ";".join(
             f"sentinel://{auth}{host}:{port}/{self.REDIS_DB}"
@@ -112,10 +126,24 @@ class Settings(BaseSettings):
             "db": self.REDIS_DB,
         }
 
+    @property
+    def CELERY_BROKER_URL(self) -> str:
+        # 单机优先用 REDIS_URL;哨兵则用 Celery Sentinel URL
+        return self.REDIS_CELERY_SENTINEL_URL if self.REDIS_MODE == "sentinel" else self.REDIS_URL
+
+    @property
+    def CELERY_BACKEND_URL(self) -> str:
+        return self.CELERY_BROKER_URL
+
+    @property
+    def CELERY_TRANSPORT_OPTIONS(self) -> Optional[Dict[str, Any]]:
+        return self.REDIS_SENTINEL_TRANSPORT_OPTIONS if self.REDIS_MODE == "sentinel" else None
+
     model_config = SettingsConfigDict(
         case_sensitive=True,
         env_file=_ENV_FILE,
         extra="ignore",
     )
 
+
 settings = Settings()

+ 12 - 13
alien_gateway/main.py

@@ -10,7 +10,7 @@ from alien_util.redis_client import check_redis_connection
 
 app = FastAPI(
     title=f"{settings.PROJECT_NAME} - Gateway & Auth Service",
-    version="1.0.0"
+    version="1.0.0",
 )
 
 app.add_middleware(
@@ -21,40 +21,40 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
-# ------------------- 日志配置 -------------------
 LOG_DIR = os.path.join("common", "logs", "alien_gateway")
 os.makedirs(LOG_DIR, exist_ok=True)
 
+
 def _init_logger():
     logger = logging.getLogger("alien_gateway")
     if logger.handlers:
         return logger
     logger.setLevel(logging.INFO)
     fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s %(message)s")
-    
-    # 文件日志
+
     info_handler = logging.FileHandler(os.path.join(LOG_DIR, "info.log"), encoding="utf-8")
     info_handler.setLevel(logging.INFO)
     info_handler.setFormatter(fmt)
-    
+
     error_handler = logging.FileHandler(os.path.join(LOG_DIR, "error.log"), encoding="utf-8")
     error_handler.setLevel(logging.ERROR)
     error_handler.setFormatter(fmt)
-    
-    # 控制台日志
+
     console_handler = logging.StreamHandler()
     console_handler.setFormatter(fmt)
-    
+
     logger.addHandler(info_handler)
     logger.addHandler(error_handler)
     logger.addHandler(console_handler)
     return logger
 
+
 logger = _init_logger()
 
+
 @app.get("/health")
 async def health():
-    return {"service": "alien_gateway", "status": "ok"}
+    return {"service": "alien_gateway", "status": "ok", "env": settings.APP_ENV}
 
 
 @app.get("/health/redis")
@@ -65,6 +65,7 @@ async def redis_health():
         logger.error("redis health check failed err=%s", exc)
         raise HTTPException(status_code=HTTP_502_BAD_GATEWAY, detail="Redis unavailable")
 
+
 # 此模块未来将承担 JWT 签发、权限校验中间件、路由聚合等核心功能
 @app.post("/auth/login")
 async def login():
@@ -84,7 +85,6 @@ HOP_BY_HOP_HEADERS: List[str] = [
 
 
 def _clean_headers(headers):
-    """移除 hop-by-hop 头,避免转发问题。"""
     return {k: v for k, v in headers.items() if k.lower() not in HOP_BY_HOP_HEADERS}
 
 
@@ -120,17 +120,16 @@ async def _proxy(request: Request, target_url: str, service_tag: str) -> Respons
 
 @app.api_route("/api/store/{full_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
 async def proxy_to_store(full_path: str, request: Request):
-    """监听 43333 端口,转发 /api/store/* 到 alien_store 服务。"""
     target_url = f"{settings.STORE_BASE_URL}/api/store/{full_path}"
     return await _proxy(request, target_url, "store")
 
 
 @app.api_route("/api/contract/{full_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
 async def proxy_to_contract(full_path: str, request: Request):
-    """转发 /api/contract/* 到 alien_contract 服务。"""
     target_url = f"{settings.CONTRACT_BASE_URL}/api/contract/{full_path}"
     return await _proxy(request, target_url, "contract")
 
+
 if __name__ == "__main__":
     import uvicorn
-    uvicorn.run(app, host="0.0.0.0", port=43333)
+    uvicorn.run(app, host="0.0.0.0", port=settings.GATEWAY_PORT)

+ 0 - 1
alien_lawyer/api/router.py

@@ -56,7 +56,6 @@ async def create_esign_templates(
         contact_name=templates_data.contact_name,
         contact_phone=templates_data.contact_phone,
         ord_id=templates_data.ord_id,
-        bundle_type="LAWYER_STANDARD",
     )
     result = await templates_server.create_bundle(request)
     if not result.get("success"):

+ 1 - 1
alien_lawyer/main.py

@@ -15,4 +15,4 @@ async def health():
 
 if __name__ == "__main__":
     import uvicorn
-    uvicorn.run(app, host="0.0.0.0", port=8004)
+    uvicorn.run(app, host="0.0.0.0", port=settings.LAWYER_PORT)

+ 8 - 2
alien_store/Dockerfile

@@ -17,6 +17,12 @@ RUN poetry source add --priority=primary tsinghua https://pypi.tuna.tsinghua.edu
 
 COPY . .
 
-EXPOSE 8001
+ARG APP_ENV=dev
+ENV APP_ENV=${APP_ENV}
 
-CMD ["uvicorn", "alien_store.main:app", "--host", "0.0.0.0", "--port", "8001"]
+ARG STORE_PORT=8001
+ENV STORE_PORT=${STORE_PORT}
+
+EXPOSE ${STORE_PORT}
+
+CMD ["sh", "-c", "uvicorn alien_store.main:app --host 0.0.0.0 --port ${STORE_PORT}"]

+ 0 - 1
alien_store/api/router.py

@@ -64,7 +64,6 @@ async def create_esign_templates(
         contact_name=templates_data.merchant_name,
         contact_phone=templates_data.contact_phone,
         ord_id=templates_data.ord_id,
-        bundle_type="STORE_STANDARD",
     )
     result = await templates_server.create_bundle(request)
     if not result.get("success"):

+ 1 - 1
alien_store/main.py

@@ -51,4 +51,4 @@ async def health():
 
 if __name__ == "__main__":
     import uvicorn
-    uvicorn.run(app, host="0.0.0.0", port=8001)
+    uvicorn.run(app, host="0.0.0.0", port=settings.STORE_PORT)

+ 11 - 12
alien_util/celery_app.py

@@ -2,32 +2,31 @@ from celery import Celery
 from celery.schedules import crontab
 from alien_gateway.config import settings
 
-# 创建 Celery 应用
+# 创建 Celery 应用:broker / backend 由 settings 自动适配单机或 Sentinel
 celery_app = Celery(
     "alien_cloud",
-    broker=settings.REDIS_CELERY_SENTINEL_URL,
-    backend=settings.REDIS_CELERY_SENTINEL_URL,
-    include=["alien_util.tasks.contract_tasks"]
+    broker=settings.CELERY_BROKER_URL,
+    backend=settings.CELERY_BACKEND_URL,
+    include=["alien_util.tasks.contract_tasks"],
 )
 
-# Celery 配置
-celery_app.conf.update(
+_celery_conf = dict(
     task_serializer="json",
     accept_content=["json"],
     result_serializer="json",
     timezone="Asia/Shanghai",
     enable_utc=True,
-    broker_transport_options=settings.REDIS_SENTINEL_TRANSPORT_OPTIONS,
-    result_backend_transport_options=settings.REDIS_SENTINEL_TRANSPORT_OPTIONS,
-    # 定时任务配置
     beat_schedule={
         "check-contract-expiry": {
             "task": "alien_util.tasks.contract_tasks.check_contract_expiry",
-            "schedule": crontab(hour=0, minute=1),  # 每天凌晨0点1分执行
+            "schedule": crontab(hour=0, minute=1),  # 每天凌晨 0:01 执行
         },
     },
 )
 
+# 哨兵模式下必须传 transport_options 让 kombu 感知 master_name 等参数
+if settings.CELERY_TRANSPORT_OPTIONS is not None:
+    _celery_conf["broker_transport_options"] = settings.CELERY_TRANSPORT_OPTIONS
+    _celery_conf["result_backend_transport_options"] = settings.CELERY_TRANSPORT_OPTIONS
 
-
-
+celery_app.conf.update(**_celery_conf)

+ 78 - 16
alien_util/redis_client.py

@@ -1,7 +1,14 @@
+"""Redis 客户端工厂:根据 settings.REDIS_MODE 自动选择单机或 Sentinel。
+
+- 单机模式:REDIS_URL 非空时启用,直接 redis.from_url
+- 哨兵模式:REDIS_SENTINELS 非空时启用,通过 Sentinel 获取主从节点
+"""
 from functools import lru_cache
 
+import redis
 from redis import Redis
 from redis.asyncio import Redis as AsyncRedis
+from redis.asyncio import from_url as async_from_url
 from redis.asyncio.sentinel import Sentinel as AsyncSentinel
 from redis.sentinel import Sentinel
 
@@ -28,6 +35,13 @@ def _ensure_sentinel_nodes() -> None:
         raise RuntimeError("REDIS_SENTINELS 未配置,无法通过 Sentinel 连接 Redis。")
 
 
+def _ensure_redis_url() -> None:
+    if not settings.REDIS_URL:
+        raise RuntimeError("REDIS_URL 未配置,无法以单机模式连接 Redis。")
+
+
+# --------------------------------------------------------------- Sentinel ----
+
 @lru_cache(maxsize=1)
 def get_sentinel_client() -> Sentinel:
     _ensure_sentinel_nodes()
@@ -50,56 +64,104 @@ def get_async_sentinel_client() -> AsyncSentinel:
     )
 
 
-def get_redis_master() -> Redis:
+def _sentinel_master() -> Redis:
     return get_sentinel_client().master_for(
         service_name=settings.REDIS_MASTER_NAME,
         **_redis_connection_kwargs(),
     )
 
 
-def get_redis_slave() -> Redis:
+def _sentinel_slave() -> Redis:
     return get_sentinel_client().slave_for(
         service_name=settings.REDIS_MASTER_NAME,
         **_redis_connection_kwargs(),
     )
 
 
-def get_async_redis_master() -> AsyncRedis:
+def _async_sentinel_master() -> AsyncRedis:
     return get_async_sentinel_client().master_for(
         service_name=settings.REDIS_MASTER_NAME,
         **_redis_connection_kwargs(),
     )
 
 
-def get_async_redis_slave() -> AsyncRedis:
+def _async_sentinel_slave() -> AsyncRedis:
     return get_async_sentinel_client().slave_for(
         service_name=settings.REDIS_MASTER_NAME,
         **_redis_connection_kwargs(),
     )
 
 
+# ---------------------------------------------------------------- Standalone -
+
+@lru_cache(maxsize=1)
+def _standalone_client() -> Redis:
+    _ensure_redis_url()
+    return redis.from_url(settings.REDIS_URL, decode_responses=True)
+
+
+@lru_cache(maxsize=1)
+def _async_standalone_client() -> AsyncRedis:
+    _ensure_redis_url()
+    return async_from_url(settings.REDIS_URL, decode_responses=True)
+
+
+# ---------------------------------------------------------------- Public API -
+
+def get_redis_master() -> Redis:
+    if settings.REDIS_MODE == "sentinel":
+        return _sentinel_master()
+    return _standalone_client()
+
+
+def get_redis_slave() -> Redis:
+    if settings.REDIS_MODE == "sentinel":
+        return _sentinel_slave()
+    # 单机无主从概念,复用同一连接
+    return _standalone_client()
+
+
+def get_async_redis_master() -> AsyncRedis:
+    if settings.REDIS_MODE == "sentinel":
+        return _async_sentinel_master()
+    return _async_standalone_client()
+
+
+def get_async_redis_slave() -> AsyncRedis:
+    if settings.REDIS_MODE == "sentinel":
+        return _async_sentinel_slave()
+    return _async_standalone_client()
+
+
 def get_redis() -> Redis:
-    # 默认返回主节点客户端,统一写入入口
+    """默认入口:单机直接返回,哨兵返回主节点。"""
     return get_redis_master()
 
 
 def get_async_redis() -> AsyncRedis:
-    # 默认返回主节点客户端,统一写入入口
     return get_async_redis_master()
 
 
 def check_redis_connection() -> dict:
-    """
-    通过 Sentinel 获取当前主节点并执行 ping,返回连接状态与主节点地址。
-    """
-    sentinel = get_sentinel_client()
-    redis_client = get_redis_master()
+    """执行 ping 检测连接,并附带模式信息。"""
+    if settings.REDIS_MODE == "sentinel":
+        sentinel = get_sentinel_client()
+        redis_client = get_redis_master()
+        pong = redis_client.ping()
+        host, port = sentinel.discover_master(settings.REDIS_MASTER_NAME)
+        return {
+            "ok": bool(pong),
+            "mode": "sentinel",
+            "master_name": settings.REDIS_MASTER_NAME,
+            "master_host": host,
+            "master_port": port,
+            "db": settings.REDIS_DB,
+        }
+
+    redis_client = get_redis()
     pong = redis_client.ping()
-    host, port = sentinel.discover_master(settings.REDIS_MASTER_NAME)
     return {
         "ok": bool(pong),
-        "master_name": settings.REDIS_MASTER_NAME,
-        "master_host": host,
-        "master_port": port,
-        "db": settings.REDIS_DB,
+        "mode": "standalone",
+        "url": settings.REDIS_URL,
     }

+ 0 - 74
test.py

@@ -1,74 +0,0 @@
-import requests
-
-
-# url = "http://127.0.0.1:8001/api/store/get_esign_templates"
-# url = "http://120.26.186.130:33333/api/store/get_esign_templates"
-# """
-# 商家入驻AI审核通过后, AI携带真实有效的信息调用此接口,数据库 store_contract 生成对应的数据
-# """
-# body = {
-#     "store_id": 666,
-#     "store_name": "爱丽恩严(大连)商务科技有限公司深圳分公司",
-#     "business_segment": "生活服务",
-#     "merchant_name": "彭少荣",
-#     "contact_phone": "13923864580",
-#     "ord_id": "91440300MADDW7XC4C"
-# }
-# res = requests.post(url, json=body)
-# print(res.text)
-
-# ----------------------------------------------------------------------------------------------------------------------
-# url = "http://127.0.0.1:8001/api/store/contracts/381"
-# url = "http://120.26.186.130:33333/api/store/contracts/381?status=0"
-# """
-# 商家点击合同管理模块时,可以条件筛选查询 未签署合同和已签署合同
-# status:签署状态 0 未签署 1 已签署
-# page:分页查询 页码 默认1
-# page_size:分页查询 每页数量 默认10
-# """
-# resp = requests.get(url)
-# print(resp.text)
-
-# ----------------------------------------------------------------------------------------------------------------------
-# url = "http://127.0.0.1:8001/api/store/contracts/detail/"
-# url = "http://120.26.186.130:33333/api/store/contracts/detail/d43a18e1e9164a7d8dab4ca5c49d9960"
-# """
-# 商家点击到某个具体的合同 向这个接口发送请求
-# 由于e签宝提供的合同模板URL会过期,所以会再次携带flow_id 向e签宝发起请求刷新新的URL
-# 下载URL同理
-# """
-# resp = requests.get(url)
-# print(resp.text)
-
-# ----------------------------------------------------------------------------------------------------------------------
-# url = "http://127.0.0.1:8001/api/store/esign/signurl"
-# url = "http://120.26.186.130:33333/api/store/esign/signurl"
-# body = {
-#     "sign_flow_id": "8980608cb10c448cbdf96aa7df51179b",
-#     "contact_phone": "13923864580"
-# }
-# """
-# 当商家点击签署按钮时
-# 携带合同相关的签署id和联系方式向e签宝发起请求
-# 获取到签署的页面链接
-# 并将签署url存入该合同对应的sign_url中
-# """
-# resp = requests.post(url, json=body)
-# print(resp.text)
-
-#-----------------------------------------------------------------------------------------------------------------------
-# url = "http://127.0.0.1:8001/api/store/get_all_templates"
-# url = "http://120.26.186.130:33333/api/store/get_all_templates"
-# params = {
-#     "page": 1, # 页码 默认1
-#     "page_size": 10, # 每页条数 默认10
-#     "store": "", # 店铺名称(模糊查询)
-#     "merchant_name": "", # 商家名称(模糊查询)
-#     "signing_status": "", # 签署状态
-#     "business_segment": "", # 经营板块
-#     "store_status": "", # 店铺状态:正常/禁用
-#     "expiry_start": "", # 到期时间起
-#     "expiry_end": "" # 到期时间止
-# }
-# resp = requests.get(url)
-# print(resp.text)