Эх сурвалжийг харах

feat: 新增支付宝授权函、微信支付承诺函、律师所入驻协议三份合同

天空之城 3 долоо хоног өмнө
parent
commit
66ce62ac36

+ 4 - 4
.env

@@ -4,13 +4,13 @@ ACCESS_TOKEN_EXPIRE_MINUTES=10080
  # 数据库配置
 DB_USER="root"
 DB_PASSWORD="Alien123456"
-DB_HOST="192.168.2.253"
-DB_PORT=40001
+DB_HOST="120.26.186.130"
+DB_PORT=30001
 DB_NAME="alien_sit"
 
 # redis配置
 # REDIS_URL= "redis://:Alien123456@172.31.154.180:30002/0"
-REDIS_URL="redis://:Alien123456@192.168.2.253:40002/0"
+REDIS_URL="redis://:Alien123456@120.26.186.130:30002/0"
 
 # 下游服务地址
 STORE_BASE_URL="http://127.0.0.1:8001"# alien_store 服务地址
@@ -19,4 +19,4 @@ 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"
+ALIYUN_ACCESS_KEY_SECRET="jLYGPpaJuc7NqmRdLvu1ObAS9CJFB8"

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

+ 84 - 0
alembic/env.py

@@ -0,0 +1,84 @@
+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
+
+# 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
+
+# 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")

+ 1 - 1
alien_gateway/config.py

@@ -42,7 +42,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 @@
+

+ 9 - 0
alien_lawyer/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_lawyer.services.contract_server import LawyerContractServer
+
+
+def get_contract_service(db: AsyncSession = Depends(get_db)) -> LawyerContractServer:
+    return LawyerContractServer(db)

+ 86 - 0
alien_lawyer/api/router.py

@@ -0,0 +1,86 @@
+from typing import Any, Union, Optional
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import ValidationError
+
+from alien_lawyer.api.deps import get_contract_service
+from alien_lawyer.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: LawyerContractServer = Depends(get_contract_service),
+) -> Union[TemplatesCreateResponse, ErrorResponse]:
+    try:
+        templates_data = LawyerTemplatesCreate.model_validate(templates_data_raw)
+    except ValidationError as exc:
+        return ErrorResponse(success=False, message="请求参数校验失败", raw={"errors": _format_validation_errors(exc)})
+    result = await templates_server.create_esign_templates(templates_data)
+    if not result.get("success"):
+        return ErrorResponse(**result)
+    return TemplatesCreateResponse(**result)
+
+
+@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 @@
+

+ 227 - 0
alien_lawyer/services/contract_server.py

@@ -0,0 +1,227 @@
+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 common.esigntool import main as esign_main
+from common.esigntool.contract_builder import build_contract_items, ContractBuildError
+from common.esigntool.main import sign_url, file_download_url
+
+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),
+]
+
+
+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 或手机号/签署流程缺失"}

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

+ 42 - 69
alien_store/services/contract_server.py

@@ -2,14 +2,13 @@ import datetime
 import os
 import logging
 import json
-import re
-import urllib.parse
 from typing import Any, Union, Optional
 from sqlalchemy.ext.asyncio import AsyncSession
 from alien_store.repositories.contract_repo import ContractRepository
 from alien_store.schemas.request.contract_store import TemplatesCreate
 from common.esigntool import main as esign_main
-from common.esigntool.main import fill_in_template, create_by_file, sign_url, file_download_url
+from common.esigntool.main import sign_url, file_download_url
+from common.esigntool.contract_builder import build_contract_items, ContractBuildError
 
 
 # ------------------- 日志配置 -------------------
@@ -34,6 +33,12 @@ def _init_logger():
 
 logger = _init_logger()
 
+CONTRACT_CREATE_CONFIGS = [
+    ("store_agreement", "店铺入驻协议", 1),
+    ("alipay_auth", "支付宝授权函", 0),
+    ("wechat_pay_commitment", "微信支付承诺函", 0),
+]
+
 
 class ContractServer:
     def __init__(self, db: AsyncSession):
