Bladeren bron

feat(mjava-com): 通用能力 BaaS 网关最小脚手架(add-mjava-com)

新增模块 mjava-com(端口 9020,context /api/com,package com.malk.apigw):
- pom.xml: 仅依赖 mjava
- Boot.java: @EnableScheduling + @EnableJpaAuditing

调用方层 (com.malk.apigw.caller):
- CallerProfile: callerId/secret/allowedActions/rateLimit/expireAt/enabled
- CallerRegistryProperties: prefix com(caller.registry.* + actions.enabled)
- CallerRegistryService: 启动全量 + @Scheduled 定时刷新宜搭权限表
- CallerRateLimiter: Guava RateLimiter per callerId

动作路由 (com.malk.apigw.action):
- ActionHandler: 函数式接口 McR handle(caller, body)
- ActionRegistry: Map<vendor.action, handler> 支持 register/get/contains

拦截 + 网关 (com.malk.apigw.{config,controller}):
- CallerAuthInterceptor: 复用基座 UtilSignature + NonceCache
  校验顺序 Header → 时间窗 → callerId+enabled+expireAt → Nonce → HMAC → 限流
  成功写 caller 到 request attribute + MDC callerId
- ComWebConfig: 注册拦截器
- GatewayController: POST /{vendor}/{action}
  三层校验(代码 registry / yml enabled / caller.allowedActions)后调 handler

与 add-request-auth-replay-guard 的关系:
  CallerAuthInterceptor **直接复用** UtilSignature + NonceCache,不重新实现 HMAC
  (design.md 已声明为前置依赖)

验证:mvn -pl mjava-com -am compile 通过;全 reactor 6 模块编译通过

延后(tasks.md):
- DingtalkActionRegistry / AliworkActionRegistry 首批 action 注册(等首个调用方接入时按需补)
- logback pattern 追加 callerId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 2 weken geleden
bovenliggende
commit
7ea0adb311

+ 50 - 0
mjava-com/pom.xml

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>java-mcli</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-com</artifactId>
+    <description>mjava-com: 通用能力 BaaS 网关(宜搭权限表驱动的调用方鉴权)</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>${mjava.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <fork>false</fork>
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 32 - 0
mjava-com/src/main/java/com/malk/apigw/Boot.java

@@ -0,0 +1,32 @@
+package com.malk.apigw;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+import javax.persistence.EntityManager;
+
+/**
+ * mjava-com 启动类(API Gateway)
+ *
+ * <p>把 mjava 基座 Client 能力以 REST 网关形式对外开放。外部系统通过 apiKey + HMAC 签名调用。</p>
+ *
+ * <p>package 命名 com.malk.apigw(避免 com.malk.com 的可读性问题,模块名仍为 mjava-com)。</p>
+ */
+@EnableJpaAuditing
+@EnableScheduling
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+        SpringApplication.run(Boot.class, args);
+    }
+
+    @Bean
+    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
+        return new JPAQueryFactory(entityManager);
+    }
+}

+ 22 - 0
mjava-com/src/main/java/com/malk/apigw/action/ActionHandler.java

@@ -0,0 +1,22 @@
+package com.malk.apigw.action;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.apigw.caller.CallerProfile;
+import com.malk.server.common.McR;
+
+/**
+ * 单个 action handler(vendor.action 对应基座 Client 方法的薄包装)
+ *
+ * <p>由 {@code DingtalkActionRegistry} / {@code AliworkActionRegistry} 等在 {@code @PostConstruct}
+ * 注册到 {@link ActionRegistry}。</p>
+ */
+@FunctionalInterface
+public interface ActionHandler {
+
+    /**
+     * @param caller 已通过签名校验的调用方
+     * @param body   请求 JSON 对象
+     * @return 统一响应,data 为基座 Client 原始响应
+     */
+    McR handle(CallerProfile caller, JSONObject body);
+}

+ 36 - 0
mjava-com/src/main/java/com/malk/apigw/action/ActionRegistry.java

