router.py 17 KB

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