健务室接入方式

健务室报告走开放平台现有 Webhook 订阅:推送只带编号,渠道凭编号回拉详情。 详情明文经 SM4-ECB 加密后放入 data。

本期仅推送 created。覆盖、撤销、W 值失效不另推;查询始终返回当前值,已删或失效为 404。 上线前存量不补推。

概述

接口路径前缀:/open/v1/workplace

方法路径说明
GET/open/v1/workplace/device-reports/{reportNo}设备检测报告详情,见设备检测报告
GET/open/v1/workplace/w-reports/{assessmentNo}W 值报告详情,见W 值报告

接入前准备

项说明
App Key / App Secret开放平台分配;Secret 仅服务端保存,用于查询接口签名
Webhook 端点渠道公网 HTTPS URL。必须精确勾选事件名:workplace.device_report.created /workplace.w_report.created。events=null、workplace.* 对本组事件无效
Webhook Secret每个端点独立,用于验签推送,与 App Secret 不同
报告加密密钥开通时由晓葆线下发放:16 字节明文 SM4 密钥,及对应 key_version。仅服务端保存,用于解密查询返回的报告 data.cipher_text
可查询的企业范围由晓葆开通时指定。未订阅、企业未开通、报告不存在,查询一律 404 REPORT_NOT_FOUND。密钥未就绪则 503 ENCRYPT_KEY_MISSING。

端到端流程

设计原则:推送只带编号,完整内容一律回拉并加密。

健务室接入端到端时序图

授权判定

推送与查询共用同一判定:精确订阅对应事件,且报告所属企业已开通。 未订阅、企业未开通、报告不存在、W 值不可交付,查询一律 404 REPORT_NOT_FOUND,不区分原因。

健务室授权与查询决策图

Webhook 推送

渠道只收编号与企业 / 用户 ID,不含姓名、证件、手机号等个人信息。

事件类型:

事件resource_typeresource_no触发
workplace.device_report.createddevice_report设备报告号(如 RPT…)设备检测报告首次入账
workplace.w_report.createdw_reportW 值评估号(以实际编号为准)W 值报告首次生成且成功

请求头:

Header说明
X-Webhook-Id本次投递唯一 ID(等于 body 中的 delivery_id)
X-Webhook-Event事件类型
X-Webhook-TimestampUnix 秒级时间戳
X-Webhook-Signature签名,见验签

请求体示例:

{
  "event": "workplace.device_report.created",
  "delivery_id": "6b0c0123-4567-89ab-cdef-0123456789ab",
  "enterprise_id": 12,
  "resource_type": "device_report",
  "resource_no": "RPT20261021ABCDEFGH",
  "user_id": 1024,
  "occurred_at": "2026-10-21T09:58:00+08:00"
}
字段类型说明
eventstring事件名
enterprise_idinteger健务室企业 ID
resource_typestringdevice_report / w_report
resource_nostring回拉路径参数;按此字段幂等
user_idinteger健务室用户 ID(非开放平台 open_user_id)
occurred_atstringISO 8601。设备报告取入账时间,W 值取 generated_at
delivery_idstring投递幂等 ID,同一次投递跨重试不变

Webhook 验签

signature = HMAC-SHA256( timestamp + "." + rawBody , webhook_secret )   // 十六进制小写
  • rawBody 必须是收到的原始字节,不要先解析再序列化
  • 校验 X-Webhook-Timestamp 与当前时间差(建议 ≤ 5 分钟)防重放
  • 签名比较使用常量时间比较

PHP

$ts   = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'];
$sig  = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$raw  = file_get_contents('php://input');
$calc = hash_hmac('sha256', $ts . '.' . $raw, $webhookSecret);
if (!hash_equals($calc, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

Java(Spring)

@PostMapping("/webhook/apex")
public ResponseEntity<String> receive(@RequestBody byte[] raw,
        @RequestHeader("X-Webhook-Timestamp") String ts,
        @RequestHeader("X-Webhook-Signature") String sig) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(webhookSecret.getBytes(UTF_8), "HmacSHA256"));
    mac.update((ts + ".").getBytes(UTF_8));
    String calc = HexFormat.of().formatHex(mac.doFinal(raw));
    boolean ok = MessageDigest.isEqual(calc.getBytes(UTF_8), sig.getBytes(UTF_8))
            && Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) <= 300;
    return ok ? ResponseEntity.ok("ok") : ResponseEntity.status(401).build();
}