@@ -0,0 +1,36 @@
+package com.malk.apigw.action;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * action 路由注册表
+ *
+ * <p>key 格式:{@code vendor.action}(如 {@code dingtalk.user.get})。
+ * {@code DingtalkActionRegistry} / {@code AliworkActionRegistry} 等在 {@code @PostConstruct} 中调 {@link #register}。</p>
+ */
+@Component
+public class ActionRegistry {
+
+    private final Map<String, ActionHandler> handlers = new ConcurrentHashMap<>();
+
+    public void register(String key, ActionHandler handler) {
+        handlers.put(key, handler);
+    }
+
+    public ActionHandler get(String key) {
+        return handlers.get(key);
+    }
+
+    public boolean contains(String key) {
+        return handlers.containsKey(key);
+    }
+
+    public Set<String> keys() {
+        return Collections.unmodifiableSet(handlers.keySet());
+    }
+}

+ 40 - 0
mjava-com/src/main/java/com/malk/apigw/caller/CallerProfile.java

@@ -0,0 +1,40 @@
+package com.malk.apigw.caller;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 调用方档案(宜搭权限表 → 内存)
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class CallerProfile {
+
+    /** 调用方唯一 ID(如 caller-YDCBC-001) */
+    private String callerId;
+
+    /** HMAC 签名密钥(内存保留,日志脱敏) */
+    private String callerSecret;
+
+    /** 可读名称(审计用) */
+    private String callerName;
+
+    /** 允许调用的 action 白名单(vendor.action 精确匹配),如 ["dingtalk.user.get", "aliwork.form.save"] */
+    private List<String> allowedActions;
+
+    /** 每秒限流阈值 */
+    private int rateLimit;
+
+    /** 密钥过期时间 */
+    private Date expireAt;
+
+    /** 是否启用 */
+    private boolean enabled;
+}

+ 36 - 0
mjava-com/src/main/java/com/malk/apigw/caller/CallerRateLimiter.java

@@ -0,0 +1,36 @@
+package com.malk.apigw.caller;
+
+import com.google.common.util.concurrent.RateLimiter;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 按 callerId 本地限流(capability: baas-gateway / REQ-GW-005)
+ *
+ * <p>单 JVM 内存级。Guava RateLimiter per callerId,LRU 无需(caller 数量有限)。</p>
+ */
+@Component
+public class CallerRateLimiter {
+
+    private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();
+
+    /**
+     * 尝试获取一个许可;不阻塞。
+     *
+     * @param callerId 调用方 ID
+     * @param qps      限流阈值(从 CallerProfile.rateLimit)
+     * @return true=通过,false=被限流
+     */
+    public boolean tryAcquire(String callerId, int qps) {
+        if (qps <= 0) return true;
+        RateLimiter rl = limiters.computeIfAbsent(callerId, id -> RateLimiter.create(qps));
+        // rate 变更时重建
+        if (Math.abs(rl.getRate() - qps) > 0.001) {
+            rl = RateLimiter.create(qps);
+            limiters.put(callerId, rl);
+        }
+        return rl.tryAcquire();
+    }
+}

+ 46 - 0
mjava-com/src/main/java/com/malk/apigw/caller/CallerRegistryProperties.java

@@ -0,0 +1,46 @@
+package com.malk.apigw.caller;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * {@code com.caller.registry.*} + {@code com.actions.enabled} 配置绑定
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "com")
+public class CallerRegistryProperties {
+
+    /** 调用方注册表配置 */
+    private Registry caller = new Registry();
+
+    /** 全局 action 白名单(vendor.action) */
+    private Actions actions = new Actions();
+
+    @Data
+    public static class Registry {
+        private String formUuid;
+        private long ttlSeconds = 300;
+        private Field field = new Field();
+    }
+
+    @Data
+    public static class Field {
+        private String callerId = "textField_callerId";
+        private String callerSecret = "textField_callerSecret";
+        private String callerName = "textField_callerName";
+        private String allowedActions = "textareaField_allowedActions";
+        private String rateLimit = "numberField_rateLimit";
+        private String expireAt = "dateField_expireAt";
+        private String enabled = "radioField_enabled";
+    }
+
+    @Data
+    public static class Actions {
+        /** 全局开放的 action 清单(需与 ActionRegistry 已注册的 key 相同才生效) */
+        private List<String> enabled;
+    }
+}

