Files
YwxAppThink/app/appmall/command/LicenseNotify.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

155 lines
5.4 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\command;
use app\appmall\library\LicensePolicy;
use think\console\Command;
use think\console\input\Option;
use think\console\Input;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
use ywxapp\library\Mailer;
/**
* 授权到期提醒(订阅到期 / 免费试用到期)。
*
* 为什么需要:订阅制与试用期都要靠「提前告知」才能形成续费转化,
* 否则用户只会在某次安装/升级被拒时才发现授权过期。
*
* 用法:
* php think appmall:license-notify 扫描 7 天内到期项并邮件提醒
* php think appmall:license-notify --days=30 扩大提醒窗口
* php think appmall:license-notify --dry-run 只列出不发信(先看看要发给谁)
* php think appmall:license-notify --no-mail 不发邮件,仅输出与记录日志
*
* 建议 crontab:0 9 * * * cd /path && php think appmall:license-notify
*/
class LicenseNotify extends Command
{
protected function configure(): void
{
$this->setName('appmall:license-notify')
->setDescription('扫描即将到期/即将结束试用的插件授权并发送提醒邮件')
->addOption('days', null, Option::VALUE_OPTIONAL, '提醒窗口天数', 7)
->addOption('dry-run', null, Option::VALUE_NONE, '只列出不发邮件')
->addOption('no-mail', null, Option::VALUE_NONE, '不发送邮件,仅输出与记日志');
}
protected function execute(Input $input, Output $output): int
{
// 先自愈表结构(新增的 trial_end / period / max_major 等列在老库可能还没有)
\app\appmall\library\AppmallSchema::ensure();
$days = (int) $input->getOption('days');
$days = $days > 0 ? $days : 7;
$rows = LicensePolicy::expiring($days);
if (empty($rows)) {
$output->info(sprintf('未来 %d 天内没有到期的授权或试用。', $days));
return 0;
}
$dry = (bool) $input->getOption('dry-run');
$noMail = (bool) $input->getOption('no-mail');
$sent = 0;
foreach ($rows as $r) {
$isTrial = ($r['kind'] ?? '') === 'trial';
$line = sprintf(
'%-6s %-22s %-10s %s %s(剩 %d 天)',
$isTrial ? '试用到期' : '授权到期',
(string) ($r['name'] ?? ''),
(string) ($r['version'] ?? ''),
date('Y-m-d', (int) ($r['end_time'] ?? 0)),
(string) ($r['title'] ?? ''),
(int) ($r['left_days'] ?? 0)
);
$output->writeln(($r['expired'] ? '<error>[已过期]</error> ' : ' ') . $line);
if ($dry || $noMail) {
continue;
}
if ($this->notify((int) ($r['uid'] ?? 0), $r)) {
$sent++;
}
}
Log::info('appmall.license.notify', [
'days' => $days, 'matched' => count($rows), 'sent' => $sent, 'dry' => $dry,
]);
$output->info(sprintf(
'扫描完成:%d 条待提醒%s',
count($rows),
$dry ? '(dry-run,未发信)' : sprintf(',已发送 %d 封', $sent)
));
return 0;
}
/**
* 给会员发一封到期/试用结束提醒
*
* @param array<string,mixed> $row LicensePolicy::expiring() 的一行
*/
private function notify(int $uid, array $row): bool
{
if ($uid <= 0) {
return false;
}
$email = $this->userEmail($uid);
if ($email === '') {
return false;
}
$isTrial = ($row['kind'] ?? '') === 'trial';
$end = date('Y-m-d', (int) ($row['end_time'] ?? 0));
$subject = $isTrial
? sprintf('【试用即将结束】%s 将于 %s 到期', (string) ($row['title'] ?? '插件'), $end)
: sprintf('【授权即将到期】%s 将于 %s 到期', (string) ($row['title'] ?? '插件'), $end);
$tip = $isTrial
? '试用结束后该插件将无法继续使用,请在应用市场购买后继续。'
: '到期后将无法安装/升级该插件,续费后立即恢复。';
$body = sprintf(
"您好:\n\n您在应用市场的插件「%s」(%s)%s于 %s %s。\n%s\n\n—— %s",
(string) ($row['title'] ?? ''),
(string) ($row['name'] ?? ''),
(int) ($row['left_days'] ?? 0) > 0 ? '将' : '已',
$end,
$isTrial ? '结束试用' : '到期',
$tip,
(string) config('app.name', 'YwxApp 应用市场')
);
try {
Mailer::send($email, $subject, nl2br($body));
return true;
} catch (\Throwable $e) {
Log::warning('到期提醒邮件发送失败:' . $e->getMessage());
return false;
}
}
/**
* 取会员邮箱(用户表在不同部署下名字可能是 user/users/member,逐一探测;都取不到就跳过)
*/
private function userEmail(int $uid): string
{
foreach (['user', 'users', 'member'] as $table) {
try {
$row = Db::name($table)->where('id', $uid)->find();
} catch (\Throwable $e) {
continue;
}
if (!empty($row['email'])) {
return (string) $row['email'];
}
}
return '';
}
}