package com.xly.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; /** * 写操作预览/保存闭环(rearch3 §1「所见即所写」): * *
    *
  1. {@link #buildPreview}(previewChange 工具的后端,**只读**):定位记录 → 校验改动 * (字段字典/FK/类型/系统列/状态合法性)→ 渲染整表预览卡(改动高亮/状态变化高亮)→ * 解析产物(表/记录/列/规范化值/当前值快照)以 previewId 存 Redis({@value #TTL_MINUTES} 分钟 TTL, * 服务端绑定,客户端只见 previewId);
  2. *
  3. {@link #save}(用户点卡上按钮后的确定性端点):按 previewId 取回 → 归属/权限重查 → * **重读记录重校验**(记录被他人改过/状态已变 → 拒绝保存,请重新预览)→ 写 ai_op_queue * ({@link OpService},AI 侧唯一写入口)→ 落 queued 事件。**xlyAi 到此为止**,执行由 ERP 侧负责。
  4. *
* * 状态合法性硬检查(预览与保存两次,防 TOCTOU):审核要求未审核、销审要求已审核、 * 作废要求未作废、复原要求已作废(依据已核实的存储过程行为:重复审核/重复作废 ERP 不拦但有副作用)。 * 表没有对应状态列时跳过该项检查。 */ @Service public class PreviewService { private static final Logger log = LoggerFactory.getLogger(PreviewService.class); private static final String KEY_PREFIX = "chat:preview:"; private static final int TTL_MINUTES = 30; private static final int STATE_SUMMARY_FIELDS = 6; private final FormRenderService render; private final FormResolverService resolver; private final OpService ops; private final LedgerService ledger; private final ErpClient erp; private final StringRedisTemplate redis; private final ObjectMapper mapper; /** Phase D 接口位:true 时保存前调 ERP dry-run 校验(/business/checkBusinessData,ERP 侧完工后开启)。 */ @Value("${erp.dry-run.enabled:false}") private boolean dryRunEnabled; public PreviewService(FormRenderService render, FormResolverService resolver, OpService ops, LedgerService ledger, ErpClient erp, StringRedisTemplate redis, ObjectMapper mapper) { this.render = render; this.resolver = resolver; this.ops = ops; this.ledger = ledger; this.erp = erp; this.redis = redis; this.mapper = mapper; } // ---------------------------------------------------------------- 预览 /** previewChange 的入口。返回卡片 JSON(type=change_preview)或 {"error":...}。 */ public String buildPreview(AgentIdentity who, String convId, String action, String entityKeyword, String recordKeyword, String fieldChinese, String newValue) { if (isBlank(action)) { return err("缺少 action。"); } String act = normalizeAction(action); if (act == null) { return err("未知 action:" + action + "(支持 update/invalid/cancelInvalid/examine/cancelExamine/delete;新增走 collectForm)"); } if (isBlank(entityKeyword) || isBlank(recordKeyword)) { return err("缺少实体类型或记录名称/单号。"); } String verb = verbOf(act); FormRenderService.Located l = render.locateRecord(who, entityKeyword, recordKeyword, verb); if (l.error != null) { return err(l.error); } return "update".equals(act) ? buildUpdatePreview(who, convId, entityKeyword, fieldChinese, newValue, l) : buildStatePreview(who, convId, act, entityKeyword, l); } private String buildUpdatePreview(AgentIdentity who, String convId, String entity, String fieldChinese, String newValue, FormRenderService.Located l) { if (isBlank(fieldChinese) || newValue == null) { return err("update 需要 字段中文名 + 新值。"); } Map fm = render.resolveColumn(l.table, fieldChinese.trim()); if (fm == null) { return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。"); } String col = String.valueOf(fm.get("col")); if (resolver.isSystemColumn(col)) { return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。"); } String fk = fm.get("fk") == null ? null : String.valueOf(fm.get("fk")); FormRenderService.Normalized n = render.normalize(l.table, col, fk, fieldChinese, newValue, who); if (n.error != null) { return err(n.error); } String warning = null; if (readState(l.rec, "bInvalid") == 1) { warning = "注意:该记录当前是**已作废**状态。"; } // 整表渲染:master 字段的当前真实值 + 被改字段高亮 List> skeleton = masterFields(l.table, l.formId); ensureFieldPresent(skeleton, col, fieldChinese, fk, l.table); List> cardFields = new ArrayList<>(); Map snapshotRaw = new LinkedHashMap<>(); Map snapshotShown = new LinkedHashMap<>(); List> fieldsMeta = new ArrayList<>(); for (Map f : skeleton) { String name = String.valueOf(f.get("name")); String label = String.valueOf(f.get("label")); String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable")); String raw = l.rec.path(name).asText(""); String shown = ffk != null ? render.fkDisplayName(ffk, raw) : raw; snapshotRaw.put(name, raw); snapshotShown.put(name, shown); Map meta = new LinkedHashMap<>(); meta.put("name", name); meta.put("label", label); if (ffk != null) { meta.put("fk", ffk); } fieldsMeta.add(meta); Map cf = new LinkedHashMap<>(f); cf.put("value", shown); if (name.equals(col)) { cf.put("changed", true); cf.put("newValue", n.shown); } cardFields.add(cf); } String oldShown = snapshotShown.getOrDefault(col, ""); String description = "将【" + l.recordName + "】的【" + fieldChinese + "】" + (oldShown.isBlank() ? "" : ("由「" + oldShown + "」")) + "改为「" + n.shown + "」" + (n.shown.equals(newValue) ? "" : "(原话:" + newValue + ")"); Map draft = new LinkedHashMap<>(); draft.put("action", "update"); draft.put("userId", who.userId()); draft.put("convId", convId); draft.put("entity", entity); draft.put("formId", l.formId); draft.put("moduleId", l.moduleId); draft.put("table", l.table); draft.put("billId", l.billId); draft.put("recordName", l.recordName); draft.put("changedCol", col); draft.put("changedLabel", fieldChinese); draft.put("changedNewShown", n.shown); draft.put("snapshotRaw", snapshotRaw); draft.put("snapshotShown", snapshotShown); draft.put("fieldsMeta", fieldsMeta); draft.put("description", description); String previewId = stash(draft); if (previewId == null) { return err("预览暂存失败,请稍后重试。"); } Map card = new LinkedHashMap<>(); card.put("type", "change_preview"); card.put("previewId", previewId); card.put("action", "update"); card.put("entity", entity); card.put("recordName", l.recordName); card.put("title", "修改「" + l.recordName + "」(" + entity + ")"); card.put("summary", description); card.put("buttonLabel", "保存"); card.put("editable", true); card.put("fields", cardFields); card.put("message", (warning == null ? "" : warning + "\n") + "请核对整张表单(改动已高亮,可继续修正其他字段),确认后点【保存】。保存前不会写入任何数据。"); return toJson(card); } private String buildStatePreview(AgentIdentity who, String convId, String act, String entity, FormRenderService.Located l) { Map types = resolver.columnTypes(l.table); int bCheck = readState(l.rec, "bCheck"); int bInvalid = readState(l.rec, "bInvalid"); // 状态合法性硬检查(依据已核实的存储过程行为;无对应列的表跳过) String illegal = stateIllegalReason(act, types, bCheck, bInvalid, l.recordName); if (illegal != null) { return err(illegal); } String warning = null; if ("delete".equals(act) && types.containsKey("bCheck") && bCheck == 1) { warning = "注意:该单据**已审核**,物理删除有风险且不可恢复,建议改用作废。"; } // 单据摘要(前几个有值的业务字段)+ 状态变化高亮,无字段编辑 List> summaryFields = new ArrayList<>(); for (Map f : masterFields(l.table, l.formId)) { if (summaryFields.size() >= STATE_SUMMARY_FIELDS) { break; } String name = String.valueOf(f.get("name")); String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable")); String raw = l.rec.path(name).asText(""); if (raw.isBlank()) { continue; } Map cf = new LinkedHashMap<>(); cf.put("name", name); cf.put("label", f.get("label")); cf.put("value", ffk != null ? render.fkDisplayName(ffk, raw) : raw); summaryFields.add(cf); } String billNo = l.rec.path("sBillNo").asText(""); if (!billNo.isBlank()) { Map bn = new LinkedHashMap<>(); bn.put("name", "sBillNo"); bn.put("label", "单号"); bn.put("value", billNo); summaryFields.add(0, bn); } Map statusChange = statusChangeOf(act); String verb = verbOf(act); String description = verb + "【" + l.recordName + "】(" + entity + ")"; Map expected = new LinkedHashMap<>(); if (types.containsKey("bCheck")) { expected.put("bCheck", bCheck); } if (types.containsKey("bInvalid")) { expected.put("bInvalid", bInvalid); } Map draft = new LinkedHashMap<>(); draft.put("action", act); draft.put("userId", who.userId()); draft.put("convId", convId); draft.put("entity", entity); draft.put("formId", l.formId); draft.put("moduleId", l.moduleId); draft.put("table", l.table); draft.put("billId", l.billId); draft.put("recordName", l.recordName); draft.put("expected", expected); draft.put("description", description); String previewId = stash(draft); if (previewId == null) { return err("预览暂存失败,请稍后重试。"); } Map card = new LinkedHashMap<>(); card.put("type", "change_preview"); card.put("previewId", previewId); card.put("action", act); card.put("entity", entity); card.put("recordName", l.recordName); card.put("title", verb + "「" + l.recordName + "」(" + entity + ")"); card.put("summary", description); card.put("buttonLabel", verb); card.put("editable", false); card.put("fields", summaryFields); card.put("statusChange", statusChange); card.put("message", (warning == null ? "" : warning + "\n") + "请核对单据信息与状态变化,确认后点【" + verb + "】提交待办。点按钮前不会写入任何数据。"); return toJson(card); } // ---------------------------------------------------------------- 保存 /** * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → **claim 抢占**(删 previewId * 的原子 DEL,防并发/重复提交入队两次)→ 写 ai_op_queue。 * editedFields = 卡上(可编辑预览)用户最终确认的 字段技术名→值;状态类传 null。 * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。 */ public Map save(AgentIdentity who, String previewId, Map editedFields) { Map out = new LinkedHashMap<>(); JsonNode draft = unstash(previewId); if (draft == null) { out.put("error", "预览已过期或不存在,请让助手重新生成预览。"); return out; } if (!draft.path("userId").asText("").equals(who.userId())) { out.put("error", "无权处理该预览。"); return out; } String moduleId = draft.path("moduleId").asText(""); if (!who.canAccessModule(moduleId)) { out.put("error", "你没有该单据的操作权限。"); return out; } String action = draft.path("action").asText(""); String convId = draft.path("convId").asText(""); FormRenderService.Located cur = render.locateById(who, draft.path("formId").asText(""), moduleId, draft.path("table").asText(""), draft.path("billId").asText("")); if (cur.error != null) { out.put("error", cur.error + " 请重新预览。"); return out; } return "update".equals(action) ? saveUpdate(who, draft, editedFields, cur, convId, previewId) : saveStateOp(who, draft, cur, convId, action, previewId); } /** 入队多字段更新的一次改动。 */ private record Change(String col, String label, String wasShown, Object stored, String shown) { } private Map saveUpdate(AgentIdentity who, JsonNode draft, Map editedFields, FormRenderService.Located cur, String convId, String previewId) { Map out = new LinkedHashMap<>(); String table = draft.path("table").asText(""); String recordName = draft.path("recordName").asText(""); JsonNode snapshotRaw = draft.path("snapshotRaw"); JsonNode snapshotShown = draft.path("snapshotShown"); String changedCol = draft.path("changedCol").asText(""); String changedNewShown = draft.path("changedNewShown").asText(""); // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段。 // 键 = 字段**技术名**(同表两列同中文名时 label 键会串写出"幻影更新")。 // 值为空 = 放弃该字段的改动(含模型提议的那个改动)——卡上无法把字段改成空值,属已知取舍。 Map desired = new LinkedHashMap<>(); desired.put(changedCol, changedNewShown); if (editedFields != null && !editedFields.isEmpty()) { for (JsonNode meta : draft.path("fieldsMeta")) { String name = meta.path("name").asText(""); if (!editedFields.containsKey(name)) { continue; } String v = editedFields.get(name); if (v == null || v.isBlank()) { desired.remove(name); } else { desired.put(name, v); } } } // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半) List changes = new ArrayList<>(); for (JsonNode meta : draft.path("fieldsMeta")) { String col = meta.path("name").asText(""); String label = meta.path("label").asText(""); String fk = meta.path("fk").isMissingNode() ? null : meta.path("fk").asText(null); String want = desired.get(col); if (want == null) { continue; } String wasShown = snapshotShown.path(col).asText(""); if (want.equals(wasShown)) { continue; // 没改 } if (resolver.isSystemColumn(col)) { out.put("error", "「" + label + "」是系统字段,不能修改。"); return out; } // 所见即所写:预览时的当前值必须仍是现在的当前值(他人已改 → 拒绝) String rawAtPreview = snapshotRaw.path(col).asText(""); String rawNow = cur.rec.path(col).asText(""); if (!rawNow.equals(rawAtPreview)) { out.put("error", "「" + label + "」在预览之后被其他人修改过(现在是「" + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。"); return out; } FormRenderService.Normalized n = render.normalize(table, col, fk, label, want, who); if (n.error != null) { out.put("error", n.error); return out; } changes.add(new Change(col, label, wasShown, n.stored, n.shown)); } if (changes.isEmpty()) { out.put("error", "没有需要保存的改动。"); return out; } String dryErr = dryRunUpdate(who, draft, changes); if (dryErr != null) { out.put("error", dryErr); return out; } // claim(原子 DEL):并发/重复点保存只有一个能入队 if (!claim(previewId)) { out.put("error", "该预览已提交过或已失效,请勿重复提交。"); return out; } // 第二遍:批量入队(单事务;ERP 执行器为单字段 update,一次保存多字段 = 多行待办) List fcs = new ArrayList<>(); List descs = new ArrayList<>(); for (Change c : changes) { String desc = "将【" + recordName + "】的【" + c.label() + "】" + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」"; descs.add(desc); fcs.add(new OpService.FieldChange(c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc)); } List opIds; try { opIds = ops.queueUpdates(who, convId, draft.path("formId").asText(""), draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), fcs); } catch (Exception e) { log.warn("queue updates failed (conv={}): {}", convId, e.getMessage()); out.put("error", "提交待办失败:" + e.getMessage() + ",请重新预览。"); return out; } String description = String.join(";", descs); recordQueued(convId, who, opIds, description); out.put("queued", true); out.put("opIds", opIds); out.put("description", description); out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。"); return out; } private Map saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur, String convId, String action, String previewId) { Map out = new LinkedHashMap<>(); String table = draft.path("table").asText(""); Map types = resolver.columnTypes(table); int bCheck = readState(cur.rec, "bCheck"); int bInvalid = readState(cur.rec, "bInvalid"); // 所见即所写:预览时看到的状态必须没变 JsonNode expected = draft.path("expected"); if (expected.has("bCheck") && expected.path("bCheck").asInt() != bCheck) { out.put("error", "该单据的审核状态在预览之后发生了变化,已拒绝提交,请重新预览。"); return out; } if (expected.has("bInvalid") && expected.path("bInvalid").asInt() != bInvalid) { out.put("error", "该单据的作废状态在预览之后发生了变化,已拒绝提交,请重新预览。"); return out; } String illegal = stateIllegalReason(action, types, bCheck, bInvalid, draft.path("recordName").asText("")); if (illegal != null) { out.put("error", illegal); return out; } String opType; String sNewValue; switch (action) { case "invalid" -> { opType = "invalid"; sNewValue = "toVoid"; } case "cancelinvalid", "cancelInvalid" -> { opType = "invalid"; sNewValue = "cancel"; } case "examine" -> { opType = "examine"; sNewValue = "1"; } case "cancelexamine", "cancelExamine" -> { opType = "examine"; sNewValue = "0"; } case "delete" -> { opType = "delete"; sNewValue = null; } default -> { out.put("error", "未知动作:" + action); return out; } } // claim(原子 DEL):并发/重复点按钮只有一个能入队——重复审核/作废的副作用正是状态检查要防的 if (!claim(previewId)) { out.put("error", "该预览已提交过或已失效,请勿重复提交。"); return out; } String description = draft.path("description").asText(""); String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""), draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description); recordQueued(convId, who, List.of(opId), description); out.put("queued", true); out.put("opIds", List.of(opId)); out.put("description", description); out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。"); return out; } // ---------------------------------------------------------------- create(表单保存按钮的后端) /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */ public Map saveCreate(AgentIdentity who, String convId, String entity, Map fields) { Map out = new LinkedHashMap<>(); FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields); if (b.error != null) { out.put("error", b.error); return out; } String dryErr = dryRunCreate(who, b); if (dryErr != null) { out.put("error", dryErr); return out; } String opId = ops.queueCreate(who, convId, b.formId, b.moduleId, b.table, b.payload, b.description); recordQueued(convId, who, List.of(opId), b.description); out.put("queued", true); out.put("opIds", List.of(opId)); out.put("opId", opId); out.put("summary", b.description); out.put("description", b.description); out.put("message", "已提交待办:" + b.description + "。是否/何时执行由 ERP 处理。"); return out; } // ---------------------------------------------------------------- Phase D:ERP dry-run 接口位 /** ERP 校验 API 完工前恒关(erp.dry-run.enabled=false)。开启后:校验失败返回用户可读错误,网络异常放行(ERP 执行时仍会真校验)。 */ private String dryRunCreate(AgentIdentity who, FormRenderService.CreateBuild b) { if (!dryRunEnabled) { return null; } try { JsonNode r = erp.checkBusinessData(who.token(), b.moduleId, b.table, b.payload, "add"); if (r != null && r.path("code").asInt(1) != 1) { return "ERP 校验未通过:" + r.path("msg").asText("数据不合法"); } } catch (Exception e) { log.warn("dry-run create failed (放行,执行时 ERP 仍会校验): {}", e.getMessage()); } return null; } private String dryRunUpdate(AgentIdentity who, JsonNode draft, List changes) { if (!dryRunEnabled) { return null; } try { // 与真实入队完全同口径:只发改动列,值用规范化后的入库值(FK=id、数字已 coerce) Map col = new LinkedHashMap<>(); col.put("sId", draft.path("billId").asText("")); for (Change c : changes) { col.put(c.col(), c.stored()); } JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""), draft.path("table").asText(""), mapper.writeValueAsString(col), "update"); if (r != null && r.path("code").asInt(1) != 1) { return "ERP 校验未通过:" + r.path("msg").asText("数据不合法"); } } catch (Exception e) { log.warn("dry-run update failed (放行,执行时 ERP 仍会校验): {}", e.getMessage()); } return null; } // ---------------------------------------------------------------- 内部 private void recordQueued(String convId, AgentIdentity who, List opIds, String description) { Map data = new LinkedHashMap<>(); data.put("opIds", opIds); data.put("description", description); ledger.append(convId, "queued", data, who); } /** 状态-动作合法性:非法返回用户可读原因,合法返回 null。无对应状态列的表跳过检查。 */ private static String stateIllegalReason(String act, Map types, int bCheck, int bInvalid, String recordName) { boolean hasCheck = types.containsKey("bCheck"); boolean hasInvalid = types.containsKey("bInvalid"); switch (act) { case "examine": if (hasCheck && bCheck == 1) { return "【" + recordName + "】已经是**已审核**状态,无需重复审核(重复审核会覆盖审核人并重跑回写)。"; } if (hasInvalid && bInvalid == 1) { return "【" + recordName + "】是已作废单据,不能审核。"; } break; case "cancelexamine", "cancelExamine": if (hasCheck && bCheck == 0) { return "【" + recordName + "】还未审核,无法销审。"; } break; case "invalid": if (hasInvalid && bInvalid == 1) { return "【" + recordName + "】已经是**已作废**状态,无需重复作废(重复作废会重跑上下游回写)。"; } break; case "cancelinvalid", "cancelInvalid": if (hasInvalid && bInvalid == 0) { return "【" + recordName + "】不是作废状态,无需复原。"; } break; default: } return null; } /** 记录的状态位(bCheck/bInvalid):1/true → 1,其余 → 0;列不存在也返回 0(调用方按 types 判断是否采信)。 */ private static int readState(JsonNode rec, String col) { JsonNode v = rec == null ? null : rec.path(col); if (v == null || v.isMissingNode() || v.isNull()) { return 0; } String s = v.asText(""); return "1".equals(s) || "true".equalsIgnoreCase(s) ? 1 : 0; } private static Map statusChangeOf(String act) { Map m = new LinkedHashMap<>(); switch (act) { case "examine" -> { m.put("label", "审核状态"); m.put("from", "未审核"); m.put("to", "已审核"); } case "cancelexamine", "cancelExamine" -> { m.put("label", "审核状态"); m.put("from", "已审核"); m.put("to", "未审核"); } case "invalid" -> { m.put("label", "单据状态"); m.put("from", "正常"); m.put("to", "已作废(可复原)"); } case "cancelinvalid", "cancelInvalid" -> { m.put("label", "单据状态"); m.put("from", "已作废"); m.put("to", "正常"); } case "delete" -> { m.put("label", "单据状态"); m.put("from", "存在"); m.put("to", "物理删除(不可恢复)"); } default -> { } } return m; } private static String verbOf(String act) { return switch (act) { case "update" -> "保存"; case "invalid" -> "作废"; case "cancelinvalid", "cancelInvalid" -> "取消作废"; case "examine" -> "审核"; case "cancelexamine", "cancelExamine" -> "反审核"; case "delete" -> "删除"; default -> "操作"; }; } private static String normalizeAction(String action) { return switch (action.trim().toLowerCase()) { case "update" -> "update"; case "invalid" -> "invalid"; case "cancelinvalid" -> "cancelInvalid"; case "examine" -> "examine"; case "cancelexamine" -> "cancelExamine"; case "delete" -> "delete"; default -> null; }; } /** update 预览渲染的字段集合:master 目标的骨架字段(报价策展含从表字段,改字段只支持主表列)。 */ private List> masterFields(String table, String formId) { List> out = new ArrayList<>(); for (Map f : render.formSkeleton(table, formId)) { Object target = f.get("target"); if (target == null || "master".equals(String.valueOf(target))) { out.add(f); } } return out; } /** 被改字段不在骨架里(低使用度列)时补一行,保证卡上一定能看到改动。 */ private void ensureFieldPresent(List> skeleton, String col, String label, String fk, String table) { for (Map f : skeleton) { if (col.equals(String.valueOf(f.get("name")))) { return; } } Map f = new LinkedHashMap<>(); f.put("name", col); f.put("label", label); f.put("required", false); if (fk != null && !fk.isBlank()) { f.put("type", "fkselect"); f.put("fkTable", fk); } else { f.put("type", "text"); } skeleton.add(0, f); } private String stash(Map draft) { try { String id = UUID.randomUUID().toString().replace("-", ""); redis.opsForValue().set(KEY_PREFIX + id, mapper.writeValueAsString(draft), Duration.ofMinutes(TTL_MINUTES)); return id; } catch (Exception e) { log.warn("preview stash failed: {}", e.getMessage()); return null; } } private JsonNode unstash(String previewId) { if (previewId == null || previewId.isBlank() || !previewId.matches("[A-Za-z0-9]{16,64}")) { return null; } try { String json = redis.opsForValue().get(KEY_PREFIX + previewId); return json == null ? null : mapper.readTree(json); } catch (Exception e) { return null; } } /** 原子 DEL 抢占:只有真正删掉 key 的那个请求返回 true——并发/重复保存只有一个能入队。 */ private boolean claim(String previewId) { try { return Boolean.TRUE.equals(redis.delete(KEY_PREFIX + previewId)); } catch (Exception e) { return false; } } private String err(String msg) { Map m = new LinkedHashMap<>(); m.put("error", msg); return toJson(m); } private String toJson(Map m) { try { return mapper.writeValueAsString(m); } catch (Exception e) { return "{\"error\":\"内部错误\"}"; } } private static boolean isBlank(String s) { return s == null || s.isBlank(); } }