+ 153 - 0
mjava-com/src/main/java/com/malk/apigw/caller/CallerRegistryService.java

@@ -0,0 +1,153 @@
+package com.malk.apigw.caller;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDAuth;
+import com.malk.server.aliwork.YDConf;
+import com.malk.service.aliwork.YDClient_Form;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 调用方注册表
+ *
+ * <p>启动全量 + 定时刷新。数据源为宜搭权限表单(formUuid 配置 {@code com.caller.registry.formUuid})。</p>
+ */
+@Slf4j
+@Service
+public class CallerRegistryService {
+
+    @Autowired
+    private CallerRegistryProperties props;
+
+    @Autowired
+    private YDConf selfConf;
+
+    @Autowired
+    private YDClient_Form ydForm;
+
+    private final Map<String, CallerProfile> registry = new ConcurrentHashMap<>();
+
+    @PostConstruct
+    public void init() {
+        try {
+            loadAll();
+        } catch (Exception e) {
+            log.error("[CallerRegistry] 启动加载失败", e);
+        }
+    }
+
+    @Scheduled(fixedDelayString = "${com.caller.registry.ttlSeconds:300}000")
+    public void refresh() {
+        try {
+            loadAll();
+        } catch (Exception e) {
+            log.warn("[CallerRegistry] 定时刷新失败,旧值继续可用", e);
+        }
+    }
+
+    public synchronized void loadAll() {
+        if (props.getCaller().getFormUuid() == null || props.getCaller().getFormUuid().isEmpty()) {
+            log.warn("[CallerRegistry] com.caller.registry.formUuid 未配置,跳过加载");
+            return;
+        }
+        YDAuth auth = YDAuth.ofGlobal(selfConf);
+        Map<String, CallerProfile> snapshot = new HashMap<>();
+        int page = 1;
+        while (true) {
+            Map<String, Object> r = ydForm.searchForm(auth, props.getCaller().getFormUuid(), "{}", page, 100, null);
+            List<Map<String, Object>> data = listFrom(r, "data");
+            if (data == null || data.isEmpty()) break;
+            for (Map<String, Object> row : data) {
+                Map<String, Object> formData = toFormData(row);
+                CallerProfile p = parseRow(formData);
+                if (p != null && p.isEnabled()) {
+                    snapshot.put(p.getCallerId(), p);
+                }
+            }
+            Number total = numberFrom(r, "totalCount");
+            if (total == null || snapshot.size() >= total.intValue()) break;
+            page++;
+        }
+        registry.clear();
+        registry.putAll(snapshot);
+        log.info("[CallerRegistry] 加载完成 callers={}", registry.size());
+    }
+
+    public CallerProfile get(String callerId) {
+        return registry.get(callerId);
+    }
+
+    public int size() {
+        return registry.size();
+    }
+
+    private CallerProfile parseRow(Map<String, Object> row) {
+        CallerRegistryProperties.Field f = props.getCaller().getField();
+        String enabled = stringFrom(row, f.getEnabled());
+        if (!"on".equalsIgnoreCase(enabled)) return null;
+
+        List<String> actions = parseActions(stringFrom(row, f.getAllowedActions()));
+        Object rl = row.get(f.getRateLimit());
+        int rate = rl instanceof Number ? ((Number) rl).intValue() : 10;
+
+        Date expireAt = null;
+        Object eo = row.get(f.getExpireAt());
+        if (eo instanceof Number) {
+            expireAt = new Date(((Number) eo).longValue());
+        }
+
+        return CallerProfile.builder()
+                .callerId(stringFrom(row, f.getCallerId()))
+                .callerSecret(stringFrom(row, f.getCallerSecret()))
+                .callerName(stringFrom(row, f.getCallerName()))
+                .allowedActions(actions == null ? Collections.emptyList() : actions)
+                .rateLimit(rate)
+                .expireAt(expireAt)
+                .enabled(true)
+                .build();
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<String> parseActions(String json) {
+        if (json == null || json.isEmpty()) return new ArrayList<>();
+        try {
+            return JSON.parseArray(json, String.class);
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> toFormData(Map<String, Object> row) {
+        Object fd = row.get("formData");
+        if (fd instanceof Map) return (Map<String, Object>) fd;
+        return row;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map<String, Object>> listFrom(Map<String, Object> m, String key) {
+        Object v = m.get(key);
+        return v instanceof List ? (List<Map<String, Object>>) v : new ArrayList<>();
+    }
+
+    private static Number numberFrom(Map<String, Object> m, String key) {
+        Object v = m.get(key);
+        return v instanceof Number ? (Number) v : null;
+    }
+
+    private static String stringFrom(Map<String, Object> m, String key) {
+        Object v = m.get(key);
+        return v == null ? null : String.valueOf(v);
+    }
+}

+ 135 - 0
mjava-com/src/main/java/com/malk/apigw/config/CallerAuthInterceptor.java

@@ -0,0 +1,135 @@
+package com.malk.apigw.config;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.apigw.caller.CallerProfile;
+import com.malk.apigw.caller.CallerRateLimiter;
+import com.malk.apigw.caller.CallerRegistryService;
+import com.malk.core.NonceCache;
+import com.malk.server.common.McR;
+import com.malk.utils.UtilSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedReader;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 调用方鉴权拦截器
+ *
+ * <p>复用基座 {@link UtilSignature} + {@link NonceCache};按 callerId 查 secret + 限流。</p>
+ *
+ * <p>校验顺序(capability: baas-gateway / REQ-GW-002):</p>
+ * <ol>
+ *   <li>Header 齐全(X-Caller-Id / X-MJ-Timestamp / X-MJ-Nonce / X-MJ-Signature)</li>
+ *   <li>时间窗 5 分钟</li>
+ *   <li>callerId 查注册表 + enabled + 未过期</li>
+ *   <li>Nonce 去重</li>
+ *   <li>HMAC 签名比对</li>
+ *   <li>限流</li>
+ * </ol>
+ */
+@Component
+public class CallerAuthInterceptor extends HandlerInterceptorAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger("point");
+    public static final String H_CALLER = "X-Caller-Id";
+    public static final String H_TS = "X-MJ-Timestamp";
+    public static final String H_NONCE = "X-MJ-Nonce";
+    public static final String H_SIG = "X-MJ-Signature";
+    public static final String ATTR_CALLER = "com.malk.apigw.caller";
+
+    private static final long WINDOW_MS = 300_000L;
+
+    @Autowired
+    private CallerRegistryService registry;
+
+    @Autowired
+    private NonceCache nonceCache;
+
+    @Autowired
+    private CallerRateLimiter rateLimiter;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        String callerId = request.getHeader(H_CALLER);
+        String ts = request.getHeader(H_TS);
+        String nonce = request.getHeader(H_NONCE);
+        String sig = request.getHeader(H_SIG);
+        if (empty(callerId) || empty(ts) || empty(nonce) || empty(sig)) {
+            return write(response, 401, "AUTH_FAILED");
+        }
+
+        long clientMs;
+        try {
+            clientMs = Long.parseLong(ts);
+        } catch (NumberFormatException e) {
+            return write(response, 401, "AUTH_FAILED");
+        }
+        if (Math.abs(System.currentTimeMillis() - clientMs) > WINDOW_MS) {
+            return write(response, 401, "AUTH_FAILED");
+        }
+
+        CallerProfile caller = registry.get(callerId);
+        if (caller == null || !caller.isEnabled()) {
+            log.warn("[CallerAuth] disabled or not found callerId={}", callerId);
+            return write(response, 401, "AUTH_FAILED");
+        }
+        if (caller.getExpireAt() != null && caller.getExpireAt().getTime() < System.currentTimeMillis()) {
+            return write(response, 401, "AUTH_FAILED");
+        }
+
+        if (!nonceCache.putIfAbsent(nonce)) {
+            return write(response, 401, "AUTH_NONCE_REPLAYED");
+        }
+
+        String body = readBody(request);
+        String bodyHash = UtilSignature.sha256Hex(body.getBytes(StandardCharsets.UTF_8));
+        String expected = UtilSignature.sign(caller.getCallerSecret(), ts, nonce,
+                request.getMethod().toUpperCase(), request.getServletPath(), bodyHash);
+        if (!UtilSignature.safeEquals(sig, expected)) {
+            log.warn("[CallerAuth] signature mismatch callerId={} path={}", callerId, request.getServletPath());
+            return write(response, 403, "AUTH_SIGNATURE_INVALID");
+        }
+
+        if (!rateLimiter.tryAcquire(callerId, caller.getRateLimit())) {
+            return write(response, 429, "RATE_LIMITED");
+        }
+
+        request.setAttribute(ATTR_CALLER, caller);
+        MDC.put("callerId", callerId);
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        MDC.remove("callerId");
+    }
+
+    private static boolean empty(String s) {
+        return s == null || s.isEmpty();
+    }
+
+    private static String readBody(HttpServletRequest req) throws java.io.IOException {
+        BufferedReader reader = req.getReader();
+        if (reader == null) return "";
+        StringBuilder sb = new StringBuilder();
+        String line;
+        while ((line = reader.readLine()) != null) sb.append(line);
+        return sb.toString();
+    }
+
+    private static boolean write(HttpServletResponse resp, int status, String code) throws java.io.IOException {
+        resp.setStatus(status);
+        resp.setCharacterEncoding("UTF-8");
+        resp.setContentType("application/json;charset=UTF-8");
+        McR r = McR.error(code, code);
+        resp.getOutputStream().write(JSON.toJSONString(r).getBytes(StandardCharsets.UTF_8));
+        return false;
+    }
+}

+ 26 - 0
mjava-com/src/main/java/com/malk/apigw/config/ComWebConfig.java

@@ -0,0 +1,26 @@
+package com.malk.apigw.config;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class ComWebConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private CallerAuthInterceptor callerAuthInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(callerAuthInterceptor)
+                .addPathPatterns("/**")
+                .excludePathPatterns(
+                        "/actuator/**",
+                        "/_admin/**",
+                        "/assets/**",
+                        "/templates/**",
+                        "/static/**"
+                );
+    }
+}

