Files
fa-agent/finance/formula_views.py
T
Jayway Liu 509b406254
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 1s
AI Review / review (pull_request) Successful in 16s
fix(formula): 导出文件安全、清理回收、撤权校验与定义库可靠性(F01/F02/F05-F08)
- F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与
  清理命令统一使用,只清理受控目录内本次创建的文件
- F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件
- F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409;
  保留最近 20 版历史;支持个人/团队共享;审计与变更同事务
- F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验
- F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用
  服务端 Decimal 表达式求值参考
- F08: 导出任务执行/下载阶段重校验账号有效性与角色
- 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传
- 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker)
2026-10-11 09:43:27 +08:00

391 lines
16 KiB
Python

import json
from pathlib import Path
import httpx
from django.conf import settings
from django.db import transaction
from django.db.models import Q
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 Dataset, FormulaDefinition, FormulaExport
from .views import APIError, audit, body, dataset, endpoint, limit
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)
or len(item["content"]) > 1000
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)
.filter(Q(user=request.user) | Q(shared=True))
.select_related("user")
.order_by("normalized_name")
)
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": item.user_id == request.user.pk,
"owner": item.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(),
}
for item in items
]
}
)
data = body(request)
definition = validate_column(data.get("definition"))
shared = data.get("shared", False)
if type(shared) is not bool:
raise APIError("共享选项无效")
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
requested_revision = data.get("revision")
with transaction.atomic():
Dataset.objects.select_for_update().get(pk=ds.pk)
existing = (
FormulaDefinition.objects.select_for_update()
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
.first()
)
if existing:
if requested_revision is not None and (
type(requested_revision) is not int or requested_revision != existing.revision
):
raise APIError("公式定义已在其他窗口更新,请重新载入", 409)
# ponytail: retain 20 revisions; use a separate history table if long-term retention is required.
existing.history = [
*existing.history,
{
"revision": existing.revision,
"definition": existing.definition,
"updated_at": existing.updated_at.isoformat(),
},
][-20:]
existing.definition = definition
existing.shared = shared if "shared" in data else existing.shared
existing.revision += 1
existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"])
item, created = existing, False
else:
if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item = FormulaDefinition.objects.create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
definition=definition,
shared=shared,
)
created = True
audit(
request,
"formula_definition.save",
item.pk,
{
"dataset": str(ds.pk),
"name": definition["name"],
"shared": item.shared,
"revision": item.revision,
},
)
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": True,
"owner": request.user.username,
"history": item.history,
"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 = FormulaDefinition.objects.select_for_update().get(pk=item.pk)
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
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):
if not request.user.is_active or request.member.role not in ("admin", "analyst"):
raise APIError("没有此导出任务的访问权限", 403)
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",))
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",))
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")