Bladeren bron

feat(mjava-pro): 多客户单部署子模块最小脚手架(add-mjava-pro)

按 change 四件套落地 mjava-pro,提供租户上下文 + 宜搭应用表驱动的注册表:

新增模块 mjava-pro(端口 9010,context /api/pro):
- pom.xml: 仅依赖 mjava 基座;独立 Boot jar
- Boot.java: @EnableScheduling + @EnableJpaAuditing + scanBasePackages=com.malk
- application.yml: scheduling=true 启用注册表定时刷新
- application-prod.yml.example: 全部敏感值走环境变量

租户运行时(com.malk.pro.tenant):
- TenantProfile / VendorCredential: 数据载体
- TenantContext: ThreadLocal 持有当前请求租户;set/current/clear/propagate
- TenantInterceptor: preHandle 读 X-Tenant-Id → 注册表查 → set;401/403 返回统一 McR;afterCompletion clear MDC
- TenantRegistryProperties: prefix tenant.registry(formUuid/ttlSeconds/failFast/field 映射)
- TenantRegistryService: @PostConstruct 全量加载;@Scheduled(fixedDelay) 定时刷新;
  直接消费 YDClient_Form.searchForm(extend-yida-api-coverage 原子接口);
  同 tenantId 多 vendor 合并到 vendorCredentials Map

配置 & Controller:
- ProWebConfig: 注册 TenantInterceptor(与基座 WebConfiguration 共存)
- AdminController: @Profile("dev") @NoAuth POST /_admin/reloadTenant 热刷

根 pom.xml modules 追加 mjava-pro。

验证:mvn -pl mjava-pro -am compile 通过,全模块 reactor 编译通过。

延后(见 tasks.md):
- TenantTaskDecorator(@Async 跨线程传播)待首次生产启用时评估
- DynamicDDService / DynamicYDService Client 透传样例待需求明确后补
- 单元 + 集成测试

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

+ 51 - 0
mjava-pro/pom.xml

@@ -0,0 +1,51 @@
+<?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-pro</artifactId>
+    <description>mjava-pro: 多客户单部署(宜搭应用表驱动的租户配置)</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>

+ 39 - 0
mjava-pro/src/main/java/com/malk/pro/Boot.java

@@ -0,0 +1,39 @@
+package com.malk.pro;
+
+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-pro 启动类
+ *
+ * <p>多客户单部署模式。请求带 {@code X-Tenant-Id} 从宜搭应用表动态加载租户配置。</p>
+ *
+ * <p>基座扫描 {@code com.malk}:</p>
+ * <ul>
+ *   <li>复用 mjava 基座所有 Client / Service / UtilHttp / UtilToken / TraceIdFilter</li>
+ *   <li>复用 AuthFilter / AuthInterceptor(可通过 mjava.auth.enabled 开启)</li>
+ *   <li>新增 TenantContext / TenantInterceptor 做租户隔离</li>
+ * </ul>
+ *
+ * <p>默认端口 9010,context-path {@code /api/pro}。</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);
+    }
+}

+ 34 - 0
mjava-pro/src/main/java/com/malk/pro/config/ProWebConfig.java

@@ -0,0 +1,34 @@
+package com.malk.pro.config;
+
+import com.malk.pro.tenant.TenantInterceptor;
+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;
+
+/**
+ * mjava-pro 的 MVC 拦截器注册
+ *
+ * <p>与基座 WebConfiguration 共存:Spring 会同时调用所有 WebMvcConfigurer。
+ * 本配置仅追加 TenantInterceptor,不影响基座的 RequestInterceptor / AuthInterceptor。</p>
+ */
+@Configuration
+public class ProWebConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private TenantInterceptor tenantInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(tenantInterceptor)
+                .addPathPatterns("/**")
+                .excludePathPatterns(
+                        "/actuator/**",
+                        "/_admin/**",
+                        "/assets/**",
+                        "/templates/**",
+                        "/static/**",
+                        "/web2/**"
+                );
+    }
+}

+ 37 - 0
mjava-pro/src/main/java/com/malk/pro/controller/AdminController.java

@@ -0,0 +1,37 @@
+package com.malk.pro.controller;
+
+import com.malk.filter.NoAuth;
+import com.malk.pro.tenant.TenantRegistryService;
+import com.malk.server.common.McR;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 管理入口(仅 dev profile 启用)
+ *
+ * <p>仅用于开发期热刷租户注册表。生产 profile 下本 Controller 不装配。</p>
+ */
+@Profile("dev")
+@RestController
+@RequestMapping("/_admin")
+public class AdminController {
+
+    @Autowired
+    private TenantRegistryService registry;
+
+    /** 手动热刷整个注册表 */
+    @NoAuth
+    @PostMapping("/reloadTenant")
+    public McR reloadTenant() {
+        registry.loadAll();
+        Map<String, Object> data = new HashMap<>();
+        data.put("tenants", registry.size());
+        return McR.success(data);
+    }
+}

