router.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import datetime
  2. from fastapi import APIRouter, Depends, Query
  3. from typing import Any, Union, Optional
  4. import os
  5. import logging
  6. from pydantic import ValidationError
  7. from alien_store.api.deps import get_contract_service
  8. from alien_store.schemas.request.contract_store import TemplatesCreate
  9. from alien_store.schemas.response.contract_store import (
  10. ModuleStatusResponse,
  11. TemplatesCreateResponse,
  12. ErrorResponse,
  13. ContractStoreResponse,
  14. PaginatedResponse,
  15. SuccessResponse
  16. )
  17. from alien_store.services.contract_server import ContractServer
  18. from common.esigntool.main import *
  19. from common.esigntool import main as esign_main
  20. import re, urllib.parse
  21. # ------------------- 日志配置 -------------------
  22. LOG_DIR = os.path.join("common", "logs", "alien_store")
  23. os.makedirs(LOG_DIR, exist_ok=True)
  24. def _init_logger():
  25. logger = logging.getLogger("alien_store")
  26. if logger.handlers:
  27. return logger
  28. logger.setLevel(logging.INFO)
  29. fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s %(message)s")
  30. info_handler = logging.FileHandler(os.path.join(LOG_DIR, "info.log"), encoding="utf-8")
  31. info_handler.setLevel(logging.INFO)
  32. info_handler.setFormatter(fmt)
  33. error_handler = logging.FileHandler(os.path.join(LOG_DIR, "error.log"), encoding="utf-8")
  34. error_handler.setLevel(logging.ERROR)
  35. error_handler.setFormatter(fmt)
  36. logger.addHandler(info_handler)
  37. logger.addHandler(error_handler)
  38. # 控制台可选: logger.addHandler(logging.StreamHandler())
  39. return logger
  40. logger = _init_logger()
  41. router = APIRouter()
  42. def _format_validation_errors(exc: ValidationError) -> list[dict[str, str]]:
  43. errors = []
  44. for err in exc.errors():
  45. loc = err.get("loc", ())
  46. field = ".".join(str(item) for item in loc if item != "body")
  47. errors.append(
  48. {
  49. "field": field or "body",
  50. "type": err.get("type", "validation_error"),
  51. "message": err.get("msg", "参数校验失败"),
  52. }
  53. )
  54. return errors
  55. @router.get("/", response_model=ModuleStatusResponse)
  56. async def index() -> ModuleStatusResponse:
  57. return ModuleStatusResponse(module="Contract", status="Ok")
  58. @router.post("/get_esign_templates", response_model=Union[TemplatesCreateResponse, ErrorResponse])
  59. async def create_esign_templates(
  60. templates_data: dict[str, Any],
  61. templates_server: ContractServer = Depends(get_contract_service)
  62. ) -> Union[TemplatesCreateResponse, ErrorResponse]:
  63. """AI审核完调用 e签宝生成文件"""
  64. try:
  65. templates_data = TemplatesCreate.model_validate(templates_data)
  66. except ValidationError as e:
  67. detail = _format_validation_errors(e)
  68. logger.error("get_esign_templates validation failed: %s", detail)
  69. return ErrorResponse(
  70. success=False,
  71. message="请求参数校验失败",
  72. raw={"errors": detail},
  73. )
  74. logger.info(f"get_esign_templates request: {templates_data}")
  75. # res_text = fill_in_template(templates_data.merchant_name)
  76. res_text = fill_in_template(templates_data.store_name)
  77. try:
  78. res_data = json.loads(res_text)
  79. except json.JSONDecodeError:
  80. logger.error(f"fill_in_template non-json resp: {res_text}")
  81. return ErrorResponse(success=False, message="e签宝返回非 JSON", raw=res_text)
  82. # 从返回结构提取下载链接,需与实际返回字段匹配
  83. try:
  84. contract_url = res_data["data"]["fileDownloadUrl"]
  85. file_id = res_data["data"]["fileId"]
  86. m = re.search(r'/([^/]+)\.pdf', contract_url)
  87. if m:
  88. encoded_name = m.group(1)
  89. file_name = urllib.parse.unquote(encoded_name)
  90. except Exception:
  91. logger.error(f"fill_in_template missing fileDownloadUrl: {res_data}")
  92. return ErrorResponse(success=False, message="e签宝返回缺少 fileDownloadUrl", raw=res_data)
  93. # sign_data = create_by_file(file_id, file_name, templates_data.contact_phone, templates_data.merchant_name)
  94. 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)
  95. print(sign_data)
  96. try:
  97. sign_json = json.loads(sign_data)
  98. except json.JSONDecodeError:
  99. logger.error(f"create_by_file non-json resp: {sign_data}")
  100. return ErrorResponse(success=False, message="e签宝 create_by_file 返回非 JSON", raw=sign_data)
  101. if not sign_json.get("data"):
  102. logger.error(f"create_by_file failed or missing data: {sign_json}")
  103. return ErrorResponse(success=False, message="e签宝创建签署流程失败", raw=sign_json)
  104. sing_id = sign_json["data"].get("signFlowId")
  105. if not sing_id:
  106. logger.error(f"create_by_file missing signFlowId: {sign_json}")
  107. return ErrorResponse(success=False, message="e签宝返回缺少 signFlowId", raw=sign_json)
  108. result_contract = {
  109. "contract_url": contract_url, # 合同模版链接
  110. "file_name": file_name, # 签署的合同的文件名称
  111. "file_id": file_id, # 生成的文件ID
  112. "status": 0, # 签署状态 0 未签署 1 已签署
  113. "sign_flow_id": sing_id, # 从
  114. "sign_url": "", # e签宝生成的签署页面
  115. "signing_time": "", # 签署合同的时间
  116. "effective_time": "", # 合同生效的时间
  117. "expiry_time": "", # 合同失效的时间
  118. "contract_download_url": "", # 合同签署完成后 下载文件的链接
  119. "is_master": 1 # 是否是入驻店铺的协议合同 是 1 否 0
  120. }
  121. updated = await templates_server.append_contract_url(templates_data, result_contract)
  122. logger.info(f"get_esign_templates success contact_phone={templates_data.contact_phone}, sign_flow_id={sing_id}")
  123. return TemplatesCreateResponse(
  124. success=True,
  125. message="合同模板已追加/创建",
  126. sign_flow_id=sing_id,
  127. file_id=file_id,
  128. contract_url=contract_url
  129. )
  130. @router.get("/contracts/{store_id}", response_model=Union[dict, Any])
  131. async def list_contracts(
  132. store_id: int,
  133. status: Optional[int] = Query(None, description="筛选合同状态:0 未签署,1 已签署"),
  134. page: int = Query(1, ge=1, description="页码,从1开始"),
  135. page_size: int = Query(10, ge=1, le=100, description="每页条数,默认10"),
  136. templates_server: ContractServer = Depends(get_contract_service)
  137. ) -> Any:
  138. """根据 store_id 查询所有合同,支持根据 status 筛选和分页"""
  139. logger.info(
  140. "list_contracts request store_id=%s status=%s page=%s page_size=%s",
  141. store_id,
  142. status,
  143. page,
  144. page_size,
  145. )
  146. try:
  147. # 1. 检查 store_info 中的审核状态
  148. reason = await templates_server.get_store_reason(store_id)
  149. if reason != "审核通过":
  150. return {"code": 555, "msg": "先进行认证", "reason": reason}
  151. # 2. 返回合同列表
  152. rows = await templates_server.list_by_store(store_id)
  153. all_filtered_items = []
  154. # 3. 解析并筛选所有符合条件的合同项
  155. for row in rows:
  156. contract_url_raw = row.get("contract_url")
  157. if not contract_url_raw:
  158. continue
  159. try:
  160. items = json.loads(contract_url_raw)
  161. if not isinstance(items, list):
  162. continue
  163. for item in items:
  164. # 如果传了 status,则进行筛选
  165. if status is not None and item.get("status") != status:
  166. continue
  167. # 将店铺基础信息混入每个合同项中,方便前端展示
  168. item_with_info = dict(item)
  169. item_with_info["id"] = row.get("id")
  170. item_with_info["store_id"] = row.get("store_id")
  171. item_with_info["store_name"] = row.get("store_name")
  172. item_with_info["merchant_name"] = row.get("merchant_name")
  173. item_with_info["contact_phone"] = row.get("contact_phone")
  174. all_filtered_items.append(item_with_info)
  175. except Exception as e:
  176. logger.error(f"Error processing contracts for store_id {store_id}: {e}", exc_info=True)
  177. continue
  178. # 4. 手动分页
  179. total = len(all_filtered_items)
  180. start = (page - 1) * page_size
  181. end = start + page_size
  182. paged_items = all_filtered_items[start:end]
  183. total_pages = (total + page_size - 1) // page_size if total > 0 else 0
  184. return {
  185. "items": paged_items,
  186. "total": total,
  187. "page": page,
  188. "page_size": page_size,
  189. "total_pages": total_pages
  190. }
  191. except Exception as e:
  192. logger.error(f"list_contracts failed store_id={store_id}: {e}", exc_info=True)
  193. return {"code": 500, "msg": "查询合同失败", "error": str(e)}
  194. @router.get("/contracts/detail/{sign_flow_id}", response_model=Union[dict, ErrorResponse])
  195. async def get_contract_detail(
  196. sign_flow_id: str,
  197. templates_server: ContractServer = Depends(get_contract_service)
  198. ) -> Union[dict, ErrorResponse]:
  199. """
  200. 根据 sign_flow_id 获取合同详情
  201. - status=0: 返回合同PDF链接(contract_url)和签署链接(sign_url)
  202. - status=1: 拉取最新下载链接并更新数据库,返回 contract_download_url
  203. """
  204. row, item, items = await templates_server.get_contract_item_by_sign_flow_id(sign_flow_id)
  205. if not item:
  206. return ErrorResponse(success=False, message="未找到合同")
  207. status = item.get("status")
  208. if status == 0:
  209. file_id = item.get("file_id")
  210. if not file_id:
  211. return ErrorResponse(success=False, message="缺少 file_id,无法获取合同详情")
  212. try:
  213. detail_resp = esign_main.get_contract_detail(file_id)
  214. detail_json = json.loads(detail_resp)
  215. data = detail_json.get("data") if isinstance(detail_json, dict) else None
  216. contract_url = None
  217. if isinstance(data, dict):
  218. contract_url = data.get("fileDownloadUrl")
  219. if not contract_url and isinstance(detail_json, dict):
  220. contract_url = detail_json.get("fileDownloadUrl")
  221. except Exception as e:
  222. logger.error(f"get_contract_detail failed file_id={file_id}: {e}")
  223. return ErrorResponse(success=False, message="获取合同链接失败", raw=str(e))
  224. if not contract_url:
  225. logger.error(f"get_contract_detail missing contract_url file_id={file_id}: {detail_resp}")
  226. return ErrorResponse(success=False, message="e签宝返回缺少合同链接", raw=detail_resp)
  227. if row and isinstance(items, list):
  228. for it in items:
  229. if it.get("sign_flow_id") == sign_flow_id:
  230. it["contract_url"] = contract_url
  231. break
  232. await templates_server.update_contract_items(row["id"], items)
  233. # 融合原 /esign/signurl 逻辑:调用 e签宝 获取签署链接并落库
  234. contact_phone = item.get("contact_phone") or (row.get("contact_phone") if isinstance(row, dict) else None)
  235. if not contact_phone:
  236. return ErrorResponse(success=False, message="缺少 contact_phone,无法获取签署链接")
  237. try:
  238. sign_resp = sign_url(sign_flow_id, contact_phone)
  239. sign_json = json.loads(sign_resp)
  240. except json.JSONDecodeError:
  241. logger.error(f"sign_url non-json resp: {sign_resp}")
  242. return ErrorResponse(success=False, message="e签宝返回非JSON", raw=sign_resp)
  243. except Exception as e:
  244. logger.error(f"sign_url failed sign_flow_id={sign_flow_id}, contact_phone={contact_phone}: {e}")
  245. return ErrorResponse(success=False, message="获取签署链接失败", raw=str(e))
  246. sign_data = sign_json.get("data") if isinstance(sign_json, dict) else None
  247. result_sign_url = sign_data.get("url") if isinstance(sign_data, dict) else None
  248. if not result_sign_url:
  249. logger.error(f"sign_url missing url: {sign_json}")
  250. return ErrorResponse(success=False, message="e签宝返回缺少签署链接", raw=sign_json)
  251. await templates_server.update_sign_url(contact_phone, sign_flow_id, result_sign_url)
  252. return {
  253. "status": 0,
  254. "contract_url": contract_url,
  255. "sign_url": result_sign_url,
  256. "sign_flow_id": sign_flow_id
  257. }
  258. if status == 1:
  259. try:
  260. download_resp = file_download_url(sign_flow_id)
  261. download_json = json.loads(download_resp)
  262. contract_download_url = download_json["data"]["files"][0]["downloadUrl"]
  263. except Exception as e:
  264. logger.error(f"file_download_url failed sign_flow_id={sign_flow_id}: {e}")
  265. return ErrorResponse(success=False, message="获取合同下载链接失败", raw=str(e))
  266. if row and isinstance(items, list):
  267. for it in items:
  268. if it.get("sign_flow_id") == sign_flow_id:
  269. it["contract_download_url"] = contract_download_url
  270. it["contract_url"] = contract_download_url # 与 status=0 一致,用 file_download_url 更新最新 contract_url
  271. break
  272. await templates_server.update_contract_items(row["id"], items)
  273. return {
  274. "status": 1,
  275. "contract_url": contract_download_url,
  276. "contract_download_url": contract_download_url,
  277. "sign_flow_id": sign_flow_id
  278. }
  279. return ErrorResponse(success=False, message="未知合同状态", raw={"status": status})
  280. @router.get("/get_all_templates", response_model=PaginatedResponse)
  281. async def get_all_templates(
  282. page: int = Query(1, ge=1, description="页码,从1开始"),
  283. page_size: int = Query(10, ge=1, le=100, description="每页条数,默认10"),
  284. store_name: Optional[str] = Query(None, description="店铺名称(模糊查询)"),
  285. merchant_name: Optional[str] = Query(None, description="商家姓名(模糊查询)"),
  286. signing_status: Optional[str] = Query(None, description="签署状态"),
  287. business_segment: Optional[str] = Query(None, description="经营板块"),
  288. store_status: Optional[str] = Query(None, description="店铺状态:正常/禁用"),
  289. expiry_start: Optional[datetime] = Query(None, description="到期时间起"),
  290. expiry_end: Optional[datetime] = Query(None, description="到期时间止"),
  291. templates_server: ContractServer = Depends(get_contract_service)
  292. ) -> PaginatedResponse:
  293. """分页查询所有合同,支持筛选"""
  294. rows, total = await templates_server.list_all_paged(
  295. page,
  296. page_size,
  297. store_name=store_name,
  298. merchant_name=merchant_name,
  299. signing_status=signing_status,
  300. business_segment=business_segment,
  301. store_status=store_status,
  302. expiry_start=expiry_start,
  303. expiry_end=expiry_end,
  304. )
  305. total_pages = (total + page_size - 1) // page_size if total > 0 else 0
  306. items = [ContractStoreResponse(**row) for row in rows]
  307. return PaginatedResponse(
  308. items=items,
  309. total=total,
  310. page=page,
  311. page_size=page_size,
  312. total_pages=total_pages
  313. )
  314. @router.post("/esign/callback", response_model=Union[SuccessResponse, ErrorResponse])
  315. async def esign_callback(
  316. payload: dict,
  317. templates_server: ContractServer = Depends(get_contract_service)
  318. ) -> Union[SuccessResponse, ErrorResponse]:
  319. """
  320. e签宝签署结果回调
  321. 需求:签署完成 -> 更新 signing_status=已签署,contract_url 中 status=1
  322. """
  323. sign_result = payload.get("signResult")
  324. operator = payload.get("operator") or {}
  325. sign_flow_id = payload.get("signFlowId")
  326. psn_account = operator.get("psnAccount") or {}
  327. contact_phone = psn_account.get("accountMobile")
  328. # 取回调中的毫秒时间戳,优先 operateTime,其次 timestamp
  329. ts_ms = payload.get("operateTime") or payload.get("timestamp")
  330. signing_dt = None
  331. if ts_ms:
  332. try:
  333. signing_dt = datetime.fromtimestamp(ts_ms / 1000)
  334. except Exception:
  335. signing_dt = None
  336. if sign_result == 2:
  337. # 获取合同下载链接
  338. contract_download_url = None
  339. try:
  340. download_resp = file_download_url(sign_flow_id)
  341. download_json = json.loads(download_resp)
  342. contract_download_url = download_json["data"]["files"][0]["downloadUrl"]
  343. except Exception as e:
  344. logger.error(f"file_download_url failed for sign_flow_id={sign_flow_id}: {e}")
  345. updated = await templates_server.mark_signed_by_phone(contact_phone, sign_flow_id, signing_dt, contract_download_url)
  346. logger.info(f"esign_callback success phone={contact_phone}, sign_flow_id={sign_flow_id}, updated={updated}")
  347. return SuccessResponse(code="200", msg="success")
  348. logger.error(f"esign_callback ignored payload: {payload}")
  349. return ErrorResponse(success=False, message="未处理: signResult!=2 或手机号/签署流程缺失")
  350. # @router.post("/esign/callback_auth", response_model=SuccessResponse)
  351. # async def esign_callback_auth(
  352. # payload: dict,
  353. # templates_server: ContractServer = Depends(get_contract_service)
  354. # ) -> SuccessResponse:
  355. # logger.info(f"esign_callback_auth payload: {payload}")
  356. # return SuccessResponse(code="200", msg="success")