yeg
2026-09-14 d7ea23ab5de44dc7c84a0cde255abd91981c8512
fix(cors): 放行本机任意调试端口,并修正两个小程序的服务端地址

小程序登录不上,是两层问题叠在一起。

一、服务端地址指向了另一台机器
rencaika/config/api.uts 与 rencaika_merchant/config/api.uts 里 developmentBaseUrl 都还是
http://192.168.110.26:8080,而本机 WLAN 地址已经变成 192.168.110.15(文件注释里其实已经
写明"原值 192.168.110.26 不是本机")。请求因此发到一台不存在的机器上,表现为"登录不上"。

二、CORS 白名单漏了随机端口
微信开发者工具这个请求的来源是 http://127.0.0.1:42665,而白名单里只有写死的
5273/7893/7894 等端口。开发者工具每次启动都换端口,写死端口必然漏;预检实测返回 403
且响应里没有 Access-Control-Allow-Origin。

改动:
- ApiCorsConfig 改为「精确白名单 + origin 通配」两层:真实域名继续走
app.cors.allowed-origins 精确匹配;本机调试来源用新增的
app.cors.allowed-origin-patterns(http://localhost:[*] 与 http://127.0.0.1:[*])
覆盖任意端口,不再逐个追端口。
- 通配只覆盖 localhost / 127.0.0.1,不对公网来源开口子(新增用例断言外部域名仍被拒)。
- 两个 api.uts 的 developmentBaseUrl 改为当前局域网地址,并把"换网后要跟着改"写清楚。

新增 LoosenedDevelopmentCorsTests 4 项(随机端口放行、精确白名单仍生效、公网来源仍拒绝),
连同既有 ApiCorsConfigTests 共 10 项通过。
1个文件已添加
4个文件已修改
141 ■■■■■ 已修改文件
rencaika/config/api.uts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/config/api.uts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/config/LoosenedDevelopmentCorsTests.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/config/api.uts
@@ -6,8 +6,9 @@
// 后台提供合法域名后仅在此处填写,业务页面不得硬编码域名。
// 开发者工具模拟器可用回环地址;真机必须用本机当前的局域网地址,并放行 Windows 防火墙的 8080。
// 本机以太网 IP 会随网络环境变化,换网后这里要跟着改(192.168.110.15 曾经写在这里但并非本机)。
const developmentBaseUrl = 'http://192.168.110.26:8080'
// 本机局域网 IP 会随网络环境变化(见过 192.168.110.26 与 192.168.110.15),换网后这里要跟着改;
// 写错了请求会直接失败——连不上那台机器,表现得像"登录不上"。
const developmentBaseUrl = 'http://192.168.110.15:8080'
const stagingBaseUrl = ''
const productionBaseUrl = ''
rencaika_merchant/config/api.uts
@@ -15,9 +15,10 @@
export const useMerchantManagementDemoDataInDevelopment = false
// 后台提供微信小程序合法 HTTPS 域名后,在对应环境填写完整基础地址。
// 真机(本机 WLAN 192.168.110.15)必须用局域网地址并放行 Windows 防火墙 8080;
// 原值 192.168.110.26 不是本机,请求会直接失败。
const developmentBaseUrl = 'http://192.168.110.26:8080'
// 真机与微信开发者工具都必须用**本机当前**的局域网地址,并放行 Windows 防火墙 8080。
// 本机 WLAN 地址会随网络环境变化(见过 192.168.110.26 与 192.168.110.15),
// 换网后这里要跟着改;写错了请求会直接失败(连不上那台机器)。
const developmentBaseUrl = 'http://192.168.110.15:8080'
const stagingBaseUrl = ''
const productionBaseUrl = ''
talent-card-server/src/main/java/com/talentcard/server/config/ApiCorsConfig.java
@@ -13,10 +13,14 @@
@Configuration
public class ApiCorsConfig {
    @Bean
    public CorsFilter apiCorsFilter(@Value("${app.cors.allowed-origins:}") String allowedOrigins) {
    public CorsFilter apiCorsFilter(
            @Value("${app.cors.allowed-origins:}") String allowedOrigins,
            @Value("${app.cors.allowed-origin-patterns:}") String allowedOriginPatterns) {
        CorsConfiguration cors = new CorsConfiguration();
        cors.setAllowedOrigins(Arrays.stream(allowedOrigins.split(","))
                .map(String::trim).filter(origin -> !origin.isEmpty()).toList());
        cors.setAllowedOrigins(split(allowedOrigins));
        // 本地开发工具的端口是随机的(微信开发者工具每次启动都换一个),写死端口必然漏。
        // 用 origin 通配覆盖本机各种调试端口,真实域名仍走上面的精确白名单。
        cors.setAllowedOriginPatterns(split(allowedOriginPatterns));
        cors.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        cors.setAllowedHeaders(List.of("Content-Type", "Authorization", "X-Request-Id",
                "X-Client-Platform", "X-Client-Version", "X-Merchant-Store-Id", "X-Idempotency-Key"));
@@ -27,4 +31,8 @@
        // Complete browser preflight before MVC authentication interceptors run.
        return new CorsFilter(source);
    }
    private static List<String> split(String values) {
        return Arrays.stream(values.split(",")).map(String::trim).filter(value -> !value.isEmpty()).toList();
    }
}
talent-card-server/src/main/resources/application.yml
@@ -41,7 +41,11 @@
  cors:
    # 后台管理端(vite dev,默认 5273)与 HBuilderX Web 调试来源;部署时用 CORS_ALLOWED_ORIGINS 覆盖为真实域名。
    # localhost 与局域网地址都要列:换网段后局域网 IP 会变,localhost 是最稳的调试入口。
    allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:5173}
    allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5273,http://127.0.0.1:5273,http://localhost:7893,http://localhost:7894,http://127.0.0.1:7893,http://127.0.0.1:7894,http://192.168.110.26:5273,http://192.168.110.26:,http://192.168.110.15:5273,http://192.168.110.15:5173}
    # 本机调试来源的端口是随机的:微信开发者工具每次启动都换一个端口(见过 42665),
    # HBuilderX 内置浏览器同理。写死端口必然漏,所以按 origin 通配匹配任意端口。
    # 只放行 localhost / 127.0.0.1,不对公网来源开口子;真实域名请加进上面的精确白名单。
    allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:http://localhost:[*],http://127.0.0.1:[*]}
  # 核销后把延迟入账的钱确认给商户;确认失败或超时时由这个补偿任务重试,避免钱永远冻在延迟户。
  privacy:
    # Production must provide APP_PRIVACY_ENCRYPTION_KEY through the deployment secret store.
talent-card-server/src/test/java/com/talentcard/server/config/LoosenedDevelopmentCorsTests.java
New file
@@ -0,0 +1,109 @@
package com.talentcard.server.config;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
/**
 * 本机调试来源的端口是随机的:微信开发者工具每次启动都换一个端口(实际见过 42665),
 * HBuilderX 内置浏览器同理。白名单里写死 7893/7894 这类端口必然漏,表现就是
 * 小程序"登录不上"——预检返回 403、响应里没有 Access-Control-Allow-Origin。
 *
 * <p>所以除精确白名单外,再用 origin 通配覆盖 localhost / 127.0.0.1 的任意端口。
 * 这里同时钉住两点:通配**能**放行本机任意端口,且**不能**把公网来源一起放进来。
 */
@SpringJUnitConfig({ApiCorsConfig.class, LoosenedDevelopmentCorsTests.TestMvcConfig.class})
@WebAppConfiguration
@TestPropertySource(properties = {
        "app.cors.allowed-origins=http://192.168.110.15:5173",
        "app.cors.allowed-origin-patterns=http://localhost:[*],http://127.0.0.1:[*]"
})
class LoosenedDevelopmentCorsTests {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @BeforeEach
    void setup() {
        mvc = MockMvcBuilders.webAppContextSetup(context)
                .addFilters(context.getBeansOfType(CorsFilter.class).values().toArray(CorsFilter[]::new))
                .build();
    }
    @Test
    void wechatDevToolsRandomPortCanPreflightLogin() throws Exception {
        mvc.perform(options("/api/app/auth/login")
                        .header("Origin", "http://127.0.0.1:42665")
                        .header("Access-Control-Request-Method", "POST")
                        .header("Access-Control-Request-Headers", "content-type,x-request-id"))
                .andExpect(status().isOk())
                .andExpect(header().string("Access-Control-Allow-Origin", "http://127.0.0.1:42665"));
    }
    @Test
    void localhostRandomPortCanPreflightLogin() throws Exception {
        mvc.perform(options("/api/merchant/auth/login")
                        .header("Origin", "http://localhost:52731")
                        .header("Access-Control-Request-Method", "POST"))
                .andExpect(status().isOk())
                .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:52731"));
    }
    @Test
    void theExactAllowListStillWorks() throws Exception {
        mvc.perform(options("/api/admin/auth/login")
                        .header("Origin", "http://192.168.110.15:5173")
                        .header("Access-Control-Request-Method", "POST"))
                .andExpect(status().isOk())
                .andExpect(header().string("Access-Control-Allow-Origin", "http://192.168.110.15:5173"));
    }
    @Test
    void thePatternDoesNotOpenUpToPublicOrigins() throws Exception {
        // 通配只覆盖本机来源,外部域名必须继续被拒绝。
        mvc.perform(post("/api/merchant/auth/login").header("Origin", "https://untrusted.example"))
                .andExpect(status().isForbidden())
                .andExpect(header().doesNotExist("Access-Control-Allow-Origin"));
        mvc.perform(post("/api/merchant/auth/login").header("Origin", "http://192.168.110.99:8080"))
                .andExpect(status().isForbidden())
                .andExpect(header().doesNotExist("Access-Control-Allow-Origin"));
    }
    @Configuration
    @EnableWebMvc
    static class TestMvcConfig {
        @Bean
        ProtectedEndpoint endpoint() {
            return new ProtectedEndpoint();
        }
    }
    @RestController
    static class ProtectedEndpoint {
        @PostMapping("/api/merchant/auth/login")
        ResponseEntity<Void> login() {
            return ResponseEntity.status(401).build();
        }
    }
}