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:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -21,10 +21,10 @@ use think\Request;
|
||||
* - 客户机模式(is_market_client()=true,ywxapp.api_url 指向中心站):
|
||||
* 经 RemoteService 代理中心站 /appmall/api/addon/*。
|
||||
* - 中心站模式(api_url 为空/指向本机):
|
||||
* 进程内委托 appmall 插件的 MarketService(订单/授权/收益/支付回调全部在插件侧)。
|
||||
* - 未安装 appmall 插件且非客户机:市场功能不可用,返回明确提示。
|
||||
* 进程内委托 appmall 应用(app/appmall)的 MarketService(订单/授权/收益/支付回调全部在应用侧)。
|
||||
* - appmall 应用缺席且非客户机:市场功能不可用,返回明确提示。
|
||||
*
|
||||
* 中心站领域数据(appmarket_* 表)已全部收敛到 addon/appmall,核心不再直接读写。
|
||||
* 中心站领域数据(appmall_* 表)已全部收敛到 app/appmall,核心不再直接读写。
|
||||
*/
|
||||
class Addon extends ApiBase
|
||||
{
|
||||
@@ -34,14 +34,14 @@ class Addon extends ApiBase
|
||||
|
||||
/**
|
||||
* 中心站模式下委托的插件市场服务
|
||||
* @var \addon\appmall\service\MarketService|null
|
||||
* @var \app\appmall\service\MarketService|null
|
||||
*/
|
||||
protected $market = null;
|
||||
|
||||
public function initialize()
|
||||
{
|
||||
if (!is_market_client() && class_exists(\addon\appmall\service\MarketService::class)) {
|
||||
$this->market = \addon\appmall\service\MarketService::instance();
|
||||
if (!is_market_client() && class_exists(\app\appmall\service\MarketService::class)) {
|
||||
$this->market = \app\appmall\service\MarketService::instance();
|
||||
$this->market->ensureTables();
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,11 @@ class Addon extends ApiBase
|
||||
if (!$this->market) {
|
||||
return $this->marketUnavailable();
|
||||
}
|
||||
$r = $this->market->payResult((string) $request->get('out_trade_no', ''));
|
||||
// method 用于本地 select 查单(alipay / wechat);远程模式随 GET 参数整体透传
|
||||
$r = $this->market->payResult(
|
||||
(string) $request->get('out_trade_no', ''),
|
||||
(string) $request->get('method', 'alipay')
|
||||
);
|
||||
return $this->result->success($r['data'], $r['message']);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@ use addon\articles\model\Article as ArticleModel;
|
||||
|
||||
class Article extends ApiBase
|
||||
{
|
||||
protected $noNeedLogin = ['*'];
|
||||
protected $needRight = ['*'];
|
||||
/**
|
||||
* 免登录白名单:仅只读接口对外公开。
|
||||
*
|
||||
* 历史写法是 ['*'] + $needRight = ['*']:而 ApiBase 只认 $noNeedLogin/$noNeedVerify,
|
||||
* $needRight 根本不存在,等于 create/update/delete 全部裸奔——任何人都能改全站文章。
|
||||
* 写操作必须登录,且只能操作自己的文章(见 assertOwner())。
|
||||
*/
|
||||
protected $noNeedLogin = ['index', 'detail', 'init'];
|
||||
|
||||
|
||||
public function initialize()
|
||||
@@ -41,10 +47,17 @@ class Article extends ApiBase
|
||||
*/
|
||||
public function create(\think\Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
// 字段白名单:绝不能把 $request->post() 全量塞进 create(),
|
||||
// 否则客户端可自行伪造 uid/status/create_at 等本该由服务端决定的字段。
|
||||
$data = $request->only(
|
||||
['title', 'cover_image', 'summary', 'content', 'author', 'cid', 'status'],
|
||||
'post'
|
||||
);
|
||||
if (empty($data['title'])) {
|
||||
$this->result->error('标题不能为空', 404);
|
||||
}
|
||||
// 归属以登录态为准,不接受客户端指定
|
||||
$data['uid'] = $this->uid();
|
||||
$res = $this->model->create($data);
|
||||
if ($res) {
|
||||
event('article_create_after', $res);
|
||||
@@ -53,6 +66,20 @@ class Article extends ApiBase
|
||||
$this->result->error('创建失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 越权保护:只能改/删自己的文章。
|
||||
*
|
||||
* @param \addon\articles\model\Article $info
|
||||
* @return void
|
||||
*/
|
||||
protected function assertOwner($info): void
|
||||
{
|
||||
$uid = $this->uid();
|
||||
if ($uid <= 0 || (int) ($info->uid ?? 0) !== $uid) {
|
||||
$this->result->error('无权操作他人文章', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章详情
|
||||
* @route GET /detail, method:GET
|
||||
@@ -80,6 +107,7 @@ class Article extends ApiBase
|
||||
if (! $info) {
|
||||
$this->result->error('文章不存在', 404);
|
||||
}
|
||||
$this->assertOwner($info);
|
||||
$data = $request->only(
|
||||
['title', 'cover_image', 'summary', 'content', 'author', 'cid', 'status'],
|
||||
'put'
|
||||
@@ -101,18 +129,13 @@ class Article extends ApiBase
|
||||
if (! $info) {
|
||||
$this->result->error('文章不存在', 404);
|
||||
}
|
||||
$this->assertOwner($info);
|
||||
$info->delete();
|
||||
event('article_delete_after', $info);
|
||||
$this->result->success('', '删除成功');
|
||||
}
|
||||
|
||||
|
||||
private function getEncryptPassword($password, $salt = '')
|
||||
{
|
||||
return md5(md5($password) . $salt);
|
||||
}
|
||||
|
||||
|
||||
public function init()
|
||||
{
|
||||
return json(['message' => 'This is version 1 of the API']);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use think\facade\Log;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
use ywxapp\service\FileStorageService;
|
||||
|
||||
/**
|
||||
* 附件管理(需登录)。
|
||||
*
|
||||
* 上传统一走 /api/v1/common/upload(FileStorageService),成功后自动写入
|
||||
* common_attachment 表;本控制器提供当前用户附件的分页查询与删除。
|
||||
*
|
||||
* 删除 = 物理删除文件(经 FileDelete 事件,插件可接管 OSS/FTP)+ 软删记录(delete_at)。
|
||||
*/
|
||||
class Attachment extends ApiBase
|
||||
{
|
||||
/**
|
||||
* 我的附件列表(分页)。
|
||||
*
|
||||
* @param int $page 页码(默认 1)
|
||||
* @param int $size 每页条数(默认 20,上限 100)
|
||||
* @param string $module 模块过滤(可选,如 api / forum / chat)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带 data.list 与 data.total
|
||||
*
|
||||
* @route GET /api/v1/attachment/list
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$page = max(1, (int) $this->request->get('page', 1));
|
||||
$size = min(100, max(1, (int) $this->request->get('size', 20)));
|
||||
$module = trim((string) $this->request->get('module', ''));
|
||||
|
||||
$query = CommonAttachment::where('uid', $this->uid());
|
||||
if ($module !== '') {
|
||||
$query->where('module', $module);
|
||||
}
|
||||
$paginate = $query->order('id', 'desc')->paginate([
|
||||
'list_rows' => $size,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
$items = array_map(function ($item) {
|
||||
return $item->toArray();
|
||||
}, $paginate->items());
|
||||
|
||||
return $this->apiSuccess([
|
||||
'list' => $items,
|
||||
'total' => $paginate->total(),
|
||||
], 'ok');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除附件(仅限本人附件)。
|
||||
*
|
||||
* 步骤:物理删除文件(FileDelete 事件可接管,失败不阻断)→ 软删记录。
|
||||
*
|
||||
* @param int $id 附件 ID(必填)
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带 deleted_file 表示物理文件是否已删除
|
||||
*
|
||||
* @route POST /api/v1/attachment/delete
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = (int) $this->request->post('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->apiError('参数错误');
|
||||
}
|
||||
$att = CommonAttachment::where('uid', $this->uid())->find($id);
|
||||
if (!$att) {
|
||||
return $this->apiError('附件不存在');
|
||||
}
|
||||
|
||||
$deletedFile = false;
|
||||
try {
|
||||
$deletedFile = (new FileStorageService())->delete((string) $att->path, ['id' => $att->id]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('附件物理删除失败 id=' . $att->id . ' ' . $e->getMessage());
|
||||
}
|
||||
$att->delete(); // 软删
|
||||
|
||||
return $this->apiSuccess(['deleted_file' => $deletedFile], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use think\facade\Filesystem;
|
||||
use ywxapp\controller\ApiBase;
|
||||
use ywxapp\model\SystemConfig;
|
||||
use ywxapp\service\FileStorageService;
|
||||
|
||||
/**
|
||||
* 通用基础接口(App 启动所需,聊天 / 商城等应用共用)
|
||||
@@ -73,11 +73,13 @@ class Common extends ApiBase
|
||||
/**
|
||||
* 文件上传(需登录)。
|
||||
*
|
||||
* 接收 multipart/form-data 中的 file 字段,保存到本地存储并返回可访问 URL。
|
||||
* 接收 multipart/form-data 中的 file 字段,统一走 FileStorageService:
|
||||
* 内置驱动兜底,插件监听 FileUpload 事件可接管(OSS / FTP / 自定义存储),
|
||||
* 上传成功后触发 FileUploaded 事件(缩略图 / 水印等扩展)。
|
||||
*
|
||||
* @param \think\file\UploadedFile $file 上传的文件(form-data: file)
|
||||
*
|
||||
* @return \think\Response JSON 响应,成功携带 url / path
|
||||
* @return \think\Response JSON 响应,成功携带 url / path / name / size
|
||||
*
|
||||
* @throws \Throwable 当文件存储失败时
|
||||
*
|
||||
@@ -90,11 +92,20 @@ class Common extends ApiBase
|
||||
return $this->apiError('请选择上传文件');
|
||||
}
|
||||
try {
|
||||
$path = Filesystem::disk('local')->putFile('uploads', $file);
|
||||
$url = Filesystem::disk('local')->url($path);
|
||||
$storage = new FileStorageService();
|
||||
$data = $storage->upload($file, '', [
|
||||
'module' => 'api',
|
||||
'uid' => $this->uid(),
|
||||
'rules' => [
|
||||
'fileSize' => 10 * 1024 * 1024,
|
||||
'fileExt' => 'jpg,jpeg,png,gif,webp,bmp,mp4,mov,mp3,wav,zip,rar,doc,docx,xls,xlsx,pdf,txt',
|
||||
],
|
||||
]);
|
||||
return $this->apiSuccess([
|
||||
'url' => $url,
|
||||
'path' => $path,
|
||||
'url' => $data['url'],
|
||||
'path' => $data['path'],
|
||||
'name' => $data['name'] ?? '',
|
||||
'size' => $data['size'] ?? 0,
|
||||
], '上传成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->apiError('上传失败:' . $e->getMessage());
|
||||
|
||||
@@ -68,12 +68,15 @@ class Sms extends ApiBase
|
||||
public function send()
|
||||
{
|
||||
$cfg = config('smsbao');
|
||||
|
||||
dump($cfg);die;
|
||||
$mobile = $this->request->post("mobile");
|
||||
$event = $this->request->param("event", 'register');
|
||||
$type = $this->request->post("type", 'auto');
|
||||
$source_id = $this->request->post("source_id", '');
|
||||
if (! is_scalar($mobile) || ! is_scalar($event)) {
|
||||
$this->apiError('参数错误');
|
||||
}
|
||||
$mobile = (string) $mobile;
|
||||
$event = (string) $event;
|
||||
try {
|
||||
validate([
|
||||
'mobile' => 'require|max:11',
|
||||
@@ -142,6 +145,12 @@ class Sms extends ApiBase
|
||||
$mobile = $this->request->post("mobile");
|
||||
$event = $this->request->post("event", 'register');
|
||||
$captcha = $this->request->post("captcha");
|
||||
if (! is_scalar($mobile) || ! is_scalar($event) || ! is_scalar($captcha)) {
|
||||
$this->apiError('参数错误');
|
||||
}
|
||||
$mobile = (string) $mobile;
|
||||
$event = (string) $event;
|
||||
$captcha = (string) $captcha;
|
||||
if (! $mobile || ! \think\Validate::regex($mobile, "^1\d{10}$")) {
|
||||
$this->apiError('手机号不正确');
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ class User extends ApiBase
|
||||
if ($user->isLocked()) {
|
||||
return $this->apiError('账号已锁定,请稍后再试');
|
||||
}
|
||||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
if (!$user->checkPassword($password)) {
|
||||
$user->recordLoginFail($this->request->ip());
|
||||
return $this->apiError('密码错误');
|
||||
@@ -229,9 +230,9 @@ class User extends ApiBase
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带会员信息数组
|
||||
*
|
||||
* @route GET /api/v1/user/profile
|
||||
* @route GET /api/v1/user/info
|
||||
*/
|
||||
public function profile()
|
||||
public function info()
|
||||
{
|
||||
$user = $this->auth->model;
|
||||
return $this->apiSuccess($user->toArray());
|
||||
@@ -279,6 +280,92 @@ class User extends ApiBase
|
||||
return $this->apiSuccess($user->toArray(), '保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号(需登录)。
|
||||
*
|
||||
* 校验新手机号 + 短信验证码(事件 bindmobile)通过,且账号未绑定手机号、号码未被占用时写入。
|
||||
* 已绑定手机号的账号请使用 changeMobile 更换。
|
||||
*
|
||||
* @param string $mobile 手机号(必填)
|
||||
* @param string $captcha 短信验证码(必填,事件 bindmobile)
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带绑定后的手机号
|
||||
*
|
||||
* @route POST /api/v1/user/bindMobile
|
||||
*/
|
||||
public function bindMobile()
|
||||
{
|
||||
$user = $this->auth->model;
|
||||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
|
||||
if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) {
|
||||
return $this->apiError('手机号格式不正确');
|
||||
}
|
||||
if ($captcha === '') {
|
||||
return $this->apiError('短信验证码不能为空');
|
||||
}
|
||||
if ($user->mobile) {
|
||||
return $this->apiError('该账号已绑定手机号,如需更换请使用更换接口');
|
||||
}
|
||||
if (! SmsLib::check($mobile, $captcha, 'bindmobile')) {
|
||||
return $this->apiError('短信验证码不正确');
|
||||
}
|
||||
if (UserModel::getByMobile($mobile)) {
|
||||
return $this->apiError('该手机号已被其他账号绑定');
|
||||
}
|
||||
$user->mobile = $mobile;
|
||||
$user->save();
|
||||
SmsLib::flush($mobile, 'bindmobile');
|
||||
|
||||
return $this->apiSuccess(['mobile' => $user->mobile], '绑定成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换绑定手机号(需登录)。
|
||||
*
|
||||
* 仅需新手机号短信验证码(事件 changemobile),无需旧号验证码,
|
||||
* 旧手机号不可用时仍可换绑。新号被其他账号占用时拒绝。
|
||||
*
|
||||
* @param string $mobile 新手机号(必填)
|
||||
* @param string $captcha 新手机号收到的短信验证码(必填,事件 changemobile)
|
||||
*
|
||||
* @return \think\Response JSON 响应,携带绑定后的手机号
|
||||
*
|
||||
* @route POST /api/v1/user/changeMobile
|
||||
*/
|
||||
public function changeMobile()
|
||||
{
|
||||
$user = $this->auth->model;
|
||||
$mobile = trim((string) $this->request->post('mobile', ''));
|
||||
$captcha = trim((string) $this->request->post('captcha', ''));
|
||||
|
||||
if (! preg_match('/^1[3-9]\d{9}$/', $mobile)) {
|
||||
return $this->apiError('手机号格式不正确');
|
||||
}
|
||||
if ($captcha === '') {
|
||||
return $this->apiError('短信验证码不能为空');
|
||||
}
|
||||
if (! $user->mobile) {
|
||||
return $this->apiError('该账号未绑定手机号,请使用绑定接口');
|
||||
}
|
||||
if ($user->mobile === $mobile) {
|
||||
return $this->apiError('新手机号与当前绑定手机号相同');
|
||||
}
|
||||
if (! SmsLib::check($mobile, $captcha, 'changemobile')) {
|
||||
return $this->apiError('短信验证码不正确');
|
||||
}
|
||||
$exists = UserModel::getByMobile($mobile);
|
||||
if ($exists && $exists->uid != $user->uid) {
|
||||
return $this->apiError('该手机号已被其他账号绑定');
|
||||
}
|
||||
$user->mobile = $mobile;
|
||||
$user->save();
|
||||
SmsLib::flush($mobile, 'changemobile');
|
||||
|
||||
return $this->apiSuccess(['mobile' => $user->mobile], '更换成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(需登录)。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user