ソースを参照

Merge branch 'dev' of http://8.152.195.41:3000/alien/alien_py_cloud into sit

天空之城 3 週間 前
コミット
dedee2c019
42 ファイル変更1217 行追加883 行削除
  1. 2 0
      alembic/env.py
  2. 96 0
      alembic/versions/a7d4b3e21c10_create_contract_center_tables.py
  3. 22 0
      alien_contract/Dockerfile
  4. 1 0
      alien_contract/__init__.py
  5. 1 0
      alien_contract/api/__init__.py
  6. 9 0
      alien_contract/api/deps.py
  7. 66 0
      alien_contract/api/router.py
  8. 1 0
      alien_contract/db/__init__.py
  9. 3 0
      alien_contract/db/models/__init__.py
  10. 20 0
      alien_contract/db/models/bundle.py
  11. 26 0
      alien_contract/db/models/document.py
  12. 16 0
      alien_contract/db/models/event.py
  13. 1 0
      alien_contract/infrastructure/__init__.py
  14. 0 2
      alien_contract/infrastructure/esign/__init__.py
  15. 28 4
      alien_contract/infrastructure/esign/contract_builder.py
  16. 115 0
      alien_contract/infrastructure/esign/esign_algorithm.py
  17. 26 0
      alien_contract/infrastructure/esign/esign_config.py
  18. 201 0
      alien_contract/infrastructure/esign/main.py
  19. 22 0
      alien_contract/main.py
  20. 1 0
      alien_contract/repositories/__init__.py
  21. 145 0
      alien_contract/repositories/contract_repo.py
  22. 1 0
      alien_contract/schemas/__init__.py
  23. 1 0
      alien_contract/schemas/request/__init__.py
  24. 29 0
      alien_contract/schemas/request/contract.py
  25. 1 0
      alien_contract/schemas/response/__init__.py
  26. 67 0
      alien_contract/schemas/response/contract.py
  27. 1 0
      alien_contract/services/__init__.py
  28. 248 0
      alien_contract/services/contract_server.py
  29. 5 0
      alien_lawyer/api/deps.py
  30. 23 4
      alien_lawyer/api/router.py
  31. 5 3
      alien_lawyer/services/contract_server.py
  32. 6 1
      alien_store/api/deps.py
  33. 23 5
      alien_store/api/router.py
  34. 3 3
      alien_store/services/contract_server.py
  35. 0 211
      common/esigntool/esign_algorithm.py
  36. 0 30
      common/esigntool/esign_config.py
  37. 0 9
      common/esigntool/esign_emun.py
  38. 0 40
      common/esigntool/esign_file.py
  39. 0 14
      common/esigntool/esign_templates.py
  40. 0 256
      common/esigntool/main.py
  41. 2 0
      main.py
  42. 0 301
      tests/test_esigntool.py

+ 2 - 0
alembic/env.py

@@ -8,6 +8,7 @@ from alien_gateway.config import settings
 from alien_database.base import Base
 from alien_store.db.models import contract_store
 from alien_lawyer.db.models import lawyer_contract
+from alien_contract.db.models import bundle, document, event
 
 # this is the Alembic Config object, which provides
 # access to the values within the .ini file in use.
@@ -23,6 +24,7 @@ if config.config_file_name is not None:
 # from myapp import mymodel
 # target_metadata = mymodel.Base.metadata
 target_metadata = Base.metadata
+_MODEL_IMPORTS = (contract_store, lawyer_contract, bundle, document, event)
 
 # other values from the config, defined by the needs of env.py,
 # can be acquired:

+ 96 - 0
alembic/versions/a7d4b3e21c10_create_contract_center_tables.py

