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|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 $addon 商品行(appmall_addon_list) * @param array|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 $addon 商品行 * @param int $type 订单类型(AddonOrder::TYPE_*) * @param int $major type=2 时为本次升级到的大版本 * @param int $seats type=3 时为新增座席数 * @return array 授权行 */ 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> */ 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, ]; } }