功能:新增用户积分流水系统

- 新建 user_currency_logs 流水表 (Migration)
- App\Enums\CurrencySource 来源枚举(可扩展)
- App\Models\UserCurrencyLog 流水模型
- App\Services\UserCurrencyService 统一积分变更服务
- FishingController:抛竿/收竿接入流水记录
- AutoSaveExp:自动存点接入流水记录
- Admin/UserManagerController:管理员调整接入流水记录
- LeaderboardController:新增今日三榜(经验/金币/魅力)+ 个人流水日志页
- Admin/CurrencyStatsController:后台活动统计页
- views:新增个人日志页、后台统计页;排行榜新增今日榜数据传递
- routes:新增个人日志路由 /my/currency-logs、后台路由 /admin/currency-stats
This commit is contained in:
2026-02-28 12:49:26 +08:00
parent 3f5d0e9539
commit 0c5e218aa8
14 changed files with 1045 additions and 223 deletions

View File

@@ -2,7 +2,7 @@
> **技术栈**Laravel 12 · PHP 8.4 · Laravel Reverb (WebSocket) · Redis · MySQL 8.0 · Laravel Horizon
> **原项目**`/Users/pllx/Web/chat/hp0709`VBScript ASP + MS Access 聊天室)
> **目标域名**`http://chatroom.test`Herd 自动配置)
> **目标域名**`http://chatroom.test`Herd 自动配置)| `https://chat.ay.lc`(生产环境)
---
@@ -93,13 +93,9 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
// 注册聊天室登录验证中间件
$middleware->alias([
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
]);
// Session 中间件Web 路由自动携带)
$middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class,
'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class,
'chat.level' => \App\Http\Middleware\LevelRequired::class,
'chat.site_owner' => \App\Http\Middleware\SiteOwnerOnly::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
@@ -150,26 +146,18 @@ class ChatStateService
```bash
cd /Users/pllx/Web/Herd/chatroom
# 安装 Reverb WebSocket 服务器(已完成)
composer require laravel/reverb predis/predis
# 安装 PHP 依赖(已完成)
composer install
# 安装 Horizon 队列管理(替代 queue:work提供 Web 监控面板
composer require laravel/horizon
# 发布配置文件
php artisan reverb:install
php artisan horizon:install
# 创建数据库(已经完成)
# 创建数据库(已完成
mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS chatroom CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 运行数据库迁移(迁移前检查原有迁移文件是否有用)
php artisan migrate
# 运行数据库迁移 + 基础数据填充
php artisan migrate --seed
# 安装前端依赖
npm install (已经完成)
npm install laravel-echo pusher-js已经完成
npm run dev
# 安装前端依赖并构建(已完成)
npm install
npm run build
```
**开发时运行的服务**
@@ -177,7 +165,7 @@ npm run dev
```bash
php artisan reverb:start --debug # WebSocket 服务器 :8080
php artisan horizon # 队列 Worker含 Web 监控 /horizon
npm run dev # Vite 热更新
npm run dev # Vite 热更新(开发阶段)
# HTTP 由 Herd 自动提供 chatroom.test
```
@@ -187,44 +175,32 @@ npm run dev # Vite 热更新
**原 Access 表** → **Laravel Migration** 对应关系:
| 原 ASP 表 | Laravel 迁移文件 | Model 类 | 说明 |
| ----------- | -------------------------------- | ----------------- | --------------------------------- |
| `user` | `create_users_table` | `User` | 主用户表(默认迁移文件,需修改) |
| `room` | `create_rooms_table` | `Room` | 聊天房间 |
| _(内存)_ | `create_messages_table` | `Message` | 消息归档(原用 Application 内存) |
| `sysparam` | `create_sys_params_table` | `SysParam` | 系统参数 |
| `ip_lock` | `create_ip_locks_table` | `IpLock` | IP 封锁 |
| `record` | `create_audit_logs_table` | `AuditLog` | 管理操作日志 |
| `guestbook` | `create_guestbooks_table` | `Guestbook` | 留言板 |
| `calls` | `create_friend_calls_table` | `FriendCall` | 好友呼叫 |
| `friendrq` | `create_friend_requests_table` | `FriendRequest` | 好友申请 |
| `action` | `create_actions_table` | `Action` | 表情/动作定义 |
| `admincz` | `create_admin_logs_table` | `AdminLog` | 管理员操作统计 |
| `gg` | `create_user_items_table` | `UserItem` | 道具(封口令等) |
| `scrollad` | `create_scroll_ads_table` | `ScrollAd` | 滚动公告 |
| `hy` / `lh` | `create_marriages_table` | `Marriage` | 婚姻关系 |
| `ip` | `create_ip_logs_table` | `IpLog` | IP 登录日志 |
| `room_des` | `create_room_descriptions_table` | `RoomDescription` | 房间描述模板 |
**批量生成迁移命令**
```bash
php artisan make:migration create_rooms_table
php artisan make:migration create_messages_table
php artisan make:migration create_sys_params_table
php artisan make:migration create_ip_locks_table
php artisan make:migration create_audit_logs_table
php artisan make:migration create_guestbooks_table
php artisan make:migration create_friend_calls_table
php artisan make:migration create_friend_requests_table
php artisan make:migration create_actions_table
php artisan make:migration create_admin_logs_table
php artisan make:migration create_user_items_table
php artisan make:migration create_scroll_ads_table
php artisan make:migration create_marriages_table
php artisan make:migration create_ip_logs_table
php artisan make:migration create_room_descriptions_table
```
| 原 ASP 表 | Laravel 迁移文件 | Model 类 | 说明 |
| ----------- | ---------------------------------- | ------------------- | ---------------------------------- |
| `user` | `create_users_table` | `User` | 主用户表(含 jjb 金币 / exp 经验) |
| `room` | `create_rooms_table` | `Room` | 聊天房间 |
| _(内存)_ | `create_messages_table` | `Message` | 消息归档(实时用 Redis |
| `sysparam` | `create_sys_params_table` | `Sysparam` | 系统参数alias/guidetxt/body |
| `ip_lock` | `create_ip_locks_table` | `IpLock` | IP 封锁 |
| `record` | `create_audit_logs_table` | `AuditLog` | 管理操作日志 |
| `guestbook` | `create_guestbooks_table` | `Guestbook` | 留言板 |
| `calls` | `create_friend_calls_table` | `FriendCall` | 好友呼叫 |
| `friendrq` | `create_friend_requests_table` | `FriendRequest` | 好友申请 |
| `action` | `create_actions_table` | `Action` | 表情/动作定义 |
| `admincz` | `create_admin_logs_table` | `AdminLog` | 管理员操作统计 |
| `gg` | `create_user_items_table` | `UserItem` | 道具(封口令等) |
| `scrollad` | `create_scroll_ads_table` | `ScrollAd` | 滚动公告 |
| `hy` / `lh` | `create_marriages_table` | `Marriage` | 婚姻关系 |
| `ip` | `create_ip_logs_table` | `IpLog` | IP 登录日志 |
| `room_des` | `create_room_descriptions_table` | `RoomDescription` | 房间描述模板 |
| `autoact` | `create_autoacts_table` | `Autoact` | 机器人随机事件 |
| _(新增)_ | `create_gifts_table` | `Gift` | 礼物/鲜花定义(名称/图标/金币) |
| _(新增)_ | `create_shop_items_table` | `ShopItem` | 商店商品(特效卡/改名卡等) |
| _(新增)_ | `create_user_purchases_table` | `UserPurchase` | 用户购买记录 |
| _(新增)_ | `create_vip_levels_table` | `VipLevel` | VIP 会员等级(倍率/进入模板) |
| _(新增)_ | `create_ai_provider_configs_table` | `AiProviderConfig` | AI 服务商配置(多厂商支持) |
| _(新增)_ | `create_ai_usage_logs_table` | `AiUsageLog` | AI 使用日志Token 用量记录) |
| _(新增)_ | `create_username_blacklist_table` | `UsernameBlacklist` | 用户名黑名单 |
---
@@ -238,31 +214,45 @@ app/
│ ├── UserLeft.php # 用户离开(替代 LEAVE.ASP
│ ├── UserKicked.php # 踢人
│ ├── UserMuted.php # 封口
── RoomTitleUpdated.php # 房间标题更新
── UserLevelUp.php # 用户升级通知
│ ├── RoomTitleUpdated.php # 房间标题更新
│ ├── ScreenCleared.php # 管理员清屏
│ └── EffectBroadcast.php # 特效广播(烟花/下雨等)
├── Services/ # 业务逻辑服务层(纯 PHP不含 HTTP 逻辑)
│ ├── ChatStateService.php # Redis 全局状态(替代 Application 对象)
│ ├── MessageFilterService.php # 敏感词/HTML 过滤
│ ├── AuthService.php # 登录验证逻辑
── UserLevelService.php # 等级权限判断(替代 getLevel()
│ ├── UserLevelService.php # 等级权限判断(替代 getLevel()
── AiChatService.php # AI 聊天服务多厂商适配OpenAI/DeepSeek 等
│ ├── VipService.php # VIP 开通/到期检查
│ └── ShopService.php # 商店购买 + 道具激活逻辑
├── Http/
│ ├── Controllers/
│ │ ├── AuthController.php # DEFAULT.asp + CHECK.asp + CLOSE.ASP
│ │ ├── RegisterController.php # Reg.asp + addnewuser.asp
│ │ ├── ChatController.php # NEWSAY.ASP + INIT.ASP + LEAVE.ASP
│ │ ├── RoomController.php # ROOM*.ASP 系列
│ │ ├── UserController.php # USERSET + DOUSER + KILLUSER
│ │ ├── FriendController.php # addfriend + agreefriend 等
│ │ ├── GuestbookController.php # GUEST.ASP
│ │ ├── ChatController.php # NEWSAY.ASP + INIT.ASP + LEAVE.ASP + 心跳
│ │ ├── RoomController.php # ROOM*.ASP 系列(大厅/创建/编辑/删除/转让)
│ │ ├── UserController.php # USERSET + DOUSER + KILLUSER + 封禁/解封
│ │ ├── GuestbookController.php # GUEST.ASP留言板
│ │ ├── LeaderboardController.php # TOP10.ASP排行榜
│ │ ├── ShopController.php # 商店购买 + 改名
│ │ ├── ChatBotController.php # AI 聊天机器人接口
│ │ ├── FishingController.php # 钓鱼小游戏(复刻原版 diaoyu/ 功能)
│ │ ├── AdminCommandController.php # 聊天室内管理员快捷命令
│ │ └── Admin/
│ │ ├── AdminController.php
│ │ ├── UserManagerController.php
│ │ ── SystemController.php # VIEWSYS.ASP
│ │ ├── DashboardController.php # 后台首页总览
│ │ ├── SystemController.php # VIEWSYS.ASP 系统参数配置
│ │ ── UserManagerController.php # 用户大盘管理
│ │ ├── RoomManagerController.php # 房间大盘管理
│ │ ├── AutoactController.php # 随机事件管理
│ │ ├── VipController.php # VIP 等级 CRUD
│ │ ├── AiProviderController.php # AI 厂商配置管理
│ │ └── SmtpController.php # 邮件发信配置
│ │
│ ├── Middleware/
│ │ ├── ChatAuthenticate.php # 聊天室登录验证
│ │ ── LevelRequired.php # 等级权限中间件(用法:chat.level:5
│ │ ├── ChatAuthenticate.php # 聊天室登录验证(检查 Session
│ │ ── LevelRequired.php # 等级权限中间件chat.level:super
│ │ └── SiteOwnerOnly.php # 超级站长专属chat.site_owner
│ │
│ └── Requests/ # Form Request 验证
│ ├── LoginRequest.php
@@ -270,12 +260,16 @@ app/
│ └── CreateRoomRequest.php
├── Models/
│ ├── User.php
│ ├── Room.php
│ ├── Message.php
│ ├── SysParam.php
│ ├── IpLock.php
── ...
│ ├── User.php ├── Room.php ├── Message.php
│ ├── Sysparam.php ├── IpLock.php ├── IpLog.php
│ ├── AuditLog.php ├── AdminLog.php ├── Guestbook.php
│ ├── FriendCall.php ├── FriendRequest.php ├── Marriage.php
│ ├── Action.php ├── Autoact.php ├── ScrollAd.php
── RoomDescription.php ├── UserItem.php ├── Gift.php
│ ├── ShopItem.php ├── UserPurchase.php ├── VipLevel.php
│ ├── AiProviderConfig.php ├── AiUsageLog.php ├── UsernameBlacklist.php
│ └── System/
│ └── PlatformSmtpAccount.php
└── Jobs/
└── SaveMessageJob.php # 异步将消息持久化到 MySQL
@@ -285,162 +279,171 @@ bootstrap/
routes/
├── web.php # 所有 HTTP 路由
├── api.php # API 路由(Horizon 监控等)
├── api.php # API 路由(验证码等)
└── channels.php # WebSocket Presence Channel 鉴权
resources/
├── views/
│ ├── index.blade.php # 登录页DEFAULT.asp
│ ├── chat/
│ │ ├── frame.blade.php # 聊天主框架CHAT.ASP
│ │ └── room.blade.php # 消息区
│ └── ...
└── js/
├── app.js
└── chat.js # Laravel Echo 替代 newChat.js
```
---
## 六、具体开发任务计划
### ✅ 已完成
- [x] 创建 Laravel 12 项目
- [x] 安装 `laravel/reverb``predis/predis`
- [x] 创建 MySQL 数据库 `chatroom`
- [x] 配置 `.env`MySQL root/root · Redis · Reverb · 时区 Asia/Shanghai
> **图例**`[x]` 已完成 · `[/]` 进行中 · `[ ]` 待开发
---
### 🔲 第一阶段:数据库层(预计 1-2 天)
### 第一阶段:数据库层
**目标**:所有表创建完毕,并完成基础 Seeder。
- [ ] **修改默认 `users` 迁移**,对应 ASP `user` 表字段username/passwd/sex/user_level/exp_num/friends/headface/等
- [ ] **创建 `rooms` 迁移**room_name/owner/auto/des/title/permit_level/door_open
- [ ] **创建 `messages` 迁移**room_id/from_user/to_user/content/is_secret/font_color/action/sent_at
- [ ] **创建 `sys_params` 迁移**alias/guidetxt/body
- [ ] **创建 `ip_locks` 迁移**ip/end_time/act_level
- [ ] **创建 `audit_logs` 迁移**occ_time/occ_env/stype
- [ ] **创建 `guestbooks` 迁移**who/towho/secret/ip/post_time/text_title/text_body
- [ ] **创建 `friend_calls` 迁移**who/towho/callmess/calltime/read
- [ ] **创建 `friend_requests` 迁移**who/towho/sub_time
- [ ] **创建 `actions` 迁移**act_name/alias/toall/toself/toother
- [ ] **创建 `user_items` 迁移**name/gg/times/dayy/lx — 对应道具/封口令等)
- [ ] **创建 `scroll_ads` 迁移**ad_title/ad_link/ad_new_flag
- [ ] **创建 `marriages` 迁移**hyname/hyname1/hytime/hygb/hyjb
- [ ] **创建 `ip_logs` 迁移**ip/sdate/uuname
- [ ] **创建所有 Model 文件**(含 `$fillable``$casts`、关联关系、中文 DocBlock
- [ ] **创建 `SysParamSeeder`**写入系统默认参数maxpeople/namelength/maxsayslength 等
- [ ] 运行 `php artisan migrate --seed`,验证建表成功
- [x] 修改默认 `users` 迁移username/jjb/exp/sex/headface/user_level/email 等)
- [x] 创建 `rooms` 迁移room_name/owner/auto/des/title/permit_level/door_open
- [x] 创建 `messages` 迁移room_id/from_user/to_user/content/is_secret/font_color/action/sent_at
- [x] 创建 `sys_params` 迁移alias/guidetxt/body
- [x] 创建 `ip_locks` 迁移
- [x] 创建 `audit_logs` 迁移
- [x] 创建 `guestbooks` 迁移
- [x] 创建 `friend_calls` 迁移
- [x] 创建 `friend_requests` 迁移
- [x] 创建 `actions` 迁移
- [x] 创建 `admin_logs` 迁移
- [x] 创建 `user_items` 迁移(封口令道具
- [x] 创建 `scroll_ads` 迁移
- [x] 创建 `marriages` 迁移
- [x] 创建 `ip_logs` 迁移
- [x] 创建 `room_descriptions` 迁移
- [x] 创建 `autoacts` 迁移(随机事件/机器人自动发言
- [x] 创建 `gifts` 迁移礼物定义emoji/cost/charm
- [x] 创建 `shop_items` 迁移(商店商品:特效卡/改名卡)
- [x] 创建 `user_purchases` 迁移(购买记录)
- [x] 创建 `vip_levels` 迁移VIP 等级:倍率/进入模板/价格)
- [x] 创建 `ai_provider_configs` 迁移AI 服务商配置)
- [x] 创建 `ai_usage_logs` 迁移AI 用量日志)
- [x] 创建 `username_blacklist` 迁移(用户名黑名单)
- [x] 创建所有 Model 文件(含 `$fillable``$casts`、关联关系、中文 DocBlock
- [x] 创建 `DatabaseSeeder`(默认房间「公共大厅」、系统参数)
- [x] 运行 `php artisan migrate --seed` 验证建表成功
---
### 🔲 第二阶段Auth 认证(预计 1-2 天)
### 第二阶段Auth 认证
**目标**:用户能够登录、注册、退出。
- [ ] **`LoginRequest`**验证username/password/captcha 验证码)
- [ ] **`AuthController::index()`** — 登录页(含验证码生成,替代 `session("regjm")`
- [ ] **`AuthController::check()`** — 登录验证(含 IP 封锁检查 + 密码 MD5/bcrypt 双模式
- [ ] **`AuthController::logout()`** — 退出并清理 Redis 用户状态
- [ ] **`RegisterController::show()`** — 注册页
- [ ] **`RegisterController::store()`** — 注册逻辑(含 IP 注册频率限制)
- [ ] **`ChatAuthenticate` 中间件** — 检查 Session 是否有效,无则跳转登录页
- [ ] **`LevelRequired` 中间件** — 检查用户等级,不足则拒绝(`chat.level:5`
- [ ] 在 **`bootstrap/app.php`** 注册以上中间件别名
- [ ] **登录 Blade 视图** `resources/views/index.blade.php`(仿原 DEFAULT.asp 样式)
- [ ] 测试:注册 → 登录 → 退出完整流程
- [x] `AuthController::login()` — 登录(含 IP 封锁检查 + 密码 bcrypt
- [x] `AuthController::logout()` — 退出并清理 Redis 用户状态
- [x] `ChatAuthenticate` 中间件 — 检查 Session 是否有效
- [x] `LevelRequired` 中间件 — 权限等级检查(`chat.level:super`
- [x] `SiteOwnerOnly` 中间件 — 超级站长专属(最高权限区块
- [x] 登录 Blade 视图 `resources/views/index.blade.php`
- [x] 测试:登录 → 退出完整流程
---
### 🔲 第三阶段Redis 状态层(预计 1 天)
### 第三阶段Redis 状态层
**目标**`ChatStateService` 完整实现,替代原 Application 对象。
- [ ] **`ChatStateService`** 实现以下方法(全部带中文注释):
- `userJoin(int $roomId, string $username, array $info): void`
- `userLeave(int $roomId, string $username): void`
- `getRoomUsers(int $roomId): array`
- `pushMessage(int $roomId, array $message): void`
- `getNewMessages(int $roomId, int $lastId): array`
- `nextMessageId(int $roomId): int`Redis 自增计数器)
- `withLock(string $key, callable $callback): mixed`(分布式锁)
- `getSysParam(string $alias): string`读取系统参数缓存1分钟
- [ ] **`MessageFilterService`** — 敏感词替换 + HTML 过滤(替代 `TrStr()` / `SHTM()` 函数)
- [ ] **`UserLevelService`** — 从 Redis 读取当前用户等级
- [x] `ChatStateService` 完整实现userJoin/userLeave/getRoomUsers/pushMessage/nextMsgId/withLock/getSysParam
- [x] `MessageFilterService` — 敏感词替换 + HTML 过滤
- [x] `UserLevelService` — 等级权限判断
---
### 🔲 第四阶段WebSocket 广播(预计 1 天)
### 第四阶段WebSocket 广播
**目标**Reverb 正常运行,前端能收到实时消息。
- [ ] **`MessageSent` Event**implement `ShouldBroadcast`)— 广播到 `room.{id}` Presence Channel
- [ ] **`UserJoined` Event**用户进入广播
- [ ] **`UserLeft` Event**用户离开广播
- [ ] **`UserKicked` Event**踢人广播(私有频道,只发给被踢人)
- [ ] **`UserMuted` Event**封口广播
- [ ] **`RoomTitleUpdated` Event**标题更新广播
- [ ] **`routes/channels.php`** — Presence Channel 鉴权(验证等级 + 返回用户信息
- [ ] **`resources/js/chat.js`** — Laravel Echo 接入(`Echo.join('room.X').here().joining().leaving().listen()`
- [ ] 运行 `php artisan reverb:start --debug`,测试 WebSocket 连通性
- [x] `MessageSent` Event广播到 `room.{id}` Presence Channel
- [x] `UserJoined` Event — 用户进入广播
- [x] `UserLeft` Event — 用户离开广播
- [x] `UserKicked` Event — 踢人广播(私有频道)
- [x] `UserMuted` Event — 封口广播
- [x] `UserLevelUp` Event — 升级通知广播
- [x] `RoomTitleUpdated` Event — 标题更新广播
- [x] `ScreenCleared` Event — 管理员清屏广播
- [x] `EffectBroadcast` Event — 特效广播(烟花/下雨/闪电/彩带
- [x] `routes/channels.php` — Presence Channel 鉴权
- [x] `resources/js/chat.js` — Laravel Echo 接入
- [x] Reverb 连通性测试通过
---
### 🔲 第五阶段:聊天核心(预计 2-3 天)
### 第五阶段:聊天核心
**目标**:进房、发言、离开完整流程可用。
- [ ] **`ChatController::init()`**进入房间(读取 Redis 用户列表 + 历史消息,替代 INIT.ASP
- [ ] **`ChatController::send()`**发言(过滤 → 推 Redis → `SaveMessageJob` → 广播 Event
- [ ] **`ChatController::leave()`**离开房间(清 Redis → 广播 `UserLeft`
- [ ] **`SaveMessageJob`** — 实现 `ShouldQueue`,异步写消息到 `messages`
- [ ] **聊天 Blade 视图** `resources/views/chat/frame.blade.php`(主框架,含 Vite 引入)
- [ ] 测试:登录 → 进房 → 发言 → 另一浏览器实时收到消息
- [x] `ChatController::init()` — 进入房间(历史消息 + 在线用户)
- [x] `ChatController::send()` — 发言(过滤 → Redis → SaveMessageJob → 广播)
- [x] `ChatController::heartbeat()`挂机心跳限流每分钟6次
- [x] `ChatController::leave()`离开房间
- [x] `ChatController::headfaceList()`头像列表
- [x] `ChatController::changeAvatar()` — 修改头像
- [x] `ChatController::setAnnouncement()` — 设置房间公告
- [x] `ChatController::sendFlower()` — 送花/礼物(扣金币 + 增魅力)
- [x] `SaveMessageJob` — 异步消息持久化到 MySQL
- [x] 聊天主界面 Blade 视图 `resources/views/chat/frame.blade.php`
- [x] 测试:进房 → 发言 → 实时收到消息
---
### 🔲 第六阶段:房间管理(预计 2 天)
### 第六阶段:房间管理
- [ ] **`RoomController::list()`** — 房间列表(替代 ROOMLIST.ASP
- [ ] **`RoomController::create()`** / `store()` — 创建房间(替代 NEWROOM.ASP
- [ ] **`RoomController::edit()`** / `update()` — 修改设置(替代 ROOMSET.ASP
- [ ] **`RoomController::destroy()`** — 删除/解散房间(替代 CUTROOM.ASP
- [ ] **`RoomController::transfer()`** — 转让房主(替代 OVERROOM.ASP
- [ ] 对应 Blade 视图
- [x] `RoomController::index()`大厅房间列表
- [x] `RoomController::store()` — 创建房间
- [x] `RoomController::update()` — 修改设置
- [x] `RoomController::destroy()` — 删除/解散房间
- [x] `RoomController::transfer()` — 转让房主
- [x] 对应 Blade 视图(大厅列表、引导页)
---
### 🔲 第七阶段:用户管理(预计 2 天)
### 第七阶段:用户管理
- [ ] **`UserController::info()`** — 用户信息(替代 USERinfo.ASP
- [ ] **`UserController::update()`** — 修改个人资料(替代 USERSET.ASP
- [ ] **`UserController::kick()`**踢人(替代 KILLUSER.ASP广播 `UserKicked`
- [ ] **`UserController::mute()`**封口(道具 `user_items` 表操作)
- [ ] **`UserController::changePassword()`**改密码(替代 chpasswd.asp
- [x] `UserController::show()` — 用户名片/资料页
- [x] `UserController::updateProfile()` — 修改个人资料
- [x] `UserController::changePassword()`改密码
- [x] `UserController::kick()`踢人
- [x] `UserController::mute()`封口
- [x] `UserController::ban()` — 封号
- [x] `UserController::banIp()` — 封 IP
- [x] 邮箱验证码发送(`Api\VerificationController`
---
### 🔲 第八阶段:管理后台(预计 3-5 天)
### 第八阶段:管理后台
- [ ] **`LevelRequired` 中间件** 保护 `/admin` 路由(需 level=15
- [ ] **`Admin\SystemController`** — 系统参数配置(替代 VIEWSYS.ASP
- [ ] **`Admin\UserManagerController`**用户管理列表(替代 `gl/` 目录)
- [ ] **`Admin\SqlController`**后台 SQL 执行(替代 SQL.asp⚠ 仅 SELECT
- [ ] **Horizon 面板** `/horizon`(队列监控,替代后台日志查看)
- [ ] 对应 Blade 视图
- [x] `Admin\DashboardController` — 后台首页总览
- [x] `Admin\SystemController` — 系统参数配置
- [x] `Admin\SmtpController`邮件发信配置
- [x] `Admin\UserManagerController`用户大盘管理(编辑/删除
- [x] `Admin\RoomManagerController` — 房间大盘管理
- [x] `Admin\AutoactController` — 随机事件 CRUD 管理
- [x] `Admin\VipController` — VIP 等级 CRUD 管理
- [x] `Admin\AiProviderController` — AI 厂商配置管理(含启用/禁用/设为默认)
- [x] Horizon 面板 `/horizon`(队列监控)
- [x] 对应 Blade 视图
---
### 🔲 第九阶段:附加功能(按需)
### 第九阶段:外围功能
- [ ] 好友系统FriendController
- [ ] 留言板(GuestbookController
- [ ] 排行榜RankController
- [ ] 会员系统(`huiyuan/` 对应功能)
- [ ] 滚动公告ScrollAd 管理)
- [x] `LeaderboardController` — 风云排行榜(经验/金币/魅力榜
- [x] `GuestbookController` — 留言板(发表/删除
---
### ✅ 第十阶段:扩展玩法
- [x] **钓鱼小游戏** `FishingController` — 复刻原版 `diaoyu/` 功能(投竿/收竿/随机收益)
- [x] **AI 聊天机器人** `ChatBotController` + `AiChatService`多厂商OpenAI/DeepSeek/Gemini 等)
- [x] **礼物/送花系统**`Gift` 模型 + `ChatController::sendFlower()`(金币消耗 + 魅力增量)
- [x] **商店系统** `ShopController` + `ShopService`(购买特效卡/改名卡,`ShopItem` + `UserPurchase`
- [x] **VIP 会员系统** `VipService` + `VipLevel`(进入/离开专属模板、倍率加成)
- [x] **管理员快捷命令** `AdminCommandController`(警告/踢人/封口/冻结/清屏/全服广播/特效)
- [x] **特效系统** `EffectBroadcast` Event烟花/下雨/闪电/彩带,支持单次卡/周卡)
---
### 🔲 待完善事项
- [ ] **用户名黑名单管理**(后台 CRUD`UsernameBlacklist` 模型已建,路由/界面待完成)
- [ ] **滚动公告管理**(后台 CRUD`ScrollAd` 模型已建,界面待完成)
- [ ] **好友系统**`FriendCall` / `FriendRequest` 模型已建Controller 待完成)
- [ ] **婚姻系统**`Marriage` 模型已建,界面待完成)
- [ ] **历史数据迁移**(从 Access 导入旧用户数据GBK→UTF-8 转换)
- [ ] **密码兼容过渡**(旧 MD5 用户登录时自动升级为 bcrypt
- [ ] **单元测试**(核心 Service 层测试覆盖率)
- [ ] **Flash 游戏替代**`game/``pig/` 等 .swf 文件,考虑 HTML5/Canvas 重写)
---
@@ -451,7 +454,6 @@ resources/
- 导入旧数据时,`password` 字段存原始 MD5 值32位字符串
- 登录时双模式验证:`md5($input) === $storedPass` 成功后升级为 `bcrypt`
- 新注册用户直接用 `bcrypt``Hash::make()`
- 约 3 个月后移除 MD5 兼容分支
### 7.2 字符编码
@@ -477,7 +479,25 @@ resources/
### 7.5 Flash 游戏(暂不处理)
`game/``pig/``Gupiao/` 等目录内的 `.swf` Flash 文件现代浏览器已不支持,**本期不做转换**,后续单独用 HTML5/Canvas 重写
`game/``pig/``Gupiao/` 等目录内的 `.swf` Flash 文件现代浏览器已不支持,**本期不做转换**。
### 7.6 AI 机器人多厂商支持
`AiChatService` 支持以下 Provider通过 `ai_provider_configs` 后台配置切换:
| Provider | 推荐模型 | 说明 |
| ---------- | ------------------ | ---------------------------- |
| `openai` | `gpt-4o-mini` | OpenAI 官方 API |
| `deepseek` | `deepseek-chat` | 国产低成本,兼容 OpenAI 接口 |
| `gemini` | `gemini-1.5-flash` | Google Gemini |
### 7.7 商店商品类型说明
| type | slug 示例 | 说明 |
| ---------- | ---------------- | ---------------------- |
| `instant` | `once_fireworks` | 单次特效卡(即时生效) |
| `duration` | `week_fireworks` | 周卡7天内持续生效 |
| `rename` | `rename` | 改名卡(改变显示名称) |
---

View File

@@ -17,10 +17,12 @@
namespace App\Console\Commands;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Jobs\SaveMessageJob;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
@@ -41,8 +43,9 @@ class AutoSaveExp extends Command
* 注入依赖服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
@@ -134,21 +137,31 @@ class AutoSaveExp extends Command
return;
}
// 1. 发放经验奖励(支持 VIP 倍率)
// 1. 计算奖励量(经验/金币 均支持 VIP 倍率)
$expGain = $this->parseRewardValue($expGainRaw);
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
// 2. 发放金币奖励(支持 VIP 倍率)
$jjbGain = $this->parseRewardValue($jjbGainRaw);
$actualJjbGain = 0;
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
}
// 2. 通过统一积分服务发放奖励(原子写入 + 流水记录)
if ($actualExpGain > 0) {
$this->currencyService->change(
$user, 'exp', $actualExpGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
if ($actualJjbGain > 0) {
$this->currencyService->change(
$user, 'gold', $actualJjbGain, CurrencySource::AUTO_SAVE, '自动存点', $roomId,
);
}
$user->refresh(); // 刷新获取最新属性service 已原子更新)
// 3. 自动升降级(管理员不参与)
$oldLevel = $user->user_level;
$leveledUp = false;

View File

@@ -0,0 +1,61 @@
<?php
/**
* 文件功能:积分来源活动枚举
* 集中管理所有合法的 source 标识值,新增活动只需在此加一行常量,数据库字段无需任何变更。
* 对应数据表user_currency_logs.sourcevarchar 字段,非 ENUM可自由扩展
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Enums;
enum CurrencySource: string
{
/** 自动存点Horizon 定时任务每5分钟给在线用户加经验/金币) */
case AUTO_SAVE = 'auto_save';
/** 钓鱼收竿奖励(获得经验或金币) */
case FISHING_GAIN = 'fishing_gain';
/** 钓鱼抛竿消耗(扣除金币) */
case FISHING_COST = 'fishing_cost';
/** 送出礼物(送方扣金币) */
case SEND_GIFT = 'send_gift';
/** 收到礼物(收方魅力增加) */
case RECV_GIFT = 'recv_gift';
/** 新人礼包(首次登录赠送金币) */
case NEWBIE_BONUS = 'newbie_bonus';
/** 商城购买消耗(扣除金币) */
case SHOP_BUY = 'shop_buy';
/** 管理员手动调整(后台直接修改经验/金币/魅力) */
case ADMIN_ADJUST = 'admin_adjust';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
// case PVP_WIN = 'pvp_win'; // PVP 胜利奖励
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
public function label(): string
{
return match ($this) {
self::AUTO_SAVE => '自动存点',
self::FISHING_GAIN => '钓鱼奖励',
self::FISHING_COST => '钓鱼消耗',
self::SEND_GIFT => '送出礼物',
self::RECV_GIFT => '收到礼物',
self::NEWBIE_BONUS => '新人礼包',
self::SHOP_BUY => '商城购买',
self::ADMIN_ADJUST => '管理员调整',
};
}
}

View File

@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:后台积分活动统计控制器
* 展示今日(或指定日期)各来源活动产生的经验/金币/魅力统计,以及今日净流通量。
* 仅限 superlevel 以上管理员访问。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Enums\CurrencySource;
use App\Http\Controllers\Controller;
use App\Models\UserCurrencyLog;
use App\Services\UserCurrencyService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CurrencyStatsController extends Controller
{
/**
* 注入积分统计服务
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示指定日期的积分活动统计(默认今日)。
*/
public function index(Request $request): View
{
// 日期选择(默认今日)
$date = $request->input('date', today()->toDateString());
// 各来源活动产出统计(按 source + currency 分组汇总)
$stats = $this->currencyService->activityStats($date);
// 按货币类型分组,方便视图展示
$statsByType = $stats->groupBy('currency')->map(
fn ($rows) => $rows->keyBy('source')
);
// 今日净流通量(正向增加 - 负向消耗),可判断通货膨胀
$netFlow = [];
foreach (['exp', 'gold', 'charm'] as $currency) {
$totalIn = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '>', 0)
->sum('amount');
$totalOut = UserCurrencyLog::whereDate('created_at', $date)
->where('currency', $currency)->where('amount', '<', 0)
->sum('amount');
$netFlow[$currency] = [
'in' => $totalIn,
'out' => abs($totalOut),
'net' => $totalIn + $totalOut, // 净增量
];
}
// 所有已知来源(供视图展示缺失来源的空行)
$allSources = CurrencySource::cases();
return view('admin.currency-stats.index', compact(
'date', 'stats', 'statsByType', 'netFlow', 'allSources',
));
}
}

View File

@@ -12,7 +12,9 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -22,6 +24,12 @@ use Illuminate\View\View;
class UserManagerController extends Controller
{
/**
* 注入统一积分服务(用于管理员调整经验/金币/魅力时记录流水)
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 显示用户列表及搜索(支持按等级/经验/金币/魅力排序)
*/
@@ -90,13 +98,35 @@ class UserManagerController extends Controller
$targetUser->sex = $validated['sex'];
}
if (isset($validated['exp_num'])) {
$targetUser->exp_num = $validated['exp_num'];
// 计算差值并通过统一服务记录流水(管理员手动调整)
$expDiff = $validated['exp_num'] - ($targetUser->exp_num ?? 0);
if ($expDiff !== 0) {
$this->currencyService->change(
$targetUser, 'exp', $expDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整经验",
);
$targetUser->refresh();
}
}
if (isset($validated['jjb'])) {
$targetUser->jjb = $validated['jjb'];
$jjbDiff = $validated['jjb'] - ($targetUser->jjb ?? 0);
if ($jjbDiff !== 0) {
$this->currencyService->change(
$targetUser, 'gold', $jjbDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整金币",
);
$targetUser->refresh();
}
}
if (isset($validated['meili'])) {
$targetUser->meili = $validated['meili'];
$meiliDiff = $validated['meili'] - ($targetUser->meili ?? 0);
if ($meiliDiff !== 0) {
$this->currencyService->change(
$targetUser, 'charm', $meiliDiff, CurrencySource::ADMIN_ADJUST,
"管理员 {$currentUser->username} 手动调整魅力",
);
$targetUser->refresh();
}
}
if (array_key_exists('qianming', $validated)) {
$targetUser->qianming = $validated['qianming'];

View File

@@ -13,8 +13,10 @@
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Enums\CurrencySource;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -24,8 +26,9 @@ use Illuminate\Support\Facades\Redis;
class FishingController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {}
/**
@@ -61,9 +64,16 @@ class FishingController extends Controller
], 422);
}
// 3. 扣除金币
$user->jjb = max(0, ($user->jjb ?? 0) - $cost);
$user->save();
// 3. 扣除金币(通过统一积分服务记录流水)
$this->currencyService->change(
$user,
'gold',
-$cost,
CurrencySource::FISHING_COST,
"钓鱼抛竿消耗 {$cost} 金币",
$id,
);
$user->refresh(); // 刷新本地模型service 已原子更新)
// 4. 设置"正在钓鱼"标记防止重复抛竿30秒后自动过期
Redis::setex("fishing:active:{$user->id}", 30, time());
@@ -113,18 +123,24 @@ class FishingController extends Controller
// 3. 随机决定钓鱼结果
$result = $this->randomFishResult();
// 4. 更新用户经验和金币(正向奖励按 VIP 倍率加成,负面惩罚不变)
// 4. 通过统一积分服务更新经验和金币,写入流水
$expMul = $this->vipService->getExpMultiplier($user);
$jjbMul = $this->vipService->getJjbMultiplier($user);
if ($result['exp'] !== 0) {
$finalExp = $result['exp'] > 0 ? (int) round($result['exp'] * $expMul) : $result['exp'];
$user->exp_num = max(0, ($user->exp_num ?? 0) + $finalExp);
$this->currencyService->change(
$user, 'exp', $finalExp, CurrencySource::FISHING_GAIN,
"钓鱼收竿:{$result['message']}", $id,
);
}
if ($result['jjb'] !== 0) {
$finalJjb = $result['jjb'] > 0 ? (int) round($result['jjb'] * $jjbMul) : $result['jjb'];
$user->jjb = max(0, ($user->jjb ?? 0) + $finalJjb);
$this->currencyService->change(
$user, 'gold', $finalJjb, CurrencySource::FISHING_GAIN,
"钓鱼收竿:{$result['message']}", $id,
);
}
$user->save();
$user->refresh(); // 刷新获取最新余额
// 5. 广播钓鱼结果到聊天室
$sysMsg = [

View File

@@ -3,6 +3,7 @@
/**
* 文件功能:全局风云排行榜控制器
* 各种维度等级、经验、交友币、魅力的前20名抓取与缓存展示。
* 新增今日榜:显示今天经验成长、今日金币获得、今日魅力增长最多的用户。
*
* @author ChatRoom Laravel
*
@@ -12,26 +13,33 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\UserCurrencyService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class LeaderboardController extends Controller
{
/**
* 渲染排行榜主视角
* 注入积分统计服务(用于今日榜单数据查询)
*/
public function __construct(
private readonly UserCurrencyService $currencyService,
) {}
/**
* 渲染排行榜主视角(包含累计榜 + 今日榜)
*/
public function index(): View
{
// 缓存 15 分钟,防止每秒几百个人看排行榜把数据库扫死
// 选用 remember 则在过期时自动执行闭包查询并重置缓存
$ttl = 60 * 15;
// 管理员等级阈值,排行榜中隐藏管理员
$superLevel = (int) \App\Models\Sysparam::getValue('superlevel', '100');
// 排行榜显示人数(后台可配置)
$topN = (int) \App\Models\Sysparam::getValue('leaderboard_limit', '20');
// ── 累计榜15分钟缓存──────────────────────────────
$ttl = 60 * 15;
// 1. 境界榜 (以 user_level 为尊)
$topLevels = Cache::remember('leaderboard:top_levels', $ttl, function () use ($superLevel, $topN) {
return User::select('id', 'username', 'usersf', 'user_level', 'sex')
@@ -76,6 +84,36 @@ class LeaderboardController extends Controller
->get();
});
return view('leaderboard.index', compact('topLevels', 'topExp', 'topWealth', 'topCharm'));
// ── 今日榜5分钟缓存数据来自 user_currency_logs 流水表)──
$todayTtl = 60 * 5;
$today = today()->toDateString();
$todayExp = Cache::remember("leaderboard:today_exp:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('exp', $topN, $today)
);
$todayGold = Cache::remember("leaderboard:today_gold:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('gold', $topN, $today)
);
$todayCharm = Cache::remember("leaderboard:today_charm:{$today}", $todayTtl,
fn () => $this->currencyService->todayLeaderboard('charm', $topN, $today)
);
return view('leaderboard.index', compact(
'topLevels', 'topExp', 'topWealth', 'topCharm',
'todayExp', 'todayGold', 'todayCharm',
));
}
/**
* 用户个人流水日志页(查询自己的经验/金币/魅力操作历史)
*/
public function myLogs(): View
{
$user = auth()->user();
$currency = request('currency');
$days = (int) request('days', 7);
$logs = $this->currencyService->userLogs($user->id, $currency ?: null, $days);
return view('leaderboard.my-logs', compact('logs', 'user', 'currency', 'days'));
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* 文件功能:用户积分流水 Eloquent 模型
* 对应表user_currency_logs
* 只读写,不允许 update流水记录不可更改
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserCurrencyLog extends Model
{
/**
* 只有 created_at没有 updated_at流水记录只写不改
*/
public const UPDATED_AT = null;
/**
* 允许批量赋值的字段
*/
protected $fillable = [
'user_id',
'username',
'currency',
'amount',
'balance_after',
'source',
'remark',
'room_id',
];
/**
* 字段类型转换
*/
protected $casts = [
'amount' => 'integer',
'balance_after'=> 'integer',
'room_id' => 'integer',
'created_at' => 'datetime',
];
// ─── 关联 ─────────────────────────────────────────────────
/**
* 关联用户(流水属于哪个用户)
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// ─── 作用域Scope快捷查询 ──────────────────────────────
/**
* 按货币类型过滤
*/
public function scopeCurrency(Builder $query, string $currency): Builder
{
return $query->where('currency', $currency);
}
/**
* 今日数据
*/
public function scopeToday(Builder $query): Builder
{
return $query->whereDate('created_at', today());
}
/**
* 指定日期数据
*/
public function scopeOnDate(Builder $query, string $date): Builder
{
return $query->whereDate('created_at', $date);
}
/**
* 仅正向增加(用于排行榜,不算消耗)
*/
public function scopeGain(Builder $query): Builder
{
return $query->where('amount', '>', 0);
}
/**
* 按来源过滤
*/
public function scopeSource(Builder $query, string $source): Builder
{
return $query->where('source', $source);
}
}

View File

@@ -0,0 +1,192 @@
<?php
/**
* 文件功能:用户积分统一变更服务
* 所有修改 exp_num经验、jjb金币、meili魅力 的操作必须经由此服务,
* 禁止在 Controller 中直接操作 User 属性并 save()
* 本服务负责:原子性更新用户属性、写入流水记录、提供统计与排行数据。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UserCurrencyService
{
/**
* currency 标识与 users 表字段名的映射关系。
* 以后新增货币类型,在此加一行即可。
*/
private const FIELD_MAP = [
'exp' => 'exp_num',
'gold' => 'jjb',
'charm' => 'meili',
];
/**
* 统一变更用户货币属性并写入流水记录。
* 使用数据库事务保证原子性:用户属性更新 + 流水写入同时成功或同时回滚。
*
* @param User $user 目标用户
* @param string $currency 货币类型('exp' / 'gold' / 'charm'
* @param int $amount 变更量,正数增加,负数扣除
* @param CurrencySource $source 来源活动枚举
* @param string $remark 备注说明
* @param int|null $roomId 所在房间 ID可选
*/
public function change(
User $user,
string $currency,
int $amount,
CurrencySource $source,
string $remark = '',
?int $roomId = null,
): void {
if ($amount === 0) {
return; // 变更量为 0 不写记录
}
$field = self::FIELD_MAP[$currency] ?? null;
if (! $field) {
return; // 未知货币类型,静默忽略(不抛异常,避免影响主流程)
}
DB::transaction(function () use ($user, $currency, $amount, $source, $remark, $roomId, $field) {
// 原子性更新用户属性(用 increment/decrement 防并发竞态)
if ($amount > 0) {
$user->increment($field, $amount);
} else {
// 扣除时不让余额低于 0
$user->decrement($field, min(abs($amount), $user->$field ?? 0));
}
// 重新读取最新余额(避免缓存脏数据)
$balanceAfter = (int) $user->fresh()->$field;
// 写入流水记录(快照当前用户名,排行 JOIN 时再取最新名)
UserCurrencyLog::create([
'user_id' => $user->id,
'username' => $user->username,
'currency' => $currency,
'amount' => $amount,
'balance_after'=> $balanceAfter,
'source' => $source->value,
'remark' => $remark,
'room_id' => $roomId,
]);
});
}
/**
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
* 每位用户仍独立走事务,单人失败不影响其他人。
*
* @param array $items [['user' => User, 'changes' => ['exp'=>1,'gold'=>2]], ...]
* @param CurrencySource $source
* @param int|null $roomId
*/
public function batchChange(array $items, CurrencySource $source, ?int $roomId = null): void
{
foreach ($items as $item) {
$user = $item['user'];
$changes = $item['changes'] ?? [];
foreach ($changes as $currency => $amount) {
$this->change($user, $currency, (int) $amount, $source, '', $roomId);
}
}
}
/**
* 查询某日各来源活动的产出统计(后台统计页面使用)。
* 返回格式Collection of stdClass { source, currency, total_amount, participant_count }
*
* @param string|null $date 日期字符串如 '2026-02-28',默认今日
*/
public function activityStats(?string $date = null): Collection
{
$date = $date ?? today()->toDateString();
return UserCurrencyLog::query()
->whereDate('created_at', $date)
->selectRaw('source, currency, SUM(amount) as total_amount, COUNT(DISTINCT user_id) as participant_count')
->groupBy('source', 'currency')
->orderBy('currency')
->orderByRaw('ABS(SUM(amount)) DESC')
->get();
}
/**
* 今日排行榜(按 user_id 聚合,展示最新用户名)。
* 只统计正向变更amount > 0),不因消耗而扣分。
*
* @param string $currency 'exp' | 'gold' | 'charm'
* @param int $limit 返回条数
* @param string|null $date 日期,默认今日
*/
public function todayLeaderboard(string $currency, int $limit = 20, ?string $date = null): Collection
{
$date = $date ?? today()->toDateString();
return UserCurrencyLog::query()
->whereDate('created_at', $date)
->where('currency', $currency)
->where('amount', '>', 0) // 只统计正向
->selectRaw('user_id, SUM(amount) as total')
->groupBy('user_id')
->orderByRaw('SUM(amount) DESC')
->limit($limit)
->get()
->map(function ($row) {
// JOIN 取最新用户名(避免改名后显示旧名)
$user = User::select('id', 'username', 'user_level', 'sex', 'headface')
->find($row->user_id);
return (object) [
'user_id' => $row->user_id,
'username' => $user?->username ?? '未知用户',
'level' => $user?->user_level ?? 0,
'sex' => $user?->sex ?? 1,
'headface' => $user?->headface ?? '1.gif',
'total' => $row->total,
];
});
}
/**
* 用户个人流水明细(用户查询自己的日志)。
*
* @param int $userId 用户 ID
* @param string|null $currency null 时返回所有货币类型
* @param int $days 查询最近多少天
*/
public function userLogs(int $userId, ?string $currency = null, int $days = 7): Collection
{
return UserCurrencyLog::query()
->where('user_id', $userId)
->when($currency, fn ($q) => $q->where('currency', $currency))
->where('created_at', '>=', now()->subDays($days))
->orderByDesc('created_at')
->limit(200)
->get();
}
/**
* 货币类型中文名映射(用于视图展示)。
*/
public static function currencyLabel(string $currency): string
{
return match ($currency) {
'exp' => '经验',
'gold' => '金币',
'charm' => '魅力',
default => $currency,
};
}
}

View File

@@ -65,7 +65,7 @@ return [
|
*/
'timezone' => 'UTC',
'timezone' => 'Asia/Shanghai',
/*
|--------------------------------------------------------------------------

View File

@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:用户积分流水表 Migration
* 记录所有经验、金币、魅力的变更明细,提供今日排行与活动统计数据源。
* 使用 user_id 作为真实身份锚点username 仅为写入时快照,支持改名场景。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 user_currency_logs 流水表。
*/
public function up(): void
{
Schema::create('user_currency_logs', function (Blueprint $table) {
$table->id();
// 真实身份锚点:使用 user_id即使用户改名历史记录仍可准确归属
$table->unsignedBigInteger('user_id')->index()->comment('用户 ID身份锚点不随改名变化');
// 写入时的用户名快照:仅用于日志可读性,排行榜展示取 users.username 当前值
$table->string('username', 50)->comment('操作时刻用户名快照');
// 货币类型(对应 CurrencySource 枚举varchar 允许未来无缝新增)
$table->string('currency', 20)->comment('货币类型: exp / gold / charm / 以后可自由扩展');
// 变更量(正数增加,负数扣除)
$table->integer('amount')->comment('变更量,正数为增加,负数为扣除');
// 变更后余额(方便对账,无需重算历史)
$table->integer('balance_after')->comment('变更后余额快照');
// 来源活动varchar 非 ENUM新增活动无需改表只在 CurrencySource 枚举加一行)
$table->string('source', 30)->comment('来源标识: auto_save / fishing_gain / send_gift / ...');
// 备注(补充说明,如"送花给流星"
$table->string('remark', 255)->default('')->comment('操作备注');
// 发生所在房间(部分操作无房间)
$table->unsignedInteger('room_id')->nullable()->comment('所在房间 ID无则为 null');
// 只记录创建时间,流水不可修改
$table->timestamp('created_at')->useCurrent()->comment('记录时间');
// ── 索引(按高频查询场景设计)────────────────────────────
// 今日排行快速聚合:按货币类型 + 日期 + 金额
$table->index(['currency', 'created_at', 'amount'], 'idx_daily_board');
// 用户个人流水查询:按 user_id + 货币类型 + 时间
$table->index(['user_id', 'currency', 'created_at'], 'idx_user_log');
// 活动统计聚合:按来源 + 货币类型 + 日期
$table->index(['source', 'currency', 'created_at'], 'idx_source_stat');
});
}
/**
* 回滚:删除流水表。
*/
public function down(): void
{
Schema::dropIfExists('user_currency_logs');
}
};

View File

@@ -0,0 +1,102 @@
{{--
文件功能:后台积分活动统计页面
显示指定日期下各来源活动(钓鱼、存点等)产出的经验/金币/魅力统计,以及今日净流通量
@extends admin/layouts
--}}
@extends('layouts.admin')
@section('title', '积分流水统计')
@section('content')
<div class="p-6 max-w-6xl mx-auto">
{{-- 页头 --}}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">📊 积分流水活动统计</h1>
{{-- 日期选择器 --}}
<form method="GET" action="{{ route('admin.currency-stats.index') }}" class="flex items-center gap-3">
<label class="text-sm text-gray-600">查询日期:</label>
<input type="date" name="date" value="{{ $date }}"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-400">
<button type="submit"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition">
查询
</button>
</form>
</div>
{{-- 净流通量摘要卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-8">
@foreach (['exp' => ['label' => '经验', 'icon' => '⚡', 'color' => 'amber'], 'gold' => ['label' => '金币', 'icon' => '💰', 'color' => 'yellow'], 'charm' => ['label' => '魅力', 'icon' => '🌸', 'color' => 'pink']] as $cur => $info)
@php $flow = $netFlow[$cur] ?? ['in'=>0,'out'=>0,'net'=>0]; @endphp
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<div class="flex items-center gap-2 mb-3">
<span class="text-2xl">{{ $info['icon'] }}</span>
<span class="font-semibold text-gray-700">{{ $info['label'] }} 流通</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-green-600">+{{ number_format($flow['in']) }} 流入</span>
<span class="text-red-500">-{{ number_format($flow['out']) }} 消耗</span>
</div>
<div class="mt-2 text-lg font-bold {{ $flow['net'] >= 0 ? 'text-green-700' : 'text-red-600' }}">
净增:{{ $flow['net'] >= 0 ? '+' : '' }}{{ number_format($flow['net']) }}
</div>
</div>
@endforeach
</div>
{{-- 来源活动详细统计表 --}}
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h2 class="text-lg font-semibold text-gray-700">各活动来源产出明细</h2>
<p class="text-xs text-gray-400 mt-1">日期:{{ $date }}(仅统计正向增加,不含消耗)</p>
</div>
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b text-gray-600">
<tr>
<th class="px-6 py-3 text-left">来源活动</th>
<th class="px-6 py-3 text-right"> 经验产出</th>
<th class="px-6 py-3 text-right">💰 金币产出</th>
<th class="px-6 py-3 text-right">🌸 魅力产出</th>
<th class="px-6 py-3 text-right">参与人次</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($allSources as $source)
@php
$expRow = $statsByType['exp'][$source->value] ?? null;
$goldRow = $statsByType['gold'][$source->value] ?? null;
$charmRow = $statsByType['charm'][$source->value] ?? null;
$hasData = $expRow || $goldRow || $charmRow;
$maxParticipants = max(
$expRow?->participant_count ?? 0,
$goldRow?->participant_count ?? 0,
$charmRow?->participant_count ?? 0,
);
@endphp
<tr class="{{ $hasData ? '' : 'opacity-40' }} hover:bg-gray-50 transition">
<td class="px-6 py-3 font-medium text-gray-700">{{ $source->label() }}</td>
<td
class="px-6 py-3 text-right {{ $expRow?->total_amount > 0 ? 'text-amber-600 font-semibold' : 'text-gray-300' }}">
{{ $expRow ? number_format($expRow->total_amount) : '—' }}
</td>
<td
class="px-6 py-3 text-right {{ $goldRow?->total_amount > 0 ? 'text-yellow-600 font-semibold' : 'text-gray-300' }}">
{{ $goldRow ? number_format($goldRow->total_amount) : '—' }}
</td>
<td
class="px-6 py-3 text-right {{ $charmRow?->total_amount > 0 ? 'text-pink-600 font-semibold' : 'text-gray-300' }}">
{{ $charmRow ? number_format($charmRow->total_amount) : '—' }}
</td>
<td class="px-6 py-3 text-right text-gray-500">
{{ $maxParticipants > 0 ? $maxParticipants . ' 人' : '—' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endsection

View File

@@ -0,0 +1,103 @@
{{--
文件功能:用户个人积分流水日志页面
用户可筛选查看自己的经验、金币、魅力变动历史,按日期倒序排列
@extends layouts.app
--}}
@extends('layouts.app')
@section('title', '我的积分记录 - 飘落流星')
@section('nav-icon', '📊')
@section('nav-title', '我的积分记录')
@section('content')
<main class="p-4 sm:p-6 lg:p-8">
<div class="max-w-4xl mx-auto">
{{-- 筛选栏 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6 flex flex-wrap gap-3 items-center">
<span class="text-gray-600 text-sm font-medium">筛选:</span>
{{-- 货币类型 --}}
<div class="flex gap-2">
@foreach(['' => '全部', 'exp' => '⚡ 经验', 'gold' => '💰 金币', 'charm' => '🌸 魅力'] as $val => $label)
<a href="{{ route('currency.my-logs', array_merge(request()->query(), ['currency' => $val, 'days' => $days])) }}"
class="px-3 py-1 rounded-full text-xs font-medium transition
{{ ($currency ?? '') === $val ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-indigo-100' }}">
{{ $label }}
</a>
@endforeach
</div>
{{-- 日期范围 --}}
<div class="flex gap-2 ml-auto">
@foreach([7 => '7 天', 14 => '14 天', 30 => '30 天'] as $d => $label)
<a href="{{ route('currency.my-logs', array_merge(request()->query(), ['days' => $d])) }}"
class="px-3 py-1 rounded-full text-xs font-medium transition
{{ $days === $d ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-indigo-100' }}">
{{ $label }}
</a>
@endforeach
</div>
</div>
{{-- 流水列表 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
@if($logs->isEmpty())
<div class="p-12 text-center text-gray-400">
<div class="text-4xl mb-3">📭</div>
<p>暂无积分记录,快去钓鱼或挂机吧!</p>
</div>
@else
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200 text-gray-600">
<tr>
<th class="px-4 py-3 text-left">时间</th>
<th class="px-4 py-3 text-left">类型</th>
<th class="px-4 py-3 text-left">来源</th>
<th class="px-4 py-3 text-right">变动</th>
<th class="px-4 py-3 text-right">变动后余额</th>
<th class="px-4 py-3 text-left">备注</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($logs as $log)
@php
$currencyIcons = ['exp' => '⚡', 'gold' => '💰', 'charm' => '🌸'];
$icon = $currencyIcons[$log->currency] ?? '📌';
$isPositive = $log->amount >= 0;
@endphp
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">
{{ \Carbon\Carbon::parse($log->created_at)->format('m-d H:i') }}
</td>
<td class="px-4 py-3">
{{ $icon }} {{ \App\Services\UserCurrencyService::currencyLabel($log->currency) }}
</td>
<td class="px-4 py-3 text-gray-600">
@php
$sourceLabel = '';
try { $sourceLabel = \App\Enums\CurrencySource::from($log->source)->label(); } catch (\Throwable) { $sourceLabel = $log->source; }
@endphp
{{ $sourceLabel }}
</td>
<td class="px-4 py-3 text-right font-semibold {{ $isPositive ? 'text-green-600' : 'text-red-500' }}">
{{ $isPositive ? '+' : '' }}{{ number_format($log->amount) }}
</td>
<td class="px-4 py-3 text-right text-gray-500">
{{ number_format($log->balance_after) }}
</td>
<td class="px-4 py-3 text-gray-400 text-xs max-w-[160px] truncate">
{{ $log->remark ?: '—' }}
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
<p class="text-center text-xs text-gray-400 mt-4">最多显示最近 200 条记录</p>
</div>
</main>
@endsection

View File

@@ -41,6 +41,9 @@ Route::middleware(['chat.auth'])->group(function () {
// ---- 第九阶段:外围矩阵 - 风云排行榜 ----
Route::get('/leaderboard', [\App\Http\Controllers\LeaderboardController::class, 'index'])->name('leaderboard.index');
// 用户个人积分流水日志(查询自己的经验/金币/魅力历史)
Route::get('/my/currency-logs', [\App\Http\Controllers\LeaderboardController::class, 'myLogs'])->name('currency.my-logs');
// ---- 第十阶段:站内信与留言板系统 ----
Route::get('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'index'])->name('guestbook.index');
Route::post('/guestbook', [\App\Http\Controllers\GuestbookController::class, 'store'])->middleware('throttle:10,1')->name('guestbook.store');
@@ -146,6 +149,9 @@ Route::middleware(['chat.auth', 'chat.level:super'])->prefix('admin')->name('adm
Route::put('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update');
Route::delete('/vip/{id}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy');
// 积分流水活动统计(管理员查看每日经验/金币产出概况)
Route::get('/currency-stats', [\App\Http\Controllers\Admin\CurrencyStatsController::class, 'index'])->name('currency-stats.index');
// AI 厂商配置管理
Route::middleware(['chat.site_owner'])->group(function () {
Route::get('/ai-providers', [\App\Http\Controllers\Admin\AiProviderController::class, 'index'])->name('ai-providers.index');