@@ -0,0 +1,96 @@
+"""create contract center tables
+
+Revision ID: a7d4b3e21c10
+Revises: 984d88f91c0d
+Create Date: 2026-03-28 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+revision: str = "a7d4b3e21c10"
+down_revision: Union[str, Sequence[str], None] = "984d88f91c0d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.execute("DROP TABLE IF EXISTS contract_event")
+    op.execute("DROP TABLE IF EXISTS contract_document")
+    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.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_contract_bundle_subject", "contract_bundle", ["subject_type", "subject_id"], unique=False)
+    op.create_index("idx_contract_bundle_status", "contract_bundle", ["status"], unique=False)
+
+    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.ForeignKeyConstraint(["bundle_id"], ["contract_bundle.id"]),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_contract_document_bundle_id", "contract_document", ["bundle_id"], unique=False)
+    op.create_index("idx_contract_document_sign_flow_id", "contract_document", ["sign_flow_id"], unique=True)
+    op.create_index("idx_contract_document_bundle_type_unique", "contract_document", ["bundle_id", "contract_type"], unique=True)
+
+    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.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_contract_event_sign_flow_id", "contract_event", ["sign_flow_id"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_index("idx_contract_event_sign_flow_id", table_name="contract_event")
+    op.drop_table("contract_event")
+    op.drop_index("idx_contract_document_bundle_type_unique", table_name="contract_document")
+    op.drop_index("idx_contract_document_sign_flow_id", table_name="contract_document")
+    op.drop_index("idx_contract_document_bundle_id", table_name="contract_document")
+    op.drop_table("contract_document")
+    op.drop_index("idx_contract_bundle_status", table_name="contract_bundle")
+    op.drop_index("idx_contract_bundle_subject", table_name="contract_bundle")
+    op.drop_table("contract_bundle")

+ 22 - 0
alien_contract/Dockerfile

@@ -0,0 +1,22 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ENV POETRY_VIRTUALENVS_CREATE=false \
+    PYTHONUNBUFFERED=1 \
+    PYTHONDONTWRITEBYTECODE=1
+
+RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \
+    && pip install --no-cache-dir poetry
+
+COPY pyproject.toml poetry.lock ./
+
+RUN poetry source add --priority=primary tsinghua https://pypi.tuna.tsinghua.edu.cn/simple || true \
+  && poetry lock \
+  && poetry install --no-root --no-interaction --no-ansi
+
+COPY . .
+
+EXPOSE 8005
+
+CMD ["uvicorn", "alien_contract.main:app", "--host", "0.0.0.0", "--port", "8002"]

+ 1 - 0
alien_contract/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
alien_contract/api/__init__.py

@@ -0,0 +1 @@
+

+ 9 - 0
alien_contract/api/deps.py

@@ -0,0 +1,9 @@
+from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from alien_database.session import get_db
+from alien_contract.services.contract_server import ContractCenterService
+
+
+def get_contract_service(db: AsyncSession = Depends(get_db)) -> ContractCenterService:
+    return ContractCenterService(db)

+ 66 - 0
alien_contract/api/router.py

@@ -0,0 +1,66 @@
+from typing import Union
+
+from fastapi import APIRouter, Depends, Query
+
+from alien_contract.api.deps import get_contract_service
+from alien_contract.schemas.request.contract import BundleCreateRequest
+from alien_contract.schemas.response.contract import (
+    ModuleStatusResponse,
+    BundleCreateResponse,
+    ErrorResponse,
+    PaginatedBundleResponse,
+    SuccessResponse,
+)
+from alien_contract.services.contract_server import ContractCenterService
+
+router = APIRouter()
+
+
+@router.get("/", response_model=ModuleStatusResponse)
+async def index() -> ModuleStatusResponse:
+    return ModuleStatusResponse(module="ContractCenter", status="Ok")
+
+
+@router.post("/bundles", response_model=Union[BundleCreateResponse, ErrorResponse])
+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)
+    return BundleCreateResponse(**result)
+
+
+@router.get("/bundles", response_model=PaginatedBundleResponse)
+async def list_bundles(
+    subject_type: str = Query(...),
+    subject_id: int = Query(..., gt=0),
+    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)
+    return PaginatedBundleResponse(**result)
+
+
+@router.get("/documents/{sign_flow_id}", response_model=Union[dict, ErrorResponse])
+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)
+    return result
+
+
+@router.post("/esign/callback", response_model=Union[SuccessResponse, ErrorResponse])
+async def esign_callback(
+    payload: dict,
+    service: ContractCenterService = Depends(get_contract_service),
+) -> Union[SuccessResponse, ErrorResponse]:
+    result = await service.process_esign_callback(payload)
+    if not result.get("success"):
+        return ErrorResponse(**result)
+    return SuccessResponse(code=result["code"], msg=result["msg"])

+ 1 - 0
alien_contract/db/__init__.py

@@ -0,0 +1 @@
+

+ 3 - 0
alien_contract/db/models/__init__.py

@@ -0,0 +1,3 @@
+from .bundle import ContractBundle
+from .document import ContractDocument
+from .event import ContractEvent

+ 20 - 0
alien_contract/db/models/bundle.py

@@ -0,0 +1,20 @@
+from sqlalchemy import BigInteger, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+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)

+ 26 - 0
alien_contract/db/models/document.py

@@ -0,0 +1,26 @@
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Integer, String, ForeignKey
+from sqlalchemy.dialects.mysql import LONGTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+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)

+ 16 - 0
alien_contract/db/models/event.py

@@ -0,0 +1,16 @@
+from sqlalchemy import BigInteger, String
+from sqlalchemy.dialects.mysql import LONGTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+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)

+ 1 - 0
alien_contract/infrastructure/__init__.py

@@ -0,0 +1 @@
+

+ 0 - 2
common/esigntool/__init__.py → alien_contract/infrastructure/esign/__init__.py

@@ -1,4 +1,2 @@
 from .esign_algorithm import *
-from .esign_emun import *
-from .esign_file import *
 from .esign_config import *

+ 28 - 4
common/esigntool/contract_builder.py → alien_contract/infrastructure/esign/contract_builder.py

@@ -4,9 +4,11 @@ import re
 import urllib.parse
 from typing import Any
 
-from common.esigntool.main import fill_in_template, create_by_file
+from alien_contract.infrastructure.esign.main import fill_in_template, create_by_file
+from alien_contract.infrastructure.esign.esign_config import Config
 
 logger = logging.getLogger(__name__)
+cfg = Config()
 
 
 class ContractBuildError(Exception):
@@ -47,9 +49,17 @@ def build_contract_items(
                 file_name = f"{contract_type}.pdf"
         except Exception:
             logger.error("fill_in_template missing fileDownloadUrl contract_type=%s: %s", contract_type, res_data)
+            template_id = cfg.templates_map.get(contract_type)
+            code = res_data.get("code") if isinstance(res_data, dict) else None
+            if code == 404:
+                message = f"{contract_name}生成失败:模板不存在或无权限(template_id={template_id})"
+            elif code == 401:
+                message = f"{contract_name}生成失败:签名校验失败(template_id={template_id})"
+            else:
+                message = f"{contract_name}生成失败:e签宝返回缺少 fileDownloadUrl"
             raise ContractBuildError(
-                message=f"{contract_name}生成失败:e签宝返回缺少 fileDownloadUrl",
-                raw={"contract_type": contract_type, "resp": res_data},
+                message=message,
+                raw={"contract_type": contract_type, "template_id": template_id, "resp": res_data},
             )
 
         sign_data = create_by_file(
@@ -72,8 +82,22 @@ def build_contract_items(
 
         if not sign_json.get("data"):
             logger.error("create_by_file failed or missing data contract_type=%s: %s", contract_type, sign_json)
+            resp_code = sign_json.get("code") if isinstance(sign_json, dict) else None
+            resp_msg = sign_json.get("message") if isinstance(sign_json, dict) else None
+            if resp_code == 1437328:
+                message = f"{contract_name}发起签署失败:手机号与真实姓名不匹配,请确认联系人实名信息"
+            elif resp_code == 1435002:
+                message = f"{contract_name}发起签署失败:签署参数不合法({resp_msg})"
+            elif resp_code == 1437306:
+                message = f"{contract_name}发起签署失败:签章定位页超出文档页数,请检查模板页码配置"
+            elif resp_code == 1435011:
+                message = f"{contract_name}发起签署失败:签署文件或签署人为空,请检查签章坐标与签署方配置"
+            elif resp_msg == "EMPTY_RESPONSE":
+                message = f"{contract_name}发起签署失败:e签宝返回空响应,请检查签署参数与账号配置"
+            else:
+                message = f"{contract_name}发起签署失败"
             raise ContractBuildError(
-                message=f"{contract_name}发起签署失败",
+                message=message,
                 raw={"contract_type": contract_type, "resp": sign_json},
             )
 

+ 115 - 0
alien_contract/infrastructure/esign/esign_algorithm.py

@@ -0,0 +1,115 @@
+import base64
+import hashlib
+import hmac
+from functools import wraps
+from hashlib import sha256
+import time
+import logging
+import json
+from urllib.parse import urlparse, parse_qs, urlencode
+
+
+def doContentMd5(data):
+    obj = hashlib.md5()
+    obj.update(data.encode("utf-8"))
+    secret = obj.digest()
+    return base64.b64encode(secret).decode("utf-8")
+
+
+def appendSignDataString(http_method, content_md5, url, accept="*/*", content_type="application/json; charset=UTF-8", **kwargs):
+    sign_data_str = "{}\n{}\n{}\n{}\n".format(http_method, accept, content_md5, content_type)
+    date = kwargs.get("date")
+    headers = kwargs.get("headers")
+    if date == "" or date is None:
+        sign_data_str = "{}\n".format(sign_data_str)
+    else:
+        sign_data_str = "{}{}\n".format(sign_data_str, date)
+    if headers == "" or headers is None:
+        sign_data_str = "{}{}".format(sign_data_str, url)
+    else:
+        sign_data_str = "{}{}\n{}".format(sign_data_str, headers, url)
+    return sign_data_str
+
+
+def doSignatureBase64(message, secret):
+    key = secret.encode("utf-8")
+    message = message.encode("utf-8")
+    sign = base64.b64encode(hmac.new(key, message, digestmod=sha256).digest()).decode()
+    return sign
+
+
+def getMillisecondStamp():
+    return str(time.time_ns() // 1_000_000)
+
+
+def buildHeader(appid, content_md5, req_signature, accept="*/*", content_type="application/json; charset=UTF-8", auth_mode="Signature", **kwargs):
+    header = {
+        "X-Tsign-Open-App-Id": appid,
+        "Content-Type": content_type,
+        "X-Tsign-Open-Ca-Timestamp": getMillisecondStamp(),
+        "Accept": accept,
+        "X-Tsign-Open-Ca-Signature": req_signature,
+        "Content-MD5": content_md5,
+        "X-Tsign-Open-Auth-Mode": auth_mode,
+        "X-Tsign-Dns-App-Id": appid,
+    }
+    for key, value in kwargs.items():
+        header[key] = value
+    return header
+
+
+def buildFileUploadHeader(contentType, contentMd5):
+    return {"Content-Type": contentType, "Content-MD5": contentMd5}
+
+
+def buildSignJsonHeader(appid, secret, http_method, url, body=None, **kwargs):
+    content_md5 = ""
+    if "PUT" == http_method or "POST" == http_method:
+        body = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
+        content_md5 = doContentMd5(body)
+    message = appendSignDataString(http_method, content_md5, apiPathSort(url), **kwargs)
+    req_signature = doSignatureBase64(message, secret)
+    header = buildHeader(appid, content_md5, req_signature, **kwargs)
+    logging.debug("开始运行".center(50, "-"))
+    logging.debug("计算md5的body:{}\n生成的md5:{}\n拼接的待签字符串:{}\n签名值:{}".format(body, content_md5, message, req_signature))
+    logging.debug("结束运行".center(50, "-"))
+    return header
+
+
+def apiPathSort(api_path, is_query=False, is_url_encode=False):
+    if is_query:
+        query = api_path
+        urls = ""
+        path = ""
+    else:
+        urls = "?"
+        url = urlparse(api_path)
+        query = url.query
+        path = url.path
+    body = parse_qs(query)
+    body = {key: body[key][0] for key in sorted(body.keys())}
+    if is_url_encode:
+        body = urlencode(body)
+        urls = path + urls + body
+    else:
+        for key in body:
+            temp_str = key + "=" + body[key]
+            urls = urls + temp_str + "&"
+        urls = path + urls
+    urls = urls[:len(urls) - 1]
+    return urls
+
+
+def esign_run_print_outer(func):
+    @wraps(func)
+    def esign_run_print(*args, **kwargs):
+        print("开始运行{}".format(func.__name__).center(50, "-"))
+        start_time = time.time()
+        response = func(*args, **kwargs)
+        end_time = time.time()
+        print("运行时间:{}".format(end_time - start_time))
+        print("结束运行{}".format(func.__name__).center(50, "-"))
+        print("\n\n")
+        return response
+
+    return esign_run_print

+ 26 - 0
alien_contract/infrastructure/esign/esign_config.py

@@ -0,0 +1,26 @@
+
+import os
+
+
+class Config:
+    def __init__(self):
+        self.appId = "7439100277"
+        self.scert = "fda5f9e9652571066631f7ba938092e1"
+        self.host = "https://smlopenapi.esign.cn"
+        self.ordid = "f25ca92b44a34b2c94289e0afec2518b"
+        self.templates_id = "266369d0efd94e14a78035b881e8cb93"
+        self.templates_map = {
+            "store_agreement": "266369d0efd94e14a78035b881e8cb93",
+            "lawyer_agreement": "28f8c0337b044ca0acb6e3559f526d95",
+            "alipay_auth": "f42e72f59bb648c280ec4c3e937e0c26",
+            "wechat_pay_commitment": "18ecbd5a85cc4a0ba190a010a79bf8da",
+        }
+        self.template_file_names = {
+            "store_agreement": "U店在这-商户入驻协议",
+            "lawyer_agreement": "U店在这-律所入驻协议",
+            "alipay_auth": "U店在这-支付宝授权函",
+            "wechat_pay_commitment": "U店在这-微信支付承诺函",
+        }
+        self.callback_url = os.getenv("ESIGN_CALLBACK_URL", "http://120.26.186.130:33333/api/contract/esign/callback")
+        self.developer_callback_url = os.getenv("ESIGN_DEVELOPER_CALLBACK_URL", self.callback_url)
+        self.redirect_url = os.getenv("ESIGN_REDIRECT_URL", "https://www.esign.cn/")

+ 201 - 0
alien_contract/infrastructure/esign/main.py

@@ -0,0 +1,201 @@
+import requests
+import json
+from datetime import datetime
+from alien_contract.infrastructure.esign.esign_config import Config
+from alien_contract.infrastructure.esign.esign_algorithm import buildSignJsonHeader
+
+cfg = Config()
+
+SIGN_FLOW_TITLES = {
+    "store_agreement": "商家入驻U店平台协议签署",
+    "lawyer_agreement": "律所入驻U店平台协议签署",
+    "alipay_auth": "支付宝授权函签署",
+    "wechat_pay_commitment": "微信支付承诺函签署",
+}
+
+SIGN_POSITIONS = {
+    "store_agreement": {"alien":{"page": 7, "x": 294, "y": 668}, "org": {"page": 7, "x": 114, "y": 666}},
+    "lawyer_agreement": {"alien": {"page": 6, "x": 336, "y": 418}, "org": {"page": 6, "x": 145, "y": 418}},
+    "alipay_auth": {"alien": {"page": 1, "x": 535, "y": 555}, "org": {"page": 1, "x": 535, "y": 431}},
+    "wechat_pay_commitment": {"org": {"page": 1, "x": 535, "y": 515}},
+}
+
+TEMPLATE_COMPONENT_VALUES = {
+    "store_agreement": lambda store_name: [
+        {"componentKey": "store_name", "componentValue": store_name},
+        {"componentKey": "one_name", "componentValue": store_name},
+        {"componentKey": "date", "componentValue": datetime.now().strftime("%Y年%m月%d日")},
+        {"componentKey": "alien_name", "componentValue": "爱丽恩严(大连)商务科技有限公司"},
+    ],
+    "lawyer_agreement": lambda store_name: [
+        {"componentKey": "alien_name", "componentValue": "爱丽恩严(大连)商务科技有限公司"},
+        {"componentKey": "alien_name_2", "componentValue": "爱丽恩严(大连)商务科技有限公司"},
+        {"componentKey": "law_firm_name", "componentValue": store_name},
+        {"componentKey": "law_firm_name_2", "componentValue": store_name},
+        {"componentKey": "signing_date", "componentValue": datetime.now().strftime("%Y年%m月%d日")},
+    ],
+    "alipay_auth": lambda store_name: [],
+    "wechat_pay_commitment": lambda store_name: [],
+}
+
+
+def _request(method: str, url: str, body: dict | None = None) -> str:
+    if not url.startswith("http"):
+        url = f"{cfg.host}{url}"
+    headers = buildSignJsonHeader(cfg.appId, cfg.scert, method, url, body)
+    if body is None:
+        response = requests.request(method, url, headers=headers)
+    else:
+        payload = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
+        response = requests.request(method, url, data=payload, headers=headers)
+    if response.text:
+        return response.text
+    fallback = {"success": False, "http_status": response.status_code, "message": "EMPTY_RESPONSE", "request_id": response.headers.get("x-ts-request-id")}
+    return json.dumps(fallback, ensure_ascii=False)
+
+
+def apply_platform_seal(sign_flow_id):
+    sign_url = "/v3/sign-flow/{}/sign-fields/auto-sign".format(sign_flow_id)
+    return _request("POST", sign_url, {})
+
+
+def _build_alien_signer(file_id: str, position: dict) -> dict:
+    """构建平台方(爱丽恩严)签署人配置"""
+    return {
+        "signConfig": {"signOrder": 1},
+        "signerType": 1,
+        "signFields": [{
+            "customBizNum": "9527",
+            "fileId": file_id,
+            "normalSignFieldConfig": {
+                "autoSign": True,
+                "signFieldStyle": 1,
+                "signFieldPosition": {
+                    "positionPage": str(position["page"]),
+                    "positionX": position["x"],
+                    "positionY": position["y"]
+                }
+            }
+        }]
+    }
+
+
+def _build_org_signer(file_id: str, position: dict, signer_name: str, signer_id_num: str, psn_account: str, psn_name: str) -> dict:
+    return {
+        "signConfig": {"signOrder": 2, "forcedReadingTime": 10},
+        "signerType": 1,
+        "orgSignerInfo": {
+            "orgName": signer_name,
+            "orgInfo": {
+                "orgIDCardNum": signer_id_num,
+                "orgIDCardType": "CRED_ORG_USCC"
+            },
+            "transactorInfo": {
+                "psnAccount": psn_account,
+                "psnInfo": {"psnName": psn_name}
+            }
+        },
+        "signFields": [
+            {
+                "customBizNum": "自定义编码001",
+                "fileId": file_id,
+                "normalSignFieldConfig": {
+                    "signFieldStyle": 1,
+                    "signFieldPosition": {
+                        "positionPage": str(position["page"]),
+                        "positionX": position["x"],
+                        "positionY": position["y"],
+                    },
+                },
+            }
+        ],
+    }
+
+
+def get_auth_flow_id(org_name: str, org_id_card_num: str, legal_rep_name: str, legal_rep_id_card_num: str):
+    body = {
+        "orgAuthConfig": {
+            "orgName": org_name,
+            "orgInfo": {
+                "orgIDCardNum": org_id_card_num,
+                "legalRepName": legal_rep_name,
+                "legalRepIDCardNum": legal_rep_id_card_num,
+            },
+        },
+        "transactorUseSeal": True,
+    }
+    return _request("POST", "/v3/org-auth-url", body)
+
+
+def get_template_detail():
+    return _request("GET", f"/v3/doc-templates/{cfg.templates_id}")
+
+
+def create_by_file(
+    file_id,
+    file_name,
+    signer_name,
+    signer_id_num,
+    psn_account,
+    psn_name,
+    contract_type: str = "store_agreement",
+):
+    sign_flow_url = "/v3/sign-flow/create-by-file"
+    title = SIGN_FLOW_TITLES.get(contract_type, SIGN_FLOW_TITLES["store_agreement"])
+    positions = SIGN_POSITIONS.get(contract_type, SIGN_POSITIONS["store_agreement"])
+
+    signers = []
+    if positions.get("alien"):
+        signers.append(_build_alien_signer(file_id, positions["alien"]))
+    if positions.get("org"):
+        signers.append(_build_org_signer(file_id, positions["org"], signer_name, signer_id_num, psn_account, psn_name))
+
+
+    body = {
+        "docs": [{"fileId": file_id, "fileName": f"{file_name}.pdf"}],
+        "signFlowConfig": {
+            "signFlowTitle": title,
+            "autoFinish": True,
+            "noticeConfig": {"noticeTypes": "1,2"},
+            "notifyUrl": cfg.callback_url,
+            "noticeDeveloperUrl": cfg.developer_callback_url,
+            "redirectConfig": {"redirectUrl": cfg.redirect_url},
+        },
+        "signers": signers,
+    }
+    return _request("POST", sign_flow_url, body)
+
+
+def sign_url(sign_flow_id, account):
+    sign_url_info = "/v3/sign-flow/{}/sign-url".format(sign_flow_id)
+    body = {
+        "signFlowId": sign_flow_id,
+        "operator": {"psnAccount": account},
+        "needLogin": False,
+        "urlType": 2,
+        "clientType": "ALL",
+    }
+    return _request("POST", sign_url_info, body)
+
+
+def file_download_url(sign_flow_id):
+    sign_download = "/v3/sign-flow/{}/file-download-url".format(sign_flow_id)
+    return _request("POST", sign_download, {"urlAvailableDate": "3600"})
+
+
+def fill_in_template(store_name: str, contract_type: str = "store_agreement"):
+    template_id = cfg.templates_map.get(contract_type, cfg.templates_map["store_agreement"])
+    template_file_name = cfg.template_file_names.get(contract_type, cfg.template_file_names["store_agreement"])
+    fill_in_data = "/v3/files/create-by-doc-template"
+    component_builder = TEMPLATE_COMPONENT_VALUES.get(contract_type, TEMPLATE_COMPONENT_VALUES["store_agreement"])
+    body = {
+        "docTemplateId": template_id,
+        "fileName": template_file_name,
+        "components": component_builder(store_name),
+    }
+    return _request("POST", fill_in_data, body)
+
+
+def get_contract_detail(file_id: str):
+    detail_url = f"/v3/files/{file_id}"
+    return _request("GET", detail_url)

+ 22 - 0
alien_contract/main.py

@@ -0,0 +1,22 @@
+from fastapi import FastAPI
+
+from alien_contract.api.router import router
+from alien_gateway.config import settings
+
+app = FastAPI(
+    title=f"{settings.PROJECT_NAME} - Contract Service",
+    version="1.0.0",
+)
+
+app.include_router(router, prefix="/api/contract", tags=["Contract"])
+
+
+@app.get("/health")
+async def health():
+    return {"service": "alien_contract", "status": "ok"}
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, host="0.0.0.0", port=8005)

+ 1 - 0
alien_contract/repositories/__init__.py

@@ -0,0 +1 @@
+

+ 145 - 0
alien_contract/repositories/contract_repo.py

@@ -0,0 +1,145 @@
+import json
+from typing import Any
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from alien_contract.db.models.bundle import ContractBundle
+from alien_contract.db.models.document import ContractDocument
+from alien_contract.db.models.event import ContractEvent
+
+
+class ContractRepository:
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def create_bundle(self, data: dict[str, Any]) -> ContractBundle:
+        bundle = ContractBundle(**data)
+        self.db.add(bundle)
+        await self.db.flush()
+        return bundle
+
+    async def create_documents(self, bundle_id: int, items: list[dict[str, Any]]) -> list[ContractDocument]:
+        docs: list[ContractDocument] = []
+        for item in items:
+            doc = ContractDocument(
+                bundle_id=bundle_id,
+                contract_type=item["contract_type"],
+                contract_name=item["contract_name"],
+                is_primary=item["is_master"],
+                status=item["status"],
+                sign_flow_id=item["sign_flow_id"],
+                file_id=item["file_id"],
+                template_url=item["contract_url"],
+                sign_url=item.get("sign_url", ""),
+                download_url=item.get("contract_download_url", ""),
+            )
+            self.db.add(doc)
+            docs.append(doc)
+        await self.db.flush()
+        return docs
+
+    async def set_primary_document(self, bundle_id: int, document_id: int) -> None:
+        stmt = (
+            ContractBundle.__table__.update()
+            .where(ContractBundle.id == bundle_id)
+            .values(primary_document_id=document_id)
+        )
+        await self.db.execute(stmt)
+
+    async def list_bundles(self, subject_type: str, subject_id: int, page: int, page_size: int):
+        conditions = [
+            ContractBundle.subject_type == subject_type,
+            ContractBundle.subject_id == subject_id,
+            ContractBundle.delete_flag == 0,
+        ]
+        count_stmt = select(func.count()).select_from(ContractBundle).where(*conditions)
+        total_result = await self.db.execute(count_stmt)
+        total = total_result.scalar() or 0
+
+        stmt = (
+            select(ContractBundle)
+            .where(*conditions)
+            .order_by(ContractBundle.id.desc())
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+        )
+        result = await self.db.execute(stmt)
+        bundles = result.scalars().all()
+        return bundles, total
+
+    async def list_documents_by_bundle_ids(self, bundle_ids: list[int]) -> dict[int, list[ContractDocument]]:
+        if not bundle_ids:
+            return {}
+        stmt = select(ContractDocument).where(ContractDocument.bundle_id.in_(bundle_ids), ContractDocument.delete_flag == 0)
+        result = await self.db.execute(stmt)
+        documents = result.scalars().all()
+        grouped: dict[int, list[ContractDocument]] = {}
+        for doc in documents:
+            grouped.setdefault(doc.bundle_id, []).append(doc)
+        return grouped
+
+    async def get_document_and_bundle(self, sign_flow_id: str):
+        stmt = (
+            select(ContractDocument, ContractBundle)
+            .join(ContractBundle, ContractDocument.bundle_id == ContractBundle.id)
+            .where(ContractDocument.sign_flow_id == sign_flow_id, ContractDocument.delete_flag == 0, ContractBundle.delete_flag == 0)
+        )
+        result = await self.db.execute(stmt)
+        row = result.first()
+        if not row:
+            return None, None
+        return row[0], row[1]
+
+    async def update_document_urls(self, document_id: int, template_url: str | None = None, sign_url: str | None = None, download_url: str | None = None):
+        values: dict[str, Any] = {}
+        if template_url is not None:
+            values["template_url"] = template_url
+        if sign_url is not None:
+            values["sign_url"] = sign_url
+        if download_url is not None:
+            values["download_url"] = download_url
+        if values:
+            stmt = ContractDocument.__table__.update().where(ContractDocument.id == document_id).values(**values)
+            await self.db.execute(stmt)
+
+    async def mark_document_signed(self, document_id: int, signing_time, effective_time, expiry_time, download_url: str | None):
+        values: dict[str, Any] = {
+            "status": 1,
+            "signing_time": signing_time,
+            "effective_time": effective_time,
+            "expiry_time": expiry_time,
+        }
+        if download_url:
+            values["download_url"] = download_url
+        stmt = ContractDocument.__table__.update().where(ContractDocument.id == document_id).values(**values)
+        await self.db.execute(stmt)
+
+    async def recalc_bundle_status(self, bundle_id: int) -> str:
+        stmt = select(ContractDocument.status).where(ContractDocument.bundle_id == bundle_id, ContractDocument.delete_flag == 0)
+        result = await self.db.execute(stmt)
+        statuses = [row[0] for row in result.fetchall()]
+        if not statuses:
+            status = "pending"
+        elif all(s == 1 for s in statuses):
+            status = "all_signed"
+        elif any(s == 1 for s in statuses):
+            status = "partially_signed"
+        else:
+            status = "pending"
+        update_stmt = ContractBundle.__table__.update().where(ContractBundle.id == bundle_id).values(status=status)
+        await self.db.execute(update_stmt)
+        return status
+
+    async def create_event(self, bundle_id: int | None, document_id: int | None, sign_flow_id: str, event_type: str, payload: dict[str, Any]):
+        event = ContractEvent(
+            bundle_id=bundle_id,
+            document_id=document_id,
+            sign_flow_id=sign_flow_id,
+            event_type=event_type,
+            payload_json=json.dumps(payload, ensure_ascii=False),
+        )
+        self.db.add(event)
+
+    async def commit(self):
+        await self.db.commit()

+ 1 - 0
alien_contract/schemas/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
alien_contract/schemas/request/__init__.py

@@ -0,0 +1 @@
+

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

@@ -0,0 +1,29 @@
+import re
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class BundleCreateRequest(BaseModel):
+    subject_type: Literal["store", "lawyer"] = Field(description="主体类型")
+    subject_id: int = Field(gt=0, 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 | None = Field(default=None, description="合同包类型")  # LAWYER_STANDARD/STORE_STANDARD
+
+    @field_validator("contact_phone")
+    @classmethod
+    def validate_contact_phone(cls, value: str) -> str:
+        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

+ 1 - 0
alien_contract/schemas/response/__init__.py

@@ -0,0 +1 @@
+

+ 67 - 0
alien_contract/schemas/response/contract.py

@@ -0,0 +1,67 @@
+from datetime import datetime
+from typing import Optional, List, Any
+
+from pydantic import BaseModel
+
+
+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
+
+
+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]
+
+
+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
+
+
+class ErrorResponse(BaseModel):
+    success: bool = False
+    message: str
+    raw: Optional[Any] = None
+
+
+class SuccessResponse(BaseModel):
+    code: str
+    msg: str
+
+
+class PaginatedBundleResponse(BaseModel):
+    items: List[ContractBundleResponse]
+    total: int
+    page: int
+    page_size: int
+    total_pages: int
+
+
+class ModuleStatusResponse(BaseModel):
+    module: str
+    status: str

+ 1 - 0
alien_contract/services/__init__.py

@@ -0,0 +1 @@
+

+ 248 - 0
alien_contract/services/contract_server.py

@@ -0,0 +1,248 @@
+import datetime
+import json
+import logging
+import os
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from alien_contract.repositories.contract_repo import ContractRepository
+from alien_contract.schemas.request.contract import BundleCreateRequest
+from alien_contract.infrastructure.esign import main as esign_main
+from alien_contract.infrastructure.esign.contract_builder import build_contract_items, ContractBuildError
+from alien_contract.infrastructure.esign.main import sign_url, file_download_url
+
+LOG_DIR = os.path.join("common", "logs", "alien_contract")
+os.makedirs(LOG_DIR, exist_ok=True)
+
+
+def _init_logger():
+    logger = logging.getLogger("alien_contract_service")
+    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)
+    logger.addHandler(info_handler)
+    logger.addHandler(error_handler)
+    return logger
+
+
+logger = _init_logger()
+
+BUNDLE_CONFIGS = {
+    "STORE_STANDARD": [
+        ("store_agreement", "店铺入驻协议", 1),
+        ("alipay_auth", "支付宝授权函", 0),
+        ("wechat_pay_commitment", "微信支付承诺函", 0),
+    ],
+    "LAWYER_STANDARD": [
+        ("lawyer_agreement", "律所入驻协议", 1),
+        ("alipay_auth", "支付宝授权函", 0),
+        ("wechat_pay_commitment", "微信支付承诺函", 0),
+    ],
+}
+
+DEFAULT_BUNDLE_BY_SUBJECT = {
+    "store": "STORE_STANDARD",
+    "lawyer": "LAWYER_STANDARD",
+}
+
+
+class ContractCenterService:
+    def __init__(self, db: AsyncSession):
+        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]
+        configs = BUNDLE_CONFIGS.get(bundle_type)
+        if not configs:
+            return {"success": False, "message": "不支持的合同包类型", "raw": {"bundle_type": bundle_type}}
+        try:
+            items = build_contract_items(
+                configs=configs,
+                template_name=req.subject_name,
+                signer_name=req.subject_name,
+                signer_id_num=req.ord_id,
+                psn_account=req.contact_phone,
+                psn_name=req.contact_name,
+            )
+        except ContractBuildError as exc:
+            return {"success": False, "message": exc.message, "raw": exc.raw}
+
+        bundle = await self.repo.create_bundle(
+            {
+                "subject_type": req.subject_type,
+                "subject_id": req.subject_id,
+                "subject_name": req.subject_name,
+                "business_segment": req.business_segment,
+                "contact_name": req.contact_name,
+                "contact_phone": req.contact_phone,
+                "ord_id": req.ord_id,
+                "bundle_type": bundle_type,
+                "status": "pending",
+            }
+        )
+        documents = await self.repo.create_documents(bundle.id, items)
+        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()
+        return {
+            "success": True,
+            "message": "合同包创建成功",
+            "bundle_id": bundle.id,
+            "primary_sign_flow_id": primary_doc.sign_flow_id,
+            "created_contracts": [
+                {
+                    "contract_type": d.contract_type,
+                    "contract_name": d.contract_name,
+                    "sign_flow_id": d.sign_flow_id,
+                    "file_id": d.file_id,
+                    "contract_url": d.template_url,
+                }
+                for d in documents
+            ],
+        }
+
+    async def list_bundles(self, subject_type: str, subject_id: int, page: int, page_size: int) -> 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)
+        items = []
+        for b in bundles:
+            docs = docs_map.get(b.id, [])
+            items.append(
+                {
+                    "id": b.id,
+                    "subject_type": b.subject_type,
+                    "subject_id": b.subject_id,
+                    "subject_name": b.subject_name,
+                    "business_segment": b.business_segment,
+                    "contact_name": b.contact_name,
+                    "contact_phone": b.contact_phone,
+                    "ord_id": b.ord_id,
+                    "bundle_type": b.bundle_type,
+                    "status": b.status,
+                    "primary_document_id": b.primary_document_id,
+                    "documents": [
+                        {
+                            "id": d.id,
+                            "contract_type": d.contract_type,
+                            "contract_name": d.contract_name,
+                            "is_primary": d.is_primary,
+                            "status": d.status,
+                            "sign_flow_id": d.sign_flow_id,
+                            "file_id": d.file_id,
+                            "template_url": d.template_url,
+                            "sign_url": d.sign_url,
+                            "download_url": d.download_url,
+                            "signing_time": d.signing_time,
+                            "effective_time": d.effective_time,
+                            "expiry_time": d.expiry_time,
+                        }
+                        for d in docs
+                    ],
+                }
+            )
+        total_pages = (total + page_size - 1) // page_size if total > 0 else 0
+        return {"items": items, "total": total, "page": page, "page_size": page_size, "total_pages": total_pages}
+
+    async def get_document_detail(self, sign_flow_id: str) -> dict:
+        document, bundle = await self.repo.get_document_and_bundle(sign_flow_id)
+        if not document:
+            return {"success": False, "message": "未找到合同"}
+        if document.status == 0:
+            return await self._get_pending_detail(document, bundle)
+        return await self._get_signed_detail(document, bundle)
+
+    async def _get_pending_detail(self, document, bundle):
+        try:
+            detail_resp = esign_main.get_contract_detail(document.file_id)
+            detail_json = json.loads(detail_resp)
+            data = detail_json.get("data") if isinstance(detail_json, dict) else None
+            contract_url_val = data.get("fileDownloadUrl") if isinstance(data, dict) else None
+            if not contract_url_val and isinstance(detail_json, dict):
+                contract_url_val = detail_json.get("fileDownloadUrl")
+        except Exception as exc:
+            return {"success": False, "message": "获取合同链接失败", "raw": str(exc)}
+        if not contract_url_val:
+            return {"success": False, "message": "e签宝返回缺少合同链接", "raw": detail_resp}
+
+        await self.repo.update_document_urls(document.id, template_url=contract_url_val)
+        try:
+            sign_resp = sign_url(document.sign_flow_id, bundle.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 as exc:
+            return {"success": False, "message": "获取签署链接失败", "raw": str(exc)}
+        if not result_sign_url:
+            return {"success": False, "message": "e签宝返回缺少签署链接", "raw": sign_json}
+
+        await self.repo.update_document_urls(document.id, sign_url=result_sign_url)
+        await self.repo.commit()
+        return {
+            "status": 0,
+            "contract_url": contract_url_val,
+            "sign_url": result_sign_url,
+            "sign_flow_id": document.sign_flow_id,
+        }
+
+    async def _get_signed_detail(self, document, _bundle):
+        try:
+            download_resp = file_download_url(document.sign_flow_id)
+            download_json = json.loads(download_resp)
+            contract_download_url = download_json["data"]["files"][0]["downloadUrl"]
+        except Exception as exc:
+            return {"success": False, "message": "获取合同下载链接失败", "raw": str(exc)}
+        await self.repo.update_document_urls(document.id, template_url=contract_download_url, download_url=contract_download_url)
+        await self.repo.commit()
+        return {
+            "status": 1,
+            "contract_url": contract_download_url,
+            "contract_download_url": contract_download_url,
+            "sign_flow_id": document.sign_flow_id,
+        }
+
+    async def process_esign_callback(self, payload: dict) -> dict:
+        sign_result = payload.get("signResult")
+        sign_flow_id = payload.get("signFlowId")
+        if not sign_flow_id:
+            return {"success": True, "code": "200", "msg": "ignored_missing_signFlowId"}
+
+        document, bundle = await self.repo.get_document_and_bundle(sign_flow_id)
+        if not document:
+            return {"success": True, "code": "200", "msg": "ignored_unknown_signFlowId"}
+
+        await self.repo.create_event(bundle.id, document.id, sign_flow_id, "esign_callback", payload)
+
+        if sign_result == 2:
+            ts_ms = payload.get("operateTime") or payload.get("timestamp")
+            signing_dt = None
+            if ts_ms:
+                try:
+                    signing_dt = datetime.datetime.fromtimestamp(ts_ms / 1000)
+                except Exception:
+                    signing_dt = None
+            effective_dt = expiry_dt = None
+            if signing_dt:
+                effective_dt = (signing_dt + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
+                expiry_dt = effective_dt + datetime.timedelta(days=365)
+            contract_download_url = None
+            try:
+                download_resp = file_download_url(sign_flow_id)
+                download_json = json.loads(download_resp)
+                contract_download_url = download_json["data"]["files"][0]["downloadUrl"]
+            except Exception:
+                contract_download_url = None
+            await self.repo.mark_document_signed(document.id, signing_dt, effective_dt, expiry_dt, contract_download_url)
+            await self.repo.recalc_bundle_status(bundle.id)
+            await self.repo.commit()
+            return {"success": True, "code": "200", "msg": "success"}
+
+        await self.repo.commit()
+        return {"success": True, "code": "200", "msg": f"ignored_signResult_{sign_result}"}

+ 5 - 0
alien_lawyer/api/deps.py

@@ -2,8 +2,13 @@ from fastapi import Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from alien_database.session import get_db
+from alien_contract.services.contract_server import ContractCenterService
 from alien_lawyer.services.contract_server import LawyerContractServer
 
 
 def get_contract_service(db: AsyncSession = Depends(get_db)) -> LawyerContractServer:
     return LawyerContractServer(db)
+
+
+def get_contract_center_service(db: AsyncSession = Depends(get_db)) -> ContractCenterService:
+    return ContractCenterService(db)

+ 23 - 4
alien_lawyer/api/router.py

@@ -3,7 +3,9 @@ from typing import Any, Union, Optional
 from fastapi import APIRouter, Depends, Query
 from pydantic import ValidationError
 
-from alien_lawyer.api.deps import get_contract_service
+from alien_lawyer.api.deps import get_contract_service, get_contract_center_service
+from alien_contract.schemas.request.contract import BundleCreateRequest
+from alien_contract.services.contract_server import ContractCenterService
 from alien_lawyer.schemas.request.contract_lawyer import LawyerTemplatesCreate
 from alien_lawyer.schemas.response.contract_lawyer import (
     ModuleStatusResponse,
@@ -40,16 +42,33 @@ async def index() -> ModuleStatusResponse:
 @router.post("/get_esign_templates", response_model=Union[TemplatesCreateResponse, ErrorResponse])
 async def create_esign_templates(
     templates_data_raw: dict[str, Any],
-    templates_server: LawyerContractServer = Depends(get_contract_service),
+    templates_server: ContractCenterService = Depends(get_contract_center_service),
 ) -> Union[TemplatesCreateResponse, ErrorResponse]:
     try:
         templates_data = LawyerTemplatesCreate.model_validate(templates_data_raw)
     except ValidationError as exc:
         return ErrorResponse(success=False, message="请求参数校验失败", raw={"errors": _format_validation_errors(exc)})
-    result = await templates_server.create_esign_templates(templates_data)
+    request = BundleCreateRequest(
+        subject_type="lawyer",
+        subject_id=templates_data.lawyer_id,
+        subject_name=templates_data.law_firm_name,
+        business_segment=templates_data.business_segment,
+        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"):
         return ErrorResponse(**result)
-    return TemplatesCreateResponse(**result)
+    return TemplatesCreateResponse(
+        success=True,
+        message=result["message"],
+        sign_flow_id=result.get("primary_sign_flow_id"),
+        file_id=result.get("created_contracts", [{}])[0].get("file_id") if result.get("created_contracts") else None,
+        contract_url=result.get("created_contracts", [{}])[0].get("contract_url") if result.get("created_contracts") else None,
+        created_contracts=result.get("created_contracts"),
+    )
 
 
 @router.get("/contracts/{lawyer_id}", response_model=PaginatedResponse)

+ 5 - 3
alien_lawyer/services/contract_server.py

@@ -8,9 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from alien_lawyer.repositories.contract_repo import LawyerContractRepository
 from alien_lawyer.schemas.request.contract_lawyer import LawyerTemplatesCreate
-from common.esigntool import main as esign_main
-from common.esigntool.contract_builder import build_contract_items, ContractBuildError
-from common.esigntool.main import sign_url, file_download_url
+from alien_contract.infrastructure.esign import main as esign_main
+from alien_contract.infrastructure.esign.contract_builder import build_contract_items, ContractBuildError
+from alien_contract.infrastructure.esign.main import sign_url, file_download_url
 
 LOG_DIR = os.path.join("common", "logs", "alien_lawyer")
 os.makedirs(LOG_DIR, exist_ok=True)
@@ -37,6 +37,8 @@ logger = _init_logger()
 
 LAWYER_CONTRACT_CREATE_CONFIGS = [
     ("lawyer_agreement", "律所入驻协议", 1),
+    ("alipay_auth", "支付宝授权函", 0),
+    ("wechat_pay_commitment", "微信支付承诺函", 0),
 ]
 
 

+ 6 - 1
alien_store/api/deps.py

@@ -2,7 +2,12 @@ from fastapi import Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 from alien_database.session import get_db
 from alien_store.services.contract_server import ContractServer
+from alien_contract.services.contract_server import ContractCenterService
 
 
 def get_contract_service(db: AsyncSession = Depends(get_db)) -> ContractServer:
-    return ContractServer(db)
+    return ContractServer(db)
+
+
+def get_contract_center_service(db: AsyncSession = Depends(get_db)) -> ContractCenterService:
+    return ContractCenterService(db)

+ 23 - 5
alien_store/api/router.py

@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, Query
 from typing import Any, Union, Optional
 from pydantic import ValidationError
 
-from alien_store.api.deps import get_contract_service
+from alien_store.api.deps import get_contract_service, get_contract_center_service
+from alien_contract.schemas.request.contract import BundleCreateRequest
+from alien_contract.services.contract_server import ContractCenterService
 from alien_store.schemas.request.contract_store import TemplatesCreate
 from alien_store.schemas.response.contract_store import (
     ModuleStatusResponse,
@@ -40,7 +42,7 @@ async def index() -> ModuleStatusResponse:
 @router.post("/get_esign_templates", response_model=Union[TemplatesCreateResponse, ErrorResponse])
 async def create_esign_templates(
     templates_data_raw: dict[str, Any],
-    templates_server: ContractServer = Depends(get_contract_service)
+    templates_server: ContractCenterService = Depends(get_contract_center_service)
 ) -> Union[TemplatesCreateResponse, ErrorResponse]:
     """AI审核完调用 e签宝生成文件"""
     try:
@@ -54,11 +56,27 @@ async def create_esign_templates(
             raw={"errors": detail},
         )
 
-    result = await templates_server.create_esign_templates(templates_data)
+    request = BundleCreateRequest(
+        subject_type="store",
+        subject_id=templates_data.store_id,
+        subject_name=templates_data.store_name,
+        business_segment=templates_data.business_segment,
+        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"):
         return ErrorResponse(**result)
-    
-    return TemplatesCreateResponse(**result)
+    return TemplatesCreateResponse(
+        success=True,
+        message=result["message"],
+        sign_flow_id=result.get("primary_sign_flow_id"),
+        file_id=result.get("created_contracts", [{}])[0].get("file_id") if result.get("created_contracts") else None,
+        contract_url=result.get("created_contracts", [{}])[0].get("contract_url") if result.get("created_contracts") else None,
+        created_contracts=result.get("created_contracts"),
+    )
 
 @router.get("/contracts/{store_id}", response_model=Union[dict, Any])
 async def list_contracts(

+ 3 - 3
alien_store/services/contract_server.py

@@ -6,9 +6,9 @@ from typing import Any, Union, Optional
 from sqlalchemy.ext.asyncio import AsyncSession
 from alien_store.repositories.contract_repo import ContractRepository
 from alien_store.schemas.request.contract_store import TemplatesCreate
-from common.esigntool import main as esign_main
-from common.esigntool.main import sign_url, file_download_url
-from common.esigntool.contract_builder import build_contract_items, ContractBuildError
+from alien_contract.infrastructure.esign import main as esign_main
+from alien_contract.infrastructure.esign.main import sign_url, file_download_url
+from alien_contract.infrastructure.esign.contract_builder import build_contract_items, ContractBuildError
 
 
 # ------------------- 日志配置 -------------------

+ 0 - 211
common/esigntool/esign_algorithm.py

@@ -1,211 +0,0 @@
-import base64
-import hashlib
-import hmac
-from functools import wraps
-from hashlib import sha256
-import time
-import logging
-import json
-from urllib.parse import urlparse, parse_qs, urlencode
-
-
-
-def doContentMd5(data):
-    """
-    根据字符串计算Content-MD5
-    :param data:
-    :return:
-    """
-    obj = hashlib.md5()  # 实例化md5的时候可以给传个参数,这叫加盐
-    obj.update(data.encode("utf-8"))  # 是再加密的时候传入自己的一块字节,
-    secret = obj.digest()
-    return base64.b64encode(secret).decode('utf-8')
-
-
-def appendSignDataString(http_method, content_md5, url, accept="*/*",
-                         content_type="application/json; charset=UTF-8",
-                         **kwargs):
-    """
-    拼接待签名字符串
-    :param http_method: 统一用大写的POST GET DELETE PUT
-    :param content_md5:请求body体计算的md5值
-    :param url: 接口url地址,不带网关,例:/v1/accounts/createByThirdPartyUserId
-    :param accept: 不传默认"*/*"
-    :param content_type: 不传默认application/json; charset=UTF-8
-    :param kwargs: 可以传headers和date参数
-    :return: 拼接的待签名字符串
-    """
-    # 待签字符串
-    sign_data_str = "{}\n{}\n{}\n{}\n".format(http_method, accept, content_md5, content_type)
-    date = kwargs.get('date')
-    headers = kwargs.get('headers')
-    # 如果date是空的,直接拼接\n
-    if date == "" or date is None:
-        sign_data_str = "{}\n".format(sign_data_str)
-    else:
-        sign_data_str = "{}{}\n".format(sign_data_str, date)
-
-    # 如果header是空的,直接拼接url
-    if headers == "" or headers is None:
-        sign_data_str = "{}{}".format(sign_data_str, url)
-    else:
-        sign_data_str = "{}{}\n{}".format(sign_data_str, headers, url)
-    return sign_data_str
-
-
-def doSignatureBase64(message, secret):
-    """
-    根据待签字符串计算签名值
-    :param message: 待签名字符串
-    :param secret:密钥
-    :return:
-    """
-    key = secret.encode('utf-8')  # sha256加密的key
-    message = message.encode('utf-8')  # 待sha256加密的内容
-    sign = base64.b64encode(hmac.new(key, message, digestmod=sha256).digest()).decode()
-    return sign
-
-def getMillisecondStamp():
-    """
-    获取当前毫秒时间戳
-    :return:
-    """
-    # 使用 time_ns 避免浮点精度问题
-    # print(str(time.time_ns() // 1_000_000))
-    return str(time.time_ns() // 1_000_000)
-
-
-def buildHeader(appid, content_md5, req_signature, accept="*/*",
-                content_type="application/json; charset=UTF-8",
-                auth_mode="Signature", **kwargs):
-    """
-    构造签名鉴权请求头
-    :param appid:
-    :param content_md5:
-    :param req_signature:
-    :param accept:
-    :param content_type:
-    :param auth_mode:
-    :param kwargs:
-    :return:
-    """
-    header = {"X-Tsign-Open-App-Id": appid,
-              "Content-Type": content_type,
-              "X-Tsign-Open-Ca-Timestamp": getMillisecondStamp(),
-              "Accept": accept,
-              "X-Tsign-Open-Ca-Signature": req_signature,
-              "Content-MD5": content_md5,
-              "X-Tsign-Open-Auth-Mode": auth_mode,
-              "X-Tsign-Dns-App-Id": appid}
-    for key, value in kwargs.items():
-        header[key] = value
-    return header
-
-
-def buildFileUploadHeader(contentType, contentMd5):
-    """
-    构建文件流上传请求头
-    :param contentType:
-    :param contentMd5:
-    :return:
-    """
-    header = {
-        'Content-Type': contentType,
-        'Content-MD5': contentMd5
-    }
-    return header
-
-
-def buildSignJsonHeader(appid, secret, http_method, url, body=None,
-                        **kwargs):
-    """
-    签名并构造签名鉴权和json请求体的请求头
-    :param appid:
-    :param secret:
-    :param body: 传入字典格式数据
-    :param http_method:
-    :param url:接口url地址,不带网关,例:/v1/accounts/createByThirdPartyUserId
-    :param kwargs:可以传headers和date参数以及其它自定义请求头参数
-    :return:
-    """
-    content_md5 = ""
-    # 判断是PUT或者POST请求,不需要计算计算md5,否则md5为空
-    if "PUT" == http_method or "POST" == http_method:
-        # 字典转json字符串(去空格,保证与发送一致)
-        body = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-        # 生成md5
-        content_md5 = doContentMd5(body)
-    # 拼接待签名字符串
-    message = appendSignDataString(http_method, content_md5, apiPathSort(url), **kwargs)
-    # 传入待签名字符串生成签名值
-    req_signature = doSignatureBase64(message, secret)
-    # 生成请求头
-    header = buildHeader(appid, content_md5, req_signature, **kwargs)
-    logging.debug("开始运行".center(50, "-"))
-    logging.debug("计算md5的body:{}\n生成的md5:{}\n"
-                  "拼接的待签字符串:{}\n签名值:{}".format(body, content_md5, message, req_signature))
-    logging.debug("结束运行".center(50, "-"))
-    return header
-
-
-def apiPathSort(api_path, is_query=False, is_url_encode=False):
-    """
-    传入url对query做排序返回新的url
-    :param api_path:
-    :param is_url_encode:
-    :param is_query:
-    :return:
-    """
-    # 提取url参数
-    # 传入的api_path是不是整体都是query参数
-    if is_query:
-        query = api_path
-        urls = ""
-        path = ""
-        # 将字符串转换为字典
-        # 所得的字典的value都是以列表的形式存在,若列表中都只有一个值
-    else:
-        urls = "?"
-        url = urlparse(api_path)
-        query = url.query
-        path = url.path
-        # 将字符串转换为字典
-        # 所得的字典的value都是以列表的形式存在,若列表中都只有一个值
-    body = parse_qs(query)
-    body = {key: body[key][0] for key in sorted(body.keys())}
-    # 需要做urlencode编码
-    if is_url_encode:
-        body = urlencode(body)
-        urls = path + urls + body
-    else:
-        for key in body:
-            temp_str = key + "=" + body[key]
-            urls = urls + temp_str + "&"
-        urls = path + urls
-    urls = urls[:len(urls) - 1]
-    return urls
-
-
-def esign_run_print_outer(func):
-    """
-    装饰器用于打印运行函数的基本信息
-    :param func:
-    :return:
-    """
-
-    @wraps(func)
-    def esign_run_print(*args, **kwargs):
-        print("开始运行{}".format(func.__name__).center(50, "-"))
-        start_time = time.time()
-        response = func(*args, **kwargs)
-        end_time = time.time()
-        print("运行时间:{}".format(end_time - start_time))
-        print("结束运行{}".format(func.__name__).center(50, "-"))
-        print("\n\n")
-        return response
-
-    return esign_run_print
-
-
-if __name__ == '__main__':
-    pass

+ 0 - 30
common/esigntool/esign_config.py

@@ -1,30 +0,0 @@
-
-# 公共参数配置类
-class Config:
-    def __init__(self):
-        """
-        项目配置类,在此配置公共参数
-        """
-        # 沙箱appid获取路径:https://open.esign.cn/doc/opendoc/saas_api/vwtg6m
-        # 沙箱appid获取路径:https://open.esign.cn/doc/opendoc/saas_api/zag8bm
-        self.appId = "7439100277"  # 项目appid
-        self.scert = "fda5f9e9652571066631f7ba938092e1"  # 项目密钥
-        self.host = "https://smlopenapi.esign.cn"  # 沙箱请求地址,正式环境地址:https://openapi.esign.cn
-        self.ordid = 'f25ca92b44a34b2c94289e0afec2518b'
-        self.templates_id = "266369d0efd94e14a78035b881e8cb93"
-
-        # 合同模板ID映射
-        self.templates_map = {
-            "store_agreement": "266369d0efd94e14a78035b881e8cb93",   # 店铺入驻协议
-            "lawyer_agreement": "28f8c0337b044ca0acb6e3559f526d95",  # 律所入驻协议
-            "alipay_auth": "f42e72f59bb648c280ec4c3e937e0c26",       # 支付宝授权函
-            "wechat_pay_commitment": "18ecbd5a85cc4a0ba190a010a79bf8da",  # 微信支付承诺函
-        }
-
-        # 合同文件名映射
-        self.template_file_names = {
-            "store_agreement": "U店在这-商户入驻协议",
-            "lawyer_agreement": "U店在这-律所入驻协议",
-            "alipay_auth": "U店在这-支付宝授权函",
-            "wechat_pay_commitment": "U店在这-微信支付承诺函",
-        }

+ 0 - 9
common/esigntool/esign_emun.py

@@ -1,9 +0,0 @@
-from enum import Enum
-
-
-class httpMethodEnum(str, Enum):
-    POST = "POST"
-    GET = "GET"
-    DELETE = "DELETE"
-    PUT = "PUT"
-

+ 0 - 40
common/esigntool/esign_file.py

@@ -1,40 +0,0 @@
-import base64
-import hashlib
-import os
-
-
-# 文件辅助类
-class fileHelp:
-    def __init__(self, fileUrl):
-        """
-        为文件类定义初始化公共参数
-        :param fileUrl:
-        """
-        self.fileOs = os.path.abspath(os.path.join(os.getcwd(), fileUrl))
-        self.contentMd5 = self.__content_encoding()  # 初始化文件MD5
-        self.fileName = os.path.basename(self.fileOs)  # 初始化文件名
-        self.fileSize = os.path.getsize(self.fileOs)  # 初始化文件大小
-
-    def __content_encoding(self):
-        """
-        文件转 bytes 加密并使用 base64 编码
-        :param fileOs: 文件路径
-        :return: 返回加密编码后的字符串
-        """
-
-        with open(self.fileOs, 'rb') as f:
-            content = f.read()
-        content_md5 = hashlib.md5()
-        content_md5.update(content)
-        content_base64 = base64.b64encode(content_md5.digest())
-        return content_base64.decode("utf-8")
-
-    def getBinFile(self):
-        """
-        获取文件流
-        :param fileOs:
-        :return:
-        """
-        with open(self.fileOs, 'rb') as f:
-            binfile = f.read()
-        return binfile

+ 0 - 14
common/esigntool/esign_templates.py

@@ -1,14 +0,0 @@
-import requests
-from esign_config import Config
-
-config = Config()
-
-url = config.host+f'/v3/sign-templates?orgId={config.ordid}'
-
-print(url)
-def get_templates():
-    """获取公司在e签宝下所有的模板"""
-    res = requests.get(url, headers=config.headers)
-    return res.json()
-
-print(get_templates())

+ 0 - 256
common/esigntool/main.py

@@ -1,256 +0,0 @@
-import json
-import logging
-import requests
-
-from common.esigntool.esign_config import Config
-from common.esigntool.esign_algorithm import buildSignJsonHeader
-from datetime import datetime
-
-logger = logging.getLogger(__name__)
-
-config = Config()
-
-
-def _request(method: str, api_path: str, body: dict = None) -> str:
-    """统一的e签宝API请求方法
-
-    Args:
-        method: HTTP方法 (GET/POST)
-        api_path: API路径
-        body: 请求体字典
-
-    Returns:
-        响应文本
-    """
-    headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    url = config.host + api_path
-
-    if body is not None:
-        data = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-        resp = requests.request(method, url, data=data, headers=headers)
-    else:
-        resp = requests.request(method, url, headers=headers)
-    logger.debug("请求 %s %s, 响应: %s", method, api_path, resp.text)
-    return resp.text
-
-
-# ==================== 签署流程标题映射 ====================
-SIGN_FLOW_TITLES = {
-    "store_agreement": "商家入驻U店平台协议签署",
-    "alipay_auth": "支付宝授权函签署",
-    "wechat_pay_commitment": "微信支付承诺函签署",
-    "lawyer_agreement": "律所入驻U店平台协议签署",
-}
-
-# ==================== 签署位置配置 ====================
-SIGN_POSITIONS = {
-    "store_agreement": {
-        "alien": {"page": 7, "x": 294, "y": 668},
-        "signer": {"page": 7, "x": 114, "y": 666},
-    },
-    "alipay_auth": {
-        "alien": {"page": 1, "x": 535, "y": 555},
-        "signer": {"page": 1, "x": 535, "y": 431},
-    },
-    "wechat_pay_commitment": {
-        "signer": {"page": 1, "x": 535, "y": 515},
-    },
-    "lawyer_agreement": {
-        "alien": {"page": 6, "x": 336, "y": 418},
-        "signer": {"page": 6, "x": 145, "y": 418},
-    },
-}
-
-# ==================== 模板填充配置 ====================
-TEMPLATE_COMPONENT_VALUES = {
-    "store_agreement": {
-        "alien_name": "爱丽恩严(大连)商务科技有限公司",
-        "date": None,  # 运行时填充
-        "one_name": None,  # 运行时填充
-        "store_name": None,  # 运行时填充
-    },
-    "lawyer_agreement": {
-        "alien_name": "爱丽恩严(大连)商务科技有限公司",
-        "alien_name_2": "爱丽恩严(大连)商务科技有限公司",
-        "law_firm_name": None,  # 运行时填充
-        "law_firm_name_2": None,  # 运行时填充
-        "signing_date": None,  # 运行时填充
-    },
-    "alipay_auth": {},
-    "wechat_pay_commitment": {},
-}
-
-
-def get_auth_flow_id(org_name, org_id, legal_rep_name, legal_rep_id):
-    """获取机构认证&授权页面链接"""
-    body = {
-        "clientType": "ALL",
-        "redirectConfig": {
-            "redirectUrl": "https://www.baidu.com"
-        },
-        "orgAuthConfig": {
-            "orgName": org_name,
-            "orgInfo": {
-                "orgIDCardNum": org_id,
-                "orgIDCardType": "CRED_ORG_USCC",
-                "legalRepName": legal_rep_name,
-                "legalRepIDCardNum": legal_rep_id,
-                "legalRepIDCardType": "CRED_PSN_CH_IDCARD"
-            },
-            "transactorInfo": {
-                "psnAccount": "17337039317",
-                "psnInfo": {
-                    "psnName": "孟骞康",
-                    "psnIDCardNum": "411426200308121212",
-                    "psnIDCardType": "CRED_PSN_CH_IDCARD",
-                    "psnMobile": "17337039317"
-                }
-            }
-        },
-        "notifyUrl": "http://120.26.186.130:33333/api/store/esign/callback_auth",
-        "transactorUseSeal": True
-    }
-    return _request("POST", "/v3/org-auth-url", body)
-
-
-def get_template_detail():
-    """查询合同模板中控件详情"""
-    return _request("GET", f"/v3/doc-templates/{config.templates_id}")
-
-
-def fill_in_template(name, contract_type="store_agreement"):
-    """填写模板生成文件
-
-    Args:
-        name: 商家/律所名称
-        contract_type: 合同类型 (store_agreement/lawyer_agreement/alipay_auth/wechat_pay_commitment)
-    """
-    today = datetime.now().strftime("%Y年%m月%d日")
-    base_values = TEMPLATE_COMPONENT_VALUES.get(contract_type, {})
-
-    # 根据合同类型填充动态字段
-    dynamic_values = {
-        "store_agreement": {"store_name": name, "one_name": name, "date": today},
-        "lawyer_agreement": {"law_firm_name": name, "law_firm_name_2": name, "signing_date": today},
-    }.get(contract_type, {})
-
-    components = [
-        {"componentKey": key, "componentValue": value}
-        for key, value in {**base_values, **dynamic_values}.items()
-    ]
-
-    body = {
-        "docTemplateId": config.templates_map.get(contract_type, ""),
-        "fileName": config.template_file_names.get(contract_type, ""),
-        "components": components
-    }
-    return _request("POST", "/v3/files/create-by-doc-template", body)
-
-
-def get_contract_detail(file_id):
-    """查询PDF模板填写后文件"""
-    return _request("GET", f"/v3/files/{file_id}")
-
-
-def _build_alien_signer(file_id, position):
-    """构建平台方(爱丽恩严)签署人配置"""
-    return {
-        "signConfig": {"signOrder": 1},
-        "signerType": 1,
-        "signFields": [{
-            "customBizNum": "9527",
-            "fileId": file_id,
-            "normalSignFieldConfig": {
-                "autoSign": True,
-                "signFieldStyle": 1,
-                "signFieldPosition": {
-                    "positionPage": str(position["page"]),
-                    "positionX": position["x"],
-                    "positionY": position["y"]
-                }
-            }
-        }]
-    }
-
-
-def _build_org_signer(file_id, position, signer_name, signer_id_num, psn_account, psn_name):
-    """构建签署方(商家/律所)签署人配置"""
-    return {
-        "signConfig": {"forcedReadingTime": 10, "signOrder": 2},
-        "signerType": 1,
-        "orgSignerInfo": {
-            "orgName": signer_name,
-            "orgInfo": {
-                "orgIDCardNum": signer_id_num,
-                "orgIDCardType": "CRED_ORG_USCC"
-            },
-            "transactorInfo": {
-                "psnAccount": psn_account,
-                "psnInfo": {"psnName": psn_name}
-            }
-        },
-        "signFields": [{
-            "customBizNum": "自定义编码001",
-            "fileId": file_id,
-            "normalSignFieldConfig": {
-                "signFieldStyle": 1,
-                "signFieldPosition": {
-                    "positionPage": str(position["page"]),
-                    "positionX": position["x"],
-                    "positionY": position["y"]
-                }
-            }
-        }]
-    }
-
-
-def create_by_file(file_id, file_name, signer_name, signer_id_num, psn_account, psn_name, contract_type="store_agreement"):
-    """基于文件发起签署
-
-    Args:
-        file_id: 文件ID
-        file_name: 文件名
-        signer_name: 签署方名称
-        signer_id_num: 签署方证件号
-        psn_account: 经办人账号标识(手机号/邮箱)
-        psn_name: 经办人姓名
-        contract_type: 合同类型 (store_agreement/alipay_auth/wechat_pay_commitment/lawyer_agreement)
-    """
-    positions = SIGN_POSITIONS.get(contract_type, SIGN_POSITIONS["store_agreement"])
-
-    signers = []
-    if positions.get("alien"):
-        signers.append(_build_alien_signer(file_id, positions["alien"]))
-    if positions.get("signer"):
-        signers.append(_build_org_signer(file_id, positions["signer"], signer_name, signer_id_num, psn_account, psn_name))
-
-    body = {
-        "docs": [{"fileId": file_id, "fileName": f"{file_name}.pdf"}],
-        "signFlowConfig": {
-            "signFlowTitle": SIGN_FLOW_TITLES.get(contract_type, "合同签署"),
-            "autoFinish": True,
-            "noticeConfig": {"noticeTypes": "1,2"},
-            "notifyUrl": "http://120.26.186.130:33333:/api/store/esign/callback",
-            "redirectConfig": {"redirectUrl": "https://www.esign.cn/"},
-        },
-        "signers": signers
-    }
-    return _request("POST", "/v3/sign-flow/create-by-file", body)
-
-
-def sign_url(sign_flow_id, psn_account):
-    """获取签署页面链接"""
-    body = {
-        "signFlowId": sign_flow_id,
-        "clientType": "ALL",
-        "needLogin": False,
-        "operator": {"psnAccount": psn_account},
-        "urlType": 2
-    }
-    return _request("POST", f"/v3/sign-flow/{sign_flow_id}/sign-url", body)
-
-
-def file_download_url(sign_flow_id):
-    """下载已签署文件及附属材料"""
-    body = {"urlAvailableDate": "3600"}
-    return _request("POST", f"/v3/sign-flow/{sign_flow_id}/file-download-url", body)

+ 2 - 0
main.py

@@ -3,6 +3,7 @@ from alien_store.api.router import router as store_router
 from alien_store_platform.router import router as platform_router
 from alien_second.router import router as second_router
 from alien_lawyer.router import router as lawyer_router
+from alien_contract.router import router as contract_router
 from alien_gateway.config import settings
 
 
@@ -17,6 +18,7 @@ app.include_router(store_router, prefix="/api/store", tags=["Store"])
 app.include_router(platform_router, prefix="/api/platform", tags=["Platform"])
 app.include_router(second_router, prefix="/api/second", tags=["Second"])
 app.include_router(lawyer_router, prefix="/api/lawyer", tags=["Lawyer"])
+app.include_router(contract_router, prefix="/api/contract", tags=["Contract"])
 @app.get("/", tags=["Health Check"])
 async def root():
     return {

+ 0 - 301
tests/test_esigntool.py

@@ -1,301 +0,0 @@
-import json
-import unittest
-from unittest.mock import patch, MagicMock
-
-from common.esigntool.main import (
-    _request,
-    _build_alien_signer,
-    _build_org_signer,
-    get_auth_flow_id,
-    get_template_detail,
-    fill_in_template,
-    get_contract_detail,
-    create_by_file,
-    sign_url,
-    file_download_url,
-    SIGN_FLOW_TITLES,
-    SIGN_POSITIONS,
-    TEMPLATE_COMPONENT_VALUES,
-)
-from common.esigntool.esign_config import Config
-
-
-class TestConstants(unittest.TestCase):
-    """模块级常量的完整性测试"""
-
-    def test_sign_flow_titles_keys(self):
-        expected = {"store_agreement", "alipay_auth", "wechat_pay_commitment", "lawyer_agreement"}
-        self.assertEqual(set(SIGN_FLOW_TITLES.keys()), expected)
-
-    def test_sign_positions_keys(self):
-        expected = {"store_agreement", "alipay_auth", "wechat_pay_commitment", "lawyer_agreement"}
-        self.assertEqual(set(SIGN_POSITIONS.keys()), expected)
-
-    def test_template_component_values_keys(self):
-        expected = {"store_agreement", "lawyer_agreement", "alipay_auth", "wechat_pay_commitment"}
-        self.assertEqual(set(TEMPLATE_COMPONENT_VALUES.keys()), expected)
-
-    def test_sign_positions_have_page_xy(self):
-        for contract_type, positions in SIGN_POSITIONS.items():
-            for role, pos in positions.items():
-                with self.subTest(contract_type=contract_type, role=role):
-                    self.assertIn("page", pos, f"缺少 page: {contract_type}.{role}")
-                    self.assertIn("x", pos, f"缺少 x: {contract_type}.{role}")
-                    self.assertIn("y", pos, f"缺少 y: {contract_type}.{role}")
-
-
-class TestRequest(unittest.TestCase):
-    """统一请求方法 _request 的测试"""
-
-    @patch("common.esigntool.main.requests.request")
-    @patch("common.esigntool.main.buildSignJsonHeader")
-    def test_get_request_no_body(self, mock_build_header, mock_req):
-        mock_build_header.return_value = {"X-Test": "header"}
-        mock_resp = MagicMock()
-        mock_resp.text = '{"code":0}'
-        mock_req.return_value = mock_resp
-
-        result = _request("GET", "/v3/test")
-
-        mock_build_header.assert_called_once()
-        mock_req.assert_called_once_with(
-            "GET",
-            "https://smlopenapi.esign.cn/v3/test",
-            headers={"X-Test": "header"},
-        )
-        self.assertEqual(result, '{"code":0}')
-
-    @patch("common.esigntool.main.requests.request")
-    @patch("common.esigntool.main.buildSignJsonHeader")
-    def test_post_request_with_body(self, mock_build_header, mock_req):
-        mock_build_header.return_value = {"X-Test": "header"}
-        mock_resp = MagicMock()
-        mock_resp.text = '{"code":0,"data":{}}'
-        mock_req.return_value = mock_resp
-
-        body = {"name": "test", "value": 123}
-        result = _request("POST", "/v3/action", body)
-
-        mock_req.assert_called_once()
-        call_args = mock_req.call_args[0]
-        call_kwargs = mock_req.call_args[1]
-        self.assertEqual(call_args[0], "POST")
-        self.assertEqual(call_args[1], "https://smlopenapi.esign.cn/v3/action")
-        self.assertEqual(call_kwargs["headers"], {"X-Test": "header"})
-
-        sent_data = json.loads(call_kwargs["data"])
-        self.assertEqual(sent_data, body)
-        self.assertEqual(result, '{"code":0,"data":{}}')
-
-
-class TestBuildAlienSigner(unittest.TestCase):
-    """平台方签署人构建测试"""
-
-    def test_returns_correct_structure(self):
-        position = {"page": 1, "x": 100, "y": 200}
-        result = _build_alien_signer("file_123", position)
-
-        self.assertEqual(result["signConfig"]["signOrder"], 1)
-        self.assertEqual(result["signerType"], 1)
-        self.assertEqual(len(result["signFields"]), 1)
-
-        field = result["signFields"][0]
-        self.assertEqual(field["customBizNum"], "9527")
-        self.assertEqual(field["fileId"], "file_123")
-        self.assertTrue(field["normalSignFieldConfig"]["autoSign"])
-
-        pos = field["normalSignFieldConfig"]["signFieldPosition"]
-        self.assertEqual(pos["positionPage"], "1")
-        self.assertEqual(pos["positionX"], 100)
-        self.assertEqual(pos["positionY"], 200)
-
-
-class TestBuildOrgSigner(unittest.TestCase):
-    """签署方(商家/律所)签署人构建测试"""
-
-    def test_returns_correct_structure(self):
-        position = {"page": 3, "x": 50, "y": 100}
-        result = _build_org_signer("file_456", position, "测试公司", "91110000MA0001", "13800138000", "张三")
-
-        self.assertEqual(result["signConfig"]["signOrder"], 2)
-        self.assertEqual(result["signConfig"]["forcedReadingTime"], 10)
-        self.assertEqual(result["signerType"], 1)
-
-        self.assertEqual(result["orgSignerInfo"]["orgName"], "测试公司")
-        self.assertEqual(result["orgSignerInfo"]["orgInfo"]["orgIDCardNum"], "91110000MA0001")
-        self.assertEqual(result["orgSignerInfo"]["transactorInfo"]["psnAccount"], "13800138000")
-        self.assertEqual(result["orgSignerInfo"]["transactorInfo"]["psnInfo"]["psnName"], "张三")
-
-        field = result["signFields"][0]
-        self.assertEqual(field["customBizNum"], "自定义编码001")
-        self.assertEqual(field["fileId"], "file_456")
-
-        pos = field["normalSignFieldConfig"]["signFieldPosition"]
-        self.assertEqual(pos["positionPage"], "3")
-        self.assertEqual(pos["positionX"], 50)
-        self.assertEqual(pos["positionY"], 100)
-
-
-class TestGetAuthFlowId(unittest.TestCase):
-    """获取机构认证链接测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_calls_request_with_correct_params(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        result = get_auth_flow_id("测试公司", "91110000MA0001", "张三", "110101199001011234")
-        print(result)
-
-        mock_request.assert_called_once_with("POST", "/v3/org-auth-url", unittest.mock.ANY)
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(call_body["orgAuthConfig"]["orgName"], "测试公司")
-        self.assertEqual(call_body["orgAuthConfig"]["orgInfo"]["orgIDCardNum"], "91110000MA0001")
-        self.assertEqual(call_body["orgAuthConfig"]["orgInfo"]["legalRepName"], "张三")
-        self.assertEqual(call_body["orgAuthConfig"]["orgInfo"]["legalRepIDCardNum"], "110101199001011234")
-        self.assertTrue(call_body["transactorUseSeal"])
-        self.assertEqual(result, '{"code":0}')
-
-
-class TestGetTemplateDetail(unittest.TestCase):
-    """查询模板详情测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_calls_request_with_get(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        config = Config()
-
-        get_template_detail()
-
-        mock_request.assert_called_once_with("GET", f"/v3/doc-templates/{config.templates_id}")
-
-
-class TestFillInTemplate(unittest.TestCase):
-    """填写模板测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_store_agreement_components(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        config = Config()
-
-        fill_in_template("测试店铺", "store_agreement")
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(call_body["docTemplateId"], config.templates_map["store_agreement"])
-        self.assertEqual(call_body["fileName"], config.template_file_names["store_agreement"])
-
-        component_keys = {c["componentKey"] for c in call_body["components"]}
-        self.assertIn("store_name", component_keys)
-        self.assertIn("one_name", component_keys)
-        self.assertIn("date", component_keys)
-        self.assertIn("alien_name", component_keys)
-
-    @patch("common.esigntool.main._request")
-    def test_lawyer_agreement_components(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        config = Config()
-
-        fill_in_template("测试律所", "lawyer_agreement")
-
-        call_body = mock_request.call_args[0][2]
-        component_keys = {c["componentKey"] for c in call_body["components"]}
-        self.assertIn("law_firm_name", component_keys)
-        self.assertIn("law_firm_name_2", component_keys)
-        self.assertIn("signing_date", component_keys)
-
-    @patch("common.esigntool.main._request")
-    def test_default_contract_type(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        fill_in_template("测试")
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(call_body["fileName"], "U店在这-商户入驻协议")
-
-
-class TestGetContractDetail(unittest.TestCase):
-    """查询合同文件测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_calls_with_file_id(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        get_contract_detail("abc123")
-
-        mock_request.assert_called_once_with("GET", "/v3/files/abc123")
-
-
-class TestCreateByFile(unittest.TestCase):
-    """基于文件发起签署测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_store_agreement_has_both_signers(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        create_by_file(
-            "file_001", "测试协议", "测试公司", "91110000MA0001", "13800138000", "张三", "store_agreement"
-        )
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(len(call_body["signers"]), 2)
-        self.assertEqual(call_body["signFlowConfig"]["signFlowTitle"], "商家入驻U店平台协议签署")
-        self.assertEqual(call_body["docs"][0]["fileId"], "file_001")
-        self.assertEqual(call_body["docs"][0]["fileName"], "测试协议.pdf")
-
-    @patch("common.esigntool.main._request")
-    def test_wechat_only_signer(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        create_by_file(
-            "file_002", "承诺函", "测试公司", "91110000MA0002", "13900139000", "李四", "wechat_pay_commitment"
-        )
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(len(call_body["signers"]), 1)
-        self.assertEqual(call_body["signFlowConfig"]["signFlowTitle"], "微信支付承诺函签署")
-
-    @patch("common.esigntool.main._request")
-    def test_default_contract_type(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        create_by_file("file_003", "文件", "公司", "id001", "13800000000", "王五")
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(len(call_body["signers"]), 2)
-        self.assertEqual(call_body["signFlowConfig"]["signFlowTitle"], "商家入驻U店平台协议签署")
-
-    @patch("common.esigntool.main._request")
-    def test_auto_finish_enabled(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        create_by_file("f", "n", "c", "id", "phone", "name")
-
-        call_body = mock_request.call_args[0][2]
-        self.assertTrue(call_body["signFlowConfig"]["autoFinish"])
-        self.assertEqual(call_body["signFlowConfig"]["noticeConfig"]["noticeTypes"], "1,2")
-
-
-class TestSignUrl(unittest.TestCase):
-    """获取签署链接测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_calls_with_flow_id_and_account(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        sign_url("flow_abc", "13800138000")
-
-        call_body = mock_request.call_args[0][2]
-        self.assertEqual(call_body["signFlowId"], "flow_abc")
-        self.assertEqual(call_body["operator"]["psnAccount"], "13800138000")
-        self.assertFalse(call_body["needLogin"])
-        self.assertEqual(call_body["urlType"], 2)
-        self.assertEqual(call_body["clientType"], "ALL")
-
-
-class TestFileDownloadUrl(unittest.TestCase):
-    """下载文件链接测试"""
-
-    @patch("common.esigntool.main._request")
-    def test_calls_with_flow_id(self, mock_request):
-        mock_request.return_value = '{"code":0}'
-        file_download_url("flow_xyz")
-
-        mock_request.assert_called_once_with(
-            "POST", "/v3/sign-flow/flow_xyz/file-download-url", {"urlAvailableDate": "3600"}
-        )
-
-
-if __name__ == "__main__":
-    unittest.main()