🔐 签名接口
ThinkAdmin 通过 think\admin\service\InterfaceService 提供一套表单 POST 签名封装,适合服务端之间的简单接口调用。它负责请求数据签名、请求验签、响应签名和后端模拟请求。
该服务不是账号插件的 JWT 接口,也不会自动绑定具体业务账号。业务需要自行决定 appid 和 appkey 的来源,并在控制器中完成账号状态、业务权限和参数校验。
核心行为
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)这里的 appid 和 appkey 指 InterfaceService 当前对象中的 $appid、$appkey,通常来自系统配置或调用 setAuth() 后的值。客户端提交的 appid 字段必须存在,但 getData() 不会直接用 POST appid 覆盖当前服务对象的账号。
data 必须保持和签名时完全一致的字符串。字段顺序、中文转义、空格或斜杠转义变化都会导致验签失败。
getData 边界
getData() 的实际行为:
- 从 POST 表单读取
appid、time、nostr、data、sign。 - 使用当前服务对象的
$appid和$appkey重新计算签名。 - 签名不一致时输出根错误响应。
- 时间差超过 30 秒时输出根错误响应。
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为空时抛出异常。 - 第三个参数
$check为true时校验根响应签名,失败则抛出异常。 - 返回值是根响应
data解码后的数组。 - 如果对端使用
success()/error()输出嵌套业务结果,doRequest()返回的数组仍包含里面的code、info、data,不会自动把嵌套业务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 秒时间差是硬编码校验,调用方需要保证服务器时间同步。
- 接口服务只处理签名和响应结构,业务权限、限流、审计和账号状态需要业务自行实现。
