router.py 14 KB

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