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)
This commit is contained in:
+88
-32
@@ -6,6 +6,9 @@ import {
|
||||
roleLabels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type FormulaColumn,
|
||||
type FormulaExpression,
|
||||
type SavedFormulaDefinition,
|
||||
type Dataset,
|
||||
type Metric,
|
||||
type Preferences,
|
||||
@@ -14,21 +17,6 @@ import {
|
||||
|
||||
type User = NonNullable<Session["user"]>;
|
||||
|
||||
type FormulaExpression =
|
||||
| { metric: Metric }
|
||||
| { number: number }
|
||||
| {
|
||||
operator: "add" | "subtract" | "multiply" | "divide";
|
||||
left: FormulaExpression;
|
||||
right: FormulaExpression;
|
||||
};
|
||||
|
||||
type SavedFormulaDefinition = {
|
||||
id: string;
|
||||
definition: Record<string, unknown>;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function describeExpression(expression: FormulaExpression): string {
|
||||
if ("metric" in expression) return labels[expression.metric];
|
||||
if ("number" in expression) return String(expression.number);
|
||||
@@ -827,7 +815,7 @@ export function FormulaExportDialog({
|
||||
{ role: "user" | "assistant"; content: string }[]
|
||||
>([]),
|
||||
[draft, setDraft] = useState(""),
|
||||
[column, setColumn] = useState<Record<string, unknown> | null>(null),
|
||||
[column, setColumn] = useState<FormulaColumn | null>(null),
|
||||
[questions, setQuestions] = useState<string[]>([]),
|
||||
[task, setTask] = useState<{
|
||||
id: string;
|
||||
@@ -836,9 +824,30 @@ export function FormulaExportDialog({
|
||||
}>(),
|
||||
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
|
||||
[selectedDefinition, setSelectedDefinition] = useState(""),
|
||||
[shared, setShared] = useState(false),
|
||||
[loadedDefinition, setLoadedDefinition] = useState<FormulaColumn | null>(null),
|
||||
[consent, setConsent] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const draftKey = `traceiq.formula-draft.${ds.id}`;
|
||||
const [draftRestored, setDraftRestored] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(draftKey);
|
||||
if (stored) {
|
||||
const saved = JSON.parse(stored);
|
||||
setDraft(saved.draft || "");
|
||||
setMessages(saved.messages || []);
|
||||
setColumn(saved.column || null);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(draftKey);
|
||||
}
|
||||
setDraftRestored(true);
|
||||
}, [draftKey]);
|
||||
useEffect(() => {
|
||||
if (draftRestored) sessionStorage.setItem(draftKey, JSON.stringify({ draft, messages, column }));
|
||||
}, [draftKey, draftRestored, draft, messages, column]);
|
||||
useEffect(() => {
|
||||
api<{ items: SavedFormulaDefinition[] }>(
|
||||
`/datasets/${ds.id}/formula-definitions`,
|
||||
@@ -865,7 +874,10 @@ export function FormulaExportDialog({
|
||||
async function send() {
|
||||
const text = draft.trim();
|
||||
if (!text || !consent || userTurns >= 6) return;
|
||||
const next = [...messages, { role: "user" as const, content: text }];
|
||||
const content = loadedDefinition
|
||||
? `已载入的原始定义:${JSON.stringify(loadedDefinition)}\n本次补充:${text}`
|
||||
: text;
|
||||
const next = [...messages, { role: "user" as const, content }];
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setQuestions([]);
|
||||
@@ -874,7 +886,7 @@ export function FormulaExportDialog({
|
||||
const result = await api<{
|
||||
complete: boolean;
|
||||
questions: string[];
|
||||
column: Record<string, unknown> | null;
|
||||
column: FormulaColumn | null;
|
||||
}>(`/datasets/${ds.id}/formula-design`, "POST", {
|
||||
context: ctx,
|
||||
messages: next,
|
||||
@@ -891,6 +903,7 @@ export function FormulaExportDialog({
|
||||
]);
|
||||
setQuestions(result.questions);
|
||||
setColumn(result.column);
|
||||
setLoadedDefinition(null);
|
||||
setDraft("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -899,7 +912,7 @@ export function FormulaExportDialog({
|
||||
}
|
||||
}
|
||||
async function create() {
|
||||
if (!column) return;
|
||||
if (!column && !ctx.custom_columns?.length) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -907,7 +920,12 @@ export function FormulaExportDialog({
|
||||
await api<{ id: string; status: string }>(
|
||||
`/datasets/${ds.id}/formula-exports`,
|
||||
"POST",
|
||||
{ ...ctx, custom_columns: [column] },
|
||||
{
|
||||
...ctx,
|
||||
custom_columns: column
|
||||
? [...(ctx.custom_columns || []).filter((item) => item.name !== column.name), column]
|
||||
: ctx.custom_columns,
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -924,11 +942,24 @@ export function FormulaExportDialog({
|
||||
const saved = await api<SavedFormulaDefinition>(
|
||||
`/datasets/${ds.id}/formula-definitions`,
|
||||
"POST",
|
||||
{ definition: column },
|
||||
{
|
||||
definition: column,
|
||||
shared,
|
||||
revision: definitions.find(
|
||||
(item) =>
|
||||
(item.id === selectedDefinition && item.owned) ||
|
||||
(item.owned &&
|
||||
String(item.definition.name).trim().toLowerCase() ===
|
||||
String(column.name).trim().toLowerCase()),
|
||||
)?.revision,
|
||||
},
|
||||
);
|
||||
setDefinitions((items) =>
|
||||
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
|
||||
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
|
||||
String(a.definition.name).localeCompare(
|
||||
String(b.definition.name),
|
||||
"zh-CN",
|
||||
),
|
||||
),
|
||||
);
|
||||
setSelectedDefinition(saved.id);
|
||||
@@ -941,8 +972,14 @@ export function FormulaExportDialog({
|
||||
function loadDefinition(id: string) {
|
||||
setSelectedDefinition(id);
|
||||
const saved = definitions.find((item) => item.id === id);
|
||||
if (!saved) return;
|
||||
if (!saved) {
|
||||
setLoadedDefinition(null);
|
||||
setColumn(null);
|
||||
return;
|
||||
}
|
||||
setColumn(saved.definition);
|
||||
setShared(saved.owned && saved.shared);
|
||||
setLoadedDefinition(saved.definition);
|
||||
setMessages([]);
|
||||
setQuestions([]);
|
||||
setDraft("");
|
||||
@@ -960,6 +997,7 @@ export function FormulaExportDialog({
|
||||
items.filter((item) => item.id !== selectedDefinition),
|
||||
);
|
||||
setSelectedDefinition("");
|
||||
setLoadedDefinition(null);
|
||||
setColumn(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -969,15 +1007,10 @@ export function FormulaExportDialog({
|
||||
}
|
||||
async function getFile() {
|
||||
try {
|
||||
const blob = await api<Blob>(
|
||||
`/datasets/${ds.id}/formula-exports/${task!.id}/download`,
|
||||
),
|
||||
url = URL.createObjectURL(blob),
|
||||
link = document.createElement("a");
|
||||
link.href = url;
|
||||
const link = document.createElement("a");
|
||||
link.href = `/api/datasets/${ds.id}/formula-exports/${task!.id}/download`;
|
||||
link.download = "traceiq-formula-analysis.xlsx";
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
@@ -986,6 +1019,11 @@ export function FormulaExportDialog({
|
||||
<Modal title="生成公式工作簿" onClose={onClose} wide>
|
||||
<div className="dialog-body">
|
||||
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
|
||||
{!column && !task && !!ctx.custom_columns?.length && (
|
||||
<button disabled={busy} onClick={create}>
|
||||
按网页已选 {ctx.custom_columns.length} 列生成工作簿
|
||||
</button>
|
||||
)}
|
||||
{definitions.length > 0 && !task && (
|
||||
<div className="form-row">
|
||||
<label>
|
||||
@@ -998,7 +1036,7 @@ export function FormulaExportDialog({
|
||||
<option value="">选择定义</option>
|
||||
{definitions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{String(item.definition.name)}
|
||||
{item.definition.name}{item.owned ? "" : `(${item.owner}共享)`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1007,13 +1045,27 @@ export function FormulaExportDialog({
|
||||
className="icon"
|
||||
aria-label="删除所选定义"
|
||||
title="删除所选定义"
|
||||
disabled={busy || !selectedDefinition}
|
||||
disabled={busy || !definitions.find((item) => item.id === selectedDefinition)?.owned}
|
||||
onClick={deleteDefinition}
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{definitions.find((item) => item.id === selectedDefinition)?.history.length ? (
|
||||
<details>
|
||||
<summary>版本历史(最近 20 次,可载入后另存)</summary>
|
||||
{definitions.find((item) => item.id === selectedDefinition)!.history.map((version) => (
|
||||
<button key={version.revision} disabled={busy} onClick={() => {
|
||||
setColumn(version.definition);
|
||||
setLoadedDefinition(version.definition);
|
||||
setMessages([]);
|
||||
}}>
|
||||
载入版本 {version.revision} · {version.definition.meaning}
|
||||
</button>
|
||||
))}
|
||||
</details>
|
||||
) : null}
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1056,6 +1108,10 @@ export function FormulaExportDialog({
|
||||
</p>
|
||||
<p>适用范围:{String(column.scope)}</p>
|
||||
<p>缺失/零分母:{String(column.missing)}</p>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={shared} onChange={(event) => setShared(event.target.checked)} />
|
||||
保存时共享给当前数据集的其他全范围分析账号
|
||||
</label>
|
||||
<button
|
||||
disabled={busy || Boolean(draft.trim())}
|
||||
onClick={saveDefinition}
|
||||
|
||||
@@ -98,6 +98,32 @@ export interface Context {
|
||||
filters: Record<string, string>;
|
||||
group: string;
|
||||
metric: Metric;
|
||||
custom_columns?: FormulaColumn[];
|
||||
}
|
||||
export type FormulaExpression =
|
||||
| { metric: Metric }
|
||||
| { number: number }
|
||||
| {
|
||||
operator: "add" | "subtract" | "multiply" | "divide";
|
||||
left: FormulaExpression;
|
||||
right: FormulaExpression;
|
||||
};
|
||||
export interface FormulaColumn {
|
||||
name: string;
|
||||
meaning: string;
|
||||
scope: string;
|
||||
missing: string;
|
||||
expression: FormulaExpression;
|
||||
}
|
||||
export interface SavedFormulaDefinition {
|
||||
id: string;
|
||||
definition: FormulaColumn;
|
||||
revision: number;
|
||||
updated_at: string;
|
||||
shared: boolean;
|
||||
owned: boolean;
|
||||
owner: string;
|
||||
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
|
||||
}
|
||||
export interface Value {
|
||||
current: Numeric;
|
||||
|
||||
Reference in New Issue
Block a user