Files
YwxAppThink/app/appmall/library/LicensePolicy.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

496 lines
20 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
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,
];
}
}