设备与证书开发 Wiki

面向设备端、桌面端和 Android 开发人员的接入与字段构建说明。

API v1

一、系统概览

本模块在 FastAdmin/ThinkPHP 网站中完成用户、设备、证书模板、历史记录和通讯日志的闭环。管理员负责创建用户、绑定设备及交付设备凭证;用户只能查看自己绑定的设备、编辑自己的模板和历史记录;只有已启用且已绑定有效用户的设备才能调用 API。

管理员创建并绑定设备
设备携带 Key/Secret 鉴权
读取用户模板及字段
提交历史 JSON
用户修改并打印证书

二、整个实现过程

  1. 用户体系:复用 FastAdmin 用户账号,扩展真实姓名、所属单位和常见信息。
  2. 设备绑定:每台设备归属一个用户,并配置唯一设备编号、API Key、API Secret、启停状态和最后通讯信息。
  3. 模板初始化:用户首次进入证书功能时,从 application/common/certificate/report_template.html 创建 HTML 模板,从 report_variables.json 初始化变量说明。
  4. 模板编辑:用户通过 TinyMCE 编辑完整 HTML;模板使用 [FieldName] 占位符。
  5. 设备接入:设备先调用 Ping 验证凭证,再获取模板列表与变量说明,最后提交历史记录。
  6. 历史保存:业务数据以 JSON 形式保存,并关联提交设备和所选模板,方便用户二次编辑。
  7. 证书输出:打印时读取历史 JSON,将占位符替换为经过 HTML 转义的数据,再返回可打印 HTML。
  8. 通讯审计:API 请求方式、路径、IP、请求内容、响应内容、状态码和耗时写入设备日志,Secret 会被脱敏。

三、字段构建方式

3.1 请求外层字段

字段类型必填规则
record_nostring用户范围内唯一,最长 100 字符;重复提交同一编号时更新原记录
titlestring记录标题,最长 150 字符;省略时使用记录编号
template_idinteger必须来自模板列表,并属于设备绑定的用户
statusstringdraftcompleted
client_timestring设备端时间,建议 ISO 8601,最长 40 字符
dataobject证书业务字段组成的 JSON 对象,整个 JSON 最大 2 MB

3.2 模板字段规则

  • 字段名建议使用大写字母开头的英文 PascalCase,例如 RecordNumber
  • HTML 中写 [RecordNumber],设备提交 data.RecordNumber
  • 嵌套 JSON 使用点号,例如 JSON 的 customer.name 对应 [customer.name]
  • 数组和复杂对象可以保存到历史记录,但直接放入普通占位符时会转为 JSON 字符串;图表等复杂数据应由模板脚本专门处理。
  • 变量说明只帮助设备端和用户理解字段,不会限制 data 中的扩展字段。

3.3 当前初始化变量

JSON 字段字段含义HTML 写法
RecordNumber记录编号[RecordNumber]
RecordTime检定日期[RecordTime]
EntrustingUnit送检/委托单位[EntrustingUnit]
StationName站点名称[StationName]
Manufacturer制造厂商[Manufacturer]
Model型号规格[Model]
MachineNo出厂编号[MachineNo]
Temperature环境温度[Temperature]
Humidity环境湿度[Humidity]
AtmosphericPressure大气压力[AtmosphericPressure]
Inspector检定员[Inspector]
Verifier核验员[Verifier]
VerificationConclusion检定结论[VerificationConclusion]
Organization用户所属单位[Organization]
RealName用户真实姓名[RealName]
CloudUrl云端数据地址[CloudUrl]
StdName标准器名称[StdName]
StdModel标准器型号规格[StdModel]
StdSerialNo标准器编号[StdSerialNo]
StdCertNo标准器证书编号[StdCertNo]
StdValidDate标准器有效期[StdValidDate]
StdAccuracy标准器准确度等级[StdAccuracy]
Value1第1次测量值[Value1]
Value2第2次测量值[Value2]
Value3第3次测量值[Value3]
Value4第4次测量值[Value4]
Value5第5次测量值[Value5]
Value6第6次测量值[Value6]
Standard1第1次标准值[Standard1]
Standard2第2次标准值[Standard2]
Standard3第3次标准值[Standard3]
Standard4第4次标准值[Standard4]
Standard5第5次标准值[Standard5]
Standard6第6次标准值[Standard6]
Error1第1次误差[Error1]
Error2第2次误差[Error2]
Error3第3次误差[Error3]
Error4第4次误差[Error4]
Error5第5次误差[Error5]
Error6第6次误差[Error6]
MaxBasicError基本误差最大值[MaxBasicError]
MaxRepeatability重复性最大值[MaxRepeatability]
ValidTo有效期至[ValidTo]

