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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

-597
View File
@@ -1,597 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "YwxApp 标准 App 接口 v1",
"description": "YwxApp App/小程序/第三方客户端标准接口。\n\n统一响应:{\"code\":0,\"msg\":\"success\",\"data\":{},\"timestamp\":1765900000}\n- code=0 成功;code=1 业务失败;code=401 未登录/鉴权失败\n- 业务 code 与 HTTP 状态码解耦(未登录时 HTTP 状态也为 401)\n- 需登录接口在 Header 携带 Authorization: Bearer <access_token>\n- 登录类接口 data 内含 access_token / refresh_token / expires_in;响应顶层也回写 token 字段",
"version": "1.0.0"
},
"servers": [
{ "url": "http://localhost/api/v1", "description": "本地开发" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"ApiResponse": {
"type": "object",
"required": ["code", "msg", "timestamp"],
"properties": {
"code": { "type": "integer", "description": "0=成功 1=业务失败 401=未登录", "example": 0 },
"msg": { "type": "string", "description": "提示信息", "example": "success" },
"data": { "type": "object", "nullable": true, "description": "业务数据" },
"timestamp": { "type": "integer", "description": "服务器时间戳(秒)", "example": 1765900000 }
}
},
"LoginData": {
"type": "object",
"properties": {
"uid": { "type": "integer", "example": 1 },
"account": { "type": "string", "example": "testuser" },
"nickname": { "type": "string", "example": "测试用户" },
"mobile": { "type": "string", "example": "" },
"email": { "type": "string", "example": "" },
"avatar": { "type": "string", "example": "" },
"status": { "type": "integer", "example": 1 },
"access_token": { "type": "string", "example": "eyJ0eXAiOiJKV1Q..." },
"refresh_token": { "type": "string", "example": "def50200..." },
"expires_in": { "type": "integer", "example": 3600 },
"token_type": { "type": "string", "example": "Bearer" }
}
},
"ProfileData": {
"type": "object",
"properties": {
"uid": { "type": "integer", "example": 1 },
"account": { "type": "string", "example": "testuser" },
"nickname": { "type": "string", "example": "测试用户" },
"mobile": { "type": "string", "example": "" },
"email": { "type": "string", "example": "" },
"avatar": { "type": "string", "example": "" },
"status": { "type": "integer", "example": 1 },
"status_text": { "type": "string", "example": "正常" },
"last_login": { "type": "string", "example": "2026-08-17 10:00:00" }
}
}
}
},
"security": [{ "bearerAuth": [] }],
"paths": {
"/common/init": {
"get": {
"tags": ["Common"],
"summary": "启动初始化",
"description": "App 冷启动首屏调用,返回站点信息/注册方式/版本。公开。",
"security": [],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": {
"code": 0, "msg": "success",
"data": { "site": {}, "register_type": "all", "version": "1.0.0" },
"timestamp": 1765900000
}
}
}
}
}
}
},
"/common/config": {
"get": {
"tags": ["Common"],
"summary": "站点配置",
"security": [],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "success", "data": { "site": {} }, "timestamp": 1765900000 }
}
}
}
}
}
},
"/common/upload": {
"post": {
"tags": ["Common"],
"summary": "文件上传(需登录)",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": { "file": { "type": "string", "format": "binary" } }
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": {
"code": 0, "msg": "上传成功",
"data": { "url": "/storage/uploads/xxx.png", "path": "uploads/xxx.png" },
"timestamp": 1765900000
}
}
}
}
}
}
},
"/user/register": {
"post": {
"tags": ["User"],
"summary": "注册",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["account", "password"],
"properties": {
"account": { "type": "string", "example": "testuser" },
"password": { "type": "string", "example": "123456", "description": "至少6位" },
"nickname": { "type": "string", "example": "测试用户" },
"mobile": { "type": "string", "example": "13800138000" },
"email": { "type": "string", "example": "user@example.com" },
"captcha": { "type": "string", "example": "1234", "description": "手机号注册时必填" }
}
}
}
}
},
"responses": {
"200": {
"description": "注册成功并签发 token",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": {
"code": 0, "msg": "注册成功",
"data": { "uid": 1, "account": "testuser", "access_token": "eyJ...", "refresh_token": "def...", "expires_in": 3600, "token_type": "Bearer" },
"access_token": "eyJ...", "access_expired": 3600, "refresh_token": "def...", "refresh_expired": 86400,
"timestamp": 1765900000
}
}
}
}
}
}
},
"/user/login": {
"post": {
"tags": ["User"],
"summary": "账号密码登录",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["account", "password"],
"properties": {
"account": { "type": "string", "example": "testuser" },
"password": { "type": "string", "example": "123456" }
}
}
}
}
},
"responses": {
"200": {
"description": "登录成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": {
"code": 0, "msg": "登录成功",
"data": { "uid": 1, "access_token": "eyJ...", "refresh_token": "def...", "expires_in": 3600 },
"timestamp": 1765900000
}
}
}
}
}
}
},
"/user/loginBySms": {
"post": {
"tags": ["User"],
"summary": "短信验证码登录(未注册自动注册)",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["mobile", "captcha"],
"properties": {
"mobile": { "type": "string", "example": "13800138000" },
"captcha": { "type": "string", "example": "1234" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "登录成功", "data": { "uid": 1, "access_token": "eyJ...", "refresh_token": "def..." }, "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/refresh": {
"post": {
"tags": ["User"],
"summary": "刷新 access_token",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["refresh_token"],
"properties": { "refresh_token": { "type": "string", "example": "def50200..." } }
}
}
}
},
"responses": {
"200": {
"description": "刷新成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "刷新成功", "data": { "access_token": "eyJ...", "refresh_token": "def...", "expires_in": 3600 }, "timestamp": 1765900000 }
}
}
},
"401": {
"description": "refresh_token 失效",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 401, "msg": "refresh_token 无效或已过期", "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/logout": {
"post": {
"tags": ["User"],
"summary": "退出登录(需登录)",
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "已登出", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/profile": {
"get": {
"tags": ["User"],
"summary": "获取会员资料(需登录)",
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "success", "data": { "uid": 1, "account": "testuser", "nickname": "测试", "status": 1, "status_text": "正常", "last_login": "2026-08-17 10:00:00" }, "timestamp": 1765900000 }
}
}
},
"401": {
"description": "未登录",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 401, "msg": "请先登录", "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/updateProfile": {
"post": {
"tags": ["User"],
"summary": "修改资料(需登录)",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"nickname": { "type": "string", "example": "新昵称" },
"mobile": { "type": "string", "example": "13800138000" },
"email": { "type": "string", "example": "user@example.com" },
"avatar": { "type": "string", "example": "" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "保存成功", "data": { "uid": 1, "nickname": "新昵称" }, "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/changePwd": {
"post": {
"tags": ["User"],
"summary": "修改密码(需登录)",
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["oldpassword", "newpassword"],
"properties": {
"oldpassword": { "type": "string", "example": "123456" },
"newpassword": { "type": "string", "example": "654321", "description": "至少6位" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "密码修改成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/resetPwdBySms": {
"post": {
"tags": ["User"],
"summary": "短信重置密码(免登录)",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["mobile", "captcha", "newpassword"],
"properties": {
"mobile": { "type": "string", "example": "13800138000" },
"captcha": { "type": "string", "example": "1234" },
"newpassword": { "type": "string", "example": "654321" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "密码重置成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/user/resetPwdByEmail": {
"post": {
"tags": ["User"],
"summary": "邮箱重置密码(免登录)",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["email", "captcha", "newpassword"],
"properties": {
"email": { "type": "string", "example": "user@example.com" },
"captcha": { "type": "string", "example": "1234" },
"newpassword": { "type": "string", "example": "654321" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "密码重置成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/sms/send": {
"post": {
"tags": ["验证码"],
"summary": "发送短信验证码",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["mobile", "event"],
"properties": {
"mobile": { "type": "string", "example": "13800138000" },
"event": { "type": "string", "example": "register", "description": "register/login/mobilelogin/resetpwd/changepwd/changemobile" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "发送成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/sms/check": {
"post": {
"tags": ["验证码"],
"summary": "校验短信验证码",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["mobile", "event", "captcha"],
"properties": {
"mobile": { "type": "string", "example": "13800138000" },
"event": { "type": "string", "example": "register" },
"captcha": { "type": "string", "example": "1234" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/ems/send": {
"post": {
"tags": ["验证码"],
"summary": "发送邮箱验证码",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["email"],
"properties": {
"email": { "type": "string", "example": "user@example.com" },
"event": { "type": "string", "example": "register" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "发送成功", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
},
"/ems/check": {
"post": {
"tags": ["验证码"],
"summary": "校验邮箱验证码",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["email", "captcha"],
"properties": {
"email": { "type": "string", "example": "user@example.com" },
"event": { "type": "string", "example": "register" },
"captcha": { "type": "string", "example": "1234" }
}
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ApiResponse" },
"example": { "code": 0, "msg": "验证码正确", "data": [], "timestamp": 1765900000 }
}
}
}
}
}
}
}
}
-209
View File
@@ -1,209 +0,0 @@
# YwxApp 标准 App 接口 v1 测试文档
> 适用对象:App / 小程序 / 第三方客户端联调与测试(Apifox / Postman)
> 基础路径:`/api/v1`(建议环境变量 `base_url = http://你的域名/api/v1`)
> 最后更新:2026-08-17
---
字段 值
uid 88889464
account testuser
password Test@123456
status 1(正常)
gid 2(普通会员)
## 1. 通用约定
### 1.1 响应结构(统一 JSON)
所有接口均返回以下结构(强制 JSON,不受请求类型影响):
```json
{
"code": 0,
"msg": "success",
"data": {},
"timestamp": 1765900000
}
```
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | int | `0`=成功;`1`=业务失败;`401`=未登录/鉴权失败 |
| `msg` | string | 提示信息 |
| `data` | object/null | 业务数据;登录类接口含 `access_token` 等 |
| `timestamp` | int | 服务器时间戳(秒) |
### 1.2 业务 code 与 HTTP 状态码
- 业务 `code` 与 HTTP 状态码**解耦**:未登录时返回 `code=401` 且 HTTP 状态也为 `401`。
- 业务失败时 HTTP 状态保持 `200`,仅 `code` 非 0(除 401 外)。
### 1.3 鉴权(Bearer Token)
- 登录 / 注册成功后,响应 `data` 内含 `access_token`、`refresh_token`、`expires_in`。
- 需登录接口在请求头携带:
```
Authorization: Bearer <access_token>
```
- 跨域:路由已挂载 `AllowCrossDomain` 中间件,前端联调无 CORS 问题。
### 1.4 接口清单一览
| 接口 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/common/init` | GET | 否 | 启动初始化(站点/注册方式/版本) |
| `/common/config` | GET | 否 | 站点配置 |
| `/common/upload` | POST | 是 | 文件上传 |
| `/user/register` | POST | 否 | 注册 |
| `/user/login` | POST | 否 | 账号密码登录 |
| `/user/loginBySms` | POST | 否 | 短信验证码登录(未注册自动注册) |
| `/user/refresh` | POST | 否(refresh_token) | 刷新 access_token |
| `/user/logout` | POST | 是 | 退出登录 |
| `/user/profile` | GET | 是 | 获取会员资料 |
| `/user/updateProfile` | POST | 是 | 修改资料 |
| `/user/changePwd` | POST | 是 | 修改密码 |
| `/user/resetPwdBySms` | POST | 否 | 短信重置密码 |
| `/user/resetPwdByEmail` | POST | 否 | 邮箱重置密码 |
| `/sms/send` | POST | 否 | 发送短信验证码 |
| `/sms/check` | POST | 否 | 校验短信验证码 |
| `/ems/send` | POST | 否 | 发送邮箱验证码 |
| `/ems/check` | POST | 否 | 校验邮箱验证码 |
---
## 2. 接口详情
### 2.1 Common
#### GET /common/init
启动初始化,App 冷启动首屏调用。
- 响应示例:
```json
{ "code": 0, "msg": "success", "data": { "site": {...}, "register_type": "all", "version": "1.0.0" }, "timestamp": 1765900000 }
```
#### GET /common/config
站点配置(公开)。
- 响应 `data` 结构同 `init` 中的 `site` 字段。
#### POST /common/upload
文件上传(需登录),`Content-Type: multipart/form-data`,字段名 `file`。
- 成功响应:
```json
{ "code": 0, "msg": "上传成功", "data": { "url": "/storage/uploads/xxx.png", "path": "uploads/xxx.png" }, "timestamp": 1765900000 }
```
---
### 2.2 User
#### POST /user/register
注册账号。
- 请求体(x-www-form-urlencoded):`account`(必填), `password`(必填,≥6位), `nickname`(可选), `mobile`(可选), `email`(可选), `captcha`(可选, 手机号注册时必填且需先调 `/sms/send`)
- 成功响应(自动签发 token):
```json
{
"code": 0, "msg": "注册成功", "timestamp": 1765900000,
"data": { "uid": 1, "account": "testuser", "nickname": "测试", "access_token": "...", "refresh_token": "...", "expires_in": 3600, "token_type": "Bearer" },
"access_token": "...", "access_expired": 3600, "refresh_token": "...", "refresh_expired": 86400
}
```
> 说明:token 同时出现在 `data` 内与响应顶层(供不同客户端习惯取用)。
#### POST /user/login
账号密码登录。
- 请求体:`account`(必填), `password`(必填)
- 响应结构同 `register`。
#### POST /user/loginBySms
短信验证码登录;手机号未注册则自动注册。
- 请求体:`mobile`(必填), `captcha`(必填)
- 响应结构同 `register`,自动注册时初始密码为随机值(仅短信登录可用)。
#### POST /user/refresh
使用 refresh_token 换取新 access_token。
- 请求体:`refresh_token`(必填)
- 成功响应:
```json
{ "code": 0, "msg": "刷新成功", "data": { "access_token": "...", "refresh_token": "...", "expires_in": 3600, "token_type": "Bearer" }, "timestamp": 1765900000 }
```
- 失败(refresh_token 无效/过期):`code=401`,HTTP 401。
#### POST /user/logout
退出登录(需登录)。
- 响应:`{ "code": 0, "msg": "已登出", "data": [], "timestamp": 1765900000 }`
#### GET /user/profile
获取当前会员资料(需登录,Header 带 `Authorization: Bearer <token>`)。
- 响应 `data` 字段(password/delete_at 已隐藏,附加 status_text/last_login):
```json
{
"code": 0, "msg": "success",
"data": { "uid": 1, "account": "testuser", "nickname": "测试", "mobile": "", "email": "", "avatar": "", "status": 1, "status_text": "正常", "last_login": "...", "create_at": 1765900000 },
"timestamp": 1765900000
}
```
#### POST /user/updateProfile
修改资料(需登录)。
- 请求体(均可选):`nickname`, `mobile`, `email`, `avatar`
- 响应 `data` 为更新后的会员数组。
#### POST /user/changePwd
修改密码(需登录)。
- 请求体:`oldpassword`(必填), `newpassword`(必填,≥6位)
- 错误示例:`{ "code": 1, "msg": "原密码错误", "timestamp": 1765900000 }`
#### POST /user/resetPwdBySms
短信重置密码(免登录)。
- 请求体:`mobile`(必填), `captcha`(必填), `newpassword`(必填,≥6位)
#### POST /user/resetPwdByEmail
邮箱重置密码(免登录)。
- 请求体:`email`(必填), `captcha`(必填), `newpassword`(必填,≥6位)
---
### 2.3 验证码
#### POST /sms/send
发送短信验证码。
- 请求体:`mobile`(必填), `event`(必填, 如 register/login/mobilelogin/resetpwd/changepwd/changemobile)
- 频率限制:同手机号 60 秒内不可重复;同 IP 1 小时上限 5 条。
#### POST /sms/check
校验短信验证码。
- 请求体:`mobile`(必填), `event`(必填), `captcha`(必填)
#### POST /ems/send
发送邮箱验证码。请求体:`email`(必填), `event`(可选, 默认 register)。
#### POST /ems/check
校验邮箱验证码。请求体:`email`(必填), `event`(可选), `captcha`(必填)。
---
## 3. Apifox / Postman 联调步骤
1. 导入集合文件 `docs/postman/ywxapp-api-v1.postman_collection.json`(Postman 直接导入;Apifox 选择「导入 → Postman 格式」)。
2. 设置环境/变量 `base_url` 为你的本地域名,例如 `http://localhost/api/v1`。
3. 先调试公开接口 `GET /common/init`,确认返回 JSON(非 HTML)即链路通。
4. 调 `POST /user/login`(或先 `register`),登录成功后在 Tests 脚本里自动把 `access_token` 写入变量 `token`。
5. 之后调 `/user/profile` 等需登录接口,自动带 `Authorization: Bearer {{token}}`。
6. 故意不带 token 调 `/user/profile`,应得 `code=401` + HTTP 401。
### 排错
- **全部 404**:清除路由缓存 `runtime/route_list.php` 后重试(TP 缓存机制)。
- **返回 HTML 而非 JSON**:确认路径为小写 `/api/v1/...`,且域名正确(非后台 admin 域)。
- **401 但 token 有效**:检查 token 是否过期,`/user/refresh` 用 `refresh_token` 换新;注意 access_token 默认有效期见 `expires_in`。
---
## 4. 测试 Checklist(建议用例)
- [ ] init / config 公开可访问且返回 JSON
- [ ] register 正常注册并拿到 token
- [ ] register 重复账号 → `code=1` 提示「该账号已被注册」
- [ ] login 密码错误 → `code=1` 提示「密码错误」
- [ ] login 成功后 profile 返回会员资料
- [ ] profile 不带 token → `code=401` HTTP 401
- [ ] refresh 用 refresh_token 换发新 token 成功
- [ ] refresh 用错误 token → `code=401`
- [ ] changePwd 原密码错误 → `code=1`
- [ ] logout 成功 → `code=0`
+26
View File
@@ -0,0 +1,26 @@
; MQTT Broker 常驻守护(Supervisor / 宝塔「进程守护管理器」通用)
; 安装:
; 1) 复制到 supervisor 配置目录,例如 /etc/supervisor/conf.d/iot-broker.conf
; 宝塔:软件商店 → 进程守护管理器 → 添加守护进程,把下面 program 段落逐项填进去
; 2) supervisorctl reread && supervisorctl update && supervisorctl start iot-broker
; 3) 查看:supervisorctl status iot-broker | tail -f runtime/iot-broker.log
; 注意:改下面 3 处占位路径;Linux 下 iot start 单进程同时开 TCP 1883 + WS 8083
[program:iot-broker]
command=/www/server/php/74/bin/php think iot start
directory=/www/wwwroot/ywxapp_dev
user=www
; 与 runtime/iot.pid 协作:被守护进程异常退出由 supervisor 拉起,正常 stop 走 SIGINT
autostart=true
autorestart=true
startsecs=5
startretries=10
stopwaitsecs=30
stopsignal=INT
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/www/wwwroot/ywxapp_dev/runtime/iot-broker.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5
environment=HOME="/www/wwwroot/ywxapp_dev",PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+38
View File
@@ -0,0 +1,38 @@
[Unit]
# MQTT Broker 常驻守护(Linux 单进程同时监听 TCP 1883 + WebSocket 8083)
# 安装:
# cp docs/deploy/iot-broker.service /etc/systemd/system/iot-broker.service
# vi /etc/systemd/system/iot-broker.service # 改下面 3 处占位路径
# systemctl daemon-reload && systemctl enable --now iot-broker
# 查看:systemctl status iot-broker | journalctl -u iot-broker -f
Description=YwxApp IoT MQTT Broker (Workerman)
Documentation=https://www.ywxapp.cn
After=network-online.target mariadb.service redis.service
Wants=network-online.target
[Service]
# ▼▼▼ 按实际环境改这三行 ▼▼▼
WorkingDirectory=/www/wwwroot/ywxapp_dev
ExecStart=/www/server/php/74/bin/php think iot start
User=www
Group=www
# Workerman 主进程常驻前台(不加 -d),日志交给 journald 并按天落 runtime/log
Type=simple
StandardOutput=append:/www/wwwroot/ywxapp_dev/runtime/iot.log
StandardError=append:/www/wwwroot/ywxapp_dev/runtime/iot.log
# 若 runtime/iot.pid 里的进程还活着,说明已有一个实例在跑,直接启动失败而不是抢端口
ExecStartPre=/bin/sh -c 'p=/www/wwwroot/ywxapp_dev/runtime/iot.pid; if [ -f "$p" ] && kill -0 "$(cat "$p")" 2>/dev/null; then echo "iot broker already running (pid $(cat $p))" >&2; exit 1; fi; rm -f "$p"; exit 0'
# Workerman 推荐组合:主进程收 SIGINT 优雅退出,超时后整组 SIGKILL
KillSignal=SIGINT
KillMode=mixed
TimeoutStopSec=30
Restart=always
RestartSec=5
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
+307
View File
@@ -0,0 +1,307 @@
# App 实时推送接入指南(智慧生活)
> 面向 App 客户端的 MQTT 实时通知链路。REST API 负责查询与指令,MQTT 负责「设备状态 / 告警 / 场景 / OTA」的实时下行推送。绑定设备后,服务器自动为会员放行只读的 `/notify/{account}/#` 订阅。
## 1. 连接地址
| 协议 | 地址 | 说明 |
| --- | --- | --- |
| MQTT over TCP | `mqtt://{host}:1883` | 原生 MQTT 3.1.1 / 5.0 |
| MQTT over WebSocket | `ws://{host}:8083` | App(uni-app x)/ 浏览器 / 小程序首选,二进制帧,无子路径 |
端口以「服务设置」页(`/iot/backend/setting`)实际配置为准。
> **Windows 开发机联调**:Workerman 一个进程只能监听一个端口,需开**两个 cmd 窗口**分别启动:
> ```bash
> 窗口1: php think iot start --tcp-only # MQTT over TCP :1883
> 窗口2: php think iot start --ws-only # MQTT over WebSocket :8083
> ```
> 前提:后台「服务设置」开启「Windows强制WebSocket」(`ws_force=1`)与「启用集群桥接」(`cluster_enabled=1`,需本机 Redis 可连),两进程经 Redis Stream 互通;Linux 生产一条命令即可。
## 2. 连接认证(三种方式)
- **方式一:认证账号**:后台「服务设置」维护的 MQTT 专用账号,`username` / `password` 直接填。
- **方式二:会员账号 + 登录密码**(开启「允许会员登录」后):`username`=会员账号(`member_user.account`)、`password`=登录密码(bcrypt 校验)。适合内测/自用。
- **方式三:一次性凭证 ticket(对外分发推荐)**:App 登录后先调 REST 换 60s 短时效凭证,再以 `username`=会员账号、`password`=ticket 连接,**会员明文密码不出客户端**(防反编译泄露):
```http
POST /iot/api/client/mqttTicket # 需带 Bearer 登录 token
→ { "code": 0, "data": { "ticket": "48位hex", "account": "会员账号", "expire_in": 60, "expires_at": 1735... } }
```
- 每次建立连接 / 自动重连前都应**重新换取**(ticket 过期即失效,重连复用旧凭证会在 60s 后失败)。
- 服务端校验:ticket 存在 + 未过期 + 与连接用户名绑定一致,通过后按会员身份放行(等价 `reason=ticket`)。
- clientId 建议 `app-{account}-{随机串}`,避免与设备 clientId(=`uuid`)冲突。
- 密码/ticket 校验失败将收到 CONNACK 拒绝,需要重新获取凭据。
- 建议开启 Clean Session 并在订阅时 **不要** 使用持久会话;重连后 retain 消息会兜底补拉最新状态。
## 3. 订阅主题
统一订阅:`/notify/{account}/#`(account 为 URL 转义前的原始账号,含 `@` 等字符可直接订阅)
| 子主题 | 触发时机 | payload 摘要 |
| --- | --- | --- |
| `alert` | 告警写入(设备离线 / 事件上报 / OTA 完成失败) | `{"id":..,"type":"alert","level":1..3,"title":"..","content":"..","uuid":"..","time":..}` |
| `event` | 设备主动事件上报 | 同上(`type=event`),level 1提示 / 2警告 / 3严重 |
| `scene` | 场景动作 `notify` 执行 | `{"type":"scene","scene_id":..,"title":"..","message":"..","time":..}` |
| `ota` | OTA 进度(下载中/安装中) | `{"type":"progress","uuid":"..","state":"downloading","progress":42,"version":"1.2.0","time":..}` |
| `ota` | OTA 完成/失败 | `{"type":"alert","uuid":"..","level":1|3,"title":"..","content":"..","time":..}` |
统一约定:所有 notify payload 均为 JSON,含 `type` 字段区分场景;`time` 为 Unix 秒。
## 4. retain 兜底
- 服务器推送时 `retain=1`,最新一条告警/进度会保留在 Broker 中。
- App 断线重连后无需「补偿拉取」即可收到最近一条;历史列表仍通过 REST `alertList` / `deviceLogs` 拉取。
## 5. 端到端消息流
```
设备上报 /devices/{uuid}/state|event|ota/report
→ Broker 钩子
├─ DeviceLogService::log (in 留痕)
├─ SceneService::onMessagePublished (场景评估 → 动作入 outbox)
├─ AlertService::onDeviceEvent (event → 告警 + notify)
└─ OtaService::onReport (OTA → 告警 + notify)
→ outbox 消费器(0.5s) 发布
├─ /devices/{uuid}/cmd (场景下发指令)
└─ /notify/{account}/# (retain 实时通知)
→ App 订阅收到 → 本地弹窗 / 红点 / 进度条
```
## 6. 与 REST API 的分工
| 场景 | 用哪个 |
| --- | --- |
| 进入页面首屏 / 历史记录 / 分页 | REST(`myDevices`(**已内联 sensors/cfg_ver,首屏骨架一次到位**)、`deviceState`、`alertList`、`deviceLogs`、`otaList`) |
| 实时状态变化 / 告警弹窗 / OTA 进度 | MQTT notify 订阅 |
| 下发控制指令(开关 / 场景 / OTA) | REST(`sendCommand`、`runScene`、`publishOta`) |
建议 App 连接建立成功、收到 CONNACK 后再注册 REST 轮询兜底(如 30s 拉一次未读数),MQTT 断线时自动切换。
## 7. uni-app x(uvue)联调页面
插件自带一个可独立运行的 MQTT 联调页面,含「连接 / 订阅 / 发布 / 消息流」,App 端(Android/iOS)真机即可直连验证整条链路。
### 7.1 引入工程
1. 把插件目录 `addon/iot/client/uvue/mqtt-debug/` 整个复制到 uni-app x 工程,例如放到 `pages/mqtt-debug/`(`index.uvue` 与 `MqttClient.uts` 必须同目录,页面用相对路径 import)。
2. `pages.json` 注册页面:
```json
{ "path": "pages/mqtt-debug/index", "style": { "navigationBarTitleText": "MQTT 联调" } }
```
3. `MqttClient.uts` 是通用 MQTT 3.1.1 over WebSocket 客户端(无任何第三方依赖,基于 `uni.connectSocket` 二进制收发),可复用到业务页面。
### 7.2 联调步骤
1. 后台「服务设置」确认 `ws_port`(默认 8083);Windows 开发机把 `ws_force` 设为「开启」、`cluster_enabled` 设为「开启」(需本机 Redis)。
2. 启动 Broker:Linux 一条命令 `php think iot start -d`;Windows 开两个窗口分别 `--tcp-only` 与 `--ws-only`(见第 1 节提示)。
3. 页面填写 `ws://{局域网IP}:8083`;账号填会员账号(会员须已绑定/被共享该设备)或认证账号(需 ACL 规则显式放行),密码对应;ClientID 留空自动生成。
4. 点「连接服务器」,状态变「已连接」。
5. 订阅 `/devices/{uuid}/state`(把 `{uuid}` 换成自己的设备):设备上报状态会实时滚入消息流。
6. 发布 `/devices/{uuid}/cmd`:可下发开关指令,观察设备(或模拟器)响应。
7. 订阅 `/notify/{账号}/#`:设备事件 / 告警 / OTA 进度实时推送,验证「第 5 节」链路。
> **归属校验(必读)**:`/devices/{uuid}/...` 与 `/notify/{account}/...` 是私有主题,服务端按**设备绑定与共享关系**硬校验,
> 与「启用ACL」「ACL默认策略」无关。因此:
> - 只能订阅/发布**自己绑定的设备**,或被共享给自己的设备(`perm=1` 只读,仅可订阅 state/status/event/config/ack/ota/report;`perm=2` 可控,含 cmd/ota);
> - `/devices/+/state` 这类**跨设备通配符默认拒绝**(否则等于把全站设备暴露给一个人),确需监控时由管理员在「ACL规则」中为该账号显式放行;
> - 匿名连接(不带用户名)不享有任何设备权限,仅能完成 `system/device/register` 入网。
### 7.3 代码接入要点(业务页面直接使用)
```ts
// 方式二:会员账号 + 登录密码(内测/自用)
const client = new MqttClient({
url: 'ws://192.168.1.10:8083',
clientId: 'app-' + Date.now().toString(),
username: '会员账号', // allow_member_login=1 时用会员账号,否则用认证账号
password: '密码',
autoReconnect: true
})
client.onConnAck = (code: number) => { /* 0=成功 */ }
client.onMessage = (topic: string, payload: string) => { /* 解析 JSON 弹窗/红点 */ }
client.connect()
client.subscribe('/notify/' + account + '/#', 0)
```
```ts
// 方式三(推荐):一次性凭证 —— 每次连接/重连前先 HTTP 换取,密码不落客户端
async function connectMqtt() {
const resp = await request('/iot/api/client/mqttTicket', { method: 'POST' }) // 带 Bearer
const { ticket, account } = resp.data
const client = new MqttClient({
url: 'wss://mq.example.com:8083',
clientId: 'app-' + Date.now().toString(),
username: account, // ticket 绑定的会员账号
password: ticket, // 60s 一次性凭证
autoReconnect: false // 重连前必须重新换取
})
client.onClose = () => { /* 停 1s 后重新走本函数(重新换取),最多 N 次 */ }
client.onMessage = (topic: string, payload: string) => { /* 解析 JSON */ }
client.connect()
client.subscribe('/notify/' + account + '/#', 0)
}
```
实现参考:插件自带 `device-panel`(`addon/iot/client/uvue/device-panel/`)已按方式三实现完整「换凭证 → 连接 → 断线重连前重换」流程,可直接照搬。
**订阅被拒必须做成可见提示**(`onSubAck`):归属校验不通过时,服务端仍会回 SUBACK,只是结果码为 `0x80`(MQTT 3.1.1)/ `0x87` 等(MQTT5),
**连接是成功的、但该主题不会来任何数据**。业务页若不处理该回调,现象就是「连上了却收不到数据」,只能靠猜。
```ts
client.onSubAck = (topic: string, code: number) => {
if (code >= 128) {
showTip('无权订阅 ' + topic + ':设备未绑定到本账号或共享已撤销') // 必须让用户看到
} else {
// code = 服务端授予的 QoS(0/1/2)
}
}
```
- 收 iOS ATS 限制:线上必须使用 `wss://`(`ws_port` 同端口 TLS 部署,或经 Nginx 反代 wss)。
- 心跳由客户端自动维持(默认 60s 的 1/2),断线自动指数退避重连并恢复订阅。
## 8. 传感器配置与 state 报文(固件 v1.2.3+)
### 8.1 设计原则:配置与数值分离
| 数据 | 存放位置 | 频率 | 说明 |
|------|---------|------|------|
| 位置数 / 别名 / 探头数 / 上限 / 下限 / 温差阈值 | 服务端 `iot_sensor_cfg`(唯一真源) | 月级 | retained 下发设备 + App 经 HTTP 读同一份 |
| 当前温度 / 极差 / 探头有效性 | `/devices/{uuid}/state`(retained 影子) | 温变触发 + 120s 兜底 | 只传数值,不传设置 |
| 在线态 | `/devices/{uuid}/status`(retained,LWT) | 上下线 | `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 上线时顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` |
| 配置回执 | `/devices/{uuid}/ack`(非 retained) | 收到配置时 | `{"type":"config_ack","success":true,"cv":7}`(≤1.6.2 固件回 `cfg_ver`,服务端两版都认) |
**为什么配置不放报文**:位置列表必须在设备离线时也能渲染(App 打开详情页不能空白),且 `alias/upper/lower/gap` 是月级静态数据,塞进秒级报文纯属浪费(旧报文里它占了近一半体积)。
### 8.2 App 侧流程
```
① GET /iot/api/client/deviceConfig?uuid=xxx → 画骨架(离线也有卡片,值显示 --)
② 订阅 /devices/{uuid}/state → 填数值(按 **数组下标** 对齐位置序号,不看顺序也不读 index)
③ 订阅 /devices/{uuid}/status → 覆盖在线态(低频上报后不能靠"多久没收到 state"判离线)
④ POST /iot/api/client/saveDeviceConfig → 改配置(全量覆盖语义),保存即 retained 下发
```
`deviceConfig` 返回:`{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}]}`
(位置序号键统一 `index`;服务端兼容旧键 `i`,但 App 新代码只读写 `index`)
`applied=true` 表示设备已回 `config_ack`(`sync_ver >= cfg_ver`);`false` 时显示「待设备生效」(设备离线,上线订阅瞬间即收到 retained 配置)。
`pins` = 总线 GPIO 表(下标 = 位置序号),来自设备上线时 `status` 报文里的 `pins`,服务端缓存后下发。
它是给 App **画骨架时就把 `GPIOx ·` 显示出来**用的(v1.7.0 起 state 不再逐条带 `pin`)。
### 8.3 state 报文(`/devices/{uuid}/state`,retained)
固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`);
结构自 **v1.6.0** 的紧凑版式起一字未动(只压「随位置/探头数线性增长」的那部分字节):
```json
{
"ts": 1789000000, "ver": "1.7.0", "rssi": -57,
"iv": 120, "cv": 7,
"pos": [
{ "t": 45.63, "df": 0.25,
"pr": [ ["A3F2", 45.50],
["B71C", null ] ] },
null
]
}
```
> **为什么 state 里没有 `uuid` / `pin` 了**
> ① `uuid`:报文就发布在 `/devices/{uuid}/state` 上,App 订阅时用的就是这个 uuid ⇒ 省 ≈25B。
> ② `pin`:**编译期常量** `ONEWIRE_PINS[下标]`,设备不会变 ⇒ 改为上线时在 `status` 报一次,
> 服务端缓存进 `iot_device.pins`,App 从 `deviceConfig` 的 `pins` 取(每启用位置省 ≈8B)。
> ⚠️ 已烧录的 ≤1.6.2 固件仍会带这两个键 ⇒ **两条路都要留着**:
> `pin` 仍在 `normalizePositions()` 里解析(`ps.pin > 0` 就写进 `lastPins`),
> 老固件照旧能用;新固件则靠 `applyPins()` 从 `deviceConfig.pins` / `status.pins` 补。
各版式的解析统一在 `normalizePositions()` / `normalizeProbes()` 里归一化 ——
设备不升级也能继续用,反过来设备升到 v1.7.0 后 App 也不必同时改:
`iv`(旧名 `interval`;≤1.5.0 也曾叫 `iv`)与 `cv`(旧名 `cfg_ver`)一律「新键优先、旧键兜底」,
**不要再按版式分支取这两个键** —— 旧写法 `hasNew ? obj['interval'] : obj['iv']` 在 v1.7.0 下会取到 0。
| 字段 | 含义 | App 处理 |
|------|------|---------|
| `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C);**已无 `index` 字段**;`null` = 该位置未启用 | **用数组下标匹配骨架**(不再读 `index`);`null` 元素直接跳过;`pos` 取不到时回落 `positions` |
| ~~`pin`~~ | 该总线实际 GPIO(固件 `ONEWIRE_PINS[下标]`);**纯属性,不是身份** | ⚠️ **v1.7.0 起 state 不再带**。仅展示(如 `GPIO4 ·`),**不要用它做匹配键**。App 用 `lastPins` 攒住,两个来源:`deviceConfig.pins`(v1.7.0+,见 `applyPins()`)与老固件 state 里的 `pin`;重拉配置后靠它补回 |
| `t` | 该位置温度(有效探头算术平均);`null` = 无有效读数 | 显示 2 位小数;`null` 显示「--(无有效读数)」,**不要显示 0** |
| `df` | 位置内极差(`max-min`,探头侧算好);`null` 同 `t` | 显示;`gap>0` 且 `df>=gap` 视为告警 |
| `pr[]`(旧名 `probes`) | 该总线上各探头;**数组下标 == 总线槽位**,尾部空槽已截(空总线 `[]`);元素是**二元组 `[身份, 温度]`** | 逐探头明细,统计 `x/y 有效`;`pr` 取不到时回落 `probes` |
| `pr[][0]` | 探头身份(ROM 短码,如 `"A3F2"`);`null` = 未识别 | 显示时带上(`A3F2 45.5`),便于跨时间追踪同一颗 |
| `pr[][1]` | 该槽温度;`null` = 无读数。**配合身份可区分两种故障** | 见下方说明 |
| `ts` | NTP Unix 秒;`0` = 未对时 | 历史时间轴优先用它,为 0 时用手机接收时间兜底 |
| `iv`(旧名 `interval`) | 本条数据覆盖的秒数(距上次上报) | **前向填充**:温度值有效期为 `[ts-iv, ts]`,误差上界 = 温变阈值 |
| `cv`(旧名 `cfg_ver`) | 设备当前生效的配置版本 | 与本地不一致 → 重新拉一次 `deviceConfig`(HTTP 字段名仍是 `cfg_ver`,别混) |
> **身份与温度解耦(v1.5.0 的关键修复)**:身份只要 ROM 枚举到就会上报,与读数成败无关。
> 于是「该槽没插器件」= `[null, null]`,「插了但读数失败(接触不良/供电)」=
> `["B71C", null]`,一眼可分。旧版 `ok[j]=false` 时 `ids[j]` 也被一并抹成空串,
> 这两种情况在报文上完全一样,只能连串口才能判断。
>
> 🚨 **`[null, 24]`(有读数、没身份)不是 App 解析问题**,是固件取不到 ROM 短码:
> ①该片序列号 6 字节全 0(克隆/翻新片,无唯一身份,只能按槽位区分);
> ②固件 < v1.6.1 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码会把身份**永久锁死**为
> `null`(温度照常、重启也不恢复),v1.6.1 改为每采样周期自己搜一遍总线后已修。
> App 侧兜底:身份为空时显示槽位号 `P1/P2…`,不要显示空白(否则看着像 App 的 bug)。
>
> 另注:`t` 是位置级「有效探头均值」,**不要**用 `t == 0` 判无效——`0.00℃` 是
> DS18B20 的合法读数(冰水混合物),判无效只看是否为 `null`。
**更早的三个版式对照**:
```json
// v1.6.0:位置化数组(下标即位置序号)+ 二元组探头,键名写全
{ "uuid":"A1B2C3D4E5F6", "ts":1789000000, "ver":"1.6.0", "rssi":-57,
"interval":120, "cfg_ver":7,
"positions":[ {"pin":3,"t":45.63,"df":0.25,
"probes":[["A3F2",45.50],["B71C",null]]}, null ] }
// v1.5.0:键名写全、按 index 对齐
{ "positions":[ {"index":0,"pin":3,"temp":45.63,"temp_diff":0.25,
"probes":[{"id":"A3F2","temp":45.50},{"id":"B71C","temp":null}]} ] }
// ≤1.4.0:1~2 字母键 + v 标志 + 三个平行数组
{ "data":[ {"i":0,"t":45.63,"df":0.25,"v":true,
"vs":[45.50,45.75,0], "ok":[true,true,false], "ids":["A3F2","B71C",""]} ],
"rssi":-57,"ts":1789000000,"iv":120,"cfg_ver":7,"ver":"1.4.0" }
```
判定版式**只看键在不在,不看 `ver` 字符串**(版本号可能忘了升,键不会撒谎):
1. 有 `pos` 或 `positions` 键 → 新式(位置化数组);否则是 ≤1.4.0 的 `data[]`;
2. 新式里再分:位置元素含 `index` / `temp` / `temp_diff` 任一 → **v1.5.0**;
都没有 → **v1.6.0 / v1.7.0**(这两者只差键名:`pos`/`pr` vs `positions`/`probes`);
3. 位置元素可能是 JSON `null` → **必须先判空再取字段**,别直接访问属性;
4. 探头明细:`pr`(v1.7.0)与 `probes`(≤1.6.2)**同构**(都是二元组)→ `pr` 优先、`probes` 兜底。
⚠️ **`t` 是同名陷阱**:v1.6.0 的 `t` 与 ≤1.4.0 的 `t` 同名但语义不同 ——
旧版靠 `v` 标志判有效(`v=false` 时 `t` 照样带 `0`),新式靠 `null` 判。
所以解析顺序必须是「先判有没有 `v`」,否则会把「没读数」画成 `0.00℃`。
### 8.4 上报节奏(为什么可以低频又不丢实时性)
采样仍是 **10s**(告警判定实时性由它保证),上报改为 **温变触发 + 120s 兜底**:
① 任一有效探头 `|Δ| ≥ 0.3℃` ② 探头有效性掩码变化 ③ 告警状态翻转 ④ 距上次发送 ≥ 120s。
另:开机/重连、收到新配置后立即发一条。
实测流量:135MB/月 → ≈9.6MB/月(降 93%)。
### 8.5 历史数据(App 本地落库)
服务端不存历史。App 在收到 state 后按天分片写入本地存储:
- key:`iot_hist_{uuid}_{YYYYMMDD}`
- 行:`ts,i,t,df,okCount,total,valid`(`\n` 分隔,便于直接画曲线)
- 保留 7 天;同一 `ts+i` 会去重(retained 报文重连时会被重投)
- 容量:温变上报约 1000 条/天/位置,一行 ≈24B → 单日约 25KB,7 天可忽略
### 8.6 固件侧要点(易踩坑)
1. `onConfigJson` 是**全量覆盖**语义:`data` 里没有的位置一律停用,改完写 NVS 并回 `config_ack`。
2. `PubSubClient` 回调的 `payload` **不是 NUL 结尾**,必须 `memcpy` 到自有缓冲区再补 `'\0'`。
3. 报文用 `DynamicJsonDocument(768)`,不要用 `StaticJsonDocument`(带探头明细会 NoMemory)。
4. LWT 与告警**分主题**(`/status` vs `/event`),两者都 retained,混用会互相覆盖。
5. 设备 ACL 是 `auto:device:{uuid}`(`/devices/%u/#`)覆盖全部子主题,改协议新增主题无需改 ACL。
6. 会员侧 ACL 是**生成时快照**:新增自动规则后,老设备需自愈——App 每次换 ticket 时服务端会自动重建(`DeviceService::resyncMemberAcl` + `ShareService::resyncMember`)。
+162
View File
@@ -0,0 +1,162 @@
# 附件管理(上传 / 查询 / 删除 / 存储扩展)
统一的上传-附件闭环:上传接口负责收文件,`FileStorageService` 自动写入附件表,提供分页查询与删除接口;存储层通过**事件钩子**可扩展 OSS / FTP 等自定义存储,业务代码无需改动。
## 接口一览(均需登录)
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/v1/common/upload` | POST | 上传文件(multipart,字段 `file`),成功自动落库 |
| `/api/v1/attachment/list` | GET | 我的附件分页(可 `module` 过滤) |
| `/api/v1/attachment/delete` | POST | 删除附件(仅本人),物理删文件 + 软删记录 |
### 1. 上传
```
POST /api/v1/common/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
```
| 参数 | 说明 |
|------|------|
| `file` | 上传的文件(必填) |
默认校验:大小 ≤ 10MB,后缀白名单 `jpg,jpeg,png,gif,webp,bmp,mp4,mov,mp3,wav,zip,rar,doc,docx,xls,xlsx,pdf,txt`。
成功响应:
```json
{
"code": 0,
"msg": "上传成功",
"data": {
"url": "https://demo.ywxapp.cn/storage/2026/08/26/xxxx.jpg",
"path": "2026/08/26/xxxx.jpg",
"name": "xxxx.jpg",
"size": 102400
}
}
```
### 2. 查询(我的附件分页)
```
GET /api/v1/attachment/list?page=1&size=20&module=api
```
| 参数 | 默认 | 说明 |
|------|------|------|
| `page` | 1 | 页码 |
| `size` | 20 | 每页条数(上限 100) |
| `module` | - | 模块过滤(可选,如 `api` / `forum` / `chat`) |
响应:`data.list`(附件数组,含 `id/name/url/path/size/ext/mime/is_image/module/create_at` 等)、`data.total`(总数)。
### 3. 删除
```
POST /api/v1/attachment/delete
body: { "id": 1 }
```
- 仅能删除**自己**的附件(`uid` 归属校验)
- 流程:先物理删除文件(失败不阻断,`data.deleted_file=false` 提示)→ 软删记录(`delete_at`)
- 成功响应:`{ "code": 0, "msg": "删除成功", "data": { "deleted_file": true } }`
## 附件表 `common_attachment`
| 字段 | 说明 |
|------|------|
| `uid` | 上传者(会员 uid,未登录 0) |
| `module` | 模块标识(`api` / `forum` / `chat` …) |
| `name` / `original` | 存储文件名 / 原始文件名 |
| `path` / `url` | 相对路径 / 可访问 URL |
| `size` / `ext` / `mime` | 大小 / 后缀 / MIME |
| `storage` / `driver_info` | 存储驱动标识 / 驱动附加信息(JSON) |
| `is_image` / `width` / `height` | 图片标记与尺寸 |
| `delete_at` | 软删除时间(0=未删,模型启用 SoftDelete) |
表结构自愈:模型 `ensureSchema()` 会自动补 `delete_at` 列(旧库无需手工 ALTER)。
## 事件钩子(存储扩展)
所有上传/删除统一走 `FileStorageService`,提供 4 个事件:
| 事件 | 阶段 | 返回约定 |
|------|------|----------|
| `FileUpload` | 上传前(分发) | 监听并**返回结果数组**即接管上传(OSS/FTP) |
| `FileUploaded` | 上传成功后(通知) | 无需返回,用于缩略图/水印/审计 |
| `FileDelete` | 删除文件前(分发) | 监听并**返回 true** 即接管物理删除 |
| `FileDeleted` | 删除成功后(通知) | 无需返回 |
> 中断式事件(`Event::trigger(..., true)`):必须返回**非空数组 / true** 才视为接管;空数组会被当作未接管,回落内置驱动。
### 接管上传(OSS / FTP 示例)
```php
use think\facade\Event;
Event::listen('FileUpload', function (array $payload) {
$file = $payload['file']; // \think\file\UploadedFile
$path = $payload['path']; // 目标路径
// --- 调用你的 OSS / FTP SDK 上传,拿到远端地址 ---
$remotePath = 'uploads/' . $path;
$oss->put($remotePath, file_get_contents($file->getRealPath()));
// 返回数组即接管成功(键 name/path/url/storage 写入附件表)
return [
'name' => basename($remotePath),
'path' => $remotePath,
'url' => 'https://cdn.example.com/' . $remotePath,
'storage' => 'alioss',
];
});
```
### 上传成功后扩展(缩略图 / 水印)
```php
Event::listen('FileUploaded', function (array $data) {
// $data: {id,module,original,name,path,url,size,ext,mime,storage,is_image,width,height,...}
if (!empty($data['is_image'])) {
// 生成缩略图、加水印、CDN 刷新……
}
});
```
### 接管物理删除
```php
Event::listen('FileDelete', function (array $payload) {
// 删除 OSS / FTP 上的 $payload['path']
$oss->delete($payload['path']);
return true; // 必须返回 true 表示已接管
});
```
## 后台统一管理
系统管理 → **附件管理**(`app/backend/controller/Attachment.php`,权限 `attachment:index|delete`,首次访问自动补录):
- 全站附件一览(不分模块 / 不分上传者),可按文件名、路径、模块检索,可只看图片;
- 顶部概览:附件总数、占用空间、图片数;
- 删除走 `FileStorageService::delete()` → 触发 `FileDelete` 事件(OSS/FTP 在此接管)→ 再软删记录,
所以后台删附件和 API 端删附件走同一条链路,不会出现「记录没了文件还在」。
### 孤儿文件清理
「孤儿」= 磁盘上有、附件表里没有记录的文件(落库失败、记录被硬删、手工拷贝都会留下)。
```bash
php think attachment:clean # 只统计,列出前 20 个(安全)
php think attachment:clean --delete # 真正删除
php think attachment:clean --days=30 # 只处理 30 天前的文件
```
删除同样走 `FileStorageService::delete()`,自定义存储由 `FileDelete` 事件接管。
## 内置存储驱动
默认走 `config/filesystem.php` 的 `storage` 驱动(本地磁盘,落盘 `public/storage/`)。新增驱动:实现 `ywxapp\utils\storage\StorageInterface`(`upload()` 返回 `[name, path, url]`、`delete()` 返回 bool),注册到 `config/filesystem.php` 的 `storages` 列表后即可通过配置切换。
+57
View File
@@ -0,0 +1,57 @@
# 数据表管理(可视化建表)
系统管理 → **数据表管理**(`/admin/datatable/index`)。和「数据库备份」是两件事:
那边管数据(备份 / 还原),这边管结构(建表 / 看结构 / 优化 / 删表)。
## 能做什么
| 功能 | 入口 | 说明 |
|------|------|------|
| 表列表 | 页面表格 | 表名、备注、引擎、行数、大小、字符集;支持关键字搜索与分页 |
| 新建表 | 顶部「新建表」 | 可视化字段设计 → **生成 SQL 预览** → 确认后执行 |
| 查看结构 | 行内「结构」 | 字段(类型 / 可空 / 默认值 / 键 / 注释)+ 完整建表语句,可复制 |
| 优化表 | 行内「优化」/ 顶部「优化选中」 | `OPTIMIZE TABLE` |
| 删除表 | 行内「删除」 | 需手动输入表全名二次确认;核心表直接禁止 |
建表成功后页面会给出下一步命令:
```bash
php think addon:crud <插件> -t <表名,不含前缀>
```
## 字段表单说明
每行一个字段:字段名、类型、长度 / 精度、默认值、注释,以及
无符号 / 可空 / 自增 / 主键 / 索引(无 / 普通 / 唯一)五个开关。
- 类型走白名单(21 种常用类型),整型不输出显示宽度(MySQL 8 已废弃);
- `varchar` 默认长度 255、`decimal` 默认 `10,2`;
- 默认值 `NULL`、`CURRENT_TIMESTAMP`、数字字面量不加引号,其余按字符串处理;
- 自增字段必须同时是主键,且只能有一个(MySQL 硬性约束,表单提交前就拦下)。
## 安全约束
DDL 不可逆,这里比普通 CRUD 保守得多:
1. **白名单拼装**:表名、字段名、类型、长度、引擎、字符集、排序规则全部白名单校验,
用户输入的字符串一个字符都不会进入 SQL 语法结构;注释与默认值只做转义。
2. **只新建,不覆盖**:表已存在直接中止,绝不做 `DROP + CREATE`。
3. **删表双重闸**:必须回传与表名完全一致的确认串;`backend_admin` /
`backend_power` / `backend_role` / `backend_group` / `backend_log` /
`backend_menu` 列入保护名单,拒绝删除。
4. **全程留痕**:建表(含失败)、删表、优化都写 `backend_log`,
在「操作日志」里按 `datatable/create`、`datatable/drop` 可查。
## 权限节点
`datatable:index | create | structure | optimize | drop`,与菜单一同在首次访问时
由 `MenuRegistry`(`app/backend/library/MenuRegistry.php`)自动补录,无需手工加节点。
## 相关文件
| 文件 | 作用 |
|------|------|
| `app/backend/controller/Datatable.php` | 控制器:列表 / 结构 / 预览 / 建表 / 删表 / 优化 |
| `app/backend/view/datatable/index.html` | 列表页 + 建表弹窗 + 结构弹窗模板 |
| `public/static/backend/modules/datatable.js` | 表格渲染、字段行增删、SQL 预览与执行 |
| `app/backend/route/app.php` | `Route::group('datatable', ...)` |
+274
View File
@@ -0,0 +1,274 @@
# 设备注册(MQTT `system/device/register/+`)
设备通过 MQTT 主题向服务端注册入网,领取正式连接凭证(uuid/secret)与订阅主题清单;用户随后用**设备识别码**绑定设备获取设备信息与面板类型。
支持两种协议:
1. **MAC 即插即用注册(推荐)**:设备上电自动上报 `mac/type/model/ver`,服务端自动入网,无需后台预登记。
2. **华为式预登记注册(旧协议,兼容)**:设备出厂预置 **SN + activate_token**,后台登记后激活。
## 主题约定
| 主题 | 方向 | 说明 |
|------|------|------|
| `system/device/register/{sn}` | 设备 → 服务端 | 设备发布注册/激活请求 |
| `system/device/register/{sn}/resp` | 服务端 → 设备 | 注册回执(凭证 + 订阅主题 / 错误原因) |
ACL:上述主题**内置放行**(`Auth::checkAcl` 对 `system/device/register/{sn}` 发布与 `.../resp` 订阅始终放行,不依赖 ACL 开关),设备可在激活前匿名/以 SN 连接完成注册。
---
## MAC 即插即用注册(推荐,ESP32 等设备直连入网)
设备无需后台预登记:上电后把自身信息发布到注册主题,服务端**自动创建设备**并发放凭证。
### 主题
| 主题 | 方向 | 说明 |
|------|------|------|
| `system/device/register/{mac}` | 设备 → 服务端 | 注册请求(`{mac}` = 设备 MAC 去冒号小写,12 位 hex,如 `a0b1c2d3e4f5`) |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | 注册回执(凭证 + 订阅主题 / 错误原因) |
ACL:上述主题**内置放行**,设备可在未激活前以任意 clientId(建议直接用 mac)连接并完成注册。
### 注册消息(设备上电 publish,建议 retained)
```json
{
"mac": "a0b1c2d3e4f5",
"type": "esp32c3_temp",
"model": "KF-C3-3KANG",
"ver": "1.0"
}
```
| 字段 | 值 | 作用 |
|------|------|------|
| `mac` | 设备 MAC 去冒号小写 | 唯一身份证,服务端靠它区分设备、做绑定(须与主题中的 mac 一致,否则拒绝) |
| `type` | `esp32c3_temp` | 设备类型,服务端落库为 `dev_type`,App 据此渲染面板(炕温控器/插座/灯…) |
| `model` | `KF-C3-3KANG` | 产品型号,后台管理区分硬件版本 |
| `ver` | `1.0` | 固件版本,服务端记录,用于版本检测/OTA 提示 |
> **retained 说明**:服务端收到 publish 即处理(不依赖 retained 标志)。retained 的作用是让**之后订阅**该主题的客户端也能看到设备注册信息。回执 `.../resp` **不会 retained**(防止 secret 泄露给后来的订阅者),设备若未收到回执应延时重发注册消息(例如 5s/30s/5min 退避)。
### 处理流程
```
① 设备联网:订阅 system/device/register/{mac}/resp
② 设备发布(retained):system/device/register/{mac},payload 含 mac/type/model/ver
③ 服务端:mac 首次出现 → 自动创建设备(sn=mac)并生成 uuid/secret,写入 iot_device
④ 服务端回执:{uuid, secret(仅首次), dev_type, model, ver, topics}
⑤ 设备保存凭证,改用 clientId=uuid / username=uuid / password=secret 连接
⑥ 设备按 topics 订阅 /devices/{uuid}/#,收发状态与命令
```
再次上报(设备重启 / 固件升级后):
- mac 已存在 → 幂等更新 `type/model/ver`,**不重置凭证**,回执不带 secret;
- 固件 `ver` 变化自动同步到设备记录,后台/OTA 据此判断是否需要推送新固件。
### 回执 payload(成功)
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1, "sn": "a0b1c2d3e4f5", "mac": "a0b1c2d3e4f5",
"uuid": "dev_8f3a...",
"dev_type": "esp32c3_temp", "model": "KF-C3-3KANG", "ver": "1.0",
"secret": "明文凭证(仅首次注册返回)",
"activated": true, "bound": false,
"topics": {
"state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd",
"ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report",
"event": "/devices/dev_8f3a.../event"
}
}
}
```
失败(`code=1`)示例:`{"code":1,"message":"payload mac 与主题不一致","data":null}`、`{"code":1,"message":"mac 格式非法(应为12位十六进制,去冒号小写)","data":null}`。
### 用户绑定(App 扫码/输 mac)
```
POST /iot/api/client/bindDevice (需登录)
body: { "sn": "a0b1c2d3e4f5", "title": "炕温控器" }
```
MAC 自动注册的设备**无配对码**(扫码即绑,先到先得);如需防抢绑,可在后台设备详情为该设备补设 `pair_code`,此后绑定须携带该码。
绑定成功返回 `mac / dev_type / ver` 与 `topics`:App 用 `dev_type` 查「设备类型 → 面板」映射表渲染对应控制面板,用 `ver` 与后台最新固件比对触发 OTA 提示。
---
## 华为式预登记注册(旧协议,兼容)
```
① 后台预登记设备:生成 sn + pair_code(贴纸)+ activate_token(出厂预置)
② 设备联网:订阅 system/device/register/{sn}/resp
③ 设备发布:system/device/register/{sn},payload 见下
④ 收到回执:uuid / secret / topics → 保存凭证
⑤ 使用凭证连接 MQTT:clientId=uuid,username=uuid,password=secret
⑥ 按 topics 订阅 /devices/{uuid}/# 收发状态与命令
```
### 请求 payload(②③)
```json
{
"token": "b5d3...32位hex激活令牌"
}
```
也兼容裸 token 字符串形式(直接以 token 作为 payload 发布)。
### 回执 payload(④)
成功(`code=0`):
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"sn": "SN20260826",
"uuid": "dev_8f3a...",
"title": "智能灯泡",
"model": "LAMP-01",
"secret": "明文凭证(仅激活成功这一次返回)",
"activated": true,
"bound": false,
"topics": {
"state": "/devices/dev_8f3a.../state",
"cmd": "/devices/dev_8f3a.../cmd",
"ota": "/devices/dev_8f3a.../ota",
"ota_report": "/devices/dev_8f3a.../ota/report",
"event": "/devices/dev_8f3a.../event"
}
}
}
```
失败(`code=1`):`{ "code": 1, "message": "激活令牌无效或已失效", "data": null }`
幂等:已激活设备重复注册不报错,回执返回 `activated:true` 但 **不再下发 secret**(如需换凭证走 `resetSecret`)。
## 用户绑定设备识别码(App 侧)
设备激活后,用户通过 App 绑定:
```
POST /iot/api/client/bindDevice (需登录)
body: { "sn": "SN20260826", "pair_code": "123456", "title": "客厅灯" }
```
绑定成功返回(含订阅主题与设备信息):
```json
{
"code": 1,
"data": {
"id": 1, "sn": "SN20260826", "uuid": "dev_8f3a...",
"title": "客厅灯", "model": "LAMP-01", "room": "",
"bound": true, "activated": true, "secret": "",
"topics": { "state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd", "ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report", "event": "/devices/dev_8f3a.../event" }
}
}
```
> 注:插件 API 的 `Result::success` 用 `code=1` 表示成功(与框架 `code=0` 相反),解析时以 `message` 键为准。
绑定后 App 即可使用 `uuid` 连接 MQTT 推送(WebSocket `ws://{host}:8083`)或通过 REST 接口下发命令。
## 其他相关接口
| 接口 | 说明 |
|------|------|
| `POST /iot/api/deviceActivate` | HTTP 方式激活(`sn` + `token`),与 MQTT 注册等效,适合无 MQTT 客户端的设备 |
| `POST /iot/api/resetSecret` | 重置设备密钥(返回新明文,旧密钥作废) |
| `GET /iot/api/myDevices` | 我的设备列表(主人 + 共享,含在线状态/影子) |
| `POST /iot/api/sendCommand` | 向设备下发命令(写入 `/devices/{uuid}/cmd`) |
| `POST /iot/api/deviceLogs` | 设备消息流水 |
## 设备侧配网(ESP32 固件 `water_temp.ino`)
现场部署**不再需要**为了改 WiFi 重烧固件(OTA 又必须先联网,过去是死锁)。固件用 `WiFiManager` 自带配网页,**v1.6.2 起表单里只有 WiFi**(服务器与端口已写死在固件里,见下节):
1. 上电先用**已保存凭据**连网(`WIFI_CONNECT_S` = 15s),连上即正常跑,不会打扰用户;
2. 连不上(首次上电 / 换路由器 / 改密码)→ 自动开配网热点
`TempMonit-Esp32<MAC后4位>`(默认密码 `12345678`,见 `WIFI_AP_PASS`);
3. 手机连热点 → 浏览器打开 `http://192.168.4.1`(Android 会自动弹配网页)
→ 表单**只填 WiFi** → 保存 → 设备自动重启生效;
4. 热点 180s(`WIFI_PORTAL_S`)无人操作自动关闭,回后台继续重试 —— 要重新配网请断电重上电。
### 服务器地址:编译期写死 + 备用机自动切换(v1.6.2)
| 项 | 位置 | 说明 |
|---|---|---|
| 服务器表 | 固件常量区 `SRV_HOSTS[]` | 按 **主 → 备** 顺序填,只填域名或 IP(表尾空串/`nullptr` 即结束) |
| 端口 | `DEF_MQTT_PORT` = `1883` | **写死**,所有服务器共用(配网页已无端口输入框) |
| 切换阈值 | `SRV_FAILOVER_N` = `3` | 当前这台连续失败 3 次(5s/次 ⇒ 约 15s)切下一台,走到表尾回到第 0 台,永不卡死 |
| 切换条件 | 仅「网络层」失败 | `-2/-3/-4/3`(TCP 连不上/超时/被断/服务端不可用)才切;**CONNACK 1~5 是「服务器已应答、只是拒绝」(凭据/ACL 问题),换台毫无意义** |
默认表(按现场改这几行即可):
```c
const char* SRV_HOSTS[SRV_MAX] = {
"mqtt.ywxapp.cn", // 0 主:域名(正常走这条)
"211.149.215.200", // 1 备:同机 IP 直连 —— 免 DNS(解析失败/被劫持时仍能连上)
// "mqtt2.ywxapp.cn", // 2 备:真·第二台服务器(有独立机器时填它的域名或 IP)
};
```
- 串口:开机打印整张表(`[SRV] ... <- 当前`),心跳行带 `srv=`,切换时打
`[SRV] 连接连续失败:A → B(第 2/2 台)` —— 排障一眼看清在用哪台、切过没有。
- NVS 里的 `mqtt_host` / `mqtt_port`(≤v1.6.1 写过的)**一律不再读取**;遗留键不擦除(省一次 flash 写)。
- ⚠️ 换服务器 / 加备用机 = 改表后**重新编译烧录**;现场设备不需要任何配置,也就没有填错的可能。
- 注册流程同样参与切换(主服务器挂掉时,新设备也必须能注册上)。
**远程清空重配**(设备还连得上时最方便,App 用 `sendCommand` 把 payload 原样发下去即可):
```json
{"action": "wifi_reset"}
```
设备先回 `ack`,随后抹掉 WiFi 凭据并重启 → 开机连不上 → 自动进配网热点。
⚠️ **只清 WiFi 凭据**:`uuid` / `secret` / 传感器配置全部保留(服务器写死在固件里,不受影响),不需要重新注册。
### 清除身份 / 恢复出厂(固件 v1.3.3+,BOOT 键)
⚠️ **背景(重要)**:后台「删除设备」是**硬删** —— `DeviceService::remove()` 会清掉
`iot_device`、`iot_auth`、`iot_acl`、`iot_retain`、`iot_topic`、`iot_offline`、
分组关联 / 共享关系 / 传感器配置。**但设备端 NVS 里的 `uuid`/`secret` 不会被清**,于是:
> 设备拿旧凭据去连 → `iot_auth` 里已无此用户 → CONNACK 4/5 被拒;
> 而注册流程只在「`uuid` 为空」时才启动 → **设备永久爬不起来**。
> 现场表现:串口一直 `[MQTT] fail rc=4`,后台也永远不会再出现该设备。
固件 v1.3.3 起提供设备端「清除身份」入口,**现场无需拆机重烧**:
| 操作 | 清除内容 | 结果 |
|---|---|---|
| **按住 BOOT 键 3 秒后松手** | `uuid` / `secret` / 传感器配置 | 重启 → 自动重新注册,拿全新凭证;**WiFi 与服务器地址保留,不用重新配网** |
| **按住 BOOT 键 8 秒不松** | 上述 + WiFi 凭据(彻底恢复出厂) | 重启 → 进配网热点 `TempMonit-Esp32<MAC后4位>` |
- **键位**:ESP32-C3 = `GPIO9`,经典 ESP32 = `GPIO0`(改固件宏 `BTN_PIN`);固件里的
`ONEWIRE_PINS`(当前 `{3,4,10}`)不要与它撞脚。
- **上电时已按住** 也生效(恢复出厂的标准姿势),无 3s 内的时机要求。
- 串口日志以 `[BTN]` / `[RST]` 开头,会在按满 3s 时提示"现在松手=清身份 / 继续按住=全清"。
- **护栏**:开机按住超 15s(`BTN_ABNORMAL_MS`)判定为引脚异常并放弃清除;运行期必须先
观察到一次「松开」才允许触发(`armed` 标志),引脚恒低的板子不会被反复恢复出厂。
**远程等价命令**(设备仍在线时最方便,发到 `/devices/{uuid}/cmd`):
```json
{"action": "clear_identity"}
```
```json
{"action": "factory_reset"}
```
⚠️ 重新注册后拿到的是**全新 uuid**,后台会新增一条设备(`member_id=0` 未绑定),
需在 App / 后台**重新绑定**;旧 `iot_device_log` / `iot_alert` 中按旧 uuid 存的历史不会自动迁移。
+519
View File
@@ -0,0 +1,519 @@
# haonav 网址导航 — 开发文档
> 面向开发者。站长使用说明见 [haonav_user_guide.md](haonav_user_guide.md)。
- 命名空间:`addon\haonav\`
- 版本:1.0.9(`addon/haonav/info.php`)
- 路由前缀:`/haonav`(由 `AppService::loadAddonRoutes()` 在 boot 阶段统一包裹)
---
## 1. 目录结构
```
addon/haonav/
├── Addon.php 插件生命周期钩子
├── info.php 插件元信息(版本/入口/事件)
├── common.php 插件公共函数
├── route/app.php 路由表(相对规则,自动加 /haonav 前缀)
├── command/
│ └── CheckLinks.php 死链检测命令行
├── controller/
│ ├── Index.php 前台骨架控制器(12 个 trait 组合)
│ ├── Account.php 会员登录/注册/资料
│ ├── Favorite.php 云端收藏 + 分享
│ ├── Task.php 计划任务入口(checklinks / autocheck)
│ ├── Widget.php 小组件代理(热搜/天气)
│ ├── trait/ ★ 前台控制器按职责拆分(12 个)
│ │ ├── BaseTrait.php 配置/SEO/用户/广告/点击统计/静态资源输出
│ │ ├── HomeTrait.php index + rank
│ │ ├── SearchTrait.php search + suggest + rate
│ │ ├── SiteDetailTrait.php site 详情(JSON-LD)
│ │ ├── CategoryTrait.php category
│ │ ├── ApplyTrait.php submit + apply
│ │ ├── SitemapTrait.php sitemap 双格式
│ │ ├── ReadMetaTrait.php read
│ │ ├── FaviconTrait.php favicon 代理输出
│ │ ├── SnapshotTrait.php 网站快照
│ │ ├── SwTrait.php PWA Service Worker 输出
│ │ └── AdTrait.php adClick 广告点击跳转
│ └── backend/ 后台控制器
│ ├── HaonavBackend.php 后台基类
│ ├── Dashboard.php 数据概览 + 拼音回填
│ ├── Links.php 链接管理 + 回收站
│ ├── Category.php 分类管理
│ ├── Configs.php 配置管理
│ ├── Ad.php 广告位
│ └── Apply.php 申请审核
├── model/
│ ├── Links.php / Category.php / Configure.php / Ad.php / Apply.php
│ ├── Favorite.php / FavoriteShare.php
│ └── ...
├── library/Pinyin.php 拼音转换
├── validate/ 验证器(Category / Link)
└── view/frontend/ 前台视图
├── index/ 7 个页面
│ ├── index.html / search.html / rank.html / detail.html
│ ├── category.html / apply.html
├── favorite/shared.html 收藏分享页
└── public/ 公共 include
├── styles.html ★ CSS 入口壳(见 §5 陷阱)
└── navbar.html 顶部导航
public/static/haonav/
├── css/
│ ├── nav-base.css 全局重置/变量/卡片/响应式(全页面)
│ ├── nav-theme.css 主题面板 + 背景遮罩层
│ ├── nav-festival.css 节日粒子/徽章
│ └── nav-page-{index|category|detail|apply|share}.css 页面专属
├── js/
│ ├── nav-core.js $ / $all / safeStorage / ready 基础设施
│ ├── nav-theme.js 主题/背景/主色引擎 + 面板
│ ├── nav-festival.js ★ 节日/节气自动换装引擎(36 项)
│ ├── nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js / auth.js
├── sw.js Service Worker(缓存版本化)
├── manifest.json PWA manifest
└── img/ 默认图标等静态资源
```
---
## 2. 路由表(addon/haonav/route/app.php)
> 规则**不含** `/haonav` 前缀(外层 group 自动包裹)。URL 生成用 `url('haonav/xxx')` 或 `url('[haonav_site]', ['id' => $id])`,**不要写 `haonav/index/xxx` 三段式**(MultiApp 会剥首段导致对不上)。
### 前台
| 规则 | 控制器@方法 | name | 说明 |
|---|---|---|---|
| `index` | Index@index | haonav_index | 首页 |
| `category/:id` | Index@category | haonav_category | 分类页 |
| `site/:id` | Index@site | haonav_site | 详情页 |
| `search` / `suggest` / `rate` | Index@* | — | 搜索/建议/评分 |
| `rank` | Index@rank | — | 排行 |
| `submit` / `apply` | Index@* | — | 投稿 |
| `favicon` / `snapshot` | Index@* | favicons | 图标/快照代理 |
| `sw` | Index@sw | — | SW 输出 |
| `ad/click` | Index@adClick | — | 广告点击 |
| `sitemap(.xml)` | Index@sitemap | — | 站点地图 |
| `account/{login\|register\|logout\|profile}` | Account@* | — | 会员 |
| `widget/{hot\|weather}` | Widget@* | — | 小组件代理 |
| `favorite/{list\|add\|remove\|sort\|merge}` | Favorite@* | — | 云端收藏 API |
| `favorite/share` / `sharesave` | Favorite@* | — | 分享配置(登录) |
| `favorite/shared/:token` | Favorite@shared | — | 公开分享页 |
| `task/checklinks` / `task/autocheck` | Task@* | — | 巡检(需 token) |
### 后台(backend 组)
`dashboard / links / category / configs / ad / apply` + 各自 `:action` 子规则。
### 2.1 URL 生成规范(重点)
YwxApp 提供 **3 种**生成插件 URL 的方式,对应不同场景:
#### ① `url()` ThinkPHP 内置助手(推荐,name 化)
```php
// 方式 A:直接路径(规则须全字符匹配)
url('haonav/index') // → /haonav/index.html
url('haonav/category/5') // → /haonav/category/5.html
// 方式 B:用 route name(路由表里 ->name() 声明的)—— 带参数规则必须用 name
url('[haonav_site]', ['id' => 5]) // → /haonav/site/5.html
url('[haonav_category]', ['id' => 5])// → /haonav/category/5.html
// 附加 GET 参数
url('haonav/search', ['q' => 'haonav']) // → /haonav/search.html?q=haonav
```
**模板语法**:
```html
<a href="{$d.url|default=''}">...</a> <!-- 取控制器 assign 的 url 字段 -->
<a href="{:url('haonav/site', ['id' => $info.id])}">详情</a>
<a href="{:url('[haonav_site]', ['id' => $info.id])}">详情</a> <!-- 推荐 -->
```
#### ② `addon_url()` 框架插件助手(ywxapp/helper.php)
```php
addon_url('haonav', 'site/5'); // → /haonav/site/5
addon_url('haonav', '', ['page' => 2]);// → /haonav?page=2
addon_url('haonav', 'admin/index'); // → /haonav/admin/index
```
适用:跨插件、动态拼接、不依赖路由 name 的场景。
#### ③ 直接拼接(不推荐,仅 static 资源)
```php
// 仅限静态资源、JS 接口路径等无需经过路由解析的链接
'/static/haonav/css/nav-base.css'
```
#### ⚠️ 三大常见错误
| 错误写法 | 后果 | 正确 |
|---|---|---|
| `'haonav/index/rank'`(**三段式**) | MultiApp 剥首段后变成 `index/rank` → 对不上路由 → 404 | `'haonav/rank'`(两段:插件名+规则) |
| `url('haonav/category/5')`(带参数裸写) | 路由表规则 `category/:id` 必须有 name 才能把 5 填进路径段,否则退化为 `?id=5` | `url('[haonav_category]', ['id' => 5])` |
| `'addon_url'('haonav', '/index')`(重复斜杠) | 会生成 `/haonav//index`(无害但脏) | `addon_url('haonav', 'index')` |
#### 绑定域名下的 URL 行为
`url()` 和 `addon_url()` 都依赖 `request()->domain()`,**绑定域名后自动用绑定域名生成**:
```
未绑定:example.com/haonav/index.html
绑定后:hao.example.com/haonav/index.html(自动)
```
无需在模板里手写域名。
### 2.2 域名绑定机制(开发侧)
#### 配置文件 `config/app.php`
```php
'domain_bind' => [
'hao' => 'haonav', // 二级域名匹配
// 'hao.example.com' => 'haonav', // 完整域名匹配
],
```
#### 解析时序(ywxapp/middleware/MultiApp.php)
```
HTTP 请求进入
↓
1. 独立入口判断(如 haonav.php 存在 → 直接解析)
↓ 未命中
2. 域名绑定(domain_bind)
├── 完整域名命中 bind[host()] → 取 appName
├── 子域名命中 bind[subDomain()] → 取 appName
└── 通配 bind['*'] → 取 appName
↓ 未命中
3. URL 路径首段识别 → app_map / 多根查找
↓
resolve(appName) → 在 app_roots 中查找
├── prefix='app' → app/ 目录
└── prefix='addon' → addon/ 目录
```
`app_roots` 默认配置:
```php
'app_roots' => [
'app' => $app->getBasePath(), // app/
'addon' => $app->getRootPath() . 'addon/', // addon/
],
```
#### 多根查找的关键规则
- 裸 appName(如 `haonav`):按 roots 顺序找,**第一个匹配即用**(不是 fallback)
- 显式前缀(如 `addon:haonav`):**只在指定根找**,找不到直接 404
- 因此 `addon/haonav/` 存在 → 自动命中,无需额外配置
#### 调试技巧
```bash
# 1. 打印当前生效的域名绑定
php -r "echo json_encode(config('app.domain_bind'));"
# 2. 模拟请求命中哪个应用
php -r "
\$req = \think\facade\Request::create('http://hao.example.com/foo');
echo \$req->host() . PHP_EOL; // haon.example.com
echo \$req->subDomain() . PHP_EOL; // haon
"
# 3. 路由调试
访问 /haonav/test (Index::test 会 dump 当前路由表)
```
#### 完整域名 vs 子域名匹配的实际差异
```
请求:hao.example.com
完整域名模式 ('hao.example.com' => 'haonav')
host(true) = 'hao.example.com' ← 直接命中
子域名模式 ('hao' => 'haonav')
subDomain() = 'hao' ← 命中
host(true) = 'hao.example.com' ← 不会用作 key
```
两种方式**结果一致**,推荐**子域名模式**(部署同一证书多子域名复用、迁移服务器只改 DNS 即可)。
#### 绑定域名后的冗余首段兼容(框架层,MultiApp::stripBoundAppPrefix)
绑定域名后 URL **首段若与应用名相同**(即插件路径模式的旧链接,如 `docs.example.com/docs/xxx`),MultiApp 会把该段视为冗余前缀剥掉再分发——旧链接继续可用,不会 404。
```
docs.example.com/docs/xxx → 剥掉 docs → docs 应用内路由 xxx ✓(旧链接兼容)
docs.example.com/xxx → 首段 ≠ 应用名 → 不剥,直接路由 xxx ✓(新链接)
```
配套约定(以 docs 插件为参考实现):**插件前台链接生成走"动态前缀"**——
```php
// 插件前台基类(DocsFrontend::docsBase() 同款逻辑)
protected function addonBase(): string
{
// 1) 独立入口 public/<addon>.php → ''
$script = pathinfo((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''), PATHINFO_FILENAME);
if ($script === '<addon名>') { return ''; }
// 2) domain_bind 命中本插件(支持 '<addon>' / 'addon:<addon>' 两种绑定值)→ ''
$bind = app()->config->get('app.domain_bind', []);
// ... 遍历 host(true) / subDomain() / '*' 三级 key 对比
return '/<addon url 前缀>'; // 3) 路径模式 → '/docs'、'/haonav' 等
}
```
- 模板:`href="{$docsBase}/xxx"`(基类统一 assign),**禁止硬编码 `/docs/`**
- 前端 JS:layout 注入 `window.DOCS_BASE = '{$docsBase}'`,JS 用 `BASE + '/xxx'` 拼接;解析 pathname 时先剥掉 `BASE` 段
- 后台模板无需处理(后台始终走主域名路径模式)
#### ⚠️ `$this->fetch()` 返回字符串,不是 Response 对象
框架(ThinkPHP)里控制器的 `fetch()` **返回渲染后的 HTML 字符串**,因此**不能链式调用 Response 方法**:
```php
// ❌ 报错:Call to a member function code() on string
return $this->fetch('read/notfound')->code(404);
// ✅ 用 response() 包装(第 2 参为状态码,第 3 参为 header)
return response($this->fetch('read/notfound'), 404);
// ✅ 需要自定义 Content-Type 时(如 sitemap)
return response($xml, 200, ['Content-Type' => 'application/xml; charset=utf-8']);
```
同理 `$this->fetch()` 也不能直接 `->header()` / `->contentType()`,一律经 `response()` 中转。
(直接 `return $this->fetch(...)` 时框架会自动包装成 200 HTML 响应。)
---
## 3. 控制器架构
### 3.1 前台骨架模式(Index.php)
`Index` 仅是 trait 组合壳 + 路由注释,**业务全在 12 个 trait**。新增前台功能 → 新建 trait → 挂到 Index 类 + 加路由。
```php
class Index extends FrontendBase
{
protected $noNeedLogin = ['*']; // 前台全放行(游客)
protected $noNeedVerify = ['*']; // 跳过用户组校验
use BaseTrait; // 共享 helper(siteConfig/assignSeo/assignAppearance...)
use HomeTrait; use SearchTrait; ... // 按职责
protected function initialize()
{
$this->assignAppearance(); // 外观配置注入所有页面
}
}
```
### 3.2 视图根切换(框架 FrontendBase)
插件上下文(`addon\` 命名空间)自动把 `view_path` 切到 `addon/haonav/view/frontend/`。`fetch()` 钩子叠加皮肤覆盖层(SkinOverlay)、配色变量(SkinVariables,`<style id="ywx-skin-vars">`)与风格切换器注入。
### 3.3 BaseTrait 关键 helper
| 方法 | 用途 |
|---|---|
| `siteConfig()` | 基础配置数组 → assign `$config` |
| `seoConfig()` / `assignSeo()` | SEO TDK → assign `$seo` / `$siteUrl` |
| `assignAppearance()` | ★ 外观(bg_image/bg_mask/bg_random/festival)→ assign `$appearanceJson`(raw JSON) |
| `assignAds()` | 广告位分组 → assign `$ads` |
| `recordClickStat($linkId, $cid)` | (link_id, date, hour) 聚合表 `wxapp_haonav_click_stats`,UPSERT 写入失败静默 |
| `serveFile()` / `serveDefault()` | 静态资源输出(带缓存头) |
---
## 4. 配置系统与自愈机制
配置表:`wxapp_haonav_config`(name/group/title/tip/type/value/content)。
### 4.1 三层自愈(Configure 模型)
| 方法 | 作用 |
|---|---|
| `ensurePk()` | 老库 id 无自增主键 → 运行时修复(解决 1364 错误) |
| `ensureRebateConfig()` | 返利 3 项补种 |
| `ensureDeadlinkConfig()` | task 3 项补种 |
| `ensureAppearanceConfig()` | 外观 4 项补种(bg_image/bg_mask/bg_random/festival_theme) |
调用点:后台 `Configs::index()` 首屏触发,INSERT 按 name 判重幂等。**新增配置项照此模式**:模型加 `ensureXxxConfig()` → Configs::index 调用 → BaseTrait::assignXxx 注入。
### 4.2 前端消费方式
`styles.html` 输出:
```html
<script>window.NavAppearance = {$appearanceJson|default='null'|raw};</script>
```
JS 侧读 `window.NavAppearance.{image,mask,random,festival}`。
---
## 5. 视图层规范
### 5.1 ⚠️ think-template include 传参陷阱(曾致 500)
```html
<!-- ✗ 禁止:跨模板作用域传参不可靠,抛 Undefined variable → HTTP 500 -->
{include file="public/styles" pageCss="index" /}
<!-- ✓ 正确:无参 include + 页面自行 link -->
{include file="public/styles" /}
<link rel="stylesheet" href="/static/haonav/css/nav-page-index.css">
```
Controller 级 `assign()` 是全局作用域,include 的子模板可正常读取(`$seo`、`$config`、`$appearanceJson` 均此机制)。
### 5.2 CSS 拆分架构
| 文件 | 职责 | 加载方 |
|---|---|---|
| `nav-base.css` | `:root` 变量、dark 变量、重置、navbar、搜索、卡片、排行、响应式 | styles.html(全页面) |
| `nav-theme.css` | 主题面板、背景遮罩 `body.nav-bg-img::before` | styles.html |
| `nav-festival.css` | 粒子 `.nav-fw`、徽章 `.nav-festival-badge` | styles.html |
| `nav-page-*.css` | 页面专属(widget/倒计时/tabs...) | 各页面手动 link |
CSS 变量:`--nav-primary` / `--nav-primary-2` / `--nav-bg` / `--nav-card` / `--nav-card-text` / `--nav-muted` / `--nav-border` / `--nav-tag-bg` / `--nav-bg-mask`。
### 5.3 JS 模块依赖链
```
nav-core.js($ / safeStorage / ready)← 必须最先
├─ nav-theme.js(主题引擎,读 window.NavAppearance)
│ └─ nav-festival.js(节日引擎,读 NavAppearance + localStorage)
├─ nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js
└─ auth.js
```
页面引入顺序(body 尾):`nav-core.js` → `nav-theme.js` → `nav-festival.js` → 业务 JS。
---
## 6. 主题 / 背景 / 节日引擎(核心机制)
### 6.1 存储值协议(localStorage `haonav_bg`)
| 值 | 含义 |
|---|---|
| `''`(无) | 默认(回落到站点配置→默认渐变) |
| `grad:<id>` | 预设渐变(`grad:green` 等,共 10 组) |
| `img:<url>` | 自定义图片(body 加 `nav-bg-img` 类触发遮罩层) |
| `linear-gradient(...)` | 旧版裸值(兼容层自动识别) |
`haonav_primary`(id)、`haonav_theme`(light/dark/auto)、`haonav_festival_off`(访客关闭节日)。
### 6.2 背景优先级链
```
访客 localStorage > 节日主题(nav-festival.js)> 站点 bg_image > 站点 bg_random > :root --nav-bg
```
### 6.3 节日引擎(nav-festival.js)
- `LUNAR`:农历→公历静态表(spring/dragon/midautumn/qixi/chongyang/xiaonian,2026-2036),**数据为公开农历表整理,生产请以紫金山天文台/香港天文台校准**
- `FESTIVALS`:36 项定义(24 节气 `key:'m-d'` 公历固定 + 农历节日 `lunar:+offset` + `span` 持续天数)
- `detect()`:数组顺序遍历,**靠前者优先**(同日多条时决定胜出)
- 命中后:`bg`(渐变或 url)→ body 内联背景;`p/s` → `--nav-primary(-2)`;`icon/count` → 粒子;`suffix` → 徽章文案
- **时间源**:`new Date()` 浏览器本地时间(跨时区偏移 1 天,如需全球一致可改 `Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'})`)
### 6.4 扩展:新增节日 / 节气
```js
// 1. LUNAR 加农历映射(农历节日才需要)
LUNAR.newfest = { '2026': '8-15', '2027': '...', ... };
// 2. FESTIVALS 按日历位置插入(数组靠前优先)
{ id: 'newfest', name: '新节', lunar: 'newfest', offset: 0, span: 0, suffix: '快乐',
bg: 'linear-gradient(135deg,#xxx 0%,#yyy 100%)', p: '#xxx', s: '#yyy', icon: '🌸', count: 12 },
// 3. 公历固定日期直接用 key
{ id: 'foo', name: 'Foo节', key: '3-12', span: 0, suffix: '', bg: '...', ... },
// 4. sw.js 缓存版本 +1(否则浏览器拿旧 JS)
var CACHE = 'haonav-v7';
```
---
## 7. Service Worker 缓存策略(sw.js)
- 版本号 `CACHE`:**每次改动 css/js 必须升版本**(v6 当前),activate 时自动清旧缓存
- 静态资源(`/static/haonav/*`)+ favicon/snapshot:**缓存优先,后台更新**
- 页面(`/haonav/*`):**网络优先,离线回退缓存**
- 不缓存:`favorite|widget|task|suggest` 接口
---
## 8. 数据表
| 表 | 说明 |
|---|---|
| `wxapp_haonav_links` | 链接(含 pinyin 搜索串、click_count、快照路径) |
| `wxapp_haonav_category` | 分类(pid 两级、sort、status) |
| `wxapp_haonav_config` | 配置(见 §4) |
| `wxapp_haonav_apply` | 前台投稿 |
| `wxapp_haonav_ad` | 广告位 |
| `wxapp_haonav_favorite` | 云端收藏(uid 维度) |
| `wxapp_haonav_favorite_share` | 收藏夹分享(token) |
| `wxapp_haonav_click_stats` | 点击聚合(link_id, cid, date, hour, clicks UPSERT) |
> 老库结构问题由 `BaseModel::ensureAutoIncrementPk()` 运行时自愈。
---
## 9. 开发流程约定
### 9.1 新增前台页面
1. `controller/trait/` 建 trait(或挂到现有 trait)
2. `route/app.php` 加规则(带参数加 `->name()`)
3. `view/frontend/index/` 建模板:`{include file="public/styles" /}` + 手动 link `nav-page-<name>.css`
4. body 尾按序挂 JS(core → theme → festival → 业务)
5. `sw.js` PRECACHE 增文件 + 版本 +1
### 9.2 新增后台功能
`controller/backend/` 建类继承 `HaonavBackend`,路由加 `backend` 组,视图 `view/backend/`。
### 9.3 新增配置项
模型 `ensureXxxConfig()` → `Configs::index` 调用 → 需要前台消费时在 `BaseTrait` 加 assign 注入 → 前端 `window.NavAppearance` 或模板变量读取。
### 9.4 静态资源改动 checklist
- [ ] `sw.js` `CACHE` 版本 +1
- [ ] 新文件加入 `PRECACHE`
- [ ] 页面 link/script 引入顺序:NavAppearance 注入(head)先于 nav-theme.js(body 尾)
---
## 10. 已知限制与历史坑
| 问题 | 状态 | 说明 |
|---|---|---|
| include 传参 500 | 已修 | page-*.css 手动 link 方案固化,勿回退 |
| 节日时区偏移 | 已知 | 浏览器本地时间判定,海外访客 ±1 天 |
| 农历换算表精度 | 已知 | 静态表 2026-2036,2030+ 建议官方校准 |
| SW 缓存旧资源 | 依赖版本号 | 改动必须升 CACHE |
| 裸路由前缀吞噬 | 已修 | 后台裸规则必须 `->completeMatch(true)` 强制完整匹配 |
---
## 11. 相关文档
- 站长使用文档:[haonav_user_guide.md](haonav_user_guide.md)
- 插件开发指南:[../插件开发指南.md](../插件开发指南.md)
- 框架开发文档:[../开发文档.md](../开发文档.md)
- 插件机制对比(Discuz):[../插件机制对比_Discuz.md](../插件机制对比_Discuz.md)
+342
View File
@@ -0,0 +1,342 @@
# haonav 网址导航 — 使用文档
> 面向站长/管理员。开发者请看 [haonav_dev_guide.md](haonav_dev_guide.md)。
- 插件标识:`haonav`
- 版本:1.0.9
- 访问入口:`https://你的域名/haonav`(前台)/ `https://你的域名/haonav/dashboard`(后台)
- 依赖:YwxApp 框架 ≥ PHP 8.0、MySQL 5.7+
---
## 1. 功能总览
| 模块 | 说明 |
|---|---|
| 网址导航首页 | 顶级分类聚合 + 子分类 + 每分类精选链接 + 热门/推荐 |
| 站内搜索 | 关键词/拼音搜索 + 搜索建议(suggest)+ 多引擎跳转 |
| 热门排行 | 按点击量 Top 50 |
| 网址详情页 | 快照预览、评分、JSON-LD 结构化数据(SEO) |
| 分类浏览 | 一级/二级分类,URL 规则化(`/haonav/category/{id}.html`) |
| 申请收录 | 前台投稿 → 后台审核 |
| 广告位 | 首页/详情页广告位管理 + 点击统计 |
| 我的收藏 | 本地收藏(localStorage)+ 云端同步(登录)+ 收藏夹分享页 |
| 实时小组件 | 实时热搜 / 天气(服务端代理 + 缓存) |
| 主题个性化 | 浅色/暗色/跟随系统 + 5 组主色 + 10 组背景渐变 + 自定义背景图 + 随机换色 |
| 节日/节气换装 | **36 项自动检测**(24 节气 + 传统节日 + 公历节日),后台可关,访客可关 |
| PWA 离线 | manifest + Service Worker 缓存(支持添加到主屏幕、离线访问) |
| SEO | sitemap.xml/html、canonical、JSON-LD、拼音搜索 |
| 死链巡检 | 计划任务全量检测 + 前台惰性巡检兜底,可自动下线/恢复 |
---
## 2. 安装与启用
1. 将插件目录放入 `addon/haonav/`
2. 后台 → 应用管理 → 找到「网址导航」→ 安装/启用
3. 首次访问后台配置页时,数据库会自动补种默认配置行(含表主键自愈),无需手工执行 SQL
4. 前台访问 `/haonav` 即可看到空站点,进入后台开始录入
---
## 3. 快速上手(5 分钟建站)
1. **建分类**:后台 → 网址导航 → 分类管理 → 新增一级分类(如「常用工具」),可再挂二级分类
2. **录链接**:链接管理 → 新增 → 选分类、填名称/URL/描述、可选设「热门」「推荐」
3. **看前台**:`/haonav` 首页即展示;点击量自动累计进排行榜
4. **做 SEO**:后台 → 网址导航配置 → 填 SEO 标题/关键词/描述
5. **(可选)开启外观**:同一配置页「外观」分组设置背景图/随机换色/节日换装
---
## 4. 后台管理
### 4.1 数据概览(仪表盘)
`/haonav/dashboard` — 链接/分类/收藏/点击量汇总统计 + 点击热力图。访问时自动为存量链接回填拼音搜索串(每次 300 条,幂等)。
### 4.2 分类管理
- 一级/二级分类、排序(sort 越大越靠前)、启用/禁用
- 删除分类不影响已挂链接(链接保留待重新归类)
### 4.3 链接管理
- 字段:名称、URL、分类、描述、关键词、热门/推荐标记、排序、状态
- 自动抓取 favicon(`/haonav/favicon/...` 代理输出)与网站快照
- **回收站**:删除先进回收站,可恢复/彻底删除
- **死链巡检**:见 §8 计划任务
### 4.4 申请审核(Apply)
前台 `/haonav/apply` 投稿后进入待审列表,可一键通过(转为正式链接)/驳回。
### 4.5 广告位(Ad)
- 按 position 分组投放(如 `home_top`、`detail_side`)
- 支持图片/文字广告、投放时段、排序
- 点击经 `/haonav/ad/click` 跳转并计数
### 4.6 配置(Configs)
分组管理所有配置项,保存即时生效。见下节明细。
---
## 5. 配置项详解
后台 → 网址导航配置。首次访问自动补齐缺失项。
| 分组 | 配置项 | 默认 | 说明 |
|---|---|---|---|
| 基础 | `default_engine` | baidu | 搜索框默认引擎 |
| 基础 | `enable_submit` | 1 | 前台投稿开关 |
| 基础 | `enable_hot_api` | 0 | 实时热搜组件开关 |
| SEO | `seo_title` / `seo_keywords` / `seo_description` | — | 全站 TDK |
| 返利 | `enable_rebate` | 0 | 命中域名自动拼接返利 PID |
| 返利 | `rebate_param` | pid | 返利参数名 |
| 返利 | `rebate_domains` | taobao.com,... | 返利适用域名列表 |
| task | `deadlink_auto` | 0 | 死链连续失败达阈值自动下线 |
| task | `deadlink_threshold` | 2 | 连续失败次数阈值 |
| task | `deadlink_recover` | 0 | 恢复可达后自动重新上线 |
| **外观** | `bg_image` | 空 | 全站默认背景图 URL(空=渐变) |
| **外观** | `bg_mask` | 35 | 背景图遮罩不透明度 0-90(保证文字可读) |
| **外观** | `bg_random` | 0 | 游客随机渐变背景(每次刷新随机) |
| **外观** | `festival_theme` | 1 | 节日/节气自动换装总开关 |
---
## 6. 前台功能说明
| 页面 | URL | 说明 |
|---|---|---|
| 首页 | `/haonav`(`/haonav/index.html`) | 分类聚合 + 热门 18 + 推荐 10 |
| 搜索 | `/haonav/search?q=关键词` | 站内 + 引擎外跳 |
| 排行 | `/haonav/rank` | 点击量 Top 50 |
| 详情 | `/haonav/site/{id}.html` | 快照、评分、JSON-LD |
| 分类 | `/haonav/category/{id}.html` | 单分类列表 |
| 申请 | `/haonav/apply` | 收录/广告合作投稿 |
| 分享页 | `/haonav/favorite/shared/{token}.html` | 只读收藏夹(noindex) |
| 站点地图 | `/haonav/sitemap.xml` | XML + HTML 双格式 |
| Service Worker | `/haonav/sw` | PWA 离线支撑 |
**会员**:`/haonav/account/login|register|logout|profile`。登录后「我的导航」自动云端同步收藏;未登录自动回退本地 localStorage。
---
## 7. 主题与个性化
前台导航栏 🎨 按钮打开主题面板:
| 功能 | 说明 | 记忆 |
|---|---|---|
| 模式 | ☀️ 浅色 / 🌙 暗色 / 🌗 跟随系统 | localStorage |
| 主色 | 5 组色板(紫/绿/蓝/粉/灰) | localStorage |
| 背景 | 10 组渐变色点 + 🎲 随机换色 + 自定义图片 URL | localStorage |
| 恢复默认 | 一键清除以上所有个人偏好 | — |
**自定义背景图**:面板中「粘贴图片地址设为背景」→ 应用(或回车)。图片以 cover 全屏铺满,自动叠加白色遮罩(暗色模式自动改压暗),遮罩强度由后台 `bg_mask` 控制。
**优先级链**(高 → 低):
```
访客自选背景/主色 > 节日/节气主题 > 站点背景图(bg_image) > 随机换色 > 默认渐变
```
---
## 8. 节日 / 节气自动换装
### 8.1 内置条目(共 36 项)
- **24 节气**:立春/雨水/惊蛰/春分/清明/谷雨/立夏/小满/芒种/夏至/小暑/大暑/立秋/处暑/白露/秋分/寒露/霜降/立冬/小雪/大雪/冬至/小寒/大寒 — 公历固定日期,当天自动换季相配色 + 应季粒子(🌱☀️🍂❄️)
- **传统节日(农历)**:除夕 / 春节(初一至初三)/ 元宵 / 小年 / 端午 / 七夕 / 中秋 / 重阳 — 内置 2026-2036 公历换算表
- **公历节日**:元旦 / 情人节 / 国庆(10/1-10/7)/ 万圣节 / 圣诞(12/24-12/26)
### 8.2 效果
命中当天自动:换节日背景渐变 → 换全站主色 → 飘落装饰粒子(emoji 随机大小/速度)→ 右上角徽章(如「🏮 春节快乐」「⚡ 惊蛰」)。
- **时间判定**:浏览器本地时间(`new Date()`)。跨时区访客可能偏移 1 天,国内用户无感
- **访客关闭**:点徽章右上 × 永久关闭(localStorage)
- **站长关闭**:后台 → 外观 → 节日主题自动换装 → 0
### 8.3 「节日不生效」排查
依次检查(Console 执行):
```js
localStorage.getItem('haonav_festival_off') // '1' = 曾点过关闭 → removeItem 后刷新
localStorage.getItem('haonav_bg') // 有值 = 自选背景覆盖了节日背景
window.NavAppearance.festival // 0 = 后台总开关关闭
```
Service Worker 旧缓存干扰时:F12 → Application → Clear site data。
---
## 9. SEO 能力
- `sitemap.xml` / `sitemap.html` 双格式自动生成
- 详情页输出 JSON-LD(WebSite + SearchAction 搜索直达框)
- 全站 canonical 链接
- 链接支持自定义 keywords/description
- 拼音搜索:中文名自动转拼音串,支持拼音首字母检索
---
## 10. 死链检测(计划任务)
两种模式可同时启用:
### 10.1 定时全量(推荐 cron)
```cron
0 3 * * * curl -s "https://你的域名/haonav/task/checklinks?token=你的TOKEN" > /dev/null
```
每日凌晨全量检测所有启用链接。
### 10.2 惰性巡检(无 cron 兜底)
前台页面访问时异步触发 `/haonav/task/autocheck`,带缓存锁限频,自动分批检测。
### 10.3 自动下线/恢复
开启「死链自动下线」后:连续失败 ≥ 阈值 → 自动禁用;开启「恢复自动上线」后:重新可达 → 自动启用(仅对检测器下线的链接生效,手动禁用不受影响)。
---
## 11. 常见问题 FAQ
**Q1:页面 500?**
确认没有模板 include 传参(`{include file="..." var="x" /}` 在 think-template 跨模板作用域不可靠,page-*.css 由各页面手动 `<link>` 引入)。
**Q2:改了背景图后台配置前台不生效?**
浏览器/SW 缓存。F12 → Application → Clear site data;或等 sw 版本升级自动刷新。
**Q3:游客反馈"背景一直没变过"?**
该游客曾点过节日徽章 × 或自选过背景。让其点击面板「↺ 恢复默认」。
**Q4:拼音搜索搜不到?**
旧数据拼音串未回填。访问一次后台仪表盘即自动补(每批 300 条),或多次刷新。
**Q5:收藏在不同设备不同步?**
收藏分本地(未登录)/云端(登录)两套。登录后自动 merge 并以云端为准。
---
## 12. 绑定域名访问
希望独立域名(如 `hao.example.com`)直接访问网址导航,而不是 `example.com/haonav`?YwxApp 通过 `config/app.php` 的 `domain_bind` 支持。
### 12.1 三种使用场景
| 场景 | 配置方式 | URL 形态 |
|---|---|---|
| 默认(路径访问) | 不配 | `example.com/haonav` |
| 二级域名绑定 | `'hao' => 'haonav'` | `hao.example.com`(自动剥 `hao.`) |
| 完整域名绑定 | `'hao.example.com' => 'haonav'` | 直接访问 `hao.example.com` |
### 12.2 启用步骤(5 步)
#### ① 修改 `config/app.php`
```php
'domain_bind' => [
'hao' => 'haonav', // 二级域名 → 插件名(推荐)
// 'hao.example.com' => 'haonav', // 完整域名 → 插件名
],
```
#### ② Web 服务器指向同一项目目录
绑定域名访问本质上是同一份代码,让 DNS 把 `hao.example.com` 解析到与 `example.com` 相同的服务器 / 同一 Web 根。
**Nginx**(在主 server 块外新增或合并):
```nginx
server {
listen 80;
server_name example.com hao.example.com; # 多 server_name 即可
root /www/ywxapp_dev/public;
index index.php;
# 其余 fastcgi / rewrite 规则同主站
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
```
**Apache**:`ServerName example.com` → `ServerAlias hao.example.com`。
#### ③ DNS 解析
到域名服务商把 `hao.example.com` 用 A/CNAME 记录指向主站 IP,TTL 选短值便于调试。
#### ④ 清框架运行时缓存
```bash
rm -rf runtime/cache/*
```
#### ⑤ 测试
浏览器访问 `http://hao.example.com` → 应直接呈现 haonav 首页(不再带 `/haonav` 前缀)。
### 12.3 「绑定后无法访问」逐项排查
按下面顺序逐项确认(90% 的问题在第 1/2 步):
| 序号 | 排查点 | 自检命令 / 现象 |
|---|---|---|
| 1 | **DNS 是否解析到服务器** | `ping hao.example.com` 应返回主站 IP;不通就是 DNS 没配 |
| 2 | **Web Server 是否监听该域名** | `curl -H "Host: hao.example.com" http://服务器IP/` 不应返回 404/连接拒绝;Nginx 看 `server_name` 包含子域名,Apache 看 `ServerAlias` |
| 3 | **`config/app.php` 是否正确** | `php -r "print_r(config('app.domain_bind'));"` 确认配置生效(务必重启 php-fpm) |
| 4 | **缓存清理** | 改完配置删 `runtime/cache/*`;TP 路由缓存若有也清 |
| 5 | **插件名与 bind 值一致** | `domain_bind['hao']='haonav'` 中 `haonav` 必须与 `addon/haonav/info.php` 的 `name` 完全一致(区分大小写) |
| 6 | **`addon/haonav/` 存在且有 route** | 验证 `addon/haonav/route/app.php` 可被框架 include |
| 7 | **完整域名是否带协议** | `'hao.example.com'` 不要写成 `'http://hao.example.com'` |
| 8 | **HTTPS** | 绑定后若浏览器强制 HTTPS,请确保子域名也有证书(Let's Encrypt 用 `certbot --expand`) |
| 9 | **SSL 泛域名** | 多子域名绑同一站点,用通配证书 `*.example.com` 最省事 |
| 10 | **框架缓存类** | 部分主机有 OPcache,改完 `config/app.php` 重启 `php-fpm` 才生效 |
**最常见两大坑**:
```
坑 ①:DNS 已解析但 Web Server 没加 server_name → 访问显示「无法访问此网站」/ 默认页
坑 ②:Web Server 已加但 domain_bind 没写 / 拼写错 → 显示「路由不存在 404」
```
### 12.4 多域名绑定同一插件
```php
'domain_bind' => [
'hao' => 'haonav', // hao.example.com
'nav' => 'haonav', // nav.example.com
'hao.dev.example.com' => 'haonav', // 测试子域
],
```
不同域名可分别承担同一插件的「访问入口」与「SEO 镜像」,但**避免一站多域名导致 SEO 重复**——通过 `seo_title` 区分或加 `<link rel="canonical">` 指向主域。
### 12.5 绑定域名后的相对路径影响
| 场景 | 行为 |
|---|---|
| 模板用 `url('haonav/index')` | 生成的 URL 已自动带域名上下文 → `https://hao.example.com/haonav/index.html` |
| 模板用绝对路径 `/static/...` | 不变(推荐) |
| 模板用相对路径 `static/...` | ⚠️ 子域名访问时可能找不到资源 → **统一用绝对路径** |
| 前端 JS 中 `location.href = '/xxx'` | 浏览器会替换为当前域名下的绝对路径,安全 |
| Service Worker | 缓存清单使用绝对路径(已配置),绑定域名后仍正常工作 |
---
## 13. 相关链接
- 开发文档:[haonav_dev_guide.md](haonav_dev_guide.md)
- 框架插件机制:[../插件开发指南.md](../插件开发指南.md)
- 部署指南:`docs/deploy/`
+536
View File
@@ -0,0 +1,536 @@
# IoT 插件接口与 MQTT 主题参考
> 本文以**代码为准**(`addon/iot/route/app.php`、`controller/api/Client.php`、`controller/api/Mqtt.php`、`service/*.php`、`firmware/water_temp/water_temp.ino`)。
> 与 `device_register_mqtt.md` 冲突处,以本文为准(差异见文末「已发现的不一致」)。
---
## 一、通用约定
### 1.1 基址与鉴权
| 项 | 值 |
|---|---|
| App 接口前缀 | `https://{host}/iot/api/client/*`(接入层:绑定/共享/凭证/命令);时序·配置·告警在 `https://{host}/iot/api/monitor/*`(见 §2.4,旧 `client/*` 路径仍作兼容别名) |
| 设备 / 公开接口 | `https://{host}/iot/api/{deviceActivate,status,publish}` |
| 后台接口前缀 | `https://{host}/iot/backend/*` |
| App 鉴权 | Header `Authorization: Bearer {会员登录 token}`(框架 JWT,`ApiBase` 统一拦截,未登录 HTTP 401) |
| 后台鉴权 | `verifyAuth()` + `tokenParse()`,Bearer JWT |
| 免登录白名单 | 仅 `Client::deviceActivate`;`Mqtt` 控制器整个 `noNeedLogin = ['*']` |
### 1.2 响应格式(⚠️ 有两套,字段名不同)
**A. `Client.php`(App 全部接口)→ `apiSuccess()` / `apiError()`**
```json
{ "code": 0, "msg": "success", "data": {}, "timestamp": 1789000000 }
```
- `code=0` 成功 / `1` 业务失败 / `401` 未登录(HTTP 同为 401)
- 字段名是 **`msg`**,不是 `message`
- 携带 token 时附加 `tokens.access_token` / `tokens.access_expired` / `tokens.refresh_token`
**B. `Mqtt.php`(`status` / `publish`)→ `success()` / `error()`**
```json
{ "code": 0, "message": "success", "data": {}, "timestamp": 1789000000, "count": 12 }
```
- 字段名是 **`message`**,分页字段是 `count`
- 发布失败时 `error()` 之后**仍会继续往下执行**(`Mqtt::publish` 未 return),属已知小 bug
> ⚠️ `$this->result->setCount(n)->apiSuccess($list)` 这个组合**不会输出 `total`**:`apiSuccess()` 只认第三个参数 `$count`,而 `setCount()` 设的是 `$this->Total`(只有 HTML 版 `success()` 会用)。
> ⇒ App 端不要依赖这些接口的 `total`,条数从 `data` 数组长度取。
---
## 二、App / 会员 REST API(前缀 `/iot/api/client/`;时序·配置·告警 `/iot/api/monitor/*`)
### 2.1 设备发现与绑定
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `devices` | — | 我的 MQTT 连接记录(`iot_connection`,按 `username = 会员账号` 查,上限 200 条) |
| GET | `subscriptions` | — | 我的订阅主题列表,`data:{list,total}` |
| GET | `myDevices` | — | **我的设备**(主人 + 被共享,含 `role`、在线态、影子、**内联位置配置** `sensors/cfg_ver/sync_ver/applied`;已剔除 secret) |
| POST | `bindDevice` | `sn`(必填)、`pair_code`(设备设了配对码则必填)、`title`、`model`、`room` | 绑定;已激活设备不重置凭证;未激活且无配对码时绑定即激活 |
| POST | `unbindDevice` | `uuid`(必填) | 解绑,**会重置设备密钥** |
| POST | `shareDevice` | `device_id`、`account`(对方会员账号)、`perm`(1 只读 / 2 可控,默认 2) | 仅主人可操作 |
| POST | `revokeShare` | `device_id`、`member_id` | 撤销共享 |
| GET | `shareList` | `device_id` | 共享成员列表(仅主人) |
| POST | `resetSecret` | `uuid` | 重置密钥,新明文仅返回一次 |
> **三个标识符别混用**(各家接口用的不是一个东西):
>
> | 标识符 | 是什么 | 哪些接口用 |
> |---|---|---|
> | `sn` | `iot_device.sn` 列,**设备序列号** | `bindDevice`、`deviceActivate` |
> | `uuid` | `genUuid()` 生成,形如 `dev_` + 16 位 hex,设备 MQTT 连接用户名 | `unbindDevice`、`deviceState`、`deviceConfig`、`saveDeviceConfig`、`sendCommand`、`deviceLogs`、`publishOta`、所有 `/devices/{uuid}/…` 主题 |
> | `device_id` | `iot_device.id`(自增整数) | `shareDevice`、`revokeShare`、`groupAddDevice`、`groupRemoveDevice` |
>
> **`sn` 是不是就是 mac?** 看设备怎么入的库:
> - **即插即用注册**(当前固件走的路径,`registerByMac()`):插入时 `'sn' => $mac, 'mac' => $mac` → **`sn` 就等于纯 mac**(12 位小写 hex、去冒号)。
> - **后台预登记**(`register()`):`sn` 是手填的序列号(≤64 字符任意),真 MAC 存在独立的 `mac` 列 → 此时**必须传 `sn`**。
>
> ⚠️ `bind()` 只按 `sn` 列精确查,**没有 `mac` 兜底**(`register()` / `registerByMac()` 有 `where('mac', …)` 兜底)。
> ⚠️ 更危险:`bind()` 查不到时**直接新建一台设备**(`sn` 填你传的值、`member_id` 填你)——sn 传错不报错,会静默建出「空壳设备」,而真设备仍是 `member_id=0`(看起来绑定成功,实际设备永远不在线)。
>
> ⚠️ `myDevices` 的返回:主人侧是**整行减 `secret`**(含 `pair_code`、`activate_token`、`member_id`、`cfg_ver` 等);共享侧(`sharedToMe()`)是 12 字段白名单(`sn/uuid/title/model/room/role/perm/online/shadow/topics/…`,**无 `dev_type`/`ver`/`mac`**)→ App 统一渲染时要按缺失字段兜底。
>
> ✅ **列表已内联位置配置**(`DeviceService::attachSensorConfig()`,2026-09-14 起):每台设备(**含共享**)都带
> `sensors:[{index,alias,probes,upper,lower,gap,enabled}]` 与 `cfg_ver/sync_ver/applied`。字段与
> `deviceConfig` 接口**完全一致**(同一 `SensorConfigService::format()` 出口,见 `getAll()`)
> ⇒ App 拉一次设备列表即可画骨架(离线/首屏可用、骨架屏好做),省掉 `deviceConfig` 那次往返;
> 旧服务端不返回该字段(`sensors` 缺失或为空)时,App 回退到 `deviceConfig` 即可,新旧服务端双向兼容。
>
> ⚠️ 列表里的 `pins` 是 `iot_device.pins` 的 **CSV 字符串**(`"3,4,10"`),与 `deviceConfig` 下发的
> `pins`(数组)**不同型** —— 别把列表里的 `pins` 直接当数组用(App 端 `applyPins()` 只从
> `deviceConfig`/`/status` 取,就是为避开这个差异)。
>
> ⚠️ 实现固定 **2 条 SQL**(`whereIn(uuid)` 批量后按 uuid 分组),**不要**改成在循环里逐台
> `SensorConfigService::get()` —— 那是 N+1,设备一多就把列表接口拖垮。
### 2.2 连接凭证与指令
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `mqttTicket` | — | `{ticket(48 位 hex), account, expire_in:60, expires_at}`;顺带自愈 ACL(`resyncMemberAcl` + `resyncMember`) |
| POST | `sendCommand` | `uuid`、`payload`(JSON 字符串)、`qos`(0/1/2,默认 0) | 写入 `/devices/{uuid}/cmd`,`retain=0` |
| GET | `deviceState` | `uuid` | `{uuid, online, shadow}`(shadow = 最后一条 retained `state`) |
### 2.3 传感器配置
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `deviceConfig` | `uuid` | `{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}], uuid}` |
| POST | `saveDeviceConfig` | `uuid`、`sensors`(数组或 JSON 字符串) | **全量覆盖**语义,保存即 retained 下发 |
>
> 🔑 **位置序号键统一为 `index`**(2026-09-15):`sensors[]` 元素 = `{index,alias,probes,upper,lower,gap,enabled}`。
> 服务端 `SensorConfigService::normalize()` 同时兼容旧键 `i` 与列名 `pos_index`(老 App 提交不会错位),
> 但**新代码一律只发 `index`**。
> ⚠️ 改这个键名必须同步三处消费方:`SensorPointService::capture()`(按位置取阈值复核)、App `device-panel`、
> 后台 `view/backend/admin/device.html` —— 少改一处,位置序号会**静默变 `0`**(不报错):
> 多个位置挤到同一序号,影子数据全填到位置 0,且保存时被「全量覆盖 + 同序号后写覆盖」合并成**一个位置**。
> ⚠️ 别与**下行 config 报文里的序号键**混为一谈:报文里它同样叫 `index`,而且**只有这一个键**
> (v1.7.0 的「index+i 双发」已于 2026-09-15 废除)。唯 `data[]`(≤1.4.0 老固件专用副本)仍用旧键 `i`,
> 改它等于让老设备一个位置都不启用(见 §5.2)。
- `applied = (sync_ver >= cfg_ver)`;`false` 表示设备尚未回 `config_ack`(离线,上线即生效)
- `saveDeviceConfig` 需 `PERM_CTRL`(只读共享会报「无权限修改该设备配置」)
- `pins`:**总线 GPIO 表,下标 = 位置序号**(对应 `iot_device.pins`)。取自设备上线时
`status` 报文里的 `pins`(v1.7.0+),服务端缓存后下发;设备从没上报过(≤1.6.2 老固件)
或从没上线过则为 `[]` —— 此时 App 靠 state 报文里的 `pin` 自行补齐显示
### 2.4 实时曲线与时序聚合(前缀 `/iot/api/monitor/*`)
> ⚠️ **三个接口返回结构互不相同**,这是 App 端最容易踩的坑:`sensorSeries` 回**原始稀疏点**,
> `sensorAvg` / `recentAvg` 回**聚合值**。拿 series 的键(`temp`/`interval`/`temp_diff`/`probes`/`alert_mask`)
> 去读聚合返回 ⇒ 键不存在,曲线**全是 `0` 和 `[]`**,而数据库里明明有数据(`ts` 两边都有,所以时间轴是对的)。
> 症状口诀:**「时间点对、温度全 0」= 读错键,不是没数据。**
| 方法 | 路径 | 参数 | 返回 `data` |
|---|---|---|---|
| GET | `sensorSeries` | `uuid`、`pos=0`、`from`、`to`、`limit=500`(钳 [1,2000])、`debug=1?` | `{uuid,pos,from,to,count,points}` |
| GET | `sensorAvg` | `uuid`、`pos=0`、`step=60`(钳 [60,86400])、`window=30`、`from`、`to`、`limit=720`、`debug=1?` | `{uuid,pos,step,from,to,count,points}` |
| GET | `recentAvg` | `uuid`、`window=10`(钳 [1,1440] 分钟)、`pos`(默认 `-1` = 全部位置) | `{uuid,window_min,from,to,samples_total,positions}` |
**① `sensorSeries` → `points[]`:原始稀疏点(细粒度,逐条上报)**
```json
{ "ts": 1789700400, "interval": 120, "temp": 26.5, "temp_diff": 0.2,
"probes": [ { "id": "A3F2", "temp": 26.5 } ], "alert_mask": 0 }
```
- `interval` = 本条覆盖秒数(曲线有效期 `[ts, ts+interval]`);`temp=null` = 该位置本刻**无读数**(不是 0℃)
- `probes[].id` = 探头 ROM 短码(未识别为 `null`);`alert_mask`:bit j = 槽位 j 越限、bit7 = 位置内温差超限
- 服务端**不展开**等间隔序列(温变上报 10~120s 一条,展开纯浪费带宽)⇒ 平滑曲线由客户端前向填充
**② `sensorAvg` → `points[]`:按 `step` 分桶(画趋势用这个)**
```json
{ "ts": 1789700400, "avg": 26.54, "min": 26.5, "max": 26.56, "samples": 86, "coverage_s": 1800 }
```
- 温度读 **`avg`**(时间加权 `SUM(t×秒)/SUM(秒)`,温变期比简单 AVG 准)—— **没有 `temp` 键**
- `ts` = 桶起点(UTC 对齐,App 按本地时区格式化);`coverage_s < step` = 桶内有断档,可画「数据缺口」
- 「昨天 11:00 → 现在,每 30 分钟一点」:`step=1800&from=1789614000&limit=2000`(不传 `from` 时用 `window=N` 分钟)
- **只回有数据的桶**,空档不补点(服务端不铺骨架、不补 0);想要连续骨架由客户端按 `step` 自己铺
**③ `recentAvg` → `positions[]`:每位置一条汇总值(首屏/排障用这个)**
```json
{ "index": 1, "alias": "位置B", "avg": 39.6, "min": 31.81, "max": 46.19,
"samples": 57, "coverage_s": 3480, "last_ts": 1789699572, "data_ok": true }
```
- 无样本时 `avg=null` 且 `data_ok=false`(前端显示「无数据」,**不要显示 0℃**)
**排障:`debug=1`(`sensorSeries` / `sensorAvg` 都支持)**
附加 `data.debug`:`database / table / sql / server_ts / uuid / pos / from / to / rows_uuid / rows_pos / rows_range / min_ts / max_ts / pos_min_ts / pos_max_ts / recent[3] / error` —— 用于定位「直接跑 SQL 有数据、接口却空 / 全 0」:
- `database`、`table` 与 SQL 客户端不一致 ⇒ 接口连错库 / 表前缀不对
- `rows_uuid=0` ⇒ 这个库里没有这台设备(你查的是另一个库/另一个站点)
- `rows_uuid>0` 但 `rows_range=0` ⇒ 时间条件不成立或时钟偏移(比 `server_ts` 与 DB 的 `UNIX_TIMESTAMP()`,并确认 `ts` 单位是秒不是毫秒)
- `error` 非空 ⇒ 原来被 `catch` 静默吞掉的异常原文(同时落 `runtime/log`:`[iot] sensorSeries/sensorAvg 查询失败`)
### 2.5 日志与告警
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `deviceLogs` | `uuid`、`page=1`、`size=20`(上限 100)、`direction=in\|out`(空 = 全部) |
| GET | `alertList` | `page`、`size`(上限 100)、`unread=0\|1` |
| POST | `readAlert` | `ids[]`(**不传或空数组 = 全部标已读**),返回 `{affected}` |
| GET | `alertUnread` | — → `{unread}` |
### 2.6 场景联动
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `sceneList` | — |
| GET | `sceneDetail` | `id` |
| POST | `saveScene` | `id?`、`title`、`icon`、`enabled`、`triggers`(JSON 串)、`actions`(JSON 串) |
| POST | `deleteScene` | `id` |
| POST | `toggleScene` | `id`、`enabled`(0/1) |
| POST | `runScene` | `id`(手动触发) |
| GET | `sceneLogs` | `id`、`page`、`size` |
```jsonc
// triggers[]
{ "type": "device_state|schedule|manual",
"topic": "/devices/+/state",
"condition_json": [{"field": "positions.0.temp", "op": "gt", "value": 60, "ref": "..."}],
"cron": "0 8 * * *",
"enabled": 1 }
// actions[]
{ "type": "send_cmd|http|notify",
"device_uuid": "...",
"payload_json": "...",
"topic": "..." }
```
### 2.7 设备分组
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `groupList` | — (含设备数 / 在线数) |
| POST | `saveGroup` | `id?`、`title`、`type`(room/custom)、`sort?` |
| POST | `deleteGroup` | `id` |
| POST | `groupAddDevice` | `group_id`、`device_id` |
| POST | `groupRemoveDevice` | `group_id`、`device_id` |
| GET | `groupDevices` | `group_id` |
### 2.8 OTA
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `otaList` | `uuid?`(不传 = 全部设备) | 返回 `{list:[{device, ota\|null}]}`,按 `model` 取最新启用固件 |
| POST | `publishOta` | `ota_id`、`uuid` | 需 `PERM_CTRL` |
---
## 三、设备侧 / 运维 API(公开)
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| POST | `/iot/api/deviceActivate` | `sn`、`token` | 入云激活(免登录),幂等:已激活不再下发 secret |
| GET | `/iot/api/status` | — | `{online, total, messages, topics}` |
| POST | `/iot/api/publish` | `topic`、`payload`、`qos` | **免登录、无命名空间校验**,直接进出站队列 |
> ⚠️ **安全提醒**:路由 `api/publish` 指向的是 `Mqtt::publish`(`noNeedLogin=['*']`),它**没有** `topic` 前缀校验;
> 而带 `{account}/` 命名空间隔离的那版 `Client::publish` **未被路由绑定**。
> ⇒ 该接口目前任何人可往任意主题发消息。上线前建议改为只走 `client/publish` 或补鉴权。
---
## 四、MQTT 连接参数
| 项 | 值 |
|---|---|
| TCP | `mqtt://{host}:1883`(MQTT 3.1.1 / 5.0) |
| WebSocket | `ws://{host}:8083`(**无子路径、二进制帧**);iOS 线上必须 `wss://` |
| 端口来源 | 后台「服务设置」`/iot/backend/setting` 的 `tcp_port` / `ws_port` |
| 设备 clientId / username | 均为 `uuid`;password = `secret` 明文(库中存 bcrypt) |
| 注册阶段 clientId | 建议直接用 `mac`(未激活无凭证) |
| App clientId | 建议 `app-{account}-{随机串}`,避免与设备 `uuid` 撞名 |
### 认证三选一
| 方式 | username | password | 前提 |
|---|---|---|---|
| 认证账号 | 后台「认证」表账号 | 对应密码 | `iot_auth` 启用 |
| 会员账号 + 登录密码 | `member_user.account` | 会员登录密码(bcrypt 校验) | 后台开 `allow_member_login` |
| **一次性 ticket(推荐)** | `account`(`mqttTicket` 返回) | `ticket` | 每次连接 / 重连前重新换,TTL 60s |
- Clean Session 建议开启;重连不依赖持久会话,靠 retained 兜底
- 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)→ App 必须处理 `onSubAck`
---
## 五、设备私有主题 `/devices/{uuid}/…`
> 📄 **主题速查(功能 / 参数 / 谁发谁订 / 权限矩阵 / 排错)见 `iot_topics.md`** —— 本节只保留报文版式与历史沿革细节。
| 主题 | 方向 | QoS | retain | payload |
|---|---|---|---|---|
| `state` | 设备 → 服务端 | 0 | **1**(影子) | 见 5.1 |
| `status` | 设备 → 服务端 | 0 | **1** | 上线 `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` |
| `config` | 服务端 → 设备 | 1 | **1** | 见 5.2 |
| `ack` | 设备 → 服务端 | 0 | 0 | `config_ack` 回执,见 5.3 |
| `cmd` | 服务端 / App → 设备 | 请求决定(默认 0) | 0 | `{"action":"..."}`,见 5.4 |
| `ota` | 服务端 → 设备 | 1 | 0 | 见 5.5 |
| `ota/report` | 设备 → 服务端 | 0 | 0 | 见 5.5 |
| `event` | 设备 → 服务端 | 0 | **1** | 见 5.6 |
### 5.1 `state`(retained 影子)
固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`);
结构沿用 **v1.6.0** 的**紧凑版式**
(全长报文 ≈533B → ≈370B(v1.6.0)→ ≈349B(v1.7.0 键名)→ ≈300B(v1.7.0 去冗余)):
```json
{ "ts": 1789000000, "ver": "1.7.0", "rssi": -57,
"iv": 120, "cv": 7,
"pos": [
{ "t": 45.63, "df": 0.25,
"pr": [ ["A3F2", 45.50], ["B71C", null] ] },
null
] }
```
> **两处冗余为什么能去掉**
> ① `uuid`:报文本来就发布在 `/devices/{uuid}/state` 上,「谁发的」主题里已经写着;
> 服务端 `SensorPointService::capture()` 本来就从主题正则取 uuid,App 订阅时也早知道 ⇒ 省 ≈25B。
> ② `pin`:它是**编译期常量** `ONEWIRE_PINS[下标]`,设备这辈子都不会变。改成设备**上线时**
> 在 `status` 报文里报一次(见上表),服务端缓存进 `iot_device.pins`、随 `deviceConfig`
> 下发 ⇒ 人眼对接线端子的信息一点没少,每启用位置省 ≈8B。
> ⚠️ **已烧录的 ≤1.6.2 固件仍会带这两个键**,服务端/App 一律「有则用、无则退」,
> 所以**不需要重烧、也不需要灰度窗口**。
> **键名沿革**(服务端 / App 一律「新键优先、旧键兜底」,**老固件不用重烧**):
> `iv` ← `interval`(≤1.5.0 也曾叫 `iv`)、`pos` ← `positions`、`pr` ← `probes`、`cv` ← `cfg_ver`。
> 只改键名,结构与语义一字未动。
| 字段 | 含义 |
|---|---|
| `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C),**已没有 `index` 字段**;`null` = 该位置未启用(尾部占位会截掉,**中间的不截** —— 截了下标就前移) |
| ~~`pin`~~ | ⚠️ **v1.7.0 起 state 不再带此键**(改由设备上线时在 `status` 报一次 → 服务端缓存 → 经 `deviceConfig` 的 `pins` 下发)。它本来就是**纯属性、不是身份**:换板/改脚时它变、位置序号不变,所以别名/阈值/历史/告警都还能对上。≤1.6.2 老固件仍会带,解析照旧 |
| `t` | 该位置温度(有效探头均值);`null` = 无有效读数(`null` 显示 `--`,别显示 0) |
| `df` | 位置内极差(max − min);`null` 同 `t` |
| `pr[]`(旧名 `probes`) | 该总线各探头;**下标 == 总线槽位**,尾部空槽已截(空总线 `[]`)。元素是**二元组 `[身份, 温度]`** |
| `pr[][0]` | 探头身份(ROM 短码);`null` = 未识别。**ROM 枚举到就上报,与读数成败无关** |
| `pr[][1]` | 该槽温度;`null` = 无读数。配身份可区分「没插」与「插了读不到」 |
| `ts` | NTP Unix 秒,`0` = 未对时 |
| `iv`(旧名 `interval`) | 本条覆盖秒数(前向填充有效期 `[ts-iv, ts]`)。≤1.5.0 的固件也叫 `iv` |
| `cv`(旧名 `cfg_ver`) | 设备当前生效配置版本;与本地不一致就重拉 `deviceConfig`(HTTP 字段名仍叫 `cfg_ver`) |
> **压字节的账(v1.7.0 已做全)**:`pos`/`pr` 的字节数随「位置数 × 探头数」线性增长,是重点;
> `iv`/`cv` 每条只出现一次、收益最小,但既然这份报文的定位就是「压到最小」,索性一次压到底:
> ① 键名 `interval/positions/probes/cfg_ver` → `iv/pos/pr/cv`;② 去掉顶层 `uuid`;
> ③ 去掉每位置的 `pin`(改为上线时在 `status` 报一次)。
> 累计 549B → 300B(3 位置 2 探头),**其中高频部分压掉约 45%**。
> **探头身份(`probes[][0]`)怎么来的**(v1.6.1):DS18B20 的 64bit ROM = `[0]` 家族码(0x28)
> + `[1..6]` 48bit 序列号 + `[7]` CRC8,身份无信息量的两端不参与。短码取**序列号末 2 字节**
> (`addr[5..6]`)→ 4 位十六进制;若末 2 字节为 `0000`(克隆/翻新片常见:序列号高位恒 0),
> 依次退让到中 2 字节、前 2 字节,取第一个非 0 的 —— 这类片才有身份。
> **只有 6 字节序列号全 0** 才发 `null`:那种片各片 ROM 完全相同,任何切片都分不开,
> 报文上只能按槽位区分(固件串口会明确提示)。
> 🚨 **身份是 `null` 但温度有值(`[null, 24]`)= 取不到 ROM 短码,不是读数失败**:
> `[null, null]` 才是「该槽没插器件」。三种成因按概率排:
> ① 固件 **< v1.6.1** 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码照样把计数记成枚举数,
> 「数量变了才重读 ROM」永久为假 ⇒ 身份永久 `null`(**温度正常、重启也不恢复**,v1.6.1 已修);
> ② 该片序列号 6 字节全 0(无唯一身份,只能按槽位区分);③ 该槽真的没插(此时温度也 `null`)。
> **更早的三个版式**(服务端与 App 一律按「键在不在」判版式,**不比较版本号字符串**):
> - **v1.6.0**:`positions[].{pin,t,df,probes[[id,temp]]}` —— 与 v1.7.0 完全同构,只是键名写全。
> - **v1.5.0**:`positions[].{index,pin,temp,temp_diff,probes[{id,temp}]}` —— 键名写全、按 `index` 对齐。
> 与 v1.6.0+ 靠**元素里有没有 `index`** 区分(v1.5 每个位置都写,v1.6 起彻底删掉)。
> - **≤1.4.0**:`data[].{i,t,df,v,vs[],ok[],ids[]}` + 顶层 `iv`。
> ⚠️ 旧版的 `t` 与 v1.6.0+ 的 `t` **同键名但语义不同**:旧版靠 `v` 标志判有效(`v=0` 时 `t` 照样带
> `0`),新式靠 `null` 判 —— 解析必须**先判有没有 `v`**,否则会把「没读数」存成 `0.00℃`。
> - 解析实现:服务端 `SensorPointService::capture()`;App `normalizePositions()` / `normalizeProbes()`;
> 场景条件路径 `SceneService::upgradeStatePath()` —— 它把 `data.` / `positions.` / `probes.` /
> `interval.` 这些历史词汇自动收敛到 `pos.` / `pr.` / `iv.`。⚠️ **改报文键名时必须同步这里**,
> 否则库里已存的场景条件会「取不到值 → 恒不成立」,且**不报错**(静默失效)。
**上报节奏**:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。
### 5.2 `config`(服务端下发,retained,QoS1)
```json
{ "cv": 7,
"pos": [ {"index":0, "alias":"位置A", "pn":3, "upper":85, "lower":0, "gap":2} ] }
```
全量覆盖语义:`pos` 里没有的位置一律停用。设备写入 NVS 后回 ack。
`pos[]` **只含启用位置**(服务端 `get(uuid, true)`),所以元素里的 `index` 是必需的(见下)。
⚠️ 旧固件(≤1.7.1)收到本格式只回 ack `missing pos`:配置不生效,但**不会误报成功**,一眼可见。
> 🔑 **上下行同键名**(v1.7.2 起):下行 `cv` / `pos` / `pn` 与 §5.1 的 state 完全一致,抄报文不用换脑子。
> v1.7.0 曾为「已烧录的老固件不重烧也能收配置」叠了三层兼容:下行写长名 `positions`/`probes`、
> 复制一份旧式 `data[]`、标量新旧同发(`cfg_ver`/`probes` 与 `cv`/`pn` 同值双发)——
> 2026-09-15 **全部删除**。理由:现场只有两台自用设备、都会重烧固件,兼容收益为 0,而它
> 每多一个「同义键」就多一把备用钥匙:服务端读 `index`、固件读 `i`,改键名时只改一边就
> **静默拆锁**(当天现场事故:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。
> 旧的「宁可多发 6B 标量也不复制数组」是为了绕开已烧录设备的 1024B ArduinoJson 解析池;
> 现在没有老设备,这个约束也就没有必要了。
> · ⚠️ `probes` 在**上行是探头明细数组、下行原是探头数(数字)** —— 下行改名 `pn` 后两义消失。
> 🔑 **下行必须有 `index`,上行反而没有** —— 这是唯一一处上下行"不对称",**不是漏改**:
> · 上行 state 的 `pos[]` 靠**数组下标**定位序号:未启用位置写 `null` 占位把下标撑住,
> 每个位置省 10B,而它是每 10s~120s 一条的高频报文,字节值钱;
> · 下行 `pos[]` **只含启用位置** ⇒ 下标会塌缩(位置 0 停用时,位置 2 排在报文下标 0 上)
> ⇒ 元素里必须带 `index`,固件也**不能用下标顶替**(见固件 `onConfigJson` 注释)。
> 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动。**
### 5.3 `ack`(设备 → 服务端,非 retained)
```json
{ "type":"config_ack", "success":true, "message":"...", "cv":7,
"mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" }
```
`cv` 是唯一的版本键(v1.7.2 起;≤1.6.2 老固件回 `cfg_ver`,服务端已不再兜底)。
服务端 `SensorConfigService::onAck()` 据此记 `sync_ver`。
### 5.4 `cmd`(下发命令)
```json
{"action": "report"} // 立刻上报一次 state
{"action": "reboot"} // 重启(保留凭证)
{"action": "wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页;凭证/配置保留
{"action": "clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册;WiFi 保留(服务器写死在固件)
{"action": "factory_reset"} // 全清(含 WiFi)→ 进配网页
```
- 三个清除类动作设备会先回 `ack` 再重启(`delay(500)` 等 ack 出网)
- 未知 `action` 只打日志 `[CMD] unknown action=...`,不报错
- 前提:调用方对 `cmd` 有**发布权限**(主人 或 `perm=2` 共享者)
### 5.5 `ota` / `ota/report`
```json
// 下行 /devices/{uuid}/ota(固件实际只用 url + version,hash/size 被忽略)
{"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456}
// 上行 /devices/{uuid}/ota/report
{"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"}
```
`done` → 写 level 1 告警;`fail` → level 3 告警;其它 → `push` 进度通知(不写告警表)。
### 5.6 `event`(retained 告警)
固件 **v1.6.0** 起为结构化告警(**v1.7.0** 起键名 `probes` → `pr`):
```json
{ "type": "alert",
"alerts": [
{ "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov",
"pr": [ [0, "A3F2", 92.5] ] }, // 旧名 probes(≤1.6.2),服务端两版都认
null
] }
```
- `alerts` **数组下标 = 位置序号**(未告警的位置写 `null` 占位,尾部占位截掉)
- `rs` 是原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限。服务端
`AlertService::REASON_TEXT` 统一展开成中文 —— **改文案不用重烧固件**。
(v1.5.0 发的是中文字符串 `reason`,一条约 24B;≤1.4.0 发 `overunder` / `gap`)
- `alias` **保留写全**:它是「当时的叫法」的快照,告警文案要能独立成立,不依赖事后查配置
- `probes[]` 只列异常探头(稀疏),故每条是三元组 `[槽位, 身份, 温度]` —— 槽位必须显式带,不能靠下标推
- 旧版对应字段:`bad[]`(槽位下标)+ `bad_ids[]`(探头身份,与 `bad[]` 一一对应);
且 ≤1.4.0 的 `alerts` 里根本没有位置号 → 服务端只能记 `POS_NA`(255)
- `AlertService::onDeviceEvent()` 三版都认,逐位置落 `iot_alert`(含 `pos_index`)
采样后判定:新出现告警立即发,持续告警按 `ALERT_REPEAT_MS` 复报。
---
## 六、注册主题与通知主题
### 6.1 注册(MAC 即插即用)
| 主题 | 方向 | payload |
|---|---|---|
| `system/device/register/{mac}` | 设备 → 服务端 | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | `{"code":0,"message":"ok","data":{...}}` |
- **必须先订阅 resp 再 publish 注册**,否则错过回执(回执 QoS1、**非 retained**)
- mac 必须 12 位小写 hex(去冒号),且与主题段一致
- 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver` 不再给 secret
- ACL:这两个主题**恒定放行**,不依赖 ACL 开关,可匿名连接完成注册
- 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token
### 6.2 通知 `/notify/{account}/{子主题}`(QoS1,retain=1)
| 子主题 | 触发 | payload 摘要 |
|---|---|---|
| `alert` | 设备离线 / OTA 完成失败 | `{"id","type":"alert","level":1..3,"title","content","uuid","time"}` |
| `event` | 设备 `/event` 上报 | 同上,`type=event` |
| `scene` | 场景 `notify` 动作 | `{"id","type":"scene","level":2,"title","content","uuid","time"}`(`SceneService` → `AlertService::add($memberId,'scene',2,title,content)`;原先此处写的 `scene_id`/`message` 字段代码里并不存在,2026-09-15 校正) |
| `ota` | 升级进度 | `{"type":"progress","uuid","state","progress","version","time"}` |
| `ota` | 升级完成 / 失败 | `{"type":"alert","uuid","level","title","content","time"}` |
`time` 均为 Unix 秒。App 统一订阅 `/notify/{account}/#` 即可。
---
## 七、服务端消息流(Broker 钩子)
```text
设备 publish /devices/{uuid}/{state,event,ota/report,ack} 或 system/device/register/{mac}
→ Broker(QoS0/1 立即分发;QoS2 在 PUBREL)
├─ DeviceLogService::log 入库留痕(仅 /devices/*)
├─ SceneService::onMessagePublished 场景条件评估 → 动作入 outbox
├─ OtaService::onReport ota/report → 告警 / 进度通知
├─ AlertService::onDeviceEvent event → 告警 + notify
├─ SensorConfigService::onAck ack → 记 sync_ver
└─ handleDeviceRegister 注册 → 回执 dist 到 resp
→ outbox 消费器(0.5s)发布 cmd / notify
```
ACL 关键规则(`Auth::checkAcl` 判定顺序):
1. `$SYS` 只读
2. 注册主题放行
3. 超管放行
4. **私有主题硬归属**(`/devices/{uuid}`、`/notify/{account}`)
5. `acl_enabled` 开关
6. 首条命中规则
7. `acl_default` 兜底
私有主题只看 `iot_device.member_id` + `iot_share`:设备本体放行、主人全权、`perm=2` 全权、`perm=1` 仅可订阅 `state|status|event|config|ack|ota/report` 且**不能发 `cmd`**;`/devices/+/state` 这类跨设备通配符默认拒绝。
---
## 八、后台接口(`/iot/backend/*`,Bearer JWT)
| 分类 | 接口 |
|---|---|
| 概览 | `GET index`、`statsData` |
| 连接 / 消息 | `GET connections`、`connectionsTable`、`messages`、`messagesTable` |
| 发布 / 设置 | `GET|POST publish`、`GET|POST setting` |
| 认证 | `GET auth`、`authTable`、`POST saveAuth`、`deleteAuth` |
| ACL | `GET acl`、`aclTable`、`POST saveAcl`、`deleteAcl` |
| 设备 | `GET device`、`deviceTable`、`deviceLogs`、`deviceConfig`;`POST saveDevice`、`resetDeviceSecret`、`resetActivateToken`、`deleteDevice`、`saveDeviceConfig` |
| 规则 | `GET rule`、`ruleTable`;`POST saveRule`、`deleteRule` |
| 场景 | `GET scene`、`sceneDetail`、`sceneLogs`;`POST saveScene`、`deleteScene`、`toggleScene`、`runScene` |
| 分组 | `GET group`;`POST saveGroup`、`deleteGroup` |
| 告警 | `GET alertsPage`、`alerts`;`POST readAlertAll` |
| OTA | `GET ota`、`otaList`、`otaTable`;`POST saveOta`、`deleteOta`、`toggleOta`、`publishOta` |
> ⚠️ broker 配置**只在启动时读一次** → 后台改完「服务设置」必须重启 broker。
> ⚠️ 后台「删除设备」是**硬删**(清 `iot_device`/`iot_auth`/`iot_acl`/`iot_retain`)+ 设备端 NVS 仍留旧 uuid → 设备永久变砖,见 `device_register_mqtt.md` 的 BOOT 键重置章节。
---
## 九、已发现的不一致 / 待修正
1. `device_register_mqtt.md` 称「插件 API 的 `Result::success` 用 `code=1` 表示成功」——**错误**,`Result::success()` 内 `code` 硬编码为 `0`。
2. 同文档「用户绑定设备识别码」返回示例写 `"code": 1` 表示绑定成功——**反了**,实际 `code=0` 成功、`code=1` 失败;且 `Client` 系列字段名是 `msg` 而非 `message`。
3. 文档 / 代码注释里出现的 `/iot/api/myDevices`、`/iot/api/mqttTicket` 等(无 `client` 段)是历史写法;`route/app.php` 实际注册的是 `/iot/api/client/*`。
4. `setCount()` 与 `apiSuccess()` 不配套 → 这些接口的响应里没有 `total`。
5. `/iot/api/publish` 无鉴权、无 topic 校验,属安全隐患(见第三节)。
+317
View File
@@ -0,0 +1,317 @@
# IoT 设备主题速查(功能 / 参数 / 权限)
> 本文只讲**主题**(topic):值不值得用、谁发谁订、payload 每个字段是什么、服务端收到后做什么、有哪些坑。
> REST 接口清单见 `iot_api_reference.md` §二/§三;报文版式沿革见该文 §5.1;固件实现见 `addon/iot/firmware/water_temp/water_temp.ino`。
> 主题常量唯一出处:`addon/iot/service/DeviceService.php`(`TOPIC_*` 与 `deviceTopics()`)、`addon/iot/service/OtaService.php`(OTA 两条)。
---
## 一、命名空间总览
| 命名空间 | 归属 | 保护方式 |
|---|---|---|
| `/devices/{uuid}/…` | 单台设备私有 | **私有主题硬归属**(`iot_device.member_id` + `iot_share`),**不受 `acl_enabled` 开关影响** |
| `/notify/{account}/…` | 单个会员私有 | 同上,仅 account 本人 |
| `system/device/register/{mac}[/resp]` | 注册通道 | **恒定放行**(可匿名连接完成注册) |
| `$SYS/…` | Broker 内置指标 | 只读 |
```text
┌──────────────── 服务端(Broker 钩子 + outbox 消费器)────────────────┐
固件 ──publish──► │ state / status / event / ack / ota/report / register │
│ │ │
│ └─(业务处理)─► iot_device / iot_sensor_point │
│ iot_alert / iot_scene │
固件 ◄─subscribe─ │ config / cmd / ota ◄── outbox ◄── SensorConfigService / App / 场景 │
App ◄─subscribe─ │ state / status / event / ack / ota/report / notify/{account}/# │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 二、设备私有主题 `/devices/{uuid}/…`(8 条)
| 主题 | 方向 | QoS | retain | 发布者 | 订阅者 |
|---|---|---|---|---|---|
| `state` | 上行 | 0 | **1** | 固件 | 服务端、App(含 `perm=1` 只读共享) |
| `status` | 上行 | 0 | **1** | 固件(含 LWT) | 服务端、App |
| `event` | 上行 | 0 | **1** | 固件 | 服务端、App |
| `ack` | 上行 | 0 | 0 | 固件 | 服务端 |
| `ota/report` | 上行 | 0 | 0 | 固件 | 服务端 |
| `config` | 下行 | 1 | **1** | `SensorConfigService` | 固件 |
| `cmd` | 下行 | 调用方指定(默认 0) | 0 | App `sendCommand` / 场景 `send_cmd` / 后台发布 | 固件 |
| `ota` | 下行 | 1 | 0 | `OtaService::publish` | 固件 |
**retain 的分工是刻意的**:四条 **retain=1** 的是「当前状态」(App/固件订阅瞬间就拿到全量现状,首屏不用轮询);四条 **retain=0** 的是「一次性动作/回执」(重连时不该收到过期的命令和旧回执)。
### 2.1 `state` —— 影子(设备现状快照)
功能:设备的**唯一状态真源**。服务端写「影子」(retain 兜底),App 面板/曲线/骨架都由它驱动。
固件 v1.7.0 起(旧名见 §2.9):
```json
{ "ts": 1789000000, "ver": "1.7.0", "rssi": -57,
"iv": 120, "cv": 7,
"pos": [
{ "t": 45.63, "df": 0.25,
"pr": [ ["A3F2", 45.50], ["B71C", null] ] },
null
] }
```
| 字段 | 参数含义 |
|---|---|
| `ts` | NTP Unix 秒;`0` = 未对时(服务端用接收时间兜底) |
| `ver` | 固件版本字符串。**判定版式不要看它**(只看键在不在) |
| `rssi` | 信号强度 dBm(诊断用) |
| `iv` | 本条覆盖秒数(旧名 `interval`)→ 曲线前向填充有效期 `[ts-iv, ts]` |
| `cv` | 设备当前**生效**的配置版本(旧名 `cfg_ver`)。与本地不一致 → 重拉 `deviceConfig` |
| `pos[]` | **数组下标 = 位置序号**(= 一条 OneWire 总线);`null` = 该位置未启用(占位撑下标) |
| `pos[].t` | 位置温度(有效探头均值);`null` = 无有效读数(**显示 `--`,不是 0**) |
| `pos[].df` | 位置内极差(max − min);`null` 同 `t` |
| `pr[]` | 该总线各探头(`pr` = probes 明细);**下标 = 总线槽位**,尾部空槽已截 |
| `pr[][0]` | 探头身份(ROM 短码,4 位 hex);`null` = 未识别 |
| `pr[][1]` | 该槽温度;`null` = 无读数。`[null,24]` = 有读数无身份;`[null,null]` = 没插器件 |
> ⚠️ **报文里没有位置名**。位置别名只来自 `deviceConfig` 接口(`SensorConfigService::format()`);App 靠下标/元素序号匹配骨架。
> ⚠️ **没有 `index`**:上行靠下标定位,未启用位置写 `null` 占位 —— **下标永不前移**。`pr` 同理,下标恒等于总线槽位,禁止 `filter` 掉 null 重编号。
> ⚠️ **没有 `pin`**(≤1.6.2 老固件仍会带):pin 是编译期常量,改由上线时 `/status` 报一次(见 §2.2)。
> ⚠️ `t` 是**同名陷阱**:≤1.5.0 的 `data[].t` 靠 `v` 标志判有效(`v=0` 时照样带 `0`),≥1.6.0 靠 `null` 判 ⇒ 解析必须**先判有没有 `v`**,否则把「没读数」存成 `0.00℃`。
上报节奏:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。
服务端收到后:`DeviceLogService::log` → `SensorPointService::capture`(时序落库 `iot_sensor_point` + 云端阈值复核写 `alert_mask`)→ `SceneService::onMessagePublished`(场景条件评估)。
### 2.2 `status` —— 在线态 + 引脚表
| 场景 | payload |
|---|---|
| 上线 | `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表) |
| 掉线(LWT) | `{"online":0}` |
功能三合一:
1. **在线态真源**(App 在线/离线、后台在线数)。与 `event` 分主题就是为了避免 retained 互相覆盖。
2. **引脚表缓存**:`DeviceService::onStatusMessage()` 把 `pins` 写成 `iot_device.pins`(CSV),随 `deviceConfig` 的 `pins` 下发给 App。pin 只是**属性**(换板会变),位置序号才是身份。
3. **自动补位置 + 配置重发**:拿到 pins 后 `fillMissing()` 按下标补齐缺失位置(只补不覆盖、停用不复活、有新增才 `bumpVer`+push),无新增则 `repush()`(同版本重发,治「设备端被打瘫、两侧版本却对齐」)。
> ⚠️ 设备从未上报过(≤1.6.2 或从未上线)时 `pins` 为 `[]`,App 回退用 state 里的 `pin` 自补显示。
### 2.3 `event` —— 告警 / 事件
**两种形态,任选其一**:
```jsonc
// A. 结构化温度告警(alerts 下标 = 位置序号;未告警位置写 null 占位)
{ "type": "alert",
"alerts": [ { "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov",
"pr": [ [0, "A3F2", 92.5] ] }, null ] }
// B. 通用事件(任何品类可用,服务端零改动)
{ "title": "风扇已开启", "content": "手动触发 / 定时触发", "level": 2 }
```
| 字段 | 含义 |
|---|---|
| `alerts[].alias` | 告警发生时的**位置叫法快照**(保留写全,让文案能独立成立) |
| `alerts[].t` / `.df` | 触发时的温度 / 极差 |
| `alerts[].rs` | 原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限(服务端 `AlertService::REASON_TEXT` 展开中文,**改文案不用重烧固件**) |
| `alerts[].pr[]` | 只列异常探头(稀疏)⇒ 每条是**三元组** `[槽位, 身份, 温度]`,槽位必须显式带 |
| 通用分支 | `title` / `content`(缺省取 `msg` 或原文)/ `level`(默认 2) |
服务端:`AlertService::onDeviceEvent()` 逐条落 `iot_alert`(带 `pos_index`)+ 推送 `/notify/{account}/alert`。
≤1.4.0 老版式(`bad[]` / `bad_ids[]`,无位置号 ⇒ 记 `POS_NA`=255)与 ≤1.6.2 的 `probes` 键名服务端仍认。
无告警时不发(`event` 是 retained,所以「无告警」靠不发布,而不是发空数组)。
### 2.4 `ack` —— 回执
```json
{ "type":"config_ack", "success":true, "message":"Saved 3 sensor(s)", "cv":7,
"mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" }
```
| 字段 | 含义 |
|---|---|
| `type` | 恒为 `config_ack`(**没有单独的 `cmd_ack`**,命令执行结果也复用本格式) |
| `success` | 写入结果 |
| `message` | 失败原因(如 `missing pos` —— 旧固件收到新格式版式会这样回,**可见、不静默**) |
| `cv` | 写进 NVS 的配置版本,服务端据此记 `sync_ver` ⇒ `applied = (sync_ver >= cfg_ver)` |
服务端:`SensorConfigService::onAck()`,**只在 `type` 为空或 `config_ack` 时处理**。
> ⚠️ **缺 `type` 的回执会被当成 config_ack**(写 `sync_ver=0, sync_at=now`)。非数值品类的回执务必带自己的 `type`(如 `{"type":"sw_ack"}`),或者干脆不发 `/ack`。
### 2.5 `cmd` —— 命令 / 控制(透明管道)
```json
{"action":"report"} // 立刻上报一次 state
{"action":"reboot"} // 重启(保留凭证/配置)
{"action":"wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页(凭证/配置保留)
{"action":"clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册(WiFi 保留)
{"action":"factory_reset"} // 全清(含 WiFi)→ 配网页
```
| 要点 | 说明 |
|---|---|
| 服务端**不校验 payload** | `Client::sendCommand` 任意 JSON 直接进 outbox;**指令词表由固件定义**(`onCmdJson`),服务端零改动 |
| `action` 之外的字段 | 固件可自行扩展(如开关类 `{"action":"set","sw":[1,0]}`);未知 action 固件只打 `[CMD] unknown action=…`,不报错 |
| QoS | 由调用方指定(`sendCommand` 的 `qos` 参数,默认 0) |
| retain | 恒 0(一次性动作) |
| 权限 | 需对该设备有**发布**权:主人 / `perm=2` 共享者。`perm=1` 连订阅 `cmd` 都被拒 |
| 回执 | 清除类动作先 `ack` 再重启(`delay(500)` 等 ack 出网);`reboot` 无 ack |
### 2.6 `ota` / `ota/report` —— 固件升级
```jsonc
// 下行 /devices/{uuid}/ota(QoS1,retain=0)
{"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456}
// 固件实际只用 url + version,hash/size 被忽略
// 上行 /devices/{uuid}/ota/report(QoS0,retain=0)
{"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"}
```
服务端 `OtaService::onReport()`:
| `state` | 处理 |
|---|---|
| `done` | `iot_alert` level 1「固件升级完成」 |
| `fail` | `iot_alert` level 3「固件升级失败」+ 错误原因 |
| 其它 | 只推 `/notify/{account}/ota` 进度通知(`{"type":"progress",…}`),**不写告警表**(避免刷屏) |
---
## 三、注册主题(MAC 即插即用)
| 主题 | 方向 | QoS | retain | payload |
|---|---|---|---|---|
| `system/device/register/{mac}` | 设备 → 服务端 | 0 | 1(固件传 `true`) | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | 1 | 0 | `{"code":0,"message":"ok","data":{uuid,secret,topics,…}}` |
流程与铁律:
1. **必须先订阅 `resp` 再发布注册** —— 回执非 retain,错过即丢(固件 `mqttSubscribeAll()` 里对订阅结果做了检查,订阅失败就直接不发注册)。
2. `mac` 必须 12 位小写 hex(去冒号),且与主题段一致(不一致回 `payload mac 与主题不一致`)。
3. 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver`,**不再给 secret**。
4. ACL 恒定放行,**可匿名连接**完成注册(此时还没有凭证)。
5. 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token(走 `activate_token` 激活)。
6. 服务端处理:`Broker::handleDeviceRegister()` → `DeviceService::registerByMac()`。
---
## 四、通知主题 `/notify/{account}/{子主题}`(QoS1,retain=1)
| 子主题 | 触发场景 | payload 摘要 |
|---|---|---|
| `alert` | 云端告警(温度越限、设备离线、OTA 完成/失败…) | `{id,type:"alert",level:1..3,title,content,uuid,pos,time}` |
| `event` | 设备 `/event` 上报 | `{id,type:"event",level,title,content,uuid,time}` |
| `scene` | 场景 `notify` 动作 | `{id,type:"scene",level:2,title,content,uuid,time}`(`SceneService` 走 `AlertService::add($memberId,'scene',2,…)`,`title` 取动作的 `topic`、`content` 取 `payload_json`) |
| `ota` | 升级**进度** | `{type:"progress",uuid,state,progress,version,time}` |
- 子主题名就是 `AlertService::add()/push()` 的 `type` 参数值(`alert` / `event` / `scene` / `ota`),payload 里的 `type` 与它同值。
- `time` 均为 Unix 秒。
- retain=1 ⇒ App 冷启动订阅后立刻能收到最近一条(当作「有未读」的兜底提示;真正的未读数是 `alertUnread` 接口)。
- **App 只订阅 `/notify/{account}/#` 一条即可**。
- 该主题仅 account 本人可订(`/notify/{账号}/…`,服务端内部推送不经 ACL)。
---
## 五、权限矩阵(谁能订什么 / 发什么)
`Auth::checkPrivateTopic()` 的裁决(顺序在常规 ACL **之前**,与 `acl_enabled` 开关**无关**):
| 身份 | `/devices/{uuid}/*` 权限 |
|---|---|
| 设备本体(`username=clientId=uuid` 且 `iot_auth` 密码已验) | 全权 |
| 设备主人(`iot_device.member_id`) | 全权(含 `cmd`/`ota` 发布) |
| 共享 `perm=2`(`ShareService::PERM_CTRL`) | 全权 |
| 共享 `perm=1`(`ShareService::PERM_VIEW`) | **仅订阅** `state` `status` `event` `config` `ack` `ota/report`(`Auth::isReadonlyTail()`);**不能订 `cmd`/`ota`、不能发任何东西** |
| 认证账号(`iot_auth`,非会员) | 默认拒绝;仅「ACL 规则显式点名该账号 allow」可通 |
| 匿名 / 未知身份 | 一律拒绝 |
补充规则:
- **跨设备通配符订阅**(`/devices/+/state`、`/devices/#`)默认拒绝 —— 单一归属表达不了,放行等于把全部设备暴露给一个人;要放行必须 ACL 规则**点名该账号**。
- 匹配的 ACL 过滤器必须**以 `/devices/` 或 `/notify/` 开头**,否则直接封堵(防 `#` 越权)。
- 服务端在每次 App 换 MQTT ticket 时自愈 ACL 快照(`mqttTicket` → `resyncMemberAcl` + `resyncMember`):自动生成的 4 条 `auto:member:{uuid}:*`(`state` 订 / `status` 订 / `cmd` 发 / `notify` 订)是**生成即快照**,新增规则不会自动补到老设备上,靠这次 resync 补齐。
- 私钥(`secret`)只走注册回执和后台设备管理,**不进任何主题**。
- ⛔ 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)⇒ App 必须处理 `onSubAck`,否则表现为「连上了但一片空白」。拒因定位:`Auth::lastDenyReason()`。
---
## 六、服务端钩子分发映射(`Broker.php`)
QoS0/QoS1 消息在 `handlePublish()` 末尾触发;QoS2 延迟到 `handlePubRel()`(逻辑两处**必须同步改**)。`$SYS` 前缀不触发任何钩子。
| 触发条件 | 调用 |
|---|---|
| 任意非 `$SYS` 主题 | `DeviceLogService::log($topic,$msg,'in')` |
| 任意主题 | `SensorPointService::capture()`(内部自行判 `pos`,非 state 立即 return) |
| 任意主题 | `DeviceService::onStatusMessage()`(内部判 `status`) |
| 任意主题 | `SceneService::onMessagePublished()`(场景条件评估 → 动作入 outbox) |
| `^/devices/([^/]+)/ota/report$` | `OtaService::onReport()` |
| `^/devices/([^/]+)/event$` | `AlertService::onDeviceEvent()` |
| `^/devices/([^/]+)/ack$` | `SensorConfigService::onAck()` |
| `^system/device/register/([^/]+)$` | `Broker::handleDeviceRegister()` |
下行链路(**不经 Broker 直连**):业务代码 → `Store::enqueueManual(topic, payload, qos, retain, source)` 写 `iot_outbox` → outbox 消费器(0.5s)发布并投递。
| 入队方 | 主题 | qos / retain |
|---|---|---|
| `SensorConfigService::push()` | `config` | 1 / 1 |
| `Client::sendCommand()`、场景 `send_cmd`、后台发布 | `cmd` | 调用方 / 0 |
| `OtaService::publish()` | `ota` | 1 / 0 |
| `AlertService::push()` | `/notify/{account}/{type}` | 1 / 1 |
---
## 七、字段/协议的硬约束(改代码前必读)
1. **上下行的唯一不对称:`pos[]` 的 `index`**
- 上行 `state.pos[]`:**靠数组下标**定位序号(未启用写 `null` 占位,每位置省 ≈10B);
- 下行 `config.pos[]`:**只含启用位置** ⇒ 下标塌缩 ⇒ **元素必须带 `index`**,固件不许用下标顶替。
- 两边不是「漏改」,是高频上行压字节换来的。
2. **`config` 是全量覆盖语义**:`pos` 里没有的位置一律停用。⚠️ 位置**全停用会下发 `pos:[]`**,固件对空数组 = 清空 NVS ⇒ 后台至少留一个启用(`push()` 对「真源为空」也会拒发)。
3. **改名要同步的清单**(少一处就静默失效):
- 报文键名 → `SensorPointService::capture()`、`SceneService::dig()`/`upgradeStatePath()`、App `normalizePositions()`/`normalizeProbes()`、后台 `device.html`;
- 位置序号键 `index` → `SensorConfigService::normalize()`、App `device-panel`、后台 `device.html`。
4. **协议升级顺序:先烧固件 ≥1.7.2,再上线服务端**。旧固件收新格式只回 ack `missing pos`(配置不生效但**不误报成功**,一眼可见)。
5. **同一语义只留一个键名**:历史上「`index` + `i` 双发」=一个字段挂两把钥匙,改一边就静默拆锁(2026-09-14 现场事故:ack 成功、位置全没启用)。兼容层已于 2026-09-15 全删。
6. 服务端/App 对**上行**一律「新键优先、旧键兜底」(`pos??positions??data`、`iv??interval`、`cv??cfg_ver`、`pr??probes`)—— **老固件不用重烧**。
---
## 八、常见排查
| 现象 | 先看这里 |
|---|---|
| App「连上了但没有任何数据」 | `onSubAck` 是否处理 `0x80`;`Auth::lastDenyReason()`;订 `/notify/{自己账号}/test` 能成功而设备主题失败 = 降级成 `anonymous-named`(填了用户名但三种验证全没过,member_id=0) |
| 设备在线但 `applied=false` | `ack` 是否收到(`sync_ver` 是否更新);`cv` 是否回对;retained `config` 是否真的发出去(outbox 卡住) |
| 位置配置「老是自动变化」/ 影子全填位置 0 | `iot_sensor_cfg.update_at` 集中在上线时刻 = `fillMissing()` 正常行为;集中在用户操作时刻 = 保存路径读错键回写 |
| 设备上报了但时序库没数据 | `capture()` 首行 `if (!is_array($rawPos) || !$rawPos) return;` —— 报文里没有 `pos`/`positions` 会被**静默跳过**(非数值品类属正常) |
| 告警没进 `iot_alert` | `/event` 是否真的发(retain=1 的旧报文会被 Broker 缓存,注意别被历史 retained 误导);通用分支需要 `title`+`level` |
| 重启后设备不再上报 | 硬删设备后设备端 NVS 仍留旧凭据 ⇒ CONNACK 4/5;救:BOOT 3s 重置 / 向 `…/resp` 发 `{"code":0,data:{uuid,secret}}` / 重烧勾 Erase |
| 想给新品类加控制 | **不用改服务端**:`/cmd` 是透明管道,指令词表由固件定义;状态走 `/state`,事件走 `/event`(通用分支) |
---
## 九、待修 / 已知风险
1. 🔴 `/iot/api/publish`(`Mqtt::publish`,`noNeedLogin=['*']`)**无鉴权、无 topic 前缀校验** ⇒ 任何人可往任意主题发消息。带命名空间隔离的 `Client::publish` 未被路由绑定。上线前建议改路由或补鉴权。
2. `cmd` 与 `config` 的回执复用同一 `type=config_ack`、同一 `/ack` 主题 ⇒ 无法区分「命令执行结果」与「配置写入结果」(当前靠 `message` 文本区别)。
3. `Mqtt::publish` 失败时 `error()` 之后没有 `return`,会继续执行到 `success()`(响应内容自相矛盾)。
4. 固件头注释把上行 `state/status/event` 标为「QoS1」,实际 `PubSubClient::publish(topic,payload,retained)` 走 **QoS0**(第三个参数是 retain,不是 QoS);`subscribe` 才是 QoS1。文档以**实际行为**为准。
---
## 十、变更记录
| 版本 | 变化 |
|---|---|
| v1.7.2(下行) | 下行键名与上行统一(`cv`/`pos`/`pn`);删除全部下行兼容层(`positions`/`data[]`/`cfg_ver`/`index`+`i` 双发);升级顺序要求「先烧固件」 |
| v1.7.0(上行) | 短键 `iv`/`cv`/`pos`/`pr`;去掉顶层 `uuid` 与每位置 `pin`(`pin` 改由 `/status` 上线报一次);约 549B → 300B |
| v1.6.1 | 探头身份 ROM 短码(`pr[][0]`) |
| v1.6.0 | `positions[]` 结构化 + `event` 结构化告警(`alerts[]`/`rs` 短码) |
| v1.5.0 | `positions[].index` 对齐(v1.6.0 起删除该键,改靠下标) |
| ≤1.4.0 | `data[]` + 顶层 `interval`;有效性靠 `v` 标志 |
> 更细的报文演进与压字节账目见 `iot_api_reference.md` §5.1。
+26 -5
View File
@@ -16,6 +16,11 @@ YwxApp 的数据库结构自愈引擎收敛在 `ywxapp/model/BaseModel.php` 的
所有方法内部都包了 `try/catch`:自愈失败(如数据库账号无 `CREATE/ALTER` 权限、`NO_BACKSLASH_ESCAPES` 导致 SQL 出错)会**被静默吞掉并记录日志**,不会阻断业务——但意味着「自愈没起作用」时不会立刻报错,需要查日志才能发现。
> **模块自带 SQL 的情况**:`ensureTableFromInstall()` 只认核心的 `public/install/install.sql`。
> 中心站专用模块 `appmall` 有自己的一份事实源 `app/appmall/install.sql`,由
> `app/appmall/library/AppmallSchema` 用同样的「解析 SQL → `ensureTable()`」方式执行,
> 模块内不再内联重复 DDL。
## 二、模型壳约定
每个需要自愈的模型都自带一个静态方法:
@@ -38,6 +43,21 @@ public static function ensureSchema(): void
**幂等性**:所有自愈都先探测(SHOW TABLES / SHOW COLUMNS)再操作,已存在则跳过,重复调用安全。
**自动触发(appmall 采用)**:`app/appmall/model/` 下的 **13 个模型与本模块 13 张表一一对应**
(清单登记在 `AppmallSchema::MODELS`,加表必查),每个都在 `initialize()` 里调用
`self::ensureSchema()` —— 模型一被使用(含 `Model::where()` 这类静态查询)就先兜底建表 + 补老库扩展列,
业务入口无需记得手动调用,不会再撞 1146(缺表)/ 1054(缺列)。
`AppmallSchema::ensureTable()` 内部按「表」和「表+列」做了进程内 memo,每请求每表最多一次 `SHOW TABLES`,
开销可忽略。(核心库同样有先例:`MemberProp::initialize()` / `CommonCard::initialize()`。)
**应用级中间件兜底(appmall 采用)**:`app/appmall/middleware.php` 注册
`AppmallSchemaMiddleware`,任何进入 `/appmall/*` 的请求(前台页 / 后台页 / 对外 API / 未注册路由)
在业务之前先 `AppmallSchema::ensure()` 一次(见第三节第 3 小节)。
> 为什么要「模型 + 中间件」双层:模型兜底保证**直接使用模型**的代码路径不缺表;
> 中间件兜底保证**任何入口**(含控制器里大量 `Db::name('appmall_xxx')` 直查、
> 以及将来新增的页面 / 接口)不会因为某处忘了调用而缺表。两者都幂等,重复执行无副作用。
## 三、触发时机(谁在什么时候调用 ensureSchema)
自愈**不会定时 / 不会自动**,全部是「业务请求时按需触发」。
@@ -56,11 +76,12 @@ public static function ensureSchema(): void
| `ywxapp/model/Notice.php` | `getActiveTopNotices()` / `getList()` | 前台公告条渲染、公告列表页 |
| `ywxapp/model/Ad.php` | `getByPosition()` / `getSlots()` | 广告位渲染 |
### 3. 中间件 / 蜘蛛统计
### 3. 中间件
| 入口 | 调用 | 触发场景 |
| --- | --- | --- |
| `SpiderStat` 中间件 | `ensureTables()` | 每次请求(蜘蛛日志 / 统计表) |
| `ywxapp/middleware/SpiderStat`(全局) | `ensureTables()` | 每次请求(蜘蛛日志 / 统计表) |
| `app/appmall/middleware/AppmallSchemaMiddleware`(应用级) | `AppmallSchema::ensure()` | **任意** `/appmall/*` 请求(前台 / 后台 / 对外 API) |
### 4. 插件安装 / 升级
@@ -68,9 +89,9 @@ public static function ensureSchema(): void
| --- | --- | --- |
| blog | `BlogSchema::ensure*` | 插件业务方法 |
| forum | `ForumSchema::ensure*` | 插件业务方法 |
| appmall(中心站) | `AppmallSchema::ensure*` | 开发者中心业务方法 |
| appmall(中心站) | 应用中间件 `AppmallSchema::ensure()` + 各模型 `ensureSchema()`(`initialize()` 自动触发) | 任意 `/appmall/*` 请求,或直接使用任一 appmall 模型 |
| haonav / seo | `Addon.php::upgrade()` / `ensureTablesFromInstallSql()` | 插件升级 / 安装 |
| wxchat / mqttbroker / mqpay 等 | 各自 Schema 类 | 插件初始化 |
| wxchat / iot / mqpay 等 | 各自 Schema 类 | 插件初始化 |
## 四、线上老库迁移不在自愈范围
@@ -85,7 +106,7 @@ public static function ensureSchema(): void
1. 确认表是否在 `install.sql` 中有建表语句(事实源)。
2. 若是核心 / 插件表,确认对应 `ensureSchema()` 是否被调用(见第三节入口)。
3. ⚠️ **高频根因:运行时没取到表前缀**。`ensureSchema()` 若用 `Db::getConfig('prefix')` 取前缀,在部分线上环境(多应用路由 / CLI / Db 配置未就绪)会返回空字符串,导致自愈建出**无前缀的表**(如 `notice`),而模型 `name` 拼前缀后查的是 `wxapp_notice` → 1146。
- **修复**:所有模型壳 / 控制器一律改用 `BaseModel::currentPrefix()` 取前缀(优先 `config/database.php`,回退 `Db::getConfig`),并已统一修复 `Notice` / `Ad` 及 `appmall` / `appmall` 控制器。
- **修复**:所有模型壳 / 控制器一律改用 `BaseModel::currentPrefix()` 取前缀(优先 `config/database.php`,回退 `Db::getConfig`),并已统一修复 `Notice` / `Ad`;`appmall` 的控制器 / 服务原先各自内联 DDL 且用 `Db::getConfig('prefix')`,现已全部收敛到 `AppmallSchema`(事实源 `app/appmall/install.sql`,统一走 `currentPrefix()`),并由应用中间件统一触发。
- 自查:`grep "Db::getConfig('prefix')"` 应只剩 0 处(全部换成 `currentPrefix`)。
4. 若前缀正确、`ensureSchema()` 已调用仍 1146 → **多半是线上 DB 账号无 CREATE/ALTER 权限**,自愈被静默吞掉。
- 根治:给 DB 用户授予建表 / 改表权限,自愈会自动补齐;
+33
View File
@@ -0,0 +1,33 @@
# 管理员操作日志(backend_log)
后台的写操作统一留痕,落 `backend_log` 表(`id / aid / action / ip / remark / create_at`)。
## 日志从哪来
| 来源 | 位置 | 说明 |
|------|------|------|
| 通用增删改 | `BackendBase` 的 `create / edit / del / destroy / restore / multi` | 操作**已成功提交**后记录;失败不留痕(避免失败重试刷屏) |
| 登录 / 退出 | `BackendBase::login()` / `logout()` | 登录**成功与失败都记**;失败时 `aid=0`(还不知道是谁) |
| 业务补充 | `AdminLogger::write($action, $remark, $aid)` | 关键业务操作(如删除附件、清理日志)按需补充 |
`action` 约定为 `controller/action`(如 `Admin/create`)或语义动作(`登录成功` / `退出登录`);
`remark` 放可读摘要(如 `ID:12`)。两字段都有长度上限(100 / 255),超长自动截断。
```php
use ywxapp\library\AdminLogger;
AdminLogger::write('order/refund', '订单号:20260101001,金额:39.00');
AdminLogger::login($account, true, '', $aid); // 登录成功
AdminLogger::login($account, false, '密码错误'); // 登录失败
```
日志写入**永不抛异常**(内部吞掉):审计功能不能反过来阻断业务。
## 后台查看
系统管理 → **操作日志**(`app/backend/controller/Adminlog.php`,权限 `adminlog:index|delete|clean`,首次访问自动补录):
- 按账号 / 昵称 / IP / 备注关键字检索,按动作类型下拉筛选,支持日期区间;
- 删除选中;按保留天数清理(填 `0` = 清空,页面有二次确认)。
CLI 清理:`AdminLogger::clean($days)`,同样 `0` = 清空。
+547
View File
@@ -0,0 +1,547 @@
# 智慧生活全流程设计(iot 插件扩展)
> 版本:v0.1(设计稿)
> 日期:2026-08-21
> 目标:以米家/华为智慧生活的核心模型,在现有 `iot` 插件上落地「设备接入 → 云中枢 → App 控制 → 场景自动化 → 告警/OTA」完整闭环。
---
## 一、现状盘点(已有能力)
| 模块 | 现状 | 说明 |
|---|---|---|
| MQTT Broker | ✅ `Broker.php` | 单进程内存态(worker=1),支持 retain / 离线队列 / 遗嘱 / QoS2 |
| 认证 | ✅ `Auth.php` | bcrypt 密码、匿名开关、框架会员直通(`allow_member_login`) |
| ACL | ✅ `Auth.php` | 按 user/client 匹配,`%u`/`%c` 占位符,自动生成设备/会员规则 |
| 影子 | ✅ `Store` + `DeviceService::shadow()` | 设备 `state` 主题 retain=1,离线可查最后状态 |
| 离线/命令 | ✅ `Store::enqueueManual()` | 后台/API 发布经 outbox 出栈,Broker 消费后内部路由,零外部依赖 |
| 设备绑定 | ✅ `DeviceService` | 扫码 SN → 激活 uuid/secret → 绑定会员 → 自动 ACL |
| 事件钩子 | ✅ `Broker` 内 `event('mqtt_message_published')` | 场景引擎的天然接入点 |
| 转发规则 | ⚠️ `RuleEngine.php` | 仅「源主题→外部 Broker」桥接,**无条件判断/动作执行** |
| 场景/自动化 | ❌ 无 | 本次设计核心 |
| 设备分组 | ❌ 无(仅有 `room` 文本字段) | 本次新增 |
| 告警/通知 | ❌ 无 | 本次新增 |
| OTA 升级 | ❌ 无 | 本次新增 |
| 设备日志 | ⚠️ 仅有全量 `message` 表 | 需新增设备维度日志 |
**关键架构事实**:
- Broker 为单进程内存态 → 场景规则可做进程内内存缓存(TTL 刷新,同 `Auth::loadAclRules` 模式)。
- 场景动作不阻塞主循环 → 统一走 `Store::enqueueManual()` 异步投递。
- 所有新增表遵循框架约定:`__PREFIX__iot_*`、时间字段 `create_at/update_at/delete_at`、字段命名禁裸 `name`(用 `title`)。
---
## 二、总体架构
```
┌─ 设备端 ────────────────────────────────────────────────┐
│ 扫码SN → 激活(uuid+secret) → MQTT连云 │
│ publish /devices/{uuid}/state(retain) ← 影子 │
│ publish /devices/{uuid}/event ← 事件/告警条件 │
│ publish /devices/{uuid}/ota/report ← OTA进度 │
│ subscribe /devices/{uuid}/cmd ← 控制命令 │
│ subscribe /devices/{uuid}/ota ← 升级指令 │
└───────────────────────────────────────────────────────────┘
│ MQTT
┌─ 云中枢(iot 插件)───────────────────────────────┐
│ Broker: 认证/ACL/retain/离线/遗嘱/事件钩子 │
│ 影子服务: retain 持久化 + shadow() 查询 │
│ 命令路由: outbox 出站队列 → Broker 消费 → 内部路由 │
│ ★ SceneService: 场景/自动化引擎(新增) │
│ 订阅 mqtt_message_published → 条件匹配 → 动作入 outbox │
│ 定时触发器(Workerman Timer 轮询 cron) │
│ ★ AlertService: 告警记录 + /notify/{account}/# 通知(新增) │
│ ★ OtaService: 固件/版本管理 + 升级指令(新增) │
│ ★ GroupService: 设备分组(新增) │
└───────────────────────────────────────────────────────────┘
│ HTTPS REST │ MQTT(实时)
┌─ App 端 ─────────────────┼────────────────────┼──────────┐
│ 会员JWT │ │ │
│ 登录/注册(/api/v1/user) │ │ │
│ 设备绑定/列表/控制 │ │ │
│ 分组管理 / 场景管理 │ │ │
│ 告警列表 / OTA 查询 │ │ │
│ 实时状态订阅(/devices/+/state) │
│ 实时通知订阅(/notify/{account}/#) │
└───────────────────────────────────────────────────────────┘
```
---
## 三、Topic 规范(完整)
沿用现有 `/devices/{uuid}/...` 体系,扩展事件/OTA/通知:
| Topic | 方向 | 用途 |
|---|---|---|
| `/devices/{uuid}/state` | 设备→云 | 状态上报(retain=1=影子),**已有** |
| `/devices/{uuid}/cmd` | 云→设备 | 控制命令下发,**已有** |
| `/devices/{uuid}/event` | 设备→云 | 事件上报(非状态,如"门被打开"),新增 |
| `/devices/{uuid}/ota` | 云→设备 | OTA 升级指令,新增 |
| `/devices/{uuid}/ota/report` | 设备→云 | OTA 进度/结果上报,新增 |
| `/notify/{account}/#` | 云→App | 会员通知(告警/场景执行结果),新增 |
ACL 同步扩展:
- 设备规则 `auto:device:{uuid}` 已含 `/devices/%u/#`(覆盖新增 event/ota 子主题,无需改)。
- 会员规则 `auto:member:{uuid}:*` 需新增 `/devices/%s/state` 与 `event` 只读订阅(命令仍走 REST 下发,避免复杂化)。
- 新增 `auto:notify:{account}`:`/notify/{account}/#` 只读,绑定设备时一并生成。
---
## 四、数据库表设计(新增)
> 前缀约定 `__PREFIX__` = 实际 prefix(如 `wxapp_`);时间字段 int 时间戳。
### 1. `__PREFIX__iot_group` —— 设备分组(房间/自定义)
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_group` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局分组)',
`title` varchar(64) NOT NULL DEFAULT '' COMMENT '分组名',
`type` varchar(16) NOT NULL DEFAULT 'room' COMMENT 'room=房间 custom=自定义分组',
`sort` int NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备分组';
```
### 2. `__PREFIX__iot_group_device` —— 分组-设备关联
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_group_device` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned NOT NULL DEFAULT '0',
`device_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'iot_device.id',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_device` (`group_id`,`device_id`),
KEY `idx_device` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分组设备关联';
```
### 3. `__PREFIX__iot_scene` —— 场景
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局场景)',
`title` varchar(64) NOT NULL DEFAULT '' COMMENT '场景名',
`icon` varchar(32) NOT NULL DEFAULT '' COMMENT '图标标识',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
`remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景';
```
### 4. `__PREFIX__iot_scene_trigger` —— 场景触发器
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_trigger` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'device_state' COMMENT 'device_state=设备状态/事件 schedule=定时 manual=手动',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'device_state: 主题过滤器(默认 /devices/+/state)',
`condition_json` text COMMENT '条件 JSON(见下)',
`cron` varchar(64) NOT NULL DEFAULT '' COMMENT 'schedule 用: 5段cron,如 0 8 * * *',
`enabled` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景触发器';
```
`condition_json` 约定(device_state 触发时按顺序全部满足才触发):
```json
[
{ "field": "temperature", "op": "gt", "value": 60 },
{ "field": "status", "op": "eq", "value": "on", "ref": "/devices/dev_abc123/state" }
]
```
- `field`:payload 内 JSON 字段路径(点分,如 `pos.0.t`),缺省按整条 payload 求值。
- `op`:`eq/neq/gt/gte/lt/lte/exists/contains`。
- `value`:比较值。
- `ref`(可选):引用另一设备的影子(`/devices/{uuid}/state`),不写则取**当前触发消息**的 payload。
> **state 报文改过三轮键名,路径请按 v1.7.0 词汇写**:
> `pos[下标]`(**下标即位置序号,已没有 `index`**)+ `t` / `df` / `pr[[身份, 温度]]`。
> 已配置的旧路径由 `SceneService::dig()` 在 payload 是新格式时自动改写,无需手工迁移:
>
> | 旧(≤1.4.0) | 旧(v1.5.0 / v1.6.0) | 现在(v1.7.0) |
> |---|---|---|
> | `data.0.t` | `positions.0.t` | `pos.0.t` |
> | `data.0.df` | `positions.0.df` | `pos.0.df` |
> | `data.0.vs.1` | `positions.0.probes.1.1` | `pos.0.pr.1.1` |
> | — | `positions.0.probes.1.0` | `pos.0.pr.1.0` |
> | 顶层 `iv` | `interval` | `iv` |
>
> `pr`(v1.6.0 起叫 `probes`)从「对象数组」变成了「二元组数组」,所以 `.N` 后面那一段从
> **字段名**变成了**分量下标**(`0`=身份、`1`=温度)—— 这是旧路径唯一需要"猜"的地方,
> 改写规则就是按这个映射。
>
> 两个**迁不了**的(路径取不到值 → 条件恒不成立,必须人工改):
> - `v`(旧的有效性标志):v1.5.0 起字段已删,等价于「`t !== null`」→
> 改用对 `pos.N.t` 判 `exists` / `gt`
> - `i` / `index`(位置号):v1.6.0 起位置序号就是数组下标,报文里不再有这个字段
>
> ⚠️ 场景路径会"跟着线上词汇走",而本项目已经改过三轮(`data`→`positions`→`pos`、
> `iv`→`interval`→`iv`)。**每次改报文键名都必须同步 `SceneService::upgradeStatePath()` 的
> `$map`**,否则库里已存的场景条件会取不到值 → **静默**恒不成立(不报错,最难查)。
>
> 例:`{"field":"pos.0.t","op":"gt","value":60}` = 位置 0 平均温度超 60℃。
### 5. `__PREFIX__iot_scene_action` —— 场景动作
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_action` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`sort` int NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'send_cmd' COMMENT 'send_cmd=下发命令 http=HTTP回调 notify=站内告警',
`device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT 'send_cmd: 目标设备',
`payload_json` text COMMENT 'send_cmd: 命令payload(JSON) / http: 请求体',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'http: 回调URL 或 notify: 通知标题',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景动作';
```
动作语义:
- `send_cmd`:`Store::enqueueManual('/devices/{uuid}/cmd', payload_json)` 下发命令(在线即达,离线入队)。
- `http`:`topic` 为回调 URL,`payload_json` 为请求体,POST 触发(可对接第三方 webhook/小程序推送)。
- `notify`:写 `iot_alert`(站内告警),并 retain 到 `/notify/{account}/notice`。
### 6. `__PREFIX__iot_scene_log` —— 场景执行日志
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`trigger_type` varchar(16) NOT NULL DEFAULT '',
`matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1命中 0未命中',
`message` text COMMENT '命中摘要/失败原因',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景执行日志';
```
### 7. `__PREFIX__iot_ota` —— 固件版本
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_ota` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`model` varchar(64) NOT NULL DEFAULT '' COMMENT '适用设备型号',
`version` varchar(32) NOT NULL DEFAULT '' COMMENT '版本号',
`filename` varchar(128) NOT NULL DEFAULT '' COMMENT '固件文件名',
`file_hash` varchar(64) NOT NULL DEFAULT '' COMMENT 'SHA256',
`size` int unsigned NOT NULL DEFAULT '0' COMMENT '字节数',
`url` varchar(255) NOT NULL DEFAULT '' COMMENT '固件下载地址',
`remark` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1可下发 0停用',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_model` (`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OTA固件版本';
```
### 8. `__PREFIX__iot_device_log` —— 设备日志(设备维度)
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_device_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`topic` varchar(255) NOT NULL DEFAULT '',
`payload` longtext,
`direction` varchar(8) NOT NULL DEFAULT 'in' COMMENT 'in=设备上报 out=下发',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_uuid` (`uuid`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备日志';
```
### 9. `__PREFIX__iot_alert` —— 告警记录
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_alert` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员',
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '关联设备(可空)',
`type` varchar(32) NOT NULL DEFAULT '' COMMENT 'offline/scene/condition/custom',
`level` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1提示 2警告 3严重',
`title` varchar(128) NOT NULL DEFAULT '',
`content` varchar(512) NOT NULL DEFAULT '',
`is_read` tinyint(1) NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警记录';
```
### 自愈建表
所有新表由 `Auth::ensureTables()`(或独立 `SchemaService`)追加 `CREATE TABLE IF NOT EXISTS`,遵循现有自愈模式,老库无需重装即可用。
---
## 五、服务层模块设计(新增)
### 1. `SceneService`(场景/自动化引擎)—— 核心
```
事件流:
Broker::handlePublish
├─ 持久化 retain / message 日志
├─ 写 iot_device_log(uuid 维度,若 topic 属于 /devices/{uuid}/#)
└─ event('mqtt_message_published', [topic, payload, client_id])
└─ SceneService::evaluate($topic, $payload, $clientId) // 同步评估,动作异步投递
```
```php
class SceneService
{
/** 规则内存缓存(TTL 30s,同 Auth ACL 模式;Broker 单进程,进程内安全) */
protected static array $sceneCache = [];
protected static int $loadedAt = 0;
/** Broker 事件钩子入口 */
public static function onMessagePublished(string $topic, string $payload, string $clientId): void;
/** 评估:遍历启用场景的 device_state 触发器 */
protected static function matchTrigger(array $scene, string $topic, string $payload): ?array;
/** 条件解析:condition_json + 可选 ref 影子读取 */
protected static function matchCondition(array $conds, string $payload, string $topic): bool;
/** 执行动作(全部入 outbox 异步投递,不阻塞 Broker) */
protected static function runActions(array $scene, string $triggerType): void;
/** 定时触发:Workerman Timer 每分钟扫描 cron 命中场景(单独挂载) */
public static function onTick(): void;
/** 手动触发(App/后台一键执行场景) */
public static function runScene(int $sceneId): array;
}
```
**设计要点**:
- 评估在 Broker 进程内同步进行,但**只做内存判断**;动作一律 `Store::enqueueManual()` 异步投递,DB 写操作轻量(日志/告警),不影响消息分发主循环。
- 引用其他设备影子时读 `iot_retain`(或 Broker 进程内 retained 缓存),避免实时依赖。
- `cron` 解析:5 段(分 时 日 月 周),实现一个最小解析器(约 40 行),不引入第三方依赖。
### 2. `GroupService`(设备分组)
```php
class GroupService
{
public static function ensureTable(): bool;
public static function create(int $memberId, string $title, string $type = 'room'): array;
public static function rename(int $groupId, int $memberId, string $title): void;
public static function remove(int $groupId, int $memberId): void;
public static function groups(int $memberId): array; // 含每组的设备数/在线数
public static function addDevice(int $groupId, int $deviceId): void;
public static function removeDevice(int $groupId, int $deviceId): void;
public static function devicesByGroup(int $groupId): array; // 复用 DeviceService::myDevices 增强字段
}
```
### 3. `AlertService`(告警/通知)
```php
class AlertService
{
public static function ensureTable(): bool;
public static function add(int $memberId, string $type, int $level, string $title, string $content, string $uuid = ''): int;
public static function push(int $memberId, string $topic, string $payload): void; // retain 到 /notify/{account}/{topic}
public static function list(int $memberId, int $page = 1, int $size = 20): array;
public static function markRead(int $memberId, array $ids = []): void;
public static function onDeviceOffline(string $uuid): void; // 可选:断线遗嘱触发离线告警
}
```
### 4. `OtaService`(固件升级)
```php
class OtaService
{
public static function ensureTable(): bool;
public static function upload(string $model, string $version, string $file, string $url): array; // 计算SHA256/大小
public static function list(string $model = ''): array;
public static function publish(int $otaId, array $deviceUuids): void; // 逐个 enqueueManual 到 /devices/{uuid}/ota
public static function onReport(string $uuid, string $payload): void; // 解析 /ota/report 进度写日志/告警
}
```
---
## 六、App 端 API 接口清单
> 统一前缀 `/iot/api/client/`,需会员 JWT。响应 `{code, msg, data, timestamp}`,code=0 成功。
### 分组(GroupService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `groupList` | — | 我的分组(含设备数/在线数) |
| POST | `saveGroup` | `title`, `type`, `id?` | 新建/编辑分组 |
| POST | `deleteGroup` | `id` | 删除分组 |
| POST | `groupAddDevice` | `group_id`, `device_id` | 设备加入分组 |
| POST | `groupRemoveDevice` | `group_id`, `device_id` | 移出分组 |
| GET | `groupDevices` | `group_id` | 组内设备(含影子/在线) |
### 场景(SceneService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `sceneList` | — | 我的场景列表 |
| GET | `sceneDetail` | `id` | 场景详情(触发器+动作) |
| POST | `saveScene` | `title`, `icon`, `triggers[]`, `actions[]`, `id?` | 新建/编辑(triggers/actions 为 JSON 数组字符串) |
| POST | `deleteScene` | `id` | 删除 |
| POST | `toggleScene` | `id`, `enabled` | 启用/停用 |
| POST | `runScene` | `id` | 手动执行(触发 manual 型动作) |
| GET | `sceneLogs` | `id`, `page` | 场景执行日志 |
### 告警(AlertService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `alertList` | `page`, `size`, `is_read?` | 我的告警 |
| POST | `readAlert` | `ids[]` | 标记已读 |
| GET | `alertUnread` | — | 未读数(App 角标轮询) |
### 设备日志 / OTA
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `deviceLogs` | `uuid`, `page` | 设备日志(in/out) |
| GET | `otaList` | `model` | 可用固件列表 |
| POST | `publishOta` | `ota_id`, `uuids[]` | App 触发升级(校验设备归属) |
### 实时通道(MQTT 直连,需 `allow_member_login`)
| 用途 | 订阅/发布 | 说明 |
|---|---|---|
| 设备实时状态 | 订阅 `/devices/{uuid}/state` | ACL `auto:member:{uuid}:state` 已授权 |
| 事件流 | 订阅 `/devices/{uuid}/event` | 新增只读授权 |
| 通知 | 订阅 `/notify/{account}/#` | 新增 `auto:notify:{account}` 只读授权 |
| 控制 | 发布 `/devices/{uuid}/cmd` | ACL 已授权(或走 REST `sendCommand`) |
---
## 七、后台管理接口清单
> 前缀 `/iot/backend/...`,AdminAuth 鉴权。
| 方法 | 接口 | 说明 |
|---|---|---|
| GET | `group` | 分组管理页 |
| POST | `saveGroup` | 新建/编辑(全局分组 member_id=0) |
| POST | `deleteGroup` | 删除 |
| GET | `scene` | 场景管理页(全局场景) |
| POST | `saveScene` / `deleteScene` | 场景维护 |
| GET | `sceneLogs` | 场景日志页 |
| GET | `ota` | 固件管理页 |
| POST | `uploadOta` / `deleteOta` / `publishOta` | 固件维护/下发 |
| GET | `alerts` | 告警列表页 |
| POST | `readAlert` / `deleteAlert` | 告警处理 |
| GET | `deviceLogs` | 设备日志页(按 uuid 过滤) |
菜单 `menu.json` 新增子项:智能设备(已有)→ 分组管理 / 场景联动 / 固件升级 / 告警中心(sort 7.x)。
---
## 八、场景引擎触发时序
```
设备 publish /devices/dev_x/state payload:{"temperature":65,"status":"on"}
│
▼ Broker::handlePublish
├─ retain 持久化(影子更新)
├─ message 日志 + device_log(uuid 维度)
├─ 转发给在线订阅者(App 实时)
└─ event('mqtt_message_published')
│
▼ SceneService::onMessagePublished
├─ 加载启用场景(内存缓存 TTL30s)
├─ 逐个匹配 device_state 触发器:
│ ├─ topic 匹配(/devices/+/state ✓)
│ ├─ 条件全部满足?(temperature>60 ✓)
│ └─ 命中 → 该场景进入执行
└─ runActions(scene)
├─ 写 scene_log(matched=1)
├─ action[send_cmd] → Store::enqueueManual('/devices/dev_y/cmd', {"power":"on"})
├─ action[http] → HTTP POST webhook(异步)
└─ action[notify] → AlertService::add + push('/notify/{account}/notice')
│
▼ Broker 消费 outbox → 下发到设备 /devices/dev_y/cmd
▼ App 收到 /notify/{account}/notice(在线即推 / retain 兜底)
```
**定时触发器**:Workerman Timer(每分钟)→ `SceneService::onTick()` → 匹配 `cron` 命中的 schedule 型场景 → `runActions`。手动触发:App `runScene` → 直接执行对应场景动作。
---
## 九、OTA 流程
```
后台上传固件(uploadOta) → 计算SHA256/大小 → 存 iot_ota
│
▼ 选中设备 publishOta
Store::enqueueManual('/devices/{uuid}/ota',
{"version":"1.2.0","url":"https://...","hash":"...","size":12345})
│
▼ 设备收到 → 下载固件 → 校验哈希 → 升级
设备上报 /devices/{uuid}/ota/report {"state":"downloading","progress":42}
│
▼ Broker → OtaService::onReport()
├─ 写 device_log
├─ 进度变更 → /notify/{account}/ota 通知
└─ state=done/fail → 告警/提示
```
---
## 十、告警触发源汇总
| 触发源 | 方式 | 说明 |
|---|---|---|
| 设备离线 | Broker 遗嘱消息 → `AlertService::onDeviceOffline` | 优雅/非优雅断开均可达(可选开关) |
| 场景条件命中 | `runActions` 内 `notify` 动作 | 与场景引擎天然复用 |
| 设备主动上报 | 设备 publish 到 `/devices/{uuid}/event` | payload 含 level/title/content 即转告警 |
| OTA 失败 | `OtaService::onReport` 异常分支 | 升级失败告警 |
---
## 十一、实施路线图(分阶段)
| 阶段 | 内容 | 交付物 | 依赖 |
|---|---|---|---|
| **P0** | 前置收敛:确认清理 `runtime/route_list.php` 使现有 `/iot/api/client/*` 路由生效 | 路由联调 | — |
| **P1** | 基础增强:`device_log` + `alert` 表、Broker 钩子写日志、遗嘱离线告警 | `DeviceLogService`/`AlertService` | — |
| **P2** | 场景引擎:`scene/trigger/action/log` 表 + `SceneService` + cron 定时器 + 后台管理页 + App API | 场景自动化核心闭环 | P1 |
| **P3** | 设备分组:`group` 表 + `GroupService` + 后台 + App API + `/notify` 主题与 ACL | 分组与通知 | P2 |
| **P4** | OTA:`ota` 表 + `OtaService` + 后台上传/下发 + 设备上报处理 | 固件升级闭环 | P3 |
| **P5** | App 实时推送文档 + 端到端联调验证脚本 | 验证报告 | P2-P4 |
**每阶段验收**:后台可用 + App API 可用 + `runtime/_devtest.php` 自动化断言通过(沿用现有测试脚本模式)。
---
## 十二、风险与注意
1. **Broker 单进程**:场景评估是同步的,条件/动作必须轻量;动作入 outbox 异步投递,禁止在评估中同步发送 MQTT。
2. **cron 解析**:实现最小 5 段解析器即可(不支持秒/年,避免依赖)。
3. **路由缓存**:新增后台/API 路由后必须清 `runtime/route_list.php`(沿用 `Remove-Item runtime/route_list.php`)。
4. **自愈**:所有新表走 `ensureTables()` 自愈建表,老库升级无需重装插件。
5. **命名**:新表/字段遵守框架铁律(禁裸 `name`、`__PREFIX__`、`create_at/update_at/delete_at`)。
6. **ACL 安全**:`/notify/{account}/#` 只能放行对应账号,绑定/解绑设备时同步生成/清理 `auto:notify:{account}` 规则(同 `syncMemberAcl`/`clearMemberAcl` 模式)。
+56
View File
@@ -0,0 +1,56 @@
# 巡检信息与清理缓存
内置的「系统管理 → 系统信息」页面已于 2026-09-28 下线(System 控制器 / `view/system` / `modules/system.js` 全部删除),
原因很直接:它展示的东西控制台首页顶部一字不差地都有(同一份 `SystemInfo::collect()`),留着就是两份要维护。
现在这两件事都在**控制台首页**(`/admin/console/index.html`,`app/backend/controller/Console.php`):
- 巡检信息:`Console::index()` 渲染,数据来自 `app/backend/library/SystemInfo.php`
- 清理缓存:`Console::clean()`,权限 `console:clean`(挂在「控制台」菜单下)
## 页面内容(控制台首页)
| 区块 | 说明 |
|------|------|
| 运行统计 | 管理员数、插件数、附件数、数据库占用(走 `DbBackup::tables()` 汇总) |
| 运行环境 | YwxApp / ThinkPHP / PHP / MySQL 版本、系统、服务器时间、上传限制、内存限制、脚本超时、禁用函数 |
| 磁盘 | 已用 / 总量 / 剩余,进度条(取 `app()->getRootPath()` 所在盘) |
| 目录权限 | `runtime` / `public/storage` / `config` / `addon` 是否存在、是否可写;不可写会导致缓存无法生成、上传失败、插件装不上 |
| 维护 | 清理数据缓存 / 模板缓存 / 全量清理(含路由缓存) |
| 安全提示 | 近 7 天操作条数、其中登录失败次数 |
| 近 7 天趋势 | 会员注册数、后台操作条数(真数据,`Console::collectStats()` 产出,ECharts 渲染) |
| 最新会员 / 最近操作日志 | 各取最近 8 条真数据 |
## 清理缓存
```text
POST /admin/console/clean body: type=cache|temp|all
```
- `cache` → `runtime/cache`
- `temp` → `runtime/temp`
- `all` → 上述两个 + `runtime/route_list.php`(下次请求自动重建)
**不碰 `runtime/log`**:操作日志(`docs/guide/operation_log.md`)与错误日志要留着。
清理动作本身会记一条 `console/clean` 日志(写在 `SystemInfo::cleanRuntime()` 里)。
## 巡检数据怎么取(给排障用)
- MySQL 版本:`Db::query('SELECT VERSION()')`
- 磁盘:`disk_total_space()` / `disk_free_space()` —— 注意这俩返回 **float**,
`formatBytes()` 参数必须收 float,否则严格类型下直接 TypeError(页面 500)。
- 统计用 `SystemInfo::safeCount()` 包一层:表缺失或查询异常时返回 0,
首页不会因为某张插件表没建就整体打不开。`Console::collectStats()` 里的逐个查询同理。
## 老库迁移
已装好的库不会自动跟着变(权限表没有自删逻辑),跑一次一次性脚本即可:
```bash
php scripts/migrate_console_clean.php # 预演
php scripts/migrate_console_clean.php --force # 执行
```
做的事:删掉 `system:index` 页面节点及其子权限与角色授权,把 `system:clean` **就地改名**为
`console:clean`(Route 同步改 `console/clean`,挂到「控制台」下)。
就地改名而不是新建,是为了保住 id ⇒ 保住角色授权表里既有的绑定。
@@ -1,61 +0,0 @@
-- ============================================================
-- 积分规则表 score_rule:name → title 字段重命名升级脚本
-- 作用:按「禁用裸 name 业务字段」命名规范(name 是浏览器/layui/TP
-- 保留语义),把积分规则表的 name 列(规则名称)改名为 title,
-- 与 install.sql / create_operate_tables.sql 的新定义保持一致。
-- 适用范围:已部署老库(新装库直接使用新定义,无需执行本脚本)。
-- 特性:
-- 1) 自动探测表前缀——无需手工替换,匹配任意 `<前缀>_score_rule` 表
-- (如 wxapp_、ywx_、yourown_)。
-- 2) 幂等:仅当「name 列存在 && title 列不存在」时才执行 CHANGE;
-- 重复执行安全。
-- 执行:
-- mysql -u<user> -p <database> < rename_score_rule_name_to_title.sql
-- 或 phpMyAdmin / Navicat 直接导入。
-- ============================================================
DELIMITER //
DROP PROCEDURE IF EXISTS `__migrate_score_rule_name`//
CREATE PROCEDURE `__migrate_score_rule_name`()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE tbl VARCHAR(64);
DECLARE cur CURSOR FOR
SELECT c1.TABLE_NAME
FROM information_schema.COLUMNS c1
WHERE c1.TABLE_SCHEMA = DATABASE()
AND c1.TABLE_NAME LIKE '%\_score\_rule'
AND c1.COLUMN_NAME = 'name'
AND NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS c2
WHERE c2.TABLE_SCHEMA = c1.TABLE_SCHEMA
AND c2.TABLE_NAME = c1.TABLE_NAME
AND c2.COLUMN_NAME = 'title'
);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO tbl;
IF done = 1 THEN
LEAVE read_loop;
END IF;
-- 与 install.sql 中 score_rule.name 的列定义保持一致,仅换列名
SET @sql = CONCAT(
'ALTER TABLE `', tbl, '` CHANGE COLUMN `name` `title` ',
'varchar(100) NOT NULL DEFAULT '''' COMMENT ''规则名称'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
END//
DELIMITER ;
CALL __migrate_score_rule_name();
DROP PROCEDURE IF EXISTS `__migrate_score_rule_name`;
@@ -0,0 +1,115 @@
-- ============================================================
-- 积分规则引擎升级脚本(已部署老库)
-- 作用:把 member_score_rule / member_score_log 升级到规则引擎版本
-- 1) score_rule:name → title(沿用 rename_score_rule_name_to_title.sql 的判定),
-- 新增 addon / limit_type / limit_times / unique_type / remark 五列
-- 2) score_log:新增 action / source / source_id 三列 + idx_rule / idx_dedup 索引
-- 适用范围:已装库。新装库由 public/install/install.sql 直接建新表,无需执行。
-- 特性:
-- 1) 自动探测表前缀,匹配任意 `<前缀>_score_rule` / `<前缀>_score_log`
-- 2) 幂等:列/索引存在则跳过,重命名仅在「有 name 且无 title」时执行
-- 执行:
-- mysql -u<user> -p <database> < upgrade_score_rule_engine.sql
-- ============================================================
DELIMITER //
DROP PROCEDURE IF EXISTS `__mig_score_addcol`//
CREATE PROCEDURE `__mig_score_addcol`(IN p_tbl VARCHAR(64), IN p_col VARCHAR(64), IN p_def VARCHAR(512))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_tbl AND COLUMN_NAME = p_col
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_tbl, '` ADD COLUMN `', p_col, '` ', p_def);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END//
DROP PROCEDURE IF EXISTS `__mig_score_addindex`//
CREATE PROCEDURE `__mig_score_addindex`(IN p_tbl VARCHAR(64), IN p_idx VARCHAR(64), IN p_cols VARCHAR(255))
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_tbl AND INDEX_NAME = p_idx
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_tbl, '` ADD INDEX `', p_idx, '` (', p_cols, ')');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END//
DROP PROCEDURE IF EXISTS `__mig_score_engine`//
CREATE PROCEDURE `__mig_score_engine`()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE tbl VARCHAR(64);
DECLARE has_name INT DEFAULT 0;
DECLARE has_title INT DEFAULT 0;
-- ---------- 1. 规则表 ----------
BEGIN
DECLARE cur CURSOR FOR
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE '%\_score\_rule';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
rule_loop: LOOP
FETCH cur INTO tbl;
IF done = 1 THEN LEAVE rule_loop; END IF;
SELECT COUNT(*) INTO has_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = tbl AND COLUMN_NAME = 'name';
SELECT COUNT(*) INTO has_title FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = tbl AND COLUMN_NAME = 'title';
IF has_name > 0 AND has_title = 0 THEN
SET @sql = CONCAT(
'ALTER TABLE `', tbl, '` CHANGE COLUMN `name` `title` ',
'varchar(100) NOT NULL DEFAULT '''' COMMENT ''规则名称'''
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
CALL __mig_score_addcol(tbl, 'addon', "varchar(50) NOT NULL DEFAULT '' COMMENT '归属插件标识(空=核心)'");
CALL __mig_score_addcol(tbl, 'limit_type', "tinyint(4) NOT NULL DEFAULT 0 COMMENT '限次周期 0不限1每日2每周3每月4累计'");
CALL __mig_score_addcol(tbl, 'limit_times', "int(11) NOT NULL DEFAULT 0 COMMENT '周期内最多触发次数(0=不限)'");
CALL __mig_score_addcol(tbl, 'unique_type', "tinyint(4) NOT NULL DEFAULT 0 COMMENT '去重 0不去重1按来源业务ID去重'");
CALL __mig_score_addcol(tbl, 'remark', "varchar(255) NOT NULL DEFAULT '' COMMENT '流水备注模板({title}等占位)'");
CALL __mig_score_addindex(tbl, 'idx_addon', '`addon`');
END LOOP;
CLOSE cur;
END;
-- ---------- 2. 流水表 ----------
BEGIN
DECLARE cur2 CURSOR FOR
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE '%\_score\_log';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
SET done = 0;
OPEN cur2;
log_loop: LOOP
FETCH cur2 INTO tbl;
IF done = 1 THEN LEAVE log_loop; END IF;
CALL __mig_score_addcol(tbl, 'action', "varchar(50) NOT NULL DEFAULT '' COMMENT '触发动作标识'");
CALL __mig_score_addcol(tbl, 'source', "varchar(50) NOT NULL DEFAULT '' COMMENT '来源模块/插件'");
CALL __mig_score_addcol(tbl, 'source_id', "varchar(64) NOT NULL DEFAULT '' COMMENT '来源业务ID(去重与追踪)'");
CALL __mig_score_addindex(tbl, 'idx_rule', '`rule_id`');
CALL __mig_score_addindex(tbl, 'idx_dedup', '`action`,`uid`,`source_id`');
END LOOP;
CLOSE cur2;
END;
END//
DELIMITER ;
CALL __mig_score_engine();
DROP PROCEDURE IF EXISTS `__mig_score_engine`;
DROP PROCEDURE IF EXISTS `__mig_score_addcol`;
DROP PROCEDURE IF EXISTS `__mig_score_addindex`;
@@ -1,161 +0,0 @@
{
"info": {
"name": "YwxApp API v1",
"description": "YwxApp 标准 App 接口集合(/api/v1)。code=0 成功,非0 失败,401 未登录。登录后从响应取 access_token,通过 Tests 脚本自动写入环境变量 token。",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "base_url", "value": "http://localhost/api/v1", "type": "string" },
{ "key": "token", "value": "", "type": "string" }
],
"item": [
{
"name": "Common",
"item": [
{
"name": "init 启动初始化",
"request": {
"method": "GET",
"url": "{{base_url}}/common/init"
}
},
{
"name": "config 站点配置",
"request": {
"method": "GET",
"url": "{{base_url}}/common/config"
}
}
]
},
{
"name": "User",
"item": [
{
"name": "register 注册",
"request": {
"method": "POST",
"url": "{{base_url}}/user/register",
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "account", "value": "testuser", "type": "text" },
{ "key": "password", "value": "123456", "type": "text" },
{ "key": "nickname", "value": "测试用户", "type": "text" }
]
}
}
},
{
"name": "login 登录",
"request": {
"method": "POST",
"url": "{{base_url}}/user/login",
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "account", "value": "testuser", "type": "text" },
{ "key": "password", "value": "123456", "type": "text" }
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"var json = pm.response.json();",
"if (json.code === 0 && json.data && json.data.access_token) {",
" pm.collectionVariables.set('token', json.data.access_token);",
" pm.collectionVariables.set('refresh_token', json.data.refresh_token);",
"}"
],
"type": "text/javascript"
}
}
]
},
{
"name": "refresh 刷新token",
"request": {
"method": "POST",
"url": "{{base_url}}/user/refresh",
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "refresh_token", "value": "{{refresh_token}}", "type": "text" }
]
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"var json = pm.response.json();",
"if (json.code === 0 && json.data && json.data.access_token) {",
" pm.collectionVariables.set('token', json.data.access_token);",
"}"
],
"type": "text/javascript"
}
}
]
},
{
"name": "profile 获取资料(需登录)",
"request": {
"method": "GET",
"url": "{{base_url}}/user/profile",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" }
]
}
},
{
"name": "updateProfile 修改资料(需登录)",
"request": {
"method": "POST",
"url": "{{base_url}}/user/updateProfile",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" }
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "nickname", "value": "新昵称", "type": "text" }
]
}
}
},
{
"name": "changePwd 修改密码(需登录)",
"request": {
"method": "POST",
"url": "{{base_url}}/user/changePwd",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" }
],
"body": {
"mode": "urlencoded",
"urlencoded": [
{ "key": "oldpassword", "value": "123456", "type": "text" },
{ "key": "newpassword", "value": "654321", "type": "text" }
]
}
}
},
{
"name": "logout 退出登录(需登录)",
"request": {
"method": "POST",
"url": "{{base_url}}/user/logout",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" }
]
}
}
]
}
]
}
+76
View File
@@ -0,0 +1,76 @@
-- ============================================================
-- mqttbroker → iot 插件改名数据库迁移脚本
-- 前置:代码与目录已完成改名(addon/iot,info.php name=iot,
-- install.sql/各 service 建表语句已改为 iot_*)。
-- 说明:以下 SQL 按默认表前缀 wxapp_ 书写;若你的前缀不同
-- (config/database.php 的 DB_PREFIX),请全局替换 wxapp_ 为实际前缀。
--
-- 二选一执行:
-- PART 1 旧库已有数据:把 mqttbroker_* 表 RENAME 成 iot_*(保留全部数据)
-- PART 2 全新/可清库:删除旧表(新表由 develop 安装或插件自愈自动创建)
-- 之后必须执行 PART 3 元数据迁移。
-- ============================================================
-- ───────────────────────────────
-- PART 1(保留数据,旧库迁移用)
-- ───────────────────────────────
RENAME TABLE
`wxapp_mqttbroker_connection` TO `wxapp_iot_connection`,
`wxapp_mqttbroker_message` TO `wxapp_iot_message`,
`wxapp_mqttbroker_topic` TO `wxapp_iot_topic`,
`wxapp_mqttbroker_auth` TO `wxapp_iot_auth`,
`wxapp_mqttbroker_acl` TO `wxapp_iot_acl`,
`wxapp_mqttbroker_retain` TO `wxapp_iot_retain`,
`wxapp_mqttbroker_offline` TO `wxapp_iot_offline`,
`wxapp_mqttbroker_stats` TO `wxapp_iot_stats`,
`wxapp_mqttbroker_rule` TO `wxapp_iot_rule`,
`wxapp_mqttbroker_outbox` TO `wxapp_iot_outbox`,
`wxapp_mqttbroker_device` TO `wxapp_iot_device`,
`wxapp_mqttbroker_share` TO `wxapp_iot_share`,
`wxapp_mqttbroker_device_log` TO `wxapp_iot_device_log`,
`wxapp_mqttbroker_alert` TO `wxapp_iot_alert`,
`wxapp_mqttbroker_ota` TO `wxapp_iot_ota`,
`wxapp_mqttbroker_group` TO `wxapp_iot_group`,
`wxapp_mqttbroker_group_device` TO `wxapp_iot_group_device`,
`wxapp_mqttbroker_scene` TO `wxapp_iot_scene`,
`wxapp_mqttbroker_scene_trigger` TO `wxapp_iot_scene_trigger`,
`wxapp_mqttbroker_scene_action` TO `wxapp_iot_scene_action`,
`wxapp_mqttbroker_scene_log` TO `wxapp_iot_scene_log`,
`wxapp_mqttbroker_ticket` TO `wxapp_iot_ticket`;
-- 注:若提示某表不存在(1146)属正常——该表在你的库中尚未创建,忽略即可。
-- ───────────────────────────────
-- PART 2(可选:全新部署 / 可清库环境)
-- 与 PART 1 二选一;执行本段后旧数据将被删除,
-- iot_* 新表由后续 develop 安装 / 插件自愈 ensureTable 自动创建。
-- ───────────────────────────────
-- DROP TABLE IF EXISTS
-- `wxapp_mqttbroker_connection`, `wxapp_mqttbroker_message`, `wxapp_mqttbroker_topic`,
-- `wxapp_mqttbroker_auth`, `wxapp_mqttbroker_acl`, `wxapp_mqttbroker_retain`,
-- `wxapp_mqttbroker_offline`, `wxapp_mqttbroker_stats`, `wxapp_mqttbroker_rule`,
-- `wxapp_mqttbroker_outbox`, `wxapp_mqttbroker_device`, `wxapp_mqttbroker_share`,
-- `wxapp_mqttbroker_device_log`, `wxapp_mqttbroker_alert`, `wxapp_mqttbroker_ota`,
-- `wxapp_mqttbroker_group`, `wxapp_mqttbroker_group_device`, `wxapp_mqttbroker_scene`,
-- `wxapp_mqttbroker_scene_trigger`, `wxapp_mqttbroker_scene_action`,
-- `wxapp_mqttbroker_scene_log`, `wxapp_mqttbroker_ticket`;
-- ───────────────────────────────
-- PART 3(必做:插件元数据迁移)
-- ───────────────────────────────
-- 1) 插件配置:common_addonconf.addon
UPDATE `wxapp_common_addonconf` SET `addon` = 'iot' WHERE `addon` = 'mqttbroker';
-- 2) 后台菜单/权限:backend_power(addon / name / route 一并改)
UPDATE `wxapp_backend_power`
SET `addon` = 'iot',
`name` = REPLACE(`name`, 'mqttbroker', 'iot'),
`route` = REPLACE(`route`, '/mqttbroker/', '/iot/')
WHERE `addon` = 'mqttbroker' OR `name` LIKE 'mqttbroker:%' OR `route` LIKE '/mqttbroker/%';
-- 3) 会员权限/菜单(若存在 mqttbroker 数据;表不存在可忽略此行)
-- UPDATE `wxapp_member_rule`
-- SET `name` = REPLACE(`name`, 'mqttbroker', 'iot')
-- WHERE `name` LIKE 'mqttbroker:%';
-- 4) 若曾在 App 端离线存储过接口地址,请让用户重新登录或改本地存储 base。
-- 开发环境还需执行:php think addon:repair iot --menu (刷新菜单)
+2 -8
View File
@@ -158,14 +158,8 @@
]]
, done: function (res, curr, count) {
var view = $('#dataTable').next('.layui-table-view');
view.find('[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
$('.layui-btn[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
auth.filterDom(view);
auth.filterDom('.layui-btn[data-perm]');
}
});
+2 -8
View File
@@ -176,14 +176,8 @@
page: true,
done: function (res, curr, count, origin) {
var view = $('#dataTable').next('.layui-table-view');
view.find('[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
$('.layui-btn[data-perm]').each(function () {
var perm = $(this).attr('data-perm');
if (perm && !auth.has(perm)) { $(this).remove(); }
});
auth.filterDom(view);
auth.filterDom('.layui-btn[data-perm]');
}
});
treeTable.on('toolbar(dataTable)', function (obj) {
+7 -7
View File
@@ -29,7 +29,7 @@ Discuz! 版本对比页将功能划分为若干大类,其组织思路是:
| 账号与认证体系 | `ywxapp/library/Auth.php`(`verifyAuth()`) | 登录、JWT、用户组权限校验 |
| 会员中心框架 | `app/member/` | 控制台、iframe 工作台、主题换肤 |
| 多应用架构 | `app/{api,backend,frontend,member,wxapp}` | 后台 / 前台 / 会员 / 接口 / 小程序多端 |
| 应用市场内核 | `addon/appmall/` | 插件提交 → 审核 → 上架 → 购买 → 授权闭环 |
| 应用市场内核 | `app/appmall/` | 插件提交 → 审核 → 上架 → 购买 → 授权闭环 |
### 2. 内容生产(社区 / 博客 / 导航 / 文档)
| 子模块 | 实现位置 | 说明 |
@@ -56,15 +56,15 @@ Discuz! 版本对比页将功能划分为若干大类,其组织思路是:
| 插件授权巡检 | `command/AddonLicenseCheck.php`、`AddonService::verifyRuntimeLicense()` | 授权闸门与过期巡检 |
| 登录锁定 | `Auth::login` + `User::isLocked()` | 失败次数 / 时段锁定 |
| SEO 优化 | `addon/seo/` | 静态化 / 收录优化 |
| 即时通信 | `addon/mqttbroker/`、`addon/wxchat/` | MQTT 消息、微信会话 |
| 即时通信 | `addon/iot/`、`addon/wxchat/` | MQTT 消息、微信会话 |
### 5. 扩展与生态(多端 / 开放能力 / CLI / 部署)
| 子模块 | 实现位置 | 说明 |
|---|---|---|
| 微信生态 | `addon/wxchat/` | 小程序 / 微信对接(含 uni-app `.uvue/.uts`) |
| 应用市场生态 | `addon/appmall/` | 开发者中心、分润、下载安装 |
| 应用市场生态 | `app/appmall/` | 开发者中心、分润、下载安装 |
| CLI 命令体系 | `ywxapp/service/AppService.php` 注册 | `addon:health` / `make` / `manage` / `license-check` |
| 打包与分发 | `package_appmarket_addon.php` / `package_framework.php` | 插件包 / 框架包生成 |
| 打包与分发 | `scripts/package_addon.php` / `scripts/package_framework.php` 等 | 插件包 / 框架包生成(详见 docs/打包与发布指南.md) |
---
@@ -111,7 +111,7 @@ Discuz! 版本对比页将功能划分为若干大类,其组织思路是:
| 任务中心 | ✅ | 前台+后台 CRUD 完整 |
| 我的道具 | ✅ | 前台+后台(Prop)完整 |
| 我的勋章 | ✅ | 前台+后台(Medal)完整 |
| 充值中心 | ✅ | 套餐/卡密/记录+后台(Card/Shop)完整 |
| 充值中心 | ✅ | 套餐/卡密/记录+后台(Card)完整(内置 Shop「电子商务」已下线,商城改由独立插件承接) |
| 支付网关 | ✅ | mqpay 免签(收款码+安卓监听自动确认+后台人工确认)+ Yansongda 真实商户支付均已落地 |
| 短信服务 | ✅ | smsbao/smssend+Sms 模型+后台完整 |
| **积分规则管理** | ✅ | 本次新增 `score_rule`/`score_log` 表 + `Score` 控制器/模型/菜单 |
@@ -124,7 +124,7 @@ Discuz! 版本对比页将功能划分为若干大类,其组织思路是:
| 登录锁定 | ✅ | Auth::login + isLocked 完整 |
| SEO 优化 | ✅ | seo 插件完整 |
| 内容审核 | ✅ | forum/blog 已接入机审+人工复核队列 |
| 即时通信 | ✅ | mqttbroker 连接数/限流保护 + 重连封装 + 压测脚本完整 |
| 即时通信 | ✅ | iot 连接数/限流保护 + 重连封装 + 压测脚本完整 |
| 全文检索 | ✅ | ES 可选增强(默认 LIKE,ES 不可用自动降级),forum/blog 已接入 |
### 扩展与生态
@@ -169,7 +169,7 @@ Discuz! 版本对比页将功能划分为若干大类,其组织思路是:
- 接入点:forum 前台搜索、blog 前台搜索替换为 ES 查询;发帖/编辑/删除时同步索引(`sync/remove`)。
- 配置项:`addon/forum/config.php` + `addon/blog/config.php` 加 `search_engine`/`es_host`/`es_index`。
4. **可视化工作流 / 审批流(✅)**——用于插件上架审核流可视化。
- `appmarket_addon_review_log` 审核历史日志表(install.sql + SchemaGuard 双源)。
- `appmall_addon_review_log` 审核历史日志表(install.sql + SchemaGuard 双源)。
- `AddonReview` 后台加 `approve`/`reject` 写入审核日志;`detail`(JSON)/`detailview`(HTML)/`history`(JSON) 三接口 + 路由。
- 列表页"查看详情"按钮 → iframe 弹层打开 `detailview`:提交信息卡 + 审核状态步骤条 + 历史时间线。
+114 -8
View File
@@ -34,10 +34,10 @@
| 2. 审核 / 上架 | ✅ | 后台审核 + `MarketService::catalog()` | 通过后才进 `catalog()` 列表 |
| 3. 浏览详情 | ✅ | `Store::detail` + `view/frontend/store/detail.html` | 价格/截图/版本/授权说明 |
| 4. 前台购买下单 | ✅ | `Store::buy`(POST `/appmall/store/buy`) | 取会员 uid;中心站直连 `MarketService::buy`,客户机经 `RemoteService::buy` |
| 5. 支付 | ✅ | `MarketService::buy` 内 `Yansongda\Pay`;`Market::notify` / `payResult` | 返回 `pay_params`(web 表单)前端 `window.open` 拉起收银台 |
| 6. 回调出授权 | ✅ | `MarketService::completeOrder` | 支付成功生成 `license_key`(HMAC 签名)+ 写分润/开发者余额 |
| 5. 支付 | ✅ | 委托支付中心插件 `addon/paycenter`(`Payment::create`),支持支付宝 + 微信 | 返回 `pay_type` + `pay_params`(form / qrcode / url / params) |
| 6. 回调出授权 | ✅ | `Payment::notify` 验签 → 事件 `PayPaid` → `app\appmall\listener\OrderPaid` → `MarketService::completeOrder` | 支付成功生成 `license_key`(HMAC 签名)+ 写分润/开发者余额 |
| 7. 订单轮询 | ✅ | `Store::orderStatus`(GET `/appmall/store/orderStatus`) | 前端拉起收银台后轮询,拿到 `license_key` |
| 8. 下载安装 + 域名绑定 | ✅ | `Market::index`(下载)/ `Market::valid`(安装时校验绑定域名) | 校验:免费→uid→存在→吊销→过期→域名绑定 |
| 8. 下载安装 + 域名绑定 | ✅ | `Market::index`(下载)/ `Market::valid`(安装时校验) | 统一走 `LicensePolicy::checkAccess`:免费→未购买→吊销→试用结束→订阅过期→大版本超出→多站点座席+域名绑定 |
| 9. 启用闸门 | ✅ | `AddonService::enable` → `verifyRuntimeLicense` | POST 中心站 `validDomain`;失败抛 `AddonException`(除非 `offline_tolerant`) |
| 10. 运行期过期巡检 | ✅ | `AddonService::checkLicenses` + `addon:license-check` 命令 | crontab 每日巡检,过期落库禁用 |
| 11. 分润 / 提现 | ✅ | `Withdrawal` 模型 + 收益后台 | 开发者余额可提现 |
@@ -48,7 +48,107 @@
- `market_mode` / `api_url`:决定中心站 or 客户机。客户机(`is_market_client()===true`)才启用授权校验。
- `addon_license_check`:`false`(关闭校验)/ `true`(中心站不可达也禁用)/ `'offline_tolerant'`(不可达放行,仅真实过期拒绝禁用)。
## 四、前端购买链路(detail.html)
## 四、授权模型与售卖策略
> 一句话结论:**买断模式下,买一次 = 该插件所有版本(含后续升级)免费。**
> 是否要为某个「大版本」再付一次钱,取决于商品的 `upgrade_policy`,而不是版本号本身变了就收费。
### 4.1 为什么授权必须跨版本
商品表 `appmall_addon_list` 的唯一键是 `(name, version)`:插件每发一版就是一行、一个新 id。
而授权原先只认 `aid`(购买时那个版本行的 id),于是:
```
买了 demo 1.0 → license.aid = 旧版本行的 id
作者发布 1.1 → appmall_addon_list 多一行(新 id)
装 1.1 → 按 aid=新 id 查授权查不到 → 「您尚未购买该插件」
```
实际等于把每个版本都卖了一遍。现在授权判定改为 **`addon_name`(插件标识)**,`aid` 降级为「当时购买的版本」留痕。
同一插件所有版本共享一份授权,升级不再重复收费(买断模式)。
### 4.2 商品侧售卖策略(appmall_addon_list 新增列)
| 字段 | 默认 | 含义 |
|------|------|------|
| `license_period` | 0 | 授权有效天数:**0=买断永久**;30/365=订阅制,到期需续费 |
| `trial_days` | 0 | 免费试用天数,0=不提供试用 |
| `upgrade_policy` | 0 | 0=买断式(覆盖全部版本) 1=跨大版本按原价重购 2=跨大版本按 `upgrade_price` 优惠价 |
| `upgrade_price` | 0 | `upgrade_policy=2` 时的升级价;漏配时自动回退原价,避免 0 元付款 |
| `seats` | 1 | 一份授权可绑定站点数(多站点套餐) |
### 4.3 授权侧字段(appmall_addon_licenses 新增列)
| 字段 | 含义 |
|------|------|
| `addon_name` | 跨版本判定的主键(插件标识) |
| `max_major` | 已购覆盖的大版本上限,0=买断式不限;N=可用到 N.x |
| `seats` | 可绑定站点数,`domain` 为逗号分隔列表 |
| `trial_end` | 试用到期时间,0=非试用或已转正 |
| `period` | 订阅周期快照(天数),续费据此延长 |
### 4.4 订单类型(appmall_addon_orders.type)
| 值 | 常量 | 场景 |
|----|------|------|
| 0 | `AddonOrder::TYPE_NEW` | 首次购买 / 试用转正 / 退款后重购 |
| 1 | `AddonOrder::TYPE_RENEW` | 订阅续费(从原到期日往后接,提前续费不吃亏) |
| 2 | `AddonOrder::TYPE_UPGRADE` | 跨大版本付费升级(抬高 `max_major`) |
| 3 | `AddonOrder::TYPE_SEAT` | 增加站点座席 |
### 4.5 决策顺序
所有判定集中在 `app/appmall/library/LicensePolicy.php`(Web 端 `MarketService` 与中心站 API `controller/api/Market.php` 共用同一个真相源):
```
免费插件 ─────────────────────────────▶ 放行(安装/升级都不收费)
无授权记录 ── 商品支持试用? ── 是 ───▶ trial:免费开通 N 天
└── 否 ─────────────────▶ new:付全价
已有授权 ── 已吊销(退款)─────────────▶ revoked:允许重新购买(旧版会永远报「您已购买」)
── 试用中 ──────────────────▶ trialing:仍可用,可随时付款转正
── 试用期已过 ──────────────▶ new:转为新购,全额购买
── 订阅已过期 ──────────────▶ renew:type=1 续费
── 目标大版本 > max_major ───▶ upgrade:type=2,按 policy 取原价或优惠价
└─ 其余 ─────────────────────▶ owned:无需付钱(买断式升级到任何版本都落这里)
```
`decide()`(购买/续费/升级按钮)与 `checkAccess()`(安装 `valid()`、下载 `index()`)共用同一套口径,
不存在「购买时说能用、安装时被拒」的偏差。
| 场景 | 是否再收费 | 前端按钮/提示 |
|------|-----------|---------------|
| 免费插件安装或升级 | 否 | 免费获取 |
| 买断插件 1.0 → 1.9 | 否 | 已在授权内 |
| `upgrade_policy>0` 时 1.x → 2.0 | 是(原价或优惠价) | 升级授权 · ¥X |
| `license_period` 到期 | 是(type=1) | 续费 ¥X |
| `trial_days` 到期 | 是(转新购) | 试用期已结束,购买后可继续使用 |
| 退款吊销后 | 是(可重新购买) | 原授权已吊销,需重新购买 |
| 绑定站点超过 `seats` | 按座席增购 | 该授权最多绑定 N 个站点 |
### 4.6 已修复的两个一致性缺口
1. **离线升级绕过购买**:`AddonService::localUpgrade()` 过去完全不走授权校验,付费插件用一个自制 zip 就能覆盖升级。
现在无论在线还是离线,解压前一律走 `valid()`:**服务端明确拒绝就中止**(未购买/授权过期/版本超出授权范围),
只有「授权服务器不可达」才放行并留痕(避免内网离线部署升不了自己的插件)。
2. **退款后无法再买**:授权行在退款后仍然存在,`buy()` 的幂等分支会永远返回「您已购买该插件」。
现在识别为 `revoked`,允许重新购买并在 `grant()` 里清除吊销状态与试用标记。
### 4.7 到期 / 试用到期提醒
```bash
php think appmall:license-notify # 扫描 7 天内到期项并发邮件
php think appmall:license-notify --days=30 # 扩大提醒窗口
php think appmall:license-notify --dry-run # 只列出不发信(先看看要发给谁)
php think appmall:license-notify --no-mail # 不发邮件,仅输出与记日志
```
建议 crontab(中心站)每日一次:
```
0 9 * * * cd /path/to/site && php think appmall:license-notify >> runtime/license_notify.log 2>&1
```
## 五、前端购买链路(detail.html)
```
点击「立即购买」
@@ -59,9 +159,15 @@
← code=0, need_login → 跳 /member/login
```
## 五、部署清单
## 六、部署清单
**中心站需同步**:`addon/appmall/`(Store 控制器 + route/app.php + view/frontend/store/detail.html)、`config/pay.php`(Yansongda 配置)。
**中心站需同步**:`app/appmall/`(Store 控制器 + route/app.php + view/store/detail.html + library/LicensePolicy.php + command/LicenseNotify.php)、
**支付中心插件 `addon/paycenter`**(下单/验签/回调/查单/退款,支付宝 + 微信;密钥仍走 `.env`,自检 `php think paycenter:check --ping`)。
新增/变更的表结构(`app/appmall/install.sql` 为事实源,模型 `ensureSchema()` 自动补列):
`appmall_addon_list`(+license_period/trial_days/upgrade_policy/upgrade_price/seats)、
`appmall_addon_licenses`(+addon_name/max_major/seats/trial_end/period)、
`appmall_addon_orders`(+type/major)。
**客户机需同步**:
- `ywxapp/service/AddonService.php`(`verifyRuntimeLicense` + `checkLicenses`)
@@ -74,9 +180,9 @@
10 3 * * * cd /path/to/site && php think addon:license-check >> runtime/license_check.log 2>&1
```
## 六、待办(真实支付与增长)
## 七、待办(真实支付与增长)
1. 真实支付:当前 `pay_params` 来自 `Yansongda\Pay`(中心站 `config/pay.php`),mqpay 免签插件未参与此链路;如需接 mqpay 收银台需另接。
1. ~~真实支付~~:已由支付中心插件 `addon/paycenter` 承接(支付宝电脑网站/WAP/扫码/App + 微信 Native/H5/JSAPI/小程序/App),详见插件 README。mqpay 免签插件仍只服务会员开通链路,未参与市场购买。
2. 域名绑定可视化:安装时填写域名并校验的 UI 提示。
3. 运营增长:优惠券/分销、开发者实名结算、SEO 内容填充。
+173 -78
View File
@@ -1,83 +1,178 @@
整体模板为动态layout模式
前台 layout为 app\frontend\view\common\layout.html
后台 layout为 app\backend\view\common\layout.html
用户中心layout为 app\member\view\common\layout.html
# YwxApp 开发文档(项目骨架与约定)
插件会自动调用这三个 也可以在插件得初始化中禁用layout
layui 目录 public\assets\layui
ywxapp 目录 public\assets\ywxapp
ywxapp中加载了 layui.http ueditor 等
> 文档修订日期:2026-09-21
> 适用框架:YwxApp(基于 ThinkPHP 8 的多应用 + 插件化框架)
> 配套文档:`README.md`(入门) / `docs/插件开发指南.md`(插件开发) / `docs/插件使用说明.md`(插件使用) / `docs/自定义命令清单.md`(CLI)
table列表模板可以参考 docs\tpl\table.html
树形列表模板可以参考 docs\tpl\treetable.html
本文是**总览级别的骨架说明**:每个目录/约定的存在意义与定位。深入使用请看上方列出的配套文档。
目录划分
├─app 应用目录
│ ├─backend
│ ├─member
│ ├─frontend
│ ├─api
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─......
│ │ ├─common.php 公共函数文件
│ │ └─event.php 事件定义文件
│——addon
│ ├─(插件名)
│ │ ├─controller 控制器目录
│ │ ├─model 模型目录
│ │ ├─config 配置目录和应用类是
│ │ ├─library 一些需要使用的类
│ │ ├─route 路由
│ │ │ ├─app.php
│ │ ├─service 服务
│ │ ├─listen 事件监听
│ │ ├─subscribe 事件订阅
│ │ ├─event 事件绑定
│ │ ├─view
│ │ ├─common.php 公共函数文件
│ │ └─event.php 事件定义文件
├─config 配置目录
├─view 视图目录
├─route 路由定义目录
│ ├─route.php 路由定义文件
│ └─ ...
├─public WEB目录(对外访问目录)
│ ├─assets
│ │ ├─layui layui目录
│ │ ├─ywxapp ywxapp目录 为layui加载http ueditor 等
│ ├─static
│ │ ├─(应用或插件名)
│ │ │ ├─img 插件图片
│ │ │ ├─css 插件样式
│ │ │ ├─modules 插件js模块 参考layui模块定义
│ │ │ ├─views 一些需要Ajax加载的页面
│ ├─index.php 入口文件
│ ├─router.php 快速测试文件
---
插件 目录
├─(插件名)
│ ├─controller 控制器目录
│ │ ├─backend 插件后台控制器目录
│ │ ├─member 插件用户中心控制器目录
│ │ ├─api 插件api控制器
│ │ ├─... 插件前台控制器可以直接放这里
│ ├─model 模型目录
│ ├─config 配置目目 参考thinkphp配置
│ ├─library 一些需要使用的类
│ ├─route 路由
│ │ ├─app.php 基本就只需要这一个路由文件就行了 然后请参考下方插件路由定义
│ ├─service 服务
│ ├─listen 事件监听
│ ├─subscribe 事件订阅
│ ├─event 事件绑定
│ ├─view
│ │ ├─backend
│ │ ├─member
│ │ ├─frontend
│ ├─common.php 公共函数文件
│ └─event.php 事件定义文件
## 一、模板布局(多应用 Layout)
插件路由定义
整体参考demo演示插件
整体模板为动态 layout 模式,由 `MultiApp` 中间件按入口分发:
| 应用入口 | Layout 模板 |
|---|---|
| 前台 `/` | `app/frontend/view/common/layout.html` |
| 后台 `/admin/*` | `app/backend/view/common/layout.html` |
| 会员中心 `/user/*` | `app/member/view/common/layout.html` |
| API 应用 `/api/*` | 无 layout(统一返回 JSON) |
插件会自动调用对应 layout(继承自 `AddonBase`)。如需禁用,插件可在 `Addon::initialize()` 中调整视图配置。
## 二、第三方前端资源
- **Layui**:`public/assets/layui/`(官方未改动资源,2.x 版本)
- **YwxApp 工具包**:`public/assets/ywxapp/`(基于 Layui 二次封装,加载了 `layui.http` `ueditor` 等)
- **生产建议**:Layui 进入维护期,新项目可考虑迁移到 Layui 3 / uView / Vue3 等前端框架
## 三、模板参考样例
- 表格列表:`docs/tpl/table.html`
- 树形表格:`docs/tpl/treetable.html`
> 路径沿用历史命名(`docs/tpl/`,不是 `docs/templates/`),不影响引用。
## 四、目录结构
### 4.1 主应用 `app/`
```php
├─ app/ // 多应用(系统级)
│ ├─ frontend/ // 企业站/官网(默认首页 /)
│ │ ├─ controller/ // 控制器目录
│ │ ├─ model/ // 模型目录
│ │ ├─ service/ // 业务服务
│ │ ├─ event.php // 事件定义文件
│ │ ├─ common.php // 公共函数文件
│ │ └─ view/common/layout.html
│ ├─ backend/ // 后台管理(入口 /admin)
│ ├─ member/ // 会员中心(入口 /user)
│ └─ api/ // 接口应用(入口 /api),无 layout
```
### 4.2 插件 `addon/`
```php
├─ addon/<name>/ // 单数命名空间 addon\<name>(不是 addons<)
│ ├─ Addon.php // 插件主类(继承 ywxapp\AddonBase)
│ ├─ info.php // 元信息(name/title/version/state/events/middleware/services)
│ ├─ config.php // 插件配置(字段描述符,运行时存 wxapp_addon_config 表 + 缓存)
│ ├─ menu.json // 后台/会员/前台菜单(backend/member/frontend 三段)
│ ├─ install.sql // 安装 SQL(CREATE TABLE + 种子,禁 -- 注释、禁 \' 转义)
│ ├─ upgrade.sql // 升级 SQL(仅 CREATE/INSERT,禁止 ALTER/DROP)
│ ├─ route/app.php // 路由(写相对组名,由 AppService 自动补全插件前缀)
│ ├─ controller/
│ │ ├─ backend/ // 后台控制器
│ │ ├─ member/ // 会员中心控制器
│ │ ├─ api/ // API 控制器
│ │ └─ ... // 前台控制器可直接放 controller/ 根
│ ├─ model/ // 模型(含 ensureSchema() 自愈)
│ ├─ service/ // 业务服务
│ ├─ library/ // 插件私有工具类(不会被 runkit 自动加载)
│ ├─ event.php // 事件定义
│ ├─ common.php // 公共函数
│ ├─ command/ // 命令行(addons/<name>:xxx)
│ ├─ view/
│ │ ├─ backend/
│ │ ├─ member/
│ │ └─ frontend/
│ └─ middleware/ // 插件私有中间件
```
### 4.3 配置 / 路由 / Web 根目录
```php
├─ config/ // 全局配置(database/cache/pay/ywxapp 等)
├─ route.php // 主应用路由(与 addon/*/route/app.php 分工)
├─ public/ // WEB 目录(**唯一对外**)
│ ├─ assets/ // 前端静态资源(layui/ywxapp)
│ ├─ static/<name>/ // 插件运行时发布的资源(imgres/css/js)
│ ├─ install/ // Web 安装器(安装完成后删除)
│ ├─ index.php // 入口(已通过 .htaccess DirectoryIndex 锁定)
│ └─ router.php // PHP 内置服务器快速测试入口(生产无用)
├─ runtime/ // 运行时(缓存/日志,可写)
├─ extend/ // Composer 第三方扩展(不随插件分发)
├─ scripts/ // 构建工具链(package_framework / package_addon / package_all_addons / package_template / submit_template / framework_files / framework_files_list)
├─ ywxapp/ // 框架核心(AddonBase 基类 / AppService / AddonService / MarketService / 命令注册 / 安全过滤 / Token 等)
├─ docs/ // 项目文档与 SQL
└─ public/index.php // 唯一入口
```
## 五、关键约定
### 5.1 命名空间
- 插件命名空间:`addon\<name>\`(**单数**);旧版 `addons\` 已废弃
- 应用命名空间:`app\<应用>\`(frontend/backend/member/api)
- 框架命名空间:`ywxapp\`
### 5.2 路由
- 主应用路由:`config/route.php` + `app/<应用>/route/app.php`
- 插件路由:`addon/<name>/route/app.php`,由 `AppService::loadAddonRoutes()` 用 `Route::group('<name>')` 自动补全前缀
- **后台 URL 必须用「带点」形式**:`blog/backend.article/index`(不要写成斜杠形式,会 404)
- 改完路由务必清路由缓存:`Remove-Item runtime/route_list.php`
### 5.3 表与字段命名
- 表前缀:`wxapp_`(来自 `.env` `DB_PREFIX`)
- 时间字段:`create_at` / `update_at` / `delete_at`(软删除 int 时间戳,0=未删)
- 后台表:`backend_*`;用户中心:`member_*`(已弃用 `admin_*` / `user_*`)
- 应用市场:`wxapp_appmall_*`(**注意是 appmall**,不是 appmarket)
### 5.4 安装/升级 SQL 白名单
- `install.sql` / `upgrade.sql` 只允许 `CREATE TABLE` / `INSERT`
- `ALTER TABLE` / `DROP TABLE` 走 `install.sql`/`upgrade.sql` 会被 `importsql()` 拒绝
- 旧库迁移需手动 `RENAME TABLE` 或 `ALTER TABLE`
### 5.5 模型自愈
每个模型壳内置静态 `ensureSchema()` 自建表;`BaseModel` 提供 `ensureTable/ensureColumn/tableExists/ensureAutoIncrementPk/currentPrefix/ensureTableFromInstall`。**部署时缺表缺列会自动补齐**,无需手跑 SQL。
### 5.6 命令注册入口
- 框架命令:`ywxapp/service/AppService.php::boot()` 的 `$this->commands([...])`
- 插件命令:插件自身 `addon/<name>/info.php` 中 `'commands' => [...]`
- ⚠️ `config/console.php` 当前为**空数组**,不是有效注册入口
完整命令清单见 `docs/自定义命令清单.md`。
## 六、配置驱动开关
- `config/ywxapp.php`:
- `market_mode` / `api_url`:中心站/客户机模式
- `addon_developer`:开发模式开关(开启后后台出现「设计插件」入口)
- `addon_license_check`:运行期授权校验(`false` / `true` / `'offline_tolerant'`)
- `lang_switch_on`:多语言开关
- `config/pay.php`:`PAY_MOCK` 默认 false;生产严禁切到 true
## 七、插件路由示例
完整示例参考 `addon/demo/` 演示插件。最简骨架:
```php
// addon/<name>/route/app.php
use think\facade\Route;
Route::group('<name>', function () {
Route::get('/', '<name>/index/index');
Route::get('detail/:id', '<name>/index/detail');
})->middleware(\ywxapp\middleware\AddonBackend::class);
```
> 不要在插件路由里手写 `->prefix('api/')` 或 `->prefix('<name>/')`——`AppService` 已用 `Route::group` 自动补全。
## 八、调试与排错
- 调试模式:`APP_DEBUG=true`(仅本地/自托管;生产必关)
- 调试入口:`config/app_debug=true` 后开启 SQL 触发器与日志
- 路由缓存:`runtime/route_list.php`(改完路由务必清)
- 插件缓存:`addon_loaded_config`(1 小时;`addon:manage <name> -a reload --force` 强制重载)
- 日志:`runtime/log/`、`runtime/<addon>.log`
---
*此文档基于代码仓库 2026-09-21 状态生成;如有结构变动请同步更新本文。*
+159 -22
View File
@@ -14,10 +14,10 @@
| 框架整包 `ywxapp-<ver>.zip` | `package_framework.php` | `build/framework/` | 框架升级(整站覆盖) |
| 框架补丁 `ywxapp-<ver>-patch.zip` | `package_framework.php` | `build/framework/` | 框架增量升级(`from_version` 起 git diff) |
| 完整安装包 `ywxapp-<ver>-install.zip` | `package_framework.php` | `build/framework/` | **新站首次部署**(白名单:app/config/extend/public/ywxapp/route + 根文件;不含插件、不含真实 `.env`/`database.php`/`ywxapp.php`,内置 `.env` 占位模板) |
| 插件市场 zip + 测试 SQL | `package_all_addon.php` / `package_appmarket_addon.php` | `build/addon/`、`build/sql/` | 上传到应用市场后台,供客户机**后台购买后下载安装** |
| 插件市场 zip + 测试 SQL | `package_all_addons.php` / `package_addon.php` | `build/addon/`、`build/sql/` | 上传到应用市场后台,供客户机**后台购买后下载安装** |
> ⚠️ **重要约定**
> - 框架整包与完整安装包**都刻意不含 `addon/` 插件目录**(整包经 `includeDirs` 排除;安装包用白名单 + 排除 `public/static/<插件>/`、`public/addon/`)。全新部署框架后,插件需经后台应用市场**独立下载安装**,不要把插件打进框架包。
> - 框架整包与完整安装包**都刻意不含 `addon/` 插件目录**(整包经 `includeDirs` 排除;安装包用白名单 + 排除 `public/static/addon/`、`public/static/<插件>/`(历史路径)、`public/addon/`)。全新部署框架后,插件需经后台应用市场**独立下载安装**,不要把插件打进框架包。
> - `package_install.php` 已废弃并合并进 `package_framework.php`(安装包产物由后者统一产出),请勿再单独调用。
> - 插件走「后台购买 → 下载 → 安装」的在线分发链路,与框架整包互相独立,二者不要混包。
@@ -28,9 +28,11 @@
| 产物 | 版本号来源 | 是否进包 | 写回位置 |
|---|---|---|---|
| 主框架整站包 | `config/ywxapp.php` 第 37 行 `version` | 不进包(被排除) | `config/ywxapp.php` |
| market 插件包 | `addon/appmall/info.php` 的 `version` | 不进包(随插件本身) | `addon/appmall/info.php` |
| 其他插件包 | `addon/<name>/info.php` 的 `version` | 不进包(随插件本身) | `addon/<name>/info.php` |
> `appmall`(应用市场服务端)已由插件改为**中心站专用模块**,位于 `app/appmall/`:
> 不单独打插件包、也不进框架包(详见下方 2.5 节)。
- **框架版本与各插件版本互相独立**,各自维护、各自递增。
- 打包脚本每次都会把新版本**写回对应文件**,保证下次打包在此基础上再 `+1`。
- 写回会改动 git 跟踪文件,属预期行为;但该文件被排除在升级包外,不会覆盖客户机。
@@ -78,53 +80,107 @@ php scripts/package_framework.php 1.2.3
---
## 2. 单个插件市场包 — `scripts/package_appmarket_addon.php`
## 2. 单个插件市场包 — `scripts/package_addon.php`
把本地插件打包成市场 zip(结构对齐 `AddonService::package()`):`addon/<name>/` 为 zip 根,`public/static/addon/<name>/` 为 `static/` 前缀。
并生成 `wxapp_appmarket_addon_list` 测试记录 SQL(用于往中心站插一条数据,便于联调)。
把本地插件打包成市场 zip(结构对齐 `AddonService::package()`):`addon/<name>/` 为 zip 根,`public/static/addon/<name>/` 为 `static/` 前缀(该目录缺失时回退历史路径 `public/static/<name>/` 并打印迁移提示)。
产物额外包含包指纹:zip 注释写入 `YWXAPP-SHA256:<hash>`,并生成 `<addon>-<version>.zip.sha256`;同时把 hash 填进市场记录的 `file_hash`,客户端安装时由 `AddonService::verifyPackageIntegrity()` 比对。
指纹完整链路(打包 → 中心站存 → 下发 → 安装前比对):
```
scripts/package_addon.php / AddonService::package()
│ 写 zip 注释 + .sha256 + 市场记录 file_hash(SHA256)
▼
中心站 appmall_addon_list.file_hash(varchar(64))
│ 下载端点 Market::index() 先自校验本机文件,再经
│ StreamZipResponse 附加响应头 X-Addon-Sha256 下发给客户端
▼
客户端 AddonService::download() 记入 $this->downloadHash(含重定向场景取最后一次响应)
│
▼
安装/升级前 verifyPackageIntegrity($tmpFile, $info)
优先级:调用方 file_hash → downloadHash → zip 注释 YWXAPP-SHA256
三者都缺(自制包)→ 只留痕日志,不阻断
```
校验点在**解压之前**(install/local 的 4.5 步、onlineUpgrade/localUpgrade 的 1.5 步),损坏或被篡改的包不会落地到插件目录;失败会走既有回滚清理逻辑。历史 32 位的 md5 记录仍按 md5 比对兼容,不会把旧上架记录误判成篡改。
并生成 `wxapp_appmall_addon_list` 测试记录 SQL(用于往中心站插一条数据,便于联调)。
**产物**:
- `build/addon/<addon>-<version>.zip`
- `build/sql/<addon>-<version>-appmarket_addon_list.sql`(测试记录,`file_path` 为本地绝对路径,中心站部署时需改成实际路径或走后台上传)
- `build/sql/<addon>-<version>-appmall_addon_list.sql`(测试记录,`file_path` 为本地绝对路径,中心站部署时需改成实际路径或走后台上传)
### 用法(支持自动递增版本号)
```bash
# 不带版本号:自动按 patch +1(读 addon/<addon>/info.php 当前版本,写回新值)
php scripts/package_appmarket_addon.php appmall
php scripts/package_addon.php haonav
# 按段递增
php scripts/package_appmarket_addon.php appmall minor # 1.0.1 -> 1.1.0
php scripts/package_addon.php haonav minor # 1.0.1 -> 1.1.0
# 显式指定
php scripts/package_appmarket_addon.php appmall 1.2.0
php scripts/package_addon.php haonav 1.2.0
# 兼容旧式(带标题等可选参数,version 缺省则按 patch 自动递增)
php scripts/package_appmarket_addon.php mqttbroker "MQTT代理" "" "基于 Workerman 自研 MQTT Broker" 0
php scripts/package_addon.php iot "MQTT代理" "" "基于 Workerman 自研 MQTT Broker" 0
```
- 版本参数位于 `$argv[2]`:若匹配 `x.y.z` 或 `major|minor|patch`,则视为版本参数,其后 `title/author/description/price` 整体左移一位,保持旧调用兼容。
---
## 3. 批量插件市场包 — `scripts/package_all_addon.php`
## 2.5 中心站专用模块 `appmall`(不打任何包)
`appmall` 是**应用市场服务端**(插件提交 → 审核 → 上架 → 购买 → 授权闭环),只有中心站(总站)用得到。
站长站(客户机)只需要「下载框架 → 调中心站远程 API 购买/安装插件」,**不需要市场服务端代码**。
因此它是**独立安装模块**,不参与任何打包流程:
| 项 | 约定 |
| --- | --- |
| 代码位置 | `app/appmall/`(普通应用,URL 前缀 `/appmall/*` 不变) |
| 模块版本号 | **不需要**:目录下无 `info.php`,不走插件 / 框架的版本号与升级通道,随仓库整体走 |
| 框架整包 / 补丁包 / 安装包 | **一律排除**(`package_framework.php` 的 `$excludeAppRel = ['appmall']`,整包/补丁/安装包三处都生效) |
| 插件市场包 | 不打(已不在 `addon/`,`package_addon.php appmall` 会失败,勿再调用) |
| 数据库表 | **13 张表 ↔ 13 个模型**(`app/appmall/model/`,清单见 `AppmallSchema::MODELS`),事实源 `app/appmall/install.sql`;模型 `initialize()` 自触发 + 应用中间件兜底 |
| 部署方式 | 总站随仓库/独立部署更新;客户机完全不安装 |
**新总站首次部署**:把 `app/appmall/` 放进去,访问 `/appmall/*` 任一页面或接口即可 —— 该请求会先经应用中间件
`AppmallSchemaMiddleware` 把 13 张表与老库缺失字段一次性补齐
(`SHOW TABLES` / `SHOW COLUMNS` 幂等探测,**无版本表、无迁移脚本、无安装命令**)。
唯一前提是 DB 账号具备 `CREATE/ALTER` 权限。
配套依赖(客户机侧,随框架包分发,无需 appmall):
- `ywxapp/service/AddonService.php`、`RemoteService.php`、`FrameworkService.php`(远程调用中心站 API)
- `config/appmall.php`(`remote_token` / `ssl_verify` 等客户端配置,属根级 `config/`,**会进框架包**)
- 核心对 `app\appmall\*` 的调用全部有守卫(`class_exists` 判定 / `try-catch` 兜底),缺失时市场功能降级提示,不影响框架启动。
> ⚠️ 反向注意:`config/appmall.php` 里同时含 `developer_token`、`platform_rate` 等**服务端**配置项,
> 但因客户端也要读 `remote_token`,该文件必须留在框架包内,不要整体排除。
---
## 3. 批量插件市场包 — `scripts/package_all_addons.php`
一次性打包 `addon/` 下所有插件。
**产物**:`build/addon/<name>-<version>.zip` + `build/sql/<name>-<version>-appmarket_addon_list.sql`(每个插件一份)。
**产物**:`build/addon/<name>-<version>.zip` + `build/sql/<name>-<version>-appmall_addon_list.sql`(每个插件一份)。
### 用法
```bash
# 不带 bump:按各插件 info.php 当前版本打包,不改版本号(安全、可重复执行)
php scripts/package_all_addon.php
php scripts/package_all_addons.php
# bump:每个插件版本按 patch +1 并写回各自 info.php(发版用)
php scripts/package_all_addon.php bump
php scripts/package_all_addons.php bump
# 跳过指定插件(与 bump 可组合)
php scripts/package_all_addon.php skip:market
php scripts/package_all_addon.php bump skip:market
php scripts/package_all_addons.php skip:haonav
php scripts/package_all_addons.php bump skip:haonav
```
> ⚠️ `bump` 会修改所有被打包插件的 `info.php` 版本号。日常只想重新生成 zip 时**不要带 `bump`**。
@@ -137,13 +193,12 @@ php scripts/package_all_addon.php bump skip:market
# 1) 框架整站升级(自动 +1,含可选 upgrade.sql)
php scripts/package_framework.php
# 2) 中心站插件(market)发版(自动 +1)
php scripts/package_appmarket_addon.php appmall
# 3) 其余插件批量发版(各 +1)
php scripts/package_all_addon.php bump
# 2) 插件批量发版(各 +1)
php scripts/package_all_addons.php bump
```
> `appmall`(中心站专用模块)随后台仓库/独立部署更新,**不参与上述打包流程**(见 2.5 节)。
1. 把 `build/framework/ywxapp-<ver>.zip`(整包)在中心站后台「主框架版本管理」上传并**发布**(客户端方可检测;该整包即**新站安装包**,不含插件)。
2. 把 `build/addon/*.zip` 在中心站「应用市场管理 / 插件审核」上传,或走后台「上传升级包」(客户机后台购买后下载安装)。
3. 涉及数据库变更时,确保打包前已把增量 SQL 写入 `ywxapp/upgrade.sql`(随包发布、升级时自动执行)。
@@ -160,3 +215,85 @@ php scripts/package_all_addon.php bump
A:补丁基于 git 工作树相对基础版本的 diff。若工作树相对某基线改动很多,补丁会偏大;先把代码提交到稳定基线后再打,补丁即为当次真实改动。
- **Q:打包提示 LF/CRLF 警告?**
A:git `autocrlf` 噪声,非错误,可忽略。
---
## 6. 发布包签名(1.1.2 起强制)
### 为什么必须签
`FrameworkService::apply()` 会把下载到的 zip **直接解压覆盖整站**。
HTTPS 只能防链路窃听,防不住「官网 / CDN / DNS 被攻破」或「内部人员上传恶意包」——
那等于给所有客户机留了一条远程代码执行通道。
因此 1.1.2 起,三类产物(整包 / 补丁 / 安装包)全部带签名:
- 签名对象 = 除 `signature.json` 外所有条目内容的清单哈希(`sha256(内容) 条目名` 排序拼接后再 `sha256`),
并把 `version` 一起签进去用于**防降级**;
- 算法:`RSA-SHA256`,私钥只在打包机,公钥随版本分发;
- 客户端:`apply()` 解压前调用 `ywxapp\service\PackageVerifier::guard()`,
未签名 / 清单不符 / 签名无效 / 版本不一致 → 一律拒绝安装;
- **公钥缺失时 fail-closed**(拒绝安装),静默放行会让防护形同虚设。
### 一次生成,长期使用
```bash
# Windows 下若 openssl_pkey_new() 报 0x80000003,先指定 openssl.cnf:
# $env:OPENSSL_CONF='D:\BtSoft\php\83\extras\ssl\openssl.cnf'
php scripts/package_keygen.php # 生成 4096 位密钥对(已有则要求 --force)
```
产出:
| 文件 | 去向 | 说明 |
| --- | --- | --- |
| `runtime/package_private.pem` | **只留打包机** | `.gitignore` 已忽略 `/runtime`;丢了只能换 key 强制轮换 |
| `ywxapp/data/package_public.pem` | 随版本发布 | 可被后续整包升级覆盖,支持公钥轮换 |
| `PackageVerifier::BUILTIN_PUBLIC_KEY` | 随版本发布 | 公钥文件被误删/被篡改时的最后信任锚 |
`scripts/package_framework.php` 打包结束会自动给三个 zip 追加签名,未找到私钥时打印
`sign : **未签名**(禁止对外发布)`——**看到这行就不要发布**。
### 发布前自检
```bash
php scripts/package_verify.php build/framework/ywxapp-1.1.2.zip 1.1.2
```
### 轮换与降级
- 换私钥会让所有客户机拒绝安装旧包。正确姿势:先发一个**同时信任新旧公钥**的过渡版本,
覆盖率达标后再切换(`--force` 仅用于私钥泄漏应急)。
- 自建升级服务器、自己签名的内网场景:`FRAMEWORK_PACKAGE_VERIFY=false` 可临时关闭,
但**不可用于公网分发**。
---
## 7. 发版 Checklist(发布前逐条确认)
> 版本号唯一事实源:`config/ywxapp.php` 的 `version`;变更清单:`CHANGELOG.md`。
**代码与配置**
- [ ] `config/ywxapp.php` 的 `version` 已递增,`README.md` 顶部版本号、 `CHANGELOG.md` 已同步
- [ ] `php scripts/package_framework.php` 三个产物 **sign 一栏均为「已签名」**
- [ ] `php scripts/package_verify.php <zip> <version>` 对三个包全部验签通过
- [ ] 依赖已锁定:`composer validate` 无错、`composer.lock` 已提交、无 `*` 约束
- [ ] `.env` / 私钥 / `runtime/` 未被打进包(`zipinfo`/解压后肉眼确认无 `.env`、`runtime/`、`*private.pem`)
**官网侧(中心站)前置**
- [ ] 官网已部署**新版** `app/appmall`(否则客户机 `framework/version`、`framework/download` 会 404)
- [ ] `/appmall/api/framework/version`、`/appmall/api/framework/download` 手工通一次
- [ ] 中心站 `config/ywxapp.php` 的 `api_url` 已指向官网(不是 `http://127.0.0.1`)
**验证(干净环境)**
- [ ] 取 `-install.zip` 在**全新虚拟机**部署一遍:Web 安装向导 → 后台登录 → 在线装一个插件 → 在线升级到本次版本
- [ ] 部署后 `APP_DEBUG=false`,确认后台无堆栈泄漏、支付不走 mock
- [ ] 非超级管理员角色登录一次,确认菜单与按钮权限节点正常
**回滚预案**
- [ ] 上一版本的三个 zip 仍在(可回退)
- [ ] 客户机上一次升级的备份目录 `runtime/framework/backup/` 存在且可还原
- [ ] 若本次含数据库变更,`ywxapp/upgrade.sql` 的反向 SQL 已准备好
+104 -31
View File
@@ -1,7 +1,8 @@
# Ywxapp 插件使用说明
> 适用框架:Ywxapp(基于 ThinkPHP 8 的多应用框架)
> 本文逐一说明 `addon/` 目录下**全部 6 个插件**的功能、安装、配置与使用方法。
> 文档修订日期:2026-09-21
> 本文逐一说明 `addon/` 目录下**全部 14 个插件**的功能、安装、配置与使用方法。
> 插件机制与开发规范详见同级《插件开发指南.md》。
---
@@ -10,12 +11,23 @@
| 插件 | 标题 | 版本 | 状态 | 形态 | 一句话简介 |
| --- | --- | --- | --- | --- | --- |
| `appmall` | 应用市场 | 1.0.21 | 启用 | 中心站服务 | 插件商店 + 主框架在线升级中心站(购买/授权/分润/审核/开发者中心) |
| `articles` | 文章CMS | 1.0.1 | 禁用 | 完整业务 | 完整文章类 CMS:文章/分类/标签/评论,前台套用 layuiSimpleNews 模板 |
| `blog` | 博客应用 | 1.0.0 | 启用 | 完整业务 | 多用户博客(前台展示 / 会员中心 / 后台管理) |
| `mqttbroker` | MQTT代理 | 2.1.0 | 启用 | 常驻进程 | 基于 Workerman 自研的 MQTT 3.1.1/5.0 Broker |
| `haonav` | 网址导航 | 1.0.0 | 启用 | 完整业务 | 网址分类与站点收录、展示、后台管理 |
| `wxchat` | 聊天应用 | 1.0.0 | 启用 | 常驻进程 | 仿微信的移动端 IM 社交(GatewayWorker) |
| `smsbao` | 短信宝 | 1.0.0 | 启用 | 事件订阅 | 对接短信宝网关发送验证码(半成品) |
| `demo` | 演示插件 | 1.0.0 | 启用 | 脚手架 | 插件开发规范示例骨架(仅返回一句话) |
| `docs` | 文档中心 | 1.0.2 | 启用 | 完整业务 | 多项目/多版本在线文档系统,支持 UEditor、Word、Markdown 导入、全文搜索 |
| `download` | 下载站 | 1.0.0 | 启用 | 完整业务 | 资源下载站:分类/资源管理/下载统计 |
| `forum` | 轻社区论坛 | 1.0.3 | 启用 | 完整业务 | 基于 Fly-3.0 风格的轻量社区:版块/发帖/回复/消息 |
| `haonav` | 网址导航 | 1.0.0 | 启用 | 完整业务 | 网址分类与站点收录、展示、后台管理 |
| `iot` | MQTT代理 | 2.2.7 | 启用 | 常驻进程 | 基于 Workerman 自研的 MQTT 3.1.1/5.0 Broker |
| `mqpay` | 免签支付 | 2.0.1 | 禁用 | 支付插件 | 个人免签收款码 + 真实微信/支付宝(Yansongda Pay v3) |
| `seo` | SEO增强工具 | 1.0.1 | 禁用 | 工具型 | 自动站点地图/robots/内链关键词/收录统计 |
| `smsbao` | 短信宝 | 1.0.0 | 启用 | 事件订阅 | 对接短信宝网关发送验证码 |
| `smssend` | 生产版短信发送 | 1.0.2 | 禁用 | 事件订阅 | 生产环境真正下发短信:监听 SmsSend 事件,调用可配置网关 |
| `wxchat` | 聊天应用 | 1.0.0 | 启用 | 常驻进程 | 仿微信的移动端 IM 社交(GatewayWorker) |
> 状态列基于 `addon/<name>/info.php` 中 `state` 字段实际值;`启用=1`、`禁用=0`。
> 启用不代表"已开发完成"——开发态插件上线前需补登录/签名校验、测试代码清理等,详见各插件"注意事项"节。
---
@@ -80,38 +92,47 @@ php think addon:manage <name> -a develop
---
## 2. mqttbroker(MQTT 代理)
## 2. iot(MQTT 代理)
### 2.1 功能简介
基于 Workerman 自研的 MQTT 3.1.1 / 5.0 Broker(类 EMQX 轻量版):
- 协议:CONNECT/PUBLISH(QoS0/1/2)/SUBSCRIBE/PING/DISCONNECT/AUTH。
- 传输:TCP(默认 1883)、WebSocket(8083,浏览器/小程序)、可选 TLS。
- 认证 / ACL、保留消息、遗嘱、离线消息持久化、共享订阅(`$share/{group}/{filter}`)。
- `$SYS/broker/#` 系统指标、`/static/addon/mqttbroker/` 实时监控仪表盘。
- `$SYS/broker/#` 系统指标、`/static/addon/iot/` 实时监控仪表盘。
- 基于 Redis Stream 的多进程/WS 桥接、规则引擎转发到外部 Broker(如 EMQX)。
- 手动发布走 DB 出站队列(零外部依赖)。
### 2.2 启动命令(常驻进程)
```bash
php think mqttbroker:start # 启动 Broker(Linux 建议守护)
php think mqttbroker:acltest # ACL 规则测试工具
php think iot start -d # 启动 Broker(-d 后台守护,线上固定加)
php think iot status # 查看状态(进程/端口/配置/日志,返回码 0=正常)
php think iot stop # 停止(-f 强杀孤儿进程)
php think iot restart -d # 重启(改完配置必须重启才生效)
php think iot:acltest # ACL 规则测试工具
```
> Windows 下单进程调试模式,WebSocket 多监听在 Windows 不支持;生产请使用 Linux。
> Windows 下 Workerman 一个进程只能监听一个端口,TCP 与 WebSocket 需**两个 cmd 窗口分进程启动**:
> ```bash
> 窗口1: php think iot start --tcp-only # MQTT over TCP
> 窗口2: php think iot start --ws-only # MQTT over WebSocket
> ```
> 前提:后台「服务设置」开启「Windows强制WebSocket」(`ws_force=1`)与「启用集群桥接」(`cluster_enabled=1`,需本机 Redis 可连),两进程经 Redis Stream 互通。生产 Linux 仍一条命令同时监听 TCP+WebSocket。
### 2.3 数据表(`wxapp_mqttbroker_*`)
### 2.3 数据表(`wxapp_iot_*`)
连接、消息日志、订阅关系、认证账号、ACL 规则、保留消息、离线消息、实时指标、转发规则、出站队列(共 11 张,`stats` 表有代码自愈建表)。
### 2.4 后台入口与菜单
已注入菜单「MQTT代理」:概览 / 客户端连接 / 消息日志 / 发布消息 / 认证账号 / ACL规则 / 实时监控 / 转发规则 / 服务设置(均在 `/mqttbroker/backend/...`)。
已注入菜单「MQTT代理」:概览 / 客户端连接 / 消息日志 / 发布消息 / 认证账号 / ACL规则 / 实时监控 / 转发规则 / 服务设置(均在 `/iot/backend/...`)。
### 2.5 配置说明(后台「服务设置」)
配置项存于数据库 `wxapp_addon_config`(每个配置独立成行),由后台「插件管理 → 配置」或插件内「服务设置」页写入,读取走缓存(键 `addon_config_mqttbroker`)。表单字段由 `config.php` 定义,运行期默认值在 `Broker::loadConfig()` 中(`array_merge($defaults, $saved)`)。**两处默认值应保持一致**。关键项:
配置项存于数据库 `wxapp_addon_config`(每个配置独立成行),由后台「插件管理 → 配置」或插件内「服务设置」页写入,读取走缓存(键 `addon_config_iot`)。表单字段由 `config.php` 定义,运行期默认值在 `Broker::loadConfig()` 中(`array_merge($defaults, $saved)`)。**两处默认值应保持一致**。关键项:
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `port` | 1883 | TCP 监听端口 |
| `host` | 0.0.0.0 | 绑定地址 |
| `ws_port` | 8083 | WebSocket 端口,0=关闭 |
| `ws_force` | 0 | Windows 开发机强制启用 WebSocket(1=开启;生产 Linux 恒启用) |
| `ssl_enabled` | 0 | 对 TCP 启用 TLS |
| `ssl_cert` / `ssl_key` | 空 | PEM 证书/私钥绝对路径 |
| `allow_anonymous` | 1 | 允许匿名连接;设为 0 时需在「认证账号」维护账号 |
@@ -132,10 +153,50 @@ php think mqttbroker:acltest # ACL 规则测试工具
3. 客户端连接:`tcp://<host>:1883` 或 `ws://<host>:8083`;认证在「认证账号」维护,权限在「ACL规则」维护。
4. 后台「发布消息」可手动向主题推送;「实时监控」查看连接与吞吐;「转发规则」可将消息桥接到外部 Broker。
### 2.7 事件与扩展点
### 2.7 华为式设备绑定(激活与认领分离,v2.2.7+)
模仿华为智慧生活"配网入云 → 账号认领"模型,防止 SN 泄露即被抢绑:
1. **预登记**:后台「智能设备 → 预登记设备」录入 SN(机身二维码内容)。系统自动生成:
- `pair_code`(6 位配对码,印到机身贴纸/说明书,绑定认领时校验,防抢绑)
- `activate_token`(32 位激活令牌,出厂预置到设备,一次性)
2. **入云激活**(设备侧,无需登录):设备首次联网调用
`POST /iot/api/deviceActivate`(参数 `sn`、`token`),服务端校验后签发连接凭证 `uuid + secret`(明文**仅此一次**返回),并自动创建 MQTT 账号(username=uuid)与设备 ACL。已激活设备重复调用幂等返回状态但不再下发 secret。
3. **用户认领**:会员 App 扫码 SN + 输入配对码调用
`POST /iot/api/client/bindDevice`(参数 `sn`、`pair_code` 必填)。已激活设备只建立归属关系**不重置凭证**;老设备(无配对码)兼容"扫码即绑"(绑定即激活)。
4. **换发凭证**:已激活设备丢失 secret → 后台「重置密钥」;未激活设备令牌失效 → 后台「重置激活令牌」。
5. 解绑仅解除会员归属(`member_id` 清零),设备凭证保留,可被重新认领;家庭共享见 2.8。
#### 2.7.1 App 端调用姿势
华为式绑定在 App(uni-app x / uvue)中分三步,全部走 REST 接口,认证用会员 JWT(`Authorization: Bearer <token>`):
1. **会员登录**(拿 JWT,框架自带接口):
```
POST {站点}/api/v1/user/login
body: { "account": "会员账号", "password": "密码" }
返回:code=0 成功,响应顶层携带 access_token
```
2. **设备激活**(模拟设备固件侧;无需登录):
```
POST {站点}/iot/api/deviceActivate
body: { "sn": "SN123456", "token": "32位激活令牌" }
返回 data: { uuid, secret, activated, bound, ... }
```
`uuid` 即 MQTT username/clientId,`secret` 即密码(**明文仅首次激活返回**)。成功后可把这两项自动填入 App 的设备连接表单,作为以后 MQTT 连接凭证。
3. **用户认领**(需登录,携带 JWT):
```
POST {站点}/iot/api/client/bindDevice
header: Authorization: Bearer <access_token>
body: { "sn": "SN123456", "pair_code": "6位配对码" }
返回 data: { uuid, bound, activated, ... }
```
已激活设备只建立归属、**不重置凭证**;配对码错误会返回「配对码错误(请查看设备机身贴纸)」。
参考实现:插件自带最小 uvue 客户端 `addon/iot/client/uvue/mqtt-debug/`(`index.uvue`)已内置「华为式设备绑定」卡片(会员登录 → 设备激活 → 配对码认领),激活成功后自动把 `uuid/secret` 填入下方 MQTT 连接表单,可直接联调验证全链路。
### 2.8 事件与扩展点
- 业务侧在 `service/Broker.php` 预埋了 `mqtt_message_published` 事件(参数:`topic/payload/qos/retain/clientId`;`$SYS/` 主题不触发,qos2 在 PUBREL 后触发,确保每消息一次)。插件可监听此事件做业务联动。
### 2.8 注意事项
### 2.9 注意事项
- 单进程内存态(`worker->count=1`),会话/订阅在内存,保留/离线消息落库。
- 卸载会 DROP 全部 11 张表,操作前备份。
@@ -159,11 +220,10 @@ php think mqttbroker:acltest # ACL 规则测试工具
> 该插件**无 `Addon.php`**,安装时由框架直接执行 `install.sql`(建表 + 预置分类/站点种子数据)。
### 3.3 入口
### 3.3 入口与菜单
- **前台**:`/haonav`(首页,按分类展示)、`/haonav/category/:id`(分类详情)、`/haonav/site/:id`(站点详情)。均免登录。
- **后台**:控制器位于 `controller/backend/`(Category / Links / Configs)。
> ⚠️ **菜单未注入**:`menu.json` 三套均为空数组,后台左侧**没有菜单入口**。需直接通过 URL 访问后台控制器(如 `haonav/backend.category`),或自行在 `menu.json` 补充菜单后再 `addon:manage <name> -a develop` 重注入。
- **后台**:控制器位于 `controller/backend/`(Category / Links / Configs / Dashboard / Ad / Apply)。
- **后台菜单**:`menu.json` 已声明完整后台菜单「网址导航」含 6 个子项(数据概览 / 分类管理 / 链接管理 / 导航配置 / 广告管理 / 申请审核);通过 `php think addon:manage haonav -a develop` 注入生效。
### 3.4 配置说明
- 无 `config.php`;配置通过数据库 `wxapp_haonav_config` 表 + 后台「Configs」控制器以 KV 形式维护。
@@ -194,14 +254,16 @@ php think mqttbroker:acltest # ACL 规则测试工具
```bash
php think wxchat:server # 启动 GatewayWorker 聊天服务
```
> `common.php` 注册了指令 `wxchat:server`;WebSocket 实际地址由 `appConf` 的 `socketUrl`(默认 `wss://dev.xixingwl.cn:2346`)下发。
> ⚠️ **当前未注册**:指令定义在 `addon/wxchat/command/ChatServer.php::setName('wxchat:server')`,但 `addon/wxchat/info.php` 中**没有 `commands` 键**,因此直接执行会报「命令不存在」。
> 启用方法:在 `addon/wxchat/info.php` 添加 `'commands' => ['wxchat:server' => \addon\wxchat\command\ChatServer::class]`,再 `php think addon:manage wxchat -a reload --force`。
> WebSocket 实际地址由 `appConf` 的 `socketUrl`(默认 `wss://dev.xixingwl.cn:2346`)下发。
### 4.3 数据表(`wxapp_wxchat_*`)
共 20 张,含:app_versions、follow、friend、group_members、groups、intimacy、messages、message_recalls、moments(动态)、moment_comments/likes/collects/shares/medias、moment_hashtags(话题)、unread_counts、view_history 等。
### 4.4 入口与菜单
- **前台/API**:控制器继承 `Frontend`,对外 URL 带层前缀 `/wxchat`:对外 API 为 `/wxchat/api/*`(如 `/wxchat/api/login/smsLogin`、`/wxchat/api/index/appConf`),会员中心为 `/wxchat/member/*`,后台为 `/wxchat/backend/*`。插件接口统一返回 JSON。
- **后台菜单**:`Addon::install()` 注入**空菜单数组**,且**无 `menu.json`**,因此后台不显示入口。
- **后台菜单**:`menu.json` 已声明完整后台菜单「社交(wxchat)」含 3 个子项(任务管理 / 实名认证 / 房间管理);通过 `php think addon:manage wxchat -a develop` 注入生效。
### 4.5 配置说明
- 已新增 `config.php`,约 34 项配置(功能开关、业务限制、广告、服务器地址、第三方 AppId、版本兼容)现由后台「插件管理 → 配置」(`/backend/addon/setting?addon=wxchat`)可视化维护,实际值存于数据库 `wxapp_addon_config`(独立项 + 缓存)。
@@ -247,9 +309,9 @@ event('SmsSend', [
订阅器 `Smsbao` 的事件前缀为 `Sms`(方法 `onSend` → 事件 `SmsSend`)。`SmsGet` / `SmsNotice` / `SmsCheck` 方法当前为占位(直接 `return true`)。
### 5.5 注意事项(重要)
- 短信宝账号/密码、短信签名均为**测试硬编码值**,使用前必须替换为自己的账号/签名,并建议改为从配置项读取(新增 `config.php` 与 `.addonrc` 读写)。
- 短信宝账号/密码、短信签名默认值在 `config.php` 中是迁移占位(账号 `cqmqzp`、密码占位、签名 `妙趣横生`),**使用前必须替换为自己的账号/签名**。
- 本插件**不主动监听** `user_register_after` 等系统事件,必须由业务侧显式 `event('SmsSend', $sms)` 触发。
- 当前为**半成品**状态:无表、无菜单、无配置、控制器为空壳。
- 当前为**事件订阅型**插件:无 `install.sql`、无 `menu.json`、无 `route/`,所有功能由 `subscribe/Smsbao` 监听 `SmsSend` 事件完成。`controller/` 与 `model/`、`view/` 均为占位(留待后续完善)。
---
@@ -271,8 +333,8 @@ event('SmsSend', [
| 命令 | 归属 | 作用 |
| --- | --- | --- |
| `php think mqttbroker:start` | mqttbroker | 启动 MQTT Broker(常驻) |
| `php think mqttbroker:acltest` | mqttbroker | ACL 规则测试 |
| `php think iot start -d` | iot | 启动 MQTT Broker(常驻,另含 `stop` / `restart` / `status`) |
| `php think iot:acltest` | iot | ACL 规则测试 |
| `php think wxchat:server` | wxchat | 启动 GatewayWorker 聊天服务(常驻) |
| `php think workerman:gateway` | 框架 | GatewayWorker 通用网关 |
| `php think addon:manage <name> -a develop` | 框架 | 开发者模式安装(建表+菜单+启用,可重跑) |
@@ -281,9 +343,20 @@ event('SmsSend', [
| 插件 | 成熟度 | 上线前需处理 |
| --- | --- | --- |
| blog | 完整可用 | 无需特殊处理 |
| mqttbroker | 完整可用 | 生产用 Linux 守护;按需配置认证/ACL/TLS |
| haonav | 基本可用 | 后台菜单未注入;补登录鉴权;装 guzzle |
| wxchat | 开发态 | 开启登录/签名校验;删除测试发短信代码;补后台菜单 |
| smsbao | 半成品 | 替换硬编码账号/签名;改为配置驱动;补触发逻辑 |
| demo | 脚手架 | 仅作示例,不用于生产 |
| `articles` | 基础可用(未启用) | `info.php` 中 `state=0`,启用前确认模板适配 |
| `blog` | 完整可用 | 无需特殊处理 |
| `demo` | 脚手架 | 仅作示例,不用于生产 |
| `docs` | 完整可用 | 无需特殊处理;如需全文搜索须启用 ES/Meilisearch 后端 |
| `download` | 基础可用 | 检查 `local_path` 目录可写;按需对接对象存储(OSS/COS) |
| `forum` | 基础可用 | 内容审核、防灌水、上传鉴权需自行加强 |
| `haonav` | 基本可用 | 补登录鉴权;装 `guzzlehttp/guzzle`;清理调试遗留 `dump();die;` |
| `iot` | 完整可用 | 生产用 Linux 守护;按需配置认证/ACL/TLS |
| `mqpay` | 演示级 | 个人免签模式**仅供测试**,生产请用真实微信/支付宝 |
| `seo` | 基础可用(未启用) | 启用前确认 sitemap/robots 生成策略 |
| `smsbao` | 事件订阅型 | 替换硬编码账号/签名;按需补 trigger 与回调 |
| `smssend` | 事件订阅型(未启用) | 与 `smsbao` 二选一;生产环境推荐用 `smssend` |
| `wxchat` | 开发态 | 开启登录/签名校验;删除测试发短信代码;补 `commands` 注册 |
> `appmall`(应用市场服务端)**已不再是插件**:它改为**中心站专用模块**(`app/appmall/`),
> 只在总站独立部署,客户机无需安装、也不进框架包。详见 `docs/应用市场商业化流程.md`
> 与 `docs/打包与发布指南.md` 第 2.5 节。
+1051 -325
View File
File diff suppressed because it is too large. Load diff
+39 -39
View File
@@ -86,14 +86,14 @@ addon/<name>/
### 2.2 上架 / 市场(自研服务端)
- `market` 插件部署在**中心站(插件服务器)**,提供:市场列表 / 下载 / 授权校验 / **开发者提交与审核**(`POST /api/addon/submit` + `DEVELOPER_TOKEN`)。
- `market` 插件部署在**中心站(插件服务器)**,提供:市场列表 / 下载 / 授权校验 / **开发者提交与审核**(`POST /api/addon/submit` + `APPMARKET_DEV_TOKEN`)。
- `upgrade` 插件部署在中心站,提供主框架在线升级(版本列表/核心包下载)。
- 客户端通过 `config ywxapp.api_url` 连接中心站。
- 表:`wxapp_appmarket_addon_list` / `wxapp_appmarket_addon_submissions` / `wxapp_appmarket_developers` / `wxapp_appmarket_addon_revenues` / `wxapp_appmarket_addon_withdrawals`。
- 表:`wxapp_appmall_addon_list` / `wxapp_appmall_addon_submissions` / `wxapp_appmall_developers` / `wxapp_appmall_addon_revenues` / `wxapp_appmall_addon_withdrawals`。
### 2.3 购买 / 授权
- 服务端 `addon/appappmarket/controller/api/Market.php` 已实现:**`buy()` 下单 + `notify()` 支付回调 + `completeOrder()` 签发 license + `valid()` 安装校验**。
- 服务端 `app/appmall/controller/api/Market.php` 已实现:**`buy()` 下单 + `notify()` 支付回调 + `completeOrder()` 签发 license + `valid()` 安装校验**。
- 接 `Yansongda\Pay`(支付宝/微信),`config/pay.php` 含 `mock_enable` 直购模式(自托管友好)。
- 客户端 `app/api/controller/v1/Addon.php` + `ywxapp/service/RemoteService.php` 远程代理;`AddonOrder`/`AddonLicense`/`AddonDownloadLog` 模型。
@@ -119,7 +119,7 @@ addon/<name>/
| **上架/市场** | 官方强中心化**云平台**(审核+分发+绑定) | 自研 `market`/`upgrade` 服务端(提交/审核/下载/校验) | 🔶 你功能对等但生态弱(无官方流量/评级) |
| **购买/收费** | 应用中心**完整电商**:购买→云端授权绑定站点→安装 | `buy/notify/completeOrder/valid/refund` 已实现;中心站建表 + 站点绑定 + 退款 **已于 2026-07-24 落地** | ✅ 已对齐(可在 §6 见落地记录) |
| **安装方式** | **一键云端推送**(无 FTP/压缩包) | 离线包 + 在线下载覆盖 | 🔶 你支持离线(私有化友好),DZ 纯云端 |
| **授权绑定** | 站点↔云平台账号绑定(云端记录) | `valid()` 已支持**站点/域名绑定**(首次安装绑定、之后仅同域名),`appmarket_addon_licenses.domain/site_id` 落库 | ✅ 已对齐 DZ(见 §6) |
| **授权绑定** | 站点↔云平台账号绑定(云端记录) | `valid()` 已支持**站点/域名绑定**(首次安装绑定、之后仅同域名),`appmall_addon_licenses.domain/site_id` 落库 | ✅ 已对齐 DZ(见 §6) |
---
@@ -132,7 +132,7 @@ addon/<name>/
DZ 卸载靠开发者 `uninstall.php` 自觉删表 + 系统拆菜单;你由框架 `deleteMenu()` 统一清菜单 + 插件 `uninstall()` 钩子清表,**一致性更好**。但 `AdminPower`/`UserRule` 启用软删除,普通 `delete()` 只软删→菜单残留(已修:`destroy($ids,true)` 物理删除)。**DZ 用原生 SQL 物理删,你用模型易踩软删坑**——已修复。
3. **购买/授权闭环:主体已建,补齐三项即对齐 DZ(详见 §5)**
订单表 + 支付回调 + license 签发 + 收益分成**都已实现**;真正缺的是:① 中心站 `appmall/install.sql` 漏建 `appmarket_addon_orders`/`appmarket_addon_download_logs`/`appmarket_addon_licenses`(客户端靠 `ensureTables()` 自愈,中心站不触发);② 授权只绑 `uid` 未绑站点域名(DZ 核心能力);③ 无退款/吊销。
订单表 + 支付回调 + license 签发 + 收益分成**都已实现**;真正缺的是:① 中心站 `appmall/install.sql` 漏建 `appmall_addon_orders`/`appmall_addon_download_logs`/`appmall_addon_licenses`(客户端靠 `ensureTables()` 自愈,中心站不触发);② 授权只绑 `uid` 未绑站点域名(DZ 核心能力);③ 无退款/吊销。
4. **云端一键安装 vs 离线包**
DZ 纯云端推送;你支持离线包(利于私有化/内网)。若要追平"一键装",可强化 `onlineUpgrade` 的在线安装路径,并把 `market` 的下载接口与客户端安装流程打通。
@@ -143,19 +143,19 @@ addon/<name>/
### 5.0 已实现(实测代码,非从零)
服务端入口 `addon/appappmarket/controller/api/Market.php`,接口契约:`/api/addon/*`(由 `app/api/route/route.php` 在 api 应用阶段 include 插件 `route/api.php` 加载):
服务端入口 `app/appmall/controller/api/Market.php`,接口契约:`/api/addon/*`(由 `app/api/route/route.php` 在 api 应用阶段 include 插件 `route/api.php` 加载):
| 方法 | 作用 | 状态 |
| --- | --- | --- |
| `lists()` | 市场列表(含 `price`) | ✅ |
| `info()` | 详情 + `purchased` 标记 | ✅ |
| `submit()` | 开发者提交(待审核) | ✅ |
| `buy()` | 下单:写 `appmarket_addon_orders`(status=0);有商户号走 `Yansongda\Pay` 支付宝/微信(H5/web),无则 `pay.mock_enable` 直购 | ✅ |
| `buy()` | 下单:写 `appmall_addon_orders`(status=0);有商户号走 `Yansongda\Pay` 支付宝/微信(H5/web),无则 `pay.mock_enable` 直购 | ✅ |
| `notify()` | 异步回调:验签→金额校验→`completeOrder()` | ✅ |
| `orderStatus()` | 轮询订单状态 | ✅ |
| `payResult()` | 同步落地页(return_url) | ✅ |
| `completeOrder()` | 订单置已付 + 向 `appmarket_addon_licenses` 签发 `license_key` + 写 `appmarket_addon_revenues`(按 `appmarket_commission_rate` 抽成)+ 开发者 `balance` 累加 | ✅ |
| `valid()` | 安装前授权校验:免费直过;付费查 `appmarket_addon_licenses`(uid+aid) 且未过期 | ✅(仅 uid 维度) |
| `completeOrder()` | 订单置已付 + 向 `appmall_addon_licenses` 签发 `license_key` + 写 `appmall_addon_revenues`(按 `appmall_commission_rate` 抽成)+ 开发者 `balance` 累加 | ✅ |
| `valid()` | 安装前授权校验:免费直过;付费查 `appmall_addon_licenses`(uid+aid) 且未过期 | ✅(仅 uid 维度) |
| `index()` | 下载 zip(支持签名校验) | ✅ |
客户端配套:`app/api/controller/v1/Addon.php`(`ensureTables()` 运行时自愈建表)、`ywxapp/service/RemoteService.php`(远程代理)、`AddonOrder`/`AddonLicense`/`AddonDownloadLog` 模型、`config/pay.php`(含 mock)。
@@ -163,28 +163,28 @@ addon/<name>/
### 5.1 真实缺口(对齐 DZ 前必须补)
1. **中心站 `appmall/install.sql` 缺建表(最高优先级,急性 bug)**
`Market.php` 跑在中心站,但其 `install.sql` 只建 `appmarket_addon_list / appmarket_addon_submissions / appmarket_developers / appmarket_addon_revenues / appmarket_addon_withdrawals`,**缺 `appmarket_addon_orders` / `appmarket_addon_download_logs` / `appmarket_addon_licenses`**。
`Market.php` 跑在中心站,但其 `install.sql` 只建 `appmall_addon_list / appmall_addon_submissions / appmall_developers / appmall_addon_revenues / appmall_addon_withdrawals`,**缺 `appmall_addon_orders` / `appmall_addon_download_logs` / `appmall_addon_licenses`**。
客户端靠 `app/api/controller/v1/Addon.php::ensureTables()` 在"首次访问客户端 Addon 控制器"时自愈建表,但**中心站走 `Market.php`,该自愈不触发** → 全新部署的中心站调用 `buy()/notify()/下载` 会报「表不存在」。
(`appmarket_addon_licenses` 在 `public/install/install.sql` 与 `docs/appappmarket_schema.sql` 有定义,但中心站若以插件方式独立维护则仍可能缺。)
(`appmall_addon_licenses` 在 `public/install/install.sql` 与 `docs/appmall_schema.sql` 有定义,但中心站若以插件方式独立维护则仍可能缺。)
2. **无站点 / 域名绑定(DZ 核心能力缺失)**
`appmarket_addon_licenses.domain` 列存在但**从未写入**;`valid()` 只按 `uid` 校验 → 同一账号可在无限站点安装。DZ 是"授权绑定到具体站点域名"。
`appmall_addon_licenses.domain` 列存在但**从未写入**;`valid()` 只按 `uid` 校验 → 同一账号可在无限站点安装。DZ 是"授权绑定到具体站点域名"。
3. **无退款 / 吊销**
无 `/api/addon/refund`;订单 `status` 仅 0/1(无 2 退款/3 关闭);license 无吊销;收益无冲正。DZ 支持退款回收授权。
4. **`docs/appappmarket_schema.sql` 与 `appmall/install.sql` 不一致**
前者缺 `appmarket_addon_orders / appmarket_addon_download_logs / appmarket_addon_revenues / appmarket_addon_withdrawals / appmarket_addon_submissions / appmarket_developers`;需统一为唯一权威 schema(供 `docs/split_install_sql.py` 校验)。
4. **`docs/appmall_schema.sql` 与 `appmall/install.sql` 不一致**
前者缺 `appmall_addon_orders / appmall_addon_download_logs / appmall_addon_revenues / appmall_addon_withdrawals / appmall_addon_submissions / appmall_developers`;需统一为唯一权威 schema(供 `docs/split_install_sql.py` 校验)。
### 5.2 落地 A:补齐中心站建表(最优先,止血)
在 `addon/appappmarket/install.sql` 追加(字段与客户端 `ensureTables()` 对齐,`status` 扩展退款/关闭态):
在 `app/appmall/install.sql` 追加(字段与客户端 `ensureTables()` 对齐,`status` 扩展退款/关闭态):
```sql
CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_orders` (
CREATE TABLE IF NOT EXISTS `wxapp_appmall_addon_orders` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL DEFAULT 0,
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmarket_addon_list.id',
`aid` int unsigned NOT NULL DEFAULT 0 COMMENT 'appmall_addon_list.id',
`amount` decimal(10,2) NOT NULL DEFAULT 0,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已退款 3=已关闭',
`trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
@@ -196,10 +196,10 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_orders` (
KEY `idx_trade` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件购买订单表';
CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_licenses` (
CREATE TABLE IF NOT EXISTS `wxapp_appmall_addon_licenses` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uid` int unsigned NOT NULL COMMENT '用户ID',
`aid` int unsigned NOT NULL COMMENT '对应 appmarket_addon_list.id',
`aid` int unsigned NOT NULL COMMENT '对应 appmall_addon_list.id',
`site_id` int unsigned NOT NULL DEFAULT 0 COMMENT '绑定站点(站点授权)',
`license_key` varchar(64) NOT NULL COMMENT '授权码',
`domain` varchar(255) DEFAULT NULL COMMENT '绑定域名(站点授权)',
@@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_licenses` (
UNIQUE KEY `uk_uid_aid_site` (`uid`,`aid`,`site_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件授权表';
CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
CREATE TABLE IF NOT EXISTS `wxapp_appmall_addon_download_logs` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`license_id` int unsigned NOT NULL DEFAULT 0,
`uid` int unsigned NOT NULL DEFAULT 0,
@@ -226,14 +226,14 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
```
并在 `Market.php` 加与客户端一致的 `ensureTables()` 防御(首调用自愈),避免旧中心站漏表。
同时将 `docs/appappmarket_schema.sql` 补全为包含全部 8 张表的唯一权威 schema。
同时将 `docs/appmall_schema.sql` 补全为包含全部 8 张表的唯一权威 schema。
### 5.3 落地 B:站点 / 域名授权绑定(对齐 DZ)
- 新增 `wxapp_appmarket_sites`(中心站注册的客户端站点):`site_token` / `domain` / `owner_uid` / `status`。
- 新增 `wxapp_appmall_sites`(中心站注册的客户端站点):`site_token` / `domain` / `owner_uid` / `status`。
- 客户端安装 `valid()` 时**携带 `domain` 与 `site_token`**;`Market::valid()` 改造:
1. 免费 → 放行(仍写一条 `site_id` 绑定,便于统计);
2. 付费 → 查 `appmarket_addon_licenses` 且 `site_id/domain` 匹配且 `status=1` 且未过期。
2. 付费 → 查 `appmall_addon_licenses` 且 `site_id/domain` 匹配且 `status=1` 且未过期。
- `completeOrder()` 签发 license 时写入 `site_id`/`domain`(建议下单即采集 `domain`)。
- 达到"一个授权码只在一个站点生效",与 DZ「站点↔授权绑定」一致。
@@ -241,7 +241,7 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
- 新增 `POST /api/addon/refund {order_sn, rtoken}`:
- 订单 `status=2`;对应 license `status=0`(吊销);
- 收益冲正:`appmarket_addon_revenues` 标记退款,开发者 `balance` 扣回 `income`(或转 `frozen_balance` 待审);
- 收益冲正:`appmall_addon_revenues` 标记退款,开发者 `balance` 扣回 `income`(或转 `frozen_balance` 待审);
- 幂等:已退款直接返回成功。
- `valid()` 对 `status=0` 的 license 返回"授权已吊销"。
@@ -260,8 +260,8 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
### 5.6 实施顺序
1. **补 `appmall/install.sql` 三表 + `Market.php::ensureTables()`**(止血,最高优先)。
2. 统一 `docs/appappmarket_schema.sql` 为权威 8 表。
3. 站点绑定(`wxapp_appmarket_sites` + `valid`/`completeOrder` 改造)。
2. 统一 `docs/appmall_schema.sql` 为权威 8 表。
3. 站点绑定(`wxapp_appmall_sites` + `valid`/`completeOrder` 改造)。
4. 退款 / 吊销端点。
5. 前端:下单页 + 安装前域名校验接入 `AddonService::install()`。
@@ -275,20 +275,20 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
| 文件 | 改动 |
| --- | --- |
| `addon/appappmarket/install.sql` | **A**:补齐 `wxapp_appmarket_addon_licenses`(含 `site_id`/`status`)、`wxapp_appmarket_addon_orders`(含 `status` 0/1/2 退款态、`site_id`)、`wxapp_appmarket_addon_download_logs`、`wxapp_appmarket_sites` 四表 |
| `docs/appappmarket_schema.sql` | **A**:重写为权威 9 表 schema(与 install.sql、客户端 ensureTables 对齐),收益账本加 `status=2 退款冲正` |
| `addon/appappmarket/controller/api/Market.php` | **A/B/C**:新增 `ensureTables()` 自愈并接入 `buy/notify/valid/refund`;`valid()` 加**站点/域名绑定**(首次安装绑定、之后仅同域名、已吊销拒绝);新增 `refund()`(订单置 2 + license 吊销 + 收益冲正,幂等) |
| `addon/appappmarket/route/api.php` | **C**:注册 `POST /api/addon/refund` |
| `app/api/controller/v1/Addon.php` | **B/C**:`ensureTables()` 防御补列 `appmarket_addon_licenses.status/site_id`;`download()` 加授权吊销校验;新增 `refund()` 本地退款/吊销 + `remoteRefund()` 代理 |
| `app/appmall/install.sql` | **A**:补齐 `wxapp_appmall_addon_licenses`(含 `site_id`/`status`)、`wxapp_appmall_addon_orders`(含 `status` 0/1/2 退款态、`site_id`)、`wxapp_appmall_addon_download_logs`、`wxapp_appmall_sites` 四表 |
| `docs/appmall_schema.sql` | **A**:重写为权威 9 表 schema(与 install.sql、客户端 ensureTables 对齐),收益账本加 `status=2 退款冲正` |
| `app/appmall/controller/api/Market.php` | **A/B/C**:新增 `ensureTables()` 自愈并接入 `buy/notify/valid/refund`;`valid()` 加**站点/域名绑定**(首次安装绑定、之后仅同域名、已吊销拒绝);新增 `refund()`(订单置 2 + license 吊销 + 收益冲正,幂等) |
| `app/appmall/route/api.php` | **C**:注册 `POST /api/addon/refund` |
| `app/api/controller/v1/Addon.php` | **B/C**:`ensureTables()` 防御补列 `appmall_addon_licenses.status/site_id`;`download()` 加授权吊销校验;新增 `refund()` 本地退款/吊销 + `remoteRefund()` 代理 |
| `ywxapp/service/RemoteService.php` | **C**:新增 `refund()` 代理中心站 |
| `ywxapp/service/AddonService.php` | **B**:`prepareInstallParams()` 注入当前站点 `domain`,供中心站 `valid()` 做绑定判定 |
### 6.1 落地后行为
- **购买闭环**:新装中心站不再报「表不存在」(install.sql + ensureTables 双保险)。
- **站点绑定(对齐 DZ)**:用户购买后首次安装某付费插件时,中心站把 `appmarket_addon_licenses.domain` 绑定到当前站点域名;之后换站点安装同一授权被拒。免费插件不受影响。
- **站点绑定(对齐 DZ)**:用户购买后首次安装某付费插件时,中心站把 `appmall_addon_licenses.domain` 绑定到当前站点域名;之后换站点安装同一授权被拒。免费插件不受影响。
- **退款**:`POST /api/v1/addon/refund`(本地或远程)将订单置 `status=2`、授权吊销(`status=0` 且 `expire_time=now`,`download/valid` 双重拦截)、未结算收益扣回开发者余额,幂等。
- **兼容旧库**:客户端 `ensureTables()` 用 `ensureColumn` 给已存在的 `appmarket_addon_licenses` 补 `status`/`site_id` 列;服务端 `ensureTables()` 在表缺失时自愈,均不破坏旧结构。
- **兼容旧库**:客户端 `ensureTables()` 用 `ensureColumn` 给已存在的 `appmall_addon_licenses` 补 `status`/`site_id` 列;服务端 `ensureTables()` 在表缺失时自愈,均不破坏旧结构。
### 6.2 远程下载严格校验(2026-07-24 落地)
@@ -298,22 +298,22 @@ CREATE TABLE IF NOT EXISTS `wxapp_appmarket_addon_download_logs` (
### 6.3 后台商店页退款按钮 + 安装前域名提示(2026-07-24 落地)
- 新增后台 `addon::refund(name)`:按插件名退本地最新「已支付」订单,吊销授权 + 订单置 `status=2` + 收益冲正(与会员端 `/api/v1/addon/refund` 逻辑一致)。**仅本地模式**(`appmarket_remote_enable=false`,默认);远程模式订单在中心站、属会员 uid,后台无会员信息,返回提示「请于会员中心申请退款」。
- 新增后台 `addon::refund(name)`:按插件名退本地最新「已支付」订单,吊销授权 + 订单置 `status=2` + 收益冲正(与会员端 `/api/v1/addon/refund` 逻辑一致)。**仅本地模式**(`appmall_remote_enable=false`,默认);远程模式订单在中心站、属会员 uid,后台无会员信息,返回提示「请于会员中心申请退款」。
- `public/static/backend/modules/appmall.js`:已安装且付费插件卡片加「退款」按钮;付费插件安装前 `layer.confirm` 弹出域名授权提示(显示当前 `location.host`)。
- 会员端退款接口 `/api/v1/addon/refund`(上一轮落地)保留,供会员中心/H5 前端使用。
### 6.4 插件前台「我的插件」页 + 远程退款闭环(2026-07-24 落地)
- 新增中心站 `Market::my()`(`GET /api/addon/my`,`authRemote` 保护):列出指定 `uid` 的已购付费插件(appmarket_addon_licenses 联 appmarket_addon_list + 最近订单),返回 `license_status`/`expire_time`/`domain`/`order_id`/`trade_no`/`refundable`。路由 `addon/appappmarket/route/api.php` 注册 `addon/my`。
- 新增中心站 `Market::my()`(`GET /api/addon/my`,`authRemote` 保护):列出指定 `uid` 的已购付费插件(appmall_addon_licenses 联 appmall_addon_list + 最近订单),返回 `license_status`/`expire_time`/`domain`/`order_id`/`trade_no`/`refundable`。路由 `app/appmall/route/api.php` 注册 `addon/my`。
- `ywxapp/service/RemoteService.php` 新增 `my($uid)` 代理 `/api/addon/my`。
- 客户端 `app/api/controller/v1/Addon.php` 新增 `mine()`(本地读本地库 / 远程代理 `remoteMine()`),与 `refund()` 同一控制器,复用登录态。
- **应用商店 / 我的插件 = 核心后台「插件管理」(非 market 插件)**:用户明确——**客户机不安装 market 插件**,其所需的「应用商店(浏览中心站市场并一键安装)+ 我的插件(本机已装管理)」直接写在核心 `app\backend\controller\addon.php`:`market()`(服务端经 `RemoteService::lists()` 渲染,`/backend/addon/market`)+ `my()`(列本机已装,`/backend/addon/my`);视图 `app/backend/view/addon/{market,my}.html`;`app/backend/view/addon/index.html` 已挂「应用市场 / 我的插件」按钮。market 插件因此**只保留中心站运营职能**(`menu.json` 的 `store` 组已删除,仅剩 `market`(应用市场管理)/`revenue`(收益与结算) 两个 `centerOnly` 组)。早期 store 曾误放 `controller/` 走公开前台 `/appmall/store`、后又放 market 插件 `controller/backend/Store.php`,现已统一迁入核心后台。
- 远程模式退款闭环:付费插件购买/退款仍由会员中心 `/api/v1/addon/*`(含 `mine`/`refund`,本地/远程自动切换)承载;后台「我的插件」页(核心 addon::my)展示本机已装插件,完整升级/启停/卸载见「插件管理」列表。
- **market 插件菜单挂载到「插件管理」**:`menu.json` 顶层 `"superior": "addon:index"`(核心「插件管理」菜单 id=8,见 `public/install/install.sql:158`),使 market 插件后台菜单整体挂到「插件管理」下;仅 `market`/`revenue` 两个 `centerOnly` 组(应用市场管理 / 收益与结算)。改完 `menu.json` 须重导菜单:`php think addon:manage appmall -a refresh-menu`。
- **客户机隐藏中心运营菜单**:`menu.json` 给 `应用市场管理`(`market`) 与 `收益与结算`(`revenue`) 两组加 `"centerOnly": true`。`AddonService::saveMenus()` 递归里新增过滤——当 `config('ywxapp.appmarket_remote_enable')=true`(本机作为远程客户端连接中心站,即客户机)时,跳过 `centerOnly` 项及其子树;中心站(`appmarket_remote_enable=false`,默认) 正常显示全部。该过滤在 `createMenu()` 内,**install/enable/refresh-menu 三入口共用**,故重导菜单 `php think addon:manage appmall -a refresh-menu` 即可生效(客户机须先设 `appmarket_remote_enable=true`)。另 `getOrCreateAddonRootMenuId()` 在客户机下把根菜单标题里的「(服务端)」字样剥离(`插件应用市场(服务端)`→`插件应用市场`),避免误导;角色切换后重导菜单即按新环境重建。(注:客户机不安装 market 插件,上述菜单/路由均不加载;拦截仅作「万一安装」兜底。)
- **前台开发者中心同样仅中心站**:独立前台应用 `controller/developer/Developer.php`(路由 `/appmall/developer/*`,注册/令牌/收益/提现)依赖 `appmarket_developers`/`appmarket_addon_revenues`/`appmarket_addon_withdrawals` 运营表,客户机不持有,故**双层拦截**:① 路由层 `route/app.php` 的 `Route::group('developer')` 内 `if (Config::get('ywxapp.appmarket_remote_enable', false)) return;` 使客户机整组不注册(访问 404);② 控制器 `initialize()` 运行时兜底 `if (config('ywxapp.appmarket_remote_enable', false)) abort(404);`(防路由缓存陈旧/直连)。客户机若开过路由缓存须 `php think route:clear`。全项目无指向 `/appmall/developer` 的入口链接,不会出现死链。
- **服务中心对外 API 仅中心站(关键)**:`addon/appappmarket/route/api.php` 提供 `/api/addon/*`(lists/valid/submit/info/buy/orderStatus/notify/payResult/refund/my)与 `/api/index`(下载 zip),均操作中心站运营表,且 `/api/addon/notify` 接收支付平台回调写订单。但 `app/api/route/route.php` 对所有 `state=1` 插件的 `route/api.php` 无条件 include → **客户机也会注册这套 API**,可能写出脏数据/越权下载。已在 `route/api.php` 顶部 `if ((bool) Config::get('ywxapp.appmarket_remote_enable', false)) return;` 整文件跳过注册(客户机访问 404)。路由缓存陈旧时须 `php think route:clear`。注:`Market` 控制器无统一基类、仅经路由可达,故路由层拦截即足够(无需控制器级兜底)。
- **客户机隐藏中心运营菜单**:`menu.json` 给 `应用市场管理`(`market`) 与 `收益与结算`(`revenue`) 两组加 `"centerOnly": true`。`AddonService::saveMenus()` 递归里新增过滤——当 `config('ywxapp.appmall_remote_enable')=true`(本机作为远程客户端连接中心站,即客户机)时,跳过 `centerOnly` 项及其子树;中心站(`appmall_remote_enable=false`,默认) 正常显示全部。该过滤在 `createMenu()` 内,**install/enable/refresh-menu 三入口共用**,故重导菜单 `php think addon:manage appmall -a refresh-menu` 即可生效(客户机须先设 `appmall_remote_enable=true`)。另 `getOrCreateAddonRootMenuId()` 在客户机下把根菜单标题里的「(服务端)」字样剥离(`插件应用市场(服务端)`→`插件应用市场`),避免误导;角色切换后重导菜单即按新环境重建。(注:客户机不安装 market 插件,上述菜单/路由均不加载;拦截仅作「万一安装」兜底。)
- **前台开发者中心同样仅中心站**:独立前台应用 `controller/developer/Developer.php`(路由 `/appmall/developer/*`,注册/令牌/收益/提现)依赖 `appmall_developers`/`appmall_addon_revenues`/`appmall_addon_withdrawals` 运营表,客户机不持有,故**双层拦截**:① 路由层 `route/app.php` 的 `Route::group('developer')` 内 `if (Config::get('ywxapp.appmall_remote_enable', false)) return;` 使客户机整组不注册(访问 404);② 控制器 `initialize()` 运行时兜底 `if (config('ywxapp.appmall_remote_enable', false)) abort(404);`(防路由缓存陈旧/直连)。客户机若开过路由缓存须 `php think route:clear`。全项目无指向 `/appmall/developer` 的入口链接,不会出现死链。
- **服务中心对外 API 仅中心站(关键)**:`app/appmall/route/api.php` 提供 `/api/addon/*`(lists/valid/submit/info/buy/orderStatus/notify/payResult/refund/my)与 `/api/index`(下载 zip),均操作中心站运营表,且 `/api/addon/notify` 接收支付平台回调写订单。但 `app/api/route/route.php` 对所有 `state=1` 插件的 `route/api.php` 无条件 include → **客户机也会注册这套 API**,可能写出脏数据/越权下载。已在 `route/api.php` 顶部 `if ((bool) Config::get('ywxapp.appmall_remote_enable', false)) return;` 整文件跳过注册(客户机访问 404)。路由缓存陈旧时须 `php think route:clear`。注:`Market` 控制器无统一基类、仅经路由可达,故路由层拦截即足够(无需控制器级兜底)。
### 6.5 待办(非阻断,可选)
- 可选的 `site_token` 上报与 `wxapp_appmarket_sites` 落库逻辑(当前仅用 `domain` 字符串绑定,已满足 DZ 等价能力)。
- 可选的 `site_token` 上报与 `wxapp_appmall_sites` 落库逻辑(当前仅用 `domain` 字符串绑定,已满足 DZ 等价能力)。
+44 -16
View File
@@ -1,7 +1,9 @@
# 插件机制对比与差距分析(本项目 vs FastAdmin)
> 配套文档:《FastAdmin 插件机制参考》(`docs/FastAdmin插件机制参考.md`)、《插件开发指南》(`docs/插件开发指南.md`)。
> 配套文档:《插件开发指南》(`docs/插件开发指南.md`)、《插件使用说明》(`docs/插件使用说明.md`)。
> 文档修订日期:2026-09-21(最近一次核对 §3 全部落地状态)
> 本文基于通读双方源码得出,目的是:**看清架构差异 → 定位真实短板 → 给出可落地的完善建议**。
> §1 是当前对比;§3 / §5 保留**原始差距分析与路线图**作为复盘参考;所有短板已于 2026-07-21 前全部落地。
---
@@ -40,17 +42,36 @@
---
> **实施状态(截至 2026-07-21)**:本文列出的短板已全部补齐——
> - **§3.2 `addon_url()` 未定义** → 已新增 `ywxapp/helper.php` 辅助层(`addon_url` / `get_addon_*`)。
> - **§3.3 基类收敛** → 死代码 `BaseAddon` 已移除,统一使用 `ywxapp\addon`。
> - **§3.4 生命周期打通** → `AddonService::enable/disable/upgrade` 已回调插件主类对应方法。
> - **§3.1 钩子体系** → 已在会员/文章/支付/MQTT/异常/插件市场/插件生命周期等约 30 个节点预埋钩子点,统一使用 ThinkPHP 内置 `event()` 触发;完整钩子点清单见 `docs/插件开发指南.md` §4.1。
> - **§3.6 在线升级** → `AddonService::onlineUpgrade()` 已实现(下载 → 备份 → 覆盖 → upgrade 钩子 → 增量 SQL)。
> **实施状态(截至 2026-09-21)**:本文列出的短板已全部补齐——
> - **§3.2 `addon_url()` 未定义** ✅ 已新增 `ywxapp/helper.php` 辅助层(`addon_url` / `get_addon_*`)。
> - **§3.3 基类收敛** ✅ 死代码 `BaseAddon` 已移除,统一使用 `ywxapp\AddonBase`。
> - **§3.4 生命周期打通** ✅ `AddonService::enable/disable/upgrade` 已回调插件主类对应方法。
> - **§3.1 钩子体系** ✅ 已在会员/文章/支付/MQTT/异常/插件市场/插件生命周期等约 30 个节点预埋钩子点,统一使用 ThinkPHP 内置 `event()` 触发;完整钩子点清单见 `docs/插件开发指南.md` §4.1。
> - **§3.6 在线升级** ✅ `AddonService::onlineUpgrade()` 已实现(下载 → 备份 → 覆盖 → upgrade 钩子 → 增量 SQL)。
>
> 当前架构在"可插拔能力、生命周期完整度、开发者体验"上已**达到并在路由 / 隔离 / 配置存储方面超过** FastAdmin 水平。下文 §3 / §5 保留原始差距分析与路线图,供复盘参考。
---
## 2.5 补充落地(2026-09-29)
复查发现「已落地」项里有几处**代码写了但实际不生效**,本轮一并收口:
| 项 | 修复前 | 修复后 |
| --- | --- | --- |
| 依赖 / 冲突检测 | `validateDependencies()` 在解压前调用,插件类未加载即 `return`,**整套检测从不执行** | 新增 `postExtractChecks()`,解压后依次做「包完整性 → 依赖(含版本区间)/PHP 扩展/框架版本 → 路由·菜单·数据表冲突」 |
| 环境体检 | 无 | 新增 `checkEnvironment()`:PHP 版本(`info.php['php']`)、目录可写、磁盘 ≥32MB |
| 包完整性 | 市场存 `file_hash`、客户端算 `md5_file()`,**只存不验** | `package()` 与打包脚本写入 SHA256(zip 注释 + `.sha256`);安装端 `verifyPackageIntegrity()` 比对,无哈希时留痕放行 |
| 静态资源路径 | 打包/安装/卸载三处口径不一(两套目录并存、卸载残留) | 统一 `public/static/addon/<name>/`:打包回退历史目录、安装自动迁移、卸载两套都清、脚手架同步 |
| 插件语言包 | 只有插件自己 `Lang::load()` 才生效 | `MultiApp::loadAddonLang()` 自动载入 `lang/<langset>.php` 与 `lang/<langset>/*.php` |
| 卸载选项 | 服务层支持 `backup_data`/`delete_data`,控制器与 UI 都没暴露 | 卸载弹窗两个勾选,控制器透传 |
| 升级提醒 | 仅市场页有 `upgradable` | 已装列表接口返回 `latest_version`/`upgradable`(5 分钟缓存),列表内徽章 + 一键升级 |
| 配置字段类型 | text/textarea/select/radio/checkbox/number | 新增 `switch`/`array`/`date`/`image`,含未提交值兜底与 `default` 初值 |
依赖声明写法见 `docs/插件开发指南.md` §4.1 与 §5.3。
---
## 2. 架构差异详解(为什么不能照搬 FastAdmin)
### 2.1 路由:MultiApp(你) vs Route::execute(FA)
@@ -67,7 +88,10 @@ FastAdmin 靠 `copydirs` 把插件的 `application/`、`public/` 复制进主框
---
## 3. 真实短板与修复建议(按优先级)
## 3. 原始差距分析(已全部落地,供复盘参考)
> 原始标题:「真实短板与修复建议(按优先级)」。本节作为历史快照保留——所有项目已于 2026-07-21 前落地。
> 实施位置标注:本节每个子项末尾的【已落地位置】为该差距的实际修复点。
### 3.1 ⭐ P0:缺"钩子/行为事件"体系(最该补)
**现状**:只有原生 Event + info.php 声明,无统一钩子点、无方法即钩子、`BaseAddon::registerHooks` 是空壳。
@@ -89,7 +113,7 @@ function addon_url(string $name, string $url = '', array $vars = []): string {
return $vars ? $path . '?' . http_build_query($vars) : $path;
}
```
同时补齐 `get_addon_info/get_addon_config/get_appmarket_addon_list/get_addon_instance` 等常用包装(内部转调 `AddonService` / `Config`),形成统一辅助层。
同时补齐 `addon_url/get_addon_instance/get_addon_info/get_addon_config/is_market_client/cross_url/L` 等常用包装(内部转调 `AddonService` / `Config` / `Request`),形成统一辅助层。
### 3.3 P1:基类收敛(消除死代码 `BaseAddon`)
`ywxapp\BaseAddon` 与 `ywxapp\addon` 两套并存,实际插件全部 `extends addon`,`BaseAddon` 从未被继承/调用。
@@ -118,12 +142,16 @@ function addon_url(string $name, string $url = '', array $vars = []): string {
---
## 5. 完善路线图(建议顺序)
## 5. 已完成路线图(按落地顺序,2026-07-21 全部完成)
1. **修 `addon_url` 未定义**(§3.2)——止血,最快。
2. **建统一辅助层** `ywxapp/helper.php`(§3.2)——`get_addon_*` 系列。
3. **引入钩子体系**(§3.1)——`hook()` 助手 + 钩子点清单文档 + 复用 info.php events。
4. **基类收敛 + 生命周期打通**(§3.3、§3.4)——删/并 `BaseAddon`,补 enable/disable/upgrade 回调。
5. (可选)前台控制器基类、在线升级(§3.5、§3.6)。
> 原始标题:「完善路线图(建议顺序)」。本节作为历史路线图保留。
> 序号与 §3 中「P0/P1/P2」一致,对应修复任务已全部落地:
> 完成 1~4 后,本项目插件机制在"可插拔能力、生命周期完整度、开发者体验"上即可**达到并在路由/隔离/配置存储方面超过** FastAdmin 的水平。
1. ✅ **修 `addon_url` 未定义**(§3.2)—— 新增 `ywxapp/helper.php`,定义 `addon_url()` 函数。
2. ✅ **建统一辅助层** `ywxapp/helper.php`(§3.2)—— `addon_url` / `get_addon_*` 等系列辅助函数。
3. ✅ **引入钩子体系**(§3.1)—— `event()` 触发 + info.php events 注册 + `docs/插件开发指南.md` §4.1 钩子清单。
4. ✅ **基类收敛 + 生命周期打通**(§3.3、§3.4)—— `BaseAddon` 死代码移除;统一 `ywxapp\AddonBase` 基类;`AddonService::enable/disable/upgrade` 回调插件主类方法。
5. ✅ **在线升级**(§3.6)—— `AddonService::onlineUpgrade()` 实现下载 → 备份 → 覆盖 → upgrade 钩子 → 增量 SQL 全链路。
6. ⏭️(未做)前台控制器基类便利封装(§3.5)—— 评估为可选,未投入。
> 完成 1~5 后,本项目插件机制在"可插拔能力、生命周期完整度、开发者体验"上已**达到并在路由/隔离/配置存储方面超过** FastAdmin 的水平。
+91
View File
@@ -0,0 +1,91 @@
# 模板钩子与事件契约(Discuz 式扩展点)
模板里埋点、插件挂内容,多个插件的输出按顺序拼接到埋点位置。
## 1. 与 Discuz 的对应关系
| Discuz | 本项目 |
|---|---|
| 模板 `<!--{hook/index_top}-->` | `{:hook('backend.score.toolbar', ['row' => $row])}` |
| 插件类 `hook_index_top()` 方法 | `HookService::mount()` 或订阅器 `onRender()` |
| 编译期写入缓存模板 | 运行期触发;无监听时 `Event::hasListener()` 短路,开销可忽略 |
| 输出 HTML 片段 | 返回字符串 / 渲染插件模板,助手拼 `implode` |
差别:DZ 在模板编译期就把 hook 调用固化进缓存,我们是运行期收集 —— 换来的是插件启停即时生效、无需清模板缓存。
## 2. 两类事件语义(别混用)
| 类型 | 事件 | 触发方式 | 返回值处理 |
|---|---|---|---|
| 接管型 | `SmsSend`、`ScoreBefore` | `Event::trigger($e, $p, true)` | 取一个返回值做决策(true/false/分值) |
| 收集型 | `HookRender` | `Event::trigger($e, $p)` | **所有**监听的返回值都要,拼成 HTML |
## 3. 用法
### 模板埋点
```html
{:hook('backend.score.toolbar')} {* 无变量 *}
{:hook('backend.score.form', ['row' => $row])} {* 带变量 *}
```
`{:...}` 不做 HTML 转义,正好输出片段。
### 插件挂载
```php
use ywxapp\service\HookService;
// 方式一(推荐):声明式挂插件模板 —— 可排序、能在后台「模板钩子」页枚举到
HookService::mount('backend.score.toolbar', 'demo@hook/score-toolbar');
// 模板路径:addon/demo/view/hook/score-toolbar.html
// 方式二:回调(需要运行时计算时)
HookService::register('backend.layout.head', function (array $vars): string {
return '<meta name="demo" content="1">';
}, 5); // 第 3 参 weight,越小越靠前,默认 10
// 方式三:监听事件(多个插件各自返回,顺序 = 插件加载顺序)
class Hook
{
protected $eventPrefix = 'Hook'; // onRender -> HookRender
public function onRender($payload)
{
return $payload['name'] === 'backend.score.form' ? '<div>…</div>' : '';
}
}
```
订阅器需在 `info.php` 的 `events.subscribe` 声明。完整示例见 `addon/demo/subscribe/Hook.php` + `addon/demo/view/hook/score-toolbar.html`。
## 4. 内置埋点
| 钩子名 | 位置 |
|---|---|
| `backend.layout.head` / `body_start` / `body_end` | `app/backend/view/common/layout.html` |
| `member.layout.head` / `body_start` / `body_end` | `app/member/view/common/layout.html` |
| `frontend.layout.head` / `body_start` / `body_end` | `app/frontend/view/common/layout.html` |
| `backend.score.toolbar` | 积分规则列表工具栏(按钮容器内) |
| `backend.score.form` | 积分规则表单底部 |
命名约定:`<端>.<模块/页面>.<位置>`,加埋点只需在模板里加一行 `hook()`,不需要登记表。
## 5. 诊断页
后台 `数据运维 → 模板钩子`(`/backend/hook/index`)列出:所有埋点(扫描视图目录的 `hook('...')`)、每个点挂了谁(插件名 / 权重 / 目标)、是否存在收集型事件监听。
事件方式挂的内容无法枚举到具体插件(TP `Event` 不暴露监听列表),所以排查"插件启了没输出"时优先看「已挂载」列;用 `mount()` 挂载的都能被枚举到。
## 6. 坑(必读)
1. **监听器不要返回 `false`**:`think\Event::trigger()` 遇到 `false` 会 `break`,排在后面的插件输出全丢。不输出就返回 `''`。
2. **输出不转义**:插件自己保证 HTML 安全,用户数据要 `htmlspecialchars`,这是 DZ 插件 XSS 的老坑。
3. **模板片段不走皮肤覆盖**:`SkinOverlay` 是全页级的,钩子输出的片段是原样片段,需要跟皮肤就在插件模板里自己引变量。
4. **视图根目录是全局单例**:`renderTemplate()` 渲染前切到插件 `view/`、渲染后还原。自己写渲染逻辑时必须成对还原,否则同请求后续页面会去插件目录找模板。
5. **性能**:每个埋点一次 `hasListener` 判断 + 可能的事件派发;前台高流量页不要在循环里埋点。
## 7. 相关
- 积分规则引擎(行为扩展点):`docs/积分规则与事件契约.md`
- 核心实现:`ywxapp/service/HookService.php`;助手:`ywxapp/helper.php` 的 `hook()`(由 `AppService` 注册时 `require`)
+182
View File
@@ -0,0 +1,182 @@
# 生产部署清单
> 配套 `README.md` 的「安装部署」章节与本文件。
> 发版(打包/签名/上传)流程见 `docs/打包与发布指南.md`。
---
## 1. 运行环境
| 项 | 要求 | 备注 |
| --- | --- | --- |
| PHP | >= 8.0 | 与 `composer.json` 的 require 一致;建议 8.1+ |
| 扩展 | `pdo_mysql` `mbstring` `curl` `openssl` `fileinfo` `gd` `zip` `json` | `gd` 用于图形验证码,`zip` 用于插件/模板/升级包安装 |
| MySQL | >= 5.7 / MariaDB 10.2+ | `utf8mb4` |
| Redis | 可选(**多副本必配**) | 缓存与 Session 的默认驱动是 file,多副本会随机掉登录态 |
| Composer | 2.x | 生产用 `composer install --no-dev -o` |
```bash
composer install --no-dev -o # 依赖以 composer.lock 为准(该文件已入库)
php think version # 确认可运行
```
---
## 2. Nginx 配置要点
Web 根目录必须是 `public/`,**绝不能把项目根暴露出去**(根目录有 `.env`、`runtime/`、`addon/`、`install.lock`)。
```nginx
server {
listen 80;
server_name example.com;
root /var/www/ywxapp/public;
index index.php index.html;
# 全站 HTTPS(上线请配 301 跳转 + HSTS)
location / {
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php?s=/$1 last;
}
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# ---- 安全加固 ----
# 1) 禁止访问隐藏文件(含 .env / .git)
location ~ /\. { deny all; }
# 2) 上传目录禁止执行 PHP(防文件上传 getshell)
location ~* ^/uploads/.*\.(php|php5|phtml|pht)$ { deny all; }
location ~* ^/static/templates/.*\.(php|php5|phtml)$ { deny all; }
# 3) 静态资源走长缓存
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
access_log off;
}
}
```
**必做的两件事:**
1. 安装完成后**物理删除 `public/install/` 目录**(安装器已写根目录 `install.lock`,但仍应删掉,避免被打回安装向导);
2. 确认 `http://<域名>/runtime/`、`http://<域名>/.env` 均返回 403/404。
Windows + Apache 已内置 `public/.htaccess`;Nginx 请用上面的 `/` 规则。
---
## 3. PHP 与会话安全
建议 `php.ini`(生产):
```ini
display_errors = Off
log_errors = On
expose_php = Off
session.cookie_httponly = 1
; HTTPS 站点务必开启(也可在 .env 设 SESSION_SECURE=true)
session.cookie_secure = 1
opcache.enable = 1
opcache.validate_timestamps = 0 ; 配合发版时 reload fpm
```
`.env` 侧:见 `.env.production.example`。**至少**要保证:
| 键 | 生产值 |
| --- | --- |
| `APP_DEBUG` | `false` |
| `JWT_SECRET` | `openssl rand -base64 64`(留空会导致签发直接失败,这是刻意的) |
| `PAY_MOCK` | `false` |
| `ADDON_UNKNOWN_SOURCES` | `false` |
| `ADDON_DEVELOPER` | `false` |
| `ADDON_LICENSE_CHECK` | `true`(付费插件按售卖模型强制授权) |
| `ADDON_SECRET` | 强随机(配合 `addon_download_sign`) |
| `FRAMEWORK_PACKAGE_VERIFY` | `true`(关闭等于允许任意代码覆盖站点) |
| `APP_MARKET_MODE` / `APP_API_URL` | 客户机:`client` 指向中心站域名;中心站:`center` |
---
## 4. 目录权限
```bash
chmod -R 755 runtime addon public/static
chmod 600 .env
# 属主与 PHP-FPM 用户一致,避免使用 777
chown -R www-data:www-data /var/www/ywxapp
```
可写目录:`runtime/`(缓存、日志、安装备份)、`public/static/`(插件资源发布)、`public/uploads/`(用户上传)、`addon/`(在线安装插件时)。
---
## 5. 定时任务与常驻进程
| 用途 | 命令 | 建议频率 |
| --- | --- | --- |
| 数据库备份 | `php think db:backup --keep=7` | 每日 03:00 |
| 孤儿附件清理 | `php think attachment:clean --delete` | 每周一次 |
| 插件健康检测 | `php think addon:health` | 每日(或纳入监控告警) |
| 运行期授权校验 | `php think addon:license-check` | 每日 |
| 导航死链巡检 | `php think haonav:checklinks` | 按需(装了 haonav) |
crontab 示例:
```cron
0 3 * * * cd /var/www/ywxapp && php think db:backup --keep=7 >> /var/log/ywxapp-backup.log 2>&1
0 4 * * * cd /var/www/ywxapp && php think addon:health >> /var/log/ywxapp-health.log 2>&1
```
需要常驻的插件进程(装了才需要,用 supervisor/systemd 托管):
```bash
php think wxchat:server start # 客服 WebSocket(addon/wxchat)
php think iot start # MQTT Broker(addon/iot)
```
Windows 服务器请把上述命令注册为计划任务;常驻进程可用 `nssm` 或计划任务「启动时运行」。
---
## 6. 多副本 / 集群
默认配置(file 驱动)只适合单副本:
```env
CACHE_DRIVER = redis
SESSION_TYPE = redis
REDIS_HOST = <redis host>
REDIS_PASSWORD = <强密码>
```
需要注意:本项目的**数据库自愈**(缺表建表、缺列补列)在首次请求时触发,多副本同时冷启动会并发建表。
上线前请先用单实例跑一次 `php think addon:repair all` 完成补表,再扩容。
---
## 7. 上线后自检(10 分钟)
```bash
# 1) 配置与能力自检
php think paycenter:check --channel=all
php think addon:health
# 2) 关键路径冒烟(未登录应得到 200/302,而不是 500)
curl -sI https://example.com/admin/login/index | head -1
curl -sI https://example.com/api/v1/article/index?page=1&limit=1 | head -1
# 3) 确认敏感项不可访问
curl -sI https://example.com/.env # 期望 403/404
curl -sI https://example.com/runtime/ # 期望 403/404
curl -sI https://example.com/install/ # 期望 404(已删除安装目录)
# 4) 日志:确认按天分文件(默认保留 30 天),且没有把密码/私钥写进 runtime/log
ls runtime/log/$(date +%Ym) 2>/dev/null || ls runtime/log
```
最后:在后台用**非超级管理员**账号登录一次,确认菜单、按钮权限、以及支付/订单链路均正常——
超级管理员绕过所有权限校验,用它验收等于没验。
+155
View File
@@ -0,0 +1,155 @@
# 积分规则与事件契约
一套「动作 → 规则 → 限次/去重 → 落账 → 事件」的积分引擎。核心只负责发分,
规则由后台配置,插件通过事件与 `ScoreService` 双向接入。
## 1. 组成
| 层 | 位置 |
|---|---|
| 规则表 | `member_score_rule`(后台 `积分规则` 管理) |
| 流水表 | `member_score_log`(后台 `积分流水` 查看) |
| 余额 | `member_user.score` |
| 记账 | `ywxapp\model\MebmberScore::change()`(行锁 + 事务 + 流水,类名是既定拼写,不要改) |
| 引擎 | `ywxapp\service\ScoreService`(唯一对外触发入口) |
| 事件 | `ScoreBefore` / `ScoreAfter` |
### 规则表字段
| 字段 | 说明 |
|---|---|
| `title` | 规则名称(历史 `name` 列已统一改名,模型自愈 + 迁移脚本双保险) |
| `action` | 触发动作标识,如 `user.register`、`demo.checkin` |
| `type` | 1获取 / 2消费 |
| `value` | 变动值(正数) |
| `addon` | 归属插件标识,空 = 核心规则(插件卸载时可按此清理) |
| `limit_type` | 限次周期 0不限 / 1每日 / 2每周 / 3每月 / 4累计 |
| `limit_times` | 周期内最多触发次数,0 = 不限 |
| `unique_type` | 0不去重 / 1按来源业务ID去重 |
| `remark` | 流水备注模板,支持 `{key}` 占位(取 `trigger()` 的 context) |
同一 `action` 可配多条规则,**按顺序全部执行**(sort 降序、id 升序),分值累加。
## 2. 触发入口
```php
use ywxapp\service\ScoreService;
$res = ScoreService::trigger('task.complete', $uid, [
'task_id' => $taskId,
'title' => '新手任务',
], [
'source' => 'task', // 来源模块/插件,写入流水便于溯源
'source_id' => (string) $taskId, // 配合 unique_type=1 去重
'value' => 20, // 可选:>0 覆盖规则表 value(如任务按自身配置发奖)
'remark' => '完成任务:新手任务', // 可选:覆盖备注
'force' => false, // 可选:true 忽略限次校验
]);
// 返回:['ok'=>bool, 'msg'=>string, 'value'=>int 净变动, 'balance'=>int 最新余额, 'rules'=>int 命中规则数]
```
没有命中规则时 `ok=true / rules=0 / msg='无命中规则'`,调用方无需判空,静默跳过即可。
## 3. 内置动作埋点
| 动作 | 位置 | 说明 |
|---|---|---|
| `user.register` | `app\member\controller\Register` | 注册成功送积分 |
| `user.activate` | `app\member\listener\UserMemberActivate` | 开通会员送积分,`source_id` = 订单号 |
| `task.complete` | `ywxapp\model\CommonTask::claim()` | 任务奖励按任务配置发放;**未配置规则时退化为原逻辑**,老数据不受影响 |
动作注册表在 `ScoreService::$actions`,插件用 `ScoreService::registerAction()` 追加。
## 4. 事件契约
事件命名沿用项目既有风格(前缀 + 方法名,见 `SmsSend` / `PaymentNotify`):
订阅器 `protected $eventPrefix = 'Score';` → `onBefore()` 对应 `ScoreBefore`,`onAfter()` 对应 `ScoreAfter`。
### ScoreBefore(发放前)
payload:`action`、`uid`、`context`、`source`、`source_id`、`rules`(命中的规则数组)
监听器的返回值决定行为:
| 返回 | 效果 |
|---|---|
| `null` | 不干预 |
| `int` 或 `['value'=>n]` | 覆盖本次分值(所有命中规则统一用该值) |
| `false` 或 `['cancel'=>true]` | 取消本次发放 |
| `['rules'=>[[...]]]` | 追加临时规则(插件可自带默认规则,不必后台配置) |
### ScoreAfter(发放后)
payload:上述字段 + `rule`(当前规则)+ `value`(实发分值)。用于通知、成就、等级联动。
多个监听都会执行(非 once 触发);`value` 覆盖以最后一个返回值为准。
## 5. 插件接入
### 方式一:主动发放(最常见)
```php
ScoreService::trigger('demo.checkin', $uid, ['date' => date('Y-m-d')], [
'source' => 'demo',
'source_id' => 'checkin-' . $uid . '-' . date('Ymd'),
]);
```
### 方式二:监听事件改分 / 取消 / 注入规则
`addon/<插件>/subscribe/Score.php`:
```php
class Score
{
protected $eventPrefix = 'Score';
public function onBefore($payload)
{
ScoreService::registerAction('demo.checkin', '示例-每日签到', 'demo');
// 周末双倍
$first = $payload['rules'][0] ?? null;
if ($first && in_array(date('w'), ['0', '6'], true)) {
return ['value' => (int) $first['value'] * 2];
}
return null;
}
public function onAfter($payload): void { /* 通知、成就等 */ }
}
```
在 `info.php` 声明:
```php
'events' => [
'subscribe' => [addon\demo\subscribe\Score::class],
],
```
### 方式三:自带默认规则
插件 `install.sql` 里 `INSERT` 一条规则行(`AddonService` 白名单允许 INSERT,会自动转 `INSERT IGNORE`),
`addon` 字段写插件标识;或在 `onBefore()` 里返回 `['rules'=>[...]]` 动态注入。
完整示例见 `addon/demo/subscribe/Score.php` 与 `addon/demo/controller/api/Score.php`(`GET /demo/api/score/checkin?uid=1`)。
## 6. 后台
- 积分规则:`/backend/score/index` —— 新增/编辑/启停/软删;`触发动作` 支持下拉选择(核心 + 插件注册 + 规则表已用)与手动输入
- 积分流水:`/backend/score/log` —— 可按 uid / 动作 / 来源筛选,显示 `source`、`source_id`、`action`
## 7. 已部署老库升级
```bash
mysql -u<user> -p <database> < docs/migrations/upgrade_score_rule_engine.sql
```
脚本幂等,自动探测表前缀,负责 `name → title` 改名与新增列/索引。
即便不执行,`MebmberScore::ensureSchema()` 也会在运行时自愈列与索引(改名同样覆盖)。
## 8. 注意
- 流水 `source_id` 用普通索引而非唯一索引做去重:调用方常传空字符串,唯一索引会把多条空值记录判为冲突。去重逻辑在 `ScoreService` 内先查后写。
- `MebmberScore::change()` 内部自带事务,被包在业务事务里(如任务领取)时由 TP 事务计数保证一起回滚。
- 消费类规则(`type=2`)余额不足会失败并记录告警日志,不会抛异常中断业务。
+197 -38
View File
@@ -1,26 +1,30 @@
# YwxApp 自定义命令清单
> 生成日期:2026-07-30
> 生成日期:2026-09-21(基于代码 `AppService::boot()` + 插件 `info.php` 实际注册清单核对)
> 运行方式:在项目根目录执行 `php think <命令> [参数] [选项]`
> 命令注册入口:`ywxapp/service/AppService.php` 的 `boot()` 方法(`$this->commands([...])`)
> 命令注册入口:
> - 框架命令:`ywxapp/service/AppService.php::boot()` 中 `$this->commands([...])`
> - 插件命令:插件自身 `addon/<name>/info.php` 中 `'commands' => [...]` 键
> 命令类扫描范围:`app/command`、`ywxapp/command`、`addon/*/command`
> ⚠️ 注意:`config/console.php` 当前是空数组 `'commands' => []`(**无效**),所有命令都通过上述两个入口注册。
---
## 一、已注册命令(实际可用,共 14 个)
按功能分组列出。
按功能分组列出。下文每条均标明「注册位置」,便于排查。
### 1. 插件管理
#### `addon:manage <插件名> -a <action>`
- **文件**:`ywxapp/command/addon/Manage.php`(health 动作委托 `app/command/AddonHealth.php`)
- **文件**:`ywxapp/command/AddonManage.php`(health 动作委托 `ywxapp/command/AddonHealth.php`)
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:插件统一管理入口,合并原 `addon:develop` / `addon:install` / `addon:refresh-menu` / `addon:reload` / `addon:health` 五条命令为单入口。
- **参数**:
- `<插件名>`(可选):部分动作需要;留空语义见各动作
- `-a, --action`(必填):`develop` | `install` | `refresh-menu` | `reload` | `health`
- **动作与选项**:
- `develop`:开发者模式安装(源码已在 `addon/<name>`,原地建表 / 注入菜单 / 启用,免打包)。例:`php think addon:manage mqttbroker -a develop`
- `develop`:开发者模式安装(源码已在 `addon/<name>`,原地建表 / 注入菜单 / 启用,免打包)。例:`php think addon:manage iot -a develop`
- `install`:安装插件。`--local=<zip>` 离线安装;`--force` 在线强制覆盖。例:`php think addon:manage demo -a install` / `php think addon:manage demo -a install --local=/path/addon.zip`
- `refresh-menu`:重导菜单(`menu.json → admin_power / user_rule`),不改安装 / 启用、不跑 SQL;插件名留空 = 刷新全部。例:`php think addon:manage` / `php think addon:manage blog -a refresh-menu`
- `reload`:热重载(需 `app_debug`)。`--force` 强制;`--status` 查看状态;插件名留空 = 全部。例:`php think addon:manage myplugin -a reload --force` / `php think addon:manage -a reload --status`
@@ -28,6 +32,7 @@
#### `ywxapp:upgrade`
- **文件**:`ywxapp/command/Upgrade.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:本地离线升级一键命令。绕过在线下载,直接对本地打包产物执行升级,复用系统既有升级逻辑:
- **框架**:`FrameworkService::apply()` —— 备份、解压覆盖、跑 `upgrade.sql`、写回 `config/ywxapp.php` 版本号(自动备份到 `runtime/framework/backup/`)。
- **插件**:`AddonService::localUpgrade()` —— 备份旧目录、解压覆盖(含 Zip Slip 防护与 static 资源处理)、增量 SQL、更新 `info.php` 版本(自动备份到 `runtime/addon/backup/`)。
@@ -43,10 +48,80 @@
- 插件升级:`php think ywxapp:upgrade -f runtime/market/forum-1.0.2.zip -t addon`
- **注意**:执行后会提示清理 `runtime/cache`、`runtime/temp`;插件升级前会自动备份到 `runtime/addon/backup/<name>-<ver>-<时间戳>/`,失败可回滚。
#### `addon:repair <插件名> [--menu|--db|--config]`
- **文件**:`ywxapp/command/AddonRepair.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:一键修复插件:菜单重载 + 数据库自愈 + 配置补全;`<插件名>` 可为 `all` 修复全部。
- **示例**:`php think addon:repair blog` / `php think addon:repair all --db`
#### `addon:make <插件名> -a <action> [--name=<类>]`
- **文件**:`ywxapp/command/AddonMake.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:脚手架生成 controller/model/middleware/validate/event/listener/service/subscribe。
- **选项**:
- `-a, --action`:`build`/`clear`/`controller`/`model`/`middleware`/`validate`/`event`/`listener`/`service`/`subscribe`
- `--name=<类>`:生成器名
- `--api` / `--plain`(仅 controller)
- `-c` / `-l` / `-r`(仅 clear)
- **示例**:`php think addon:make blog -a controller --name=backend/Stat`
#### `addon:crud [<插件名>] -t <表名> [--app=backend] [--name=<类>] [--title=<标题>] [--pid=<菜单父ID>] [-f]`
- **文件**:`ywxapp/command/AddonCrud.php`(模板在 `ywxapp/command/crud/*.stub`)
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:按数据表生成后台整套增删改查,并自动注册路由与菜单权限节点。
- **两种模式**:
| 模式 | 命令 | 落点 | 权限命名 |
| --- | --- | --- | --- |
| 插件(默认) | `addon:crud articles -t articles_tag` | `addon/<插件>/{model,controller/backend,view/backend}` + `public/static/backend/modules` | `<插件>:backend:<控制器>:add`,菜单写 `menu.json` |
| 核心后台 | `addon:crud --app=backend -t backend_log` | `app/backend/{model,controller,view}` + `public/static/backend/modules` | `<控制器>:add`(Auth 用 controller:action 匹配),菜单直写 `backend_power`,`--pid` 指定父级(默认 0) |
- **生成物**:
| 文件 | 说明 |
| --- | --- |
| `addon/<插件>/model/<Model>.php` | 表名 / 时间戳 / 软删除 / 类型转换 + `ensureSchema()` |
| `addon/<插件>/controller/backend/<Ctrl>.php` | `index` / `add` / `update` / `delete` |
| `addon/<插件>/view/backend/<ctrl>/index.html` | 列表页 + 搜索栏 + 弹窗表单模板 |
| `public/static/backend/modules/<ctrl>.js` | layui 表格 + 弹窗交互 + 权限过滤 |
| `addon/<插件>/route/app.php` | 追加 backend 路由(`--no-route` 关闭) |
| `addon/<插件>/menu.json` | 追加菜单与按钮权限节点(`--no-menu` 关闭) |
- **命名规则**:表名去掉前缀与插件名后转驼峰(`articles_category` → `Category`),可用 `--name` 覆盖;模型与控制器同名,控制器内以 `<类名>Model` 别名导入。
- **权限约定**:菜单节点 `<插件>:backend:<ctrl>/index`,按钮节点 `<插件>:backend:<ctrl>:add|update|delete`;前端 `auth.setController()` + `data-perm` 与之对齐,后端以动作名校验。
- **字段映射**:`status`/`is_*` → 开关,`enum/set` → 下拉,`text`/`content` → 文本域,`sort`/数字 → 数字框,唯一索引 → `unique` 校验,`create_at`/`update_at` → 时间列格式化。
- **安全**:写路由前备份,写完用 `token_get_all(TOKEN_PARSE)` 校验语法,失败自动回滚(路由文件写坏会导致所有 `think` 命令启动失败)。
- **示例**:
```bash
php think addon:crud articles -t articles_tag
php think addon:crud blog -t blog_item --name=Item --title=文章管理 -f
php think addon:manage articles -a reload # 重载菜单,使新权限节点生效
```
#### `attachment:clean [--delete] [--days=<N>] [--dir=<路径>]`
- **文件**:`ywxapp/command/AttachmentCleanCommand.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:清理「孤儿文件」——磁盘上有、但 `common_attachment` 里没有记录的文件(落库失败 / 记录被硬删 / 手工拷贝残留)。
- **默认只统计不删除**(列出前 20 个),加 `--delete` 才真删;删除走 `FileStorageService::delete()`,OSS / FTP 由 `FileDelete` 事件接管。
- **示例**:`php think attachment:clean --days=30 --delete`
- 配套后台页面见 `docs/guide/attachment_storage.md` §「后台统一管理」。
#### `db:backup [-t <表名>] [--part=<字节>] [--keep=<N>]`
- **文件**:`ywxapp/command/DbBackupCommand.php` + `ywxapp/library/DbBackup.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:数据库备份(纯 PHP 实现,可直接挂定时任务)。
- **为什么不用 mysqldump**:运行环境禁用了 `exec`/`shell_exec`,调不到命令行工具,因此用 `SHOW CREATE TABLE` + 分块 `SELECT` 生成 INSERT,边生成边写文件(默认 2MB 分卷)。
- **选项**:`-t`(可多次,不传=全部表)、`--part`(分卷字节数)、`--keep`(保留最近 N 份,按时间清理旧的)。
- **示例**:`php think db:backup --part=5242880 --keep=7`
- **配套后台**:系统管理 → 数据库备份(`app/backend/controller/Database.php`),支持备份/下载/还原/删除/优化表;权限节点 `database:index|backup|recovery|delete|optimize` 首次访问自动补录到 `backend_power`(挂到「系统管理」下)。
#### `addon:health`
- **文件**:`ywxapp/command/AddonHealth.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:插件健康检测(依赖/表/菜单完整性)。
- **示例**:`php think addon:health`
### 2. 数据库
#### `install:copy`
#### `install:copy` ⚠️ 类已定义但未注册
- **文件**:`app/command/InstallCopy.php`
- **注册**:⚠️ 未注册(`config/console.php` 当前空、AppService 无 `commands()` 项)。如需使用请在 `AppService::boot()` 的 `$this->commands()` 中显式注册。
- **用途**:复制 / 初始化本项目数据库安装(核心表 + 插件表)。脚本只执行 `CREATE TABLE / INSERT`(白名单),对已存在的表 / 数据做幂等跳过。
- **选项**:
- `-c, --core`:仅安装核心表
@@ -58,27 +133,41 @@
### 3. 服务进程(常驻 / 网关)
#### `workerman:gateway`
#### `workerman:gateway` ⚠️ 类已定义但未注册
- **文件**:`ywxapp/command/GatewayWorker.php`
- **注册**:⚠️ 未注册(`config/console.php` 空、AppService 无 `commands()` 项、`addon/wxchat/info.php` 也没声明)。需手动加进 `AppService::boot()` 的 `$this->commands()` 才能用。
- **用途**:启动 Workerman GatewayWorker 服务(Register / Gateway / BusinessWorker)。
- **选项**:
- `-a, --acthon=<val>`:操作(start / stop / restart 等,透传给 Workerman)
- **示例**:`php think workerman:gateway -a start`
#### `wxchat:server`
#### `wxchat:server` ⚠️ 类已定义但未注册
- **文件**:`addon/wxchat/command/ChatServer.php`
- **注册**:⚠️ 未注册(`addon/wxchat/info.php` 中没有 `commands` 键、AppService 也没 `commands()` 项)。需在 `addon/wxchat/info.php` 加上 `'commands' => ['wxchat:server' => \addon\wxchat\command\ChatServer::class]` 才可用。
- **用途**:启动 wxchat 的 GatewayWorker 即时通讯服务。
- **选项**:
- `-a, --action=<val>`:操作(如 start)
- **示例**:`php think wxchat:server -a start`
#### `mqttbroker:start`
- **文件**:`addon/mqttbroker/command/MqttBroker.php`
- **用途**:启动 MQTT Broker 常驻进程(兼容 MQTT 3.1.1 / 5.0,类 EMQX 轻量 broker,零外部依赖;转发规则桥接 EMQX 需 `workerman/mqtt`,未安装时自动跳过)。
#### `iot`(start / stop / restart / status 已合并为一个文件)✅ 已注册
- **文件**:`addon/iot/command/Iot.php`
- **注册**:`addon/iot/info.php` 中 `'commands'` 键,7 条同源命令:`iot` / `iot:start` / `iot:stop` / `iot:restart` / `iot:status` / `iot:acltest` / `iot:probe`
- **用途**:MQTT Broker 统一管理命令。
- `start`:启动常驻 Broker(兼容 MQTT 3.1.1 / 5.0,类 EMQX 轻量 broker,零外部依赖;转发规则桥接 EMQX 需 `workerman/mqtt`,未安装时自动跳过)
- `stop`:按 Workerman 改写后的**进程标题特征**定位进程(`pgrep -f iot:start` 找不到),SIGINT 优雅 → 超时 SIGKILL 兜底 → 复核进程与端口
- `restart`:自管「停旧 → 等端口真释放 → 起新」,端口未释放直接报错中止
- `status`:进程/角色/运行时长、pid 文件一致性、端口、当前配置(含 `allow_member_login` 缺失告警)、环境(posix/pcntl/disable_functions)、`runtime/iot.log` 末尾、DB 快照;返回码 0=健康 / 1=异常(可挂监控)
- **参数**:`action`(`start` | `stop` | `restart` | `status`;省略则打印帮助)
- **选项**:
- `-p, --port=<val>`:监听端口(默认 `1883`)
- `-H, --host=<val>`:监听地址(默认 `0.0.0.0`)
- **示例**:`php think mqttbroker:start` / `php think mqttbroker:start -p 1883`
- `-H, --host=<val>`:监听地址(默认 `0.0.0.0`,start)
- `-p, --port=<val>`:监听端口(默认 `1883`,start)
- `-d, --daemon`:后台守护运行(start / restart;Linux 有效,不加则新进程为前台、SSH 断开即退出)
- `-f, --force`:跳过优雅退出,直接 SIGKILL 强杀(stop)
- `--tcp-only` / `--ws-only`:仅启动 TCP / WebSocket 监听(Windows 双进程调试)
- `-l, --lines=<n>`:日志显示行数(status,默认 20)
- `--no-db`:跳过数据库快照(status)
- **示例**:`php think iot start -d` / `php think iot stop -f` / `php think iot restart -d` / `php think iot status`
- **兼容**:旧写法 `iot:start` / `iot:stop` / `iot:restart` / `iot:status` 仍可用(同一实现,按命令名推导子命令)
### 4. 插件运维 / 监控
@@ -87,6 +176,7 @@
#### `addon:license-check`
- **文件**:`ywxapp/command/AddonLicenseCheck.php`
- **注册**:`AppService::boot()` → `commands()` 数组
- **用途**:运行期授权巡检(Discuz 式:已启用付费插件定期复查授权,过期自动禁用)。遍历 `addon/` 下已启用且 `info['license']=true` 的付费插件,逐个向中心站 `validDomain` 校验,校验拒绝或(非 `offline_tolerant` 时)中心站不可达 → 落库 `status=0` 禁用并写 `Log::warning`。放行条件:调试+未知来源 / `config('ywxapp.addon_license_check')===false` / 非客户机(`!is_market_client()`)时直接跳过。
- **依赖**:仅客户机(`is_market_client()===true`)且 `addon_license_check` 开启时生效。
- **示例**:
@@ -97,10 +187,28 @@
```
- 仅观察不禁用:临时设 `addon_license_check=offline_tolerant` 后运行,中心站不可达不禁用(仅真实过期拒绝仍禁用)。
#### `appmall:license-notify`(中心站)
- **文件**:`app/appmall/command/LicenseNotify.php`
- **注册**:`config/console.php` 的 `commands`(多应用 + 插件式命令目录下自动发现不稳定,故显式登记)
- **用途**:扫描即将到期的订阅授权与即将结束的免费试用,发邮件提醒促成续费/转正。
- **选项**:
- `--days=<n>`:提醒窗口(默认 7 天)
- `--dry-run`:只列出不发信(先看要发给谁)
- `--no-mail`:不发邮件,仅输出与写 `Log::info('appmall.license.notify')`
- **依赖**:中心站(`appmall_*` 表);会员邮箱按 user/users/member 顺序探测,取不到则跳过该用户
- **示例**:
- `php think appmall:license-notify --days=30`
- `php think appmall:license-notify --dry-run`
- crontab(建议每日 09:00):
```
0 9 * * * cd /path/to/site && php think appmall:license-notify >> runtime/license_notify.log 2>&1
```
### 5. 代码生成
#### `make:modely <name>`
#### `make:modely <name>` ⚠️ 类已定义但未注册
- **文件**:`ywxapp/command/ModelY.php`
- **注册**:⚠️ 未注册(`AppService::boot()` 的 `commands()` 不含、配置未启用)。如需使用请在 `AppService::boot()` 的 `$this->commands()` 中显式注册。
- **用途**:创建一个新模型类(继承自 `think\console\command\Make`)。
- **参数**:
- `name`(必填):模型类名,例如 `Yasd`
@@ -108,13 +216,20 @@
### 6. 第三方 / 业务集成
#### `mqttbroker:acltest`
- **文件**:`addon/mqttbroker/command/TestAcl.php`
#### `iot:acltest` ✅ 已注册
- **文件**:`addon/iot/command/TestAcl.php`
- **注册**:`addon/iot/info.php` → `commands`
- **用途**:ACL 实网决策验证,用真实 DB 规则校验 `Auth::checkAcl` 的放行 / 拒绝逻辑(deny 拒绝、默认放行、用户定向拒绝、超管绕过、`$SYS` 只读)。不依赖运行中的 Broker。
- **示例**:`php think mqttbroker:acltest`
- **示例**:`php think iot:acltest`
#### `haonav:checklinks`
#### `iot:probe` ✅ 已注册
- **文件**:`addon/iot/command/Probe.php`
- **注册**:`addon/iot/info.php` → `commands`
- **用途**:曲线/时序链路自检(只读)。`php think iot:probe <uuid> [pos]`,不写库。
#### `haonav:checklinks` ⚠️ 类已定义但未注册
- **文件**:`addon/haonav/command/CheckLinks.php`
- **注册**:⚠️ 未注册(`addon/haonav/info.php` 中没有 `commands` 键)。需在 `addon/haonav/info.php` 加上 `'commands' => ['haonav:checklinks' => \addon\haonav\command\CheckLinks::class]` 才可用。
- **用途**:haonav 网址导航死链巡检,更新 `status_code / last_check_at`。适合放 crontab。
- **选项**:
- `--limit=<n>`:本次最多检测条数(`0`=全部,按最久未检测优先,默认 `0`)
@@ -126,33 +241,77 @@
---
## 二、已定义但未注册命令(共 4 个)
## 二、类已定义但未注册(共 5 个)
以下命令类已实现 `setName`,但**未写入 `config/console.php`**,因此 `php think list` 不会列出、直接执行会报「命令不存在」。如需启用,把对应键值加入 `config/console.php` 的 `commands` 数组即可(注意 `clear` 重名冲突问题)。
以下命令类已实现 `setName`,但 **既不在 `AppService::boot()` 的 `$this->commands()` 里,也不在所属插件的 `info.php` `commands` 键里**,因此直接执行 `php think <cmd>` 会报「命令不存在」。
| 命令名 | 类 | 文件 | 说明 / 参数 |
|--------|-----|------|-------------|
| `addon:make` | `ywxapp\command\addon\Addon` | `ywxapp/command/addon/Addon.php` | **统一脚手架/生成器**(已通过 `AppService` 注册,替代原 `addon:build`/`addon:clear`/`addon:controller`/`addon:model`/`addon:middleware`/`addon:validate`/`addon:event`/`addon:listener`/`addon:service`/`addon:subscribe`)。用法 `php think addon:make <插件名> -a <action>`;`-a` 取 `build`/`clear`/`controller`/`model`/`middleware`/`validate`/`event`/`listener`/`service`/`subscribe`;生成器用 `--name=<类>`;`controller` 另支持 `--api`/`--plain`;`clear` 支持 `-c`/`-l`/`-r`。 |
| `app` | `ywxapp\command\AppCommand` | `ywxapp/command/AppCommand.php` | **应用命令统一入口**(已通过 `AppService` 注册)。聚合 `ywxapp/command/app/*` 全部命令:`build`/`clear`/`controller`/`model`/`event`/`listener`/`middleware`/`service`/`subscribe`/`validate`。用法 `php think app <子命令> [参数]`;如 `php think app controller app\index --api`、`php think app clear --cache`。子命令专有选项(`--api`/`--plain`/`--cache` 等)透传至原命令类,未内联、不另立独立命令名。 |
| 命令名 | 类 | 文件 | 启用方式 |
|--------|-----|------|------|
| `workerman:gateway` | `ywxapp\command\GatewayWorker` | `ywxapp/command/GatewayWorker.php` | 在 `AppService::boot()` 的 `$this->commands()` 中显式注册;或迁移到 `addon/wxchat/info.php` 走插件 commands 通道 |
| `make:modely` | `ywxapp\command\ModelY` | `ywxapp/command/ModelY.php` | 在 `AppService::boot()` 的 `$this->commands()` 中显式注册 |
| `wxchat:server` | `addon\wxchat\command\ChatServer` | `addon/wxchat/command/ChatServer.php` | 在 `addon/wxchat/info.php` 添加 `'commands' => ['wxchat:server' => \addon\wxchat\command\ChatServer::class]` |
| `haonav:checklinks` | `addon\haonav\command\CheckLinks` | `addon/haonav/command/CheckLinks.php` | 在 `addon/haonav/info.php` 添加 `'commands' => ['haonav:checklinks' => \addon\haonav\command\CheckLinks::class]` |
| `install:copy` | `app\command\InstallCopy` | `app/command/InstallCopy.php` | 在 `AppService::boot()` 的 `$this->commands()` 中显式注册 |
> 说明:`ywxapp/command/Create.php` 中的 `YMake` 是**抽象基类**(`abstract class YMake extends Command`),本身不是可执行命令,供 `ModelY` 等生成类继承,不在清单内。
---
## 三、附:`config/console.php` 当前注册表(去重后)
## 三、注册机制(与 `config/console.php` 的关系)
YwxApp 命令注册**不依赖** ThinkPHP 默认的 `config/console.php`,后者在仓库中**实际为空数组**:
```php
'commands' => [
"workerman:gateway" => \ywxapp\command\GatewayWorker::class,
"make:modely" => \ywxapp\command\ModelY::class,
"wxchat:server" => \addon\wxchat\command\ChatServer::class,
"install:copy" => \app\command\InstallCopy::class,
"mqttbroker:start" => \addon\mqttbroker\command\MqttBroker::class,
"mqttbroker:acltest" => \addon\mqttbroker\command\TestAcl::class,
"addon:manage" => \ywxapp\command\addon\Manage::class,
"addon:license-check" => \ywxapp\command\AddonLicenseCheck::class,
"haonav:checklinks" => \addon\haonav\command\CheckLinks::class,
]
// config/console.php(当前内容)
return [
'commands' => [
],
];
```
> 注:原文件中 `install:copy` 出现了两次(重复键),实际生效一份,建议清理重复项。
所以"把命令加到 `config/console.php` 的 `commands` 数组"在本项目**不会生效**。所有命令通过以下两个入口注册:
### 入口 A:框架命令 — `ywxapp/service/AppService.php::boot()` 的 `$this->commands()`
```php
// ywxapp/service/AppService.php boot()
$this->commands([
'app' => \ywxapp\command\AppCommand::class,
'addon:manage' => \ywxapp\command\AddonManage::class,
'addon:make' => \ywxapp\command\AddonMake::class,
'addon:health' => \ywxapp\command\AddonHealth::class,
'addon:license-check' => \ywxapp\command\AddonLicenseCheck::class,
'addon:repair' => \ywxapp\command\AddonRepair::class,
'ywxapp:upgrade' => \ywxapp\command\Upgrade::class,
]);
```
### 入口 B:插件命令 — 插件 `addon/<name>/info.php` 中 `commands` 键
```php
// addon/iot/info.php(示例)
'commands' => [
'iot' => \addon\iot\command\Iot::class,
'iot:start' => \addon\iot\command\Iot::class,
'iot:stop' => \addon\iot\command\Iot::class,
'iot:restart' => \addon\iot\command\Iot::class,
'iot:status' => \addon\iot\command\Iot::class,
'iot:acltest' => \addon\iot\command\TestAcl::class,
'iot:probe' => \addon\iot\command\Probe::class,
],
```
`AppService::loadAddons()` 会在框架启动阶段统一 include 已启用插件的 `info.php`,把 `commands` 键注册到 `Console` 容器;坏类名/非 `Command` 子类/实例化失败会被 `addCommands` 自动跳过,单个插件出错不影响整体加载。
### 启用未注册命令的步骤(推荐)
以 `haonav:checklinks` 为例:
1. 编辑 `addon/haonav/info.php`,在 `return [...]` 数组里加:
```php
'commands' => [
'haonav:checklinks' => \addon\haonav\command\CheckLinks::class,
],
```
2. 执行 `php think addon:manage haonav -a reload --force` 重载插件配置。
3. `php think list | grep haonav` 验证命令已出现。