- 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)
391 lines
16 KiB
Python
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")
|