Răsfoiți Sursa

Merge remote-tracking branch 'origin/dev' into uat

# Conflicts:
#	.env
dujian 3 săptămâni în urmă
părinte
comite
0604cf868c
62 a modificat fișierele cu 2604 adăugiri și 900 ștergeri
  1. 22 0
      .env.dev
  2. 149 0
      alembic.ini
  3. 1 0
      alembic/README
  4. 86 0
      alembic/env.py
  5. 28 0
      alembic/script.py.mako
  6. 49 0
      alembic/versions/984d88f91c0d_create_lawyer_contract_table.py
  7. 96 0
      alembic/versions/a7d4b3e21c10_create_contract_center_tables.py
  8. 22 0
      alien_contract/Dockerfile
  9. 1 0
      alien_contract/__init__.py
  10. 1 0
      alien_contract/api/__init__.py
  11. 9 0
      alien_contract/api/deps.py
  12. 66 0
      alien_contract/api/router.py
  13. 1 0
      alien_contract/db/__init__.py
  14. 3 0
      alien_contract/db/models/__init__.py
  15. 20 0
      alien_contract/db/models/bundle.py
  16. 26 0
      alien_contract/db/models/document.py
  17. 16 0
      alien_contract/db/models/event.py
  18. 1 0
      alien_contract/infrastructure/__init__.py
  19. 0 2
      alien_contract/infrastructure/esign/__init__.py
  20. 129 0
      alien_contract/infrastructure/esign/contract_builder.py
  21. 115 0
      alien_contract/infrastructure/esign/esign_algorithm.py
  22. 26 0
      alien_contract/infrastructure/esign/esign_config.py
  23. 201 0
      alien_contract/infrastructure/esign/main.py
  24. 22 0
      alien_contract/main.py
  25. 1 0
      alien_contract/repositories/__init__.py
  26. 145 0
      alien_contract/repositories/contract_repo.py
  27. 1 0
      alien_contract/schemas/__init__.py
  28. 1 0
      alien_contract/schemas/request/__init__.py
  29. 29 0
      alien_contract/schemas/request/contract.py
  30. 1 0
      alien_contract/schemas/response/__init__.py
  31. 67 0
      alien_contract/schemas/response/contract.py
  32. 1 0
      alien_contract/services/__init__.py
  33. 248 0
      alien_contract/services/contract_server.py
  34. 1 1
      alien_gateway/config.py
  35. 1 0
      alien_lawyer/api/__init__.py
  36. 14 0
      alien_lawyer/api/deps.py
  37. 105 0
      alien_lawyer/api/router.py
  38. 1 0
      alien_lawyer/db/__init__.py
  39. 1 0
      alien_lawyer/db/models/__init__.py
  40. 25 0
      alien_lawyer/db/models/lawyer_contract.py
  41. 1 0
      alien_lawyer/repositories/__init__.py
  42. 220 0
      alien_lawyer/repositories/contract_repo.py
  43. 1 7
      alien_lawyer/router.py
  44. 1 0
      alien_lawyer/schemas/__init__.py
  45. 1 0
      alien_lawyer/schemas/request/__init__.py
  46. 26 0
      alien_lawyer/schemas/request/contract_lawyer.py
  47. 1 0
      alien_lawyer/schemas/response/__init__.py
  48. 73 0
      alien_lawyer/schemas/response/contract_lawyer.py
  49. 1 0
      alien_lawyer/services/__init__.py
  50. 229 0
      alien_lawyer/services/contract_server.py
  51. 6 1
      alien_store/api/deps.py
  52. 39 268
      alien_store/api/router.py
  53. 1 0
      alien_store/main.py
  54. 3 0
      alien_store/schemas/response/contract_store.py
  55. 266 0
      alien_store/services/contract_server.py
  56. 0 211
      common/esigntool/esign_algorithm.py
  57. 0 14
      common/esigntool/esign_config.py
  58. 0 9
      common/esigntool/esign_emun.py
  59. 0 40
      common/esigntool/esign_file.py
  60. 0 14
      common/esigntool/esign_templates.py
  61. 0 332
      common/esigntool/main.py
  62. 3 1
      main.py

+ 22 - 0
.env.dev

@@ -0,0 +1,22 @@
+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"
+
+# 下游服务地址
+STORE_BASE_URL="http://127.0.0.1:8001"# alien_store 服务地址
+
+# 阿里云短信配置
+ALIYUN_SMS_SIGN_NAME_CONTRACT="爱丽恩严大连商务科技"
+ALIYUN_SMS_TEMPLATE_CODE_CONTRACT="SMS_501820309"
+ALIYUN_ACCESS_KEY_ID="LTAI5t77CS9gD7JMkMAjD2vF"
+ALIYUN_ACCESS_KEY_SECRET="jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8"

+ 149 - 0
alembic.ini

@@ -0,0 +1,149 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = %(here)s/alembic
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.  for multiple paths, the path separator
+# is defined by "path_separator" below.
+prepend_sys_path = .
+
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to <script_location>/versions.  When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini.  If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+#    "version_path_separator" key, which if absent then falls back to the legacy
+#    behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+#    behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+# database URL.  This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts.  See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration.  This is also consumed by the user-maintained
+# env.py script only.
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 1 - 0
alembic/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 86 - 0
alembic/env.py

@@ -0,0 +1,86 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+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.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# 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:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = settings.SQLALCHEMY_DATABASE_URI
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+    config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_DATABASE_URI)
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection, target_metadata=target_metadata, compare_type=True
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 28 - 0
alembic/script.py.mako

@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    """Upgrade schema."""
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    """Downgrade schema."""
+    ${downgrades if downgrades else "pass"}

+ 49 - 0
alembic/versions/984d88f91c0d_create_lawyer_contract_table.py

