management.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. """认证与配置 API:所有接口中文契约同时出现在自动生成的 OpenAPI 中。"""
  2. from fastapi import APIRouter, Depends, Request, Query
  3. from sqlalchemy import select
  4. from app.api.dependencies import current_user, read_db, write_db
  5. from app.domain.models import AgentConfig, AgentExecution, AuditRule, Department, Role, User
  6. from app.application.seed import latest_configs
  7. from app.application.services import get, log, require, serialize, validate_agent, validate_rule
  8. from app.domain.rules import check_rule
  9. from app.infrastructure.security import check_password, hash_password, issue_tokens, public_user, refresh_record, validate_password
  10. from app.infrastructure.ai import ai, AIUnavailable
  11. from app.shared.common import BusinessError, envelope, paginate, utcnow
  12. from app.shared.config import settings
  13. from app.shared import schemas as S
  14. router = APIRouter()
  15. def ok(request, data, message="操作成功"):
  16. return envelope(data, request.state.request_id, message)
  17. @router.post("/auth/login", tags=["认证"])
  18. async def login(body: S.Login, request: Request, session=Depends(write_db)):
  19. """功能:验证密码并创建会话。参数:登录名/密码。返回:双令牌与用户。异常:401凭据错误,422参数错误。"""
  20. user = await session.scalar(select(User).where(User.username == body.username))
  21. if not user or not user.active or not check_password(body.password, user.password_hash):
  22. raise BusinessError("用户名或密码错误", 401, "LOGIN_FAILED")
  23. actor = await public_user(session, user)
  24. log(session, actor, "auth.login", "user", user.id)
  25. return ok(request, await issue_tokens(session, user))
  26. @router.post("/auth/refresh", tags=["认证"])
  27. async def refresh(body: S.Refresh, request: Request, session=Depends(write_db)):
  28. """功能:刷新并撤销旧凭据。参数:refresh_token。返回:新双令牌。异常:401已撤销/过期/停用。"""
  29. record = await refresh_record(session, body.refresh_token)
  30. user = await get(session, User, record.user_id)
  31. if not user.active:
  32. raise BusinessError("账号已停用", 401, "USER_DISABLED")
  33. record.revoked_at = utcnow()
  34. return ok(request, await issue_tokens(session, user))
  35. @router.post("/auth/logout", tags=["认证"])
  36. async def logout(body: S.Refresh, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  37. """功能:撤销本人刷新会话。参数:refresh_token。返回:撤销状态。异常:401失效,403非本人。"""
  38. record = await refresh_record(session, body.refresh_token)
  39. if record.user_id != actor["id"]:
  40. raise BusinessError("只能退出本人会话", 403)
  41. record.revoked_at = utcnow()
  42. log(session, actor, "auth.logout", "user", actor["id"])
  43. return ok(request, {"revoked": True})
  44. @router.get("/auth/me", tags=["认证"])
  45. async def me(request: Request, actor=Depends(current_user)):
  46. """功能:返回当前身份。参数:Bearer令牌。返回:无敏感字段的用户。异常:401失效或未登录。"""
  47. return ok(request, actor)
  48. @router.get("/users", tags=["用户"])
  49. async def users(request: Request, page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100),
  50. actor=Depends(current_user), session=Depends(read_db)):
  51. """功能:经理查看用户用于审批配置。参数:分页。返回:无密码用户列表。异常:403权限不足。"""
  52. require(actor, "manager")
  53. rows = (await session.scalars(select(User).order_by(User.id))).all()
  54. return ok(request, paginate([await public_user(session, item) for item in rows], page, page_size))
  55. @router.post("/users", status_code=201, tags=["用户"])
  56. async def create_user(body: S.UserCreate, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  57. """功能:管理员创建有效用户。参数:用户/部门/角色/密码。返回:用户。异常:403权限,409重名,422弱密码。"""
  58. require(actor, "admin")
  59. validate_password(body.password)
  60. if await session.scalar(select(User).where(User.username == body.username)):
  61. raise BusinessError("登录名已存在", 409, "USERNAME_EXISTS")
  62. department = await get(session, Department, body.department_id)
  63. if not department.active:
  64. raise BusinessError("部门已停用")
  65. role = await session.scalar(select(Role).where(Role.code == body.role))
  66. user = User(username=body.username, real_name=body.real_name, password_hash=hash_password(body.password),
  67. department_id=department.id, role_id=role.id)
  68. session.add(user)
  69. await session.flush()
  70. data = await public_user(session, user)
  71. log(session, actor, "user.created", "user", user.id, after=data)
  72. return ok(request, data)
  73. @router.patch("/users/{identifier}", tags=["用户"])
  74. async def patch_user(identifier: int, body: S.UserPatch, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  75. """功能:修改身份/停用。参数:ID和允许字段。返回:更新用户。异常:403权限,404不存在,409停用自己。"""
  76. require(actor, "admin")
  77. user = await get(session, User, identifier)
  78. if user.id == actor["id"] and (body.active is False or body.role is not None and body.role != "admin"):
  79. raise BusinessError("不能停用或降级当前管理员,避免失去管理入口", 409)
  80. before = await public_user(session, user)
  81. if body.department_id is not None:
  82. department = await get(session, Department, body.department_id)
  83. if not department.active:
  84. raise BusinessError("部门已停用")
  85. user.department_id = body.department_id
  86. if body.role is not None:
  87. user.role_id = (await session.scalar(select(Role).where(Role.code == body.role))).id
  88. if body.real_name is not None:
  89. user.real_name = body.real_name
  90. if body.active is not None:
  91. user.active = body.active
  92. data = await public_user(session, user)
  93. log(session, actor, "user.updated", "user", identifier, before, data)
  94. return ok(request, data)
  95. @router.get("/departments", tags=["部门"])
  96. async def departments(request: Request, actor=Depends(current_user), session=Depends(read_db)):
  97. """功能:提供部门选项。参数:当前身份。返回:部门列表。异常:401未登录。"""
  98. return ok(request, [serialize(item) for item in (await session.scalars(select(Department).order_by(Department.id))).all()])
  99. @router.post("/departments", status_code=201, tags=["部门"])
  100. async def create_department(body: S.DepartmentBody, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  101. """功能:创建部门。参数:名称/启用。返回:部门。异常:403权限,409重名。"""
  102. require(actor, "admin")
  103. if await session.scalar(select(Department).where(Department.name == body.name)):
  104. raise BusinessError("部门名称已存在", 409)
  105. row = Department(name=body.name, active=body.active)
  106. session.add(row)
  107. await session.flush()
  108. log(session, actor, "department.created", "department", row.id, after=serialize(row))
  109. return ok(request, serialize(row))
  110. @router.patch("/departments/{identifier}", tags=["部门"])
  111. async def patch_department(identifier: int, body: S.DepartmentBody, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  112. """功能:更名或停用。参数:ID/名称/active。返回:部门。异常:403权限,404不存在,409重名。"""
  113. require(actor, "admin")
  114. row = await get(session, Department, identifier)
  115. if await session.scalar(select(Department).where(Department.name == body.name, Department.id != identifier)):
  116. raise BusinessError("部门名称已存在", 409)
  117. before = serialize(row)
  118. row.name, row.active = body.name, body.active
  119. log(session, actor, "department.updated", "department", row.id, before, serialize(row))
  120. return ok(request, serialize(row))
  121. @router.get("/agents", tags=["Agent"])
  122. async def agents(request: Request, actor=Depends(current_user), session=Depends(read_db)):
  123. """功能:查看节点配置和实际统计。参数:当前身份。返回:五节点列表。异常:403权限不足。"""
  124. require(actor, "auditor")
  125. rows = await latest_configs(session, AgentConfig, "node_name")
  126. executions = (await session.scalars(select(AgentExecution))).all()
  127. values = []
  128. for key, row in rows.items():
  129. items = [item for item in executions if item.node_name == key]
  130. values.append({**serialize(row), "executions": len(items), "degraded_count": sum(item.status == "degraded" for item in items),
  131. "average_ms": round(sum(item.duration_ms for item in items) / len(items)) if items else None})
  132. return ok(request, values)
  133. @router.get("/agents/{node_name}", tags=["Agent"])
  134. async def agent_detail(node_name: str, request: Request, actor=Depends(current_user), session=Depends(read_db)):
  135. """功能:查看节点全部版本。参数:固定节点名。返回:当前配置/历史。异常:403权限,404未知节点。"""
  136. require(actor, "auditor")
  137. rows = (await session.scalars(select(AgentConfig).where(AgentConfig.node_name == node_name).order_by(AgentConfig.version.desc()))).all()
  138. if not rows:
  139. raise BusinessError("节点不存在", 404)
  140. return ok(request, {"current": serialize(rows[0]), "history": [serialize(item) for item in rows]})
  141. @router.patch("/agents/{node_name}", tags=["Agent"])
  142. async def patch_agent(node_name: str, body: S.ConfigPatch, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  143. """功能:创建不可变配置版本。参数:节点/预期版本/参数。返回:新配置。异常:403权限,409版本冲突,422非法配置。"""
  144. require(actor, "admin")
  145. old = (await latest_configs(session, AgentConfig, "node_name")).get(node_name)
  146. if not old:
  147. raise BusinessError("节点不存在", 404)
  148. if body.expected_version != old.version:
  149. raise BusinessError("配置已更新,请刷新", 409, "VERSION_CONFLICT")
  150. config = body.config if body.config is not None else old.config_json
  151. enabled = body.enabled if body.enabled is not None else old.enabled
  152. validate_agent(node_name, config, enabled)
  153. row = AgentConfig(node_name=node_name, name=old.name, version=old.version + 1, enabled=enabled, config_json=config)
  154. session.add(row)
  155. await session.flush()
  156. log(session, actor, "agent.updated", "agent", row.id, serialize(old), serialize(row))
  157. return ok(request, serialize(row))
  158. @router.post("/agents/{node_name}/test", tags=["Agent"])
  159. async def test_agent(node_name: str, request: Request, actor=Depends(current_user), session=Depends(read_db)):
  160. """功能:真实连接或确定性节点测试。参数:节点名。返回:可用/降级原因。异常:403权限,404未知节点。"""
  161. require(actor, "auditor")
  162. row = (await latest_configs(session, AgentConfig, "node_name")).get(node_name)
  163. if not row:
  164. raise BusinessError("节点不存在", 404)
  165. if node_name not in {"extract", "risk_detect"}:
  166. return ok(request, {"status": "success", "message": "本地确定性节点已加载"})
  167. base = (settings.vlm_base_url or settings.llm_base_url) if node_name == "extract" else settings.llm_base_url
  168. if not row.enabled or not base:
  169. return ok(request, {"status": "degraded", "message": "模型未配置或节点模型能力停用"})
  170. try:
  171. key = settings.ai_api_key
  172. response = await ai.http.get(base.rstrip("/") + "/models", headers={"Authorization": "Bearer " + key} if key else {}, timeout=row.config_json.get("timeout", 20))
  173. response.raise_for_status()
  174. models = [item["id"] for item in response.json().get("data", [])]
  175. expected = (settings.vlm_model or settings.llm_model) if node_name == "extract" else settings.llm_model
  176. return ok(request, {"status": "success" if expected in models else "degraded", "models": models,
  177. "message": "模型列表已查询;该测试不代表实际识别效果"})
  178. except Exception:
  179. return ok(request, {"status": "degraded", "message": "真实模型服务连接失败"})
  180. @router.get("/rules", tags=["规则"])
  181. async def rules(request: Request, page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100),
  182. code: str | None = None, enabled: bool | None = None, actor=Depends(current_user), session=Depends(read_db)):
  183. """功能:查看生效规则。参数:分页/code/enabled。返回:最新版本列表。异常:403权限不足。"""
  184. require(actor, "auditor")
  185. rows = list((await latest_configs(session, AuditRule, "code")).values())
  186. rows = [item for item in rows if (code is None or item.code == code) and (enabled is None or item.enabled == enabled)]
  187. return ok(request, paginate([serialize(item) for item in rows], page, page_size))
  188. @router.get("/rules/{code}", tags=["规则"])
  189. async def rule_detail(code: str, request: Request, actor=Depends(current_user), session=Depends(read_db)):
  190. """功能:追踪规则历史。参数:规则code。返回:当前及所有版本。异常:403权限,404未知规则。"""
  191. require(actor, "auditor")
  192. rows = (await session.scalars(select(AuditRule).where(AuditRule.code == code).order_by(AuditRule.version.desc()))).all()
  193. if not rows:
  194. raise BusinessError("规则不存在", 404)
  195. return ok(request, {"current": serialize(rows[0]), "history": [serialize(item) for item in rows]})
  196. @router.patch("/rules/{code}", tags=["规则"])
  197. async def patch_rule(code: str, body: S.RulePatch, request: Request, actor=Depends(current_user), session=Depends(write_db)):
  198. """功能:保存规则新版本。参数:预期版本/参数/范围/开关。返回:新版本。异常:403权限,409并发,422核心检查绕过。"""
  199. require(actor, "admin")
  200. old = (await latest_configs(session, AuditRule, "code")).get(code)
  201. if not old:
  202. raise BusinessError("规则不存在", 404)
  203. if body.expected_version != old.version:
  204. raise BusinessError("规则版本已更新", 409, "VERSION_CONFLICT")
  205. params = body.params if body.params is not None else old.params_json
  206. scope = body.scope if body.scope is not None else old.scope_json
  207. severity, enabled = body.severity or old.severity, old.enabled if body.enabled is None else body.enabled
  208. validate_rule(code, params, scope, severity, enabled)
  209. row = AuditRule(code=code, name=old.name, version=old.version + 1, enabled=enabled, severity=severity, params_json=params, scope_json=scope)
  210. session.add(row)
  211. await session.flush()
  212. log(session, actor, "rule.updated", "rule", row.id, serialize(old), serialize(row))
  213. return ok(request, serialize(row))
  214. @router.post("/rules/{code}/test", tags=["规则"])
  215. async def test_rule(code: str, body: S.RuleTest, request: Request, actor=Depends(current_user), session=Depends(read_db)):
  216. """功能:独立试算不改变正式结果。参数:invoice/context。返回:规则证据。异常:403权限,404规则,422上下文非法。"""
  217. require(actor, "auditor")
  218. row = (await latest_configs(session, AuditRule, "code")).get(code)
  219. if not row:
  220. raise BusinessError("规则不存在", 404)
  221. if code == "policy_compliance":
  222. from app.application.pipeline import policy_check
  223. from app.application.services import policy_snapshot
  224. value = await policy_check(serialize(row), body.invoice, {"reimbursement": {"type": body.context.get("type", 1)}, "context": body.context, "policies": await policy_snapshot(session)})
  225. else:
  226. try:
  227. value = check_rule(serialize(row), body.invoice, body.context)
  228. except (ValueError, TypeError, KeyError):
  229. raise BusinessError("试算字段或上下文不符合规则契约", 422, "RULE_TEST_INVALID")
  230. return ok(request, value)