安全(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(市场 / 开发者中心 / 审核 / 分润)
239 lines
8.7 KiB
PHP
239 lines
8.7 KiB
PHP
<?php
|
||
/*
|
||
* @Author: YwxApp <ywx@ywxapp.cn>
|
||
* @Date: 2026-08-07 22:53:48
|
||
* @LastEditors: YwxApp <ywx@ywxapp.cn>
|
||
* @LastEditTime: 2026-08-19 21:12:33
|
||
* @Description:
|
||
* @FilePath: \ywxapp_dev\ywxapp\model\Score.php
|
||
* @CustomString: Copyright (c) 2026 YwxApp
|
||
*/
|
||
declare(strict_types=1);
|
||
namespace ywxapp\model;
|
||
|
||
use ywxapp\model\BaseModel;
|
||
|
||
use think\facade\Db;
|
||
use think\model\concern\SoftDelete;
|
||
|
||
/**
|
||
* 积分规则 & 积分流水
|
||
*/
|
||
class MebmberScore extends BaseModel
|
||
{
|
||
use SoftDelete;
|
||
|
||
/**
|
||
* 模型配置(BaseModel::applyOptions 约定)
|
||
*/
|
||
protected function getOptions(): array
|
||
{
|
||
return [
|
||
'name' => 'member_score_rule',
|
||
'strict' => true,
|
||
'autoWriteTimestamp' => 'int',
|
||
'createTime' => 'create_at',
|
||
'updateTime' => 'update_at',
|
||
'deleteTime' => 'delete_at',
|
||
'readonly' => ['view_count']
|
||
];
|
||
}
|
||
|
||
// 规则类型
|
||
const TYPE_GAIN = 1; // 获取
|
||
const TYPE_SPEND = 2; // 消费
|
||
|
||
public static function typeList(): array
|
||
{
|
||
return [
|
||
self::TYPE_GAIN => '获取',
|
||
self::TYPE_SPEND => '消费',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 确保 score_rule / score_log 关联表已就绪(静态入口可能未实例化模型)
|
||
*
|
||
* 老库自愈:install.sql 建表后仍可能缺列(如规则引擎新增的 addon/limit_* 与
|
||
* 流水的 action/source/source_id),这里按 install.sql 的定义补齐,
|
||
* 并把历史遗留的 name 列改名为 title。
|
||
*/
|
||
public static function ensureSchema(): void
|
||
{
|
||
$prefix = BaseModel::currentPrefix();
|
||
BaseModel::ensureTableFromInstall($prefix, 'member_score_rule');
|
||
BaseModel::ensureTableFromInstall($prefix, 'member_score_log');
|
||
BaseModel::ensureColumn("{$prefix}member_user", 'score', "int unsigned NOT NULL DEFAULT 0 COMMENT '积分(做任务/互动获得)'");
|
||
|
||
$ruleTable = $prefix . 'member_score_rule';
|
||
$logTable = $prefix . 'member_score_log';
|
||
|
||
// 历史遗留字段改名:name -> title(仅当 name 存在且 title 不存在)
|
||
self::renameColumnIfNeeded(
|
||
$ruleTable,
|
||
'name',
|
||
'title',
|
||
"varchar(100) NOT NULL DEFAULT '' COMMENT '规则名称'"
|
||
);
|
||
|
||
// 规则表:规则引擎新增列
|
||
foreach ([
|
||
'addon' => "varchar(50) NOT NULL DEFAULT '' COMMENT '归属插件标识(空=核心)'",
|
||
'limit_type' => "tinyint(4) NOT NULL DEFAULT 0 COMMENT '限次周期 0不限1每日2每周3每月4累计'",
|
||
'limit_times' => "int(11) NOT NULL DEFAULT 0 COMMENT '周期内最多触发次数(0=不限)'",
|
||
'unique_type' => "tinyint(4) NOT NULL DEFAULT 0 COMMENT '去重 0不去重1按来源业务ID去重'",
|
||
'remark' => "varchar(255) NOT NULL DEFAULT '' COMMENT '流水备注模板({title}等占位)'",
|
||
] as $column => $def) {
|
||
BaseModel::ensureColumn($ruleTable, $column, $def);
|
||
}
|
||
|
||
// 流水表:溯源与去重列
|
||
foreach ([
|
||
'action' => "varchar(50) NOT NULL DEFAULT '' COMMENT '触发动作标识'",
|
||
'source' => "varchar(50) NOT NULL DEFAULT '' COMMENT '来源模块/插件'",
|
||
'source_id' => "varchar(64) NOT NULL DEFAULT '' COMMENT '来源业务ID(去重与追踪)'",
|
||
] as $column => $def) {
|
||
BaseModel::ensureColumn($logTable, $column, $def);
|
||
}
|
||
|
||
self::ensureIndex($ruleTable, 'idx_addon', '`addon`');
|
||
self::ensureIndex($logTable, 'idx_rule', '`rule_id`');
|
||
self::ensureIndex($logTable, 'idx_dedup', '`action`,`uid`,`source_id`');
|
||
}
|
||
|
||
/**
|
||
* 列改名(幂等):仅当旧列存在且新列不存在时执行 CHANGE
|
||
*/
|
||
protected static function renameColumnIfNeeded(string $table, string $from, string $to, string $def): void
|
||
{
|
||
try {
|
||
$hasFrom = Db::query(
|
||
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
|
||
[$table, $from]
|
||
);
|
||
if (empty($hasFrom)) {
|
||
return;
|
||
}
|
||
$hasTo = Db::query(
|
||
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
|
||
[$table, $to]
|
||
);
|
||
if (! empty($hasTo)) {
|
||
return;
|
||
}
|
||
Db::execute("ALTER TABLE `{$table}` CHANGE COLUMN `{$from}` `{$to}` {$def}");
|
||
} catch (\Throwable $e) {
|
||
try {
|
||
\think\facade\Log::error("[MebmberScore] rename {$table}.{$from} -> {$to} failed: " . $e->getMessage());
|
||
} catch (\Throwable $e2) {
|
||
// 日志失败则忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 索引不存在则追加(幂等)
|
||
*/
|
||
protected static function ensureIndex(string $table, string $index, string $columns): void
|
||
{
|
||
try {
|
||
$exists = Db::query(
|
||
"SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1",
|
||
[$table, $index]
|
||
);
|
||
if (empty($exists)) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
try {
|
||
\think\facade\Log::error("[MebmberScore] ensureIndex {$table}.{$index} failed: " . $e->getMessage());
|
||
} catch (\Throwable $e2) {
|
||
// 日志失败则忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 积分变动(带流水记录,事务安全)
|
||
* @param int $uid 用户ID
|
||
* @param int $type 1=收入 2=支出
|
||
* @param int $value 变动值(正数)
|
||
* @param string $remark 备注
|
||
* @param int $ruleId 关联规则ID(默认0)
|
||
* @param array $extra 溯源信息:action(动作标识)/ source(来源模块或插件)/ source_id(来源业务ID)
|
||
* @return array [bool, string]
|
||
*/
|
||
public static function change(int $uid, int $type, int $value, string $remark = '', int $ruleId = 0, array $extra = []): array
|
||
{
|
||
self::ensureSchema();
|
||
$uid = (int) $uid;
|
||
$value = (int) $value;
|
||
if ($uid <= 0 || $value <= 0) {
|
||
return [false, '参数错误'];
|
||
}
|
||
$type = $type === self::TYPE_SPEND ? self::TYPE_SPEND : self::TYPE_GAIN;
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$user = Db::name('member_user')->where('uid', $uid)->lock(true)->find();
|
||
if (!$user) {
|
||
throw new \Exception('用户不存在');
|
||
}
|
||
if ($type === self::TYPE_SPEND && $user['score'] < $value) {
|
||
throw new \Exception('积分余额不足');
|
||
}
|
||
$balance = $type === self::TYPE_SPEND
|
||
? $user['score'] - $value
|
||
: $user['score'] + $value;
|
||
|
||
Db::name('member_user')->where('uid', $uid)->update(['score' => $balance]);
|
||
|
||
Db::name('member_score_log')->insert([
|
||
'uid' => $uid,
|
||
'rule_id' => $ruleId,
|
||
'action' => (string) ($extra['action'] ?? ''),
|
||
'type' => $type,
|
||
'value' => $value,
|
||
'balance' => $balance,
|
||
'source' => (string) ($extra['source'] ?? ''),
|
||
'source_id' => (string) ($extra['source_id'] ?? ''),
|
||
'remark' => $remark,
|
||
'create_at' => time(),
|
||
]);
|
||
|
||
Db::commit();
|
||
return [true, 'ok'];
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
return [false, $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 积分流水列表(后台查看)
|
||
*/
|
||
public static function logList(int $uid = 0, int $page = 1, int $limit = 15, array $filter = []): array
|
||
{
|
||
self::ensureSchema();
|
||
$query = Db::name('member_score_log');
|
||
if ($uid > 0) {
|
||
$query->where('uid', $uid);
|
||
}
|
||
if (! empty($filter['action'])) {
|
||
$query->where('action', (string) $filter['action']);
|
||
}
|
||
if (! empty($filter['source'])) {
|
||
$query->where('source', (string) $filter['source']);
|
||
}
|
||
if (! empty($filter['rule_id'])) {
|
||
$query->where('rule_id', (int) $filter['rule_id']);
|
||
}
|
||
$count = $query->count();
|
||
$list = $query->order('id', 'desc')
|
||
->page($page, $limit)
|
||
->select()
|
||
->toArray();
|
||
return ['count' => $count, 'list' => $list];
|
||
}
|
||
}
|