3.4 历史 JSON 示例

{
  "record_no": "CNG-20260803-0001",
  "title": "一号加气机检定记录",
  "template_id": 8,
  "status": "completed",
  "client_time": "2026-08-03T14:30:00+08:00",
  "data": {
    "RecordNumber": "CNG-20260803-0001",
    "RecordTime": "2026-08-03",
    "EntrustingUnit": "示例送检单位",
    "Manufacturer": "示例制造商",
    "Model": "HC-CNG-01",
    "MachineNo": "M20260803001",
    "Temperature": "25.2℃",
    "Humidity": "51%",
    "VerificationConclusion": "合格"
  }
}

四、API 接口

本地地址为 http://127.0.0.1,正式环境替换为 HTTPS 域名。所有设备接口都携带以下请求头:

Content-Type: application/json; charset=utf-8
X-Device-Key: dev_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Device-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
方法地址用途
GET/POST/api/device/ping验证设备凭证和绑定状态
GET/api/device/templates获取当前用户的启用模板、模板 ID 和变量说明
POST/api/device/history新增或更新历史记录

统一响应包含 codemsgtimedata。HTTP 401 表示凭证错误,403 表示绑定用户无效,413 表示内容过大,422 表示字段或模板校验失败。

五、C# 实现(.NET 6+)

使用系统自带的 HttpClientSystem.Text.Json。实际项目中应复用单例 HttpClient,不要为每次请求重新创建。

using System.Net.Http.Json;
using System.Text.Json;

public sealed class CertificateApiClient
{
    private readonly HttpClient _http;

    public CertificateApiClient(string baseUrl, string apiKey, string apiSecret)
    {
        _http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
        _http.DefaultRequestHeaders.Add("X-Device-Key", apiKey);
        _http.DefaultRequestHeaders.Add("X-Device-Secret", apiSecret);
    }

    public async Task PingAsync(CancellationToken token = default)
    {
        using var response = await _http.GetAsync("api/device/ping", token);
        return await ReadResponseAsync(response, token);
    }

    public async Task GetTemplatesAsync(CancellationToken token = default)
    {
        using var response = await _http.GetAsync("api/device/templates", token);
        return await ReadResponseAsync(response, token);
    }

    public async Task SubmitAsync(
        long templateId,
        string recordNo,
        object certificateData,
        CancellationToken token = default)
    {
        var request = new
        {
            record_no = recordNo,
            title = $"检测记录 {recordNo}",
            template_id = templateId,
            status = "completed",
            client_time = DateTimeOffset.Now.ToString("O"),
            data = certificateData
        };

        using var response = await _http.PostAsJsonAsync(
            "api/device/history", request, cancellationToken: token);
        return await ReadResponseAsync(response, token);
    }

    private static async Task ReadResponseAsync(
        HttpResponseMessage response, CancellationToken token)
    {
        var json = await response.Content.ReadAsStringAsync(token);
        if (!response.IsSuccessStatusCode)
            throw new HttpRequestException(
                $"API {response.StatusCode}: {json}");
        return JsonDocument.Parse(json);
    }
}

// 调用示例
var api = new CertificateApiClient(
    "https://hbhcyq.com",
    "dev_xxx",
    "secret_xxx");

