安全(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(市场 / 开发者中心 / 审核 / 分润)
361 lines
14 KiB
PHP
361 lines
14 KiB
PHP
<?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 个工作日内完成审核,"
|
|
. '通过后令牌会发送至本邮箱,请注意查收。';
|
|
}
|
|
}
|