pl_ocr_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """阿里云人员证书 OCR(身份证 / 驾驶证)"""
  2. from __future__ import annotations
  3. import io
  4. import json
  5. import logging
  6. import re
  7. from datetime import datetime
  8. from typing import Any, Optional
  9. from fastapi import HTTPException
  10. from sqlalchemy.orm import Session
  11. from app.core.config import settings
  12. from app.core.utils import generate_uuid
  13. from app.models.pl_models import PlOcrUsageMonth
  14. logger = logging.getLogger(__name__)
  15. SUPPORTED_CERT_NAMES = ("身份证", "驾驶证", "行驶证")
  16. MAX_UPLOAD_BYTES = 1_500_000
  17. def _monthly_limit() -> int:
  18. return max(1, int(settings.ALIYUN_OCR_MONTHLY_LIMIT or 170))
  19. def current_usage_month() -> str:
  20. return datetime.now().strftime("%Y-%m")
  21. def get_or_create_usage_row(db: Session, usage_month: str) -> PlOcrUsageMonth:
  22. row = (
  23. db.query(PlOcrUsageMonth)
  24. .filter(PlOcrUsageMonth.usage_month == usage_month, PlOcrUsageMonth.del_flag == 0)
  25. .first()
  26. )
  27. if row:
  28. return row
  29. row = PlOcrUsageMonth(
  30. id=generate_uuid(),
  31. usage_month=usage_month,
  32. used_count=0,
  33. tenant_no="shanxia2.0",
  34. create_dept_id="",
  35. del_flag=0,
  36. )
  37. db.add(row)
  38. db.flush()
  39. return row
  40. def assert_quota_available(db: Session) -> PlOcrUsageMonth:
  41. month = current_usage_month()
  42. row = get_or_create_usage_row(db, month)
  43. limit = _monthly_limit()
  44. if (row.used_count or 0) >= limit:
  45. raise HTTPException(
  46. status_code=400,
  47. detail=f"本月 OCR 识别次数已达上限({limit} 次),请下月再试或联系管理员",
  48. )
  49. return row
  50. def increment_usage(db: Session, row: PlOcrUsageMonth) -> int:
  51. row.used_count = int(row.used_count or 0) + 1
  52. row.update_time = datetime.now()
  53. db.add(row)
  54. db.commit()
  55. db.refresh(row)
  56. return int(row.used_count or 0)
  57. def _first_attachment_url(attachment_urls: str) -> str:
  58. parts = [p.strip() for p in (attachment_urls or "").split(",") if p.strip()]
  59. if not parts:
  60. raise HTTPException(status_code=400, detail="请先上传证书附件")
  61. url = parts[0]
  62. lower = url.lower()
  63. if lower.endswith(".pdf"):
  64. raise HTTPException(status_code=400, detail="OCR 暂不支持 PDF,请上传图片附件")
  65. return url
  66. def compress_image_bytes(raw: bytes) -> bytes:
  67. if len(raw) <= MAX_UPLOAD_BYTES:
  68. return raw
  69. from PIL import Image
  70. img = Image.open(io.BytesIO(raw))
  71. if img.mode not in ("RGB", "L"):
  72. img = img.convert("RGB")
  73. quality = 85
  74. width, height = img.size
  75. buf = io.BytesIO()
  76. while True:
  77. buf.seek(0)
  78. buf.truncate(0)
  79. img.save(buf, format="JPEG", quality=quality, optimize=True)
  80. size = buf.tell()
  81. if size <= MAX_UPLOAD_BYTES or quality <= 40:
  82. return buf.getvalue()
  83. quality -= 10
  84. if size > MAX_UPLOAD_BYTES * 1.5:
  85. width = int(width * 0.85)
  86. height = int(height * 0.85)
  87. img = img.resize((width, height), Image.Resampling.LANCZOS)
  88. def parse_expire_date(valid_period: Optional[str]) -> Optional[str]:
  89. if not valid_period:
  90. return None
  91. text = str(valid_period).strip()
  92. if "长期" in text:
  93. return None
  94. m = re.search(
  95. r"(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2}).*?[至\-—~]\s*(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2})",
  96. text,
  97. )
  98. if m:
  99. y, mo, d = m.group(4), m.group(5), m.group(6)
  100. return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
  101. m2 = re.search(r"(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})\s*$", text)
  102. if m2:
  103. return f"{int(m2.group(1)):04d}-{int(m2.group(2)):02d}-{int(m2.group(3)):02d}"
  104. return None
  105. def parse_vehicle_license_expire(inspection_record: Optional[str]) -> Optional[str]:
  106. """从行驶证副页检验记录解析有效期结束日。
  107. 常见文案:检验有效期至2026年01月 / 至2026-01 / 2026年1月
  108. 仅到年月时默认取当月最后一天的近似:用该月 01 日写入(由运营可手改),
  109. 或优先匹配完整年月日。
  110. """
  111. if not inspection_record:
  112. return None
  113. text = str(inspection_record).strip()
  114. # 完整日期
  115. full = parse_expire_date(text)
  116. if full:
  117. return full
  118. # 检验有效期至YYYY年MM月 / 至YYYY-MM
  119. m = re.search(
  120. r"(?:有效期|检验).*?至\s*(\d{4})[.\-/年](\d{1,2})月?",
  121. text,
  122. )
  123. if not m:
  124. m = re.search(r"至\s*(\d{4})[.\-/年](\d{1,2})月?", text)
  125. if m:
  126. y, mo = int(m.group(1)), int(m.group(2))
  127. return f"{y:04d}-{mo:02d}-01"
  128. return None
  129. def _build_ocr_client():
  130. ak = (settings.ALIYUN_OCR_ACCESS_KEY_ID or "").strip()
  131. sk = (settings.ALIYUN_OCR_ACCESS_KEY_SECRET or "").strip()
  132. endpoint = (settings.ALIYUN_OCR_ENDPOINT or "ocr-api.cn-hangzhou.aliyuncs.com").strip()
  133. if not ak or not sk:
  134. raise HTTPException(status_code=400, detail="OCR 未配置,请联系管理员")
  135. try:
  136. from alibabacloud_ocr_api20210707.client import Client as OcrClient
  137. from alibabacloud_tea_openapi import models as open_api_models
  138. except ImportError as exc:
  139. logger.exception("阿里云 OCR SDK 未安装")
  140. raise HTTPException(status_code=500, detail="OCR 组件未安装,请联系管理员") from exc
  141. return OcrClient(
  142. open_api_models.Config(
  143. access_key_id=ak,
  144. access_key_secret=sk,
  145. endpoint=endpoint,
  146. )
  147. )
  148. def _parse_aliyun_data(raw_data: Any) -> dict:
  149. if isinstance(raw_data, str):
  150. return json.loads(raw_data) if raw_data else {}
  151. if isinstance(raw_data, dict):
  152. return raw_data
  153. return {}
  154. def _recognize_idcard(image_bytes: bytes) -> dict:
  155. from alibabacloud_ocr_api20210707 import models as ocr_models
  156. from alibabacloud_tea_util import models as util_models
  157. client = _build_ocr_client()
  158. request = ocr_models.RecognizeIdcardRequest(body=io.BytesIO(image_bytes))
  159. resp = client.recognize_idcard_with_options(request, util_models.RuntimeOptions())
  160. body = resp.body
  161. if getattr(body, "code", None):
  162. raise HTTPException(
  163. status_code=400,
  164. detail=getattr(body, "message", None) or "身份证识别失败",
  165. )
  166. data = _parse_aliyun_data(body.data)
  167. face = (((data or {}).get("data") or {}).get("face") or {}).get("data") or {}
  168. back = (((data or {}).get("data") or {}).get("back") or {}).get("data") or {}
  169. cert_no = face.get("idNumber") or back.get("idNumber")
  170. valid_period = back.get("validPeriod") or face.get("validPeriod")
  171. expire_date = parse_expire_date(valid_period)
  172. if not cert_no and not expire_date:
  173. raise HTTPException(status_code=400, detail="未能识别到身份证证号或有效期,请换清晰图片重试")
  174. return {
  175. "certNo": str(cert_no).strip() if cert_no else None,
  176. "expireDate": expire_date,
  177. "expireDateRaw": valid_period,
  178. }
  179. def _recognize_driving_license(image_bytes: bytes) -> dict:
  180. from alibabacloud_ocr_api20210707 import models as ocr_models
  181. from alibabacloud_tea_util import models as util_models
  182. client = _build_ocr_client()
  183. request = ocr_models.RecognizeDrivingLicenseRequest(body=io.BytesIO(image_bytes))
  184. resp = client.recognize_driving_license_with_options(request, util_models.RuntimeOptions())
  185. body = resp.body
  186. if getattr(body, "code", None):
  187. raise HTTPException(
  188. status_code=400,
  189. detail=getattr(body, "message", None) or "驾驶证识别失败",
  190. )
  191. data = _parse_aliyun_data(body.data)
  192. face = (((data or {}).get("data") or {}).get("face") or {}).get("data") or {}
  193. cert_no = face.get("licenseNumber")
  194. valid_period = face.get("validPeriod")
  195. expire_date = parse_expire_date(valid_period)
  196. if not cert_no and not expire_date:
  197. raise HTTPException(status_code=400, detail="未能识别到驾驶证证号或有效期,请换清晰图片重试")
  198. return {
  199. "certNo": str(cert_no).strip() if cert_no else None,
  200. "expireDate": expire_date,
  201. "expireDateRaw": valid_period,
  202. }
  203. def _recognize_vehicle_license(image_bytes: bytes) -> dict:
  204. """行驶证:证号优先车辆识别代号(VIN),有效期从副页检验记录解析。"""
  205. from alibabacloud_ocr_api20210707 import models as ocr_models
  206. from alibabacloud_tea_util import models as util_models
  207. client = _build_ocr_client()
  208. request = ocr_models.RecognizeVehicleLicenseRequest(body=io.BytesIO(image_bytes))
  209. resp = client.recognize_vehicle_license_with_options(request, util_models.RuntimeOptions())
  210. body = resp.body
  211. if getattr(body, "code", None):
  212. raise HTTPException(
  213. status_code=400,
  214. detail=getattr(body, "message", None) or "行驶证识别失败",
  215. )
  216. data = _parse_aliyun_data(body.data)
  217. face = (((data or {}).get("data") or {}).get("face") or {}).get("data") or {}
  218. back = (((data or {}).get("data") or {}).get("back") or {}).get("data") or {}
  219. cert_no = face.get("vinCode") or back.get("vinCode") or face.get("licensePlateNumber")
  220. inspection_record = back.get("inspectionRecord") or face.get("inspectionRecord")
  221. expire_date = parse_vehicle_license_expire(inspection_record)
  222. if not cert_no and not expire_date:
  223. raise HTTPException(status_code=400, detail="未能识别到行驶证证号或有效期,请换清晰图片重试")
  224. return {
  225. "certNo": str(cert_no).strip() if cert_no else None,
  226. "expireDate": expire_date,
  227. "expireDateRaw": inspection_record,
  228. }
  229. def recognize_personnel_cert(
  230. db: Session,
  231. *,
  232. cert_name: str,
  233. attachment_urls: str,
  234. ) -> dict:
  235. name = (cert_name or "").strip()
  236. if name not in SUPPORTED_CERT_NAMES:
  237. raise HTTPException(status_code=400, detail="仅支持身份证、驾驶证、行驶证自动识别")
  238. url = _first_attachment_url(attachment_urls)
  239. try:
  240. from app.services.bx.storage import download_file_bytes
  241. raw = download_file_bytes(url)
  242. except Exception as exc:
  243. logger.exception("下载 OCR 附件失败: %s", url)
  244. raise HTTPException(status_code=400, detail="附件读取失败,请重新上传后再试") from exc
  245. image_bytes = compress_image_bytes(raw)
  246. usage_row = assert_quota_available(db)
  247. # 只要准备调用阿里云即扣次(含识别失败),避免免费额度被刷完却不记账
  248. used = increment_usage(db, usage_row)
  249. try:
  250. if name == "身份证":
  251. result = _recognize_idcard(image_bytes)
  252. elif name == "驾驶证":
  253. result = _recognize_driving_license(image_bytes)
  254. else:
  255. result = _recognize_vehicle_license(image_bytes)
  256. except HTTPException:
  257. raise
  258. except Exception as exc:
  259. logger.exception("阿里云 OCR 调用失败 cert_name=%s", name)
  260. raise HTTPException(status_code=400, detail="OCR 识别失败,请稍后重试") from exc
  261. result["monthlyUsed"] = used
  262. result["monthlyLimit"] = _monthly_limit()
  263. return result