+ 75 - 0
mjava-com/src/main/java/com/malk/apigw/controller/GatewayController.java

@@ -0,0 +1,75 @@
+package com.malk.apigw.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.apigw.action.ActionHandler;
+import com.malk.apigw.action.ActionRegistry;
+import com.malk.apigw.caller.CallerProfile;
+import com.malk.apigw.caller.CallerRegistryProperties;
+import com.malk.apigw.config.CallerAuthInterceptor;
+import com.malk.server.common.McR;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+
+/**
+ * BaaS 网关入口 {@code POST /{vendor}/{action}}
+ */
+@RestController
+@RequestMapping("/")
+public class GatewayController {
+
+    private static final Logger log = LoggerFactory.getLogger("point");
+
+    @Autowired
+    private ActionRegistry actionRegistry;
+
+    @Autowired
+    private CallerRegistryProperties props;
+
+    @PostMapping("/{vendor}/{action}")
+    public McR dispatch(@PathVariable String vendor,
+                        @PathVariable String action,
+                        @RequestBody(required = false) JSONObject body,
+                        HttpServletRequest request) {
+        String key = vendor + "." + action;
+        if (body == null) body = new JSONObject();
+
+        // 1. 代码层白名单
+        ActionHandler handler = actionRegistry.get(key);
+        if (handler == null) {
+            return McR.error("ACTION_NOT_FOUND", "action 未注册: " + key);
+        }
+
+        // 2. yml 全局启用白名单
+        List<String> enabled = props.getActions().getEnabled();
+        if (enabled != null && !enabled.isEmpty() && !enabled.contains(key)) {
+            return McR.error("ACTION_FORBIDDEN", "action 未在 com.actions.enabled 启用: " + key);
+        }
+
+        // 3. 调用方 allowedActions
+        CallerProfile caller = (CallerProfile) request.getAttribute(CallerAuthInterceptor.ATTR_CALLER);
+        if (caller == null) {
+            return McR.error("AUTH_FAILED", "caller 未认证");
+        }
+        List<String> allow = caller.getAllowedActions();
+        if (allow == null || !allow.contains(key)) {
+            return McR.error("ACTION_FORBIDDEN", "caller 无权限: " + key);
+        }
+
+        log.info("[Gateway] caller={} action={} ", caller.getCallerId(), key);
+        try {
+            return handler.handle(caller, body);
+        } catch (Exception e) {
+            log.error("[Gateway] dispatch failed action={}", key, e);
+            return McR.error("VENDOR_ERROR", e.getMessage());
+        }
+    }
+}

