2026-05-25 16:59:59 +08:00
|
|
|
|
"""审核队列(SQLite)。"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from as_platform.db.engine import session_scope
|
|
|
|
|
|
from as_platform.db.models import Approval, User
|
|
|
|
|
|
from as_platform.config import LANE_DATA_VIZ_ENABLED
|
2026-06-03 11:40:21 +08:00
|
|
|
|
from as_platform.integrations.feishu_notify import send_chat_async
|
2026-05-25 16:59:59 +08:00
|
|
|
|
|
|
|
|
|
|
ACTIONS_REQUIRING_APPROVAL = {
|
|
|
|
|
|
"build_dms", "build_lane", "enable_pack", "disable_pack",
|
|
|
|
|
|
"train_dms", "train_lane", "eval_dms", "promote_dms",
|
|
|
|
|
|
"pipeline_dms", "register_batch", "eval_lane", "visualize_dms", "visualize_lane",
|
2026-06-03 11:40:21 +08:00
|
|
|
|
"delivery_ingest",
|
2026-05-25 16:59:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 11:40:21 +08:00
|
|
|
|
REJECTION_CATEGORIES = {
|
|
|
|
|
|
"data_quality": "数据质量问题",
|
|
|
|
|
|
"wrong_params": "参数配置有误",
|
|
|
|
|
|
"duplicate": "重复提交",
|
|
|
|
|
|
"not_needed": "无需此操作",
|
|
|
|
|
|
"permission": "权限不足",
|
|
|
|
|
|
"other": "其他原因",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
REJECTION_CATEGORY_LABEL = {k: v for k, v in REJECTION_CATEGORIES.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 16:59:59 +08:00
|
|
|
|
ACTION_LABELS = {
|
|
|
|
|
|
"build_dms": "DMS 入库 (build)",
|
|
|
|
|
|
"build_lane": "车道线合并列表 (build lane)",
|
|
|
|
|
|
"enable_pack": "启用训练数据包",
|
|
|
|
|
|
"disable_pack": "停用训练数据包",
|
|
|
|
|
|
"train_dms": "DMS 训练",
|
|
|
|
|
|
"train_lane": "车道线训练",
|
|
|
|
|
|
"eval_dms": "DMS 评估",
|
|
|
|
|
|
"eval_lane": "车道线评估",
|
|
|
|
|
|
"visualize_dms": "DMS 检测可视化",
|
|
|
|
|
|
"visualize_lane": "车道线可视化",
|
|
|
|
|
|
"promote_dms": "DMS 模型晋级",
|
|
|
|
|
|
"pipeline_dms": "DMS 半自动流水线",
|
|
|
|
|
|
"register_batch": "登记批次元数据",
|
2026-06-03 11:40:21 +08:00
|
|
|
|
"delivery_ingest": "数据送标入湖",
|
2026-05-25 16:59:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _now() -> datetime:
|
|
|
|
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _new_id() -> str:
|
|
|
|
|
|
return f"apr-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def submit_approval(
|
|
|
|
|
|
action: str,
|
|
|
|
|
|
params: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
submitted_by: str | None = None,
|
|
|
|
|
|
submitted_by_user_id: int | None = None,
|
|
|
|
|
|
note: str | None = None,
|
|
|
|
|
|
auto_execute: bool = False,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
if action not in ACTIONS_REQUIRING_APPROVAL:
|
|
|
|
|
|
raise ValueError(f"未知动作: {action},允许: {sorted(ACTIONS_REQUIRING_APPROVAL)}")
|
|
|
|
|
|
if action == "visualize_lane" and not LANE_DATA_VIZ_ENABLED:
|
|
|
|
|
|
raise ValueError("车道线数据可视化暂未开放")
|
|
|
|
|
|
|
|
|
|
|
|
from as_platform.agents.trace import trace_span
|
|
|
|
|
|
|
|
|
|
|
|
with session_scope() as db:
|
|
|
|
|
|
rec = Approval(
|
|
|
|
|
|
id=_new_id(),
|
|
|
|
|
|
status="pending",
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
action_label=ACTION_LABELS.get(action, action),
|
|
|
|
|
|
note=note,
|
|
|
|
|
|
submitted_by_name=submitted_by,
|
|
|
|
|
|
submitted_by_user_id=submitted_by_user_id,
|
|
|
|
|
|
submitted_at=_now(),
|
|
|
|
|
|
)
|
|
|
|
|
|
rec.set_params(params)
|
|
|
|
|
|
db.add(rec)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
out = rec.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
with trace_span("approval_submit", approval_id=out["id"], action=action):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
if auto_execute:
|
|
|
|
|
|
return approve_and_execute(out["id"], reviewed_by="system", comment="auto_execute")
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
# 飞书通知 + 审计日志
|
|
|
|
|
|
label = ACTION_LABELS.get(action, action)
|
|
|
|
|
|
send_chat_async(f"📋 新审核提交\n{label}\n提单人: {submitted_by or '—'}\n备注: {note or '—'}")
|
|
|
|
|
|
from as_platform.audit.log_utils import log_op
|
|
|
|
|
|
log_op(user_id=submitted_by_user_id, user_name=submitted_by or "", category="audit", action="submit_approval",
|
|
|
|
|
|
target_type="approval", target_id=out["id"], summary=f"提交审核: {label}",
|
|
|
|
|
|
detail={"action": action, "params": params, "note": note})
|
|
|
|
|
|
|
2026-05-25 16:59:59 +08:00
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 11:40:21 +08:00
|
|
|
|
def list_approvals(
|
|
|
|
|
|
status: str | None = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
offset: int = 0,
|
|
|
|
|
|
limit: int = 20,
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-05-25 16:59:59 +08:00
|
|
|
|
with session_scope() as db:
|
|
|
|
|
|
q = db.query(Approval).order_by(Approval.submitted_at.desc())
|
|
|
|
|
|
if status:
|
|
|
|
|
|
q = q.filter(Approval.status == status)
|
2026-06-03 11:40:21 +08:00
|
|
|
|
total = q.count()
|
|
|
|
|
|
rows = q.offset(max(0, offset)).limit(max(1, limit)).all()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"items": [a.to_dict() for a in rows],
|
|
|
|
|
|
"total": total,
|
|
|
|
|
|
"offset": offset,
|
|
|
|
|
|
"limit": limit,
|
|
|
|
|
|
}
|
2026-05-25 16:59:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_approval(record_id: str) -> dict[str, Any] | None:
|
|
|
|
|
|
with session_scope() as db:
|
|
|
|
|
|
rec = db.get(Approval, record_id)
|
|
|
|
|
|
return rec.to_dict() if rec else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update(record_id: str, **patch: Any) -> dict[str, Any] | None:
|
|
|
|
|
|
with session_scope() as db:
|
|
|
|
|
|
rec = db.get(Approval, record_id)
|
|
|
|
|
|
if not rec:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for k, v in patch.items():
|
|
|
|
|
|
if k == "result" and isinstance(v, dict):
|
|
|
|
|
|
rec.set_result(v)
|
|
|
|
|
|
elif hasattr(rec, k):
|
|
|
|
|
|
setattr(rec, k, v)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
return rec.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def approve_and_execute(
|
|
|
|
|
|
record_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
reviewed_by: str | None = None,
|
|
|
|
|
|
reviewed_by_user_id: int | None = None,
|
|
|
|
|
|
comment: str | None = None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
rec = get_approval(record_id)
|
|
|
|
|
|
if not rec:
|
|
|
|
|
|
raise ValueError(f"审核单不存在: {record_id}")
|
|
|
|
|
|
if rec.get("status") != "pending":
|
|
|
|
|
|
raise ValueError(f"当前状态不可审批: {rec.get('status')}")
|
|
|
|
|
|
|
|
|
|
|
|
from as_platform.agents.trace import trace_span
|
|
|
|
|
|
from as_platform.jobs.queue import enqueue_job
|
|
|
|
|
|
|
|
|
|
|
|
_update(
|
|
|
|
|
|
record_id,
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
reviewed_by_name=reviewed_by,
|
|
|
|
|
|
reviewed_by_user_id=reviewed_by_user_id,
|
|
|
|
|
|
reviewed_at=_now(),
|
|
|
|
|
|
review_comment=comment,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with trace_span("approval_approved", approval_id=record_id, action=rec["action"]):
|
|
|
|
|
|
job = enqueue_job(rec["action"], rec.get("params") or {}, approval_id=record_id, async_run=True)
|
|
|
|
|
|
|
|
|
|
|
|
_update(record_id, job_id=job.get("id"), status="running")
|
2026-06-03 11:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
# 飞书通知 + 审计日志
|
|
|
|
|
|
label = ACTION_LABELS.get(rec["action"], rec["action"])
|
|
|
|
|
|
submitter = rec.get("submitted_by") or rec.get("submitted_by_name") or ""
|
|
|
|
|
|
send_chat_async(f"✅ 审核通过\n{label}\n审核人: {reviewed_by or '—'}\n提单人: {submitter}")
|
|
|
|
|
|
from as_platform.audit.log_utils import log_op
|
|
|
|
|
|
log_op(user_id=reviewed_by_user_id, user_name=reviewed_by or "", category="audit", action="approve",
|
|
|
|
|
|
target_type="approval", target_id=record_id, summary=f"审核通过: {label}",
|
|
|
|
|
|
detail={"comment": comment})
|
|
|
|
|
|
|
2026-05-25 16:59:59 +08:00
|
|
|
|
return get_approval(record_id) or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reject_approval(
|
|
|
|
|
|
record_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
reviewed_by: str | None = None,
|
|
|
|
|
|
reviewed_by_user_id: int | None = None,
|
|
|
|
|
|
comment: str | None = None,
|
2026-06-03 11:40:21 +08:00
|
|
|
|
rejection_category: str = "",
|
2026-05-25 16:59:59 +08:00
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
rec = get_approval(record_id)
|
|
|
|
|
|
if not rec:
|
|
|
|
|
|
raise ValueError(f"审核单不存在: {record_id}")
|
|
|
|
|
|
if rec.get("status") != "pending":
|
|
|
|
|
|
raise ValueError(f"当前状态不可驳回: {rec.get('status')}")
|
2026-06-03 11:40:21 +08:00
|
|
|
|
out = _update(
|
2026-05-25 16:59:59 +08:00
|
|
|
|
record_id,
|
|
|
|
|
|
status="rejected",
|
|
|
|
|
|
reviewed_by_name=reviewed_by,
|
|
|
|
|
|
reviewed_by_user_id=reviewed_by_user_id,
|
|
|
|
|
|
reviewed_at=_now(),
|
|
|
|
|
|
review_comment=comment,
|
2026-06-03 11:40:21 +08:00
|
|
|
|
rejection_category=rejection_category or "",
|
2026-05-25 16:59:59 +08:00
|
|
|
|
) or {}
|
2026-06-03 11:40:21 +08:00
|
|
|
|
# 飞书通知:审核驳回
|
|
|
|
|
|
label = ACTION_LABELS.get(rec["action"], rec["action"])
|
|
|
|
|
|
cat = REJECTION_CATEGORIES.get(rejection_category, rejection_category) if rejection_category else ""
|
|
|
|
|
|
submitter = rec.get("submitted_by") or rec.get("submitted_by_name") or ""
|
|
|
|
|
|
reason_text = f"\n原因: {cat}" if cat else ""
|
|
|
|
|
|
send_chat_async(f"❌ 审核驳回\n{label}\n审核人: {reviewed_by or '—'}\n提单人: {submitter}{reason_text}\n意见: {comment or '—'}")
|
|
|
|
|
|
from as_platform.audit.log_utils import log_op
|
|
|
|
|
|
log_op(user_id=reviewed_by_user_id, user_name=reviewed_by or "", category="audit", action="reject",
|
|
|
|
|
|
target_type="approval", target_id=record_id, summary=f"审核驳回: {label}",
|
|
|
|
|
|
detail={"comment": comment, "rejection_category": rejection_category})
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from as_platform.deliveries.service import mark_delivery_rejected_by_approval
|
|
|
|
|
|
mark_delivery_rejected_by_approval(record_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def batch_approve(
|
|
|
|
|
|
record_ids: list[str],
|
|
|
|
|
|
*,
|
|
|
|
|
|
reviewed_by: str | None = None,
|
|
|
|
|
|
reviewed_by_user_id: int | None = None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""批量通过审核。返回 {approved, failed, errors}。"""
|
|
|
|
|
|
approved: list[str] = []
|
|
|
|
|
|
errors: list[dict[str, str]] = []
|
|
|
|
|
|
for rid in record_ids:
|
|
|
|
|
|
try:
|
|
|
|
|
|
approve_and_execute(rid, reviewed_by=reviewed_by, reviewed_by_user_id=reviewed_by_user_id)
|
|
|
|
|
|
approved.append(rid)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
errors.append({"id": rid, "error": str(e)})
|
|
|
|
|
|
return {"approved": len(approved), "failed": len(errors), "errors": errors}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def batch_reject(
|
|
|
|
|
|
record_ids: list[str],
|
|
|
|
|
|
*,
|
|
|
|
|
|
reviewed_by: str | None = None,
|
|
|
|
|
|
reviewed_by_user_id: int | None = None,
|
|
|
|
|
|
comment: str | None = None,
|
|
|
|
|
|
rejection_category: str = "",
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""批量驳回审核。"""
|
|
|
|
|
|
rejected: list[str] = []
|
|
|
|
|
|
errors: list[dict[str, str]] = []
|
|
|
|
|
|
for rid in record_ids:
|
|
|
|
|
|
try:
|
|
|
|
|
|
reject_approval(rid, reviewed_by=reviewed_by, reviewed_by_user_id=reviewed_by_user_id,
|
|
|
|
|
|
comment=comment, rejection_category=rejection_category)
|
|
|
|
|
|
rejected.append(rid)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
errors.append({"id": rid, "error": str(e)})
|
|
|
|
|
|
return {"rejected": len(rejected), "failed": len(errors), "errors": errors}
|