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:
+91
-13
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import warnings
|
||||
@@ -11,22 +12,62 @@ from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
from openpyxl.styles import Font, PatternFill, Protection
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo
|
||||
|
||||
from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts
|
||||
from .formula_columns import (
|
||||
RESERVED_DATA_HEADERS,
|
||||
ensure_available_metrics,
|
||||
evaluate,
|
||||
metrics_used,
|
||||
to_excel,
|
||||
validate_column,
|
||||
)
|
||||
from .models import FormulaExport, Membership
|
||||
|
||||
# Keep the private test seam while sharing the single evaluator implementation.
|
||||
_evaluate_expression = evaluate
|
||||
|
||||
FORMULA_VERSION = "3"
|
||||
MAX_ROWS = 100_000
|
||||
MAX_GROUPS = 200
|
||||
RECONCILE_TOLERANCES = {
|
||||
"customers": Decimal("0"),
|
||||
"margin": Decimal("0.0001"),
|
||||
"discount_rate": Decimal("0.0001"),
|
||||
"upgrade_rate": Decimal("0.0001"),
|
||||
}
|
||||
DEFAULT_RECONCILE_TOLERANCE = Decimal("0.01")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_unlink_export(path, root):
|
||||
"""Delete only a file below the owned export root without following links."""
|
||||
path, root = Path(path), Path(root).absolute()
|
||||
if not path.is_absolute() or root.is_symlink() or path.is_symlink():
|
||||
logger.warning("formula export cleanup refused unsafe path: %s", path)
|
||||
return False
|
||||
parent = path.parent
|
||||
try:
|
||||
if not parent.is_relative_to(root):
|
||||
logger.warning("formula export cleanup refused out-of-root path: %s", path)
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
current = parent
|
||||
while current != root:
|
||||
if current.is_symlink() or current.parent == current:
|
||||
logger.warning("formula export cleanup refused linked parent: %s", path)
|
||||
return False
|
||||
current = current.parent
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
logger.warning("formula export cleanup failed for %s", path, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _excel_number(value):
|
||||
@@ -269,6 +310,19 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
),
|
||||
)
|
||||
data.append(headers)
|
||||
editable_indices = {len(dims) + 2, len(dims) + 3, len(dims) + 4, len(dims) + 5, len(dims) + 7}
|
||||
if count:
|
||||
numeric_input = DataValidation(
|
||||
type="decimal", operator="between", formula1="-1e15", formula2="1e15", allow_blank=True
|
||||
)
|
||||
numeric_input.showErrorMessage = True
|
||||
numeric_input.errorStyle = "stop"
|
||||
numeric_input.errorTitle = "请输入数值"
|
||||
numeric_input.error = "计算输入只接受范围内的数值或空白;请勿粘贴公式或文本。"
|
||||
for index in editable_indices:
|
||||
column_name = _column_name(index + 1)
|
||||
numeric_input.add(f"{column_name}2:{column_name}{count + 1}")
|
||||
data.data_validations.append(numeric_input)
|
||||
token_secret = secrets.token_bytes(32)
|
||||
customer_roster = {}
|
||||
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None)
|
||||
@@ -329,6 +383,12 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
f'{_column_name(len(dims) + 6)}{row_number},""))'
|
||||
),
|
||||
)
|
||||
for index in editable_indices:
|
||||
cell = row[index]
|
||||
if not hasattr(cell, "protection"):
|
||||
cell = WriteOnlyCell(data, value=cell)
|
||||
cell.protection = Protection(locked=False)
|
||||
row[index] = cell
|
||||
data.append(row)
|
||||
customer_headers = [
|
||||
"Group",
|
||||
@@ -408,7 +468,13 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
],
|
||||
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
|
||||
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
|
||||
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"],
|
||||
["产品毛利率快照", str(rates), "导入时保存的只读口径;修改需重新确认导入映射", "否"],
|
||||
[
|
||||
"对账容差",
|
||||
"人数 0;金额 0.01;比率 0.0001;自定义 0.01",
|
||||
"服务器 Decimal 参考与 Excel 结果的绝对差;正式财务验收前需确认",
|
||||
"否",
|
||||
],
|
||||
[
|
||||
"Excel 数值精度",
|
||||
"IEEE-754 双精度",
|
||||
@@ -443,6 +509,10 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
|
||||
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
|
||||
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
|
||||
[
|
||||
"编辑保护",
|
||||
"工作表结构、人数去重、原始证据和公式列已保护;计算明细仅收入、成本、毛利输入、毛利率和原价金额可编辑。行结构变化请重新导出。",
|
||||
],
|
||||
]:
|
||||
instructions.append(row)
|
||||
for definition in custom_columns:
|
||||
@@ -559,6 +629,7 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
for key, name in METRICS.items():
|
||||
formula_row = metric_rows[(label, key)]
|
||||
value = reference[key]
|
||||
tolerance = RECONCILE_TOLERANCES.get(key, DEFAULT_RECONCILE_TOLERANCE)
|
||||
reconcile.append(
|
||||
[
|
||||
name,
|
||||
@@ -567,24 +638,30 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
"" if value is None else str(value),
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={tolerance},"通过","待复核"),"待复核"))',
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
for index, definition in enumerate(custom_columns):
|
||||
formula_row = metric_rows[(label, f"custom_{index}")]
|
||||
custom_reference = evaluate(definition["expression"], reference)
|
||||
reconcile.append(
|
||||
[
|
||||
_text_cell(reconcile, definition["name"]),
|
||||
_text_cell(reconcile, label),
|
||||
side_name,
|
||||
"自定义公式无服务器参考",
|
||||
"" if custom_reference is None else str(custom_reference),
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
"",
|
||||
"需业务验收",
|
||||
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={DEFAULT_RECONCILE_TOLERANCE},"通过","待复核"),"待复核"))',
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
for sheet in wb.worksheets:
|
||||
sheet.protection.sheet = True
|
||||
sheet.protection.selectLockedCells = False
|
||||
sheet.protection.selectUnlockedCells = True
|
||||
wb.security.lockStructure = True
|
||||
wb.calculation.fullCalcOnLoad = True
|
||||
wb.calculation.forceFullCalc = True
|
||||
wb.calculation.calcMode = "auto"
|
||||
@@ -596,8 +673,8 @@ def process_formula_export(export_id):
|
||||
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
|
||||
temporary = None
|
||||
target = None
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
try:
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
if root.is_symlink():
|
||||
raise ValueError("公式导出目录不能是符号链接")
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
@@ -609,6 +686,8 @@ def process_formula_export(export_id):
|
||||
target.parent.chmod(0o700)
|
||||
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
||||
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
||||
if not job.user.is_active or member.role not in ("admin", "analyst"):
|
||||
raise PermissionError("账号已撤权,请重新提交导出")
|
||||
if set(member.departments) != set(job.departments):
|
||||
raise PermissionError("科室授权已变更,请重新提交导出")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
@@ -621,13 +700,12 @@ def process_formula_export(export_id):
|
||||
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
|
||||
return stats
|
||||
except Exception as exc:
|
||||
remaining = ""
|
||||
for path in (temporary, target):
|
||||
try:
|
||||
if path is not None and not path.is_symlink():
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
job.status, job.error, job.path = "failed", str(exc)[:500], ""
|
||||
if path is not None and not _safe_unlink_export(path, root):
|
||||
if path.exists() or path.is_symlink():
|
||||
remaining = str(path)
|
||||
job.status, job.error, job.path = "failed", str(exc)[:500], remaining
|
||||
job.finished_at, job.expires_at = timezone.now(), None
|
||||
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user