标准化系统对接功能实现
1. 目标定位
平台侧需要提供一套标准化开放接口能力,用于支撑机构系统对接。
核心场景:
- 对外接收机构推送的用户信息。
- 支持按设备或任意设备创建测评会话。
- 查询机构配置、设备绑定关系、报告状态等。
- 报告生成后主动回调第三方。
- 扣费、消费、用量等业务事件回调第三方。
平台能力要求:
| 能力 | 目标 |
|---|---|
| 安全 | appid、secretKey、签名、时间戳、nonce 防重放 |
| 幂等 | 相同业务请求重复提交不产生脏数据 |
| 限流 | 防止机构侧异常流量拖垮平台 |
| 可追踪 | 每次请求、响应、回调都有日志链路 |
| 可扩展 | 新增机构、新增回调类型不改核心流程 |
| 高可用 | 回调异步化,失败可重试、可补偿 |
2. 推荐项目结构
text
xxx-openapi-platform/
├── controller/ # 对外 API 入口
├── service/ # 业务逻辑
├── mapper/ # ORM 框架数据访问
├── model/
│ ├── entity/ # 数据库实体
│ ├── request/ # 入参 Record
│ └── response/ # 出参 Record
├── security/ # 签名验证、权限校验、重放防护
├── callback/ # 报告、扣费等主动回调
├── config/ # WebMvc、拦截器、异步线程池等配置
├── exception/ # 统一异常处理
└── util/ # 签名、ID、脱敏等工具设计原则:
- Controller 只做协议适配,不写复杂业务。
- Service 负责业务编排。
- Security 统一处理验签、时间戳、nonce。
- Callback 独立于主流程,避免第三方接口影响核心业务。
- Request、Response 使用 Java Record,减少样板代码。
3. 标准接口协议
3.1 通用请求字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
appid | string | 是 | 机构唯一标识 |
timestamp | string | 是 | 秒级时间戳 |
nonce | string | 是 | 随机字符串,防重放 |
signature | string | 是 | 请求签名 |
bizNo | string | 建议 | 机构侧业务流水号,用于幂等 |
3.2 签名规则
建议统一为:
text
appid={appid}&nonce={nonce}×tamp={timestamp}&secretKey={secretKey}再进行 MD5 或 HMAC-SHA256。
安全建议:
- 新系统优先使用 HMAC-SHA256。
secretKey禁止明文返回给前端。secretKey在数据库中加密存储。- 时间戳默认 5 分钟有效。
appid + nonce在有效期内只能使用一次。
4. 统一返回结构
java
public record ApiResult<T>(
String code,
String msg,
boolean success,
long timestamp,
T data
) {
public static <T> ApiResult<T> success(T data) {
return new ApiResult<>("0", "ok", true, System.currentTimeMillis(), data);
}
public static ApiResult<Void> error(String code, String msg) {
return new ApiResult<>(code, msg, false, System.currentTimeMillis(), null);
}
}约定:
| code | 含义 |
|---|---|
0 | 成功 |
100401 | appid 不存在或未启用 |
100402 | 签名验证失败或参数错误 |
100403 | 请求过期或 nonce 重放 |
810103 | 设备未找到或不属于该机构 |
999999 | 系统繁忙 |
5. 签名验证拦截器
核心逻辑:
java
@Component
public class SignatureInterceptor implements HandlerInterceptor {
private final OrgService orgService;
private final NonceService nonceService;
public SignatureInterceptor(OrgService orgService, NonceService nonceService) {
this.orgService = orgService;
this.nonceService = nonceService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
OpenApiAuthPayload payload = RequestBodyHolder.getAuthPayload(request);
if (payload.hasBlankRequiredField()) {
throw new BizException("100402", "缺少必要参数");
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - payload.timestamp()) > 300) {
throw new BizException("100403", "请求已过期");
}
OrgSecret secret = orgService.getEnabledSecret(payload.appid())
.orElseThrow(() -> new BizException("100401", "appid 不存在或已停用"));
if (!nonceService.tryUse(payload.appid(), payload.nonce(), payload.timestamp())) {
throw new BizException("100403", "请求重复提交");
}
String expectedSign = SignatureUtil.sign(payload.appid(), secret.secretKey(), payload.timestamp(), payload.nonce());
if (!expectedSign.equalsIgnoreCase(payload.signature())) {
throw new BizException("100402", "签名验证失败");
}
return true;
}
}注意:
- Servlet 请求体默认只能读一次,拦截器读取 body 前需要使用缓存包装器。
- nonce 防重放建议使用 Redis,TTL 与时间戳有效期保持一致。
- 签名失败、appid 不存在、nonce 重放都要记录审计日志。
6. 接收用户信息
6.1 请求体
java
public record PushUserRequest(
String appid,
Long timestamp,
String nonce,
String signature,
String bizNo,
String deviceSn,
UserPayload user
) {}
public record UserPayload(
String customerNo,
String name,
String phone,
String idCard,
Integer gender,
Integer age
) {}6.2 接口
java
@RestController
@RequestMapping("/api/openapi/receive_base_info")
public class UserPushController {
private final UserSessionService userSessionService;
public UserPushController(UserSessionService userSessionService) {
this.userSessionService = userSessionService;
}
@PostMapping("/bind")
public ApiResult<SessionCreateResponse> bindDevice(@RequestBody PushUserRequest request) {
return ApiResult.success(userSessionService.createBindDeviceSession(request));
}
@PostMapping("/any")
public ApiResult<SessionCreateResponse> anyDevice(@RequestBody PushUserRequest request) {
return ApiResult.success(userSessionService.createAnyDeviceSession(request));
}
}6.3 返回响应
java
public record SessionCreateResponse(
String deviceSn,
String startUrl,
String expireIn,
String thirdBizId
) {}6.4 服务层核心逻辑
java
@Service
public class UserSessionService {
@Transactional(rollbackFor = Exception.class)
public SessionCreateResponse createBindDeviceSession(PushUserRequest request) {
validateUser(request.user());
validateDeviceBelongsToOrg(request.appid(), request.deviceSn());
return createSession(request, request.deviceSn());
}
@Transactional(rollbackFor = Exception.class)
public SessionCreateResponse createAnyDeviceSession(PushUserRequest request) {
validateUser(request.user());
return createSession(request, null);
}
private SessionCreateResponse createSession(PushUserRequest request, String deviceSn) {
String sessionId = IdUtil.fastSimpleUUID();
LocalDateTime expireAt = LocalDateTime.now().plusHours(24);
AssessmentSession session = AssessmentSession.create(
request.appid(),
request.user().customerNo(),
request.bizNo(),
deviceSn,
sessionId,
expireAt
);
sessionMapper.upsert(session);
return new SessionCreateResponse(
deviceSn,
buildStartUrl(sessionId),
expireAt.toString(),
request.user().customerNo()
);
}
}幂等建议:
- 优先使用
appid + biz_no做唯一键。 - 如果机构没有稳定业务流水号,可退化为
appid + customer_no。 - 重复请求命中唯一键时返回既有会话,不重复创建。
7. 主动推送报告
7.1 触发时机
报告生成成功后,不应在主事务内同步调用第三方。推荐流程:
text
报告生成成功
-> 写入 report_push_log 待推送记录
-> 投递异步任务或 MQ
-> Callback Worker 推送第三方
-> 根据结果更新推送状态7.2 回调 Payload
java
public record ReportCallbackPayload(
String appid,
Long timestamp,
String nonce,
String signature,
String customerNo,
String reportNo,
String deviceSn,
String h5Url,
String pdfUrl,
BigDecimal score
) {}7.3 回调服务
java
@Service
public class ReportCallbackService {
private final ThirdPartyConfigService configService;
private final CallbackHttpClient callbackHttpClient;
private final ReportPushLogService pushLogService;
@Async("callbackExecutor")
public void pushReport(String appid, ReportData report) {
OrgCallbackConfig config = configService.getReportCallbackConfig(appid);
if (config == null || StrUtil.isBlank(config.callbackUrl())) {
pushLogService.markSkipped(report.reportNo(), "机构未配置报告回调地址");
return;
}
callbackHttpClient.postWithRetry(
config.callbackUrl(),
buildPayload(config, report),
3
);
}
}回调成功标准:
- HTTP 状态码为 200。
- 响应体业务码为
0。 - 超时、非 200、业务码非 0 都视为失败。
失败处理:
- 重试 3 次。
- 使用递增退避。
- 最终失败写入
report_push_log。 - 触发告警或进入人工补偿队列。
8. 扣费通知
扣费通知与报告回调一致,建议抽象通用回调能力。
回调类型:
| 类型 | 说明 |
|---|---|
REPORT_PUSH | 报告生成回调 |
DEDUCT_NOTIFY | 扣费通知 |
REFUND_NOTIFY | 退款通知 |
DEVICE_BIND | 设备绑定事件 |
通用回调表记录:
- 回调地址。
- 回调类型。
- 请求参数。
- 响应参数。
- HTTP 状态码。
- 业务响应码。
- 重试次数。
- 下次重试时间。
- 最后错误信息。
9. 管理接口
管理接口建议使用后台认证体系,例如 Sa-Token、JWT 或管理端会话,不建议每个管理接口重复开放平台签名。
示例:
java
@GetMapping("/org/config")
public ApiResult<OrgConfigResponse> getOrgConfig(LoginUser loginUser) {
return ApiResult.success(orgConfigService.getByAppid(loginUser.appid()));
}适合管理接口:
- 查询机构配置。
- 更新回调地址。
- 查询推送日志。
- 手动补偿回调。
- 查询设备绑定关系。
10.数据库表设计
建议使用独立 Schema 做业务隔离。
sql
CREATE SCHEMA IF NOT EXISTS openapi;
CREATE TABLE IF NOT EXISTS openapi.org_info (
id BIGSERIAL PRIMARY KEY,
appid VARCHAR(32) NOT NULL UNIQUE,
org_name VARCHAR(128) NOT NULL,
secret_key_cipher TEXT NOT NULL,
callback_url VARCHAR(512),
deduct_url VARCHAR(512),
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS openapi.assessment_session (
id BIGSERIAL PRIMARY KEY,
appid VARCHAR(32) NOT NULL,
biz_no VARCHAR(64),
customer_no VARCHAR(64) NOT NULL,
device_sn VARCHAR(64),
session_id VARCHAR(64) NOT NULL UNIQUE,
expire_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_assessment_session_biz UNIQUE (appid, biz_no),
CONSTRAINT uk_assessment_session_customer UNIQUE (appid, customer_no)
);
CREATE TABLE IF NOT EXISTS openapi.callback_log (
id BIGSERIAL PRIMARY KEY,
appid VARCHAR(32) NOT NULL,
callback_type VARCHAR(32) NOT NULL,
biz_no VARCHAR(64) NOT NULL,
target_url VARCHAR(512) NOT NULL,
request_body JSONB NOT NULL,
response_body TEXT,
http_status INTEGER,
business_code VARCHAR(32),
push_status SMALLINT NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_callback_log_biz UNIQUE (appid, callback_type, biz_no)
);
CREATE TABLE IF NOT EXISTS openapi.request_audit_log (
id BIGSERIAL PRIMARY KEY,
appid VARCHAR(32),
request_uri VARCHAR(256) NOT NULL,
request_method VARCHAR(16) NOT NULL,
nonce VARCHAR(64),
signature VARCHAR(128),
request_body JSONB,
response_body JSONB,
client_ip VARCHAR(64),
trace_id VARCHAR(64),
result_code VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_assessment_session_appid_status
ON openapi.assessment_session (appid, status);
CREATE INDEX IF NOT EXISTS idx_callback_log_retry
ON openapi.callback_log (push_status, next_retry_at)
WHERE push_status IN (0, 2);
CREATE INDEX IF NOT EXISTS idx_request_audit_log_appid_created
ON openapi.request_audit_log (appid, created_at DESC);说明:
openapiSchema 用于隔离开放平台相关表。secret_key_cipher存加密后的密钥。callback_log.request_body使用 JSONB,便于审计和问题追踪。idx_callback_log_retry用于扫描待重试任务。
11. 限流与风控
建议限流维度:
| 维度 | 示例 |
|---|---|
| appid | 单机构 QPS 限制 |
| IP | 防止单 IP 高频攻击 |
| 接口 | 对高成本接口单独限流 |
| nonce | 防止重复请求 |
实现方式:
- 单体或小规模:Redis + Lua。
- Spring Cloud:Sentinel。
- 网关层:Nginx、Kong、Spring Cloud Gateway。
12. 日志与追踪
每次开放接口请求至少记录:
traceIdappidrequestUrirequestBodyresponseBodyclientIpresultCodecostMs
敏感字段必须脱敏:
- 手机号。
- 身份证号。
- 姓名。
- secretKey。
- signature。
13. 标准化落地清单
| 模块 | 必做项 |
|---|---|
| 接入认证 | appid、secretKey、签名、timestamp、nonce |
| 请求安全 | 时间戳校验、nonce 防重放、IP 白名单可选 |
| 业务幂等 | appid + biz_no 唯一约束 |
| 用户接收 | 参数校验、设备归属校验、会话创建 |
| 报告回调 | 异步、重试、日志、告警 |
| 扣费通知 | 独立回调类型、可补偿 |
| 返回协议 | 统一 ApiResult<T> |
| 异常处理 | 全局异常码,不暴露堆栈 |
| 数据隔离 | 独立 openapi Schema |
| 可观测性 | 审计日志、traceId、耗时统计 |
