feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理模块
"""
+773
View File
@@ -0,0 +1,773 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理 API(异步版本)
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse
from online_dev.contract.schema import (
ContractInstanceCreate,
ContractInstanceListItem,
ContractInstanceOut,
ContractInstanceUpdate,
ContractLogOut,
ContractSignatureCreate,
ContractSignatureOut,
ContractTemplateCreate,
ContractTemplateListItem,
ContractTemplateOut,
ContractTemplateUpdate,
MobileContractInfo,
MobileSignRequest,
MobileSignTokenCreate,
MobileSignTokenOut,
)
from online_dev.contract.service import (
ContractInstanceService,
ContractSignatureService,
ContractTemplateService,
MobileSignService,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contract", tags=["合同管理"])
# ============ 辅助函数 ============
def _format_datetime(dt) -> str:
"""格式化日期时间"""
if dt:
return dt.strftime("%Y-%m-%d %H:%M:%S")
return ""
async def _build_template_out(template) -> dict:
"""构建模板输出"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"template_config": template.template_config or {},
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_template_list_item(template) -> dict:
"""构建模板列表项"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_instance_out(db: AsyncSession, instance) -> dict:
"""构建合同实例输出"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
template_code = template.code if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"template_id": str(instance.template_id),
"contract_config": instance.contract_config or {},
"variable_data": instance.variable_data or {},
"signature_data": instance.signature_data or {},
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"expired_at": instance.expired_at,
"pdf_file": instance.pdf_file or "",
"template_name": template_name,
"template_code": template_code,
"creator_id": str(instance.creator_id) if instance.creator_id else "",
"creator_name": creator_name,
}
async def _build_instance_list_item(db: AsyncSession, instance) -> dict:
"""构建合同实例列表项"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"template_name": template_name,
"creator_name": creator_name,
}
def _build_signature_out(signature) -> dict:
"""构建签署记录输出"""
return {
"id": str(signature.id),
"element_id": signature.element_id,
"party_type": signature.party_type,
"party_label": signature.party_label or "",
"sign_type": signature.sign_type,
"signer_id": str(signature.signer_id) if signature.signer_id else None,
"signer_name": signature.signer_name or "",
"signature_image": signature.signature_image or "",
"status": signature.status,
"signed_at": signature.signed_at,
}
async def _build_log_out(db: AsyncSession, log) -> dict:
"""构建日志输出"""
# 获取操作人信息
operator_name = ""
if log.operator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == log.operator_id)
result = await db.execute(stmt)
operator = result.scalar_one_or_none()
if operator:
operator_name = operator.name or operator.username or ""
return {
"id": str(log.id),
"action": log.action,
"operator_id": str(log.operator_id) if log.operator_id else "",
"comment": log.comment or "",
"sys_create_datetime": log.sys_create_datetime,
"operator_name": operator_name,
}
# ============ 合同模板 API ============
@router.get("/template/list", response_model=PaginatedResponse[ContractTemplateListItem], summary="模板列表")
async def list_templates(
name: str = Query(None, description="模板名称"),
code: str = Query(None, description="模板编码"),
category: str = Query(None, description="分类"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同模板列表"""
result = await ContractTemplateService.get_list(
db=db,
page=page,
page_size=page_size,
name=name,
code=code,
category=category,
status=status,
)
items = [await _build_template_list_item(item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/template/categories", response_model=List[str], summary="获取模板分类")
async def get_template_categories(db: AsyncSession = Depends(get_db)):
"""获取所有模板分类"""
return await ContractTemplateService.get_categories(db)
@router.get("/template/{template_id}", response_model=ContractTemplateOut, summary="模板详情")
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取模板详情"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.get("/template/code/{code}", response_model=ContractTemplateOut, summary="根据编码获取模板")
async def get_template_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取模板"""
template = await ContractTemplateService.get_by_code(db, code)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.post("/template", response_model=ContractTemplateOut, summary="创建模板")
async def create_template(
request: Request,
data: ContractTemplateCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同模板"""
user_id = request.state.user_id
# 检查编码是否已存在
if await ContractTemplateService.get_by_code(db, data.code):
raise HTTPException(status_code=400, detail=f"模板编码 {data.code} 已存在")
try:
template = await ContractTemplateService.create(
db=db,
name=data.name,
code=data.code,
category=data.category,
description=data.description,
template_config=data.template_config,
creator_id=user_id,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("创建模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/template/{template_id}", response_model=ContractTemplateOut, summary="更新模板")
async def update_template(
template_id: str,
data: ContractTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
try:
template = await ContractTemplateService.update(
db=db,
template=template,
name=data.name,
category=data.category,
description=data.description,
template_config=data.template_config,
thumbnail=data.thumbnail,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("更新模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/template/{template_id}", summary="删除模板")
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
await ContractTemplateService.delete(db, template)
return {"message": "删除成功"}
@router.delete("/template/batch", summary="批量删除模板")
async def batch_delete_templates(
ids: List[str] = Query(..., description="模板ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除合同模板"""
count = 0
for template_id in ids:
template = await ContractTemplateService.get_by_id(db, template_id)
if template:
await ContractTemplateService.delete(db, template)
count += 1
return {"count": count}
@router.post("/template/{template_id}/publish", response_model=ContractTemplateOut, summary="发布模板")
async def publish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""发布合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.publish(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/disable", response_model=ContractTemplateOut, summary="停用模板")
async def disable_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""停用合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.disable(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/copy", response_model=ContractTemplateOut, summary="复制模板")
async def copy_template(
request: Request,
template_id: str,
new_code: str = Query(..., alias="newCode", description="新模板编码"),
new_name: str = Query(None, alias="newName", description="新模板名称"),
db: AsyncSession = Depends(get_db),
):
"""复制合同模板"""
user_id = request.state.user_id
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
# 检查新编码是否已存在
if await ContractTemplateService.get_by_code(db, new_code):
raise HTTPException(status_code=400, detail=f"模板编码 {new_code} 已存在")
new_template = await ContractTemplateService.copy(
db=db,
template=template,
new_code=new_code,
new_name=new_name,
creator_id=user_id,
)
return await _build_template_out(new_template)
# ============ 合同实例 API ============
@router.get("/instance/generate-no", summary="生成合同编号")
async def generate_contract_no():
"""生成一个新的合同编号"""
return {"contract_no": ContractInstanceService.generate_contract_no()}
@router.get("/instance/check-no", summary="检查合同编号唯一性")
async def check_contract_no(
contract_no: str = Query(..., alias="contractNo", description="合同编号"),
exclude_id: str = Query(None, alias="excludeId", description="排除的合同ID"),
db: AsyncSession = Depends(get_db),
):
"""检查合同编号是否已存在"""
exists = await ContractInstanceService.check_contract_no_exists(db, contract_no, exclude_id)
return {"exists": exists, "valid": not exists}
@router.get("/instance/list", response_model=PaginatedResponse[ContractInstanceListItem], summary="合同列表")
async def list_instances(
title: str = Query(None, description="合同标题"),
contract_no: str = Query(None, alias="contractNo", description="合同编号"),
status: str = Query(None, description="状态"),
template_id: str = Query(None, alias="templateId", description="模板ID"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同实例列表"""
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
contract_no=contract_no,
status=status,
template_id=template_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/my", response_model=PaginatedResponse[ContractInstanceListItem], summary="我的合同")
async def list_my_instances(
request: Request,
title: str = Query(None, description="合同标题"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取我创建的合同"""
user_id = request.state.user_id
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
status=status,
creator_id=user_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/{instance_id}", response_model=ContractInstanceOut, summary="合同详情")
async def get_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同详情"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
return await _build_instance_out(db, instance)
@router.post("/instance", response_model=ContractInstanceOut, summary="创建合同")
async def create_instance(
request: Request,
data: ContractInstanceCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同实例"""
user_id = request.state.user_id
try:
instance = await ContractInstanceService.create(
db=db,
template_id=data.template_id,
title=data.title,
creator_id=user_id,
variable_data=data.variable_data,
contract_no=data.contract_no,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("创建合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/instance/{instance_id}", response_model=ContractInstanceOut, summary="更新合同")
async def update_instance(
request: Request,
instance_id: str,
data: ContractInstanceUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
try:
instance = await ContractInstanceService.update(
db=db,
instance=instance,
title=data.title,
variable_data=data.variable_data,
signature_data=data.signature_data,
operator_id=user_id,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("更新合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/instance/{instance_id}", summary="删除合同")
async def delete_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.delete(db, instance)
return {"message": "删除成功"}
@router.post("/instance/{instance_id}/submit", response_model=ContractInstanceOut, summary="提交合同")
async def submit_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""提交合同(进入待签署状态)"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.submit(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/complete", response_model=ContractInstanceOut, summary="完成合同")
async def complete_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""完成合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.complete(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/cancel", summary="取消合同")
async def cancel_instance(
request: Request,
instance_id: str,
comment: str = Query("", description="取消原因"),
db: AsyncSession = Depends(get_db),
):
"""取消合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.cancel(db, instance, user_id, comment)
return {"message": "取消成功"}
@router.get("/instance/{instance_id}/logs", response_model=List[ContractLogOut], summary="合同日志")
async def get_instance_logs(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同操作日志"""
logs = await ContractInstanceService.get_logs(db, instance_id)
return [await _build_log_out(db, log) for log in logs]
# ============ 签署 API ============
@router.post("/instance/{instance_id}/sign", response_model=ContractSignatureOut, summary="签署合同")
async def sign_instance(
request: Request,
instance_id: str,
data: ContractSignatureCreate,
db: AsyncSession = Depends(get_db),
):
"""签署合同"""
user_id = request.state.user_id
user_name = getattr(request.state, "username", "")
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await ContractSignatureService.sign(
db=db,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_id=user_id,
signer_name=data.signer_name or user_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/instance/{instance_id}/signatures", response_model=List[ContractSignatureOut], summary="签署记录")
async def get_instance_signatures(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同签署记录"""
signatures = await ContractSignatureService.get_signatures(db, instance_id)
return [_build_signature_out(sig) for sig in signatures]
# ============ 手机签署 API ============
@router.post("/instance/{instance_id}/mobile-sign-token", response_model=MobileSignTokenOut, summary="生成手机签署二维码")
async def create_mobile_sign_token(
instance_id: str,
data: MobileSignTokenCreate,
db: AsyncSession = Depends(get_db),
):
"""生成手机签署令牌和二维码"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
if instance.status not in ["pending", "signing"]:
raise HTTPException(status_code=400, detail="合同状态不允许签署")
try:
sign_token = await MobileSignService.create_sign_token(
db=db,
instance=instance,
party_type=data.party_type,
signer_name=data.signer_name,
expire_minutes=data.expire_minutes,
)
# 只返回 token,由前端构建完整 URL
return {
"token": sign_token.token,
"sign_url": "", # 由前端构建
"expired_at": sign_token.expired_at,
"qrcode_data": "", # 由前端构建
}
except Exception as e:
logger.exception("生成签署令牌失败")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/mobile/contract/{token}", response_model=MobileContractInfo, summary="获取移动端合同信息")
async def get_mobile_contract(
token: str,
db: AsyncSession = Depends(get_db),
):
"""根据令牌获取合同信息(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
contract = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not contract:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取模板名称
template = await ContractTemplateService.get_by_id(db, contract.template_id)
template_name = template.name if template else ""
return {
"id": str(contract.id),
"contract_no": contract.contract_no,
"title": contract.title,
"status": contract.status,
"contract_config": contract.contract_config or {},
"variable_data": contract.variable_data or {},
"signature_data": contract.signature_data or {},
"party_type": sign_token.party_type,
"signer_name": sign_token.signer_name or "",
"template_name": template_name,
}
@router.post("/mobile/sign/{token}", response_model=ContractSignatureOut, summary="移动端签署")
async def mobile_sign(
request: Request,
token: str,
data: MobileSignRequest,
db: AsyncSession = Depends(get_db),
):
"""移动端签署合同(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
instance = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await MobileSignService.mobile_sign(
db=db,
sign_token=sign_token,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_name=data.signer_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,129 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理数据模型
"""
from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON
from app.base_model import BaseModel
class ContractTemplate(BaseModel):
"""合同模板"""
__tablename__ = "contract_template"
name = Column(String(100), nullable=False, comment="模板名称")
code = Column(String(100), unique=True, nullable=False, comment="模板编码")
category = Column(String(50), default="", comment="分类")
description = Column(Text, default="", comment="描述")
status = Column(String(20), default="draft", index=True, comment="状态: draft/published/disabled")
version = Column(Integer, default=1, comment="版本号")
# 模板配置(JSON格式存储页面设置、元素、变量等)
template_config = Column(JSON, default=dict, comment="模板配置")
# 缩略图
thumbnail = Column(Text, default="", comment="缩略图(base64)")
class ContractInstance(BaseModel):
"""合同实例(基于模板创建的合同)"""
__tablename__ = "contract_instance"
# 关联模板(逻辑外键)
template_id = Column(String(21), nullable=False, index=True, comment="合同模板ID")
# 合同信息
contract_no = Column(String(50), unique=True, nullable=False, comment="合同编号")
title = Column(String(200), nullable=False, comment="合同标题")
status = Column(String(20), default="draft", index=True, comment="状态: draft/pending/signing/completed/expired/canceled")
# 创建人(逻辑外键)
creator_id = Column(String(21), nullable=False, index=True, comment="创建人ID")
# 合同配置(继承自模板,可修改)
contract_config = Column(JSON, default=dict, comment="合同配置")
# 变量数据
variable_data = Column(JSON, default=dict, comment="变量数据")
# 签署数据
signature_data = Column(JSON, default=dict, comment="签署数据")
# 时间记录
created_at = Column(DateTime, nullable=True, comment="创建时间")
completed_at = Column(DateTime, nullable=True, comment="完成时间")
expired_at = Column(DateTime, nullable=True, comment="过期时间")
# PDF 文件
pdf_file = Column(Text, default="", comment="PDF文件路径")
class ContractSignature(BaseModel):
"""合同签署记录"""
__tablename__ = "contract_signature"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 签署区信息
element_id = Column(String(50), nullable=False, comment="元素ID")
party_type = Column(String(20), default="party_a", comment="签署方类型: party_a/party_b/party_c/witness")
party_label = Column(String(50), default="", comment="签署方标签")
sign_type = Column(String(20), default="signature", comment="签署类型: signature/seal")
# 签署人(逻辑外键)
signer_id = Column(String(21), nullable=True, comment="签署人ID")
signer_name = Column(String(50), default="", comment="签署人姓名")
# 签署数据
signature_image = Column(Text, default="", comment="签名/印章图片(base64)")
status = Column(String(20), default="pending", comment="状态: pending/signed/rejected")
signed_at = Column(DateTime, nullable=True, comment="签署时间")
# IP 和设备信息
sign_ip = Column(String(50), default="", comment="签署IP")
sign_device = Column(String(200), default="", comment="签署设备")
class ContractSignToken(BaseModel):
"""合同签署令牌(用于手机扫码签署)"""
__tablename__ = "contract_sign_token"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 令牌
token = Column(String(64), unique=True, nullable=False, comment="签署令牌")
# 签署方信息
party_type = Column(String(20), default="party_a", comment="签署方类型")
signer_name = Column(String(50), default="", comment="签署人姓名")
# 有效期
expired_at = Column(DateTime, nullable=False, comment="过期时间")
# 是否已使用
is_used = Column(Boolean, default=False, comment="是否已使用")
used_at = Column(DateTime, nullable=True, comment="使用时间")
class ContractLog(BaseModel):
"""合同操作日志"""
__tablename__ = "contract_log"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 操作信息
action = Column(String(20), nullable=False, comment="操作类型: create/update/submit/sign/reject/cancel/complete/export/view")
# 操作人(逻辑外键)
operator_id = Column(String(21), nullable=False, comment="操作人ID")
# 操作详情
comment = Column(Text, default="", comment="备注")
extra_data = Column(JSON, default=dict, comment="额外数据")
# IP 信息
ip_address = Column(String(50), default="", comment="IP地址")
@@ -0,0 +1,212 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理 Schema 定义
"""
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
# ============ 合同模板 Schema ============
class ContractTemplateCreate(BaseModel):
"""创建合同模板"""
name: str = Field(..., max_length=100, description="模板名称")
code: str = Field(..., max_length=100, description="模板编码")
category: str = Field(default="", max_length=50, description="分类")
description: str = Field(default="", description="描述")
template_config: Dict[str, Any] = Field(default_factory=dict, description="模板配置")
class ContractTemplateUpdate(BaseModel):
"""更新合同模板"""
name: Optional[str] = Field(None, max_length=100, description="模板名称")
category: Optional[str] = Field(None, max_length=50, description="分类")
description: Optional[str] = Field(None, description="描述")
template_config: Optional[Dict[str, Any]] = Field(None, description="模板配置")
thumbnail: Optional[str] = Field(None, description="缩略图")
class ContractTemplateOut(BaseModel):
"""合同模板输出"""
id: str
name: str
code: str
category: str
description: str
status: str
version: int
template_config: Dict[str, Any]
thumbnail: str
sys_create_datetime: CSTDatetime
sys_update_datetime: CSTDatetime
model_config = ConfigDict(from_attributes=True)
class ContractTemplateListItem(BaseModel):
"""合同模板列表项"""
id: str
name: str
code: str
category: str
description: str
status: str
version: int
thumbnail: str
sys_create_datetime: CSTDatetime
sys_update_datetime: CSTDatetime
model_config = ConfigDict(from_attributes=True)
# ============ 合同实例 Schema ============
class ContractInstanceCreate(BaseModel):
"""创建合同实例"""
template_id: str = Field(..., description="模板ID")
title: str = Field(..., max_length=200, description="合同标题")
contract_no: Optional[str] = Field(None, max_length=50, description="合同编号(可选,不填则自动生成)")
variable_data: Dict[str, Any] = Field(default_factory=dict, description="变量数据")
class ContractInstanceUpdate(BaseModel):
"""更新合同实例"""
title: Optional[str] = Field(None, max_length=200, description="合同标题")
variable_data: Optional[Dict[str, Any]] = Field(None, description="变量数据")
signature_data: Optional[Dict[str, Any]] = Field(None, description="签署数据")
class ContractInstanceOut(BaseModel):
"""合同实例输出"""
id: str
contract_no: str
title: str
status: str
template_id: str
contract_config: Dict[str, Any]
variable_data: Dict[str, Any]
signature_data: Dict[str, Any]
created_at: Optional[CSTDatetime] = None
completed_at: Optional[CSTDatetime] = None
expired_at: Optional[CSTDatetime] = None
pdf_file: str
# 关联字段
template_name: str = ""
template_code: str = ""
creator_id: str = ""
creator_name: str = ""
model_config = ConfigDict(from_attributes=True)
class ContractInstanceListItem(BaseModel):
"""合同实例列表项"""
id: str
contract_no: str
title: str
status: str
created_at: Optional[CSTDatetime] = None
completed_at: Optional[CSTDatetime] = None
# 关联字段
template_name: str = ""
creator_name: str = ""
model_config = ConfigDict(from_attributes=True)
# ============ 签署记录 Schema ============
class ContractSignatureCreate(BaseModel):
"""创建签署记录"""
element_id: str = Field(..., description="元素ID")
signature_image: str = Field(..., description="签名图片(base64)")
signer_name: str = Field(default="", description="签署人姓名")
class ContractSignatureOut(BaseModel):
"""签署记录输出"""
id: str
element_id: str
party_type: str
party_label: str
sign_type: str
signer_id: Optional[str] = None
signer_name: str
signature_image: str
status: str
signed_at: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
# ============ 操作日志 Schema ============
class ContractLogOut(BaseModel):
"""操作日志输出"""
id: str
action: str
operator_id: str
comment: str
sys_create_datetime: CSTDatetime
# 关联字段
operator_name: str = ""
model_config = ConfigDict(from_attributes=True)
# ============ 手机签署 Schema ============
class MobileSignTokenCreate(BaseModel):
"""创建手机签署令牌"""
party_type: str = Field(default="party_a", description="签署方类型")
signer_name: str = Field(default="", description="签署人姓名")
expire_minutes: int = Field(default=30, description="有效期(分钟)")
class MobileSignTokenOut(BaseModel):
"""手机签署令牌输出"""
token: str
sign_url: str
expired_at: CSTDatetime
qrcode_data: str # 二维码内容
class MobileContractInfo(BaseModel):
"""移动端合同信息(简化版)"""
id: str
contract_no: str
title: str
status: str
contract_config: Dict[str, Any]
variable_data: Dict[str, Any]
signature_data: Dict[str, Any]
party_type: str = ""
signer_name: str = ""
template_name: str = ""
class MobileSignRequest(BaseModel):
"""移动端签署请求"""
element_id: str = Field(..., description="元素ID")
signature_image: str = Field(..., description="签名图片(base64)")
signer_name: str = Field(default="", description="签署人姓名")
# ============ 通用响应 ============
class MessageResponse(BaseModel):
"""消息响应"""
message: str
class CountResponse(BaseModel):
"""计数响应"""
count: int
@@ -0,0 +1,736 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理服务(异步版本)
"""
import secrets
import uuid
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.contract.model import (
ContractInstance,
ContractLog,
ContractSignature,
ContractSignToken,
ContractTemplate,
)
logger = logging.getLogger(__name__)
class ContractTemplateService:
"""合同模板服务"""
@staticmethod
async def get_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
name: str = None,
code: str = None,
category: str = None,
status: str = None,
) -> Dict[str, Any]:
"""获取模板列表"""
conditions = [ContractTemplate.is_deleted == False]
if name:
conditions.append(ContractTemplate.name.ilike(f"%{name}%"))
if code:
conditions.append(ContractTemplate.code.ilike(f"%{code}%"))
if category:
conditions.append(ContractTemplate.category == category)
if status:
conditions.append(ContractTemplate.status == status)
# 获取总数
count_stmt = select(func.count(ContractTemplate.id)).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 获取列表
offset = (page - 1) * page_size
stmt = select(ContractTemplate).where(and_(*conditions)).order_by(
ContractTemplate.sort, ContractTemplate.sys_create_datetime.desc()
).offset(offset).limit(page_size)
result = await db.execute(stmt)
items = list(result.scalars().all())
return {
"items": items,
"total": total,
"page": page,
"pageSize": page_size,
}
@staticmethod
async def get_by_id(db: AsyncSession, template_id: str) -> Optional[ContractTemplate]:
"""根据ID获取模板"""
stmt = select(ContractTemplate).where(
ContractTemplate.id == template_id,
ContractTemplate.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def get_by_code(db: AsyncSession, code: str) -> Optional[ContractTemplate]:
"""根据编码获取模板"""
stmt = select(ContractTemplate).where(
ContractTemplate.code == code,
ContractTemplate.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
name: str,
code: str,
category: str = "",
description: str = "",
template_config: Dict = None,
creator_id: str = None,
) -> ContractTemplate:
"""创建模板"""
template = ContractTemplate(
name=name,
code=code,
category=category,
description=description,
template_config=template_config or {},
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(template)
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def update(
db: AsyncSession,
template: ContractTemplate,
name: str = None,
category: str = None,
description: str = None,
template_config: Dict = None,
thumbnail: str = None,
) -> ContractTemplate:
"""更新模板"""
if name is not None:
template.name = name
if category is not None:
template.category = category
if description is not None:
template.description = description
if template_config is not None:
template.template_config = template_config
if thumbnail is not None:
template.thumbnail = thumbnail
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def delete(db: AsyncSession, template: ContractTemplate):
"""删除模板(软删除)"""
template.is_deleted = True
await db.commit()
@staticmethod
async def publish(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
"""发布模板"""
template.status = "published"
template.version += 1
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def disable(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
"""停用模板"""
template.status = "disabled"
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def copy(
db: AsyncSession,
template: ContractTemplate,
new_code: str,
new_name: str = None,
creator_id: str = None,
) -> ContractTemplate:
"""复制模板"""
new_template = ContractTemplate(
name=new_name or f"{template.name}_副本",
code=new_code,
category=template.category,
description=template.description,
template_config=template.template_config,
thumbnail=template.thumbnail,
status="draft",
version=1,
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(new_template)
await db.commit()
await db.refresh(new_template)
return new_template
@staticmethod
async def get_categories(db: AsyncSession) -> List[str]:
"""获取所有分类"""
stmt = select(ContractTemplate.category).where(
ContractTemplate.is_deleted == False,
ContractTemplate.category != ""
).distinct()
result = await db.execute(stmt)
return [row[0] for row in result.fetchall()]
class ContractInstanceService:
"""合同实例服务"""
@staticmethod
def generate_contract_no() -> str:
"""生成合同编号"""
now = datetime.now()
prefix = now.strftime("JZ%Y%m%d")
suffix = uuid.uuid4().hex[:6].upper()
return f"{prefix}{suffix}"
@staticmethod
async def check_contract_no_exists(
db: AsyncSession,
contract_no: str,
exclude_id: str = None
) -> bool:
"""检查合同编号是否已存在"""
conditions = [
ContractInstance.contract_no == contract_no,
ContractInstance.is_deleted == False
]
if exclude_id:
conditions.append(ContractInstance.id != exclude_id)
stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
result = await db.execute(stmt)
count = result.scalar() or 0
return count > 0
@staticmethod
async def get_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
title: str = None,
contract_no: str = None,
status: str = None,
template_id: str = None,
creator_id: str = None,
) -> Dict[str, Any]:
"""获取合同列表"""
conditions = [ContractInstance.is_deleted == False]
if title:
conditions.append(ContractInstance.title.ilike(f"%{title}%"))
if contract_no:
conditions.append(ContractInstance.contract_no.ilike(f"%{contract_no}%"))
if status:
conditions.append(ContractInstance.status == status)
if template_id:
conditions.append(ContractInstance.template_id == template_id)
if creator_id:
conditions.append(ContractInstance.creator_id == creator_id)
# 获取总数
count_stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 获取列表
offset = (page - 1) * page_size
stmt = select(ContractInstance).where(and_(*conditions)).order_by(
ContractInstance.sys_create_datetime.desc()
).offset(offset).limit(page_size)
result = await db.execute(stmt)
items = list(result.scalars().all())
return {
"items": items,
"total": total,
"page": page,
"pageSize": page_size,
}
@staticmethod
async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[ContractInstance]:
"""根据ID获取合同"""
stmt = select(ContractInstance).where(
ContractInstance.id == instance_id,
ContractInstance.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
template_id: str,
title: str,
creator_id: str,
variable_data: Dict = None,
contract_no: str = None,
) -> ContractInstance:
"""创建合同实例"""
# 获取模板
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise ValueError(f"模板不存在: {template_id}")
# 如果没有提供合同编号,自动生成
if not contract_no:
contract_no = ContractInstanceService.generate_contract_no()
# 检查合同编号唯一性
if await ContractInstanceService.check_contract_no_exists(db, contract_no):
raise ValueError(f"合同编号 {contract_no} 已存在")
instance = ContractInstance(
template_id=template_id,
contract_no=contract_no,
title=title,
creator_id=creator_id,
contract_config=template.template_config,
variable_data=variable_data or {},
created_at=datetime.now(),
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(instance)
await db.flush()
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="create",
operator_id=creator_id,
sys_creator_id=creator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def update(
db: AsyncSession,
instance: ContractInstance,
title: str = None,
variable_data: Dict = None,
signature_data: Dict = None,
operator_id: str = None,
) -> ContractInstance:
"""更新合同"""
if title is not None:
instance.title = title
if variable_data is not None:
instance.variable_data = variable_data
if signature_data is not None:
instance.signature_data = signature_data
instance.sys_modifier_id = operator_id
# 记录日志
if operator_id:
log = ContractLog(
contract_id=instance.id,
action="update",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def submit(
db: AsyncSession,
instance: ContractInstance,
operator_id: str
) -> ContractInstance:
"""提交合同(进入待签署状态)"""
instance.status = "pending"
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="submit",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def complete(
db: AsyncSession,
instance: ContractInstance,
operator_id: str
) -> ContractInstance:
"""完成合同"""
instance.status = "completed"
instance.completed_at = datetime.now()
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="complete",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def cancel(
db: AsyncSession,
instance: ContractInstance,
operator_id: str,
comment: str = ""
) -> ContractInstance:
"""取消合同"""
instance.status = "canceled"
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="cancel",
operator_id=operator_id,
comment=comment,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def delete(db: AsyncSession, instance: ContractInstance):
"""删除合同(软删除)"""
instance.is_deleted = True
await db.commit()
@staticmethod
async def get_logs(db: AsyncSession, instance_id: str) -> List[ContractLog]:
"""获取合同日志"""
stmt = select(ContractLog).where(
ContractLog.contract_id == instance_id
).order_by(ContractLog.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
class ContractSignatureService:
"""合同签署服务"""
@staticmethod
async def sign(
db: AsyncSession,
instance: ContractInstance,
element_id: str,
signature_image: str,
signer_id: str,
signer_name: str = "",
sign_ip: str = "",
sign_device: str = "",
) -> ContractSignature:
"""签署合同"""
# 查找元素信息
element = None
for el in instance.contract_config.get("elements", []):
if el.get("id") == element_id:
element = el
break
if not element:
raise ValueError("签署区域不存在")
# 获取签署方信息
signature_config = element.get("signature", {})
party_type = signature_config.get("partyType", "party_a")
party_label = signature_config.get("partyLabel", "")
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
# 查找是否已存在签署记录
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance.id,
ContractSignature.element_id == element_id
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# 更新现有记录
existing.party_type = party_type
existing.party_label = party_label
existing.sign_type = sign_type
existing.signer_id = signer_id
existing.signer_name = signer_name
existing.signature_image = signature_image
existing.status = "signed"
existing.signed_at = datetime.now()
existing.sign_ip = sign_ip
existing.sign_device = sign_device
signature = existing
else:
# 创建新记录
signature = ContractSignature(
contract_id=instance.id,
element_id=element_id,
party_type=party_type,
party_label=party_label,
sign_type=sign_type,
signer_id=signer_id,
signer_name=signer_name,
signature_image=signature_image,
status="signed",
signed_at=datetime.now(),
sign_ip=sign_ip,
sign_device=sign_device,
sys_creator_id=signer_id,
)
db.add(signature)
# 更新合同签署数据
signature_data = instance.signature_data or {}
signature_data[element_id] = signature_image
instance.signature_data = signature_data
# 检查是否所有必须签署区域都已签署
all_signed = await ContractSignatureService.check_all_signed(instance)
if all_signed and instance.status == "pending":
instance.status = "signing"
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="sign",
operator_id=signer_id,
comment=f"{party_label}签署",
extra_data={"element_id": element_id},
ip_address=sign_ip,
sys_creator_id=signer_id,
)
db.add(log)
await db.commit()
await db.refresh(signature)
return signature
@staticmethod
async def check_all_signed(instance: ContractInstance) -> bool:
"""检查是否所有必须签署区域都已签署"""
elements = instance.contract_config.get("elements", [])
signature_data = instance.signature_data or {}
for element in elements:
if element.get("type") in ["signature-zone", "seal-zone"]:
signature_config = element.get("signature", {})
if signature_config.get("required", True):
if element.get("id") not in signature_data:
return False
return True
@staticmethod
async def get_signatures(db: AsyncSession, instance_id: str) -> List[ContractSignature]:
"""获取合同签署记录"""
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance_id
).order_by(ContractSignature.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
class MobileSignService:
"""移动端签署服务"""
@staticmethod
async def create_sign_token(
db: AsyncSession,
instance: ContractInstance,
party_type: str = "party_a",
signer_name: str = "",
expire_minutes: int = 30,
) -> ContractSignToken:
"""创建签署令牌"""
# 生成随机令牌
token = secrets.token_urlsafe(32)
# 计算过期时间
expired_at = datetime.now() + timedelta(minutes=expire_minutes)
# 创建令牌记录
sign_token = ContractSignToken(
contract_id=instance.id,
token=token,
party_type=party_type,
signer_name=signer_name,
expired_at=expired_at,
)
db.add(sign_token)
await db.commit()
await db.refresh(sign_token)
return sign_token
@staticmethod
async def get_by_token(db: AsyncSession, token: str) -> Optional[ContractSignToken]:
"""根据令牌获取签署令牌"""
stmt = select(ContractSignToken).where(
ContractSignToken.token == token,
ContractSignToken.is_used == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
def validate_token(sign_token: ContractSignToken) -> Tuple[bool, str]:
"""验证令牌有效性"""
if sign_token.is_used:
return False, "该签署链接已被使用"
if sign_token.expired_at < datetime.now():
return False, "该签署链接已过期"
return True, ""
@staticmethod
async def mobile_sign(
db: AsyncSession,
sign_token: ContractSignToken,
instance: ContractInstance,
element_id: str,
signature_image: str,
signer_name: str = "",
sign_ip: str = "",
sign_device: str = "",
) -> ContractSignature:
"""移动端签署"""
# 查找元素信息
element = None
for el in instance.contract_config.get("elements", []):
if el.get("id") == element_id:
element = el
break
if not element:
raise ValueError("签署区域不存在")
# 获取签署方信息
signature_config = element.get("signature", {})
party_type = signature_config.get("partyType", "party_a")
party_label = signature_config.get("partyLabel", "")
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
# 使用令牌中的签署人姓名(如果提供)
final_signer_name = signer_name or sign_token.signer_name
# 截断设备信息,避免超出数据库字段长度
device_info = (sign_device + " (Mobile)")[:200] if sign_device else "Mobile"
# 查找是否已存在签署记录
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance.id,
ContractSignature.element_id == element_id
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# 更新现有记录
existing.party_type = party_type
existing.party_label = party_label
existing.sign_type = sign_type
existing.signer_id = None
existing.signer_name = final_signer_name
existing.signature_image = signature_image
existing.status = "signed"
existing.signed_at = datetime.now()
existing.sign_ip = sign_ip
existing.sign_device = device_info
signature = existing
else:
# 创建新记录
signature = ContractSignature(
contract_id=instance.id,
element_id=element_id,
party_type=party_type,
party_label=party_label,
sign_type=sign_type,
signer_id=None,
signer_name=final_signer_name,
signature_image=signature_image,
status="signed",
signed_at=datetime.now(),
sign_ip=sign_ip,
sign_device=device_info,
)
db.add(signature)
# 更新合同签署数据
signature_data = instance.signature_data or {}
signature_data[element_id] = signature_image
instance.signature_data = signature_data
# 检查是否所有必须签署区域都已签署
all_signed = await ContractSignatureService.check_all_signed(instance)
if all_signed and instance.status == "pending":
instance.status = "signing"
# 标记令牌已使用
sign_token.is_used = True
sign_token.used_at = datetime.now()
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="sign",
operator_id=instance.creator_id,
comment=f"{party_label}通过手机签署 (签署人: {final_signer_name})",
extra_data={"element_id": element_id, "mobile": True},
ip_address=sign_ip,
sys_creator_id=instance.creator_id,
)
db.add(log)
await db.commit()
await db.refresh(signature)
return signature