@@ -0,0 +1,49 @@
+"""create lawyer_contract table
+
+Revision ID: 984d88f91c0d
+Revises:
+Create Date: 2026-03-27 16:44:19.499604
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+revision: str = "984d88f91c0d"
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "lawyer_contract",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False, comment="主键"),
+        sa.Column("lawyer_id", sa.BigInteger(), nullable=False, comment="律所id"),
+        sa.Column("law_firm_name", sa.String(length=100), 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("signing_status", sa.String(length=20), nullable=False, server_default="未签署", comment="签署状态"),
+        sa.Column("contract_url", mysql.LONGTEXT(), nullable=False, comment="合同URL"),
+        sa.Column("ord_id", mysql.LONGTEXT(), nullable=False, comment="统一社会信用代码"),
+        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.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_lawyer_contract_lawyer_id", "lawyer_contract", ["lawyer_id"], unique=False)
+    op.create_index("idx_lawyer_contract_contact_phone", "lawyer_contract", ["contact_phone"], unique=False)
+    op.create_index("idx_lawyer_contract_signing_status", "lawyer_contract", ["signing_status"], unique=False)
+
+
+def downgrade() -> None:
+    op.drop_index("idx_lawyer_contract_signing_status", table_name="lawyer_contract")
+    op.drop_index("idx_lawyer_contract_contact_phone", table_name="lawyer_contract")
+    op.drop_index("idx_lawyer_contract_lawyer_id", table_name="lawyer_contract")
+    op.drop_table("lawyer_contract")

+ 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 *

+ 129 - 0
alien_contract/infrastructure/esign/contract_builder.py

@@ -0,0 +1,129 @@
+import json
+import logging
+import re
+import urllib.parse
+from typing import Any
+
+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):
+    def __init__(self, message: str, raw: Any):
+        super().__init__(message)
+        self.message = message
+        self.raw = raw
+
+
+def build_contract_items(
+    configs: list[tuple[str, str, int]],
+    template_name: str,
+    signer_name: str,
+    signer_id_num: str,
+    psn_account: str,
+    psn_name: str,
+) -> list[dict[str, Any]]:
+    items: list[dict[str, Any]] = []
+    for contract_type, contract_name, is_master in configs:
+        res_text = fill_in_template(template_name, contract_type=contract_type)
+        try:
+            res_data = json.loads(res_text)
+        except json.JSONDecodeError:
+            logger.error("fill_in_template non-json resp contract_type=%s: %s", contract_type, res_text)
+            raise ContractBuildError(
+                message=f"{contract_name}生成失败:e签宝返回非 JSON",
+                raw={"contract_type": contract_type, "resp": res_text},
+            )
+
+        try:
+            contract_url = res_data["data"]["fileDownloadUrl"]
+            file_id = res_data["data"]["fileId"]
+            m = re.search(r"/([^/]+)\.pdf", contract_url)
+            if m:
+                encoded_name = m.group(1)
+                file_name = urllib.parse.unquote(encoded_name)
+            else:
+                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=message,
+                raw={"contract_type": contract_type, "template_id": template_id, "resp": res_data},
+            )
+
+        sign_data = create_by_file(
+            file_id,
+            file_name,
+            signer_name,
+            signer_id_num,
+            psn_account,
+            psn_name,
+            contract_type=contract_type,
+        )
+        try:
+            sign_json = json.loads(sign_data)
+        except json.JSONDecodeError:
+            logger.error("create_by_file non-json resp contract_type=%s: %s", contract_type, sign_data)
+            raise ContractBuildError(
+                message=f"{contract_name}发起签署失败:e签宝返回非 JSON",
+                raw={"contract_type": contract_type, "resp": sign_data},
+            )
+
+        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=message,
+                raw={"contract_type": contract_type, "resp": sign_json},
+            )
+
+        sign_id = sign_json["data"].get("signFlowId")
+        if not sign_id:
+            logger.error("create_by_file missing signFlowId contract_type=%s: %s", contract_type, sign_json)
+            raise ContractBuildError(
+                message=f"{contract_name}发起签署失败:e签宝返回缺少 signFlowId",
+                raw={"contract_type": contract_type, "resp": sign_json},
+            )
+
+        items.append(
+            {
+                "contract_type": contract_type,
+                "contract_name": contract_name,
+                "contract_url": contract_url,
+                "file_name": file_name,
+                "file_id": file_id,
+                "status": 0,
+                "sign_flow_id": sign_id,
+                "sign_url": "",
+                "signing_time": "",
+                "effective_time": "",
+                "expiry_time": "",
+                "contract_download_url": "",
+                "is_master": is_master,
+            }
+        )
+    return items

+ 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}"}

+ 1 - 1
alien_gateway/config.py

@@ -110,7 +110,7 @@ class Settings(BaseSettings):
 
     model_config = SettingsConfigDict(
         case_sensitive=True,
-        env_file=".env",
+        env_file=".env.dev",
     )
 
 settings = Settings()

+ 1 - 0
alien_lawyer/api/__init__.py

@@ -0,0 +1 @@
+

+ 14 - 0
alien_lawyer/api/deps.py

@@ -0,0 +1,14 @@
+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)

+ 105 - 0
alien_lawyer/api/router.py

@@ -0,0 +1,105 @@
+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, 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,
+    TemplatesCreateResponse,
+    ErrorResponse,
+    PaginatedResponse,
+    SuccessResponse,
+)
+from alien_lawyer.services.contract_server import LawyerContractServer
+
+router = APIRouter()
+
+
+def _format_validation_errors(exc: ValidationError) -> list[dict[str, str]]:
+    errors = []
+    for err in exc.errors():
+        loc = err.get("loc", ())
+        field = ".".join(str(item) for item in loc if item != "body")
+        errors.append(
+            {
+                "field": field or "body",
+                "type": err.get("type", "validation_error"),
+                "message": err.get("msg", "参数校验失败"),
+            }
+        )
+    return errors
+
+
+@router.get("/", response_model=ModuleStatusResponse)
+async def index() -> ModuleStatusResponse:
+    return ModuleStatusResponse(module="LawyerContract", status="Ok")
+
+
+@router.post("/get_esign_templates", response_model=Union[TemplatesCreateResponse, ErrorResponse])
+async def create_esign_templates(
+    templates_data_raw: dict[str, Any],
+    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)})
+    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(
+        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)
+async def list_contracts(
+    lawyer_id: int,
+    status: Optional[int] = Query(None, description="筛选合同状态:0 未签署,1 已签署"),
+    page: int = Query(1, ge=1, description="页码,从1开始"),
+    page_size: int = Query(10, ge=1, le=100, description="每页条数,默认10"),
+    templates_server: LawyerContractServer = Depends(get_contract_service),
+) -> PaginatedResponse:
+    result = await templates_server.list_contracts(lawyer_id, status, page, page_size)
+    return PaginatedResponse(**result)
+
+
+@router.get("/contracts/detail/{sign_flow_id}", response_model=Union[dict, ErrorResponse])
+async def get_contract_detail(
+    sign_flow_id: str,
+    templates_server: LawyerContractServer = Depends(get_contract_service),
+) -> Union[dict, ErrorResponse]:
+    result = await templates_server.get_contract_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,
+    templates_server: LawyerContractServer = Depends(get_contract_service),
+) -> Union[SuccessResponse, ErrorResponse]:
+    result = await templates_server.process_esign_callback(payload)
+    if not result.get("success"):
+        return ErrorResponse(**result)
+    return SuccessResponse(code=result["code"], msg=result["msg"])

+ 1 - 0
alien_lawyer/db/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
alien_lawyer/db/models/__init__.py

@@ -0,0 +1 @@
+from .lawyer_contract import LawyerContract

+ 25 - 0
alien_lawyer/db/models/lawyer_contract.py

@@ -0,0 +1,25 @@
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import String, DateTime, BigInteger
+from sqlalchemy.dialects.mysql import LONGTEXT
+from sqlalchemy.orm import Mapped, mapped_column
+
+from alien_database.base import Base, AuditMixin
+
+
+class LawyerContract(Base, AuditMixin):
+    __tablename__ = "lawyer_contract"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True, comment="主键")
+    lawyer_id: Mapped[int] = mapped_column(BigInteger, comment="律所id")
+    law_firm_name: Mapped[str] = mapped_column(String(100), 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="联系电话")
+    signing_status: Mapped[str] = mapped_column(String(20), default="未签署", comment="签署状态")
+    contract_url: Mapped[str] = mapped_column(LONGTEXT, comment="合同URL")
+    ord_id: Mapped[str] = mapped_column(LONGTEXT, comment="统一社会信用代码")
+    signing_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="签署时间")
+    effective_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="生效时间")
+    expiry_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="到期时间")

+ 1 - 0
alien_lawyer/repositories/__init__.py

@@ -0,0 +1 @@
+

+ 220 - 0
alien_lawyer/repositories/contract_repo.py

@@ -0,0 +1,220 @@
+import json
+import logging
+from datetime import datetime, timedelta
+
+from sqlalchemy.exc import DBAPIError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from alien_lawyer.db.models.lawyer_contract import LawyerContract
+
+logger = logging.getLogger(__name__)
+
+
+class LawyerContractRepository:
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def _execute_with_retry(self, statement):
+        try:
+            return await self.db.execute(statement)
+        except Exception as exc:
+            if self._should_retry(exc):
+                logger.warning("DB connection invalidated, retrying once: %s", exc)
+                try:
+                    await self.db.rollback()
+                except Exception:
+                    pass
+                return await self.db.execute(statement)
+            raise
+
+    @staticmethod
+    def _should_retry(exc: Exception) -> bool:
+        if isinstance(exc, DBAPIError) and getattr(exc, "connection_invalidated", False):
+            return True
+        txt = str(exc).lower()
+        closed_keywords = ["closed", "lost connection", "connection was killed", "terminat"]
+        return any(k in txt for k in closed_keywords)
+
+    async def get_by_lawyer_id(self, lawyer_id: int):
+        result = await self._execute_with_retry(
+            LawyerContract.__table__.select().where(LawyerContract.lawyer_id == lawyer_id)
+        )
+        return [dict(row) for row in result.mappings().all()]
+
+    async def get_contract_item_by_sign_flow_id(self, sign_flow_id: str):
+        result = await self._execute_with_retry(LawyerContract.__table__.select())
+        rows = result.mappings().all()
+        for row in rows:
+            contract_url_raw = row.get("contract_url")
+            if not contract_url_raw:
+                continue
+            try:
+                items = json.loads(contract_url_raw)
+            except Exception:
+                items = None
+            if not isinstance(items, list):
+                continue
+            for item in items:
+                if item.get("sign_flow_id") == sign_flow_id:
+                    return dict(row), item, items
+        return None, None, None
+
+    async def update_contract_items(self, row_id: int, items: list) -> bool:
+        if not isinstance(items, list):
+            return False
+        await self._execute_with_retry(
+            LawyerContract.__table__.update()
+            .where(LawyerContract.id == row_id)
+            .values(contract_url=json.dumps(items, ensure_ascii=False))
+        )
+        await self.db.commit()
+        return True
+
+    async def mark_signed_by_phone(
+        self,
+        contact_phone: str,
+        sign_flow_id: str,
+        signing_time: datetime | None = None,
+        contract_download_url: str | None = None,
+    ):
+        result = await self._execute_with_retry(
+            LawyerContract.__table__.select().where(LawyerContract.contact_phone == contact_phone)
+        )
+        rows = result.mappings().all()
+        updated = False
+        for row in rows:
+            contract_url_raw = row.get("contract_url")
+            items = None
+            if contract_url_raw:
+                try:
+                    items = json.loads(contract_url_raw)
+                except Exception:
+                    items = None
+            changed = False
+            matched_item = None
+            if isinstance(items, list):
+                for item in items:
+                    if item.get("sign_flow_id") == sign_flow_id:
+                        item["status"] = 1
+                        if contract_download_url:
+                            item["contract_download_url"] = contract_download_url
+                        matched_item = item
+                        changed = True
+                        break
+
+            if changed and matched_item and matched_item.get("is_master") == 1:
+                signing_dt = signing_time
+                effective_dt = expiry_dt = None
+                if signing_dt:
+                    effective_dt = (signing_dt + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
+                    expiry_dt = effective_dt + timedelta(days=365)
+                    matched_item["signing_time"] = signing_dt.strftime("%Y-%m-%d %H:%M:%S")
+                    matched_item["effective_time"] = effective_dt.strftime("%Y-%m-%d %H:%M:%S")
+                    matched_item["expiry_time"] = expiry_dt.strftime("%Y-%m-%d %H:%M:%S")
+
+                await self._execute_with_retry(
+                    LawyerContract.__table__.update()
+                    .where(LawyerContract.id == row["id"])
+                    .values(
+                        signing_status="已签署",
+                        contract_url=json.dumps(items, ensure_ascii=False) if items else contract_url_raw,
+                        signing_time=signing_dt,
+                        effective_time=effective_dt,
+                        expiry_time=expiry_dt,
+                    )
+                )
+                updated = True
+            elif changed:
+                await self._execute_with_retry(
+                    LawyerContract.__table__.update()
+                    .where(LawyerContract.id == row["id"])
+                    .values(contract_url=json.dumps(items, ensure_ascii=False) if items else contract_url_raw)
+                )
+                updated = True
+        if updated:
+            await self.db.commit()
+        return updated
+
+    async def update_sign_url(self, contact_phone: str, sign_flow_id: str, sign_url: str):
+        result = await self._execute_with_retry(
+            LawyerContract.__table__.select().where(LawyerContract.contact_phone == contact_phone)
+        )
+        rows = result.mappings().all()
+        updated = False
+        for row in rows:
+            contract_url_raw = row.get("contract_url")
+            if not contract_url_raw:
+                continue
+            try:
+                items = json.loads(contract_url_raw)
+            except Exception:
+                items = None
+            if not isinstance(items, list):
+                continue
+            changed = False
+            for item in items:
+                if item.get("sign_flow_id") == sign_flow_id:
+                    item["sign_url"] = sign_url
+                    changed = True
+            if changed:
+                await self._execute_with_retry(
+                    LawyerContract.__table__.update()
+                    .where(LawyerContract.id == row["id"])
+                    .values(contract_url=json.dumps(items, ensure_ascii=False))
+                )
+                updated = True
+        if updated:
+            await self.db.commit()
+        return updated
+
+    async def append_contract_url(self, templates_data, contract_item: dict):
+        lawyer_id = getattr(templates_data, "lawyer_id", None)
+        if lawyer_id is None:
+            return False
+
+        result = await self._execute_with_retry(
+            LawyerContract.__table__.select().where(LawyerContract.lawyer_id == lawyer_id)
+        )
+        rows = result.mappings().all()
+        updated = False
+        law_firm_name = getattr(templates_data, "law_firm_name", None)
+        if rows:
+            for row in rows:
+                contract_url_raw = row.get("contract_url")
+                try:
+                    items = json.loads(contract_url_raw) if contract_url_raw else []
+                except Exception:
+                    items = []
+                if not isinstance(items, list):
+                    items = []
+                items.append(contract_item)
+                update_values = {"contract_url": json.dumps(items, ensure_ascii=False)}
+                if law_firm_name:
+                    update_values["law_firm_name"] = law_firm_name
+                contact_phone = getattr(templates_data, "contact_phone", None)
+                if contact_phone:
+                    update_values["contact_phone"] = contact_phone
+                await self._execute_with_retry(
+                    LawyerContract.__table__.update()
+                    .where(LawyerContract.id == row["id"])
+                    .values(**update_values)
+                )
+                updated = True
+            if updated:
+                await self.db.commit()
+            return updated
+
+        new_record = LawyerContract(
+            lawyer_id=lawyer_id,
+            law_firm_name=law_firm_name,
+            business_segment=getattr(templates_data, "business_segment", None),
+            contact_name=getattr(templates_data, "contact_name", None),
+            contact_phone=getattr(templates_data, "contact_phone", None),
+            contract_url=json.dumps([contract_item], ensure_ascii=False),
+            ord_id=getattr(templates_data, "ord_id", None),
+            signing_status="未签署",
+        )
+        self.db.add(new_record)
+        await self.db.commit()
+        await self.db.refresh(new_record)
+        return True

+ 1 - 7
alien_lawyer/router.py

@@ -1,7 +1 @@
-from fastapi import APIRouter
-
-router = APIRouter()
-
-@router.get("/")
-async def index():
-    return {"module": "alien_lawyer", "status": "initialized"}
+from alien_lawyer.api.router import router

+ 1 - 0
alien_lawyer/schemas/__init__.py

@@ -0,0 +1 @@
+

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

@@ -0,0 +1 @@
+

+ 26 - 0
alien_lawyer/schemas/request/contract_lawyer.py

@@ -0,0 +1,26 @@
+import re
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class LawyerTemplatesCreate(BaseModel):
+    lawyer_id: int = Field(gt=0, description="律所ID")
+    law_firm_name: str = Field(description="律所名称")
+    business_segment: str = Field(description="业务板块")
+    contact_name: str = Field(description="联系人姓名")
+    contact_phone: str = Field(description="联系电话")
+    ord_id: str = Field(description="统一社会信用代码")
+
+    @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_lawyer/schemas/response/__init__.py

@@ -0,0 +1 @@
+

+ 73 - 0
alien_lawyer/schemas/response/contract_lawyer.py

@@ -0,0 +1,73 @@
+from datetime import datetime
+from typing import Optional, List, Any
+
+from pydantic import BaseModel, Field
+
+
+class ContractItemResponse(BaseModel):
+    contract_url: str = Field(description="合同模板链接")
+    file_name: str = Field(description="文件名称")
+    file_id: str = Field(description="文件ID")
+    status: int = Field(description="签署状态")
+    sign_flow_id: str = Field(description="签署流程ID")
+    sign_url: str = Field(default="", description="签署页面")
+    signing_time: str = Field(default="", description="签署时间")
+    effective_time: str = Field(default="", description="生效时间")
+    expiry_time: str = Field(default="", description="到期时间")
+    contract_download_url: str = Field(default="", description="已签下载链接")
+    is_master: int = Field(description="主合同标识")
+    contract_type: Optional[str] = Field(default=None, description="合同类型")
+    contract_name: Optional[str] = Field(default=None, description="合同名称")
+
+
+class LawyerContractResponse(BaseModel):
+    id: int = Field(description="主键")
+    lawyer_id: int = Field(description="律所ID")
+    law_firm_name: str = Field(description="律所名称")
+    business_segment: str = Field(description="业务板块")
+    contact_name: str = Field(description="联系人姓名")
+    contact_phone: str = Field(description="联系电话")
+    signing_status: str = Field(description="签署状态")
+    contract_url: str = Field(description="合同URL(JSON字符串)")
+    ord_id: 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="到期时间")
+    created_time: Optional[datetime] = Field(default=None, description="创建时间")
+    updated_time: Optional[datetime] = Field(default=None, description="更新时间")
+
+    class Config:
+        from_attributes = True
+
+
+class TemplatesCreateResponse(BaseModel):
+    success: bool = Field(description="是否成功")
+    message: str = Field(description="响应消息")
+    sign_flow_id: Optional[str] = Field(default=None, description="签署流程ID")
+    file_id: Optional[str] = Field(default=None, description="文件ID")
+    contract_url: Optional[str] = Field(default=None, description="合同URL")
+    created_contracts: Optional[List[dict]] = Field(default=None, description="本次创建的合同列表")
+
+
+class ErrorResponse(BaseModel):
+    success: bool = Field(default=False, description="是否成功")
+    message: str = Field(description="错误消息")
+    raw: Optional[Any] = Field(default=None, description="原始错误数据")
+
+
+class SuccessResponse(BaseModel):
+    code: str = Field(description="响应代码")
+    msg: str = Field(description="响应消息")
+
+
+class PaginatedResponse(BaseModel):
+    items: List[dict] = 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 = Field(description="模块名称")
+    status: str = Field(description="状态")

+ 1 - 0
alien_lawyer/services/__init__.py

@@ -0,0 +1 @@
+

+ 229 - 0
alien_lawyer/services/contract_server.py

@@ -0,0 +1,229 @@
+import datetime
+import json
+import logging
+import os
+from typing import Optional, Any
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from alien_lawyer.repositories.contract_repo import LawyerContractRepository
+from alien_lawyer.schemas.request.contract_lawyer import LawyerTemplatesCreate
+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)
+
+
+def _init_logger():
+    logger = logging.getLogger("alien_lawyer_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()
+
+LAWYER_CONTRACT_CREATE_CONFIGS = [
+    ("lawyer_agreement", "律所入驻协议", 1),
+    ("alipay_auth", "支付宝授权函", 0),
+    ("wechat_pay_commitment", "微信支付承诺函", 0),
+]
+
+
+class LawyerContractServer:
+    def __init__(self, db: AsyncSession):
+        self.db = db
+        self.repo = LawyerContractRepository(db)
+
+    async def create_esign_templates(self, templates_data: LawyerTemplatesCreate) -> dict:
+        logger.info("create lawyer esign templates request: %s", templates_data)
+        try:
+            generated_contracts = build_contract_items(
+                configs=LAWYER_CONTRACT_CREATE_CONFIGS,
+                template_name=templates_data.law_firm_name,
+                signer_name=templates_data.law_firm_name,
+                signer_id_num=templates_data.ord_id,
+                psn_account=templates_data.contact_phone,
+                psn_name=templates_data.contact_name,
+            )
+        except ContractBuildError as exc:
+            return {"success": False, "message": exc.message, "raw": exc.raw}
+
+        for contract_item in generated_contracts:
+            await self.repo.append_contract_url(templates_data, contract_item)
+
+        master_contract = next((item for item in generated_contracts if item.get("is_master") == 1), generated_contracts[0])
+        return {
+            "success": True,
+            "message": "律所合同模板已追加/创建",
+            "sign_flow_id": master_contract.get("sign_flow_id"),
+            "file_id": master_contract.get("file_id"),
+            "contract_url": master_contract.get("contract_url"),
+            "created_contracts": [
+                {
+                    "contract_type": item["contract_type"],
+                    "contract_name": item["contract_name"],
+                    "sign_flow_id": item["sign_flow_id"],
+                    "file_id": item["file_id"],
+                    "contract_url": item["contract_url"],
+                }
+                for item in generated_contracts
+            ],
+        }
+
+    async def list_contracts(self, lawyer_id: int, status: Optional[int], page: int, page_size: int) -> dict:
+        rows = await self.repo.get_by_lawyer_id(lawyer_id)
+        all_filtered_items: list[dict[str, Any]] = []
+        for row in rows:
+            contract_url_raw = row.get("contract_url")
+            if not contract_url_raw:
+                continue
+            try:
+                items = json.loads(contract_url_raw)
+                if not isinstance(items, list):
+                    continue
+                for item in items:
+                    if status is not None and item.get("status") != status:
+                        continue
+                    item_with_info = dict(item)
+                    item_with_info.update(
+                        {
+                            "id": row.get("id"),
+                            "lawyer_id": row.get("lawyer_id"),
+                            "law_firm_name": row.get("law_firm_name"),
+                            "contact_name": row.get("contact_name"),
+                            "contact_phone": row.get("contact_phone"),
+                        }
+                    )
+                    all_filtered_items.append(item_with_info)
+            except Exception:
+                continue
+
+        total = len(all_filtered_items)
+        start = (page - 1) * page_size
+        end = start + page_size
+        paged_items = all_filtered_items[start:end]
+        total_pages = (total + page_size - 1) // page_size if total > 0 else 0
+        return {
+            "items": paged_items,
+            "total": total,
+            "page": page,
+            "page_size": page_size,
+            "total_pages": total_pages,
+        }
+
+    async def get_contract_detail(self, sign_flow_id: str) -> dict:
+        row, item, items = await self.repo.get_contract_item_by_sign_flow_id(sign_flow_id)
+        if not item:
+            return {"success": False, "message": "未找到合同"}
+        status = item.get("status")
+        if status == 0:
+            return await self._get_pending_contract_detail(sign_flow_id, row, item, items)
+        if status == 1:
+            return await self._get_signed_contract_detail(sign_flow_id, row, item, items)
+        return {"success": False, "message": "未知合同状态", "raw": {"status": status}}
+
+    async def _get_pending_contract_detail(self, sign_flow_id: str, row, item, items) -> dict:
+        file_id = item.get("file_id")
+        if not file_id:
+            return {"success": False, "message": "缺少 file_id,无法获取合同详情"}
+        try:
+            detail_resp = esign_main.get_contract_detail(file_id)
+            detail_json = json.loads(detail_resp)
+            data = detail_json.get("data") if isinstance(detail_json, dict) else None
+            contract_url_val = None
+            if isinstance(data, dict):
+                contract_url_val = data.get("fileDownloadUrl")
+            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 row and isinstance(items, list):
+            for it in items:
+                if it.get("sign_flow_id") == sign_flow_id:
+                    it["contract_url"] = contract_url_val
+                    break
+            await self.repo.update_contract_items(row["id"], items)
+
+        contact_phone = item.get("contact_phone") or (row.get("contact_phone") if isinstance(row, dict) else None)
+        if not contact_phone:
+            return {"success": False, "message": "缺少 contact_phone,无法获取签署链接"}
+        try:
+            sign_resp = sign_url(sign_flow_id, 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_sign_url(contact_phone, sign_flow_id, result_sign_url)
+        return {
+            "status": 0,
+            "contract_url": contract_url_val,
+            "sign_url": result_sign_url,
+            "sign_flow_id": sign_flow_id,
+        }
+
+    async def _get_signed_contract_detail(self, sign_flow_id: str, row, item, items) -> dict:
+        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 as exc:
+            return {"success": False, "message": "获取合同下载链接失败", "raw": str(exc)}
+
+        if row and isinstance(items, list):
+            for it in items:
+                if it.get("sign_flow_id") == sign_flow_id:
+                    it["contract_download_url"] = contract_download_url
+                    it["contract_url"] = contract_download_url
+                    break
+            await self.repo.update_contract_items(row["id"], items)
+
+        return {
+            "status": 1,
+            "contract_url": contract_download_url,
+            "contract_download_url": contract_download_url,
+            "sign_flow_id": sign_flow_id,
+        }
+
+    async def process_esign_callback(self, payload: dict) -> dict:
+        sign_result = payload.get("signResult")
+        sign_flow_id = payload.get("signFlowId")
+        operator = payload.get("operator") or {}
+        psn_account = operator.get("psnAccount") or {}
+        contact_phone = psn_account.get("accountMobile")
+        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
+
+        if sign_result == 2:
+            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_signed_by_phone(contact_phone, sign_flow_id, signing_dt, contract_download_url)
+            return {"success": True, "code": "200", "msg": "success"}
+        return {"success": False, "message": "未处理: signResult!=2 或手机号/签署流程缺失"}

+ 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)

+ 39 - 268
alien_store/api/router.py

@@ -1,10 +1,12 @@
 import datetime
-from fastapi import APIRouter, Depends, Query
-from typing import Any,  Union, Optional
-import os
 import logging
+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,
@@ -15,35 +17,9 @@ from alien_store.schemas.response.contract_store import (
     SuccessResponse
 )
 from alien_store.services.contract_server import ContractServer
-from common.esigntool.main import *
-from common.esigntool import main as esign_main
-import re, urllib.parse
-
-# ------------------- 日志配置 -------------------
-LOG_DIR = os.path.join("common", "logs", "alien_store")
-os.makedirs(LOG_DIR, exist_ok=True)
-
-def _init_logger():
-    logger = logging.getLogger("alien_store")
-    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)
-    # 控制台可选: logger.addHandler(logging.StreamHandler())
-    return logger
-
-logger = _init_logger()
 
 router = APIRouter()
-
+logger = logging.getLogger("alien_store")
 
 def _format_validation_errors(exc: ValidationError) -> list[dict[str, str]]:
     errors = []
@@ -65,12 +41,12 @@ async def index() -> ModuleStatusResponse:
 
 @router.post("/get_esign_templates", response_model=Union[TemplatesCreateResponse, ErrorResponse])
 async def create_esign_templates(
-    templates_data: dict[str, Any],
-    templates_server: ContractServer = Depends(get_contract_service)
+    templates_data_raw: dict[str, Any],
+    templates_server: ContractCenterService = Depends(get_contract_center_service)
 ) -> Union[TemplatesCreateResponse, ErrorResponse]:
     """AI审核完调用 e签宝生成文件"""
     try:
-        templates_data = TemplatesCreate.model_validate(templates_data)
+        templates_data = TemplatesCreate.model_validate(templates_data_raw)
     except ValidationError as e:
         detail = _format_validation_errors(e)
         logger.error("get_esign_templates validation failed: %s", detail)
@@ -80,65 +56,26 @@ async def create_esign_templates(
             raw={"errors": detail},
         )
 
-    logger.info(f"get_esign_templates request: {templates_data}")
-    # res_text = fill_in_template(templates_data.merchant_name)
-    res_text = fill_in_template(templates_data.store_name)
-    try:
-        res_data = json.loads(res_text)
-    except json.JSONDecodeError:
-        logger.error(f"fill_in_template non-json resp: {res_text}")
-        return ErrorResponse(success=False, message="e签宝返回非 JSON", raw=res_text)
-    # 从返回结构提取下载链接,需与实际返回字段匹配
-    try:
-        contract_url = res_data["data"]["fileDownloadUrl"]
-        file_id = res_data["data"]["fileId"]
-        m = re.search(r'/([^/]+)\.pdf', contract_url)
-        if m:
-            encoded_name = m.group(1)
-            file_name = urllib.parse.unquote(encoded_name)
-    except Exception:
-        logger.error(f"fill_in_template missing fileDownloadUrl: {res_data}")
-        return ErrorResponse(success=False, message="e签宝返回缺少 fileDownloadUrl", raw=res_data)
-
-    # sign_data = create_by_file(file_id, file_name, templates_data.contact_phone, templates_data.merchant_name)
-    sign_data = create_by_file(file_id, file_name, templates_data.contact_phone, templates_data.store_name, templates_data.merchant_name, templates_data.ord_id)
-    print(sign_data)
-    try:
-        sign_json = json.loads(sign_data)
-    except json.JSONDecodeError:
-        logger.error(f"create_by_file non-json resp: {sign_data}")
-        return ErrorResponse(success=False, message="e签宝 create_by_file 返回非 JSON", raw=sign_data)
-
-    if not sign_json.get("data"):
-        logger.error(f"create_by_file failed or missing data: {sign_json}")
-        return ErrorResponse(success=False, message="e签宝创建签署流程失败", raw=sign_json)
-
-    sing_id = sign_json["data"].get("signFlowId")
-    if not sing_id:
-        logger.error(f"create_by_file missing signFlowId: {sign_json}")
-        return ErrorResponse(success=False, message="e签宝返回缺少 signFlowId", raw=sign_json)
-
-    result_contract = {
-        "contract_url": contract_url, # 合同模版链接
-        "file_name": file_name, # 签署的合同的文件名称
-        "file_id": file_id, # 生成的文件ID
-        "status": 0, # 签署状态 0 未签署 1 已签署
-        "sign_flow_id": sing_id, # 从
-        "sign_url": "", # e签宝生成的签署页面
-        "signing_time": "", # 签署合同的时间
-        "effective_time": "", # 合同生效的时间
-        "expiry_time": "", # 合同失效的时间
-        "contract_download_url": "", # 合同签署完成后 下载文件的链接
-        "is_master": 1 # 是否是入驻店铺的协议合同 是 1 否 0
-    }
-    updated = await templates_server.append_contract_url(templates_data, result_contract)
-    logger.info(f"get_esign_templates success contact_phone={templates_data.contact_phone}, sign_flow_id={sing_id}")
+    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(
         success=True,
-        message="合同模板已追加/创建",
-        sign_flow_id=sing_id,
-        file_id=file_id,
-        contract_url=contract_url
+        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])
@@ -150,68 +87,7 @@ async def list_contracts(
     templates_server: ContractServer = Depends(get_contract_service)
 ) -> Any:
     """根据 store_id 查询所有合同,支持根据 status 筛选和分页"""
-    logger.info(
-        "list_contracts request store_id=%s status=%s page=%s page_size=%s",
-        store_id,
-        status,
-        page,
-        page_size,
-    )
-    try:
-        # 1. 检查 store_info 中的审核状态
-        reason = await templates_server.get_store_reason(store_id)
-        if reason != "审核通过":
-            return {"code": 555, "msg": "先进行认证", "reason": reason}
-
-        # 2. 返回合同列表
-        rows = await templates_server.list_by_store(store_id)
-        
-        all_filtered_items = []
-        # 3. 解析并筛选所有符合条件的合同项
-        for row in rows:
-            contract_url_raw = row.get("contract_url")
-            if not contract_url_raw:
-                continue
-            try:
-                items = json.loads(contract_url_raw)
-                if not isinstance(items, list):
-                    continue
-                
-                for item in items:
-                    # 如果传了 status,则进行筛选
-                    if status is not None and item.get("status") != status:
-                        continue
-                    
-                    # 将店铺基础信息混入每个合同项中,方便前端展示
-                    item_with_info = dict(item)
-                    item_with_info["id"] = row.get("id")
-                    item_with_info["store_id"] = row.get("store_id")
-                    item_with_info["store_name"] = row.get("store_name")
-                    item_with_info["merchant_name"] = row.get("merchant_name")
-                    item_with_info["contact_phone"] = row.get("contact_phone")
-                    all_filtered_items.append(item_with_info)
-            except Exception as e:
-                logger.error(f"Error processing contracts for store_id {store_id}: {e}", exc_info=True)
-                continue
-
-        # 4. 手动分页
-        total = len(all_filtered_items)
-        start = (page - 1) * page_size
-        end = start + page_size
-        paged_items = all_filtered_items[start:end]
-        
-        total_pages = (total + page_size - 1) // page_size if total > 0 else 0
-
-        return {
-            "items": paged_items,
-            "total": total,
-            "page": page,
-            "page_size": page_size,
-            "total_pages": total_pages
-        }
-    except Exception as e:
-        logger.error(f"list_contracts failed store_id={store_id}: {e}", exc_info=True)
-        return {"code": 500, "msg": "查询合同失败", "error": str(e)}
+    return await templates_server.list_contracts(store_id, status, page, page_size)
 
 @router.get("/contracts/detail/{sign_flow_id}", response_model=Union[dict, ErrorResponse])
 async def get_contract_detail(
@@ -223,91 +99,10 @@ async def get_contract_detail(
     - status=0: 返回合同PDF链接(contract_url)和签署链接(sign_url)
     - status=1: 拉取最新下载链接并更新数据库,返回 contract_download_url
     """
