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

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

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

360 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
use BcMath\Number;
use think\exception\HttpResponseException;
use think\facade\Request;
use think\Response;
class Result
{
/**
* Summary of StatusCode
* @var int
*/
protected $StatusCode = 200;
public $Message = null;
/**
* Summary of Headers
* @var array
*/
protected $Headers = [];
protected $AccessToken;
protected $AccessExpired;
protected $RefreshToken;
protected $RefreshExpired;
protected $Total = 0;
/**
* Summary of Type
* @var string
*/
protected $Type = 'json';
protected $isCout = false;
public function __construct()
{
if (! Request::isAjax()) {
$this->Type = 'html';
}
}
public function success($data = null, $message = 'success', int $code = 0)
{
$result = ['code' => 0, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->Total > 0) {
$result['count'] = $this->Total;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
if ('html' == strtolower($this->Type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp/view/']);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('success.html', $result);
}
$response = Response::create($result, strtolower($this->Type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
$response->send();
app()->http->end($response);
exit;
// throw new HttpResponseException($response);
}
public function error($message = 'Error', int $code = 1, $data = null)
{
$result = ['code' => $code, 'message' => $message, 'timestamp' => time()];
if ($data) {
$result['data'] = $data;
}
if ($this->AccessToken != null) {
$result['access_token'] = $this->AccessToken;
$result['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken != null) {
$result['refresh_token'] = $this->RefreshToken;
$result['refresh_expired'] = $this->RefreshExpired;
}
$response = '';
// 若已被强制为 JSON(如插件 API 在 MultiApp 中间件里 setType('json')),
// 或请求本身是 ajax,则统一返回 JSON;否则按 HTML 渲染。
$type = ($this->Type === 'json') ? 'json' : (Request::isAjax() ? 'json' : 'html');
if ($this->StatusCode == 401) {
$result['url'] = url('login/index');
$result['code'] = $this->StatusCode;
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
$result = \think\facade\View::fetch('401.html', $result);
}
$response = Response::create($result, strtolower($type), 401);
$this->applyTokenCookies($response);
} else {
if ('html' == strtolower($type)) {
\think\facade\View::config(['view_path' => root_path() . 'ywxapp' . DIRECTORY_SEPARATOR . 'view' . DIRECTORY_SEPARATOR]);
\think\facade\View::layout(false);
$result = \think\facade\View::fetch('error.html', $result);
}
$response = Response::create($result, strtolower($type), $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
}
$response->send();
app()->http->end($response);
exit;
throw new HttpResponseException($response);
}
/**
* 返回封装后的 API 数据到客户端(已弃用,字段契约与 success/error 不一致).
*
* 请改用 apiSuccess() / apiError():统一 JSON、统一字段(code/msg/data/timestamp),
* 业务 code 与 HTTP 状态码解耦,不再受后台 HTML/401 跳转逻辑影响。
*
* @param mixed $data 要返回的数据
* @param int $code 返回的 code
* @param mixed $msg 提示信息
* @param string $type 返回数据格式
* @param array $header 发送的 Header 信息
*
* @deprecated 使用 apiSuccess() / apiError() 替代
*/
protected function result($data, $code = 0, $msg = '', $type = '', array $header = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => Request::server('REQUEST_TIME'),
'data' => $data,
];
$type = $type ?: $this->getResponseType();
$response = Response::create($result, $type)->header($header);
throw new HttpResponseException($response);
}
/**
* API 成功响应(强制 JSON,契约稳定).
*
* 专供 App / 小程序 / 第三方客户端调用,与后台 HTML 响应完全解耦:
* - 无论请求是否 ajax,固定返回 JSON;
* - 统一字段:code(0) / msg / data / timestamp;
* - 若本实例持有 token,自动附加 access_token / refresh_token 等字段;
* - 若设置了 count,自动附加 count(分页场景)。
*
* @param mixed $data 业务数据,可为数组 / 模型 / null
* @param string $message 成功提示语
* @param int $count 分页总数(可选,>0 时附加 count 字段)
*
* @return void 通过 Response 直接输出并终止
*/
public function apiSuccess($data = null, string $message = 'success', int $count = 0): void
{
$result = ['code' => 0, 'msg' => $message, 'timestamp' => time()];
if ($data !== null) {
$result['data'] = $data;
}
if ($count > 0) {
$result['total'] = $count;
}
if ($this->AccessToken !== null) {
$result['tokens']['access_token'] = $this->AccessToken;
$result['tokens']['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken !== null) {
$result['tokens']['refresh_token'] = $this->RefreshToken;
$result['tokens']['refresh_expired'] = $this->RefreshExpired;
}
$response = Response::create($result, 'json', $this->StatusCode)->header($this->Headers);
$this->applyTokenCookies($response);
$response->send();
app()->http->end($response);
exit;
}
/**
* API 失败响应(强制 JSON,业务 code 与 HTTP 状态解耦).
*
* 与 apiSuccess 配套,专供客户端调用:
* - 固定返回 JSON;
* - 统一字段:code / msg / data(可选) / timestamp;
* - 业务 code 原样透传(如 401 失败返回 code=401,而非被覆盖);
* - HTTP 状态码由 $httpStatus 控制(默认 200,鉴权失败传 401)。
*
* @param string $message 失败提示语
* @param int $code 业务状态码(默认 1,鉴权失败建议 401)
* @param mixed $data 附加数据(可选)
* @param int $httpStatus HTTP 状态码(默认 200)
*
* @return void 通过 Response 直接输出并终止
*/
public function apiError(string $message = 'Error', int $code = 1, $data = null, int $httpStatus = 200): void
{
$result = ['code' => $code, 'msg' => $message, 'timestamp' => time()];
if ($data !== null) {
$result['data'] = $data;
}
if ($this->AccessToken !== null) {
$result['tokens']['access_token'] = $this->AccessToken;
$result['tokens']['access_expired'] = $this->AccessExpired;
}
if ($this->RefreshToken !== null) {
$result['tokens']['refresh_token'] = $this->RefreshToken;
$result['tokens']['refresh_expired'] = $this->RefreshExpired;
}
$response = Response::create($result, 'json', $httpStatus)->header($this->Headers);
$this->applyTokenCookies($response);
$response->send();
app()->http->end($response);
exit;
}
/**
* URL 重定向.
*
* @param string $url 跳转的 URL 表达式
* @param array|int $params 其它 URL 参数
* @param int $code http code
* @param array $with 隐式传参
*/
public function redirect($url, $code = 302, $params = [], $with = []): self
{
if (is_int($params)) {
$code = $params;
}
$response = \redirect($url);
$response->code($code)->with($with);
throw new HttpResponseException($response);
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setAccessToken($token, $expired = null): self
{
$this->AccessToken = $token;
$this->AccessExpired = $expired;
return app()->result;
}
/**
* 设置Token
* @param string $token
* @return void
*/
public function setRefreshToken($token, $expired = null): self
{
$this->RefreshToken = $token;
$this->RefreshExpired = $expired;
return $this;
}
public function setStatusCode(int $code = 200): self
{
$this->StatusCode = $code;
return $this;
}
public function setMessage(string $message = ''): self
{
$this->Message = $message;
return $this;
}
public function setHeaders(array $header = []): self
{
$this->Headers = $header;
return $this;
}
/**
* 强制设置响应类型(json/html)
* 插件 API 由小程序/APP 等客户端调用,请求不带 X-Requested-With,
* 在 MultiApp 中间件里统一 setType('json') 以确保返回 JSON。
* @param string $type
* @return self
*/
public function setType(string $type = 'json'): self
{
$this->Type = strtolower($type);
return app()->result;
}
/**
* 把当前持有的 access/refresh token 写入响应 Cookie(path=/,非 httponly)。
*
* 背景:登录/注册的 token 经 JwtService 存入本 Result 单例(setAccessToken/setRefreshToken),
* 但 persistTokens() 用的全局 Cookie 门面与 Response 持有的 Cookie 实例并非同一个,
* 仅靠门面 set 的 token 不会被 Response::send() 输出,导致整页跳转(菜单点击、iframe)
* 不带 token → 后端读不到 → 跳登录页。此处直接写到 Response 实例上,确保 100% 随响应输出。
*
* @param Response $response
* @return void
*/
protected function applyTokenCookies(Response $response): void
{
if ($this->AccessToken !== null && $this->AccessToken !== '') {
$expire = $this->AccessExpired ? ((int) $this->AccessExpired - time()) : 0;
$response->cookie('access_token', (string) $this->AccessToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
if ($this->RefreshToken !== null && $this->RefreshToken !== '') {
$expire = $this->RefreshExpired ? ((int) $this->RefreshExpired - time()) : 0;
$response->cookie('refresh_token', (string) $this->RefreshToken, [
'expire' => $expire,
'path' => '/',
'httponly' => false,
]);
}
}
public function setCount(int $total): self
{
$this->Total = $total;
return app()->result;
}
public static function instance($options = []): Result
{
return app()->result;
}
}