Compare commits

...
14 Commits
374 changed files with 13988 additions and 4494 deletions
+14 -10
View File
@@ -19,7 +19,7 @@
| 缓存 | Redis(Spring Data Redis + Lettuce) |
| 消息 | RabbitMQ(业务侧配置虚拟主机等,详见各 profile) |
| 实时 | WebSocket(如 `/ws/**` 等路径,与安全放行配置保持一致) |
| API 文档 | **Springfox 3.x**(主用,Swagger / OAS3 分组);另引入 **springdoc-openapi** Starter,可与 Springfox 并行存在 |
| API 文档 | 已移除(不再依赖 Springfox / springdoc,仓库内无 Swagger UI 与 OpenAPI 端点) |
---
@@ -81,7 +81,7 @@ chengdu-backend/
### 对外匿名接口(简述)
与安全配置对齐的常见分类包括:`/login`、`/register`、`/captchaImage`,以及业务侧的 `/api/**`、`/admin/**`、WebSocket、部分 Swagger 与 Druid 监控路径等。具体请以 `ruoyi-framework` 中 **SecurityFilterChain** 与白名单配置为准。
与安全配置对齐的常见分类包括:`/login`、`/register`、`/captchaImage`,以及业务侧的 `/api/**`、`/admin/**`、WebSocket、Druid 监控路径等。具体请以 `ruoyi-framework` 中 **SecurityFilterChain** 与白名单配置为准。
---
@@ -130,7 +130,7 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
- 技术栈:Vue CLI、Element UI(`package.json` 声明 Node `>=8.9`,实际开发建议 Node **16 LTS** 或 **18 LTS**,并按需开启 OpenSSL 兼容)。
- 脚本见 `ruoyi-ui/package.json`(Windows 脚本含 `set NODE_OPTIONS=--openssl-legacy-provider`;在 macOS/Linux 上需 `export NODE_OPTIONS=...` 或使用等价脚本)。
- 开发与打包:`npm run dev`、`npm run build:prod` 等。
- 系统工具内「Swagger」页当前 iframe 指向 **`/doc.html`**;若后端未部署对应聚合页,请改为与下文 **API 文档** 中实际地址一致(例如 `/swagger-ui.html`)。
- 原「系统工具 → 系统接口」的 Swagger 页已随接口文档一并删除(`views/tool/swagger/index.vue`),若侧边栏仍有该菜单,执行 `DELETE FROM sys_menu WHERE component = 'tool/swagger/index';` 后重新登录。
### promo-ui2(Vue 3)
@@ -158,16 +158,14 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
## API 文档
- **Springfox(当前主要使用)**:启动后访问(端口与 `server.port` 一致,仓库默认 **28081**)
`http://localhost:28081/swagger-ui.html`
在页面右上角切换分组(如 `user`、`thirdParty`、`playingMethod`、`task`、`promo` 等;与 `SwaggerConfig` 中 Docket 一致)。OpenAPI JSON 路径形如 `/<group>/api-docs`(Spring Security 已对 `/*/api-docs` 等放行,以实际配置为准)。
- **springdoc-openapi**:`ruoyi-admin` 已引入依赖;若单独启用 SpringDoc UI,需确保 **Security** 对 `/swagger-ui/**`、`/v3/api-docs` 等路径放行,避免 401。
- 项目已**移除全部接口文档能力**:删除 Springfox(`springfox-boot-starter`)、springdoc-openapi 依赖、`SwaggerConfig` 与源码中的 `@Api` / `@ApiOperation` / `@ApiModelProperty` 等注解,`SecurityConfig` 与 `ResourcesConfig` 里对应的放行与静态映射也已去掉。
- 因此 `/swagger-ui.html`、`/v3/api-docs`、`/*/api-docs`、`/doc.html` 均不再对外提供服务(未认证访问会直接落到 401)。
---
## 版本信息
- 父 POM:`com.ruoyi:ruoyi:4.8.2`(`spring-boot.version` **4.0.3**,`springdoc.version` **3.0.2**,`swagger` / Springfox **3.0.0**)
- 父 POM:`com.ruoyi:ruoyi:4.8.2`(`spring-boot.version` **4.0.3**)
- 若依官网参考:<http://www.ruoyi.vip>
---
@@ -175,7 +173,13 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
## 安全建议
1. 轮换并私有化所有第三方(支付宝、聚合支付、短信等)密钥;生产环境勿使用仓库内示例明文。
2. 修改 JWT `token.secret` 与 Druid 监控口令等非默认值。
3. 按需收紧 Druid、`/actuator`(若启用)及 Swagger 相关路径的访问范围。
2. 修改 JWT `token.secret` 等非默认值。
3. Druid 监控台:生产已关闭(`application-pro.yml` 的 `statViewServlet.enabled=false`,`/druid/**` 是匿名放行的,开着等于把全部 SQL 与连接信息暴露到公网);
开发环境已限制 `allow: 127.0.0.1` 并改用非默认口令。临时排障如需开启,务必保持白名单 + 改口令,用完立即关回。
4. **三端 token 互不通用**(游戏管理后台 / 推广后台 / 玩家端):三个端共用同一套 JWT 与会话存储,
靠 `LoginUser.loginScope`(`admin` / `promo` / `player`)区分,判定在 `ruoyi-framework` 的 `SecurityConfig`:
`/admin|/system|/monitor|/tool|/test` 只认后台账号、`/promo/**` 只认推广端签发的 token、`/api/**` 只认玩家端签发的 token。
标记由 `ApiUserDetailsServiceImpl` 按登录账号前缀(`api_` / `promo_` / `promo_user_` / 无前缀)写入,随会话缓存在 Redis。
**注意**:升级到该版本后,不带端标记的旧 token 会被拒绝,推广后台与玩家端需要重新登录一次(后台账号不受影响)。
如有与本说明不一致之处,以当前仓库代码及团队部署文档为准。
+282 -108
View File
@@ -1,130 +1,304 @@
# 数据库变更台账
> **数据库改动只写这里**:中文标题 + 完整可执行 SQL。不再单独建 `docs/*.sql`(全量基线除外)。
> 每条都要能回得去 —— **回滚方案与验证步骤必写**。
> 只记**变更 SQL**;**不写回滚 SQL** —— 已执行的条目即为既成事实,要撤销请从本文件的 git 历史取旧版本。
> 若某条变更依赖**尚未执行**的前置对象(建表、种子数据等),必须在条目标注清楚,别让后来者踩空。
---
## 删除「奥林匹斯 / 荒野争锋 / 极速糖果」三个玩法的后台菜单
## 头像库「是否默认」唯一化:`tt_user_avatar` 存量数据清洗
**背景**:这三个玩法不再需要,要求从后台左侧菜单移除。
**背景**:`tt_user_avatar.is_default` 的语义是「新用户 / 白号注册时分配的初始头像」,
**全表必须最多只有一条 `is_default='1'`**。但后台「设置 → 头像管理」的开关写成默认时,
后端只做了 `updateById`,没有把其他记录的默认标记清掉 ⇒ 库中一度累积出 **7 条默认**(id 63~69),
`id=62` 反而是「否」。后果:后台看到一排「是」容易误解(用户 2026-09-24 反馈),
且 C 端注册处(`ApiRegisterServiceImpl` / `PromoLoginServiceImpl`)用的是
`list(is_default='1')` + `RandomUtil.choice(...)` **随机挑一条**,导致默认头像实际不唯一、每次注册拿到的都不同。
**为什么先动数据库**:RuoYi 的左侧菜单是**数据库驱动**的(`sys_menu` → `/getRouters` 接口 → 前端 `sidebarRouters`),
代码里没有菜单清单。所以顺序是:**先删数据,再删前端页面文件**。
**代码侧修复**(非 SQL,见 `docs/修改记录.md` 同日条目):`TtUserAvatarServiceImpl`
在 `updateUserAvatarById` / `saveAvatar` 中增加互斥逻辑 —— 任何把某条置为 `is_default='1'` 的写入,
都先把其余记录清成 `'0'`,两步同一事务。
**影响面**(实查本机库 `henan`,非估算):
**本次数据库变更 1 处**(2026-09-24 已在库 `henan` 执行):把多余的默认标记收敛为**只保留 id 最小的一条**(id=63)。
| 表 | 行数 | 说明 |
|---|---|---|
| `sys_menu` | **22** | 3 个顶级菜单(`menu_type='M'`)+ 19 个子页面(`'C'`);**无** `'F'` 按钮权限行 |
| `sys_role_menu` | **22** | 全部属于 `role_id = 103` 这一个角色 |
| 顶级 menu_id | 菜单 | path | 子菜单 | 子菜单 menu_id | 前端 component |
|---|---|---|---|---|---|
| 3130 | 奥林匹斯 | `olympus` | 7 个 | 3131~3137 | `game/olympus/*` |
| 2105 | 荒野争锋 | `bullet` | 6 个 | 2106~2110、2112 | `game/bullet/*` |
| 2125 | 极速糖果 | `sweet` | 6 个 | 2126~2131 | `game/sweet/*` |
⚠️ **`2111`(宝箱管理→临时倍率)不在删除范围内** —— 它虽然编号紧邻 `2110`,但父级是 `2018`(宝箱管理),
且其 component 是 `game/box/mock/index`(被 `3141` 经典开箱的「临时爆率」也在用),**不要动**。
**变更 SQL**(2026-09-22 已在库 `henan` 执行):
**变更 SQL**:
```sql
-- 1) 先清角色-菜单授权,避免留下孤儿行
DELETE FROM sys_role_menu
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131);
-- 2) 再删菜单本身
DELETE FROM sys_menu
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131);
UPDATE `tt_user_avatar`
SET `is_default` = '0'
WHERE `is_default` = '1'
AND `id` <> (SELECT `min_id` FROM (
SELECT MIN(`id`) AS `min_id` FROM `tt_user_avatar` WHERE `is_default` = '1'
) AS `t`);
```
**验证**(执行后实测):
> ⚠️ MySQL 不允许在 `UPDATE` 的子查询里直接查同一张表,故此处包了一层派生表 `t` 绕过;
> 该写法的坑已记在 `.workbuddy/memory/` 当日日志里,后续同类 SQL 照此办理。
**验证口径**:执行后 `SELECT is_default, COUNT(*) FROM tt_user_avatar GROUP BY is_default;`
应当只有两行 —— `0 → N`(N = 总数-1)、`1 → 1`。
---
## 幸运升级·水位策略简化为「单一目标水位」:`sys_config` 配置结构变更
**背景**:上一版把水位策略做成了 **4 档波段表**(每档 = 水位下界/上界 + 随机数下界/上界),
后台要维护 16 个数、还要保证档间单调且留有重叠,运营难维护也容易配错。本次简化为**只填一个「目标水位(%)」**:
随机数的**中心**由目标水位自动反推,**取值范围**仍由每个饰品自己的「随机数区间」限定
(水位高于目标 → 落点靠近区间下限)。**口径推导、换算表、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。**
**本次数据库变更 1 处**(2026-09-24 已在库 `henan` 执行):`sys_config.config_key = 'luckyUpgradeWatermark'` 的
`config_value` 由「波段表」改为「目标水位 + 灵敏度」,`bands` 字段废弃(后端已不再读取,留着不报错)。
> ⚠️ **前置依赖**:本条的 `UPDATE` 依赖下一节「幸运升级·水位驱动改造」插入的那行 `sys_config`
> —— 全新库必须先执行那一条,再执行本条。
>
> ⚠️ **同时恢复**(代码侧,非 SQL):饰品列表页的「随机数区间」编辑入口**重新启用**,
> `tt_upgrade_ornaments.luck_section` / `anchor_luck_section` / `blank_luck_section` 现在**必须配置**,
> 且要**包含按目标水位推算出的中心值**,否则中心被夹在边界上、水位停不到目标值。
> ⇒ 下一节里「上线前清空三个区间列」的说法**已作废**,不要再执行那两条清空 SQL。
**变更 SQL**:
```sql
-- 两个都必须是 0
SELECT COUNT(*) FROM sys_menu
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131)
OR parent_id IN (3130, 2105, 2125); -- 实测 0
SELECT COUNT(*) FROM sys_role_menu
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131); -- 实测 0
UPDATE `sys_config`
SET `config_value` = '{"enabled":true,"targetWatermark":30,"gain":2,"luckyValueMode":"raw"}',
`remark` = '幸运升级水位策略:目标水位%(平台希望长期维持的净收益比例)、灵敏度 gain、幸运值展示口径 raw/eff。随机数中心 = 100/((1-目标水位/100)*实测标定系数),再由每个饰品的随机数区间限定取值范围;中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间',
`update_time` = NOW()
WHERE `config_key` = 'luckyUpgradeWatermark';
```
一级菜单复查(`SELECT menu_id, menu_name, path FROM sys_menu WHERE parent_id = 0 ORDER BY order_num, menu_id`)
实测剩余 20 项,已无「奥林匹斯 / 荒野争锋 / 极速糖果」。
**字段口径**:
**回滚 SQL**(22 条 `sys_menu` + 22 条 `sys_role_menu`,由 `mysqldump --no-create-info --complete-insert` 导出):
```sql
-- 事务包裹更安全;menu_id 是显式指定,不需要自增
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2105,'荒野争锋',0,7,'bullet',NULL,NULL,1,0,'M','0','0','','excel','admin','2026-05-14 14:36:15','admin','2026-08-01 13:37:13','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2106,'饰品列表',2105,1,'ornament','game/bullet/ornament/index',NULL,1,0,'C','0','0','game:bullet:ornament:list','button','admin','2026-05-14 14:49:48','admin','2026-05-14 14:55:16','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2107,'箱子管理',2105,2,'box','game/bullet/box/index',NULL,1,0,'C','0','0','game:bullet:box:list','bug','admin','2026-05-14 14:56:11','',NULL,'');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2108,'回合管理',2105,3,'round','game/bullet/round/index',NULL,1,0,'C','0','0','game:bullet:round:list','build','admin','2026-05-14 14:56:49','',NULL,'');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2109,'玩家记录',2105,4,'record','game/bullet/record/index',NULL,1,0,'C','0','0','game:bullet:record:list','button','admin','2026-05-14 14:57:29','',NULL,'');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2110,'每日统计',2105,5,'dailywin','game/bullet/dailywin/index',NULL,1,0,'C','0','0','game:bullet:dailywin:list','chart','admin','2026-05-14 14:58:07','',NULL,'');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2112,'奖品管理',2105,6,'prize','game/bullet/prize/index',NULL,1,0,'C','0','0','game:bullet:prize:list','component','admin','2026-05-26 04:47:02','admin','2026-05-26 05:34:40','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2125,'极速糖果',0,7,'sweet',NULL,NULL,1,0,'M','0','0','','star','admin','2026-06-18 10:15:36','admin','2026-08-01 13:29:08','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2126,'箱子管理',2125,2,' box','game/sweet/box/index',NULL,1,0,'C','0','0','game:sweet:box:list','404','admin','2026-06-18 10:17:18','admin','2026-06-18 14:34:47','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2127,'饰品列表',2125,1,'ornament','game/sweet/ornament/index',NULL,1,0,'C','0','0','game:sweet:ornament:list','bug','admin','2026-06-18 10:18:01','admin','2026-06-18 14:34:29','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2128,'奖品管理',2125,6,'prize','game/sweet/prize/index',NULL,1,0,'C','0','0','game:sweet:prize:list','build','admin','2026-06-18 10:18:41','admin','2026-06-18 14:35:55','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2129,'每日统计',2125,5,'dailywin','game/sweet/dailywin/index',NULL,1,0,'C','0','0','game:sweet:dailywin:list','button','admin','2026-06-18 10:19:39','admin','2026-06-18 14:35:39','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2130,'回合管理',2125,3,'round','game/sweet/round/index',NULL,1,0,'C','0','0','game:sweet:round:list','cascader','admin','2026-06-18 10:20:13','admin','2026-06-18 14:35:03','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2131,'玩家记录',2125,4,'record','game/sweet/record/index',NULL,1,0,'C','0','0','game:sweet:record:list','cascader','admin','2026-06-18 10:21:20','admin','2026-06-18 14:35:17','');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3130,'奥林匹斯',0,5,'olympus','',NULL,1,0,'M','0','0','','star','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯电子游戏管理');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3131,'箱子配置',3130,1,'box','game/olympus/box/index',NULL,1,0,'C','0','0','game:olympus:box','shopping','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯箱子配置与身份桶分布');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3132,'饰品列表',3130,2,'ornament','game/olympus/ornament/index',NULL,1,0,'C','0','0','game:olympus:ornament','star','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯 17 档饰品桶定义');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3133,'回合管理',3130,3,'round','game/olympus/round/index',NULL,1,0,'C','0','0','game:olympus:round','documentation','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯预生成回合查询');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3134,'奖池水位',3130,4,'rewardPool','game/olympus/rewardPool/index',NULL,1,0,'C','0','0','game:olympus:pool','money','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯按箱奖池水位(注水/抽水/批量/重置)');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3135,'玩家记录',3130,5,'record','game/olympus/record/index',NULL,1,0,'C','0','0','game:olympus:record','list','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯游戏记录');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3136,'每日统计',3130,6,'dailywin','game/olympus/dailywin/index',NULL,1,0,'C','0','0','game:olympus:win','chart','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯每日盈亏统计');
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3137,'奖品管理',3130,7,'prize','game/olympus/prize/index',NULL,1,0,'C','0','0','game:olympus:prize','guide','admin','2026-08-01 13:41:53','admin','2026-08-04 13:46:18','奥林匹斯返奖饰品与区间配置');
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2105);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2106);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2107);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2108);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2109);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2110);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2112);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2125);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2126);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2127);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2128);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2129);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2130);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2131);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3130);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3131);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3132);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3133);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3134);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3135);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3136);
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3137);
```
**配套的前端文件删除**(38 个文件,全部在 git 里,`git checkout` 可恢复):
| 目录 | 文件数 |
| 字段 | 含义 |
|---|---|
| `ruoyi-ui/src/views/game/olympus/` | 7 |
| `ruoyi-ui/src/views/game/bullet/` | 6 |
| `ruoyi-ui/src/views/game/sweet/` | 6 |
| `ruoyi-ui/src/api/game/olympus/` | 7 |
| `ruoyi-ui/src/api/game/bullet/` | 6 |
| `ruoyi-ui/src/api/game/sweet/` | 6 |
| `enabled` | `true` = 按水位动态出随机数;`false` = 逐字节回退「每个饰品位固定 `luck_section`」 |
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70% |
| `gain` | 响应灵敏度:水位每偏离目标 1 个点,随机数中心反向调整 `gain`%。默认 2,后台不展示(调参用) |
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加前端原始概率)/ `eff`(累加 `min(概率,75)`);**不影响判定** |
**刻意没动**:
---
- **后端 Java 代码**。`olympus / bullet / sweet / sugar` 在后端出现在**跨模块共享**的位置 ——
`TtAccountRecordSource`(流水来源码:60=奥林匹斯…)、`TtboxRecordSource`、`RedisConstants`、
`MessageCategoryEnum`、`PromoTurnover*`、`ApiUserPackSackServiceImpl`、`WebsiteSetupServiceImpl` 等。
删这些会波及**其他玩法与推广/发货链路**,不在「删页面」范围内。
- **数据库业务表**(`game_olympus_*`、`game_sweet_*` 等)原样保留,未 TRUNCATE、未 DROP。
- `ruoyi-ui/src/views/game/sugar/`(5 个 api + 5 个页面)—— **没有任何菜单引用它**:
菜单里的「极速糖果」指向的是 `game/sweet/*`,而 `game_sugar_*` 表在 `docs/cleanup_records.sql` 里被标注为「极速糖果」。
即 `sugar` 与 `sweet` 是两套并存的东西,`sugar` 未被任何菜单挂载(后端却存在 `GameSugarWin/User/StepInfo/Spin` 实体)。
**疑似遗留或未接入的开发分支,未确认前不删。**
## 幸运升级·水位驱动改造:水位日志表、拉取记录快照列、水位策略配置与「奖池水位」菜单
**背景**:幸运升级原来每个饰品位配一套固定幸运区间,随机数永远落在区间内(如 `[100,160)`),
玩家能看出「每次都要投到 1 倍原价以上才出」。本次改为**按全局水位动态决定本轮的随机数区间**:
`水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注`(排除白号),水位越高 → 可出的随机数越低。
同时新增后台人工注水/抽水。**方案、换算表、口径推导与算例全部汇总在 `docs/修改记录.md` 的 2026-09-23 条**(不另建单独文档)。
**本次数据库变更 4 处**(2026-09-23 已在库 `henan` 执行):
| 对象 | 变更 | 说明 |
|---|---|---|
| `tt_upgrade_reward_log` | **新建表** | 注水/抽水审计日志,`delta` 为带符号影响,水位调整额 = `SUM(delta)`(DB 为真相,Redis 只是短缓存) |
| `tt_upgrade_record` | 新增 **2 列** | `watermark`(本次拉取时的水位%)、`round_target`(本轮抽到的随机数快照)|
| `sys_config` | 新增 **1 行** | `config_id=155`、`config_key='luckyUpgradeWatermark'`,水位波段表 + 幸运值展示口径;`enabled=false` 可逐字节回退旧行为 |
| `sys_menu` / `sys_role_menu` | 各新增 **1 / 2 行** | `menu_id=3208`「幸运升级 → 奖池水位」,授权给 `role_id` 101(三恒)、103(Fdily)|
> **为什么 `menu_id` 用 `3208`**:`3200~3207` 是麻将胡了的号段,按顺序往后取,不复用已下架玩法的旧号段。
**变更 SQL**:
```sql
CREATE TABLE IF NOT EXISTS `tt_upgrade_reward_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'PK',
`type` tinyint NOT NULL COMMENT '1注水 2抽水 3重置',
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值,展示用)',
`delta` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '对水位的带符号影响(注水正/抽水负/重置为抵消值)',
`watermark_before` decimal(8,2) DEFAULT NULL COMMENT '调整前水位%',
`watermark_after` decimal(8,2) DEFAULT NULL COMMENT '调整后水位%',
`operator` varchar(64) DEFAULT NULL COMMENT '操作人',
`remark` varchar(200) DEFAULT NULL COMMENT '调整原因(必填,前端保证)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_create` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幸运升级奖池水位调整日志';
ALTER TABLE `tt_upgrade_record`
ADD COLUMN `watermark` decimal(8,2) NULL COMMENT '本次拉取时的水位%(1-RTP,排除白号,含人工调整)',
ADD COLUMN `round_target` int NULL COMMENT '本轮抽到的随机数 fullV 快照';
INSERT INTO `sys_config`
(`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
VALUES
(155, '幸运升级水位策略', 'luckyUpgradeWatermark',
'{"enabled":true,"luckyValueMode":"raw","bands":[{"from":-999,"to":10,"vMin":95,"vMax":130},{"from":10,"to":26,"vMin":115,"vMax":150},{"from":26,"to":36,"vMin":75,"vMax":175},{"from":36,"to":999,"vMin":85,"vMax":128}]}',
'Y', 'admin', NOW(), '', NULL, '幸运升级水位策略:波段表(水位%→随机数区间) + 幸运值展示口径 raw(原始概率)/eff(与判定同口径);中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间');
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
VALUES
(3208, '奖池水位', 2075, 3, 'rewardPool', 'skins/luckyUpgrade/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'skins:upgrade:pool', 'money', 'admin', NOW(), '', NULL, '幸运升级·奖池水位(全局水位/人工注水抽水/调整历史)');
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (101, 3208), (103, 3208);
```
**验证**(2026-09-23 实测结果):
```sql
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='henan' AND table_name='tt_upgrade_reward_log'; -- 期望 1,实测 1
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='henan' AND table_name='tt_upgrade_record'
AND column_name IN ('watermark','round_target'); -- 期望 2,实测 2
SELECT COUNT(*) FROM sys_config WHERE config_key='luckyUpgradeWatermark'; -- 期望 1,实测 1
SELECT COUNT(*) FROM sys_menu WHERE menu_id=3208; -- 期望 1,实测 1
SELECT COUNT(*) FROM sys_role_menu WHERE menu_id=3208; -- 期望 2,实测 2
```
后台左侧「幸运升级」目录下出现「奖池水位」页(`/luckyUpgrade/rewardPool`)。
**注意:水位策略上线后的运营动作**
> ⚠️ **本节第 1 条已被本文件顶部「水位策略简化为单一目标水位」那条取代**:
> `luck_section` / `anchor_luck_section` / `blank_luck_section` **重新成为必填项**
> (语义 = 随机数的允许取值范围),**不要清空**;而且要配成**包含「按目标水位推算出的中心值」**的区间。
> 清空后后端会用兜底区间 `[80,160]`,能用但不受控。
1. ~~两个已存在的饰品位(`tt_upgrade_ornaments.id` 18 / 25)的 `luck_section` 要清空,才能完全跟随全局波段~~
—— **已作废**,见上方提示。
2. 水位从 0 起步。`enabled=true` 时前几件货会落在收紧档(水位低 → 随机数高),属预期行为;
需要立刻放水用后台「奖池水位 → 注水」。
## 管理后台按钮级权限补全:`sys_menu` 补 24 行 `F`(按钮)记录
**背景**:后台按钮的显隐走前端 `v-hasPermi`(`ruoyi-ui/src/directive/permission/hasPermi.js`),
接口放行走后端 `@PreAuthorize("@ss.hasPermi('...')")`,**两边比对的是同一个 `sys_menu.perms`**
(超管 `user_id=1` 拿 `*:*:*`,其他人拿「已授权菜单行的 `perms` 去重集合」,见
`ruoyi-framework/.../SysPermissionService.java:55`)。
历史遗留:**从 `menu_id` 31xx 段开始,新增页面只建了 `C` 行、没建 `F` 行**
(`F` 行分布:1000 段 61 行、2000 段 18 行、3100 段仅 9 行、3200+ 段 0 行)。
后果是这些页面的按钮在角色授权树里**根本勾不到** ⇒ 除超管外谁都看不见;
更严重的是部分 **列表/查询接口也会 403**,页面进去是空的。
盘点结果:代码在用、但 `sys_menu` 未登记的权限串共 **43 个**。其中 **23 个属于 4 个无菜单入口的孤儿页面**
(`ttOrnaments/boxOpenChance`、`ttBonus/firstRecharge`、`ttWelfare/monthlyRecharges`、`ttReplacementRecord/index`
—— 已核实无菜单、无组件引用、无路由、无接口复用方,**不可达,本期不动**);
**其余 20 个 + 复用组补 4 行 = 24 行**,本次补齐。
> 为什么 `menu_id` 用 `3209~3232`:`MAX(menu_id)=3208`(麻将胡了 3200~3207、幸运升级 3208),按顺序往后取。
**本次数据库变更 3 处**(2026-09-27 已在库 `henan` 执行):
| 对象 | 变更 | 说明 |
|---|---|---|
| `sys_menu` | 新增 **24 行** | `menu_id=3209~3232`,`menu_type='F'`,挂到 6 个 C 行下(2035/2038/2086/2092/2123/3118)|
| `sys_menu` | 修正 **3 行** | `menu_id=3160~3162` 的 `parent_id`:`3156`(目录 `M`「流水抽奖」)→ `3157`(页面 `C`「抽奖活动」)。**同属 F 行挂载错误**,不改的话勾「抽奖活动」联动带不到这 3 个按钮 |
| `sys_role_menu` | 新增 **24 行** | 把上述 F 行补授权给「**已授权其父 C 行**」的角色(动态 SQL,执行时按实际授权现状生成)。执行时这些父 C 行**仅 `role_id=104`(测试人员)**已授权 ⇒ 24 行全落给 104(`sys_role_menu` 总行数 222 → 246)|
> 关于 `3160~3162`:这三个串(`system:turnoverLottery:add/edit/remove`)**当前代码里无人使用**
> (`turnoverLottery/list.vue` 的新增/修改/删除按钮没写 `v-hasPermi`,后端也没有对应 `@PreAuthorize`),
> 所以本次修正只是把菜单树层级摆正、消除「F 行挂在目录下」的隐患,不影响线上行为。
> 若将来给该页面补按钮权限,直接复用这三个串即可。
**变更 SQL**:
```sql
/* ---- 1. 24 行按钮权限(F)---- */
INSERT INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`,
`menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
VALUES
/* 奖池详情(ttRoll/prizedetail)—— 该页「删除」按钮历史上绑的就是 admin:prize:edit */
(3209, '修改奖池', 2038, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:prize:edit', '#', 'admin', NOW(), '', NULL, '奖池详情·修改'),
/* CDK管理(ttRedPack/index)—— 整套新增,此前 0 行 F */
(3210, '新增红包', 2086, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:add', '#', 'admin', NOW(), '', NULL, 'CDK管理·新增'),
(3211, '删除红包', 2086, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:remove', '#', 'admin', NOW(), '', NULL, 'CDK管理·删除'),
(3212, '修改红包', 2086, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:edit', '#', 'admin', NOW(), '', NULL, 'CDK管理·修改'),
(3213, '导出红包', 2086, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:export', '#', 'admin', NOW(), '', NULL, 'CDK管理·导出'),
(3214, '红包查询', 2086, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:query', '#', 'admin', NOW(), '', NULL, 'CDK管理·详情'),
(3215, '红包列表', 2086, 6, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:list', '#', 'admin', NOW(), '', NULL, 'CDK管理·列表接口'),
(3216, '红包全部列表', 2086, 7, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:listAll', '#', 'admin', NOW(), '', NULL, 'CDK管理·不分页列表接口'),
/* 对战排行榜奖励(ttFight/fightRankingReward)—— 此前 0 行 F */
(3217, '新增奖励', 2092, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:add', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·生成'),
(3218, '删除奖励', 2092, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:remove', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·清空'),
(3219, '修改奖励', 2092, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:edit', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·修改'),
(3220, '奖励查询', 2092, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:query', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·详情'),
(3221, '奖励列表', 2092, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:list', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·列表接口'),
/* 机器人对战配置(ttFight/robotFightConfig)—— 该 C 行下原有 4 行(3119~3122),价格配置这套是漏的,故 order 从 5 起 */
(3222, '新增价格配置', 3118, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:add', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置新增'),
(3223, '删除价格配置', 3118, 6, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:remove', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置删除'),
(3224, '修改价格配置', 3118, 7, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:edit', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置修改'),
(3225, '价格配置查询', 3118, 8, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:query', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置详情'),
(3226, '价格配置列表', 3118, 9, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:list', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置列表接口'),
/* 货币列表(ttRechargeList/index)—— 该页只有详情接口校验;列表接口本身无 @PreAuthorize */
(3227, '货币查询', 2035, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:rechargeList:query', '#', 'admin', NOW(), '', NULL, '货币列表·详情'),
/* 机器人对战分组宝箱(ttFight/robotFightGroupBox)
⚠️ 复用组:该页前端按钮用 admin:box:{add,edit,remove}、后端接口用 admin:box:{list,query},
而这 4 个串原先只挂在 2019「宝箱列表」下 —— 父子联动带不过来,
所以必须在 2123 下再挂一遍,才能做到「勾这一个页面 = 该页完整可用」。*/
(3228, '新增分组宝箱', 2123, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:add', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·新增(与 2020 同 perms,刻意重复)'),
(3229, '删除分组宝箱', 2123, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:remove', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·删除(与 2021 同 perms,刻意重复)'),
(3230, '修改分组宝箱', 2123, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:edit', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·修改(与 2022 同 perms,刻意重复)'),
(3231, '分组宝箱查询', 2123, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:query', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·详情接口'),
(3232, '分组宝箱列表', 2123, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:list', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·列表接口(与 2019/3139 同 perms,刻意重复)');
/* ---- 2. 授权补齐:把新 F 行补给「已授权其父 C 行」的角色 ----
动态写法的好处:不依赖执行时刻的角色授权现状,也不用在角色界面逐个重存。
注意:只补 F 行;C 行(页面本身)是否可见仍由业务在「角色管理」里决定。*/
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT DISTINCT `rm`.`role_id`, `f`.`menu_id`
FROM `sys_menu` `f`
JOIN `sys_role_menu` `rm` ON `rm`.`menu_id` = `f`.`parent_id`
WHERE `f`.`menu_id` BETWEEN 3209 AND 3232
AND NOT EXISTS (SELECT 1 FROM `sys_role_menu` `x`
WHERE `x`.`role_id` = `rm`.`role_id` AND `x`.`menu_id` = `f`.`menu_id`);
/* ---- 3. 修正 3 行挂错父节点的 F(3160~3162:目录 3156 → 页面 3157)---- */
UPDATE `sys_menu`
SET `parent_id` = 3157
WHERE `menu_id` BETWEEN 3160 AND 3162
AND `menu_type` = 'F'
AND `parent_id` = 3156;
```
**验证**:
```sql
-- ① 24 行 F 都进去了、且 perms 无重复录入
SELECT COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 3209 AND 3232 AND menu_type='F'; -- 期望 24
SELECT perms, COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 3209 AND 3232
GROUP BY perms HAVING COUNT(*) > 1; -- 期望 0 行(2123 组与 2019 组不同 id,不算重复)
-- ② 全库 F 行的 parent 必须是指向 C 行的 menu_id(防挂错;本次修掉了 3160~3162 这组)
SELECT f.menu_id, f.menu_name, f.perms, f.parent_id, p.menu_type
FROM sys_menu f LEFT JOIN sys_menu p ON p.menu_id = f.parent_id
WHERE f.menu_type = 'F' AND (p.menu_id IS NULL OR p.menu_type <> 'C'); -- 期望 0 行
-- ③ 授权补齐后:每个新 F 行,其父 C 行的每个授权角色都应能查到
SELECT f.menu_id, f.perms, rm.role_id
FROM sys_menu f
JOIN sys_role_menu rm ON rm.menu_id = f.parent_id
WHERE f.menu_id BETWEEN 3209 AND 3232
AND NOT EXISTS (SELECT 1 FROM sys_role_menu x
WHERE x.role_id = rm.role_id AND x.menu_id = f.menu_id); -- 期望 0 行
-- ④ 端到端:模拟某角色的 perms 集合,确认缺串已消失
SELECT DISTINCT m.perms FROM sys_menu m
JOIN sys_role_menu rm ON m.menu_id = rm.menu_id
WHERE rm.role_id = <角色ID> AND m.perms IS NOT NULL AND m.perms <> '';
```
**代码侧配套(非 SQL)**:新增 `.workbuddy/verify-perms.cjs`,做「代码在用的权限串 ↔ `sys_menu.perms`」双向差集,
防止后续新增页面再次只建 C 行、漏建 F 行。跑法见脚本头部注释。
**注意:权限生效需要用户重新登录**(`permissions` 是登录时从 `/getInfo` 拉取的,存在前端 store 里,不会热更新)。
---
-----------------------对战接口优化--------------------------
SHOW INDEX FROM tt_fight;
ALTER TABLE tt_fight ADD INDEX idx_ttf_status_create (status, create_time);
ALTER TABLE tt_fight
ADD COLUMN total_ornaments_price DECIMAL(18,2) NULL COMMENT '本场开箱饰品总价值(百佳榜排序用)',
ADD INDEX idx_ttf_status_total (status, total_ornaments_price);
ALTER TABLE tt_fight_user ADD INDEX idx_tfu_fight_user (fight_id, user_id);
ALTER TABLE tt_fight ADD INDEX idx_ttf_create_time (create_time);
-----------------------------------------------------------
+964 -4
View File
@@ -2,11 +2,821 @@
> **只记大改动**:一个需求 / 一个模块算一条;同一件事的多轮微调合并成一条,不逐轮记。
> **排序:最新在最上面。**
> 展开细节看 `docs/updata_sql.md`(数据库改动,含完整可执行 SQL 与回滚)与 `.workbuddy/memory/YYYY-MM-DD.md`(过程与踩坑)。
> 展开细节看 `docs/updata_sql.md`(数据库改动,含完整可执行 SQL)与 `.workbuddy/memory/YYYY-MM-DD.md`(过程与踩坑)。
> **维护**:完成一个大改动后,在顶部按日期加一条;同一天追加在该日期块末尾。
---
## 2026-09-27
- **管理后台按钮级权限补全:`sys_menu` 补 24 行 `F` + 防漂移校验脚本**
**起因**:用户问「管理后台会不会有只有超管能看到、非超管看不见的页面内按钮」。核查结论:**会,而且比按钮更严重**。
**机制**:按钮显隐走前端 `v-hasPermi`、接口放行走后端 `@PreAuthorize("@ss.hasPermi(...)")`,
两边比对的是**同一份 `sys_menu.perms`**;超管(`user_id==1`)拿 `*:*:*` 所以永远看得见。
⇒ **某权限串只要在 `sys_menu` 里没有对应行,就永远授权不到任何非超管角色**:按钮看不见、部分列表/详情接口直接 403。
**盘出的问题**(代码在用、库里未登记,并集 43 串):
- 4 个孤儿页面(开箱概率 / 首充奖励 / 月度充值 / 换装记录)占 23 串 —— 已核实无菜单、无组件引用、无路由、无接口复用方,
**不可达,本期不动**(手输 URL 也 404,因 RuoYi 路由由 `/getRouters` 动态注册)。
- 其余 20 串 + `admin:box:*` 复用组补 4 行 = **24 行**,本次补齐。
- 结构性漂移:**从 `menu_id` 31xx 段起,新增页面只建 `C` 行、不建 `F` 行**(F 行分布:1000 段 61、2000 段 18、3100 段 9、3200+ 段 0)。
**改动清单**
1. `sys_menu` 新增 24 行 `F`(`menu_id 3209~3232`,挂 2035/2038/2086/2092/2123/3118 六个 C 行下)。
2. `sys_role_menu` 动态补齐:把新 F 行补给「已授权其父 C 行」的角色(本次实写 24 行,全给 `role_id=104`)。
3. `sys_menu` 修正 3 行(`3160~3162`)`parent_id`:目录 `3156` → 页面 `3157`(F 行挂错层级,父子联动带不到)。
4. **代码侧配套**:新增 `.workbuddy/verify-perms.cjs` —— 扫前端 `v-hasPermi` + 后端 `@PreAuthorize`,
与 `sys_menu.perms` 做双向差集 + 校验全库 F 行 parent 必须指向 C 行。非 0 退出码,建议挂 `publish.sh` 前防复发。
**验证**:库层 5 项全对(24 行 / 0 重复 / 0 挂错父节点 / 0 未授权 / `role_menu` 222→246);
`node .workbuddy/verify-perms.cjs` ⇒ **ALL_OK 6 passed / 0 failed**。
**注意**:① 权限生效需用户**重新登录**(`permissions` 登录时从 `/getInfo` 拉取,不热更新)。
② 执行时库内授权已被重排 —— 只有 `role_id=104`(测试人员)持有这些页面,`三恒` 等角色**0 菜单**,
且多个启用用户(三恒/国荣/s1/zzyzzy123/Fdily)**未分配任何角色**,这是业务配置问题,本次未动。
细节 SQL 见 `docs/updata_sql.md`「管理后台按钮级权限补全」,过程与踩坑见 `.workbuddy/memory/2026-09-27.md`。
- **推广后台「佣金明细记录」等 3 张报表右侧留白 → 全列 `width` 改 `min-width`**
**起因**:用户截图 `promo-ui2/src/views/CommissionReport/index.vue` 的「佣金明细记录」表,说「这里表格填充不满」。
**根因**(读 Element Plus 源码定论,不是猜):`element-plus/es/components/table/src/table-layout.mjs:100-171`
的 `updateColumnsWidth()` 只在 **存在弹性列** 时才把表格撑满容器
(`flexColumns = flattenColumns.filter(c => !isNumber(c.width))`);
全列都是数字 `width` 时走 else 分支 ⇒ `bodyWidth = Σ realWidth` ⇒ **表格宽恒等于列宽之和,容器再宽右侧也留白**。
⚠️ 关键陷阱:`width="170"` 这种**静态字符串**也会被 `table-column/render-helper.mjs:39` 的
`parseWidth()`(= `Number.parseInt`)转成数字 170 ⇒ 那是**固定列**;
反过来只写 `min-width` 时 `setColumnWidth()` 会执行 `column.width = void 0` ⇒ 那才是**弹性列**。
**改动清单**(`promo-ui2/src/views/CommissionReport/index.vue`,3 张表共 16 列;另有 6 张表见本节末):
1. 「佣金明细记录」6 列(本地时间170 / 手机号130 / 该次消费120 / 佣金比例90 / 佣金金额100 / 来源120)
2. 「佣金日报」5 列(手机号130 / 今日消费120 / 来源120 / 佣金金额120 / 日期130)
3. 「佣金汇总」5 列(手机号130 / 来源120 / 消费金额120 / 本次佣金120 / 参加游戏次数140)
—— **一律 `width` → `min-width`,数值不变**。选「全列都变弹性」而不是「只挑一列变弹性」:
弹性的那一列会独吞全部余量(1280 视口下 170px 的时间列会涨到 ≈590px),全列按 min-width 等比摊才均匀。
模板顶部加了注释说明为什么不能改回 `width`。脚本/接口/分页/过滤逻辑一行未动。
**验证**(3 件,全部 ALL_OK):
- SFC 编译:`node .workbuddy/verify-promo-vue-compile.cjs src/views/CommissionReport/index.vue` ⇒ **ALL_OK 4/0**
(连带跑 Home ⇒ 11/0,未受影响)。
- 算法级多视口回归:`node .workbuddy/verify-ep-table-stretch.cjs src/views/CommissionReport/index.vue`
⇒ **ALL_OK 15/0**。1280/1440/1600/1920/2560 五个视口下 3 张表**留白全为 0px、`scrollX` 全 false**。
- 真产物:`node .workbuddy/check-promo-modules.cjs src/views/CommissionReport/index.vue --has=min-width --prop=min-width:16`
⇒ **ALL_OK**(用户常驻的 `:4000` dev server 上主模块 200、`"min-width"` prop 恰好 **16** 次、0 处编译错误串)。
- 全站审计:`node .workbuddy/audit-table-fill-ep.cjs` ⇒ CommissionReport 3 张表从「✗ 不能撑满」转为有弹性列。
**新增脚本(3 个,都是 promo-ui2 / Element Plus 侧原先缺的)**:
`.workbuddy/audit-table-fill-ep.cjs`(全站扫「全固定列 ⇒ 留白」)、
`.workbuddy/verify-ep-table-stretch.cjs`(复现 `updateColumnsWidth` 做多视口定量回归)、
`.workbuddy/check-promo-modules.cjs`(从 `:4000` 拉真实编译产物做断言)。
原有的 `sim-table-width.cjs` / `verify-table-fill.cjs` 只适用 ruoyi-ui(Element UI 2.x),**不要混用**。
**注意 / 一并修的另外 6 张**:审计出全站共 **9 张**同样会留白的表,用户拍板「6 张全改」,已一并完成:
`BusinessManage/index.vue` 3 张(招商 Σ1128 / 主播 Σ828 / 普通用户 Σ920,后两张在展开行内)、
`CardMerchant/index.vue:44`(Σ1210)、`subFlow/index.vue:20`(Σ1570)、`SubordinateRecharge/index.vue:23`(Σ760)。
- 改法一致:**数据列 `width` → `min-width`**;**「操作」列保持 `width`**(CardMerchant 100、subFlow 120)——
操作列固定宽是设计意图,不该被拉宽。BusinessManage 的 `type="expand"` 列也不动(`cellStarts` 强制 48px 固定)。
- **嵌套子表(BusinessManage 二/三级)**:容器宽度由父级展开行决定,**不能用「视口 − 288」推算**。
`verify-ep-table-stretch.cjs` 已加 `depth` 判定,嵌套表只做「有没有弹性列」的定性断言,不套用视口断言。
- **两张宽表仍会横向滚动**(改 min-width 治不了,属列宽总量问题):`CardMerchant` Σ1210 ⇒ 视口 ≤1440 横滚(差 58px);
`subFlow` Σ1570 ⇒ 视口 ≤1600 横滚。**这两个视口下改前改后都是横滚**,未新增;≥1600 / ≥1920 起改为撑满(改前是留白)。
若要 1440 也不横滚,得再压列宽(`使用时间/创建时间` 180→165、`卡密` 200→190 之类),本次未做,等确认。
- 复验:全站审计 **不能撑满 0 / 34**;SFC 编译 **19/0 ALL_OK**;列宽回归 **22/0 ALL_OK**(6 张表 × 视口);
真产物(`:4000`)4 个文件主模块全 200、`"min-width"` prop 计数 **22 / 8 / 6 / 13** 逐一吻合、0 错误串。
⚠️ **未经浏览器实测**(本机 Chrome 起不来,见 `.workbuddy/memory/theme-skin.md`)⇒ 效果请自己刷 `:4000` 确认。
- **修复:幸运升级「水位驱动」因 mapper 里 `SUM(x, 0)` 语法错而整条不可用**
**现象**:启动日志 `TtUpgradeRewardLogMapper.xml` → `sumRecordTotals` 报
`java.sql.SQLSyntaxErrorException: ... right syntax to use near '' at line 4`,
外层 WARN「幸运升级水位驱动初始化失败(不阻断启动,首次拉取时会自动重试)」。
**根因**:`TtUpgradeRewardLogMapper.xml` 的 `blankBet` / `blankWin` 两行把外层 `COALESCE(..., 0)` 的写法抄进了 `SUM()` ——
`SUM(CASE WHEN is_blank THEN r.amount_consumed END, 0)`。**`SUM()` 只接受一个参数**,第二个 `, 0` 直接语法错。
(同文件 `totalBet` / `totalWin` 两行写的是 `CASE WHEN is_blank THEN 0 ELSE ... END`,是对的,可作对照。)
**改动**:删掉 `SUM(...)` 里多余的 `, 0`(外层 `COALESCE(SUM(...), 0)` 保留,仍是 null-safe)。全仓库仅此一处。
**影响面(不是"只是个 WARN")**:
- `ensureInit(false)`(`@PostConstruct` 预热)异常 → Redis 累计量 `KEY_BET/KEY_WIN/KEY_BLANK_*` **从未写入**,
`KEY_INIT_AT` 也没落 ⇒ 每次调用都重新全表扫并再次失败,**水位恒为 0**,水位驱动实际等于停摆。
- 后台「奖池水位 → 按历史重算」`/recalc` 调 `ensureInit(true)` 且**没有 try/catch** ⇒ 该按钮**必 500**。
- `calibrationFactor()` 有 try/catch → 失败返回 0 → 回落兜底系数 1.19(这条不受影响)。
**验证**:库里实测 `SELECT SUM(CASE WHEN 1 THEN 2 END, 0)` 复现同一 `ERROR 1064`;
改成 `SUM(CASE WHEN ... END)` 后整条 `sumRecordTotals` 跑通出数。XML 重新解析通过。
**上线动作**:`./ry.sh stop` → `mvn clean install`(见 `USER.md` 的「必须 clean install,否则嵌套旧 jar」)→ `./ry.sh start`。
- **推广后台登录页新增「开发环境账号密码登录」**
**起因**:推广后台唯一登录入口是 `/promo/smsLogin`(手机号 + 4 位短信验证码 + 滑块),本地联调要连短信通道、拖滑块;
后端此前已明确下线账号密码登录(`PromoLoginServiceImpl` 注释:「账号密码登录已下线……避免用密码绕过短信校验被撞库」)。
**改动清单**(前后端配合,**默认关闭**):
1. **后端新增 `POST /promo/devLogin`**(`@Anonymous`):账号解析直接复用 `createUserToken` 的 `promo_user_` 入口
⇒ 推广账号(`tt_user` 的主播/招商/卡商,登录名=手机号)与超管(`promo_admin` 的 `username` 或 `phone_number`)
**都能登录**,区别只是「短信验证码」换成了 `SecurityUtils.matchesPassword` 的 BCrypt 密码校验。
账号不存在 / 停用 / 密码错统一返回「账号或密码错误」(防账号枚举,真实原因走 `log.warn`)。
2. **开关走配置文件而非数据库**:新增 `promo.dev-login.enabled` —— `application.yml` 默认 `false`,`application-dev.yml` 置 `true`,
生产 profile 不覆盖 ⇒ 接口恒返回「账号密码登录未开启」,且后台管理员无法误开。
3. `PromoWebConfig` 放行 `/promo/devLogin`(维护拦截器不拦登录类接口,与 smsLogin/register 同口径)。
4. **前端** `LoginForm.vue` 只在 `import.meta.env.DEV`(即 `pnpm run dev`)下渲染「短信验证码 / 账号密码」切换与密码表单;
登录成功后复用原有的 `completeLogin` + `enterSystem` 落地流程。`api/login` 增加 `devLoginApi` 与 `DevLoginType`。
**验证**:`vue-tsc --noEmit` 改动文件 0 报错(仓库其余 30+ 条为既存错误);`mvn -B -DskipTests compile` ⇒ `BUILD SUCCESS`。
**无需数据库变更**:开关是配置文件项,不是 `sys_config`(相对 `promoMaintenance` 的取舍见 `.workbuddy/memory/2026-09-27.md`)。
**使用注意**:密码按 BCrypt 校验,测试账号若不知明文,查 `tt_user.remark`(格式 `明文密码:xxx`)或 `promo_admin` 表。
- **管理后台首页改排版:11 张等宽统计卡 → 「左窄轨 + 右主区」两栏**
**起因**:用户反馈首页「排版还是和原来一样,只是改了 UI 的风格」——换肤把配色圆角阴影改了,但骨架仍是
「页头 → 卡片网格 → 区块 → 区块」的垂直堆叠模板,要求**排版差异化、风格照旧**。
**改动清单**(`ruoyi-ui/src/views/index.vue`,单文件)
1. **骨架拆两栏**:`grid-template-columns: 300px minmax(0,1fr)` + `align-items:start`。
- **左窄轨**(`position:sticky; top:16px`,跟随滚动):3 个核心大数字(在线用户 / 总充值 / 总流水,字号 28px)
+ 待办区(待处理提货申请 / 提货余额)。待处理 >0 时雏菊金描边 + 数值变色。
- **右侧主区**:实时 RTP → 「经营数据」紧凑指标带(6 项 `mini-stat`,小图标 + 标签 + 数值)→ 流水统计图
(顺序按用户看完后的要求定:图表 400px 最高,放最底部滚到底正好)。
2. **指标一项不丢**:原 `statCards` 11 项拆成 `railMetrics`(3) + `miniStats`(6) + 待办区硬编码 2 项 = 11。
原「图标 + 大数值 + 标签」的等权卡片形态取消,改为「大数字 / 紧凑条」两级,主次拉开。
3. **RTP 网格** `repeat(5,1fr)` → `repeat(auto-fit, minmax(160px,1fr))`:右区宽度随屏幕浮动(左轨占了 300px),
写死 5 列会在 1440 以下挤出 4+1。
4. 旧骨架 CSS(`.stat-grid` / `.stat-card` / `.stat-icon` / `.stat-info` / `.stat-arrow`)与 `statCards` computed **一并删除**。
**风格**:色板 / 圆角 / 阴影 / 图标浅底规则全部沿用,**未新增任何色值** —— cottagecore 配色纪律不动。
**验证**:`node .workbuddy/verify-home-layout.cjs`(新增,cwd=ruoyi-ui)⇒ **ALL_OK 28 条 0 失败**
(含 vue-template-compiler 编译模板、dart-sass 编译样式、新旧骨架核对、区块顺序、指标总数 11、交互绑定未丢);
`node .workbuddy/audit-palette.cjs` ⇒ **离版文件 0/185、离版色值 0 次**;
dev server(`:80`)`/static/js/app.js` ⇒ 200 / 7.1MB / 0 处 `Module build failed`。
⚠️ 本机助手拉不起 Chrome(安全策略)⇒ **渲染后实际观感未经实测**,需用户刷新页面肉眼确认。
**后续补丁(用户看完截图提的)**:左轨「待处理提货申请 / **提货余额**」那格里,提货余额写的是 `$9259.64`
(**美元符号**),而页面其余金额(总充值 `¥1800`、总流水 `¥0.00`、RTP 投注/奖励 `¥0.00`、经营数据 6 项 `¥…`)
一律 `¥` ⇒ 统一为 `¥{{ balance }}`(`ruoyi-ui/src/views/index.vue:55`,全站唯一一处 `$` 货币写法,已 grep 确认)。
`verify-home-layout.cjs` 补 2 条断言(模板内不得出现 `$` 紧跟插值;提货余额那行前缀必须是 `¥`)⇒ 现 **30 条 ALL_OK**。
真产物复核(新增 `.workbuddy/check-ruoyi-bundle.cjs`,从 `:80` 拉 `app.js`):编译后为
`_vm._v("¥" + _vm._s(_vm.balance))`,`$` 版本 0 处。
- **推广后台首页改排版:「基本信息 / 5 张返利卡 / 汇总表」三段堆叠 → 「推广工具置顶」**
**起因**:与上一轮管理端首页同类问题(换肤只改配色,骨架没动)。用户在 3 轮高保真稿里选定 **版本 3 · 推广工具置顶**。
**改动清单**(`promo-ui2/src/views/Home/index.vue`,单文件)
1. **① 我的推广(置顶通栏)**:`hero` 行放**推广链接**(15px 加粗、超长省略、整条可复制)+ 主色「复制链接」按钮(无链接时 `disabled`);
下面一条虚线分隔的三格:**我的邀请码**(复制 / 修改 / 已修改)、**手机号码**(绑 `phoneNumber`)、**上级邀请码**。
2. **② 返利 KPI 横带**(4 格,19px 数字):**累计返利 / 今日返利 / 本周返利 / 累计下级** ——
全部从已有的 `getGameCommissionData()` 的 `periods` 里取(`总`/`今日`/`本周` 行),**不新增任何接口**。
3. **③ 底部两栏** `260px minmax(0,1fr)`:左 = **返利比例**(5 行「玩法名 ⋯⋯ 百分比」点线清单,替掉原来 5 张等宽卡);
右 = **周期返利汇总**表格(列一项没删,仍是 10 列)。`@media(max-width:1200px)` 收成一栏让表格拿满宽度。
4. **④ 账号信息降级到最底部**:用户账号 / 用户类型 / 金币余额 / 积分余额 / 注册时间 / Steam交易链接(6 项)。
样式上**做成非卡片**(`background/border` 透明、去阴影),避免和上面的数据抢注意力。
5. 旧骨架(`.section` / `.section-title` / `.game-rate-cards` / `.rate-card*`)与那两条**从未命中过**的
`.el-table__row tr:nth-child(odd)` 伪条纹规则一并删除(Element 的 `stripe` 已负责隔行)。
6. 颜色从残留的旧 Element 灰(`#f5f7fa` / `#e4e7ed` / `#303133` / `#67c23a` / `#606266` / `#909399` / `#fafafa` / `#e6a23c`)
收敛到田园核白名单 —— 该页 scoped 样式是换肤时漏掉的一份。
**一字未动的**:`username: userInfo.userName`(改邀请码的**入参**,后端拿它做「只能改自己」校验,与展示用的 `phoneNumber` 语义不同);
返利比例出处仍是 `getInfo()` 的三个 `*CommissionRate`;表格 10 列 / 弹窗 / 复制 / `onMounted` 双并发加载全部保留。
**验证**(新增 2 个脚本)
- `node .workbuddy/verify-promo-home.cjs` ⇒ **ALL_OK 54 条 0 失败**:新骨架 12 项、旧骨架 4 项须绝迹、区块顺序、
表格 10 列 / 账号 6 项 / KPI 4 项 / 玩法 5 项齐全、交互绑定 7 项、旧 Element 色 0 处、色值全在白名单、8 组对比度 ≥4.5:1。
- `node .workbuddy/verify-promo-vue-compile.cjs`(新增,用 promo 自带的 `vue/compiler-sfc` 走
parse → compileScript → compileTemplate → compileStyle)⇒ **ALL_OK 7 条 0 失败**。
- `node .workbuddy/verify-home-page.cjs` ⇒ **ALL_OK 5 条 0 失败**(该脚本①的结构性正则随「手机号码」移位已改写为与结构无关的定位)。
- **真机 dev 产物**(用户的 vite dev server 正在 `:4000` 跑,热更新):`/src/views/Home/index.vue` 的
style / template / script-setup / main 四个模块全 200,0 处 `Transform failed` / `Internal server error`;
编译后 15/15 个新骨架选择器都带上了 `[data-v-a763a6ae]` 前缀,旧类名 0 残留。
⚠️ 两点未做实测:**渲染后观感**(本机 Chrome 拉不起来);**窄屏横向滚动** —— 底部两栏时表格可用宽度 ≈ 屏宽-200(侧栏)-80,
10 列 `min-width` 合计 1090px,**视口 < 约 1600px 时右侧表格会出现横向滚动**(≥1600 正常),已在 `<1200px` 断点收成一栏规避。
**数据全是 0 是账号本身无下级/无消费所致**,不是渲染问题(`periodData` 的 4 行都在,只是值都为 0.00)。
过程与踩坑见 `.workbuddy/memory/2026-09-27.md`。
- **管理后台站点名「河南项目」→「凯科佰娱」(ruoyi-ui)**
**起因**:用户截图(`localhost/#/index`)圈出左下角 Logo 旁标题与浏览器 tab 标题,要求改成「凯科佰娱」。
**定位**:截图端口 80 + hash 路由 ⇒ 是 **`ruoyi-ui`(Vue2 管理后台,`vue.config.js` 默认 port 80)**,不是 promo-ui2(:4000)。
站点名只有一个源:`VUE_APP_TITLE`(`.env.*`)。消费方两处 ——
`src/layout/components/Sidebar/Logo.vue:23`(侧栏标题)、`src/App.vue:20-29` 的 `metaInfo.titleTemplate`
(`${路由名} - ${VUE_APP_TITLE}`);`public/index.html` 的 `<title>` 由 `vue.config.js:10` 的 `name` 注入。
⇒ **改一处 env 即可同时改掉侧栏 + tab**。另有 login/register 两页是硬编码同名,一并改。
**改动清单(6 处,全是字符串替换)**
1. `.env.development` / `.env.production` / `.env.staging`:`VUE_APP_TITLE = 河南项目` → `凯科佰娱`。
2. `vue.config.js:10` 兜底值 `|| '河南项目'` → `|| '凯科佰娱'`。
3. `src/views/login.vue:5`、`src/views/register.vue:4` 的 `<h3 class="title">`。
**注意**:① `.env` 在**进程启动时**注入 `process.env`,**改完必须重启 dev server**(webpack 不会热更 env)。
② `ruoyi-ui/dist/` 是构建产物,仍含旧串,需 `npm run build:prod` 重新生成才会更新(未手动改 dist)。
③ 后端 `application-*.yml` 里的「河南」是注释(控制台账号备注),与站点名无关,未动;
`docs/修改记录.md:910` 等处的「河南项目」是历史叙述,保留。
过程见 `.workbuddy/memory/2026-09-27.md`。
- **推广后台顶栏「分享链接」删除(与首页「我的推广链接」重复)**
**起因**:用户截图 `promo-ui2` 首页(`localhost:4000/#/home/index`),圈出**顶栏**的 `分享链接:https://…?code=…`
与**页面里**的「我的推广链接」hero 卡是同一份内容,要求「把上面的去掉」。
**定位**:两处不同来源 ——
① **顶栏**:`src/layout/components/ToolHeader.vue:45-47` 的 `<span>分享链接:{userStore.getUserInfo?.shareLink}</span>`
(**布局层组件,所有页面都有**,`role !== '99' && role !== '05'` 才显示);
② **页面**:`src/views/Home/index.vue:4-21` 的 hero 卡 —— 本次改版(版本 3「推广工具置顶」)**新加的**。
⇒ hero 一上线,顶栏那份就成了重复。按用户口径删**顶栏**(上面的)。
**改动清单**
1. `ToolHeader.vue`:删掉该 `<span>`(原地留注释说明为何删)。
2. 同文件连带清理:`useUserStore` 的 import 与 `const userStore = useUserStore()` —— 全文件只有这一处在用,
不删会变成未使用变量(`vue-tsc` / eslint 会报)。
3. `Home/index.vue`:删掉「改邀请码成功后同步顶部 header 分享链接」的 `userStore.setUserInfo({...})` 块 + 其 import/实例——
该块**存在的唯一目的**就是喂被删掉的顶栏,属同一改动的死代码,不是重构。
**验证**:给 `.workbuddy/check-promo-modules.cjs` **新增 `--absent=<子串>`**(原先只有 `--has` 用于"必须有",
删元素没法验),从用户常驻的 `:4000` 拉 **vite 真产物**断言:
- `layout/components/ToolHeader.vue` → 主模块 200、style 子模块 200、`useUserStore` / `shareLink` / `getUserInfo` **全部已消失**,**ALL_OK 6/0**。
- `views/Home/index.vue` → 主模块 200、`useUserStore` / `setUserInfo` **已消失**,**ALL_OK 4/0**。
⚠️ `--absent` 的坑:dev 构建**保留模板注释** ⇒ 注释里**别写被删的那个词**,否则 `_createCommentVNode("…")` 会命中假 FAIL。
**影响面**:改的是**布局层** ⇒ 所有页面顶栏都不再显示分享链接(不只首页)。
`userStore.userInfo.shareLink` 仍由 `Login/completeLogin.ts` 与 `views/User/index.vue` 写入,只是**不再有展示方**(无其他读取处,已 grep 确认);
改邀请码后 store 里那份不再自动刷新,因无消费方,无影响。
过程见 `.workbuddy/memory/2026-09-27.md`。
---
## 2026-09-24
- **头像管理:「是否默认」可同时多条为「是」的缺陷修复(唯一性约束 + 存量数据清洗 + 开关交互)**
**起因**:用户在「网站设置 → 头像管理」截图反馈,「是否默认」那一列有 7 条都显示绿色「是」,
而按语义**全站默认头像只应该有一条**。
**排查结论(逐层验证,不是猜的)**:
| 层 | 实测 | 结论 |
|---|---|---|
| 数据库 | `SELECT is_default, COUNT(*) ... GROUP BY is_default` → `0→1`、`1→7`(id 63~69) | **数据本身就错**,界面是忠实反映 |
| 前端 | `v-model="scope.row.isDefault"` 直读接口字段 | 无显示层问题,不需要改渲染 |
| 后端 | `TtUserAvatarServiceImpl.updateUserAvatarById` 只做 `updateById` | **根因**:设为默认时不互斥清空其他记录 |
⇒ 定位为**后端写接口缺约束 + 存量脏数据**,不是 UI bug。
**二、连带发现(比表面问题更严重)**:C 端有 4 处消费「默认头像」,其中
`ApiRegisterServiceImpl`、`PromoLoginServiceImpl` 用的是
`list(is_default='1')` + `RandomUtil.choice(...)` **随机挑一条** —— 这是在「可能有多条默认」的
既成事实上打的补丁。后果是**每次注册拿到的初始头像都不一样**,默认头像形同虚设。
(`TtUserServiceImpl` 白号那边已有 `defaultAvatarList.isEmpty()` 的降级,逻辑本身没问题。)
**三、改动清单**
1. **后端互斥(核心)** `skins-service/service-admin`:
- `TtUserAvatarService` 新增 `saveAvatar(TtUserAvatar)`、`getDefaultAvatar()`;
- `TtUserAvatarServiceImpl` 新增私有方法 `clearOtherDefaultsIfNeeded(...)`:
凡是把某条写成 `is_default='1'` 的写入,**先 `UPDATE ... SET is_default='0' WHERE is_default='1' AND id<>当前id`**,
再落当前这条,两步同一事务(`@Transactional`)。只在确实要置默认时才执行 UPDATE,普通编辑不白扫全表。
- `updateUserAvatarById` / `saveAvatar` 均接入;顺手删掉原方法里无意义的 `setAvatar(getAvatar())`。
2. **控制器** `TtUserAvatarController.add` 由 `userAvatarService.save(...)` 改走 `saveAvatar(...)`,
使**新增**也受同一约束(原来新增一条默认就会叠加出第二个)。
3. **存量数据清洗**:`UPDATE tt_user_avatar SET is_default='0' WHERE is_default='1' AND id <> (最小 id 的默认)`,
实测影响 6 行,清洗后 `0→7 / 1→1`(保留 id=63)。SQL 全文见 `docs/updata_sql.md`。
4. **前端交互** `ruoyi-ui/src/views/skins/ttSetting/avatar.vue`:「是否默认」由 `updateUserStatus`
改走新的 `updateDefault(...)` —— 开启时先 `$confirm` 告知「原默认头像「xx」将自动取消默认」,
取消则回滚开关视觉并重新拉列表。关闭时直接保存。
**四、验证**:后端 `mvn -q -pl skins-service/service-admin -am install -DskipTests` 通过,
字节搜 `clearOtherDefaultsIfNeeded`/`saveAvatar`/`getDefaultAvatar` 均在 `.class` 里;
前端 `vue-cli-service build` 通过,新文案与新方法名均落在 `dist/static/js/chunk-cf2f9538.*.js`。
**五、踩坑**:MySQL **不允许 `UPDATE` 的子查询直接查同一张表**(报 1093),
解法是包一层派生表 `(SELECT MIN(id) ... ) AS t` 绕过 —— 后续同类 SQL 照此办理。
⚠️ **待办**:后端 jar 未重打包、服务未重启,互斥逻辑**尚未在运行中的服务里生效**;
前端 `dist` 已就绪但线上资源需按部署流程发布。存量数据已清洗,界面当下已正确。
- **幸运升级:水位配置从「4 档波段表」简化为「一个目标水位百分比」+ 饰品随机数区间恢复可编辑**
**起因**:上一版后台要维护一张 4 档波段表(水位下界/上界 × 随机数下界/上界 = 16 个数),
还要保证档间单调且留有重叠 —— 运营看不懂,也容易配出互相矛盾的表。
用户要的模型其实简单得多:**「每个饰品设一个区间,水位高的时候随机数靠近区间下限,
但水位要维持在我设定的那个值」** ⇒ 后台只该填**一个水位百分比**。
⚠️ **本条取代 `2026-09-23` 条里的「三、波段表」与「十、上线前清空旧区间」两处**,
其余口径(三身份判定隔离、幸运值共享展示、标定系数、注水抽水、水位统计口径)**全部仍然有效**。
**一、新口径(一句话)**
目标水位 → 自动反推随机数中心 → 在该饰品自己的区间内围绕中心抖动。
后台不配随机数,随机数由目标水位算出来:
```
V* = 100 / ((1 − 目标水位/100) × k) k = 实测标定系数(见第四节,样本不足按 1.19)
中心 = V* × (1 − (当前水位 − 目标水位)/100 × gain) gain 默认 2
t = (中心 − 下限) / (上限 − 下限) 中心在区间内的相对位置,夹到 [0.10, 0.90]
V = 下限 + (上限 − 下限) × u^p, p = 1/t − 1 u ~ U(0,1)
```
- **偏斜随机**:支撑集始终是**整个区间**(任何值都可能出),水位只改变**概率密度**。
水位 = 目标 ⇒ `t = 0.5`、`p = 1` ⇒ **均匀分布**(区间内等概率);
水位高于目标(放水)⇒ 中心下移、`p > 1` ⇒ 概率向**下限**堆积;低于目标则向**上限**堆积。
- `E[u^p] = 1/(p+1) = t` ⇒ `E[V] = 中心` ⇒ **水位精确收敛回目标值**(这就是「保证维持在我设定的值」)。
- ⚠️ 曾先写过「中心平移 + 固定宽度抖动,再夹取到区间边界」,**已废弃**:中心贴边时落点会被压成贴着边界的一条窄带,
且**水位偏离越大窄带越窄**,玩家一眼看出是"卡边界"。改成偏斜随机后,任何水位下支撑集都是整个区间。
- ⚠️ `t` 夹到 `[0.10, 0.90]` 决定了**中心的可表达范围 = 区间下界 + [10%, 90%] × 区间宽度**。
中心超出这个范围(水位远离目标,或区间配得太窄)⇒ 水位只能停在附近。
例:区间 `[100,160]` 的中心可表达范围是 `[106, 154]`,对应水位约 **`[20.7%, 45.4%]`**;
想把水位开到 60%,区间上限得提到 **210 以上**。
**二、目标水位 ↔ 随机数中心换算表**(`k = 1.19`,即实测系数兜底值)
| 目标水位 | 目标 RTP | 随机数中心 `V*` |
|---:|---:|---:|
| 0% | 100% | 84 |
| 10% | 90% | 93 |
| 20% | 80% | 105 |
| **30%** | **70%** | **120** |
| 40% | 60% | 140 |
| 45% | 55% | 153 |
| 50% | 50% | 168 |
| 60% | 40% | 210 |
⚠️ **`V*` 必须落在饰品的随机数区间内**(且离边界 > 10),否则中心被夹住、水位停在别处。
例:目标 60% 需要中心 210 ⇒ 饰品区间上界必须 ≥ 210。
**三、饰品随机数区间的作用(恢复了编辑入口)**
`tt_upgrade_ornaments` 的三个区间列(`luck_section` / `anchor_luck_section` / `blank_luck_section`)
重新成为**必填项**,语义从「固定随机数区间」变成「**随机数的取值范围**」:
**整个区间始终都可能出**,中心由水位决定、只控制概率往哪一端堆。区间越宽,单轮波动越大、越难被玩家归纳出规律。
⇒ **`2026-09-23` 条里「上线前清空三个区间列」的做法已作废**,不要再执行。
⇒ 区间还**限定了这台机器能维持的水位范围**(见第一节末条):区间太窄或下限太高,高水位目标就实现不了。
**四、实测标定系数 k:改为 5 分钟内存缓存**
k = 「改造后累计下注(排除白号) / Σ(`round_target/100` × 目标饰品价)」,含义与算法不变。
但它是一次**全表聚合 + `tt_user` JOIN**,而抽随机数的热路径现在也要用它
⇒ 加 **5 分钟内存缓存**(启动预热 + 后台「按历史重算」主动刷新),不能每次拉取都查一次 DB。
**五、仿真验证**(脚本 `.workbuddy/upgrade-target-watermark-sim.mjs`,每种配置 60 万次拉取,可复跑)
| 配置 | 目标水位 | 实测稳态水位 | 偏差 | 实测 RTP | p10~p90 随机数 | 随机数实际范围 | 低于 100 占比 |
|---|---:|---:|---:|---:|---:|---|---:|
| A 区间 `[100,160]` | 30% | **29.97%** | −0.03 | 70.03% | 100~148 | **`[100,160]` 全覆盖** | 0.0% |
| B 区间 `[75,175]`(宽) | 30% | **29.98%** | −0.02 | 69.99% | 81~163 | **`[75,175]` 全覆盖** | **32.8%** |
| C 区间 `[100,160]` | 45% | 43.80% | −1.20 | 56.21% | 146~159 | `[111,160]` | 0.0% |
| D 区间 `[100,160]` | 60% | 43.79% | **−16.21** | 56.21% | 146~159 | `[117,160]` | 0.0% |
| E 区间 `[80,90]`(窄) | 30% | 9.77% | **−20.23** | 90.25% | 88~90 | `[83,90]` | 100.0% |
- **A/B**:区间包含 `V*` ⇒ 稳态水位与目标值差 < 0.05 个点,**「维持在我设定的值」成立**;
且随机数的**实际出现范围就是整个区间**(不是贴着中心的一条窄带)。
- **C**:`V* = 157.4` 距区间上限 160 只差 2.6,小于 10% 区间宽度(6)⇒ 被 `t` 上限夹住,偏 1.2 个点。
- **D/E**:区间不包含 `V*` ⇒ 中心被夹死在边界,水位**到不了**目标值(偏 16~20 个点)—— 这是唯一的坑。
- **B 的 32.8%** 说明:想把「每次都要 100 以上才出」的观感彻底打掉,区间下限要低于 100(宽区间 `[75,175]`)。
**六、改动清单**
1. `UpgradeWatermarkSettingBody`:删 `bands` / `Band` 类,改为 `targetWatermark` + `gain`(后台只填目标水位)。
2. `UpgradeWatermarkService`:删波段表查表逻辑,新增 `targetCenter()` / `centerAt()` / `drawTarget(lo, hi)`;
`drawTarget` 用**偏斜随机**(`V = 下限 + 宽度 × u^p`,`p = 1/t − 1`,`t` 由中心在区间内的相对位置给出);
标定系数加 5 分钟缓存;`info()` 返回 `targetWatermark` / `targetGap` / `targetCenter` / `centerNow` /
`effectiveCalibration`(原 `bands` / `bandRows` / `bandMin` / `bandMax` 全部移除)。
3. `ApiLuckyUpgradeServiceImpl`:新增 `resolveRange()`(读该饰品位 × 本身份的区间,**直接读实体字段**,
不再走 Redis 区间缓存 ⇒ 后台改完下一轮立即生效,不用清缓存);`drawRoundTarget` 把区间交给水位服务。
未配区间时兜底 `[80,160]`。回退分支 `legacyFixedTarget()` 保持旧的 Redis 缓存写法不动。
4. 前端 `views/skins/luckyUpgrade/rewardPool/index.vue`:**删掉整张波段表**,改为一个「目标水位」输入框
+ 策略开关,**改完立即生效(页面没有保存按钮)**;实时条显示「当前水位 / 目标水位 / 离目标多少 /
下一轮随机数 / 人工调整 / 实测标定系数 / 白号 / 策略开关」。
策略卡里另有一个**「随机数区间 → 可维持水位范围」换算器**:填两个数即时反推
(`中心可表达范围 = 下限 + [10%, 90%] × 宽度`,再代回 `水位 = 1 − 100/(k × 中心)`),
目标水位不在算出的范围内会标红提示;右侧 `?` 悬浮说明「区间是什么 / 为什么它限定了水位范围 / 怎么用」。
⇒ 正文只讲**区间硬约束**,不展示「随机数中心」这类内部推算量。
5. 前端 `views/skins/ttUpgrade/index.vue`:**恢复**普通/主播/白号三列「随机数区间」编辑(列名由「幸运区间」改为
「随机数区间」);顶部水位条改为显示「目标水位 / 推算随机数中心」(原「本轮随机数区间」两栏随波段表一起删)。
**七、验证**
- `mvn -B clean install -DskipTests` 通过;`ruoyi-ui` 生产构建通过。
- 仿真 E 组反证有效:区间 `[80,90]` + 目标 30% ⇒ 稳态 9.84%、RTP 90.2%,说明夹取逻辑确实按预期生效(不是"看起来对")。
- 全仓扫 `bands` / `bandRows` / `bandMin` / `bandMax` 无残留引用。
**八、上线前必做**
1. **给每个在售饰品配随机数区间**,且区间要包含「按目标水位推算的中心值」,**中心离两端各留约 10% 区间宽度**
(后台水位页会显示该中心)。区间缺失时后端兜底 `[80,160]`,能用但不受控。
区间同时**限定了水位能调到的范围**(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
2. 目标水位**默认 30%**(≈ 长期 RTP 70%);上线初期水位从 0 起步,会有一段收紧期,想立刻拉到目标值就在水位页注水。
3. 后端需**重启**才生效(策略与抽数逻辑都在 Java 侧)。
**九、虚拟水位:一批饰品同时开轮时,不会都按同一个水位放水**(2026-09-24 补充)
**问题**:真实水位只反映**已经结算**的下注/返奖。一批饰品同时开新轮时(同时上架、或几个玩家同时首次拉取),
它们会全部用同一个真实水位去算中心 —— 水位高时连着出好几个低随机数,而这批货真出完后水位其实已经变了。
**做法**:每抽一个随机数就把这一轮的**预估交易**预留进 `upgrade_pool:pending:bet` / `:pending:win`:
`预估下注 = 饰品价 × k × 随机数 / 100`(乘 k 是因为判定溢出)、`预估返奖 = 饰品价`。
抽下一个时用的是**虚拟水位**:
```
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
```
这一轮出货过程中产生的实际流水会在 `recordPull` 里**逐步把预留扣回去** —— 两边口径一致
(`k` 就是「实际 / 名义」的实测系数),所以真正出货时预留基本归零、不会长期挂账。
白号轮次不进水位口径,不参与预留;`enabled=false` 或没抽过数时 pending = 0,退化为真实水位。
后台「按历史重算」会把 pending 一起归零,兜住「某个轮次永远没出货、pending 长期挂账」的漂移。
**效果**(同一仿真脚本的「预留机制」段;8 轮连开后的 `E[V]`):
| 累计下注体量 | 不用预留 | 用预留(第 8 轮) | 收紧 |
|---:|---:|---:|---:|
| 2 万 | 88.0 | **108.6** | **+23.4%** |
| 10 万 | 88.0 | 90.8 | +3.2% |
| 100 万 | 88.0 | 88.0 | 0% |
⚠️ **效果与水位体量成反比,这是数学必然、不是实现问题** —— 水位是「累计比例」,
一件货(玩家成本约 1.2 倍饰品价)只占累计下注的极小一份。刚上线体量小时很灵敏,体量大后几乎可忽略。
⇒ 想让大水位下收敛更灵敏,要调的是 **`gain`**(水位每偏离 1 个点、中心移动的幅度,默认 2),不是靠预留。
⇒ 另外:中心一旦撞到区间边界(`t` 被夹到 `[0.10, 0.90]`),`E[V]` 就不再随水位变化 ——
上面 100 万那组水位明明降了 0.32 个点,`E[V]` 仍是 88,就是这个原因。
**★ 生效边界:能稳定处理的量级**(判据 `|Δ虚拟水位| ≥ 1 个点` ⇒ `E[V]` 变化 ≥ 2.4 点、约 2.7%,线上看得出来):
| 累计下注 \ 连开个数 | 3 个 | 5 个 | 10 个 | 20 个 | 50 个 |
|---:|---:|---:|---:|---:|---:|
| 1 万 | −10.0 \* | −10.8 \* | −16.1 \* | −12.6 \* | −14.0 \* |
| 3 万 | −4.1 \* | −6.6 \* | −6.4 \* | −13.1 \* | −13.2 \* |
| 10 万 | −1.4 \* | −2.2 \* | −4.0 \* | −6.3 \* | −10.6 \* |
| 30 万 | −0.5 | −0.7 | −1.4 \* | −2.7 \* | −6.0 \* |
| 100 万 | −0.1 | −0.2 | −0.3 | −0.9 | −2.0 \* |
| 500 万 | −0.03 | −0.04 | −0.1 | −0.2 | −0.4 |
(\* = 有效;饰品价按 1000、水位 45%、目标 30% 算。因撞区间边界后随机数饱和,1 万那行不再单调。)
**门槛公式**:`Δ水位 ≈ x·[q(1−r) − 1] / (1 + q·x)`,其中 `x = N·p/B`、`q = k·V/100 ≈ 1.2~1.43`、`r` = 当前水位。
⇒ **`r = 目标水位` 时分子为 0 —— 稳态下预留不起作用(本该如此)**,水位偏离目标越多才越明显。
⇒ 判据的本质是「**连开饰品的总面值 `Np` 占累计下注 `B` 的比例**」:**`Np/B ≳ 3%~5%` 才看得出来**。
**为什么和累计下注相关(而不是只看水位)** —— 水位是**比值**、流水是**绝对额**:
`水位 = 利润 / 累计下注`,一件货(饰品价 1000、随机数 120)带来「下注 +1200、返奖 +1000、净赚 +200」,
但这 200 元对水位的推动完全取决于分母:
| 累计下注 B | 加一件货后 | 水位变化 |
|---:|---|---:|
| 10 万 | 45.00% → 44.80% | **−0.20 点** |
| 100 万 | 45.00% → 44.98% | **−0.02 点** |
⇒ 同一笔钱落进 10 倍大的池子,效果就是 1/10。
⚠️ **预留不会凭空制造推动** —— 它预估的就是这批货真实产生的交易,所以效果上限 = **这批货真出完后本来就会推动的量**,
机制只是把这份推动**提前**给第 2、第 3 个随机数看到。⇒ 「连开 N 个才有效」的门槛,本质不是预留不灵,
而是**这 N 件货本来也推不动水位**。
⇒ 所以「按水位判断」和「体量影响效果」是两件事:**水位→随机数**(判断,只看水位与 `gain`)与
**流水→水位**(记账,比例反馈天然有惯性,强依赖体量)。
| 累计下注(p = 1000) | 需要连开多少个才有效 |
|---:|---|
| 1 万 ~ 3 万 | 2 ~ 3 个 |
| 10 万 | 3 个(面值占 3%) |
| 30 万 | 10 个 |
| 100 万 | 50 个(现实中不存在) |
| 500 万以上 | 不现实 |
⇒ **一句话结论:刚上线、累计下注在十万量级以内能稳定处理;滚到百万以上基本失效。**
这是「水位是累计比例」的必然结果 —— 想在大水位下也能快速纠偏,要调 **`gain`**(水位每偏离 1 个点、
中心移动的幅度;现在默认 2,暂未在后台暴露),或者直接人工注水/抽水。
- **后台开关(el-switch)统一为「文字 = 当前值」口径,修掉反向文字露底色的误导**
**起因**:用户反馈「头像管理」的**状态**与**是否默认**两列看得费解。查下来是两个叠加问题:
1. **两边文字显示规则相反**。element-ui 的 `el-switch` 里 `active-text` 渲染在**右**侧、
且只在**开启时**显示;`inactive-text` 渲染在**左**侧、只在**关闭时**显示。
本页「状态」用的是 `inactive-text="启用"`(把当前值放在左侧),
「是否默认」用的是 `active-text="是"`(把当前值放在右侧)—— 同一张表里两套相反的读法。
2. **更糟的是反向文字会露出底色**。「状态」列开启时左侧露出绿色底 + 白字「启用」,
点一下滑块滑到左边、绿底被盖住,可读文字变成「停用」。两列的滑块方向可以完全相反,
同一行的绿白视觉还不一样(`is-checked` 与背景色是两套独立配色)。
⇒ **定的新口径(全后台统一):`inactive-text` = 关闭时的值,`active-text` = 开启时的值,
也就是「文字显示的就是当前值」,滑块停在哪边、哪边的文字就是现在的结果。**
颜色同步收敛为与 `active-value`/`inactive-value` 同侧:**绿 = 该项生效、红 = 该项未生效**。
**改动清单(7 个文件,15 处开关)**
| 文件 | 列 / 字段 | 调整 |
|---|---|---|
| `skins/ttSetting/avatar.vue` | 状态 `status` | 文字翻正(启用/停用),配色与 `active-value` 同侧 |
| `skins/ttSetting/avatar.vue` | 是否默认 `isDefault` | `active/inactive-value` 翻正为 `1/0`(= 与 `initData`、弹窗单选按钮一致),文字归右侧 |
| `skins/ttuser/index.vue` | 帐号状态 `status` | `active/inactive-value` 翻正为 `0/1`(原为 `1/0`,是全局唯一的反向异类) |
| `skins/ttuser/index.vue` | 提货状态 `deliveryStatus` | 同上翻正(`1` = 已禁用提货) |
| `skins/ttuser/index.vue` | 赠送状态 `giftStatus` | 同上翻正(`1` = 已禁用赠送) |
| `skins/ttBonus/index.vue` | 启用状态 `status` | 文字从「是/否」改为「启用/禁用」并翻正 |
| `skins/ttBox/boxType.vue` | 是否对战类型 `isFightType`、是否显示 `isShow` | label 归位;`isShow` 的 `active/inactive-value` 翻正为 `1/0` |
| `skins/ttredPacket/index.vue` | 是否启用 `useStatus` | `active/inactive-value` 翻正为 `1/0`,配色同侧 |
| `skins/ttUpgrade/index.vue` | 状态 `status` | `active/inactive-value` 翻正为 `0/1`(原为 `1/0`)+ 文字翻正 |
| `skins/ttBox/index.vue` | 弹窗内 是否对战宝箱 `isFight`、宝箱状态 `status`、是否首页推荐 `isHome` | 文字归位、`isFight`/`status` 的 `active-value` 翻正 |
**关键约束:以上全部只是「值 ↔ 显示」的映射调整,`active-value` 与 `inactive-value` 成对同翻,
提交给后端的字段值逐字节不变,数据库语义与接口契约零改动**(各页 `@change` 都是把整个 row/form
原样 `PUT` 出去,不做值转换)。
**语义校对依据**(改前逐个确认过,避免把含义改反):
- `tt_user_avatar.status` 与 `tt_user.status`:`UserStatus` 枚举 = `OK("0","正常")` / `DISABLE("1","停用")`,
`ApiUserDetailsServiceImpl` 对 `DISABLE` 直接拒登并踢线 ⇒ **`0` = 启用**。
- `tt_user_avatar.is_default`:`TtUserAvatar` 的 `@Excel(readConverterExp = "0=否,1=是")`,
且注册 / 白号 / 登录三处均查 `is_default = '1'` 取默认头像 ⇒ **`1` = 是**。
- `tt_user.delivery_status` / `gift_status`:实体注释 + 业务代码均为 **`1` = 已禁用**(`"1".equals(...)` 返回"已被禁用"提示)。
- 可对照的既有正确写法:`ttSetting/banner.vue`、`ttRechargeList/index.vue`、`ttuser/userPack.vue`。
⚠️ **踩坑**:用户截图里「是否默认」列显示为**红底白字「否」**容易被误读成"开着"。
那是 `active-color`/`inactive-color` 与 `active-text`/`inactive-text` 配成反的典型症状 ——
改完须确认**颜色所在的半边和文字所在的半边是同一半**。
⚠️ **未改(故意留着)**:`ttRoll/officialRollConfig.vue` 的状态开关用的是
`:value="scope.row.status === '0'"` + 无文字标注的正向布尔(绿=开),本身自洽;
只建议后续补一个文字标注。
- **管理后台:3 个页面表格右侧留白(「表格没有平铺完整」)**
**起因**:用户反馈「管理后台有些页面的表格没有平铺完整」。追问确认现象是
**表格右侧留一条空白**(不是列错位、也不是横向滚动条)。
**根因**(读 Element 源码定论):`element-ui/packages/table/src/table-layout.js:132-195`
的 `updateColumnsWidth()` ——
```
flexColumns = 所有「width 不是数字」的列(含显式 min-width 列、以及两者都没写的列)
if (flexColumns.length > 0 && fit) → 自动分配,表格撑满 bodyWidth (第 145 行)
else → bodyWidth = Σ(width || minWidth || 80)(第 182-194 行)
```
⇒ **只要有一列写了 `min-width`(或宽度两者都不写),表格就会自动撑满容器;
但当所有列都是固定 `width` 时,表格宽度恒等于列宽之和 —— 容器再宽也不拉伸,右侧就空一条。**
同一份源码里还有个易漏的点:`type="selection" / "expand" / "index"` 在未写 width 时,
由 `config.js:3-26` 的 `cellStarts` 给死 **48px 固定宽**,**不参与弹性分配** ——
所以「展开列 + 其余全固定宽」的表格同样会留白。
**扫描结论**(115 个顶层表格,1920 视口下容器可用 1584px):
- **仅 6 个**是全固定列(会留白);**109 个**含弹性列、会自行撑满。
- **0 个**会出横向滚动条(判据:`Σwidth + Σmin-width > 容器`)。
- 留白量**随屏幕变宽而增大**(2560 视口下最大 1854px)—— 这正是"没平铺完整"的典型特征。
**改动清单(3 个页面主表格,各把「内容最长的固定列」改为 `min-width`)**
| 文件 | 列 | 调整 | 表格宽 → 结果 |
|---|---|---|---|
| `skins/blankCardKey/index.vue` | 卡密编码 `cardCode` | `width="200"` → `min-width="200"` | 1000 → 撑满(原留白 584px) |
| `system/role/index.vue` | 权限字符 `roleKey` | `width="150"` → `min-width="150"` | 1075 → 撑满(原留白 509px) |
| `game/wheel/records/index.vue` | 投注符号 `betSymbols` | `width="140"` → `min-width="140"` | 1368 → 撑满(原留白 216px) |
选列理由:卡密编码是 32 位等宽字符 + 复制按钮、权限字符内容最长且**已带 `show-overflow-tooltip`**
(加宽无副作用)、投注符号是拼接字符串、长度波动最大 —— 都是"多给宽度只有好处"的列。
三处均加注释说明「为什么必须是 min-width」,防止后来人按"列宽统一用 width"的惯例改回去。
**未改(用户明确不动)**:3 个展开行内嵌明细表(`wheel/records` 下注明细、
`delivery/transferApply` 与 `transferApplyProcessed` 的饰品明细)与 1 个弹窗内倍率表
(`wheel/config`,固定 `width:640px`)—— 它们宽度受容器约束是设计意图。
其中 `transferApply` / `transferApplyProcessed` 声明了 `style="width: auto"` 却只有 2 列固定宽
(300+120=420),**在展开行里只占 420px**;若后续要铺满,改其中一列为 `min-width` 即可。
**验证**:
- 编译产物断言:`vue-template-compiler` 编译 3 个页面,✓ 全部通过,
`min-width` 各 1 个(200 / 150 / 140),其余列仍为固定 width。
- 算法级回归:复现 `updateColumnsWidth` 后,
**1280 / 1440 / 1600 / 1920 / 2560 五个视口下 3 个主表格留白全部归零**。
- dev server 起来后 `curl` 返回 200,确认改动未破坏编译。
- ⚠️ **本机无法真机截图核对**:助手拉起 Chrome 被安全策略拦(连带报 `wsl.exe` 在程序黑名单),
故本次验证口径是「读源码 + 复现算法 + 编译产物断言」,**未经浏览器实测**。
**纯前端改动,不涉及数据库与接口**;`dist` 未发布。
---
## 2026-09-23
- **幸运升级:随机数改由「全局水位」动态决定 + 判定层三身份隔离 / 展示层共享幸运值 + 后台注水抽水页**
> ⚠️ **后续修订(2026-09-24)**:本条的「三、水位 → 随机数的映射(波段表)」与「十、上线前必做」第 1 条
> (清空三个区间列)**已被 2026-09-24 条取代** —— 水位从「4 档波段表」简化为「单一目标水位」,
> 饰品区间从"建议清空"变为"必须配置且要包含推算中心"。其余内容(水位口径、三身份判定隔离、
> 幸运值共享展示、标定系数、注水抽水、换算关系)**全部仍然有效**。
**起因**:原来每个饰品位配一套**固定**幸运区间(如 `[100,160)`),每轮从区间里均匀抽一个随机数当作出货阈值,
于是随机数永远 ≥ 区间下限 —— 玩家能看出「每次都要投到 1 倍原价以上才出」这条硬底。
同时三个身份(普通/主播/白号)各有各的区间与进度,前端展示的「幸运值」各不相同,
看主播或白号表演的观众会发现数值对不上账。
**一、换算关系(改这个玩法的参数前必看)**
幸运区间的单位是**「饰品价的百分比」**:
| 量 | 关系 |
|---|---|
| 每拉一次消耗 | `饰品价 × probability / 100` |
| 每拉一次推进 | `eff = min(probability, 75)` 点进度 |
| 每 1 点进度的成本 | `饰品价 / 100`(`probability ≤ 75` 时严格成立) |
| 出一件货需要 | 约 `fullV` 点进度 |
| **理论 RTP** | `100 / fullV` |
| **水位** | `1 − RTP = 1 − 100/fullV` |
| 目标水位 | 目标 RTP | 名义中心随机数 |
|---:|---:|---:|
| −41% | 141% | 71 |
| −20% | 120% | 83 |
| 0% | 100% | 100 |
| 10% | 90% | 111 |
| 20% | 80% | 125 |
| **30%** | **70%** | **143** |
| 40% | 60% | 167 |
| 47% | 53% | 189 |
⚠️ **水位能到多少由随机数区间的上下界决定**:想让水位能长期停在 40% 以上,某一档的随机数上界必须 > 160。
**二、最终口径(三条,都是拍过板的)**
1. **随机数 + 判定进度:按身份完全隔离**。每个身份有自己的一份「本轮进度 + 本轮随机数」,
白号中奖**只刷新白号自己的随机数**,普通玩家的进度与随机数原样不动。
2. **水位:全局**(整个幸运升级一个水位,不按饰品位拆),且**排除白号**。
3. **幸运值:判定层隔离、展示层共享** —— 见下面第 4 条改动。
**三、水位 → 随机数的映射(波段表)**
`sys_config: luckyUpgradeWatermark` 存一张波段表,命中档后在该档区间内**均匀随机**(有 10s 内存缓存):
| 水位区间 | 可出的随机数区间 |
|---|---|
| < 10% | `[95, 130]` |
| 10% ~ 26% | `[115, 150]` |
| **26% ~ 36%** | **`[75, 175]`** ← 稳态档 |
| ≥ 36% | `[85, 128]` |
保存时后端强校验:水位档升序不重叠、`0 < vMin < vMax`、**水位越高随机数中心值必须越小**(配反直接拒绝)。
**四、名义 ≠ 实测:标定系数 k**
判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,所以**实测等效需求是名义 `fullV` 的 118%~120%**,
波段表的随机数值**不能按 `100/fullV` 公式拍**。后台自动算标定系数:
```
k = 改造后累计下注(排除白号) / Σ(round_target/100 × 目标饰品价,仅中奖记录)
预计落点水位 = (1 − 100/(k × 中心随机数)) × 100
```
数据不足时 `k = null`,后台显示「样本不足」而不是用公式瞎估;「预计落点水位」落在该档自己的水位区间内 ⇒ 该档是**稳态档**(后台标绿)。
**五、改动清单**
(SQL 见 `docs/updata_sql.md`,接口口径见 `docs/幸运升级接口文档.md`)
1. **水位**:`水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100`,统计范围**整个幸运升级**且**排除白号**。
累计量走 Redis 增量计数(`upgrade_pool:*`),DB 只用于启动初始化与后台「按历史重算」——
不在拉取热路径上对 `tt_upgrade_record` 做全表聚合。
2. **随机数**:按当前水位查波段表,命中档后在档内均匀随机;水位越高随机数越低(放水)。
`enabled=false` ⇒ 逐字节回退到「每个饰品位固定 `luck_section`」,不发版可回滚。
新增 `UpgradeWatermarkService`(水位/波段表/标定/注水抽水)+ `TtUpgradeWatermarkController`。
3. **判定层三身份完全隔离**:进度与随机数各存 Redis `upgrade_range_fixed:{饰品位}:{身份}`
(结构与改造前一致,老缓存不用迁移),白号中奖不影响普通玩家的进度与随机数。
4. **幸运值(展示)**:累加口径**保持不变** —— 按「玩家在前端拉的那个值」累加原始 `probability`;
**中奖判定恒用 `min(概率,75)`**,两者刻意分开(75% 封顶是风控手段,不改展示数字)。
**展示值是三个身份共享的一个累计器:任一身份(含白号)中奖即整体归零,且中奖那一拉的增量仍计入**
(出货那一刻显示的是含本次的值,下一拉才从 0 重新累计);表里那三列只用来**分别记录各身份的贡献**(后台排查),展示时相加。
`getOrnamentsList.luckyValue` 返回这个共享值(所以所有人看到的数一致),另给 `myLuckyValue`。
另留 `luckyValueMode` 开关(默认 `raw`,可切 `eff`),**只影响展示值与落库快照**。
5. **后台新增「幸运升级 → 奖池水位」页**(`menu_id=3208`):水位/实测RTP 统计卡、波段表编辑器(含名义RTP 与
**按实测标定的「预计落点水位」**)、注水抽水(原因必填)、调整历史、「按历史重算」。
6. 拉取记录新增 `watermark` / `round_target` 快照列,便于事后复盘「这批货是在什么水位下出的」。
**六、仿真结论(脚本 `.workbuddy/upgrade-watermark-sim.mjs`,40 万次拉取,可复跑)**
| 方案 | 名义 target | 实测 RTP | 实测水位 | 等效需求/名义 | 随机数<100 占比 |
|---|---:|---:|---:|---:|---:|
| **A** 现状:固定 `[100,160)` | 129 | 65.1% | 34.9% | 119% | **0.0%** |
| **C** 曾设想的「共享池 + 只清中奖方」(**已否决**) | 108 | **131.8%** | **−31.9%** | 71% | 19.2% |
| **F** 水位波段表(已实现) | 125 | **66.9%** | **33.1%** | 120% | **24.6%** |
三个关键结论:
- ⚠️ **「三身份共享一个池、中奖只清中奖方」会亏钱**:残留进度会垫薄下一轮阈值,非主力身份中奖时垫得最狠还会连锁
⇒ 仿真实测 **RTP 从 62% 飙到 131.8%、水位掉到 −32%**。所以**判定层必须按身份隔离**,
「一个奖池」只体现在**幸运值的展示**上。
- ⚠️ **名义 ≠ 实测**(见第四节):波段表的随机数必须按实测标定,按 `100/fullV` 公式拍一定偏。
- **稳态随机数的均值由目标水位唯一决定**(水位 30% ⟺ 平均随机数 ≈ 121),这是数学、改不掉;
能做的只是把档内区间**做宽**(推荐跨度 ≥ 80)。改造后随机数覆盖 75~175、**24.6% 的轮次低于 100**,
而 RTP 基本不变(65.1% → 66.9%)—— 玩家不再看到「必须 100 以上才出」的硬底。
**七、一个完整算例(对着数字走一遍)**
**前提**:饰品价 1000 元;波段表如上(当前落在 26~36% 档 ⇒ 随机数取自 `[75,175]`);
起始累计(排除白号)下注 100,000 / 返奖 65,000 / 调整 0 ⇒ **水位 = 35.00%**。
| 步 | 谁 | 拉取 | 扣款 | 该池随机数 | 判定(进度 + min(概率,75)) | 结果 | 该池进度 |
|---|---|---|---|---|---|---|---|
| 1 | 普通 A | 10% | 100 | 120(本池新抽) | 0 + 10 = 10 < 120 | 失败 | 10 |
| 2 | 普通 B | 50% | 500 | 120(沿用) | 10 + 50 = 60 < 120 | 失败 | 60 |
| 3 | 主播 C | 20% | 200 | **95**(主播池自己抽) | 0 + 20 = 20 < 95 | 失败 | 20 |
| 4 | 白号 D | 90% | 900 | **88**(白号池自己抽) | 0 + **75** = 75 < 88 | 失败 | 75 |
| 5 | 白号 D | 20% | 200 | 88(沿用) | 75 + 20 = 95 ≥ 88 | **中奖** | 0 → 重抽 130 |
| 6 | 普通 A | 75% | 750 | 120(沿用) | 60 + 75 = 135 ≥ 120 | **中奖** | 0 → 重抽 105 |
**第 5 步后三个池的状态**(判定层隔离:白号中奖,普通/主播一点没动)
| 池 | 本轮随机数 | 进度 |
|---|---|---|
| 普通 | 120 | 60 |
| 主播 | 95 | 20 |
| 白号 | 130(重抽) | 0 |
**第 4 步为什么要用 75 而不是 90**:白号拉 90% 时扣款按 90% 收(900 元)、判定只按 `min(90,75)=75` 加进度,
而界面幸运值按**原始概率**加(白号那份 +90)。⇒ 那一刻白号显示「幸运值 90」、本池随机数 88,看着早该中了,
但判定进度只有 75(还差 13)—— **实际没中**;下一步再拉 20%(75+20=95 ≥ 88)才中。这就是那个分叉的实际后果。
**幸运值展示 = 三个身份共享的一个累计值**(所有人看到同一个数;任一身份中奖即整体归零)
| 时点 | 普通 | 主播 | 白号 | 展示 luckyValue | 说明 |
|---|---|---|---|---|---|
| 起始 | 0 | 0 | 0 | **0** | |
| 步 1 后 | 10 | 0 | 0 | **10** | 普通 +10 |
| 步 2 后 | 60 | 0 | 0 | **60** | 普通 +50 |
| 步 3 后 | 60 | 20 | 0 | **80** | 主播**也加进同一个数** |
| 步 4 后 | 60 | 20 | 90 | **170** | 白号 +90 |
| 步 5 后 | 0 | 0 | 0 | **190** | 白号 +20 → **中奖 → 整体归零**;显示的是中奖那一刻的 190 |
| 步 6 后 | 0 | 0 | 0 | **75** | 0 + 75 → **中奖 → 整体归零** |
**水位的变化(白号的量不算)**
| 步 | 累计下注 | 累计返奖 | 水位 |
|---|---|---|---|
| 起始 | 100,000 | 65,000 | 35.00% |
| 1(普通 +100) | 100,100 | 65,000 | 35.06% |
| 2(普通 +500) | 100,600 | 65,000 | 35.39% |
| 3(主播 +200) | 100,800 | 65,000 | 35.52% |
| 4(白号 +900) | 100,800 | 65,000 | 35.52%(**不变**) |
| 5(白号 +200 且中奖 +1000) | 100,800 | 65,000 | 35.52%(**不变**) |
| 6(普通 +750 且中奖 +1000) | 101,550 | 66,000 | 35.01% |
白号的下注/返奖进另一组计数(后台「白号」那一栏),只展示、不影响水位。
**水位什么时候生效**:只在「新的一轮开始」读一次(该饰品位该身份首次拉取 / 刚中奖)。
本轮中途水位变化**不改手里的随机数**;若水位涨到 ≥ 36%,下一次**新开的一轮**才会从 `[85,128]` 里抽。
**落库记录(`tt_upgrade_record`,节选)**
| 步 | user_type | amount_consumed | amount_required | round_target | watermark | is_victory | lucky_value |
|---|---|---|---|---|---|---|---|
| 1 | 02 | 100 | 1200 | 120 | 35.00 | 0 | 10 |
| 4 | 06 | 900 | 880 | 88 | 35.52 | 0 | 170 |
| 5 | 06 | 200 | 880 | 88 | 35.52 | 1 | **190** |
| 6 | 02 | 750 | 1200 | 120 | 35.52 | 1 | **75** |
- `amount_required = 饰品价 × round_target / 100`。
- **中奖记录的 `lucky_value` 记的是「中奖那一刻的累计值」(含本次),不是 0** —— 与玩家当下看到的一致。
- **`watermark` 记的是「本次拉取之前」的水位** —— 代码先读水位、判定出货成功后才做增量记账,顺序不能反。
**八、改动文件**
后端 `ApiLuckyUpgradeServiceImpl`、`UpgradeWatermarkService`(新)、`TtUpgradeWatermarkController`(新)、
`TtUpgradeRewardLogMapper`(新) + XML、`RedisConstants`、`ApiLuckyUpgradeMapper.xml`、`TtUpgradeOrnamentsMapper.xml`、
实体 `TtUpgradeRecord` / `TtUpgradeRewardLog`(新) / `UpgradeWatermarkSettingBody`(新) / 两个 VO;
前端 `views/skins/luckyUpgrade/rewardPool/index.vue`(新) + api、`views/skins/ttUpgrade/index.vue`。
**九、验证**
`mvn -B clean install -DskipTests` 通过,且 **javap 产物确认 `doUpgrade` 只剩新签名、无旧 `(Integer[], UpgradeBodyA)` 残留**;
`ruoyi-ui` 构建 `DONE Build complete`;`audit-palette` 离版文件/色值 **0 / 0**;`verify-dist` **ALL_OK(34 项)**;
`audit-ops-fit` 需加宽 **0 条**。新页面确认已进 `dist` chunk。
算例里的 6 个幸运值(10/60/80/170/190/75)用脚本复刻改后逻辑跑过,全部一致。
**踩坑**:① 本项目是 **Spring Boot 4(jakarta)**,`@PostConstruct` 写成 `javax.` 会编译失败;
② **并行对同一文件发多个 Edit 会静默丢改动**(回执 success 但没落盘,仿真脚本主循环那次白排查一轮)⇒ 改完必须 grep 复验;
③ 15 万元素数组用 `Math.min(...arr)` 直接 `RangeError: Maximum call stack size exceeded`,大数组统计先 sort 再取分位;
④ **口径类需求别用文字对齐**:「幸运值」到底是「三身份各自累计、各清各的」还是「一个共享累计器、谁中奖都整体归零」,
文字上两句话都解释得通 —— 最后靠「第 5 步应该显示 190、第 6 步应该显示 75」这组数字才把模型逼出来
(若是各清各的,第 5 步应为 80;若不清零,第 6 步应为 265)。
**十、上线前必做**
1. 清空两个已存在饰品位的旧固定区间
(`UPDATE tt_upgrade_ornaments SET luck_section = NULL, anchor_luck_section = NULL, blank_luck_section = NULL;`)
—— 不清的话它们仍会被旧区间夹住,仍看得见「100 以上才出」的硬底;
2. 水位从 0 起步,`enabled=true` 时前几件货会落在收紧档,属预期,需要立刻放水就在新页面注水。
## 2026-09-22
- **docker 配置命名统一:`changzhou` → `henan`(全套 `henan_` 前缀)**
@@ -37,14 +847,14 @@
**关键前提**:RuoYi 的左侧菜单是**数据库驱动**的(`sys_menu` → `/getRouters` → 前端 `sidebarRouters`),**代码里没有菜单清单**,所以必须"先删数据、再删页面文件",只删前端文件的话菜单依然在。
**改动清单**(完整 SQL 与回滚语句见 `docs/updata_sql.md`):
**改动清单**(完整 SQL 见 `docs/updata_sql.md`):
1. **数据库**(库 `henan`,已执行):`sys_menu` 删 **22 行**(3 个顶级 M + 19 个子页面 C);`sys_role_menu` 删 **22 行**授权(全属 `role_id=103`)。**无 `'F'` 按钮权限行**。
- 3130 奥林匹斯 `olympus` → 子菜单 3131~3137
- 2105 荒野争锋 `bullet` → 子菜单 2106~2110、2112
- 2125 极速糖果 `sweet` → 子菜单 2126~2131
2. **前端文件**(38 个,删前已确认全部被 git 跟踪):`ruoyi-ui/src/views/game/{olympus,bullet,sweet}/`(7+6+6)与 `ruoyi-ui/src/api/game/{olympus,bullet,sweet}/`(7+6+6)。
3. **文档**:本条 + `docs/updata_sql.md` 新条目(含 44 条回滚 INSERT)。
3. **文档**:本条 + `docs/updata_sql.md` 新条目。
**验证**:
@@ -58,4 +868,154 @@
2. **数据库业务表**:`game_olympus_*` / `game_sweet_*` 等原样保留,未 TRUNCATE、未 DROP。数据还在,随时可回。
3. **`ruoyi-ui/src/views/game/sugar/`**(5 api + 5 页面)—— **没有任何菜单引用**。菜单「极速糖果」指向的是 `game/sweet/*`,而 `docs/cleanup_records.sql` 又把 `game_sugar_*` 标注为「极速糖果」,两套并存;后端还存在 `GameSugarWin/User/StepInfo/Spin` 实体。**疑似遗留或未接入的开发分支,未确认前不删。**
**遗留待定**:① 仪表盘「实时 RTP」卡片与「流水统计」折线里仍有 **玫瑰女郎(=olympus)** 一条 —— 那是后端 `/getRtpData` 返回的玩法列表,**与菜单无关**,删菜单不会让它消失;若要一并去掉得动后端。② `game/sugar/*` 是否清理待定。③ 是否需要把 `game_olympus_*` / `game_sweet_*` 业务表也清掉(现在只是入口没了,数据仍在库里占空间)。
**遗留待定**:① 仪表盘「实时 RTP」卡片与「流水统计」折线里仍有 **玫瑰女郎(=olympus)** 一条 —— **已于下一轮处理**;② `game/sugar/*` 是否清理待定。③ 是否需要把 `game_olympus_*` / `game_sweet_*` 业务表也清掉(现在只是入口没了,数据仍在库里占空间)——实查确认**不存在 `%olympus%` 业务表**,无需处理。
- **彻底清理「玫瑰女郎」(olympus)的后端:枚举、返利统计链路与前端列**
**起因**:上一轮只下架了菜单与页面,后端仍保留整套 olympus 常量、统计列与返利计算。本轮要求「和后端相关的都删掉,包括返利的统计」。
**顺手修掉一个构建阻塞**:`skins-service/pom.xml` 仍声明 `<module>game-olympus-service</module>`、`ruoyi-admin/pom.xml` 仍依赖该 artifact,而**该模块目录早已不存在** ⇒ 这是**当时 Maven 构建必挂**的隐藏故障(本次删掉引用后构建才通过)。全仓已无任何文件名含 olympus。
**改动面:2 个 pom + 21 个后端文件 + 13 个前端文件**
| 层 | 文件 | 改动 |
|---|---|---|
| pom | `skins-service/pom.xml`、`ruoyi-admin/pom.xml` | 删 `game-olympus-service` 的 module / dependency |
| 常量 | `TtAccountRecordSource.java` | 删 `OLYMPUS_SPIN(60)` / `OLYMPUS_REWARD(61)` 枚举项,并从 **6 处**集合与判断中移除(推广福利流水、返利游戏类型、今日投注、今日赢奖、流水抽奖条件);删 `isSpecialGameConsume` / `isNewGameConsumeForCommission`,`isGameConsumeForCommission` 简化为只判经典 |
| 常量 | `TtboxRecordSource.java` | 删 `OLYMPUS_BOX(13)` |
| 常量 | `MessageCategoryEnum.java` | 删 `GAME_OLYMPUS` |
| 常量 | `RedisConstants.java` | 删 3 个 `OLYMPUS_*` 缓存键 |
| 配置 | `ParameterSettingBody.java`、`WebsiteSetupServiceImpl.java` | 删 `olympusMaintenance` 字段与读写(数据库侧见 `updata_sql.md`) |
| 仪表盘 | `TtRechargeRecordController.java` | 折线 `chartData` 整条 `roseladyList` 链删除;实时 RTP 的 `gameConfig` / `gameNames` 去掉第 8 项(**保留索引 5 = Roll 房**,`if (i == 5)` 依赖它) |
| 用户报表 | `TtUserBlendErcashMapper.xml`、`TtUserSourceReportMapper.xml`、`TtUserAmountRecordsController.java` | 删 `olympusGame` 列 ×2 + 排序分支;总流水 source 列表去 60 |
| 开箱统计 | `TtBoxRecordsMapper.xml` | 返奖总额 `source IN (...)` 去 13 |
| 返利 | `PromoTurnoverMapper.xml` | 删 `todayOlympus` / `yesterdayOlympus` / `weekOlympus` / `totalOlympus` 四列、`todayNewGame` / `yesterdayNewGame`、`newGameConsume` ×2,source 列表去 60 |
| 返利 | `PromoTurnoverMapper.java` | 删 `getSubNewGameConsumeByDate` / `getSpecialGameConsumeByUserId` 两个专属查询 |
| 返利 | `PromoTurnoverServiceImpl.java` | 看板 `buildPeriod` 去掉 olympus 周期项与 4 个调用点参数;下级佣金去掉新游戏分项 |
| 返利 | `PromoTurnoverController.java` | 三处 `olympusConsume` 统计链删除;`businessGameRate` 统一取经典费率 |
| 返利 | `PromoAdminManageController.java` | 删 `specialGameTurnover` 输出与 `sumNewGameConsume` |
| 返利 | `CommissionSettlementTask.java` | **周结佣金**去掉新游戏分项 |
| 其他 | `TtUserServiceImpl.java` | 实时返佣分支去掉「新游戏」,只判经典 |
| 其他 | `ApiUserPackSackServiceImpl.java`、`ApiProfileRecordsServiceImpl.java`、`TaskUpdate.java`、`TtBoxServiceImpl.java` | 映射 / 筛选集合 / 发奖公式 / 注释 |
| 前端 ruoyi-ui | `utils/accountSource.js`、`ttSetting/index.vue`、`ttuser/{userTurnover,userPack,creditsRecords,amountRecords,blendErcashArchive}.vue` | 删流水来源码 60/61 与 13 的字典与筛选项、删「玫瑰女郎维护」开关、删用户流水表的 `olympusGame` 列与排序标签 |
| 前端 promo-ui2 | `Home`、`FlowRecord`、`BusinessManage`、`AnchorManage`、`subFlow/transaction`、`CommissionReport` | 删费率卡 / 周期列 / 玫瑰女郎列 ×3 / 特殊游戏流水列 ×2 / 日特殊游戏消费列 / 来源码字典 |
**验证**(产物级断言,不是看代码):
- 后端 `mvn -B clean install -DskipTests` → **`BUILD SUCCESS`**,`Total time: 04:13 min`,无 `BUILD FAILURE` / `COMPILATION ERROR`;`ruoyi-admin.jar` 正常产出(6 个模块全链打通)。
- `target/classes` 里 5 个关键 class(`TtAccountRecordSource` / `TtboxRecordSource` / `MessageCategoryEnum` / `RedisConstants` / `PromoTurnoverServiceImpl`)搜 `OLYMPUS` **命中 = 0**。
- `target` 中 3 个 mapper XML 搜 `olympus` / `newGame` / `source 60` **命中全 0**。
- 数据库:`sys_config` 里 `%olympus%` 残留 **= 0**;一级菜单 20 个(与上轮一致)。
- `ruoyi-ui` 生产构建通过;`promo-ui2` 因 `node_modules` 缺 `esbuild` 无法构建(环境问题,非本次改动),改做静态校验。
**刻意保留**:
1. **历史数据**(`tt_user_blend_ercash` 47 行、`tt_box_records` 293 行、`tt_user_source_report` 14 行)—— 属账目,未删。副作用:流水来源码字典删了,这些历史流水在后台显示不出中文名。
2. `ruoyi-ui/src/views/index.vue` 的 `hidden` 数组仍留「玫瑰女郎」、`.rtp-theme-4` 样式保留 —— 后端已不再返回,留作**二层防护**。
3. **`newGameCommissionRate`(新游戏返佣比例)字段保留** —— 它挂在 `tt_user` 表上,属**用户配置字段**而非统计,删除涉及 DDL 与用户管理页,且语义上不点名玫瑰女郎。现状:无任何消费来源指向它,相关分项恒为 0。
- **新增「麻将胡了」玩法:以「新游戏」类型接入,复用原玫瑰女郎的整套统计口径**
**起因**:玫瑰女郎下架后,用户引入新游戏「麻将胡了」(后端 `mahjong` 玩法),要求「以新游戏的类型把相应的统计都加上」。
本轮是**上一轮删除动作的完整镜像** —— 把上一轮删掉的 olympus 分支,按 mahjong 重新接回全部统计链路。
**口径映射**(沿用原语义,未改结构):
| 概念 | olympus 旧值 | mahjong 新值 |
|---|---|---|
| 消费来源码 | `OLYMPUS_SPIN = 60` | `MAHJONG_SPIN = 68` |
| 奖励来源码 | `OLYMPUS_REWARD = 61` | `MAHJONG_REWARD = 69` |
| 开箱产出码 | `TtboxRecordSource` 13 | `MAHJONG_BOX = 15` |
| 新游戏返佣费率 | `tt_user.newGameCommissionRate` | **同名沿用**(未动 DDL) |
| 返佣流水来源 | `GAME_CONSUME_COMMISSION(47)`「特殊游戏返利」 | 同 |
**改动清单**(后端 21 个文件 + 前端 7 个 + DB 3 张表):
| 层 | 文件 | 改了什么 |
|---|---|---|
| 枚举 | `TtAccountRecordSource` / `TtboxRecordSource` / `MessageCategoryEnum` / `RedisConstants` | 新增 68 / 69 / 15 及消息分类、缓存键 |
| 仪表盘 | `TtRechargeRecordController` | `chartData` 加 `mahjongList`;`realTimeRtp` 加第 8 项「麻将胡了」(**追加在末尾,不动 `i == 5` 的 Roll 房下标**) |
| 开箱统计 | `TtBoxRecordsMapper.xml` | 3 处返奖口径 `source IN` 加 `15` |
| 用户报表 | `TtUserBlendErcashMapper.xml` / `TtUserSourceReportMapper.xml` / `TtUserAmountRecordsController` | 加 `mahjongGame` 列 ×2 + 排序分支 + `AGG_SORT_FIELDS` |
| 返利 | `PromoTurnoverMapper.xml` | `getBatchedCommissionData` / `getGameCommissionData` / `getWeeklyCommissionDataWithSnapshot` / `getWeeklyDailyCommissionData` / `businessGameConsumeRows` 全部加 68 与 newGame/mahjong 列 |
| 返利 | `PromoTurnoverServiceImpl` / `PromoTurnoverController` / `PromoAdminManageController` / `CommissionSettlementTask` | 看板费率卡与周期项、三处 `*Consume` 统计链、`businessGameRate` 新游戏分支、`specialGameTurnover`、`sumNewGameConsume`、周结分项 |
| 其他 | `TtUserServiceImpl`(实时返佣新游戏分支)、`ApiProfileRecordsServiceImpl`(69 / 68)、`ApiUserPackSackServiceImpl`(15 → 69 映射)、`TaskUpdate`(特殊游戏消费 + 0.01 系数)、`TtBoxServiceImpl`(注释) | |
| 配置 | `ParameterSettingBody` + `WebsiteSetupServiceImpl` | `mahjongMaintenance` 读写(键 `mahjongMaintenance`,兜底 `"0"`) |
| 前端 ruoyi-ui | `views/index.vue`(折线加「麻将胡了流水」)、`utils/accountSource.js`(68 / 69)、`skins/ttSetting/index.vue`(麻将胡了维护)、`skins/ttuser/{amountRecords,creditsRecords,blendErcashArchive,userPack,userTurnover}` | 来源码字典与筛选 68 / 69、开箱码 15、`mahjongGame` 列与排序标签 |
| 前端 promo-ui2 | `Home`(费率卡 + 周期列)、`FlowRecord`(麻将胡了列 ×2)、`BusinessManage` / `AnchorManage`(特殊游戏流水列)、`subFlow/transaction`(日特殊游戏消费)、`CommissionReport`(68 → 麻将胡了) | |
**数据库**:`sys_menu` +8(麻将胡了目录 `3200` + 7 子页)、`sys_role_menu` +8(role 103)、
`sys_config` +1(`mahjongMaintenance`)。完整 SQL 见 `docs/updata_sql.md`。
**验证**(产物级断言):
- 后端 `mvn -B clean install -DskipTests` → **`BUILD SUCCESS`**,19 个模块全过,`Total time: 03:55 min`。
- `ruoyi-ui` 生产构建 → `DONE Build complete`。
- `promo-ui2` 生产构建 → **本轮把环境修好了**,构建通过(修法见「踩坑」)。
- 数据库实测:`sys_menu` 3200~3207 = 8 行、`sys_role_menu` = 8 行、`sys_config.mahjongMaintenance` = 1 行。
**踩坑(记一次,下次别重踩)**:`promo-ui2/node_modules` 是**残缺的扁平布局** —— 绝大多数包只在
`node_modules/.pnpm/node_modules/` 里有,顶层没有软链 ⇒ vite 一启动就 `ERR_MODULE_NOT_FOUND`
(先 `esbuild`、再 `@rollup/rollup-win32-x64-msvc`、再 `@babel/core`…… 逐个补是打地鼠)。
**一次性修法**:把 `.pnpm/node_modules` 下**所有**包(含 `@scope` 子目录)在顶层批量建 junction
(本次补了 800+ 个链接)后构建即通过。根因是当初 `pnpm install` 没跑完/被裁过,属**环境问题、非代码问题**。
**刻意未做**:麻将胡了的 **11 张 `game_mahjong_*` 游戏表尚未落库**(DDL + 种子在 `mahjong-py/schema.sql`)。
这 7 个新菜单页面依赖它们,未建表前打开会报「表不存在」。属「游戏本体」前置依赖、且 generator 还没写完
(见 `mahjong-py/README.md`),**待用户确认后再执行**。
- **后台管理端(`ruoyi-ui`)整站换肤:Cottagecore 田园核风 + 逐页 UI 细节收敛**
**起因**:管理后台长期沿用它默认的企业蓝(Element 默认色板 + vue-element-admin / AdminLTE 遗留色),与河南项目的
田园调性不符;用户要求按 Cottagecore 规范整站重做,并**逐页检查小问题、把 UI 细节调到位**。
**做法:不引 Tailwind,走「Element 变量 + 皮肤覆盖层」**
(`ruoyi-ui` 是 Vue2 + Element UI 2.15 + vue-cli4/webpack4/postcss7,Tailwind 的 preflight 会打乱 Element 基础样式)。
新建 **`src/assets/styles/cottagecore.scss` 作为全站唯一色源**,其余文件一律引用变量、不写死十六进制;
再通过重编译 `element-ui` 的 theme-chalk 源码(`element-variables.scss`)换掉 Element 自己的底色。
| 文件 | 角色 |
|---|---|
| `assets/styles/cottagecore.scss` | **唯一色源**:品牌色 + 组件槽位色 + 图标色相 + 对比度实测注释 |
| `assets/styles/element-variables.scss` | 覆盖 `$--color-*` / 圆角 / 阴影 / `$--color-white` → 暖白 `#fffdfa`,再 `@import` theme-chalk 源码整包编译 |
| `assets/styles/skin.scss` | 皮肤覆盖层(按钮 / 表单 / 表格 / 分页 / 弹层 / Element 写死的冷色) |
| `assets/styles/index.scss` | 总入口,**最后一行 `@import './skin.scss'`**(整体回滚 = 注释这一行) |
| `assets/styles/sidebar.scss` / `variables.scss` | 侧栏(4 色图标轮换)/ 菜单深浅两套色 + `:export` 给 js |
**六轮递进,要点**:
1. **建色源 + 换 Element 底色**:primary 定陶土 `#9a5c35`(**不用草绿**,否则「新增 / 修改」同色系分不清;
想切回只改色源一处),success 草绿 `#457450`,warning 深雏菊金 `#8f6418`,danger 莓红 `#a4485a`,info 暖褐 `#6b6152`。
正文 `#5f4f3d`(**`#8b7355` 只有 4.17:1,禁止做正文**);草绿 `#5a8f5a` 白字仅 3.81:1 ⇒ **只做装饰与大色块**,
绿色文字一律 `#3f6b45`。
2. **摘掉隐藏的 ThemePicker**:`App.vue` 曾无条件挂 `<theme-picker>`(只用 CSS `display:none` 藏,组件照样 mount),
它在 `created()` 里会去 `unpkg.com` 拉整包 Element 默认主题并注入 `<style>` **把配色改回企业蓝**。已移除,别再挂回去。
3. **全局色板机械清扫**(45 个文件):把散在业务页里的旧 Element 色(`#409eff` / `#67c23a` / `#e6a23c` / `#f56c6c` / `#909399` …)
按语义映射到色源;同时改掉 `Logo.vue` / `Navbar.vue` 的白色毛玻璃 + 蓝阴影、全局滚动条的冷蓝。
4. **顶栏与标签页合并成一条**,去掉重复显示的面包屑。
5. **Element 内部写死的冷色**(hex 搜索抓不到、只能用更高权重规则盖):白遮罩、蓝色 info toast、
冷绿上传成功勾 `#13ce66`(**它的选择器是带父级的三段式 0,2,0,单类选择器会被压住**)、冷灰滚动条 / 标签 / 日期面板。
根因是 `$--color-white` → 把**暖白 `#fffdfa`** 提为「白」,一次性统一所有白色面。
6. **操作列按钮折行**(用户截图里那类问题)——根因不是列宽不够,而是 `element-ui.scss` 里一条全局规则
`.fixed-width .el-button--mini { width: 60px }` 把按钮钉死 60px,`图标 + 2~4 个中文` 直接被截断换行。
删掉该规则 + 给 19 个操作列补显式宽度(≥3 个按钮的收进「更多」下拉)+ 补下拉与前钮间距。
**验证(三层,脚本留在 `.workbuddy/`,可重跑)**:
- `node .workbuddy/audit-palette.cjs` —— 源码级色板审计(`.vue` + `.scss` + `.css`,**先剥注释再扫**):
当前 **0 / 184 文件离版**。色板白名单里另有一类**刻意保留的域内语义色**:
CS2 稀有度、麻将/饰品品质色谱(金/红/紫)、仪表盘折线色 —— **不是主题色,不许改**。
- `node .workbuddy/verify-dist.cjs` —— 构建产物断言,**34 项 ALL_OK**:① 本项目源码的冷色必须为 0;
② 新增规则必须存在;③ 中性色(`rgba(0,0,0,*)`)设「冻结基线」上限防复发。
**扫描范围是 app.css + 全部 `chunk-*.css`**(views/* 是懒加载,只看 app.css 会整片漏掉)。
- `node .workbuddy/verify-computed.cjs` —— **真机 Chrome 计算样式断言,20 项 ALL_OK**。
专治 element-ui 内部字面量色:这些色值**在样式表文本里去不掉**,`grep = 0` 不成立,必须看计算值。
- 构建:`DONE Build complete`,零 Sass 错误;仅剩既有的 `delLevel` 缺失导出 + 体积警告,与本改动无关。
**踩坑**:并行对**同一文件**发多个 Edit 会撞 `EBUSY`;更阴的是**偶发不报错、但两次写入只剩一次**(本次 `sidebar.scss` 丢过两个替换)
⇒ **机械替换一律串行,改完用 grep 逐条复验**,别信 Edit 的 success 回执。
**刻意未做**:① `variables.scss` 里 `sideTheme: theme-dark` 的深色侧栏回退色(冷蓝灰)保留 ——
设置抽屉里的「深色」按钮现在点下去视觉上是空操作(皮肤层已把侧栏壳设成透明 + 文字 `!important`),
**没有删按钮也没改回退色**,要不要彻底下掉这个开关待定;② `btn.scss` 的 `.blue-btn` 等 7 个类全项目 0 引用,
但其颜色已随 `variables.scss` 指向新色板,**不构成视觉问题,未清理**。
+156 -31
View File
@@ -1,6 +1,12 @@
# 幸运升级接口文档(Cocos 客户端对接)
> 适用版本:当前 master。所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
> 适用版本:当前 master。
> **2026-09-23 起随机数改由「全局水位」动态决定**(不再是每个饰品位的固定幸运区间);三身份的进度与随机数**完全隔离**;
> 幸运值展示值改为三身份合计(累加口径仍按玩家在前端拉的值,**中奖判定恒用 `min(概率,75)`**)。
> **2026-09-24 起水位配置简化为「只填一个目标水位(%)」** —— 随机数中心由它自动推算,
> 取值范围仍由每个饰品自己的区间限定(见 §1.2 / §1.3 / §6.4)。
> 方案、换算表、口径推导与算例见 `docs/修改记录.md` 的 2026-09-24 条(前情见 2026-09-23 条);数据库变更见 `docs/updata_sql.md`。
> 所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
> 代码位置:`skins-service/service-playingmethod/.../ApiLuckyUpgradeController.java`、`ApiLuckyUpgradeServiceImpl.java`、`ApiUpgradeRecordServiceImpl.java`。
---
@@ -15,8 +21,8 @@
↓ 校验(身份 / 封禁 / 删除 / 未充值 703 / 余额 702)
↓ 加锁(饰品位 + 奖池类型)→ 扣款(source=40 幸运升级消费)
↓ 判定(见 1.2)
├─ 成功:获得该饰品位对应的整件饰品,进度与幸运值清 0
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),进度与幸运值累加
├─ 成功:获得该饰品位对应的整件饰品,**该身份**的进度与随机数清 0 / 重抽(其他身份不受影响)
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),**该身份**的进度累加
↓ 写 tt_upgrade_record 记录(含各项快照)+ 背包 tt_box_records(source=4 幸运升级)+ 站内消息
↓ 返回升级记录(含 gainOrnamentList,可用于结果页立即分解)
```
@@ -27,23 +33,72 @@
|---|---|
| 单次消耗 | `price = 饰品实时价(tt_ornament.use_price) × probability / 100`,保留 2 位小数(**前端不传价格**,由后端算,防 0 元 100% 中奖) |
| 判定概率 | `eff = min(probability, 75)`(**封顶 75**:选 90% 也只按 75% 判定,但钱按 90% 收) |
| 本轮空间 | `fullV`:从该饰品位对应奖池的幸运区间 `[min, max)` 里随机取 `nextInt(max-min) + min` |
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后 `fullV` 重新随机、进度清 0;否则失败继续累加 |
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**。所以幸运区间 = 该池的"售价百分比区间":`[50,250]` 表示平均要付 50%~250% 原价才出;压到个位数(如 `[9,10]`)等于必中、平台必亏 |
| 本轮空间 | `fullV`:由**当前全局水位**自动推算 —— 先按目标水位反推中心,再在该饰品自己的区间 `[min, max]` 内做**偏斜随机**(支撑集是整个区间,水位高时下限附近的数更频繁)。水位见 1.3 |
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后**只刷新当前身份**的 `fullV`、该身份进度清 0;**其他身份的进度与随机数一律不受影响**;否则失败继续累加 |
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**,即 `理论 RTP = 100 / fullV`、`水位 = 1 − 100/fullV`。⇒ 想让水位能长期停在某个值,饰品的随机数区间必须**包含**该水位对应的中心随机数 |
### 1.3 幸运值与幸运区间:三套独立奖池
> ⚠️ **名义 ≠ 实测**:判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,实测等效需求是名义值的
> **118%~120%**。所以**不要按 `100/fullV` 公式拍参数**,按后台「奖池水位」页的实测 RTP 与标定系数来调。
`幸运值` = 该池"累计原始概率",失败累加 `probability`(原始值,不是封顶后的 75)、中奖清 0;界面与记录里展示的就是它。
### 1.3 幸运值、水位与三个身份池
| 奖池 | 判定/显示用 userType | 幸运区间字段 | 幸运值字段 | 本轮已投金额字段 |
|---|---|---|---|---|
| 普通用户 | `02` | `tt_upgrade_ornaments.luck_section` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
| 主播 | `01` | `anchor_luck_section` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_luck_section`(未配置回退 `luck_section`) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
**水位(全局,2026-09-23 起)**:
- 三池**完全隔离**:进度缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}` / `upgrade_range:{饰品位}:{池类型}`,互不影响、互不阻塞(锁也是按「饰品位 + 池类型」)。
- 同一池内是**共享进度**:同一个池子的玩家一起把进度推到 `fullV`,谁那一拉跨过阈值谁中奖(和普通/主播现有行为一致,白号同理,白号之间共享 06 池)。
- 白号记录里的幸运值/已投金额读的都是白号自己的池,不再是真实玩家池的读数。
```
水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100 # 统计范围 = 整个幸运升级,排除白号
```
水位越高说明平台已经净赚越多 → 本轮可出的随机数越低(放水);水位不足 → 随机数越高(收紧)。
后台只配一个**目标水位 T(%)**,随机数中心由它自动反推:
```
V* = 100 / ((1 − T/100) × k) k = 实测标定系数(1.18~1.20,样本不足按 1.19)
中心 = V* × (1 − (当前水位 − T)/100 × gain) gain 默认 2
t = (中心 − min) / (max − min) 中心在区间内的相对位置,夹到 [0.10, 0.90]
V = min + (max − min) × u^p, p = 1/t − 1 u ~ U(0,1)
```
**整个区间始终都可能出**,水位只改变概率重心:水位 = 目标时 `p = 1`(区间内均匀分布);
水位高于目标 ⇒ `p > 1` ⇒ 下限附近更频繁;低于目标则相反。
`E[V] = 中心` ⇒ 水位会**长期自动收敛回目标值**(仿真实测偏差 < 0.05 个点)。
⚠️ `t` 的夹取范围意味着**中心的可表达范围 = min + [10%, 90%] × 区间宽度**:
区间太窄或下限太高时,高水位目标就实现不了(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
**虚拟水位(抽随机数实际用的是它,不是上面那个真实水位)**:真实水位只反映**已经结算**的下注/返奖,
一批饰品同时开新轮时若都用同一个真实水位,会连着出好几个同侧偏斜的随机数。所以每抽一个随机数,
就把这一轮的预估交易预留进 pending:
```
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
pending_bet += 饰品价 × k × 随机数 / 100 pending_win += 饰品价
```
出货过程中产生的实际流水会**逐步把预留扣回去**(两边口径一致 ⇒ 真正出货时归零,不会长期挂账)。
白号轮次不进水位口径,不参与预留;未抽过数时 pending = 0,退化为真实水位。
水位从 0 起步,可用后台「幸运升级 → 奖池水位」人工注水/抽水。策略与人工调整见 §6.4。
**三个身份池**:
| 池 | userType | 幸运值字段(累计进度) | 本轮已投金额字段 |
|---|---|---|---|
| 普通用户 | `02` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
| 主播 | `01` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
- 三池**完全隔离**:进度与随机数缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}`,锁也是按「饰品位 + 池类型」。
**白号中奖只刷新白号自己的随机数**;普通玩家的进度和随机数原样保留 —— 一个奖池但不会互相污染。
- **随机数的中心由全局水位决定,取值范围由每个饰品自己的区间限定**:`luck_section` /
`anchor_luck_section` / `blank_luck_section` 三列仍按身份分别生效,但语义从改造前的「固定随机数区间」
变为「**允许的取值范围**」—— 区间要**包含水位推算出的中心值**,否则中心被夹在边界上、水位会停在别处。
策略关闭(`enabled=false`)时这三列恢复成改造前的「固定随机数区间」用法。
- **幸运值的累加口径**:默认按「玩家在前端拉的那个值」累加(原始 `probability`,选 90% 就 `+90`),与历史记录口径一致;
**中奖判定固定用 `min(probability, 75)`** —— 两者刻意分开(75% 封顶是风控手段,不改展示数字)。
后台开关 `luckyValueMode` 可切成 `eff`(按 `min(概率,75)` 累加),**只影响展示值与落库快照**。
- **幸运值 = 三个身份共享的一个累计器**:`getOrnamentsList.luckyValue` 就是这个值,所以所有人看到的数完全一致;
**任一身份(含白号)中奖都把它整体归零**,且**中奖那一拉的增量仍计入**(出货那一刻看到的是含本次的数,下一拉才从 0 重新累计)。
⚠️ 这与「判定进度」不是一回事:**判定进度与随机数按身份完全独立**,白号中奖不会动普通玩家的进度。
另有 `myLuckyValue` = 查看者所属身份的贡献(自上次任一身份中奖以来),排查用。
### 1.4 获得与失败奖励
@@ -132,7 +187,8 @@
| usePrice | BigDecimal | 实时价格(= 计算消耗、展示目标金额的基准) |
| imageUrl | String | 饰品图片 |
| shortName / typeName / rarityName / exteriorName / levelImg | String | 简称 / 类型 / 稀有度 / 磨损度 / 品质图 |
| luckyValue | Integer | **当前幸运值**(按登录身份取对应池:普通/主播/白号) |
| luckyValue | Integer | **当前幸运值**(三个身份共享的一个累计值,所有人看到的数一致);口径见 1.3。**任一身份中奖即整体归零** |
| myLuckyValue | Integer | 查看者所属身份对共享值的贡献(自上次任一身份中奖以来);排查用,一般不用 |
### 4.2 `POST /api/luckyUpgrade/upgrade` — 拉取一次
@@ -210,7 +266,7 @@
|---|---|---|
| userId / avatar / nickName / vipLevel | - | 玩家信息(昵称是升级时快照) |
| probability | Integer | 成功率(原始概率,如 89 表示 89%) |
| luckyValue | Integer | 幸运值快照(按该玩家所属池;白号是白号池的值) |
| luckyValue | Integer | 幸运值快照(**中奖记录记的是「中奖那一刻」的值,含本次**),与 `getOrnamentsList.luckyValue` 同口径 |
| amountConsumed | BigDecimal | 消耗 |
| gainOrnamentsPrice | BigDecimal | 获得饰品总价值 |
| isVictory | Boolean | 成败:true=升级成功(前端胜负样式用) |
@@ -240,7 +296,7 @@
|---|---|---|
| avatar / nickName / vipLevel | - | 拉取人信息 |
| probability | Integer | 本次拉取**选择的成功率**(不是中奖率) |
| luckyValue | Integer | 本次拉取时的**幸运值快照**(累计原始概率;成功为 0),与 `recordPage` 同口径 |
| luckyValue | Integer | 本次拉取时的**幸运值快照**(含本次的增量;中奖那一拉记的就是出货那一刻的值),与 `recordPage` 同口径 |
| isVictory | Boolean | 成败:true=升级成功 |
| name / imageUrl / usePrice / exteriorName / levelImg | - | **目标饰品**("谁在拉这个饰品",不是获得物) |
| openTime | String | 拉取时间 |
@@ -291,8 +347,8 @@
1. **upgradeId 语义**:所有接口里的 `upgradeId` / `recentPulls.upgradeRecordId` 都是**可升级饰品ID**(`tt_upgrade_ornaments.id`),来自 `getOrnamentsList` 的 `id`,不是 `tt_ornament.id`,也不是升级记录ID。
2. **扣款以服务端为准**:不要传价格;展示价格用 `usePrice × probability / 100`(与后端同公式,后端保留 2 位小数)。
3. **概率封顶 75**:UI 上若提供 >75% 的档位,要提示"更高概率只加钱、不提升判定"。
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`;拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
5. **分池**:幸运值/幸运区间按 普通(02) / 主播(01) / 白号(06) 三套独立,白号看到的是白号池的值;同一池内进度是共享的,别人垫的进度也算在你头上。
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`(**三个身份合计的全服累计值,所有人一致**);拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
5. **分池与水位**:进度与随机数按 普通(02) / 主播(01) / 白号(06) 三套**完全独立** —— 别人(含白号)中奖**不会**动你的进度和随机数。随机数的**中心**由全局水位统一决定(水位越高越容易出货),**取值范围**仍由该饰品自己的随机数区间限定。
6. **分页**:`recordPage`/`myHistoryRecord` 的 `size` 上限 20;切换 tab(我的/全服、当前饰品/全部饰品)时 `page` 重置为 1。
7. **失败记录展示**:以 `isVictory` 判断胜负,`name/imageUrl/...` 是"实际获得物"(失败=安慰奖);无安慰奖时金额为 0。
8. **结果页分解**:`gainOrnamentList` 里的 `id` 就是分解入参;不调用 = 饰品留在背包。
@@ -306,16 +362,20 @@
| 后台列 | 字段 | 说明 |
|---|---|---|
| 普通用户 / 幸运区间 | `luck_section` | 普通用户池的 `[min, max)`,即"售价百分比区间" |
| 普通用户 / 随机数区间 | `luck_section` | **随机数的允许取值范围**(`[min,max]` JSON),水位推算出的中心必须落在里面 |
| 普通用户 / 本轮已投金额 | `amount_invested` | 普通池当前累计投入(运行时值,后台不可编辑回写) |
| 主播用户 / 幸运区间 | `anchor_luck_section` | 主播池区间 |
| 白号用户 / 幸运区间 | `blank_luck_section` | 白号池区间;为空则回退 `luck_section` |
| 白号用户 / 本轮已投金额 | `blank_amount_invested` | 白号池当前累计投入 |
| 主播用户 / 随机数区间 | `anchor_luck_section` | 同上,主播身份独立配置 |
| 白号用户 / 随机数区间 | `blank_luck_section` | 同上,白号身份独立配置;未配则回退 `luck_section` |
| 状态 | `status` | 0=启用(=可升级、会出现在客户端列表) 1=禁用 |
- 调难度就调"幸运区间":区间 = 玩家为该饰品付出的**价格百分比**。想更容易出就整体下调(如 `[80,280]`→`[60,200]`);**不要压到个位数**(如 `[9,10]` 等于必中,等于按 9% 价格卖货),也不要配成 `min == max`(会被后端防护强制改成 `[0,160]`)。
- **改完区间要清缓存才立即生效**:`DEL upgrade_range:{饰品位}:{池}` + `DEL upgrade_range_fixed:{饰品位}:{池}`(池=01/02/06)。不清的话当前这一轮仍按旧空间跑,等本轮中奖后自动按新配置重建。Redis 实测在 db0,密码见 `application-*.yml`。
- 白号是独立奖池,可与普通/主播配不同的区间;「批量填货」新增饰品位时白号区间默认取普通用户的默认值。
- **随机数的中心不在这里配**:中心由「目标水位」自动推算(页面顶部水位条显示 当前水位 / 目标水位 /
实测 RTP / 推算随机数中心),这里只配**取值范围**。调难度请改目标水位(见 6.4)。
- ⚠️ **区间必须包含推算出的中心值**,且**中心离两端各留约 10% 区间宽度**,否则中心被夹住、水位会停在附近
而不是目标值(仿真实测可偏 16~20 个点)。区间还**限定了能维持的水位范围**:`[100,160]` 只能维持约 `[20.7%, 45.4%]`。
- **整个区间始终都可能出**(偏斜随机),水位只改变概率重心;水位 = 目标时是均匀分布。
区间越宽,单轮随机数波动越大、越难被玩家归纳出规律。
- 区间改完**下一轮立即生效**,不用清 Redis(水位驱动下直接读实体字段,不走区间缓存)。
- ⚠️ 旧版本文档里「上线前清空三个区间列」的做法**已作废** —— 现在这三列是必填项。
### 6.2 「幸运升级 → 失败奖励」(`tt_upgrade_fail_ornaments`)
@@ -328,13 +388,78 @@
| `websiteMaintenance` | `1` | 全站维护:`upgrade` 与分解接口都拦截 |
| `luckyUpgradeMaintenance` | `1` | 只拦幸运升级:`upgrade` 返回「幸运升级维护中......」 |
### 6.4 数据落库
### 6.4 「幸运升级 → 奖池水位」(目标水位 / 注水抽水)—— 后台接口
Base = `/admin/upgradeWatermark`,均需后台登录。
| 接口 | 说明 |
|---|---|
| `GET /info` | 当前水位快照 + 目标水位策略 + 按目标水位推算的随机数中心 |
| `POST /setting` | 保存策略:`targetWatermark`(必填)+ `gain`(可选,默认 2)+ `enabled` + `luckyValueMode` |
| `POST /adjust` | 注水 / 抽水(`type` 1注水 2抽水;调整原因必填) |
| `POST /adjustReset` | 重置人工调整(插入抵消日志,历史保留) |
| `GET /adjustLog` | 调整历史分页 |
| `POST /recalc` | 按历史流水全量重算累计量 + 刷新标定系数(纠正 Redis 计数漂移) |
`/info` 返回(节选):
| 字段 | 说明 |
|---|---|
| `enabled` | 策略开关;`false` = 回退到每个饰品位的固定随机数区间 |
| `targetWatermark` | **目标水位(%)** —— 后台唯一要配的运营参数 |
| `gain` | 响应灵敏度(水位每偏离目标 1 个点,随机数中心反向调整 gain%),默认 2 |
| `watermark` / `rtp` | 当前(真实)水位% / 实测 RTP%(均排除白号) |
| `watermarkWithPending` | **虚拟水位** —— 抽随机数实际用的那个;与 `watermark` 的差 = 已抽走未出货轮次的影响 |
| `pendingBet` / `pendingWin` | 已抽走随机数、还没出货的那些轮次的剩余预估下注 / 返奖 |
| `targetGap` | 当前水位 − 目标水位(正 = 赚得比目标多 ⇒ 下一轮放水) |
| `targetCenter` | 按目标水位推算的随机数中心 `V*`(**饰品区间要包含它**) |
| `centerNow` | 下一轮新开会用到的随机数中心(按**虚拟水位**算,与实际抽数口径一致) |
| `calibration` | 实测标定系数 k;样本不足时为 `null` |
| `effectiveCalibration` | 实际参与推算的系数(样本不足时 = 1.19 兜底) |
| `totalBet` / `totalWin` / `blankBet` / `blankWin` / `adjust` | 水位口径的累计量(白号不进水位,仅并列展示) |
**策略配置(`sys_config: luckyUpgradeWatermark`)**:
```json
{
"enabled": true,
"targetWatermark": 30,
"gain": 2,
"luckyValueMode": "raw"
}
```
| 字段 | 说明 |
|---|---|
| `enabled` | `false` = 逐字节回退改造前的固定幸运区间行为,不发版即可回滚 |
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70%。合法范围 −50 ~ 95 |
| `gain` | 响应灵敏度,默认 2:水位每高出目标 1 个点,随机数中心降 2%(放水);低于目标则反之 |
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加原始 `probability`)/ `eff`(累加 `min(概率,75)`)。**不影响中奖判定** |
**目标水位 → 随机数中心换算表**(按 `k = 1.19` 兜底系数算):
| 目标水位 | 0% | 10% | 20% | **30%** | 40% | 45% | 50% | 60% |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 中心 `V*` | 84 | 93 | 105 | **120** | 140 | 153 | 168 | 210 |
保存时强校验:`targetWatermark` 必填且在 −50% ~ 95% 之间;`gain` 若填写需在 0.5 ~ 10 之间。
⚠️ **标定**:判定溢出让「实测等效需求」是名义值的 118%~120%,所以中心公式必须带上**实测标定系数**
(`V* = 100 / ((1 − 目标水位/100) × k)`),不能按 `100/(1 − 目标水位/100)` 直接算。
`k` 由系统按真实流水滚动计算(5 分钟内存缓存),样本不足时按 1.19 兜底。
⚠️ **`V*` 必须落在饰品的随机数区间内**,且**离两端各留约 10% 区间宽度**,否则中心被夹住、水位停不到目标值。
⚠️ **区间同时限定了能维持的水位范围**(中心可表达范围 = 区间下界 + `[10%, 90%]` 宽度):
例 `[100,160]` 对应水位约 `[20.7%, 45.4%]`;想把水位开到 60%,区间上限得提到 210 以上。
完整推导、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。
### 6.5 数据落库
| 表 | 说明 |
|---|---|
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`) |
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`、`watermark` 水位快照、`round_target` 本轮随机数快照) |
| `tt_box_records` | 获得物入背包(`source=4` 幸运升级),`id` 即结果页分解入参 |
| `tt_user_blend_ercash` | 余额流水:消费 40、游戏内分解 41、背包分解 10 |
| `tt_upgrade_ornaments` | 饰品位配置 + 三个池的运行进度(各池幸运值/已投金额) |
| `tt_upgrade_ornaments` | 饰品位配置 + 三个身份的运行进度(各身份累计幸运值/已投金额) |
| `tt_upgrade_reward_log` | 水位人工调整日志(注水/抽水/重置;`delta` 为带符号影响,水位调整额 = `SUM(delta)`) |
字段变更历史见 `docs/updata_sql.md`(含白号三列的 ALTER)。
+217 -148
View File
@@ -2,7 +2,11 @@
> 目标:把「麻将胡了」(5 轴 × 4 行、1024 路、级联消除、金色符号、乘数阶梯、免费旋转)做成**玫瑰女郎(Olympus)同一套设计模式**的兄弟游戏。
> 参考实现:`olympus-py/`(回合池生成器)+ `skins-service/game-olympus-service/`(Java 服务)+ `ruoyi-ui/src/views/game/olympus/`(后台)+ `docs/玫瑰女郎接口文档.md`(接口契约)。
> 状态:**方案待评审**,未动任何代码。末尾第 13 节列出必须由你拍板的 10 个问题。
> **文档来源**:本方案是随同源项目从 **`changzhou_backend`** 搬到本仓的(本仓 `docs/修改记录.md` 已把它归为"旧项目文档"),**通篇是 changzhou 语境**。所以文内的 `olympus-py/`、`skins-service/game-olympus-service/`、`docs/玫瑰女郎接口文档.md`、`docs/updata_sql.md`、`D:\changzhou-cocos\...` 都是**那个仓库**的资产:
> - 在 `D:\changzhou_app\changzhou_backend` 里它们**完整存在**(含上面两个 docs 模板);
> - 在本仓(henan-backend)里**没有**——`olympus-py/` 与 `game-olympus-service/` 未随拷贝带过来,`docs/updata_sql.md` 已清空、`docs/玫瑰女郎接口文档.md` 已删(均为有意为之)。
> - **落地仓库已定:本仓 henan-backend**。因此:① 路径类引用一律按**本仓现状**读(§3.8 / §6.5 / §8 / 附录 B 已就地标注);② 开工前先把母本从 `D:\changzhou_app\changzhou_backend` 拷过来当参照(`olympus-py/` → 本方案 §5 的照抄对象;`skins-service/game-olympus-service/` → §6 的照抄对象);③ 注意本仓 `skins-service/pom.xml:22` 仍声明 `game-olympus-service` 而该目录不存在,**Maven 从根构建会以 "Child module does not exist" 失败**(`ruoyi-admin/pom.xml:100` 也依赖它),要么先拷母本、要么先摘掉这行。数学与规则部分(§2 全篇)与仓库无关,两边通用。
> 状态:**方案待评审**,未动任何代码。第 13 节是全部定案与决策链(原列的 10 个待拍板项已全部澄清)。
---
@@ -11,12 +15,12 @@
| 项 | 结论 |
|---|---|
| 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 |
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **12 个箱子**(只按投注档分,档位见 §2.8.1)、无模式倍率、无 `need_fs` 过滤 |
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **11 个箱子**(只按投注档分,档位见 §2.8.2)、无模式倍率、无 `need_fs` 过滤 |
| 必须重写什么 | ① 中奖判定(1024 路**前缀链**:必须从第 1 轴起 1→2→3→4→5 连续,中间断链即整笔作废,替代 8+ 聚簇)② 盘面几何(5×4=20 格替代 5×6=30 格)③ 乘数介质(阶梯倍率替代炸弹球)④ **级联的消除规则**(金色中奖格**不清空**,就地留一个百搭占位) |
| 发奖形态 | **与玫瑰女郎完全一致**:游戏内**显示现金**(「共赢得 X.XX」),但**最终赢额转成一个饰品发到背包**(`settleAtStart` 按赢额落区间查饰品 → 写 `tt_box_records`),玩家在末帧选择「放入背包 / 立即分解」。→ 所以**不做**"开户即加余额"那条近路,`prize_ornament` / `prize_range` 两张表都要 |
| 新增资产 | `mahjong-py/`(生成器)+ `game-mahjong-service`(Java 模块)+ **11 张** `game_mahjong_*` 表 + 7 个后台页(含奖品管理)+ `docs/麻将胡了接口文档.md` + Cocos 面板 |
| 工作量 | 后端(py + Java + 后台 + 文档)≈ **20~26** 人日;客户端 ≈ 15~20 人日;联调/RTP 验收 ≈ 5 人日 |
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ 赔付计量单位(=总额÷20)若在规则页没说清,玩家会按"倍总额"理解而产生对账投诉 |
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ **`prize_range` 只管"发哪件饰品"、不是 RTP 旋钮**(12 件/12 段已导入且连续无缝、上界 99,999,999,见 §3.8;饰品价格 = 本局赢额,空转兜底只有 ¥0.01)—— RTP 全部在桶配额里,别再往饰品价格上找旋钮 |
---
@@ -28,12 +32,12 @@
|---|---|---|---|
| 1 | **服务端权威**:客户端不能决定任何结果 | 全部结果由后端挑出的回合决定 | 一致 |
| 2 | **离线预生成回合池**:Python 造 `round`+`step` 落库,Java 只挑不算 | `olympus-py/generate_rounds.py` 写 11.7 万回合 / 106 万步 | `mahjong-py/generate_rounds.py`,同结构 |
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 10 档(见 §3.1) |
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构 |
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 16 档(见 §3.1) |
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构(**3 身份**,见 §3.6) |
| 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` |
| 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) |
| 7 | **身份分桶** common/anchor/robot/white,白号独立库存不进真实经济 | `OlympusAudience` | 直接复用(建议上移到共享模块) |
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致,但结算动作从"写背包"改为"加余额" |
| 7 | **身份分桶** common/anchor/white(**3 个**;原 robot 维度已按用户要求去掉,`user_type='03'` 归 common),白号独立库存不进真实经济 | `OlympusAudience`(它那份是 4 个) | 本游戏自带 3 个身份的判定,见 §3.6 |
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致(结算动作就是"写背包":赢额落 `prize_range` 区间匹配饰品,见 §6.3) |
| 9 | **客户端只续播**:`dospin` 带 `boxId` = 开新局;不带 = 下一步;`state=FINISH` 才展示结算 | `OlympusGameService.continueGame():230-254` | 一致 |
| 10 | **Redis 单局缓存 30 分钟 + `hasActive` 重连**,重连要能接回累计量 | `ruoyi:olympus:{userId}` TTLL 30min | `ruoyi:mahjong:{userId}`;接回量 = 本旋波次/本局累计 |
| 11 | **一轮 cycle**:`N = 固定池/(1−收敛比)`,抽满 `resetCycle + refillFromConfig`(模板) | `OlympusCycleRepository` + `box_template` | 一致 |
@@ -41,7 +45,7 @@
**结论:12 条全部照抄,一条都不改。**(用户 2026-09-21 定案:麻将胡了的赢额也**要变成饰品发到背包**,所以"赢额 → 落区间 → 查饰品 → 写背包 → 末帧选择放入/分解"这条链路与玫瑰女郎一模一样,连 `settleAtStart` 的 `max(totalWin, 0.01)` 兜底都能照抄。)
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值 = 饰品价格",前端同样显示金额)。这句话不是"直付余额"的意思。
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值",而背包记录里的价格字段 `ornaments_price` 存的就是这个赢额——前端同样显示金额)。这句话不是"直付余额"的意思。
---
@@ -255,7 +259,7 @@
| ⑥ 能和谁一起消 | 百搭**和普通符号完全同权**:它参与的那条前缀链里可以是普通符号,**也可以是金色符号**;金色符号本来就是普通符号镀金,所以**不需要任何特殊逻辑** |
| ⑦ **不跨旋** | 下一旋**整盘重发**,百搭与金色标记都不带走;**每个旋开局时盘面上 0 个百搭** |
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且**不限张数**——每个金色格被消除时各就地留一个(见 §2.2 铁律)。
> 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。
- **胡(夺宝/SCATTER)**:不参与 1024 路(**既不计入链,也不被百搭替代**);出现在任意轴任意位置;**自身不赔现金**(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 `scatterTrigger.scatterWin` 恒为 0。
- 3 个及以上胡 → 触发免费旋转:**12 旋 + 每多 1 个额外 +2 旋**(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。
@@ -280,8 +284,8 @@
→ ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。
"消除"不等于"清空":消除之后该格才分成两类——
2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号
3. 金色的中奖格 → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W**
(这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空)
3. 金色的中奖格 → 该符号被消耗;**每个金色格各自就地写入 W**(无数量上限)
(这些格子**不是空洞**、不补新符号;同波每个金色格都按这一步处理)
4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题")
5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画
第 n+1 波:
@@ -303,8 +307,8 @@
- 百搭**上方**的格被清空 → 百搭留在原地不动,上方的牌落到它头上。
- 如果把它做成"钉在空中",**它下方被清空的格就落不进任何符号**(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以**不能钉**。
- 客户端要注意:**百搭会移动位置**,下落动画必须用帧里的 `dropData`(`from → to`)驱动,**不能假设它原地不动**;"金色→百搭"的变形动画(`wildConverted`)播完之后,紧接着就可能是它的下落动画。
- ⚠ **"涉及中奖"的判定**:金色格被计入某笔中奖的 `ways`(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。**金色格只做自己那一种符号的替代**——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。
> 注意:由于最多只有 1 个百搭,且金色格本身是自然符号,**"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生**,不会成群出现。
- ⚠ **"涉及中奖"的判定**:金色格只要被计入某笔中奖的 `ways` 就算"涉及中奖"(于是该格被消除、就地转成 `W`)。⚠️ **金色格只当它自己的自然符号用,不做别的符号的替代**——金色「中」只算「中」;"顶替别人"这件事**只有元宝 `W` 会做**(`W` 既能顶替金色格的符号,也能顶替任何普通符号)。这是 §2.2 铁律 + §15 第 1 波算例共同确定的口径:**若金色格同时还能当百搭,§15 第 1 波白板链的 `ways` 就会从 2 变成 4,全盘数学都会变。**
> 注意:盘面上可以有**多个**百搭(每个金色格各留一个),且**同一个 `W` 可以被多个符号同时借用**(每条链各算各的,见 §15 第 4 波:一个 `W` 同时支撑筒与索红绿两条 5 连)——判定时逐个百搭独立参与每条链,既不限量、也不去重。
**其余约定**:
@@ -350,7 +354,7 @@
| × 本波阶梯倍率 | 基础旋 `1/2/3/5`;FS 旋 `2/4/6/10` |
| = 本波赢额 | `本笔基础倍率 × 投注总额 × rotationMul` |
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.2)。
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.3)。
> 实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(`accumulator_before/after`,FS 内跨旋传递)机制**原封不动复用**——麻将胡了虽然乘数不跨旋累计,但"本波阶梯倍率 / 本波序号"需要每帧下发,字段名可直接沿用 `accumulator`(语义改为"本波阶梯倍率")以免客户端大改。**建议改名为 `rotationMul`**,避免误解。
@@ -427,27 +431,27 @@
原版:投注大小 0.02~50.00 × 投注倍数 1~10,另有现金钱包与自动旋转。
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.1):
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.2):
- `price(实际扣费) = box.bet`(本游戏 costMultiplier ≡ 1)
- **回合池里存的全是倍率**(`actual_mul`),与投注额无关 → **改档位不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
- 奖池水位按 `(boxId, audience)` 聚合,投注额只影响单局金额,不影响池结构。
**曾经的备选(已否)**:给"档位 × 倍数"每个组合建一个箱子 → 池子碎片化、水位口径混乱。
#### 2.8.1 设计多少个下注额、哪几个、能多大?
#### 2.8.1 该设计多少个下注额、哪几个、能多大?(先把成本分清)
**先分清两件事,因为成本差一个数量级:**
| 加什么 | 池子要不要动 | 单局理论最大赔付 | 成本 |
|---|---|---|---|
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 40 条配比 + 一套水位) |
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 48 条配比〔16 桶 × 3 身份〕+ 一套水位) |
| **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **同箱内直接放大 10 倍** | 几乎为零 |
> ⚠️ **关键认识**:`betMul` 不是"免费做大",它**把同一箱内的金额跨度拉到 10 倍,等价于偷偷多开了 9 个档位**,却只用一个池子——风控口径和水位观察口径都会变粗。
> 而且在 500x 封顶下,`betMul` 会把**理论单局最大赔付**直接乘以 10。
> 所以本次**不再推荐"档位 × 倍数"双控件**,改为**只保留投注档**(与玫瑰女郎完全一致)。
#### 2.8.1 推荐方案(11 档,只保留投注档)
#### 2.8.2 推荐方案(11 档,只保留投注档)
**结论:`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`,共 11 档;不设投注倍数。**
@@ -468,7 +472,7 @@
| **上限(风控)** | 单局理论最大赔付 = **500x × 500 = ¥250,000**;玫瑰女郎现状 = 1000x × 300 = **¥300,000** | ✅ **压在玫瑰女郎之下** |
| **档位取整** | 档位都是整数 → 任何中奖金额都是整分 | ✅ 不会出现除不尽的数 |
| **前 10 档** | 与玫瑰女郎**一字不差** | ✅ 玩家熟悉、报表口径统一 |
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 20~30 分钟 / 704 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 15~25 分钟 / 528 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
**为什么不设投注倍数**(与玫瑰女郎保持一致的做法)
@@ -485,28 +489,28 @@
| 到 ¥1000 **且守住风控** | 加 ¥1000 档的同时把封顶降到 **300x** | 300x × 1000 = **¥300,000** ✅ 正好对齐玫瑰女郎 |
| 到 ¥2000 | 加 ¥2000 档 + 封顶 150x | ¥300,000 ✅(但 150x 封顶会让桶表整体缩一档,要重配池子) |
> 每加一档的成本:+4500 回合 / +8 万帧 / +52 条配比行 / 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
> 每加一档的成本:+4500 回合 / +8 万帧 / +48 条配比行(16 桶 × 3 身份)/ 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
**已否掉的方案**
- ❌ 原版双控件(档位 1~300 × 倍数 1~10):顶档 ¥3000 一注、单局理论最大赔付 **¥1,500,000**(玫瑰女郎的 5 倍),且同箱金额跨度 10 倍。
- ❌ 12 档到 ¥1000 + 倍数 1~10(最高 ¥10,000 一注):单局理论最大赔付 **¥5,000,000**,是玫瑰女郎的 16 倍,风控口径直接破。
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.2),当成总额抄过来会让最低中奖显示成 +0.00。
2. **冷门高档位会水位饥饿**:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放 10 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.3),当成总额抄过来会让最低中奖显示成 +0.00。
- **冷门高档位会水位饥饿**:¥300 / ¥500 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放前 9 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
**为什么不是更多档**
1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束):
- 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
- 多 64 条**配比行**(16 桶 × 4 身份)要配、要重算、要维护模板;
- 多 48 条**配比行**(16 桶 × 3 身份)要配、要重算、要维护模板;
- 多一套**奖池水位**(4 个身份各一条)要运营盯。
10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
11 档 → 5 万回合 / 90 万帧 / 生成 15~25 分钟 / 528 条配比行,正好是玫瑰女郎已经在跑的量级。
2. **档位越多,冷门档越容易"饿死"**:奖池水位是**按箱独立**的,没人玩的档水位长期为 0 → `payoutCap = max(0, 水位+成本) × rtp` 被压到接近 0 → **那个档几乎出不了奖**,只能靠运营注水救。所以档位不是越多越好。
3. **玩家侧足够**:1~500 共 11 档,比原版 0.02~50 的覆盖面更宽;相邻档位间距 2~3 倍,选注手感是行业常规。
4. **对齐玫瑰女郎**:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。
**不推荐的做法**
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.2 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.3 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
- ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
- ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。
@@ -516,7 +520,7 @@
> ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。
#### 2.8.2 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
#### 2.8.3 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。
@@ -536,36 +540,34 @@
→ **`box.bet ≥ 0.10` 是硬下限**(我们的模型里 `box.bet` = **投注总额**)。
> ⚠️ **关于原版"投注大小 0.02~50.00"的理解**:规则页有一条「一条中奖路所得的奖励等同于赔付表上的赔付值**乘以投注大小和投注倍数**」。
> 这条要成立,只有一种读法:**原版的「投注大小」= 每条基础投注的金额**(即 `投注总额 = 投注大小 × 20 × 投注倍数`)。
> 按这个读法,原版最低档 0.02 的**最小中奖 = 2 × 0.02 = ¥0.04**,**不会丢分**。
> 而我们的模型里 `box.bet` 是**投注总额**,所以下限是 `0.10`;单纯把原版的 `0.02` 抄过来当**总额**才会丢分(0.002 显示成 +0.00)。
> **待确认(一个 30 秒的真机测试)**:把原版「投注大小」设成 **1.00**、「投注倍数」设成 **1**,看「投注总额」显示 **20.00** 还是 **1.00**——
> 显示 **20.00** ⇒ 投注大小 = 每条基础投注(上面那种读法,规则页第 1 条成立);显示 **1.00** ⇒ 投注大小 = 总额(那第 1 条就该改写成"乘以投注总额后除以 20")。
> **已确认(用户口径)**:原版的「投注大小」**就是投注总额**——设成 1.00 就显示 1.00,不 ×20。所以那条规则里的"点数"必须按**"游戏规定了 20 条基础投注"折算**,实际算式是 `点数 × ways × (投注总额 ÷ 20) × 倍数`。
> 由此得到正确结论:原版最低档 `0.02` 的**最小中奖 = 2 × (0.02 ÷ 20) = ¥0.002** —— 那正是"社娱场面额"下会显示成 `+0.00` 的原因,也再次说明**原版的档位不能直接抄成本平台的总额**。
> 我们的口径不变:`box.bet` 是**投注总额**、赔付表已把 `÷20` 内化成倍率、11 档从 ¥1 起(最小中奖 ¥0.10)。
**附带的一个好性质**:**赔付表里的倍率都是 0.05 的整数倍**(0.1 / 0.2 / 0.25 / 0.3 / 0.4 / 0.5 / 0.75 / 1 / 2 / 3 / 4 / 5),而投注档又都是整数金额,所以**任何一笔中奖金额都是整分**,不会出现 0.0333 这种除不尽的数。建议投注档表**只放整数**,把这条性质保住。
**约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"**
**约束二:来自饰品区间表 —— 它只决定"发哪件饰品",不决定赔多少钱**
"必得饰品"意味着**空转局也会发一个最低档饰品**(`win = max(totalWin, 0.01)` 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估:
⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低档饰品的价格决定每局保底返奖",**是错前提**。真实模型:
```
每局保底返奖 ≈ 空转率 × 最低档饰品价格
低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额
```
- olympus 写背包用的是 `.ornamentsPrice(win)` —— **饰品的"价格"记的就是本局赢额**;
- 平台分解入账是 `Σ TtBoxRecords.ornamentsPrice`(`service-user/.../ApiUserPackSackServiceImpl.settleDecomposeBySource`)——**玩家最终拿到的钱就是这个数**;
- 玫瑰女郎线上 dump 里 1 号饰品(安慰奖)的注释也写着「兜底饰品 1是(**仅展示不覆盖金额**)」。
| 最低档饰品价 | 空转率 | 投注 ¥1 时的保底 RTP |
⇒ **每局实付 = `record.settled_win = win`**(空转局 = `max(totalWin, 0.01)` = **¥0.01**),**饰品只是载体**。所以"必得饰品"的真实保底成本:
| 兜底金额 | 空转率 | 投注 ¥1 时的保底 RTP |
|---|---|---|
| ¥0.01 | 40% | 0.4% ✅ |
| ¥0.05 | 40% | 2% ✅ |
| ¥0.50 | 40% | **20%** ⚠️ 光保底就吃掉 1/4 的目标 RTP |
| **¥0.01(当前实际值)** | 65% | **0.65%** ✅ 可忽略 |
| (假设哪天把兜底抬到 ¥0.50) | 65% | 32.5% ⚠️ 那才需要担心 |
→ **最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级**;饰品区间表(`prize_range`)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。
→ 对账口径也要注意:水位里的"派彩"应当用 **`record.settled_win`(饰品价)**,而不是 `total_win`(游戏内赢额)——否则这两者对不上时,水位会漂。
→ **`prize_range` 不是 RTP 旋钮**:区间有空洞或没覆盖,只会让那一档走 `findPrizeWithFallback` **换一件饰品发**,**金额不变**(`settled_win` 照旧)。
→ 对账口径:水位里的"派彩"用 **`record.settled_win`**(= 本局 win = 背包记录里的 `ornaments_price`)。
**结论**:下注档表要同时满足
**结论**:下注档表要满足
1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起)
2. 档位取整数(保证 unit 是整分)
3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束)
3. ~~最低档饰品价 ≤ ¥0.05 量级~~ **(作废:饰品价格 = 赢额,不是成本项)** —— 真正要盯的是**空转兜底金额** `max(totalWin, 0.01)` 这个常量,目前 ¥0.01、可忽略
模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。
@@ -576,7 +578,7 @@
> 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套):
> - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet`;
> - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶;
> - 箱数从 30 降到 **10**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
> - 箱数从 30 降到 **11**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
> - 高倍桶(100x 以上)**只能靠自然触发 FS 的回合供给**,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动**构造**高倍局,不能靠碰运气(见 §5.4 风险)。
---
@@ -642,11 +644,12 @@
| 1 | 白板 3 连 | 0.4 | 2 | 0.80 | x1 | ↑ 并入上行 | ↑ | ↑ |
| 2 | 八萬 4 连 | 0.75 | 4 | 3.00 | x2 | 3.00 | 6.00 | ¥600 |
| 3 | 花筒 5 连 | 1 | 1 | 1.00 | x3 | 1.00 | 3.00 | ¥300 |
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.00** | 10.00 | ¥1000 |
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.40** | 12.00 | ¥1200 |
| 4 | 索红绿 5 连 | 1 | 1 | 1.00 | x5 | ↑ 并入上行 | ↑ | ↑ |
| 4 | 白板 3 连 | 0.4 | 1 | 0.40 | x5 | ↑ 并入上行 | ↑ | ↑ |
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 10.00 = 21.80 倍**(未触 500x 封顶)
- **本局赢额 = 21.80 × ¥100 = ¥2180** ✓(与 §15 总账一致)
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 12.00 = 23.80 倍**(未触 500x 封顶)
- **本局赢额 = 23.80 × ¥100 = ¥2380** ✓(与 §15 总账一致)
- 全程只有 **1 个胡** → 若满 3 个胡就会先走 FS(FS 内阶梯换成 `2/4/6/10`,其余公式不变)
---
@@ -763,38 +766,42 @@
**直接沿用玫瑰女郎那张 17 档桶表,只去掉最高那档**(它到 1000x,我们封顶 500x,1000x 那档永远不会被命中)。
这样后台页面、配比算法、收敛逻辑、运营经验**全部零改动**;与玫瑰女郎的差异只有顶桶一行。
物理表名 `game_mahjong_bucket`;**后台页显示名「饰品桶」**(对应玫瑰女郎的 `game_olympus_ornament`,见附录 A 映射)。
物理表名 `game_mahjong_bucket`。**后台命名一律沿用现网玫瑰女郎,不自造新词**:
- 菜单名 = **「饰品列表」**(与 rose 的 `game/olympus/ornament/index` 同款;别叫"倍率桶")
- 箱子页里的动作按钮 = **「饰品配置」**(别叫"桶配比")、**「模板管理」**
- 桶的 `name` 值也照母本:`0x / 0.5x / 1.5x / 2.5x / 4x / 6.5x / 10x / 16x / 25x / 40x / 60x / 100x / 150x / 200x / 300x / 500x`
- 箱子 `name` = **下注额本身**(`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`):本游戏**只有一个模式**,所以**不加** `{模式名}-bet` 前缀(母本有前缀是因为它有 normal/ante/buy_fs 三种模式)——2026-09-22 用户定;别叫"¥1 档"
| 桶ID | 名称 | 期望倍率 | 落桶下限 | 落桶上限 |
|---|---|---|---|---|
| 1 | 0倍饰品 | 0 | 0 | 0 |
| 2 | 0.5倍饰品 | 0.5 | 0.01 | 1 |
| 3 | 1.5倍饰品 | 1.5 | 1 | 2 |
| 4 | 2.5倍饰品 | 2.5 | 2 | 3 |
| 5 | 4倍饰品 | 4 | 3 | 5 |
| 6 | 6.5倍饰品 | 6.5 | 5 | 8 |
| 7 | 10倍饰品 | 10 | 8 | 12 |
| 8 | 16倍饰品 | 16 | 12 | 20 |
| 9 | 25倍饰品 | 25 | 20 | 30 |
| 10 | 40倍饰品 | 40 | 30 | 50 |
| 11 | 60倍饰品 | 60 | 50 | 70 |
| 12 | 100倍饰品 | 100 | 70 | 130 |
| 13 | 150倍饰品 | 150 | 130 | 170 |
| 14 | 200倍饰品 | 200 | 170 | 230 |
| 15 | 300倍饰品 | 300 | 230 | 370 |
| **16** | **500倍饰品(封顶)** | **500** | 370 | **700** |
| 1 | 0x | 0 | 0 | 0 |
| 2 | 0.5x | 0.5 | 0.01 | 1 |
| 3 | 1.5x | 1.5 | 1 | 2 |
| 4 | 2.5x | 2.5 | 2 | 3 |
| 5 | 4x | 4 | 3 | 5 |
| 6 | 6.5x | 6.5 | 5 | 8 |
| 7 | 10x | 10 | 8 | 12 |
| 8 | 16x | 16 | 12 | 20 |
| 9 | 25x | 25 | 20 | 30 |
| 10 | 40x | 40 | 30 | 50 |
| 11 | 60x | 60 | 50 | 70 |
| 12 | 100x | 100 | 70 | 130 |
| 13 | 150x | 150 | 130 | 170 |
| 14 | 200x | 200 | 170 | 230 |
| 15 | 300x | 300 | 230 | 370 |
| **16** | **500x(封顶)** | **500** | 370 | **700** |
约定(与玫瑰女郎一致):
- **期望倍率 = (落桶下限 + 落桶上限) / 2**;区间**连续无缝、覆盖 0~封顶**。
- **桶 1 = 空转**(本局没有任何 1024 路中奖)。注意:**空转局照样发最低档饰品**("必得饰品",见 §6.3),所以桶 1 也是要配库存的。
- **桶 16 = 封顶档**:区间上界**故意开到 700(1.4× 封顶)**,这样"本来该赔 600x 的局"也落进这一档;**实际发放一律截断到 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
- **桶 16 = 封顶档**:生成端**先按 500x 截断得 `actual_mul`、再落桶**(顺序不能反——先落桶再截断会让 500x 以上的局无桶可落);区间上界仍留 **700** 作缓冲,**实际发放一律 = 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
- 因为 500x 封顶,桶 16 的池内实测均值会明显低于名义 500(被截断的局都堆在这里),配比计算按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是名义 1000、实用 835)。
**这张表**只管两件事:① 一局落在哪个倍率档(配比与库存的单位);② RTP 配平。
**实际发哪一件饰品**是另一回事——由 `prize_range` **按金额(¥)匹配**,见 §3.8。
**配置量与规模**
- `16 桶 × 11 箱 × 4 身份 = **704 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
- `16 桶 × 11 箱 × 3 身份 = **528 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
- 注册轴范围 / 生成器/后台页面清单里对应的桶数见 §4、§5、§8。
**可选优化(先不做,看实测再决定)**
@@ -807,7 +814,7 @@
| 2a | 0.2倍饰品 | 0.2 | 0.01 | 0.4 |
| 2b | 0.7倍饰品 | 0.7 | 0.4 | 1 |
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为 `赔付值 × ways × ladder ÷ 20` 里赔付值都是整数。所以任何桶边界都会自然对齐,不需要额外处理。
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为赔付表本身就是 0.05 的整数倍(0.1 / 0.25 / 0.5 / 0.75 …),`赔付表 × ways × ladder` 的结果自然落在 0.05 的网格上。所以任何桶边界都会自然对齐,不需要额外处理。
### 3.2 RTP 恒等式
```
@@ -846,11 +853,12 @@ fixedRounds= round(N × (1 - cr))
### 3.5 奖池水位(与玫瑰女郎同口径)
```
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.settled_win) + adjust
payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null
```
- bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。
- **`record.audience` 是"开局时的身份快照",要落库**(`MahjongGameService` 在建 record 时写入):水位聚合用它、也可以像玫瑰女郎那样 join `tt_user` 现推 —— 身份没变时两者等价,**身份被改过(例如白号开关被关)时以快照为准**,所以快照必写。**派彩一律取 `record.settled_win`(= `max(total_win, 0.01)`,也是背包记录 `tt_box_records.ornaments_price` 存的那个数)**,而不是只认 `total_win` —— 玫瑰女郎 `OlympusRecordMapper.sumPool` 用的就是 `SUM(r.settled_win)`,取错会让水位单向漂移(见 §3.8)。
- 收敛/固定两阶段都用 `payoutCap` 过滤"期望赔付超上限"的桶,捞不到就退最小桶。
- 白号(`is_blank_account=1`)`payoutCap=null`:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。
@@ -859,21 +867,28 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
各身份的 RTP **不在代码里写死**,和玫瑰女郎一样由后台配置,两层:
1. **箱子级**:`game_mahjong_box.rtp`(目标返奖率)+ `rtp_converge_ratio`(收敛比例)—— 在后台「箱子配置」页改;
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 饰品配置」弹窗改,或用「一键重算」按目标 RTP 反算。
所以"各身份 RTP 定多少"**不是一个需要在方案阶段拍板的数**,而是运营参数:先全部按 **0.80** 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。
| audience | 判定 | 建库默认 RTP | 说明 |
|---|---|---|---|
| common | 其余全部 | 0.80 | |
| anchor | `user_type='01'` | 0.80 | 四身份互斥并集 = 全部 |
| robot | `user_type='03'` | 0.80 | |
| common | 其余全部(**含 `user_type='03'` 机器人**) | 0.80 | |
| anchor | `user_type='01'` | 0.80 | **三身份互斥并集 = 全部** |
| white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 |
> ⚠️ **2026-09-22 用户定:身份维度从 4 个降为 3 个 —— 去掉 robot。** 机器人不再单独成池,
> `user_type='03'` 现在归入 `common`(会消耗 common 库存、计入 common 水位)。这条同时改了:
> 配比行 `16 桶 × 11 箱 × 3 身份 = 528 条`(原 704)、模板 `11 × 3 = 33 条`(原 44)、
> 后台奖池水位页 3 个 Tab(原 4 个)。Java 侧的 `MahjongAudience`(已删 `ROBOT`)、
> `MahjongAdminController.AUDIENCES`、`audienceUserSubQuery`、`MahjongRecordMapper.sumPool`
> 与前端 3 个页面的身份数组都已同步。
> **连带影响(要知道)**:机器人的下注/派彩现在会进入 common 池,不再是"隔离池"。
### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
- 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 6~8 号桶。**生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 10~13 号桶。**生成器要保证 10~13 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
- **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**:
- 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋);
- 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`);
@@ -900,7 +915,9 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
**② 饰品区间表怎么来(不要重新设计)**
照玫瑰女郎的做法(`docs/updata_sql.md:1187-1200`)**整套导入**:
**已落地:直接沿用玫瑰女郎线上那套(用户 2026-09-22 提供的 `game_olympus_prize_ornament` dump)**,12 件饰品 + 12 段区间,`id` 原样保留(因为 `prize_range.prize_id` 引用它)、`game_type` 改成 `MAHJONG`,SQL 见 `mahjong-py/schema.sql` 末尾两段(写法是 `INSERT ... ON DUPLICATE KEY UPDATE`,幂等,**没有**用 dump 里的 `DROP TABLE`)。
若以后要换源(比如改成糖果那套),照下面这段整套导:
```sql
DELETE FROM game_mahjong_prize_range;
@@ -911,21 +928,70 @@ INSERT INTO game_mahjong_prize_range (prize_id, win_low, win_high)
SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
```
**导入后必须核的 3 条(都直接决定 RTP,不能跳过)**
**导入后核的 3 条(**已按玫瑰女郎线上 dump 导入并逐条核过:12 件饰品 / 12 段区间**)**
| # | 检查项 | 为什么 | 判据 |
|---|---|---|---|
| 1 | **最低档饰品的价格** | "必得饰品"意味着**空转局也发最低档饰品**,它的价格就是每局的**保底返奖**:`保底 RTP ≈ 空转率 × 最低饰品价 ÷ 投注额`。空转率按 **65%** 估,¥1 档下若最低饰品价 **¥0.50** → **保底 RTP 32.5%**,直接把目标 80% 吃掉一半 | **最低饰品价 ≤ ¥0.05**(→ 保底 ≈3%) |
| 2 | **区间上界要覆盖到 500x × 最高档 = ¥250,000** | 覆盖不到的话,高倍局全都走 `findPrizeWithFallback` 落到兜底饰品,出现"赢了 20 万给个 50 块的皮肤" | 最高区间下限 ≥ ¥250,000(玫瑰女郎是 1000x × 300 = ¥300,000,量级相同,大概率够) |
| 3 | **区间连续无缝、无空洞** | 有空洞就会走 fallback(落到 `is_fallback=1` 或最低/最高区间),发奖与赢额不匹配 | 按 `win_low` 排序后逐条核 `high_i + 0.01 == low_{i+1}` |
> ⚠️ **先纠一个前提**:这三条**都不影响 RTP**(饰品价格 = 本局赢额,见 §2.8.3)。它们只决定**"赢了这笔钱发哪件饰品"**(展示层)—— 没覆盖或有空洞时走 `findPrizeWithFallback` 换一件,**金额不变**。
> 本地 MySQL 没启动,我没法直连查现成的区间数据;**上线前请在测试库跑一次**:
> ```sql
> SELECT MIN(win_low) AS 最低区间下限, MAX(win_high) AS 最高区间上限,
> COUNT(*) AS 条数 FROM game_olympus_prize_range; -- 玫瑰女郎现成的
> SELECT o.name, o.grade, r.win_low, r.win_high FROM game_olympus_prize_range r
> JOIN game_olympus_prize_ornament o ON o.id = r.prize_id ORDER BY r.win_low LIMIT 5;
> ```
| # | 检查项 | 为什么 | 判据 | 实测结果 |
|---|---|---|---|---|
| 1 | **最低区间要覆盖空转兜底 ¥0.01** | 空转局的 `win = max(totalWin, 0.01) = 0.01` 得能落进区间 | 存在一段覆盖 0.01 | ✅ 第 1 段 `0.01~0.14` → 安慰奖 |
| 2 | **区间上界要覆盖 500x × 最高档 = ¥250,000** | 覆盖不到的话,最大的那几档全走 fallback | 最高区间上限 ≥ ¥250,000 | ✅ 上界 99,999,999;¥250,000 落第 9 段 → 猎杀者匕首 \| 渐变之色 |
| 3 | **区间连续无缝、无空洞** | 有空洞就走 fallback,发奖与赢额不匹配 | 按 `win_low` 排序逐条核 `high_i == low_{i+1}` | ✅ 断点 0(12 段首尾相接) |
> **实测(导入后按 mapper 同口径 SQL 验证)**:¥0.10 / ¥0.14 → 安慰奖;¥10 → SG 553 浪花喷漆;¥50 → 双持贝瑞塔 黑榄仁木;¥20,000 → AK-47 燃料喷射器;**¥250,000 → 猎杀者匕首 渐变之色**;¥2,500,000 → AWP 巨龙传说。
> **两个实现细节(来自 `MahjongPrizeRangeMapper`,别按"双闭区间"想当然)**:
> ① mapper 用**半开区间** `win_low < win <= win_high` + `ORDER BY win_low DESC LIMIT 1` ⇒ 段与段共享的那个端点值归**上一段**(¥10.00 发 SG 553,不发双持贝瑞塔),也**不会** `TooManyResults`;
> ② `win = 0.01` 按半开语义其实**匹配不到任何区间**(`0.01 > 0.01` 为假)→ 走 `findFallbackPrize()`;而 1 号「安慰奖」正好就是 `is_fallback=1`,所以**结果一样**。以后在后台改兜底饰品时要记得:**空转局发的是它**。
---
### 3.9 空转率(桶 1 占比)定多少:**65%**(已用仿真校准)
**它是什么**:空转率 = **桶 1 的配额占比**(本局一圈 1024 路都没中),由 `calcRounds` 算出的 `count` 分布决定,不是硬编码概率 —— 所以"空转率定多少"就是"桶 1 的 count 占多少",后台「一键重算」可调。
**为什么它是个(小)指标**:空转局照样发兜底饰品,这笔钱无法用桶配比压掉:
```
保底 RTP = 空转率 × 兜底金额 ÷ 投注总额 (兜底金额 = max(totalWin, 0.01) = ¥0.01)
```
**¥0.01 的兜底很便宜**:空转 65% → 保底 **0.65%**;空转 80% → 0.80% —— 都在 ±1pt 容差内,不构成压力。
> ⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低饰品价 v 才是大闸门:v=¥0.05→3.25%、v=¥0.50→32.5%、v=¥2→130%(必亏)"——**那是把饰品的市场价当成了成本**。实际是 `ornamentsPrice = win`(olympus 写背包 `.ornamentsPrice(win)`,分解按 `Σ TtBoxRecords.ornamentsPrice` 入账),**饰品只是载体、每局实付 = `settled_win = win`**,所以兜底成本就是 ¥0.01。两个连带结论随之更正:
> ① **11 个箱可以共用同一套桶配比期望**:保底只占 ≤0.65pt(¥1 档 0.65% / ¥10 档 0.065% / ¥500 档 0.0013%),落在容差内;要较真只给 ¥1、¥3 两档微调,不必逐箱重配。
> ② **水位没有"虚高"问题**:空转局 `settled_win = 0.01` 就是实际派彩(饰品价格 = 赢额),`水位 = Σcost − Σsettled_win` 口径正确,**不需要**按饰品市场价记账。
**目标 RTP 0.80 的预算反解**(取 FS 触发率 0.5%、FS 局整局平均 45x、兜底金额 ¥0.01):
| 空转率 q | 命中率 | 保底 RTP | 其余中奖局需要的平均倍率 | 需要"抹掉"的自然中奖占比 |
|---|---|---|---|---|
| 50% | 50% | 0.50% | 1.15x | 2.5%(几乎不动自然形态) |
| 60% | 40% | 0.60% | 1.44x | 22% |
| **65%(建议)** | **35%** | **0.65%** | **1.65x** | **32%** |
| 70% | 30% | 0.70% | 1.93x | 42% |
| 80% | 20% | 0.80% | 2.91x | **61%** ⚠️ |
**结论:空转率取 65%(命中率 35%),可接受区间 60~70%。** 三条理由:
1. **≤50% 不建议**:中奖局平均只有 ~1.15x,而自然中奖的中位数只有 0.2x —— 池子里会塞满"中 0.1~0.5 倍"的小奖,观感是"一直在中小钱",运营侧每局都要走一次饰品区间匹配,账也更碎;
2. **≥80% 不建议**:要抹掉 61% 的自然中奖、并把 RTP 压在 14/15/16 号高倍桶上,而这三档**没有"买 FS"兜底**(§5.4 风险),最容易饿死;
3. **65% 只抹掉 32% 的自然中奖**(自然命中 51.3% → 35%),构造量适中,中奖局平均 1.65x "每次中奖都有感"。
**配套(同样要定死)**:**FS 局的整局倍率中心值取 45x、区间 25x~60x**,不要沿用"30x~150x"。因为按 0.5% 触发率算,FS 局平均 150x 一项就要吃掉 `0.5% × 150 = 75%` 的 RTP 预算,剩下的局几乎没有钱可赔;45x 时它占 **22.5%**,其余局还有 54% 的预算。
**自然形态实测(`mahjong-py` 20 万局、完全不加偏置)** —— 这些就是生成端要"买回来"的差距:
| 指标 | 自然值 | 设计目标 |
|---|---|---|
| 自然 RTP(平均 `actual_mul`) | **5.25**(525%) | 0.80 |
| 基础旋命中率 / 空转率 | 51.3% / 48.7% | 35% / **65%** |
| FS 触发率(3+ 胡落定) | **1.33%** | 0.5% |
| 悬念盘(2 胡且第 5 轴无胡) | **4.89%** | 1~2% |
| 触 500x 封顶 | 0.015% | — |
| 波数分布(0 / 1 / 2 / ≥3 波) | 48.7% / 26.0% / 13.3% / 12.0% | — |
> 两条硬结论:① **自然 RTP 5.25 说明"RTP 完全靠池子构造买回来"**(与玫瑰女郎同构:规则本身不收敛,配额才收敛)⇒ 生成端**不能"自然抽样 + 重新分桶"**,必须按桶目标主动摆盘面(`_force_win` / bias 反解);
> ② **胡要人为压到约 1/2.7**(1.33% → 0.5%)、**悬念盘压到约 1/3**(4.89% → 1.5%)—— 做法是**按配额投放胡**(生成端决定本旋落几个胡),而不是"改权重再撞运气"。
---
@@ -937,7 +1003,7 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|---|---|---|---|---|
| 1 | `game_mahjong_box` | `game_olympus_box` | box_id, box_type, bet, price, total_rounds, rtp, rtp_converge_ratio, status | `total_ornaments`→`total_rounds` |
| 2 | `game_mahjong_bucket` | `game_olympus_ornament` | id(1-16), expect_mul, name, range_low, range_high | **16 档饰品桶** |
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 4 身份 = 704 条** |
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 3 身份 = 528 条** |
| 4 | `game_mahjong_round` | `game_olympus_round` | round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss | 去 `total_cascades` 语义改为 `total_rotations`(波数);`need_fs` 不需要了(没有买 FS 模式);加 `near_miss` 标记 2 胡悬念盘 |
| 5 | `game_mahjong_step` | `game_olympus_step` | 见 §4.1 | 字段大改(ways/golden/阶梯) |
| 6 | `game_mahjong_record` | `game_olympus_record` | user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, **prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type** | **保留全部 prize_* 列**(发饰品要走区间匹配),只多一个 `bet_mul` |
@@ -961,8 +1027,8 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
"wild_converted": [{"pos":7,"fromSymbol":"B"}],// 本波"金色→百搭"就地转化的格(客户端播变形动画)
"ways_detail": [{"symbol":"A","reelCount":3,"counts":[1,2,1,0,0],
"loc":[1,6,11,7], // 本笔中奖的全部格位(1起),含金色格 —— 客户端按它播爆裂动画
"ways":2,"payBase":15,"rotationMul":2,"win":3.00}],
// win = 15 × 2 × (bet÷K) × 2,K 见 §2.1 计量单位
"ways":2,"payBase":0.75,"rotationMul":2,"win":3.00}],
// payBase 已是倍投注总额(發 3 连 = 0.75,无 ÷20);win = 0.75 × 2 × bet × 2(本例 bet=1)
"drop_data": [{"from":12,"to":17,"symbol":"H"}],
"fill_data": [{"pos":2,"symbol":"G"}],
"scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0}, // 或 null
@@ -996,7 +1062,7 @@ mahjong-py/
├── generate_rounds.py # 批量落库(箱编号/池子分配/id 段切分/并行)
├── simulate_cycle.py # 整轮 RTP 仿真验收(几乎原样拷贝)
├── _recalc_boxes.py # 按目标 RTP 重算配比(calc_rounds 一行不改)
├── _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
├── _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
├── _regen.py # check/snapshot/clear/restore/verify
├── _reset_boxes.py
├── README.md # 结构与 runbook(照抄并改写游戏规则段)
@@ -1056,8 +1122,8 @@ mahjong-py/
cd mahjong-py
python mahjong_db.py # 建 11 张表 + 16 档桶种子(幂等)
python generate_rounds.py --per-bucket 20 # 干跑,不写库
python generate_rounds.py --all --alloc config --write --workers 4 # 12 箱写库(只有 normal)
python _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
python generate_rounds.py --all --alloc config --write --workers 4 # 11 箱写库(只有 normal)
python _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
python _recalc_boxes.py --mode normal # 重算配比 + 回写 total_rounds
python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026 # RTP 验收
python _regen.py check|snapshot|clear|restore|verify # 体检/快照/清池
@@ -1074,9 +1140,9 @@ python -m pytest tests -q # 回归
| `ALLOC_FLOOR` | 60 | 每桶保底条数 |
| `BOX_STEP_BAND` | 200000 | 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估) |
| 每箱池子 | 4000~5000 条回合 | 因为没有"买 FS"那批高倍回合兜底,**高倍桶要靠 normal 池自己凑**,所以单箱池子要比玫瑰女郎更大 |
| 总规模 | **12 箱** × ~4500 = **5.4 万回合**,每回合 8~40 帧 → **~96 万 step**(与 olympus 的 106 万同一量级) |
| 总规模 | **11 箱** × ~4500 = **5 万回合**,每回合 8~40 帧 → **~90 万 step**(与 olympus 的 106 万同一量级) |
| 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) |
| 风险点 | **高倍桶(8/9/10 号桶)没有买 FS 兜底**:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
| 风险点 | **高倍桶(14/15/16 号桶)没有买 FS 兜底**:500x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
> 若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 `copyFromSameBucket` 复制(olympus 已有此兜底,`:179-231`)。
@@ -1163,7 +1229,7 @@ startNewGame(userId, boxId):
- `scope=0` 全服口径排除机器人(`user_type='03'`)、封禁(`status='1'`)、已删除(`del_flag!='0'`),**白号保留上榜**。
- 响应体用 `@JsonInclude(NON_NULL)`:未出现的 key 就是 null,客户端无需判空。
### 6.5 平台共享文件的改动(9 处,漏一处就少一块统计)
### 6.5 平台共享文件的改动(10 处,漏一处就少一块统计)
| # | 文件 | 改动 |
|---|---|---|
@@ -1175,14 +1241,14 @@ startNewGame(userId, boxId):
| 6 | `skins-model/.../MessageCategoryEnum.java` | 加 `GAME_MAHJONG("GAME_MAHJONG","麻将胡了")`(前缀 `GAME_` 才进"游戏"Tab)—— **本游戏要发结算站内信,必加** |
| 7 | `sys_config` | `mahjongMaintenance` 维护开关(0 正常 1 维护) |
| 8 | `sys_menu` | 7 个后台菜单(见 §8) |
| 9 | `docs/updata_sql.md` | 新增一节「麻将胡了模块建表 / 服务接入」,格式照 1144-1225 行;**返奖饰品种子**照 1187-1200 行从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
| 9 | `docs/updata_sql.md` | **该台账已按需求清空,不再当建表台账/模板用**(建表只以 `mahjong-py/schema.sql` 为权威、直接对库执行);**返奖饰品种子**仍照 §3.8 那段从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
| 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 |
> **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。
### 6.6 封顶与"匹配/发放分离"
- 回合池里桶 10 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
- 回合池里桶 16 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
- 运行期 `payoutCap` 过滤 + 收敛阶段 `capMul = payoutCap / bet` 双保险。
- FINISH 时 `totalMul ≥ 500` → `maxWinReached=true`、`spinsRemaining=0`。
@@ -1197,7 +1263,7 @@ startNewGame(userId, boxId):
| 单笔/单波金额 | `base_score × bet`(base_score 由生成端算好) | `倍率 × ways × 投注额 × rotationMul` | 同结构(都是"乘数 × bet") |
| **本局赢额** | `totalWin = actual_mul × bet` | `totalWin = actual_mul × bet` | ✅ **完全一致** |
| 本局倍率 | `totalMul = actual_mul` | `totalMul = actual_mul` | ✅ 一致 |
| 发奖 | 赢额 → 落 `prize_range` 区间 → 饰品 → 写背包 → `settled_win` = 饰品价 | 同 | ✅ 一致 |
| 发奖 | 赢额 → 落 `prize_range` 区间 → 选定饰品 → 写背包(`ornaments_price` = 该赢额)→ `settled_win` = 该赢额 | 同 | ✅ 一致 |
| 分解 | `OLYMPUS_BOX(13) → OLYMPUS_REWARD(61)` | `MAHJONG_BOX(15) → MAHJONG_REWARD(69)` | 同结构 |
| 空转局 | `win = max(totalWin, 0.01)` → 最低档饰品(必得饰品) | 同 | ✅ 一致 |
| 奖池水位记账 | `水位 = Σcost − Σsettled_win + adjust` | 同 | ✅ 一致 |
@@ -1250,7 +1316,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| `gridState` | 每帧 | **4 行 × 5 列**短码矩阵,20 格全满(权威渲染数据) |
| `goldenDetail` | 每帧 | `[{"pos":1起,"symbol":"B"}]` 本帧仍为"金色普通符号"的格(它按 `symbol` 的自然符号成奖) |
| `wildConverted` | 每帧 | `[{"pos":1起,"fromSymbol":"B"}]` 本波**就地转成百搭**的格:该格 `gridState` 已是 `W`、不清空不补新符,客户端播"金色→百搭"变形动画 |
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × (bet÷K) × rotationMul`(**K 见 §2.1 计量单位**) |
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × bet × rotationMul`(`payBase` 已是倍投注总额,**无 ÷20**,见 §2.1) |
| `dropData` / `fillData` | 每帧 | 与玫瑰女郎同构(`from/to/symbol`、`pos/symbol`) |
| `rotationMul` | 每帧 | **本波阶梯倍率**(基础旋 `1/2/3/5`、FS `2/4/6/10`);重连时用它接回档位 |
| `winAmount` | 每帧 | **本旋累计到本帧**(末帧 = 本旋总额),不是增量 |
@@ -1282,19 +1348,19 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|
| 麻将胡了(目录) | `mahjong` | — | |
| 箱子配置 | `box` | `game/mahjong/box/index` | `game:mahjong:box` |
| 倍率桶 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
| 饰品列表 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
| 回合管理 | `round` | `game/mahjong/round/index` | `game:mahjong:round` |
| 奖池水位 | `rewardPool` | `game/mahjong/rewardPool/index` | `game:mahjong:pool` |
| 玩家记录 | `record` | `game/mahjong/record/index` | `game:mahjong:record` |
| 每日统计 | `dailywin` | `game/mahjong/dailywin/index` | `game:mahjong:win` |
| **奖品管理** | `prize` | `game/mahjong/prize/index` | `game:mahjong:prize` | **本游戏要有**(赢额→饰品、返奖区间编辑、品质图) |
菜单 SQL 照抄 `docs/updata_sql.md:1165-1186` 的写法(`SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单)。**注意两个仓库现状**:
- `sql/` 目录是空的,全量 DDL 历史只在 `docs/updata_sql.md`(追加式)和生成器的 `schema.sql` 里,别去 `sql/` 找;
菜单 SQL 的写法是 `SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单;**仓内已无模板可抄**(`docs/updata_sql.md` 已清空、`sql/` 目录是空的),改成对现库 `sys_menu` 直接操作(现成的 olympus 目录行可当样板)。**注意仓库现状**:
- 全量 DDL 只剩生成器的 `schema.sql` 这一处权威,别去 `sql/` 或 `docs/updata_sql.md` 找;
- 后台页面**没有任何 `v-hasPermi` 指令**,路由也**不在 `router/index.js` 里静态注册**——能否访问完全由 `sys_menu` 行决定(`component='game/mahjong/<module>/index'`)。菜单 SQL 漏一条,页面就是白屏而不是报错。
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 桶配比弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
**奖池水位页**:四身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 饰品配置弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
**奖池水位页**:三身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
**奖品管理页**:返奖饰品列表(名称/品质/图片/品质图/磨损/兜底/返奖区间)+ 编辑(`prize/save` 同步写 `game_mahjong_prize_range`)——照抄 `olympus/prize/index.vue`(121 行)。
**玩家记录页**:列里带**饰品名+图**与「播放状态 start/finish」标签(和玫瑰女郎一致)。
**必做校验**:`calcRounds` 回写时同时更新 `total_rounds`,否则 `cycleN` 与实际池子对不上。
@@ -1307,7 +1373,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|
| `assets/Scripts/Pages/MahjongPanel/MahjongPanel.ts` | 主面板(5×4 盘面、级联动画、阶梯条、FS 模式、自动旋转) | 结构照 `CandySloterPanel.ts`(186KB),**盘面改为 4 行 5 列、去掉炸弹、加金色转化动画** |
| `MahjongBigWin.ts` | 爆奖三档 | 拷贝 |
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注大小与倍数) | 拷贝改文案 |
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注档选择,**无投注倍数**) | 拷贝改文案 |
| `MahjongSfx.ts` / `MahjongSpineUtil.ts` | 音效 / 骨骼工具 | 拷贝 |
**主界面 HUD 布局(按用户给的真实运行截图逐块对齐)**:
@@ -1336,8 +1402,8 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 阶段 | 交付物 | 验收标准 | 人日 |
|---|---|---|---|
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 20~30%、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **12 箱**池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 **35%(空转率 65%,口径见 §3.9)**、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **11 箱**池子生成成功,无饥饿桶(尤其 14/15/16 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
| **P2 Java 服务** | `game-mahjong-service` 全套 + 10 处共享文件改动 | ① `mvn -pl skins-service/game-mahjong-service -am compile` 通过;② 开局/续播/重连/封顶/空转局五条链路离线单测;③ 扣费与发奖同一事务、`settled` 幂等可重放、饰品开局即入背包、分解流水口径正确 | 6~8(含饰品/背包/站内信链路) |
| **P3 后台 + SQL 文档** | 7 个 Vue 页面(含奖品管理)+ `docs/updata_sql.md` 新节 + `docs/麻将胡了接口文档.md` | ① 菜单挂载正确、权限生效;② 一键重算与注水/抽水(原因必填)可用;③ 返奖饰品与区间可编辑;④ 接口文档与真实响应逐字段对齐 | 4~5 |
| **P4 客户端** | Mahjong 面板全套 | ① 逐帧复刻服务端盘面(含金色转化、阶梯高亮、**2 胡悬念放大**);② 断线重连回到正确波次;③ 末帧「放入背包/立即分解」+ 自动分解开关 | 15~20 |
@@ -1352,10 +1418,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
**Python**
- `test_engine.py`:
- **前缀链判定逐条覆盖 §2.1 的 7 个正反例**(`2,3,1,0,0` ✅ / `2,3,1,2,0` ✅ 4 连 / `2,0,3,4,4` ❌ / `0,4,4,4,4` ❌ / `1,1,0,4,4` ❌ / `1,2,3,4,4` ✅ ways=96 / `1,1,1,1,1` ✅ ways=1)——**这是全游戏最容易写错的一处,必须单测钉死**;
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `count_wild(grid) ≤ 1`**;
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `W` 只可能出现在第 2/3/4 轴、且盘上百搭数 = 本波中奖金色格数(无上限)**;
- **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `gridState` 变 `W` 且 `wildConverted` 有它;② 该格**没有**被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 `發,發,_,...` 变成 3 连);④ 该 W 在更后面的波参与成奖时**被正常消除**;⑤ 未被中奖涉及的金色格**不转化**、保持原样;
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**百搭不能替代胡**(`發,胡,發` 不成链)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 8/9/10 号高倍桶**——没有买 FS 兜底,最容易饿死)。
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 14/15/16 号高倍桶**——没有买 FS 兜底,最容易饿死)。
- `simulate_cycle.py`:整轮 RTP 与设计的偏差;白号池独立。
**Java**
@@ -1368,7 +1434,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
- 并发:同一用户并发 `dospin` 只有一个能开局(`COMMON_LOCK+userId`)。
**对账 / 回归**
- `Σ record.settled_win`(已结算局) == `Σ` 对应饰品价 × 条数 == 背包里 `tt_box_records.source=15` 的条数口径。
- `Σ record.settled_win`(已结算局) == `Σ tt_box_records.ornaments_price`(source=15)—— 两边都是"本局赢额",逐条对得上(饰品由 `prize_range` 按这个金额选定)。
- 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。
- 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。
- 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。
@@ -1381,14 +1447,14 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|
| 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 |
| 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 |
| 3 | **step 表体量**(~96 万帧,12 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
| 3 | **step 表体量**(~90 万帧,11 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
| 4 | **py / Java 两份 `calcRounds` 不同步**(玫瑰女郎已因此返工) | 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比) |
| 5 | **免费旋转"演不动"**(FS 是白送的 12 旋,预算铺不开时动画会空转) | 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分 |
| 6 | ~~现金直付与平台既有"游戏必给饰品"体验差异~~ | **已定案:走饰品**(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期 |
| 7 | **客户端工作量大**(玫瑰女郎面板是 186KB 单文件) | 直接复制改造,**不做公共 slot 基类抽象**(两个游戏的盘面/动画差异大于共性) |
| 8 | **生成耗时**(本机 i3-10105F,4 物理核) | `--workers` 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意 |
| 9 | **母本代码有文档漂移**(`olympus-py/README.md:459` 声称 RoundPicker 有 `ruoyi:olympus:seen:*` 不重发去重,**实际代码里不存在**) | 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认 |
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数 |
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 14/15/16 号桶有没有条数 |
---
@@ -1402,10 +1468,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| ~~Q1~~ | ~~纯百搭链(第1/2/3轴全百搭)算什么?~~ | ✅ **由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解**——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,**纯百搭链数学上不可能出现**,不需要单独规则、也不需要 `WILD_PAYS` 开关(见 §2.2) |
| ~~Q2~~ | ~~金色→百搭的那个百搭是否"钉住不动"?~~ | ✅ **不钉住**("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") |
| ~~Q3~~ | ~~免费旋转总旋数上限~~ | ✅ **36 旋**(用户 2026-09-21 定案),并配"**发不满就不发**"硬规则:`已发放 + 本次应赠 > 36` 时后续旋不再落 3+ 胡,杜绝"落胡只补几旋" |
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 |
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 12 箱、无 `need_fs`、`costMultiplier≡1` |
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配最高档 ¥500(**无投注倍数**),单局理论最大赔付 **¥250,000**(见 Q7) |
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 11 箱、无 `need_fs`、`costMultiplier≡1` |
| ~~Q6~~ | ~~各身份目标 RTP~~ | ✅ **不是拍板项**:后台配置(箱子级 `rtp` + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.1 |
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.2 |
| Q8 | 每日统计要不要独立日表 | 🔧 **技术默认:不要**,后台按 `record` 聚合 |
| ~~Q9~~ | ~~奖励形态:现金直付 vs 饰品入背包~~ | ✅ **赢额变成一个饰品发到背包** → 区间匹配 → `tt_box_records`(source=15) → 末帧放入/分解 → 分解时写 `MAHJONG_REWARD(69)` |
| Q10 | 后台"回合管理"要不要暴露金色/阶梯明细 | 🔧 **技术默认**:只读展示 `actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘`,不展示内部帧 |
@@ -1428,7 +1494,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 项 | 规则 |
|---|---|
| 投注构成 | 选一个**投注档**(11 档,见下),即为**投注总额**,开局即扣(**不设投注倍数**) |
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000` |
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`(共 **11 档**,**无投注倍数**) |
| 游戏单位 | 金额以 ¥ 为单位 |
| 现金钱包 | 显示当下玩家可用的现金余额 |
| 自动旋转 | 可按玩家选择的回合数量自动进行游戏 |
@@ -1471,7 +1537,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
### 14.5 百搭符号(元宝)
- **百搭符号可替代除「夺宝符号(胡)」以外的所有符号**。
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**一个盘面上最多只会出现 1 个**。
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**每个金色格各留一个、无数量上限**(一个盘面可同时有多个)。
- 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。
- **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。
- 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。
@@ -1525,7 +1591,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
### 14.9 免费旋转模式
- **当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转**。
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次(**一屏最多只出现 5 个胡**,不存在 6 个及以上 → 单次最多 16 旋)。
- **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。
- **免费旋转可以被重复触发**(FS 内再落 3 / 4 / 5 个胡 → **再送 12 / 14 / 16 旋**,与首触发同一套公式,见 §2.6)。
- **免费旋转的总旋数上限为 36 旋**:`已发放 + 本次应赠 > 36` 时,后续旋**不再出现 3+ 个胡**(避免"落了胡却只补几旋")。因为单次最少赠 12 旋,所以**已发放 ≥ 25 旋之后一律不再出胡**,整局永远 ≤ 36 旋。
@@ -1757,7 +1823,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
> `W` 停在原处(轴3 那列没被清空),其余列各自下移一格并补新牌。
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被两个符号同时借用,本旋结束**
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被三个符号同时借用,本旋结束**
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
@@ -1770,11 +1836,12 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|---|---|---|
| **筒 G** | 1(行1) | 1(行4) | **1(百搭当筒)** | 1(行1、行3 → 2) | 1(行4) | **5 连,ways = 1×1×1×2×1 = 2** |
| **索红绿 F** | 1(行4) | 1(行3) | **1(百搭当索红绿)** | 1(行4) | 1(行3) | **5 连,ways = 1×1×1×1×1 = 1** |
| 白板 C | 1(行3) | 1(行2) | 0(W/A/B/H) | — | — | 轴3 断 |
| **白板 C** | 1(行3) | 1(行2) | **1(百搭当白板)** | 0(G/A/G/F) | — | **3 连,ways = 1×1×1 = 1** |
| 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 |
| 發 A | 轴1 没有 | — | — | — | — | 不成奖 |
> 同一个百搭这波**同时被筒和索红绿两个符号借用**,两条链都成立 → **两笔并赔**。
> 同一个百搭这波**同时被筒、索红绿、白板三个符号借用**,三条链都成立 → **三笔并赔**。
> ⚠️ **勘误**:本节原来只算了筒与索红绿两笔,漏了"同一个 `W` 也能当白板"→ 白板 3 连(`loc` 含那个 `W`)。按 `mahjong-py` 引擎判定修正后,本波是三笔并赔,本旋总账从 ¥2180 变为 **¥2380**。
**赔付**:
@@ -1782,33 +1849,34 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|---|---|
| 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | **¥500** |
| 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | **¥500** |
| | | | | 小计 | **¥1000** |
| 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | **¥200** |
| | | | | 小计 | **¥1200** |
**本波共 9 个符号被消除**(筒链 5 格 ∪ 索红绿链 5 格,重叠在 `W` 与部分格):
(行1,轴1)G、(行4,轴1)F、(行4,轴2)G、(行3,轴2)F、**(行1,轴3) W**、(行1,轴4)G、(行3,轴4)G、(行4,轴4)F、(行3,轴5)F、(行4,轴5)G——
`W` 参与了两条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
**本波共 12 个符号被消除**(筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,其中 `W` 被三条链共用 → 6 + 5 + 3 − 2 = 12):
轴1:G(行1)、C(行3)、F(行4) 轴2:C(行2)、F(行3)、G(行4) 轴3:**(行1) W** 轴4:G(行1)、G(行3)、F(行4) 轴5:F(行3)、G(行4)
`W` 参与了三条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
#### 盘面演变(第 4 波 → 本旋结束)
**① 消除瞬间**(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 `W`):
**① 消除瞬间**(12 格全爆:筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,`W` 被三条链共用):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | A | ▢ | ▢ | C |
| 行2 | H | C | A | A | S |
| 行3 | C | ▢ | B | ▢ | ▢ |
| 行2 | H | ▢ | A | A | S |
| 行3 | ▢ | ▢ | B | ▢ | ▢ |
| 行4 | ▢ | ▢ | H | ▢ | ▢ |
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**:
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**(下表是引擎的一次真实结算,补位是新抽的牌):
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|---|---|---|---|---|---|---|
| 行1 | **E** | **G** | **E** | **C** | **A** | 轴1:H/C 下移两格 → 落进 2 张 |
| 行2 | **F** | **D** | A | **D** | **B** | 轴2:A/C 下移两格 → 落进 2 张 |
| 行3 | H | A | B | **E** | C | 轴3:A/B/H 下移一格 → 落进 1 张 |
| 行4 | C | C | H | A | S | 轴4:A 落到底 + 落进 3 张;轴5:C/S 下移 + 落进 2 张 |
| 行1 | **H** | **D** | **F** | **F** | **S** | 轴1:3 格空洞(G/C/F 被消)→ 只剩 H 存活、下移三格到行4;顶部补 H、G、E |
| 行2 | **G** | **E** | A | **G** | **H** | 轴2:3 格空洞(C/F/G 被消)→ A 落到行4;顶部补 D、E、D |
| 行3 | **E** | **D** | B | **D** | C | 轴3:只有一个空洞(`W` 被消)→ A/B/H 各上一格;顶部补 F |
| 行4 | H | A | H | A | S | 轴4:3 格空洞 → A 落到底 + 补 F、G、D;轴5:2 格空洞 → C/S 下移两格 + 补 S、H |
→ 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而**轴3 没有 C** → **无任何中奖组合,本旋结束(整局共 4 波)**。
→ 复核这个盘面:轴1 里有 H/G/E,其中 E 能在轴2 对上(行2),但**轴3 没有 E** → 轴3 断链;H、G 在轴2 就已经断了 ⇒ **无任何中奖组合,本旋结束(整局共 4 波)**。
### 本旋总账
@@ -1820,12 +1888,13 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 3 | 花筒 5 连 | 1 | 1 | **1.00 倍** | x3 | ¥300 |
| 4 | 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | ¥500 |
| 4 | 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | ¥500 |
| | | | | | 合计 | **¥2180** |
| 4 | 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | ¥200 |
| | | | | | 合计 | **¥2380** |
> 计算方法:**本笔基础倍率 = 符号倍率 × ways**;**本波赢额 = Σ本笔基础倍率 × 投注总额 × 本波阶梯倍率**(投注总额 ¥100)。
> 例:第 2 波 `0.75 × 4 = 3.00 倍` → `3.00 × 100 × 2 = ¥600` ✓
- 投注总额 ¥100 → **本局总倍率 = 2180 ÷ 100 = 21.80x**(未触及 500x 封顶)
- 投注总额 ¥100 → **本局总倍率 = 2380 ÷ 100 = 23.80x**(未触及 500x 封顶)
- 本旋只有 **1 个胡**(一直挂在轴5)→ **不触发免费旋转**(需 3+)
### 百搭轨迹(这张表是重点:**几个金色格中奖,就留几个百搭**)
@@ -1836,21 +1905,21 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 第 1 波结束 | 0 → **2** | 同波有**两张**金色「中」中奖 → **各留一个百搭**(轴2、轴3 各一个)。两个 `W` 随后**都随重力从行2 掉到行3** |
| 第 2 波结束 | 2 → **0** | **两个 `W` 都参与了八萬链**(满足消除条件)→ 一起被消除;本波无金色中奖,不补 |
| 第 3 波结束 | 0 → **1** | 本波只有 **1 张**金色「花筒」中奖 → 留 **1 个** `W`;该列没有空洞,所以它**原地不动** |
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补 |
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链、索红绿链、白板链 → 被消除;本波无金色中奖,不补 |
| 旋结束 | 0 | 无后续中奖;下一旋整盘重发 |
> **百搭数量没有上限**(第 1 波就同时有 2 个),但**只可能出现在第 2/3/4 轴**——因为金色格只在那三轴,而百搭的唯一来源是金色转化。
> 这条推论很关键:**第 1 轴永远不会有百搭**,所以前缀链的第 1 轴必然是自然符号 ⇒ **任何一笔中奖都至少含 1 个自然符号** ⇒ **"纯百搭链"在数学上不可能出现**(Q1 因此永久关闭,不需要额外规则)。
> 全程每一波盘面上的百搭数都 **≤ 1** ✅,且**永远只由金色转化产生** ✅。
> 每一波盘面上的百搭数 **= 本波中奖的金色格数**(0 / 1 / 2 …,无上限),且**永远只由金色转化产生** ✅。
> 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。
---
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 30 → FINISH)
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 36 → FINISH)
> 场景:**普通旋转**,投注档 **¥100**。
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 30**、FINISH 帧。
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 36**、FINISH 帧。
> 图例同 §15:`A`發 `B`中 `C`白板 `D`八萬 `E`花筒 `F`索红绿 `G`筒 `H`索绿 `W`百搭 `S`胡 **`*`=金色**。
### 16.1 基础旋(`spinNum = 0`,阶梯 x1)——触发盘
@@ -2020,13 +2089,13 @@ boxRecordId = 背包记录 id // 末帧「放入背包 / 立即
| 生成端补桶/饥饿重校准 | `olympus-py/olympus_simulator.py:740-805` |
| 级联几何复刻测试 | `olympus-py/tests/test_simulator.py::TestTumbleGeometry` |
| 平台流水 source 聚合 | `skins-model/.../TtAccountRecordSource.java:193-279` |
| 建表 + 菜单 SQL 写法 | `docs/updata_sql.md:1144-1225` |
| 接口契约模板 | `docs/玫瑰女郎接口文档.md`(706 行) |
| 建表 + 菜单 SQL 写法 | ~~`docs/updata_sql.md:1144-1225`~~(该台账已按需求清空,**不再可用**)→ 建表以 `mahjong-py/schema.sql` 为权威;菜单直接对现库 `sys_menu` 操作 |
| 接口契约模板 | ~~`docs/玫瑰女郎接口文档.md`~~(已按需求删除)→ 沿用本仓现存 `docs/疯狂转盘接口文档.md` / `docs/流水抽奖接口文档.md` 的写法 |
## 附录 C:待新增的运维 runbook(对齐 `olympus-py/README.md`)
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 12 箱 × 4 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥500/¥1000 这两档必须单独注**)。
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode X` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 11 箱 × 3 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥300/¥500 这两档必须单独注**)。
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode normal` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
3. **重建池子**:`_regen.py snapshot` → `generate_rounds.py --clear --write` → 重算 → 清 Redis → 刷模板 → `_regen.py verify`。
4. **调水位**:后台奖池水位页按身份逐箱注水/抽水(**原因必填**)或批量按"一轮花费基数 × 比例%"。
5. **日常体检**:`_regen.py check`(每桶有条目 / 无封顶缺失 / 池内 RTP)+ `simulate_cycle.py` 抽样。
+38
View File
@@ -0,0 +1,38 @@
# mahjong-py · 麻将胡了(回合池生成器)
规则唯一事实来源是 `docs/麻将胡了设计方案.md`;本目录是它的 Python 实现(母本风格照 `D:\changzhou_app\changzhou_backend\olympus-py`,但**引擎是重写的**:麻将从盘面自己判定,母本只记账)。
## 现在有什么 / 还没有什么
| 状态 | 内容 |
|---|---|
| ✅ 已实现 | `mahjong/config.py`(规则参数唯一来源)、`models.py`、`engine.py`(前缀链 / 金色就地转百搭 / 列内重力+补位 / 阶梯 / FS / 500x 封顶)、`tests/test_engine.py`(11 项)、`mahjong_simulator.py`(自然盘面仿真) |
| ⛔ 还没有 | `generate_rounds.py`(按桶配额主动摆盘面 + bias 反解 + 落库)、`_recalc_boxes.py`(配比)、`schema.sql`、`mahjong_db.py`、`_make_templates.py`、`simulate_cycle.py`、`_regen.py` |
## 跑起来
```bash
cd mahjong-py
set PYTHONIOENCODING=utf-8 # Windows:中文输出
python tests/test_engine.py # 11 项规则单测(无需 pytest)
python mahjong_simulator.py --rounds 200000 --seed 2026 # 自然盘面仿真(不含偏置构造)
```
## 引擎的 6 条铁律(写代码时别绕开)
1. **前缀链**:从第 1 轴起 1→2→3→4→5 连续,断链整笔作废;`ways` 只乘链内轴。
2. **金色格只当它自己的自然符号**(不做别的符号的替代);"顶替别人"只有 `W` 会做。
3. **金色中奖格就地变 `W`**:不清空、不补牌;几个金格中奖就留几个 `W`(无上限)。金色只镀在第 2/3/4 轴的普通符号上 ⇒ **`W` 永远不落在第 1/5 轴**。
4. **`W` 要满足消除链才被消除**;没参与成奖就留到下一波。同一个 `W` 可以同时被多个符号借用。
5. **金色标记跟着符号下落**(不是跟着格号)——否则会"传染"给新补的牌。
6. **阶梯每旋重置**:基础 `1/2/3/5`、FS `2/4/6/10`;**一屏最多 5 个胡**;FS 上限 36 旋且"发不满就不发"。
## 与设计文档的已知偏差(文档已同步勘误)
- §15 第 4 波原本只算两笔奖,漏了"同一个 `W` 也能当白板"→ 白板 3 连。修正后本波 ¥1200、本旋总账 **¥2380**(原 ¥2180)。
- §3.9 是新加的空转率结论(65%),依据就是本目录 20 万局的自然仿真数据。
## ponytail 备注
- 生成端(配额/偏置)**故意还没写**:它的形状取决于 §3.9 的空转率口径与桶配比,先把引擎钉死再往上堆,避免拿未验证的数学去凑池子。
- 仿真只做自然盘面,没有引入任何"目标倍率反解";`play_spin` 已经支持外部传盘面,构造器接上去即可。
+405
View File
@@ -0,0 +1,405 @@
"""麻将胡了 · 回合池生成器:把引擎跑出的回合写进 `game_mahjong_round` / `game_mahjong_step`。
两种模式:
--mode natural (默认)自然形态:不干预,直接采样落库。**RTP≈5.25,只能测链路,不能当验收**。
--mode quota 配额构造:先标定每桶池内实测均值 → 二分解出 16 桶配额(空转率锁 65%、目标 RTP 0.80)
→ 按配额定量填充。**这才是能上线/能做 RTP 验收的池子**。
用法(在 mahjong-py 目录下):
python generate_rounds.py --mode quota --per-box 1000 --dry-run
python generate_rounds.py --mode quota --per-box 1000 --write
python generate_rounds.py --mode natural --per-box 100 --dry-run
python generate_rounds.py --boxes 1,2 --per-box 100 --write --mode quota
写库内容(两种模式一样):
1. `game_mahjong_round`:每局一行(bucket_id / actual_mul / 旋数 / 波数 / has_fs / near_miss)
2. `game_mahjong_step`:每帧一行(逐波;含 grid_state / ways_detail / 下落补位 / 阶梯 / 触发信息)
3. `game_mahjong_box_bucket`:**配额就是 count**(3 个身份各一套,used=0)
4. `game_mahjong_box_template`:每箱每身份一条生效模板(= 配额快照,供抽干补桶)
5. `game_mahjong_box.total_rounds`:回写成本次生成的回合数
"""
from __future__ import annotations
import argparse
import json
import math
import random
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import GRID_COLS, GRID_ROWS, play_round # noqa: E402
DB = dict(host="127.0.0.1", port=23306, user="henan", password="iTSJSPPZM3LSGAPC", database="henan")
EMPTY_SHARE = 0.65 # 空转率(桶 1 配额占比,见设计文档 §3.9);未按箱分档时的统一值
# 按档调波动(用户 2026-09-22 定):**所有箱子 RTP 都锁 0.80**,但波动不同 ——
# 低档(1~20):空转低 → 剩余配额必须更"平" → 小奖更多、手感热;
# 高档(200~500):空转高 → 剩余配额更"陡" → 中大奖更多、博性更强。
# 机制:`solve_quotas` 会把"剩余部分"的加权均值凑到 `0.80/(1-空转率)`,所以
# **只改空转率就能同时改形状**,不用再单独调 power。
VOLATILITY_LADDER = (
(20, 0.62, "低档:空转 62%,小奖更多(热)"),
(100, 0.65, "中档:空转 65%(设计基准)"),
(10 ** 9, 0.68, "高档:空转 68%,中大奖更多(陡)"),
)
def empty_share_for(box_id: int) -> float:
bet = C.bet_of(box_id)
for upper, share, _ in VOLATILITY_LADDER:
if bet <= upper:
return share
return EMPTY_SHARE
TARGET_RTP = 0.80
CALIB_ROUNDS = 6000 # 每箱标定样本(用来量每个桶的**池内实测**均值)
MAX_TRIES = 400_000 # 单个桶的采样上限(防低概率桶把时间吃光)
# ---------------------------------------------------------------- 行构造
def grid_rows(flat) -> list:
"""扁平 20 格 → 4 行 × 5 列(接口用的 gridState 形状)。"""
return [list(flat[r * GRID_COLS:(r + 1) * GRID_COLS]) for r in range(GRID_ROWS)]
def build_steps(round_, round_id: int):
"""把一局摊成逐帧行。"""
rows, step_seq = [], 0
spin_win = 0.0
for spin in round_.spins:
spin_win_before = spin_win
acc_sym = Counter()
winners = []
rotations = spin.rotations
frames = rotations or (None,) # 空旋也要发一帧(落定盘面),否则客户端无从渲染
for i, rot in enumerate(frames, start=1):
step_seq += 1
if rot is None:
grid_flat = spin.landing
ways, removed, drops, fills, conv, gold = [], [], [], [], [], []
base_mul, rot_mul = 0.0, C.ladder_mul(1, spin.is_fs)
else:
grid_flat = rot.grid_before
gold = [{"pos": p, "symbol": rot.grid_before[p - 1]} for p in rot.golden_before]
conv = [{"pos": p, "fromSymbol": s} for p, s in rot.converted]
ways = [{"symbol": w.symbol, "reelCount": w.run, "counts": list(w.counts),
"loc": list(w.loc), "ways": w.ways, "payBase": w.pay_base,
"rotationMul": rot.rotation_mul,
"win": round(w.pay_base * rot.rotation_mul, 4)}
for w in rot.wins]
seen = set()
removed = [p for w in rot.wins for p in w.loc if not (p in seen or seen.add(p))]
drops = [{"from": a, "to": b, "symbol": s} for a, b, s in rot.drops]
fills = [{"pos": p, "symbol": s} for p, s in rot.fills]
base_mul, rot_mul = rot.base_mul, rot.rotation_mul
for w in rot.wins:
acc_sym[w.symbol] += len(w.loc)
winners.append({"symbol": w.symbol, "count": w.run,
"win": round(w.pay_base * rot.rotation_mul, 4)})
spin_win = spin_win_before + sum(r.win_mul for r in rotations[:i])
is_last = (i == len(frames))
scat = ({"scatterCount": spin.scatters, "freeSpins": spin.fs_awarded, "extraSpins": 0}
if spin.index == 0 and i == 1 and spin.fs_awarded else None)
retri = ({"scatterCount": spin.scatters, "extraSpins": C.fs_award(spin.scatters)}
if spin.index > 0 and i == 1 and spin.retrigger else None)
rows.append(dict(
id=round_id * 100 + step_seq,
round_id=round_id,
reel_spin_id=spin.index,
rotation_seq=i,
grid_state=json.dumps(grid_rows(grid_flat), ensure_ascii=False) if grid_flat else None,
golden_detail=json.dumps(gold, ensure_ascii=False) if gold else None,
wild_converted=json.dumps(conv, ensure_ascii=False) if conv else None,
ways_detail=json.dumps(ways, ensure_ascii=False) if ways else None,
accumulated_symbols=json.dumps(dict(acc_sym), ensure_ascii=False),
spin_winners=json.dumps(winners, ensure_ascii=False),
removed_positions=json.dumps(removed),
drop_data=json.dumps(drops, ensure_ascii=False) if drops else None,
fill_data=json.dumps(fills, ensure_ascii=False) if fills else None,
scatter_trigger=json.dumps(scat, ensure_ascii=False) if scat else None,
retrigger_info=json.dumps(retri, ensure_ascii=False) if retri else None,
base_score=round(base_mul, 4),
win_amount=round(sum(r.win_mul for r in rotations[:i]), 4),
rotation_mul=rot_mul,
spin_win=round(spin_win, 4),
win_capped=1 if (round_.capped and is_last and spin is round_.spins[-1]) else 0,
bigwin_level=None,
is_final=1 if is_last else 0,
))
spin_win = spin_win_before + sum(r.win_mul for r in rotations)
return rows
def make_round(box_id: int, seq: int, rng: random.Random):
r = play_round(rng, box_id=box_id)
round_id = box_id * 1_000_000 + seq
steps = build_steps(r, round_id=round_id)
row = dict(round_id=round_id, box_id=box_id, bucket_id=C.bucket_of(r.actual_mul),
actual_mul=round(r.actual_mul, 4), free_spin_count=r.free_spins,
total_rotations=sum(len(s.rotations) for s in r.spins),
has_fs=1 if r.has_fs else 0, near_miss=1 if r.near_miss else 0)
return row, steps
# ---------------------------------------------------------------- 配额构造
def calibrate(box_id: int, n: int, seed: int):
"""小样本标定:每个桶的**池内实测均值**(自然均值与名义 expect_mul 差很多,必须实测)。"""
rng = random.Random(seed)
tot, cnt = Counter(), Counter()
for _ in range(n):
r = play_round(rng, box_id=box_id)
b = C.bucket_of(r.actual_mul)
tot[b] += r.actual_mul
cnt[b] += 1
means = {}
for b in C.BUCKETS:
means[b.id] = (tot[b.id] / cnt[b.id]) if cnt[b.id] else b.expect_mul
return means, cnt
def _quota_from_power(means, power: float, empty_share: float):
"""w_b = 30×0.6^((b-1)×power)(b≥2),桶 1 锁 empty_share;返回 16 个配额(下标 1..16)。"""
ws = {b.id: 30.0 * (0.6 ** ((b.id - 1) * power)) for b in C.BUCKETS if b.id >= 2}
s = sum(ws.values())
q = {b.id: 0.0 for b in C.BUCKETS}
q[1] = empty_share
for b_id, w in ws.items():
q[b_id] = (1.0 - empty_share) * w / s
return q
def solve_quotas(means, target_rtp=TARGET_RTP, empty_share=EMPTY_SHARE):
"""二分 power,使 Σ q_b × mean_b = target_rtp。power 越大越偏向低倍桶(期望越低)。"""
def expected(power):
q = _quota_from_power(means, power, empty_share)
return sum(q[b.id] * means[b.id] for b in C.BUCKETS)
lo, hi = 0.05, 8.0
# expected(power) 随 power **递减**:power 越大越偏向低倍桶
for _ in range(80):
mid = (lo + hi) / 2
if expected(mid) > target_rtp:
lo = mid # 期望偏高 → 还要更大的 power
else:
hi = mid
q = _quota_from_power(means, (lo + hi) / 2, empty_share)
return q, expected((lo + hi) / 2)
def _fill(box_id: int, targets: dict, seed: int, max_tries=MAX_TRIES):
"""按桶目标条数填充:落进目标桶才收。返回 (pool, bucket_cnt, short)。"""
rng = random.Random(seed)
pool, tries, short = [], Counter(), []
for b in C.BUCKETS:
got = 0
while got < targets[b.id] and tries[b.id] < max_tries:
tries[b.id] += 1
row, steps = make_round(box_id, len(pool) + 1, rng)
if row["bucket_id"] == b.id:
pool.append((row, steps))
got += 1
if got < targets[b.id]:
short.append(f"桶{b.id} {got}/{targets[b.id]}")
for i, (row, steps) in enumerate(pool, start=1):
rid = box_id * 1_000_000 + i
row["round_id"] = rid
for j, st in enumerate(steps, start=1):
st["round_id"] = rid
st["id"] = rid * 100 + j
cnt = Counter(row["bucket_id"] for row, _ in pool)
return pool, cnt, short
def quota_targets(per_box: int, quotas: dict):
targets = {b.id: int(round(per_box * quotas[b.id])) for b in C.BUCKETS}
diff = per_box - sum(targets.values())
if diff:
targets[1] += diff
return targets
def _sample_bucket(box_id: int, bucket_id: int, rng: random.Random, max_tries=MAX_TRIES):
"""采一条落进指定桶的回合(低概率桶会多试几次)。"""
for _ in range(max_tries):
row, steps = make_round(box_id, 0, rng)
if row["bucket_id"] == bucket_id:
return row, steps
return None
def _tune(box_id: int, pool: list, target_rtp: float, per_box: int, seed: int, verbose=True):
"""整数可执行的定向微调:配额算出来是小数,四舍五入会把「0.4 条」的尾部桶抹成 0 条,
于是它本该贡献的期望就丢了(实测池内均值会系统性偏低 5~8pt)。
做法:拿空转局(桶 1,倍率 0)去换中高桶的回合,每换一条按**真实倍率**结账,
直到池内均值落进目标 ±0.005 以内。空转率只会动几条(<0.5pt)。
"""
rng = random.Random(seed + 31337)
cur = sum(r["actual_mul"] for r, _ in pool)
need = target_rtp * per_box - cur # 还差多少(单位:倍率总和)
empties = [i for i, (r, _) in enumerate(pool) if r["bucket_id"] == 1]
ordered = sorted((x for x in C.BUCKETS if x.id >= 2), key=lambda x: -x.expect_mul)
swaps = 0
while need > 0.6 and empties and ordered:
cand = next((b for b in ordered if b.expect_mul <= need), None)
if cand is None:
break
got = None
for _ in range(300): # 只收"不超过缺口"的回合,保证不会冲过头
s = _sample_bucket(box_id, cand.id, rng)
if s and s[0]["actual_mul"] <= need:
got = s
break
if got is None: # 这一档试不出来,退一档
ordered = [b for b in ordered if b.id != cand.id]
continue
row, steps = got
pool[empties.pop()] = (row, steps)
need -= row["actual_mul"]
swaps += 1
rng = random.Random(rng.random()) # 让后续采样换流
if verbose and swaps:
print(f" 微调:换入 {swaps} 条中高桶回合(空转率随之动 {swaps / per_box:.2%})")
cnt = Counter(r["bucket_id"] for r, _ in pool)
rtp = sum(r["actual_mul"] for r, _ in pool) / max(1, len(pool))
if verbose:
print(f" 微调后池内实测 RTP {rtp:.4f}(与目标差 {abs(rtp - target_rtp):.4f})")
return pool, cnt
def generate_quota(box_id: int, per_box: int, seed: int, empty_share: float, target_rtp: float,
verbose=True):
"""解配额 → 定条数 → 填充 → 整数微调 → 重排 id。
`empty_share`:桶 1 的配额占比。CLI 不传时按 `empty_share_for(box_id)` 取**按档波动**的值
(低档 62% / 中档 65% / 高档 68%),这样各箱形状不同、但 RTP 都锁在 `target_rtp`。
为什么填完还要微调:运行时按库存消耗(一个 cycle 每条回合正好播一次)⇒ `cycle RTP = 池内均值`,
而配额是连续解出来的小数,四舍五入会抹掉尾部桶(见 `_tune` 的注释)。
"""
means, _ = calibrate(box_id, CALIB_ROUNDS, seed)
quotas, exp_rtp = solve_quotas(means, target_rtp, empty_share)
targets = quota_targets(per_box, quotas)
if verbose:
print(f" 标定:配额理论期望 {exp_rtp:.4f}(目标 {target_rtp});桶1 配额 {quotas[1]:.2%};"
f"桶条数(1/2/3/12/16) = {targets[1]}/{targets[2]}/{targets[3]}/{targets[12]}/{targets[16]}")
pool, cnt, short = _fill(box_id, targets, seed + 999 + box_id * 7919)
pool, cnt = _tune(box_id, pool, target_rtp, per_box, seed + box_id, verbose)
for i, (row, steps) in enumerate(pool, start=1): # 重排 id(采样与微调都打乱了顺序)
rid = box_id * 1_000_000 + i
row["round_id"] = rid
for j, st in enumerate(steps, start=1):
st["round_id"] = rid
st["id"] = rid * 100 + j
return pool, quotas, means, exp_rtp, short
# ---------------------------------------------------------------- 写库
def write(conn, pool_by_box, per_box, audiences=C.AUDIENCES):
cur = conn.cursor()
total_r = total_s = 0
for box, (pool, quotas, bucket_cnt) in pool_by_box.items():
rounds = [row for row, _ in pool]
steps = [st for _, sts in pool for st in sts]
lo, hi = box * 1_000_000 + 1, box * 1_000_000 + per_box
cur.execute(f"DELETE FROM game_mahjong_step WHERE round_id BETWEEN {lo} AND {hi}")
cur.execute(f"DELETE FROM game_mahjong_round WHERE box_id={box}")
cur.executemany(
"INSERT INTO game_mahjong_round (round_id,box_id,bucket_id,actual_mul,free_spin_count,"
"total_rotations,has_fs,near_miss) VALUES (%(round_id)s,%(box_id)s,%(bucket_id)s,"
"%(actual_mul)s,%(free_spin_count)s,%(total_rotations)s,%(has_fs)s,%(near_miss)s)", rounds)
cols = ("id", "round_id", "reel_spin_id", "rotation_seq", "grid_state", "golden_detail",
"wild_converted", "ways_detail", "accumulated_symbols", "spin_winners",
"removed_positions", "drop_data", "fill_data", "scatter_trigger", "retrigger_info",
"base_score", "win_amount", "rotation_mul", "spin_win", "win_capped",
"bigwin_level", "is_final")
cur.executemany(
f"INSERT INTO game_mahjong_step ({','.join(cols)}) "
f"VALUES ({','.join('%(' + c + ')s' for c in cols)})", steps)
for aud in audiences:
cur.execute("DELETE FROM game_mahjong_box_bucket WHERE box_id=%s AND audience=%s", (box, aud))
cur.executemany(
"INSERT INTO game_mahjong_box_bucket (box_id,bucket_id,audience,count,used) "
"VALUES (%s,%s,%s,%s,0)",
[(box, b.id, aud, bucket_cnt.get(b.id, 0)) for b in C.BUCKETS])
items = json.dumps([{"bucketId": b.id, "count": bucket_cnt.get(b.id, 0)} for b in C.BUCKETS],
ensure_ascii=False)
cur.execute("DELETE FROM game_mahjong_box_template WHERE box_id=%s AND audience=%s", (box, aud))
cur.execute(
"INSERT INTO game_mahjong_box_template (box_id,audience,name,total_rounds,items_json,is_active) "
"VALUES (%s,%s,%s,%s,%s,1)",
(box, aud, f"配额池-{per_box}局", per_box, items))
cur.execute("UPDATE game_mahjong_box SET total_rounds=%s WHERE box_id=%s", (per_box, box))
conn.commit()
total_r += len(rounds)
total_s += len(steps)
rtp = sum(r["actual_mul"] for r in rounds) / max(1, len(rounds))
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rounds):>5} 帧 {len(steps):>6} "
f"**池内实测 RTP {rtp:.4f}** 空转 {bucket_cnt.get(1,0)/max(1,len(rounds)):.1%} "
f"FS 局 {sum(r['has_fs'] for r in rounds)}")
print(f"合计:回合 {total_r},帧 {total_s}")
return total_r, total_s
def build_natural(box_id, per_box, seed):
rng = random.Random(seed + box_id)
pool, cnt = [], Counter()
for i in range(1, per_box + 1):
row, steps = make_round(box_id, i, rng)
pool.append((row, steps))
cnt[row["bucket_id"]] += 1
return pool, cnt
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=("natural", "quota"), default="natural")
ap.add_argument("--per-box", type=int, default=1000)
ap.add_argument("--boxes", default="")
ap.add_argument("--seed", type=int, default=20260922)
ap.add_argument("--empty-share", type=float, default=None,
help="空转率(桶 1 配额占比);不传=按档取(低档 0.62 / 中档 0.65 / 高档 0.68)")
ap.add_argument("--target-rtp", type=float, default=TARGET_RTP)
ap.add_argument("--write", action="store_true")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--host", default=DB["host"]); ap.add_argument("--port", type=int, default=DB["port"])
ap.add_argument("--user", default=DB["user"]); ap.add_argument("--password", default=DB["password"])
ap.add_argument("--database", default=DB["database"])
args = ap.parse_args()
boxes = [int(x) for x in args.boxes.split(",") if x.strip()] or list(C.BOX_IDS)
print(f"==== {args.mode} 模式:{len(boxes)} 箱 × {args.per_box} 局"
f"{'' if args.write and not args.dry_run else '(干跑,不写库)'} ====")
pool_by_box = {}
for box in boxes:
if args.mode == "natural":
pool, cnt = build_natural(box, args.per_box, args.seed)
quotas = None
pool_by_box[box] = (pool, quotas, cnt)
else:
share = args.empty_share if args.empty_share is not None else empty_share_for(box)
pool, quotas, means, exp_rtp, short = generate_quota(
box, args.per_box, args.seed, share, args.target_rtp)
cnt = Counter(row["bucket_id"] for row, _ in pool)
if short:
print(f" 未填满:{';'.join(short)}")
pool_by_box[box] = (pool, quotas, cnt)
if not (args.write and not args.dry_run):
rows = [r for r, _ in pool_by_box[box][0]]
rtp = sum(r["actual_mul"] for r in rows) / max(1, len(rows))
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rows):>5} "
f"**池内实测 RTP {rtp:.4f}** 空转 {cnt.get(1,0)/max(1,len(rows)):.1%} "
f"FS 局 {sum(r['has_fs'] for r in rows)}")
if not (args.write and not args.dry_run):
return
import pymysql
conn = pymysql.connect(host=args.host, port=args.port, user=args.user,
password=args.password, database=args.database, charset="utf8mb4")
write(conn, pool_by_box, args.per_box)
conn.close()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
"""麻将胡了 · 生成器包(规则参数 / 模型 / 引擎)。"""
from .config import * # noqa: F401,F403
from .engine import ( # noqa: F401
assert_no_native_wild, assert_wild_only_middle, count_scatters, new_grid,
play_round, play_spin, pos, settle_rotation, ways_wins,
)
from .models import Round, Rotation, Spin, Win # noqa: F401
+166
View File
@@ -0,0 +1,166 @@
"""麻将胡了 · 唯一事实来源(规则参数)。
数值全部取自 docs/麻将胡了设计方案.md(§2 规则 / §3 桶与 RTP / §2.8 投注档)。
改规则改这里;生成器、引擎、单测、仿真都从这里读。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Mapping, Tuple
# ============================================================
# 盘面:5 轴 × 4 行 = 20 格,ways = 4^5 = 1024
# ============================================================
GRID_COLS = 5
GRID_ROWS = 4
GRID_SIZE = GRID_ROWS * GRID_COLS
WAYS = GRID_ROWS ** GRID_COLS
WILD = "W" # 百搭(元宝):只能由金色符号转化产生,卷轴永不落
SCATTER = "S" # 胡(夺宝):只触发免费旋转,不赔现金
# 格位约定:接口/落库用 1 基、行优先(1~20);引擎内部用 0 基。
# 引擎内 index = row * GRID_COLS + col;pos() 负责换算(见 engine.py)。
@dataclass(frozen=True)
class SymbolRule:
code: str
name: str
payouts: Mapping[int, float] # 连轴数(3/4/5) → 倍投注总额
# 赔付表:原版"每条基础投注点数"已 ÷20 内化,表中数值**直接是倍率**
SYMBOL_RULES: Dict[str, SymbolRule] = {
"A": SymbolRule("A", "發", {3: 0.75, 4: 3.0, 5: 5.0}),
"B": SymbolRule("B", "中", {3: 0.5, 4: 2.0, 5: 4.0}),
"C": SymbolRule("C", "白板", {3: 0.4, 4: 1.0, 5: 3.0}),
"D": SymbolRule("D", "八萬", {3: 0.3, 4: 0.75, 5: 2.0}),
"E": SymbolRule("E", "花筒", {3: 0.2, 4: 0.5, 5: 1.0}),
"F": SymbolRule("F", "索红绿", {3: 0.2, 4: 0.5, 5: 1.0}),
"G": SymbolRule("G", "筒", {3: 0.1, 4: 0.25, 5: 0.5}),
"H": SymbolRule("H", "索绿", {3: 0.1, 4: 0.25, 5: 0.5}),
}
PAYING: Tuple[str, ...] = ("A", "B", "C", "D", "E", "F", "G", "H")
PAYTABLE: Dict[str, Mapping[int, float]] = {code: r.payouts for code, r in SYMBOL_RULES.items()}
MIN_CHAIN = 3 # 前缀链至少 3 连才成奖(只有 3/4/5 三档)
# 卷轴出现权重:只有 8 个付费符号 + 胡(**没有 W**,原生盘面永不落百搭)
SYMBOL_WEIGHTS: Dict[str, int] = {
"A": 5, "B": 6, "C": 8, "D": 9, "E": 11, "F": 11, "G": 13, "H": 13, "S": 2,
}
# 金色麻将符号:只可能出现在第 2/3/4 轴(0 基列 1..3)
GOLDEN_COLS: Tuple[int, ...] = (1, 2, 3)
GOLDEN_CELL_PROB = 0.10 # 逐格独立;新补入的符号也可以带金
# ============================================================
# 乘数阶梯(本波阶梯倍率):每旋从第一档重新开始,不跨旋累计
# ============================================================
LADDER_BASE: Tuple[int, ...] = (1, 2, 3, 5)
LADDER_FS: Tuple[int, ...] = (2, 4, 6, 10)
def ladder_mul(rotation_seq: int, is_fs: bool) -> int:
"""第 n 波(1 基)的阶梯倍率;第 4 波及以后取最后一档。"""
if rotation_seq < 1:
raise ValueError("波序从 1 开始")
ladder = LADDER_FS if is_fs else LADDER_BASE
return ladder[min(rotation_seq, len(ladder)) - 1]
# ============================================================
# 免费旋转:3+ 胡 → 12 旋 + 每多 1 个 +2;一屏最多 5 个胡;上限 36 旋
# "发不满就不发":已发放 + 本次应赠 > 36 时,本旋绝不落 3+ 胡
# ============================================================
SCATTER_TRIGGER = 3
FS_BASE_SPINS = 12
FS_EXTRA_PER_SCATTER = 2
MAX_SCATTER_PER_SCREEN = 5
MAX_FREE_SPINS = 36
def fs_award(scatter_count: int) -> int:
return FS_BASE_SPINS + FS_EXTRA_PER_SCATTER * max(0, scatter_count - SCATTER_TRIGGER)
# 单局封顶:总倍率到达即截断,剩余旋作废
MAX_WIN_CAP_MUL = 500
# 悬念盘:基础旋恰好 2 个胡且第 5 轴无胡(生成端配额,客户端播第 5 列放大)
NEARMISS_SCATTER_COUNT = 2
NEARMISS_2SCATTER_RATE = 0.015
# ============================================================
# 投注档:11 档纯投注档,无投注倍数(betMul 仅预留、服务端只接受 1)
# price = cost = box.bet;boxId = 序号(1~11)
# ============================================================
BET_LADDER: Tuple[int, ...] = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500)
BOX_IDS: Tuple[int, ...] = tuple(range(1, len(BET_LADDER) + 1))
def bet_of(box_id: int) -> int:
return BET_LADDER[box_id - 1]
# ============================================================
# 饰品桶(16 档,0x~500x):沿用玫瑰女郎 17 档结构、删掉 1000x 那档
# 期望倍率 = (区间下限 + 上限) / 2;区间连续无缝
# 桶 16 区间上界故意开到 700 作缓冲,但生成端"先按 500 截断再落桶"
# ============================================================
@dataclass(frozen=True)
class Bucket:
id: int
name: str
expect_mul: float
range_low: float
range_high: float
BUCKETS: Tuple[Bucket, ...] = (
Bucket(1, "0倍饰品", 0.0, 0.0, 0.0),
Bucket(2, "0.5倍饰品", 0.5, 0.01, 1.0),
Bucket(3, "1.5倍饰品", 1.5, 1.0, 2.0),
Bucket(4, "2.5倍饰品", 2.5, 2.0, 3.0),
Bucket(5, "4倍饰品", 4.0, 3.0, 5.0),
Bucket(6, "6.5倍饰品", 6.5, 5.0, 8.0),
Bucket(7, "10倍饰品", 10.0, 8.0, 12.0),
Bucket(8, "16倍饰品", 16.0, 12.0, 20.0),
Bucket(9, "25倍饰品", 25.0, 20.0, 30.0),
Bucket(10, "40倍饰品", 40.0, 30.0, 50.0),
Bucket(11, "60倍饰品", 60.0, 50.0, 70.0),
Bucket(12, "100倍饰品", 100.0, 70.0, 130.0),
Bucket(13, "150倍饰品", 150.0, 130.0, 170.0),
Bucket(14, "200倍饰品", 200.0, 170.0, 230.0),
Bucket(15, "300倍饰品", 300.0, 230.0, 370.0),
Bucket(16, "500倍饰品(封顶)", 500.0, 370.0, 700.0),
)
FIXED_CAP_BUCKET_ID = 16 # 封顶桶
def bucket_of(mul: float) -> int:
"""按**已截断**的倍率落桶(顺序:先 min(mul, 500) 再落桶)。<0.01 视为空转。"""
mul = min(float(mul), MAX_WIN_CAP_MUL)
if mul < 0.01:
return 1
for b in BUCKETS:
if b.range_low <= mul <= b.range_high:
return b.id
return FIXED_CAP_BUCKET_ID
# ============================================================
# RTP 与身份
# ============================================================
RTP = 0.80 # 目标返奖率(后台可按箱改)
CONVERGE_RATIO = 0.15 # 收敛阶段占比(一轮 N = fixedSum / (1 - cr))
AUDIENCE_COMMON = "common" # 其余全部(**含 user_type='03' 机器人**)
AUDIENCE_ANCHOR = "anchor" # user_type = '01'
AUDIENCE_WHITE = "white" # is_blank_account = 1(最高优先)
# 只有 3 个身份维度:robot 维度已按用户要求去掉(2026-09-22),机器人归 common
AUDIENCES: Tuple[str, ...] = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_WHITE)
# 生成器/池子规模(先小规模验证,再放大到线上)
ALLOC_K = 1.5 # 池子 = 配比 × 1.5
ALLOC_FLOOR = 60 # 每桶保底条数
SMOKE_ROUNDS_PER_BUCKET = 20 # `--per-bucket 20` 那种冒烟规模
+279
View File
@@ -0,0 +1,279 @@
"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
与母本 olympus/engine.py 的差别(母本只记账、消除由 simulator 决定):
本引擎**从盘面自己判定**——1024 路前缀链、金色就地转百搭、列内重力+补位、阶梯、FS、500x 封顶。
生成端(mahjong_simulator.py)只负责"把盘面摆成想要的形状",摆完之后一切由这里算。
"""
from __future__ import annotations
import random
from dataclasses import replace
from typing import Dict, List, Optional, Sequence, Set, Tuple
from .config import (
GOLDEN_CELL_PROB, GOLDEN_COLS, GRID_COLS, GRID_ROWS, MAX_SCATTER_PER_SCREEN,
MAX_WIN_CAP_MUL, MIN_CHAIN, NEARMISS_SCATTER_COUNT, PAYING, PAYTABLE, SCATTER,
SCATTER_TRIGGER, SYMBOL_WEIGHTS, WILD, fs_award, ladder_mul,
)
from .models import Round, Rotation, Spin, Win
Cell = Optional[str] # None = 空洞(结算中间态,必被补位填满)
Grid = List[Cell]
# ---------------------------------------------------------------- 格位换算
def pos(row: int, col: int) -> int:
"""0 基 (行,列) → 接口/落库用的 1 基格位 1~20(行优先)。"""
return row * GRID_COLS + col + 1
def index_of(p: int) -> int:
"""1 基格位 → 0 基内部下标。"""
return p - 1
def _rc(i: int) -> Tuple[int, int]:
return divmod(i, GRID_COLS)
# ---------------------------------------------------------------- 盘面构造
def _pick(rng: random.Random) -> str:
"""卷轴落一个符号:只从 8 付费符号 + 胡里出(永不落 W)。"""
codes = tuple(SYMBOL_WEIGHTS)
return rng.choices(codes, weights=[SYMBOL_WEIGHTS[c] for c in codes])[0]
def _mark_golden(rng: random.Random, col: int, sym: str) -> bool:
"""金色只在第 2/3/4 轴,且只打在普通符号上(百搭/胡不变金)。"""
return (col in GOLDEN_COLS and sym != SCATTER and sym != WILD
and rng.random() < GOLDEN_CELL_PROB)
def new_grid(rng: random.Random) -> Tuple[Grid, Set[int]]:
"""开一旋:整盘新发 + 第 2/3/4 轴的普通符号按概率打金色。返回 (盘面, 金色格下标集)。"""
grid: Grid = []
golden: Set[int] = set()
for i in range(GRID_ROWS * GRID_COLS):
row, col = _rc(i)
sym = _pick(rng)
grid.append(sym)
if _mark_golden(rng, col, sym):
golden.add(i)
return grid, golden
def assert_no_native_wild(grid: Sequence[Cell]) -> None:
"""铁律:原生盘面(未结算)+ 补位都不产出 W,百搭只能由金色转化产生。"""
for i, v in enumerate(grid):
if v == WILD:
raise AssertionError(f"格位 {pos(*_rc(i))} 出现了非转化来源的百搭")
def assert_wild_only_middle(grid: Sequence[Cell]) -> None:
"""铁律:金色只在第 2/3/4 轴 ⇒ 百搭只可能在第 2/3/4 轴(第 1 轴永远没有)。"""
for i, v in enumerate(grid):
if v == WILD and _rc(i)[1] not in GOLDEN_COLS:
raise AssertionError(f"格位 {pos(*_rc(i))} 的百搭落在第 1 或第 5 轴,不可能")
def count_scatters(grid: Sequence[Cell]) -> int:
return sum(1 for v in grid if v == SCATTER)
def scatters_in_col(grid: Sequence[Cell], col: int) -> int:
return sum(1 for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] == SCATTER)
# ---------------------------------------------------------------- 1024 路判定
def _count_in_col(grid: Sequence[Cell], col: int, sym: str) -> int:
"""该轴「符号 X 或百搭」的格数(金色格按自然符号计入)。"""
n = 0
for r in range(GRID_ROWS):
v = grid[r * GRID_COLS + col]
if v == sym or v == WILD:
n += 1
return n
def chain_cells(grid: Sequence[Cell], sym: str, run: int) -> Tuple[int, ...]:
cells = []
for col in range(run):
for r in range(GRID_ROWS):
if grid[r * GRID_COLS + col] in (sym, WILD):
cells.append(pos(r, col))
return tuple(cells)
def ways_wins(grid: Sequence[Cell]) -> Tuple[Win, ...]:
"""前缀链:第 1 轴起 1→2→3→4→5 连续,一旦断链整笔作废;ways 只乘链内轴。"""
wins: List[Win] = []
for sym in PAYING:
run, ways, counts = 0, 1, []
for col in range(GRID_COLS):
n = _count_in_col(grid, col, sym)
if n == 0:
break
run += 1
ways *= n
counts.append(n)
if run >= MIN_CHAIN:
wins.append(Win(
symbol=sym, run=run, counts=tuple(counts), ways=ways,
loc=chain_cells(grid, sym, run), pay_base=PAYTABLE[sym][run] * ways,
))
return tuple(wins)
# ---------------------------------------------------------------- 重力 + 补位
def collapse_fill(grid: Grid, golden: Set[int], rng: random.Random
) -> Tuple[Tuple[Tuple[int, int], ...], Tuple[Tuple[int, str], ...]]:
"""列内重力:幸存符号(含刚转化的百搭)保持顺序压到底,顶部补新符号(可带金色)。
金色是**镀在符号上**的:幸存符号下落时,金色标记要跟着它一起走,
否则会留在原格"传染"给新补进来的牌(也就不再是"那个金色符号"了)。
"""
drops: List[Tuple[int, int]] = []
fills: List[Tuple[int, str]] = []
for col in range(GRID_COLS):
survivors = [(r, grid[r * GRID_COLS + col], (r * GRID_COLS + col) in golden)
for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] is not None]
for r in range(GRID_ROWS):
golden.discard(r * GRID_COLS + col) # 清空本列标记,按落点重打
new_col: List[Cell] = [None] * GRID_ROWS
offset = GRID_ROWS - len(survivors)
for k, (r, v, was_golden) in enumerate(survivors):
dst_row = offset + k
new_col[dst_row] = v
if was_golden:
golden.add(dst_row * GRID_COLS + col)
if dst_row != r:
drops.append((pos(r, col), pos(dst_row, col)))
for r in range(offset):
sym = _pick(rng)
new_col[r] = sym
fills.append((pos(r, col), sym))
if _mark_golden(rng, col, sym):
golden.add(r * GRID_COLS + col)
for r in range(GRID_ROWS):
grid[r * GRID_COLS + col] = new_col[r]
return tuple(drops), tuple(fills)
# ---------------------------------------------------------------- 一波结算
def settle_rotation(grid: Grid, golden: Set[int], seq: int, is_fs: bool,
rng: random.Random) -> Optional[Rotation]:
"""结算一波;无成奖返回 None。会就地改写 grid / golden。"""
wins = ways_wins(grid)
if not wins:
return None
involved: Set[int] = {index_of(p) for w in wins for p in w.loc}
rotation_mul = ladder_mul(seq, is_fs)
base_mul = sum(w.pay_base for w in wins)
# ① 全部中奖格"被消除",但金色格是就地改写:不空洞、不补牌
converted: List[Tuple[int, str]] = []
for i in sorted(involved):
if i in golden:
golden.discard(i)
converted.append((i, grid[i] or ""))
grid[i] = WILD
converted_idx = {i for i, _ in converted}
for i in involved:
if i not in converted_idx:
grid[i] = None
# ② 重力下落 + 顶部补位(刚转化的 W 也是普通盘面符号,一起落)
drops, fills = collapse_fill(grid, golden, rng)
assert_wild_only_middle(grid)
return Rotation(
seq=seq, rotation_mul=rotation_mul, wins=wins, base_mul=base_mul,
win_mul=base_mul * rotation_mul,
converted=tuple((pos(*_rc(i)), s) for i, s in converted),
drops=drops, fills=fills, grid=tuple(grid),
)
# ---------------------------------------------------------------- 一旋 / 一局
def _strip_scatters(grid: Grid, golden: Set[int], rng: random.Random,
keep: int = SCATTER_TRIGGER - 1) -> None:
"""把落定的胡数压到 keep(用于"一屏最多 5 个胡"与"发不满就不发")。"""
hits = [i for i, v in enumerate(grid) if v == SCATTER]
for i in hits[keep:]:
row, col = _rc(i)
grid[i] = _pick(rng)
golden.discard(i)
if _mark_golden(rng, col, grid[i] or ""):
golden.add(i)
def play_spin(rng: random.Random, index: int, is_fs: bool,
grid: Optional[Grid] = None, golden: Optional[Set[int]] = None) -> Spin:
"""播一旋:落定(grid)→ 逐波结算 → 直到本波无成奖。"""
if grid is None or golden is None:
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
scatters = count_scatters(grid) # 触发只看**落定盘面**(补位落下的胡不算)
rotations: List[Rotation] = []
seq = 1
while True:
rot = settle_rotation(grid, golden, seq, is_fs, rng)
if rot is None:
break
rotations.append(rot)
seq += 1
return Spin(index=index, is_fs=is_fs, rotations=tuple(rotations),
mul=sum(r.win_mul for r in rotations), scatters=scatters)
def _is_near_miss(grid: Sequence[Cell]) -> bool:
"""恰好 2 个胡、且第 5 轴没有胡(客户端在第 5 列播放大)。"""
return (count_scatters(grid) == NEARMISS_SCATTER_COUNT
and scatters_in_col(grid, GRID_COLS - 1) == 0)
def play_round(rng: random.Random, box_id: int = 1) -> Round:
"""完整一局:基础旋 →(3+ 胡)免费旋转 → 重触发 → 封顶截断。"""
round_ = Round(box_id=box_id)
total = 0.0
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN) # 一屏最多 5 个胡
round_.near_miss = _is_near_miss(grid)
base = play_spin(rng, index=0, is_fs=False, grid=grid, golden=golden)
if base.scatters >= SCATTER_TRIGGER:
award = fs_award(base.scatters)
base = replace(base, fs_awarded=award)
round_.free_spins = award
round_.spins.append(base)
total += base.mul
played = 0
while played < round_.free_spins:
played += 1
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
sc = count_scatters(grid)
retrigger = False
if sc >= SCATTER_TRIGGER:
extra = fs_award(sc)
if round_.free_spins + extra <= 36: # "发不满就不发"
round_.free_spins += extra
retrigger = True
else:
_strip_scatters(grid, golden, rng) # 本旋就是不落 3+ 胡
spin = play_spin(rng, index=played, is_fs=True, grid=grid, golden=golden)
if retrigger:
spin = replace(spin, retrigger=True)
round_.spins.append(spin)
total += spin.mul
if total >= MAX_WIN_CAP_MUL: # 触顶:剩余旋作废
round_.capped = True
break
round_.raw_mul = total
round_.actual_mul = min(total, float(MAX_WIN_CAP_MUL))
return round_
+58
View File
@@ -0,0 +1,58 @@
"""麻将胡了 · 数据模型(frozen dataclass,母本 olympus/models.py 同风格)。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
@dataclass(frozen=True)
class Win:
"""一笔奖 = 某符号的一条前缀链。"""
symbol: str
run: int # 链长 3/4/5(从第 1 轴起连续的轴数)
counts: Tuple[int, ...] # 链内各轴的格数(百搭计入)
ways: int # counts 的乘积
loc: Tuple[int, ...] # 本笔涉及的全部格位(1 基,含金色格与百搭)
pay_base: float # 赔付表[符号][链长] × ways,单位 = 倍投注总额
@dataclass(frozen=True)
class Rotation:
"""一波 = 一次消除结算。"""
seq: int # 本旋内第几波(= 阶梯序号)
rotation_mul: int # 本波阶梯倍率
wins: Tuple[Win, ...]
base_mul: float # Σ pay_base
win_mul: float # base_mul × rotation_mul
converted: Tuple[Tuple[int, str], ...] # (格位1基, 原自然符号):金色就地转百搭
drops: Tuple[Tuple[int, int], ...] # (from1基, to1基)
fills: Tuple[Tuple[int, str], ...] # (格位1基, 新符号)
grid: Tuple[Optional[str], ...] # 本波结算+补位后的盘面(20 格短码)
@dataclass(frozen=True)
class Spin:
"""一旋。"""
index: int # 0 = 基础旋(触发盘),>0 = 第 N 次免费旋转
is_fs: bool
rotations: Tuple[Rotation, ...]
mul: float # 本旋倍率
scatters: int # 本旋**落定盘面**上的胡数(补位产生的不算触发)
fs_awarded: int = 0 # 本旋触发的赠送旋数(0 = 没触发)
retrigger: bool = False
@dataclass
class Round:
"""一局 = 基础旋 + 免费旋转(可重触发),受 500x 封顶。"""
box_id: int
spins: List[Spin] = field(default_factory=list)
raw_mul: float = 0.0 # 封顶前的总倍率
actual_mul: float = 0.0 # min(raw_mul, 500) —— 落桶/发奖用这个
capped: bool = False
near_miss: bool = False # 基础旋恰好 2 个胡且第 5 轴无胡
free_spins: int = 0 # 本局发放的免费旋数
@property
def has_fs(self) -> bool:
return self.free_spins > 0
+99
View File
@@ -0,0 +1,99 @@
"""麻将胡了 · 自然盘面仿真(回答"空转率/命中率到底是多少、离目标差多远")。
用法:
python mahjong_simulator.py --rounds 200000 --seed 2026
它只跑**自然落牌 + 无偏构造**的局面 —— 也就是"完全不干预"时游戏长什么样:
自然 RTP(= 平均 actual_mul)、基础旋命中率、级联波数分布、FS 自然触发率、悬念盘占比。
生成端要做的事(把配比凑到目标 RTP、把高倍桶构造出来)都在这些数字之上做偏置。
"""
from __future__ import annotations
import argparse
import random
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import play_round # noqa: E402
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--rounds", type=int, default=100000)
ap.add_argument("--seed", type=int, default=2026)
ap.add_argument("--box", type=int, default=5, help="投注档 boxId,只影响金额不影响倍率")
args = ap.parse_args()
rng = random.Random(args.seed)
n = args.rounds
base_hit = 0
empty_rounds = 0
fs_rounds = 0
retrigger_rounds = 0
near_miss = 0
capped = 0
rotations = Counter() # 本旋波数(基础旋)
fs_award_dist = Counter()
bucket_dist = Counter()
scatter_dist = Counter()
muls = []
total = 0.0
for _ in range(n):
r = play_round(rng, box_id=args.box)
muls.append(r.actual_mul)
total += r.actual_mul
base = r.spins[0]
if base.mul > 0:
base_hit += 1
else:
empty_rounds += 1
rotations[len(base.rotations)] += 1
scatter_dist[base.scatters] += 1
if r.has_fs:
fs_rounds += 1
fs_award_dist[r.free_spins] += 1
if any(s.retrigger for s in r.spins):
retrigger_rounds += 1
if r.near_miss:
near_miss += 1
if r.capped:
capped += 1
bucket_dist[C.bucket_of(r.actual_mul)] += 1
muls.sort()
def pct(p: float) -> float:
return muls[min(n - 1, int(n * p))]
print(f"=== 自然仿真:{n} 局 / seed={args.seed} / 无偏构造 ===")
print(f"自然 RTP(平均 actual_mul) : {total / n:.4f}")
print(f"基础旋命中率(至少一波成奖) : {base_hit / n:.4%} → 空转率 {empty_rounds / n:.2%}")
print(f"基础旋空转局(= 桶 1 占比) : {empty_rounds / n:.4%}")
print(f"FS 自然触发率(3+ 胡) : {fs_rounds / n:.4%} (设计目标 ≈0.5%,生成端要压低)")
print(f"其中重触发过的局 : {retrigger_rounds / n:.4%}")
print(f"悬念盘(2 胡且第 5 轴无胡) : {near_miss / n:.4%} (设计目标 1~2%)")
print(f"触 500x 封顶 : {capped / n:.4%}")
print(f"actual_mul 中位 / p90 / p99 / max: {pct(0.5):.2f} / {pct(0.9):.2f} / {pct(0.99):.2f} / {muls[-1]:.2f}")
print("\n基础旋波数分布(0 = 一波都不成):")
for k in sorted(rotations):
print(f" {k} 波: {rotations[k] / n:7.3%}")
print("\n落定盘面的胡数分布:")
for k in sorted(scatter_dist):
print(f" {k} 个: {scatter_dist[k] / n:7.3%}")
print("\nFS 赠送旋数分布(触发局内):")
for k in sorted(fs_award_dist):
print(f" {k} 旋: {fs_award_dist[k] / max(1, fs_rounds):7.3%}")
print("\n落桶分布(桶: 占比 / 名义期望倍率):")
for b in C.BUCKETS:
share = bucket_dist[b.id] / n
if share:
print(f" {b.id:2d} {b.name:<16} {share:7.4%} expect={b.expect_mul}")
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
-- ============================================================================
-- 步骤 1:插入「麻将胡了」顶级目录(照 olympus menu_id=3130 的结构)
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
visible, status, perms, icon, create_by, create_time, remark)
VALUES
('麻将胡了', 0, 6, 'mahjong', '', NULL, 1, 0, 'M',
'0', '0', '', 'star', 'admin', NOW(), '麻将胡了游戏后台管理目录');
-- 步骤 2:取刚插入的目录 id(LAST_INSERT_ID() 是**会话级**的,
-- 所以目录 INSERT 和下面子菜单 INSERT 必须在同一个会话里连着执行,不能拆开跑)
SET @mj_dir := LAST_INSERT_ID();
-- 步骤 3:插入 7 个子菜单(order_num 1..7,与 olympus 同序)
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
visible, status, perms, icon, create_by, create_time, remark)
VALUES
('箱子配置', @mj_dir, 1, 'box', 'game/mahjong/box/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:box', 'shopping', 'admin', NOW(), '麻将胡了·箱子配置(11 档投注,price ≡ bet)'),
('饰品列表', @mj_dir, 2, 'bucket', 'game/mahjong/bucket/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:bucket', 'star', 'admin', NOW(), '麻将胡了·16 档饰品桶定义(0x~500x,只读)'),
('回合管理', @mj_dir, 3, 'round', 'game/mahjong/round/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:round', 'documentation', 'admin', NOW(), '麻将胡了·预生成回合池(只读)'),
('奖池水位', @mj_dir, 4, 'rewardPool', 'game/mahjong/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:pool', 'money', 'admin', NOW(), '麻将胡了·奖池水位(3 身份:普通/主播/白号)'),
('玩家记录', @mj_dir, 5, 'record', 'game/mahjong/record/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:record', 'list', 'admin', NOW(), '麻将胡了·玩家游玩记录'),
('每日统计', @mj_dir, 6, 'dailywin', 'game/mahjong/dailywin/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:win', 'chart', 'admin', NOW(), '麻将胡了·每日投注/赢奖/RTP 统计'),
('奖品管理', @mj_dir, 7, 'prize', 'game/mahjong/prize/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:prize', 'guide', 'admin', NOW(), '麻将胡了·返奖饰品与返奖区间');
+298
View File
@@ -0,0 +1,298 @@
-- ============================================================
-- 麻将胡了(Mahjong)模块 全量建表 DDL
-- 表前缀: game_mahjong_ 游戏类型: MAHJONG
-- 字符集/排序规则: **utf8mb4 / utf8mb4_0900_ai_ci**(显式写出,不依赖服务器默认;与本库 henan 的默认一致,
-- 也与 game_wheel_* 一致。注意本库另有 111 张表是 utf8mb4_general_ci / utf8mb4_unicode_ci —— 只要**不要**拿
-- 字符串列去 join 它们(我们的 join 全在 user_id/box_id/round_id 等数值键上),就不会撞 Illegal mix of collations)
-- 权威来源: docs/麻将胡了设计方案.md §4(表清单)/ 附录 A(与玫瑰女郎的映射)
-- 与玫瑰女郎(game_olympus_*)的差异:
-- 1. 饰品桶 16 档(0x~500x,删掉它的 1000x 那档);单局封顶 500 倍
-- 2. 无模式倍率:只有 normal,cost = bet(去 mode_type 的 CM 语义)
-- 3. 无炸弹:step 的 multiplier_detail / accumulator_* → rotation_mul(本波阶梯,不跨旋)
-- 4. 盘面 4 行 × 5 列 = 20 格;step 自带 golden_detail / wild_converted / ways_detail
-- 5. record 自带 audience(按箱×身份记水位)与 bet_mul(预留、恒 1)
-- 6. box.total_ornaments → total_rounds
-- 执行:mysql --default-character-set=utf8mb4 -u<user> -p <db> < schema.sql (可重复执行)
-- ============================================================
-- 1. 箱子配置(= 投注档,11 档;price = bet)
CREATE TABLE IF NOT EXISTS game_mahjong_box (
box_id BIGINT NOT NULL COMMENT 'PK 1-11',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
box_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '只有 normal',
bet DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '投注档 = 投注总额',
name VARCHAR(50) DEFAULT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际扣费 = bet(costMultiplier ≡ 1)',
total_rounds INT NOT NULL DEFAULT '0' COMMENT '总回合数(配比重算时回写)',
rtp DECIMAL(5,4) NOT NULL DEFAULT '0.8000' COMMENT '目标返奖率',
rtp_converge_ratio DECIMAL(4,3) NULL COMMENT '一轮收敛回合占比,空=默认0.15',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1' COMMENT '1启用 0禁用',
del_flag VARCHAR(1) DEFAULT '0',
remark VARCHAR(200) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (box_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子配置(=投注档)';
-- 2. 饰品桶定义(0x ~ 500x,共 16 档)
CREATE TABLE IF NOT EXISTS game_mahjong_bucket (
id BIGINT NOT NULL COMMENT 'PK 1-16',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高 500)',
name VARCHAR(50) DEFAULT NULL,
range_low DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间下限(含)',
range_high DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间上限(含)',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了饰品桶定义';
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
-- audience 多身份爆率:每种身份一套独立分布;白号(white)独立库存、不进真实经济
CREATE TABLE IF NOT EXISTS game_mahjong_box_bucket (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL,
bucket_id INT NOT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common普通/anchor主播/white白号(无 robot 维度)',
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
PRIMARY KEY (id),
UNIQUE KEY uk_box_bucket_aud (box_id, bucket_id, audience)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子饰品桶分布(多身份)';
-- 4. 预生成回合(池里只存倍率,与投注额无关)
CREATE TABLE IF NOT EXISTS game_mahjong_round (
round_id BIGINT NOT NULL COMMENT 'PK',
box_id BIGINT NOT NULL,
bucket_id INT NOT NULL COMMENT '落入的饰品桶 1-16',
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(先按 500 截断再落桶)',
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(12/14/16 + 重触发)',
total_rotations INT NOT NULL DEFAULT '0' COMMENT '整局总波数',
has_fs TINYINT NOT NULL DEFAULT '0' COMMENT '是否含免费旋转 0否1是',
near_miss TINYINT NOT NULL DEFAULT '0' COMMENT '悬念盘:基础旋恰好2个胡且第5轴无胡',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (round_id),
INDEX idx_box_bucket (box_id, bucket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了预生成回合';
-- 5. 回合步骤(每帧一行,客户端逐帧续播)
CREATE TABLE IF NOT EXISTS game_mahjong_step (
id BIGINT NOT NULL COMMENT 'PK',
round_id BIGINT NOT NULL,
reel_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋/触发盘, >0=第N次免费旋转',
rotation_seq INT NOT NULL DEFAULT '0' COMMENT '本旋内第几波(= 阶梯序号)',
grid_state JSON DEFAULT NULL COMMENT '4行×5列=20格短码矩阵',
golden_detail JSON DEFAULT NULL COMMENT '本帧仍为"金色普通符号"的格 [{"pos","symbol"}]',
wild_converted JSON DEFAULT NULL COMMENT '本波"金色→百搭"就地转化的格 [{"pos","fromSymbol"}]',
ways_detail JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]',
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋累计消除符号 {"A":9,...}',
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加,新旋清空',
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...](含金色格)',
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from","to","symbol"}]',
fill_data JSON DEFAULT NULL COMMENT '补位数据 [{"pos","symbol"}]',
scatter_trigger JSON DEFAULT NULL COMMENT '触发免费旋转 {"scatterCount":3,"freeSpins":12,"extraSpins":0}',
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转内重触发',
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波基础倍率(Σ符号倍率×ways,未乘阶梯)',
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本旋累计到本步(末帧=本旋总额),不是增量',
rotation_mul DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波阶梯倍率(基础 1/2/3/5,FS 2/4/6/10;每旋重置)',
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额(倍率)到本步',
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步被 500x 封顶截断 0否1是',
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位 small/middle/big(只在 FS 旋末帧,按单旋赢额)',
is_final TINYINT DEFAULT '0' COMMENT '本旋末帧',
PRIMARY KEY (id),
INDEX idx_round (round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了回合步骤';
-- 6. 游戏记录(含结算/幂等/发奖字段,建表即带全)
CREATE TABLE IF NOT EXISTS game_mahjong_record (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
box_id BIGINT NOT NULL,
round_id BIGINT NOT NULL,
cost DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '扣费 = bet',
bet_mul DECIMAL(10,2) NOT NULL DEFAULT '1.00' COMMENT '投注倍数(预留字段,恒 1)',
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(倍数×投注额,≤bet×500)',
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
mode_type VARCHAR(20) DEFAULT 'normal' COMMENT '只有 normal',
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(=命中饰品价格;空转局按饰品实际价值记账)',
prize_id BIGINT DEFAULT NULL COMMENT 'game_mahjong_prize_ornament.id',
prize_name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
prize_image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
prize_level_img VARCHAR(500) DEFAULT NULL COMMENT '品质图片URL',
settle_source VARCHAR(20) DEFAULT NULL COMMENT 'start=开局已结算(动画还在播) / finish=末帧播完(进战绩)',
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份:水位按 箱×身份 聚合',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user_time (user_id, create_time),
INDEX idx_box_time (box_id, create_time),
INDEX idx_settled_time (settled, create_time),
INDEX idx_box_aud (box_id, audience)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了游戏记录';
-- 7. 用户统计
CREATE TABLE IF NOT EXISTS game_mahjong_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
total_spins BIGINT NOT NULL DEFAULT '0',
total_cost DECIMAL(12,2) NOT NULL DEFAULT '0.00',
total_win DECIMAL(12,2) NOT NULL DEFAULT '0.00',
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤500)',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user (user_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了用户统计';
-- 8. 箱子桶分布模板(抽干补桶依据,按身份区分)
CREATE TABLE IF NOT EXISTS game_mahjong_box_template (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common',
name VARCHAR(100) NOT NULL COMMENT '模板名称(同箱+身份内唯一)',
total_rounds INT NOT NULL DEFAULT 0 COMMENT '= items 各 count 之和',
items_json TEXT NOT NULL COMMENT '分布JSON: [{"bucketId":1,"count":100},...]',
is_active TINYINT NOT NULL DEFAULT 0 COMMENT '是否生效模板 1是 0否',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_box_aud_name (box_id, audience, name),
KEY idx_box_aud_active (box_id, audience, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子桶分布模板(多身份)';
-- 9. 奖池水位调整日志(注水/抽水/重置审计,原因必填由前端保证)
CREATE TABLE IF NOT EXISTS game_mahjong_reward_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
box_name VARCHAR(100) DEFAULT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common/anchor/white',
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
operator VARCHAR(64) DEFAULT NULL,
remark VARCHAR(200) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_create (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了奖池水位调整日志';
-- 10. 返奖饰品定义(按赢额金额区间匹配,与游戏无关)
CREATE TABLE IF NOT EXISTS game_mahjong_prize_ornament (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
name VARCHAR(100) DEFAULT NULL,
image_url VARCHAR(500) DEFAULT NULL,
grade INT DEFAULT NULL COMMENT '品质等级 1-5',
exterior_name VARCHAR(100) DEFAULT NULL COMMENT '品质名称 崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1',
is_fallback TINYINT DEFAULT '0' COMMENT '兜底饰品 1是 0否(区间空洞时落它)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖饰品';
-- 11. 返奖饰品区间(与 prize_ornament 一一对应,区间连续无缝覆盖 0~250000)
CREATE TABLE IF NOT EXISTS game_mahjong_prize_range (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
prize_id BIGINT NOT NULL COMMENT 'game_mahjong_prize_ornament.id',
win_low DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间下限(含)',
win_high DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间上限(含)',
PRIMARY KEY (id),
UNIQUE KEY uk_prize (prize_id),
KEY idx_win_low (win_low)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖区间';
-- ============================================================
-- 种子数据:饰品桶 1-16(0x ~ 500x)
-- ============================================================
INSERT INTO game_mahjong_bucket (id, game_type, expect_mul, name, range_low, range_high) VALUES
(1, 'MAHJONG', 0.00, '0x', 0.00, 0.00),
(2, 'MAHJONG', 0.50, '0.5x', 0.01, 1.00),
(3, 'MAHJONG', 1.50, '1.5x', 1.00, 2.00),
(4, 'MAHJONG', 2.50, '2.5x', 2.00, 3.00),
(5, 'MAHJONG', 4.00, '4x', 3.00, 5.00),
(6, 'MAHJONG', 6.50, '6.5x', 5.00, 8.00),
(7, 'MAHJONG', 10.00, '10x', 8.00, 12.00),
(8, 'MAHJONG', 16.00, '16x', 12.00, 20.00),
(9, 'MAHJONG', 25.00, '25x', 20.00, 30.00),
(10, 'MAHJONG', 40.00, '40x', 30.00, 50.00),
(11, 'MAHJONG', 60.00, '60x', 50.00, 70.00),
(12, 'MAHJONG', 100.00, '100x', 70.00, 130.00),
(13, 'MAHJONG', 150.00, '150x', 130.00, 170.00),
(14, 'MAHJONG', 200.00, '200x', 170.00, 230.00),
(15, 'MAHJONG', 300.00, '300x', 230.00, 370.00),
(16, 'MAHJONG', 500.00, '500x', 370.00, 700.00)
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
range_low=VALUES(range_low), range_high=VALUES(range_high);
-- ============================================================
-- 种子数据:11 个箱子(=11 个投注档,price = bet,无投注倍数)
-- ============================================================
INSERT INTO game_mahjong_box (box_id, game_type, box_type, bet, name, price, rtp, sort, status, remark) VALUES
(1, 'MAHJONG', 'normal', 1.00, '1', 1.00, 0.8000, 1, '1', NULL),
(2, 'MAHJONG', 'normal', 3.00, '3', 3.00, 0.8000, 2, '1', NULL),
(3, 'MAHJONG', 'normal', 5.00, '5', 5.00, 0.8000, 3, '1', NULL),
(4, 'MAHJONG', 'normal', 10.00, '10', 10.00, 0.8000, 4, '1', NULL),
(5, 'MAHJONG', 'normal', 20.00, '20', 20.00, 0.8000, 5, '1', NULL),
(6, 'MAHJONG', 'normal', 30.00, '30', 30.00, 0.8000, 6, '1', NULL),
(7, 'MAHJONG', 'normal', 50.00, '50', 50.00, 0.8000, 7, '1', NULL),
(8, 'MAHJONG', 'normal', 100.00, '100', 100.00, 0.8000, 8, '1', NULL),
(9, 'MAHJONG', 'normal', 200.00, '200', 200.00, 0.8000, 9, '1', NULL),
(10, 'MAHJONG', 'normal', 300.00, '300', 300.00, 0.8000, 10, '1', NULL),
(11, 'MAHJONG', 'normal', 500.00, '500', 500.00, 0.8000, 11, '1', NULL)
ON DUPLICATE KEY UPDATE bet=VALUES(bet), price=VALUES(price), name=VALUES(name),
rtp=VALUES(rtp), sort=VALUES(sort), status=VALUES(status);
-- ============================================================
-- 种子数据:返奖饰品 12 件(取自玫瑰女郎线上 dump,id 原样保留以维持区间映射)
-- 说明:zone 是按**赢额金额(¥)**匹配的展示层,与饰品桶(倍率)无关;
-- 保留原 id 是因为 prize_range.prize_id 直接引用它。
-- ============================================================
INSERT INTO game_mahjong_prize_ornament
(id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback, create_time, update_time) VALUES
(1, 'MAHJONG', '安慰奖', '/profile/upload/2026/09/15/VU1QLTQ1IHwgU2NvcmNoZWQgKEZhY3RvcnkgTmV3KQ==_20260915144142A001.png', 4, '战痕累累', 1, '1', 1, '2026-08-01 14:05:58', '2026-09-15 14:41:45'),
(2, 'MAHJONG', 'SG 553 | 浪花喷漆', '/profile/upload/2026/07/02/U0cgNTUzIHwgV2F2ZSBTcHJheSAoRmFjdG9yeSBOZXcp_20260702034148A002.png', 2, '略有磨损', 2, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(3, 'MAHJONG', '双持贝瑞塔 | 黑榄仁木', '/profile/upload/2026/07/02/RHVhbCBCZXJldHRhcyB8IEJsYWNrIExpbWJhIChGYWN0b3J5IE5ldyk=_20260702034157A003.png', 3, '久经沙场', 3, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(4, 'MAHJONG', 'M4A4 | 皇家圣骑士', '/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png', 4, '破损不堪', 4, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(5, 'MAHJONG', 'AK-47 | 深海复仇', '/profile/upload/2026/07/02/QUstNDcgfCBBcXVhbWFyaW5lIFJldmVuZ2UgKEZhY3RvcnkgTmV3KQ==_20260702034212A005.png', 4, '破损不堪', 5, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(6, 'MAHJONG', 'AK-47 | 火蛇', '/profile/upload/2026/07/02/QUstNDcgfCBGaXJlIFNlcnBlbnQgKEZhY3RvcnkgTmV3KQ==_20260702034220A006.png', 3, '久经沙场', 6, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(7, 'MAHJONG', 'AK-47 | 燃料喷射器', '/profile/upload/2026/07/02/QUstNDcgfCBGdWVsIEluamVjdG9yIChGYWN0b3J5IE5ldyk=_20260702034228A007.png', 3, '久经沙场', 7, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(8, 'MAHJONG', '加利尔 AR | 水蓝阶地', '/profile/upload/2026/07/02/R2FsaWwgQVIgfCBBcXVhIFRlcnJhY2UgKEZhY3RvcnkgTmV3KQ==_20260702034236A008.png', 2, '略有磨损', 8, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(9, 'MAHJONG', '猎杀者匕首 | 渐变之色', '/profile/upload/2026/07/02/4piFIEh1bnRzbWFuIEtuaWZlIHwgRmFkZSAoRmFjdG9yeSBOZXcp_20260702034302A012.png', 2, '略有磨损', 9, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(10, 'MAHJONG', '爪子刀 | 都市伪装', '/profile/upload/2026/07/02/4piFIEthcmFtYml0IHwgVXJiYW4gTWFza2VkIChGYWN0b3J5IE5ldyk=_20260702034248A010.png', 1, '崭新出厂', 10, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(11, 'MAHJONG', '熊刀 | 多普勒', '/profile/upload/2026/07/02/4piFIFVyc3VzIEtuaWZlIHwgRG9wcGxlciAoTWluaW1hbCBXZWFyKQ==_20260702034256A011.png', 1, '崭新出厂', 11, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(12, 'MAHJONG', 'AWP | 巨龙传说', '/profile/upload/2026/07/02/QVdQIHwgRHJhZ29uIExvcmUgKEZhY3RvcnkgTmV3KQ==_20260702034308A013.png', 1, '崭新出厂', 12, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58')
ON DUPLICATE KEY UPDATE game_type=VALUES(game_type), name=VALUES(name), image_url=VALUES(image_url),
grade=VALUES(grade), exterior_name=VALUES(exterior_name), sort=VALUES(sort),
status=VALUES(status), is_fallback=VALUES(is_fallback), update_time=NOW();
-- ============================================================
-- 种子数据:返奖区间 12 段(连续无缝覆盖 0.01 ~ 99,999,999)
-- 已核:① 每段 win_high = 下一段 win_low(无空洞、无重叠)
-- ② 最大档上界 99,999,999 ≥ 麻将单局理论最大赔付 ¥250,000(500x × ¥500)
-- —— ¥250,000 落在第 9 段(100,005.82~499,988),发 9 号饰品
-- ③ 空转局兜底 win=0.01 落在第 1 段(0.01~0.14),发「安慰奖」(is_fallback=1)
-- ⚠️ 这 12 段只决定"发哪件饰品"(展示层);实际入账金额是 record.settled_win
-- = 本局 win,与区间无关(见 service-user 分解入账:Σ TtBoxRecords.ornamentsPrice)
-- ============================================================
INSERT INTO game_mahjong_prize_range (id, prize_id, win_low, win_high) VALUES
(1, 1, 0.01, 0.14),
(2, 2, 0.14, 10.00),
(3, 3, 10.00, 50.00),
(4, 4, 50.00, 200.00),
(5, 5, 200.00, 1000.00),
(6, 6, 1000.00, 5000.00),
(7, 7, 5000.00, 20000.00),
(8, 8, 20000.00, 100005.82),
(9, 9, 100005.82, 499988.00),
(10, 10, 499988.00, 1000013.00),
(11, 11, 1000013.00, 2000012.00),
(12, 12, 2000012.00, 99999999.00)
ON DUPLICATE KEY UPDATE prize_id=VALUES(prize_id), win_low=VALUES(win_low), win_high=VALUES(win_high);
-- ============================================================
-- 还没做(不在本文件内,见设计文档 §6.5):
-- 1. 528 条 game_mahjong_box_bucket 配比行(16 桶 × 11 箱 × 3 身份):由 `_recalc_boxes.py` / 后台「一键重算」生成
-- 2. sys_config mahjongMaintenance、sys_menu 7 个菜单:平台共享改动,单独执行
-- 3. 每箱×身份的初始水位:后台奖池水位页注水(¥300/¥500 两档必须单独注)
-- ============================================================
+203
View File
@@ -0,0 +1,203 @@
"""麻将胡了 · 引擎单测(不依赖 pytest:`python tests/test_engine.py` 直接跑)。
重点覆盖:§2.1 七个正反例、百搭补链/不能替代胡、金色就地转百搭、
一个百搭被两条链同时借用、阶梯取档、FS 旋数上限与"发不满就不发"、500x 封顶。
"""
from __future__ import annotations
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import ( # noqa: E402
assert_wild_only_middle, count_scatters, new_grid, play_round,
settle_rotation, ways_wins,
)
def make_grid(rows, golden=()):
"""rows: 4 个长度 5 的短码字符串(上→下);golden: 0 基 (行,列) 集合。"""
grid = [c for row in rows for c in row]
assert len(grid) == C.GRID_SIZE, f"盘面必须是 20 格,收到 {len(grid)}"
return grid, {r * C.GRID_COLS + c for r, c in golden}
def cols_of(counts, fill="S", sym="B"):
"""按"每轴 sym 的个数"造盘面(其余填 fill,保证不形成别的链)。"""
rows = []
for r in range(C.GRID_ROWS):
row = []
for c in range(C.GRID_COLS):
# 从底部往上摆 sym
row.append(sym if r >= C.GRID_ROWS - counts[c] else fill)
rows.append("".join(row))
return make_grid(rows)
def mul(x):
return round(x, 4)
# ---------------------------------------------------------------- §2.1 前缀链
CASES = [
([2, 3, 1, 0, 0], 3, 6), # ✅ 3 连
([2, 3, 1, 2, 0], 4, 12), # ✅ 4 连(第 5 轴才断)
([2, 0, 3, 4, 4], None, None), # ❌ 第 2 轴断
([0, 4, 4, 4, 4], None, None), # ❌ 第 1 轴必需
([1, 1, 0, 4, 4], None, None), # ❌ 不能跳轴
([1, 2, 3, 4, 4], 5, 96), # ✅ 满链
([1, 1, 1, 1, 1], 5, 1), # ✅ 最低 ways
]
def test_prefix_chain():
for counts, run, ways in CASES:
grid, _ = cols_of(counts)
wins = [w for w in ways_wins(grid) if w.symbol == "B"]
if run is None:
assert not wins, f"{counts} 不该成奖,却得到 {wins}"
else:
assert len(wins) == 1, f"{counts} 应恰好一笔,得到 {wins}"
assert wins[0].run == run and wins[0].ways == ways, f"{counts} → {wins[0]}"
print("[ok] §2.1 前缀链 7 个正反例")
def test_scatter_breaks_chain_and_no_substitute():
grid, _ = make_grid(["ASASS", "SSSSS", "SSSSS", "SSSSS"])
assert ways_wins(grid) == (), "發,胡,發 不能成链(胡不进链、也挡链)"
grid, _ = make_grid(["AWASS", "SSSSS", "SSSSS", "SSSSS"])
wins = [w for w in ways_wins(grid) if w.symbol == "A"]
assert len(wins) == 1 and wins[0].run == 3 and wins[0].ways == 1, "百搭要能补链中间的断点"
print("[ok] 胡不成链 / 百搭补链")
def test_paytable_grid():
for code, rule in C.SYMBOL_RULES.items():
for run, pay in rule.payouts.items():
assert abs(pay / 0.05 - round(pay / 0.05)) < 1e-9, f"{code} {run} 连 {pay} 不是 0.05 的整数倍"
print("[ok] 赔付表全部落在 0.05 网格上")
# ---------------------------------------------------------------- 金色 → 百搭
def test_golden_converts_in_place():
"""§15 第 1 波:两张金色「中」各留一个百搭,且是就地改写的。"""
grid, golden = make_grid(
["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(1, 1), (1, 2)])
rng = random.Random(7)
rot = settle_rotation(grid, golden, 1, False, rng)
assert rot is not None
paid = {w.symbol: mul(w.pay_base) for w in rot.wins}
assert paid == {"B": 2.0, "C": 0.8}, f"本波两笔奖应为 2.00 / 0.80,得到 {paid}"
assert mul(rot.win_mul) == 2.8, "x1 阶梯下本波 2.80 倍"
assert len(rot.converted) == 2, f"两个金色格各留一个百搭,得到 {rot.converted}"
assert {s for _, s in rot.converted} == {"B"}, "转化前的自然符号应记进 wild_converted"
assert all(grid[i] != C.WILD for i in golden), "百搭永不回填金色标记(每格只转化一次)"
assert sum(1 for v in grid if v == C.WILD) == 2, "两个百搭仍在盘面上(跟着重力落)"
assert_wild_only_middle(grid)
print("[ok] 金色格就地转百搭(不空洞/不补牌,2 个金色格 → 2 个百搭)")
def test_one_wild_borrowed_by_three_chains():
"""§15 第 4 波:同一个百搭同时撑起「筒」5 连、「索红绿」5 连、「白板」3 连。
注:设计文档原来只算了前两笔(漏了白板链),本测试按规则把它补上;
文档已同步勘误(本波 ¥1200、本旋总账 ¥2380)。
"""
grid, golden = make_grid(["GAWGC", "HCAAS", "CFBGF", "FGHFG"])
rng = random.Random(11)
rot = settle_rotation(grid, golden, 4, False, rng)
assert rot is not None
got = {w.symbol: (w.run, w.ways, mul(w.pay_base)) for w in rot.wins}
assert got == {"G": (5, 2, 1.0), "F": (5, 1, 1.0), "C": (3, 1, 0.4)}, f"三笔并赔,得到 {got}"
shared = set(rot.wins[0].loc)
for w in rot.wins[1:]:
shared &= set(w.loc)
assert shared == {3}, f"三条链必须共用第 3 格那个百搭,实际 {shared}"
assert mul(rot.base_mul) == 2.4 and mul(rot.win_mul) == 12.0, "本波 2.40 倍 × x5 = 12.00 倍"
assert sum(1 for v in grid if v == C.WILD) == 0, "参与成链的百搭要随本波被消除"
print("[ok] 一个百搭被三条链同时借用,并随波被消除")
def test_involved_golden_only():
"""只有"参与成奖"的金色格才转化;没被涉及的金色格原地不动。"""
# B 链在轴1~轴3;轴4 的 F 带金色但没参与任何链 → 不转化
grid, golden = make_grid(["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(3, 3)])
rng = random.Random(9)
rot = settle_rotation(grid, golden, 1, False, rng)
assert rot is not None and rot.converted == (), f"未被涉及的金色格不该转化:{rot.converted}"
assert 3 * C.GRID_COLS + 3 in golden, "未涉及的金色标记要保留"
print("[ok] 只有参与成奖的金色格才就地转百搭")
def test_golden_travels_with_symbol():
"""金色是镀在符号上的:金色格没被消除但它下方的牌被消 → 它下落,金标记跟着走。"""
grid, golden = make_grid(["BDBSS", "BBBSS", "BBBSS", "SSSSS"], golden=[(0, 1)])
rot = settle_rotation(grid, golden, 1, False, random.Random(1))
assert rot is not None, "B 应为 3 连(col0=3 / col1=2 / col2=3)"
# D 从行1 落到行3(col1 有 2 格空洞),金标记必须跟着
assert grid[2 * C.GRID_COLS + 1] == "D", "金色 D 应该落到行3"
assert 2 * C.GRID_COLS + 1 in golden, "金标记要跟着符号落到新位置"
assert 0 * C.GRID_COLS + 1 not in golden, "不能留在原格(否则会传染给新补的牌)"
print("[ok] 金色标记跟着符号下落")
def test_no_wild_without_conversion():
"""原生盘面(卷轴+补位)永不落百搭;金色只在第 2/3/4 轴。"""
for seed in range(300):
g, gold = new_grid(random.Random(seed))
assert C.WILD not in g, "原生盘面永远不落百搭"
assert all(c % C.GRID_COLS in C.GOLDEN_COLS for c in gold), "金色只在第 2/3/4 轴"
print("[ok] 原生不落百搭 / 金色只在第 2/3/4 轴")
# ---------------------------------------------------------------- 阶梯 / 封顶 / FS
def test_ladder():
base = [C.ladder_mul(n, False) for n in range(1, 7)]
fs = [C.ladder_mul(n, True) for n in range(1, 7)]
assert base == [1, 2, 3, 5, 5, 5], base
assert fs == [2, 4, 6, 10, 10, 10], fs
print("[ok] 阶梯:基础 1/2/3/5,FS 2/4/6/10,第 4 波起封顶")
def test_example_2_9_1_wave1():
"""§2.9.1 第 1 波:中 3 连 + 白板 3 连 = 0.90 倍(x1)。"""
grid, golden = make_grid(["BCBEH", "DBAGF", "EDCAG", "CEHFD"])
rot = settle_rotation(grid, golden, 1, False, random.Random(5))
assert rot is not None and mul(rot.base_mul) == 0.9 and mul(rot.win_mul) == 0.9
print("[ok] §2.9.1 第 1 波 = 0.90 倍")
def test_fs_award_and_cap():
assert [C.fs_award(n) for n in (3, 4, 5)] == [12, 14, 16]
rng = random.Random(2026)
reachable = set()
for _ in range(40000):
r = play_round(rng, box_id=5)
assert r.actual_mul <= C.MAX_WIN_CAP_MUL, "单局封顶 500x"
assert mul(r.actual_mul) == mul(min(r.raw_mul, C.MAX_WIN_CAP_MUL))
if r.free_spins:
assert r.free_spins <= C.MAX_FREE_SPINS, f"FS 上限 36,得到 {r.free_spins}"
assert r.free_spins in (12, 14, 16, 24, 26, 28, 30, 32, 36), r.free_spins
reachable.add(r.free_spins)
if r.capped:
assert r.actual_mul == C.MAX_WIN_CAP_MUL
assert reachable, "4 万局都没触发过一次 FS,权重/参数明显不对"
print(f"[ok] 封顶与 FS 上限(4 万局内出现的赠送旋数:{sorted(reachable)})")
if __name__ == "__main__":
test_prefix_chain()
test_scatter_breaks_chain_and_no_substitute()
test_paytable_grid()
test_golden_converts_in_place()
test_one_wild_borrowed_by_three_chains()
test_involved_golden_only()
test_golden_travels_with_symbol()
test_no_wild_without_conversion()
test_ladder()
test_example_2_9_1_wave1()
test_fs_award_and_cap()
print("\n全部通过 ✅")
-22
View File
@@ -24,7 +24,6 @@
<druid.version>1.2.28</druid.version>
<yauaa.version>8.1.0</yauaa.version>
<bitwalker.version>1.21</bitwalker.version>
<swagger.version>3.0.0</swagger.version>
<kaptcha.version>2.3.3</kaptcha.version>
<tianai-captcha.version>1.5.5</tianai-captcha.version>
<pagehelper.boot.version>2.1.1</pagehelper.boot.version>
@@ -35,7 +34,6 @@
<poi.version>4.1.2</poi.version>
<velocity.version>2.3</velocity.version>
<jwt.version>0.9.1</jwt.version>
<springdoc.version>3.0.2</springdoc.version>
</properties>
<!-- 依赖声明 -->
@@ -149,26 +147,6 @@
<version>${oshi.version}</version>
</dependency>
<!-- spring-doc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Swagger3依赖 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>${swagger.version}</version>
<!-- <exclusions>-->
<!-- <exclusion>-->
<!-- <groupId>io.swagger</groupId>-->
<!-- <artifactId>swagger-models</artifactId>-->
<!-- </exclusion>-->
<!-- </exclusions>-->
</dependency>
<!-- io常用工具类 -->
<dependency>
<groupId>commons-io</groupId>
+1 -1
View File
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
VITE_OUT_DIR=dist-dev
# 标题
VITE_APP_TITLE=ElementAdmin
VITE_APP_TITLE=推广后台
# 是否包分析
VITE_USE_BUNDLE_ANALYZER=false
+23 -5
View File
@@ -1,18 +1,22 @@
import request from '@/axios'
import type { RegisterUserType, SmsLoginType, UserLoginType, UserType } from './types'
import type { DevLoginType, RegisterUserType, SmsLoginType, UserType } from './types'
interface RoleParams {
roleName: string
}
export const loginApi = (data: UserLoginType): Promise<IResponse<UserType>> => {
return request.post({ url: '/promo/login', data })
}
export const smsLoginApi = (data: SmsLoginType): Promise<IResponse<UserType>> => {
return request.post({ url: '/promo/smsLogin', data })
}
/**
* 开发环境专用的账号密码登录(仅 vite dev server 下可见)。
* 后端需在 sys_config 里把 promoDevLoginEnabled 置为 '1',否则返回「账号密码登录未开启」。
*/
export const devLoginApi = (data: DevLoginType): Promise<IResponse<UserType>> => {
return request.post({ url: '/promo/devLogin', data })
}
export const registerApi = (data: RegisterUserType): Promise<IResponse<UserType>> => {
return request.post({ url: '/promo/register', data })
}
@@ -34,6 +38,20 @@ export const getCodeApi = (
})
}
/** 点「获取验证码」时的账号前置校验:无推广后台权限的号在这一步就被拒绝,不用先拖滑块 */
export const precheckPhoneApi = (
phoneNumber: string,
parentInvitationCode?: string
): Promise<IResponse<UserType>> => {
return request.post({
url: '/promo/sms/precheck',
data: {
phoneNumber: phoneNumber,
parentInvitationCode
}
})
}
export const loginOutApi = (): Promise<IResponse> => {
return request.get({ url: '/mock/user/loginOut' })
}
+7
View File
@@ -10,6 +10,13 @@ export interface SmsLoginType {
parentInvitationCode?: string
}
/** 开发环境专用的账号密码登录入参(后端 promoDevLoginEnabled=1 才可用) */
export interface DevLoginType {
/** 推广账号(主播/招商/卡商)填手机号;超管可填登录账号或手机号 */
account: string
password: string
}
export interface UserType {
uid: number
username: string
@@ -5,14 +5,12 @@ import { UserInfo } from '@/components/UserInfo'
import { Breadcrumb } from '@/components/Breadcrumb'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import { useUserStore } from '@/store/modules/user'
const { getPrefixCls, variables } = useDesign()
const prefixCls = getPrefixCls('tool-header')
const appStore = useAppStore()
const userStore = useUserStore()
// 面包屑
const breadcrumb = computed(() => appStore.getBreadcrumb)
@@ -42,9 +40,7 @@ export default defineComponent({
{breadcrumb.value ? <Breadcrumb class="<md:hidden"></Breadcrumb> : undefined}
</div>
) : undefined}
{userStore.getUserInfo?.role !== '99' && userStore.getUserInfo?.role !== '05' ? (
<span>分享链接:{userStore.getUserInfo?.shareLink}</span>
) : undefined}
{/* 顶栏的「分享链接」已移除:与首页「我的推广链接」hero 卡重复(2026-09-27) */}
<div class="h-full flex items-center">
<UserInfo></UserInfo>
</div>
+8 -2
View File
@@ -54,7 +54,7 @@ export const useAppStore = defineStore('app', {
tagsViewIcon: true, // 是否显示标签图标
logo: true, // logo
fixedHeader: true, // 固定toolheader
footer: true, // 显示页脚
footer: false, // 不显示页脚(推广后台不需要底部版权栏)
greyMode: false, // 是否开始灰色模式,用于特殊悼念日
dynamicRouter: true, // 是否动态路由
serverDynamicRouter: false, // 是否服务端渲染动态路由
@@ -336,7 +336,13 @@ export const useAppStore = defineStore('app', {
newTitle !== this.getTitle && this.setTitle(newTitle)
}
},
persist: true
persist: {
/* footer 不进持久化:本 store 原先 persist: true 会把 footer: true 写进 localStorage,
老用户下次进来 hydrate 又把它盖回 true ⇒ 光改上面的默认值页脚去不掉。
omit 在「存」和「取」两侧都生效(见 pinia-plugin-persistedstate dist/index.js:23),
所以旧数据里的 footer 会被直接忽略,永远取 state 默认值。 */
omit: ['footer']
}
})
export const useAppStoreWithOut = () => {
+28 -22
View File
@@ -5,6 +5,12 @@
<p class="desc">查看所有招商及其下属主播、普通用户的汇总数据</p>
</div>
<!--
三级表格的列一律用 min-width,不要改回 width:
Element Plus 只在存在弹性列时才把表格撑满容器,全列写死 width ⇒ 右侧留白。
(二级/三级表在展开行内,容器宽度由父级决定,所以更要用 min-width 跟随容器。)
详见 docs/修改记录.md「表格右侧留白」。
-->
<!-- 一级:招商列表 -->
<ElTable v-loading="loading" :data="pagedData" border stripe row-key="userId">
<ElTableColumn type="expand">
@@ -21,26 +27,26 @@
<!-- 三级:普通用户列表 -->
<ElTable :data="anchor.users || []" border size="small">
<ElTableColumn prop="userId" label="用户ID" width="90" />
<ElTableColumn prop="nickName" label="昵称" width="110" />
<ElTableColumn prop="userName" label="用户名" width="120" />
<ElTableColumn prop="createTime" label="创建时间" width="155" />
<ElTableColumn label="总充值" width="105">
<ElTableColumn prop="userId" label="用户ID" min-width="90" />
<ElTableColumn prop="nickName" label="昵称" min-width="110" />
<ElTableColumn prop="userName" label="用户名" min-width="120" />
<ElTableColumn prop="createTime" label="创建时间" min-width="155" />
<ElTableColumn label="总充值" min-width="105">
<template #default="{ row: u }">
{{ formatMoney(u.totalRecharge) }}
</template>
</ElTableColumn>
<ElTableColumn label="总流水" width="110">
<ElTableColumn label="总流水" min-width="110">
<template #default="{ row: u }">
{{ formatMoney(u.totalTurnover) }}
</template>
</ElTableColumn>
<ElTableColumn label="特殊游戏流水" width="115">
<ElTableColumn label="特殊游戏流水" min-width="115">
<template #default="{ row: u }">
{{ formatMoney(u.specialGameTurnover) }}
</template>
</ElTableColumn>
<ElTableColumn label="经典游戏流水" width="115">
<ElTableColumn label="经典游戏流水" min-width="115">
<template #default="{ row: u }">
{{ formatMoney(u.classicGameTurnover) }}
</template>
@@ -49,16 +55,16 @@
</div>
</template>
</ElTableColumn>
<ElTableColumn prop="userId" label="主播ID" width="120" />
<ElTableColumn prop="nickName" label="昵称" width="140" />
<ElTableColumn prop="userName" label="用户名" width="150" />
<ElTableColumn prop="userCount" label="下属用户数" width="110" />
<ElTableColumn label="主播充值" width="130">
<ElTableColumn prop="userId" label="主播ID" min-width="120" />
<ElTableColumn prop="nickName" label="昵称" min-width="140" />
<ElTableColumn prop="userName" label="用户名" min-width="150" />
<ElTableColumn prop="userCount" label="下属用户数" min-width="110" />
<ElTableColumn label="主播充值" min-width="130">
<template #default="{ row: a }">
{{ formatMoney(a.selfRecharge) }}
</template>
</ElTableColumn>
<ElTableColumn label="总收益" width="130">
<ElTableColumn label="总收益" min-width="130">
<template #default="{ row: a }">
{{ formatMoney(a.totalEarnings) }}
</template>
@@ -68,26 +74,26 @@
</template>
</ElTableColumn>
<ElTableColumn prop="userId" label="招商ID" width="120" />
<ElTableColumn prop="nickName" label="昵称" width="140" />
<ElTableColumn prop="userName" label="用户名" width="150" />
<ElTableColumn prop="anchorCount" label="下属主播数" width="110" />
<ElTableColumn label="总充值" width="130">
<ElTableColumn prop="userId" label="招商ID" min-width="120" />
<ElTableColumn prop="nickName" label="昵称" min-width="140" />
<ElTableColumn prop="userName" label="用户名" min-width="150" />
<ElTableColumn prop="anchorCount" label="下属主播数" min-width="110" />
<ElTableColumn label="总充值" min-width="130">
<template #default="{ row }">
{{ formatMoney(row.totalRecharge) }}
</template>
</ElTableColumn>
<ElTableColumn label="总流水" width="130">
<ElTableColumn label="总流水" min-width="130">
<template #default="{ row }">
{{ formatMoney(row.totalTurnover) }}
</template>
</ElTableColumn>
<ElTableColumn label="总收益" width="130">
<ElTableColumn label="总收益" min-width="130">
<template #default="{ row }">
{{ formatMoney(row.totalEarnings) }}
</template>
</ElTableColumn>
<ElTableColumn prop="createTime" label="创建时间" width="170" />
<ElTableColumn prop="createTime" label="创建时间" min-width="170" />
</ElTable>
<div class="pagination-wrap">
+13 -8
View File
@@ -36,6 +36,11 @@
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</div>
<!--
数据列一律用 min-width(「操作」列除外,它要固定宽):
Element Plus 只在存在弹性列时才把表格撑满容器,全列写死 width ⇒ 右侧留白。
详见 docs/修改记录.md「表格右侧留白」。
-->
<ElTable
:data="cardList"
row-key="cardCode"
@@ -44,8 +49,8 @@
v-loading="tableLoading"
style="width: 100%"
>
<ElTableColumn prop="id" label="ID" width="80" align="center" />
<ElTableColumn prop="cardCode" label="卡密" width="200" align="center">
<ElTableColumn prop="id" label="ID" min-width="80" align="center" />
<ElTableColumn prop="cardCode" label="卡密" min-width="200" align="center">
<template #default="{ row }">
<span class="card-code">{{ row.cardCode }}</span>
<ElButton type="primary" link size="small" @click="copyText(row.cardCode)">
@@ -53,26 +58,26 @@
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="amount" label="金额" width="100" align="center" />
<ElTableColumn prop="status" label="状态" width="90" align="center">
<ElTableColumn prop="amount" label="金额" min-width="100" align="center" />
<ElTableColumn prop="status" label="状态" min-width="90" align="center">
<template #default="{ row }">
<ElTag v-if="row.status === 0" type="success" size="small">未使用</ElTag>
<ElTag v-else-if="row.status === 2" type="danger" size="small">已销毁</ElTag>
<ElTag v-else type="info" size="small">已使用</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="usedByName" label="使用者" width="140" align="center">
<ElTableColumn prop="usedByName" label="使用者" min-width="140" align="center">
<template #default="{ row }">
{{ row.usedByName || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="usedTime" label="使用时间" width="180" align="center">
<ElTableColumn prop="usedTime" label="使用时间" min-width="180" align="center">
<template #default="{ row }">
{{ row.usedTime || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="creatorName" label="创建者" width="140" align="center" />
<ElTableColumn prop="createTime" label="创建时间" width="180" align="center" />
<ElTableColumn prop="creatorName" label="创建者" min-width="140" align="center" />
<ElTableColumn prop="createTime" label="创建时间" min-width="180" align="center" />
<ElTableColumn label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<ElPopconfirm
+25 -17
View File
@@ -1,5 +1,13 @@
<template>
<div class="page-container">
<!--
三张报表的列一律用 min-width,不要改回 width:
Element Plus 只在「存在弹性列」时才把表格撑满容器
(element-plus/es/components/table/src/table-layout.mjs 的 updateColumnsWidth)。
全列写死 width 时表格宽 = 各列宽之和 ⇒ 容器再宽右侧也会留白。
注意 setColumnWidth 会把 `width` 静态字符串 parseInt 成数字 ⇒ 那是固定列;
只写 `min-width` 才会把 column.width 置空 ⇒ 成为弹性列。详见 docs/修改记录.md。
-->
<ElTabs v-model="activeTab">
<ElTabPane label="佣金明细记录" name="detail">
<div class="search-bar">
@@ -10,21 +18,21 @@
<ElButton type="primary" :icon="Search" @click="loadDetail">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="pagedDetailData" border stripe>
<ElTableColumn prop="localTime" label="本地时间" sortable width="170" />
<ElTableColumn prop="phone" label="手机号" width="130" />
<ElTableColumn prop="consumeAmount" sortable width="120">
<ElTableColumn prop="localTime" label="本地时间" sortable min-width="170" />
<ElTableColumn prop="phone" label="手机号" min-width="130" />
<ElTableColumn prop="consumeAmount" sortable min-width="120">
<template #header>
<span>该次消费/充值金额</span>
</template>
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.consumeAmount) }}</span></template>
</ElTableColumn>
<ElTableColumn label="佣金比例" width="90">
<ElTableColumn label="佣金比例" min-width="90">
<template #default="{ row }">{{ formatRate(row.commissionRate, row.sourceGame) }}</template>
</ElTableColumn>
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable width="100">
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable min-width="100">
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.commissionAmount) }}</span></template>
</ElTableColumn>
<ElTableColumn prop="sourceGame" label="来源" width="120">
<ElTableColumn prop="sourceGame" label="来源" min-width="120">
<template #default="{ row }">{{ gameTypeLabel(row.sourceGame, row.originalSource) }}</template>
</ElTableColumn>
</ElTable>
@@ -42,17 +50,17 @@
<ElButton type="primary" :icon="Search" @click="loadDaily">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="pagedDailyData" border stripe>
<ElTableColumn prop="phone" label="手机号" width="130" />
<ElTableColumn prop="consumeAmount" label="今日消费" sortable width="120">
<ElTableColumn prop="phone" label="手机号" min-width="130" />
<ElTableColumn prop="consumeAmount" label="今日消费" sortable min-width="120">
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.consumeAmount) }}</span></template>
</ElTableColumn>
<ElTableColumn prop="gameType" label="来源" width="120">
<ElTableColumn prop="gameType" label="来源" min-width="120">
<template #default="{ row }">{{ gameTypeLabel(row.gameType) }}</template>
</ElTableColumn>
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable width="120">
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable min-width="120">
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.commissionAmount) }}</span></template>
</ElTableColumn>
<ElTableColumn prop="date" label="日期" sortable width="130" />
<ElTableColumn prop="date" label="日期" sortable min-width="130" />
</ElTable>
<div class="pagination-wrap">
<ElPagination v-model:current-page="dailyPage" v-model:page-size="dailySize" :page-sizes="[10,20,50]" :total="dailyData.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dailyPage = 1" />
@@ -69,17 +77,17 @@
<ElButton type="primary" :icon="Search" @click="loadSummary">查询</ElButton>
</div>
<ElTable v-loading="loading" :data="pagedSummaryData" border stripe>
<ElTableColumn prop="phone" label="手机号" width="130" />
<ElTableColumn prop="gameType" label="来源" width="120">
<ElTableColumn prop="phone" label="手机号" min-width="130" />
<ElTableColumn prop="gameType" label="来源" min-width="120">
<template #default="{ row }">{{ gameTypeLabel(row.gameType) }}</template>
</ElTableColumn>
<ElTableColumn prop="totalFlow" label="消费金额" sortable width="120">
<ElTableColumn prop="totalFlow" label="消费金额" sortable min-width="120">
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.totalFlow) }}</span></template>
</ElTableColumn>
<ElTableColumn prop="totalCommission" label="本次佣金" sortable width="120">
<ElTableColumn prop="totalCommission" label="本次佣金" sortable min-width="120">
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.totalCommission) }}</span></template>
</ElTableColumn>
<ElTableColumn prop="gameCount" label="参加游戏次数" sortable width="140" />
<ElTableColumn prop="gameCount" label="参加游戏次数" sortable min-width="140" />
</ElTable>
<div class="pagination-wrap">
<ElPagination v-model:current-page="summaryPage" v-model:page-size="summarySize" :page-sizes="[10,20,50]" :total="summaryData.length" layout="total, sizes, prev, pager, next, jumper" @size-change="summaryPage = 1" />
@@ -104,7 +112,7 @@ const gameTypeLabel = (code: number, originalSource?: number) => {
4: '对战',
38: '疯狂转盘',
40: '幸运升级',
60: '玫瑰女郎'
68: '麻将胡了'
}
const key = originalSource || code
if (key != null && originalMap[key]) return originalMap[key]
+4 -4
View File
@@ -81,9 +81,9 @@
<span style="color: #f56c6c">{{ formatNum(row.crazyWheelConsume) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="玫瑰女郎" width="110">
<ElTableColumn label="麻将胡了" width="110">
<template #default="{ row }">
<span style="color: #e6a23c">{{ formatNum(row.olympusConsume) }}</span>
<span style="color: #e6a23c">{{ formatNum(row.mahjongConsume) }}</span>
</template>
</ElTableColumn>
</ElTable>
@@ -130,9 +130,9 @@
<span style="color: #f56c6c">{{ formatNum(child.crazyWheelConsume) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="玫瑰女郎" width="110">
<ElTableColumn label="麻将胡了" width="110">
<template #default="{ row: child }">
<span style="color: #e6a23c">{{ formatNum(child.olympusConsume) }}</span>
<span style="color: #e6a23c">{{ formatNum(child.mahjongConsume) }}</span>
</template>
</ElTableColumn>
</ElTable>
+379 -188
View File
@@ -1,8 +1,118 @@
<template>
<div class="home-page">
<!-- 基本信息 -->
<div class="section">
<h3 class="section-title">基本信息</h3>
<!-- ① 我的推广(置顶通栏):把每天真正要用的「链接 / 邀请码」提到最显眼处 -->
<section class="cc-card promo">
<div class="promo-hero">
<div class="hero-main">
<div class="hero-label">我的推广链接</div>
<div class="hero-link" :title="shareLink">
{{ shareLink || '暂未生成推广链接' }}
</div>
</div>
<ElButton
type="primary"
class="hero-btn"
:disabled="!shareLink"
@click="copyText(shareLink)"
>
<ElIcon :size="15"><DocumentCopy /></ElIcon>
<span class="hero-btn-text">复制链接</span>
</ElButton>
</div>
<div class="promo-cells">
<div class="pcell">
<div class="pcell-label">我的邀请码</div>
<div class="pcell-value">
<span class="pcell-strong">{{ userInfo.invitationCode || '-' }}</span>
<ElButton
type="primary"
link
size="small"
@click="copyText(userInfo.invitationCode)"
>
<ElIcon :size="14"><DocumentCopy /></ElIcon>
</ElButton>
<ElButton
v-if="!userInfo.invitationCodeModified"
type="warning"
link
size="small"
@click="editDialogVisible = true"
>
修改
</ElButton>
<span v-else class="pcell-tip">(已修改)</span>
</div>
</div>
<div class="pcell">
<div class="pcell-label">手机号码</div>
<div class="pcell-value">{{ userInfo.phoneNumber || '-' }}</div>
</div>
<div class="pcell">
<div class="pcell-label">上级邀请码</div>
<div class="pcell-value">{{ userInfo.parentInvitationCode || '-' }}</div>
</div>
</div>
</section>
<!-- ② 返利 KPI 横带:累计 / 今日 / 本周 / 下级 -->
<div class="kpi-band">
<div v-for="k in kpis" :key="k.label" class="kpi">
<div class="kpi-label">{{ k.label }}</div>
<div class="kpi-value" :class="k.tone">{{ k.value }}</div>
</div>
</div>
<!-- ③ 底部两栏:返利比例(左窄) | 周期返利汇总(右宽) -->
<div class="bottom-grid">
<section class="cc-card rate-panel">
<h3 class="cc-title">返利比例</h3>
<ul class="rate-list">
<li v-for="g in rateRows" :key="g.name" class="rate-row">
<span class="rate-name">{{ g.name }}</span>
<span class="rate-dots" aria-hidden="true"></span>
<span class="rate-val">{{ formatRate(g.rate) }}</span>
</li>
</ul>
</section>
<section class="cc-card table-panel">
<h3 class="cc-title">周期返利汇总</h3>
<ElTable :data="periodData" border stripe class="commission-table">
<ElTableColumn prop="period" label="周期" align="center" width="100" />
<ElTableColumn prop="classicBox" label="经典开箱" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.classicBox) }}</template>
</ElTableColumn>
<ElTableColumn prop="luckyUpgrade" label="幸运升级" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.luckyUpgrade) }}</template>
</ElTableColumn>
<ElTableColumn prop="battle" label="对战" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.battle) }}</template>
</ElTableColumn>
<ElTableColumn prop="crazyWheel" label="疯狂转盘" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.crazyWheel) }}</template>
</ElTableColumn>
<ElTableColumn prop="mahjong" label="麻将胡了" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.mahjong) }}</template>
</ElTableColumn>
<ElTableColumn prop="totalConsume" label="总消费" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.totalConsume) }}</template>
</ElTableColumn>
<ElTableColumn prop="recharge" label="充值" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.recharge) }}</template>
</ElTableColumn>
<ElTableColumn prop="newSub" label="新增下级" align="center" min-width="100" />
<ElTableColumn prop="totalRebate" label="总返利" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.totalRebate) }}</template>
</ElTableColumn>
</ElTable>
</section>
</div>
<!-- ④ 账号信息(降级到底部):日常用不到的身份/余额信息 -->
<section class="cc-card account-panel">
<h3 class="cc-title">账号信息</h3>
<ElDescriptions :column="3" border class="custom-desc">
<ElDescriptionsItem label="用户账号">
{{ userInfo.userId }}
@@ -16,121 +126,18 @@
<ElDescriptionsItem label="积分余额">
{{ userInfo.accountCredits ?? 0 }}
</ElDescriptionsItem>
<ElDescriptionsItem label="手机号码">
{{ userInfo.userName }}
</ElDescriptionsItem>
<ElDescriptionsItem label="注册时间">
{{ userInfo.createTime }}
</ElDescriptionsItem>
<ElDescriptionsItem label="Steam交易链接">
{{ userInfo.transactionLink || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="上级邀请码">
{{ userInfo.parentInvitationCode || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="我的邀请码">
{{ userInfo.invitationCode }}
<ElButton
type="primary"
link
size="small"
style="margin-left: 8px"
@click="copyText(userInfo.invitationCode)"
>
<ElIcon :size="14"><DocumentCopy /></ElIcon>
</ElButton>
<ElButton
v-if="!userInfo.invitationCodeModified"
type="warning"
link
size="small"
style="margin-left: 8px"
@click="editDialogVisible = true"
>
修改
</ElButton>
<span v-else style="margin-left: 8px; color: #909399; font-size: 12px">(已修改)</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="推广链接">
{{ shareLink }}
<ElButton
v-if="shareLink"
type="primary"
link
size="small"
style="margin-left: 8px"
@click="copyText(shareLink)"
>
<ElIcon :size="14"><DocumentCopy /></ElIcon>
</ElButton>
</ElDescriptionsItem>
<ElDescriptionsItem label="" />
</ElDescriptions>
</div>
<!-- 游戏返利看板 -->
<div class="section">
<h3 class="section-title">游戏返利比例</h3>
<div class="game-rate-cards">
<div class="rate-card">
<div class="rate-card-title">经典开箱</div>
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
</div>
<div class="rate-card">
<div class="rate-card-title">幸运升级</div>
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
</div>
<div class="rate-card">
<div class="rate-card-title">对战</div>
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
</div>
<div class="rate-card">
<div class="rate-card-title">疯狂转盘</div>
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
</div>
<div class="rate-card">
<div class="rate-card-title">玫瑰女郎</div>
<div class="rate-card-value">{{ formatRate(gameRates.newGameRate) }}</div>
</div>
</div>
</div>
<!-- 周期返利汇总 -->
<div class="section">
<h3 class="section-title">周期返利汇总 </h3>
<ElTable :data="periodData" border stripe class="commission-table">
<ElTableColumn prop="period" label="周期" align="center" width="100" />
<ElTableColumn prop="classicBox" label="经典开箱" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.classicBox) }}</template>
</ElTableColumn>
<ElTableColumn prop="luckyUpgrade" label="幸运升级" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.luckyUpgrade) }}</template>
</ElTableColumn>
<ElTableColumn prop="battle" label="对战" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.battle) }}</template>
</ElTableColumn>
<ElTableColumn prop="crazyWheel" label="疯狂转盘" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.crazyWheel) }}</template>
</ElTableColumn>
<ElTableColumn prop="olympus" label="玫瑰女郎" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.olympus) }}</template>
</ElTableColumn>
<ElTableColumn prop="totalConsume" label="总消费" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.totalConsume) }}</template>
</ElTableColumn>
<ElTableColumn prop="recharge" label="充值" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.recharge) }}</template>
</ElTableColumn>
<ElTableColumn prop="newSub" label="新增下级" align="center" min-width="100" />
<ElTableColumn prop="totalRebate" label="总返利" align="center" min-width="110">
<template #default="{ row }">{{ formatNum(row.totalRebate) }}</template>
</ElTableColumn>
</ElTable>
</div>
</section>
<!-- 修改邀请码弹窗 -->
<ElDialog v-model="editDialogVisible" title="修改邀请码" width="400px">
<p style="color: #e6a23c; margin-bottom: 12px">注意:邀请码只能修改一次,请谨慎操作!</p>
<p style="color: #8f6418; margin-bottom: 12px">注意:邀请码只能修改一次,请谨慎操作!</p>
<ElForm>
<ElFormItem label="新邀请码">
<ElInput v-model="newInvitationCode" placeholder="请输入 6 位邀请码" maxlength="6" :pattern="/^[a-zA-Z0-9]*$/" :rules="[{ required: true, message: '邀请码只能包含英文字母和数字', trigger: 'blur' }]" />
@@ -150,12 +157,10 @@ import { ElDescriptions, ElDescriptionsItem, ElButton, ElMessage, ElDialog, ElFo
import { DocumentCopy } from '@element-plus/icons-vue'
import { getInfo, getShareDomain, updateInvitationCode } from '@/api/user'
import { getGameCommissionData } from '@/api/turnover'
import { useUserStore } from '@/store/modules/user'
// 用户基本信息
const userInfo = reactive<any>({})
const shareLink = ref('')
const userStore = useUserStore()
// 修改邀请码弹窗
const editDialogVisible = ref(false)
@@ -183,13 +188,6 @@ const submitInvitationCode = async () => {
editDialogVisible.value = false
newInvitationCode.value = ''
await loadUserInfo()
// 同步更新顶部 header 中的分享链接
if (userStore.getUserInfo) {
userStore.setUserInfo({
...userStore.getUserInfo,
shareLink: shareLink.value
})
}
} else {
ElMessage.error(res.msg || '修改失败')
}
@@ -205,7 +203,7 @@ const gameRates = reactive({
classicGameRate: 0
})
// 周期返利汇总数据
// 周期返利汇总数据(后端顺序固定:今日 / 昨日 / 本周 / 总)
const periodData = ref([] as any[])
// 用户类型映射
@@ -221,6 +219,38 @@ const userTypeLabel = computed(() => {
return map[userInfo.userType] || '未知'
})
/**
* 返利比例列表。后端只下发三个比例(充值 / 新游戏 / 经典玩法),
* 5 个玩法里「麻将胡了」走新游戏比例,其余 4 个经典玩法共用经典比例
* —— 与后端 buildPeriod 里 classicConsume / mahjong 的算法口径一致。
*/
const rateRows = computed(() => [
{ name: '经典开箱', rate: gameRates.classicGameRate },
{ name: '幸运升级', rate: gameRates.classicGameRate },
{ name: '对战', rate: gameRates.classicGameRate },
{ name: '疯狂转盘', rate: gameRates.classicGameRate },
{ name: '麻将胡了', rate: gameRates.newGameRate }
])
// 按周期名取 periodData 里的一行
const periodOf = (name: string) => periodData.value.find((p: any) => p.period === name)
/**
* 顶部 KPI 横带:累计返利 / 今日返利 / 本周返利 / 累计下级。
* 全部取自周期返利汇总的「总 / 今日 / 本周」行,不额外发请求。
*/
const kpis = computed(() => {
const total = periodOf('总')
const today = periodOf('今日')
const week = periodOf('本周')
return [
{ label: '累计返利', value: formatNum(total?.totalRebate ?? 0), tone: 'is-primary' },
{ label: '今日返利', value: formatNum(today?.totalRebate ?? 0), tone: '' },
{ label: '本周返利', value: formatNum(week?.totalRebate ?? 0), tone: '' },
{ label: '累计下级', value: String(total?.newSub ?? 0), tone: 'is-success' }
]
})
// 复制到剪贴板(兼容非 HTTPS 环境)
const copyText = (text: string) => {
if (navigator.clipboard) {
@@ -322,97 +352,258 @@ onMounted(async () => {
<style scoped>
.home-page {
padding: 5px 10px;
}
.section {
margin-bottom: 30px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin: 0 0 15px 0;
}
.user-type-anchor {
color: #e6a23c;
}
/* 覆盖 ElDescriptions 默认的 label 灰色背景 */
.custom-desc :deep(.el-descriptions__label) {
background-color: #fff;
color: #606266;
font-weight: normal;
width: 120px;
}
.custom-desc :deep(.el-descriptions__content) {
background-color: #fff;
}
/* 游戏返利比例卡片 */
.game-rate-cards {
display: flex;
gap: 15px;
justify-content: space-between;
flex-wrap: wrap;
flex-direction: column;
gap: 16px;
}
.rate-card {
flex: 1;
min-width: 150px;
max-width: calc(20% - 12px);
background-color: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 20px 15px;
text-align: center;
transition: all 0.3s ease;
/* 通用卡片外壳(田园核:奶油面 + 亚麻描边) */
.cc-card {
background-color: #fffdfa;
border: 1px solid #eadfd0;
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.06);
}
.rate-card:hover {
background-color: #fbf5ea;
border-color: #9a5c35;
transform: translateY(-2px);
box-shadow: 0 2px 12px rgba(154, 92, 53, 0.10);
}
.rate-card-title {
.cc-title {
font-size: 15px;
font-weight: bold;
color: #303133;
font-weight: 700;
color: #4f4130;
margin: 0 0 14px 0;
}
/* ===== ① 我的推广 ===== */
.promo-hero {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
background-color: #fbf5ea;
border: 1px solid #eadfd0;
border-radius: 10px;
}
.hero-main {
flex: 1;
min-width: 0;
}
.hero-label {
font-size: 12px;
color: #7a664e;
margin-bottom: 6px;
}
.hero-link {
font-size: 15px;
font-weight: 600;
color: #4f4130;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-variant-numeric: tabular-nums;
}
.hero-btn {
flex: none;
}
.hero-btn-text {
margin-left: 6px;
}
.promo-cells {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #eadfd0;
}
.pcell {
padding: 0 18px;
border-left: 1px solid #eadfd0;
min-width: 0;
}
.pcell:first-child {
padding-left: 0;
border-left: none;
}
.pcell-label {
font-size: 12px;
color: #7a664e;
margin-bottom: 6px;
}
.pcell-value {
display: flex;
align-items: center;
font-size: 15px;
color: #4f4130;
min-width: 0;
}
.pcell-strong {
font-weight: 700;
color: #9a5c35;
letter-spacing: 1px;
}
.pcell-tip {
margin-left: 8px;
font-size: 12px;
color: #7a664e;
}
/* ===== ② KPI 横带 ===== */
.kpi-band {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
background-color: #fffdfa;
border: 1px solid #eadfd0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.06);
}
.kpi {
padding: 16px 20px;
border-left: 1px solid #eadfd0;
}
.kpi:first-child {
border-left: none;
}
.kpi-label {
font-size: 12px;
color: #7a664e;
margin-bottom: 8px;
}
.rate-card-sub {
font-size: 12px;
color: #909399;
margin-bottom: 12px;
.kpi-value {
font-size: 19px;
font-weight: 700;
line-height: 1.2;
color: #4f4130;
font-variant-numeric: tabular-nums;
}
.rate-card-value {
font-size: 20px;
font-weight: bold;
color: #67c23a;
.kpi-value.is-primary {
color: #9a5c35;
}
.kpi-value.is-success {
color: #3f6b45;
}
/* ===== ③ 底部两栏 ===== */
.bottom-grid {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.rate-list {
list-style: none;
margin: 0;
padding: 0;
}
.rate-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 10px 0;
border-bottom: 1px dashed #eadfd0;
}
.rate-row:last-child {
border-bottom: none;
}
.rate-name {
font-size: 14px;
color: #5f4f3d;
flex: none;
}
.rate-dots {
flex: 1;
border-bottom: 1px dotted #d8c3b0;
transform: translateY(-3px);
}
.rate-val {
flex: none;
font-size: 15px;
font-weight: 700;
color: #3f6b45;
font-variant-numeric: tabular-nums;
}
.table-panel {
min-width: 0;
overflow: hidden;
}
/* 返利汇总表格 */
.commission-table {
width: 100%;
}
.commission-table :deep(.el-table__header th) {
background-color: #fafafa !important;
color: #606266;
font-weight: bold;
background-color: #f6efe3 !important;
color: #5f4f3d;
font-weight: 700;
}
.commission-table :deep(.el-table__row tr:nth-child(odd)) {
background-color: #fafafa;
/* ===== ④ 账号信息(降级:不做成卡片,别和上面的数据抢注意力) ===== */
.account-panel {
background-color: transparent;
border-color: transparent;
box-shadow: none;
}
.commission-table :deep(.el-table__row tr:nth-child(even)) {
background-color: #fff;
.user-type-anchor {
color: #8f6418;
}
.custom-desc :deep(.el-descriptions__label) {
background-color: #f6efe3;
color: #7a664e;
font-weight: normal;
width: 110px;
}
.custom-desc :deep(.el-descriptions__content) {
background-color: #fffdfa;
color: #4f4130;
}
/* 窄屏:底部两栏收成一栏,表格拿满宽度 */
@media (max-width: 1200px) {
.bottom-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.kpi-band {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.promo-cells {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.pcell {
padding: 0;
border-left: none;
}
}
</style>
+7 -78
View File
@@ -215,86 +215,15 @@ const inviteCode = computed(() => {
/* =========================
验证码行
只改按钮,不碰 input / row / col 的布局
「获取验证码」按钮的配色写在 LoginForm.vue 里(.login-code-btn)。
这里原本有一条 `.el-form-item .el-row .el-col:last-child .el-button`
和一组 .send-code / .code-button / .get-code-btn,都是死代码:
Form.vue 的实际结构是 ElRow > ElCol > ElFormItem > 插槽内容,
el-row 是 el-form-item 的祖先而不是后代,选择器永远匹配不到;
类名也对不上(真实类名是 .login-code-btn)。
不要再往这里加按钮样式,改 LoginForm.vue。
========================= */
/*
LoginForm 常见结构:
el-form-item
el-row
el-col -> input
el-col -> button
这里仅让右侧按钮与输入框同高、同圆角、同玻璃质感。
*/
:deep(.el-form-item .el-row .el-col:last-child .el-button) {
width: 100%;
height: 48px;
padding: 0 16px;
border-radius: 12px;
border: 1px solid rgba(212, 160, 160, 0.6);
background: rgba(245, 215, 95, 0.22);
color: #9a5c35;
font-size: 13px;
font-weight: 600;
box-shadow: none;
transition:
background 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover {
color: #7d4a2a;
border-color: #c08a8a;
background: rgba(245, 215, 95, 0.34);
}
&:active {
transform: scale(0.97);
}
&.is-disabled,
&:disabled {
color: #9a886e;
border-color: #eadfd0;
background: #f6efe3;
}
}
/*
如果验证码按钮不是放在 el-row/el-col 里,
兼容常见的 send-code / code-button 类。
*/
:deep(.send-code),
:deep(.code-button),
:deep(.get-code-btn) {
height: 48px;
border-radius: 12px;
border: 1px solid rgba(212, 160, 160, 0.6);
background: rgba(245, 215, 95, 0.22);
color: #9a5c35;
font-size: 13px;
font-weight: 600;
}
/* =========================
登录主按钮
========================= */
@@ -1,6 +1,12 @@
<script setup lang="tsx">
import { getAdminRoleApi, getCodeApi, smsLoginApi } from '@/api/login'
import type { SmsLoginType, UserType } from '@/api/login/types'
import {
devLoginApi,
getAdminRoleApi,
getCodeApi,
precheckPhoneApi,
smsLoginApi
} from '@/api/login'
import type { DevLoginType, SmsLoginType, UserType } from '@/api/login/types'
import { getMyMenus } from '@/api/permission'
import { PATH_URL } from '@/axios/service'
import { BaseButton } from '@/components/Button'
@@ -25,16 +31,25 @@ const { currentRoute, addRoute, push, resolve } = router
const { required } = useValidator()
const { formRegister, formMethods } = useForm()
const { getFormData, getElFormExpose, setValues } = formMethods
// 账号密码登录用独立表单实例:与短信表单互斥渲染(v-if 切换),各自持有自己的 el-form
const { formRegister: pwdFormRegister, formMethods: pwdFormMethods } = useForm()
const { getFormData: getPwdFormData, getElFormExpose: getPwdElFormExpose } = pwdFormMethods
// 招商邀请链接里带的邀请码(/login?code=xxx),进入后自动填到邀请码框
const props = defineProps<{
inviteCode?: string
}>()
// 账号密码登录只在本地 dev server(pnpm run dev)下提供:
// 生产/测试用 vite build 打包时 import.meta.env.DEV 为 false,入口与表单整块不渲染
const devPasswordLogin = import.meta.env.DEV
const loginMode = ref<'sms' | 'password'>('sms')
const loading = ref(false)
const redirect = ref('')
const getCodeTime = ref(60)
const getCodeLoading = ref(false)
const captchaCode = ref('')
let tacInstance: any = null
let codeTimer: ReturnType<typeof setInterval> | undefined
@@ -61,19 +76,27 @@ const rules = {
code: [required()]
}
const passwordRules = {
account: [required()],
password: [required()]
}
// 两种登录模式共用的标题,保证卡片标题样式完全一致
const renderLoginTitle = () => (
<div class="login-form-title">
<div class="title-decoration"></div>
<h2>登录</h2>
<div class="title-decoration"></div>
</div>
)
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: { span: 24 },
formItemProps: {
slots: {
default: () => (
<div class="login-form-title">
<div class="title-decoration"></div>
<h2>登录</h2>
<div class="title-decoration"></div>
</div>
)
default: renderLoginTitle
}
}
},
@@ -107,8 +130,17 @@ const schema = reactive<FormSchema[]>([
if (event.key === 'Enter') signIn()
}}
/>
<ElButton class="login-code-btn" disabled={unref(getCodeLoading)} onClick={getCode}>
{unref(getCodeLoading) ? `${unref(getCodeTime)}s` : '获取验证码'}
<ElButton
class="login-code-btn"
disabled={unref(getCodeLoading)}
loading={unref(getCodeLoading) && !unref(captchaCode)}
onClick={getCode}
>
{unref(getCodeLoading)
? unref(captchaCode)
? `${unref(getCodeTime)}s`
: '发送中'
: '获取验证码'}
</ElButton>
</div>
)
@@ -152,6 +184,82 @@ const schema = reactive<FormSchema[]>([
}
])
// 账号密码登录表单:字段口径与后端 /promo/devLogin 一致(推广账号填手机号,超管可填账号名或手机号)
const passwordSchema = reactive<FormSchema[]>([
{
field: 'title',
colProps: { span: 24 },
formItemProps: {
slots: {
default: renderLoginTitle
}
}
},
{
field: 'account',
label: '',
value: '',
colProps: { span: 24 },
formItemProps: {
slots: {
default: (formData) => (
<ElInput
v-model={formData.account}
maxlength={32}
clearable
placeholder="请输入账号(推广账号填手机号)"
onKeydown={(event: KeyboardEvent) => {
if (event.key === 'Enter') passwordSignIn()
}}
/>
)
}
}
},
{
field: 'password',
label: '',
value: '',
colProps: { span: 24 },
formItemProps: {
slots: {
default: (formData) => (
<ElInput
v-model={formData.password}
type="password"
showPassword
maxlength={32}
placeholder="请输入密码"
onKeydown={(event: KeyboardEvent) => {
if (event.key === 'Enter') passwordSignIn()
}}
/>
)
}
}
},
{
field: 'login',
colProps: { span: 24 },
formItemProps: {
slots: {
default: () => (
<div class="login-form-btn w-[100%]">
<BaseButton
loading={loading.value}
type="primary"
class="full-width"
onClick={passwordSignIn}
>
登录
</BaseButton>
</div>
)
}
}
}
])
watch(
() => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => {
@@ -165,6 +273,32 @@ onBeforeUnmount(() => {
if (codeTimer) clearInterval(codeTimer)
})
// 点下按钮就开始倒计时:短信网关往返通常 1~3 秒,等接口返回再倒计时会像卡死
const startCountdown = () => {
if (codeTimer) clearInterval(codeTimer)
captchaCode.value = ''
getCodeTime.value = 60
getCodeLoading.value = true
codeTimer = setInterval(() => {
getCodeTime.value--
if (getCodeTime.value <= 0) {
clearInterval(codeTimer)
codeTimer = undefined
getCodeTime.value = 60
getCodeLoading.value = false
}
}, 1000)
}
// 发送失败(限流/验证码失效/发送异常):解禁按钮,让用户能立刻重试
const resetCountdown = () => {
if (codeTimer) clearInterval(codeTimer)
codeTimer = undefined
getCodeTime.value = 60
getCodeLoading.value = false
captchaCode.value = ''
}
const getCode = async () => {
if (getCodeLoading.value) return
const { username, parentInvitationCode } = await getFormData<SmsLoginType>()
@@ -172,6 +306,10 @@ const getCode = async () => {
ElMessage.error('请输入正确的手机号')
return
}
// 先验账号再弹滑块:不是推广后台用户(与后台账号校验同一份规则)直接拒绝,
// 避免用户白拖一次滑块才被告知没权限;接口失败/被限流时放行走原流程,发送时后端还会再验一次
const phoneCheck = await precheckPhoneApi(username, parentInvitationCode).catch(() => null)
if (!phoneCheck) return
if (typeof window.TAC === 'undefined' || typeof window.CaptchaConfig === 'undefined') {
ElMessage.error('滑块验证码未加载,请刷新页面')
return
@@ -187,19 +325,18 @@ const getCode = async () => {
validSuccess: (_res: unknown, _config: unknown, tac: any) => {
tac.destroyWindow()
tacInstance = null
getCodeApi(username, captchaId, '2', parentInvitationCode).then(() => {
ElMessage.success('验证码已发送')
getCodeLoading.value = true
codeTimer = setInterval(() => {
getCodeTime.value--
if (getCodeTime.value <= 0) {
clearInterval(codeTimer)
codeTimer = undefined
getCodeTime.value = 60
getCodeLoading.value = false
startCountdown()
getCodeApi(username, captchaId, '2', parentInvitationCode)
.then((res: any) => {
// 业务失败(限流/验证码失效)响应拦截器已弹过错误提示,这里只释放倒计时
if (res?.code !== 0 && res?.code !== 200) {
resetCountdown()
return
}
}, 1000)
})
captchaCode.value = captchaId
ElMessage.success('验证码已发送')
})
.catch(() => resetCountdown())
},
validFail: (_res: unknown, _config: unknown, tac: any) => {
ElMessage.error('滑块验证失败,请重试')
@@ -248,6 +385,37 @@ const signIn = async () => {
}
}
// 账号密码登录(仅 dev server 可见):后端 /promo/devLogin 校验 BCrypt 密码后直接签发 token,
// 之后的落地流程与短信登录完全一致
const passwordSignIn = async () => {
if (loading.value) return
const formRef = await getPwdElFormExpose()
if (
!(await formRef
?.validateField(['account', 'password'])
.then(() => true)
.catch(() => false))
)
return
loading.value = true
try {
const formData = await getPwdFormData<DevLoginType>()
const res = await devLoginApi({
account: formData.account,
password: formData.password
})
if (!res) return
userStore.setLoginInfo(undefined)
userStore.setRememberMe(false)
await completeLogin(formData.account, (res as any).token)
await enterSystem(formData.account)
} finally {
loading.value = false
}
}
const getRoleRouters = async () => {
const res = (await getMyMenus()) as any
return { code: 0, data: res.data || [] }
@@ -290,18 +458,96 @@ const enterSystem = async (username: string) => {
</script>
<template>
<!-- 登录方式切换:仅本地 dev server 渲染,生产/测试构建包不会出现该入口 -->
<div v-if="devPasswordLogin" class="login-mode-switch">
<button
type="button"
class="login-mode-item"
:class="{ 'is-active': loginMode === 'sms' }"
@click="loginMode = 'sms'"
>
短信验证码登录
</button>
<button
type="button"
class="login-mode-item"
:class="{ 'is-active': loginMode === 'password' }"
@click="loginMode = 'password'"
>
账号密码登录
</button>
</div>
<Form
:schema="schema"
:rules="rules"
v-if="devPasswordLogin && loginMode === 'password'"
:schema="passwordSchema"
:rules="passwordRules"
label-position="top"
hide-required-asterisk
size="large"
@register="formRegister"
@register="pwdFormRegister"
/>
<div id="captcha-box" class="captcha-box"></div>
<template v-else>
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
@register="formRegister"
/>
<div id="captcha-box" class="captcha-box"></div>
</template>
</template>
<style lang="less" scoped>
/* =========================
登录方式切换(仅 dev server 渲染)
与卡片同色系:默认低调暖棕文字,选中态用奶油底 + 花粉细边
对比度:默认 5.9:1 / hover 5.3:1 / 选中 6.3:1,均过 WCAG AA
========================= */
.login-mode-switch {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.login-mode-item {
padding: 7px 16px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: #7a664e;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
transition:
color 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
&:hover {
color: #9a5c35;
border-color: rgba(212, 160, 160, 0.45);
}
&.is-active {
color: #854c2a;
font-weight: 600;
background: #fbf5ea;
border-color: rgba(212, 160, 160, 0.55);
}
}
.captcha-box {
position: fixed;
top: 50%;
@@ -322,32 +568,39 @@ const enterSystem = async (username: string) => {
min-width: 0;
}
/* 独立圆角按钮:拼接式(el-input-group__append)左侧必须是直角,四角圆角做不到 */
/* 独立圆角按钮:拼接式(el-input-group__append)左侧必须是直角,四角圆角做不到。
原紫蓝半透明底(rgba(126,132,240,.3))+ 近白字(#eef0ff)是给深色背景配的,
搬到奶油卡片上后字面比底色还亮(对比度约 0.8:1),按钮看上去就是一块空白;
改用主题色板内色值:默认 6.31:1 / hover 5.99:1 / 倒计时 5.00:1,均过 WCAG AA */
:deep(.sms-code-row .login-code-btn) {
flex: 0 0 124px;
height: 48px;
margin: 0 0 0 10px;
padding: 0;
color: #eef0ff;
color: #854c2a;
font-size: 13px;
font-weight: 600;
border: 1px solid rgba(150, 158, 255, 0.42);
border: 1px solid rgba(212, 160, 160, 0.55);
border-radius: 12px;
background: rgba(126, 132, 240, 0.3);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
background: #fbf5ea;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition:
background 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
:deep(.sms-code-row .login-code-btn:hover) {
color: #fff;
border-color: rgba(172, 180, 255, 0.62);
background: rgba(126, 132, 240, 0.44);
color: #854c2a;
border-color: #c08a8a;
background: #f6efe3;
}
:deep(.sms-code-row .login-code-btn.is-disabled),
:deep(.sms-code-row .login-code-btn.is-disabled:hover) {
color: rgba(230, 232, 255, 0.45);
border-color: rgba(255, 255, 255, 0.12);
background: rgba(126, 132, 240, 0.14);
color: #6b6152;
border-color: #eadfd0;
background: #f0e8dc;
}
:deep(.login-form-btn .el-button) {
@@ -10,7 +10,7 @@ import { RegisterUserType } from '@/api/login/types'
import { useUserStore } from '@/store/modules/user'
import { completeLogin } from '../completeLogin'
import { useRouter } from 'vue-router'
import { registerApi, getCodeApi } from '@/api/login'
import { registerApi, getCodeApi, precheckPhoneApi } from '@/api/login'
import { PATH_URL } from '@/axios/service'
declare global {
@@ -38,12 +38,38 @@ const { required } = useValidator()
const getCodeTime = ref(60)
const getCodeLoading = ref(false)
let tacInstance: any = null
let codeTimer: ReturnType<typeof setInterval> | undefined
// 点下按钮就开始倒计时:短信网关往返通常 1~3 秒,等接口返回再倒计时会像卡死
const startCountdown = () => {
if (codeTimer) clearInterval(codeTimer)
getCodeTime.value = 60
getCodeLoading.value = true
codeTimer = setInterval(() => {
getCodeTime.value--
if (getCodeTime.value <= 0) {
clearInterval(codeTimer)
codeTimer = undefined
getCodeTime.value = 60
getCodeLoading.value = false
}
}, 1000)
}
// 发送失败(限流/验证码失效/发送异常):解禁按钮,让用户能立刻重试
const resetCountdown = () => {
if (codeTimer) clearInterval(codeTimer)
codeTimer = undefined
getCodeTime.value = 60
getCodeLoading.value = false
}
onBeforeUnmount(() => {
if (tacInstance) {
tacInstance.destroyWindow()
tacInstance = null
}
if (codeTimer) clearInterval(codeTimer)
})
const getCode = async () => {
@@ -52,6 +78,12 @@ const getCode = async () => {
ElMessage.error('手机号不能为空')
return
}
// 先验账号再弹滑块,与登录页同一份规则(注册页无账号时靠招商邀请码放行);接口失败/被限流时放行走原流程
const phoneCheck = await precheckPhoneApi(
formData.username,
(formData as any).parentInvitationCode
).catch(() => null)
if (!phoneCheck) return
// 判断TAC是否加载
if (typeof window.TAC === 'undefined' || typeof window.CaptchaConfig === 'undefined') {
@@ -71,23 +103,18 @@ const getCode = async () => {
tac.destroyWindow()
tacInstance = null
const captchaId = window._captchaId
// 滑块验证成功,发送短信验证码
// 滑块验证成功,立刻进入倒计时,再等短信接口返回
startCountdown()
getCodeApi(formData.username, captchaId)
.then(() => {
.then((res: any) => {
// 业务失败(限流/验证码失效)响应拦截器已弹过错误提示,这里只释放倒计时
if (res?.code !== 0 && res?.code !== 200) {
resetCountdown()
return
}
ElMessage.success('获取验证码成功')
getCodeLoading.value = true
const timer = setInterval(() => {
getCodeTime.value--
if (getCodeTime.value <= 0) {
clearInterval(timer)
getCodeTime.value = 60
getCodeLoading.value = false
}
}, 1000)
})
.catch(() => {
ElMessage.error('获取验证码失败')
})
.catch(() => resetCountdown())
},
validFail: (res: any, c: any, tac: any) => {
ElMessage.error('滑块验证失败,请重试')
+5 -2
View File
@@ -255,12 +255,15 @@
<ElTableColumn prop="id" label="ID" width="60" />
<ElTableColumn prop="templateName" label="模板名称" min-width="150" />
<ElTableColumn prop="ornamentCount" label="饰品数" width="80" />
<ElTableColumn prop="createBy" label="创建人" width="100" />
<ElTableColumn prop="createBy" label="创建人" width="100">
<template #default="{ row }">{{ row.createBy || '其他主播' }}</template>
</ElTableColumn>
<ElTableColumn prop="createTime" label="创建时间" width="170" />
<ElTableColumn label="操作" width="140">
<template #default="{ row }">
<ElButton type="primary" link size="small" @click="handleLoadTemplate(row)">加载</ElButton>
<ElButton type="danger" link size="small" @click="handleDeleteTemplate(row.id)">删除</ElButton>
<!-- 后端只允许创建者本人或超管删除;共享模板 createBy 会被置空,这里不给别人显示删除按钮 -->
<ElButton v-if="row.createBy" type="danger" link size="small" @click="handleDeleteTemplate(row.id)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
@@ -19,21 +19,22 @@
<ElButton type="primary" :icon="Search" @click="loadList">查询</ElButton>
</div>
<!-- 列宽一律用 min-width:全列写死 width 时 Element Plus 不拉伸,表格右侧会留白(见 docs/修改记录.md) -->
<ElTable v-loading="loading" :data="pagedData" border stripe>
<ElTableColumn prop="rechargeTime" label="充值时间" sortable width="170" />
<ElTableColumn prop="userId" label="用户ID" width="120" />
<ElTableColumn prop="parentId" label="上级ID" width="120">
<ElTableColumn prop="rechargeTime" label="充值时间" sortable min-width="170" />
<ElTableColumn prop="userId" label="用户ID" min-width="120" />
<ElTableColumn prop="parentId" label="上级ID" min-width="120">
<template #default="{ row }">
{{ row.parentId || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="phone" label="手机号" width="130" />
<ElTableColumn prop="userType" label="用户类型" width="100">
<ElTableColumn prop="phone" label="手机号" min-width="130" />
<ElTableColumn prop="userType" label="用户类型" min-width="100">
<template #default="{ row }">
{{ row.userType === '01' ? '主播' : row.userType === '02' ? '玩家' : row.userType === '04' ? '招商' : row.userType || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="rechargeAmount" label="充值金额" sortable width="120">
<ElTableColumn prop="rechargeAmount" label="充值金额" sortable min-width="120">
<template #default="{ row }">
<span style="color: #67c23a">{{ formatNum(row.rechargeAmount) }}</span>
</template>
+17 -13
View File
@@ -13,6 +13,10 @@
>
</el-form-item>
</el-form>
<!--
数据列一律用 min-width(「操作」列除外,它要固定宽):
全列写死 width 时 Element Plus 不拉伸表格 ⇒ 右侧留白(见 docs/修改记录.md)。
-->
<el-table
:data="alltableData"
style="width: 100%; margin-bottom: 20px; margin-top: 10px"
@@ -21,25 +25,25 @@
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="userName" label="用户名" width="140" />
<el-table-column prop="nickName" label="昵称" width="120" />
<el-table-column prop="userType" label="用户类型" width="100">
<el-table-column prop="userId" label="用户ID" min-width="120" />
<el-table-column prop="userName" label="用户名" min-width="140" />
<el-table-column prop="nickName" label="昵称" min-width="120" />
<el-table-column prop="userType" label="用户类型" min-width="100">
<template #default="scope">
<el-tag v-if="scope.row.userType === '01'" type="warning" size="small">主播</el-tag>
<el-tag v-else-if="scope.row.userType === '02'" type="info" size="small">玩家</el-tag>
<el-tag v-else size="small">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column prop="totalRecharge" label="总充值" width="100" />
<el-table-column prop="classicBoxConsume" label="经典开箱" width="100" />
<el-table-column prop="battleConsume" label="对战" width="100" />
<el-table-column prop="luckUpgradeConsume" label="幸运升级" width="100" />
<el-table-column prop="crazyWheelConsume" label="疯狂转盘" width="100" />
<el-table-column prop="westDuelConsume" label="荒野争锋" width="100" />
<el-table-column prop="sweetConsume" label="极速糖果" width="100" />
<el-table-column prop="totalCommission" label="总佣金" width="100" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
<el-table-column prop="totalRecharge" label="总充值" min-width="100" />
<el-table-column prop="classicBoxConsume" label="经典开箱" min-width="100" />
<el-table-column prop="battleConsume" label="对战" min-width="100" />
<el-table-column prop="luckUpgradeConsume" label="幸运升级" min-width="100" />
<el-table-column prop="crazyWheelConsume" label="疯狂转盘" min-width="100" />
<el-table-column prop="westDuelConsume" label="荒野争锋" min-width="100" />
<el-table-column prop="sweetConsume" label="极速糖果" min-width="100" />
<el-table-column prop="totalCommission" label="总佣金" min-width="100" />
<el-table-column label="操作" align="center" width="120" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
+3
View File
@@ -159,6 +159,9 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
host: '0.0.0.0'
},
optimizeDeps: {
// 默认会扫描 **/*.html,把 dist-dev/dist-pro/stats.html 等构建产物也当入口,
// 显式限定为 dev 入口,避免产物目录里的旧代码参与依赖预构建
entries: ['index.html'],
include: [
'vue',
'vue-router',
+1 -7
View File
@@ -29,12 +29,6 @@
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> <!-- 表示依赖不会传递 -->
</dependency>
<!-- spring-doc -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- Mysql驱动包 -->
<dependency>
<groupId>com.mysql</groupId>
@@ -97,7 +91,7 @@
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>game-olympus-service</artifactId>
<artifactId>game-mahjong-service</artifactId>
<version>${ruoyi.version}</version>
</dependency>
@@ -4,8 +4,6 @@ import java.util.ArrayList;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
@@ -33,7 +31,6 @@ import com.ruoyi.system.service.ISysDictTypeService;
*
* @author ruoyi
*/
@Api(tags = "管理端 字段值管理")
@RestController
@RequestMapping("/system/dict/data")
public class SysDictDataController extends BaseController
@@ -44,7 +41,6 @@ public class SysDictDataController extends BaseController
@Autowired
private ISysDictTypeService dictTypeService;
@ApiOperation("获取字典值")
@PreAuthorize("@ss.hasPermi('system:dict:list')")
@GetMapping("/list")
public TableDataInfo list(SysDictData dictData)
@@ -91,7 +87,6 @@ public class SysDictDataController extends BaseController
/**
* 新增字典类型
*/
@ApiOperation("新增字典值")
@PreAuthorize("@ss.hasPermi('system:dict:add')")
@Log(title = "字典数据", businessType = BusinessType.INSERT)
@PostMapping
@@ -104,7 +99,6 @@ public class SysDictDataController extends BaseController
/**
* 修改保存字典类型
*/
@ApiOperation("修改字典值")
@PreAuthorize("@ss.hasPermi('system:dict:edit')")
@Log(title = "字典数据", businessType = BusinessType.UPDATE)
@PutMapping
@@ -3,7 +3,6 @@ package com.ruoyi.web.controller.system;
import java.util.List;
import java.util.Set;
import io.swagger.annotations.Api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -24,7 +23,6 @@ import com.ruoyi.system.service.ISysMenuService;
*
* @author ruoyi
*/
@Api(tags = "管理端认证api")
@RestController
public class SysLoginController
{
@@ -11,11 +11,10 @@ import java.util.List;
import java.util.Map;
/**
* swagger 用户测试方法
* 用户测试方法(若依自带示例,未对外使用)
*
* @author ruoyi
*/
//@Api(tags = "用户信息管理")
@RestController
@RequestMapping("/test/user")
public class TestController extends BaseController
@@ -26,7 +25,6 @@ public class TestController extends BaseController
users.put(2, new UserEntity(2, "ry", "admin123", "15666666666"));
}
// @ApiOperation("获取用户列表")
@GetMapping("/list")
public R<List<UserEntity>> userList()
{
@@ -34,8 +32,6 @@ public class TestController extends BaseController
return R.ok(userList);
}
// @ApiOperation("获取用户详细")
// @ApiImplicitParam(name = "userId", value = "用户ID", required = true, dataType = "int", paramType = "path", dataTypeClass = Integer.class)
@GetMapping("/{userId}")
public R<UserEntity> getUser(@PathVariable Integer userId)
{
@@ -49,13 +45,6 @@ public class TestController extends BaseController
}
}
// @ApiOperation("新增用户")
// @ApiImplicitParams({
// @ApiImplicitParam(name = "userId", value = "用户id", dataType = "Integer", dataTypeClass = Integer.class),
// @ApiImplicitParam(name = "username", value = "用户名称", dataType = "String", dataTypeClass = String.class),
// @ApiImplicitParam(name = "password", value = "用户密码", dataType = "String", dataTypeClass = String.class),
// @ApiImplicitParam(name = "mobile", value = "用户手机", dataType = "String", dataTypeClass = String.class)
// })
@PostMapping("/save")
public R<String> save(UserEntity user)
{
@@ -67,7 +56,6 @@ public class TestController extends BaseController
return R.ok();
}
// @ApiOperation("更新用户")
@PutMapping("/update")
public R<String> update(@RequestBody UserEntity user)
{
@@ -84,8 +72,6 @@ public class TestController extends BaseController
return R.ok();
}
// @ApiOperation("删除用户信息")
// @ApiImplicitParam(name = "userId", value = "用户ID", required = true, dataType = "int", paramType = "path", dataTypeClass = Integer.class)
@DeleteMapping("/{userId}")
public R<String> delete(@PathVariable Integer userId)
{
@@ -101,19 +87,14 @@ public class TestController extends BaseController
}
}
//@ApiModel(value = "UserEntity", description = "用户实体")
class UserEntity
{
// @ApiModelProperty("用户ID")
private Integer userId;
// @ApiModelProperty("用户名称")
private String username;
// @ApiModelProperty("用户密码")
private String password;
// @ApiModelProperty("用户手机")
private String mobile;
public UserEntity()
@@ -1,170 +0,0 @@
package com.ruoyi.web.core.config;
import io.swagger.annotations.ApiOperation;
import io.swagger.models.auth.In;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiKey;
import springfox.documentation.service.AuthorizationScope;
import springfox.documentation.service.SecurityReference;
import springfox.documentation.service.SecurityScheme;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class SwaggerConfig {
/**
* 后台接口分组
*/
// @Bean
public Docket adminDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
// 扫描注解
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.admin.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
// 扫描所有.apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build()
.groupName("admin")
// 设置安全模式,访问token
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 用户业务接口分组
*/
@Bean
public Docket userDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.user.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("user")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 第三方业务接口分组
*/
@Bean
public Docket thirdPartyDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.thirdparty"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("thirdParty")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 玩法业务接口分组
*/
@Bean
public Docket playingMethodDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.playingmethod.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("playingMethod")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 糖果玩法业务接口分组
*/
@Bean
public Docket gameSugarMethodDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.game.sugar.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("gameSugarMethod")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 任务业务接口分组
*/
@Bean
public Docket taskDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.task.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("task")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 推广后台接口分组
*/
@Bean
public Docket promoDocket() {
return new Docket(DocumentationType.OAS_30)
.select()
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.promo.controller"))
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build()
.groupName("promo")
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}
/**
* 安全模式,这里指定token通过Authorization请求头传递
*/
private List<SecurityScheme> securitySchemes() {
List<SecurityScheme> apiKeyList = new ArrayList<SecurityScheme>();
apiKeyList.add(new ApiKey("Authorization", "Authorization", In.HEADER.toValue()));
return apiKeyList;
}
/**
* 安全上下文
*/
private List<SecurityContext> securityContexts() {
List<SecurityContext> securityContexts = new ArrayList<>();
securityContexts.add(SecurityContext.builder()
.securityReferences(defaultAuth())
.operationSelector(o -> o.requestMappingPattern().matches("/.*"))
.build());
return securityContexts;
}
/**
* 默认的安全引用
*/
private List<SecurityReference> defaultAuth() {
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
List<SecurityReference> securityReferences = new ArrayList<>();
securityReferences.add(new SecurityReference("Authorization", authorizationScopes));
return securityReferences;
}
}
@@ -70,12 +70,12 @@ spring:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
# 白名单:只允许本机访问(原来留空 = 任意 IP 都能打开控制台)
allow: 127.0.0.1
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
# 控制台管理用户名和密码(河南单独一套,勿用常州/默认值)
login-username: druidhn
login-password: Hn@Druid#7t4Wq9
filter:
stat:
enabled: true
@@ -202,6 +202,12 @@ alipay:
apispace:
carriersAuthToken: rcpg373exsddqb8p24nixfo6zgdzw35c
carriersAuthUrl: https://eolink.o.apispace.com/carriers/carriers-auth
# 推广后台配置
promo:
dev-login:
# 开发用账号密码登录(POST /promo/devLogin):dev 环境打开,配合前端 pnpm run dev 的登录页入口
enabled: true
#
#hipay:
# payUrl: https://xahnob7yqke3.hipay.one/pay/pay/createpay
@@ -69,13 +69,15 @@ spring:
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
# 生产关闭 Druid 控制台:/druid/** 是匿名放行的,开着等于把全部 SQL 语句、URI 统计、连接信息挂到公网
# 需要临时排障时改成 true,并保持 allow 白名单 + 非默认口令,用完立刻关回
enabled: false
# 白名单:只允许本机访问(原来留空 = 任意 IP)
allow: 127.0.0.1
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
# 控制台管理用户名和密码(河南单独一套,临时开启时使用,勿改回默认值)
login-username: druidhn
login-password: Hn@Druid#7t4Wq9
filter:
stat:
enabled: true
@@ -94,6 +94,14 @@ aes:
# AES 向量(16位,与前端 AES_IV 保持一致)
iv: ruoyi@aes@iv2024
# 推广后台配置
promo:
dev-login:
# 开发用账号密码登录(POST /promo/devLogin)开关。
# 仅本地/测试联调使用:置 true 后可用「账号 + 密码」登录,绕过短信验证码。
# 默认 false;生产 profile 不覆盖该项,即天然关闭。
enabled: false
# 防止XSS攻击
xss:
# 过滤开关
@@ -0,0 +1,125 @@
package com.ruoyi.framework.config;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.entity.UserData;
import com.ruoyi.common.core.domain.model.LoginUser;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import java.util.Collections;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 三端 token 隔离规则验证(不启动 Spring,直接测 SecurityConfig 里的三个判定方法):
* <ul>
* <li>后台面 /admin|/system|/monitor|/tool|/test → 只认后台 sys_user 账号签发的 token</li>
* <li>推广面 /promo/** → 只认推广后台签发的 token(loginScope = promo)</li>
* <li>玩家面 /api/** → 只认玩家端签发的 token(loginScope = player)</li>
* </ul>
* 关键点:主播(01)/招商(04)/卡商(05) 是同一张 tt_user 表、两端都能登录,LoginUser 长得一模一样,
* 只有登录端标记能区分,所以「主播的玩家端 token」不能进推广接口,「主播的推广 token」也不能进玩家接口。
*/
public class SecurityRulesTest {
private final SecurityConfig config = new SecurityConfig();
/** 后台账号登录(sys_user) */
private static LoginUser adminLogin() {
LoginUser loginUser = new LoginUser();
loginUser.setUser(new SysUser());
loginUser.setLoginScope(LoginUser.SCOPE_ADMIN);
return loginUser;
}
/** 推广后台登录(超管 99 / 主播 01 / 招商 04 / 卡商 05) */
private static LoginUser promoLogin(String userType) {
LoginUser loginUser = new LoginUser();
loginUser.setUserData(UserData.builder().userType(userType).build());
loginUser.setLoginScope(LoginUser.SCOPE_PROMO);
return loginUser;
}
/** 玩家端登录(通用用户 00 / 主播 01 / 招商 04 / 卡商 05) */
private static LoginUser playerLogin(String userType) {
LoginUser loginUser = new LoginUser();
loginUser.setUserData(UserData.builder().userType(userType).build());
loginUser.setLoginScope(LoginUser.SCOPE_PLAYER);
return loginUser;
}
/** 本次改造前签发的老 token:没有登录端标记 */
private static LoginUser legacyLogin(String userType) {
LoginUser loginUser = new LoginUser();
loginUser.setUserData(UserData.builder().userType(userType).build());
return loginUser;
}
/** 已认证的令牌:与 JwtAuthenticationTokenFilter 一致,用三参构造(两参构造是"未认证"状态) */
private static Supplier<Authentication> auth(LoginUser principal) {
return principal == null ? () -> null
: () -> new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList());
}
/** 未认证的令牌:两参构造,isAuthenticated() 为 false */
private static Supplier<Authentication> unauthenticated(LoginUser principal) {
return () -> new UsernamePasswordAuthenticationToken(principal, null);
}
private boolean adminAllowed(LoginUser principal) {
return config.checkAdminUser(auth(principal), null).isGranted();
}
private boolean promoAllowed(LoginUser principal) {
return config.checkPromoUser(auth(principal), null).isGranted();
}
private boolean playerAllowed(LoginUser principal) {
return config.checkPlayerUser(auth(principal), null).isGranted();
}
@Test
public void adminEndOnlyAcceptsBackendAccount() {
assertTrue(adminAllowed(adminLogin()), "后台账号应能访问后台接口");
assertFalse(adminAllowed(promoLogin("99")), "推广超管 token 不能访问后台接口");
assertFalse(adminAllowed(promoLogin("01")), "推广 token 不能访问后台接口");
assertFalse(adminAllowed(playerLogin("00")), "玩家 token 不能访问后台接口");
assertFalse(adminAllowed(playerLogin("01")), "主播的玩家端 token 不能访问后台接口");
assertFalse(adminAllowed(null), "未登录不能访问后台接口");
}
@Test
public void promoEndOnlyAcceptsPromoToken() {
for (String userType : new String[]{"99", "04", "01", "05"}) {
assertTrue(promoAllowed(promoLogin(userType)), "推广后台 token(" + userType + ")应能访问推广接口");
}
assertFalse(promoAllowed(playerLogin("01")), "主播的玩家端 token 不能访问推广接口");
assertFalse(promoAllowed(playerLogin("00")), "玩家 token 不能访问推广接口");
assertFalse(promoAllowed(adminLogin()), "后台 token 不能访问推广接口");
assertFalse(promoAllowed(legacyLogin("01")), "本次改造前的老 token 没有端标记,必须重新登录");
assertFalse(promoAllowed(null), "未登录不能访问推广接口");
}
@Test
public void playerEndOnlyAcceptsPlayerToken() {
for (String userType : new String[]{"00", "01", "04", "05"}) {
assertTrue(playerAllowed(playerLogin(userType)), "玩家端 token(" + userType + ")应能访问 /api/**");
}
assertFalse(playerAllowed(promoLogin("01")), "主播的推广 token 不能访问 /api/**");
assertFalse(playerAllowed(promoLogin("99")), "推广超管 token 不能访问 /api/**");
assertFalse(playerAllowed(adminLogin()), "后台 token 不能访问 /api/**");
assertFalse(playerAllowed(legacyLogin("00")), "本次改造前的老 token 没有端标记,必须重新登录");
assertFalse(playerAllowed(null), "未登录不能访问 /api/**");
}
@Test
public void unauthenticatedTokenIsDeniedEverywhere() {
Supplier<Authentication> notAuthenticated = unauthenticated(adminLogin());
assertFalse(config.checkAdminUser(notAuthenticated, null).isGranted());
assertFalse(config.checkPromoUser(notAuthenticated, null).isGranted());
assertFalse(config.checkPlayerUser(notAuthenticated, null).isGranted());
}
}
-7
View File
@@ -192,13 +192,6 @@
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Swagger3依赖 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>${swagger.version}</version>
</dependency>
</dependencies>
</project>
@@ -22,6 +22,15 @@ public class LoginUser implements UserDetails
{
private static final long serialVersionUID = 1L;
/** 登录端:游戏管理后台(sys_user 账号) */
public static final String SCOPE_ADMIN = "admin";
/** 登录端:推广后台(promo_admin 超管 / 主播 01 / 招商 04 / 卡商 05) */
public static final String SCOPE_PROMO = "promo";
/** 登录端:玩家端(H5 / App,api_ 前缀登录) */
public static final String SCOPE_PLAYER = "player";
/**
* 用户ID
*/
@@ -79,6 +88,13 @@ public class LoginUser implements UserDetails
private UserData userData;
/**
* 登录端标记(admin / promo / player):同一个 tt_user 既能登录玩家端也能登录推广后台,
* 两端签发的 token 必须互不通用,鉴权时按这个标记区分(见 SecurityConfig 的三条规则)。
* 签发时在 ApiUserDetailsServiceImpl 按账号前缀写入,随会话缓存在 Redis 里。
*/
private String loginScope;
public LoginUser(Long userId, UserData userData) {
this.userId = userId;
this.userData = userData;
@@ -30,7 +30,9 @@ public class MimeTypeUtils
// 图片
"bmp", "gif", "jpg", "jpeg", "png",
// word excel powerpoint
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
// 2026-09-23 去掉 html/htm:上传目录 /profile/** 是匿名可访问的静态目录,
// 放行 html/htm 等于允许任意登录用户往自己域名下丢页面(.html 被当静态资源打开就是 XSS/钓鱼面)
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
// 压缩文件
"rar", "zip", "gz", "bz2",
// 视频格式
@@ -5,14 +5,12 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
@@ -54,11 +52,6 @@ public class ResourcesConfig implements WebMvcConfigurer
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + RuoYiConfig.getProfile() + "/upload/");
/** swagger配置 */
registry.addResourceHandler("/swagger-ui/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/")
.setCacheControl(CacheControl.maxAge(5, TimeUnit.HOURS).cachePublic());
}
/**
@@ -36,6 +36,7 @@ import java.util.function.Supplier;
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {
@Qualifier("apiUserDetailsServiceImpl")
@Autowired
private UserDetailsService apiUserDetailsService;
@@ -101,13 +102,21 @@ public class SecurityConfig {
// 静态资源
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html",
"/**/*.css", "/**/*.js", "/profile/**", "/uploads/**", "/upload/**").permitAll()
// Swagger & Druid
.requestMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// Druid 监控台:生产已在 application-pro.yml 关闭 statViewServlet(enabled=false)
// 并限制 allow=127.0.0.1 + 非默认口令;这里保留放行是为了本地开发能打开控制台
.requestMatchers("/druid/**").permitAll()
// WebSocket
.requestMatchers("/api/websocket/skins").permitAll()
.requestMatchers("/ws/**").permitAll()
// 后台接口:只认「后台 sys_user 账号」的 token(详见 checkAdminUser)
.requestMatchers("/admin/**").access(this::checkAdminUser)
// 后台接口(游戏管理后台 + 框架自带系统管理/监控/代码生成/测试接口):
// 只认「后台 sys_user 账号」的 token,推广 / 玩家 token 一律 403(详见 checkAdminUser)
// /common/** 是文件上传下载(只有后台 ruoyi-ui 在用),不放进三端前缀里会对所有端开放
.requestMatchers("/admin/**", "/system/**", "/monitor/**", "/tool/**", "/test/**", "/common/**")
.access(this::checkAdminUser)
// 推广接口:只认推广身份的 token,玩家(00)与后台账号一律 403(详见 checkPromoUser)
.requestMatchers("/promo/**").access(this::checkPromoUser)
// 玩家接口:只认玩家端身份的 token,推广超管与后台账号一律 403(详见 checkPlayerUser)
.requestMatchers("/api/**").access(this::checkPlayerUser)
// 其他所有请求都需要认证
.anyRequest().authenticated();
})
@@ -138,8 +147,8 @@ public class SecurityConfig {
* → LoginUser.userData 是 UserData,LoginUser.user 为 null → 拒绝(403)。
* 这样即使某个后台 Controller 忘了标 @PreAuthorize,也不会被玩家 token 调用。
*/
private AuthorizationDecision checkAdminUser(Supplier<? extends Authentication> authentication,
RequestAuthorizationContext context) {
AuthorizationDecision checkAdminUser(Supplier<? extends Authentication> authentication,
RequestAuthorizationContext context) {
Authentication auth = authentication.get();
if (auth == null || !auth.isAuthenticated()) {
return new AuthorizationDecision(false);
@@ -150,6 +159,48 @@ public class SecurityConfig {
}
/**
* /promo/** 的访问判定:只允许「推广后台签发」的 token(loginScope = promo)。
*
* 同一个 tt_user(主播 01 / 招商 04 / 卡商 05)既能登录玩家端也能登录推广后台,
* 两端拿到的 LoginUser(userId + userData)完全一样,光看 userType 区分不了,
* 所以这里必须按登录端标记卡:玩家端签发的 token 即使账号是主播,也进不来推广接口。
*/
AuthorizationDecision checkPromoUser(Supplier<? extends Authentication> authentication,
RequestAuthorizationContext context) {
LoginUser loginUser = loginUserOf(authentication);
if (loginUser == null || loginUser.getUserData() == null) {
return new AuthorizationDecision(false);
}
return new AuthorizationDecision(LoginUser.SCOPE_PROMO.equals(loginUser.getLoginScope()));
}
/**
* /api/** 的访问判定:只允许「玩家端签发」的 token(loginScope = player)。
*
* 推广后台 token(哪怕底层账号就是 tt_user)一律拒绝;后台账号(sys_user)没有 userData,也进不来。
*/
AuthorizationDecision checkPlayerUser(Supplier<? extends Authentication> authentication,
RequestAuthorizationContext context) {
LoginUser loginUser = loginUserOf(authentication);
if (loginUser == null || loginUser.getUserData() == null) {
return new AuthorizationDecision(false);
}
return new AuthorizationDecision(LoginUser.SCOPE_PLAYER.equals(loginUser.getLoginScope()));
}
/**
* 取出已认证的 LoginUser,其余情况(匿名 / 未认证 / 非本框架主体)返回 null
*/
private LoginUser loginUserOf(Supplier<? extends Authentication> authentication) {
Authentication auth = authentication.get();
if (auth == null || !auth.isAuthenticated()) {
return null;
}
return auth.getPrincipal() instanceof LoginUser loginUser ? loginUser : null;
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
@@ -61,6 +61,8 @@ public class UserDetailsServiceImpl implements UserDetailsService
public UserDetails createLoginUser(SysUser user)
{
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
loginUser.setLoginScope(LoginUser.SCOPE_ADMIN);
return loginUser;
}
}
@@ -45,6 +45,11 @@ public class WsTokenService {
if (loginUser == null) {
return null;
}
// 只认玩家端 token:推广/后台 token 即使 userId 撞上某个 tt_user 的 id 也不能连玩家 WS
// (与 SecurityConfig.checkPlayerUser 口径一致;本次改造前签发的老 token 无端标记,同样要重新登录)
if (!LoginUser.SCOPE_PLAYER.equals(loginUser.getLoginScope())) {
return null;
}
return loginUser.getUserId().equals(expectedUserId) ? uuid : null;
} catch (Exception e) {
log.warn("WS Token 校验异常, userId={}", expectedUserId, e);
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 后台管理系统
VUE_APP_TITLE = 凯科佰娱
# 开发环境配置
ENV = 'development'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 常州项目
VUE_APP_TITLE = 凯科佰娱
# 生产环境配置
ENV = 'production'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 后台管理系统
VUE_APP_TITLE = 凯科佰娱
NODE_ENV = production
+62
View File
@@ -0,0 +1,62 @@
import request from '@/utils/request'
// 箱子列表(分页;boxType 只有一个 normal,留参数只为兼容后台筛选框):MahjongAdminController.boxList
// 返回 { rows:[{...各身份 targetRtpByAudience/actualRtpByAudience/totalRoundsByAudience}], total }
export function getBoxList(params) {
return request({ url: '/admin/mahjong/box/list', method: 'get', params })
}
// 新增/编辑箱子:MahjongAdminController.boxSave(price = bet,本游戏无投注倍数、costMultiplier ≡ 1)
export function saveBox(data) {
return request({ url: '/admin/mahjong/box/save', method: 'post', data })
}
// 某箱某身份的倍率桶分布:MahjongAdminController.boxBuckets
// 返回 { list:[{bucketId,count,used,remaining,convergeCount,...}], audience, fixedRounds, convergeRounds, poolExpect }
export function getBoxBuckets(boxId, audience) {
return request({ url: `/admin/mahjong/box/buckets/${boxId}`, method: 'get', params: { audience }})
}
// 保存某箱某身份倍率桶分布(items 为 [{bucketId,count}]):MahjongAdminController.boxBucketsSave
export function saveBoxBuckets(data) {
return request({ url: '/admin/mahjong/box/buckets/save', method: 'post', data })
}
// 按目标 RTP 重算某箱某身份分布:MahjongAdminController.calcDistribution
export function calcDistribution(data) {
return request({ url: '/admin/mahjong/box/buckets/calc', method: 'post', data })
}
// 模拟 payoutCap 约束下的预估实际 RTP:MahjongAdminController.simulateRtp
export function simulateRtp(data) {
return request({ url: '/admin/mahjong/box/buckets/simulate-rtp', method: 'post', data })
}
// ==================== 分布模板(按身份) ====================
// 模板列表:MahjongAdminController.templateList
export function getBoxTemplates(boxId, audience) {
return request({ url: `/admin/mahjong/box/templates/${boxId}`, method: 'get', params: { audience }})
}
// 保存为模板:MahjongAdminController.templateSave
export function saveBoxTemplate(data) {
return request({ url: '/admin/mahjong/box/template/save', method: 'post', data })
}
// 删除模板:MahjongAdminController.templateDelete
export function deleteBoxTemplate(id) {
return request({ url: `/admin/mahjong/box/template/${id}`, method: 'delete' })
}
// 应用选中模板:MahjongAdminController.templateApply
export function applyBoxTemplate(data) {
return request({ url: '/admin/mahjong/box/template/apply', method: 'post', data })
}
// 一键重算所有箱子 × 所有身份(按各箱现有配置):MahjongAdminController.recalcAll
export function recalcAll() {
return request({ url: '/admin/mahjong/box/recalc_all', method: 'post' })
}
// 重算分布:按参数批量重算(boxType 空=全部;audience 空=全部身份):MahjongAdminController.recalcBatch
export function recalcBatch(data) {
return request({ url: '/admin/mahjong/box/recalcBatch', method: 'post', data })
}
@@ -0,0 +1,6 @@
import request from '@/utils/request'
// 倍率桶定义列表(16 档 0x~500x):MahjongAdminController.bucketList
export function getBucketList() {
return request({ url: '/admin/mahjong/bucket/list', method: 'get' })
}
@@ -0,0 +1,11 @@
import request from '@/utils/request'
// 每日统计列表(分页 + 按日期区间):MahjongAdminController.winList
export function getWinList(params) {
return request({ url: '/admin/mahjong/win/list', method: 'get', params })
}
// 汇总(按日期区间):MahjongAdminController.winSummary
export function getWinSummary(params) {
return request({ url: '/admin/mahjong/win/summary', method: 'get', params })
}
@@ -0,0 +1,11 @@
import request from '@/utils/request'
// 返奖饰品列表(含返奖区间 winLow/winHigh、品质图 levelImg):MahjongAdminController.prizeList
export function prizeList() {
return request({ url: '/admin/mahjong/prize/list', method: 'get' })
}
// 新增/编辑返奖饰品(同步写 game_mahjong_prize_range):MahjongAdminController.prizeSave
export function prizeSave(data) {
return request({ url: '/admin/mahjong/prize/save', method: 'post', data })
}
@@ -0,0 +1,7 @@
import request from '@/utils/request'
// 玩家记录列表(分页 + 按玩家ID/身份筛选;audience=common/anchor/white,空=全部身份)
// MahjongAdminController.recordList(行内回填 audience)
export function getRecordList(params) {
return request({ url: '/admin/mahjong/record/list', method: 'get', params })
}
@@ -0,0 +1,29 @@
import request from '@/utils/request'
// 奖池配置快照(总池 + 各箱池),按身份 audience=common/anchor/white
// MahjongAdminController.rewardPoolConfig
export function getPoolConfig(params) {
return request({ url: '/admin/mahjong/rewardPool/config', method: 'get', params })
}
// 逐箱注水/抽水(type=1 注水 / 2 抽水,含 audience、remark):MahjongAdminController.rewardPoolAdjust
export function adjustPool(data) {
return request({ url: '/admin/mahjong/rewardPool/adjust', method: 'post', data })
}
// 批量按一轮花费比例注水/抽水:{ audience, type, ratio, remark }
// 本游戏无投注倍数,每箱基数 = bet × 总回合数;MahjongAdminController.rewardPoolAdjustBatch
export function adjustPoolBatch(data) {
return request({ url: '/admin/mahjong/rewardPool/adjustBatch', method: 'post', data })
}
// 重置人工调整:该身份所有箱子注水/抽水累计归零,只保留真实水位(下注−返奖):{ audience, remark }
// MahjongAdminController.rewardPoolAdjustReset
export function adjustPoolReset(data) {
return request({ url: '/admin/mahjong/rewardPool/adjustReset', method: 'post', data })
}
// 奖池调整历史(分页,params 含 audience):MahjongAdminController.rewardPoolAdjustLog
export function getPoolLogs(params) {
return request({ url: '/admin/mahjong/rewardPool/adjustLog', method: 'get', params })
}
@@ -0,0 +1,6 @@
import request from '@/utils/request'
// 预生成回合列表(分页 + 按回合ID/箱子筛选):MahjongAdminController.roundList
export function getRoundList(params) {
return request({ url: '/admin/mahjong/round/list', method: 'get', params })
}
@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 当前水位快照 + 波段表策略 + 本轮生效的随机数区间
export function getWatermarkInfo() {
return request({
url: '/admin/upgradeWatermark/info',
method: 'get'
})
}
// 保存水位波段表策略(后端带校验:区间合法 + 水位越高随机数越小)
export function saveWatermarkSetting(data) {
return request({
url: '/admin/upgradeWatermark/setting',
method: 'post',
data: data
})
}
// 注水 / 抽水(type 1注水 2抽水;调整原因必填)
export function adjustWatermark(data) {
return request({
url: '/admin/upgradeWatermark/adjust',
method: 'post',
data: data
})
}
// 重置人工调整(插入抵消日志,SUM(delta) 归零,历史保留)
export function resetWatermarkAdjust(data) {
return request({
url: '/admin/upgradeWatermark/adjustReset',
method: 'post',
data: data
})
}
// 调整历史(分页)
export function getWatermarkLogs(params) {
return request({
url: '/admin/upgradeWatermark/adjustLog',
method: 'get',
params: params
})
}
// 按历史记录全量重算累计下注/返奖(纠正 Redis 计数漂移)
export function recalcWatermark() {
return request({
url: '/admin/upgradeWatermark/recalc',
method: 'post'
})
}
@@ -47,6 +47,31 @@ $cc-shadow-sm: 0 2px 8px rgba(139, 115, 85, 0.10);
$cc-shadow-md: 0 6px 18px rgba(139, 115, 85, 0.13);
$cc-shadow-lg: 0 12px 32px rgba(139, 115, 85, 0.17);
/* ---- 组件变量槽位(Element 需要的多级描边 / 浅底)----
这些是「中间色」,不承担品牌语义,但必须收在这里:
否则同一层底色会在不同文件里写成两个近似值
(如 #f6efe3 与 $cc-linen #f3ece0 肉眼几乎分不出,却算成了两套色)。 */
$cc-border-1: #d8c3b0; // 主描边:输入框、表格线
$cc-border-2: #e2d3c2; // 次描边:轻分隔、禁用描边
$cc-border-3: #eadfd0; // 细描边(比 $cc-linen-2 更接近白)
$cc-tint-1: #f0e8dc; // 最浅底:凹槽
$cc-tint-2: #f6efe3; // 浅底:标签 / 区块
$cc-tint-3: #fbf5ea; // 悬停底:整行 hover、标签 hover
$cc-terra-deep: #854c2a; // 陶土加深:主色悬停、激活标签渐变末端(白字 6.85:1)
/* ---- 图标色相:一级菜单图标块 / 统计卡图标共用同一套 4 色 ----
陶土、草绿直接复用主色;雏菊金 / 玫瑰粉是「图标专用」的浅档
($cc-daisy-deep 太暗、$cc-rose 太艳,塞进 30px 小方块会发黑/发跳),
只在图标上用,不再往别处扩散。
★ 这两档同时被 .vue 里的内联色引用(scoped style 拿不到 sass 变量),
改动时必须同步 .workbuddy/audit-palette.cjs 的白名单。 */
$cc-hue-daisy: #a8791f;
$cc-hue-rose: #b57474;
$cc-icon-hues: ($cc-terra, $cc-grass-deep, $cc-hue-daisy, $cc-hue-rose);
/* ---- 状态淡底 ---- */
$cc-blossom-tint: #f7e9e6; // 花粉淡底:中奖格浅红(别在 .vue 里再写一个近似粉)
/* ---- 字体 ----
Cottagecore 要求衬线,但后台是拿表格干活的:13px 宋体在密集表格里可读性明显下降。
因此只在「标题类」(≥ 16px 的页面标题 / 弹窗标题)用衬线,
+13 -5
View File
@@ -28,11 +28,19 @@
}
}
.fixed-width {
.el-button--mini {
padding: 7px 10px;
width: 60px;
}
/* ★ 这里原本是 RuoYi 的遗留规则,已删除:
.fixed-width .el-button--mini { padding: 7px 10px; width: 60px }
.fixed-width 是「操作列」专用类。强设 60px 后,border-box 的内容盒只剩 48px,
而本项目的操作按钮普遍是「图标 + 2~4 字」(如 el-icon-date +「发放饰品」≈ 70px)
⇒ 文字溢出按钮、盖到相邻按钮上,整列还会折行。
按钮宽度改回按内容撑开;文字按钮的左右留白由 skin.scss 的
`.el-table .el-button--text { padding: 4px 6px }` 接管。
★ 操作列若按钮 ≥3 个,请在模板上显式给 `width`,否则自动列宽会挤压换行。 */
/* 「更多」是 el-dropdown 包着 el-button,不是 .el-button 的兄弟节点,
拿不到 Element 的 `.el-button + .el-button { margin-left: 10px }`,会贴住前一个按钮 */
.el-table .cell .el-button + .el-dropdown {
margin-left: 10px;
}
.status-col {
@@ -28,16 +28,29 @@ $--color-text-secondary: $cc-ink-3;
$--color-text-placeholder: $cc-ink-4;
/* 描边:花粉细边,禁止纯黑硬线 */
$--border-color-base: #d8c3b0;
$--border-color-light: #e2d3c2;
$--border-color-lighter: #eadfd0;
$--border-color-extra-light: #f0e8dc;
$--border-color-base: $cc-border-1;
$--border-color-light: $cc-border-2;
$--border-color-lighter: $cc-border-3;
$--border-color-extra-light: $cc-tint-1;
/* 底色 */
/* ★ 白:Cottagecore 的「白」是暖白卡片色 #fffdfa,不是 #fff。
Element 把 $--color-white 当成**所有白色面的根**——输入框、下拉面板、
气泡、对话框、分页底、单选/复选底,以及各 light 变体的 mix 基色。
改这一处,比逐个选择器打补丁稳,也不会漏。 */
$--color-white: $cc-cream-hi;
$--background-color-base: $cc-linen;
$--disabled-fill-base: $cc-linen;
$--disabled-color-base: $cc-ink-4;
$--disabled-border-base: #e2d3c2;
$--disabled-border-base: $cc-border-2;
/* Element 写死的两处冷蓝(是 !default 变量,可以覆盖)——
① 默认/信息类 toast 的底:原 #edf2fc 淡蓝,RuoYi 里 this.$message('…')
不带 type 时就会走它,是页面上会闪的蓝块;
② 禁用勾选框填充:原 #edf2fc 同样偏蓝。 */
$--message-background-color: mix($cc-cream, $cc-ash, 92%);
$--checkbox-disabled-input-fill: $cc-linen-2;
/* 圆润:Cottagecore 的圆角一律大于 Element 默认的 4px */
$--border-radius-base: 12px;
@@ -50,11 +63,11 @@ $--box-shadow-dark: $cc-shadow-lg;
$--box-shadow-light: $cc-shadow-sm;
/* 表格:柔和暖线,禁用 #dfe6ec 冷灰 */
$--table-border: 1px solid #eadfd0;
$--table-border-color: #eadfd0;
$--table-border: 1px solid $cc-border-3;
$--table-border-color: $cc-border-3;
$--table-header-background-color: $cc-linen;
$--table-header-font-color: $cc-ink-3;
$--table-row-hover-background-color: #fbf5ea;
$--table-row-hover-background-color: $cc-tint-3;
$--table-current-row-background-color: $cc-linen;
$--table-font-color: $cc-ink-2;
+4 -2
View File
@@ -132,7 +132,7 @@ aside {
// 否则「页卡 > 区块卡 > 小卡」三层卡片套在一起,画面很碎(规范也禁止卡片嵌套)。
.app-container {
padding: 24px;
background: #fdfaf5;
background: $cc-cream-hi;
border: 0;
border-radius: 20px;
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.07);
@@ -145,7 +145,7 @@ aside {
position: relative;
margin: 6px 10px 6px 0;
padding: 10px 24px 24px;
background: #fdfaf5;
background: $cc-cream-hi;
border: 0;
border-radius: 20px;
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.07);
@@ -255,3 +255,5 @@ aside {
// 皮肤层放最后,保证覆盖 Element UI 默认样式
@import './skin.scss';
// 开关(状态列)已改为自研组件 components/CapsuleSwitch(自带 scoped 样式),
// 不再需要历史上那种全局 ::v-deep 覆盖 el-switch 内部结构的写法。
+4 -2
View File
@@ -7,8 +7,10 @@
}
@mixin scrollBar {
/* 原为 Element 冷蓝灰 #d3dce6 / #99a9bf。当前项目未 include 本 mixin
(侧栏走 .el-scrollbar__thumb),顺手改成暖色,避免日后启用时又漏一版冷色。 */
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
background: #ece2d3;
}
&::-webkit-scrollbar {
@@ -16,7 +18,7 @@
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
background: #8b755a;
border-radius: 20px;
}
}
+43 -55
View File
@@ -1,8 +1,13 @@
/**
* 通用css样式布局处理
* Copyright (c) 2019 ruoyi
*
* ⚠️ 本文件由 main.js 在 index.scss(含皮肤层 skin.scss)**之后**引入,
* 权重相同时它会赢。改这里的任何视觉规则前,先确认不是在和皮肤层打架。
*/
@import './cottagecore.scss';
/** 基础通用 **/
.pt5 {
padding-top: 5px;
@@ -79,9 +84,12 @@
.el-table__header-wrapper, .el-table__fixed-header-wrapper {
th {
word-break: break-word;
background-color: #f8f8f9;
color: #515a6e;
height: 40px;
/* 原为冷灰 #f8f8f9 / 石板蓝 #515a6e —— 权重低于皮肤层的
.el-table th.el-table__cell,本来就没生效;这里直接对齐皮肤,
免得将来权重一变又漏出冷灰表头。 */
background-color: $cc-linen;
color: $cc-ink-3;
height: 42px;
font-size: 13px;
}
}
@@ -93,35 +101,32 @@
}
/** 表单布局 **/
/* 冷蓝 #6379bb + 冷灰 #ddd 的分区标题(角色分配 / 代码生成两页在用)→ 暖色板 */
.form-header {
font-size:15px;
color:#6379bb;
border-bottom:1px solid #ddd;
color:$cc-terra;
border-bottom:1px solid $cc-line-2;
margin:8px 10px 25px 10px;
padding-bottom:5px
}
/** 表格布局 **/
.pagination-container {
position: relative;
height: 25px;
margin-bottom: 10px;
margin-top: 15px;
padding: 10px 20px !important;
}
/* .pagination-container 的旧定义(position:relative / height:25px / margin 15px 10px /
padding:10px 20px !important)已删除:
它是 AdminLTE 时代的紧凑分页,和皮肤层打架,而且那条 !important 让皮肤层的
padding 永远失效;height:25px 又把 32px 高的页码按钮挤出去,看起来就是表格下方
多了一条空白白带。分页样式现在只在 assets/styles/skin.scss 里定义一处。 */
/* tree border */
.tree-border {
margin-top: 5px;
border: 1px solid #e5e6e7;
background: #FFFFFF none;
border-radius:4px;
border: 1px solid $cc-line;
background: $cc-cream-hi none;
border-radius: 12px;
}
.pagination-container .el-pagination {
right: 0;
position: absolute;
}
/* 原来的 .pagination-container .el-pagination { right:0; position:absolute } 已删除。
绝对定位让分页脱离文档流(外层白带高度算不准),右对齐改由皮肤层 text-align 负责。 */
@media ( max-width : 768px) {
.pagination-container .el-pagination > .el-pagination__jump {
@@ -132,11 +137,9 @@
}
}
.el-table .fixed-width .el-button--mini {
padding-left: 0;
padding-right: 0;
width: inherit;
}
/* 操作列的 .el-button--mini 在 old 版里被抹掉左右内边距(padding:0 + width:inherit),
文字按钮会挤成「修改删除」连在一起,和皮肤层 .el-table .el-button--text 的 4px 6px 打架。
删掉这条,按钮间距交回皮肤层。 */
/** 表格更多操作下拉样式 */
.el-table .el-dropdown-link,.el-table .el-dropdown-selfdefine {
@@ -166,8 +169,8 @@
}
.list-group-item {
border-bottom: 1px solid #e7eaec;
border-top: 1px solid #e7eaec;
border-bottom: 1px solid $cc-line-2;
border-top: 1px solid $cc-line-2;
margin-bottom: -1px;
padding: 11px 0px;
font-size: 13px;
@@ -192,30 +195,14 @@
margin-bottom: 10px;
}
/* button color */
.el-button--cyan.is-active,
.el-button--cyan:active {
background: #20B2AA;
border-color: #20B2AA;
color: #FFFFFF;
}
.el-button--cyan:focus,
.el-button--cyan:hover {
background: #48D1CC;
border-color: #48D1CC;
color: #FFFFFF;
}
.el-button--cyan {
background-color: #20B2AA;
border-color: #20B2AA;
color: #FFFFFF;
}
/* 说明:这里原本是 AdminLTE / vue-element-admin 遗留的青色按钮与文字色
(#20B2AA / #48D1CC / #1ab394 / #1c84c6 / #23c6c8 / #f8ac59 / #ed5565 / #888888),
全是冷色,和 Cottagecore 色板冲突。已按语义映射到色板;
.el-button--cyan 全项目 0 处使用,直接删除。 */
/* text color */
.text-navy {
color: #1ab394;
color: $cc-grass-text;
}
.text-primary {
@@ -223,23 +210,23 @@
}
.text-success {
color: #1c84c6;
color: $cc-grass-text;
}
.text-info {
color: #23c6c8;
color: $cc-ash;
}
.text-warning {
color: #f8ac59;
color: $cc-daisy-deep;
}
.text-danger {
color: #ed5565;
color: $cc-rose;
}
.text-muted {
color: #888888;
color: $cc-ink-3;
}
/* image */
@@ -260,15 +247,16 @@
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 0 0 4px #ccc;
box-shadow: $cc-shadow-md;
overflow: hidden;
}
/* 拖拽列样式 */
/* 拖拽列样式(RuoYi 表单构建器 / 表格拖拽排序共用)
原来是 vue-element-admin 的 #42b983(Vue 青绿),和暖色板冲突 → 用草绿深档 */
.sortable-ghost{
opacity: .8;
color: #fff!important;
background: #42b983!important;
background: $cc-grass-deep!important;
}
.top-right-btn {
+22 -26
View File
@@ -33,12 +33,8 @@ $card-shadow: $cc-shadow-sm;
/* 一级菜单图标色板:只留 4 个田园色循环(陶土 / 草绿 / 雏菊金 / 玫瑰粉)
原来是 8 色高饱和渐变,15 个菜单项齐上像调色盘;
现在与仪表盘统计卡共用同一套 4 色,整站配色口径统一。 */
$icon-hues:
#9a5c35, // 1 陶土
#457450, // 2 草绿
#a8791f, // 3 雏菊金
#b57474; // 4 玫瑰粉
现在与统计卡图标共用 cottagecore.scss 的 $cc-icon-hues,整站配色口径统一。 */
$icon-hues: $cc-icon-hues;
$icon-hue-count: length($icon-hues);
html,
@@ -51,10 +47,10 @@ body {
/* 亚麻底整体加深一档(原来是 #fdfaf5→#f3ece0,顶部近白,
和奶油卡片几乎同色,卡片边界会糊掉)。现在卡片是亮面、页面是麻面,层次清楚。 */
background:
radial-gradient(760px 520px at 4% 100%, rgba(90, 143, 90, .11), transparent 62%),
radial-gradient(760px 520px at 96% 0%, rgba(212, 160, 160, .13), transparent 62%),
radial-gradient(520px 420px at 78% 88%, rgba(245, 215, 95, .14), transparent 60%),
linear-gradient(155deg, #f7f1e6 0%, #f3ece0 48%, #ece2d3 100%);
radial-gradient(760px 520px at 4% 100%, rgba($cc-grass, .11), transparent 62%),
radial-gradient(760px 520px at 96% 0%, rgba($cc-blossom, .13), transparent 62%),
radial-gradient(520px 420px at 78% 88%, rgba($cc-daisy, .14), transparent 60%),
linear-gradient(155deg, $cc-tint-2 0%, $cc-linen 48%, $cc-linen-2 100%);
background-attachment: fixed;
.main-container {
@@ -112,7 +108,7 @@ body {
}
.el-scrollbar__thumb {
background: rgba(139, 115, 85, .26);
background: rgba($cc-earth, .26);
}
.is-horizontal {
@@ -187,7 +183,7 @@ body {
.el-menu:not(.el-menu--inline) > div > .el-submenu.is-active > .el-submenu__title {
color: $cc-grass-text !important;
border-color: $cc-blossom;
background: linear-gradient(135deg, $cc-cream-hi, rgba(245, 215, 95, .18)) !important;
background: linear-gradient(135deg, $cc-cream-hi, rgba($cc-daisy, .18)) !important;
box-shadow: $cc-shadow-md, inset 3px 0 0 $brand;
}
@@ -195,7 +191,7 @@ body {
.el-menu:not(.el-menu--inline) > div > a > .el-menu-item.is-active {
color: $brand !important;
border-color: $cc-blossom;
background: linear-gradient(135deg, $cc-cream-hi, rgba(212, 160, 160, .22)) !important;
background: linear-gradient(135deg, $cc-cream-hi, rgba($cc-blossom, .22)) !important;
box-shadow: $cc-shadow-md, inset 3px 0 0 $brand;
}
@@ -225,10 +221,10 @@ body {
border-radius: 11px;
/* 浅底 + 同色深图标。底色不能太淡,否则小图标「看不见」(用户反馈对比度不够),
所以 tint 用 .22 并加 1px 内描边。 */
color: #9a5c35 !important;
color: $cc-terra !important;
fill: currentColor;
background: rgba(154, 92, 53, .22);
box-shadow: inset 0 0 0 1px rgba(154, 92, 53, .32);
background: rgba($cc-terra, .22);
box-shadow: inset 0 0 0 1px rgba($cc-terra, .32);
}
/* 按菜单顺序在这 4 个色里轮换 */
@@ -267,7 +263,7 @@ body {
bottom: 7px;
width: 2px;
border-radius: 1px;
background: linear-gradient(180deg, rgba(154, 92, 53, .38), rgba(212, 160, 160, .22));
background: linear-gradient(180deg, rgba($cc-terra, .38), rgba($cc-blossom, .22));
}
.el-menu--inline .el-menu-item,
@@ -321,19 +317,19 @@ body {
.el-menu--inline .el-menu-item:hover,
.el-menu--inline .el-submenu > .el-submenu__title:hover {
color: $brand !important;
background: rgba(245, 215, 95, .20) !important;
background: rgba($cc-daisy, .20) !important;
transform: none !important;
&::before {
border-color: $brand;
background: rgba(154, 92, 53, .16);
background: rgba($cc-terra, .16);
}
}
.el-menu--inline .el-menu-item.is-active {
color: $brand !important;
font-weight: 650;
background: linear-gradient(90deg, rgba(212, 160, 160, .30), rgba(245, 215, 95, .16)) !important;
background: linear-gradient(90deg, rgba($cc-blossom, .30), rgba($cc-daisy, .16)) !important;
}
.el-menu--inline .el-menu-item.is-active::before {
@@ -342,7 +338,7 @@ body {
margin-top: -5px;
border-color: $brand;
background: $brand;
box-shadow: 0 0 0 4px rgba(154, 92, 53, .16);
box-shadow: 0 0 0 4px rgba($cc-terra, .16);
}
/* 三级:在二级基础上小步缩进,不把内容推到很右边 */
@@ -470,14 +466,14 @@ body {
background: transparent !important;
&:hover {
background: rgba(245, 215, 95, .24) !important;
background: rgba($cc-daisy, .24) !important;
color: $brand !important;
}
&.is-active {
color: $brand !important;
font-weight: 650;
background: linear-gradient(90deg, rgba(212, 160, 160, .32), rgba(245, 215, 95, .18)) !important;
background: linear-gradient(90deg, rgba($cc-blossom, .32), rgba($cc-daisy, .18)) !important;
}
.svg-icon {
@@ -499,7 +495,7 @@ body {
bottom: 7px;
width: 2px;
border-radius: 1px;
background: linear-gradient(180deg, rgba(154, 92, 53, .34), rgba(212, 160, 160, .2));
background: linear-gradient(180deg, rgba($cc-terra, .34), rgba($cc-blossom, .2));
}
.el-menu-item,
@@ -528,7 +524,7 @@ body {
&:hover {
color: $brand !important;
background: rgba(245, 215, 95, .24) !important;
background: rgba($cc-daisy, .24) !important;
&::before {
border-color: $brand;
@@ -538,7 +534,7 @@ body {
&.is-active {
color: $brand !important;
font-weight: 650;
background: rgba(212, 160, 160, .26) !important;
background: rgba($cc-blossom, .26) !important;
&::before {
border-color: $brand;
+130 -11
View File
@@ -14,7 +14,7 @@ $brand: $cc-terra;
$ink: $cc-ink;
$ink-2: $cc-ink-2;
$ink-3: $cc-ink-3;
$line: #eadfd0;
$line: $cc-border-3;
/* ------------------------------- 按钮 ------------------------------- */
.el-button {
@@ -59,8 +59,8 @@ $line: #eadfd0;
color: #fff;
&:hover, &:focus {
background: #854c2a;
border-color: #854c2a;
background: $cc-terra-deep;
border-color: $cc-terra-deep;
color: #fff;
}
@@ -190,9 +190,15 @@ $line: #eadfd0;
box-shadow: 0 0 0 3px rgba(154, 92, 53, .12);
}
/* 搜索条不再显示 label,用 placeholder 表达(与设计图一致) */
/* 搜索条恢复显示 label(2026-09-27 用户反馈「搜索框旁边的提示词不显示了」)。
此前这里把 inline 表单的 label 整片 display:none、只用 placeholder 表达,实测丢语境:
①「最小值 / 最大值」两个框没有前面的「价格区间」就不知道在填什么(同一处还有「请选择」×3 看不出是外观/类型/是否上架);
② 只写了 label、没写 placeholder 的框会变成没有任何说明的空白框;
③ 清空/选中后 placeholder 消失,条件就再也认不出来了。
⇒ 交回 Element 默认的 inline label(float:none + inline-block),只把字号/颜色对齐 size="small" 的控件。 */
.el-form--inline .el-form-item__label {
display: none;
font-size: 13px;
color: $ink-3;
}
.el-form--inline .el-form-item {
@@ -233,7 +239,7 @@ $line: #eadfd0;
}
.el-table__row:hover > td.el-table__cell {
background: #fbf5ea;
background: $cc-tint-3;
}
.el-button--text {
@@ -249,12 +255,28 @@ $line: #eadfd0;
}
/* ------------------------------- 分页 -------------------------------
分页保持在页面最底部(与原型图一致),不做悬浮/吸底
------------------------------------------------------------------- */
.app-container .pagination-container {
margin-top: 6px;
padding: 8px 16px 6px;
分页保持在页面最底部(与原型图一致),不做悬浮/吸底。
★ 这里的选择器刻意把 .pagination-container 写两遍(权重 0,3,0):
components/Pagination/index.vue 是 scoped 组件,它的规则会被编译成
.pagination-container[data-v-xxxxxxx],权重 0,2,0,而且**排在皮肤层后面**
⇒ 只写一遍时权重打平、组件赢,原版那条 background:#fff 就会在奶油底上
露出一条白带,padding:32px 16px 还会把分页推离表格一大截。
组件里那条已经删掉了,这里再抬一档权重,防止以后有人加回去又悄悄复发。
分页右对齐靠 text-align(原来靠 ruoyi.scss 里 el-pagination 的
position:absolute + right:0,那会让分页脱离文档流、外层高度算不准)。 */
.app-container .pagination-container.pagination-container {
margin: 4px 0 0;
padding: 8px 0 2px;
background: transparent;
border-radius: 14px;
text-align: right;
}
/* 表格里的文字按钮(编辑 / 删除 / 详情…)不许把标签折成两行 */
.el-table .el-button--text {
white-space: nowrap;
}
.el-pagination {
@@ -426,6 +448,103 @@ $line: #eadfd0;
border-bottom-color: $cc-line-2;
}
/* ================= Element 内部写死的冷色 =================
下面这些色在 element-ui 的 scss 里是**字面量**(不是变量),
改 element-variables.scss 覆盖不到,只能在皮肤层压掉。
共同特征:都是冷蓝 / 冷灰 / 冷绿,放在奶油底上明显出戏。
======================================================== */
/* 图片上传(ImageUpload 组件,头像 / banner):
①「+」虚线框:原 background #fbfdff、dashed #c0ccda、图标 #8c939d,全是冷色;
② 上传成功的绿对勾角标:upload.scss 里写死 #13ce66 的冷绿。 */
.el-upload--picture-card {
background-color: $cc-cream-hi;
border: 1px dashed $cc-line;
border-radius: 14px; /* 原 6px,和弹层/卡片统一 */
i {
color: $cc-ink-4;
}
&:hover {
border-color: $cc-blossom-deep;
}
}
/* 上传成功的绿勾角标:upload.scss 里写死 #13ce66,且用的是**带父级**的三段选择器
(权重 0,2,0)——只写 `.el-upload-list__item-status-label` 单类会被它压住,必须对齐权重。
顺带把角标的黑影 rgba(0,0,0,.2) / #ccc 也换成暖棕柔影。 */
.el-upload-list--picture-card .el-upload-list__item-status-label,
.el-upload-list--picture .el-upload-list__item-status-label,
.el-upload-cover__label {
background: $cc-grass-deep;
box-shadow: 0 1px 1px rgba(139, 115, 85, .28);
}
/* 多选下拉里已选中的标签底:原 #f0f2f5 冷蓝灰 */
.el-select .el-tag,
.el-cascader__tags .el-tag {
background-color: $cc-linen-2;
border-color: $cc-border-2;
}
/* 日期 / 时间 / 颜色面板的冷灰分隔线与近黑文字(原 #e4e4e4 / #dcdcdc / #333) */
.el-picker-panel__footer,
.el-picker-panel__sidebar,
.el-time-panel__footer,
.el-date-picker__time-header,
.el-date-range-picker__content.is-left,
.el-date-range-picker__time-header {
border-color: $cc-line-2;
background-color: $cc-cream-hi;
}
.el-picker-panel__btn,
.el-color-dropdown__btn {
border-color: $cc-border-2;
color: $cc-ink-2;
border-radius: 10px;
}
/* 日期面板「快捷选项」选中态:原 #e6f1fe 淡蓝底 */
.el-picker-panel__shortcut.active {
background-color: rgba(245, 215, 95, .28);
color: $cc-terra;
}
.el-color-picker__trigger,
.el-color-hue-slider__thumb,
.el-color-alpha-slider__thumb {
border-color: $cc-border-2;
}
/* 左/右侧标签页(border-card 变体):原 #d1dbe5 */
.el-tabs--left.el-tabs--border-card .el-tabs__item.is-left.is-active,
.el-tabs--right.el-tabs--border-card .el-tabs__item.is-right.is-active {
border-top-color: $cc-border-2;
border-bottom-color: $cc-border-2;
}
/* 输入框内滚动条滑块:原 #b4bccc */
.el-input::-webkit-scrollbar-thumb {
background: $cc-border-1;
}
/* 加载遮罩:Element 默认 rgba(255,255,255,.9),
在奶油底页面上是「刺眼白闪」(旋转图标本身已是陶土色) */
.el-loading-mask {
background-color: rgba(250, 246, 240, .86);
}
/* 斑马纹行 / 描述列表标签底:原 #fafafa 冷灰 */
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: $cc-cream;
}
.el-descriptions-item__label.is-bordered-label {
background: $cc-linen;
}
/* ------------------------------- 无障碍 -------------------------------
规范要求动效提供 reduced-motion 备选:关掉悬停位移/旋转,只留颜色变化
------------------------------------------------------------------- */
@@ -0,0 +1,187 @@
<template>
<!--
胶囊开关(替代 el-switch,用于后台所有布尔/状态列)
## 为什么自己写一个,而不是继续改 el-switch 的样式
Element 的 el-switch 把「轨道」「滑块」和「左右两段文字」拆成四个各自定位的元素,
文字用 is-active 控制显隐、轨道用 CSS 变量上色。后台原先 17 个页面各自拿
::v-deep 去覆盖它,改出来的东西越修越乱(滑块跑出轨道、两段文字同时出现),
**连续三次返工都没收敛**。这里改成自己拼 DOM:一个轨道容器 + 一个滑块 + 一段文字,
任何时刻只有一段文字、位置全部由本组件算好,不再依赖 Element 内部结构。
## 视觉规则(用户 2026-09-24 定的口径)
| 值 | 轨道 | 滑块 | 文字 |
|---|---|---|---|
| 真(生效) | 绿 #457450 | 白圆点停在**右侧** | 「启用」白字,落在左半边 |
| 假(未生效) | 白 + 细描边 | 白圆点停在**左侧** | 「禁用」灰字,落在右半边 |
⇒ **绿 = 该项生效,白 = 该项未生效**;文字是结果,整颗开关只出现一次。
## 尺寸要点(改尺寸必读)
容器 `66×26`;滑块 `22` 停在 `left:2`(开启 `translateX(38px)`);文字框宽 `36`,
关闭态 `left:26`、开启态 `left:3` —— **文字只在滑块不在的那半边**,两态重叠宽度都是 0。
⚠️ 早期版本是 54px 宽、文字框横跨整条轨道(`left:11; width:34`),结果**滑块压住文字**
(用户 2026-09-24 反馈「启用」的「用」被盖掉)。**根因是文字框与滑块占用了同一段横向空间**,
不是 z-index 或颜色问题。加宽容器把两者分到各半边才根治。
## 用法(与 el-switch 的 v-model 语义一致,可直接替换)
<capsule-switch v-model="scope.row.status" @change="save(scope.row)" />
// 自定义两端文案
<capsule-switch v-model="row.isDefault" active-text="是" inactive-text="否" />
// 库里是 '0'/'1' 字符串时(Element 写法兼容)
<capsule-switch v-model="row.status" active-value="0" inactive-value="1" />
若页面用 `:active-value` / `:inactive-value` 传了非布尔值,本组件内部把
传入值与 activeValue 比较得出开关位置,change 时再回吐对应的原始值,
⇒ 父组件拿到的仍然是原来的 '0'/'1',**不必改后端契约**。
-->
<div
class="capsule-switch"
:class="{ 'is-on': isOn, 'is-disabled': disabled }"
role="switch"
:aria-checked="isOn"
:aria-disabled="disabled"
tabindex="0"
@click="toggle"
@keydown.enter.prevent="toggle"
@keydown.space.prevent="toggle"
>
<span class="capsule-switch__text">{{ isOn ? activeText : inactiveText }}</span>
<span class="capsule-switch__dot"></span>
</div>
</template>
<script>
export default {
name: 'CapsuleSwitch',
props: {
// 兼容 el-switch:父组件既可能传布尔,也可能传 '0'/'1'
value: {
type: [Boolean, String, Number],
default: false
},
activeValue: {
type: [Boolean, String, Number],
default: true
},
inactiveValue: {
type: [Boolean, String, Number],
default: false
},
activeText: {
type: String,
default: '启用'
},
inactiveText: {
type: String,
default: '禁用'
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
isOn() {
return this.value === this.activeValue;
}
},
methods: {
toggle() {
if (this.disabled) return;
const next = this.isOn ? this.inactiveValue : this.activeValue;
this.$emit('input', next);
this.$emit('change', next);
}
}
};
</script>
<style lang="scss" scoped>
/* 尺寸常量:宽 66、高 26,滑块 22,两端留 2px 缝
⚠️ 改尺寸必须重算 —— 约束是「文字不能被滑块压住」:
关闭:滑块停在左(x 3~25),文字只占右半边(x 27~63),两者不重叠
开启:滑块停在右(x 41~63),文字只占左半边(x 3~39),两者不重叠
容器宽度必须 ≥ 滑块(22) + 文字所需宽度(36) + 两侧留白(约 8),
54px 那版文字框横跨整条轨道 ⇒ 必然被滑块压住(用户 2026-09-24 反馈的正是这个)。 */
.capsule-switch {
position: relative;
display: inline-block;
width: 66px;
height: 26px;
border-radius: 13px;
border: 1px solid #d8c3b0;
background: #fffdfa;
cursor: pointer;
vertical-align: middle;
transition: background-color 0.3s, border-color 0.3s;
user-select: none;
outline: none;
&:focus-visible {
box-shadow: 0 0 0 3px rgba(69, 116, 80, 0.22);
}
/* 滑块:白色正圆 */
&__dot {
position: absolute;
top: 1px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(139, 115, 85, 0.3);
transition: transform 0.3s;
z-index: 2;
}
/* 文字:整颗开关只渲染这一段,换的是内容不是显隐 ⇒ 不可能出现两段文字。
⚠️ 必须 z-index 高于滑块,否则关闭态下会被白圆点盖住(「启用」= 绿底白字,压在绿块上反而难读)。 */
&__text {
position: absolute;
top: 0;
left: 26px;
width: 36px;
height: 24px;
line-height: 24px;
font-size: 11px;
font-weight: 500;
text-align: center;
color: #8b755a;
transition: color 0.3s, left 0.3s;
pointer-events: none;
z-index: 3;
}
&__dot,
&__text {
pointer-events: none;
}
/* ---- 开启:绿底 + 滑块右移 38(2 → 40),文字换到左半边并改白 ---- */
&.is-on {
border-color: #457450;
background: #457450;
.capsule-switch__dot {
transform: translateX(38px);
}
.capsule-switch__text {
left: 3px;
color: #fff;
}
}
&.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
</style>
+4 -4
View File
@@ -376,7 +376,7 @@ export default {
.popup-main {
position: relative;
margin: 10px auto;
background: #fff;
background: #fffdfa; /* $cc-cream-hi */
border-radius: 5px;
font-size: 12px;
overflow: hidden;
@@ -385,7 +385,7 @@ export default {
overflow: hidden;
line-height: 34px;
padding-top: 6px;
background: #f2f2f2;
background: #ece2d3; /* $cc-linen-2 */
}
.popup-result {
box-sizing: border-box;
@@ -404,7 +404,7 @@ export default {
margin-left: -70px;
text-align: center;
line-height: 30px;
background: #fff;
background: #fffdfa; /* $cc-cream-hi */
}
.popup-result table {
text-align: center;
@@ -419,7 +419,7 @@ export default {
height: 30px;
white-space: nowrap;
overflow: hidden;
border: 1px solid #e8e8e8;
border: 1px solid rgba(139, 115, 85, 0.2); /* $cc-line-2 */
}
.popup-result-scroll {
font-size: 12px;
+1 -1
View File
@@ -198,7 +198,7 @@ export default {
margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
border: 1px solid #e4e7ed;
border: 1px solid rgba(212, 160, 160, 0.55); /* $cc-line */
line-height: 2;
margin-bottom: 10px;
position: relative;
@@ -183,7 +183,7 @@ export default {
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid #d9d9d9;
border-bottom: 1px solid rgba(139, 115, 85, 0.2); /* $cc-line-2 */
vertical-align: middle;
}
}
+2 -2
View File
@@ -77,7 +77,7 @@ export default {
height: 100%;
padding: 0 5px;
&:hover {
background: #ececec;
background: #ece2d3; /* $cc-linen-2 */
border-radius: 5px;
}
.icon {
@@ -94,7 +94,7 @@ export default {
}
}
.icon-item.active {
background: #ececec;
background: #ece2d3; /* $cc-linen-2 */
border-radius: 5px;
}
}
@@ -67,9 +67,13 @@ export default {
<style lang="scss" scoped>
.el-image {
border-radius: 5px;
background-color: #ebeef5;
box-shadow: 0 0 5px 1px #ccc;
/* 原为冷灰底(ebeef5)+ 硬灰影(0 0 5px 1px ccc)+ 5px 方角。
列表页的头像/图片缩略图全走这个组件,冷灰底 + 硬影在奶油卡上很突兀,
而且它在表格里被 skin.scss 压到 32px ⇒ 出错占位图标(原本 30px)几乎铺满整格,
看起来像"图裂了"。改成暖色板 + 柔和暖影,占位图缩小到不刺眼。 */
border-radius: 10px;
background-color: #f3ece0; // $cc-linen
box-shadow: 0 1px 3px rgba(139, 115, 85, 0.18);
::v-deep .el-image__inner {
transition: all 0.3s;
cursor: pointer;
@@ -83,8 +87,8 @@ export default {
align-items: center;
width: 100%;
height: 100%;
color: #7a664e;
font-size: 30px;
color: #8b755a; // $cc-ink-4
font-size: 18px;
}
}
</style>
+6 -4
View File
@@ -104,10 +104,12 @@ export default {
</script>
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
/* 只保留「隐藏」这一条。原版的 background:#fff + padding:32px 16px 已经删掉:
本组件是 scoped 的,选择器权重是 .pagination-container[data-v-x] (0,2,0),
而皮肤层(assets/styles/skin.scss)的 .app-container .pagination-container 也是 (0,2,0)
—— 权重打平、组件样式在产物里更靠后,于是原版这两条永远赢:
在奶油底 .app-container 上露出一条纯白带,还有 32px 的上下留白。
⇒ 分页的底色/间距/圆角一律由皮肤层负责,这里不要再写。 */
.pagination-container.hidden {
display: none;
}
+4 -3
View File
@@ -61,13 +61,14 @@ export default {
</script>
<style lang="scss" scoped>
/* 冷中性半透明(纯黑遮罩 / 纯黑影)→ 暖棕:Cottagecore 里不留灰黑 */
.rightPanel-background {
position: fixed;
top: 0;
left: 0;
opacity: 0;
transition: opacity .3s cubic-bezier(.7, .3, .1, 1);
background: rgba(0, 0, 0, .2);
background: rgba(79, 65, 48, .22); /* $cc-ink 22% */
z-index: -1;
}
@@ -78,10 +79,10 @@ export default {
position: fixed;
top: 0;
right: 0;
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, .05);
box-shadow: 0 0 15px 0 rgba(139, 115, 85, .13); /* $cc-earth 13% */
transition: all .25s cubic-bezier(.7, .3, .1, 1);
transform: translate(100%);
background: #fff;
background: #fffdfa; /* $cc-cream-hi */
z-index: 40000;
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default {
.screenfull-svg {
display: inline-block;
cursor: pointer;
fill: #5a5e66;;
fill: #7a664e; /* $cc-ink-3;原为冷板岩(5a5e66),且多打了一个分号 */
width: 20px;
height: 20px;
vertical-align: 10px;
+1 -38
View File
@@ -7,16 +7,6 @@
<tags-view v-if="showTagsView" class="navbar-tags-view" />
<div class="right-menu">
<!-- 任意页面都能一键看"当前这个页面"的操作记录 -->
<div
v-if="showOperLog"
v-hasPermi="['monitor:operlog:query']"
class="right-menu-item operlog-entry"
@click="goOperationLog"
>
<i class="el-icon-document" />
<span class="operlog-text">操作日志</span>
</div>
<el-dropdown class="avatar-container right-menu-item" trigger="click">
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
@@ -52,21 +42,9 @@ export default {
/** 标签页已并入顶栏,是否显示跟随布局设置 */
showTagsView() {
return this.$store.state.settings.tagsView
},
/** 已经在操作日志页时不显示入口 */
showOperLog() {
return this.$route.path !== '/system/log/operlog'
}
},
methods: {
/** 跳到操作日志,并自动按当前页面的菜单名筛选 */
goOperationLog() {
const pageTitle = (this.$route.meta && this.$route.meta.title) || ''
this.$router.push({
path: '/system/log/operlog',
query: pageTitle ? { title: pageTitle } : {}
})
},
async logout() {
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
@@ -131,21 +109,6 @@ export default {
color: #5f4f3d;
}
/* 操作日志入口:文案按钮,比图标更好找 */
.operlog-entry {
cursor: pointer;
font-size: 14px;
transition: color 0.2s;
.operlog-text {
margin-left: 4px;
}
&:hover {
color: #9a5c35;
}
}
.avatar-container {
cursor: pointer;
@@ -174,7 +137,7 @@ export default {
.el-icon-caret-bottom {
font-size: 12px;
color: #9a886e;
color: #8b755a; /* $cc-ink-4;原为未加暗的旧值(9a886e) */
}
}
}
@@ -194,7 +194,7 @@ export default {
.setting-drawer-content {
.setting-drawer-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, .85);
color: #4f4130; /* $cc-ink */
font-size: 14px;
line-height: 22px;
font-weight: bold;
@@ -242,13 +242,13 @@ export default {
.drawer-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, .85);
color: #4f4130; /* $cc-ink */
font-size: 14px;
line-height: 22px;
}
.drawer-item {
color: rgba(0, 0, 0, .65);
color: #5f4f3d; /* $cc-ink-2 */
font-size: 14px;
padding: 12px 0;
}
@@ -304,8 +304,9 @@ export default {
}
&.active {
/* 渐变两端都保证白字 ≥4.5:1(#9a5c35 为 5.31:1,#7d4a2a 为 7.28:1) */
background: linear-gradient(135deg, #9a5c35, #7d4a2a);
/* 渐变两端都保证白字 ≥4.5:1(#9a5c35 为 5.31:1,#854c2a 为 6.85:1)
末端用 $cc-terra-deep,和 skin.scss 里主色悬停是同一个值 */
background: linear-gradient(135deg, #9a5c35, #854c2a);
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.22);
@@ -371,8 +372,8 @@ export default {
vertical-align: 2px;
border-radius: 50%;
text-align: center;
color: #8b98ad;
background-color: rgba(139, 152, 173, .18);
color: #7a664e; /* $cc-ink-3;原为冷板岩(8b98ad) */
background-color: rgba(122, 102, 78, 0.18);
transition: all .3s cubic-bezier(.645, .045, .355, 1);
transform-origin: 100% 50%;
&:before {
+3
View File
@@ -33,6 +33,8 @@ import ImageUpload from "@/components/ImageUpload"
import ImagePreview from "@/components/ImagePreview"
// 字典标签组件
import DictTag from '@/components/DictTag'
// 胶囊开关组件(替代 el-switch)
import CapsuleSwitch from '@/components/CapsuleSwitch'
// 头部标签组件
import VueMeta from 'vue-meta'
// 字典数据组件
@@ -58,6 +60,7 @@ Vue.component('Editor', Editor)
Vue.component('FileUpload', FileUpload)
Vue.component('ImageUpload', ImageUpload)
Vue.component('ImagePreview', ImagePreview)
Vue.component('CapsuleSwitch', CapsuleSwitch)
Vue.use(directive)
Vue.use(plugins)
+3 -3
View File
@@ -53,12 +53,12 @@ export const ACCOUNT_SOURCE_MAP = {
52: '极速糖果奖励',
56: 'Roll房未发放退回主播',
59: '主播Roll房奖池扣款',
60: '玫瑰女郎消费',
61: '玫瑰女郎奖励',
62: '招商周结佣金',
63: '流水抽奖奖励',
65: '管理员调整余额',
66: '管理员调整积分'
66: '管理员调整积分',
68: '麻将胡了消费',
69: '麻将胡了奖励'
};
/**
+3 -3
View File
@@ -53,7 +53,7 @@ export default {
max-width: 100%;
margin: 100px auto;
.pan-back-btn {
background: #008489;
background: #9a5c35;
color: #fff;
border: none!important;
}
@@ -69,7 +69,7 @@ export default {
.text-jumbo {
font-size: 60px;
font-weight: 700;
color: #484848;
color: #4f4130;
}
.list-unstyled {
font-size: 14px;
@@ -77,7 +77,7 @@ export default {
padding-bottom: 5px;
}
a {
color: #008489;
color: #9a5c35;
text-decoration: none;
&:hover {
text-decoration: underline;
+2 -2
View File
@@ -170,7 +170,7 @@ export default {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
color: #9a5c35;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
@@ -205,7 +205,7 @@ export default {
float: left;
width: 110px;
height: 36px;
background: #1482f0;
background: #9a5c35;
border-radius: 100px;
text-align: center;
color: #ffffff;
+1 -1
View File
@@ -31,7 +31,7 @@
<div style="margin-top: 16px; padding-left: 68px">
<div v-for="(item, index) in items" :key="index" style="display: flex; align-items: center; margin-bottom: 10px">
<span style="width: 100px; color: #606266; font-size: 13px">临时爆率 #{{ index + 1 }}</span>
<span style="width: 100px; color: #5f4f3d; font-size: 13px">临时爆率 #{{ index + 1 }}</span>
<el-input
v-model="item.lowPrice"
type="number"
@@ -0,0 +1,448 @@
<template>
<div class="app-container">
<el-row :gutter="10" style="margin-bottom:16px">
<el-input v-model="queryParams.name" placeholder="按赌注搜索" clearable style="width:200px;margin-right:10px" @clear="loadData" @keyup.enter.native="loadData">
<el-button slot="append" icon="el-icon-search" @click="loadData" />
</el-input>
<span style="margin-left:10px;margin-right:4px;color:#5f4f3d">身份:</span>
<el-radio-group v-model="audienceFilter" size="small" @change="loadData">
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
</el-radio-group>
<el-button icon="el-icon-refresh" style="margin-left:10px" @click="loadData">刷新</el-button>
<el-button type="danger" icon="el-icon-refresh" :loading="recalcLoading" style="float:right" @click="openRecalcDialog">一键重算分布</el-button>
</el-row>
<el-table v-loading="loading" :data="boxList" border>
<el-table-column prop="boxId" label="ID" min-width="50" align="center" />
<el-table-column prop="bet" label="赌注" min-width="70" align="center" />
<el-table-column label="总回合" min-width="80" align="center">
<template slot-scope="scope">
<span v-if="totalRoundsOf(scope.row) != null">{{ totalRoundsOf(scope.row) }}</span>
<span v-else style="color:#8b755a">-</span>
</template>
</el-table-column>
<el-table-column label="目标RTP" min-width="80" align="center">
<template slot-scope="scope">
<!-- 显示 1 位小数:配比是"整数条回合 × 桶内实际均值"分出来的,精确值留在 title 里,鼠标悬停可看 -->
<span v-if="targetRtp(scope.row) != null" :title="targetRtp(scope.row) + '%'">{{ Number(targetRtp(scope.row)).toFixed(1) + '%' }}</span>
<span v-else style="color:#8b755a">-</span>
</template>
</el-table-column>
<el-table-column label="实时RTP" min-width="80" align="center">
<template slot-scope="scope">
<span v-if="actualRtp(scope.row) != null" :style="{color: actualRtp(scope.row) > (targetRtp(scope.row) != null ? targetRtp(scope.row) : 0) ? '#a4485a' : '#457450'}">{{ actualRtp(scope.row) }}%</span>
<span v-else style="color:#8b755a">-</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="60" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '1' ? 'success' : 'danger'" size="small">{{ scope.row.status === '1' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="270" align="center">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="info" @click="handleBuckets(scope.row)">饰品配置</el-button>
<el-button size="mini" type="success" @click="openTemplateManager(scope.row)">模板管理</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50,100]"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 编辑弹窗 -->
<el-dialog
:title="'编辑箱子 - 赌注 ' + (form.bet != null ? form.bet : '')"
:visible.sync="dialogVisible"
width="480px"
append-to-body
>
<el-form ref="form" :model="form" label-width="110px">
<el-form-item label="基本赌注"><el-input-number v-model="form.bet" disabled style="width:100%" /></el-form-item>
<el-form-item label="总回合数">
<el-input-number v-model="form.totalRounds" :min="0" :step="50" style="width:100%" />
<span style="color:#7a664e;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
</el-form-item>
<el-form-item label="目标RTP">
<el-input-number v-model="form.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
<span style="color:#7a664e;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
</el-form-item>
<el-form-item label="固定/收敛回合%">
<el-input-number v-model="form.fixedPct" :min="0" :max="100" :step="5" style="width:38%" @change="onFixedPctChange" />
<span style="margin-left:4px">%</span>
<span style="margin:0 4%">/</span>
<el-input-number v-model="form.convergePct" :min="0" :max="100" :step="5" style="width:38%" @change="onConvergePctChange" />
<span style="margin-left:4px">%</span>
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标,随保存作用于当前身份</div>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio label="1">启用</el-radio>
<el-radio label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitForm">确 定</el-button>
</div>
</el-dialog>
<!-- 重算分布弹窗 -->
<el-dialog
title="重算分布"
:visible.sync="recalcDialogVisible"
width="480px"
append-to-body
>
<el-form label-width="110px">
<el-form-item label="身份范围">
<el-radio-group v-model="recalcForm.onlyCurrent">
<el-radio :label="false">全部身份</el-radio>
<el-radio :label="true">仅当前身份【{{ audienceLabel(audienceFilter) }}】</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="目标RTP">
<el-input-number v-model="recalcForm.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
</el-form-item>
<el-form-item label="总回合">
<el-input-number v-model="recalcForm.totalRounds" :min="50" :step="50" style="width:100%" />
</el-form-item>
<el-form-item label="固定/收敛回合%">
<el-input-number v-model="recalcForm.fixedPct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcFixedPctChange" />
<span style="margin-left:4px">%</span>
<span style="margin:0 4%">/</span>
<el-input-number v-model="recalcForm.convergePct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcConvergePctChange" />
<span style="margin-left:4px">%</span>
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标</div>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="recalcDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="recalcLoading" @click="submitRecalc">确认重算</el-button>
</div>
</el-dialog>
<!-- 饰品配置弹窗(跟随列表页当前身份) -->
<el-dialog
:title="'饰品配置 - (ID:' + currentBox.boxId + ')【' + audienceLabel(audience) + '】'"
:visible.sync="bucketVisible"
width="760px"
append-to-body
>
<div style="display:flex;align-items:center;margin-bottom:10px">
<el-alert type="info" :closable="false" style="flex:1">
身份【{{ audienceLabel(audience) }}】 计算回合(固定): <b>{{ bucketTotal }}</b> &nbsp;|&nbsp;
收敛回合: <b>{{ convergeRoundsDerived }}</b> &nbsp;|&nbsp; 总回合: <b>{{ totalRoundsDerived }}</b> &nbsp;|&nbsp;
计算RTP(池内实际均值): <b>{{ bucketRTP }}%</b> &nbsp;|&nbsp; 目标RTP: <b>{{ currentBucketTargetRtp }}%</b>
</el-alert>
<el-button type="warning" size="small" icon="el-icon-refresh" style="margin-left:10px;flex-shrink:0" @click="handleBucketRecalc">重算分布</el-button>
</div>
<el-table v-loading="bucketLoading" :data="bucketList" border max-height="420">
<el-table-column prop="bucketId" label="桶ID" width="60" align="center" />
<el-table-column label="饰品" min-width="110" align="center">
<template slot-scope="scope">{{ (scope.row.bucketName || scope.row.name || '').replace('x', '倍桶') }}</template>
</el-table-column>
<el-table-column label="倍率范围" min-width="120" align="center">
<template slot-scope="scope">{{ scope.row.rangeLow }} - {{ scope.row.rangeHigh }}</template>
</el-table-column>
<el-table-column label="期望倍率" min-width="90" align="center">
<template slot-scope="scope">{{ scope.row.expectMul }}</template>
</el-table-column>
<el-table-column label="回合数" min-width="140" align="center">
<template slot-scope="scope">
<el-input-number v-model="scope.row.count" :min="0" :step="10" size="small" controls-position="right" />
</template>
</el-table-column>
<el-table-column label="剩余" min-width="70" align="center">
<template slot-scope="scope">
<span :style="{color: (scope.row.remaining != null ? scope.row.remaining : scope.row.count) <= 0 ? '#a4485a' : '#457450'}">{{ scope.row.remaining != null ? scope.row.remaining : scope.row.count }}</span>
</template>
</el-table-column>
<el-table-column label="动态收敛回合" min-width="100" align="center">
<template slot-scope="scope"><span style="color:#8f6418">{{ scope.row.convergeCount || 0 }}</span></template>
</el-table-column>
</el-table>
<div slot="footer">
<el-button @click="bucketVisible = false">取 消</el-button>
<el-button type="success" icon="el-icon-document-add" @click="handleSaveAsTemplate">保存为模板</el-button>
<el-button type="primary" @click="saveBuckets">保存</el-button>
</div>
</el-dialog>
<!-- 模板管理弹窗(跟随列表页当前身份) -->
<el-dialog
:title="'模板管理 - (ID:' + templateBox.boxId + ')【' + audienceLabel(templateAudience) + '】'"
:visible.sync="templateDialogVisible"
width="720px"
append-to-body
>
<el-alert type="info" :closable="false" style="margin-bottom:12px">
当前为身份【{{ audienceLabel(templateAudience) }}】的模板。模板保存了该箱子该身份的饰品桶回合分布。选择后点【应用】可将该身份桶数量恢复为模板值,并设为抽干补桶依据。
</el-alert>
<el-table v-if="boxTemplates.length > 0" :data="boxTemplates" border highlight-current-row @current-change="onTemplateRowChange">
<el-table-column label="模板名称" min-width="150">
<template slot-scope="scope">
{{ scope.row.name }}
<el-tag v-if="scope.row.isActive === 1" type="success" size="mini" style="margin-left:6px">生效中</el-tag>
</template>
</el-table-column>
<el-table-column label="饰品数" width="80" align="center">
<template slot-scope="scope">{{ scope.row.items ? scope.row.items.length : 0 }}</template>
</el-table-column>
<el-table-column label="固定回合" width="90" align="center">
<template slot-scope="scope">{{ scope.row.totalRounds }}</template>
</el-table-column>
<el-table-column label="保存时间" width="170" align="center">
<template slot-scope="scope">{{ scope.row.createTime }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template slot-scope="scope">
<el-button size="mini" type="danger" @click="handleDeleteTemplate(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="该身份暂无模板,请在饰品配置中修改数量后点【保存为模板】。" />
<div slot="footer">
<el-button @click="templateDialogVisible = false">关 闭</el-button>
<el-button type="primary" :disabled="!selectedTemplateRow" @click="handleApplyTemplate">应用选中模板</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getBoxList, saveBox, getBoxBuckets, saveBoxBuckets, calcDistribution, getBoxTemplates, saveBoxTemplate, deleteBoxTemplate, applyBoxTemplate, recalcBatch } from '@/api/game/mahjong/box/api'
import { getBucketList } from '@/api/game/mahjong/bucket/api'
export default {
name: 'MahjongBox',
data() {
return {
loading: false, recalcLoading: false, saveLoading: false, boxList: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, boxType: null },
audienceFilter: 'common',
dialogVisible: false, bucketVisible: false, currentBox: {},
form: { boxId: null, name: '', bet: 1, boxType: 'normal', rtp: 0.8, status: '1', price: 0, totalRounds: 0, fixedPct: 85, convergePct: 15 },
// 重算分布弹窗
recalcDialogVisible: false,
recalcForm: { onlyCurrent: false, boxType: '', rtp: 0.8, totalRounds: 200, fixedPct: 85, convergePct: 15 },
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
audience: 'common', bucketLoading: false, bucketList: [], bucketAll: [], bucketExpects: {},
// 模板
templateDialogVisible: false, templateBox: {}, templateAudience: 'common', boxTemplates: [], selectedTemplateRow: null
}
},
computed: {
bucketTotal() { return this.bucketList.reduce((s, o) => s + (o.count || 0), 0) },
bucketRTP() {
const total = this.bucketTotal
if (!total || !this.currentBox.bet) return '0.0'
let contrib = 0
this.bucketList.forEach(o => { const mul = this.bucketExpects[o.bucketId]; if (mul != null) contrib += (o.count || 0) * mul })
// 本游戏 costMultiplier ≡ 1(price ≡ bet),此处不再有模式倍率表
const cm = Number(this.currentBox.costMultiplier) || 1
return (contrib / total / cm * 100).toFixed(1)
},
convergeRatioVal() {
let cr = this.currentBox.rtpConvergeRatio != null ? Number(this.currentBox.rtpConvergeRatio) : 0.15
if (!(cr >= 0)) cr = 0.15
return Math.min(0.9, Math.max(0, cr))
},
totalRoundsDerived() { const fixed = this.bucketTotal; return fixed > 0 ? Math.round(fixed / (1 - this.convergeRatioVal)) : 0 },
convergeRoundsDerived() { return Math.max(0, this.totalRoundsDerived - this.bucketTotal) },
// 当前身份目标RTP(%):优先取该身份分布反推值,无则回退箱子统一值
currentBucketTargetRtp() {
const m = this.currentBox.targetRtpByAudience
const v = m && m[this.audience] != null ? m[this.audience] : null
if (v != null) return Number(v).toFixed(1)
return this.currentBox.rtp != null ? (this.currentBox.rtp * 100).toFixed(1) : '-'
}
},
created() { this.loadData() },
methods: {
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
getBoxList(params).then(res => {
this.boxList = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
openRecalcDialog() {
// 默认参数取当前列表第一行的当前身份反推值(无数据时用默认)
const row = this.boxList[0] || {}
let rtp = 0.8, totalRounds = 200, convergePct = 15
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
if (t != null) rtp = Number(t) / 100
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
if (tr != null) totalRounds = Number(tr)
if (row.rtpConvergeRatio != null) convergePct = Math.round(Number(row.rtpConvergeRatio) * 100)
this.recalcForm = { onlyCurrent: false, boxType: '', rtp, totalRounds, fixedPct: 100 - convergePct, convergePct }
this.recalcDialogVisible = true
},
onRecalcFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.fixedPct = val; this.recalcForm.convergePct = 100 - val },
onRecalcConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.convergePct = val; this.recalcForm.fixedPct = 100 - val },
submitRecalc() {
const f = this.recalcForm
const params = {
boxType: f.boxType || null,
audience: f.onlyCurrent ? this.audienceFilter : null,
rtp: f.rtp,
totalRounds: f.totalRounds,
convergeRatio: f.convergePct / 100
}
this.recalcLoading = true
recalcBatch(params).then(() => {
this.$message.success('重算完成')
this.recalcDialogVisible = false
this.loadData()
}).catch(e => this.$message.error('重算失败: ' + (e.message || e)))
.finally(() => { this.recalcLoading = false })
},
handleEdit(row) {
const convergePct = row.rtpConvergeRatio != null ? Math.round(row.rtpConvergeRatio * 100) : 15
// 目标RTP 初始值 = 当前身份分布反推的目标(百分点/100),避免显示成其他身份的值
let rtp = row.rtp != null ? row.rtp : 0.8
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
if (t != null) rtp = Number(t) / 100
// 总回合 初始值 = 当前身份分布反推的一轮总回合(身份级独立)
let totalRounds = row.totalRounds || 0
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
if (tr != null) totalRounds = Number(tr)
this.form = { ...row, totalRounds, rtp, status: row.status || '1', convergePct, fixedPct: 100 - convergePct }
this.dialogVisible = true
},
onFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.fixedPct = val; this.form.convergePct = 100 - val },
onConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.convergePct = val; this.form.fixedPct = 100 - val },
submitForm() {
const convergeRatio = (this.form.convergePct != null ? this.form.convergePct : 15) / 100
// totalRounds 会同时回写 total_rounds,保证 cycleN 与实际池子一致
const data = { boxId: this.form.boxId, name: this.form.name, bet: this.form.bet, boxType: this.form.boxType, rtp: this.form.rtp, status: this.form.status, rtpConvergeRatio: convergeRatio, totalRounds: this.form.totalRounds }
this.saveLoading = true
saveBox(data).then(res => {
const boxId = this.form.boxId || (res.data && res.data.boxId)
if (!boxId) { this.saveLoading = false; this.$message.success('保存成功'); this.dialogVisible = false; this.loadData(); return }
// 保存后只重算当前身份
calcDistribution({ boxId, audience: this.audienceFilter, totalRounds: this.form.totalRounds, rtp: this.form.rtp, convergeRatio })
.then(() => { this.$message.success('修改成功,已重算【' + this.audienceLabel(this.audienceFilter) + '】身份分布') })
.catch(() => { this.$message.success('修改成功(分布计算失败,可稍后用"重算分布"重试)') })
.finally(() => { this.saveLoading = false; this.dialogVisible = false; this.loadData() })
}).catch(() => { this.saveLoading = false })
},
handleBuckets(row) {
this.currentBox = row
this.audience = this.audienceFilter // 跟随列表页当前身份
getBucketList().then(bkRes => {
this.bucketAll = (bkRes.data && bkRes.data.list) ? bkRes.data.list : []
this.bucketExpects = {}
this.bucketAll.forEach(o => { this.bucketExpects[o.id] = Number(o.expectMul) })
this.bucketVisible = true
this._refreshBucketList()
})
},
_refreshBucketList() {
this.bucketLoading = true
getBoxBuckets(this.currentBox.boxId, this.audience).then(res => {
const items = (res.data && res.data.list) ? res.data.list : []
// 「计算RTP」必须与后台「目标RTP」同一口径 = 该桶**池内实际均值**(AVG(actual_mul)),
// 否则会拿桶定义的名义 expectMul(区间中点)来算,两个数必然对不上。
// 池子里还没有该桶的回合时,后端会回退名义值,这里保持同样回退。
const pe = (res.data && res.data.poolExpect) || {}
const expects = {}
this.bucketAll.forEach(o => {
expects[o.id] = pe[o.id] != null ? Number(pe[o.id]) : Number(o.expectMul)
})
this.bucketExpects = expects
const map = {}
items.forEach(it => { map[it.bucketId] = { count: it.count, remaining: it.remaining != null ? it.remaining : (it.count - (it.used || 0)), convergeCount: it.convergeCount } })
// 只有 normal 一种模式:桶范围恒为 1~16
this.bucketList = this.bucketAll.filter(o => o.id >= 1 && o.id <= 16)
.map(o => ({ ...o, count: (map[o.id] && map[o.id].count) || 0, remaining: map[o.id] ? map[o.id].remaining : 0, convergeCount: map[o.id] ? map[o.id].convergeCount : 0 }))
this.bucketLoading = false
}).catch(() => { this.bucketLoading = false })
},
handleBucketRecalc() {
const box = this.currentBox
const cr = box.rtpConvergeRatio != null ? box.rtpConvergeRatio : 0.15
// 目标RTP 优先用当前身份分布反推值,与"按身份编辑"语义一致
const t = box.targetRtpByAudience && box.targetRtpByAudience[this.audience] != null ? Number(box.targetRtpByAudience[this.audience]) / 100 : (box.rtp || 0.8)
// 总回合 用当前身份反推值(身份级独立)
const tr = box.totalRoundsByAudience && box.totalRoundsByAudience[this.audience] != null ? Number(box.totalRoundsByAudience[this.audience]) : (box.totalRounds || 200)
calcDistribution({ boxId: box.boxId, audience: this.audience, totalRounds: tr, rtp: t, convergeRatio: cr }).then(() => {
this.$message.success('分布已重算并保存')
this._refreshBucketList()
this.loadData()
}).catch(() => { this.$message.error('重算失败') })
},
saveBuckets() {
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
saveBoxBuckets({ boxId: this.currentBox.boxId, audience: this.audience, items }).then(() => {
this.$message.success('保存成功')
this.bucketVisible = false
this.loadData()
})
},
handleSaveAsTemplate() {
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
if (items.length === 0) return this.$message.warning('当前没有配置任何桶回合数,无法保存模板')
this.$prompt('请输入模板名称(同一箱子+身份内不可重复)', '保存为模板【' + this.audienceLabel(this.audience) + '】', { inputPattern: /\S+/, inputErrorMessage: '名称不能为空' })
.then(({ value }) => {
saveBoxTemplate({ boxId: this.currentBox.boxId, audience: this.audience, name: value.trim(), items })
.then(() => this.$message.success('模板【' + value.trim() + '】已保存'))
.catch(e => this.$message.error('保存失败: ' + (e.message || e)))
}).catch(() => {})
},
// 模板管理
openTemplateManager(row) { this.templateBox = row; this.templateAudience = this.audienceFilter; this.selectedTemplateRow = null; this.templateDialogVisible = true; this._loadTemplates() },
_loadTemplates() {
getBoxTemplates(this.templateBox.boxId, this.templateAudience).then(res => { this.boxTemplates = (res.data && res.data.list) || [] }).catch(() => { this.boxTemplates = [] })
},
onTemplateRowChange(row) { this.selectedTemplateRow = row },
handleDeleteTemplate(tpl) {
this.$confirm('确定删除模板【' + tpl.name + '】吗?', '提示', { type: 'warning' }).then(() => {
deleteBoxTemplate(tpl.id).then(() => { this.selectedTemplateRow = null; this.$message.success('已删除'); this._loadTemplates() })
}).catch(() => {})
},
handleApplyTemplate() {
if (!this.selectedTemplateRow) return
const tpl = this.selectedTemplateRow
this.$confirm('将模板【' + tpl.name + '】应用到身份【' + this.audienceLabel(this.templateAudience) + '】,并设为抽干补桶依据,确认?', '应用模板', { type: 'warning' }).then(() => {
applyBoxTemplate({ templateId: tpl.id }).then(() => {
this.$message.success('模板已应用,桶分布已更新')
this.templateDialogVisible = false
if (this.bucketVisible && this.currentBox.boxId === this.templateBox.boxId && this.audience === this.templateAudience) this._refreshBucketList()
this.loadData()
}).catch(e => this.$message.error('应用失败: ' + (e.message || e)))
}).catch(() => {})
},
audienceLabel(key) { const a = this.audiences.find(x => x.key === key); return a ? a.name : key },
// 按当前身份筛选取值(后端一次返回4身份)
targetRtp(row) {
const m = row.targetRtpByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
},
actualRtp(row) {
const m = row.actualRtpByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
},
// 当前身份一轮总回合(身份级独立)
totalRoundsOf(row) {
const m = row.totalRoundsByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : (row.totalRounds != null ? row.totalRounds : null)
}
}
}
</script>
@@ -0,0 +1,33 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:16px">
16 档倍率桶(0x~500x)的定义与落桶区间。单局封顶 500x,所以最高一档的上限就是 500。
分布由 Python 预生成落库,此处仅供参考查看。
</el-alert>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="桶ID" width="80" align="center" />
<el-table-column label="名称" min-width="140" align="center">
<template slot-scope="scope">{{ (scope.row.name || '').replace('x', '倍桶') }}</template>
</el-table-column>
<el-table-column prop="expectMul" label="期望倍率" min-width="120" align="center" />
<el-table-column prop="rangeLow" label="落桶下限" min-width="120" align="center" />
<el-table-column prop="rangeHigh" label="落桶上限" min-width="120" align="center" />
</el-table>
</div>
</template>
<script>
import { getBucketList } from '@/api/game/mahjong/bucket/api'
export default {
name: 'MahjongBucket',
data() { return { loading: false, list: [] } },
created() {
this.loading = true
getBucketList().then(res => {
this.list = (res.data && res.data.list) || []
this.loading = false
}).catch(() => { this.loading = false })
}
}
</script>
@@ -0,0 +1,92 @@
<template>
<div class="app-container">
<el-row :gutter="16" style="margin-bottom:16px">
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计投注</small><h3>{{ summary.totalCost || 0 }}</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计赢奖</small><h3>{{ summary.totalWin || 0 }}</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>整体RTP</small><h3 :style="{color: rtp > 100 ? '#a4485a' : '#457450'}">{{ rtp }}%</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>总局数</small><h3>{{ summary.totalSpins || 0 }}</h3></div></el-card></el-col>
</el-row>
<div style="margin-bottom:16px">
<el-date-picker v-model="queryParams.dateStart" type="date" placeholder="开始日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
<span style="margin:0 4px">至</span>
<el-date-picker v-model="queryParams.dateEnd" type="date" placeholder="结束日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
<el-button type="primary" icon="el-icon-search" @click="loadAll">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="date" label="日期" min-width="120" align="center" />
<el-table-column prop="totalCost" label="总投注" min-width="120" align="center" />
<el-table-column prop="totalWin" label="总赢奖" min-width="120" align="center" />
<el-table-column prop="totalSpins" label="总局数" min-width="90" align="center" />
<el-table-column prop="playerCount" label="玩家数" min-width="90" align="center" />
<el-table-column label="RTP" min-width="100" align="center">
<template slot-scope="scope">
<span :style="{color: scope.row.totalCost > 0 && scope.row.totalWin / scope.row.totalCost > 1 ? '#a4485a' : '#457450'}">
{{ scope.row.totalCost > 0 ? (Math.round(scope.row.totalWin / scope.row.totalCost * 10000) / 100).toFixed(2) + '%' : '-' }}
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getWinList, getWinSummary } from '@/api/game/mahjong/dailywin/api'
export default {
name: 'MahjongDailyWin',
data() {
return {
loading: false, list: [], total: 0, summary: {},
queryParams: { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
}
},
computed: {
rtp() {
const c = Number(this.summary.totalCost) || 0
const w = Number(this.summary.totalWin) || 0
return c > 0 ? (Math.round(w / c * 10000) / 100).toFixed(2) : '0.00'
}
},
created() { this.loadAll() },
methods: {
loadAll() { this.loadSummary(); this.loadData() },
loadSummary() {
getWinSummary({ dateStart: this.queryParams.dateStart, dateEnd: this.queryParams.dateEnd }).then(res => {
this.summary = res.data || {}
})
},
loadData() {
this.loading = true
getWinList(this.queryParams).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleReset() {
this.queryParams = { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
this.loadAll()
}
}
}
</script>
<style scoped>
.stat { text-align: center; }
.stat small { color: #7a664e; }
</style>
@@ -0,0 +1,133 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:12px">
返奖饰品按"结算赢额"匹配返奖区间展示。区间需连续覆盖;未命中时用兜底饰品。
保存时会同步写入 <b>game_mahjong_prize_range</b>(按 prizeId 唯一)。
</el-alert>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="name" label="名称" min-width="160" align="center" show-overflow-tooltip />
<el-table-column label="品质" width="80" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.grade" size="small" :type="gradeType(scope.row.grade)" :style="gradeStyle(scope.row.grade)">{{ gradeLabel(scope.row.grade) }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="饰品图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.imageUrl" :src="getImg(scope.row.imageUrl)" style="width:40px;height:40px" fit="cover" />
</template>
</el-table-column>
<el-table-column label="品质图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.levelImg" :src="getImg(scope.row.levelImg)" style="width:40px;height:40px" fit="contain" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="磨损" width="110" align="center">
<template slot-scope="scope">{{ scope.row.exteriorName || '崭新出厂' }}</template>
</el-table-column>
<el-table-column label="兜底" width="70" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.isFallback === 1" type="danger" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column label="返奖区间" min-width="160" align="center">
<template slot-scope="scope">
<span v-if="scope.row.winLow != null">{{ scope.row.winLow }} ~ {{ scope.row.winHigh }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="排序" width="70" align="center">
<template slot-scope="scope">{{ scope.row.sort }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="编辑奖品"
:visible.sync="dialogVisible"
width="480px"
append-to-body
>
<el-form ref="form" :model="form" label-width="80px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="品质">
<el-select v-model="form.grade" style="width:100%">
<el-option label="1 - 金" :value="1" />
<el-option label="2 - 红" :value="2" />
<el-option label="3 - 紫" :value="3" />
<el-option label="4 - 蓝" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="饰品图片">
<ImageUpload v-model="form.imageUrl" :limit="1" />
</el-form-item>
<el-form-item label="磨损">
<el-select v-model="form.exteriorName" placeholder="请选择磨损度" clearable style="width:100%">
<el-option label="崭新出厂" value="崭新出厂" />
<el-option label="略有磨损" value="略有磨损" />
<el-option label="久经沙场" value="久经沙场" />
<el-option label="破损不堪" value="破损不堪" />
<el-option label="战痕累累" value="战痕累累" />
</el-select>
</el-form-item>
<el-form-item label="兜底饰品">
<el-switch v-model="form.isFallback" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否" />
<div style="color:#7a664e;font-size:12px;margin-top:4px">未命中区间时随机选择一个兜底饰品返奖</div>
</el-form-item>
<el-form-item label="返奖区间">
<div style="display:flex;align-items:center;gap:8px">
<el-input-number v-model="form.winLow" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最小值" style="flex:1" />
<span>~</span>
<el-input-number v-model="form.winHigh" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最大值" style="flex:1" />
</div>
<div style="color:#7a664e;font-size:12px;margin-top:4px">玩家结算赢额落在该区间即获得此饰品(最高到 500x 封顶对应的赢额)</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :step="1" style="width:100%" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { prizeList, prizeSave } from '@/api/game/mahjong/prize/api'
import ImageUpload from '@/components/ImageUpload'
export default {
name: 'MahjongPrize',
components: { ImageUpload },
data() {
return { loading: false, list: [], dialogVisible: false, form: {}}
},
created() { this.getList() },
methods: {
getList() {
this.loading = true
prizeList().then(res => { this.list = (res.data && res.data.list) || []; this.loading = false }).catch(() => { this.loading = false })
},
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' },
// 品质配色与饰品品质一致:1金/2红/3紫/4蓝
gradeType(g) { const m = { 1: 'warning', 2: 'danger', 3: '', 4: '' }; return m[g] === undefined ? 'info' : m[g] },
gradeStyle(g) { return g === 3 ? { color: '#a16ee8', background: '#f7f1fe', borderColor: '#e4d5f9' } : null },
gradeLabel(g) { return ({ 1: '金', 2: '红', 3: '紫', 4: '蓝' })[g] || ('品质' + g) },
handleEdit(row) { this.form = { ...row }; this.dialogVisible = true },
submitForm() {
if (!this.form.name || !this.form.name.trim()) return this.$message.warning('名称不能为空')
prizeSave(this.form).then(() => { this.$message.success('保存成功'); this.dialogVisible = false; this.getList() })
}
}
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="app-container">
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="玩家ID">
<el-input v-model="queryParams.userId" placeholder="输入玩家ID" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="用户类型">
<el-select v-model="queryParams.audience" placeholder="全部" clearable style="width:130px" @change="handleQuery">
<el-option v-for="a in audiences" :key="a.key" :label="a.name" :value="a.key" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="userId" label="玩家ID" width="90" align="center" />
<el-table-column label="用户类型" width="90" align="center">
<template slot-scope="scope">
<el-tag :type="audienceTagType(scope.row.audience)" size="small">{{ audienceLabel(scope.row.audience) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="boxId" label="箱子" width="70" align="center" />
<el-table-column prop="prizeName" label="饰品名称" min-width="150" align="center" show-overflow-tooltip />
<el-table-column label="饰品图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.prizeImageUrl" :src="getImg(scope.row.prizeImageUrl)" style="width:40px;height:40px" fit="cover" />
</template>
</el-table-column>
<el-table-column prop="cost" label="花费" width="90" align="center" />
<el-table-column prop="settledWin" label="结算赢额" width="100" align="center" />
<el-table-column label="播放状态" width="110" align="center">
<template slot-scope="scope">
<el-tooltip :content="settleTips[scope.row.settleSource] || '历史值'" placement="top">
<el-tag :type="scope.row.settleSource === 'start' ? 'warning' : 'success'" size="small">
{{ settleLabels[scope.row.settleSource] || scope.row.settleSource || '—' }}
</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="createTime" label="时间" width="160" align="center" />
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getRecordList } from '@/api/game/mahjong/record/api'
export default {
name: 'MahjongRecord',
data() {
return {
loading: false, list: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, userId: null, audience: null },
// 身份 4 种:白号独立库存、不进真实经济
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
// 播放状态:start=开局即结算(奖已入背包)但动画没播完;与"结算赢额"不矛盾
settleLabels: { start: '未播完', finish: '已播完', reconnect: '重连补结', scheduled: '定时兜底', force_settle: '强制结算' },
settleTips: {
start: '开局就已结算:钱和饰品当场定下并直接进背包(防断网丢奖)。只是整局动画没播到末帧(玩家中途退出/断网),客户端战绩暂不展示,超 30 分钟兜底放行',
finish: '整局已播到末帧,客户端战绩正常展示(站内信也是此刻才发)'
}
}
},
created() { this.loadData() },
methods: {
audienceLabel(key) {
const a = this.audiences.find(x => x.key === key)
return a ? a.name : (key || '—')
},
audienceTagType(key) {
return { common: '', anchor: 'success', white: 'warning' }[key] || 'info'
},
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.userId) params.userId = this.queryParams.userId
if (this.queryParams.audience) params.audience = this.queryParams.audience
getRecordList(params).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, userId: null, audience: null }; this.loadData() },
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' }
}
}
</script>
@@ -0,0 +1,455 @@
<template>
<div class="app-container reward-pool-page">
<div class="page-head">
<div class="page-head-row">
<el-button type="text" size="mini" :icon="showGuide ? 'el-icon-arrow-up' : 'el-icon-question'" @click="showGuide = !showGuide">
{{ showGuide ? '收起说明' : '配置说明' }}
</el-button>
</div>
<p class="head-tip">
奖池按【箱子 × 身份】独立:每箱每身份一套水位 = 该身份箱内下注 − 箱内返奖 + 该箱该身份人工调整;开局按「(本箱该身份水位 + 本局花费) × 目标RTP」算赔付上限,只有预期赔付不超上限的回合才可被抽中。白号独立库存,水位仅统计与人工调整,不受上限约束(纯概率),不进真实经济。
</p>
<div class="audience-row">
<span class="aud-label">身份:</span>
<el-radio-group v-model="audience" size="small" @change="switchAudience">
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
</el-radio-group>
<span class="aud-tip">三个身份(普通/主播/白号)各自独立水位、独立注水/抽水、独立调整历史。</span>
</div>
<div v-show="showGuide" class="guide-panel">
<div class="guide-col">
<h5><i class="el-icon-info" /> 机制说明</h5>
<ul>
<li>每箱每身份独立水位 = 该身份箱内累计下注 − 累计返奖 + 人工调整;三个身份互不串池。</li>
<li>白号(audience=white)水位独立统计,但按设计不受水位约束,白号出奖=配比即爆率,注水/抽水只影响记录。</li>
<li>抽回合两阶段:固定阶段按分布抽取(确定性);轮末收敛阶段按实时RTP偏差定向选桶纠偏。</li>
<li>水位随下注升高、随返奖降低;顶部总池为当前身份全站汇总,仅展示不可调。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-setting" /> 如何配置</h5>
<ul>
<li>新箱上线或水位偏低:建议批量注水一轮花费的 60%~80%(接近目标RTP),相当于提前垫付整轮返奖预算,保证高倍桶全程可正常抽出。</li>
<li>日常运营小幅补水:比例 5%~20% 即可;某箱长期不出高倍时检查该箱水位,接近 0 或为负则单箱注水。</li>
<li>返奖偏高想收紧:批量或单箱抽水;水位为负时只会出最低倍,请谨慎大额抽水。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-operation" /> 批量调整规则</h5>
<ul>
<li>基数 = 该箱抽干一轮的总花费 = 赌注 × 总回合数。<b>本游戏无投注倍数</b>(price ≡ 赌注)。</li>
<li>每箱调整金额 = 基数 × 比例。例:赌注 1、1000 回合 → 基数 1000,注水 10% = +100。</li>
<li>比例支持 0~1000%;基数为 0 的箱子自动跳过;批量操作记一条汇总历史可审计(调整原因必填,会写进调整历史)。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-data-line" /> 两阶段RTP</h5>
<ul>
<li>本轮实时RTP / 已抽数反映当前轮收敛进度;一轮抽满 N 回合后自动重置账本开新轮。</li>
<li>收敛占比越高,轮末纠偏能力越强;本游戏只有普通旋转,单局封顶 500x。</li>
<li>总回合数 N 与收敛占比在「箱子配置 → 编辑」中配置。</li>
</ul>
</div>
</div>
</div>
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-blue"><i class="el-icon-coin" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总下注额</small><h3>{{ fmt(pool.totalBet) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-orange"><i class="el-icon-present" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总返奖额</small><h3>{{ fmt(pool.totalWin) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-cyan"><i class="el-icon-wallet" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总池余额</small><h3 :style="{color: Number(pool.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(pool.balance) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-green"><i class="el-icon-data-line" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·实时RTP</small><h3 :style="{color: rtpPct(pool.rtp) > 100 ? '#a4485a' : '#457450'}">{{ rtpPct(pool.rtp).toFixed(1) }}%</h3></div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="batch-card">
<div slot="header">
<b>批量调整(按一轮花费比例)</b>
<span class="sub-tip">身份【{{ audienceLabel(audience) }}】:基数 = 赌注 × 总回合数(随箱子配置动态变化,本游戏无投注倍数)</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<!-- 注:skin.scss 全局隐藏了 inline 表单的 label,所以字段名写成 span,别用 label 属性 -->
<el-form-item>
<span class="fld-label">调整比例</span>
<el-input-number v-model="batchForm.ratio" :min="0.01" :max="1000" :precision="2" :step="5" :controls="false" style="width:120px" placeholder="如 10" />
<span style="margin-left:4px">%</span>
</el-form-item>
<el-form-item>
<span class="fld-label">调整原因<span class="req">*</span></span>
<el-input v-model="batchForm.remark" maxlength="80" placeholder="必填,如:活动补水" style="width:200px" />
</el-form-item>
<el-form-item>
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 注水,预估共 +' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(1)">
<el-button slot="reference" type="success" plain icon="el-icon-top" :loading="batchLoading" :disabled="!batchForm.ratio">批量注水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 抽水,预估共 -' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(2)">
<el-button slot="reference" type="danger" plain icon="el-icon-bottom" :loading="batchLoading" :disabled="!batchForm.ratio">批量抽水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm :title="'重置将把身份【' + audienceLabel(audience) + '】所有箱子的注水/抽水累计值归 0,只保留真实水位(下注−返奖),确定?'" @confirm="submitReset">
<el-button slot="reference" type="warning" plain icon="el-icon-refresh-left" :loading="resetLoading">重置调整</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item v-if="batchForm.ratio">
<span class="est-text">预估总额:{{ fmt(estBatchTotal) }}({{ boxes.length }} 个箱子)</span>
</el-form-item>
</el-form>
</el-card>
<div class="section-head">
<h4 style="margin:0">箱子奖池 · 身份【{{ audienceLabel(audience) }}】</h4>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="loadConfig">刷新</el-button>
</div>
<el-table v-loading="boxLoading" :data="pagedBoxes" size="small" border stripe>
<el-table-column prop="boxId" label="ID" width="60" align="center" />
<el-table-column prop="name" label="箱子" min-width="140" align="center" show-overflow-tooltip />
<el-table-column label="赌注" width="80" align="center">
<template slot-scope="scope">{{ fmt(scope.row.bet) }}</template>
</el-table-column>
<el-table-column label="一轮花费(基数)" width="130" align="center">
<template slot-scope="scope">
<div>{{ fmt(scope.row.cycleCost) }}</div>
<div style="font-size:11px;color:#7a664e">{{ fmt(scope.row.costPerRound) }} × {{ scope.row.cycleRounds || '—' }}回合</div>
</template>
</el-table-column>
<el-table-column label="箱内下注" min-width="100" align="center">
<template slot-scope="scope">{{ fmt(scope.row.totalBet) }}</template>
</el-table-column>
<el-table-column label="箱内返奖" min-width="100" align="center">
<template slot-scope="scope">{{ fmt(scope.row.totalWin) }}</template>
</el-table-column>
<el-table-column label="人工调整" min-width="90" align="center">
<template slot-scope="scope">
<span :style="{color: signColor(scope.row.adjust)}">{{ fmt(scope.row.adjust) }}</span>
</template>
</el-table-column>
<el-table-column label="水位余额" min-width="100" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(scope.row.balance) }}</b>
</template>
</el-table-column>
<el-table-column label="累计RTP" width="90" align="center">
<template slot-scope="scope">
<span :style="{color: rtpPct(scope.row.rtp) > 100 ? '#a4485a' : '#457450'}">{{ scope.row.rtp == null ? '—' : rtpPct(scope.row.rtp).toFixed(1) + '%' }}</span>
</template>
</el-table-column>
<el-table-column label="本轮RTP/进度" min-width="120" align="center">
<template slot-scope="scope">
<div :style="{color: rtpPct(scope.row.cycleRtp) > 100 ? '#a4485a' : '#457450'}">{{ scope.row.cycleRtp == null ? '—' : rtpPct(scope.row.cycleRtp).toFixed(1) + '%' }}</div>
<div style="font-size:11px;color:#7a664e">{{ scope.row.cycleDrawn || 0 }} / {{ scope.row.totalRounds || '—' }} 回合</div>
</template>
</el-table-column>
<el-table-column label="收敛占比" width="80" align="center">
<template slot-scope="scope">{{ scope.row.convergeRatio == null ? '—' : (Number(scope.row.convergeRatio) * 100).toFixed(0) + '%' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template slot-scope="scope">
<el-tooltip content="注水(增加水位)" placement="top">
<el-button circle size="mini" type="success" plain icon="el-icon-top" @click="openAdjust(scope.row, 1)" />
</el-tooltip>
<el-tooltip content="抽水(降低水位)" placement="top">
<el-button circle size="mini" type="danger" plain icon="el-icon-bottom" @click="openAdjust(scope.row, 2)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:12px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="boxes.length"
:page-size.sync="boxSize"
:current-page.sync="boxPage"
:page-sizes="[10,20,30,50]"
/>
<el-dialog
:title="'调整水位【' + audienceLabel(audience) + '】- ' + (form.boxName || '')"
:visible.sync="dialogVisible"
width="440px"
append-to-body
>
<el-form label-width="90px" @submit.native.prevent>
<el-form-item label="调整类型">
<el-radio-group v-model="form.type">
<el-radio-button :label="1">注水(增加)</el-radio-button>
<el-radio-button :label="2">抽水(减少)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="当前水位">
<b :style="{color: Number(form.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(form.balance) }}</b>
</el-form-item>
<el-form-item label="调整金额">
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="100" :controls="false" style="width:100%" placeholder="请输入金额" />
</el-form-item>
<el-form-item v-if="form.amount" label="调整后水位">
<b :style="{color: afterBalance < 0 ? '#a4485a' : '#457450'}">{{ fmt(afterBalance) }}</b>
</el-form-item>
<el-form-item label="调整原因" required>
<el-input v-model="form.remark" maxlength="100" placeholder="必填,如:活动补水" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :type="form.type === 1 ? 'success' : 'danger'" :loading="submitting" @click="submit">确定{{ form.type === 1 ? '注水' : '抽水' }}</el-button>
</div>
</el-dialog>
<div class="section-head">
<h4 style="margin:0">调整历史 · 身份【{{ audienceLabel(audience) }}】</h4>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column prop="createTime" label="时间" min-width="150" align="center" />
<el-table-column label="身份" width="80" align="center">
<template slot-scope="scope"><el-tag size="mini">{{ audienceLabel(scope.row.audience) }}</el-tag></template>
</el-table-column>
<el-table-column prop="boxName" label="箱子" min-width="130" align="center" show-overflow-tooltip />
<el-table-column label="类型" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.type === 1 ? 'success' : (scope.row.type === 2 ? 'danger' : 'warning')" size="mini">
{{ scope.row.type === 1 ? '注水' : (scope.row.type === 2 ? '抽水' : '重置') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" min-width="110" align="center">
<template slot-scope="scope">
<span :style="{color: scope.row.type === 1 ? '#457450' : (scope.row.type === 2 ? '#a4485a' : '#8f6418')}">
{{ scope.row.type === 1 ? '+' : (scope.row.type === 2 ? '-' : '') }}{{ fmt(scope.row.amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="调整前余额" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.balanceBefore == null ? '—' : fmt(scope.row.balanceBefore) }}</template>
</el-table-column>
<el-table-column label="调整后余额" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.balanceAfter == null ? '—' : fmt(scope.row.balanceAfter) }}</template>
</el-table-column>
<el-table-column label="总池余额" min-width="110" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.poolBalance) < 0 ? '#a4485a' : '#9a5c35'}">{{ scope.row.poolBalance == null ? '—' : fmt(scope.row.poolBalance) }}</b>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="90" align="center" />
<el-table-column label="备注" min-width="160" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<span :style="{color: scope.row.remark ? '#4f4130' : '#8b755a'}">{{ scope.row.remark || '—' }}</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.size"
:current-page.sync="queryParams.page"
:page-sizes="[10,20,50]"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div>
</template>
<script>
import { getPoolConfig, adjustPool, adjustPoolBatch, adjustPoolReset, getPoolLogs } from '@/api/game/mahjong/rewardPool/api'
export default {
name: 'MahjongRewardPool',
data() {
return {
loading: false,
boxLoading: false,
submitting: false,
batchLoading: false,
resetLoading: false,
dialogVisible: false,
showGuide: false,
audience: 'common',
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
pool: { totalBet: 0, totalWin: 0, adjust: 0, balance: 0, rtp: 0 },
boxes: [],
list: [],
total: 0,
boxPage: 1,
boxSize: 10,
form: { boxId: undefined, boxName: '', balance: 0, type: 1, amount: undefined, remark: '' },
batchForm: { ratio: undefined, remark: '' },
queryParams: { page: 1, size: 10, audience: 'common' }
}
},
computed: {
pagedBoxes() {
const start = (this.boxPage - 1) * this.boxSize
return this.boxes.slice(start, start + this.boxSize)
},
estBatchTotal() {
const r = Number(this.batchForm.ratio) || 0
if (r <= 0) return 0
return this.boxes.reduce((s, b) => s + (Number(b.cycleCost) > 0 ? Number(b.cycleCost) * r / 100 : 0), 0)
},
afterBalance() {
const amt = Number(this.form.amount) || 0
return Number(this.form.balance) + (this.form.type === 2 ? -amt : amt)
}
},
created() {
this.loadConfig()
this.loadLogs()
},
methods: {
fmt(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toFixed(2)
},
rtpPct(v) {
const n = Number(v)
return isNaN(n) ? 0 : n * 100
},
signColor(v) {
const n = Number(v)
return n < 0 ? '#a4485a' : (n > 0 ? '#457450' : '#4f4130')
},
applyConfig(data) {
if (!data) return
this.pool = data.total || this.pool
this.boxes = data.boxes || []
},
audienceLabel(key) {
const a = this.audiences.find(x => x.key === key)
return a ? a.name : (key || '普通')
},
requiredRemark(v) {
if (v && v.trim()) return true
this.$message.warning('请填写调整原因(备注)')
return false
},
switchAudience() {
this.queryParams.audience = this.audience
this.queryParams.page = 1
this.loadConfig()
this.loadLogs()
},
loadConfig() {
this.boxLoading = true
getPoolConfig({ audience: this.audience }).then(res => {
this.applyConfig(res.data)
this.boxLoading = false
}).catch(() => { this.boxLoading = false })
},
loadLogs() {
this.loading = true
this.queryParams.audience = this.audience
getPoolLogs(this.queryParams).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
openAdjust(row, type) {
this.form = { boxId: row.boxId, boxName: row.name, audience: this.audience, balance: row.balance, type, amount: undefined, remark: '' }
this.dialogVisible = true
},
submit() {
if (!this.form.amount || this.form.amount <= 0) {
this.$message.warning('请输入大于 0 的调整金额')
return
}
if (!this.requiredRemark(this.form.remark)) return
this.submitting = true
adjustPool({ boxId: this.form.boxId, audience: this.audience, type: this.form.type, amount: this.form.amount, remark: this.form.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success((this.form.type === 1 ? '注水' : '抽水') + '成功')
this.dialogVisible = false
this.queryParams.page = 1
this.loadLogs()
this.submitting = false
}).catch(() => { this.submitting = false })
},
submitBatch(type) {
if (!this.batchForm.ratio || this.batchForm.ratio <= 0) {
this.$message.warning('请输入大于 0 的调整比例')
return
}
if (!this.requiredRemark(this.batchForm.remark)) return
this.batchLoading = true
adjustPoolBatch({ audience: this.audience, type, ratio: this.batchForm.ratio, remark: this.batchForm.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success(`批量${type === 1 ? '注水' : '抽水'}完成:${res.data.adjusted} 个箱子,共 ${this.fmt(res.data.totalAmount)}`)
this.batchForm.ratio = undefined
this.batchForm.remark = ''
this.queryParams.page = 1
this.loadLogs()
this.batchLoading = false
}).catch(() => { this.batchLoading = false })
},
submitReset() {
if (!this.requiredRemark(this.batchForm.remark)) return
this.resetLoading = true
adjustPoolReset({ audience: this.audience, remark: this.batchForm.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success(`重置完成:${res.data.cleared} 个箱子的人工调整已归零(清除净调整 ${this.fmt(res.data.totalCleared)})`)
this.batchForm.remark = ''
this.queryParams.page = 1
this.loadLogs()
this.resetLoading = false
}).catch(() => { this.resetLoading = false })
}
}
}
</script>
<style scoped>
.page-head { margin-bottom: 14px; }
.page-head-row { display: flex; align-items: center; justify-content: space-between; }
.head-tip { color: #7a664e; font-size: 12px; margin: 6px 0 0; }
.audience-row { display: flex; align-items: center; margin-top: 10px; }
.aud-label { color: #5f4f3d; font-size: 13px; margin-right: 6px; }
.aud-tip { color: #7a664e; font-size: 12px; margin-left: 10px; }
.guide-panel { display: flex; gap: 16px; background: #f6efe3; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
.guide-col { flex: 1; min-width: 0; }
.guide-col h5 { margin: 0 0 8px; font-size: 13px; color: #4f4130; }
.guide-col h5 i { color: #9a5c35; margin-right: 4px; }
.guide-col ul { margin: 0; padding-left: 16px; }
.guide-col li { font-size: 12px; color: #5f4f3d; line-height: 1.9; }
.stat-row { margin-bottom: 16px; }
/* 统计卡图标:与侧栏一级菜单共用 cottagecore.scss 的 $cc-icon-hues 4 色
(陶土 / 草绿 / 雏菊金 / 玫瑰粉),底一律 $cc-tint-3。
原先第 3 个是冷青 #4a7f8c,在暖底上是唯一的冷点。 */
.stat-card { display: flex; align-items: center; gap: 12px; background: #fffdfa; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(139,115,85,.10); }
.sc-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.sc-blue { background: #fbf5ea; color: #9a5c35; }
.sc-orange { background: #fbf5ea; color: #a8791f; }
.sc-cyan { background: #fbf5ea; color: #b57474; }
.sc-green { background: #fbf5ea; color: #457450; }
.sc-body small { color: #7a664e; display: block; }
.sc-body h3 { margin: 2px 0 0; font-size: 18px; }
.batch-card { margin-bottom: 16px; border-radius: 8px; }
.batch-card .sub-tip { color: #7a664e; font-size: 12px; margin-left: 10px; }
.fld-label { color: #5f4f3d; font-size: 13px; margin-right: 6px; }
.fld-label .req { color: #a4485a; margin-left: 2px; }
.batch-card >>> .el-form-item { margin-bottom: 0; }
.est-text { color: #8f6418; font-size: 13px; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
</style>
@@ -0,0 +1,82 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:12px">
预生成回合池(只读):池里存的是倍率,与赌注无关。<b>actualMul</b> 为本局实际总倍率(已按 500x 封顶);
<b>hasFs</b>=是否自然触发免费旋转;<b>nearMiss</b>=悬念盘(基础旋恰好 2 个胡且第 5 轴无胡)。
</el-alert>
<el-form :inline="true" :model="queryParams" class="demo-form-inline">
<el-form-item label="回合ID">
<el-input v-model="queryParams.roundId" placeholder="输入回合ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="roundList" border style="width:100%">
<el-table-column prop="roundId" label="回合ID" width="100" align="center" />
<el-table-column prop="boxId" label="箱子ID" width="80" align="center" />
<el-table-column prop="bucketId" label="饰品桶" width="80" align="center" />
<el-table-column prop="actualMul" label="返奖倍率" width="100" align="center" />
<el-table-column prop="freeSpinCount" label="免费旋转" width="90" align="center" />
<el-table-column prop="totalRotations" label="总波数" width="90" align="center" />
<el-table-column label="含FS" width="70" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.hasFs === 1" type="success" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column label="悬念盘" width="80" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.nearMiss === 1" type="warning" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="生成时间" min-width="160" align="center" />
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50,100]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getRoundList } from '@/api/game/mahjong/round/api'
export default {
name: 'MahjongRound',
data() {
return {
loading: false, roundList: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, roundId: null, boxType: null }
}
},
created() { this.loadData() },
methods: {
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.roundId) params.roundId = this.queryParams.roundId
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
getRoundList(params).then(res => {
this.roundList = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, roundId: null, boxType: null }; this.loadData() }
}
}
</script>
@@ -129,70 +129,6 @@ export default {
};
</script>
<style lang="scss" scoped>
::v-deep .el-switch {
border-radius: 0px !important;
height: 24px !important;
}
::v-deep .el-switch__core {
width: 70px !important;
height: 24px;
border-radius: 3px;
border: none;
}
::v-deep .el-switch__core::after {
width: 50%;
height: 24px;
border-radius: 0px;
top: 0px !important;
right: 0px !important;
left: 0px;
border: 1px solid #ccc;
border-radius: 3px;
}
::v-deep .el-switch.is-checked .el-switch__core::after {
margin-left: -35px;
box-sizing: border-box;
top: 0px !important;
right: 0px !important;
border: 1px solid #ccc;
border-radius: 3px;
text-align: center;
}
/*关闭时文字位置设置*/
::v-deep .el-switch__label--right {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
right: 6px;
margin-left: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
/* 激活时另一个文字消失 */
::v-deep .el-switch__label.is-active {
text-align: center;
display: none;
}
/*开启时文字位置设置*/
::v-deep .el-switch__label--left {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
left: 5px;
margin-right: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
#main {
width: 500px;
height: 400px;
@@ -216,79 +216,6 @@ export default {
};
</script>
<style lang="scss" scoped>
::v-deep .el-switch {
border-radius: 0px !important;
height: 24px !important;
}
::v-deep .el-switch__core {
width: 70px !important;
height: 24px;
border-radius: 3px;
border: none;
}
::v-deep .el-switch__core::after {
width: 50%;
height: 24px;
border-radius: 0px;
top: 0px !important;
right: 0px !important;
left: 0px;
border: 1px solid #ccc;
border-radius: 3px;
}
::v-deep .el-switch.is-checked .el-switch__core::after {
margin-left: -35px;
box-sizing: border-box;
top: 0px !important;
right: 0px !important;
border: 1px solid #ccc;
border-radius: 3px;
text-align: center;
}
/*关闭时文字位置设置*/
::v-deep .el-switch__label--right {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
right: 6px;
margin-left: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
/* 激活时另一个文字消失 */
::v-deep .el-switch__label.is-active {
text-align: center;
display: none;
}
/*开启时文字位置设置*/
::v-deep .el-switch__label--left {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
left: 5px;
margin-right: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
#main {
width: 500px;
height: 400px;
@@ -263,79 +263,6 @@ export default {
};
</script>
<style lang="scss" scoped>
::v-deep .el-switch {
border-radius: 0px !important;
height: 24px !important;
}
::v-deep .el-switch__core {
width: 70px !important;
height: 24px;
border-radius: 3px;
border: none;
}
::v-deep .el-switch__core::after {
width: 50%;
height: 24px;
border-radius: 0px;
top: 0px !important;
right: 0px !important;
left: 0px;
border: 1px solid #ccc;
border-radius: 3px;
}
::v-deep .el-switch.is-checked .el-switch__core::after {
margin-left: -35px;
box-sizing: border-box;
top: 0px !important;
right: 0px !important;
border: 1px solid #ccc;
border-radius: 3px;
text-align: center;
}
/*关闭时文字位置设置*/
::v-deep .el-switch__label--right {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
right: 6px;
margin-left: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
/* 激活时另一个文字消失 */
::v-deep .el-switch__label.is-active {
text-align: center;
display: none;
}
/*开启时文字位置设置*/
::v-deep .el-switch__label--left {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
left: 5px;
margin-right: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
#main {
width: 500px;
height: 400px;
@@ -369,70 +369,6 @@ export default {
};
</script>
<style lang="scss" scoped>
::v-deep .el-switch {
border-radius: 0px !important;
height: 24px !important;
}
::v-deep .el-switch__core {
width: 70px !important;
height: 24px;
border-radius: 3px;
border: none;
}
::v-deep .el-switch__core::after {
width: 50%;
height: 24px;
border-radius: 0px;
top: 0px !important;
right: 0px !important;
left: 0px;
border: 1px solid #ccc;
border-radius: 3px;
}
::v-deep .el-switch.is-checked .el-switch__core::after {
margin-left: -35px;
box-sizing: border-box;
top: 0px !important;
right: 0px !important;
border: 1px solid #ccc;
border-radius: 3px;
text-align: center;
}
/*关闭时文字位置设置*/
::v-deep .el-switch__label--right {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
right: 6px;
margin-left: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
/* 激活时另一个文字消失 */
::v-deep .el-switch__label.is-active {
text-align: center;
display: none;
}
/*开启时文字位置设置*/
::v-deep .el-switch__label--left {
text-align: center;
position: absolute;
width: 40%;
z-index: 1;
left: 5px;
margin-right: 0px;
color: rgba(255, 255, 255, 1);
span {
font-size: 13px;
box-sizing: border-box;
color: #fff;
}
}
#main {
width: 500px;
height: 400px;
@@ -205,7 +205,7 @@
<div style="margin-bottom: 6px;">
<strong>单局奖池投放比例 (bufferRatio = {{ config.bufferRatio }})</strong>:一局最多从奖池拿出 <span style="color: #8f6418;">{{ bufferRatioPct }} 的余额</span> 来发奖
</div>
<div style="padding-left: 16px; color: #606266; font-size: 12px; margin-bottom: 8px;">
<div style="padding-left: 16px; color: #5f4f3d; font-size: 12px; margin-bottom: 8px;">
• 例:奖池余额 10000 元 → 本项上限 10000 × {{ config.bufferRatio }} = <strong>{{ poolCapDemo }} 元</strong><br>
• 奖池 = 累计真实下注 − 累计真实返奖(机器人、白号不计)<br>
• 口径提醒:这里填的是"最多拿出多少",不是"保留多少"——{{ config.bufferRatio }} = 最多拿出 {{ bufferRatioPct }},剩下 {{ bufferKeepPct }} 留在池里
@@ -214,7 +214,7 @@
<div style="margin-bottom: 6px;">
<strong>目标RTP (rtp = {{ config.rtp }},累计口径)</strong>:按"总共该发多少 − 已经发了多少"算出<span style="color: #8f6418;">还能发多少</span>,这就是本局的返奖预算(是发奖上限,不是平台欠玩家钱)
</div>
<div style="padding-left: 16px; color: #606266; font-size: 12px; margin-bottom: 8px;">
<div style="padding-left: 16px; color: #5f4f3d; font-size: 12px; margin-bottom: 8px;">
• 本局可发额度 = 累计真实投注 × {{ rtpPct }} − 累计真实返奖;<br>
• 例:累计投注 10 万、累计返奖 7 万 → 该发 {{ rtpDemo.should }} 万、已发 7 万 → 本局额度 <strong>{{ rtpDemo.room }} 万</strong><br>
• 它只是"这局最多能发多少":实际发多少仍由转盘概率决定,没发完的额度继续攒到以后(所以池子厚了能一局爆大奖)。本局投注在封盘时已计入累计,历史刚好发到 {{ rtpPct }} 时,额度正好 = 本局投注 × {{ rtpPct }}<br>
@@ -222,8 +222,8 @@
• 实际返奖率请以首页「实时RTP」为准,不要拿本配置值当预期
</div>
<div style="padding: 8px; background: #F4F4F5; border-radius: 4px; margin-top: 8px;">
<strong style="color: #303133;">协同机制</strong>:发奖上限 = <span style="color: #9a5c35;">min(奖池余额×{{ config.bufferRatio }}, 本局可发额度)</span>,两条都要满足<br>
<div style="padding: 8px; background: #f3ece0; border-radius: 4px; margin-top: 8px;">
<strong style="color: #4f4130;">协同机制</strong>:发奖上限 = <span style="color: #9a5c35;">min(奖池余额×{{ config.bufferRatio }}, 本局可发额度)</span>,两条都要满足<br>
<span style="color: #7a664e; font-size: 12px;">
• 谁小听谁的:池子不够厚 → 按奖池的 {{ bufferRatioPct }} 卡;池子够厚 → 按返奖额度卡<br>
• 额度够时,单局返奖可以远超本局投注("池子厚就能爆大奖")<br>
@@ -353,6 +353,8 @@ export default {
},
syncMultiplierTable() {
const multipliers = this.config.multipliers || [];
// 这里的 color 必须与后端契约一致(gold/red/purple),也是 cellCounts() 的 key:
// 写成颜色值(如 #a4485a)会同时导致标签显示成色号、红色那一行算不到盘面格数(整行倍率被跳过)
const colors = ['gold', 'red', 'purple'];
const symbols = ['A', 'B', 'C', 'D'];
this.multiplierTable = colors.map(color => {
@@ -9,7 +9,7 @@
</el-table-column>
<el-table-column label="倍率预览">
<template slot-scope="scope">
<div v-for="color in ['gold', 'red', 'purple']" :key="color" style="display: inline-block; margin-right: 16px;">
<div v-for="color in ['gold', '#a4485a', 'purple']" :key="color" style="display: inline-block; margin-right: 16px;">
<el-tag :type="colorTagType(color)" size="mini">{{ colorLabel(color) }}</el-tag>
<span v-for="sym in ['A', 'B', 'C', 'D']" :key="sym" style="margin-left: 6px; font-size: 12px;">
{{ sym }}:<b>{{ getMultiplier(scope.row, color, sym) }}</b>
@@ -124,7 +124,7 @@ export default {
},
computed: {
matrixByColor() {
const colors = ['gold', 'red', 'purple'];
const colors = ['gold', '#a4485a', 'purple'];
return colors.map(color => ({
color,
items: this.currentMatrixList.filter(i => i.color === color)
@@ -156,7 +156,7 @@ export default {
openConfig(row) {
this.currentBetAmount = row.betAmount;
// 确保12条记录存在(即使数据库为空也生成骨架)
const colors = ['gold', 'red', 'purple'];
const colors = ['gold', '#a4485a', 'purple'];
const symbols = ['A', 'B', 'C', 'D'];
const list = [];
colors.forEach(color => {
@@ -23,7 +23,7 @@
<!-- 回合结果优化展示 -->
<div style="margin-bottom: 12px; line-height: 2;">
<div style="margin-bottom: 8px;">
<strong style="color: #303133;">回合结果:</strong>
<strong style="color: #4f4130;">回合结果:</strong>
<el-tag v-if="scope.row.result != null" type="primary" size="medium" style="font-size: 16px; font-weight: bold;">
{{ scope.row.result }}
</el-tag>
@@ -33,7 +33,7 @@
</div>
<!-- 外圈符号序列(高亮第result格) -->
<div style="margin-bottom: 6px;">
<strong style="color: #606266;">外圈符号:</strong>
<strong style="color: #5f4f3d;">外圈符号:</strong>
<span v-if="scope.row.symbols" style="font-family: 'Courier New', monospace; font-size: 14px;">
<span
v-for="(sym, idx) in scope.row.symbols.split('')"
@@ -45,7 +45,7 @@
fontWeight: idx == scope.row.result ? 'bold' : 'normal',
fontSize: idx == scope.row.result ? '16px' : '14px',
color: idx == scope.row.result ? '#8f6418' : '#7a664e',
backgroundColor: idx == scope.row.result ? '#FDF6EC' : 'transparent',
backgroundColor: idx == scope.row.result ? '#fbf5ea' : 'transparent',
padding: idx == scope.row.result ? '3px 0' : '0',
borderRadius: '3px',
border: idx == scope.row.result ? '2px solid #8f6418' : 'none',
@@ -56,7 +56,7 @@
</div>
<!-- 内圈颜色序列(高亮第result格) -->
<div>
<strong style="color: #606266;">内圈颜色:</strong>
<strong style="color: #5f4f3d;">内圈颜色:</strong>
<span v-if="scope.row.innerColors" style="font-family: 'Courier New', monospace; font-size: 14px;">
<span
v-for="(color, idx) in formatInnerColors(scope.row.innerColors).split('')"
@@ -67,7 +67,7 @@
textAlign: 'center',
fontWeight: idx == scope.row.result ? 'bold' : 'normal',
fontSize: idx == scope.row.result ? '16px' : '14px',
color: idx == scope.row.result ? getHighlightColor(color) : '#C0C4CC',
color: idx == scope.row.result ? getHighlightColor(color) : '#8b755a',
backgroundColor: idx == scope.row.result ? getHighlightBgColor(color) : 'transparent',
padding: idx == scope.row.result ? '3px 0' : '0',
borderRadius: '3px',
@@ -106,7 +106,10 @@
<el-tag v-if="scope.row.poolTypes" :type="poolTagType(scope.row.poolTypes)" size="mini">{{ poolLabel(scope.row.poolTypes) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="betSymbols" label="投注符号" width="140" align="center" />
<!-- ★ min-width 而不是 width:展开列(48)+其余 11 列原全为固定宽,合计 1368px,
没有弹性列时表格宽度恒等于列宽之和(table-layout.js:182-194),1920 视口下右侧空 216px。
投注符号是拼接字符串、长度波动最大,交给它吃剩余宽度最合适。 -->
<el-table-column prop="betSymbols" label="投注符号" min-width="140" align="center" />
<el-table-column prop="totalBetAmount" label="总下注" width="100" align="right" />
<el-table-column prop="userRoundPayout" label="回合实际赔付" width="120" align="right">
<template slot-scope="scope">
@@ -220,9 +223,10 @@ export default {
const map = { '金': '#8f6418', '红': '#a4485a', '紫': '#7a664e' };
return map[color] || '#9a5c35';
},
/** 高亮背景色(用于中奖格背景) */
/** 高亮背景色(用于中奖格背景)
取值是 cottagecore.scss 的 $cc-tint-3 / $cc-blossom-tint / $cc-linen */
getHighlightBgColor(color) {
const map = { '金': '#FDF6EC', '红': '#FEF0F0', '紫': '#F4F4F5' };
const map = { '金': '#fbf5ea', '红': '#f7e9e6', '紫': '#f3ece0' };
return map[color] || '#fbf5ea';
}
}
+366 -186
View File
@@ -16,87 +16,133 @@
>刷新数据</el-button>
</div>
<!-- 统计卡片网格 -->
<div class="stat-grid">
<div
v-for="card in statCards"
:key="card.key"
class="stat-card"
:class="{ clickable: !!card.route }"
@click="card.route && handto(card.route)"
>
<div class="stat-icon" :class="'theme-' + card.theme">
<i :class="card.icon"></i>
</div>
<div class="stat-info">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">
{{ card.label }}
<el-tooltip v-if="card.note" :content="card.note" placement="top">
<i class="el-icon-info stat-note-icon"></i>
</el-tooltip>
</div>
</div>
<i v-if="card.route" class="el-icon-arrow-right stat-arrow"></i>
</div>
</div>
<!-- 主体:左窄轨 + 右侧主区 -->
<div class="home-body">
<!-- 左窄轨:核心指标 + 待办 -->
<div class="rail">
<div class="rail-title">核心指标</div>
<!-- 实时 RTP -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<span class="title-bar"></span>
<span>实时 RTP</span>
</div>
<span class="section-sub">每 30 秒自动刷新</span>
</div>
<div class="rtp-grid">
<div
v-for="(item, idx) in visibleRtpList"
:key="item.name || idx"
class="rtp-card"
:class="'rtp-theme-' + (idx % 5)"
>
<div class="rtp-name">
<span class="rtp-dot"></span>
{{ item.name }}
</div>
<div class="rtp-value">
{{ item.rtp !== null && item.rtp !== undefined ? item.rtp.toFixed(2) + '%' : '--' }}
</div>
<div class="rtp-progress">
<div class="rtp-progress-inner" :style="{ width: Math.min(Number(item.rtp) || 0, 100) + '%' }"></div>
</div>
<div class="rtp-rows">
<div class="rtp-row">
<span>投注</span>
<span>¥{{ item.bet !== null && item.bet !== undefined ? Number(item.bet).toFixed(2) : '--' }}</span>
<div class="rail-metrics">
<div
v-for="m in railMetrics"
:key="m.key"
class="rail-metric"
:class="{ clickable: !!m.route }"
@click="m.route && handto(m.route)"
>
<div class="rail-metric-label">
<span>{{ m.label }}</span>
<el-tooltip v-if="m.note" :content="m.note" placement="top">
<i class="el-icon-info rail-note"></i>
</el-tooltip>
</div>
<div class="rtp-row">
<span>奖励</span>
<span>¥{{ item.reward !== null && item.reward !== undefined ? Number(item.reward).toFixed(2) : '--' }}</span>
<div class="rail-metric-value">{{ m.value }}</div>
<i v-if="m.route" class="el-icon-arrow-right rail-arrow"></i>
</div>
</div>
<div class="rail-todo">
<div
class="rail-todo-item clickable"
:class="{ alert: totaldeliver > 0 }"
@click="handto(3)"
>
<span class="rail-todo-label">待处理提货申请</span>
<span class="rail-todo-value">{{ totaldeliver === null ? '--' : totaldeliver }}</span>
</div>
<div class="rail-todo-item">
<span class="rail-todo-label">提货余额</span>
<span class="rail-todo-value">¥{{ balance }}</span>
</div>
</div>
</div>
<!-- 右侧主区 -->
<div class="main-col">
<!-- 实时 RTP -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<span class="title-bar"></span>
<span>实时 RTP</span>
</div>
<span class="section-sub">每 30 秒自动刷新</span>
</div>
<div class="rtp-grid">
<div
v-for="(item, idx) in visibleRtpList"
:key="item.name || idx"
class="rtp-card"
:class="'rtp-theme-' + (idx % 5)"
>
<div class="rtp-name">
<span class="rtp-dot"></span>
{{ item.name }}
</div>
<div class="rtp-value">
{{ item.rtp !== null && item.rtp !== undefined ? item.rtp.toFixed(2) + '%' : '--' }}
</div>
<div class="rtp-progress">
<div class="rtp-progress-inner" :style="{ width: Math.min(Number(item.rtp) || 0, 100) + '%' }"></div>
</div>
<div class="rtp-rows">
<div class="rtp-row">
<span>投注</span>
<span>¥{{ item.bet !== null && item.bet !== undefined ? Number(item.bet).toFixed(2) : '--' }}</span>
</div>
<div class="rtp-row">
<span>奖励</span>
<span>¥{{ item.reward !== null && item.reward !== undefined ? Number(item.reward).toFixed(2) : '--' }}</span>
</div>
</div>
</div>
<div v-if="!visibleRtpList.length" class="rtp-empty">暂无 RTP 数据</div>
</div>
</div>
<!-- 经营数据(次级指标带)—— 放在图表上方:图表最高,放最后滚到底正好 -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<span class="title-bar"></span>
<span>经营数据</span>
</div>
</div>
<div class="mini-stats">
<div v-for="s in miniStats" :key="s.key" class="mini-stat">
<div class="mini-icon" :class="'theme-' + s.theme">
<i :class="s.icon"></i>
</div>
<div class="mini-info">
<div class="mini-label">
<span>{{ s.label }}</span>
<el-tooltip v-if="s.note" :content="s.note" placement="top">
<i class="el-icon-info mini-note"></i>
</el-tooltip>
</div>
<div class="mini-value">{{ s.value }}</div>
</div>
</div>
</div>
</div>
<div v-if="!visibleRtpList.length" class="rtp-empty">暂无 RTP 数据</div>
</div>
</div>
<!-- 流水统计图表 -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<span class="title-bar"></span>
<span>流水统计</span>
<span class="section-sub chart-sub">{{ chartTypeLabel }}</span>
</div>
<div class="chart-tabs">
<span :class="['tab-item', chartType === 'hour' ? 'active' : '']" @click="switchChartType('hour')">小时</span>
<span :class="['tab-item', chartType === 'day' ? 'active' : '']" @click="switchChartType('day')">天</span>
<span :class="['tab-item', chartType === 'week' ? 'active' : '']" @click="switchChartType('week')">周</span>
<!-- 流水统计图表 -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<span class="title-bar"></span>
<span>流水统计</span>
<span class="section-sub chart-sub">{{ chartTypeLabel }}</span>
</div>
<div class="chart-tabs">
<span :class="['tab-item', chartType === 'hour' ? 'active' : '']" @click="switchChartType('hour')">小时</span>
<span :class="['tab-item', chartType === 'day' ? 'active' : '']" @click="switchChartType('day')">天</span>
<span :class="['tab-item', chartType === 'week' ? 'active' : '']" @click="switchChartType('week')">周</span>
</div>
</div>
<div id="lineChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
<div id="lineChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
</template>
@@ -145,25 +191,49 @@ export default {
};
},
computed: {
// 隐藏极速糖果、Roll 房、荒野争锋卡片
// 隐藏已下架玩法的 RTP 卡片。按后端返回的 name 精确匹配。
// 2026-09-22:「玫瑰女郎」(= 后端 olympus 玩法)已下架并隐藏;
// 新游戏「麻将胡了」不在隐藏列表里,正常展示。
visibleRtpList() {
const hidden = ["极速糖果", "Roll 房", "Roll房", "荒野争锋"];
const hidden = ["极速糖果", "Roll 房", "Roll房", "荒野争锋", "玫瑰女郎"];
return this.rtpList.filter(item => !hidden.includes(item.name));
},
chartTypeLabel() {
return this.chartType === "hour" ? "今日(按小时)" : this.chartType === "week" ? "近 4 周" : "近 7 天";
},
statCards() {
/* 左窄轨:只放「平台体量」三项,给足字号做层级;
其余指标下沉到右侧主区底部的「经营数据」带。 */
railMetrics() {
return [
{
key: "online",
label: "在线用户",
note: "当前在线的用户数,点击可查看在线用户列表",
value: this.total === null ? "--" : this.total,
route: 1
},
{
key: "totalRecharge",
label: "总充值",
note: "历史累计充值总额(排除无效用户)",
value: "¥" + this.totalRecord,
route: 2
},
{
key: "totalFlow",
label: "总流水",
note: "历史累计全部游戏投注消费总额(已排除无效用户)",
value: "¥" + Number(this.totalFlow).toFixed(2)
}
];
},
/* 右侧主区底部:其余 6 项,紧凑成一条指标带 */
miniStats() {
return [
{ key: "online", label: "在线用户", note: "当前在线的用户数,点击可查看在线用户列表", value: this.total === null ? "--" : this.total, icon: "el-icon-user-solid", theme: "blue", route: 1 },
{ key: "totalRecharge", label: "总充值", note: "历史累计充值总额(排除无效用户)", value: "¥" + this.totalRecord, icon: "el-icon-s-order", theme: "orange", route: 2 },
{ key: "deliver", label: "待处理提货申请", note: "待审核处理的提货申请数量,点击可前往处理", value: this.totaldeliver === null ? "--" : this.totaldeliver, icon: "el-icon-shopping-cart-full", theme: "green", route: 3 },
{ key: "balance", label: "提货余额", note: "第三方发货平台账户余额,用于采购发货", value: "$" + this.balance, icon: "el-icon-wallet", theme: "red" },
{ key: "todayRecharge", label: "今日总充值", note: "今日0点至今的充值总额(已排除无效用户)", value: "¥" + this.todayRecharge, icon: "el-icon-money", theme: "purple" },
{ key: "todayBet", label: "今日总投注", note: "今日全部游戏的投注消费总额(已排除无效用户)", value: "¥" + this.todayBet, icon: "el-icon-coin", theme: "cyan" },
{ key: "todayReward", label: "今日总返奖", note: "今日全游戏产出饰品总价值(按背包产出统计,含已分解)", value: "¥" + this.todayReward, icon: "el-icon-present", theme: "pink" },
{ key: "deliveryAmount", label: "总发货金额", note: "已完成发货订单的采购价合计(已排除无效用户)", value: "¥" + Number(this.totalDeliveryAmount).toFixed(2), icon: "el-icon-box", theme: "amber" },
{ key: "totalFlow", label: "总流水", note: "历史累计全部游戏投注消费总额(已排除无效用户)", value: "¥" + Number(this.totalFlow).toFixed(2), icon: "el-icon-data-line", theme: "indigo" },
{ key: "historyReward", label: "历史总返奖", note: "全游戏产出饰品总价值(按背包产出统计,含已分解)", value: "¥" + Number(this.historyReward).toFixed(2), icon: "el-icon-trophy", theme: "teal" },
{ key: "rollRoom", label: "Roll 房今日返奖", note: "不含下注(仅饰品产出)", value: "¥" + Number(this.rollRoomReward || 0).toFixed(2), icon: "el-icon-house", theme: "sky" }
];
@@ -311,7 +381,7 @@ export default {
getChartDataList() {
return getChartData(this.chartType).then(res => {
const data = res.data;
this.drawLineChart(data.dateList, data.rechargeList, data.boxOpenList, data.fightBoxOpenList, data.luckyUpgradeList, data.westernDuelList, data.wheelList, data.roseladyList);
this.drawLineChart(data.dateList, data.rechargeList, data.boxOpenList, data.fightBoxOpenList, data.luckyUpgradeList, data.westernDuelList, data.wheelList, data.roseladyList, data.mahjongList);
});
},
switchChartType(type) {
@@ -337,7 +407,7 @@ export default {
this.rollRoomReward = 0;
});
},
drawLineChart(dateList, rechargeList, boxOpenList, fightBoxOpenList, luckyUpgradeList, westernDuelList, wheelList, roseladyList) {
drawLineChart(dateList, rechargeList, boxOpenList, fightBoxOpenList, luckyUpgradeList, westernDuelList, wheelList, roseladyList, mahjongList) {
var chartDom = document.getElementById("lineChart");
if (this.lineChartInstance) {
this.lineChartInstance.dispose();
@@ -354,7 +424,10 @@ export default {
{ name: "对战开箱流水", data: fightBoxOpenList, color: "#4a9163" },
{ name: "幸运升级流水", data: luckyUpgradeList, color: "#4a7f8c" },
{ name: "疯狂转盘流水", data: wheelList, color: "#7a6a9c" },
{ name: "玫瑰女郎流水", data: roseladyList, color: "#a35790" }
// 麻将胡了流水:沿用原玫瑰女郎的品红 #a35790(色相与前 5 条继续错开,不撞色)
{ name: "麻将胡了流水", data: mahjongList, color: "#a35790" }
// 玫瑰女郎流水(= 后端 olympus 玩法)已随玩法下架移除,2026-09-22;
// roseladyList 参数仍由 getChartData 传入但不再使用(与 westernDuelList 写法一致,方便将来一行恢复)。
];
var option = {
tooltip: {
@@ -475,107 +548,95 @@ export default {
}
}
/* ========== 统计卡片网格 ========== */
.stat-grid {
/* ========== 主体两栏:左窄轨 + 右主区 ========== */
.home-body {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
grid-template-columns: 300px minmax(0, 1fr);
gap: 20px;
/* start:左轨按内容高度,不跟着右列拉长(否则底部空一大截) */
align-items: start;
@media (max-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1000px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
@media (max-width: 1200px) {
grid-template-columns: minmax(0, 1fr);
}
}
.stat-card {
position: relative;
display: flex;
align-items: center;
/* ========== 左窄轨 ========== */
.rail {
/* 右列很长,左轨跟着滚,随时能看到核心数字 */
position: sticky;
top: 16px;
padding: 20px;
background: #fffdfa;
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 14px rgba(139, 115, 85, 0.10);
border: 1px solid rgba(212, 160, 160, 0.55);
transition:
transform 0.5s ease-in-out,
box-shadow 0.5s ease-in-out,
border-color 0.5s ease-in-out;
overflow: hidden;
&:hover {
/* 规范:悬停用「小幅放大 + ≤1deg 微旋」,避免机械式直上直下 */
transform: scale(1.015) rotate(-0.3deg);
border-color: #c08a8a;
box-shadow: 0 10px 26px rgba(139, 115, 85, 0.16);
@media (max-width: 1200px) {
position: static;
}
}
.stat-icon {
transform: rotate(-1deg) scale(1.04);
}
.stat-arrow {
opacity: 1;
transform: translateX(0);
}
.rail-title {
font-size: 13px;
font-weight: 600;
color: #7a664e;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.rail-metric {
position: relative;
padding: 16px 0;
border-top: 1px solid #f3ece0;
&:first-child {
border-top: none;
padding-top: 10px;
}
&.clickable {
cursor: pointer;
}
}
.stat-icon {
flex-shrink: 0;
width: 54px;
height: 54px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
/* 底色与图标色由 .theme-* 提供:浅底 + 同色深图标(水彩感),
不再是一格一色的高饱和实心块 */
background: rgba(154, 92, 53, 0.22);
transition: transform 0.5s ease-in-out;
i {
font-size: 26px;
color: #9a5c35;
}
}
.stat-info {
min-width: 0;
.stat-value {
font-size: 24px;
font-weight: 700;
color: #4f4130;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
margin-top: 4px;
font-size: 13px;
color: #7a664e;
.stat-note-icon {
margin-left: 4px;
color: #7a664e;
cursor: help;
&.clickable:hover {
.rail-metric-value {
color: #9a5c35;
}
.rail-arrow {
opacity: 1;
transform: translateX(0);
}
}
}
.stat-arrow {
.rail-metric-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #7a664e;
.rail-note {
cursor: help;
}
}
.rail-metric-value {
margin-top: 6px;
font-size: 28px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.4px;
color: #4f4130;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-arrow {
position: absolute;
right: 16px;
right: 0;
top: 50%;
margin-top: -7px;
font-size: 14px;
@@ -585,11 +646,74 @@ export default {
transition: all 0.25s ease;
}
/* 统计卡图标配色
11 个卡片只用 5 个田园色(陶土 / 草绿 / 苔绿 / 雏菊金 / 玫瑰粉),循环使用。
浅底 + 同色深图标。
tint 从 .14~.18 提到 .22~.26:原来太淡,小图标几乎"看不见"(用户反馈对比度不够)。
类名(blue/orange/...)只是 card.theme 的旧键名,沿用不改,免得动模板。 */
/* ========== 左轨待办区 ========== */
.rail-todo {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
padding-top: 16px;
border-top: 1px solid #f3ece0;
}
.rail-todo-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: #f6efe3;
border: 1px solid rgba(212, 160, 160, 0.40);
transition:
background-color 0.25s ease,
border-color 0.25s ease;
&.clickable {
cursor: pointer;
}
&.clickable:hover {
background: #fbf5ea;
border-color: #c08a8a;
}
/* 有积压时用雏菊金描边 + 数值变色提醒。
刻意不给 background:留空才不会和 hover 的底色打架(同权重,靠顺序定胜负太脆) */
&.alert {
border-color: rgba(168, 121, 31, 0.32);
.rail-todo-value {
color: #7d5512;
}
}
}
.rail-todo-label {
font-size: 12px;
color: #7a664e;
}
.rail-todo-value {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #4f4130;
}
/* ========== 右侧主区 ========== */
.main-col {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
/* ========== 统计卡图标配色 ==========
浅底 + 同色深图标(水彩感)。五个田园色循环使用:
陶土 / 草绿 / 苔绿 / 雏菊金 / 玫瑰粉。
tint 用 .22~.26:太淡小图标会"看不见"。
类名(blue/orange/...)沿用旧键名不改,免得动逻辑。 */
.theme-blue,
.theme-indigo {
background: rgba(154, 92, 53, 0.22) !important;
@@ -622,14 +746,13 @@ export default {
i { color: #8f3f50 !important; }
}
/* ========== 区块卡片(RTP / 图表) ========== */
/* ========== 区块卡片(RTP / 图表 / 经营数据) ========== */
.section-card {
background: #faf6f0;
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 6px 18px rgba(139, 115, 85, 0.11);
border: 1px solid rgba(212, 160, 160, 0.55);
margin-bottom: 20px;
}
.section-header {
@@ -670,21 +793,13 @@ export default {
}
}
/* ========== RTP 卡片 ========== */
/* ========== RTP 卡片 ==========
右侧主区宽度随屏幕浮动(有左轨占位),用 auto-fit 自适应
—— 宽屏一行 5 张,窄屏自动折行,不再写死 5 列。 */
.rtp-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
@media (max-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.rtp-card {
@@ -769,9 +884,7 @@ export default {
/* RTP 主题色(只给圆点 + 进度条上色)
★ 与上方折线图的颜色一一对应:同一游戏在哪儿都是同一个色,
这样「颜色」本身就带信息,不用每次重新认。
(原来是品红 #ec4899 / 青 #14b8a6 / 紫 #8b5cf6 / 翠绿 #10b981 / 琥珀 #f59e0b,
属于规范禁止的高饱和荧光色,已换掉。) */
这样「颜色」本身就带信息,不用每次重新认。 */
.rtp-theme-0 { /* 疯狂转盘 */
.rtp-dot { background: #7a6a9c; }
.rtp-progress-inner { background: linear-gradient(90deg, #a89cc0, #7a6a9c); }
@@ -788,11 +901,78 @@ export default {
.rtp-dot { background: #4a9163; }
.rtp-progress-inner { background: linear-gradient(90deg, #8fb8a0, #4a9163); }
}
.rtp-theme-4 { /* 玫瑰女郎 */
.rtp-theme-4 { /* 麻将胡了(idx%5=4) */
.rtp-dot { background: #a35790; }
.rtp-progress-inner { background: linear-gradient(90deg, #c69bb8, #a35790); }
}
/* ========== 经营数据(次级指标带) ========== */
.mini-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.mini-stat {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 12px;
background: #f6efe3;
border: 1px solid rgba(212, 160, 160, 0.40);
transition:
background-color 0.5s ease-in-out,
border-color 0.5s ease-in-out;
&:hover {
background: #fbf5ea;
border-color: #c08a8a;
}
}
.mini-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 17px;
}
}
.mini-info {
min-width: 0;
}
.mini-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #7a664e;
white-space: nowrap;
.mini-note {
cursor: help;
}
}
.mini-value {
margin-top: 2px;
font-size: 17px;
font-weight: 700;
line-height: 1.25;
color: #4f4130;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ========== 图表切换标签 ========== */
.chart-tabs {
display: flex;

Some files were not shown because too many files have changed in this diff Show More