Compare commits

37 Commits

Author SHA1 Message Date
5e54859a30 1.玩家抽奖记录/dice/play_record/index新增时间筛选 2026-06-05 10:17:37 +08:00
226675c6bd 1.增加/dice/reward_config_record/index的关联删除/dice/play_record_test/index中的测试详细数据 2026-06-04 15:49:44 +08:00
e7a4a47329 1.修复一键测试权重BUG时每次压注的底注为随机时可能会出现底注丢失的情况 2026-06-04 15:35:05 +08:00
99949c4c3c 1.优化一键测试权重时每次压注的底注为随机 2026-06-04 15:08:24 +08:00
6ef3663957 1.优化一键测试权重时每次压注的底注为随机 2026-06-04 15:00:55 +08:00
f8969097d7 1.优化色子奖励权重表备注没有更新上去 2026-06-04 14:55:33 +08:00
0cc81299f0 1.优化色子奖励权重表备注没有更新上去 2026-06-04 14:42:22 +08:00
8a4a268526 1.优化彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 14:24:22 +08:00
16a59c28d4 1.修改彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 13:56:33 +08:00
eb9ade6d16 1.修改彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 13:47:35 +08:00
8c6c122dc2 1.新增默认彩金池配置
2.优化关联彩金池配置的名称显示
3.优化一键测试权重
4.优化底注配置
2026-06-04 12:29:23 +08:00
dfb37dd33a 1.新增默认彩金池配置
2.优化关联彩金池配置的名称显示
3.优化一键测试权重
4.优化底注配置
2026-06-04 12:21:57 +08:00
5d316ef7d6 1.抽奖测试记录页面/dice/play_record_test/index增加彩金池配置筛选条件 2026-06-04 10:31:48 +08:00
58a4b229a8 1.优化/dice/reward_config/index页面中的备注根据结算金额来变化 2026-06-04 09:45:26 +08:00
0c4da1540d 1.优化/dice/reward_config/index页面中的备注根据设置的档位来进行变化
2.检查为什么色子奖励权重有104条显示,应该只有26条(对应色子点数5-30)
3.移除掉所有实际金额在后台的显示
2026-06-04 09:33:29 +08:00
1980ff4af0 1.优化测试权重表单样式 2026-06-03 17:47:24 +08:00
5eb0ac24cd 1.优化抽奖券的抽奖逻辑 2026-06-03 17:36:57 +08:00
307a942b8e 1.优化抽奖券的抽奖逻辑 2026-06-03 17:23:13 +08:00
51105dd1e0 1.优化彩金池配置UI显示 2026-06-03 16:45:57 +08:00
136c18e413 1.优化渠道删除失败问题 2026-06-03 15:41:56 +08:00
9fb98dee3f 1.新增描述文档 2026-06-03 13:53:45 +08:00
37b0ee134e 1.修复抽奖档位不统一的问题 2026-06-02 17:31:23 +08:00
c0d5258aee 1.修复抽奖档位不统一的问题 2026-06-02 17:04:55 +08:00
13dacc8fdd 1.优化奖励配置设置-创建奖励对照表单样式 2026-06-02 15:24:17 +08:00
79c84c198a 1.优化奖励配置设置 2026-06-02 14:51:10 +08:00
3f97905ffa 1.优化中奖,当余额不足支付惩罚时,直接扣完钱包 2026-06-01 14:09:26 +08:00
d77e390fa3 1.优化角色管理页面/system/role中角色级别排序 2026-06-01 09:56:17 +08:00
906539995d 1.修改游戏档位和奖励金额的配置
2.优化T4和T5的奖励权重可以修改
2026-06-01 09:53:24 +08:00
18944f0d48 优化指南菜单页面样式 2026-05-30 18:41:53 +08:00
90abab14a3 1.新增菜单后台操作指南,方便管理员查看使用 2026-05-30 17:48:55 +08:00
a4c8f623be DB数据库文件 2026-05-26 09:43:42 +08:00
e0b303c5d4 1.优化/api/v1/getGameUrl接口,无需再验证password 2026-05-26 09:43:28 +08:00
77db2357ba 1.更新平台对接文档 2026-05-25 09:57:48 +08:00
cde5a851e5 1.对接平台接口新增api-key参数 2026-05-25 09:31:24 +08:00
9a43e1d8f2 1.优化查询用户信息接口/api/v1/getPlayerInfo,如果没有用户则创建 2026-05-19 17:18:51 +08:00
37c0035bfc 1.优化运维管理中渠道列表选择渠道的筛选错误问题
2.优化渠道运维管理目录菜单权限
2026-05-19 15:31:04 +08:00
5628af683f 1.优化主站彩金池配置页面当前彩金池多渠道显示错误问题 2026-05-19 14:58:44 +08:00
181 changed files with 7593 additions and 819 deletions

View File

@@ -19,6 +19,7 @@
- **请求方法**:项目路由多数使用 `Route::any`,对接建议统一使用 **POST**(便于 body 传参);个别接口文档中标注了 GET 参数。
- **编码**`UTF-8`
- **Content-Type**:建议 `application/x-www-form-urlencoded``application/json`(以平台实际实现为准)
- **必带凭证**:所有 `/api/v1/*` 接口均需带 `api-key`(与服务端 `.env``API_KEY` 一致);除 `/api/v1/authToken` 外另需 `auth-token`。详见 §2.2。
### 1.3 统一返回结构
@@ -49,10 +50,10 @@
## 2. 鉴权与对接流程(平台侧 /api/v1
平台侧接口分两步:
平台侧接口需统一携带请求头 **`api-key`**(与服务端 `.env``API_KEY` 一致),业务接口另需 **`auth-token`**。
1. **获取 `auth-token`**
2. **携带 `auth-token` 调用 `/api/v1/*` 业务接口**
1. **获取 `auth-token`**(同时携带 `api-key`
2. **携带 `api-key` + `auth-token` 调用 `/api/v1/*` 业务接口**
### 2.1 获取 auth-token
@@ -100,11 +101,22 @@ signature = md5(agent_id + secret + time)
- 密钥错误/签名错误/时间戳无效:`code=403`
- 服务端未配置密钥或生成失败:`code=500`
### 2.2 调用 v1 业务接口(携带 auth-token
### 2.2 平台 api-key所有 /api/v1/* 必填
`/api/v1/authToken` 外,其余 `/api/v1/*` 接口需要在请求头携带:
- **取值**:与服务端环境变量 `API_KEY` 完全一致(部署在 `server/.env`
- **适用范围**:所有 `/api/v1/*` 接口(含 `/api/v1/authToken` 与业务接口)
- **携带方式**(任选其一,按优先级读取,先命中即采用):
1. 请求头 `api-key: <API_KEY>`**推荐**
2. URL 查询参数 `api_key=<API_KEY>`(或 `api-key=<API_KEY>`
3. body 表单/JSON 字段 `api_key`(或 `api-key`
- **未携带或错误**`401` / `403`
- `auth-token: <authtoken>`
### 2.3 调用 v1 业务接口(携带 auth-token
`/api/v1/authToken` 外,其余 `/api/v1/*` 接口需要携带:
- `api-key: <与 API_KEY 一致>`(请求头 / query / body 任选其一,参见 2.2
- `auth-token: <authtoken>`(仅支持请求头)
`auth-token` 过期或失效,返回 `code=402`,需要重新调用 `/api/v1/authToken` 获取新 token。
@@ -116,7 +128,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getGameUrl`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
- **说明**:根据平台用户名创建/登录玩家并生成登录 JWT返回可直接打开的游戏地址。
#### 请求参数body
@@ -124,7 +136,6 @@ signature = md5(agent_id + secret + time)
| 参数名 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| username | 是 | string | 玩家唯一账号(平台侧用户名) |
| password | 否 | string | 默认 `123456` |
| time | 否 | int/string | 默认当前时间戳 |
| lang | 否 | string | `zh` / `en`,默认 `zh` |
@@ -144,7 +155,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerInfo`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
#### 请求参数
@@ -160,7 +171,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerGameRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
#### 请求参数
@@ -181,7 +192,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerWalletRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
参数与时间规则同 3.3(无 `page`,仅 `limit` 限制条数),返回钱包流水列表(附带 `dice_player`)。
@@ -189,7 +200,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerTicketRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
参数与时间规则同 3.3,返回中奖券记录列表(附带 `dice_player`)。
@@ -197,7 +208,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/setPlayerWallet`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
- **说明**:平台为玩家加币/扣币,生成钱包流水。
#### 请求参数
@@ -256,9 +267,9 @@ signature = md5(agent_id + secret + time)
| --- | --- | --- |
| 200 | 成功 | 请求成功 |
| 400 | 请求参数错误 | 缺参、参数格式不合法、范围错误 |
| 401 | 未授权 | 未携带 `auth-token``token` |
| 401 | 未授权 | 未携带 `api-key``auth-token``token` |
| 402 | token 无效或已过期 | `auth-token/token` 过期、签名错误、被挤下线等 |
| 403 | 鉴权失败 | `secret` 错误、签名验证失败、时间戳无效等 |
| 403 | 鉴权失败 | `api-key` 无效、`secret` 错误、签名验证失败、时间戳无效等 |
| 404 | 资源不存在 | 用户不存在等 |
| 422 | 业务逻辑错误 | 余额不足、业务校验失败等 |
| 500 | 服务器内部错误 | 服务端异常或配置缺失 |

View File

@@ -128,7 +128,7 @@
| 文档 | 内容 |
| --- | --- |
| [`API对接文档.md`](API对接文档.md) | 平台 `/api/v1/*``auth-token`)、玩家 `/api/*``token`)、统一返回码、联调建议。 |
| [`API对接文档.md`](API对接文档.md) | 平台 `/api/v1/*``api-key` + `auth-token`)、玩家 `/api/*``token`)、统一返回码、联调建议。 |
| `server/docs/` | 性能、权重测试、出点分析等专项说明(按需阅读)。 |
**与玩法直接相关的玩家接口示例**

View File

@@ -8,7 +8,8 @@
},
"scripts": {
"dev": "vite --open",
"build": "vue-tsc --noEmit && vite build",
"sync:flowcharts": "node scripts/sync-dice-flowcharts.mjs",
"build": "node scripts/sync-dice-flowcharts.mjs && vue-tsc --noEmit && vite build",
"serve": "vite preview",
"lint": "eslint",
"fix": "eslint --fix",

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>为何最终抽到该奖励</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f5f7fa;
color: #1a1a2e;
line-height: 1.6;
}
header { max-width: 1100px; margin: 0 auto 16px; }
h1 { font-size: 1.5rem; margin: 0 0 8px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.95rem; margin: 0; }
.card {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.copy-hint {
max-width: 1100px;
margin: 12px auto 0;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1100px;
margin: 8px auto 0;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
max-height: 280px;
overflow: auto;
color: #303133;
}
.legend {
max-width: 1100px;
margin: 20px auto 0;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
font-size: 0.9rem;
color: #444;
}
.legend h2 { font-size: 1rem; margin: 0 0 10px; }
.legend ul { margin: 0; padding-left: 1.2em; }
.legend li { margin: 4px 0; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>为何最终抽到的是这个奖励</h1>
<p class="subtitle">业务说明:一局抽奖从开局到到账的决策顺序(仅用本项目菜单与业务用语)</p>
</header>
<p class="copy-hint">复制方式展开下方「Mermaid 源码」全选复制,粘贴到 ProcessOn / draw.io / 飞书文档等支持 Mermaid 的流程图工具;或直接用浏览器打开本页看图。</p>
<div class="card">
<pre class="mermaid">
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0</pre>
</details>
<div class="legend">
<h2>读图要点</h2>
<ul>
<li><strong>两步抽签</strong>:先抽档位 T1T5大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</li>
<li><strong>免费局与杀分局</strong>:都用杀分奖池的档位概率;一般不会出豹子大奖,也不会抽到只能组成豹子的点数 5、30。</li>
<li><strong>普通付费局</strong>:彩金池未到杀分条件时,用该玩家在「玩家管理」里的档位权重,才可能按「大奖权重」出豹子。</li>
<li><strong>玩家最终看到</strong>:色子点数、五颗骰子图案、到账平台币(普通奖 + 豹子奖)、是否获得「再来一次」免费券。</li>
</ul>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 16, nodeSpacing: 28, rankSpacing: 40 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>后台如何配置中奖逻辑</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f0f4f8;
color: #1a1a2e;
line-height: 1.55;
}
header { max-width: 1200px; margin: 0 auto 16px; }
h1 { font-size: 1.45rem; margin: 0 0 6px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.92rem; margin: 0; }
.tip {
max-width: 1200px;
margin: 0 auto 16px;
padding: 12px 16px;
background: #fff8e6;
border-left: 4px solid #e6a23c;
border-radius: 6px;
font-size: 0.9rem;
}
.card {
max-width: 1200px;
margin: 0 auto 20px;
background: #fff;
border-radius: 12px;
padding: 24px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.card h2 { font-size: 1.05rem; margin: 0 0 12px; color: #303133; }
.copy-hint {
max-width: 1200px;
margin: 0 auto 12px;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1200px;
margin: 8px auto 20px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.76rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 320px;
overflow: auto;
}
.steps {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 10px;
}
.step {
background: #fff;
border-radius: 10px;
padding: 12px 16px;
border: 1px solid #e4e7ed;
font-size: 0.9rem;
}
.step strong { color: #409eff; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>后台如何配置中奖逻辑</h1>
<p class="subtitle">按「一局真实抽奖」顺序:每个环节对应左侧菜单与页面按钮(与前台逻辑一致)</p>
</header>
<p class="tip">菜单根目录:<strong>大富翁-色子游戏</strong>。多渠道后台请先选顶部<strong>渠道</strong>,再改该渠道数据。</p>
<p class="copy-hint">复制展开「Mermaid 源码」粘贴到流程图工具;日常维护也可打开同目录 <code>dice-后台中奖逻辑配置.mmd</code></p>
<div class="card">
<h2>主流程图(抽奖环节 → 去哪点哪个按钮)</h2>
<pre class="mermaid">
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8</pre>
</details>
<div class="card">
<h2>首次搭建推荐顺序(与上图环节对应)</h2>
<pre class="mermaid">
flowchart TD
O([开始配置]) --> R1[奖励配置 · 页签「奖励索引」· 按钮「保存」]
R1 --> R2[奖励配置 · 页签「大奖权重」· 按钮「保存」]
R2 --> R3[奖励配置 · 按钮「创建奖励对照」·「确认导入」]
R3 --> W[色子奖励权重 · 按钮「权重配比」· 按钮「提交」]
W --> P1[彩金池配置 · 行内「编辑」default / killScore ·「提交」]
P1 --> P2[彩金池配置 ·「查看当前彩金池」·「保存安全线」]
P2 --> PL[玩家管理 · 行内「编辑」· 档位权重 ·「提交」]
PL --> T{要仿真?}
T -->|是| Test[色子奖励权重 ·「一键测试权重」·「开始测试」]
Test --> Imp[权重测试记录 ·「查看详情」·「导入到当前配置」·「确认导入」]
T -->|否| Live([上线])
Imp --> Live
style O fill:#e8f4fc
style Live fill:#e8fce8
</pre>
</div>
<div class="steps">
<div class="step"><strong>档位含义</strong>T1 大奖 · T2 小赚 · T3 抽水 · T4 惩罚 · T5 再来一次(由「奖励索引」结算金额规则决定,见页内说明)。</div>
<div class="step"><strong>改「奖励索引」后</strong>:必须再点「创建奖励对照」→「确认导入」,否则抽奖仍用旧对照表。</div>
<div class="step"><strong>核对真实对局</strong>:大富翁-色子游戏 → 玩家抽奖记录(看奖励档位、色子点数、摇色子中奖平台币、中大奖平台币、底注、方向)。</div>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 14, nodeSpacing: 24, rankSpacing: 36 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8

View File

@@ -0,0 +1,26 @@
/**
* 将 server/docs/flowcharts 同步到 saiadmin-artd/public/docs/flowcharts
* 构建前执行,保证部署包内含最新流程图 HTML/MMD
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const artdRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(artdRoot, '..')
const srcDir = path.join(repoRoot, 'server', 'docs', 'flowcharts')
const destDir = path.join(artdRoot, 'public', 'docs', 'flowcharts')
if (!fs.existsSync(srcDir)) {
console.warn('[sync-dice-flowcharts] 源目录不存在,跳过:', srcDir)
process.exit(0)
}
fs.mkdirSync(destDir, { recursive: true })
const names = fs.readdirSync(srcDir).filter((n) => /\.(html|mmd)$/i.test(n))
for (const name of names) {
fs.copyFileSync(path.join(srcDir, name), path.join(destDir, name))
console.log('[sync-dice-flowcharts] copied', name)
}
console.log('[sync-dice-flowcharts] done,', names.length, 'file(s)')

View File

@@ -0,0 +1,26 @@
import request from '@/utils/http'
/**
* 后台操作指南 API
*/
export default {
/**
* 读取 Markdown 内容
*/
read() {
return request.get<Api.Common.ApiData>({
url: '/core/adminGuide/read'
})
},
/**
* 保存 Markdown 内容
*/
save(params: { content: string }) {
return request.post<Api.Common.ApiData>({
url: '/core/adminGuide/save',
data: params,
showSuccessMessage: true
})
}
}

View File

@@ -59,7 +59,8 @@ export default {
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/dept/destroy',
data: params
data: params,
showErrorMessage: false
})
},

View File

@@ -25,8 +25,8 @@
</div>
<div class="flex flex-col flex-grow min-w-0 min-h-0">
<div v-if="selectedDeptLabel" class="channel-banner mb-3 text-sm text-g-500">
{{ bannerLabel }}<b>{{ selectedDeptLabel }}</b>
<div v-if="selectedDisplayLabel" class="channel-banner mb-3 text-sm text-g-500">
{{ bannerLabel }}<b>{{ selectedDisplayLabel }}</b>
</div>
<div class="flex flex-col flex-1 min-h-0 min-w-0">
<slot />
@@ -66,10 +66,10 @@
treeData,
selectedDeptId,
loadingChannels,
selectedDeptLabel,
handleChannelClick,
provideScope,
isConfigScope,
isAllChannelScope,
showDefaultTemplate
} = useChannelDeptScope()
@@ -82,11 +82,17 @@
const defaultLabel = isRoleChannelRoute(route)
? t('common.channelScope.defaultRoleTemplate')
: t('common.channelScope.defaultTemplate')
const emptyNodeLabel = isAllChannelScope.value ? t('common.channelScope.allChannels') : defaultLabel
return nodes.map((node) =>
node.id === DEFAULT_CHANNEL_ID && !node.label ? { ...node, label: defaultLabel } : node
node.id === DEFAULT_CHANNEL_ID && !node.label ? { ...node, label: emptyNodeLabel } : node
)
})
const selectedDisplayLabel = computed(() => {
const item = displayTreeData.value.find((node) => node.id === selectedDeptId.value)
return item?.label ?? ''
})
const bannerLabel = computed(() => {
if (isConfigScope.value) {
return t('common.channelScope.currentConfig')

View File

@@ -1,8 +1,15 @@
import type { InjectionKey, Ref, ComputedRef } from 'vue'
import { useRoute } from 'vue-router'
import deptApi from '@/api/system/dept'
import { router } from '@/router'
import { useUserStore } from '@/store/modules/user'
import { isConfigChannelRoute, isRoleChannelRoute, isSuperAdminUser } from '@/utils/channelLayout'
import {
isAllChannelScopeRoute,
isConfigChannelRoute,
isNoChannelLayoutRoute,
isRoleChannelRoute,
isSuperAdminUser
} from '@/utils/channelLayout'
export interface ChannelTreeNode {
id: number
@@ -21,18 +28,42 @@ export interface ChannelDeptScopeContext {
selectedDeptLabel: Ref<string>
deptQueryParams: ComputedRef<{ dept_id: number }>
isConfigScope: ComputedRef<boolean>
isAllChannelScope: ComputedRef<boolean>
showDefaultTemplate: ComputedRef<boolean>
}
export const CHANNEL_DEPT_SCOPE_KEY: InjectionKey<ChannelDeptScopeContext> =
Symbol('channelDeptScope')
/**
* 当前应用中处于激活态的超管渠道上下文。
* 仅有一个 SuperAdminChannelShell 实例,所以可以在模块作用域内缓存其 ctx
* 供 setInterval / 异步回调 / 事件处理器等"setup 外"路径取用——
* 这些路径上 Vue 的 inject() 会失效getCurrentInstance() 为 null
*/
let activeChannelCtx: ChannelDeptScopeContext | null = null
export function provideChannelDeptScope(ctx: ChannelDeptScopeContext) {
provide(CHANNEL_DEPT_SCOPE_KEY, ctx)
activeChannelCtx = ctx
onScopeDispose(() => {
if (activeChannelCtx === ctx) {
activeChannelCtx = null
}
})
}
export function useInjectedChannelDept(): ChannelDeptScopeContext | null {
return inject(CHANNEL_DEPT_SCOPE_KEY, null)
// 仅在组件 setup 同步路径下调用 inject() 才可靠;
// 否则异步回调、setInterval、事件处理器等退化为读取模块级激活上下文
// 避免渠道切换后 getCurrentPool/withChannelDeptParams 等仍按超管自身部门发请求。
if (getCurrentInstance()) {
const ctx = inject(CHANNEL_DEPT_SCOPE_KEY, null)
if (ctx) {
return ctx
}
}
return activeChannelCtx
}
/** 超管全局渠道栏:创建并 provide 渠道上下文 */
@@ -45,6 +76,7 @@ export function useChannelDeptScope() {
const isConfigScope = computed(() => isConfigChannelRoute(route))
const isRoleScope = computed(() => isRoleChannelRoute(route))
const isAllChannelScope = computed(() => isAllChannelScopeRoute(route))
const showDefaultTemplate = computed(() => isConfigScope.value || isRoleScope.value)
const isSuperAdmin = computed(() => isSuperAdminUser())
@@ -67,6 +99,9 @@ export function useChannelDeptScope() {
const deptQueryParams = computed(() => {
const id = selectedDeptId.value
if (isAllChannelScope.value && id <= 0) {
return { dept_id: 0 }
}
if (!showDefaultTemplate.value && id <= 0) {
return { dept_id: 0 }
}
@@ -83,7 +118,7 @@ export function useChannelDeptScope() {
label: String(item.label ?? item.name ?? item.id)
}))
if (isSuperAdmin.value) {
if (showDefaultTemplate.value) {
if (showDefaultTemplate.value || isAllChannelScope.value) {
treeData.value = [{ id: DEFAULT_CHANNEL_ID, label: '' }, ...nodes]
if (!treeData.value.some((n) => n.id === selectedDeptId.value)) {
selectedDeptId.value = DEFAULT_CHANNEL_ID
@@ -120,6 +155,7 @@ export function useChannelDeptScope() {
selectedDeptLabel,
deptQueryParams,
isConfigScope,
isAllChannelScope,
showDefaultTemplate
}
@@ -147,6 +183,11 @@ export function bindChannelDeptToSearchParams(
}
const apply = (deptId: number) => {
if (channel.isAllChannelScope.value && deptId <= 0) {
delete searchParams.dept_id
refresh()
return
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return
}
@@ -174,6 +215,10 @@ export function useChannelDeptReload(loadFn: () => void | Promise<void>) {
watch(
() => channel.selectedDeptId.value,
(deptId) => {
if (channel.isAllChannelScope.value && deptId <= 0) {
void loadFn()
return
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return
}
@@ -186,8 +231,15 @@ export function useChannelDeptReload(loadFn: () => void | Promise<void>) {
/** 请求参数:业务页附带 dept_id渠道管理员固定本渠道 */
export function getChannelDeptRequestParams(): { dept_id?: number } {
const channel = useInjectedChannelDept()
const route = getCurrentInstance() ? useRoute() : router.currentRoute.value
if (isNoChannelLayoutRoute(route)) {
return {}
}
if (channel?.isSuperAdmin.value) {
const deptId = channel.selectedDeptId.value
if (channel.isAllChannelScope.value && deptId <= 0) {
return {}
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return {}
}
@@ -209,18 +261,25 @@ export function getChannelDeptRequestParams(): { dept_id?: number } {
return {}
}
/** 保存/更新时附带 dept_id优先渠道栏选中值,其次表单/行数据中的 dept_id */
/** 保存/更新时附带 dept_id新增优先渠道栏;更新优先行内 dept_id避免默认模板 0 覆盖真实渠道 */
export function withChannelDeptParams<T extends Record<string, unknown>>(payload: T): T {
const rowDeptRaw = payload.dept_id
const hasRowDept =
rowDeptRaw !== undefined && rowDeptRaw !== null && rowDeptRaw !== ''
const rowDeptNum = hasRowDept ? Number(rowDeptRaw) : NaN
const isUpdate =
payload.id !== undefined && payload.id !== null && payload.id !== ''
if (isUpdate && hasRowDept && Number.isFinite(rowDeptNum) && rowDeptNum >= 0) {
return { ...payload, dept_id: rowDeptNum }
}
const extra = getChannelDeptRequestParams()
if ('dept_id' in extra) {
return { ...payload, ...extra }
}
const rowDeptId = payload.dept_id
if (rowDeptId !== undefined && rowDeptId !== null && rowDeptId !== '') {
const num = Number(rowDeptId)
if (num > 0) {
return { ...payload, dept_id: num }
}
if (hasRowDept && Number.isFinite(rowDeptNum) && rowDeptNum > 0) {
return { ...payload, dept_id: rowDeptNum }
}
const channel = useInjectedChannelDept()
if (channel && channel.selectedDeptId.value > 0) {

View File

@@ -42,6 +42,7 @@
"listTitle": "Channels",
"defaultTemplate": "Default template",
"defaultRoleTemplate": "Default role template",
"allChannels": "All",
"currentConfig": "Current config",
"currentChannel": "Current channel",
"currentRole": "Current roles"

View File

@@ -27,6 +27,8 @@
"labelIsDefault": "Default Ante",
"placeholderName": "Please enter name",
"placeholderTitle": "Please enter title",
"placeholderNameAuto": "Auto from multiplier, e.g. x5",
"placeholderTitleAuto": "Auto from multiplier, e.g. x5",
"ruleNameRequired": "Please enter name",
"ruleTitleRequired": "Please enter title",
"ruleMultRequired": "Please enter ante multiplier",

View File

@@ -5,10 +5,13 @@
"dialogTitleEdit": "Edit Lottery Pool Config",
"placeholderName": "Please enter name",
"placeholderRemark": "Please enter remark",
"placeholderPoolName": "Pool display name, e.g. Normal pool",
"placeholderConfigNote": "Optional notes about this pool",
"poolType": "Pool Type",
"placeholderPoolType": "Please select pool type",
"poolTypeNormal": "Normal",
"poolTypeKill": "Kill",
"poolTypeFree": "Free",
"poolTypeKill": "Force score kill",
"poolTypeT1": "T1 High",
"safetyLine": "Safety Line",
"t1Weight": "T1 Pool Weight (%)",
@@ -21,20 +24,22 @@
"currentPoolTitle": "Current Lottery Pool",
"loading": "Loading...",
"poolName": "Pool Name",
"playerProfit": "Player Total Profit (profit_amount):",
"poolProfitAmount": "Pool cumulative profit (profit_amount):",
"realtime": "Live",
"profitCalcHint": "Profit per round: paid = win_coin (incl. BIGWIN) - paid_amount (= ante×1); free = win_coin. Refreshes every 2s while open.",
"tierRuleTitle": "Tier Rule",
"tierRuleContent": "When player profit in this pool is below safety line, use player T*_weight; when above or equal, use pool T*_weight (kill).",
"profitCalcHint": "Accumulated on name=default (Normal) pool: paid += win_coin paid_amount (ante×1); free += win_coin. Compared with safety line to decide paid-draw kill switch. Refreshes every 2s while open.",
"tierRuleTitle": "Paid draw tier rule",
"tierRuleContent": "Compares default pool profit_amount (not per-player profit). Below safety line or kill off: paid uses player T*_weight; at/above safety line with kill on: paid uses killScore pool. Free draws always use channel name=free pool weights (fallback default if missing); safety line N/A.",
"enableKillScore": "Enable kill score",
"killScoreWeights": "Kill weights",
"killWeightNote": "(Kill weights from pool config type=1; edit in list.)",
"btnResetProfit": "Reset Player Total Profit",
"btnSaveSafetyLine": "Save Safety Line",
"killScoreWeights": "Kill weights (killScore)",
"killWeightNote": "Edit killScore (Force Kill) row in the list for kill weights. This dialog only configures default pool safety line and kill switch.",
"btnResetProfit": "Reset pool cumulative profit",
"btnSaveSafetyLine": "Save safety line & kill switch",
"safetyLineDefaultOnlyHint": "Only the default (Normal) pool safety line affects kill logic; do not set safety line on other pool types.",
"safetyLineNotUsedReadonly": "This pool type does not use safety line for kill logic. Edit the Normal (default) row or use View Current Pool.",
"ruleSafetyLineRequired": "Please enter safety line",
"msgGetPoolFailed": "Failed to get lottery pool",
"msgSaveSuccess": "Save Success",
"msgResetProfitSuccess": "Player total profit reset to 0",
"msgResetProfitSuccess": "Pool cumulative profit reset to 0",
"msgResetFailed": "Reset failed",
"ruleNameRequired": "Name is required",
"rulePoolTypeRequired": "Please select pool type",
@@ -55,13 +60,18 @@
"placeholderName": "Please enter name",
"placeholderPoolType": "Please select pool type",
"poolTypeNormal": "Normal",
"poolTypeKill": "Force Kill",
"poolTypeFree": "Free",
"poolTypeKill": "Force Score Kill",
"poolTypeT1": "T1 High Rate"
},
"table": {
"name": "Name",
"name": "Code",
"poolName": "Pool Name",
"configNote": "Remark",
"poolType": "Pool Type",
"safetyLine": "Safety Line",
"safetyLineNotUsed": "Not used for kill",
"safetyLineTip": "Normal (default) row only",
"t1PoolWeight": "T1 Pool Weight",
"t2PoolWeight": "T2 Pool Weight",
"t3PoolWeight": "T3 Pool Weight",

View File

@@ -57,6 +57,7 @@
"rollNumber": "Roll Number",
"rewardTier": "Reward Tier",
"rewardConfig": "Reward Config",
"createTime": "Created At",
"usernameFuzzy": "Username (fuzzy)",
"nameFuzzy": "Name (fuzzy)",
"uiTextFuzzy": "UI Text (fuzzy)",
@@ -84,6 +85,7 @@
"rollArray": "Roll Array",
"rollNumber": "Roll Number",
"rewardTier": "Reward Tier",
"remark": "Remark",
"createTime": "Create Time",
"updateTime": "Update Time"
}

View File

@@ -4,6 +4,8 @@
"platformTotalProfit": "Platform Total Profit"
},
"search": {
"lotteryPoolConfig": "Lottery Pool Config",
"placeholderLotteryPool": "Select pool (search by name)",
"rewardConfigRecordId": "Weight Test Record ID",
"drawType": "Draw Type",
"direction": "Direction",

View File

@@ -22,6 +22,8 @@
"placeholderLotteryPool": "Leave empty for custom weights below, or select pool",
"currentConfig": "Current Config",
"configLabelName": "Name",
"configLabelPoolName": "Pool name",
"configLabelCode": "Code",
"configLabelType": "Type",
"configLabelWeights": "T1T5 Weights",
"configLabelRemark": "Remark",

View File

@@ -25,8 +25,6 @@
"emptyTier": "No data for this tier",
"sumLineDual": "Tier weight sum (clockwise): {cw}; counter-clockwise: {ccw} (each row 110000, ratio draw within tier, sum not limited)",
"sumLineSingle": "Tier weight sum: {sum} (each row 110000, ratio draw within tier, sum not limited)",
"t4t5NoteSingle": "T4 and T5 have a single outcome; no weight configuration.",
"t4t5NoteDual": "T4 and T5 have a single outcome when hit; no weight configuration.",
"colEndIndexId": "End Index (id)",
"colGridNumber": "Points (grid_number)",
"colDicePoints": "Dice Points",
@@ -45,33 +43,40 @@
},
"weightEdit": {
"title": "Dice Reward (dice_reward) Weight Ratio",
"globalTip": "You are editing weights on dice_reward (DiceReward), split by end_index into clockwise and counter-clockwise; the draw uses the set for the current direction."
"globalTip": "You are editing weights on dice_reward (DiceReward), split by end_index into clockwise and counter-clockwise; the draw uses the set for the current direction. T4/T5 support the same multi-point weight ratio as T1T3."
},
"weightRatio": {
"title": "Weight Ratio",
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1T5); each row weight 110000, ratio draw within tier.",
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1T5); each row weight 110000, ratio draw within tier for dice points (T4/T5 use the same logic as T1T3 and support multiple point weights).",
"tabClockwise": "Clockwise",
"tabCounterclockwise": "Counter-clockwise"
},
"weightTest": {
"title": "One-Click Weight Test",
"alertTitle": "Bonus pool logic",
"alertBody": "Test mode is non-kill by default. You can enable kill mode below with switch + safety line: once simulated player cumulative profit reaches the line, paid draws switch to killScore.",
"alertBody": "Kill switching is off by default. Enable “Test kill mode below and set a test safety line (independent from the lottery pool config) to simulate kill triggers.",
"chainModeHint": "Simulation: set paid spin counts only (CW/CCW). If a paid draw hits “play again” (or T5), the next draw is free with the same ante, lottery type free, paid amount 0. Free-draw tier odds are configured below (including chained free plays).",
"killModeHint": "When test kill mode is enabled: use simulated player cumulative profit as trigger; once cumulative profit >= safety line, subsequent paid draws use killScore. Free draws still follow the configured free settings.",
"labelKillModeEnabled": "Enable test kill mode",
"killModeHint": "When test kill mode is on: start from default pool profit_amount and accumulate each spin (paid: win_coin - paid_amount; free: win_coin). Once profit >= the test safety line below, subsequent paid draws use killScore; free draws still use the name=free pool.",
"killModePanelTitle": "Test kill mode",
"killModeSwitchOn": "On",
"killModeSwitchOff": "Off",
"labelTestSafetyLine": "Test safety line",
"testSafetyLineHint": "Used for this weight test only, independent from the pool config safety line. Use a lower value to observe kill switching quickly.",
"poolProfitRef": "Reference: default pool profit {profit}, pool config safety line {line}",
"killModeOffHint": "When off, all draws follow paid/free settings without kill switching.",
"sectionPaid": "Paid draws",
"sectionFreeAfterPlayAgain": "Free draw tier odds (after play-again)",
"tierProbHintFreeChain": "When using custom tier odds: T1T5 below apply when a free draw runs (tier roll; combined with dice_reward row weights).",
"sectionFreeAfterPlayAgain": "Free draws (play again)",
"tierProbHintFreeChain": "Custom tiers: T1T5 odds for free draws (combined with dice_reward row weights).",
"stepPaid": "Paid ticket",
"stepFree": "Free ticket",
"labelLotteryTypePaid": "Test pool type",
"labelLotteryTypeFree": "Test pool type",
"labelLotteryTypePaid": "Paid tier pool",
"labelLotteryTypeFree": "Free tier pool",
"labelAnte": "Ante",
"placeholderAnte": "Select ante config",
"placeholderPaidPool": "Leave empty for custom tier odds below (default: default)",
"placeholderFreePool": "Leave empty for custom tier odds below (default: killScore)",
"anteRandomOption": "Random (each paid spin picks independently from channel ante configs)",
"placeholderPaidPool": "Leave empty to set T1T5 weights manually",
"placeholderFreePool": "Leave empty to set T1T5 weights manually",
"selectedPoolHint": "Selected pool: {name}",
"tierProbHint": "Custom tier odds (T1T5), each 0100%, sum of five must not exceed 100%",
"tierFieldLabel": "Tier {tier} (%)",
"tierSumError": "Current sum of five tiers is {sum}%, cannot exceed 100%",

View File

@@ -8,6 +8,18 @@
"tabIndex": "Reward Index",
"tabBigwin": "Big Win Weights",
"tipIndex": "Dice points must be between 5 and 30 and unique in this table.",
"tierRecommendRules": "[Settlement vs tier] T1 (big prize): >2; T2 (small win): 2≥amount>1; T3 (rake): 1≥amount>0; T4 (penalty): 0>amount; T5 (try again): 0=amount. Set recommended settlement per tier below. The Tier column is auto-calculated from settlement and cannot be edited manually.",
"tierRecommendRealEv": "Recommended settlement",
"tierRecommendAutoMatch": "Auto-match tier when settlement changes",
"tierRecommendApplyAmount": "Fill recommended amount for rows with tier set",
"tierRecommendApplyAmountOk": "Filled recommended settlement for {n} row(s)",
"tierRecommendNoTierRows": "No rows with a tier inferable from settlement",
"tierRecommendMatchTier": "Match all tiers from settlement",
"tierRecommendMatchTierOk": "Matched tier for {n} row(s) from settlement",
"tierRecommendMatchTierNone": "No rows to match",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"colTierAutoHint": "Auto-matched from settlement",
"tipBigwin": "Left to right: big-win points (read-only), display text, real EV, remark, weight (0~10000). Points 5 and 30 are fixed at 100%. This tab saves big-win weights only.",
"colId": "Index (id)",
"colDicePoints": "Dice Points",
@@ -36,6 +48,19 @@
"confirmCreateRefMsg": "Create reward reference by rule: start_index is the id of the cell for grid_number in reward config; clockwise end_index=(start_index+roll)%26; counter-clockwise end_index=start_index-roll if >=0 else 26+start_index-roll. Existing data will be cleared, then 26 points (530) for both directions will be generated. Continue?",
"confirmCreateRefOk": "Create",
"confirmCreateRefCancel": "Cancel",
"createRefPreviewTitle": "Create Reward Reference Preview",
"createRefPreviewClockwise": "Clockwise",
"createRefPreviewCounterclockwise": "Counter-clockwise",
"createRefPreviewTipUnchanged": "Dice points mapping is unchanged: weights in the preview are reused from current dice_reward; importing will not override existing weights.",
"createRefPreviewTipChanged": "Dice points mapping has changed: preview weights use defaults (100 by default; sums 5/10/15/20/25/30 default to 1). After importing, adjust weights in the Dice Reward page if needed.",
"createRefPreviewSkipped": "{n} dice point(s) are missing in the reward index and were skipped (please complete all 26 points from 5 to 30).",
"createRefPreviewRefresh": "Refresh preview",
"createRefPreviewImport": "Import",
"createRefPreviewImportOk": "Imported reward reference",
"createRefPreviewImportNoop": "Mapping unchanged, nothing to import (existing weights kept)",
"createRefPreviewDiff": "Diff (old → new)",
"createRefPreviewNoDiff": "No change",
"createRefPreviewWeightsSaved": "Weights saved",
"createRefSuccess": "Created for 26 dice points (530), clockwise + counter-clockwise: clockwise added {cwNew}, counter-clockwise added {ccwNew}; clockwise updated {cwUp}, counter-clockwise updated {ccwUp}{skippedPart}",
"createRefSuccessSkipped": "; {n} point(s) used fallback start index",
"createRefSuccessSimple": "Created successfully",
@@ -54,7 +79,7 @@
"infoNoBigwin": "No BIGWIN rows. Set tier to BIGWIN in the Reward Index tab first.",
"btnRuleGenerate": "Generate by rules",
"ruleGenerateTitle": "Generate reward index by rules",
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 025; each rows grid_number is 530 and unique.\n• Roll D (530): start at the cell whose grid_number equals D (start_index); clockwise landing = (start position + D) mod 26; counter-clockwise = start D (if negative, +26).\n• Each reference rows “dice points” column is the roll D; tier / real_ev / display text come from the config at the landing id.\n\n[Leopard rolls]\nFor rolls 5, 10, 15, 20, 25, 30, clockwise and counter-clockwise landing tiers must NOT be T4 or T5 (avoid leopard roll + penalty / once again).\n\n[Settlement amount vs tier]\nSettlement < 0 → T4; 0 < Settlement < 100 → T3; 100 < Settlement < 200 → T2; Settlement > 200 → T1; T5 “once again” settlement = 0. You can set a unified settlement standard for each tier below; generated rows write those values into the config, and details can be edited later in the table.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts (each roll result counts once) must each satisfy the entered values; T1, T4, and T5 are entered separately.\nSettlement standard: all cells in the same tier use the same value. On generation, T1T4 use ui_text / ui_text_en = settlement real_ev; T5 is fixed to \"再来一次\" / \"Once again\". Remarks still distinguish break-even / small win, etc.",
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 025; each rows grid_number is 530 and unique.\n• Roll D (530): start at the cell whose grid_number equals D (start_index); clockwise landing = (start position + D) mod 26; counter-clockwise = start D (if negative, +26).\n• Each reference rows “dice points” column is the roll D; tier / real_ev / display text come from the config at the landing id.\n\n[Leopard rolls]\nFor rolls 5, 10, 15, 20, 25, 30, clockwise and counter-clockwise landing tiers must NOT be T4 or T5 (avoid leopard roll + penalty / try again).\n\n[Settlement vs tier]\nT1: >2; T2: 2≥amount>1; T3: 1≥amount>0; T4: 0>amount; T5: 0=amount. Set recommended settlement per tier below.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts must each satisfy the entered values.\nSettlement standard: same tier uses the same value. T1T4 use ui_text = settlement; T5 is fixed to \"再来一次\" / \"Once again\".",
"ruleGenT1Row": "T1 (big prize)",
"ruleGenT2Row": "T2 (small win / break-even)",
"ruleGenT3RealEvOnly": "T3 (rake)",
@@ -64,11 +89,11 @@
"ruleGenFixedCount": "Fixed count (CW & CCW)",
"ruleGenRealEvStd": "real_ev standard",
"ruleGenRealEvEditHint": "After saving, you can still edit display text, EN, real_ev and remarks per row in the table above.",
"ruleGenInvalidT1RealEv": "T1 settlement amount must satisfy: value > 200",
"ruleGenInvalidT2RealEv": "T2 settlement amount must satisfy: 100 < value < 200",
"ruleGenInvalidT3RealEv": "T3 settlement amount must satisfy: 0 < value < 100",
"ruleGenInvalidT4RealEv": "T4 settlement amount must satisfy: value < 0",
"ruleGenInvalidT5RealEv": "T5 try again” real_ev must be 0",
"ruleGenInvalidT1RealEv": "T1 (big prize) settlement must satisfy: value > 2",
"ruleGenInvalidT2RealEv": "T2 (small win) settlement must satisfy: 1 < value 2",
"ruleGenInvalidT3RealEv": "T3 (rake) settlement must satisfy: 0 < value 1",
"ruleGenInvalidT4RealEv": "T4 (penalty) settlement must satisfy: value < 0",
"ruleGenInvalidT5RealEv": "T5 (try again) settlement must be 0",
"ruleGenT1Min": "T1 fixed count (CW & CCW)",
"ruleGenT2Min": "T2 min (CW & CCW)",
"ruleGenT4Max": "T4 fixed count (CW & CCW)",

View File

@@ -17,6 +17,8 @@
"chainModeNo": "No",
"paidPlannedSpins": "Planned paid spins",
"ante": "Ante",
"anteRandom": "Random",
"testSafetyLine": "Safety line",
"playAgainCount": "Play-again count",
"progressDraws": "{over} done",
"progressFailed": "{over} before fail",
@@ -51,11 +53,13 @@
"testCountProgress": "In progress: {over} done",
"testCountFailed": "{over} before failure",
"chainModeLabel": "Chain play-again",
"killModeOff": "Kill mode off",
"paidPlannedSpins": "Planned paid spins",
"testSafetyLine": "Test safety line",
"createTime": "Created at",
"admin": "Operator",
"paidPoolId": "Paid lottery pool config ID",
"freePoolId": "Free lottery pool config ID",
"paidPoolId": "Paid lottery pool",
"freePoolId": "Free lottery pool",
"bigwinSnapshot": "BIGWIN weight snapshot",
"sectionPaidTier": "Paid draw tier odds (T1T5, used in test)",
"sectionFreeTier": "Free draw tier odds (T1T5, used in test)",

View File

@@ -0,0 +1,27 @@
{
"title": "Admin Guide",
"toolbar": {
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh"
},
"meta": {
"filePath": "File Path",
"updateTime": "Updated At"
},
"catalog": {
"title": "Catalog",
"empty": "No headings"
},
"image": {
"zoom": "Click to view full size"
},
"message": {
"loadFailed": "Failed to load admin guide",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"cancelConfirm": "You have unsaved changes. Cancel editing?",
"editRequired": "Please click Edit before saving"
}
}

View File

@@ -42,6 +42,7 @@
"listTitle": "渠道列表",
"defaultTemplate": "默认配置模板",
"defaultRoleTemplate": "默认角色模板",
"allChannels": "全部",
"currentConfig": "当前配置",
"currentChannel": "当前渠道",
"currentRole": "当前角色范围"

View File

@@ -27,6 +27,8 @@
"labelIsDefault": "默认底注",
"placeholderName": "请输入名称",
"placeholderTitle": "请输入标题",
"placeholderNameAuto": "随底注倍率自动生成,如 x5",
"placeholderTitleAuto": "随底注倍率自动生成,如 x5",
"ruleNameRequired": "请输入名称",
"ruleTitleRequired": "请输入标题",
"ruleMultRequired": "请输入底注倍率",

View File

@@ -5,10 +5,14 @@
"dialogTitleEdit": "编辑色子奖池配置",
"placeholderName": "请输入名称",
"placeholderRemark": "请输入备注",
"placeholderPoolName": "请输入奖池名称,如:正常池",
"placeholderConfigNote": "选填,用于说明该奖池用途或规则",
"poolType": "奖池类型",
"poolName": "奖池名称",
"placeholderPoolType": "请选择奖池类型",
"poolTypeNormal": "正常",
"poolTypeKill": "强制杀猪",
"poolTypeFree": "免费",
"poolTypeKill": "强制杀分",
"poolTypeT1": "T1高倍率",
"safetyLine": "安全线",
"t1Weight": "T1池权重(%)",
@@ -21,20 +25,22 @@
"currentPoolTitle": "当前彩金池",
"loading": "加载中...",
"poolName": "池子名称",
"playerProfit": "玩家累计盈利profit_amount",
"poolProfitAmount": "彩金池累计盈利profit_amount",
"realtime": "实时",
"profitCalcHint": "计算方式:付费每局按“赢取平台币 win_coin含 BIGWIN减去付费金额 压注金额paid_amount= 压注倍数ante×1”累加;免费每局按“玩家赢得平台币win_coin”累加。弹窗打开期间每 2 秒自动刷新",
"tierRuleTitle": "抽奖档位规则",
"tierRuleContent": "当玩家在当前彩金池的累计盈利 低于安全线 时,按 玩家 的 T*_weight 权重抽取档位;当累计盈利 高于或等于安全线 时,按 当前彩金池 的 T*_weight 权重抽取档位(杀分)。",
"profitCalcHint": "累计在 name=default正常奖池上付费每局 += win_coin paid_amountante×1免费每局 += win_coin。用于与安全线比较判定付费抽奖是否切换杀分。弹窗打开期间每 2 秒自动刷新",
"tierRuleTitle": "付费抽奖档位规则",
"tierRuleContent": "比较对象为 default 奖池的 profit_amount非单个玩家盈利。当 profit_amount 低于安全线或未开启杀分时,付费按玩家 T*_weight 抽档;当 profit_amount 高于或等于安全线且已开启杀分时,付费按 killScore 奖池抽档。免费抽奖始终按本渠道 name=free 奖池权重(无 free 时回退 default与安全线无关。",
"enableKillScore": "开启杀分",
"killScoreWeights": "杀分权重",
"killWeightNote": "杀分权重来自奖池配置,请在列表中编辑对应记录)",
"btnResetProfit": "重置玩家累计盈利",
"btnSaveSafetyLine": "保存安全线",
"killScoreWeights": "杀分权重killScore",
"killWeightNote": "杀分权重请在列表中编辑 name=killScore强制杀分记录本弹窗仅配置 default 奖池的安全线与杀分开关。",
"btnResetProfit": "重置彩金池累计盈利",
"btnSaveSafetyLine": "保存安全线与杀分开关",
"safetyLineDefaultOnlyHint": "仅 name=default正常奖池的安全线参与杀分判定其它奖池类型请勿在此配置安全线。",
"safetyLineNotUsedReadonly": "当前奖池类型不参与杀分判定,安全线仅对「正常(default)」奖池生效,请通过「查看当前彩金池」或编辑正常行修改。",
"ruleSafetyLineRequired": "请输入安全线",
"msgGetPoolFailed": "获取彩金池失败",
"msgSaveSuccess": "保存成功",
"msgResetProfitSuccess": "玩家累计盈利已重置为 0",
"msgResetProfitSuccess": "彩金池累计盈利已重置为 0",
"msgResetFailed": "重置失败",
"ruleNameRequired": "名称必需填写",
"rulePoolTypeRequired": "请选择奖池类型",
@@ -52,16 +58,22 @@
},
"search": {
"poolType": "奖池类型",
"poolName": "奖池名称",
"placeholderName": "请输入名称",
"placeholderPoolType": "请选择奖池类型",
"poolTypeNormal": "正常",
"poolTypeKill": "强制杀猪",
"poolTypeFree": "免费",
"poolTypeKill": "强制杀分",
"poolTypeT1": "T1高倍率"
},
"table": {
"name": "名称",
"name": "内部标识",
"poolName": "奖池名称",
"configNote": "备注",
"poolType": "奖池类型",
"safetyLine": "安全线",
"safetyLineNotUsed": "不参与杀分判定",
"safetyLineTip": "仅「正常(default)」行有效",
"t1PoolWeight": "T1池权重",
"t2PoolWeight": "T2池权重",
"t3PoolWeight": "T3池权重",

View File

@@ -57,6 +57,7 @@
"rollNumber": "摇取点数和",
"rewardTier": "中奖档位",
"rewardConfig": "奖励配置",
"createTime": "创建时间",
"usernameFuzzy": "用户名模糊",
"nameFuzzy": "名称模糊",
"uiTextFuzzy": "前端显示文本模糊",
@@ -84,6 +85,7 @@
"rollArray": "摇取点数",
"rollNumber": "摇取点数和",
"rewardTier": "中奖档位",
"remark": "备注",
"createTime": "创建时间",
"updateTime": "更新时间"
}

View File

@@ -4,6 +4,8 @@
"platformTotalProfit": "平台总盈利"
},
"search": {
"lotteryPoolConfig": "彩金池配置",
"placeholderLotteryPool": "请选择彩金池(可搜索 name",
"rewardConfigRecordId": "测试记录ID",
"drawType": "抽奖类型",
"direction": "方向",

View File

@@ -22,6 +22,8 @@
"placeholderLotteryPool": "留空则使用下方自定义权重,或选择彩金池",
"currentConfig": "当前配置",
"configLabelName": "名称",
"configLabelPoolName": "奖池名称",
"configLabelCode": "内部标识",
"configLabelType": "类型",
"configLabelWeights": "T1T5 权重",
"configLabelRemark": "备注",

View File

@@ -25,8 +25,6 @@
"emptyTier": "该档位暂无配置数据",
"sumLineDual": "当前档位权重合计(顺时针):{cw};逆时针:{ccw}(各条 1-10000档位内按权重比抽取和不限制",
"sumLineSingle": "当前档位权重合计:{sum}(各条 1-10000档位内按权重比抽取和不限制",
"t4t5NoteSingle": "T4、T5 仅单一结果,无需配置权重。",
"t4t5NoteDual": "T4、T5 档位抽中时仅有一个结果,无需配置权重。",
"colEndIndexId": "结束索引(id)",
"colGridNumber": "点数(grid_number)",
"colDicePoints": "色子点数",
@@ -45,33 +43,40 @@
},
"weightEdit": {
"title": "奖励对照表dice_reward权重配比",
"globalTip": "编辑的是奖励对照表dice_reward / DiceReward 模型的权重按结束索引end_index区分顺时针与逆时针两套权重抽奖时按当前方向取对应权重。"
"globalTip": "编辑的是奖励对照表dice_reward / DiceReward 模型的权重按结束索引end_index区分顺时针与逆时针两套权重抽奖时按当前方向取对应权重T4/T5 与 T1-T3 相同支持多点数权重配比。"
},
"weightRatio": {
"title": "权重配比",
"globalTip": "配置奖励对照表dice_reward的权重一级按方向顺时针/逆时针二级按档位T1-T5各条权重 1-10000档位内按权重比抽取。",
"globalTip": "配置奖励对照表dice_reward的权重一级按方向顺时针/逆时针二级按档位T1-T5各条权重 1-10000档位内按权重比抽取骰子点数T4/T5 与 T1-T3 逻辑相同,可配置多个点数的权重配比)。",
"tabClockwise": "顺时针",
"tabCounterclockwise": "逆时针"
},
"weightTest": {
"title": "一键测试权重",
"alertTitle": "彩金池逻辑说明",
"alertBody": "测试模式默认不启用杀分切换;可通过下方“杀分开关 + 安全线”在测试内启用:当模拟玩家累计盈利达到安全线后,付费抽奖切换到 killScore。",
"alertBody": "测试模式默认不启用杀分切换;开启下方「测试内杀分」后,可单独设置测试安全线(与彩金池配置中的安全线无关),用于模拟杀分触发。",
"chainModeHint": "模拟方式:只配置付费抽奖次数(顺/逆时针)。付费抽到「再来一次」或 T5 时,下一局自动为免费抽奖,底注与触发局相同,抽奖类型记为免费、付费金额记为 0。免费抽奖的档位概率由下方「免费抽奖」配置决定含通过再来一次触发的后续免费局。",
"killModeHint": "杀分开关开启后:以“模拟玩家累计盈利”作为判定值;当累计盈利 >= 安全线时,后续付费抽奖 killScore 配置抽取;免费抽奖仍按“免费抽奖配置”执行。",
"labelKillModeEnabled": "开启测试内杀分",
"killModeHint": "杀分开关开启后:从 default 奖池当前 profit_amount 起步逐局累加(付费=win_coin-paid_amount免费=win_coin当累计盈利 ≥ 下方「测试安全线时,后续付费抽奖 killScore免费抽奖仍走 name=free 奖池。",
"killModePanelTitle": "测试内杀分",
"killModeSwitchOn": "已开启",
"killModeSwitchOff": "已关闭",
"labelTestSafetyLine": "测试安全线",
"testSafetyLineHint": "仅用于本次权重测试,与彩金池配置页的安全线独立;可设较小值以便快速观察杀分切换效果。",
"poolProfitRef": "参考:当前 default 池盈利 {profit},彩金池配置安全线 {line}",
"killModeOffHint": "关闭时全程按付费/免费配置抽档,不模拟杀分切换。",
"sectionPaid": "付费抽奖",
"sectionFreeAfterPlayAgain": "免费抽奖(再来一次后的档位概率",
"tierProbHintFreeChain": "当使用自定义档位时:以下为「免费抽奖」时 T1T5 档位概率(仅在有免费局时参与摇档,与 dice_reward 格子权重共同决定结果)。",
"sectionFreeAfterPlayAgain": "免费抽奖(再来一次)",
"tierProbHintFreeChain": "自定义档位时:免费局 T1T5 档位概率(与 dice_reward 格子权重共同决定结果)。",
"stepPaid": "付费抽奖券",
"stepFree": "免费抽奖券",
"labelLotteryTypePaid": "测试数据档位类型",
"labelLotteryTypeFree": "测试数据档位类型",
"labelLotteryTypePaid": "付费档位奖池",
"labelLotteryTypeFree": "免费档位奖池",
"labelAnte": "底注",
"placeholderAnte": "请选择底注配置",
"placeholderPaidPool": "不选则下方自定义档位概率(默认 default",
"placeholderFreePool": "不选则下方自定义档位概率(默认 killScore",
"anteRandomOption": "随机(每局付费抽奖从当前渠道底注配置中独立抽取",
"placeholderPaidPool": "不选则下方手动设定 T1T5 档位权重",
"placeholderFreePool": "不选则下方手动设定 T1T5 档位权重",
"selectedPoolHint": "已选奖池:{name}",
"tierProbHint": "自定义档位概率T1T5每档 0-100%,五档之和不能超过 100%",
"tierFieldLabel": "档位 {tier}%",
"tierSumError": "当前五档之和为 {sum}%,不能超过 100%",

View File

@@ -8,7 +8,19 @@
"tabIndex": "奖励索引",
"tabBigwin": "大奖权重",
"tipIndex": "色子点数须在 530 之间且本表内不重复。",
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、实际中奖、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
"tierRecommendRules": "【结算金额与档位】【大奖】T1结算金额>2【小赚】T22>=结算金额>1【抽水】T31>=结算金额>0【惩罚】T40>结算金额【再来一次】T50=结算金额。下方可为各档位填写推荐结算金额;表格中「所属档位」随结算金额自动计算,不可手动修改。",
"tierRecommendRealEv": "推荐结算金额",
"tierRecommendAutoMatch": "修改结算金额时自动匹配档位,并实时更新备注(大奖/小赚/抽水/惩罚/再来一次)",
"tierRecommendApplyAmount": "将推荐金额填入已选档位的行",
"tierRecommendApplyAmountOk": "已为 {n} 行填入推荐结算金额",
"tierRecommendNoTierRows": "没有可根据结算金额推断档位的行",
"tierRecommendMatchTier": "按结算金额匹配全部档位",
"tierRecommendMatchTierOk": "已根据结算金额为 {n} 行匹配档位",
"tierRecommendMatchTierNone": "没有可匹配档位的行",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"colTierAutoHint": "根据结算金额自动匹配",
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、结算金额、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
"colId": "索引(id)",
"colDicePoints": "色子点数",
"colDisplayText": "显示文本",
@@ -26,7 +38,7 @@
"colBigwinPoints": "中大奖点数",
"colDisplayInfo": "显示信息",
"colDisplayInfoEn": "显示信息(英文)",
"colRealPrize": "实际中奖",
"colRealPrize": "结算金额",
"colWeightRange": "权重(0-10000)",
"placeholderDisplayInfoZh": "显示信息(中文)",
"placeholderDisplayInfoEn": "显示信息(英文)",
@@ -36,6 +48,19 @@
"confirmCreateRefMsg": "按规则创建奖励对照:起始索引 start_index=奖励配置中 grid_number 对应格位的 id顺时针 end_index=(start_index+摇取点数)%26逆时针 end_index=start_index-摇取点数≥0 则取该值,否则 26+start_index-摇取点数。先清空现有数据再为 5-30 共 26 个点数、顺/逆时针分别生成。是否继续?",
"confirmCreateRefOk": "确定创建",
"confirmCreateRefCancel": "取消",
"createRefPreviewTitle": "创建奖励对照预览",
"createRefPreviewClockwise": "顺时针",
"createRefPreviewCounterclockwise": "逆时针",
"createRefPreviewTipUnchanged": "检测到色子点数映射未变化预览中权重将复用当前奖励对照表dice_reward的权重导入时不会覆盖现有权重。",
"createRefPreviewTipChanged": "检测到色子点数映射已变化:预览中权重将使用默认值(默认 100点数和为 5/10/15/20/25/30 默认 1确认导入后可再到「奖励对照」页面调整权重。",
"createRefPreviewSkipped": "有 {n} 个点数在当前奖励索引中缺失,已跳过生成(请先补齐 530 共 26 个点数)。",
"createRefPreviewRefresh": "刷新预览",
"createRefPreviewImport": "确认导入",
"createRefPreviewImportOk": "已导入奖励对照表",
"createRefPreviewImportNoop": "色子点数映射未变化,无需导入(已保留现有权重)",
"createRefPreviewDiff": "差异(旧 → 新)",
"createRefPreviewNoDiff": "无变化",
"createRefPreviewWeightsSaved": "已保存权重",
"createRefSuccess": "已按 5-30 共 26 个点数、顺时针+逆时针创建:顺时针新增 {cwNew} 条、逆时针新增 {ccwNew} 条;顺时针更新 {cwUp} 条、逆时针更新 {ccwUp} 条{skippedPart}",
"createRefSuccessSkipped": "{n} 个点数使用兜底起始索引",
"createRefSuccessSimple": "创建成功",
@@ -54,7 +79,7 @@
"infoNoBigwin": "暂无 BIGWIN 档位配置,请先在「奖励索引」中设置 tier 为 BIGWIN",
"btnRuleGenerate": "按规则生成",
"ruleGenerateTitle": "按规则生成奖励索引",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「grid_number=D」所在格位的 id即 start_index顺时针落点位置 = (起点位置 + D) mod 26逆时针落点 = 起点位置 D若小于 0 则 +26。\n• 对照表每条记录的「色子点数」列为摇取点数 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n【豹子摇取点数】\n摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5避免对照表上出现豹子点数 + 惩罚/再来一次)。\n\n【结算金额 与 档位】\n结算金额 < 0 → T40 < 结算金额 < 100 → T3100 < 结算金额 < 200 → T2200 < 结算金额 → T1T5「再来一次」结算金额=0。下方可为各档位填写统一的 结算金额 标准,生成时写入配置;细则可稍后在表格中再改。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T4 的 显示文本 / 显示文本(英文) = 结算金额T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「grid_number=D」所在格位的 id即 start_index顺时针落点位置 = (起点位置 + D) mod 26逆时针落点 = 起点位置 D若小于 0 则 +26。\n• 对照表每条记录的「色子点数」列为摇取点数 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n【豹子摇取点数】\n摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5避免对照表上出现豹子点数 + 惩罚/再来一次)。\n\n【结算金额 与 档位】\n【大奖】T1>2【小赚】T22>=金额>1【抽水】T31>=金额>0【惩罚】T40>金额【再来一次】T50=金额。下方可为各档位填写推荐结算金额标准,生成时写入配置。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T4 的显示文本 = 结算金额T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenT1Row": "T1 大奖",
"ruleGenT2Row": "T2 小赚/回本",
"ruleGenT3RealEvOnly": "T3 抽水",
@@ -64,11 +89,11 @@
"ruleGenFixedCount": "固定条数(顺/逆)",
"ruleGenRealEvStd": "结算金额",
"ruleGenRealEvEditHint": "生成并保存后,仍可在本页表格中逐条修改显示文案、英文、真实结算与备注。",
"ruleGenInvalidT1RealEv": "T1结算金额 满足:200 < 值",
"ruleGenInvalidT2RealEv": "T2结算金额 满足100 < 值 < 200",
"ruleGenInvalidT3RealEv": "T3结算金额 满足0 < 值 < 100",
"ruleGenInvalidT4RealEv": "T4结算金额 满足:值 < 0",
"ruleGenInvalidT5RealEv": "T5再来一次」的 结算金额 须为 0",
"ruleGenInvalidT1RealEv": "T1(大奖)结算金额满足:值 > 2",
"ruleGenInvalidT2RealEv": "T2(小赚)结算金额满足1 < 值 2",
"ruleGenInvalidT3RealEv": "T3(抽水)结算金额满足0 < 值 1",
"ruleGenInvalidT4RealEv": "T4(惩罚)结算金额满足:值 < 0",
"ruleGenInvalidT5RealEv": "T5再来一次结算金额须为 0",
"ruleGenT1Min": "T1 固定条数(顺/逆)",
"ruleGenT2Min": "T2 最少条数(顺/逆)",
"ruleGenT4Max": "T4 固定条数(顺/逆)",

View File

@@ -17,6 +17,8 @@
"chainModeNo": "否",
"paidPlannedSpins": "计划付费次数",
"ante": "底注",
"anteRandom": "随机",
"testSafetyLine": "安全线",
"playAgainCount": "再来一次次数",
"progressDraws": "已完成 {over} 次",
"progressFailed": "失败前 {over} 次",
@@ -51,11 +53,13 @@
"testCountProgress": "进行中:已完成 {over} 次",
"testCountFailed": "失败前 {over} 次",
"chainModeLabel": "链式再来一次",
"killModeOff": "未开启杀分",
"paidPlannedSpins": "计划付费次数",
"testSafetyLine": "测试安全线",
"createTime": "创建时间",
"admin": "执行管理员",
"paidPoolId": "付费奖池配置ID",
"freePoolId": "免费奖池配置ID",
"paidPoolId": "付费彩金池",
"freePoolId": "免费彩金池",
"bigwinSnapshot": "BIGWIN 权重快照",
"sectionPaidTier": "付费抽奖档位概率T1-T5测试时使用",
"sectionFreeTier": "免费抽奖档位概率T1-T5测试时使用",

View File

@@ -0,0 +1,27 @@
{
"title": "后台操作指南",
"toolbar": {
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"refresh": "刷新"
},
"meta": {
"filePath": "文档路径",
"updateTime": "更新时间"
},
"catalog": {
"title": "目录",
"empty": "暂无目录"
},
"image": {
"zoom": "点击查看原图"
},
"message": {
"loadFailed": "加载操作指南失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"cancelConfirm": "当前有未保存的修改,确定取消编辑吗?",
"editRequired": "请先点击编辑后再保存"
}
}

View File

@@ -7,6 +7,23 @@ const BUILTIN_CHANNEL_LAYOUT_PATHS = [
'/system/dept'
]
/** 运维页里不需要按渠道分栏的页面 */
const NO_CHANNEL_LAYOUT_PATHS = [
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log',
'/admin_guide'
]
/** 日志页左侧首项为「全部」dept_id=0 表示不按渠道过滤 */
const ALL_CHANNEL_SCOPE_PATHS = [
'/safeguard/login-log',
'/safeguard/oper-log'
]
export function isSuperAdminUser(): boolean {
const userStore = useUserStore()
return Number(userStore.info?.id ?? 0) === 1
@@ -28,6 +45,20 @@ export function isRoleChannelRoute(route: Pick<RouteLocationNormalized, 'path' |
return route.path.startsWith('/system/role')
}
export function isAllChannelScopeRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.channelScope === 'all') {
return true
}
return ALL_CHANNEL_SCOPE_PATHS.some((item) => route.path.startsWith(item))
}
export function isNoChannelLayoutRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.noChannelLayout === true) {
return true
}
return NO_CHANNEL_LAYOUT_PATHS.some((item) => route.path.startsWith(item))
}
export function shouldWrapSuperAdminChannelLayout(route: RouteLocationNormalized): boolean {
if (!isSuperAdminUser()) {
return false
@@ -35,10 +66,10 @@ export function shouldWrapSuperAdminChannelLayout(route: RouteLocationNormalized
if (route.meta?.isFullPage) {
return false
}
if (route.meta?.noChannelLayout === true) {
const path = route.path
if (isNoChannelLayoutRoute(route)) {
return false
}
const path = route.path
for (let i = 0; i < BUILTIN_CHANNEL_LAYOUT_PATHS.length; i++) {
if (path.startsWith(BUILTIN_CHANNEL_LAYOUT_PATHS[i])) {
return false

View File

@@ -16,10 +16,11 @@
*/
import { AppRouteRecord } from '@/types/router'
import { router } from '@/router'
import { resolveAppAssetUrl } from '@/utils/navigation/resolveAppAssetUrl'
// 打开外部链接
// 打开外部链接(含站内静态页,自动拼接部署 base
export const openExternalLink = (link: string) => {
window.open(link, '_blank')
window.open(resolveAppAssetUrl(link), '_blank', 'noopener,noreferrer')
}
/**

View File

@@ -0,0 +1,19 @@
/**
* 解析菜单外链 / 静态资源路径(兼容 VITE_BASE_URL 子目录部署)
*/
export function resolveAppAssetUrl(relativeOrAbsolute: string): string {
const link = relativeOrAbsolute.trim()
if (!link) {
return link
}
if (/^https?:\/\//i.test(link)) {
return link
}
const base = import.meta.env.BASE_URL || '/'
const normalizedBase = base.endsWith('/') ? base : `${base}/`
const path = link.startsWith('/') ? link.slice(1) : link
if (typeof window === 'undefined') {
return `${normalizedBase}${path}`
}
return `${window.location.origin}${normalizedBase}${path}`
}

View File

@@ -9,13 +9,19 @@
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item :label="$t('page.form.labelName')" prop="name">
<el-input v-model="formData.name" :placeholder="$t('page.form.placeholderName')" />
<el-input v-model="formData.name" disabled :placeholder="$t('page.form.placeholderNameAuto')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelTitle')" prop="title">
<el-input v-model="formData.title" :placeholder="$t('page.form.placeholderTitle')" />
<el-input v-model="formData.title" disabled :placeholder="$t('page.form.placeholderTitleAuto')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelMult')" prop="mult">
<el-input-number v-model="formData.mult" :min="1" :step="1" style="width: 100%" />
<el-input-number
v-model="formData.mult"
:min="1"
:step="1"
style="width: 100%"
@update:model-value="syncNameTitleFromMult"
/>
</el-form-item>
<el-form-item :label="$t('page.form.labelIsDefault')" prop="is_default">
<el-radio-group v-model="formData.is_default">
@@ -87,6 +93,13 @@
const formData = reactive({ ...initialFormData })
function syncNameTitleFromMult() {
const mult = Number(formData.mult) || 1
const label = `x${mult}`
formData.name = label
formData.title = label
}
watch(
() => props.modelValue,
async (newVal) => {
@@ -99,6 +112,7 @@
if (typeof props.data.title === 'string') formData.title = props.data.title
formData.mult = Number(props.data.mult ?? 1) || 1
formData.is_default = Number(props.data.is_default ?? 0) === 1 ? 1 : 0
syncNameTitleFromMult()
}
)

View File

@@ -1,4 +1,29 @@
import request from '@/utils/http'
import {
normalizeLotteryPoolOption,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
export type LotteryPoolConfigOption = LotteryPoolOption & {
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}
/** 规范化接口返回的彩金池配置(含权重) */
export function parseLotteryPoolConfigOption(raw: Record<string, unknown>): LotteryPoolConfigOption {
const base = normalizeLotteryPoolOption(raw)
return {
...base,
t1_weight: Number(raw.t1_weight ?? 0),
t2_weight: Number(raw.t2_weight ?? 0),
t3_weight: Number(raw.t3_weight ?? 0),
t4_weight: Number(raw.t4_weight ?? 0),
t5_weight: Number(raw.t5_weight ?? 0)
}
}
/**
* 色子奖池配置 API 接口
@@ -20,32 +45,14 @@ export default {
* 获取 DiceLotteryPoolConfig 列表数据,含 id、name、t1_weightt5_weight用于一键测试权重档位类型下拉
* name 映射default=原 type=0killScore=原 type=1up=原 type=2
*/
async getOptions(params?: Record<string, unknown>): Promise<
Array<{
id: number
name: string
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}>
> {
async getOptions(params?: Record<string, unknown>): Promise<LotteryPoolConfigOption[]> {
const res = await request.get<any>({
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions',
params
})
const rows = Array.isArray(res) ? res : (Array.isArray((res as any)?.data) ? (res as any).data : [])
if (!Array.isArray(rows)) return []
return rows.map((r: any) => ({
id: Number(r.id),
name: String(r.name ?? r.id ?? ''),
t1_weight: Number(r.t1_weight ?? 0),
t2_weight: Number(r.t2_weight ?? 0),
t3_weight: Number(r.t3_weight ?? 0),
t4_weight: Number(r.t4_weight ?? 0),
t5_weight: Number(r.t5_weight ?? 0)
}))
return rows.map((r: Record<string, unknown>) => parseLotteryPoolConfigOption(r))
},
/**

View File

@@ -1,4 +1,5 @@
import request from '@/utils/http'
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/**
* 玩家抽奖记录 API接口
@@ -59,11 +60,13 @@ export default {
})
},
/** 获取彩金池配置选项(id、name */
getLotteryConfigOptions(params?: Record<string, unknown>) {
return request.get<{ id: number; name: string }[]>({
/** 获取彩金池配置选项(含奖池名称 */
async getLotteryConfigOptions(params?: Record<string, unknown>): Promise<LotteryPoolOption[]> {
const res = await request.get<any>({
url: '/core/dice/play_record/DicePlayRecord/getLotteryConfigOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<Record<string, unknown>>
return rows.map((r) => normalizeLotteryPoolOption(r))
}
}

View File

@@ -1,4 +1,5 @@
import request from '@/utils/http'
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/**
* 大富翁-玩家 API接口
@@ -84,16 +85,15 @@ export default {
},
/**
* 获取彩金池配置选项DiceLotteryPoolConfig.id、name,供 lottery_config_id 下拉使用
* @returns [ { id, name } ]
* 获取彩金池配置选项,供 lottery_config_id 下拉使用(含奖池名称 display_name
*/
async getLotteryConfigOptions(params?: Record<string, unknown>): Promise<Array<{ id: number; name: string }>> {
async getLotteryConfigOptions(params?: Record<string, unknown>): Promise<LotteryPoolOption[]> {
const res = await request.get<any>({
url: '/core/dice/player/DicePlayer/getLotteryConfigOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<{ id: number; name: string }>
return rows.map((r) => ({ id: Number(r.id), name: String(r.name ?? r.id ?? '') }))
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<Record<string, unknown>>
return rows.map((r) => normalizeLotteryPoolOption(r))
},
/**

View File

@@ -76,6 +76,27 @@ export default {
})
},
/**
* 按规则生成并保存奖励索引(需 dice:reward_config:index:tierRecommend 权限)
*/
generateIndexByRules(
items: Array<{
id: number
grid_number?: number
ui_text?: string
ui_text_en?: string
real_ev?: number
tier?: string
remark?: string
}>,
extra?: Record<string, any>
) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/generateIndexByRules',
data: { items, ...(extra || {}) }
})
},
/**
* T1-T5、BIGWIN 权重配比:按档位分组获取配置列表
*/
@@ -126,5 +147,15 @@ export default {
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReference',
data: params || {}
})
},
/**
* 创建奖励对照(预览):不写库,返回将要生成的对照与权重预览
*/
createRewardReferencePreview(params?: Record<string, any>) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReferencePreview',
data: params || {}
})
}
}

View File

@@ -29,6 +29,12 @@
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<template #safety_line="{ row }">
<span v-if="isDefaultPoolRow(row)" class="font-mono">{{
formatSafetyLine(row.safety_line)
}}</span>
<span v-else class="text-gray-400 text-xs">{{ $t('page.table.safetyLineNotUsed') }}</span>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
@@ -81,13 +87,10 @@
getData()
}
// 奖池类型展示:按 name 映射
const typeFormatter = (row: Record<string, unknown>) => {
const n = String(row.name ?? '')
if (n === 'default') return t('page.search.poolTypeNormal')
if (n === 'killScore') return t('page.search.poolTypeKill')
if (n === 'up') return t('page.search.poolTypeT1')
return n || '-'
const poolNameFormatter = (row: Record<string, unknown>) => {
const remark = String(row.remark ?? '').trim()
if (remark) return remark
return String(row.name ?? '').trim() || '-'
}
// 权重列带 %
@@ -96,6 +99,17 @@
return v != null && v !== '' ? `${v}%` : '-'
}
/** 仅 name=default正常奖池的安全线参与杀分判定 */
function isDefaultPoolRow(row: Record<string, unknown>): boolean {
return String(row.name ?? '') === 'default'
}
function formatSafetyLine(val: unknown): string {
if (val === null || val === undefined || val === '') return '-'
const n = typeof val === 'number' ? val : Number(val)
return Number.isFinite(n) ? n.toFixed(2) : '-'
}
// 表格配置
const {
columns,
@@ -114,9 +128,26 @@
core: {
apiFn: api.list,
columnsFactory: () => [
{ prop: 'name', label: 'page.table.name', align: 'center' },
{ prop: 'name', label: 'page.table.poolType', width: 100, align: 'center', formatter: typeFormatter },
{ prop: 'safety_line', label: 'page.table.safetyLine', align: 'center' },
{ prop: 'remark', label: 'page.table.poolName', minWidth: 120, align: 'center', formatter: poolNameFormatter },
{
prop: 'config_note',
label: 'page.table.configNote',
minWidth: 140,
align: 'center',
showOverflowTooltip: true,
formatter: (row: Record<string, unknown>) => {
const v = String(row.config_note ?? '').trim()
return v || '-'
}
},
{ prop: 'name', label: 'page.table.name', width: 110, align: 'center' },
{
prop: 'safety_line',
label: 'page.table.safetyLine',
minWidth: 120,
align: 'center',
useSlot: true
},
{
prop: 't1_weight',
label: 'page.table.t1PoolWeight',

View File

@@ -16,7 +16,7 @@
</div>
<div class="profit-row mb-3">
<div class="flex items-center gap-2">
<span class="text-gray-500">{{ $t('page.form.playerProfit') }}</span>
<span class="text-gray-500">{{ $t('page.form.poolProfitAmount') }}</span>
<span class="font-mono text-lg" :class="profitAmountClass">{{
displayProfitAmount
}}</span>
@@ -41,6 +41,7 @@
:precision="2"
style="width: 100%"
/>
<div class="text-gray-500 text-xs mt-1">{{ $t('page.table.safetyLineTip') }}</div>
</el-form-item>
<el-form-item :label="$t('page.form.enableKillScore')">
<el-switch v-model="formData.kill_enabled" :active-value="1" :inactive-value="0" />
@@ -239,6 +240,8 @@
() => props.modelValue,
(open) => {
if (open) {
// 切换渠道后再打开弹窗时,先清掉旧池数据,避免视觉残留
pool.value = null
loadPool().then(() => startPolling())
} else {
stopPolling()

View File

@@ -15,24 +15,41 @@
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item :label="$t('form.labelRemark')" prop="remark">
<el-form-item :label="$t('page.form.poolName')" prop="remark">
<el-input
v-model="formData.remark"
:placeholder="$t('page.form.placeholderPoolName')"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item :label="$t('form.labelRemark')" prop="config_note">
<el-input
v-model="formData.config_note"
type="textarea"
:rows="3"
:placeholder="$t('page.form.placeholderRemark')"
:placeholder="$t('page.form.placeholderConfigNote')"
maxlength="500"
show-word-limit
/>
</el-form-item>
<!-- dice_lottery_pool_config 已移除 type 字段 name 区分 default/killScore/upname -->
<el-form-item :label="$t('page.form.safetyLine')" prop="safety_line">
<el-form-item v-if="showSafetyLineField" :label="$t('page.form.safetyLine')" prop="safety_line">
<el-input-number
v-model="formData.safety_line"
:min="0"
:precision="2"
style="width: 100%"
/>
<div class="text-gray-500 text-xs mt-1">{{ $t('page.table.safetyLineTip') }}</div>
</el-form-item>
<el-form-item v-else-if="showSafetyLineReadonlyHint" :label="$t('page.form.safetyLine')">
<el-alert
type="info"
:closable="false"
show-icon
:title="$t('page.form.safetyLineNotUsedReadonly')"
/>
</el-form-item>
<el-form-item :label="$t('page.form.t1Weight')" prop="t1_weight">
<el-slider v-model="formData.t1_weight" :min="0" :max="100" :step="0.01" show-input />
@@ -111,17 +128,36 @@
return WEIGHT_KEYS.reduce((sum, key) => sum + Number(formData[key] ?? 0), 0)
})
function isDefaultPoolName(name: unknown): boolean {
return String(name ?? '') === 'default'
}
const showSafetyLineField = computed(() => isDefaultPoolName(formData.name))
const showSafetyLineReadonlyHint = computed(() => {
const n = String(formData.name ?? '').trim()
return n !== '' && !isDefaultPoolName(n)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: t('page.form.ruleNameRequired'), trigger: 'blur' }],
t1_weight: [{ required: true, message: t('page.form.ruleT1Required'), trigger: 'blur' }],
t2_weight: [{ required: true, message: t('page.form.ruleT2Required'), trigger: 'blur' }],
t3_weight: [{ required: true, message: t('page.form.ruleT3Required'), trigger: 'blur' }],
t4_weight: [{ required: true, message: t('page.form.ruleT4Required'), trigger: 'blur' }],
t5_weight: [{ required: true, message: t('page.form.ruleT5Required'), trigger: 'blur' }]
}))
const rules = computed<FormRules>(() => {
const base: FormRules = {
name: [{ required: true, message: t('page.form.ruleNameRequired'), trigger: 'blur' }],
t1_weight: [{ required: true, message: t('page.form.ruleT1Required'), trigger: 'blur' }],
t2_weight: [{ required: true, message: t('page.form.ruleT2Required'), trigger: 'blur' }],
t3_weight: [{ required: true, message: t('page.form.ruleT3Required'), trigger: 'blur' }],
t4_weight: [{ required: true, message: t('page.form.ruleT4Required'), trigger: 'blur' }],
t5_weight: [{ required: true, message: t('page.form.ruleT5Required'), trigger: 'blur' }]
}
if (showSafetyLineField.value) {
base.safety_line = [
{ required: true, message: t('page.form.ruleSafetyLineRequired'), trigger: 'blur' }
]
}
return base
})
/**
* 初始数据(权重为数字便于输入与校验)
@@ -131,6 +167,7 @@
dept_id: undefined as number | undefined,
name: '',
remark: '',
config_note: '',
safety_line: 0 as number,
t1_weight: 0 as number,
t2_weight: 0 as number,
@@ -224,11 +261,15 @@
props.data?.dept_id ??
channelScope?.selectedDeptId.value
})
const { safety_line, ...submitWithoutSafetyLine } = submitData
const payload = isDefaultPoolName(submitData.name)
? submitData
: submitWithoutSafetyLine
if (props.dialogType === 'add') {
await api.save(submitData)
await api.save(payload)
ElMessage.success(t('page.form.msgAddSuccess'))
} else {
await api.update(submitData)
await api.update(payload)
ElMessage.success(t('page.form.msgUpdateSuccess'))
}
emit('success')

View File

@@ -1,7 +1,7 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleResetSearch" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
@@ -143,6 +143,7 @@
import api from '../../api/play_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
const { t } = useI18n()
// 搜索表单
@@ -157,9 +158,40 @@
roll_number_max: undefined,
reward_ui_text: undefined,
reward_tier: undefined,
direction: undefined
direction: undefined,
create_time: undefined
})
const PLAY_RECORD_SEARCH_KEYS = [
'username',
'lottery_config_name',
'lottery_type',
'is_win',
'win_coin_min',
'win_coin_max',
'roll_number_min',
'roll_number_max',
'reward_ui_text',
'reward_tier',
'direction',
'create_time_min',
'create_time_max'
] as const
const applySearchParams = (params: Record<string, unknown>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
const paramsRecord = searchParams as Record<string, unknown>
PLAY_RECORD_SEARCH_KEYS.forEach((key) => {
delete paramsRecord[key]
})
Object.assign(searchParams, p)
}
/** 当前筛选下平台总盈利(付费金额 paid_amount 求和 - 玩家总收益) */
const totalWinCoin = ref<number | null>(null)
@@ -169,16 +201,19 @@
return res
}
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
applySearchParams(params)
getData()
}
const handleResetSearch = () => {
searchForm.value.create_time = undefined
resetSearchParams()
}
const usernameFormatter = (row: Record<string, any>) =>
row?.dicePlayer?.username ?? row?.player_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) =>
row?.diceLotteryPoolConfig?.name ?? row?.lottery_config_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) => lotteryPoolRowLabel(row)
const rewardTierFormatter = (row: Record<string, any>) => row?.reward_tier ?? '-'
/** 摇取点数格式化为 1,3,4,5,6,6 */
@@ -221,6 +256,7 @@
core: {
apiFn: listApi,
apiParams: { limit: 100 },
excludeParams: ['create_time'],
columnsFactory: () => [
// { type: 'selection' },
{ prop: 'id', label: 'page.table.id', width: 80 },
@@ -253,6 +289,13 @@
width: 100,
formatter: (row: Record<string, any>) => rewardTierFormatter(row)
},
{
prop: 'remark',
label: 'page.table.remark',
width: 200,
showOverflowTooltip: true,
formatter: (row: Record<string, any>) => row?.remark || '-'
},
{ prop: 'create_time', label: 'page.table.createTime', width: 170 },
{ prop: 'update_time', label: 'page.table.updateTime', width: 170 },
{

View File

@@ -37,7 +37,7 @@
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="item.name"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -180,6 +180,10 @@
<script setup lang="ts">
import api from '../../../api/play_record/index'
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
import {
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import type { FormInstance } from 'element-plus'
interface Props {
@@ -209,7 +213,7 @@
})
const playerOptions = ref<Array<{ id: number; username: string }>>([])
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
const initialFormData = {
id: null as number | null,

View File

@@ -105,6 +105,19 @@
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item :label="$t('page.search.createTime')" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
:range-separator="$t('table.searchBar.rangeSeparator')"
:start-placeholder="$t('table.searchBar.startTime')"
:end-placeholder="$t('table.searchBar.endTime')"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>

View File

@@ -155,10 +155,12 @@
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
const { t } = useI18n()
// 搜索表单(与 play_record 对齐:方向、赢取平台币范围、是否中大奖、中奖档位、点数和)
const searchForm = ref<Record<string, unknown>>({
lottery_config_id: undefined,
reward_config_record_id: undefined,
lottery_type: undefined,
direction: undefined,
@@ -180,8 +182,7 @@
return res
}
const lotteryConfigNameFormatter = (row: Record<string, any>) =>
row?.diceLotteryPoolConfig?.name ?? row?.lottery_config_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) => lotteryPoolRowLabel(row)
const rewardTierFormatter = (row: Record<string, any>) => row?.reward_tier ?? '-'
/** 摇取点数格式化为 1,3,4,5,6 */

View File

@@ -8,6 +8,29 @@
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.lotteryPoolConfig')" prop="lottery_config_id">
<el-select
v-model="formData.lottery_config_id"
:placeholder="$t('page.search.placeholderLotteryPool')"
clearable
filterable
remote
reserve-keyword
:loading="lotteryPoolLoading"
:remote-method="filterLotteryPoolOptions"
style="width: 100%"
@visible-change="onLotteryPoolDropdownVisible"
>
<el-option
v-for="item in lotteryPoolOptions"
:key="item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.rewardConfigRecordId')" prop="reward_config_record_id">
<el-input-number
@@ -120,6 +143,17 @@
</template>
<script setup lang="ts">
import lotteryPoolApi from '../../../api/lottery_pool_config/index'
import {
getChannelDeptRequestParams,
useInjectedChannelDept
} from '@/composables/useChannelDeptScope'
import {
filterLotteryPoolOptionsByQuery,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
interface Props {
modelValue: Record<string, any>
}
@@ -131,6 +165,57 @@
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
const channelScope = useInjectedChannelDept()
const lotteryPoolAllOptions = ref<LotteryPoolOption[]>([])
const lotteryPoolOptions = ref<LotteryPoolOption[]>([])
const lotteryPoolLoading = ref(false)
function resolveDeptParams(): Record<string, unknown> {
const extra = getChannelDeptRequestParams()
if (extra.dept_id !== undefined) {
return extra
}
if (channelScope) {
return { dept_id: channelScope.selectedDeptId.value }
}
return {}
}
async function loadLotteryPoolOptions() {
lotteryPoolLoading.value = true
try {
const list = await lotteryPoolApi.getOptions(resolveDeptParams())
lotteryPoolAllOptions.value = list
lotteryPoolOptions.value = list
} catch {
lotteryPoolAllOptions.value = []
lotteryPoolOptions.value = []
} finally {
lotteryPoolLoading.value = false
}
}
function filterLotteryPoolOptions(query: string) {
lotteryPoolOptions.value = filterLotteryPoolOptionsByQuery(lotteryPoolAllOptions.value, query)
}
function onLotteryPoolDropdownVisible(visible: boolean) {
if (visible && lotteryPoolAllOptions.value.length === 0) {
void loadLotteryPoolOptions()
}
}
onMounted(() => {
void loadLotteryPoolOptions()
})
watch(
() => channelScope?.selectedDeptId.value,
() => {
void loadLotteryPoolOptions()
}
)
const searchBarRef = ref()
const formData = computed({

View File

@@ -115,6 +115,7 @@
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import WalletOperateDialog from './modules/WalletOperateDialog.vue'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
const { t } = useI18n()
const { copy } = useClipboard()
@@ -141,10 +142,13 @@
return cellValue != null && cellValue !== '' ? `${cellValue}%` : '-'
}
// 根据 lottery_config_id 显示彩金池配置名称
const lotteryConfigNameFormatter = (row: any) =>
row?.diceLotteryPoolConfig?.name ??
(row?.lottery_config_id ? `#${row.lottery_config_id}` : t('page.table.customConfig'))
const lotteryConfigNameFormatter = (row: any) => {
const label = lotteryPoolRowLabel(row)
if (label === '-' && !row?.lottery_config_id) {
return t('page.table.customConfig')
}
return label
}
// 表格
const {

View File

@@ -88,7 +88,7 @@
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="(item.name && String(item.name).trim()) || `#${item.id}`"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -97,12 +97,12 @@
<el-form-item v-if="currentLotteryConfig" :label="$t('page.form.currentConfig')" class="current-config-block">
<div class="current-lottery-config">
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelName') }}</span>
<span>{{ currentLotteryConfig.name ?? '-' }}</span>
<span class="config-label">{{ $t('page.form.configLabelPoolName') }}</span>
<span>{{ lotteryPoolDisplayLabel(currentLotteryConfig) }}</span>
</div>
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelType') }}</span>
<span>{{ lotteryConfigTypeText(currentLotteryConfig.name) }}</span>
<span class="config-label">{{ $t('page.form.configLabelCode') }}</span>
<span>{{ currentLotteryConfig.name ?? '-' }}</span>
</div>
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelWeights') }}</span>
@@ -183,8 +183,16 @@
<script setup lang="ts">
import api from '../../../api/player/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import lotteryConfigApi, {
parseLotteryPoolConfigOption,
type LotteryPoolConfigOption
} from '../../../api/lottery_pool_config/index'
import { useI18n } from 'vue-i18n'
import {
lotteryPoolDisplayLabel,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import { getChannelDeptRequestParams, withChannelDeptParams } from '@/composables/useChannelDeptScope'
import { isSuperAdminUser } from '@/utils/channelLayout'
import { ElMessage } from 'element-plus'
@@ -272,7 +280,7 @@
const formData = reactive({ ...initialFormData })
/** 彩金池配置下拉选项DiceLotteryConfig id、name */
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
/** 彩金池选项加载中 */
const lotteryConfigLoading = ref(false)
/** 后台管理员下拉选项SystemUser */
@@ -298,7 +306,21 @@
/** 管理员选项加载中 */
const systemUserOptionsLoading = ref(false)
/** 当前选中的 DiceLotteryConfig 完整数据(用于展示) */
const currentLotteryConfig = ref<Record<string, any> | null>(null)
const currentLotteryConfig = ref<LotteryPoolConfigOption | null>(null)
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function extractReadPayload(res: unknown): Record<string, unknown> | null {
if (!isRecord(res)) {
return null
}
if (isRecord(res.data)) {
return res.data
}
return res
}
function lotteryConfigTypeText(name: unknown): string {
const n = String(name ?? '')
@@ -314,6 +336,15 @@
return v == null || v === 0
}
/** 将彩金池配置的 T1T5 写入表单(绑定彩金池时展示与提交均以池为准) */
function applyPoolWeightsToForm(cfg: LotteryPoolConfigOption) {
formData.t1_weight = Number(cfg.t1_weight ?? 0)
formData.t2_weight = Number(cfg.t2_weight ?? 0)
formData.t3_weight = Number(cfg.t3_weight ?? 0)
formData.t4_weight = Number(cfg.t4_weight ?? 0)
formData.t5_weight = Number(cfg.t5_weight ?? 0)
}
/** 根据当前 lottery_config_id 加载 DiceLotteryConfig并将五个权重写入当前 player.*_weight */
async function loadCurrentLotteryConfig() {
const id = formData.lottery_config_id
@@ -323,12 +354,11 @@
}
try {
const res = await lotteryConfigApi.read(id)
const row = (res as any)?.data ?? (res as any)
if (row && typeof row === 'object') {
const payload = extractReadPayload(res)
if (payload) {
const row = parseLotteryPoolConfigOption(payload)
currentLotteryConfig.value = row
WEIGHT_FIELDS.forEach((key) => {
;(formData as any)[key] = Number(row[key] ?? 0)
})
applyPoolWeightsToForm(row)
} else {
currentLotteryConfig.value = null
}
@@ -337,6 +367,15 @@
}
}
watch(
() => currentLotteryConfig.value,
(cfg) => {
if (cfg && !isLotteryConfigEmpty()) {
applyPoolWeightsToForm(cfg)
}
}
)
watch(
() => props.modelValue,
(newVal) => {
@@ -352,11 +391,10 @@
}
try {
const res = await lotteryConfigApi.read(lotteryConfigId)
const row = (res as any)?.data ?? (res as any)
if (row && typeof row === 'object') {
WEIGHT_FIELDS.forEach((key) => {
;(formData as any)[key] = Number(row[key] ?? 0)
})
const payload = extractReadPayload(res)
if (payload) {
const row = parseLotteryPoolConfigOption(payload)
applyPoolWeightsToForm(row)
currentLotteryConfig.value = row
} else {
currentLotteryConfig.value = null
@@ -486,9 +524,12 @@
ElMessage.warning(t('page.form.ruleWeightsSumMustBe100'))
return
}
if (!isLotteryConfigEmpty() && currentLotteryConfig.value) {
applyPoolWeightsToForm(currentLotteryConfig.value)
}
const payload = { ...formData }
if (isLotteryConfigEmpty()) {
;(payload as any).lottery_config_id = null
payload.lottery_config_id = null
}
if (props.dialogType === 'edit' && !payload.password) {
delete (payload as any).password

View File

@@ -54,7 +54,7 @@
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="item.name"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -65,6 +65,7 @@
<script setup lang="ts">
import api from '../../../api/player/index'
import { lotteryPoolOptionLabel, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
interface Props {
modelValue: Record<string, any>
@@ -77,7 +78,7 @@
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
/** 从玩家控制器获取 DiceLotteryPoolConfig id/name 列表,用于 lottery_config_id 筛选 */
onMounted(async () => {

View File

@@ -78,13 +78,6 @@
return api.list({ ...params, direction: currentDirection.value })
}
function formatMoney2(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return n.toFixed(2)
}
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, { ...params, direction: currentDirection.value })
getData()
@@ -132,13 +125,6 @@
align: 'center',
showOverflowTooltip: true
},
{
prop: 'real_ev',
label: 'page.table.realEv',
width: 110,
align: 'center',
formatter: (row: Record<string, any>) => formatMoney2(row?.real_ev)
},
{ prop: 'remark', label: 'page.table.remark', minWidth: 80, align: 'center', showOverflowTooltip: true },
{ prop: 'weight', label: 'page.table.weight', width: 110, align: 'center' }
]

View File

@@ -15,7 +15,7 @@
<el-tab-pane v-for="t in tierKeys" :key="t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<div class="chart-row">
<ArtBarChart
:x-axis-name="$t('page.weightShared.xAxisEndIndex')"
@@ -31,7 +31,7 @@
/>
</div>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineDual', {
cw: getTierSum(t, 'clockwise'),
@@ -39,7 +39,6 @@
})
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colEndIndexId')"
@@ -54,17 +53,6 @@
width="80"
align="center"
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ formatMoney2(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -89,7 +77,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -106,7 +93,7 @@
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') <= 1"
:disabled="getItemWeight(row, 'clockwise') <= 1"
@click="
setItemWeightByRow(
t,
@@ -122,7 +109,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -140,7 +126,7 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') >= 10000
getItemWeight(row, 'clockwise') >= 10000
"
@click="
setItemWeightByRow(
@@ -166,7 +152,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -184,7 +169,7 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeight(row, 'counterclockwise') <= 1
getItemWeight(row, 'counterclockwise') <= 1
"
@click="
setItemWeightByRow(
@@ -201,7 +186,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -219,7 +203,6 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeight(row, 'counterclockwise') >= 10000
"
@click="
@@ -361,11 +344,6 @@
else row[key] = v
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
@@ -465,8 +443,8 @@
const items: Array<{ id: number; weight: number }> = []
for (const t of TIER_KEYS) {
for (const row of getTierItems(t)) {
const w0 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'clockwise')
const w1 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'counterclockwise')
const w0 = getItemWeight(row, 'clockwise')
const w1 = getItemWeight(row, 'counterclockwise')
const rid0 = row.reward_id_clockwise != null ? row.reward_id_clockwise : 0
const rid1 = row.reward_id_counterclockwise != null ? row.reward_id_counterclockwise : 0
if (rid0 > 0) items.push({ id: rid0, weight: w0 })
@@ -530,9 +508,6 @@
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;

View File

@@ -18,7 +18,7 @@
<el-tab-pane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<ArtBarChart
:key="'cw-' + activeDirection + '-' + t"
:x-axis-name="$t('page.weightShared.xAxisGridNumber')"
@@ -27,12 +27,11 @@
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineSingle', { sum: getTierSumForCurrentDirection(t) })
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colGridNumber')"
@@ -48,17 +47,6 @@
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ formatMoney2(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -87,7 +75,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -103,9 +90,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1
"
:disabled="getItemWeightForCurrentDirection(row) <= 1"
@click="
setItemWeightForCurrentDirection(
t,
@@ -120,7 +105,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -136,10 +120,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeightForCurrentDirection(row) >= 10000
"
:disabled="getItemWeightForCurrentDirection(row) >= 10000"
@click="
setItemWeightForCurrentDirection(
t,
@@ -163,7 +144,7 @@
<el-tab-pane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<ArtBarChart
:key="'ccw-' + activeDirection + '-' + t"
:x-axis-name="$t('page.weightShared.xAxisGridNumber')"
@@ -172,12 +153,11 @@
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineSingle', { sum: getTierSumForCurrentDirection(t) })
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colGridNumber')"
@@ -193,13 +173,6 @@
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -228,7 +201,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -244,9 +216,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1
"
:disabled="getItemWeightForCurrentDirection(row) <= 1"
@click="
setItemWeightForCurrentDirection(
t,
@@ -261,7 +231,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -277,10 +246,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeightForCurrentDirection(row) >= 10000
"
:disabled="getItemWeightForCurrentDirection(row) >= 10000"
@click="
setItemWeightForCurrentDirection(
t,
@@ -433,11 +399,6 @@
grouped.value[tier][dir] = [...list]
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
@@ -506,7 +467,7 @@
for (const row of list) {
const rid = row.reward_id != null ? Number(row.reward_id) : 0
if (rid <= 0) continue
const w = isWeightDisabled(row, t) ? 10000 : toWeightPrecision(row.weight ?? 1)
const w = toWeightPrecision(row.weight ?? 1)
items.push({ id: rid, reward_id: rid, weight: w })
}
}
@@ -567,9 +528,6 @@
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;

View File

@@ -2,7 +2,7 @@
<ElDialog
v-model="visible"
:title="$t('page.weightTest.title')"
width="920px"
width="960px"
top="4vh"
class="weight-test-dialog"
:close-on-click-modal="false"
@@ -20,7 +20,7 @@
<ElForm :model="form" label-width="108px" class="weight-test-form">
<ElRow :gutter="16">
<ElCol :span="8">
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelAnte')" prop="ante_config_id" required>
<ElSelect
v-model="form.ante_config_id"
@@ -29,6 +29,10 @@
style="width: 100%"
@change="syncAnteFromSelect"
>
<ElOption
:label="$t('page.weightTest.anteRandomOption')"
:value="RANDOM_ANTE_CONFIG_ID"
/>
<ElOption
v-for="item in anteOptions"
:key="item.id"
@@ -38,130 +42,167 @@
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem :label="$t('page.weightTest.labelKillModeEnabled')" prop="kill_mode_enabled">
<ElSwitch v-model="form.kill_mode_enabled" />
</ElFormItem>
</ElCol>
<ElCol :span="8">
<ElFormItem :label="$t('page.weightTest.labelTestSafetyLine')" prop="test_safety_line">
</ElRow>
<div class="kill-mode-panel">
<div class="kill-mode-head">
<div class="kill-mode-title">{{ $t('page.weightTest.killModePanelTitle') }}</div>
<ElSwitch
v-model="form.kill_mode_enabled"
:active-text="$t('page.weightTest.killModeSwitchOn')"
:inactive-text="$t('page.weightTest.killModeSwitchOff')"
inline-prompt
/>
</div>
<div v-if="form.kill_mode_enabled" class="kill-mode-body">
<div class="kill-mode-field">
<div class="kill-mode-field-label">{{ $t('page.weightTest.labelTestSafetyLine') }}</div>
<ElInputNumber
v-model="form.test_safety_line"
:min="0"
:step="100"
:disabled="!form.kill_mode_enabled"
controls-position="right"
style="width: 100%"
class="kill-mode-field-input"
/>
</ElFormItem>
</ElCol>
</ElRow>
</div>
<div class="kill-mode-hint">{{ $t('page.weightTest.testSafetyLineHint') }}</div>
<div v-if="defaultPoolInfo" class="kill-mode-ref">
{{ $t('page.weightTest.poolProfitRef', {
profit: defaultPoolInfo.profit_amount,
line: defaultPoolInfo.safety_line
}) }}
</div>
</div>
<div v-else class="kill-mode-off-hint">{{ $t('page.weightTest.killModeOffHint') }}</div>
</div>
<ElRow :gutter="20" class="section-row">
<ElCol :span="12">
<div class="section-title">{{ $t('page.weightTest.sectionPaid') }}</div>
<ElFormItem :label="$t('page.weightTest.labelLotteryTypePaid')" prop="paid_lottery_config_id">
<ElSelect
v-model="form.paid_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderPaidPool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in paidLotteryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.paid_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHint') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'paid-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getPaidTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setPaidTier(t, $event)"
/>
</div>
<ElForm :model="form" label-position="top" class="section-form">
<ElFormItem :label="$t('page.weightTest.labelLotteryTypePaid')" prop="paid_lottery_config_id">
<ElSelect
v-model="form.paid_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderPaidPool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in lotteryOptions"
:key="'paid-pool-' + item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
<div v-if="selectedPaidPool" class="pool-selected-hint">
{{ $t('page.weightTest.selectedPoolHint', { name: lotteryPoolDisplayLabel(selectedPaidPool) }) }}
</div>
<div v-if="selectedPaidPool" class="pool-weights-preview">
{{ poolTierWeightsText(selectedPaidPool) }}
</div>
</ElFormItem>
<template v-if="form.paid_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHint') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'paid-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getPaidTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setPaidTier(t, $event)"
/>
</div>
</ElCol>
</ElRow>
<div v-if="paidTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: paidTierSum })
}}</div>
</template>
<ElRow :gutter="12">
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelCwCount')" prop="paid_s_count" required>
<ElSelect
v-model="form.paid_s_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelCcwCount')" prop="paid_n_count" required>
<ElSelect
v-model="form.paid_n_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="'n-' + c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<div v-if="paidTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: paidTierSum })
}}</div>
</template>
<ElFormItem :label="$t('page.weightTest.labelCwCount')" prop="paid_s_count" required>
<ElSelect
v-model="form.paid_s_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('page.weightTest.labelCcwCount')" prop="paid_n_count" required>
<ElSelect
v-model="form.paid_n_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="'n-' + c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
</ElForm>
</ElCol>
<ElCol :span="12">
<div class="section-title">{{ $t('page.weightTest.sectionFreeAfterPlayAgain') }}</div>
<ElFormItem :label="$t('page.weightTest.labelLotteryTypeFree')" prop="free_lottery_config_id">
<ElSelect
v-model="form.free_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderFreePool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in freeLotteryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.free_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHintFreeChain') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'free-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getFreeTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setFreeTier(t, $event)"
/>
</div>
</ElCol>
</ElRow>
<div v-if="freeTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: freeTierSum })
}}</div>
</template>
<ElForm :model="form" label-position="top" class="section-form">
<ElFormItem :label="$t('page.weightTest.labelLotteryTypeFree')" prop="free_lottery_config_id">
<ElSelect
v-model="form.free_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderFreePool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in lotteryOptions"
:key="'free-pool-' + item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
<div v-if="selectedFreePool" class="pool-selected-hint">
{{ $t('page.weightTest.selectedPoolHint', { name: lotteryPoolDisplayLabel(selectedFreePool) }) }}
</div>
<div v-if="selectedFreePool" class="pool-weights-preview">
{{ poolTierWeightsText(selectedFreePool) }}
</div>
</ElFormItem>
<template v-if="form.free_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHintFreeChain') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'free-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getFreeTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setFreeTier(t, $event)"
/>
</div>
</ElCol>
</ElRow>
<div v-if="freeTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: freeTierSum })
}}</div>
</template>
</ElForm>
</ElCol>
</ElRow>
</ElForm>
@@ -185,7 +226,7 @@
<script setup lang="ts">
import api from '../../../api/reward/index'
import anteConfigApi from '../../../api/ante_config/index'
import lotteryPoolApi from '../../../api/lottery_pool_config/index'
import lotteryPoolApi, { type LotteryPoolConfigOption } from '../../../api/lottery_pool_config/index'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
@@ -193,6 +234,13 @@
useInjectedChannelDept,
withChannelDeptParams
} from '@/composables/useChannelDeptScope'
import {
lotteryPoolDisplayLabel,
lotteryPoolOptionLabel
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/** 底注下拉「随机」选项值(非真实 ante_config.id */
const RANDOM_ANTE_CONFIG_ID = -1
const props = defineProps<{
/** 父页面渠道栏选中值(弹窗 teleport 后 inject 可能失效) */
@@ -222,16 +270,16 @@
paid_s_count: 100,
paid_n_count: 100,
kill_mode_enabled: false,
test_safety_line: 5000
test_safety_line: 0
})
const lotteryOptions = ref<Array<{ id: number; name: string }>>([])
const paidLotteryOptions = computed(() =>
lotteryOptions.value.filter((r) => r.name === 'default')
const lotteryOptions = ref<LotteryPoolConfigOption[]>([])
const selectedPaidPool = computed(() =>
lotteryOptions.value.find((r) => r.id === form.paid_lottery_config_id) ?? null
)
const freeLotteryOptions = computed(() => {
const list = lotteryOptions.value.filter((r) => r.name === 'killScore')
return list.length > 0 ? list : lotteryOptions.value
})
const selectedFreePool = computed(() =>
lotteryOptions.value.find((r) => r.id === form.free_lottery_config_id) ?? null
)
const defaultPoolInfo = ref<{ safety_line: number; kill_enabled: number; profit_amount: number } | null>(null)
const running = ref(false)
function onClose() {
@@ -296,6 +344,15 @@
return label ? `${label} (×${item.mult})` : `×${item.mult}`
}
function poolTierWeightsText(pool: LotteryPoolConfigOption): string {
const parts = tierKeys.map((t) => {
const key = `${t.toLowerCase()}_weight` as keyof LotteryPoolConfigOption
const v = pool[key]
return `${t} ${v ?? 0}%`
})
return parts.join(' · ')
}
function syncAnteFromSelect() {
const opt = anteOptions.value.find((o) => o.id === form.ante_config_id)
if (opt) {
@@ -321,20 +378,35 @@
}
}
async function loadDefaultPoolInfo() {
try {
const pool = await lotteryPoolApi.getCurrentPool(resolveDeptParams())
const safetyLine = Number(pool?.safety_line ?? 0)
defaultPoolInfo.value = {
safety_line: safetyLine,
kill_enabled: Number(pool?.kill_enabled ?? 1),
profit_amount: Number(pool?.profit_amount ?? 0)
}
form.test_safety_line = safetyLine
} catch {
defaultPoolInfo.value = null
}
}
async function loadLotteryOptions() {
try {
const list = await lotteryPoolApi.getOptions(resolveDeptParams())
lotteryOptions.value = list.map((r: { id: number; name: string }) => ({
id: r.id,
name: r.name
}))
const normal = list.find((r: { name?: string }) => r.name === 'default')
if (normal) {
lotteryOptions.value = list
const playerDefault = list.find((r) => r.name === 'playerDefault')
const normal = list.find((r) => r.name === 'default')
if (playerDefault) {
form.paid_lottery_config_id = playerDefault.id
} else if (normal) {
form.paid_lottery_config_id = normal.id
}
const kill = list.find((r: { name?: string }) => r.name === 'killScore')
if (kill) {
form.free_lottery_config_id = kill.id
const freePool = list.find((r: { name?: string }) => r.name === 'free')
if (freePool) {
form.free_lottery_config_id = freePool.id
} else if (list.length > 0) {
form.free_lottery_config_id = list[0].id
}
@@ -346,7 +418,6 @@
function buildPayload() {
const payload: Record<string, unknown> = {
ante: form.ante,
ante_config_id: form.ante_config_id,
paid_s_count: form.paid_s_count,
paid_n_count: form.paid_n_count,
free_s_count: 0,
@@ -355,6 +426,11 @@
kill_mode_enabled: form.kill_mode_enabled,
test_safety_line: form.test_safety_line
}
if (form.ante_config_id === RANDOM_ANTE_CONFIG_ID) {
payload.ante_random = true
} else {
payload.ante_config_id = form.ante_config_id
}
if (form.paid_lottery_config_id != null) {
payload.paid_lottery_config_id = form.paid_lottery_config_id
} else {
@@ -369,12 +445,15 @@
}
function validateForm(): boolean {
if (form.ante_config_id == null || form.ante_config_id <= 0) {
const isRandomAnte = form.ante_config_id === RANDOM_ANTE_CONFIG_ID
if (!isRandomAnte && (form.ante_config_id == null || form.ante_config_id <= 0)) {
ElMessage.warning(t('page.weightTest.warnAnte'))
return false
}
syncAnteFromSelect()
if (form.ante == null || form.ante <= 0) {
if (!isRandomAnte) {
syncAnteFromSelect()
}
if (!isRandomAnte && (form.ante == null || form.ante <= 0)) {
ElMessage.warning(t('page.weightTest.warnAnte'))
return false
}
@@ -437,6 +516,7 @@
if (v) {
void loadAnteOptions()
void loadLotteryOptions()
void loadDefaultPoolInfo()
} else {
onClose()
}
@@ -448,6 +528,7 @@
if (visible.value) {
void loadAnteOptions()
void loadLotteryOptions()
void loadDefaultPoolInfo()
}
}
)
@@ -478,6 +559,91 @@
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
}
.section-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
line-height: 1.4;
padding-bottom: 4px;
}
}
.kill-mode-panel {
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-light);
}
.kill-mode-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.kill-mode-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.kill-mode-body {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--el-border-color);
}
.pool-selected-hint {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.pool-weights-preview {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-regular);
font-family: ui-monospace, monospace;
}
.kill-mode-field {
max-width: 280px;
}
.kill-mode-field-label {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.4;
margin-bottom: 6px;
white-space: nowrap;
}
.kill-mode-field-input {
width: 100%;
}
.kill-mode-hint,
.kill-mode-off-hint,
.kill-mode-ref {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.kill-mode-ref {
margin-top: 4px;
color: var(--el-text-color-placeholder);
}
.section-row {
@@ -491,6 +657,9 @@
margin: 0 0 10px;
padding-bottom: 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tier-label {

View File

@@ -21,9 +21,38 @@
<ElTabPane :label="$t('page.configPage.tabIndex')" name="index">
<div class="tab-panel">
<div class="panel-tip">{{ $t('page.configPage.tipIndex') }}</div>
<div class="index-toolbar">
<div v-if="canTierRecommend" class="tier-recommend-panel">
<div class="tier-recommend-rules">{{ $t('page.configPage.tierRecommendRules') }}</div>
<div class="tier-recommend-grid">
<div v-for="tk in TIER_RECOMMEND_KEYS" :key="tk" class="tier-recommend-cell">
<span class="tier-recommend-label">{{ tk }}</span>
<span class="tier-recommend-hint">{{ $t('page.configPage.tierRecommendRealEv') }}</span>
<ElInputNumber
v-model="tierRecommend[tk]"
:disabled="tk === 'T5'"
:step="0.1"
:precision="2"
controls-position="right"
size="small"
class="tier-recommend-input"
/>
</div>
</div>
<div class="tier-recommend-actions">
<ElCheckbox v-model="autoMatchTierOnRealEv">{{
$t('page.configPage.tierRecommendAutoMatch')
}}</ElCheckbox>
<ElButton size="small" @click="handleApplyRecommendRealEv" v-ripple>{{
$t('page.configPage.tierRecommendApplyAmount')
}}</ElButton>
<ElButton size="small" type="primary" plain @click="handleMatchAllTiersFromRealEv" v-ripple>{{
$t('page.configPage.tierRecommendMatchTier')
}}</ElButton>
</div>
</div>
<div v-if="canTierRecommend" class="index-toolbar">
<ElButton
v-permission="'dice:reward_config:index:batchUpdate'"
v-permission="PERM_TIER_RECOMMEND"
type="default"
@click="openRuleGenerateDialog"
v-ripple
@@ -99,38 +128,18 @@
<template #default="{ row }">
<ElInputNumber
v-model="row.real_ev"
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
@update:model-value="() => handleRealEvChange(row)"
/>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('page.configPage.colRealReward')"
min-width="130"
align="center"
>
<template #default="{ row }">
<span>{{ formatMoney2(calcRealReward(row.real_ev)) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.colTier')" width="100" align="center">
<template #default="{ row }">
<ElSelect
v-model="row.tier"
:placeholder="$t('page.configPage.placeholderTierSelect')"
clearable
size="small"
class="full-width"
>
<ElOption label="T1" value="T1" />
<ElOption label="T2" value="T2" />
<ElOption label="T3" value="T3" />
<ElOption label="T4" value="T4" />
<ElOption label="T5" value="T5" />
</ElSelect>
<span class="tier-readonly">{{ displayRowTier(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn
@@ -142,7 +151,7 @@
<ElInput
v-model="row.remark"
size="small"
:placeholder="$t('page.configPage.placeholderRemark')"
:placeholder="remarkPlaceholderForRow(row)"
/>
</template>
</ElTableColumn>
@@ -167,6 +176,7 @@
<ElTable
v-loading="loading"
:data="bigwinRows"
row-key="id"
border
size="default"
class="config-table bigwin-table"
@@ -214,11 +224,12 @@
<template #default="{ row }">
<ElInputNumber
v-model="row.real_ev"
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
@update:model-value="() => handleRealEvChange(row)"
/>
</template>
</ElTableColumn>
@@ -231,7 +242,7 @@
<ElInput
v-model="row.remark"
size="small"
:placeholder="$t('page.configPage.placeholderRemark')"
:placeholder="remarkPlaceholderForRow(row)"
/>
</template>
</ElTableColumn>
@@ -243,14 +254,17 @@
<template #default="{ row }">
<div class="weight-cell">
<ElSlider
v-model="row.weight"
:model-value="row.weight"
:min="0"
:max="10000"
:step="100"
:disabled="isBigwinWeightDisabled(row)"
@update:model-value="
(v: number | number[]) => setBigwinRowWeight(row, Array.isArray(v) ? v[0] : v)
"
/>
<ElInputNumber
v-model="row.weight"
:model-value="row.weight"
:min="0"
:max="10000"
:step="100"
@@ -258,6 +272,7 @@
controls-position="right"
size="small"
class="weight-input"
@update:model-value="(v: number | undefined) => setBigwinRowWeight(row, v)"
/>
</div>
<span v-if="isBigwinWeightDisabled(row)" class="weight-tip">{{
@@ -286,6 +301,7 @@
</ElCard>
<ElDialog
v-if="canTierRecommend"
v-model="ruleGenerateDialogVisible"
:title="$t('page.configPage.ruleGenerateTitle')"
:width="ruleGenDialogWidth"
@@ -393,6 +409,7 @@
<ElInputNumber
v-model="ruleGenT4RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
@@ -421,8 +438,8 @@
<ElInputNumber
v-model="ruleGenT5RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:step="0.1"
:precision="2"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
controls-position="right"
@@ -442,6 +459,12 @@
</div>
</template>
</ElDialog>
<CreateRewardReferencePreviewDialog
v-model="createRewardPreviewVisible"
:dept-id="filterDeptId"
@success="loadIndexList"
/>
</div>
</template>
@@ -453,20 +476,29 @@
useInjectedChannelDept
} from '@/composables/useChannelDeptScope'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../api/reward_config/index'
import { checkAuth } from '@/utils/tool'
import CreateRewardReferencePreviewDialog from './modules/create-reward-reference-preview-dialog.vue'
import {
buildRowsFromTiers,
computeBoardFrequencies,
DEFAULT_TIER_REAL_EV_STANDARDS,
defaultRemarkForTier,
generateTiers,
inferTierFromRealEv,
summarizeCounts,
type TierRealEvStandards,
validateTierRealEvStandards
} from '../utils/generateIndexByRules'
const { t } = useI18n()
/** 档位结算推荐配置T1-T5 推荐金额栏、按规则生成/导入) */
const PERM_TIER_RECOMMEND = 'dice:reward_config:index:tierRecommend'
const canTierRecommend = computed(() => checkAuth(PERM_TIER_RECOMMEND))
const { width: viewportWidth } = useWindowSize()
/** 窄屏:单列、标签置顶、全屏弹窗 */
const isRuleGenMobile = computed(() => viewportWidth.value < 640)
@@ -510,18 +542,101 @@
const savingIndex = ref(false)
const savingBigwin = ref(false)
const createRewardLoading = ref(false)
const createRewardPreviewVisible = ref(false)
const ruleGenerateDialogVisible = ref(false)
const ruleGenSubmitting = ref(false)
const ruleGenT1Fixed = ref(3)
const ruleGenT2Min = ref(5)
const ruleGenT4Fixed = ref(1)
const ruleGenT5Fixed = ref(1)
const TIER_RECOMMEND_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
type TierRecommendKey = (typeof TIER_RECOMMEND_KEYS)[number]
const tierRecommend = reactive<TierRealEvStandards>({
T1: DEFAULT_TIER_REAL_EV_STANDARDS.T1,
T2: DEFAULT_TIER_REAL_EV_STANDARDS.T2,
T3: DEFAULT_TIER_REAL_EV_STANDARDS.T3,
T4: DEFAULT_TIER_REAL_EV_STANDARDS.T4,
T5: DEFAULT_TIER_REAL_EV_STANDARDS.T5
})
const autoMatchTierOnRealEv = ref(false)
const ruleGenT1RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T1)
const ruleGenT2RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T2)
const ruleGenT3RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T3)
const ruleGenT4RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T4)
const ruleGenT5RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T5)
function syncRuleGenFromTierRecommend() {
ruleGenT1RealEv.value = tierRecommend.T1
ruleGenT2RealEv.value = tierRecommend.T2
ruleGenT3RealEv.value = tierRecommend.T3
ruleGenT4RealEv.value = tierRecommend.T4
ruleGenT5RealEv.value = tierRecommend.T5
}
function syncTierRecommendFromRuleGen() {
tierRecommend.T1 = Number(ruleGenT1RealEv.value)
tierRecommend.T2 = Number(ruleGenT2RealEv.value)
tierRecommend.T3 = Number(ruleGenT3RealEv.value)
tierRecommend.T4 = Number(ruleGenT4RealEv.value)
tierRecommend.T5 = Number(ruleGenT5RealEv.value)
}
function applyRealEvDisplay(row: IndexRow, n: number) {
const text = Number.isNaN(n) ? '' : Number(n).toFixed(2)
row.ui_text = text
row.ui_text_en = text
}
function rowRealEvNumber(row: IndexRow): number {
return typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
}
function syncRowTierFromRealEv(row: IndexRow) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier !== '') {
row.tier = tier
}
}
/** 按当前结算金额推断档位并写入对应备注T1大奖/T2小赚/T3抽水/T4惩罚/T5再来一次 */
function syncRemarkFromSettlement(row: IndexRow) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier === '') {
return
}
row.remark = defaultRemarkForTier(tier)
}
function remarkPlaceholderForRow(row: IndexRow): string {
const tier = displayRowTier(row)
if (tier === '' || tier === '-') {
return t('page.configPage.placeholderRemark')
}
return defaultRemarkForTier(tier)
}
function displayRowTier(row: IndexRow): string {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
return tier !== '' ? tier : row.tier || '-'
}
function applyRecommendRealEvToRow(row: IndexRow, tier: TierRecommendKey) {
const ev = tierRecommend[tier]
row.real_ev = ev
if (tier === 'T5') {
row.ui_text = t('page.configPage.tierRecommendT5UiText')
row.ui_text_en = t('page.configPage.tierRecommendT5UiTextEn')
} else {
applyRealEvDisplay(row, ev)
}
syncRowTierFromRealEv(row)
syncRemarkFromSettlement(row)
}
/** 奖励索引 id 与后端 DiceRewardConfigLogic 一致025 */
const REWARD_INDEX_MIN = 0
const REWARD_INDEX_MAX = 25
@@ -564,22 +679,65 @@
}
}
function calcRealReward(realEv: unknown): number {
const n = typeof realEv === 'number' && !Number.isNaN(realEv) ? realEv : Number(realEv)
if (Number.isNaN(n)) {
return -1
}
return n - 1
/** BIGWIN 行不参与按 real_ev 推断 T1-T5避免编辑时从大奖权重表消失 */
function setBigwinRowWeight(row: IndexRow, v: number | number[] | undefined | null) {
const n = Array.isArray(v) ? v[0] : v
row.weight = toWeight(n)
}
function handleRealEvChange(row: IndexRow) {
const n =
typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
const text = Number.isNaN(n) ? '' : Number(n).toFixed(2)
row.ui_text = text
row.ui_text_en = text
if (row.tier === 'BIGWIN') {
row.remark = defaultRemarkForTier('BIGWIN')
return
}
const n = rowRealEvNumber(row)
syncRowTierFromRealEv(row)
const tier = inferTierFromRealEv(n)
if (tier === 'T5') {
row.ui_text = t('page.configPage.tierRecommendT5UiText')
row.ui_text_en = t('page.configPage.tierRecommendT5UiTextEn')
} else {
applyRealEvDisplay(row, n)
}
syncRemarkFromSettlement(row)
}
function handleApplyRecommendRealEv() {
if (!canTierRecommend.value) {
return
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier === 'T1' || tier === 'T2' || tier === 'T3' || tier === 'T4' || tier === 'T5') {
applyRecommendRealEvToRow(row, tier)
count++
}
}
if (count === 0) {
ElMessage.info(t('page.configPage.tierRecommendNoTierRows'))
return
}
ElMessage.success(t('page.configPage.tierRecommendApplyAmountOk', { n: count }))
}
function handleMatchAllTiersFromRealEv() {
if (!canTierRecommend.value) {
return
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
syncRowTierFromRealEv(row)
syncRemarkFromSettlement(row)
if (row.tier !== '') {
count++
}
}
if (count === 0) {
ElMessage.info(t('page.configPage.tierRecommendMatchTierNone'))
return
}
ElMessage.success(t('page.configPage.tierRecommendMatchTierOk', { n: count }))
}
function formatMoney2(val: unknown): string {
@@ -590,43 +748,7 @@
}
async function handleCreateRewardReference() {
try {
await ElMessageBox.confirm(
t('page.configPage.confirmCreateRefMsg'),
t('page.configPage.confirmCreateRefTitle'),
{
confirmButtonText: t('page.configPage.confirmCreateRefOk'),
cancelButtonText: t('page.configPage.confirmCreateRefCancel'),
type: 'warning'
}
)
} catch {
return
}
createRewardLoading.value = true
try {
const res: any = await api.createRewardReference({ dept_id: filterDeptId.value as number })
const data = res?.data ?? res
let msg = t('page.configPage.createRefSuccessSimple')
if (typeof data === 'object' && data !== null) {
const skipped = Number(data.skipped ?? 0)
const skippedPart =
skipped > 0 ? t('page.configPage.createRefSuccessSkipped', { n: skipped }) : ''
msg = t('page.configPage.createRefSuccess', {
cwNew: data.created_clockwise ?? 0,
ccwNew: data.created_counterclockwise ?? 0,
cwUp: data.updated_clockwise ?? 0,
ccwUp: data.updated_counterclockwise ?? 0,
skippedPart
})
}
ElMessage.success(msg)
loadIndexList()
} catch (e: any) {
ElMessage.error(e?.message ?? t('page.configPage.createRefFail'))
} finally {
createRewardLoading.value = false
}
createRewardPreviewVisible.value = true
}
function extractIndexList(res: unknown): Record<string, unknown>[] {
@@ -651,6 +773,11 @@
.list({ saiType: 'all', limit: 200, dept_id: filterDeptId.value })
.then((res: unknown) => {
const rows = extractIndexList(res).map((r) => normalizeIndexRow(r))
for (const row of rows) {
if (row.tier !== 'BIGWIN') {
syncRowTierFromRealEv(row)
}
}
indexRows.value = rows
indexRowsSnapshot = rows.map((r) => ({ ...r }))
})
@@ -745,6 +872,10 @@
}
function openRuleGenerateDialog() {
if (!canTierRecommend.value) {
return
}
syncRuleGenFromTierRecommend()
ruleGenerateDialogVisible.value = true
}
@@ -781,6 +912,9 @@
}
async function handleRuleGenerateApply() {
if (!canTierRecommend.value) {
return
}
const grids = extractGrids26()
if (grids === null) {
ElMessage.warning(t('page.configPage.ruleGenNeedFullGrid'))
@@ -790,12 +924,13 @@
const t2 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT2Min.value))))
const x4 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT4Fixed.value))))
const x5 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT5Fixed.value))))
syncTierRecommendFromRuleGen()
const standards = {
T1: Number(ruleGenT1RealEv.value),
T2: Number(ruleGenT2RealEv.value),
T3: Number(ruleGenT3RealEv.value),
T4: Number(ruleGenT4RealEv.value),
T5: Number(ruleGenT5RealEv.value)
T1: Number(tierRecommend.T1),
T2: Number(tierRecommend.T2),
T3: Number(tierRecommend.T3),
T4: Number(tierRecommend.T4),
T5: Number(tierRecommend.T5)
}
const invalidKey = validateTierRealEvStandards(standards)
if (invalidKey !== null) {
@@ -850,7 +985,7 @@
remark: r.remark
})
)
await api.batchUpdate(indexPayload, { dept_id: filterDeptId.value })
await api.generateIndexByRules(indexPayload, { dept_id: filterDeptId.value })
ElMessage.success(
t('page.configPage.ruleGenSuccess', {
cwT1: sc.cw.T1,
@@ -888,6 +1023,9 @@
return
}
const toSave = indexRows.value.filter((r) => r.tier !== 'BIGWIN')
for (const row of toSave) {
syncRowTierFromRealEv(row)
}
savingIndex.value = true
try {
const indexPayload = toSave.map((r) => ({
@@ -957,7 +1095,7 @@
ui_text: r.ui_text,
ui_text_en: r.ui_text_en,
real_ev: r.real_ev,
tier: r.tier,
tier: 'BIGWIN',
remark: r.remark
}))
await api.batchUpdate(batchPayload, { dept_id: filterDeptId.value })
@@ -1058,6 +1196,61 @@
margin-bottom: 12px;
line-height: 1.5;
}
.tier-recommend-panel {
margin-bottom: 12px;
padding: 12px 14px;
background: var(--el-fill-color-light);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.tier-recommend-rules {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-bottom: 12px;
}
.tier-recommend-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 12px;
}
.tier-recommend-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 140px;
flex: 1 1 160px;
}
.tier-recommend-label {
font-weight: 600;
font-size: 13px;
color: var(--el-text-color-primary);
min-width: 28px;
}
.tier-recommend-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.tier-recommend-input {
width: 120px;
flex: 1;
min-width: 100px;
max-width: 160px;
}
.tier-readonly {
font-weight: 500;
color: var(--el-text-color-primary);
}
.tier-recommend-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
}
.index-toolbar {
display: flex;
align-items: center;

View File

@@ -0,0 +1,343 @@
<template>
<ElDialog
v-model="visible"
:title="$t('page.configPage.createRefPreviewTitle')"
width="860px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div v-loading="loading" class="dialog-body">
<div class="preview-tip">
<div v-if="meta.unchanged" class="tip-line">
{{ $t('page.configPage.createRefPreviewTipUnchanged') }}
</div>
<div v-else class="tip-line">
{{ $t('page.configPage.createRefPreviewTipChanged') }}
</div>
<div class="tip-line" v-if="meta.skipped > 0">
{{ $t('page.configPage.createRefPreviewSkipped', { n: meta.skipped }) }}
</div>
</div>
<ElTabs v-model="activeDirection" type="card" class="direction-tabs">
<ElTabPane :label="$t('page.configPage.createRefPreviewClockwise')" name="0">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 0)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="90" align="center" />
<ElTableColumn label="start" prop="start_index" width="78" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="78" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" width="90" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="80" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" width="130" align="center">
<template #default="{ row }">
<ElInputNumber
v-model="row.weight"
:min="1"
:max="10000"
:step="1"
controls-position="right"
size="small"
class="weight-input"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
<ElTabPane :label="$t('page.configPage.createRefPreviewCounterclockwise')" name="1">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 1)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="90" align="center" />
<ElTableColumn label="start" prop="start_index" width="78" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="78" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" width="90" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="80" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" width="120" align="center">
<template #default="{ row }">
<ElInputNumber
v-model="row.weight"
:min="1"
:max="10000"
:step="1"
controls-position="right"
size="small"
class="weight-input"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="refreshPreview" :loading="loading" v-ripple>{{
$t('page.configPage.createRefPreviewRefresh')
}}</ElButton>
<ElButton @click="handleClose" v-ripple>{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleImport" v-ripple>{{
$t('page.configPage.createRefPreviewImport')
}}</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../../api/reward_config/index'
const { t } = useI18n()
const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
const tierKeys = TIER_KEYS
interface Props {
modelValue: boolean
deptId: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
deptId: 0
})
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const activeDirection = ref<'0' | '1'>('0')
const activeTier = ref('T1')
const loading = ref(false)
const submitting = ref(false)
const meta = reactive<{ unchanged: boolean; skipped: number }>({ unchanged: false, skipped: 0 })
const preview = ref<Record<string, { 0: any[]; 1: any[] }>>({})
function rowClassName({ row }: { row: any }): string {
return row?.diff_changed ? 'row-diff' : ''
}
function formatDiff(row: any): string {
if (!row) return ''
const oldStart = row.old_start_index != null ? String(row.old_start_index) : '-'
const oldEnd = row.old_end_index != null ? String(row.old_end_index) : '-'
const oldTier = row.old_tier != null ? String(row.old_tier) : '-'
const newStart = row.start_index != null ? String(row.start_index) : '-'
const newEnd = row.id != null ? String(row.id) : '-'
const newTier = row.tier != null ? String(row.tier) : '-'
return `${oldStart}/${oldEnd}/${oldTier}${newStart}/${newEnd}/${newTier}`
}
function getTierItems(tier: string, direction: 0 | 1): any[] {
const tierData = preview.value?.[tier]
if (!tierData) return []
const rows = direction === 0 ? tierData[0] : tierData[1]
return Array.isArray(rows) ? rows : []
}
async function refreshPreview() {
loading.value = true
try {
const res: any = await api.createRewardReferencePreview({ dept_id: props.deptId })
const data = res?.data ?? res
meta.unchanged = Boolean(data?.unchanged)
meta.skipped = Number(data?.skipped ?? 0)
preview.value = (data?.preview ?? {}) as any
activeDirection.value = '0'
activeTier.value = 'T1'
} catch (e: any) {
ElMessage.error(e?.message ?? 'preview failed')
} finally {
loading.value = false
}
}
function toWeight(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
return Math.max(1, Math.min(10000, Math.floor(num)))
}
function collectDesiredWeights(): Record<string, number> {
const map: Record<string, number> = {}
for (const t of tierKeys) {
const tierData = preview.value?.[t]
if (!tierData) continue
const cw = Array.isArray(tierData[0]) ? tierData[0] : []
const ccw = Array.isArray(tierData[1]) ? tierData[1] : []
for (const r of cw) {
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) map[`0:${gn}`] = toWeight(r?.weight)
}
for (const r of ccw) {
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) map[`1:${gn}`] = toWeight(r?.weight)
}
}
return map
}
async function applyWeights(desired: Record<string, number>) {
const res: any = await api.weightRatioList({ dept_id: props.deptId })
const raw = res?.data ?? res
const items: Array<{ id: number; weight: number }> = []
for (const t of tierKeys) {
const tierData = raw?.[t]
if (!tierData) continue
const list0 = Array.isArray(tierData[0]) ? tierData[0] : []
const list1 = Array.isArray(tierData[1]) ? tierData[1] : []
for (const r of list0) {
const rid = r?.reward_id != null ? Number(r.reward_id) : 0
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (rid > 0 && !Number.isNaN(gn)) {
const w = desired[`0:${gn}`]
if (w != null) items.push({ id: rid, weight: w })
}
}
for (const r of list1) {
const rid = r?.reward_id != null ? Number(r.reward_id) : 0
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (rid > 0 && !Number.isNaN(gn)) {
const w = desired[`1:${gn}`]
if (w != null) items.push({ id: rid, weight: w })
}
}
}
if (items.length > 0) {
await api.batchUpdateWeights(items, { dept_id: props.deptId })
}
}
async function handleImport() {
submitting.value = true
try {
const desired = collectDesiredWeights()
if (!meta.unchanged) {
await api.createRewardReference({ dept_id: props.deptId })
await applyWeights(desired)
ElMessage.success(t('page.configPage.createRefPreviewImportOk'))
} else {
// 映射未变化:直接保存权重即可
await applyWeights(desired)
ElMessage.success(t('page.configPage.createRefPreviewWeightsSaved'))
}
emit('success')
handleClose()
} catch (e: any) {
ElMessage.error(e?.message ?? 'import failed')
} finally {
submitting.value = false
}
}
function handleClose() {
visible.value = false
}
watch(
() => props.modelValue,
(open) => {
if (open) {
refreshPreview()
}
}
)
</script>
<style scoped lang="scss">
.dialog-body {
min-height: 120px;
}
.preview-tip {
margin-bottom: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
line-height: 1.5;
}
.tip-line + .tip-line {
margin-top: 4px;
}
.preview-table {
margin-top: 4px;
}
.weight-input {
width: 110px;
}
:deep(.row-diff td) {
background: var(--el-color-warning-light-9);
}
.diff-text {
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 1.2;
word-break: break-all;
}
.diff-text-ok {
color: var(--el-text-color-secondary);
}
/* 减少弹窗内容区与表格留白 */
:deep(.el-dialog__body) {
padding: 12px 14px;
}
:deep(.el-tabs__header) {
margin: 0 0 8px 0;
}
:deep(.el-tabs__item) {
height: 34px;
line-height: 34px;
}
:deep(.el-table .cell) {
padding: 4px 6px;
}
:deep(.el-table__row) {
height: 34px;
}
</style>

View File

@@ -50,13 +50,6 @@
show-overflow-tooltip
/>
<el-table-column :label="$t('page.weightRatio.colDicePoints')" prop="grid_number" width="80" align="center" />
<el-table-column
:label="$t('page.weightRatio.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightRatio.colUiText')"
prop="ui_text"

View File

@@ -53,7 +53,7 @@ export interface TierRealEvStandards {
T5: number
}
/** 默认标准(与规则弹窗说明一致 */
/** 默认推荐结算金额(满足档位区间规则,可在页面修改 */
export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
T1: 3,
T2: 1.5,
@@ -62,6 +62,55 @@ export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
T5: 0
}
/**
* 按结算金额推断档位(与奖励配置页规则说明一致)
* T1 大奖:>2
* T2 小赚2>=金额>1
* T3 抽水1>=金额>0
* T4 惩罚0>金额
* T5 再来一次:=0
*/
/** 各档位默认备注(与结算金额推断档位规则一致,修改结算金额时实时同步) */
export const TIER_REMARK_BY_TIER: Record<IndexTier, string> = {
T1: '大奖',
T2: '小赚',
T3: '抽水',
T4: '惩罚',
T5: '再来一次'
}
export function defaultRemarkForTier(tier: IndexTier | 'BIGWIN' | string): string {
if (tier === 'BIGWIN' || tier === 'T1') {
return TIER_REMARK_BY_TIER.T1
}
if (tier === 'T2' || tier === 'T3' || tier === 'T4' || tier === 'T5') {
return TIER_REMARK_BY_TIER[tier]
}
return ''
}
export function inferTierFromRealEv(realEv: number): IndexTier | '' {
if (!Number.isFinite(realEv)) {
return ''
}
if (realEv === 0) {
return 'T5'
}
if (realEv < 0) {
return 'T4'
}
if (realEv > 2) {
return 'T1'
}
if (realEv > 1 && realEv <= 2) {
return 'T2'
}
if (realEv > 0 && realEv <= 1) {
return 'T3'
}
return ''
}
/**
* 校验档位与 real_ev 区间是否一致;通过返回 null否则返回 i18n 键名(不含 page.configPage. 前缀)
*/
@@ -69,10 +118,10 @@ export function validateTierRealEvStandards(s: TierRealEvStandards): string | nu
if (!Number.isFinite(s.T1) || !(s.T1 > 2)) {
return 'ruleGenInvalidT1RealEv'
}
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 < 2)) {
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 <= 2)) {
return 'ruleGenInvalidT2RealEv'
}
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 < 1)) {
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 <= 1)) {
return 'ruleGenInvalidT3RealEv'
}
if (!Number.isFinite(s.T4) || !(s.T4 < 0)) {
@@ -353,7 +402,7 @@ export function buildRowsFromTiers(
const f = uiTextByTierWhenStandards(tier, real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
remark = '再来一次'
}
} else if (tier === 'T1') {
real_ev = 101 + ((id * 17 + grid_number * 3) % 398)
@@ -393,10 +442,9 @@ export function buildRowsFromTiers(
remark = '惩罚'
} else {
real_ev = 0
const f = uiTextFromRealEv(real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
ui_text = '再来一次'
ui_text_en = 'Once again'
remark = '再来一次'
}
rows.push({
id,

View File

@@ -143,6 +143,35 @@
return Number(row.paid_n_count ?? 0)
}
function formatTestSafetyLine(row: Record<string, unknown>): string {
const dash = t('page.detail.dash')
if (Number(row.kill_mode_enabled ?? 0) !== 1) {
return dash
}
const line = row.test_safety_line
if (line === null || line === undefined || line === '') {
return dash
}
const n = Number(line)
return Number.isFinite(n) ? String(n) : dash
}
function formatAnteCell(row: Record<string, unknown>): string {
const snap = row.tier_weights_snapshot
const isRandom =
snap &&
typeof snap === 'object' &&
(snap as { ante_random?: boolean }).ante_random === true
if (isRandom) {
return t('page.table.anteRandom')
}
const ante = row.ante
if (ante === null || ante === undefined || ante === '') {
return t('page.detail.dash')
}
return String(ante)
}
// 平台赚取金额展示(未完成或空显示 —)
function formatPlatformProfit(v: unknown): string {
const dash = t('page.detail.dash')
@@ -228,8 +257,16 @@
{
prop: 'ante',
label: 'page.table.ante',
width: 90,
align: 'center'
width: 100,
align: 'center',
formatter: (row: Record<string, unknown>) => formatAnteCell(row)
},
{
prop: 'test_safety_line',
label: 'page.table.testSafetyLine',
width: 100,
align: 'center',
formatter: (row: Record<string, unknown>) => formatTestSafetyLine(row)
},
{
prop: 'play_again_count',

View File

@@ -20,6 +20,12 @@
<el-descriptions-item :label="$t('page.detail.paidPlannedSpins')">
{{ record.paid_planned_spins ?? $t('page.detail.dash') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.testSafetyLine')">
{{ formatTestSafetyLineDetail(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.table.ante')">
{{ formatAnteDetail(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.testCount')">
{{ formatTestCountDisplay(record) }}
</el-descriptions-item>
@@ -30,10 +36,10 @@
{{ record.admin_name ?? record.admin_id ?? $t('page.detail.dash') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.paidPoolId')">
{{ record.paid_lottery_config_id ?? record.lottery_config_id ?? $t('page.detail.dash') }}
{{ formatRecordPaidPoolName(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.freePoolId')">
{{ record.free_lottery_config_id ?? $t('page.detail.dash') }}
{{ formatRecordFreePoolName(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.bigwinSnapshot')">
<template v-if="bigwinWeightDisplay.length">
@@ -182,7 +188,7 @@
<el-option
v-for="opt in paidLotteryOptions"
:key="opt.id"
:label="opt.name"
:label="lotteryPoolOptionLabel(opt)"
:value="opt.id"
/>
</el-select>
@@ -199,7 +205,7 @@
<el-option
v-for="opt in freeLotteryOptions"
:key="opt.id"
:label="opt.name"
:label="lotteryPoolOptionLabel(opt)"
:value="opt.id"
/>
</el-select>
@@ -224,6 +230,11 @@
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import recordApi from '../../../api/reward_config_record/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import {
lotteryPoolLabelById,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -242,6 +253,9 @@
over_play_count?: number
chain_free_mode?: number | boolean | string
paid_planned_spins?: number
ante?: number
kill_mode_enabled?: number
test_safety_line?: number
create_time?: string
admin_id?: number | null
admin_name?: string
@@ -278,6 +292,35 @@
return t('page.table.chainModeNo')
}
function formatTestSafetyLineDetail(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
if (Number(record.kill_mode_enabled ?? 0) !== 1) {
return t('page.detail.killModeOff')
}
const line = record.test_safety_line
if (line === null || line === undefined) {
return t('page.detail.dash')
}
return String(line)
}
function formatAnteDetail(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const snap = record.tier_weights_snapshot
const isRandom =
snap &&
typeof snap === 'object' &&
(snap as { ante_random?: boolean }).ante_random === true
if (isRandom) {
return t('page.table.anteRandom')
}
const ante = record.ante
if (ante === null || ante === undefined) {
return t('page.detail.dash')
}
return String(ante)
}
function formatTestCountDisplay(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const status = Number(record.status)
@@ -316,7 +359,18 @@
const importing = ref(false)
const importPaidLotteryConfigId = ref<number | null>(null)
const importFreeLotteryConfigId = ref<number | null>(null)
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
function formatRecordPaidPoolName(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const id = record.paid_lottery_config_id ?? record.lottery_config_id ?? null
return lotteryPoolLabelById(id, lotteryConfigOptions.value)
}
function formatRecordFreePoolName(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
return lotteryPoolLabelById(record.free_lottery_config_id ?? null, lotteryConfigOptions.value)
}
function tierWeightsToTableData(weightsMap: Record<string, number> | null | undefined) {
const dash = t('page.detail.dash')
@@ -496,6 +550,15 @@
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
void loadLotteryOptions()
}
}
)
function openImport() {
importPaidLotteryConfigId.value =
props.record?.paid_lottery_config_id ?? props.record?.lottery_config_id ?? null

View File

@@ -0,0 +1,99 @@
/** 彩金池选项/关联行通用结构 */
export interface LotteryPoolOption {
id: number
name?: string
remark?: string
display_name?: string
}
/** 彩金池后台展示名(奖池名称):优先 display_name / remark不用内部 name 作为首选 */
export function lotteryPoolDisplayLabel(item?: LotteryPoolOption | null): string {
if (!item) {
return '-'
}
const display = String(item.display_name ?? '').trim()
if (display) {
return display
}
const remark = String(item.remark ?? '').trim()
if (remark) {
return remark
}
return String(item.name ?? '').trim() || '-'
}
/** 下拉选项文案默认仅奖池名称withId=true 时附带 ID */
export function lotteryPoolOptionLabel(
item: LotteryPoolOption,
options?: { withId?: boolean }
): string {
const label = lotteryPoolDisplayLabel(item)
if (options?.withId && item.id > 0) {
return label !== '-' ? `${label} (#${item.id})` : `#${item.id}`
}
return label !== '-' ? label : `#${item.id}`
}
/** 列表行关联彩金池(含 diceLotteryPoolConfig 关联) */
export function lotteryPoolRowLabel(row?: {
diceLotteryPoolConfig?: LotteryPoolOption | null
lottery_config_id?: number | null | string
} | null): string {
if (!row) {
return '-'
}
const pool = row.diceLotteryPoolConfig
if (pool && (pool.id || pool.remark || pool.name || pool.display_name)) {
return lotteryPoolDisplayLabel(pool)
}
const id = row.lottery_config_id
if (id !== null && id !== undefined && id !== '') {
return `#${id}`
}
return '-'
}
/** 规范化接口返回的彩金池选项 */
export function normalizeLotteryPoolOption(raw: Record<string, unknown>): LotteryPoolOption {
const id = Number(raw.id ?? 0)
const name = String(raw.name ?? '')
const remark = String(raw.remark ?? '')
const displayName = String(raw.display_name ?? '').trim()
return {
id,
name,
remark,
display_name: displayName !== '' ? displayName : remark !== '' ? remark : name
}
}
/** 按奖池名称 / 内部标识 / ID 过滤下拉 */
export function filterLotteryPoolOptionsByQuery(
list: LotteryPoolOption[],
query: string
): LotteryPoolOption[] {
const q = (query || '').trim().toLowerCase()
if (!q) {
return [...list]
}
return list.filter((item) => {
const label = lotteryPoolDisplayLabel(item).toLowerCase()
const code = String(item.name ?? '').toLowerCase()
return label.includes(q) || code.includes(q) || String(item.id).includes(q)
})
}
/** 根据 ID 从选项列表解析奖池名称 */
export function lotteryPoolLabelById(
poolId: number | null | undefined,
options: LotteryPoolOption[]
): string {
if (poolId == null || poolId <= 0) {
return '-'
}
const found = options.find((o) => o.id === poolId)
if (found) {
return lotteryPoolDisplayLabel(found)
}
return `#${poolId}`
}

View File

@@ -0,0 +1,549 @@
<template>
<div class="art-full-height admin-guide-page">
<ElCard class="art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<b>{{ $t('page.title') }}</b>
<div v-if="meta.filePath" class="mt-1 text-xs text-g-500">
{{ $t('page.meta.filePath') }}{{ meta.filePath }}
<span v-if="meta.updateTime" class="ml-3">
{{ $t('page.meta.updateTime') }}{{ meta.updateTime }}
</span>
</div>
</div>
<ElSpace wrap>
<ElButton
v-permission="'system:admin_guide:index:read'"
:loading="loading"
@click="loadContent"
>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
{{ $t('page.toolbar.refresh') }}
</ElButton>
<ElButton
v-if="!isEditing"
v-permission="'system:admin_guide:index:edit'"
type="primary"
@click="startEdit"
>
<template #icon>
<ArtSvgIcon icon="ri:pencil-line" />
</template>
{{ $t('page.toolbar.edit') }}
</ElButton>
<template v-if="isEditing">
<ElButton @click="handleCancel">
{{ $t('page.toolbar.cancel') }}
</ElButton>
<ElButton
v-permission="'system:admin_guide:index:save'"
type="primary"
:loading="saving"
@click="handleSave"
>
<template #icon>
<ArtSvgIcon icon="ri:save-line" />
</template>
{{ $t('page.toolbar.save') }}
</ElButton>
</template>
</ElSpace>
</div>
</template>
<div v-loading="loading" class="admin-guide-body flex-1 min-h-0">
<SaMdEditor
v-if="isEditing"
v-model="editContent"
class="admin-guide-editor"
height="calc(100vh - 220px)"
min-height="480px"
/>
<div v-else class="admin-guide-read flex h-full min-h-0">
<aside class="admin-guide-catalog flex-shrink-0">
<div class="catalog-title">{{ $t('page.catalog.title') }}</div>
<ElScrollbar class="catalog-scroll">
<nav v-if="tocList.length" class="guide-toc">
<button
v-for="(item, index) in tocList"
:key="`${item.level}-${item.text}-${index}`"
type="button"
class="guide-toc-item"
:class="[
`guide-toc-level-${item.level}`,
{ 'is-active': activeTocIndex === index }
]"
:title="item.text"
@click="scrollToHeading(item, index)"
>
{{ item.text }}
</button>
</nav>
<div v-else class="guide-toc-empty">{{ $t('page.catalog.empty') }}</div>
</ElScrollbar>
</aside>
<div
id="admin-guide-scroll"
ref="previewScrollRef"
class="admin-guide-preview-wrap flex-1 min-h-0 overflow-auto"
>
<MdPreview
:editor-id="previewEditorId"
:model-value="previewContent"
:theme="previewTheme"
preview-theme="github"
no-img-zoom-in
class="admin-guide-preview"
@on-html-changed="handlePreviewHtmlReady"
/>
</div>
</div>
</div>
</ElCard>
<ElImageViewer
v-if="imageViewerVisible"
:key="imageViewerIndex"
:url-list="previewImageUrls"
:initial-index="imageViewerIndex"
:hide-on-click-modal="true"
:z-index="3000"
teleported
@close="closeImageViewer"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElImageViewer, ElMessageBox } from 'element-plus'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import SaMdEditor from '@/components/sai/sa-md-editor/index.vue'
import { useSettingStore } from '@/store/modules/setting'
import api from '@/api/system/admin_guide'
defineOptions({ name: 'SystemAdminGuide' })
interface GuideTocItem {
level: number
text: string
}
const { t } = useI18n()
const settingStore = useSettingStore()
const previewEditorId = 'admin-guide-preview'
const previewScrollRef = ref<HTMLElement | null>(null)
const loading = ref(false)
const saving = ref(false)
const isEditing = ref(false)
const previewContent = ref('')
const editContent = ref('')
const originalContent = ref('')
const meta = ref<{ filePath?: string; updateTime?: string }>({})
const tocList = ref<GuideTocItem[]>([])
const activeTocIndex = ref(-1)
const previewImageUrls = ref<string[]>([])
const imageViewerVisible = ref(false)
const imageViewerIndex = ref(0)
let previewImageClickHandler: ((event: Event) => void) | null = null
let previewEnhanceTimer: ReturnType<typeof setTimeout> | null = null
const previewTheme = computed(() => (settingStore.isDark ? 'dark' : 'light'))
const getStaticFileBase = (): string => {
const apiUrl = import.meta.env.VITE_API_URL || ''
if (apiUrl.startsWith('http')) {
return apiUrl.replace(/\/$/, '')
}
const proxyUrl = import.meta.env.VITE_API_PROXY_URL || ''
if (proxyUrl) {
return String(proxyUrl).replace(/\/$/, '')
}
return ''
}
const resolveGuideImages = (content: string): string => {
const staticBase = getStaticFileBase()
if (!staticBase) {
return content
}
return content.replace(/!\[([^\]]*)\]\((\/docs\/picture\/[^)]+)\)/g, (_match, alt, path) => {
return `![${alt}](${staticBase}${path})`
})
}
const parseTocFromMarkdown = (content: string): GuideTocItem[] => {
const items: GuideTocItem[] = []
for (const line of content.split('\n')) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) {
continue
}
items.push({
level: match[1].length,
text: match[2].trim()
})
}
return items
}
const refreshTocList = () => {
tocList.value = parseTocFromMarkdown(originalContent.value)
activeTocIndex.value = -1
}
const collectPreviewImages = () => {
const container = previewScrollRef.value
if (!container) {
previewImageUrls.value = []
return
}
const imgs = container.querySelectorAll('img')
previewImageUrls.value = Array.from(imgs).map((img) => {
const el = img as HTMLImageElement
return el.currentSrc || el.src
})
}
const findImageIndex = (img: HTMLImageElement): number => {
collectPreviewImages()
const targetSrc = img.currentSrc || img.src
let index = previewImageUrls.value.indexOf(targetSrc)
if (index >= 0) {
return index
}
index = previewImageUrls.value.findIndex((url) => targetSrc.endsWith(url) || url.endsWith(targetSrc))
if (index >= 0) {
return index
}
previewImageUrls.value = [...previewImageUrls.value, targetSrc]
return previewImageUrls.value.length - 1
}
const openImageViewerByImg = (img: HTMLImageElement) => {
const index = findImageIndex(img)
openImageViewer(index)
}
const openImageViewer = (index: number) => {
if (index < 0 || index >= previewImageUrls.value.length) {
return
}
imageViewerIndex.value = index
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
}
const unbindPreviewImageClick = (clearTimer = true) => {
const container = previewScrollRef.value
if (container && previewImageClickHandler) {
container.removeEventListener('click', previewImageClickHandler, true)
}
previewImageClickHandler = null
if (clearTimer && previewEnhanceTimer) {
clearTimeout(previewEnhanceTimer)
previewEnhanceTimer = null
}
}
const bindPreviewImageClick = () => {
unbindPreviewImageClick(false)
const container = previewScrollRef.value
if (!container) {
return
}
collectPreviewImages()
previewImageClickHandler = (event: Event) => {
const imgEl = (event.target as Element | null)?.closest('img')
if (!(imgEl instanceof HTMLImageElement)) {
return
}
if (!container.contains(imgEl)) {
return
}
event.preventDefault()
event.stopPropagation()
openImageViewerByImg(imgEl)
}
container.addEventListener('click', previewImageClickHandler, true)
const imgs = container.querySelectorAll('img')
imgs.forEach((img) => {
const el = img as HTMLImageElement
el.style.cursor = 'zoom-in'
el.title = t('page.image.zoom')
})
}
const handlePreviewHtmlReady = () => {
if (previewEnhanceTimer) {
clearTimeout(previewEnhanceTimer)
}
previewEnhanceTimer = setTimeout(() => {
bindPreviewImageClick()
previewEnhanceTimer = null
}, 50)
}
const setupPreviewEnhancements = async () => {
await nextTick()
refreshTocList()
handlePreviewHtmlReady()
}
const scrollToHeading = (item: GuideTocItem, index: number) => {
const container = previewScrollRef.value
if (!container) {
return
}
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6')
for (const heading of Array.from(headings)) {
if (heading.textContent?.trim() === item.text) {
activeTocIndex.value = index
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
}
}
const loadContent = async () => {
loading.value = true
unbindPreviewImageClick()
try {
const res = await api.read()
const data = res as { content?: string; file_path?: string; update_time?: string }
const rawContent = data.content ?? ''
editContent.value = rawContent
originalContent.value = rawContent
previewContent.value = resolveGuideImages(rawContent)
meta.value = {
filePath: data.file_path,
updateTime: data.update_time
}
await setupPreviewEnhancements()
} catch {
// 错误由 http 工具统一处理
} finally {
loading.value = false
}
}
const startEdit = () => {
editContent.value = originalContent.value
isEditing.value = true
unbindPreviewImageClick()
}
const handleCancel = async () => {
if (editContent.value !== originalContent.value) {
try {
await ElMessageBox.confirm(t('page.message.cancelConfirm'), {
type: 'warning'
})
} catch {
return
}
}
editContent.value = originalContent.value
isEditing.value = false
await setupPreviewEnhancements()
}
const handleSave = async () => {
if (!isEditing.value) {
return
}
saving.value = true
try {
const res = await api.save({ content: editContent.value })
const data = res as { content?: string; file_path?: string; update_time?: string }
const savedContent = data.content ?? editContent.value
editContent.value = savedContent
originalContent.value = savedContent
previewContent.value = resolveGuideImages(savedContent)
meta.value = {
filePath: data.file_path ?? meta.value.filePath,
updateTime: data.update_time ?? meta.value.updateTime
}
isEditing.value = false
await setupPreviewEnhancements()
} catch {
// 错误由 http 工具统一处理
} finally {
saving.value = false
}
}
watch(previewContent, async () => {
if (!isEditing.value) {
await nextTick()
handlePreviewHtmlReady()
}
})
onMounted(() => {
loadContent()
})
onBeforeUnmount(() => {
unbindPreviewImageClick()
})
</script>
<style scoped lang="scss">
.admin-guide-page {
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding-top: 12px;
}
}
.admin-guide-body {
width: 100%;
min-height: 0;
}
.admin-guide-read {
min-height: 0;
gap: 0;
}
.admin-guide-catalog {
display: flex;
flex-direction: column;
width: 240px;
min-height: 0;
padding: 4px 12px 12px 4px;
border-right: 1px solid var(--el-border-color-lighter);
}
.catalog-title {
flex-shrink: 0;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.catalog-scroll {
flex: 1;
min-height: 0;
}
.guide-toc {
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 8px;
}
.guide-toc-item {
display: block;
width: 100%;
padding: 5px 8px;
overflow: hidden;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-regular);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition: color 0.2s, background-color 0.2s;
&:hover,
&.is-active {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
}
.guide-toc-level-1 {
padding-left: 8px;
font-weight: 600;
}
.guide-toc-level-2 {
padding-left: 20px;
}
.guide-toc-level-3 {
padding-left: 32px;
font-size: 12px;
}
.guide-toc-level-4 {
padding-left: 44px;
font-size: 12px;
}
.guide-toc-level-5,
.guide-toc-level-6 {
padding-left: 56px;
font-size: 12px;
}
.guide-toc-empty {
padding: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.admin-guide-preview-wrap {
position: relative;
padding: 8px 12px 8px 16px;
}
.admin-guide-preview {
padding: 0;
}
.admin-guide-editor {
width: 100%;
}
@media (max-width: 768px) {
.admin-guide-read {
flex-direction: column;
}
.admin-guide-catalog {
width: 100%;
max-height: 200px;
padding-right: 0;
border-right: none;
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
</style>
<style lang="scss">
.admin-guide-preview-wrap .md-editor-preview-wrapper img,
.admin-guide-preview-wrap .md-editor-preview img,
.admin-guide-editor .md-editor-preview-wrapper img,
.admin-guide-editor .md-editor-preview img {
display: block;
width: 50% !important;
max-width: 50% !important;
height: auto !important;
margin: 8px 0;
cursor: zoom-in;
}
</style>

View File

@@ -106,8 +106,15 @@
ElMessage.success('删除成功')
emit('success')
handleClose()
} catch (e: any) {
ElMessage.error(e?.message || '删除失败')
} catch (e: unknown) {
let msg = '删除失败'
if (e !== null && typeof e === 'object' && 'message' in e) {
const m = Reflect.get(e, 'message')
if (typeof m === 'string' && m !== '') {
msg = m
}
}
ElMessage.error(msg)
} finally {
submitting.value = false
}

View File

@@ -125,6 +125,10 @@
} = useTable({
core: {
apiFn: api.list,
apiParams: {
orderField: 'level',
orderType: 'desc'
},
columnsFactory: () => [
{ prop: 'id', label: 'table.columns.common.no', minWidth: 60, align: 'center' },
{ prop: 'name', label: 'page.table.roleName', minWidth: 120 },

View File

@@ -4,7 +4,7 @@ DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=dafuweng-v3
DB_USER=dafuweng-v3
DB_PASSWORD=tA6rciKLKxpFNGAm
DB_PASSWORD=123456
DB_PREFIX=
DB_POOL_MAX=32
DB_POOL_MIN=4
@@ -28,14 +28,16 @@ WEBMAN_CHANNEL_LISTEN_HOST=0.0.0.0
GAME_URL=dice-v3-game.h55555game.top
# API 鉴权与用户(可选,不填则用默认值)
# 平台对接 /api/v1/* 请求头 api-key必填与对接方约定
API_KEY=
# authToken 签名密钥(必填,与客户端约定,用于 signature 校验)
API_AUTH_TOKEN_SECRET=xF75oK91TQj13s0UmNIr1NBWMWGfflNO
API_AUTH_TOKEN_SECRET=
# authToken 时间戳允许误差秒数,防重放,默认 300
API_AUTH_TOKEN_TIME_TOLERANCE=300
API_AUTH_TOKEN_EXP=86400
# API_USER_TOKEN_EXP=604800
API_USER_CACHE_EXPIRE=86400
API_USER_ENCRYPT_KEY=Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi
API_USER_ENCRYPT_KEY=
# 验证码配置,支持cache|session
CAPTCHA_MODE=cache

View File

@@ -92,15 +92,11 @@ class GameController extends BaseController
public function getGameUrl(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$password = trim((string) ($request->post('password', '123456')));
$time = trim((string) ($request->post('time', '')));
if ($username === '') {
return $this->fail('username is required', ReturnCode::PARAMS_ERROR);
}
if ($password === '') {
$password = '123456';
}
if ($time === '') {
$time = (string) time();
}
@@ -114,7 +110,8 @@ class GameController extends BaseController
try {
$logic = new UserLogic();
$result = $logic->loginByUsername($username, $password, $lang, 0.0, $time, $adminId, $adminIdsInTopDept, $deptId);
// 平台 v1 已通过 api-key + auth-token 双重校验,此处不再做 password 校验
$result = $logic->loginByUsername($username, '', $lang, 0.0, $time, $adminId, $adminIdsInTopDept, $deptId, true);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::PARAMS_ERROR);
}
@@ -148,7 +145,18 @@ class GameController extends BaseController
return $this->success($cached);
}
$player = DicePlayer::field(self::PLAYER_INFO_DB_FIELDS)->where('username', $username)->where('dept_id', $deptId)->find();
try {
$logic = new UserLogic();
$player = $logic->findOrCreatePlayerByUsername(
$username,
$this->agentAdminId($request),
$deptId > 0 ? $deptId : null
);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::PARAMS_ERROR);
}
$player = DicePlayer::field(self::PLAYER_INFO_DB_FIELDS)->where('id', (int) $player->id)->find();
if (!$player) {
return $this->fail('User not found', ReturnCode::PARAMS_ERROR);
}

View File

@@ -48,6 +48,9 @@ return [
'没有原因' => 'Unknown reason',
'缺少参数agent_id、secret、time、signature 不能为空' => 'Missing parameters: agent_id, secret, time, signature are required',
'服务端未配置 API_AUTH_TOKEN_SECRET' => 'API_AUTH_TOKEN_SECRET is not configured',
'服务端未配置 API_KEY' => 'API_KEY is not configured',
'请携带 api-key' => 'Please provide api-key',
'api-key 无效' => 'Invalid api-key',
'密钥错误' => 'Invalid secret',
'时间戳已过期或无效,请同步时间' => 'Timestamp expired or invalid, please sync time',
'签名验证失败' => 'Signature verification failed',

View File

@@ -50,6 +50,8 @@ class PlayStartLogic
private const SUPER_WIN_GRID_NUMBERS = [5, 10, 15, 20, 25, 30];
/** 5 和 30 抽到即豹子,不参与 BIGWIN 权重判定10/15/20/25 按 BIGWIN weight 判定是否豹子 */
private const SUPER_WIN_ALWAYS_GRID_NUMBERS = [5, 30];
/** T4 惩罚格余额不足时写入游玩记录的备注 */
private const REMARK_T4_INSUFFICIENT_BALANCE = '惩罚格奖励:玩家余额不足,已扣尽钱包剩余余额';
/**
* 执行一局游戏
@@ -116,7 +118,8 @@ class PlayStartLogic
}
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->where('dept_id', $configDeptId)->find();
$configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->where('dept_id', $configDeptId)->find();
$configKill = DiceLotteryPoolConfig::where('name', 'killScore')->where('dept_id', $configDeptId)->find();
$configFree = DiceLotteryPoolConfig::where('name', 'free')->where('dept_id', $configDeptId)->find();
if (!$configType0) {
throw new ApiException('Lottery pool config not found (name=default required)');
}
@@ -124,41 +127,40 @@ class PlayStartLogic
// 付费抽奖:开始前扣除费用 ante * UNIT_COST
$paidAmount = $ticketType === self::LOTTERY_TYPE_PAID ? round($ante * self::UNIT_COST, 2) : 0.0;
// 游玩前余额校验(按 T4 惩罚最大值兜底):
// 门槛 = paidAmount(压注*1) + abs(T4最小real_ev)*ante
$t4List = DiceRewardConfig::getCachedByTier('T4', $configDeptId);
$t4MinRealEv = null;
foreach ($t4List as $row) {
$ev = $row['real_ev'] ?? null;
if ($ev === null || $ev === '') {
continue;
}
$evFloat = filter_var($ev, FILTER_VALIDATE_FLOAT);
if ($evFloat === false) {
continue;
}
if ($t4MinRealEv === null || $evFloat < $t4MinRealEv) {
$t4MinRealEv = $evFloat;
}
}
$t4PenaltyAbs = $t4MinRealEv === null ? 0.0 : abs($t4MinRealEv) * $ante;
$needMinBalance = round($paidAmount + $t4PenaltyAbs, 2);
if ($coin < $needMinBalance) {
// 付费抽奖余额不足单局费用ante * UNIT_COST时不允许开始惩罚格不足部分在局内扣尽剩余余额
if ($ticketType === self::LOTTERY_TYPE_PAID && $coin < $paidAmount) {
throw new ApiException('余额不足');
}
// 彩金池累计盈利:用于判断是否触发杀分(不再依赖单个玩家累计盈利)
// 该值来自 dice_lottery_pool_config.profit_amount
// 该值来自 dice_lottery_pool_config.profit_amountdefault 奖池)
$poolProfitTotal = $configType0->profit_amount ?? 0;
$safetyLine = (int) ($configType0->safety_line ?? 0);
$killEnabled = ((int) ($configType0->kill_enabled ?? 1)) === 1;
// 盈利>=安全线且开启杀分:付费/免费都用 killScore盈利<安全线:付费用玩家权重,免费用 killScore无则用 default
// 记录 lottery_config_id用池权重时记对应池付费用玩家权重时记 default
$usePoolWeights = ($ticketType === self::LOTTERY_TYPE_PAID && $killEnabled && $poolProfitTotal >= $safetyLine && $configType1 !== null)
|| ($ticketType === self::LOTTERY_TYPE_FREE);
$config = $usePoolWeights
? (($ticketType === self::LOTTERY_TYPE_FREE && $configType1 === null) ? $configType0 : $configType1)
: $configType0;
$usePaidKill = $ticketType === self::LOTTERY_TYPE_PAID
&& $killEnabled
&& $poolProfitTotal >= $safetyLine
&& $configKill !== null;
$playerLinkedPool = $this->resolvePlayerLinkedPoolConfig($player, $configDeptId);
if ($ticketType === self::LOTTERY_TYPE_FREE) {
// 免费抽奖券:使用本渠道 name=free 奖池档位权重;无 free 时回退 default
$config = $configFree ?? $configType0;
$usePoolWeights = true;
} elseif ($usePaidKill) {
$config = $configKill;
$usePoolWeights = true;
} else {
// 付费未触发杀分:关联 playerDefault 时实时读该池权重;否则按玩家行内 T*_weight
$config = $configType0;
$usePoolWeights = false;
if ($playerLinkedPool !== null && $playerLinkedPool->isPlayerDefaultTemplate()) {
$usePoolWeights = true;
$config = $playerLinkedPool;
}
}
// 按档位 T1-T5 抽取后,从 DiceReward 表按当前方向取该档位数据,再按 weight 抽取一条得到 grid_number
$rewardInstance = DiceReward::getCachedInstance($configDeptId);
@@ -203,9 +205,9 @@ class PlayStartLogic
$rollNumber = (int) ($chosen['grid_number'] ?? 0);
$realEv = (float) ($chosen['real_ev'] ?? 0);
// T5/再来一次:以奖励行 tier 为准,并以摇奖档位 $tier 兜底(与 reward_tier 展示一致,避免 dice_reward 行缺 tier 时不发券)
$isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5';
if ($isTierT5 === false && (string) ($tier ?? '') === 'T5') {
$isTierT5 = true;
$isTierPlayAgain = (string) ($chosen['tier'] ?? '') === 'T5';
if ($isTierPlayAgain === false && (string) ($tier ?? '') === 'T5') {
$isTierPlayAgain = true;
}
// 摇色子中奖:按 dice_reward_config.real_ev 直接结算(已乘 ante
$rewardWinCoin = round($realEv * $ante, 2);
@@ -246,7 +248,7 @@ class PlayStartLogic
// 中 BIGWIN 豹子:不走原奖励流程,不记录原奖励,不触发 T5 再来一次,仅发放豹子奖金
$rewardWinCoin = 0.0;
$realEv = 0.0;
$isTierT5 = false;
$isTierPlayAgain = false;
} else {
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
}
@@ -265,7 +267,11 @@ class PlayStartLogic
$winCoin = round($superWinCoin + $rewardWinCoin, 2); // 赢取平台币 = 中大奖 + 摇色子中奖(豹子时 rewardWinCoin 已为 0
$record = null;
$settledWinCoin = $winCoin;
$configId = (int) $config->id;
if ($ticketType === self::LOTTERY_TYPE_PAID && !$usePaidKill && $playerLinkedPool !== null) {
$configId = (int) $playerLinkedPool->id;
}
$type0ConfigId = (int) $configType0->id;
$rewardId = ($isWin === 1 && $superWinCoin > 0) ? 0 : $targetIndex; // 中豹子不记录原奖励配置 id
$configName = (string) ($config->name ?? '');
@@ -291,11 +297,33 @@ class PlayStartLogic
$startIndex,
$targetIndex,
$rollArray,
$isTierT5,
$isTierPlayAgain,
$tier,
&$record
&$record,
&$settledWinCoin
) {
$rewardTier = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
$p = DicePlayer::find($playerId);
if (!$p) {
throw new \RuntimeException('玩家不存在');
}
$coinBefore = (float) $p->coin;
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
$recordWinCoin = $winCoin;
$recordRewardWinCoin = $rewardWinCoin;
$playRecordRemark = null;
// T4 惩罚:扣完购券费用后若不足以支付全额惩罚,则扣尽钱包剩余余额并记录备注
if ($rewardTier === 'T4' && $coinAfter < 0) {
$walletRemain = round($coinBefore - $paidAmount, 2);
$coinAfter = 0.0;
$recordWinCoin = round(-$walletRemain, 2);
$recordRewardWinCoin = $recordWinCoin;
$playRecordRemark = self::REMARK_T4_INSUFFICIENT_BALANCE;
}
$settledWinCoin = $recordWinCoin;
$record = DicePlayRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
@@ -305,9 +333,9 @@ class PlayStartLogic
'ante' => $ante,
'paid_amount' => $paidAmount,
'is_win' => $isWin,
'win_coin' => $winCoin,
'win_coin' => $recordWinCoin,
'super_win_coin' => $superWinCoin,
'reward_win_coin' => $rewardWinCoin,
'reward_win_coin' => $recordRewardWinCoin,
'direction' => $direction,
'reward_tier' => $rewardTier,
'start_index' => $startIndex,
@@ -315,19 +343,9 @@ class PlayStartLogic
'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray,
'roll_number' => is_array($rollArray) ? array_sum($rollArray) : 0,
'status' => self::RECORD_STATUS_SUCCESS,
'remark' => $playRecordRemark,
]);
$p = DicePlayer::find($playerId);
if (!$p) {
throw new \RuntimeException('玩家不存在');
}
$coinBefore = (float) $p->coin;
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
// T4 惩罚兜底:扣完购券费用后若余额不足以承受本次惩罚(导致为负),统一按“余额不足”提示
if ($rewardTier === 'T4' && $coinAfter < 0) {
throw new ApiException('余额不足');
}
$p->coin = $coinAfter;
// 免费抽奖消耗:优先消耗 free_ticket.count耗尽则清空 free_ticket否则兼容旧 free_ticket_count
if ($ticketType === self::LOTTERY_TYPE_FREE) {
@@ -375,7 +393,7 @@ class PlayStartLogic
// 若本局中奖档位为 T5则额外赠送 1 次免费抽奖次数:
// - 新结构:写入 free_ticketante=本局注数count+1
// - 兼容旧结构free_ticket_count +1
if ($isTierT5) {
if ($isTierPlayAgain) {
$ft = $p->free_ticket ?? null;
$ftAnte = null;
$ftCount = 0;
@@ -429,7 +447,7 @@ class PlayStartLogic
// 彩金池累计盈利累加在 name=default 彩金池上:
// 付费:每局按「本局赢取平台币 win_coin - 抽奖费用 paid_amountante*UNIT_COST
// 免费券paid_amount=0只计入 win_coin
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($winCoin - $paidAmount) : $winCoin;
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($recordWinCoin - $paidAmount) : $recordWinCoin;
$addProfit = round($perPlayProfit, 2);
try {
DiceLotteryPoolConfig::where('id', $type0ConfigId)->update([
@@ -459,12 +477,15 @@ class PlayStartLogic
}
$walletBeforeDraw = $coinBefore - $paidAmount;
$drawRemark = ($winCoin >= 0 ? '抽奖中奖' : '抽奖惩罚') . '|play_record_id=' . $record->id;
$drawRemark = ($recordWinCoin >= 0 ? '抽奖中奖' : '抽奖惩罚') . '|play_record_id=' . $record->id;
if ($playRecordRemark !== null) {
$drawRemark .= '|' . $playRecordRemark;
}
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'coin' => $winCoin,
'coin' => $recordWinCoin,
'type' => self::WALLET_TYPE_DRAW,
'wallet_before' => round($walletBeforeDraw, 2),
'wallet_after' => $coinAfter,
@@ -654,6 +675,7 @@ class PlayStartLogic
*/
public function simulateOnePlay($config, int $direction, int $lotteryType = 0, int $ante = 1, ?array $customTierWeights = null, ?int $configDeptId = null): array
{
$useKillMode = false;
$rewardInstance = DiceReward::getCachedInstance($configDeptId);
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
$maxTierRetry = 10;
@@ -776,4 +798,24 @@ class PlayStartLogic
'grants_free_ticket' => $grantsFreeTicket,
];
}
/**
* 玩家已选彩金池配置(用于 playerDefault 运行时权重)
*/
private function resolvePlayerLinkedPoolConfig(DicePlayer $player, int $configDeptId): ?DiceLotteryPoolConfig
{
$linkedId = (int) ($player->lottery_config_id ?? 0);
if ($linkedId <= 0) {
return null;
}
$cfg = DiceLotteryPoolConfig::find($linkedId);
if (!$cfg) {
return null;
}
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($cfg->dept_id ?? null);
if ($poolDeptId !== AdminScopeHelper::normalizeRecordDeptId($configDeptId)) {
return null;
}
return $cfg;
}
}

View File

@@ -68,6 +68,54 @@ class UserLogic
return array_map('intval', $adminIds ?: [(int) $admin->id]);
}
/**
* 按用户名查找玩家;不存在则创建并绑定渠道/管理员(供 getPlayerInfo 等接口)
*
* @param int|null $adminId 关联后台管理员 IDsa_system_user.id
* @param int|null $deptId 所属渠道 ID
*/
public function findOrCreatePlayerByUsername(string $username, ?int $adminId = null, ?int $deptId = null): DicePlayer
{
$username = trim($username);
if ($username === '') {
throw new ApiException('username is required');
}
$query = DicePlayer::where('username', $username);
if ($deptId !== null && $deptId > 0) {
$query->where('dept_id', $deptId);
}
$player = $query->find();
if ($player) {
if ((int) ($player->status ?? 1) === 0) {
throw new ApiException('Account is disabled');
}
return $player;
}
$player = new DicePlayer();
$player->username = $username;
$player->phone = $username;
$player->password = $this->hashPassword('123456');
$player->status = self::STATUS_NORMAL;
$player->coin = 0;
if ($deptId !== null && $deptId > 0) {
$player->dept_id = $deptId;
}
if ($adminId !== null && $adminId > 0) {
$player->admin_id = $adminId;
if ($deptId === null || $deptId <= 0) {
$adminUser = SystemUser::find($adminId);
if ($adminUser && !empty($adminUser->dept_id)) {
$player->dept_id = $adminUser->dept_id;
}
}
}
$player->save();
return $player;
}
/**
* 登录JSONusername, password, lang, coin, time
* 存在则校验密码并更新 coin累加不存在则创建用户并写入 coin。
@@ -76,7 +124,7 @@ class UserLogic
* @param int|null $adminId 创建新用户时关联的后台管理员IDsa_system_user.id可选
* @param int[]|null $adminIdsInTopDept 当前管理员顶级部门下的所有管理员ID用于按部门范围查找玩家为空时退化为仅按 username 查找
*/
public function loginByUsername(string $username, string $password, string $lang, float $coin, string $time, ?int $adminId = null, ?array $adminIdsInTopDept = null, ?int $deptId = null): array
public function loginByUsername(string $username, string $password, string $lang, float $coin, string $time, ?int $adminId = null, ?array $adminIdsInTopDept = null, ?int $deptId = null, bool $skipPasswordValidation = false): array
{
$username = trim($username);
if ($username === '') {
@@ -95,9 +143,11 @@ class UserLogic
if ((int) ($player->status ?? 1) === 0) {
throw new ApiException('Account is disabled and cannot log in');
}
$hashed = $this->hashPassword($password);
if ($player->password !== $hashed) {
throw new ApiException('Wrong password');
if (!$skipPasswordValidation) {
$hashed = $this->hashPassword($password);
if ($player->password !== $hashed) {
throw new ApiException('Wrong password');
}
}
$currentCoin = (float) $player->coin;
$player->coin = $currentCoin + $coin;

View File

@@ -20,6 +20,7 @@ class ApiAccessLogMiddleware implements MiddlewareInterface
/** 请求头名称(小写) */
private const SENSITIVE_HEADER_NAMES = [
'api-key',
'auth-token',
'token',
'authorization',
@@ -32,6 +33,8 @@ class ApiAccessLogMiddleware implements MiddlewareInterface
'secret',
'signature',
'token',
'api-key',
'api_key',
'auth-token',
'auth_token',
'old_token',

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
/**
* 校验对接平台 api-key与 .env 中 API_KEY 一致)
* 仅用于 /api/v1/* 平台对接接口
*
* 取值优先级(按顺序读取,首个非空即采用):
* 1. 请求头 api-key推荐
* 2. 查询参数 api_key / api-key
* 3. body 表单/JSON api_key / api-key
*/
class ApiKeyMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$expected = (string) config('api.platform_api_key', '');
if ($expected === '') {
throw new ApiException('API_KEY is not configured', ReturnCode::SERVER_ERROR);
}
$apiKey = $this->resolveApiKey($request);
if ($apiKey === '') {
throw new ApiException('Please provide api-key', ReturnCode::UNAUTHORIZED);
}
if (!hash_equals($expected, $apiKey)) {
throw new ApiException('Invalid api-key', ReturnCode::FORBIDDEN);
}
return $handler($request);
}
private function resolveApiKey(Request $request): string
{
$headerValue = $request->header('api-key');
if ($headerValue !== null && trim((string) $headerValue) !== '') {
return trim((string) $headerValue);
}
foreach (['api_key', 'api-key'] as $key) {
$val = $request->get($key);
if ($val !== null && trim((string) $val) !== '') {
return trim((string) $val);
}
}
foreach (['api_key', 'api-key'] as $key) {
$val = $request->post($key);
if ($val !== null && trim((string) $val) !== '') {
return trim((string) $val);
}
}
return '';
}
}

View File

@@ -83,6 +83,47 @@ class LotteryService
Cache::set($key, json_encode($data), self::EXPIRE);
}
/**
* 使玩家彩金池 Redis 快照失效(下次 getOrCreate 从库重建)
*/
public static function invalidatePlayerLotteryCache(int $playerId): void
{
if ($playerId <= 0) {
return;
}
Cache::delete(self::getRedisKey($playerId));
}
/**
* 若 Redis 中已有该玩家彩金池快照,则仅更新 player_weights避免全量重建
*
* @param array{t1_weight?:int,t2_weight?:int,t3_weight?:int,t4_weight?:int,t5_weight?:int} $weights
*/
public static function patchPlayerWeightsCache(int $playerId, array $weights): void
{
if ($playerId <= 0) {
return;
}
$key = self::getRedisKey($playerId);
$cached = Cache::get($key);
if (!$cached || !is_string($cached)) {
return;
}
$data = json_decode($cached, true);
if (!is_array($data)) {
Cache::delete($key);
return;
}
$data['player_weights'] = [
't1_weight' => (int) ($weights['t1_weight'] ?? 0),
't2_weight' => (int) ($weights['t2_weight'] ?? 0),
't3_weight' => (int) ($weights['t3_weight'] ?? 0),
't4_weight' => (int) ($weights['t4_weight'] ?? 0),
't5_weight' => (int) ($weights['t5_weight'] ?? 0),
];
Cache::set($key, json_encode($data), self::EXPIRE);
}
/** 根据奖池配置的 t1_weight..t5_weight 权重随机抽取档位 T1-T5 */
public static function drawTierByWeights(DiceLotteryPoolConfig $config): string
{

View File

@@ -38,19 +38,23 @@ class DiceLotteryPoolConfigController extends BaseController
#[Permission('色子奖池配置列表', 'dice:lottery_pool_config:index:index')]
public function getOptions(Request $request): Response
{
$query = DiceLotteryPoolConfig::field('id,name,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
$query = DiceLotteryPoolConfig::field('id,name,remark,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
->order('id', 'asc');
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(function ($item) {
$row = is_array($item) ? $item : $item->toArray();
$display = DiceLotteryPoolConfig::displayLabel($row);
return [
'id' => (int) $item['id'],
'name' => (string) ($item['name'] ?? ''),
't1_weight' => (int) ($item['t1_weight'] ?? 0),
't2_weight' => (int) ($item['t2_weight'] ?? 0),
't3_weight' => (int) ($item['t3_weight'] ?? 0),
't4_weight' => (int) ($item['t4_weight'] ?? 0),
't5_weight' => (int) ($item['t5_weight'] ?? 0),
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => $display,
't1_weight' => (int) ($row['t1_weight'] ?? 0),
't2_weight' => (int) ($row['t2_weight'] ?? 0),
't3_weight' => (int) ($row['t3_weight'] ?? 0),
't4_weight' => (int) ($row['t4_weight'] ?? 0),
't5_weight' => (int) ($row['t5_weight'] ?? 0),
];
})->toArray();
return $this->success($data);

View File

@@ -51,6 +51,8 @@ class DicePlayRecordController extends BaseController
['reward_ui_text', ''],
['reward_tier', ''],
['direction', ''],
['create_time_min', ''],
['create_time_max', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
@@ -92,7 +94,7 @@ class DicePlayRecordController extends BaseController
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function getLotteryConfigOptions(Request $request): Response
{
$query = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc');
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
@@ -100,7 +102,13 @@ class DicePlayRecordController extends BaseController
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $requestDeptId);
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => (int) $item['id'], 'name' => (string) ($item['name'] ?? '')];
$row = is_array($item) ? $item : $item->toArray();
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => DiceLotteryPoolConfig::displayLabel($row),
];
})->toArray();
return $this->success($data);
}

View File

@@ -40,6 +40,7 @@ class DicePlayRecordTestController extends BaseController
{
$where = $request->more([
['reward_config_record_id', ''],
['lottery_config_id', ''],
['lottery_type', ''],
['direction', ''],
['is_win', ''],

View File

@@ -43,7 +43,7 @@ class DicePlayerController extends BaseController
#[Permission('玩家列表', 'dice:player:index:index')]
public function getLotteryConfigOptions(Request $request): Response
{
$query = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc');
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
@@ -51,7 +51,13 @@ class DicePlayerController extends BaseController
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $requestDeptId);
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => (int) $item['id'], 'name' => (string) ($item['name'] ?? '')];
$row = is_array($item) ? $item : $item->toArray();
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => DiceLotteryPoolConfig::displayLabel($row),
];
})->toArray();
return $this->success($data);
}

View File

@@ -96,7 +96,7 @@ class DiceRewardController extends BaseController
* 参数lottery_config_id 可选paid_tier_weights / free_tier_weights 自定义档位;
* paid_s_count, paid_n_count
* chain_free_mode=1仅按付费次数模拟付费抽到再来一次/T5 则在队列中插入免费局同底注、lottery_type=免费、paid_amount=0
* kill_mode_enabled=1测试内启用杀分当模拟玩家累计盈利达到 test_safety_line 后,付费抽奖切到 killScore
* kill_mode_enabled=1测试内启用杀分当模拟盈利达到 test_safety_line 后,付费抽奖切到 killScore
*/
#[Permission('一键测试权重', 'dice:reward:index:startWeightTest')]
public function startWeightTest(Request $request): Response
@@ -116,6 +116,7 @@ class DiceRewardController extends BaseController
'test_safety_line' => $post['test_safety_line'] ?? null,
'dept_id' => $post['dept_id'] ?? null,
'ante_config_id' => $post['ante_config_id'] ?? null,
'ante_random' => $post['ante_random'] ?? null,
];
$adminId = isset($this->adminInfo['id']) ? (int) $this->adminInfo['id'] : null;
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), $post);

View File

@@ -127,6 +127,25 @@ class DiceRewardConfigController extends BaseController
*/
#[Permission('修改奖励索引', 'dice:reward_config:index:batchUpdate')]
public function batchUpdate(Request $request): Response
{
return $this->doBatchUpdateIndex($request);
}
/**
* 按规则生成并保存奖励索引(需档位结算推荐配置权限,与 batchUpdate 写入逻辑相同)
*
* @param Request $request items: [{ id, grid_number?, ui_text?, ui_text_en?, real_ev?, tier?, remark? }, ...]
*/
#[Permission('档位结算推荐配置', 'dice:reward_config:index:tierRecommend')]
public function generateIndexByRules(Request $request): Response
{
return $this->doBatchUpdateIndex($request);
}
/**
* @return Response
*/
private function doBatchUpdateIndex(Request $request): Response
{
$items = $request->post('items', []);
if (! is_array($items)) {
@@ -244,6 +263,23 @@ class DiceRewardConfigController extends BaseController
}
}
/**
* 创建奖励对照(预览):不写入 dice_reward仅计算并返回预览分组数据。
* 若当前 dice_reward 与计算结果一致,则 unchanged=true并在预览中复用现有权重导入时仍沿用旧权重
*/
#[Permission('创建奖励对照', 'dice:reward_config:index:createRewardReference')]
public function createRewardReferencePreview(Request $request): Response
{
try {
$rewardLogic = new DiceRewardLogic();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$result = $rewardLogic->createRewardReferencePreviewFromConfig($deptId);
return $this->success($result, 'preview reward mapping success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
/**
* 权重配比测试:仅模拟落点统计,不创建游玩记录。按当前配置在内存中模拟 N 次抽奖,返回各 grid_number 落点次数,可选保存到 dice_reward_config_record。
* @param Request $request test_count: 100|500|1000, save_record: bool, lottery_config_id: int|null 奖池配置ID用于设定 T1-T5 概率

View File

@@ -24,6 +24,7 @@ class DiceAnteConfigLogic extends DiceBaseLogic
public function add(array $data): mixed
{
return $this->transaction(function () use ($data) {
$this->applyNameTitleFromMult($data);
$this->normalizeDefaultField($data);
$deptId = AdminScopeHelper::resolveConfigDeptId(null, $data['dept_id'] ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
if ((int) ($data['is_default'] ?? 0) === 1) {
@@ -38,6 +39,7 @@ class DiceAnteConfigLogic extends DiceBaseLogic
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
return $this->transaction(function () use ($id, $data, $deptId, $adminInfo, $pickedDeptId) {
$this->applyNameTitleFromMult($data);
$this->normalizeDefaultField($data);
if ((int) ($data['is_default'] ?? 0) === 1) {
$this->clearOtherDefaults((int) $id, $deptId);
@@ -92,6 +94,21 @@ class DiceAnteConfigLogic extends DiceBaseLogic
$data['is_default'] = ((int) $data['is_default']) === 1 ? 1 : 0;
}
/** 名称、标题随底注倍率自动设为 xN */
private function applyNameTitleFromMult(array &$data): void
{
if (!array_key_exists('mult', $data)) {
return;
}
$mult = (int) $data['mult'];
if ($mult <= 0) {
return;
}
$label = 'x' . $mult;
$data['name'] = $label;
$data['title'] = $label;
}
private function clearOtherDefaults(?int $excludeId = null, int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
$query = $this->model->where('is_default', 1);

View File

@@ -6,13 +6,17 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\lottery_pool_config;
use app\api\cache\UserCache;
use app\api\service\LotteryService;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\player\DicePlayer;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use support\think\Cache;
use support\think\Db;
/**
* 色子奖池配置逻辑层
@@ -39,7 +43,7 @@ class DiceLotteryPoolConfigLogic extends DiceBaseLogic
{
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
return ConfigScopeEditHelper::updateByPkAndDept(
$result = ConfigScopeEditHelper::updateByPkAndDept(
$this->model,
$id,
$deptId,
@@ -48,6 +52,106 @@ class DiceLotteryPoolConfigLogic extends DiceBaseLogic
$adminInfo,
$pickedDeptId
);
if ($result) {
$pool = DiceLotteryPoolConfig::where('id', $id)->find();
if ($pool && $pool->isPlayerDefaultTemplate()) {
$this->syncPlayersBoundToPlayerDefaultPool($pool);
}
}
return $result;
}
/**
* 修改 playerDefault 后:同步同渠道所有绑定该池(及应跟随 playerDefault 的玩家T1T5 权重,并刷新 Redis
*
* @return int 已同步玩家数量
*/
public function syncPlayersBoundToPlayerDefaultPool(DiceLotteryPoolConfig $pool): int
{
$poolId = (int) ($pool->id ?? 0);
if ($poolId <= 0) {
return 0;
}
$fresh = DiceLotteryPoolConfig::where('id', $poolId)->find();
if (!$fresh || !$fresh->isPlayerDefaultTemplate()) {
return 0;
}
$pool = $fresh;
$weights = $this->extractPoolTierWeights($pool);
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($pool->dept_id ?? null);
$legacyDefaultPoolId = $this->findDefaultPoolIdForDept($poolDeptId);
$idsMap = [];
$boundPlayers = DicePlayer::where('lottery_config_id', $poolId)->select();
foreach ($boundPlayers as $player) {
$idsMap[(int) $player->id] = true;
}
if ($legacyDefaultPoolId > 0 && $legacyDefaultPoolId !== $poolId) {
$legacyPlayers = DicePlayer::where('lottery_config_id', $legacyDefaultPoolId)->select();
foreach ($legacyPlayers as $player) {
if (AdminScopeHelper::resolvePlayerConfigDeptId($player) === $poolDeptId) {
$idsMap[(int) $player->id] = true;
}
}
}
$unboundPlayers = DicePlayer::where(function ($q) {
$q->where('lottery_config_id', 0)->whereOr('lottery_config_id', null);
})->select();
foreach ($unboundPlayers as $player) {
if (AdminScopeHelper::resolvePlayerConfigDeptId($player) === $poolDeptId) {
$idsMap[(int) $player->id] = true;
}
}
if ($idsMap === []) {
return 0;
}
$ids = array_keys($idsMap);
$update = array_merge($weights, ['lottery_config_id' => $poolId]);
Db::table('dice_player')->whereIn('id', $ids)->update($update);
foreach ($ids as $playerId) {
LotteryService::patchPlayerWeightsCache($playerId, $weights);
LotteryService::invalidatePlayerLotteryCache($playerId);
UserCache::deleteUser($playerId);
}
return count($ids);
}
/**
* @return array{t1_weight:float,t2_weight:float,t3_weight:float,t4_weight:float,t5_weight:float}
*/
private function extractPoolTierWeights(DiceLotteryPoolConfig $pool): array
{
$data = $pool->getData();
return [
't1_weight' => (float) ($data['t1_weight'] ?? 0),
't2_weight' => (float) ($data['t2_weight'] ?? 0),
't3_weight' => (float) ($data['t3_weight'] ?? 0),
't4_weight' => (float) ($data['t4_weight'] ?? 0),
't5_weight' => (float) ($data['t5_weight'] ?? 0),
];
}
private function findDefaultPoolIdForDept(int $deptId): int
{
$query = DiceLotteryPoolConfig::where('name', 'default');
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query->where('dept_id', $deptId);
}
$row = $query->find();
return $row ? (int) $row->id : 0;
}
/**

View File

@@ -7,6 +7,7 @@
namespace app\dice\logic\player;
use app\dice\basic\DiceBaseLogic;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\player\DicePlayer;
@@ -48,9 +49,30 @@ class DicePlayerLogic extends DiceBaseLogic
} else {
unset($data['password']);
}
$data = $this->applyLotteryPoolWeightsToPlayerData($data);
return parent::edit($id, $data);
}
/**
* 已绑定彩金池时:玩家 T1T5 以池配置为准,避免前端提交陈旧权重覆盖同步结果
*/
private function applyLotteryPoolWeightsToPlayerData(array $data): array
{
$configId = isset($data['lottery_config_id']) ? (int) $data['lottery_config_id'] : 0;
if ($configId <= 0) {
return $data;
}
$config = DiceLotteryPoolConfig::find($configId);
if (!$config) {
return $data;
}
$row = $config->getData();
foreach (['t1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight'] as $field) {
$data[$field] = $row[$field] ?? 0;
}
return $data;
}
/**
* 密码加密md5(salt . password)
*/

View File

@@ -43,19 +43,21 @@ class DiceRewardLogic
$orderField = isset($where['orderField']) && $where['orderField'] !== '' ? (string) $where['orderField'] : 'r.tier';
$orderType = isset($where['orderType']) && strtoupper((string) $where['orderType']) === 'DESC' ? 'desc' : 'asc';
$keepIds = $this->resolveDedupedRewardIdsByGrid($direction, $tier, $adminInfo, $requestDeptId);
if ($keepIds === []) {
return [
'total' => 0,
'per_page' => $limit,
'current_page' => $page,
'data' => [],
];
}
$query = DiceReward::alias('r')
->where('r.direction', $direction)
->whereIn('r.id', $keepIds)
->field('r.id,r.tier,r.direction,r.end_index,r.weight,r.grid_number,r.start_index,r.ui_text,r.real_ev,r.remark,r.type,r.create_time,r.update_time')
->order($orderField, $orderType)
->order('r.end_index', 'asc');
if ($adminInfo !== null) {
AdminScopeHelper::applyConfigScope($query, $adminInfo, $requestDeptId, 'r.dept_id');
}
if ($tier !== '') {
$query->where('r.tier', $tier);
}
->order('r.grid_number', 'asc');
$paginator = $query->paginate($limit, false, ['page' => $page]);
$arr = $paginator->toArray();
@@ -81,6 +83,41 @@ class DiceRewardLogic
];
}
/**
* 列表去重:每个方向、每个色子点数(5-30)仅保留一条(取 id 最大),避免历史重复数据导致 104 条
* @return int[]
*/
private function resolveDedupedRewardIdsByGrid(
int $direction,
string $tier,
?array $adminInfo,
$requestDeptId
): array {
$dedupeQuery = DiceReward::alias('rd')
->field('MAX(rd.id) AS keep_id')
->where('rd.direction', $direction)
->whereBetween('rd.grid_number', [5, 30]);
if ($adminInfo !== null) {
AdminScopeHelper::applyConfigScope($dedupeQuery, $adminInfo, $requestDeptId, 'rd.dept_id');
}
if ($tier !== '') {
$dedupeQuery->where('rd.tier', $tier);
}
$rows = $dedupeQuery->group('rd.grid_number')->select()->toArray();
$ids = [];
foreach ($rows as $row) {
$id = isset($row['keep_id']) ? (int) $row['keep_id'] : 0;
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
/**
* 按单方向批量更新权重(仅更新当前方向的 weight并刷新缓存
* @param int $direction 0=顺时针 1=逆时针
@@ -327,6 +364,95 @@ class DiceRewardLogic
private const GRID_NUMBER_MIN = 5;
private const GRID_NUMBER_MAX = 30;
/**
* 预览:按当前 dice_reward_config 计算将要生成的 dice_reward不写库
* 若当前 dice_reward 与计算结果完全一致,则标记 unchanged=true并返回现有权重导入时将复用旧权重
*
* @return array{unchanged: bool, skipped: int, preview: array<string, array{0: array, 1: array}>}
* @throws ApiException
*/
public function createRewardReferencePreviewFromConfig(?int $deptId = null): array
{
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
$unchanged = $compare['unchanged'];
$specialGrids = [5, 10, 15, 20, 25, 30];
$previewRows = [];
foreach ($computed['rows'] as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
$gridNumber = isset($row['grid_number']) ? (int) $row['grid_number'] : 0;
$weight = $unchanged
? self::WEIGHT_MIN
: (in_array($gridNumber, $specialGrids, true) ? self::WEIGHT_MIN : 100);
$oldStart = null;
$oldEnd = null;
$oldTier = null;
$oldRemark = null;
$oldWeight = null;
if (isset($existing[$key])) {
$oldStart = isset($existing[$key]['start_index']) ? (int) $existing[$key]['start_index'] : null;
$oldEnd = isset($existing[$key]['end_index']) ? (int) $existing[$key]['end_index'] : null;
$oldTier = isset($existing[$key]['tier']) ? (string) $existing[$key]['tier'] : null;
$oldRemark = isset($existing[$key]['remark']) ? (string) $existing[$key]['remark'] : null;
$oldWeight = isset($existing[$key]['weight']) ? (int) $existing[$key]['weight'] : null;
}
// 映射未变化时:通常复用旧权重;但若旧权重为 1 且非特殊点数,则按新默认建议展示为 100方便管理员快速落配置
if ($unchanged && $oldWeight !== null) {
$oldWeight = (int) $oldWeight;
$weight = max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, $oldWeight));
if ($weight === self::WEIGHT_MIN && !in_array($gridNumber, $specialGrids, true)) {
$weight = 100;
}
}
$diffChanged = false;
$diffFields = [];
if ($oldStart === null || $oldEnd === null || $oldTier === null) {
$diffChanged = true;
$diffFields[] = 'new';
} else {
if ((int) $oldStart !== (int) ($row['start_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'start_index';
}
if ((int) $oldEnd !== (int) ($row['end_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'end_index';
}
if (trim((string) $oldTier) !== trim((string) ($row['tier'] ?? ''))) {
$diffChanged = true;
$diffFields[] = 'tier';
}
if (trim((string) $oldRemark) !== trim((string) ($row['remark'] ?? ''))) {
$diffChanged = true;
$diffFields[] = 'remark';
}
}
$previewRows[] = array_merge($row, [
'weight' => $weight,
'old_start_index' => $oldStart,
'old_end_index' => $oldEnd,
'old_tier' => $oldTier,
'old_remark' => $oldRemark,
'old_weight' => $oldWeight,
'diff_changed' => $diffChanged,
'diff_fields' => $diffFields,
]);
}
return [
'unchanged' => $unchanged,
'skipped' => $computed['skipped'],
'preview' => $this->groupReferenceRowsByTierWithDirection($previewRows),
];
}
/**
* 创建奖励对照:先清空 dice_reward 表,再按两种方向为点数 5-30 生成记录。
*
@@ -339,7 +465,7 @@ class DiceRewardLogic
* - 奖励档位tier = DiceRewardConfig::where('id', $end_index)->first()->tier
* - 显示uiui_text = DiceRewardConfig::where('id', $end_index)->first()->ui_text
* - 实际中奖real_ev = DiceRewardConfig::where('id', $end_index)->first()->real_ev
* - 备注remark = DiceRewardConfig::where('id', $end_index)->first()->remark
* - 备注remark = 按本条对照档位(推断后 T1-T5的默认备注T1 大奖、T2 小赚…),非落点格盘面 remark 字段
* - 类型type = DiceRewardConfig::where('id', $end_index)->first()->type-2=唯一惩罚,-1=抽水,0=回本,1=再来一次,2=小赚,3=大奖格)
* - weight 默认 1后续在权重编辑弹窗设置
*
@@ -352,10 +478,76 @@ class DiceRewardLogic
* @throws ApiException
*/
public function createRewardReferenceFromConfig(?int $deptId = null): array
{
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
if ($compare['unchanged']) {
return [
'created_clockwise' => 0,
'created_counterclockwise' => 0,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => true,
];
}
$table = (new DiceReward())->getTable();
if ($normalizedDeptId === null) {
Db::table($table)->whereNull('dept_id')->delete();
} else {
Db::table($table)->where('dept_id', $normalizedDeptId)->delete();
}
$createdCw = 0;
$createdCcw = 0;
foreach ($computed['rows'] as $row) {
$m = new DiceReward();
$m->tier = $row['tier'];
$m->direction = (int) $row['direction'];
$m->end_index = (int) $row['end_index'];
$m->weight = self::WEIGHT_MIN;
$m->grid_number = (int) $row['grid_number'];
$m->start_index = (int) $row['start_index'];
$m->ui_text = (string) ($row['ui_text'] ?? '');
$m->real_ev = $row['real_ev'] ?? null;
$m->remark = (string) ($row['remark'] ?? '');
$m->type = isset($row['type']) ? (int) $row['type'] : 0;
if ($normalizedDeptId !== null) {
$m->dept_id = $normalizedDeptId;
}
$m->save();
if ((int) $row['direction'] === DiceReward::DIRECTION_CLOCKWISE) {
$createdCw++;
} else {
$createdCcw++;
}
}
DiceReward::refreshCache($normalizedDeptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => false,
];
}
/**
* 读取奖励配置(按 id asc并把模板 dept 转为 null
* @return array<int, array<string, mixed>>
*/
private function loadConfigListForReference(?int &$deptId): array
{
$configQuery = DiceRewardConfig::order('id', 'asc');
if ($deptId === null || $deptId === \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
if ($deptId === null || $deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$configQuery->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', 'null');
});
@@ -367,25 +559,25 @@ class DiceRewardLogic
if (empty($list)) {
throw new ApiException('Reward config is empty, please maintain dice_reward_config first');
}
$configCount = count($list);
if ($configCount < self::BOARD_SIZE) {
if (count($list) < self::BOARD_SIZE) {
throw new ApiException(
\app\api\util\ApiLang::translateParams(
'奖励配置需覆盖 26 个格位id 0-25 或 1-26当前仅 %s 条,无法完整生成 5-30 共26个点数、顺时针与逆时针的奖励对照',
[$configCount]
[count($list)]
)
);
}
return $list;
}
$table = (new DiceReward())->getTable();
if ($deptId === null) {
Db::table($table)->whereNull('dept_id')->delete();
} else {
Db::table($table)->where('dept_id', $deptId)->delete();
}
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos],避免 config.id 非 0-25/1-26 时取模结果找不到
/**
* 计算 5-30 两个方向的对照行(不含权重)
* @param array<int, array<string, mixed>> $list
* @return array{rows: array<int, array<string, mixed>>, skipped: int}
*/
private function computeReferenceRowsFromConfigList(array $list, ?int $deptId): array
{
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos]
$gridToPosition = [];
foreach ($list as $pos => $row) {
$gn = isset($row['grid_number']) ? (int) $row['grid_number'] : 0;
@@ -394,12 +586,8 @@ class DiceRewardLogic
}
}
$createdCw = 0;
$createdCcw = 0;
$updatedCw = 0;
$updatedCcw = 0;
$rows = [];
$skipped = 0;
for ($gridNumber = self::GRID_NUMBER_MIN; $gridNumber <= self::GRID_NUMBER_MAX; $gridNumber++) {
if (!isset($gridToPosition[$gridNumber])) {
$skipped++;
@@ -414,115 +602,192 @@ class DiceRewardLogic
$configCw = $list[$endPosCw] ?? null;
$configCcw = $list[$endPosCcw] ?? null;
$endIdCw = $configCw !== null && isset($configCw['id']) ? (int) $configCw['id'] : 0;
$endIdCcw = $configCcw !== null && isset($configCcw['id']) ? (int) $configCcw['id'] : 0;
if ($configCw !== null) {
$tier = isset($configCw['tier']) ? trim((string) $configCw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCw = isset($configCw['weight']) && $configCw['weight'] !== null
? $configCw['weight']
: self::WEIGHT_MIN;
$payloadCw = [
'tier' => $tier,
'weight' => $weightCw,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCw,
'ui_text' => $configCw['ui_text'] ?? '',
'real_ev' => $configCw['real_ev'] ?? null,
'remark' => $configCw['remark'] ?? '',
'type' => isset($configCw['type']) ? (int) $configCw['type'] : 0,
];
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_CLOCKWISE)->where('grid_number', $gridNumber);
if ($deptId === null) {
$existingQuery->whereNull('dept_id');
} else {
$existingQuery->where('dept_id', $deptId);
}
$existing = $existingQuery->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCw);
$updatedCw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_CLOCKWISE;
$m->end_index = $endIdCw;
$m->weight = $weightCw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCw['ui_text'] ?? '';
$m->real_ev = $configCw['real_ev'] ?? null;
$m->remark = $configCw['remark'] ?? '';
$m->type = isset($configCw['type']) ? (int) $configCw['type'] : 0;
if ($deptId !== null) {
$m->dept_id = $deptId;
}
$m->save();
$createdCw++;
}
$rows[] = $this->buildReferenceRowFromLandingConfig(
$tier,
$configCw,
DiceReward::DIRECTION_CLOCKWISE,
$gridNumber,
$startId
);
}
}
if ($configCcw !== null) {
$tier = isset($configCcw['tier']) ? trim((string) $configCcw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCcw = isset($configCcw['weight']) && $configCcw['weight'] !== null
? $configCcw['weight']
: self::WEIGHT_MIN;
$payloadCcw = [
'tier' => $tier,
'weight' => $weightCcw,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCcw,
'ui_text' => $configCcw['ui_text'] ?? '',
'real_ev' => $configCcw['real_ev'] ?? null,
'remark' => $configCcw['remark'] ?? '',
'type' => isset($configCcw['type']) ? (int) $configCcw['type'] : 0,
];
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_COUNTERCLOCKWISE)->where('grid_number', $gridNumber);
if ($deptId === null) {
$existingQuery->whereNull('dept_id');
} else {
$existingQuery->where('dept_id', $deptId);
}
$existing = $existingQuery->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCcw);
$updatedCcw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_COUNTERCLOCKWISE;
$m->end_index = $endIdCcw;
$m->weight = $weightCcw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCcw['ui_text'] ?? '';
$m->real_ev = $configCcw['real_ev'] ?? null;
$m->remark = $configCcw['remark'] ?? '';
$m->type = isset($configCcw['type']) ? (int) $configCcw['type'] : 0;
if ($deptId !== null) {
$m->dept_id = $deptId;
}
$m->save();
$createdCcw++;
}
$rows[] = $this->buildReferenceRowFromLandingConfig(
$tier,
$configCcw,
DiceReward::DIRECTION_COUNTERCLOCKWISE,
$gridNumber,
$startId
);
}
}
}
return ['rows' => $rows, 'skipped' => $skipped];
}
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
/**
* 对照表落点行:档位按落点格 real_ev 推断;备注随**本条对照档位**T1 大奖 / T2 小赚 …),
* 与奖励配置页「按结算金额匹配档位备注」一致,不拷贝落点格盘面备注(盘面可保留「大奖格」等细分文案)。
*
* @param array<string, mixed> $landingConfig
* @return array<string, mixed>
*/
private function buildReferenceRowFromLandingConfig(
string $tier,
array $landingConfig,
int $direction,
int $gridNumber,
int $startId
): array {
$realEv = isset($landingConfig['real_ev']) ? (float) $landingConfig['real_ev'] : 0.0;
if ($tier !== 'BIGWIN') {
$inferred = $this->inferTierFromRealEv($realEv);
if ($inferred !== '') {
$tier = $inferred;
}
}
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => $updatedCw,
'updated_counterclockwise' => $updatedCcw,
'skipped' => $skipped,
'tier' => $tier,
'direction' => $direction,
'weight' => self::WEIGHT_MIN,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => isset($landingConfig['id']) ? (int) $landingConfig['id'] : 0,
'ui_text' => $landingConfig['ui_text'] ?? '',
'real_ev' => $landingConfig['real_ev'] ?? null,
'remark' => $this->defaultRemarkForTier($tier),
'type' => isset($landingConfig['type']) ? (int) $landingConfig['type'] : 0,
];
}
/**
* 按结算金额推断档位(与前端 generateIndexByRules 一致)
*/
private function inferTierFromRealEv(float $realEv): string
{
if ($realEv > 2) {
return 'T1';
}
if ($realEv > 1) {
return 'T2';
}
if ($realEv > 0) {
return 'T3';
}
if ($realEv < 0) {
return 'T4';
}
return 'T5';
}
/**
* 档位默认备注
*/
private function defaultRemarkForTier(string $tier): string
{
return match ($tier) {
'T1', 'BIGWIN' => '大奖',
'T2' => '小赚',
'T3' => '抽水',
'T4' => '惩罚',
'T5' => '再来一次',
default => '',
};
}
/**
* 读出当前 dice_reward用于对比/复用权重。key = "direction:grid_number"
* @return array<string, array<string, mixed>>
*/
private function loadExistingRewardRowsForReference(?int $deptId): array
{
$query = DiceReward::whereIn('grid_number', range(self::GRID_NUMBER_MIN, self::GRID_NUMBER_MAX))
->whereIn('direction', [DiceReward::DIRECTION_CLOCKWISE, DiceReward::DIRECTION_COUNTERCLOCKWISE]);
if ($deptId === null) {
$query->whereNull('dept_id');
} else {
$query->where('dept_id', $deptId);
}
$rows = $query->select()->toArray();
$map = [];
foreach ($rows as $r) {
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$gn = isset($r['grid_number']) ? (int) $r['grid_number'] : 0;
$map[$dir . ':' . $gn] = $r;
}
return $map;
}
/**
* 对比 computed 与 existing 是否完全一致(忽略权重)
* @param array<int, array<string, mixed>> $computedRows
* @param array<string, array<string, mixed>> $existingMap
* @return array{unchanged: bool}
*/
private function compareReferenceRows(array $computedRows, array $existingMap): array
{
if (empty($computedRows)) {
return ['unchanged' => false];
}
foreach ($computedRows as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
if (!isset($existingMap[$key])) {
return ['unchanged' => false];
}
$ex = $existingMap[$key];
$same =
((int) ($ex['start_index'] ?? 0) === (int) ($row['start_index'] ?? 0)) &&
((int) ($ex['end_index'] ?? 0) === (int) ($row['end_index'] ?? 0)) &&
(trim((string) ($ex['tier'] ?? '')) === trim((string) ($row['tier'] ?? ''))) &&
(trim((string) ($ex['remark'] ?? '')) === trim((string) ($row['remark'] ?? '')));
if (!$same) {
return ['unchanged' => false];
}
}
return ['unchanged' => true];
}
/**
* 将行按 tier -> {0:[],1:[]} 组织,便于前端展示(与 weightRatioList 输出结构一致)
* @param array<int, array<string, mixed>> $rows
* @return array<string, array{0: array, 1: array}>
*/
private function groupReferenceRowsByTierWithDirection(array $rows): array
{
$result = [];
foreach (self::TIER_KEYS as $tier) {
$result[$tier] = [0 => [], 1 => []];
}
foreach ($rows as $r) {
$tier = isset($r['tier']) ? trim((string) $r['tier']) : '';
if ($tier === '' || !isset($result[$tier])) {
continue;
}
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$dir = $dir === 1 ? 1 : 0;
$result[$tier][$dir][] = [
'reward_id' => 0,
'id' => isset($r['end_index']) ? (int) $r['end_index'] : 0,
'grid_number' => isset($r['grid_number']) ? (int) $r['grid_number'] : 0,
'ui_text' => (string) ($r['ui_text'] ?? ''),
'real_ev' => $r['real_ev'] ?? 0,
'remark' => (string) ($r['remark'] ?? ''),
'weight' => isset($r['weight']) ? max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, (int) $r['weight'])) : self::WEIGHT_MIN,
'start_index' => isset($r['start_index']) ? (int) $r['start_index'] : 0,
'tier' => (string) ($r['tier'] ?? ''),
'old_start_index' => $r['old_start_index'] ?? null,
'old_end_index' => $r['old_end_index'] ?? null,
'old_tier' => $r['old_tier'] ?? null,
'old_weight' => $r['old_weight'] ?? null,
'diff_changed' => (bool) ($r['diff_changed'] ?? false),
'diff_fields' => $r['diff_fields'] ?? [],
];
}
return $result;
}
}

View File

@@ -11,6 +11,7 @@ use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward\DiceRewardConfig;
use app\dice\model\play_record_test\DicePlayRecordTest;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
@@ -77,6 +78,51 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
return $map;
}
/**
* 删除权重测试记录,并级联删除 dice_play_record_test 中 reward_config_record_id 关联的明细
* @param mixed $ids
*/
public function destroy($ids): bool
{
return $this->transaction(function () use ($ids) {
$intIds = $this->normalizeDestroyIds($ids);
if ($intIds === []) {
return false;
}
$this->destroyRelatedPlayRecordTests($intIds);
return parent::destroy($intIds);
});
}
/**
* @param mixed $ids
* @return list<int>
*/
private function normalizeDestroyIds($ids): array
{
$idList = is_array($ids) ? $ids : explode(',', (string) $ids);
$intIds = [];
foreach ($idList as $v) {
$iv = (int) $v;
if ($iv > 0) {
$intIds[] = $iv;
}
}
return array_values(array_unique($intIds));
}
/**
* @param list<int> $recordIds dice_reward_config_record.id
*/
private function destroyRelatedPlayRecordTests(array $recordIds): void
{
DicePlayRecordTest::destroy(function ($query) use ($recordIds) {
$query->whereIn('reward_config_record_id', $recordIds);
});
}
/**
* 将测试记录导入DiceReward权重快照、DiceRewardConfigBIGWIN weight、DiceLotteryPoolConfig付费/免费 T1-T5
* @param int $recordId 测试记录 ID
@@ -268,7 +314,8 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
is_array($params) ? $params : []
);
$allowed = [100, 500, 1000, 5000];
$ante = $this->resolveWeightTestAnte($params, $deptId);
$anteRandom = !empty($params['ante_random']);
$ante = $anteRandom ? 0 : $this->resolveWeightTestAnte($params, $deptId);
$lotteryConfigId = isset($params['lottery_config_id']) ? (int) $params['lottery_config_id'] : 0;
$paidConfigId = isset($params['paid_lottery_config_id']) ? (int) $params['paid_lottery_config_id'] : 0;
@@ -283,7 +330,12 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
$paidN = isset($params['paid_n_count']) ? (int) $params['paid_n_count'] : 0;
$chainFreeMode = !empty($params['chain_free_mode']);
$killModeEnabled = !empty($params['kill_mode_enabled']);
$testSafetyLine = isset($params['test_safety_line']) ? (int) $params['test_safety_line'] : 5000;
if (array_key_exists('test_safety_line', $params) && $params['test_safety_line'] !== null && $params['test_safety_line'] !== '') {
$testSafetyLine = (int) $params['test_safety_line'];
} else {
$defaultPool = DiceLotteryPoolConfig::findByNameForDept('default', $deptId);
$testSafetyLine = (int) ($defaultPool->safety_line ?? 0);
}
if ($testSafetyLine < 0) {
throw new ApiException('test_safety_line must be greater than or equal to 0');
}
@@ -405,6 +457,9 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
if ($chainFreeMode) {
$tierWeightsSnapshot['chain_free_mode'] = true;
}
if (!empty($params['ante_random'])) {
$tierWeightsSnapshot['ante_random'] = true;
}
$record = new DiceRewardConfigRecord();
$plannedPaidSpins = $paidS + $paidN;
@@ -494,6 +549,21 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
*/
private function resolveWeightTestAnte(array $params, int $deptId): int
{
if (!empty($params['ante_random'])) {
$anteQuery = DiceAnteConfig::field('id,mult')->order('mult', 'asc');
ConfigScopeEditHelper::applyDeptIdWhere($anteQuery, $deptId);
$rows = $anteQuery->select()->toArray();
if ($rows === []) {
throw new ApiException('No ante config in current channel');
}
$picked = $rows[random_int(0, count($rows) - 1)];
$mult = (int) ($picked['mult'] ?? 0);
if ($mult <= 0) {
throw new ApiException('ANTE_MUST_POSITIVE');
}
return $mult;
}
$anteConfigId = isset($params['ante_config_id']) ? (int) $params['ante_config_id'] : 0;
if ($anteConfigId > 0) {
$config = DiceAnteConfig::find($anteConfigId);

View File

@@ -5,6 +5,8 @@ namespace app\dice\logic\reward_config_record;
use app\api\logic\PlayStartLogic;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\play_record_test\DicePlayRecordTest;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
@@ -15,7 +17,10 @@ use support\think\Db;
/**
* 一键测试权重:单进程后台执行模拟摇色子,写入 dice_play_record_test 并更新 dice_reward_config_record 进度
* 支持测试内杀分:当模拟玩家累计盈利达到安全线后,付费抽奖切换到 killScore
* 抽奖规则与 PlayStartLogic 一致:
* - 付费未杀分按模拟玩家档位权重抽档lottery_config_id 记 default
* - 付费杀分:测试内杀分开启且模拟池盈利 >= test_safety_line 后切 killScore
* - 免费券name=free 奖池(无则 default排除 5/30 豹子
*/
class WeightTestRunner
{
@@ -57,7 +62,6 @@ class WeightTestRunner
return;
}
$ante = is_numeric($record->ante ?? null) ? intval($record->ante) : 1;
$paidS = (int) ($record->paid_s_count ?? 0);
$paidN = (int) ($record->paid_n_count ?? 0);
$total = $paidS + $paidN;
@@ -68,11 +72,18 @@ class WeightTestRunner
$this->runDeptId = $this->resolveRunDeptId($recordId, $record);
$deptId = $this->runDeptId;
$anteRandom = $this->isAnteRandomMode($record);
$ante = is_numeric($record->ante ?? null) ? intval($record->ante) : 1;
if (!$anteRandom && $ante <= 0) {
$ante = $this->getMinAnteMult($deptId);
}
DiceReward::setRequestDeptId($deptId);
DiceRewardConfig::clearRequestInstance();
$configType0 = DiceLotteryPoolConfig::findByNameForDept('default', $deptId);
$configType1 = DiceLotteryPoolConfig::findByNameForDept('killScore', $deptId);
$configKill = DiceLotteryPoolConfig::findByNameForDept('killScore', $deptId);
$configFree = DiceLotteryPoolConfig::findByNameForDept('free', $deptId);
if (!$configType0) {
$this->markFailed($recordId, '彩金池配置 name=default 不存在(当前渠道)');
return;
@@ -92,9 +103,9 @@ class WeightTestRunner
if (!$paidPoolConfig || AdminScopeHelper::normalizeRecordDeptId($paidPoolConfig->dept_id ?? null) !== $deptId) {
$paidPoolConfig = $configType0;
}
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configType1;
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configFree;
if (!$freePoolConfig || AdminScopeHelper::normalizeRecordDeptId($freePoolConfig->dept_id ?? null) !== $deptId) {
$freePoolConfig = $configType1 ?: $configType0;
$freePoolConfig = $configFree ?: $configType0;
}
if ($paidTierWeightsCustom !== null && array_sum($paidTierWeightsCustom) <= 0) {
@@ -116,8 +127,11 @@ class WeightTestRunner
$testSafetyLine = 0;
}
// 测试内“玩家累计盈利”:用于控制付费局是否切换杀分
$playerProfitTotal = 0.0;
// 彩金池累计盈利:从 default.profit_amount 起步并在测试内逐局累加,与杀分判定值一致
$poolProfitTotal = (float) ($configType0->profit_amount ?? 0);
// 付费未杀分时的模拟玩家档位权重(自定义 > 快照 > 兜底奖池)
$paidPlayerWeights = $paidTierWeightsCustom ?? $this->resolveTierWeightsSnapshot($record, 'paid');
$playLogic = new PlayStartLogic();
$resultCounts = [];
@@ -133,14 +147,16 @@ class WeightTestRunner
$paidS,
$paidN,
$ante,
$anteRandom,
$configType0,
$paidPoolConfig,
$freePoolConfig,
$configType1,
$paidTierWeightsCustom,
$configKill,
$paidPlayerWeights,
$freeTierWeightsCustom,
$killModeEnabled,
$testSafetyLine,
$playerProfitTotal,
$poolProfitTotal,
$resultCounts,
$tierCounts,
$buffer,
@@ -172,14 +188,16 @@ class WeightTestRunner
int $paidS,
int $paidN,
int $ante,
bool $anteRandom,
$defaultPoolConfig,
$paidPoolConfig,
$freePoolConfig,
$killPoolConfig,
?array $paidTierWeightsCustom,
?array $paidPlayerWeights,
?array $freeTierWeightsCustom,
bool $killModeEnabled,
int $testSafetyLine,
float &$playerProfitTotal,
float &$poolProfitTotal,
array &$resultCounts,
array &$tierCounts,
array &$buffer,
@@ -187,27 +205,46 @@ class WeightTestRunner
): void {
$queue = [];
for ($i = 0; $i < $paidS; $i++) {
$queue[] = ['paid', 0, $ante];
$queue[] = ['paid', 0, $anteRandom ? 0 : $ante];
}
for ($i = 0; $i < $paidN; $i++) {
$queue[] = ['paid', 1, $ante];
$queue[] = ['paid', 1, $anteRandom ? 0 : $ante];
}
$qi = 0;
$lastPaidPlayAnte = 0;
while ($qi < count($queue)) {
$item = $queue[$qi];
$isPaid = $item[0] === 'paid';
$dir = $item[1];
$playAnte = $item[2];
$playAnte = (int) $item[2];
$playAnte = $this->resolvePlayAnteMult(
$deptId,
$playAnte,
$isPaid,
$anteRandom,
$ante,
$lastPaidPlayAnte
);
if ($isPaid) {
$lastPaidPlayAnte = $playAnte;
}
$lotteryType = $isPaid ? 0 : 1;
if ($isPaid) {
$useKillForPaid = $killModeEnabled && $playerProfitTotal >= $testSafetyLine && $killPoolConfig !== null;
$useKillForPaid = $killModeEnabled
&& $poolProfitTotal >= $testSafetyLine
&& $killPoolConfig !== null;
if ($useKillForPaid) {
$cfg = $killPoolConfig;
$customWeights = null;
} else {
$cfg = $paidPoolConfig;
$customWeights = $paidTierWeightsCustom;
// 付费未杀分模拟玩家档位权重lottery_config_id 记 default与 PlayStartLogic 一致)
$cfg = $defaultPoolConfig;
$customWeights = $paidPlayerWeights;
if ($customWeights === null) {
$cfg = $paidPoolConfig;
$customWeights = null;
}
}
} else {
$cfg = $freePoolConfig;
@@ -215,9 +252,12 @@ class WeightTestRunner
}
$row = $playLogic->simulateOnePlay($cfg, $dir, $lotteryType, $playAnte, $customWeights, $deptId);
// 明细底注必须为 dice_ante_config.mult随机模式每局独立抽取后的值
$row['ante'] = $playAnte;
$winCoin = (float) ($row['win_coin'] ?? 0);
$paidAmount = (float) ($row['paid_amount'] ?? 0);
$playerProfitTotal += $winCoin - $paidAmount;
$perPlayProfit = $isPaid ? ($winCoin - $paidAmount) : $winCoin;
$poolProfitTotal += round($perPlayProfit, 2);
$this->aggregate($row, $resultCounts, $tierCounts);
$buffer[] = $this->rowForInsert($row, $recordId, $deptId);
$done++;
@@ -232,6 +272,137 @@ class WeightTestRunner
}
}
/**
* 是否启用「底注随机」:每局付费抽奖从当前渠道底注配置中独立抽取 mult
*/
private function isAnteRandomMode(DiceRewardConfigRecord $record): bool
{
$snap = $this->normalizeTierWeightsSnapshot($record->tier_weights_snapshot ?? null);
if (is_array($snap) && !empty($snap['ante_random'])) {
return true;
}
// 创建随机测试时主表 ante 固定为 0快照丢失 ante_random 时仍按随机模式执行
$recordAnte = is_numeric($record->ante ?? null) ? (int) $record->ante : -1;
return $recordAnte === 0;
}
/**
* @param mixed $snap
* @return array<string, mixed>|null
*/
private function normalizeTierWeightsSnapshot($snap): ?array
{
if (is_array($snap)) {
return $snap;
}
if (is_string($snap) && $snap !== '') {
$decoded = json_decode($snap, true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
/**
* 解析本局有效底注dice_ante_config.mult禁止写入 0
*/
private function resolvePlayAnteMult(
int $deptId,
int $queuedAnte,
bool $isPaid,
bool $anteRandom,
int $recordAnte,
int $lastPaidPlayAnte
): int {
if ($isPaid) {
if ($anteRandom) {
return $this->pickRandomAnteMult($deptId);
}
if ($queuedAnte > 0) {
return $queuedAnte;
}
if ($recordAnte > 0) {
return $recordAnte;
}
return $this->getMinAnteMult($deptId);
}
// 免费券T5 链式):与触发付费局同底注,不得为 0
if ($queuedAnte > 0) {
return $queuedAnte;
}
if ($lastPaidPlayAnte > 0) {
return $lastPaidPlayAnte;
}
if ($recordAnte > 0) {
return $recordAnte;
}
return $this->getMinAnteMult($deptId);
}
/**
* 当前渠道底注配置中最小正数 mult
*/
private function getMinAnteMult(int $deptId): int
{
$mults = $this->listPositiveAnteMults($deptId);
return $mults !== [] ? $mults[0] : 1;
}
/**
* @return list<int>
*/
private function listPositiveAnteMults(int $deptId): array
{
$anteQuery = DiceAnteConfig::field('mult')->order('mult', 'asc');
ConfigScopeEditHelper::applyDeptIdWhere($anteQuery, $deptId);
$rows = $anteQuery->select()->toArray();
$mults = [];
foreach ($rows as $row) {
$mult = (int) ($row['mult'] ?? 0);
if ($mult > 0) {
$mults[] = $mult;
}
}
return $mults;
}
/**
* 从当前渠道 dice_ante_config 中随机取一条 mult仅 mult>0
*/
private function pickRandomAnteMult(int $deptId): int
{
$mults = $this->listPositiveAnteMults($deptId);
if ($mults === []) {
return 1;
}
return $mults[random_int(0, count($mults) - 1)];
}
/**
* 从 tier_weights_snapshot 读取付费/免费档位权重快照
*/
private function resolveTierWeightsSnapshot(DiceRewardConfigRecord $record, string $side): ?array
{
$snap = $this->normalizeTierWeightsSnapshot($record->tier_weights_snapshot ?? null);
if ($snap === null) {
return null;
}
$weights = $snap[$side] ?? null;
if (! is_array($weights) || $weights === []) {
return null;
}
return $weights;
}
/**
* 解析本次测试渠道:优先读库字段,避免 ORM 字段缓存未含 dept_id 时读不到
*/
@@ -316,6 +487,9 @@ class WeightTestRunner
$out[$k] = $row[$k];
}
}
if (array_key_exists('ante', $out) && (int) ($out['ante'] ?? 0) <= 0) {
$out['ante'] = $this->getMinAnteMult($bindDeptId > 0 ? $bindDeptId : $deptId);
}
return $out;
}

View File

@@ -17,7 +17,8 @@ use app\dice\model\DiceModel;
*
* @property $id ID
* @property $name 名称
* @property $remark 备注
* @property $remark 奖池名称(后台展示名)
* @property $config_note 配置备注
* @property $safety_line 安全线
* @property $kill_enabled 是否启用杀分0=关闭 1=开启
* @property $create_time 创建时间
@@ -31,6 +32,9 @@ use app\dice\model\DiceModel;
*/
class DiceLotteryPoolConfig extends DiceModel
{
/** 玩家默认彩金池(新玩家关联;付费未杀分时运行时读取该池 T1T5 权重) */
public const NAME_PLAYER_DEFAULT = 'playerDefault';
/**
* 数据表主键
* @var string
@@ -43,6 +47,9 @@ class DiceLotteryPoolConfig extends DiceModel
*/
protected $table = 'dice_lottery_pool_config';
/** 列表/关联 JSON 附带奖池展示名 */
protected $append = ['display_name'];
/**
* 按名称与渠道查找奖池配置(一键测试等场景,避免命中其他渠道同名配置)
*/
@@ -53,12 +60,68 @@ class DiceLotteryPoolConfig extends DiceModel
return $query->find();
}
/**
* 是否玩家默认模板池name=playerDefault
* 须用 getData()['name']:方法内 $this->name 会命中 ThinkORM 内部属性而非表字段,导致恒为 false
*/
public static function isPlayerDefaultPoolName($name): bool
{
return (string) $name === self::NAME_PLAYER_DEFAULT;
}
/**
* 是否玩家默认模板池(运行时按该池权重抽档,改池配置即对所有关联玩家生效)
*/
public function isPlayerDefaultTemplate(): bool
{
$data = $this->getData();
return self::isPlayerDefaultPoolName($data['name'] ?? '');
}
/**
* 后台展示用奖池名称:优先 remark否则 name
*
* @param array<string, mixed>|self $row
*/
public static function displayLabel($row): string
{
// 禁止对模型实例 toArray()append display_name 会再次触发本方法,导致内存耗尽
if ($row instanceof self) {
$data = $row->getData();
$remark = trim((string) ($data['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($data['name'] ?? ''));
}
$remark = trim((string) ($row['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($row['name'] ?? ''));
}
public function getDisplayNameAttr(): string
{
$data = $this->getData();
$remark = trim((string) ($data['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($data['name'] ?? ''));
}
/**
* 名称 搜索
*/
public function searchNameAttr($query, $value)
{
$query->where('name', 'like', '%'.$value.'%');
$like = '%' . $value . '%';
$query->where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->whereOr('remark', 'like', $like);
});
}
}

View File

@@ -31,6 +31,7 @@ use think\model\relation\BelongsTo;
* @property $use_coins 消耗平台币(兼容字段:付费局=paid_amount免费局=0
* @property $direction 方向:0=顺时针,1=逆时针
* @property $reward_tier 中奖档位T1,T2,T3,T4,T5,BIGWIN
* @property $remark 备注(如惩罚格余额不足)
* @property $lottery_id 奖池
* @property $start_index 起始索引
* @property $target_index 结束索引
@@ -87,13 +88,17 @@ class DicePlayRecord extends DiceModel
}
}
/** 按彩金池配置名称模糊diceLotteryPoolConfig.name */
/** 按彩金池奖池名称或内部标识模糊搜索 */
public function searchLotteryConfigNameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DiceLotteryPoolConfig::where('name', 'like', '%' . $value . '%')->column('id');
$like = '%' . $value . '%';
$ids = DiceLotteryPoolConfig::where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->whereOr('remark', 'like', $like);
})->column('id');
if (!empty($ids)) {
$query->whereIn('lottery_config_id', $ids);
} else {
@@ -281,4 +286,20 @@ class DicePlayRecord extends DiceModel
$query->where('roll_number', '<=', $value);
}
}
/** 创建时间起始 */
public function searchCreateTimeMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '>=', $value);
}
}
/** 创建时间结束 */
public function searchCreateTimeMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '<=', $value);
}
}
}

View File

@@ -73,6 +73,15 @@ class DicePlayRecordTest extends DiceModel
return $this->belongsTo(DiceRewardConfigRecord::class, 'reward_config_record_id', 'id');
}
/** 彩金池配置 id关联 dice_lottery_pool_config.id */
public function searchLotteryConfigIdAttr($query, $value): void
{
if ($value === '' || $value === null) {
return;
}
$query->where('lottery_config_id', '=', (int) $value);
}
/** 抽奖类型 0=付费 1=免费 */
public function searchLotteryTypeAttr($query, $value)
{

View File

@@ -6,6 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\model\player;
use app\dice\helper\AdminScopeHelper;
use app\dice\model\DiceModel;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
@@ -84,26 +85,61 @@ class DicePlayer extends DiceModel
if ($name === null || $name === '') {
$model->setAttr('name', $uid);
}
// 创建玩家时:未指定则自动保存 lottery_config_id 为 DiceLotteryPoolConfig name=default 的 id,没有则为 0
// 创建玩家时:未指定则关联 name=playerDefault玩家默认彩金池,没有则为 0
try {
$lotteryConfigId = $model->getAttr('lottery_config_id');
} catch (\Throwable $e) {
$lotteryConfigId = null;
}
if ($lotteryConfigId === null || $lotteryConfigId === '' || (int) $lotteryConfigId === 0) {
$config = DiceLotteryPoolConfig::where('name', 'default')->find();
$config = self::findPlayerDefaultLotteryConfigForPlayer($model);
$model->setAttr('lottery_config_id', $config ? (int) $config->id : 0);
}
// 彩金池权重默认取 name=default 的奖池配置
// 展示用权重从玩家关联的彩金池复制playerDefault 在抽奖时仍实时读池配置
self::setDefaultWeightsFromLotteryConfig($model);
}
/**
* 从 DiceLotteryPoolConfig name=default 取 t1_weightt5_weight 作为玩家未设置时的默认值
* 按玩家所属渠道查找玩家默认彩金池name=playerDefault
*/
protected static function findPlayerDefaultLotteryConfigForPlayer(DicePlayer $model): ?DiceLotteryPoolConfig
{
try {
$deptId = $model->getAttr('dept_id');
} catch (\Throwable $e) {
$deptId = null;
}
$normalizedDeptId = AdminScopeHelper::resolvePlayerConfigDeptId($model);
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
$normalizedDeptId = (int) $deptId;
}
$config = DiceLotteryPoolConfig::findByNameForDept(
DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT,
$normalizedDeptId
);
if ($config) {
return $config;
}
return DiceLotteryPoolConfig::findByNameForDept('default', $normalizedDeptId);
}
/**
* 从玩家关联彩金池(或 playerDefault / default取 t1_weightt5_weight 作为未设置时的默认值
*/
protected static function setDefaultWeightsFromLotteryConfig(DicePlayer $model): void
{
$config = DiceLotteryPoolConfig::where('name', 'default')->find();
$config = null;
try {
$lotteryConfigId = (int) $model->getAttr('lottery_config_id');
} catch (\Throwable $e) {
$lotteryConfigId = 0;
}
if ($lotteryConfigId > 0) {
$config = DiceLotteryPoolConfig::find($lotteryConfigId);
}
if (!$config) {
$config = self::findPlayerDefaultLotteryConfigForPlayer($model);
}
if (!$config) {
return;
}

View File

@@ -5,6 +5,7 @@ namespace app\dice\service;
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward\DiceRewardLogic;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\app\model\system\SystemDept;
@@ -54,6 +55,22 @@ class DiceChannelConfigService
'dice_reward_config_record' => ['label' => '权重测试记录', 'group' => 'records'],
];
/** 关联数据删除顺序:先删流水/记录,再删玩家,最后删配置 */
private const DELETE_TABLE_ORDER = [
'dice_play_record',
'dice_play_record_test',
'dice_player_wallet_record',
'dice_player_ticket_record',
'dice_reward_config_record',
'dice_player',
'dice_reward',
'dice_reward_config',
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_game',
];
/**
* 默认模板 dept_id 统一为 0并为固定 id 的配置表建立 (dept_id, id) 唯一约束
*/
@@ -193,9 +210,99 @@ class DiceChannelConfigService
}
$this->ensureRewardReferenceForDept($deptId);
DiceRewardConfig::refreshCache($deptId);
$this->ensurePlayerDefaultPoolForDept($deptId);
$this->migratePlayersDefaultPoolToPlayerDefault($deptId);
return $result;
}
/**
* 为渠道补齐玩家默认彩金池 name=playerDefault权重复制自 default
*/
public function ensurePlayerDefaultPoolForDept(int $deptId): ?int
{
if (!$this->tableHasColumn('dice_lottery_pool_config', 'dept_id')) {
return null;
}
$exists = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->count();
if ($exists > 0) {
return (int) Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->value('id');
}
$defaultRow = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', 'default')
->find();
if (!$defaultRow) {
return null;
}
$defaultRow = (array) $defaultRow;
unset($defaultRow['id'], $defaultRow['row_id'], $defaultRow['create_time'], $defaultRow['update_time'], $defaultRow['delete_time']);
$defaultRow['name'] = DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT;
$defaultRow['remark'] = '默认';
$defaultRow['safety_line'] = 0;
$defaultRow['kill_enabled'] = 0;
$defaultRow['profit_amount'] = 0;
$defaultRow['dept_id'] = $deptId;
return (int) Db::table('dice_lottery_pool_config')->insertGetId($defaultRow);
}
/**
* 将仍关联 name=default 的玩家改为关联 playerDefault杀分逻辑仍用 default 池)
*/
public function migratePlayersDefaultPoolToPlayerDefault(int $deptId): int
{
if (!$this->tableHasColumn('dice_player', 'dept_id')
|| !$this->tableHasColumn('dice_player', 'lottery_config_id')) {
return 0;
}
$playerDefaultId = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->value('id');
if (!$playerDefaultId) {
return 0;
}
$defaultPoolId = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', 'default')
->value('id');
if (!$defaultPoolId) {
return 0;
}
return Db::table('dice_player')
->where('dept_id', $deptId)
->where('lottery_config_id', (int) $defaultPoolId)
->update(['lottery_config_id' => (int) $playerDefaultId]);
}
/**
* 为全部渠道与默认模板补齐 playerDefault 奖池并迁移玩家关联
*/
public function ensurePlayerDefaultPoolsAllChannels(): array
{
$deptIds = [AdminScopeHelper::DEFAULT_TEMPLATE_DEPT];
foreach (SystemDept::column('id') as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
$deptIds = array_values(array_unique($deptIds));
$summary = [];
foreach ($deptIds as $deptId) {
$summary[$deptId] = [
'pool_id' => $this->ensurePlayerDefaultPoolForDept($deptId),
'players_migrated' => $this->migratePlayersDefaultPoolToPlayerDefault($deptId),
];
}
return $summary;
}
/**
* 按业务 id 从默认模板补齐配置dice_config / dice_reward_config
*/
@@ -281,6 +388,7 @@ class DiceChannelConfigService
}
$summary[$deptId] = $this->copyDefaultConfigToDept($deptId);
}
$summary['_player_default_pools'] = $this->ensurePlayerDefaultPoolsAllChannels();
return $summary;
}
@@ -417,21 +525,54 @@ class DiceChannelConfigService
if ($userCount > 0) {
throw new ApiException('This channel has users, please delete or transfer them first');
}
$allowed = array_keys(self::RELATION_TABLES);
foreach ($deleteTables as $table) {
if (!in_array($table, $allowed, true)) {
continue;
$tablesToDelete = $this->sortTablesForDelete($deleteTables);
Db::startTrans();
try {
foreach ($tablesToDelete as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
Db::table($table)->where('dept_id', $deptId)->delete();
}
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
Db::table($table)->where('dept_id', $deptId)->delete();
Db::name('sa_system_role_dept')->where('dept_id', $deptId)->delete();
SystemDept::destroy($deptId, true);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw new ApiException('Channel delete failed: ' . $e->getMessage());
}
SystemDept::destroy($deptId, true);
DiceRewardConfig::refreshCache($deptId);
DiceReward::refreshCache($deptId);
}
/**
* 按依赖顺序排列待删表(勾选顺序无关)
*
* @param array<int, string> $deleteTables
* @return array<int, string>
*/
private function sortTablesForDelete(array $deleteTables): array
{
$allowed = array_keys(self::RELATION_TABLES);
$picked = [];
foreach ($deleteTables as $table) {
if (is_string($table) && in_array($table, $allowed, true)) {
$picked[$table] = true;
}
}
$ordered = [];
foreach (self::DELETE_TABLE_ORDER as $table) {
if (isset($picked[$table])) {
$ordered[] = $table;
unset($picked[$table]);
}
}
foreach (array_keys($picked) as $table) {
$ordered[] = $table;
}
return $ordered;
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -477,7 +618,9 @@ class DiceChannelConfigService
if ($deptId === null || $deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$query->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', 'null');
$q->where('dept_id', $templateId)->whereOr(function ($sub) {
$sub->whereNull('dept_id');
});
});
} else {
$query->where('dept_id', $deptId);

View File

@@ -18,6 +18,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
*/
protected $rule = [
'name' => 'require',
'remark' => 'max:200',
'config_note' => 'max:500',
't1_weight' => 'require',
't2_weight' => 'require',
't3_weight' => 'require',
@@ -43,6 +45,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
protected $scene = [
'save' => [
'name',
'remark',
'config_note',
't1_weight',
't2_weight',
't3_weight',
@@ -51,6 +55,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
],
'update' => [
'name',
'remark',
'config_note',
't1_weight',
't2_weight',
't3_weight',

View File

@@ -11,6 +11,8 @@ return [
'session_username_prefix' => env('API_SESSION_USERNAME_PREFIX', 'api:user:session:'),
// 登录会话过期时间(秒),默认 7 天
'session_expire' => (int) env('API_SESSION_EXPIRE', 604800),
// 平台对接请求头 api-key/api/v1/* 必填,与客户端请求头 api-key 一致)
'platform_api_key' => env('API_KEY', ''),
// auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填)
'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''),
// auth-token 时间戳允许误差(秒),防重放,默认 300 秒

View File

@@ -14,6 +14,7 @@
use Webman\Route;
use app\api\middleware\ApiAccessLogMiddleware;
use app\api\middleware\ApiKeyMiddleware;
use app\api\middleware\AuthTokenMiddleware;
use app\api\middleware\TokenMiddleware;
@@ -22,9 +23,10 @@ Route::group('/api/v1', function () {
Route::any('/authToken', [app\api\controller\v1\AuthTokenController::class, 'index']);
})->middleware([
ApiAccessLogMiddleware::class,
ApiKeyMiddleware::class,
]);
// 平台 v1 接口:需在请求头携带 auth-token
// 平台 v1 接口:需在请求头携带 api-key、auth-token
Route::group('/api/v1', function () {
Route::any('/getGameList', [app\api\controller\v1\GameController::class, 'getGameList']);
Route::any('/getGameHall', [app\api\controller\v1\GameController::class, 'getGameHall']);
@@ -36,6 +38,7 @@ Route::group('/api/v1', function () {
Route::any('/setPlayerWallet', [app\api\controller\v1\GameController::class, 'setPlayerWallet']);
})->middleware([
ApiAccessLogMiddleware::class,
ApiKeyMiddleware::class,
AuthTokenMiddleware::class,
]);

View File

@@ -0,0 +1,84 @@
<?php
/**
* 审计各渠道游戏配置是否已从默认模板实例化
* 用法php server/db/audit_channel_config.php [--fix]
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use app\dice\helper\AdminScopeHelper;
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
$fix = in_array('--fix', $argv ?? [], true);
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_game',
'dice_reward',
];
$templateCounts = [];
foreach ($tables as $table) {
$templateCounts[$table] = (int) Db::table($table)
->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', null);
})
->count();
}
$depts = SystemDept::where('id', '>', 0)->column('id');
echo "========== 渠道配置实例化审计 ==========\n";
echo "默认模板 dept_id={$templateId} 行数:\n";
foreach ($templateCounts as $table => $cnt) {
echo " {$table}: {$cnt}\n";
}
echo "\n";
$missing = [];
foreach ($depts as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
$issues = [];
foreach ($tables as $table) {
$expected = $templateCounts[$table];
if ($expected <= 0) {
continue;
}
$actual = (int) Db::table($table)->where('dept_id', $deptId)->count();
if ($actual < $expected) {
$issues[] = "{$table}: {$actual}/{$expected}";
}
}
if ($issues !== []) {
$missing[$deptId] = $issues;
echo "渠道 {$deptId} 不完整 → " . implode(', ', $issues) . "\n";
} else {
echo "渠道 {$deptId} OK\n";
}
}
if ($missing === []) {
echo "\n全部渠道配置已实例化。\n";
exit(0);
}
if (!$fix) {
echo "\n存在缺失。执行 php server/db/audit_channel_config.php --fix 可自动补齐。\n";
exit(1);
}
echo "\n开始补齐...\n";
$service = new DiceChannelConfigService();
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied_tables'] ?? []);
echo "渠道 {$deptId}: 新增表 [{$copied}] 补齐行 " . ($info['merged_rows'] ?? 0) . "\n";
}
echo "补齐完成,请重新运行审计确认。\n";

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
$depts = Db::table('sa_system_dept')->column('id');
array_unshift($depts, 0);
foreach ($depts as $d) {
$c = Db::table('dice_reward_config')->where('dept_id', $d)->count();
$r = Db::table('dice_reward')->where('dept_id', $d)->count();
$nonBig = Db::table('dice_reward_config')->where('dept_id', $d)->where('tier', '<>', 'BIGWIN')->count();
echo "dept {$d}: reward_config={$c}, non_bigwin={$nonBig}, reward={$r}\n";
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
foreach (['dice_config', 'dice_ante_config', 'dice_game', 'dice_lottery_pool_config'] as $table) {
echo "{$table} dept1123: " . Db::table($table)->where('dept_id', 1123)->count() . "\n";
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* 查询指定表/视图的类型BASE TABLE / VIEW
*
* 用法(在 server 目录执行):
* php db/debug_check_table_type.php bet_order_view
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$table = $argv[1] ?? '';
$table = trim((string) $table);
if ($table === '') {
fwrite(STDERR, "Missing table name.\n");
exit(1);
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
"SELECT TABLE_NAME, TABLE_TYPE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name"
);
$stmt->execute(['name' => $table]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo "NOT_FOUND\t{$table}\n";
exit(0);
}
echo $row['TABLE_NAME'] . "\t" . $row['TABLE_TYPE'] . "\n";

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* 列出当前数据库中所有 *__view_backup 对象,用于排查宝塔备份报错。
*
* 用法(在 server 目录执行):
* php db/debug_list_view_backup_objects.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$sql = "SELECT TABLE_NAME, TABLE_TYPE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE '%\\_\\_view\\_backup'
ORDER BY TABLE_NAME";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
echo "No *__view_backup objects found.\n";
exit(0);
}
foreach ($rows as $row) {
echo $row['TABLE_NAME'] . "\t" . $row['TABLE_TYPE'] . "\n";
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* 列出当前数据库中的 VIEW 及其 DEFINER用于排查宝塔备份“缺少表 xxx__view_backup”。
*
* 用法(在 server 目录执行):
* php db/debug_list_views_and_definers.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$sql = "SELECT TABLE_NAME, DEFINER, SECURITY_TYPE
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
echo "No views found.\n";
exit(0);
}
foreach ($rows as $row) {
echo $row['TABLE_NAME'] . "\t" . ($row['DEFINER'] ?? '') . "\t" . ($row['SECURITY_TYPE'] ?? '') . "\n";
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
use support\think\Db;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 0);
$_GET['limit'] = 200;
$_REQUEST['limit'] = 200;
$result = $logic->getList($query);
echo "limit=200 data count: " . count($result['data'] ?? []) . " total=" . ($result['total'] ?? 0) . "\n";
$bw = 0;
foreach ($result['data'] ?? [] as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bw++;
echo "BIGWIN id={$row['id']} grid={$row['grid_number']}\n";
}
}
$query3 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query3, $adminInfo, 0);
unset($_GET['limit'], $_REQUEST['limit']);
$result3 = $logic->getList($query3);
echo "default limit data count: " . count($result3['data'] ?? []) . "\n";
$bw3 = 0;
foreach ($result3['data'] ?? [] as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bw3++;
}
}
echo "default limit BIGWIN: {$bw3}\n";
$_GET['saiType'] = 'all';
$_REQUEST['saiType'] = 'all';
$resultAll = $logic->getList($query);
echo "saiType=all count: " . (is_array($resultAll) ? count($resultAll) : 0) . "\n";
if (is_array($resultAll)) {
$bwAll = 0;
foreach ($resultAll as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bwAll++;
}
}
echo "saiType=all BIGWIN: {$bwAll}\n";
}
echo "\nAll rows by id for dept 1123:\n";
$allRows = \support\think\Db::table('dice_reward_config')->where('dept_id', 1123)->order('id', 'asc')->select();
foreach ($allRows as $r) {
echo "id={$r['id']} tier={$r['tier']} grid={$r['grid_number']}\n";
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
// paginated default
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 0);
$page = $logic->getList($query);
echo "default paginate count=" . count($page['data'] ?? []) . " total=" . ($page['total'] ?? 0) . "\n";
// saiType all
$_GET['saiType'] = 'all';
$_REQUEST['saiType'] = 'all';
$query2 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query2, $adminInfo, 0);
$all = $logic->getList($query2);
echo "saiType=all is_array=" . (is_array($all) ? 'yes' : 'no') . " count=" . (is_array($all) ? count($all) : 0) . "\n";
$nonBigwin = 0;
if (is_array($all)) {
foreach ($all as $row) {
if (($row['tier'] ?? '') !== 'BIGWIN') {
$nonBigwin++;
}
}
}
echo "non-BIGWIN rows for index tab: {$nonBigwin}\n";
// simulate dept_id=1123 explicit
unset($_GET['saiType'], $_REQUEST['saiType']);
$_GET['saiType'] = 'all';
$query3 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query3, $adminInfo, 1123);
$all3 = $logic->getList($query3);
echo "dept_id=1123 saiType=all count=" . (is_array($all3) ? count($all3) : 0) . "\n";

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\app\cache\UserInfoCache;
use support\think\Db;
$adminInfo = UserInfoCache::getUserInfo(123);
echo "Raw DB count dept 1123: " . Db::table('dice_reward_config')->where('dept_id', 1123)->whereNull('delete_time')->count() . "\n";
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 1123);
$sql = $query->fetchSql(true)->select();
echo "SQL: {$sql}\n";
$rows = $query->fetchSql(false)->select()->toArray();
echo "Model select count: " . count($rows) . "\n";
$model = new DiceRewardConfig();
$q2 = $model->where('dept_id', 1123)->order('id', 'asc');
$rows2 = $q2->select()->toArray();
echo "Direct model dept 1123: " . count($rows2) . "\n";
$tierCounts = Db::table('dice_reward_config')->where('dept_id', 1123)->group('tier')->column('count(*)', 'tier');
echo "Tier counts: " . json_encode($tierCounts, JSON_UNESCAPED_UNICODE) . "\n";
$idCounts = Db::query('SELECT id, COUNT(*) as c FROM dice_reward_config WHERE dept_id=1123 GROUP BY id HAVING c>1');
echo "Duplicate business ids in dept 1123: " . count($idCounts) . "\n";
if ($idCounts) {
print_r($idCounts);
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\app\cache\UserInfoCache;
use plugin\saiadmin\app\logic\system\SystemUserLogic;
use support\think\Db;
$uid = 123;
$user = Db::table('sa_system_user')->where('id', $uid)->find();
echo "DB user: " . json_encode($user, JSON_UNESCAPED_UNICODE) . "\n";
$logic = new SystemUserLogic();
$info = $logic->getUser($uid);
echo "getUser deptList: " . json_encode($info['deptList'] ?? null, JSON_UNESCAPED_UNICODE) . "\n";
echo "getUser dept_id: " . ($info['dept_id'] ?? 'null') . "\n";
$cached = UserInfoCache::getUserInfo($uid);
echo "cache deptList: " . json_encode($cached['deptList'] ?? null, JSON_UNESCAPED_UNICODE) . "\n";
echo "getDeptId: " . var_export(AdminScopeHelper::getDeptId($cached), true) . "\n";
echo "resolveConfigDeptId(null): " . AdminScopeHelper::resolveConfigDeptId($cached, null) . "\n";
echo "resolveConfigDeptId(0): " . AdminScopeHelper::resolveConfigDeptId($cached, 0) . "\n";
$deptId = 1123;
$all = Db::table('dice_reward_config')->where('dept_id', $deptId)->count();
$bigwin = Db::table('dice_reward_config')->where('dept_id', $deptId)->where('tier', 'BIGWIN')->count();
$reward = Db::table('dice_reward')->where('dept_id', $deptId)->count();
echo "dept {$deptId}: reward_config={$all}, BIGWIN={$bigwin}, dice_reward={$reward}\n";
$sample = Db::table('dice_reward_config')->where('dept_id', $deptId)->limit(3)->select();
echo "sample reward_config: " . json_encode($sample, JSON_UNESCAPED_UNICODE) . "\n";

View File

@@ -0,0 +1,26 @@
-- 渠道扁平化:将子渠道用户归并到顶级渠道,删除子渠道,更新表注释
-- 执行前请备份数据库
-- 1. 表及字段注释改为「渠道」
ALTER TABLE `sa_system_dept` COMMENT = '渠道表';
ALTER TABLE `sa_system_dept`
MODIFY COLUMN `parent_id` bigint(20) UNSIGNED NULL DEFAULT 0 COMMENT '父级ID扁平渠道固定为0',
MODIFY COLUMN `name` varchar(64) NOT NULL COMMENT '渠道名称',
MODIFY COLUMN `code` varchar(64) NULL DEFAULT NULL COMMENT '渠道编码',
MODIFY COLUMN `leader_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '渠道负责人ID';
-- 2. 菜单名称(按实际 id 调整id=5 为渠道管理菜单)
UPDATE `sa_system_menu` SET `name` = '渠道管理' WHERE `id` = 5 OR `name` LIKE '%渠道(部门)%' OR `name` = '部门管理';
-- 3. 将子渠道下的用户 dept_id 提升到顶级渠道(需配合 run_dept_flatten_channels.php 处理多级)
-- 以下为单级子渠道快速迁移parent_id != 0 的直接挂到父级)
UPDATE `sa_system_user` u
INNER JOIN `sa_system_dept` d ON u.dept_id = d.id AND d.parent_id > 0
INNER JOIN `sa_system_dept` p ON d.parent_id = p.id
SET u.dept_id = p.id;
-- 4. 删除所有子渠道parent_id > 0
DELETE FROM `sa_system_dept` WHERE `parent_id` > 0;
-- 5. 剩余渠道统一为顶级
UPDATE `sa_system_dept` SET `parent_id` = 0, `level` = '0';

View File

@@ -0,0 +1,33 @@
-- 抽奖流程图两个顶级外链菜单type=4点击新窗口打开 public/docs/flowcharts/*.html
-- 挂载位置与「后台操作指南」同级parent_id=0紧挨其下方sort 略小,列表按 sort 降序)
SET @now = NOW();
-- 移除旧的「抽奖流程说明」内嵌页菜单(若已安装)
SET @old_flow_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `path` = 'flowcharts' AND `component` = '/plugin/dice/flowcharts/index/index' AND `type` = 2
ORDER BY `id` ASC LIMIT 1
);
DELETE FROM `sa_system_role_menu` WHERE `menu_id` = @old_flow_menu_id;
DELETE FROM `sa_system_menu` WHERE `parent_id` = @old_flow_menu_id AND `type` = 3;
DELETE FROM `sa_system_menu` WHERE `id` = @old_flow_menu_id;
-- 1) 为何最终抽到该奖励
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`link_url`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '为何最终抽到该奖励', 'DiceFlowWhyReward', NULL, 4, 'dice_flow_why_reward', '', NULL, 'ri:question-answer-line', 4, '/docs/flowcharts/dice-为何抽到该奖励.html', 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu`
WHERE `type` = 4 AND `link_url` = '/docs/flowcharts/dice-为何抽到该奖励.html'
);
-- 2) 后台如何配置中奖逻辑
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`link_url`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '后台如何配置中奖逻辑', 'DiceFlowAdminConfig', NULL, 4, 'dice_flow_admin_config', '', NULL, 'ri:settings-3-line', 3, '/docs/flowcharts/dice-后台中奖逻辑配置.html', 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu`
WHERE `type` = 4 AND `link_url` = '/docs/flowcharts/dice-后台中奖逻辑配置.html'
);

View File

@@ -0,0 +1,3 @@
-- dice_play_record 新增备注T4 惩罚余额不足等场景)
ALTER TABLE `dice_play_record`
ADD COLUMN `remark` varchar(255) DEFAULT NULL COMMENT '备注(如惩罚格余额不足)' AFTER `reward_tier`;

View File

@@ -0,0 +1,10 @@
-- dice_player同一渠道内用户名唯一dept_id + username— 数据库根本约束
-- 执行前请备份;若存在重复数据需先清理后再执行
-- 推荐php db/run_dice_player_dept_username_unique.php
-- 移除仅按 username 的普通索引(若不存在可忽略报错)
-- ALTER TABLE `dice_player` DROP INDEX `idx_dice_player_username`;
-- 同一渠道下用户名唯一UNIQUE 为数据库层最终约束)
ALTER TABLE `dice_player`
ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`);

View File

@@ -0,0 +1,2 @@
-- 已废弃:请使用 dice_player_dept_username_unique.sql同一渠道 dept_id + username 唯一)
-- 历史脚本保留说明,勿再单独执行本文件

View File

@@ -0,0 +1,23 @@
-- 奖励配置档位结算推荐配置T1-T5 推荐金额、按规则生成)按钮权限
-- 挂载在「奖励配置」菜单type=2slug 与 DiceRewardConfigController Permission 一致
SET @now = NOW();
SET @reward_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `type` = 2
AND (
`path` = 'reward_config'
OR `component` LIKE '%reward_config%'
)
ORDER BY `id` ASC
LIMIT 1
);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @reward_menu_id, '档位结算推荐配置', '', 'dice:reward_config:index:tierRecommend', 3, '', '', '', 95, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE @reward_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'dice:reward_config:index:tierRecommend' AND `type` = 3
);

View File

@@ -0,0 +1,92 @@
-- 大富翁游戏相关表增加 dept_id关联 sa_system_dept渠道表
ALTER TABLE `dice_ante_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_game`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_poll_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_pool_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_play_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_play_record_test`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_player`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
-- 同一渠道内用户名唯一(根本约束,新库初始化时执行;已有库请用 dice_player_dept_username_unique.sql
ALTER TABLE `dice_player`
ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`);
ALTER TABLE `dice_player_ticket_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_player_wallet_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward_config_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
-- 从管理员归属回填玩家 dept_id
UPDATE `dice_player` p
INNER JOIN `sa_system_user` u ON p.admin_id = u.id
SET p.dept_id = u.dept_id
WHERE p.dept_id IS NULL AND u.dept_id IS NOT NULL AND u.dept_id > 0;
UPDATE `dice_play_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_play_record_test` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_player_ticket_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_player_wallet_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_reward_config_record` r
INNER JOIN `sa_system_user` u ON r.admin_id = u.id
SET r.dept_id = u.dept_id
WHERE r.dept_id IS NULL AND u.dept_id IS NOT NULL AND u.dept_id > 0;

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* 解决宝塔面板备份偶发报错:
* “备份文件中缺少表: xxx__view_backup”
*
* 原因通常是:数据库存在 VIEW但宝塔的备份校验按“表”去匹配
* 或其内部会期望存在 xxx__view_backup 之类的“视图备份表”标记。
*
* 本脚本会:
* - 扫描当前库所有 VIEW
* - 为每个 VIEW 创建一个同名的备份表:<view_name>__view_backup
* - 在该表写入 SHOW CREATE VIEW 的结果(若权限不足则写入空串)
*
* 用法(在 server 目录执行):
* php db/fix_bt_backup_view_tables.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
if ($dbName === '' || $user === '') {
fwrite(STDERR, "Missing DB_NAME/DB_USER in .env\n");
exit(1);
}
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$views = $pdo
->query("SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME")
->fetchAll(PDO::FETCH_COLUMN);
if (!$views) {
echo "No views found; nothing to do.\n";
exit(0);
}
$created = 0;
$updated = 0;
$failed = 0;
foreach ($views as $viewName) {
$viewName = (string) $viewName;
$backupTable = $viewName . '__view_backup';
if (!preg_match('/^[a-zA-Z0-9_]+$/', $backupTable)) {
$failed++;
echo "[skip] invalid name: {$backupTable}\n";
continue;
}
try {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `{$backupTable}` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`view_name` VARCHAR(255) NOT NULL,
`create_sql` LONGTEXT NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_view_name` (`view_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$created++;
} catch (Throwable $e) {
$failed++;
echo "[fail] create table {$backupTable}: " . $e->getMessage() . "\n";
continue;
}
$createSql = '';
try {
$stmt = $pdo->query("SHOW CREATE VIEW `{$viewName}`");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : false;
if ($row) {
// SHOW CREATE VIEW 返回字段名可能是 "Create View" 或类似
foreach ($row as $k => $v) {
if (is_string($k) && stripos($k, 'create') !== false && is_string($v)) {
$createSql = $v;
break;
}
}
}
} catch (Throwable $e) {
// 权限不足也不阻断,只是留空,保证备份表存在
$createSql = '';
}
try {
$stmt = $pdo->prepare(
"INSERT INTO `{$backupTable}` (`view_name`, `create_sql`, `updated_at`)
VALUES (:view_name, :create_sql, :updated_at)
ON DUPLICATE KEY UPDATE
`create_sql` = VALUES(`create_sql`),
`updated_at` = VALUES(`updated_at`)"
);
$stmt->execute([
'view_name' => $viewName,
'create_sql' => $createSql,
'updated_at' => date('Y-m-d H:i:s'),
]);
$updated++;
echo "[ok] {$viewName} -> {$backupTable}\n";
} catch (Throwable $e) {
$failed++;
echo "[fail] upsert {$backupTable}: " . $e->getMessage() . "\n";
}
}
echo "Done. created={$created}, updated={$updated}, failed={$failed}\n";

View File

@@ -0,0 +1,93 @@
<?php
/**
* 排查 dice_player 删除报错:
* - 表结构
* - 外键引用情况
* - 实际 destroy 流程(不真的删,仅 dry-run 抓异常)
*
* 用法php server/db/inspect_player_destroy.php [<id1>,<id2>,...]
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use support\think\Db;
$config = config('database');
$default = $config['default'];
$conn = $config['connections'][$default];
$dbName = $conn['database'] ?? '';
echo "[DB] {$default} -> {$dbName}\n\n";
echo "--- dice_player columns ---\n";
$cols = Db::query("SHOW FULL COLUMNS FROM `dice_player`");
foreach ($cols as $c) {
echo str_pad((string)$c['Field'], 26) . ' | ' . str_pad((string)$c['Type'], 24) . ' | NULL=' . $c['Null'] . ' | Key=' . $c['Key'] . "\n";
}
echo "\n";
echo "--- referenced by foreign keys (other tables -> dice_player) ---\n";
$fks = Db::query("SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME = 'dice_player'", [$dbName]);
if (empty($fks)) {
echo " (none)\n";
} else {
foreach ($fks as $f) {
echo " {$f['TABLE_NAME']}.{$f['COLUMN_NAME']} -> {$f['REFERENCED_TABLE_NAME']}.{$f['REFERENCED_COLUMN_NAME']} [{$f['CONSTRAINT_NAME']}]\n";
}
}
echo "\n";
echo "--- top 3 dice_player rows ---\n";
$rows = Db::table('dice_player')->limit(3)->select()->toArray();
foreach ($rows as $r) {
echo "id={$r['id']} dept_id={$r['dept_id']} username={$r['username']} delete_time=" . ($r['delete_time'] ?? 'null') . "\n";
}
echo "\n";
$idsArg = $argv[1] ?? '';
if ($idsArg === '') {
echo "(no ids passed, skip dry-run delete)\n";
return;
}
$ids = array_filter(array_map('intval', explode(',', $idsArg)));
if (empty($ids)) {
echo "(invalid ids)\n";
return;
}
echo "--- dry-run destroy ids: " . implode(',', $ids) . " ---\n";
$beforeAll = Db::query('SELECT id, delete_time FROM dice_player WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')', $ids);
echo "before (raw): " . count($beforeAll) . " rows\n";
$placeholders = implode(',', array_fill(0, count($ids), '?'));
// Case A: static destroy
try {
Db::startTrans();
$result = \app\dice\model\player\DicePlayer::destroy($ids, true);
$afterAny = Db::query("SELECT COUNT(*) AS c FROM dice_player WHERE id IN ({$placeholders})", $ids);
echo "[static destroy] returned: " . var_export($result, true) . ", remaining raw rows: " . ($afterAny[0]['c'] ?? 'n/a') . "\n";
Db::rollback();
} catch (\Throwable $e) {
Db::rollback();
echo "EXCEPTION (static destroy): " . get_class($e) . ": " . $e->getMessage() . "\n";
}
// Case B: instance ->delete()
try {
Db::startTrans();
$instance = \app\dice\model\player\DicePlayer::find($ids[0]);
if ($instance) {
$r = $instance->delete();
$afterAny = Db::query("SELECT COUNT(*) AS c FROM dice_player WHERE id = ?", [$ids[0]]);
echo "[instance delete] returned: " . var_export($r, true) . ", remaining raw rows for id={$ids[0]}: " . ($afterAny[0]['c'] ?? 'n/a') . "\n";
} else {
echo "[instance delete] no instance found for id={$ids[0]}\n";
}
Db::rollback();
} catch (\Throwable $e) {
Db::rollback();
echo "EXCEPTION (instance delete): " . get_class($e) . ": " . $e->getMessage() . "\n";
}
echo "(both rolled back, no actual delete)\n";

View File

@@ -0,0 +1,35 @@
-- 从所有角色中移除以下运维菜单及其按钮权限
-- /safeguard/dict
-- /safeguard/attachment
-- /safeguard/database
-- /safeguard/server
-- /safeguard/cache
-- /safeguard/email-log
--
-- 推荐执行php db/run_remove_safeguard_ops_role_menus.php
-- 该脚本会按 route 动态匹配菜单及其子权限,并清理菜单缓存
-- 主菜单
DELETE rm FROM `sa_system_role_menu` rm
INNER JOIN `sa_system_menu` m ON rm.menu_id = m.id
WHERE m.component IN (
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log'
);
-- 子按钮权限
DELETE rm FROM `sa_system_role_menu` rm
INNER JOIN `sa_system_menu` child ON rm.menu_id = child.id
INNER JOIN `sa_system_menu` parent ON child.parent_id = parent.id
WHERE parent.component IN (
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log'
);

View File

@@ -0,0 +1,14 @@
-- 移除岗位Post相关数据表与菜单
-- 执行前请备份数据库
-- 删除岗位子菜单权限id: 41-47
DELETE FROM `sa_system_role_menu` WHERE `menu_id` IN (41, 42, 43, 44, 45, 46, 47);
DELETE FROM `sa_system_menu` WHERE `id` IN (41, 42, 43, 44, 45, 46, 47);
-- 删除岗位管理主菜单id: 7
DELETE FROM `sa_system_role_menu` WHERE `menu_id` = 7;
DELETE FROM `sa_system_menu` WHERE `id` = 7;
-- 删除用户岗位关联表与岗位信息表
DROP TABLE IF EXISTS `sa_system_user_post`;
DROP TABLE IF EXISTS `sa_system_post`;

191
server/db/run_all_init.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* 一键执行渠道与游戏配置初始化(需已备份数据库)
* 用法:在 server 目录执行 php db/run_all_init.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
// 加载配置(排除 route.php避免 CLI 报错)
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use support\think\Db;
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
function runSqlFile(PDO $pdo, string $path, string $label, bool $alterOnly = false): void
{
echo "\n=== {$label} ===\n";
if (!is_file($path)) {
echo "跳过:文件不存在\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
$skip = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
if ($alterOnly && stripos($statement, 'ALTER TABLE') !== 0) {
continue;
}
try {
$pdo->exec($statement);
$ok++;
} catch (PDOException $e) {
$msg = $e->getMessage();
$isAlter = stripos($statement, 'ALTER TABLE') === 0;
if ($isAlter && (stripos($msg, 'Duplicate column') !== false
|| stripos($msg, 'Duplicate key name') !== false)) {
$skip++;
} elseif (!$isAlter) {
echo " [警告] " . substr($msg, 0, 120) . "\n";
$skip++;
} else {
echo " [错误] {$msg}\n";
throw $e;
}
}
}
echo "完成:成功 {$ok}" . ($skip > 0 ? ",跳过 {$skip} 条(字段已存在)" : '') . "\n";
}
function getRootDeptId(int $deptId): ?int
{
$currentId = $deptId;
$visited = [];
while ($currentId > 0 && !isset($visited[$currentId])) {
$visited[$currentId] = true;
$dept = SystemDept::find($currentId);
if (!$dept) {
return null;
}
$parentId = (int) ($dept->parent_id ?? 0);
if ($parentId === 0) {
return $currentId;
}
$currentId = $parentId;
}
return $currentId > 0 ? $currentId : null;
}
echo "========== 大富翁渠道/配置初始化 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/dice_tables_add_dept_id.sql', '1. dice 表增加 dept_id', true);
runSqlFile($pdo, __DIR__ . '/dept_flatten_channels.sql', '2. 渠道扁平化 SQL');
echo "\n=== 3. 渠道扁平化 PHP多级用户归并 + 删除子渠道) ===\n";
Db::transaction(function () {
$users = SystemUser::where('dept_id', '>', 0)->select();
$moved = 0;
foreach ($users as $user) {
$deptId = (int) $user->dept_id;
$rootId = getRootDeptId($deptId);
if ($rootId !== null && $rootId !== $deptId) {
SystemUser::where('id', $user->id)->update(['dept_id' => $rootId]);
echo " 用户 {$user->id}: dept {$deptId} -> {$rootId}\n";
$moved++;
}
}
$childIds = SystemDept::where('parent_id', '>', 0)->column('id');
if (!empty($childIds)) {
SystemDept::destroy($childIds);
echo ' 已删除子渠道: ' . implode(',', $childIds) . "\n";
} else {
echo " 无子渠道需删除\n";
}
SystemDept::where('id', '>', 0)->update(['parent_id' => 0, 'level' => '0']);
echo " 用户归并 {$moved} 人,渠道扁平化完成\n";
});
$service = new DiceChannelConfigService();
echo "\n=== 3.5 配置表复合键与默认模板 dept_id=0 ===\n";
$service->ensureConfigCompositeKeys();
echo " dice_config / dice_reward_config: ok\n";
echo "\n=== 3.6 彩金池配置按渠道唯一dept_id + name ===\n";
try {
$indexes = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'dice_lottery_poll_config_unique'");
if (!empty($indexes)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` DROP INDEX `dice_lottery_poll_config_unique`');
echo " 已移除 name 全局唯一索引\n";
}
$uk = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'uk_dept_name'");
if (empty($uk)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` ADD UNIQUE KEY `uk_dept_name` (`dept_id`, `name`)');
echo " 已添加 uk_dept_name(dept_id, name)\n";
} else {
echo " uk_dept_name 已存在\n";
}
} catch (\Throwable $e) {
echo " 跳过: {$e->getMessage()}\n";
}
echo "\n=== 4. 将现有配置设为默认模板dept_id=0 ===\n";
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
];
foreach ($tables as $table) {
try {
Db::table($table)->update(['dept_id' => 0]);
echo " {$table}: ok\n";
} catch (\Throwable $e) {
echo " {$table}: 跳过 ({$e->getMessage()})\n";
}
}
echo "\n=== 5. 为所有渠道从默认模板复制配置 ===\n";
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied'] ?? []) ?: '无';
$skipped = implode(',', $info['skipped'] ?? []) ?: '无';
echo " 渠道 {$deptId}: 复制 [{$copied}],跳过 [{$skipped}]\n";
}
echo "\n=== 6. 修复无效渠道并回填 dept_id ===\n";
echo " 无效渠道归并: ";
print_r($service->repairOrphanDeptReferences());
echo " 回填: ";
print_r($service->backfillDataDeptId());
$deptCount = SystemDept::count();
echo "\n当前顶级渠道数: {$deptCount}\n";
echo "========== 全部初始化完成 ==========\n";

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
$service = new DiceChannelConfigService();
echo "修复无效渠道引用...\n";
print_r($service->repairOrphanDeptReferences());
echo "\n回填 dept_id...\n";
print_r($service->backfillDataDeptId());
echo "完成。\n";

View File

@@ -0,0 +1,46 @@
<?php
/**
* 渠道配置初始化:默认模板 + 为已有渠道补齐配置 + 业务数据 dept_id 回填
* 用法:在 server 目录执行 php db/run_channel_config_init.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
$bootstrap = __DIR__ . '/../support/bootstrap.php';
if (is_file($bootstrap)) {
require_once $bootstrap;
}
use app\dice\service\DiceChannelConfigService;
use support\think\Db;
$service = new DiceChannelConfigService();
echo "1. 将 dept_id=0 的配置归为默认模板dept_id 置空)...\n";
echo " 若需将全部现有配置作为模板,请确认后手动执行 UPDATE ... SET dept_id=NULL\n";
foreach (
[
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
] as $table
) {
try {
Db::table($table)->where('dept_id', 0)->update(['dept_id' => null]);
echo " {$table}: ok\n";
} catch (\Throwable $e) {
echo " {$table}: 跳过 ({$e->getMessage()})\n";
}
}
echo "2. 为所有渠道补齐默认配置...\n";
$summary = $service->syncAllChannelsFromDefault();
print_r($summary);
echo "3. 回填玩家及记录 dept_id...\n";
$stats = $service->backfillDataDeptId();
print_r($stats);
echo "完成。\n";

View File

@@ -0,0 +1,55 @@
<?php
/**
* 渠道扁平化迁移脚本(多级子渠道用户归并到顶级渠道后删除子渠道)
* 用法:在 server 目录下执行 php db/run_dept_flatten_channels.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use support\think\Db;
$bootstrap = __DIR__ . '/../support/bootstrap.php';
if (is_file($bootstrap)) {
require_once $bootstrap;
}
function getRootDeptId(int $deptId): ?int
{
$currentId = $deptId;
$visited = [];
while ($currentId > 0 && !isset($visited[$currentId])) {
$visited[$currentId] = true;
$dept = SystemDept::find($currentId);
if (!$dept) {
return null;
}
$parentId = (int) ($dept->parent_id ?? 0);
if ($parentId === 0) {
return $currentId;
}
$currentId = $parentId;
}
return $currentId > 0 ? $currentId : null;
}
Db::transaction(function () {
$users = SystemUser::where('dept_id', '>', 0)->select();
foreach ($users as $user) {
$deptId = (int) $user->dept_id;
$rootId = getRootDeptId($deptId);
if ($rootId !== null && $rootId !== $deptId) {
SystemUser::where('id', $user->id)->update(['dept_id' => $rootId]);
echo "用户 {$user->id} dept_id {$deptId} -> {$rootId}\n";
}
}
$childIds = SystemDept::where('parent_id', '>', 0)->column('id');
if (!empty($childIds)) {
SystemDept::destroy($childIds);
echo '已删除子渠道: ' . implode(',', $childIds) . "\n";
}
SystemDept::where('id', '>', 0)->update(['parent_id' => 0, 'level' => '0']);
echo "渠道扁平化完成\n";
});

View File

@@ -0,0 +1,99 @@
<?php
/**
* 安装两个抽奖流程图外链菜单,并授权超级管理员
* 用法(在 server 目录): php db/run_dice_flowcharts_menu.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
function runSqlFile(PDO $pdo, string $path, string $label): void
{
echo "\n=== {$label} ===\n";
if (! is_file($path)) {
echo "跳过:文件不存在 {$path}\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
$ok++;
}
echo "完成:执行 {$ok} 条语句\n";
}
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
echo "========== 抽奖流程图外链菜单安装 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/dice_flowcharts_menu.sql', '1. 外链菜单');
$menuIds = Db::name('sa_system_menu')
->where('type', 4)
->whereIn('link_url', [
'/docs/flowcharts/dice-为何抽到该奖励.html',
'/docs/flowcharts/dice-后台中奖逻辑配置.html',
])
->column('id');
if ($menuIds === [] || $menuIds === null) {
echo "错误:未找到流程图菜单\n";
exit(1);
}
$menuIds = array_map('intval', $menuIds);
echo "\n流程图菜单 ID: " . implode(', ', $menuIds) . "\n";
echo "\n=== 2. 授权超级管理员角色 ===\n";
$adminRoleIds = Db::name('sa_system_role')
->where('code', 'super_admin')
->column('id');
if ($adminRoleIds === [] || $adminRoleIds === null) {
$adminRoleIds = Db::name('sa_system_role')->where('id', 1)->column('id');
}
foreach ($adminRoleIds as $roleId) {
$roleId = (int) $roleId;
foreach ($menuIds as $menuId) {
$exists = Db::name('sa_system_role_menu')
->where('role_id', $roleId)
->where('menu_id', $menuId)
->count();
if ($exists > 0) {
continue;
}
Db::name('sa_system_role_menu')->insert([
'role_id' => $roleId,
'menu_id' => $menuId,
]);
}
echo "角色 {$roleId} 已关联流程图菜单\n";
}
UserMenuCache::clearMenuCache();
echo "\n已清理菜单缓存。请重新登录后台或刷新页面查看侧边栏。\n";
echo "点击菜单将在新窗口打开 /docs/flowcharts/*.html\n";

View File

@@ -0,0 +1,44 @@
<?php
/**
* 执行 dice_play_record 备注字段迁移
* 用法:在 server 目录执行 php db/run_dice_play_record_add_remark.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$sqlFile = __DIR__ . '/dice_play_record_add_remark.sql';
$sql = file_get_contents($sqlFile);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
echo "执行: dice_play_record_add_remark.sql\n";
echo "数据库: {$db} @ {$host}\n\n";
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
echo "OK\n";
}
echo "\n完成。\n";

View File

@@ -0,0 +1,53 @@
<?php
/**
* 为 dice_player 增加 (dept_id, username) 唯一索引
* 用法:在 server 目录执行 php db/run_dice_player_dept_username_unique.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
echo "检查 (dept_id, username) 重复...\n";
$dupes = Db::query(
'SELECT dept_id, username, COUNT(*) AS c FROM dice_player
WHERE username IS NOT NULL AND username <> \'\'
GROUP BY dept_id, username HAVING c > 1 LIMIT 20'
);
if (!empty($dupes)) {
echo "存在重复记录,请先处理后再执行:\n";
print_r($dupes);
exit(1);
}
$indexes = Db::query("SHOW INDEX FROM `dice_player` WHERE Key_name = 'idx_dice_player_username'");
if (!empty($indexes)) {
Db::execute('ALTER TABLE `dice_player` DROP INDEX `idx_dice_player_username`');
echo "已删除 idx_dice_player_username\n";
}
$uk = Db::query("SHOW INDEX FROM `dice_player` WHERE Key_name = 'uk_dice_player_dept_username'");
if (empty($uk)) {
Db::execute(
'ALTER TABLE `dice_player` ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`)'
);
echo "已创建 uk_dice_player_dept_username\n";
} else {
echo "uk_dice_player_dept_username 已存在,跳过\n";
}
echo "完成\n";

View File

@@ -0,0 +1,60 @@
<?php
/**
* 执行档位结算推荐配置菜单权限 SQL
* 用法:在 server 目录执行 php db/run_dice_reward_config_tier_recommend_menu.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$sqlFile = __DIR__ . '/dice_reward_config_tier_recommend_menu.sql';
$sql = file_get_contents($sqlFile);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
echo "执行: dice_reward_config_tier_recommend_menu.sql\n";
echo "数据库: {$db} @ {$host}\n\n";
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
}
$row = $pdo->query(
"SELECT id, parent_id, name, slug FROM sa_system_menu WHERE slug = 'dice:reward_config:index:tierRecommend' AND type = 3 LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
if ($row) {
echo "成功:已写入菜单权限\n";
echo " id={$row['id']} parent_id={$row['parent_id']} name={$row['name']} slug={$row['slug']}\n";
} else {
$parent = $pdo->query(
"SELECT id, name, path FROM sa_system_menu WHERE type = 2 AND (path = 'reward_config' OR component LIKE '%reward_config%') ORDER BY id ASC LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
if ($parent === false) {
echo "失败:未找到「奖励配置」父菜单 (type=2),请先创建 reward_config 菜单\n";
exit(1);
}
echo "警告:权限 slug 未查到(可能已存在但未插入)。父菜单: id={$parent['id']} path={$parent['path']}\n";
exit(1);
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* 为已有渠道补齐缺失的默认配置(不删除已有数据)
* 用法php db/run_fill_missing_channel_config.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
echo "========== 补齐渠道缺失配置 ==========\n";
$service = new DiceChannelConfigService();
$service->ensureConfigCompositeKeys();
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied'] ?? []) ?: '无';
$merged = empty($info['merged']) ? '无' : json_encode($info['merged'], JSON_UNESCAPED_UNICODE);
$skipped = implode(',', $info['skipped'] ?? []) ?: '无';
echo "渠道 {$deptId}: 新增表 [{$copied}] 补齐行 {$merged} 跳过 [{$skipped}]\n";
}
echo "\n渠道数: " . SystemDept::count() . "\n完成。\n";

View File

@@ -0,0 +1,100 @@
<?php
/**
* 从所有角色中移除以下运维菜单及其按钮权限:
* /safeguard/dict
* /safeguard/attachment
* /safeguard/database
* /safeguard/server
* /safeguard/cache
* /safeguard/email-log
*
* 用法(在 server 目录): php db/run_remove_safeguard_ops_role_menus.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
$targetRoutes = [
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log',
];
/**
* @param int[] $parentIds
* @return int[]
*/
function collectChildMenuIds(array $parentIds): array
{
if ($parentIds === []) {
return [];
}
$childIds = Db::name('sa_system_menu')
->whereIn('parent_id', $parentIds)
->column('id');
if ($childIds === []) {
return [];
}
$childIds = array_map('intval', $childIds);
$deeper = collectChildMenuIds($childIds);
return array_values(array_unique(array_merge($childIds, $deeper)));
}
echo "=== remove safeguard ops menus from all roles ===\n";
$rootMenuIds = Db::name('sa_system_menu')
->whereIn('component', $targetRoutes)
->column('id');
$rootMenuIds = array_map('intval', $rootMenuIds ?: []);
$childMenuIds = collectChildMenuIds($rootMenuIds);
$menuIds = array_values(array_unique(array_merge($rootMenuIds, $childMenuIds)));
if ($menuIds === []) {
echo "WARN: no menu matched target routes\n";
exit(0);
}
$menuRows = Db::name('sa_system_menu')
->whereIn('id', $menuIds)
->field('id, parent_id, name, component, slug, type')
->order('id', 'asc')
->select()
->toArray();
echo "Matched menus (" . count($menuRows) . "):\n";
foreach ($menuRows as $row) {
echo sprintf(
" - id=%d parent=%d type=%s component=%s slug=%s name=%s\n",
(int) ($row['id'] ?? 0),
(int) ($row['parent_id'] ?? 0),
(string) ($row['type'] ?? ''),
(string) ($row['component'] ?? ''),
(string) ($row['slug'] ?? ''),
(string) ($row['name'] ?? '')
);
}
$deleted = Db::name('sa_system_role_menu')->whereIn('menu_id', $menuIds)->delete();
echo "Deleted role-menu rows: {$deleted}\n";
$roleCount = Db::name('sa_system_role_menu')
->whereIn('menu_id', $menuIds)
->count();
echo "Remaining role-menu rows for these menus: {$roleCount}\n";
UserMenuCache::clearMenuCache();
echo "Cleared menu cache\n";
echo "Done.\n";

View File

@@ -0,0 +1,74 @@
<?php
/**
* 执行 sa_system_role 渠道隔离迁移,并为已有渠道复制默认角色、映射用户角色
*
* 用法(在 server 目录): php db/run_sa_system_role_dept_id.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\service\SystemRoleChannelService;
use support\think\Db;
function tableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
function indexExists(string $table, string $indexName): bool
{
$rows = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
return !empty($rows);
}
echo "=== sa_system_role dept_id migration ===\n";
if (!tableHasColumn('sa_system_role', 'dept_id')) {
Db::execute(
"ALTER TABLE `sa_system_role`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属渠道ID0=默认模板' AFTER `id`"
);
echo "OK: ADD COLUMN dept_id\n";
} else {
echo "SKIP: dept_id column exists\n";
}
if (!indexExists('sa_system_role', 'idx_dept_id')) {
Db::execute('ALTER TABLE `sa_system_role` ADD INDEX `idx_dept_id` (`dept_id`)');
echo "OK: ADD INDEX idx_dept_id\n";
} else {
echo "SKIP: idx_dept_id exists\n";
}
Db::execute('UPDATE `sa_system_role` SET `dept_id` = 0 WHERE `id` > 1');
echo "OK: UPDATE template dept_id\n";
if (indexExists('sa_system_role', 'uk_slug')) {
Db::execute('ALTER TABLE `sa_system_role` DROP INDEX `uk_slug`');
echo "OK: DROP INDEX uk_slug\n";
} else {
echo "SKIP: uk_slug not found\n";
}
if (!indexExists('sa_system_role', 'uk_dept_code')) {
Db::execute('ALTER TABLE `sa_system_role` ADD UNIQUE KEY `uk_dept_code` (`dept_id`, `code`)');
echo "OK: ADD UNIQUE uk_dept_code\n";
} else {
echo "SKIP: uk_dept_code exists\n";
}
$service = new SystemRoleChannelService();
$sync = $service->syncAllChannelsFromDefault();
echo "Synced roles for channels: " . json_encode($sync, JSON_UNESCAPED_UNICODE) . "\n";
$mapped = $service->remapUserRolesToChannelRoles();
echo "Remapped user roles: {$mapped}\n";
echo "Done.\n";

View File

@@ -0,0 +1,58 @@
<?php
/**
* 仅执行渠道配置同步(步骤 4-6适用于已完成表结构迁移后
* 用法php db/run_sync_channel_config.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
echo "========== 渠道配置同步 ==========\n";
$service = new DiceChannelConfigService();
echo "调整配置表主键与默认模板 dept_id=0...\n";
$service->ensureConfigCompositeKeys();
$tables = ['dice_config', 'dice_ante_config', 'dice_lottery_pool_config', 'dice_reward_config', 'dice_reward', 'dice_game'];
echo "\n清理各渠道不完整配置后重新复制...\n";
$deptIds = SystemDept::column('id');
foreach ($deptIds as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
foreach ($tables as $table) {
try {
Db::table($table)->where('dept_id', $deptId)->delete();
} catch (\Throwable $e) {
}
}
}
echo "设为默认模板 (dept_id=0)...\n";
foreach ($tables as $table) {
Db::table($table)->whereNull('dept_id')->update(['dept_id' => 0]);
}
echo "从默认模板复制到各渠道...\n";
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
echo "渠道 {$deptId}: 复制 [" . (implode(',', $info['copied'] ?? []) ?: '无') . "] 跳过 [" . (implode(',', $info['skipped'] ?? []) ?: '无') . "]\n";
}
echo "\n回填 dept_id...\n";
print_r($service->backfillDataDeptId());
echo "\n渠道数: " . SystemDept::count() . "\n完成。\n";

View File

@@ -0,0 +1,107 @@
<?php
/**
* 安装「后台操作指南」菜单与权限,并授权给超级管理员角色
* 用法(在 server 目录): php db/run_system_admin_guide_menu.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
function runSqlFile(PDO $pdo, string $path, string $label): void
{
echo "\n=== {$label} ===\n";
if (! is_file($path)) {
echo "跳过:文件不存在 {$path}\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
$ok++;
}
echo "完成:执行 {$ok} 条语句\n";
}
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
echo "========== 后台操作指南菜单安装 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/system_admin_guide_menu.sql', '1. 菜单与按钮权限');
$menuId = (int) Db::name('sa_system_menu')
->where('path', 'admin_guide')
->where('component', '/system/admin_guide/index')
->where('type', 2)
->value('id');
if ($menuId <= 0) {
echo "错误:未找到后台操作指南菜单\n";
exit(1);
}
$buttonIds = Db::name('sa_system_menu')
->where('parent_id', $menuId)
->where('type', 3)
->column('id');
$allMenuIds = array_values(array_unique(array_merge([$menuId], array_map('intval', $buttonIds ?: []))));
echo "\n=== 2. 授权超级管理员角色 ===\n";
$adminRoleIds = Db::name('sa_system_role')
->where('code', 'super_admin')
->column('id');
if ($adminRoleIds === [] || $adminRoleIds === null) {
$adminRoleIds = Db::name('sa_system_role')->where('id', 1)->column('id');
}
$inserted = 0;
foreach ($adminRoleIds as $roleId) {
$roleId = (int) $roleId;
foreach ($allMenuIds as $mid) {
$exists = Db::name('sa_system_role_menu')
->where('role_id', $roleId)
->where('menu_id', $mid)
->count();
if ($exists > 0) {
continue;
}
Db::name('sa_system_role_menu')->insert([
'role_id' => $roleId,
'menu_id' => $mid,
]);
$inserted++;
}
echo " 角色 {$roleId}:新增授权 {$inserted}\n";
}
UserMenuCache::clearMenuCache();
\plugin\saiadmin\app\cache\UserAuthCache::clear();
echo "\n菜单 ID: {$menuId}\n";
echo "按钮权限: " . implode(',', $allMenuIds) . "\n";
echo "已清除菜单缓存,请重新登录后台查看。\n";
echo "========== 安装完成 ==========\n";

View File

@@ -0,0 +1,85 @@
<?php
/**
* 为登录日志、操作日志补充 dept_id 字段并回填历史数据。
*
* 用法(在 server 目录): php db/run_system_log_dept_id.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use support\think\Db;
function systemLogTableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
function systemLogIndexExists(string $table, string $indexName): bool
{
$rows = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
return !empty($rows);
}
function addLogDeptColumn(string $table, string $afterColumn): void
{
if (!systemLogTableHasColumn($table, 'dept_id')) {
Db::execute(
"ALTER TABLE `{$table}`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `{$afterColumn}`"
);
echo "OK: {$table} ADD COLUMN dept_id\n";
} else {
echo "SKIP: {$table} dept_id column exists\n";
}
if (!systemLogIndexExists($table, 'idx_dept_id')) {
Db::execute("ALTER TABLE `{$table}` ADD INDEX `idx_dept_id` (`dept_id`)");
echo "OK: {$table} ADD INDEX idx_dept_id\n";
} else {
echo "SKIP: {$table} idx_dept_id exists\n";
}
}
echo "=== system log dept_id migration ===\n";
addLogDeptColumn('sa_system_login_log', 'login_time');
addLogDeptColumn('sa_system_oper_log', 'request_data');
$loginByCreator = Db::execute(
"UPDATE `sa_system_login_log` l
INNER JOIN `sa_system_user` u ON u.id = l.created_by
SET l.dept_id = u.dept_id
WHERE (l.dept_id IS NULL OR l.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: login log backfilled by created_by: {$loginByCreator}\n";
$loginByUsername = Db::execute(
"UPDATE `sa_system_login_log` l
INNER JOIN `sa_system_user` u ON u.username = l.username
SET l.dept_id = u.dept_id
WHERE (l.dept_id IS NULL OR l.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: login log backfilled by username: {$loginByUsername}\n";
$operByUsername = Db::execute(
"UPDATE `sa_system_oper_log` o
INNER JOIN `sa_system_user` u ON u.username = o.username
SET o.dept_id = u.dept_id
WHERE (o.dept_id IS NULL OR o.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: oper log backfilled by username: {$operByUsername}\n";
echo "Done.\n";

View File

@@ -0,0 +1,10 @@
-- 角色表按渠道隔离dept_id=0 为默认模板角色,各渠道拥有独立角色副本
ALTER TABLE `sa_system_role`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属渠道ID0=默认模板' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
UPDATE `sa_system_role` SET `dept_id` = 0 WHERE `dept_id` IS NULL OR `id` > 1;
ALTER TABLE `sa_system_role` DROP INDEX `uk_slug`;
ALTER TABLE `sa_system_role` ADD UNIQUE KEY `uk_dept_code` (`dept_id`, `code`);

View File

@@ -0,0 +1,21 @@
<?php
/**
* 为各渠道补齐三个默认代理角色,并清理多余角色(无用户绑定的)
* 用法: php db/sync_channel_default_roles.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\service\SystemRoleChannelService;
$service = new SystemRoleChannelService();
echo 'Default role codes: ' . implode(', ', $service->getDefaultChannelRoleCodes()) . "\n";
$sync = $service->syncAllChannelsFromDefault();
echo json_encode($sync, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
$mapped = $service->remapUserRolesToChannelRoles();
echo "Remapped user roles: {$mapped}\n";
echo "Done.\n";

View File

@@ -0,0 +1,39 @@
-- 后台操作指南顶级菜单与权限
-- 说明挂载到顶级菜单parent_id=0内容来源 server/docs/ADMIN_GUIDE.md
SET @now = NOW();
-- 1) 创建后台操作指南顶级菜单type=2parent_id=0
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '后台操作指南', 'AdminGuide', NULL, 2, 'admin_guide', '/system/admin_guide/index', NULL, 'ri:book-read-line', 5, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu` WHERE `path` = 'admin_guide' AND `component` = '/system/admin_guide/index' AND `type` = 2
);
SET @admin_guide_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `path` = 'admin_guide' AND `component` = '/system/admin_guide/index' AND `type` = 2
ORDER BY `id` ASC LIMIT 1
);
-- 2) 创建按钮权限
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '数据列表', '', 'system:admin_guide:index:index', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:index' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '读取', '', 'system:admin_guide:index:read', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:read' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '编辑', '', 'system:admin_guide:index:edit', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:edit' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '保存', '', 'system:admin_guide:index:save', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:save' AND `type` = 3);

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\model\reward_config\DiceRewardConfig;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
$deptId = 1123;
$query = (new DiceRewardConfig())->order('id', 'asc');
AdminScopeHelper::applyConfigScope($query, null, $deptId);
$logic = new DiceRewardConfigLogic();
$result = $logic->getList($query);
echo 'keys: ' . implode(',', array_keys($result)) . "\n";
$data = $result['data'] ?? $result['records'] ?? [];
echo 'count: ' . (is_array($data) ? count($data) : 0) . "\n";

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\model\reward_config\DiceRewardConfig;
foreach ([0, 1123] as $deptId) {
$q1 = DiceRewardConfig::where('dept_id', $deptId);
echo "direct dept {$deptId}: " . $q1->count() . "\n";
$q2 = (new DiceRewardConfig())->order('id', 'asc');
AdminScopeHelper::applyConfigScope($q2, ['id' => 1], $deptId);
echo "super admin scoped dept {$deptId}: " . $q2->count() . "\n";
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
$deptIds = SystemDept::column('id');
echo "渠道数: " . count($deptIds) . " [" . implode(',', $deptIds) . "]\n\n";
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
];
echo "=== 配置按 dept_id 统计 ===\n";
foreach ($tables as $table) {
$rows = Db::query("SELECT dept_id, COUNT(*) AS cnt FROM `{$table}` GROUP BY dept_id ORDER BY dept_id");
echo "{$table}:\n";
foreach ($rows as $r) {
$dept = $r['dept_id'] === null ? 'NULL' : (string) $r['dept_id'];
echo " dept_id={$dept}: {$r['cnt']}\n";
}
}
echo "\n=== 业务数据未回填 dept_id ===\n";
$biz = ['dice_player', 'dice_play_record', 'dice_play_record_test'];
foreach ($biz as $table) {
if (!Db::getFields($table)) {
continue;
}
$nullCnt = Db::table($table)->where(function ($q) {
$q->whereNull('dept_id')->whereOr('dept_id', 0);
})->count();
$total = Db::table($table)->count();
echo "{$table}: 未回填 {$nullCnt} / 总计 {$total}\n";
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 1123);
$data = $query->order('id', 'asc')->select()->toArray();
echo 'controller-style all rows: ' . count($data) . PHP_EOL;
$nonBig = 0;
foreach ($data as $row) {
if (($row['tier'] ?? '') !== 'BIGWIN') {
$nonBig++;
}
}
echo 'index tab rows (non-BIGWIN): ' . $nonBig . PHP_EOL;
echo 'bigwin tab rows: ' . (count($data) - $nonBig) . PHP_EOL;

128
server/docs/ADMIN_GUIDE.md Normal file
View File

@@ -0,0 +1,128 @@
# 大富翁-使用说明指南
## 菜单简单介绍
### 工作台/统计页面:统计数据
![image.png](/docs/picture/guide_01.png)
### 角色管理:对角色的菜单权限设置
按等级设定,等级越低权限越少(不要出现上级角色没有的权限,子角色有)
避免方式:使用子角色创建下级角色,可以避免下级角色比上级角色操作权限更多的问题
![image.png](/docs/picture/guide_02.png)
这里设置角色的菜单以及按钮权限
![image.png](/docs/picture/guide_03.png)
### 彩金池配置:监听彩金池实时变化
可以实时监听彩金池累积金额的变化
![image.png](/docs/picture/guide_04.png)
### 游戏配置:游戏规则和平台币转化比
游戏配置
![image.png](/docs/picture/guide_05.png)
其中游戏玩法为进入游戏的弹窗,和规则介绍(无特殊需求不需要大改)
![image.png](/docs/picture/guide_06.png)
游戏平台币兑换币为进入平台时平台比转化比比如当前设置的为11如果从jk8平台转入100那么获取的游戏币为100如果设置12则获取的平台币为200
### 底注配置:方便玩家快速调整压注倍率
底注配置
![image.png](/docs/picture/guide_07.png)
对应游戏中的其中每次游玩对局基础消耗为1游戏币无法修改底注的设置只是方便玩家快速修改压注金额
![image.png](/docs/picture/guide_08.png)
## 抽奖逻辑
### 判断抽奖档位
当前的抽奖逻辑时按照抽奖档位T1-T5进行抽奖在【玩家管理】菜单中的设置玩家具体的档位权重
![image.png](/docs/picture/guide_09.png)
也可以在【彩金池配置】菜单中设置玩家正常的抽奖档位权重
- 其中正常的档位权重为注册玩家默认绑定的档位权重,并且只有在修改完后,创建的玩家才能绑定最新的正常档位权重
![image.png](/docs/picture/guide_10.png)
- 其中free为杀分权重为如果当前彩金池平台盈利超过设置的安全线则制动走杀分的权重
![image.png](/docs/picture/guide_11.png)
- 剩余的两个权重可以方便快速切换用户的档位抽奖权重
![image.png](/docs/picture/guide_12.png)
![image.png](/docs/picture/guide_13.png)
### 根据档位抽取中奖号码
#### 设置中奖号码地图
在后台设置地图缩影
![image.png](/docs/picture/guide_14.png)
地图的索引参看如下
![image.png](/docs/picture/guide_15.png)
其中地图的索引可以按照需求点击图中的按规则生成
并且规则尽可能符合:结算金额>2 → T12>=结算金额>1 → T21>=结算金额>0 → T30>结算金额 → T4惩罚0=结算金额 → T5再来一次
![image.png](/docs/picture/guide_16.png)
![image.png](/docs/picture/guide_17.png)
#### 创建完地图索引后创建相应的奖励对照表
创建奖励对照表的原因是由于有每个号码的权重不一样豹子号10152025有多重组合方式所以需要设置奖励对照表中的权重配比
![image.png](/docs/picture/guide_18.png)
![image.png](/docs/picture/guide_19.png)
根据抽到的奖励档位抽取号码主要用于设置抽取豹子号的51015202530的权重
![image.png](/docs/picture/guide_20.png)
比如上图中如果不设置色子点数5抽到的概率和其他点数的概率是一样的可能抽7次T1奖励就有1次中豹子号5的可能
由于抽到色子点数和为10152025的色子点数组合有多种所以在抽该这四个点数时还需要单独配置相应的中大奖概率其中豹子号5和30只有一种组合【11111】和【66666】所以不需要配置其中权重拉到最大10000那么中奖概率为100%只要摇到了相应的色子点数和则中奖概率为100%
![image.png](/docs/picture/guide_21.png)
#### 扩展
- 测试设置的中奖概率,根据如下设置可以测试当前设置权重的中奖概率,该测试数据不记录到真实数据系统中
![image.png](/docs/picture/guide_22.png)
![image.png](/docs/picture/guide_23.png)
能够准确的反馈抽中点数的统计
![image.png](/docs/picture/guide_24.png)
如果当前中奖概率符合预期,或则测试多组数据选取一组符合预期的导入到当前的配置中
![image.png](/docs/picture/guide_25.png)
这里可以详情查询到指定测试记录的详情,
![image.png](/docs/picture/guide_26.png)

View File

@@ -26,7 +26,12 @@
- 请求头统一:
- `Content-Type: application/json`
- `Accept: application/json`
- `api-key: {api_key}`**所有 `/api/v1/*` 必传**,与服务端 `.env``API_KEY` 一致)
- `auth-token: {authtoken}`(除 `/api/v1/authToken` 外必传)
- `api-key` 携带方式(任选其一,按优先级读取,先命中即采用):
1. 请求头 `api-key`**推荐**
2. URL 查询参数 `api_key`(或 `api-key`
3. body 表单/JSON 字段 `api_key`(或 `api-key`
- 时间相关参数统一使用 Unix 时间戳(秒)
- 建议所有请求设置超时:连接超时 `3s`,读取超时 `10s`
- 生产环境建议增加调用方 IP 白名单和重试退避机制(避免瞬时重试风暴)
@@ -53,9 +58,9 @@
常见错误码:
- `400` 参数错误
- `401` 未携带 token
- `401` 未携带 `api-key``auth-token``token`
- `402` token 无效或过期
- `403` 签名或鉴权失败
- `403` `api-key` 无效、签名或鉴权失败
- `404` 资源不存在
- `422` 业务错误(如余额不足)
- `500` 服务端异常
@@ -64,11 +69,18 @@
## 4. 鉴权流程(平台级)
平台级凭证分两层:
- **`api-key`**:所有 `/api/v1/*` 接口必传,与服务端 `.env``API_KEY` 一致可通过请求头、query、body 任一方式携带(详见 §2.1)。
- **`auth-token`**:业务接口(除 `/api/v1/authToken` 外)必传,由 `/api/v1/authToken` 颁发。
`/api/v1/*` 接口调用前,先获取 `auth-token`
### 4.1 获取 auth-token
- 路径: `GET /api/v1/authToken`
- Header
- `api-key: {api_key}`(必传,与服务端 `.env``API_KEY` 一致)
- 鉴权参数Query
- `agent_id`:代理标识(商户标识)
- `secret`:双方约定密钥
@@ -113,11 +125,12 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
服务端校验逻辑(关键点):
- `api-key` 缺失即失败(`401`),与 `.env``API_KEY` 不一致即失败(`403`
- `agent_id/secret/time/signature` 任一缺失即失败(`400`
- `secret` 不匹配即失败(`403`
- `time` 超出容差窗口即失败(`403`,默认容差 `300s`
- `signature` 校验失败即失败(`403`
- 校验通过后颁发 `authtoken`,后续请求必须放在 Header `auth-token`
- 校验通过后颁发 `authtoken`,后续请求必须放在 Header `auth-token`(同时仍需带 `api-key`
防重放与时间同步建议:
@@ -140,14 +153,15 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
后续调用 `/api/v1/*` 时,请在 Header 携带:
```text
api-key: {api_key}
auth-token: {authtoken}
```
### 4.2 完整调用链(推荐)
1. 计算 `signature = md5(agent_id + secret + time)`
2. 调用 `GET /api/v1/authToken` 获取 `authtoken`
3. 在 Header 添加 `auth-token: {authtoken}`
2. 调用 `GET /api/v1/authToken`Header 携带 `api-key`获取 `authtoken`
3. 在 Header 添加 `api-key: {api_key}``auth-token: {authtoken}`
4. 调用业务接口(如 `getPlayerInfo``setPlayerWallet``getGameUrl``getPlayerGameRecord``getPlayerWalletRecord``getPlayerTicketRecord`
5. 若返回 `402`,重新获取 `authtoken` 后重试一次
@@ -155,12 +169,13 @@ auth-token: {authtoken}
## 5. 游戏相关接口
以下接口均需 Header: `auth-token`
以下接口均需 Header`api-key` + `auth-token``api-key` 也可放 query/body参见 §2.1
## 5.1 获取游戏列表(已支持)
- 路径: `POST /api/v1/getGameList`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -240,6 +255,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getGameHall`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -316,9 +332,11 @@ auth-token: {authtoken}
## 5.3 获取某个游戏地址(已支持)
- 路径: `POST /api/v1/getGameUrl`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(必填):玩家账号(不存在会自动创建)
- `password`(可选):默认 `123456`
- `time`(可选):不传则服务端取当前时间戳
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -349,6 +367,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerGameRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(可选):玩家账号;不传则**不按玩家筛选**(返回库内符合条件的记录,请谨慎使用)
@@ -396,7 +415,7 @@ auth-token: {authtoken}
## 7. 钱包相关接口
以下接口均需 Header: `auth-token`
以下接口均需 Header`api-key` + `auth-token``api-key` 也可放 query/body参见 §2.1
### 7.1 查询余额(已支持)
@@ -430,6 +449,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerWalletRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(可选):玩家账号;不传则**不按玩家筛选**
@@ -445,6 +465,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerTicketRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:与 **7.4** 相同(`username``start_create_time``end_create_time``limit`
- 返回说明:
@@ -462,7 +483,8 @@ auth-token: {authtoken}
- `provider``Dicey Fun`
- `provider_code``DF`
- `agent_id``5ef059938ba799aaa845e1c2e8a762bd`
- `secret`:签名密钥(双方约定)
- `secret`:签名密钥(双方约定,对应服务端 `.env``API_AUTH_TOKEN_SECRET`
- `api_key`:所有 `/api/v1/*` 请求必传的 `api-key`(对应服务端 `.env``API_KEY`
- `agent_token``[我来填]`(如需额外业务层 token
- `game_url`:游戏前端域名/地址
- `lobby_url`:大厅地址(可选)
@@ -482,8 +504,8 @@ auth-token: {authtoken}
## 10. 对接时序(建议)
1. 平台分配 `agent_id``secret`
2. 第三方调用 `/api/v1/authToken` 获取 `authtoken`
1. 平台分配 `agent_id``secret``api_key`
2. 第三方调用 `/api/v1/authToken`Header 携带 `api-key`获取 `authtoken`
3. 第三方调用 `/api/v1/getGameHall``/api/v1/getGameList` 获取大厅/游戏信息
4. 第三方调用 `/api/v1/getPlayerInfo`(可选,检查用户与余额)
5. 第三方调用 `/api/v1/setPlayerWallet` 进行额度转入(如有)
@@ -498,7 +520,8 @@ auth-token: {authtoken}
### 11.1 获取 auth-token
```bash
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}'
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}' \
--header 'api-key: {api_key}'
```
建议在接入测试时,先本地打印以下值再发请求,便于排查:
@@ -514,6 +537,7 @@ curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -526,6 +550,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -537,6 +562,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"en"
@@ -548,6 +574,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -559,6 +586,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -571,6 +599,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001"
@@ -582,6 +611,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -594,6 +624,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -606,6 +637,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletReco
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerTicketRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",

View File

@@ -26,7 +26,12 @@
- Unified headers:
- `Content-Type: application/json`
- `Accept: application/json`
- `api-key: {api_key}` (**Required for ALL `/api/v1/*` endpoints**, must match `API_KEY` in server `.env`)
- `auth-token: {authtoken}` (Required for all endpoints except `/api/v1/authToken`)
- `api-key` may be supplied in any of the following ways (read in priority order, first non-empty wins):
1. HTTP header `api-key` (**recommended**)
2. Query string `api_key` (or `api-key`)
3. Body form/JSON field `api_key` (or `api-key`)
- All time-related parameters use Unix timestamps (seconds)
- Recommended timeouts: connect timeout `3s`, read timeout `10s`
- Production recommendation: add caller IP whitelist and retry backoff (to avoid burst retry storms)
@@ -53,9 +58,9 @@ Notes:
Common error codes:
- `400` Invalid parameters
- `401` Missing token
- `401` Missing `api-key`, `auth-token` or `token`
- `402` Token invalid or expired
- `403` Signature or authentication failed
- `403` Invalid `api-key`, signature or authentication failed
- `404` Resource not found
- `422` Business error (e.g., insufficient balance)
- `500` Server exception
@@ -64,11 +69,18 @@ Common error codes:
## 4. Authentication Flow (Platform Level)
Two layers of platform-level credentials:
- **`api-key`**: Required for ALL `/api/v1/*` endpoints, must match `API_KEY` in server `.env`. May be sent in header, query, or body (see §2.1).
- **`auth-token`**: Required for business endpoints (i.e., everything except `/api/v1/authToken`); obtained from `/api/v1/authToken`.
Before calling any `/api/v1/*` endpoint, obtain an `auth-token` first.
### 4.1 Get auth-token
- Path: `GET /api/v1/authToken`
- Header:
- `api-key: {api_key}` (Required, must match `API_KEY` in server `.env`)
- Auth parameters (Query):
- `agent_id`: Agent identifier (merchant identifier)
- `secret`: Shared secret agreed by both parties
@@ -113,11 +125,12 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
Server-side validation logic (key points):
- Missing `api-key` => fail (`401`); `api-key` not equal to `.env` `API_KEY` => fail (`403`)
- Missing any of `agent_id/secret/time/signature` => fail (`400`)
- `secret` mismatch => fail (`403`)
- `time` outside tolerance window => fail (`403`, default tolerance `300s`)
- `signature` mismatch => fail (`403`)
- If validated, the server issues `authtoken`; subsequent requests must include it in the `auth-token` header
- If validated, the server issues `authtoken`; subsequent requests must include it in the `auth-token` header (and still carry `api-key`)
Anti-replay and time sync recommendations:
@@ -137,17 +150,18 @@ Success response example:
}
```
For subsequent calls to `/api/v1/*`, include the following header:
For subsequent calls to `/api/v1/*`, include the following headers:
```text
api-key: {api_key}
auth-token: {authtoken}
```
### 4.2 Full Call Chain (Recommended)
1. Compute `signature = md5(agent_id + secret + time)`
2. Call `GET /api/v1/authToken` to obtain `authtoken`
3. Add header `auth-token: {authtoken}`
2. Call `GET /api/v1/authToken` (Header `api-key`) to obtain `authtoken`
3. Add headers `api-key: {api_key}` and `auth-token: {authtoken}`
4. Call business endpoints (e.g., `getPlayerInfo`, `setPlayerWallet`, `getGameUrl`, `getPlayerGameRecord`, `getPlayerWalletRecord`, `getPlayerTicketRecord`)
5. If `402` is returned, re-fetch `authtoken` and retry once
@@ -155,12 +169,13 @@ auth-token: {authtoken}
## 5. Game APIs
All endpoints below require the `auth-token` header.
All endpoints below require headers `api-key` + `auth-token` (`api-key` may also be sent via query/body, see §2.1).
## 5.1 Get Game List (Supported)
- Path: `POST /api/v1/getGameList`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `lang` (optional): `zh`/`en`, default `zh`
@@ -240,6 +255,7 @@ Success example (`lang=en`):
- Path: `POST /api/v1/getGameHall`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `lang` (optional): `zh`/`en`, default `zh`
@@ -316,9 +332,11 @@ Success example (`lang=en`):
## 5.3 Get Game URL (Supported)
- Path: `POST /api/v1/getGameUrl`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (required): Player username (auto-created if not exists)
- `password` (optional): default `123456`
- `time` (optional): if omitted, server uses current timestamp
- `lang` (optional): `zh`/`en`, default `zh`
@@ -349,6 +367,7 @@ An independent endpoint is provided: `POST /api/v1/getGameList`, supporting both
- Path: `POST /api/v1/getPlayerGameRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (optional): Player username; if omitted, **no player filter** is applied (returns matching rows from the database—use with care)
@@ -396,7 +415,7 @@ This update introduces a game management table and menu to centrally manage basi
## 7. Wallet APIs
All endpoints below require the `auth-token` header.
All endpoints below require headers `api-key` + `auth-token` (`api-key` may also be sent via query/body, see §2.1).
### 7.1 Query Balance (Supported)
@@ -430,6 +449,7 @@ If the integrators wallet flow requires “return lobby URL after transfer”
- Path: `POST /api/v1/getPlayerWalletRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (optional): Player username; if omitted, **no player filter** is applied
@@ -445,6 +465,7 @@ If the integrators wallet flow requires “return lobby URL after transfer”
- Path: `POST /api/v1/getPlayerTicketRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters: Same as **7.4** (`username`, `start_create_time`, `end_create_time`, `limit`)
- Response notes:
@@ -462,7 +483,8 @@ It is recommended to configure the following fields in the integration parameter
- `provider`: `Dicey Fun`
- `provider_code`: `DF`
- `agent_id`: `5ef059938ba799aaa845e1c2e8a762bd`
- `secret`: Signature secret (shared by both parties)
- `secret`: Signature secret (shared by both parties, maps to server `.env` `API_AUTH_TOKEN_SECRET`)
- `api_key`: The `api-key` required by every `/api/v1/*` request (maps to server `.env` `API_KEY`)
- `agent_token`: `[to be filled by us]` (if an additional business-layer token is needed)
- `game_url`: Game frontend domain/URL
- `lobby_url`: Lobby URL (optional)
@@ -482,8 +504,8 @@ It is recommended to configure the following fields in the integration parameter
## 10. Integration Sequence (Recommended)
1. Platform assigns `agent_id` and `secret`
2. Third party calls `/api/v1/authToken` to obtain `authtoken`
1. Platform assigns `agent_id`, `secret` and `api_key`
2. Third party calls `/api/v1/authToken` (with header `api-key`) to obtain `authtoken`
3. Third party calls `/api/v1/getGameHall` or `/api/v1/getGameList` to obtain lobby/game info
4. Third party calls `/api/v1/getPlayerInfo` (optional, check user and balance)
5. Third party calls `/api/v1/setPlayerWallet` to credit in (if applicable)
@@ -498,7 +520,8 @@ It is recommended to configure the following fields in the integration parameter
### 11.1 Get auth-token
```bash
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}'
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}' \
--header 'api-key: {api_key}'
```
During integration testing, it is recommended to print the following values locally before sending the request to ease troubleshooting:
@@ -514,6 +537,7 @@ During integration testing, it is recommended to print the following values loca
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -526,6 +550,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -537,6 +562,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"en"
@@ -548,6 +574,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -559,6 +586,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -571,6 +599,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001"
@@ -582,6 +611,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -594,6 +624,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -606,6 +637,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletReco
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerTicketRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>为何最终抽到该奖励</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f5f7fa;
color: #1a1a2e;
line-height: 1.6;
}
header { max-width: 1100px; margin: 0 auto 16px; }
h1 { font-size: 1.5rem; margin: 0 0 8px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.95rem; margin: 0; }
.card {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.copy-hint {
max-width: 1100px;
margin: 12px auto 0;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1100px;
margin: 8px auto 0;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
max-height: 280px;
overflow: auto;
color: #303133;
}
.legend {
max-width: 1100px;
margin: 20px auto 0;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
font-size: 0.9rem;
color: #444;
}
.legend h2 { font-size: 1rem; margin: 0 0 10px; }
.legend ul { margin: 0; padding-left: 1.2em; }
.legend li { margin: 4px 0; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>为何最终抽到的是这个奖励</h1>
<p class="subtitle">业务说明:一局抽奖从开局到到账的决策顺序(仅用本项目菜单与业务用语)</p>
</header>
<p class="copy-hint">复制方式展开下方「Mermaid 源码」全选复制,粘贴到 ProcessOn / draw.io / 飞书文档等支持 Mermaid 的流程图工具;或直接用浏览器打开本页看图。</p>
<div class="card">
<pre class="mermaid">
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0</pre>
</details>
<div class="legend">
<h2>读图要点</h2>
<ul>
<li><strong>两步抽签</strong>:先抽档位 T1T5大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</li>
<li><strong>免费局与杀分局</strong>:都用杀分奖池的档位概率;一般不会出豹子大奖,也不会抽到只能组成豹子的点数 5、30。</li>
<li><strong>普通付费局</strong>:彩金池未到杀分条件时,用该玩家在「玩家管理」里的档位权重,才可能按「大奖权重」出豹子。</li>
<li><strong>玩家最终看到</strong>:色子点数、五颗骰子图案、到账平台币(普通奖 + 豹子奖)、是否获得「再来一次」免费券。</li>
</ul>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 16, nodeSpacing: 28, rankSpacing: 40 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>后台如何配置中奖逻辑</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f0f4f8;
color: #1a1a2e;
line-height: 1.55;
}
header { max-width: 1200px; margin: 0 auto 16px; }
h1 { font-size: 1.45rem; margin: 0 0 6px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.92rem; margin: 0; }
.tip {
max-width: 1200px;
margin: 0 auto 16px;
padding: 12px 16px;
background: #fff8e6;
border-left: 4px solid #e6a23c;
border-radius: 6px;
font-size: 0.9rem;
}
.card {
max-width: 1200px;
margin: 0 auto 20px;
background: #fff;
border-radius: 12px;
padding: 24px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.card h2 { font-size: 1.05rem; margin: 0 0 12px; color: #303133; }
.copy-hint {
max-width: 1200px;
margin: 0 auto 12px;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1200px;
margin: 8px auto 20px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.76rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 320px;
overflow: auto;
}
.steps {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 10px;
}
.step {
background: #fff;
border-radius: 10px;
padding: 12px 16px;
border: 1px solid #e4e7ed;
font-size: 0.9rem;
}
.step strong { color: #409eff; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>后台如何配置中奖逻辑</h1>
<p class="subtitle">按「一局真实抽奖」顺序:每个环节对应左侧菜单与页面按钮(与前台逻辑一致)</p>
</header>
<p class="tip">菜单根目录:<strong>大富翁-色子游戏</strong>。多渠道后台请先选顶部<strong>渠道</strong>,再改该渠道数据。</p>
<p class="copy-hint">复制展开「Mermaid 源码」粘贴到流程图工具;日常维护也可打开同目录 <code>dice-后台中奖逻辑配置.mmd</code></p>
<div class="card">
<h2>主流程图(抽奖环节 → 去哪点哪个按钮)</h2>
<pre class="mermaid">
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8</pre>
</details>
<div class="card">
<h2>首次搭建推荐顺序(与上图环节对应)</h2>
<pre class="mermaid">
flowchart TD
O([开始配置]) --> R1[奖励配置 · 页签「奖励索引」· 按钮「保存」]
R1 --> R2[奖励配置 · 页签「大奖权重」· 按钮「保存」]
R2 --> R3[奖励配置 · 按钮「创建奖励对照」·「确认导入」]
R3 --> W[色子奖励权重 · 按钮「权重配比」· 按钮「提交」]
W --> P1[彩金池配置 · 行内「编辑」default / killScore ·「提交」]
P1 --> P2[彩金池配置 ·「查看当前彩金池」·「保存安全线」]
P2 --> PL[玩家管理 · 行内「编辑」· 档位权重 ·「提交」]
PL --> T{要仿真?}
T -->|是| Test[色子奖励权重 ·「一键测试权重」·「开始测试」]
Test --> Imp[权重测试记录 ·「查看详情」·「导入到当前配置」·「确认导入」]
T -->|否| Live([上线])
Imp --> Live
style O fill:#e8f4fc
style Live fill:#e8fce8
</pre>
</div>
<div class="steps">
<div class="step"><strong>档位含义</strong>T1 大奖 · T2 小赚 · T3 抽水 · T4 惩罚 · T5 再来一次(由「奖励索引」结算金额规则决定,见页内说明)。</div>
<div class="step"><strong>改「奖励索引」后</strong>:必须再点「创建奖励对照」→「确认导入」,否则抽奖仍用旧对照表。</div>
<div class="step"><strong>核对真实对局</strong>:大富翁-色子游戏 → 玩家抽奖记录(看奖励档位、色子点数、摇色子中奖平台币、中大奖平台币、底注、方向)。</div>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 14, nodeSpacing: 24, rankSpacing: 36 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8

View File

@@ -0,0 +1,49 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\controller\system;
use plugin\saiadmin\app\logic\system\SystemAdminGuideLogic;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 后台操作指南控制器
*/
class SystemAdminGuideController extends BaseController
{
private SystemAdminGuideLogic $guideLogic;
public function __construct()
{
$this->guideLogic = new SystemAdminGuideLogic();
parent::__construct();
}
/**
* 读取后台操作指南 Markdown 内容
*/
#[Permission('后台操作指南读取', 'system:admin_guide:index:read')]
public function read(Request $request): Response
{
$data = $this->guideLogic->read();
return $this->success($data);
}
/**
* 保存后台操作指南 Markdown 内容
*/
#[Permission('后台操作指南保存', 'system:admin_guide:index:save')]
public function save(Request $request): Response
{
$content = $request->post('content', '');
if (! is_string($content)) {
return $this->fail('invalid content');
}
$data = $this->guideLogic->save($content);
return $this->success($data, 'save success');
}
}

View File

@@ -107,11 +107,12 @@ class SystemDeptController extends BaseController
#[Permission('渠道数据删除','core:dept:destroy')]
public function destroy(Request $request) : Response
{
$ids = $request->post('ids', '');
// DELETE + JSON body 须用 input()post() 仅表单 POST 有效(与 SystemUserController 一致)
$ids = $request->input('ids', '');
if (empty($ids)) {
return $this->fail('please select data to delete');
}
$deleteTables = $request->post('delete_tables', []);
$deleteTables = $request->input('delete_tables', []);
if (!is_array($deleteTables)) {
$deleteTables = [];
}

View File

@@ -32,6 +32,7 @@ class SystemLogController extends BaseController
['username', ''],
['status', ''],
['ip', ''],
['dept_id', ''],
]);
$logic = new SystemLoginLogLogic();
$query = $logic->search($where);
@@ -71,6 +72,7 @@ class SystemLogController extends BaseController
['service_name', ''],
['router', ''],
['ip', ''],
['dept_id', ''],
]);
$logic = new SystemOperLogLogic();
$logic->init($this->adminInfo);

View File

@@ -9,6 +9,7 @@ namespace plugin\saiadmin\app\event;
use plugin\saiadmin\app\cache\ReflectionCache;
use plugin\saiadmin\app\model\system\SystemLoginLog;
use plugin\saiadmin\app\model\system\SystemOperLog;
use plugin\saiadmin\app\model\system\SystemUser as SystemUserModel;
class SystemUser
{
@@ -32,6 +33,10 @@ class SystemUser
if (isset($item['admin_id'])) {
$data['created_by'] = $item['admin_id'];
$data['updated_by'] = $item['admin_id'];
$deptId = SystemUserModel::where('id', $item['admin_id'])->value('dept_id');
if ($deptId !== null && $deptId !== '' && $deptId > 0) {
$data['dept_id'] = $deptId;
}
}
SystemLoginLog::create($data);
}
@@ -49,6 +54,9 @@ class SystemUser
return false;
}
$info = getCurrentInfo();
if (!$info) {
return false;
}
$ip = $request->getRealIp();
$module = $request->plugin;
$rule = trim($request->uri());
@@ -60,6 +68,14 @@ class SystemUser
$data['ip'] = $ip;
$data['ip_location'] = self::getIpLocation($ip);
$data['request_data'] = $this->filterParams($request->all());
if (isset($info['dept_id']) && $info['dept_id'] > 0) {
$data['dept_id'] = $info['dept_id'];
} elseif (isset($info['id'])) {
$deptId = SystemUserModel::where('id', $info['id'])->value('dept_id');
if ($deptId !== null && $deptId !== '' && $deptId > 0) {
$data['dept_id'] = $deptId;
}
}
SystemOperLog::create($data);
return true;
}

View File

@@ -0,0 +1,72 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\logic\system;
use plugin\saiadmin\exception\ApiException;
/**
* 后台操作指南逻辑(读写 server/docs/ADMIN_GUIDE.md
*/
class SystemAdminGuideLogic
{
private const GUIDE_FILENAME = 'ADMIN_GUIDE.md';
/**
* 获取指南 Markdown 文件绝对路径
*/
public function getFilePath(): string
{
return base_path() . DIRECTORY_SEPARATOR . 'docs' . DIRECTORY_SEPARATOR . self::GUIDE_FILENAME;
}
/**
* 读取指南内容
* @return array{content: string, file_path: string, update_time: string|null}
*/
public function read(): array
{
$filePath = $this->getFilePath();
if (! is_file($filePath)) {
throw new ApiException('admin guide file not found');
}
$content = file_get_contents($filePath);
if ($content === false) {
throw new ApiException('failed to read admin guide file');
}
return [
'content' => $content,
'file_path' => 'docs/' . self::GUIDE_FILENAME,
'update_time' => date('Y-m-d H:i:s', filemtime($filePath)),
];
}
/**
* 保存指南内容到 Markdown 文件
* @param string $content
* @return array{content: string, file_path: string, update_time: string}
*/
public function save(string $content): array
{
$filePath = $this->getFilePath();
$dir = dirname($filePath);
if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new ApiException('failed to create docs directory');
}
$result = file_put_contents($filePath, $content, LOCK_EX);
if ($result === false) {
throw new ApiException('failed to save admin guide file');
}
clearstatcache(true, $filePath);
return [
'content' => $content,
'file_path' => 'docs/' . self::GUIDE_FILENAME,
'update_time' => date('Y-m-d H:i:s', filemtime($filePath)),
];
}
}

View File

@@ -20,6 +20,10 @@ use support\think\Db;
*/
class SystemRoleLogic extends BaseLogic
{
protected string $orderField = 'level';
protected string $orderType = 'desc';
public function __construct()
{
$this->model = new SystemRole();
@@ -110,7 +114,6 @@ class SystemRoleLogic extends BaseLogic
$query->where('level', '<', $maxLevel);
}
$query->where('id', '<>', SystemRoleChannelService::SUPER_ADMIN_ROLE_ID);
$query->order('sort', 'desc');
return $this->getAll($query);
}

View File

@@ -23,6 +23,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $status 登录状态
* @property $message 提示消息
* @property $login_time 登录时间
* @property $dept_id 所属渠道
* @property $remark 备注
* @property $created_by 创建者
* @property $updated_by 更新者
@@ -47,4 +48,14 @@ class SystemLoginLog extends BaseModel
$query->whereTime('login_time', 'between', $value);
}
/**
* 渠道搜索
*/
public function searchDeptIdAttr($query, $value): void
{
if ($value !== '' && $value !== null && $value > 0) {
$query->where('dept_id', '=', $value);
}
}
}

View File

@@ -23,6 +23,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $ip 请求IP地址
* @property $ip_location IP所属地
* @property $request_data 请求数据
* @property $dept_id 所属渠道
* @property $remark 备注
* @property $created_by 创建者
* @property $updated_by 更新者
@@ -39,4 +40,14 @@ class SystemOperLog extends BaseModel
protected $table = 'sa_system_oper_log';
/**
* 渠道搜索
*/
public function searchDeptIdAttr($query, $value): void
{
if ($value !== '' && $value !== null && $value > 0) {
$query->where('dept_id', '=', $value);
}
}
}

View File

@@ -189,12 +189,16 @@ class SystemRoleChannelService
if ($this->tableHasColumn('sa_system_role', 'dept_id')) {
$query->where('dept_id', 0);
}
$rows = $query->order('sort', 'desc')->select()->toArray();
if (count($rows) === count($codes)) {
return $rows;
$rows = $query->select()->toArray();
if (empty($rows)) {
return [];
}
// 按配置顺序返回,缺失的 code 跳过
if (count($rows) === count($codes)) {
return $this->sortRolesByLevelDesc($rows);
}
// 按配置 code 补齐,缺失的 code 跳过,最终按角色级别从大到小排序
$byCode = [];
foreach ($rows as $row) {
$byCode[(string) ($row['code'] ?? '')] = $row;
@@ -205,7 +209,25 @@ class SystemRoleChannelService
$ordered[] = $byCode[$code];
}
}
return $ordered;
return $this->sortRolesByLevelDesc($ordered);
}
/**
* 按角色级别从大到小排序(同级别按 sort 降序)
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function sortRolesByLevelDesc(array $rows): array
{
usort($rows, static function (array $a, array $b): int {
$levelCompare = (int) ($b['level'] ?? 0) <=> (int) ($a['level'] ?? 0);
if ($levelCompare !== 0) {
return $levelCompare;
}
return (int) ($b['sort'] ?? 0) <=> (int) ($a['sort'] ?? 0);
});
return $rows;
}
private function insertRoleFromTemplate(array $template, int $deptId): int

View File

@@ -81,6 +81,10 @@ Route::group('/core', function () {
Route::delete("/logs/deleteOperLog", [\plugin\saiadmin\app\controller\system\SystemLogController::class, 'deleteOperLog']);
fastRoute("email", \plugin\saiadmin\app\controller\system\SystemMailController::class);
// 后台操作指南
Route::get("/adminGuide/read", [\plugin\saiadmin\app\controller\system\SystemAdminGuideController::class, 'read']);
Route::post("/adminGuide/save", [\plugin\saiadmin\app\controller\system\SystemAdminGuideController::class, 'save']);
// 服务管理
Route::get("/server/monitor", [\plugin\saiadmin\app\controller\system\SystemServerController::class, 'monitor']);
Route::get("/server/cache", [\plugin\saiadmin\app\controller\system\SystemServerController::class, 'cache']);
@@ -115,7 +119,9 @@ Route::group('/core', function () {
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdateWeights', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdateWeights']);
Route::post('/dice/reward_config/DiceRewardConfig/saveBigwinWeightsByGrid', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'saveBigwinWeightsByGrid']);
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdate', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdate']);
Route::post('/dice/reward_config/DiceRewardConfig/generateIndexByRules', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'generateIndexByRules']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReference', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReference']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReferencePreview', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReferencePreview']);
Route::post('/dice/reward_config/DiceRewardConfig/runWeightTest', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'runWeightTest']);
fastRoute('dice/game/DiceGame', \app\dice\controller\game\DiceGameController::class);
fastRoute('dice/ante_config/DiceAnteConfig', \app\dice\controller\ante_config\DiceAnteConfigController::class);

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
return [
'ACCOUNT_DISABLED' => 'Account is disabled and cannot log in',
'API_AUTH_TOKEN_SECRET is not configured' => 'API_AUTH_TOKEN_SECRET is not configured',
'API_KEY is not configured' => 'API_KEY is not configured',
'Please provide api-key' => 'Please provide api-key',
'Invalid api-key' => 'Invalid api-key',
'AUTH_TOKEN_EXPIRED' => 'auth-token expired',
'AUTH_TOKEN_FORMAT_INVALID' => 'auth-token format invalid',
'AUTH_TOKEN_INVALID' => 'auth-token invalid',

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
return [
'ACCOUNT_DISABLED' => '账号已被禁用,无法登录',
'API_AUTH_TOKEN_SECRET is not configured' => '服务端未配置 API_AUTH_TOKEN_SECRET',
'API_KEY is not configured' => '服务端未配置 API_KEY',
'Please provide api-key' => '请携带 api-key',
'Invalid api-key' => 'api-key 无效',
'AUTH_TOKEN_EXPIRED' => 'auth-token 已过期',
'AUTH_TOKEN_FORMAT_INVALID' => 'auth-token 格式无效',
'AUTH_TOKEN_INVALID' => 'auth-token 无效',

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
$options = getopt('', ['agent_id:', 'secret:', 'time::']);
$agentId = $options['agent_id'] ?? '5ef059938ba799aaa845e1c2e8a762bd';
$agentId = $options['agent_id'] ?? '76dc611d6ebaafc66cc0879c71b5db5c';
$secret = $options['secret'] ?? 'xF75oK91TQj13s0UmNIr1NBWMWGfflNO';
$time = $options['time'] ?? (string) time();