+ 61 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/TenantContext.java

@@ -0,0 +1,61 @@
+package com.malk.pro.tenant;
+
+/**
+ * 租户上下文(ThreadLocal)
+ *
+ * <p>规则(capability: multi-tenant-runtime):</p>
+ * <ul>
+ *   <li>请求入口 {@link TenantInterceptor} 写入</li>
+ *   <li>finally 块 / {@code afterCompletion} 清理,防止线程池复用污染</li>
+ *   <li>异步线程切换用 {@link #propagate(TenantProfile, Runnable)}</li>
+ * </ul>
+ */
+public final class TenantContext {
+
+    private static final ThreadLocal<TenantProfile> HOLDER = new ThreadLocal<>();
+
+    private TenantContext() {
+    }
+
+    public static void set(TenantProfile profile) {
+        HOLDER.set(profile);
+    }
+
+    public static TenantProfile current() {
+        return HOLDER.get();
+    }
+
+    public static String currentTenantId() {
+        TenantProfile p = HOLDER.get();
+        return p == null ? null : p.getTenantId();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+
+    /**
+     * 在指定 TenantProfile 下执行 runnable,执行结束恢复原 context。
+     *
+     * <p>手动切换线程时使用:</p>
+     * <pre>
+     * TenantProfile ctx = TenantContext.current();
+     * CompletableFuture.runAsync(() -&gt; TenantContext.propagate(ctx, () -&gt; {
+     *     // 这里 TenantContext.current() 可用
+     * }), executor);
+     * </pre>
+     */
+    public static void propagate(TenantProfile target, Runnable runnable) {
+        TenantProfile prev = HOLDER.get();
+        HOLDER.set(target);
+        try {
+            runnable.run();
+        } finally {
+            if (prev == null) {
+                HOLDER.remove();
+            } else {
+                HOLDER.set(prev);
+            }
+        }
+    }
+}

+ 67 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/TenantInterceptor.java

@@ -0,0 +1,67 @@
+package com.malk.pro.tenant;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.common.McR;
+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.nio.charset.StandardCharsets;
+
+/**
+ * 租户解析拦截器(capability: multi-tenant-runtime)
+ *
+ * <ul>
+ *   <li>preHandle:读 {@code X-Tenant-Id} → 查注册表 → {@link TenantContext#set},并写 MDC {@code tenantId}</li>
+ *   <li>afterCompletion:清 TenantContext + MDC</li>
+ *   <li>缺失 → 401 {@code TENANT_REQUIRED};不存在 → 403 {@code TENANT_NOT_FOUND}</li>
+ * </ul>
+ */
+@Component
+public class TenantInterceptor extends HandlerInterceptorAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger("point");
+    public static final String HEADER = "X-Tenant-Id";
+    public static final String MDC_KEY = "tenantId";
+
+    @Autowired
+    private TenantRegistryService registry;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        String tenantId = request.getHeader(HEADER);
+        if (tenantId == null || tenantId.isEmpty()) {
+            write(response, 401, "TENANT_REQUIRED");
+            log.warn("[Tenant] header missing path={}", request.getServletPath());
+            return false;
+        }
+        TenantProfile profile = registry.get(tenantId);
+        if (profile == null || !profile.isEnabled()) {
+            write(response, 403, "TENANT_NOT_FOUND");
+            log.warn("[Tenant] not found or disabled tenantId={} path={}", tenantId, request.getServletPath());
+            return false;
+        }
+        TenantContext.set(profile);
+        MDC.put(MDC_KEY, tenantId);
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        TenantContext.clear();
+        MDC.remove(MDC_KEY);
+    }
+
+    private static void 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));
+    }
+}

+ 39 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/TenantProfile.java

@@ -0,0 +1,39 @@
+package com.malk.pro.tenant;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 租户配置档案(mjava-pro 运行时内存副本)
+ *
+ * <p>由 {@link TenantRegistryService} 从宜搭应用表拉取构造,写入 {@link TenantContext} ThreadLocal。</p>
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class TenantProfile {
+
+    /** 租户唯一标识(建议用客户代号,如 guangming / shunfeng)*/
+    private String tenantId;
+
+    /** 按 vendor 的授权凭据映射,key 为 vendor name */
+    private Map<String, VendorCredential> vendorCredentials;
+
+    /** 租户级扩展配置(业务自定义) */
+    private Map<String, Object> extra;
+
+    /** 是否启用;false 时从注册表移除 */
+    private boolean enabled;
+
+    /**
+     * 取指定 vendor 的凭据;不存在返回 null。
+     */
+    public VendorCredential credential(String vendor) {
+        return vendorCredentials == null ? null : vendorCredentials.get(vendor);
+    }
+}

