健务室接入方式
健务室报告走开放平台现有 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_type | resource_no | 触发 |
|---|---|---|---|
workplace.device_report.created | device_report | 设备报告号(如 RPT…) | 设备检测报告首次入账 |
workplace.w_report.created | w_report | W 值评估号(以实际编号为准) | W 值报告首次生成且成功 |
请求头:
| Header | 说明 |
|---|---|
X-Webhook-Id | 本次投递唯一 ID(等于 body 中的 delivery_id) |
X-Webhook-Event | 事件类型 |
X-Webhook-Timestamp | Unix 秒级时间戳 |
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"
}| 字段 | 类型 | 说明 |
|---|---|---|
event | string | 事件名 |
enterprise_id | integer | 健务室企业 ID |
resource_type | string | device_report / w_report |
resource_no | string | 回拉路径参数;按此字段幂等 |
user_id | integer | 健务室用户 ID(非开放平台 open_user_id) |
occurred_at | string | ISO 8601。设备报告取入账时间,W 值取 generated_at |
delivery_id | string | 投递幂等 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_textGo
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_type | employee | guest |
name | 员工花名册姓名 | 访客姓名 |
gender | MALE / FEMALE / null | 同左(W 值用评估快照覆盖) |
birth_date | Y-m-d | 同左(W 值用评估快照覆盖) |
id_type | ID_CARD / PASSPORT / HK_MACAO_PERMIT / TAIWAN_PERMIT | null |
id_number | 解密证件号;仅有脱敏历史号时返回脱敏值并置 id_number_masked=true | null |
phone / email / employee_no | 花名册字段 | phone 取用户手机;email 仅当访客登录账号含 @;employee_no 为 null |
guest_login_account | null | 访客登录账号(手机 / 邮箱 / 微信标识) |
age | 设备报告不返回此字段 | 仅 W 值返回;评估快照周岁 |
user_type以查询时刻身份为准;访客转员工后同一user_id再查即为employee- 设备报告的用户信息是查询时刻当前值;W 值的
gender/birth_date/age用评估快照,其余仍为当前值 - 证件号解密失败不导致接口 500,
id_number为 null
错误码
| HTTP | code | 含义 |
|---|---|---|
| 404 | REPORT_NOT_FOUND | 未订阅 / 企业未开通 / 编号不存在 / W 值非成功或已失效 |
| 503 | ENCRYPT_KEY_MISSING | 报告加密密钥未就绪,联系晓葆开通 |
| 401 | INVALID_SIGNATURE | 查询请求签名失败或时间戳超出 300 秒 |