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:
@@ -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)
|
||||
|
||||
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user