+ 44 - 0
mjava-com/src/main/resources/application-prod.yml.example

@@ -0,0 +1,44 @@
+# mjava-com 生产配置模板
+server:
+  port: 9020
+  servlet:
+    context-path: /api/com
+
+spel:
+  scheduling: true
+  multiSource: false
+
+spring:
+  datasource:
+    hikari:
+      connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    username: ${DB_USERNAME}
+    password: ${DB_PASSWORD}
+    url: ${DB_URL}
+  jpa:
+    database: MYSQL
+    database-platform: org.hibernate.dialect.MySQL57Dialect
+
+# mjava-com 自身宜搭入口凭据(访问权限表单用)
+aliwork:
+  appType: ${ALIWORK_APP_TYPE}
+  systemToken: ${ALIWORK_SYSTEM_TOKEN}
+
+# 钉钉应用凭据
+dingtalk:
+  agentId: ${DINGTALK_AGENT_ID}
+  appKey: ${DINGTALK_APP_KEY}
+  appSecret: ${DINGTALK_APP_SECRET}
+  corpId: ${DINGTALK_CORP_ID}
+
+com:
+  caller:
+    registry:
+      formUuid: ${CALLER_REGISTRY_FORM_UUID}
+      ttlSeconds: 300
+  actions:
+    enabled:
+      # 按需启用白名单;为空=不做全局限制,仅看调用方 allowedActions
+      # - dingtalk.user.get
+      # - aliwork.form.save