Python(Flask)

import hmac, hashlib, time
from flask import request, abort

@app.post("/webhook/apex")
def receive():
    ts  = request.headers["X-Webhook-Timestamp"]
    sig = request.headers["X-Webhook-Signature"]
    raw = request.get_data()  # 原始字节,勿先 json.loads
    calc = hmac.new(WEBHOOK_SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(calc, sig) or abs(time.time() - int(ts)) > 300:
        abort(401)
    return "ok"

Go

func receive(w http.ResponseWriter, r *http.Request) {
	raw, _ := io.ReadAll(r.Body)
	ts := r.Header.Get("X-Webhook-Timestamp")
	sig := r.Header.Get("X-Webhook-Signature")
	mac := hmac.New(sha256.New, []byte(webhookSecret))
	mac.Write([]byte(ts + "."))
	mac.Write(raw)
	calc := hex.EncodeToString(mac.Sum(nil))
	t, _ := strconv.ParseInt(ts, 10, 64)
	if !hmac.Equal([]byte(calc), []byte(sig)) || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		http.Error(w, "unauthorized", 401)
		return
	}
	w.Write([]byte("ok"))
}

应答与重试

  • 返回任意 2xx 视为成功;其他状态码、超时(10 秒)、连接失败均重试
  • 最多 5 次,间隔依次约 1 分钟、5 分钟、30 分钟、2 小时、6 小时
  • 必须按 delivery_id 幂等去重;业务处理先回 200 再异步回拉

查询接口

标准开放平台签名。GET 无请求体,body 按空串参与哈希。

请求头:

Header必填说明
X-App-Key是应用 Key
X-Timestamp是Unix 秒级,与服务器偏差 ≤ 300 秒
X-Nonce否随机串;缺省按空串参与签名
X-Signature是请求签名

请求签名

signString = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + sha256_hex(body)
signature  = HMAC-SHA256(signString, app_secret)   // 十六进制小写
  • METHOD 大写;URI 为含查询串的请求路径
  • GET 的 body 为空串,sha256_hex("") 仍参与计算

PHP

$uri   = '/open/v1/workplace/device-reports/RPT20261021ABCDEFGH';
$ts    = time();
$nonce = bin2hex(random_bytes(8));
$sign  = hash_hmac('sha256',
    implode("\n", ['GET', $uri, $ts, $nonce, hash('sha256', '')]), $appSecret);

$ch = curl_init("https://{host}{$uri}");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "X-App-Key: {$appKey}", "X-Timestamp: {$ts}",
        "X-Nonce: {$nonce}", "X-Signature: {$sign}",
    ],
]);
$envelope = json_decode(curl_exec($ch), true)['data'];
// $envelope = ['encrypt_type'=>'SM4-ECB','key_version'=>1,'cipher_text'=>'...']

Java

String uri = "/open/v1/workplace/device-reports/RPT20261021ABCDEFGH";
String ts = String.valueOf(System.currentTimeMillis() / 1000);
String nonce = UUID.randomUUID().toString().replace("-", "");
String bodyHash = HexFormat.of().formatHex(
        MessageDigest.getInstance("SHA-256").digest(new byte[0]));
String signStr = String.join("\n", "GET", uri, ts, nonce, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(UTF_8), "HmacSHA256"));
String sig = HexFormat.of().formatHex(mac.doFinal(signStr.getBytes(UTF_8)));

HttpRequest req = HttpRequest.newBuilder(URI.create("https://{host}" + uri))
        .header("X-App-Key", appKey).header("X-Timestamp", ts)
        .header("X-Nonce", nonce).header("X-Signature", sig).GET().build();
String resp = HttpClient.newHttpClient()
        .send(req, HttpResponse.BodyHandlers.ofString()).body();

Python

import hashlib, hmac, secrets, time, requests

uri = "/open/v1/workplace/device-reports/RPT20261021ABCDEFGH"
ts, nonce = str(int(time.time())), secrets.token_hex(8)
sign_str = "\n".join(["GET", uri, ts, nonce, hashlib.sha256(b"").hexdigest()])
sig = hmac.new(APP_SECRET.encode(), sign_str.encode(), hashlib.sha256).hexdigest()

