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)):
|
||||
|
||||
@@ -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面不存在(安全底线,不添加) ──
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user