-    row, item, items = await templates_server.get_contract_item_by_sign_flow_id(sign_flow_id)
-    if not item:
-        return ErrorResponse(success=False, message="未找到合同")
-
-    status = item.get("status")
-    if status == 0:
-        file_id = item.get("file_id")
-        if not file_id:
-            return ErrorResponse(success=False, message="缺少 file_id,无法获取合同详情")
-        try:
-            detail_resp = esign_main.get_contract_detail(file_id)
-            detail_json = json.loads(detail_resp)
-            data = detail_json.get("data") if isinstance(detail_json, dict) else None
-            contract_url = None
-            if isinstance(data, dict):
-                contract_url = data.get("fileDownloadUrl")
-            if not contract_url and isinstance(detail_json, dict):
-                contract_url = detail_json.get("fileDownloadUrl")
-        except Exception as e:
-            logger.error(f"get_contract_detail failed file_id={file_id}: {e}")
-            return ErrorResponse(success=False, message="获取合同链接失败", raw=str(e))
-
-        if not contract_url:
-            logger.error(f"get_contract_detail missing contract_url file_id={file_id}: {detail_resp}")
-            return ErrorResponse(success=False, message="e签宝返回缺少合同链接", raw=detail_resp)
-
-        if row and isinstance(items, list):
-            for it in items:
-                if it.get("sign_flow_id") == sign_flow_id:
-                    it["contract_url"] = contract_url
-                    break
-            await templates_server.update_contract_items(row["id"], items)
-
-        # 融合原 /esign/signurl 逻辑:调用 e签宝 获取签署链接并落库
-        contact_phone = item.get("contact_phone") or (row.get("contact_phone") if isinstance(row, dict) else None)
-        if not contact_phone:
-            return ErrorResponse(success=False, message="缺少 contact_phone,无法获取签署链接")
-        try:
-            sign_resp = sign_url(sign_flow_id, contact_phone)
-            sign_json = json.loads(sign_resp)
-        except json.JSONDecodeError:
-            logger.error(f"sign_url non-json resp: {sign_resp}")
-            return ErrorResponse(success=False, message="e签宝返回非JSON", raw=sign_resp)
-        except Exception as e:
-            logger.error(f"sign_url failed sign_flow_id={sign_flow_id}, contact_phone={contact_phone}: {e}")
-            return ErrorResponse(success=False, message="获取签署链接失败", raw=str(e))
-
-        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
-        if not result_sign_url:
-            logger.error(f"sign_url missing url: {sign_json}")
-            return ErrorResponse(success=False, message="e签宝返回缺少签署链接", raw=sign_json)
-        await templates_server.update_sign_url(contact_phone, sign_flow_id, result_sign_url)
-
-        return {
-            "status": 0,
-            "contract_url": contract_url,
-            "sign_url": result_sign_url,
-            "sign_flow_id": sign_flow_id
-        }
-
-    if status == 1:
-        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 as e:
-            logger.error(f"file_download_url failed sign_flow_id={sign_flow_id}: {e}")
-            return ErrorResponse(success=False, message="获取合同下载链接失败", raw=str(e))
-        if row and isinstance(items, list):
-            for it in items:
-                if it.get("sign_flow_id") == sign_flow_id:
-                    it["contract_download_url"] = contract_download_url
-                    it["contract_url"] = contract_download_url  # 与 status=0 一致,用 file_download_url 更新最新 contract_url
-                    break
-            await templates_server.update_contract_items(row["id"], items)
-
-        return {
-            "status": 1,
-            "contract_url": contract_download_url,
-            "contract_download_url": contract_download_url,
-            "sign_flow_id": sign_flow_id
-        }
-
-    return ErrorResponse(success=False, message="未知合同状态", raw={"status": status})
+    result = await templates_server.get_contract_detail(sign_flow_id)
+    if not result.get("success", True): # get_contract_detail 返回的成功结果里没有 success 键,只有 status
+        return ErrorResponse(**result)
+    return result
 
 @router.get("/get_all_templates", response_model=PaginatedResponse)
 async def get_all_templates(
@@ -318,8 +113,8 @@ async def get_all_templates(
     signing_status: Optional[str] = Query(None, description="签署状态"),
     business_segment: Optional[str] = Query(None, description="经营板块"),
     store_status: Optional[str] = Query(None, description="店铺状态:正常/禁用"),
-    expiry_start: Optional[datetime] = Query(None, description="到期时间起"),
-    expiry_end: Optional[datetime] = Query(None, description="到期时间止"),
+    expiry_start: Optional[datetime.datetime] = Query(None, description="到期时间起"),
+    expiry_end: Optional[datetime.datetime] = Query(None, description="到期时间止"),
     templates_server: ContractServer = Depends(get_contract_service)
 ) -> PaginatedResponse:
     """分页查询所有合同,支持筛选"""
@@ -354,34 +149,10 @@ async def esign_callback(
     e签宝签署结果回调
     需求:签署完成 -> 更新 signing_status=已签署,contract_url 中 status=1
     """
-    sign_result = payload.get("signResult")
-    operator = payload.get("operator") or {}
-    sign_flow_id = payload.get("signFlowId")
-    psn_account = operator.get("psnAccount") or {}
-    contact_phone = psn_account.get("accountMobile")
-    # 取回调中的毫秒时间戳,优先 operateTime,其次 timestamp
-    ts_ms = payload.get("operateTime") or payload.get("timestamp")
-    signing_dt = None
-    if ts_ms:
-        try:
-            signing_dt = datetime.fromtimestamp(ts_ms / 1000)
-        except Exception:
-            signing_dt = None
-
-    if sign_result == 2:
-        # 获取合同下载链接
-        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 as e:
-            logger.error(f"file_download_url failed for sign_flow_id={sign_flow_id}: {e}")
-        updated = await templates_server.mark_signed_by_phone(contact_phone, sign_flow_id, signing_dt, contract_download_url)
-        logger.info(f"esign_callback success phone={contact_phone}, sign_flow_id={sign_flow_id}, updated={updated}")
-        return SuccessResponse(code="200", msg="success")
-    logger.error(f"esign_callback ignored payload: {payload}")
-    return ErrorResponse(success=False, message="未处理: signResult!=2 或手机号/签署流程缺失")
+    result = await templates_server.process_esign_callback(payload)
+    if not result.get("success"):
+        return ErrorResponse(**result)
+    return SuccessResponse(code=result["code"], msg=result["msg"])
 
 
 

+ 1 - 0
alien_store/main.py

@@ -25,6 +25,7 @@ logger = logging.getLogger("alien_store")
 @app.middleware("http")
 async def log_requests(request: Request, call_next):
     start = time.perf_counter()
+    response = None
     try:
         response = await call_next(request)
         return response

+ 3 - 0
alien_store/schemas/response/contract_store.py

@@ -16,6 +16,8 @@ class ContractItemResponse(BaseModel):
     expiry_time: str = Field(default="", description="合同失效的时间")
     contract_download_url: str = Field(default="", description="合同签署完成后下载文件的链接")
     is_master: int = Field(description="是否是入驻店铺的协议合同 是 1 否 0")
+    contract_type: Optional[str] = Field(default=None, description="合同类型")
+    contract_name: Optional[str] = Field(default=None, description="合同名称")
 
 
 class ContractStoreResponse(BaseModel):
@@ -64,6 +66,7 @@ class TemplatesCreateResponse(BaseModel):
     sign_flow_id: Optional[str] = Field(default=None, description="签署流程ID")
     file_id: Optional[str] = Field(default=None, description="文件ID")
     contract_url: Optional[str] = Field(default=None, description="合同URL")
+    created_contracts: Optional[List[dict]] = Field(default=None, description="本次创建的合同列表")
 
 
 class SignUrlResponse(BaseModel):

+ 266 - 0
alien_store/services/contract_server.py

@@ -1,6 +1,43 @@
+import datetime
+import os
+import logging
+import json
+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 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
+
+
+# ------------------- 日志配置 -------------------
+LOG_DIR = os.path.join("common", "logs", "alien_store")
+os.makedirs(LOG_DIR, exist_ok=True)
+
+def _init_logger():
+    logger = logging.getLogger("alien_store_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()
+
+CONTRACT_CREATE_CONFIGS = [
+    ("store_agreement", "店铺入驻协议", 1),
+    ("alipay_auth", "支付宝授权函", 0),
+    ("wechat_pay_commitment", "微信支付承诺函", 0),
+]
 
 
 class ContractServer:
@@ -14,6 +51,235 @@ class ContractServer:
             "message": "模板创建成功"
         }
 
+    async def create_esign_templates(self, templates_data: TemplatesCreate) -> dict:
+        """AI审核完调用 e签宝生成文件"""
+        logger.info(f"create_esign_templates request: {templates_data}")
+        try:
+            generated_contracts = build_contract_items(
+                configs=CONTRACT_CREATE_CONFIGS,
+                template_name=templates_data.store_name,
+                signer_name=templates_data.store_name,
+                signer_id_num=templates_data.ord_id,
+                psn_account=templates_data.contact_phone,
+                psn_name=templates_data.merchant_name,
+            )
+        except ContractBuildError as exc:
+            return {"success": False, "message": exc.message, "raw": exc.raw}
+
+        for contract_item in generated_contracts:
+            await self.esign_repo.append_contract_url(templates_data, contract_item)
+
+        master_contract = next((item for item in generated_contracts if item.get("is_master") == 1), generated_contracts[0])
+        logger.info(
+            "create_esign_templates success contact_phone=%s master_sign_flow_id=%s all_sign_flow_ids=%s",
+            templates_data.contact_phone,
+            master_contract.get("sign_flow_id"),
+            [item.get("sign_flow_id") for item in generated_contracts],
+        )
+
+        return {
+            "success": True,
+            "message": "合同模板已追加/创建",
+            "sign_flow_id": master_contract.get("sign_flow_id"),
+            "file_id": master_contract.get("file_id"),
+            "contract_url": master_contract.get("contract_url"),
+            "created_contracts": [
+                {
+                    "contract_type": item["contract_type"],
+                    "contract_name": item["contract_name"],
+                    "sign_flow_id": item["sign_flow_id"],
+                    "file_id": item["file_id"],
+                    "contract_url": item["contract_url"],
+                }
+                for item in generated_contracts
+            ],
+        }
+
+    async def list_contracts(self, store_id: int, status: Optional[int], page: int, page_size: int) -> dict:
+        """根据 store_id 查询所有合同,支持根据 status 筛选和分页"""
+        logger.info(
+            "list_contracts request store_id=%s status=%s page=%s page_size=%s",
+            store_id, status, page, page_size
+        )
+        
+        # 1. 检查 store_info 中的审核状态
+        reason = await self.esign_repo.check_store_status(store_id)
+        if reason != "审核通过":
+            return {"code": 555, "msg": "先进行认证", "reason": reason}
+
+        # 2. 获取原始数据
+        rows = await self.esign_repo.get_by_store_id(store_id)
+        
+        all_filtered_items = []
+        # 3. 解析并筛选
+        for row in rows:
+            contract_url_raw = row.get("contract_url")
+            if not contract_url_raw:
+                continue
+            try:
+                items = json.loads(contract_url_raw)
+                if not isinstance(items, list):
+                    continue
+                
+                for item in items:
+                    if status is not None and item.get("status") != status:
+                        continue
+                    
+                    item_with_info = dict(item)
+                    item_with_info.update({
+                        "id": row.get("id"),
+                        "store_id": row.get("store_id"),
+                        "store_name": row.get("store_name"),
+                        "merchant_name": row.get("merchant_name"),
+                        "contact_phone": row.get("contact_phone")
+                    })
+                    all_filtered_items.append(item_with_info)
+            except Exception as e:
+                logger.error(f"Error processing contracts for store_id {store_id}: {e}", exc_info=True)
+                continue
+
+        # 4. 手动分页
+        total = len(all_filtered_items)
+        start = (page - 1) * page_size
+        end = start + page_size
+        paged_items = all_filtered_items[start:end]
+        total_pages = (total + page_size - 1) // page_size if total > 0 else 0
+
+        return {
+            "items": paged_items,
+            "total": total,
+            "page": page,
+            "page_size": page_size,
+            "total_pages": total_pages
+        }
+
+    async def get_contract_detail(self, sign_flow_id: str) -> dict:
+        """获取合同详情"""
+        row, item, items = await self.esign_repo.get_contract_item_by_sign_flow_id(sign_flow_id)
+        if not item:
+            return {"success": False, "message": "未找到合同"}
+
+        status = item.get("status")
+        if status == 0:
+            return await self._get_pending_contract_detail(sign_flow_id, row, item, items)
+        elif status == 1:
+            return await self._get_signed_contract_detail(sign_flow_id, row, item, items)
+        
+        return {"success": False, "message": "未知合同状态", "raw": {"status": status}}
+
+    async def _get_pending_contract_detail(self, sign_flow_id: str, row, item, items) -> dict:
+        file_id = item.get("file_id")
+        if not file_id:
+            return {"success": False, "message": "缺少 file_id,无法获取合同详情"}
+        
+        try:
+            detail_resp = esign_main.get_contract_detail(file_id)
+            detail_json = json.loads(detail_resp)
+            data = detail_json.get("data") if isinstance(detail_json, dict) else None
+            contract_url_val = None
+            if isinstance(data, dict):
+                contract_url_val = data.get("fileDownloadUrl")
+            if not contract_url_val and isinstance(detail_json, dict):
+                contract_url_val = detail_json.get("fileDownloadUrl")
+        except Exception as e:
+            logger.error(f"get_contract_detail failed file_id={file_id}: {e}")
+            return {"success": False, "message": "获取合同链接失败", "raw": str(e)}
+
+        if not contract_url_val:
+            logger.error(f"get_contract_detail missing contract_url file_id={file_id}: {detail_resp}")
+            return {"success": False, "message": "e签宝返回缺少合同链接", "raw": detail_resp}
+
+        # 更新数据库中的合同链接
+        if row and isinstance(items, list):
+            for it in items:
+                if it.get("sign_flow_id") == sign_flow_id:
+                    it["contract_url"] = contract_url_val
+                    break
+            await self.esign_repo.update_contract_items(row["id"], items)
+
+        # 获取签署链接
+        contact_phone = item.get("contact_phone") or (row.get("contact_phone") if isinstance(row, dict) else None)
+        if not contact_phone:
+            return {"success": False, "message": "缺少 contact_phone,无法获取签署链接"}
+        
+        try:
+            sign_resp = sign_url(sign_flow_id, 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 e:
+            logger.error(f"sign_url failed sign_flow_id={sign_flow_id}, contact_phone={contact_phone}: {e}")
+            return {"success": False, "message": "获取签署链接失败", "raw": str(e)}
+
+        if not result_sign_url:
+            logger.error(f"sign_url missing url: {sign_json}")
+            return {"success": False, "message": "e签宝返回缺少签署链接", "raw": sign_json}
+            
+        await self.esign_repo.update_sign_url(contact_phone, sign_flow_id, result_sign_url)
+
+        return {
+            "status": 0,
+            "contract_url": contract_url_val,
+            "sign_url": result_sign_url,
+            "sign_flow_id": sign_flow_id
+        }
+
+    async def _get_signed_contract_detail(self, sign_flow_id: str, row, item, items) -> dict:
+        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 as e:
+            logger.error(f"file_download_url failed sign_flow_id={sign_flow_id}: {e}")
+            return {"success": False, "message": "获取合同下载链接失败", "raw": str(e)}
+            
+        if row and isinstance(items, list):
+            for it in items:
+                if it.get("sign_flow_id") == sign_flow_id:
+                    it["contract_download_url"] = contract_download_url
+                    it["contract_url"] = contract_download_url
+                    break
+            await self.esign_repo.update_contract_items(row["id"], items)
+
+        return {
+            "status": 1,
+            "contract_url": contract_download_url,
+            "contract_download_url": contract_download_url,
+            "sign_flow_id": sign_flow_id
+        }
+
+    async def process_esign_callback(self, payload: dict) -> dict:
+        """处理 e签宝 回调"""
+        sign_result = payload.get("signResult")
+        sign_flow_id = payload.get("signFlowId")
+        operator = payload.get("operator") or {}
+        psn_account = operator.get("psnAccount") or {}
+        contact_phone = psn_account.get("accountMobile")
+        
+        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
+
+        if sign_result == 2:
+            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 as e:
+                logger.error(f"file_download_url failed for sign_flow_id={sign_flow_id}: {e}")
+            
+            updated = await self.esign_repo.mark_signed_by_phone(contact_phone, sign_flow_id, signing_dt, contract_download_url)
+            logger.info(f"esign_callback success phone={contact_phone}, sign_flow_id={sign_flow_id}, updated={updated}")
+            return {"success": True, "code": "200", "msg": "success"}
+            
+        logger.error(f"esign_callback ignored payload: {payload}")
+        return {"success": False, "message": "未处理: signResult!=2 或手机号/签署流程缺失"}
+
     async def list_by_store(self, store_id: int):
         return await self.esign_repo.get_by_store_id(store_id)
 

+ 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 - 14
common/esigntool/esign_config.py

@@ -1,14 +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"

+ 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 - 332
common/esigntool/main.py

@@ -1,332 +0,0 @@
-import requests
-import json
-from common.esigntool.esign_config import Config
-from common.esigntool.esign_algorithm import buildSignJsonHeader
-import time
-from datetime import datetime
-config = Config()
-
-def get_auth_flow_id(org_name, org_id, legal_rep_name, legal_rep_id):
-    """获取机构认证&授权页面链接"""
-    api_path = "/v3/org-auth-url"
-    method = "POST"
-    body = {
-        "clientType": "ALL",
-        "redirectConfig": {
-            "redirectUrl": "https://www.baidu.com"
-        },
-        "orgAuthConfig": {
-            # "orgName": "爱丽恩严(大连)商务科技有限公司深圳分公司",
-            "orgName": org_name,
-            "orgInfo": {
-                # "orgIDCardNum": "91440300MADDW7XC4C",
-                "orgIDCardNum": org_id,
-                "orgIDCardType": "CRED_ORG_USCC",
-                # "legalRepName": "彭少荣",
-                "legalRepName": legal_rep_name,
-                # "legalRepIDCardNum": "362204198807182420",
-                "legalRepIDCardNum": legal_rep_id,
-                "legalRepIDCardType": "CRED_PSN_CH_IDCARD"
-            },
-            "transactorInfo": {
-                "psnAccount": "17337039317",
-                "psnInfo": {
-                    "psnName": "孟骞康",
-                    "psnIDCardNum": "411426200308121212",
-                    "psnIDCardType": "CRED_PSN_CH_IDCARD",
-                    "psnMobile": "17337039317"
-                }
-            }
-        },
-        # "authorizeConfig": {
-        #     "authorizedScopes": [
-        #         "get_org_identity_info",
-        #         "get_psn_identity_info",
-        #         "org_initiate_sign",
-        #         "manage_org_resource"
-        #     ]
-        # },
-        "notifyUrl": "http://120.26.186.130:33333/api/store/esign/callback_auth",
-        "transactorUseSeal": True
-    }
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-    resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-# get_auth_flow_id()
-
-def get_template_detail():
-    """查询合同模板中控件详情"""
-    api_path = f"/v3/doc-templates/{config.templates_id}"
-    method = "GET"
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path)
-    resp = requests.request(method, config.host + api_path, headers=json_headers)
-    print(resp.text)
-
-def fill_in_template(name):
-    """填写模板生成文件"""
-    api_path = "/v3/files/create-by-doc-template"
-    method = "POST"
-    body = {
-        "docTemplateId": config.templates_id,
-        "fileName": "U店在这-商户入驻协议",
-        "components": [
-            {
-                "componentKey": "alien_name",
-                "componentValue": "爱丽恩严(大连)商务科技有限公司"
-            },
-            {
-                "componentKey": "store_name",
-                "componentValue": name
-            },
-            {
-                "componentKey": "date",
-                "componentValue": datetime.now().strftime("%Y年%m月%d日")
-            },
-            {
-                "componentKey": "one_name",
-                "componentValue": name
-            },
-        ]
-    }
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-    resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-def get_contract_detail(file_id):
-    """查询PDF模板填写后文件"""
-    api_path = f"/v3/files/{file_id}"
-    method = "GET"
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path)
-    resp = requests.request(method, config.host + api_path, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-# get_contract_detail("f0371b4ae7c64c8ca16be3bf031d1d6e")
-# def create_by_file(file_id, file_name,  contact_phone, merchant_name):
-#     """基于文件发起签署"""
-#     api_path = "/v3/sign-flow/create-by-file"
-#     method = "POST"
-#     body = {
-#         "docs": [
-#             {
-#                 "fileId": file_id,
-#                 "fileName": f"{file_name}.pdf"
-#             }
-#         ],
-#         "signFlowConfig": {
-#             "signFlowTitle": "商家入驻U店的签署协议", # 请设置当前签署任务的主题
-#             "autoFinish": True,
-#             "noticeConfig": {
-#                 "noticeTypes": "" #
-#                 # """通知类型,通知发起方、签署方、抄送方,默认不通知(值为""空字符串),允许多种通知方式,请使用英文逗号分隔
-#                 #
-#                 # "" - 不通知(默认值)
-#                 #
-#                 # 1 - 短信通知(如果套餐内带“分项”字样,请确保开通【电子签名流量费(分项)认证】中的子项:【短信服务】,否则短信通知收不到)
-#                 #
-#                 # 2 - 邮件通知
-#                 #
-#                 # 3 - 钉钉工作通知(需使用e签宝钉签产品)
-#                 #
-#                 # 5 - 微信通知(用户需关注“e签宝电子签名”微信公众号且使用过e签宝微信小程序)
-#                 #
-#                 # 6 - 企业微信通知(需要使用e签宝企微版产品)
-#                 #
-#                 # 7 - 飞书通知(需要使用e签宝飞书版产品)
-#                 #
-#                 # 补充说明:
-#                 #
-#                 # 1、2:个人账号中需要绑定短信/邮件才有对应的通知方式;
-#                 # 3、5、6、7:仅限e签宝正式环境调用才会有。"""
-#             },
-#             "notifyUrl": "http://120.26.186.130:33333:/api/store/esign/callback", # 接收相关回调通知的Web地址,
-#             "redirectConfig": {
-#                 "redirectUrl": "https://www.esign.cn/"
-#             }
-#         },
-#         "signers": [
-#             {
-#                 "signConfig": {
-#                     "signOrder": 1
-#                 },
-#                 "signerType": 1,
-#                 "signFields": [
-#                     {
-#                         "customBizNum": "9527", # 开发者自定义业务编号
-#                         "fileId": file_id,  #签署区所在待签署文件ID 【注】这里的fileId需先添加在docs数组中,否则会报错“参数错误: 文件id不在签署流程中”。
-#                         "normalSignFieldConfig": {
-#                             "autoSign": True,
-#                             "signFieldStyle": 1,
-#                             "signFieldPosition": {
-#                                 "positionPage": "7",
-#                                 "positionX": 294, # 获取需要盖章的位置: https://open.esign.cn/tools/seal-position
-#                                 "positionY": 668
-#                             }
-#                         }
-#                     }
-#                 ]
-#             },
-#             {
-#                 "psnSignerInfo": {
-#                     "psnAccount": contact_phone,
-#                     "psnInfo": {
-#                         "psnName": merchant_name
-#                     }
-#                 },
-#                 "signConfig": {
-#                     "forcedReadingTime": 10,
-#                     "signOrder": 2
-#                 },
-#                 "signerType": 0,
-#                 "signFields": [
-#                     {
-#                         "customBizNum": "9527",
-#                         "fileId": file_id,
-#                         "normalSignFieldConfig": {
-#                             "signFieldStyle": 1,
-#                             "signFieldPosition": {
-#                                 "positionPage": "7",
-#                                 "positionX": 114,  # 获取需要盖章的位置: https://open.esign.cn/tools/seal-position
-#                                 "positionY": 666
-#                             }
-#                         }
-#                     }
-#                 ]
-#             }
-#         ]
-#     }
-#     json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-#     body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-#     resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-#     print(resp.text)
-#     return resp.text
-
-def create_by_file(file_id, file_name,  contact_phone, store_name, merchant_name, ord_id):
-    """基于文件发起签署"""
-    api_path = "/v3/sign-flow/create-by-file"
-    method = "POST"
-    body = {
-    "docs": [
-        {
-            "fileId": file_id,
-            "fileName": f"{file_name}.pdf"
-        }
-    ],
-    "signFlowConfig": {
-        "signFlowTitle": "商家入驻U店平台协议签署",
-        "autoFinish": True,
-        "noticeConfig": {
-            "noticeTypes": "1,2"
-        },
-        "notifyUrl": "http://120.26.186.130:33333:/api/store/esign/callback",
-        "redirectConfig": {
-            "redirectUrl": "https://www.esign.cn/"
-        }
-    },
-    "signers": [
-        {
-            "signConfig": {
-                "signOrder": 1
-            },
-            "signerType": 1,
-            "signFields": [
-                {
-                    "customBizNum": "9527",
-                    "fileId": file_id,
-                    "normalSignFieldConfig": {
-                        "autoSign": True,
-                        "signFieldStyle": 1,
-                        "signFieldPosition": {
-                            "positionPage": "7",
-                            "positionX": 294,
-                            "positionY": 668
-                        }
-                    }
-                }
-            ]
-        },
-        {
-            "orgSignerInfo": {
-                "orgName": store_name,
-                "orgInfo": {
-                    "orgIDCardNum": ord_id, # "91440300MADDW7XC4C"
-                    "orgIDCardType": "CRED_ORG_USCC"
-                },
-                "transactorInfo": {
-                    "psnAccount": contact_phone,
-                    "psnInfo": {
-                        "psnName": merchant_name
-                    }
-                }
-            },
-            "signConfig": {
-                "forcedReadingTime": 10,
-                "signOrder": 2
-            },
-            "signerType": 1,
-            "signFields": [
-                {
-                    "customBizNum": "自定义编码001",
-                    "fileId": file_id,
-                    "normalSignFieldConfig": {
-                        "signFieldStyle": 1,
-                        "signFieldPosition": {
-                            "positionPage": "7",
-                            "positionX": 114,
-                            "positionY": 666
-                        }
-                    }
-                }
-            ]
-        }
-    ]
-}
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-    resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-def sign_url(sign_flow_id, contact_phone):
-    """获取签署页面链接"""
-    api_path = f"/v3/sign-flow/{sign_flow_id}/sign-url"
-    method = "POST"
-    body = {
-    "signFlowId": sign_flow_id,
-    "clientType": "ALL",
-    "needLogin": False,
-    "operator": {
-    "psnAccount": contact_phone
-  },
-  "urlType": 2
-}
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-    resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-def file_download_url(sign_flow_id):
-    """下载已签署文件及附属材料"""
-    api_path = f"/v3/sign-flow/{sign_flow_id}/file-download-url"
-    method = "POST"
-    body = {
-    "urlAvailableDate": "3600"
-}
-    json_headers = buildSignJsonHeader(config.appId, config.scert, method, api_path, body=body)
-    body_json = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
-    resp = requests.request(method, config.host + api_path, data=body_json, headers=json_headers)
-    print(resp.text)
-    return resp.text
-
-# fill_in_template("我勒个去")
-# sing_data = create_by_file("41bd938c47394e6b9bf4a491949c161e", "U店在这-商户入驻协议",  "13503301290", "孟骞康")
-# sign_json  = json.loads(sing_data)
-# sing_id = sign_json["data"]["signFlowId"]
-# sign_url("", "13503301290")
-# file_download_url("15156afb603e4145b112ad6eab0815d5")

+ 3 - 1
main.py

@@ -1,8 +1,9 @@
 from fastapi import FastAPI
-from alien_store.router import router as store_router
+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 {