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)):
+3 -1
View File
@@ -29,6 +29,7 @@ low/high 与 L1-L4 是两套正交分级,勿混:
POST /api/cma/bot-bridge/kpi-result — 写KPI实际值 (source_batch) (L2)
POST /api/cma/bot-kpis/{kpi_id}/value — 写KPI当前值 (L2)
POST /api/cma/bot/import — Excel批量导入KPI实际值 (L3)
POST /api/cma/bot/dict/zxt-account — 智享通科目字典 UPSERT(zxt_account_dict) (L3, 2026-09-12 新增)
POST /api/cma/bot-bridge/mpm-result — MPM分析结果→KPI实际值 (L3)
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行 (L2, 轻量verify_log)
POST /api/cma/bot/okr/create — 创建OKR Objective (L2, 管理类业务写,保留评审: 2026-09-03 P1待办)
@@ -89,10 +90,11 @@ API_RISK_MAP = {
"POST /api/cma/bot-bridge/verify/{action_plan_id}": "L2",
"POST /api/cma/bot/okr/create": "L2", # 单条OKR创建(方案清单未列出,按单条业务写归类)
"POST /api/cma/bot/ai/suggestions": "L2", # Bot高危建议创建通道(写操作语义 high,见文件头注释;本身只建建议不直写考核基准)
# ── L3 批量写/创建(3项) ──
# ── L3 批量写/创建(4项) ──
"POST /api/cma/bot/import": "L3",
"POST /api/cma/bot/kpis/create-with-links": "L3",
"POST /api/cma/bot-bridge/mpm-result": "L3",
"POST /api/cma/bot/dict/zxt-account": "L3", # 智享通科目字典 UPSERT(2026-09-12 ops-cma-dict-write-endpoint)
# ── L4 危险:Bot API面不存在(安全底线,不添加) ──
}
+48
View File
@@ -368,3 +368,51 @@ class TestOkrAndNlp:
resp2 = client.get("/api/cma/bot/nlp?intent=总览", headers=BOT_KEY)
assert resp2.status_code == 200
assert "overview" in resp2.json()
class TestDictZxtAccountWrite:
"""智享通科目字典写入端点(POST /api/cma/bot/dict/zxt-account)负例
说明: 正向 UPSERT 走 MySQL 专有 ON DUPLICATE KEY UPDATE,测试库为 SQLite
无法覆盖——正向/幂等/落库一致性由生产 curl 实测(见任务验收记录),
此处覆盖鉴权与参数校验(校验先于任何 DB 访问,故 SQLite 可测)。
"""
URL = "/api/cma/bot/dict/zxt-account"
def test_no_key_401(self, client: TestClient):
resp = client.post(self.URL, json=[{"CwAccountID": "999", "subject_code": "1"}])
assert resp.status_code == 401
def test_invalid_key_401(self, client: TestClient):
resp = client.post(self.URL, json=[{"CwAccountID": "999"}],
headers={"X-BOT-KEY": "wrong-key"})
assert resp.status_code == 401
def test_non_whitelist_field_400(self, client: TestClient):
resp = client.post(self.URL,
json=[{"CwAccountID": "999", "entity_id": 1}], headers=BOT_KEY)
assert resp.status_code == 400
assert "非白名单字段" in str(resp.json())
def test_missing_required_field_400(self, client: TestClient):
resp = client.post(self.URL, json=[{"subject_code": "1122"}], headers=BOT_KEY)
assert resp.status_code == 400
assert "CwAccountID" in str(resp.json())
def test_illegal_value_400(self, client: TestClient):
resp = client.post(self.URL, json=[{"CwAccountID": "9 99;DROP"}], headers=BOT_KEY)
assert resp.status_code == 400
def test_empty_array_400(self, client: TestClient):
resp = client.post(self.URL, json=[], headers=BOT_KEY)
assert resp.status_code == 400
def test_non_array_json_400(self, client: TestClient):
resp = client.post(self.URL, json={"CwAccountID": "999"}, headers=BOT_KEY)
assert resp.status_code == 400
def test_rows_wrapper_accepted_shape(self, client: TestClient):
"""{"rows":[...]} 包装形态被解析(校验失败仍 400,证明已进入校验而非形态拒绝)"""
resp = client.post(self.URL, json={"rows": [{"bad_field": 1}]}, headers=BOT_KEY)
assert resp.status_code == 400
assert "非白名单字段" in str(resp.json())
+4 -3
View File
@@ -49,12 +49,12 @@ class TestRiskMapCoverage:
assert not unlabeled, f"以下路由函数缺少 @risk_level 装饰器: {unlabeled}"
def test_risk_level_counts(self):
"""分级统计与方案一致:L1=21(20项清单+risk-levels端点)、L2=6(4项清单+okr/create+bot建议通道)、L3=3、L4=0"""
"""分级统计与方案一致:L1=21(20项清单+risk-levels端点)、L2=6(4项清单+okr/create+bot建议通道)、L3=4(+字典写入)、L4=0"""
from collections import Counter
counts = Counter(API_RISK_MAP.values())
assert counts["L1"] == 21, counts
assert counts["L2"] == 6, counts
assert counts["L3"] == 3, counts
assert counts["L3"] == 4, counts
assert counts["L4"] == 0, "Bot API面不得存在L4端点(安全底线)"
def test_no_l4_bot_endpoints(self):
@@ -91,11 +91,12 @@ class TestRiskLevelsEndpoint:
assert apis["POST /api/cma/bot/kpi-value-with-check"]["risk_level"] == "L2"
assert apis["POST /api/cma/bot/import"]["risk_level"] == "L3"
assert apis["POST /api/cma/bot/import"]["handling"] # 处理方式非空
assert apis["POST /api/cma/bot/dict/zxt-account"]["risk_level"] == "L3"
# 分级统计
assert data["summary"]["L1"] == 21
assert data["summary"]["L2"] == 6
assert data["summary"]["L3"] == 3
assert data["summary"]["L3"] == 4
assert data["summary"]["L4"] == 0