@@ -49,77 +54,45 @@ class ContractServer:
     async def create_esign_templates(self, templates_data: TemplatesCreate) -> dict:
         """AI审核完调用 e签宝生成文件"""
         logger.info(f"create_esign_templates request: {templates_data}")
-        
-        # 1. 填充模板
-        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 {"success": False, "message": "e签宝返回非 JSON", "raw": res_text}
-
-        # 2. 提取文件信息
         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 = "contract.pdf"
-        except Exception:
-            logger.error(f"fill_in_template missing fileDownloadUrl: {res_data}")
-            return {"success": False, "message": "e签宝返回缺少 fileDownloadUrl", "raw": res_data}
-
-        # 3. 创建签署流程
-        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
+            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],
         )
-        try:
-            sign_json = json.loads(sign_data)
-        except json.JSONDecodeError:
-            logger.error(f"create_by_file non-json resp: {sign_data}")
-            return {"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 {"success": False, "message": "e签宝创建签署流程失败", "raw": sign_json}
-
-        sign_id = sign_json["data"].get("signFlowId")
-        if not sign_id:
-            logger.error(f"create_by_file missing signFlowId: {sign_json}")
-            return {"success": False, "message": "e签宝返回缺少 signFlowId", "raw": sign_json}
-
-        # 4. 构建合同记录
-        result_contract = {
-            "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": 1
-        }
-        
-        await self.esign_repo.append_contract_url(templates_data, result_contract)
-        logger.info(f"create_esign_templates success contact_phone={templates_data.contact_phone}, sign_flow_id={sign_id}")
-        
+
         return {
             "success": True,
             "message": "合同模板已追加/创建",
-            "sign_flow_id": sign_id,
-            "file_id": file_id,
-            "contract_url": contract_url
+            "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:

+ 105 - 0
common/esigntool/contract_builder.py

@@ -0,0 +1,105 @@
+import json
+import logging
+import re
+import urllib.parse
+from typing import Any
+
+from common.esigntool.main import fill_in_template, create_by_file
+
+logger = logging.getLogger(__name__)
+
+
+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)
+            raise ContractBuildError(
+                message=f"{contract_name}生成失败:e签宝返回缺少 fileDownloadUrl",
+                raw={"contract_type": contract_type, "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)
+            raise ContractBuildError(
+                message=f"{contract_name}发起签署失败",
+                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

+ 17 - 1
common/esigntool/esign_config.py

@@ -11,4 +11,20 @@ class Config:
         self.scert = "fda5f9e9652571066631f7ba938092e1"  # 项目密钥
         self.host = "https://smlopenapi.esign.cn"  # 沙箱请求地址,正式环境地址:https://openapi.esign.cn
         self.ordid = 'f25ca92b44a34b2c94289e0afec2518b'
-        self.templates_id = "266369d0efd94e14a78035b881e8cb93"
+        self.templates_id = "266369d0efd94e14a78035b881e8cb93"
+
+        # 合同模板ID映射
+        self.templates_map = {
+            "store_agreement": "266369d0efd94e14a78035b881e8cb93",   # 店铺入驻协议
+            "lawyer_agreement": "28f8c0337b044ca0acb6e3559f526d95",  # 律所入驻协议
+            "alipay_auth": "f42e72f59bb648c280ec4c3e937e0c26",       # 支付宝授权函
+            "wechat_pay_commitment": "18ecbd5a85cc4a0ba190a010a79bf8da",  # 微信支付承诺函
+        }
+
+        # 合同文件名映射
+        self.template_file_names = {
+            "store_agreement": "U店在这-商户入驻协议",
+            "lawyer_agreement": "U店在这-律所入驻协议",
+            "alipay_auth": "U店在这-支付宝授权函",
+            "wechat_pay_commitment": "U店在这-微信支付承诺函",
+        }

+ 201 - 277
common/esigntool/main.py

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

+ 1 - 1
main.py

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

+ 301 - 0
tests/test_esigntool.py

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