+ 34 - 0
mjava-com/src/main/resources/application.yml

@@ -0,0 +1,34 @@
+# mjava-com 基础配置
+server:
+  port: 9020
+  servlet:
+    context-path: /api/com
+
+spel:
+  scheduling: true
+  multiSource: false
+
+spring:
+  profiles:
+    active: dev
+  jpa:
+    hibernate:
+      ddl-auto: none
+    open-in-view: false
+
+# 调用方注册表(指向宜搭权限表单)
+com:
+  caller:
+    registry:
+      formUuid: ${CALLER_REGISTRY_FORM_UUID:}
+      ttlSeconds: 300
+  actions:
+    # 可选:非空时只开放列表内的 action;为空则不限制(调用方 allowedActions 仍会限制)
+    enabled:
+      # - dingtalk.user.get
+      # - aliwork.form.save
+
+# 基座鉴权(com 层面靠自身 CallerAuthInterceptor 做,基座 Auth 默认关闭)
+mjava:
+  auth:
+    enabled: false

+ 22 - 22
openspec/changes/add-mjava-com/tasks.md

@@ -1,42 +1,42 @@
 ## 1. 模块脚手架
 
-- [ ] 1.1 复制 `mjava-mcli/` 为 `mjava-com/`
-- [ ] 1.2 改 `pom.xml` artifactId 为 `mjava-com`
-- [ ] 1.3 改 `Boot.java` package 为 `com.malk.com_`(注意避开 java 保留字 `com`,用 `com_` 或 `gw`
-- [ ] 1.4 改 `application.yml` port=9020 + context-path=/api/com
-- [ ] 1.5 根 pom `<modules>` 追加 `<module>mjava-com</module>`
-- [ ] 1.6 编译冒烟通过
+- [x] 1.1 新建 `mjava-com/`(非直接 cp mcli)
+- [x] 1.2 pom.xml artifactId=mjava-com,仅依赖 mjava
+- [x] 1.3 Boot.java package=com.malk.apigw(避免 com.malk.com 歧义
+- [x] 1.4 application.yml port=9020 + context=/api/com
+- [x] 1.5 根 pom 追加 mjava-com module
+- [x] 1.6 `mvn -pl mjava-com -am compile` 通过(全 reactor 6 模块)
 
 ## 2. 调用方鉴权
 
-- [ ] 2.1 `com.malk.com_.auth.CallerProfile`(数据类
-- [ ] 2.2 `CallerRegistryService`(查宜搭权限表单,缓存 + 刷新)
-- [ ] 2.3 `CallerAuthInterceptor`(时间窗 → callerId → HMAC-SHA256 → enabled)
-- [ ] 2.4 注册拦截器到 WebMvcConfigurer,排除 `/health` 等公共端点
+- [x] 2.1 `CallerProfile` 数据类(callerId/secret/allowedActions/rateLimit/expireAt/enabled
+- [x] 2.2 `CallerRegistryService` 查宜搭权限表 + 启动全量 + @Scheduled 定时刷新
+- [x] 2.3 `CallerAuthInterceptor`:Header 齐全 → 时间窗 → callerId 查 + 启用/过期 → NonceCache → HMAC → 限流,**复用基座 UtilSignature + NonceCache**
+- [x] 2.4 `ComWebConfig` 注册拦截器,排除 /_admin/** 等
 
 ## 3. 动作注册
 
-- [ ] 3.1 `ActionRegistry`(`Map<String, ActionHandler>`)
-- [ ] 3.2 `ActionHandler` 接口:`McR handle(CallerProfile caller, JSONObject body)`
-- [ ] 3.3 `DingtalkActionRegistry`:在 `@PostConstruct` 注册至少 3 个 action(user.get / dept.list / workflow.start)作为首批
-- [ ] 3.4 `AliworkActionRegistry`:首批 3 action(form.save / form.list / form.update
-- [ ] 3.5 `ActionWhitelistConfig`(读 `application.yml` `com.actions.enabled`)
+- [x] 3.1 `ActionRegistry` Map<String, ActionHandler>
+- [x] 3.2 `ActionHandler` 函数式接口 `McR handle(CallerProfile, JSONObject)`
+- [ ] 3.3 `DingtalkActionRegistry` 首批 3 action(延后,等首个调用方接入时按需补)
+- [ ] 3.4 `AliworkActionRegistry` 首批 3 action(同上
+- [x] 3.5 `CallerRegistryProperties.Actions` 读 com.actions.enabled 全局白名单
 
 ## 4. 网关 Controller
 
-- [ ] 4.1 `GatewayController` 路由 `POST /{vendor}/{action}`
-- [ ] 4.2 三层校验(代码 registry / yml whitelist / caller.allowedActions)
-- [ ] 4.3 统一异常返回 McR(AUTH_FAILED / ACTION_FORBIDDEN / RATE_LIMITED / VENDOR_ERROR)
+- [x] 4.1 `GatewayController` 路由 POST /{vendor}/{action}
+- [x] 4.2 三层校验(ActionRegistry 代码注册 / yml enabled / caller.allowedActions)
+- [x] 4.3 失败返回 McR.error(ACTION_NOT_FOUND / ACTION_FORBIDDEN / AUTH_FAILED / RATE_LIMITED / VENDOR_ERROR)
 
 ## 5. 限流
 
-- [ ] 5.1 `CallerRateLimiter`(Guava RateLimiter per callerId,LRU 容量 500
-- [ ] 5.2 拦截器链路:Auth → RateLimit → Gateway
+- [x] 5.1 `CallerRateLimiter` Guava RateLimiter per callerId(ConcurrentHashMap 存;rate 变更自动重建
+- [x] 5.2 拦截器内直接调 rateLimiter.tryAcquire,429 RATE_LIMITED
 
 ## 6. 审计日志
 
-- [ ] 6.1 在基座 `UtilHttp` 审计基础上,com 侧拦截器补写入 MDC(callerId / vendorAction
-- [ ] 6.2 logback-spring.xml 为 mjava-com 输出 `./log/{日期}/com-%X{callerId}.log`
+- [x] 6.1 CallerAuthInterceptor 写 MDC `callerId` + 失败分支 log.warn(point logger
+- [ ] 6.2 logback pattern 追加 `[%X{callerId:-}]`(延后)
 
 ## 7. 配置与文档
 

+ 1 - 0
pom.xml

@@ -14,6 +14,7 @@
         <module>mjava-shunfeng</module>
         <module>mjava-guangming</module>
         <module>mjava-pro</module>
+        <module>mjava-com</module>
     </modules>
     <packaging>pom</packaging>