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

+360
View File
@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
namespace app\appmall\controller;
use think\facade\Db;
use ywxapp\library\Mailer;
use app\appmall\library\AppmallSchema;
use ywxapp\controller\FrontendBase;
/**
* 开发者中心(独立前台应用,非会员中心、非后台)
* 开发者自助注册 / 查询令牌 / 收益查询 / 提现;完全公开(noNeedLogin=*),
* 以开发者令牌(token)而非会员登录态认证。
* 路由前缀:/appmall/developer/*(由 MultiApp 剥离首段 appmall 后与本应用路由对齐)。
* 视图:app/appmall/view/frontend/developer/。
*
* 注:此处显式把 view_path 锁定到本应用视图目录,与 AppmallFrontend 基类的切换保持一致(幂等)。
*/
class Developer extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 锁定视图路径到本应用视图目录
*/
protected function initialize()
{
url('appmall/developer/index' );
// 确保 appmall 运营表存在(开发者中心只读开发者/收益/提现等表,避免全新安装 1146)
AppmallSchema::ensure();
$this->view->layout(false);
// 供前端 <script src=".../wxapp.js" module="{$site.app}"> 使用(前台应用目录名)
$this->view->assign('site', ['app' => 'frontend']);
}
/**
* 注册页
*/
public function index()
{
$this->view->assign('register_open', (bool) config('appmall.allow_register', true));
$this->view->assign('need_approval', (bool) config('appmall.need_approval', true));
return $this->view->fetch();
}
/**
* 提交注册(生成令牌,按配置决定是否需要审核)
*/
public function save()
{
if (! $this->request->isPost()) {
$this->result->error('访问错误');
}
if (! (bool) config('appmall.allow_register', true)) {
$this->result->error('当前未开放自助注册');
}
$email = trim($this->request->param('email', ''));
$name = trim($this->request->param('username', ''));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->result->error('邮箱格式不正确');
}
if ($name === '') {
$this->result->error('请填写开发者名称');
}
// 防刷:重复 POST 会疯狂写库 + 发邮件
$this->assertRateLimit('register', $email);
if (Db::name('appmall_developers')->where('email', $email)->find()) {
$this->result->error('该邮箱已申请过开发者令牌');
}
$token = bin2hex(random_bytes(32));
$needApproval = (bool) config('appmall.need_approval', true);
$status = $needApproval ? 0 : 1;
Db::name('appmall_developers')->insert([
'username' => $name,
'email' => $email,
'token' => $token,
'status' => $status,
'create_at' => time(),
'update_at' => time(),
]);
if ($needApproval) {
Mailer::send($email, '开发者入驻申请已收到', $this->pendingMail($name));
$this->result->success([], '注册成功,等待运营审核,通过后令牌将发送至您的邮箱');
}
Mailer::send($email, '您的开发者令牌', $this->tokenMail($name, $token));
$this->result->success(
['token' => $token],
'注册成功,请将下方令牌配置到开发者控制台的 APPMARKET_DEV_TOKEN'
);
}
/**
* 查询我的令牌
*/
public function mine()
{
return $this->view->fetch('developer/mine');
}
/**
* 根据邮箱取回令牌(只重发邮件,不再在响应里回显明文)
*
* 安全说明(P0):原实现把 token 明文直接放进 JSON 响应,且用不同文案区分
* 「邮箱不存在 / 待审核 / 已禁用」—— 等于给了一个**开发者邮箱枚举接口**,
* 任何人拿到邮箱就能直接冒用开发者身份去发布插件、改价、提现。
* 现在:统一文案 + 只发邮件 + IP/邮箱双维度限流 + 随机延时。
*/
public function lookup()
{
if (! $this->request->isPost()) {
$this->result->error('访问错误');
}
$email = trim($this->request->param('email', ''));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->result->error('邮箱格式不正确');
}
$this->assertRateLimit('lookup', $email);
$dev = Db::name('appmall_developers')->where('email', $email)->find();
if ($dev && (int) $dev['status'] === 1) {
Mailer::send($email, '您的开发者令牌', $this->tokenMail($dev['username'], $dev['token']));
}
// 随机延时:抹平「命中/未命中」的响应耗时差异,堵住时序侧信道枚举
usleep(random_int(150, 500) * 1000);
// 无论邮箱是否存在、状态如何,都返回同一句话(防枚举)
$this->result->success([], '如果该邮箱已注册且已通过审核,令牌已发送至邮箱,请查收');
}
/**
* 频率限制(IP + 业务 key 双维度)
*
* 开发者中心所有接口都免登录,若不限制相当于公开的压力放大器:
* 可被用来刷邮件(SMTP 信誉 / 配额被耗尽)与枚举开发者邮箱。
*
* @param string $scene 业务场景 key
* @param string $extra 参与计数的业务值(如邮箱)
*/
protected function assertRateLimit(string $scene, string $extra = ''): void
{
$ip = (string) $this->request->ip();
$limit = (int) config('appmall.developer_limit', 5);
$window = (int) config('appmall.developer_window', 600);
$key = 'appmall_dev_' . $scene . '_' . md5($ip . '|' . strtolower($extra));
$num = (int) \think\facade\Cache::get($key, 0);
if ($num >= $limit) {
\think\facade\Log::warning('[Developer] 开发者中心请求超频', [
'scene' => $scene, 'ip' => $ip,
]);
$this->result->error('操作过于频繁,请 ' . (int) ($window / 60) . ' 分钟后再试');
}
\think\facade\Cache::set($key, $num + 1, $window);
}
/**
* 我的收益(令牌认证):返回余额与近期收益账本
*/
public function earnings()
{
$this->ensureTables();
$token = $this->request->param('token', '');
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
if (empty($dev)) {
return $this->result->error('令牌无效');
}
$revenues = Db::name('appmall_addon_revenues')->where('developer_id', $dev['id'])
->order('id', 'desc')->limit(20)->select()->toArray();
$this->result->success([
'balance' => $dev['balance'] ?? 0,
'frozen_balance' => $dev['frozen_balance'] ?? 0,
'revenues' => $revenues,
], '获取成功');
}
/**
* 发起提现(令牌认证):可提现余额 → 冻结,生成提现单待运营打款
*/
public function withdraw()
{
if (! $this->request->isPost()) {
return $this->result->error('访问错误');
}
$this->ensureTables();
$token = $this->request->param('token', '');
$amount = (float) $this->request->param('amount', 0);
$channel = $this->request->param('channel', 'alipay');
$account = trim($this->request->param('account', ''));
$dev = Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
if (empty($dev)) {
return $this->result->error('令牌无效');
}
if ($amount <= 0) {
return $this->result->error('提现金额必须大于 0');
}
if ($amount > (float) ($dev['balance'] ?? 0)) {
return $this->result->error('可提现余额不足');
}
if ($account === '') {
return $this->result->error('请填写收款账号');
}
Db::transaction(function () use ($dev, $amount, $channel, $account) {
Db::name('appmall_developers')->where('id', $dev['id'])
->dec('balance', $amount)->inc('frozen_balance', $amount)->update();
Db::name('appmall_addon_withdrawals')->insert([
'developer_id' => $dev['id'],
'amount' => $amount,
'channel' => $channel,
'account' => $account,
'status' => 0,
'create_at' => time(),
'update_at' => time(),
]);
});
return $this->result->success([], '提现申请已提交,等待运营打款');
}
/**
* 开发者控制台入口(统一 DZ 风格布局):令牌认证后渲染控制台
* 路由:GET /appmall/developer/console?token=xxx
*/
public function console()
{
$this->view->assign('token', $this->request->param('token', ''));
return $this->view->fetch('developer/console');
}
/**
* 开发者控制台(兼容旧入口,重定向到统一控制台)
*/
public function dashboard()
{
$this->view->assign('token', $this->request->param('token', ''));
return $this->view->fetch('developer/console');
}
/**
* 控制台概览数据(令牌认证):应用数 / 待审数 / 总下载 / 余额 / 累计收益 / 近期提交
* 路由:POST /appmall/developer/overview
*/
public function overview()
{
$this->ensureTables();
$dev = $this->authDev();
if (empty($dev)) {
return $this->result->error('令牌无效');
}
$devId = (int) $dev['id'];
$appCount = Db::name('appmall_addon_list')->where('developer_id', $devId)->where('status', 1)->count();
$pendingCount = Db::name('appmall_addon_submissions')->where('developer_id', $devId)->where('status', 0)->count();
$totalDownload = (int) Db::name('appmall_addon_list')->where('developer_id', $devId)->sum('download_count');
$totalIncome = (float) Db::name('appmall_addon_revenues')->where('developer_id', $devId)->where('status', 1)->sum('income');
$recent = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
->field('title,version,status,update_at,type')->order('id', 'desc')->limit(5)->select()->toArray();
$this->result->success([
'username' => $dev['username'],
'email' => $dev['email'],
'balance' => $dev['balance'] ?? 0,
'frozen_balance' => $dev['frozen_balance'] ?? 0,
'app_count' => $appCount,
'pending_count' => $pendingCount,
'total_downloads' => $totalDownload,
'total_income' => $totalIncome,
'recent' => $recent,
], '获取成功');
}
/**
* 我的应用(令牌认证):已上架/下架应用 + 提交记录(待审/驳回)合并返回
* 路由:POST /appmall/developer/apps
*/
public function apps()
{
$this->ensureTables();
$dev = $this->authDev();
if (empty($dev)) {
return $this->result->error('令牌无效');
}
$devId = (int) $dev['id'];
$listed = Db::name('appmall_addon_list')->where('developer_id', $devId)
->field('id,name,title,version,price,logo,category,download_count,status,update_at,type')
->order('update_at', 'desc')->select()->toArray();
$subs = Db::name('appmall_addon_submissions')->where('developer_id', $devId)
->field('id,name,title,version,price,logo,category,status,reject_reason,update_at,type')
->order('update_at', 'desc')->select()->toArray();
$this->result->success(['listed' => $listed, 'submissions' => $subs], '获取成功');
}
/**
* 修改我的应用价格(令牌认证,仅能改自己名下的应用;无需重新打包/重新提审)
* 路由:POST /appmall/developer/price {token, name, price}
*/
public function price()
{
if (! $this->request->isPost()) {
return $this->result->error('访问错误');
}
$this->ensureTables();
$dev = $this->authDev();
if (empty($dev)) {
return $this->result->error('令牌无效');
}
$name = trim((string) $this->request->param('name', ''));
$price = (float) $this->request->param('price', -1);
try {
\app\appmall\service\MarketService::instance()
->updatePrice($name, $price, (int) $dev['id']);
} catch (\app\appmall\service\MarketException $e) {
return $this->result->error($e->getMessage());
}
return $this->result->success([], '价格已更新,市场即时生效');
}
/**
* 令牌鉴权:返回 status=1 的开发者记录,否则 null(沿用 earnings/withdraw 的契约)
*/
private function authDev()
{
$token = $this->request->param('token', '');
return Db::name('appmall_developers')->where('token', $token)->where('status', 1)->find();
}
/**
* 运行时自愈建表:开发者 / 收益 / 提现等运营表(幂等,进程内只探测一次)。
* 唯一事实源是 app/appmall/install.sql,由 AppmallSchema 解析执行,本类不再内联 DDL。
*/
private function ensureTables(): void
{
AppmallSchema::ensure();
}
/**
* 开发者令牌邮件正文
*/
private function tokenMail($name, $token): string
{
return "尊敬的 {$name}:<br>您的开发者令牌已生成:<br><br>"
. "<code style=\"background:#f4f4f4;padding:4px 8px;border-radius:4px;\">{$token}</code><br><br>"
. '请将其配置到开发者控制台的 <b>APPMARKET_DEV_TOKEN</b> 环境变量后,即可在「发布到官方市场」中使用。';
}
/**
* 审核中邮件正文
*/
private function pendingMail($name): string
{
return "尊敬的 {$name}:<br>我们已收到您的开发者入驻申请,将在 1-3 个工作日内完成审核,"
. '通过后令牌会发送至本邮箱,请注意查收。';
}
}