await api.PingAsync();
var result = await api.SubmitAsync(8, "CNG-20260803-0001", new
{
    RecordNumber = "CNG-20260803-0001",
    RecordTime = "2026-08-03",
    EntrustingUnit = "示例送检单位",
    Model = "HC-CNG-01",
    VerificationConclusion = "合格"
});

六、Java 实现(Java 11+)

以下示例使用 Java 标准库 HttpClient。JSON 示例直接构建字符串;正式项目建议使用 Jackson 或 Gson 从对象序列化,避免手工拼接用户数据。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;

public final class CertificateApiClient {
    private final HttpClient http = HttpClient.newHttpClient();
    private final String baseUrl;
    private final String apiKey;
    private final String apiSecret;

    public CertificateApiClient(String baseUrl, String apiKey, String apiSecret) {
        this.baseUrl = baseUrl.replaceAll("/+$", "");
        this.apiKey = apiKey;
        this.apiSecret = apiSecret;
    }

    private HttpRequest.Builder request(String path) {
        return HttpRequest.newBuilder(URI.create(baseUrl + path))
            .header("X-Device-Key", apiKey)
            .header("X-Device-Secret", apiSecret)
            .header("Content-Type", "application/json; charset=utf-8");
    }

    public String ping() throws Exception {
        HttpRequest req = request("/api/device/ping").GET().build();
        return send(req);
    }

    public String getTemplates() throws Exception {
        HttpRequest req = request("/api/device/templates").GET().build();
        return send(req);
    }

    public String submit(long templateId, String recordNo) throws Exception {
        String safeRecordNo = escapeJson(recordNo);
        String json = String.format(
            "{" +
            "\"record_no\":\"%s\"," +
            "\"title\":\"设备检测记录\"," +
            "\"template_id\":%d," +
            "\"status\":\"completed\"," +
            "\"client_time\":\"%s\"," +
            "\"data\":{" +
            "\"RecordNumber\":\"%s\"," +
            "\"RecordTime\":\"2026-08-03\"," +
            "\"Model\":\"HC-CNG-01\"," +
            "\"VerificationConclusion\":\"合格\"}" +
            "}",
            safeRecordNo, templateId, OffsetDateTime.now(), safeRecordNo);

        HttpRequest req = request("/api/device/history")
            .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
            .build();
        return send(req);
    }

    private static String escapeJson(String value) {
        return value.replace("\\", "\\\\").replace("\"", "\\\"");
    }

    private String send(HttpRequest request) throws Exception {
        HttpResponse response = http.send(
            request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new IllegalStateException(
                "API " + response.statusCode() + ": " + response.body());
        }
        return response.body();
    }
}

// 调用示例
CertificateApiClient api = new CertificateApiClient(
    "https://hbhcyq.com", "dev_xxx", "secret_xxx");
System.out.println(api.ping());
System.out.println(api.getTemplates());
System.out.println(api.submit(8L, "CNG-20260803-0001"));

Android 如果使用 OkHttp,Header 和 JSON 结构完全相同;网络请求必须放在线程池或协程中,不能在主线程执行。

七、安全、重试与排错

  • 正式环境必须使用 HTTPS,API Secret 不得写入公开仓库、普通日志或界面截图。
  • 管理员下载凭证后应通过安全方式烧录到指定设备;用户端只能查看设备,不能下载 Secret。
  • 设备启动时先 Ping;401 时停止业务提交并提示重新配置凭证。
  • 网络超时可以重试。相同用户和相同 record_no 会更新原记录,因此重试不会重复创建。
  • 提交前先获取模板列表,不要在程序中永久写死模板 ID;模板被停用后应重新选择。
  • 查看“通讯日志”可核对请求方式、路径、IP、请求 JSON、响应和耗时。

服务端会校验模板是否属于设备绑定用户。不要使用其他账号看到的模板 ID,否则返回 HTTP 422。