feat(bot-bridge): 新增智享通科目字典写入端点 POST /api/cma/bot/dict/zxt-account — 幂等UPSERT(JSON/CSV)+L3分级+audit留痕, 解除 ops 管道 API 迁移阻塞 (ops-cma-dict-write-endpoint-20260912)

This commit is contained in:
Hermes CI Fix
2026-09-12 10:07:20 +08:00
parent 998da4ebd4
commit af541e9043
5 changed files with 232 additions and 6 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ def _extract_l3_rows(level, resp_body: bytes):
return None
if not isinstance(data, dict):
return None
for key in ("imported", "kpi_updated"):
for key in ("imported", "kpi_updated", "upserted"):
v = data.get(key)
if isinstance(v, (int, float)):
return int(v)
+176 -1
View File
@@ -3,7 +3,7 @@ CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
无需用户登录,使用 BOT API Key 认证
"""
import os, json, logging
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File, Request
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime
@@ -798,6 +798,181 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
"causality_links": links, "status": "ok"}
# ═══════════════ 智享通科目字典写入(zxt_account_dict 幂等 UPSERT)═══════════════
# 2026-09-12 新增(ops-cma-dict-write-endpoint-20260912):补齐 bot_bridge 字典表写入能力,
# 解除 ops 侧 zxt_dict_load.py / zxt_load_account_dict.py 直连管道的 API 迁移阻塞。
# 分级 L3(批量写,与 /import 同级);留痕走 audit middle(logs/bot_audit.log)+ operation_logs。
_ZXT_DICT_ALLOWED = ("CwAccountID", "subject_code", "subject_name", "zxt_code", "remark")
_ZXT_DICT_REQUIRED = ("CwAccountID",)
_ZXT_DICT_MAXLEN = {"CwAccountID": 20, "subject_code": 20, "subject_name": 200,
"zxt_code": 50, "remark": 200}
_ZXT_DICT_CODE_FIELDS = ("CwAccountID", "subject_code", "zxt_code")
_ZXT_DICT_MAX_ROWS = 5000
def _validate_zxt_dict_rows(rows):
"""全量先校验(任一错误 → 400,不落库),返回标准化行 [{字段: 字符串}]"""
import re
if not isinstance(rows, list) or not rows:
raise HTTPException(400, "请求体必须是非空的行数组:JSON [{...}] 或 multipart CSV 文件")
if len(rows) > _ZXT_DICT_MAX_ROWS:
raise HTTPException(400, f"单次批量上限 {_ZXT_DICT_MAX_ROWS} 行,本次 {len(rows)} 行")
code_re = re.compile(r"^[A-Za-z0-9_.\-]+$")
clean, errs = [], []
for i, raw in enumerate(rows, start=1):
if not isinstance(raw, dict):
errs.append(f"第{i}行: 行必须是对象(dict)")
continue
unknown = [k for k in raw.keys() if k not in _ZXT_DICT_ALLOWED]
if unknown:
errs.append(f"第{i}行: 非白名单字段 {unknown},允许字段 {list(_ZXT_DICT_ALLOWED)}")
continue
item, bad = {}, False
for f in _ZXT_DICT_ALLOWED:
v = raw.get(f)
if v is None:
v = ""
elif isinstance(v, bool):
errs.append(f"第{i}行: {f} 类型非法(bool)")
bad = True
continue
elif isinstance(v, (int, float)):
v = str(int(v)) if float(v).is_integer() else str(v)
if not isinstance(v, str):
errs.append(f"第{i}行: {f} 必须是字符串(实际 {type(v).__name__})")
bad = True
continue
v = v.strip()
if len(v) > _ZXT_DICT_MAXLEN[f]:
errs.append(f"第{i}行: {f} 超长(>{_ZXT_DICT_MAXLEN[f]})")
bad = True
continue
if f in _ZXT_DICT_REQUIRED and not v:
errs.append(f"第{i}行: {f} 必填且不能为空")
bad = True
continue
if v and f in _ZXT_DICT_CODE_FIELDS and not code_re.match(v):
errs.append(f"第{i}行: {f} 含非法字符(仅限字母/数字/_/./-):{v}")
bad = True
continue
item[f] = v
if bad:
continue
for f in _ZXT_DICT_ALLOWED:
item.setdefault(f, "")
clean.append(item)
if errs:
raise HTTPException(400, detail={
"message": f"参数校验失败({len(errs)} 处),未写入任何数据",
"errors": errs[:20],
})
return clean
async def _read_zxt_dict_body(request: Request):
"""兼容两种入参: application/json(数组或 {"rows":[...]})/ multipart CSV 文件(字段 file)"""
import csv, io
ctype = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" in ctype:
form = await request.form()
up = None
for key in ("file", "csv", "upload"):
v = form.get(key)
if v is not None and hasattr(v, "read"):
up = v
break
if up is None:
for v in form.values():
if hasattr(v, "read"):
up = v # type: ignore[assignment]
break
if up is None:
raise HTTPException(400, "multipart 请求缺少 CSV 文件字段(file)")
raw = up.read()
raw = await raw if hasattr(raw, "__await__") else raw
text_data = raw.decode("utf-8-sig", errors="replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
reader = csv.DictReader(io.StringIO(text_data))
if not reader.fieldnames:
raise HTTPException(400, "CSV 缺少表头行")
headers = [(h or "").strip() for h in reader.fieldnames]
unknown = [h for h in headers if h not in _ZXT_DICT_ALLOWED]
if unknown:
raise HTTPException(400, f"CSV 含非白名单列 {unknown},允许列 {list(_ZXT_DICT_ALLOWED)}")
rows = []
for r in reader:
if not any((v or "").strip() for v in r.values() if v is not None):
continue
rows.append({(k or "").strip(): ("" if v is None else v) for k, v in r.items()})
if not rows:
raise HTTPException(400, "CSV 无数据行")
return {"rows": rows, "source": "csv"}
try:
raw = await request.body()
data = json.loads(raw) if raw else None
except Exception as e:
raise HTTPException(400, f"JSON 解析失败: {e}")
if isinstance(data, dict) and isinstance(data.get("rows"), list):
data = data["rows"]
if not isinstance(data, list):
raise HTTPException(400, 'JSON 必须是行数组 [{"CwAccountID":...}] 或 {"rows":[...]}')
return {"rows": data, "source": "json"}
@router.post("/dict/zxt-account")
@risk_level("L3")
def bot_dict_zxt_account(
bot: dict = Depends(verify_bot_key),
payload: dict = Depends(_read_zxt_dict_body),
db: Session = Depends(get_db),
):
"""智享通科目字典写入(zxt_account_dict 幂等 UPSERT,单事务)
body 二选一:
1) application/json: [{"CwAccountID","subject_code","subject_name","zxt_code"}]
或 {"rows":[...]}
2) multipart/form-data: file=<csv>(表头同字段名,utf-8/utf-8-sig)
返回: {"ok":true,"upserted":N,"before":x,"after":y};参数错误 400 且不落库,未授权 401。
凭据走后端 app.database(无硬编码)。
"""
from sqlalchemy import text
clean = _validate_zxt_dict_rows(payload.get("rows"))
source = payload.get("source", "json")
try:
stmt = text(
"INSERT INTO zxt_account_dict "
"(CwAccountID, subject_code, subject_name, zxt_code, remark) "
"VALUES (:CwAccountID, :subject_code, :subject_name, :zxt_code, :remark) "
"ON DUPLICATE KEY UPDATE "
"subject_code=VALUES(subject_code), subject_name=VALUES(subject_name), "
"zxt_code=VALUES(zxt_code), remark=VALUES(remark)"
)
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.execute(stmt, clean)
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.add(OperationLog(
action="import", target_type="zxt_account_dict", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
"upserted": len(clean), "before": before, "after": after, "source": source},
))
db.commit()
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"zxt_account_dict upsert 失败: {e}")
raise HTTPException(500, f"字典写入失败: {e}")
logger.info(f"zxt_account_dict UPSERT: bot={bot.get('name')} rows={len(clean)} "
f"before={before} after={after}")
return {"ok": True, "upserted": len(clean), "before": before, "after": after,
"inserted": max(after - before, 0), "updated": len(clean) - max(after - before, 0),
"table": "zxt_account_dict", "source": source}
@router.get("/risk-levels")
@risk_level("L1")
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):