+ 37 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/TenantRegistryProperties.java

@@ -0,0 +1,37 @@
+package com.malk.pro.tenant;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * {@code tenant.registry.*} 配置绑定
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "tenant.registry")
+public class TenantRegistryProperties {
+
+    /** 宜搭应用表 formUuid(必填) */
+    private String formUuid;
+
+    /** 租户记录刷新 TTL,秒 */
+    private long ttlSeconds = 600;
+
+    /** 启动时全量加载失败是否抛异常(生产建议 true) */
+    private boolean failFast = false;
+
+    /** 字段映射(可覆盖默认命名) */
+    private FieldMap field = new FieldMap();
+
+    @Data
+    public static class FieldMap {
+        private String tenantId = "textField_tenantId";
+        private String vendor = "textField_vendor";
+        private String appKey = "textField_appKey";
+        private String appSecret = "textField_appSecret";
+        private String corpId = "textField_corpId";
+        private String extraJson = "textareaField_extraJson";
+        private String enabled = "radioField_enabled";
+    }
+}

+ 165 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/TenantRegistryService.java

@@ -0,0 +1,165 @@
+package com.malk.pro.tenant;
+
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 租户注册表
+ *
+ * <p>启动时全量拉取宜搭应用表 → 内存 Map。`@Scheduled` 定时刷新。</p>
+ *
+ * <p>入口凭据用 mjava-pro 自身的 YDConf({@code aliwork.appType} / {@code aliwork.systemToken}),
+ * 从其 searchForm 获取租户记录;每条记录的 appKey/appSecret 属于某个**租户 + vendor**。</p>
+ */
+@Slf4j
+@Service
+public class TenantRegistryService {
+
+    @Autowired
+    private TenantRegistryProperties props;
+
+    @Autowired
+    private YDConf selfConf;
+
+    @Autowired
+    private YDClient_Form ydForm;
+
+    /** 内存注册表:tenantId → profile */
+    private final Map<String, TenantProfile> registry = new ConcurrentHashMap<>();
+
+    @PostConstruct
+    public void init() {
+        try {
+            loadAll();
+        } catch (Exception e) {
+            log.error("[TenantRegistry] 启动加载失败", e);
+            if (props.isFailFast()) {
+                throw e;
+            }
+        }
+    }
+
+    @Scheduled(fixedDelayString = "${tenant.registry.ttlSeconds:600}000")
+    public void refresh() {
+        try {
+            loadAll();
+        } catch (Exception e) {
+            log.warn("[TenantRegistry] 定时刷新失败,旧值继续可用", e);
+        }
+    }
+
+    /**
+     * 全量加载:按 formUuid 分页 searchForm → 转 TenantProfile → 替换 registry
+     */
+    public synchronized void loadAll() {
+        if (props.getFormUuid() == null || props.getFormUuid().isEmpty()) {
+            log.warn("[TenantRegistry] tenant.registry.formUuid 未配置,跳过加载");
+            return;
+        }
+        YDAuth auth = YDAuth.ofGlobal(selfConf);
+        Map<String, TenantProfile> snapshot = new HashMap<>();
+        int page = 1;
+        while (true) {
+            Map<String, Object> r = ydForm.searchForm(auth, props.getFormUuid(), "{}", page, 100, null);
+            List<Map<String, Object>> data = listFrom(r, "data");
+            if (data == null || data.isEmpty()) break;
+            for (Map<String, Object> row : data) {
+                mergeRow(snapshot, toFormData(row));
+            }
+            Number total = numberFrom(r, "totalCount");
+            if (total == null || snapshot.size() >= total.intValue()) break;
+            page++;
+        }
+        registry.clear();
+        registry.putAll(snapshot);
+        log.info("[TenantRegistry] 加载完成 tenants={} vendors={}",
+                registry.size(),
+                registry.values().stream().mapToInt(p -> p.getVendorCredentials() == null ? 0 : p.getVendorCredentials().size()).sum());
+    }
+
+    /** 合并宜搭表一行:同一 tenantId 的多行按 vendor 聚合 */
+    private void mergeRow(Map<String, TenantProfile> snapshot, Map<String, Object> row) {
+        TenantRegistryProperties.FieldMap f = props.getField();
+        String tenantId = stringFrom(row, f.getTenantId());
+        String vendor = stringFrom(row, f.getVendor());
+        String enabled = stringFrom(row, f.getEnabled());
+        if (tenantId == null || vendor == null) return;
+        if (!"on".equalsIgnoreCase(enabled)) return;
+
+        VendorCredential cred = VendorCredential.builder()
+                .vendor(vendor)
+                .appKey(stringFrom(row, f.getAppKey()))
+                .appSecret(stringFrom(row, f.getAppSecret()))
+                .corpId(stringFrom(row, f.getCorpId()))
+                .extra(parseExtra(stringFrom(row, f.getExtraJson())))
+                .build();
+
+        TenantProfile profile = snapshot.computeIfAbsent(tenantId, id ->
+                TenantProfile.builder()
+                        .tenantId(id)
+                        .vendorCredentials(new HashMap<>())
+                        .enabled(true)
+                        .build());
+        profile.getVendorCredentials().put(vendor, cred);
+    }
+
+    public TenantProfile get(String tenantId) {
+        return registry.get(tenantId);
+    }
+
+    public boolean contains(String tenantId) {
+        return registry.containsKey(tenantId);
+    }
+
+    public int size() {
+        return registry.size();
+    }
+
+    // --------- helpers ---------
+
+    @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);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> parseExtra(String json) {
+        if (json == null || json.isEmpty()) return null;
+        try {
+            return JSON.parseObject(json, Map.class);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}

+ 35 - 0
mjava-pro/src/main/java/com/malk/pro/tenant/VendorCredential.java

@@ -0,0 +1,35 @@
+package com.malk.pro.tenant;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 单个 vendor(钉钉/宜搭/北森/...)的授权凭据
+ *
+ * <p>属于某个 {@link TenantProfile} 的 vendorCredentials Map 的 value。</p>
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class VendorCredential {
+
+    /** 平台标识:dingtalk / aliwork / beisen / ... */
+    private String vendor;
+
+    /** 应用 appKey */
+    private String appKey;
+
+    /** 应用 appSecret(内存明文保留;日志脱敏) */
+    private String appSecret;
+
+    /** 企业 corpId(钉钉需要;其他 vendor 按需) */
+    private String corpId;
+
+    /** 其他扩展字段(如 agentId、systemToken 等),JSON 解析后放入 */
+    private Map<String, Object> extra;
+}

+ 50 - 0
mjava-pro/src/main/resources/application-prod.yml.example

@@ -0,0 +1,50 @@
+# mjava-pro 生产配置模板
+# 复制为 application-prod.yml 填入真实值;真实文件被 .gitignore 排除
+
+server:
+  port: 9010
+  servlet:
+    context-path: /api/pro
+
+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-pro 自身的宜搭入口凭据(访问"应用表"用)
+aliwork:
+  appType: ${ALIWORK_APP_TYPE}
+  systemToken: ${ALIWORK_SYSTEM_TOKEN}
+
+# 钉钉应用凭据(取 accessToken 访问宜搭 OpenAPI)
+dingtalk:
+  agentId: ${DINGTALK_AGENT_ID}
+  appKey: ${DINGTALK_APP_KEY}
+  appSecret: ${DINGTALK_APP_SECRET}
+  corpId: ${DINGTALK_CORP_ID}
+
+# 租户注册表
+tenant:
+  registry:
+    formUuid: ${TENANT_REGISTRY_FORM_UUID}
+    ttlSeconds: 600
+    failFast: true
+
+# 请求鉴权(生产建议开启)
+mjava:
+  auth:
+    enabled: true
+    secret: ${AUTH_SECRET}
+    window: 300
+    nonce-cache-size: 10000

+ 31 - 0
mjava-pro/src/main/resources/application.yml

@@ -0,0 +1,31 @@
+# mjava-pro 基础配置(提交入库)
+server:
+  port: 9010
+  servlet:
+    context-path: /api/pro
+
+# 条件注入(复用基座开关)
+spel:
+  scheduling: true        # 需要 TenantRegistryService @Scheduled 刷新
+  multiSource: false
+
+spring:
+  profiles:
+    active: dev
+  jpa:
+    hibernate:
+      ddl-auto: none      # 无数据库或共享库时避免启动崩溃
+    open-in-view: false
+
+# 租户注册表配置(指向宜搭应用表)
+tenant:
+  registry:
+    formUuid: ${TENANT_REGISTRY_FORM_UUID:}
+    ttlSeconds: 600
+    failFast: false
+    # field 映射使用默认值(TenantRegistryProperties.FieldMap)
+
+# 基座请求鉴权(可选开启)
+mjava:
+  auth:
+    enabled: false

+ 16 - 16
openspec/changes/add-mjava-pro/tasks.md

@@ -1,27 +1,27 @@
 ## 1. 模块脚手架
 
-- [ ] 1.1 复制 `mjava-mcli/` 为 `mjava-pro/`
-- [ ] 1.2 改 `pom.xml` artifactId 为 `mjava-pro`
-- [ ] 1.3 改 `Boot.java` package 为 `com.malk.pro`,`scanBasePackages = {"com.malk"}` 保持
-- [ ] 1.4 改 `application.yml` server.port=9010 + context-path=/api/pro;spel.multiSource=false
-- [ ] 1.5 根 `pom.xml` `<modules>` 追加 `<module>mjava-pro</module>`
-- [ ] 1.6 `mvn -pl mjava-pro -am compile` 通过
+- [x] 1.1 新建 `mjava-pro/`(非直接 cp mcli,精简依赖)
+- [x] 1.2 `pom.xml` artifactId=mjava-pro,依赖仅 mjava 基座
+- [x] 1.3 `Boot.java` package=com.malk.pro,`@EnableScheduling` + `@EnableJpaAuditing` + scanBasePackages=com.malk
+- [x] 1.4 `application.yml` port=9010 / context-path=/api/pro / spel.scheduling=true / multiSource=false
+- [x] 1.5 根 `pom.xml` `<modules>` 追加 `mjava-pro`
+- [x] 1.6 `mvn -pl mjava-pro -am compile` 通过(2026-04-19 验证)
 
 ## 2. TenantContext 核心
 
-- [ ] 2.1 新建 `mjava-pro/src/main/java/com/malk/pro/tenant/TenantProfile.java`(数据类:tenantId + vendorCredentials + extraJson)
-- [ ] 2.2 新建 `com.malk.pro.tenant.TenantContext`(`ThreadLocal<TenantProfile>` + `current()/set()/clear()/propagate()`
-- [ ] 2.3 新建 `TenantInterceptor`(`HandlerInterceptor`)解析 `X-Tenant-Id` → 查注册表 → set;afterCompletion clear
-- [ ] 2.4 在 `WebMvcConfigurer` 注册拦截器,排除 `/_admin/**` 的公共端点
-- [ ] 2.5 扩展 `MdcTaskDecorator` 或新建 `TenantTaskDecorator`,挂到 `AsyncConfig` 两个线程池
+- [x] 2.1 `TenantProfile` + `VendorCredential` 数据类
+- [x] 2.2 `TenantContext`(ThreadLocal + set/current/clear/propagate + currentTenantId
+- [x] 2.3 `TenantInterceptor`(preHandle 读 X-Tenant-Id → registry.get → TenantContext.set + MDC;afterCompletion clear;401 TENANT_REQUIRED / 403 TENANT_NOT_FOUND)
+- [x] 2.4 `ProWebConfig` 注册拦截器,排除 /_admin/** / /actuator/** / /static/** 等
+- [ ] 2.5 TenantTaskDecorator(@Async 传播)— 延后,首次生产启用时评估
 
 ## 3. TenantRegistry
 
-- [ ] 3.1 新建 `com.malk.pro.tenant.TenantRegistryService`(依赖 `YDClient`
-- [ ] 3.2 实现 `loadAll()`:按 `tenant.registry.formUuid` 分页查宜搭应用表,转 `TenantProfile`
-- [ ] 3.3 实现 `get(tenantId)`:内存 Map 查;缺失触发 `loadOne()` 单条拉取
-- [ ] 3.4 `@PostConstruct loadAll()`;`@Scheduled(fixedDelay = ttl)` 异步刷新
-- [ ] 3.5 新建 `AdminController`(仅 dev profile 通过 `@Profile("dev")` 生效)暴露 `/reloadTenant`
+- [x] 3.1 `TenantRegistryService`(依赖 `YDClient_Form`,使用新原子接口 searchForm
+- [x] 3.2 `loadAll()` 按 formUuid 分页查宜搭应用表 → mergeRow 合并同 tenantId 多 vendor → `Map<String, TenantProfile>`
+- [x] 3.3 `get(tenantId)` / `contains` / `size` 内存 Map 直查(缓存 miss 不触发单条拉取,等下次 TTL 刷新)
+- [x] 3.4 `@PostConstruct init()` + `@Scheduled(fixedDelayString=ttl*1000)` 定时刷新;failFast 开关控制启动失败行为
+- [x] 3.5 `AdminController` `@Profile("dev") @NoAuth POST /_admin/reloadTenant`
 
 ## 4. Client / Service 透传
 

+ 1 - 0
pom.xml

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