🔐 签名接口

ThinkAdmin 通过 think\admin\service\InterfaceService 提供一套表单 POST 签名封装,适合服务端之间的简单接口调用。它负责请求数据签名、请求验签、响应签名和后端模拟请求。

该服务不是账号插件的 JWT 接口,也不会自动绑定具体业务账号。业务需要自行决定 appidappkey 的来源,并在控制器中完成账号状态、业务权限和参数校验。

核心行为

InterfaceService 启动时默认读取系统配置:

配置项说明
data.interface_appid默认接口账号
data.interface_appkey默认接口密钥
data.interface_getway默认请求网关

也可以在代码中覆盖:

use think\admin\service\InterfaceService;

$service = InterfaceService::instance()
    ->getway('https://api.example.com')
    ->setAuth('your_app_id', 'your_app_key');

服务提供这些公开方法:

方法说明
getway($getway)设置请求网关
setAuth($appid, $appkey)设置当前验签和签名使用的账号密钥
setOutTypeJson()响应中的 data 输出为 JSON 字符串,默认模式
setOutTypeArray()响应中的 data 输出为数组
getAppid()获取当前服务使用的 appid
getData()从 POST 读取参数、验签、校验时间并解析 data
success() / error()输出带签名的嵌套业务响应
baseSuccess() / baseError()输出带签名的根响应
doRequest()生成签名请求并校验对端根响应签名

请求字段

客户端需要使用 POST 表单提交以下字段:

字段说明
appid接口账号,必填;当前实现不会自动用它替换服务对象中的账号
time秒级时间戳,和服务端时间差不能超过 30 秒
nostr随机字符串
data参与签名的 JSON 字符串
sign请求签名

签名字符串固定为:

appid#data#time#appkey#nostr

最终签名为:

md5(appid + '#' + data + '#' + time + '#' + appkey + '#' + nostr)

这里的 appidappkeyInterfaceService 当前对象中的 $appid$appkey,通常来自系统配置或调用 setAuth() 后的值。客户端提交的 appid 字段必须存在,但 getData() 不会直接用 POST appid 覆盖当前服务对象的账号。

data 必须保持和签名时完全一致的字符串。字段顺序、中文转义、空格或斜杠转义变化都会导致验签失败。

getData 边界

getData() 的实际行为:

  1. 从 POST 表单读取 appidtimenostrdatasign
  2. 使用当前服务对象的 $appid$appkey 重新计算签名。
  3. 签名不一致时输出根错误响应。
  4. 时间差超过 30 秒时输出根错误响应。
  5. json_decode($data, true),解析失败时返回空数组。

注意:固定账号模式下,POST 里的 appid 只做必填校验,不会自动参与服务对象账号选择,也不会自动判断“提交的 appid 是否等于当前服务配置的 appid”。如果需要多账号或账号状态校验,应先按 POST 的 appid 查询业务账号,再调用 setAuth($appid, $appkey) 后执行 getData()

响应格式

所有响应都会包含根签名字段:

{
    "code": 1,
    "info": "请求响应成功!",
    "time": "1710000000",
    "sign": "md5-string",
    "appid": "your_app_id",
    "nostr": "random-string",
    "data": "{\"code\":1,\"info\":\"获取成功\",\"data\":{\"id\":1}}"
}

默认 setOutTypeJson() 下,根响应的 data 是 JSON 字符串;调用 setOutTypeArray() 后,根响应的 data 会输出为数组。无论输出是哪种形式,响应签名都按内部生成的 JSON 字符串计算。

success()error() 会把业务结果放进根响应的 data 中:

$service->success('获取成功', ['id' => 1]);

默认响应结构相当于:

{
    "code": 1,
    "info": "请求响应成功!",
    "data": "{\"code\":1,\"info\":\"获取成功\",\"data\":{\"id\":1}}"
}

如果不需要嵌套业务结构,可以直接使用 baseSuccess() / baseError() 输出根数据。

后端调用

doRequest() 会自动调用 signData() 发起 POST 请求,并校验对端根响应签名:

use think\admin\service\InterfaceService;

$service = InterfaceService::instance()
    ->getway('https://api.example.com')
    ->setAuth('your_app_id', 'your_app_key');

