Files
fa-agent/finance/formula_views.py
T
Jayway Liu 0aaa87e31a fix(definitions): F05 定义库保存原子化并引入 revision 冲突检测
- FormulaDefinition 增加 revision 字段(迁移 0005)
- 保存接口在单事务内锁定用户所属行序列化并发:配额(50/数据集)检查与写入原子化,
  不再可能产生第 51 条;唯一约束竞争返回 409
- 同名更新必须携带匹配的 revision,两个窗口不再相互静默覆盖;
  GET 列表返回当前 revision;保存增加 30/小时限流
- 审计与定义变更(保存/删除)同一事务提交,审计失败一并回滚
- 前端保存同名定义时携带已知 revision,并明示“同名覆盖”行为
- 测试:revision 边界(缺失/过期/正确/再次过期)、配额拒绝与释放、
  审计失败回滚;PostgreSQL 并发配额用例独立门禁(本机 SQLite 跳过)
2026-09-26 01:37:55 +08:00

359 lines
15 KiB
Python

import json
from pathlib import Path
import httpx
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import FileResponse, JsonResponse
from django.utils import timezone
from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
from .models import FormulaDefinition, FormulaExport, Membership
from .views import APIError, audit, body, dataset, endpoint, limit
MAX_DEFINITIONS_PER_DATASET = 50
def design_column(messages, ds, ctx):
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
if not isinstance(messages, list) or not 1 <= len(messages) <= 12:
raise APIError("对话消息格式无效")
if any(
not isinstance(item, dict)
or set(item) != {"role", "content"}
or item["role"] not in ("user", "assistant")
or not isinstance(item["content"], str)
# 2000 字符容纳载入已保存定义后的完整对话上下文
or len(item["content"]) > 2000
for item in messages
):
raise APIError("对话消息内容无效")
if (
messages[0]["role"] != "user"
or messages[-1]["role"] != "user"
or any(left["role"] == right["role"] for left, right in zip(messages, messages[1:]))
):
raise APIError("对话消息必须按用户描述、助手追问、用户补充的顺序提交")
available = available_metrics(ds)
def expression_schema(depth=0):
leaf = [
{
"type": "object",
"properties": {"metric": {"type": "string", "enum": list(available)}},
"required": ["metric"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"number": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
},
"required": ["number"],
"additionalProperties": False,
},
]
if depth >= 8:
return {"anyOf": leaf}
return {
"anyOf": [
*leaf,
{
"type": "object",
"properties": {
"operator": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]},
"left": expression_schema(depth + 1),
"right": expression_schema(depth + 1),
},
"required": ["operator", "left", "right"],
"additionalProperties": False,
},
]
}
schema = {
"type": "object",
"properties": {
"complete": {"type": "boolean"},
"questions": {"type": "array", "items": {"type": "string"}},
"column": {
"anyOf": [
{"type": "null"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"meaning": {"type": "string"},
"scope": {"type": "string"},
"missing": {"type": "string", "enum": ["结果不可用(#N/A);零分母不可用"]},
"expression": expression_schema(),
},
"required": ["name", "meaning", "scope", "missing", "expression"],
"additionalProperties": False,
},
]
},
},
"required": ["complete", "questions", "column"],
"additionalProperties": False,
}
system = (
"你是财务计算定义助手。只可用给定指标生成表达式树,不可输出公式文本、SQL或新字段。"
"available_metrics 是当前数据集可计算指标;其他指标依赖字段未映射,必须说明并追问,不可猜测或替代。"
"必须确认列名、含义、适用范围和缺失值/零分母处理。缺少任何信息时 complete=false、column=null,提出具体中文问题。"
"指标节点{metric:指标key},常数{number:数值},运算{operator:add|subtract|multiply|divide,left:...,right:...}。"
"本系统当前仅支持结果不可用(Excel #N/A)且零分母不可用,不支持按零替代或跳过缺失项。"
"用户补充信息后结合整段对话重新判断,只有公式含义无歧义且能表达时 complete=true。"
+ json.dumps(
{
"metrics": METRICS,
"available_metrics": available,
"unavailable_metrics": {key: value for key, value in METRICS.items() if key not in available},
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("current", "baseline", "filters", "group")},
},
ensure_ascii=False,
)
)
payload = {
"model": settings.OPENAI_MODEL,
"store": False,
"max_output_tokens": 1400,
"input": [{"role": "system", "content": system}, *messages],
"text": {
"format": {
"type": "json_schema",
"name": "formula_column_design",
"strict": True,
"schema": schema,
}
},
}
try:
with httpx.Client(timeout=30) as client:
response = client.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
json=payload,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or result.get("status") != "completed":
raise ValueError("incomplete response")
text = next(c["text"] for o in result["output"] for c in o["content"] if c["type"] == "output_text")
design = json.loads(text)
if (
set(design) != {"complete", "questions", "column"}
or type(design["complete"]) is not bool
or not isinstance(design["questions"], list)
or len(design["questions"]) > 8
):
raise ValueError("invalid response")
if design["complete"]:
if design["column"] is None:
raise ValueError("complete response has no column")
design["column"] = validate_column(design["column"])
try:
ensure_available_metrics(ds, design["column"])
except ValueError as exc:
return {"complete": False, "questions": [str(exc)], "column": None}
if design["questions"]:
raise ValueError("complete response contains questions")
elif (
design["column"] is not None
or not design["questions"]
or any(not isinstance(q, str) or not q.strip() or len(q) > 500 for q in design["questions"])
):
raise ValueError("incomplete response malformed")
return design
except (httpx.HTTPError, ValueError, KeyError, TypeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了不安全的计算定义;未创建工作簿", 502) from exc
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_design(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
if request.member.departments:
raise APIError("科室授权账号不可定义跨范围公式导出", 403)
ctx = context(ds, data.get("context", {}))
if data.get("consent") is not True:
raise APIError("请先同意发送列描述和筛选范围到外部 AI", 400)
limit(f"formula-design:{request.user.pk}", 20, 60)
result = design_column(data.get("messages"), ds, ctx)
audit(request, "formula_export.design", ds.pk, {"complete": result["complete"]})
return JsonResponse(result)
@endpoint(("GET", "POST"), roles=("admin", "analyst"))
def formula_definitions(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET":
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"updated_at": item.updated_at.isoformat(),
}
for item in items
]
}
)
data = body(request)
definition = validate_column(data.get("definition"))
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
limit(f"formula-definition:{request.user.pk}", 30, 3600)
with transaction.atomic():
# 锁定稳定的用户所属行序列化同一用户的并发保存,使配额检查与写入原子化。
Membership.objects.select_for_update().get(pk=request.member.pk)
existing = (
FormulaDefinition.objects.select_for_update()
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
.first()
)
if existing:
# 同名更新按 revision 做乐观并发控制,避免两个窗口相互静默覆盖。
if data.get("revision") != existing.revision:
raise APIError("定义已在其他窗口更新,请刷新后重试", 409)
existing.definition, existing.revision = definition, existing.revision + 1
existing.save(update_fields=["definition", "revision", "updated_at"])
item, created = existing, False
else:
if (
FormulaDefinition.objects.filter(dataset=ds, user=request.user).count()
>= MAX_DEFINITIONS_PER_DATASET
):
raise APIError(f"每个数据集最多保存 {MAX_DEFINITIONS_PER_DATASET} 个公式定义")
try:
item = FormulaDefinition.objects.create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
definition=definition,
)
except IntegrityError as exc:
raise APIError("定义保存发生并发冲突,请重试", 409) from exc
created = True
# 审计与定义变更在同一事务内提交;审计失败时变更一并回滚。
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
)
@endpoint(("DELETE",), roles=("admin", "analyst"))
def formula_definition_delete(request, dataset_id, definition_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
item = FormulaDefinition.objects.filter(pk=definition_id, dataset=ds, user=request.user).first()
if not item:
raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "")
with transaction.atomic():
item.delete()
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
return JsonResponse({"deleted": True})
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_export_create(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可创建公式导出", 403)
data = body(request)
ctx = context(ds, data)
definitions = data.get("custom_columns", [])
if not isinstance(definitions, list) or len(definitions) > 10:
raise APIError("自定义列最多 10 个")
for item in definitions:
validate_column(item)
try:
ensure_available_metrics(ds, item)
except ValueError as exc:
raise APIError(str(exc)) from exc
limit(f"formula-export:{request.user.pk}", 5, 3600)
job = FormulaExport.objects.create(
dataset=ds,
user=request.user,
context={**ctx, "custom_columns": definitions},
departments=list(request.member.departments),
)
audit(
request,
"formula_export.create",
job.pk,
{"dataset": str(ds.pk), "columns": [x["name"] for x in definitions]},
)
return JsonResponse({"id": str(job.pk), "status": job.status}, status=202)
def _owned_job(request, dataset_id, export_id):
job = FormulaExport.objects.filter(
pk=export_id,
dataset_id=dataset_id,
dataset__organization=request.member.organization,
user=request.user,
).first()
if not job:
raise APIError("导出任务不存在", 404)
if set(request.member.departments) != set(job.departments):
raise APIError("授权范围已变化,不能访问此导出", 403)
return job
@endpoint(("GET",), roles=("admin", "analyst"))
def formula_export_status(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
return JsonResponse(
{
"id": str(job.pk),
"status": job.status,
"error": "生成失败,请联系管理员并提供任务编号" if job.status == "failed" else "",
"expires_at": job.expires_at.isoformat() if job.expires_at else None,
}
)
@endpoint(("GET",), roles=("admin", "analyst"))
def formula_export_download(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
raise APIError("文件尚未就绪或已过期", 410 if job.status == "succeeded" else 409)
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
candidate = Path(job.path)
expected = export_root / str(job.user_id) / f"{job.pk}.xlsx"
root = export_root.resolve()
path = candidate.resolve()
if (
export_root.is_symlink()
or candidate.is_symlink()
or candidate.parent.is_symlink()
or path != expected.resolve()
or not path.is_relative_to(root)
or not path.is_file()
):
raise APIError("导出文件不存在", 410)
audit(request, "formula_export.download", job.pk)
return FileResponse(path.open("rb"), as_attachment=True, filename="traceiq-formula-analysis.xlsx")