SaaS 开放平台对接规范 / 第三方接口统一管理
1. SaaS 开放平台概述
1.1 开放平台架构
SaaS 开放平台作为生态系统的核心枢纽,为第三方开发者提供标准化的接入能力和管理界面。整体架构由以下核心组件构成:
| 组件 | 职责 | 技术选型参考 |
|---|---|---|
| 开发者门户 | 开发者注册、应用管理、文档查阅、调试工具 | Vue/React + VitePress |
| API 网关 | 统一入口、路由转发、限流熔断、协议转换 | Spring Cloud Gateway / Kong |
| 认证中心 | AK/SK 签发、JWT 颁发、OAuth2 授权 | Spring Authorization Server / Keycloak |
| 沙箱环境 | 隔离的测试环境、模拟数据、Mock 接口 | 独立部署 + Mock Server |
| 计量计费 | 调用次数统计、套餐管理、用量预警 | Prometheus + 自定义计费服务 |
| 开发者文档 | OpenAPI 规范、SDK 下载、示例代码 | VitePress / Swagger UI |
架构分层示意图:
text
+---------------------------------------------------------------------+
| 第三方开发者/合作伙伴 |
+---------------------------------------------------------------------+
|
HTTPS / HTTP
|
+---------------------------------------------------------------------+
| API 网关 (统一入口层) |
| 路由转发 / 限流熔断 / IP 白名单 / 请求签名校验 / 日志采集 |
+---------------------------------------------------------------------+
| | |
v v v
+----------------+ +------------------+ +------------------+
| 认证中心 | | 核心业务服务 | | 计量计费服务 |
| AK/SK 管理 | | 数据开放 API | | 调用量统计 |
| JWT 签发 | | 业务开放 API | | 套餐管理 |
| OAuth2 授权 | | 能力开放 API | | 账单生成 |
+----------------+ +------------------+ +------------------+
| | |
v v v
+---------------------------------------------------------------------+
| 后端基础设施层 |
| 数据库 / Redis / MQ / 配置中心 / 注册中心 / 日志中心 |
+---------------------------------------------------------------------+1.2 开放能力分类
| 能力类型 | 说明 | 示例 |
|---|---|---|
| 数据开放 | 开放平台积累的业务数据,供开发者查询和分析 | 订单数据、用户画像、商品信息 |
| 业务开放 | 将核心业务流程封装为 API,支持开发者调用 | 下单、支付、退款、物流查询 |
| 能力开放 | 开放平台的技术能力和算法能力 | 智能推荐、图像识别、风控评分 |
| 硬件开放 | 开放 IoT 设备、打印设备等硬件能力 | 云打印、智能硬件控制 |
1.3 开发者生态
1.3.1 应用市场
应用市场是开发者发布应用、商家选购应用的平台,核心流程如下:
text
开发者提交应用
|
v
应用审核 (自动化 + 人工)
|
v
审核通过 -> 上架应用市场
|
v
商家选购 -> 授权应用 -> 开通服务
|
v
应用运行 (调用开放平台 API)
|
v
违规/到期 -> 下架/停用1.3.2 应用审核
应用审核包含以下检查项:
| 审核项 | 检查内容 | 审核方式 |
|---|---|---|
| 应用信息 | 应用名称、图标、描述完整性 | 自动 |
| 权限申请 | 申请的 API 权限是否合理 | 自动 + 人工 |
| 安全审查 | 回调地址白名单、IP 白名单 | 自动 |
| 功能测试 | 应用功能是否符合描述 | 人工 |
| 合规审查 | 是否涉及违规内容、数据合规 | 人工 |
1.3.3 应用上架/下架
java
/**
* 应用状态机
*/
public enum AppStatus {
DRAFT(0, "草稿"),
PENDING_REVIEW(1, "待审核"),
REVIEWING(2, "审核中"),
REJECTED(3, "审核驳回"),
APPROVED(4, "已通过"),
PUBLISHED(5, "已上架"),
OFF_SHELF(6, "已下架"),
DISABLED(7, "已停用");
private final int code;
private final String description;
}
/**
* 应用审核服务
*/
@Service
public class AppReviewService {
@Autowired
private AppMapper appMapper;
@Transactional
public void submitForReview(Long appId) {
App app = appMapper.selectById(appId);
if (app.getStatus() != AppStatus.DRAFT && app.getStatus() != AppStatus.REJECTED) {
throw new BusinessException("当前状态不允许提交审核");
}
app.setStatus(AppStatus.PENDING_REVIEW);
app.setSubmitTime(LocalDateTime.now());
appMapper.updateById(app);
// 发送审核通知
notifyReviewer(app);
}
@Transactional
public void approve(Long appId, String reviewer) {
App app = appMapper.selectById(appId);
app.setStatus(AppStatus.APPROVED);
app.setReviewer(reviewer);
app.setReviewTime(LocalDateTime.now());
appMapper.updateById(app);
}
@Transactional
public void publish(Long appId) {
App app = appMapper.selectById(appId);
app.setStatus(AppStatus.PUBLISHED);
app.setPublishTime(LocalDateTime.now());
appMapper.updateById(app);
}
@Transactional
public void offShelf(Long appId, String reason) {
App app = appMapper.selectById(appId);
app.setStatus(AppStatus.OFF_SHELF);
app.setOffShelfReason(reason);
app.setOffShelfTime(LocalDateTime.now());
appMapper.updateById(app);
// 通知开发者
notifyDeveloper(app.getDeveloperId(), "您的应用已下架,原因: " + reason);
}
}2. API 统一管理
2.1 API 注册与版本管理
2.1.1 API 注册
每个开放 API 需要在 API 管理平台注册元数据,包括接口路径、请求方式、参数定义、权限等级等。
java
/**
* API 定义实体
*/
@Data
@TableName("open_api_definition")
public class ApiDefinition {
@TableId(type = IdType.AUTO)
private Long id;
private String apiName; // API 名称
private String apiPath; // 请求路径,如 /v1/orders
private String httpMethod; // GET / POST / PUT / DELETE
private String version; // 版本号,如 v1、v2
private String category; // 分类,如 order、user、payment
private String description; // 接口描述
private Integer authType; // 鉴权方式:1-AK/SK, 2-JWT, 3-OAuth2
private Integer rateLimitQps; // 默认 QPS 限制
private Integer timeoutMs; // 超时时间(毫秒)
private Boolean requireSsl; // 是否强制 HTTPS
private String status; // ENABLED / DEPRECATED / DISABLED
private String requestSchema; // 请求参数 JSON Schema
private String responseSchema; // 响应参数 JSON Schema
private String errorCodes; // 错误码定义 JSON
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}2.1.2 版本策略
| 版本类型 | 格式 | 兼容性 | 生命周期 |
|---|---|---|---|
| 主版本 | v1、v2 | 不兼容,允许 Breaking Change | 长期维护 |
| 次版本 | v1.0、v1.1 | 向后兼容,新增字段或接口 | 6 个月 |
| 修订版本 | v1.0.0、v1.0.1 | 完全兼容,仅修复 Bug | 3 个月 |
版本管理策略:
text
v1 发布 -> v1.1 发布(兼容 v1)-> v2 发布(不兼容 v1)
|
v1 标记废弃 (deprecated)
|
v1 进入维护期(仅修复安全问题)
|
v1 下架 ( sunset ),返回 410 Gonejava
/**
* API 版本路由
* 通过请求路径中的版本号分发到不同的处理器
*/
@RestController
public class OrderApiController {
/**
* v1 版本 - 旧版订单查询
*/
@GetMapping("/v1/orders/{id}")
public OrderV1Response getOrderV1(@PathVariable Long id) {
Order order = orderService.getById(id);
return OrderV1Response.from(order); // 旧版响应格式
}
/**
* v2 版本 - 新版订单查询,优化了响应结构
*/
@GetMapping("/v2/orders/{id}")
public OrderV2Response getOrderV2(@PathVariable Long id) {
Order order = orderService.getById(id);
return OrderV2Response.from(order); // 新版响应格式,增加字段
}
}
/**
* 版本废弃通知
*/
@Scheduled(cron = "0 0 0 * * ?") // 每日执行
public void checkDeprecatedApis() {
List<ApiDefinition> deprecatedApis = apiDefinitionMapper.selectList(
new LambdaQueryWrapper<ApiDefinition>()
.eq(ApiDefinition::getStatus, "DEPRECATED")
.lt(ApiDefinition::getUpdatedAt, LocalDateTime.now().minusMonths(6)));
for (ApiDefinition api : deprecatedApis) {
// 通知已订阅的应用开发者
List<AppSubscription> subscribers = subscriptionMapper.selectByApiId(api.getId());
for (AppSubscription sub : subscribers) {
notificationService.notifyDeveloper(
sub.getDeveloperId(),
"您使用的 API [" + api.getApiName() + "] 将于 30 天后下架,请尽快迁移");
}
// 标记为即将下架
api.setStatus("SUNSETTING");
apiDefinitionMapper.updateById(api);
}
}2.2 API 文档规范
2.2.1 OpenAPI 3.0 标准
所有开放 API 必须遵循 OpenAPI 3.0 规范定义,通过注解自动生成文档。
java
@RestController
@RequestMapping("/v1/orders")
@Tag(name = "订单管理", description = "订单创建、查询、退款等接口")
public class OrderOpenApiController {
@PostMapping
@Operation(summary = "创建订单", description = "第三方开发者通过此接口创建交易订单")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "创建成功",
content = @Content(schema = @Schema(implementation = CreateOrderResponse.class))),
@ApiResponse(responseCode = "400", description = "参数错误",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "401", description = "鉴权失败"),
@ApiResponse(responseCode = "429", description = "请求频率超限")
})
public Response<CreateOrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request,
@RequestHeader("X-Access-Key") String accessKey,
@RequestHeader("X-Signature") String signature,
@RequestHeader("X-Timestamp") Long timestamp) {
// 1. 签名校验
signatureService.verify(accessKey, timestamp, signature, request);
// 2. 权限校验
permissionService.checkApiPermission(accessKey, "order:create");
// 3. 业务处理
Order order = orderService.create(request.toOrder());
return Response.success(CreateOrderResponse.from(order));
}
}对应的 OpenAPI 规范片段(自动生成):
yaml
openapi: 3.0.0
info:
title: SaaS 开放平台 API
version: v1
description: SaaS 开放平台提供订单、支付、物流等标准化接口
servers:
- url: https://api.example.com
description: 生产环境
- url: https://sandbox-api.example.com
description: 沙箱环境
paths:
/v1/orders:
post:
summary: 创建订单
tags:
- 订单管理
parameters:
- name: X-Access-Key
in: header
required: true
schema:
type: string
- name: X-Signature
in: header
required: true
schema:
type: string
- name: X-Timestamp
in: header
required: true
schema:
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'200':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderResponse'2.2.2 在线调试
通过 Swagger UI 或 Knife4j 提供在线调试功能,开发者无需编写代码即可测试 API。
yaml
# application.yml - Knife4j 配置
knife4j:
enable: true
setting:
enableSwaggerModels: true
enableDocumentManage: true
enableReloadCacheParameter: true
enableVersion: true
enableRequestCache: true
enableFooter: false2.2.3 SDK 生成
使用 OpenAPI Generator 自动生成多语言 SDK:
xml
<!-- pom.xml - Maven 插件配置 -->
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.2.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi.yaml</inputSpec>
<generatorName>java</generatorName>
<output>${project.build.directory}/generated-sdk</output>
<packageName>com.example.openapi.sdk</packageName>
<library>okhttp-gson</library>
</configuration>
</execution>
</executions>
</plugin>bash
# 使用 CLI 生成 SDK
# Java
openapi-generator-cli generate -i openapi.yaml -g java -o sdk/java
# Python
openapi-generator-cli generate -i openapi.yaml -g python -o sdk/python
# JavaScript
openapi-generator-cli generate -i openapi.yaml -g javascript -o sdk/javascript
# Go
openapi-generator-cli generate -i openapi.yaml -g go -o sdk/go2.3 API 路由与负载均衡
API 网关根据请求路径、Header 等条件将请求路由到后端服务。
yaml
# Spring Cloud Gateway 路由配置
spring:
cloud:
gateway:
routes:
# 开放平台 API 路由
- id: openapi-v1-orders
uri: lb://order-service
predicates:
- Path=/v1/orders/**
filters:
- StripPrefix=0
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
- id: openapi-v1-payments
uri: lb://payment-service
predicates:
- Path=/v1/payments/**
filters:
- StripPrefix=0
- id: openapi-v2-orders
uri: lb://order-service-v2
predicates:
- Path=/v2/orders/**
filters:
- StripPrefix=02.4 API 限流降级
2.4.1 限流维度
| 限流维度 | 说明 | 典型配置 |
|---|---|---|
| 应用级 | 每个应用的整体调用量限制 | 免费版 1000 QPS |
| 用户级 | 每个开发者的调用量限制 | 单个开发者 5000 QPS |
| API 级 | 单个 API 的调用量限制 | 下单接口 200 QPS |
| IP 级 | 来源 IP 的调用量限制 | 单 IP 1000 QPS |
2.4.2 Sentinel 限流实现
java
/**
* API 限流切面
*/
@Aspect
@Component
public class OpenApiRateLimitAspect {
private static final Logger log = LoggerFactory.getLogger(OpenApiRateLimitAspect.class);
@Around("@annotation(rateLimit)")
public Object doRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
// 获取当前请求的上下文
RequestContext context = RequestContextHolder.get();
// 应用级限流
String appResource = "app:" + context.getAppId();
try (Entry entry = SphU.entry(appResource, EntryType.IN)) {
// API 级限流
String apiResource = "api:" + context.getApiPath();
try (Entry apiEntry = SphU.entry(apiResource, EntryType.IN)) {
return pjp.proceed();
} catch (BlockException e) {
log.warn("API 限流触发: appId={}, api={}", context.getAppId(), context.getApiPath());
throw new RateLimitException("API 调用频率超限,请稍后重试");
}
} catch (BlockException e) {
log.warn("应用级限流触发: appId={}", context.getAppId());
throw new RateLimitException("应用调用量已达上限");
}
}
}
/**
* 动态限流规则配置
* 根据套餐绑定不同的限流策略
*/
@Service
public class RateLimitRuleService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 为应用加载限流规则
*/
public void loadRateLimitRules(String appId, String planCode) {
// 根据套餐获取限流配置
PlanConfig planConfig = getPlanConfig(planCode);
// 应用级规则
FlowRule appRule = new FlowRule();
appRule.setResource("app:" + appId);
appRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
appRule.setCount(planConfig.getAppQps());
appRule.setLimitApp("default");
FlowRuleManager.loadRules(List.of(appRule));
// API 级规则存储在 Redis 中,由网关加载
String ruleKey = "ratelimit:rules:" + appId;
redisTemplate.opsForValue().set(ruleKey, JSON.toJSONString(planConfig.getApiRules()));
}
private PlanConfig getPlanConfig(String planCode) {
switch (planCode) {
case "free":
return new PlanConfig(100, 50, 10);
case "professional":
return new PlanConfig(1000, 500, 100);
case "enterprise":
return new PlanConfig(10000, 5000, 1000);
default:
throw new BusinessException("未知套餐: " + planCode);
}
}
}
@Data
@AllArgsConstructor
public class PlanConfig {
private int appQps; // 应用级 QPS
private int userQps; // 用户级 QPS
private int defaultApiQps; // 默认单 API QPS
}2.5 API 鉴权
2.5.1 AK/SK HMAC-SHA256
AK/SK 鉴权是最常见的开放平台鉴权方式,Access Key 标识身份,Secret Key 用于签名。
签名流程:
text
1. 开发者使用 AK + SK 对请求参数进行 HMAC-SHA256 签名
2. 请求时携带 AK、Signature、Timestamp 等 Header
3. 服务端根据 AK 查找对应的 SK,重新计算签名并比对签名算法:
java
/**
* 服务端签名校验
*/
@Component
public class SignatureService {
@Autowired
private CredentialManager credentialManager;
private static final long SIGN_TIMEOUT_SECONDS = 300; // 5 分钟有效
/**
* 验证请求签名
*/
public void verify(String accessKey, Long timestamp, String signature, Object body) {
// 1. 校验时间戳防止重放攻击
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > SIGN_TIMEOUT_SECONDS) {
throw new AuthException("请求已过期");
}
// 2. 查询 AK 对应的 SK
AppCredential credential = credentialManager.getCredential(accessKey);
if (credential == null || credential.getStatus() != 1) {
throw new AuthException("无效的 AccessKey");
}
// 3. 重新计算签名
String expectedSign = calculateSignature(
credential.getAccessSecret(), timestamp, body);
// 4. 比对签名(使用 MessageDigest.isEqual 防止时序攻击)
if !MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expectedSign.getBytes(StandardCharsets.UTF_8)) {
throw new AuthException("签名验证失败");
}
}
/**
* 签名计算
* signature = HMAC-SHA256(secret, timestamp + "|" + body)
*/
public String calculateSignature(String secret, Long timestamp, Object body) {
String bodyStr = body == null ? "" : JSON.toJSONString(body);
String data = timestamp + "|" + bodyStr;
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] signBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signBytes);
} catch (Exception e) {
throw new AuthException("签名计算失败", e);
}
}
/**
* 校验请求中的 Nonce 防止重放
*/
public boolean checkNonce(String nonce) {
String key = "nonce:" + nonce;
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1",
Duration.ofSeconds(SIGN_TIMEOUT_SECONDS));
return Boolean.TRUE.equals(success);
}
}客户端签名示例:
java
/**
* 客户端签名工具
*/
public class OpenApiClient {
private final String accessKey;
private final String accessSecret;
private final String baseUrl;
private final OkHttpClient httpClient;
public OpenApiClient(String accessKey, String accessSecret, String baseUrl) {
this.accessKey = accessKey;
this.accessSecret = accessSecret;
this.baseUrl = baseUrl;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
/**
* 发送签名请求
*/
public String call(String method, String path, Object body) {
long timestamp = System.currentTimeMillis() / 1000;
String bodyJson = body == null ? "" : JSON.toJSONString(body);
String signature = sign(timestamp, bodyJson);
Request.Builder builder = new Request.Builder()
.url(baseUrl + path)
.addHeader("X-Access-Key", accessKey)
.addHeader("X-Signature", signature)
.addHeader("X-Timestamp", String.valueOf(timestamp))
.addHeader("Content-Type", "application/json");
if (body != null) {
builder.method(method, RequestBody.create(bodyJson, MediaType.parse("application/json")));
} else {
builder.method(method, null);
}
try (Response response = httpClient.newCall(builder.build()).execute()) {
if (!response.isSuccessful()) {
String errorBody = response.body() != null ? response.body().string() : "";
throw new ApiException(response.code(), "API 调用失败: " + errorBody);
}
return response.body() != null ? response.body().string() : null;
} catch (IOException e) {
throw new ApiException("网络异常", e);
}
}
private String sign(long timestamp, String body) {
String data = timestamp + "|" + body;
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
accessSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] signBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signBytes);
} catch (Exception e) {
throw new RuntimeException("签名失败", e);
}
}
}2.5.2 JWT 鉴权
适用于服务端到服务端的认证场景。
java
/**
* JWT Token 签发
*/
@Service
public class JwtTokenService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration-hours}")
private int expirationHours;
/**
* 为应用生成 JWT Token
*/
public String generateToken(AppCredential credential) {
long now = System.currentTimeMillis();
Date expiry = new Date(now + TimeUnit.HOURS.toMillis(expirationHours));
return Jwts.builder()
.setIssuer("SaaS-Platform")
.setSubject(credential.getAppId())
.claim("app_name", credential.getAppName())
.claim("developer_id", credential.getDeveloperId())
.claim("scopes", credential.getApiScopes())
.setIssuedAt(new Date(now))
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, secret.getBytes())
.compact();
}
/**
* 验证 JWT Token
*/
public Claims verifyToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret.getBytes())
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new AuthException("Token 已过期");
} catch (JwtException e) {
throw new AuthException("无效的 Token");
}
}
/**
* 从 Token 中提取应用信息
*/
public TokenInfo parseToken(String token) {
Claims claims = verifyToken(token);
TokenInfo info = new TokenInfo();
info.setAppId(claims.getSubject());
info.setAppName((String) claims.get("app_name"));
info.setDeveloperId((String) claims.get("developer_id"));
info.setScopes((List<String>) claims.get("scopes"));
return info;
}
}2.5.3 OAuth2 授权
适用于需要获取用户授权的场景(如代用户操作)。
java
/**
* OAuth2 授权配置
*/
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private AppClientDetailsService clientDetailsService;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)
.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter());
}
@Bean
public TokenStore tokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(secret);
return converter;
}
}
/**
* OAuth2 应用注册
*/
@Service
public class OAuth2ClientService {
/**
* 注册 OAuth2 应用
*/
public void registerClient(OAuth2ClientRequest request) {
OAuth2Client client = new OAuth2Client();
client.setClientId(generateClientId());
client.setClientSecret(generateClientSecret());
client.setClientName(request.getClientName());
client.setRedirectUris(String.join(",", request.getRedirectUris()));
client.setGrantTypes("authorization_code,refresh_token");
client.setScopes(String.join(",", request.getScopes()));
client.setAccessTokenValidity(7200); // 2 小时
client.setRefreshTokenValidity(2592000); // 30 天
client.setStatus(1);
clientMapper.insert(client);
}
}3. SaaS 多租户架构
3.1 租户模型
3.1.1 隔离模式对比
| 隔离模式 | 数据隔离性 | 成本 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 高 | 高 | 大型企业、金融合规要求高 |
| 共享数据库 + Schema 隔离 | 较强 | 中 | 中 | 中型客户,需要一定程度隔离 |
| 共享数据库 + 共享 Schema | 一般 | 低 | 低 | 小型客户,成本敏感 |
| 混合模式 | 灵活 | 按需 | 高 | 同时服务大中小客户 |
3.1.2 混合模式实现
java
/**
* 租户信息模型
*/
@Data
@TableName("saas_tenant")
public class Tenant {
@TableId
private String tenantId; // 租户 ID (唯一标识)
private String tenantName; // 租户名称
private String contactName; // 联系人
private String contactPhone; // 联系电话
private String domain; // 独立域名
private String planCode; // 套餐编码: free/professional/enterprise
private Integer isolationMode; // 隔离模式: 1-独立库, 2-Schema 隔离, 3-共享表
private String datasourceKey; // 数据源标识
private String dbName; // 数据库名 (独立库模式)
private String schemaName; // Schema 名 (Schema 隔离模式)
private Integer status; // 0-创建中, 1-运行中, 2-已冻结, 3-已删除
private LocalDateTime expireAt; // 过期时间
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
/**
* 租户套餐信息
*/
@Data
@TableName("saas_tenant_plan")
public class TenantPlan {
@TableId
private Long id;
private String tenantId;
private String planCode; // 套餐编码
private String planName; // 套餐名称
private Integer maxUsers; // 最大用户数
private Integer maxStorageGb; // 最大存储 (GB)
private Integer maxApiQps; // 最大 API QPS
private Integer maxApps; // 最大应用数
private LocalDateTime startAt; // 生效时间
private LocalDateTime endAt; // 到期时间
private Integer autoRenew; // 自动续费
}3.2 租户路由
3.2.1 多数据源动态切换
java
/**
* 动态数据源路由器
* 根据 TenantContext 中保存的 tenantId,动态切换到对应的数据源
*/
@Component
public class DynamicDataSourceRouter extends AbstractRoutingDataSource {
@Autowired
private DataSourceConfigManager dataSourceConfigManager;
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenantId();
}
/**
* 根据租户 ID 获取数据源
*/
public DataSource getDataSource(String tenantId) {
// 先从缓存获取
DataSource ds = dataSourceCache.get(tenantId);
if (ds != null) {
return ds;
}
// 查询租户配置,创建数据源
Tenant tenant = tenantMapper.selectById(tenantId);
if (tenant == null) {
throw new TenantNotFoundException("租户不存在: " + tenantId);
}
ds = createDataSource(tenant);
dataSourceCache.put(tenantId, ds);
return ds;
}
private DataSource createDataSource(Tenant tenant) {
// 根据不同模式创建数据源
DataSourceConfig config = dataSourceConfigManager.getBaseConfig();
switch (tenant.getIsolationMode()) {
case 1: // 独立数据库
config.setUrl(buildJdbcUrl(tenant.getDbName()));
break;
case 2: // Schema 隔离
config.setUrl(buildJdbcUrlWithSchema(tenant.getSchemaName()));
break;
case 3: // 共享表
config.setUrl(buildJdbcUrl("shared_db"));
break;
}
return DataSourceBuilder.create()
.url(config.getUrl())
.username(config.getUsername())
.password(config.getPassword())
.driverClassName(config.getDriverClassName())
.build();
}
}3.2.2 TenantContext 传递
java
/**
* 租户上下文 - 使用 ThreadLocal 存储当前请求的租户信息
*/
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
private static final ThreadLocal<Tenant> CURRENT_TENANT_INFO = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getCurrentTenantId() {
return CURRENT_TENANT.get();
}
public static void setTenantInfo(Tenant tenant) {
CURRENT_TENANT_INFO.set(tenant);
}
public static Tenant getCurrentTenantInfo() {
return CURRENT_TENANT_INFO.get();
}
public static void clear() {
CURRENT_TENANT.remove();
CURRENT_TENANT_INFO.remove();
}
}
/**
* 租户拦截器 - 从请求中解析租户信息,写入 TenantContext
*/
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Autowired
private TenantService tenantService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 从域名或 Header 中获取租户 ID
String tenantId = resolveTenantId(request);
if (tenantId == null) {
throw new TenantNotFoundException("无法识别租户");
}
// 查询租户信息
Tenant tenant = tenantService.getTenant(tenantId);
if (tenant == null || tenant.getStatus() != 1) {
throw new TenantNotFoundException("租户不可用");
}
// 设置租户上下文
TenantContext.setTenantId(tenantId);
TenantContext.setTenantInfo(tenant);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
}
/**
* 从请求中解析租户 ID
* 策略: Header > 子域名 > URL 路径
*/
private String resolveTenantId(HttpServletRequest request) {
// 1. 从 Header 获取
String tenantId = request.getHeader("X-Tenant-Id");
if (tenantId != null) {
return tenantId;
}
// 2. 从子域名获取 (tenant.example.com)
String host = request.getHeader("Host");
if (host != null && host.contains(".")) {
String subdomain = host.substring(0, host.indexOf('.'));
if (!"www".equals(subdomain) && !"api".equals(subdomain)) {
return subdomain;
}
}
// 3. 从 JWT Token 中获取
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtTokenService.verifyToken(token);
return claims.get("tenant_id", String.class);
} catch (Exception e) {
// ignore
}
}
return null;
}
}3.3 租户初始化
租户初始化流程:
text
创建租户 -> 选择套餐 -> 支付 -> 初始化数据库 -> 配置域名 -> 完成java
/**
* 租户初始化服务
*/
@Service
public class TenantInitializationService {
@Autowired
private TenantMapper tenantMapper;
@Autowired
private DynamicDataSourceRouter dataSourceRouter;
@Autowired
private Flyway flyway;
@Transactional
public Tenant createTenant(CreateTenantRequest request) {
// 1. 创建租户记录
Tenant tenant = new Tenant();
tenant.setTenantId(generateTenantId());
tenant.setTenantName(request.getTenantName());
tenant.setContactName(request.getContactName());
tenant.setContactPhone(request.getContactPhone());
tenant.setPlanCode(request.getPlanCode());
tenant.setIsolationMode(determineIsolationMode(request.getPlanCode()));
tenant.setStatus(0); // 创建中
tenant.setExpireAt(LocalDateTime.now().plusMonths(1)); // 试用期
tenantMapper.insert(tenant);
// 2. 异步初始化租户资源
CompletableFuture.runAsync(() -> initializeTenantResources(tenant))
.exceptionally(ex -> {
log.error("租户初始化失败: tenantId={}", tenant.getTenantId(), ex);
tenant.setStatus(3); // 标记为失败
tenantMapper.updateById(tenant);
return null;
});
return tenant;
}
/**
* 初始化租户资源
*/
public void initializeTenantResources(Tenant tenant) {
try {
// 2.1 初始化数据库
initDatabase(tenant);
// 2.2 初始化 Redis 命名空间
initRedisNamespace(tenant);
// 2.3 初始化存储空间
initStorage(tenant);
// 2.4 配置域名 (可选)
if (tenant.getDomain() != null) {
configureDomain(tenant);
}
// 2.5 开通套餐
activatePlan(tenant);
// 2.6 标记租户为运行状态
tenant.setStatus(1);
tenantMapper.updateById(tenant);
log.info("租户初始化完成: tenantId={}", tenant.getTenantId());
} catch (Exception e) {
log.error("租户初始化异常: tenantId={}", tenant.getTenantId(), e);
throw e;
}
}
private void initDatabase(Tenant tenant) {
if (tenant.getIsolationMode() == 1) {
// 独立数据库模式: 创建新数据库
String dbName = "saas_tenant_" + tenant.getTenantId().replace("-", "_");
dataSourceConfigManager.createDatabase(dbName);
tenant.setDbName(dbName);
// 执行 Flyway 迁移
DataSource ds = dataSourceRouter.getDataSource(tenant.getTenantId());
Flyway.configure()
.dataSource(ds)
.locations("db/migration/tenant")
.baselineOnMigrate(true)
.load()
.migrate();
} else if (tenant.getIsolationMode() == 2) {
// Schema 隔离模式: 创建 Schema
String schemaName = "tenant_" + tenant.getTenantId().replace("-", "_");
dataSourceConfigManager.createSchema(schemaName);
tenant.setSchemaName(schemaName);
}
tenantMapper.updateById(tenant);
}
private void initRedisNamespace(Tenant tenant) {
// 为租户初始化 Redis key 前缀
String prefix = "tenant:" + tenant.getTenantId() + ":";
redisTemplate.opsForValue().set(prefix + "init", "1");
}
private void initStorage(Tenant tenant) {
// 初始化 OSS 存储目录
String bucketName = "saas-tenant-" + tenant.getTenantId();
ossClient.createBucket(bucketName);
}
private void activatePlan(Tenant tenant) {
TenantPlan plan = new TenantPlan();
plan.setTenantId(tenant.getTenantId());
plan.setPlanCode(tenant.getPlanCode());
plan.setPlanName(getPlanName(tenant.getPlanCode()));
plan.setStartAt(LocalDateTime.now());
plan.setEndAt(tenant.getExpireAt());
plan.setAutoRenew(0);
tenantPlanMapper.insert(plan);
}
private int determineIsolationMode(String planCode) {
switch (planCode) {
case "enterprise":
return 1; // 独立数据库
case "professional":
return 2; // Schema 隔离
case "free":
default:
return 3; // 共享表
}
}
}3.4 租户数据隔离实现
3.4.1 MyBatis Plus 拦截器
java
/**
* MyBatis Plus 租户拦截器
* 自动在 SQL 上追加 tenant_id 过滤条件
*/
@Component
public class TenantSqlInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
String tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null) {
return;
}
// 获取原始 SQL
String sql = boundSql.getSql();
// 判断是否需要追加租户过滤 (通过表名配置)
if (shouldAddTenantFilter(sql)) {
String newSql = addTenantFilter(sql, tenantId);
// 通过反射更新 BoundSql
ReflectionUtil.setFieldValue(boundSql, "sql", newSql);
}
}
@Override
public void beforeUpdate(Executor executor, MappedStatement ms,
Object parameter) {
String tenantId = TenantContext.getCurrentTenantId();
if (tenantId == null) {
return;
}
String sql = boundSql.getSql();
if (shouldAddTenantFilter(sql)) {
String newSql = addTenantFilter(sql, tenantId);
ReflectionUtil.setFieldValue(boundSql, "sql", newSql);
}
}
private String addTenantFilter(String sql, String tenantId) {
// 在 WHERE 条件后追加 AND tenant_id = 'xxx'
if (sql.contains("WHERE")) {
return sql.replace("WHERE", "WHERE tenant_id = '" + tenantId + "' AND ");
} else {
// 没有 WHERE 条件,追加
int lastFromIndex = sql.lastIndexOf("FROM");
int afterFrom = sql.indexOf(" ", lastFromIndex + 4);
String tablePart = sql.substring(lastFromIndex, afterFrom);
return sql.replace(tablePart,
tablePart + " WHERE tenant_id = '" + tenantId + "' ");
}
}
}3.4.2 行级数据隔离
java
/**
* 实体类中的租户字段
* 所有需要隔离的业务表都必须包含 tenant_id 字段
*/
@Data
@TableName("saas_order")
public class TenantOrder {
@TableId
private Long id;
private String tenantId; // 租户 ID (分片键)
private String orderNo;
private BigDecimal amount;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableField(exist = false)
private String tenantName; // 非持久化字段
}
/**
* Mapper 层自动填充租户 ID
*/
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
String tenantId = TenantContext.getCurrentTenantId();
if (tenantId != null) {
this.strictInsertFill(metaObject, "tenantId", String.class, tenantId);
}
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
}4. 第三方接口统一管理
4.1 第三方接口注册
4.1.1 接口元数据
java
/**
* 第三方接口定义
*/
@Data
@TableName("third_party_api")
public class ThirdPartyApi {
@TableId(type = IdType.AUTO)
private Long id;
private String provider; // 服务商: aliyun/tencent/amap/kdniao
private String apiName; // 接口名称: 短信发送/物流查询/地图编码
private String apiCode; // 接口编码: sms.send/logistics.query
private String urlTemplate; // URL 模板: https://{region}.aliyuncs.com
private String httpMethod; // 请求方法: GET/POST
private String contentType; // Content-Type: application/json
private Integer connectTimeout; // 连接超时 (ms)
private Integer readTimeout; // 读取超时 (ms)
private Integer maxRetries; // 最大重试次数
private Integer retryIntervalMs; // 重试间隔 (ms)
private String circuitBreakerRule; // 熔断规则 JSON
private Integer maxConcurrent; // 最大并发数
private String status; // ENABLED / DISABLED
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}