package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.service.AuthzService; import com.xly.service.ErpClient; import com.xly.service.FormResolverService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选, * 多列展示 + 名称搜索 + 分页。 * *

两条取数路径,优先前者: *

    *
  1. ERP 下拉配置({@code POST /ai/fieldOptions},需 formId+field)——候选口径与 ERP 网页 * 完全一致:带**行级数据权限**、**级联过滤**(如产品限定在已选客户名下)、**联动回填映射**。
  2. *
  3. 本地字段字典({@link FormResolverService#fkOptionPage})——ERP 端点未上线/无该字段配置/ * 调用失败时的保底,行为同改造前。
  4. *
* *

前端调 {@code GET /api/agent/form/options?table=elecustomer&formId=..&field=sCustomerId&q=..&page=1}, * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize, valueField?, assign?}}; * 级联条件未满足时返回 {@code {needContext:true, requires:[…], message:"请先选择客户"}}。 * *

安全:需登录;本地路径的租户来自服务端内省的身份(不接受客户端传 brandsid,否则不传即可跨租户 * 全库翻页),且表名限于「在字段字典里作为外键目标出现过」并被有权表单引用(见 * {@link FormResolverService#fkTableAccessible});ERP 路径由 ERP 用用户 token 自行鉴权。 */ @RestController @RequestMapping("/api/agent/form") public class FormController { private static final Logger log = LoggerFactory.getLogger(FormController.class); private final FormResolverService resolver; private final AuthzService authz; private final ErpClient erp; private final ObjectMapper mapper; /** ERP 字段候选接口开关(默认开:端点未上线时会自动回落,不影响可用)。 */ @Value("${erp.field-options.enabled:true}") private boolean erpFieldOptionsEnabled; public FormController(FormResolverService resolver, AuthzService authz, ErpClient erp, ObjectMapper mapper) { this.resolver = resolver; this.authz = authz; this.erp = erp; this.mapper = mapper; } @GetMapping("/options") public Map options(@RequestParam("table") String table, @RequestParam(value = "q", required = false) String q, @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, @RequestParam(value = "formId", required = false) String formId, @RequestParam(value = "field", required = false) String field, @RequestParam(value = "ctx", required = false) String ctxJson, @RequestHeader(value = "Authorization", required = false) String auth) { AgentIdentity id = authz.resolveIdentity(auth); if (id == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); } Map viaErp = tryErp(id, formId, field, q, page, pageSize, ctxJson); if (viaErp != null) { return viaErp; } // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录 if (!resolver.fkTableAccessible(table, id)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据"); } return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); } /** 走 ERP 下拉配置;不可用(未上线/该字段无配置/异常)返回 null 让调用方回落。 */ private Map tryErp(AgentIdentity id, String formId, String field, String q, int page, int pageSize, String ctxJson) { if (!erpFieldOptionsEnabled || isBlank(formId) || isBlank(field)) { return null; } JsonNode r = erp.fieldOptions(id.token(), formId.trim(), field.trim(), q, page, pageSize, parseCtx(ctxJson)); if (r == null) { return null; } String mode = r.path("mode").asText(""); if ("need_context".equals(mode)) { Map out = new LinkedHashMap<>(); out.put("needContext", true); out.put("requires", textList(r.path("requires"))); String msg = r.path("msg").asText(""); out.put("message", msg.isBlank() ? "请先选择上级字段,再选这一项。" : msg); return out; } if (!"list".equals(mode)) { return null; // mode=none 等:该字段在 ERP 侧没有可列的候选,回落本地字典 } return normalize(r); } /** ERP 载荷 → 前端既有的 {columns, rows, total, …} 形状,额外带上 valueField/assign。 */ private Map normalize(JsonNode r) { List> columns = new ArrayList<>(); for (JsonNode c : r.path("columns")) { String col = c.path("col").asText(""); if (!col.isBlank()) { columns.add(Map.of("col", col, "label", c.path("label").asText(col))); } } List> rows = new ArrayList<>(); for (JsonNode row : r.path("rows")) { Map m = mapper.convertValue(row, Map.class); rows.add(m); } Map out = new LinkedHashMap<>(); out.put("columns", columns); out.put("rows", rows); out.put("total", r.path("total").asInt(rows.size())); out.put("page", r.path("pageNum").asInt(1)); out.put("pageSize", r.path("pageSize").asInt(rows.size())); out.put("nameField", r.path("nameField").asText(columns.isEmpty() ? "" : String.valueOf(columns.get(0).get("col")))); out.put("valueField", r.path("valueField").asText("sId")); out.put("assign", r.has("assign") ? mapper.convertValue(r.path("assign"), Map.class) : Map.of()); out.put("source", "erp"); return out; } /** ctx = 表单上已绑定的其它字段值(字段名→id),供 ERP 的级联条件用。解析失败按无上下文处理。 */ private Map parseCtx(String ctxJson) { if (isBlank(ctxJson)) { return Map.of(); } try { @SuppressWarnings("unchecked") Map m = mapper.readValue(ctxJson, Map.class); return m; } catch (Exception e) { log.debug("ctx 解析失败,按无上下文处理: {}", e.getMessage()); return Map.of(); } } private static List textList(JsonNode arr) { List out = new ArrayList<>(); for (JsonNode n : arr) { out.add(n.asText()); } return out; } private static boolean isBlank(String s) { return s == null || s.isBlank(); } }