FormController.java 7.91 KB
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 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选,
 * 多列展示 + 名称搜索 + 分页。
 *
 * <p>两条取数路径,优先前者:
 * <ol>
 *   <li><b>ERP 下拉配置</b>({@code POST /ai/fieldOptions},需 formId+field)——候选口径与 ERP 网页
 *       完全一致:带**行级数据权限**、**级联过滤**(如产品限定在已选客户名下)、**联动回填映射**。</li>
 *   <li><b>本地字段字典</b>({@link FormResolverService#fkOptionPage})——ERP 端点未上线/无该字段配置/
 *       调用失败时的保底,行为同改造前。</li>
 * </ol>
 *
 * <p>前端调 {@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:"请先选择客户"}}。
 *
 * <p><b>安全</b>:需登录;本地路径的租户来自服务端内省的身份(不接受客户端传 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> normalize(JsonNode r) {
        List<Map<String, Object>> 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<Map<String, Object>> rows = new ArrayList<>();
        for (JsonNode row : r.path("rows")) {
            Map<String, Object> m = mapper.convertValue(row, Map.class);
            rows.add(m);
        }
        Map<String, Object> 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<String, String> parseCtx(String ctxJson) {
        if (isBlank(ctxJson)) {
            return Map.of();
        }
        try {
            @SuppressWarnings("unchecked")
            Map<String, String> m = mapper.readValue(ctxJson, Map.class);
            return m;
        } catch (Exception e) {
            log.debug("ctx 解析失败,按无上下文处理: {}", e.getMessage());
            return Map.of();
        }
    }

    private static List<String> textList(JsonNode arr) {
        List<String> out = new ArrayList<>();
        for (JsonNode n : arr) {
            out.add(n.asText());
        }
        return out;
    }

    private static boolean isBlank(String s) {
        return s == null || s.isBlank();
    }
}