feat: phase1 UI prep — P1 安全(XFF防护), P2 规模化(平台索引+region保护), P3 文档补全

P1 安全: WebUtils.getClientIp 改为 remoteAddr 优先 + 可信代理 CIDR 配置
P2 规模化: DISTINCT ON 查询补匹配排序的部分索引 (migrate-k113)
P2 一致性: syncCountryByIp regionCode 同语言/时区/币种加保护,不覆盖用户自定义
P3 文档: api-phase1.md 新增 platform 参数说明, IpRegionService 新增集成测试 + VERSION
新增: CountryProfile 52 国映射 + IpRegionService 离线 ip2region 解析
平台筛选: app-users/favorites/likes/watch-history 按最近一次会话 ANDROID/IOS 筛选
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-27 15:45:15 +08:00
1 parent a41ab18636
commit 272aa62434
34 files changed
+748 -77

No files matched your search

@@ -22,6 +22,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
public class AccessDeniedRecorder {
private final ObjectProvider<OperationLogSink> sinkProvider;
private final ObjectProvider<WebUtils> webUtilsProvider;
public void record(HttpServletRequest request) {
OperationLogSink sink = sinkProvider.getIfAvailable();
@@ -47,10 +48,16 @@ public class AccessDeniedRecorder {
.status("FAIL")
.errorMsg("无权限")
.durationMs(0)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.ip(request == null ? "" : clientIp(request))
.build());
}
/** 优先用 Spring 注入的 WebUtils(尊重可信代理配置),拿不到回退静态方法。 */
private String clientIp(HttpServletRequest request) {
WebUtils webUtils = webUtilsProvider.getIfAvailable();
return webUtils != null ? webUtils.clientIp(request) : WebUtils.getClientIp(request);
}
/** 从当前线程取出请求,供全局异常处理使用。 */
public void recordCurrentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
@@ -43,6 +43,7 @@ public class OperationLogAspect {
private final ObjectMapper objectMapper;
private final ObjectProvider<OperationLogSink> sinkProvider;
private final ObjectProvider<WebUtils> webUtilsProvider;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
@@ -86,10 +87,16 @@ public class OperationLogAspect {
.status(error == null ? "SUCCESS" : "FAIL")
.errorMsg(error == null ? "" : truncate(error.getMessage()))
.durationMs(durationMs)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.ip(request == null ? "" : clientIp(request))
.build());
}
/** 优先用 Spring 注入的 WebUtils(尊重可信代理配置),拿不到回退静态方法。 */
private String clientIp(HttpServletRequest request) {
WebUtils webUtils = webUtilsProvider.getIfAvailable();
return webUtils != null ? webUtils.clientIp(request) : WebUtils.getClientIp(request);
}
/** 把方法入参整理成 JSON,文件只记名称和大小。 */
private String body(ProceedingJoinPoint point) {
Object[] args = point.getArgs();
@@ -62,6 +62,8 @@ public final class ImpersonationGuard {
public static String clientIp() {
HttpServletRequest request = currentRequest();
// 这里走静态 getClientIp——等价于 trusted-proxies 为空时的逻辑(只用 remoteAddr,不解析 XFF)。
// 这个类是纯静态工具(无 Spring 注入),且只在 Creator 代看场景用,安全影响低。
return request == null ? "" : com.fruitshorts.framework.web.WebUtils.getClientIp(request);
}
@@ -3,25 +3,146 @@
package com.fruitshorts.framework.web;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
public final class WebUtils {
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
/**
* 取真实客户端 IP。
*
* <p>安全策略:默认信任 {@link HttpServletRequest#getRemoteAddr()} 本身,只有当
* 它属于配置的可信代理 CIDR 时才解析 X-Forwarded-For / X-Real-IP 等头。
* 这样客户端伪造的 XFF 不会生效,只有经 Nginx / K8s ingress 等前置代理转发的请求
* 才能带上可信的 XFF 链。
*
* <p>生产建议:同时开启 Tomcat RemoteIpValve(prod profile 已配置),让
* {@code getRemoteAddr()} 在经过代理后直接就是客户端真实 IP。此时可信代理列表
* 配成空即可,Valve 已经帮我们做了 XFF 解析。
*/
@Slf4j
@Component
public class WebUtils {
private static final String UNKNOWN = "unknown";
private static final String[] PROXY_HEADERS = {"X-Forwarded-For", "X-Real-IP",
"Proxy-Client-IP", "WL-Proxy-Client-IP"};
private WebUtils() {
/** 可信代理 CIDR 列表,逗号分隔;空表示永不解析 XFF,直接用 remoteAddr。 */
@Value("${fruitshorts.web.trusted-proxies:}")
private String trustedProxies = "";
/** 纯静态调用路径(不依赖 Spring),供测试或无 IoC 场景使用。 */
public static String getClientIp(HttpServletRequest request) {
return resolve(request, List.of());
}
/** 取真实客户端 IP,兼容 Nginx 反向代理。 */
public static String getClientIp(HttpServletRequest request) {
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
for (String header : headers) {
String value = request.getHeader(header);
if (StringUtils.hasText(value) && !UNKNOWN.equalsIgnoreCase(value)) {
int comma = value.indexOf(',');
return comma > 0 ? value.substring(0, comma).trim() : value.trim();
/** Spring 注入版:自动按配置解析 XFF。 */
public String clientIp(HttpServletRequest request) {
List<String> cidrs = StringUtils.hasText(trustedProxies)
? Arrays.asList(trustedProxies.split(","))
: List.of();
return resolve(request, cidrs);
}
private static String resolve(HttpServletRequest request, List<String> trustedCidrs) {
String remoteAddr = request.getRemoteAddr();
if (isTrustedProxy(remoteAddr, trustedCidrs)) {
String fromHeader = readProxyHeader(request);
if (StringUtils.hasText(fromHeader)) {
return fromHeader;
}
}
return request.getRemoteAddr();
return remoteAddr;
}
private static String readProxyHeader(HttpServletRequest request) {
for (String header : PROXY_HEADERS) {
String value = request.getHeader(header);
if (StringUtils.hasText(value) && !UNKNOWN.equalsIgnoreCase(value)) {
// XFF 链格式:client, proxy1, proxy2 —— 最左侧是原始客户端 IP
String[] parts = value.split(",");
return parts[0].trim();
}
}
return "";
}
/**
* 判断 remoteAddr 是否属于可信代理。
*
* <p>CIDR 简化实现:只支持单个 IP 或 "/24" 这类前缀掩码,不支持复杂区间。
* 生产反代一般就是 127.0.0.1、10.0.0.0/8 这类,够用。
*/
static boolean isTrustedProxy(String remoteAddr, List<String> trustedCidrs) {
if (!StringUtils.hasText(remoteAddr) || trustedCidrs.isEmpty()) {
return false;
}
try {
InetAddress remote = InetAddress.getByName(remoteAddr.trim());
for (String cidr : trustedCidrs) {
if (cidrMatches(remote, cidr.trim())) {
return true;
}
}
} catch (UnknownHostException ex) {
log.debug("无法解析 remoteAddr {} 为 IP,跳过 XFF 信任判定", remoteAddr);
}
return false;
}
private static boolean cidrMatches(InetAddress target, String cidr) {
if (!cidr.contains("/")) {
// 单 IP 精确匹配
InetAddress trusted;
try {
trusted = InetAddress.getByName(cidr);
} catch (UnknownHostException ex) {
return false;
}
return trusted.equals(target);
}
String[] parts = cidr.split("/", 2);
String netPart = parts[0];
int prefixLen;
try {
prefixLen = Integer.parseInt(parts[1]);
} catch (NumberFormatException ex) {
return false;
}
InetAddress net;
try {
net = InetAddress.getByName(netPart);
} catch (UnknownHostException ex) {
return false;
}
byte[] targetBytes = target.getAddress();
byte[] netBytes = net.getAddress();
if (targetBytes.length != netBytes.length) {
return false;
}
int fullBytes = prefixLen / 8;
int remainingBits = prefixLen % 8;
for (int i = 0; i < fullBytes; i++) {
if (targetBytes[i] != netBytes[i]) {
return false;
}
}
if (remainingBits > 0 && fullBytes < targetBytes.length) {
int mask = (0xFF << (8 - remainingBits)) & 0xFF;
if ((targetBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) {
return false;
}
}
return true;
}
/** Spring 直接实例化;保留 package-private 方便单测 new。 */
WebUtils() {
}
}