Files
YwxAppThink/ywxapp/model/MebmberScore.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

239 lines
8.7 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
/*
* @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];
}
}