安全(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(市场 / 开发者中心 / 审核 / 分润)
496 lines
20 KiB
PHP
496 lines
20 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\appmall\library;
|
||
|
||
use think\facade\Db;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 插件授权策略中心:判定「要不要再付钱」「能不能装/能不能下载」。
|
||
*
|
||
* 背景(为什么需要本类):
|
||
* - 商品表 appmall_addon_list 的唯一键是 (name, version),同一插件每发一版就是一行、一个新 id;
|
||
* - 而授权表原本只认 aid,导致「买了 1.0」在升到 1.1 时找不到授权,被当成未购买——
|
||
* 实际上把每个版本都卖了一遍。
|
||
* 现在授权以 addon_name(插件标识)为准跨版本生效,aid 只作为「当时购买的版本」留痕。
|
||
*
|
||
* 支持的商业模式(全部由商品表字段驱动,开发者自助配置):
|
||
* - 买断:license_period=0、upgrade_policy=0(默认)→ 一次付款,全部版本可用
|
||
* - 订阅:license_period=30/365 → 到期后续费(订单 type=1)
|
||
* - 大版本收费:upgrade_policy=1(原价)/2(upgrade_price 优惠价) → 1.x 付款后升 2.x 需再付
|
||
* - 多站点:seats > 1 → 一份授权可绑定多个域名;超出按座席增购(订单 type=3)
|
||
* - 试用:trial_days > 0 → 免费试用 N 天,到期需购买转正
|
||
*
|
||
* 本类是 Web 成交链路(MarketService)与中心站 API(controller/api/Market.php)的唯一真相源,
|
||
* 两边都调用它,避免历史上「两份 completeOrder」逐份漂移的情况重演。
|
||
*/
|
||
class LicensePolicy
|
||
{
|
||
/** 决策结果 */
|
||
public const FREE = 'free'; // 免费插件
|
||
public const OWNED = 'owned'; // 已购且在有效期内,无需付钱
|
||
public const TRIALING = 'trialing'; // 试用期内
|
||
public const TRIAL = 'trial'; // 可开通试用
|
||
public const NEW = 'new'; // 首次购买
|
||
public const RENEW = 'renew'; // 订阅续费
|
||
public const UPGRADE = 'upgrade'; // 跨大版本付费升级
|
||
public const SEAT = 'seat'; // 增加站点座席
|
||
public const REVOKED = 'revoked'; // 已吊销(退款)
|
||
|
||
/**
|
||
* 取版本号的大版本位:'1.2.3' → 1,'2.0' → 2,非法版本 → 0
|
||
*/
|
||
public static function majorOf(string $version): int
|
||
{
|
||
$v = ltrim(trim($version), 'vV');
|
||
if ($v === '' || !preg_match('/^(\d+)/', $v, $m)) {
|
||
return 0;
|
||
}
|
||
return (int) $m[1];
|
||
}
|
||
|
||
/**
|
||
* 查找用户的插件授权:以 addon_name 跨版本匹配,老数据回退 aid。
|
||
*
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
public static function findLicense(int $uid, string $addonName, int $aid = 0): ?array
|
||
{
|
||
if ($uid <= 0) {
|
||
return null;
|
||
}
|
||
$row = null;
|
||
if ($addonName !== '') {
|
||
$row = Db::name('appmall_addon_licenses')
|
||
->where('uid', $uid)
|
||
->where('addon_name', $addonName)
|
||
->order('id', 'desc')
|
||
->find();
|
||
}
|
||
// 老授权(addon_name 为空)只能按当时购买的版本行匹配
|
||
if (!$row && $aid > 0) {
|
||
$row = Db::name('appmall_addon_licenses')
|
||
->where('uid', $uid)
|
||
->where('aid', $aid)
|
||
->find();
|
||
}
|
||
return $row ?: null;
|
||
}
|
||
|
||
/**
|
||
* 购买决策:用户现在要点「购买/续费/升级」,到底要不要付钱、付多少。
|
||
*
|
||
* @param array<string,mixed> $addon 商品行(appmall_addon_list)
|
||
* @param array<string,mixed>|null $license 现有授权(findLicense 结果)
|
||
* @param string $targetVersion 目标版本(留空取商品当前版本)
|
||
* @return array{action:string, pay:bool, amount:float, type:int, major:int, message:string}
|
||
*/
|
||
public static function decide(array $addon, ?array $license, string $targetVersion = ''): array
|
||
{
|
||
$price = (float) ($addon['price'] ?? 0);
|
||
$ver = $targetVersion !== '' ? $targetVersion : (string) ($addon['version'] ?? '');
|
||
$major = self::majorOf($ver);
|
||
$policy = (int) ($addon['upgrade_policy'] ?? 0);
|
||
$period = (int) ($addon['license_period'] ?? 0);
|
||
$trial = (int) ($addon['trial_days'] ?? 0);
|
||
|
||
// 免费插件:永远放行(服务端 valid()/download() 同口径)
|
||
if ($price <= 0) {
|
||
return self::verdict(self::FREE, false, 0, 0, $major, '该插件免费,可直接安装');
|
||
}
|
||
|
||
if (!$license) {
|
||
return $trial > 0
|
||
? self::verdict(self::TRIAL, false, 0, 0, $major, sprintf('可免费试用 %d 天', $trial))
|
||
: self::verdict(self::NEW, true, $price, 0, $major, '首次购买');
|
||
}
|
||
|
||
// 已退款/吊销:行还在但不能续,只能重新购买(否则 buy() 幂等会永远返回「您已购买」)
|
||
if ((int) ($license['status'] ?? 1) !== 1) {
|
||
return self::verdict(self::REVOKED, true, $price, 0, $major, '原授权已吊销,需重新购买');
|
||
}
|
||
|
||
$now = time();
|
||
|
||
// 试用期未结束:仍可使用,提示到期后可购买转正
|
||
$trialEnd = (int) ($license['trial_end'] ?? 0);
|
||
if ($trialEnd > 0) {
|
||
if ($trialEnd > $now) {
|
||
return self::verdict(
|
||
self::TRIALING,
|
||
true,
|
||
$price,
|
||
0,
|
||
$major,
|
||
sprintf('试用中,剩 %d 天到期;购买后不受限制', (int) ceil(($trialEnd - $now) / 86400))
|
||
);
|
||
}
|
||
// 试用期已结束 → 按首次购买处理(不叠加订阅/版本判断,反正要全额付)
|
||
return self::verdict(self::NEW, true, $price, 0, $major, '试用期已结束,购买后可继续使用');
|
||
}
|
||
|
||
// 订阅到期 → 续费
|
||
$expire = (int) ($license['expire_time'] ?? 0);
|
||
if ($expire > 0 && $expire < $now) {
|
||
return self::verdict(self::RENEW, true, $price, 1, $major, sprintf(
|
||
'授权已于 %s 到期,续费后继续使用',
|
||
date('Y-m-d', $expire)
|
||
));
|
||
}
|
||
|
||
// 跨大版本付费升级(max_major=0 表示买断式不限版本)
|
||
$owned = (int) ($license['max_major'] ?? 0);
|
||
if ($policy > 0 && $owned > 0 && $major > $owned) {
|
||
$amount = $policy === 2 ? (float) ($addon['upgrade_price'] ?? 0) : $price;
|
||
if ($amount <= 0) {
|
||
$amount = $price; // 优惠价未配置时退回原价,避免出现 0 元付款后被当成「从未购买」
|
||
}
|
||
return self::verdict(
|
||
self::UPGRADE,
|
||
true,
|
||
$amount,
|
||
2,
|
||
$major,
|
||
sprintf('您已购买到 %d.x,升级 %s 需支付 %.2f 元', $owned, $ver, $amount)
|
||
);
|
||
}
|
||
|
||
$msg = 'owned';
|
||
if ($expire > 0) {
|
||
$msg = sprintf('授权有效至 %s', date('Y-m-d', $expire));
|
||
}
|
||
return self::verdict(self::OWNED, false, 0, 0, $major, $msg);
|
||
}
|
||
|
||
/**
|
||
* 访问校验(安装前 valid() 与下载前 download() 共用同一套口径)。
|
||
*
|
||
* @return string|null null=通过;非 null 为拒绝原因
|
||
*/
|
||
public static function checkAccess(array $addon, ?array $license, string $version = ''): ?string
|
||
{
|
||
if ((float) ($addon['price'] ?? 0) <= 0) {
|
||
return null; // 免费插件放行
|
||
}
|
||
if (!$license) {
|
||
// 文案需与 Market::valid() 的「uid<=0」分支区分:这里是「带了账号但查不到授权」,
|
||
// 即没买 / 买在别的账号下 / 授权被退。
|
||
return '该插件为付费插件:当前账号未找到有效授权(请确认已购买,或登录购买时用的账号)';
|
||
}
|
||
if ((int) ($license['status'] ?? 1) !== 1) {
|
||
return '授权已退款/吊销,无法使用';
|
||
}
|
||
|
||
$now = time();
|
||
$trialEnd = (int) ($license['trial_end'] ?? 0);
|
||
if ($trialEnd > 0 && $trialEnd < $now) {
|
||
return '试用期已结束,请购买后继续使用';
|
||
}
|
||
|
||
// 试用期内的授权不做订阅到期判定(expire_time 为 0)
|
||
if ($trialEnd === 0) {
|
||
$expire = (int) ($license['expire_time'] ?? 0);
|
||
if ($expire > 0 && $expire < $now) {
|
||
return '授权已过期,请续费后继续使用';
|
||
}
|
||
}
|
||
|
||
// 大版本上限:0=买断式不限版本
|
||
$owned = (int) ($license['max_major'] ?? 0);
|
||
if ($owned > 0) {
|
||
$major = self::majorOf($version !== '' ? $version : (string) ($addon['version'] ?? ''));
|
||
if ($major > $owned) {
|
||
return sprintf('您的授权仅覆盖到 %d.x,%s 需付费升级后才能使用', $owned, $version);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 域名/站点绑定校验(多站点套餐:domain 逗号分隔,上限由 seats 决定)。
|
||
*
|
||
* @return string|null null=通过(含「已绑定」);非 null 为拒绝原因
|
||
*/
|
||
public static function checkDomain(array $license, string $domain): ?string
|
||
{
|
||
if ($domain === '') {
|
||
return null;
|
||
}
|
||
$bound = array_values(array_filter(array_map('trim', preg_split('/[,\s]+/', (string) ($license['domain'] ?? '')))));
|
||
if (in_array($domain, $bound, true)) {
|
||
return null;
|
||
}
|
||
$seats = (int) ($license['seats'] ?? 1);
|
||
if ($seats > 0 && count($bound) >= $seats) {
|
||
return sprintf('该授权最多绑定 %d 个站点,如需增加请购买站点座席', $seats);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 首次安装时落定域名绑定(把当前域名写进授权;多站点时追加)。
|
||
*
|
||
* @return bool 是否成功(超限返回 false,原因由 checkDomain 表述)
|
||
*/
|
||
public static function bindDomain(array $license, string $domain): bool
|
||
{
|
||
if ($domain === '') {
|
||
return true;
|
||
}
|
||
$bound = array_values(array_filter(array_map('trim', preg_split('/[,\s]+/', (string) ($license['domain'] ?? '')))));
|
||
if (in_array($domain, $bound, true)) {
|
||
return true;
|
||
}
|
||
$seats = (int) ($license['seats'] ?? 1);
|
||
if ($seats > 0 && count($bound) >= $seats) {
|
||
return false;
|
||
}
|
||
$bound[] = $domain;
|
||
Db::name('appmall_addon_licenses')->where('id', (int) $license['id'])
|
||
->update(['domain' => implode(',', $bound), 'update_at' => time()]);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 签发 / 变更授权(支付成功后调用,幂等)。
|
||
*
|
||
* @param array<string,mixed> $addon 商品行
|
||
* @param int $type 订单类型(AddonOrder::TYPE_*)
|
||
* @param int $major type=2 时为本次升级到的大版本
|
||
* @param int $seats type=3 时为新增座席数
|
||
* @return array<string,mixed> 授权行
|
||
*/
|
||
public static function grant(int $uid, array $addon, int $type, int $major = 0, int $seats = 0): array
|
||
{
|
||
$name = (string) ($addon['name'] ?? '');
|
||
$aid = (int) ($addon['id'] ?? 0);
|
||
$period = (int) ($addon['license_period'] ?? 0);
|
||
$policy = (int) ($addon['upgrade_policy'] ?? 0);
|
||
$now = time();
|
||
|
||
$license = self::findLicense($uid, $name, $aid);
|
||
if (!$license) {
|
||
$row = [
|
||
'uid' => $uid,
|
||
'aid' => $aid,
|
||
'addon_name' => $name,
|
||
'license_key' => self::licenseKey($uid, $aid),
|
||
'status' => 1,
|
||
'expire_time' => $period > 0 ? $now + $period * 86400 : 0,
|
||
'max_major' => $policy > 0 ? self::majorOf((string) ($addon['version'] ?? '')) : 0,
|
||
'seats' => max(1, (int) ($addon['seats'] ?? 1)),
|
||
'trial_end' => 0,
|
||
'period' => $period,
|
||
'download_count' => 0,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
];
|
||
try {
|
||
$id = Db::name('appmall_addon_licenses')->insertGetId($row);
|
||
} catch (\Throwable $e) {
|
||
// 并发下可能已被别的请求签发:取回已有行即可
|
||
$again = self::findLicense($uid, $name, $aid);
|
||
if ($again) {
|
||
return $again;
|
||
}
|
||
throw $e;
|
||
}
|
||
$row['id'] = $id;
|
||
return $row;
|
||
}
|
||
|
||
$update = ['update_at' => $now];
|
||
|
||
if ($type === \app\appmall\model\AddonOrder::TYPE_RENEW) {
|
||
// 续费:从「当前时间」或「尚未到期的到期时间」往后接一个周期,避免提前续费吃亏
|
||
$periodSnap = $period > 0 ? $period : (int) ($license['period'] ?? 0);
|
||
$base = max($now, (int) ($license['expire_time'] ?? 0));
|
||
if ($periodSnap > 0) {
|
||
$update['expire_time'] = $base + $periodSnap * 86400;
|
||
$update['period'] = $periodSnap;
|
||
} else {
|
||
$update['expire_time'] = 0; // 已改成买断
|
||
$update['period'] = 0;
|
||
}
|
||
} elseif ($type === \app\appmall\model\AddonOrder::TYPE_UPGRADE) {
|
||
// 升级:抬高版本上限(取 max,防止回退订单把上限降下来)
|
||
$update['max_major'] = max((int) ($license['max_major'] ?? 0), $major > 0 ? $major : self::majorOf((string) ($addon['version'] ?? '')));
|
||
} elseif ($type === \app\appmall\model\AddonOrder::TYPE_SEAT) {
|
||
$update['seats'] = max(1, (int) ($license['seats'] ?? 1)) + max(0, $seats);
|
||
} else {
|
||
// 新购(含试用转正、退款重购)
|
||
$update['max_major'] = $policy > 0 ? self::majorOf((string) ($addon['version'] ?? '')) : 0;
|
||
$update['period'] = $period;
|
||
$update['expire_time'] = $period > 0 ? $now + $period * 86400 : 0;
|
||
}
|
||
|
||
// 转正 / 重购:清掉试用标记与吊销状态,域名绑定保留(同一用户的站点继续使用)
|
||
$update['trial_end'] = 0;
|
||
$update['status'] = 1;
|
||
|
||
Db::name('appmall_addon_licenses')->where('id', (int) $license['id'])->update($update);
|
||
|
||
return array_merge($license, $update);
|
||
}
|
||
|
||
/**
|
||
* 开通免费试用(一个插件一个用户只能试用一次)。
|
||
*
|
||
* @throws \RuntimeException 不满足试用条件时抛出可直接展示给用户的文案
|
||
*/
|
||
public static function startTrial(int $uid, array $addon): array
|
||
{
|
||
$name = (string) ($addon['name'] ?? '');
|
||
$days = (int) ($addon['trial_days'] ?? 0);
|
||
$exist = self::findLicense($uid, $name, (int) ($addon['id'] ?? 0));
|
||
if ($exist) {
|
||
throw new \RuntimeException('您已有该插件的授权记录,无需试用');
|
||
}
|
||
if ($days <= 0) {
|
||
throw new \RuntimeException('该插件未提供免费试用');
|
||
}
|
||
|
||
$policy = (int) ($addon['upgrade_policy'] ?? 0);
|
||
$now = time();
|
||
$row = [
|
||
'uid' => $uid,
|
||
'aid' => (int) ($addon['id'] ?? 0),
|
||
'addon_name' => $name,
|
||
'license_key' => self::licenseKey($uid, (int) ($addon['id'] ?? 0)),
|
||
'status' => 1,
|
||
'expire_time' => 0,
|
||
'max_major' => $policy > 0 ? self::majorOf((string) ($addon['version'] ?? '')) : 0,
|
||
'seats' => max(1, (int) ($addon['seats'] ?? 1)),
|
||
'trial_end' => $now + $days * 86400,
|
||
'period' => 0,
|
||
'download_count' => 0,
|
||
'create_at' => $now,
|
||
'update_at' => $now,
|
||
];
|
||
try {
|
||
$row['id'] = Db::name('appmall_addon_licenses')->insertGetId($row);
|
||
} catch (\Throwable $e) {
|
||
$again = self::findLicense($uid, $name, (int) ($addon['id'] ?? 0));
|
||
if ($again) {
|
||
return $again;
|
||
}
|
||
throw $e;
|
||
}
|
||
return $row;
|
||
}
|
||
|
||
/**
|
||
* 即将到期 / 即将结束试用的授权(供 license:notify 命令扫描提醒)
|
||
*
|
||
* @param int $withinDays 未来多少天内到期
|
||
* @return array<int,array<string,mixed>>
|
||
*/
|
||
public static function expiring(int $withinDays = 7): array
|
||
{
|
||
$now = time();
|
||
$dead = $now + max(1, $withinDays) * 86400;
|
||
|
||
// 用 LEFT JOIN:商品行被删(下架清理)也不该把「快要到期的授权」整体漏扫,
|
||
// 名称回退到 addon_name。
|
||
$rows = Db::name('appmall_addon_licenses')
|
||
->alias('l')
|
||
->leftJoin('appmall_addon_list a', 'a.id = l.aid')
|
||
->where('l.status', 1)
|
||
->where(function ($q) use ($now, $dead) {
|
||
$q->where(function ($q2) use ($now, $dead) {
|
||
// 订阅到期(含当天到期)
|
||
$q2->where('l.trial_end', 0)
|
||
->where('l.expire_time', '>', 0)
|
||
->where('l.expire_time', '<=', $dead);
|
||
})->whereOr(function ($q3) use ($now, $dead) {
|
||
// 试用到期
|
||
$q3->where('l.trial_end', '>', 0)
|
||
->where('l.trial_end', '<=', $dead);
|
||
});
|
||
})
|
||
->field('l.id,l.uid,l.aid,l.addon_name,l.license_key,l.expire_time,l.trial_end,l.period,l.max_major,l.domain,l.status,'
|
||
. 'a.name,a.title,a.version,a.price,a.license_period')
|
||
->select()
|
||
->toArray();
|
||
|
||
foreach ($rows as &$r) {
|
||
if (empty($r['name'])) {
|
||
$r['name'] = (string) ($r['addon_name'] ?? '');
|
||
$r['title'] = $r['name'];
|
||
}
|
||
$isTrial = (int) ($r['trial_end'] ?? 0) > 0;
|
||
$end = $isTrial ? (int) $r['trial_end'] : (int) $r['expire_time'];
|
||
$r['kind'] = $isTrial ? 'trial' : 'expire';
|
||
$r['end_time'] = $end;
|
||
$r['expired'] = $end <= $now;
|
||
$r['left_days'] = (int) ceil(($end - $now) / 86400);
|
||
}
|
||
unset($r);
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 给用户看的授权摘要(前台「我的已购」/ 详情页用)
|
||
*/
|
||
public static function summary(array $addon, ?array $license): array
|
||
{
|
||
$d = self::decide($addon, $license, (string) ($addon['version'] ?? ''));
|
||
return [
|
||
'owned' => $license !== null && (int) ($license['status'] ?? 1) === 1,
|
||
'action' => $d['action'],
|
||
'need_pay' => $d['pay'],
|
||
'pay_amount' => $d['amount'],
|
||
'pay_type' => $d['type'],
|
||
'message' => $d['message'],
|
||
'trial_end' => (int) ($license['trial_end'] ?? 0),
|
||
'expire_time' => (int) ($license['expire_time'] ?? 0),
|
||
'max_major' => (int) ($license['max_major'] ?? 0),
|
||
'seats' => (int) ($license['seats'] ?? 1),
|
||
'bound_domain' => (string) ($license['domain'] ?? ''),
|
||
'license_key' => (string) ($license['license_key'] ?? ''),
|
||
// 商品侧售卖策略(前端据此决定按钮文案)
|
||
'policy' => [
|
||
'license_period' => (int) ($addon['license_period'] ?? 0),
|
||
'trial_days' => (int) ($addon['trial_days'] ?? 0),
|
||
'upgrade_policy' => (int) ($addon['upgrade_policy'] ?? 0),
|
||
'upgrade_price' => (float) ($addon['upgrade_price'] ?? 0),
|
||
'seats' => (int) ($addon['seats'] ?? 1),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 生成唯一授权码
|
||
*/
|
||
public static function licenseKey(int $uid, int $aid): string
|
||
{
|
||
do {
|
||
$key = strtoupper(sprintf(
|
||
'%s-%s-%s',
|
||
substr(md5($uid . $aid . random_bytes(8)), 0, 8),
|
||
bin2hex(random_bytes(4)),
|
||
bin2hex(random_bytes(4))
|
||
));
|
||
} while (Db::name('appmall_addon_licenses')->where('license_key', $key)->find());
|
||
return $key;
|
||
}
|
||
|
||
/**
|
||
* 决策数组构造(保证字段齐全)
|
||
*/
|
||
private static function verdict(string $action, bool $pay, float $amount, int $type, int $major, string $message): array
|
||
{
|
||
return [
|
||
'action' => $action,
|
||
'pay' => $pay,
|
||
'amount' => round($amount, 2),
|
||
'type' => $type,
|
||
'major' => $major,
|
||
'message' => $message,
|
||
];
|
||
}
|
||
}
|