Files
YwxAppThink/app/appmall/controller/Store.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

276 lines
10 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>
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace app\appmall\controller;
use app\appmall\library\LicensePolicy;
use app\appmall\service\MarketService;
use think\facade\Db;
use think\facade\Request;
use ywxapp\controller\FrontendBase;
/**
* 应用商店前台展示页(公开,仅中心站)
*
* 列出应用市场上线的插件 / 模板,卡片展示 logo、价格、评分、下载量等。
* 数据来自中心站本地库(appmall_addon_list),复用 MarketService::catalog()。
* 路由前缀:/appmall/store/*(由 MultiApp 剥离首段 appmall 后与本应用路由对齐)。
* 视图:app/appmall/view/frontend/store/。
*/
class Store extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 锁定视图路径到应用目录(app/appmall/view/frontend/);仅中心站暴露(客户机不持有市场列表数据,访问即 404)。
*/
protected function initialize()
{
$this->view->layout(false);
}
/**
* 商店首页:上线插件列表
*/
public function index()
{
$catalog = MarketService::instance()->catalog();
$items = $catalog['list'] ?? [];
$categories = $catalog['categories'] ?? [];
$plugins = [];
foreach ($items as $it) {
$price = (float) ($it['price'] ?? 0);
$plugins[] = [
'id' => $it['id'] ?? 0,
'name' => $it['name'] ?? '',
'title' => $it['title'] ?? ($it['name'] ?? ''),
'desc' => $it['intro'] ?? '',
'author' => $it['author'] ?? '',
'logo' => $it['logo'] ?? '',
'version' => $it['version'] ?? '',
'price' => $price,
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
'rating' => (float) ($it['rating'] ?? 0),
'downloads' => (int) ($it['download_count'] ?? 0),
'category' => $it['category'] ?? '',
'type' => $it['type'] ?? 'addon',
];
}
$this->view->assign('list', $plugins);
$this->view->assign('categories', $categories);
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
return $this->view->fetch('store/index');
}
/**
* 插件详情页(/appmall/store/detail/<name>)
* 参考 FastAdmin 插件市场详情版式:左封面+截图、右元信息+版本历史。
*/
public function detail()
{
$name = input('name', '', 'trim');
if ($name === '') {
abort(404, '插件标识为空');
}
$catalog = MarketService::instance()->catalog();
$hit = null;
foreach (($catalog['list'] ?? []) as $it) {
if (($it['name'] ?? '') === $name) {
$hit = $it;
break;
}
}
if (!$hit) {
abort(404, '插件不存在或未上架');
}
$price = (float) ($hit['price'] ?? 0);
$row = [
'id' => $hit['id'] ?? 0,
'name' => $hit['name'] ?? '',
'title' => $hit['title'] ?? ($hit['name'] ?? ''),
'intro' => $hit['intro'] ?? '',
'author' => $hit['author'] ?? '',
'logo' => $hit['logo'] ?? '',
'version' => $hit['version'] ?? '',
'price' => $price,
'price_text' => $price > 0 ? '¥' . number_format($price, 2) : '免费',
'rating' => (float) ($hit['rating'] ?? 0),
'downloads' => (int) ($hit['download_count'] ?? 0),
'category' => $hit['category'] ?? '',
'tags' => $hit['tags'] ?? '',
'type' => $hit['type'] ?? 'addon',
'update_at' => (int) ($hit['update_at'] ?? 0),
'update_fmt' => $hit['update_at'] ? date('Y-m-d', (int) $hit['update_at']) : '',
'screenshots'=> $this->parseScreenshots($hit['screenshots'] ?? ''),
'versions' => $this->normalizeVersions($hit['versions'] ?? []),
// 用户评价:评分与「已审核通过」的评论(submitAddon 提交后由后台审核发布)
'review_count' => (int) ($hit['review_count'] ?? 0),
'reviews' => $this->publishedReviews((int) ($hit['id'] ?? 0)),
];
// 授权状态:登录会员能看到自己「能不能装、要不要续费、要不要升级」。
// 授权按插件标识跨版本匹配,所以展示的是该插件最新版的可用性。
$uid = $this->memberId();
$product = Db::name('appmall_addon_list')->where('id', (int) ($hit['id'] ?? 0))->find() ?: $hit;
$license = $uid > 0 ? LicensePolicy::findLicense($uid, (string) ($hit['name'] ?? ''), (int) ($hit['id'] ?? 0)) : null;
$summary = LicensePolicy::summary($product, $license);
$this->view->assign('addon', $row);
$this->view->assign('license', $summary);
$this->view->assign('member_id', $uid);
$this->view->assign('market_name', config('appmall.appmall_name', 'YwxApp 应用市场'));
return $this->view->fetch('store/detail');
}
/**
* 详情页「购买 / 续费 / 升级」入口。
* POST /appmall/store/buy {id, method?}
*
* 返回 {code:1,data:{...}}:
* - free / owned:直接可用(免支付的免费插件、或已购且无需再付)
* - pay_params:支付表单 HTML,前端插入页面后自动提交即可跳转收银台
* - license_key:模拟支付环境下直接签发
*/
public function buy()
{
$uid = $this->memberId();
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录后再购买']);
}
$id = (int) input('id', 0);
$method = (string) input('method', 'alipay');
try {
$res = MarketService::instance()->buy($uid, $id, $method, (string) Request::domain());
} catch (\Throwable $e) {
return json(['code' => 0, 'msg' => $e->getMessage()]);
}
return json([
'code' => 1,
'msg' => $res['message'] ?? '',
'data' => $res['data'] ?? [],
]);
}
/**
* 详情页「开通试用」入口(商品需配置 trial_days)。
* POST /appmall/store/trial {id}
*/
public function trial()
{
$uid = $this->memberId();
if ($uid <= 0) {
return json(['code' => 0, 'msg' => '请先登录后再试用']);
}
$id = (int) input('id', 0);
try {
$res = MarketService::instance()->trial($uid, $id);
} catch (\Throwable $e) {
return json(['code' => 0, 'msg' => $e->getMessage()]);
}
return json([
'code' => 1,
'msg' => $res['message'] ?? '',
'data' => $res['data'] ?? [],
]);
}
/**
* 当前登录会员 ID(未登录返回 0)
*/
private function memberId(): int
{
if ($this->auth && !empty($this->auth->isLogin)) {
return (int) ($this->auth->info['id'] ?? 0);
}
return 0;
}
/**
* screenshots 可能是逗号分隔字符串 / JSON 数组,归一化为数组。
*/
private function parseScreenshots($raw): array
{
if (is_array($raw)) {
return array_values($raw);
}
$raw = trim((string) $raw);
if ($raw === '') {
return [];
}
if (strpos($raw, '[') === 0) {
$dec = json_decode($raw, true);
return is_array($dec) ? array_values($dec) : [];
}
return array_filter(array_map('trim', explode(',', $raw)), fn ($x) => $x !== '');
}
/**
* versions 子版本归一化(version/price/update_fmt)。
*/
private function normalizeVersions(array $vers): array
{
$out = [];
foreach ($vers as $v) {
$out[] = [
'version' => (string) ($v['version'] ?? ''),
'price' => (float) ($v['price'] ?? 0),
'price_text'=> ((float) ($v['price'] ?? 0)) > 0 ? '¥' . number_format((float) $v['price'], 2) : '免费',
'update_fmt'=> !empty($v['update_at']) ? date('Y-m-d', (int) $v['update_at']) : '',
];
}
return $out;
}
/**
* 取某插件已审核通过的评价(详情页展示用,最多 10 条)
*
* 只取 status=1:用户提交后需运营审核才公开,避免详情页出现垃圾/攻击性内容。
* 表不存在时(老站未升级 schema)返回空数组,详情页降级为「暂无评价」。
*/
private function publishedReviews(int $aid): array
{
if ($aid <= 0) {
return [];
}
try {
$rows = Db::name('appmall_addon_reviews')
->where('aid', $aid)
->where('status', \app\appmall\model\AddonReview::STATUS_PUBLISHED)
->order('create_at', 'desc')
->limit(10)
->field('id,rating,content,version,create_at,uid,site_id')
->select()->toArray();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($rows as $it) {
$out[] = [
'id' => (int) $it['id'],
'rating' => (int) $it['rating'],
'content' => (string) $it['content'],
'version' => (string) $it['version'],
'date' => date('Y-m-d', (int) $it['create_at']),
// 评论者可能来自任意客户机,统一脱敏
'user' => $it['uid'] > 0
? '用户' . substr(md5((string) $it['site_id'] . ':' . $it['uid']), 0, 6)
: '匿名用户',
];
}
return $out;
}
}