r = requests.get("https://{host}" + uri, headers={
    "X-App-Key": APP_KEY, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig,
})
envelope = r.json()["data"]  # encrypt_type / key_version / cipher_text

Go

uri := "/open/v1/workplace/device-reports/RPT20261021ABCDEFGH"
ts := strconv.FormatInt(time.Now().Unix(), 10)
nb := make([]byte, 8); rand.Read(nb)
nonce := hex.EncodeToString(nb)
bh := sha256.Sum256(nil)
signStr := strings.Join([]string{"GET", uri, ts, nonce, hex.EncodeToString(bh[:])}, "\n")
mac := hmac.New(sha256.New, []byte(appSecret))
mac.Write([]byte(signStr))
sig := hex.EncodeToString(mac.Sum(nil))

req, _ := http.NewRequest("GET", "https://{host}"+uri, nil)
req.Header.Set("X-App-Key", appKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", sig)
resp, _ := http.DefaultClient.Do(req)

成功响应(200 OK),data 为加密信封:

{
  "success": true,
  "code": 0,
  "message": "获取成功",
  "data": {
    "encrypt_type": "SM4-ECB",
    "key_version": 1,
    "cipher_text": "Base64(SM4-ECB-PKCS7(明文JSON UTF-8))"
  },
  "timestamp": "2026-10-21T10:00:00+08:00"
}

报告 SM4 加解密

SM4 只保护查询接口返回的报告明文(data.cipher_text), Webhook 推送不加密。解密后得到 UTF-8 JSON,结构见设备检测报告 /W 值报告。

  • 算法:SM4-ECB,PKCS7 填充,密文 Base64,无 IV
  • 密钥:开通时发放的 16 字节明文;按响应 key_version 选用对应密钥
  • 解密:plain = SM4_ECB_Decrypt(Base64Decode(cipher_text), key)
  • 加密仅用于联调自测,与平台出站算法相同
明文含姓名、证件号、手机号。解密后按对方合规要求存储,禁止把密钥或明文写进浏览器 / 客户端。

PHP

// composer require lpilp/guomi   (与开放平台同一实现:RtSm4 + sm4-ecb + Base64,无 IV)
use Rtgm\sm\RtSm4;

function sm4EcbDecrypt(string $cipherB64, string $key): string
{
    if (strlen($key) !== 16) {
        throw new InvalidArgumentException('SM4 密钥必须是 16 字节');
    }
    return (new RtSm4($key))->decrypt($cipherB64, 'sm4-ecb', '', 'base64');
}

function sm4EcbEncrypt(string $plainUtf8, string $key): string
{
    if (strlen($key) !== 16) {
        throw new InvalidArgumentException('SM4 密钥必须是 16 字节');
    }
    return (new RtSm4($key))->encrypt($plainUtf8, 'sm4-ecb', '', 'base64');
}

$plain = sm4EcbDecrypt($envelope['cipher_text'], $sm4Key);
$report = json_decode($plain, true);

Java(BouncyCastle)

// BouncyCastle:SM4/ECB/PKCS7Padding,无 IV
Security.addProvider(new BouncyCastleProvider());

static String sm4EcbDecrypt(String cipherB64, String key) throws Exception {
    if (key.length() != 16) throw new IllegalArgumentException("SM4 密钥必须是 16 字节");
    Cipher c = Cipher.getInstance("SM4/ECB/PKCS7Padding", "BC");
    c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(UTF_8), "SM4"));
    return new String(c.doFinal(Base64.getDecoder().decode(cipherB64)), UTF_8);
}

static String sm4EcbEncrypt(String plainUtf8, String key) throws Exception {
    if (key.length() != 16) throw new IllegalArgumentException("SM4 密钥必须是 16 字节");
    Cipher c = Cipher.getInstance("SM4/ECB/PKCS7Padding", "BC");
    c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(UTF_8), "SM4"));
    return Base64.getEncoder().encodeToString(c.doFinal(plainUtf8.getBytes(UTF_8)));
}

String plain = sm4EcbDecrypt(envelope.get("cipher_text"), sm4Key);

Python(gmssl)

# pip install gmssl
import base64, json
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT

def sm4_ecb_crypt(key: str, data: bytes, decrypt: bool) -> bytes:
    if len(key.encode()) != 16:
        raise ValueError("SM4 密钥必须是 16 字节")
    c = CryptSM4()
    c.set_key(key.encode(), SM4_DECRYPT if decrypt else SM4_ENCRYPT)
    return c.crypt_ecb(data)

