Skip to content

标准化系统对接功能实现

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 通用请求字段

字段类型必填说明
appidstring机构唯一标识
timestampstring秒级时间戳
noncestring随机字符串,防重放
signaturestring请求签名
bizNostring建议机构侧业务流水号,用于幂等

3.2 签名规则

建议统一为:

text
appid={appid}&nonce={nonce}&timestamp={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成功
100401appid 不存在或未启用
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);

说明:

  • openapi Schema 用于隔离开放平台相关表。
  • 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. 日志与追踪

每次开放接口请求至少记录:

  • traceId
  • appid
  • requestUri
  • requestBody
  • responseBody
  • clientIp
  • resultCode
  • costMs

敏感字段必须脱敏:

  • 手机号。
  • 身份证号。
  • 姓名。
  • secretKey。
  • signature。

13. 标准化落地清单

模块必做项
接入认证appid、secretKey、签名、timestamp、nonce
请求安全时间戳校验、nonce 防重放、IP 白名单可选
业务幂等appid + biz_no 唯一约束
用户接收参数校验、设备归属校验、会话创建
报告回调异步、重试、日志、告警
扣费通知独立回调类型、可补偿
返回协议统一 ApiResult<T>
异常处理全局异常码,不暴露堆栈
数据隔离独立 openapi Schema
可观测性审计日志、traceId、耗时统计

所有文章版权皆归博主所有,仅供学习参考。