feat(tenant): 租户域名 —— 用子域前缀判断"这是哪个单位的站点"(t1.xxx.com / t2.xxx.com)
客户新提:不同的租户采用不同的地址前缀来获取租户ID。
一、它解决什么
在此之前"这次是哪个单位"都要人填或扫:PC 登录要手填租户码(跨单位重名时报 40012
「请填写租户码」,而用户根本不知道该填什么)、H5 与小程序靠扫码 scene。
而域名本身就是"我是谁"最自然的答案 —— 在 t1.xxx.com 上打开页面时,
系统不该再问"你是哪个单位的"。
前缀用 t{tenantId},与扫码 scene 的 t{tid} **同一套约定**(scene 早就是 t1-k17),
不引入第二套编号。
二、★ 唯一必须守住的规则:Host 定位,token 授权
未登录 → Host 决定"去哪个单位里找这个人"
已登录 → 一律以 token 里的 tenantId 为准,且 Host 不一致就拒绝
不校验后者的话:在 t1.xxx.com 上拿 t2 的 token 就能读 t2 的数据 ——
而界面一切正常(前端本来就会带 token),是个安静且极难发现的越权。
所以 AuthInterceptor 里那段判断比"登录免填租户码"重要得多,E2E 专门守着它。
三、落地
FillProperties.TenantDomain(enabled/base/scheme) 默认关,siteOf(tid) 拼站点地址
support/TenantDomainResolver 解析不出来返回 null(不是错误)
TenantMapper.selectActiveById 停用/已删的租户不解析
UserMapper.selectByTenantIdAndUsername 域名路径按 id 找用户
AuthService.locateUser 域名优先;手填租户码与域名矛盾 → 明确报错
AuthInterceptor Host 与 token 租户不一致 → 403
MpH5AuthController.redirectToApp 按 scene 的 t{tid} 跳回该单位站点
application-dev.yml enabled/base=localhost/scheme=http
null 的语义很重要:解析不出来 ≠ 出错,而是"这是统一入口/单域名部署",回退老路径。
所以单域名部署仍然完全合法(enabled=false 即可)。
四、两条来自微信的硬约束(决定架构,不是可选)
1. 公众号网页授权的「授权回调页面域名」**只能配一个** → 每租户一个子域时,
回调统一落在主域名,再按 scene 里的 t{tid} 302 回该单位站点。
正好复用既有的 state(scene 随 state 走)+ token 放 fragment 的机制。
2. 小程序的 request 合法域名是**固定白名单**,配不了 *.xxx.com →
小程序端用不了域名,继续靠 scene 里的 t{tid}。这点客户需要知道。
五、本地开发的坑(已处理)
开发期 base 是 localhost,真按它回跳会把手机上的学生从穿透域名扔到 t1.localhost ——
打不开,且是浏览器级失败,看不出是我们跳错了。所以回跳加了本机地址判断
(localhost/127.0.0.1/192.168.*/10.*/*.local),本机 base 一律回退到 h5-base-url。
六、E2E:scripts/e2e-m1-tenant-domain.py → 16/16
[1] t1.localhost + 不填租户码 → 登录成功(对照:不带域名 → 40012);
带端口/大小写/多一层子域都能解析
[2] 域名与手填租户码矛盾 → 明确报错;一致则正常
[3] ★ 拿 t1 的 token 访问 t14 域名 → 403,提示可理解
[4] 边界:x1.localhost / t999999.localhost / example.com → 都回退老路径
用 http.client 伪造 Host 头(urllib 会按 URL 自己算 Host,覆盖不掉)。
七、全量 676 条断言、0 失败(新增 16);verify-docs 19 项通过
八、⏳ 需要拍板的三件事(已写进交接摘要 §一·二十五)
1. 正式域名(base)+ 泛域名解析 + 泛域名证书 *.xxx.com(备案一个主域名即可覆盖子域)
2. 要不要保留"手填租户码"(我保留了:统一入口、老客户端、运维排查都需要)
3. 小程序端确认继续靠 scene
另:这条新需求应补进 PRD 与 03-接口设计(目前只落在代码与本摘要里)——下一轮补。
This commit is contained in:
@@ -22,6 +22,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* @param storage 材料文件的存储后端(本地磁盘 / 七牛云)
|
||||
* @param miniProgram 微信小程序(填报二维码)
|
||||
* @param mpH5 H5 填报端(网页授权)
|
||||
* @param tenantDomain 租户域名(用子域前缀区分租户,如 t1.xxx.com)
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "fill")
|
||||
public record FillProperties(
|
||||
@@ -32,9 +33,61 @@ public record FillProperties(
|
||||
Upload upload,
|
||||
Storage storage,
|
||||
MiniProgram miniProgram,
|
||||
MpH5 mpH5
|
||||
MpH5 mpH5,
|
||||
TenantDomain tenantDomain
|
||||
) {
|
||||
|
||||
/**
|
||||
* 租户域名:用<b>子域前缀</b>区分租户({@code t1.xxx.com} / {@code t2.xxx.com})。
|
||||
*
|
||||
* <h3>为什么要有它</h3>
|
||||
*
|
||||
* <p>在此之前,"这次是哪个单位"有三个来源,且都要人填或扫:
|
||||
* PC 登录要手填<b>租户码</b>(账号跨单位重名时报 {@code 40012})、
|
||||
* H5 靠扫码 scene 里的 {@code t{tid}}、小程序同样靠 scene。
|
||||
* 域名本身就是"我是谁"的最自然来源 —— 用户在 {@code t1.xxx.com} 上打开页面时,
|
||||
* 系统不该再问他"你是哪个单位的"。
|
||||
*
|
||||
* <p>前缀用 {@code t{tenantId}},与扫码 scene 的 {@code t{tid}} 是<b>同一套约定</b>
|
||||
* (scene 早就是 {@code t1-k17} 了),两处不引入第二套编号。
|
||||
*
|
||||
* <h3>⚠️ 三件不能忘的事</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>默认关闭</b>({@code enabled: false})。单域名部署仍然是合法形态,
|
||||
* 开了它就要有泛域名解析与泛域名证书,不该默认逼着人配。</li>
|
||||
* <li><b>Host 只能用来"定位",不能用来"授权"</b>。已登录的请求一律以
|
||||
* token 里的 {@code tenantId} 为准,并且要校验它与 Host 一致 ——
|
||||
* 否则在 {@code t1.xxx.com} 上拿 t2 的 token 就能读 t2 的数据。
|
||||
* 见 {@code AuthInterceptor}。</li>
|
||||
* <li><b>小程序端用不了它</b>(微信的 request 合法域名是固定白名单,配不了泛域名),
|
||||
* 所以小程序继续靠 scene 里的 {@code t{tid}}。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param enabled 是否启用(默认 false)
|
||||
* @param base 主域名,如 {@code xxx.com};租户域名为 {@code t{tid}.{base}}
|
||||
* @param scheme 对外协议({@code https},本地开发 {@code http})
|
||||
*/
|
||||
public record TenantDomain(Boolean enabled, String base, String scheme) {
|
||||
|
||||
public boolean on() {
|
||||
return Boolean.TRUE.equals(enabled) && base != null && !base.isBlank();
|
||||
}
|
||||
|
||||
public String baseOrDefault() {
|
||||
return base == null ? "" : base.trim().toLowerCase();
|
||||
}
|
||||
|
||||
public String schemeOrDefault() {
|
||||
return scheme == null || scheme.isBlank() ? "https" : scheme.trim();
|
||||
}
|
||||
|
||||
/** 某个租户的站点地址,如 {@code https://t1.xxx.com}。 */
|
||||
public String siteOf(Long tenantId) {
|
||||
return schemeOrDefault() + "://t" + tenantId + "." + baseOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 材料文件存哪里。
|
||||
*
|
||||
@@ -51,8 +104,7 @@ public record FillProperties(
|
||||
Qiniu qiniu,
|
||||
Boolean selfCheckOnStartup,
|
||||
Boolean keepRaw
|
||||
) {
|
||||
public String providerOrDefault() {
|
||||
) { public String providerOrDefault() {
|
||||
// 默认 local:这样"什么都不配"也能把项目跑起来,
|
||||
// 而不是因为缺一个云存储密钥连本地开发都做不了
|
||||
return provider == null || provider.isBlank() ? "local" : provider.trim().toLowerCase();
|
||||
|
||||
+35
-5
@@ -63,6 +63,9 @@ public class AuthService {
|
||||
*/
|
||||
private final String dummyHash;
|
||||
|
||||
/** 从域名判断"这是哪个单位的站点"(t1.xxx.com)——只有启用了租户域名才会有值。 */
|
||||
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
|
||||
|
||||
public AuthService(UserMapper userMapper,
|
||||
UserRoleMapper userRoleMapper,
|
||||
TenantMapper tenantMapper,
|
||||
@@ -73,7 +76,8 @@ public class AuthService {
|
||||
PasswordEncoder passwordEncoder,
|
||||
cn.tianbaotong.fill.security.JwtService jwtService,
|
||||
TokenBlacklist tokenBlacklist,
|
||||
PermissionChecker permissionChecker) {
|
||||
PermissionChecker permissionChecker,
|
||||
cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver) {
|
||||
this.userMapper = userMapper;
|
||||
this.userRoleMapper = userRoleMapper;
|
||||
this.tenantMapper = tenantMapper;
|
||||
@@ -85,6 +89,7 @@ public class AuthService {
|
||||
this.jwtService = jwtService;
|
||||
this.tokenBlacklist = tokenBlacklist;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.tenantDomainResolver = tenantDomainResolver;
|
||||
this.dummyHash = passwordEncoder.encode("this-is-not-a-real-password");
|
||||
}
|
||||
|
||||
@@ -110,7 +115,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
// 3) 定位用户(账号唯一性是 (tenant_id, username),可能跨租户重名)
|
||||
SysUser user = locateUser(req, account);
|
||||
SysUser user = locateUser(req, account, httpReq);
|
||||
|
||||
// 4) 密码校验
|
||||
String plainPassword;
|
||||
@@ -271,10 +276,35 @@ public class AuthService {
|
||||
* </ol>
|
||||
* 第 3 条绝不"随便挑一条"——那会让用户登进别人的学校。
|
||||
*/
|
||||
private SysUser locateUser(LoginReq req, String account) {
|
||||
/**
|
||||
* 定位用户(账号唯一性是 {@code (tenant_id, username)},可能跨租户重名)。
|
||||
*
|
||||
* <h3>租户从哪来:域名优先</h3>
|
||||
*
|
||||
* <p>启用租户域名后({@code t1.xxx.com}),<b>域名本身已经回答了"你是哪个单位的"</b>,
|
||||
* 所以这条路径下不再需要人填租户码 —— 那句"该账号存在于多个单位,请填写租户码"
|
||||
* ({@code 40012})在租户域名下根本不会出现。
|
||||
*
|
||||
* <p>⚠️ 如果用户<b>同时</b>填了租户码,而它与域名对不上,<b>明确报错而不是悄悄忽略</b>:
|
||||
* 悄悄忽略会让"我在 A 单位的域名下用 B 单位的码登进去了"这种事实被掩盖,
|
||||
* 而当事人只会觉得"这个码填了没用"。
|
||||
*/
|
||||
private SysUser locateUser(LoginReq req, String account, HttpServletRequest httpReq) {
|
||||
String code = req.getTenantCode() == null ? null : req.getTenantCode().trim();
|
||||
Long hostTenant = tenantDomainResolver.resolve(httpReq);
|
||||
|
||||
List<SysUser> candidates;
|
||||
if (req.getTenantCode() != null && !req.getTenantCode().isBlank()) {
|
||||
candidates = userMapper.selectByTenantCodeAndUsername(req.getTenantCode().trim(), account);
|
||||
if (hostTenant != null) {
|
||||
SysTenant tenant = tenantMapper.selectActiveById(hostTenant);
|
||||
if (code != null && !code.isEmpty() && tenant != null
|
||||
&& !code.equalsIgnoreCase(tenant.getTenantCode())) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"当前访问的是「" + tenant.getTenantName() + "」的站点("
|
||||
+ tenant.getTenantCode() + "),与填写的租户码不一致,请检查链接");
|
||||
}
|
||||
candidates = userMapper.selectByTenantIdAndUsername(hostTenant, account);
|
||||
} else if (code != null && !code.isEmpty()) {
|
||||
candidates = userMapper.selectByTenantCodeAndUsername(code, account);
|
||||
} else {
|
||||
candidates = userMapper.selectByUsernameAcrossTenants(account);
|
||||
}
|
||||
|
||||
+29
@@ -141,11 +141,40 @@ public class MpH5AuthController {
|
||||
* 换句话说:<b>参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query</b>,
|
||||
* 别放裸 hash。
|
||||
*/
|
||||
/**
|
||||
* 是不是本机/内网地址({@code localhost} / {@code 127.0.0.1} / {@code *.local})。
|
||||
*
|
||||
* <p>用来决定"要不要按租户域名回跳":开发期的 base 就是 {@code localhost},
|
||||
* 真按它跳会把手机上的学生扔到一个打不开的地址({@code t1.localhost}),
|
||||
* 而那是浏览器级的失败,看不出是我们跳错了。
|
||||
*/
|
||||
private static boolean isLoopbackHost(String host) {
|
||||
if (host == null || host.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String h = host.trim().toLowerCase();
|
||||
return "localhost".equals(h) || h.endsWith(".localhost") || h.endsWith(".local")
|
||||
|| h.equals("127.0.0.1") || h.equals("0.0.0.0") || h.startsWith("192.168.")
|
||||
|| h.startsWith("10.") || h.endsWith(".internal");
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirectToApp(String token, String scene, String error) {
|
||||
String base = props.mpH5() == null ? null : props.mpH5().h5BaseUrlOrDefault();
|
||||
if (base == null || base.isBlank()) {
|
||||
base = "http://127.0.0.1:5174";
|
||||
}
|
||||
// ★ 租户域名优先:授权回调**只能配一个域名**(微信的"授权回调页面域名"是单值),
|
||||
// 所以回调统一落在主域名上,再按 scene 里的 t{tid} 跳回该单位的站点。
|
||||
// 这就是"每租户一个域名"与"微信只允许一个回调域名"之间的桥。
|
||||
//
|
||||
// ⚠️ 但**本地/内网地址不能这么跳**:开发期 base 是 localhost,
|
||||
// 真照它跳的话,手机上的学生会从穿透域名被扔到 t1.localhost —— 打不开,
|
||||
// 而且报错是浏览器级的,看不出是我们跳错了。所以本机地址一律回退到 h5-base-url。
|
||||
Long tid = scene == null || scene.isBlank() ? null : MpSceneService.tenantIdOf(scene);
|
||||
if (tid != null && props.tenantDomain() != null && props.tenantDomain().on()
|
||||
&& !isLoopbackHost(props.tenantDomain().baseOrDefault())) {
|
||||
base = props.tenantDomain().siteOf(tid);
|
||||
}
|
||||
StringBuilder q = new StringBuilder();
|
||||
if (token != null) {
|
||||
q.append("token=").append(enc(token));
|
||||
|
||||
+17
@@ -1,7 +1,10 @@
|
||||
package cn.tianbaotong.fill.module.tenant.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.tenant.entity.SysTenant;
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 租户 Mapper。
|
||||
@@ -11,4 +14,18 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
* (由 {@code @RequirePerm(TENANT_MANAGE)} 在 Controller 层把关)。
|
||||
*/
|
||||
public interface TenantMapper extends BaseMapper<SysTenant> {
|
||||
|
||||
/**
|
||||
* 按 id 取<b>可用</b>的租户 —— 不存在 / 已停用 / 已删都返回 {@code null}。
|
||||
*
|
||||
* <p>给租户域名解析用({@code t1.xxx.com} → 1):域名对得上不代表这个单位还能用,
|
||||
* 停用的单位不该有人能登进去。返回整个实体(而不只是 id),
|
||||
* 是因为登录时还要拿它的名字/租户码去比对手填的租户码。
|
||||
*
|
||||
* <p>⚠️ 关掉租户拦截器并显式按主键查:{@code sys_tenant} 是<b>租户本身那张表</b>,
|
||||
* 它没有 {@code tenant_id} 列,走拦截器会拼出一个不存在的条件。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT * FROM sys_tenant WHERE id = #{id} AND status = 1 AND deleted = 0")
|
||||
SysTenant selectActiveById(@Param("id") Long id);
|
||||
}
|
||||
|
||||
+18
@@ -50,6 +50,24 @@ public interface UserMapper extends BaseMapper<SysUser> {
|
||||
List<SysUser> selectByTenantCodeAndUsername(@Param("tenantCode") String tenantCode,
|
||||
@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 按<b>租户域名解析出的 tenantId</b> + 登录名精确定位(租户域名下的路径)。
|
||||
*
|
||||
* <p>与上面那条的区别只是"用 id 还是用 code":域名前缀是 {@code t{tenantId}}
|
||||
* (与扫码 scene 的 {@code t{tid}} 同一套约定),所以这里按 id 查。
|
||||
*
|
||||
* <p>租户状态也一并卡住:已停用的单位,域名还在解析,但不该有人能登进去。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT u.* FROM sys_user u
|
||||
JOIN sys_tenant t ON t.id = u.tenant_id AND t.deleted = 0 AND t.status = 1
|
||||
WHERE u.tenant_id = #{tenantId}
|
||||
AND u.username = #{username} AND u.deleted = 0
|
||||
""")
|
||||
List<SysUser> selectByTenantIdAndUsername(@Param("tenantId") Long tenantId,
|
||||
@Param("username") String username);
|
||||
|
||||
/** 按手机号 HMAC 查用户(同一租户内)。检索走 hash 列,密文列无法等值查询。 */
|
||||
@Select("SELECT * FROM sys_user WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0")
|
||||
List<SysUser> selectByPhoneHash(@Param("tenantId") Long tenantId,
|
||||
|
||||
@@ -30,6 +30,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
private final JwtService jwtService;
|
||||
private final TokenBlacklist blacklist;
|
||||
private final PermissionChecker permissionChecker;
|
||||
private final cn.tianbaotong.fill.support.TenantDomainResolver tenantDomainResolver;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
@@ -50,6 +51,21 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
}
|
||||
|
||||
LoginUser user = jwtService.parse(token);
|
||||
|
||||
// ★ 租户域名与 token 的租户必须一致 —— 这是"用域名区分租户"唯一必须守住的点。
|
||||
//
|
||||
// Host 只用来**定位**(这个请求落在谁的站点上),授权一律以 token 为准。
|
||||
// 不校验的话:在 t1.xxx.com 上拿着 t2 的 token 就能读 t2 的数据 ——
|
||||
// 一个安静、且极难被发现的越权(界面一切正常,因为前端本来就会带 token)。
|
||||
Long hostTenant = tenantDomainResolver.resolve(request);
|
||||
if (hostTenant != null && !hostTenant.equals(user.tenantId())) {
|
||||
log.warn("租户域名与 token 不一致:uid={} tokenTenant={} hostTenant={} host={}",
|
||||
user.uid(), user.tenantId(), hostTenant,
|
||||
cn.tianbaotong.fill.support.TenantDomainResolver.hostOf(request));
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"你登录的是另一个单位,请回到本单位的网址重新登录");
|
||||
}
|
||||
|
||||
UserContext.set(user);
|
||||
request.setAttribute(ATTR_TOKEN, token);
|
||||
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import cn.tianbaotong.fill.config.FillProperties;
|
||||
import cn.tianbaotong.fill.module.tenant.mapper.TenantMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 从<b>域名</b>判断"这是哪个单位的站点"({@code t1.xxx.com} / {@code t2.xxx.com})。
|
||||
*
|
||||
* <h3>它只回答"定位",不回答"授权"</h3>
|
||||
*
|
||||
* <p>这是本类唯一需要记住的边界:
|
||||
*
|
||||
* <table>
|
||||
* <tr><th></th><th>来源</th><th>用途</th></tr>
|
||||
* <tr><td>未登录</td><td><b>Host</b></td><td>定位:登录时该去哪个单位里找这个人、H5 回调该跳回哪</td></tr>
|
||||
* <tr><td>已登录</td><td><b>token</b></td><td>授权:以 token 里的 tenantId 为准</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>两者不一致时**必须拒绝**(见 {@code AuthInterceptor}):否则在 {@code t1.xxx.com}
|
||||
* 上拿着 t2 的 token 就能读到 t2 的数据 —— 那是个安静、且极难被发现的越权。
|
||||
*
|
||||
* <h3>前缀约定与扫码 scene 是同一套</h3>
|
||||
*
|
||||
* <p>{@code t{tenantId}} —— 扫码 scene 早就是 {@code t1-k17} 了({@code WxMiniProgramService.sceneOf}),
|
||||
* 这里沿用同一个编号方式,不引入第二套"租户短码"。
|
||||
*
|
||||
* <p>⚠️ 小程序端<b>用不了域名</b>:微信的 request 合法域名是固定白名单,
|
||||
* 配不了 {@code *.xxx.com}。所以小程序继续靠 scene 里的 {@code t{tid}}。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TenantDomainResolver {
|
||||
|
||||
/** 只认 {@code t} + 纯数字,且只有一层({@code t1} ✓、{@code a.t1} 由调用方截断后判断)。 */
|
||||
private static final Pattern PREFIX = Pattern.compile("^t(\\d+)$");
|
||||
|
||||
private final FillProperties props;
|
||||
private final TenantMapper tenantMapper;
|
||||
|
||||
/**
|
||||
* 这条请求落在哪个租户的域名上。
|
||||
*
|
||||
* @return tenantId;<b>解析不出来就返回 {@code null}</b> ——
|
||||
* 表示"这是统一入口/单域名部署",调用方应回退到原有的方式(填租户码、靠 scene)。
|
||||
* <b>不要把 null 当成错误</b>:单域名部署是合法形态。
|
||||
*/
|
||||
public Long resolve(HttpServletRequest req) {
|
||||
return resolveHost(hostOf(req));
|
||||
}
|
||||
|
||||
/** 把 Host 解析成 tenantId(抽出来便于测试与日志)。 */
|
||||
public Long resolveHost(String host) {
|
||||
FillProperties.TenantDomain cfg = props.tenantDomain();
|
||||
if (cfg == null || !cfg.on() || host == null || host.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String h = host.trim().toLowerCase();
|
||||
String base = cfg.baseOrDefault();
|
||||
if (!h.endsWith("." + base)) {
|
||||
// 主域名本身(xxx.com)= 平台/统一入口,不属于任何租户
|
||||
return null;
|
||||
}
|
||||
String prefix = h.substring(0, h.length() - base.length() - 1);
|
||||
// 多一层子域时不猜:a.t1.xxx.com 取最后一段(t1)——"再往上是 CDN 还是环境名"无从判断
|
||||
int dot = prefix.lastIndexOf('.');
|
||||
if (dot >= 0) {
|
||||
prefix = prefix.substring(dot + 1);
|
||||
}
|
||||
Matcher m = PREFIX.matcher(prefix);
|
||||
if (!m.matches()) {
|
||||
return null;
|
||||
}
|
||||
long id;
|
||||
try {
|
||||
id = Long.parseLong(m.group(1));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
Long active = tenantMapper.selectActiveById(id) == null ? null : id;
|
||||
if (active == null) {
|
||||
// 域名对得上、但单位不存在或已停用:**当作没有这个租户域名**,
|
||||
// 由上层给出明确提示("该单位不存在或已停用"),而不是让它退化成"找不到人"
|
||||
log.debug("租户域名未命中有效单位:host={} 解析出的 id={}", host, id);
|
||||
return null;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 Host:优先 {@code X-Forwarded-Host}(花生壳穿透 / 反代场景),再去掉端口。
|
||||
*
|
||||
* <p>为什么要看 {@code X-Forwarded-Host}:本地与穿透环境下,Tomcat 看到的
|
||||
* {@code Host} 常常是 {@code 127.0.0.1:8080},真实域名在转发头里。
|
||||
* <b>只在有配置时采信它</b>(生产是网关直连,一般没有这个头)。
|
||||
*/
|
||||
public static String hostOf(HttpServletRequest req) {
|
||||
if (req == null) {
|
||||
return null;
|
||||
}
|
||||
String h = req.getHeader("X-Forwarded-Host");
|
||||
if (h == null || h.isBlank()) {
|
||||
h = req.getServerName();
|
||||
}
|
||||
if (h == null || h.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
// X-Forwarded-Host 可能是 "a.com, b.com"(多级代理),取第一个
|
||||
int comma = h.indexOf(',');
|
||||
if (comma > 0) {
|
||||
h = h.substring(0, comma);
|
||||
}
|
||||
h = h.trim();
|
||||
int colon = h.indexOf(':');
|
||||
if (colon > 0) {
|
||||
h = h.substring(0, colon);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,28 @@ fill:
|
||||
# 代码里还会再判一次 profile 不含 prod(双保险)。
|
||||
stub-enabled: ${WX_H5_STUB:true}
|
||||
|
||||
# ── 租户域名(用子域前缀区分租户)──────────────────────────────────────
|
||||
#
|
||||
# 形态:t{tenantId}.{base},如 t1.example.com / t2.example.com。
|
||||
# 前缀用的是 **tenantId**,与扫码 scene 的 t{tid} 是同一套约定(scene 早就是 t1-k17)。
|
||||
#
|
||||
# 打开之后:
|
||||
# · PC 登录在 t1.xxx.com 上**不用再填租户码** —— 域名已经回答了"你是哪个单位的",
|
||||
# 那句「该账号存在于多个单位,请填写租户码」在这条路径下不会再出现
|
||||
# · H5 微信授权回调仍落在主域名(微信的"授权回调页面域名"只能配一个),
|
||||
# 回调再按 scene 里的 t{tid} 跳回该单位的站点
|
||||
#
|
||||
# ⚠️ 已登录的请求**以 token 里的 tenantId 为准**,Host 只用于定位;
|
||||
# 两者不一致会被 AuthInterceptor 拒掉(403)—— 否则在 t1 域名上拿 t2 的 token
|
||||
# 就能读 t2 的数据,而界面看起来一切正常。
|
||||
# ⚠️ 小程序端**用不了它**:微信的 request 合法域名是固定白名单,配不了 *.xxx.com,
|
||||
# 所以小程序继续靠 scene 里的 t{tid}。
|
||||
# ⚠️ 生产要配**泛域名解析 + 泛域名证书**(*.example.com)。
|
||||
tenant-domain:
|
||||
enabled: ${XT_TENANT_DOMAIN_ENABLED:true}
|
||||
base: ${XT_TENANT_DOMAIN_BASE:localhost}
|
||||
scheme: ${XT_TENANT_DOMAIN_SCHEME:http}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
|
||||
Reference in New Issue
Block a user