def sm4_ecb_decrypt(cipher_b64: str, key: str) -> str:
    return sm4_ecb_crypt(key, base64.b64decode(cipher_b64), True).decode("utf-8")

def sm4_ecb_encrypt(plain_utf8: str, key: str) -> str:
    return base64.b64encode(sm4_ecb_crypt(key, plain_utf8.encode(), False)).decode()

plain = sm4_ecb_decrypt(envelope["cipher_text"], SM4_KEY)
report = json.loads(plain)

Go(gmsm)

// go get github.com/tjfoc/gmsm/sm4
func pkcs7Unpad(b []byte) ([]byte, error) {
	if len(b) == 0 {
		return nil, errors.New("empty")
	}
	n := int(b[len(b)-1])
	if n == 0 || n > len(b) {
		return nil, errors.New("bad pkcs7")
	}
	return b[:len(b)-n], nil
}

func pkcs7Pad(b []byte, block int) []byte {
	n := block - len(b)%block
	return append(b, bytes.Repeat([]byte{byte(n)}, n)...)
}

func sm4EcbDecrypt(cipherB64, key string) (string, error) {
	if len(key) != 16 {
		return "", errors.New("SM4 密钥必须是 16 字节")
	}
	raw, err := base64.StdEncoding.DecodeString(cipherB64)
	if err != nil {
		return "", err
	}
	block, err := sm4.NewCipher([]byte(key))
	if err != nil {
		return "", err
	}
	if len(raw)%block.BlockSize() != 0 {
		return "", errors.New("bad block")
	}
	out := make([]byte, len(raw))
	bs := block.BlockSize()
	for i := 0; i < len(raw); i += bs {
		block.Decrypt(out[i:i+bs], raw[i:i+bs])
	}
	plain, err := pkcs7Unpad(out)
	return string(plain), err
}

func sm4EcbEncrypt(plainUtf8, key string) (string, error) {
	if len(key) != 16 {
		return "", errors.New("SM4 密钥必须是 16 字节")
	}
	block, err := sm4.NewCipher([]byte(key))
	if err != nil {
		return "", err
	}
	raw := pkcs7Pad([]byte(plainUtf8), block.BlockSize())
	out := make([]byte, len(raw))
	bs := block.BlockSize()
	for i := 0; i < len(raw); i += bs {
		block.Encrypt(out[i:i+bs], raw[i:i+bs])
	}
	return base64.StdEncoding.EncodeToString(out), nil
}

企业与用户

设备报告与 W 值报告共用同一 enterprise / user 结构。 员工、访客字段齐全,缺失一律 null,不省略 key。 对方按 user.user_type 分支:employee / guest。

匹配主键:enterprise.id + user_id。同一自然人在不同企业是不同 user_id。

字段员工访客
enterprise.id / enterprise_no / name报告所属企业,两种身份都返回同左;企业行缺失则编号/名为 null,id 仍出
user.user_id健务室用户 ID同左
user.user_typeemployeeguest
name员工花名册姓名访客姓名
genderMALE / FEMALE / null同左(W 值用评估快照覆盖)
birth_dateY-m-d同左(W 值用评估快照覆盖)
id_typeID_CARD / PASSPORT / HK_MACAO_PERMIT / TAIWAN_PERMITnull
id_number解密证件号;仅有脱敏历史号时返回脱敏值并置 id_number_masked=truenull
phone / email / employee_no花名册字段phone 取用户手机;email 仅当访客登录账号含 @;employee_no 为 null
guest_login_accountnull访客登录账号(手机 / 邮箱 / 微信标识)
age设备报告不返回此字段仅 W 值返回;评估快照周岁
  • user_type 以查询时刻身份为准;访客转员工后同一 user_id 再查即为 employee
  • 设备报告的用户信息是查询时刻当前值;W 值的 gender / birth_date / age 用评估快照,其余仍为当前值
  • 证件号解密失败不导致接口 500,id_number 为 null

错误码

HTTPcode含义
404REPORT_NOT_FOUND未订阅 / 企业未开通 / 编号不存在 / W 值非成功或已失效
503ENCRYPT_KEY_MISSING报告加密密钥未就绪,联系晓葆开通
401INVALID_SIGNATURE查询请求签名失败或时间戳超出 300 秒