* @Date: 2026-08-07 22:53:48 * @LastEditors: YwxApp * @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]; } }