try {
    $result = $service->doRequest('/data/api/profile', ['user_id' => 123]);
} catch (\Throwable $exception) {
    echo $exception->getMessage();
}

实际边界:

  • 对端响应不是 JSON 或为空时抛出异常。
  • 根响应 code 为空时抛出异常。
  • 第三个参数 $checktrue 时校验根响应签名,失败则抛出异常。
  • 返回值是根响应 data 解码后的数组。
  • 如果对端使用 success() / error() 输出嵌套业务结果,doRequest() 返回的数组仍包含里面的 codeinfodata,不会自动把嵌套业务 code=0 转成异常。

需要处理嵌套业务结果时可自行判断:

$result = $service->doRequest('/data/api/profile', ['user_id' => 123]);

if (isset($result['code']) && intval($result['code']) !== 1) {
    throw new \RuntimeException($result['info'] ?? '接口业务处理失败');
}

$data = $result['data'] ?? $result;

前端签名示例

后台静态资源中的 md5 模块实际是 SparkMD5,字符串 MD5 使用 md5.hash()

require(['md5'], function (md5) {
    const appid = 'your_app_id';
    const appkey = 'your_app_key';
    const payload = {user_id: 123};

    const data = JSON.stringify(payload);
    const time = String(Math.ceil(Date.now() / 1000));
    const nostr = Math.random().toString(36).slice(2);
    const sign = md5.hash([appid, data, time, appkey, nostr].join('#'));

    $.ajax({
        url: '/data/api/profile',
        type: 'POST',
        dataType: 'json',
        data: {appid, time, nostr, data, sign},
        success: function (ret) {
            if (ret.code !== 1) {
                console.error(ret.info);
                return;
            }

            // 默认 data 是 JSON 字符串。
            const body = typeof ret.data === 'string' ? JSON.parse(ret.data) : ret.data;
            console.log(body);
        }
    });
});

如果项目不使用内置 RequireJS 模块,只要确保客户端 MD5 算法和签名字符串一致即可。

服务端控制器示例

固定账号示例:

<?php
declare(strict_types=1);

namespace app\data\controller\api;

use think\admin\Controller;
use think\admin\service\InterfaceService;

class Auth extends Controller
{
    protected $data = [];

    protected $interface;

    protected function initialize()
    {
        $this->interface = InterfaceService::instance()
            ->setAuth('your_app_id', 'your_app_key');

        $this->data = $this->interface->getData();
    }

    public function success($info, $data = '{-null-}', $code = 1): void
    {
        $this->interface->success($info, $data, $code);
    }

    public function error($info, $data = '{-null-}', $code = 0): void
    {
        $this->interface->error($info, $data, $code);
    }
}

多账号示例:

protected function initialize()
{
    $this->interface = InterfaceService::instance();

    $post = $this->request->post();
    $appid = $post['appid'] ?? '';
    if ($appid === '') {
        $this->interface->baseError('参数 APPID 不能为空!');
    }

    $user = $this->app->db->name('AppUser')->where(['appid' => $appid])->find();
    if (empty($user)) {
        $this->interface->baseError('接口账号不存在!');
    }
    if (empty($user['status'])) {
        $this->interface->baseError('接口账号已被禁用!');
    }

    $this->interface->setAuth($user['appid'], $user['appkey']);
    $this->data = $this->interface->getData();
}

业务接口示例:

class Api extends Auth
{
    public function profile()
    {
        $data = $this->_vali([
            'user_id.require' => '用户ID不能为空!',
            'user_id.integer' => '用户ID格式错误!',
        ], $this->data, [$this, 'error']);

        $user = $this->app->db->name('SystemUser')
            ->where(['id' => $data['user_id'], 'is_deleted' => 0])
            ->find();

        if (empty($user)) {
            $this->error('用户不存在!');
        }

        $this->success('获取成功', $user);
    }
}

使用建议

  • 保持调用方和服务端 JSON 序列化规则一致,优先复用同一种签名工具。
  • nostr 没有内置去重存储,如需强防重放,应在业务侧记录已使用随机串或请求流水。
  • 30 秒时间差是硬编码校验,调用方需要保证服务器时间同步。
  • 接口服务只处理签名和响应结构,业务权限、限流、审计和账号状态需要业务自行实现。
最近更新:
Contributors: 邹景立, Anyon