Files
YwxAppThink/addon/iot/controller/api/Client.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

391 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\iot\controller\api;
use think\facade\Db;
use addon\iot\service\Store;
use addon\iot\service\DeviceService;
use addon\iot\service\ShareService;
use addon\iot\service\TicketService;
/**
* App 端设备域 API(需框架会员 JWT 登录)
* 路由前缀:/iot/api/client/...
*
* 2026-09-18 按功能域拆分后本控制器只保留「接入层」:连接/设备绑定/共享/
* 影子/命令 —— 全品类共用(品类无关)。传感器配置/曲线/日志/告警在 Monitor,
* 场景/分组在 Scene,OTA 在 Ota。共用会员取数在 ClientBase。
*/
class Client extends ClientBase
{
/** 免登录公开接口(设备侧调用,非会员) */
protected $noNeedLogin = ['deviceActivate'];
protected $noNeedVerify = ['deviceActivate'];
/**
* 我的设备 / 连接列表
* GET /iot/api/client/devices
*/
public function devices()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->apiError('无法获取当前会员账号');
}
$list = Db::name('iot_connection')
->where('username', $account)
->order('update_at', 'desc')
->limit(200)
->field('client_id,username,ip,status,create_at,update_at')
->select()
->toArray();
return $this->result->setCount(count($list))->apiSuccess($list);
}
/**
* 我的订阅主题列表
* GET /iot/api/client/subscriptions
*/
public function subscriptions()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->apiError('无法获取当前会员账号');
}
$clientIds = Db::name('iot_connection')
->where('username', $account)
->column('client_id');
$list = [];
if ($clientIds) {
$list = Db::name('iot_topic')
->whereIn('client_id', $clientIds)
->order('id', 'desc')
->field('client_id,topic,qos,create_at')
->select()
->toArray();
}
return $this->result->apiSuccess(['list' => $list, 'total' => count($list)]);
}
/**
* 发布消息(服务端代发,强制命名空间隔离)
* POST /iot/api/client/publish
* param: topic, payload, qos(0/1/2), retain(0/1)
*/
public function publish()
{
$account = $this->memberAccount();
if ($account === '') {
return $this->result->apiError('无法获取当前会员账号');
}
$topic = trim((string) $this->request->post('topic', ''));
$payload = (string) $this->request->post('payload', '');
$qos = (int) $this->request->post('qos', 0);
$retain = (int) $this->request->post('retain', 0);
if ($topic === '' || $topic[0] === '$') {
return $this->result->apiError('主题不能为空,且禁止发布到 $SYS 等系统主题');
}
// 命名空间隔离:topic 必须以 {username}/ 开头,避免越权发到他人主题
$prefix = $account . '/';
if (strncmp($topic, $prefix, strlen($prefix)) !== 0) {
return $this->result->apiError("主题必须以 {$prefix} 开头(命名空间隔离)");
}
if (!in_array($qos, [0, 1, 2], true)) {
$qos = 0;
}
$ok = Store::enqueueManual($topic, $payload, $qos, $retain, 'app');
if (!$ok) {
return $this->result->apiError('发布失败,请稍后重试');
}
return $this->result->apiSuccess(['topic' => $topic]);
}
/* ============================================================
* 智能设备绑定(华为式:激活与认领分离)
* 绑定与激活分离:设备先入云激活领凭证(deviceActivate,设备侧调),
* 用户再扫码 SN + 配对码绑定(bindDevice,认领归属)。
* 连接约定:设备 MQTT username=clientId=uuid,密码=激活时下发的 secret
* Topic:/devices/{uuid}/state(设备上报·影子) /devices/{uuid}/cmd(下发命令)
* ========================================================== */
/**
* 绑定设备(用户认领:扫码 SN + 配对码,华为式)
* 设备已激活(uuid+secret 已签发)→ 绑定仅建立归属,凭证不变;
* 设备未激活(无配对码的老设备)→ 兼容米家式:绑定即激活
* POST /iot/api/client/bindDevice
* param: sn(必填), pair_code(设备设置了配对码则必填), title, model, room
*/
public function bindDevice()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$sn = trim((string) $this->request->post('sn', ''));
$pairCode = trim((string) $this->request->post('pair_code', ''));
$title = trim((string) $this->request->post('title', ''));
$model = trim((string) $this->request->post('model', ''));
$room = trim((string) $this->request->post('room', ''));
try {
$res = DeviceService::bind($sn, $uid, $title, $model, $room, $pairCode);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess($res);
}
/**
* 设备入云激活(华为式,设备侧调用,无需会员登录)
* 设备出厂预置 sn + activate_token,首次联网调此接口领取正式连接凭证 uuid/secret(明文仅此一次返回)
* POST /iot/api/deviceActivate
* param: sn(必填), token(必填,后台预登记生成的激活令牌)
* 幂等:已激活设备重复调用返回激活状态但不再次下发 secret(如需换凭证走 resetSecret)
*/
public function deviceActivate()
{
$sn = trim((string) $this->request->post('sn', ''));
$token = trim((string) $this->request->post('token', ''));
try {
$res = DeviceService::activate($sn, $token);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess($res);
}
/**
* 我的设备列表(主人 + 共享给我,role 区分 owner/shared,含在线状态 + 影子,剔除密钥)
* GET /iot/api/client/myDevices
*/
public function myDevices()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$list = DeviceService::visibleDevices($uid);
return $this->result->setCount(count($list))->apiSuccess($list);
}
/**
* 共享设备给其他会员(仅主人可操作,华为式家庭授权)
* POST /iot/api/client/shareDevice
* param: device_id(必填), account(被共享者会员账号/手机号), perm(1只读 2可控制,默认2)
*/
public function shareDevice()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$deviceId = (int) $this->request->post('device_id', 0);
$account = trim((string) $this->request->post('account', ''));
$perm = (int) $this->request->post('perm', ShareService::PERM_CTRL);
if ($deviceId <= 0 || $account === '') {
return $this->result->apiError('设备与对方账号必填');
}
// 按账号查被共享会员(member_user 主键 uid)
$target = Db::name('member_user')->where('account', $account)->find();
if (!$target || empty($target['uid'])) {
return $this->result->apiError('对方账号不存在');
}
try {
$res = ShareService::share($deviceId, $uid, (int) $target['uid'], $perm);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess($res);
}
/**
* 撤销共享(仅主人可操作)
* POST /iot/api/client/revokeShare
* param: device_id, member_id
*/
public function revokeShare()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$deviceId = (int) $this->request->post('device_id', 0);
$memberId = (int) $this->request->post('member_id', 0);
if ($deviceId <= 0 || $memberId <= 0) {
return $this->result->apiError('参数无效');
}
try {
ShareService::revoke($deviceId, $uid, $memberId);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess('已撤销共享');
}
/**
* 某设备的共享成员列表(主人管理页)
* GET /iot/api/client/shareList?device_id=1
*/
public function shareList()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$deviceId = (int) $this->request->get('device_id', 0);
if ($deviceId <= 0) {
return $this->result->apiError('参数无效');
}
$dev = DeviceService::findById($deviceId);
if (!$dev || (int) $dev['member_id'] !== $uid) {
return $this->result->apiError('设备不存在或无权操作');
}
$list = ShareService::shareList($deviceId, $uid);
return $this->result->apiSuccess(['list' => $list, 'total' => count($list)]);
}
/**
* 解绑设备
* POST /iot/api/client/unbindDevice
* param: uuid
*/
public function unbindDevice()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->post('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
try {
DeviceService::unbind($uuid, $uid);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess('解绑成功,设备可被重新绑定(密钥将重置)');
}
/**
* 重置设备密钥(旧密钥立即作废,返回新明文一次)
* POST /iot/api/client/resetSecret
* param: uuid
*/
public function resetSecret()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->post('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
try {
$res = DeviceService::resetSecret($uuid, $uid);
} catch (\Throwable $e) {
return $this->result->apiError($e->getMessage());
}
return $this->result->apiSuccess($res);
}
/**
* 换取 MQTT 一次性连接凭证(ticket)
* App 每次建立/重连 MQTT 前调用;连接时 username=会员账号(返回 account)、password=ticket。
* 凭证默认 60s 有效,过期失效;不直接使用会员明文密码,防反编译泄露。
* POST /iot/api/client/mqttTicket
*/
public function mqttTicket()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$account = $this->memberAccount();
if ($account === '') {
return $this->result->apiError('无法获取当前会员账号');
}
// ACL 自愈:ACL 是生成时快照,后来新增的自动规则(如 /status 在线态订阅)不会补到老设备上,
// 老用户升级 App 后会订阅被拒。换 ticket 是每次连接必经之路,在此顺手重建,幂等且代价极小。
try {
DeviceService::resyncMemberAcl($uid);
ShareService::resyncMember($uid);
} catch (\Throwable $e) {
}
$ticket = TicketService::issue($uid, $account);
return $this->result->apiSuccess([
'ticket' => $ticket,
'account' => $account,
'expire_in' => TicketService::TTL,
'expires_at' => time() + TicketService::TTL,
]);
}
/**
* 向绑定的设备下发命令
* POST /iot/api/client/sendCommand
* param: uuid, payload(JSON 字符串), qos(可选0/1/2)
*/
public function sendCommand()
{
$uid = $this->memberId();
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$uuid = trim((string) $this->request->post('uuid', ''));
$payload = (string) $this->request->post('payload', '');
$qos = (int) $this->request->post('qos', 0);
if ($uuid === '') {
return $this->result->apiError('设备uuid不能为空');
}
if ($payload === '') {
return $this->result->apiError('命令内容不能为空');
}
$dev = DeviceService::findByUuid($uuid);
if (!$dev) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_CTRL)) {
return $this->result->apiError('无权控制该设备');
}
if (!in_array($qos, [0, 1, 2], true)) {
$qos = 0;
}
$ok = Store::enqueueManual(sprintf(DeviceService::TOPIC_CMD, $uuid), $payload, $qos, 0, 'app');
if (!$ok) {
return $this->result->apiError('命令下发失败,请稍后重试');
}
return $this->result->apiSuccess(['uuid' => $uuid, 'topic' => sprintf(DeviceService::TOPIC_CMD, $uuid)]);
}
/**
* 读取设备状态(影子:最后上报的 retain 消息 + 在线状态)
* GET /iot/api/client/deviceState?uuid=xxx
*/
public function deviceState()
{
$uid = $this->memberId();
$uuid = trim((string) $this->request->get('uuid', ''));
if ($uid <= 0) {
return $this->result->apiError('未登录');
}
$dev = DeviceService::findByUuid($uuid);
if (!$dev) {
return $this->result->apiError('设备不存在');
}
if (!ShareService::canAccess($uuid, $uid, ShareService::PERM_VIEW)) {
return $this->result->apiError('无权查看该设备');
}
return $this->result->apiSuccess([
'uuid' => $uuid,
'online' => DeviceService::isOnline($uuid),
'shadow' => DeviceService::shadow($uuid),
]);
}
}