Compare commits
97 Commits
fd324f2882
...
master-gam
| Author | SHA1 | Date | |
|---|---|---|---|
| d336831fad | |||
| 75e91fee13 | |||
| 9a3f3b747f | |||
| f6197a9af5 | |||
| 16a7ef7413 | |||
| eb4c17cadf | |||
| 7ab3db121c | |||
| 1cdd597879 | |||
| 15b9313c07 | |||
| 1f9b2c2663 | |||
| e65c3474bd | |||
| 28d0100d5a | |||
| db0ddb1a2a | |||
| 54fb283b8d | |||
| 7c1307e355 | |||
| 2a5132d70f | |||
| 4bd332a6ff | |||
| 4324c19d30 | |||
| 1df1396b44 | |||
| 8e2de58765 | |||
| d8673fb2c5 | |||
| f286fcc56f | |||
| 0aa0809ad1 | |||
| be781504db | |||
| 9be9e2666b | |||
| f3677eb0e3 | |||
| eba80b1bf4 | |||
| 2140b37dfd | |||
| 4343a3e08c | |||
| 1ba6ddd24e | |||
| 3e5858bcce | |||
| c49db7ca93 | |||
| 99df6b378a | |||
| c3ac33ec3d | |||
| 6b20b074a9 | |||
| 4de4d4a721 | |||
| ab47440016 | |||
| 37d8459609 | |||
| cd19c499e2 | |||
| ad63ced9d0 | |||
| f3ed6848c7 | |||
| b93940eaee | |||
| a96aa0fb41 | |||
| 8f5ba977a4 | |||
| a7c2a29764 | |||
| 4c69a8c77f | |||
| 6d3711e1db | |||
| 98b2696f89 | |||
| e163090bc2 | |||
| bb5ef82d49 | |||
| bdd66f7bd9 | |||
| 6c4e935b6c | |||
| 5d33b13c6f | |||
| 5f8f5aa4ca | |||
| 827da2058f | |||
| 365e643072 | |||
| 0122a0301f | |||
| 53eefd901d | |||
| 66c002f522 | |||
| 8c3f3c4e2c | |||
| 38dec9d7a2 | |||
| d8d03662ed | |||
| 5cbb7ea485 | |||
| 1b26539ac5 | |||
| 24f30b5ef2 | |||
| 40247af0d6 | |||
| 3a2af4d7c2 | |||
| ae7af24565 | |||
| 7edc3ec010 | |||
| bad6641eeb | |||
| c10908b4da | |||
| 21caa6d548 | |||
| 13e7f3439a | |||
| d1dceb44d8 | |||
| 1b8d947f97 | |||
| b9e4d806f7 | |||
| 91229f4477 | |||
| 575aa279bd | |||
| 90ec085cd5 | |||
| 932a433613 | |||
| c7fc754573 | |||
| e8c2b9d345 | |||
| f136e1c1ca | |||
| e47857fcf2 | |||
| ba4ece422a | |||
| 687257adaa | |||
| 4ea83d2818 | |||
| a033bf47b6 | |||
| 9bc439ea5e | |||
| 85133ee92b | |||
| aefb8b16c8 | |||
| d7375222ce | |||
| 26f4151339 | |||
| 5ab9172b31 | |||
| d9b574676b | |||
| 203e478b65 | |||
| 2ce128e63a |
44
.env-example
44
.env-example
@@ -38,12 +38,46 @@ GAME_HOT_CACHE_QUEUE_CONSUMER_TICK = 0.1
|
||||
GAME_HOT_CACHE_QUEUE_CONSUMER_BATCH = 80
|
||||
|
||||
# 移动端接口鉴权(/api/v1/authToken)
|
||||
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a
|
||||
AUTH_TOKEN_SECRET =
|
||||
|
||||
# Webman Push:浏览器实际连接的 WS 基址(不含 /app/)。HTTPS 后台须用 wss://,与 Nginx 反代 3131 一致
|
||||
# 示例:PUSH_WEBSOCKET_CLIENT_URL = wss://zihua-api.yuliao666.top
|
||||
PUSH_WEBSOCKET_CLIENT_URL =
|
||||
# H5/后台联调共用:WebSocket 连接地址(建议带 /ws/ 路径,进程 gameWebSocketServer 默认监听 3131)
|
||||
# HTTPS 页面必须用 wss://;生产勿填 127.0.0.1(外网浏览器无法连接,服务端会按请求 Host 推导 ws(s)://域名/ws/)
|
||||
# Nginx 需将 location /ws/ 反代到 websocket://127.0.0.1:3131
|
||||
# 示例:H5_WEBSOCKET_URL = wss://zihua-api.h55555game.top/ws/
|
||||
H5_WEBSOCKET_URL = wss://zihua-api.h55555game.top/ws/
|
||||
|
||||
# H5 前端游戏页地址(模拟充值完成后「返回游戏」在当前窗口跳转;无尾斜杠)
|
||||
# 示例:H5_GAME_URL = http://127.0.0.1:5173
|
||||
H5_GAME_URL = https://zihua-game.h55555game.top/
|
||||
|
||||
# 充值支付渠道:在代码注册表之外追加渠道(JSON 数组,每项含 code / name / name_en / sort)
|
||||
# 示例:DEPOSIT_CHANNELS_REGISTRY_JSON = [{"code":"bank_a","name":"银行转账A","name_en":"Bank A","sort":20}]
|
||||
# 当前代码注册表仅内置 ddpay;一般无需再追加。示例:DEPOSIT_CHANNELS_REGISTRY_JSON =
|
||||
DEPOSIT_CHANNELS_REGISTRY_JSON =
|
||||
|
||||
# 模拟支付(channel_code=mock):未接入 DDPay 时用于联调充值/提现;生产请设为 0
|
||||
FINANCE_MOCK_PAY_ENABLED = 1
|
||||
|
||||
# 充值待支付有效秒数(超时自动失败、支付链接倒计时;mock/ddpay 全渠道统一,默认 60)
|
||||
DEPOSIT_PENDING_EXPIRE_SECONDS = 60
|
||||
|
||||
# ========== DDPay Payment Gateway(文档:docs/DDPay Payment Gateway_v1.1.3_zh.md)==========
|
||||
# 公网 HTTPS 根地址,无尾斜杠;用于拼接入金/出金 callback_url。生产必填;不配则从请求 Host 推导(本地可能为 http)。
|
||||
DDPAY_PUBLIC_BASE_URL =
|
||||
|
||||
# 商户标识(文档 client_id;identifier 为项目对接扩展字段,按 DDPay 分配填写)
|
||||
DDPAY_CLIENT_ID =
|
||||
DDPAY_IDENTIFIER =
|
||||
# API 签名密钥(文档 API Secret,勿提交到版本库)
|
||||
DDPAY_API_SECRET =
|
||||
|
||||
# 接口 Endpoint(由 DDPay 商户支持提供;HTTPS POST application/json)
|
||||
DDPAY_DEPOSIT_INIT_URL =
|
||||
DDPAY_DEPOSIT_STATUS_URL =
|
||||
DDPAY_PAYOUT_INIT_URL =
|
||||
DDPAY_PAYOUT_STATUS_URL =
|
||||
|
||||
# 模拟充值:前端 public/mock-deposit.html 所在站点根地址(无尾斜杠)
|
||||
# 开发:Vite 一般为 http://127.0.0.1:5173;生产:与后台同域则填 DDPAY_PUBLIC_BASE_URL 或留空(自动用 API 公网根)
|
||||
# MOCK_DEPOSIT_HTML_BASE = http://127.0.0.1:5173
|
||||
# 模拟充值链接签名密钥(不配则回退 AUTH_TOKEN_SECRET)
|
||||
# FINANCE_MOCK_PAY_LINK_SECRET =
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
# 通过 Git 部署项目至线上时建议删除的忽略规则
|
||||
/vendor
|
||||
/modules
|
||||
/public/*.lock
|
||||
/public/index.html
|
||||
#/public/*.lock
|
||||
#/public/index.html
|
||||
/public/assets
|
||||
|
||||
# 通过 Git 部署项目至线上时可以考虑删除的忽略规则
|
||||
@@ -41,6 +41,9 @@ pnpm-debug.log*
|
||||
!/web/.vscode
|
||||
|
||||
# Other
|
||||
# 本地商户开放 API 临时签名脚本(含密钥,勿提交)
|
||||
/scripts/tmp_merchant_open_api_sign.php
|
||||
/scripts/merchant_open_api_env.php
|
||||
*.css.map
|
||||
*.local
|
||||
!.gitkeep
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\admin\controller;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\AdminChannelScopeService;
|
||||
use app\common\service\ChannelSettlementService;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
@@ -17,7 +18,16 @@ class Channel extends Backend
|
||||
/**
|
||||
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
|
||||
*/
|
||||
protected array $noNeedPermission = ['manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare', 'batchSettlePending', 'settleStats'];
|
||||
protected array $noNeedPermission = [
|
||||
'manualSettlePreview',
|
||||
'channelAdminShareList',
|
||||
'saveChannelAdminShare',
|
||||
'settleStats',
|
||||
'dividendRecordList',
|
||||
'directBetRecordList',
|
||||
'companyBetRecordList',
|
||||
'settlementBetRecordList',
|
||||
];
|
||||
|
||||
/**
|
||||
* Channel模型对象
|
||||
@@ -34,15 +44,36 @@ class Channel extends Backend
|
||||
|
||||
protected bool $modelSceneValidate = true;
|
||||
|
||||
private array $currentChannelIds = [];
|
||||
|
||||
protected function initController(WebmanRequest $request): ?Response
|
||||
{
|
||||
$this->model = new \app\common\model\Channel();
|
||||
$this->currentChannelIds = $this->getCurrentChannelIds();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/统计按可读渠道收窄;null 表示不限制
|
||||
*
|
||||
* @param array<int, array<int|string, mixed>> $where
|
||||
*/
|
||||
private function appendReadableChannelWhere(array &$where, array $alias, string $tableKey = 'channel'): void
|
||||
{
|
||||
$scope = $this->readableChannelIds();
|
||||
if ($scope !== null) {
|
||||
$where[] = [$alias[$tableKey] . '.id', 'in', $scope];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \think\db\BaseQuery|\think\db\Query $query
|
||||
*/
|
||||
private function applyReadableChannelScope($query, string $column): void
|
||||
{
|
||||
$scope = $this->readableChannelIds();
|
||||
if ($scope !== null) {
|
||||
$query->where($column, 'in', $scope);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
|
||||
*/
|
||||
@@ -54,63 +85,18 @@ class Channel extends Backend
|
||||
$query = Db::name('channel')
|
||||
->field(['id', 'name'])
|
||||
->order('id', 'asc');
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
$query = $query->where('id', 'in', $this->currentChannelIds ?: [0]);
|
||||
}
|
||||
$this->applyReadableChannelScope($query, 'id');
|
||||
$channels = $query->select()->toArray();
|
||||
|
||||
$groupChildrenCache = [];
|
||||
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
|
||||
if ($groupId === null || $groupId === '') return [];
|
||||
if (array_key_exists($groupId, $groupChildrenCache)) return $groupChildrenCache[$groupId];
|
||||
$children = Db::name('admin_group')
|
||||
->where('pid', $groupId)
|
||||
->where('status', 1)
|
||||
->column('id');
|
||||
$all = [];
|
||||
foreach ($children as $cid) {
|
||||
$all[] = $cid;
|
||||
foreach ($getGroupChildren($cid) as $cc) {
|
||||
$all[] = $cc;
|
||||
}
|
||||
}
|
||||
$groupChildrenCache[$groupId] = $all;
|
||||
return $all;
|
||||
};
|
||||
|
||||
$tree = [];
|
||||
foreach ($channels as $ch) {
|
||||
$channelId = (int) ($ch['id'] ?? 0);
|
||||
$rootGroupIds = Db::name('admin_group')
|
||||
$admins = Db::name('admin')
|
||||
->field(['id', 'username'])
|
||||
->where('channel_id', $channelId)
|
||||
->where('pid', 0)
|
||||
->where('status', 1)
|
||||
->column('id');
|
||||
$groupIds = [];
|
||||
foreach ($rootGroupIds as $rootId) {
|
||||
$groupIds[] = $rootId;
|
||||
foreach ($getGroupChildren($rootId) as $gid) {
|
||||
$groupIds[] = $gid;
|
||||
}
|
||||
}
|
||||
|
||||
$adminIds = [];
|
||||
if ($groupIds) {
|
||||
$adminIds = Db::name('admin_group_access')
|
||||
->where('group_id', 'in', array_unique($groupIds))
|
||||
->column('uid');
|
||||
}
|
||||
$adminIds = array_values(array_unique($adminIds));
|
||||
|
||||
$admins = [];
|
||||
if ($adminIds) {
|
||||
$admins = Db::name('admin')
|
||||
->field(['id', 'username'])
|
||||
->where('id', 'in', $adminIds)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$children = [];
|
||||
foreach ($admins as $a) {
|
||||
@@ -204,7 +190,7 @@ class Channel extends Backend
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) {
|
||||
if (!$this->assertChannelVisible((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
@@ -214,6 +200,9 @@ class Channel extends Backend
|
||||
}
|
||||
|
||||
if ($this->request && $this->request->method() === 'POST') {
|
||||
if (!$this->assertChannelWritable((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$data = $this->request->post();
|
||||
if (!$data) {
|
||||
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||
@@ -263,6 +252,21 @@ class Channel extends Backend
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除:须在可写渠道范围内
|
||||
*/
|
||||
protected function _del(): Response
|
||||
{
|
||||
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
|
||||
$ids = is_array($ids) ? $ids : [];
|
||||
foreach ($ids as $id) {
|
||||
if (!$this->assertChannelWritable((int) $id)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
}
|
||||
return parent::_del();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
@@ -274,9 +278,7 @@ class Channel extends Backend
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
$where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]];
|
||||
}
|
||||
$this->appendReadableChannelWhere($where, $alias);
|
||||
$res = $this->model
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
@@ -312,12 +314,18 @@ class Channel extends Backend
|
||||
->where('status', 2)
|
||||
->group('channel_id')
|
||||
->column('SUM(total_amount - win_amount - jackpot_extra_amount) AS p', 'channel_id');
|
||||
$directBetMap = Db::name('game_play_record')
|
||||
->where('channel_id', 'in', $channelIds)
|
||||
->group('channel_id')
|
||||
->column('SUM(total_amount) AS s', 'channel_id');
|
||||
foreach ($items as $k => $item) {
|
||||
$cid = intval($item['id'] ?? 0);
|
||||
if ($cid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$items[$k]['user_count'] = intval($userCountMap[$cid] ?? 0);
|
||||
$directBet = strval($directBetMap[$cid] ?? '0.00');
|
||||
$items[$k]['direct_bet_amount'] = bcadd($directBet, '0', 2);
|
||||
$profit = strval($profitMap[$cid] ?? '0.00');
|
||||
$items[$k]['profit_amount'] = bcadd($profit, '0', 2);
|
||||
if (!isset($items[$k]['total_profit_amount']) || $items[$k]['total_profit_amount'] === null || $items[$k]['total_profit_amount'] === '') {
|
||||
@@ -345,7 +353,7 @@ class Channel extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->check('channel/manualSettle')) {
|
||||
if (!$this->canManualSettle()) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
@@ -357,7 +365,7 @@ class Channel extends Backend
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
|
||||
if (!$this->assertChannelVisible((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
@@ -389,7 +397,7 @@ class Channel extends Backend
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
|
||||
if (!$this->assertChannelVisible((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
@@ -458,9 +466,27 @@ class Channel extends Backend
|
||||
if ($channelId <= 0 || $adminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$accessRows = Db::name('admin_group_access')
|
||||
->field(['uid', 'group_id'])
|
||||
->where('uid', 'in', $adminIds)
|
||||
->order('uid', 'asc')
|
||||
->order('group_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$groupIds = [];
|
||||
foreach ($accessRows as $accessRow) {
|
||||
$gid = (int) ($accessRow['group_id'] ?? 0);
|
||||
if ($gid > 0) {
|
||||
$groupIds[] = $gid;
|
||||
}
|
||||
}
|
||||
$groupIds = array_values(array_unique($groupIds));
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
$groupRows = Db::name('admin_group')
|
||||
->field(['id', 'pid', 'name'])
|
||||
->where('channel_id', $channelId)
|
||||
->where('id', 'in', $groupIds)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -501,13 +527,6 @@ class Channel extends Backend
|
||||
return $depth;
|
||||
};
|
||||
|
||||
$accessRows = Db::name('admin_group_access')
|
||||
->field(['uid', 'group_id'])
|
||||
->where('uid', 'in', $adminIds)
|
||||
->order('uid', 'asc')
|
||||
->order('group_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$metaMap = [];
|
||||
foreach ($accessRows as $accessRow) {
|
||||
$uid = (int) ($accessRow['uid'] ?? 0);
|
||||
@@ -569,12 +588,12 @@ class Channel extends Backend
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
|
||||
if (!$this->assertChannelWritable((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$rowsRaw = $request->post('list', []);
|
||||
if (!is_array($rowsRaw) || $rowsRaw === []) {
|
||||
return $this->error('请至少配置一条分配记录');
|
||||
return $this->error(__('Please configure at least one share record'));
|
||||
}
|
||||
|
||||
$adminIds = Db::name('admin')
|
||||
@@ -585,7 +604,7 @@ class Channel extends Backend
|
||||
$adminIdSet[(int) $adminId] = true;
|
||||
}
|
||||
if ($adminIdSet === []) {
|
||||
return $this->error('该渠道下暂无管理员,无法配置分配比例');
|
||||
return $this->error(__('There are no admins under this channel; cannot configure share ratios'));
|
||||
}
|
||||
|
||||
$enabledSum = '0.00';
|
||||
@@ -602,7 +621,7 @@ class Channel extends Backend
|
||||
$shareRaw = $line['share_rate'] ?? null;
|
||||
$shareRate = self::normalizeAmountScale($shareRaw === null ? '0' : (string) $shareRaw, 2);
|
||||
if (bccomp($shareRate, '0', 2) < 0 || bccomp($shareRate, '100', 2) > 0) {
|
||||
return $this->error('分配比例必须在0到100之间');
|
||||
return $this->error(__('Share ratio must be between 0 and 100'));
|
||||
}
|
||||
if ($status === 1) {
|
||||
$enabledSum = bcadd($enabledSum, $shareRate, 2);
|
||||
@@ -617,10 +636,10 @@ class Channel extends Backend
|
||||
];
|
||||
}
|
||||
if ($insertRows === []) {
|
||||
return $this->error('请至少配置一条有效分配记录');
|
||||
return $this->error(__('Please configure at least one valid share record'));
|
||||
}
|
||||
if (bccomp($enabledSum, '100.00', 2) !== 0) {
|
||||
return $this->error('启用的分配比例总和必须等于100');
|
||||
return $this->error(__('Sum of enabled share ratios must equal 100'));
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
@@ -633,7 +652,7 @@ class Channel extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('分配比例保存成功');
|
||||
return $this->success(__('Share ratios saved successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,8 +664,8 @@ class Channel extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
return $this->error('仅超管可执行结算,结算后系统会自动发放至管理员钱包');
|
||||
if (!$this->canManualSettle()) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$id = (int) ($request->post('id', $request->get('id', 0)));
|
||||
@@ -657,21 +676,25 @@ class Channel extends Backend
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
|
||||
if (!$this->assertChannelVisible((int) $row['id'])) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$remark = trim((string) $request->post('remark', ''));
|
||||
|
||||
$res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false);
|
||||
if (($res['ok'] ?? false) !== true) {
|
||||
return $this->error((string) ($res['msg'] ?? '结算失败'));
|
||||
$handlingFeeByAdmin = $this->parseCommissionSplitHandlingFees($request->post('commission_split'));
|
||||
if ($handlingFeeByAdmin === false) {
|
||||
return $this->error(__('Settlement handling fee rate must be between 0 and 100'));
|
||||
}
|
||||
return $this->success('超管结算完成,已按分配比例自动发放给管理员');
|
||||
|
||||
$res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false, $handlingFeeByAdmin);
|
||||
if (($res['ok'] ?? false) !== true) {
|
||||
return $this->error((string) ($res['msg'] ?? __('Settlement failed')));
|
||||
}
|
||||
return $this->success(__('Settlement completed; commissions paid automatically by share ratios'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管批量结算全部待结算渠道(可作为“提前结算”入口)
|
||||
* 批量结算待结算渠道(需 channel/batchSettlePending;范围=当前账号可写渠道)
|
||||
*/
|
||||
public function batchSettlePending(WebmanRequest $request): Response
|
||||
{
|
||||
@@ -679,12 +702,16 @@ class Channel extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
if (!$this->canBatchSettle()) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$scope = AdminChannelScopeService::writableChannelIds($this->auth);
|
||||
if ($scope !== null && $scope === []) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
// 批量按钮语义:手动触发“待结算渠道”结算,不受结算周期到点限制。
|
||||
$res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false);
|
||||
return $this->success('批量结算完成', $res);
|
||||
$res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false, $scope);
|
||||
return $this->success(__('Batch settlement completed'), $res);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -697,9 +724,7 @@ class Channel extends Backend
|
||||
return $response;
|
||||
}
|
||||
$query = Db::name('channel');
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
$query->where('id', 'in', $this->currentChannelIds ?: [0]);
|
||||
}
|
||||
$this->applyReadableChannelScope($query, 'id');
|
||||
$rows = $query->field(['id', 'status', 'carryover_balance'])->select()->toArray();
|
||||
$total = count($rows);
|
||||
$enabled = 0;
|
||||
@@ -707,7 +732,12 @@ class Channel extends Backend
|
||||
$carryoverPositiveCount = 0;
|
||||
$carryoverTotal = '0.00';
|
||||
$carryoverPositiveTotal = '0.00';
|
||||
$channelIdList = [];
|
||||
foreach ($rows as $row) {
|
||||
$cid = intval($row['id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$channelIdList[] = $cid;
|
||||
}
|
||||
$status = intval($row['status'] ?? 0);
|
||||
if ($status === 1) {
|
||||
$enabled++;
|
||||
@@ -721,6 +751,22 @@ class Channel extends Backend
|
||||
$carryoverPositiveTotal = bcadd($carryoverPositiveTotal, $carry, 2);
|
||||
}
|
||||
}
|
||||
$paidDividendTotal = '0.00';
|
||||
$companyTotalBetAmount = '0.00';
|
||||
if ($channelIdList !== []) {
|
||||
$paidRow = Db::name('agent_commission_record')
|
||||
->where('channel_id', 'in', $channelIdList)
|
||||
->where('status', 1)
|
||||
->field('SUM(commission_amount) AS s')
|
||||
->find();
|
||||
$paidDividendTotal = bcadd(strval(is_array($paidRow) ? ($paidRow['s'] ?? '0') : '0'), '0', 2);
|
||||
|
||||
$companyBetRow = Db::name('game_play_record')
|
||||
->where('channel_id', 'in', $channelIdList)
|
||||
->field('SUM(total_amount) AS s')
|
||||
->find();
|
||||
$companyTotalBetAmount = bcadd(strval(is_array($companyBetRow) ? ($companyBetRow['s'] ?? '0') : '0'), '0', 2);
|
||||
}
|
||||
return $this->success('', [
|
||||
'channel_total' => $total,
|
||||
'enabled_count' => $enabled,
|
||||
@@ -728,9 +774,450 @@ class Channel extends Backend
|
||||
'carryover_positive_count' => $carryoverPositiveCount,
|
||||
'carryover_total' => $carryoverTotal,
|
||||
'carryover_positive_total' => $carryoverPositiveTotal,
|
||||
'paid_dividend_total' => $paidDividendTotal,
|
||||
'company_total_bet_amount' => $companyTotalBetAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已分红记录列表(顶部统计卡片点击)
|
||||
*/
|
||||
public function dividendRecordList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->check('channel/index')) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
[$page, $limit] = $this->parseListPageParams($request);
|
||||
$channelId = (int) ($request->get('channel_id', 0));
|
||||
|
||||
$query = Db::name('agent_commission_record')->alias('acr')
|
||||
->leftJoin('agent_settlement_period asp', 'acr.settlement_period_id = asp.id')
|
||||
->leftJoin('channel c', 'acr.channel_id = c.id')
|
||||
->leftJoin('admin a', 'acr.admin_id = a.id')
|
||||
->where('acr.status', 1);
|
||||
$this->applyReadableChannelScope($query, 'acr.channel_id');
|
||||
if ($channelId > 0) {
|
||||
if (!$this->assertChannelAccessible($channelId)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$query->where('acr.channel_id', $channelId);
|
||||
}
|
||||
|
||||
$total = (int) $query->count('acr.id');
|
||||
$list = $query
|
||||
->field([
|
||||
'acr.id',
|
||||
'acr.channel_id',
|
||||
'acr.admin_id',
|
||||
'acr.commission_amount',
|
||||
'acr.settled_at',
|
||||
'acr.remark',
|
||||
'asp.settlement_no',
|
||||
'asp.period_start_at',
|
||||
'asp.period_end_at',
|
||||
'c.name as channel_name',
|
||||
'a.username as admin_username',
|
||||
])
|
||||
->order('acr.settled_at', 'desc')
|
||||
->order('acr.id', 'desc')
|
||||
->page($page, $limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($list as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$startTs = intval($row['period_start_at'] ?? 0);
|
||||
$endTs = intval($row['period_end_at'] ?? 0);
|
||||
$list[$idx]['period_start_at'] = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : '';
|
||||
$list[$idx]['period_end_at'] = $endTs > 0 ? date('Y-m-d H:i:s', $endTs) : '';
|
||||
$settledTs = intval($row['settled_at'] ?? 0);
|
||||
$list[$idx]['settled_at'] = $settledTs > 0 ? date('Y-m-d H:i:s', $settledTs) : '';
|
||||
$list[$idx]['commission_amount'] = bcadd(strval($row['commission_amount'] ?? '0'), '0', 2);
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道直属玩家下注记录(直属投注额列点击;该渠道名下玩家全部注单)
|
||||
*/
|
||||
public function directBetRecordList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->check('channel/index')) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$channelId = (int) ($request->get('channel_id', 0));
|
||||
if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司总投注记录下注明细(顶部公司总投注额点击;含未结算,当前账号可见渠道范围)
|
||||
*/
|
||||
public function companyBetRecordList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->auth->check('channel/index')) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, 0, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用 companyBetRecordList
|
||||
*/
|
||||
public function settlementBetRecordList(WebmanRequest $request): Response
|
||||
{
|
||||
return $this->companyBetRecordList($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $channelId 0=当前账号可见全部渠道
|
||||
* @return array{list: array<int, array<string, mixed>>, total: int, summary: array{record_count:int,total_bet_amount:string,total_win_amount:string}}
|
||||
*/
|
||||
private function fetchChannelPlayRecordListPayload(WebmanRequest $request, int $channelId, bool $settledOnly): array
|
||||
{
|
||||
[$page, $limit] = $this->parseListPageParams($request);
|
||||
$filters = $this->parsePlayRecordListFilters($request);
|
||||
|
||||
$listQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
|
||||
$this->applyPlayRecordListFilters($listQuery, $filters);
|
||||
$total = (int) $listQuery->count('pr.id');
|
||||
$list = $listQuery
|
||||
->field($this->channelPlayRecordListFields())
|
||||
->order('pr.id', 'desc')
|
||||
->page($page, $limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$summaryQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
|
||||
$this->applyPlayRecordListFilters($summaryQuery, $filters);
|
||||
$summary = $this->summarizePlayRecordQuery($summaryQuery);
|
||||
|
||||
return [
|
||||
'list' => $this->normalizePlayRecordListRows($list),
|
||||
'total' => $total,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string}
|
||||
*/
|
||||
private function parsePlayRecordListFilters(WebmanRequest $request): array
|
||||
{
|
||||
$winHit = trim((string) $request->get('win_hit', ''));
|
||||
if (!in_array($winHit, ['won', 'lost', 'pending'], true)) {
|
||||
$winHit = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'period_no' => trim((string) $request->get('period_no', '')),
|
||||
'user_keyword' => trim((string) $request->get('user_keyword', '')),
|
||||
'result_number' => trim((string) $request->get('result_number', '')),
|
||||
'pick_number' => trim((string) $request->get('pick_number', '')),
|
||||
'win_hit' => $winHit,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \think\db\Query $query
|
||||
* @param array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string} $filters
|
||||
*/
|
||||
private function applyPlayRecordListFilters($query, array $filters): void
|
||||
{
|
||||
if ($filters['period_no'] !== '') {
|
||||
$like = '%' . $this->escapeLikeKeyword($filters['period_no']) . '%';
|
||||
$query->where(function ($sub) use ($like) {
|
||||
$sub->where('pr.period_no', 'like', $like)->whereOr('gr.period_no', 'like', $like);
|
||||
});
|
||||
}
|
||||
if ($filters['user_keyword'] !== '') {
|
||||
$like = '%' . $this->escapeLikeKeyword($filters['user_keyword']) . '%';
|
||||
$query->where(function ($sub) use ($like) {
|
||||
$sub->where('u.username', 'like', $like)->whereOr('u.phone', 'like', $like);
|
||||
});
|
||||
}
|
||||
if ($filters['result_number'] !== '') {
|
||||
$query->where('gr.result_number', $filters['result_number']);
|
||||
}
|
||||
if ($filters['pick_number'] !== '') {
|
||||
$like = '%' . $this->escapeLikeKeyword($filters['pick_number']) . '%';
|
||||
$query->where('pr.pick_numbers', 'like', $like);
|
||||
}
|
||||
if ($filters['win_hit'] === 'won') {
|
||||
$query->where('pr.status', 2)
|
||||
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) > 0');
|
||||
} elseif ($filters['win_hit'] === 'lost') {
|
||||
$query->where('pr.status', 2)
|
||||
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) <= 0');
|
||||
} elseif ($filters['win_hit'] === 'pending') {
|
||||
$query->where('pr.status', '<>', 2);
|
||||
}
|
||||
}
|
||||
|
||||
private function escapeLikeKeyword(string $keyword): string
|
||||
{
|
||||
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:int}
|
||||
*/
|
||||
private function parseListPageParams(WebmanRequest $request): array
|
||||
{
|
||||
$pageRaw = $request->get('page', 1);
|
||||
$page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1;
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
$limitRaw = $request->get('limit', 20);
|
||||
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 20;
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
return [$page, $limit];
|
||||
}
|
||||
|
||||
private function assertChannelVisible(int $channelId): bool
|
||||
{
|
||||
if ($channelId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$scope = $this->readableChannelIds();
|
||||
if ($scope === null) {
|
||||
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
|
||||
}
|
||||
|
||||
return in_array($channelId, $scope, true);
|
||||
}
|
||||
|
||||
private function assertChannelWritable(int $channelId): bool
|
||||
{
|
||||
if ($channelId <= 0 || $this->auth === null) {
|
||||
return false;
|
||||
}
|
||||
$scope = AdminChannelScopeService::writableChannelIds($this->auth);
|
||||
if ($scope === null) {
|
||||
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
|
||||
}
|
||||
|
||||
return in_array($channelId, $scope, true);
|
||||
}
|
||||
|
||||
private function assertChannelAccessible(int $channelId): bool
|
||||
{
|
||||
return $this->assertChannelVisible($channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \think\db\Query
|
||||
*/
|
||||
private function canManualSettle(): bool
|
||||
{
|
||||
if ($this->auth === null) {
|
||||
return false;
|
||||
}
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->auth->check('channel/manualSettle');
|
||||
}
|
||||
|
||||
private function canBatchSettle(): bool
|
||||
{
|
||||
if ($this->auth === null) {
|
||||
return false;
|
||||
}
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->auth->check('channel/batchSettlePending');
|
||||
}
|
||||
|
||||
private function buildChannelPlayRecordQuery(int $channelId, bool $settledOnly)
|
||||
{
|
||||
$query = Db::name('game_play_record')->alias('pr')
|
||||
->leftJoin('user u', 'u.id = pr.user_id')
|
||||
->leftJoin('game_record gr', 'gr.id = pr.period_id')
|
||||
->leftJoin('channel c', 'c.id = pr.channel_id');
|
||||
if ($channelId > 0) {
|
||||
if (!$this->assertChannelAccessible($channelId)) {
|
||||
$query->where('pr.channel_id', 0);
|
||||
return $query;
|
||||
}
|
||||
$query->where('pr.channel_id', $channelId);
|
||||
} else {
|
||||
$scope = $this->readableChannelIds();
|
||||
if ($scope === []) {
|
||||
$query->where('pr.channel_id', 0);
|
||||
} elseif ($scope !== null) {
|
||||
$query->whereIn('pr.channel_id', $scope);
|
||||
}
|
||||
}
|
||||
if ($settledOnly) {
|
||||
$query->where('pr.status', 2);
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function channelPlayRecordListFields(): array
|
||||
{
|
||||
return [
|
||||
'pr.id',
|
||||
'pr.period_no',
|
||||
'pr.period_id',
|
||||
'pr.user_id',
|
||||
'pr.pick_numbers',
|
||||
'pr.total_amount',
|
||||
'pr.win_amount',
|
||||
'pr.jackpot_extra_amount',
|
||||
'pr.status',
|
||||
'pr.create_time',
|
||||
'u.username as user_username',
|
||||
'c.name as channel_name',
|
||||
'gr.period_no as game_record_period_no',
|
||||
'gr.result_number',
|
||||
'gr.status as game_record_status',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $list
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function normalizePlayRecordListRows(array $list): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($list as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$periodNo = trim((string) ($row['period_no'] ?? ''));
|
||||
if ($periodNo === '') {
|
||||
$periodNo = trim((string) ($row['game_record_period_no'] ?? ''));
|
||||
}
|
||||
$win = bcadd(strval($row['win_amount'] ?? '0'), strval($row['jackpot_extra_amount'] ?? '0'), 2);
|
||||
$playStatus = intval($row['status'] ?? 0);
|
||||
$out[] = [
|
||||
'id' => intval($row['id'] ?? 0),
|
||||
'period_no' => $periodNo,
|
||||
'user_username' => (string) ($row['user_username'] ?? ''),
|
||||
'channel_name' => (string) ($row['channel_name'] ?? ''),
|
||||
'pick_numbers' => $this->formatPickNumbersForList($row['pick_numbers'] ?? null),
|
||||
'result_number' => $this->formatResultNumberForList($row['result_number'] ?? null),
|
||||
'win_hit' => $this->resolveWinHitCode($win, $playStatus),
|
||||
'play_status' => $playStatus,
|
||||
'total_amount' => bcadd(strval($row['total_amount'] ?? '0'), '0', 2),
|
||||
'win_amount' => bcadd($win, '0', 2),
|
||||
'create_time' => intval($row['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function formatPickNumbersForList(mixed $pickNumbers): string
|
||||
{
|
||||
if ($pickNumbers === null || $pickNumbers === '') {
|
||||
return '';
|
||||
}
|
||||
if (is_string($pickNumbers)) {
|
||||
$trimmed = trim($pickNumbers);
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$pickNumbers = $decoded;
|
||||
} else {
|
||||
return $trimmed;
|
||||
}
|
||||
}
|
||||
if (!is_array($pickNumbers)) {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($pickNumbers as $num) {
|
||||
if ($num === null || $num === '') {
|
||||
continue;
|
||||
}
|
||||
$parts[] = (string) $num;
|
||||
}
|
||||
|
||||
return implode(',', $parts);
|
||||
}
|
||||
|
||||
private function formatResultNumberForList(mixed $resultNumber): string
|
||||
{
|
||||
if ($resultNumber === null || $resultNumber === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $resultNumber;
|
||||
}
|
||||
|
||||
private function resolveWinHitCode(string $winAmount, int $playStatus): string
|
||||
{
|
||||
if ($playStatus > 0 && $playStatus !== 2) {
|
||||
return 'pending';
|
||||
}
|
||||
if (bccomp($winAmount, '0', 2) > 0) {
|
||||
return 'won';
|
||||
}
|
||||
|
||||
return 'lost';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \think\db\Query $query
|
||||
* @return array{record_count:int,total_bet_amount:string,total_win_amount:string}
|
||||
*/
|
||||
private function summarizePlayRecordQuery($query): array
|
||||
{
|
||||
$row = $query
|
||||
->field('COUNT(pr.id) AS c, SUM(pr.total_amount) AS tb, SUM(pr.win_amount) AS tw, SUM(pr.jackpot_extra_amount) AS tj')
|
||||
->find();
|
||||
$count = is_array($row) ? intval($row['c'] ?? 0) : 0;
|
||||
$tb = is_array($row) ? strval($row['tb'] ?? '0') : '0';
|
||||
$tw = is_array($row) ? strval($row['tw'] ?? '0') : '0';
|
||||
$tj = is_array($row) ? strval($row['tj'] ?? '0') : '0';
|
||||
return [
|
||||
'record_count' => $count,
|
||||
'total_bet_amount' => bcadd($tb, '0', 2),
|
||||
'total_win_amount' => bcadd(bcadd($tw, $tj, 2), '0', 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|string 成功返回预览数据数组,失败返回错误文案
|
||||
*/
|
||||
@@ -738,7 +1225,7 @@ class Channel extends Backend
|
||||
{
|
||||
$channelId = (int) ($row['id'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
return '渠道数据异常';
|
||||
return (string) __('Invalid channel data');
|
||||
}
|
||||
|
||||
$endTs = time();
|
||||
@@ -751,7 +1238,7 @@ class Channel extends Backend
|
||||
}
|
||||
|
||||
if ($periodStartTs >= $endTs) {
|
||||
return '结算区间无效(开始时间不早于当前)';
|
||||
return (string) __('Invalid settlement period (start time is not earlier than now)');
|
||||
}
|
||||
|
||||
$stats = $this->aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs);
|
||||
@@ -864,7 +1351,7 @@ class Channel extends Backend
|
||||
if ($mode === 'turnover') {
|
||||
$ratePercent = $row['turnover_share_rate'] ?? null;
|
||||
if ($ratePercent === null || $ratePercent === '') {
|
||||
return '普通返水代理未配置返水分红比例';
|
||||
return (string) __('Turnover agent commission rate is not configured');
|
||||
}
|
||||
$rateDec = bcdiv((string) $ratePercent, '100', 2);
|
||||
$amount = bcmul($totalBet, $rateDec, 2);
|
||||
@@ -879,11 +1366,11 @@ class Channel extends Backend
|
||||
$fee = $row['affiliate_fee_rate'] ?? null;
|
||||
$rulesRaw = $row['affiliate_ladder_rules'] ?? null;
|
||||
if ($fee === null || $fee === '') {
|
||||
return '联营代理未配置成本扣除比例';
|
||||
return (string) __('Affiliate agent fee rate is not configured');
|
||||
}
|
||||
$rules = $this->normalizeLadderRulesForSettlement($rulesRaw);
|
||||
if ($rules === []) {
|
||||
return '联营阶梯规则无效或为空';
|
||||
return (string) __('Affiliate ladder rules are empty or invalid');
|
||||
}
|
||||
|
||||
if (bccomp($platformProfit, '0', 2) <= 0) {
|
||||
@@ -915,7 +1402,7 @@ class Channel extends Backend
|
||||
];
|
||||
}
|
||||
|
||||
return '未知的代理模式';
|
||||
return (string) __('Unknown agent mode');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -968,22 +1455,6 @@ class Channel extends Backend
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
private function getCurrentChannelIds(): array
|
||||
{
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return Db::name('channel')->column('id');
|
||||
}
|
||||
$admin = Db::name('admin')
|
||||
->field(['id', 'channel_id'])
|
||||
->where('id', $this->auth->id)
|
||||
->find();
|
||||
$ids = [];
|
||||
if ($admin && !empty($admin['channel_id'])) {
|
||||
$ids[] = $admin['channel_id'];
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* 佣金归属管理员:取该渠道下 admin.channel_id 匹配的首个管理员(按 id 升序)。
|
||||
*/
|
||||
@@ -1169,20 +1640,20 @@ class Channel extends Backend
|
||||
{
|
||||
$cycle = isset($data['settle_cycle']) ? trim((string) $data['settle_cycle']) : 'weekly';
|
||||
if (!in_array($cycle, ['daily', 'weekly', 'monthly'], true)) {
|
||||
return '结算周期不合法';
|
||||
return (string) __('Invalid settlement cycle');
|
||||
}
|
||||
$data['settle_cycle'] = $cycle;
|
||||
|
||||
$settleTime = isset($data['settle_time']) ? trim((string) $data['settle_time']) : '02:00:00';
|
||||
if (!preg_match('/^\d{2}:\d{2}:\d{2}$/', $settleTime)) {
|
||||
return '结算时间格式不正确(HH:mm:ss)';
|
||||
return (string) __('Invalid settlement time format (HH:mm:ss)');
|
||||
}
|
||||
$data['settle_time'] = $settleTime;
|
||||
|
||||
if ($cycle === 'weekly') {
|
||||
$weekday = isset($data['settle_weekday']) ? (int) $data['settle_weekday'] : 1;
|
||||
if ($weekday < 1 || $weekday > 7) {
|
||||
return '周结必须选择周一到周日';
|
||||
return (string) __('Weekly settlement must select Monday to Sunday');
|
||||
}
|
||||
$data['settle_weekday'] = $weekday;
|
||||
} else {
|
||||
@@ -1192,7 +1663,7 @@ class Channel extends Backend
|
||||
if ($cycle === 'monthly') {
|
||||
$monthday = isset($data['settle_monthday']) ? (int) $data['settle_monthday'] : 1;
|
||||
if ($monthday < 1 || $monthday > 31) {
|
||||
return '月结日期必须在1到31之间';
|
||||
return (string) __('Monthly settlement day must be between 1 and 31');
|
||||
}
|
||||
$data['settle_monthday'] = $monthday;
|
||||
} else {
|
||||
@@ -1200,11 +1671,21 @@ class Channel extends Backend
|
||||
}
|
||||
|
||||
$mode = isset($data['agent_mode']) ? (string) $data['agent_mode'] : '';
|
||||
if (isset($data['settlement_handling_fee']) && $data['settlement_handling_fee'] !== '' && $data['settlement_handling_fee'] !== null) {
|
||||
$fee = (float) $data['settlement_handling_fee'];
|
||||
if ($fee < 0 || $fee > 100) {
|
||||
return (string) __('Settlement handling fee rate must be between 0 and 100');
|
||||
}
|
||||
$data['settlement_handling_fee'] = number_format($fee, 2, '.', '');
|
||||
} else {
|
||||
$data['settlement_handling_fee'] = '0.00';
|
||||
}
|
||||
|
||||
if ($mode === 'turnover') {
|
||||
if (isset($data['turnover_share_rate']) && $data['turnover_share_rate'] !== '' && $data['turnover_share_rate'] !== null) {
|
||||
$num = (float) $data['turnover_share_rate'];
|
||||
if ($num < 0 || $num > 100) {
|
||||
return '返水分红比例必须在0到100之间';
|
||||
return (string) __('Turnover commission rate must be between 0 and 100');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1213,11 +1694,11 @@ class Channel extends Backend
|
||||
if ($mode === 'affiliate') {
|
||||
foreach (['affiliate_share_rate' => '联营占成比例', 'affiliate_fee_rate' => '联营成本扣除比例'] as $field => $label) {
|
||||
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
|
||||
return $label . '不能为空';
|
||||
return (string) __('Affiliate share/fee rates are required');
|
||||
}
|
||||
$num = (float) $data[$field];
|
||||
if ($num < 0 || $num > 1) {
|
||||
return $label . '必须在0到1之间';
|
||||
return (string) __('Affiliate share/fee rates must be between 0 and 1');
|
||||
}
|
||||
}
|
||||
$ladderErr = $this->validateLadderRulesField($data);
|
||||
@@ -1258,49 +1739,76 @@ class Channel extends Backend
|
||||
return $negative ? ('-' . $v) : $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null|false admin_id => 手续费比例(%);false=比例非法
|
||||
*/
|
||||
private function parseCommissionSplitHandlingFees(mixed $splitRaw): array|null|false
|
||||
{
|
||||
if (!is_array($splitRaw) || $splitRaw === []) {
|
||||
return null;
|
||||
}
|
||||
$map = [];
|
||||
foreach ($splitRaw as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$adminId = intval($item['admin_id'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rateRaw = $item['handling_fee_rate'] ?? ($item['handling_fee'] ?? '0');
|
||||
$rate = bcadd(strval($rateRaw), '0', 2);
|
||||
if (bccomp($rate, '0', 2) < 0 || bccomp($rate, '100', 2) > 0) {
|
||||
return false;
|
||||
}
|
||||
$map[$adminId] = $rate;
|
||||
}
|
||||
return $map === [] ? null : $map;
|
||||
}
|
||||
|
||||
private function validateLadderRulesField(array &$data): ?string
|
||||
{
|
||||
$rulesRaw = $data['affiliate_ladder_rules'] ?? null;
|
||||
if ($rulesRaw === null || $rulesRaw === '') {
|
||||
return '联营阶梯规则不能为空';
|
||||
return (string) __('Affiliate ladder rules are required');
|
||||
}
|
||||
|
||||
if (is_string($rulesRaw)) {
|
||||
$decoded = json_decode($rulesRaw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return '联营阶梯规则必须是有效JSON数组';
|
||||
return (string) __('Affiliate ladder rules must be a valid JSON array');
|
||||
}
|
||||
$rulesRaw = $decoded;
|
||||
}
|
||||
|
||||
if (!is_array($rulesRaw) || $rulesRaw === []) {
|
||||
return '联营阶梯规则至少需要一条';
|
||||
return (string) __('Affiliate ladder rules must contain at least one row');
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$prevMinLoss = null;
|
||||
foreach ($rulesRaw as $idx => $rule) {
|
||||
if (!is_array($rule)) {
|
||||
return '联营阶梯规则第' . ($idx + 1) . '行格式错误';
|
||||
return (string) __('Affiliate ladder rules row format error') . ' #' . ($idx + 1);
|
||||
}
|
||||
$minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null);
|
||||
$shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null);
|
||||
if ($minLoss === null || $minLoss === '' || !is_numeric((string) $minLoss)) {
|
||||
return '联营阶梯规则第' . ($idx + 1) . '行起始客损格式错误';
|
||||
return (string) __('Affiliate ladder rules minLoss format error') . ' #' . ($idx + 1);
|
||||
}
|
||||
if ($shareRate === null || $shareRate === '' || !is_numeric((string) $shareRate)) {
|
||||
return '联营阶梯规则第' . ($idx + 1) . '行占成比例格式错误';
|
||||
return (string) __('Affiliate ladder rules shareRate format error') . ' #' . ($idx + 1);
|
||||
}
|
||||
$minLossNum = (float) $minLoss;
|
||||
$shareRateNum = (float) $shareRate;
|
||||
if ($minLossNum < 0) {
|
||||
return '联营阶梯规则第' . ($idx + 1) . '行起始客损不能为负';
|
||||
return (string) __('Affiliate ladder rules minLoss cannot be negative') . ' #' . ($idx + 1);
|
||||
}
|
||||
if ($shareRateNum < 0 || $shareRateNum > 1) {
|
||||
return '联营阶梯规则第' . ($idx + 1) . '行占成比例必须在0到1之间';
|
||||
return (string) __('Affiliate ladder rules shareRate must be between 0 and 1') . ' #' . ($idx + 1);
|
||||
}
|
||||
if ($prevMinLoss !== null && $minLossNum <= $prevMinLoss) {
|
||||
return '联营阶梯规则需按起始客损递增';
|
||||
return (string) __('Affiliate ladder rules must be strictly increasing by minLoss');
|
||||
}
|
||||
$prevMinLoss = $minLossNum;
|
||||
$normalized[] = [
|
||||
|
||||
@@ -379,7 +379,7 @@ class Dashboard extends Backend
|
||||
*/
|
||||
private function ownerAdminIdOrNull(): ?int
|
||||
{
|
||||
if (!$this->auth || $this->auth->isSuperAdmin()) {
|
||||
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return null;
|
||||
}
|
||||
$idRaw = $this->auth->id;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\admin\controller\admin;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -37,9 +38,9 @@ class AdminWallet extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
// 非超管仅可查看自己钱包
|
||||
$where[] = [$mainShort . '.admin_id', '=', intval($this->auth->id ?? 0)];
|
||||
$scopedAdminIds = $this->getManageableScopeAdminIds();
|
||||
if ($mainShort !== '' && $scopedAdminIds !== []) {
|
||||
$where[] = [$mainShort . '.admin_id', 'in', $scopedAdminIds];
|
||||
}
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
@@ -51,9 +52,28 @@ class AdminWallet extends Backend
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
$items = $res->items();
|
||||
$channelIds = [];
|
||||
foreach ($items as $item) {
|
||||
$channelId = intval($item['admin']['channel_id'] ?? 0);
|
||||
if ($channelId > 0) {
|
||||
$channelIds[] = $channelId;
|
||||
}
|
||||
}
|
||||
$channelNames = [];
|
||||
if ($channelIds !== []) {
|
||||
$channelNames = Db::name('channel')
|
||||
->whereIn('id', array_values(array_unique($channelIds)))
|
||||
->column('name', 'id');
|
||||
}
|
||||
foreach ($items as &$item) {
|
||||
$channelId = intval($item['admin']['channel_id'] ?? 0);
|
||||
$item['channel_name'] = $channelId > 0 ? strval($channelNames[$channelId] ?? '') : '';
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'list' => $items,
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
@@ -61,17 +81,17 @@ class AdminWallet extends Backend
|
||||
|
||||
protected function _add(): Response
|
||||
{
|
||||
return $this->error('管理员钱包不允许手动新增');
|
||||
return $this->error(__('Admin wallet does not allow manual creation'));
|
||||
}
|
||||
|
||||
protected function _edit(): Response
|
||||
{
|
||||
return $this->error('管理员钱包不允许手动编辑');
|
||||
return $this->error(__('Admin wallet does not allow manual editing'));
|
||||
}
|
||||
|
||||
protected function _del(): Response
|
||||
{
|
||||
return $this->error('管理员钱包不允许删除');
|
||||
return $this->error(__('Admin wallet does not allow deletion'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ class AdminWalletRecord extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
$where[] = [$mainShort . '.admin_id', '=', intval($this->auth->id ?? 0)];
|
||||
$scopedAdminIds = $this->getManageableScopeAdminIds();
|
||||
if ($mainShort !== '' && $scopedAdminIds !== []) {
|
||||
$where[] = [$mainShort . '.admin_id', 'in', $scopedAdminIds];
|
||||
}
|
||||
|
||||
$res = $this->model
|
||||
@@ -63,17 +64,17 @@ class AdminWalletRecord extends Backend
|
||||
|
||||
protected function _add(): Response
|
||||
{
|
||||
return $this->error('管理员钱包流水不允许手动新增');
|
||||
return $this->error(__('Admin wallet records do not allow manual creation'));
|
||||
}
|
||||
|
||||
protected function _edit(): Response
|
||||
{
|
||||
return $this->error('管理员钱包流水不允许手动编辑');
|
||||
return $this->error(__('Admin wallet records do not allow manual editing'));
|
||||
}
|
||||
|
||||
protected function _del(): Response
|
||||
{
|
||||
return $this->error('管理员钱包流水不允许删除');
|
||||
return $this->error(__('Admin wallet records do not allow deletion'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,5 +32,59 @@ class CommissionRecord extends Backend
|
||||
$this->model = new \app\common\model\AgentCommissionRecord();
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function _index(): Response
|
||||
{
|
||||
if ($this->request && $this->request->get('select')) {
|
||||
return $this->select($this->request);
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->field($this->indexField)
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->with($this->withJoinTable)
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
$list = $this->enrichCommissionRecordList($res->items());
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $items
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function enrichCommissionRecordList(array $items): array
|
||||
{
|
||||
if ($items === []) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($items as $item) {
|
||||
$row = is_array($item) ? $item : (method_exists($item, 'toArray') ? $item->toArray() : []);
|
||||
$gross = strval($row['commission_amount'] ?? '0.00');
|
||||
$fee = strval($row['handling_fee'] ?? '0.00');
|
||||
$net = strval($row['net_commission_amount'] ?? '0.00');
|
||||
if (bccomp($net, '0', 2) <= 0 && (bccomp($gross, '0', 2) > 0 || bccomp($fee, '0', 2) > 0)) {
|
||||
$net = bcsub($gross, $fee, 2);
|
||||
if (bccomp($net, '0', 2) < 0) {
|
||||
$net = '0.00';
|
||||
}
|
||||
$row['net_commission_amount'] = $net;
|
||||
}
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,145 +5,292 @@ declare(strict_types=1);
|
||||
namespace app\admin\controller\auth;
|
||||
|
||||
use ba\Random;
|
||||
use ba\Tree;
|
||||
use Throwable;
|
||||
use support\think\Db;
|
||||
use support\validation\Validator;
|
||||
use support\validation\ValidationException;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\AdminCommissionDistributionService;
|
||||
use app\admin\model\Admin as AdminModel;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class Admin extends Backend
|
||||
{
|
||||
/**
|
||||
* 分红比例余量查询(表单提示用)
|
||||
*/
|
||||
protected array $noNeedPermission = ['commissionShareRemainder', 'groupMeta', 'parentMeta'];
|
||||
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip'];
|
||||
|
||||
protected array|string $quickSearchField = ['username', 'nickname'];
|
||||
protected array|string $quickSearchField = ['username', 'nickname', 'mobile', 'invite_code'];
|
||||
|
||||
protected string|int|bool $dataLimit = 'parent';
|
||||
/** 使用 parent_admin_id 树过滤,不用角色组 dataLimit */
|
||||
protected string|int|bool $dataLimit = false;
|
||||
|
||||
protected string $dataLimitField = 'id';
|
||||
|
||||
protected Tree $tree;
|
||||
|
||||
protected array $initValue = [];
|
||||
|
||||
protected string $keyword = '';
|
||||
|
||||
protected bool $assembleTree = false;
|
||||
|
||||
protected function initController(Request $request): ?Response
|
||||
{
|
||||
$this->model = new AdminModel();
|
||||
$this->tree = Tree::instance();
|
||||
|
||||
$isTree = $request->get('isTree') ?? $request->post('isTree') ?? false;
|
||||
$initValue = $request->get('initValue') ?? $request->post('initValue') ?? [];
|
||||
if (is_array($initValue)) {
|
||||
$this->initValue = array_filter($initValue);
|
||||
} elseif ($initValue !== null && $initValue !== '') {
|
||||
$this->initValue = array_filter(explode(',', (string) $initValue));
|
||||
} else {
|
||||
$this->initValue = [];
|
||||
}
|
||||
$this->keyword = (string) ($request->get('quickSearch') ?? $request->post('quickSearch') ?? '');
|
||||
$this->assembleTree = self::isRequestTruthy($isTree) && $this->initValue === [];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析请求中的布尔参数(兼容 true / "true" / 1 / "1")
|
||||
*/
|
||||
private static function isRequestTruthy(mixed $value): bool
|
||||
{
|
||||
if ($value === false || $value === null || $value === '' || $value === 0 || $value === '0' || $value === 'false') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉树形选项(isTree=1)
|
||||
*/
|
||||
private function shouldFormatSelectTree(Request $request): bool
|
||||
{
|
||||
$isTree = $request->get('isTree') ?? $request->post('isTree') ?? false;
|
||||
|
||||
return self::isRequestTruthy($isTree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为远程下拉请求
|
||||
*/
|
||||
private function isRemoteSelectRequest(Request $request): bool
|
||||
{
|
||||
if ($request->get('select') ?? $request->post('select')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->shouldFormatSelectTree($request);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) return $response;
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') ?? $request->post('select')) {
|
||||
$selectRes = $this->select($request);
|
||||
if ($selectRes !== null) return $selectRes;
|
||||
if ($this->isRemoteSelectRequest($request)) {
|
||||
return $this->select($request);
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$adminAlias = $alias[strtolower($this->model->getTable())] ?? 'admin';
|
||||
|
||||
$query = $this->model
|
||||
->withoutField('login_failure,password,salt')
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->alias($alias)
|
||||
->where($where);
|
||||
|
||||
// 仅返回“顶级角色组(pid=0)”下的管理员(用于远程下拉等场景)
|
||||
$visibleIds = $this->getVisibleAdminIdsForAdminModule();
|
||||
if ($visibleIds !== []) {
|
||||
$query->where($adminAlias . '.id', 'in', $visibleIds);
|
||||
}
|
||||
|
||||
$topGroup = $request->get('top_group') ?? $request->post('top_group');
|
||||
if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) {
|
||||
$query = $query
|
||||
->join('admin_group_access aga', $alias['admin'] . '.id = aga.uid')
|
||||
->join('admin_group_access aga', $adminAlias . '.id = aga.uid')
|
||||
->join('admin_group ag', 'aga.group_id = ag.id')
|
||||
->where('ag.pid', 0)
|
||||
->distinct(true);
|
||||
}
|
||||
|
||||
$res = $query
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
$items = $res->items();
|
||||
$rows = $query->order($order)->select()->toArray();
|
||||
$rows = $this->enrichAdminRows($rows);
|
||||
foreach ($rows as $k => $row) {
|
||||
$parentId = intval($row['parent_admin_id'] ?? 0);
|
||||
$rows[$k]['pid'] = $parentId > 0 ? $parentId : 0;
|
||||
}
|
||||
|
||||
$tree = Tree::instance()->assembleChild($rows, 'pid', 'id');
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $items,
|
||||
'total' => $res->total(),
|
||||
'list' => $tree,
|
||||
'total' => count($rows),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉(重写:支持 top_group=1 仅返回顶级组管理员)
|
||||
* 查询同上级下剩余可分配分红比例(表单提示)
|
||||
*/
|
||||
protected function _select(): Response
|
||||
public function commissionShareRemainder(Request $request): Response
|
||||
{
|
||||
if (empty($this->model)) {
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$parentAdminId = intval($request->get('parent_admin_id', 0));
|
||||
$excludeId = intval($request->get('exclude_id', 0));
|
||||
$channelId = intval($request->get('channel_id', 0));
|
||||
|
||||
if ($parentAdminId <= 0 && $channelId > 0) {
|
||||
$stats = AdminCommissionDistributionService::getChannelRootShareRemainder(
|
||||
$channelId,
|
||||
$excludeId > 0 ? $excludeId : null
|
||||
);
|
||||
return $this->success('', [
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'used_rate' => $stats['used_rate'],
|
||||
'remaining_rate' => $stats['remaining_rate'],
|
||||
'parent_has_no_share' => bccomp($stats['remaining_rate'], '0', 2) <= 0,
|
||||
'is_channel_root' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$pk = $this->model->getPk();
|
||||
|
||||
$fields = [$pk];
|
||||
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', (string) $this->quickSearchField);
|
||||
foreach ($quickSearchArr as $f) {
|
||||
$f = trim((string) $f);
|
||||
if ($f === '') continue;
|
||||
$f = str_contains($f, '.') ? substr($f, strrpos($f, '.') + 1) : $f;
|
||||
if ($f !== '' && !in_array($f, $fields, true)) {
|
||||
$fields[] = $f;
|
||||
}
|
||||
if ($parentAdminId <= 0) {
|
||||
return $this->success('', [
|
||||
'used_rate' => '0.00',
|
||||
'remaining_rate' => '100.00',
|
||||
'parent_has_no_share' => false,
|
||||
'is_channel_root' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$modelTable = strtolower($this->model->getTable());
|
||||
$mainAlias = ($alias[$modelTable] ?? $modelTable) . '.';
|
||||
|
||||
// 联表时避免字段歧义:主表字段统一 select 为 "admin.xxx as xxx"
|
||||
$selectFields = [];
|
||||
foreach ($fields as $f) {
|
||||
$f = trim((string) $f);
|
||||
if ($f === '') continue;
|
||||
$selectFields[] = $mainAlias . $f . ' as ' . $f;
|
||||
if (!$this->canManageAdminId($parentAdminId)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
// 联表时避免排序字段歧义:无前缀的字段默认加主表前缀
|
||||
$qualifiedOrder = [];
|
||||
if (is_array($order)) {
|
||||
foreach ($order as $k => $v) {
|
||||
$k = (string) $k;
|
||||
$qualifiedOrder[str_contains($k, '.') ? $k : ($mainAlias . $k)] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
$query = $this->model
|
||||
->field($selectFields)
|
||||
->alias($alias)
|
||||
->where($where);
|
||||
|
||||
$topGroup = $this->request ? ($this->request->get('top_group') ?? $this->request->post('top_group')) : null;
|
||||
if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) {
|
||||
$query = $query
|
||||
->join('admin_group_access aga', $mainAlias . 'id = aga.uid')
|
||||
->join('admin_group ag', 'aga.group_id = ag.id')
|
||||
->where('ag.pid', 0)
|
||||
->distinct(true);
|
||||
}
|
||||
|
||||
$res = $query
|
||||
->order($qualifiedOrder ?: $order)
|
||||
->paginate($limit);
|
||||
$stats = AdminCommissionDistributionService::getShareRemainder(
|
||||
$parentAdminId,
|
||||
$excludeId > 0 ? $excludeId : null
|
||||
);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'used_rate' => $stats['used_rate'],
|
||||
'remaining_rate' => $stats['remaining_rate'],
|
||||
'parent_has_no_share' => bccomp($stats['remaining_rate'], '0', 2) <= 0,
|
||||
'is_top_level' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询角色组是否为顶级(pid=0),供管理员表单联动
|
||||
*/
|
||||
public function groupMeta(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$groupId = intval($request->get('group_id', 0));
|
||||
if ($groupId <= 0) {
|
||||
return $this->error(__('Invalid parameters'));
|
||||
}
|
||||
|
||||
$group = Db::name('admin_group')->where('id', $groupId)->field(['id', 'pid'])->find();
|
||||
if (!is_array($group)) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
$authGroups = $this->getManageableGroupIds();
|
||||
if (!in_array($groupId, $authGroups, true)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
}
|
||||
|
||||
$pid = intval($group['pid'] ?? 0);
|
||||
|
||||
return $this->success('', [
|
||||
'is_top_level' => $pid === 0,
|
||||
'pid' => $pid,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上级代理渠道信息(子代理表单只读展示用)
|
||||
*/
|
||||
public function parentMeta(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$parentId = intval($request->get('parent_admin_id', 0));
|
||||
if ($parentId <= 0) {
|
||||
return $this->success('', [
|
||||
'channel_id' => null,
|
||||
'channel_name' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$this->canManageAdminId($parentId) && !$this->auth->isSuperAdmin()) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$parent = Db::name('admin')->where('id', $parentId)->field(['id', 'channel_id'])->find();
|
||||
if (!is_array($parent)) {
|
||||
return $this->error(__('Invalid parent administrator'));
|
||||
}
|
||||
|
||||
$cid = $parent['channel_id'] ?? null;
|
||||
$channelName = '';
|
||||
if ($cid !== null && $cid !== '') {
|
||||
$channelName = (string) (Db::name('channel')->where('id', $cid)->value('name') ?? '');
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'channel_id' => $cid,
|
||||
'channel_name' => $channelName,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉(兼容 Backend trait,统一走 select 树形)
|
||||
*/
|
||||
protected function _select(): Response
|
||||
{
|
||||
if ($this->request instanceof Request) {
|
||||
return $this->select($this->request);
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'options' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function add(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) return $response;
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST') {
|
||||
$data = $request->post();
|
||||
@@ -152,7 +299,7 @@ class Admin extends Backend
|
||||
}
|
||||
$data = $this->normalizeSingleGroup($data);
|
||||
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
|
||||
return $this->error('请选择且仅选择一个角色组');
|
||||
return $this->error(__('Please select exactly one role group'));
|
||||
}
|
||||
|
||||
if ($this->modelValidate) {
|
||||
@@ -177,28 +324,19 @@ class Admin extends Backend
|
||||
|
||||
$passwd = $data['password'] ?? '';
|
||||
$data = $this->excludeFields($data);
|
||||
$creatorChannelId = $this->getCreatorChannelId();
|
||||
$groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []);
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
if ($creatorChannelId === null || $creatorChannelId === '') {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
if ($groupChannelId === null || $groupChannelId === '') {
|
||||
return $this->error('所选角色组未绑定渠道');
|
||||
}
|
||||
if ((string) $groupChannelId !== (string) $creatorChannelId) {
|
||||
return $this->error('所选角色组渠道与当前账号不一致');
|
||||
}
|
||||
$data['channel_id'] = $creatorChannelId;
|
||||
$data['parent_admin_id'] = $this->auth->id;
|
||||
} else {
|
||||
$data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId;
|
||||
|
||||
$parentErr = $this->normalizeParentAndShareFields($data, null, $data['group_arr'] ?? []);
|
||||
if ($parentErr !== null) {
|
||||
return $this->error($parentErr);
|
||||
}
|
||||
|
||||
$data['invite_code'] = $this->generateUniqueInviteCode();
|
||||
$result = false;
|
||||
if (!empty($data['group_arr'])) {
|
||||
$authRes = $this->checkGroupAuth($data['group_arr']);
|
||||
if ($authRes !== null) return $authRes;
|
||||
if ($authRes !== null) {
|
||||
return $authRes;
|
||||
}
|
||||
}
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
@@ -234,7 +372,9 @@ class Admin extends Backend
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) return $response;
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$pk = $this->model->getPk();
|
||||
$id = $request->get($pk) ?? $request->post($pk);
|
||||
@@ -243,8 +383,7 @@ class Admin extends Backend
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$dataLimitAdminIds = $this->getDataLimitAdminIds();
|
||||
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
|
||||
if (!$this->canManageAdminId(intval($row['id']))) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
@@ -253,28 +392,20 @@ class Admin extends Backend
|
||||
if (!$data) {
|
||||
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
$data = $this->normalizeSingleGroup($data);
|
||||
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
|
||||
return $this->error('请选择且仅选择一个角色组');
|
||||
$isSelfEdit = (int) $this->auth->id === (int) $id;
|
||||
if ($isSelfEdit) {
|
||||
unset($data['group_arr'], $data['group_name_arr'], $data['parent_admin_id'], $data['commission_share_rate']);
|
||||
}
|
||||
|
||||
$postedGroups = array_map('intval', $data['group_arr'] ?? []);
|
||||
$rowGroups = array_map('intval', $row->group_arr ?? []);
|
||||
sort($postedGroups);
|
||||
sort($rowGroups);
|
||||
|
||||
// 当前管理员编辑自身时,不允许修改角色组
|
||||
if ((int)$this->auth->id === (int)$id) {
|
||||
$postedGroups = $data['group_arr'] ?? [];
|
||||
if (!is_array($postedGroups)) {
|
||||
$postedGroups = [];
|
||||
}
|
||||
$originGroups = $row->group_arr ?? [];
|
||||
sort($postedGroups);
|
||||
sort($originGroups);
|
||||
if ($postedGroups !== $originGroups) {
|
||||
return $this->error(__('You cannot modify your own management group!'));
|
||||
$editGroupArr = null;
|
||||
if (array_key_exists('group_arr', $data)) {
|
||||
$data = $this->normalizeSingleGroup($data);
|
||||
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
|
||||
return $this->error(__('Please select exactly one role group'));
|
||||
}
|
||||
$editGroupArr = $data['group_arr'];
|
||||
} elseif (!$isSelfEdit) {
|
||||
return $this->error(__('Please select exactly one role group'));
|
||||
}
|
||||
|
||||
if ($this->modelValidate) {
|
||||
@@ -285,8 +416,10 @@ class Admin extends Backend
|
||||
'password' => 'nullable|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
|
||||
'email' => 'email|unique:admin,email,' . $id,
|
||||
'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile,' . $id,
|
||||
'group_arr' => 'required|array',
|
||||
];
|
||||
if (array_key_exists('group_arr', $data)) {
|
||||
$rules['group_arr'] = 'required|array';
|
||||
}
|
||||
$messages = [
|
||||
'username.regex' => __('Please input correct username'),
|
||||
'password.regex' => __('Please input correct password'),
|
||||
@@ -306,10 +439,10 @@ class Admin extends Backend
|
||||
}
|
||||
|
||||
$groupAccess = [];
|
||||
if (!empty($data['group_arr'])) {
|
||||
if (!$isSelfEdit && !empty($editGroupArr)) {
|
||||
$checkGroups = [];
|
||||
$rowGroupArr = $row->group_arr ?? [];
|
||||
foreach ($data['group_arr'] as $datum) {
|
||||
foreach ($editGroupArr as $datum) {
|
||||
if (!in_array($datum, $rowGroupArr)) {
|
||||
$checkGroups[] = $datum;
|
||||
}
|
||||
@@ -319,36 +452,31 @@ class Admin extends Backend
|
||||
];
|
||||
}
|
||||
$authRes = $this->checkGroupAuth($checkGroups);
|
||||
if ($authRes !== null) return $authRes;
|
||||
if ($authRes !== null) {
|
||||
return $authRes;
|
||||
}
|
||||
}
|
||||
|
||||
$data = $this->excludeFields($data);
|
||||
unset($data['invite_code']);
|
||||
$creatorChannelId = $this->getCreatorChannelId();
|
||||
$groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []);
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
if ($creatorChannelId === null || $creatorChannelId === '') {
|
||||
return $this->error(__('You have no permission'));
|
||||
unset($data['invite_code'], $data['group_arr'], $data['group_name_arr']);
|
||||
if (!$isSelfEdit) {
|
||||
$parentErr = $this->normalizeParentAndShareFields($data, intval($id), $editGroupArr ?? []);
|
||||
if ($parentErr !== null) {
|
||||
return $this->error($parentErr);
|
||||
}
|
||||
if ($groupChannelId === null || $groupChannelId === '') {
|
||||
return $this->error('所选角色组未绑定渠道');
|
||||
}
|
||||
if ((string) $groupChannelId !== (string) $creatorChannelId) {
|
||||
return $this->error('所选角色组渠道与当前账号不一致');
|
||||
}
|
||||
$data['channel_id'] = $creatorChannelId;
|
||||
} else {
|
||||
$data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
$result = $row->save($data);
|
||||
Db::name('admin_group_access')
|
||||
->where('uid', $id)
|
||||
->delete();
|
||||
if ($groupAccess) {
|
||||
Db::name('admin_group_access')->insertAll($groupAccess);
|
||||
if (!$isSelfEdit) {
|
||||
Db::name('admin_group_access')
|
||||
->where('uid', $id)
|
||||
->delete();
|
||||
if ($groupAccess) {
|
||||
Db::name('admin_group_access')->insertAll($groupAccess);
|
||||
}
|
||||
}
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
@@ -363,37 +491,62 @@ class Admin extends Backend
|
||||
|
||||
unset($row['salt'], $row['login_failure']);
|
||||
$row['password'] = '';
|
||||
$rowData = $row->toArray();
|
||||
$enriched = $this->enrichAdminRows([$rowData]);
|
||||
$rowData = $enriched[0] ?? $rowData;
|
||||
$groupArr = is_array($rowData['group_arr'] ?? null) ? $rowData['group_arr'] : [];
|
||||
$rowData['primary_group_is_top_level'] = $this->isPrimaryGroupTopLevel($groupArr);
|
||||
$parentId = intval($rowData['parent_admin_id'] ?? 0);
|
||||
$channelId = intval($rowData['channel_id'] ?? 0);
|
||||
if ($parentId > 0) {
|
||||
$remainder = AdminCommissionDistributionService::getShareRemainder($parentId, intval($id));
|
||||
$rowData['share_remainder'] = $remainder;
|
||||
} elseif ($channelId > 0) {
|
||||
$rowData['root_share_remainder'] = AdminCommissionDistributionService::getChannelRootShareRemainder(
|
||||
$channelId,
|
||||
intval($id)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'row' => $row
|
||||
'row' => $rowData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function del(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) return $response;
|
||||
|
||||
$where = [];
|
||||
$dataLimitAdminIds = $this->getDataLimitAdminIds();
|
||||
if ($dataLimitAdminIds) {
|
||||
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$ids = $request->get('ids') ?? $request->post('ids') ?? [];
|
||||
$ids = is_array($ids) ? $ids : [];
|
||||
$where[] = [$this->model->getPk(), 'in', $ids];
|
||||
$data = $this->model->where($where)->select();
|
||||
if ($ids === []) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$data = $this->model->where($this->model->getPk(), 'in', $ids)->select();
|
||||
|
||||
$count = 0;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
foreach ($data as $v) {
|
||||
if ($v->id != $this->auth->id) {
|
||||
$count += $v->delete();
|
||||
Db::name('admin_group_access')
|
||||
->where('uid', $v['id'])
|
||||
->delete();
|
||||
if ($v->id == $this->auth->id) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->canManageAdminId(intval($v->id))) {
|
||||
continue;
|
||||
}
|
||||
$childCount = Db::name('admin')->where('parent_admin_id', $v->id)->count();
|
||||
if ($childCount > 0) {
|
||||
$this->model->rollback();
|
||||
return $this->error(__('Cannot delete administrator with sub-agents'));
|
||||
}
|
||||
$count += $v->delete();
|
||||
Db::name('admin_group_access')
|
||||
->where('uid', $v['id'])
|
||||
->delete();
|
||||
}
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
@@ -406,12 +559,85 @@ class Admin extends Backend
|
||||
return $this->error(__('No rows were deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉(Admin 无自定义,走父类默认列表)
|
||||
*/
|
||||
public function select(Request $request): Response
|
||||
{
|
||||
return parent::select($request);
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$rows = $this->getAdminsForSelect($request);
|
||||
if ($this->shouldFormatSelectTree($request)) {
|
||||
$treeData = $this->tree->assembleChild($rows, 'pid', 'id');
|
||||
$rows = $this->tree->assembleTree($this->tree->getTreeArray($treeData, 'username'));
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'options' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getAdminsForSelect(Request $request): array
|
||||
{
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$adminAlias = $alias[strtolower($this->model->getTable())] ?? 'admin';
|
||||
|
||||
$query = $this->model
|
||||
->withoutField('login_failure,password,salt')
|
||||
->alias($alias)
|
||||
->where($where);
|
||||
|
||||
$visibleIds = $this->getVisibleAdminIdsForAdminModule();
|
||||
if ($visibleIds !== []) {
|
||||
$query->where($adminAlias . '.id', 'in', $visibleIds);
|
||||
}
|
||||
|
||||
$rows = $query->order($order)->select()->toArray();
|
||||
foreach ($rows as $k => $row) {
|
||||
$parentId = intval($row['parent_admin_id'] ?? 0);
|
||||
$rows[$k]['pid'] = $parentId > 0 ? $parentId : 0;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非超管可见管理员:本人 + 代理树下级 + 角色组管理范围内(本人所在组及下级组)的全部成员。
|
||||
*
|
||||
* @return int[] 空数组表示不限制(超管)
|
||||
*/
|
||||
private function getVisibleAdminIdsForAdminModule(): array
|
||||
{
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$operatorId = intval($this->auth->id);
|
||||
$ids = AdminCommissionDistributionService::getVisibleAdminIdsForOperator($operatorId, false);
|
||||
|
||||
$groupIds = $this->getManageableGroupIds();
|
||||
if ($groupIds !== []) {
|
||||
$groupAdminIds = Db::name('admin_group_access')
|
||||
->where('group_id', 'in', $groupIds)
|
||||
->column('uid');
|
||||
foreach ($groupAdminIds as $uid) {
|
||||
$uidInt = intval($uid);
|
||||
if ($uidInt > 0) {
|
||||
$ids[] = $uidInt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($operatorId > 0) {
|
||||
$ids[] = $operatorId;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
|
||||
|
||||
return $ids === [] ? [0] : $ids;
|
||||
}
|
||||
|
||||
private function checkGroupAuth(array $groups): ?Response
|
||||
@@ -438,33 +664,199 @@ class Admin extends Backend
|
||||
return array_values(array_unique(array_merge($own, $children)));
|
||||
}
|
||||
|
||||
private function getCreatorChannelId(): mixed
|
||||
private function operatorMayAssignChannel(): bool
|
||||
{
|
||||
$currentAdmin = Db::name('admin')
|
||||
->field(['id', 'channel_id'])
|
||||
->where('id', $this->auth->id)
|
||||
->find();
|
||||
if ($currentAdmin && !empty($currentAdmin['channel_id'])) {
|
||||
return $currentAdmin['channel_id'];
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
foreach (['channel/index', 'channel/Index', 'Channel/index', 'Channel/Index'] as $routePath) {
|
||||
if ($this->auth->check($routePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private function canManageAdminId(int $adminId): bool
|
||||
{
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if ($adminId === intval($this->auth->id)) {
|
||||
return true;
|
||||
}
|
||||
return in_array($adminId, $this->getVisibleAdminIdsForAdminModule(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $groupIds
|
||||
*/
|
||||
private function resolveChannelIdFromPrimaryGroup(array $groupIds): mixed
|
||||
private function isPrimaryGroupTopLevel(array $groupIds): bool
|
||||
{
|
||||
if ($groupIds === []) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
$gid = $groupIds[0];
|
||||
if ($gid === null || $gid === '') {
|
||||
$gid = intval($groupIds[0]);
|
||||
if ($gid <= 0) {
|
||||
return false;
|
||||
}
|
||||
$pid = Db::name('admin_group')->where('id', $gid)->value('pid');
|
||||
|
||||
return intval($pid ?? 0) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int|string> $groupIds
|
||||
*/
|
||||
private function normalizeParentAndShareFields(array &$data, ?int $editAdminId, array $groupIds = []): ?string
|
||||
{
|
||||
if ($this->isPrimaryGroupTopLevel($groupIds)) {
|
||||
$data['parent_admin_id'] = null;
|
||||
}
|
||||
|
||||
$parentId = isset($data['parent_admin_id']) && $data['parent_admin_id'] !== '' && $data['parent_admin_id'] !== null
|
||||
? intval($data['parent_admin_id'])
|
||||
: 0;
|
||||
if ($parentId <= 0 && $editAdminId !== null && $editAdminId > 0 && !array_key_exists('parent_admin_id', $data)) {
|
||||
$existingParent = Db::name('admin')->where('id', $editAdminId)->value('parent_admin_id');
|
||||
$parentId = intval($existingParent ?? 0);
|
||||
}
|
||||
|
||||
if ($parentId > 0) {
|
||||
if ($editAdminId !== null && $parentId === $editAdminId) {
|
||||
return (string) __('Cannot set yourself as parent administrator');
|
||||
}
|
||||
|
||||
$parent = Db::name('admin')->where('id', $parentId)->find();
|
||||
if (!is_array($parent)) {
|
||||
return (string) __('Invalid parent administrator');
|
||||
}
|
||||
if (!$this->canManageAdminId($parentId) && !$this->auth->isSuperAdmin()) {
|
||||
return (string) __('You have no permission');
|
||||
}
|
||||
|
||||
$data['parent_admin_id'] = $parentId;
|
||||
if (!empty($parent['channel_id'])) {
|
||||
$data['channel_id'] = $parent['channel_id'];
|
||||
$shareErr = AdminCommissionDistributionService::validateCommissionShareRate(
|
||||
$parentId,
|
||||
$data['commission_share_rate'] ?? null,
|
||||
$editAdminId
|
||||
);
|
||||
if ($shareErr !== null) {
|
||||
return $shareErr;
|
||||
}
|
||||
$data['commission_share_rate'] = bcadd(strval($data['commission_share_rate'] ?? '0'), '0', 2);
|
||||
} else {
|
||||
$data['channel_id'] = null;
|
||||
$data['commission_share_rate'] = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return Db::name('admin_group')->where('id', $gid)->value('channel_id');
|
||||
$data['parent_admin_id'] = null;
|
||||
if ($editAdminId !== null && $editAdminId > 0 && !array_key_exists('channel_id', $data)) {
|
||||
return null;
|
||||
}
|
||||
$channelIdInt = intval($data['channel_id'] ?? 0);
|
||||
if ($channelIdInt <= 0) {
|
||||
$data['channel_id'] = null;
|
||||
$data['commission_share_rate'] = null;
|
||||
if ($editAdminId === null && !$this->auth->isSuperAdmin() && !$this->operatorMayAssignChannel()) {
|
||||
return (string) __('Please select a parent agent');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin() && !$this->operatorMayAssignChannel()) {
|
||||
return (string) __('You have no permission');
|
||||
}
|
||||
$exists = Db::name('channel')->where('id', $channelIdInt)->value('id');
|
||||
if (!$exists) {
|
||||
return (string) __('Record not found');
|
||||
}
|
||||
$shareErr = AdminCommissionDistributionService::validateChannelRootCommissionShareRate(
|
||||
$channelIdInt,
|
||||
$data['commission_share_rate'] ?? null,
|
||||
$editAdminId
|
||||
);
|
||||
if ($shareErr !== null) {
|
||||
return $shareErr;
|
||||
}
|
||||
$data['commission_share_rate'] = bcadd(strval($data['commission_share_rate'] ?? '0'), '0', 2);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function enrichAdminRows(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = [];
|
||||
$channelIds = [];
|
||||
$parentIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = intval($row['id'] ?? 0);
|
||||
if ($aid > 0) {
|
||||
$adminIds[] = $aid;
|
||||
}
|
||||
$cid = $row['channel_id'] ?? null;
|
||||
if ($cid !== null && $cid !== '') {
|
||||
$channelIds[] = intval($cid);
|
||||
}
|
||||
$pid = intval($row['parent_admin_id'] ?? 0);
|
||||
if ($pid > 0) {
|
||||
$parentIds[] = $pid;
|
||||
}
|
||||
}
|
||||
$channelNames = $channelIds !== []
|
||||
? Db::name('channel')->where('id', 'in', array_unique($channelIds))->column('name', 'id')
|
||||
: [];
|
||||
$parentNames = $parentIds !== []
|
||||
? Db::name('admin')->where('id', 'in', array_unique($parentIds))->column('username', 'id')
|
||||
: [];
|
||||
|
||||
$groupMap = [];
|
||||
if ($adminIds !== []) {
|
||||
$accessRows = Db::name('admin_group_access')->where('uid', 'in', $adminIds)->select()->toArray();
|
||||
$groupIds = array_unique(array_map(static fn(array $r): int => intval($r['group_id'] ?? 0), $accessRows));
|
||||
$groupNames = $groupIds !== []
|
||||
? Db::name('admin_group')->where('id', 'in', $groupIds)->column('name', 'id')
|
||||
: [];
|
||||
foreach ($accessRows as $access) {
|
||||
$uid = intval($access['uid'] ?? 0);
|
||||
$gid = intval($access['group_id'] ?? 0);
|
||||
if ($uid <= 0 || $gid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($groupMap[$uid])) {
|
||||
$groupMap[$uid] = [];
|
||||
}
|
||||
$name = $groupNames[$gid] ?? '';
|
||||
if ($name !== '') {
|
||||
$groupMap[$uid][] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as $k => $row) {
|
||||
$cid = $row['channel_id'] ?? null;
|
||||
$rows[$k]['channel_name'] = ($cid !== null && $cid !== '') ? strval($channelNames[intval($cid)] ?? '') : '';
|
||||
$pid = intval($row['parent_admin_id'] ?? 0);
|
||||
$rows[$k]['parent_admin_username'] = $pid > 0 ? strval($parentNames[$pid] ?? '') : '';
|
||||
$aid = intval($row['id'] ?? 0);
|
||||
$rows[$k]['group_name_arr'] = $groupMap[$aid] ?? [];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function generateUniqueInviteCode(): string
|
||||
@@ -502,4 +894,47 @@ class Admin extends Backend
|
||||
{
|
||||
return is_array($groups) && count($groups) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧 worker 内存中仍调用本方法的 add/edit;新逻辑在 normalizeParentAndShareFields。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int|string> $groupIds
|
||||
*/
|
||||
private function applyTopLevelChannelFromOperator(array &$data, array $groupIds): ?string
|
||||
{
|
||||
if (!$this->isPrimaryGroupTopLevel($groupIds)) {
|
||||
return null;
|
||||
}
|
||||
$parentId = isset($data['parent_admin_id']) && $data['parent_admin_id'] !== '' && $data['parent_admin_id'] !== null
|
||||
? intval($data['parent_admin_id'])
|
||||
: 0;
|
||||
if ($parentId > 0) {
|
||||
return null;
|
||||
}
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
$mayAssignChannel = false;
|
||||
foreach (['channel/index', 'channel/Index', 'Channel/index', 'Channel/Index'] as $routePath) {
|
||||
if ($this->auth->check($routePath)) {
|
||||
$mayAssignChannel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$mayAssignChannel) {
|
||||
unset($data['channel_id']);
|
||||
}
|
||||
}
|
||||
$cid = $data['channel_id'] ?? null;
|
||||
if ($cid === null || $cid === '') {
|
||||
$data['channel_id'] = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
$exists = Db::name('channel')->where('id', $cid)->value('id');
|
||||
if (!$exists) {
|
||||
return (string) __('Record not found');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ class Group extends Backend
|
||||
{
|
||||
protected string $authMethod = 'allAuthAndOthers';
|
||||
|
||||
/**
|
||||
* 角色组表单分配权限树(仅需登录 + 具备角色组管理相关权限,不依赖菜单规则管理权限)
|
||||
*/
|
||||
protected array $noNeedPermission = ['rules'];
|
||||
|
||||
protected ?object $model = null;
|
||||
|
||||
protected string|array $preExcludeFields = ['create_time', 'update_time'];
|
||||
@@ -86,11 +91,8 @@ class Group extends Backend
|
||||
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$inheritRes = $this->applyChannelInheritance($data, $pidInt);
|
||||
if ($inheritRes !== null) {
|
||||
return $inheritRes;
|
||||
}
|
||||
$rulesRes = $this->handleRules($data);
|
||||
unset($data['channel_id']);
|
||||
$rulesRes = $this->handleRules($data, $pidInt);
|
||||
if ($rulesRes instanceof Response) return $rulesRes;
|
||||
|
||||
$result = false;
|
||||
@@ -157,11 +159,8 @@ class Group extends Backend
|
||||
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
$inheritRes = $this->applyChannelInheritance($data, $pidInt);
|
||||
if ($inheritRes !== null) {
|
||||
return $inheritRes;
|
||||
}
|
||||
$rulesRes = $this->handleRules($data);
|
||||
unset($data['channel_id']);
|
||||
$rulesRes = $this->handleRules($data, $pidInt);
|
||||
if ($rulesRes instanceof Response) return $rulesRes;
|
||||
|
||||
$result = false;
|
||||
@@ -188,7 +187,6 @@ class Group extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
if ($result !== false) {
|
||||
$this->syncDescendantChannelIds(intval((string)$row['id']));
|
||||
return $this->success(__('Update successful'));
|
||||
}
|
||||
return $this->error(__('No rows updated'));
|
||||
@@ -208,39 +206,11 @@ class Group extends Backend
|
||||
}
|
||||
$rowData = $row->toArray();
|
||||
$rowData['rules'] = array_values($rules);
|
||||
$rowData = $this->enrichChannelDisplay($rowData);
|
||||
return $this->success('', [
|
||||
'row' => $rowData
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单只读展示:根据 channel_id 解析渠道名称与渠道负责人(admin.channel_id → admin.username,取首个)
|
||||
*/
|
||||
public function channelBindPreview(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$cid = $request->get('channel_id') ?? $request->post('channel_id');
|
||||
if ($cid === null || $cid === '') {
|
||||
return $this->success('', [
|
||||
'channel_name' => '',
|
||||
'channel_admin_username' => '',
|
||||
]);
|
||||
}
|
||||
if (!Db::name('channel')->where('id', $cid)->value('id')) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
$row = $this->enrichChannelDisplay(['channel_id' => $cid]);
|
||||
|
||||
return $this->success('', [
|
||||
'channel_name' => $row['channel_name'] ?? '',
|
||||
'channel_admin_username' => $row['channel_admin_username'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function del(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
@@ -296,9 +266,29 @@ class Group extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录管理员可分配给下级角色组的菜单权限树(与 Rule::getMenus 一致,走角色组管理权限)
|
||||
*/
|
||||
public function rules(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (!$this->auth->isSuperAdmin() && !$this->canManageRoleGroups()) {
|
||||
return $this->error(__('You have no permission'), [], 401);
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $this->getAssignableMenuRules($request),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $pidInt 上级角色组 ID;大于 0 表示创建/编辑的是下级组,可与当前管理员拥有相同菜单权限
|
||||
* @return array|Response
|
||||
*/
|
||||
private function handleRules(array &$data)
|
||||
private function handleRules(array &$data, int $pidInt = 0)
|
||||
{
|
||||
if (!empty($data['rules']) && is_array($data['rules'])) {
|
||||
$superAdmin = true;
|
||||
@@ -310,7 +300,6 @@ class Group extends Backend
|
||||
$checkedRules[] = $postRuleId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($allRuleIds as $ruleId) {
|
||||
if (!in_array($ruleId, $checkedRules)) {
|
||||
$superAdmin = false;
|
||||
@@ -320,9 +309,15 @@ class Group extends Backend
|
||||
if ($superAdmin && $this->auth->isSuperAdmin()) {
|
||||
$data['rules'] = '*';
|
||||
} else {
|
||||
$ownedRuleIds = $this->auth->getRuleIds();
|
||||
$ownedRuleIds = $this->normalizeRuleIds($this->auth->getRuleIds());
|
||||
$checkedRules = $this->normalizeRuleIds($checkedRules);
|
||||
|
||||
if (!array_diff($ownedRuleIds, $checkedRules)) {
|
||||
// 仅限制「非下级」角色组:防止子管理员新建与自己平级的全权限组;下级组(pid>0)允许授予相同菜单权限
|
||||
if (
|
||||
$pidInt <= 0
|
||||
&& $ownedRuleIds !== []
|
||||
&& !array_diff($ownedRuleIds, $checkedRules)
|
||||
) {
|
||||
return $this->error(__('Role group has all your rights, please contact the upper administrator to add or do not need to add!'));
|
||||
}
|
||||
|
||||
@@ -338,6 +333,23 @@ class Group extends Backend
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $ids
|
||||
* @return array<int>
|
||||
*/
|
||||
private function normalizeRuleIds(array $ids): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($ids as $id) {
|
||||
if ($id === '*' || $id === '' || $id === null) {
|
||||
continue;
|
||||
}
|
||||
$result[] = (int) $id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
private function getGroups(Request $request, array $where = []): array
|
||||
{
|
||||
$pk = $this->model->getPk();
|
||||
@@ -366,21 +378,7 @@ class Group extends Backend
|
||||
}
|
||||
$data = $this->model->where($where)->select()->toArray();
|
||||
|
||||
$channelIds = [];
|
||||
foreach ($data as $datum) {
|
||||
$c = $datum['channel_id'] ?? null;
|
||||
if ($c !== null && $c !== '') {
|
||||
$channelIds[] = $c;
|
||||
}
|
||||
}
|
||||
$channelNames = [];
|
||||
if ($channelIds !== []) {
|
||||
$channelNames = Db::name('channel')->where('id', 'in', array_unique($channelIds))->column('name', 'id');
|
||||
}
|
||||
|
||||
foreach ($data as &$datum) {
|
||||
$c = $datum['channel_id'] ?? null;
|
||||
$datum['channel_name'] = ($c !== null && $c !== '') ? ($channelNames[$c] ?? '') : '';
|
||||
if ($datum['rules']) {
|
||||
if ($datum['rules'] == '*') {
|
||||
$datum['rules'] = __('Super administrator');
|
||||
@@ -419,85 +417,82 @@ class Group extends Backend
|
||||
return array_values(array_unique(array_merge($own, $children)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶级角色组可选渠道;子级继承父级 channel_id(不信任客户端提交的子级 channel_id)。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function applyChannelInheritance(array &$data, int $pidInt): ?Response
|
||||
private function canManageRoleGroups(): bool
|
||||
{
|
||||
if ($pidInt === 0) {
|
||||
if (!$this->auth->isSuperAdmin()) {
|
||||
unset($data['channel_id']);
|
||||
$cc = $this->getCreatorChannelId();
|
||||
if ($cc !== null && $cc !== '') {
|
||||
$data['channel_id'] = $cc;
|
||||
}
|
||||
foreach (['auth/group/index', 'auth/group/add', 'auth/group/edit', 'auth/Group/index', 'auth/Group/add', 'auth/Group/edit'] as $routePath) {
|
||||
if ($this->auth->check($routePath)) {
|
||||
return true;
|
||||
}
|
||||
$cid = $data['channel_id'] ?? null;
|
||||
if ($cid !== null && $cid !== '') {
|
||||
$exists = Db::name('channel')->where('id', $cid)->value('id');
|
||||
if (!$exists) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($data['channel_id']);
|
||||
$parent = Db::name('admin_group')->where('id', $pidInt)->find();
|
||||
if (!$parent) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
$data['channel_id'] = $parent['channel_id'];
|
||||
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function enrichChannelDisplay(array $row): array
|
||||
private function getAssignableMenuRules(Request $request): array
|
||||
{
|
||||
$row['channel_name'] = '';
|
||||
$row['channel_admin_username'] = '';
|
||||
$cid = $row['channel_id'] ?? null;
|
||||
if ($cid === null || $cid === '') {
|
||||
return $row;
|
||||
$ids = $this->auth->getRuleIds();
|
||||
$where = [];
|
||||
if (!in_array('*', $ids, true)) {
|
||||
$where[] = ['id', 'in', $ids ?: [0]];
|
||||
}
|
||||
$ch = Db::name('channel')->where('id', $cid)->field(['id', 'name'])->find();
|
||||
if (!$ch) {
|
||||
return $row;
|
||||
}
|
||||
$row['channel_name'] = $ch['name'] ?? '';
|
||||
$row['channel_admin_username'] = (string) (Db::name('admin')->where('channel_id', $cid)->order('id', 'asc')->value('username') ?? '');
|
||||
|
||||
return $row;
|
||||
$rules = (new AdminRule())
|
||||
->where($where)
|
||||
->order(['weigh' => 'desc'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$toEnglish = !$this->shouldForceMenuTitleZh($request) && $this->shouldTranslateMenuToEnglish();
|
||||
foreach ($rules as $idx => $rule) {
|
||||
$title = $rule['title'] ?? '';
|
||||
if (is_string($title) && $title !== '') {
|
||||
$rules[$idx]['title'] = $toEnglish ? $this->menuTitleToEn($title) : $this->menuTitleToZh($title);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tree->assembleChild($rules);
|
||||
}
|
||||
|
||||
private function syncDescendantChannelIds(int $groupId): void
|
||||
private function shouldTranslateMenuToEnglish(): bool
|
||||
{
|
||||
$channelId = Db::name('admin_group')->where('id', $groupId)->value('channel_id');
|
||||
$children = Db::name('admin_group')->where('pid', $groupId)->column('id');
|
||||
foreach ($children as $childId) {
|
||||
Db::name('admin_group')->where('id', $childId)->update(['channel_id' => $channelId]);
|
||||
$this->syncDescendantChannelIds($childId);
|
||||
}
|
||||
$lang = function_exists('locale') ? locale() : '';
|
||||
$normalized = is_string($lang) ? strtolower(str_replace('_', '-', trim($lang))) : '';
|
||||
|
||||
return str_starts_with($normalized, 'en');
|
||||
}
|
||||
|
||||
private function getCreatorChannelId(): mixed
|
||||
private function shouldForceMenuTitleZh(Request $request): bool
|
||||
{
|
||||
$currentAdmin = Db::name('admin')
|
||||
->field(['id', 'channel_id'])
|
||||
->where('id', $this->auth->id)
|
||||
->find();
|
||||
if ($currentAdmin && !empty($currentAdmin['channel_id'])) {
|
||||
return $currentAdmin['channel_id'];
|
||||
$flag = $request->get('force_menu_zh') ?? $request->post('force_menu_zh');
|
||||
|
||||
return in_array($flag, [1, '1', true, 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function menuTitleToZh(string $title): string
|
||||
{
|
||||
static $zhMap = null;
|
||||
if (!is_array($zhMap)) {
|
||||
$mapFile = app_path() . '/common/lang/zh-cn/admin_rule_title.php';
|
||||
$loaded = is_file($mapFile) ? include $mapFile : [];
|
||||
$zhMap = is_array($loaded) ? $loaded : [];
|
||||
}
|
||||
|
||||
return null;
|
||||
return isset($zhMap[$title]) && is_string($zhMap[$title]) ? $zhMap[$title] : $title;
|
||||
}
|
||||
|
||||
private function menuTitleToEn(string $title): string
|
||||
{
|
||||
static $enMap = null;
|
||||
if (!is_array($enMap)) {
|
||||
$mapFile = app_path() . '/common/lang/en/admin_rule_title.php';
|
||||
$loaded = is_file($mapFile) ? include $mapFile : [];
|
||||
$enMap = is_array($loaded) ? $loaded : [];
|
||||
}
|
||||
|
||||
return isset($enMap[$title]) && is_string($enMap[$title]) ? $enMap[$title] : $title;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ class DepositChannel extends Backend
|
||||
}
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error('items 必须为数组');
|
||||
return $this->error(__('Items must be an array'));
|
||||
}
|
||||
|
||||
return $this->persistChannelList($items);
|
||||
@@ -250,7 +250,7 @@ class DepositChannel extends Backend
|
||||
$got = array_column($clean, 'code');
|
||||
sort($got);
|
||||
if ($expectedCodes !== $got) {
|
||||
return $this->error('请保存全部已注册渠道行(不可缺行)');
|
||||
return $this->error(__('Please save all registered channel rows (no missing rows)'));
|
||||
}
|
||||
$json = DepositChannelLib::encodeForDb($clean);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
@@ -261,7 +261,7 @@ class DepositChannel extends Backend
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositChannelLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
try {
|
||||
|
||||
@@ -264,7 +264,7 @@ class DepositTier extends Backend
|
||||
}
|
||||
$rid = $it['id'] ?? '';
|
||||
if (is_string($rid) && $rid === $newId) {
|
||||
return $this->error('档位 ID 已存在');
|
||||
return $this->error(__('Tier ID already exists'));
|
||||
}
|
||||
}
|
||||
$items[] = $this->normalizeFormRow($payload, $newId);
|
||||
@@ -342,7 +342,7 @@ class DepositTier extends Backend
|
||||
}
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error('items 必须为数组');
|
||||
return $this->error(__('Items must be an array'));
|
||||
}
|
||||
|
||||
return $this->persistTierList($items);
|
||||
@@ -364,7 +364,7 @@ class DepositTier extends Backend
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositTierLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\admin\controller\config;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\game\DepositChannel as DepositChannelLib;
|
||||
use app\common\library\game\DepositTier as DepositTierLib;
|
||||
use app\common\library\game\FinanceCashierConfig as FinanceCashierConfigLib;
|
||||
use app\common\service\GameHotDataCoordinator;
|
||||
use app\common\service\GameHotDataLock;
|
||||
@@ -22,7 +23,7 @@ class FinanceCashierConfig extends Backend
|
||||
{
|
||||
protected bool $modelValidate = false;
|
||||
|
||||
protected array $noNeedPermission = ['index', 'save'];
|
||||
protected array $noNeedPermission = ['index', 'save', 'tierList', 'tierSave'];
|
||||
|
||||
private function hasNodePermission(WebmanRequest $request, string $action): bool
|
||||
{
|
||||
@@ -109,6 +110,21 @@ class FinanceCashierConfig extends Backend
|
||||
if (!is_array($payload)) {
|
||||
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
$pruneTierCurrencies = [];
|
||||
if (isset($payload['prune_tier_currency_codes']) && is_array($payload['prune_tier_currency_codes'])) {
|
||||
foreach ($payload['prune_tier_currency_codes'] as $c) {
|
||||
if (!is_string($c)) {
|
||||
continue;
|
||||
}
|
||||
$s = strtoupper(trim($c));
|
||||
if ($s !== '') {
|
||||
$pruneTierCurrencies[] = $s;
|
||||
}
|
||||
}
|
||||
$pruneTierCurrencies = array_values(array_unique($pruneTierCurrencies));
|
||||
}
|
||||
unset($payload['prune_tier_currency_codes']);
|
||||
|
||||
try {
|
||||
$json = FinanceCashierConfigLib::encodeForDb($payload);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
@@ -119,7 +135,7 @@ class FinanceCashierConfig extends Backend
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(FinanceCashierConfigLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
try {
|
||||
@@ -162,11 +178,17 @@ class FinanceCashierConfig extends Backend
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
if ($pruneTierCurrencies !== []) {
|
||||
$this->pruneDepositTiersByCurrencies($pruneTierCurrencies, $now);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(FinanceCashierConfigLib::CONFIG_KEY);
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositChannelLib::CONFIG_KEY);
|
||||
if ($pruneTierCurrencies !== []) {
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY);
|
||||
}
|
||||
|
||||
return $this->success(__('Saved successfully'));
|
||||
} finally {
|
||||
@@ -174,6 +196,83 @@ class FinanceCashierConfig extends Backend
|
||||
}
|
||||
}
|
||||
|
||||
public function tierList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->hasNodePermission($request, 'tierList')) {
|
||||
return $this->error(__('You have no permission'), [], 401);
|
||||
}
|
||||
if ($request->method() !== 'GET') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$list = $this->loadDepositTierItems();
|
||||
|
||||
return $this->success('', ['list' => $list]);
|
||||
}
|
||||
|
||||
public function tierSave(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!$this->hasNodePermission($request, 'tierSave')) {
|
||||
return $this->error(__('You have no permission'), [], 401);
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$payload = $request->post();
|
||||
if (!is_array($payload)) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error(__('Items must be an array'));
|
||||
}
|
||||
try {
|
||||
$clean = DepositTierLib::prepareItemsForSave(array_values($items));
|
||||
$json = DepositTierLib::encodeForDb($clean);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
$now = time();
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(DepositTierLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
$exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
|
||||
if ($exists) {
|
||||
Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([
|
||||
'config_value' => $json,
|
||||
'value_type' => 'json',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('game_config')->insert([
|
||||
'config_key' => DepositTierLib::CONFIG_KEY,
|
||||
'config_value' => $json,
|
||||
'value_type' => 'json',
|
||||
'remark' => '充值档位 JSON 数组(financeCashierConfig 统一维护)',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(DepositTierLib::CONFIG_KEY);
|
||||
|
||||
return $this->success(__('Saved successfully'));
|
||||
} catch (Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{name: string, name_en: string, sort: int}>
|
||||
*/
|
||||
@@ -194,4 +293,54 @@ class FinanceCashierConfig extends Backend
|
||||
|
||||
return $registryOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function loadDepositTierItems(): array
|
||||
{
|
||||
$row = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
|
||||
|
||||
return DepositTierLib::parseFromConfigValue(is_array($row) ? ($row['config_value'] ?? null) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $currencyCodes
|
||||
*/
|
||||
private function pruneDepositTiersByCurrencies(array $currencyCodes, int $now): void
|
||||
{
|
||||
if ($currencyCodes === []) {
|
||||
return;
|
||||
}
|
||||
$exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
|
||||
$items = $this->loadDepositTierItems();
|
||||
$filtered = [];
|
||||
foreach ($items as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$currency = isset($row['currency']) && is_string($row['currency']) ? strtoupper(trim($row['currency'])) : '';
|
||||
if ($currency !== '' && in_array($currency, $currencyCodes, true)) {
|
||||
continue;
|
||||
}
|
||||
$filtered[] = $row;
|
||||
}
|
||||
$json = DepositTierLib::encodeForDb(DepositTierLib::prepareItemsForSave(array_values($filtered)));
|
||||
if ($exists) {
|
||||
Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([
|
||||
'config_value' => $json,
|
||||
'value_type' => 'json',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('game_config')->insert([
|
||||
'config_key' => DepositTierLib::CONFIG_KEY,
|
||||
'config_value' => $json,
|
||||
'value_type' => 'json',
|
||||
'remark' => '充值档位 JSON 数组(financeCashierConfig 统一维护)',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use app\common\library\game\DepositTier;
|
||||
use app\common\library\game\FinanceCashierConfig;
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
|
||||
use app\common\service\GameHotDataCoordinator;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -50,6 +52,17 @@ class GameConfig extends Backend
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function formExcludedKeys(): array
|
||||
{
|
||||
return [
|
||||
'period_auto_create_enabled',
|
||||
'period_manual_create_enabled',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表:排除独立表单维护的配置键
|
||||
*/
|
||||
@@ -128,4 +141,90 @@ class GameConfig extends Backend
|
||||
'total' => $res->total(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单批量保存:一次提交当前页面全部配置
|
||||
*/
|
||||
public function save(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$payload = $request->post();
|
||||
if (!is_array($payload)) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$exclude = array_merge($this->excludedConfigKeys(), $this->formExcludedKeys());
|
||||
$rows = Db::name('game_config')
|
||||
->whereNotIn('config_key', $exclude)
|
||||
->field(['id', 'config_key', 'value_type'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if ($rows === []) {
|
||||
return $this->success(__('Operation completed'));
|
||||
}
|
||||
|
||||
$allowMap = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!isset($row['config_key']) || !is_string($row['config_key']) || $row['config_key'] === '') {
|
||||
continue;
|
||||
}
|
||||
$allowMap[$row['config_key']] = $row;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$updatedKeys = [];
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($items as $configKey => $configValue) {
|
||||
if (!is_string($configKey) || $configKey === '' || !isset($allowMap[$configKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$valueType = isset($allowMap[$configKey]['value_type']) && is_string($allowMap[$configKey]['value_type'])
|
||||
? $allowMap[$configKey]['value_type']
|
||||
: 'string';
|
||||
|
||||
$persistValue = $configValue;
|
||||
if (is_array($persistValue)) {
|
||||
if ($valueType === 'json') {
|
||||
$persistValue = json_encode($persistValue, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$persistValue = '';
|
||||
}
|
||||
}
|
||||
if ($persistValue === null) {
|
||||
$persistValue = '';
|
||||
}
|
||||
|
||||
Db::name('game_config')->where('id', $allowMap[$configKey]['id'])->update([
|
||||
'config_value' => (string) $persistValue,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$updatedKeys[] = $configKey;
|
||||
}
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
foreach (array_values(array_unique($updatedKeys)) as $key) {
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted($key);
|
||||
}
|
||||
|
||||
return $this->success(__('Operation completed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ class StreakWinReward extends Backend
|
||||
}
|
||||
$payload = $request->post('rows');
|
||||
if (!is_array($payload)) {
|
||||
return $this->error('参数错误');
|
||||
return $this->error(__('Invalid parameters'));
|
||||
}
|
||||
$encoded = StreakWinRewardLib::encodeForDb($payload);
|
||||
$now = time();
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(StreakWinRewardLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
Db::startTrans();
|
||||
@@ -125,7 +125,7 @@ class StreakWinReward extends Backend
|
||||
StreakWinRewardLib::clearCache();
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(StreakWinRewardLib::CONFIG_KEY);
|
||||
|
||||
return $this->success('保存成功');
|
||||
return $this->success(__('Saved successfully'));
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class ZiHuaDictionary extends Backend
|
||||
}
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error('items 必须为数组');
|
||||
return $this->error(__('Items must be an array'));
|
||||
}
|
||||
try {
|
||||
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
|
||||
@@ -108,7 +108,7 @@ class ZiHuaDictionary extends Backend
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
try {
|
||||
|
||||
82
app/admin/controller/docs/Doc36ZiHuaMobileApi.php
Normal file
82
app/admin/controller/docs/Doc36ZiHuaMobileApi.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\docs;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\docs\MarkdownDocReader;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 后台只读展示《36字花-移动端接口设计草案》Markdown,并提供下载。
|
||||
*/
|
||||
class Doc36ZiHuaMobileApi extends Backend
|
||||
{
|
||||
private const DOC_ZH = [
|
||||
'relative' => 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md',
|
||||
'filename' => '36字花-移动端接口设计草案.md',
|
||||
'ascii_fallback' => '36zihua-mobile-api-design-draft.md',
|
||||
];
|
||||
|
||||
private const DOC_EN = [
|
||||
'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . '36zihua-mobile-api-design-draft.md',
|
||||
'filename' => '36zihua-mobile-api-design-draft.md',
|
||||
'ascii_fallback' => '36zihua-mobile-api-design-draft.md',
|
||||
];
|
||||
|
||||
public function content(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||
if (!is_file($doc['path'])) {
|
||||
return $this->error(__('Document file not found'));
|
||||
}
|
||||
|
||||
$raw = file_get_contents($doc['path']);
|
||||
if ($raw === false) {
|
||||
return $this->error(__('Failed to read document'));
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'markdown' => $raw,
|
||||
'filename' => $doc['filename'],
|
||||
'lang' => $doc['lang'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||
if (!is_file($doc['path'])) {
|
||||
return $this->error(__('Document file not found'));
|
||||
}
|
||||
|
||||
$body = file_get_contents($doc['path']);
|
||||
if ($body === false) {
|
||||
return $this->error(__('Failed to read document'));
|
||||
}
|
||||
|
||||
$disposition = sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$doc['ascii_fallback'],
|
||||
rawurlencode($doc['filename'])
|
||||
);
|
||||
|
||||
return new Response(200, [
|
||||
'Content-Type' => 'text/markdown; charset=UTF-8',
|
||||
'Content-Disposition' => $disposition,
|
||||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||
], $body);
|
||||
}
|
||||
}
|
||||
82
app/admin/controller/docs/DocCommissionShare.php
Normal file
82
app/admin/controller/docs/DocCommissionShare.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\docs;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\docs\MarkdownDocReader;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 后台只读展示《分红说明文档》Markdown,并提供下载。
|
||||
*/
|
||||
class DocCommissionShare extends Backend
|
||||
{
|
||||
private const DOC_ZH = [
|
||||
'relative' => 'docs' . DIRECTORY_SEPARATOR . '分红说明文档.md',
|
||||
'filename' => '分红说明文档.md',
|
||||
'ascii_fallback' => 'commission-share-guide.md',
|
||||
];
|
||||
|
||||
private const DOC_EN = [
|
||||
'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . 'commission-share-guide.md',
|
||||
'filename' => 'commission-share-guide.md',
|
||||
'ascii_fallback' => 'commission-share-guide.md',
|
||||
];
|
||||
|
||||
public function content(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||
if (!is_file($doc['path'])) {
|
||||
return $this->error(__('Document file not found'));
|
||||
}
|
||||
|
||||
$raw = file_get_contents($doc['path']);
|
||||
if ($raw === false) {
|
||||
return $this->error(__('Failed to read document'));
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'markdown' => $raw,
|
||||
'filename' => $doc['filename'],
|
||||
'lang' => $doc['lang'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||
if (!is_file($doc['path'])) {
|
||||
return $this->error(__('Document file not found'));
|
||||
}
|
||||
|
||||
$body = file_get_contents($doc['path']);
|
||||
if ($body === false) {
|
||||
return $this->error(__('Failed to read document'));
|
||||
}
|
||||
|
||||
$disposition = sprintf(
|
||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||
$doc['ascii_fallback'],
|
||||
rawurlencode($doc['filename'])
|
||||
);
|
||||
|
||||
return new Response(200, [
|
||||
'Content-Type' => 'text/markdown; charset=UTF-8',
|
||||
'Content-Disposition' => $disposition,
|
||||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||
], $body);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\admin\WebSocketConfigHelper;
|
||||
use app\common\service\GameLiveService;
|
||||
use app\common\service\GameRecordService;
|
||||
use app\common\service\GameWebSocketAuthHelper;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
/**
|
||||
* 游戏实时对局
|
||||
* Game live control.
|
||||
*/
|
||||
class Live extends Backend
|
||||
{
|
||||
@@ -24,6 +26,7 @@ class Live extends Backend
|
||||
{
|
||||
$recordIdRaw = $this->request ? $this->request->get('record_id') : null;
|
||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||
|
||||
return $this->success('', GameLiveService::buildSnapshot($recordId));
|
||||
}
|
||||
|
||||
@@ -35,9 +38,63 @@ class Live extends Backend
|
||||
}
|
||||
$recordIdRaw = $request->get('record_id');
|
||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||
|
||||
return $this->success('', GameLiveService::buildSnapshot($recordId));
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket config for admin live page.
|
||||
*/
|
||||
public function wsConfig(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$topics = [
|
||||
'admin.live.snapshot',
|
||||
'admin.live.opened',
|
||||
'admin.live.finalized',
|
||||
'jackpot.hit',
|
||||
'period.tick',
|
||||
'period.locked',
|
||||
'period.opened',
|
||||
'period.payout',
|
||||
'period.payout.tick',
|
||||
'bet.accepted',
|
||||
'bet.win',
|
||||
'wallet.changed',
|
||||
'auto.spin.progress',
|
||||
];
|
||||
|
||||
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
|
||||
$adminWs = GameWebSocketAuthHelper::issueAdminWsToken($adminId);
|
||||
$baseWsUrl = WebSocketConfigHelper::wsUrl($request);
|
||||
$wsUrl = GameWebSocketAuthHelper::buildAdminConnectUrl(
|
||||
$baseWsUrl,
|
||||
(string) ($adminWs['token'] ?? '')
|
||||
);
|
||||
|
||||
return $this->success('', [
|
||||
'name' => 'ws.admin.live',
|
||||
'ws_url' => $wsUrl,
|
||||
'ws_base_url' => $baseWsUrl,
|
||||
'admin-ws-token' => (string) ($adminWs['token'] ?? ''),
|
||||
'admin_ws_token_ttl' => (int) ($adminWs['ttl'] ?? 0),
|
||||
'ws_query_params' => [
|
||||
GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN,
|
||||
],
|
||||
'connect_tip' => 'The admin live page auto-subscribes topics for status, draw result and payout events.',
|
||||
'subscribe_topics' => $topics,
|
||||
'sample_messages' => [
|
||||
'{"action":"ping"}',
|
||||
'{"action":"subscribe","topics":["admin.live.snapshot","admin.live.opened"]}',
|
||||
'{"action":"subscribe","topics":["period.tick","period.opened","wallet.changed"]}',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function calculate(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
@@ -61,7 +118,7 @@ class Live extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约本期开奖号码(倒计时结束后自动开奖,不立即开奖)。
|
||||
* Schedule draw number for current period.
|
||||
*/
|
||||
public function draw(WebmanRequest $request): Response
|
||||
{
|
||||
@@ -89,7 +146,7 @@ class Live extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏运行开关:关闭时禁止下注、派彩结束后不自动开新期,但当局仍自动开奖并结算;重新开启且无进行中局时立即创建新一期。
|
||||
* Runtime switch for game period generation and betting.
|
||||
*/
|
||||
public function runtime(WebmanRequest $request): Response
|
||||
{
|
||||
@@ -102,15 +159,17 @@ class Live extends Backend
|
||||
}
|
||||
$raw = $request->post('enabled');
|
||||
$enabled = $raw === true || $raw === '1' || $raw === 1;
|
||||
GameRecordService::setLiveRuntimeEnabled($enabled);
|
||||
GameRecordService::setAutoCreateEnabled($enabled);
|
||||
if ($enabled) {
|
||||
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
|
||||
} else {
|
||||
GameLiveService::voidOrphanActiveRoundsOnRuntimeDisabled();
|
||||
}
|
||||
return $this->success('', GameLiveService::buildSnapshot(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废当前对局(下注/封盘阶段),填写原因后退款待开奖注单并关闭运行开关。
|
||||
* Void current period and refund waiting orders.
|
||||
*/
|
||||
public function voidPeriod(WebmanRequest $request): Response
|
||||
{
|
||||
@@ -131,6 +190,12 @@ class Live extends Backend
|
||||
return $this->error(is_string($errMsg) ? $errMsg : __('Void failed'));
|
||||
}
|
||||
$okMsg = $res['msg'] ?? '';
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', GameLiveService::buildSnapshot(null));
|
||||
$snapshot = GameLiveService::buildSnapshotAfterVoid();
|
||||
$refund = $res['refund'] ?? null;
|
||||
if (is_array($refund)) {
|
||||
$snapshot['void_refund'] = $refund;
|
||||
}
|
||||
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', $snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\GameBetSettleService;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -35,7 +37,7 @@ class PlayRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游玩记录由游戏接口生成,禁止后台手工新增');
|
||||
return $this->error(__('Play record is generated by game API; manual creation is not allowed'));
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
@@ -44,7 +46,7 @@ class PlayRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游玩记录不可编辑');
|
||||
return $this->error(__('Play record cannot be edited'));
|
||||
}
|
||||
|
||||
public function del(WebmanRequest $request): Response
|
||||
@@ -53,7 +55,7 @@ class PlayRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游玩记录不可删除');
|
||||
return $this->error(__('Play record cannot be deleted'));
|
||||
}
|
||||
|
||||
public function sortable(WebmanRequest $request): Response
|
||||
@@ -62,7 +64,7 @@ class PlayRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('不支持排序');
|
||||
return $this->error(__('Sorting is not supported'));
|
||||
}
|
||||
|
||||
protected function _index(): Response
|
||||
@@ -74,30 +76,200 @@ class PlayRecord extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->with($this->withJoinTable)
|
||||
->visible([
|
||||
'user' => ['username', 'phone'],
|
||||
'channel' => ['name'],
|
||||
'gameRecord' => ['period_no', 'status'],
|
||||
// 避免 ThinkORM withJoin 对 game_record 的字段缓存导致 select 出已删除列(如 preset_number)
|
||||
// 这里改为手写 join + 明确 field 列表,保证数据库字段变更后不受 schema 缓存影响。
|
||||
$query = Db::name($table)->alias($mainShort !== '' ? $mainShort : 'play_record')
|
||||
->leftJoin('user user', 'user.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.user_id')
|
||||
->leftJoin('channel channel', 'channel.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.channel_id')
|
||||
->leftJoin('game_record game_record', 'game_record.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.period_id')
|
||||
->where($where);
|
||||
|
||||
$res = $query
|
||||
->field([
|
||||
($mainShort !== '' ? $mainShort : 'play_record') . '.*',
|
||||
'user.username as user__username',
|
||||
'user.phone as user__phone',
|
||||
'channel.name as channel__name',
|
||||
'game_record.period_no as gameRecord__period_no',
|
||||
'game_record.status as gameRecord__status',
|
||||
'game_record.result_number as gameRecord__result_number',
|
||||
])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
$list = $res->items();
|
||||
$total = $res->total();
|
||||
|
||||
// 将 join 扁平字段还原为原页面所需结构:user/channel/gameRecord
|
||||
foreach ($list as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$row['user'] = [
|
||||
'username' => isset($row['user__username']) ? (string) $row['user__username'] : '',
|
||||
'phone' => isset($row['user__phone']) ? (string) $row['user__phone'] : '',
|
||||
];
|
||||
$row['channel'] = [
|
||||
'name' => isset($row['channel__name']) ? (string) $row['channel__name'] : '',
|
||||
];
|
||||
$row['gameRecord'] = [
|
||||
'period_no' => isset($row['gameRecord__period_no']) ? (string) $row['gameRecord__period_no'] : '',
|
||||
'status' => isset($row['gameRecord__status']) && is_numeric((string) $row['gameRecord__status'])
|
||||
? (int) $row['gameRecord__status']
|
||||
: null,
|
||||
'result_number' => isset($row['gameRecord__result_number']) && $row['gameRecord__result_number'] !== '' && $row['gameRecord__result_number'] !== null
|
||||
? (string) $row['gameRecord__result_number']
|
||||
: null,
|
||||
];
|
||||
unset(
|
||||
$row['user__username'],
|
||||
$row['user__phone'],
|
||||
$row['channel__name'],
|
||||
$row['gameRecord__period_no'],
|
||||
$row['gameRecord__status'],
|
||||
$row['gameRecord__result_number'],
|
||||
);
|
||||
$list[$idx] = $row;
|
||||
}
|
||||
|
||||
$threshold = $this->jackpotMaxAmount();
|
||||
foreach ($list as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
|
||||
$win = bcadd(strval($row['win_amount'] ?? '0'), '0', 2);
|
||||
$needReview = bccomp($threshold, '0', 2) > 0 && bccomp($win, $threshold, 2) >= 0;
|
||||
$canApprove = $needReview && $status === GameBetSettleService::PLAY_STATUS_PENDING_REVIEW;
|
||||
$row['jackpot_need_review'] = $needReview ? 1 : 0;
|
||||
$row['can_jackpot_approve'] = $canApprove ? 1 : 0;
|
||||
$list[$idx] = $row;
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 大奖审核通过并派彩(仅 win_amount >= game_config.jackpot_max_amount 且 status=待审核 才可操作)
|
||||
*/
|
||||
public function approveJackpot(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$idRaw = $request->post('id');
|
||||
if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$id = (int) $idRaw;
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$remarkRaw = $request->post('remark');
|
||||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||||
|
||||
// 权限范围校验:复用列表逻辑(非超管只能操作其下辖用户)
|
||||
if ($this->shouldApplyUserAdminScope()) {
|
||||
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
|
||||
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
|
||||
if ($uid <= 0) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
$ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id');
|
||||
$aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId;
|
||||
if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
}
|
||||
|
||||
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$result = GameBetSettleService::approveJackpotPlayRecord($id, $adminId, $remark);
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
Db::rollback();
|
||||
$msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error');
|
||||
return $this->error($msg);
|
||||
}
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Approved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 大奖审核拒绝(remark 必填)
|
||||
*/
|
||||
public function rejectJackpot(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$idRaw = $request->post('id');
|
||||
if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$id = (int) $idRaw;
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$remarkRaw = $request->post('remark');
|
||||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||||
if ($remark === '') {
|
||||
return $this->error(__('Please provide reject reason'));
|
||||
}
|
||||
|
||||
if ($this->shouldApplyUserAdminScope()) {
|
||||
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
|
||||
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
|
||||
if ($uid <= 0) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
$ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id');
|
||||
$aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId;
|
||||
if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
}
|
||||
|
||||
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
|
||||
Db::startTrans();
|
||||
try {
|
||||
$result = GameBetSettleService::rejectJackpotPlayRecord($id, $adminId, $remark);
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
Db::rollback();
|
||||
$msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error');
|
||||
return $this->error($msg);
|
||||
}
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
return $this->success(__('Rejected'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
@@ -116,5 +288,26 @@ class PlayRecord extends Backend
|
||||
$adminIds = array_values(array_unique(array_filter($adminIds, static fn($id) => $id > 0)));
|
||||
return $adminIds === [] ? [0] : $adminIds;
|
||||
}
|
||||
|
||||
private function jackpotMaxAmount(): string
|
||||
{
|
||||
$row = Db::name('game_config')->where('config_key', GameBetSettleService::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
|
||||
if (!is_array($row)) {
|
||||
return '0.00';
|
||||
}
|
||||
$raw = $row['config_value'] ?? null;
|
||||
if ($raw === null || $raw === '') {
|
||||
return '0.00';
|
||||
}
|
||||
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
|
||||
if ($v === '' || !is_numeric($v)) {
|
||||
return '0.00';
|
||||
}
|
||||
$normalized = bcadd($v, '0', 2);
|
||||
if (bccomp($normalized, '0', 2) <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -31,13 +32,51 @@ class Record extends Backend
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function _index(): Response
|
||||
{
|
||||
if ($this->request && $this->request->get('select')) {
|
||||
return $this->select($this->request);
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
|
||||
$res = $this->model
|
||||
->field($this->indexField)
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->with($this->withJoinTable)
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
$list = $res->items();
|
||||
foreach ($list as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$status = isset($row['status']) && is_numeric((string) $row['status']) ? (int) $row['status'] : 0;
|
||||
$reason = isset($row['void_reason']) && is_string($row['void_reason']) ? $row['void_reason'] : '';
|
||||
// 将“系统自愈作废”的对局在列表中标记为【异常】(展示态,不改库中 status=5 的事实)
|
||||
if ($status === 5 && $reason !== '' && str_starts_with($reason, 'system_recover:')) {
|
||||
$row['status'] = 6;
|
||||
$list[$idx] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function add(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游戏对局记录由系统自动生成,禁止后台手工新增');
|
||||
return $this->error(__('Game record is generated by system; manual creation is not allowed'));
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
@@ -47,7 +86,7 @@ class Record extends Backend
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() === 'POST') {
|
||||
return $this->error('游戏对局记录不可编辑');
|
||||
return $this->error(__('Game record cannot be edited'));
|
||||
}
|
||||
$pk = $this->model->getPk();
|
||||
$id = $request->get($pk);
|
||||
@@ -64,6 +103,247 @@ class Record extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('游戏对局记录不可删除');
|
||||
return $this->error(__('Game record cannot be deleted'));
|
||||
}
|
||||
|
||||
public function abnormalList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$limitRaw = $request->get('limit', 30);
|
||||
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30;
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
|
||||
$rows = Db::name('game_record')
|
||||
->where('status', 5)
|
||||
// 仅展示服务重启自愈导致的异常作废,排除管理员手动作废
|
||||
->whereLike('void_reason', 'system_recover:%')
|
||||
->field(['id', 'period_no', 'void_reason', 'update_time'])
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$periodIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$pid = (int) ($row['id'] ?? 0);
|
||||
if ($pid > 0) {
|
||||
$periodIds[] = $pid;
|
||||
}
|
||||
}
|
||||
$refundAggByPeriod = [];
|
||||
$refundIdsByPeriod = [];
|
||||
if ($periodIds !== []) {
|
||||
$aggRows = Db::name('bet_order')
|
||||
->whereIn('period_id', $periodIds)
|
||||
->where('status', 3)
|
||||
->fieldRaw('period_id as pid, COUNT(*) as cnt, COUNT(DISTINCT user_id) as users, COALESCE(SUM(total_amount), 0) as amt')
|
||||
->group('period_id')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($aggRows as $a) {
|
||||
$pid = (int) ($a['pid'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$refundAggByPeriod[$pid] = [
|
||||
'orders' => (int) ($a['cnt'] ?? 0),
|
||||
'users' => (int) ($a['users'] ?? 0),
|
||||
'amount' => (string) ($a['amt'] ?? '0.00'),
|
||||
];
|
||||
}
|
||||
|
||||
$idRows = Db::name('bet_order')
|
||||
->whereIn('period_id', $periodIds)
|
||||
->where('status', 3)
|
||||
->field(['period_id', 'id'])
|
||||
->order('id', 'desc')
|
||||
->limit(2000)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($idRows as $r) {
|
||||
$pid = (int) ($r['period_id'] ?? 0);
|
||||
$bid = (int) ($r['id'] ?? 0);
|
||||
if ($pid <= 0 || $bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($refundIdsByPeriod[$pid])) {
|
||||
$refundIdsByPeriod[$pid] = [];
|
||||
}
|
||||
if (count($refundIdsByPeriod[$pid]) < 50) {
|
||||
$refundIdsByPeriod[$pid][] = $bid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$meta = $this->parseRecoverVoidReason(is_string($row['void_reason'] ?? null) ? $row['void_reason'] : '');
|
||||
$reason = is_string($row['void_reason'] ?? null) ? $row['void_reason'] : '';
|
||||
$isAutoRecover = $this->isSystemRecoverReason($reason);
|
||||
$pid = (int) ($row['id'] ?? 0);
|
||||
$agg = $refundAggByPeriod[$pid] ?? null;
|
||||
$users = (int) ($meta['users'] ?? 0);
|
||||
$orders = (int) ($meta['orders'] ?? 0);
|
||||
$amount = is_string($meta['amount'] ?? null) ? $meta['amount'] : '0.00';
|
||||
if (is_array($agg)) {
|
||||
if ($orders <= 0 && ($agg['orders'] ?? 0) > 0) {
|
||||
$orders = (int) ($agg['orders'] ?? 0);
|
||||
}
|
||||
if ($users <= 0 && ($agg['users'] ?? 0) > 0) {
|
||||
$users = (int) ($agg['users'] ?? 0);
|
||||
}
|
||||
if (bccomp($amount, '0', 2) <= 0 && is_string($agg['amount'] ?? null)) {
|
||||
$amount = (string) $agg['amount'];
|
||||
}
|
||||
}
|
||||
$list[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'period_no' => (string) ($row['period_no'] ?? ''),
|
||||
'abnormal_from_status' => $meta['from_status'],
|
||||
'refunded_user_count' => $users,
|
||||
'refunded_order_count' => $orders,
|
||||
'refunded_total_amount' => $amount,
|
||||
'refunded_order_ids' => $refundIdsByPeriod[$pid] ?? [],
|
||||
'recovered_at' => (int) ($row['update_time'] ?? 0),
|
||||
'void_reason' => $reason,
|
||||
'is_auto_recover' => $isAutoRecover ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => count($list),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某期对局的游玩(压注)记录列表(用于对局记录页弹窗查看)。
|
||||
*/
|
||||
public function playRecordList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$periodIdRaw = $request->get('period_id');
|
||||
if (!is_numeric((string) $periodIdRaw)) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$periodId = (int) $periodIdRaw;
|
||||
if ($periodId <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$pageRaw = $request->get('page', 1);
|
||||
$page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1;
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
|
||||
$limitRaw = $request->get('limit', 30);
|
||||
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30;
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
|
||||
$query = Db::name('game_play_record')
|
||||
->alias('pr')
|
||||
->leftJoin('user u', 'u.id = pr.user_id')
|
||||
->leftJoin('channel c', 'c.id = pr.channel_id')
|
||||
->where('pr.period_id', $periodId);
|
||||
|
||||
$total = (int) $query->count('pr.id');
|
||||
$list = $query
|
||||
->field([
|
||||
'pr.id',
|
||||
'pr.period_id',
|
||||
'pr.user_id',
|
||||
'pr.channel_id',
|
||||
'pr.pick_numbers',
|
||||
'pr.total_amount',
|
||||
'pr.streak_at_bet',
|
||||
'pr.is_auto',
|
||||
'pr.win_amount',
|
||||
'pr.jackpot_extra_amount',
|
||||
'pr.status',
|
||||
'pr.idempotency_key',
|
||||
'pr.create_time',
|
||||
'pr.update_time',
|
||||
'u.username as user_username',
|
||||
'c.name as channel_name',
|
||||
])
|
||||
->order('pr.id', 'desc')
|
||||
->page($page, $limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from_status:int,users:int,orders:int,amount:string}
|
||||
*/
|
||||
private function parseRecoverVoidReason(string $reason): array
|
||||
{
|
||||
$meta = [
|
||||
'from_status' => -1,
|
||||
'users' => 0,
|
||||
'orders' => 0,
|
||||
'amount' => '0.00',
|
||||
];
|
||||
if (!$this->isSystemRecoverReason($reason)) {
|
||||
return $meta;
|
||||
}
|
||||
$payload = substr($reason, strlen('system_recover:'));
|
||||
if (!str_contains($payload, '=')) {
|
||||
return $meta;
|
||||
}
|
||||
$parts = explode('|', $payload);
|
||||
foreach ($parts as $part) {
|
||||
$item = trim($part);
|
||||
if ($item === '' || !str_contains($item, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $item, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
if ($key === 'from' && is_numeric($value)) {
|
||||
$meta['from_status'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'users' && is_numeric($value)) {
|
||||
$meta['users'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'orders' && is_numeric($value)) {
|
||||
$meta['orders'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'amount' && $value !== '') {
|
||||
$meta['amount'] = $value;
|
||||
}
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
|
||||
private function isSystemRecoverReason(string $reason): bool
|
||||
{
|
||||
return $reason !== '' && str_starts_with($reason, 'system_recover:');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class ZiHuaDictionary extends Backend
|
||||
}
|
||||
$items = $payload['items'] ?? null;
|
||||
if (!is_array($items)) {
|
||||
return $this->error('items 必须为数组');
|
||||
return $this->error(__('Items must be an array'));
|
||||
}
|
||||
try {
|
||||
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
|
||||
@@ -94,7 +94,7 @@ class ZiHuaDictionary extends Backend
|
||||
$resourceKey = GameHotDataLock::safeResourceKeyForConfig(ZiHuaDictionaryLib::CONFIG_KEY);
|
||||
$lock = GameHotDataLock::tryAcquire(GameHotDataLock::TYPE_GAME_CONFIG, $resourceKey);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该配置正在被其他操作占用,请稍后再试');
|
||||
return $this->error(__('This config is locked by another operation, please try again later'));
|
||||
}
|
||||
try {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
/**
|
||||
* 用户公告阅读记录
|
||||
* 用户公告阅读记录(只读,由客户端确认已读写入)
|
||||
*/
|
||||
class UserNoticeRead extends Backend
|
||||
{
|
||||
@@ -33,6 +33,42 @@ class UserNoticeRead extends Backend
|
||||
return null;
|
||||
}
|
||||
|
||||
public function add(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error(__('User notice read records are client-generated; manual creation is not allowed'));
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error(__('User notice read record cannot be edited'));
|
||||
}
|
||||
|
||||
public function del(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error(__('User notice read record cannot be deleted'));
|
||||
}
|
||||
|
||||
public function sortable(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error(__('Sorting is not supported'));
|
||||
}
|
||||
|
||||
protected function _index(): Response
|
||||
{
|
||||
if ($this->request && $this->request->get('select')) {
|
||||
@@ -42,7 +78,7 @@ class UserNoticeRead extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use Webman\Http\Request as WebmanRequest;
|
||||
*/
|
||||
class AdminWithdrawOrder extends Backend
|
||||
{
|
||||
protected array $noNeedPermission = ['stats', 'approve', 'reject'];
|
||||
protected array $noNeedPermission = ['stats'];
|
||||
|
||||
protected ?object $model = null;
|
||||
|
||||
@@ -42,8 +42,9 @@ class AdminWithdrawOrder extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
$where[] = [$mainShort . '.channel_id', 'in', $this->getCurrentAdminChannelIds()];
|
||||
$scopedAdminIds = $this->getManageableScopeAdminIds();
|
||||
if ($mainShort !== '' && $scopedAdminIds !== []) {
|
||||
$where[] = [$mainShort . '.admin_id', 'in', $scopedAdminIds];
|
||||
}
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
@@ -58,13 +59,15 @@ class AdminWithdrawOrder extends Backend
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
$list = $res->items();
|
||||
foreach ($list as $idx => $item) {
|
||||
$list[$idx]['can_review'] = $this->canReviewOrder(is_array($item) ? $item : []) ? 1 : 0;
|
||||
$listArr = [];
|
||||
foreach ($res->items() as $item) {
|
||||
$row = is_array($item) ? $item : $item->toArray();
|
||||
$row['can_review'] = $this->canReviewOrder($row) ? 1 : 0;
|
||||
$listArr[] = $row;
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'list' => $listArr,
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
@@ -78,7 +81,7 @@ class AdminWithdrawOrder extends Backend
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
if ($this->request && $this->request->method() === 'POST') {
|
||||
return $this->error('请使用通过/拒绝按钮审核');
|
||||
return $this->error(__('Please use the review action to process this order'));
|
||||
}
|
||||
$row = $this->loadWithRelations(intval(strval($id)));
|
||||
if (!$row) {
|
||||
@@ -90,7 +93,10 @@ class AdminWithdrawOrder extends Backend
|
||||
return $this->success('', ['row' => $row]);
|
||||
}
|
||||
|
||||
public function approve(WebmanRequest $request): Response
|
||||
/**
|
||||
* 审核(通过 / 拒绝)
|
||||
*/
|
||||
public function review(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
@@ -100,9 +106,14 @@ class AdminWithdrawOrder extends Backend
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$id = intval(strval($request->post('id', 0)));
|
||||
if ($id <= 0) {
|
||||
$action = strtolower(trim((string) $request->post('action', '')));
|
||||
if ($id <= 0 || !in_array($action, ['approve', 'reject'], true)) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$remark = trim((string) $request->post('remark', ''));
|
||||
if ($action === 'reject' && $remark === '') {
|
||||
return $this->error(__('Please provide reject reason'));
|
||||
}
|
||||
$order = Db::name('admin_withdraw_order')->where('id', $id)->find();
|
||||
if (!is_array($order)) {
|
||||
return $this->error(__('Record not found'));
|
||||
@@ -111,56 +122,22 @@ class AdminWithdrawOrder extends Backend
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
if (intval($order['status'] ?? 0) !== 0) {
|
||||
return $this->error('该提现订单已审核');
|
||||
return $this->error(__('This withdraw order has already been reviewed'));
|
||||
}
|
||||
$remark = trim((string) $request->post('remark', ''));
|
||||
Db::startTrans();
|
||||
try {
|
||||
AdminWalletService::approveWithdraw($order, intval($this->auth->id), $remark);
|
||||
if ($action === 'approve') {
|
||||
AdminWalletService::approveWithdraw($order, intval($this->auth->id), $remark);
|
||||
} else {
|
||||
AdminWalletService::rejectWithdraw($order, intval($this->auth->id), $remark);
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
return $this->success('审核通过');
|
||||
}
|
||||
|
||||
public function reject(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$id = intval(strval($request->post('id', 0)));
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
$remark = trim((string) $request->post('remark', ''));
|
||||
if ($remark === '') {
|
||||
return $this->error('请填写拒绝原因');
|
||||
}
|
||||
$order = Db::name('admin_withdraw_order')->where('id', $id)->find();
|
||||
if (!is_array($order)) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->canReviewOrder($order)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
if (intval($order['status'] ?? 0) !== 0) {
|
||||
return $this->error('该提现订单已审核');
|
||||
}
|
||||
Db::startTrans();
|
||||
try {
|
||||
AdminWalletService::rejectWithdraw($order, intval($this->auth->id), $remark);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
return $this->success('审核拒绝完成');
|
||||
return $this->success($action === 'approve' ? __('Approved') : __('Rejected'));
|
||||
}
|
||||
|
||||
public function stats(WebmanRequest $request): Response
|
||||
@@ -170,8 +147,9 @@ class AdminWithdrawOrder extends Backend
|
||||
return $response;
|
||||
}
|
||||
$query = Db::name('admin_withdraw_order');
|
||||
if ($this->auth && !$this->auth->isSuperAdmin()) {
|
||||
$query->where('channel_id', 'in', $this->getCurrentAdminChannelIds());
|
||||
$scopedAdminIds = $this->getManageableScopeAdminIds();
|
||||
if ($scopedAdminIds !== []) {
|
||||
$query->where('admin_id', 'in', $scopedAdminIds);
|
||||
}
|
||||
$rows = $query->field(['status', 'amount', 'actual_amount'])->select()->toArray();
|
||||
$total = count($rows);
|
||||
@@ -223,6 +201,26 @@ class AdminWithdrawOrder extends Backend
|
||||
}
|
||||
|
||||
private function canReviewOrder(array $order): bool
|
||||
{
|
||||
if (!$this->auth || intval($order['status'] ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->hasAdminWithdrawReviewPermission()) {
|
||||
return false;
|
||||
}
|
||||
$adminId = intval($order['admin_id'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$scopedAdminIds = $this->getManageableScopeAdminIds();
|
||||
if ($scopedAdminIds === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($adminId, $scopedAdminIds, true);
|
||||
}
|
||||
|
||||
private function hasAdminWithdrawReviewPermission(): bool
|
||||
{
|
||||
if (!$this->auth) {
|
||||
return false;
|
||||
@@ -230,50 +228,13 @@ class AdminWithdrawOrder extends Backend
|
||||
if ($this->auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
$channelId = intval($order['channel_id'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$allowed = $this->getCurrentAdminChannelIds();
|
||||
return in_array($channelId, $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前管理员可审核的渠道(优先取自身 channel_id,同时兼容角色组继承链上的 channel_id)
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function getCurrentAdminChannelIds(): array
|
||||
{
|
||||
$uid = intval($this->auth->id ?? 0);
|
||||
if ($uid <= 0) {
|
||||
return [0];
|
||||
}
|
||||
$channelIds = [];
|
||||
|
||||
$selfChannelId = intval(Db::name('admin')->where('id', $uid)->value('channel_id') ?? 0);
|
||||
if ($selfChannelId > 0) {
|
||||
$channelIds[] = $selfChannelId;
|
||||
}
|
||||
|
||||
$groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id');
|
||||
if ($groupIds !== []) {
|
||||
$groupIds = array_values(array_unique(array_merge($groupIds, $this->auth->getAdminChildGroups())));
|
||||
$rows = Db::name('admin_group')
|
||||
->field(['id', 'channel_id'])
|
||||
->where('id', 'in', $groupIds)
|
||||
->whereNotNull('channel_id')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$cid = intval($row['channel_id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$channelIds[] = $cid;
|
||||
}
|
||||
foreach ($this->buildPermissionRoutePaths('order/adminWithdrawOrder', 'review') as $routePath) {
|
||||
if ($this->auth->check($routePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return $channelIds === [] ? [0] : array_values(array_unique($channelIds));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class BetOrder extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('注单由游戏接口生成,禁止后台手工新增');
|
||||
return $this->error(__('Bet order is generated by game API; manual creation is not allowed'));
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
@@ -44,7 +44,7 @@ class BetOrder extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('注单不可编辑');
|
||||
return $this->error(__('Bet order cannot be edited'));
|
||||
}
|
||||
|
||||
public function del(WebmanRequest $request): Response
|
||||
@@ -53,7 +53,7 @@ class BetOrder extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('注单不可删除');
|
||||
return $this->error(__('Bet order cannot be deleted'));
|
||||
}
|
||||
|
||||
public function sortable(WebmanRequest $request): Response
|
||||
@@ -62,7 +62,7 @@ class BetOrder extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('不支持排序');
|
||||
return $this->error(__('Sorting is not supported'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +77,7 @@ class BetOrder extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
namespace app\admin\controller\order;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\finance\DepositSettlement;
|
||||
use app\common\library\finance\MockPay;
|
||||
use RuntimeException;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
/**
|
||||
* 充值订单
|
||||
*
|
||||
* 订单的"由 0 转 1(成功入账)"统一走 app\common\library\finance\DepositSettlement。
|
||||
* 当前充值接口为 mock 支付网关,点击即成功;后台不再保留人工审核按钮,
|
||||
* 如需人工补单,请通过后续专门的"补单/冲正"工具完成,而不是在这个 CRUD 里直接改 status。
|
||||
*
|
||||
* 编辑入口现在只用于"查看详情":GET 返回订单 + 关联的 user/channel 信息,
|
||||
* 阻止 POST 任何改字段的动作(保证金额、状态只能由结算服务变更)。
|
||||
* 模拟支付流程:用户确认支付后 status=3(待审核),管理员 approve 后由 DepositSettlement 入账。
|
||||
* 编辑入口用于查看详情;审核通过/驳回走 approve/reject。
|
||||
*/
|
||||
class DepositOrder extends Backend
|
||||
{
|
||||
@@ -30,7 +31,7 @@ class DepositOrder extends Backend
|
||||
|
||||
protected string|array $orderGuarantee = ['id' => 'desc'];
|
||||
|
||||
protected array $withJoinTable = ['user', 'channel'];
|
||||
protected array $withJoinTable = ['user', 'channel', 'reviewAdmin'];
|
||||
|
||||
protected function initController(WebmanRequest $request): ?Response
|
||||
{
|
||||
@@ -47,7 +48,7 @@ class DepositOrder extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
$this->appendDepositOrderIndexWhere($where, $mainShort);
|
||||
@@ -58,6 +59,7 @@ class DepositOrder extends Backend
|
||||
->visible([
|
||||
'user' => ['username', 'phone'],
|
||||
'channel' => ['name'],
|
||||
'reviewAdmin' => ['username'],
|
||||
])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
@@ -93,7 +95,7 @@ class DepositOrder extends Backend
|
||||
}
|
||||
|
||||
if ($this->request && $this->request->method() === 'POST') {
|
||||
return $this->error('充值订单为自动入账,禁止直接修改,如需补单请走专用工具');
|
||||
return $this->error(__('Please use approve/reject buttons to complete the review'));
|
||||
}
|
||||
|
||||
$row = $this->loadWithRelations(intval(strval($id)));
|
||||
@@ -113,8 +115,9 @@ class DepositOrder extends Backend
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->with($this->withJoinTable)
|
||||
->visible([
|
||||
'user' => ['username', 'phone', 'admin_id'],
|
||||
'channel' => ['name'],
|
||||
'user' => ['username', 'phone', 'admin_id'],
|
||||
'channel' => ['name'],
|
||||
'reviewAdmin' => ['username'],
|
||||
])
|
||||
->where($this->model->getTable() . '.id', $id)
|
||||
->find();
|
||||
@@ -126,7 +129,7 @@ class DepositOrder extends Backend
|
||||
|
||||
private function checkChannelScoped(array $row): bool
|
||||
{
|
||||
if (!$this->auth || $this->auth->isSuperAdmin()) {
|
||||
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return true;
|
||||
}
|
||||
$userRow = $row['user'] ?? null;
|
||||
@@ -164,4 +167,190 @@ class DepositOrder extends Backend
|
||||
return $adminIds === [] ? [0] : $adminIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过:将待审核订单结算入账(status 3 -> 1)
|
||||
*/
|
||||
public function approve(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$id = $this->intParam($request->post('id'));
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$scoped = $this->loadWithRelations($id);
|
||||
if (!$scoped) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->checkChannelScoped($scoped)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$order = Db::name('deposit_order')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||||
if ($currentStatus !== MockPay::DEPOSIT_STATUS_PENDING_REVIEW) {
|
||||
return $this->error(__('This order has already been reviewed'));
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$adminId = $this->intParam($this->auth->id ?? 0);
|
||||
$adminName = $this->adminDisplayName();
|
||||
$extraRemark = '管理员(' . $adminName . ')审核通过并入账';
|
||||
|
||||
try {
|
||||
DepositSettlement::settle(
|
||||
$id,
|
||||
DepositSettlement::SOURCE_ADMIN_APPROVE,
|
||||
'admin approve mock deposit',
|
||||
$adminId > 0 ? $adminId : null,
|
||||
$extraRemark
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
return $this->error($e->getMessage());
|
||||
} catch (Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$patch = [
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($this->depositOrderHasColumn('review_admin_id')) {
|
||||
$patch['review_admin_id'] = $adminId > 0 ? $adminId : null;
|
||||
}
|
||||
if ($this->depositOrderHasColumn('review_time')) {
|
||||
$patch['review_time'] = $now;
|
||||
}
|
||||
Db::name('deposit_order')->where('id', $id)->where('status', 1)->update($patch);
|
||||
|
||||
return $this->success(__('Approved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回:必须填写备注(reject_reason)
|
||||
*/
|
||||
public function reject(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$id = $this->intParam($request->post('id'));
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$remarkRaw = $request->post('remark');
|
||||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||||
if ($remark === '') {
|
||||
return $this->error(__('Please provide reject reason'));
|
||||
}
|
||||
|
||||
$scoped = $this->loadWithRelations($id);
|
||||
if (!$scoped) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if (!$this->checkChannelScoped($scoped)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
$order = Db::name('deposit_order')->where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||||
if ($currentStatus !== MockPay::DEPOSIT_STATUS_PENDING_REVIEW) {
|
||||
return $this->error(__('This order has already been reviewed'));
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$adminId = $this->intParam($this->auth->id ?? 0);
|
||||
$adminName = $this->adminDisplayName();
|
||||
$rejectReason = mb_substr($remark, 0, 255);
|
||||
$baseRemark = is_string($order['remark'] ?? null) ? trim($order['remark']) : '';
|
||||
$note = '管理员(' . $adminName . ')驳回:' . $rejectReason;
|
||||
$combined = $baseRemark === '' ? $note : mb_substr($baseRemark . ' | ' . $note, 0, 255);
|
||||
|
||||
$update = [
|
||||
'status' => 2,
|
||||
'remark' => $combined,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($this->depositOrderHasColumn('reject_reason')) {
|
||||
$update['reject_reason'] = $rejectReason;
|
||||
}
|
||||
if ($this->depositOrderHasColumn('review_admin_id')) {
|
||||
$update['review_admin_id'] = $adminId > 0 ? $adminId : null;
|
||||
}
|
||||
if ($this->depositOrderHasColumn('review_time')) {
|
||||
$update['review_time'] = $now;
|
||||
}
|
||||
|
||||
$affected = Db::name('deposit_order')
|
||||
->where('id', $id)
|
||||
->where('status', MockPay::DEPOSIT_STATUS_PENDING_REVIEW)
|
||||
->update($update);
|
||||
if (!is_numeric($affected) || intval($affected) <= 0) {
|
||||
return $this->error(__('This order has already been reviewed'));
|
||||
}
|
||||
|
||||
return $this->success(__('Rejected'));
|
||||
}
|
||||
|
||||
private function depositOrderHasColumn(string $column): bool
|
||||
{
|
||||
static $cache = [];
|
||||
if (array_key_exists($column, $cache)) {
|
||||
return $cache[$column];
|
||||
}
|
||||
try {
|
||||
$rows = Db::query('SHOW COLUMNS FROM `deposit_order` LIKE ?', [$column]);
|
||||
$cache[$column] = is_array($rows) && $rows !== [];
|
||||
} catch (Throwable $e) {
|
||||
$cache[$column] = false;
|
||||
}
|
||||
|
||||
return $cache[$column];
|
||||
}
|
||||
|
||||
private function intParam($raw): int
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric(strval($raw))) {
|
||||
return intval(strval($raw));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function adminDisplayName(): string
|
||||
{
|
||||
if (!$this->auth) {
|
||||
return 'admin';
|
||||
}
|
||||
$username = $this->auth->username ?? '';
|
||||
if (is_string($username) && trim($username) !== '') {
|
||||
return trim($username);
|
||||
}
|
||||
|
||||
return 'admin#' . strval($this->auth->id ?? 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace app\admin\controller\order;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\finance\DDPayGateway;
|
||||
use app\common\library\finance\MockPay;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
@@ -24,7 +26,7 @@ class WithdrawOrder extends Backend
|
||||
|
||||
protected bool $modelSceneValidate = true;
|
||||
|
||||
protected string|array $quickSearchField = ['id', 'order_no', 'idempotency_key', 'receive_type', 'receive_account', 'remark'];
|
||||
protected string|array $quickSearchField = ['id', 'order_no', 'idempotency_key', 'pay_channel', 'receive_type', 'receive_account', 'ddpay_receiver_name', 'receiver_email', 'receiver_mobile', 'remark'];
|
||||
|
||||
protected string|array $defaultSortField = ['id' => 'desc'];
|
||||
|
||||
@@ -47,7 +49,7 @@ class WithdrawOrder extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ class WithdrawOrder extends Backend
|
||||
|
||||
if ($this->request && $this->request->method() === 'POST') {
|
||||
// 历史 CRUD 的 POST 编辑已被 approve/reject 替代,这里阻止直接改金额绕过审核流程
|
||||
return $this->error('请使用通过/拒绝按钮完成审核');
|
||||
return $this->error(__('Please use approve/reject buttons to complete the review'));
|
||||
}
|
||||
|
||||
$row = $this->loadWithRelations(intval(strval($id)));
|
||||
@@ -119,13 +121,13 @@ class WithdrawOrder extends Backend
|
||||
$newAmount = $this->decimalParam($request->post('amount'), '0');
|
||||
$newFee = $this->decimalParam($request->post('fee'), '0');
|
||||
if (bccomp($newAmount, '0', 2) <= 0) {
|
||||
return $this->error('申请金额必须大于 0');
|
||||
return $this->error(__('Apply amount must be greater than 0'));
|
||||
}
|
||||
if (bccomp($newFee, '0', 2) < 0) {
|
||||
return $this->error('手续费不能为负');
|
||||
return $this->error(__('Fee cannot be negative'));
|
||||
}
|
||||
if (bccomp($newFee, $newAmount, 2) > 0) {
|
||||
return $this->error('手续费不能大于申请金额');
|
||||
return $this->error(__('Fee cannot be greater than apply amount'));
|
||||
}
|
||||
$newActual = bcsub($newAmount, $newFee, 2);
|
||||
|
||||
@@ -141,12 +143,12 @@ class WithdrawOrder extends Backend
|
||||
}
|
||||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||||
if ($currentStatus !== 0) {
|
||||
return $this->error('该订单已审核,无需重复操作');
|
||||
return $this->error(__('This order has already been reviewed'));
|
||||
}
|
||||
|
||||
$userId = $this->intParam($order['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return $this->error('订单缺少用户信息');
|
||||
return $this->error(__('Order is missing user info'));
|
||||
}
|
||||
$oldAmount = bcadd(strval($order['amount'] ?? '0'), '0', 2);
|
||||
$diff = bcsub($newAmount, $oldAmount, 2);
|
||||
@@ -173,12 +175,12 @@ class WithdrawOrder extends Backend
|
||||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||||
if (!$userRow) {
|
||||
Db::rollback();
|
||||
return $this->error('关联用户不存在');
|
||||
return $this->error(__('Related user does not exist'));
|
||||
}
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
if (bccomp($beforeCoin, $diff, 2) < 0) {
|
||||
Db::rollback();
|
||||
return $this->error('用户余额不足以补扣调整差额');
|
||||
return $this->error(__('User balance is insufficient to cover the adjustment difference'));
|
||||
}
|
||||
$afterCoin = bcsub($beforeCoin, $diff, 2);
|
||||
Db::name('user')->where('id', $userId)->update([
|
||||
@@ -208,7 +210,7 @@ class WithdrawOrder extends Backend
|
||||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||||
if (!$userRow) {
|
||||
Db::rollback();
|
||||
return $this->error('关联用户不存在');
|
||||
return $this->error(__('Related user does not exist'));
|
||||
}
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $abs, 2);
|
||||
@@ -251,12 +253,286 @@ class WithdrawOrder extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('审核通过', [
|
||||
// 审核通过后自动发起 DDPAY 出金(并在失败时回冲余额)
|
||||
try {
|
||||
$fresh = Db::name('withdraw_order')->where('id', $id)->find();
|
||||
if (!is_array($fresh)) {
|
||||
// 理论上不会发生:只写失败备注
|
||||
Db::name('withdraw_order')->where('id', $id)->update([
|
||||
'remark' => '[ddpay] payout order missing',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} else {
|
||||
$orderNo = is_string($fresh['order_no'] ?? null) ? trim($fresh['order_no'] ?? '') : strval($fresh['order_no'] ?? '');
|
||||
$receiveType = is_string($fresh['receive_type'] ?? null) ? strtolower(trim($fresh['receive_type'] ?? '')) : '';
|
||||
$payChannel = is_string($fresh['pay_channel'] ?? null) ? strtolower(trim($fresh['pay_channel'] ?? '')) : '';
|
||||
|
||||
// 模拟出金:审核通过即标记已打款,不回冲、不调用 DDPay
|
||||
if ($orderNo !== '' && MockPay::shouldSimulateWithdrawPayout($payChannel)) {
|
||||
$prevRemark = is_string($fresh['remark'] ?? null) ? trim($fresh['remark']) : '';
|
||||
$mockNote = '[mock] 管理员(' . $adminName . ')审核通过,模拟打款成功';
|
||||
$finalRemark = $prevRemark === '' ? $mockNote : mb_substr($prevRemark . ' | ' . $mockNote, 0, 255);
|
||||
Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'remark' => $finalRemark,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} elseif ($orderNo !== '' && $receiveType === 'bank' && $payChannel === 'ddpay') {
|
||||
$base = \app\common\library\finance\DDPayGateway::publicBaseUrlForCallbacks($request);
|
||||
if ($base === '') {
|
||||
$base = 'https://' . strval($request->host());
|
||||
}
|
||||
$callbackUrl = rtrim($base, '/') . '/api/finance/ddpayPayoutNotify';
|
||||
$payoutAmount = is_string($fresh['actual_amount'] ?? null) && $fresh['actual_amount'] !== '' ? trim($fresh['actual_amount']) : $newActual;
|
||||
|
||||
$receiverName = is_string($fresh['ddpay_receiver_name'] ?? null) ? trim($fresh['ddpay_receiver_name'] ?? '') : '';
|
||||
$receiverAccount = is_string($fresh['receive_account'] ?? null) ? trim($fresh['receive_account'] ?? '') : '';
|
||||
$bankName = is_string($fresh['ddpay_bank_name'] ?? null) ? trim($fresh['ddpay_bank_name'] ?? '') : '';
|
||||
$bankBranch = is_string($fresh['ddpay_bank_branch'] ?? null) ? trim($fresh['ddpay_bank_branch'] ?? '') : 'N/A';
|
||||
|
||||
if ($receiverName === '' || $receiverAccount === '' || $bankName === '') {
|
||||
// 缺少 DDPAY 出金字段:回冲并置为失败
|
||||
Db::startTrans();
|
||||
try {
|
||||
$amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2);
|
||||
$updated = Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] missing payout fields',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) {
|
||||
$userIdRefund = intval(strval($fresh['user_id'] ?? 0));
|
||||
$userRow = Db::name('user')->where('id', $userIdRefund)->find();
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $amountRefund, 2);
|
||||
Db::name('user')->where('id', $userIdRefund)->update([
|
||||
'coin' => $afterCoin,
|
||||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo);
|
||||
$exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find();
|
||||
if (!$exists) {
|
||||
$channelId = null;
|
||||
if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) {
|
||||
$channelId = intval(strval($fresh['channel_id']));
|
||||
}
|
||||
Db::name('user_wallet_record')->insert([
|
||||
'user_id' => $userIdRefund,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'withdraw_refund',
|
||||
'direction' => 1,
|
||||
'amount' => $amountRefund,
|
||||
'balance_before' => $beforeCoin,
|
||||
'balance_after' => $afterCoin,
|
||||
'ref_type' => 'withdraw_order',
|
||||
'ref_id' => intval(strval($id)),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'operator_admin_id' => null,
|
||||
'remark' => '[ddpay] missing payout fields refund',
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e2) {
|
||||
Db::rollback();
|
||||
}
|
||||
} else {
|
||||
$clientId = config('app.ddpay_client_id', '');
|
||||
$identifier = config('app.ddpay_identifier', '');
|
||||
|
||||
$ddReq = [
|
||||
'client_id' => $clientId,
|
||||
'identifier' => $identifier,
|
||||
'bill_number' => $orderNo,
|
||||
'amount' => $payoutAmount,
|
||||
'receiver_name' => $receiverName,
|
||||
'receiver_account' => $receiverAccount,
|
||||
'bank[name]' => $bankName,
|
||||
'bank_branch' => $bankBranch,
|
||||
'callback_url' => $callbackUrl,
|
||||
];
|
||||
|
||||
$ddResp = [];
|
||||
$ts = '';
|
||||
try {
|
||||
$ddResp = DDPayGateway::payoutInitiation($ddReq);
|
||||
$ts = is_string($ddResp['transaction_status'] ?? null) ? strtolower(trim($ddResp['transaction_status'] ?? '')) : '';
|
||||
} catch (Throwable $e) {
|
||||
// initiation 异常:同“failed”处理,回冲并置失败
|
||||
$ts = 'failed';
|
||||
$ddResp = ['error' => (string) $e->getMessage()];
|
||||
}
|
||||
|
||||
if (is_array($ddResp)) {
|
||||
Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'ddpay_payout_snapshot' => json_encode([
|
||||
'init_request' => $ddReq,
|
||||
'init_response' => $ddResp,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($ts === 'completed') {
|
||||
Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'remark' => '[ddpay] payout completed',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} elseif ($ts === 'failed') {
|
||||
$amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$updated = Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] payout failed',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) {
|
||||
$userIdRefund = intval(strval($fresh['user_id'] ?? 0));
|
||||
$userRow = Db::name('user')->where('id', $userIdRefund)->find();
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $amountRefund, 2);
|
||||
Db::name('user')->where('id', $userIdRefund)->update([
|
||||
'coin' => $afterCoin,
|
||||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo);
|
||||
$exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find();
|
||||
if (!$exists) {
|
||||
$channelId = null;
|
||||
if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) {
|
||||
$channelId = intval(strval($fresh['channel_id']));
|
||||
}
|
||||
Db::name('user_wallet_record')->insert([
|
||||
'user_id' => $userIdRefund,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'withdraw_refund',
|
||||
'direction' => 1,
|
||||
'amount' => $amountRefund,
|
||||
'balance_before' => $beforeCoin,
|
||||
'balance_after' => $afterCoin,
|
||||
'ref_type' => 'withdraw_order',
|
||||
'ref_id' => intval(strval($id)),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'operator_admin_id' => null,
|
||||
'remark' => '[ddpay] payout failed refund',
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e3) {
|
||||
Db::rollback();
|
||||
}
|
||||
} else {
|
||||
// pending:按文档做一次 status inquiry 兜底(Webhook 仍会最终落账)
|
||||
try {
|
||||
$inq = DDPayGateway::payoutStatusInquiry([
|
||||
'client_id' => $clientId,
|
||||
'bill_number' => $orderNo,
|
||||
]);
|
||||
$ts2 = is_string($inq['transaction_status'] ?? null) ? strtolower(trim($inq['transaction_status'] ?? '')) : '';
|
||||
if ($ts2 === 'completed') {
|
||||
Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'remark' => '[ddpay] payout completed (after status inquiry)',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} elseif ($ts2 === 'failed') {
|
||||
$amountRefund = bcadd(strval($fresh['amount'] ?? '0'), '0', 2);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$updated = Db::name('withdraw_order')
|
||||
->where('id', $id)
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] payout failed (after status inquiry)',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($updated > 0 && bccomp($amountRefund, '0', 2) > 0) {
|
||||
$userIdRefund = intval(strval($fresh['user_id'] ?? 0));
|
||||
$userRow = Db::name('user')->where('id', $userIdRefund)->find();
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $amountRefund, 2);
|
||||
Db::name('user')->where('id', $userIdRefund)->update([
|
||||
'coin' => $afterCoin,
|
||||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amountRefund),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$idempotencyKey = 'wd_ddpay_failed_' . strval($orderNo);
|
||||
$exists = Db::name('user_wallet_record')->where('idempotency_key', $idempotencyKey)->find();
|
||||
if (!$exists) {
|
||||
$channelId = null;
|
||||
if (isset($fresh['channel_id']) && is_numeric(strval($fresh['channel_id']))) {
|
||||
$channelId = intval(strval($fresh['channel_id']));
|
||||
}
|
||||
Db::name('user_wallet_record')->insert([
|
||||
'user_id' => $userIdRefund,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'withdraw_refund',
|
||||
'direction' => 1,
|
||||
'amount' => $amountRefund,
|
||||
'balance_before' => $beforeCoin,
|
||||
'balance_after' => $afterCoin,
|
||||
'ref_type' => 'withdraw_order',
|
||||
'ref_id' => intval(strval($id)),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'operator_admin_id' => null,
|
||||
'remark' => '[ddpay] payout failed refund (after status inquiry)',
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e4) {
|
||||
Db::rollback();
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e5) {
|
||||
// ignore:Webhook 会兜底
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// 外部出金调用失败不阻断审核流:只记录 remark(避免阻塞用户提现)
|
||||
Db::name('withdraw_order')->where('id', $id)->update([
|
||||
'remark' => '[ddpay] payout flow exception: ' . substr((string) $e->getMessage(), 0, 200),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$finalStatus = Db::name('withdraw_order')->where('id', $id)->value('status');
|
||||
$finalStatusInt = is_numeric($finalStatus) ? intval($finalStatus) : 1;
|
||||
|
||||
return $this->success(__('Approved'), [
|
||||
'id' => $id,
|
||||
'amount' => $newAmount,
|
||||
'fee' => $newFee,
|
||||
'actual_amount' => $newActual,
|
||||
'status' => 1,
|
||||
'status' => $finalStatusInt,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -281,7 +557,7 @@ class WithdrawOrder extends Backend
|
||||
$remarkRaw = $request->post('remark');
|
||||
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
|
||||
if ($remark === '') {
|
||||
return $this->error('请填写拒绝原因');
|
||||
return $this->error(__('Please provide reject reason'));
|
||||
}
|
||||
|
||||
$order = Db::name('withdraw_order')->where('id', $id)->find();
|
||||
@@ -293,12 +569,12 @@ class WithdrawOrder extends Backend
|
||||
}
|
||||
$currentStatus = $this->intParam($order['status'] ?? 0);
|
||||
if ($currentStatus !== 0) {
|
||||
return $this->error('该订单已审核,无需重复操作');
|
||||
return $this->error(__('This order has already been reviewed'));
|
||||
}
|
||||
|
||||
$userId = $this->intParam($order['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return $this->error('订单缺少用户信息');
|
||||
return $this->error(__('Order is missing user info'));
|
||||
}
|
||||
$amount = bcadd(strval($order['amount'] ?? '0'), '0', 2);
|
||||
$channelIdRaw = $order['channel_id'] ?? null;
|
||||
@@ -315,7 +591,7 @@ class WithdrawOrder extends Backend
|
||||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||||
if (!$userRow) {
|
||||
Db::rollback();
|
||||
return $this->error('关联用户不存在');
|
||||
return $this->error(__('Related user does not exist'));
|
||||
}
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $amount, 2);
|
||||
@@ -355,7 +631,7 @@ class WithdrawOrder extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('审核已拒绝', [
|
||||
return $this->success(__('Rejected'), [
|
||||
'id' => $id,
|
||||
'status' => 2,
|
||||
'remark' => $remark,
|
||||
@@ -382,7 +658,7 @@ class WithdrawOrder extends Backend
|
||||
|
||||
private function checkChannelScoped(array|object $row): bool
|
||||
{
|
||||
if (!$this->auth || $this->auth->isSuperAdmin()) {
|
||||
if (!$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return true;
|
||||
}
|
||||
$uidRaw = is_array($row) ? ($row['user_id'] ?? null) : ($row->user_id ?? null);
|
||||
|
||||
@@ -164,18 +164,18 @@ class AdminInfo extends Backend
|
||||
$receiveType = trim(is_string($request->post('receive_type', '')) ? $request->post('receive_type', '') : '');
|
||||
$idempotencyKey = trim(is_string($request->post('idempotency_key', '')) ? $request->post('idempotency_key', '') : '');
|
||||
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
|
||||
return $this->error('参数缺失');
|
||||
return $this->error(__('Missing required parameters'));
|
||||
}
|
||||
if (mb_strlen($idempotencyKey) > 64) {
|
||||
return $this->error('幂等键过长');
|
||||
return $this->error(__('Idempotency key is too long'));
|
||||
}
|
||||
if (!is_numeric($withdrawCoin) || bccomp($withdrawCoin, '0', 2) <= 0) {
|
||||
return $this->error('提现金额必须大于0');
|
||||
return $this->error(__('Withdraw amount must be greater than 0'));
|
||||
}
|
||||
$withdrawCoin = bcadd($withdrawCoin, '0', 2);
|
||||
$allowedReceiveTypes = ['bank', 'ewallet', 'crypto'];
|
||||
if (!in_array($receiveType, $allowedReceiveTypes, true)) {
|
||||
return $this->error('收款类型不合法,仅支持 bank/ewallet/crypto');
|
||||
return $this->error(__('Invalid receive type; only bank/ewallet/crypto are supported'));
|
||||
}
|
||||
$remark = trim((string) $request->post('remark', ''));
|
||||
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $adminId)->find();
|
||||
@@ -185,14 +185,14 @@ class AdminInfo extends Backend
|
||||
$res = AdminWalletService::applyWithdraw($adminId, $channelId, $withdrawCoin, $receiveType, $receiveAccount, $idempotencyKey, $remark);
|
||||
if (($res['ok'] ?? false) !== true) {
|
||||
Db::rollback();
|
||||
return $this->error(strval($res['msg'] ?? '提现申请失败'));
|
||||
return $this->error(strval($res['msg'] ?? __('Withdraw apply failed')));
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
return $this->success('提现申请已提交,待渠道超管审核', [
|
||||
return $this->success(__('Withdraw request submitted; pending channel super admin review'), [
|
||||
'order_id' => intval($res['order_id'] ?? 0),
|
||||
'order_no' => strval($res['order_no'] ?? ''),
|
||||
'idempotent_hit' => !empty($res['idempotent_hit']),
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace app\admin\controller\test;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\admin\WebSocketConfigHelper;
|
||||
use app\common\service\GameWebSocketAuthHelper;
|
||||
use app\common\service\GameWebSocketPayloadHelper;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
/**
|
||||
* WebSocket 测试:状态流(period.tick / period.opened)
|
||||
* WebSocket test for period status stream.
|
||||
*/
|
||||
class GameCurrentStatus extends Backend
|
||||
{
|
||||
@@ -25,23 +27,50 @@ class GameCurrentStatus extends Backend
|
||||
|
||||
$subscribeTopics = [
|
||||
'period.tick',
|
||||
'period.opened',
|
||||
'period.locked',
|
||||
'period.opened',
|
||||
'period.payout',
|
||||
'bet.accepted',
|
||||
'period.payout.tick',
|
||||
'bet.win',
|
||||
'user.streak',
|
||||
'wallet.changed',
|
||||
'bet.accepted',
|
||||
'jackpot.hit',
|
||||
'auto.spin.progress',
|
||||
'admin.live.snapshot',
|
||||
'admin.live.opened',
|
||||
];
|
||||
|
||||
$oddsPushTopics = GameWebSocketPayloadHelper::ODDS_PUSH_TOPICS;
|
||||
$testPlayerOdds = GameWebSocketPayloadHelper::adminTestPlayerOddsSnapshot();
|
||||
|
||||
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
|
||||
$adminWs = GameWebSocketAuthHelper::issueAdminWsToken($adminId);
|
||||
$baseWsUrl = WebSocketConfigHelper::wsUrl($request);
|
||||
$wsUrl = GameWebSocketAuthHelper::buildAdminConnectUrl(
|
||||
$baseWsUrl,
|
||||
(string) ($adminWs['token'] ?? '')
|
||||
);
|
||||
|
||||
return $this->success('', [
|
||||
'name' => 'ws.period',
|
||||
'ws_url' => WebSocketConfigHelper::wsUrl(),
|
||||
'connect_tip' => '连接成功后会自动订阅下列主题;也可在「发送消息」中手动改订阅。未订阅时不会收到业务推送。',
|
||||
'ws_url' => $wsUrl,
|
||||
'ws_base_url' => $baseWsUrl,
|
||||
'admin-ws-token' => (string) ($adminWs['token'] ?? ''),
|
||||
'admin_ws_token_ttl' => (int) ($adminWs['ttl'] ?? 0),
|
||||
'ws_query_params' => [
|
||||
GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN,
|
||||
],
|
||||
'connect_tip' => '连接成功后将自动订阅下列主题。真实业务仅在有玩家下注/结算时推送赔率;本页联调会在订阅后额外推送带 is_test/preview 的演示帧(见下方测试玩家赔率)。',
|
||||
'subscribe_topics' => $subscribeTopics,
|
||||
'odds_push_topics' => $oddsPushTopics,
|
||||
'player_odds_fields' => ['current_streak', 'streak_level', 'odds_factor', 'is_jackpot'],
|
||||
'test_player_odds' => $testPlayerOdds,
|
||||
'test_push_topics' => $oddsPushTopics,
|
||||
'sample_messages' => [
|
||||
'{"action":"ping"}',
|
||||
'{"action":"subscribe","topics":["period.tick","period.opened"]}',
|
||||
'{"action":"subscribe","topics":["bet.accepted","wallet.changed","auto.spin.progress"]}',
|
||||
'{"action":"subscribe","topics":["period.tick","user.streak","period.opened","period.locked","period.payout"]}',
|
||||
'{"action":"subscribe","topics":["user.streak","wallet.changed","bet.accepted","auto.spin.progress"]}',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -234,16 +234,16 @@ class User extends Backend
|
||||
$opRaw = $request->post('op');
|
||||
$op = is_string($opRaw) ? trim($opRaw) : '';
|
||||
if (!in_array($op, ['credit', 'deduct'], true)) {
|
||||
return $this->error('操作类型不正确');
|
||||
return $this->error(__('Invalid operation type'));
|
||||
}
|
||||
|
||||
$amountRaw = $request->post('amount');
|
||||
$amountText = is_string($amountRaw) || is_numeric($amountRaw) ? trim(strval($amountRaw)) : '';
|
||||
if ($amountText === '' || !is_numeric($amountText)) {
|
||||
return $this->error('金额格式不正确');
|
||||
return $this->error(__('Invalid amount format'));
|
||||
}
|
||||
if (bccomp($amountText, '0', 2) <= 0) {
|
||||
return $this->error('金额必须大于0');
|
||||
return $this->error(__('Amount must be greater than 0'));
|
||||
}
|
||||
|
||||
$remarkRaw = $request->post('remark');
|
||||
@@ -257,7 +257,7 @@ class User extends Backend
|
||||
|
||||
$lock = GameHotDataRedis::userAdminMutationLockTry($userId);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->error('该用户正在被其他管理员操作(钱包/并发保存),请稍后再试');
|
||||
return $this->error(__('This user is being operated by another admin (wallet/concurrent save); please try again later'));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -284,7 +284,7 @@ class User extends Backend
|
||||
$direction = 1;
|
||||
} else {
|
||||
if (bccomp($before, $delta, 2) < 0) {
|
||||
return $this->error('余额不足,扣点失败');
|
||||
return $this->error(__('Insufficient balance; deduction failed'));
|
||||
}
|
||||
$after = bcsub($before, $delta, 2);
|
||||
$bizType = 'admin_deduct';
|
||||
@@ -306,7 +306,7 @@ class User extends Backend
|
||||
]);
|
||||
if ($affected !== 1) {
|
||||
Db::rollback();
|
||||
return $this->error('保存失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试');
|
||||
return $this->error(__('Save failed: user balance changed by another request; please refresh and retry'));
|
||||
}
|
||||
|
||||
Db::name('user_wallet_record')->insert([
|
||||
@@ -332,7 +332,7 @@ class User extends Backend
|
||||
|
||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||
|
||||
return $this->success('钱包调整成功', [
|
||||
return $this->success(__('Wallet adjusted successfully'), [
|
||||
'user_id' => $userId,
|
||||
'coin_before' => self::formatAmountForDisplay($before),
|
||||
'coin_after' => self::formatAmountForDisplay($after),
|
||||
|
||||
@@ -35,7 +35,7 @@ class UserWalletRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('钱包流水仅允许业务入账,禁止后台手工新增');
|
||||
return $this->error(__('Wallet records are business-generated; manual creation is not allowed'));
|
||||
}
|
||||
|
||||
public function edit(WebmanRequest $request): Response
|
||||
@@ -44,7 +44,7 @@ class UserWalletRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('钱包流水不可编辑');
|
||||
return $this->error(__('Wallet record cannot be edited'));
|
||||
}
|
||||
|
||||
public function del(WebmanRequest $request): Response
|
||||
@@ -53,7 +53,7 @@ class UserWalletRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('钱包流水不可删除');
|
||||
return $this->error(__('Wallet record cannot be deleted'));
|
||||
}
|
||||
|
||||
public function sortable(WebmanRequest $request): Response
|
||||
@@ -62,7 +62,7 @@ class UserWalletRecord extends Backend
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->error('不支持排序');
|
||||
return $this->error(__('Sorting is not supported'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +77,7 @@ class UserWalletRecord extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$table = strtolower($this->model->getTable());
|
||||
$mainShort = $alias[$table] ?? '';
|
||||
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
|
||||
if ($mainShort !== '' && $this->shouldApplyUserAdminScope()) {
|
||||
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
|
||||
}
|
||||
|
||||
|
||||
@@ -96,4 +96,7 @@ return [
|
||||
'Please input correct username' => 'Please enter the correct username',
|
||||
'Please enter a valid commission rate for non-top role group' => 'Non-top role groups require a commission rate between 0 and 100 (%)',
|
||||
'Group Name Arr' => 'Group Name Arr',
|
||||
'Document file not found' => 'Document file not found',
|
||||
'Failed to read document' => 'Failed to read document',
|
||||
'Please select a parent agent' => 'Please select a parent agent, or ask an administrator for channel assignment permission',
|
||||
];
|
||||
@@ -115,4 +115,7 @@ return [
|
||||
'Please input correct username' => '请输入正确的用户名',
|
||||
'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0~100 的分红比例(%)',
|
||||
'Group Name Arr' => '分组名称数组',
|
||||
'Document file not found' => '文档文件不存在',
|
||||
'Failed to read document' => '读取文档失败',
|
||||
'Please select a parent agent' => '请选择上级代理,或联系管理员开通渠道分配权限',
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
return [
|
||||
'Remark lang' => '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!',
|
||||
'Remark lang' => 'telegram@zhenhui666',
|
||||
];
|
||||
@@ -5,6 +5,7 @@ namespace app\api\controller;
|
||||
use ba\Date;
|
||||
use ba\Captcha;
|
||||
use ba\Random;
|
||||
use app\common\library\MalaysiaMobilePhone;
|
||||
use app\common\library\finance\WithdrawFlow;
|
||||
use app\common\model\User;
|
||||
use app\common\facade\Token;
|
||||
@@ -12,6 +13,7 @@ use app\common\model\UserScoreLog;
|
||||
use app\common\model\UserMoneyLog;
|
||||
use app\common\controller\Frontend;
|
||||
use app\common\facade\Token as TokenFacade;
|
||||
use app\common\service\MobileAuthDeviceService;
|
||||
use support\think\Db;
|
||||
use support\validation\Validator;
|
||||
use support\validation\ValidationException;
|
||||
@@ -44,6 +46,10 @@ class Account extends Frontend
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
$userId = intval(strval($user->id));
|
||||
$deviceError = MobileAuthDeviceService::validateUserDeviceSession($authToken, $userId);
|
||||
if ($deviceError !== null) {
|
||||
return $this->mobileResult(1101, $deviceError);
|
||||
}
|
||||
$coinBalance = WithdrawFlow::amountString($user->coin ?? '0');
|
||||
|
||||
// 打码量 / 提现配额快照
|
||||
@@ -76,7 +82,7 @@ class Account extends Frontend
|
||||
'last_bet_period_no' => $user->last_bet_period_no ?? '',
|
||||
'create_time' => $user->create_time ?? 0,
|
||||
|
||||
// 资金字段(4 位小数字符串,与 /api/wallet/balanceSummary 对齐)
|
||||
// 资金字段(4 位小数字符串,与钱包展示口径一致)
|
||||
'coin' => floatval($coinBalance),
|
||||
'coin_balance' => floatval($coinBalance),
|
||||
'frozen_balance' => 0.00,
|
||||
@@ -230,7 +236,7 @@ class Account extends Frontend
|
||||
return $this->error(__('Email') . ' ' . __('already exists'));
|
||||
}
|
||||
} else {
|
||||
Validator::make($params, ['mobile' => 'required|regex:/^1[3-9]\d{9}$/'])->validate();
|
||||
Validator::make($params, ['mobile' => 'required|regex:' . MalaysiaMobilePhone::PATTERN])->validate();
|
||||
if (User::where('mobile', $params['mobile'])->find()) {
|
||||
return $this->error(__('Mobile') . ' ' . __('already exists'));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace app\api\controller;
|
||||
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth as UserAuth;
|
||||
use app\common\library\MalaysiaMobilePhone;
|
||||
use app\common\model\User;
|
||||
use app\common\service\MobileAuthDeviceService;
|
||||
use ba\Random;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
@@ -37,7 +39,7 @@ class Auth extends MobileBase
|
||||
if ($inviteCode === '') {
|
||||
return $this->mobileError(1001, 'Invite code required');
|
||||
}
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $username)) {
|
||||
if (!MalaysiaMobilePhone::isValid($username)) {
|
||||
return $this->mobileError(1003, 'Please enter the correct mobile number');
|
||||
}
|
||||
|
||||
@@ -83,6 +85,8 @@ class Auth extends MobileBase
|
||||
return $this->mobileError(2000, 'Registered successfully but login failed');
|
||||
}
|
||||
|
||||
$this->bindMobileDeviceSession($request);
|
||||
|
||||
return $this->mobileSuccess($this->buildLoginPayload());
|
||||
}
|
||||
|
||||
@@ -104,11 +108,43 @@ class Auth extends MobileBase
|
||||
|
||||
$ok = $this->auth->login($username, $password, true);
|
||||
if (!$ok) {
|
||||
return $this->mobileError(1101, 'Incorrect account or password');
|
||||
$detail = (string) $this->auth->getError();
|
||||
return $this->mobileError(1101, $detail !== '' ? $detail : 'Incorrect account or password');
|
||||
}
|
||||
|
||||
$this->bindMobileDeviceSession($request);
|
||||
|
||||
return $this->mobileSuccess($this->buildLoginPayload());
|
||||
}
|
||||
|
||||
public function userLogout(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$refreshToken = trim((string) $request->post('refresh_token', ''));
|
||||
if ($refreshToken === '') {
|
||||
$refreshToken = trim((string) $request->post('refreshToken', ''));
|
||||
}
|
||||
|
||||
if ($this->auth->isLogin()) {
|
||||
$userId = (int) $this->auth->id;
|
||||
if ($refreshToken === '') {
|
||||
$refreshToken = $this->auth->getRefreshToken();
|
||||
}
|
||||
$this->auth->logout();
|
||||
MobileAuthDeviceService::onUserLogout($userId);
|
||||
}
|
||||
|
||||
if ($refreshToken !== '') {
|
||||
Token::delete($refreshToken);
|
||||
}
|
||||
|
||||
return $this->mobileSuccess();
|
||||
}
|
||||
|
||||
public function tokenRefresh(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
@@ -126,14 +162,41 @@ class Auth extends MobileBase
|
||||
return $this->mobileError(1101, 'Login status has expired');
|
||||
}
|
||||
|
||||
$authToken = trim((string) $request->header('auth-token', ''));
|
||||
$userId = filter_var($tokenData['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
return $this->mobileError(1101, 'Login status has expired');
|
||||
}
|
||||
$deviceError = MobileAuthDeviceService::validateUserDeviceSession($authToken, (int) $userId);
|
||||
if ($deviceError !== null) {
|
||||
return $this->mobileError(1101, $deviceError);
|
||||
}
|
||||
|
||||
$newToken = Random::uuid();
|
||||
Token::set($newToken, UserAuth::TOKEN_TYPE, $tokenData['user_id'], config('buildadmin.user_token_keep_time', 259200));
|
||||
Token::set($newToken, UserAuth::TOKEN_TYPE, (int) $userId, config('buildadmin.user_token_keep_time', 259200));
|
||||
return $this->mobileSuccess([
|
||||
'user-token' => $newToken,
|
||||
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
|
||||
]);
|
||||
}
|
||||
|
||||
private function bindMobileDeviceSession(Request $request): void
|
||||
{
|
||||
if (!$this->auth->isLogin()) {
|
||||
return;
|
||||
}
|
||||
$authToken = trim((string) $request->header('auth-token', ''));
|
||||
if ($authToken === '') {
|
||||
return;
|
||||
}
|
||||
MobileAuthDeviceService::onUserLogin(
|
||||
(int) $this->auth->id,
|
||||
$authToken,
|
||||
$this->auth->getToken(),
|
||||
$this->auth->getRefreshToken()
|
||||
);
|
||||
}
|
||||
|
||||
private function buildLoginPayload(): array
|
||||
{
|
||||
$userInfo = $this->auth->getUserInfo();
|
||||
|
||||
@@ -4,8 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\library\finance\DepositMockGateway;
|
||||
use app\common\library\finance\DepositSettlement;
|
||||
use app\common\library\finance\MockPay;
|
||||
use app\common\library\finance\DDPayGateway;
|
||||
use app\common\library\finance\WithdrawFlow;
|
||||
use app\common\library\game\DepositChannel as DepositChannelLib;
|
||||
use app\common\library\game\DepositTier as DepositTierLib;
|
||||
@@ -14,23 +15,39 @@ use app\common\model\DepositOrder;
|
||||
use app\common\model\GameConfig;
|
||||
use app\common\model\WithdrawOrder;
|
||||
use app\common\service\DepositOrderExpireService;
|
||||
use support\Log;
|
||||
use support\Response;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
use Webman\Http\Request;
|
||||
use function response;
|
||||
|
||||
class Finance extends MobileBase
|
||||
{
|
||||
/**
|
||||
* 模拟第三方收银台页与支付回调,无需 user-token,仅 HMAC 防篡改。
|
||||
* DDPay Webhook / 重定向等无需用户登录态。
|
||||
*/
|
||||
protected array $noNeedLogin = ['depositMockPayPage', 'depositMockNotify'];
|
||||
protected array $noNeedLogin = [
|
||||
'ddpayDepositNotify',
|
||||
'ddpayPayoutNotify',
|
||||
'mockDepositPage',
|
||||
'mockDepositConfirm',
|
||||
'mockDepositStatus',
|
||||
];
|
||||
|
||||
/**
|
||||
* 允许浏览器直接打开 pay_url 而不带 auth-token。
|
||||
* DDPay 回调、模拟收银台页允许浏览器直接访问(无 auth-token)。
|
||||
*/
|
||||
protected array $noNeedAuthToken = ['depositMockPayPage', 'depositMockNotify'];
|
||||
protected array $noNeedAuthToken = [
|
||||
'ddpayDepositNotify',
|
||||
'ddpayDepositRedirect',
|
||||
'ddpayPayoutNotify',
|
||||
'ddpayPayoutRedirect',
|
||||
'mockDepositPage',
|
||||
'mockDepositConfirm',
|
||||
'mockDepositStatus',
|
||||
];
|
||||
|
||||
/**
|
||||
* 充值档位列表(仅启用档位,按 sort 升序)
|
||||
@@ -67,7 +84,7 @@ class Finance extends MobileBase
|
||||
'bonus_amount' => $this->amountNumber($bonus),
|
||||
'total_amount' => $this->amountNumber($total),
|
||||
'desc' => $localized['desc'],
|
||||
'channels' => DepositChannelLib::channelsForTier($tierId, $effectiveChannels, $lang),
|
||||
'channels' => DepositChannelLib::channelsForTier($tierId, $effectiveChannels, $lang, $currency),
|
||||
];
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
@@ -88,22 +105,16 @@ class Finance extends MobileBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建充值订单
|
||||
* 创建充值订单(DDPay 或模拟支付 mock)
|
||||
*
|
||||
* 当前为 mock 支付网关:本接口仅创建待支付订单并返回 pay_url。
|
||||
* 未来接入真实第三方支付时,仅需替换 pay_url 生成与回调验签,入账仍在回调中调用 DepositSettlement::settle。
|
||||
* - `channel_code=mock`:返回 `pay_url`(模拟收银台,3 分钟有效);用户确认后 status=3 待审核,后台通过后入账。
|
||||
* - `channel_code=ddpay`:调用 DDPay「入金发起」;入账由回调或同步 completed 结算。
|
||||
*
|
||||
* 请求:application/json 或 x-www-form-urlencoded
|
||||
* - tier_id / tier_key: 必填,档位唯一标识(与 depositTierList 中 id、tier_key 一致)
|
||||
* - channel_code: 必填,支付渠道代码(与 depositTierList 各档位 channels[].code 一致)
|
||||
* - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单
|
||||
* - tier_id / tier_key、channel_code、idempotency_key:必填
|
||||
* - DDPay 渠道另需 payment_type、payer_name、payer_bank_name
|
||||
*
|
||||
* 流程:仅创建 `status=0` 的待支付订单,返回 `pay_url`(含签名的模拟「第三方收银台」页);玩家打开后点确认,
|
||||
* 由服务端 `depositMockNotify` 模拟网关联调完成入账。未来接入真实三方时,将「打开 pay_url + 等回调」替换为
|
||||
* 真网关,入账仍走 `DepositSettlement::settle`。
|
||||
*
|
||||
* 响应(统一结构,未来接入第三方也保持此形状):
|
||||
* - order_no / amount / pay_channel / paid / pay_url / status / create_time / pay_time
|
||||
* 响应:`order_no` / `amount` / `pay_channel` / `paid` / `pay_url` / `status` / `create_time` / `pay_time`
|
||||
*/
|
||||
public function depositCreate(Request $request): Response
|
||||
{
|
||||
@@ -124,6 +135,24 @@ class Finance extends MobileBase
|
||||
if (mb_strlen($idempotencyKey) > 64) {
|
||||
return $this->mobileError(1002, 'Idempotency key is too long');
|
||||
}
|
||||
if ($channelCode === MockPay::CHANNEL_CODE) {
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'Mock pay channel is disabled');
|
||||
}
|
||||
} elseif ($channelCode === 'ddpay') {
|
||||
if (!DDPayGateway::isConfigured()) {
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'DDPay is not configured', [
|
||||
'suggest_channel_code' => MockPay::CHANNEL_CODE,
|
||||
'hint' => 'Set FINANCE_MOCK_PAY_ENABLED=1 or configure DDPAY_* in .env',
|
||||
]);
|
||||
}
|
||||
// 未配置 DDPay 商户:联调环境自动改用 mock(无需改 Apifox 参数)
|
||||
$channelCode = MockPay::CHANNEL_CODE;
|
||||
}
|
||||
} else {
|
||||
return $this->mobileError(2004, 'Pay channel not supported');
|
||||
}
|
||||
|
||||
$tiers = $this->loadEnabledTiers();
|
||||
$tier = DepositTierLib::findById($tiers, $tierId);
|
||||
@@ -158,7 +187,7 @@ class Finance extends MobileBase
|
||||
return $this->mobileError(2005, 'Too many pending deposit orders', [
|
||||
'max_pending' => DepositOrderExpireService::MAX_PENDING_DEPOSIT,
|
||||
'pending_count' => $pendingCount,
|
||||
'expire_seconds' => DepositOrderExpireService::EXPIRE_SECONDS,
|
||||
'expire_seconds' => DepositOrderExpireService::pendingExpireSeconds(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -167,6 +196,9 @@ class Finance extends MobileBase
|
||||
if ($curSnap === '') {
|
||||
$curSnap = 'CNY';
|
||||
}
|
||||
if (!DepositChannelLib::assertChannelAllowsCurrency($channelCode, $curSnap, $effectiveChannels)) {
|
||||
return $this->mobileError(2004, 'Pay channel not available for this currency');
|
||||
}
|
||||
$tierSnapshot = [
|
||||
'id' => $tier['id'],
|
||||
'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '',
|
||||
@@ -179,6 +211,9 @@ class Finance extends MobileBase
|
||||
'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '',
|
||||
'channel_code' => $channelCode,
|
||||
];
|
||||
if ($channelCode === MockPay::CHANNEL_CODE && strtolower($this->stringParam($request->input('channel_code'))) === 'ddpay') {
|
||||
$tierSnapshot['ddpay_fallback'] = true;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$channelId = null;
|
||||
@@ -198,7 +233,9 @@ class Finance extends MobileBase
|
||||
'pay_channel' => $channelCode,
|
||||
'deposit_tier_id' => $tier['id'],
|
||||
'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'remark' => '',
|
||||
'remark' => $channelCode === MockPay::CHANNEL_CODE && ($tierSnapshot['ddpay_fallback'] ?? false)
|
||||
? '[mock] auto fallback: DDPay not configured'
|
||||
: '',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
@@ -213,8 +250,492 @@ class Finance extends MobileBase
|
||||
return $this->mobileError(2000, $msg);
|
||||
}
|
||||
|
||||
// 仅落待支付单;真实入账在模拟网关联调 depositMockNotify 中完成
|
||||
return $this->mobileSuccess($this->buildDepositResponse($order, $this->publicOriginFromRequest($request)));
|
||||
$orderId = is_numeric($order->id ?? null) ? intval(strval($order->id)) : 0;
|
||||
$publicOrigin = $this->publicOriginFromRequest($request);
|
||||
|
||||
if ($channelCode === MockPay::CHANNEL_CODE) {
|
||||
return $this->finishMockDepositCreate($order, $orderId, $orderNo, $publicOrigin);
|
||||
}
|
||||
|
||||
// DDPay 入金:创建订单后,调用三方「入金发起」拿到 payment_url,并在回调里验签结算。
|
||||
$toString = static function (mixed $v): string {
|
||||
if (is_string($v)) {
|
||||
return trim($v);
|
||||
}
|
||||
if (is_numeric($v)) {
|
||||
return trim(strval($v));
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$paymentType = $toString($request->input('payment_type'));
|
||||
if ($paymentType === '') {
|
||||
$paymentType = $toString($request->input('paymentType'));
|
||||
}
|
||||
$payerName = $toString($request->input('payer_name'));
|
||||
if ($payerName === '') {
|
||||
$payerName = $toString($request->input('payerName'));
|
||||
}
|
||||
$payerBankName = $toString($request->input('payer_bank_name'));
|
||||
if ($payerBankName === '') {
|
||||
$payerBankName = $toString($request->input('payer_bank[name]'));
|
||||
}
|
||||
if ($payerBankName === '') {
|
||||
$payerBankName = $toString($request->input('payerBankName'));
|
||||
}
|
||||
|
||||
if ($paymentType === '' || $payerName === '' || $payerBankName === '') {
|
||||
return $this->mobileError(1001, 'Missing DDPay parameters');
|
||||
}
|
||||
if (!$this->isValidDdpayPaymentType($paymentType)) {
|
||||
return $this->mobileError(1001, 'Invalid DDPay payment_type');
|
||||
}
|
||||
|
||||
$callbackUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositNotify';
|
||||
$redirectUrl = rtrim($publicOrigin, '/') . '/api/finance/ddpayDepositRedirect?order_no=' . rawurlencode($orderNo);
|
||||
|
||||
$ddReq = [
|
||||
'client_id' => config('app.ddpay_client_id', ''),
|
||||
'identifier' => config('app.ddpay_identifier', ''),
|
||||
'order_id' => $orderNo,
|
||||
'payment_type' => $paymentType,
|
||||
'transaction_amount' => $tierSnapshot['pay_amount'],
|
||||
'payer_name' => $payerName,
|
||||
'payer_bank[name]' => $payerBankName,
|
||||
'callback_url' => $callbackUrl,
|
||||
'redirect_url' => $redirectUrl,
|
||||
];
|
||||
|
||||
try {
|
||||
$ddResp = DDPayGateway::depositInitiation($ddReq);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('[depositCreate] ddpay initiation failed: ' . json_encode([
|
||||
'order_no' => $orderNo,
|
||||
'user_id' => $userId,
|
||||
'exception' => get_class($e),
|
||||
'reason' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
if ($orderId > 0) {
|
||||
$remark = '[ddpay] ' . $e->getMessage();
|
||||
$remark = mb_substr($remark, 0, 255);
|
||||
Db::name('deposit_order')
|
||||
->where('id', $orderId)
|
||||
->where('status', 0)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => $remark,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$reason = trim($e->getMessage());
|
||||
if ($reason === '') {
|
||||
$reason = 'DDPay deposit initiation failed';
|
||||
}
|
||||
return $this->mobileError(2000, 'DDPay deposit initiation failed', [
|
||||
'gateway_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
$ts = '';
|
||||
if (isset($ddResp['transaction_status']) && is_string($ddResp['transaction_status'])) {
|
||||
$ts = strtolower(trim($ddResp['transaction_status']));
|
||||
}
|
||||
|
||||
$paymentUrl = '';
|
||||
if (isset($ddResp['payment_url']) && is_string($ddResp['payment_url'])) {
|
||||
$paymentUrl = trim($ddResp['payment_url']);
|
||||
}
|
||||
|
||||
if ($orderId > 0) {
|
||||
$snapUpdate = $tierSnapshot;
|
||||
if ($paymentUrl !== '') {
|
||||
$snapUpdate['payment_url'] = $paymentUrl;
|
||||
}
|
||||
$snapUpdate['ddpay'] = $ddResp;
|
||||
Db::name('deposit_order')
|
||||
->where('id', $orderId)
|
||||
->update([
|
||||
'pay_account_snapshot' => json_encode($snapUpdate, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($ts === 'completed' && $orderId > 0) {
|
||||
try {
|
||||
DepositSettlement::settle(
|
||||
$orderId,
|
||||
DepositSettlement::SOURCE_THIRD_PARTY,
|
||||
'ddpay deposit initiation completed',
|
||||
null,
|
||||
'transaction_status=' . $ts
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
// 已有状态不允许 settlement 时忽略,返回当前订单状态即可
|
||||
}
|
||||
}
|
||||
|
||||
if ($ts === 'failed' && $orderId > 0) {
|
||||
$statusMsg = is_string($ddResp['status_message'] ?? null) ? trim($ddResp['status_message']) : 'DDPay transaction failed';
|
||||
$remark = '[ddpay] ' . $statusMsg;
|
||||
$remark = mb_substr($remark, 0, 255);
|
||||
Db::name('deposit_order')
|
||||
->where('id', $orderId)
|
||||
->where('status', 0)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => $remark,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$fresh = DepositOrder::where('id', $orderId)->find();
|
||||
if ($fresh) {
|
||||
return $this->mobileSuccess($this->buildDepositResponse($fresh, $publicOrigin));
|
||||
}
|
||||
|
||||
return $this->mobileSuccess($this->buildDepositResponse($order, $publicOrigin));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧链接:校验订单后 302 跳转到前端静态收银台(带签名)
|
||||
*/
|
||||
public function mockDepositPage(Request $request): Response
|
||||
{
|
||||
if (!MockPay::isEnabled()) {
|
||||
return response($this->renderMockDepositMessageHtml('模拟支付未开启', ''), 404, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
if ($orderNo === '') {
|
||||
return response($this->renderMockDepositMessageHtml('缺少订单号', ''), 400, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$redirectUrl = $this->buildMockDepositFrontendUrl($orderNo, $this->publicOriginFromRequest($request));
|
||||
if ($redirectUrl === '') {
|
||||
return response($this->renderMockDepositMessageHtml('订单不存在', ''), 404, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟收银台页拉取订单状态(前端静态页调用,须携带 sign + expire_at)
|
||||
*/
|
||||
public function mockDepositStatus(Request $request): Response
|
||||
{
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'Mock pay channel is disabled');
|
||||
}
|
||||
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
$expireAt = $this->intParam($request->input('expire_at'));
|
||||
$sign = $this->stringParam($request->input('sign'));
|
||||
if ($orderNo === '' || $expireAt <= 0 || $sign === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) {
|
||||
return $this->mobileError(1002, 'Invalid payment link signature');
|
||||
}
|
||||
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$row = Db::name('deposit_order')->where('order_no', $orderNo)->find();
|
||||
if (!is_array($row)) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
|
||||
$payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : '';
|
||||
if ($payChannel !== MockPay::CHANNEL_CODE) {
|
||||
return $this->mobileError(2000, 'Order is not a mock pay deposit');
|
||||
}
|
||||
|
||||
$statusCode = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1;
|
||||
$amount = $this->amountString($row['amount'] ?? '0');
|
||||
$bonus = $this->amountString($row['bonus_amount'] ?? '0');
|
||||
$now = time();
|
||||
$remaining = $expireAt > $now ? ($expireAt - $now) : 0;
|
||||
$canPay = $statusCode === 0 && DepositOrderExpireService::isPendingPaymentValid($row) && $remaining > 0;
|
||||
|
||||
return $this->mobileSuccess($this->attachMockDepositReturnGameUrl([
|
||||
'order_no' => $orderNo,
|
||||
'amount' => $this->amountNumber($amount),
|
||||
'bonus_amount' => $this->amountNumber($bonus),
|
||||
'total_amount' => $this->amountNumber(bcadd($amount, $bonus, 2)),
|
||||
'status' => $this->mapDepositStatus($statusCode),
|
||||
'status_code' => $statusCode,
|
||||
'expire_at' => $expireAt,
|
||||
'remaining_seconds' => $remaining,
|
||||
'can_pay' => $canPay,
|
||||
'reject_reason' => is_string($row['reject_reason'] ?? null) && trim($row['reject_reason']) !== ''
|
||||
? trim($row['reject_reason'])
|
||||
: null,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟收银台确认支付(浏览器页内调用,无需 auth-token)
|
||||
*/
|
||||
public function mockDepositConfirm(Request $request): Response
|
||||
{
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'Mock pay channel is disabled');
|
||||
}
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
$expireAt = $this->intParam($request->input('expire_at'));
|
||||
$sign = $this->stringParam($request->input('sign'));
|
||||
if ($orderNo === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if ($expireAt <= 0 || $sign === '') {
|
||||
return $this->mobileError(1001, 'Missing payment link signature');
|
||||
}
|
||||
if (!$this->verifyMockDepositLinkSign($orderNo, $expireAt, $sign)) {
|
||||
return $this->mobileError(1002, 'Invalid payment link signature');
|
||||
}
|
||||
|
||||
$result = $this->confirmMockDepositPendingPayment($orderNo, 'mock page confirm');
|
||||
if ($result['error'] !== null) {
|
||||
return $result['error'];
|
||||
}
|
||||
|
||||
return $this->mobileSuccess($result['payload']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟支付:确认已支付(需登录,API 客户端调用)
|
||||
*/
|
||||
public function mockDepositPay(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'Mock pay channel is disabled');
|
||||
}
|
||||
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
if ($orderNo === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
|
||||
$order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find();
|
||||
if (!$order) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
|
||||
$result = $this->confirmMockDepositPendingPayment($orderNo, 'mock api confirm');
|
||||
if ($result['error'] !== null) {
|
||||
return $result['error'];
|
||||
}
|
||||
|
||||
return $this->mobileSuccess($result['payload']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{error: ?Response, payload: array<string, mixed>}
|
||||
*/
|
||||
private function confirmMockDepositPendingPayment(string $orderNo, string $sourceLabel): array
|
||||
{
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$row = Db::name('deposit_order')->where('order_no', $orderNo)->find();
|
||||
if (!is_array($row)) {
|
||||
return [
|
||||
'error' => $this->mobileError(2003, 'Order does not exist'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : '';
|
||||
if ($payChannel !== MockPay::CHANNEL_CODE) {
|
||||
return [
|
||||
'error' => $this->mobileError(2000, 'Order is not a mock pay deposit'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$status = is_numeric($row['status'] ?? null) ? intval($row['status']) : -1;
|
||||
if ($status === 1) {
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
$payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null));
|
||||
$payload['review_required'] = false;
|
||||
return ['error' => null, 'payload' => $payload];
|
||||
}
|
||||
if ($status === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) {
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
$payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null));
|
||||
$payload['review_required'] = true;
|
||||
return ['error' => null, 'payload' => $payload];
|
||||
}
|
||||
if ($status !== 0 || !DepositOrderExpireService::isPendingPaymentValid($row)) {
|
||||
return [
|
||||
'error' => $this->mobileError(2000, 'Order cannot be paid'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$orderId = is_numeric($row['id'] ?? null) ? intval($row['id']) : 0;
|
||||
if ($orderId <= 0) {
|
||||
return [
|
||||
'error' => $this->mobileError(2000, 'Order id invalid'),
|
||||
'payload' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$snap = [];
|
||||
$snapRaw = $row['pay_account_snapshot'] ?? '';
|
||||
if (is_string($snapRaw) && trim($snapRaw) !== '') {
|
||||
$decoded = json_decode($snapRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$snap = $decoded;
|
||||
}
|
||||
}
|
||||
$snap['mock_paid_at'] = $now;
|
||||
$snap['mock_paid_source'] = $sourceLabel;
|
||||
|
||||
Db::name('deposit_order')->where('id', $orderId)->where('status', 0)->update([
|
||||
'status' => MockPay::DEPOSIT_STATUS_PENDING_REVIEW,
|
||||
'remark' => '[mock] 玩家已支付,待管理员审核',
|
||||
'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$order = DepositOrder::where('id', $orderId)->find();
|
||||
$payload = $this->attachMockDepositReturnGameUrl($this->buildDepositResponse($order, null));
|
||||
$payload['review_required'] = true;
|
||||
$payload['mock_pay_success'] = true;
|
||||
$payload['mock_pay_message'] = 'Payment submitted. Pending admin review.';
|
||||
|
||||
return ['error' => null, 'payload' => $payload];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function attachMockDepositReturnGameUrl(array $payload): array
|
||||
{
|
||||
$returnGameUrl = MockPay::resolveGameReturnUrl();
|
||||
if ($returnGameUrl !== '') {
|
||||
$payload['return_game_url'] = $returnGameUrl;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \app\common\model\DepositOrder|object $order
|
||||
*/
|
||||
private function finishMockDepositCreate($order, int $orderId, string $orderNo, string $publicOrigin): Response
|
||||
{
|
||||
$amountStr = $this->amountString($order->amount ?? '0');
|
||||
$bonusStr = $this->amountString($order->bonus_amount ?? '0');
|
||||
$createTime = is_numeric($order->create_time ?? null) ? intval(strval($order->create_time)) : time();
|
||||
$linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime);
|
||||
$expireAt = $linkAuth['expire_at'];
|
||||
$sign = $linkAuth['sign'];
|
||||
$payUrl = MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amountStr, $bonusStr);
|
||||
|
||||
if ($orderId > 0) {
|
||||
$snap = [];
|
||||
$snapRaw = $order->pay_account_snapshot ?? '';
|
||||
if (is_string($snapRaw) && trim($snapRaw) !== '') {
|
||||
$decoded = json_decode($snapRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$snap = $decoded;
|
||||
}
|
||||
}
|
||||
$snap['payment_url'] = $payUrl;
|
||||
$snap['mock'] = true;
|
||||
$snap['expire_at'] = $expireAt;
|
||||
$snap['mock_pay_sign'] = $sign;
|
||||
Db::name('deposit_order')->where('id', $orderId)->update([
|
||||
'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$refreshed = DepositOrder::where('id', $orderId)->find();
|
||||
if ($refreshed) {
|
||||
$order = $refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = $this->buildDepositResponse($order, $publicOrigin);
|
||||
$payload['expire_at'] = $expireAt;
|
||||
$payload['expire_seconds'] = DepositOrderExpireService::pendingExpireSeconds();
|
||||
|
||||
return $this->mobileSuccess($payload);
|
||||
}
|
||||
|
||||
private function renderMockDepositCheckoutHtml(
|
||||
string $orderNo,
|
||||
string $amount,
|
||||
string $bonus,
|
||||
int $expireAt,
|
||||
string $confirmUrl
|
||||
): string {
|
||||
$total = bcadd($amount, $bonus, 2);
|
||||
$expireText = date('Y-m-d H:i:s', $expireAt);
|
||||
|
||||
$orderNoEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8');
|
||||
$amountEsc = htmlspecialchars($amount, ENT_QUOTES, 'UTF-8');
|
||||
$bonusEsc = htmlspecialchars($bonus, ENT_QUOTES, 'UTF-8');
|
||||
$totalEsc = htmlspecialchars($total, ENT_QUOTES, 'UTF-8');
|
||||
$expireEsc = htmlspecialchars($expireText, ENT_QUOTES, 'UTF-8');
|
||||
$orderNoJs = json_encode($orderNo, JSON_UNESCAPED_UNICODE);
|
||||
$confirmUrlJs = json_encode($confirmUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$returnGameUrl = MockPay::resolveGameReturnUrl();
|
||||
$returnGameUrlJs = json_encode($returnGameUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
||||
. '<title>模拟充值</title><style>'
|
||||
. 'body{font-family:system-ui,sans-serif;background:#f5f7fa;margin:0;padding:24px;}'
|
||||
. '.card{max-width:420px;margin:40px auto;background:#fff;border-radius:12px;padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.08);}'
|
||||
. 'h1{font-size:20px;margin:0 0 8px;}p{color:#666;line-height:1.6;margin:8px 0;}'
|
||||
. '.amt{font-size:28px;color:#1677ff;font-weight:700;margin:16px 0;}'
|
||||
. 'button{width:100%;padding:14px;font-size:16px;border:0;border-radius:8px;background:#1677ff;color:#fff;cursor:pointer;margin-top:12px;}'
|
||||
. 'button:disabled{background:#ccc;}'
|
||||
. '.btn-back{display:none;background:#fff;color:#cf1322;border:1px solid #d9d9d9;}'
|
||||
. '.hint{font-size:13px;color:#999;}'
|
||||
. '.ok{display:none;margin-top:16px;padding:12px;background:#f6ffed;border:1px solid #b7eb8f;border-radius:8px;color:#389e0d;line-height:1.6;}'
|
||||
. '</style></head><body>'
|
||||
. '<div class="card"><h1>模拟充值收银台</h1>'
|
||||
. '<p class="hint">订单号:' . $orderNoEsc . '</p>'
|
||||
. '<p>充值金额 <strong>' . $amountEsc . '</strong>,赠送 <strong>' . $bonusEsc . '</strong></p>'
|
||||
. '<div class="amt">预计到账 ' . $totalEsc . '</div>'
|
||||
. '<p class="hint">链接有效期至 ' . $expireEsc . '(约 3 分钟,过期后订单将自动失效)</p>'
|
||||
. '<button type="button" id="btnPay">确认支付</button>'
|
||||
. '<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br/>订单已提交,需管理员在后台审核通过后才会入账。</div>'
|
||||
. '<button type="button" class="btn-back" id="btnBackGame">返回游戏</button>'
|
||||
. '<script>(function(){var confirmUrl=' . $confirmUrlJs . ',orderNo=' . $orderNoJs . ',returnGameUrl=' . $returnGameUrlJs . ';'
|
||||
. 'function showBackGame(){if(!returnGameUrl){return;}var btn=document.getElementById("btnBackGame");btn.style.display="block";btn.onclick=function(){window.location.href=returnGameUrl;};}'
|
||||
. 'document.getElementById("btnPay").onclick=function(){var btn=this;btn.disabled=true;btn.textContent="处理中...";'
|
||||
. 'fetch(confirmUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},'
|
||||
. 'body:"order_no="+encodeURIComponent(orderNo)}).then(function(r){return r.json();}).then(function(res){'
|
||||
. 'if(res&&res.code===1){document.getElementById("okBox").style.display="block";btn.style.display="none";'
|
||||
. 'if(res.data&&res.data.return_game_url){returnGameUrl=res.data.return_game_url;}showBackGame();}'
|
||||
. 'else{alert((res&&res.message)||"支付失败");btn.disabled=false;btn.textContent="确认支付";}})'
|
||||
. '.catch(function(){alert("网络错误,请重试");btn.disabled=false;btn.textContent="确认支付";});};})();</script>'
|
||||
. '</div></body></html>';
|
||||
}
|
||||
|
||||
private function renderMockDepositMessageHtml(string $title, string $hint): string
|
||||
{
|
||||
$titleEsc = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||
$hintHtml = $hint !== '' ? '<p>' . htmlspecialchars($hint, ENT_QUOTES, 'UTF-8') . '</p>' : '';
|
||||
|
||||
return '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
||||
. '<title>' . $titleEsc . '</title>'
|
||||
. '<style>body{font-family:system-ui,sans-serif;background:#f5f7fa;padding:40px;text-align:center;}'
|
||||
. '.card{max-width:400px;margin:0 auto;background:#fff;padding:32px;border-radius:12px;}</style></head><body>'
|
||||
. '<div class="card"><h1>' . $titleEsc . '</h1>' . $hintHtml . '</div></body></html>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,106 +745,224 @@ class Finance extends MobileBase
|
||||
*/
|
||||
private function buildDepositResponse($order, ?string $publicOrigin = null): array
|
||||
{
|
||||
$status = $this->mapDepositStatus($order->status);
|
||||
$statusCode = $this->intValue($order->status);
|
||||
$status = $this->mapDepositStatus($statusCode);
|
||||
$paid = $status === 'paid';
|
||||
$amount = $this->amountString($order->amount);
|
||||
$bonus = $this->amountString($order->bonus_amount);
|
||||
$total = bcadd($amount, $bonus, 2);
|
||||
$on = is_string($order->order_no) ? $order->order_no : strval($order->order_no);
|
||||
$payUrl = '';
|
||||
if ($this->intValue($order->status) === 0 && $on !== '') {
|
||||
$payUrl = DepositMockGateway::payPageUrl($on, $publicOrigin);
|
||||
$expireAt = 0;
|
||||
$payChannel = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel);
|
||||
$snapRaw = $order->pay_account_snapshot ?? null;
|
||||
$snap = null;
|
||||
if (is_array($snapRaw)) {
|
||||
$snap = $snapRaw;
|
||||
} elseif (is_string($snapRaw) && trim($snapRaw) !== '') {
|
||||
$decoded = json_decode($snapRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$snap = $decoded;
|
||||
}
|
||||
}
|
||||
if (is_array($snap) && isset($snap['payment_url']) && is_string($snap['payment_url'])) {
|
||||
$payUrl = trim($snap['payment_url']);
|
||||
}
|
||||
if (is_array($snap) && isset($snap['expire_at']) && is_numeric($snap['expire_at'])) {
|
||||
$expireAt = intval($snap['expire_at']);
|
||||
}
|
||||
if ($statusCode === 0 && $on !== '' && $payChannel === MockPay::CHANNEL_CODE) {
|
||||
$origin = $publicOrigin !== null && $publicOrigin !== '' ? $publicOrigin : '';
|
||||
$createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time();
|
||||
if ($expireAt <= 0) {
|
||||
$expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds();
|
||||
}
|
||||
$sign = '';
|
||||
if (is_array($snap) && isset($snap['mock_pay_sign']) && is_string($snap['mock_pay_sign'])) {
|
||||
$sign = trim($snap['mock_pay_sign']);
|
||||
}
|
||||
if ($sign === '' || !MockPay::verifyDepositLink($on, $expireAt, $sign)) {
|
||||
$linkAuth = MockPay::buildDepositLinkAuth($on, $createTime);
|
||||
$expireAt = $linkAuth['expire_at'];
|
||||
$sign = $linkAuth['sign'];
|
||||
}
|
||||
if ($payUrl === '') {
|
||||
$payUrl = MockPay::depositPageUrl($on, $origin, $expireAt, $sign, $amount, $bonus);
|
||||
}
|
||||
}
|
||||
if ($expireAt <= 0 && $statusCode === 0 && $payChannel === MockPay::CHANNEL_CODE) {
|
||||
$createTime = is_numeric(strval($order->create_time ?? 0)) ? intval(strval($order->create_time)) : time();
|
||||
$expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds();
|
||||
}
|
||||
$rejectReason = is_string($order->reject_reason ?? null) ? trim($order->reject_reason) : '';
|
||||
|
||||
return [
|
||||
'order_no' => $on,
|
||||
'amount' => $this->amountNumber($amount),
|
||||
'bonus_amount' => $this->amountNumber($bonus),
|
||||
'total_amount' => $this->amountNumber($total),
|
||||
'status' => $status,
|
||||
'paid' => $paid,
|
||||
'pay_channel' => is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel),
|
||||
'pay_url' => $payUrl,
|
||||
'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0,
|
||||
'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0,
|
||||
'order_no' => $on,
|
||||
'amount' => $this->amountNumber($amount),
|
||||
'bonus_amount' => $this->amountNumber($bonus),
|
||||
'total_amount' => $this->amountNumber($total),
|
||||
'status' => $status,
|
||||
'paid' => $paid,
|
||||
'review_required' => $status === 'pending_review',
|
||||
'reject_reason' => $rejectReason !== '' ? $rejectReason : null,
|
||||
'pay_channel' => $payChannel,
|
||||
'pay_url' => $payUrl,
|
||||
'expire_at' => $expireAt > 0 ? $expireAt : null,
|
||||
'expire_seconds' => $statusCode === 0 ? DepositOrderExpireService::pendingExpireSeconds() : null,
|
||||
'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0,
|
||||
'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求拼出公网 origin,用于给客户端直接可用的完整 pay_url。
|
||||
* 公网根 URL:优先环境变量 DDPAY_PUBLIC_BASE_URL,否则按请求推导(见 DDPayGateway::publicBaseUrlForCallbacks)。
|
||||
*/
|
||||
private function publicOriginFromRequest(Request $request): string
|
||||
{
|
||||
$proto = strtolower((string) $request->header('x-forwarded-proto', ''));
|
||||
$https = $proto === 'https' || strtolower((string) $request->header('x-forwarded-ssl', '')) === 'on';
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = trim((string) $request->header('host', ''));
|
||||
if ($host === '') {
|
||||
$host = trim((string) ($request->header('x-forwarded-host', '')));
|
||||
}
|
||||
if ($host === '') {
|
||||
$host = '127.0.0.1:8787';
|
||||
}
|
||||
|
||||
return $scheme . '://' . $host;
|
||||
return DDPayGateway::publicBaseUrlForCallbacks($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟第三方支付收银台(HTML)。玩家浏览器打开,点击按钮即向 depositMockNotify 发起回调。
|
||||
* DDPay Webhook 回调:验签后把 deposit_order 更新为 paid/failed。
|
||||
*
|
||||
* 文档要求:返回纯文本 + HTTP 200(避免三方重复推送)。
|
||||
*/
|
||||
public function depositMockPayPage(Request $request): Response
|
||||
public function ddpayDepositNotify(Request $request): Response
|
||||
{
|
||||
// Webman Request::input() 需要传 key;Webhook 签名计算只依赖文档列出的固定字段。
|
||||
$payload = [
|
||||
'client_id' => $request->input('client_id'),
|
||||
'order_id' => $request->input('order_id'),
|
||||
'transaction_status' => $request->input('transaction_status'),
|
||||
'timestamp' => $request->input('timestamp'),
|
||||
'transaction_amount' => $request->input('transaction_amount'),
|
||||
'signature' => $request->input('signature'),
|
||||
];
|
||||
|
||||
$verified = false;
|
||||
try {
|
||||
$verified = DDPayGateway::verifyWebhookSignature($payload);
|
||||
} catch (Throwable $e) {
|
||||
$verified = false;
|
||||
}
|
||||
|
||||
if (!$verified) {
|
||||
return response('Invalid signature', 403, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$orderNoRaw = $payload['order_id'] ?? '';
|
||||
$orderNo = is_string($orderNoRaw) ? trim($orderNoRaw) : (is_numeric($orderNoRaw) ? strval($orderNoRaw) : '');
|
||||
if ($orderNo === '') {
|
||||
return response('Missing order_id', 400, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$statusRaw = $payload['transaction_status'] ?? '';
|
||||
$status = is_string($statusRaw) ? strtolower(trim($statusRaw)) : '';
|
||||
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
// 订单不存在通常是传参错误:直接 ack 以避免重复重试轰炸。
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
// 快照写入(不影响主流程)
|
||||
try {
|
||||
$snapRaw = $order->pay_account_snapshot ?? null;
|
||||
$snap = null;
|
||||
if (is_array($snapRaw)) {
|
||||
$snap = $snapRaw;
|
||||
} elseif (is_string($snapRaw) && trim($snapRaw) !== '') {
|
||||
$decoded = json_decode($snapRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
$snap = $decoded;
|
||||
}
|
||||
}
|
||||
if (!is_array($snap)) {
|
||||
$snap = [];
|
||||
}
|
||||
$snap['ddpay_webhook'] = $payload;
|
||||
Db::name('deposit_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->update([
|
||||
'pay_account_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if ($this->intValue($order->status) === 0) {
|
||||
if ($status === 'completed') {
|
||||
try {
|
||||
DepositSettlement::settle(
|
||||
intval(strval($order->id)),
|
||||
DepositSettlement::SOURCE_THIRD_PARTY,
|
||||
'ddpay webhook completed',
|
||||
null,
|
||||
'transaction_status=' . $status
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
// settlement 不允许非待支付状态时忽略
|
||||
}
|
||||
} elseif ($status === 'failed') {
|
||||
$amtRaw = $payload['transaction_amount'] ?? null;
|
||||
$amt = is_string($amtRaw) ? trim($amtRaw) : (is_numeric($amtRaw) ? strval($amtRaw) : '');
|
||||
$remark = '[ddpay] transaction failed' . ($amt !== '' ? ' amount=' . $amt : '');
|
||||
$remark = mb_substr($remark, 0, 255);
|
||||
Db::name('deposit_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->where('status', 0)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => $remark,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DDPay redirect_url:展示“请返回 APP 查看余额”提示。
|
||||
*/
|
||||
public function ddpayDepositRedirect(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
$sign = $this->stringParam($request->input('sign'));
|
||||
if ($orderNo === '' || $sign === '' || !DepositMockGateway::verifyOrderNo($orderNo, $sign)) {
|
||||
return response('Invalid or expired payment link', 403, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
return response('Order not found', 404, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
return response('Order not found', 404, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
if ($this->intValue($order->status) !== 0) {
|
||||
$st = $this->mapDepositStatus($order->status);
|
||||
$msg = 'Order status: ' . $st;
|
||||
if ($st === 'paid') {
|
||||
$msg = 'This order is already paid. You can return to the app.';
|
||||
}
|
||||
$msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return response('<!doctype html><html><head><meta charset="utf-8"><title>充值</title></head><body><p>' . $msgEsc . '</p></body></html>', 200, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
]);
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
$statusRaw = $request->input('transaction_status');
|
||||
$status = is_string($statusRaw) ? strtolower(trim($statusRaw)) : '';
|
||||
|
||||
$lang = $this->currentLang();
|
||||
$isZh = str_starts_with($lang, 'zh');
|
||||
$msg = $isZh ? '已收到支付,请返回 App 查看余额。' : 'Payment received. You can return to the app to check your balance.';
|
||||
if ($status === 'completed') {
|
||||
$msg = $isZh ? '支付已完成,建议返回 App 查看结果。' : 'Payment completed. Returning to the app is recommended.';
|
||||
} elseif ($status === 'failed') {
|
||||
$msg = $isZh ? '支付失败,请稍后重试。' : 'Payment failed. Please try again.';
|
||||
}
|
||||
$amount = $this->amountString($order->amount);
|
||||
$bonus = $this->amountString($order->bonus_amount);
|
||||
$noEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8');
|
||||
$signEsc = htmlspecialchars($sign, ENT_QUOTES, 'UTF-8');
|
||||
$payChannel = is_string($order->pay_channel) ? htmlspecialchars($order->pay_channel, ENT_QUOTES, 'UTF-8') : '';
|
||||
$html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>模拟支付</title></head><body style="font-family:system-ui;padding:1rem">';
|
||||
$html .= '<h2>模拟第三方收银台</h2>';
|
||||
$html .= '<p>订单号:' . $noEsc . '</p>';
|
||||
$html .= '<p>支付渠道:' . $payChannel . '</p>';
|
||||
$html .= '<p>金额(法币/标价):' . htmlspecialchars($amount, ENT_QUOTES, 'UTF-8') . ' + 赠送 ' . htmlspecialchars($bonus, ENT_QUOTES, 'UTF-8') . '(币)</p>';
|
||||
$html .= '<p>点击下方按钮即视为<strong>第三方支付成功</strong>,服务端会回调并到账。</p>';
|
||||
$html .= '<form method="post" action="/api/finance/depositMockNotify" style="margin-top:1rem">';
|
||||
$html .= '<input type="hidden" name="order_no" value="' . $noEsc . '">';
|
||||
$html .= '<input type="hidden" name="sign" value="' . $signEsc . '">';
|
||||
$html .= '<button type="submit" style="padding:0.5rem 1rem">确认支付(模拟成功)</button>';
|
||||
$html .= '</form></body></html>';
|
||||
|
||||
$orderNoEsc = htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8');
|
||||
$msgEsc = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$html = '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>DDPay</title></head><body style="font-family:system-ui;padding:1rem">';
|
||||
$html .= '<h2>DDPay</h2>';
|
||||
if ($orderNoEsc !== '') {
|
||||
$html .= '<p>Order: ' . $orderNoEsc . '</p>';
|
||||
}
|
||||
$html .= '<p>' . $msgEsc . '</p>';
|
||||
$html .= '</body></html>';
|
||||
|
||||
return response($html, 200, [
|
||||
'Content-Type' => 'text/html; charset=utf-8',
|
||||
@@ -331,56 +970,177 @@ class Finance extends MobileBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟第三方异步通知:验签后调用 DepositSettlement::settle 入账。
|
||||
* DDPay 出金 Webhook 回调:验签后更新 withdraw_order.status,并在 failed 时进行返还余额。
|
||||
*
|
||||
* DDPAY 文档要求:返回纯文本 + HTTP 200。
|
||||
*/
|
||||
public function depositMockNotify(Request $request): Response
|
||||
public function ddpayPayoutNotify(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$orderNo = $this->stringParam($request->input('order_no'));
|
||||
$sign = $this->stringParam($request->input('sign'));
|
||||
if ($orderNo === '' || $sign === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (!DepositMockGateway::verifyOrderNo($orderNo, $sign)) {
|
||||
return $this->mobileError(1003, 'Invalid parameter value');
|
||||
}
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$order = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
return $this->mobileError(2003, 'Order does not exist');
|
||||
}
|
||||
if ($this->intValue($order->status) !== 0) {
|
||||
return $this->mobileSuccess($this->buildDepositResponse($order, null));
|
||||
}
|
||||
$orderId = intval(strval($order->id));
|
||||
if ($orderId <= 0) {
|
||||
return $this->mobileError(2000, 'Order id invalid');
|
||||
}
|
||||
$pc = is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel);
|
||||
// Webhook 签名计算只依赖文档列出的固定字段
|
||||
$payload = [
|
||||
'client_id' => $request->input('client_id'),
|
||||
'order_id' => $request->input('order_id'),
|
||||
'transaction_status' => $request->input('transaction_status'),
|
||||
'timestamp' => $request->input('timestamp'),
|
||||
'transaction_amount' => $request->input('transaction_amount'),
|
||||
'signature' => $request->input('signature'),
|
||||
];
|
||||
|
||||
try {
|
||||
$result = DepositSettlement::settle(
|
||||
$orderId,
|
||||
DepositSettlement::SOURCE_THIRD_PARTY,
|
||||
'mock third party notify',
|
||||
null,
|
||||
'channel_code=' . $pc
|
||||
);
|
||||
$verified = DDPayGateway::verifyWebhookSignature($payload);
|
||||
} catch (Throwable $e) {
|
||||
return $this->mobileError(2000, $e->getMessage());
|
||||
}
|
||||
$fresh = DepositOrder::where('order_no', $orderNo)->find();
|
||||
if (!$fresh) {
|
||||
return $this->mobileError(2000, 'Order not found after settle');
|
||||
$verified = false;
|
||||
}
|
||||
|
||||
return $this->mobileSuccess($this->buildDepositResponse($fresh, null));
|
||||
if (!$verified) {
|
||||
return response('Invalid signature', 403, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$orderNoRaw = $payload['order_id'] ?? '';
|
||||
$orderNo = is_string($orderNoRaw) ? trim($orderNoRaw) : (is_numeric($orderNoRaw) ? strval($orderNoRaw) : '');
|
||||
if ($orderNo === '') {
|
||||
return response('Missing order_id', 400, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$statusStr = is_string($payload['transaction_status'] ?? '') ? strtolower(trim((string) $payload['transaction_status'])) : '';
|
||||
|
||||
$order = WithdrawOrder::where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
// 避免无效重试轰炸:订单不存在则 ack 200
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$currentStatus = $this->intValue($order->status);
|
||||
if ($currentStatus !== 1) {
|
||||
// 只处理“已通过待打款”状态,避免重复返还
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($statusStr === 'completed') {
|
||||
Db::name('withdraw_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 3,
|
||||
'remark' => '[ddpay] payout completed',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($statusStr === 'failed') {
|
||||
$amount = bcadd(strval($order->amount ?? '0'), '0', 2);
|
||||
if (bccomp($amount, '0', 2) <= 0) {
|
||||
// 金额异常直接置失败,不做返还
|
||||
Db::name('withdraw_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] payout failed',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$userId = intval(strval($order->user_id ?? 0));
|
||||
$channelId = null;
|
||||
if (isset($order->channel_id) && is_numeric(strval($order->channel_id))) {
|
||||
$channelId = intval(strval($order->channel_id));
|
||||
}
|
||||
|
||||
$idempotencyKey = 'wd_ddpay_failed_' . strval($order->order_no);
|
||||
Db::startTrans();
|
||||
try {
|
||||
$walletExists = Db::name('user_wallet_record')
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->find();
|
||||
if ($walletExists) {
|
||||
// 已返还过,只需更新状态
|
||||
Db::name('withdraw_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] payout failed (already refunded)',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::commit();
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
$userRow = Db::name('user')->where('id', $userId)->find();
|
||||
if (!is_array($userRow)) {
|
||||
throw new RuntimeException('User not found for refund');
|
||||
}
|
||||
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 2);
|
||||
$afterCoin = bcadd($beforeCoin, $amount, 2);
|
||||
|
||||
Db::name('user')->where('id', $userId)->update([
|
||||
'coin' => $afterCoin,
|
||||
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amount),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
Db::name('user_wallet_record')->insert([
|
||||
'user_id' => $userId,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'withdraw_refund',
|
||||
'direction' => 1,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $beforeCoin,
|
||||
'balance_after' => $afterCoin,
|
||||
'ref_type' => 'withdraw_order',
|
||||
'ref_id' => intval(strval($order->id)),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'operator_admin_id' => null,
|
||||
'remark' => '[ddpay] payout failed refund',
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
Db::name('withdraw_order')
|
||||
->where('id', intval(strval($order->id)))
|
||||
->where('status', 1)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => '[ddpay] payout failed',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
// 失败不 ack 以便三方重试;但仍需要避免无限循环
|
||||
return response('Refund failed: ' . (string) $e->getMessage(), 500, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
// pending / other 状态:直接 ack
|
||||
return response('{"status":"ok"}', 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,12 +1237,49 @@ class Finance extends MobileBase
|
||||
}
|
||||
$withdrawCoinRaw = $request->post('withdraw_coin', '');
|
||||
$withdrawCoin = is_string($withdrawCoinRaw) ? trim($withdrawCoinRaw) : (is_numeric($withdrawCoinRaw) ? strval($withdrawCoinRaw) : '');
|
||||
$channelCode = strtolower($this->stringParam($request->post('channel_code')));
|
||||
if ($channelCode === '') {
|
||||
$channelCode = strtolower($this->stringParam($request->post('pay_channel')));
|
||||
}
|
||||
$receiveAccount = trim(is_string($request->post('receive_account', '')) ? $request->post('receive_account', '') : '');
|
||||
$receiveType = trim(is_string($request->post('receive_type', '')) ? $request->post('receive_type', '') : '');
|
||||
$receiveType = strtolower($receiveType);
|
||||
|
||||
// DDPAY 出金(Payout)所需扩展字段:当前仅支持 receive_type=bank
|
||||
$receiverName = trim(is_string($request->post('receiver_name', '')) ? $request->post('receiver_name', '') : '');
|
||||
$receiverEmail = trim(is_string($request->post('receiver_email', '')) ? $request->post('receiver_email', '') : '');
|
||||
$receiverMobile = trim(is_string($request->post('receiver_mobile', '')) ? $request->post('receiver_mobile', '') : '');
|
||||
$bankCode = trim(is_string($request->post('bank_code', '')) ? $request->post('bank_code', '') : '');
|
||||
$bankBranch = trim(is_string($request->post('bank_branch', '')) ? $request->post('bank_branch', '') : '');
|
||||
$idempotencyKey = trim(is_string($request->post('idempotency_key', '')) ? $request->post('idempotency_key', '') : '');
|
||||
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
|
||||
if ($withdrawCoin === '' || $channelCode === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === ''
|
||||
|| $receiverEmail === '' || $receiverMobile === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (!in_array($channelCode, DepositChannelLib::withdrawPayoutChannelCodes(), true)) {
|
||||
return $this->mobileError(2004, 'Pay channel not supported');
|
||||
}
|
||||
if ($channelCode === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'Mock pay channel is disabled');
|
||||
}
|
||||
if ($channelCode === 'ddpay' && !DDPayGateway::isConfigured()) {
|
||||
if (!MockPay::isEnabled()) {
|
||||
return $this->mobileError(2004, 'DDPay is not configured', [
|
||||
'suggest_channel_code' => MockPay::CHANNEL_CODE,
|
||||
]);
|
||||
}
|
||||
$channelCode = MockPay::CHANNEL_CODE;
|
||||
}
|
||||
$effectiveChannels = $this->loadDepositChannelEffective();
|
||||
if (!DepositChannelLib::assertChannelEnabled($channelCode, $effectiveChannels)) {
|
||||
return $this->mobileError(2004, 'Pay channel not available');
|
||||
}
|
||||
if (mb_strlen($receiverEmail) > 255 || !filter_var($receiverEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->mobileError(1001, 'Invalid receiver email');
|
||||
}
|
||||
if (mb_strlen($receiverMobile) > 64 || !$this->isValidReceiverMobile($receiverMobile)) {
|
||||
return $this->mobileError(1001, 'Invalid receiver mobile');
|
||||
}
|
||||
if (mb_strlen($idempotencyKey) > 64) {
|
||||
return $this->mobileError(1002, 'Idempotency key is too long');
|
||||
}
|
||||
@@ -491,6 +1288,40 @@ class Finance extends MobileBase
|
||||
}
|
||||
$withdrawCoin = bcadd($withdrawCoin, '0', 2);
|
||||
|
||||
// 按 DDPAY 文档接入:当前仅支持 bank 类型出金
|
||||
if ($receiveType !== 'bank') {
|
||||
return $this->mobileError(2000, 'DDPay payout integration supports receive_type=bank only');
|
||||
}
|
||||
if ($receiverName === '' || $bankCode === '') {
|
||||
return $this->mobileError(1001, 'Missing DDPay bank payout parameters');
|
||||
}
|
||||
if ($bankBranch === '') {
|
||||
$bankBranch = 'N/A';
|
||||
}
|
||||
|
||||
// 映射 bank_code -> DDPAY 所需的完整银行名称(来自 financeCashier.withdraw_banks 配置)
|
||||
$row = GameConfig::where('config_key', FinanceCashierConfigLib::CONFIG_KEY)->find();
|
||||
$cfg = FinanceCashierConfigLib::parseFromConfigValue($row?->config_value ?? null);
|
||||
$banks = is_array($cfg['withdraw_banks'] ?? null) ? $cfg['withdraw_banks'] : [];
|
||||
$bankCodeNorm = strtolower(trim($bankCode));
|
||||
$ddpayBankName = '';
|
||||
foreach ($banks as $b) {
|
||||
if (!is_array($b)) {
|
||||
continue;
|
||||
}
|
||||
$c = isset($b['code']) && is_string($b['code']) ? strtolower(trim($b['code'])) : '';
|
||||
if ($c === '' || $c !== $bankCodeNorm) {
|
||||
continue;
|
||||
}
|
||||
$nameEn = isset($b['name_en']) && is_string($b['name_en']) ? trim($b['name_en']) : '';
|
||||
$nameZh = isset($b['name_zh']) && is_string($b['name_zh']) ? trim($b['name_zh']) : '';
|
||||
$ddpayBankName = $nameEn !== '' ? $nameEn : $nameZh;
|
||||
break;
|
||||
}
|
||||
if ($ddpayBankName === '') {
|
||||
return $this->mobileError(1001, 'Bank code not configured for withdrawal');
|
||||
}
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
$userId = intval(strval($user->id));
|
||||
|
||||
@@ -581,11 +1412,17 @@ class Finance extends MobileBase
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'user_id' => $userId,
|
||||
'channel_id' => $channelId,
|
||||
'pay_channel' => $channelCode,
|
||||
'amount' => $withdrawCoin,
|
||||
'fee' => $feeCoin,
|
||||
'actual_amount' => $actualArrivalCoin,
|
||||
'receive_type' => $receiveType,
|
||||
'receive_account' => $receiveAccount,
|
||||
'receiver_email' => $receiverEmail,
|
||||
'receiver_mobile' => $receiverMobile,
|
||||
'ddpay_receiver_name' => $receiverName,
|
||||
'ddpay_bank_name' => $ddpayBankName,
|
||||
'ddpay_bank_branch' => $bankBranch,
|
||||
'status' => 0,
|
||||
'review_admin_id' => null,
|
||||
'review_time' => null,
|
||||
@@ -652,6 +1489,9 @@ class Finance extends MobileBase
|
||||
'actual_arrival_coin' => $this->amountNumber($order->actual_amount ?? '0'),
|
||||
'receive_type' => is_string($order->receive_type ?? null) ? $order->receive_type : strval($order->receive_type ?? ''),
|
||||
'receive_account' => is_string($order->receive_account ?? null) ? $order->receive_account : strval($order->receive_account ?? ''),
|
||||
'pay_channel' => is_string($order->pay_channel ?? null) ? $order->pay_channel : strval($order->pay_channel ?? ''),
|
||||
'receiver_email' => is_string($order->receiver_email ?? null) ? $order->receiver_email : strval($order->receiver_email ?? ''),
|
||||
'receiver_mobile' => is_string($order->receiver_mobile ?? null) ? $order->receiver_mobile : strval($order->receiver_mobile ?? ''),
|
||||
'reject_reason' => $statusCode === 2 && $remark !== '' ? $remark : null,
|
||||
'create_time' => $order->create_time,
|
||||
'review_time' => $order->review_time,
|
||||
@@ -776,30 +1616,74 @@ class Finance extends MobileBase
|
||||
];
|
||||
}
|
||||
|
||||
$banks = [];
|
||||
$withdrawBanks = [];
|
||||
if (isset($cfg['withdraw_banks']) && is_array($cfg['withdraw_banks'])) {
|
||||
$list = $cfg['withdraw_banks'];
|
||||
usort($list, function (array $a, array $b): int {
|
||||
$ca = isset($a['currency_code']) && is_string($a['currency_code']) ? $a['currency_code'] : '';
|
||||
$cb = isset($b['currency_code']) && is_string($b['currency_code']) ? $b['currency_code'] : '';
|
||||
if ($ca !== $cb) {
|
||||
return strcmp($ca, $cb);
|
||||
}
|
||||
$cmp = $this->sortBySortKeyOnly($a, $b);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
$ca = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
|
||||
$cb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
$ka = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
|
||||
$kb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
|
||||
return strcmp($ca, $cb);
|
||||
return strcmp($ka, $kb);
|
||||
});
|
||||
foreach ($list as $b) {
|
||||
if (!is_array($b)) {
|
||||
continue;
|
||||
}
|
||||
$currencyCode = isset($b['currency_code']) && is_string($b['currency_code']) ? strtoupper(trim($b['currency_code'])) : '';
|
||||
$code = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
if ($code === '') {
|
||||
if ($currencyCode === '' || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$banks[] = [
|
||||
'code' => $code,
|
||||
'name' => $isZh
|
||||
$withdrawBanks[] = [
|
||||
'currency_code' => $currencyCode,
|
||||
'code' => $code,
|
||||
'name' => $isZh
|
||||
? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '')
|
||||
: (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$depositBanks = [];
|
||||
if (isset($cfg['deposit_banks']) && is_array($cfg['deposit_banks'])) {
|
||||
$list = $cfg['deposit_banks'];
|
||||
usort($list, function (array $a, array $b): int {
|
||||
$ca = isset($a['currency_code']) && is_string($a['currency_code']) ? $a['currency_code'] : '';
|
||||
$cb = isset($b['currency_code']) && is_string($b['currency_code']) ? $b['currency_code'] : '';
|
||||
if ($ca !== $cb) {
|
||||
return strcmp($ca, $cb);
|
||||
}
|
||||
$cmp = $this->sortBySortKeyOnly($a, $b);
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
$ka = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
|
||||
$kb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
|
||||
return strcmp($ka, $kb);
|
||||
});
|
||||
foreach ($list as $b) {
|
||||
if (!is_array($b)) {
|
||||
continue;
|
||||
}
|
||||
$currencyCode = isset($b['currency_code']) && is_string($b['currency_code']) ? strtoupper(trim($b['currency_code'])) : '';
|
||||
$code = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
if ($currencyCode === '' || $code === '') {
|
||||
continue;
|
||||
}
|
||||
$depositBanks[] = [
|
||||
'currency_code' => $currencyCode,
|
||||
'code' => $code,
|
||||
'name' => $isZh
|
||||
? (is_string($b['name_zh'] ?? null) ? $b['name_zh'] : '')
|
||||
: (is_string($b['name_en'] ?? null) ? $b['name_en'] : ''),
|
||||
];
|
||||
@@ -813,14 +1697,10 @@ class Finance extends MobileBase
|
||||
$wc = $cfg['withdraw_copy'] ?? [];
|
||||
$rateMode = is_array($wc) && isset($wc['rate_mode']) && is_string($wc['rate_mode']) ? $wc['rate_mode'] : 'fixed';
|
||||
|
||||
$wf = $cfg['withdraw_fields'] ?? [];
|
||||
$reqCard = is_array($wf) && !empty($wf['require_cardholder']);
|
||||
$reqAcct = is_array($wf) && !empty($wf['require_bank_account']);
|
||||
$reqMail = is_array($wf) && !empty($wf['require_email']);
|
||||
$reqMob = is_array($wf) && !empty($wf['require_mobile']);
|
||||
$effectiveCh = DepositChannelLib::effectiveRowsFromDb();
|
||||
$withdrawPayChannels = DepositChannelLib::channelsForWithdraw($effectiveCh, $lang);
|
||||
|
||||
$payChannels = [];
|
||||
$effectiveCh = DepositChannelLib::effectiveRowsFromDb();
|
||||
$regCh = DepositChannelLib::codeRegistry();
|
||||
foreach ($effectiveCh as $row) {
|
||||
if (!is_array($row)) {
|
||||
@@ -830,6 +1710,9 @@ class Finance extends MobileBase
|
||||
if ($code === '' || !isset($regCh[$code]) || !is_array($regCh[$code])) {
|
||||
continue;
|
||||
}
|
||||
if ($code === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
$meta = $regCh[$code];
|
||||
$nameZh = isset($meta['name']) && is_string($meta['name']) ? $meta['name'] : '';
|
||||
$nameEn = isset($meta['name_en']) && is_string($meta['name_en']) ? $meta['name_en'] : '';
|
||||
@@ -862,8 +1745,12 @@ class Finance extends MobileBase
|
||||
'currencies' => $currencies,
|
||||
'rates' => $rates,
|
||||
'pay_channels' => $payChannels,
|
||||
'deposit' => [
|
||||
'banks' => $depositBanks,
|
||||
],
|
||||
'withdraw' => [
|
||||
'banks' => $banks,
|
||||
'pay_channels' => $withdrawPayChannels,
|
||||
'banks' => $withdrawBanks,
|
||||
'min_ewallet' => $minEw,
|
||||
'min_bank' => $minBk,
|
||||
'rate_hint' => $isZh
|
||||
@@ -876,11 +1763,16 @@ class Finance extends MobileBase
|
||||
? (is_array($wc) && is_string($wc['fee_note_zh'] ?? null) ? $wc['fee_note_zh'] : '')
|
||||
: (is_array($wc) && is_string($wc['fee_note_en'] ?? null) ? $wc['fee_note_en'] : ''),
|
||||
'rate_mode' => $rateMode,
|
||||
// 与 DDPay 出金及 withdrawCreate 一致,不由后台开关配置
|
||||
'fields' => [
|
||||
'require_cardholder' => $reqCard,
|
||||
'require_bank_account' => $reqAcct,
|
||||
'require_email' => $reqMail,
|
||||
'require_mobile' => $reqMob,
|
||||
'receive_type_bank_only' => true,
|
||||
'require_channel_code' => true,
|
||||
'require_receiver_name' => true,
|
||||
'require_receive_account' => true,
|
||||
'require_receiver_email' => true,
|
||||
'require_receiver_mobile' => true,
|
||||
'require_bank_code' => true,
|
||||
'require_bank_branch' => false,
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -930,6 +1822,31 @@ class Finance extends MobileBase
|
||||
return trim($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* DDPay 入金 payment_type:官方枚举 01=FPX / 02=duitnow / 03=ewallet
|
||||
*/
|
||||
private function isValidDdpayPaymentType(string $paymentType): bool
|
||||
{
|
||||
return in_array($paymentType, ['01', '02', '03'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收款人手机号:5–32 位,仅允许数字与常见分隔符(+ - 空格)
|
||||
*/
|
||||
private function isValidReceiverMobile(string $mobile): bool
|
||||
{
|
||||
$len = mb_strlen($mobile);
|
||||
if ($len < 5 || $len > 32) {
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^[0-9+\-\s]+$/', $mobile)) {
|
||||
return false;
|
||||
}
|
||||
$digits = preg_replace('/\D/', '', $mobile);
|
||||
|
||||
return is_string($digits) && strlen($digits) >= 5;
|
||||
}
|
||||
|
||||
private function loadEnabledTiers(): array
|
||||
{
|
||||
$row = GameConfig::where('config_key', DepositTierLib::CONFIG_KEY)->find();
|
||||
@@ -947,10 +1864,14 @@ class Finance extends MobileBase
|
||||
|
||||
private function mapDepositStatus($status): string
|
||||
{
|
||||
if ($this->intValue($status) === 1) {
|
||||
$code = $this->intValue($status);
|
||||
if ($code === 1) {
|
||||
return 'paid';
|
||||
}
|
||||
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
||||
if ($code === MockPay::DEPOSIT_STATUS_PENDING_REVIEW) {
|
||||
return 'pending_review';
|
||||
}
|
||||
if ($code === 2) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'pending';
|
||||
@@ -979,4 +1900,62 @@ class Finance extends MobileBase
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function intParam($raw): int
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric(strval($raw))) {
|
||||
return intval(strval($raw));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function verifyMockDepositLinkSign(string $orderNo, int $expireAt, string $sign): bool
|
||||
{
|
||||
return MockPay::verifyDepositLink($orderNo, $expireAt, $sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带签名的前端静态收银台 URL;订单不存在或非 mock 时返回空串
|
||||
*/
|
||||
private function buildMockDepositFrontendUrl(string $orderNo, string $publicOrigin): string
|
||||
{
|
||||
DepositOrderExpireService::expirePendingOrders(null, $orderNo);
|
||||
$row = Db::name('deposit_order')->where('order_no', $orderNo)->find();
|
||||
if (!is_array($row)) {
|
||||
return '';
|
||||
}
|
||||
$payChannel = is_string($row['pay_channel'] ?? null) ? strtolower(trim($row['pay_channel'])) : '';
|
||||
if ($payChannel !== MockPay::CHANNEL_CODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$createTime = is_numeric($row['create_time'] ?? null) ? intval($row['create_time']) : time();
|
||||
$amount = $this->amountString($row['amount'] ?? '0');
|
||||
$bonus = $this->amountString($row['bonus_amount'] ?? '0');
|
||||
$expireAt = 0;
|
||||
$sign = '';
|
||||
$snapRaw = $row['pay_account_snapshot'] ?? '';
|
||||
if (is_string($snapRaw) && trim($snapRaw) !== '') {
|
||||
$decoded = json_decode($snapRaw, true);
|
||||
if (is_array($decoded)) {
|
||||
if (isset($decoded['expire_at']) && is_numeric($decoded['expire_at'])) {
|
||||
$expireAt = intval($decoded['expire_at']);
|
||||
}
|
||||
if (isset($decoded['mock_pay_sign']) && is_string($decoded['mock_pay_sign'])) {
|
||||
$sign = trim($decoded['mock_pay_sign']);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($expireAt <= 0 || $sign === '' || !MockPay::verifyDepositLink($orderNo, $expireAt, $sign)) {
|
||||
$linkAuth = MockPay::buildDepositLinkAuth($orderNo, $createTime);
|
||||
$expireAt = $linkAuth['expire_at'];
|
||||
$sign = $linkAuth['sign'];
|
||||
}
|
||||
|
||||
return MockPay::depositPageUrl($orderNo, $publicOrigin, $expireAt, $sign, $amount, $bonus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\library\game\BetChips;
|
||||
use app\common\library\game\GamePeriodNo;
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use app\common\library\game\ZiHuaDictionary;
|
||||
use app\common\model\BetOrder;
|
||||
use app\common\model\GameRecord;
|
||||
@@ -12,6 +15,7 @@ use app\common\service\GameHotDataCoordinator;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use app\common\service\GameRecordService;
|
||||
use app\common\service\GameWebSocketEventBus;
|
||||
use app\common\service\GameWebSocketPayloadHelper;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
@@ -27,7 +31,7 @@ class Game extends MobileBase
|
||||
return $response;
|
||||
}
|
||||
|
||||
$periodRow = GameHotDataRedis::gameRecordLatest();
|
||||
$periodRow = $this->resolveMobilePeriodRow();
|
||||
$now = time();
|
||||
$startAt = $periodRow ? $this->intValue($periodRow['period_start_at'] ?? 0) : $now;
|
||||
$lockAt = $startAt + 20;
|
||||
@@ -47,26 +51,38 @@ class Game extends MobileBase
|
||||
}
|
||||
|
||||
$user = $this->auth->getUser();
|
||||
$currentStreakRaw = $user->current_streak ?? 0;
|
||||
$currentStreakParsed = filter_var($currentStreakRaw, FILTER_VALIDATE_INT);
|
||||
$currentStreak = $currentStreakParsed === false ? 0 : $currentStreakParsed;
|
||||
return $this->mobileSuccess([
|
||||
'server_time' => $now,
|
||||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
|
||||
'period' => [
|
||||
'period_no' => (string) ($periodRow['period_no'] ?? ''),
|
||||
'period_id' => $periodRow ? $this->intValue($periodRow['id'] ?? 0) : 0,
|
||||
'period_no' => GamePeriodNo::toDisplay(
|
||||
(string) ($periodRow['period_no'] ?? ''),
|
||||
$periodRow ? $this->intValue($periodRow['id'] ?? 0) : 0
|
||||
),
|
||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||
'countdown' => $countdown,
|
||||
'lock_at' => $lockAt,
|
||||
'open_at' => $openAt,
|
||||
],
|
||||
'bet_config' => [
|
||||
'pick_max_number_count' => $this->getPickMaxNumberCount(),
|
||||
'chips' => ['1.00', '5.00', '10.00', '25.00', '50.00', '100.00'],
|
||||
'single_number_max_bet' => $this->getConfigValue('single_number_max_bet', '500.00'),
|
||||
],
|
||||
'bet_config' => array_merge(
|
||||
[
|
||||
'pick_max_number_count' => $this->getPickMaxNumberCount(),
|
||||
'min_bet_per_number' => $this->getConfigValue('min_bet_per_number', '0.0100'),
|
||||
'max_bet_per_number' => $this->getConfigValue('max_bet_per_number', '10000.0000'),
|
||||
],
|
||||
BetChips::lobbyChipsPayload()
|
||||
),
|
||||
'dictionary' => $items,
|
||||
'user_snapshot' => [
|
||||
'coin' => $user->coin,
|
||||
'current_streak' => $user->current_streak ?? 0,
|
||||
],
|
||||
'user_snapshot' => array_merge(
|
||||
[
|
||||
'coin' => $user->coin,
|
||||
],
|
||||
StreakWinReward::playerBetOddsForCurrentStreak($currentStreak)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -93,6 +109,9 @@ class Game extends MobileBase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近开奖记录(默认最新 30 条),期号为纯数字。
|
||||
*/
|
||||
public function periodHistory(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
@@ -103,15 +122,20 @@ class Game extends MobileBase
|
||||
if ($limit < 1) {
|
||||
$limit = 30;
|
||||
}
|
||||
if ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$list = GameRecord::whereNotNull('result_number')->order('id', 'desc')->limit($limit)->select();
|
||||
$rows = [];
|
||||
foreach ($list as $item) {
|
||||
$periodId = $this->intValue($item->id ?? 0);
|
||||
$rows[] = [
|
||||
'period_no' => $item->period_no,
|
||||
'period_no' => GamePeriodNo::toDisplay((string) ($item->period_no ?? ''), $periodId),
|
||||
'result_number' => $item->result_number,
|
||||
'open_time' => $item->update_time,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->mobileSuccess(['list' => $rows]);
|
||||
}
|
||||
|
||||
@@ -121,16 +145,19 @@ class Game extends MobileBase
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$periodRow = GameHotDataRedis::gameRecordLatest();
|
||||
$periodRow = $this->resolveMobilePeriodRow();
|
||||
if (!$periodRow) {
|
||||
return $this->mobileError(2002, 'Game period does not exist');
|
||||
}
|
||||
$now = time();
|
||||
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
|
||||
return $this->mobileSuccess([
|
||||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||
'period_id' => $periodRow['id'],
|
||||
'period_no' => $periodRow['period_no'],
|
||||
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
|
||||
'period_id' => $this->intValue($periodRow['id'] ?? 0),
|
||||
'period_no' => GamePeriodNo::toDisplay(
|
||||
(string) ($periodRow['period_no'] ?? ''),
|
||||
$this->intValue($periodRow['id'] ?? 0)
|
||||
),
|
||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||
'countdown' => max(0, ($startAt + 30) - $now),
|
||||
'bet_close_in' => max(0, ($startAt + 20) - $now),
|
||||
@@ -138,17 +165,9 @@ class Game extends MobileBase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与前端文档对齐:/api/game/current_status
|
||||
*/
|
||||
public function currentStatus(Request $request): Response
|
||||
{
|
||||
return $this->periodCurrent($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧路由:/api/game/betPlace
|
||||
* 新语义与 place_bet 一致:bet_amount 作为“单注金额”。
|
||||
* 与 placeBet 一致:须传筹码标识 bet_id(1–6),单注金额取自后台 game_config 的 bet_chips。
|
||||
*/
|
||||
public function betPlace(Request $request): Response
|
||||
{
|
||||
@@ -156,8 +175,7 @@ class Game extends MobileBase
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交下注:入参为 period_no + numbers + single_bet_amount + idempotency_key。
|
||||
* 兼容前端传参 bet_amount(作为 single_bet_amount 同义字段)。
|
||||
* 提交下注:入参为 period_no + numbers + bet_id(1–6,对应 lobbyInit.bet_config.chips 的键)+ idempotency_key。
|
||||
*/
|
||||
public function placeBet(Request $request): Response
|
||||
{
|
||||
@@ -167,9 +185,14 @@ class Game extends MobileBase
|
||||
}
|
||||
$periodNo = trim((string) $request->post('period_no', ''));
|
||||
$numbersRaw = $request->post('numbers', '');
|
||||
$singleBetAmount = trim((string) ($request->post('single_bet_amount', $request->post('bet_amount', ''))));
|
||||
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
|
||||
if ($periodNo === '' || $singleBetAmount === '' || $idempotencyKey === '') {
|
||||
$chipPick = $this->resolveBetChipFromRequest($request);
|
||||
if (isset($chipPick['error'])) {
|
||||
return $chipPick['error'];
|
||||
}
|
||||
$betChipId = $chipPick['bet_id'];
|
||||
$singleBetAmount = $chipPick['amount'];
|
||||
if ($periodNo === '' || $idempotencyKey === '') {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (!is_numeric($singleBetAmount) || bccomp($singleBetAmount, '0', 2) <= 0) {
|
||||
@@ -184,19 +207,44 @@ class Game extends MobileBase
|
||||
return $this->mobileError(1003, 'Invalid parameter value');
|
||||
}
|
||||
$singleAmount = bcadd($singleBetAmount, '0', 2);
|
||||
$minPer = trim((string) $this->getConfigValue('min_bet_per_number', '0.0100'));
|
||||
$maxPer = trim((string) $this->getConfigValue('max_bet_per_number', '10000.0000'));
|
||||
if (!is_numeric($minPer) || bccomp($minPer, '0', 4) <= 0) {
|
||||
$minPer = '0.0100';
|
||||
}
|
||||
if (!is_numeric($maxPer) || bccomp($maxPer, $minPer, 4) < 0) {
|
||||
$maxPer = '10000.0000';
|
||||
}
|
||||
if (bccomp($singleAmount, $minPer, 4) < 0 || bccomp($singleAmount, $maxPer, 4) > 0) {
|
||||
return $this->mobileError(1003, 'Bet amount out of range');
|
||||
}
|
||||
$numberCount = (string) count($numbers);
|
||||
$totalAmount = bcmul($singleAmount, $numberCount, 2);
|
||||
|
||||
if (!GameRecordService::isLiveRuntimeEnabled()) {
|
||||
if (!GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE)) {
|
||||
return $this->mobileError(3001, 'Game is paused');
|
||||
}
|
||||
$period = GameRecord::where('period_no', $periodNo)->find();
|
||||
if (!$period && ctype_digit($periodNo)) {
|
||||
$periodIdLookup = filter_var($periodNo, FILTER_VALIDATE_INT);
|
||||
if ($periodIdLookup !== false && $periodIdLookup > 0) {
|
||||
$period = GameRecord::find($periodIdLookup);
|
||||
}
|
||||
}
|
||||
if (!$period) {
|
||||
return $this->mobileError(2002, 'Game period does not exist');
|
||||
}
|
||||
if ($this->intValue($period->status) !== 0) {
|
||||
return $this->mobileError(3002, 'Betting is closed');
|
||||
}
|
||||
$activeRow = GameHotDataRedis::gameRecordActive();
|
||||
if ($activeRow !== null) {
|
||||
$activeNo = trim((string) ($activeRow['period_no'] ?? ''));
|
||||
$activeId = $this->intValue($activeRow['id'] ?? 0);
|
||||
if ($activeNo !== '' && ($periodNo !== $activeNo || $this->intValue($period->id) !== $activeId)) {
|
||||
return $this->mobileError(3004, 'Not the current period; please refresh period_no');
|
||||
}
|
||||
}
|
||||
|
||||
$userIdRaw = $this->auth->id ?? null;
|
||||
$userId = filter_var($userIdRaw, FILTER_VALIDATE_INT);
|
||||
@@ -216,7 +264,7 @@ class Game extends MobileBase
|
||||
|
||||
$lock = GameHotDataRedis::userAdminMutationLockTry($userId);
|
||||
if (!$lock['acquired']) {
|
||||
return $this->mobileError(5000, '该用户正在被其他管理员操作(钱包/并发保存),请稍后再试');
|
||||
return $this->mobileError(5000, (string) __('This user is being operated by another admin (wallet/concurrent save); please try again later'));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -253,7 +301,7 @@ class Game extends MobileBase
|
||||
]);
|
||||
if ($affected !== 1) {
|
||||
Db::rollback();
|
||||
return $this->mobileError(5000, '扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试');
|
||||
return $this->mobileError(5000, (string) __('Debit failed: user balance changed by another request; please refresh and retry'));
|
||||
}
|
||||
|
||||
UserWalletRecord::create([
|
||||
@@ -289,26 +337,32 @@ class Game extends MobileBase
|
||||
}
|
||||
|
||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||
GameWebSocketEventBus::publish('bet.accepted', [
|
||||
GameWebSocketEventBus::publish('bet.accepted', GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||
'user_id' => $userId,
|
||||
'period_no' => $period->period_no,
|
||||
'numbers' => $numbers,
|
||||
'bet_id' => $betChipId,
|
||||
'single_bet_amount' => $singleAmount,
|
||||
'numbers_count' => count($numbers),
|
||||
'total_amount' => $totalAmount,
|
||||
'balance_after' => $after,
|
||||
'accepted_at' => time(),
|
||||
]);
|
||||
GameWebSocketEventBus::publish('wallet.changed', [
|
||||
], $userId, $streakAtBet));
|
||||
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||
'user_id' => $userId,
|
||||
'balance_after' => $after,
|
||||
'biz_type' => 'bet',
|
||||
'changed_at' => time(),
|
||||
]);
|
||||
], $userId, $streakAtBet));
|
||||
$periodIdForDisplay = filter_var($period->id ?? null, FILTER_VALIDATE_INT);
|
||||
if ($periodIdForDisplay === false) {
|
||||
$periodIdForDisplay = 0;
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'order_no' => $orderNo,
|
||||
'period_no' => $period->period_no,
|
||||
'period_no' => GamePeriodNo::toDisplay((string) ($period->period_no ?? ''), $periodIdForDisplay),
|
||||
'status' => 'accepted',
|
||||
'bet_id' => $betChipId,
|
||||
'single_bet_amount' => $singleAmount,
|
||||
'numbers_count' => count($numbers),
|
||||
'locked_balance' => '0.00',
|
||||
@@ -339,9 +393,13 @@ class Game extends MobileBase
|
||||
|
||||
$periodNo = trim((string) $request->post('period_no', ''));
|
||||
$numbersRaw = $request->post('numbers', '');
|
||||
$singleBetAmount = trim((string) ($request->post('single_bet_amount', $request->post('bet_amount', ''))));
|
||||
$rounds = $this->intValue($request->post('rounds', 1));
|
||||
if ($periodNo === '' || $singleBetAmount === '' || $rounds < 1) {
|
||||
$chipPick = $this->resolveBetChipFromRequest($request);
|
||||
if (isset($chipPick['error'])) {
|
||||
return $chipPick['error'];
|
||||
}
|
||||
$singleBetAmount = $chipPick['amount'];
|
||||
if ($periodNo === '' || $rounds < 1) {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
if (!is_numeric($singleBetAmount) || bccomp($singleBetAmount, '0', 2) <= 0) {
|
||||
@@ -358,6 +416,7 @@ class Game extends MobileBase
|
||||
GameWebSocketEventBus::publish('auto.spin.progress', [
|
||||
'user_id' => $userIdValue,
|
||||
'period_no' => $periodNo,
|
||||
'bet_id' => $chipPick['bet_id'],
|
||||
'rounds' => $rounds,
|
||||
'remaining_rounds' => $rounds,
|
||||
'completed_rounds' => 0,
|
||||
@@ -369,6 +428,7 @@ class Game extends MobileBase
|
||||
'auto_mode' => true,
|
||||
'period_no' => $periodNo,
|
||||
'numbers' => $numbers,
|
||||
'bet_id' => $chipPick['bet_id'],
|
||||
'single_bet_amount' => bcadd($singleBetAmount, '0', 2),
|
||||
'rounds' => $rounds,
|
||||
'remaining_rounds' => $rounds,
|
||||
@@ -388,16 +448,48 @@ class Game extends MobileBase
|
||||
'list_rows' => $pageSize,
|
||||
]);
|
||||
|
||||
$periodIds = [];
|
||||
foreach ($paginate->items() as $item) {
|
||||
$periodIdValue = filter_var($item->period_id ?? null, FILTER_VALIDATE_INT);
|
||||
if ($periodIdValue !== false && $periodIdValue > 0) {
|
||||
$periodIds[] = $periodIdValue;
|
||||
}
|
||||
}
|
||||
$periodIds = array_values(array_unique($periodIds));
|
||||
$resultNumberByPeriodId = [];
|
||||
if ($periodIds !== []) {
|
||||
$periodRows = Db::name('game_record')
|
||||
->whereIn('id', $periodIds)
|
||||
->field(['id', 'result_number'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($periodRows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$pid = filter_var($row['id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($pid === false || $pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rn = filter_var($row['result_number'] ?? null, FILTER_VALIDATE_INT);
|
||||
$resultNumberByPeriodId[$pid] = ($rn === false ? null : $rn);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($paginate->items() as $item) {
|
||||
$periodIdValue = filter_var($item->period_id ?? null, FILTER_VALIDATE_INT);
|
||||
if ($periodIdValue === false) {
|
||||
$periodIdValue = 0;
|
||||
}
|
||||
$rows[] = [
|
||||
'order_no' => (string) $item->id,
|
||||
'period_no' => $item->period_no,
|
||||
'period_no' => GamePeriodNo::toDisplay((string) ($item->period_no ?? ''), $periodIdValue),
|
||||
'numbers' => $item->pick_numbers ?? [],
|
||||
// 整笔压注金额(与请求 bet_amount 语义一致)
|
||||
// 整笔压注金额(本笔总扣款)
|
||||
'bet_amount' => $item->total_amount,
|
||||
'total_amount' => $item->total_amount,
|
||||
'result_number' => null,
|
||||
'result_number' => $periodIdValue > 0 ? ($resultNumberByPeriodId[$periodIdValue] ?? null) : null,
|
||||
'win_amount' => $item->win_amount,
|
||||
'status' => (string) $item->status,
|
||||
'create_time' => $item->create_time,
|
||||
@@ -474,6 +566,28 @@ class Game extends MobileBase
|
||||
return 'finished';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{bet_id: int, amount: string}|array{error: Response}
|
||||
*/
|
||||
private function resolveBetChipFromRequest(Request $request): array
|
||||
{
|
||||
$betIdRaw = $request->post('bet_id', '');
|
||||
if ($betIdRaw === '' || $betIdRaw === null) {
|
||||
return ['error' => $this->mobileError(1001, 'Missing parameters')];
|
||||
}
|
||||
$betId = filter_var($betIdRaw, FILTER_VALIDATE_INT);
|
||||
if ($betId === false || $betId < 1 || $betId > 6) {
|
||||
return ['error' => $this->mobileError(1003, 'Invalid parameter value')];
|
||||
}
|
||||
$resolved = BetChips::resolveFromHotData();
|
||||
$amount = BetChips::amountForBetId($betId, $resolved['map']);
|
||||
if ($amount === null) {
|
||||
return ['error' => $this->mobileError(1003, 'Invalid parameter value')];
|
||||
}
|
||||
|
||||
return ['bet_id' => $betId, 'amount' => $amount];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单注最多可选号码个数:`game_config.config_key = pick_max_number_count`
|
||||
*/
|
||||
@@ -503,6 +617,16 @@ class Game extends MobileBase
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端展示的当前期:优先进行中局(id 最大且 status∈0..3),避免 gameRecordLatest 指向已结束新局而客户端仍用旧 period_no 下注。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function resolveMobilePeriodRow(): ?array
|
||||
{
|
||||
return GameHotDataRedis::gameRecordActive() ?? GameHotDataRedis::gameRecordLatest();
|
||||
}
|
||||
|
||||
private function intValue($value): int
|
||||
{
|
||||
$result = filter_var($value, FILTER_VALIDATE_INT);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Frontend;
|
||||
use app\common\facade\Token;
|
||||
use app\common\service\MobileAuthDeviceService;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
use function response;
|
||||
@@ -28,6 +29,7 @@ abstract class MobileBase extends Frontend
|
||||
$parts = explode('/', $path);
|
||||
$action = $parts[array_key_last($parts)] ?? '';
|
||||
$needAuthToken = !action_in_arr($this->noNeedAuthToken, $action);
|
||||
$authToken = '';
|
||||
if ($needAuthToken) {
|
||||
$authToken = trim((string) $request->header('auth-token', ''));
|
||||
if ($authToken === '') {
|
||||
@@ -41,7 +43,20 @@ abstract class MobileBase extends Frontend
|
||||
}
|
||||
}
|
||||
|
||||
return $this->initializeFrontend($request);
|
||||
$response = $this->initializeFrontend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$needLogin = !action_in_arr($this->noNeedLogin, $action);
|
||||
if ($needAuthToken && $needLogin && $this->auth->isLogin()) {
|
||||
$deviceError = MobileAuthDeviceService::validateUserDeviceSession($authToken, (int) $this->auth->id);
|
||||
if ($deviceError !== null) {
|
||||
return $this->mobileError(1101, $deviceError);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function mobileSuccess(array $data = [], string $message = 'ok'): Response
|
||||
|
||||
@@ -11,6 +11,11 @@ use support\Response;
|
||||
|
||||
class Notice extends MobileBase
|
||||
{
|
||||
/** 公告列表/详情:公开接口,无需 auth-token / user-token */
|
||||
protected array $noNeedLogin = ['noticeList', 'noticeDetail'];
|
||||
|
||||
protected array $noNeedAuthToken = ['noticeList', 'noticeDetail'];
|
||||
|
||||
public function noticeList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
@@ -25,25 +30,22 @@ class Notice extends MobileBase
|
||||
'list_rows' => $pageSize,
|
||||
]);
|
||||
|
||||
$noticeIds = [];
|
||||
$popoutNoticeIds = [];
|
||||
foreach ($paginate->items() as $row) {
|
||||
$noticeIds[] = $row->id;
|
||||
if ($this->intValue($row->notice_type, 0) === 1) {
|
||||
$popoutNoticeIds[] = $row->id;
|
||||
}
|
||||
}
|
||||
$readRows = [];
|
||||
if ($noticeIds !== []) {
|
||||
$readRows = UserNoticeRead::where('user_id', $this->auth->id)->whereIn('notice_id', $noticeIds)->column('notice_id');
|
||||
$readMap = [];
|
||||
$userId = ($this->auth && $this->auth->isLogin()) ? $this->auth->id : 0;
|
||||
if ($userId > 0 && $popoutNoticeIds !== []) {
|
||||
$readRows = UserNoticeRead::where('user_id', $userId)->whereIn('notice_id', $popoutNoticeIds)->column('notice_id');
|
||||
$readMap = array_flip($readRows);
|
||||
}
|
||||
$readMap = array_flip($readRows);
|
||||
|
||||
$list = [];
|
||||
foreach ($paginate->items() as $row) {
|
||||
$list[] = [
|
||||
'notice_id' => $row->id,
|
||||
'title' => $row->title,
|
||||
'notice_type' => $this->intValue($row->notice_type, 0) === 1 ? 'popout' : 'silent',
|
||||
'is_read' => isset($readMap[$row->id]),
|
||||
'publish_time' => $row->publish_at,
|
||||
];
|
||||
$list[] = $this->formatNoticeItem($row, $readMap);
|
||||
}
|
||||
|
||||
return $this->mobileSuccess(['list' => $list]);
|
||||
@@ -55,22 +57,24 @@ class Notice extends MobileBase
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$id = $this->intValue($request->input('notice_id', 0), 0);
|
||||
if ($id < 1) {
|
||||
$noticeId = $this->resolveNoticeId($request);
|
||||
if ($noticeId < 1) {
|
||||
return $this->mobileError(1001, 'Missing parameters');
|
||||
}
|
||||
$notice = OperationNotice::where('id', $id)->where('status', 1)->find();
|
||||
$notice = OperationNotice::where('id', $noticeId)->where('status', 1)->find();
|
||||
if (!$notice) {
|
||||
return $this->mobileError(2004, 'Notice does not exist');
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'notice_id' => $notice->id,
|
||||
'title' => $notice->title,
|
||||
'content' => $notice->content,
|
||||
'notice_type' => $this->intValue($notice->notice_type, 0) === 1 ? 'popout' : 'silent',
|
||||
'must_confirm' => $this->intValue($notice->notice_type, 0) === 1,
|
||||
'publish_time' => $notice->publish_at,
|
||||
]);
|
||||
|
||||
$readMap = [];
|
||||
$userId = ($this->auth && $this->auth->isLogin()) ? $this->auth->id : 0;
|
||||
$isPopout = $this->intValue($notice->notice_type, 0) === 1;
|
||||
if ($userId > 0 && $isPopout) {
|
||||
$readRow = UserNoticeRead::where('user_id', $userId)->where('notice_id', $noticeId)->column('notice_id');
|
||||
$readMap = array_flip($readRow);
|
||||
}
|
||||
|
||||
return $this->mobileSuccess($this->formatNoticeItem($notice, $readMap));
|
||||
}
|
||||
|
||||
public function noticeConfirm(Request $request): Response
|
||||
@@ -87,14 +91,18 @@ class Notice extends MobileBase
|
||||
if (!$notice) {
|
||||
return $this->mobileError(2004, 'Notice does not exist');
|
||||
}
|
||||
if ($this->intValue($notice->notice_type, 0) !== 1) {
|
||||
return $this->mobileError(2004, 'Notice does not require confirmation');
|
||||
}
|
||||
|
||||
$exists = UserNoticeRead::where('user_id', $this->auth->id)->where('notice_id', $noticeId)->find();
|
||||
$readRow = UserNoticeRead::where('user_id', $this->auth->id)->where('notice_id', $noticeId)->find();
|
||||
$now = time();
|
||||
if ($exists) {
|
||||
$exists->save([
|
||||
'confirmed' => 1,
|
||||
'read_at' => $now,
|
||||
]);
|
||||
if ($readRow) {
|
||||
$readRow->read_at = $now;
|
||||
if ($this->intValue($readRow->confirmed, 0) !== 1) {
|
||||
$readRow->confirmed = 1;
|
||||
}
|
||||
$readRow->save();
|
||||
} else {
|
||||
UserNoticeRead::create([
|
||||
'user_id' => $this->auth->id,
|
||||
@@ -119,5 +127,36 @@ class Notice extends MobileBase
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 notice_id 与部分客户端使用的 n 参数。
|
||||
*/
|
||||
private function resolveNoticeId(Request $request): int
|
||||
{
|
||||
$noticeId = $this->intValue($request->input('notice_id', 0), 0);
|
||||
if ($noticeId > 0) {
|
||||
return $noticeId;
|
||||
}
|
||||
|
||||
return $this->intValue($request->input('n', 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $readMap notice_id => index(来自 array_flip)
|
||||
*/
|
||||
private function formatNoticeItem(OperationNotice $row, array $readMap = []): array
|
||||
{
|
||||
$isPopout = $this->intValue($row->notice_type, 0) === 1;
|
||||
|
||||
return [
|
||||
'notice_id' => $row->id,
|
||||
'title' => $row->title,
|
||||
'content' => $row->content,
|
||||
'notice_type' => $isPopout ? 'popout' : 'silent',
|
||||
'must_confirm' => $isPopout,
|
||||
'is_read' => $isPopout && isset($readMap[$row->id]),
|
||||
'publish_time' => $row->publish_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\library\MalaysiaMobilePhone;
|
||||
use ba\Captcha;
|
||||
use ba\ClickCaptcha;
|
||||
use app\common\controller\Frontend;
|
||||
@@ -138,7 +139,7 @@ class User extends Frontend
|
||||
'password' => 'required|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/',
|
||||
'registerType' => 'required|in:email,mobile',
|
||||
'email' => 'required_if:registerType,email|email|unique:user,email',
|
||||
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
|
||||
'mobile' => 'required_if:registerType,mobile|regex:' . MalaysiaMobilePhone::PATTERN . '|unique:user,mobile',
|
||||
'captcha' => 'required|string',
|
||||
'invite_code' => 'nullable|string|max:64',
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use app\common\facade\Token;
|
||||
use app\common\service\MobileAuthDeviceService;
|
||||
use ba\Random;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
@@ -64,6 +65,7 @@ class V1 extends Api
|
||||
$token = Random::uuid();
|
||||
$expire = 60 * 60 * 24;
|
||||
Token::set($token, 'auth-token', 0, $expire);
|
||||
MobileAuthDeviceService::bindAuthTokenDevice($token, $deviceId, $expire);
|
||||
|
||||
return $this->mobileResult(1, 'ok', [
|
||||
'auth_token' => $token,
|
||||
|
||||
@@ -4,47 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\library\finance\WithdrawFlow;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
class Wallet extends MobileBase
|
||||
{
|
||||
public function balanceSummary(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$user = $this->auth->getUser();
|
||||
$coinBalance = WithdrawFlow::amountString($user->coin ?? '0');
|
||||
$flow = WithdrawFlow::status(intval($user->id), [
|
||||
'total_deposit_coin' => $user->total_deposit_coin ?? '0',
|
||||
'total_withdraw_coin' => $user->total_withdraw_coin ?? '0',
|
||||
'bet_flow_coin' => $user->bet_flow_coin ?? '0',
|
||||
]);
|
||||
$maxWithdrawable = WithdrawFlow::maxWithdrawable($coinBalance, $flow);
|
||||
return $this->mobileSuccess([
|
||||
'coin_balance' => floatval($coinBalance),
|
||||
'frozen_balance' => 0.00,
|
||||
'withdrawable_balance' => floatval($coinBalance),
|
||||
'max_withdrawable' => floatval($maxWithdrawable),
|
||||
'total_deposit_coin' => floatval(WithdrawFlow::amountString($user->total_deposit_coin ?? '0')),
|
||||
'total_withdraw_coin' => floatval(WithdrawFlow::amountString($user->total_withdraw_coin ?? '0')),
|
||||
'bet_flow_coin' => floatval($flow['bet_flow_coin']),
|
||||
'withdraw_flow' => [
|
||||
'ratio' => floatval($flow['ratio']),
|
||||
'net_deposit' => floatval($flow['net_deposit']),
|
||||
'required_bet_flow' => floatval($flow['required_bet_flow']),
|
||||
'remaining_bet_flow' => floatval($flow['remaining_bet_flow']),
|
||||
'eligible' => $flow['eligible'],
|
||||
'max_withdraw_by_flow' => $flow['flow_unlimited'] ? null : floatval($flow['max_withdraw_by_flow']),
|
||||
'flow_unlimited' => $flow['flow_unlimited'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeMobile($request);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
return [
|
||||
'Login expired, please login again.' => 'Login expired, please login again.',
|
||||
'Logged in on another device, please login again.' => 'Your account was logged in on another device. Please sign in again.',
|
||||
'Account not exist' => 'Account does not exist',
|
||||
'Account disabled' => 'Account is disabled',
|
||||
'Token login failed' => 'Token login failed',
|
||||
@@ -20,17 +21,22 @@ return [
|
||||
'auth-token is invalid or expired' => 'auth-token is invalid or expired',
|
||||
'Invalid secret' => 'Invalid secret',
|
||||
'Invalid signature' => 'Invalid signature',
|
||||
'Invalid payment link signature' => 'Invalid payment link signature',
|
||||
'Missing payment link signature' => 'Missing payment link signature',
|
||||
'Invalid timestamp' => 'Invalid timestamp',
|
||||
'Invite code does not exist' => 'Invite code does not exist',
|
||||
'Register only supports phone' => 'Register only supports phone',
|
||||
'Invite code required' => 'Invite code is required',
|
||||
'Invite code not bound to channel' => 'This invite code is not bound to a valid channel',
|
||||
'Channel disabled' => 'Channel is disabled',
|
||||
'Account already registered' => 'This phone number is already registered. Please sign in.',
|
||||
'Please enter the correct mobile number' => 'Please enter the correct mobile number',
|
||||
'Please enter the correct mobile number' => 'Please enter a valid Malaysia mobile number starting with 60',
|
||||
'Registered successfully but login failed' => 'Registered successfully but login failed',
|
||||
'Incorrect account or password' => 'Incorrect account or password',
|
||||
'Login status has expired' => 'Login status has expired',
|
||||
'Game period does not exist' => 'Game period does not exist',
|
||||
'Game is paused' => 'Game is paused',
|
||||
'Bet amount out of range' => 'Bet amount out of range',
|
||||
'Betting is closed' => 'Betting is closed',
|
||||
'Insufficient balance' => 'Insufficient balance',
|
||||
'Duplicate request' => 'Duplicate request',
|
||||
@@ -38,15 +44,33 @@ return [
|
||||
'Current process does not allow this operation' => 'Current process does not allow this operation',
|
||||
'Order does not exist' => 'Order does not exist',
|
||||
'Notice does not exist' => 'Notice does not exist',
|
||||
'Notice does not require confirmation' => 'This notice does not require confirmation',
|
||||
// Deposit / Withdraw
|
||||
'Idempotency key is too long' => 'Idempotency key is too long',
|
||||
'Idempotency key conflict' => 'Idempotency key conflict, please do not submit repeatedly',
|
||||
'Deposit tier not available' => 'The selected deposit tier is not available',
|
||||
'Order not found after settle' => 'Order not found after settlement',
|
||||
'Invalid withdraw amount' => 'Invalid withdraw amount',
|
||||
'Invalid receiver email' => 'Invalid receiver email',
|
||||
'Invalid receiver mobile' => 'Invalid receiver mobile',
|
||||
'Withdraw exceeds available bet flow' => 'The withdraw amount exceeds the available bet-flow quota',
|
||||
'Too many pending deposit orders' => 'You already have multiple pending deposit orders, please complete payment first or wait for timeout',
|
||||
'Too many pending withdraw orders' => 'You already have withdraw orders under review, please wait for them to be processed',
|
||||
'Missing DDPay parameters' => 'Missing DDPay parameters',
|
||||
'Invalid DDPay payment_type' => 'Invalid DDPay payment_type; use 01 (FPX), 02 (duitnow), or 03 (ewallet)',
|
||||
'DDPay payout integration supports receive_type=bank only' => 'DDPay payout integration currently supports receive_type=bank only',
|
||||
'Missing DDPay bank payout parameters' => 'Missing DDPay bank payout parameters',
|
||||
'Bank code not configured for withdrawal' => 'The bank code is not configured for withdrawal',
|
||||
'Pay channel not available' => 'The payment channel is not available',
|
||||
'Pay channel not available for this currency' => 'The payment channel is not available for this currency',
|
||||
'DDPay deposit initiation failed' => 'DDPay deposit initiation failed',
|
||||
'Deposit only supports DDPay' => 'Only DDPay deposits are supported (channel_code must be ddpay)',
|
||||
'Pay channel not supported' => 'Payment channel is not supported',
|
||||
'Mock pay channel is disabled' => 'Mock pay channel is disabled',
|
||||
'DDPay is not configured' => 'DDPay is not configured; use channel_code=mock or set DDPAY_* in .env',
|
||||
'Order is not a mock pay deposit' => 'This order is not a mock-pay deposit',
|
||||
'Order cannot be paid' => 'This order cannot be paid in its current status',
|
||||
'Withdraw only supports DDPay' => 'Only DDPay withdrawals are supported (channel_code must be ddpay)',
|
||||
// Member center account
|
||||
'Data updated successfully~' => 'Data updated successfully~',
|
||||
'Password has been changed~' => 'Password has been changed~',
|
||||
|
||||
@@ -31,6 +31,7 @@ return [
|
||||
'Mobile' => '手机号',
|
||||
'Password' => '密码',
|
||||
'Login expired, please login again.' => '登录过期,请重新登录。',
|
||||
'Logged in on another device, please login again.' => '您的账号已在其他设备登录,请重新登录',
|
||||
'Account not exist' => '帐户不存在',
|
||||
'Account disabled' => '帐户已禁用',
|
||||
'Token login failed' => '令牌登录失败',
|
||||
@@ -52,17 +53,22 @@ return [
|
||||
'auth-token is invalid or expired' => 'auth-token 无效或已过期',
|
||||
'Invalid secret' => '密钥无效',
|
||||
'Invalid signature' => '签名错误',
|
||||
'Invalid payment link signature' => '支付链接签名校验失败',
|
||||
'Missing payment link signature' => '缺少支付链接签名',
|
||||
'Invalid timestamp' => '时间戳无效',
|
||||
'Invite code does not exist' => '邀请码不存在',
|
||||
'Register only supports phone' => '注册仅支持手机号',
|
||||
'Invite code required' => '请填写邀请码',
|
||||
'Invite code not bound to channel' => '该邀请码未绑定有效渠道',
|
||||
'Channel disabled' => '渠道已关闭',
|
||||
'Account already registered' => '该手机号已注册,请直接登录',
|
||||
'Please enter the correct mobile number' => '请输入正确的手机号',
|
||||
'Please enter the correct mobile number' => '请输入正确的马来西亚手机号(60开头)',
|
||||
'Registered successfully but login failed' => '注册成功但登录失败',
|
||||
'Incorrect account or password' => '账号或密码错误',
|
||||
'Login status has expired' => '登录状态已过期',
|
||||
'Game period does not exist' => '对局不存在',
|
||||
'Game is paused' => '游戏已暂停(维护或已关闭运行开关)',
|
||||
'Bet amount out of range' => '单注金额超出允许范围',
|
||||
'Betting is closed' => '已封盘,禁止下注',
|
||||
'Insufficient balance' => '余额不足',
|
||||
'Duplicate request' => '重复请求(幂等冲突)',
|
||||
@@ -70,15 +76,33 @@ return [
|
||||
'Current process does not allow this operation' => '当前流程不允许该操作',
|
||||
'Order does not exist' => '订单不存在',
|
||||
'Notice does not exist' => '公告不存在',
|
||||
'Notice does not require confirmation' => '该公告无需确认已读',
|
||||
// 充值 / 提现
|
||||
'Idempotency key is too long' => '幂等键过长',
|
||||
'Idempotency key conflict' => '幂等键冲突(请勿重复提交)',
|
||||
'Deposit tier not available' => '所选充值档位不可用',
|
||||
'Order not found after settle' => '充值成功后未找到订单',
|
||||
'Invalid withdraw amount' => '提现金额不合法',
|
||||
'Invalid receiver email' => '收款人邮箱格式不正确',
|
||||
'Invalid receiver mobile' => '收款人手机号格式不正确',
|
||||
'Withdraw exceeds available bet flow' => '提现金额超出可提现额度',
|
||||
'Too many pending deposit orders' => '存在多笔待支付充值订单,请先完成支付或等待超时',
|
||||
'Too many pending withdraw orders' => '用户当前存在多笔提现订单,请等待审核',
|
||||
'Missing DDPay parameters' => '缺少 DDPay 支付参数',
|
||||
'Invalid DDPay payment_type' => 'DDPay 支付方式无效,请传 01(FPX)/02(duitnow)/03(ewallet)',
|
||||
'DDPay payout integration supports receive_type=bank only' => 'DDPay 出金当前仅支持 bank 收款类型',
|
||||
'Missing DDPay bank payout parameters' => '缺少 DDPay 银行出金参数',
|
||||
'Bank code not configured for withdrawal' => '提现银行代码未在系统配置中维护',
|
||||
'Pay channel not available' => '该支付渠道不可用',
|
||||
'Pay channel not available for this currency' => '当前币种不支持该支付渠道',
|
||||
'DDPay deposit initiation failed' => 'DDPay 充值发起失败',
|
||||
'Deposit only supports DDPay' => '仅支持 DDPay 充值(channel_code 须为 ddpay)',
|
||||
'Pay channel not supported' => '不支持的支付渠道',
|
||||
'Mock pay channel is disabled' => '模拟支付渠道已关闭',
|
||||
'DDPay is not configured' => '未配置 DDPay 商户,请使用 channel_code=mock 或配置 DDPAY_*',
|
||||
'Order is not a mock pay deposit' => '该订单不是模拟支付充值单',
|
||||
'Order cannot be paid' => '订单当前状态不可支付',
|
||||
'Withdraw only supports DDPay' => '仅支持 DDPay 提现(channel_code 须为 ddpay)',
|
||||
// 会员中心 account
|
||||
'Data updated successfully~' => '资料更新成功~',
|
||||
'Password has been changed~' => '密码已修改~',
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace app\common\controller;
|
||||
|
||||
use Throwable;
|
||||
use app\admin\library\Auth;
|
||||
use app\common\service\AdminChannelScopeService;
|
||||
use app\common\service\AdminCommissionDistributionService;
|
||||
use support\think\Db;
|
||||
use app\common\library\token\TokenExpirationException;
|
||||
use app\admin\library\traits\Backend as BackendTrait;
|
||||
use support\Response;
|
||||
@@ -421,7 +424,7 @@ class Backend extends Api
|
||||
*/
|
||||
protected function getDataLimitAdminIds(): array
|
||||
{
|
||||
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
|
||||
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = [];
|
||||
@@ -475,6 +478,51 @@ class Backend extends Api
|
||||
return get_controller_path($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全平台只读范围:超管,或未绑定渠道且拥有渠道模块基础权限
|
||||
*/
|
||||
protected function hasGlobalReadScope(): bool
|
||||
{
|
||||
return $this->auth !== null && AdminChannelScopeService::hasGlobalReadScope($this->auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否按「名下管理员/用户」收窄列表(非超管且非全平台只读范围)
|
||||
*/
|
||||
protected function shouldApplyUserAdminScope(): bool
|
||||
{
|
||||
if ($this->auth === null || !$this->auth->isLogin()) {
|
||||
return false;
|
||||
}
|
||||
if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可读渠道 ID;null 表示全部渠道
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
protected function readableChannelIds(): ?array
|
||||
{
|
||||
if ($this->auth === null) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
return AdminChannelScopeService::readableChannelIds($this->auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表是否需要按 channel_id 收窄
|
||||
*/
|
||||
protected function shouldApplyChannelIdScope(): bool
|
||||
{
|
||||
return $this->readableChannelIds() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造权限节点候选:兼容 snake_case 与 camelCase 节点名
|
||||
*
|
||||
@@ -496,4 +544,49 @@ class Backend extends Api
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色组管理范围内可见的管理员 ID(本人 + 代理树下级 + 本人所在组及下级组内管理员)
|
||||
* 超管或全平台只读范围返回空数组表示不限制
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
protected function getManageableScopeAdminIds(): array
|
||||
{
|
||||
if ($this->auth === null || !$this->auth->isLogin()) {
|
||||
return [0];
|
||||
}
|
||||
if ($this->auth->isSuperAdmin() || $this->hasGlobalReadScope()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$operatorId = intval($this->auth->id);
|
||||
$ids = AdminCommissionDistributionService::getVisibleAdminIdsForOperator($operatorId, false);
|
||||
|
||||
$ownGroupIds = Db::name('admin_group_access')->where('uid', $operatorId)->column('group_id');
|
||||
$childGroupIds = $this->auth->getAdminChildGroups();
|
||||
$groupIds = array_values(array_unique(array_merge(
|
||||
array_map(static fn($id) => intval(strval($id)), $ownGroupIds),
|
||||
array_map(static fn($id) => intval(strval($id)), $childGroupIds)
|
||||
)));
|
||||
if ($groupIds !== []) {
|
||||
$groupAdminIds = Db::name('admin_group_access')
|
||||
->where('group_id', 'in', $groupIds)
|
||||
->column('uid');
|
||||
foreach ($groupAdminIds as $uid) {
|
||||
$uidInt = intval(strval($uid));
|
||||
if ($uidInt > 0) {
|
||||
$ids[] = $uidInt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($operatorId > 0) {
|
||||
$ids[] = $operatorId;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
|
||||
|
||||
return $ids === [] ? [0] : $ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ return [
|
||||
'游戏对局' => 'Game rounds',
|
||||
'游戏对局记录' => 'Game records',
|
||||
'游戏实时对局' => 'Game live',
|
||||
'游玩记录' => 'Play records',
|
||||
'压注订单' => 'Bet orders',
|
||||
'充值订单' => 'Deposit orders',
|
||||
'提现订单' => 'Withdraw orders',
|
||||
@@ -91,6 +92,20 @@ return [
|
||||
'管理员钱包流水' => 'Admin wallet records',
|
||||
'一键批量结算待结算渠道' => 'Batch settle pending channels',
|
||||
'渠道结算统计' => 'Channel settlement statistics',
|
||||
'推送配置' => 'Push configuration',
|
||||
'连接服务器websocket' => 'Connect WebSocket server',
|
||||
'连接配置' => 'Connection settings',
|
||||
'数据快照' => 'Data snapshot',
|
||||
'运行开关' => 'Runtime switch',
|
||||
'预约开奖' => 'Schedule draw',
|
||||
'作废本局' => 'Void round',
|
||||
'计算盈亏' => 'Calculate P/L',
|
||||
'档位查看' => 'View tiers',
|
||||
'档位保存' => 'Save tiers',
|
||||
'详情' => 'Detail',
|
||||
'审核' => 'Review',
|
||||
'通过' => 'Approve',
|
||||
'拒绝' => 'Reject',
|
||||
|
||||
// 演示/运营公告标题(若入库为菜单展示)
|
||||
'系统维护通知(演示)' => 'Maintenance notice (demo)',
|
||||
@@ -105,8 +120,25 @@ return [
|
||||
'createNextManual' => 'Create next period manually',
|
||||
'periodSettings' => 'Period settings',
|
||||
'manualSettle' => 'Manual settle',
|
||||
'batchSettlePending' => 'Batch settle pending channels',
|
||||
'viewCommissionRecords' => 'View commission records',
|
||||
'viewDividendRecords' => 'View paid dividend records',
|
||||
'viewDirectBetRecords' => 'View direct bet records',
|
||||
'viewSettlementBetRecords' => 'View company bet records',
|
||||
'viewAllChannels' => 'View all channels',
|
||||
|
||||
// 其它中文按钮文案
|
||||
'期号开关' => 'Period toggle',
|
||||
'手动创建下一期' => 'Create next period manually',
|
||||
|
||||
// Markdown 文档目录
|
||||
'Markdown文档' => 'Markdown documents',
|
||||
|
||||
// 文档:36字花移动端接口草案
|
||||
'36字花移动端接口文档' => '36 Zihua mobile API design draft',
|
||||
'拉取正文' => 'Load document body',
|
||||
'下载 Markdown' => 'Download Markdown',
|
||||
|
||||
// 文档:分红说明
|
||||
'分红说明文档' => 'Commission share guide',
|
||||
];
|
||||
|
||||
206
app/common/lang/en/service.php
Normal file
206
app/common/lang/en/service.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'Channel not found' => 'Channel not found',
|
||||
'Settlement number conflict, please retry' => 'Settlement number conflict, please retry',
|
||||
'No available admin share ratios under this channel; cannot settle' => 'No available admin share ratios under this channel; cannot settle',
|
||||
'No channel root agent configured for commission distribution' => 'No top-level agent is configured for this channel; cannot settle with tree commission',
|
||||
'Sub-agent commission share rate is required' => 'Commission share rate from parent is required for sub-agents',
|
||||
'Commission share rate must be between 0 and 100' => 'Commission share rate must be between 0 and 100',
|
||||
'Sum of sibling commission share rates cannot exceed 100%' => 'Sum of sibling commission share rates cannot exceed 100%',
|
||||
'Sum of channel top-level commission share rates cannot exceed 100%' => 'Sum of channel top-level commission share rates cannot exceed 100%',
|
||||
'Top-level agent commission share rate is required' => 'Commission share rate from channel is required for top-level role group administrators',
|
||||
'Channel is required for top-level agent commission share' => 'Channel must be set before configuring top-level commission share rate',
|
||||
'Parent administrator is required for sub-agent' => 'Parent administrator is required for sub-agent',
|
||||
'Invalid parent administrator' => 'Invalid parent administrator',
|
||||
'Parent administrator must belong to the same channel' => 'Parent administrator must belong to the same channel',
|
||||
'Cannot set yourself as parent administrator' => 'Cannot set yourself as parent administrator',
|
||||
'Top-level agent does not need a commission share rate' => 'Top-level agent does not need a commission share rate',
|
||||
'Cannot delete administrator with sub-agents' => 'Cannot delete an administrator who has sub-agents',
|
||||
'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again' => 'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again',
|
||||
'Invalid channel data' => 'Invalid channel data',
|
||||
'Invalid settlement period (start time is not earlier than now)' => 'Invalid settlement period (start time is not earlier than now)',
|
||||
'Turnover agent commission rate is not configured' => 'Turnover agent commission rate is not configured',
|
||||
'Affiliate agent fee rate is not configured' => 'Affiliate agent fee rate is not configured',
|
||||
'Affiliate ladder rules are empty or invalid' => 'Affiliate ladder rules are empty or invalid',
|
||||
'Unknown agent mode' => 'Unknown agent mode',
|
||||
|
||||
'Manual create next round is disabled' => 'Manual create next round is disabled',
|
||||
'There is an unfinished round; cannot create a new one' => 'There is an unfinished round; cannot create a new one',
|
||||
'New round created' => 'New round created',
|
||||
|
||||
'Idempotency key conflict' => 'Idempotency key conflict',
|
||||
'Insufficient wallet balance' => 'Insufficient wallet balance',
|
||||
|
||||
'Game record is generated by system; manual creation is not allowed' => 'Game record is generated by system; manual creation is not allowed',
|
||||
'Game record cannot be edited' => 'Game record cannot be edited',
|
||||
'Game record cannot be deleted' => 'Game record cannot be deleted',
|
||||
|
||||
'Bet order is generated by game API; manual creation is not allowed' => 'Bet order is generated by game API; manual creation is not allowed',
|
||||
'Bet order cannot be edited' => 'Bet order cannot be edited',
|
||||
'Bet order cannot be deleted' => 'Bet order cannot be deleted',
|
||||
'Sorting is not supported' => 'Sorting is not supported',
|
||||
|
||||
'Play record is generated by game API; manual creation is not allowed' => 'Play record is generated by game API; manual creation is not allowed',
|
||||
'Play record cannot be edited' => 'Play record cannot be edited',
|
||||
'Play record cannot be deleted' => 'Play record cannot be deleted',
|
||||
|
||||
'Wallet records are business-generated; manual creation is not allowed' => 'Wallet records are business-generated; manual creation is not allowed',
|
||||
'Wallet record cannot be edited' => 'Wallet record cannot be edited',
|
||||
'Wallet record cannot be deleted' => 'Wallet record cannot be deleted',
|
||||
|
||||
'User notice read records are client-generated; manual creation is not allowed' => 'User notice read records are client-generated; manual creation is not allowed',
|
||||
'User notice read record cannot be edited' => 'User notice read record cannot be edited',
|
||||
'User notice read record cannot be deleted' => 'User notice read record cannot be deleted',
|
||||
|
||||
'Admin wallet does not allow manual creation' => 'Admin wallet does not allow manual creation',
|
||||
'Admin wallet does not allow manual editing' => 'Admin wallet does not allow manual editing',
|
||||
'Admin wallet does not allow deletion' => 'Admin wallet does not allow deletion',
|
||||
|
||||
'Admin wallet records do not allow manual creation' => 'Admin wallet records do not allow manual creation',
|
||||
'Admin wallet records do not allow manual editing' => 'Admin wallet records do not allow manual editing',
|
||||
'Admin wallet records do not allow deletion' => 'Admin wallet records do not allow deletion',
|
||||
|
||||
'Invalid parameters' => 'Invalid parameters',
|
||||
'This config is locked by another operation, please try again later' => 'This config is locked by another operation, please try again later',
|
||||
'Saved successfully' => 'Saved successfully',
|
||||
|
||||
'Please use approve/reject buttons to review' => 'Please use approve/reject buttons to review',
|
||||
'This withdraw order has already been reviewed' => 'This withdraw order has already been reviewed',
|
||||
'Approved' => 'Approved',
|
||||
'Please provide reject reason' => 'Please provide reject reason',
|
||||
'Rejected' => 'Rejected',
|
||||
|
||||
'Deposit orders are auto-settled; direct modification is not allowed. Use the dedicated tool for manual adjustment.' => 'Deposit orders are auto-settled; direct modification is not allowed. Use the dedicated tool for manual adjustment.',
|
||||
|
||||
'Missing required parameters' => 'Missing required parameters',
|
||||
'Idempotency key is too long' => 'Idempotency key is too long',
|
||||
'Withdraw amount must be greater than 0' => 'Withdraw amount must be greater than 0',
|
||||
'Invalid receive type; only bank/ewallet/crypto are supported' => 'Invalid receive type; only bank/ewallet/crypto are supported',
|
||||
'Withdraw request submitted; pending channel super admin review' => 'Withdraw request submitted; pending channel super admin review',
|
||||
|
||||
'Tier ID already exists' => 'Tier ID already exists',
|
||||
'Items must be an array' => 'Items must be an array',
|
||||
'Please save all registered channel rows (no missing rows)' => 'Please save all registered channel rows (no missing rows)',
|
||||
|
||||
'Please use approve/reject buttons to complete the review' => 'Please use approve/reject buttons to complete the review',
|
||||
'Apply amount must be greater than 0' => 'Apply amount must be greater than 0',
|
||||
'Fee cannot be negative' => 'Fee cannot be negative',
|
||||
'Fee cannot be greater than apply amount' => 'Fee cannot be greater than apply amount',
|
||||
'This order has already been reviewed' => 'This order has already been reviewed',
|
||||
'Order is missing user info' => 'Order is missing user info',
|
||||
'Related user does not exist' => 'Related user does not exist',
|
||||
'User balance is insufficient to cover the adjustment difference' => 'User balance is insufficient to cover the adjustment difference',
|
||||
|
||||
'Please select exactly one role group' => 'Please select exactly one role group',
|
||||
'Selected role group is not bound to a channel' => 'Selected role group is not bound to a channel',
|
||||
'Selected role group channel does not match current account' => 'Selected role group channel does not match current account',
|
||||
|
||||
'Invalid operation type' => 'Invalid operation type',
|
||||
'Invalid amount format' => 'Invalid amount format',
|
||||
'Amount must be greater than 0' => 'Amount must be greater than 0',
|
||||
'This user is being operated by another admin (wallet/concurrent save); please try again later' => 'This user is being operated by another admin (wallet/concurrent save); please try again later',
|
||||
'Insufficient balance; deduction failed' => 'Insufficient balance; deduction failed',
|
||||
'Save failed: user balance changed by another request; please refresh and retry' => 'Save failed: user balance changed by another request; please refresh and retry',
|
||||
'Wallet adjusted successfully' => 'Wallet adjusted successfully',
|
||||
|
||||
'Please configure at least one share record' => 'Please configure at least one share record',
|
||||
'There are no admins under this channel; cannot configure share ratios' => 'There are no admins under this channel; cannot configure share ratios',
|
||||
'Share ratio must be between 0 and 100' => 'Share ratio must be between 0 and 100',
|
||||
'Please configure at least one valid share record' => 'Please configure at least one valid share record',
|
||||
'Sum of enabled share ratios must equal 100' => 'Sum of enabled share ratios must equal 100',
|
||||
'Share ratios saved successfully' => 'Share ratios saved successfully',
|
||||
'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => 'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets',
|
||||
'Settlement failed' => 'Settlement failed',
|
||||
'Super admin settlement completed; paid automatically by share ratios' => 'Super admin settlement completed; paid automatically by share ratios',
|
||||
'Settlement completed; commissions paid automatically by share ratios' => 'Settlement completed; commissions paid automatically by share ratios',
|
||||
'Batch settlement completed' => 'Batch settlement completed',
|
||||
|
||||
'Invalid settlement cycle' => 'Invalid settlement cycle',
|
||||
'Invalid settlement time format (HH:mm:ss)' => 'Invalid settlement time format (HH:mm:ss)',
|
||||
'Weekly settlement must select Monday to Sunday' => 'Weekly settlement must select Monday to Sunday',
|
||||
'Monthly settlement day must be between 1 and 31' => 'Monthly settlement day must be between 1 and 31',
|
||||
'Turnover commission rate must be between 0 and 100' => 'Turnover commission rate must be between 0 and 100',
|
||||
'Settlement handling fee cannot be negative' => 'Settlement handling fee cannot be negative',
|
||||
'Settlement handling fee rate must be between 0 and 100' => 'Settlement handling fee rate must be between 0 and 100',
|
||||
'Affiliate share/fee rates are required' => 'Affiliate share/fee rates are required',
|
||||
'Affiliate share/fee rates must be between 0 and 1' => 'Affiliate share/fee rates must be between 0 and 1',
|
||||
'Affiliate ladder rules are required' => 'Affiliate ladder rules are required',
|
||||
'Affiliate ladder rules must be a valid JSON array' => 'Affiliate ladder rules must be a valid JSON array',
|
||||
'Affiliate ladder rules must contain at least one row' => 'Affiliate ladder rules must contain at least one row',
|
||||
'Affiliate ladder rules row format error' => 'Affiliate ladder rules row format error',
|
||||
'Affiliate ladder rules minLoss format error' => 'Affiliate ladder rules minLoss format error',
|
||||
'Affiliate ladder rules shareRate format error' => 'Affiliate ladder rules shareRate format error',
|
||||
'Affiliate ladder rules minLoss cannot be negative' => 'Affiliate ladder rules minLoss cannot be negative',
|
||||
'Affiliate ladder rules shareRate must be between 0 and 1' => 'Affiliate ladder rules shareRate must be between 0 and 1',
|
||||
'Affiliate ladder rules must be strictly increasing by minLoss' => 'Affiliate ladder rules must be strictly increasing by minLoss',
|
||||
|
||||
'Withdraw apply failed' => 'Withdraw apply failed',
|
||||
|
||||
'Debit failed: user balance changed by another request; please refresh and retry' => 'Debit failed: user balance changed by another request; please refresh and retry',
|
||||
|
||||
'Deposit' => 'Deposit',
|
||||
'Mock payment' => 'Mock payment',
|
||||
'Mock third-party cashier' => 'Mock third-party cashier',
|
||||
'Order No' => 'Order No',
|
||||
'Pay channel' => 'Pay channel',
|
||||
'Amount (fiat/pricing)' => 'Amount (fiat/pricing)',
|
||||
'Bonus' => 'Bonus',
|
||||
'coin' => 'coin',
|
||||
'Click the button below to simulate successful third-party payment; the server will callback and settle the deposit.' => 'Click the button below to simulate successful third-party payment; the server will callback and settle the deposit.',
|
||||
'Confirm payment (simulate success)' => 'Confirm payment (simulate success)',
|
||||
|
||||
'Order id is invalid' => 'Order id is invalid',
|
||||
'Order does not exist' => 'Order does not exist',
|
||||
'Order number is empty' => 'Order number is empty',
|
||||
'Order status does not allow settlement' => 'Order status does not allow settlement',
|
||||
'Order amount is invalid' => 'Order amount is invalid',
|
||||
'Order user is invalid' => 'Order user is invalid',
|
||||
'User does not exist' => 'User does not exist',
|
||||
'Order state changed, please refresh and retry' => 'Order state changed, please refresh and retry',
|
||||
|
||||
'Row format error' => 'Row format error',
|
||||
'Channel code is not registered' => 'Channel code is not registered',
|
||||
'Duplicate channel code' => 'Duplicate channel code',
|
||||
'Invalid channel row data' => 'Invalid channel row data',
|
||||
'JSON encode failed' => 'JSON encode failed',
|
||||
|
||||
'Tier id is invalid' => 'Tier id is invalid',
|
||||
'Duplicate tier id' => 'Duplicate tier id',
|
||||
'Tier title (zh) is required' => 'Tier title (zh) is required',
|
||||
'Tier title (zh) is too long' => 'Tier title (zh) is too long',
|
||||
'Tier title (en) is too long' => 'Tier title (en) is too long',
|
||||
'Currency is required' => 'Currency is required',
|
||||
'Pay amount must be greater than 0' => 'Pay amount must be greater than 0',
|
||||
'Base credit amount must be greater than 0' => 'Base credit amount must be greater than 0',
|
||||
'Bonus amount cannot be negative' => 'Bonus amount cannot be negative',
|
||||
'Description (zh) is too long' => 'Description (zh) is too long',
|
||||
'Description (en) is too long' => 'Description (en) is too long',
|
||||
|
||||
'platform_coin format error' => 'platform_coin format error',
|
||||
'Platform coin labels (zh/en) are required' => 'Platform coin labels (zh/en) are required',
|
||||
'At least one currency is required' => 'At least one currency is required',
|
||||
'Currency row format error' => 'Currency row format error',
|
||||
'Currency code is invalid' => 'Currency code is invalid',
|
||||
'Duplicate currency code' => 'Duplicate currency code',
|
||||
'Deposit rate must be a number greater than 0' => 'Deposit rate must be a number greater than 0',
|
||||
'Withdraw rate must be a number greater than 0' => 'Withdraw rate must be a number greater than 0',
|
||||
'Bank row format error' => 'Bank row format error',
|
||||
'Bank code is invalid' => 'Bank code is invalid',
|
||||
'Duplicate bank code' => 'Duplicate bank code',
|
||||
'Withdraw min limit must be a number not less than 0' => 'Withdraw min limit must be a number not less than 0',
|
||||
'Please configure deposit channels' => 'Please configure deposit channels',
|
||||
|
||||
'Must be exactly 36 items' => 'Must be exactly 36 items',
|
||||
'No must be numeric' => 'No must be numeric',
|
||||
'No must be between 1 and 36' => 'No must be between 1 and 36',
|
||||
'Duplicate no' => 'Duplicate no',
|
||||
'Name is required' => 'Name is required',
|
||||
'Category is invalid' => 'Category is invalid',
|
||||
'Missing no' => 'Missing no',
|
||||
|
||||
'Failed to generate commission rows' => 'Failed to generate commission rows',
|
||||
];
|
||||
|
||||
@@ -53,6 +53,15 @@ return [
|
||||
'periodSettings' => '期号设置',
|
||||
'manualSettle' => '手动结算',
|
||||
'batchSettlePending' => '批量结算待结算渠道',
|
||||
'viewCommissionRecords' => '查看代理佣金记录',
|
||||
'viewDividendRecords' => '查看已分红记录',
|
||||
'viewDirectBetRecords' => '查看直属投注记录',
|
||||
'viewSettlementBetRecords' => '查看公司总投注记录',
|
||||
'viewAllChannels' => '查看所有渠道',
|
||||
'walletAdjust' => '钱包加减点',
|
||||
'Markdown文档' => 'Markdown文档',
|
||||
'分红说明文档' => '分红说明文档',
|
||||
'拉取正文' => '拉取正文',
|
||||
'下载 Markdown' => '下载 Markdown',
|
||||
];
|
||||
|
||||
|
||||
206
app/common/lang/zh-cn/service.php
Normal file
206
app/common/lang/zh-cn/service.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'Channel not found' => '渠道不存在',
|
||||
'Settlement number conflict, please retry' => '结算单号冲突,请重试',
|
||||
'No available admin share ratios under this channel; cannot settle' => '渠道下无可用管理员分配比例,无法结算',
|
||||
'No channel root agent configured for commission distribution' => '渠道未配置顶级代理管理员,无法按树形分红结算',
|
||||
'Sub-agent commission share rate is required' => '子代理须填写从上级分红中抽取的比例',
|
||||
'Commission share rate must be between 0 and 100' => '分红比例须在0到100之间',
|
||||
'Sum of sibling commission share rates cannot exceed 100%' => '同上级下各子代理分红比例合计不能超过100%',
|
||||
'Sum of channel top-level commission share rates cannot exceed 100%' => '同渠道顶级代理分红比例合计不能超过100%',
|
||||
'Top-level agent commission share rate is required' => '顶级角色组管理员须填写从渠道分红中分得的比例',
|
||||
'Channel is required for top-level agent commission share' => '顶级角色组管理员须先绑定渠道后再设置分红比例',
|
||||
'Parent administrator is required for sub-agent' => '子代理须绑定上级管理员',
|
||||
'Invalid parent administrator' => '上级管理员无效',
|
||||
'Parent administrator must belong to the same channel' => '上级管理员须与当前渠道一致',
|
||||
'Cannot set yourself as parent administrator' => '不能将自己设为上级管理员',
|
||||
'Top-level agent does not need a commission share rate' => '顶级代理无需填写分红比例',
|
||||
'Cannot delete administrator with sub-agents' => '存在下级子代理的管理员不可删除',
|
||||
'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again' => '当前流程为超管结算后自动发放,渠道管理员无需二次结算',
|
||||
'Invalid channel data' => '渠道数据异常',
|
||||
'Invalid settlement period (start time is not earlier than now)' => '结算区间无效(开始时间不早于当前)',
|
||||
'Turnover agent commission rate is not configured' => '普通返水代理未配置返水分红比例',
|
||||
'Affiliate agent fee rate is not configured' => '联营代理未配置成本扣除比例',
|
||||
'Affiliate ladder rules are empty or invalid' => '联营阶梯规则无效或为空',
|
||||
'Unknown agent mode' => '未知的代理模式',
|
||||
|
||||
'Manual create next round is disabled' => '未开启「手动创建下一局」开关',
|
||||
'There is an unfinished round; cannot create a new one' => '存在未结束对局,无法新建',
|
||||
'New round created' => '已创建新对局',
|
||||
|
||||
'Idempotency key conflict' => '幂等键冲突',
|
||||
'Insufficient wallet balance' => '钱包余额不足',
|
||||
|
||||
'Game record is generated by system; manual creation is not allowed' => '游戏对局记录由系统自动生成,禁止后台手工新增',
|
||||
'Game record cannot be edited' => '游戏对局记录不可编辑',
|
||||
'Game record cannot be deleted' => '游戏对局记录不可删除',
|
||||
|
||||
'Bet order is generated by game API; manual creation is not allowed' => '注单由游戏接口生成,禁止后台手工新增',
|
||||
'Bet order cannot be edited' => '注单不可编辑',
|
||||
'Bet order cannot be deleted' => '注单不可删除',
|
||||
'Sorting is not supported' => '不支持排序',
|
||||
|
||||
'Play record is generated by game API; manual creation is not allowed' => '游玩记录由游戏接口生成,禁止后台手工新增',
|
||||
'Play record cannot be edited' => '游玩记录不可编辑',
|
||||
'Play record cannot be deleted' => '游玩记录不可删除',
|
||||
|
||||
'Wallet records are business-generated; manual creation is not allowed' => '钱包流水仅允许业务入账,禁止后台手工新增',
|
||||
'Wallet record cannot be edited' => '钱包流水不可编辑',
|
||||
'Wallet record cannot be deleted' => '钱包流水不可删除',
|
||||
|
||||
'User notice read records are client-generated; manual creation is not allowed' => '用户阅读记录由客户端确认已读写入,禁止后台手工新增',
|
||||
'User notice read record cannot be edited' => '用户阅读记录不可编辑',
|
||||
'User notice read record cannot be deleted' => '用户阅读记录不可删除',
|
||||
|
||||
'Admin wallet does not allow manual creation' => '管理员钱包不允许手动新增',
|
||||
'Admin wallet does not allow manual editing' => '管理员钱包不允许手动编辑',
|
||||
'Admin wallet does not allow deletion' => '管理员钱包不允许删除',
|
||||
|
||||
'Admin wallet records do not allow manual creation' => '管理员钱包流水不允许手动新增',
|
||||
'Admin wallet records do not allow manual editing' => '管理员钱包流水不允许手动编辑',
|
||||
'Admin wallet records do not allow deletion' => '管理员钱包流水不允许删除',
|
||||
|
||||
'Invalid parameters' => '参数错误',
|
||||
'This config is locked by another operation, please try again later' => '该配置正在被其他操作占用,请稍后再试',
|
||||
'Saved successfully' => '保存成功',
|
||||
|
||||
'Please use approve/reject buttons to review' => '请使用通过/拒绝按钮审核',
|
||||
'This withdraw order has already been reviewed' => '该提现订单已审核',
|
||||
'Approved' => '审核通过',
|
||||
'Please provide reject reason' => '请填写拒绝原因',
|
||||
'Rejected' => '审核拒绝完成',
|
||||
|
||||
'Deposit orders are auto-settled; direct modification is not allowed. Use the dedicated tool for manual adjustment.' => '充值订单为自动入账,禁止直接修改,如需补单请走专用工具',
|
||||
|
||||
'Missing required parameters' => '参数缺失',
|
||||
'Idempotency key is too long' => '幂等键过长',
|
||||
'Withdraw amount must be greater than 0' => '提现金额必须大于0',
|
||||
'Invalid receive type; only bank/ewallet/crypto are supported' => '收款类型不合法,仅支持 bank/ewallet/crypto',
|
||||
'Withdraw request submitted; pending channel super admin review' => '提现申请已提交,待渠道超管审核',
|
||||
|
||||
'Tier ID already exists' => '档位 ID 已存在',
|
||||
'Items must be an array' => 'items 必须为数组',
|
||||
'Please save all registered channel rows (no missing rows)' => '请保存全部已注册渠道行(不可缺行)',
|
||||
|
||||
'Please use approve/reject buttons to complete the review' => '请使用通过/拒绝按钮完成审核',
|
||||
'Apply amount must be greater than 0' => '申请金额必须大于 0',
|
||||
'Fee cannot be negative' => '手续费不能为负',
|
||||
'Fee cannot be greater than apply amount' => '手续费不能大于申请金额',
|
||||
'This order has already been reviewed' => '该订单已审核,无需重复操作',
|
||||
'Order is missing user info' => '订单缺少用户信息',
|
||||
'Related user does not exist' => '关联用户不存在',
|
||||
'User balance is insufficient to cover the adjustment difference' => '用户余额不足以补扣调整差额',
|
||||
|
||||
'Please select exactly one role group' => '请选择且仅选择一个角色组',
|
||||
'Selected role group is not bound to a channel' => '所选角色组未绑定渠道',
|
||||
'Selected role group channel does not match current account' => '所选角色组渠道与当前账号不一致',
|
||||
|
||||
'Invalid operation type' => '操作类型不正确',
|
||||
'Invalid amount format' => '金额格式不正确',
|
||||
'Amount must be greater than 0' => '金额必须大于0',
|
||||
'This user is being operated by another admin (wallet/concurrent save); please try again later' => '该用户正在被其他管理员操作(钱包/并发保存),请稍后再试',
|
||||
'Insufficient balance; deduction failed' => '余额不足,扣点失败',
|
||||
'Save failed: user balance changed by another request; please refresh and retry' => '保存失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试',
|
||||
'Wallet adjusted successfully' => '钱包调整成功',
|
||||
|
||||
'Please configure at least one share record' => '请至少配置一条分配记录',
|
||||
'There are no admins under this channel; cannot configure share ratios' => '该渠道下暂无管理员,无法配置分配比例',
|
||||
'Share ratio must be between 0 and 100' => '分配比例必须在0到100之间',
|
||||
'Please configure at least one valid share record' => '请至少配置一条有效分配记录',
|
||||
'Sum of enabled share ratios must equal 100' => '启用的分配比例总和必须等于100',
|
||||
'Share ratios saved successfully' => '分配比例保存成功',
|
||||
'Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets' => '仅超管可执行结算,结算后系统会自动发放至管理员钱包',
|
||||
'Settlement failed' => '结算失败',
|
||||
'Super admin settlement completed; paid automatically by share ratios' => '超管结算完成,已按分配比例自动发放给管理员',
|
||||
'Settlement completed; commissions paid automatically by share ratios' => '结算完成,已按分配比例自动发放给管理员',
|
||||
'Batch settlement completed' => '批量结算完成',
|
||||
|
||||
'Invalid settlement cycle' => '结算周期不合法',
|
||||
'Invalid settlement time format (HH:mm:ss)' => '结算时间格式不正确(HH:mm:ss)',
|
||||
'Weekly settlement must select Monday to Sunday' => '周结必须选择周一到周日',
|
||||
'Monthly settlement day must be between 1 and 31' => '月结日期必须在1到31之间',
|
||||
'Turnover commission rate must be between 0 and 100' => '返水分红比例必须在0到100之间',
|
||||
'Settlement handling fee cannot be negative' => '代理结算手续费不能为负数',
|
||||
'Settlement handling fee rate must be between 0 and 100' => '代理结算手续费比例必须在0到100之间',
|
||||
'Affiliate share/fee rates are required' => '联营占成比例不能为空',
|
||||
'Affiliate share/fee rates must be between 0 and 1' => '联营占成比例必须在0到1之间',
|
||||
'Affiliate ladder rules are required' => '联营阶梯规则不能为空',
|
||||
'Affiliate ladder rules must be a valid JSON array' => '联营阶梯规则必须是有效JSON数组',
|
||||
'Affiliate ladder rules must contain at least one row' => '联营阶梯规则至少需要一条',
|
||||
'Affiliate ladder rules row format error' => '联营阶梯规则行格式错误',
|
||||
'Affiliate ladder rules minLoss format error' => '联营阶梯规则起始客损格式错误',
|
||||
'Affiliate ladder rules shareRate format error' => '联营阶梯规则占成比例格式错误',
|
||||
'Affiliate ladder rules minLoss cannot be negative' => '联营阶梯规则起始客损不能为负',
|
||||
'Affiliate ladder rules shareRate must be between 0 and 1' => '联营阶梯规则占成比例必须在0到1之间',
|
||||
'Affiliate ladder rules must be strictly increasing by minLoss' => '联营阶梯规则需按起始客损递增',
|
||||
|
||||
'Withdraw apply failed' => '提现申请失败',
|
||||
|
||||
'Debit failed: user balance changed by another request; please refresh and retry' => '扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试',
|
||||
|
||||
'Deposit' => '充值',
|
||||
'Mock payment' => '模拟支付',
|
||||
'Mock third-party cashier' => '模拟第三方收银台',
|
||||
'Order No' => '订单号',
|
||||
'Pay channel' => '支付渠道',
|
||||
'Amount (fiat/pricing)' => '金额(法币/标价)',
|
||||
'Bonus' => '赠送',
|
||||
'coin' => '币',
|
||||
'Click the button below to simulate successful third-party payment; the server will callback and settle the deposit.' => '点击下方按钮即视为第三方支付成功,服务端会回调并到账。',
|
||||
'Confirm payment (simulate success)' => '确认支付(模拟成功)',
|
||||
|
||||
'Order id is invalid' => '订单 ID 非法',
|
||||
'Order does not exist' => '订单不存在',
|
||||
'Order number is empty' => '订单号为空',
|
||||
'Order status does not allow settlement' => '订单状态不允许结算',
|
||||
'Order amount is invalid' => '订单金额异常',
|
||||
'Order user is invalid' => '订单所属玩家无效',
|
||||
'User does not exist' => '玩家不存在',
|
||||
'Order state changed, please refresh and retry' => '订单状态已变更,请刷新后重试',
|
||||
|
||||
'Row format error' => '行格式错误',
|
||||
'Channel code is not registered' => '渠道 code 未注册',
|
||||
'Duplicate channel code' => '渠道 code 重复',
|
||||
'Invalid channel row data' => '渠道数据无效',
|
||||
'JSON encode failed' => 'JSON 编码失败',
|
||||
|
||||
'Tier id is invalid' => '档位 ID 非法',
|
||||
'Duplicate tier id' => '档位 ID 重复',
|
||||
'Tier title (zh) is required' => '中文充值名称不能为空',
|
||||
'Tier title (zh) is too long' => '中文充值名称过长',
|
||||
'Tier title (en) is too long' => '英文充值名称过长',
|
||||
'Currency is required' => '支付货币不能为空',
|
||||
'Pay amount must be greater than 0' => '支付货币额度必须大于 0',
|
||||
'Base credit amount must be greater than 0' => '基础平台币到账必须大于 0',
|
||||
'Bonus amount cannot be negative' => '赠送金额不能为负数',
|
||||
'Description (zh) is too long' => '中文描述过长',
|
||||
'Description (en) is too long' => '英文描述过长',
|
||||
|
||||
'platform_coin format error' => 'platform_coin 格式错误',
|
||||
'Platform coin labels (zh/en) are required' => '请填写平台币中英文名称',
|
||||
'At least one currency is required' => '至少保留一条货币',
|
||||
'Currency row format error' => '货币列表行格式错误',
|
||||
'Currency code is invalid' => '货币代码非法',
|
||||
'Duplicate currency code' => '货币代码不能重复',
|
||||
'Deposit rate must be a number greater than 0' => '充值汇率须为大于 0 的数字',
|
||||
'Withdraw rate must be a number greater than 0' => '提现汇率须为大于 0 的数字',
|
||||
'Bank row format error' => '银行行格式错误',
|
||||
'Bank code is invalid' => '银行代码非法',
|
||||
'Duplicate bank code' => '银行代码重复',
|
||||
'Withdraw min limit must be a number not less than 0' => '提现最低限额须为不小于 0 的数字',
|
||||
'Please configure deposit channels' => '请配置充值渠道',
|
||||
|
||||
'Must be exactly 36 items' => '必须恰好 36 条',
|
||||
'No must be numeric' => '编号必须为数字',
|
||||
'No must be between 1 and 36' => '编号必须在 1–36',
|
||||
'Duplicate no' => '编号重复',
|
||||
'Name is required' => '名称不能为空',
|
||||
'Category is invalid' => '类型无效',
|
||||
'Missing no' => '缺少编号',
|
||||
|
||||
'Failed to generate commission rows' => '生成待分红记录失败',
|
||||
];
|
||||
|
||||
@@ -105,7 +105,7 @@ class Auth extends \ba\Auth
|
||||
$this->setError(__('Email'));
|
||||
return false;
|
||||
}
|
||||
$isMobileUsername = preg_match('/^1[3-9]\d{9}$/', $username) === 1;
|
||||
$isMobileUsername = MalaysiaMobilePhone::isValid($username);
|
||||
$isNormalUsername = preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username) === 1;
|
||||
if ($username && !$isMobileUsername && !$isNormalUsername) {
|
||||
$this->setError(__('Username'));
|
||||
@@ -127,7 +127,7 @@ class Auth extends \ba\Auth
|
||||
return false;
|
||||
}
|
||||
|
||||
$nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username);
|
||||
$nickname = MalaysiaMobilePhone::maskInNickname($username);
|
||||
$time = time();
|
||||
$data = [
|
||||
'group_id' => $group,
|
||||
@@ -170,15 +170,22 @@ class Auth extends \ba\Auth
|
||||
public function login(string $username, string $password, bool $keep): bool
|
||||
{
|
||||
$accountType = false;
|
||||
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'phone';
|
||||
elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email';
|
||||
elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) $accountType = 'username';
|
||||
if (!$accountType) {
|
||||
$this->setError('Account not exist');
|
||||
return false;
|
||||
if (MalaysiaMobilePhone::isValid($username)) {
|
||||
$accountType = 'phone';
|
||||
} elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) {
|
||||
$accountType = 'email';
|
||||
} elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) {
|
||||
$accountType = 'username';
|
||||
}
|
||||
if ($accountType) {
|
||||
$this->model = User::where($accountType, $username)->find();
|
||||
} else {
|
||||
// 兼容历史纯数字账号、带 + 前缀手机号等非标准格式
|
||||
$this->model = User::where('username', $username)->whereOr('phone', $username)->find();
|
||||
if (!$this->model && str_starts_with($username, '+')) {
|
||||
$this->model = User::where('phone', substr($username, 1))->find();
|
||||
}
|
||||
}
|
||||
|
||||
$this->model = User::where($accountType, $username)->find();
|
||||
if (!$this->model) {
|
||||
$this->setError('Account not exist');
|
||||
return false;
|
||||
@@ -200,7 +207,7 @@ class Auth extends \ba\Auth
|
||||
if ($this->model->login_failure > 0 && $lastLoginTs > 0 && time() - $lastLoginTs >= 86400) {
|
||||
$this->model->login_failure = 0;
|
||||
$this->model->save();
|
||||
$this->model = User::where($accountType, $username)->find();
|
||||
$this->model = User::find($this->model->id);
|
||||
}
|
||||
if ($this->model->login_failure >= $userLoginRetry) {
|
||||
$this->setError('Please try again after 1 day');
|
||||
|
||||
29
app/common/library/MalaysiaMobilePhone.php
Normal file
29
app/common/library/MalaysiaMobilePhone.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
/**
|
||||
* 马来西亚手机号(60 国际前缀,不含 +)
|
||||
*/
|
||||
class MalaysiaMobilePhone
|
||||
{
|
||||
public const PATTERN = '/^60(1[0-9])\d{7,9}$/';
|
||||
|
||||
public static function isValid(string $phone): bool
|
||||
{
|
||||
return $phone !== '' && preg_match(self::PATTERN, $phone) === 1;
|
||||
}
|
||||
|
||||
public static function maskInNickname(string $text): string
|
||||
{
|
||||
$masked = preg_replace_callback(
|
||||
self::PATTERN,
|
||||
static fn(array $matches): string => substr($matches[0], 0, 3) . '****' . substr($matches[0], -4),
|
||||
$text
|
||||
);
|
||||
|
||||
return is_string($masked) ? $masked : $text;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,158 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\admin;
|
||||
|
||||
use app\common\service\GameWebSocketAuthHelper;
|
||||
use Webman\Http\Request;
|
||||
|
||||
final class WebSocketConfigHelper
|
||||
{
|
||||
public static function wsUrl(): string
|
||||
public static function wsUrl(?Request $request = null): string
|
||||
{
|
||||
$url = trim((string) env('H5_WEBSOCKET_URL', ''));
|
||||
if ($url !== '' && $request !== null && self::isLoopbackWsUrl($url) && !self::isLoopbackRequestHost($request)) {
|
||||
$url = '';
|
||||
}
|
||||
if ($url !== '') {
|
||||
return $url;
|
||||
}
|
||||
return 'ws://127.0.0.1:3131';
|
||||
|
||||
if ($request !== null) {
|
||||
$proto = strtolower((string) $request->header('x-forwarded-proto', ''));
|
||||
if ($proto === '') {
|
||||
$proto = strtolower((string) $request->header('x-forwarded-protocol', ''));
|
||||
}
|
||||
$isHttps = $proto === 'https'
|
||||
|| strtolower((string) $request->header('x-forwarded-ssl', '')) === 'on'
|
||||
|| strtolower((string) $request->header('x-scheme', '')) === 'https';
|
||||
$scheme = $isHttps ? 'wss' : 'ws';
|
||||
|
||||
$host = trim((string) $request->header('host', ''));
|
||||
if ($host === '') {
|
||||
$host = trim((string) $request->header('x-forwarded-host', ''));
|
||||
}
|
||||
if ($host !== '') {
|
||||
return $scheme . '://' . $host . '/ws/';
|
||||
}
|
||||
}
|
||||
|
||||
return 'ws://127.0.0.1:3131/ws/';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在基础 ws_url 上拼接握手鉴权 Query(**输出统一为连字符**:auth-token、user-token、admin-ws-token)。
|
||||
*
|
||||
* $tokens 的键可使用标准名或旧名下划线别名(auth_token 等),拼到 URL 时一律转为连字符。
|
||||
*
|
||||
* @param array<string, string> $tokens 如 auth-token、user-token、admin-ws-token
|
||||
* @param array<string, string> $extraQuery 如 device_id、lang
|
||||
*/
|
||||
public static function appendTokensToWsUrl(string $wsUrl, array $tokens, array $extraQuery = []): string
|
||||
{
|
||||
$wsUrl = trim($wsUrl);
|
||||
if ($wsUrl === '') {
|
||||
return $wsUrl;
|
||||
}
|
||||
|
||||
$authVal = self::pickTokenFromMap($tokens, [
|
||||
GameWebSocketAuthHelper::QUERY_AUTH_TOKEN,
|
||||
'auth_token',
|
||||
'authToken',
|
||||
]);
|
||||
$userVal = self::pickTokenFromMap($tokens, [
|
||||
GameWebSocketAuthHelper::QUERY_USER_TOKEN,
|
||||
'user_token',
|
||||
'userToken',
|
||||
'token',
|
||||
]);
|
||||
$adminVal = self::pickTokenFromMap($tokens, [
|
||||
GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN,
|
||||
'admin_ws_token',
|
||||
'adminWsToken',
|
||||
]);
|
||||
|
||||
$canonical = [];
|
||||
if ($authVal !== '') {
|
||||
$canonical[GameWebSocketAuthHelper::QUERY_AUTH_TOKEN] = $authVal;
|
||||
}
|
||||
if ($userVal !== '') {
|
||||
$canonical[GameWebSocketAuthHelper::QUERY_USER_TOKEN] = $userVal;
|
||||
}
|
||||
if ($adminVal !== '') {
|
||||
$canonical[GameWebSocketAuthHelper::QUERY_ADMIN_WS_TOKEN] = $adminVal;
|
||||
}
|
||||
|
||||
foreach ($extraQuery as $k => $v) {
|
||||
if (!is_string($k) || $k === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_scalar($v)) {
|
||||
continue;
|
||||
}
|
||||
$s = trim((string) $v);
|
||||
if ($s !== '') {
|
||||
$canonical[$k] = $s;
|
||||
}
|
||||
}
|
||||
|
||||
if ($canonical === []) {
|
||||
return $wsUrl;
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
foreach ($canonical as $key => $val) {
|
||||
$pairs[] = $key . '=' . rawurlencode($val);
|
||||
}
|
||||
$sep = str_contains($wsUrl, '?') ? '&' : '?';
|
||||
|
||||
return $wsUrl . $sep . implode('&', $pairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tokens
|
||||
* @param list<string> $aliases
|
||||
*/
|
||||
private static function pickTokenFromMap(array $tokens, array $aliases): string
|
||||
{
|
||||
foreach ($aliases as $key) {
|
||||
if (!isset($tokens[$key])) {
|
||||
continue;
|
||||
}
|
||||
$val = $tokens[$key];
|
||||
if (!is_string($val)) {
|
||||
continue;
|
||||
}
|
||||
$s = trim($val);
|
||||
if ($s !== '') {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function isLoopbackWsUrl(string $url): bool
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (!is_string($host) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower($host);
|
||||
|
||||
return in_array($host, ['127.0.0.1', 'localhost', '::1'], true);
|
||||
}
|
||||
|
||||
private static function isLoopbackRequestHost(Request $request): bool
|
||||
{
|
||||
$host = strtolower(trim((string) $request->host(true)));
|
||||
if ($host === '') {
|
||||
$host = strtolower(trim((string) $request->header('host', '')));
|
||||
}
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
$hostOnly = preg_split('/:/', $host)[0] ?? $host;
|
||||
|
||||
return in_array($hostOnly, ['127.0.0.1', 'localhost', '::1'], true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
82
app/common/library/docs/MarkdownDocReader.php
Normal file
82
app/common/library/docs/MarkdownDocReader.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\docs;
|
||||
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 按请求语言读取 docs 目录下的 Markdown 文件(英文缺失时回退中文)。
|
||||
*/
|
||||
final class MarkdownDocReader
|
||||
{
|
||||
/**
|
||||
* @param array{relative:string,filename:string,ascii_fallback?:string} $zh
|
||||
* @param array{relative:string,filename:string,ascii_fallback?:string} $en
|
||||
* @return array{path:string,filename:string,ascii_fallback:string,lang:string}
|
||||
*/
|
||||
public static function resolve(Request $request, array $zh, array $en): array
|
||||
{
|
||||
$lang = self::resolveLang($request);
|
||||
$useEn = $lang === 'en';
|
||||
$spec = $useEn ? $en : $zh;
|
||||
$base = rtrim(base_path(), DIRECTORY_SEPARATOR . '/');
|
||||
$path = $base . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $spec['relative']);
|
||||
if ($useEn && !is_file($path)) {
|
||||
$spec = $zh;
|
||||
$path = $base . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $zh['relative']);
|
||||
$lang = 'zh-cn';
|
||||
}
|
||||
$filename = strval($spec['filename'] ?? 'document.md');
|
||||
$asciiFallback = strval($spec['ascii_fallback'] ?? $filename);
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'filename' => $filename,
|
||||
'ascii_fallback' => $asciiFallback,
|
||||
'lang' => $lang,
|
||||
];
|
||||
}
|
||||
|
||||
public static function resolveLang(Request $request): string
|
||||
{
|
||||
$thinkRaw = $request->header('think-lang', '');
|
||||
$thinkLang = is_string($thinkRaw) ? trim($thinkRaw) : '';
|
||||
if ($thinkLang === '' && is_array($thinkRaw) && isset($thinkRaw[0]) && is_string($thinkRaw[0])) {
|
||||
$thinkLang = trim($thinkRaw[0]);
|
||||
}
|
||||
|
||||
$queryRaw = $request->get('lang');
|
||||
if ($queryRaw === null || $queryRaw === '') {
|
||||
$queryRaw = $request->post('lang');
|
||||
}
|
||||
$queryLang = is_string($queryRaw) ? trim($queryRaw) : (is_scalar($queryRaw) ? trim(strval($queryRaw)) : '');
|
||||
|
||||
$headerRaw = $request->header('lang', '');
|
||||
$headerLang = is_string($headerRaw) ? trim($headerRaw) : '';
|
||||
|
||||
$normalize = static function (string $raw): string {
|
||||
$s = str_replace('_', '-', strtolower(trim($raw)));
|
||||
if ($s === 'zh') {
|
||||
return 'zh-cn';
|
||||
}
|
||||
return $s;
|
||||
};
|
||||
|
||||
foreach ([$thinkLang, $headerLang, $queryLang] as $candidate) {
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
$normalized = $normalize($candidate);
|
||||
if ($normalized === 'en') {
|
||||
return 'en';
|
||||
}
|
||||
if ($normalized === 'zh-cn') {
|
||||
return 'zh-cn';
|
||||
}
|
||||
}
|
||||
|
||||
return 'zh-cn';
|
||||
}
|
||||
}
|
||||
372
app/common/library/finance/DDPayGateway.php
Normal file
372
app/common/library/finance/DDPayGateway.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\finance;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* DDPay 支付网关接入(基于文档 v1.1.3/1.1.x):
|
||||
* - MD5 签名:key-value 按 ASCII 升序拼接 + 追加 &key=API_SECRET
|
||||
* - 发送 HTTPS POST(application/json)
|
||||
* - 支持入金(Deposit)与回调通知(Webhook)
|
||||
*
|
||||
* 注意:生产环境需在 config/app.php 或环境变量中配置 DDPAY_*。
|
||||
*/
|
||||
final class DDPayGateway
|
||||
{
|
||||
private const SIGNATURE_FIELD = 'signature';
|
||||
private const SECRET_KEY_PARAM = 'key';
|
||||
|
||||
/**
|
||||
* 入金/出金回调 URL 使用的公网根地址(无尾部斜杠)。
|
||||
* 优先 `DDPAY_PUBLIC_BASE_URL`(见 config/app.php);未配置时按请求头推导(生产务必配置 HTTPS 公网地址,与 DDPay 文档一致)。
|
||||
*/
|
||||
public static function publicBaseUrlForCallbacks(?Request $request = null): string
|
||||
{
|
||||
$cfg = config('app.ddpay_public_base_url', '');
|
||||
if (is_string($cfg) && trim($cfg) !== '') {
|
||||
return rtrim(trim($cfg), '/');
|
||||
}
|
||||
if ($request === null) {
|
||||
return '';
|
||||
}
|
||||
$proto = strtolower((string) $request->header('x-forwarded-proto', ''));
|
||||
$https = $proto === 'https' || strtolower((string) $request->header('x-forwarded-ssl', '')) === 'on';
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = trim((string) $request->header('host', ''));
|
||||
if ($host === '') {
|
||||
$host = trim((string) ($request->header('x-forwarded-host', '')));
|
||||
}
|
||||
if ($host === '') {
|
||||
$host = '127.0.0.1:8787';
|
||||
}
|
||||
|
||||
return $scheme . '://' . $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已配置 DDPay 入金所需项(未配置时不应调用三方接口)
|
||||
*/
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
$envMap = [
|
||||
'ddpay_client_id' => 'DDPAY_CLIENT_ID',
|
||||
'ddpay_identifier' => 'DDPAY_IDENTIFIER',
|
||||
'ddpay_api_secret' => 'DDPAY_API_SECRET',
|
||||
'ddpay_deposit_init_url' => 'DDPAY_DEPOSIT_INIT_URL',
|
||||
];
|
||||
foreach ($envMap as $cfgKey => $envKey) {
|
||||
$v = getenv($envKey);
|
||||
if (!is_string($v) || trim($v) === '') {
|
||||
$cfg = config('app.' . $cfgKey, '');
|
||||
if (!is_string($cfg) || trim($cfg) === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function depositInitiation(array $params): array
|
||||
{
|
||||
$endpoint = self::requireConfig('ddpay_deposit_init_url');
|
||||
$apiSecret = self::requireConfig('ddpay_api_secret');
|
||||
|
||||
$req = $params;
|
||||
if (isset($req[self::SIGNATURE_FIELD])) {
|
||||
unset($req[self::SIGNATURE_FIELD]);
|
||||
}
|
||||
|
||||
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
|
||||
$resp = self::postJson($endpoint, $req);
|
||||
|
||||
// 按文档:响应签名需使用同方法校验(字段名 signature)
|
||||
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
|
||||
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
|
||||
if ($sig !== '') {
|
||||
$respForSign = $resp;
|
||||
unset($respForSign[self::SIGNATURE_FIELD]);
|
||||
$expected = self::computeSignature($respForSign, $apiSecret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
throw new RuntimeException('DDPay response signature mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statusCode = self::intValue($resp['status_code'] ?? 0);
|
||||
if ($statusCode !== 0) {
|
||||
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay deposit initiation failed';
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出金发起(Payout Initiation)
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function payoutInitiation(array $params): array
|
||||
{
|
||||
$endpoint = self::requireConfig('ddpay_payout_init_url');
|
||||
$apiSecret = self::requireConfig('ddpay_api_secret');
|
||||
|
||||
$req = $params;
|
||||
if (isset($req[self::SIGNATURE_FIELD])) {
|
||||
unset($req[self::SIGNATURE_FIELD]);
|
||||
}
|
||||
|
||||
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
|
||||
$resp = self::postJson($endpoint, $req);
|
||||
|
||||
// 按文档:响应签名需使用同方法校验(字段名 signature)
|
||||
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
|
||||
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
|
||||
if ($sig !== '') {
|
||||
$respForSign = $resp;
|
||||
unset($respForSign[self::SIGNATURE_FIELD]);
|
||||
$expected = self::computeSignature($respForSign, $apiSecret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
throw new RuntimeException('DDPay response signature mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statusCode = self::intValue($resp['status_code'] ?? 0);
|
||||
if ($statusCode !== 0) {
|
||||
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay payout initiation failed';
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出金状态查询(Payout Status Inquiry)
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function payoutStatusInquiry(array $params): array
|
||||
{
|
||||
$endpoint = self::requireConfig('ddpay_payout_status_url');
|
||||
$apiSecret = self::requireConfig('ddpay_api_secret');
|
||||
|
||||
$req = $params;
|
||||
if (isset($req[self::SIGNATURE_FIELD])) {
|
||||
unset($req[self::SIGNATURE_FIELD]);
|
||||
}
|
||||
$req[self::SIGNATURE_FIELD] = self::computeSignature($req, $apiSecret);
|
||||
$resp = self::postJson($endpoint, $req);
|
||||
|
||||
if (array_key_exists(self::SIGNATURE_FIELD, $resp)) {
|
||||
$sig = is_string($resp[self::SIGNATURE_FIELD]) ? $resp[self::SIGNATURE_FIELD] : '';
|
||||
if ($sig !== '') {
|
||||
$respForSign = $resp;
|
||||
unset($respForSign[self::SIGNATURE_FIELD]);
|
||||
$expected = self::computeSignature($respForSign, $apiSecret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
throw new RuntimeException('DDPay response signature mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statusCode = self::intValue($resp['status_code'] ?? 0);
|
||||
if ($statusCode !== 0) {
|
||||
$msg = is_string($resp['status_message'] ?? null) ? $resp['status_message'] : 'DDPay payout status inquiry failed';
|
||||
throw new RuntimeException($msg);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 DDPay Webhook 通知签名。
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function verifyWebhookSignature(array $payload): bool
|
||||
{
|
||||
$apiSecret = self::requireConfig('ddpay_api_secret');
|
||||
$sigRaw = $payload[self::SIGNATURE_FIELD] ?? '';
|
||||
$sig = is_string($sigRaw) ? trim($sigRaw) : '';
|
||||
if ($sig === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payloadForSign = $payload;
|
||||
unset($payloadForSign[self::SIGNATURE_FIELD]);
|
||||
$expected = self::computeSignature($payloadForSign, $apiSecret);
|
||||
|
||||
return hash_equals($expected, $sig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private static function computeSignature(array $params, string $apiSecret): string
|
||||
{
|
||||
// 1) 排除 signature & 空值/null
|
||||
$filtered = [];
|
||||
foreach ($params as $k => $v) {
|
||||
if (!is_string($k) || $k === '') {
|
||||
continue;
|
||||
}
|
||||
if ($k === self::SIGNATURE_FIELD) {
|
||||
continue;
|
||||
}
|
||||
if ($v === null) {
|
||||
continue;
|
||||
}
|
||||
if (is_string($v) && trim($v) === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_bool($v)) {
|
||||
$filtered[$k] = $v ? 'true' : 'false';
|
||||
continue;
|
||||
}
|
||||
if (is_int($v) || is_float($v) || is_numeric($v)) {
|
||||
$filtered[$k] = strval($v);
|
||||
continue;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
$filtered[$k] = trim($v);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 数组/对象等不应参与签名;忽略它们
|
||||
if (is_array($v) || is_object($v)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[$k] = strval($v);
|
||||
}
|
||||
|
||||
// 2) 按 ASCII 升序排序 key
|
||||
ksort($filtered, SORT_STRING);
|
||||
|
||||
// 3) 拼接 key=value,用 & 连接
|
||||
$pairs = [];
|
||||
foreach ($filtered as $k => $v) {
|
||||
$pairs[] = $k . '=' . $v;
|
||||
}
|
||||
|
||||
// 4) 追加 &key=API_SECRET
|
||||
$base = implode('&', $pairs);
|
||||
$signStr = $base . '&' . self::SECRET_KEY_PARAM . '=' . $apiSecret;
|
||||
|
||||
// 5) MD5 小写
|
||||
$hash = md5($signStr);
|
||||
return is_string($hash) ? strtolower($hash) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function postJson(string $url, array $payload): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('curl extension is required for DDPayGateway');
|
||||
}
|
||||
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($body) || $body === '') {
|
||||
throw new RuntimeException('DDPay request body encode failed');
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new RuntimeException('DDPay curl_init failed');
|
||||
}
|
||||
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'Accept: application/json',
|
||||
]);
|
||||
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$respBody = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($respBody === false) {
|
||||
throw new RuntimeException('DDPay request failed: ' . ($errno > 0 ? 'curl_' . strval($errno) : 'unknown'));
|
||||
}
|
||||
if (!is_numeric($httpCode)) {
|
||||
throw new RuntimeException('DDPay request http code invalid');
|
||||
}
|
||||
$httpCodeInt = intval(strval($httpCode));
|
||||
if ($httpCodeInt < 200 || $httpCodeInt >= 300) {
|
||||
$snippet = '';
|
||||
if (is_string($respBody)) {
|
||||
$snippet = trim($respBody);
|
||||
if (mb_strlen($snippet) > 400) {
|
||||
$snippet = mb_substr($snippet, 0, 400) . '...';
|
||||
}
|
||||
}
|
||||
$suffix = $snippet !== '' ? (' body=' . $snippet) : '';
|
||||
throw new RuntimeException('DDPay request http failed, http_code=' . strval($httpCodeInt) . $suffix);
|
||||
}
|
||||
|
||||
$decoded = json_decode(is_string($respBody) ? $respBody : '', true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('DDPay response decode failed');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
private static function requireConfig(string $key): mixed
|
||||
{
|
||||
$v = config('app.' . $key, '');
|
||||
if (!is_string($v)) {
|
||||
return '';
|
||||
}
|
||||
$s = trim($v);
|
||||
if ($s === '') {
|
||||
throw new InvalidArgumentException('Missing config: app.' . $key);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $v
|
||||
*/
|
||||
private static function intValue(mixed $v): int
|
||||
{
|
||||
if (is_int($v)) {
|
||||
return $v;
|
||||
}
|
||||
if (is_string($v) && $v !== '') {
|
||||
$n = filter_var($v, FILTER_VALIDATE_INT);
|
||||
return $n === false ? 0 : intval(strval($n));
|
||||
}
|
||||
if (is_numeric($v)) {
|
||||
$n = filter_var($v, FILTER_VALIDATE_INT);
|
||||
return $n === false ? 0 : intval(strval($n));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\finance;
|
||||
|
||||
/**
|
||||
* 模拟第三方支付:HMAC 签名的收银台地址 + 回调验签,便于未来替换为真实网关仅改 URL/验签实现。
|
||||
*/
|
||||
final class DepositMockGateway
|
||||
{
|
||||
/**
|
||||
* 优先读取环境变量 DEPOSIT_MOCK_HMAC_KEY,其次 config('app.deposit_mock_hmac_key'),再使用开发默认值(生产环境务必设置 env)。
|
||||
*/
|
||||
public static function hmacKey(): string
|
||||
{
|
||||
$raw = getenv('DEPOSIT_MOCK_HMAC_KEY');
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
return trim($raw);
|
||||
}
|
||||
$cfg = config('app.deposit_mock_hmac_key', '');
|
||||
if (is_string($cfg) && $cfg !== '') {
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
return 'webman-dfw-deposit-mock-dev-key-set-DEPOSIT_MOCK_HMAC_KEY-in-prod';
|
||||
}
|
||||
|
||||
public static function signOrderNo(string $orderNo): string
|
||||
{
|
||||
if ($orderNo === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $orderNo, self::hmacKey());
|
||||
}
|
||||
|
||||
public static function verifyOrderNo(string $orderNo, string $sign): bool
|
||||
{
|
||||
if ($orderNo === '' || $sign === '') {
|
||||
return false;
|
||||
}
|
||||
$e = self::signOrderNo($orderNo);
|
||||
if ($e === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($e, $sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家浏览器打开的「第三方收银台」地址(本项目中为简单 HTML 模拟页,点击后向 notify 发起 POST 完成入账)。
|
||||
*
|
||||
* @param string|null $publicOrigin 如 https://api.example.com;为 null 时只返回以 / 开头的 path+query,由客户端与 API 域名拼接
|
||||
*/
|
||||
public static function payPageUrl(string $orderNo, ?string $publicOrigin = null): string
|
||||
{
|
||||
$sign = self::signOrderNo($orderNo);
|
||||
$q = http_build_query([
|
||||
'order_no' => $orderNo,
|
||||
'sign' => $sign,
|
||||
]);
|
||||
$path = '/api/finance/depositMockPayPage?' . $q;
|
||||
if ($publicOrigin === null) {
|
||||
return $path;
|
||||
}
|
||||
$base = rtrim($publicOrigin, '/');
|
||||
|
||||
return $base . $path;
|
||||
}
|
||||
}
|
||||
@@ -57,17 +57,17 @@ final class DepositSettlement
|
||||
?string $extraRemark = null
|
||||
): array {
|
||||
if ($orderId <= 0) {
|
||||
throw new RuntimeException('订单 ID 非法');
|
||||
throw new RuntimeException('Order id is invalid');
|
||||
}
|
||||
|
||||
$order = Db::name('deposit_order')->where('id', $orderId)->find();
|
||||
if (!$order) {
|
||||
throw new RuntimeException('订单不存在');
|
||||
throw new RuntimeException('Order does not exist');
|
||||
}
|
||||
|
||||
$orderNo = is_string($order['order_no']) ? $order['order_no'] : strval($order['order_no']);
|
||||
if ($orderNo === '') {
|
||||
throw new RuntimeException('订单号为空');
|
||||
throw new RuntimeException('Order number is empty');
|
||||
}
|
||||
|
||||
$statusRaw = $order['status'] ?? 0;
|
||||
@@ -96,13 +96,13 @@ final class DepositSettlement
|
||||
];
|
||||
}
|
||||
|
||||
if ($status !== 0) {
|
||||
throw new RuntimeException('订单状态不允许结算');
|
||||
if ($status !== 0 && $status !== 3) {
|
||||
throw new RuntimeException('Order status does not allow settlement');
|
||||
}
|
||||
|
||||
$amount = self::amountString($order['amount'] ?? '0');
|
||||
if (bccomp($amount, '0', 2) <= 0) {
|
||||
throw new RuntimeException('订单金额异常');
|
||||
throw new RuntimeException('Order amount is invalid');
|
||||
}
|
||||
$bonus = self::amountString($order['bonus_amount'] ?? '0');
|
||||
if (bccomp($bonus, '0', 2) < 0) {
|
||||
@@ -112,12 +112,12 @@ final class DepositSettlement
|
||||
|
||||
$userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0;
|
||||
if ($userId <= 0) {
|
||||
throw new RuntimeException('订单所属玩家无效');
|
||||
throw new RuntimeException('Order user is invalid');
|
||||
}
|
||||
|
||||
$user = Db::name('user')->where('id', $userId)->find();
|
||||
if (!$user) {
|
||||
throw new RuntimeException('玩家不存在');
|
||||
throw new RuntimeException('User does not exist');
|
||||
}
|
||||
|
||||
$channelId = is_numeric($order['channel_id'] ?? null) ? intval($order['channel_id']) : null;
|
||||
@@ -139,9 +139,10 @@ final class DepositSettlement
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$statusBefore = $status === 3 ? 3 : 0;
|
||||
$affected = Db::name('deposit_order')
|
||||
->where('id', $orderId)
|
||||
->where('status', 0)
|
||||
->where('status', $statusBefore)
|
||||
->update([
|
||||
'status' => 1,
|
||||
'pay_time' => $now,
|
||||
@@ -149,7 +150,7 @@ final class DepositSettlement
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($affected <= 0) {
|
||||
throw new RuntimeException('订单状态已变更,请刷新后重试');
|
||||
throw new RuntimeException('Order state changed, please refresh and retry');
|
||||
}
|
||||
|
||||
Db::name('user')->where('id', $userId)->update([
|
||||
@@ -184,13 +185,13 @@ final class DepositSettlement
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
|
||||
GameWebSocketEventBus::publish('wallet.changed', [
|
||||
GameWebSocketEventBus::publish('wallet.changed', \app\common\service\GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||
'user_id' => $userId,
|
||||
'balance_after' => $balanceAfter,
|
||||
'biz_type' => 'deposit',
|
||||
'order_no' => $orderNo,
|
||||
'changed_at' => $now,
|
||||
]);
|
||||
], $userId));
|
||||
|
||||
return [
|
||||
'order_id' => $orderId,
|
||||
|
||||
407
app/common/library/finance/MockPay.php
Normal file
407
app/common/library/finance/MockPay.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
|
||||
namespace app\common\library\finance;
|
||||
|
||||
use app\common\service\DepositOrderExpireService;
|
||||
|
||||
/**
|
||||
|
||||
* 模拟支付(无真实商户网关):用于开发/联调充值与提现审核。
|
||||
|
||||
*/
|
||||
|
||||
final class MockPay
|
||||
|
||||
{
|
||||
|
||||
public const CHANNEL_CODE = 'mock';
|
||||
|
||||
|
||||
|
||||
/** 待审核(用户已在模拟页确认支付,等待后台审核) */
|
||||
|
||||
public const DEPOSIT_STATUS_PENDING_REVIEW = 3;
|
||||
|
||||
|
||||
|
||||
public static function isEnabled(): bool
|
||||
|
||||
{
|
||||
|
||||
$raw = getenv('FINANCE_MOCK_PAY_ENABLED');
|
||||
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
|
||||
$norm = strtolower(trim($raw));
|
||||
|
||||
if (in_array($norm, ['0', 'false', 'no', 'off'], true)) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
if (in_array($norm, ['1', 'true', 'yes', 'on'], true)) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$cfg = config('app.finance_mock_pay_enabled', null);
|
||||
|
||||
if ($cfg === true) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
if ($cfg === false) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$debugRaw = getenv('APP_DEBUG');
|
||||
|
||||
if (is_string($debugRaw) && trim($debugRaw) !== '') {
|
||||
|
||||
return in_array(strtolower(trim($debugRaw)), ['1', 'true', 'yes', 'on'], true);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现审核后是否走模拟出金(不调用 DDPay)。
|
||||
* - pay_channel=mock:始终模拟;
|
||||
* - FINANCE_MOCK_PAY_ENABLED 开启:ddpay/空 一律模拟(审核通过即成功);
|
||||
* - 未开启 mock 且未配置 DDPay:ddpay/空 也模拟,避免误调网关。
|
||||
*/
|
||||
public static function shouldSimulateWithdrawPayout(string $payChannel): bool
|
||||
{
|
||||
$ch = strtolower(trim($payChannel));
|
||||
if ($ch === self::CHANNEL_CODE) {
|
||||
return true;
|
||||
}
|
||||
if ($ch !== '' && $ch !== 'ddpay') {
|
||||
return false;
|
||||
}
|
||||
if (self::isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !DDPayGateway::isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* 计算链接过期时间与签名(防猜单号)
|
||||
|
||||
*
|
||||
|
||||
* @return array{expire_at: int, sign: string}
|
||||
|
||||
*/
|
||||
|
||||
public static function buildDepositLinkAuth(string $orderNo, int $createTime): array
|
||||
|
||||
{
|
||||
|
||||
$expireAt = $createTime + DepositOrderExpireService::pendingExpireSeconds();
|
||||
|
||||
|
||||
|
||||
return [
|
||||
|
||||
'expire_at' => $expireAt,
|
||||
|
||||
'sign' => self::signDepositLink($orderNo, $expireAt),
|
||||
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function signDepositLink(string $orderNo, int $expireAt): string
|
||||
|
||||
{
|
||||
|
||||
$params = [
|
||||
|
||||
'expire_at' => strval($expireAt),
|
||||
|
||||
'order_no' => $orderNo,
|
||||
|
||||
'secret' => self::linkSecret(),
|
||||
|
||||
];
|
||||
|
||||
ksort($params);
|
||||
|
||||
$pairs = [];
|
||||
|
||||
foreach ($params as $key => $value) {
|
||||
|
||||
$pairs[] = $key . '=' . $value;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return strtoupper(md5(implode('&', $pairs)));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function verifyDepositLink(string $orderNo, int $expireAt, string $sign): bool
|
||||
|
||||
{
|
||||
|
||||
$signNorm = strtoupper(trim($sign));
|
||||
|
||||
if ($signNorm === '' || $orderNo === '' || $expireAt <= 0) {
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return hash_equals(self::signDepositLink($orderNo, $expireAt), $signNorm);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 前端静态收银台 URL(优先于服务端内联页)
|
||||
|
||||
*
|
||||
|
||||
* @param string $amountDisplay 2 位小数字符串,供页面展示
|
||||
|
||||
* @param string $bonusDisplay 2 位小数字符串
|
||||
|
||||
*/
|
||||
|
||||
public static function depositPageUrl(
|
||||
|
||||
string $orderNo,
|
||||
|
||||
string $publicOrigin,
|
||||
|
||||
int $expireAt,
|
||||
|
||||
string $sign,
|
||||
|
||||
string $amountDisplay = '',
|
||||
|
||||
string $bonusDisplay = ''
|
||||
|
||||
): string {
|
||||
|
||||
$htmlBase = self::resolveHtmlBase($publicOrigin);
|
||||
|
||||
$apiBase = rtrim($publicOrigin, '/');
|
||||
|
||||
|
||||
|
||||
$query = [
|
||||
|
||||
'order_no' => $orderNo,
|
||||
|
||||
'expire_at' => strval($expireAt),
|
||||
|
||||
'sign' => $sign,
|
||||
|
||||
'api_base' => $apiBase,
|
||||
|
||||
];
|
||||
|
||||
if ($amountDisplay !== '') {
|
||||
|
||||
$query['amount'] = $amountDisplay;
|
||||
|
||||
}
|
||||
|
||||
if ($bonusDisplay !== '') {
|
||||
|
||||
$query['bonus'] = $bonusDisplay;
|
||||
|
||||
}
|
||||
|
||||
$returnGameUrl = self::resolveGameReturnUrl();
|
||||
|
||||
if ($returnGameUrl !== '') {
|
||||
|
||||
$query['return_game_url'] = $returnGameUrl;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return $htmlBase . '/mock-deposit.html?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 模拟页内确认支付接口(无需 auth-token;须携带 sign + expire_at)
|
||||
|
||||
*/
|
||||
|
||||
public static function depositConfirmUrl(string $orderNo, string $publicOrigin, int $expireAt, string $sign): string
|
||||
|
||||
{
|
||||
|
||||
$base = rtrim($publicOrigin, '/');
|
||||
|
||||
$query = http_build_query([
|
||||
|
||||
'order_no' => $orderNo,
|
||||
|
||||
'expire_at' => strval($expireAt),
|
||||
|
||||
'sign' => $sign,
|
||||
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
|
||||
|
||||
return $base . '/api/finance/mockDepositConfirm?' . $query;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 解析前端静态页根地址:MOCK_DEPOSIT_HTML_BASE > DDPAY_PUBLIC_BASE_URL > API 公网根
|
||||
|
||||
*/
|
||||
|
||||
public static function resolveHtmlBase(string $publicOrigin): string
|
||||
|
||||
{
|
||||
|
||||
$raw = getenv('MOCK_DEPOSIT_HTML_BASE');
|
||||
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
|
||||
return rtrim(trim($raw), '/');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$ddpayPublic = getenv('DDPAY_PUBLIC_BASE_URL');
|
||||
|
||||
if (is_string($ddpayPublic) && trim($ddpayPublic) !== '') {
|
||||
|
||||
return rtrim(trim($ddpayPublic), '/');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$cfg = config('app.ddpay_public_base_url', '');
|
||||
|
||||
if (is_string($cfg) && trim($cfg) !== '') {
|
||||
|
||||
return rtrim(trim($cfg), '/');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return rtrim($publicOrigin, '/');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 模拟充值完成后「返回游戏」跳转地址(H5_GAME_URL)
|
||||
|
||||
*/
|
||||
|
||||
public static function resolveGameReturnUrl(): string
|
||||
|
||||
{
|
||||
|
||||
$raw = getenv('H5_GAME_URL');
|
||||
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
|
||||
return trim($raw);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
$cfg = config('app.h5_game_url', '');
|
||||
|
||||
if (is_string($cfg) && trim($cfg) !== '') {
|
||||
|
||||
return trim($cfg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return '';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static function linkSecret(): string
|
||||
|
||||
{
|
||||
|
||||
$raw = getenv('FINANCE_MOCK_PAY_LINK_SECRET');
|
||||
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
|
||||
return trim($raw);
|
||||
|
||||
}
|
||||
|
||||
$auth = getenv('AUTH_TOKEN_SECRET');
|
||||
|
||||
if (is_string($auth) && trim($auth) !== '') {
|
||||
|
||||
return trim($auth);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return 'mock-deposit-link-dev-secret';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
148
app/common/library/game/BetChips.php
Normal file
148
app/common/library/game/BetChips.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\game;
|
||||
|
||||
use app\common\service\GameHotDataRedis;
|
||||
|
||||
/**
|
||||
* 移动端快捷筹码(固定 1–6 档)及默认选中档位,对应 game_config:`bet_chips`(JSON)、`default_bet_chip_id`(int)。
|
||||
*/
|
||||
final class BetChips
|
||||
{
|
||||
public const CONFIG_KEY_CHIPS = 'bet_chips';
|
||||
|
||||
public const CONFIG_KEY_DEFAULT_ID = 'default_bet_chip_id';
|
||||
|
||||
/**
|
||||
* @return array<int, string> 键 1..6 => 两位小数字符串面额
|
||||
*/
|
||||
public static function defaultChipAmounts(): array
|
||||
{
|
||||
return [
|
||||
1 => '1.00',
|
||||
2 => '5.00',
|
||||
3 => '10.00',
|
||||
4 => '25.00',
|
||||
5 => '50.00',
|
||||
6 => '100.00',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{map: array<int, string>, default_id: int}
|
||||
*/
|
||||
public static function resolveFromHotData(): array
|
||||
{
|
||||
$rowChips = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY_CHIPS);
|
||||
$rowDefault = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY_DEFAULT_ID);
|
||||
$rawJson = $rowChips !== null ? ($rowChips['config_value'] ?? null) : null;
|
||||
$map = self::parseChipsJson($rawJson);
|
||||
|
||||
$defaultRaw = $rowDefault !== null ? ($rowDefault['config_value'] ?? null) : null;
|
||||
$defaultId = filter_var(trim('' . $defaultRaw), FILTER_VALIDATE_INT);
|
||||
if ($defaultId === false || $defaultId < 1 || $defaultId > 6) {
|
||||
$defaultId = 1;
|
||||
}
|
||||
$amt = $map[$defaultId] ?? null;
|
||||
if ($amt === null || !is_numeric($amt) || bccomp(bcadd($amt, '0', 2), '0', 2) <= 0) {
|
||||
$defaultId = self::firstPositiveBetId($map);
|
||||
}
|
||||
|
||||
return ['map' => $map, 'default_id' => $defaultId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $map
|
||||
*/
|
||||
public static function amountForBetId(int $betId, array $map): ?string
|
||||
{
|
||||
if ($betId < 1 || $betId > 6) {
|
||||
return null;
|
||||
}
|
||||
if (!isset($map[$betId])) {
|
||||
return null;
|
||||
}
|
||||
$amt = $map[$betId];
|
||||
if (!is_numeric($amt) || bccomp(bcadd($amt, '0', 2), '0', 2) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bcadd($amt, '0', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* lobbyInit 用:筹码为字典,键为标识字符串 `"1"`…`"6"`,值为两位小数字符串面额。
|
||||
*
|
||||
* @return array{chips: array<string, string>, default_bet_chip_id: int}
|
||||
*/
|
||||
public static function lobbyChipsPayload(): array
|
||||
{
|
||||
$resolved = self::resolveFromHotData();
|
||||
$chips = [];
|
||||
foreach ($resolved['map'] as $id => $amount) {
|
||||
$chips['' . $id] = $amount;
|
||||
}
|
||||
|
||||
return [
|
||||
'chips' => $chips,
|
||||
'default_bet_chip_id' => $resolved['default_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rawJson
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function parseChipsJson($rawJson): array
|
||||
{
|
||||
$defaults = self::defaultChipAmounts();
|
||||
$parsed = [];
|
||||
if (is_string($rawJson) && $rawJson !== '') {
|
||||
$decoded = json_decode($rawJson, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $k => $v) {
|
||||
$id = filter_var($k, FILTER_VALIDATE_INT);
|
||||
if ($id === false || $id < 1 || $id > 6) {
|
||||
continue;
|
||||
}
|
||||
$amtRaw = trim('' . $v);
|
||||
if (!is_numeric($amtRaw) || bccomp(bcadd($amtRaw, '0', 2), '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$parsed[$id] = bcadd($amtRaw, '0', 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
foreach ($defaults as $id => $def) {
|
||||
if (isset($parsed[$id])) {
|
||||
$out[$id] = $parsed[$id];
|
||||
} else {
|
||||
$out[$id] = $def;
|
||||
}
|
||||
}
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $map
|
||||
*/
|
||||
private static function firstPositiveBetId(array $map): int
|
||||
{
|
||||
for ($i = 1; $i <= 6; $i++) {
|
||||
if (!isset($map[$i])) {
|
||||
continue;
|
||||
}
|
||||
$a = $map[$i];
|
||||
if (is_numeric($a) && bccomp(bcadd($a, '0', 2), '0', 2) > 0) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\game;
|
||||
|
||||
use app\common\library\finance\MockPay;
|
||||
use InvalidArgumentException;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 充值支付渠道:优先读取 game_config.finance_cashier.channels;无此键时回退 game_config.deposit_channel(迁移期镜像)
|
||||
*
|
||||
* 每项:code(须在代码/环境注册表内)、sort、status(0/1)。
|
||||
* 每项:code(须在代码/环境注册表内)、sort、status(0/1)。内置 `ddpay`(DDPay)、`mock`(模拟支付,见 FINANCE_MOCK_PAY_ENABLED)。
|
||||
*
|
||||
* 渠道展示名以代码注册表为准;运营只配置开关与排序,默认兼容全部充值档位。
|
||||
* 渠道展示名以代码注册表为准;运营只配置开关、排序与支持币种,默认兼容全部充值档位。
|
||||
*/
|
||||
final class DepositChannel
|
||||
{
|
||||
@@ -24,7 +25,8 @@ final class DepositChannel
|
||||
public static function codeRegistry(): array
|
||||
{
|
||||
$base = [
|
||||
'directpay' => ['name' => 'DirectPay', 'name_en' => 'DirectPay', 'sort' => 10],
|
||||
'ddpay' => ['name' => 'DDPay', 'name_en' => 'DDPay', 'sort' => 10],
|
||||
'mock' => ['name' => '模拟支付', 'name_en' => 'Mock Pay', 'sort' => 5],
|
||||
];
|
||||
$extra = self::registryFromEnv();
|
||||
foreach ($extra as $code => $meta) {
|
||||
@@ -150,17 +152,55 @@ final class DepositChannel
|
||||
$sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0;
|
||||
$status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1;
|
||||
$status = $status === 1 ? 1 : 0;
|
||||
$currencyCodes = null;
|
||||
if (array_key_exists('currency_codes', $row)) {
|
||||
if (is_array($row['currency_codes'])) {
|
||||
$currencyCodes = self::normalizeCurrencyCodes($row['currency_codes']);
|
||||
} else {
|
||||
$currencyCodes = null;
|
||||
}
|
||||
}
|
||||
$out[] = [
|
||||
'code' => $code,
|
||||
'sort' => $sort,
|
||||
'status' => $status,
|
||||
'tier_ids' => [],
|
||||
// null 表示“兼容全部充值币种”(历史配置默认行为)
|
||||
// 空数组 [] 表示“不支持任何充值币种”(运营可用作显式禁用某币种)
|
||||
'currency_codes' => $currencyCodes,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化为小写/去空并返回大写币种码列表(允许为空数组)。
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeCurrencyCodes(mixed $raw): array
|
||||
{
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($raw as $c) {
|
||||
if (!is_string($c) && !is_numeric($c)) {
|
||||
continue;
|
||||
}
|
||||
$s = is_string($c) ? trim($c) : strval($c);
|
||||
$s = strtoupper($s);
|
||||
if (!preg_match('/^[A-Z0-9]{2,12}$/', $s)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $s;
|
||||
}
|
||||
$out = array_values(array_unique($out));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并注册表与运营覆盖;若库中无覆盖则对注册表内全部渠道启用默认行
|
||||
*
|
||||
@@ -187,6 +227,7 @@ final class DepositChannel
|
||||
'sort' => $sortVal,
|
||||
'status' => 1,
|
||||
'tier_ids' => [],
|
||||
'currency_codes' => null, // 默认兼容全部币种(历史行为)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -235,7 +276,7 @@ final class DepositChannel
|
||||
*
|
||||
* @return list<array{code: string, name: string, sort: int}>
|
||||
*/
|
||||
public static function channelsForTier(string $tierId, array $overrideRows, string $lang): array
|
||||
public static function channelsForTier(string $tierId, array $overrideRows, string $lang, string $fiatCurrencyCode = ''): array
|
||||
{
|
||||
$registry = self::codeRegistry();
|
||||
$out = [];
|
||||
@@ -247,6 +288,12 @@ final class DepositChannel
|
||||
if (!isset($registry[$code])) {
|
||||
continue;
|
||||
}
|
||||
if ($code === MockPay::CHANNEL_CODE && !MockPay::isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
if ($fiatCurrencyCode !== '' && !self::isCurrencyAllowedForRow($row, $fiatCurrencyCode)) {
|
||||
continue;
|
||||
}
|
||||
$meta = $registry[$code];
|
||||
$name = self::pickLangName($meta, $lang);
|
||||
$sortRaw = $row['sort'] ?? 0;
|
||||
@@ -268,6 +315,27 @@ final class DepositChannel
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function isCurrencyAllowedForRow(array $row, string $fiatCurrencyCode): bool
|
||||
{
|
||||
$normCurrency = strtoupper(trim($fiatCurrencyCode));
|
||||
if ($normCurrency === '') {
|
||||
return true;
|
||||
}
|
||||
$cc = $row['currency_codes'] ?? null;
|
||||
if ($cc === null) {
|
||||
// 历史/默认配置:不填则表示兼容全部币种
|
||||
return true;
|
||||
}
|
||||
if (!is_array($cc)) {
|
||||
return true;
|
||||
}
|
||||
// 显式空数组:不支持任何币种
|
||||
if ($cc === []) {
|
||||
return false;
|
||||
}
|
||||
return in_array($normCurrency, $cc, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $meta
|
||||
*/
|
||||
@@ -300,6 +368,22 @@ final class DepositChannel
|
||||
return self::isTierAllowed($row, $tierId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{code: string, sort: int, status: int, tier_ids: list<string>, currency_codes: (list<string>|null)}> $effectiveRows
|
||||
*/
|
||||
public static function assertChannelAllowsCurrency(string $channelCode, string $fiatCurrencyCode, array $effectiveRows): bool
|
||||
{
|
||||
$row = self::findMergedByCode($effectiveRows, $channelCode);
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
if (($row['status'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::isCurrencyAllowedForRow($row, $fiatCurrencyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $effectiveRows
|
||||
*
|
||||
@@ -322,6 +406,77 @@ final class DepositChannel
|
||||
return array_values(array_unique($codes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前服务端已对接自动出金的渠道(与 withdrawCreate 校验一致)
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function withdrawPayoutChannelCodes(): array
|
||||
{
|
||||
$codes = ['ddpay'];
|
||||
if (MockPay::isEnabled()) {
|
||||
$codes[] = MockPay::CHANNEL_CODE;
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $effectiveRows
|
||||
*/
|
||||
public static function assertChannelEnabled(string $channelCode, array $effectiveRows): bool
|
||||
{
|
||||
$row = self::findMergedByCode($effectiveRows, $channelCode);
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($row['status'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现页可选支付渠道(仅返回已启用且已对接出金的渠道)
|
||||
*
|
||||
* @param list<array{code: string, sort: int, status: int, tier_ids: list<string>}> $effectiveRows
|
||||
*
|
||||
* @return list<array{code: string, name: string, sort: int}>
|
||||
*/
|
||||
public static function channelsForWithdraw(array $effectiveRows, string $lang): array
|
||||
{
|
||||
$registry = self::codeRegistry();
|
||||
$allowed = self::withdrawPayoutChannelCodes();
|
||||
$out = [];
|
||||
foreach ($effectiveRows as $row) {
|
||||
if (($row['status'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$code = isset($row['code']) && is_string($row['code']) ? $row['code'] : '';
|
||||
if ($code === '' || !in_array($code, $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($registry[$code])) {
|
||||
continue;
|
||||
}
|
||||
$meta = $registry[$code];
|
||||
$sortRaw = $row['sort'] ?? 0;
|
||||
$sortVal = is_numeric($sortRaw) ? intval($sortRaw) : 0;
|
||||
$out[] = [
|
||||
'code' => $code,
|
||||
'name' => self::pickLangName($meta, $lang),
|
||||
'sort' => $sortVal,
|
||||
];
|
||||
}
|
||||
usort($out, static function (array $a, array $b): int {
|
||||
if ($a['sort'] !== $b['sort']) {
|
||||
return $a['sort'] <=> $b['sort'];
|
||||
}
|
||||
|
||||
return strcmp($a['code'], $b['code']);
|
||||
});
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
*
|
||||
@@ -334,19 +489,19 @@ final class DepositChannel
|
||||
$out = [];
|
||||
foreach ($items as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误');
|
||||
throw new InvalidArgumentException('Row format error');
|
||||
}
|
||||
$code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
|
||||
if ($code === '' || !isset($registry[$code])) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道 code 未注册');
|
||||
throw new InvalidArgumentException('Channel code is not registered');
|
||||
}
|
||||
if (isset($seen[$code])) {
|
||||
throw new InvalidArgumentException('渠道 code 重复:' . $code);
|
||||
throw new InvalidArgumentException('Duplicate channel code');
|
||||
}
|
||||
$seen[$code] = true;
|
||||
$norm = self::normalizeOverrides([array_merge($row, ['code' => $code])]);
|
||||
if ($norm === []) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行渠道数据无效');
|
||||
throw new InvalidArgumentException('Invalid channel row data');
|
||||
}
|
||||
$out[] = $norm[0];
|
||||
}
|
||||
@@ -369,7 +524,7 @@ final class DepositChannel
|
||||
$wrapped = ['channels' => $items];
|
||||
$encoded = json_encode($wrapped, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new InvalidArgumentException('JSON 编码失败');
|
||||
throw new InvalidArgumentException('JSON encode failed');
|
||||
}
|
||||
|
||||
return $encoded;
|
||||
@@ -399,6 +554,7 @@ final class DepositChannel
|
||||
'sort' => $sortDefault,
|
||||
'status' => 1,
|
||||
'tier_ids' => [],
|
||||
'currency_codes' => null,
|
||||
];
|
||||
}
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
|
||||
@@ -149,7 +149,7 @@ final class DepositTier
|
||||
foreach ($items as $idx => $row) {
|
||||
$no = $idx + 1;
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行格式错误');
|
||||
throw new InvalidArgumentException('Row format error');
|
||||
}
|
||||
|
||||
$id = isset($row['id']) && is_string($row['id']) ? trim($row['id']) : '';
|
||||
@@ -157,10 +157,10 @@ final class DepositTier
|
||||
$id = self::generateId();
|
||||
}
|
||||
if (!preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $id)) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行 ID 非法');
|
||||
throw new InvalidArgumentException('Tier id is invalid');
|
||||
}
|
||||
if (isset($seenId[$id])) {
|
||||
throw new InvalidArgumentException('档位 ID 重复:' . $id);
|
||||
throw new InvalidArgumentException('Duplicate tier id');
|
||||
}
|
||||
$seenId[$id] = true;
|
||||
|
||||
@@ -170,45 +170,45 @@ final class DepositTier
|
||||
$title = self::stringField($row, 'name');
|
||||
}
|
||||
if ($title === '') {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称不能为空');
|
||||
throw new InvalidArgumentException('Tier title (zh) is required');
|
||||
}
|
||||
if (mb_strlen($title) > 64) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称过长');
|
||||
throw new InvalidArgumentException('Tier title (zh) is too long');
|
||||
}
|
||||
|
||||
$titleEn = self::stringField($row, 'title_en');
|
||||
if (mb_strlen($titleEn) > 64) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行英文充值名称过长');
|
||||
throw new InvalidArgumentException('Tier title (en) is too long');
|
||||
}
|
||||
|
||||
$currency = self::normalizeCurrency($row['currency'] ?? '');
|
||||
if ($currency === '') {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行支付货币不能为空');
|
||||
throw new InvalidArgumentException('Currency is required');
|
||||
}
|
||||
|
||||
$payAmount = self::normalizeAmount($row['pay_amount'] ?? '');
|
||||
if (bccomp($payAmount, '0', 2) <= 0) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行支付货币额度必须大于 0');
|
||||
throw new InvalidArgumentException('Pay amount must be greater than 0');
|
||||
}
|
||||
|
||||
$amount = self::normalizeAmount($row['amount'] ?? '');
|
||||
if (bccomp($amount, '0', 2) <= 0) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行基础平台币到账必须大于 0');
|
||||
throw new InvalidArgumentException('Base credit amount must be greater than 0');
|
||||
}
|
||||
|
||||
$bonus = self::normalizeAmount($row['bonus_amount'] ?? '0');
|
||||
if (bccomp($bonus, '0', 2) < 0) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行赠送金额不能为负数');
|
||||
throw new InvalidArgumentException('Bonus amount cannot be negative');
|
||||
}
|
||||
|
||||
$desc = self::stringField($row, 'desc');
|
||||
if (mb_strlen($desc) > 255) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行中文描述过长');
|
||||
throw new InvalidArgumentException('Description (zh) is too long');
|
||||
}
|
||||
|
||||
$descEn = self::stringField($row, 'desc_en');
|
||||
if (mb_strlen($descEn) > 255) {
|
||||
throw new InvalidArgumentException('第 ' . $no . ' 行英文描述过长');
|
||||
throw new InvalidArgumentException('Description (en) is too long');
|
||||
}
|
||||
|
||||
$sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0;
|
||||
@@ -248,7 +248,7 @@ final class DepositTier
|
||||
{
|
||||
$encoded = json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new InvalidArgumentException('JSON 编码失败');
|
||||
throw new InvalidArgumentException('JSON encode failed');
|
||||
}
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
@@ -37,24 +37,23 @@ final class FinanceCashierConfig
|
||||
'withdraw_coins_per_fiat' => '100',
|
||||
],
|
||||
[
|
||||
'code' => 'VND',
|
||||
'label_zh' => '越南盾',
|
||||
'label_en' => 'Vietnamese Dong',
|
||||
'code' => 'THB',
|
||||
'label_zh' => '泰铢',
|
||||
'label_en' => 'Thai Baht',
|
||||
'sort' => 20,
|
||||
'deposit_coins_per_fiat' => '10',
|
||||
'withdraw_coins_per_fiat' => '10',
|
||||
],
|
||||
[
|
||||
'code' => 'USDT',
|
||||
'label_zh' => 'USDT',
|
||||
'label_en' => 'USDT',
|
||||
'sort' => 30,
|
||||
'deposit_coins_per_fiat' => '1',
|
||||
'withdraw_coins_per_fiat' => '1',
|
||||
'deposit_coins_per_fiat' => '100',
|
||||
'withdraw_coins_per_fiat' => '100',
|
||||
],
|
||||
],
|
||||
'deposit_banks' => [
|
||||
['currency_code' => 'MYR', 'code' => 'pbb', 'name_zh' => 'Public Bank', 'name_en' => 'Public Bank', 'sort' => 10],
|
||||
['currency_code' => 'MYR', 'code' => 'mbb', 'name_zh' => 'Maybank2U', 'name_en' => 'Maybank2U', 'sort' => 20],
|
||||
['currency_code' => 'THB', 'code' => '106', 'name_zh' => 'BANGKOK BANK PUBLIC COMPANY LTD.', 'name_en' => 'BANGKOK BANK PUBLIC COMPANY LTD.', 'sort' => 10],
|
||||
['currency_code' => 'THB', 'code' => '107', 'name_zh' => 'KASIKORNBANK PUBLIC COMPANY LIMITED', 'name_en' => 'KASIKORNBANK PUBLIC COMPANY LIMITED', 'sort' => 20],
|
||||
],
|
||||
'withdraw_banks' => [
|
||||
['code' => 'agrobank', 'name_zh' => 'Agrobank', 'name_en' => 'Agrobank', 'sort' => 10],
|
||||
['currency_code' => 'MYR', 'code' => 'pbb', 'name_zh' => 'Public Bank', 'name_en' => 'Public Bank', 'sort' => 10],
|
||||
['currency_code' => 'MYR', 'code' => 'mbb', 'name_zh' => 'Maybank2U', 'name_en' => 'Maybank2U', 'sort' => 20],
|
||||
],
|
||||
'withdraw_limits' => [
|
||||
'min_ewallet' => '10',
|
||||
@@ -69,12 +68,6 @@ final class FinanceCashierConfig
|
||||
'fee_note_en' => 'A minimum RM 1 handling fee may apply for withdrawals between RM 10 and RM 99.99.',
|
||||
'rate_mode' => 'fixed',
|
||||
],
|
||||
'withdraw_fields' => [
|
||||
'require_cardholder' => true,
|
||||
'require_bank_account' => true,
|
||||
'require_email' => true,
|
||||
'require_mobile' => true,
|
||||
],
|
||||
'channels' => [],
|
||||
];
|
||||
}
|
||||
@@ -99,7 +92,7 @@ final class FinanceCashierConfig
|
||||
if (isset($decoded['rates']) && is_array($decoded['rates'])) {
|
||||
$legacyRates = array_values($decoded['rates']);
|
||||
}
|
||||
foreach (['currencies', 'withdraw_banks', 'channels'] as $listKey) {
|
||||
foreach (['currencies', 'deposit_banks', 'withdraw_banks', 'channels'] as $listKey) {
|
||||
if (isset($decoded[$listKey]) && is_array($decoded[$listKey])) {
|
||||
$out[$listKey] = array_values($decoded[$listKey]);
|
||||
}
|
||||
@@ -110,9 +103,6 @@ final class FinanceCashierConfig
|
||||
if (isset($decoded['withdraw_copy']) && is_array($decoded['withdraw_copy'])) {
|
||||
$out['withdraw_copy'] = array_replace($out['withdraw_copy'], array_intersect_key($decoded['withdraw_copy'], $out['withdraw_copy']));
|
||||
}
|
||||
if (isset($decoded['withdraw_fields']) && is_array($decoded['withdraw_fields'])) {
|
||||
$out['withdraw_fields'] = array_replace($out['withdraw_fields'], array_intersect_key($decoded['withdraw_fields'], $out['withdraw_fields']));
|
||||
}
|
||||
|
||||
return self::normalizePayload($out, $legacyRates);
|
||||
}
|
||||
@@ -208,33 +198,12 @@ final class FinanceCashierConfig
|
||||
return strcmp($ca, $cb);
|
||||
});
|
||||
|
||||
if (isset($out['withdraw_banks']) && is_array($out['withdraw_banks'])) {
|
||||
foreach ($out['withdraw_banks'] as $i => $row) {
|
||||
if (!is_array($row)) {
|
||||
unset($out['withdraw_banks'][$i]);
|
||||
continue;
|
||||
}
|
||||
$code = isset($row['code']) && is_string($row['code']) ? strtolower(trim($row['code'])) : '';
|
||||
$out['withdraw_banks'][$i] = [
|
||||
'code' => $code,
|
||||
'name_zh' => isset($row['name_zh']) && is_string($row['name_zh']) ? trim($row['name_zh']) : '',
|
||||
'name_en' => isset($row['name_en']) && is_string($row['name_en']) ? trim($row['name_en']) : '',
|
||||
'sort' => self::normalizeSort($row['sort'] ?? 0),
|
||||
];
|
||||
}
|
||||
$out['withdraw_banks'] = array_values(array_filter($out['withdraw_banks'], static fn ($r) => is_array($r) && $r['code'] !== ''));
|
||||
usort($out['withdraw_banks'], static function (array $a, array $b): int {
|
||||
$sa = $a['sort'] ?? 0;
|
||||
$sb = $b['sort'] ?? 0;
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb;
|
||||
}
|
||||
$ca = $a['code'] ?? '';
|
||||
$cb = $b['code'] ?? '';
|
||||
|
||||
return strcmp($ca, $cb);
|
||||
});
|
||||
}
|
||||
$out['deposit_banks'] = self::normalizeBanksByCurrency(
|
||||
isset($out['deposit_banks']) && is_array($out['deposit_banks']) ? $out['deposit_banks'] : []
|
||||
);
|
||||
$out['withdraw_banks'] = self::normalizeBanksByCurrency(
|
||||
isset($out['withdraw_banks']) && is_array($out['withdraw_banks']) ? $out['withdraw_banks'] : []
|
||||
);
|
||||
if (isset($out['withdraw_limits']) && is_array($out['withdraw_limits'])) {
|
||||
$wl = array_replace($defaults['withdraw_limits'], $out['withdraw_limits']);
|
||||
foreach (['min_ewallet', 'min_bank'] as $k) {
|
||||
@@ -257,13 +226,6 @@ final class FinanceCashierConfig
|
||||
}
|
||||
$out['withdraw_copy']['rate_mode'] = $mode;
|
||||
}
|
||||
if (isset($out['withdraw_fields']) && is_array($out['withdraw_fields'])) {
|
||||
$wf = array_replace($defaults['withdraw_fields'], array_intersect_key($out['withdraw_fields'], $defaults['withdraw_fields']));
|
||||
foreach (array_keys($defaults['withdraw_fields']) as $fk) {
|
||||
$wf[$fk] = !empty($wf[$fk]);
|
||||
}
|
||||
$out['withdraw_fields'] = $wf;
|
||||
}
|
||||
if (isset($out['channels']) && is_array($out['channels'])) {
|
||||
$out['channels'] = DepositChannel::normalizeOverrides(array_values($out['channels']));
|
||||
} else {
|
||||
@@ -280,6 +242,8 @@ final class FinanceCashierConfig
|
||||
});
|
||||
|
||||
unset($out['fx_pairs']);
|
||||
// 历史 JSON 可能含 withdraw_fields;已废弃,不再落库与返回给后台表单
|
||||
unset($out['withdraw_fields']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
@@ -322,71 +286,159 @@ final class FinanceCashierConfig
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $rows
|
||||
* @return list<array{currency_code: string, code: string, name_zh: string, name_en: string, sort: int}>
|
||||
*/
|
||||
private static function normalizeBanksByCurrency(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$currencyCode = isset($row['currency_code']) && is_string($row['currency_code']) ? strtoupper(trim($row['currency_code'])) : '';
|
||||
if ($currencyCode === '') {
|
||||
$currencyCode = 'MYR';
|
||||
}
|
||||
$codeRaw = $row['code'] ?? '';
|
||||
$code = '';
|
||||
if (is_string($codeRaw)) {
|
||||
$code = strtolower(trim($codeRaw));
|
||||
} elseif (is_numeric($codeRaw)) {
|
||||
$code = strtolower(trim(strval($codeRaw)));
|
||||
}
|
||||
$out[] = [
|
||||
'currency_code' => $currencyCode,
|
||||
'code' => $code,
|
||||
'name_zh' => isset($row['name_zh']) && is_string($row['name_zh']) ? trim($row['name_zh']) : '',
|
||||
'name_en' => isset($row['name_en']) && is_string($row['name_en']) ? trim($row['name_en']) : '',
|
||||
'sort' => self::normalizeSort($row['sort'] ?? 0),
|
||||
];
|
||||
}
|
||||
$out = array_values(array_filter($out, static fn ($r) => is_array($r) && $r['currency_code'] !== '' && $r['code'] !== ''));
|
||||
usort($out, static function (array $a, array $b): int {
|
||||
$ca = isset($a['currency_code']) && is_string($a['currency_code']) ? $a['currency_code'] : '';
|
||||
$cb = isset($b['currency_code']) && is_string($b['currency_code']) ? $b['currency_code'] : '';
|
||||
if ($ca !== $cb) {
|
||||
return strcmp($ca, $cb);
|
||||
}
|
||||
$sa = isset($a['sort']) && is_numeric($a['sort']) ? intval(strval($a['sort'])) : 0;
|
||||
$sb = isset($b['sort']) && is_numeric($b['sort']) ? intval(strval($b['sort'])) : 0;
|
||||
if ($sa !== $sb) {
|
||||
return $sa <=> $sb;
|
||||
}
|
||||
$ka = isset($a['code']) && is_string($a['code']) ? $a['code'] : '';
|
||||
$kb = isset($b['code']) && is_string($b['code']) ? $b['code'] : '';
|
||||
|
||||
return strcmp($ka, $kb);
|
||||
});
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $p
|
||||
*/
|
||||
private static function validate(array $p): void
|
||||
{
|
||||
if (!isset($p['platform_coin']) || !is_array($p['platform_coin'])) {
|
||||
throw new InvalidArgumentException('platform_coin 格式错误');
|
||||
throw new InvalidArgumentException('platform_coin format error');
|
||||
}
|
||||
$lz = $p['platform_coin']['label_zh'] ?? '';
|
||||
$le = $p['platform_coin']['label_en'] ?? '';
|
||||
if (!is_string($lz) || trim($lz) === '' || !is_string($le) || trim($le) === '') {
|
||||
throw new InvalidArgumentException('请填写平台币中英文名称');
|
||||
throw new InvalidArgumentException('Platform coin labels (zh/en) are required');
|
||||
}
|
||||
if (!isset($p['currencies']) || !is_array($p['currencies']) || $p['currencies'] === []) {
|
||||
throw new InvalidArgumentException('至少保留一条货币');
|
||||
throw new InvalidArgumentException('At least one currency is required');
|
||||
}
|
||||
$seenCodes = [];
|
||||
foreach ($p['currencies'] as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('货币列表第 ' . ($idx + 1) . ' 行格式错误');
|
||||
throw new InvalidArgumentException('Currency row format error');
|
||||
}
|
||||
$code = $row['code'] ?? '';
|
||||
if (!is_string($code) || !preg_match('/^[A-Z0-9]{2,12}$/', $code)) {
|
||||
throw new InvalidArgumentException('货币代码非法:' . (is_string($code) ? $code : ''));
|
||||
throw new InvalidArgumentException('Currency code is invalid');
|
||||
}
|
||||
if (isset($seenCodes[$code])) {
|
||||
throw new InvalidArgumentException('货币代码不能重复:' . $code);
|
||||
throw new InvalidArgumentException('Duplicate currency code');
|
||||
}
|
||||
$seenCodes[$code] = true;
|
||||
$dep = $row['deposit_coins_per_fiat'] ?? '';
|
||||
$wdr = $row['withdraw_coins_per_fiat'] ?? '';
|
||||
if (!is_string($dep) || $dep === '' || !is_numeric($dep) || bccomp($dep, '0', 2) <= 0) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:充值汇率须为大于 0 的数字');
|
||||
throw new InvalidArgumentException('Deposit rate must be a number greater than 0');
|
||||
}
|
||||
if (!is_string($wdr) || $wdr === '' || !is_numeric($wdr) || bccomp($wdr, '0', 2) <= 0) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行:提现汇率须为大于 0 的数字');
|
||||
throw new InvalidArgumentException('Withdraw rate must be a number greater than 0');
|
||||
}
|
||||
}
|
||||
if (isset($p['withdraw_banks']) && is_array($p['withdraw_banks'])) {
|
||||
$seen = [];
|
||||
foreach ($p['withdraw_banks'] as $idx => $row) {
|
||||
|
||||
// 校验 deposit channels 的币种白名单:currency_codes 仅允许来自 currencies
|
||||
if (isset($p['channels']) && is_array($p['channels'])) {
|
||||
foreach ($p['channels'] as $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('银行第 ' . ($idx + 1) . ' 行格式错误');
|
||||
continue;
|
||||
}
|
||||
$status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 0;
|
||||
$cc = array_key_exists('currency_codes', $row) ? ($row['currency_codes'] ?? null) : null;
|
||||
if ($cc === null) {
|
||||
continue; // null => 兼容全部币种(历史配置默认行为)
|
||||
}
|
||||
if (!is_array($cc)) {
|
||||
throw new InvalidArgumentException('Channel currency_codes format error');
|
||||
}
|
||||
if ($status === 1 && $cc === []) {
|
||||
throw new InvalidArgumentException('Enabled channel currency_codes can not be empty');
|
||||
}
|
||||
foreach ($cc as $c) {
|
||||
if (!is_string($c) || !preg_match('/^[A-Z0-9]{2,12}$/', $c)) {
|
||||
throw new InvalidArgumentException('Channel currency_codes contains invalid currency code');
|
||||
}
|
||||
if (!isset($seenCodes[$c])) {
|
||||
throw new InvalidArgumentException('Channel currency_codes contains currency not configured');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['deposit_banks', 'withdraw_banks'] as $bankKey) {
|
||||
if (!isset($p[$bankKey]) || !is_array($p[$bankKey])) {
|
||||
continue;
|
||||
}
|
||||
$seen = [];
|
||||
foreach ($p[$bankKey] as $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('Bank row format error');
|
||||
}
|
||||
$currencyCode = $row['currency_code'] ?? '';
|
||||
if (!is_string($currencyCode) || !isset($seenCodes[$currencyCode])) {
|
||||
throw new InvalidArgumentException('Bank currency_code is invalid');
|
||||
}
|
||||
$code = $row['code'] ?? '';
|
||||
if (!is_string($code) || !preg_match('/^[a-z0-9][a-z0-9_\-]{0,31}$/', $code)) {
|
||||
throw new InvalidArgumentException('银行代码非法');
|
||||
throw new InvalidArgumentException('Bank code is invalid');
|
||||
}
|
||||
if (isset($seen[$code])) {
|
||||
throw new InvalidArgumentException('银行代码重复:' . $code);
|
||||
$uniq = $currencyCode . '|' . $code;
|
||||
if (isset($seen[$uniq])) {
|
||||
throw new InvalidArgumentException('Duplicate bank code');
|
||||
}
|
||||
$seen[$code] = true;
|
||||
$seen[$uniq] = true;
|
||||
}
|
||||
}
|
||||
if (isset($p['withdraw_limits']) && is_array($p['withdraw_limits'])) {
|
||||
foreach (['min_ewallet', 'min_bank'] as $k) {
|
||||
$v = $p['withdraw_limits'][$k] ?? '0';
|
||||
if (!is_string($v) || !is_numeric($v) || bccomp($v, '0', 2) < 0) {
|
||||
throw new InvalidArgumentException('提现最低限额须为不小于 0 的数字');
|
||||
throw new InvalidArgumentException('Withdraw min limit must be a number not less than 0');
|
||||
}
|
||||
}
|
||||
}
|
||||
$reg = DepositChannel::codeRegistry();
|
||||
if ($reg !== [] && (!isset($p['channels']) || !is_array($p['channels']) || $p['channels'] === [])) {
|
||||
throw new InvalidArgumentException('请配置充值渠道');
|
||||
throw new InvalidArgumentException('Please configure deposit channels');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
app/common/library/game/GamePeriodNo.php
Normal file
55
app/common/library/game/GamePeriodNo.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\game;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 游戏期号:纯数字生成与对外展示格式化。
|
||||
*/
|
||||
final class GamePeriodNo
|
||||
{
|
||||
/**
|
||||
* 生成下一期业务期号(纯数字字符串,递增)。
|
||||
*/
|
||||
public static function generateNext(): string
|
||||
{
|
||||
$maxIdRaw = Db::name('game_record')->max('id');
|
||||
$maxId = filter_var($maxIdRaw, FILTER_VALIDATE_INT);
|
||||
if ($maxId === false) {
|
||||
$maxId = 0;
|
||||
}
|
||||
|
||||
$maxNumericRow = Db::name('game_record')
|
||||
->whereRaw("period_no REGEXP '^[0-9]+$'")
|
||||
->fieldRaw('MAX(CAST(period_no AS UNSIGNED)) AS max_no')
|
||||
->find();
|
||||
$maxNumericRaw = is_array($maxNumericRow) ? ($maxNumericRow['max_no'] ?? null) : null;
|
||||
$maxNumeric = filter_var($maxNumericRaw, FILTER_VALIDATE_INT);
|
||||
if ($maxNumeric === false) {
|
||||
$maxNumeric = 0;
|
||||
}
|
||||
|
||||
$next = max($maxId, $maxNumeric) + 1;
|
||||
|
||||
return (string) $next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外 API / 列表展示的纯数字期号;历史非数字期号回退为 period_id。
|
||||
*/
|
||||
public static function toDisplay(string $periodNo, int $periodId = 0): string
|
||||
{
|
||||
$trimmed = trim($periodNo);
|
||||
if ($trimmed !== '' && ctype_digit($trimmed)) {
|
||||
return $trimmed;
|
||||
}
|
||||
if ($periodId > 0) {
|
||||
return (string) $periodId;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,12 @@ final class StreakWinReward
|
||||
|
||||
/** @var list<array{streak: int, odds_factor: int, is_jackpot: bool}>|null */
|
||||
private static ?array $cache = null;
|
||||
private static ?string $cacheConfigValue = null;
|
||||
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$cache = null;
|
||||
self::$cacheConfigValue = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,15 +99,40 @@ final class StreakWinReward
|
||||
*/
|
||||
public static function loadRows(): array
|
||||
{
|
||||
if (self::$cache !== null) {
|
||||
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
|
||||
$rawValue = $row['config_value'] ?? null;
|
||||
$normalizedRaw = is_string($rawValue) ? trim($rawValue) : (is_numeric($rawValue) ? (string) $rawValue : '');
|
||||
|
||||
if (self::$cache !== null && self::$cacheConfigValue === $normalizedRaw) {
|
||||
return self::$cache;
|
||||
}
|
||||
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
|
||||
self::$cache = self::parseFromConfigValue($row['config_value'] ?? null);
|
||||
|
||||
self::$cache = self::parseFromConfigValue($normalizedRaw);
|
||||
self::$cacheConfigValue = $normalizedRaw;
|
||||
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前玩家本局适用赔率(非全表):按 current_streak 解析下一注中奖将使用的档位。
|
||||
*
|
||||
* @return array{current_streak: int, streak_level: int, odds_factor: int, is_jackpot: bool}
|
||||
*/
|
||||
public static function playerBetOddsForCurrentStreak(int $currentStreak): array
|
||||
{
|
||||
if ($currentStreak < 0) {
|
||||
$currentStreak = 0;
|
||||
}
|
||||
$row = self::rowForStreakAtBet($currentStreak);
|
||||
|
||||
return [
|
||||
'current_streak' => $currentStreak,
|
||||
'streak_level' => self::levelFromStreakAtBet($currentStreak),
|
||||
'odds_factor' => (int) ($row['odds_factor'] ?? 1),
|
||||
'is_jackpot' => ($row['is_jackpot'] ?? false) === true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* streak_at_bet 为下注时快照(0 表示尚未连胜);档位取 min(streak_at_bet+1, 10)。
|
||||
*/
|
||||
|
||||
@@ -168,43 +168,43 @@ final class ZiHuaDictionary
|
||||
public static function prepareItemsForSave(array $items): array
|
||||
{
|
||||
if (count($items) !== 36) {
|
||||
throw new InvalidArgumentException('必须恰好 36 条');
|
||||
throw new InvalidArgumentException('Must be exactly 36 items');
|
||||
}
|
||||
$nos = [];
|
||||
$out = [];
|
||||
foreach ($items as $idx => $row) {
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误');
|
||||
throw new InvalidArgumentException('Row format error');
|
||||
}
|
||||
$no = $row['no'] ?? null;
|
||||
if (!is_numeric($no)) {
|
||||
throw new InvalidArgumentException('编号必须为数字');
|
||||
throw new InvalidArgumentException('No must be numeric');
|
||||
}
|
||||
$n = intval($no, 10);
|
||||
if ($n < 1 || $n > 36) {
|
||||
throw new InvalidArgumentException('编号必须在 1–36');
|
||||
throw new InvalidArgumentException('No must be between 1 and 36');
|
||||
}
|
||||
if (isset($nos[$n])) {
|
||||
throw new InvalidArgumentException('编号重复:' . $n);
|
||||
throw new InvalidArgumentException('Duplicate no');
|
||||
}
|
||||
$nos[$n] = true;
|
||||
|
||||
$name = $row['name'] ?? '';
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
throw new InvalidArgumentException('编号 ' . $n . ' 名称不能为空');
|
||||
throw new InvalidArgumentException('Name is required');
|
||||
}
|
||||
|
||||
$cat = $row['category'] ?? '';
|
||||
$catTrim = is_string($cat) ? trim($cat) : '';
|
||||
if (!in_array($catTrim, self::CATEGORIES, true)) {
|
||||
throw new InvalidArgumentException('编号 ' . $n . ' 类型无效');
|
||||
throw new InvalidArgumentException('Category is invalid');
|
||||
}
|
||||
|
||||
$out[] = ['no' => $n, 'name' => trim($name), 'category' => $catTrim];
|
||||
}
|
||||
for ($i = 1; $i <= 36; $i++) {
|
||||
if (!isset($nos[$i])) {
|
||||
throw new InvalidArgumentException('缺少编号:' . $i);
|
||||
throw new InvalidArgumentException('Missing no');
|
||||
}
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
|
||||
@@ -219,7 +219,7 @@ final class ZiHuaDictionary
|
||||
usort($items, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
|
||||
$encoded = json_encode($items, JSON_UNESCAPED_UNICODE);
|
||||
if ($encoded === false) {
|
||||
throw new InvalidArgumentException('JSON 编码失败');
|
||||
throw new InvalidArgumentException('JSON encode failed');
|
||||
}
|
||||
return $encoded;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ use Webman\Http\Response;
|
||||
*/
|
||||
class AllowCrossDomain implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* 允许跨域的自定义请求头(注意:携带 credentials 时,Allow-Headers 通配符在部分浏览器无效)
|
||||
*/
|
||||
protected const ALLOW_HEADERS = 'Content-Type, Authorization, batoken, ba-user-token, user-token, access-token, auth-token, think-lang, lang, accept-language, x-requested-with, server';
|
||||
|
||||
protected array $header = [
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Max-Age' => '1800',
|
||||
'Access-Control-Allow-Methods' => '*',
|
||||
'Access-Control-Allow-Headers' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => self::ALLOW_HEADERS,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -30,7 +36,8 @@ class AllowCrossDomain implements MiddlewareInterface
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Max-Age' => '1800',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, user-token, access-token, think-lang, server',
|
||||
'Access-Control-Allow-Headers' => self::ALLOW_HEADERS,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
$origin = $request->header('origin');
|
||||
if (is_array($origin)) {
|
||||
|
||||
232
app/common/middleware/InterfaceRequestLog.php
Normal file
232
app/common/middleware/InterfaceRequestLog.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\middleware;
|
||||
|
||||
use support\Log;
|
||||
use Throwable;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
/**
|
||||
* 接口调用日志中间件
|
||||
*
|
||||
* 目标:
|
||||
* - 统一记录接口调用(请求 + 响应)
|
||||
* - 成功记录返回参数
|
||||
* - 失败记录失败原因
|
||||
*/
|
||||
class InterfaceRequestLog implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* 仅记录接口路由(避免静态资源噪音)
|
||||
*/
|
||||
private const API_PATH_PREFIXES = ['api/', 'admin/'];
|
||||
|
||||
/**
|
||||
* 需要脱敏的字段名(不区分大小写)
|
||||
*/
|
||||
private const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
'oldpassword',
|
||||
'newpassword',
|
||||
'token',
|
||||
'user-token',
|
||||
'user_token',
|
||||
'auth-token',
|
||||
'auth_token',
|
||||
'refresh_token',
|
||||
'secret',
|
||||
'signature',
|
||||
'sign',
|
||||
];
|
||||
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$path = trim($request->path(), '/');
|
||||
if (!$this->shouldLogPath($path)) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$requestPayload = $this->buildRequestPayload($request);
|
||||
|
||||
try {
|
||||
$response = $handler($request);
|
||||
} catch (Throwable $e) {
|
||||
$costMs = $this->costMs($start);
|
||||
Log::error('[InterfaceRequestLog] ' . $this->encodeJson([
|
||||
'path' => '/' . $path,
|
||||
'method' => strtoupper($request->method()),
|
||||
'ip' => $request->getRealIp(),
|
||||
'cost_ms' => $costMs,
|
||||
'request' => $requestPayload,
|
||||
'success' => false,
|
||||
'fail_reason' => $e->getMessage(),
|
||||
'exception_class' => get_class($e),
|
||||
'exception_trace' => $this->truncateText($e->getTraceAsString(), 3000),
|
||||
]));
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$costMs = $this->costMs($start);
|
||||
$statusCode = method_exists($response, 'getStatusCode') ? intval($response->getStatusCode()) : 200;
|
||||
$responseBodyRaw = $this->extractResponseBody($response);
|
||||
$responseDecoded = $this->decodeJsonArray($responseBodyRaw);
|
||||
|
||||
$success = false;
|
||||
$failReason = '';
|
||||
$responseData = null;
|
||||
|
||||
if (is_array($responseDecoded)) {
|
||||
if (array_key_exists('code', $responseDecoded)) {
|
||||
$success = intval($responseDecoded['code']) === 1;
|
||||
} else {
|
||||
$success = $statusCode >= 200 && $statusCode < 400;
|
||||
}
|
||||
$responseData = $responseDecoded['data'] ?? $responseDecoded;
|
||||
if (!$success) {
|
||||
$failReasonRaw = $responseDecoded['message'] ?? ($responseDecoded['msg'] ?? '');
|
||||
$failReason = is_string($failReasonRaw) ? $failReasonRaw : strval($failReasonRaw);
|
||||
}
|
||||
} else {
|
||||
$success = $statusCode >= 200 && $statusCode < 400;
|
||||
if ($success) {
|
||||
$responseData = $this->truncateText($responseBodyRaw, 2000);
|
||||
} else {
|
||||
$failReason = $this->truncateText($responseBodyRaw, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
$logPayload = [
|
||||
'path' => '/' . $path,
|
||||
'method' => strtoupper($request->method()),
|
||||
'ip' => $request->getRealIp(),
|
||||
'status_code' => $statusCode,
|
||||
'cost_ms' => $costMs,
|
||||
'request' => $requestPayload,
|
||||
'success' => $success,
|
||||
];
|
||||
|
||||
if ($success) {
|
||||
$logPayload['response_data'] = $this->maskMixed($responseData);
|
||||
Log::info('[InterfaceRequestLog] ' . $this->encodeJson($logPayload));
|
||||
} else {
|
||||
$logPayload['fail_reason'] = $failReason !== '' ? $failReason : 'Unknown error';
|
||||
if (is_array($responseDecoded)) {
|
||||
$logPayload['api_response'] = $this->maskMixed($responseDecoded);
|
||||
}
|
||||
Log::error('[InterfaceRequestLog] ' . $this->encodeJson($logPayload));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function shouldLogPath(string $path): bool
|
||||
{
|
||||
foreach (self::API_PATH_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function buildRequestPayload(Request $request): array
|
||||
{
|
||||
$query = $request->get();
|
||||
$post = $request->post();
|
||||
if (!is_array($query)) {
|
||||
$query = [];
|
||||
}
|
||||
if (!is_array($post)) {
|
||||
$post = [];
|
||||
}
|
||||
|
||||
$rawBody = method_exists($request, 'rawBody') ? strval($request->rawBody()) : '';
|
||||
$jsonBody = [];
|
||||
if ($rawBody !== '') {
|
||||
$jsonDecoded = json_decode($rawBody, true);
|
||||
if (is_array($jsonDecoded)) {
|
||||
$jsonBody = $jsonDecoded;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'query' => $this->maskMixed($query),
|
||||
'post' => $this->maskMixed($post),
|
||||
'json' => $this->maskMixed($jsonBody),
|
||||
];
|
||||
}
|
||||
|
||||
private function extractResponseBody(Response $response): string
|
||||
{
|
||||
if (method_exists($response, 'rawBody')) {
|
||||
return strval($response->rawBody());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function decodeJsonArray(string $content): ?array
|
||||
{
|
||||
if ($content === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($content, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function maskMixed($value, ?string $parentKey = null)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$result = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$keyName = is_string($k) ? $k : $parentKey;
|
||||
$result[$k] = $this->maskMixed($v, $keyName);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($parentKey !== null && $this->isSensitiveKey($parentKey)) {
|
||||
return '***';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function isSensitiveKey(string $key): bool
|
||||
{
|
||||
$normalized = strtolower(trim($key));
|
||||
foreach (self::SENSITIVE_KEYS as $sensitive) {
|
||||
if ($normalized === $sensitive) {
|
||||
return true;
|
||||
}
|
||||
if (str_contains($normalized, $sensitive)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function truncateText(string $text, int $maxLen): string
|
||||
{
|
||||
if ($maxLen <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (mb_strlen($text) <= $maxLen) {
|
||||
return $text;
|
||||
}
|
||||
return mb_substr($text, 0, $maxLen) . '...';
|
||||
}
|
||||
|
||||
private function encodeJson(array $payload): string
|
||||
{
|
||||
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private function costMs(float $start): int
|
||||
{
|
||||
return intval((microtime(true) - $start) * 1000);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class LoadLangPack implements MiddlewareInterface
|
||||
/**
|
||||
* 解析当前请求语言。
|
||||
* - 后台 admin:优先请求头 think-lang(zh-cn / en),其次 lang 头,再次查询/表单参数 lang(支持 zh→zh-cn)。
|
||||
* - 对外 api:优先查询/表单参数 lang(zh / en),其次 lang 头,再次 think-lang。
|
||||
* - 对外 api:优先查询/表单参数 lang(zh / zh-cn / en),其次 lang 头,再次 think-lang;仅当解析结果为允许列表中的 zh-cn 或 en 时生效,否则固定 zh-cn(不使用 Accept-Language)。仅 lang=en(规范化后)返回英文文案。
|
||||
*/
|
||||
protected function resolveLangSet(Request $request): string
|
||||
{
|
||||
@@ -38,13 +38,28 @@ class LoadLangPack implements MiddlewareInterface
|
||||
if ($queryRaw === null || $queryRaw === '') {
|
||||
$queryRaw = $request->post('lang');
|
||||
}
|
||||
$queryLang = is_string($queryRaw) ? $queryRaw : '';
|
||||
$queryLang = '';
|
||||
if (is_string($queryRaw)) {
|
||||
$queryLang = $queryRaw;
|
||||
} elseif (is_scalar($queryRaw)) {
|
||||
$queryLang = trim('' . $queryRaw);
|
||||
}
|
||||
|
||||
$thinkRaw = $request->header('think-lang');
|
||||
$thinkLang = is_string($thinkRaw) ? $thinkRaw : '';
|
||||
$thinkRaw = $request->header('think-lang', '');
|
||||
$thinkLang = '';
|
||||
if (is_string($thinkRaw)) {
|
||||
$thinkLang = $thinkRaw;
|
||||
} elseif (is_array($thinkRaw) && isset($thinkRaw[0]) && is_string($thinkRaw[0])) {
|
||||
$thinkLang = $thinkRaw[0];
|
||||
}
|
||||
|
||||
$headerLangRaw = $request->header('lang');
|
||||
$headerLang = is_string($headerLangRaw) ? $headerLangRaw : '';
|
||||
$headerLangRaw = $request->header('lang', '');
|
||||
$headerLang = '';
|
||||
if (is_string($headerLangRaw)) {
|
||||
$headerLang = $headerLangRaw;
|
||||
} elseif (is_array($headerLangRaw) && isset($headerLangRaw[0]) && is_string($headerLangRaw[0])) {
|
||||
$headerLang = $headerLangRaw[0];
|
||||
}
|
||||
|
||||
$normalize = static function (string $raw): string {
|
||||
$s = str_replace('_', '-', strtolower(trim($raw)));
|
||||
@@ -86,6 +101,11 @@ class LoadLangPack implements MiddlewareInterface
|
||||
}
|
||||
}
|
||||
|
||||
// 对外 api:无显式 lang / think-lang 时默认中文,避免浏览器 Accept-Language: en 覆盖产品默认
|
||||
if ($app === 'api') {
|
||||
return 'zh-cn';
|
||||
}
|
||||
|
||||
$acceptRaw = $request->header('accept-language');
|
||||
$acceptLang = is_string($acceptRaw) ? $acceptRaw : '';
|
||||
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
|
||||
|
||||
@@ -15,8 +15,13 @@ class AgentCommissionRecord extends Model
|
||||
'update_time' => 'integer',
|
||||
'settled_at' => 'integer',
|
||||
'commission_rate' => 'string',
|
||||
'share_rate' => 'string',
|
||||
'calc_base_amount' => 'string',
|
||||
'commission_amount' => 'string',
|
||||
'commission_share_percent' => 'string',
|
||||
'handling_fee' => 'string',
|
||||
'handling_fee_rate' => 'string',
|
||||
'net_commission_amount' => 'string',
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
|
||||
@@ -27,4 +27,9 @@ class DepositOrder extends Model
|
||||
{
|
||||
return $this->belongsTo(Channel::class, 'channel_id', 'id');
|
||||
}
|
||||
|
||||
public function reviewAdmin(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\app\admin\model\Admin::class, 'review_admin_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ class GamePeriod extends Model
|
||||
'period_start_at' => 'integer',
|
||||
'status' => 'integer',
|
||||
'draw_mode' => 'integer',
|
||||
'preset_number' => 'integer',
|
||||
'result_number' => 'integer',
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ class GameRecord extends Model
|
||||
'period_start_at' => 'integer',
|
||||
'status' => 'integer',
|
||||
'draw_mode' => 'integer',
|
||||
'preset_number' => 'integer',
|
||||
'result_number' => 'integer',
|
||||
'ai_locked_number' => 'integer',
|
||||
'pending_draw_number' => 'integer',
|
||||
|
||||
197
app/common/service/AdminChannelScopeService.php
Normal file
197
app/common/service/AdminChannelScopeService.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\library\Auth;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 后台管理员渠道数据范围:
|
||||
* - 账号或角色组绑定 channel_id → 仅该批渠道(读+写)
|
||||
* - 均未绑定且拥有渠道模块基础权限(channel/index、edit 等)→ 全部渠道(读+写)
|
||||
* - 均未绑定且无渠道模块权限 → 不可见
|
||||
*/
|
||||
class AdminChannelScopeService
|
||||
{
|
||||
/**
|
||||
* 是否具备全平台只读范围(其它菜单按 admin 收窄时跳过):超管 / 未绑定渠道且拥有渠道模块权限
|
||||
*/
|
||||
public static function hasGlobalReadScope(Auth $auth): bool
|
||||
{
|
||||
if (!$auth->isLogin()) {
|
||||
return false;
|
||||
}
|
||||
if ($auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (self::resolveEffectiveChannelIds($auth) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::hasAnyChannelMenuPermission($auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可读渠道 ID 列表;null 表示不限制(全部渠道)
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public static function readableChannelIds(Auth $auth): ?array
|
||||
{
|
||||
return self::resolveScopedChannelIds($auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可写渠道 ID 列表;null 表示不限制(全部渠道)
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public static function writableChannelIds(Auth $auth): ?array
|
||||
{
|
||||
return self::resolveScopedChannelIds($auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
private static function resolveScopedChannelIds(Auth $auth): ?array
|
||||
{
|
||||
if (!$auth->isLogin()) {
|
||||
return [0];
|
||||
}
|
||||
if ($auth->isSuperAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ids = self::resolveEffectiveChannelIds($auth);
|
||||
if ($ids !== []) {
|
||||
return $ids;
|
||||
}
|
||||
|
||||
if (self::hasAnyChannelMenuPermission($auth)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否拥有渠道管理模块相关权限(菜单或任一 channel/* 按钮)
|
||||
*/
|
||||
public static function hasAnyChannelMenuPermission(Auth $auth): bool
|
||||
{
|
||||
if (!$auth->isLogin()) {
|
||||
return false;
|
||||
}
|
||||
if ($auth->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$checkNodes = [
|
||||
'channel',
|
||||
'channel/index',
|
||||
'channel/add',
|
||||
'channel/edit',
|
||||
'channel/del',
|
||||
'channel/manualSettle',
|
||||
'channel/batchSettlePending',
|
||||
'channel/settleStats',
|
||||
];
|
||||
foreach ($checkNodes as $node) {
|
||||
if ($auth->check($node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$ruleList = $auth->getRuleList();
|
||||
foreach ($ruleList as $name) {
|
||||
if (!is_string($name) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$lower = strtolower($name);
|
||||
if ($lower === 'channel' || str_starts_with($lower, 'channel/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员实际绑定的渠道(角色组 channel_id + 账号 admin.channel_id,去重)
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function resolveEffectiveChannelIds(Auth $auth): array
|
||||
{
|
||||
$ids = self::resolveBoundGroupChannelIds($auth);
|
||||
$selfChannelId = self::resolveAdminAccountChannelId($auth);
|
||||
if ($selfChannelId > 0) {
|
||||
$ids[] = $selfChannelId;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前管理员账号上的 channel_id
|
||||
*/
|
||||
public static function resolveAdminAccountChannelId(Auth $auth): int
|
||||
{
|
||||
if (!$auth->isLogin()) {
|
||||
return 0;
|
||||
}
|
||||
$uid = (int) $auth->id;
|
||||
if ($uid <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$value = Db::name('admin')->where('id', $uid)->value('channel_id');
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前管理员所属角色组上绑定的渠道 ID(去重)
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function resolveBoundGroupChannelIds(Auth $auth): array
|
||||
{
|
||||
$uid = (int) $auth->id;
|
||||
if ($uid <= 0) {
|
||||
return [];
|
||||
}
|
||||
$groupIds = Db::name('admin_group_access')->where('uid', $uid)->column('group_id');
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = Db::name('admin_group')
|
||||
->where('id', 'in', $groupIds)
|
||||
->whereNotNull('channel_id')
|
||||
->where('channel_id', '>', 0)
|
||||
->column('channel_id');
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $cid) {
|
||||
$ids[] = (int) $cid;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表按 channel_id 过滤时使用的 ID;null=不过滤
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public static function channelIdFilterForQuery(Auth $auth): ?array
|
||||
{
|
||||
return self::readableChannelIds($auth);
|
||||
}
|
||||
}
|
||||
482
app/common/service/AdminCommissionDistributionService.php
Normal file
482
app/common/service/AdminCommissionDistributionService.php
Normal file
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 代理管理员树形分红:子代理从上级分红中按 commission_share_rate 抽取,上级保留剩余部分
|
||||
*/
|
||||
class AdminCommissionDistributionService
|
||||
{
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getDescendantAdminIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$childIds = Db::name('admin')
|
||||
->where('parent_admin_id', $adminId)
|
||||
->where('status', 'enable')
|
||||
->column('id');
|
||||
$all = [];
|
||||
foreach ($childIds as $cid) {
|
||||
$cid = intval($cid);
|
||||
if ($cid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$all[] = $cid;
|
||||
foreach (self::getDescendantAdminIds($cid) as $descId) {
|
||||
$all[] = $descId;
|
||||
}
|
||||
}
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非超管可见管理员 ID;超管返回空数组表示不限制
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getVisibleAdminIdsForOperator(int $operatorAdminId, bool $isSuperAdmin): array
|
||||
{
|
||||
if ($isSuperAdmin || $operatorAdminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$ids = self::getDescendantAdminIds($operatorAdminId);
|
||||
$ids[] = $operatorAdminId;
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{used_rate:string,remaining_rate:string}
|
||||
*/
|
||||
public static function getShareRemainder(int $parentAdminId, ?int $excludeAdminId = null): array
|
||||
{
|
||||
$used = '0.00';
|
||||
if ($parentAdminId <= 0) {
|
||||
return ['used_rate' => $used, 'remaining_rate' => '100.00'];
|
||||
}
|
||||
$query = Db::name('admin')
|
||||
->where('parent_admin_id', $parentAdminId)
|
||||
->where('status', 'enable');
|
||||
if ($excludeAdminId !== null && $excludeAdminId > 0) {
|
||||
$query->where('id', '<>', $excludeAdminId);
|
||||
}
|
||||
$rows = $query->column('commission_share_rate');
|
||||
foreach ($rows as $rate) {
|
||||
if ($rate === null || $rate === '') {
|
||||
continue;
|
||||
}
|
||||
$used = bcadd($used, bcadd(strval($rate), '0', 2), 2);
|
||||
}
|
||||
$remaining = bcsub('100.00', $used, 2);
|
||||
if (bccomp($remaining, '0', 2) < 0) {
|
||||
$remaining = '0.00';
|
||||
}
|
||||
return ['used_rate' => $used, 'remaining_rate' => $remaining];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同渠道下顶级代理(无上级)已占用的渠道分红比例
|
||||
*
|
||||
* @return array{used_rate:string,remaining_rate:string}
|
||||
*/
|
||||
public static function getChannelRootShareRemainder(int $channelId, ?int $excludeAdminId = null): array
|
||||
{
|
||||
$used = '0.00';
|
||||
if ($channelId <= 0) {
|
||||
return ['used_rate' => $used, 'remaining_rate' => '100.00'];
|
||||
}
|
||||
$query = Db::name('admin')
|
||||
->where('channel_id', $channelId)
|
||||
->where('status', 'enable')
|
||||
->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)');
|
||||
if ($excludeAdminId !== null && $excludeAdminId > 0) {
|
||||
$query->where('id', '<>', $excludeAdminId);
|
||||
}
|
||||
$rows = $query->column('commission_share_rate');
|
||||
foreach ($rows as $rate) {
|
||||
if ($rate === null || $rate === '') {
|
||||
continue;
|
||||
}
|
||||
$used = bcadd($used, bcadd(strval($rate), '0', 2), 2);
|
||||
}
|
||||
$remaining = bcsub('100.00', $used, 2);
|
||||
if (bccomp($remaining, '0', 2) < 0) {
|
||||
$remaining = '0.00';
|
||||
}
|
||||
return ['used_rate' => $used, 'remaining_rate' => $remaining];
|
||||
}
|
||||
|
||||
public static function validateChannelRootCommissionShareRate(int $channelId, mixed $rateRaw, ?int $excludeAdminId = null): ?string
|
||||
{
|
||||
if ($channelId <= 0) {
|
||||
return (string) __('Channel is required for top-level agent commission share');
|
||||
}
|
||||
if ($rateRaw === null || $rateRaw === '') {
|
||||
return (string) __('Top-level agent commission share rate is required');
|
||||
}
|
||||
$rate = bcadd(strval($rateRaw), '0', 2);
|
||||
if (bccomp($rate, '0', 2) <= 0 || bccomp($rate, '100', 2) > 0) {
|
||||
return (string) __('Commission share rate must be between 0 and 100');
|
||||
}
|
||||
$remainder = self::getChannelRootShareRemainder($channelId, $excludeAdminId);
|
||||
if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) {
|
||||
return (string) __('Sum of channel top-level commission share rates cannot exceed 100%');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function validateCommissionShareRate(?int $parentAdminId, mixed $rateRaw, ?int $excludeAdminId = null): ?string
|
||||
{
|
||||
if ($parentAdminId === null || $parentAdminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
if ($rateRaw === null || $rateRaw === '') {
|
||||
return (string) __('Sub-agent commission share rate is required');
|
||||
}
|
||||
$rate = bcadd(strval($rateRaw), '0', 2);
|
||||
if (bccomp($rate, '0', 2) < 0 || bccomp($rate, '100', 2) > 0) {
|
||||
return (string) __('Commission share rate must be between 0 and 100');
|
||||
}
|
||||
$remainder = self::getShareRemainder($parentAdminId, $excludeAdminId);
|
||||
if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) {
|
||||
return (string) __('Sum of sibling commission share rates cannot exceed 100%');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理费前佣金占渠道本期总佣金的比例(%)
|
||||
*/
|
||||
public static function calcCommissionSharePercent(string $gross, string $totalCommission): string
|
||||
{
|
||||
if (bccomp($totalCommission, '0', 2) <= 0 || bccomp($gross, '0', 2) <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
return bcdiv(bcmul($gross, '100', 4), $totalCommission, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按百分比计算手续费金额:手续费 = 费前佣金 × (费率% / 100)
|
||||
*/
|
||||
public static function calcHandlingFeeAmount(string $gross, string $ratePercent): string
|
||||
{
|
||||
$rate = bcadd($ratePercent, '0', 2);
|
||||
if (bccomp($gross, '0', 2) <= 0 || bccomp($rate, '0', 2) <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
if (bccomp($rate, '100', 2) > 0) {
|
||||
$rate = '100.00';
|
||||
}
|
||||
return bcmul($gross, bcdiv($rate, '100', 4), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将渠道本期总佣金按管理员树分配,返回各管理员实得金额(费前)
|
||||
*
|
||||
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||||
* @return array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee_rate:string,handling_fee:string,net_commission_amount:string}>
|
||||
*/
|
||||
public static function distributeChannelCommission(
|
||||
int $channelId,
|
||||
string $totalCommission,
|
||||
string $calcBaseAmount,
|
||||
string $defaultHandlingFeeRate = '0.00',
|
||||
?array $handlingFeeRateByAdmin = null
|
||||
): array {
|
||||
$nodes = self::collectHierarchicalNodes($channelId, $totalCommission);
|
||||
if ($nodes === []) {
|
||||
return [];
|
||||
}
|
||||
$shareRateByAdmin = [];
|
||||
foreach ($nodes as $node) {
|
||||
$nodeAdminId = intval($node['admin_id'] ?? 0);
|
||||
if ($nodeAdminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$shareRateByAdmin[$nodeAdminId] = strval($node['share_rate'] ?? '0.00');
|
||||
}
|
||||
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
|
||||
$merged = [];
|
||||
foreach ($nodes as $node) {
|
||||
$adminId = intval($node['admin_id'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$gross = strval($node['commission_amount'] ?? '0.00');
|
||||
if (bccomp($gross, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$settlementBase = strval($node['settlement_base_amount'] ?? '0.00');
|
||||
$feeRate = $defaultRate;
|
||||
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
|
||||
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
|
||||
}
|
||||
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
|
||||
$net = bcsub($gross, $feeAmount, 2);
|
||||
if (bccomp($net, '0', 2) < 0) {
|
||||
$net = '0.00';
|
||||
}
|
||||
$effectiveRate = bccomp($settlementBase, '0', 2) <= 0
|
||||
? '0.0000'
|
||||
: bcdiv($gross, $settlementBase, 6);
|
||||
$merged[$adminId] = [
|
||||
'admin_id' => $adminId,
|
||||
'commission_amount' => $gross,
|
||||
'commission_rate' => $effectiveRate,
|
||||
'calc_base_amount' => $settlementBase,
|
||||
'share_rate' => $shareRateByAdmin[$adminId] ?? '0.00',
|
||||
'commission_share_percent' => self::calcCommissionSharePercent($gross, $totalCommission),
|
||||
'handling_fee_rate' => $feeRate,
|
||||
'handling_fee' => $feeAmount,
|
||||
'net_commission_amount' => $net,
|
||||
];
|
||||
}
|
||||
return array_values($merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级分配预览(先序遍历),含结算基数与手续费
|
||||
*
|
||||
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function buildSplitPreview(
|
||||
int $channelId,
|
||||
string $commissionTotal,
|
||||
string $calcBaseAmount,
|
||||
string $defaultHandlingFeeRate = '0.00',
|
||||
?array $handlingFeeRateByAdmin = null
|
||||
): array {
|
||||
unset($calcBaseAmount);
|
||||
$nodes = self::collectHierarchicalNodes($channelId, $commissionTotal);
|
||||
if ($nodes === []) {
|
||||
return [];
|
||||
}
|
||||
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
|
||||
$out = [];
|
||||
foreach ($nodes as $node) {
|
||||
$adminId = intval($node['admin_id'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$gross = strval($node['commission_amount'] ?? '0.00');
|
||||
$feeRate = $defaultRate;
|
||||
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
|
||||
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
|
||||
}
|
||||
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
|
||||
$net = bcsub($gross, $feeAmount, 2);
|
||||
if (bccomp($net, '0', 2) < 0) {
|
||||
$net = '0.00';
|
||||
}
|
||||
$out[] = [
|
||||
'admin_id' => $adminId,
|
||||
'admin_username' => strval($node['admin_username'] ?? ('#' . $adminId)),
|
||||
'parent_admin_id' => intval($node['parent_admin_id'] ?? 0),
|
||||
'level' => intval($node['level'] ?? 0),
|
||||
'settlement_base_amount' => strval($node['settlement_base_amount'] ?? '0.00'),
|
||||
'share_rate' => strval($node['share_rate'] ?? '0.00'),
|
||||
'commission_amount' => $gross,
|
||||
'commission_share_percent' => self::calcCommissionSharePercent($gross, $commissionTotal),
|
||||
'handling_fee_rate' => $feeRate,
|
||||
'handling_fee' => $feeAmount,
|
||||
'net_commission_amount' => $net,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function normalizeHandlingFeeRatePercent(string $rateRaw): string
|
||||
{
|
||||
$rate = bcadd($rateRaw, '0', 2);
|
||||
if (bccomp($rate, '0', 2) < 0) {
|
||||
return '0.00';
|
||||
}
|
||||
if (bccomp($rate, '100', 2) > 0) {
|
||||
return '100.00';
|
||||
}
|
||||
return $rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}>
|
||||
*/
|
||||
private static function collectHierarchicalNodes(int $channelId, string $totalCommission): array
|
||||
{
|
||||
if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rootRows = Db::name('admin')
|
||||
->where('channel_id', $channelId)
|
||||
->where('status', 'enable')
|
||||
->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)')
|
||||
->order('id', 'asc')
|
||||
->field(['id', 'commission_share_rate', 'username'])
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rootRows === []) {
|
||||
return [];
|
||||
}
|
||||
$useRateSplit = true;
|
||||
foreach ($rootRows as $rootRow) {
|
||||
$rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2);
|
||||
if (bccomp($rate, '0', 2) <= 0) {
|
||||
$useRateSplit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$nodes = [];
|
||||
if ($useRateSplit) {
|
||||
foreach ($rootRows as $rootRow) {
|
||||
$rootId = intval($rootRow['id'] ?? 0);
|
||||
if ($rootId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2);
|
||||
$rootAmount = bcmul($totalCommission, bcdiv($rate, '100', 4), 2);
|
||||
if (bccomp($rootAmount, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
self::appendNodeTree($rootId, $rootAmount, 0, 0, $rate, $nodes);
|
||||
}
|
||||
} else {
|
||||
$rootCount = count($rootRows);
|
||||
$perRoot = bcdiv($totalCommission, strval($rootCount), 2);
|
||||
$assigned = '0.00';
|
||||
foreach ($rootRows as $index => $rootRow) {
|
||||
$rootId = intval($rootRow['id'] ?? 0);
|
||||
if ($rootId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$isLast = $index === $rootCount - 1;
|
||||
$rootAmount = $isLast ? bcsub($totalCommission, $assigned, 2) : $perRoot;
|
||||
if (!$isLast) {
|
||||
$assigned = bcadd($assigned, $rootAmount, 2);
|
||||
}
|
||||
if (bccomp($rootAmount, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
self::appendNodeTree($rootId, $rootAmount, 0, 0, '100.00', $nodes);
|
||||
}
|
||||
}
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}> $nodes
|
||||
*/
|
||||
private static function appendNodeTree(
|
||||
int $adminId,
|
||||
string $incomingAmount,
|
||||
int $level,
|
||||
int $parentAdminId,
|
||||
string $shareRateFromParent,
|
||||
array &$nodes
|
||||
): void {
|
||||
if ($adminId <= 0 || bccomp($incomingAmount, '0', 2) <= 0) {
|
||||
return;
|
||||
}
|
||||
$adminRow = Db::name('admin')
|
||||
->where('id', $adminId)
|
||||
->field(['id', 'username', 'commission_share_rate'])
|
||||
->find();
|
||||
if (!is_array($adminRow)) {
|
||||
return;
|
||||
}
|
||||
$children = Db::name('admin')
|
||||
->where('parent_admin_id', $adminId)
|
||||
->where('status', 'enable')
|
||||
->order('id', 'asc')
|
||||
->field(['id', 'commission_share_rate'])
|
||||
->select()
|
||||
->toArray();
|
||||
$givenToChildren = '0.00';
|
||||
$childPlans = [];
|
||||
foreach ($children as $child) {
|
||||
$childId = intval($child['id'] ?? 0);
|
||||
if ($childId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2);
|
||||
if (bccomp($rate, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$childAmount = bcmul($incomingAmount, bcdiv($rate, '100', 4), 2);
|
||||
if (bccomp($childAmount, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
|
||||
$childPlans[] = ['id' => $childId, 'amount' => $childAmount, 'rate' => $rate];
|
||||
}
|
||||
$selfKeep = bcsub($incomingAmount, $givenToChildren, 2);
|
||||
$nodes[] = [
|
||||
'admin_id' => $adminId,
|
||||
'admin_username' => strval($adminRow['username'] ?? ('#' . $adminId)),
|
||||
'parent_admin_id' => $parentAdminId,
|
||||
'level' => $level,
|
||||
'settlement_base_amount' => $incomingAmount,
|
||||
'share_rate' => $shareRateFromParent,
|
||||
'commission_amount' => bccomp($selfKeep, '0', 2) > 0 ? $selfKeep : '0.00',
|
||||
];
|
||||
foreach ($childPlans as $plan) {
|
||||
self::appendNodeTree(intval($plan['id']), strval($plan['amount']), $level + 1, $adminId, strval($plan['rate']), $nodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> admin_id => amount
|
||||
*/
|
||||
private static function distributeFromAdmin(int $adminId, string $amount, string $calcBaseAmount): array
|
||||
{
|
||||
unset($calcBaseAmount);
|
||||
if ($adminId <= 0 || bccomp($amount, '0', 2) <= 0) {
|
||||
return [];
|
||||
}
|
||||
$children = Db::name('admin')
|
||||
->where('parent_admin_id', $adminId)
|
||||
->where('status', 'enable')
|
||||
->order('id', 'asc')
|
||||
->field(['id', 'commission_share_rate'])
|
||||
->select()
|
||||
->toArray();
|
||||
$givenToChildren = '0.00';
|
||||
$result = [];
|
||||
foreach ($children as $child) {
|
||||
$childId = intval($child['id'] ?? 0);
|
||||
if ($childId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2);
|
||||
if (bccomp($rate, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$childAmount = bcmul($amount, bcdiv($rate, '100', 4), 2);
|
||||
if (bccomp($childAmount, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
|
||||
$childParts = self::distributeFromAdmin($childId, $childAmount, '0.00');
|
||||
foreach ($childParts as $aid => $part) {
|
||||
if (!isset($result[$aid])) {
|
||||
$result[$aid] = '0.00';
|
||||
}
|
||||
$result[$aid] = bcadd($result[$aid], $part, 2);
|
||||
}
|
||||
}
|
||||
$selfKeep = bcsub($amount, $givenToChildren, 2);
|
||||
if (bccomp($selfKeep, '0', 2) > 0) {
|
||||
if (!isset($result[$adminId])) {
|
||||
$result[$adminId] = '0.00';
|
||||
}
|
||||
$result[$adminId] = bcadd($result[$adminId], $selfKeep, 2);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class AdminWalletService
|
||||
if (is_array($existing)) {
|
||||
$existAdminId = intval($existing['admin_id'] ?? 0);
|
||||
if ($existAdminId !== $adminId) {
|
||||
return ['ok' => false, 'msg' => 'Idempotency key conflict'];
|
||||
return ['ok' => false, 'msg' => __('Idempotency key conflict')];
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
@@ -90,7 +90,7 @@ class AdminWalletService
|
||||
$wallet = self::ensureWallet($adminId);
|
||||
$before = strval($wallet['balance'] ?? '0.00');
|
||||
if (bccomp($before, $withdrawCoin, 2) < 0) {
|
||||
return ['ok' => false, 'msg' => '钱包余额不足'];
|
||||
return ['ok' => false, 'msg' => __('Insufficient wallet balance')];
|
||||
}
|
||||
$after = bcsub($before, $withdrawCoin, 2);
|
||||
$beforeFrozen = strval($wallet['frozen_balance'] ?? '0.00');
|
||||
|
||||
@@ -9,23 +9,38 @@ use Throwable;
|
||||
|
||||
class ChannelSettlementService
|
||||
{
|
||||
public static function settleBySuperAdmin(int $channelId, int $operatorAdminId, string $remark = '', bool $auto = false): array
|
||||
{
|
||||
/**
|
||||
* @param array<int, string>|null $handlingFeeByAdmin admin_id => handling fee
|
||||
*/
|
||||
public static function settleBySuperAdmin(
|
||||
int $channelId,
|
||||
int $operatorAdminId,
|
||||
string $remark = '',
|
||||
bool $auto = false,
|
||||
?array $handlingFeeByAdmin = null
|
||||
): array {
|
||||
$channel = Db::name('channel')->where('id', $channelId)->find();
|
||||
if (!is_array($channel)) {
|
||||
return ['ok' => false, 'msg' => '渠道不存在'];
|
||||
return ['ok' => false, 'msg' => __('Channel not found')];
|
||||
}
|
||||
$payload = self::buildSettlePayload($channel);
|
||||
if (is_string($payload)) {
|
||||
return ['ok' => false, 'msg' => $payload];
|
||||
}
|
||||
$defaultFee = bcadd(strval($channel['settlement_handling_fee'] ?? '0'), '0', 2);
|
||||
$settlementNo = self::generateAgentSettlementNo($auto ? 'A' : 'M', $channelId, intval($payload['period_end_ts']));
|
||||
if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) {
|
||||
return ['ok' => false, 'msg' => '结算单号冲突,请重试'];
|
||||
return ['ok' => false, 'msg' => __('Settlement number conflict, please retry')];
|
||||
}
|
||||
$shareRows = self::resolveCommissionSharesForChannel($channelId);
|
||||
if ($shareRows === []) {
|
||||
return ['ok' => false, 'msg' => '渠道下无可用管理员分配比例,无法结算'];
|
||||
$distributions = AdminCommissionDistributionService::distributeChannelCommission(
|
||||
$channelId,
|
||||
strval($payload['commission_amount']),
|
||||
strval($payload['calc_base_amount']),
|
||||
$defaultFee,
|
||||
$handlingFeeByAdmin
|
||||
);
|
||||
if ($distributions === []) {
|
||||
return ['ok' => false, 'msg' => __('No channel root agent configured for commission distribution')];
|
||||
}
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
@@ -42,21 +57,19 @@ class ChannelSettlementService
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]));
|
||||
$rows = self::buildCommissionRowsForSplit(
|
||||
$shareRows,
|
||||
$rows = self::buildCommissionRowsFromDistribution(
|
||||
$distributions,
|
||||
$channelId,
|
||||
$periodId,
|
||||
strval($payload['calc_base_amount']),
|
||||
strval($payload['commission_amount']),
|
||||
$remark !== '' ? $remark : '渠道待分红记录',
|
||||
$now
|
||||
);
|
||||
if ($rows === []) {
|
||||
throw new \RuntimeException('生成待分红记录失败');
|
||||
throw new \RuntimeException('Failed to generate commission rows');
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$adminId = intval($row['admin_id'] ?? 0);
|
||||
$amount = strval($row['commission_amount'] ?? '0.00');
|
||||
$netAmount = strval($row['net_commission_amount'] ?? '0.00');
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -65,11 +78,11 @@ class ChannelSettlementService
|
||||
$row['remark'] = strval($row['remark'] ?? '') . ' | 超管结算直接发放';
|
||||
$row['update_time'] = $now;
|
||||
$commissionRecordId = intval(Db::name('agent_commission_record')->insertGetId($row));
|
||||
if (bccomp($amount, '0.00', 2) > 0) {
|
||||
if (bccomp($netAmount, '0.00', 2) > 0) {
|
||||
AdminWalletService::creditCommission(
|
||||
$adminId,
|
||||
$channelId,
|
||||
$amount,
|
||||
$netAmount,
|
||||
'agent_commission_record',
|
||||
$commissionRecordId,
|
||||
$remark !== '' ? $remark : '超管结算自动发放分红',
|
||||
@@ -92,12 +105,19 @@ class ChannelSettlementService
|
||||
|
||||
public static function settleDividendByChannelAdmin(int $channelId, int $operatorAdminId, string $remark = ''): array
|
||||
{
|
||||
return ['ok' => false, 'msg' => '当前流程为超管结算后自动发放,渠道管理员无需二次结算'];
|
||||
return ['ok' => false, 'msg' => __('This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again')];
|
||||
}
|
||||
|
||||
public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true): array
|
||||
public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true, ?array $channelIds = null): array
|
||||
{
|
||||
$channels = Db::name('channel')->where('status', 1)->select()->toArray();
|
||||
$query = Db::name('channel')->where('status', 1);
|
||||
if ($channelIds !== null) {
|
||||
if ($channelIds === []) {
|
||||
return ['ok_count' => 0, 'failed' => []];
|
||||
}
|
||||
$query->whereIn('id', $channelIds);
|
||||
}
|
||||
$channels = $query->select()->toArray();
|
||||
$ok = 0;
|
||||
$failed = [];
|
||||
$now = time();
|
||||
@@ -159,14 +179,14 @@ class ChannelSettlementService
|
||||
{
|
||||
$channelId = intval($row['id'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
return '渠道数据异常';
|
||||
return (string) __('Invalid channel data');
|
||||
}
|
||||
$endTs = time();
|
||||
$lastEnd = self::getLastSettlementEndForChannel($channelId);
|
||||
$channelCreateTs = intval($row['create_time'] ?? 0);
|
||||
$periodStartTs = $lastEnd === null ? ($channelCreateTs > 0 ? $channelCreateTs : 0) : $lastEnd;
|
||||
if ($periodStartTs >= $endTs) {
|
||||
return '结算区间无效(开始时间不早于当前)';
|
||||
return (string) __('Invalid settlement period (start time is not earlier than now)');
|
||||
}
|
||||
$stats = self::aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs);
|
||||
$totalBet = $stats['total_bet'];
|
||||
@@ -189,7 +209,13 @@ class ChannelSettlementService
|
||||
'calc_base_amount' => $commission['calc_base_amount'],
|
||||
'commission_amount' => $commission['commission_amount'],
|
||||
'agent_mode' => $mode,
|
||||
'commission_split' => self::buildCommissionSplitPreview(self::resolveCommissionSharesForChannel($channelId), $commission['commission_amount']),
|
||||
'settlement_handling_fee' => bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2),
|
||||
'commission_split' => AdminCommissionDistributionService::buildSplitPreview(
|
||||
$channelId,
|
||||
$commission['commission_amount'],
|
||||
$commission['calc_base_amount'],
|
||||
bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -234,7 +260,7 @@ class ChannelSettlementService
|
||||
if ($mode === 'turnover') {
|
||||
$ratePercent = $row['turnover_share_rate'] ?? null;
|
||||
if ($ratePercent === null || $ratePercent === '') {
|
||||
return '普通返水代理未配置返水分红比例';
|
||||
return (string) __('Turnover agent commission rate is not configured');
|
||||
}
|
||||
$rateDec = bcdiv(strval($ratePercent), '100', 4);
|
||||
return [
|
||||
@@ -247,11 +273,11 @@ class ChannelSettlementService
|
||||
$fee = $row['affiliate_fee_rate'] ?? null;
|
||||
$rulesRaw = $row['affiliate_ladder_rules'] ?? null;
|
||||
if ($fee === null || $fee === '') {
|
||||
return '联营代理未配置成本扣除比例';
|
||||
return (string) __('Affiliate agent fee rate is not configured');
|
||||
}
|
||||
$rules = self::normalizeLadderRulesForSettlement($rulesRaw);
|
||||
if ($rules === []) {
|
||||
return '联营阶梯规则无效或为空';
|
||||
return (string) __('Affiliate ladder rules are empty or invalid');
|
||||
}
|
||||
if (bccomp($platformProfit, '0', 2) <= 0) {
|
||||
return ['commission_rate' => '0.0000', 'calc_base_amount' => '0.00', 'commission_amount' => '0.00'];
|
||||
@@ -268,7 +294,7 @@ class ChannelSettlementService
|
||||
'commission_amount' => bcmul($afterFee, $rateDec, 2),
|
||||
];
|
||||
}
|
||||
return '未知的代理模式';
|
||||
return (string) __('Unknown agent mode');
|
||||
}
|
||||
|
||||
private static function normalizeLadderRulesForSettlement(mixed $rulesRaw): array
|
||||
@@ -325,93 +351,39 @@ class ChannelSettlementService
|
||||
return $base . strtoupper(substr(bin2hex(random_bytes(4)), 0, 2));
|
||||
}
|
||||
|
||||
private static function resolveCommissionSharesForChannel(int $channelId): array
|
||||
/**
|
||||
* @param array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee:string,net_commission_amount:string}> $distributions
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function buildCommissionRowsFromDistribution(array $distributions, int $channelId, int $periodId, string $remark, int $now): array
|
||||
{
|
||||
$rows = Db::name('channel_admin_share')->alias('cas')
|
||||
->join('admin a', 'cas.admin_id = a.id')
|
||||
->field(['cas.admin_id', 'cas.share_rate'])
|
||||
->where('cas.channel_id', $channelId)
|
||||
->where('cas.status', 1)
|
||||
->where('a.status', 'enable')
|
||||
->order('cas.admin_id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
$sum = '0.00';
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = intval($row['admin_id'] ?? 0);
|
||||
$shareRate = bcadd(strval($row['share_rate'] ?? '0'), '0', 2);
|
||||
if ($adminId <= 0 || bccomp($shareRate, '0', 2) <= 0) {
|
||||
$rows = [];
|
||||
foreach ($distributions as $dist) {
|
||||
$adminId = intval($dist['admin_id'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$sum = bcadd($sum, $shareRate, 2);
|
||||
$out[] = ['admin_id' => $adminId, 'share_rate' => $shareRate];
|
||||
}
|
||||
if ($out === [] || bccomp($sum, '100.00', 2) !== 0) {
|
||||
return [];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function buildCommissionRowsForSplit(array $shareRows, int $channelId, int $periodId, string $calcBaseAmount, string $commissionTotal, string $remark, int $now): array
|
||||
{
|
||||
$sum = '0.00';
|
||||
$rows = [];
|
||||
$lastIndex = count($shareRows) - 1;
|
||||
foreach ($shareRows as $index => $shareRow) {
|
||||
$shareRate = bcadd(strval($shareRow['share_rate'] ?? '0.00'), '0', 2);
|
||||
$shareDec = bcdiv($shareRate, '100', 4);
|
||||
$amount = $index === $lastIndex ? bcsub($commissionTotal, $sum, 2) : bcmul($commissionTotal, $shareDec, 2);
|
||||
if ($index !== $lastIndex) {
|
||||
$sum = bcadd($sum, $amount, 2);
|
||||
}
|
||||
$effectiveRate = bccomp($calcBaseAmount, '0', 2) <= 0 ? '0.0000' : bcdiv($amount, $calcBaseAmount, 6);
|
||||
$amount = strval($dist['commission_amount'] ?? '0.00');
|
||||
$rows[] = [
|
||||
'settlement_period_id' => $periodId,
|
||||
'channel_id' => $channelId,
|
||||
'admin_id' => intval($shareRow['admin_id'] ?? 0),
|
||||
'commission_rate' => $effectiveRate,
|
||||
'calc_base_amount' => $calcBaseAmount,
|
||||
'admin_id' => $adminId,
|
||||
'commission_rate' => strval($dist['commission_rate'] ?? '0.0000'),
|
||||
'share_rate' => strval($dist['share_rate'] ?? '0.00'),
|
||||
'calc_base_amount' => strval($dist['calc_base_amount'] ?? '0.00'),
|
||||
'commission_amount' => $amount,
|
||||
'commission_share_percent' => strval($dist['commission_share_percent'] ?? '0.00'),
|
||||
'handling_fee' => strval($dist['handling_fee'] ?? '0.00'),
|
||||
'handling_fee_rate' => strval($dist['handling_fee_rate'] ?? '0.00'),
|
||||
'net_commission_amount' => strval($dist['net_commission_amount'] ?? '0.00'),
|
||||
'status' => 0,
|
||||
'settled_at' => null,
|
||||
'remark' => $remark . ' | 分配比例=' . $shareRate . '%',
|
||||
'remark' => $remark . ' | 树形分红实发',
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private static function buildCommissionSplitPreview(array $shareRows, string $commissionTotal): array
|
||||
{
|
||||
if ($shareRows === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = array_map(static fn(array $row): int => intval($row['admin_id'] ?? 0), $shareRows);
|
||||
$adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id');
|
||||
$sum = '0.00';
|
||||
$out = [];
|
||||
$lastIndex = count($shareRows) - 1;
|
||||
foreach ($shareRows as $index => $shareRow) {
|
||||
$shareRate = bcadd(strval($shareRow['share_rate'] ?? '0.00'), '0', 2);
|
||||
$shareDec = bcdiv($shareRate, '100', 4);
|
||||
$amount = $index === $lastIndex ? bcsub($commissionTotal, $sum, 2) : bcmul($commissionTotal, $shareDec, 2);
|
||||
if ($index !== $lastIndex) {
|
||||
$sum = bcadd($sum, $amount, 2);
|
||||
}
|
||||
$aid = intval($shareRow['admin_id'] ?? 0);
|
||||
$out[] = [
|
||||
'admin_id' => $aid,
|
||||
'admin_username' => strval($adminNames[$aid] ?? ('#' . $aid)),
|
||||
'share_rate' => $shareRate,
|
||||
'commission_amount' => $amount,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 充值待支付订单超时处理:
|
||||
* - 同用户最多允许 3 笔待支付订单
|
||||
* - 待支付订单创建后 60 秒未支付,自动标记为失败并写失败原因
|
||||
*/
|
||||
final class DepositOrderExpireService
|
||||
{
|
||||
public const MAX_PENDING_DEPOSIT = 3;
|
||||
public const EXPIRE_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* 超时失效处理。
|
||||
*
|
||||
* @param int|null $userId 仅处理某用户;null 表示不过滤用户
|
||||
* @param string|null $orderNo 仅处理某订单;null 表示不过滤订单
|
||||
*
|
||||
* @return int 本次转失败的订单数
|
||||
*/
|
||||
public static function expirePendingOrders(?int $userId = null, ?string $orderNo = null): int
|
||||
{
|
||||
$expireBefore = time() - self::EXPIRE_SECONDS;
|
||||
$query = Db::name('deposit_order')
|
||||
->where('status', 0)
|
||||
->where('create_time', '<=', $expireBefore);
|
||||
if ($userId !== null && $userId > 0) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
if ($orderNo !== null && $orderNo !== '') {
|
||||
$query->where('order_no', $orderNo);
|
||||
}
|
||||
$rows = $query->field(['id', 'remark'])->select()->toArray();
|
||||
if ($rows === []) {
|
||||
return 0;
|
||||
}
|
||||
$now = time();
|
||||
$affectedCount = 0;
|
||||
foreach ($rows as $row) {
|
||||
$id = isset($row['id']) && is_numeric($row['id']) ? intval($row['id']) : 0;
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$oldRemark = isset($row['remark']) && is_string($row['remark']) ? trim($row['remark']) : '';
|
||||
$reason = '[timeout] unpaid over ' . self::EXPIRE_SECONDS . 's';
|
||||
$remark = $oldRemark === '' ? $reason : mb_substr($oldRemark . ' | ' . $reason, 0, 255);
|
||||
$affected = Db::name('deposit_order')
|
||||
->where('id', $id)
|
||||
->where('status', 0)
|
||||
->update([
|
||||
'status' => 2,
|
||||
'remark' => $remark,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if (is_numeric($affected) && intval($affected) > 0) {
|
||||
$affectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $affectedCount;
|
||||
}
|
||||
|
||||
public static function pendingCountByUserId(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Db::name('deposit_order')
|
||||
->where('user_id', $userId)
|
||||
->where('status', 0)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
|
||||
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 充值待支付订单超时处理:
|
||||
|
||||
* - 同用户最多允许 3 笔待支付订单
|
||||
|
||||
* - 待支付订单创建后超过有效期未支付,自动标记为失败
|
||||
|
||||
* - 有效秒数由 .env DEPOSIT_PENDING_EXPIRE_SECONDS 配置(默认 60),全渠道统一
|
||||
|
||||
*/
|
||||
|
||||
final class DepositOrderExpireService
|
||||
|
||||
{
|
||||
|
||||
public const MAX_PENDING_DEPOSIT = 3;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 待支付充值单有效秒数(与 config app.deposit_pending_expire_seconds / .env 一致)
|
||||
|
||||
*/
|
||||
|
||||
public static function pendingExpireSeconds(): int
|
||||
|
||||
{
|
||||
|
||||
$cfg = config('app.deposit_pending_expire_seconds', 60);
|
||||
|
||||
if (is_numeric($cfg)) {
|
||||
|
||||
$v = intval($cfg);
|
||||
|
||||
if ($v > 0) {
|
||||
|
||||
return $v;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return 60;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* @param array<string, mixed> $order
|
||||
|
||||
*/
|
||||
|
||||
public static function expireSecondsForOrder(array $order): int
|
||||
|
||||
{
|
||||
|
||||
return self::pendingExpireSeconds();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* @param int|null $userId 仅处理某用户;null 表示不过滤用户
|
||||
|
||||
* @param string|null $orderNo 仅处理某订单;null 表示不过滤订单
|
||||
|
||||
*
|
||||
|
||||
* @return int 本次转失败的订单数
|
||||
|
||||
*/
|
||||
|
||||
public static function expirePendingOrders(?int $userId = null, ?string $orderNo = null): int
|
||||
|
||||
{
|
||||
|
||||
$query = Db::name('deposit_order')->where('status', 0);
|
||||
|
||||
if ($userId !== null && $userId > 0) {
|
||||
|
||||
$query->where('user_id', $userId);
|
||||
|
||||
}
|
||||
|
||||
if ($orderNo !== null && $orderNo !== '') {
|
||||
|
||||
$query->where('order_no', $orderNo);
|
||||
|
||||
}
|
||||
|
||||
$rows = $query->field(['id', 'remark', 'pay_channel', 'create_time'])->select()->toArray();
|
||||
|
||||
if ($rows === []) {
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
$expireSec = self::pendingExpireSeconds();
|
||||
|
||||
$affectedCount = 0;
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use support\Log;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
@@ -14,23 +16,116 @@ use Throwable;
|
||||
*/
|
||||
final class GameBetSettleService
|
||||
{
|
||||
public const PLAY_STATUS_PENDING_DRAW = 1;
|
||||
public const PLAY_STATUS_SETTLED = 2;
|
||||
public const PLAY_STATUS_REFUNDED = 3;
|
||||
public const PLAY_STATUS_RETURNED = 4;
|
||||
public const PLAY_STATUS_PENDING_REVIEW = 5;
|
||||
|
||||
public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount';
|
||||
|
||||
/** 本期中奖(含小奖/大奖档):移动端弹窗/通知统一入口,以 is_jackpot 区分 */
|
||||
public const TOPIC_BET_WIN = 'bet.win';
|
||||
|
||||
public const TOPIC_JACKPOT_HIT = 'jackpot.hit';
|
||||
|
||||
/** 每期结算推送去重(避免事务重试 / recover 重复推 user.streak、wallet.changed) */
|
||||
private const SETTLE_NOTIFY_DEDUP_PREFIX = 'dfw:v1:settle:notify:';
|
||||
|
||||
/** 每期每用户 bet.win 去重(与 streak/wallet 分离,避免整期 dedup 吞掉中奖推送) */
|
||||
private const BET_WIN_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:betwin:';
|
||||
|
||||
/** jackpot.hit 去重(独立于 settleNotify,避免 recover/补偿路径漏广播) */
|
||||
private const JACKPOT_HIT_DEDUP_PREFIX = 'dfw:v1:ws:jackpot_hit:';
|
||||
|
||||
/**
|
||||
* 从 bet.win 聚合载荷重建 jackpot.hit 广播(用于兜底:jackpot_hits 聚合缺失或旧逻辑漏记时)。
|
||||
*
|
||||
* @param list<array<string, mixed>> $betWins
|
||||
* @return list<array{user_id:int,nickname:string,period_no:string,total_win:string,result_number:int}>
|
||||
*/
|
||||
private static function buildJackpotHitsFromBetWins(array $betWins, string $periodNo, int $resultNumber): array
|
||||
{
|
||||
if ($betWins === [] || $periodNo === '' || $resultNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
$byUser = [];
|
||||
foreach ($betWins as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (empty($row['is_jackpot'])) {
|
||||
continue;
|
||||
}
|
||||
$uid = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($uid === false || $uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$total = isset($row['total_win']) && is_scalar($row['total_win']) ? (string) $row['total_win'] : '0.00';
|
||||
if (!isset($byUser[$uid])) {
|
||||
$byUser[$uid] = '0.00';
|
||||
}
|
||||
$byUser[$uid] = bcadd((string) $byUser[$uid], bcadd($total, '0', 2), 2);
|
||||
}
|
||||
if ($byUser === []) {
|
||||
return [];
|
||||
}
|
||||
$userIds = array_map('intval', array_keys($byUser));
|
||||
$nameMap = self::loadUserDisplayNames($userIds);
|
||||
$out = [];
|
||||
foreach ($userIds as $uid) {
|
||||
$out[] = [
|
||||
'user_id' => $uid,
|
||||
'nickname' => $nameMap[$uid] ?? ('用户' . $uid),
|
||||
'period_no' => $periodNo,
|
||||
'total_win' => (string) ($byUser[$uid] ?? '0.00'),
|
||||
'result_number' => $resultNumber,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
|
||||
*
|
||||
* @return array{jackpot_hits: list<array{user_id: int, period_no: string, total_win: string, result_number: int}>}
|
||||
* @return array{
|
||||
* jackpot_hits: list<array{user_id: int, nickname: string, period_no: string, total_win: string, result_number: int}>,
|
||||
* bet_wins: list<array<string, mixed>>
|
||||
* }
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
|
||||
{
|
||||
if ($recordId <= 0 || $resultNumber < 1) {
|
||||
return ['jackpot_hits' => []];
|
||||
return [
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => [],
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$periodStatus = filter_var(
|
||||
Db::name('game_record')->where('id', $recordId)->value('status'),
|
||||
FILTER_VALIDATE_INT
|
||||
);
|
||||
if ($periodStatus === 5) {
|
||||
return [
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => [],
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$jackpotMaxAmount = self::jackpotMaxAmount();
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $recordId)
|
||||
->where('status', 1)
|
||||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
@@ -44,7 +139,26 @@ final class GameBetSettleService
|
||||
/** @var array<int, true> */
|
||||
$jackpotNotify = [];
|
||||
|
||||
/** @var array<int, array{user_id: int, period_id: int, period_no: string, result_number: int, total_win: string, balance_after: string, is_jackpot: bool, bets: list<array{bet_id: int, win_amount: string}>}> */
|
||||
$winByUser = [];
|
||||
|
||||
/** @var list<array{user_id: int, current_streak: int, extra: array<string, mixed>}> */
|
||||
$userStreakEvents = [];
|
||||
|
||||
/** @var list<array<string, mixed>> */
|
||||
$walletEvents = [];
|
||||
|
||||
$settledOrderCount = 0;
|
||||
|
||||
foreach ($bets as $bet) {
|
||||
$periodStatusNow = filter_var(
|
||||
Db::name('game_record')->where('id', $recordId)->value('status'),
|
||||
FILTER_VALIDATE_INT
|
||||
);
|
||||
if ($periodStatusNow === 5) {
|
||||
break;
|
||||
}
|
||||
|
||||
$betId = (int) ($bet['id'] ?? 0);
|
||||
if ($betId <= 0) {
|
||||
continue;
|
||||
@@ -60,14 +174,16 @@ final class GameBetSettleService
|
||||
|
||||
$win = self::computeWinAmount($bet, $resultNumber);
|
||||
$jackpot = '0.00';
|
||||
$needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount);
|
||||
$nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED;
|
||||
|
||||
$affected = Db::name('bet_order')
|
||||
->where('id', $betId)
|
||||
->where('status', 1)
|
||||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||||
->update([
|
||||
'win_amount' => $win,
|
||||
'jackpot_extra_amount' => $jackpot,
|
||||
'status' => 2,
|
||||
'status' => $nextStatus,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
@@ -75,13 +191,21 @@ final class GameBetSettleService
|
||||
continue;
|
||||
}
|
||||
|
||||
$settledOrderCount++;
|
||||
|
||||
self::creditUserBetFlow($bet, $now);
|
||||
|
||||
$jackpotFlags = ['is_jackpot' => false, 'payout_pending_review' => false];
|
||||
if (bccomp($win, '0', 2) > 0) {
|
||||
$jackpotFlags = self::betWinJackpotFlagsForOrder($bet, $win, $needReview);
|
||||
}
|
||||
|
||||
if ($userId > 0) {
|
||||
if (bccomp($win, '0', 2) > 0) {
|
||||
$userOutcome[$userId]['had_win'] = true;
|
||||
}
|
||||
if (bccomp($win, '0', 2) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) {
|
||||
// 规则统一:只要 bet.win 判定为中大奖(is_jackpot=true),就必须广播 jackpot.hit 给所有订阅者
|
||||
if (bccomp($win, '0', 2) > 0 && ($jackpotFlags['is_jackpot'] ?? false) === true) {
|
||||
$jackpotNotify[$userId] = true;
|
||||
}
|
||||
}
|
||||
@@ -91,13 +215,45 @@ final class GameBetSettleService
|
||||
}
|
||||
|
||||
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||
if (bccomp($win, '0', 2) > 0) {
|
||||
$paid = self::creditUserPayout($bet, $betId, $win, $now);
|
||||
if ($paid !== null) {
|
||||
$balanceAfter = $paid;
|
||||
if (!$needReview && bccomp($win, '0', 2) > 0) {
|
||||
$paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩', $resultNumber, false);
|
||||
if (is_array($paid)) {
|
||||
$balanceAfter = (string) ($paid['balance_after'] ?? $balanceAfter);
|
||||
$walletPayload = $paid['wallet_payload'] ?? null;
|
||||
if (is_array($walletPayload)) {
|
||||
$walletEvents[] = $walletPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bccomp($win, '0', 2) > 0) {
|
||||
if (!isset($winByUser[$userId])) {
|
||||
$winByUser[$userId] = [
|
||||
'user_id' => $userId,
|
||||
'period_id' => $recordId,
|
||||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||||
'result_number' => $resultNumber,
|
||||
'total_win' => '0.00',
|
||||
'balance_after' => $balanceAfter,
|
||||
'is_jackpot' => false,
|
||||
'payout_pending_review' => false,
|
||||
'bets' => [],
|
||||
];
|
||||
}
|
||||
if ($jackpotFlags['is_jackpot']) {
|
||||
$winByUser[$userId]['is_jackpot'] = true;
|
||||
}
|
||||
if ($jackpotFlags['payout_pending_review']) {
|
||||
$winByUser[$userId]['payout_pending_review'] = true;
|
||||
}
|
||||
$winByUser[$userId]['total_win'] = bcadd($winByUser[$userId]['total_win'], $win, 2);
|
||||
$winByUser[$userId]['balance_after'] = $balanceAfter;
|
||||
$winByUser[$userId]['bets'][] = [
|
||||
'bet_id' => $betId,
|
||||
'win_amount' => $win,
|
||||
];
|
||||
}
|
||||
|
||||
$periodNo = (string) ($bet['period_no'] ?? '');
|
||||
if (!isset($aggregateByUser[$userId])) {
|
||||
$aggregateByUser[$userId] = [
|
||||
@@ -132,9 +288,52 @@ final class GameBetSettleService
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||
$periodNo = isset($aggregateByUser[$userId]['period_no']) ? (string) $aggregateByUser[$userId]['period_no'] : '';
|
||||
$userStreakEvents[] = [
|
||||
'user_id' => $userId,
|
||||
'current_streak' => $next,
|
||||
'extra' => [
|
||||
'is_win' => $hadWin,
|
||||
'period_id' => $recordId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'settled_at' => $now,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 兜底:若已判定本期中奖(is_win=true),但聚合中奖事件意外缺失,补一条 bet.win,保证客户端可感知中奖。
|
||||
foreach ($userOutcome as $userId => $info) {
|
||||
$hadWin = (bool) ($info['had_win'] ?? false);
|
||||
if (!$hadWin || isset($winByUser[$userId])) {
|
||||
continue;
|
||||
}
|
||||
$agg = $aggregateByUser[$userId] ?? null;
|
||||
if (!is_array($agg) || bccomp((string) ($agg['total_win'] ?? '0.00'), '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$isJackpotTier = isset($jackpotNotify[$userId]);
|
||||
$winByUser[$userId] = [
|
||||
'user_id' => $userId,
|
||||
'period_id' => $recordId,
|
||||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||||
'result_number' => $resultNumber,
|
||||
'total_win' => (string) ($agg['total_win'] ?? '0.00'),
|
||||
'balance_after' => (string) ($agg['balance_after'] ?? '0'),
|
||||
'is_jackpot' => $isJackpotTier,
|
||||
'payout_pending_review' => false,
|
||||
'bets' => [],
|
||||
];
|
||||
Log::warning('bet.win fallback emitted for settled winner', [
|
||||
'user_id' => $userId,
|
||||
'period_id' => $recordId,
|
||||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||||
'is_jackpot' => $isJackpotTier,
|
||||
]);
|
||||
}
|
||||
|
||||
$jackpotHits = [];
|
||||
$hitUserIds = [];
|
||||
foreach ($jackpotNotify as $uid => $_) {
|
||||
if (!isset($aggregateByUser[$uid])) {
|
||||
continue;
|
||||
@@ -143,15 +342,593 @@ final class GameBetSettleService
|
||||
if (bccomp($agg['total_win'], '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$hitUserIds[] = (int) $uid;
|
||||
}
|
||||
$userNameMap = self::loadUserDisplayNames($hitUserIds);
|
||||
foreach ($hitUserIds as $uid) {
|
||||
$agg = $aggregateByUser[$uid];
|
||||
$jackpotHits[] = [
|
||||
'user_id' => (int) $uid,
|
||||
'user_id' => $uid,
|
||||
'nickname' => $userNameMap[$uid] ?? ('用户' . $uid),
|
||||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||||
'total_win' => (string) $agg['total_win'],
|
||||
'result_number' => $resultNumber,
|
||||
];
|
||||
}
|
||||
|
||||
return ['jackpot_hits' => $jackpotHits];
|
||||
$betWins = array_values($winByUser);
|
||||
|
||||
return [
|
||||
'jackpot_hits' => $jackpotHits,
|
||||
'bet_wins' => $betWins,
|
||||
'user_streak_events' => $userStreakEvents,
|
||||
'wallet_events' => $walletEvents,
|
||||
'settled_order_count' => $settledOrderCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务提交后推送本期中奖(bet.win,小奖/大奖统一;data.is_jackpot 区分档位)。
|
||||
*
|
||||
* @param list<array<string, mixed>> $betWins
|
||||
*/
|
||||
public static function publishBetWinsAfterCommit(array $betWins, int $periodId = 0): void
|
||||
{
|
||||
$now = time();
|
||||
foreach ($betWins as $payload) {
|
||||
if (!is_array($payload)) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($periodId > 0 && self::hasBetWinNotifyMarked($periodId, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$isJackpot = !empty($payload['is_jackpot']);
|
||||
$payoutPendingReview = !empty($payload['payout_pending_review']);
|
||||
$data = GameWebSocketPayloadHelper::mergeUserStreakInto(array_merge($payload, [
|
||||
'is_jackpot' => $isJackpot,
|
||||
'is_win' => true,
|
||||
'payout_pending_review' => $payoutPendingReview,
|
||||
'server_time' => $now,
|
||||
]), $userId);
|
||||
$ok = GameWebSocketEventBus::publish(self::TOPIC_BET_WIN, $data);
|
||||
if (!$ok) {
|
||||
Log::warning('bet.win publish failed (will retry next round)', [
|
||||
'period_id' => $periodId,
|
||||
'user_id' => $userId,
|
||||
'total_win' => $payload['total_win'] ?? '',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if ($periodId > 0) {
|
||||
self::markBetWinNotifyOnce($periodId, $userId);
|
||||
}
|
||||
Log::info('bet.win published', [
|
||||
'period_id' => $periodId,
|
||||
'user_id' => $userId,
|
||||
'total_win' => $payload['total_win'] ?? '',
|
||||
'is_jackpot' => $isJackpot,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从库内已结算中奖注单重建 bet.win 载荷(结算内存聚合缺失或推送被 dedup 拦截时的补偿)。
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function buildBetWinPayloadsFromSettledOrders(int $periodId, int $resultNumber): array
|
||||
{
|
||||
if ($periodId <= 0 || $resultNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->whereIn('status', [self::PLAY_STATUS_SETTLED, self::PLAY_STATUS_PENDING_REVIEW])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
/** @var array<int, array<string, mixed>> $winByUser */
|
||||
$winByUser = [];
|
||||
foreach ($rows as $bet) {
|
||||
if (!is_array($bet)) {
|
||||
continue;
|
||||
}
|
||||
$win = bcadd((string) ($bet['win_amount'] ?? '0'), '0', 2);
|
||||
if (bccomp($win, '0', 2) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($bet['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$orderStatus = filter_var($bet['status'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$needReview = $orderStatus === self::PLAY_STATUS_PENDING_REVIEW;
|
||||
$jackpotFlags = self::betWinJackpotFlagsForOrder($bet, $win, $needReview);
|
||||
if (!isset($winByUser[$userId])) {
|
||||
$coin = Db::name('user')->where('id', $userId)->value('coin');
|
||||
$winByUser[$userId] = [
|
||||
'user_id' => $userId,
|
||||
'period_id' => $periodId,
|
||||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||||
'result_number' => $resultNumber,
|
||||
'total_win' => '0.00',
|
||||
'balance_after' => (string) ($coin ?? '0'),
|
||||
'is_jackpot' => false,
|
||||
'payout_pending_review' => false,
|
||||
'bets' => [],
|
||||
];
|
||||
}
|
||||
if ($jackpotFlags['is_jackpot']) {
|
||||
$winByUser[$userId]['is_jackpot'] = true;
|
||||
}
|
||||
if ($jackpotFlags['payout_pending_review']) {
|
||||
$winByUser[$userId]['payout_pending_review'] = true;
|
||||
}
|
||||
$winByUser[$userId]['total_win'] = bcadd((string) $winByUser[$userId]['total_win'], $win, 2);
|
||||
$betId = filter_var($bet['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$winByUser[$userId]['bets'][] = [
|
||||
'bet_id' => $betId === false ? 0 : $betId,
|
||||
'win_amount' => $win,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($winByUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 大奖档命中时额外推送公共频道 jackpot.hit(全站公告;个人中奖通知仍以 bet.win 为准,不可替代)。
|
||||
*
|
||||
* @param list<array<string, mixed>> $jackpotHits
|
||||
*/
|
||||
public static function publishJackpotHitsAfterCommit(array $jackpotHits, int $periodId, string $periodNo, int $resultNumber): void
|
||||
{
|
||||
if ($jackpotHits === [] || $periodId <= 0 || $resultNumber < 1) {
|
||||
return;
|
||||
}
|
||||
$dedupKey = self::JACKPOT_HIT_DEDUP_PREFIX . $periodId;
|
||||
try {
|
||||
$already = Redis::get($dedupKey);
|
||||
if ($already !== false && $already !== null && $already !== '') {
|
||||
Log::channel('ws')->info('jackpot.hit skipped (dedup exists)', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'hit_count' => count($jackpotHits),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
$ok = GameWebSocketEventBus::publish(self::TOPIC_JACKPOT_HIT, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'hits' => $jackpotHits,
|
||||
'server_time' => time(),
|
||||
]);
|
||||
if ($ok) {
|
||||
try {
|
||||
Redis::setEx($dedupKey, 86400, '1');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
Log::channel('ws')->info('jackpot.hit published', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'hit_count' => count($jackpotHits),
|
||||
]);
|
||||
} else {
|
||||
Log::channel('ws')->warning('jackpot.hit publish failed', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算提交后统一推送:user.streak / wallet.changed / bet.win / jackpot.hit(每期仅推一次)。
|
||||
*
|
||||
* @param array{
|
||||
* jackpot_hits?: list<array<string, mixed>>,
|
||||
* bet_wins?: list<array<string, mixed>>,
|
||||
* user_streak_events?: list<array{user_id: int, current_streak: int, extra?: array<string, mixed>}>,
|
||||
* wallet_events?: list<array<string, mixed>>,
|
||||
* settled_order_count?: int
|
||||
* } $settleOut
|
||||
*/
|
||||
public static function publishSettlementWinsAfterCommit(array $settleOut, int $periodId, string $periodNo, int $resultNumber): void
|
||||
{
|
||||
$settledCount = filter_var($settleOut['settled_order_count'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$betWins = is_array($settleOut['bet_wins'] ?? null) ? $settleOut['bet_wins'] : [];
|
||||
$hasStreak = is_array($settleOut['user_streak_events'] ?? null) && $settleOut['user_streak_events'] !== [];
|
||||
$hasWallet = is_array($settleOut['wallet_events'] ?? null) && $settleOut['wallet_events'] !== [];
|
||||
if ($settledCount === false || $settledCount <= 0) {
|
||||
if ($betWins === [] && !$hasStreak && !$hasWallet) {
|
||||
if ($periodId <= 0 || $resultNumber < 1) {
|
||||
return;
|
||||
}
|
||||
$betWins = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
if ($betWins === []) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jackpot.hit 独立去重:即使 settleNotify 已被占位(recover/补偿路径)也允许补广播一次
|
||||
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
|
||||
if ($jackpotHits === [] && $betWins !== []) {
|
||||
$rebuilt = self::buildJackpotHitsFromBetWins($betWins, $periodNo, $resultNumber);
|
||||
if ($rebuilt !== []) {
|
||||
Log::channel('ws')->warning('jackpot.hit rebuilt from bet_wins (jackpot_hits empty)', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'hit_count' => count($rebuilt),
|
||||
]);
|
||||
$jackpotHits = $rebuilt;
|
||||
}
|
||||
}
|
||||
self::publishJackpotHitsAfterCommit($jackpotHits, $periodId, $periodNo, $resultNumber);
|
||||
|
||||
if (($settledCount !== false && $settledCount > 0) || $hasStreak || $hasWallet) {
|
||||
if (self::markSettlementNotifyOnce($periodId)) {
|
||||
$streakEvents = is_array($settleOut['user_streak_events'] ?? null) ? $settleOut['user_streak_events'] : [];
|
||||
foreach ($streakEvents as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($row['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$streak = filter_var($row['current_streak'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$extra = is_array($row['extra'] ?? null) ? $row['extra'] : [];
|
||||
GameWebSocketPayloadHelper::publishUserStreak($userId, $streak === false ? 0 : $streak, $extra);
|
||||
}
|
||||
|
||||
$walletEvents = is_array($settleOut['wallet_events'] ?? null) ? $settleOut['wallet_events'] : [];
|
||||
foreach ($walletEvents as $payload) {
|
||||
if (!is_array($payload)) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($payload, $userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveBetWins = $periodId > 0 && $resultNumber > 0
|
||||
? self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber)
|
||||
: [];
|
||||
if ($effectiveBetWins === []) {
|
||||
$effectiveBetWins = $betWins;
|
||||
} else {
|
||||
$effectiveBetWins = self::mergeBetWinPayloads($betWins, $effectiveBetWins);
|
||||
}
|
||||
self::publishBetWinsAfterCommit($effectiveBetWins, $periodId);
|
||||
if ($periodId > 0 && $resultNumber > 0) {
|
||||
self::ensurePeriodBetWinNotifications($periodId, $resultNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底补偿:在派彩期内(status=3)允许周期性调用,确保 bet.win / jackpot.hit 最终可达。
|
||||
* - bet.win 由 dfw:v1:ws:betwin:{period_id}:{user_id} 去重
|
||||
* - jackpot.hit 由 dfw:v1:ws:jackpot_hit:{period_id} 去重
|
||||
* - user.streak / wallet.changed 仍受 dfw:v1:settle:notify:{period_id} 约束
|
||||
*/
|
||||
public static function ensurePeriodWinAndJackpotNotifications(int $periodId, string $periodNo, int $resultNumber): void
|
||||
{
|
||||
if ($periodId <= 0 || $periodNo === '' || $resultNumber < 1) {
|
||||
return;
|
||||
}
|
||||
self::publishSettlementWinsAfterCommit([
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => [],
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
], $periodId, $periodNo, $resultNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开奖后兜底:库内已有中奖但 Redis 无 bet.win 去重键时补推(避免 streak/wallet 整期 dedup 或旧版逻辑漏推)。
|
||||
*/
|
||||
public static function ensurePeriodBetWinNotifications(int $periodId, int $resultNumber): void
|
||||
{
|
||||
if ($periodId <= 0 || $resultNumber < 1) {
|
||||
return;
|
||||
}
|
||||
$payloads = self::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
if ($payloads === []) {
|
||||
return;
|
||||
}
|
||||
$missing = [];
|
||||
foreach ($payloads as $payload) {
|
||||
if (!is_array($payload)) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (self::hasBetWinNotifyMarked($periodId, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$missing[] = $payload;
|
||||
}
|
||||
if ($missing !== []) {
|
||||
Log::warning('bet.win ensurePeriodBetWinNotifications republish', [
|
||||
'period_id' => $periodId,
|
||||
'result_number' => $resultNumber,
|
||||
'user_ids' => array_map(static fn (array $p): int => (int) ($p['user_id'] ?? 0), $missing),
|
||||
]);
|
||||
self::publishBetWinsAfterCommit($missing, $periodId);
|
||||
}
|
||||
}
|
||||
|
||||
private static function hasBetWinNotifyMarked(int $periodId, int $userId): bool
|
||||
{
|
||||
if ($periodId <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId;
|
||||
try {
|
||||
$existing = Redis::get($key);
|
||||
|
||||
return $existing !== false && $existing !== null && $existing !== '';
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function markBetWinNotifyOnce(int $periodId, int $userId): void
|
||||
{
|
||||
if ($periodId <= 0 || $userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$key = self::BET_WIN_NOTIFY_DEDUP_PREFIX . $periodId . ':' . $userId;
|
||||
try {
|
||||
Redis::setEx($key, 86400, '1');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $primary
|
||||
* @param list<array<string, mixed>> $secondary
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function mergeBetWinPayloads(array $primary, array $secondary): array
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $byUser */
|
||||
$byUser = [];
|
||||
foreach (array_merge($primary, $secondary) as $payload) {
|
||||
if (!is_array($payload)) {
|
||||
continue;
|
||||
}
|
||||
$userId = filter_var($payload['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($byUser[$userId])) {
|
||||
$byUser[$userId] = $payload;
|
||||
continue;
|
||||
}
|
||||
if (!empty($payload['is_jackpot'])) {
|
||||
$byUser[$userId]['is_jackpot'] = true;
|
||||
}
|
||||
if (!empty($payload['payout_pending_review'])) {
|
||||
$byUser[$userId]['payout_pending_review'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($byUser);
|
||||
}
|
||||
|
||||
private static function markSettlementNotifyOnce(int $periodId): bool
|
||||
{
|
||||
if ($periodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$key = self::SETTLE_NOTIFY_DEDUP_PREFIX . $periodId;
|
||||
try {
|
||||
$ok = Redis::set($key, '1', ['nx', 'ex' => 86400]);
|
||||
|
||||
return $ok === true || $ok === 'OK';
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取用户展示名:nickname 优先;空则 fallback 到 username;仍空则返回空串(调用方自行兜底)。
|
||||
*
|
||||
* @param list<int> $userIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function loadUserDisplayNames(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, static fn ($v): bool => $v > 0)));
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
// 兼容不同库结构:有的库没有 nickname 字段(仅 username)
|
||||
try {
|
||||
$rows = Db::name('user')
|
||||
->whereIn('id', $userIds)
|
||||
->field(['id', 'nickname', 'username'])
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (Throwable) {
|
||||
$rows = Db::name('user')
|
||||
->whereIn('id', $userIds)
|
||||
->field(['id', 'username'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$uid = isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : 0;
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$nickname = isset($row['nickname']) && is_string($row['nickname']) ? trim($row['nickname']) : '';
|
||||
if ($nickname !== '') {
|
||||
$out[$uid] = $nickname;
|
||||
continue;
|
||||
}
|
||||
$username = isset($row['username']) && is_string($row['username']) ? trim($row['username']) : '';
|
||||
$out[$uid] = $username;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。
|
||||
*
|
||||
* @return array{ok: bool, msg: string, balance_after?: string}
|
||||
*/
|
||||
public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array
|
||||
{
|
||||
if ($playRecordId <= 0) {
|
||||
return ['ok' => false, 'msg' => __('Parameter error')];
|
||||
}
|
||||
// 兼容:bet_order 可能是 VIEW,且 * 列表会固化;审核字段始终以 game_play_record 为准
|
||||
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
|
||||
if (!is_array($row)) {
|
||||
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
|
||||
}
|
||||
if (!is_array($row)) {
|
||||
return ['ok' => false, 'msg' => __('Record not found')];
|
||||
}
|
||||
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
|
||||
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
|
||||
return ['ok' => false, 'msg' => __('This record does not require review')];
|
||||
}
|
||||
$winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2);
|
||||
$threshold = self::jackpotMaxAmount();
|
||||
if (!self::shouldRequireJackpotReview($winAmount, $threshold)) {
|
||||
return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')];
|
||||
}
|
||||
$userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0;
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'msg' => __('Order is missing user info')];
|
||||
}
|
||||
$now = time();
|
||||
$balanceAfter = null;
|
||||
if (bccomp($winAmount, '0', 2) > 0) {
|
||||
$paid = self::creditUserPayout(
|
||||
$row,
|
||||
$playRecordId,
|
||||
$winAmount,
|
||||
$now,
|
||||
$operatorAdminId > 0 ? $operatorAdminId : null,
|
||||
'大奖审核通过派彩',
|
||||
null,
|
||||
true
|
||||
);
|
||||
if (is_array($paid)) {
|
||||
$balanceAfter = (string) ($paid['balance_after'] ?? '0');
|
||||
}
|
||||
}
|
||||
$reviewRemark = trim($remark);
|
||||
if ($reviewRemark === '') {
|
||||
$reviewRemark = 'approved';
|
||||
}
|
||||
$update = [
|
||||
'status' => self::PLAY_STATUS_SETTLED,
|
||||
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
|
||||
'review_time' => $now,
|
||||
'review_remark' => substr($reviewRemark, 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
// 优先写主表
|
||||
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||||
// 兼容写 view 场景(若存在且可写)
|
||||
try {
|
||||
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$periodId = filter_var($row['period_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
|
||||
$resultNumber = filter_var(
|
||||
Db::name('game_record')->where('id', $periodId)->value('result_number'),
|
||||
FILTER_VALIDATE_INT
|
||||
);
|
||||
if ($periodId !== false && $periodId > 0 && $resultNumber !== false && $resultNumber > 0 && bccomp($winAmount, '0', 2) > 0) {
|
||||
$jackpotFlags = self::betWinJackpotFlagsForOrder($row, $winAmount, false);
|
||||
self::publishBetWinsAfterCommit([[
|
||||
'user_id' => $userId,
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'total_win' => $winAmount,
|
||||
'balance_after' => is_string($balanceAfter ?? null) ? $balanceAfter : (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0'),
|
||||
'is_jackpot' => $jackpotFlags['is_jackpot'],
|
||||
'payout_pending_review' => false,
|
||||
'bets' => [['bet_id' => $playRecordId, 'win_amount' => $winAmount]],
|
||||
]], $periodId);
|
||||
}
|
||||
|
||||
$out = ['ok' => true, 'msg' => __('Approved')];
|
||||
if (is_string($balanceAfter)) {
|
||||
$out['balance_after'] = $balanceAfter;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 大奖审核拒绝:仅当 status=待审核 才可操作;拒绝后不派彩,标记为已退回(status=4)。
|
||||
*
|
||||
* @return array{ok: bool, msg: string}
|
||||
*/
|
||||
public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array
|
||||
{
|
||||
if ($playRecordId <= 0) {
|
||||
return ['ok' => false, 'msg' => __('Parameter error')];
|
||||
}
|
||||
$reason = trim($remark);
|
||||
if ($reason === '') {
|
||||
return ['ok' => false, 'msg' => __('Please provide reject reason')];
|
||||
}
|
||||
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
|
||||
if (!is_array($row)) {
|
||||
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
|
||||
}
|
||||
if (!is_array($row)) {
|
||||
return ['ok' => false, 'msg' => __('Record not found')];
|
||||
}
|
||||
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
|
||||
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
|
||||
return ['ok' => false, 'msg' => __('This record does not require review')];
|
||||
}
|
||||
$now = time();
|
||||
$update = [
|
||||
'status' => self::PLAY_STATUS_RETURNED,
|
||||
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
|
||||
'review_time' => $now,
|
||||
'review_remark' => substr($reason, 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||||
try {
|
||||
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
return ['ok' => true, 'msg' => __('Rejected')];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,15 +953,17 @@ final class GameBetSettleService
|
||||
}
|
||||
$pending = Db::name('bet_order')
|
||||
->where('period_id', $rid)
|
||||
->where('status', 1)
|
||||
->where('status', self::PLAY_STATUS_PENDING_DRAW)
|
||||
->count();
|
||||
if ($pending === 0) {
|
||||
continue;
|
||||
}
|
||||
Db::startTrans();
|
||||
try {
|
||||
self::settleBetsForDraw($rid, $rn);
|
||||
$settleOut = self::settleBetsForDraw($rid, $rn);
|
||||
Db::commit();
|
||||
$periodNo = (string) Db::name('game_record')->where('id', $rid)->value('period_no');
|
||||
self::publishSettlementWinsAfterCommit($settleOut, $rid, $periodNo, $rn);
|
||||
$count++;
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
@@ -249,10 +1028,18 @@ final class GameBetSettleService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
|
||||
* @return array{balance_after: string, wallet_payload: array<string, mixed>}|null
|
||||
*/
|
||||
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
|
||||
{
|
||||
private static function creditUserPayout(
|
||||
array $bet,
|
||||
int $betId,
|
||||
string $winAmount,
|
||||
int $now,
|
||||
?int $operatorAdminId,
|
||||
string $remark,
|
||||
?int $resultNumber = null,
|
||||
bool $emitWalletEvent = true
|
||||
): ?array {
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
@@ -261,8 +1048,25 @@ final class GameBetSettleService
|
||||
$idem = 'payout_bet_' . $betId;
|
||||
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
||||
$coin = Db::name('user')->where('id', $userId)->value('coin');
|
||||
$balanceAfter = (string) ($coin ?? '0');
|
||||
$walletPayload = [
|
||||
'user_id' => $userId,
|
||||
'balance_after' => $balanceAfter,
|
||||
'biz_type' => 'payout',
|
||||
'ref_id' => $betId,
|
||||
'amount' => $winAmount,
|
||||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||||
'period_id' => isset($bet['period_id']) && is_numeric($bet['period_id']) ? (int) $bet['period_id'] : 0,
|
||||
'changed_at' => $now,
|
||||
];
|
||||
if ($resultNumber !== null && $resultNumber > 0) {
|
||||
$walletPayload['result_number'] = $resultNumber;
|
||||
}
|
||||
|
||||
return (string) ($coin ?? '0');
|
||||
return [
|
||||
'balance_after' => $balanceAfter,
|
||||
'wallet_payload' => $walletPayload,
|
||||
];
|
||||
}
|
||||
|
||||
$user = Db::name('user')->where('id', $userId)->find();
|
||||
@@ -284,8 +1088,8 @@ final class GameBetSettleService
|
||||
'ref_type' => 'bet_order',
|
||||
'ref_id' => $betId,
|
||||
'idempotency_key' => $idem,
|
||||
'operator_admin_id' => null,
|
||||
'remark' => '压注派彩',
|
||||
'operator_admin_id' => $operatorAdminId,
|
||||
'remark' => $remark !== '' ? $remark : '压注派彩',
|
||||
'create_time' => $now,
|
||||
]);
|
||||
|
||||
@@ -294,14 +1098,74 @@ final class GameBetSettleService
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||
GameWebSocketEventBus::publish('wallet.changed', [
|
||||
$walletPayload = [
|
||||
'user_id' => $userId,
|
||||
'balance_after' => $after,
|
||||
'biz_type' => 'payout',
|
||||
'ref_id' => $betId,
|
||||
'amount' => $winAmount,
|
||||
'period_no' => (string) ($bet['period_no'] ?? ''),
|
||||
'period_id' => isset($bet['period_id']) && is_numeric($bet['period_id']) ? (int) $bet['period_id'] : 0,
|
||||
'changed_at' => $now,
|
||||
]);
|
||||
];
|
||||
if ($resultNumber !== null && $resultNumber > 0) {
|
||||
$walletPayload['result_number'] = $resultNumber;
|
||||
}
|
||||
if ($emitWalletEvent) {
|
||||
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto($walletPayload, $userId));
|
||||
}
|
||||
|
||||
return $after;
|
||||
return [
|
||||
'balance_after' => $after,
|
||||
'wallet_payload' => $walletPayload,
|
||||
];
|
||||
}
|
||||
|
||||
private static function jackpotMaxAmount(): string
|
||||
{
|
||||
// 结算属于高频长驻进程逻辑:为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效,
|
||||
// 这里直接读库拿最新值(本方法在 settleBetsForDraw 中仅调用一次)。
|
||||
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
|
||||
if (!is_array($row)) {
|
||||
return '0.00';
|
||||
}
|
||||
$raw = $row['config_value'] ?? null;
|
||||
if ($raw === null || $raw === '') {
|
||||
return '0.00';
|
||||
}
|
||||
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
|
||||
if ($v === '' || !is_numeric($v)) {
|
||||
return '0.00';
|
||||
}
|
||||
$normalized = bcadd($v, '0', 2);
|
||||
if (bccomp($normalized, '0', 2) <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool
|
||||
{
|
||||
if (bccomp($threshold, '0', 2) <= 0) {
|
||||
return false;
|
||||
}
|
||||
return bccomp($winAmount, $threshold, 2) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* bet.win 展示用大奖标记:连胜大奖档 或 触发后台大奖审核阈值,均视为「中大奖」并走 bet.win 通知。
|
||||
*
|
||||
* @param array<string, mixed> $bet
|
||||
* @return array{is_jackpot: bool, payout_pending_review: bool}
|
||||
*/
|
||||
private static function betWinJackpotFlagsForOrder(array $bet, string $winAmount, bool $needReview): array
|
||||
{
|
||||
$streakJackpot = StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0));
|
||||
$amountJackpot = self::shouldRequireJackpotReview($winAmount, self::jackpotMaxAmount());
|
||||
|
||||
return [
|
||||
'is_jackpot' => $streakJackpot || $amountJackpot || $needReview,
|
||||
'payout_pending_review' => $needReview,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,23 @@ final class GameHotDataLock
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员强制操作前释放可能残留的互斥锁(仅删 key,不校验 token)。
|
||||
*/
|
||||
public static function forceRelease(string $type, string $resourceKey): void
|
||||
{
|
||||
if ($resourceKey === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$client = Redis::connection()->client();
|
||||
if (is_object($client) && method_exists($client, 'del')) {
|
||||
$client->del(self::lockKey($type, $resourceKey));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public static function release(string $type, string $resourceKey, ?string $token, bool $redisLock): void
|
||||
{
|
||||
if ($resourceKey === '' || !$redisLock || $token === null || $token === '') {
|
||||
|
||||
@@ -25,6 +25,15 @@ final class GameHotDataRedis
|
||||
|
||||
private const KEY_USER = 'dfw:v1:user:';
|
||||
|
||||
/**
|
||||
* 进程内实例化缓存(避免同一请求/进程内重复打 Redis/DB)。
|
||||
* - key => null:表示已确认不存在(负缓存)
|
||||
* - key => array:表示已加载行数据
|
||||
*
|
||||
* @var array<string, array<string, mixed>|null>
|
||||
*/
|
||||
private static array $gcLocal = [];
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return config('game_hot_cache.enabled', true) === true;
|
||||
@@ -38,6 +47,8 @@ final class GameHotDataRedis
|
||||
if ($configKey === '') {
|
||||
return null;
|
||||
}
|
||||
// game_config 为全局配置,多进程/多 worker 间必须强一致;
|
||||
// 因此不使用进程内本地缓存(gcLocal),避免某个进程读到旧值导致前端回弹/行为冲突。
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet(self::KEY_GC . $configKey);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
@@ -64,6 +75,7 @@ final class GameHotDataRedis
|
||||
return;
|
||||
}
|
||||
self::redisDel(self::KEY_GC . $configKey);
|
||||
unset(self::$gcLocal[$configKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,20 +83,28 @@ final class GameHotDataRedis
|
||||
*/
|
||||
public static function gameConfigReplaceFromDb(string $configKey): void
|
||||
{
|
||||
if ($configKey === '' || !self::enabled()) {
|
||||
if ($configKey === '') {
|
||||
return;
|
||||
}
|
||||
// 无论是否启用 Redis 热点缓存,都要刷新进程内缓存,避免同一 worker 读到旧值
|
||||
$row = Db::name('game_config')->where('config_key', $configKey)->find();
|
||||
if (!$row) {
|
||||
self::gameConfigForget($configKey);
|
||||
self::$gcLocal[$configKey] = null;
|
||||
if (self::enabled()) {
|
||||
self::redisDel(self::KEY_GC . $configKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$ttl = self::intConfig('ttl_game_config', 86400);
|
||||
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
self::$gcLocal[$configKey] = $row;
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_game_config', 86400);
|
||||
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对局写入后:刷新指定 id 的行缓存,并删除「活跃局 / 最新局」聚合键以免脏读
|
||||
* 对局写入后:刷新指定 id 行缓存,并回写「活跃局 / 最新局」聚合键(供 snapshot / WS 只读 Redis)
|
||||
*
|
||||
* @param int|null $id 可为 null(仅清聚合键)
|
||||
*/
|
||||
@@ -102,7 +122,75 @@ final class GameHotDataRedis
|
||||
self::redisDel(self::KEY_GR_ID . $id);
|
||||
}
|
||||
}
|
||||
self::redisDel(self::KEY_GR_ACTIVE, self::KEY_GR_LATEST);
|
||||
self::gameRecordRefreshAggregateCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入后回写「活跃局 / 最新局」聚合缓存(读库一次,供 snapshot / WS 直推只读 Redis)。
|
||||
*/
|
||||
public static function gameRecordRefreshAggregateCaches(): void
|
||||
{
|
||||
if (!self::enabled()) {
|
||||
return;
|
||||
}
|
||||
$ttl = self::intConfig('ttl_game_record', 60);
|
||||
$active = Db::name('game_record')
|
||||
->whereIn('status', [0, 1, 2, 3])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (is_array($active)) {
|
||||
self::redisSetEx(self::KEY_GR_ACTIVE, $ttl, json_encode($active, JSON_UNESCAPED_UNICODE));
|
||||
} else {
|
||||
self::redisDel(self::KEY_GR_ACTIVE);
|
||||
}
|
||||
$latest = Db::name('game_record')->order('id', 'desc')->find();
|
||||
if (is_array($latest)) {
|
||||
self::redisSetEx(self::KEY_GR_LATEST, $ttl, json_encode($latest, JSON_UNESCAPED_UNICODE));
|
||||
} else {
|
||||
self::redisDel(self::KEY_GR_LATEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩宽限期已结束但缓存仍为 status=3(导致页面倒计时 0、无法开新期)。
|
||||
*/
|
||||
public static function isExpiredPayoutRecord(array $row): bool
|
||||
{
|
||||
if ((int) ($row['status'] ?? -1) !== 3) {
|
||||
return false;
|
||||
}
|
||||
$until = (int) ($row['payout_until'] ?? 0);
|
||||
|
||||
return $until <= 0 || $until <= time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下注/封盘期缓存与库不一致或已超时未开奖(period_start 过久仍为 0/1)。
|
||||
*/
|
||||
public static function isStaleOpenPeriodRecord(array $row, int $periodSeconds): bool
|
||||
{
|
||||
$st = (int) ($row['status'] ?? -1);
|
||||
if (!in_array($st, [0, 1], true)) {
|
||||
return false;
|
||||
}
|
||||
$start = (int) ($row['period_start_at'] ?? 0);
|
||||
if ($start <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return time() - $start > $periodSeconds + 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存行疑似过期时,按库回写 Redis 后再由调用方重新读取。
|
||||
*/
|
||||
public static function gameRecordRevalidateFromDbIfStale(array $row, int $periodSeconds = 30): void
|
||||
{
|
||||
if (!self::isExpiredPayoutRecord($row) && !self::isStaleOpenPeriodRecord($row, $periodSeconds)) {
|
||||
return;
|
||||
}
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
self::gameRecordSyncCachesAfterDbWrite($id > 0 ? $id : null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +207,11 @@ final class GameHotDataRedis
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
if (self::isExpiredPayoutRecord($decoded)) {
|
||||
self::gameRecordSyncCachesAfterDbWrite($id);
|
||||
} else {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +238,11 @@ final class GameHotDataRedis
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
if (self::isExpiredPayoutRecord($decoded)) {
|
||||
self::gameRecordRefreshAggregateCaches();
|
||||
} else {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,16 @@ use app\common\library\game\StreakWinReward;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use app\common\service\GameHotDataCoordinator;
|
||||
use app\common\service\GameHotDataLock;
|
||||
use support\Log;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
final class GameLiveService
|
||||
{
|
||||
/** @var array<int, true> 防止同进程内 recover→draw 重入导致 Redis 自锁 */
|
||||
private static array $drawingRecordIds = [];
|
||||
|
||||
private const CHANNEL = 'game-live';
|
||||
private const EVENT = 'bet-updated';
|
||||
|
||||
@@ -22,19 +27,312 @@ final class GameLiveService
|
||||
private const EVT_PERIOD_LOCKED = 'period.locked';
|
||||
private const EVT_PERIOD_OPENED = 'period.opened';
|
||||
private const EVT_PERIOD_PAYOUT = 'period.payout';
|
||||
/** 派彩宽限期内每秒倒计时(不含彩池/下注列表,仅剩余秒数) */
|
||||
private const EVT_PERIOD_PAYOUT_TICK = 'period.payout.tick';
|
||||
|
||||
/** period.tick 边界帧去重(finished / void 每期只推一次),TTL 兼顾跨进程与跨期重启 */
|
||||
private const TICK_BOUNDARY_DEDUP_KEY_PREFIX = 'dfw:v1:ws:tick:boundary:';
|
||||
private const TICK_BOUNDARY_DEDUP_TTL_SECONDS = 300;
|
||||
|
||||
/** 开奖/派彩阶段公共推送去重(与 bet.win 分离;recover 补偿路径也会写) */
|
||||
private const PERIOD_OPENED_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:period_opened:';
|
||||
private const PERIOD_PAYOUT_NOTIFY_DEDUP_PREFIX = 'dfw:v1:ws:period_payout:';
|
||||
private const PERIOD_DRAW_NOTIFY_DEDUP_TTL = 86400;
|
||||
private const KEY_PERIOD_SECONDS = 'period_seconds';
|
||||
private const KEY_BET_SECONDS = 'bet_seconds';
|
||||
private const KEY_PAYOUT_SECONDS = 'payout_seconds';
|
||||
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
|
||||
|
||||
/** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */
|
||||
private const DRAW_NUMBER_MAX = 36;
|
||||
|
||||
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
|
||||
private const PAYOUT_GRACE_SECONDS = 3;
|
||||
/** 派彩展示宽限期默认值(秒),可被 game_config.payout_seconds 覆盖 */
|
||||
private const DEFAULT_PAYOUT_SECONDS = 3;
|
||||
/** 启动自愈:判定“异常卡局”的最小超时冗余秒数 */
|
||||
private const STARTUP_RECOVER_GRACE_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* 服务重启后自动巡检上一局:若长时间卡在进行中状态,则自动作废并退款待开奖注单。
|
||||
*/
|
||||
public static function recoverAbnormalPeriodOnStartup(): void
|
||||
{
|
||||
$row = Db::name('game_record')
|
||||
->whereIn('status', [0, 1, 2, 3])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recordId = (int) ($row['id'] ?? 0);
|
||||
if ($recordId <= 0) {
|
||||
return;
|
||||
}
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0;
|
||||
if ($resultNumber > 0 && in_array($status, [0, 1, 2, 3], true)) {
|
||||
self::recoverPayoutForRecordOnStartup($recordId);
|
||||
return;
|
||||
}
|
||||
|
||||
$periodStartAt = (int) ($row['period_start_at'] ?? 0);
|
||||
if ($periodStartAt <= 0) {
|
||||
return;
|
||||
}
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$timeoutAt = $periodStartAt + $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS;
|
||||
if (time() <= $timeoutAt) {
|
||||
return;
|
||||
}
|
||||
self::markAbnormalAndRefundOnStartup($recordId, $status);
|
||||
}
|
||||
|
||||
private static function recoverPayoutForRecordOnStartup(int $recordId): void
|
||||
{
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
|
||||
if (!$lock['acquired']) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$row = Db::name('game_record')->where('id', $recordId)->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
if (!in_array($status, [0, 1, 2, 3], true)) {
|
||||
return;
|
||||
}
|
||||
$resultNumber = isset($row['result_number']) ? (int) $row['result_number'] : 0;
|
||||
if ($resultNumber <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pendingCount = (int) Db::name('bet_order')
|
||||
->where('period_id', $recordId)
|
||||
->where('status', GameBetSettleService::PLAY_STATUS_PENDING_DRAW)
|
||||
->count();
|
||||
|
||||
$now = time();
|
||||
$payoutUntil = isset($row['payout_until']) ? (int) $row['payout_until'] : 0;
|
||||
$settleOut = [
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => [],
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
];
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($pendingCount > 0) {
|
||||
$settleOut = GameBetSettleService::settleBetsForDraw($recordId, $resultNumber);
|
||||
}
|
||||
if ($status === 2) {
|
||||
if ($payoutUntil <= 0) {
|
||||
$payoutUntil = $now + self::getPayoutGraceSeconds();
|
||||
}
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'status' => 3,
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} elseif ($status === 3) {
|
||||
if ($payoutUntil <= 0) {
|
||||
$payoutUntil = $now;
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$payoutUntil = $now;
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'status' => 3,
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::warning('game live startup payout recover failed', [
|
||||
'record_id' => $recordId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
|
||||
$drawMode = filter_var($row['draw_mode'] ?? 0, FILTER_VALIDATE_INT);
|
||||
GameBetSettleService::publishSettlementWinsAfterCommit(
|
||||
$settleOut,
|
||||
$recordId,
|
||||
$periodNo,
|
||||
(int) $resultNumber
|
||||
);
|
||||
GameBetSettleService::ensurePeriodBetWinNotifications($recordId, (int) $resultNumber);
|
||||
self::ensurePeriodDrawNotifications(
|
||||
$recordId,
|
||||
$periodNo,
|
||||
(int) $resultNumber,
|
||||
$drawMode === false ? 0 : $drawMode,
|
||||
$payoutUntil > 0 ? $payoutUntil : $now + self::getPayoutGraceSeconds(),
|
||||
$now
|
||||
);
|
||||
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||||
self::publishSnapshot(null);
|
||||
|
||||
if ($payoutUntil <= $now) {
|
||||
self::finalizePayoutForRecordLocked($recordId);
|
||||
}
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function markAbnormalAndRefundOnStartup(int $recordId, int $status): void
|
||||
{
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
|
||||
if (!$lock['acquired']) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$fresh = Db::name('game_record')->where('id', $recordId)->find();
|
||||
if (!$fresh) {
|
||||
return;
|
||||
}
|
||||
$freshStatus = (int) ($fresh['status'] ?? 0);
|
||||
if (!in_array($freshStatus, [0, 1, 2, 3], true)) {
|
||||
return;
|
||||
}
|
||||
$freshResultNumber = isset($fresh['result_number']) ? (int) $fresh['result_number'] : 0;
|
||||
if ($freshResultNumber > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00'];
|
||||
Db::startTrans();
|
||||
try {
|
||||
$refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now);
|
||||
$refundedUserCount = count($refund['user_ids']);
|
||||
$refundedOrderCount = (int) ($refund['order_count'] ?? 0);
|
||||
$refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00';
|
||||
$reason = sprintf(
|
||||
'system_recover:from=%d|users=%d|orders=%d|amount=%s',
|
||||
$freshStatus,
|
||||
$refundedUserCount,
|
||||
$refundedOrderCount,
|
||||
$refundedTotalAmount
|
||||
);
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'ai_locked_number' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::warning('game live startup abnormal recover failed', [
|
||||
'record_id' => $recordId,
|
||||
'status' => $status,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||||
foreach ($refund['user_ids'] as $uid) {
|
||||
if ($uid > 0) {
|
||||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||
}
|
||||
}
|
||||
// 异常对局作废后:自动暂停游戏,不自动创建新一期;需管理员手动开启「游戏运行」才会重新开局
|
||||
GameRecordService::setAutoCreateEnabled(false);
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
|
||||
self::publishSnapshot(null);
|
||||
Log::info('game live startup marked abnormal and refunded', [
|
||||
'record_id' => $recordId,
|
||||
'old_status' => $freshStatus,
|
||||
'refunded_user_count' => count($refund['user_ids']),
|
||||
'refunded_order_count' => (int) ($refund['order_count'] ?? 0),
|
||||
'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00',
|
||||
]);
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function finalizePayoutForRecordLocked(int $recordId): void
|
||||
{
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$affected = Db::name('game_record')->where('id', $recordId)->where('status', 3)->update([
|
||||
'status' => 4,
|
||||
'payout_until' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($affected < 1) {
|
||||
Db::rollback();
|
||||
|
||||
return;
|
||||
}
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::warning('game live startup finalize payout failed', [
|
||||
'record_id' => $recordId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$newPeriodNo = null;
|
||||
if (GameRecordService::isAutoCreateEnabled()) {
|
||||
try {
|
||||
$newPeriodNo = GameRecordService::createNextRecordAfterDraw();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('game live startup create next record failed', [
|
||||
'record_id' => $recordId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||||
try {
|
||||
GameRecordStatService::refreshForRecordId($recordId);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
if (!GameRecordService::isLiveRuntimeEnabled()) {
|
||||
self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize();
|
||||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||||
} elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') {
|
||||
self::publishImmediateBettingTickAfterFinalize();
|
||||
}
|
||||
self::publishSnapshot(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务/WS 推送前:结单 + 超时自动开奖(勿在已持期号锁时调用)。
|
||||
*/
|
||||
public static function recoverLiveRoundState(): void
|
||||
{
|
||||
self::finalizePayoutGrace();
|
||||
self::tickAutoDraw();
|
||||
GameLiveStuckDiagnostic::inspectAfterRecovery();
|
||||
}
|
||||
|
||||
public static function buildSnapshot(?int $recordId = null): array
|
||||
{
|
||||
$record = self::resolveRecord($recordId);
|
||||
if ($record) {
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
GameHotDataRedis::gameRecordRevalidateFromDbIfStale($record, $periodSeconds);
|
||||
$record = self::resolveRecord($recordId);
|
||||
}
|
||||
if (!$record) {
|
||||
return self::emptySnapshotPayload();
|
||||
}
|
||||
@@ -54,22 +352,27 @@ final class GameLiveService
|
||||
$betRemaining = max(0, $betSeconds - $elapsed);
|
||||
$status = (int) $record['status'];
|
||||
|
||||
$now = time();
|
||||
$payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0;
|
||||
$payoutRemaining = 0;
|
||||
if ($status === 3 && $payoutUntil > 0) {
|
||||
$payoutRemaining = max(0, $payoutUntil - time());
|
||||
$isPayoutPhase = $status === 3 && $payoutUntil > $now;
|
||||
if ($isPayoutPhase) {
|
||||
$payoutRemaining = $payoutUntil - $now;
|
||||
}
|
||||
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $rid)
|
||||
->order('id', 'desc')
|
||||
->alias('bo')
|
||||
->leftJoin('user gu', 'gu.id = bo.user_id')
|
||||
->where('bo.period_id', $rid)
|
||||
->order('bo.id', 'desc')
|
||||
->limit(200)
|
||||
->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.win_amount,bo.status as bet_status,bo.create_time,gu.username as user_username')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$candidates = [];
|
||||
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
|
||||
if ($canCalculate) {
|
||||
if (self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds)) {
|
||||
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
||||
$loss = self::estimateLossForNumber($bets, $n);
|
||||
$candidates[] = [
|
||||
@@ -79,6 +382,8 @@ final class GameLiveService
|
||||
}
|
||||
}
|
||||
|
||||
$resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0;
|
||||
|
||||
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||
$aiDisplay = null;
|
||||
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||
@@ -99,9 +404,8 @@ final class GameLiveService
|
||||
&& $elapsed < $periodSeconds;
|
||||
|
||||
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||
/** 关服且已无进行中局:派彩结束后的「完整维护」态(仅此时展示维护中 UI) */
|
||||
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||
$maintenanceUi = !$runtimeEnabled && !in_array($status, [0, 1, 2, 3], true);
|
||||
|
||||
return [
|
||||
'record' => $record,
|
||||
@@ -109,14 +413,19 @@ final class GameLiveService
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'user_id' => (int) $row['user_id'],
|
||||
'username' => isset($row['user_username']) && is_string($row['user_username']) ? $row['user_username'] : '',
|
||||
'period_no' => (string) $row['period_no'],
|
||||
'pick_numbers' => $row['pick_numbers'],
|
||||
'total_amount' => (string) $row['total_amount'],
|
||||
'streak_at_bet' => (int) $row['streak_at_bet'],
|
||||
'win_amount' => (string) ($row['win_amount'] ?? '0.00'),
|
||||
'bet_status' => (int) ($row['bet_status'] ?? 0),
|
||||
'create_time' => (int) $row['create_time'],
|
||||
];
|
||||
}, $bets),
|
||||
'candidate_numbers' => $candidates,
|
||||
'result_number' => $resultNumber > 0 ? $resultNumber : null,
|
||||
'show_settlement_preview' => self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds),
|
||||
'ai_default_number' => $aiDisplay,
|
||||
'calc_number' => $aiDisplay,
|
||||
'pending_draw_number' => $pendingDraw,
|
||||
@@ -127,14 +436,14 @@ final class GameLiveService
|
||||
'remaining_seconds' => $remaining,
|
||||
'bet_remaining_seconds' => $betRemaining,
|
||||
'payout_remaining_seconds' => $payoutRemaining,
|
||||
'is_payout_phase' => $status === 3,
|
||||
'is_payout_phase' => $isPayoutPhase,
|
||||
'runtime_enabled' => $runtimeEnabled,
|
||||
'maintenance_ui' => $maintenanceUi,
|
||||
/** 关闭游戏(维护)时仍允许完成当局、计算与预约开奖;仅阻止新用户下注与结束后自动开新期 */
|
||||
'can_calculate' => $canCalculate,
|
||||
'can_draw' => $canScheduleDraw,
|
||||
'can_schedule_draw' => $canScheduleDraw,
|
||||
'server_time' => time(),
|
||||
'server_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -144,7 +453,8 @@ final class GameLiveService
|
||||
private static function emptySnapshotPayload(): array
|
||||
{
|
||||
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||
$active = GameHotDataRedis::gameRecordActive();
|
||||
$hasActiveRound = is_array($active) && in_array((int) ($active['status'] ?? -1), [0, 1, 2, 3], true);
|
||||
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||
|
||||
return [
|
||||
@@ -167,6 +477,8 @@ final class GameLiveService
|
||||
'can_calculate' => false,
|
||||
'can_draw' => false,
|
||||
'can_schedule_draw' => false,
|
||||
'result_number' => null,
|
||||
'show_settlement_preview' => false,
|
||||
'server_time' => time(),
|
||||
];
|
||||
}
|
||||
@@ -280,11 +592,16 @@ final class GameLiveService
|
||||
'pending_draw_number' => $manualNumber,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$saved = Db::name('game_record')->where('id', $rid)->value('pending_draw_number');
|
||||
$savedParsed = filter_var($saved, FILTER_VALIDATE_INT);
|
||||
if ($savedParsed === false || $savedParsed !== $manualNumber) {
|
||||
return ['ok' => false, 'msg' => __('Failed to save scheduled draw number; please try again')];
|
||||
}
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
self::publishSnapshot(null);
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
self::publishSnapshot(null);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
@@ -315,88 +632,202 @@ final class GameLiveService
|
||||
}
|
||||
|
||||
$rid = (int) $record['id'];
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 2000);
|
||||
if (isset(self::$drawingRecordIds[$rid])) {
|
||||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||
}
|
||||
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 500);
|
||||
if (!$lock['acquired']) {
|
||||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||
}
|
||||
|
||||
/** @var null|callable(): void */
|
||||
$postLockWork = null;
|
||||
$result = ['ok' => false, 'msg' => __('Game live: settlement error')];
|
||||
|
||||
self::$drawingRecordIds[$rid] = true;
|
||||
try {
|
||||
self::ensureAiLocked($rid);
|
||||
$record = self::reloadRecord($rid);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
self::ensureAiLocked($rid);
|
||||
|
||||
$useManual = $manualNumber;
|
||||
if ($useManual === null) {
|
||||
$p = $record['pending_draw_number'] ?? null;
|
||||
if ($p !== null && $p !== '' && is_numeric((string) $p)) {
|
||||
$pn = (int) $p;
|
||||
if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) {
|
||||
$useManual = $pn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$finalNumber = null;
|
||||
$drawMode = 0;
|
||||
if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) {
|
||||
$finalNumber = $useManual;
|
||||
$drawMode = 1;
|
||||
} else {
|
||||
$al = $record['ai_locked_number'] ?? null;
|
||||
if ($al !== null && $al !== '' && is_numeric((string) $al)) {
|
||||
$finalNumber = (int) $al;
|
||||
}
|
||||
}
|
||||
if ($finalNumber === null || $finalNumber < 1) {
|
||||
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||
$finalNumber = self::computeBestNumberFromBets($bets) ?? 1;
|
||||
$settleOut = ['jackpot_hits' => [], 'bet_wins' => []];
|
||||
$finalNumber = 0;
|
||||
$drawMode = 0;
|
||||
}
|
||||
$finalLoss = '0.00';
|
||||
$payoutUntil = 0;
|
||||
$periodNo = '';
|
||||
$now = time();
|
||||
$drawCommitted = false;
|
||||
|
||||
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
|
||||
$now = time();
|
||||
$payoutUntil = $now + self::PAYOUT_GRACE_SECONDS;
|
||||
$txResult = self::withShortInnodbLockWait(3, static function () use (
|
||||
$rid,
|
||||
$betSeconds,
|
||||
$periodSeconds,
|
||||
$manualNumber,
|
||||
$now
|
||||
): array {
|
||||
Db::startTrans();
|
||||
try {
|
||||
$record = self::loadRecordRowFromDb($rid, true);
|
||||
if (!$record) {
|
||||
Db::rollback();
|
||||
|
||||
$settleOut = ['jackpot_hits' => []];
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||
'status' => 3,
|
||||
'result_number' => $finalNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
|
||||
}
|
||||
return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('No active game in progress')]];
|
||||
}
|
||||
$st = (int) ($record['status'] ?? -1);
|
||||
$existingResult = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($existingResult !== false && $existingResult >= 1 && $existingResult <= self::DRAW_NUMBER_MAX && $st >= 2) {
|
||||
Db::commit();
|
||||
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
return [
|
||||
'ok' => true,
|
||||
'draw_committed' => false,
|
||||
'existing_result' => $existingResult,
|
||||
'record' => $record,
|
||||
];
|
||||
}
|
||||
if (!in_array($st, [0, 1], true)) {
|
||||
Db::rollback();
|
||||
|
||||
try {
|
||||
GameRecordStatService::refreshForRecordId($rid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||||
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
|
||||
self::publishSnapshot(null);
|
||||
return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('Current game status does not allow drawing')]];
|
||||
}
|
||||
$elapsedLocked = max(0, $now - (int) ($record['period_start_at'] ?? $now));
|
||||
if ($elapsedLocked < $betSeconds || $elapsedLocked < $periodSeconds) {
|
||||
Db::rollback();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => __('Draw completed; paying out'),
|
||||
'result_number' => $finalNumber,
|
||||
'estimated_loss' => $finalLoss,
|
||||
'payout_until' => $payoutUntil,
|
||||
];
|
||||
return ['ok' => false, 'draw_committed' => false, 'result' => ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')]];
|
||||
}
|
||||
[$finalNumber, $drawMode] = self::resolveFinalDrawNumber($record, $manualNumber);
|
||||
$bets = Db::name('bet_order')->where('period_id', $rid)->select()->toArray();
|
||||
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
|
||||
$payoutUntil = $now + self::getPayoutGraceSeconds();
|
||||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||||
|
||||
Db::name('game_record')->where('id', $rid)->update([
|
||||
'status' => 3,
|
||||
'result_number' => $finalNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'draw_committed' => true,
|
||||
'final_number' => $finalNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'final_loss' => $finalLoss,
|
||||
'payout_until' => $payoutUntil,
|
||||
'period_no' => $periodNo,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'draw_committed' => false,
|
||||
'result' => ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()],
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
if (!($txResult['ok'] ?? false)) {
|
||||
$result = is_array($txResult['result'] ?? null) ? $txResult['result'] : ['ok' => false, 'msg' => __('Game live: settlement error')];
|
||||
} elseif (!empty($txResult['draw_committed'])) {
|
||||
$drawCommitted = true;
|
||||
$finalNumber = (int) ($txResult['final_number'] ?? 0);
|
||||
$drawMode = (int) ($txResult['draw_mode'] ?? 0);
|
||||
$finalLoss = is_string($txResult['final_loss'] ?? null) ? $txResult['final_loss'] : '0.00';
|
||||
$payoutUntil = (int) ($txResult['payout_until'] ?? 0);
|
||||
$periodNo = is_string($txResult['period_no'] ?? null) ? (string) $txResult['period_no'] : '';
|
||||
} else {
|
||||
$existingResult = (int) ($txResult['existing_result'] ?? 0);
|
||||
$periodNo = is_string($txResult['record']['period_no'] ?? null) ? (string) $txResult['record']['period_no'] : '';
|
||||
$postLockWork = static function () use ($rid): void {
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
self::publishSnapshot($rid, false);
|
||||
};
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'msg' => __('Draw completed; paying out'),
|
||||
'result_number' => $existingResult,
|
||||
'estimated_loss' => '0.00',
|
||||
'payout_until' => (int) ($txResult['record']['payout_until'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if ($drawCommitted) {
|
||||
$postLockWork = static function () use (
|
||||
$rid,
|
||||
$periodNo,
|
||||
$finalNumber,
|
||||
$drawMode,
|
||||
$payoutUntil,
|
||||
$now
|
||||
): void {
|
||||
// 开奖号已写入 DB:先推 period.opened / period.payout,再结算(避免 settle 异常导致永远收不到开奖推送)
|
||||
self::ensurePeriodDrawNotifications($rid, $periodNo, $finalNumber, $drawMode, $payoutUntil, $now);
|
||||
|
||||
$settleOut = [
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => [],
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
];
|
||||
try {
|
||||
$settleOut = GameBetSettleService::settleBetsForDraw($rid, $finalNumber);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('drawResult settle after lock failed', [
|
||||
'record_id' => $rid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
GameBetSettleService::publishSettlementWinsAfterCommit(
|
||||
$settleOut,
|
||||
$rid,
|
||||
$periodNo,
|
||||
$finalNumber
|
||||
);
|
||||
GameBetSettleService::ensurePeriodBetWinNotifications($rid, $finalNumber);
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
try {
|
||||
GameRecordStatService::refreshForRecordId($rid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
$jackpotHits = is_array($settleOut['jackpot_hits'] ?? null) ? $settleOut['jackpot_hits'] : [];
|
||||
GameWebSocketEventBus::publish('admin.live.opened', [
|
||||
'period_id' => $rid,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $finalNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'payout_until' => $payoutUntil,
|
||||
'jackpot_hits' => $jackpotHits,
|
||||
'server_time' => $now,
|
||||
]);
|
||||
self::publishSnapshot(null, false);
|
||||
};
|
||||
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'msg' => __('Draw completed; paying out'),
|
||||
'result_number' => $finalNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'estimated_loss' => $finalLoss,
|
||||
'payout_until' => $payoutUntil,
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
unset(self::$drawingRecordIds[$rid]);
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
|
||||
if ($postLockWork !== null) {
|
||||
$postLockWork();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,10 +835,10 @@ final class GameLiveService
|
||||
*/
|
||||
public static function finalizePayoutGrace(): void
|
||||
{
|
||||
$now = time();
|
||||
$row = Db::name('game_record')
|
||||
->where('status', 3)
|
||||
->where('payout_until', '>', 0)
|
||||
->where('payout_until', '<=', time())
|
||||
->whereRaw('((payout_until > 0 AND payout_until <= ?) OR payout_until IS NULL OR payout_until = 0)', [$now])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row) {
|
||||
@@ -416,49 +847,272 @@ final class GameLiveService
|
||||
$id = (int) $row['id'];
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, 2000);
|
||||
if (!$lock['acquired']) {
|
||||
Log::warning('finalizePayoutGrace: lock not acquired', ['record_id' => $id]);
|
||||
GameLiveStuckDiagnostic::report('payout_finalize_lock', [
|
||||
'record_id' => $id,
|
||||
'period_no' => is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '',
|
||||
'phase_label' => '派彩结单 finalizePayoutGrace 拿不到 Redis 行锁',
|
||||
'hint' => '键名 dfw:v1:lock:mut:gr:' . $id . ';2s 内未拿到锁,每秒重试',
|
||||
], $id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var null|callable(): void */
|
||||
$notifyAfterLock = null;
|
||||
|
||||
try {
|
||||
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
|
||||
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($resultNumber === false || $resultNumber < 1) {
|
||||
$resultNumber = null;
|
||||
}
|
||||
$newPeriodNo = null;
|
||||
$now = time();
|
||||
$finalized = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('game_record')->where('id', $id)->update([
|
||||
'status' => 4,
|
||||
'payout_until' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
GameRecordService::createNextRecordAfterDraw();
|
||||
Db::commit();
|
||||
} catch (Throwable) {
|
||||
$affected = Db::name('game_record')
|
||||
->where('id', $id)
|
||||
->where('status', 3)
|
||||
->update([
|
||||
'status' => 4,
|
||||
'payout_until' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($affected < 1) {
|
||||
Db::rollback();
|
||||
} else {
|
||||
Db::commit();
|
||||
$finalized = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::warning('finalizePayoutGrace failed: ' . $e->getMessage(), ['record_id' => $id]);
|
||||
}
|
||||
|
||||
if (!$finalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameRecordService::isAutoCreateEnabled()) {
|
||||
try {
|
||||
$newPeriodNo = GameRecordService::createNextRecordAfterDraw();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('finalizePayoutGrace: create next record failed', [
|
||||
'record_id' => $id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($id);
|
||||
GameRecordStatService::refreshForRecordId($id);
|
||||
self::publishSnapshot(null);
|
||||
|
||||
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||
$notifyAfterLock = static function () use (
|
||||
$id,
|
||||
$periodNo,
|
||||
$resultNumber,
|
||||
$now,
|
||||
$runtimeEnabled,
|
||||
$newPeriodNo
|
||||
): void {
|
||||
self::publishPublicPeriodFinished($id, $periodNo, $resultNumber);
|
||||
GameWebSocketEventBus::publish('admin.live.finalized', [
|
||||
'period_id' => $id,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'runtime_enabled' => $runtimeEnabled,
|
||||
'maintenance_ui' => !$runtimeEnabled && !GameRecordService::hasActiveRecord(),
|
||||
'server_time' => $now,
|
||||
]);
|
||||
if (!$runtimeEnabled) {
|
||||
self::voidRemainingOpenRoundsOnMaintenanceAfterFinalize();
|
||||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||||
} elseif (is_string($newPeriodNo ?? null) && $newPeriodNo !== '') {
|
||||
self::publishImmediateBettingTickAfterFinalize();
|
||||
}
|
||||
self::publishSnapshot(null, false);
|
||||
};
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $id, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
|
||||
if ($notifyAfterLock !== null) {
|
||||
$notifyAfterLock();
|
||||
}
|
||||
}
|
||||
|
||||
public static function tickAutoDraw(): void
|
||||
{
|
||||
$record = self::resolveRecord(null);
|
||||
$record = self::resolveRecordForAutoDraw();
|
||||
if (!$record || !in_array((int) $record['status'], [0, 1], true)) {
|
||||
return;
|
||||
}
|
||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||
self::ensureAiLocked((int) $record['id']);
|
||||
$record = self::reloadRecord((int) $record['id']);
|
||||
if (!$record) {
|
||||
return;
|
||||
}
|
||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||
if ($elapsed < $periodSeconds) {
|
||||
return;
|
||||
}
|
||||
self::drawResult((int) $record['id'], null);
|
||||
$rid = (int) $record['id'];
|
||||
$st = (int) ($record['status'] ?? 0);
|
||||
$abnormalAfter = $periodSeconds + self::getPayoutGraceSeconds() + self::STARTUP_RECOVER_GRACE_SECONDS;
|
||||
if ($elapsed > $abnormalAfter) {
|
||||
$resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0;
|
||||
if ($resultNumber <= 0) {
|
||||
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid);
|
||||
self::markAbnormalAndRefundOnStartup($rid, $st);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$out = self::drawResult($rid, null);
|
||||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||||
GameLiveStuckDiagnostic::noteDrawAttempt($rid, $periodNo, $out);
|
||||
if ($out['ok'] ?? false) {
|
||||
return;
|
||||
}
|
||||
$msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : '';
|
||||
if (
|
||||
!str_contains($msg, 'Another operation is in progress')
|
||||
&& !str_contains($msg, 'Lock wait timeout')
|
||||
&& !str_contains($msg, '1205')
|
||||
) {
|
||||
Log::warning('tickAutoDraw: drawResult failed', [
|
||||
'record_id' => $rid,
|
||||
'period_no' => $periodNo,
|
||||
'msg' => $msg,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭「自动创建下一局」时:作废除当前 draining 局外的其它下注/封盘期(历史重复开局遗留),避免被 tickAutoDraw 当成新一局继续跑。
|
||||
*/
|
||||
public static function voidOrphanActiveRoundsOnRuntimeDisabled(): void
|
||||
{
|
||||
if (GameRecordService::isLiveRuntimeEnabled()) {
|
||||
return;
|
||||
}
|
||||
$canonical = GameHotDataRedis::gameRecordActive();
|
||||
if (!$canonical) {
|
||||
return;
|
||||
}
|
||||
$keepId = filter_var($canonical['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($keepId === false || $keepId <= 0) {
|
||||
return;
|
||||
}
|
||||
$reason = (string) __('Orphan period closed: auto-create next round is disabled');
|
||||
$rows = Db::name('game_record')
|
||||
->whereIn('status', [0, 1])
|
||||
->where('id', '<>', $keepId)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$rid = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($rid === false || $rid <= 0) {
|
||||
continue;
|
||||
}
|
||||
self::voidOpenPeriodInternal($rid, $reason);
|
||||
}
|
||||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||||
self::publishSnapshot(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 维护模式:本期派彩结单后,清理仍停留在下注/封盘的遗留对局(不插入新期)。
|
||||
*/
|
||||
public static function voidRemainingOpenRoundsOnMaintenanceAfterFinalize(): void
|
||||
{
|
||||
if (GameRecordService::isLiveRuntimeEnabled()) {
|
||||
return;
|
||||
}
|
||||
$reason = (string) __('Open period closed after payout: game is in maintenance');
|
||||
$rows = Db::name('game_record')
|
||||
->whereIn('status', [0, 1, 2])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$rid = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($rid === false || $rid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$st = (int) ($row['status'] ?? -1);
|
||||
if ($st === 2) {
|
||||
Db::name('game_record')->where('id', $rid)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
continue;
|
||||
}
|
||||
self::voidOpenPeriodInternal($rid, $reason);
|
||||
}
|
||||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||||
self::publishSnapshot(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废单期(仅 status 0/1),不修改自动开局开关。
|
||||
*
|
||||
* @return array{ok: bool, msg?: string}
|
||||
*/
|
||||
private static function voidOpenPeriodInternal(int $recordId, string $voidReason): array
|
||||
{
|
||||
if ($recordId <= 0) {
|
||||
return ['ok' => false, 'msg' => __('Parameter error')];
|
||||
}
|
||||
$reason = trim($voidReason);
|
||||
if ($reason === '') {
|
||||
return ['ok' => false, 'msg' => __('Void reason is required')];
|
||||
}
|
||||
$record = Db::name('game_record')->where('id', $recordId)->find();
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
$st = (int) ($record['status'] ?? -1);
|
||||
if (!in_array($st, [0, 1, 3], true)) {
|
||||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||||
}
|
||||
$lock = self::acquireRecordLockForAdminMutation((string) $recordId, 8000);
|
||||
if (!$lock['acquired']) {
|
||||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||
}
|
||||
$refundedUserIds = [];
|
||||
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
|
||||
try {
|
||||
$now = time();
|
||||
$marked = self::markPeriodVoidedInDb($recordId, $reason, $now);
|
||||
if (!($marked['ok'] ?? false)) {
|
||||
$errMsg = $marked['msg'] ?? null;
|
||||
|
||||
return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')];
|
||||
}
|
||||
$refund = self::refundPendingBetsSummaryForPeriod($recordId, $now);
|
||||
$refundedUserIds = $refund['user_ids'];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||||
foreach ($refundedUserIds as $uid) {
|
||||
if ($uid > 0) {
|
||||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => __('Period voided'),
|
||||
'refund' => $refund,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,59 +1142,120 @@ final class GameLiveService
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
$st = (int) $record['status'];
|
||||
if (!in_array($st, [0, 1], true)) {
|
||||
if (!in_array($st, [0, 1, 3], true)) {
|
||||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||||
}
|
||||
$rid = (int) $record['id'];
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000);
|
||||
if (!$lock['acquired']) {
|
||||
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||
$internal = self::voidOpenPeriodInternal($rid, $reason);
|
||||
if (!($internal['ok'] ?? false)) {
|
||||
$errMsg = $internal['msg'] ?? null;
|
||||
return ['ok' => false, 'msg' => is_string($errMsg) ? $errMsg : __('Void failed')];
|
||||
}
|
||||
$refundedUserIds = [];
|
||||
try {
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now);
|
||||
Db::name('game_record')->where('id', $rid)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'ai_locked_number' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
|
||||
}
|
||||
GameRecordService::setLiveRuntimeEnabled(false);
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME);
|
||||
foreach ($refundedUserIds as $uid) {
|
||||
if ($uid > 0) {
|
||||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||
}
|
||||
}
|
||||
self::publishSnapshot(null);
|
||||
GameRecordService::setAutoCreateEnabled(false);
|
||||
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
|
||||
$refund = $internal['refund'] ?? null;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => __('Period voided'),
|
||||
'record' => self::reloadRecord($rid),
|
||||
];
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => __('Period voided'),
|
||||
'refund' => is_array($refund) ? $refund : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 作废本局后返回给前端的轻量快照(不再触发 publishSnapshot,避免 HTTP 超时)。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function buildSnapshotAfterVoid(): array
|
||||
{
|
||||
GameHotDataRedis::gameRecordRefreshAggregateCaches();
|
||||
$snapshot = self::buildSnapshot(null);
|
||||
$snapshot['runtime_enabled'] = false;
|
||||
$snapshot['maintenance_ui'] = true;
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
|
||||
{
|
||||
$summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now);
|
||||
return $summary['user_ids'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 先将本期标记为作废(短事务、尽快释放 game_record 行锁),再逐笔退款。
|
||||
*
|
||||
* @return array{ok: bool, msg?: string}
|
||||
*/
|
||||
private static function markPeriodVoidedInDb(int $recordId, string $reason, int $now): array
|
||||
{
|
||||
$attempts = 0;
|
||||
while ($attempts < 4) {
|
||||
$attempts++;
|
||||
Db::startTrans();
|
||||
try {
|
||||
$row = self::loadRecordRowFromDb($recordId, true);
|
||||
if (!$row) {
|
||||
Db::rollback();
|
||||
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
$st = (int) ($row['status'] ?? -1);
|
||||
if (!in_array($st, [0, 1, 3], true)) {
|
||||
Db::rollback();
|
||||
|
||||
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||||
}
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'ai_locked_number' => $st === 3 ? ($row['ai_locked_number'] ?? null) : null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
|
||||
return ['ok' => true];
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
$msg = $e->getMessage();
|
||||
if ($attempts < 4 && str_contains($msg, '1205')) {
|
||||
usleep(200_000);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $msg];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => false, 'msg' => __('Void failed') . ': lock wait timeout'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
|
||||
*/
|
||||
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
|
||||
{
|
||||
return self::refundPendingBetsSummaryForPeriod($periodId, $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐笔退款(每笔独立短事务),避免与开奖结算共用一个长事务抢 InnoDB 行锁。
|
||||
*
|
||||
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
|
||||
*/
|
||||
private static function refundPendingBetsSummaryForPeriod(int $periodId, int $now): array
|
||||
{
|
||||
$userIdSet = [];
|
||||
$orderCount = 0;
|
||||
$totalAmount = '0.00';
|
||||
$orderIds = [];
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->where('status', 1)
|
||||
@@ -548,54 +1263,16 @@ final class GameLiveService
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($bets as $bet) {
|
||||
$betId = (int) ($bet['id'] ?? 0);
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
$totalRaw = $bet['total_amount'] ?? '0';
|
||||
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
|
||||
if ($betId <= 0) {
|
||||
$single = self::refundSinglePendingBet($bet, $now);
|
||||
if ($single === null) {
|
||||
continue;
|
||||
}
|
||||
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
|
||||
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
continue;
|
||||
if ($single['user_id'] > 0) {
|
||||
$userIdSet[$single['user_id']] = true;
|
||||
}
|
||||
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||
$after = bcadd($before, $total, 2);
|
||||
$u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
|
||||
'coin' => $after,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($u !== 1) {
|
||||
throw new \RuntimeException((string) __('Concurrent balance update; please retry'));
|
||||
}
|
||||
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($bo !== 1) {
|
||||
throw new \RuntimeException((string) __('Bet order state changed; please retry'));
|
||||
}
|
||||
$channelIdRaw = $bet['channel_id'] ?? null;
|
||||
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
|
||||
if ($channelId === false) {
|
||||
$channelId = null;
|
||||
}
|
||||
UserWalletRecord::create([
|
||||
'user_id' => $userId,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'bet_void',
|
||||
'direction' => 1,
|
||||
'amount' => $total,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'ref_type' => 'bet_order',
|
||||
'remark' => (string) __('Period void refund'),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
$userIdSet[$userId] = true;
|
||||
$orderCount++;
|
||||
$totalAmount = bcadd($totalAmount, $single['amount'], 2);
|
||||
$orderIds[] = $single['bet_id'];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
@@ -603,15 +1280,196 @@ final class GameLiveService
|
||||
$out[] = (int) $uid;
|
||||
}
|
||||
|
||||
return $out;
|
||||
return [
|
||||
'user_ids' => $out,
|
||||
'order_count' => $orderCount,
|
||||
'total_amount' => $totalAmount,
|
||||
'order_ids' => $orderIds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function publishSnapshot(?int $recordId = null): void
|
||||
/**
|
||||
* @param array<string, mixed> $bet
|
||||
* @return array{user_id: int, bet_id: int, amount: string}|null
|
||||
*/
|
||||
private static function refundSinglePendingBet(array $bet, int $now): ?array
|
||||
{
|
||||
$betId = (int) ($bet['id'] ?? 0);
|
||||
if ($betId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
$totalRaw = $bet['total_amount'] ?? '0';
|
||||
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
|
||||
|
||||
$attempts = 0;
|
||||
while ($attempts < 4) {
|
||||
$attempts++;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($userId <= 0 || bccomp($total, '0', 2) <= 0) {
|
||||
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($bo !== 1) {
|
||||
Db::rollback();
|
||||
|
||||
return null;
|
||||
}
|
||||
Db::commit();
|
||||
|
||||
return ['user_id' => 0, 'bet_id' => $betId, 'amount' => '0.00'];
|
||||
}
|
||||
|
||||
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||
$after = bcadd($before, $total, 2);
|
||||
$u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
|
||||
'coin' => $after,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($u !== 1) {
|
||||
throw new \RuntimeException((string) __('Concurrent balance update; please retry'));
|
||||
}
|
||||
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||
'status' => 3,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if ($bo !== 1) {
|
||||
Db::rollback();
|
||||
|
||||
return null;
|
||||
}
|
||||
$channelIdRaw = $bet['channel_id'] ?? null;
|
||||
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
|
||||
if ($channelId === false) {
|
||||
$channelId = null;
|
||||
}
|
||||
UserWalletRecord::create([
|
||||
'user_id' => $userId,
|
||||
'channel_id' => $channelId,
|
||||
'biz_type' => 'void_refund',
|
||||
'direction' => 1,
|
||||
'amount' => $total,
|
||||
'balance_before' => $before,
|
||||
'balance_after' => $after,
|
||||
'ref_type' => 'bet_order',
|
||||
'remark' => (string) __('Period void refund'),
|
||||
'create_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
|
||||
return ['user_id' => $userId, 'bet_id' => $betId, 'amount' => $total];
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
$msg = $e->getMessage();
|
||||
if ($attempts < 4 && str_contains($msg, '1205')) {
|
||||
usleep(200_000);
|
||||
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException((string) __('Void failed') . ': lock wait timeout');
|
||||
}
|
||||
|
||||
public static function publishSnapshot(?int $recordId = null, bool $runRecovery = true): void
|
||||
{
|
||||
if ($runRecovery && empty(self::$drawingRecordIds)) {
|
||||
self::recoverLiveRoundState();
|
||||
}
|
||||
$snapshot = self::buildSnapshot($recordId);
|
||||
self::publishPublicPeriodPayoutCountdown($snapshot);
|
||||
self::publishPublicPeriodTick($snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩宽限期内每秒推送倒计时(仅剩余秒数,不含彩池变化)。
|
||||
*/
|
||||
private static function publishPublicPeriodPayoutCountdown(array $snapshot): void
|
||||
{
|
||||
$record = $snapshot['record'] ?? null;
|
||||
if (!is_array($record)) {
|
||||
return;
|
||||
}
|
||||
$dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($dbStatus !== 3) {
|
||||
return;
|
||||
}
|
||||
$payoutUntil = filter_var($record['payout_until'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($payoutUntil === false || $payoutUntil <= 0) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
if ($payoutUntil <= $now) {
|
||||
return;
|
||||
}
|
||||
$periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($periodId === false) {
|
||||
$periodId = 0;
|
||||
}
|
||||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||||
$resultNumber = null;
|
||||
$resultParsed = filter_var($record['result_number'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($resultParsed !== false && $resultParsed > 0) {
|
||||
$resultNumber = $resultParsed;
|
||||
}
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT_TICK, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'status' => 'payouting',
|
||||
'payout_until' => $payoutUntil,
|
||||
'payout_seconds' => self::getPayoutGraceSeconds(),
|
||||
'payout_remaining_seconds' => max(0, $payoutUntil - $now),
|
||||
'result_number' => $resultNumber,
|
||||
'server_time' => $now,
|
||||
]);
|
||||
|
||||
// 兜底:派彩期内每秒触发一次“最终一致”补偿(有独立去重键,最多各推一次)。
|
||||
// 用于处理“开奖推送已发,但结算推送 bet.win/jackpot.hit 因瞬时 Redis/入队失败丢失”的场景。
|
||||
if ($periodId > 0 && $periodNo !== '' && is_int($resultNumber) && $resultNumber > 0) {
|
||||
GameBetSettleService::ensurePeriodWinAndJackpotNotifications($periodId, $periodNo, $resultNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩结束并创建新期后,立即推送一帧 betting(避免热缓存仍指向上一期 payouting 导致长时间无 tick)。
|
||||
*/
|
||||
private static function publishImmediateBettingTickAfterFinalize(): void
|
||||
{
|
||||
$record = GameHotDataRedis::gameRecordActive();
|
||||
if (!is_array($record)) {
|
||||
return;
|
||||
}
|
||||
$dbStatus = filter_var($record['status'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($dbStatus !== 0) {
|
||||
return;
|
||||
}
|
||||
$periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($periodId === false || $periodId <= 0) {
|
||||
return;
|
||||
}
|
||||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||||
if ($periodNo === '') {
|
||||
return;
|
||||
}
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||
$elapsed = max(0, time() - (int) ($record['period_start_at'] ?? time()));
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'status' => 'betting',
|
||||
'countdown' => max(0, $periodSeconds - $elapsed),
|
||||
'bet_close_in' => max(0, $betSeconds - $elapsed),
|
||||
'result_number' => null,
|
||||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||
'server_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义)
|
||||
*/
|
||||
@@ -661,9 +1519,56 @@ final class GameLiveService
|
||||
'server_time' => time(),
|
||||
];
|
||||
|
||||
if (!self::shouldPublishPeriodTick($status, $periodNo)) {
|
||||
return;
|
||||
}
|
||||
if (empty($snapshot['runtime_enabled']) && in_array($status, ['betting', 'locked'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* period.tick 状态过滤(与《36字花-移动端接口设计草案》7.1.3 推送规则对齐):
|
||||
* - betting / locked:保持每秒推送
|
||||
* - payouting:完全静默(中奖信息已通过 period.opened / period.payout / jackpot.hit / wallet.changed 通知)
|
||||
* - finished / void:每个期号只推一次边界帧,告知前端本期收尾,随后静默直到下一期 betting
|
||||
*/
|
||||
private static function shouldPublishPeriodTick(string $status, string $periodNo): bool
|
||||
{
|
||||
if ($status === 'payouting') {
|
||||
return false;
|
||||
}
|
||||
if ($status === 'finished' || $status === 'void') {
|
||||
if ($periodNo === '') {
|
||||
return true;
|
||||
}
|
||||
return self::markBoundaryFrameOnce($periodNo, $status);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 边界帧去重:SET NX EX,占位成功(首次)返回 true;已存在返回 false。Redis 异常时降级为放行。
|
||||
*/
|
||||
private static function markBoundaryFrameOnce(string $periodNo, string $status): bool
|
||||
{
|
||||
$key = self::TICK_BOUNDARY_DEDUP_KEY_PREFIX . $periodNo . ':' . $status;
|
||||
try {
|
||||
$client = Redis::connection()->client();
|
||||
if (!is_object($client) || !method_exists($client, 'set')) {
|
||||
return true;
|
||||
}
|
||||
$ok = $client->set($key, '1', ['nx', 'ex' => self::TICK_BOUNDARY_DEDUP_TTL_SECONDS]);
|
||||
|
||||
return $ok === true;
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record game_record 行
|
||||
*/
|
||||
@@ -682,24 +1587,140 @@ final class GameLiveService
|
||||
]);
|
||||
}
|
||||
|
||||
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
|
||||
/**
|
||||
* 开奖后推送 period.opened + period.payout(每期各至多一次;入队成功后才写 dedup)。
|
||||
* recover / drawResult 补偿路径均会调用,避免仅 recover 结算时漏推 period.opened。
|
||||
*/
|
||||
public static function ensurePeriodDrawNotifications(
|
||||
int $periodId,
|
||||
string $periodNo,
|
||||
int $resultNumber,
|
||||
int $drawMode,
|
||||
int $payoutUntil,
|
||||
int $openTime
|
||||
): void {
|
||||
if ($periodId <= 0 || $periodNo === '' || $resultNumber < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$openedKey = self::PERIOD_OPENED_NOTIFY_DEDUP_PREFIX . $periodId;
|
||||
$payoutKey = self::PERIOD_PAYOUT_NOTIFY_DEDUP_PREFIX . $periodId;
|
||||
|
||||
if (!self::hasPeriodNotifyMarked($openedKey)) {
|
||||
$ok = GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'open_time' => $openTime,
|
||||
]);
|
||||
if ($ok) {
|
||||
self::markPeriodNotifyOnce($openedKey);
|
||||
Log::channel('ws')->info('period.opened published', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
]);
|
||||
} else {
|
||||
Log::channel('ws')->warning('period.opened publish failed', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!self::hasPeriodNotifyMarked($payoutKey)) {
|
||||
$grace = self::getPayoutGraceSeconds();
|
||||
$remaining = max(0, $payoutUntil - $openTime);
|
||||
$ok = GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'payout_until' => $payoutUntil,
|
||||
'payout_seconds' => $grace,
|
||||
'payout_remaining_seconds' => $remaining,
|
||||
'server_time' => $openTime,
|
||||
]);
|
||||
if ($ok) {
|
||||
self::markPeriodNotifyOnce($payoutKey);
|
||||
Log::channel('ws')->info('period.payout published', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
]);
|
||||
} else {
|
||||
Log::channel('ws')->warning('period.payout publish failed', [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function hasPeriodNotifyMarked(string $key): bool
|
||||
{
|
||||
try {
|
||||
$existing = Redis::get($key);
|
||||
return $existing !== false && $existing !== null && $existing !== '';
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function markPeriodNotifyOnce(string $key): void
|
||||
{
|
||||
try {
|
||||
Redis::setEx($key, self::PERIOD_DRAW_NOTIFY_DEDUP_TTL, '1');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $drawMode, int $openTime): void
|
||||
{
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'draw_mode' => $drawMode,
|
||||
'open_time' => $openTime,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩阶段开始(开奖后宽限期内推送)
|
||||
* 派彩阶段开始(开奖后宽限期内推送)。
|
||||
* 客户端应以 payout_until 与 server_time 做倒计时,勿用 period.tick 或上期 countdown。
|
||||
*/
|
||||
private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void
|
||||
private static function publishPublicPeriodPayout(int $periodId, string $periodNo, int $resultNumber, int $payoutUntil, int $serverTime): void
|
||||
{
|
||||
$grace = self::getPayoutGraceSeconds();
|
||||
$remaining = max(0, $payoutUntil - $serverTime);
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'result_number' => $resultNumber,
|
||||
'payout_until' => $payoutUntil,
|
||||
'payout_seconds' => $grace,
|
||||
'payout_remaining_seconds' => $remaining,
|
||||
'server_time' => $serverTime,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩宽限期结束、进入下一期前:推送本期收尾帧(每期一次)。
|
||||
*/
|
||||
private static function publishPublicPeriodFinished(int $periodId, string $periodNo, ?int $resultNumber): void
|
||||
{
|
||||
if ($periodNo === '') {
|
||||
return;
|
||||
}
|
||||
if (!self::markBoundaryFrameOnce($periodNo, 'finished')) {
|
||||
return;
|
||||
}
|
||||
GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, [
|
||||
'period_id' => $periodId,
|
||||
'period_no' => $periodNo,
|
||||
'status' => 'finished',
|
||||
'countdown' => 0,
|
||||
'bet_close_in' => 0,
|
||||
'result_number' => $resultNumber,
|
||||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||
'server_time' => time(),
|
||||
]);
|
||||
}
|
||||
@@ -748,6 +1769,84 @@ final class GameLiveService
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动开奖目标期:优先取已预约开奖号码的进行中局,避免开错期。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function resolveRecordForAutoDraw(): ?array
|
||||
{
|
||||
$pendingRow = Db::name('game_record')
|
||||
->whereIn('status', [0, 1])
|
||||
->where('pending_draw_number', '>', 0)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (is_array($pendingRow)) {
|
||||
return $pendingRow;
|
||||
}
|
||||
|
||||
$row = Db::name('game_record')
|
||||
->whereIn('status', [0, 1])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function loadRecordRowFromDb(int $recordId, bool $forUpdate = false): ?array
|
||||
{
|
||||
if ($recordId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$query = Db::name('game_record')->where('id', $recordId);
|
||||
if ($forUpdate) {
|
||||
$query->lock(true);
|
||||
}
|
||||
$row = $query->find();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开奖号码优先级:显式入参 > 预约开奖 pending_draw_number > AI 锁定号 > 按注单估算。
|
||||
*
|
||||
* @return array{0: int, 1: int} [finalNumber, drawMode] drawMode: 0=AI/估算 1=预约/手动
|
||||
*/
|
||||
private static function resolveFinalDrawNumber(array $record, ?int $manualOverride): array
|
||||
{
|
||||
$max = self::DRAW_NUMBER_MAX;
|
||||
if ($manualOverride !== null && $manualOverride >= 1 && $manualOverride <= $max) {
|
||||
return [$manualOverride, 1];
|
||||
}
|
||||
|
||||
$pending = $record['pending_draw_number'] ?? null;
|
||||
if ($pending !== null && $pending !== '' && is_numeric((string) $pending)) {
|
||||
$pendingNumber = (int) $pending;
|
||||
if ($pendingNumber >= 1 && $pendingNumber <= $max) {
|
||||
return [$pendingNumber, 1];
|
||||
}
|
||||
}
|
||||
|
||||
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||
$aiNumber = (int) $aiLocked;
|
||||
if ($aiNumber >= 1 && $aiNumber <= $max) {
|
||||
return [$aiNumber, 0];
|
||||
}
|
||||
}
|
||||
|
||||
$recordId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$bets = [];
|
||||
if ($recordId !== false && $recordId > 0) {
|
||||
$bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
|
||||
}
|
||||
|
||||
return [self::computeBestNumberFromBets($bets) ?? 1, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。
|
||||
*/
|
||||
@@ -838,6 +1937,18 @@ final class GameLiveService
|
||||
return (int) $v;
|
||||
}
|
||||
|
||||
private static function getPayoutGraceSeconds(): int
|
||||
{
|
||||
$seconds = self::getConfigInt(self::KEY_PAYOUT_SECONDS, self::DEFAULT_PAYOUT_SECONDS);
|
||||
if ($seconds < 1) {
|
||||
return 1;
|
||||
}
|
||||
if ($seconds > 300) {
|
||||
return 300;
|
||||
}
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
private static function getPickMaxNumberCount(): int
|
||||
{
|
||||
$max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36);
|
||||
@@ -850,6 +1961,38 @@ final class GameLiveService
|
||||
return $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员作废等操作:等待锁失败时清除残留锁再试一次,避免 ticker/开奖持锁导致无法作废。
|
||||
*
|
||||
* @return array{acquired: bool, token: ?string, redis_lock: bool}
|
||||
*/
|
||||
private static function acquireRecordLockForAdminMutation(string $recordId, int $waitMs): array
|
||||
{
|
||||
if ($recordId === '') {
|
||||
return ['acquired' => false, 'token' => null, 'redis_lock' => false];
|
||||
}
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, $waitMs);
|
||||
if ($lock['acquired']) {
|
||||
return $lock;
|
||||
}
|
||||
GameHotDataLock::forceRelease(GameHotDataLock::TYPE_GAME_RECORD, $recordId);
|
||||
Log::warning('admin record lock force-released before void retry', ['record_id' => $recordId]);
|
||||
|
||||
return GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, $recordId, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。
|
||||
*/
|
||||
private static function shouldBuildCandidateEstimates(int $status, int $elapsed, int $betSeconds): bool
|
||||
{
|
||||
if ($elapsed < $betSeconds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($status, [0, 1, 2, 3, 4], true);
|
||||
}
|
||||
|
||||
private static function estimateLossForNumber(array $bets, int $number): string
|
||||
{
|
||||
$payout = '0.00';
|
||||
@@ -894,4 +2037,28 @@ final class GameLiveService
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开奖事务使用较短行锁等待,避免 HTTP/定时任务被 InnoDB 默认 50s 锁等待拖死。
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $fn
|
||||
* @return T
|
||||
*/
|
||||
private static function withShortInnodbLockWait(int $seconds, callable $fn): mixed
|
||||
{
|
||||
$seconds = max(1, min(50, $seconds));
|
||||
try {
|
||||
Db::execute('SET SESSION innodb_lock_wait_timeout = ' . $seconds);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
try {
|
||||
return $fn();
|
||||
} finally {
|
||||
try {
|
||||
Db::execute('SET SESSION innodb_lock_wait_timeout = 50');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
364
app/common/service/GameLiveStuckDiagnostic.php
Normal file
364
app/common/service/GameLiveStuckDiagnostic.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\Log;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 游戏实时对局「卡住」诊断:独立日志通道 game_live_stuck,便于 grep 排查。
|
||||
*
|
||||
* 写入:runtime/logs/game-live-stuck-YYYY-MM-DD.log
|
||||
* 调用:Log::channel('game_live_stuck')->warning(...)
|
||||
*/
|
||||
final class GameLiveStuckDiagnostic
|
||||
{
|
||||
private const LOG_CHANNEL = 'game_live_stuck';
|
||||
|
||||
/** 同 phase+期 30 秒内不重复刷日志 */
|
||||
private const THROTTLE_KEY_PREFIX = 'dfw:v1:log:game_live_stuck:';
|
||||
private const THROTTLE_SECONDS = 30;
|
||||
|
||||
private const LAST_PHASE_KEY_PREFIX = 'dfw:v1:log:game_live_stuck:last:';
|
||||
|
||||
/** @var array{record_id?:int,period_no?:string,ok?:bool,msg?:string,at?:int}|null */
|
||||
private static ?array $lastDrawAttempt = null;
|
||||
|
||||
private const STATUS_LABELS = [
|
||||
0 => '下注中',
|
||||
1 => '已封盘',
|
||||
2 => '算票中(遗留)',
|
||||
3 => '派彩中',
|
||||
4 => '已结束',
|
||||
5 => '已作废',
|
||||
];
|
||||
|
||||
/**
|
||||
* 记录最近一次自动开奖尝试(tickAutoDraw → drawResult)。
|
||||
*
|
||||
* @param array{ok?:bool,msg?:string} $out
|
||||
*/
|
||||
public static function noteDrawAttempt(int $recordId, string $periodNo, array $out): void
|
||||
{
|
||||
if ($recordId <= 0) {
|
||||
return;
|
||||
}
|
||||
self::$lastDrawAttempt = [
|
||||
'record_id' => $recordId,
|
||||
'period_no' => $periodNo,
|
||||
'ok' => (bool) ($out['ok'] ?? false),
|
||||
'msg' => is_string($out['msg'] ?? null) ? (string) $out['msg'] : '',
|
||||
'at' => time(),
|
||||
];
|
||||
if ($out['ok'] ?? false) {
|
||||
return;
|
||||
}
|
||||
$msg = is_string($out['msg'] ?? null) ? (string) $out['msg'] : '';
|
||||
if ($msg === '') {
|
||||
return;
|
||||
}
|
||||
self::emit('draw_attempt_failed', [
|
||||
'record_id' => $recordId,
|
||||
'period_no' => $periodNo,
|
||||
'draw_ok' => false,
|
||||
'draw_msg' => $msg,
|
||||
'phase_label' => '自动开奖 drawResult 返回失败',
|
||||
'hint' => self::hintForDrawMessage($msg),
|
||||
], $recordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 recoverLiveRoundState(结单 + 自动开奖)之后巡检当前进行中局。
|
||||
*/
|
||||
public static function inspectAfterRecovery(): void
|
||||
{
|
||||
try {
|
||||
$row = Db::name('game_record')
|
||||
->whereIn('status', [0, 1, 2, 3])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!is_array($row)) {
|
||||
self::maybeLogRecovered();
|
||||
return;
|
||||
}
|
||||
$recordId = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($recordId === false || $recordId <= 0) {
|
||||
return;
|
||||
}
|
||||
$diagnosis = self::diagnoseRow($row);
|
||||
if ($diagnosis === null) {
|
||||
self::maybeLogRecovered($recordId);
|
||||
return;
|
||||
}
|
||||
self::rememberLastPhase($recordId, $diagnosis['phase']);
|
||||
self::emit($diagnosis['phase'], $diagnosis['context'], $recordId);
|
||||
} catch (Throwable $e) {
|
||||
self::emit('inspect_error', [
|
||||
'phase_label' => '卡住巡检异常',
|
||||
'error' => $e->getMessage(),
|
||||
'hint' => '检查数据库与 Redis 连接',
|
||||
], 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 即时上报(如 finalize 拿不到锁),仍受 30s 节流。
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function report(string $phase, array $context, int $recordId = 0): void
|
||||
{
|
||||
$context['phase_label'] = $context['phase_label'] ?? self::phaseLabel($phase);
|
||||
self::emit($phase, $context, $recordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return null|array{phase: string, context: array<string, mixed>}
|
||||
*/
|
||||
private static function diagnoseRow(array $row): ?array
|
||||
{
|
||||
$recordId = filter_var($row['id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($recordId === false || $recordId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$periodNo = is_string($row['period_no'] ?? null) ? (string) $row['period_no'] : '';
|
||||
$status = filter_var($row['status'] ?? -1, FILTER_VALIDATE_INT);
|
||||
if ($status === false) {
|
||||
$status = -1;
|
||||
}
|
||||
$periodStartAt = filter_var($row['period_start_at'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($periodStartAt === false) {
|
||||
$periodStartAt = 0;
|
||||
}
|
||||
$now = time();
|
||||
$elapsed = $periodStartAt > 0 ? max(0, $now - $periodStartAt) : 0;
|
||||
$periodSeconds = self::getConfigInt('period_seconds', 30);
|
||||
$payoutSeconds = self::getConfigInt('payout_seconds', 3);
|
||||
$abnormalAfter = $periodSeconds + $payoutSeconds + 10;
|
||||
|
||||
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($resultNumber === false) {
|
||||
$resultNumber = 0;
|
||||
}
|
||||
$payoutUntil = filter_var($row['payout_until'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($payoutUntil === false) {
|
||||
$payoutUntil = 0;
|
||||
}
|
||||
|
||||
$pendingBetCount = (int) Db::name('bet_order')
|
||||
->where('period_id', $recordId)
|
||||
->where('status', GameBetSettleService::PLAY_STATUS_PENDING_DRAW)
|
||||
->count();
|
||||
|
||||
$base = [
|
||||
'record_id' => $recordId,
|
||||
'period_no' => $periodNo,
|
||||
'db_status' => $status,
|
||||
'db_status_label' => self::STATUS_LABELS[$status] ?? '未知',
|
||||
'elapsed_sec' => $elapsed,
|
||||
'period_seconds' => $periodSeconds,
|
||||
'payout_seconds' => $payoutSeconds,
|
||||
'result_number' => $resultNumber > 0 ? $resultNumber : null,
|
||||
'payout_until' => $payoutUntil > 0 ? $payoutUntil : null,
|
||||
'payout_until_at' => $payoutUntil > 0 ? date('Y-m-d H:i:s', $payoutUntil) : null,
|
||||
'ai_locked_number' => filter_var($row['ai_locked_number'] ?? 0, FILTER_VALIDATE_INT) ?: null,
|
||||
'pending_draw_number' => filter_var($row['pending_draw_number'] ?? 0, FILTER_VALIDATE_INT) ?: null,
|
||||
'pending_bet_count' => $pendingBetCount,
|
||||
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||
'auto_create_enabled' => GameRecordService::isAutoCreateEnabled(),
|
||||
'last_draw_attempt' => self::$lastDrawAttempt,
|
||||
'server_time' => date('Y-m-d H:i:s', $now),
|
||||
];
|
||||
|
||||
if ($status === 2) {
|
||||
return [
|
||||
'phase' => 'legacy_settling_status',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '遗留 status=2 算票中,自动开奖不会处理',
|
||||
'stuck_sec' => $elapsed,
|
||||
'hint' => '将 game_record.status 改为 0/1 或 3/4,或重启后依赖启动自愈;检查是否有人工改库',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($status, [0, 1], true)) {
|
||||
if ($elapsed < $periodSeconds) {
|
||||
return null;
|
||||
}
|
||||
$stuckSec = $elapsed - $periodSeconds;
|
||||
if ($elapsed > $abnormalAfter && $resultNumber <= 0) {
|
||||
return [
|
||||
'phase' => 'await_abnormal_void',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '已超异常作废阈值但仍未开奖/作废',
|
||||
'stuck_sec' => $stuckSec,
|
||||
'abnormal_after_sec' => $abnormalAfter,
|
||||
'hint' => '应触发 markAbnormalAndRefundOnStartup;检查 gameLiveTicker 与 Redis 锁 dfw:v1:lock:mut:gr:' . $recordId,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'phase' => 'await_auto_draw',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '下注/封盘期已过开奖时刻但未进入派彩',
|
||||
'stuck_sec' => $stuckSec,
|
||||
'hint' => '确认 gameLiveTicker 进程在跑;查 Redis 锁与 webman 日志 tickAutoDraw;HTTP snapshot 不会触发开奖',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === 3) {
|
||||
$payoutExpired = $payoutUntil <= 0 || $payoutUntil <= $now;
|
||||
if ($payoutExpired) {
|
||||
return [
|
||||
'phase' => 'payout_finalize_pending',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '派彩宽限期已结束但未结单(status→4)',
|
||||
'stuck_sec' => $payoutUntil > 0 ? max(0, $now - $payoutUntil) : $elapsed,
|
||||
'hint' => 'finalizePayoutGrace 未成功;查 finalizePayoutGrace: lock not acquired 与 Redis 锁',
|
||||
]),
|
||||
];
|
||||
}
|
||||
if ($pendingBetCount > 0 && $resultNumber > 0) {
|
||||
return [
|
||||
'phase' => 'settle_pending',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '已开奖但仍有待结算注单',
|
||||
'stuck_sec' => max(0, $now - ($periodStartAt + $periodSeconds)),
|
||||
'hint' => '查 drawResult settle after lock failed;必要时补偿结算或重推',
|
||||
]),
|
||||
];
|
||||
}
|
||||
if ($payoutUntil > $now && $pendingBetCount > 0 && $resultNumber <= 0) {
|
||||
return [
|
||||
'phase' => 'payout_without_result',
|
||||
'context' => array_merge($base, [
|
||||
'phase_label' => '派彩中但无有效开奖号码',
|
||||
'stuck_sec' => max(0, $now - ($periodStartAt + $periodSeconds)),
|
||||
'hint' => '数据异常:status=3 但 result_number 无效,需人工核对 game_record',
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private static function emit(string $phase, array $context, int $recordId): void
|
||||
{
|
||||
$signature = $phase . ':' . ($recordId > 0 ? (string) $recordId : 'global');
|
||||
if (!self::shouldEmit($signature)) {
|
||||
return;
|
||||
}
|
||||
$payload = array_merge([
|
||||
'phase' => $phase,
|
||||
'phase_label' => self::phaseLabel($phase),
|
||||
], $context);
|
||||
Log::channel(self::LOG_CHANNEL)->warning('game_live_stuck', $payload);
|
||||
}
|
||||
|
||||
private static function shouldEmit(string $signature): bool
|
||||
{
|
||||
try {
|
||||
$key = self::THROTTLE_KEY_PREFIX . $signature;
|
||||
$ok = Redis::set($key, '1', ['nx', 'ex' => self::THROTTLE_SECONDS]);
|
||||
|
||||
return $ok !== false && $ok !== null;
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static function rememberLastPhase(int $recordId, string $phase): void
|
||||
{
|
||||
try {
|
||||
Redis::setEx(self::LAST_PHASE_KEY_PREFIX . $recordId, 3600, $phase);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function maybeLogRecovered(int $recordId = 0): void
|
||||
{
|
||||
if ($recordId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$key = self::LAST_PHASE_KEY_PREFIX . $recordId;
|
||||
$last = Redis::get($key);
|
||||
if (!is_string($last) || $last === '') {
|
||||
return;
|
||||
}
|
||||
Redis::del($key);
|
||||
$sig = 'recovered:' . $recordId;
|
||||
if (!self::shouldEmit($sig)) {
|
||||
return;
|
||||
}
|
||||
Log::channel(self::LOG_CHANNEL)->info('game_live_recovered', [
|
||||
'phase' => 'recovered',
|
||||
'phase_label' => '对局已从卡住状态恢复',
|
||||
'record_id' => $recordId,
|
||||
'previous_phase' => $last,
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function phaseLabel(string $phase): string
|
||||
{
|
||||
return match ($phase) {
|
||||
'await_auto_draw' => '待自动开奖',
|
||||
'await_abnormal_void' => '待异常作废',
|
||||
'payout_finalize_pending' => '待派彩结单',
|
||||
'payout_finalize_lock' => '派彩结单拿不到锁',
|
||||
'settle_pending' => '待结算注单',
|
||||
'payout_without_result' => '派彩无开奖号',
|
||||
'legacy_settling_status' => '遗留算票状态',
|
||||
'draw_attempt_failed' => '开奖尝试失败',
|
||||
'inspect_error' => '巡检异常',
|
||||
default => $phase,
|
||||
};
|
||||
}
|
||||
|
||||
private static function hintForDrawMessage(string $msg): string
|
||||
{
|
||||
if (str_contains($msg, 'Another operation is in progress')) {
|
||||
return 'Redis 行锁竞争或锁未释放,键名 dfw:v1:lock:mut:gr:{record_id};500ms 内未拿到锁则静默重试';
|
||||
}
|
||||
if (str_contains($msg, 'Lock wait timeout') || str_contains($msg, '1205')) {
|
||||
return 'InnoDB 锁等待超时,检查是否有长事务占用 game_record / bet_order';
|
||||
}
|
||||
if (str_contains($msg, 'countdown has not ended')) {
|
||||
return '服务端认为倒计时未结束,核对 period_start_at 与 period_seconds 配置';
|
||||
}
|
||||
|
||||
return '见 draw_msg 与 webman 日志 tickAutoDraw: drawResult failed';
|
||||
}
|
||||
|
||||
private static function getConfigInt(string $key, int $default): int
|
||||
{
|
||||
$row = GameHotDataRedis::gameConfigRow($key);
|
||||
if (!$row) {
|
||||
return $default;
|
||||
}
|
||||
$v = $row['config_value'] ?? null;
|
||||
if ($v === null || $v === '') {
|
||||
return $default;
|
||||
}
|
||||
if (!is_numeric((string) $v)) {
|
||||
return $default;
|
||||
}
|
||||
$parsed = filter_var($v, FILTER_VALIDATE_INT);
|
||||
|
||||
return $parsed !== false ? $parsed : $default;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\GamePeriodNo;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
@@ -13,10 +14,8 @@ final class GameRecordService
|
||||
|
||||
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
|
||||
|
||||
/** 后台「游戏实时对局」运行开关:0=暂停自动开奖与派彩后自动创建下一期;1=运行 */
|
||||
public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled';
|
||||
|
||||
private const ACTIVE_STATUSES = [0, 1, 2, 3];
|
||||
private const AUTO_CREATE_LOCK_KEY = 'auto-create-next-record';
|
||||
|
||||
public static function getConfigBool(string $key): bool
|
||||
{
|
||||
@@ -25,6 +24,31 @@ final class GameRecordService
|
||||
return false;
|
||||
}
|
||||
$v = $row['config_value'] ?? '';
|
||||
return self::truthyConfigValue($v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许自动创建下一局(读库,避免 Redis 缓存仍为 1 时误开新期)。
|
||||
*/
|
||||
public static function isAutoCreateEnabled(): bool
|
||||
{
|
||||
return self::getConfigBoolFromDb(self::KEY_AUTO_CREATE);
|
||||
}
|
||||
|
||||
private static function getConfigBoolFromDb(string $key): bool
|
||||
{
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
$row = Db::name('game_config')->where('config_key', $key)->find();
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
return self::truthyConfigValue($row['config_value'] ?? '');
|
||||
}
|
||||
|
||||
private static function truthyConfigValue(mixed $v): bool
|
||||
{
|
||||
return $v === '1' || $v === 1;
|
||||
}
|
||||
|
||||
@@ -53,17 +77,11 @@ final class GameRecordService
|
||||
|
||||
public static function tickAutoCreate(): void
|
||||
{
|
||||
if (!self::isLiveRuntimeEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
|
||||
return;
|
||||
}
|
||||
if (self::hasActiveRecord()) {
|
||||
if (!self::isAutoCreateEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
self::createNextRecordRow();
|
||||
self::createNextRecordRowIfNoActive();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
@@ -71,14 +89,14 @@ final class GameRecordService
|
||||
public static function createNextRecordForManual(): array
|
||||
{
|
||||
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
|
||||
return ['ok' => false, 'msg' => '未开启「手动创建下一局」开关'];
|
||||
return ['ok' => false, 'msg' => __('Manual create next round is disabled')];
|
||||
}
|
||||
if (self::hasActiveRecord()) {
|
||||
return ['ok' => false, 'msg' => '存在未结束对局,无法新建'];
|
||||
return ['ok' => false, 'msg' => __('There is an unfinished round; cannot create a new one')];
|
||||
}
|
||||
try {
|
||||
$periodNo = self::createNextRecordRow();
|
||||
return ['ok' => true, 'msg' => '已创建新对局', 'period_no' => $periodNo];
|
||||
return ['ok' => true, 'msg' => __('New round created'), 'period_no' => $periodNo];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'msg' => $e->getMessage()];
|
||||
}
|
||||
@@ -86,39 +104,20 @@ final class GameRecordService
|
||||
|
||||
public static function createNextRecordAfterDraw(): ?string
|
||||
{
|
||||
if (!self::isLiveRuntimeEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (self::hasActiveRecord()) {
|
||||
return null;
|
||||
}
|
||||
return self::createNextRecordRow();
|
||||
return self::createNextRecordRowIfNoActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配置键时视为开启(兼容旧库未跑迁移)。
|
||||
* 实时对局页「自动创建下一局」开关(兼容旧命名 runtime)。
|
||||
*/
|
||||
public static function isLiveRuntimeEnabled(): bool
|
||||
{
|
||||
$row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME);
|
||||
if ($row === null) {
|
||||
return true;
|
||||
}
|
||||
$v = $row['config_value'] ?? '';
|
||||
return $v === '1' || $v === 1;
|
||||
return self::isAutoCreateEnabled();
|
||||
}
|
||||
|
||||
public static function setLiveRuntimeEnabled(bool $enabled): void
|
||||
{
|
||||
$now = time();
|
||||
$v = $enabled ? '1' : '0';
|
||||
self::upsertConfig(
|
||||
self::KEY_LIVE_RUNTIME,
|
||||
$v,
|
||||
'int',
|
||||
'后台「游戏实时对局」运行开关:0=维护(禁止下注、结束后不自动开新期,当局仍自动开奖并结算);1=运行',
|
||||
$now
|
||||
);
|
||||
self::setAutoCreateEnabled($enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,17 +125,54 @@ final class GameRecordService
|
||||
*/
|
||||
public static function bootstrapPeriodWhenRuntimeEnabled(): void
|
||||
{
|
||||
if (self::hasActiveRecord()) {
|
||||
if (!self::isAutoCreateEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
self::createNextRecordRow();
|
||||
self::createNextRecordRowIfNoActive();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function createNextRecordRow(): string
|
||||
public static function setAutoCreateEnabled(bool $enabled): void
|
||||
{
|
||||
$now = time();
|
||||
$v = $enabled ? '1' : '0';
|
||||
GameHotDataRedis::gameConfigForget(self::KEY_AUTO_CREATE);
|
||||
self::upsertConfig(self::KEY_AUTO_CREATE, $v, 'int', '是否允许自动创建下一局(全局仅一局)', $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派彩结单后插入新一期(调用方须已保证无其它进行中局)。
|
||||
*/
|
||||
public static function createNextRecordRow(): string
|
||||
{
|
||||
$created = self::createNextRecordRowIfNoActive(false);
|
||||
if ($created === null) {
|
||||
throw new \RuntimeException((string) __('There is an unfinished round; cannot create a new one'));
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等插入下一期:有进行中局则不插入,返回 null。
|
||||
*
|
||||
* @param bool $requireAutoCreate true=须开启自动开局(定时/派彩后);false=手动创建下一期
|
||||
*/
|
||||
public static function createNextRecordRowIfNoActive(bool $requireAutoCreate = true): ?string
|
||||
{
|
||||
if ($requireAutoCreate && !self::isAutoCreateEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, self::AUTO_CREATE_LOCK_KEY, 1500);
|
||||
if (!$lock['acquired']) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (self::hasActiveRecord()) {
|
||||
return null;
|
||||
}
|
||||
$periodNo = self::generatePeriodNo();
|
||||
$now = time();
|
||||
Db::name('game_record')->insert([
|
||||
@@ -150,11 +186,14 @@ final class GameRecordService
|
||||
]);
|
||||
GameHotDataCoordinator::afterGameRecordCommitted(null);
|
||||
return $periodNo;
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, self::AUTO_CREATE_LOCK_KEY, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function generatePeriodNo(): string
|
||||
{
|
||||
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
return GamePeriodNo::generateNext();
|
||||
}
|
||||
|
||||
private static function truthyConfigInput(mixed $v): bool
|
||||
|
||||
267
app/common/service/GameWebSocketAuthHelper.php
Normal file
267
app/common/service/GameWebSocketAuthHelper.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* WebSocket 握手鉴权助手(与 HTTP §1.3 对齐):
|
||||
*
|
||||
* 两种合法身份(URL Query 参数名统一为连字符,与 HTTP 请求头一致):
|
||||
* 1) **mobile(H5/移动端)**:必须带 **`auth-token`** + **`user-token`**,校验通过后绑定 user_id;
|
||||
* 分发器对 user 级主题(bet.win 等)按 user_id 过滤,只发本人。
|
||||
* 2) **admin(后台联调/实时对局页)**:必须带 **`admin-ws-token`**(由后台 `wsConfig` 签发,写 Redis 短时签名)。绑定 user_id=0,
|
||||
* 分发器对该连接不做 user 级过滤,可观测全量推送(用于运维/联调)。
|
||||
*
|
||||
* 任一身份通过即可建连;都不满足则拒绝握手。
|
||||
*
|
||||
* 返回结构:
|
||||
* [
|
||||
* 'ok' => bool,
|
||||
* 'user_id' => int,
|
||||
* 'mode' => 'mobile' | 'admin' | '',
|
||||
* 'admin_id'=> int,
|
||||
* 'reason' => string,
|
||||
* 'auth_token' => string,
|
||||
* 'user_token' => string,
|
||||
* 'admin_ws_token' => string,
|
||||
* ]
|
||||
*/
|
||||
final class GameWebSocketAuthHelper
|
||||
{
|
||||
/** WebSocket 握手 Query 标准参数名(与 HTTP 头 auth-token / user-token 一致,一律用连字符) */
|
||||
public const QUERY_AUTH_TOKEN = 'auth-token';
|
||||
|
||||
public const QUERY_USER_TOKEN = 'user-token';
|
||||
|
||||
public const QUERY_ADMIN_WS_TOKEN = 'admin-ws-token';
|
||||
|
||||
/** @var list<string> 兼容旧客户端的下划线/驼峰别名(解析时仍可读,拼 URL 时勿用) */
|
||||
private const LEGACY_AUTH_KEYS = ['auth_token', 'authToken'];
|
||||
|
||||
/** @var list<string> */
|
||||
private const LEGACY_USER_KEYS = ['user_token', 'userToken', 'token'];
|
||||
|
||||
/** @var list<string> */
|
||||
private const LEGACY_ADMIN_KEYS = ['admin_ws_token', 'adminWsToken'];
|
||||
|
||||
/** admin-ws-token 在 Redis 中的 key 前缀;value 存 admin_id,TTL 由 issueAdminWsToken 决定 */
|
||||
private const ADMIN_TOKEN_REDIS_PREFIX = 'dfw:v1:ws:admin_token:';
|
||||
private const ADMIN_TOKEN_DEFAULT_TTL = 7200;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query 解析后的 URL Query 参数
|
||||
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
|
||||
*/
|
||||
public static function authorize(array $query): array
|
||||
{
|
||||
$authToken = self::pickFirstString($query, array_merge([self::QUERY_AUTH_TOKEN], self::LEGACY_AUTH_KEYS));
|
||||
$userToken = self::pickFirstString($query, array_merge([self::QUERY_USER_TOKEN], self::LEGACY_USER_KEYS));
|
||||
$adminWsToken = self::pickFirstString($query, array_merge([self::QUERY_ADMIN_WS_TOKEN], self::LEGACY_ADMIN_KEYS));
|
||||
|
||||
// ===== Admin 旁路:只校验 admin_ws_token(由后台 wsConfig 签发,已隐含管理员身份) =====
|
||||
if ($adminWsToken !== '') {
|
||||
$adminId = self::validateAdminWsToken($adminWsToken);
|
||||
if ($adminId > 0) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'user_id' => 0,
|
||||
'mode' => 'admin',
|
||||
'admin_id' => $adminId,
|
||||
'reason' => '',
|
||||
'auth_token' => $authToken,
|
||||
'user_token' => $userToken,
|
||||
'admin_ws_token' => $adminWsToken,
|
||||
];
|
||||
}
|
||||
return self::deny('admin-ws-token invalid or expired', $authToken, $userToken, $adminWsToken);
|
||||
}
|
||||
|
||||
// ===== Mobile(H5):必须同时校验 auth-token + user-token =====
|
||||
if ($authToken === '') {
|
||||
return self::deny('missing auth-token', '', $userToken, '');
|
||||
}
|
||||
$authData = Token::get($authToken);
|
||||
if (!is_array($authData) || ($authData['type'] ?? '') !== 'auth-token') {
|
||||
return self::deny('invalid auth-token type', $authToken, $userToken, '');
|
||||
}
|
||||
$authExpire = filter_var($authData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($authExpire === false || $authExpire < time()) {
|
||||
return self::deny('auth-token expired', $authToken, $userToken, '');
|
||||
}
|
||||
|
||||
if ($userToken === '') {
|
||||
return self::deny('missing user-token', $authToken, '', '');
|
||||
}
|
||||
$userData = Token::get($userToken);
|
||||
if (!is_array($userData) || ($userData['type'] ?? '') !== Auth::TOKEN_TYPE) {
|
||||
return self::deny('invalid user-token type', $authToken, $userToken, '');
|
||||
}
|
||||
$userExpire = filter_var($userData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userExpire === false || $userExpire < time()) {
|
||||
return self::deny('user-token expired', $authToken, $userToken, '');
|
||||
}
|
||||
$userId = filter_var($userData['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($userId === false || $userId <= 0) {
|
||||
return self::deny('user-token has no user_id', $authToken, $userToken, '');
|
||||
}
|
||||
|
||||
$deviceError = MobileAuthDeviceService::validateUserDeviceSession($authToken, (int) $userId);
|
||||
if ($deviceError !== null) {
|
||||
return self::deny($deviceError, $authToken, $userToken, '');
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'user_id' => (int) $userId,
|
||||
'mode' => 'mobile',
|
||||
'admin_id' => 0,
|
||||
'reason' => '',
|
||||
'auth_token' => $authToken,
|
||||
'user_token' => $userToken,
|
||||
'admin_ws_token' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为已登录的后台管理员签发短时 admin-ws-token;返回 [token, ttl]。
|
||||
* 调用方:app/admin/controller/test/GameCurrentStatus::wsConfig、app/admin/controller/game/Live::wsConfig
|
||||
*/
|
||||
public static function issueAdminWsToken(int $adminId, ?int $ttl = null): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return ['token' => '', 'ttl' => 0];
|
||||
}
|
||||
$ttl = ($ttl !== null && $ttl > 0) ? $ttl : self::ADMIN_TOKEN_DEFAULT_TTL;
|
||||
try {
|
||||
$token = bin2hex(random_bytes(20));
|
||||
} catch (Throwable) {
|
||||
$token = md5(uniqid('admin_ws_', true) . microtime(true) . random_int(0, PHP_INT_MAX));
|
||||
}
|
||||
try {
|
||||
Redis::setEx(self::ADMIN_TOKEN_REDIS_PREFIX . $token, $ttl, (string) $adminId);
|
||||
} catch (Throwable) {
|
||||
return ['token' => '', 'ttl' => 0];
|
||||
}
|
||||
return ['token' => $token, 'ttl' => $ttl];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 admin-ws-token;返回 admin_id(>0 表示有效),0 表示无效/过期。
|
||||
*/
|
||||
public static function validateAdminWsToken(string $token): int
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '' || strlen($token) > 96) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
$raw = Redis::get(self::ADMIN_TOKEN_REDIS_PREFIX . $token);
|
||||
} catch (Throwable) {
|
||||
return 0;
|
||||
}
|
||||
if ($raw === false || $raw === null || $raw === '') {
|
||||
return 0;
|
||||
}
|
||||
$adminId = filter_var($raw, FILTER_VALIDATE_INT);
|
||||
return $adminId === false ? 0 : (int) $adminId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装移动端 WebSocket 连接 URL(Query 固定为 auth-token、user-token)。
|
||||
*
|
||||
* @param array<string, string> $extraQuery 其它 Query,如 device_id、lang
|
||||
*/
|
||||
public static function buildMobileConnectUrl(string $baseWsUrl, string $authToken, string $userToken, array $extraQuery = []): string
|
||||
{
|
||||
return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [
|
||||
self::QUERY_AUTH_TOKEN => $authToken,
|
||||
self::QUERY_USER_TOKEN => $userToken,
|
||||
], $extraQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装后台 WebSocket 连接 URL(Query 固定为 admin-ws-token)。
|
||||
*/
|
||||
public static function buildAdminConnectUrl(string $baseWsUrl, string $adminWsToken): string
|
||||
{
|
||||
return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [
|
||||
self::QUERY_ADMIN_WS_TOKEN => $adminWsToken,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ws header 中解析 GET 行 Query(Workerman 在 onWebSocketConnect($connection, $request) 时
|
||||
* $request 可能为字符串或对象;为兼容,这里允许直接传 URI Query 字符串)。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function parseQueryString(string $queryString): array
|
||||
{
|
||||
$queryString = trim($queryString);
|
||||
if ($queryString === '') {
|
||||
return [];
|
||||
}
|
||||
if ($queryString[0] === '?') {
|
||||
$queryString = substr($queryString, 1);
|
||||
}
|
||||
$out = [];
|
||||
parse_str($queryString, $out);
|
||||
$clean = [];
|
||||
foreach ($out as $k => $v) {
|
||||
if (!is_string($k)) {
|
||||
continue;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
$clean[$k] = $v;
|
||||
} elseif (is_scalar($v)) {
|
||||
$clean[$k] = (string) $v;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @param list<string> $keys
|
||||
*/
|
||||
private static function pickFirstString(array $query, array $keys): string
|
||||
{
|
||||
foreach ($keys as $k) {
|
||||
if (!isset($query[$k])) {
|
||||
continue;
|
||||
}
|
||||
$v = $query[$k];
|
||||
if (!is_scalar($v)) {
|
||||
continue;
|
||||
}
|
||||
$s = trim((string) $v);
|
||||
if ($s !== '') {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
|
||||
*/
|
||||
private static function deny(string $reason, string $authToken, string $userToken, string $adminWsToken): array
|
||||
{
|
||||
return [
|
||||
'ok' => false,
|
||||
'user_id' => 0,
|
||||
'mode' => '',
|
||||
'admin_id' => 0,
|
||||
'reason' => $reason,
|
||||
'auth_token' => $authToken,
|
||||
'user_token' => $userToken,
|
||||
'admin_ws_token' => $adminWsToken,
|
||||
];
|
||||
}
|
||||
}
|
||||
170
app/common/service/GameWebSocketDispatcher.php
Normal file
170
app/common/service/GameWebSocketDispatcher.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\Log;
|
||||
use Throwable;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
|
||||
/**
|
||||
* WebSocket 事件分发器(仅 gameWebSocketServer 进程内调用)。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 从 GameWebSocketEventBus 队列消费事件
|
||||
* 2. 按 topic 反向索引取出候选 connection_id
|
||||
* 3. user 级主题(bet.win / user.streak / wallet.changed / bet.accepted 等)按
|
||||
* data.user_id 与连接绑定 user_id 比对,仅命中本人才下发
|
||||
* 4. 每一步都打 ws 日志,便于排查"为什么没收到推送"
|
||||
*/
|
||||
final class GameWebSocketDispatcher
|
||||
{
|
||||
/**
|
||||
* 这些 topic 的 data.user_id 必须等于连接绑定的 user_id 才会下发;
|
||||
* 其它 topic(period.tick / period.opened / jackpot.hit / admin.* 等)一律广播给订阅者。
|
||||
*
|
||||
* 与 docs/36字花-移动端接口设计草案.md §7.1.2A 保持一致。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const USER_SCOPED_TOPICS = [
|
||||
'bet.win',
|
||||
'user.streak',
|
||||
'wallet.changed',
|
||||
'bet.accepted',
|
||||
'auto.spin.progress',
|
||||
];
|
||||
|
||||
/**
|
||||
* 分发单条事件到所有命中的连接。
|
||||
*
|
||||
* @param array{topic:string, event:string, data:array<string,mixed>, server_time:int} $event
|
||||
* @param array<int, TcpConnection> $connections connection_id => TcpConnection
|
||||
*/
|
||||
public static function dispatch(array $event, array $connections): void
|
||||
{
|
||||
$topic = $event['topic'] ?? '';
|
||||
if (!is_string($topic) || $topic === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidateIds = GameWebSocketSubscriptionRegistry::connectionsForTopic($topic);
|
||||
if ($candidateIds === []) {
|
||||
Log::channel('ws')->debug('dispatch skip: no subscriber', [
|
||||
'topic' => $topic,
|
||||
'queue_server_time' => $event['server_time'] ?? 0,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userScoped = in_array($topic, self::USER_SCOPED_TOPICS, true);
|
||||
$payloadUserId = 0;
|
||||
if ($userScoped) {
|
||||
$raw = $event['data']['user_id'] ?? 0;
|
||||
$parsed = filter_var($raw, FILTER_VALIDATE_INT);
|
||||
$payloadUserId = $parsed === false ? 0 : (int) $parsed;
|
||||
}
|
||||
|
||||
$rawData = is_array($event['data'] ?? null) ? $event['data'] : [];
|
||||
$clientData = GameWebSocketPayloadHelper::sanitizeOutboundData($rawData);
|
||||
|
||||
$frame = json_encode([
|
||||
'event' => $event['event'] ?? $topic,
|
||||
'topic' => $topic,
|
||||
'data' => $clientData,
|
||||
'server_time' => $event['server_time'] ?? time(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($frame) || $frame === '') {
|
||||
Log::channel('ws')->warning('dispatch skip: invalid json frame', [
|
||||
'topic' => $topic,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$matched = 0;
|
||||
$skippedNotOwner = 0;
|
||||
$skippedClosed = 0;
|
||||
$sendFailed = 0;
|
||||
|
||||
foreach ($candidateIds as $cid) {
|
||||
if (!isset($connections[$cid])) {
|
||||
$skippedClosed++;
|
||||
continue;
|
||||
}
|
||||
if ($userScoped && $payloadUserId > 0) {
|
||||
$boundUid = GameWebSocketSubscriptionRegistry::userIdOf($cid);
|
||||
// admin/ws 联调场景:admin 连接在握手时绑定 user_id=0,允许观测所有 user-scoped topic(例如 bet.win)
|
||||
// mobile 用户连接则必须严格匹配 data.user_id,避免泄露其它用户事件
|
||||
if ($boundUid !== 0 && $boundUid !== $payloadUserId) {
|
||||
$skippedNotOwner++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$connections[$cid]->send($frame);
|
||||
$matched++;
|
||||
} catch (Throwable $e) {
|
||||
$sendFailed++;
|
||||
Log::channel('ws')->warning('dispatch send failed', [
|
||||
'topic' => $topic,
|
||||
'connection_id' => $cid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::channel('ws')->info('dispatch', [
|
||||
'topic' => $topic,
|
||||
'user_scoped' => $userScoped,
|
||||
'payload_user_id' => $payloadUserId,
|
||||
'candidates' => count($candidateIds),
|
||||
'matched' => $matched,
|
||||
'skipped_not_owner' => $skippedNotOwner,
|
||||
'skipped_closed' => $skippedClosed,
|
||||
'send_failed' => $sendFailed,
|
||||
'frame_size' => strlen($frame),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接向某连接下发单帧(握手回执 / 订阅回执 / pong / 演示帧)。
|
||||
*/
|
||||
public static function sendDirect(TcpConnection $connection, string $event, array $data, string $tag = ''): void
|
||||
{
|
||||
$controlEvents = ['ws.connected', 'ws.subscribed', 'ws.error', 'pong'];
|
||||
$payload = $data;
|
||||
if (!in_array($event, $controlEvents, true)) {
|
||||
if (isset($payload['data']) && is_array($payload['data'])) {
|
||||
$payload['data'] = GameWebSocketPayloadHelper::sanitizeOutboundData($payload['data']);
|
||||
}
|
||||
}
|
||||
if ($event === 'ws.connected') {
|
||||
unset($payload['user_id']);
|
||||
}
|
||||
|
||||
$frame = json_encode(array_merge([
|
||||
'event' => $event,
|
||||
], $payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($frame) || $frame === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$connection->send($frame);
|
||||
Log::channel('ws')->debug('direct send', [
|
||||
'connection_id' => $connection->id,
|
||||
'event' => $event,
|
||||
'tag' => $tag,
|
||||
'frame_size' => strlen($frame),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->warning('direct send failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'event' => $event,
|
||||
'tag' => $tag,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\Log;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 通过 Redis 列表在不同进程间投递 WebSocket 事件。
|
||||
*
|
||||
* 入队失败统一返回 false 并写 ws 日志(runtime/logs/ws.log),便于排查"为什么没有推送"。
|
||||
*/
|
||||
final class GameWebSocketEventBus
|
||||
{
|
||||
@@ -17,12 +20,13 @@ final class GameWebSocketEventBus
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return bool 是否成功入队(false 表示 Redis 不可用或参数非法,调用方应避免标记"已推送")
|
||||
*/
|
||||
public static function publish(string $topic, array $data): void
|
||||
public static function publish(string $topic, array $data): bool
|
||||
{
|
||||
$topic = trim($topic);
|
||||
if ($topic === '') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$payload = [
|
||||
'topic' => $topic,
|
||||
@@ -32,11 +36,39 @@ final class GameWebSocketEventBus
|
||||
];
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($json) || $json === '') {
|
||||
return;
|
||||
Log::channel('ws')->warning('publish skip: invalid json payload', [
|
||||
'topic' => $topic,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Redis::lPush(self::KEY_QUEUE, $json);
|
||||
} catch (Throwable) {
|
||||
$len = Redis::lPush(self::KEY_QUEUE, $json);
|
||||
$ok = is_numeric($len) && (int) $len > 0;
|
||||
if ($ok) {
|
||||
$uid = filter_var($data['user_id'] ?? 0, FILTER_VALIDATE_INT);
|
||||
Log::channel('ws')->info('publish', [
|
||||
'topic' => $topic,
|
||||
'user_id' => $uid === false ? 0 : (int) $uid,
|
||||
'queue_len_after' => (int) $len,
|
||||
'payload_size' => strlen($json),
|
||||
]);
|
||||
} else {
|
||||
Log::channel('ws')->warning('publish lpush returned non-positive', [
|
||||
'topic' => $topic,
|
||||
'returned' => $len,
|
||||
]);
|
||||
}
|
||||
return $ok;
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('publish failed (redis exception)', [
|
||||
'topic' => $topic,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Log::warning('ws event bus publish failed', [
|
||||
'topic' => $topic,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +116,27 @@ final class GameWebSocketEventBus
|
||||
'server_time' => $serverTime,
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('popBatch failed (redis exception)', [
|
||||
'error' => $e->getMessage(),
|
||||
'popped' => count($out),
|
||||
]);
|
||||
return $out;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前队列堆积长度(监控用)。
|
||||
*/
|
||||
public static function queueLength(): int
|
||||
{
|
||||
try {
|
||||
$len = Redis::lLen(self::KEY_QUEUE);
|
||||
return is_numeric($len) ? (int) $len : 0;
|
||||
} catch (Throwable) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
311
app/common/service/GameWebSocketPayloadHelper.php
Normal file
311
app/common/service/GameWebSocketPayloadHelper.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 移动端 WebSocket:仅推送当前玩家本局适用赔率(非 streak_win_reward 全表)。
|
||||
*/
|
||||
final class GameWebSocketPayloadHelper
|
||||
{
|
||||
public const TOPIC_USER_STREAK = 'user.streak';
|
||||
|
||||
/** @var list<string> */
|
||||
public const ODDS_PUSH_TOPICS = [
|
||||
'user.streak',
|
||||
'wallet.changed',
|
||||
'bet.accepted',
|
||||
];
|
||||
|
||||
/**
|
||||
* 下发给客户端前从 data 中移除的字段(服务端入队/路由仍保留完整载荷)。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const OUTBOUND_STRIP_KEYS = [
|
||||
'user_id',
|
||||
'uuid',
|
||||
'phone',
|
||||
'balance_before',
|
||||
'channel_id',
|
||||
'review_admin_id',
|
||||
'operator_admin_id',
|
||||
'idempotency_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{user_id: int, current_streak: int, streak_level: int, odds_factor: int, is_jackpot: bool}
|
||||
*/
|
||||
public static function userStreakData(int $userId, ?int $currentStreak = null): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [
|
||||
'user_id' => 0,
|
||||
'current_streak' => 0,
|
||||
'streak_level' => 1,
|
||||
'odds_factor' => 1,
|
||||
'is_jackpot' => false,
|
||||
];
|
||||
}
|
||||
if ($currentStreak === null) {
|
||||
$row = GameHotDataRedis::userRow($userId);
|
||||
$raw = $row['current_streak'] ?? 0;
|
||||
$parsed = filter_var($raw, FILTER_VALIDATE_INT);
|
||||
$currentStreak = $parsed === false ? 0 : $parsed;
|
||||
}
|
||||
|
||||
$odds = StreakWinReward::playerBetOddsForCurrentStreak($currentStreak);
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'current_streak' => $odds['current_streak'],
|
||||
'streak_level' => $odds['streak_level'],
|
||||
'odds_factor' => $odds['odds_factor'],
|
||||
'is_jackpot' => $odds['is_jackpot'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 出站 WebSocket 帧 data 脱敏:移除 user_id 等(连接已绑定用户,无需在载荷中重复暴露)。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function sanitizeOutboundData(array $data): array
|
||||
{
|
||||
return self::stripSensitiveKeysRecursive($data, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function stripSensitiveKeysRecursive(array $data, int $depth): array
|
||||
{
|
||||
if ($depth > 8) {
|
||||
return $data;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (!is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($key, self::OUTBOUND_STRIP_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$isList = array_is_list($value);
|
||||
$child = [];
|
||||
foreach ($value as $k => $item) {
|
||||
if (is_array($item)) {
|
||||
$child[$k] = self::stripSensitiveKeysRecursive($item, $depth + 1);
|
||||
} else {
|
||||
$child[$k] = $item;
|
||||
}
|
||||
}
|
||||
$out[$key] = $isList ? array_values($child) : $child;
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $value;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function mergeUserStreakInto(array $payload, int $userId, ?int $currentStreak = null): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return $payload;
|
||||
}
|
||||
// 注意:部分业务事件(尤其 bet.win)的 is_jackpot 语义为“本期中奖是否大奖”,
|
||||
// 而赔率字段 is_jackpot 语义为“下一注下注是否处于大奖档”。二者字段名相同但语义不同,
|
||||
// 这里以 payload 里已有字段为准,避免被 userStreakData 覆盖导致“1胜被误判为中大奖”。
|
||||
$streak = self::userStreakData($userId, $currentStreak);
|
||||
foreach ($streak as $k => $v) {
|
||||
if (!array_key_exists($k, $payload)) {
|
||||
$payload[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
public static function publishUserStreak(int $userId, ?int $currentStreak = null, array $extra = []): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$payload = self::userStreakData($userId, $currentStreak);
|
||||
if ($extra !== []) {
|
||||
$payload = array_merge($payload, $extra);
|
||||
}
|
||||
GameWebSocketEventBus::publish(self::TOPIC_USER_STREAK, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台 WebSocket 联调:从库内选取样例玩家(优先 current_streak 最高)。
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function pickAdminTestUserRow(): ?array
|
||||
{
|
||||
$fields = ['id', 'username', 'uuid', 'phone', 'current_streak', 'coin'];
|
||||
$row = Db::name('user')
|
||||
->where('status', 1)
|
||||
->order('current_streak', 'desc')
|
||||
->order('id', 'asc')
|
||||
->field($fields)
|
||||
->find();
|
||||
if (is_array($row) && !empty($row['id'])) {
|
||||
return $row;
|
||||
}
|
||||
$fallback = Db::name('user')
|
||||
->order('id', 'asc')
|
||||
->field($fields)
|
||||
->find();
|
||||
|
||||
return is_array($fallback) && !empty($fallback['id']) ? $fallback : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* wsConfig 与订阅后演示推送共用的玩家赔率快照。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function adminTestPlayerOddsSnapshot(): array
|
||||
{
|
||||
$row = self::pickAdminTestUserRow();
|
||||
if ($row === null) {
|
||||
$demoStreak = 3;
|
||||
$odds = StreakWinReward::playerBetOddsForCurrentStreak($demoStreak);
|
||||
|
||||
return array_merge([
|
||||
'is_test' => true,
|
||||
'preview' => true,
|
||||
'user_id' => 0,
|
||||
'username' => '演示玩家(库内无用户)',
|
||||
'uuid' => '',
|
||||
'phone' => '',
|
||||
'coin' => '0.00',
|
||||
'source' => 'synthetic',
|
||||
], $odds);
|
||||
}
|
||||
|
||||
$userIdRaw = $row['id'] ?? 0;
|
||||
$userIdParsed = filter_var($userIdRaw, FILTER_VALIDATE_INT);
|
||||
$userId = $userIdParsed === false ? 0 : $userIdParsed;
|
||||
$streakRaw = $row['current_streak'] ?? 0;
|
||||
$streakParsed = filter_var($streakRaw, FILTER_VALIDATE_INT);
|
||||
$currentStreak = $streakParsed === false ? 0 : $streakParsed;
|
||||
$odds = StreakWinReward::playerBetOddsForCurrentStreak($currentStreak);
|
||||
|
||||
return array_merge([
|
||||
'is_test' => true,
|
||||
'preview' => true,
|
||||
'user_id' => $userId,
|
||||
'username' => (string) ($row['username'] ?? ''),
|
||||
'uuid' => (string) ($row['uuid'] ?? ''),
|
||||
'phone' => (string) ($row['phone'] ?? ''),
|
||||
'coin' => (string) ($row['coin'] ?? '0.00'),
|
||||
'source' => 'db_user',
|
||||
], $odds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台测试连接订阅赔率主题后,推送演示帧(非真实业务事件)。
|
||||
*
|
||||
* @param list<string> $subscribedTopics
|
||||
* @return list<array{topic: string, event: string, data: array<string, mixed>}>
|
||||
*/
|
||||
public static function adminTestPushFrames(array $subscribedTopics): array
|
||||
{
|
||||
$snapshot = self::adminTestPlayerOddsSnapshot();
|
||||
$userIdRaw = $snapshot['user_id'] ?? 0;
|
||||
$userIdParsed = filter_var($userIdRaw, FILTER_VALIDATE_INT);
|
||||
$userId = $userIdParsed === false ? 0 : $userIdParsed;
|
||||
$streakRaw = $snapshot['current_streak'] ?? 0;
|
||||
$streakParsed = filter_var($streakRaw, FILTER_VALIDATE_INT);
|
||||
$currentStreak = $streakParsed === false ? 0 : $streakParsed;
|
||||
|
||||
$topicSet = [];
|
||||
foreach ($subscribedTopics as $topic) {
|
||||
if (!is_string($topic)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim($topic);
|
||||
if ($value !== '') {
|
||||
$topicSet[$value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$frames = [];
|
||||
if (isset($topicSet[self::TOPIC_USER_STREAK])) {
|
||||
$frames[] = [
|
||||
'topic' => self::TOPIC_USER_STREAK,
|
||||
'event' => self::TOPIC_USER_STREAK,
|
||||
'data' => $snapshot,
|
||||
];
|
||||
}
|
||||
if (isset($topicSet['wallet.changed'])) {
|
||||
$frames[] = [
|
||||
'topic' => 'wallet.changed',
|
||||
'event' => 'wallet.changed',
|
||||
'data' => self::mergeOddsFieldsFromSnapshot([
|
||||
'is_test' => true,
|
||||
'preview' => true,
|
||||
'user_id' => $userId,
|
||||
'balance_after' => (string) ($snapshot['coin'] ?? '0.00'),
|
||||
'biz_type' => 'admin_test_preview',
|
||||
'changed_at' => time(),
|
||||
], $snapshot),
|
||||
];
|
||||
}
|
||||
if (isset($topicSet['bet.accepted'])) {
|
||||
$frames[] = [
|
||||
'topic' => 'bet.accepted',
|
||||
'event' => 'bet.accepted',
|
||||
'data' => self::mergeOddsFieldsFromSnapshot([
|
||||
'is_test' => true,
|
||||
'preview' => true,
|
||||
'user_id' => $userId,
|
||||
'period_no' => 'ADMIN-TEST-PREVIEW',
|
||||
'numbers' => [1, 2, 3],
|
||||
'bet_id' => 1,
|
||||
'single_bet_amount' => '1.00',
|
||||
'numbers_count' => 3,
|
||||
'total_amount' => '3.00',
|
||||
'balance_after' => (string) ($snapshot['coin'] ?? '0.00'),
|
||||
'accepted_at' => time(),
|
||||
], $snapshot),
|
||||
];
|
||||
}
|
||||
|
||||
return $frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function mergeOddsFieldsFromSnapshot(array $payload, array $snapshot): array
|
||||
{
|
||||
return array_merge($payload, [
|
||||
'current_streak' => $snapshot['current_streak'] ?? 0,
|
||||
'streak_level' => $snapshot['streak_level'] ?? 1,
|
||||
'odds_factor' => $snapshot['odds_factor'] ?? 1,
|
||||
'is_jackpot' => ($snapshot['is_jackpot'] ?? false) === true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
251
app/common/service/GameWebSocketSubscriptionRegistry.php
Normal file
251
app/common/service/GameWebSocketSubscriptionRegistry.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 进程内 WebSocket 订阅注册表(仅在 gameWebSocketServer 单进程内使用)。
|
||||
*
|
||||
* - 维护双向索引:
|
||||
* - topic => map<connection_id, true>
|
||||
* - connection => [ topics: list<string>, user_id: int, last_seen_at: int, remote_ip: string ]
|
||||
* - 分发器按 (topic) 直接拿到候选连接列表,按 (data.user_id) 过滤后再 send,避免 O(N) 全遍历。
|
||||
* - **必须** 与 GameWebSocketServer 同进程使用:count=1,不可水平扩展(多 worker 间无法共享连接)。
|
||||
*
|
||||
* 该类不持有 TcpConnection 引用,仅持有 connection_id 与元数据;Server 维护 connection_id => TcpConnection 映射。
|
||||
*/
|
||||
final class GameWebSocketSubscriptionRegistry
|
||||
{
|
||||
/** @var array<string, array<int, true>> topic => { connection_id: true } */
|
||||
private static array $topicIndex = [];
|
||||
|
||||
/** @var array<int, array{topics: list<string>, user_id: int, mode: string, last_seen_at: int, remote_ip: string}> */
|
||||
private static array $connectionMeta = [];
|
||||
|
||||
/**
|
||||
* 注册新连接(onConnect 调用)。
|
||||
*/
|
||||
public static function registerConnection(int $connectionId, int $userId, string $mode = 'mobile', string $remoteIp = ''): void
|
||||
{
|
||||
if ($connectionId <= 0) {
|
||||
return;
|
||||
}
|
||||
$mode = trim($mode);
|
||||
if ($mode !== 'admin') {
|
||||
$mode = 'mobile';
|
||||
}
|
||||
self::$connectionMeta[$connectionId] = [
|
||||
'topics' => [],
|
||||
'user_id' => max(0, $userId),
|
||||
'mode' => $mode,
|
||||
'last_seen_at' => time(),
|
||||
'remote_ip' => $remoteIp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销连接(onClose 调用):从所有 topic 索引中移除该 connection。
|
||||
*/
|
||||
public static function unregisterConnection(int $connectionId): void
|
||||
{
|
||||
if ($connectionId <= 0 || !isset(self::$connectionMeta[$connectionId])) {
|
||||
return;
|
||||
}
|
||||
foreach (self::$connectionMeta[$connectionId]['topics'] as $topic) {
|
||||
if (isset(self::$topicIndex[$topic][$connectionId])) {
|
||||
unset(self::$topicIndex[$topic][$connectionId]);
|
||||
if (self::$topicIndex[$topic] === []) {
|
||||
unset(self::$topicIndex[$topic]);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset(self::$connectionMeta[$connectionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换该连接的订阅列表(subscribe 报文调用,覆盖式订阅,符合现网协议)。
|
||||
*
|
||||
* @param list<string> $topics
|
||||
* @return list<string> 实际生效的、去重排序后的订阅列表
|
||||
*/
|
||||
public static function replaceSubscriptions(int $connectionId, array $topics): array
|
||||
{
|
||||
if ($connectionId <= 0 || !isset(self::$connectionMeta[$connectionId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (self::$connectionMeta[$connectionId]['topics'] as $oldTopic) {
|
||||
if (isset(self::$topicIndex[$oldTopic][$connectionId])) {
|
||||
unset(self::$topicIndex[$oldTopic][$connectionId]);
|
||||
if (self::$topicIndex[$oldTopic] === []) {
|
||||
unset(self::$topicIndex[$oldTopic]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($topics as $t) {
|
||||
if (!is_string($t)) {
|
||||
continue;
|
||||
}
|
||||
$v = trim($t);
|
||||
if ($v === '' || strlen($v) > 64) {
|
||||
continue;
|
||||
}
|
||||
$clean[$v] = true;
|
||||
}
|
||||
$finalTopics = array_keys($clean);
|
||||
sort($finalTopics);
|
||||
|
||||
self::$connectionMeta[$connectionId]['topics'] = $finalTopics;
|
||||
foreach ($finalTopics as $topic) {
|
||||
self::$topicIndex[$topic][$connectionId] = true;
|
||||
}
|
||||
|
||||
return $finalTopics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量订阅:将 topics 合并到现有订阅集合(不会移除旧 topic)。
|
||||
*
|
||||
* 兼容部分客户端“多次 subscribe 但只携带增量 topic”的行为,避免后续误覆盖导致 bet.win/jackpot.hit 丢订阅。
|
||||
*
|
||||
* @param list<string> $topics
|
||||
* @return list<string> 合并后的订阅列表(去重排序)
|
||||
*/
|
||||
public static function mergeSubscriptions(int $connectionId, array $topics): array
|
||||
{
|
||||
if ($connectionId <= 0 || !isset(self::$connectionMeta[$connectionId])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($topics as $t) {
|
||||
if (!is_string($t)) {
|
||||
continue;
|
||||
}
|
||||
$v = trim($t);
|
||||
if ($v === '' || strlen($v) > 64) {
|
||||
continue;
|
||||
}
|
||||
$clean[$v] = true;
|
||||
}
|
||||
if ($clean === []) {
|
||||
return self::$connectionMeta[$connectionId]['topics'];
|
||||
}
|
||||
|
||||
$existing = self::$connectionMeta[$connectionId]['topics'];
|
||||
$mergedMap = [];
|
||||
foreach ($existing as $t) {
|
||||
$mergedMap[$t] = true;
|
||||
}
|
||||
foreach (array_keys($clean) as $t) {
|
||||
$mergedMap[$t] = true;
|
||||
}
|
||||
$finalTopics = array_keys($mergedMap);
|
||||
sort($finalTopics);
|
||||
|
||||
// 只需要把新增 topic 写入 topicIndex(旧 topic 已存在索引)
|
||||
$existingMap = [];
|
||||
foreach ($existing as $t) {
|
||||
$existingMap[$t] = true;
|
||||
}
|
||||
foreach ($finalTopics as $topic) {
|
||||
if (!isset($existingMap[$topic])) {
|
||||
self::$topicIndex[$topic][$connectionId] = true;
|
||||
}
|
||||
}
|
||||
self::$connectionMeta[$connectionId]['topics'] = $finalTopics;
|
||||
|
||||
return $finalTopics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订阅了指定 topic 的所有 connection_id。
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function connectionsForTopic(string $topic): array
|
||||
{
|
||||
$topic = trim($topic);
|
||||
if ($topic === '' || !isset(self::$topicIndex[$topic])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_keys(self::$topicIndex[$topic]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记连接活跃时间(接收任意消息时调用,用于心跳超时判断)。
|
||||
*/
|
||||
public static function touch(int $connectionId): void
|
||||
{
|
||||
if (isset(self::$connectionMeta[$connectionId])) {
|
||||
self::$connectionMeta[$connectionId]['last_seen_at'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{topics: list<string>, user_id: int, last_seen_at: int, remote_ip: string}|null
|
||||
*/
|
||||
public static function meta(int $connectionId): ?array
|
||||
{
|
||||
return self::$connectionMeta[$connectionId] ?? null;
|
||||
}
|
||||
|
||||
public static function userIdOf(int $connectionId): int
|
||||
{
|
||||
return self::$connectionMeta[$connectionId]['user_id'] ?? 0;
|
||||
}
|
||||
|
||||
public static function isAdmin(int $connectionId): bool
|
||||
{
|
||||
return (self::$connectionMeta[$connectionId]['mode'] ?? '') === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出所有 last_seen_at 早于 $cutoff 的连接 id(用于服务端主动关闭僵尸连接)。
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function staleConnections(int $cutoff): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::$connectionMeta as $cid => $meta) {
|
||||
if (($meta['last_seen_at'] ?? 0) < $cutoff) {
|
||||
$out[] = $cid;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前活跃连接数(运维/诊断用)。
|
||||
*/
|
||||
public static function connectionCount(): int
|
||||
{
|
||||
return count(self::$connectionMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前活跃订阅总数(运维/诊断用)。
|
||||
*/
|
||||
public static function subscriptionCount(): int
|
||||
{
|
||||
$sum = 0;
|
||||
foreach (self::$topicIndex as $conns) {
|
||||
$sum += count($conns);
|
||||
}
|
||||
return $sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供测试/进程重启时清空索引。
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$topicIndex = [];
|
||||
self::$connectionMeta = [];
|
||||
}
|
||||
}
|
||||
168
app/common/service/MobileAuthDeviceService.php
Normal file
168
app/common/service/MobileAuthDeviceService.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth;
|
||||
use support\Redis;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 移动端单设备登录:auth-token 绑定 device_id,用户登录态绑定当前设备,异设备踢下线。
|
||||
*/
|
||||
final class MobileAuthDeviceService
|
||||
{
|
||||
private const PREFIX_AUTH_DEVICE = 'dfw:v1:auth_token:device:';
|
||||
|
||||
private const PREFIX_USER_DEVICE = 'dfw:v1:user:active_device:';
|
||||
|
||||
public static function bindAuthTokenDevice(string $authToken, string $deviceId, int $ttlSeconds): void
|
||||
{
|
||||
$authToken = trim($authToken);
|
||||
$deviceId = trim($deviceId);
|
||||
if ($authToken === '' || $deviceId === '' || $ttlSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Redis::setEx(self::PREFIX_AUTH_DEVICE . self::tokenStorageKey($authToken), $ttlSeconds, $deviceId);
|
||||
} catch (Throwable) {
|
||||
// Redis 不可用时跳过绑定,后续校验将拒绝已登录请求(fail closed)
|
||||
}
|
||||
}
|
||||
|
||||
public static function getAuthTokenDevice(string $authToken): string
|
||||
{
|
||||
$authToken = trim($authToken);
|
||||
if ($authToken === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
$raw = Redis::get(self::PREFIX_AUTH_DEVICE . self::tokenStorageKey($authToken));
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
if ($raw === false || $raw === null) {
|
||||
return '';
|
||||
}
|
||||
return trim((string) $raw);
|
||||
}
|
||||
|
||||
public static function bindUserActiveDevice(int $userId, string $deviceId, int $ttlSeconds): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$deviceId = trim($deviceId);
|
||||
if ($deviceId === '' || $ttlSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Redis::setEx(self::PREFIX_USER_DEVICE . $userId, $ttlSeconds, $deviceId);
|
||||
} catch (Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public static function getUserActiveDevice(int $userId): string
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
$raw = Redis::get(self::PREFIX_USER_DEVICE . $userId);
|
||||
} catch (Throwable) {
|
||||
return '';
|
||||
}
|
||||
if ($raw === false || $raw === null) {
|
||||
return '';
|
||||
}
|
||||
return trim((string) $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录/注册成功后:清除其它会话,绑定当前 auth-token 对应设备,并恢复本次登录签发的 token。
|
||||
*
|
||||
* @param string $userToken 本次登录的 user-token(clear 后需写回)
|
||||
* @param string $refreshToken 本次登录的 refresh_token(可为空)
|
||||
*/
|
||||
public static function onUserLogin(
|
||||
int $userId,
|
||||
string $authToken,
|
||||
string $userToken,
|
||||
string $refreshToken = '',
|
||||
?int $userTokenTtl = null,
|
||||
?int $refreshTokenTtl = null
|
||||
): void {
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
Token::clear(Auth::TOKEN_TYPE, $userId);
|
||||
Token::clear(Auth::TOKEN_TYPE . '-refresh', $userId);
|
||||
|
||||
$userTtl = $userTokenTtl ?? (int) config('buildadmin.user_token_keep_time', 259200);
|
||||
$refreshTtl = $refreshTokenTtl ?? 2592000;
|
||||
if ($userToken !== '') {
|
||||
Token::set($userToken, Auth::TOKEN_TYPE, $userId, $userTtl);
|
||||
}
|
||||
if ($refreshToken !== '') {
|
||||
Token::set($refreshToken, Auth::TOKEN_TYPE . '-refresh', $userId, $refreshTtl);
|
||||
}
|
||||
|
||||
$deviceId = self::getAuthTokenDevice($authToken);
|
||||
if ($deviceId === '') {
|
||||
return;
|
||||
}
|
||||
self::bindUserActiveDevice($userId, $deviceId, $userTtl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录请求校验:auth-token 设备须与用户当前绑定设备一致。
|
||||
*
|
||||
* @return string|null 失败时返回多语言 message key,成功返回 null
|
||||
*/
|
||||
/**
|
||||
* 用户主动退出:清除 Redis 中的活跃设备绑定。
|
||||
*/
|
||||
public static function onUserLogout(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Redis::del(self::PREFIX_USER_DEVICE . $userId);
|
||||
} catch (Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public static function validateUserDeviceSession(string $authToken, int $userId): ?string
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$authDevice = self::getAuthTokenDevice($authToken);
|
||||
if ($authDevice === '') {
|
||||
return 'auth-token is invalid or expired';
|
||||
}
|
||||
$activeDevice = self::getUserActiveDevice($userId);
|
||||
if ($activeDevice === '') {
|
||||
$ttl = (int) config('buildadmin.user_token_keep_time', 259200);
|
||||
self::bindUserActiveDevice($userId, $authDevice, $ttl);
|
||||
return null;
|
||||
}
|
||||
if (!hash_equals($activeDevice, $authDevice)) {
|
||||
return 'Logged in on another device, please login again.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function tokenStorageKey(string $token): string
|
||||
{
|
||||
$config = config('buildadmin.token');
|
||||
$algo = is_array($config) && isset($config['algo']) ? (string) $config['algo'] : 'ripemd160';
|
||||
$key = is_array($config) && isset($config['key']) ? (string) $config['key'] : '';
|
||||
return hash_hmac($algo, $token, $key);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,11 @@ class GamePeriod extends Validate
|
||||
'period_start_at' => 'integer',
|
||||
'status' => 'require|in:0,1,2,3,4,5',
|
||||
'draw_mode' => 'in:0,1',
|
||||
'preset_number' => 'between:1,36',
|
||||
'result_number' => 'between:1,36',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
|
||||
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
|
||||
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'],
|
||||
'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,12 +15,11 @@ class GameRecord extends Validate
|
||||
'period_start_at' => 'integer',
|
||||
'status' => 'require|in:0,1,2,3,4,5',
|
||||
'draw_mode' => 'in:0,1',
|
||||
'preset_number' => 'between:1,36',
|
||||
'result_number' => 'between:1,36',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
|
||||
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
|
||||
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'],
|
||||
'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -39,13 +39,38 @@ if (!function_exists('env')) {
|
||||
if (!function_exists('__')) {
|
||||
/**
|
||||
* 语言翻译(BuildAdmin 兼容)
|
||||
* ThinkPHP 风格占位符(%s / %d 等 + 数字下标 vars)在翻译后走 sprintf;
|
||||
* Symfony 风格占位符(%name% 或 '%s' => value 等字符串键)走 trans/strtr。
|
||||
*/
|
||||
function __(string $name, array $vars = [], string $lang = ''): mixed
|
||||
{
|
||||
if (is_numeric($name) || !$name) {
|
||||
return $name;
|
||||
}
|
||||
return function_exists('trans') ? trans($name, $vars, null, $lang ?: null) : $name;
|
||||
if (!function_exists('trans')) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$positional = [];
|
||||
$named = [];
|
||||
foreach ($vars as $k => $v) {
|
||||
if (is_int($k)) {
|
||||
$positional[$k] = $v;
|
||||
} else {
|
||||
$named[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
if ($positional !== [] && $named === []) {
|
||||
$translated = trans($name, [], null, $lang ?: null);
|
||||
if ($translated === '' || $translated === $name) {
|
||||
$translated = $name;
|
||||
}
|
||||
|
||||
return vsprintf($translated, array_values($positional));
|
||||
}
|
||||
|
||||
return trans($name, $vars, null, $lang ?: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,25 +480,28 @@ if (!function_exists('get_route_remark')) {
|
||||
function get_route_remark(): string
|
||||
{
|
||||
$controllerPath = get_controller_path() ?? '';
|
||||
$actionName = '';
|
||||
if (function_exists('request')) {
|
||||
$req = request();
|
||||
if ($req) {
|
||||
$path = trim($req->path(), '/');
|
||||
$parts = explode('/', $path);
|
||||
$actionName = $parts[array_key_last($parts)] ?? '';
|
||||
}
|
||||
if ($controllerPath === '') {
|
||||
return '';
|
||||
}
|
||||
$path = str_replace('.', '/', $controllerPath);
|
||||
$names = [$path];
|
||||
if ($actionName) {
|
||||
$names[] = $path . '/' . $actionName;
|
||||
$menuNames = [$path];
|
||||
$parts = explode('/', $path);
|
||||
foreach ($parts as &$part) {
|
||||
if (str_contains($part, '_')) {
|
||||
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
|
||||
}
|
||||
}
|
||||
unset($part);
|
||||
$camelPath = implode('/', $parts);
|
||||
if ($camelPath !== $path) {
|
||||
$menuNames[] = $camelPath;
|
||||
}
|
||||
$remark = \support\think\Db::name('admin_rule')
|
||||
->where('name', 'in', $names)
|
||||
->where('name', 'in', array_values(array_unique($menuNames)))
|
||||
->where('type', 'in', ['menu', 'menu_dir'])
|
||||
->value('remark');
|
||||
$remarkStr = (string) ($remark ?? '');
|
||||
if (!$remarkStr) {
|
||||
if ($remarkStr === '') {
|
||||
return '';
|
||||
}
|
||||
return function_exists('trans') ? trans($remarkStr, [], $controllerPath ?: null) : $remarkStr;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\process;
|
||||
|
||||
use app\common\service\GameLiveService;
|
||||
use app\common\service\GameLiveStuckDiagnostic;
|
||||
use Workerman\Timer;
|
||||
|
||||
/**
|
||||
@@ -12,9 +13,10 @@ class GameLiveTicker
|
||||
{
|
||||
public function onWorkerStart(): void
|
||||
{
|
||||
GameLiveService::recoverAbnormalPeriodOnStartup();
|
||||
GameLiveStuckDiagnostic::inspectAfterRecovery();
|
||||
|
||||
Timer::add(1, static function (): void {
|
||||
GameLiveService::finalizePayoutGrace();
|
||||
GameLiveService::tickAutoDraw();
|
||||
GameLiveService::publishSnapshot(null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,23 +4,251 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\service\GameLiveService;
|
||||
use app\common\service\GameWebSocketAuthHelper;
|
||||
use app\common\service\GameWebSocketDispatcher;
|
||||
use app\common\service\GameWebSocketEventBus;
|
||||
use app\common\service\GameWebSocketPayloadHelper;
|
||||
use app\common\service\GameWebSocketSubscriptionRegistry;
|
||||
use support\Log;
|
||||
use Throwable;
|
||||
use Workerman\Connection\TcpConnection;
|
||||
use Workerman\Timer;
|
||||
|
||||
/**
|
||||
* 后台测试页 WebSocket 服务(仅用于连接联调)
|
||||
* H5/后台 WebSocket 服务(重构版,2026-05)
|
||||
*
|
||||
* 设计与 docs/36字花-移动端接口设计草案.md §7 对齐:
|
||||
*
|
||||
* - 握手鉴权(GameWebSocketAuthHelper)
|
||||
* - mobile:URL Query **`auth-token`** + **`user-token`**(与 HTTP 头一致),绑定 user_id
|
||||
* - admin: URL Query **`admin-ws-token`**(后台 wsConfig 签发),user_id=0,
|
||||
* user 级主题不过滤(运维/联调可观测全量)
|
||||
* - 客户端 -> 服务端:`{"action":"ping"}` / `{"action":"subscribe","topics":[...]}`
|
||||
* - 服务端 -> 客户端:`ws.connected` / `ws.subscribed` / `pong` / `ws.error` / 业务事件帧
|
||||
* - 主动心跳超时:连接 60s 内无任何上行报文(含 ping)即被 server 主动 close,
|
||||
* 触发客户端重连,避免半关闭僵尸连接堵在分发表里
|
||||
* - 事件分发由 GameWebSocketDispatcher 负责;每次 publish/consume/dispatch/send/订阅/关闭
|
||||
* 都写入独立日志通道 runtime/logs/ws.log
|
||||
*
|
||||
* 高可用:
|
||||
* - 消费 Timer 的 popBatch 异常已被 EventBus 内部捕获并记录,下次 tick 自动恢复
|
||||
* - 任一连接 send 异常不会影响其它连接的下发(Dispatcher 内单连接 try/catch)
|
||||
* - admin.live.snapshot 兜底直推 Timer 与队列消费解耦,Redis 异常时后台仍能看到对局状态
|
||||
*/
|
||||
class GameWebSocketServer
|
||||
{
|
||||
/** @var array<int, TcpConnection> */
|
||||
/** @var array<int, TcpConnection> connection_id => TcpConnection(仅本进程内) */
|
||||
private static array $connections = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> connection_id => 鉴权元数据(mode/admin_id 等,便于日志/排查) */
|
||||
private static array $connectionAuth = [];
|
||||
|
||||
private static bool $eventBusConsumerStarted = false;
|
||||
private static bool $adminSnapshotTickerStarted = false;
|
||||
private static bool $heartbeatCheckerStarted = false;
|
||||
/** @var bool */
|
||||
private static bool $liveStateTickerStarted = false;
|
||||
|
||||
/** 客户端 60s 内无任何上行报文(含 ping)即被主动断开 */
|
||||
private const HEARTBEAT_IDLE_SECONDS = 60;
|
||||
|
||||
/** 事件队列每 N ms 拉一次(默认 1s;与文档 §7.1.3 一致) */
|
||||
private const QUEUE_TICK_INTERVAL = 1;
|
||||
|
||||
/** 心跳超时检查间隔(秒) */
|
||||
private const HEARTBEAT_CHECK_INTERVAL = 10;
|
||||
|
||||
public function onWorkerStart(): void
|
||||
{
|
||||
Log::channel('ws')->info('ws worker start', [
|
||||
'pid' => function_exists('posix_getpid') ? posix_getpid() : getmypid(),
|
||||
]);
|
||||
self::ensureEventBusConsumer();
|
||||
self::ensureAdminLiveSnapshotTicker();
|
||||
self::ensureHeartbeatChecker();
|
||||
}
|
||||
|
||||
public function onConnect(TcpConnection $connection): void
|
||||
{
|
||||
$connection->topics = [];
|
||||
$connection->wsAuth = null;
|
||||
self::$connections[$connection->id] = $connection;
|
||||
Log::channel('ws')->debug('tcp onConnect', [
|
||||
'connection_id' => $connection->id,
|
||||
'remote' => method_exists($connection, 'getRemoteIp') ? $connection->getRemoteIp() : '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Redis 队列拉取事件并推送给已订阅连接。
|
||||
* 部分环境下 WebSocket 进程的 onWorkerStart 可能未触发,因此在首帧握手处也会兜底启动一次(全局仅注册一个 Timer)。
|
||||
* Workerman 在 WebSocket 握手时回调;$request 可能是 string(HTTP raw)或 Webman\Http\Request 对象,
|
||||
* 这里仅尝试从 connection 内置变量取 QueryString 做最大兼容。
|
||||
*/
|
||||
public function onWebSocketConnect(TcpConnection $connection, mixed $request = null): void
|
||||
{
|
||||
self::ensureEventBusConsumer();
|
||||
self::ensureAdminLiveSnapshotTicker();
|
||||
self::ensureHeartbeatChecker();
|
||||
self::ensureLiveStateTicker();
|
||||
|
||||
$queryString = self::extractQueryString($connection, $request);
|
||||
$query = GameWebSocketAuthHelper::parseQueryString($queryString);
|
||||
|
||||
$auth = GameWebSocketAuthHelper::authorize($query);
|
||||
$remoteIp = self::remoteIp($connection);
|
||||
|
||||
if (!$auth['ok']) {
|
||||
Log::channel('ws')->warning('handshake denied', [
|
||||
'connection_id' => $connection->id,
|
||||
'remote_ip' => $remoteIp,
|
||||
'reason' => $auth['reason'],
|
||||
'query_keys' => array_keys($query),
|
||||
]);
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'ws.error', [
|
||||
'code' => 1101,
|
||||
'message' => 'Authentication failed: ' . ($auth['reason'] ?: 'unauthorized'),
|
||||
], 'handshake_denied');
|
||||
try {
|
||||
$connection->close();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$connection->wsAuth = $auth;
|
||||
self::$connectionAuth[$connection->id] = $auth;
|
||||
GameWebSocketSubscriptionRegistry::registerConnection(
|
||||
$connection->id,
|
||||
(int) $auth['user_id'],
|
||||
is_string($auth['mode'] ?? null) ? (string) $auth['mode'] : 'mobile',
|
||||
$remoteIp
|
||||
);
|
||||
|
||||
Log::channel('ws')->info('handshake ok', [
|
||||
'connection_id' => $connection->id,
|
||||
'remote_ip' => $remoteIp,
|
||||
'mode' => $auth['mode'],
|
||||
'user_id' => $auth['user_id'],
|
||||
'admin_id' => $auth['admin_id'],
|
||||
]);
|
||||
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'ws.connected', [
|
||||
'message' => 'WebSocket connected',
|
||||
'mode' => $auth['mode'],
|
||||
'server_time' => time(),
|
||||
'heartbeat_interval' => 30,
|
||||
'idle_timeout' => self::HEARTBEAT_IDLE_SECONDS,
|
||||
], 'handshake_ok');
|
||||
}
|
||||
|
||||
public function onMessage(TcpConnection $connection, string $payload): void
|
||||
{
|
||||
GameWebSocketSubscriptionRegistry::touch($connection->id);
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
if (!is_array($decoded)) {
|
||||
Log::channel('ws')->debug('invalid json payload', [
|
||||
'connection_id' => $connection->id,
|
||||
'payload_size' => strlen($payload),
|
||||
]);
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'ws.error', [
|
||||
'message' => 'Invalid JSON payload',
|
||||
], 'invalid_json');
|
||||
return;
|
||||
}
|
||||
|
||||
$action = isset($decoded['action']) && is_string($decoded['action']) ? trim($decoded['action']) : '';
|
||||
|
||||
if ($action === 'ping') {
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'pong', [
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
'server_time_ts' => time(),
|
||||
], 'pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'subscribe') {
|
||||
$rawTopics = $decoded['topics'] ?? [];
|
||||
$rawList = is_array($rawTopics) ? $rawTopics : [];
|
||||
$replace = false;
|
||||
if (!empty($decoded['replace'])) {
|
||||
$replace = true;
|
||||
}
|
||||
if (isset($decoded['mode']) && is_string($decoded['mode']) && trim($decoded['mode']) === 'replace') {
|
||||
$replace = true;
|
||||
}
|
||||
$before = GameWebSocketSubscriptionRegistry::meta($connection->id);
|
||||
$beforeTopics = is_array($before) ? ($before['topics'] ?? []) : [];
|
||||
|
||||
$finalTopics = $replace
|
||||
? GameWebSocketSubscriptionRegistry::replaceSubscriptions($connection->id, $rawList)
|
||||
: GameWebSocketSubscriptionRegistry::mergeSubscriptions($connection->id, $rawList);
|
||||
Log::channel('ws')->info('subscribe', [
|
||||
'connection_id' => $connection->id,
|
||||
'user_id' => GameWebSocketSubscriptionRegistry::userIdOf($connection->id),
|
||||
'replace' => $replace,
|
||||
'before_topics' => $beforeTopics,
|
||||
'topics' => $finalTopics,
|
||||
'requested_count' => is_array($rawTopics) ? count($rawTopics) : 0,
|
||||
]);
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'ws.subscribed', [
|
||||
'topics' => $finalTopics,
|
||||
], 'subscribed');
|
||||
// 演示帧仅供后台 admin 联调;mobile 用户订阅后不应收到样例玩家的 bet.accepted / wallet.changed
|
||||
if (GameWebSocketSubscriptionRegistry::isAdmin($connection->id)) {
|
||||
self::pushAdminTestOddsPreview($connection, $finalTopics);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Log::channel('ws')->debug('unsupported action', [
|
||||
'connection_id' => $connection->id,
|
||||
'action' => $action,
|
||||
]);
|
||||
GameWebSocketDispatcher::sendDirect($connection, 'ws.error', [
|
||||
'message' => 'Unsupported action',
|
||||
'received_action' => $action,
|
||||
], 'unsupported_action');
|
||||
}
|
||||
|
||||
public function onError(TcpConnection $connection, int $code, string $msg): void
|
||||
{
|
||||
Log::channel('ws')->warning('connection error', [
|
||||
'connection_id' => $connection->id,
|
||||
'code' => $code,
|
||||
'detail' => $msg,
|
||||
]);
|
||||
try {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.error',
|
||||
'message' => 'Server internal error',
|
||||
'code' => $code,
|
||||
'detail' => $msg,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function onClose(TcpConnection $connection): void
|
||||
{
|
||||
$auth = self::$connectionAuth[$connection->id] ?? [];
|
||||
Log::channel('ws')->info('connection close', [
|
||||
'connection_id' => $connection->id,
|
||||
'user_id' => GameWebSocketSubscriptionRegistry::userIdOf($connection->id),
|
||||
'mode' => $auth['mode'] ?? '',
|
||||
'remaining_connections' => max(0, GameWebSocketSubscriptionRegistry::connectionCount() - 1),
|
||||
]);
|
||||
GameWebSocketSubscriptionRegistry::unregisterConnection($connection->id);
|
||||
unset(self::$connections[$connection->id], self::$connectionAuth[$connection->id]);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部:Timer / 兜底
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 每秒拉取 Redis 队列事件并分发到对应订阅连接。
|
||||
* popBatch 内部已捕获 Redis 异常并写 ws 日志,Timer 不会因此停跑。
|
||||
*/
|
||||
private static function ensureEventBusConsumer(): void
|
||||
{
|
||||
@@ -28,124 +256,214 @@ class GameWebSocketServer
|
||||
return;
|
||||
}
|
||||
self::$eventBusConsumerStarted = true;
|
||||
Timer::add(self::QUEUE_TICK_INTERVAL, static function (): void {
|
||||
try {
|
||||
$events = GameWebSocketEventBus::popBatch();
|
||||
if ($events === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
try {
|
||||
GameWebSocketDispatcher::dispatch($event, self::$connections);
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('dispatch loop exception', [
|
||||
'topic' => $event['topic'] ?? '',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('event bus consumer tick exception', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
Log::channel('ws')->info('event bus consumer started', [
|
||||
'tick_interval_seconds' => self::QUEUE_TICK_INTERVAL,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* admin.live.snapshot 兜底:每秒构建一次快照直接推送(不依赖 Redis 队列)。
|
||||
* 用于 /admin/game/live 实时对局页,确保 Redis 异常时后台仍能看到对局状态。
|
||||
*/
|
||||
private static function ensureAdminLiveSnapshotTicker(): void
|
||||
{
|
||||
if (self::$adminSnapshotTickerStarted) {
|
||||
return;
|
||||
}
|
||||
self::$adminSnapshotTickerStarted = true;
|
||||
Timer::add(1, static function (): void {
|
||||
$events = GameWebSocketEventBus::popBatch();
|
||||
if ($events === []) {
|
||||
$cids = GameWebSocketSubscriptionRegistry::connectionsForTopic('admin.live.snapshot');
|
||||
if ($cids === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
$topic = $event['topic'] ?? '';
|
||||
if (!is_string($topic) || $topic === '') {
|
||||
try {
|
||||
$snapshot = GameLiveService::buildSnapshot(null);
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('admin snapshot build failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
$payload = json_encode([
|
||||
'event' => 'admin.live.snapshot',
|
||||
'topic' => 'admin.live.snapshot',
|
||||
'data' => GameWebSocketPayloadHelper::sanitizeOutboundData($snapshot),
|
||||
'server_time' => time(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($payload) || $payload === '') {
|
||||
return;
|
||||
}
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
foreach ($cids as $cid) {
|
||||
if (!isset(self::$connections[$cid])) {
|
||||
continue;
|
||||
}
|
||||
$eventName = $event['event'] ?? $topic;
|
||||
$data = $event['data'] ?? [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$serverTime = $event['server_time'] ?? time();
|
||||
foreach (self::$connections as $connection) {
|
||||
$topics = $connection->topics ?? [];
|
||||
if (!is_array($topics) || !in_array($topic, $topics, true)) {
|
||||
continue;
|
||||
}
|
||||
$connection->send(json_encode([
|
||||
'event' => $eventName,
|
||||
'topic' => $topic,
|
||||
'data' => $data,
|
||||
'server_time' => $serverTime,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
try {
|
||||
self::$connections[$cid]->send($payload);
|
||||
$sent++;
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
Log::channel('ws')->warning('admin snapshot send failed', [
|
||||
'connection_id' => $cid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function onWorkerStart(): void
|
||||
/**
|
||||
* 心跳超时检查器:每 10s 扫描一次,关闭 60s 无上行报文的连接。
|
||||
* 触发客户端重连,避免半关闭僵尸连接持续接收推送但不实际送达。
|
||||
*/
|
||||
private static function ensureHeartbeatChecker(): void
|
||||
{
|
||||
self::ensureEventBusConsumer();
|
||||
}
|
||||
|
||||
public function onConnect(TcpConnection $connection): void
|
||||
{
|
||||
$connection->topics = [];
|
||||
self::$connections[$connection->id] = $connection;
|
||||
}
|
||||
|
||||
public function onWebSocketConnect(TcpConnection $connection): void
|
||||
{
|
||||
self::ensureEventBusConsumer();
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.connected',
|
||||
'message' => 'WebSocket connected',
|
||||
'connection_id' => $connection->id,
|
||||
'server_time' => time(),
|
||||
'heartbeat_interval' => 30,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public function onMessage(TcpConnection $connection, string $payload): void
|
||||
{
|
||||
$decoded = json_decode($payload, true);
|
||||
if (!is_array($decoded)) {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.error',
|
||||
'message' => 'Invalid JSON payload',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
if (self::$heartbeatCheckerStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
$action = $decoded['action'] ?? '';
|
||||
if ($action === 'ping') {
|
||||
$connection->send(json_encode([
|
||||
'event' => 'pong',
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'subscribe') {
|
||||
$topics = $decoded['topics'] ?? [];
|
||||
$sanitized = [];
|
||||
if (is_array($topics)) {
|
||||
foreach ($topics as $topic) {
|
||||
if (!is_string($topic)) {
|
||||
continue;
|
||||
self::$heartbeatCheckerStarted = true;
|
||||
Timer::add(self::HEARTBEAT_CHECK_INTERVAL, static function (): void {
|
||||
$cutoff = time() - self::HEARTBEAT_IDLE_SECONDS;
|
||||
$stale = GameWebSocketSubscriptionRegistry::staleConnections($cutoff);
|
||||
if ($stale === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($stale as $cid) {
|
||||
Log::channel('ws')->info('close idle connection', [
|
||||
'connection_id' => $cid,
|
||||
'user_id' => GameWebSocketSubscriptionRegistry::userIdOf($cid),
|
||||
'idle_seconds' => self::HEARTBEAT_IDLE_SECONDS,
|
||||
]);
|
||||
if (isset(self::$connections[$cid])) {
|
||||
try {
|
||||
self::$connections[$cid]->close();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
$value = trim($topic);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$sanitized[] = $value;
|
||||
}
|
||||
}
|
||||
$connection->topics = array_values(array_unique($sanitized));
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.subscribed',
|
||||
'topics' => $connection->topics,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
});
|
||||
Log::channel('ws')->info('heartbeat checker started', [
|
||||
'idle_seconds' => self::HEARTBEAT_IDLE_SECONDS,
|
||||
'check_interval' => self::HEARTBEAT_CHECK_INTERVAL,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底:当 gameLiveTicker 未运行/卡死时,WS 进程也能驱动对局推进,避免对局长期卡在派彩中(status=3)。
|
||||
*
|
||||
* - 每秒执行一次 recoverLiveRoundState()(内部:finalizePayoutGrace + tickAutoDraw)
|
||||
* - 幂等且有锁保护,和 gameLiveTicker 并存也不会破坏一致性
|
||||
*/
|
||||
private static function ensureLiveStateTicker(): void
|
||||
{
|
||||
if (self::$liveStateTickerStarted) {
|
||||
return;
|
||||
}
|
||||
self::$liveStateTickerStarted = true;
|
||||
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.error',
|
||||
'message' => 'Unsupported action',
|
||||
'received_action' => $action,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
Timer::add(1, static function (): void {
|
||||
try {
|
||||
GameLiveService::recoverLiveRoundState();
|
||||
} catch (Throwable $e) {
|
||||
Log::channel('ws')->error('live state ticker failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
Log::channel('ws')->info('live state ticker started', [
|
||||
'tick_interval_seconds' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onError(TcpConnection $connection, int $code, string $msg): void
|
||||
/**
|
||||
* 后台联调:订阅赔率相关主题后立即推送演示帧(库内样例玩家,带 is_test / preview)。
|
||||
*
|
||||
* @param list<string> $topics
|
||||
*/
|
||||
private static function pushAdminTestOddsPreview(TcpConnection $connection, array $topics): void
|
||||
{
|
||||
$connection->send(json_encode([
|
||||
'event' => 'ws.error',
|
||||
'message' => 'Server internal error',
|
||||
'code' => $code,
|
||||
'detail' => $msg,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$frames = GameWebSocketPayloadHelper::adminTestPushFrames($topics);
|
||||
if ($frames === []) {
|
||||
return;
|
||||
}
|
||||
$serverTime = time();
|
||||
foreach ($frames as $frame) {
|
||||
GameWebSocketDispatcher::sendDirect($connection, $frame['event'], [
|
||||
'topic' => $frame['topic'],
|
||||
'data' => $frame['data'],
|
||||
'server_time' => $serverTime,
|
||||
], 'admin_test_preview');
|
||||
}
|
||||
}
|
||||
|
||||
public function onClose(TcpConnection $connection): void
|
||||
/**
|
||||
* 兼容多种 Workerman 版本:尝试取握手 URI 的 query 部分。
|
||||
* Workerman v5 中 onWebSocketConnect 第二参数已是 array $headers/$request;这里尽量兼容字符串与对象。
|
||||
*/
|
||||
private static function extractQueryString(TcpConnection $connection, mixed $request): string
|
||||
{
|
||||
$connection->topics = [];
|
||||
unset(self::$connections[$connection->id]);
|
||||
if (isset($connection->wsHandshakeQuery) && is_string($connection->wsHandshakeQuery)) {
|
||||
return (string) $connection->wsHandshakeQuery;
|
||||
}
|
||||
if (isset($connection->onWebSocketConnect)) {
|
||||
// noop
|
||||
}
|
||||
$serverUri = '';
|
||||
if (isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI'])) {
|
||||
$serverUri = $_SERVER['REQUEST_URI'];
|
||||
}
|
||||
if ($serverUri === '' && isset($connection->headers) && is_array($connection->headers)) {
|
||||
$line = $connection->headers['get'] ?? $connection->headers['GET'] ?? '';
|
||||
if (is_string($line)) {
|
||||
$serverUri = $line;
|
||||
}
|
||||
}
|
||||
if ($serverUri === '' && is_string($request)) {
|
||||
if (preg_match('#^GET\s+([^\s]+)#i', $request, $m) === 1) {
|
||||
$serverUri = $m[1];
|
||||
}
|
||||
}
|
||||
if ($serverUri === '' && is_object($request) && method_exists($request, 'queryString')) {
|
||||
$serverUri = '?' . (string) $request->queryString();
|
||||
}
|
||||
$qPos = strpos($serverUri, '?');
|
||||
if ($qPos === false) {
|
||||
return '';
|
||||
}
|
||||
return substr($serverUri, $qPos + 1);
|
||||
}
|
||||
|
||||
private static function remoteIp(TcpConnection $connection): string
|
||||
{
|
||||
try {
|
||||
if (method_exists($connection, 'getRemoteIp')) {
|
||||
return (string) $connection->getRemoteIp();
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@ abstract class BaseController
|
||||
*/
|
||||
protected function result(string $msg, mixed $data = null, int $code = 0, array $header = []): Response
|
||||
{
|
||||
// 统一对返回 msg 做多语言翻译:若 $msg 为语言 key(英文),则按请求语言输出;
|
||||
// 若无对应翻译,则保持原样返回。
|
||||
if ($msg !== '' && function_exists('__')) {
|
||||
$translated = __($msg);
|
||||
if (is_string($translated) && $translated !== '') {
|
||||
$msg = $translated;
|
||||
}
|
||||
}
|
||||
$body = [
|
||||
'code' => $code,
|
||||
'msg' => $msg,
|
||||
|
||||
@@ -16,12 +16,61 @@ use support\Request;
|
||||
|
||||
return [
|
||||
/**
|
||||
* 模拟充值网关 HMAC 密钥;生产环境请设置环境变量 DEPOSIT_MOCK_HMAC_KEY 覆盖。
|
||||
* 与 app\common\library\finance\DepositMockGateway 使用。
|
||||
* DDPay(Payment Gateway)全量配置来自环境变量,见仓库根目录 `.env-example` 中 DDPAY_* 说明。
|
||||
*
|
||||
* - DDPAY_PUBLIC_BASE_URL:站点对外的 HTTPS 根地址(无尾斜杠),用于拼接入金/出金 callback_url;不配置则从请求头推导(本地可能为 http,生产务必配置)。
|
||||
* - DDPAY_CLIENT_ID / DDPAY_IDENTIFIER / DDPAY_API_SECRET:商户标识与签名密钥(文档 Authentication)。
|
||||
* - DDPAY_DEPOSIT_INIT_URL / DDPAY_DEPOSIT_STATUS_URL:入金发起、入金状态查询(文档 §3.1 / §3.3)。
|
||||
* - DDPAY_PAYOUT_INIT_URL / DDPAY_PAYOUT_STATUS_URL:出金发起、出金状态查询(文档 §3.2 / §3.4)。
|
||||
*/
|
||||
'deposit_mock_hmac_key' => is_string(getenv('DEPOSIT_MOCK_HMAC_KEY')) && trim((string) getenv('DEPOSIT_MOCK_HMAC_KEY')) !== ''
|
||||
? trim((string) getenv('DEPOSIT_MOCK_HMAC_KEY'))
|
||||
: '',
|
||||
'ddpay_public_base_url' => is_string(getenv('DDPAY_PUBLIC_BASE_URL')) ? trim(getenv('DDPAY_PUBLIC_BASE_URL')) : '',
|
||||
|
||||
/** H5 前端游戏页地址:模拟充值收银台「返回游戏」在当前窗口跳转至此(无尾斜杠) */
|
||||
'h5_game_url' => is_string(getenv('H5_GAME_URL')) ? trim(getenv('H5_GAME_URL')) : '',
|
||||
|
||||
'ddpay_client_id' => is_string(getenv('DDPAY_CLIENT_ID')) ? trim(getenv('DDPAY_CLIENT_ID')) : '',
|
||||
'ddpay_identifier' => is_string(getenv('DDPAY_IDENTIFIER')) ? trim(getenv('DDPAY_IDENTIFIER')) : '',
|
||||
'ddpay_api_secret' => is_string(getenv('DDPAY_API_SECRET')) ? trim(getenv('DDPAY_API_SECRET')) : '',
|
||||
|
||||
'ddpay_deposit_init_url' => is_string(getenv('DDPAY_DEPOSIT_INIT_URL')) ? trim(getenv('DDPAY_DEPOSIT_INIT_URL')) : '',
|
||||
'ddpay_deposit_status_url' => is_string(getenv('DDPAY_DEPOSIT_STATUS_URL')) ? trim(getenv('DDPAY_DEPOSIT_STATUS_URL')) : '',
|
||||
|
||||
'ddpay_payout_init_url' => is_string(getenv('DDPAY_PAYOUT_INIT_URL')) ? trim(getenv('DDPAY_PAYOUT_INIT_URL')) : '',
|
||||
'ddpay_payout_status_url' => is_string(getenv('DDPAY_PAYOUT_STATUS_URL')) ? trim(getenv('DDPAY_PAYOUT_STATUS_URL')) : '',
|
||||
|
||||
// 模拟支付(channel_code=mock):未接入 DDPay 时用于联调;FINANCE_MOCK_PAY_ENABLED=0 可关闭
|
||||
'finance_mock_pay_enabled' => (static function (): bool {
|
||||
$raw = getenv('FINANCE_MOCK_PAY_ENABLED');
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
$norm = strtolower(trim($raw));
|
||||
if (in_array($norm, ['0', 'false', 'no', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($norm, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$debugRaw = getenv('APP_DEBUG');
|
||||
if ($debugRaw === false || $debugRaw === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string) $debugRaw)), ['1', 'true', 'yes', 'on'], true);
|
||||
})(),
|
||||
|
||||
/** 充值待支付订单有效秒数(超时未支付自动失败;支付链接倒计时与此一致) */
|
||||
'deposit_pending_expire_seconds' => (static function (): int {
|
||||
$raw = getenv('DEPOSIT_PENDING_EXPIRE_SECONDS');
|
||||
if (is_string($raw) && trim($raw) !== '') {
|
||||
$v = filter_var(trim($raw), FILTER_VALIDATE_INT);
|
||||
if ($v !== false && $v > 0) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return 60;
|
||||
})(),
|
||||
|
||||
'debug' => true,
|
||||
'error_reporting' => E_ALL,
|
||||
'default_timezone' => 'Asia/Shanghai',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
return [
|
||||
// 允许跨域访问的域名(* 表示任意;开发可用 *,生产建议填具体域名)
|
||||
'cors_request_domain' => '*,zihua.yuliao666.top',
|
||||
'cors_request_domain' => '*,zihua.h55555game.top,zihua-game.h55555game.top',
|
||||
// 是否开启会员登录验证码
|
||||
'user_login_captcha' => false,
|
||||
// 是否开启管理员登录验证码
|
||||
|
||||
@@ -29,4 +29,48 @@ return [
|
||||
]
|
||||
],
|
||||
],
|
||||
/**
|
||||
* 游戏 WebSocket 推送链路专用日志(独立通道):
|
||||
* - 写入 runtime/logs/ws-YYYY-MM-DD.log,保留 7 天
|
||||
* - 通过 Log::channel('ws')->info(...) 访问
|
||||
* - 记录维度:publish 入队、queue 消费、按 topic/user_id 分发、心跳与连接生命周期、握手鉴权失败等
|
||||
*/
|
||||
'ws' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/ws.log',
|
||||
7,
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
/**
|
||||
* 游戏实时对局卡住诊断(独立通道):
|
||||
* - 写入 runtime/logs/game-live-stuck-YYYY-MM-DD.log,保留 7 天
|
||||
* - 由 GameLiveStuckDiagnostic 在 recoverLiveRoundState 后巡检写入
|
||||
* - 字段含 phase(卡住阶段)、db_status、stuck_sec、hint 等,便于 grep
|
||||
*/
|
||||
'game_live_stuck' => [
|
||||
'handlers' => [
|
||||
[
|
||||
'class' => Monolog\Handler\RotatingFileHandler::class,
|
||||
'constructor' => [
|
||||
runtime_path() . '/logs/game-live-stuck.log',
|
||||
7,
|
||||
Monolog\Logger::DEBUG,
|
||||
],
|
||||
'formatter' => [
|
||||
'class' => Monolog\Formatter\LineFormatter::class,
|
||||
'constructor' => [null, 'Y-m-d H:i:s', true],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -22,5 +22,6 @@ return [
|
||||
\app\common\middleware\AllowCrossDomain::class,
|
||||
\app\common\middleware\LoadLangPack::class,
|
||||
\app\common\middleware\AdminLog::class,
|
||||
\app\common\middleware\InterfaceRequestLog::class,
|
||||
],
|
||||
];
|
||||
@@ -91,7 +91,8 @@ return [
|
||||
'php', 'html', 'htm', 'env'
|
||||
],
|
||||
'options' => [
|
||||
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
|
||||
// Windows 下也需监测 PHP 变更并 reload,否则长期占用旧版 Admin 等类
|
||||
'enable_file_monitor' => !in_array('-d', $argv),
|
||||
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
|
||||
]
|
||||
]
|
||||
|
||||
@@ -73,7 +73,7 @@ Route::get('/api/v1/authToken', [\app\api\controller\V1::class, 'authToken']);
|
||||
|
||||
// api/user(GET 获取配置,POST 登录/注册)
|
||||
Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
|
||||
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
|
||||
Route::post('/api/user/logout', [\app\api\controller\Auth::class, 'userLogout']);
|
||||
|
||||
// api/install(安装流程多为 POST)
|
||||
Route::add(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], '/api/install/terminal', [\app\api\controller\Install::class, 'terminal']);
|
||||
@@ -127,21 +127,24 @@ Route::add(['GET', 'POST'], '/api/account/userProfile', [\app\api\controller\Acc
|
||||
|
||||
Route::add(['GET', 'POST'], '/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']);
|
||||
Route::add(['GET', 'POST'], '/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']);
|
||||
Route::add(['GET', 'POST'], '/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']);
|
||||
Route::add(['GET', 'POST'], '/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']);
|
||||
Route::add(['GET', 'POST'], '/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']);
|
||||
Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']);
|
||||
Route::add(['GET', 'POST'], '/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']);
|
||||
Route::add(['GET', 'POST'], '/api/game/currentStatus', [\app\api\controller\Game::class, 'currentStatus']);
|
||||
Route::post('/api/game/placeBet', [\app\api\controller\Game::class, 'placeBet']);
|
||||
Route::post('/api/game/autoSpin', [\app\api\controller\Game::class, 'autoSpin']);
|
||||
|
||||
Route::add(['GET', 'POST'], '/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']);
|
||||
Route::add(['GET', 'POST'], '/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']);
|
||||
|
||||
Route::add(['GET', 'POST'], '/api/finance/depositTierList', [\app\api\controller\Finance::class, 'depositTierList']);
|
||||
Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']);
|
||||
Route::get('/api/finance/depositMockPayPage', [\app\api\controller\Finance::class, 'depositMockPayPage']);
|
||||
Route::post('/api/finance/depositMockNotify', [\app\api\controller\Finance::class, 'depositMockNotify']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/mockDepositPage', [\app\api\controller\Finance::class, 'mockDepositPage']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/mockDepositStatus', [\app\api\controller\Finance::class, 'mockDepositStatus']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/mockDepositConfirm', [\app\api\controller\Finance::class, 'mockDepositConfirm']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/mockDepositPay', [\app\api\controller\Finance::class, 'mockDepositPay']);
|
||||
Route::post('/api/finance/ddpayDepositNotify', [\app\api\controller\Finance::class, 'ddpayDepositNotify']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/ddpayDepositRedirect', [\app\api\controller\Finance::class, 'ddpayDepositRedirect']);
|
||||
Route::post('/api/finance/ddpayPayoutNotify', [\app\api\controller\Finance::class, 'ddpayPayoutNotify']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/depositList', [\app\api\controller\Finance::class, 'depositList']);
|
||||
Route::add(['GET', 'POST'], '/api/finance/cashierConfig', [\app\api\controller\Finance::class, 'cashierConfig']);
|
||||
@@ -207,6 +210,7 @@ Route::post('/admin/auth/group/add', [\app\admin\controller\auth\Group::class, '
|
||||
Route::post('/admin/auth/group/edit', [\app\admin\controller\auth\Group::class, 'edit']);
|
||||
Route::post('/admin/auth/group/del', [\app\admin\controller\auth\Group::class, 'del']);
|
||||
Route::get('/admin/auth/group/select', [\app\admin\controller\auth\Group::class, 'select']);
|
||||
Route::get('/admin/auth/group/rules', [\app\admin\controller\auth\Group::class, 'rules']);
|
||||
|
||||
// admin/auth/rule
|
||||
Route::get('/admin/auth/rule/index', [\app\admin\controller\auth\Rule::class, 'index']);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
-- 已上线环境:移除后台「会员管理」目录及子菜单、相关安全配置项(与代码移除配套执行)
|
||||
-- 执行前请备份数据库
|
||||
|
||||
DELETE FROM admin_rule WHERE `name` = 'user' OR `name` LIKE 'user/%';
|
||||
|
||||
DELETE FROM security_sensitive_data WHERE `id` = 2;
|
||||
|
||||
DELETE FROM security_data_recycle WHERE `id` = 5;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 以下可选:物理删除会员业务表(执行后 /api/user、/api/account 等会员接口将不可用)
|
||||
-- 若仍使用前台会员中心,请勿执行 DROP,并保留 user、user_group、user_rule 等表
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SET FOREIGN_KEY_CHECKS = 0;
|
||||
-- DROP TABLE IF EXISTS `user_money_log`;
|
||||
-- DROP TABLE IF EXISTS `user_score_log`;
|
||||
-- DROP TABLE IF EXISTS `user`;
|
||||
-- DROP TABLE IF EXISTS `user_group`;
|
||||
-- SET FOREIGN_KEY_CHECKS = 1;
|
||||
-- 说明:`user_rule` 仍用于前台会员菜单与权限,若整体下线会员功能再单独处理
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* Markdown 文档:分红说明文档(归入 docs/mdManual 目录)
|
||||
*/
|
||||
final class DocsCommissionShareMenu extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$ruleTable = $this->hasTable('admin_rule') ? 'admin_rule' : ($this->hasTable('menu_rule') ? 'menu_rule' : null);
|
||||
if ($ruleTable === null) {
|
||||
return;
|
||||
}
|
||||
$ct = $this->table($ruleTable)->hasColumn('create_time') ? 'create_time' : 'createtime';
|
||||
$ut = $this->table($ruleTable)->hasColumn('update_time') ? 'update_time' : 'updatetime';
|
||||
$now = time();
|
||||
|
||||
$dirId = $this->resolveDocsDirId($ruleTable, $ct, $ut, $now);
|
||||
$menuName = 'docs/docCommissionShare';
|
||||
$menuId = $this->ensureMenu($ruleTable, $dirId, $menuName, $ct, $ut, $now);
|
||||
$this->ensureButtons($ruleTable, $menuId, $menuName, $ct, $ut, $now);
|
||||
}
|
||||
|
||||
private function resolveDocsDirId(string $table, string $ct, string $ut, int $now): int
|
||||
{
|
||||
$row = Db::name($table)->where('name', 'docs/mdManual')->where('type', 'menu_dir')->find();
|
||||
if ($row) {
|
||||
return (int) $row['id'];
|
||||
}
|
||||
return (int) Db::name($table)->insertGetId([
|
||||
'pid' => 0,
|
||||
'type' => 'menu_dir',
|
||||
'title' => 'Markdown文档',
|
||||
'name' => 'docs/mdManual',
|
||||
'path' => 'docs/mdManual',
|
||||
'icon' => 'fa fa-book',
|
||||
'menu_type' => 'tab',
|
||||
'component' => '',
|
||||
'url' => '',
|
||||
'extend' => 'none',
|
||||
'remark' => '',
|
||||
'weigh' => 53,
|
||||
'status' => 1,
|
||||
'keepalive' => 0,
|
||||
$ct => $now,
|
||||
$ut => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureMenu(string $table, int $pid, string $name, string $ct, string $ut, int $now): int
|
||||
{
|
||||
$row = Db::name($table)->where('name', $name)->where('type', 'menu')->find();
|
||||
if ($row) {
|
||||
Db::name($table)->where('id', $row['id'])->update([
|
||||
'pid' => $pid,
|
||||
'title' => '分红说明文档',
|
||||
'path' => $name,
|
||||
'component' => '/src/views/backend/docs/docCommissionShare/index.vue',
|
||||
'icon' => 'fa fa-percent',
|
||||
'menu_type' => 'tab',
|
||||
'weigh' => 51,
|
||||
'status' => 1,
|
||||
'keepalive' => 1,
|
||||
$ut => $now,
|
||||
]);
|
||||
return (int) $row['id'];
|
||||
}
|
||||
return (int) Db::name($table)->insertGetId([
|
||||
'pid' => $pid,
|
||||
'type' => 'menu',
|
||||
'title' => '分红说明文档',
|
||||
'name' => $name,
|
||||
'path' => $name,
|
||||
'icon' => 'fa fa-percent',
|
||||
'menu_type' => 'tab',
|
||||
'component' => '/src/views/backend/docs/docCommissionShare/index.vue',
|
||||
'url' => '',
|
||||
'extend' => 'none',
|
||||
'remark' => 'commission share guide markdown',
|
||||
'weigh' => 51,
|
||||
'status' => 1,
|
||||
'keepalive' => 1,
|
||||
$ct => $now,
|
||||
$ut => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureButtons(string $table, int $pid, string $menuName, string $ct, string $ut, int $now): void
|
||||
{
|
||||
$titleMap = [
|
||||
'content' => '拉取正文',
|
||||
'download' => '下载 Markdown',
|
||||
];
|
||||
foreach ($titleMap as $action => $title) {
|
||||
$btnName = $menuName . '/' . $action;
|
||||
$exists = Db::name($table)->where('name', $btnName)->where('type', 'button')->find();
|
||||
if ($exists) {
|
||||
Db::name($table)->where('id', $exists['id'])->update([
|
||||
'pid' => $pid,
|
||||
'title' => $title,
|
||||
'status' => 1,
|
||||
$ut => $now,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
Db::name($table)->insert([
|
||||
'pid' => $pid,
|
||||
'type' => 'button',
|
||||
'title' => $title,
|
||||
'name' => $btnName,
|
||||
'path' => '',
|
||||
'icon' => '',
|
||||
'menu_type' => 'tab',
|
||||
'component' => '',
|
||||
'url' => '',
|
||||
'extend' => 'none',
|
||||
'remark' => '',
|
||||
'weigh' => 0,
|
||||
'status' => 1,
|
||||
'keepalive' => 0,
|
||||
$ct => $now,
|
||||
$ut => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,14 @@
|
||||
| `streak_win_reward` | JSON:`rows[]` 每项含 `streak`(1~10)、`odds_factor`(与 33 相乘为整段赔率)、`is_jackpot`(是否大奖)。派彩公式:`total_amount × odds_factor × 33`。默认第 10 档 `is_jackpot=true`。 |
|
||||
| `deposit_tier` | 仍由「充值档位」独立菜单维护,**不出现在**「常规配置」列表。 |
|
||||
|
||||
开奖结算后更新 **`user.current_streak`**:本期有中奖注单则 `min(streak_at_bet+1, 10)`,否则 **连胜归 0**(无档位配置)。若命中大奖档,向玩家 **私有频道** `private-user-{uuid}` 与 **公共频道** `public-game-period` 推送事件 **`jackpot.hit`**(负载含 `period_no`、`user_id`、`total_win_amount`、`result_number` 等)。
|
||||
开奖结算后更新 **`user.current_streak`**:本期有中奖注单则 `min(streak_at_bet+1, 10)`,否则 **连胜归 0**(无档位配置)。**中奖 WebSocket(同一结算批次)**(实现:`GameBetSettleService::publishSettlementWinsAfterCommit`):
|
||||
- 向相关用户推送 **`bet.win`**(小奖/大奖统一;**个人中奖弹窗只认此主题**)。载荷含 `total_win`、`balance_after`、`bets[]`、`is_win=true`,并合并 `current_streak` / `streak_level` / `odds_factor` / **`is_jackpot`**(是否**大奖档**,由 `streak_at_bet` 对应 `streak_win_reward.is_jackpot` 决定,非 `bet.accepted` 的下注时展示含义)。
|
||||
- 若存在大奖档命中,**再**向公共频道推送 **`jackpot.hit`**(`hits[]` 每项含 `user_id`、**`nickname`**、`period_no`、`total_win`、`result_number`),供全站跑马灯;**个人弹窗仍以 `bet.win` 为准**。
|
||||
- **去重**:`user.streak` / `wallet.changed` / `jackpot.hit` 使用 `dfw:v1:settle:notify:{period_id}`;**`bet.win` 独立** `dfw:v1:ws:betwin:{period_id}:{user_id}`,避免整期 dedup 吞掉中奖推送。
|
||||
- **补偿**:库内已结算中奖但内存未聚合时,从 `bet_order` 重建并补发;运维可用 `php scripts/republish_bet_win.php`(见移动端草案 §7.1.4)。
|
||||
- **大奖审核**:`win_amount >= jackpot_max_amount` 时注单可先 `status=待审核`,仍推 `bet.win`;后台审核通过后再推一次 `bet.win`(`approveJackpot`)。
|
||||
|
||||
> **派彩期间 `period.tick` 静默规则**:开奖到派彩宽限期结束(`status=payouting`)期间**不再推送 `period.tick`**,避免覆盖中奖动画;本期进入 `finished`/`void` 时各推一帧边界帧(每期号每状态去重,Redis Key `dfw:v1:ws:tick:boundary:{period_no}:{status}`,TTL 300s),下一期 `betting` 时恢复每秒推送。详见《36字花-移动端接口设计草案》§7.1.3。
|
||||
|
||||
---
|
||||
|
||||
@@ -131,7 +138,7 @@
|
||||
|
||||
### 3.3 注意
|
||||
|
||||
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。除超管外,渠道管理仅可查看当前管理员归属渠道。
|
||||
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表范围:超管不限;绑定 `channel_id` → 仅对应渠道;均未绑定且拥有渠道模块基础权限(`channel/index`、`edit` 等)→ 全平台可读可写。详见 `docs/渠道管理后台说明.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -162,14 +169,20 @@
|
||||
| 渠道归属 | `channel_id` 表示该子代理属于哪个顶级渠道 |
|
||||
| 邀请管理 | `invite_code` 自动生成且全局唯一,用于发展玩家并做归属绑定 |
|
||||
| 角色标识 | `agent_role`(如 `agent_admin` / `sub_agent` / `staff`) |
|
||||
| 分红设置 | 不再使用 `admin` / `admin_group` 分红字段;统一改为渠道维度结算后按 `channel_admin_share` 二次分配 |
|
||||
| 分红设置 | 在 **管理员管理** 维护:`parent_admin_id`(非顶级角色组必填)、`commission_share_rate`(顶级角色组=从渠道总佣金分得 %;子代理=从上级实得抽取 %) |
|
||||
| 开奖权限 | 不在数据层开放给渠道/子代理;开奖权限仅由超管 RBAC 控制 |
|
||||
|
||||
### 5.1 分红计算口径(现行)
|
||||
### 5.1 分红计算口径(现行,2026-05-29)
|
||||
|
||||
- 渠道分红:先按 `channel.agent_mode` 计算渠道总佣金
|
||||
- 管理员分红:再按 `channel_admin_share.share_rate` 对渠道总佣金进行二次分配
|
||||
- 角色组仅用于权限与数据范围,不再参与金额拆分
|
||||
- **渠道分红**:先按 `channel.agent_mode` 与已结算注单(`bet_order.status=2`)计算 **渠道总佣金**(非充值口径)
|
||||
- **管理员分红**:渠道总佣金先按顶级代理 `commission_share_rate` 划入,再按 `admin.parent_admin_id` 树递归拆分:
|
||||
- 顶级实得 = 渠道本期总佣金 × 顶级 `commission_share_rate`(%)
|
||||
- 子代理实得 = 上级本期实得 × `commission_share_rate`(%)
|
||||
- 上级保留 = 上级实得 − 所有直属子代理实得之和
|
||||
- 同一上级下子代理比例合计 **≤ 100%**
|
||||
- **配置入口**:`/admin/auth/admin`(树形列表;非超管仅见本人及下级)
|
||||
- **角色组**仅用于权限与数据范围,不参与金额拆分
|
||||
- **历史**:`channel_admin_share` 已退出结算主流程,渠道页不再维护分配比例
|
||||
|
||||
---
|
||||
|
||||
@@ -278,6 +291,21 @@
|
||||
| 后台快照 `maintenance_ui` | `!game_live_runtime_enabled && !hasActiveRecord()` 时为 `true`:表示**当局已完全结束**(含派彩),此时后台页展示「维护中」并锁定操作区(仅顶部「游戏运行」开关可用);当局尚在 0–3 状态时为 `false`,倒计时与操作区保持可用直至收尾完成 |
|
||||
| 作废本局(后台按钮) | 仅当本期为 **下注开放 / 已封盘**(`game_record.status` 为 `0` 或 `1`):将所有 **待开奖** 注单(`bet_order.status = 1`)按 `total_amount` 退回余额并置 `bet_order.status = 3`,写钱包流水 `biz_type = bet_void`;本期 **`game_record.status = 5`**,写入 **`void_reason`**;并 **强制将 `game_live_runtime_enabled` 置为 `0`**。需管理员在后台再次打开「游戏运行」开关后,才恢复下注与后续期次创建 |
|
||||
| 后台接口 | `POST /admin/game.Live/runtime`(body:`enabled` 0/1);`POST /admin/game.Live/voidPeriod`(body:`record_id` 可选、`void_reason` 必填) |
|
||||
| 实时快照 HTTP | `GET /admin/game.Live/snapshot`:**只读** `buildSnapshot`,不执行 `recoverLiveRoundState`,避免接口超时;对局自愈/自动开奖由 **`gameLiveTicker`**(每秒 `publishSnapshot`)与 `drawResult` 链路负责 |
|
||||
| WebSocket | `GET /admin/game.Live/wsConfig` 返回 `ws_url` 与推荐 `subscribe_topics`(含 `admin.live.snapshot`、`bet.win` 等);`admin.live.snapshot` 由 WS 进程每秒直推(有订阅者时) |
|
||||
| WS 地址 | `WebSocketConfigHelper`:`.env` 中 `H5_WEBSOCKET_URL` 为 `127.0.0.1` 且请求 Host 非本机时自动改用 `ws(s)://{host}/ws/`;生产需 Nginx 将 `/ws/` 反代至 **3131** |
|
||||
|
||||
#### 8.2.2 WebSocket 主题与 `bet.win`(联调清单)
|
||||
|
||||
| 主题 | 触发 | 移动端 |
|
||||
|------|------|--------|
|
||||
| `bet.accepted` | 下注成功 | 扣款确认;`is_jackpot` 为下注时档位 |
|
||||
| `bet.win` | 开奖结算后中奖 | **中奖弹窗**;`is_jackpot` 为大奖档 |
|
||||
| `jackpot.hit` | 本期有大奖档命中 | 全站公告(可选) |
|
||||
| `wallet.changed` | 余额变动 | 含 `biz_type=payout` 派彩 |
|
||||
| `user.streak` | 每期结算后 | 连胜更新 |
|
||||
|
||||
详见《36字花-移动端接口设计草案》§7.1.3、§7.1.4。
|
||||
|
||||
### 8.3 代理与结算(逻辑就绪后)
|
||||
|
||||
@@ -286,12 +314,13 @@
|
||||
3. **大盘盈利**:流水占比分桶 + 级差,各级金额之和与线总包一致(可对照文档数值验算)。
|
||||
4. **联营**:客损为负产生负结转;下期盈利先抵扣再分佣。
|
||||
|
||||
### 8.3.1 渠道结算新流程(2026-04-23)
|
||||
### 8.3.1 渠道结算新流程(2026-04-23,2026-05-29 树形拆分)
|
||||
|
||||
1. **仅超管可结算**:按渠道结算周期(支持自动任务与手动提前结算)执行渠道结算。
|
||||
2. **结算即发放**:结算时按 `channel_admin_share` 比例,直接发放到管理员钱包并写入 `admin_wallet_record`(`commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`。
|
||||
3. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。
|
||||
4. **停用渠道限制**:`channel.status != 1` 时,该渠道不再允许玩家注册与登录。
|
||||
1. **结算权限**:超管,或拥有按钮权限 `channel/manualSettle` 且目标渠道在可读范围内,可手动提前结算;**一键批量结算**仍仅超管。自动任务由 `ChannelAutoSettleTicker` 执行。
|
||||
2. **结算即发放**:结算时按 **代理树**(`admin.parent_admin_id` + `admin.commission_share_rate`)拆分各管理员实得,直接发放到管理员钱包并写入 `admin_wallet_record`(`commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`。
|
||||
3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。渠道页支持查看直属/分红口径下注记录、已分红记录及筛选,见 `docs/渠道管理后台说明.md`。
|
||||
4. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。
|
||||
5. **停用渠道限制**:`channel.status != 1` 时,该渠道不再允许玩家注册与登录。
|
||||
|
||||
### 8.3.2 后台管理员资金与提现流程(2026-04-23)
|
||||
|
||||
@@ -376,6 +405,9 @@
|
||||
| V1.14 | 2026-04-20 | 服务端 Redis 热点缓存:`GameHotDataRedis`(`user` / `game_config` / `game_record`),`config/game_hot_cache.php` 与 `.env-example` 中 `GAME_HOT_CACHE_*`;更新 §1.1、§2.1、§8.4、第九章风险与依赖、附录 `user`;与 `CACHE_DRIVER`(系统 `config` 表文件缓存)区分说明 |
|
||||
| V1.15 | 2026-04-20 | 热点写路径收口:`GameHotDataCoordinator` + `GameHotDataLock` + `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`;文档与实现对齐(替代仅 `*Forget` 描述);移动端 `betPlace` 与后台钱包共用用户互斥锁及 `coin` 乐观更新 |
|
||||
| V1.16 | 2026-04-23 | 渠道结算改为单阶段口径(仅超管结算,结算即按比例发放管理员钱包并记录操作人)与管理员钱包提现流程(`admin_wallet` / `admin_wallet_record` / `admin_withdraw_order`,渠道顶级组审核) |
|
||||
| V1.17 | 2026-05-29 | 代理分红改为树形拆分:`admin.commission_share_rate` + `parent_admin_id`;配置迁移至管理员管理;渠道页移除 `channel_admin_share` 入口;管理员列表树形展示与下级可见范围 |
|
||||
| V1.18 | 2026-05-29 | 顶级角色组(`admin_group.pid=0`)可配置渠道分红比例;表单禁用上级代理并增加说明 |
|
||||
| V1.19 | 2026-05-30 | 渠道管理:全平台只读范围、`viewAllChannels`、下注记录弹窗与筛选、移动端适配;`manualSettle` 按权限;`docs/渠道管理后台说明.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -418,10 +450,11 @@
|
||||
|---------|------|
|
||||
| `admin.parent_admin_id` | 子代理上下级 |
|
||||
| `admin.channel_id` | 所属渠道 |
|
||||
| `admin.commission_share_rate` | 顶级角色组:从渠道本期总佣金分得比例(%);子代理:从上级实得抽取比例(%) |
|
||||
| `admin.invite_code` | 子代理邀请码,注册归属 |
|
||||
| `admin.agent_role` | 角色类型(均无开奖权) |
|
||||
| `admin_group.channel_id` | 角色组归属渠道;`NULL` 可为系统级(仅超管) |
|
||||
| `channel_admin_share.share_rate` | 渠道内管理员分配比例(百分比,启用项合计=100) |
|
||||
| `channel_admin_share` | **历史表**,2026-05-29 起结算不再使用;配置改在 `admin` 树 |
|
||||
|
||||
### 11.4 `game_config`(动态参数)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
**补充(2026-04)**:§**1.5** 描述服务端 **Redis 热点缓存**(`GameHotDataRedis`),**不改变**各接口 URL、参数与响应字段约定,仅供联调与运维对照。
|
||||
|
||||
**补充(2026-05)**:§**1.3** / §**1.4** / §**2.3** 描述 **单设备登录** 与 **`POST /api/user/logout`** 退出接口(依赖 Redis 存储 `device_id` 绑定)。
|
||||
|
||||
## 1. 设计约定
|
||||
|
||||
### 1.1 基础约定
|
||||
@@ -13,7 +15,8 @@
|
||||
- **请求方法**:所有移动端业务接口(`/api/*`,不含 `/api/v1/authToken`)一律使用 `POST` 调用;查询类接口同时兼容 `GET`(便于浏览器/调试工具直接访问),客户端统一走 `POST`
|
||||
- `POST` 时请求头 `Content-Type: application/json`,参数放在 JSON body
|
||||
- `GET` 兼容模式下,参数走 URL query string
|
||||
- **例外**:公告模块 `/api/notice/noticeList`、`/api/notice/noticeDetail`、`/api/notice/noticeConfirm` 与模拟收银台页 `/api/finance/depositMockPayPage` **仅支持 `GET`**,参数一律走 URL query string
|
||||
- **例外**:公告模块 `/api/notice/noticeList`、`/api/notice/noticeConfirm` **仅支持 `GET`**,参数一律走 URL query string
|
||||
- **公告列表免鉴权**:`GET /api/notice/noticeList` **无需** `auth-token`、`user-token`;若仍携带 `user-token` 则强弹窗项可返回该用户真实的 `is_read`
|
||||
- 鉴权类接口 `/api/v1/authToken` 仍为 `GET`
|
||||
- 时间:UTC 时间戳(秒) + 服务端时区配置
|
||||
- 金额:数字传输(如 `"100.00"`),客户端展示统一保留两位小数(存储仍为 `decimal(18,2)`)
|
||||
@@ -62,6 +65,11 @@
|
||||
### 1.3 鉴权方式
|
||||
- **接口鉴权(auth-token)**:所有移动端业务接口请求时必须携带请求头 `auth-token`(由 `/api/v1/authToken` 签发)
|
||||
- **用户登录鉴权(user-token)**:需要登录的接口携带请求头 `user-token`;token 失效后调用刷新或重新登录
|
||||
- **单设备登录(实现约束)**:
|
||||
- 获取 `auth-token` 时 Query 中的 `device_id` 会与该 `auth-token` 绑定(Redis)
|
||||
- 同一账号**仅允许一个活跃设备**:新设备登录成功后,会清除该用户其它 `user-token` / `refresh_token`,并绑定新 `device_id`
|
||||
- 旧设备继续携带原 `user-token` 访问需登录接口时,返回 `code=1101`,`message` 为「您的账号已在其他设备登录,请重新登录」(`lang=en` 时为英文)
|
||||
- 客户端应为每个安装实例生成**稳定且唯一**的 `device_id`(勿每次随机,否则会被视为新设备)
|
||||
|
||||
### 1.4 获取接口鉴权 Token(auth-token)
|
||||
- **GET** `/api/v1/authToken`
|
||||
@@ -86,6 +94,10 @@
|
||||
- `expires_in`:int(含义:有效期秒数)
|
||||
- `server_time`:int(含义:服务器时间戳,用于校时)
|
||||
|
||||
服务端行为(与单设备登录相关):
|
||||
- 签发成功后将 `device_id` 与 `auth_token` 写入 Redis,TTL 与 `expires_in` 一致
|
||||
- 后续登录、已登录业务接口、WebSocket 握手均以此 `auth-token` 所绑定的 `device_id` 与用户活跃设备比对
|
||||
|
||||
可能错误码:
|
||||
- `1001` 参数缺失
|
||||
- `1002` 参数格式错误
|
||||
@@ -127,13 +139,13 @@
|
||||
|
||||
### 2.1 注册
|
||||
- **POST** `/api/user/register`
|
||||
- 用途:仅手机号注册并绑定邀请归属(admin/channel)
|
||||
- 用途:仅手机号注册并绑定邀请归属(admin/channel);注册成功后会自动登录
|
||||
- 请求头:必带 `auth-token`(设备标识以该 token 绑定的 `device_id` 为准,见 §1.3)
|
||||
|
||||
请求参数:
|
||||
- `username`:string,手机号(含义:注册账号,仅支持大陆手机号)
|
||||
- `password`:string,明文经 HTTPS 传输(含义:登录密码,服务端需加盐哈希存储)
|
||||
- `invite_code`:string,必填(含义:子代理邀请码,用于绑定渠道 `channel_id` 与归属)
|
||||
- `device_id`:string,可选(含义:设备标识,用于风控与登录记录)
|
||||
|
||||
返回参数:
|
||||
- `user-token`:string(含义:后续接口登录态令牌;用于需要登录的接口请求头)
|
||||
@@ -148,11 +160,14 @@
|
||||
|
||||
### 2.2 登录
|
||||
- **POST** `/api/user/login`
|
||||
- 请求头:必带 `auth-token`(其绑定的 `device_id` 即本次登录设备,**无需**在 body 重复传 `device_id`)
|
||||
|
||||
请求参数:
|
||||
- `username`:string(含义:登录账号,当前支持手机号)
|
||||
- `password`:string(含义:登录密码)
|
||||
- `device_id`:string,可选(含义:设备标识,辅助风控)
|
||||
|
||||
服务端行为:
|
||||
- 登录成功后会清除该用户其它会话 token,并将活跃设备设为当前 `auth-token` 对应 `device_id`(见 §1.3)
|
||||
|
||||
返回参数:
|
||||
- `user-token`:string(含义:访问令牌;用于需要登录的接口请求头)
|
||||
@@ -165,10 +180,32 @@
|
||||
- `channel_id`:int(含义:归属渠道 ID)
|
||||
- `risk_flags`:int(含义:风控状态位)
|
||||
|
||||
### 2.3 获取当前用户信息
|
||||
### 2.3 退出登录
|
||||
- **POST** `/api/user/logout`
|
||||
- 用途:主动退出当前账号,作废登录态并释放单设备占用
|
||||
|
||||
请求头:
|
||||
- `auth-token`:必填
|
||||
- `user-token`:已登录时必填
|
||||
|
||||
请求参数(可选):
|
||||
- `refresh_token`:string(含义:刷新令牌;建议传入以便服务端一并删除;未传且已登录时服务端尝试删除当前会话的 refresh_token)
|
||||
|
||||
返回参数:
|
||||
- `data`:空对象 `{}`
|
||||
|
||||
服务端行为:
|
||||
- 删除当前 `user-token`、清除 Redis 中该用户的活跃 `device_id` 绑定
|
||||
- 若提供 `refresh_token` 则删除对应 refresh 记录
|
||||
- **幂等**:未登录或 token 已失效时仍返回 `code=1`(便于客户端清理本地缓存)
|
||||
|
||||
可能错误码:
|
||||
- `1101`:`auth-token` 缺失或无效(与 §1.4 一致)
|
||||
|
||||
### 2.4 获取当前用户信息
|
||||
- **POST** `/api/user/profile`
|
||||
|
||||
返回参数(金额类字段统一 2 位小数字符串,与 `/api/wallet/balanceSummary` 对齐):
|
||||
返回参数(金额类字段统一 2 位小数字符串,与钱包展示口径一致):
|
||||
|
||||
**基础档案**
|
||||
- `uuid`:string(含义:用户对外唯一标识,10 位)
|
||||
@@ -184,13 +221,13 @@
|
||||
- `create_time`:int(含义:注册时间戳)
|
||||
|
||||
**资金与提现配额**
|
||||
- `coin` / `coin_balance`:string(含义:当前余额;两字段同值,便于与 `/api/wallet/balanceSummary` 平滑切换)
|
||||
- `coin` / `coin_balance`:string(含义:当前余额;两字段同值)
|
||||
- `frozen_balance`:string(含义:冻结余额;无冻结场景,固定 `0.00`)
|
||||
- `total_deposit_coin`:string(含义:累计充值)
|
||||
- `total_withdraw_coin`:string(含义:累计提现;受理后累加)
|
||||
- `bet_flow_coin`:string(含义:打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
|
||||
- `max_withdrawable`:string(含义:**当前允许发起的单笔最大提现金额** = `min(coin_balance, max_withdraw_by_flow)`)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,结构与 `/api/wallet/balanceSummary.withdraw_flow` 一致,此处额外附 `pending_withdraw`)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,此处额外附 `pending_withdraw`)
|
||||
- `ratio`:string(打码量倍数;`0` 表示不限打码)
|
||||
- `net_deposit`:string(净充值 = max(0, 累计充值 − 累计提现))
|
||||
- `required_bet_flow`:string(按门槛口径所需打码量,纯展示)
|
||||
@@ -202,8 +239,9 @@
|
||||
- `count`:int(当前待审核提现订单数)
|
||||
- `max`:int(单用户最多允许的待审核提现数,当前为 `3`;超过 `withdrawCreate` 返回 `code=2004`)
|
||||
|
||||
### 2.4 刷新令牌(可选)
|
||||
### 2.5 刷新令牌(可选)
|
||||
- **POST** `/api/user/refreshToken`
|
||||
- 请求头:必带 `auth-token`;已登录会话须与当前活跃 `device_id` 一致(§1.3)
|
||||
|
||||
请求参数:
|
||||
- `refresh_token`:string(含义:续签访问令牌的凭证)
|
||||
@@ -231,14 +269,21 @@
|
||||
- `open_at`:int(含义:预计开奖时间戳)
|
||||
- `bet_config`:object
|
||||
- `pick_max_number_count`:int(含义:单注最多可选号码数,来自 `game_config.config_key = pick_max_number_count`,缺省与库内种子一致,通常为 10,合法范围 1–36)
|
||||
- `chips`:array[string](如 `["1.00","5.00"]`,含义:快捷筹码面额)
|
||||
- `single_number_max_bet`:string(含义:单号码最大下注额)
|
||||
- `chips`:object(含义:快捷筹码字典,固定 6 个键 `"1"`…`"6"`,值为该档单注面额字符串,两位小数;与后台 `game_config.bet_chips` 语义一致)
|
||||
- `default_bet_chip_id`:int(含义:默认选中的筹码标识,来自 `game_config.default_bet_chip_id`,非法或指向无效档位时服务端回退为首个有效档)
|
||||
- `min_bet_per_number`:string(含义:单号码最小下注额,须 ≤ 所选筹码面额且受后台配置约束)
|
||||
- `max_bet_per_number`:string(含义:单号码最大下注额)
|
||||
- `dictionary`:array<object>
|
||||
- `number`:int(1-36,含义:字花编号)
|
||||
- `name`:string(含义:字花名称)
|
||||
- `category`:string(含义:字花分类)
|
||||
- `icon`:string(含义:图标资源地址)
|
||||
- `user_snapshot`:object(`coin`、`current_streak`,含义:用户状态快照)
|
||||
- `user_snapshot`:object(含义:用户状态快照 + **当前玩家本局适用赔率**,不下发 1~10 全表)
|
||||
- `coin`:string(余额)
|
||||
- `current_streak`:int(当前连胜场数)
|
||||
- `streak_level`:int(若本局中奖将使用的连胜档位 1~10,由 `min(current_streak+1, 10)` 推导)
|
||||
- `odds_factor`:int(赔率乘数;中奖派彩 = 本笔 `total_amount` × `odds_factor`)
|
||||
- `is_jackpot`:bool(是否大奖档)
|
||||
|
||||
### 3.2 获取36字花字典(可缓存)
|
||||
- **POST** `/api/game/dictionaryList`
|
||||
@@ -247,24 +292,10 @@
|
||||
- `version`:string(含义:字典版本号,前端可用于缓存比对)
|
||||
- `items`:同 `dictionary`(含义:36字花字典明细)
|
||||
|
||||
### 3.3 获取最近开奖记录(近30期)
|
||||
- **POST** `/api/game/periodHistory`
|
||||
|
||||
请求参数:
|
||||
- `limit`:int(可选,默认 30,含义:返回最近几期)
|
||||
|
||||
返回参数:
|
||||
- `list`:array<object>
|
||||
- `period_no`:string(含义:历史期号)
|
||||
- `result_number`:int(含义:该期开奖结果)
|
||||
- `open_time`:int(含义:开奖时间)
|
||||
|
||||
---
|
||||
|
||||
## 4. 下注与对局模块(game/bet)
|
||||
|
||||
### 4.1 获取当前期详情
|
||||
- **POST** `/api/game/currentStatus`(兼容旧路径 `/api/game/periodCurrent`)
|
||||
- **POST** `/api/game/periodCurrent`
|
||||
|
||||
返回参数:
|
||||
- `runtime_enabled`:bool(含义:同 `lobbyInit.runtime_enabled`)
|
||||
@@ -277,20 +308,20 @@
|
||||
|
||||
### 4.2 提交下注
|
||||
- **POST** `/api/game/placeBet`(兼容旧路径 `/api/game/betPlace`)
|
||||
- 用途:单期手动下注;玩家传入**压注号码**与**单注金额 `single_bet_amount`**。服务端按 `single_bet_amount × numbers数量` 计算本笔总扣款(落库 `total_amount`),开奖只出一个号码,若该号码 ∈ 所选号码集合即视为中奖。
|
||||
- 用途:单期手动下注;玩家传入**压注号码**与**筹码标识 `bet_id`(1–6)**。单注金额由后台 `game_config.bet_chips` 解析,服务端按 `单注金额 × numbers数量` 计算本笔总扣款(落库 `total_amount`),开奖只出一个号码,若该号码 ∈ 所选号码集合即视为中奖。
|
||||
|
||||
请求参数:
|
||||
- `period_no`:string(含义:下注目标期号)
|
||||
- `numbers`:string(含义:本次压注号码集合,**英文逗号分隔**,如 `1,8,16`;每个号码为 1–36 的整数,数量不超过 `pick_max_number_count`(同 `lobbyInit.bet_config`),重复号码会去重)
|
||||
- `single_bet_amount`:string(含义:**单注金额**,> 0)
|
||||
- `bet_amount`:string(兼容字段,含义同 `single_bet_amount`)
|
||||
- `bet_id`:int(含义:**快捷筹码标识**,取值 1–6,须为 `lobbyInit.bet_config.chips` 中存在的键;不再使用 `single_bet_amount` / `bet_amount` 传参)
|
||||
- `idempotency_key`:string(必填,含义:防止重复下单)
|
||||
|
||||
返回参数:
|
||||
- `order_no`:string(含义:下注订单号)
|
||||
- `period_no`:string(含义:实际落单期号)
|
||||
- `status`:string(`accepted`/`rejected`,含义:受理结果)
|
||||
- `single_bet_amount`:string(含义:本次单注金额)
|
||||
- `bet_id`:int(含义:本次使用的筹码标识)
|
||||
- `single_bet_amount`:string(含义:本次单注金额,由 `bet_id` 对应档位解析得到)
|
||||
- `numbers_count`:int(含义:本次号码数量)
|
||||
- `locked_balance`:string(可选,含义:冻结金额)
|
||||
- `balance_after`:string(含义:下单后余额)
|
||||
@@ -308,12 +339,14 @@
|
||||
- `action`:string(`start`/`stop`)
|
||||
- `period_no`:string(`action=start` 时必填)
|
||||
- `numbers`:string(`action=start` 时必填,英文逗号分隔)
|
||||
- `single_bet_amount`:string(`action=start` 时必填,支持兼容字段 `bet_amount`)
|
||||
- `bet_id`:int(`action=start` 时必填,含义同 `placeBet`,快捷筹码 1–6)
|
||||
- `rounds`:int(`action=start` 时必填,>=1)
|
||||
|
||||
返回参数:
|
||||
- `status`:string(`scheduled`/`stopped`)
|
||||
- `auto_mode`:bool
|
||||
- `bet_id`:int(仅 `start` 返回,本次托管使用的筹码标识)
|
||||
- `single_bet_amount`:string(仅 `start` 返回,由 `bet_id` 解析得到的单注面额)
|
||||
- `remaining_rounds`:int(仅 `start` 返回)
|
||||
|
||||
### 4.4 查询我的下注记录(最近1个月)
|
||||
@@ -340,29 +373,12 @@
|
||||
|
||||
## 5. 钱包与资金模块(wallet/finance)
|
||||
|
||||
### 5.1 获取钱包摘要
|
||||
- **POST** `/api/wallet/balanceSummary`
|
||||
|
||||
返回参数:
|
||||
- `coin_balance`:string(含义:可用余额,等同于 `user.coin`)
|
||||
- `frozen_balance`:string(含义:冻结余额;当前系统无冻结场景,固定返回 `0.00`)
|
||||
- `withdrawable_balance`:string(含义:可提现余额;等同于 `coin_balance`,**单笔实际上限以 `max_withdrawable` 为准**)
|
||||
- `max_withdrawable`:string(含义:**当前允许发起的单笔最大提现金额** = min(`coin_balance`, 打码配额余量);客户端直接用于"最大可提 XXX"提示与金额输入上限校验)
|
||||
- `total_deposit_coin`:string(含义:累计充值币额)
|
||||
- `total_withdraw_coin`:string(含义:累计提现币额;提现受理时累加)
|
||||
- `bet_flow_coin`:string(含义:打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
|
||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,供前端展示说明与上限推导)
|
||||
- `ratio`:string(含义:打码量倍数,来自 `game_config.withdraw_bet_flow_ratio`,默认 `1.00`;`0` 表示"不限打码")
|
||||
- `net_deposit`:string(含义:净充值 = max(0, 累计充值 - 累计提现))
|
||||
- `required_bet_flow`:string(含义:按门槛口径所需的打码量 = 净充值 × 倍数,纯展示)
|
||||
- `remaining_bet_flow`:string(含义:按门槛口径还差多少打码量,纯展示;已达标为 `0.00`)
|
||||
- `eligible`:bool(含义:是否满足整体门槛,纯展示,实际放行以 `max_withdrawable` 为准)
|
||||
- `max_withdraw_by_flow`:string/null(含义:仅按打码量折算的单笔可提上限 = max(0, `bet_flow_coin` / `ratio` - `total_withdraw_coin`);`ratio=0` 不限打码时返回 `null`)
|
||||
- `flow_unlimited`:bool(含义:是否处于"不限打码"状态,`ratio=0` 时为 `true`)
|
||||
|
||||
说明(打码量即提现配额模型):
|
||||
- 每笔提现按 `withdraw_coin × ratio` 消耗打码配额;`total_withdraw_coin` 已累积历史消耗。
|
||||
- **单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`**;`ratio=0` 时退化为仅受余额约束。
|
||||
### 5.1 余额同步口径(已移除独立摘要接口)
|
||||
- 已移除 `/api/wallet/balanceSummary`。
|
||||
- 余额同步来源调整为:
|
||||
- 下注返回 `placeBet.balance_after`
|
||||
- WebSocket 推送 `wallet.changed`
|
||||
- 充值/提现详情接口(如 `depositDetail` / `withdrawDetail`)作为业务单据维度核对
|
||||
- 倍数 `ratio` 由后台「游戏配置 → `withdraw_bet_flow_ratio`」维护,修改后对新请求立即生效。
|
||||
- 历史累计类字段(`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`)均为累加语义;若后续审核驳回,回冲逻辑由后台审核流程负责。
|
||||
|
||||
@@ -450,6 +466,8 @@
|
||||
- `status`:int(启用状态,1=启用)
|
||||
- `tier_ids`:array(兼容字段;当前固定空数组,表示自动兼容全部充值档位)
|
||||
- `withdraw`:object
|
||||
- `pay_channels`:array(提现可选支付渠道,用于 `withdrawCreate` 的 `channel_code`;仅返回后台已启用且服务端已对接出金的渠道)
|
||||
- 每项:`code`(string,渠道代码,小写)、`name`(展示名)、`sort`(排序)
|
||||
- `banks`:array(提现银行)
|
||||
- `min_ewallet`:string(电子钱包最低提现)
|
||||
- `min_bank`:string(银行卡最低提现)
|
||||
@@ -457,28 +475,75 @@
|
||||
- `processing_note`:string(到账提示文案)
|
||||
- `fee_note`:string(手续费提示文案)
|
||||
- `rate_mode`:string(`fixed` / `live`)
|
||||
- `fields`:object(提现表单必填项开关)
|
||||
- `fields`:object(**与 DDPay / `withdrawCreate` 一致**,不由后台「支付/收款配置」开关维护;用于客户端展示必填项)
|
||||
- `receive_type_bank_only`:bool(当前固定 `true`,仅支持银行卡出金)
|
||||
- `require_channel_code`:bool(固定 `true`,对应 `channel_code`)
|
||||
- `require_receiver_name`:bool(固定 `true`,对应 `receiver_name`)
|
||||
- `require_receive_account`:bool(固定 `true`,对应 `receive_account`)
|
||||
- `require_receiver_email`:bool(固定 `true`,对应 `receiver_email`)
|
||||
- `require_receiver_mobile`:bool(固定 `true`,对应 `receiver_mobile`)
|
||||
- `require_bank_code`:bool(固定 `true`,对应 `bank_code`)
|
||||
- `require_bank_branch`:bool(固定 `false`,`bank_branch` 选填,不传时服务端按 `N/A` 提交 DDPay)
|
||||
|
||||
### 5.4 创建充值订单
|
||||
- **POST** `/api/finance/depositCreate`
|
||||
- `Content-Type: application/json`(推荐)、`application/x-www-form-urlencoded` 或 **`multipart/form-data`**(如 Apifox 的 form-data);字段名与下表一致即可,服务端通过统一参数名读取,**不限制**为某一种 body 类型。
|
||||
|
||||
说明(与真实「创建订单 → 调起三方 → 异步回调」一致):
|
||||
- **创建单**:`depositCreate` 仅写入 **待支付** 订单(`status=pending`),**不在此请求内入账**。返回体中 `paid=false`,`pay_url` 为 **模拟第三方收银台** 完整 URL(HMAC 防篡改,见下 §5.4.1)。
|
||||
- **客户端**:在 WebView/系统浏览器中打开 `pay_url`;用户完成支付后(模拟页为「确认支付」按钮),由服务端 `depositMockNotify` 验签后调用 `DepositSettlement::settle` 入账,并推送 `wallet.changed`;客户端可轮询 `depositDetail` 或依赖推送更新余额。
|
||||
- **未来接入真实第三方支付**:将 `pay_url` 与回调 URL 替换为真网关,入账仍**仅**在回调/验签成功路径中调用 `DepositSettlement::settle`(与当前模拟回调一致)。
|
||||
- 档位与渠道取自 `depositTierList`:创建订单时须选择返回 `channels` 中某一渠道的 `code` 作为 `channel_code` 传入;服务端会校验档位存在、启用且渠道已启用。
|
||||
- **HMAC 密钥**:模拟链路与签名校验使用环境变量 **`DEPOSIT_MOCK_HMAC_KEY`**(或 `config('app.deposit_mock_hmac_key')`);生产环境务必配置,与代码中默认值区分。
|
||||
- **并发上限**:同一用户最多同时存在 **3 笔待支付充值单**(`status=0`);超过后创建接口返回 `code=2005`。
|
||||
- **超时失效**:充值单创建后 **60 秒内未支付**将自动置为失败(`status=failed`),并在订单备注记录失败原因(`[timeout] unpaid over 60s`)。
|
||||
- **定时任务兜底**:服务端进程 `depositOrderExpireTicker` 每 **10 秒**主动扫描超时待支付单,保证即使用户不访问任何充值接口也会准时失效。
|
||||
说明:
|
||||
- **模拟支付(推荐联调)**:`channel_code=mock`,无需 DDPay 商户配置;创建后返回 `pay_url`(**模拟收银台页面** `GET /api/finance/mockDepositPage`,浏览器打开;**链接有效期 3 分钟**,过期未支付则订单自动失败)。用户在收银台点击「确认支付」后,订单进入 **`pending_review`(待后台审核)**,不会立即入账;管理员在后台「充值订单」审核通过后才入账。
|
||||
- 可选 **`GET/POST /api/finance/mockDepositPay`**(需登录,与创建订单同一 `auth-token`):用于 App 内二次确认(与收银台确认二选一,幂等)。
|
||||
- **DDPay**:`channel_code=ddpay`;先创建待支付单,再调用 DDPay「入金发起」;若成功返回 `payment_url`,则 `pay_url=payment_url`。
|
||||
- 开关:`FINANCE_MOCK_PAY_ENABLED`(默认开发环境开启);关闭后 `mock` 渠道不可用。
|
||||
- 入账由 **`POST /api/finance/ddpayDepositNotify`** 验签后结算,或网关同步返回 `transaction_status=completed` 时结算;**`mock` 渠道**在用户确认支付后需 **后台审核通过** 才调用入账结算。
|
||||
- 档位与渠道取自 `depositTierList`:`channels` 中会出现后台已启用的渠道(包含 `mock` / `ddpay` 等)。
|
||||
- 同一用户最多 3 笔待支付充值单;**待支付**订单创建后,超过 `DEPOSIT_PENDING_EXPIRE_SECONDS`(默认 **60 秒**,见 `.env`)未支付会自动失败;支付链接倒计时与此一致。
|
||||
|
||||
请求参数(**三者缺一不可**,任一为空或空白即 `code=1001` 参数缺失):
|
||||
- `tier_id`:string,必填(含义:玩家选择的充值档位 ID,取自 `depositTierList` 的 `id`;也可用同义字段名 `tier_key`)
|
||||
- `channel_code`:string,必填(含义:支付渠道代码,**小写**;须与所选档位在 `depositTierList` 返回的 `channels[].code` 之一一致,例如默认内置渠道常为 `directpay`)
|
||||
- `idempotency_key`:string,必填,≤64(含义:客户端生成的唯一键,短时间内同 `idempotency_key` 不会重复下单;建议 UUID。**调试工具中若使用变量,请确保解析后非空**)
|
||||
请求参数:
|
||||
|
||||
> **常见 1001 原因**:只传了 `tier_id` + `idempotency_key`,**漏传 `channel_code`**。请先调 `depositTierList`,用对应档位下 `channels` 中某项的 `code` 作为 `channel_code`。
|
||||
**A. 通用必填参数**
|
||||
- `tier_id`:string,必填(充值档位 ID;同义字段:`tier_key`)
|
||||
- `channel_code`:string,必填,`mock`(模拟)或 `ddpay`(真实网关)
|
||||
- `idempotency_key`:string,必填,≤64(客户端幂等键,建议 UUID)
|
||||
|
||||
**A.1 模拟支付(`channel_code=mock`)**
|
||||
|
||||
- 无需 `payment_type` / `payer_name` / `payer_bank_name`
|
||||
|
||||
**B. DDPay 渠道必填参数(`channel_code=ddpay`)**
|
||||
|
||||
> **依据**:DDPay 官方《Payment Gateway》接口说明(与仓库内 `docs/DDPay Payment Gateway_v1.1.3_zh.md` / `docs/DDPay Payment Gateway_v1.1.3.pdf` 一致;以下「官方」均指该文档)。
|
||||
|
||||
- `payment_type`:string,必填(DDPay 字段 **`payment_type`,支付方式选择**)
|
||||
- **取值须为官方枚举字符串**(文档 §3.1):**`01`** = FPX,**`02`** = duitnow,**`03`** = ewallet;**其他取值须向 DDPay 商户支持另行确认**,勿自行臆造。
|
||||
- 兼容字段:`paymentType`
|
||||
- **注意**:若传入 `FPX`、`duitnow` 等**非官方编码**,网关可能直接拒绝;移动端应传 **`01` / `02` / `03`**。
|
||||
- `payer_name`:string,必填(**付款账户持有人姓名**,须与银行登记信息一致)
|
||||
- 兼容字段:`payerName`
|
||||
- `payer_bank_name`:string,必填(对应官方字段 **`payer_bank[name]`,付款银行名称**)
|
||||
- 须使用 **DDPay 银行列表中的银行全称**(与官方附录一致):**MYR** 参考官方附录「入金银行列表(MYR)」中 **英文全称**(如 `Public Bank`、`Maybank2U`);**THB** 参考「入金银行列表(THB)」中 **英文全称**(如 `BANGKOK BANK PUBLIC COMPANY LTD.`)。勿使用简称或与列表不一致的拼写,否则可能被网关拒单。
|
||||
- 兼容字段:`payer_bank[name]`、`payerBankName`
|
||||
|
||||
**B.1 服务端代传、客户端无需传的 DDPay 字段(供联调对照)**
|
||||
|
||||
以下由服务端在调用 DDPay「入金发起」时自动组装(来自环境/配置),移动端**不要**也无法通过本接口覆盖:
|
||||
|
||||
- `client_id`、`identifier`(若项目环境已配置 `DDPAY_IDENTIFIER`)、`order_id`(即本系统 `order_no`)
|
||||
- `transaction_amount`:取自所选档位的 **`pay_amount`**(法币应付额,类型为 Decimal),币种以商户在 DDPay **onboarding 时约定币种**为准;官方示例表写 **MYR**,若贵司为 THB 等,以 DDPay 后台为准。
|
||||
- `callback_url`、`redirect_url`:由服务端拼接,根地址优先读取环境变量 **`DDPAY_PUBLIC_BASE_URL`**(见 `.env-example`),未配置时按请求 `Host` 推导;官方要求 **HTTPS**(生产务必配置公网 HTTPS 根地址)。
|
||||
|
||||
> **常见 1001 原因(DDPay)**:只传了通用参数,漏传 `payment_type / payer_name / payer_bank_name` 之一;或 `payment_type` 未使用官方 `01/02/03`。
|
||||
|
||||
推荐请求示例(DDPay,MYR + FPX):
|
||||
```json
|
||||
{
|
||||
"tier_id": "t_xxxxxxxx",
|
||||
"channel_code": "ddpay",
|
||||
"idempotency_key": "dp_20260429_xxx",
|
||||
"payment_type": "01",
|
||||
"payer_name": "ZHANG SAN",
|
||||
"payer_bank_name": "Public Bank"
|
||||
}
|
||||
```
|
||||
|
||||
返回参数:
|
||||
- `order_no`:string(含义:充值订单号)
|
||||
@@ -486,34 +551,53 @@
|
||||
- `bonus_amount`:string(2 位小数,含义:本次赠送金额,与所选档位 `bonus_amount` 一致,无赠送为 `0.00`)
|
||||
- `total_amount`:string(2 位小数,含义:实际入账总额 = amount + bonus_amount)
|
||||
- `pay_channel`:string(含义:支付通道标识,与请求中选择的 `channel_code` 一致,落库在订单上)
|
||||
- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示待玩家在第三方支付页面完成支付)
|
||||
- `pay_url`:string(含义:第三方支付收银台地址;**`paid=false`(待支付)** 时返回**完整 URL**(如 `https://你的域名/api/finance/depositMockPayPage?order_no=...&sign=...`);`paid=true` 时为空串)
|
||||
- `status`:string(`pending`/`paid`/`failed`,含义:本接口创建成功时为 `pending`,入账完成后为 `paid`)
|
||||
- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示尚未入账;**`mock` 待审核**也为 `false`)
|
||||
- `pay_url`:string(含义:收银台地址;**`pending`(待支付)** 时:`ddpay` 为三方 **`payment_url`(完整 URL)**;**`mock` 为模拟收银台**(`GET /api/finance/mockDepositPage`);**`paid=true` 或已进入待审核**时可能为空串)
|
||||
- `review_required`:bool(含义:是否需要后台审核入账;**仅 `mock` 在用户确认支付后**为 `true`)
|
||||
- `reject_reason`:string 或 null(含义:审核驳回原因;仅失败且存在驳回原因时有值)
|
||||
- `expire_at`:int 或 null(含义:`pay_url` 过期时间戳(秒);主要用于 `mock` 待支付)
|
||||
- `expire_seconds`:int 或 null(含义:与 `expire_at` 配套,待支付时返回,值同 `DEPOSIT_PENDING_EXPIRE_SECONDS`)
|
||||
- `status`:string(`pending` / `pending_review` / `paid` / `failed`,含义:`pending`=待支付;`pending_review`(仅 mock)=用户已确认,等待后台审核;`paid`=已入账;`failed`=失败/超时/驳回)
|
||||
- `create_time`:int(含义:订单创建时间,秒级时间戳)
|
||||
- `pay_time`:int(含义:订单到账时间,未到账为 0)
|
||||
|
||||
#### 5.4.1 模拟第三方:收银台页与「异步通知」回调(开发/无真网关时使用)
|
||||
#### 5.4.1 DDPay 回调与状态说明(当前实现)
|
||||
|
||||
- **GET** `/api/finance/depositMockPayPage`
|
||||
- **Query**:`order_no`(与 `depositCreate` 返回一致)、`sign`(HMAC,与 `pay_url` 中一致;**不要自行拼接,须完整使用 `depositCreate` 返回的 `pay_url` 或同接口再次查询到的地址**)
|
||||
- 无需 `auth-token` / `user-token`(外跳浏览器使用)。
|
||||
- 返回:HTML 页面,用户点击 **「确认支付(模拟成功)」** 即提交到下方 `depositMockNotify`。
|
||||
- 回调地址:`POST /api/finance/ddpayDepositNotify`(由服务端在 DDPay 发起请求时作为 **`callback_url`** 传给三方;须 **HTTPS**)。
|
||||
- 官方 Webhook 负载字段(文档 §3.5,处理前**必须先验签**):
|
||||
|
||||
- **POST** `/api/finance/depositMockNotify`
|
||||
- **Body**(`application/x-www-form-urlencoded` 或 JSON 均可,字段名一致即可):`order_no`、`sign`(与上页/支付链接一致)
|
||||
- 无需 `user-token`;`auth-token` 可选(当前实现不校验)。
|
||||
- 验签成功后:对 `status=0` 的订单执行入账(`DepositSettlement::settle`,`source=third_party` 语义),并推送 `wallet.changed`。已入账订单**幂等**再调返回当前订单信息。
|
||||
- 成功响应:与 `depositCreate` 成功体相同结构(`code=1` + `data` 为统一充值订单结构)。
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `client_id` | 商户标识 |
|
||||
| `order_id` | 交易引用号(与本系统充值 `order_no` 对应) |
|
||||
| `transaction_status` | 最终状态:`pending` / `completed` / `failed`(含义见官方 §4.1) |
|
||||
| `timestamp` | 通知时间 |
|
||||
| `transaction_amount` | 支付金额(配置币种下) |
|
||||
| `signature` | 通知校验签名 |
|
||||
|
||||
- 我方验签通过后:
|
||||
- `transaction_status=completed`:执行入账结算,订单转 `paid`
|
||||
- `transaction_status=failed`:订单转 `failed`
|
||||
- 官方要求:收到通知后应尽快返回 HTTP **200**,响应体为纯文本 **`{"status":"ok"}`**;未收到确认时平台最多重试 **6** 次(以官方文档为准)。
|
||||
- 若三方暂未提供/未打通回调,`pending` 订单将保持待处理;官方另提供「**入金状态查询**」接口(请求需含 `query_time`,格式 **`YYYY-MM-DD HH:MM:SS`**),当前移动端未单独暴露,由服务端按需扩展。
|
||||
|
||||
错误码约定:
|
||||
- `1001`:缺少必填参数(`tier_id`(或 `tier_key`)、`channel_code`、`idempotency_key` 任一未传或为空字符串)
|
||||
- `1001`(DDPay):缺少 `payment_type` / `payer_name` / `payer_bank_name`,或 `payment_type` 非官方允许取值
|
||||
- `1002`:`idempotency_key` 过长,或与其他玩家的订单冲突
|
||||
- `1003`:模拟回调/链接参数非法(如 `sign` 与 `order_no` 不匹配)——`depositMockNotify` 与无效支付链接
|
||||
- `2000`:订单落库或入账失败(事务回滚后返回原始错误描述)
|
||||
- `2000`:**通用**:订单落库或入账失败(事务回滚等,可能带原始错误描述);**DDPay**:调用「入金发起」失败(网络/HTTP/JSON/验签/`status_code≠0` 等),对外文案为「DDPay 充值发起失败」,**具体原因可查看该笔 `deposit_order.remark`(前缀 `[ddpay]`)或服务端日志**
|
||||
- `2003`:所选 `tier_id` 不存在、已停用或不在启用列表中
|
||||
- `2004`:`channel_code` 未配置或已禁用
|
||||
- `2004`:不支持的 `channel_code`;`mock` 已关闭;或 `ddpay` 未启用;或当前档位币种与该渠道不允许的组合
|
||||
- `2005`:待支付充值单超过上限(`data.max_pending`、`data.pending_count`、`data.expire_seconds`)
|
||||
|
||||
### 5.4A 模拟支付(浏览器收银台 + 可选 App 确认)
|
||||
|
||||
- **GET/POST** `/api/finance/mockDepositPage`:模拟收银台 HTML 页面(**无需登录**;`order_no` 必填;仅 `pay_channel=mock` 且 `status=pending` 且未过期可展示支付按钮)
|
||||
- **GET/POST** `/api/finance/mockDepositConfirm`:收银台内确认支付(**无需登录**;将订单标记为 **`pending_review`**,不入账)
|
||||
- **GET/POST** `/api/finance/mockDepositPay`:App 内确认(**需登录**;与上一接口二选一、幂等)
|
||||
|
||||
说明:后台管理员在 **「充值订单」** 对待审核单执行审核通过/驳回后,`mock` 订单才会最终成功入账或失败(未接入真实商户前的模拟流程)。
|
||||
|
||||
### 5.5 查看充值订单详情
|
||||
- **POST** `/api/finance/depositDetail`
|
||||
|
||||
@@ -527,8 +611,12 @@
|
||||
- `total_amount`:string(2 位小数,含义:入账总额)
|
||||
- `pay_channel`:string(含义:支付通道标识)
|
||||
- `paid`:bool(含义:是否已到账)
|
||||
- `pay_url`:string(含义:第三方支付页面地址,已到账为空串)
|
||||
- `status`:string(`pending`/`paid`/`failed`)
|
||||
- `pay_url`:string(含义:收银台地址;已到账或无需再支付时可能为空串)
|
||||
- `review_required`:bool(含义:是否等待后台审核入账)
|
||||
- `reject_reason`:string 或 null(含义:驳回原因)
|
||||
- `expire_at`:int 或 null(含义:`pay_url` 过期时间戳(秒))
|
||||
- `expire_seconds`:int 或 null(含义:与 `expire_at` 配套)
|
||||
- `status`:string(`pending` / `pending_review` / `paid` / `failed`)
|
||||
- `create_time`:int(含义:订单创建时间)
|
||||
- `pay_time`:int(含义:订单到账时间,未到账为 0)
|
||||
|
||||
@@ -546,7 +634,7 @@
|
||||
- `order_no`:string(含义:充值订单号)
|
||||
- `amount`:string(2 位小数,含义:本单充值金额)
|
||||
- `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`)
|
||||
- `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending`/`paid`/`failed`)
|
||||
- `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending` / `pending_review` / `paid` / `failed`)
|
||||
- `pagination`:object(含义:分页信息)
|
||||
- `page`:int(含义:当前页码)
|
||||
- `page_size`:int(含义:每页数量)
|
||||
@@ -555,31 +643,58 @@
|
||||
### 5.7 提现申请
|
||||
- **POST** `/api/finance/withdrawCreate`
|
||||
|
||||
说明:
|
||||
- **仅支持 DDPay 出金**:`channel_code` 必须为 **`ddpay`**(兼容同义字段 `pay_channel`),且该渠道在收银台配置中为启用状态,否则返回 `code=2004`。
|
||||
- 可选渠道列表见 `depositWithdrawConfig` → `withdraw.pay_channels`。
|
||||
|
||||
请求参数:
|
||||
- `channel_code`:string,必填(支付渠道代码;当前仅支持 `ddpay`;兼容字段 `pay_channel`)
|
||||
- `withdraw_coin`:string(含义:申请提现金额,必须 > 0)
|
||||
- `receive_account`:string(含义:收款账号)
|
||||
- `receive_type`:string(`bank`/`ewallet`/`crypto`,含义:收款类型)
|
||||
- `receive_account`:string(含义:收款账号;对接 DDPay 出金时对应官方字段 **`receiver_account`**,为收款账户号/手机号,须与银行登记一致)
|
||||
- `receive_type`:string(含义:收款类型;当前版本仅支持 `bank`)
|
||||
- `idempotency_key`:string(含义:防重复提交提现)
|
||||
- `receiver_name`:string(含义:收款账户持有人姓名;`receive_type=bank` 必填,对应官方 **`receiver_name`**,须与银行登记一致)
|
||||
- `receiver_email`:string(含义:收款人邮箱,必填,须为合法邮箱格式,最长 255)
|
||||
- `receiver_mobile`:string(含义:收款人手机号,必填,5–32 位,仅允许数字与 `+` `-` 空格,至少含 5 位数字)
|
||||
- `bank_code`:string(含义:银行代码;`receive_type=bank` 必填。取值来自收银台配置 `withdraw_banks[].code`;服务端会映射为 DDPay 所需的 **`bank[name]`** 银行全称发起出金)
|
||||
- `bank_branch`:string(含义:银行支行名称;`receive_type=bank` 可选。官方 **`bank_branch`** 为必填项,若客户端不传则服务端按 **`N/A`** 提交,与 DDPay 文档「若缺失则默认值为 `N/A`」一致)
|
||||
|
||||
**DDPay 出金(Payout)官方请求字段对照(文档 §3.2,由服务端在审核通过后组装)**
|
||||
|
||||
| 官方参数 | 说明 |
|
||||
|---|---|
|
||||
| `client_id` | 商户账号标识 |
|
||||
| `bill_number` | 出金唯一引用号(与本系统提现 `order_no` 对应) |
|
||||
| `amount` | 出金金额(Decimal,配置币种下) |
|
||||
| `receiver_name` | 收款账户持有人姓名 |
|
||||
| `receiver_account` | 收款账户号/手机号 |
|
||||
| `bank[name]` | 银行全称(须参考官方附录「出金银行列表」) |
|
||||
| `bank_branch` | 支行名称;缺失为 `N/A` |
|
||||
| `callback_url` | 异步通知地址,须 **HTTPS** |
|
||||
| `signature` | MD5 签名(小写) |
|
||||
|
||||
出金成功响应中,官方还可能返回 `transaction_fee`、`transaction_total`、`transaction_status`、`remark` 等;最终以 DDPay 文档为准。
|
||||
|
||||
返回参数:
|
||||
- `order_no`:string(含义:提现订单号)
|
||||
- `status`:string(`pending_review`/`processing`,含义:提现状态)
|
||||
- `status`:string(`pending_review`/`approved`/`rejected`,含义:提现状态;已打款合并到 `approved`)
|
||||
- `fee_coin`:string(含义:手续费)
|
||||
- `actual_arrival_coin`:string(含义:实到账金额)
|
||||
- `risk_review_required`:bool(含义:是否命中人工审核)
|
||||
|
||||
校验顺序(任一失败即返回对应错误码,不再创建订单):
|
||||
1. 参数完整性与金额合法性(`code=1001`;金额必须为数值且 > 0)
|
||||
2. **待审核订单数限制**:同一用户 `status=0`(待审核)的 `withdraw_order` 不得超过 3 笔,否则 `code=2004 Too many pending withdraw orders`,`data` 中回传:
|
||||
1. 参数完整性与金额合法性(`code=1001`;含 `channel_code`、`withdraw_coin` 等必填项;金额必须为数值且 > 0)
|
||||
2. **支付渠道**:`channel_code` 非 `ddpay` 返回 `code=2004 Withdraw only supports DDPay`;渠道未启用返回 `code=2004 Pay channel not available`
|
||||
3. **待审核订单数限制**:同一用户 `status=0`(待审核)的 `withdraw_order` 不得超过 3 笔,否则 `code=2004 Too many pending withdraw orders`,`data` 中回传:
|
||||
- `max_pending`:上限值(当前为 `3`)
|
||||
- `pending_count`:当前待审核订单数
|
||||
3. `coin_balance >= withdraw_coin`,否则 `code=2001 Insufficient balance`
|
||||
4. **单笔上限校验**:`withdraw_coin <= max_withdrawable`,否则 `code=2002 Withdraw exceeds available bet flow`,`data` 中回传:
|
||||
4. `coin_balance >= withdraw_coin`,否则 `code=2001 Insufficient balance`
|
||||
5. **单笔上限校验**:`withdraw_coin <= max_withdrawable`,否则 `code=2002 Withdraw exceeds available bet flow`,`data` 中回传:
|
||||
- `max_withdrawable`:**当前允许的单笔最大提现金额**(= `min(coin_balance, max_withdraw_by_flow)`,前端据此提示"最大可提现金额为 XXX")
|
||||
- `coin_balance`、`bet_flow_coin`、`total_withdraw_coin`、`ratio`
|
||||
- `max_withdraw_by_flow`:仅按打码量折算的上限(= `max(0, bet_flow_coin / ratio - total_withdraw_coin)`);`ratio=0` 时为 `null`
|
||||
5. 以上全通过后在同一事务内:
|
||||
- `withdraw_order` 写入:`amount` / `fee`(默认 0.5%) / `actual_amount = amount - fee` / `status=0`(待审核) / `channel_id` 取自用户归属渠道快照。
|
||||
6. 以上全通过后在同一事务内:
|
||||
- `withdraw_order` 写入:`pay_channel`(= `channel_code`)/ `amount` / `fee`(默认 0.5%) / `actual_amount = amount - fee` / `status=0`(待审核) / `channel_id` 取自用户归属渠道快照;同时写入收款字段(`receive_type/receive_account/receiver_email/receiver_mobile/receiver_name/bank_code/bank_branch`)。
|
||||
- `user` 表原子更新:`coin -= withdraw_coin` 且 `total_withdraw_coin += withdraw_coin`(WHERE `coin >= withdraw_coin` 防止并发超额扣减)。
|
||||
- `user_wallet_record` 写入 `biz_type=withdraw`、`direction=2`、`amount=withdraw_coin`、`ref_type=withdraw_order`、`idempotency_key=wd_apply_{order_no}`,代表"冻结"动作。
|
||||
|
||||
@@ -587,7 +702,8 @@
|
||||
- 单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`;每笔提现按 `withdraw_coin × ratio` 消耗打码配额,已消耗部分累积在 `total_withdraw_coin`。
|
||||
- `ratio = 0` 时视为"不限打码",单笔上限仅受 `coin_balance` 约束。
|
||||
- 采用"申请即冻结"语义:提现在移动端提交后立即从 `user.coin` 中扣减并写出金流水;后台审核 **拒绝** 时由管理端在同一事务中回冲余额、`total_withdraw_coin` 与流水,不出现"等待审核期间用户还能把这笔钱再下注"的漏洞。
|
||||
- 后台审核 **通过** 时不再额外触碰余额;若管理员调整了 `amount` 或 `fee`,按新旧差额再生成一条 `withdraw` / `withdraw_refund` 流水以保持账务平衡,并同步修正 `total_withdraw_coin`。
|
||||
- 后台审核 **通过** 时会触发三方出金(DDPay Payout);出金 **失败** 会自动回冲余额、`total_withdraw_coin` 与流水(`withdraw_refund`),并将订单标记为 `rejected`(内部 `status=2`)。
|
||||
- 后台审核 **通过** 且出金完成后,订单内部 `status=3`(已打款),移动端对外仍合并展示为 `approved`。
|
||||
- `withdraw_bet_flow_ratio` 由后台「游戏配置」维护,默认 `1.00`,修改后对新请求立即生效。
|
||||
|
||||
### 5.8 查看提现订单详情
|
||||
@@ -602,6 +718,11 @@
|
||||
- `withdraw_coin`:string(含义:申请提现金额,与后台 `withdraw_order.amount` 对齐)
|
||||
- `fee_coin`:string(含义:手续费,与后台 `withdraw_order.fee` 对齐)
|
||||
- `actual_arrival_coin`:string(含义:实际到账金额 = 申请金额 - 手续费;后台审核调整后会同步刷新)
|
||||
- `receive_type`:string(含义:收款类型)
|
||||
- `receive_account`:string(含义:收款账号)
|
||||
- `pay_channel`:string(含义:支付渠道代码,与 `channel_code` 一致)
|
||||
- `receiver_email`:string(含义:收款人邮箱)
|
||||
- `receiver_mobile`:string(含义:收款人手机号)
|
||||
- `reject_reason`:string/null(含义:拒绝原因,`status=rejected` 时取自 `withdraw_order.remark`,否则为 `null`)
|
||||
- `create_time`:int(含义:申请时间)
|
||||
- `review_time`:int/null(含义:审核时间戳,未审核为 `null`)
|
||||
@@ -631,6 +752,7 @@
|
||||
|
||||
### 6.1 拉取公告列表
|
||||
- **GET** `/api/notice/noticeList`
|
||||
- **鉴权**:无需 `auth-token`、`user-token`(公开接口)
|
||||
|
||||
请求参数(query string):
|
||||
- `page`:int(可选,默认 1)
|
||||
@@ -640,30 +762,25 @@
|
||||
- `list`:array<object>
|
||||
- `notice_id`:int(含义:公告 ID)
|
||||
- `title`:string(含义:公告标题)
|
||||
- `content`:string(含义:公告正文,原详情接口字段)
|
||||
- `notice_type`:string(`silent`/`popout`,含义:公告类型)
|
||||
- `is_read`:bool(含义:当前用户是否已读)
|
||||
- `must_confirm`:bool(含义:是否必须手动确认;强弹窗为 `true`)
|
||||
- `is_read`:bool(含义:当前用户是否已确认;**仅 `popout` 强弹窗有效**,`silent` 恒为 `false`,不写入阅读记录)
|
||||
- `publish_time`:int(含义:发布时间)
|
||||
|
||||
### 6.2 公告详情
|
||||
- **GET** `/api/notice/noticeDetail`
|
||||
> **阅读记录口径**:`user_notice_read` 仅用于强弹窗(`notice_type=popout`)的确认已读;静默信箱(`silent`)不生成、不查询阅读记录。
|
||||
|
||||
请求参数(query string):
|
||||
- `id`:int,必填(含义:公告 ID)
|
||||
|
||||
返回参数:
|
||||
- `notice_id`:int(含义:公告 ID)
|
||||
- `title`:string(含义:公告标题)
|
||||
- `content`:string(含义:公告正文)
|
||||
- `notice_type`:string(含义:公告类型)
|
||||
- `must_confirm`:bool(含义:是否必须手动确认)
|
||||
- `publish_time`:int(含义:发布时间)
|
||||
|
||||
### 6.3 强弹窗确认已读
|
||||
### 6.2 强弹窗确认已读
|
||||
- **GET** `/api/notice/noticeConfirm`
|
||||
|
||||
请求参数(query string):
|
||||
- `notice_id`:int(含义:待确认公告 ID)
|
||||
|
||||
行为说明:
|
||||
- **仅强弹窗**(`notice_type=popout`)可调用;静默公告调用返回业务错误
|
||||
- 若当前用户对该公告已有阅读记录:仅更新 `read_at`;若尚未确认则同时将 `confirmed` 置为 `1`
|
||||
- 若无阅读记录:创建一条已确认记录
|
||||
|
||||
返回参数:
|
||||
- `notice_id`:int(含义:已确认公告 ID)
|
||||
- `confirmed`:bool(含义:确认结果)
|
||||
@@ -675,33 +792,147 @@
|
||||
|
||||
> 本版本已移除 webman/push 频道模式;H5 前端使用原生 WebSocket 直连,HTTP 轮询仅作为弱网兜底。
|
||||
|
||||
### 7.0 部署与连接前置条件(当前实现)
|
||||
|
||||
以下与代码 `app/process/GameWebSocketServer.php`、`config/process.php`、`app/common/library/admin/WebSocketConfigHelper.php` 一致,**文档此前未写明的条件**在此补齐:
|
||||
|
||||
- **独立进程**:需在运行环境中启动 Webman 自定义进程 **`gameWebSocketServer`**(见 `config/process.php`),默认监听 **`H5_WEBSOCKET_LISTEN`**(缺省为 `websocket://0.0.0.0:3131`)。未启动进程则客户端无法建连或立刻断线。
|
||||
- **连接 URL**:优先使用环境变量 **`H5_WEBSOCKET_URL`**(完整 `ws://` / `wss://` 地址,建议带路径,如 `.env-example` 所示)。**生产环境勿将 `H5_WEBSOCKET_URL` 配成 `127.0.0.1` / `localhost`**:浏览器无法访问服务端本机地址。若 `.env` 中为回环地址且 HTTP 请求来自外网域名,`WebSocketConfigHelper` 会**忽略该配置**,改按当前请求的 `Host` / `X-Forwarded-*` 推导 `ws(s)://{host}/ws/`。未配置时,后台 **`GET /admin/test.GameCurrentStatus/wsConfig`**、**`GET /admin/game.Live/wsConfig`** 等同理;本地兜底 **`ws://127.0.0.1:3131/ws/`**。HTTPS 站点需在 Nginx 等反向代理将 **`/ws/`** 转发至 `gameWebSocketServer` 监听端口(默认 **3131**)。
|
||||
- **移动端配置缺口**:**`POST /api/game/lobbyInit` 当前不下发 WebSocket 地址**;H5 需与运维约定同一套 `H5_WEBSOCKET_URL`(打包进前端配置、远程配置中心等),与 HTTP API 基址可不同域。
|
||||
- **混合内容**:若 H5 页面为 **HTTPS**,浏览器要求 WebSocket 使用 **`wss://`**,否则会被拦截。
|
||||
- **事件投递依赖 Redis**:HTTP 侧业务通过 **`GameWebSocketEventBus`**(Redis 列表)将事件投递到 WebSocket 进程;Redis 不可用或队列异常时,**除 `admin.live.snapshot` 外**的广播类推送可能收不到。后台若订阅了 `admin.live.snapshot`,服务端有**每秒直连构建快照**的兜底,不依赖队列。
|
||||
- **握手鉴权(2026-05 重构后强制)**:`GameWebSocketServer::onWebSocketConnect` 通过 `GameWebSocketAuthHelper::authorize` 校验 URL Query。两种合法身份:
|
||||
- **mobile(H5/移动端)**:必须同时携带 Query **`auth-token`**、**`user-token`**(与 HTTP 请求头同名,**统一用连字符**)。校验通过后绑定 `user_id`,分发器仅向其推送本人的 user 级主题;并校验 `auth-token` 绑定 `device_id` 与用户活跃设备一致(§1.3),异设备会话将被拒绝。服务端仍兼容旧别名 `auth_token` / `user_token` 解析,但**新接入请只用连字符**。
|
||||
- **admin(后台/运维)**:必须携带 Query **`admin-ws-token`**(由后台 `wsConfig` 签发,写入 Redis,默认 TTL 7200s)。`ws_url` 已自动拼接该参数;admin 模式 `user_id=0`,可观测全量推送。
|
||||
- 任一身份不通过 → 服务端发送 `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` 并立即 `close`。
|
||||
- **服务端按 user_id 过滤(user 级主题)**:以下 topic 的 `data.user_id` 必须 **等于** 当前连接绑定的 `user_id` 才会下发——**`bet.win` / `user.streak` / `wallet.changed` / `bet.accepted` / `auto.spin.progress`**。其它 topic(`period.tick` / `period.opened` / `jackpot.hit` / `admin.*` 等)按订阅广播。admin 模式不参与此过滤。
|
||||
- **心跳超时(服务端主动)**:连接 60s 内无任何上行报文(含 `ping`/`subscribe`)即被 server 主动 `close`,触发客户端走重连流程;避免半关闭的僵尸连接长期持有订阅却不能实际送达推送。
|
||||
- **独立日志通道 `ws`**:`runtime/logs/ws.log`(保留 7 天)。记录维度包含 `publish 入队 / popBatch 异常 / dispatch(topic/candidates/matched/skipped_not_owner/skipped_closed/send_failed)/ handshake_ok | denied / subscribe / pong / close idle / send failed` 等。排查"为什么没收到推送"时优先看此文件。
|
||||
- **订阅才有业务推送**:建连后仅会收到握手首帧(见下)及本连接已订阅主题的消息;不发送 `subscribe` 则收不到 `period.tick` 等(`admin.live.snapshot` 同上,需显式订阅)。
|
||||
|
||||
### 7.1 WebSocket 连接与消息
|
||||
|
||||
- **连接地址**:由服务端配置下发(后台测试页读取 `H5_WEBSOCKET_URL`)
|
||||
- **连接地址**:见 **§7.0**(环境变量 `H5_WEBSOCKET_URL` 或后台 `wsConfig` 返回的 `ws_url`)
|
||||
- **客户端**:浏览器原生 `WebSocket`(`ws://` / `wss://`)
|
||||
- **连接时携带参数(建议)**:
|
||||
- URL Query:`token`(用户登录态 user-token)、`auth_token`(接口鉴权)、`device_id`(设备标识)、`lang`(`zh/en`)
|
||||
- 示例:`wss://ws.example.com/game?token=xxx&auth_token=xxx&device_id=ios_001&lang=zh`
|
||||
- **连接成功返回(服务端首帧建议)**:
|
||||
- **连接时必带 Query 参数(2026-05 起强制)**:
|
||||
- **H5/移动端**:`auth-token=<HTTP auth-token 的值>` + `user-token=<HTTP user-token 的值>`。可选:`device_id`、`lang`。
|
||||
- **后台**:`admin-ws-token=<wsConfig 返回的 admin-ws-token>`(已拼入 `ws_url`)。
|
||||
- 示例:
|
||||
- H5:`wss://ws.example.com/ws/?auth-token=xxx&user-token=yyy&device_id=ios_001&lang=zh`
|
||||
- 后台:`wss://ws.example.com/ws/?admin-ws-token=zzz`
|
||||
- 缺失任一必填字段或 token 失效 → 服务端回 `{"event":"ws.error","code":1101,...}` 后立即关闭连接。
|
||||
- **连接成功首帧(当前实现)**:
|
||||
- `event`:`ws.connected`
|
||||
- `connection_id`:连接唯一标识
|
||||
- `server_time`:服务器时间戳(秒)
|
||||
- `heartbeat_interval`:心跳间隔(秒)
|
||||
- **连接失败返回(建议)**:
|
||||
- `event`:`ws.error`
|
||||
- `code`:错误码(如 `1101` 未登录、`1103` 鉴权失败)
|
||||
- `message`:错误描述
|
||||
- `message`:固定文案 `WebSocket connected`(便于联调日志)
|
||||
- `connection_id`:连接唯一标识(进程内)
|
||||
- `mode`:`mobile` | `admin`(表明本连接的鉴权身份;**不下发** `user_id`)
|
||||
- `server_time`:服务器时间戳(**秒**,int)
|
||||
- `heartbeat_interval`:建议心跳间隔(**秒**,当前实现固定为 `30`)
|
||||
- `idle_timeout`:服务端主动关闭的空闲秒数(**秒**,当前实现固定为 `60`;客户端 `idle_timeout - 心跳间隔` 内必须发出 `ping`,否则会被 server 主动 `close`)
|
||||
- **连接后错误帧(当前实现,非 HTTP 业务码)**:
|
||||
- JSON 无法解析:`event`=`ws.error`,`message`=`Invalid JSON payload`(无 `code` 或与 HTTP `code` 不同体系)
|
||||
- 未知 `action`:`event`=`ws.error`,`message`=`Unsupported action`,并可能带 `received_action`
|
||||
- 进程级异常:`onError` 回包含 `event`=`ws.error`、`message`=`Server internal error` 及 Workerman 侧 `code`/`detail`(**非** §1.2 的 HTTP API `code` 段)
|
||||
- **建议消息**:
|
||||
- 心跳:`{"action":"ping"}`
|
||||
- 订阅状态流:`{"action":"subscribe","topics":["period.tick","period.opened"]}`
|
||||
- 服务端对心跳的当前实现回包:`{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}`(**注意**:此处 `server_time` 为**本地时间字符串**,与业务推送帧里 `server_time` 常用**秒级 int** 不一致,客户端解析时请分支处理)
|
||||
- 订阅状态流:`{"action":"subscribe","topics":["period.tick"]}`
|
||||
- 订阅连胜/赔率(仅当前玩家):`{"action":"subscribe","topics":["user.streak","wallet.changed","bet.accepted"]}`
|
||||
- 订阅资金流:`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
|
||||
- 订阅托管流:`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
|
||||
- 移动端推荐合并订阅:`period.tick`、`bet.win`、`user.streak`、`wallet.changed`、`bet.accepted`、`period.opened`、`period.payout`、`jackpot.hit`
|
||||
|
||||
#### 7.1.1 消息协议字段定义(联调口径)
|
||||
|
||||
- 客户端 -> 服务端:
|
||||
- `action`:动作名(当前约定 `ping` / `subscribe`)
|
||||
- `topics`:仅 `subscribe` 时必填,表示要订阅的主题列表(数组)
|
||||
- 服务端 -> 客户端:
|
||||
- `event`:事件名(如 `period.tick`、`wallet.changed`、`jackpot.hit`)
|
||||
- `topic`:所属主题(通常与 `event` 一致;用于前端按主题路由)
|
||||
- `data`:业务载荷(对象)
|
||||
- `server_time`:服务端时间戳(秒,倒计时与对时基准)
|
||||
|
||||
#### 7.1.2 订阅行为说明
|
||||
|
||||
- **仅建立连接不会自动下发全部业务消息**;客户端需要发送 `subscribe` 明确订阅主题。
|
||||
- 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`(已去重、按字典序排序,与提交顺序无关)。
|
||||
- **`subscribe` 覆盖式生效**:每次发送都会**完全替换**该连接的订阅集合(不是累加)。需要追加请把已有列表一并发上来。
|
||||
- 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。
|
||||
- **服务端按连接绑定用户过滤**:mobile 模式仅下发本人相关的 user 级主题;**出站 `data` 不含 `user_id`**(及其它敏感字段,见下)。客户端**无需**再按 `user_id` 过滤。
|
||||
- **出站脱敏字段(2026-05)**:`data` 中移除 `user_id`、`uuid`、`phone`、`balance_before`、`channel_id` 等;`jackpot.hit` 的 `hits[]` 仅保留 `nickname`、`period_no`、`total_win`、`result_number` 等展示字段。
|
||||
- **不下发** `streak_win_reward` 全表(1~10 档);赔率仅通过 `user.streak` / `wallet.changed` / `bet.accepted` 及 `lobbyInit.user_snapshot` 推送**当前登录玩家**本局适用字段。
|
||||
|
||||
#### 7.1.2A 连胜赔率与连胜场次(WebSocket)
|
||||
|
||||
- **`user.streak`**(开奖结算后推送;载荷为当前玩家本局适用赔率)
|
||||
- `data.current_streak`:int
|
||||
- `data.streak_level`:int
|
||||
- `data.odds_factor`:int
|
||||
- `data.is_jackpot`:bool
|
||||
- **`wallet.changed` / `bet.accepted` / `bet.win`**:在原有字段上合并 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**(**不含** `user_id`)。
|
||||
- **`bet.accepted` 与 `bet.win` 的 `is_jackpot` 区别**:`bet.accepted` 表示**下注时**本笔适用档位是否大奖档(赔率展示);**开奖中奖通知以 `bet.win` 为准**,其 `is_jackpot` 表示**结算时**该用户中奖注单是否含大奖档(`streak_at_bet` 对应 `streak_win_reward.is_jackpot=true`,通常为第 10 档)。
|
||||
|
||||
#### 7.1.3 推送频率与触发规则(当前实现)
|
||||
|
||||
- `period.tick`:**仅在 `status ∈ {betting, locked}` 时每秒推送**(用于倒计时、状态同步;**不含**赔率全表)。
|
||||
- **派彩静默期**:`status=payouting` 期间**不推** `period.tick`(避免彩池/倒计时干扰中奖动画)。改为每秒推送 **`period.payout.tick`**,仅含 `payout_remaining_seconds` / `payout_until` 等派彩倒计时字段。中奖展示仍靠 `period.opened` / `period.payout` / **`bet.win`** / `jackpot.hit`(大奖补充)/ `wallet.changed(biz_type=payout)`。
|
||||
- **边界帧(每期仅一次)**:
|
||||
- `status=finished`:派彩宽限期结束、本期进入收尾时推送一帧,告知前端可以清理本期 UI。
|
||||
- `status=void`:本期被作废时推送一帧作为通知。
|
||||
- **恢复推送**:下一期创建并进入 `betting` 后,按新 `period_no` 重新开始每秒推送。
|
||||
- 服务端去重:边界帧通过 Redis Key `dfw:v1:ws:tick:boundary:{period_no}:{status}`(TTL 300s)保证同一期号同一状态只推一次。
|
||||
- `user.streak`:每期结算更新用户连胜后按用户推送(未中奖也会推送,`current_streak` 可能归零)。
|
||||
- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照;不受派彩静默期影响)。
|
||||
- `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。
|
||||
- `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。派彩时 `biz_type=payout`,并带 `amount`(本次派彩金额)、`period_no`、`period_id`、`result_number`(若有)。
|
||||
- **`bet.win`(本期中奖,小奖/大奖统一)**:开奖结算后,**凡本期有中奖的用户**均按用户聚合推送一帧(与 `wallet.changed(payout)` 同一结算批次);**个人中奖弹窗/横幅统一监听此主题**,用 `data.is_jackpot` 区分普通档与大奖档样式。**中大奖档用户同样会收到 `bet.win`**,无需仅依赖 `jackpot.hit`。
|
||||
- `data.period_id` / `data.period_no` / `data.result_number`(**不含** `user_id`)
|
||||
- `data.total_win`:本期该用户派彩合计(已入账部分;若触发**后台大奖审核**(`win_amount >= game_config.jackpot_max_amount`)且注单为待审核,可能尚未入账,但仍会推送本事件)
|
||||
- `data.balance_after`:推送时用户余额(已派彩则为派彩后余额)
|
||||
- `data.bets[]`:`{ bet_id, win_amount }` 明细
|
||||
- **`data.is_jackpot`**:`bool`,`true` 表示**中大奖**(满足任一:连胜**大奖档**、或派彩金额达 `jackpot_max_amount` 需后台审核)。**客户端用此字段做大奖样式,勿仅依赖 `jackpot.hit`**
|
||||
- **`data.is_win`**:`bool`,固定为 `true`(便于与 `user.streak` 的 `extra.is_win` 对齐)
|
||||
- **`data.payout_pending_review`**:`bool`,`true` 表示已中奖但派彩待后台大奖审核,尚未入账(仍应展示中奖 UI)
|
||||
- **合并赔率字段**(与 §7.1.2A 一致):`current_streak`、`streak_level`、`odds_factor`、`is_jackpot`
|
||||
- `data.server_time`:Unix 秒
|
||||
- **服务端去重**:Redis Key `dfw:v1:ws:betwin:{period_id}:{user_id}`(TTL 86400s),**入队成功后再写入**,每期每用户至多推送一次;与 `dfw:v1:settle:notify:{period_id}` **分离**。结算推送以库内已结算中奖注单为准重建载荷,避免内存聚合丢失。
|
||||
- **补偿**:若内存聚合 `bet_wins` 为空但库内已有本期已结算中奖注单,结算服务会从库重建载荷并补发(`buildBetWinPayloadsFromSettledOrders`)。
|
||||
- **大奖审核通过后**:后台 `approveJackpot` 会再次向该用户推送 `bet.win`(入账后)。
|
||||
- **`jackpot.hit`(公共大奖广播,补充)**:在 **`bet.win` 之后**(同一结算批次内),若本期存在**大奖档命中**用户,**额外**向公共频道推送一帧,供全站公告/跑马灯;无大奖命中则不推送。**个人弹窗仍以 `bet.win` 为主**;`jackpot.hit` 用于全站展示昵称与金额。
|
||||
- **推送顺序**:先 `bet.win`(按用户,含 `is_jackpot`)→ 再 `jackpot.hit`(仅大奖档)
|
||||
- **载荷字段**:`period_id` / `period_no` / `result_number` / `hits[]` / `server_time`。
|
||||
- `hits[]` 数组每项字段:
|
||||
- `nickname`:string(用户昵称,供全站公告展示;**不含** `user_id`)
|
||||
- `period_no`:string
|
||||
- `total_win`:string(本期该用户的命中大奖派彩合计,金额字符串)
|
||||
- `result_number`:int
|
||||
|
||||
#### 7.1.4 结算推送去重与运维补发(2026-05)
|
||||
|
||||
| Redis Key | 作用 |
|
||||
|-----------|------|
|
||||
| `dfw:v1:settle:notify:{period_id}` | 整期 `user.streak` / `wallet.changed`(结算批次内)/ `jackpot.hit` 仅推一次 |
|
||||
| `dfw:v1:ws:betwin:{period_id}:{user_id}` | 该用户本期 `bet.win` 仅推一次(与上表独立) |
|
||||
|
||||
**运维补发**(已结算中奖但客户端未收到 `bet.win` 时):
|
||||
|
||||
```bash
|
||||
php scripts/republish_bet_win.php --play-record-id=1370
|
||||
php scripts/republish_bet_win.php --period-id=123
|
||||
php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1
|
||||
# 强制忽略 dedup 再推:加 --force
|
||||
```
|
||||
|
||||
联调脚本:`php scripts/verify_ws_topic_subscribe.php`(含 `bet.win` 入队/订阅校验)。
|
||||
|
||||
### 7.1A 后台连接方式(管理端联调)
|
||||
|
||||
- 后台菜单:仅保留一个菜单 `连接服务器websocket`,用于统一联调 WebSocket
|
||||
- 后台连接入口:
|
||||
- 后台菜单:`连接服务器websocket`(联调)、**`游戏管理` → `游戏实时对局`**(`/admin/game/live`,生产监控)
|
||||
- 后台 WebSocket 配置入口:
|
||||
- `/admin/test.GameCurrentStatus/wsConfig`
|
||||
- `/admin/game.Live/wsConfig`(实时对局页自动拉取并订阅,含 `admin.live.snapshot`、`bet.win` 等)
|
||||
- **HTTP 快照**:`GET /admin/game.Live/snapshot` 为**只读**快照(`buildSnapshot`),**不在此接口执行** `recoverLiveRoundState` / 自动开奖,避免请求超时;对局推进由进程 **`gameLiveTicker`** 与开奖流程负责。
|
||||
- 后台页面能力:
|
||||
- 读取 `ws_url`、`connect_tip`、`sample_messages`
|
||||
- 手动连接/断开 WebSocket
|
||||
@@ -710,44 +941,43 @@
|
||||
|
||||
### 7.2 HTTP 兜底接口
|
||||
|
||||
- **当前期状态**:`POST /api/game/currentStatus`(建议 1 秒/次兜底)
|
||||
- **开奖记录**:`POST /api/game/periodHistory`(建议 3~5 秒/次兜底)
|
||||
- **余额快照**:`POST /api/wallet/balanceSummary`(下注后主动刷新)
|
||||
- 本版本已移除以下兜底接口:`/api/game/currentStatus`、`/api/game/periodHistory`、`/api/wallet/balanceSummary`。
|
||||
- 状态与余额统一以 WebSocket 推送为主,HTTP 仅保留业务动作/详情查询接口(如 `placeBet`、`depositDetail`、`withdrawDetail`)。
|
||||
|
||||
### 7.3 一致性规则
|
||||
|
||||
- 倒计时以服务端下发时间为准,不信任本地时钟累计。
|
||||
- 下注成功后以 `placeBet` 返回的 `balance_after` 为准,再调用钱包接口兜底。
|
||||
- WebSocket 断线后立即重连,并并发触发 `currentStatus + balanceSummary` 全量回补。
|
||||
- 下注成功后以 `placeBet` 返回的 `balance_after` 为准,并等待 `wallet.changed` 同步。
|
||||
- WebSocket 断线后立即重连并重新订阅主题,不再依赖 `currentStatus/periodHistory/balanceSummary` 回补。
|
||||
|
||||
---
|
||||
|
||||
## 8. 移动端完整调用流程
|
||||
|
||||
## 8.1 首次进入游戏
|
||||
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`
|
||||
2. `POST /api/user/login` 登录(请求头带 `auth-token`)
|
||||
3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`)
|
||||
4. 建立 WebSocket(H5)连接,发送订阅消息监听状态流
|
||||
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`(`device_id` 须为客户端稳定设备码)
|
||||
2. `POST /api/user/login` 登录(请求头带 `auth-token` + 登录成功后带 `user-token`)
|
||||
3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token` + `user-token`)
|
||||
4. 取得 WebSocket 基址(**当前非 lobbyInit 下发**:与运维/打包配置中的 `H5_WEBSOCKET_URL` 或自建配置接口一致)后建立 WebSocket 连接,**立即发送 `subscribe`** 监听状态流(见 §7.0 / §7.1;**务必包含 `bet.win`**)
|
||||
5. 用户下注调用 `POST /api/game/placeBet`
|
||||
6. 下单后调用 `POST /api/wallet/balanceSummary` 刷新余额(并等待 WebSocket 消息)
|
||||
7. 断线或页面回前台时,兜底调用 `currentStatus + periodHistory` 回补状态
|
||||
6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额;开奖结算后监听 **`bet.win`**(`is_win=true`)展示中奖,大奖档看 `data.is_jackpot`(连接已绑定用户,载荷无 `user_id`)
|
||||
7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态
|
||||
8. 用户主动退出:`POST /api/user/logout`(请求头 `auth-token` + `user-token`,body 可选 `refresh_token`),成功后清除本地 token 并关闭 WebSocket
|
||||
|
||||
## 8.2 充值到下注到提现闭环
|
||||
1. 拉取档位:`POST /api/finance/depositTierList`(玩家选择一档,并记下该档 `channels[].code`)
|
||||
2. 创建订单:`POST /api/finance/depositCreate`(`tier_id` + `channel_code` + `idempotency_key`,三者为必填;可用 JSON / form-data / x-www-form-urlencoded)
|
||||
- 返回 `paid=false`、`status=pending`、**非空 `pay_url`**:客户端在 WebView/浏览器中打开 `pay_url`(`GET /api/finance/depositMockPayPage`);用户在模拟页点击确认后,由 `POST /api/finance/depositMockNotify` 完成入账,或轮询 `depositDetail` / 等 `wallet.changed` 再刷新余额
|
||||
- 未来接真实第三方:将 `pay_url` 换为真网关,入账仅在支付平台 **异步通知** 中调用 `DepositSettlement::settle`(与当前 `depositMockNotify` 路径一致)
|
||||
2. 创建订单:`POST /api/finance/depositCreate`(`tier_id` + **`channel_code=ddpay`** + `idempotency_key` + DDPay 入金必填字段;可用 JSON / form-data / x-www-form-urlencoded)
|
||||
- 返回 `paid=false`、`status=pending`、**非空 `pay_url`**:客户端在 WebView/浏览器中打开 **`pay_url`(DDPay `payment_url`)** 完成支付;入账由 **`ddpayDepositNotify`** Webhook 驱动,可轮询 `depositDetail` 或等待 `wallet.changed` 刷新余额
|
||||
3. 客户端可选轮询 `POST /api/finance/depositDetail` 兜底确认状态;入账成功后会收到 `wallet.changed`
|
||||
4. 下注:`POST /api/game/placeBet`
|
||||
5. 轮询余额:`POST /api/wallet/balanceSummary`
|
||||
5. 监听余额:`wallet.changed`(或按订单详情接口核对)
|
||||
6. 查询流水:`POST /api/wallet/recordList`
|
||||
7. 提现:`POST /api/finance/withdrawCreate`(即时冻结 `user.coin` 与写出 `withdraw` 流水) -> `POST /api/finance/withdrawDetail`
|
||||
|
||||
## 8.3 公告强触达流程
|
||||
1. 客户端监听 `notice.popout`
|
||||
2. 拉取详情 `GET /api/notice/noticeDetail`
|
||||
3. 用户勾选确认 `GET /api/notice/noticeConfirm?notice_id=...`
|
||||
2. 拉取列表 `GET /api/notice/noticeList`(列表项已含 `content`、`must_confirm`)
|
||||
3. 用户勾选确认 `GET /api/notice/noticeConfirm?notice_id=...`(若已有阅读记录则仅更新 `read_at`)
|
||||
4. 未确认前可由前端阻断下注入口
|
||||
|
||||
---
|
||||
@@ -760,63 +990,62 @@ flowchart TD
|
||||
B --> C[连接 WebSocket 并订阅主题]
|
||||
C --> D{0-20秒下注期?}
|
||||
D -- 是 --> E[提交下注 /api/game/placeBet]
|
||||
E --> F[刷新余额 /api/wallet/balanceSummary]
|
||||
E --> F[等待 wallet.changed 同步余额]
|
||||
D -- 否 --> G[进入封盘与开奖阶段]
|
||||
G --> H[服务端算票与开奖]
|
||||
H --> I[WebSocket 推送状态变化]
|
||||
I --> J[断线兜底 /api/game/currentStatus]
|
||||
H --> I[WebSocket: period.opened / bet.win / wallet.changed 等]
|
||||
I --> J[断线重连并重新订阅]
|
||||
J --> C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 后台渠道分红比例配置(管理端补充)
|
||||
## 10. 后台代理分红配置(管理端补充,2026-05-29)
|
||||
|
||||
> 本节为管理后台 `/admin/channel`「分配比例」弹窗补充口径,用于便于管理员按角色层级设置二次分红比例。
|
||||
> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。渠道页交互(统计、下注记录、筛选、权限)见 `docs/渠道管理后台说明.md`。
|
||||
|
||||
### 10.1 角色组展示规则
|
||||
### 10.1 页面与展示
|
||||
|
||||
- 表格列顺序调整为:`角色组层级` -> `负责人` -> `状态` -> `分配比例(%)`
|
||||
- `角色组层级` 在 `负责人` 前展示,降低识别与分配成本
|
||||
- 层级路径使用 `/` 拼接,如:`顶级组 / 运营组 / 一组`
|
||||
- 同一负责人若存在多个角色组,按多标签展示多条路径
|
||||
- 无角色组时显示 `-`
|
||||
- 列表为 **树形表格**(按 `parent_admin_id`),参考角色组管理样式;支持展开/收起
|
||||
- 列含:用户名、昵称、**渠道**、**上级代理**、**分红比例(%)**、角色组、邀请码、状态等
|
||||
- 超管公共搜索支持 **渠道下拉筛选**
|
||||
- 非超管仅见 **本人及全部下级** 代理,不见其他代理线下的子代理
|
||||
|
||||
### 10.2 接口:读取渠道管理员分配配置
|
||||
### 10.2 表单字段
|
||||
|
||||
- **GET** `/admin/channel/channelAdminShareList?id={channel_id}`
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `channel_id` | 所属渠道(超管可选;非超管随角色组/当前账号) |
|
||||
| `parent_admin_id` | 上级代理;**顶级角色组**(`admin_group.pid=0`)时留空且不可选 |
|
||||
| `commission_share_rate` | **顶级角色组**:从渠道本期总佣金分得的比例(0–100),必填;**子代理**:从上级本期实得抽取的比例,有上级时必填 |
|
||||
| `group_arr` | 角色组(单选,仅权限;是否顶级由 `pid=0` 判定) |
|
||||
|
||||
返回参数(`data.list[]`)新增:
|
||||
- `group_paths`:array<string>(负责人所属角色组层级路径列表)
|
||||
- `group_paths_text`:string(层级路径拼接文本,`|` 分隔,用于兼容纯文本场景)
|
||||
### 10.3 校验与提示
|
||||
|
||||
返回示例(节选):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"channel_id": 1,
|
||||
"channel_name": "渠道A",
|
||||
"list": [
|
||||
{
|
||||
"admin_id": 12,
|
||||
"username": "zhuguan1",
|
||||
"group_paths": ["顶级组 / 运营组 / A组"],
|
||||
"group_paths_text": "顶级组 / 运营组 / A组",
|
||||
"status": 1,
|
||||
"share_rate": "30.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
**顶级角色组**:
|
||||
|
||||
### 10.3 保存约束(沿用现有)
|
||||
- 上级代理字段禁用,并提示「无需绑定上级代理」
|
||||
- 须填写分红比例;实得 = **渠道本期总佣金 × 本项比例**
|
||||
- 同一 `channel_id` 下顶级代理比例 **合计 ≤ 100%**
|
||||
- 表单调用 **GET** `/admin/auth.Admin/commissionShareRemainder?is_top_level=1&channel_id=&exclude_id=`
|
||||
- 角色组联动:**GET** `/admin/auth.Admin/groupMeta?group_id=`
|
||||
|
||||
- **POST** `/admin/channel/saveChannelAdminShare`
|
||||
- 仅 `status=1` 的行参与占比汇总
|
||||
- 启用项分配比例总和必须严格等于 `100.00`
|
||||
**子代理**:
|
||||
|
||||
- 同一 `parent_admin_id` 下,启用子代理的 `commission_share_rate` **合计 ≤ 100%**
|
||||
- 表单调用 **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` 展示剩余可分配比例
|
||||
- 合计 100% 时提示:上级在本层将无分红留存
|
||||
|
||||
### 10.4 结算拆分(与渠道结算联动)
|
||||
|
||||
- 渠道结算得到总佣金后,由 `AdminCommissionDistributionService` 先按顶级代理 `commission_share_rate` 划入,再向下递归拆分
|
||||
- 每个管理员实得写入 `agent_commission_record` 并 **即时入账** `admin_wallet`
|
||||
- 须存在至少一名渠道顶级代理,否则结算失败
|
||||
|
||||
### 10.5 历史接口(已废弃于 UI,勿新接)
|
||||
|
||||
- ~~GET `/admin/channel/channelAdminShareList`~~
|
||||
- ~~POST `/admin/channel/saveChannelAdminShare`~~
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
docs/DDPay Payment Gateway_v1.1.3.pdf
Normal file
BIN
docs/DDPay Payment Gateway_v1.1.3.pdf
Normal file
Binary file not shown.
319
docs/DDPay Payment Gateway_v1.1.3_zh.md
Normal file
319
docs/DDPay Payment Gateway_v1.1.3_zh.md
Normal file
@@ -0,0 +1,319 @@
|
||||
## DDPay 支付网关接口对接文档
|
||||
|
||||
> 文档名称:DDPay Payment Gateway
|
||||
> 版本:1.1.0(文末“Document Version”标注为 1.1.2;文件命名为 v1.1.3)
|
||||
> 最后更新:2026-03-20
|
||||
> 版权:© 2025 DDPay
|
||||
|
||||
## 目录
|
||||
|
||||
- 1. 概述(Overview)
|
||||
- 2. 认证与安全(Authentication & Security)
|
||||
- 3. API 接口(API Endpoints)
|
||||
- 3.1 入金发起(Deposit Initiation)
|
||||
- 3.2 出金发起(Payout Initiation)
|
||||
- 3.3 入金状态查询(Deposit Status Inquiry)
|
||||
- 3.4 出金状态查询(Payout Status Inquiry)
|
||||
- 3.5 Webhook 回调(Webhook Notifications)
|
||||
- 4. 返回码(Response Codes)
|
||||
- 5. 附录
|
||||
- 5.1 入金银行列表(MYR)
|
||||
- 5.2 出金银行列表(MYR)
|
||||
- 5.3 入金银行列表(THB)
|
||||
|
||||
## 1. 概述(Overview)
|
||||
|
||||
### 1.1 目的
|
||||
|
||||
本文件提供与 DDPay Payment Gateway API 集成的完整技术规范,涵盖:
|
||||
|
||||
- 请求/响应字段结构
|
||||
- 认证机制
|
||||
- 集成协议(包括签名与回调处理)
|
||||
|
||||
### 1.2 术语(Terminology)
|
||||
|
||||
| 术语 | 定义 |
|
||||
|---|---|
|
||||
| Merchant Account | 允许通过 DDPay 处理交易的已注册商户主体 |
|
||||
| Client ID | 每个商户在平台注册时分配的唯一标识 |
|
||||
| API Secret | 用于请求认证与签名校验的保密密钥 |
|
||||
|
||||
## 2. 认证与安全(Authentication & Security)
|
||||
|
||||
### 2.1 通信协议(Communication Protocol)
|
||||
|
||||
- 传输方式:`HTTPS POST` 请求
|
||||
- Content-Type:`application/json`
|
||||
- 字符编码:`UTF-8`
|
||||
- 响应格式:`JSON`
|
||||
|
||||
要求:
|
||||
|
||||
- 所有 API 通信必须使用安全 HTTPS。
|
||||
- 发起交易接口由平台提供同步响应;最终交易状态需要通过“状态查询接口”或“状态回调(Webhook)”确认。
|
||||
|
||||
### 2.2 请求签名(Request Signing)
|
||||
|
||||
DDPay 使用加密签名以保证请求的真实性与数据完整性。签名算法为 **MD5**,流程如下:
|
||||
|
||||
1. 收集请求参数(**不包含**签名字段本身)
|
||||
2. 按参数 key 名进行字典序排序(ASCII 顺序)
|
||||
3. 将所有 `key=value` 使用字符 `&` 连接(中间用 `&` 分隔)
|
||||
4. 在末尾追加:`&key=YOUR_API_SECRET`
|
||||
5. 对得到的字符串执行 `MD5`
|
||||
6. 将 MD5 哈希结果转为小写
|
||||
7. 将该哈希值填入签名字段
|
||||
|
||||
重要注意:
|
||||
|
||||
- **空值或 null 参数值应排除在签名计算之外**。
|
||||
|
||||
## 3. API 接口(API Endpoints)
|
||||
|
||||
> 生产环境的 Endpoint URL:请联系商户支持获取。
|
||||
|
||||
### 3.1 入金发起(Deposit Initiation)
|
||||
|
||||
目的:提交一笔新的支付入金请求
|
||||
|
||||
- HTTP Method:`POST`
|
||||
- Endpoint URL:联系商户支持获取生产地址
|
||||
|
||||
#### 请求参数(Request Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 必填 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `client_id` | 商户账号标识 | String | 是 | onboarding 提供 |
|
||||
| `order_id` | 交易唯一引用号 | String | 是 | 每笔交易必须唯一 |
|
||||
| `payment_type` | 支付方式选择 | String | 是 | `01`=FPX,`02`=duitnow,`03`=ewallet;其他请咨询获取 |
|
||||
| `transaction_amount` | 支付金额 | Decimal | 是 | 币种:MYR |
|
||||
| `payer_name` | 付款账户持有人姓名 | String | 是 | 按银行登记信息 |
|
||||
| `payer_bank[name]` | 付款银行名称 | String | 是 | 请参考银行列表获取全称 |
|
||||
| `callback_url` | 异步通知地址 | String | 是 | 必须使用 HTTPS 协议 |
|
||||
| `redirect_url` | 用户返回地址 | String | 是 | 必须使用 HTTPS 协议 |
|
||||
| `signature` | 请求认证签名哈希 | String | 是 | 小写字母数字 MD5 |
|
||||
|
||||
#### 响应参数(Response Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 条件/备注 |
|
||||
|---|---|---|---|
|
||||
| `status_code` | 操作结果指示 | Integer | 非 0 表示失败 |
|
||||
| `status_message` | 可读状态说明 | String | 永远返回 |
|
||||
| `details` | 失败细节(如适用) | String | 仅失败时适用 |
|
||||
| `client_id` | 商户标识回显 | String | 成功时回显 |
|
||||
| `order_id` | 入金订单号回显 | String | 成功时回显,用于关联 |
|
||||
| `transaction_amount` | 支付金额确认 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_status` | 当前处理状态 | String | 成功时返回;参考“状态码” |
|
||||
| `payment_url` | 支付界面 URL | String | 成功时返回,跳转到该 URL 完成支付 |
|
||||
| `timestamp` | 响应生成时间 | DateTime | ISO 8601 |
|
||||
| `signature` | 响应校验签名哈希 | String | 成功时返回(使用同方法校验) |
|
||||
|
||||
### 3.2 出金发起(Payout Initiation)
|
||||
|
||||
目的:提交一笔新的出金请求
|
||||
|
||||
- HTTP Method:`POST`
|
||||
- Endpoint URL:联系商户支持获取生产地址
|
||||
|
||||
#### 请求参数(Request Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 必填 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `client_id` | 商户账号标识 | String | 是 | onboarding 提供 |
|
||||
| `bill_number` | 交易唯一引用号 | String | 是 | 每笔出金请求必须唯一 |
|
||||
| `amount` | 出金金额 | Decimal | 是 | 币种:配置币种 |
|
||||
| `receiver_name` | 收款账户持有人姓名 | String | 是 | 按银行登记信息 |
|
||||
| `receiver_account` | 收款账户号/手机号 | String | 是 | 按银行登记信息 |
|
||||
| `bank[name]` | 银行名称 | String | 是 | 请参考银行列表获取全称 |
|
||||
| `bank_branch` | 银行支行名称 | String | 是 | 若缺失则默认值为 `N/A` |
|
||||
| `callback_url` | 异步通知地址 | String | 是 | 必须使用 HTTPS 协议 |
|
||||
| `signature` | 请求认证签名哈希 | String | 是 | 小写字母数字 MD5 |
|
||||
|
||||
#### 响应参数(Response Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 条件/备注 |
|
||||
|---|---|---|---|
|
||||
| `status_code` | 操作结果指示 | Integer | 非 0 表示失败 |
|
||||
| `status_message` | 可读状态说明 | String | 永远返回 |
|
||||
| `details` | 失败细节(如适用) | String | 仅失败时适用 |
|
||||
| `client_id` | 商户标识回显 | String | 成功时回显 |
|
||||
| `bill_number` | 出金订单号回显 | String | 成功时回显,用于关联 |
|
||||
| `transaction_amount` | 出金金额确认 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_fee` | 出金手续费 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_total` | 出金总金额 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_status` | 当前处理状态 | String | 成功时返回;参考“状态码” |
|
||||
| `timestamp` | 响应生成时间 | DateTime | ISO 8601 |
|
||||
| `signature` | 响应校验签名哈希 | String | 成功时返回(使用同方法校验) |
|
||||
| `remark` | 出金备注 | String | 成功时回显(文档描述为 OK 状态下返回) |
|
||||
|
||||
### 3.3 入金状态查询(Deposit Status Inquiry)
|
||||
|
||||
目的:查询已提交入金的当前状态
|
||||
|
||||
- HTTP Method:`POST`
|
||||
|
||||
#### 请求参数(Request Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 必填 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `client_id` | 商户账号标识 | String | 是 | |
|
||||
| `order_id` | 入金订单号/交易引用 | String | 是 | 原始订单标识 |
|
||||
| `query_time` | 请求时间戳 | DateTime | 是 | 格式:`YYYY-MM-DD HH:MM:SS` |
|
||||
| `signature` | 请求认证签名哈希 | String | 是 | |
|
||||
|
||||
#### 响应参数(Response Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 条件/备注 |
|
||||
|---|---|---|---|
|
||||
| `status_code` | 查询结果指示 | Integer | 永远返回 |
|
||||
| `status_message` | 查询结果说明 | String | 永远返回 |
|
||||
| `client_id` | 商户标识回显 | String | 成功时回显 |
|
||||
| `order_id` | 订单号回显 | String | 成功时回显 |
|
||||
| `transaction_amount` | 支付金额 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_status` | 当前处理状态 | String | 参考“状态码” |
|
||||
| `timestamp` | 响应时间 | DateTime | 仅返回 ISO 8601 |
|
||||
| `signature` | 响应校验签名哈希 | String | 成功时返回(使用同方法校验) |
|
||||
|
||||
### 3.4 出金状态查询(Payout Status Inquiry)
|
||||
|
||||
目的:查询已提交出金的当前状态
|
||||
|
||||
- HTTP Method:`POST`
|
||||
|
||||
#### 请求参数(Request Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 必填 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `client_id` | 商户账号标识 | String | 是 | |
|
||||
| `bill_number` | 出金引用号 | String | 是 | 原始出金标识 |
|
||||
| `signature` | 请求认证签名哈希 | String | 是 | |
|
||||
|
||||
#### 响应参数(Response Parameters)
|
||||
|
||||
| 参数 | 描述 | 类型 | 条件/备注 |
|
||||
|---|---|---|---|
|
||||
| `status_code` | 查询结果指示 | Integer | 永远返回 |
|
||||
| `status_message` | 查询结果说明 | String | 永远返回 |
|
||||
| `client_id` | 商户标识回显 | String | 成功时回显 |
|
||||
| `bill_number` | 出金引用号回显 | String | 成功时回显 |
|
||||
| `transaction_amount` | 出金金额 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_fee` | 出金手续费 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_total` | 出金总金额 | Decimal | 成功时在配置币种下返回 |
|
||||
| `transaction_status` | 当前处理状态 | String | 参考“状态码” |
|
||||
| `remark` | 出金备注 | String | 成功时回显 |
|
||||
| `timestamp` | 响应时间 | DateTime | ISO 8601 |
|
||||
| `signature` | 响应校验签名哈希 | String | 成功时返回(使用同方法校验) |
|
||||
|
||||
### 3.5 Webhook 回调(Webhook Notifications)
|
||||
|
||||
目的:接收异步交易状态更新
|
||||
|
||||
说明:
|
||||
|
||||
- 平台通过 `callback_url` 在交易状态变化时向你系统发送 `POST` 通知
|
||||
- 通知发送范围:成功与失败交易都会发送
|
||||
- 格式:通过 `HTTPS POST` 发送 `JSON`
|
||||
- 重试机制:如果未收到确认,最多重试 6 次
|
||||
- 你需要的响应:返回纯文本 `{"status":"ok"}` 并确保 HTTP 状态码为 `200`
|
||||
|
||||
#### 通知负载(Notification Payload)
|
||||
|
||||
| 参数 | 描述 | 类型 | 必填 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `client_id` | 商户标识 | String | 是 | |
|
||||
| `order_id` | 交易引用号 | String | 是 | |
|
||||
| `transaction_status` | 最终状态 | String | 是 | 参考状态码 |
|
||||
| `timestamp` | 通知时间 | DateTime | 是 | |
|
||||
| `transaction_amount` | 支付金额 | Decimal | 是 | 配置币种下返回 |
|
||||
| `signature` | 通知校验签名哈希 | String | 是 | 处理前必须校验签名 |
|
||||
|
||||
#### 集成要求(Implementation Requirements)
|
||||
|
||||
- 在处理 webhook 之前先验证签名
|
||||
- 收到后应立刻响应纯文本 `{"status":"ok"}`(HTTP 200)
|
||||
- 使用 HTTPS endpoint,并确保 SSL 证书有效
|
||||
|
||||
## 4. 返回码(Response Codes)
|
||||
|
||||
### 4.1 入金状态值(Deposit Status Values)
|
||||
|
||||
| 状态码 | 描述 | 最终状态 | 是否需要操作 | 处理建议 |
|
||||
|---|---|---|---|---|
|
||||
| `pending` | 交易正在处理中 | 否 | 否 | 无需调用轮询接口或等待通知 |
|
||||
| `completed` | 交易成功 | 是 | 是 | 履约订单/服务(fulfill order/service) |
|
||||
| `failed` | 交易失败 | 是 | 是 | 通知客户,可选允许重试(allow retry) |
|
||||
|
||||
> 文档中 `transaction_status` 建议参考本节状态码定义。
|
||||
|
||||
## 5. 附录(Appendices)
|
||||
|
||||
### 5.1 入金银行列表(MYR)
|
||||
|
||||
- `pbb` : Public Bank
|
||||
- `br` : Bank Rakyat
|
||||
- `alb` : Alliance Bank
|
||||
- `mbb` : Maybank2U
|
||||
- `bi` : Bank Islam
|
||||
- `bm` : Bank Muamalat
|
||||
- `afb` : Affin Bank
|
||||
- `rhb` : RHB Bank
|
||||
- `ocbc` : OCBC Bank
|
||||
- `scb` : Standard Chartered
|
||||
- `hlb` : Hong Leong Bank
|
||||
- `uob` : UOB Bank
|
||||
- `cimb` : CIMB Clicks
|
||||
- `amb` : AmBank
|
||||
- `bsn` : Bank Simpanan Nasional
|
||||
- `hsbc` : HSBC Bank
|
||||
- `cob` : Bank of China
|
||||
|
||||
### 5.2 出金银行列表(MYR)
|
||||
|
||||
> 与入金 MYR 银行列表一致
|
||||
|
||||
- `pbb` : Public Bank
|
||||
- `br` : Bank Rakyat
|
||||
- `alb` : Alliance Bank
|
||||
- `mbb` : Maybank2U
|
||||
- `bi` : Bank Islam
|
||||
- `bm` : Bank Muamalat
|
||||
- `afb` : Affin Bank
|
||||
- `rhb` : RHB Bank
|
||||
- `ocbc` : OCBC Bank
|
||||
- `scb` : Standard Chartered
|
||||
- `hlb` : Hong Leong Bank
|
||||
- `uob` : UOB Bank
|
||||
- `cimb` : CIMB Clicks
|
||||
- `amb` : AmBank
|
||||
- `bsn` : Bank Simpanan Nasional
|
||||
- `hsbc` : HSBC Bank
|
||||
- `cob` : Bank of China
|
||||
- `tng` : Touch & Go
|
||||
|
||||
### 5.3 入金银行列表(THB)
|
||||
|
||||
- `105` : BANK OF THAILAND
|
||||
- `106` : BANGKOK BANK PUBLIC COMPANY LTD.
|
||||
- `107` : KASIKORNBANK PUBLIC COMPANY LIMITED
|
||||
- `108` : KRUNG THAI BANK PUBLIC COMPANY LTD.
|
||||
- `109` : TMB THANACHART BANK PUBLIC COMPANY LIMITED
|
||||
- `110` : SIAM COMMERCIAL BANK PUBLIC COMPANY LTD.
|
||||
- `111` : CITIBANK, N.A.
|
||||
- `112` : STANDARD CHARTERED BANK (THAI) PUBLIC COMPANY LIMITED
|
||||
- `113` : CIMB THAI BANK PUBLIC COMPANY LIMITED
|
||||
- `114` : UNITED OVERSEAS BANK (THAI) PUBLIC COMPANY LIMITED
|
||||
- `115` : BANK OF AYUDHYA PUBLIC COMPANY LTD.
|
||||
- `116` : GOVERNMENT SAVINGS BANK
|
||||
- `117` : THE GOVERNMENT HOUSING BANK
|
||||
- `118` : BANK FOR AGRICULTURE AND AGRICULTURAL COOPERATIVES
|
||||
- `119` : BANK OF CHINA (THAI) PUBLIC COMPANY LIMITED
|
||||
- `120` : ISLAMIC BANK OF THAILAND
|
||||
- `121` : KIATNAKIN PHATRA BANK PUBLIC COMPANY LIMITED
|
||||
- `122` : INDUSTRIAL AND COMMERCIAL BANK OF CHINA (THAI) PUBLIC COMPANY LIMITED
|
||||
- `123` : LAND AND HOUSES BANK PUBLIC COMPANY LIMITED
|
||||
- `158` : TISCO BANK PUBLIC COMPANY LIMITED
|
||||
- `357` : RHB BANK BERHAD
|
||||
- `358` : INDIAN OVERSEA BANK
|
||||
- `359` : ANZ BANK (THAI) PUBLIC COMPANY LIMITED
|
||||
|
||||
953
docs/en/36zihua-mobile-api-design-draft.md
Normal file
953
docs/en/36zihua-mobile-api-design-draft.md
Normal file
@@ -0,0 +1,953 @@
|
||||
# 36 Zihua Mobile API Design Draft (V1)
|
||||
|
||||
This document is based on `docs/36字花-数据库与实施计划.md` and the PRD. It provides an initial mobile-facing API inventory and field definitions.
|
||||
|
||||
Scope: **platform-wide single period number and single draw result**; channels are used only for attribution, commission sharing, and risk control—not for splitting game rounds.
|
||||
|
||||
**Addendum (2026-04)**: §**1.5** describes server-side **Redis hot-spot caching** (`GameHotDataRedis`). It does **not** change any interface URL, parameter, or response field contracts; it is for integration testing and operations reference only.
|
||||
|
||||
**Addendum (2026-05)**: §**1.3** / §**1.4** / §**2.3** document **single-device login** and **`POST /api/user/logout`** (Redis-backed `device_id` binding).
|
||||
|
||||
## 1. Design Conventions
|
||||
|
||||
### 1.1 Base Conventions
|
||||
- Protocol: HTTPS + JSON
|
||||
- Path naming: `/api/{module}/{action}`, must match regex `^/api/[a-z]+/[a-z]+[A-Z][a-zA-Z]*$`
|
||||
- **HTTP methods**: All mobile business APIs (`/api/*`, excluding `/api/v1/authToken`) use `POST`. Query-style endpoints also accept `GET` (for browser/debug tools); clients should uniformly use `POST`.
|
||||
- For `POST`, header `Content-Type: application/json`; parameters in JSON body
|
||||
- In `GET` compatibility mode, parameters go in the URL query string
|
||||
- **Exception**: Notice module `/api/notice/noticeList`, `/api/notice/noticeConfirm` support **`GET` only**; parameters always use URL query string
|
||||
- **Notice list without auth**: `GET /api/notice/noticeList` does **not** require `auth-token` or `user-token`; if `user-token` is still sent, popout items can return the user’s real `is_read`
|
||||
- Auth endpoint `/api/v1/authToken` remains `GET`
|
||||
- Time: UTC timestamp (seconds) + server timezone configuration
|
||||
- Amounts: numeric transport (e.g. `"100.00"`); client display uses two decimal places (storage remains `decimal(18,2)`)
|
||||
- Idempotency: critical write APIs require `idempotency_key`
|
||||
- Required headers:
|
||||
- `auth-token`: API auth token from `GET /api/v1/authToken` (signature-based API access credential)
|
||||
- `user-token`: user session token; required on login-protected APIs
|
||||
- Language header:
|
||||
- `lang=zh`: Chinese (default)
|
||||
- `lang=en`: English
|
||||
|
||||
### 1.2 Common Response Shape
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "ok",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- `code=1` means success; any other value is a business error
|
||||
- All `/api/*` response messages support Chinese and English: default Chinese; `lang=en` returns English, `lang=zh` returns Chinese
|
||||
- Suggested error code ranges (by nature):
|
||||
- `1000-1099`: parameter errors (missing fields, type errors, format errors, out of range)
|
||||
- `1100-1199`: auth errors (not logged in, token expired, insufficient permission)
|
||||
- `2000-2999`: business errors (insufficient balance, round not found, order not found, notice not found)
|
||||
- `3000-3099`: flow errors (illegal flow/state, e.g. bet after lock, duplicate confirm, illegal state transition)
|
||||
- `5000-5999`: system errors (service exception, dependency timeout, unknown error)
|
||||
|
||||
- Recommended base error codes (first release):
|
||||
- `1`: success
|
||||
- `1001`: missing parameter
|
||||
- `1002`: invalid parameter format
|
||||
- `1003`: illegal parameter value
|
||||
- `1101`: not logged in or session expired
|
||||
- `1103`: no permission
|
||||
- `2001`: insufficient balance
|
||||
- `2002`: round not found
|
||||
- `2003`: order not found
|
||||
- `2004`: notice not found
|
||||
- `3001`: operation not allowed in current flow
|
||||
- `3002`: betting closed, bets not allowed
|
||||
- `3003`: duplicate request (idempotency conflict)
|
||||
- `5000`: system busy, try again later
|
||||
|
||||
### 1.3 Authentication
|
||||
- **API auth (`auth-token`)**: all mobile business APIs must send header `auth-token` (issued by `/api/v1/authToken`)
|
||||
- **User session (`user-token`)**: login-protected APIs send header `user-token`; on expiry, refresh or log in again
|
||||
- **Single-device session (server rule)**:
|
||||
- `device_id` in the `authToken` query is bound to that `auth-token` in Redis
|
||||
- Each account allows **one active device** only: a successful login on a new device clears other `user-token` / `refresh_token` rows and binds the new `device_id`
|
||||
- Old devices calling login-protected APIs get `code=1101` with message like “logged in on another device, please sign in again” (`lang=en` for English)
|
||||
- Clients must use a **stable unique** `device_id` per install (do not randomize on every launch)
|
||||
|
||||
### 1.4 Obtain API Auth Token (`auth-token`)
|
||||
- **GET** `/api/v1/authToken`
|
||||
- Purpose: obtain `auth-token` (required on all API request headers)
|
||||
|
||||
Request example:
|
||||
`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a×tamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7`
|
||||
|
||||
Request parameters:
|
||||
- `secret`: string (client secret; server validates against env `AUTH_TOKEN_SECRET`)
|
||||
- `timestamp`: int (request timestamp; server allows ±300 seconds from server time)
|
||||
- `device_id`: string (device id)
|
||||
- `signature`: string (signature value)
|
||||
|
||||
Signature algorithm:
|
||||
- Parameters in signature (excluding `signature`): `device_id`, `secret`, `timestamp`
|
||||
- Sort parameter names **a-z**, concatenate as `key=value&key=value...`
|
||||
- Compute: `signature = strtoupper(md5(concatenated string))`
|
||||
|
||||
Response parameters:
|
||||
- `auth_token`: string (API auth token; put in header `auth-token`)
|
||||
- `expires_in`: int (TTL in seconds)
|
||||
- `server_time`: int (server timestamp for clock sync)
|
||||
|
||||
Server behavior (single-device):
|
||||
- On success, stores `device_id` for this `auth_token` in Redis with TTL = `expires_in`
|
||||
- Login, login-protected HTTP APIs, and WebSocket handshake compare this `device_id` with the user’s active device
|
||||
|
||||
Possible error codes:
|
||||
- `1001` missing parameter
|
||||
- `1002` invalid parameter format
|
||||
- `1103` invalid secret / signature error
|
||||
- `3001` invalid timestamp
|
||||
|
||||
### 1.5 Server Performance & Redis Hot-Spot Cache (Implementation Notes)
|
||||
|
||||
> **No client contract change**: request paths, parameters, response JSON shape, and error codes are unchanged by caching; this section only explains how the server reduces latency, read paths, and consistency notes.
|
||||
|
||||
**Difference from “framework file cache”**
|
||||
|
||||
| Config | Scope |
|
||||
|--------|--------|
|
||||
| `CACHE_DRIVER` (`config/cache.php`, e.g. `file`) | Think-ORM / `get_sys_config()` etc. **system `config` table** model cache under `runtime/cache`; **not** used on this game hot-path. |
|
||||
| `GAME_HOT_CACHE_*` (`config/game_hot_cache.php`) | Game **`user` / `game_config` / `game_record`** row-level JSON cache via **`support\Redis`** (`config/redis.php`), key prefix `dfw:v1:`. |
|
||||
|
||||
**Server cache coverage (read paths relevant to mobile)**
|
||||
|
||||
- **User**: member auth prefers Redis `user` row snapshot; on miss, DB then backfill. After **balance, streak, bet-flow**, etc. change and commit, **`GameHotDataCoordinator::afterUserCommitted($userId)`**: **`GameHotDataRedis::userReplaceCacheFromDb`** aligns with DB, then enqueues idempotent refresh tasks (`GameHotDataWriteQueue` / `GameHotDataQueueConsumer`) for peak shaving—not a substitute for synchronous reload.
|
||||
- **Game config**: `game_config` cached by `config_key`. Direct `Db` updates in admin must call **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`** (model `GameConfig` events and standalone form controllers wired); standalone save uses **`GameHotDataLock` (`TYPE_GAME_CONFIG`)** per `config_key`. Do not only delete cache keys without reload—max inconsistency window is TTL.
|
||||
- **Round**: active round, round by `id`, latest `game_record`, etc.; after write, **`GameHotDataCoordinator::afterGameRecordCommitted`** refreshes Redis keys and enqueues. Draw/lock paths may use **`GameHotDataLock` (`TYPE_GAME_RECORD`)** per record id.
|
||||
|
||||
**Environment variables (see repo root `.env-example`)**
|
||||
|
||||
- `GAME_HOT_CACHE_ENABLED`: enable Redis hot cache (`false` = always DB).
|
||||
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`: TTL seconds; **write-then-sync reload is primary**, TTL is fallback only.
|
||||
- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` and queue length, consumer interval, etc.: idempotent refresh after write (`config/game_hot_cache.php`).
|
||||
|
||||
**Consistency notes (integration / testing)**
|
||||
|
||||
- Scripts that bypass coordinator and only change DB without **`GameHotDataCoordinator`** may be briefly inconsistent with Redis; avoid in production.
|
||||
- **`POST /api/game/betPlace`** debit path uses the same **per-user Redis lock** as admin wallet adjust (`GameHotDataRedis::userAdminMutationLockTry`) and **`WHERE coin = ?` conditional update**, mutually exclusive with concurrent payout/admin adjust; on failure returns Chinese messages listed in **§4.2**.
|
||||
- Clients may still use **§3.2 `dictionaryList` `version`** for local cache; server dictionary also has Redis acceleration—both can coexist.
|
||||
|
||||
---
|
||||
|
||||
## 2. Auth & Account Module (`user`)
|
||||
|
||||
### 2.1 Register
|
||||
- **POST** `/api/user/register`
|
||||
- Purpose: phone-only registration with invite attribution (admin/channel); auto-login on success
|
||||
- Headers: `auth-token` required (device id is taken from that token’s bound `device_id`; see §1.3)
|
||||
|
||||
Request parameters:
|
||||
- `username`: string, phone number (registration account; mainland China mobile only)
|
||||
- `password`: string, plaintext over HTTPS (login password; server stores salted hash)
|
||||
- `invite_code`: string, required (sub-agent invite code; binds `channel_id` and ownership)
|
||||
|
||||
Response parameters:
|
||||
- `user-token`: string (session token for login-protected APIs)
|
||||
- `refresh_token`: string, optional (refresh access token)
|
||||
- `expires_in`: int (seconds, token TTL)
|
||||
- `user`: object (non-sensitive fields only; no `id`)
|
||||
- `uuid`: string (public user id, 10 chars)
|
||||
- `username`: string (nickname/display name)
|
||||
- `coin`: string (current balance)
|
||||
- `channel_id`: int (attribution channel id)
|
||||
- `risk_flags`: int (risk status bitmask)
|
||||
|
||||
### 2.2 Login
|
||||
- **POST** `/api/user/login`
|
||||
- Headers: `auth-token` required (bound `device_id` is the login device; **do not** send `device_id` in body)
|
||||
|
||||
Request parameters:
|
||||
- `username`: string (login account; currently phone)
|
||||
- `password`: string (login password)
|
||||
|
||||
Server behavior:
|
||||
- On success, clears other sessions for this user and sets active device to the `auth-token`’s `device_id` (§1.3)
|
||||
|
||||
Response parameters:
|
||||
- `user-token`: string (access token for login-protected APIs)
|
||||
- `refresh_token`: string, optional
|
||||
- `expires_in`: int (access token remaining seconds)
|
||||
- `user`: object (non-sensitive fields only; no `id`)
|
||||
- `uuid`: string (public user id, 10 chars)
|
||||
- `username`: string (nickname/display name)
|
||||
- `coin`: string (current balance)
|
||||
- `channel_id`: int (attribution channel id)
|
||||
- `risk_flags`: int (risk status bitmask)
|
||||
|
||||
### 2.3 Logout
|
||||
- **POST** `/api/user/logout`
|
||||
- Purpose: sign out, invalidate session, release single-device lock
|
||||
|
||||
Headers:
|
||||
- `auth-token`: required
|
||||
- `user-token`: required when logged in
|
||||
|
||||
Request parameters (optional):
|
||||
- `refresh_token`: string (recommended; server deletes it; if omitted while logged in, server tries current session refresh token)
|
||||
|
||||
Response:
|
||||
- `data`: empty object `{}`
|
||||
|
||||
Server behavior:
|
||||
- Deletes current `user-token`, clears Redis active `device_id` for the user
|
||||
- Deletes `refresh_token` when provided
|
||||
- **Idempotent**: returns `code=1` even if already logged out (client can clear local storage)
|
||||
|
||||
Possible error codes:
|
||||
- `1101`: missing or invalid `auth-token` (same as §1.4)
|
||||
|
||||
### 2.4 Current User Profile
|
||||
- **POST** `/api/user/profile`
|
||||
|
||||
Response parameters (amount fields as 2-decimal strings, aligned with wallet display):
|
||||
|
||||
**Basic profile**
|
||||
- `uuid`: string (public user id, 10 chars)
|
||||
- `username`: string (nickname)
|
||||
- `head_image`: string (avatar URL)
|
||||
- `phone`: string (mobile)
|
||||
- `email`: string (email)
|
||||
- `register_invite_code`: string (invite code snapshot at registration)
|
||||
- `channel_id`: int (attribution channel id)
|
||||
- `risk_flags`: int (risk status bitmask)
|
||||
- `current_streak`: int (current win streak count)
|
||||
- `last_bet_period_no`: string (period no of last valid bet)
|
||||
- `create_time`: int (registration timestamp)
|
||||
|
||||
**Funds & withdraw quota**
|
||||
- `coin` / `coin_balance`: string (current balance; same value)
|
||||
- `frozen_balance`: string (frozen balance; fixed `0.00` when none)
|
||||
- `total_deposit_coin`: string (lifetime deposits)
|
||||
- `total_withdraw_coin`: string (lifetime withdraws; incremented after acceptance)
|
||||
- `bet_flow_coin`: string (bet flow / turnover; after settlement, +`total_amount` per bet 1:1)
|
||||
- `max_withdrawable`: string (**max single withdraw allowed now** = `min(coin_balance, max_withdraw_by_flow)`)
|
||||
- `withdraw_flow`: object (bet-flow / withdraw quota diagnostic snapshot; includes `pending_withdraw`)
|
||||
- `ratio`: string (bet-flow multiplier; `0` = unlimited)
|
||||
- `net_deposit`: string (net deposit = max(0, total deposit − total withdraw))
|
||||
- `required_bet_flow`: string (required bet flow by threshold rule; display only)
|
||||
- `remaining_bet_flow`: string (remaining bet flow by threshold; display only)
|
||||
- `eligible`: bool (meets overall threshold; display only; actual gate is `max_withdrawable`)
|
||||
- `max_withdraw_by_flow`: string/null (cap from bet flow only; `null` when `ratio=0`)
|
||||
- `flow_unlimited`: bool (unlimited bet-flow mode)
|
||||
- `pending_withdraw`: object
|
||||
- `count`: int (pending-review withdraw orders)
|
||||
- `max`: int (max pending-review withdraws per user, currently `3`; exceeds → `withdrawCreate` returns `code=2004`)
|
||||
|
||||
### 2.5 Refresh Token (Optional)
|
||||
- **POST** `/api/user/refreshToken`
|
||||
- Headers: `auth-token` required; active `device_id` must match (§1.3)
|
||||
|
||||
Request parameters:
|
||||
- `refresh_token`: string (credential to renew access token)
|
||||
|
||||
Response parameters:
|
||||
- `user-token`: string (new access token)
|
||||
- `expires_in`: int (new token TTL)
|
||||
|
||||
---
|
||||
|
||||
## 3. Game Lobby & Dictionary Module (`game`/`lobby`)
|
||||
|
||||
### 3.1 Lobby Init
|
||||
- **POST** `/api/game/lobbyInit`
|
||||
- Purpose: one-shot current round, config, 36 zihua dictionary, user quick display
|
||||
|
||||
Response parameters:
|
||||
- `server_time`: int (server time for client clock sync)
|
||||
- `runtime_enabled`: bool (**game runtime switch**; `false` = admin maintenance—**no new bets**, idle won’t auto-create periods, post-payout won’t auto-create next; **current open round still draws, pays out, and settles**. Mobile should disable bet entry and show maintenance copy)
|
||||
- `period`: object
|
||||
- `period_no`: string (global period number)
|
||||
- `status`: string (`betting`/`locked`/`settling`/`finished`/`void`; `void` = period voided)
|
||||
- `countdown`: int (countdown seconds)
|
||||
- `lock_at`: int (lock timestamp)
|
||||
- `open_at`: int (expected draw timestamp)
|
||||
- `bet_config`: object
|
||||
- `pick_max_number_count`: int (max numbers per bet; from `game_config.config_key = pick_max_number_count`; default matches seed, usually 10; range 1–36)
|
||||
- `chips`: object (quick chip map; fixed keys `"1"`…`"6"`, values are per-number stake strings, 2 decimals; same as admin `game_config.bet_chips`)
|
||||
- `default_bet_chip_id`: int (default chip id from `game_config.default_bet_chip_id`; invalid → first valid chip)
|
||||
- `min_bet_per_number`: string (min per number; ≤ selected chip and admin limits)
|
||||
- `max_bet_per_number`: string (max per number)
|
||||
- `dictionary`: array<object>
|
||||
- `number`: int (1-36, zihua number)
|
||||
- `name`: string (zihua name)
|
||||
- `category`: string (category)
|
||||
- `icon`: string (icon URL)
|
||||
- `user_snapshot`: object (user snapshot + **odds for current player this round**; no full 1–10 table)
|
||||
- `coin`: string (balance)
|
||||
- `current_streak`: int (current win streak)
|
||||
- `streak_level`: int (streak tier 1–10 if win this round: `min(current_streak+1, 10)`)
|
||||
- `odds_factor`: int (odds multiplier; payout = bet `total_amount` × `odds_factor`)
|
||||
- `is_jackpot`: bool (jackpot tier)
|
||||
|
||||
### 3.2 36 Zihua Dictionary (Cacheable)
|
||||
- **POST** `/api/game/dictionaryList`
|
||||
|
||||
Response parameters:
|
||||
- `version`: string (dictionary version for client cache compare)
|
||||
- `items`: same as `dictionary` (36 zihua entries)
|
||||
|
||||
## 4. Betting & Round Module (`game`/`bet`)
|
||||
|
||||
### 4.1 Current Period Detail
|
||||
- **POST** `/api/game/periodCurrent`
|
||||
|
||||
Response parameters:
|
||||
- `runtime_enabled`: bool (same as `lobbyInit.runtime_enabled`)
|
||||
- `period_id`: int (current period primary key)
|
||||
- `period_no`: string (current period number)
|
||||
- `status`: string (includes `void`)
|
||||
- `countdown`: int (remaining seconds)
|
||||
- `bet_close_in`: int (seconds until lock)
|
||||
- `result_number`: int/null (null before draw)
|
||||
|
||||
### 4.2 Place Bet
|
||||
- **POST** `/api/game/placeBet` (legacy path `/api/game/betPlace` supported)
|
||||
- Purpose: manual bet per period; player sends **picked numbers** and **chip id `bet_id` (1–6)**. Stake per number from `game_config.bet_chips`; server debits `per-number amount × count(numbers)` as `total_amount`. One winning number per round; win if that number ∈ picked set.
|
||||
|
||||
Request parameters:
|
||||
- `period_no`: string (target period)
|
||||
- `numbers`: string (comma-separated picks, e.g. `1,8,16`; each 1–36; count ≤ `pick_max_number_count`; duplicates deduped)
|
||||
- `bet_id`: int (quick chip 1–6; must exist in `lobbyInit.bet_config.chips`; **`single_bet_amount` / `bet_amount` not used**)
|
||||
- `idempotency_key`: string, required
|
||||
|
||||
Response parameters:
|
||||
- `order_no`: string (bet order no)
|
||||
- `period_no`: string (actual period)
|
||||
- `status`: string (`accepted`/`rejected`)
|
||||
- `bet_id`: int (chip used)
|
||||
- `single_bet_amount`: string (from `bet_id`)
|
||||
- `numbers_count`: int (number count)
|
||||
- `locked_balance`: string, optional (frozen amount)
|
||||
- `balance_after`: string (balance after order)
|
||||
- `current_streak`: int (streak snapshot after order)
|
||||
|
||||
**Additional possible error codes** (see header ranges; debit/cache consistency):
|
||||
|
||||
- `3001`: game paused (`runtime_enabled=false`, admin live switch off or void without restart; same band as flow errors)
|
||||
- `5000`: system busy; or **user Redis lock** not acquired (same serial as admin wallet/concurrent writes: 「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」); or **`coin` conditional update** miss (concurrent bet/payout/admin: 「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」).
|
||||
|
||||
### 4.3 Auto Spin
|
||||
- **POST** `/api/game/autoSpin`
|
||||
|
||||
Request parameters:
|
||||
- `action`: string (`start`/`stop`)
|
||||
- `period_no`: string (required when `action=start`)
|
||||
- `numbers`: string (required when `action=start`, comma-separated)
|
||||
- `bet_id`: int (required when `action=start`; same as `placeBet`, chips 1–6)
|
||||
- `rounds`: int (required when `action=start`, >=1)
|
||||
|
||||
Response parameters:
|
||||
- `status`: string (`scheduled`/`stopped`)
|
||||
- `auto_mode`: bool
|
||||
- `bet_id`: int (`start` only)
|
||||
- `single_bet_amount`: string (`start` only, from `bet_id`)
|
||||
- `remaining_rounds`: int (`start` only)
|
||||
|
||||
### 4.4 My Bet Orders (Last 1 Month)
|
||||
- **POST** `/api/game/betMyOrders`
|
||||
|
||||
Request parameters:
|
||||
- `page`: int, optional, default 1
|
||||
- `page_size`: int, optional, default 20
|
||||
|
||||
Response parameters:
|
||||
- `list`: array<object>
|
||||
- `order_no`: string
|
||||
- `period_no`: string
|
||||
- `numbers`: array[int]
|
||||
- `bet_amount`: string (same as `total_amount`)
|
||||
- `total_amount`: string
|
||||
- `result_number`: int/null
|
||||
- `win_amount`: string
|
||||
- `status`: string
|
||||
- `create_time`: int
|
||||
- `pagination`: object (`page`, `page_size`, `total`)
|
||||
|
||||
---
|
||||
|
||||
## 5. Wallet & Finance Module (`wallet`/`finance`)
|
||||
|
||||
### 5.1 Balance Sync (Standalone Summary Removed)
|
||||
- Removed `/api/wallet/balanceSummary`.
|
||||
- Balance sync sources:
|
||||
- `placeBet.balance_after`
|
||||
- WebSocket `wallet.changed`
|
||||
- Deposit/withdraw detail APIs (`depositDetail` / `withdrawDetail`) for order-level reconciliation
|
||||
- Multiplier `ratio` from admin **Game Config → `withdraw_bet_flow_ratio`**; effective immediately on new requests.
|
||||
- Lifetime fields (`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`) are cumulative; rejections reversed by admin review flow.
|
||||
|
||||
### 5.2 Wallet Records
|
||||
- **POST** `/api/wallet/recordList`
|
||||
|
||||
Request parameters:
|
||||
- `page`: int, optional, default 1
|
||||
- `page_size`: int, optional, default 20
|
||||
- `type`: string, optional (filter; omit = all):
|
||||
- `deposit`: deposit credit
|
||||
- `withdraw`: withdraw debit/freeze
|
||||
- `bet`: bet debit
|
||||
- `payout`: draw payout credit
|
||||
- `adjust`: manual adjust (`biz_type=admin_credit/admin_deduct`)
|
||||
- `bet_void`: period void refund (admin live void pending bets)
|
||||
|
||||
Response parameters:
|
||||
- `list`: array<object>
|
||||
- `record_id`: int
|
||||
- `biz_type`: string
|
||||
- `direction`: int (1 in, 2 out)
|
||||
- `amount`: string
|
||||
- `balance_before`: string
|
||||
- `balance_after`: string
|
||||
- `ref_type`: string
|
||||
- `ref_id`: string
|
||||
- `create_time`: int
|
||||
|
||||
Notes:
|
||||
- Amount fields display with two decimals on client.
|
||||
- Admin credit/deduct creates `biz_type=admin_credit/admin_deduct` records; default remark template: `后台管理员(操作管理员)加点/扣点100(值)` (example).
|
||||
|
||||
### 5.3 Deposit Tier List
|
||||
- **POST** `/api/finance/depositTierList`
|
||||
|
||||
Notes:
|
||||
- Maintained in admin **Config → Deposit Tiers**, stored in `game_config.deposit_tier` (JSON array).
|
||||
- Admin “pay currency” dropdown from `game_config.finance_cashier.currencies` (not hardcoded).
|
||||
- Init/rebuild: **6 tiers per currency** from `finance_cashier` (ops may edit).
|
||||
- Only `status=1` tiers, sorted by `sort`.
|
||||
- Tiers describe recharge spec only; collection via third-party `pay_url`.
|
||||
- **i18n**: admin stores `title`, `title_en`, `desc`, `desc_en`. API `title` / `desc` follow `lang`:
|
||||
- `lang=zh` (default): `title` / `desc`, fallback to English if empty
|
||||
- `lang=en`: `title_en` / `desc_en`, fallback to Chinese if empty
|
||||
- Mobile sees single `title` / `desc` only
|
||||
|
||||
Request parameters: none
|
||||
|
||||
Response parameters:
|
||||
- `list`: tier list; each item:
|
||||
- `id`: string (stable tier id; pass as `tier_id`; same as `tier_key`)
|
||||
- `tier_key`: string (same as `id`, legacy)
|
||||
- `title`: string (localized by `lang`)
|
||||
- `currency`: string (e.g. `CNY`)
|
||||
- `pay_amount`: string (2 decimals, list price)
|
||||
- `amount`: string (2 decimals, amount user pays)
|
||||
- `bonus_amount`: string (2 decimals, bonus; `0.00` if none)
|
||||
- `total_amount`: string (2 decimals, `amount + bonus_amount`)
|
||||
- `desc`: string (localized; may be empty)
|
||||
- `channels`: array (for `depositCreate` `channel_code`; all enabled channels compatible with all tiers)
|
||||
- each: `code`, `name`, `sort`
|
||||
|
||||
### 5.3A Deposit / Withdraw Config
|
||||
- **POST** `/api/finance/depositWithdrawConfig`
|
||||
- Legacy: `POST /api/finance/cashierConfig` (same shape; prefer `depositWithdrawConfig`)
|
||||
|
||||
Purpose:
|
||||
- One-shot recharge/withdraw page config: currencies, rates, deposit channels, withdraw banks, limits, copy.
|
||||
|
||||
Response parameters:
|
||||
- `platform_coin_label`: string (localized)
|
||||
- `currencies`: array
|
||||
- `code`, `label`, `deposit_coins_per_fiat`, `withdraw_coins_per_fiat`
|
||||
- `rates`: array (compat)
|
||||
- `currency`, `diamonds_per_fiat_unit`
|
||||
- `pay_channels`: array (deposit)
|
||||
- `code`, `name`, `sort`, `status` (1=enabled), `tier_ids` (compat; empty = all tiers)
|
||||
- `withdraw`: object
|
||||
- `pay_channels`: array (for `withdrawCreate` `channel_code`; enabled + server-integrated only)
|
||||
- `banks`: array
|
||||
- `min_ewallet`, `min_bank`, `rate_hint`, `processing_note`, `fee_note`
|
||||
- `rate_mode`: `fixed` / `live`
|
||||
- `fields`: object (**aligned with DDPay / `withdrawCreate`**; not admin cashier toggles)
|
||||
- `receive_type_bank_only`: bool (fixed `true`, bank only)
|
||||
- `require_channel_code`: bool (`true`, `channel_code`)
|
||||
- `require_receiver_name`: bool (`true`, `receiver_name`)
|
||||
- `require_receive_account`: bool (`true`, `receive_account`)
|
||||
- `require_receiver_email`: bool (`true`, `receiver_email`)
|
||||
- `require_receiver_mobile`: bool (`true`, `receiver_mobile`)
|
||||
- `require_bank_code`: bool (`true`, `bank_code`)
|
||||
- `require_bank_branch`: bool (`false`, optional; server sends `N/A` to DDPay if omitted)
|
||||
|
||||
### 5.4 Create Deposit Order
|
||||
- **POST** `/api/finance/depositCreate`
|
||||
- `Content-Type: application/json` (recommended), `application/x-www-form-urlencoded`, or **`multipart/form-data`**; same field names; server reads unified param names.
|
||||
|
||||
Notes:
|
||||
- **Mock pay (integration)**: `channel_code=mock`, no DDPay merchant; returns `pay_url` (**mock cashier** `GET /api/finance/mockDepositPage`, **3 min** TTL). User confirms → **`pending_review`**; credit after admin approves in **Deposit Orders**.
|
||||
- Optional **`GET/POST /api/finance/mockDepositPay`** (login + same `auth-token`): in-app confirm (either cashier or this; idempotent).
|
||||
- **DDPay**: `channel_code=ddpay`; pending order then DDPay deposit API; on success `pay_url=payment_url`.
|
||||
- Switch: `FINANCE_MOCK_PAY_ENABLED` (default on in dev); off → `mock` unavailable.
|
||||
- Settlement: **`POST /api/finance/ddpayDepositNotify`** verified, or sync `transaction_status=completed`; **`mock`** credits only after admin approval.
|
||||
- Tiers/channels from `depositTierList`.
|
||||
- Max 3 pending deposit orders per user; unpaid orders fail after `DEPOSIT_PENDING_EXPIRE_SECONDS` (default **60s**, `.env`); pay link countdown matches.
|
||||
|
||||
Request parameters:
|
||||
|
||||
**A. Common required**
|
||||
- `tier_id`: string, required (synonym `tier_key`)
|
||||
- `channel_code`: string, required, `mock` or `ddpay`
|
||||
- `idempotency_key`: string, required, ≤64
|
||||
|
||||
**A.1 Mock (`channel_code=mock`)**
|
||||
- No `payment_type` / `payer_name` / `payer_bank_name`
|
||||
|
||||
**B. DDPay required (`channel_code=ddpay`)**
|
||||
|
||||
> Per DDPay official *Payment Gateway* (`docs/DDPay Payment Gateway_v1.1.3_zh.md` / PDF).
|
||||
|
||||
- `payment_type`: string, required (**official enum §3.1**): **`01`** FPX, **`02`** duitnow, **`03`** ewallet; confirm other values with DDPay support.
|
||||
- Alias: `paymentType`
|
||||
- **Note**: values like `FPX`, `duitnow` may be rejected; send **`01` / `02` / `03`**.
|
||||
- `payer_name`: string, required (account holder name)
|
||||
- Alias: `payerName`
|
||||
- `payer_bank_name`: string, required (**`payer_bank[name]`**; full name from DDPay bank list)
|
||||
- **MYR**: English full name from deposit bank list (e.g. `Public Bank`, `Maybank2U`); **THB**: THB list English full name. Mismatches may be rejected.
|
||||
- Aliases: `payer_bank[name]`, `payerBankName`
|
||||
|
||||
**B.1 Server-only DDPay fields (integration reference)**
|
||||
|
||||
Mobile must not send; server fills on deposit API:
|
||||
|
||||
- `client_id`, `identifier` (if `DDPAY_IDENTIFIER` set), `order_id` (= `order_no`)
|
||||
- `transaction_amount`: tier **`pay_amount`** (Decimal); currency per DDPay onboarding (docs often MYR; use your merchant currency).
|
||||
- `callback_url`, `redirect_url`: from **`DDPAY_PUBLIC_BASE_URL`** (`.env-example`) or request `Host`; production **HTTPS**.
|
||||
|
||||
> **Common `1001` (DDPay)**: missing `payment_type` / `payer_name` / `payer_bank_name`; or `payment_type` not `01/02/03`.
|
||||
|
||||
Example (DDPay, MYR + FPX):
|
||||
```json
|
||||
{
|
||||
"tier_id": "t_xxxxxxxx",
|
||||
"channel_code": "ddpay",
|
||||
"idempotency_key": "dp_20260429_xxx",
|
||||
"payment_type": "01",
|
||||
"payer_name": "ZHANG SAN",
|
||||
"payer_bank_name": "Public Bank"
|
||||
}
|
||||
```
|
||||
|
||||
Response parameters:
|
||||
- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status` (`pending` / `pending_review` / `paid` / `failed`), `create_time`, `pay_time`
|
||||
|
||||
#### 5.4.1 DDPay Callback & Status (Current Implementation)
|
||||
|
||||
- Callback: `POST /api/finance/ddpayDepositNotify` (**`callback_url`** to DDPay; **HTTPS**).
|
||||
- Official webhook fields (§3.5, **verify signature first**):
|
||||
|
||||
| Param | Description |
|
||||
|-------|-------------|
|
||||
| `client_id` | Merchant id |
|
||||
| `order_id` | Reference (= deposit `order_no`) |
|
||||
| `transaction_status` | `pending` / `completed` / `failed` (§4.1) |
|
||||
| `timestamp` | Notify time |
|
||||
| `transaction_amount` | Paid amount |
|
||||
| `signature` | Signature |
|
||||
|
||||
- After verify:
|
||||
- `completed` → settle, `paid`
|
||||
- `failed` → `failed`
|
||||
- Respond HTTP **200**, body **`{"status":"ok"}`**; up to **6** retries per docs.
|
||||
- Without callback, `pending` persists; server may use deposit status query (`query_time` `YYYY-MM-DD HH:MM:SS`); not exposed to mobile yet.
|
||||
|
||||
Error codes:
|
||||
- `1001`: missing `tier_id`/`tier_key`, `channel_code`, `idempotency_key`
|
||||
- `1001` (DDPay): missing/invalid DDPay fields
|
||||
- `1002`: `idempotency_key` too long or cross-user conflict
|
||||
- `2000`: generic DB/settle failure; DDPay API failure → 「DDPay 充值发起失败」, see `deposit_order.remark` (`[ddpay]`) or logs
|
||||
- `2003`: invalid/disabled tier
|
||||
- `2004`: bad `channel_code`; mock off; ddpay off; currency/channel mismatch
|
||||
- `2005`: too many pending (`data.max_pending`, `data.pending_count`, `data.expire_seconds`)
|
||||
|
||||
### 5.4A Mock Pay (Browser Cashier + Optional App Confirm)
|
||||
|
||||
- **GET/POST** `/api/finance/mockDepositPage`: HTML cashier (no login; `order_no` required; `pay_channel=mock`, `status=pending`, not expired)
|
||||
- **GET/POST** `/api/finance/mockDepositConfirm`: cashier confirm (no login; → **`pending_review`**, no credit)
|
||||
- **GET/POST** `/api/finance/mockDepositPay`: app confirm (login; idempotent with above)
|
||||
|
||||
After admin approves/rejects in **Deposit Orders**, mock order succeeds or fails.
|
||||
|
||||
### 5.5 Deposit Order Detail
|
||||
- **POST** `/api/finance/depositDetail`
|
||||
|
||||
Request parameters:
|
||||
- `order_no`: string, required
|
||||
|
||||
Response parameters (same as `depositCreate`):
|
||||
- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status`, `create_time`, `pay_time`
|
||||
|
||||
### 5.6 Deposit Order List
|
||||
- **POST** `/api/finance/depositList`
|
||||
|
||||
Paged recharge history; use `depositDetail` for full fields.
|
||||
|
||||
Request parameters:
|
||||
- `page`, `page_size` (max `100`, over → `20`)
|
||||
|
||||
Response parameters:
|
||||
- `list`: `order_no`, `amount`, `bonus_amount`, `status`
|
||||
- `pagination`: `page`, `page_size`, `total`
|
||||
|
||||
### 5.7 Withdraw Request
|
||||
- **POST** `/api/finance/withdrawCreate`
|
||||
|
||||
Notes:
|
||||
- **DDPay payout only**: `channel_code` must be **`ddpay`** (alias `pay_channel`); else `code=2004`.
|
||||
- Channels: `depositWithdrawConfig` → `withdraw.pay_channels`.
|
||||
|
||||
Request parameters:
|
||||
- `channel_code`: string, required (`ddpay`; alias `pay_channel`)
|
||||
- `withdraw_coin`: string (> 0)
|
||||
- `receive_account`: string (DDPay **`receiver_account`**)
|
||||
- `receive_type`: string (`bank` only currently)
|
||||
- `idempotency_key`: string
|
||||
- `receiver_name`: string (required for `bank`; DDPay **`receiver_name`**)
|
||||
- `receiver_email`: string (required, valid email, max 255)
|
||||
- `receiver_mobile`: string (required, 5–32, digits and `+` `-` space, ≥5 digits)
|
||||
- `bank_code`: string (required for `bank`; from `withdraw_banks[].code` → DDPay **`bank[name]`**)
|
||||
- `bank_branch`: string (optional; server sends **`N/A`** if omitted per DDPay)
|
||||
|
||||
**DDPay Payout field map (§3.2, server assembles after approval)**
|
||||
|
||||
| Official param | Description |
|
||||
|----------------|-------------|
|
||||
| `client_id` | Merchant id |
|
||||
| `bill_number` | Unique ref (= `order_no`) |
|
||||
| `amount` | Payout amount |
|
||||
| `receiver_name` | Account holder |
|
||||
| `receiver_account` | Account / mobile |
|
||||
| `bank[name]` | Full bank name (appendix) |
|
||||
| `bank_branch` | Branch; default `N/A` |
|
||||
| `callback_url` | HTTPS notify URL |
|
||||
| `signature` | MD5 lowercase |
|
||||
|
||||
Response may include `transaction_fee`, `transaction_total`, `transaction_status`, `remark`, etc.
|
||||
|
||||
Return parameters:
|
||||
- `order_no`, `status` (`pending_review`/`approved`/`rejected`; paid merged into `approved`), `fee_coin`, `actual_arrival_coin`, `risk_review_required`
|
||||
|
||||
Validation order (first failure wins):
|
||||
1. Params & amount (`1001`)
|
||||
2. Channel: not `ddpay` → `2004 Withdraw only supports DDPay`; disabled → `2004 Pay channel not available`
|
||||
3. Pending limit: `status=0` withdraw orders ≤ 3 → `2004 Too many pending withdraw orders` + `max_pending`, `pending_count`
|
||||
4. `coin_balance >= withdraw_coin` → `2001 Insufficient balance`
|
||||
5. `withdraw_coin <= max_withdrawable` → `2002 Withdraw exceeds available bet flow` + diagnostic `data`
|
||||
6. On success in one transaction:
|
||||
- `withdraw_order` with `pay_channel`, amounts, `status=0`, user `channel_id` snapshot, receiver fields
|
||||
- `user`: `coin -= withdraw_coin`, `total_withdraw_coin += withdraw_coin` with `WHERE coin >= withdraw_coin`
|
||||
- `user_wallet_record`: `biz_type=withdraw`, `direction=2`, `idempotency_key=wd_apply_{order_no}` (freeze semantics)
|
||||
|
||||
Notes (bet-flow withdraw quota):
|
||||
- `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`; each withdraw consumes `withdraw_coin × ratio` quota via `total_withdraw_coin`.
|
||||
- `ratio = 0`: unlimited bet-flow; cap = `coin_balance` only.
|
||||
- Apply-and-freeze: mobile submit debits `user.coin` immediately; admin **reject** refunds in one transaction.
|
||||
- Admin **approve** triggers DDPay payout; payout **fail** auto-refund (`withdraw_refund`), `rejected` (`status=2`).
|
||||
- After payout, internal `status=3`; mobile shows `approved`.
|
||||
- `withdraw_bet_flow_ratio` in game config; default `1.00`.
|
||||
|
||||
### 5.8 Withdraw Order Detail
|
||||
- **POST** `/api/finance/withdrawDetail`
|
||||
|
||||
Request parameters:
|
||||
- `order_no`: string, required
|
||||
|
||||
Response parameters:
|
||||
- `order_no`, `status`, `withdraw_coin`, `fee_coin`, `actual_arrival_coin`, `receive_type`, `receive_account`, `pay_channel`, `receiver_email`, `receiver_mobile`, `reject_reason`, `create_time`, `review_time`
|
||||
|
||||
### 5.9 Withdraw Order List
|
||||
- **POST** `/api/finance/withdrawList`
|
||||
|
||||
Paged list; use `withdrawDetail` for fees, actual amount, reject reason.
|
||||
|
||||
Request parameters:
|
||||
- `page`, `page_size` (max `100`)
|
||||
|
||||
Response parameters:
|
||||
- `list`: `order_no`, `amount`, `status`
|
||||
- `pagination`: `page`, `page_size`, `total`
|
||||
|
||||
---
|
||||
|
||||
## 6. Notice Module (`operation`/`notice`)
|
||||
|
||||
### 6.1 Notice List
|
||||
- **GET** `/api/notice/noticeList`
|
||||
- **Auth**: no `auth-token` or `user-token` (public)
|
||||
|
||||
Request parameters (query):
|
||||
- `page`, `page_size`
|
||||
|
||||
Response parameters:
|
||||
- `list`: array<object>
|
||||
- `notice_id`, `title`, `content`, `notice_type` (`silent`/`popout`), `must_confirm`, `is_read` (**popout only**; `silent` always `false`, no read record), `publish_time`
|
||||
|
||||
> **Read records**: `user_notice_read` only for popout confirm; silent mailbox never writes/reads records.
|
||||
|
||||
### 6.2 Popout Confirm Read
|
||||
- **GET** `/api/notice/noticeConfirm`
|
||||
|
||||
Request parameters (query):
|
||||
- `notice_id`: int
|
||||
|
||||
Behavior:
|
||||
- **Popout only**; silent returns business error
|
||||
- Existing record: update `read_at`; if unconfirmed, set `confirmed=1`
|
||||
- No record: create confirmed record
|
||||
|
||||
Response parameters:
|
||||
- `notice_id`, `confirmed`, `confirm_time`
|
||||
|
||||
---
|
||||
|
||||
## 7. WebSocket (H5) & State Sync
|
||||
|
||||
> This version removed webman/push channel mode; H5 uses native WebSocket; HTTP polling is weak-network fallback only.
|
||||
|
||||
### 7.0 Deployment & Connection Prerequisites (Current Implementation)
|
||||
|
||||
Aligned with `app/process/GameWebSocketServer.php`, `config/process.php`, `app/common/library/admin/WebSocketConfigHelper.php`:
|
||||
|
||||
- **Dedicated process**: run Webman process **`gameWebSocketServer`** (`config/process.php`), default **`H5_WEBSOCKET_LISTEN`** (`websocket://0.0.0.0:3131`). Without it, clients cannot connect.
|
||||
- **URL**: prefer **`H5_WEBSOCKET_URL`** (full `ws://` / `wss://`, with path per `.env-example`). **Do not use `127.0.0.1` / `localhost` in production** for browser clients. Loopback in `.env` with external HTTP Host → `WebSocketConfigHelper` ignores config and derives `ws(s)://{host}/ws/`. Admin **`GET /admin/test.GameCurrentStatus/wsConfig`**, **`GET /admin/game.Live/wsConfig`** same; local fallback **`ws://127.0.0.1:3131/ws/`**. HTTPS sites need reverse proxy **`/ws/`** → port **3131**.
|
||||
- **Mobile gap**: **`POST /api/game/lobbyInit` does not return WebSocket URL**; H5 must use ops/bundle `H5_WEBSOCKET_URL` or remote config (may differ from HTTP API host).
|
||||
- **Mixed content**: HTTPS pages require **`wss://`**.
|
||||
- **Redis event bus**: HTTP uses **`GameWebSocketEventBus`** (Redis list); if Redis down, broadcast pushes may fail except **`admin.live.snapshot`** has per-second direct snapshot fallback.
|
||||
- **Handshake auth (2026-05, mandatory)**: `GameWebSocketServer::onWebSocketConnect` via `GameWebSocketAuthHelper::authorize` on URL query:
|
||||
- **mobile**: query **`auth-token`** + **`user-token`** (hyphenated; legacy `auth_token`/`user_token` parsed but **use hyphens for new clients**). Binds `user_id`; user topics only to owner. Also validates `auth-token`’s bound `device_id` against the user’s active device (§1.3); other-device sessions are rejected.
|
||||
- **admin**: query **`admin-ws-token`** (from admin `wsConfig`, Redis, TTL 7200s). `user_id=0`, full observability.
|
||||
- Failure → `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` then `close`.
|
||||
- **Server filter (user topics)**: `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `auto.spin.progress` only if `data.user_id` equals connection `user_id`. Others broadcast by subscription. Admin mode not filtered.
|
||||
- **Idle timeout**: no uplink (incl. `ping`/`subscribe`) for 60s → server `close`.
|
||||
- **Log channel `ws`**: `runtime/logs/ws.log` (7 days): enqueue, dispatch, handshake, subscribe, pong, idle close, send failures.
|
||||
- **Subscribe required**: after connect, only handshake + subscribed topics; no `subscribe` → no `period.tick` etc.
|
||||
|
||||
### 7.1 WebSocket Connection & Messages
|
||||
|
||||
- **URL**: §7.0 (`H5_WEBSOCKET_URL` or admin `ws_url`)
|
||||
- **Client**: browser `WebSocket` (`ws://` / `wss://`)
|
||||
- **Required query (2026-05+)**:
|
||||
- **H5/mobile**: `auth-token` + `user-token`. Optional: `device_id`, `lang`.
|
||||
- **Admin**: `admin-ws-token` (in `ws_url`).
|
||||
- Examples:
|
||||
- H5: `wss://ws.example.com/ws/?auth-token=xxx&user-token=yyy&device_id=ios_001&lang=zh`
|
||||
- Admin: `wss://ws.example.com/ws/?admin-ws-token=zzz`
|
||||
- Missing/invalid → `ws.error` `1101` then close.
|
||||
- **First frame on connect**:
|
||||
- `event`: `ws.connected`
|
||||
- `message`: `WebSocket connected`
|
||||
- `connection_id`, `mode` (`mobile` | `admin`; **no** `user_id`)
|
||||
- `server_time` (seconds, int)
|
||||
- `heartbeat_interval` (30)
|
||||
- `idle_timeout` (60; send `ping` within `idle_timeout - heartbeat_interval`)
|
||||
- **Error frames** (not HTTP `code` band):
|
||||
- Bad JSON: `ws.error`, `Invalid JSON payload`
|
||||
- Unknown `action`: `ws.error`, `Unsupported action`, maybe `received_action`
|
||||
- Process error: `ws.error`, `Server internal error`, Workerman `code`/`detail`
|
||||
- **Suggested messages**:
|
||||
- Heartbeat: `{"action":"ping"}`
|
||||
- Pong: `{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}` (**string** local time; business pushes often use int seconds—branch in client)
|
||||
- Subscribe: `{"action":"subscribe","topics":["period.tick"]}`
|
||||
- Streak/odds: `["user.streak","wallet.changed","bet.accepted"]`
|
||||
- Funds: `["bet.accepted","wallet.changed"]`
|
||||
- Auto spin: `["auto.spin.progress","wallet.changed"]`
|
||||
- Recommended mobile set: `period.tick`, `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `period.opened`, `period.payout`, `jackpot.hit`
|
||||
|
||||
#### 7.1.1 Message Protocol Fields
|
||||
|
||||
- Client → server:
|
||||
- `action`: `ping` / `subscribe`
|
||||
- `topics`: required for `subscribe` (array)
|
||||
- Server → client:
|
||||
- `event`, `topic`, `data`, `server_time` (seconds)
|
||||
|
||||
#### 7.1.2 Subscribe Behavior
|
||||
|
||||
- **Connection alone does not stream all business events**; send `subscribe`.
|
||||
- Success: `{"event":"ws.subscribed","topics":[...]}` (deduped, sorted).
|
||||
- **`subscribe` replaces** the whole subscription set (not append).
|
||||
- Without subscription: handshake + `pong` only.
|
||||
- **Server filters by bound user** on mobile; outbound **`data` has no `user_id`** (and other sensitive fields).
|
||||
- **Outbound redaction (2026-05)**: removes `user_id`, `uuid`, `phone`, `balance_before`, `channel_id`; `jackpot.hit` `hits[]` keeps `nickname`, `period_no`, `total_win`, `result_number`, etc.
|
||||
- **No** full `streak_win_reward` table; odds via `user.streak` / `wallet.changed` / `bet.accepted` and `lobbyInit.user_snapshot`.
|
||||
|
||||
#### 7.1.2A Streak Odds & Streak Count (WebSocket)
|
||||
|
||||
- **`user.streak`** (after settlement; current player odds):
|
||||
- `data.current_streak`, `streak_level`, `odds_factor`, `is_jackpot`
|
||||
- **`wallet.changed` / `bet.accepted` / `bet.win`**: merge **`current_streak`**, **`streak_level`**, **`odds_factor`**, **`is_jackpot`** (no `user_id`).
|
||||
- **`bet.accepted` vs `bet.win` `is_jackpot`**: `bet.accepted` = tier at bet time; **`bet.win` authoritative** for settlement jackpot tier (`streak_at_bet` → `streak_win_reward.is_jackpot=true`, usually tier 10).
|
||||
|
||||
#### 7.1.3 Push Frequency & Triggers (Current)
|
||||
|
||||
- `period.tick`: **only when `status ∈ {betting, locked}`**, once per second (no full odds table).
|
||||
- **Payout silence**: no `period.tick` during `status=payouting`; instead **`period.payout.tick`** per second with `payout_remaining_seconds` / `payout_until`. Wins via `period.opened` / `period.payout` / **`bet.win`** / `jackpot.hit` / `wallet.changed(biz_type=payout)`.
|
||||
- **Boundary (once per period)**:
|
||||
- `status=finished`: one frame when payout grace ends.
|
||||
- `status=void`: one frame on void.
|
||||
- **Resume**: new period `betting` restarts ticks.
|
||||
- Dedup: Redis `dfw:v1:ws:tick:boundary:{period_no}:{status}` (TTL 300s).
|
||||
- `user.streak`: per user after settlement (may reset to 0).
|
||||
- `admin.live.snapshot`: every second (admin live page; not affected by payout silence).
|
||||
- `period.opened` / `period.payout` / `admin.live.opened`: event-driven.
|
||||
- `wallet.changed`: on balance change; payout includes `amount`, `period_no`, `period_id`, `result_number`.
|
||||
- **`bet.win` (wins this period, small + jackpot)**:
|
||||
- After settlement, aggregated per winning user (same batch as `wallet.changed(payout)`); **personal win UI listens here**; style via `data.is_jackpot`. Jackpot users still get `bet.win`; don’t rely only on `jackpot.hit`.
|
||||
- Fields: `period_id`, `period_no`, `result_number`, `total_win`, `balance_after`, `bets[]` `{ bet_id, win_amount }`, **`is_jackpot`**, **`is_win`** (`true`), **`payout_pending_review`** (jackpot admin review pending), merged streak fields, `server_time`.
|
||||
- Dedup: `dfw:v1:ws:betwin:{period_id}:{user_id}` (86400s), separate from `dfw:v1:settle:notify:{period_id}`. Payload rebuilt from DB settled orders if memory aggregate empty.
|
||||
- **Compensation**: `buildBetWinPayloadsFromSettledOrders` if needed.
|
||||
- After **`approveJackpot`**: pushes `bet.win` again post-credit.
|
||||
- **`jackpot.hit` (public jackpot broadcast)**:
|
||||
- After **`bet.win`** in same batch, if jackpot-tier hits exist, one public frame for marquee; else skip. **Personal UI still `bet.win`**.
|
||||
- Order: `bet.win` per user → `jackpot.hit` if applicable.
|
||||
- `hits[]`: `nickname`, `period_no`, `total_win`, `result_number` (no `user_id`).
|
||||
|
||||
#### 7.1.4 Settlement Push Dedup & Ops Republish (2026-05)
|
||||
|
||||
| Redis Key | Purpose |
|
||||
|-----------|---------|
|
||||
| `dfw:v1:settle:notify:{period_id}` | Once per period: `user.streak` / settlement-batch `wallet.changed` / `jackpot.hit` |
|
||||
| `dfw:v1:ws:betwin:{period_id}:{user_id}` | Once per user per period: `bet.win` |
|
||||
|
||||
**Ops republish** (settled win but client missed `bet.win`):
|
||||
|
||||
```bash
|
||||
php scripts/republish_bet_win.php --play-record-id=1370
|
||||
php scripts/republish_bet_win.php --period-id=123
|
||||
php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1
|
||||
# force ignore dedup: add --force
|
||||
```
|
||||
|
||||
Integration: `php scripts/verify_ws_topic_subscribe.php`.
|
||||
|
||||
### 7.1A Admin Connection (Ops Integration)
|
||||
|
||||
- Menus: `连接服务器websocket` (test), **Game Management → Live Game** (`/admin/game/live`)
|
||||
- WS config:
|
||||
- `/admin/test.GameCurrentStatus/wsConfig`
|
||||
- `/admin/game.Live/wsConfig` (auto subscribe incl. `admin.live.snapshot`, `bet.win`)
|
||||
- **HTTP snapshot**: `GET /admin/game.Live/snapshot` read-only (`buildSnapshot`); **no** `recoverLiveRoundState` / auto-draw on this endpoint; progression via **`gameLiveTicker`** and draw flow.
|
||||
- Admin page: `ws_url`, `connect_tip`, `sample_messages`; connect/disconnect; manual subscribe/ping; live frame log.
|
||||
|
||||
### 7.2 HTTP Fallback APIs
|
||||
|
||||
- Removed: `/api/game/currentStatus`, `/api/game/periodHistory`, `/api/wallet/balanceSummary`.
|
||||
- State/balance primarily WebSocket; HTTP for actions/details (`placeBet`, `depositDetail`, `withdrawDetail`).
|
||||
|
||||
### 7.3 Consistency Rules
|
||||
|
||||
- Countdown from server time, not local clock drift.
|
||||
- After bet, trust `placeBet.balance_after` and `wallet.changed`.
|
||||
- On disconnect, reconnect and resubscribe; no `currentStatus`/`periodHistory`/`balanceSummary` backfill.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile End-to-End Call Flows
|
||||
|
||||
## 8.1 First Game Entry
|
||||
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` → `auth-token` (stable `device_id` per install)
|
||||
2. `POST /api/user/login` (headers `auth-token`; after login also `user-token`)
|
||||
3. `POST /api/game/lobbyInit` (headers `auth-token` + `user-token`)
|
||||
4. WebSocket base URL (**not in lobbyInit today**: ops/bundle `H5_WEBSOCKET_URL` or custom config) → connect, **send `subscribe` immediately** (§7.0/7.1; **include `bet.win`**)
|
||||
5. `POST /api/game/placeBet`
|
||||
6. Balance: `placeBet.balance_after` + `wallet.changed`; after draw, **`bet.win`** (`is_win=true`), jackpot style via `data.is_jackpot` (no `user_id` in payload)
|
||||
7. On disconnect/foreground, reconnect and resubscribe
|
||||
8. User logout: `POST /api/user/logout` (headers `auth-token` + `user-token`, optional body `refresh_token`); clear local tokens and close WebSocket
|
||||
|
||||
## 8.2 Deposit → Bet → Withdraw
|
||||
1. `POST /api/finance/depositTierList` (pick tier + `channels[].code`)
|
||||
2. `POST /api/finance/depositCreate` (`tier_id` + **`channel_code=ddpay`** + `idempotency_key` + DDPay fields; JSON / form-data / urlencoded)
|
||||
- `paid=false`, `status=pending`, non-empty `pay_url`: open **`pay_url`** (DDPay `payment_url`); credit via **`ddpayDepositNotify`**; poll `depositDetail` or `wallet.changed`
|
||||
3. Optional `POST /api/finance/depositDetail`
|
||||
4. `POST /api/game/placeBet`
|
||||
5. `wallet.changed` or order detail
|
||||
6. `POST /api/wallet/recordList`
|
||||
7. `POST /api/finance/withdrawCreate` (immediate freeze) → `POST /api/finance/withdrawDetail`
|
||||
|
||||
## 8.3 Popout Notice Flow
|
||||
1. Client listens `notice.popout`
|
||||
2. `GET /api/notice/noticeList` (includes `content`, `must_confirm`)
|
||||
3. User confirms `GET /api/notice/noticeConfirm?notice_id=...`
|
||||
4. Frontend may block bet until confirmed
|
||||
|
||||
---
|
||||
|
||||
## 9. Game Sequence (WebSocket + HTTP)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User login /api/user/login] --> B[Lobby init /api/game/lobbyInit]
|
||||
B --> C[Connect WebSocket and subscribe]
|
||||
C --> D{0-20s betting window?}
|
||||
D -- yes --> E[Place bet /api/game/placeBet]
|
||||
E --> F[Wait wallet.changed for balance]
|
||||
D -- no --> G[Lock and draw phase]
|
||||
G --> H[Server tally and draw]
|
||||
H --> I[WebSocket: period.opened / bet.win / wallet.changed etc.]
|
||||
I --> J[Reconnect and resubscribe on disconnect]
|
||||
J --> C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Admin Agent Commission Config (Admin Supplement, 2026-05-29)
|
||||
|
||||
> Commission rates are maintained in **Administrator Management** `/admin/auth/admin`, not via the channel page “share ratio” dialog.
|
||||
|
||||
### 10.1 Page & Display
|
||||
|
||||
- **Tree table** by `parent_admin_id` (role-group style); expand/collapse
|
||||
- Columns: username, nickname, **channel**, **parent agent**, **commission share (%)**, role group, invite code, status, etc.
|
||||
- Super-admin public search: **channel dropdown filter**
|
||||
- Non-super-admin: **self and all downline agents only**; no peers under other channel roots
|
||||
|
||||
### 10.2 Form Fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `channel_id` | Channel (super-admin selectable; others follow role/account) |
|
||||
| `parent_admin_id` | Parent agent; **top-level role group** (`admin_group.pid=0`): empty and disabled |
|
||||
| `commission_share_rate` | **Top-level role group**: share of channel period total (0–100), required; **sub-agent**: share from parent’s net amount, required when parent set |
|
||||
| `group_arr` | Role group (single select, permissions; top-level when `pid=0`) |
|
||||
|
||||
### 10.3 Validation & Hints
|
||||
|
||||
**Top-level role group:**
|
||||
|
||||
- Parent agent field disabled with hint “no parent required”
|
||||
- Share rate required; amount = **channel period total × this rate**
|
||||
- Under same `channel_id`, top-level agents’ rates **≤ 100% total**
|
||||
- **GET** `/admin/auth.Admin/commissionShareRemainder?is_top_level=1&channel_id=&exclude_id=`
|
||||
- Role group linkage: **GET** `/admin/auth.Admin/groupMeta?group_id=`
|
||||
|
||||
**Sub-agent:**
|
||||
|
||||
- Under same `parent_admin_id`, sum of enabled children’s `commission_share_rate` **≤ 100%**
|
||||
- **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` for remaining allocatable %
|
||||
- At 100% total: hint that parent retains no share at this level
|
||||
|
||||
### 10.4 Settlement Split (Channel Settlement Integration)
|
||||
|
||||
- After channel settlement total commission, **`AdminCommissionDistributionService`** allocates to top-level agents by `commission_share_rate`, then splits recursively downline
|
||||
- Each admin’s net amount → `agent_commission_record` and **immediate credit** to `admin_wallet`
|
||||
- At least one channel root agent required; else settlement fails
|
||||
|
||||
### 10.5 Legacy APIs (Deprecated in UI; Do Not Integrate)
|
||||
|
||||
- ~~GET `/admin/channel/channelAdminShareList`~~
|
||||
- ~~POST `/admin/channel/saveChannelAdminShare`~~
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Implementation Decisions (Before API Development)
|
||||
|
||||
1. **Login**: password only, or SMS/email OTP?
|
||||
2. **Withdraw receive types**: bank only v1, or e-wallet/crypto too?
|
||||
3. **Auto spin**: ship in v1 or hide `auto-bet` APIs?
|
||||
4. **WebSocket topics**: fix topic names and payloads per this doc?
|
||||
5. **Error codes**: company-wide table to align with this draft?
|
||||
|
||||
After confirmation: implement controllers + validate + service + routes per this document.
|
||||
82
docs/en/channel-admin-guide.md
Normal file
82
docs/en/channel-admin-guide.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Channel Management (Admin)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Documents the **Channel Management** page (`/admin/channel`): summary cards, list scope, button permissions, bet-record dialogs, and APIs. For commission calculation and agent tree split, see [commission-share-guide.md](./commission-share-guide.md).
|
||||
|
||||
---
|
||||
|
||||
## 2. Summary cards
|
||||
|
||||
| Card | Description |
|
||||
|------|-------------|
|
||||
| Total channels | Count in **readable scope** |
|
||||
| Enabled | `status = 1` |
|
||||
| Pending dividend (count) | `carryover_balance > 0` |
|
||||
| Pending dividend (amount) | Sum of those balances |
|
||||
| Paid dividend | Sum of paid `agent_commission_record` in scope; clickable dialog requires `channel/index` |
|
||||
| Company total bet | Total bets in readable scope; clickable records dialog requires `channel/index` |
|
||||
|
||||
List filters: **All / With balance / No balance / Enabled only / Disabled only** (UI search only).
|
||||
|
||||
---
|
||||
|
||||
## 3. Read scope
|
||||
|
||||
`AdminChannelScopeService` applies to list and stats:
|
||||
|
||||
**Bound channel** (read + write): `admin.channel_id` and/or any role group `channel_id` → only those channels.
|
||||
|
||||
**All channels** (read + write): super admin, **or** no channel binding **and** any channel module permission (`channel/index`, `channel/edit`, etc.).
|
||||
|
||||
**No access**: unbound and no channel module permissions → empty list.
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions & permissions
|
||||
|
||||
| Node | Label | Behavior |
|
||||
|------|-------|----------|
|
||||
| `channel/index` | View | List, stat card clicks, bet/dividend record dialogs |
|
||||
| `channel/manualSettle` | Manual settle | Preview + submit (readable channel) |
|
||||
| `channel/batchSettlePending` | Batch settle | Writable enabled channels in scope |
|
||||
|
||||
Re-login after role changes to refresh `authNode`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual settlement
|
||||
|
||||
- `GET /admin/channel/manualSettlePreview?id=`
|
||||
- `POST /admin/channel/manualSettle`
|
||||
- Super admin **or** `channel/manualSettle` with channel in read scope
|
||||
- Same payout flow as super-admin settle (`ChannelSettlementService::settleBySuperAdmin`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Bet record dialogs
|
||||
|
||||
| Entry | API | Data |
|
||||
|-------|-----|------|
|
||||
| Direct bet amount (column) | `directBetRecordList` | All play records for that channel |
|
||||
| Company total bet (top card) | `companyBetRecordList` | All play records in readable scope |
|
||||
|
||||
**Filters (GET):** `period_no`, `user_keyword`, `result_number`, `pick_number`, `win_hit` (`won`/`lost`/`pending`), `page`, `limit`.
|
||||
|
||||
**Columns:** period, player, channel, picks, winning number, win status, bet amount, win amount.
|
||||
|
||||
**Mobile:** ~92% width, scrollable body, 3 summary cards per row, horizontal table scroll.
|
||||
|
||||
---
|
||||
|
||||
## 7. APIs
|
||||
|
||||
See §8 in the Chinese doc `docs/渠道管理后台说明.md` (same paths under `/admin/channel/`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog
|
||||
|
||||
| Date | Note |
|
||||
|------|------|
|
||||
| 2026-05-30 | Bet record columns, filters, mobile layout; `viewAllChannels`; manual settle by permission |
|
||||
144
docs/en/commission-share-guide.md
Normal file
144
docs/en/commission-share-guide.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Commission Share Guide
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document describes the end-to-end flow **channel commission → agent tree split → admin wallet credit**, so operations, finance, and engineering share the same configuration and settlement rules.
|
||||
|
||||
---
|
||||
|
||||
## 2. Overall Structure (Two Layers + Tree)
|
||||
|
||||
Commission is calculated in two steps:
|
||||
|
||||
### A. Layer 1: Channel settlement (platform → channel)
|
||||
|
||||
- Compute **channel total commission** from `channel.agent_mode`:
|
||||
- **turnover**: base = sum of settled bet amounts; commission = total bet × `turnover_share_rate`
|
||||
- **affiliate**: base = platform profit after cost deduction; commission = base × ladder share rate
|
||||
- Data scope: `bet_order.status = 2` (settled), period = **last settlement end ~ current settlement time**
|
||||
- Output: `agent_settlement_period` snapshot
|
||||
|
||||
### B. Layer 2: Agent tree split (channel total → each admin’s net amount)
|
||||
|
||||
- **Do not** configure flat channel-wide shares on the channel page (`channel_admin_share` is deprecated in UI; table may remain for history)
|
||||
- Maintain the agent tree in **Administrator Management** (`/admin/auth/admin`):
|
||||
- `parent_admin_id`: parent agent (**required for non–top-level role groups**; **disabled and empty when role group `admin_group.pid = 0`**)
|
||||
- `commission_share_rate`:
|
||||
- **Top-level role group**: share (%) of **channel period total commission**; amount = channel period total × this rate
|
||||
- **Sub-agent**: share (%) taken from **parent’s commission for this period**
|
||||
- At settlement, `AdminCommissionDistributionService` splits recursively:
|
||||
1. Channel total commission is allocated to **top-level agents** by their `commission_share_rate` (sum per channel ≤ 100%; legacy equal split if rates missing)
|
||||
2. Each agent allocates to direct children by `commission_share_rate` from **their own received amount**
|
||||
3. **Parent keeps** = received amount − sum allocated to children
|
||||
|
||||
**Example** (parent receives 3000 this period):
|
||||
|
||||
| Sub-agent | Rate | Amount |
|
||||
|-----------|------|--------|
|
||||
| Sub-agent A | 20% | 600 |
|
||||
| Sub-agent B | 40% | 1200 |
|
||||
| Parent | — | 1200 (3000 − 600 − 1200) |
|
||||
|
||||
If a sub-agent has further downline, the same rules apply on **their received amount**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration & Permissions
|
||||
|
||||
| Capability | Entry | Notes |
|
||||
|------------|-------|-------|
|
||||
| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, etc.; see [channel-admin-guide.md](./channel-admin-guide.md) |
|
||||
| Agent tree & share rates | `/admin/auth/admin` | Tree list; parent agent, share rate, channel |
|
||||
| Channel filter | Admin list common search | Super admin can filter by channel |
|
||||
| Channel list scope | `/admin/channel` | Super admin / unbound + channel module perms → all channels; bound → bound channels only |
|
||||
| Visibility | Admin list | Non–super admin sees **self + all downline** only |
|
||||
| Settlement | `/admin/channel` manual / cron | Super admin **or** `channel/manualSettle`; batch still super admin only |
|
||||
|
||||
### 3.1 Share rate validation
|
||||
|
||||
**Top-level role group** (`admin_group.pid = 0`):
|
||||
|
||||
- No parent agent required or allowed
|
||||
- `commission_share_rate` is required: share of **channel period total commission**
|
||||
- Under the same `channel_id`, top-level agents’ rates **must not exceed 100% in total**
|
||||
- Form shows remaining allocatable share on the channel
|
||||
|
||||
**Sub-agent** (non–top-level role group):
|
||||
|
||||
- `parent_admin_id` required
|
||||
- `commission_share_rate` is share from **parent’s net amount this period**
|
||||
- Under the same `parent_admin_id`, enabled sub-agents’ rates **must not exceed 100% in total**
|
||||
- Form shows remaining allocatable share under the parent
|
||||
|
||||
If a level totals 100%, the parent at that level keeps **no commission**.
|
||||
|
||||
### 3.2 Role groups
|
||||
|
||||
- `admin_group` is for **menu/data permissions only**
|
||||
- **Not** used in amount calculation
|
||||
|
||||
---
|
||||
|
||||
## 4. Settlement Flow
|
||||
|
||||
1. Super admin or holder of `channel/manualSettle` triggers settlement (manual or cron; batch API still super admin only)
|
||||
2. `ChannelSettlementService::buildSettlePayload` aggregates bets and computes channel total commission
|
||||
3. `AdminCommissionDistributionService::distributeChannelCommission` splits by agent tree
|
||||
4. In one transaction:
|
||||
- Insert `agent_settlement_period` (`status = 2` completed)
|
||||
- Insert `agent_commission_record` per admin with amount > 0 (`status = 1` paid)
|
||||
- `AdminWalletService::creditCommission` → `admin_wallet` + `admin_wallet_record` (`biz_type = commission_income`)
|
||||
5. Reset `channel.carryover_balance` to 0 (settle-and-pay; no channel pending pool)
|
||||
|
||||
**Prerequisite**: at least one **top-level agent** for the channel (`channel_id` match, `parent_admin_id` empty); otherwise settlement fails.
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommended wording (operations / stakeholders)
|
||||
|
||||
1. **Channel commission**: computed from settled bets for the period (**not** from deposit volume).
|
||||
2. **Distribution**: total commission enters the channel top agent, then splits down by **parent/child and share rates** in Administrator Management; parent keeps the remainder after paying children.
|
||||
3. **Role groups**: permissions only, not money.
|
||||
4. **Traceability**: each `agent_commission_record` links to `agent_settlement_period`; wallet `commission_income` traces back to commission records.
|
||||
|
||||
---
|
||||
|
||||
## 6. Core fields
|
||||
|
||||
| Table / field | Role |
|
||||
|---------------|------|
|
||||
| `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | Channel commission calculation |
|
||||
| `admin.parent_admin_id` | Parent agent |
|
||||
| `admin.channel_id` | Channel |
|
||||
| `admin.commission_share_rate` | Top-level role group: share of channel period total (%); sub-agent: share from parent (%) |
|
||||
| `agent_settlement_period` | Settlement period snapshot |
|
||||
| `agent_commission_record` | Paid commission per admin |
|
||||
| `admin_wallet` / `admin_wallet_record` | Admin wallet & ledger |
|
||||
|
||||
> **Legacy** `channel_admin_share`: flat 100% split (2026-04-18); tree split since 2026-05-29. Do not configure via this table.
|
||||
|
||||
---
|
||||
|
||||
## 7. Related code
|
||||
|
||||
| Module | Path |
|
||||
|--------|------|
|
||||
| Channel settlement | `app/common/service/ChannelSettlementService.php` |
|
||||
| Channel read scope | `app/common/service/AdminChannelScopeService.php` |
|
||||
| Channel admin UI | `app/admin/controller/Channel.php` |
|
||||
| Tree split | `app/common/service/AdminCommissionDistributionService.php` |
|
||||
| Admin CRUD / validation | `app/admin/controller/auth/Admin.php` |
|
||||
| Admin UI | `web/src/views/backend/auth/admin/` |
|
||||
| Auto settlement | `app/process/ChannelAutoSettleTicker.php` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-04-18 | `channel_admin_share` flat split; removed `admin`/`admin_group.commission_rate` |
|
||||
| 2026-04-23 | Settle-and-pay to admin wallet; `admin_wallet` system |
|
||||
| 2026-05-29 | **Agent tree commission** in Administrator Management; removed channel share UI; tree list & downline visibility |
|
||||
| 2026-05-29 | Top-level role groups (`pid=0`) require channel share rate; parent agent disabled in form |
|
||||
| 2026-05-30 | Channel UI: view-all, bet/dividend dialogs, filters, mobile; manual settle by permission; `channel-admin-guide.md` |
|
||||
@@ -70,6 +70,8 @@
|
||||
2. **总代拓客**:总代登录 Agent Portal,生成自己的推广链接发给散户;或者生成【子代专属邀请码】发展下级代理。
|
||||
3. **分配比例**:总代在给子代开户时,将自己手中 80% 的分红权,划拨一部分(如 70%)给子代,自己赚取 10% 的级差。
|
||||
|
||||
> **系统实现(2026-05-29)**:在后台 **管理员管理**(`/admin/auth/admin`)为子代理设置 **上级代理** 与 **分红比例(%)**;渠道结算后从上级实得中按该比例向子代理拆分,上级保留剩余部分。同一上级下子代理比例合计不得超过 100%。详见 `docs/分红说明文档.md`。
|
||||
|
||||
### 2.2 🌟 核心分佣结算流程(流水占比分桶模式)
|
||||
系统设定为每周一凌晨(或每日)自动执行结算脚本:
|
||||
|
||||
|
||||
243
docs/分红说明文档.md
243
docs/分红说明文档.md
@@ -1,182 +1,145 @@
|
||||
# 分红说明文档(现状分析 + 简化设计建议)
|
||||
# 分红说明文档
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文用于回答两个问题:
|
||||
|
||||
1. 甲方管理员是否容易看懂当前分红方式?
|
||||
2. 是否有更简单、更容易执行和对账的分红设计?
|
||||
|
||||
并给出推荐方案与落地路径。
|
||||
说明当前系统中 **渠道分红 → 代理树形拆分 → 管理员钱包入账** 的完整口径,便于运营、财务与开发对齐配置方式与结算行为。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前分红设计现状(按代码实际行为)
|
||||
## 2. 总体结构(两层 + 树形)
|
||||
|
||||
## 2.1 配置层面(看起来是三级)
|
||||
分红分为两个明确步骤:
|
||||
|
||||
历史上后台有三处比例字段(其中后两项已下线):
|
||||
### A. 一级:渠道结算(平台 → 渠道)
|
||||
|
||||
- `channel`:渠道分红参数(`turnover_share_rate`、`affiliate_*` 等)
|
||||
- `admin_group`:角色组 `commission_rate`(已删除)
|
||||
- `admin`:管理员 `commission_rate`(已删除)
|
||||
- 按渠道配置的 `agent_mode` 计算本期 **渠道总佣金**:
|
||||
- **turnover(流水/返水)**:基数 = 已结算注单总投注,佣金 = 总投注 × `turnover_share_rate`
|
||||
- **affiliate(联营)**:基数 = 平台盈亏扣成本后金额,佣金 = 基数 × 阶梯占成
|
||||
- 统计范围:`bet_order.status = 2`(已结算),周期为 **上次结算结束时间 ~ 本次结算时刻**
|
||||
- 产出:`agent_settlement_period`(结算周期快照)
|
||||
|
||||
从“配置界面”角度,容易被理解为:
|
||||
**渠道比例 -> 角色组比例 -> 管理员比例** 的三级链路。
|
||||
### B. 二级:代理树拆分(渠道总佣金 → 各管理员实得)
|
||||
|
||||
## 2.2 结算层面(实际执行是单层)
|
||||
- **不再**在渠道管理页维护「渠道内管理员分配比例」(`channel_admin_share` 已废弃于 UI,历史表可保留)
|
||||
- 在 **管理员管理**(`/admin/auth/admin`)维护代理树:
|
||||
- `parent_admin_id`:上级代理(**非顶级角色组**必填;**顶级角色组** `admin_group.pid=0` 时留空且不可选)
|
||||
- `commission_share_rate`:
|
||||
- **顶级角色组**:从 **渠道本期总佣金** 中分得的比例(%);实得 = 渠道本期总佣金 × 本项比例
|
||||
- **子代理**:从 **上级本期分红** 中抽取的比例(%)
|
||||
- 结算时由 `AdminCommissionDistributionService` 递归拆分:
|
||||
1. 渠道总佣金按各 **顶级代理** 的 `commission_share_rate` 划入(同渠道合计 ≤ 100%;未配置比例时历史数据均分兜底)
|
||||
2. 每个代理按直属子代理的 `commission_share_rate`,从 **自己拿到的金额** 中划出子代理份额
|
||||
3. **上级保留** = 自己拿到的金额 − 已分给所有子代理的金额
|
||||
|
||||
当前手动结算逻辑在 `Channel` 控制器中:
|
||||
**示例**(上级本期拿到 3000):
|
||||
|
||||
- 按渠道下注汇总计算 `total_bet_amount / platform_profit_amount`
|
||||
- 根据 `channel.agent_mode` 计算佣金基数与佣金比例
|
||||
- 生成 `agent_settlement_period` 与 `agent_commission_record`
|
||||
| 子代理 | 配置比例 | 实得 |
|
||||
|--------|----------|------|
|
||||
| 子代理 A | 20% | 600 |
|
||||
| 子代理 B | 40% | 1200 |
|
||||
| 上级自身 | — | 1200(3000 − 600 − 1200) |
|
||||
|
||||
关键点:**佣金记录只写了一条 admin_id(渠道下首个管理员)**,并未按角色组/管理员比例拆分多条佣金明细。
|
||||
也就是说,目前“角色组比例、管理员比例”主要在新增/编辑时做约束校验,但**没有进入最终结算分账公式**。
|
||||
若子代理还有下级,在其 **实得金额** 上继续按同样规则向下拆分。
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前方案对甲方可理解性的评估
|
||||
## 3. 配置入口与权限
|
||||
|
||||
结论:**一般不容易看懂,且容易产生“配置了却不生效”的认知落差。**
|
||||
| 能力 | 入口 | 说明 |
|
||||
|------|------|------|
|
||||
| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) |
|
||||
| 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 |
|
||||
| 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 |
|
||||
| 渠道列表可见范围 | `/admin/channel` | 绑定渠道 → 仅该渠道;未绑定且有渠道模块权限 → 全平台;超管不限 |
|
||||
| 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 |
|
||||
| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**或 `channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` |
|
||||
|
||||
主要原因:
|
||||
### 3.1 分红比例校验
|
||||
|
||||
1. **心智复杂**:甲方要同时理解渠道模式(返水/联营)、角色组比例、管理员比例三层规则。
|
||||
2. **口径不一致**:界面上有三级比例,但结算时是渠道单层落账,容易质疑“为什么不是按我配的三级比例发放”。
|
||||
3. **对账难**:财务看到佣金记录时只能看到渠道维度,不容易反推角色组和管理员分配关系。
|
||||
4. **运维负担高**:层级变化(调组、换管理员)后,历史解释成本高。
|
||||
**顶级角色组**(`admin_group.pid = 0`):
|
||||
|
||||
- 无需、不可绑定上级代理
|
||||
- 须填写 `commission_share_rate`,表示从 **渠道本期总佣金** 中分得的比例
|
||||
- 同一 `channel_id` 下,所有无上级顶级代理的比例 **合计 ≤ 100%**
|
||||
- 表单提示同渠道剩余可分配比例
|
||||
|
||||
**子代理**(非顶级角色组):
|
||||
|
||||
- 须绑定 `parent_admin_id`
|
||||
- `commission_share_rate` 表示从 **上级本期实得** 中抽取的比例
|
||||
- 同一 `parent_admin_id` 下,启用子代理比例 **合计 ≤ 100%**
|
||||
- 表单提示同上级剩余可分配比例
|
||||
|
||||
若某层合计为 100%,该层上级 **不再保留分红**。
|
||||
|
||||
### 3.2 角色组的作用
|
||||
|
||||
- `admin_group` **只负责**菜单权限、数据范围、角色隔离
|
||||
- **不参与**金额运算
|
||||
|
||||
---
|
||||
|
||||
## 4. 更简单的分红设计方式(推荐)
|
||||
## 4. 结算执行流程
|
||||
|
||||
## 4.1 推荐方案:`渠道结算 + 渠道内二次分配`(两层)
|
||||
1. 超管或具备 `channel/manualSettle` 的管理员触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务;批量接口仍仅超管)
|
||||
2. `ChannelSettlementService::buildSettlePayload` 汇总注单并计算渠道总佣金
|
||||
3. `AdminCommissionDistributionService::distributeChannelCommission` 按代理树拆分各管理员实得
|
||||
4. 事务内写入:
|
||||
- `agent_settlement_period`(`status = 2` 已完成)
|
||||
- 多条 `agent_commission_record`(每个实得 > 0 的管理员一条,`status = 1` 已发放)
|
||||
- `AdminWalletService::creditCommission` → `admin_wallet` + `admin_wallet_record`(`biz_type = commission_income`)
|
||||
5. 渠道 `carryover_balance` 重置为 0(超管结算即发放,不再保留渠道待分红池)
|
||||
|
||||
将分红流程拆为两个明确步骤:
|
||||
|
||||
### A. 一级:渠道结算(平台对渠道)
|
||||
|
||||
- 保留当前 `channel.agent_mode` 的计算方式(turnover/affiliate)
|
||||
- 先得到一个渠道应发佣金:`channel_commission_amount`
|
||||
- 生成渠道结算单(当前已具备)
|
||||
|
||||
### B. 二级:渠道内分配(渠道对管理员)
|
||||
|
||||
- 不再使用“角色组分红比例”参与金额运算(角色组只负责权限)
|
||||
- 仅维护“渠道下管理员分配权重”(例如总和=100%)
|
||||
- 自动拆分:`admin_commission = channel_commission_amount × admin_weight`
|
||||
- 为每个管理员生成独立佣金记录(多条)
|
||||
|
||||
> 这样甲方只需要理解一句话:
|
||||
> **平台先算渠道总佣金,再按渠道内管理员权重拆分。**
|
||||
|
||||
## 4.2 为什么推荐该方案
|
||||
|
||||
1. **业务更直观**:甲方看“渠道总佣金 + 管理员占比”即可。
|
||||
2. **和现有代码贴近**:你们当前已经是“先算渠道佣金”,只需补“二次拆分”。
|
||||
3. **对账容易**:每个管理员佣金来源清晰,可直接汇总对账。
|
||||
4. **后续可扩展**:以后要按团队、按代理线拆分,可以在“二次分配”层升级,不影响一级结算。
|
||||
**前置条件**:渠道下须存在至少一名 **顶级代理**(`channel_id` 匹配且 `parent_admin_id` 为空);否则结算失败并提示「渠道未配置顶级代理管理员」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 备选方案对比
|
||||
## 5. 给甲方/运营的话术(推荐)
|
||||
|
||||
## 5.1 方案A(现状表象):渠道 -> 角色组 -> 管理员三级
|
||||
|
||||
- 优点:理论上精细
|
||||
- 缺点:配置理解成本高、维护复杂、容易和实际结算脱节
|
||||
- 适用:组织结构非常稳定、财务系统成熟的大盘
|
||||
|
||||
## 5.2 方案B(推荐):渠道 -> 管理员两级
|
||||
|
||||
- 优点:简单、易讲、易对账、改造成本低
|
||||
- 缺点:若强依赖“角色组抽成”会减少一层表达
|
||||
- 适用:当前阶段(快速上线、减少运营误解)
|
||||
|
||||
## 5.3 方案C(最简):仅渠道一级
|
||||
|
||||
- 优点:最简单
|
||||
- 缺点:无法直接落地到管理员收益,不利于激励
|
||||
- 适用:仅用于统计,不用于发佣
|
||||
1. **渠道分红**:系统按渠道模式(返水或联营)自动计算该周期 **渠道总佣金**(基于玩家已结算注单,**不是**按充值金额)。
|
||||
2. **人员分配**:总佣金进入渠道顶级代理后,按 **管理员管理中配置的上下级与分红比例** 自动向下拆分;上级只保留分给下级后的余额。
|
||||
3. **角色组**:仅管权限,不管分钱。
|
||||
4. **结算可追溯**:每条 `agent_commission_record` 可关联 `agent_settlement_period`;钱包流水 `commission_income` 可反查佣金记录。
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的产品口径(给甲方的话术)
|
||||
## 6. 核心数据字段
|
||||
|
||||
建议统一说明为:
|
||||
| 表/字段 | 作用 |
|
||||
|---------|------|
|
||||
| `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | 渠道总佣金计算参数 |
|
||||
| `admin.parent_admin_id` | 上级代理 |
|
||||
| `admin.channel_id` | 所属渠道 |
|
||||
| `admin.commission_share_rate` | 顶级角色组:从渠道本期总佣金分得比例(%);子代理:从上级分红抽取比例(%) |
|
||||
| `agent_settlement_period` | 结算周期与大盘快照 |
|
||||
| `agent_commission_record` | 各管理员本期实发佣金 |
|
||||
| `admin_wallet` / `admin_wallet_record` | 管理员钱包与入账流水 |
|
||||
|
||||
1. **渠道分红**:系统按渠道配置(返水或联营)自动计算该周期渠道总佣金。
|
||||
2. **人员分配**:渠道总佣金按“渠道内管理员分配比例”自动拆分。
|
||||
3. **角色组作用**:角色组只负责菜单权限与数据权限,不参与金额运算。
|
||||
4. **结算可追溯**:每条管理员佣金都能追溯到对应渠道结算单。
|
||||
> **历史表** `channel_admin_share`:2026-04-18 曾用于「渠道内 flat 100% 拆分」,2026-05-29 起结算改走代理树,后台入口已移除,请勿再依赖该表配置。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据与实现改造建议(按最小改动)
|
||||
## 7. 相关代码
|
||||
|
||||
## 7.1 数据字段建议
|
||||
|
||||
- 保留:`channel` 的分红计算参数(现有)
|
||||
- 建议新增(任选其一):
|
||||
- 在 `admin` 增加 `channel_share_rate`(该管理员在所属渠道的拆分比例)
|
||||
- 或新增 `channel_admin_share(channel_id, admin_id, share_rate)`
|
||||
|
||||
## 7.2 逻辑改造建议
|
||||
|
||||
1. 渠道结算得到 `commission_amount`
|
||||
2. 拉取该渠道有效管理员分配比例列表(总和=100%)
|
||||
3. 按比例拆分并批量写入 `agent_commission_record`
|
||||
4. 若分配比例未配置:
|
||||
- 方案一:默认100%给渠道负责人
|
||||
- 方案二:阻止结算并提示“请先配置渠道管理员分配比例”
|
||||
|
||||
## 7.3 风险控制建议
|
||||
|
||||
- 分配比例总和强校验(必须=100%)
|
||||
- 管理员离职/禁用时自动重算或禁止结算
|
||||
- 结算后锁单,后改比例不影响历史账单
|
||||
| 模块 | 路径 |
|
||||
|------|------|
|
||||
| 渠道结算 | `app/common/service/ChannelSettlementService.php` |
|
||||
| 渠道列表范围 | `app/common/service/AdminChannelScopeService.php` |
|
||||
| 渠道后台 | `app/admin/controller/Channel.php`、`web/src/views/backend/channel/` |
|
||||
| 树形拆分 | `app/common/service/AdminCommissionDistributionService.php` |
|
||||
| 管理员 CRUD / 校验 | `app/admin/controller/auth/Admin.php` |
|
||||
| 管理员前端 | `web/src/views/backend/auth/admin/` |
|
||||
| 自动结算 | `app/process/ChannelAutoSettleTicker.php` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 迁移策略建议
|
||||
|
||||
分三步上线:
|
||||
|
||||
1. **第1步(兼容期)**:保留现有字段,新增“渠道内管理员分配比例”配置
|
||||
2. **第2步(双轨期)**:结算时同时产出“旧口径结果 + 新口径预览”用于核对
|
||||
3. **第3步(切换期)**:正式切到“渠道+管理员两级”,角色组比例仅保留展示或下线
|
||||
|
||||
---
|
||||
|
||||
## 9. 最终建议结论
|
||||
|
||||
从“甲方能否看懂”和“系统可维护性”来看,建议采用:
|
||||
|
||||
**渠道结算 + 渠道内管理员二次分配(两级)**。
|
||||
|
||||
这是在你们当前代码基础上改造成本最低、解释成本最低、财务对账最清晰的方案。
|
||||
不建议继续强化“渠道->角色组->管理员”三级分红公式作为主线。
|
||||
|
||||
---
|
||||
|
||||
## 10. 本次已落地改造(2026-04-18)
|
||||
|
||||
已在系统中完成以下改造:
|
||||
|
||||
1. 新增渠道管理员分配表:`channel_admin_share`
|
||||
2. 下线并删除旧字段:`admin_group.commission_rate`、`admin.commission_rate`
|
||||
3. 新增后台接口:
|
||||
- `channelAdminShareList`:读取渠道管理员分配配置
|
||||
- `saveChannelAdminShare`:保存渠道管理员分配配置(启用项合计必须100%)
|
||||
4. 手动结算改造:
|
||||
- 先算渠道总佣金
|
||||
- 再按 `channel_admin_share` 比例拆分为多条 `agent_commission_record`
|
||||
- 结算预览支持查看拆分明细
|
||||
5. 渠道后台页面新增“分配比例”按钮与配置弹窗,用于维护管理员分配比例
|
||||
6. 角色组与管理员页面已移除旧分红比例字段展示与编辑项
|
||||
|
||||
说明:
|
||||
- 若未配置 `channel_admin_share`,系统会回退为“渠道首个管理员100%”以保证兼容历史流程。
|
||||
## 8. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-04-18 | 落地 `channel_admin_share` 渠道内 flat 拆分;移除 `admin`/`admin_group.commission_rate` |
|
||||
| 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 |
|
||||
| 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 |
|
||||
| 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 |
|
||||
| 2026-05-29 | **顶级角色组可配置渠道分红比例**:`pid=0` 时禁用上级代理并必填 `commission_share_rate`;结算按顶级比例划入后再向下拆分 |
|
||||
| 2026-05-30 | 渠道页:查看所有渠道、下注/分红记录弹窗与筛选、移动端适配;`manualSettle` 按按钮权限展示;文档 [渠道管理后台说明.md](./渠道管理后台说明.md) |
|
||||
|
||||
19
docs/后端.md
19
docs/后端.md
@@ -76,13 +76,18 @@
|
||||
* **提现手续费收益报表**:单独统计系统抽取的 0.5% 手续费总收入。
|
||||
|
||||
### 3.5 🏢 代理中枢与佣金结算系统 (Agent System)
|
||||
* **创建总代账号**:由于前端不支持散户变代理,只能由管理员在此处点击 `[新增总代 Master Agent]`,生成专属邀请链接。
|
||||
* **代理树状图 (Tree View)**:可视化查看整个多级代理层级及人数。
|
||||
* **佣金结算看板 (Commission Settlement)**:
|
||||
* 后端按周/月跑批处理脚本(Cron Job)。
|
||||
* 展示全平台周期总盈利(大盘盈亏)。
|
||||
* 列出各代理线的流水占比%、应得分红、GM盘口扣除利润、实际下放分红。
|
||||
* 提供 `[一键发佣]` 按钮,将佣金批量转入各代理的 Agent 钱包。
|
||||
* **创建总代/子代账号**:在 **管理员管理**(`/admin/auth/admin`)维护代理树:`parent_admin_id`、`commission_share_rate`(顶级角色组从渠道总佣金分得 %,子代理从上级实得抽取 %)、`channel_id`、邀请码。
|
||||
* **代理树状图 (Tree View)**:管理员列表以树形展示;非超管仅见本人及全部下级。
|
||||
* **渠道管理页**(`/admin/channel`):
|
||||
* 顶部统计:渠道数、待分红、已分红、**公司总投注额**(可点开全部下注记录);列表支持分红余额/启用状态筛选。
|
||||
* **数据范围**:`AdminChannelScopeService`;全平台只读条件见 `docs/渠道管理后台说明.md` §3。
|
||||
* **操作**:直属投注额列点击 / 公司总投注额卡片(弹窗 + 筛选);手动结算(超管或 `channel/manualSettle`)。
|
||||
* **渠道佣金结算**:
|
||||
* 按渠道 `agent_mode` 与已结算注单计算渠道总佣金(非充值口径)。
|
||||
* 按代理树拆分各管理员实得,写入 `agent_commission_record` 并 **即时入账** `admin_wallet`。
|
||||
* 支持周期自动结算(`ChannelAutoSettleTicker`)、手动提前结算;批量待结算仅超管。
|
||||
* **佣金结算看板**:`agent_settlement_period`、`agent_commission_record` 列表查询与对账。
|
||||
* 详细口径见 `docs/分红说明文档.md`;渠道页交互见 `docs/渠道管理后台说明.md`。
|
||||
|
||||
### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management)
|
||||
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:
|
||||
|
||||
165
docs/渠道管理后台说明.md
Normal file
165
docs/渠道管理后台说明.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 渠道管理后台说明
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
说明 **渠道管理**(`/admin/channel`)页面的统计卡片、列表数据范围、操作按钮权限、下注记录弹窗与相关接口,便于运营配置权限与开发联调。
|
||||
|
||||
分红计算与代理树拆分口径见 [分红说明文档.md](./分红说明文档.md)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 页面入口与统计卡片
|
||||
|
||||
| 卡片 | 说明 |
|
||||
|------|------|
|
||||
| 渠道总数 | 当前账号**可读范围**内渠道数量 |
|
||||
| 启用渠道 | `status = 1` 的渠道数 |
|
||||
| 待分红渠道 | `carryover_balance > 0` 的渠道数 |
|
||||
| 待分红总额 | 上述渠道 `carryover_balance` 合计 |
|
||||
| 公司总投注额 | 可读范围内全部玩家总投注(含未结算);**可点击**打开全部下注记录(需 `channel/index`) |
|
||||
| 已分红金额 | 可读范围内渠道下,已发放佣金(`agent_commission_record.status = 1`)合计;**可点击**打开已分红记录弹窗(需 `channel/index`) |
|
||||
|
||||
列表上方筛选:**全部 / 有分红余额 / 无分红余额 / 仅启用 / 仅停用**(前端 `search` 条件,不改变数据范围规则)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据可见范围(只读)
|
||||
|
||||
由 `app/common/service/AdminChannelScopeService.php` 统一判定,列表与统计均遵守:
|
||||
|
||||
**优先规则:只要绑定了渠道,就只能看绑定渠道**(即使勾选了「查看所有渠道」):
|
||||
|
||||
- 本人 `admin.channel_id` > 0,或
|
||||
- 任一所属角色组 `admin_group.channel_id` > 0
|
||||
|
||||
上述情况合并去重后作为可读渠道 ID 列表。
|
||||
|
||||
**全平台渠道范围**(读、写一致,须同时满足):
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| 超管 | 权限含 `*` |
|
||||
| 未绑定任何渠道 | 账号 `channel_id` 为空且所有角色组 `channel_id` 为空 |
|
||||
| 且拥有渠道模块基础权限 | 如 `channel/index`、`channel/edit` 等(含渠道菜单或任一 `channel/*` 按钮) |
|
||||
|
||||
未绑定渠道且无上述渠道权限时,渠道列表为空。
|
||||
|
||||
**读、写范围一致**:绑定渠道 → 仅绑定渠道;未绑定且有渠道模块权限 → 全部渠道(含新增/编辑/删除/手动结算写库)。
|
||||
|
||||
其它菜单(用户、充值/提现订单、游玩记录、控制台等)在只读全平台时同样可不按 `admin_id` 收窄,逻辑与 `Backend::hasGlobalReadScope()` 一致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表字段与操作
|
||||
|
||||
### 4.1 常用列
|
||||
|
||||
- 渠道标识、名称、代理模式、联营负结转、契约编号、结算周期等
|
||||
- **直属投注额**:该渠道名下全部玩家的总投注额(含未结算);**可点击**打开该渠道直属玩家游戏下注记录(需 `channel/index`)
|
||||
- 顶部统计卡片 **公司总投注额**:当前账号可见渠道范围内的真实码量合计(含未结算);**可点击**打开全部下注记录明细(需 `channel/index`)
|
||||
- 操作列:**手动结算**、编辑、删除(后两者受写权限约束)
|
||||
|
||||
### 4.2 操作按钮权限
|
||||
|
||||
| 按钮权限节点 | 名称 | 行为 |
|
||||
|--------------|------|------|
|
||||
| `channel/index` | 查看 | 列表、统计卡片点击、下注/分红记录弹窗 |
|
||||
| `channel/manualSettle` | 手动结算 | 操作列;预览并提交渠道结算(见 §5) |
|
||||
| `channel/batchSettlePending` | 一键批量结算 | 批量结算当前账号**可写范围**内启用渠道 |
|
||||
| `channel/add` / `edit` / `del` | 增删改 | 须对目标渠道具备写权限 |
|
||||
|
||||
角色组在 **权限管理 → 角色组** 中勾选对应按钮;保存后管理员需**重新登录**以刷新前端 `authNode`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 手动结算
|
||||
|
||||
- **接口**:`GET /admin/channel/manualSettlePreview?id={channelId}`、`POST /admin/channel/manualSettle`
|
||||
- **权限**:超管,或拥有 `channel/manualSettle` 且目标渠道在**可读范围**内
|
||||
- **逻辑**:与超管结算相同,调用 `ChannelSettlementService::settleBySuperAdmin`,结算即按代理树发放至 `admin_wallet`
|
||||
- **周期**:上次结算结束时间 ~ 当前时间;金额来自期内 **已结算** 游玩记录(`game_play_record.status = 2`)
|
||||
- **手续费**:渠道字段 `settlement_handling_fee` 为默认**手续费比例(%)**;计算公式:手续费金额 = 费前佣金 × 比例 / 100;预览弹窗可按代理修改比例,提交时 `commission_split[].handling_fee_rate` 回传;实发 = 费前佣金 − 手续费金额,记录表 `handling_fee` 存扣除金额
|
||||
- **分配预览**:按代理树先序展示,列含层级缩进、**结算基数**(上级分给该代理的金额)、分配比例、费前佣金、手续费、实发佣金
|
||||
- **批量**:`POST /admin/channel/batchSettlePending` 需 `channel/batchSettlePending`;仅结算当前账号可写范围内启用渠道
|
||||
|
||||
---
|
||||
|
||||
## 6. 下注记录弹窗
|
||||
|
||||
两种入口共用同一套 UI 与筛选(接口不同):
|
||||
|
||||
| 入口 | 接口 | 数据范围 |
|
||||
|------|------|----------|
|
||||
| 直属投注额(列点击) | `GET /admin/channel/directBetRecordList` | 该渠道名下玩家 **全部** 游玩记录(含未结算) |
|
||||
| 公司总投注额(顶部卡片) | `GET /admin/channel/companyBetRecordList` | 当前账号可见渠道范围内 **全部** 游玩记录(含未结算) |
|
||||
|
||||
### 6.1 顶部统计(Card)
|
||||
|
||||
- 总笔数、总投注额、总中奖额(随筛选条件重算)
|
||||
|
||||
### 6.2 列表列
|
||||
|
||||
游戏期号、玩家名、渠道、选号、中奖号码、中奖状态(已中奖 / 未中奖 / 待开奖)、投注金额、玩家中奖金额(含 `jackpot_extra_amount`)。
|
||||
|
||||
### 6.3 筛选参数(GET)
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `channel_id` | 必填 |
|
||||
| `page` / `limit` | 分页,默认 1 / 20,最大 200 |
|
||||
| `period_no` | 期号模糊(`pr.period_no` 或 `game_record.period_no`) |
|
||||
| `user_keyword` | 玩家名或手机号模糊 |
|
||||
| `result_number` | 开奖号码精确匹配 |
|
||||
| `pick_number` | 选号模糊(匹配 `pick_numbers` JSON 文本) |
|
||||
| `win_hit` | `won` / `lost` / `pending`(与列表中奖状态一致) |
|
||||
|
||||
### 6.4 移动端适配
|
||||
|
||||
- 弹窗宽度约 92% 视口,内容区可**纵向滚动**(勿使用 `ba-operate-dialog` 固定高度)
|
||||
- 统计 Card **一行三列**;筛选项纵向铺满;表格区域可**横向滑动**
|
||||
|
||||
---
|
||||
|
||||
## 7. 已分红记录弹窗
|
||||
|
||||
- **接口**:`GET /admin/channel/dividendRecordList`
|
||||
- **权限**:`channel/index`
|
||||
- **字段**:结算单号、渠道名、代理账号、分红金额、结算周期、发放时间等
|
||||
|
||||
---
|
||||
|
||||
## 8. 相关接口一览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/admin/channel/index` | 渠道列表 |
|
||||
| GET | `/admin/channel/settleStats` | 顶部统计卡片 |
|
||||
| GET | `/admin/channel/directBetRecordList` | 直属下注记录 |
|
||||
| GET | `/admin/channel/settlementBetRecordList` | 分红口径下注记录 |
|
||||
| GET | `/admin/channel/dividendRecordList` | 已分红记录 |
|
||||
| GET | `/admin/channel/manualSettlePreview` | 手动结算预览 |
|
||||
| POST | `/admin/channel/manualSettle` | 提交手动结算 |
|
||||
| POST | `/admin/channel/batchSettlePending` | 批量结算(可写渠道范围) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关代码
|
||||
|
||||
| 模块 | 路径 |
|
||||
|------|------|
|
||||
| 渠道控制器 | `app/admin/controller/Channel.php` |
|
||||
| 数据范围 | `app/common/service/AdminChannelScopeService.php` |
|
||||
| 渠道结算 | `app/common/service/ChannelSettlementService.php` |
|
||||
| 前端页面 | `web/src/views/backend/channel/index.vue` |
|
||||
| 权限迁移 | `database/migrations/20260530120000_*`、`20260530130000_*` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-05-30 | 新增:查看所有渠道、下注/分红查看按钮;下注记录弹窗列与筛选;移动端弹窗适配 |
|
||||
| 2026-05-30 | 手动结算:拥有 `channel/manualSettle` 且渠道可读即可操作(不再仅限超管展示按钮) |
|
||||
| 2026-05-30 | 修复:账号已设 `channel_id` 时不再因角色组未绑渠道而误判为全平台可见 |
|
||||
| 2026-05-30 | 移除 `channel/viewAllChannels`;未绑定渠道且拥有渠道模块基础权限时读写全平台渠道 |
|
||||
@@ -42,7 +42,8 @@ class Auth
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getChildren($this->children[0]);
|
||||
$menus = $this->getChildren($this->children[0]);
|
||||
return $this->filterVisibleMenus($menus, $uid);
|
||||
}
|
||||
|
||||
private function getChildren(array $rules): array
|
||||
@@ -55,6 +56,55 @@ class Auth
|
||||
return $rules;
|
||||
}
|
||||
|
||||
private function filterVisibleMenus(array $menus, int $uid): array
|
||||
{
|
||||
$ruleList = $this->getRuleList($uid);
|
||||
if (in_array('*', $ruleList, true)) {
|
||||
return $menus;
|
||||
}
|
||||
|
||||
$allowedRuleMap = [];
|
||||
foreach ($ruleList as $ruleName) {
|
||||
if (is_string($ruleName) && $ruleName !== '') {
|
||||
$allowedRuleMap[strtolower($ruleName)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->doFilterVisibleMenus($menus, $allowedRuleMap);
|
||||
}
|
||||
|
||||
private function doFilterVisibleMenus(array $menus, array $allowedRuleMap): array
|
||||
{
|
||||
$visibleMenus = [];
|
||||
foreach ($menus as $menu) {
|
||||
if (!is_array($menu)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$children = $menu['children'] ?? [];
|
||||
if (is_array($children) && $children) {
|
||||
$menu['children'] = $this->doFilterVisibleMenus($children, $allowedRuleMap);
|
||||
}
|
||||
|
||||
$name = strtolower(strval($menu['name'] ?? ''));
|
||||
$type = strval($menu['type'] ?? '');
|
||||
$isAllowed = $name !== '' && isset($allowedRuleMap[$name]);
|
||||
|
||||
if ($type === 'button') {
|
||||
if ($isAllowed) {
|
||||
$visibleMenus[] = $menu;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isAllowed || !empty($menu['children'])) {
|
||||
$visibleMenus[] = $menu;
|
||||
}
|
||||
}
|
||||
|
||||
return $visibleMenus;
|
||||
}
|
||||
|
||||
public function check(string $name, int $uid, string $relation = 'or', string $mode = 'url'): bool
|
||||
{
|
||||
$ruleList = $this->getRuleList($uid);
|
||||
|
||||
225
public/mock-deposit.html
Normal file
225
public/mock-deposit.html
Normal file
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>模拟充值收银台</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f5f7fa; margin: 0; padding: 24px; }
|
||||
.card { max-width: 420px; margin: 40px auto; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #666; line-height: 1.6; margin: 8px 0; }
|
||||
.amt { font-size: 28px; color: #1677ff; font-weight: 700; margin: 16px 0; }
|
||||
.countdown { font-size: 15px; color: #fa8c16; font-weight: 600; margin: 12px 0; }
|
||||
.countdown.expired { color: #cf1322; }
|
||||
button { width: 100%; padding: 14px; font-size: 16px; border: 0; border-radius: 8px; background: #1677ff; color: #fff; cursor: pointer; margin-top: 12px; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.btn-back { display: none; background: #fff; color: #cf1322; border: 1px solid #d9d9d9; }
|
||||
.hint { font-size: 13px; color: #999; }
|
||||
.ok { display: none; margin-top: 16px; padding: 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 8px; color: #389e0d; line-height: 1.6; }
|
||||
.info { margin-top: 12px; padding: 12px; background: #e6f4ff; border: 1px solid #91caff; border-radius: 8px; color: #0958d9; font-size: 14px; line-height: 1.6; }
|
||||
.err { margin-top: 12px; padding: 12px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 8px; color: #cf1322; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>模拟充值收银台</h1>
|
||||
<p class="hint">订单号:<span id="orderNo">-</span></p>
|
||||
<div id="amountBlock" style="display:none;">
|
||||
<p>充值金额 <strong id="amount">-</strong>,赠送 <strong id="bonus">-</strong></p>
|
||||
<div class="amt">预计到账 <span id="total">-</span></div>
|
||||
</div>
|
||||
<p class="countdown" id="countdown">正在加载…</p>
|
||||
<p class="hint" id="expireLine"></p>
|
||||
<button type="button" id="btnPay">确认支付</button>
|
||||
<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br />订单已提交,需管理员在后台审核通过后才会入账。</div>
|
||||
<button type="button" class="btn-back" id="btnBackGame">返回游戏</button>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var orderNo = params.get('order_no') || '';
|
||||
var apiBase = (params.get('api_base') || '').replace(/\/$/, '');
|
||||
var expireAt = parseInt(params.get('expire_at') || '0', 10);
|
||||
var sign = params.get('sign') || '';
|
||||
var returnGameUrl = params.get('return_game_url') || '';
|
||||
if (!apiBase) {
|
||||
apiBase = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||
}
|
||||
document.getElementById('orderNo').textContent = orderNo || '(缺少 order_no)';
|
||||
|
||||
var countdownEl = document.getElementById('countdown');
|
||||
var expireLineEl = document.getElementById('expireLine');
|
||||
var btnPay = document.getElementById('btnPay');
|
||||
var btnBackGame = document.getElementById('btnBackGame');
|
||||
var countdownTimer = null;
|
||||
|
||||
function showBackGame(url) {
|
||||
var target = (url || returnGameUrl || '').trim();
|
||||
if (!target) return;
|
||||
returnGameUrl = target;
|
||||
btnBackGame.style.display = 'block';
|
||||
btnBackGame.onclick = function () {
|
||||
window.location.href = returnGameUrl;
|
||||
};
|
||||
}
|
||||
|
||||
function showErr(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="err">' + text + '</div>';
|
||||
}
|
||||
function showInfo(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="info">' + text + '</div>';
|
||||
}
|
||||
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
||||
function formatTs(ts) {
|
||||
var d = new Date(ts * 1000);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
|
||||
+ pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
function formatRemain(sec) {
|
||||
if (sec <= 0) return '00:00';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
function updateCountdown(remaining) {
|
||||
if (remaining <= 0) {
|
||||
countdownEl.textContent = '支付链接已过期,请返回应用重新发起充值';
|
||||
countdownEl.className = 'countdown expired';
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
countdownEl.textContent = '剩余支付时间 ' + formatRemain(remaining);
|
||||
countdownEl.className = 'countdown';
|
||||
if (expireAt > 0) {
|
||||
expireLineEl.textContent = '链接有效期至 ' + formatTs(expireAt);
|
||||
}
|
||||
}
|
||||
function startCountdown(remaining) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
var left = remaining;
|
||||
updateCountdown(left);
|
||||
countdownTimer = setInterval(function () {
|
||||
left -= 1;
|
||||
updateCountdown(left);
|
||||
if (left <= 0) clearInterval(countdownTimer);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
var amount = data.amount;
|
||||
var bonus = data.bonus_amount;
|
||||
var total = data.total_amount;
|
||||
if (amount !== undefined) {
|
||||
document.getElementById('amount').textContent = Number(amount).toFixed(2);
|
||||
document.getElementById('bonus').textContent = Number(bonus || 0).toFixed(2);
|
||||
document.getElementById('total').textContent = Number(total || 0).toFixed(2);
|
||||
document.getElementById('amountBlock').style.display = 'block';
|
||||
}
|
||||
if (data.expire_at) expireAt = data.expire_at;
|
||||
var remaining = data.remaining_seconds !== undefined ? data.remaining_seconds : Math.max(0, expireAt - Math.floor(Date.now() / 1000));
|
||||
startCountdown(remaining);
|
||||
|
||||
if (data.status === 'paid') {
|
||||
showInfo('该订单已入账。');
|
||||
btnPay.style.display = 'none';
|
||||
showBackGame(data.return_game_url);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_review') {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
showInfo('您已提交支付,请等待管理员审核通过后到账。');
|
||||
showBackGame(data.return_game_url);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
showErr(data.reject_reason || '订单已失效或超时,请重新发起充值。');
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!data.can_pay) {
|
||||
btnPay.disabled = true;
|
||||
if (remaining <= 0) {
|
||||
showErr('支付链接已过期,请返回应用重新发起充值。');
|
||||
}
|
||||
} else {
|
||||
btnPay.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
if (!orderNo || !sign || expireAt <= 0) {
|
||||
showErr('链接无效或缺少签名参数,请通过 App 内「去支付」打开完整链接。');
|
||||
btnPay.disabled = true;
|
||||
countdownEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var statusUrl = apiBase + '/api/finance/mockDepositStatus?' + new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString();
|
||||
fetch(statusUrl, { method: 'GET', credentials: 'omit' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1 && res.data) {
|
||||
applyStatus(res.data);
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '无法加载订单信息');
|
||||
btnPay.disabled = true;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('无法连接支付服务,请检查网络或 api_base 配置。');
|
||||
btnPay.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
btnPay.onclick = function () {
|
||||
if (!orderNo || !sign || expireAt <= 0) return;
|
||||
btnPay.disabled = true;
|
||||
btnPay.textContent = '处理中...';
|
||||
var confirmUrl = apiBase + '/api/finance/mockDepositConfirm';
|
||||
fetch(confirmUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1) {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
document.getElementById('msg').innerHTML = '';
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownEl.textContent = '已提交,等待后台审核';
|
||||
countdownEl.className = 'countdown';
|
||||
if (res.data && res.data.return_game_url) {
|
||||
showBackGame(res.data.return_game_url);
|
||||
} else {
|
||||
showBackGame('');
|
||||
}
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '提交失败');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('网络错误,请稍后重试。');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
});
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
scripts/debug_bet_win_push.php
Normal file
79
scripts/debug_bet_win_push.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use app\common\service\GameBetSettleService;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
|
||||
$playId = (int) ($argv[1] ?? 1372);
|
||||
|
||||
$row = Db::name('game_play_record')->where('id', $playId)->find();
|
||||
if (!is_array($row)) {
|
||||
$row = Db::name('bet_order')->where('id', $playId)->find();
|
||||
}
|
||||
if (!is_array($row)) {
|
||||
fwrite(STDERR, "play record not found: {$playId}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$periodId = (int) ($row['period_id'] ?? 0);
|
||||
$periodNo = (string) ($row['period_no'] ?? '');
|
||||
$userId = (int) ($row['user_id'] ?? 0);
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$winAmount = (string) ($row['win_amount'] ?? '0');
|
||||
|
||||
echo "=== play_record id={$playId} ===\n";
|
||||
echo "period_id={$periodId} period_no={$periodNo}\n";
|
||||
echo "user_id={$userId} status={$status} win_amount={$winAmount}\n";
|
||||
echo "streak_at_bet=" . ($row['streak_at_bet'] ?? '') . "\n";
|
||||
|
||||
$user = Db::name('user')->where('id', $userId)->field('id,username,phone')->find();
|
||||
if (is_array($user)) {
|
||||
echo "user: id={$user['id']} username={$user['username']} phone={$user['phone']}\n";
|
||||
}
|
||||
|
||||
$gr = Db::name('game_record')->where('id', $periodId)->find();
|
||||
if (is_array($gr)) {
|
||||
echo "game_record: status={$gr['status']} result_number={$gr['result_number']} period_start_at={$gr['period_start_at']}\n";
|
||||
}
|
||||
|
||||
$settleKey = 'dfw:v1:settle:notify:' . $periodId;
|
||||
$betWinKey = 'dfw:v1:ws:betwin:' . $periodId . ':' . $userId;
|
||||
echo "\n=== Redis dedup keys ===\n";
|
||||
try {
|
||||
echo "settle_notify={$settleKey} => " . var_export(Redis::get($settleKey), true) . "\n";
|
||||
echo "bet_win={$betWinKey} => " . var_export(Redis::get($betWinKey), true) . "\n";
|
||||
} catch (Throwable $e) {
|
||||
echo 'redis err: ' . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
$queueLen = 0;
|
||||
try {
|
||||
$queueLen = (int) Redis::lLen('dfw:v1:ws:event:queue');
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
echo "ws queue length={$queueLen}\n";
|
||||
|
||||
$resultNumber = is_array($gr) ? (int) ($gr['result_number'] ?? 0) : 0;
|
||||
$payloads = GameBetSettleService::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
echo "\n=== buildBetWinPayloadsFromSettledOrders ===\n";
|
||||
echo 'winner_count=' . count($payloads) . "\n";
|
||||
foreach ($payloads as $p) {
|
||||
echo json_encode($p, JSON_UNESCAPED_UNICODE) . "\n";
|
||||
}
|
||||
|
||||
$allWinners = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->whereIn('status', [2, 5])
|
||||
->whereRaw('CAST(win_amount AS DECIMAL(20,2)) > 0')
|
||||
->field('id,user_id,win_amount,status,streak_at_bet')
|
||||
->select()
|
||||
->toArray();
|
||||
echo "\n=== all winning orders in period ===\n";
|
||||
foreach ($allWinners as $w) {
|
||||
echo "bet_id={$w['id']} user_id={$w['user_id']} win={$w['win_amount']} status={$w['status']} streak_at_bet={$w['streak_at_bet']}\n";
|
||||
}
|
||||
156
scripts/debug_period_bet_win.php
Normal file
156
scripts/debug_period_bet_win.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* bet.win 推送链路诊断脚本(线上排查专用)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/debug_period_bet_win.php # 默认查最近 5 期
|
||||
* php scripts/debug_period_bet_win.php <period_no> # 指定单期
|
||||
* php scripts/debug_period_bet_win.php --recent=10 # 查最近 N 期
|
||||
*
|
||||
* 输出维度:
|
||||
* 1) 期次基本信息(开奖号、状态)
|
||||
* 2) 中奖注单(user_id / win_amount / status)
|
||||
* 3) Redis bet.win 去重键:dfw:v1:ws:betwin:{period_id}:{user_id}
|
||||
* —— 若库内有中奖、Redis 无键 ⇒ publish 未执行(请重启 webman + 看 runtime/logs 是否有 'bet.win published')
|
||||
* 4) Redis 事件队列 dfw:v1:ws:event:queue 当前 bet.win 待消费数(>0 说明 WS 消费滞后或进程未启动)
|
||||
*/
|
||||
|
||||
$argvList = $argv;
|
||||
array_shift($argvList);
|
||||
|
||||
$periodNo = '';
|
||||
$recent = 0;
|
||||
foreach ($argvList as $a) {
|
||||
if (str_starts_with($a, '--recent=')) {
|
||||
$recent = max(1, (int) substr($a, 9));
|
||||
continue;
|
||||
}
|
||||
if ($a !== '' && $a[0] !== '-') {
|
||||
$periodNo = $a;
|
||||
}
|
||||
}
|
||||
if ($periodNo === '' && $recent === 0) {
|
||||
$recent = 5;
|
||||
}
|
||||
|
||||
$queueKey = 'dfw:v1:ws:event:queue';
|
||||
try {
|
||||
$queueLen = (int) Redis::lLen($queueKey);
|
||||
} catch (\Throwable $e) {
|
||||
$queueLen = -1;
|
||||
}
|
||||
echo "[redis] {$queueKey} length = {$queueLen}\n";
|
||||
if ($queueLen > 0) {
|
||||
try {
|
||||
$sample = Redis::lRange($queueKey, 0, 49);
|
||||
$betWinPending = 0;
|
||||
if (is_array($sample)) {
|
||||
foreach ($sample as $raw) {
|
||||
if (is_string($raw) && str_contains($raw, '"bet.win"')) {
|
||||
$betWinPending++;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "[redis] bet.win pending in first 50 = {$betWinPending}\n";
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
$records = [];
|
||||
if ($periodNo !== '') {
|
||||
$row = Db::name('game_record')->where('period_no', $periodNo)->find();
|
||||
if (!is_array($row)) {
|
||||
fwrite(STDERR, "period not found: {$periodNo}\n");
|
||||
exit(1);
|
||||
}
|
||||
$records[] = $row;
|
||||
} else {
|
||||
$records = Db::name('game_record')
|
||||
->whereIn('status', [2, 3, 4])
|
||||
->whereNotNull('result_number')
|
||||
->order('id', 'desc')
|
||||
->limit($recent)
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
foreach ($records as $gr) {
|
||||
$pid = (int) ($gr['id'] ?? 0);
|
||||
$rn = (int) ($gr['result_number'] ?? 0);
|
||||
$status = (int) ($gr['status'] ?? 0);
|
||||
$pno = (string) ($gr['period_no'] ?? '');
|
||||
echo "==== period_id={$pid} no={$pno} status={$status} result={$rn} ====\n";
|
||||
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $pid)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$winUserIds = [];
|
||||
foreach ($bets as $b) {
|
||||
$win = (string) ($b['win_amount'] ?? '0');
|
||||
$uid = (int) ($b['user_id'] ?? 0);
|
||||
$bs = (int) ($b['status'] ?? 0);
|
||||
$tag = bccomp($win, '0', 2) > 0 ? 'WIN' : '----';
|
||||
echo sprintf(" bet=%d user=%d status=%d win=%s [%s]\n", (int) ($b['id'] ?? 0), $uid, $bs, $win, $tag);
|
||||
if ($tag === 'WIN' && $uid > 0) {
|
||||
$winUserIds[$uid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($winUserIds === []) {
|
||||
echo " (no winners)\n\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
echo " -- draw / bet.win dedup keys --\n";
|
||||
foreach (['dfw:v1:ws:period_opened:' . $pid => 'period.opened', 'dfw:v1:ws:period_payout:' . $pid => 'period.payout'] as $key => $label) {
|
||||
try {
|
||||
$val = Redis::get($key);
|
||||
$ttl = Redis::ttl($key);
|
||||
} catch (\Throwable) {
|
||||
$val = false;
|
||||
$ttl = -2;
|
||||
}
|
||||
$exists = ($val !== false && $val !== null && $val !== '');
|
||||
echo sprintf(
|
||||
" %s exists=%s ttl=%s %s\n",
|
||||
$label,
|
||||
$exists ? 'YES' : 'NO',
|
||||
(string) $ttl,
|
||||
$exists ? '(已推送)' : '(未推送)'
|
||||
);
|
||||
}
|
||||
foreach (array_keys($winUserIds) as $uid) {
|
||||
$key = 'dfw:v1:ws:betwin:' . $pid . ':' . $uid;
|
||||
try {
|
||||
$val = Redis::get($key);
|
||||
$ttl = Redis::ttl($key);
|
||||
} catch (\Throwable) {
|
||||
$val = false;
|
||||
$ttl = -2;
|
||||
}
|
||||
$exists = ($val !== false && $val !== null && $val !== '');
|
||||
echo sprintf(
|
||||
" bet.win user=%d exists=%s ttl=%s %s\n",
|
||||
$uid,
|
||||
$exists ? 'YES' : 'NO',
|
||||
(string) $ttl,
|
||||
$exists ? '(已推送)' : '(未推送)'
|
||||
);
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "补发:php scripts/republish_period_draw.php --period-no=<no> --force\n";
|
||||
104
scripts/debug_period_win_flags.php
Normal file
104
scripts/debug_period_win_flags.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 诊断某一期 bet.win / jackpot.hit 标记与推送条件:
|
||||
* - 输出每个已结算中奖用户的:user_id / bet_id / streak_at_bet / 档位 / odds_factor / win_amount / is_jackpot
|
||||
* - 输出 jackpot 审核阈值(若配置)与 payout_pending_review
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/debug_period_win_flags.php --period-id=77689
|
||||
* php scripts/debug_period_win_flags.php --period-no=20260528-171443-3ebcbf2e
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use app\common\service\GameBetSettleService;
|
||||
use support\think\Db;
|
||||
|
||||
$opts = getopt('', ['period-id:', 'period-no:']);
|
||||
$periodId = isset($opts['period-id']) ? (int) $opts['period-id'] : 0;
|
||||
if ($periodId <= 0 && isset($opts['period-no'])) {
|
||||
$periodNo = trim((string) $opts['period-no']);
|
||||
if ($periodNo !== '') {
|
||||
$pid = Db::name('game_record')->where('period_no', $periodNo)->value('id');
|
||||
$periodId = is_numeric((string) $pid) ? (int) $pid : 0;
|
||||
if ($periodId <= 0) {
|
||||
// 兜底:部分环境 game_record.period_no 与 bet_order.period_no 不一致(或 game_record 未落库),从注单反查 period_id
|
||||
$pid2 = Db::name('bet_order')->where('period_no', $periodNo)->value('period_id');
|
||||
$periodId = is_numeric((string) $pid2) ? (int) $pid2 : 0;
|
||||
}
|
||||
echo "period_no={$periodNo} => period_id={$periodId}" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
if ($periodId <= 0) {
|
||||
fwrite(STDERR, "请指定 --period-id 或 --period-no\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$record = Db::name('game_record')->where('id', $periodId)->find();
|
||||
if (!is_array($record)) {
|
||||
fwrite(STDERR, "对局不存在: period_id={$periodId}\n");
|
||||
exit(1);
|
||||
}
|
||||
$resultNumber = filter_var($record['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($resultNumber === false || $resultNumber < 1) {
|
||||
fwrite(STDERR, "对局尚未开奖(result_number 无效),无法诊断\n");
|
||||
exit(1);
|
||||
}
|
||||
$periodNo = is_string($record['period_no'] ?? null) ? (string) $record['period_no'] : '';
|
||||
|
||||
echo "period_id={$periodId} period_no={$periodNo} result_number={$resultNumber}" . PHP_EOL;
|
||||
|
||||
// 注意:这里只输出标记相关信息,不会触发任何写库/推送
|
||||
$rows = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->whereIn('status', [GameBetSettleService::PLAY_STATUS_SETTLED, GameBetSettleService::PLAY_STATUS_PENDING_REVIEW])
|
||||
->field(['id', 'user_id', 'streak_at_bet', 'total_amount', 'win_amount', 'status'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if ($rows === []) {
|
||||
echo "no settled orders\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "streak_win_reward rows (level=1..10):" . PHP_EOL;
|
||||
foreach (StreakWinReward::loadRows() as $r) {
|
||||
$st = (int) ($r['streak'] ?? 0);
|
||||
$od = (int) ($r['odds_factor'] ?? 1);
|
||||
$jk = !empty($r['is_jackpot']) ? 1 : 0;
|
||||
echo "- level={$st} odds_factor={$od} is_jackpot={$jk}" . PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL . "orders:" . PHP_EOL;
|
||||
$anyJackpot = false;
|
||||
foreach ($rows as $row) {
|
||||
$betId = (int) ($row['id'] ?? 0);
|
||||
$uid = (int) ($row['user_id'] ?? 0);
|
||||
$streakAtBet = (int) ($row['streak_at_bet'] ?? 0);
|
||||
$win = is_string($row['win_amount'] ?? null) ? (string) $row['win_amount'] : (is_numeric($row['win_amount'] ?? null) ? (string) $row['win_amount'] : '0.00');
|
||||
$winNorm = bcadd($win, '0', 2);
|
||||
$level = StreakWinReward::levelFromStreakAtBet($streakAtBet);
|
||||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streakAtBet);
|
||||
$isJackpotByStreak = StreakWinReward::isJackpotForStreakAtBet($streakAtBet);
|
||||
$needReview = ((int) ($row['status'] ?? 0)) === GameBetSettleService::PLAY_STATUS_PENDING_REVIEW;
|
||||
$flags = (new ReflectionClass(GameBetSettleService::class))->getMethod('betWinJackpotFlagsForOrder');
|
||||
$flags->setAccessible(true);
|
||||
/** @var array{is_jackpot: bool, payout_pending_review: bool} $computed */
|
||||
$computed = $flags->invoke(null, $row, $winNorm, $needReview);
|
||||
$anyJackpot = $anyJackpot || !empty($computed['is_jackpot']);
|
||||
|
||||
echo "- bet_id={$betId} user_id={$uid} streak_at_bet={$streakAtBet} level={$level} odds_factor={$odds} win_amount={$winNorm}"
|
||||
. " is_jackpot_by_streak=" . ($isJackpotByStreak ? '1' : '0')
|
||||
. " payout_pending_review=" . (!empty($computed['payout_pending_review']) ? '1' : '0')
|
||||
. " is_jackpot=" . (!empty($computed['is_jackpot']) ? '1' : '0')
|
||||
. PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL . "summary: any_jackpot=" . ($anyJackpot ? '1' : '0') . PHP_EOL;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 执行方法
|
||||
* php scripts/generate_auth_signature.php
|
||||
* php scripts/generate_auth_signature.php 设备码 密钥 时间戳
|
||||
* php scripts/generate_auth_signature.php 1 564d14asdasd113e46542asd6das1a2a 1776331077
|
||||
* php scripts/generate_auth_signature.php 1 564d14asdasd113e46542asd6das1a2a
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
84
scripts/republish_bet_win.php
Normal file
84
scripts/republish_bet_win.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 补发 bet.win WebSocket 推送(已结算中奖但客户端未收到时使用)。
|
||||
*
|
||||
* 用法:
|
||||
* php scripts/republish_bet_win.php --period-id=123
|
||||
* php scripts/republish_bet_win.php --play-record-id=1370
|
||||
* php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1
|
||||
* php scripts/republish_bet_win.php --period-id=123 --force
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use app\common\service\GameBetSettleService;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
|
||||
$opts = getopt('', ['period-id:', 'play-record-id:', 'period-no:', 'force']);
|
||||
$force = array_key_exists('force', $opts);
|
||||
|
||||
$periodId = isset($opts['period-id']) ? (int) $opts['period-id'] : 0;
|
||||
if (isset($opts['play-record-id'])) {
|
||||
$playId = (int) $opts['play-record-id'];
|
||||
if ($playId > 0) {
|
||||
$pid = Db::name('game_play_record')->where('id', $playId)->value('period_id');
|
||||
$periodId = is_numeric((string) $pid) ? (int) $pid : 0;
|
||||
echo "play_record_id={$playId} => period_id={$periodId}" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
if ($periodId <= 0 && isset($opts['period-no'])) {
|
||||
$periodNo = trim((string) $opts['period-no']);
|
||||
if ($periodNo !== '') {
|
||||
$pid = Db::name('game_record')->where('period_no', $periodNo)->value('id');
|
||||
$periodId = is_numeric((string) $pid) ? (int) $pid : 0;
|
||||
echo "period_no={$periodNo} => period_id={$periodId}" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
if ($periodId <= 0) {
|
||||
fwrite(STDERR, "请指定 --period-id、--play-record-id 或 --period-no\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$row = Db::name('game_record')->where('id', $periodId)->find();
|
||||
if (!is_array($row)) {
|
||||
fwrite(STDERR, "对局不存在: period_id={$periodId}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($resultNumber === false || $resultNumber < 1) {
|
||||
fwrite(STDERR, "对局尚未开奖,无法补发 bet.win\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$payloads = GameBetSettleService::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
if ($payloads === []) {
|
||||
echo "本期无已结算中奖注单,无需补发。\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
foreach ($payloads as $payload) {
|
||||
$uid = (int) ($payload['user_id'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
Redis::del('dfw:v1:ws:betwin:' . $periodId . ':' . $uid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
echo "已清除 dedup 键(--force)\n";
|
||||
}
|
||||
|
||||
GameBetSettleService::publishBetWinsAfterCommit($payloads, $periodId);
|
||||
echo '已补发 bet.win 用户数: ' . count($payloads) . PHP_EOL;
|
||||
foreach ($payloads as $p) {
|
||||
echo ' user_id=' . ($p['user_id'] ?? '') . ' total_win=' . ($p['total_win'] ?? '') . ' period_no=' . ($p['period_no'] ?? '') . PHP_EOL;
|
||||
}
|
||||
112
scripts/republish_period_draw.php
Normal file
112
scripts/republish_period_draw.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 补发本期开奖推送:period.opened + period.payout + bet.win(已结算中奖用户)。
|
||||
*
|
||||
* 用法(在生产服务器执行,需 Redis 与 gameWebSocketServer 同机):
|
||||
* php scripts/republish_period_draw.php --period-no=20260527-103109-7fbbbb5e
|
||||
* php scripts/republish_period_draw.php --play-record-id=1388
|
||||
* php scripts/republish_period_draw.php --period-id=74992 --force
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use app\common\service\GameBetSettleService;
|
||||
use app\common\service\GameLiveService;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
|
||||
$opts = getopt('', ['period-id:', 'play-record-id:', 'period-no:', 'force']);
|
||||
$force = array_key_exists('force', $opts);
|
||||
|
||||
$periodId = isset($opts['period-id']) ? (int) $opts['period-id'] : 0;
|
||||
if (isset($opts['play-record-id'])) {
|
||||
$playId = (int) $opts['play-record-id'];
|
||||
if ($playId > 0) {
|
||||
$pid = Db::name('game_play_record')->where('id', $playId)->value('period_id');
|
||||
$periodId = is_numeric((string) $pid) ? (int) $pid : 0;
|
||||
echo "play_record_id={$playId} => period_id={$periodId}" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
if ($periodId <= 0 && isset($opts['period-no'])) {
|
||||
$periodNo = trim((string) $opts['period-no']);
|
||||
if ($periodNo !== '') {
|
||||
$pid = Db::name('game_record')->where('period_no', $periodNo)->value('id');
|
||||
$periodId = is_numeric((string) $pid) ? (int) $pid : 0;
|
||||
echo "period_no={$periodNo} => period_id={$periodId}" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
if ($periodId <= 0) {
|
||||
fwrite(STDERR, "请指定 --period-id、--play-record-id 或 --period-no\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$row = Db::name('game_record')->where('id', $periodId)->find();
|
||||
if (!is_array($row)) {
|
||||
fwrite(STDERR, "对局不存在: period_id={$periodId}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$resultNumber = filter_var($row['result_number'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($resultNumber === false || $resultNumber < 1) {
|
||||
fwrite(STDERR, "对局尚未开奖,无法补发\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$periodNo = (string) ($row['period_no'] ?? '');
|
||||
$drawMode = filter_var($row['draw_mode'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$payoutUntil = filter_var($row['payout_until'] ?? 0, FILTER_VALIDATE_INT);
|
||||
$openTime = filter_var($row['update_time'] ?? time(), FILTER_VALIDATE_INT);
|
||||
if ($openTime === false || $openTime <= 0) {
|
||||
$openTime = time();
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
try {
|
||||
Redis::del('dfw:v1:ws:period_opened:' . $periodId);
|
||||
Redis::del('dfw:v1:ws:period_payout:' . $periodId);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
$payloads = GameBetSettleService::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
foreach ($payloads as $payload) {
|
||||
$uid = (int) ($payload['user_id'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
try {
|
||||
Redis::del('dfw:v1:ws:betwin:' . $periodId . ':' . $uid);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
echo "已清除 dedup 键(--force)\n";
|
||||
}
|
||||
|
||||
GameLiveService::ensurePeriodDrawNotifications(
|
||||
$periodId,
|
||||
$periodNo,
|
||||
$resultNumber,
|
||||
$drawMode === false ? 0 : $drawMode,
|
||||
$payoutUntil !== false && $payoutUntil > 0 ? $payoutUntil : time(),
|
||||
$openTime
|
||||
);
|
||||
|
||||
$payloads = GameBetSettleService::buildBetWinPayloadsFromSettledOrders($periodId, $resultNumber);
|
||||
if ($payloads !== []) {
|
||||
// 同时触发 jackpot.hit(若 bet.win 判定为大奖则应广播);publishSettlementWinsAfterCommit 内含 jackpot.hit 独立去重与从 bet_wins 重建兜底
|
||||
GameBetSettleService::publishSettlementWinsAfterCommit([
|
||||
'jackpot_hits' => [],
|
||||
'bet_wins' => $payloads,
|
||||
'user_streak_events' => [],
|
||||
'wallet_events' => [],
|
||||
'settled_order_count' => 0,
|
||||
], $periodId, $periodNo, $resultNumber);
|
||||
GameBetSettleService::publishBetWinsAfterCommit($payloads, $periodId);
|
||||
echo 'bet.win republished for user_ids: ' . implode(',', array_map(static fn (array $p): int => (int) ($p['user_id'] ?? 0), $payloads)) . PHP_EOL;
|
||||
} else {
|
||||
echo "本期无已结算中奖注单。\n";
|
||||
}
|
||||
|
||||
echo "done period_id={$periodId} period_no={$periodNo} result={$resultNumber}\n";
|
||||
59
scripts/smoke_ws_auth_helper.php
Normal file
59
scripts/smoke_ws_auth_helper.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 本地烟雾测试:验证 GameWebSocketAuthHelper / Registry / Dispatcher 关键路径。
|
||||
* 不依赖 webman 进程,可直接 `php scripts/smoke_ws_auth_helper.php` 跑。
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
require __DIR__ . '/../support/bootstrap.php';
|
||||
|
||||
use app\common\service\GameWebSocketAuthHelper;
|
||||
use app\common\service\GameWebSocketSubscriptionRegistry;
|
||||
|
||||
echo "[1] parseQueryString\n";
|
||||
$q = GameWebSocketAuthHelper::parseQueryString('?auth_token=AAA&user_token=BBB&device_id=ios&extra=1');
|
||||
echo json_encode($q, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
|
||||
|
||||
echo "[2] authorize: missing auth-token (mobile)\n";
|
||||
$r = GameWebSocketAuthHelper::authorize(['user_token' => 'whatever']);
|
||||
echo json_encode($r, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
|
||||
|
||||
echo "[3] authorize: invalid auth-token\n";
|
||||
$r = GameWebSocketAuthHelper::authorize(['auth_token' => 'not_existing_token', 'user_token' => 'not_existing_user_token']);
|
||||
echo json_encode($r, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
|
||||
|
||||
echo "[4] authorize: invalid admin_ws_token bypass\n";
|
||||
$r = GameWebSocketAuthHelper::authorize(['admin_ws_token' => 'no_such_admin_token_xxxxxx']);
|
||||
echo json_encode($r, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL, PHP_EOL;
|
||||
|
||||
echo "[5] issue + validate admin_ws_token\n";
|
||||
$issued = GameWebSocketAuthHelper::issueAdminWsToken(99, 60);
|
||||
echo 'issued: ', json_encode($issued, JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
if (($issued['token'] ?? '') !== '') {
|
||||
$r = GameWebSocketAuthHelper::authorize(['admin_ws_token' => $issued['token']]);
|
||||
echo 'authorize ok with issued token: ', json_encode($r, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL;
|
||||
} else {
|
||||
echo "WARN: issueAdminWsToken returned empty (Redis 不可用?)\n";
|
||||
}
|
||||
echo PHP_EOL;
|
||||
|
||||
echo "[6] SubscriptionRegistry\n";
|
||||
GameWebSocketSubscriptionRegistry::reset();
|
||||
GameWebSocketSubscriptionRegistry::registerConnection(10, 14, '127.0.0.1');
|
||||
GameWebSocketSubscriptionRegistry::registerConnection(11, 0, '127.0.0.1');
|
||||
$t1 = GameWebSocketSubscriptionRegistry::replaceSubscriptions(10, ['bet.win', 'period.opened', 'period.opened', '', ' ', 'bet.win']);
|
||||
$t2 = GameWebSocketSubscriptionRegistry::replaceSubscriptions(11, ['admin.live.snapshot', 'period.opened']);
|
||||
echo 'cid=10 topics=', json_encode($t1), PHP_EOL;
|
||||
echo 'cid=11 topics=', json_encode($t2), PHP_EOL;
|
||||
echo 'period.opened subscribers=', json_encode(GameWebSocketSubscriptionRegistry::connectionsForTopic('period.opened')), PHP_EOL;
|
||||
echo 'bet.win subscribers=', json_encode(GameWebSocketSubscriptionRegistry::connectionsForTopic('bet.win')), PHP_EOL;
|
||||
echo 'admin.live.snapshot subscribers=', json_encode(GameWebSocketSubscriptionRegistry::connectionsForTopic('admin.live.snapshot')), PHP_EOL;
|
||||
GameWebSocketSubscriptionRegistry::unregisterConnection(10);
|
||||
echo 'after unregister(10), bet.win subscribers=', json_encode(GameWebSocketSubscriptionRegistry::connectionsForTopic('bet.win')), PHP_EOL;
|
||||
echo 'after unregister(10), period.opened subscribers=', json_encode(GameWebSocketSubscriptionRegistry::connectionsForTopic('period.opened')), PHP_EOL;
|
||||
echo 'remaining connection count=', GameWebSocketSubscriptionRegistry::connectionCount(), PHP_EOL;
|
||||
|
||||
echo "\nALL DONE\n";
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace support\bootstrap;
|
||||
|
||||
use app\common\library\MalaysiaMobilePhone;
|
||||
use think\Validate;
|
||||
use support\think\Db;
|
||||
use Workerman\Worker;
|
||||
@@ -17,6 +18,9 @@ class ValidateInit implements \Webman\Bootstrap
|
||||
{
|
||||
Validate::maker(function (Validate $validate): void {
|
||||
$validate->setDb(Db::connect());
|
||||
$validate->extend('mobile', static function ($value): bool {
|
||||
return MalaysiaMobilePhone::isValid((string) $value);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ VITE_BASE_PATH = '/'
|
||||
VITE_OUT_DIR = 'dist'
|
||||
|
||||
# 线上环境接口地址 - 'getCurrentDomain:表示获取当前域名'
|
||||
VITE_AXIOS_BASE_URL = 'https://zihua-api.yuliao666.top'
|
||||
VITE_AXIOS_BASE_URL = 'https://zihua-api.h55555game.top'
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"element-plus": "2.9.1",
|
||||
"font-awesome": "4.7.0",
|
||||
"lodash-es": "4.17.21",
|
||||
"marked": "9.1.6",
|
||||
"mitt": "3.0.1",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "2.3.0",
|
||||
|
||||
225
web/public/mock-deposit.html
Normal file
225
web/public/mock-deposit.html
Normal file
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>模拟充值收银台</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f5f7fa; margin: 0; padding: 24px; }
|
||||
.card { max-width: 420px; margin: 40px auto; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #666; line-height: 1.6; margin: 8px 0; }
|
||||
.amt { font-size: 28px; color: #1677ff; font-weight: 700; margin: 16px 0; }
|
||||
.countdown { font-size: 15px; color: #fa8c16; font-weight: 600; margin: 12px 0; }
|
||||
.countdown.expired { color: #cf1322; }
|
||||
button { width: 100%; padding: 14px; font-size: 16px; border: 0; border-radius: 8px; background: #1677ff; color: #fff; cursor: pointer; margin-top: 12px; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.btn-back { display: none; background: #fff; color: #cf1322; border: 1px solid #d9d9d9; }
|
||||
.hint { font-size: 13px; color: #999; }
|
||||
.ok { display: none; margin-top: 16px; padding: 12px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 8px; color: #389e0d; line-height: 1.6; }
|
||||
.info { margin-top: 12px; padding: 12px; background: #e6f4ff; border: 1px solid #91caff; border-radius: 8px; color: #0958d9; font-size: 14px; line-height: 1.6; }
|
||||
.err { margin-top: 12px; padding: 12px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 8px; color: #cf1322; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>模拟充值收银台</h1>
|
||||
<p class="hint">订单号:<span id="orderNo">-</span></p>
|
||||
<div id="amountBlock" style="display:none;">
|
||||
<p>充值金额 <strong id="amount">-</strong>,赠送 <strong id="bonus">-</strong></p>
|
||||
<div class="amt">预计到账 <span id="total">-</span></div>
|
||||
</div>
|
||||
<p class="countdown" id="countdown">正在加载…</p>
|
||||
<p class="hint" id="expireLine"></p>
|
||||
<button type="button" id="btnPay">确认支付</button>
|
||||
<div class="ok" id="okBox"><strong>支付成功(模拟)</strong><br />订单已提交,需管理员在后台审核通过后才会入账。</div>
|
||||
<button type="button" class="btn-back" id="btnBackGame">返回游戏</button>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var orderNo = params.get('order_no') || '';
|
||||
var apiBase = (params.get('api_base') || '').replace(/\/$/, '');
|
||||
var expireAt = parseInt(params.get('expire_at') || '0', 10);
|
||||
var sign = params.get('sign') || '';
|
||||
var returnGameUrl = params.get('return_game_url') || '';
|
||||
if (!apiBase) {
|
||||
apiBase = window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||
}
|
||||
document.getElementById('orderNo').textContent = orderNo || '(缺少 order_no)';
|
||||
|
||||
var countdownEl = document.getElementById('countdown');
|
||||
var expireLineEl = document.getElementById('expireLine');
|
||||
var btnPay = document.getElementById('btnPay');
|
||||
var btnBackGame = document.getElementById('btnBackGame');
|
||||
var countdownTimer = null;
|
||||
|
||||
function showBackGame(url) {
|
||||
var target = (url || returnGameUrl || '').trim();
|
||||
if (!target) return;
|
||||
returnGameUrl = target;
|
||||
btnBackGame.style.display = 'block';
|
||||
btnBackGame.onclick = function () {
|
||||
window.location.href = returnGameUrl;
|
||||
};
|
||||
}
|
||||
|
||||
function showErr(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="err">' + text + '</div>';
|
||||
}
|
||||
function showInfo(text) {
|
||||
document.getElementById('msg').innerHTML = '<div class="info">' + text + '</div>';
|
||||
}
|
||||
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
||||
function formatTs(ts) {
|
||||
var d = new Date(ts * 1000);
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' '
|
||||
+ pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
}
|
||||
function formatRemain(sec) {
|
||||
if (sec <= 0) return '00:00';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return pad(m) + ':' + pad(s);
|
||||
}
|
||||
function updateCountdown(remaining) {
|
||||
if (remaining <= 0) {
|
||||
countdownEl.textContent = '支付链接已过期,请返回应用重新发起充值';
|
||||
countdownEl.className = 'countdown expired';
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
countdownEl.textContent = '剩余支付时间 ' + formatRemain(remaining);
|
||||
countdownEl.className = 'countdown';
|
||||
if (expireAt > 0) {
|
||||
expireLineEl.textContent = '链接有效期至 ' + formatTs(expireAt);
|
||||
}
|
||||
}
|
||||
function startCountdown(remaining) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
var left = remaining;
|
||||
updateCountdown(left);
|
||||
countdownTimer = setInterval(function () {
|
||||
left -= 1;
|
||||
updateCountdown(left);
|
||||
if (left <= 0) clearInterval(countdownTimer);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyStatus(data) {
|
||||
var amount = data.amount;
|
||||
var bonus = data.bonus_amount;
|
||||
var total = data.total_amount;
|
||||
if (amount !== undefined) {
|
||||
document.getElementById('amount').textContent = Number(amount).toFixed(2);
|
||||
document.getElementById('bonus').textContent = Number(bonus || 0).toFixed(2);
|
||||
document.getElementById('total').textContent = Number(total || 0).toFixed(2);
|
||||
document.getElementById('amountBlock').style.display = 'block';
|
||||
}
|
||||
if (data.expire_at) expireAt = data.expire_at;
|
||||
var remaining = data.remaining_seconds !== undefined ? data.remaining_seconds : Math.max(0, expireAt - Math.floor(Date.now() / 1000));
|
||||
startCountdown(remaining);
|
||||
|
||||
if (data.status === 'paid') {
|
||||
showInfo('该订单已入账。');
|
||||
btnPay.style.display = 'none';
|
||||
showBackGame(data.return_game_url);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'pending_review') {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
showInfo('您已提交支付,请等待管理员审核通过后到账。');
|
||||
showBackGame(data.return_game_url);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
showErr(data.reject_reason || '订单已失效或超时,请重新发起充值。');
|
||||
btnPay.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!data.can_pay) {
|
||||
btnPay.disabled = true;
|
||||
if (remaining <= 0) {
|
||||
showErr('支付链接已过期,请返回应用重新发起充值。');
|
||||
}
|
||||
} else {
|
||||
btnPay.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
if (!orderNo || !sign || expireAt <= 0) {
|
||||
showErr('链接无效或缺少签名参数,请通过 App 内「去支付」打开完整链接。');
|
||||
btnPay.disabled = true;
|
||||
countdownEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var statusUrl = apiBase + '/api/finance/mockDepositStatus?' + new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString();
|
||||
fetch(statusUrl, { method: 'GET', credentials: 'omit' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1 && res.data) {
|
||||
applyStatus(res.data);
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '无法加载订单信息');
|
||||
btnPay.disabled = true;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('无法连接支付服务,请检查网络或 api_base 配置。');
|
||||
btnPay.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
btnPay.onclick = function () {
|
||||
if (!orderNo || !sign || expireAt <= 0) return;
|
||||
btnPay.disabled = true;
|
||||
btnPay.textContent = '处理中...';
|
||||
var confirmUrl = apiBase + '/api/finance/mockDepositConfirm';
|
||||
fetch(confirmUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
order_no: orderNo,
|
||||
expire_at: String(expireAt),
|
||||
sign: sign
|
||||
}).toString()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res && res.code === 1) {
|
||||
document.getElementById('okBox').style.display = 'block';
|
||||
btnPay.style.display = 'none';
|
||||
document.getElementById('msg').innerHTML = '';
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
countdownEl.textContent = '已提交,等待后台审核';
|
||||
countdownEl.className = 'countdown';
|
||||
if (res.data && res.data.return_game_url) {
|
||||
showBackGame(res.data.return_game_url);
|
||||
} else {
|
||||
showBackGame('');
|
||||
}
|
||||
} else {
|
||||
showErr((res && res.message) ? res.message : '提交失败');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showErr('网络错误,请稍后重试。');
|
||||
btnPay.disabled = false;
|
||||
btnPay.textContent = '确认支付';
|
||||
});
|
||||
};
|
||||
|
||||
loadStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@ import createAxios from '/@/utils/axios'
|
||||
|
||||
export function getAdminRules() {
|
||||
return createAxios({
|
||||
url: '/admin/auth.Rule/index',
|
||||
url: '/admin/auth.Group/rules',
|
||||
method: 'get',
|
||||
params: {
|
||||
force_menu_zh: 1,
|
||||
|
||||
@@ -85,6 +85,18 @@ export default {
|
||||
'Newly added tasks will never start because they are blocked by failed tasks!(Web terminal)',
|
||||
'Failed to modify the source command, Please try again manually': 'Failed to modify the source command. Please try again manually.',
|
||||
},
|
||||
docs36ziMobileApi: {
|
||||
download: 'Download Markdown',
|
||||
load_fail: 'Failed to load document',
|
||||
doc_title: '36 Zihua Mobile API Design Draft',
|
||||
nav_outline: 'Outline',
|
||||
},
|
||||
docsCommissionShare: {
|
||||
download: 'Download Markdown',
|
||||
load_fail: 'Failed to load document',
|
||||
doc_title: 'Commission Share Guide',
|
||||
nav_outline: 'Outline',
|
||||
},
|
||||
vite: {
|
||||
Later: '稍后',
|
||||
'Restart hot update': '重启热更新',
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
admin_id: 'admin_id',
|
||||
admin_username: 'admin_username',
|
||||
channel_id: 'channel_id',
|
||||
channel_name: 'channel',
|
||||
balance: 'available_balance',
|
||||
frozen_balance: 'frozen_balance',
|
||||
total_income: 'total_income',
|
||||
|
||||
@@ -11,7 +11,14 @@ export default {
|
||||
balance_before: 'balance_before',
|
||||
balance_after: 'balance_after',
|
||||
ref_type: 'ref_type',
|
||||
ref_title: 'reference_title',
|
||||
ref_id: 'ref_id',
|
||||
'ref title agent_commission_record': 'Agent commission',
|
||||
'ref title admin_withdraw_order': 'Admin withdraw order',
|
||||
'ref title withdraw_order': 'Withdraw order',
|
||||
'ref title deposit_order': 'Deposit order',
|
||||
'ref title bet_order': 'Bet order',
|
||||
'ref title admin_user_wallet_adjust': 'Admin wallet adjustment',
|
||||
idempotency_key: 'idempotency_key',
|
||||
operator_admin_username: 'operator_admin',
|
||||
remark: 'remark',
|
||||
|
||||
@@ -7,9 +7,16 @@ export default {
|
||||
channel_name: 'Channel',
|
||||
admin_id: 'Agent admin',
|
||||
admin_username: 'Agent username',
|
||||
commission_rate: 'Commission rate',
|
||||
calc_base_amount: 'Calculation base amount',
|
||||
commission_amount: 'Commission amount',
|
||||
calc_base_amount: 'Settlement base',
|
||||
share_rate: 'Share rate',
|
||||
commission_rate: 'Effective rate',
|
||||
commission_amount: 'Commission (gross)',
|
||||
commission_share_percent: 'Share of total',
|
||||
handling_fee_rate: 'Handling fee rate',
|
||||
handling_fee: 'Handling fee',
|
||||
net_commission_amount: 'Net commission',
|
||||
filter_by_settlement_no: 'Filtered by settlement no.: {no}',
|
||||
reset_settlement_filter: 'Reset filter',
|
||||
status: 'Status',
|
||||
'status 0': 'Pending',
|
||||
'status 1': 'Paid',
|
||||
|
||||
@@ -15,5 +15,6 @@ export default {
|
||||
remark: 'Remark',
|
||||
create_time: 'Created',
|
||||
update_time: 'Updated',
|
||||
view_commission_records: 'View commission records',
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ export default {
|
||||
username: 'Username',
|
||||
nickname: 'Nickname',
|
||||
group: 'Group',
|
||||
channel: 'Channel',
|
||||
channel_inherit_from_parent: 'Channel is inherited from the selected parent agent and cannot be changed separately.',
|
||||
parent_admin: 'Parent agent',
|
||||
commission_share_rate: 'Commission share (%)',
|
||||
avatar: 'Avatar',
|
||||
email: 'Email',
|
||||
mobile: 'Mobile Number',
|
||||
@@ -12,4 +16,19 @@ export default {
|
||||
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
|
||||
'Personal signature': 'Personal Signature',
|
||||
'Administrator login': 'Administrator Login Name',
|
||||
'Manage subordinate agents here':
|
||||
'Manage administrators within your role-group scope (all accounts in your groups and child groups, plus agents in your downline).',
|
||||
'Parent admin placeholder': 'Leave empty for top-level channel agent',
|
||||
'Top level group parent hint':
|
||||
'The selected role group is top-level; no parent agent is required. Settlement uses the channel commission share configured here.',
|
||||
'Top level share formula hint':
|
||||
'For a top-level role group, this rate is the share (%) of the channel period total commission. Amount = channel period total × this rate; sub-agents still take their share from their parent’s received amount.',
|
||||
'Channel root share remainder hint':
|
||||
'Other top-level agents on this channel already use {used}%; remaining allocatable {remaining}%. After saving this value, about {after}% remains on the channel.',
|
||||
'Channel root share remainder none': 'Top-level shares on this channel already total 100%; no remaining allocatable share.',
|
||||
'Share remainder hint':
|
||||
'Siblings already use {used}%; remaining allocatable {remaining}%. After saving this value, parent keeps about {after}%.',
|
||||
'Share remainder none for parent': 'Sibling shares total 100%; the parent will keep no commission at this level.',
|
||||
'Share remainder none after current': 'This rate exceeds the remaining allocatable share. Please lower it before saving.',
|
||||
'Commission share rate range': 'Commission share must be between 0 and 100',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ export default {
|
||||
agent_mode_desc_affiliate_2: 'Formula: net loss after costs × affiliate share rate.',
|
||||
agent_mode_desc_affiliate_3: 'Fields: supports fee deduction rate and carryover balance; turnover rate is cleared automatically.',
|
||||
turnover_share_rate: 'turnover_share_rate',
|
||||
settlement_handling_fee: 'settlement_handling_fee(%)',
|
||||
settlement_handling_fee_tip: 'Percent of gross commission per agent per period; adjustable in manual settle',
|
||||
settlement_handling_fee_percent: 'handling_fee(%)',
|
||||
settlement_handling_fee_amount: 'handling_fee_amount',
|
||||
manual_settle_settlement_base: 'settlement_base',
|
||||
manual_settle_net_commission: 'net_commission',
|
||||
manual_settle_col_base: 'Base',
|
||||
manual_settle_col_share: 'Share',
|
||||
manual_settle_col_gross: 'Gross',
|
||||
manual_settle_col_commission_share: 'Share%',
|
||||
manual_settle_col_net_short: 'Net',
|
||||
manual_settle_col_fee: 'Fee',
|
||||
manual_settle_col_net: 'Net',
|
||||
affiliate_share_rate: 'affiliate_share_rate',
|
||||
affiliate_fee_rate: 'affiliate_fee_rate',
|
||||
affiliate_contract_no: 'affiliate_contract_no',
|
||||
@@ -72,6 +85,19 @@ export default {
|
||||
manual_settle_commission_amount: 'Commission amount',
|
||||
manual_settle_submit: 'Settle',
|
||||
manual_settle_remark: 'Remark',
|
||||
manual_settle_calc_title: 'Settlement calculation',
|
||||
manual_settle_calc_intro_turnover_1: 'For this period, sum settled bets: total bet and total payout; platform PnL = bet − payout.',
|
||||
manual_settle_calc_intro_turnover_2: 'Channel commission = settlement base (total bet) × turnover share rate; the table below splits it by agent tree.',
|
||||
manual_settle_calc_intro_turnover_3: 'Commission applies only when platform-wide PnL is positive; otherwise channel commission is 0.',
|
||||
manual_settle_calc_intro_affiliate_1: 'For this period, affiliate commission is based on net player loss and ladder rules in this channel.',
|
||||
manual_settle_calc_intro_affiliate_2: 'Channel commission = base after costs × share rate; split in the table by agent hierarchy.',
|
||||
manual_settle_calc_intro_affiliate_3: 'If net loss is not positive, commission may be 0 — use preview amounts.',
|
||||
manual_settle_calc_tree_1: 'Expand/collapse the tree; each row is one agent share in this settlement.',
|
||||
manual_settle_calc_tree_2: 'Settlement base = amount from parent; share % = cut from parent pool; commission = gross before fee.',
|
||||
manual_settle_calc_tree_3: 'Commission share % = agent gross ÷ channel total commission × 100%.',
|
||||
manual_settle_calc_handling_fee: 'Net = gross − fee; fee uses channel rate {rate}% of gross, once per agent per period.',
|
||||
manual_settle_col_net: 'Net',
|
||||
manual_settle_split_scroll_tip: 'Swipe horizontally to view all columns',
|
||||
share_config: 'Share config',
|
||||
share_config_title: 'Channel admin share config',
|
||||
share_config_tip: 'Only enabled rows participate in settlement split, and enabled share total must equal 100%.',
|
||||
@@ -83,6 +109,34 @@ export default {
|
||||
settle_stats_enabled: 'Enabled channels',
|
||||
settle_stats_pending_dividend: 'Channels pending dividend',
|
||||
settle_stats_pending_amount: 'Pending dividend amount',
|
||||
settle_stats_paid_dividend: 'Paid dividend amount',
|
||||
settle_stats_company_total_bet: 'Company total bet',
|
||||
direct_bet_amount: 'Direct bet amount',
|
||||
direct_bet_amount_tip: 'Total bets from all players under this channel (including unsettled)',
|
||||
company_bet_record_dialog_title: 'Company bet records',
|
||||
direct_bet_record_dialog_title: 'Direct player game bets',
|
||||
dividend_record_dialog_title: 'Paid dividend records',
|
||||
bet_record_period_no: 'Period No.',
|
||||
bet_record_user_username: 'Player',
|
||||
bet_record_total_amount: 'Bet amount',
|
||||
bet_record_win_amount: 'Win amount',
|
||||
bet_record_channel_name: 'Channel',
|
||||
bet_record_pick_numbers: 'Picks',
|
||||
bet_record_result_number: 'Winning number',
|
||||
bet_record_win_hit: 'Win status',
|
||||
bet_record_win_hit_won: 'Won',
|
||||
bet_record_win_hit_lost: 'Lost',
|
||||
bet_record_win_hit_pending: 'Pending',
|
||||
bet_record_pick_filter_placeholder: 'Number',
|
||||
bet_record_summary_count: 'Total records',
|
||||
bet_record_summary_bet: 'Total bet',
|
||||
bet_record_summary_win: 'Total win',
|
||||
dividend_settlement_no: 'Settlement No.',
|
||||
dividend_channel_name: 'Channel',
|
||||
dividend_admin_username: 'Agent',
|
||||
dividend_commission_amount: 'Dividend amount',
|
||||
dividend_settled_at: 'Paid at',
|
||||
dividend_period_range: 'Settlement period',
|
||||
settle_filter_all: 'All',
|
||||
settle_filter_with_balance: 'With dividend balance',
|
||||
settle_filter_no_balance: 'No dividend balance',
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
export default {
|
||||
desc: 'Mobile pay & receipt settings: platform coin labels, currencies and rates, deposit pay channels (on/off and sort; all tiers are supported automatically), withdraw banks, limits, copy, and required withdraw fields.',
|
||||
desc: 'Mobile pay & receipt settings: platform coin labels, currencies and rates, deposit pay channels (on/off and sort; supported currencies per channel), withdraw banks, limits and copy. DDPay-required fields follow the gateway doc and API contracts—not toggles on this page.',
|
||||
btn_save: 'Save',
|
||||
btn_add_row: 'Add row',
|
||||
sec_platform: 'Platform coin labels',
|
||||
tab_cashier: 'Deposit/Withdraw config',
|
||||
tab_tiers: 'Deposit tiers',
|
||||
tab_deposit_banks: 'Deposit banks',
|
||||
tab_withdraw_banks: 'Withdraw banks',
|
||||
platform_label_zh: 'Label (Chinese)',
|
||||
platform_label_en: 'Label (English)',
|
||||
sec_currencies: 'Currencies (deposit/withdraw selectors)',
|
||||
sec_deposit_channels: 'Deposit pay channels',
|
||||
deposit_channels_hint: 'Display names come from the registry; here you only set enabled state and sort order. All enabled channels automatically support all deposit tiers.',
|
||||
deposit_channels_hint: 'Only the DDPay channel is built-in. Set enabled state, sort and supported fiat currencies per row.',
|
||||
currency_rates_hint: 'Deposit rate: platform coins credited per 1 fiat paid. Withdraw rate: platform coins needed per 1 fiat redeemed (e.g. 100 ⇒ 100 coins = 1 fiat unit).',
|
||||
err_dup_code: 'Duplicate currency codes are not allowed.',
|
||||
sec_banks: 'Withdraw bank codes',
|
||||
sec_deposit_banks: 'Deposit banks by currency',
|
||||
deposit_banks_hint: 'Used for DDPay deposit bank options and client display, maintained by currency_code.',
|
||||
sec_withdraw_banks: 'Withdraw banks by currency',
|
||||
withdraw_banks_hint: 'Used for withdrawCreate bank_code mapping and client display, maintained by currency_code.',
|
||||
sec_deposit_tiers: 'Deposit tiers (embedded)',
|
||||
sec_tabbed_config: 'Banks & deposit tiers (tabs)',
|
||||
deposit_tiers_hint: 'Moved into this page. When deleting currencies, related tiers will be previewed and removed together after confirmation.',
|
||||
btn_save_tiers: 'Save tiers',
|
||||
sec_limits: 'Minimum withdraw (fiat amount; match your copy currency)',
|
||||
min_ewallet: 'E-wallet minimum',
|
||||
min_bank: 'Bank minimum',
|
||||
@@ -24,11 +35,17 @@ export default {
|
||||
processing_en: 'Processing note (EN)',
|
||||
fee_note_zh: 'Fee note (ZH)',
|
||||
fee_note_en: 'Fee note (EN)',
|
||||
sec_fields: 'Withdraw form (required)',
|
||||
field_cardholder: 'Cardholder name',
|
||||
field_bank_account: 'Bank account',
|
||||
field_email: 'Payee email',
|
||||
field_mobile: 'Payee mobile',
|
||||
sec_ddpay_spec: 'DDPay integration (read-only)',
|
||||
ddpay_spec_intro:
|
||||
'Withdrawals use DDPay Payout (mobile: withdrawCreate). Deposits with channel ddpay use depositCreate. Below is a short field summary; see the DDPay doc and the mobile API draft in the repo for details.',
|
||||
ddpay_spec_li_withdraw:
|
||||
'Withdraw (required): channel_code=ddpay, withdraw_coin, receive_type=bank, receive_account, receiver_name (as registered with the bank), receiver_email, receiver_mobile, bank_code (must match a code from “Withdraw banks by currency” on this page), idempotency_key; bank_branch optional (server sends N/A if omitted).',
|
||||
ddpay_spec_li_bank_table:
|
||||
'English bank name maps to DDPay bank[name] and must match the official full bank names, or payout may be rejected.',
|
||||
ddpay_spec_li_deposit:
|
||||
'Deposit (ddpay): payment_type (01=FPX, 02=duitnow, 03=ewallet), payer_name, payer_bank_name (full name per official deposit bank list).',
|
||||
ddpay_spec_li_doc:
|
||||
'Gateway doc: docs/DDPay Payment Gateway_v1.1.3_zh.md (and PDF); HTTPS and callback rules follow the vendor spec.',
|
||||
col_code: 'Code',
|
||||
col_label_zh: 'Name (ZH)',
|
||||
col_label_en: 'Name (EN)',
|
||||
@@ -36,6 +53,17 @@ export default {
|
||||
col_deposit_rate: 'Deposit rate',
|
||||
col_withdraw_rate: 'Withdraw rate',
|
||||
col_bank_code: 'Bank code',
|
||||
col_currency_code: 'Currency',
|
||||
col_tier_id: 'Tier ID',
|
||||
col_title_zh: 'Title (ZH)',
|
||||
col_title_en: 'Title (EN)',
|
||||
col_pay_amount: 'Pay amount',
|
||||
col_amount: 'Platform amount',
|
||||
col_bonus_amount: 'Bonus',
|
||||
col_tier_sort: 'Sort',
|
||||
col_tier_status: 'Enabled',
|
||||
msg_delete_currency_prune_tiers: 'Deleting currency {currency} will also remove {count} related deposit tier(s). Continue?',
|
||||
msg_affected_tier_ids: 'Affected tiers',
|
||||
col_name_zh: 'Bank (ZH)',
|
||||
col_name_en: 'Bank (EN)',
|
||||
ph_ratio: 'e.g. 100',
|
||||
@@ -45,4 +73,5 @@ export default {
|
||||
ch_display_name: 'Display name',
|
||||
ch_sort: 'Sort',
|
||||
ch_status: 'Enabled',
|
||||
ch_currency_codes: 'Supported currencies',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
desc: 'Streak levels 1–10: payout = bet total × odds_factor. Jackpot rows trigger jackpot.hit on the user private channel, public-game-period, and public-operation-notice when won.',
|
||||
desc: 'Streak levels 1–10: payout = bet total × odds_factor. All wins publish bet.win (data.is_jackpot marks jackpot tier); jackpot rows also publish public jackpot.hit.',
|
||||
btn_save: 'Save',
|
||||
streak: 'Streak (rounds)',
|
||||
odds_factor: 'Odds factor',
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
export default {
|
||||
'quick Search Fields': 'ID / Key / Remark',
|
||||
'form tab label': 'Game config',
|
||||
'save success': 'Saved successfully',
|
||||
'field period_auto_create_enabled': 'Auto create next period',
|
||||
'field_tip period_auto_create_enabled': 'Whether to automatically create the next period',
|
||||
'field period_manual_create_enabled': 'Manual create next period',
|
||||
'field_tip period_manual_create_enabled': 'Whether manual creation of the next period is allowed',
|
||||
'field period_seconds': 'Period duration (seconds)',
|
||||
'field_tip period_seconds': 'Duration of each period in seconds',
|
||||
'field bet_seconds': 'Betting duration (seconds)',
|
||||
'field_tip bet_seconds': 'How many seconds betting stays open in each period',
|
||||
'field payout_seconds': 'Payout duration (seconds)',
|
||||
'field_tip payout_seconds': 'Grace period after draw before the next period starts; lock/calc duration = period duration − betting duration',
|
||||
'field pick_max_number_count': 'Max numbers per ticket',
|
||||
'field_tip pick_max_number_count': 'Maximum amount of selectable numbers per ticket',
|
||||
'field bet_chips': 'Quick chip amounts',
|
||||
'field_tip bet_chips': 'Exactly 6 tiers (ids 1–6, fixed). Edit amounts only; stored as JSON for lobbyInit.',
|
||||
bet_chips_colon: ': ',
|
||||
bet_chips_validate_slot: 'Chip tier {slot} must be a number greater than 0 (prefer ≥ min bet per number)',
|
||||
'field default_bet_chip_id': 'Default selected chip id',
|
||||
'field_tip default_bet_chip_id': 'Default highlighted chip id (1–6), must map to a valid amount in bet_chips',
|
||||
'field min_bet_per_number': 'Min bet per number',
|
||||
'field_tip min_bet_per_number': 'Minimum bet amount per selected number',
|
||||
'field max_bet_per_number': 'Max bet per number',
|
||||
'field_tip max_bet_per_number': 'Maximum bet amount per selected number',
|
||||
'field withdraw_bet_flow_ratio': 'Withdraw flow ratio',
|
||||
'field_tip withdraw_bet_flow_ratio': 'Required betting flow ratio before withdrawal',
|
||||
'field jackpot_max_amount': 'Jackpot review threshold',
|
||||
'field_tip jackpot_max_amount': 'Winning amount threshold that requires manual jackpot review',
|
||||
id: 'ID',
|
||||
config_key: 'Config key',
|
||||
config_value: 'Value',
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
bet_countdown: 'Bet left',
|
||||
draw_countdown: 'Draw left',
|
||||
payout_countdown: 'Payout left',
|
||||
payout_na: '—',
|
||||
payout_na: '?',
|
||||
payout_phase: 'Payout in progress',
|
||||
action_panel: 'Actions',
|
||||
manual_draw_number: 'Scheduled draw',
|
||||
@@ -17,22 +17,32 @@ export default {
|
||||
calc_estimated_loss: 'Estimated payout',
|
||||
push_connected: 'Realtime connection established',
|
||||
push_disconnected: 'Polling mode enabled (push removed)',
|
||||
ws_connected: 'Connected to real-time match',
|
||||
ws_disconnected: 'Realtime push disconnected, using polling',
|
||||
snapshot_load_failed: 'Failed to load match snapshot, please retry',
|
||||
ws_panel_title: 'Admin WebSocket (vs. mobile lightweight stream)',
|
||||
ws_reload_config: 'Load WS config',
|
||||
ws_connect: 'Connect WS',
|
||||
ws_disconnect: 'Disconnect WS',
|
||||
ws_log_empty: 'No WebSocket logs yet.',
|
||||
jackpot_hit_tip: 'Jackpot winners detected in this round.',
|
||||
candidate_title: 'Candidate payout estimates',
|
||||
number: 'Number',
|
||||
estimated_loss: 'Estimated payout',
|
||||
bet_stream_title: 'Realtime bet stream',
|
||||
bet_id: 'Bet ID',
|
||||
user_id: 'Player ID',
|
||||
username: 'Username',
|
||||
pick_numbers: 'Pick numbers',
|
||||
total_amount: 'Total bet amount',
|
||||
win_amount: 'Payout amount',
|
||||
streak_at_bet: 'Streak at bet',
|
||||
runtime_switch: 'Game runtime',
|
||||
runtime_switch: 'Auto-create next round',
|
||||
countdown_maintenance: 'Maintenance',
|
||||
runtime_draining_banner:
|
||||
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
|
||||
runtime_maintenance_banner:
|
||||
'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.',
|
||||
runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.',
|
||||
runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn on auto-create to resume; a new round is created when idle.',
|
||||
runtime_off_tip: 'When enabling auto-create with no active round, a new period is created immediately.',
|
||||
void_btn: 'Void round',
|
||||
void_dialog_title: 'Void current round',
|
||||
void_reason_label: 'Reason',
|
||||
|
||||
@@ -17,11 +17,24 @@ export default {
|
||||
'status 1': 'Pending draw',
|
||||
'status 2': 'Settled',
|
||||
'status 3': 'Refunded',
|
||||
'status 4': 'Returned (review rejected)',
|
||||
'status 5': 'Pending review',
|
||||
review_title: 'Win review',
|
||||
review_open: 'Review',
|
||||
review_approve: 'Approve',
|
||||
review_reject: 'Reject',
|
||||
review_remark: 'Reject remark',
|
||||
review_remark_placeholder: 'Please enter reject reason',
|
||||
review_remark_required: 'Remark is required when rejecting',
|
||||
review_approve_confirm: 'Confirm approve and pay out?',
|
||||
review_approve_success: 'Approved and paid out',
|
||||
review_reject_success: 'Rejected',
|
||||
idempotency_key: 'Idempotency key',
|
||||
create_time: 'Created',
|
||||
update_time: 'Updated',
|
||||
gameRecord_period_no: 'Round (relation)',
|
||||
gameRecord_status: 'Round status',
|
||||
result_number: 'Result number',
|
||||
'gameRecord_status 0': 'Open for betting',
|
||||
'gameRecord_status 1': 'Closed',
|
||||
'gameRecord_status 2': 'Settling tickets',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
'quick Search Fields': 'Round No. / ID',
|
||||
'quick Search Fields': 'Period No. / ID',
|
||||
id: 'ID',
|
||||
period_no: 'Round No.',
|
||||
period_no: 'Period No.',
|
||||
period_start_at: 'Start time',
|
||||
status: 'Status',
|
||||
'status 0': 'Betting open',
|
||||
@@ -10,10 +10,10 @@ export default {
|
||||
'status 3': 'Paying',
|
||||
'status 4': 'Ended',
|
||||
'status 5': 'Void',
|
||||
'status 6': 'Abnormal',
|
||||
draw_mode: 'Draw mode',
|
||||
'draw_mode 0': 'Auto AI',
|
||||
'draw_mode 1': 'Manual preset',
|
||||
preset_number: 'Preset number',
|
||||
result_number: 'Result number',
|
||||
platform_profit_amount: 'Round P/L (platform)',
|
||||
winner_user_count: 'Winners',
|
||||
@@ -26,4 +26,23 @@ export default {
|
||||
manual_create_label: 'Allow manual create next round',
|
||||
manual_create_tip: 'When enabled, button below can create next round manually',
|
||||
btn_create_next: 'Create next round (manual)',
|
||||
view_play_records: 'View play records',
|
||||
play_record_dialog_title: 'Play (bet) records',
|
||||
play_record_col_id: 'ID',
|
||||
play_record_col_user: 'Username',
|
||||
play_record_col_channel: 'Channel',
|
||||
play_record_col_pick_numbers: 'Picks',
|
||||
play_record_col_total_amount: 'Play amount',
|
||||
play_record_col_is_auto: 'Auto',
|
||||
play_record_col_win_amount: 'Payout',
|
||||
play_record_col_jackpot_extra_amount: 'Jackpot extra',
|
||||
play_record_col_status: 'Status',
|
||||
play_record_col_create_time: 'Created',
|
||||
play_record_is_auto_0: 'Manual',
|
||||
play_record_is_auto_1: 'Auto bet',
|
||||
play_record_status_1: 'Pending draw',
|
||||
play_record_status_2: 'Settled',
|
||||
play_record_status_3: 'Refunded',
|
||||
play_record_status_4: 'Returned',
|
||||
load_play_record_failed: 'Failed to load play records',
|
||||
}
|
||||
|
||||
@@ -15,10 +15,15 @@ export default {
|
||||
review_admin_username: 'Reviewer',
|
||||
remark: 'Remark',
|
||||
create_time: 'Create time',
|
||||
review_btn: 'Review',
|
||||
review_title: 'Withdraw review',
|
||||
review_btn_approve: 'Approve',
|
||||
review_btn_reject: 'Reject',
|
||||
review_btn_back: 'Back',
|
||||
review_btn_confirm_reject: 'Confirm reject',
|
||||
review_approve_title: 'Approve order',
|
||||
review_reject_title: 'Reject order',
|
||||
review_reject_tip: 'Rejected orders will unfreeze the amount back to the admin wallet',
|
||||
review_remark_optional: 'Optional review remark',
|
||||
reject_reason_required: 'Please enter reject reason',
|
||||
stat_total_count: 'Total orders',
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
'status 0': 'Pending',
|
||||
'status 1': 'Success',
|
||||
'status 2': 'Failed',
|
||||
'status 3': 'Canceled',
|
||||
'status 3': 'Pending review',
|
||||
pay_channel: 'Pay channel',
|
||||
pay_time: 'Pay time',
|
||||
deposit_tier_id: 'Deposit tier',
|
||||
@@ -23,4 +23,18 @@ export default {
|
||||
channel_name: 'Channel',
|
||||
detail_title: 'Deposit Order Detail',
|
||||
close_btn: 'Close',
|
||||
reject_reason: 'Reject reason',
|
||||
review_admin_username: 'Reviewer',
|
||||
review_time: 'Review time',
|
||||
review_title: 'Deposit review',
|
||||
review_reject_title: 'Reject deposit',
|
||||
review_btn: 'Review',
|
||||
review_btn_approve: 'Approve',
|
||||
review_btn_reject: 'Reject',
|
||||
review_btn_back: 'Back',
|
||||
review_btn_confirm_reject: 'Confirm reject',
|
||||
review_reject_tip: 'After rejection, the order is marked failed and no funds will be credited.',
|
||||
review_reject_placeholder: 'Enter reject reason',
|
||||
reject_reason_required: 'Please enter reject reason',
|
||||
already_reviewed: 'This order has already been reviewed',
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ export default {
|
||||
amount: 'Apply amount',
|
||||
fee: 'Fee',
|
||||
actual_amount: 'Actual amount',
|
||||
pay_channel: 'Pay channel',
|
||||
receive_type: 'Receive type',
|
||||
receive_account: 'Receive account',
|
||||
receiver_name: 'Cardholder name',
|
||||
receiver_email: 'Receiver email',
|
||||
receiver_mobile: 'Receiver mobile',
|
||||
idempotency_key: 'Idempotency key',
|
||||
status: 'Status',
|
||||
'status 0': 'Pending review',
|
||||
@@ -24,6 +28,7 @@ export default {
|
||||
channel_name: 'Channel',
|
||||
review_admin_username: 'Reviewer',
|
||||
review_title: 'Withdraw review',
|
||||
review_btn: 'Review',
|
||||
review_reject_title: 'Reject withdraw',
|
||||
review_btn_approve: 'Approve',
|
||||
review_btn_reject: 'Reject',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default {
|
||||
tip: 'WebSocket connection test for status stream: listen to period.tick / period.opened events.',
|
||||
tip: 'WebSocket test: load config then connect to auto-subscribe. Period state via period.tick; player odds via user.streak / wallet.changed / bet.accepted (no game.config full table).',
|
||||
}
|
||||
|
||||
@@ -12,5 +12,12 @@ export default {
|
||||
btn_clear: 'Clear log',
|
||||
log_title: 'WebSocket log',
|
||||
log_empty: 'No logs yet. Connect first and then send a message.',
|
||||
subscribe_topics: 'Auto-subscribe topics',
|
||||
odds_push_topics: 'Odds push topics',
|
||||
player_odds_fields: 'Player odds fields',
|
||||
test_player_odds: 'Test player odds (config preview)',
|
||||
test_player_odds_hint: 'After connect and subscribe, the server pushes demo frames with is_test / preview (from highest-streak sample user in DB).',
|
||||
test_source_db: 'Sample user from DB',
|
||||
test_source_synthetic: 'Synthetic demo',
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
section_admin_attribution: 'Administrator',
|
||||
admin_affiliation: 'Assigned admin',
|
||||
admin_affiliation_placeholder: 'Role group tree — only admins in your scope',
|
||||
admin_no_channel_bound: 'The selected admin is not bound to a channel; bind a channel in Admin management before creating users',
|
||||
register_invite_code_auto_placeholder: 'Filled from selected admin invite code',
|
||||
channel_id: 'Channel',
|
||||
channel__name: 'Channel',
|
||||
|
||||
@@ -85,6 +85,18 @@ export default {
|
||||
'Newly added tasks will never start because they are blocked by failed tasks': '新添加的任务永远不会开始,因为被失败的任务阻塞!(WEB终端)',
|
||||
'Failed to modify the source command, Please try again manually': '修改源的命令执行失败,请手动重试。',
|
||||
},
|
||||
docs36ziMobileApi: {
|
||||
download: '下载 Markdown',
|
||||
load_fail: '文档加载失败',
|
||||
doc_title: '36字花移动端接口设计草案',
|
||||
nav_outline: '目录',
|
||||
},
|
||||
docsCommissionShare: {
|
||||
download: '下载 Markdown',
|
||||
load_fail: '文档加载失败',
|
||||
doc_title: '分红说明文档',
|
||||
nav_outline: '目录',
|
||||
},
|
||||
vite: {
|
||||
Later: '稍后',
|
||||
'Restart hot update': '重启热更新',
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
admin_id: '管理员ID',
|
||||
admin_username: '管理员账号',
|
||||
channel_id: '渠道ID',
|
||||
channel_name: '渠道',
|
||||
balance: '可用余额',
|
||||
frozen_balance: '冻结余额',
|
||||
total_income: '累计入账',
|
||||
|
||||
@@ -11,7 +11,14 @@ export default {
|
||||
balance_before: '变动前余额',
|
||||
balance_after: '变动后余额',
|
||||
ref_type: '来源类型',
|
||||
ref_title: '关联标题',
|
||||
ref_id: '来源ID',
|
||||
'ref title agent_commission_record': '代理分红记录',
|
||||
'ref title admin_withdraw_order': '管理员提现单',
|
||||
'ref title withdraw_order': '用户提现单',
|
||||
'ref title deposit_order': '用户充值单',
|
||||
'ref title bet_order': '注单记录',
|
||||
'ref title admin_user_wallet_adjust': '后台钱包调整',
|
||||
idempotency_key: '幂等键',
|
||||
operator_admin_username: '操作管理员',
|
||||
remark: '备注',
|
||||
|
||||
@@ -7,9 +7,16 @@ export default {
|
||||
channel_name: '渠道名称',
|
||||
admin_id: '代理管理员',
|
||||
admin_username: '代理账号',
|
||||
commission_rate: '佣金比例',
|
||||
calc_base_amount: '结算基数',
|
||||
commission_amount: '佣金金额',
|
||||
share_rate: '分配比例',
|
||||
commission_rate: '有效比例',
|
||||
commission_amount: '佣金(费前)',
|
||||
commission_share_percent: '占比',
|
||||
handling_fee_rate: '手续费比例',
|
||||
handling_fee: '手续费',
|
||||
net_commission_amount: '实发佣金',
|
||||
filter_by_settlement_no: '当前筛选结算周期号:{no}',
|
||||
reset_settlement_filter: '重置筛选',
|
||||
status: '状态',
|
||||
'status 0': '待发放',
|
||||
'status 1': '已发放',
|
||||
|
||||
@@ -15,5 +15,6 @@ export default {
|
||||
remark: '备注',
|
||||
create_time: '创建时间',
|
||||
update_time: '更新时间',
|
||||
view_commission_records: '查看代理佣金记录',
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ export default {
|
||||
username: '用户名',
|
||||
nickname: '昵称',
|
||||
group: '角色组',
|
||||
channel: '渠道',
|
||||
channel_inherit_from_parent: '渠道继承自所选上级代理,不可单独修改',
|
||||
parent_admin: '上级代理',
|
||||
commission_share_rate: '分红比例(%)',
|
||||
avatar: '头像',
|
||||
email: '电子邮箱',
|
||||
mobile: '手机号',
|
||||
@@ -12,4 +16,16 @@ export default {
|
||||
'Please leave blank if not modified': '不修改请留空',
|
||||
'Personal signature': '个性签名',
|
||||
'Administrator login': '管理员登录名',
|
||||
'Manage subordinate agents here':
|
||||
'在此管理您角色组管理范围内的管理员(本人所在角色组及其下级组内的全部账号,并含您代理线下的下级)。',
|
||||
'Parent admin placeholder': '留空表示渠道顶级代理',
|
||||
'Top level group parent hint': '当前角色组为顶级角色组,无需绑定上级代理;系统将按渠道分红比例直接结算至该管理员。',
|
||||
'Top level share formula hint':
|
||||
'顶级角色组分红比例表示从渠道本期总佣金中分得的比例(%)。实得 = 渠道本期总佣金 × 本项分红比例;下级子代理仍从其上级实得中按各自比例抽取。',
|
||||
'Channel root share remainder hint': '同渠道顶级代理已分配 {used}%,当前剩余可设 {remaining}%;若本项设为当前值,保存后同渠道约剩 {after}%。',
|
||||
'Channel root share remainder none': '同渠道顶级代理比例已合计 100%,无法再分配。',
|
||||
'Share remainder hint': '同上级下已分配 {used}%,当前剩余可设 {remaining}%;若本项设为当前值,保存后上级约剩 {after}%。',
|
||||
'Share remainder none for parent': '同上级下子代理比例已合计 100%,上级在本层将无分红留存。',
|
||||
'Share remainder none after current': '当前比例将超过剩余可分配额度,请调低后再保存。',
|
||||
'Commission share rate range': '分红比例须在 0~100 之间',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,20 @@ export default {
|
||||
agent_mode_desc_affiliate_2: '计算方式:净客损扣除成本后 × 联营占成比例(affiliate_share_rate)。',
|
||||
agent_mode_desc_affiliate_3: '字段说明:可配置联营成本扣除比例与负结转余额;切换到该模式会自动清空返水比例。',
|
||||
turnover_share_rate: '返水分红比例',
|
||||
settlement_handling_fee: '代理结算手续费(%)',
|
||||
settlement_handling_fee_tip: '按该代理费前佣金的百分比扣除,每期每代理扣一次;手动结算时可单独调整',
|
||||
settlement_handling_fee_percent: '手续费(%)',
|
||||
settlement_handling_fee_amount: '手续费金额',
|
||||
manual_settle_settlement_base: '结算基数',
|
||||
manual_settle_net_commission: '实发佣金',
|
||||
manual_settle_col_base: '基数',
|
||||
manual_settle_col_share: '比例',
|
||||
manual_settle_col_gross: '佣金',
|
||||
manual_settle_col_commission_share: '占比',
|
||||
manual_settle_col_net_short: '实发',
|
||||
manual_settle_col_fee: '手续费',
|
||||
manual_settle_col_net: '实发',
|
||||
manual_settle_split_scroll_tip: '左右滑动查看全部分配列',
|
||||
affiliate_share_rate: '联营占成比例',
|
||||
affiliate_fee_rate: '联营成本扣除比例',
|
||||
affiliate_contract_no: '联营契约编号',
|
||||
@@ -72,6 +86,17 @@ export default {
|
||||
manual_settle_commission_amount: '佣金金额',
|
||||
manual_settle_submit: '结算',
|
||||
manual_settle_remark: '备注',
|
||||
manual_settle_calc_title: '结算计算说明',
|
||||
manual_settle_calc_intro_turnover_1: '本期统计区间内,汇总该渠道已结算注单的总投注额与总派彩额,平台盈亏 = 总投注 − 总派彩。',
|
||||
manual_settle_calc_intro_turnover_2: '渠道本期可分配佣金 = 结算基数(总投注额)× 返水分红比例;下方表格按代理树自上而下拆分该笔佣金。',
|
||||
manual_settle_calc_intro_turnover_3: '仅当平台大盘盈利时参与分红;若大盘亏损或持平,本期渠道佣金为 0。',
|
||||
manual_settle_calc_intro_affiliate_1: '本期统计区间内,按该渠道辖区净客损(平台盈亏)及联营规则计算可分配佣金。',
|
||||
manual_settle_calc_intro_affiliate_2: '渠道本期可分配佣金 = 扣除联营成本后的结算基数 × 阶梯占成比例;下方表格按代理树拆分。',
|
||||
manual_settle_calc_intro_affiliate_3: '净客损为负或持平时,本期可分配佣金可能为 0,以实际预览金额为准。',
|
||||
manual_settle_calc_tree_1: '分配树可点击展开/收起;每一行对应该代理在本期分到的金额。',
|
||||
manual_settle_calc_tree_2: '结算基数 = 上级代理结算后分给本代理的金额;分配比例 = 从上级佣金中抽取的百分比;佣金金额 = 本代理实得(费前)。',
|
||||
manual_settle_calc_tree_3: '佣金占比 = 该代理费前佣金 ÷ 渠道本期总佣金 × 100%,表示占全部可分配佣金的比例。',
|
||||
manual_settle_calc_handling_fee: '实发佣金 = 佣金金额 − 手续费金额;手续费按渠道配置 {rate}%(费前佣金比例)扣除,每期每代理扣一次。',
|
||||
share_config: '分配比例',
|
||||
share_config_title: '渠道管理员分配比例',
|
||||
share_config_tip: '仅启用项参与结算拆分,且启用项占比总和必须等于100%。',
|
||||
@@ -83,6 +108,34 @@ export default {
|
||||
settle_stats_enabled: '启用渠道',
|
||||
settle_stats_pending_dividend: '待分红渠道',
|
||||
settle_stats_pending_amount: '待分红总额',
|
||||
settle_stats_paid_dividend: '已分红金额',
|
||||
settle_stats_company_total_bet: '公司总投注额',
|
||||
direct_bet_amount: '直属投注额',
|
||||
direct_bet_amount_tip: '该渠道名下全部玩家的总投注额(含未结算)',
|
||||
company_bet_record_dialog_title: '公司总投注记录下注明细',
|
||||
direct_bet_record_dialog_title: '直属玩家游戏下注记录',
|
||||
dividend_record_dialog_title: '已分红记录',
|
||||
bet_record_period_no: '游戏期号',
|
||||
bet_record_user_username: '玩家名',
|
||||
bet_record_total_amount: '投注金额',
|
||||
bet_record_win_amount: '玩家中奖金额',
|
||||
bet_record_channel_name: '渠道',
|
||||
bet_record_pick_numbers: '选号',
|
||||
bet_record_result_number: '中奖号码',
|
||||
bet_record_win_hit: '中奖状态',
|
||||
bet_record_win_hit_won: '已中奖',
|
||||
bet_record_win_hit_lost: '未中奖',
|
||||
bet_record_win_hit_pending: '待开奖',
|
||||
bet_record_pick_filter_placeholder: '号码',
|
||||
bet_record_summary_count: '总笔数',
|
||||
bet_record_summary_bet: '总投注额',
|
||||
bet_record_summary_win: '总中奖额',
|
||||
dividend_settlement_no: '结算单号',
|
||||
dividend_channel_name: '渠道',
|
||||
dividend_admin_username: '代理账号',
|
||||
dividend_commission_amount: '分红金额',
|
||||
dividend_settled_at: '发放时间',
|
||||
dividend_period_range: '结算周期',
|
||||
settle_filter_all: '全部',
|
||||
settle_filter_with_balance: '有分红余额',
|
||||
settle_filter_no_balance: '无分红余额',
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
export default {
|
||||
desc: '配置移动端支付与收款展示:平台币名称、货币与汇率、充值支付渠道(开关/排序,自动兼容全部档位)、提现银行、最低限额、文案与提现表单字段。',
|
||||
desc: '配置移动端支付与收款展示:平台币名称、货币与汇率、充值支付渠道(开关/排序,以及按渠道配置支持币种)、提现银行、最低限额与文案。提现/充值与 DDPay 对接的必填字段由网关文档与接口约定,不在此页配置。',
|
||||
btn_save: '保存',
|
||||
btn_add_row: '新增一行',
|
||||
sec_platform: '平台币展示名',
|
||||
tab_cashier: '充值/提现配置',
|
||||
tab_tiers: '充值档位配置',
|
||||
tab_deposit_banks: '充值银行配置',
|
||||
tab_withdraw_banks: '提现银行配置',
|
||||
platform_label_zh: '名称(中文)',
|
||||
platform_label_en: '名称(英文)',
|
||||
sec_currencies: '货币列表(充值/提现货币下拉)',
|
||||
sec_deposit_channels: '充值支付渠道',
|
||||
deposit_channels_hint: '展示名由环境注册表决定,此处仅维护启用状态与排序;所有启用渠道自动兼容全部充值档位。',
|
||||
deposit_channels_hint: '当前仅 DDPay 渠道。展示名由注册表决定;此处维护启用状态、排序与各渠道支持的法币币种。',
|
||||
currency_rates_hint: '充值汇率:每支付 1 单位该货币到账的平台币;提现汇率:每兑换 1 单位该货币所需平台币(例 100 表示 100 平台币 = 1 单位)。',
|
||||
err_dup_code: '货币代码不能重复,请检查后再保存。',
|
||||
sec_banks: '提现支持银行代码',
|
||||
sec_deposit_banks: '充值支持银行(按币种)',
|
||||
deposit_banks_hint: '用于 DDPay 入金参数辅助与客户端展示,按 currency_code 维护可选银行。',
|
||||
sec_withdraw_banks: '提现支持银行(按币种)',
|
||||
withdraw_banks_hint: '用于 withdrawCreate 的 bank_code 映射与客户端展示,按 currency_code 维护。',
|
||||
sec_deposit_tiers: '充值档位(内嵌维护)',
|
||||
sec_tabbed_config: '银行与充值档位(标签页)',
|
||||
deposit_tiers_hint: '已并入当前页面。删除币种时若存在关联档位,将提示并同步删除对应档位。',
|
||||
btn_save_tiers: '保存档位',
|
||||
sec_limits: '提现最低限额(法币金额,与文案中币种一致)',
|
||||
min_ewallet: '电子钱包最低',
|
||||
min_bank: '银行最低',
|
||||
@@ -24,11 +35,17 @@ export default {
|
||||
processing_en: '到账说明(英文)',
|
||||
fee_note_zh: '手续费说明(中文)',
|
||||
fee_note_en: '手续费说明(英文)',
|
||||
sec_fields: '提现表单字段(必填)',
|
||||
field_cardholder: '持卡人姓名',
|
||||
field_bank_account: '银行账号',
|
||||
field_email: '收款邮箱',
|
||||
field_mobile: '收款手机',
|
||||
sec_ddpay_spec: 'DDPay 对接说明(只读)',
|
||||
ddpay_spec_intro:
|
||||
'当前提现走 DDPay 出金(Payout),移动端调用 withdrawCreate;充值渠道为 ddpay 时调用 depositCreate。下列为字段约定摘要,详细以仓库内 DDPay 文档与《36字花-移动端接口设计草案》为准。',
|
||||
ddpay_spec_li_withdraw:
|
||||
'充值联调可用 channel_code=mock(模拟支付,无需 DDPay 配置);生产请用 ddpay。提现必填:channel_code=ddpay 或 mock(模拟)、withdraw_coin、receive_type=bank、receive_account、receiver_name、receiver_email、receiver_mobile、bank_code、idempotency_key;bank_branch 选填。',
|
||||
ddpay_spec_li_bank_table:
|
||||
'「银行名(英文)」将映射为 DDPay 的 bank[name],请与 DDPay 官方银行全称列表一致,否则出金可能被拒。',
|
||||
ddpay_spec_li_deposit:
|
||||
'充值(channel_code=ddpay)必填:payment_type(官方取值 01=FPX、02=duitnow、03=ewallet)、payer_name、payer_bank_name(须与官方入金银行列表全称一致)。',
|
||||
ddpay_spec_li_doc:
|
||||
'官方文档:docs/DDPay Payment Gateway_v1.1.3_zh.md;回调与 HTTPS 要求以文档为准。',
|
||||
col_code: '代码',
|
||||
col_label_zh: '中文名',
|
||||
col_label_en: '英文名',
|
||||
@@ -36,6 +53,17 @@ export default {
|
||||
col_deposit_rate: '充值汇率',
|
||||
col_withdraw_rate: '提现汇率',
|
||||
col_bank_code: '银行代码',
|
||||
col_currency_code: '币种',
|
||||
col_tier_id: '档位ID',
|
||||
col_title_zh: '标题(中文)',
|
||||
col_title_en: '标题(英文)',
|
||||
col_pay_amount: '支付金额',
|
||||
col_amount: '平台币',
|
||||
col_bonus_amount: '赠送',
|
||||
col_tier_sort: '排序',
|
||||
col_tier_status: '启用',
|
||||
msg_delete_currency_prune_tiers: '将删除币种 {currency},并删除关联充值档位 {count} 条,是否继续?',
|
||||
msg_affected_tier_ids: '影响档位',
|
||||
col_name_zh: '银行名(中文)',
|
||||
col_name_en: '银行名(英文)',
|
||||
ph_ratio: '如 100',
|
||||
@@ -46,4 +74,5 @@ export default {
|
||||
ch_display_name: '展示名称',
|
||||
ch_sort: '排序',
|
||||
ch_status: '启用',
|
||||
ch_currency_codes: '支持币种',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。',
|
||||
desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。本期中奖统一推送 bet.win(data.is_jackpot 标记是否大奖档);勾选「大奖」的档位在中奖时另推送公共 jackpot.hit。',
|
||||
btn_save: '保存',
|
||||
streak: '连胜档(局)',
|
||||
odds_factor: '赔率系数',
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
export default {
|
||||
'quick Search Fields': 'ID/参数键/说明',
|
||||
'form tab label': '游戏参数配置',
|
||||
'save success': '保存成功',
|
||||
'field period_auto_create_enabled': '自动创建下一期开关',
|
||||
'field_tip period_auto_create_enabled': '是否允许自动创建下一期',
|
||||
'field period_manual_create_enabled': '手动创建下一期开关',
|
||||
'field_tip period_manual_create_enabled': '是否允许手动创建下一期',
|
||||
'field period_seconds': '每期时长(秒)',
|
||||
'field_tip period_seconds': '每一局的总时长(秒)',
|
||||
'field bet_seconds': '下注时长(秒)',
|
||||
'field_tip bet_seconds': '每一局允许下注的时长(秒)',
|
||||
'field payout_seconds': '派彩时长(秒)',
|
||||
'field_tip payout_seconds': '开奖后派彩展示宽限期(秒),结束后自动进入下一期;封盘计算时长 = 每期时长 − 下注时长',
|
||||
'field pick_max_number_count': '单注最多号码个数',
|
||||
'field_tip pick_max_number_count': '单注最多可选号码数量',
|
||||
'field bet_chips': '快捷筹码面额',
|
||||
'field_tip bet_chips': '固定 6 档(标识 1–6 不可改),仅可修改每档面额;保存时写入为 JSON,与移动端 lobbyInit 一致',
|
||||
bet_chips_colon: ':',
|
||||
bet_chips_validate_slot: '第 {slot} 档筹码面额须为大于 0 的数字(建议 ≥ 单号最小下注额)',
|
||||
'field default_bet_chip_id': '默认选中筹码',
|
||||
'field_tip default_bet_chip_id': '大厅默认高亮的筹码标识,须为 1–6 且对应 bet_chips 有效面额',
|
||||
'field min_bet_per_number': '单号最小下注额',
|
||||
'field_tip min_bet_per_number': '每个号码允许的最小下注金额',
|
||||
'field max_bet_per_number': '单号最大下注额',
|
||||
'field_tip max_bet_per_number': '每个号码允许的最大下注金额',
|
||||
'field withdraw_bet_flow_ratio': '提现所需流水倍数',
|
||||
'field_tip withdraw_bet_flow_ratio': '提现前所需完成的投注流水倍数',
|
||||
'field jackpot_max_amount': '大奖审核阈值',
|
||||
'field_tip jackpot_max_amount': '达到该中奖金额后需要进入大奖审核流程',
|
||||
id: 'ID',
|
||||
config_key: '参数键',
|
||||
config_value: '参数值',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
tip: '实时监听压注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。',
|
||||
tip: '实时监听下注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。',
|
||||
current_record: '当前对局',
|
||||
ai_default_number: 'AI默认开奖号码',
|
||||
pending_draw: '已预约开奖号码',
|
||||
@@ -17,21 +17,31 @@ export default {
|
||||
calc_estimated_loss: '计算预估赔付',
|
||||
push_connected: '实时连接已建立',
|
||||
push_disconnected: '已切换为轮询模式(无推送)',
|
||||
ws_connected: '已连接实时对局',
|
||||
ws_disconnected: '实时推送未连接,已使用轮询刷新',
|
||||
snapshot_load_failed: '对局快照加载失败,请稍后重试',
|
||||
ws_panel_title: '后台 WebSocket 连接(区别于前端轻量流)',
|
||||
ws_reload_config: '加载WS配置',
|
||||
ws_connect: '连接WS',
|
||||
ws_disconnect: '断开WS',
|
||||
ws_log_empty: '暂无 WebSocket 日志。',
|
||||
jackpot_hit_tip: '本期出现中大奖用户,请查看开奖信息。',
|
||||
candidate_title: '候选号码赔付预估',
|
||||
number: '号码',
|
||||
estimated_loss: '预估赔付',
|
||||
bet_stream_title: '实时压注记录',
|
||||
bet_stream_title: '实时下注记录',
|
||||
bet_id: '注单ID',
|
||||
user_id: '玩家ID',
|
||||
pick_numbers: '压注号码',
|
||||
total_amount: '压注总额',
|
||||
username: '用户名',
|
||||
pick_numbers: '下注号码',
|
||||
total_amount: '下注总额',
|
||||
win_amount: '派彩金额',
|
||||
streak_at_bet: '下注时连胜',
|
||||
runtime_switch: '游戏运行',
|
||||
runtime_switch: '自动创建下一局',
|
||||
countdown_maintenance: '维护中',
|
||||
runtime_draining_banner:
|
||||
'已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。',
|
||||
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。',
|
||||
runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。',
|
||||
runtime_draining_banner: '已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。',
|
||||
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「自动创建下一局」恢复;若无进行中的局将自动创建新一期。',
|
||||
runtime_off_tip: '开启「自动创建下一局」后,若无进行中的局将立即创建新一期。',
|
||||
void_btn: '作废本局',
|
||||
void_dialog_title: '作废本局',
|
||||
void_reason_label: '作废原因',
|
||||
|
||||
@@ -17,11 +17,24 @@ export default {
|
||||
'status 1': '待开奖',
|
||||
'status 2': '已结算',
|
||||
'status 3': '已退款',
|
||||
'status 4': '已退回·拒审',
|
||||
'status 5': '待审核',
|
||||
review_title: '中奖审核',
|
||||
review_open: '审核',
|
||||
review_approve: '通过',
|
||||
review_reject: '拒绝',
|
||||
review_remark: '拒绝备注',
|
||||
review_remark_placeholder: '请输入拒绝原因',
|
||||
review_remark_required: '拒绝时必须填写备注',
|
||||
review_approve_confirm: '确认审核通过并派彩提现吗?',
|
||||
review_approve_success: '审核通过,已派彩',
|
||||
review_reject_success: '已拒绝派彩',
|
||||
idempotency_key: '幂等键',
|
||||
create_time: '创建时间',
|
||||
update_time: '更新时间',
|
||||
gameRecord_period_no: '对局期号',
|
||||
gameRecord_status: '期状态',
|
||||
result_number: '中奖号码',
|
||||
'gameRecord_status 0': '下注开放',
|
||||
'gameRecord_status 1': '已封盘',
|
||||
'gameRecord_status 2': '算票中',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default {
|
||||
'quick Search Fields': '局号/ID',
|
||||
'quick Search Fields': '期数/ID',
|
||||
id: 'ID',
|
||||
period_no: '局号',
|
||||
period_no: '期数',
|
||||
period_start_at: '开始时间',
|
||||
status: '状态',
|
||||
'status 0': '下注开放',
|
||||
@@ -10,10 +10,10 @@ export default {
|
||||
'status 3': '派彩中',
|
||||
'status 4': '已结束',
|
||||
'status 5': '已作废',
|
||||
'status 6': '异常',
|
||||
draw_mode: '开奖方式',
|
||||
'draw_mode 0': '自动AI',
|
||||
'draw_mode 1': '手动预设',
|
||||
preset_number: '预设号码',
|
||||
result_number: '开奖号码',
|
||||
platform_profit_amount: '对局盈亏(平台)',
|
||||
winner_user_count: '中奖人数',
|
||||
@@ -26,4 +26,23 @@ export default {
|
||||
manual_create_label: '允许手动创建下一局',
|
||||
manual_create_tip: '开启后可在本页使用「手动创建下一局」按钮',
|
||||
btn_create_next: '手动创建下一局',
|
||||
view_play_records: '查看游玩记录',
|
||||
play_record_dialog_title: '游玩(压注)记录',
|
||||
play_record_col_id: 'ID',
|
||||
play_record_col_user: '用户名',
|
||||
play_record_col_channel: '渠道',
|
||||
play_record_col_pick_numbers: '选号',
|
||||
play_record_col_total_amount: '游玩金额',
|
||||
play_record_col_is_auto: '托管',
|
||||
play_record_col_win_amount: '派彩',
|
||||
play_record_col_jackpot_extra_amount: 'Jackpot',
|
||||
play_record_col_status: '状态',
|
||||
play_record_col_create_time: '创建时间',
|
||||
play_record_is_auto_0: '手动',
|
||||
play_record_is_auto_1: '托管',
|
||||
play_record_status_1: '待开奖',
|
||||
play_record_status_2: '已结算',
|
||||
play_record_status_3: '已退款',
|
||||
play_record_status_4: '已退回',
|
||||
load_play_record_failed: '加载游玩记录失败',
|
||||
}
|
||||
|
||||
@@ -15,10 +15,15 @@ export default {
|
||||
review_admin_username: '审核人',
|
||||
remark: '备注',
|
||||
create_time: '创建时间',
|
||||
review_btn: '审核',
|
||||
review_title: '提现审核',
|
||||
review_btn_approve: '通过',
|
||||
review_btn_reject: '拒绝',
|
||||
review_btn_back: '返回',
|
||||
review_btn_confirm_reject: '确认拒绝',
|
||||
review_approve_title: '通过审核',
|
||||
review_reject_title: '拒绝审核',
|
||||
review_reject_tip: '拒绝后冻结金额将退回管理员钱包',
|
||||
review_remark_optional: '可选填写审核备注',
|
||||
reject_reason_required: '请填写拒绝原因',
|
||||
stat_total_count: '提现总单数',
|
||||
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
'status 0': '待支付',
|
||||
'status 1': '成功',
|
||||
'status 2': '失败',
|
||||
'status 3': '已取消',
|
||||
'status 3': '待审核',
|
||||
pay_channel: '支付通道',
|
||||
pay_time: '支付时间',
|
||||
deposit_tier_id: '充值档位',
|
||||
@@ -23,4 +23,18 @@ export default {
|
||||
update_time: '更新时间',
|
||||
detail_title: '充值订单详情',
|
||||
close_btn: '关闭',
|
||||
reject_reason: '驳回原因',
|
||||
review_admin_username: '审核人',
|
||||
review_time: '审核时间',
|
||||
review_title: '充值审核',
|
||||
review_reject_title: '拒绝充值',
|
||||
review_btn: '审核',
|
||||
review_btn_approve: '通过',
|
||||
review_btn_reject: '拒绝',
|
||||
review_btn_back: '返回',
|
||||
review_btn_confirm_reject: '确认拒绝',
|
||||
review_reject_tip: '拒绝后订单将标记为失败,资金不会入账。',
|
||||
review_reject_placeholder: '请输入驳回原因',
|
||||
reject_reason_required: '请输入驳回原因',
|
||||
already_reviewed: '该订单已审核,无需重复操作',
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ export default {
|
||||
amount: '申请金额',
|
||||
fee: '手续费',
|
||||
actual_amount: '实际到账',
|
||||
pay_channel: '支付渠道',
|
||||
receive_type: '收款类型',
|
||||
receive_account: '收款账号',
|
||||
receiver_name: '持卡人姓名',
|
||||
receiver_email: '收款人邮箱',
|
||||
receiver_mobile: '收款人手机号',
|
||||
idempotency_key: '幂等键',
|
||||
status: '状态',
|
||||
'status 0': '待审核',
|
||||
@@ -24,6 +28,7 @@ export default {
|
||||
channel_name: '渠道',
|
||||
review_admin_username: '审核人',
|
||||
review_title: '提现审核',
|
||||
review_btn: '审核',
|
||||
review_reject_title: '提现拒绝',
|
||||
review_btn_approve: '通过',
|
||||
review_btn_reject: '拒绝',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default {
|
||||
tip: 'WebSocket 连接测试(状态流):按文档监听 period.tick / period.opened 等事件。',
|
||||
tip: 'WebSocket 联调:加载配置后连接即自动订阅;对局状态见 period.tick,当前玩家赔率见 user.streak / wallet.changed / bet.accepted(不含 game.config 全表)。',
|
||||
}
|
||||
|
||||
@@ -12,5 +12,12 @@ export default {
|
||||
btn_clear: '清空日志',
|
||||
log_title: 'WebSocket 日志',
|
||||
log_empty: '暂无日志,请先连接后发送消息。',
|
||||
subscribe_topics: '自动订阅主题',
|
||||
odds_push_topics: '赔率推送主题',
|
||||
player_odds_fields: '玩家赔率字段',
|
||||
test_player_odds: '测试玩家赔率(配置预览)',
|
||||
test_player_odds_hint: '连接并订阅赔率主题后,服务端将推送带 is_test / preview 的演示帧(数据来自库内连胜最高的样例玩家)。',
|
||||
test_source_db: '库内样例玩家',
|
||||
test_source_synthetic: '合成演示',
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
section_admin_attribution: '管理员归属',
|
||||
admin_affiliation: '归属管理员',
|
||||
admin_affiliation_placeholder: '按角色组展开,仅展示您可管理范围内的管理员',
|
||||
admin_no_channel_bound: '所选归属管理员未绑定渠道,无法创建用户,请先在管理员管理中绑定渠道',
|
||||
register_invite_code_auto_placeholder: '随所选管理员邀请码自动带出',
|
||||
channel_id: '所属渠道',
|
||||
channel__name: '渠道名',
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
'The correct area is not clicked, please try again!': 'The correct area is not clicked, please try again!',
|
||||
'Verification is successful!': 'Verification is successful!',
|
||||
'Please click': 'Please click',
|
||||
'Please enter the correct mobile number': 'Please enter the correct mobile number',
|
||||
'Please enter the correct mobile number': 'Please enter a valid Malaysia mobile number starting with 60',
|
||||
'Please enter the correct account': 'The account requires 3 to 15 characters and contains a-z A-Z 0-9 _',
|
||||
'Please enter the correct password': 'The password requires 6 to 32 characters and cannot contains & < > " \'',
|
||||
'Please enter the correct name': 'Please enter the correct name',
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
'The correct area is not clicked, please try again!': '未点中正确区域,请重试!',
|
||||
'Verification is successful!': '验证成功!',
|
||||
'Please click': '请依次点击',
|
||||
'Please enter the correct mobile number': '请输入正确的手机号',
|
||||
'Please enter the correct mobile number': '请输入正确的马来西亚手机号(60开头)',
|
||||
'Please enter the correct account': '要求3到15位,字母开头且只含字母、数字、下划线',
|
||||
'Please enter the correct password': '密码要求6到32位,不能包含 & < > " \'',
|
||||
'Please enter the correct name': '请输入正确的名称',
|
||||
|
||||
@@ -25,7 +25,7 @@ const assignLocale: anyObj = {
|
||||
|
||||
export async function loadLang(app: App) {
|
||||
const config = useConfig()
|
||||
const locale = config.lang.defaultLang
|
||||
const locale = config.lang.defaultLang === 'zh' ? 'zh-cn' : config.lang.defaultLang
|
||||
|
||||
// 加载框架全局语言包
|
||||
const lang = await import(`./globs-${locale}.ts`)
|
||||
@@ -70,7 +70,7 @@ export async function loadLang(app: App) {
|
||||
locale: locale,
|
||||
legacy: false, // 组合式api
|
||||
globalInjection: true, // 挂载$t,$d等到全局
|
||||
fallbackLocale: config.lang.fallbackLang,
|
||||
fallbackLocale: config.lang.fallbackLang === 'zh' ? 'zh-cn' : config.lang.fallbackLang,
|
||||
messages,
|
||||
})
|
||||
|
||||
|
||||
@@ -44,12 +44,13 @@ router.beforeEach((to, from, next) => {
|
||||
.join('/')
|
||||
}
|
||||
const config = useConfig()
|
||||
const langCode = config.lang.defaultLang === 'zh' ? 'zh-cn' : config.lang.defaultLang
|
||||
if (to.path in langAutoLoadMap) {
|
||||
loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
|
||||
}
|
||||
let prefix = ''
|
||||
if (isAdminApp(to.fullPath)) {
|
||||
prefix = './backend/' + config.lang.defaultLang
|
||||
prefix = './backend/' + langCode
|
||||
|
||||
// 去除 path 中的 /admin
|
||||
const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
|
||||
@@ -58,7 +59,7 @@ router.beforeEach((to, from, next) => {
|
||||
loadPath.push(prefix + toCamelPath(adminPath) + '.ts')
|
||||
}
|
||||
} else {
|
||||
prefix = './frontend/' + config.lang.defaultLang
|
||||
prefix = './frontend/' + langCode
|
||||
loadPath.push(prefix + to.path + '.ts')
|
||||
}
|
||||
|
||||
@@ -80,7 +81,7 @@ router.beforeEach((to, from, next) => {
|
||||
loadPath = uniq(loadPath)
|
||||
|
||||
for (const key in loadPath) {
|
||||
loadPath[key] = loadPath[key].replaceAll('${lang}', config.lang.defaultLang)
|
||||
loadPath[key] = loadPath[key].replaceAll('${lang}', langCode)
|
||||
if (loadPath[key] in window.loadLangHandle) {
|
||||
window.loadLangHandle[loadPath[key]]().then((res: { default: anyObj }) => {
|
||||
const pathName = loadPath[key].slice(loadPath[key].lastIndexOf(prefix) + (prefix.length + 1), loadPath[key].lastIndexOf('.'))
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { RuleType } from 'async-validator'
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { i18n } from '../lang'
|
||||
|
||||
/**
|
||||
* 马来西亚手机号(60 国际前缀,不含 +)
|
||||
*/
|
||||
export const malaysiaMobilePattern = /^60(1[0-9])\d{7,9}$/
|
||||
|
||||
/**
|
||||
* 手机号码验证
|
||||
*/
|
||||
@@ -10,7 +15,7 @@ export function validatorMobile(rule: any, mobile: string | number, callback: Fu
|
||||
if (!mobile) {
|
||||
return callback()
|
||||
}
|
||||
if (!/^(1[3-9])\d{9}$/.test(mobile.toString())) {
|
||||
if (!malaysiaMobilePattern.test(mobile.toString())) {
|
||||
return callback(new Error(i18n.global.t('validate.Please enter the correct mobile number')))
|
||||
}
|
||||
return callback()
|
||||
|
||||
@@ -27,10 +27,51 @@ const baTable = new baTableClass(
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('admin.adminWallet.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('admin.adminWallet.admin_id'), prop: 'admin_id', align: 'center', width: 90, operator: 'RANGE', sortable: false },
|
||||
{ label: t('admin.adminWallet.admin_username'), prop: 'admin.username', align: 'center', minWidth: 140, operator: 'LIKE' },
|
||||
{ label: t('admin.adminWallet.channel_id'), prop: 'admin.channel_id', align: 'center', width: 100, operator: 'RANGE' },
|
||||
{
|
||||
label: t('admin.adminWallet.id'),
|
||||
prop: 'id',
|
||||
align: 'center',
|
||||
width: 70,
|
||||
operator: 'RANGE',
|
||||
sortable: 'custom',
|
||||
},
|
||||
{
|
||||
label: t('admin.adminWallet.admin_id'),
|
||||
prop: 'admin_id',
|
||||
align: 'center',
|
||||
show: false,
|
||||
width: 90,
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: t('admin.adminWallet.admin_username'),
|
||||
prop: 'admin.username',
|
||||
align: 'center',
|
||||
minWidth: 140,
|
||||
operator: 'LIKE',
|
||||
render: 'tag',
|
||||
customRenderAttr: {
|
||||
tag: () => ({
|
||||
color: '#e8f3ff',
|
||||
style: { color: '#1677ff', borderColor: '#91caff' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('admin.adminWallet.channel_name'),
|
||||
prop: 'channel_name',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operator: 'LIKE',
|
||||
render: 'tag',
|
||||
customRenderAttr: {
|
||||
tag: () => ({
|
||||
color: '#f0f9eb',
|
||||
style: { color: '#67c23a', borderColor: '#b3e19d' },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ label: t('admin.adminWallet.balance'), prop: 'balance', align: 'center', minWidth: 120, operator: 'RANGE' },
|
||||
{ label: t('admin.adminWallet.frozen_balance'), prop: 'frozen_balance', align: 'center', minWidth: 120, operator: 'RANGE' },
|
||||
{ label: t('admin.adminWallet.total_income'), prop: 'total_income', align: 'center', minWidth: 120, operator: 'RANGE' },
|
||||
|
||||
@@ -21,6 +21,24 @@ defineOptions({
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const refTypeReplace = {
|
||||
agent_commission_record: t('admin.adminWalletRecord.ref title agent_commission_record'),
|
||||
admin_withdraw_order: t('admin.adminWalletRecord.ref title admin_withdraw_order'),
|
||||
withdraw_order: t('admin.adminWalletRecord.ref title withdraw_order'),
|
||||
deposit_order: t('admin.adminWalletRecord.ref title deposit_order'),
|
||||
bet_order: t('admin.adminWalletRecord.ref title bet_order'),
|
||||
admin_user_wallet_adjust: t('admin.adminWalletRecord.ref title admin_user_wallet_adjust'),
|
||||
}
|
||||
|
||||
const refTypeTagCustom = {
|
||||
agent_commission_record: 'success',
|
||||
admin_withdraw_order: 'warning',
|
||||
withdraw_order: 'danger',
|
||||
deposit_order: 'primary',
|
||||
bet_order: 'info',
|
||||
admin_user_wallet_adjust: 'warning',
|
||||
}
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/admin.AdminWalletRecord/'),
|
||||
{
|
||||
@@ -45,8 +63,17 @@ const baTable = new baTableClass(
|
||||
{ label: t('admin.adminWalletRecord.amount'), prop: 'amount', align: 'center', minWidth: 100, operator: 'RANGE' },
|
||||
{ label: t('admin.adminWalletRecord.balance_before'), prop: 'balance_before', align: 'center', minWidth: 110, operator: 'RANGE' },
|
||||
{ label: t('admin.adminWalletRecord.balance_after'), prop: 'balance_after', align: 'center', minWidth: 110, operator: 'RANGE' },
|
||||
{ label: t('admin.adminWalletRecord.ref_type'), prop: 'ref_type', align: 'center', minWidth: 120, operator: 'LIKE' },
|
||||
{ label: t('admin.adminWalletRecord.ref_id'), prop: 'ref_id', align: 'center', minWidth: 100, operator: 'RANGE' },
|
||||
{
|
||||
label: t('admin.adminWalletRecord.ref_title'),
|
||||
prop: 'ref_type',
|
||||
align: 'center',
|
||||
minWidth: 150,
|
||||
operator: 'eq',
|
||||
render: 'tag',
|
||||
replaceValue: refTypeReplace,
|
||||
custom: refTypeTagCustom,
|
||||
},
|
||||
{ label: t('admin.adminWalletRecord.ref_id'), prop: 'ref_id', align: 'center', minWidth: 100, operator: 'RANGE', show: false },
|
||||
{ label: t('admin.adminWalletRecord.idempotency_key'), prop: 'idempotency_key', align: 'center', minWidth: 180, operator: 'LIKE' },
|
||||
{ label: t('admin.adminWalletRecord.operator_admin_username'), prop: 'operatorAdmin.username', align: 'center', minWidth: 130, operator: 'LIKE' },
|
||||
{ label: t('admin.adminWalletRecord.remark'), prop: 'remark', align: 'center', minWidth: 180, operator: 'LIKE', showOverflowTooltip: true },
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
<div class="default-main ba-table-box">
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<el-alert
|
||||
v-if="settlementNoFilter"
|
||||
class="commission-record-filter-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
{{ t('agent.commissionRecord.filter_by_settlement_no', { no: settlementNoFilter }) }}
|
||||
<el-button link type="primary" class="commission-record-filter-reset" @click="resetSettlementNoFilter">
|
||||
{{ t('agent.commissionRecord.reset_settlement_filter') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('agent.commissionRecord.quick Search Fields') })"
|
||||
@@ -13,7 +28,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { computed, onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
@@ -21,12 +37,16 @@ import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import { routePush } from '/@/utils/router'
|
||||
|
||||
defineOptions({
|
||||
name: 'agent/commissionRecord',
|
||||
})
|
||||
|
||||
const SETTLEMENT_NO_PROP = 'settlementPeriod.settlement_no'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
|
||||
@@ -41,6 +61,33 @@ function formatAmount2(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
function formatPercent(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
return '-'
|
||||
}
|
||||
const n = Number(cellValue)
|
||||
if (!Number.isFinite(n)) {
|
||||
return String(cellValue)
|
||||
}
|
||||
return `${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function formatNetCommission(row: anyObj) {
|
||||
const preset = row?.net_commission_amount
|
||||
if (preset !== null && preset !== undefined && preset !== '') {
|
||||
const n = Number(preset)
|
||||
if (Number.isFinite(n)) {
|
||||
return n.toFixed(2)
|
||||
}
|
||||
}
|
||||
const gross = Number(row?.commission_amount ?? 0)
|
||||
const fee = Number(row?.handling_fee ?? 0)
|
||||
if (!Number.isFinite(gross) || !Number.isFinite(fee)) {
|
||||
return '-'
|
||||
}
|
||||
return Math.max(0, gross - fee).toFixed(2)
|
||||
}
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/agent.CommissionRecord/'),
|
||||
{
|
||||
@@ -58,7 +105,7 @@ const baTable = new baTableClass(
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.settlement_period_no'),
|
||||
prop: 'settlementPeriod.settlement_no',
|
||||
prop: SETTLEMENT_NO_PROP,
|
||||
align: 'center',
|
||||
minWidth: 170,
|
||||
operator: 'LIKE',
|
||||
@@ -86,28 +133,70 @@ const baTable = new baTableClass(
|
||||
render: 'tags',
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.commission_rate'),
|
||||
prop: 'commission_rate',
|
||||
label: t('agent.commissionRecord.calc_base_amount'),
|
||||
prop: 'calc_base_amount',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
operator: 'RANGE',
|
||||
formatter: formatAmount2,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.share_rate'),
|
||||
prop: 'share_rate',
|
||||
align: 'center',
|
||||
minWidth: 96,
|
||||
operator: 'RANGE',
|
||||
formatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.commission_amount'),
|
||||
prop: 'commission_amount',
|
||||
align: 'center',
|
||||
minWidth: 110,
|
||||
operator: 'RANGE',
|
||||
formatter: formatAmount2,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.calc_base_amount'),
|
||||
prop: 'calc_base_amount',
|
||||
label: t('agent.commissionRecord.commission_share_percent'),
|
||||
prop: 'commission_share_percent',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
minWidth: 88,
|
||||
operator: 'RANGE',
|
||||
formatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.handling_fee_rate'),
|
||||
prop: 'handling_fee_rate',
|
||||
align: 'center',
|
||||
minWidth: 96,
|
||||
operator: 'RANGE',
|
||||
formatter: formatPercent,
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.handling_fee'),
|
||||
prop: 'handling_fee',
|
||||
align: 'center',
|
||||
minWidth: 96,
|
||||
operator: 'RANGE',
|
||||
formatter: formatAmount2,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.commission_amount'),
|
||||
prop: 'commission_amount',
|
||||
label: t('agent.commissionRecord.net_commission_amount'),
|
||||
prop: 'net_commission_amount',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
minWidth: 96,
|
||||
operator: 'RANGE',
|
||||
formatter: (row: anyObj) => formatNetCommission(row),
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.commission_rate'),
|
||||
prop: 'commission_rate',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
operator: 'RANGE',
|
||||
formatter: formatAmount2,
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.status'),
|
||||
@@ -154,6 +243,7 @@ const baTable = new baTableClass(
|
||||
width: 170,
|
||||
sortable: 'custom',
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: t('agent.commissionRecord.update_time'),
|
||||
@@ -165,6 +255,7 @@ const baTable = new baTableClass(
|
||||
width: 170,
|
||||
sortable: 'custom',
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
show: false,
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
@@ -174,6 +265,24 @@ const baTable = new baTableClass(
|
||||
}
|
||||
)
|
||||
|
||||
const settlementNoFilter = computed(() => {
|
||||
const fromQuery = route.query[SETTLEMENT_NO_PROP]
|
||||
if (typeof fromQuery === 'string' && fromQuery.trim() !== '') {
|
||||
return fromQuery.trim()
|
||||
}
|
||||
const fromForm = baTable.comSearch.form[SETTLEMENT_NO_PROP]
|
||||
return typeof fromForm === 'string' && fromForm.trim() !== '' ? fromForm.trim() : ''
|
||||
})
|
||||
|
||||
const resetSettlementNoFilter = () => {
|
||||
baTable.comSearch.form[SETTLEMENT_NO_PROP] = ''
|
||||
baTable.setFilterSearchData(baTable.getComSearchData(), 'cover')
|
||||
if (route.query[SETTLEMENT_NO_PROP]) {
|
||||
void routePush({ path: route.path, query: {} })
|
||||
}
|
||||
baTable.onTableHeaderAction('refresh', { event: 'reset-settlement-filter' })
|
||||
}
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -186,4 +295,13 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.commission-record-filter-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.commission-record-filter-reset {
|
||||
margin-left: 8px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,14 +21,43 @@ import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import { auth } from '/@/utils/common'
|
||||
import { routePush } from '/@/utils/router'
|
||||
|
||||
defineOptions({
|
||||
name: 'agent/settlementPeriod',
|
||||
})
|
||||
|
||||
const SETTLEMENT_NO_PROP = 'settlementPeriod.settlement_no'
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
const optButtons: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'viewCommissionRecords',
|
||||
title: 'agent.settlementPeriod.view_commission_records',
|
||||
text: '',
|
||||
type: 'primary',
|
||||
icon: 'fa fa-list-alt',
|
||||
class: 'table-row-view-commission-records',
|
||||
disabledTip: false,
|
||||
display: () => auth('viewCommissionRecords'),
|
||||
click: (row: TableRow) => {
|
||||
const settlementNo = String(row?.settlement_no ?? '').trim()
|
||||
if (settlementNo === '') {
|
||||
return
|
||||
}
|
||||
void routePush({
|
||||
path: '/admin/agent/commissionRecord',
|
||||
query: {
|
||||
[SETTLEMENT_NO_PROP]: settlementNo,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
...defaultOptButtons(['edit', 'delete']),
|
||||
]
|
||||
|
||||
function formatAmount2(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
@@ -149,7 +178,7 @@ const baTable = new baTableClass(
|
||||
sortable: 'custom',
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
{ label: t('Operate'), align: 'center', minWidth: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
<template>
|
||||
<div class="default-main ba-table-box">
|
||||
<el-alert
|
||||
class="ba-table-alert admin-tree-alert"
|
||||
v-if="!adminInfo.super"
|
||||
:title="t('auth.admin.Manage subordinate agents here')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<!-- 表格顶部菜单 -->
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.admin.username') + '/' + t('auth.admin.nickname') })"
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="
|
||||
t('Quick search placeholder', {
|
||||
fields:
|
||||
t('auth.admin.username') + '/' + t('auth.admin.nickname') + '/' + t('auth.admin.mobile') + '/' + t('auth.admin.invite_code'),
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 要使用`el-table`组件原有的属性,直接加在Table标签上即可 -->
|
||||
<Table />
|
||||
<Table ref="tableRef" :pagination="false" />
|
||||
|
||||
<!-- 表单 -->
|
||||
<PopupForm />
|
||||
@@ -18,7 +29,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
@@ -26,7 +38,6 @@ import TableHeader from '/@/components/table/header/index.vue'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineOptions({
|
||||
name: 'auth/admin',
|
||||
@@ -34,6 +45,7 @@ defineOptions({
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const optButtons = defaultOptButtons(['edit', 'delete'])
|
||||
optButtons[1].display = (row) => {
|
||||
@@ -43,39 +55,80 @@ optButtons[1].display = (row) => {
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/auth.Admin/'),
|
||||
{
|
||||
expandAll: true,
|
||||
dblClickNotEditColumn: [undefined, 'status'],
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
|
||||
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ type: 'selection', align: 'center' },
|
||||
{
|
||||
label: t('auth.admin.username'),
|
||||
prop: 'username',
|
||||
align: 'left',
|
||||
minWidth: '140',
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.nickname'),
|
||||
prop: 'nickname',
|
||||
align: 'center',
|
||||
minWidth: '120',
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.avatar'),
|
||||
prop: 'avatar',
|
||||
align: 'center',
|
||||
width: '80',
|
||||
render: 'image',
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.invite_code'),
|
||||
prop: 'invite_code',
|
||||
align: 'center',
|
||||
minWidth: '110',
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.channel'),
|
||||
prop: 'channel_name',
|
||||
align: 'center',
|
||||
minWidth: '140',
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.commission_share_rate'),
|
||||
prop: 'commission_share_rate',
|
||||
align: 'center',
|
||||
width: '120',
|
||||
formatter: (_row, _col, val) => {
|
||||
if (val === null || val === undefined || val === '') return '-'
|
||||
return `${val}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.group'),
|
||||
prop: 'group_name_arr',
|
||||
align: 'center',
|
||||
minWidth: 150,
|
||||
operator: false,
|
||||
minWidth: '140',
|
||||
render: 'tags',
|
||||
},
|
||||
{ label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
|
||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{
|
||||
label: t('auth.admin.mobile'),
|
||||
prop: 'mobile',
|
||||
align: 'center',
|
||||
minWidth: '130',
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', show: false },
|
||||
{
|
||||
label: t('auth.admin.Last login'),
|
||||
prop: 'last_login_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
sortable: 'custom',
|
||||
operator: 'RANGE',
|
||||
width: 160,
|
||||
width: '160',
|
||||
show: false,
|
||||
},
|
||||
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
|
||||
{
|
||||
label: t('State'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'switch',
|
||||
replaceValue: { disable: t('Disable'), enable: t('Enable') },
|
||||
customRenderAttr: {
|
||||
@@ -85,29 +138,52 @@ const baTable = new baTableClass(
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
minWidth: '80',
|
||||
width: '80',
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined, 'status'],
|
||||
},
|
||||
{
|
||||
defaultItems: {
|
||||
status: 'enable',
|
||||
parent_admin_id: null,
|
||||
commission_share_rate: null,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
baTable.before.getData = () => {
|
||||
if (baTable.table.filter?.quickSearch) {
|
||||
baTable.table.expandAll = true
|
||||
}
|
||||
}
|
||||
|
||||
baTable.before.onSubmit = ({ operate, items }) => {
|
||||
if (operate === 'edit' && items.id == adminInfo.id) {
|
||||
delete items.group_arr
|
||||
delete items.group_name_arr
|
||||
delete items.parent_admin_id
|
||||
delete items.commission_share_rate
|
||||
}
|
||||
}
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
baTable.mount()
|
||||
baTable.getData()
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.admin-tree-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<el-dialog
|
||||
class="ba-operate-dialog"
|
||||
:close-on-click-modal="false"
|
||||
@@ -56,6 +55,66 @@
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="showParentField"
|
||||
:label="t('auth.admin.parent_admin')"
|
||||
v-model="baTable.form.items!.parent_admin_id"
|
||||
type="remoteSelect"
|
||||
prop="parent_admin_id"
|
||||
:key="'parent-' + (baTable.form.items!.channel_id ?? '') + '-' + baTable.form.items!.id"
|
||||
:input-attr="{
|
||||
remoteUrl: '/admin/auth.Admin/index',
|
||||
field: 'username',
|
||||
pk: 'id',
|
||||
disabled: isTopLevelGroup || !hasGroupSelected,
|
||||
pagination: false,
|
||||
params: parentSelectParams,
|
||||
placeholder: t('auth.admin.Parent admin placeholder'),
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-if="showParentField && isTopLevelGroup" label=" ">
|
||||
<el-alert :title="t('auth.admin.Top level group parent hint')" type="info" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<FormItem
|
||||
v-if="showChannelEditable"
|
||||
:label="t('auth.admin.channel')"
|
||||
v-model="baTable.form.items!.channel_id"
|
||||
type="remoteSelect"
|
||||
prop="channel_id"
|
||||
:key="'channel-' + (baTable.form.items!.parent_admin_id ?? '') + '-' + baTable.form.items!.id"
|
||||
:input-attr="{
|
||||
remoteUrl: '/admin/channel/index',
|
||||
field: 'name',
|
||||
pk: 'id',
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-else-if="showChannelReadonly" :label="t('auth.admin.channel')">
|
||||
<el-input :model-value="channelDisplayName" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showChannelReadonly && hasParentAdmin" label=" ">
|
||||
<el-alert :title="t('auth.admin.channel_inherit_from_parent')" type="info" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<FormItem
|
||||
v-if="showShareRateField"
|
||||
:label="t('auth.admin.commission_share_rate')"
|
||||
v-model="baTable.form.items!.commission_share_rate"
|
||||
type="number"
|
||||
prop="commission_share_rate"
|
||||
:input-attr="{
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 0.01,
|
||||
precision: 2,
|
||||
class: 'w100',
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-if="showShareRateField && isTopLevelGroup" label=" ">
|
||||
<el-alert :title="t('auth.admin.Top level share formula hint')" type="info" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showShareRateField && shareHint" label=" ">
|
||||
<el-alert :title="shareHint" :type="shareHintType" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<FormItem
|
||||
v-if="baTable.form.operate == 'Edit'"
|
||||
:label="t('auth.admin.invite_code')"
|
||||
@@ -123,7 +182,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, inject, watch, useTemplateRef } from 'vue'
|
||||
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
import { regularPassword, buildValidatorData } from '/@/utils/validate'
|
||||
@@ -131,6 +190,8 @@ import type { FormItemRule } from 'element-plus'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { auth } from '/@/utils/common'
|
||||
|
||||
const config = useConfig()
|
||||
const adminInfo = useAdminInfo()
|
||||
@@ -139,6 +200,86 @@ const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const shareHint = ref('')
|
||||
const shareHintType = ref<'info' | 'warning'>('info')
|
||||
const isTopLevelGroup = ref(false)
|
||||
|
||||
const isSelfEdit = computed(() => baTable.form.operate === 'Edit' && adminInfo.id == baTable.form.items?.id)
|
||||
|
||||
const hasChannelIndexAuth = computed(
|
||||
() =>
|
||||
adminInfo.super ||
|
||||
auth({ name: '/admin/channel', subNodeName: '/admin/channel/index' }) ||
|
||||
auth({ name: '/admin/Channel', subNodeName: '/admin/Channel/index' })
|
||||
)
|
||||
|
||||
const hasGroupSelected = computed(() => {
|
||||
const group = baTable.form.items?.group_arr
|
||||
if (Array.isArray(group)) {
|
||||
return group.length > 0
|
||||
}
|
||||
return group !== null && group !== undefined && group !== ''
|
||||
})
|
||||
|
||||
const hasParentAdmin = computed(() => {
|
||||
const pid = baTable.form.items?.parent_admin_id
|
||||
return pid !== null && pid !== undefined && pid !== '' && Number(pid) > 0
|
||||
})
|
||||
|
||||
const showChannelEditable = computed(
|
||||
() =>
|
||||
hasGroupSelected.value &&
|
||||
!hasParentAdmin.value &&
|
||||
!isSelfEdit.value &&
|
||||
(adminInfo.super || hasChannelIndexAuth.value)
|
||||
)
|
||||
|
||||
const channelDisplayName = computed(() => {
|
||||
const row = baTable.form.items
|
||||
if (!row) return ''
|
||||
const name = row['channel_name']
|
||||
if (typeof name === 'string' && name !== '') {
|
||||
return name
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const showChannelReadonly = computed(() => {
|
||||
if (isSelfEdit.value || !hasGroupSelected.value) return false
|
||||
if (showChannelEditable.value) return false
|
||||
if (hasParentAdmin.value) return true
|
||||
return channelDisplayName.value !== ''
|
||||
})
|
||||
|
||||
const showParentField = computed(() => !isSelfEdit.value)
|
||||
|
||||
const hasChannelForShare = computed(() => {
|
||||
const cid = baTable.form.items?.channel_id
|
||||
return cid !== null && cid !== undefined && cid !== '' && Number(cid) > 0
|
||||
})
|
||||
|
||||
const showShareRateField = computed(() => {
|
||||
if (isSelfEdit.value || !hasGroupSelected.value) return false
|
||||
return hasChannelForShare.value
|
||||
})
|
||||
|
||||
const parentSelectParams = computed(() => {
|
||||
const params: anyObj = { isTree: 1 }
|
||||
const cid = baTable.form.items?.channel_id
|
||||
if (cid !== null && cid !== undefined && cid !== '') {
|
||||
params['search[0][field]'] = 'channel_id'
|
||||
params['search[0][operator]'] = 'eq'
|
||||
params['search[0][val]'] = cid
|
||||
}
|
||||
const excludeId = baTable.form.items?.id
|
||||
if (excludeId) {
|
||||
params['search[1][field]'] = 'id'
|
||||
params['search[1][operator]'] = 'neq'
|
||||
params['search[1][val]'] = excludeId
|
||||
}
|
||||
return params
|
||||
})
|
||||
|
||||
const singleGroupValue = computed({
|
||||
get: () => {
|
||||
const group = baTable.form.items?.group_arr
|
||||
@@ -155,6 +296,230 @@ const singleGroupValue = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const resolveChannelIdForShare = (): number => {
|
||||
const cid = baTable.form.items?.channel_id
|
||||
if (cid !== null && cid !== undefined && cid !== '') {
|
||||
return Number(cid)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const loadGroupMeta = async (groupId: unknown) => {
|
||||
if (groupId === null || groupId === undefined || groupId === '') {
|
||||
isTopLevelGroup.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/auth.Admin/groupMeta',
|
||||
method: 'get',
|
||||
params: { group_id: groupId },
|
||||
})
|
||||
isTopLevelGroup.value = !!res.data.is_top_level
|
||||
if (isTopLevelGroup.value && baTable.form.items) {
|
||||
baTable.form.items.parent_admin_id = null
|
||||
}
|
||||
} catch {
|
||||
isTopLevelGroup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadParentChannelMeta = async (parentId: unknown) => {
|
||||
const items = baTable.form.items
|
||||
if (!items) return
|
||||
if (parentId === null || parentId === undefined || parentId === '' || Number(parentId) <= 0) {
|
||||
items['channel_name'] = ''
|
||||
if (baTable.form.operate === 'Add') {
|
||||
items.channel_id = null
|
||||
items.commission_share_rate = null
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/auth.Admin/parentMeta',
|
||||
method: 'get',
|
||||
params: { parent_admin_id: parentId },
|
||||
})
|
||||
const cid = res.data.channel_id
|
||||
const cname = res.data.channel_name
|
||||
if (cid !== null && cid !== undefined && cid !== '') {
|
||||
items.channel_id = cid
|
||||
} else {
|
||||
items.channel_id = null
|
||||
}
|
||||
items['channel_name'] = typeof cname === 'string' ? cname : ''
|
||||
} catch {
|
||||
items['channel_name'] = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadShareRemainder = async () => {
|
||||
if (!showShareRateField.value) {
|
||||
shareHint.value = ''
|
||||
return
|
||||
}
|
||||
if (!hasParentAdmin.value && hasChannelForShare.value) {
|
||||
const channelId = resolveChannelIdForShare()
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/auth.Admin/commissionShareRemainder',
|
||||
method: 'get',
|
||||
params: {
|
||||
channel_id: channelId,
|
||||
exclude_id: baTable.form.items?.id || 0,
|
||||
},
|
||||
})
|
||||
const remaining = res.data.remaining_rate ?? '100.00'
|
||||
const used = res.data.used_rate ?? '0.00'
|
||||
const current = baTable.form.items?.commission_share_rate
|
||||
let afterCurrent = remaining
|
||||
if (current !== null && current !== undefined && current !== '') {
|
||||
afterCurrent = (Number(remaining) - Number(current)).toFixed(2)
|
||||
}
|
||||
shareHint.value = t('auth.admin.Channel root share remainder hint', {
|
||||
used,
|
||||
remaining,
|
||||
after: afterCurrent,
|
||||
})
|
||||
shareHintType.value = Number(remaining) <= 0 || Number(afterCurrent) < 0 ? 'warning' : 'info'
|
||||
if (Number(remaining) <= 0) {
|
||||
shareHint.value = t('auth.admin.Channel root share remainder none')
|
||||
} else if (Number(afterCurrent) <= 0 && current) {
|
||||
shareHint.value = t('auth.admin.Share remainder none after current')
|
||||
}
|
||||
} catch {
|
||||
shareHint.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = baTable.form.items?.parent_admin_id
|
||||
if (!parentId || Number(parentId) <= 0) {
|
||||
shareHint.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/auth.Admin/commissionShareRemainder',
|
||||
method: 'get',
|
||||
params: {
|
||||
parent_admin_id: parentId,
|
||||
exclude_id: baTable.form.items?.id || 0,
|
||||
},
|
||||
})
|
||||
const remaining = res.data.remaining_rate ?? '100.00'
|
||||
const used = res.data.used_rate ?? '0.00'
|
||||
const current = baTable.form.items?.commission_share_rate
|
||||
let afterCurrent = remaining
|
||||
if (current !== null && current !== undefined && current !== '') {
|
||||
afterCurrent = (Number(remaining) - Number(current)).toFixed(2)
|
||||
}
|
||||
shareHint.value = t('auth.admin.Share remainder hint', {
|
||||
used,
|
||||
remaining,
|
||||
after: afterCurrent,
|
||||
})
|
||||
shareHintType.value = Number(remaining) <= 0 || Number(afterCurrent) < 0 ? 'warning' : 'info'
|
||||
if (Number(remaining) <= 0) {
|
||||
shareHint.value = t('auth.admin.Share remainder none for parent')
|
||||
} else if (Number(afterCurrent) <= 0 && current) {
|
||||
shareHint.value = t('auth.admin.Share remainder none after current')
|
||||
}
|
||||
} catch {
|
||||
shareHint.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => singleGroupValue.value,
|
||||
(groupId) => {
|
||||
void loadGroupMeta(groupId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => baTable.form.items?.parent_admin_id,
|
||||
(parentId) => {
|
||||
void loadParentChannelMeta(parentId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
baTable.form.items?.parent_admin_id,
|
||||
baTable.form.items?.commission_share_rate,
|
||||
baTable.form.items?.channel_id,
|
||||
baTable.form.operate,
|
||||
isTopLevelGroup.value,
|
||||
],
|
||||
() => {
|
||||
void loadShareRemainder()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => baTable.form.items?.primary_group_is_top_level,
|
||||
(val) => {
|
||||
if (typeof val === 'boolean') {
|
||||
isTopLevelGroup.value = val
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => baTable.form.items?.share_remainder,
|
||||
(val) => {
|
||||
if (val && baTable.form.operate === 'Edit' && !isTopLevelGroup.value) {
|
||||
shareHint.value = t('auth.admin.Share remainder hint', {
|
||||
used: val.used_rate ?? '0.00',
|
||||
remaining: val.remaining_rate ?? '100.00',
|
||||
after: val.remaining_rate ?? '100.00',
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => baTable.form.items?.root_share_remainder,
|
||||
(val) => {
|
||||
const pid = baTable.form.items?.parent_admin_id
|
||||
const hasParent = pid !== null && pid !== undefined && pid !== '' && Number(pid) > 0
|
||||
if (val && baTable.form.operate === 'Edit' && !hasParent) {
|
||||
shareHint.value = t('auth.admin.Channel root share remainder hint', {
|
||||
used: val.used_rate ?? '0.00',
|
||||
remaining: val.remaining_rate ?? '100.00',
|
||||
after: val.remaining_rate ?? '100.00',
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(isTopLevelGroup, (topLevel) => {
|
||||
if (topLevel && baTable.form.items) {
|
||||
baTable.form.items.parent_admin_id = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => baTable.form.operate,
|
||||
(operate) => {
|
||||
if (operate !== 'Add' || !baTable.form.items) {
|
||||
return
|
||||
}
|
||||
baTable.form.items.parent_admin_id = null
|
||||
baTable.form.items.channel_id = null
|
||||
baTable.form.items['channel_name'] = ''
|
||||
baTable.form.items.commission_share_rate = null
|
||||
}
|
||||
)
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
|
||||
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
|
||||
@@ -162,6 +527,9 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
{
|
||||
required: true,
|
||||
validator: (_rule: any, val: unknown, callback: Function) => {
|
||||
if (baTable.form.operate === 'Edit' && adminInfo.id == baTable.form.items?.id) {
|
||||
return callback()
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length !== 1) {
|
||||
return callback(new Error(t('auth.admin.Please select exactly one group')))
|
||||
@@ -176,6 +544,24 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
commission_share_rate: [
|
||||
{
|
||||
validator: (_rule: any, val: unknown, callback: Function) => {
|
||||
if (!showShareRateField.value) {
|
||||
return callback()
|
||||
}
|
||||
if (val === null || val === undefined || val === '') {
|
||||
return callback(new Error(t('Please input field', { field: t('auth.admin.commission_share_rate') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num) || num < 0 || num > 100) {
|
||||
return callback(new Error(t('auth.admin.Commission share rate range')))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
|
||||
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
|
||||
password: [
|
||||
|
||||
@@ -60,12 +60,6 @@ const baTable: baTableClass = new baTableClass(
|
||||
align: 'left',
|
||||
minWidth: '180',
|
||||
},
|
||||
{
|
||||
label: t('auth.group.channel_name'),
|
||||
prop: 'channel_name',
|
||||
align: 'center',
|
||||
minWidth: '140',
|
||||
},
|
||||
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
|
||||
{
|
||||
label: t('State'),
|
||||
@@ -91,7 +85,6 @@ const baTable: baTableClass = new baTableClass(
|
||||
{
|
||||
defaultItems: {
|
||||
status: 1,
|
||||
channel_id: null,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -101,14 +94,9 @@ baTable.before.onSubmit = ({ formEl, operate, items }) => {
|
||||
let submitCallback = () => {
|
||||
baTable.form.submitLoading = true
|
||||
const postItems: anyObj = { ...items }
|
||||
const pid = Number(postItems.pid ?? 0)
|
||||
if (pid !== 0) {
|
||||
delete postItems.channel_id
|
||||
delete postItems.channel_name
|
||||
delete postItems.channel_admin_username
|
||||
} else if (!adminInfo.super) {
|
||||
delete postItems.channel_id
|
||||
}
|
||||
delete postItems.channel_id
|
||||
delete postItems.channel_name
|
||||
delete postItems.channel_admin_username
|
||||
baTable.api
|
||||
.postData(operate, {
|
||||
...postItems,
|
||||
@@ -168,10 +156,6 @@ baTable.after.toggleForm = ({ operate }) => {
|
||||
|
||||
// 编辑请求完成后钩子
|
||||
baTable.after.getEditData = () => {
|
||||
const pid = Number(baTable.form.items?.pid ?? 0)
|
||||
if (pid !== 0 && baTable.form.items) {
|
||||
delete baTable.form.items.channel_id
|
||||
}
|
||||
menuRuleTreeUpdate()
|
||||
}
|
||||
|
||||
@@ -182,9 +166,17 @@ const menuRuleTreeUpdate = () => {
|
||||
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
|
||||
if (baTable.form.items!.rules.includes('*')) {
|
||||
let arr: number[] = []
|
||||
for (const key in baTable.form.extend!.menuRules) {
|
||||
arr.push(baTable.form.extend!.menuRules[key].id)
|
||||
const walk = (nodes: anyObj[]) => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.id) {
|
||||
arr.push(node.id)
|
||||
}
|
||||
if (node.children?.length) {
|
||||
walk(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
walk(res.data.list || [])
|
||||
baTable.form.extend!.defaultCheckedKeys = arr
|
||||
} else {
|
||||
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
|
||||
|
||||
@@ -41,43 +41,6 @@
|
||||
valueOnClear: 0,
|
||||
}"
|
||||
/>
|
||||
<p
|
||||
v-if="!isRootGroup"
|
||||
class="group-channel-inherit-hint"
|
||||
:style="{ paddingLeft: (baTable.form.labelWidth ?? 120) + 'px' }"
|
||||
>
|
||||
{{ t('auth.group.channel_inherit_hint') }}
|
||||
</p>
|
||||
|
||||
<!-- 顶级+超管:可选渠道;展示只读渠道名称(channel_id 仅由表单传给后端) -->
|
||||
<FormItem
|
||||
v-if="isRootGroup && adminInfo.super"
|
||||
:label="t('auth.group.channel_id')"
|
||||
v-model="baTable.form.items!.channel_id"
|
||||
type="remoteSelect"
|
||||
prop="channel_id"
|
||||
:input-attr="{
|
||||
pk: 'id',
|
||||
field: 'name',
|
||||
remoteUrl: '/admin/channel/index',
|
||||
placeholder: t('Click select'),
|
||||
emptyValues: ['', null, undefined, 0],
|
||||
valueOnClear: null,
|
||||
}"
|
||||
/>
|
||||
<template v-if="isRootGroup && adminInfo.super && channelPreviewName">
|
||||
<el-form-item :label="t('auth.group.channel_name')">
|
||||
<el-input :model-value="channelPreviewName" readonly />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 子级:只读展示上级对应渠道名称,不提交 channel_id(由后端按父级写入) -->
|
||||
<template v-if="!isRootGroup && channelPreviewName">
|
||||
<el-form-item :label="t('auth.group.channel_name')">
|
||||
<el-input :model-value="channelPreviewName" readonly />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item prop="name" :label="t('auth.group.Group name')">
|
||||
<el-input
|
||||
v-model="baTable.form.items!.name"
|
||||
@@ -122,7 +85,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, inject, useTemplateRef, computed, watch } from 'vue'
|
||||
import { reactive, inject, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
@@ -130,105 +93,13 @@ import type { ElTree, FormItemRule } from 'element-plus'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
const config = useConfig()
|
||||
const adminInfo = useAdminInfo()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const treeRef = useTemplateRef('treeRef')
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const { t } = useI18n()
|
||||
const isRootGroup = computed(() => {
|
||||
const p = baTable.form.items?.pid
|
||||
if (p === undefined || p === null || p === '') {
|
||||
return true
|
||||
}
|
||||
return Number(p) === 0
|
||||
})
|
||||
|
||||
const strFromRow = (key: string): string => {
|
||||
const row = baTable.form.items
|
||||
if (!row) return ''
|
||||
const v = row[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
const channelPreviewName = computed(() => strFromRow('channel_name'))
|
||||
|
||||
/**
|
||||
* 子角色组:选择上级分组后,只拉取展示用渠道名;channel_id 由后端按父级保存,不在此写入提交字段。
|
||||
*/
|
||||
watch(
|
||||
() => baTable.form.items?.pid,
|
||||
async (pid, oldPid) => {
|
||||
const items = baTable.form.items
|
||||
if (!items || !baTable.form.operate || !['Add', 'Edit'].includes(baTable.form.operate)) {
|
||||
return
|
||||
}
|
||||
const pidNum = Number(pid ?? 0)
|
||||
const oldNum = oldPid === undefined || oldPid === null || oldPid === '' ? null : Number(oldPid)
|
||||
|
||||
if (pidNum === 0) {
|
||||
if (adminInfo.super && oldNum !== null && oldNum !== 0) {
|
||||
items.channel_id = null
|
||||
items['channel_name'] = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
delete items.channel_id
|
||||
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/auth.Group/edit',
|
||||
method: 'get',
|
||||
params: { id: pidNum },
|
||||
},
|
||||
{ showErrorMessage: false, showCodeMessage: false }
|
||||
)
|
||||
const row = res.data.row
|
||||
if (row) {
|
||||
items['channel_name'] = typeof row.channel_name === 'string' ? row.channel_name : ''
|
||||
}
|
||||
} catch {
|
||||
items['channel_name'] = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** 顶级+超管:所选渠道变更时刷新只读渠道名 */
|
||||
watch(
|
||||
() => baTable.form.items?.channel_id,
|
||||
async (cid) => {
|
||||
const items = baTable.form.items
|
||||
if (!items || !baTable.form.operate || !['Add', 'Edit'].includes(baTable.form.operate)) {
|
||||
return
|
||||
}
|
||||
if (!isRootGroup.value || !adminInfo.super) {
|
||||
return
|
||||
}
|
||||
if (cid === null || cid === undefined || cid === '') {
|
||||
items['channel_name'] = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/auth.Group/channelBindPreview',
|
||||
method: 'get',
|
||||
params: { channel_id: cid },
|
||||
},
|
||||
{ showErrorMessage: false, showCodeMessage: false }
|
||||
)
|
||||
items['channel_name'] = typeof res.data.channel_name === 'string' ? res.data.channel_name : ''
|
||||
} catch {
|
||||
items['channel_name'] = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
|
||||
@@ -281,14 +152,6 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.group-channel-inherit-hint {
|
||||
margin: -6px 0 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.penultimate-node) {
|
||||
.el-tree-node__children {
|
||||
padding-left: 60px;
|
||||
|
||||
@@ -24,6 +24,24 @@
|
||||
<div class="label">{{ t('channel.settle_stats_pending_amount') }}</div>
|
||||
<div class="value">{{ settleStats.carryover_positive_total }}</div>
|
||||
</el-card>
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="channel-stat-card"
|
||||
:class="{ 'channel-stat-card-clickable': auth('index') }"
|
||||
@click="onCompanyTotalBetCardClick"
|
||||
>
|
||||
<div class="label">{{ t('channel.settle_stats_company_total_bet') }}</div>
|
||||
<div class="value">{{ settleStats.company_total_bet_amount }}</div>
|
||||
</el-card>
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="channel-stat-card"
|
||||
:class="{ 'channel-stat-card-clickable': auth('index') }"
|
||||
@click="onPaidDividendCardClick"
|
||||
>
|
||||
<div class="label">{{ t('channel.settle_stats_paid_dividend') }}</div>
|
||||
<div class="value">{{ settleStats.paid_dividend_total }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="channel-action-row">
|
||||
<el-radio-group v-model="settleFilterMode" size="small" @change="onSettleFilterChange">
|
||||
@@ -33,7 +51,7 @@
|
||||
<el-radio-button label="enabled">{{ t('channel.settle_filter_enabled') }}</el-radio-button>
|
||||
<el-radio-button label="disabled">{{ t('channel.settle_filter_disabled') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button v-if="adminInfo.super && auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
|
||||
<el-button v-if="auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
|
||||
{{ t('channel.batch_settle_pending') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -44,52 +62,150 @@
|
||||
<PopupForm />
|
||||
|
||||
<el-dialog
|
||||
class="ba-operate-dialog manual-settle-dialog"
|
||||
class="manual-settle-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="manualSettle.visible"
|
||||
width="860px"
|
||||
:width="manualSettleDialogWidth"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="closeManualSettleDialog"
|
||||
@opened="dismissFloatingTooltips"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title">{{ t('channel.manual_settle') }}</div>
|
||||
</template>
|
||||
<div v-loading="manualSettle.previewLoading" class="manual-settle-dialog-body">
|
||||
<el-form :model="manualSettle.form" label-width="140px" class="manual-settle-form">
|
||||
<el-form-item :label="t('channel.manual_settle_settlement_no')">
|
||||
<el-input v-model="manualSettle.form.settlement_no" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_period_start')">
|
||||
<el-input v-model="manualSettle.form.period_start_at" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_period_end')">
|
||||
<el-input v-model="manualSettle.form.period_end_at" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_total_bet')">
|
||||
<el-input v-model="manualSettle.form.total_bet_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_total_payout')">
|
||||
<el-input v-model="manualSettle.form.total_payout_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_platform_profit')">
|
||||
<el-input v-model="manualSettle.form.platform_profit_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_commission_rate')">
|
||||
<el-input v-model="manualSettle.form.commission_rate" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_calc_base')">
|
||||
<el-input v-model="manualSettle.form.calc_base_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_commission_amount')">
|
||||
<el-input v-model="manualSettle.form.commission_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.share_config')" class="manual-settle-form-item-full">
|
||||
<el-table :data="manualSettle.form.commission_split" border size="small" class="w100" max-height="220">
|
||||
<el-table-column prop="admin_username" :label="t('channel.admin__username')" min-width="100" />
|
||||
<el-table-column prop="share_rate" :label="t('channel.share_rate_percent')" min-width="90">
|
||||
<template #default="scope">{{ scope.row.share_rate }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="commission_amount" :label="t('channel.manual_settle_commission_amount')" min-width="110" />
|
||||
</el-table>
|
||||
<el-descriptions
|
||||
class="manual-settle-summary"
|
||||
:column="manualSettleSummaryColumns"
|
||||
border
|
||||
:size="manualSettleViewportMobile ? 'small' : 'default'"
|
||||
>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_settlement_no')">
|
||||
{{ manualSettle.form.settlement_no || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_period_start')">
|
||||
{{ manualSettle.form.period_start_at || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_period_end')">
|
||||
{{ manualSettle.form.period_end_at || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_total_bet')">
|
||||
{{ manualSettle.form.total_bet_amount || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_total_payout')">
|
||||
{{ manualSettle.form.total_payout_amount || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_platform_profit')">
|
||||
{{ manualSettle.form.platform_profit_amount || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_commission_rate')">
|
||||
{{ manualSettle.form.commission_rate || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_calc_base')">
|
||||
{{ manualSettle.form.calc_base_amount || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('channel.manual_settle_commission_amount')">
|
||||
<span class="manual-settle-summary-highlight">{{ manualSettle.form.commission_amount || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-form
|
||||
:model="manualSettle.form"
|
||||
:label-width="manualSettleFormLabelWidth"
|
||||
label-position="top"
|
||||
:size="manualSettleFormSize"
|
||||
class="manual-settle-form"
|
||||
>
|
||||
<el-form-item :label="t('channel.share_config')" class="manual-settle-form-item-full manual-settle-split-form-item">
|
||||
<div class="manual-settle-split-block">
|
||||
<div
|
||||
class="manual-settle-split-table-scroll"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile }"
|
||||
>
|
||||
<div
|
||||
class="manual-settle-split-table-inner"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile }"
|
||||
:style="manualSettleSplitInnerStyle"
|
||||
>
|
||||
<el-table
|
||||
:data="manualSettleSplitTree"
|
||||
row-key="admin_id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
border
|
||||
:size="manualSettleTableSize"
|
||||
:fit="!manualSettleViewportMobile"
|
||||
class="manual-settle-split-table"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile, 'w100': !manualSettleViewportMobile }"
|
||||
:table-layout="manualSettleTableLayout"
|
||||
>
|
||||
<el-table-column
|
||||
prop="admin_username"
|
||||
:label="t('channel.admin__username')"
|
||||
:min-width="manualSettleViewportMobile ? undefined : manualSettleSplitCol.adminWidth"
|
||||
:width="manualSettleViewportMobile ? manualSettleSplitCol.adminWidth : undefined"
|
||||
:show-overflow-tooltip="!manualSettleViewportMobile"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="settlement_base_amount"
|
||||
:label="t('channel.manual_settle_col_base')"
|
||||
:width="manualSettleSplitCol.baseWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="share_rate"
|
||||
:label="t('channel.manual_settle_col_share')"
|
||||
:width="manualSettleSplitCol.shareWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ scope.row.share_rate }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="commission_amount"
|
||||
:label="t('channel.manual_settle_col_gross')"
|
||||
:width="manualSettleSplitCol.grossWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="commission_share_percent"
|
||||
:label="t('channel.manual_settle_col_commission_share')"
|
||||
:width="manualSettleSplitCol.commissionShareWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ formatCommissionSharePercent(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="handling_fee"
|
||||
:label="t('channel.manual_settle_col_fee')"
|
||||
:width="manualSettleSplitCol.feeWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="t('channel.manual_settle_col_net')"
|
||||
:width="manualSettleSplitCol.netWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ formatNetCommission(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<p v-if="manualSettleViewportMobile" class="manual-settle-split-scroll-tip">
|
||||
{{ t('channel.manual_settle_split_scroll_tip') }}
|
||||
</p>
|
||||
</div>
|
||||
<el-collapse v-model="manualSettleCalcCollapse" class="manual-settle-calc-collapse">
|
||||
<el-collapse-item :title="t('channel.manual_settle_calc_title')" name="calc">
|
||||
<ul class="manual-settle-calc-list">
|
||||
<li v-for="(line, idx) in manualSettleCalcDescLines" :key="idx">{{ line }}</li>
|
||||
</ul>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_remark')" class="manual-settle-form-item-full">
|
||||
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
|
||||
@@ -106,62 +222,226 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="shareDialog.visible" @close="closeShareDialog">
|
||||
<el-dialog
|
||||
class="channel-record-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="dividendDialog.visible"
|
||||
width="92%"
|
||||
@close="closeDividendDialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title">{{ t('channel.share_config_title') }}</div>
|
||||
<div class="title">{{ t('channel.dividend_record_dialog_title') }}</div>
|
||||
</template>
|
||||
<div v-loading="shareDialog.loading" class="manual-settle-dialog-body">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||
{{ t('channel.share_config_tip') }}
|
||||
</el-alert>
|
||||
<el-table :data="shareDialog.list" border size="small">
|
||||
<el-table-column :label="t('channel.admin_group_names')" min-width="260">
|
||||
<div v-loading="dividendDialog.loading" class="channel-record-dialog__body">
|
||||
<div class="channel-record-table-wrap">
|
||||
<el-table :data="dividendDialog.list" border size="small" class="channel-record-table">
|
||||
<el-table-column prop="settlement_no" :label="t('channel.dividend_settlement_no')" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="channel_name" :label="t('channel.dividend_channel_name')" min-width="100" />
|
||||
<el-table-column prop="admin_username" :label="t('channel.dividend_admin_username')" min-width="100" />
|
||||
<el-table-column prop="commission_amount" :label="t('channel.dividend_commission_amount')" min-width="100" align="right" />
|
||||
<el-table-column :label="t('channel.dividend_period_range')" min-width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.role_group_name" class="share-group-single">{{ scope.row.role_group_name }}</span>
|
||||
<span v-else class="share-group-empty">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" :label="t('channel.admin__username')" min-width="120" />
|
||||
<el-table-column :label="t('channel.status')" width="120">
|
||||
<template #default="scope">
|
||||
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('channel.share_rate_percent')" min-width="180">
|
||||
<template #default="scope">
|
||||
<el-input-number
|
||||
v-model="scope.row.share_rate"
|
||||
:disabled="scope.row.status !== 1"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w100"
|
||||
/>
|
||||
{{ scope.row.period_start_at || '-' }} ~ {{ scope.row.period_end_at || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settled_at" :label="t('channel.dividend_settled_at')" min-width="150" />
|
||||
</el-table>
|
||||
<div class="share-total-row">
|
||||
<span>{{ t('channel.share_total_enabled') }}: </span>
|
||||
<el-tag :type="shareEnabledTotal === '100.00' ? 'success' : 'danger'">{{ shareEnabledTotal }}%</el-tag>
|
||||
</div>
|
||||
<div class="channel-record-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="dividendDialog.total"
|
||||
:page-size="dividendDialog.limit"
|
||||
:current-page="dividendDialog.page"
|
||||
@current-change="onDividendPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closeShareDialog">{{ t('Cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="shareDialog.saving" @click="saveShareDialog">{{ t('Save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
class="channel-record-dialog channel-bet-record-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="betRecordDialog.visible"
|
||||
width="92%"
|
||||
@close="closeBetRecordDialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title">{{ betRecordDialog.title }}</div>
|
||||
</template>
|
||||
<div v-loading="betRecordDialog.loading" class="channel-record-dialog__body">
|
||||
<div class="channel-bet-summary">
|
||||
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
|
||||
<div class="label">{{ t('channel.bet_record_summary_count') }}</div>
|
||||
<div class="value">{{ betRecordDialog.summary.record_count }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
|
||||
<div class="label">{{ t('channel.bet_record_summary_bet') }}</div>
|
||||
<div class="value">{{ betRecordDialog.summary.total_bet_amount }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="channel-bet-summary-card channel-stat-card">
|
||||
<div class="label">{{ t('channel.bet_record_summary_win') }}</div>
|
||||
<div class="value">{{ betRecordDialog.summary.total_win_amount }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-form :inline="true" size="small" class="channel-bet-filter" @submit.prevent="onBetRecordSearch">
|
||||
<el-form-item :label="t('channel.bet_record_period_no')">
|
||||
<el-input
|
||||
v-model="betRecordDialog.filters.period_no"
|
||||
clearable
|
||||
:placeholder="t('Fuzzy query')"
|
||||
class="channel-bet-filter-input"
|
||||
@keyup.enter="onBetRecordSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.bet_record_user_username')">
|
||||
<el-input
|
||||
v-model="betRecordDialog.filters.user_keyword"
|
||||
clearable
|
||||
:placeholder="t('Fuzzy query')"
|
||||
class="channel-bet-filter-input"
|
||||
@keyup.enter="onBetRecordSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.bet_record_result_number')">
|
||||
<el-input
|
||||
v-model="betRecordDialog.filters.result_number"
|
||||
clearable
|
||||
class="channel-bet-filter-input--short"
|
||||
@keyup.enter="onBetRecordSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.bet_record_pick_numbers')">
|
||||
<el-input
|
||||
v-model="betRecordDialog.filters.pick_number"
|
||||
clearable
|
||||
:placeholder="t('channel.bet_record_pick_filter_placeholder')"
|
||||
class="channel-bet-filter-input--short"
|
||||
@keyup.enter="onBetRecordSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.bet_record_win_hit')">
|
||||
<el-select
|
||||
v-model="betRecordDialog.filters.win_hit"
|
||||
clearable
|
||||
:placeholder="t('Please select field', { field: t('channel.bet_record_win_hit') })"
|
||||
class="channel-bet-filter-select"
|
||||
>
|
||||
<el-option :label="t('channel.bet_record_win_hit_won')" value="won" />
|
||||
<el-option :label="t('channel.bet_record_win_hit_lost')" value="lost" />
|
||||
<el-option :label="t('channel.bet_record_win_hit_pending')" value="pending" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onBetRecordSearch">{{ t('Search') }}</el-button>
|
||||
<el-button @click="onBetRecordReset">{{ t('Reset') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="channel-record-table-wrap">
|
||||
<el-table
|
||||
:data="betRecordDialog.list"
|
||||
border
|
||||
size="small"
|
||||
class="channel-bet-record-table channel-record-table"
|
||||
>
|
||||
<el-table-column
|
||||
prop="period_no"
|
||||
:label="t('channel.bet_record_period_no')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="user_username"
|
||||
:label="t('channel.bet_record_user_username')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
v-if="betRecordDialog.mode === 'company'"
|
||||
prop="channel_name"
|
||||
:label="t('channel.bet_record_channel_name')"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="pick_numbers"
|
||||
:label="t('channel.bet_record_pick_numbers')"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="result_number"
|
||||
:label="t('channel.bet_record_result_number')"
|
||||
min-width="90"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="t('channel.bet_record_win_hit')"
|
||||
min-width="90"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
v-if="scope.row.win_hit"
|
||||
:type="betRecordWinHitTagType(scope.row.win_hit)"
|
||||
effect="dark"
|
||||
size="small"
|
||||
>
|
||||
{{ formatBetRecordWinHit(scope.row.win_hit) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="total_amount"
|
||||
:label="t('channel.bet_record_total_amount')"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="win_amount"
|
||||
:label="t('channel.bet_record_win_amount')"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="channel-record-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="betRecordDialog.total"
|
||||
:page-size="betRecordDialog.limit"
|
||||
:current-page="betRecordDialog.page"
|
||||
@current-change="onBetRecordPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { auth } from '/@/utils/common'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
@@ -173,23 +453,81 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
|
||||
const MANUAL_SETTLE_MOBILE_BREAKPOINT = 768
|
||||
const manualSettleViewportMobile = ref(typeof window !== 'undefined' ? window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT : false)
|
||||
const manualSettleCalcCollapse = ref<string[]>([])
|
||||
const manualSettleDialogWidth = computed(() => (manualSettleViewportMobile.value ? '96%' : '920px'))
|
||||
const manualSettleSummaryColumns = computed(() => (manualSettleViewportMobile.value ? 1 : 3))
|
||||
const manualSettleFormSize = computed(() => (manualSettleViewportMobile.value ? 'small' : 'default'))
|
||||
const manualSettleTableSize = computed(() => (manualSettleViewportMobile.value ? 'small' : 'default'))
|
||||
const manualSettleFormLabelWidth = computed(() => 'auto')
|
||||
const manualSettleTableLayout = computed(() => 'fixed')
|
||||
const manualSettleSplitCol = computed(() => {
|
||||
if (manualSettleViewportMobile.value) {
|
||||
return {
|
||||
adminWidth: 120,
|
||||
baseWidth: 72,
|
||||
shareWidth: 58,
|
||||
grossWidth: 72,
|
||||
commissionShareWidth: 62,
|
||||
feeWidth: 64,
|
||||
netWidth: 72,
|
||||
}
|
||||
}
|
||||
return {
|
||||
adminWidth: 132,
|
||||
baseWidth: 88,
|
||||
shareWidth: 72,
|
||||
grossWidth: 88,
|
||||
commissionShareWidth: 76,
|
||||
feeWidth: 80,
|
||||
netWidth: 88,
|
||||
}
|
||||
})
|
||||
|
||||
const manualSettleSplitTableMinWidth = computed(() => {
|
||||
const col = manualSettleSplitCol.value
|
||||
return (
|
||||
col.adminWidth +
|
||||
col.baseWidth +
|
||||
col.shareWidth +
|
||||
col.grossWidth +
|
||||
col.commissionShareWidth +
|
||||
col.feeWidth +
|
||||
col.netWidth +
|
||||
32
|
||||
)
|
||||
})
|
||||
|
||||
const manualSettleSplitInnerStyle = computed(() => {
|
||||
if (!manualSettleViewportMobile.value) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
width: `${manualSettleSplitTableMinWidth.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const syncManualSettleViewport = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
manualSettleViewportMobile.value = window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT
|
||||
}
|
||||
|
||||
const dismissFloatingTooltips = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.querySelectorAll('.el-popper[role="tooltip"]').forEach((node) => node.remove())
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur()
|
||||
}
|
||||
}
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
let optButtons: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'shareConfig',
|
||||
title: 'channel.share_config',
|
||||
text: '',
|
||||
type: 'primary',
|
||||
icon: 'el-icon-Setting',
|
||||
class: 'table-row-share-config',
|
||||
disabledTip: false,
|
||||
display: () => auth('edit'),
|
||||
click: (row: TableRow) => {
|
||||
void openShareDialog(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'manualSettle',
|
||||
@@ -198,8 +536,8 @@ let optButtons: OptButton[] = [
|
||||
type: 'warning',
|
||||
icon: 'el-icon-Clock',
|
||||
class: 'table-row-manual-settle',
|
||||
disabledTip: false,
|
||||
display: () => adminInfo.super && auth('manualSettle'),
|
||||
disabledTip: true,
|
||||
display: () => auth('manualSettle'),
|
||||
click: (row: TableRow) => {
|
||||
void openManualSettleDialog(row)
|
||||
},
|
||||
@@ -212,6 +550,57 @@ const formatAmountInt = (_row: any, _column: any, cellValue: number | string | n
|
||||
if (Number.isNaN(num)) return '-'
|
||||
return `${num}`
|
||||
}
|
||||
const formatCommissionSharePercent = (row: { commission_amount?: string | number; commission_share_percent?: string | number }) => {
|
||||
const preset = row.commission_share_percent
|
||||
if (preset !== null && preset !== undefined && preset !== '') {
|
||||
const n = Number(preset)
|
||||
if (Number.isFinite(n)) {
|
||||
return `${n.toFixed(2)}%`
|
||||
}
|
||||
}
|
||||
const total = Number(manualSettle.form.commission_amount ?? 0)
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(gross) || gross <= 0) {
|
||||
return '0.00%'
|
||||
}
|
||||
return `${((gross / total) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
const formatSplitAmountCell = (_row: anyObj, _column: any, cellValue: unknown) => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
return '-'
|
||||
}
|
||||
const n = Number(cellValue)
|
||||
if (!Number.isFinite(n)) {
|
||||
return String(cellValue)
|
||||
}
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
const formatAmount2 = (_row: any, _column: any, cellValue: number | string | null) => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
|
||||
const num = Number(cellValue)
|
||||
if (Number.isNaN(num)) return '-'
|
||||
return num.toFixed(2)
|
||||
}
|
||||
const formatBetRecordWinHit = (code: unknown) => {
|
||||
const key = code === null || code === undefined ? '' : String(code)
|
||||
if (!key) return '-'
|
||||
const i18nKey = `channel.bet_record_win_hit_${key}`
|
||||
const text = t(i18nKey)
|
||||
return text !== i18nKey ? text : key
|
||||
}
|
||||
|
||||
const betRecordWinHitTagType = (code: unknown): 'success' | 'info' | 'warning' | 'danger' => {
|
||||
const key = code === null || code === undefined ? '' : String(code)
|
||||
const map: Record<string, 'success' | 'info' | 'warning' | 'danger'> = {
|
||||
won: 'success',
|
||||
lost: 'info',
|
||||
pending: 'warning',
|
||||
}
|
||||
return map[key] ?? 'info'
|
||||
}
|
||||
|
||||
const formatSettleDay = (row: anyObj) => {
|
||||
if (row.settle_cycle === 'weekly') {
|
||||
return t(`channel.weekday ${row.settle_weekday ?? 1}`)
|
||||
@@ -237,18 +626,25 @@ const manualSettle = reactive({
|
||||
commission_rate: '',
|
||||
calc_base_amount: '',
|
||||
commission_amount: '',
|
||||
commission_split: [] as Array<{ admin_id: number; admin_username: string; share_rate: string; commission_amount: string }>,
|
||||
agent_mode: 'turnover' as string,
|
||||
settlement_handling_fee: '0.00',
|
||||
commission_split: [] as Array<{
|
||||
admin_id: number
|
||||
admin_username: string
|
||||
parent_admin_id?: number
|
||||
level?: number
|
||||
settlement_base_amount?: string
|
||||
share_rate: string
|
||||
commission_amount: string
|
||||
commission_share_percent?: string
|
||||
handling_fee_rate: number | string
|
||||
handling_fee?: string
|
||||
net_commission_amount?: string
|
||||
}>,
|
||||
remark: '',
|
||||
},
|
||||
})
|
||||
|
||||
const shareDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
channelId: 0,
|
||||
list: [] as Array<{ admin_id: number; username: string; role_group_name: string; role_level: number; status: number; share_rate: number | null }>,
|
||||
})
|
||||
const settleFilterMode = ref<'all' | 'with_balance' | 'no_balance' | 'enabled' | 'disabled'>('all')
|
||||
const settleStats = reactive({
|
||||
channel_total: 0,
|
||||
@@ -257,91 +653,44 @@ const settleStats = reactive({
|
||||
carryover_positive_count: 0,
|
||||
carryover_total: '0.00',
|
||||
carryover_positive_total: '0.00',
|
||||
paid_dividend_total: '0.00',
|
||||
company_total_bet_amount: '0.00',
|
||||
})
|
||||
|
||||
const shareEnabledTotal = computed(() => {
|
||||
let sum = 0
|
||||
for (const row of shareDialog.list) {
|
||||
if (row.status === 1) {
|
||||
const n = Number(row.share_rate ?? 0)
|
||||
if (Number.isFinite(n)) {
|
||||
sum += n
|
||||
}
|
||||
}
|
||||
}
|
||||
return sum.toFixed(2)
|
||||
const dividendDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
list: [] as anyObj[],
|
||||
})
|
||||
|
||||
const closeShareDialog = () => {
|
||||
shareDialog.visible = false
|
||||
shareDialog.channelId = 0
|
||||
shareDialog.list = []
|
||||
}
|
||||
const createBetRecordFilters = () => ({
|
||||
period_no: '',
|
||||
user_keyword: '',
|
||||
result_number: '',
|
||||
pick_number: '',
|
||||
win_hit: '' as '' | 'won' | 'lost' | 'pending',
|
||||
})
|
||||
|
||||
const openShareDialog = async (row: TableRow) => {
|
||||
shareDialog.channelId = Number(row.id || 0)
|
||||
shareDialog.visible = true
|
||||
shareDialog.loading = true
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/channel/channelAdminShareList',
|
||||
method: 'get',
|
||||
params: { id: row.id },
|
||||
},
|
||||
{ showErrorMessage: true }
|
||||
)
|
||||
if (res.code !== 1 || !res.data) {
|
||||
closeShareDialog()
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(res.data.list) ? res.data.list : []
|
||||
shareDialog.list = list.map((item: anyObj) => {
|
||||
const rate = item.share_rate
|
||||
return {
|
||||
admin_id: Number(item.admin_id || 0),
|
||||
username: String(item.username || ''),
|
||||
role_group_name: String(item.role_group_name || ''),
|
||||
role_level: Number(item.role_level ?? 9999),
|
||||
status: Number(item.status ?? 1) === 1 ? 1 : 0,
|
||||
share_rate: rate === null || rate === undefined || rate === '' ? null : Number(rate),
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
shareDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveShareDialog = async () => {
|
||||
if (!shareDialog.channelId) {
|
||||
return
|
||||
}
|
||||
if (shareEnabledTotal.value !== '100.00') {
|
||||
ElMessage.error(t('channel.share_total_must_100'))
|
||||
return
|
||||
}
|
||||
shareDialog.saving = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/channel/saveChannelAdminShare',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: shareDialog.channelId,
|
||||
list: shareDialog.list.map((row) => ({
|
||||
admin_id: row.admin_id,
|
||||
status: row.status,
|
||||
share_rate: Number(row.share_rate || 0).toFixed(2),
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
closeShareDialog()
|
||||
} finally {
|
||||
shareDialog.saving = false
|
||||
}
|
||||
}
|
||||
const betRecordDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
mode: '' as '' | 'direct' | 'company',
|
||||
channelId: 0,
|
||||
title: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
list: [] as anyObj[],
|
||||
filters: createBetRecordFilters(),
|
||||
summary: {
|
||||
record_count: 0,
|
||||
total_bet_amount: '0.00',
|
||||
total_win_amount: '0.00',
|
||||
},
|
||||
})
|
||||
|
||||
const resetManualSettleForm = () => {
|
||||
manualSettle.form.settlement_no = ''
|
||||
@@ -354,18 +703,105 @@ const resetManualSettleForm = () => {
|
||||
manualSettle.form.calc_base_amount = ''
|
||||
manualSettle.form.commission_amount = ''
|
||||
manualSettle.form.commission_split = []
|
||||
manualSettle.form.agent_mode = 'turnover'
|
||||
manualSettle.form.settlement_handling_fee = '0.00'
|
||||
manualSettle.form.remark = ''
|
||||
}
|
||||
|
||||
type ManualSettleSplitRow = {
|
||||
admin_id: number
|
||||
admin_username: string
|
||||
parent_admin_id?: number
|
||||
level?: number
|
||||
settlement_base_amount?: string
|
||||
share_rate: string
|
||||
commission_amount: string
|
||||
commission_share_percent?: string
|
||||
handling_fee_rate: number | string
|
||||
handling_fee?: string
|
||||
net_commission_amount?: string
|
||||
children?: ManualSettleSplitRow[]
|
||||
}
|
||||
|
||||
const buildCommissionSplitTree = (flat: ManualSettleSplitRow[]): ManualSettleSplitRow[] => {
|
||||
if (!flat.length) {
|
||||
return []
|
||||
}
|
||||
const nodeMap = new Map<number, ManualSettleSplitRow>()
|
||||
for (const row of flat) {
|
||||
nodeMap.set(row.admin_id, { ...row, children: [] })
|
||||
}
|
||||
const roots: ManualSettleSplitRow[] = []
|
||||
for (const row of flat) {
|
||||
const node = nodeMap.get(row.admin_id)
|
||||
if (!node) {
|
||||
continue
|
||||
}
|
||||
const parentId = Number(row.parent_admin_id ?? 0)
|
||||
if (parentId > 0 && nodeMap.has(parentId)) {
|
||||
const parent = nodeMap.get(parentId)
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
roots.push(node)
|
||||
}
|
||||
const pruneEmptyChildren = (nodes: ManualSettleSplitRow[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
pruneEmptyChildren(node.children)
|
||||
} else {
|
||||
delete node.children
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneEmptyChildren(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
const manualSettleSplitTree = computed(() => buildCommissionSplitTree(manualSettle.form.commission_split))
|
||||
|
||||
const manualSettleCalcDescLines = computed(() => {
|
||||
const mode = manualSettle.form.agent_mode === 'affiliate' ? 'affiliate' : 'turnover'
|
||||
const feeRate = manualSettle.form.settlement_handling_fee
|
||||
const prefix =
|
||||
mode === 'affiliate'
|
||||
? [
|
||||
t('channel.manual_settle_calc_intro_affiliate_1'),
|
||||
t('channel.manual_settle_calc_intro_affiliate_2'),
|
||||
t('channel.manual_settle_calc_intro_affiliate_3'),
|
||||
]
|
||||
: [
|
||||
t('channel.manual_settle_calc_intro_turnover_1'),
|
||||
t('channel.manual_settle_calc_intro_turnover_2'),
|
||||
t('channel.manual_settle_calc_intro_turnover_3'),
|
||||
]
|
||||
return [
|
||||
...prefix,
|
||||
t('channel.manual_settle_calc_tree_1'),
|
||||
t('channel.manual_settle_calc_tree_2'),
|
||||
t('channel.manual_settle_calc_tree_3'),
|
||||
t('channel.manual_settle_calc_handling_fee', { rate: feeRate }),
|
||||
]
|
||||
})
|
||||
|
||||
const closeManualSettleDialog = () => {
|
||||
manualSettle.visible = false
|
||||
resetManualSettleForm()
|
||||
}
|
||||
|
||||
const openManualSettleDialog = async (row: TableRow) => {
|
||||
dismissFloatingTooltips()
|
||||
syncManualSettleViewport()
|
||||
manualSettle.channelId = row.id
|
||||
resetManualSettleForm()
|
||||
manualSettle.visible = true
|
||||
await nextTick()
|
||||
dismissFloatingTooltips()
|
||||
manualSettle.previewLoading = true
|
||||
try {
|
||||
const res = await createAxios(
|
||||
@@ -390,7 +826,9 @@ const openManualSettleDialog = async (row: TableRow) => {
|
||||
manualSettle.form.commission_rate = d.commission_rate ?? ''
|
||||
manualSettle.form.calc_base_amount = d.calc_base_amount ?? ''
|
||||
manualSettle.form.commission_amount = d.commission_amount ?? ''
|
||||
manualSettle.form.commission_split = Array.isArray(d.commission_split) ? d.commission_split : []
|
||||
manualSettle.form.agent_mode = d.agent_mode ?? 'turnover'
|
||||
manualSettle.form.settlement_handling_fee = d.settlement_handling_fee ?? '0.00'
|
||||
manualSettle.form.commission_split = normalizeManualSettleSplit(d.commission_split, d.settlement_handling_fee)
|
||||
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
|
||||
} catch {
|
||||
manualSettle.visible = false
|
||||
@@ -399,6 +837,64 @@ const openManualSettleDialog = async (row: TableRow) => {
|
||||
}
|
||||
}
|
||||
|
||||
const calcHandlingFeeByPercent = (gross: number, ratePercent: number) => {
|
||||
const g = Number.isFinite(gross) && gross > 0 ? gross : 0
|
||||
let rate = Number.isFinite(ratePercent) ? ratePercent : 0
|
||||
if (rate < 0) rate = 0
|
||||
if (rate > 100) rate = 100
|
||||
return Math.round(g * rate * 100) / 10000
|
||||
}
|
||||
|
||||
const applyManualSettleRowAmounts = (row: {
|
||||
commission_amount?: string | number
|
||||
handling_fee_rate?: string | number
|
||||
handling_fee?: string | number
|
||||
net_commission_amount?: string
|
||||
}) => {
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
const rate = Number(row.handling_fee_rate ?? 0)
|
||||
const feeAmount = calcHandlingFeeByPercent(gross, rate)
|
||||
row.handling_fee = feeAmount.toFixed(2)
|
||||
row.net_commission_amount = Math.max(0, (Number.isFinite(gross) ? gross : 0) - feeAmount).toFixed(2)
|
||||
}
|
||||
|
||||
const normalizeManualSettleSplit = (rows: unknown, defaultFeeRate: unknown) => {
|
||||
const feeDefault = Number(defaultFeeRate)
|
||||
const baseRate = Number.isFinite(feeDefault) && feeDefault >= 0 ? Math.min(100, feeDefault) : 0
|
||||
if (!Array.isArray(rows)) {
|
||||
return []
|
||||
}
|
||||
return rows.map((row: anyObj) => {
|
||||
let rate = Number(row.handling_fee_rate ?? row.handling_fee ?? baseRate)
|
||||
if (!Number.isFinite(rate) || rate < 0) {
|
||||
rate = baseRate
|
||||
}
|
||||
if (rate > 100) {
|
||||
rate = 100
|
||||
}
|
||||
const normalized = {
|
||||
...row,
|
||||
handling_fee_rate: rate,
|
||||
}
|
||||
applyManualSettleRowAmounts(normalized)
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
const formatNetCommission = (row: {
|
||||
commission_amount?: string | number
|
||||
handling_fee_rate?: string | number
|
||||
net_commission_amount?: string
|
||||
}) => {
|
||||
if (row.net_commission_amount !== undefined && row.net_commission_amount !== '') {
|
||||
return row.net_commission_amount
|
||||
}
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
const rate = Number(row.handling_fee_rate ?? 0)
|
||||
const net = Math.max(0, (Number.isFinite(gross) ? gross : 0) - calcHandlingFeeByPercent(gross, rate))
|
||||
return net.toFixed(2)
|
||||
}
|
||||
|
||||
const submitManualSettle = async () => {
|
||||
if (!manualSettle.channelId) return
|
||||
manualSettle.loading = true
|
||||
@@ -410,6 +906,10 @@ const submitManualSettle = async () => {
|
||||
data: {
|
||||
id: manualSettle.channelId,
|
||||
remark: manualSettle.form.remark,
|
||||
commission_split: manualSettle.form.commission_split.map((row) => ({
|
||||
admin_id: row.admin_id,
|
||||
handling_fee_rate: row.handling_fee_rate ?? 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
@@ -448,6 +948,176 @@ const loadSettleStats = async () => {
|
||||
settleStats.carryover_positive_count = Number(res.data.carryover_positive_count ?? 0)
|
||||
settleStats.carryover_total = String(res.data.carryover_total ?? '0.00')
|
||||
settleStats.carryover_positive_total = String(res.data.carryover_positive_total ?? '0.00')
|
||||
settleStats.paid_dividend_total = String(res.data.paid_dividend_total ?? '0.00')
|
||||
settleStats.company_total_bet_amount = String(res.data.company_total_bet_amount ?? '0.00')
|
||||
}
|
||||
|
||||
const loadDividendRecords = async () => {
|
||||
dividendDialog.loading = true
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/channel/dividendRecordList',
|
||||
method: 'get',
|
||||
params: { page: dividendDialog.page, limit: dividendDialog.limit },
|
||||
})
|
||||
if (res.code !== 1 || !res.data) {
|
||||
return
|
||||
}
|
||||
dividendDialog.list = Array.isArray(res.data.list) ? res.data.list : []
|
||||
dividendDialog.total = Number(res.data.total ?? 0)
|
||||
} finally {
|
||||
dividendDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPaidDividendCardClick = () => {
|
||||
if (!auth('index')) {
|
||||
return
|
||||
}
|
||||
dividendDialog.page = 1
|
||||
dividendDialog.visible = true
|
||||
void loadDividendRecords()
|
||||
}
|
||||
|
||||
const onCompanyTotalBetCardClick = () => {
|
||||
if (!auth('index')) {
|
||||
return
|
||||
}
|
||||
resetBetRecordDialog()
|
||||
betRecordDialog.mode = 'company'
|
||||
betRecordDialog.channelId = 0
|
||||
betRecordDialog.title = t('channel.company_bet_record_dialog_title')
|
||||
betRecordDialog.visible = true
|
||||
void loadBetRecords()
|
||||
}
|
||||
|
||||
const closeDividendDialog = () => {
|
||||
dividendDialog.visible = false
|
||||
dividendDialog.list = []
|
||||
dividendDialog.total = 0
|
||||
dividendDialog.page = 1
|
||||
}
|
||||
|
||||
const onDividendPageChange = (page: number) => {
|
||||
dividendDialog.page = page
|
||||
void loadDividendRecords()
|
||||
}
|
||||
|
||||
const resetBetRecordDialog = () => {
|
||||
betRecordDialog.mode = ''
|
||||
betRecordDialog.channelId = 0
|
||||
betRecordDialog.title = ''
|
||||
betRecordDialog.page = 1
|
||||
betRecordDialog.total = 0
|
||||
betRecordDialog.list = []
|
||||
betRecordDialog.summary = {
|
||||
record_count: 0,
|
||||
total_bet_amount: '0.00',
|
||||
total_win_amount: '0.00',
|
||||
}
|
||||
Object.assign(betRecordDialog.filters, createBetRecordFilters())
|
||||
}
|
||||
|
||||
const buildBetRecordFilterParams = () => {
|
||||
const f = betRecordDialog.filters
|
||||
const params: Record<string, string> = {}
|
||||
if (f.period_no.trim()) {
|
||||
params.period_no = f.period_no.trim()
|
||||
}
|
||||
if (f.user_keyword.trim()) {
|
||||
params.user_keyword = f.user_keyword.trim()
|
||||
}
|
||||
if (f.result_number.trim()) {
|
||||
params.result_number = f.result_number.trim()
|
||||
}
|
||||
if (f.pick_number.trim()) {
|
||||
params.pick_number = f.pick_number.trim()
|
||||
}
|
||||
if (f.win_hit) {
|
||||
params.win_hit = f.win_hit
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const loadBetRecords = async () => {
|
||||
if (!betRecordDialog.mode) {
|
||||
return
|
||||
}
|
||||
if (betRecordDialog.mode === 'direct' && !betRecordDialog.channelId) {
|
||||
return
|
||||
}
|
||||
const url =
|
||||
betRecordDialog.mode === 'direct'
|
||||
? '/admin/channel/directBetRecordList'
|
||||
: '/admin/channel/companyBetRecordList'
|
||||
betRecordDialog.loading = true
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: betRecordDialog.page,
|
||||
limit: betRecordDialog.limit,
|
||||
...buildBetRecordFilterParams(),
|
||||
}
|
||||
if (betRecordDialog.mode === 'direct') {
|
||||
params.channel_id = betRecordDialog.channelId
|
||||
}
|
||||
const res = await createAxios({
|
||||
url,
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
if (res.code !== 1 || !res.data) {
|
||||
return
|
||||
}
|
||||
betRecordDialog.list = Array.isArray(res.data.list) ? res.data.list : []
|
||||
betRecordDialog.total = Number(res.data.total ?? 0)
|
||||
const summary = res.data.summary
|
||||
if (summary && typeof summary === 'object') {
|
||||
betRecordDialog.summary.record_count = Number(summary.record_count ?? 0)
|
||||
betRecordDialog.summary.total_bet_amount = String(summary.total_bet_amount ?? '0.00')
|
||||
betRecordDialog.summary.total_win_amount = String(summary.total_win_amount ?? '0.00')
|
||||
}
|
||||
} finally {
|
||||
betRecordDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const openBetRecordDialog = (mode: 'direct' | 'company', row: TableRow) => {
|
||||
if (!auth('index')) {
|
||||
return
|
||||
}
|
||||
resetBetRecordDialog()
|
||||
betRecordDialog.mode = mode
|
||||
betRecordDialog.channelId = Number(row.id ?? 0)
|
||||
betRecordDialog.title =
|
||||
mode === 'direct'
|
||||
? `${t('channel.direct_bet_record_dialog_title')} - ${row.name ?? row.id}`
|
||||
: t('channel.company_bet_record_dialog_title')
|
||||
betRecordDialog.visible = true
|
||||
void loadBetRecords()
|
||||
}
|
||||
|
||||
const closeBetRecordDialog = () => {
|
||||
betRecordDialog.visible = false
|
||||
resetBetRecordDialog()
|
||||
}
|
||||
|
||||
const onBetRecordPageChange = (page: number) => {
|
||||
betRecordDialog.page = page
|
||||
void loadBetRecords()
|
||||
}
|
||||
|
||||
const onBetRecordSearch = () => {
|
||||
betRecordDialog.page = 1
|
||||
void loadBetRecords()
|
||||
}
|
||||
|
||||
const onBetRecordReset = () => {
|
||||
Object.assign(betRecordDialog.filters, createBetRecordFilters())
|
||||
onBetRecordSearch()
|
||||
}
|
||||
|
||||
const openDirectBetDialog = (row: TableRow) => {
|
||||
void openBetRecordDialog('direct', row)
|
||||
}
|
||||
|
||||
const baTable = new baTableClass(
|
||||
@@ -570,6 +1240,22 @@ const baTable = new baTableClass(
|
||||
sortable: false,
|
||||
operator: 'RANGE',
|
||||
},
|
||||
{
|
||||
label: t('channel.direct_bet_amount'),
|
||||
prop: 'direct_bet_amount',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
sortable: false,
|
||||
operator: false,
|
||||
render: 'tag',
|
||||
formatter: formatAmount2,
|
||||
customRenderAttr: {
|
||||
tag: ({ row }: { row: TableRow }) => ({
|
||||
class: auth('index') ? 'channel-direct-bet-tag' : '',
|
||||
onClick: auth('index') ? () => openDirectBetDialog(row) : undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('channel.profit_amount'),
|
||||
prop: 'profit_amount',
|
||||
@@ -628,7 +1314,7 @@ const baTable = new baTableClass(
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
{ label: t('Operate'), align: 'center', width: 160, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined, 'status'],
|
||||
},
|
||||
@@ -636,6 +1322,7 @@ const baTable = new baTableClass(
|
||||
defaultItems: {
|
||||
status: '1',
|
||||
agent_mode: 'turnover',
|
||||
settlement_handling_fee: 0,
|
||||
settle_cycle: 'weekly',
|
||||
settle_weekday: 1,
|
||||
settle_monthday: 1,
|
||||
@@ -668,6 +1355,8 @@ baTable.after.getData = () => {
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
syncManualSettleViewport()
|
||||
window.addEventListener('resize', syncManualSettleViewport)
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
@@ -676,6 +1365,10 @@ onMounted(() => {
|
||||
})
|
||||
void loadSettleStats()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', syncManualSettleViewport)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -706,23 +1399,115 @@ onMounted(() => {
|
||||
|
||||
.channel-stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.channel-stat-card .label {
|
||||
.channel-stats-cards :deep(.el-card__body) {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.channel-stat-card {
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-stat-card-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.channel-stat-card-clickable:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.channel-stat-card-clickable .value {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(.channel-direct-bet-tag) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-bet-filter {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px 2px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.channel-bet-filter :deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.channel-bet-filter-input {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.channel-bet-filter-input--short {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.channel-bet-filter-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.channel-bet-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.channel-bet-summary-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-bet-summary-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.channel-stat-card .value {
|
||||
.channel-bet-summary-card .value {
|
||||
margin-top: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.channel-bet-record-table :deep(.el-table__cell) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-bet-record-table :deep(.cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.channel-record-pagination {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.channel-stat-card .label {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.channel-stat-card .value {
|
||||
margin-top: 4px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.channel-action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -731,30 +1516,307 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 手动结算弹窗:可滚动(勿使用 ba-operate-dialog 固定 58vh 高度) */
|
||||
:deep(.manual-settle-dialog.el-dialog) {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
width: 92% !important;
|
||||
max-width: 920px;
|
||||
max-height: 90vh;
|
||||
margin: 5vh auto !important;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__header) {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 20px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__header .title) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__body) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
max-height: calc(90vh - 116px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__footer) {
|
||||
flex-shrink: 0;
|
||||
position: static;
|
||||
width: auto;
|
||||
box-shadow: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.manual-settle-dialog-body {
|
||||
max-height: min(70vh, 680px);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog-body .el-loading-mask) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.manual-settle-summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.manual-settle-summary :deep(.el-descriptions__label) {
|
||||
width: 148px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.manual-settle-summary :deep(.el-descriptions__content) {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.manual-settle-summary-highlight {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.manual-settle-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-form :deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.manual-settle-split-form-item :deep(.el-form-item__label) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
padding-bottom: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.manual-settle-form-item-full {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manual-settle-form-item-full :deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-split-block {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.manual-settle-split-table-inner.is-mobile {
|
||||
display: inline-block;
|
||||
max-width: none;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__inner-wrapper) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__body-wrapper),
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__header-wrapper) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__wrap) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__bar.is-horizontal) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-scroll-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.manual-settle-split-table:not(.is-mobile) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header th.el-table__cell),
|
||||
.manual-settle-split-table :deep(.el-table__body td.el-table__cell) {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header .cell),
|
||||
.manual-settle-split-table :deep(.el-table__body .cell) {
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header .cell) {
|
||||
white-space: normal;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__header th.el-table__cell),
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body td.el-table__cell) {
|
||||
padding: 5px 3px;
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__header .cell),
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body .cell) {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body td:first-child .cell) {
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.manual-settle-calc-collapse {
|
||||
margin-top: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.manual-settle-calc-collapse :deep(.el-collapse-item__header) {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: none;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-collapse :deep(.el-collapse-item__wrap) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.manual-settle-calc-collapse :deep(.el-collapse-item__content) {
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.65;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.manual-settle-calc-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.manual-settle-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
.manual-settle-footer .el-button {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
/* 渠道记录弹窗:可滚动、适配移动端(勿使用 ba-operate-dialog 固定高度) */
|
||||
:deep(.channel-record-dialog.el-dialog) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 92vh;
|
||||
margin: 4vh auto !important;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
width: 92% !important;
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog.channel-bet-record-dialog.el-dialog) {
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog .el-dialog__header) {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog .el-dialog__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
max-height: calc(92vh - 56px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 12px 14px 16px;
|
||||
}
|
||||
|
||||
.channel-record-dialog__body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.channel-record-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.channel-record-table {
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.channel-bet-record-table {
|
||||
min-width: 880px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -762,8 +1824,159 @@ onMounted(() => {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.manual-settle-form {
|
||||
grid-template-columns: 1fr;
|
||||
.manual-settle-summary :deep(.el-descriptions__label) {
|
||||
width: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manual-settle-summary :deep(.el-descriptions__content) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.manual-settle-dialog.el-dialog) {
|
||||
width: 96% !important;
|
||||
max-width: none;
|
||||
max-height: 94vh;
|
||||
margin: 3vh auto !important;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__header) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__body) {
|
||||
max-height: calc(94vh - 96px);
|
||||
padding: 12px 12px 14px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__footer) {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.manual-settle-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.manual-settle-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.manual-settle-split-form-item :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.manual-settle-form-item-full :deep(.el-form-item__content) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manual-settle-split-block {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-calc-collapse :deep(.el-collapse-item__header) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-list {
|
||||
font-size: 11px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.manual-settle-footer {
|
||||
flex-wrap: wrap;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.manual-settle-footer .el-button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog.el-dialog) {
|
||||
width: 96% !important;
|
||||
max-height: 94vh;
|
||||
margin: 3vh auto !important;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog .el-dialog__body) {
|
||||
max-height: calc(94vh - 52px);
|
||||
padding: 10px 10px 14px;
|
||||
}
|
||||
|
||||
.channel-bet-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.channel-bet-summary-card :deep(.el-card__body) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.channel-bet-summary-card .label {
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.channel-bet-summary-card .value {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.channel-bet-filter {
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.channel-bet-filter :deep(.el-form-item) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.channel-bet-filter :deep(.el-form-item__label) {
|
||||
width: 72px !important;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.channel-bet-filter :deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-bet-filter-input,
|
||||
.channel-bet-filter-input--short,
|
||||
.channel-bet-filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-bet-filter :deep(.el-form-item:last-child .el-form-item__content) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channel-record-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.channel-record-pagination :deep(.el-pagination) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,6 +69,14 @@
|
||||
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
|
||||
:placeholder="`${t('Please input field', { field: t('channel.turnover_share_rate') })} (例如 30.5)`"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('channel.settlement_handling_fee')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.settlement_handling_fee"
|
||||
prop="settlement_handling_fee"
|
||||
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
|
||||
:placeholder="t('channel.settlement_handling_fee_tip')"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="currentAgentMode === 'affiliate'"
|
||||
:label="t('channel.affiliate_contract_no')"
|
||||
|
||||
@@ -5,14 +5,21 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
{{ t('config.financeCashierConfig.btn_save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeMainTab" class="main-tabs">
|
||||
<el-tab-pane :label="t('config.financeCashierConfig.tab_cashier')" name="cashier"></el-tab-pane>
|
||||
<el-tab-pane :label="t('config.financeCashierConfig.tab_tiers')" name="tiers"></el-tab-pane>
|
||||
<el-tab-pane :label="t('config.financeCashierConfig.tab_deposit_banks')" name="depositBanks"></el-tab-pane>
|
||||
<el-tab-pane :label="t('config.financeCashierConfig.tab_withdraw_banks')" name="withdrawBanks"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-scrollbar max-height="calc(100vh - 220px)">
|
||||
<el-form v-loading="loading" label-width="160px" class="finance-cashier-form">
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_platform') }}</template>
|
||||
<el-form-item :label="t('config.financeCashierConfig.platform_label_zh')">
|
||||
<el-input v-model="form.platform_coin.label_zh" maxlength="32" class="w400" />
|
||||
@@ -22,10 +29,10 @@
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<span>{{ t('config.financeCashierConfig.sec_currencies') }}</span>
|
||||
<el-button type="primary" link class="ml-2" @click="addCurrency">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
<el-button v-if="canSave" type="primary" link class="ml-2" @click="addCurrency">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
</template>
|
||||
<p class="hint">{{ t('config.financeCashierConfig.currency_rates_hint') }}</p>
|
||||
<el-table :data="form.currencies" border stripe size="small">
|
||||
@@ -66,13 +73,13 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('Operate')" width="90" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link @click="removeRow(form.currencies, $index)">{{ t('Delete') }}</el-button>
|
||||
<el-button v-if="canSave" type="danger" link @click="removeCurrencyRow($index)">{{ t('Delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_deposit_channels') }}</template>
|
||||
<p class="hint">{{ t('config.financeCashierConfig.deposit_channels_hint') }}</p>
|
||||
<el-table :data="form.channels" border stripe size="small">
|
||||
@@ -103,15 +110,101 @@
|
||||
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.ch_currency_codes')" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="currency-codes-cell">
|
||||
<el-checkbox-group v-model="row.currency_codes" size="small">
|
||||
<el-checkbox
|
||||
v-for="c in form.currencies"
|
||||
:key="c.code"
|
||||
:label="c.code"
|
||||
>
|
||||
{{ currencyLabel(c.code) }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'tiers'" shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<span>{{ t('config.financeCashierConfig.sec_banks') }}</span>
|
||||
<el-button type="primary" link class="ml-2" @click="addBank">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
<span>{{ t('config.financeCashierConfig.sec_deposit_tiers') }}</span>
|
||||
<el-button v-if="canSave" type="primary" link class="ml-2" @click="addTier">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
<el-button v-if="canSave" type="success" link class="ml-2" :loading="tiersSaving" @click="saveTiers">{{ t('config.financeCashierConfig.btn_save_tiers') }}</el-button>
|
||||
</template>
|
||||
<el-table :data="form.withdraw_banks" border stripe size="small">
|
||||
<p class="hint">{{ t('config.financeCashierConfig.deposit_tiers_hint') }}</p>
|
||||
<el-table :data="tiers" border stripe size="small">
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_tier_id')" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.id" maxlength="32" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_title_zh')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.title" maxlength="64" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_title_en')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.title_en" maxlength="64" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_currency_code')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.currency" class="w100p" filterable>
|
||||
<el-option v-for="c in form.currencies" :key="c.code" :label="currencyLabel(c.code)" :value="c.code" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_pay_amount')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.pay_amount" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_amount')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.amount" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_bonus_amount')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.bonus_amount" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_tier_sort')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="false" class="w100p" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_tier_status')" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('Operate')" width="90" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button v-if="canSave" type="danger" link @click="removeRow(tiers, $index)">{{ t('Delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeMainTab === 'depositBanks'" shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<span>{{ t('config.financeCashierConfig.sec_deposit_banks') }}</span>
|
||||
<el-button v-if="canSave" type="primary" link class="ml-2" @click="addDepositBank">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
</template>
|
||||
<p class="hint">{{ t('config.financeCashierConfig.deposit_banks_hint') }}</p>
|
||||
<el-table :data="form.deposit_banks" border stripe size="small">
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_currency_code')" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.currency_code" class="w100p" filterable>
|
||||
<el-option v-for="c in form.currencies" :key="c.code" :label="currencyLabel(c.code)" :value="c.code" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_bank_code')" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.code" maxlength="32" :placeholder="t('config.financeCashierConfig.ph_bank_code')" />
|
||||
@@ -129,18 +222,60 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_sort')" width="108">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="true" class="w100p" @change="resortBanksInPlace" />
|
||||
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="true" class="w100p" @change="resortDepositBanksInPlace" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('Operate')" width="90" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link @click="removeRow(form.withdraw_banks, $index)">{{ t('Delete') }}</el-button>
|
||||
<el-button v-if="canSave" type="danger" link @click="removeRow(form.deposit_banks, $index)">{{ t('Delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'withdrawBanks'" shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<span>{{ t('config.financeCashierConfig.sec_withdraw_banks') }}</span>
|
||||
<el-button v-if="canSave" type="primary" link class="ml-2" @click="addWithdrawBank">{{ t('config.financeCashierConfig.btn_add_row') }}</el-button>
|
||||
</template>
|
||||
<p class="hint">{{ t('config.financeCashierConfig.withdraw_banks_hint') }}</p>
|
||||
<el-table :data="form.withdraw_banks" border stripe size="small">
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_currency_code')" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.currency_code" class="w100p" filterable>
|
||||
<el-option v-for="c in form.currencies" :key="c.code" :label="currencyLabel(c.code)" :value="c.code" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_bank_code')" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.code" maxlength="32" :placeholder="t('config.financeCashierConfig.ph_bank_code')" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_name_zh')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name_zh" maxlength="64" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_name_en')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name_en" maxlength="64" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('config.financeCashierConfig.col_sort')" width="108">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.sort" :min="0" :max="99999" :controls="true" class="w100p" @change="resortWithdrawBanksInPlace" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('Operate')" width="90" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button v-if="canSave" type="danger" link @click="removeRow(form.withdraw_banks, $index)">{{ t('Delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_limits') }}</template>
|
||||
<el-form-item :label="t('config.financeCashierConfig.min_ewallet')">
|
||||
<el-input v-model="form.withdraw_limits.min_ewallet" class="w240" />
|
||||
@@ -150,7 +285,7 @@
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_copy') }}</template>
|
||||
<el-form-item :label="t('config.financeCashierConfig.rate_mode')">
|
||||
<el-select v-model="form.withdraw_copy.rate_mode" class="w240">
|
||||
@@ -178,20 +313,17 @@
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_fields') }}</template>
|
||||
<el-form-item :label="t('config.financeCashierConfig.field_cardholder')">
|
||||
<el-switch v-model="form.withdraw_fields.require_cardholder" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('config.financeCashierConfig.field_bank_account')">
|
||||
<el-switch v-model="form.withdraw_fields.require_bank_account" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('config.financeCashierConfig.field_email')">
|
||||
<el-switch v-model="form.withdraw_fields.require_email" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('config.financeCashierConfig.field_mobile')">
|
||||
<el-switch v-model="form.withdraw_fields.require_mobile" />
|
||||
</el-form-item>
|
||||
<el-card v-if="activeMainTab === 'cashier'" shadow="never" class="section-card">
|
||||
<template #header>{{ t('config.financeCashierConfig.sec_ddpay_spec') }}</template>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-2">
|
||||
{{ t('config.financeCashierConfig.ddpay_spec_intro') }}
|
||||
</el-alert>
|
||||
<ul class="ddpay-spec-list">
|
||||
<li>{{ t('config.financeCashierConfig.ddpay_spec_li_withdraw') }}</li>
|
||||
<li>{{ t('config.financeCashierConfig.ddpay_spec_li_bank_table') }}</li>
|
||||
<li>{{ t('config.financeCashierConfig.ddpay_spec_li_deposit') }}</li>
|
||||
<li>{{ t('config.financeCashierConfig.ddpay_spec_li_doc') }}</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</el-scrollbar>
|
||||
@@ -201,7 +333,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { auth } from '/@/utils/common'
|
||||
|
||||
@@ -210,6 +342,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const canSave = auth('save')
|
||||
|
||||
type CurrencyRow = {
|
||||
code: string
|
||||
@@ -219,17 +352,35 @@ type CurrencyRow = {
|
||||
deposit_coins_per_fiat: string
|
||||
withdraw_coins_per_fiat: string
|
||||
}
|
||||
type BankRow = { code: string; name_zh: string; name_en: string; sort: number }
|
||||
type ChannelRow = { code: string; sort: number; status: number; tier_ids: string[] }
|
||||
type BankRow = { currency_code: string; code: string; name_zh: string; name_en: string; sort: number }
|
||||
type ChannelRow = { code: string; sort: number; status: number; tier_ids: string[]; currency_codes: string[] }
|
||||
type TierRow = {
|
||||
id: string
|
||||
title: string
|
||||
title_en: string
|
||||
currency: string
|
||||
pay_amount: string
|
||||
amount: string
|
||||
bonus_amount: string
|
||||
desc: string
|
||||
desc_en: string
|
||||
sort: number
|
||||
status: number
|
||||
}
|
||||
type RegistryMeta = { name?: string; name_en?: string; sort?: number }
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const registry = ref<Record<string, RegistryMeta>>({})
|
||||
const activeMainTab = ref('cashier')
|
||||
const originalCurrencyCodes = ref<string[]>([])
|
||||
const tiers = ref<TierRow[]>([])
|
||||
const tiersSaving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
platform_coin: { label_zh: '', label_en: '' },
|
||||
currencies: [] as CurrencyRow[],
|
||||
deposit_banks: [] as BankRow[],
|
||||
withdraw_banks: [] as BankRow[],
|
||||
withdraw_limits: { min_ewallet: '0', min_bank: '0' },
|
||||
withdraw_copy: {
|
||||
@@ -241,12 +392,6 @@ const form = reactive({
|
||||
fee_note_en: '',
|
||||
rate_mode: 'fixed',
|
||||
},
|
||||
withdraw_fields: {
|
||||
require_cardholder: true,
|
||||
require_bank_account: true,
|
||||
require_email: true,
|
||||
require_mobile: true,
|
||||
},
|
||||
channels: [] as ChannelRow[],
|
||||
})
|
||||
|
||||
@@ -300,8 +445,26 @@ function resortCurrenciesInPlace() {
|
||||
})
|
||||
}
|
||||
|
||||
function resortBanksInPlace() {
|
||||
function resortDepositBanksInPlace() {
|
||||
form.deposit_banks.sort((a, b) => {
|
||||
const cc = String(a.currency_code || '').localeCompare(String(b.currency_code || ''))
|
||||
if (cc !== 0) {
|
||||
return cc
|
||||
}
|
||||
const ds = rowSortValue(a) - rowSortValue(b)
|
||||
if (ds !== 0) {
|
||||
return ds
|
||||
}
|
||||
return String(a.code || '').localeCompare(String(b.code || ''))
|
||||
})
|
||||
}
|
||||
|
||||
function resortWithdrawBanksInPlace() {
|
||||
form.withdraw_banks.sort((a, b) => {
|
||||
const cc = String(a.currency_code || '').localeCompare(String(b.currency_code || ''))
|
||||
if (cc !== 0) {
|
||||
return cc
|
||||
}
|
||||
const ds = rowSortValue(a) - rowSortValue(b)
|
||||
if (ds !== 0) {
|
||||
return ds
|
||||
@@ -344,25 +507,92 @@ function channelDisplayName(code: string): string {
|
||||
return code
|
||||
}
|
||||
|
||||
function normalizeChannelRow(c: Record<string, unknown>): ChannelRow {
|
||||
function currencyLabel(code: string): string {
|
||||
const cur = form.currencies.find((x) => x.code === code)
|
||||
if (!cur) {
|
||||
return code
|
||||
}
|
||||
const loc = String(locale.value ?? '').toLowerCase().replaceAll('_', '-')
|
||||
const preferEn = loc === 'en' || loc.startsWith('en-')
|
||||
if (preferEn) {
|
||||
if (typeof cur.label_en === 'string' && cur.label_en !== '') {
|
||||
return cur.label_en
|
||||
}
|
||||
}
|
||||
if (typeof cur.label_zh === 'string' && cur.label_zh !== '') {
|
||||
return cur.label_zh
|
||||
}
|
||||
return cur.label_en
|
||||
}
|
||||
|
||||
function normalizeChannelRow(c: Record<string, unknown>, defaultCurrencyCodes: string[]): ChannelRow {
|
||||
const st = c.status
|
||||
const statusOn = st === 1 || st === true || st === '1'
|
||||
const rawCodes = c.currency_codes
|
||||
const isArray = Array.isArray(rawCodes)
|
||||
// null/undefined 表示“兼容全部币种”(历史配置默认行为)
|
||||
const codesCandidate = isArray ? (rawCodes as unknown[]) : null
|
||||
const all = new Set(defaultCurrencyCodes)
|
||||
|
||||
let cc: string[]
|
||||
if (codesCandidate === null) {
|
||||
cc = defaultCurrencyCodes
|
||||
} else {
|
||||
cc = codesCandidate
|
||||
.map((x) => (typeof x === 'string' ? x.trim().toUpperCase() : ''))
|
||||
.filter((x) => x !== '')
|
||||
.filter((x) => all.has(x))
|
||||
}
|
||||
|
||||
return {
|
||||
code: typeof c.code === 'string' ? c.code : '',
|
||||
sort: rowSortValue({ sort: c.sort }),
|
||||
status: statusOn ? 1 : 0,
|
||||
tier_ids: [],
|
||||
currency_codes: cc,
|
||||
}
|
||||
}
|
||||
|
||||
function addBank() {
|
||||
form.withdraw_banks.push({ code: '', name_zh: '', name_en: '', sort: nextSort(form.withdraw_banks) })
|
||||
function addDepositBank() {
|
||||
form.deposit_banks.push({ currency_code: '', code: '', name_zh: '', name_en: '', sort: nextSort(form.deposit_banks) })
|
||||
}
|
||||
|
||||
function addWithdrawBank() {
|
||||
form.withdraw_banks.push({ currency_code: '', code: '', name_zh: '', name_en: '', sort: nextSort(form.withdraw_banks) })
|
||||
}
|
||||
|
||||
function removeRow<T>(arr: T[], index: number) {
|
||||
arr.splice(index, 1)
|
||||
}
|
||||
|
||||
async function removeCurrencyRow(index: number) {
|
||||
const row = form.currencies[index]
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
const code = String(row.code || '').trim().toUpperCase()
|
||||
if (code === '') {
|
||||
form.currencies.splice(index, 1)
|
||||
return
|
||||
}
|
||||
const affected = tiers.value.filter((x) => String(x.currency || '').trim().toUpperCase() === code)
|
||||
if (affected.length === 0) {
|
||||
form.currencies.splice(index, 1)
|
||||
return
|
||||
}
|
||||
const sample = affected.slice(0, 10).map((x) => x.id).join(',')
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
t('config.financeCashierConfig.msg_delete_currency_prune_tiers', { currency: code, count: affected.length }) +
|
||||
(sample !== '' ? `\n${t('config.financeCashierConfig.msg_affected_tier_ids')}: ${sample}` : ''),
|
||||
t('Warning'),
|
||||
{ type: 'warning', confirmButtonText: t('OK'), cancelButtonText: t('Cancel') }
|
||||
).then(() => true).catch(() => false)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
form.currencies.splice(index, 1)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -398,29 +628,100 @@ async function load() {
|
||||
: '100',
|
||||
}))
|
||||
form.currencies.splice(0, form.currencies.length, ...normalized)
|
||||
originalCurrencyCodes.value = normalized.map((x) => x.code)
|
||||
resortCurrenciesInPlace()
|
||||
const defaultCurrencyCodes = normalized.map((x) => String(x.code || '')).filter((x) => x !== '')
|
||||
const depositBankList = Array.isArray(f.deposit_banks) ? f.deposit_banks : []
|
||||
const depositBanksNorm: BankRow[] = depositBankList.map((b: Record<string, unknown>) => ({
|
||||
currency_code: typeof b.currency_code === 'string' ? b.currency_code : '',
|
||||
code: typeof b.code === 'string' ? b.code : '',
|
||||
name_zh: typeof b.name_zh === 'string' ? b.name_zh : '',
|
||||
name_en: typeof b.name_en === 'string' ? b.name_en : '',
|
||||
sort: rowSortValue({ sort: b.sort }),
|
||||
}))
|
||||
form.deposit_banks.splice(0, form.deposit_banks.length, ...depositBanksNorm)
|
||||
resortDepositBanksInPlace()
|
||||
const bankList = Array.isArray(f.withdraw_banks) ? f.withdraw_banks : []
|
||||
const banksNorm: BankRow[] = bankList.map((b: Record<string, unknown>) => ({
|
||||
currency_code: typeof b.currency_code === 'string' ? b.currency_code : '',
|
||||
code: typeof b.code === 'string' ? b.code : '',
|
||||
name_zh: typeof b.name_zh === 'string' ? b.name_zh : '',
|
||||
name_en: typeof b.name_en === 'string' ? b.name_en : '',
|
||||
sort: rowSortValue({ sort: b.sort }),
|
||||
}))
|
||||
form.withdraw_banks.splice(0, form.withdraw_banks.length, ...banksNorm)
|
||||
resortBanksInPlace()
|
||||
resortWithdrawBanksInPlace()
|
||||
const chList = Array.isArray(f.channels) ? f.channels : []
|
||||
const channelsNorm: ChannelRow[] = chList.map((c) => normalizeChannelRow(c as Record<string, unknown>))
|
||||
const channelsNorm: ChannelRow[] = chList.map((c) => normalizeChannelRow(c as Record<string, unknown>, defaultCurrencyCodes))
|
||||
form.channels.splice(0, form.channels.length, ...channelsNorm)
|
||||
resortChannelsInPlace()
|
||||
Object.assign(form.withdraw_limits, f.withdraw_limits || {})
|
||||
Object.assign(form.withdraw_copy, f.withdraw_copy || {})
|
||||
Object.assign(form.withdraw_fields, f.withdraw_fields || {})
|
||||
await loadTiers()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTiers() {
|
||||
const res = await createAxios({
|
||||
url: '/admin/config.FinanceCashierConfig/tierList',
|
||||
method: 'get',
|
||||
})
|
||||
const listRaw = res.code === 1 && Array.isArray(res.data?.list) ? res.data.list : []
|
||||
tiers.value = listRaw.map((row: Record<string, unknown>) => ({
|
||||
id: typeof row.id === 'string' ? row.id : '',
|
||||
title: typeof row.title === 'string' ? row.title : '',
|
||||
title_en: typeof row.title_en === 'string' ? row.title_en : '',
|
||||
currency: typeof row.currency === 'string' ? row.currency : '',
|
||||
pay_amount: typeof row.pay_amount === 'string' ? row.pay_amount : String(row.pay_amount ?? ''),
|
||||
amount: typeof row.amount === 'string' ? row.amount : String(row.amount ?? ''),
|
||||
bonus_amount: typeof row.bonus_amount === 'string' ? row.bonus_amount : String(row.bonus_amount ?? '0'),
|
||||
desc: typeof row.desc === 'string' ? row.desc : '',
|
||||
desc_en: typeof row.desc_en === 'string' ? row.desc_en : '',
|
||||
sort: rowSortValue({ sort: row.sort }),
|
||||
status: row.status === 1 || row.status === true || row.status === '1' ? 1 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function addTier() {
|
||||
const currency = form.currencies.length > 0 ? form.currencies[0].code : ''
|
||||
tiers.value.push({
|
||||
id: `t_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
currency,
|
||||
pay_amount: '',
|
||||
amount: '',
|
||||
bonus_amount: '0',
|
||||
desc: '',
|
||||
desc_en: '',
|
||||
sort: 10,
|
||||
status: 1,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveTiers() {
|
||||
if (!canSave) {
|
||||
return
|
||||
}
|
||||
tiersSaving.value = true
|
||||
try {
|
||||
await createAxios({
|
||||
url: '/admin/config.FinanceCashierConfig/tierSave',
|
||||
method: 'post',
|
||||
data: {
|
||||
items: JSON.parse(JSON.stringify(tiers.value)),
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
await loadTiers()
|
||||
} finally {
|
||||
tiersSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!auth('save')) {
|
||||
return
|
||||
@@ -434,14 +735,67 @@ async function onSave() {
|
||||
return
|
||||
}
|
||||
resortCurrenciesInPlace()
|
||||
resortBanksInPlace()
|
||||
const allowed = new Set(form.currencies.map((c) => String(c.code || '').trim().toUpperCase()).filter((x) => x !== ''))
|
||||
for (const b of form.deposit_banks) {
|
||||
b.currency_code = String(b.currency_code || '').trim().toUpperCase()
|
||||
b.code = String(b.code || '').trim().toLowerCase()
|
||||
if (!allowed.has(b.currency_code)) {
|
||||
b.currency_code = ''
|
||||
}
|
||||
}
|
||||
for (const b of form.withdraw_banks) {
|
||||
b.currency_code = String(b.currency_code || '').trim().toUpperCase()
|
||||
b.code = String(b.code || '').trim().toLowerCase()
|
||||
if (!allowed.has(b.currency_code)) {
|
||||
b.currency_code = ''
|
||||
}
|
||||
}
|
||||
resortDepositBanksInPlace()
|
||||
resortWithdrawBanksInPlace()
|
||||
resortChannelsInPlace()
|
||||
const removedCurrencyCodes = originalCurrencyCodes.value.filter((x) => !allowed.has(x))
|
||||
let pruneTierCurrencies: string[] = []
|
||||
if (removedCurrencyCodes.length > 0) {
|
||||
const affected = tiers.value.filter((x) => removedCurrencyCodes.includes(String(x.currency || '').toUpperCase()))
|
||||
if (affected.length > 0) {
|
||||
const sample = affected.slice(0, 10).map((x) => x.id).join(', ')
|
||||
const detail = sample !== '' ? `\n${t('config.financeCashierConfig.msg_affected_tier_ids')}: ${sample}` : ''
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
t('config.financeCashierConfig.msg_delete_currency_prune_tiers', {
|
||||
currency: removedCurrencyCodes.join(','),
|
||||
count: affected.length,
|
||||
}) + detail,
|
||||
t('Warning'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: t('OK'),
|
||||
cancelButtonText: t('Cancel'),
|
||||
}
|
||||
).then(() => true).catch(() => false)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
pruneTierCurrencies = removedCurrencyCodes
|
||||
}
|
||||
}
|
||||
// 仅保留配置了的币种码(后端还会校验)
|
||||
for (const ch of form.channels) {
|
||||
const raw = Array.isArray(ch.currency_codes) ? ch.currency_codes : []
|
||||
const normalized = raw
|
||||
.map((x) => (typeof x === 'string' ? x.trim().toUpperCase() : ''))
|
||||
.filter((x) => x !== '')
|
||||
.filter((x) => allowed.has(x))
|
||||
ch.currency_codes = Array.from(new Set(normalized))
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createAxios({
|
||||
url: '/admin/config.FinanceCashierConfig/save',
|
||||
method: 'post',
|
||||
data: JSON.parse(JSON.stringify(form)),
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(form)),
|
||||
prune_tier_currency_codes: pruneTierCurrencies,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
await load()
|
||||
@@ -483,5 +837,30 @@ onMounted(() => {
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.main-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ddpay-spec-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.ddpay-spec-list li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.currency-codes-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
padding-right: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,134 +1,340 @@
|
||||
<template>
|
||||
<div class="default-main ba-table-box">
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('config.gameConfig.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
|
||||
<PopupForm />
|
||||
<template>
|
||||
<div class="default-main">
|
||||
<el-row v-loading="state.loading">
|
||||
<el-col :xs="24" :sm="18">
|
||||
<el-alert v-if="state.remark" :title="state.remark" type="info" show-icon />
|
||||
<el-form
|
||||
v-if="!state.loading"
|
||||
ref="formRef"
|
||||
:model="state.form"
|
||||
:label-position="'top'"
|
||||
@submit.prevent=""
|
||||
@keyup.enter="onSubmit()"
|
||||
>
|
||||
<el-tabs v-model="state.activeTab" type="border-card">
|
||||
<el-tab-pane :label="t('config.gameConfig.form tab label')" name="game_config">
|
||||
<div class="config-form-item" v-for="item in state.configList" :key="item.id">
|
||||
<template v-if="item.config_key === BET_CHIPS_KEY">
|
||||
<el-form-item :label="resolveFieldLabel(item.config_key)">
|
||||
<div class="bet-chips-editor">
|
||||
<div
|
||||
v-for="slot in CHIP_SLOTS"
|
||||
:key="slot"
|
||||
class="bet-chips-row"
|
||||
>
|
||||
<span class="bet-chips-key">{{ slot }}</span>
|
||||
<span class="bet-chips-sep">{{ t('config.gameConfig.bet_chips_colon') }}</span>
|
||||
<el-input-number
|
||||
v-model="state.betChipsAmounts[slot]"
|
||||
:min="0.01"
|
||||
:max="99999999"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="bet-chips-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resolveFieldTip(item.config_key, item.remark)" class="bet-chips-tip">
|
||||
{{ resolveFieldTip(item.config_key, item.remark) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="config-form-item-name">{{ item.config_key }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FormItem
|
||||
:label="resolveFieldLabel(item.config_key)"
|
||||
:type="resolveFormType(item.value_type)"
|
||||
v-model="state.form[item.config_key]"
|
||||
:input-attr="resolveInputAttr(item.value_type)"
|
||||
:tip="resolveFieldTip(item.config_key, item.remark)"
|
||||
/>
|
||||
<div class="config-form-item-name">{{ item.config_key }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-button @click="onReset">{{ t('Reset') }}</el-button>
|
||||
<el-button v-if="canSave" type="primary" :loading="state.submitLoading" @click="onSubmit()">{{ t('Save') }}</el-button>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-form>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { auth } from '/@/utils/common'
|
||||
|
||||
defineOptions({
|
||||
name: 'config/gameConfig',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
interface GameConfigItem {
|
||||
id: number
|
||||
config_key: string
|
||||
config_value: string
|
||||
value_type: 'string' | 'int' | 'decimal' | 'json'
|
||||
remark: string
|
||||
}
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/config.GameConfig/'),
|
||||
{
|
||||
pk: 'id',
|
||||
filter: { page: 1, limit: 50 },
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('config.gameConfig.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('config.gameConfig.config_key'),
|
||||
prop: 'config_key',
|
||||
align: 'center',
|
||||
minWidth: 200,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('config.gameConfig.config_value'),
|
||||
prop: 'config_value',
|
||||
align: 'center',
|
||||
minWidth: 160,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('config.gameConfig.value_type'),
|
||||
prop: 'value_type',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
operator: 'eq',
|
||||
render: 'tag',
|
||||
custom: {
|
||||
string: 'primary',
|
||||
int: 'success',
|
||||
decimal: 'warning',
|
||||
json: 'info',
|
||||
},
|
||||
replaceValue: {
|
||||
string: t('config.gameConfig.value_type string'),
|
||||
int: t('config.gameConfig.value_type int'),
|
||||
decimal: t('config.gameConfig.value_type decimal'),
|
||||
json: t('config.gameConfig.value_type json'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('config.gameConfig.remark'),
|
||||
prop: 'remark',
|
||||
align: 'center',
|
||||
minWidth: 200,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('config.gameConfig.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('config.gameConfig.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: { value_type: 'string', remark: '' },
|
||||
const BET_CHIPS_KEY = 'bet_chips'
|
||||
const CHIP_SLOTS = [1, 2, 3, 4, 5, 6] as const
|
||||
|
||||
const defaultBetChipsAmounts = (): Record<number, number> => ({
|
||||
1: 1,
|
||||
2: 5,
|
||||
3: 10,
|
||||
4: 25,
|
||||
5: 50,
|
||||
6: 100,
|
||||
})
|
||||
|
||||
const parseBetChipsFromServer = (raw: string): Record<number, number> => {
|
||||
const out = defaultBetChipsAmounts()
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return out
|
||||
}
|
||||
)
|
||||
try {
|
||||
const o = JSON.parse(raw) as unknown
|
||||
if (typeof o !== 'object' || o === null) {
|
||||
return out
|
||||
}
|
||||
const rec = o as Record<string, unknown>
|
||||
for (const slot of CHIP_SLOTS) {
|
||||
const rawVal = rec[String(slot)]
|
||||
const n = Number(rawVal)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
out[slot] = n
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 使用内置默认 */
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
provide('baTable', baTable)
|
||||
const serializeBetChips = (amounts: Record<number, number>): string => {
|
||||
const o: Record<string, string> = {}
|
||||
for (const slot of CHIP_SLOTS) {
|
||||
const n = Number(amounts[slot])
|
||||
o[String(slot)] = Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
return JSON.stringify(o)
|
||||
}
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const api = new baTableApi('/admin/config.GameConfig/')
|
||||
const excludedConfigKeys = new Set(['period_auto_create_enabled', 'period_manual_create_enabled'])
|
||||
const canSave =
|
||||
auth('save') ||
|
||||
auth({ name: '/admin/config/gameConfig', subNodeName: '/admin/config/game_config/save' })
|
||||
|
||||
const state: {
|
||||
loading: boolean
|
||||
submitLoading: boolean
|
||||
activeTab: string
|
||||
remark: string
|
||||
configList: GameConfigItem[]
|
||||
form: Record<string, string | number>
|
||||
betChipsAmounts: Record<number, number>
|
||||
} = reactive({
|
||||
loading: true,
|
||||
submitLoading: false,
|
||||
activeTab: 'game_config',
|
||||
remark: '',
|
||||
configList: [],
|
||||
form: {},
|
||||
betChipsAmounts: defaultBetChipsAmounts(),
|
||||
})
|
||||
|
||||
const getData = () => {
|
||||
state.loading = true
|
||||
api.index({ page: 1, limit: 999 }).then((res) => {
|
||||
const allList = (res.data.list || []) as GameConfigItem[]
|
||||
const list = allList.filter((item) => !excludedConfigKeys.has(item.config_key))
|
||||
const displayOrder: Record<string, number> = {
|
||||
period_seconds: 10,
|
||||
bet_seconds: 20,
|
||||
payout_seconds: 30,
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
const orderA = displayOrder[a.config_key] ?? 1000 + a.id
|
||||
const orderB = displayOrder[b.config_key] ?? 1000 + b.id
|
||||
return orderA - orderB
|
||||
})
|
||||
state.configList = list
|
||||
state.remark = res.data.remark || ''
|
||||
const nextForm: Record<string, string | number> = {}
|
||||
for (const item of list) {
|
||||
if (item.config_key === BET_CHIPS_KEY) {
|
||||
state.betChipsAmounts = parseBetChipsFromServer(item.config_value ?? '')
|
||||
continue
|
||||
}
|
||||
if (item.value_type === 'int' || item.value_type === 'decimal') {
|
||||
const parsed = Number(item.config_value)
|
||||
nextForm[item.config_key] = Number.isNaN(parsed) ? 0 : parsed
|
||||
continue
|
||||
}
|
||||
nextForm[item.config_key] = item.config_value ?? ''
|
||||
}
|
||||
state.form = nextForm
|
||||
}).finally(() => {
|
||||
state.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
const resolveFormType = (valueType: GameConfigItem['value_type']) => {
|
||||
if (valueType === 'json') return 'textarea'
|
||||
if (valueType === 'int' || valueType === 'decimal') return 'number'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
const resolveInputAttr = (valueType: GameConfigItem['value_type']) => {
|
||||
if (valueType === 'json') {
|
||||
return { rows: 5 }
|
||||
}
|
||||
if (valueType === 'decimal') {
|
||||
return { precision: 4 }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const resolveFieldLabel = (configKey: string) => {
|
||||
const langKey = `config.gameConfig.field ${configKey}`
|
||||
if (te(langKey)) {
|
||||
return t(langKey)
|
||||
}
|
||||
return configKey
|
||||
}
|
||||
|
||||
const resolveFieldTip = (configKey: string, fallbackRemark: string) => {
|
||||
const langKey = `config.gameConfig.field_tip ${configKey}`
|
||||
if (te(langKey)) {
|
||||
return t(langKey)
|
||||
}
|
||||
return fallbackRemark || ''
|
||||
}
|
||||
|
||||
const validateBetChips = (): boolean => {
|
||||
for (const slot of CHIP_SLOTS) {
|
||||
const v = state.betChipsAmounts[slot]
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
ElMessage.error(t('config.gameConfig.bet_chips_validate_slot', { slot }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
if (!validateBetChips()) {
|
||||
return
|
||||
}
|
||||
|
||||
state.submitLoading = true
|
||||
try {
|
||||
const items = JSON.parse(JSON.stringify(state.form)) as Record<string, string | number>
|
||||
items[BET_CHIPS_KEY] = serializeBetChips(state.betChipsAmounts)
|
||||
await createAxios({
|
||||
url: '/admin/config.GameConfig/save',
|
||||
method: 'post',
|
||||
data: {
|
||||
items,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
getData()
|
||||
} finally {
|
||||
state.submitLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
getData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
getData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.el-tabs--border-card {
|
||||
border: none;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
.el-tabs--border-card :deep(.el-tabs__header) {
|
||||
background-color: var(--ba-bg-color);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--el-border-radius-base);
|
||||
border-top-right-radius: var(--el-border-radius-base);
|
||||
}
|
||||
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
|
||||
border-top-left-radius: var(--el-border-radius-base);
|
||||
border-top-right-radius: var(--el-border-radius-base);
|
||||
}
|
||||
.config-form-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
.el-form-item {
|
||||
flex: 13;
|
||||
}
|
||||
.config-form-item-name {
|
||||
flex: 3;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-disabled);
|
||||
padding-left: 20px;
|
||||
opacity: 0;
|
||||
padding-top: 28px;
|
||||
}
|
||||
&:hover .config-form-item-name {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.bet-chips-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.bet-chips-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.bet-chips-key {
|
||||
min-width: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.bet-chips-sep {
|
||||
color: var(--el-text-color-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.bet-chips-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.bet-chips-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="success" :loading="saving" :disabled="loading" @click="onSave">
|
||||
<el-button v-if="canSave" type="success" :loading="saving" :disabled="loading" @click="onSave">
|
||||
{{ t('config.streakWinReward.btn_save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -50,6 +50,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const canSave = auth('save')
|
||||
|
||||
type Row = {
|
||||
streak: number
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
{{ t('config.ziHuaDictionary.btn_save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -39,6 +39,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const canSave = auth('save')
|
||||
|
||||
type Item = { no: number; name: string; category: string }
|
||||
|
||||
|
||||
286
web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue
Normal file
286
web/src/views/backend/docs/doc36ZiHuaMobileApi/index.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="default-main doc-md-page" v-loading="loading">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="doc-md-header">
|
||||
<span class="doc-md-title">{{ pageTitle }}</span>
|
||||
<el-button type="primary" :loading="downloading" @click="onDownload">{{ t('docs36ziMobileApi.download') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loadError" class="color-danger">{{ loadError }}</div>
|
||||
<el-row v-else :gutter="16" class="doc-md-row">
|
||||
<el-col v-if="toc.length" :xs="24" :sm="8" :md="7" :lg="6" class="doc-md-nav-col">
|
||||
<el-affix :offset="88">
|
||||
<div class="doc-nav-panel">
|
||||
<div class="doc-nav-title">{{ t('docs36ziMobileApi.nav_outline') }}</div>
|
||||
<el-scrollbar max-height="calc(100vh - 200px)">
|
||||
<ul class="doc-nav-list">
|
||||
<li
|
||||
v-for="item in toc"
|
||||
:key="item.id"
|
||||
class="doc-nav-item"
|
||||
:class="'doc-nav-level-' + item.level"
|
||||
>
|
||||
<a href="#" class="doc-nav-link" @click.prevent="scrollToHeading(item.id)">{{
|
||||
item.text
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-affix>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="toc.length ? 16 : 24" :md="toc.length ? 17 : 24" :lg="toc.length ? 18 : 24">
|
||||
<div ref="bodyRef" class="ba-markdown doc-md-body" v-html="html"></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getUrl } from '/@/utils/axios'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
const config = useConfig()
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const downloading = ref(false)
|
||||
const markdown = ref('')
|
||||
const loadError = ref('')
|
||||
const downloadFilename = ref('36字花-移动端接口设计草案.md')
|
||||
|
||||
marked.use({ breaks: true })
|
||||
|
||||
interface TocItem {
|
||||
level: number
|
||||
text: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const toc = computed((): TocItem[] => {
|
||||
const md = markdown.value
|
||||
if (!md) {
|
||||
return []
|
||||
}
|
||||
const lines = md.split(/\r?\n/)
|
||||
const out: TocItem[] = []
|
||||
let idx = 0
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
const m = /^(#{1,4})\s+(.+)$/.exec(trimmed)
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
const level = m[1].length
|
||||
let text = m[2].trim()
|
||||
text = text.replace(/\s+#+\s*$/, '').trim()
|
||||
out.push({ level, text, id: 'md-nav-' + String(idx) })
|
||||
idx += 1
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const html = computed(() => {
|
||||
if (!markdown.value) {
|
||||
return ''
|
||||
}
|
||||
return String(marked.parse(markdown.value))
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => t('docs36ziMobileApi.doc_title'))
|
||||
|
||||
function applyHeadingIds() {
|
||||
const root = bodyRef.value
|
||||
const items = toc.value
|
||||
if (!root || !items.length) {
|
||||
return
|
||||
}
|
||||
const hs = root.querySelectorAll('h1, h2, h3, h4')
|
||||
const n = Math.min(items.length, hs.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const el = hs.item(i)
|
||||
el.id = items[i].id
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [html.value, markdown.value],
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
applyHeadingIds()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
async function loadContent() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/docs.Doc36ZiHuaMobileApi/content',
|
||||
method: 'get',
|
||||
},
|
||||
{ loading: false }
|
||||
)
|
||||
if (res.code === 1 && res.data && typeof res.data.markdown === 'string') {
|
||||
markdown.value = res.data.markdown
|
||||
if (typeof res.data.filename === 'string' && res.data.filename) {
|
||||
downloadFilename.value = res.data.filename
|
||||
}
|
||||
} else {
|
||||
loadError.value = res.msg || t('docs36ziMobileApi.load_fail')
|
||||
}
|
||||
} catch {
|
||||
loadError.value = t('docs36ziMobileApi.load_fail')
|
||||
} finally {
|
||||
loading.value = false
|
||||
void nextTick(() => {
|
||||
applyHeadingIds()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHeading(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function onDownload() {
|
||||
downloading.value = true
|
||||
try {
|
||||
const token = adminInfo.getToken()
|
||||
const fullUrl = getUrl() + '/admin/docs.Doc36ZiHuaMobileApi/download'
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
batoken: token,
|
||||
server: 'true',
|
||||
'think-lang': config.lang.defaultLang,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
loadError.value = t('docs36ziMobileApi.load_fail')
|
||||
return
|
||||
}
|
||||
const ctype = response.headers.get('content-type') || ''
|
||||
if (ctype.includes('application/json')) {
|
||||
const j = await response.json()
|
||||
loadError.value = typeof j.msg === 'string' && j.msg ? j.msg : t('docs36ziMobileApi.load_fail')
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
triggerBlobDownload(blob, downloadFilename.value)
|
||||
} catch {
|
||||
loadError.value = t('docs36ziMobileApi.load_fail')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadContent()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => config.lang.defaultLang,
|
||||
() => {
|
||||
void loadContent()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.doc-md-page {
|
||||
.doc-md-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.doc-md-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-md-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.doc-md-nav-col {
|
||||
margin-bottom: 16px;
|
||||
@media (min-width: 768px) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.doc-nav-panel {
|
||||
padding: 12px 12px 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--ba-bg-color-overlay);
|
||||
}
|
||||
.doc-nav-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.doc-nav-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.doc-nav-item {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.doc-nav-level-1 {
|
||||
padding-left: 0;
|
||||
}
|
||||
.doc-nav-level-2 {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.doc-nav-level-3 {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.doc-nav-level-4 {
|
||||
padding-left: 30px;
|
||||
}
|
||||
.doc-nav-link {
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
.doc-md-body {
|
||||
max-width: 1100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
290
web/src/views/backend/docs/docCommissionShare/index.vue
Normal file
290
web/src/views/backend/docs/docCommissionShare/index.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="default-main doc-md-page" v-loading="loading">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="doc-md-header">
|
||||
<span class="doc-md-title">{{ pageTitle }}</span>
|
||||
<el-button type="primary" :loading="downloading" @click="onDownload">{{ t('docsCommissionShare.download') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loadError" class="color-danger">{{ loadError }}</div>
|
||||
<el-row v-else :gutter="16" class="doc-md-row">
|
||||
<el-col v-if="toc.length" :xs="24" :sm="8" :md="7" :lg="6" class="doc-md-nav-col">
|
||||
<el-affix :offset="88">
|
||||
<div class="doc-nav-panel">
|
||||
<div class="doc-nav-title">{{ t('docsCommissionShare.nav_outline') }}</div>
|
||||
<el-scrollbar max-height="calc(100vh - 200px)">
|
||||
<ul class="doc-nav-list">
|
||||
<li
|
||||
v-for="item in toc"
|
||||
:key="item.id"
|
||||
class="doc-nav-item"
|
||||
:class="'doc-nav-level-' + item.level"
|
||||
>
|
||||
<a href="#" class="doc-nav-link" @click.prevent="scrollToHeading(item.id)">{{
|
||||
item.text
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-affix>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="toc.length ? 16 : 24" :md="toc.length ? 17 : 24" :lg="toc.length ? 18 : 24">
|
||||
<div ref="bodyRef" class="ba-markdown doc-md-body" v-html="html"></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getUrl } from '/@/utils/axios'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
|
||||
defineOptions({
|
||||
name: 'docs/docCommissionShare',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
const config = useConfig()
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const downloading = ref(false)
|
||||
const markdown = ref('')
|
||||
const loadError = ref('')
|
||||
const downloadFilename = ref('分红说明文档.md')
|
||||
|
||||
marked.use({ breaks: true })
|
||||
|
||||
interface TocItem {
|
||||
level: number
|
||||
text: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const toc = computed((): TocItem[] => {
|
||||
const md = markdown.value
|
||||
if (!md) {
|
||||
return []
|
||||
}
|
||||
const lines = md.split(/\r?\n/)
|
||||
const out: TocItem[] = []
|
||||
let idx = 0
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
const m = /^(#{1,4})\s+(.+)$/.exec(trimmed)
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
const level = m[1].length
|
||||
let text = m[2].trim()
|
||||
text = text.replace(/\s+#+\s*$/, '').trim()
|
||||
out.push({ level, text, id: 'md-nav-' + String(idx) })
|
||||
idx += 1
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const html = computed(() => {
|
||||
if (!markdown.value) {
|
||||
return ''
|
||||
}
|
||||
return String(marked.parse(markdown.value))
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => t('docsCommissionShare.doc_title'))
|
||||
|
||||
function applyHeadingIds() {
|
||||
const root = bodyRef.value
|
||||
const items = toc.value
|
||||
if (!root || !items.length) {
|
||||
return
|
||||
}
|
||||
const hs = root.querySelectorAll('h1, h2, h3, h4')
|
||||
const n = Math.min(items.length, hs.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const el = hs.item(i)
|
||||
el.id = items[i].id
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [html.value, markdown.value],
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
applyHeadingIds()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
async function loadContent() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/docs.DocCommissionShare/content',
|
||||
method: 'get',
|
||||
},
|
||||
{ loading: false }
|
||||
)
|
||||
if (res.code === 1 && res.data && typeof res.data.markdown === 'string') {
|
||||
markdown.value = res.data.markdown
|
||||
if (typeof res.data.filename === 'string' && res.data.filename) {
|
||||
downloadFilename.value = res.data.filename
|
||||
}
|
||||
} else {
|
||||
loadError.value = res.msg || t('docsCommissionShare.load_fail')
|
||||
}
|
||||
} catch {
|
||||
loadError.value = t('docsCommissionShare.load_fail')
|
||||
} finally {
|
||||
loading.value = false
|
||||
void nextTick(() => {
|
||||
applyHeadingIds()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHeading(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function onDownload() {
|
||||
downloading.value = true
|
||||
try {
|
||||
const token = adminInfo.getToken()
|
||||
const fullUrl = getUrl() + '/admin/docs.DocCommissionShare/download'
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
batoken: token,
|
||||
server: 'true',
|
||||
'think-lang': config.lang.defaultLang,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
loadError.value = t('docsCommissionShare.load_fail')
|
||||
return
|
||||
}
|
||||
const ctype = response.headers.get('content-type') || ''
|
||||
if (ctype.includes('application/json')) {
|
||||
const j = await response.json()
|
||||
loadError.value = typeof j.msg === 'string' && j.msg ? j.msg : t('docsCommissionShare.load_fail')
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
triggerBlobDownload(blob, downloadFilename.value)
|
||||
} catch {
|
||||
loadError.value = t('docsCommissionShare.load_fail')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadContent()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => config.lang.defaultLang,
|
||||
() => {
|
||||
void loadContent()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.doc-md-page {
|
||||
.doc-md-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.doc-md-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-md-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.doc-md-nav-col {
|
||||
margin-bottom: 16px;
|
||||
@media (min-width: 768px) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.doc-nav-panel {
|
||||
padding: 12px 12px 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--ba-bg-color-overlay);
|
||||
}
|
||||
.doc-nav-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.doc-nav-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.doc-nav-item {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.doc-nav-level-1 {
|
||||
padding-left: 0;
|
||||
}
|
||||
.doc-nav-level-2 {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.doc-nav-level-3 {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.doc-nav-level-4 {
|
||||
padding-left: 30px;
|
||||
}
|
||||
.doc-nav-link {
|
||||
color: var(--el-text-color-regular);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
.doc-md-body {
|
||||
max-width: 1100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="default-main">
|
||||
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
|
||||
<el-alert type="info" :title="t('game.live.push_disconnected')" show-icon class="mb-12" />
|
||||
<el-alert :type="wsConnected ? 'success' : 'warning'" :title="wsConnected ? t('game.live.ws_connected') : t('game.live.ws_disconnected')" show-icon class="mb-12" />
|
||||
<el-alert
|
||||
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
|
||||
type="warning"
|
||||
@@ -27,6 +27,17 @@
|
||||
@change="onRuntimeSwitch"
|
||||
/>
|
||||
<span v-if="snapshot.maintenance_ui" class="live-top-toolbar__hint">{{ t('game.live.runtime_off_tip') }}</span>
|
||||
<div class="live-top-toolbar__actions">
|
||||
<el-button
|
||||
class="live-top-toolbar__btn-void"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="asideOperationLocked || !canVoidPeriod || voidSubmitting || runtimeSwitchLoading"
|
||||
@click="openVoidDialog"
|
||||
>
|
||||
{{ t('game.live.void_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +81,7 @@
|
||||
<div class="calc-result-bar">
|
||||
<span class="calc-result-bar__item">
|
||||
<span class="calc-result-bar__k">{{ t('game.live.calc_result_number') }}</span>
|
||||
<span class="calc-result-bar__v">{{ calcResultNumber ?? '—' }}</span>
|
||||
<span class="calc-result-bar__v">{{ displayResultNumber ?? '—' }}</span>
|
||||
</span>
|
||||
<span class="calc-result-bar__item">
|
||||
<span class="calc-result-bar__k">{{ t('game.live.calc_estimated_loss') }}</span>
|
||||
@@ -78,75 +89,81 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="live-control-aside" :class="{ 'is-locked': asideOperationLocked }">
|
||||
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
|
||||
<el-button
|
||||
class="aside-void-btn"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="asideOperationLocked || !canVoidPeriod || voidSubmitting || runtimeSwitchLoading"
|
||||
@click="openVoidDialog"
|
||||
>
|
||||
{{ t('game.live.void_btn') }}
|
||||
</el-button>
|
||||
<div class="aside-field">
|
||||
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
|
||||
<el-input-number
|
||||
v-model="manualNumber"
|
||||
class="aside-field__input"
|
||||
:min="1"
|
||||
:max="snapshot.draw_number_max ?? 36"
|
||||
:step="1"
|
||||
:disabled="asideOperationLocked"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
<div class="aside-btns">
|
||||
<el-button
|
||||
:loading="calcLoading"
|
||||
:disabled="asideOperationLocked || !snapshot.can_calculate"
|
||||
@click="onCalculate"
|
||||
>
|
||||
{{ t('game.live.btn_calc') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="drawLoading"
|
||||
:disabled="asideOperationLocked || !snapshot.can_schedule_draw"
|
||||
@click="onDraw"
|
||||
>
|
||||
{{ t('game.live.btn_draw') }}
|
||||
</el-button>
|
||||
<el-button :loading="loading" :disabled="asideOperationLocked" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-row :gutter="12" class="live-tables-row">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('game.live.candidate_title') }}</template>
|
||||
<el-table :data="snapshot.candidate_numbers" height="420">
|
||||
<el-table-column prop="number" :label="t('game.live.number')" width="100" />
|
||||
<el-table-column prop="estimated_loss" :label="t('game.live.estimated_loss')" />
|
||||
<el-table
|
||||
:data="candidateNumbersSorted"
|
||||
:height="tableHeight"
|
||||
:row-class-name="candidateRowClassName"
|
||||
class="candidate-table"
|
||||
:default-sort="{ prop: 'estimated_loss', order: 'ascending' }"
|
||||
@sort-change="onCandidateSortChange"
|
||||
>
|
||||
<el-table-column prop="number" :label="t('game.live.number')" width="76" align="center" header-align="center" sortable="custom">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" effect="plain" class="number-tag">
|
||||
{{ scope.row.number ?? '-' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="estimated_loss"
|
||||
:label="t('game.live.estimated_loss')"
|
||||
width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
sortable="custom"
|
||||
/>
|
||||
<el-table-column :label="t('game.live.btn_draw')" width="96" align="center" header-align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="isScheduledNumber(scope.row.number)"
|
||||
:loading="drawLoading && pendingSwitchNumber === scope.row.number"
|
||||
:disabled="asideOperationLocked || !snapshot.can_schedule_draw || drawLoading"
|
||||
inline-prompt
|
||||
@change="(v:boolean) => onPickSwitchChange(v, scope.row.number)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('game.live.bet_stream_title') }}</template>
|
||||
<el-table :data="snapshot.bets" height="420">
|
||||
<el-table-column prop="id" :label="t('game.live.bet_id')" width="90" />
|
||||
<el-table-column prop="user_id" :label="t('game.live.user_id')" width="90" />
|
||||
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')">
|
||||
<el-table :data="snapshot.bets" :height="tableHeight" class="bet-stream-table">
|
||||
<el-table-column prop="username" :label="t('game.live.username')" width="120" align="center" header-align="center">
|
||||
<template #default="scope">
|
||||
{{ formatPicks(scope.row.pick_numbers) }}
|
||||
<span class="bet-user">{{ String(scope.row.username || '-') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')" min-width="150" align="center" header-align="center">
|
||||
<template #default="scope">
|
||||
<div class="pick-tags">
|
||||
<el-tag
|
||||
v-for="num in parsePickNumbers(scope.row.pick_numbers)"
|
||||
:key="`pick-${scope.row.id}-${String(num)}`"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="pick-tags__item"
|
||||
>
|
||||
{{ num }}
|
||||
</el-tag>
|
||||
<span v-if="parsePickNumbers(scope.row.pick_numbers).length === 0">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_amount" :label="t('game.live.total_amount')" width="92" align="center" header-align="center" />
|
||||
<el-table-column prop="win_amount" :label="t('game.live.win_amount')" width="92" align="center" header-align="center">
|
||||
<template #default="scope">
|
||||
<span class="mono">{{ formatWinAmount(scope.row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_amount" :label="t('game.live.total_amount')" width="120" />
|
||||
<el-table-column prop="streak_at_bet" :label="t('game.live.streak_at_bet')" width="90" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -187,9 +204,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
interface Snapshot {
|
||||
@@ -215,6 +233,8 @@ interface Snapshot {
|
||||
runtime_enabled?: boolean
|
||||
/** 完整维护 UI:关服且当前无进行中/未结清对局(派彩已全部完成) */
|
||||
maintenance_ui?: boolean
|
||||
/** 已开奖号码(status≥2 时有值) */
|
||||
result_number?: number | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -238,14 +258,16 @@ const snapshot = reactive<Snapshot>({
|
||||
can_schedule_draw: false,
|
||||
runtime_enabled: true,
|
||||
maintenance_ui: false,
|
||||
result_number: null,
|
||||
})
|
||||
const calcLoading = ref(false)
|
||||
const drawLoading = ref(false)
|
||||
const pendingSwitchNumber = ref<number | null>(null)
|
||||
const runtimeSwitchLoading = ref(false)
|
||||
const pendingRuntimeTarget = ref<boolean | null>(null)
|
||||
const voidDialogVisible = ref(false)
|
||||
const voidReason = ref('')
|
||||
const voidSubmitting = ref(false)
|
||||
const manualNumber = ref<number | null>(1)
|
||||
const calcResultNumber = ref<number | null>(null)
|
||||
const calcEstimatedLoss = ref<string>('0.00')
|
||||
|
||||
@@ -254,8 +276,269 @@ const serverSkewSeconds = ref(0)
|
||||
/** 每秒递增,驱动派彩剩余秒本地刷新 */
|
||||
const clockTick = ref(0)
|
||||
let clockTimer: number | null = null
|
||||
let fallbackPollTimer: number | null = null
|
||||
/** 合并并发 snapshot 请求,避免 axios 重复请求取消导致控制台报错 */
|
||||
let snapshotLoadPromise: Promise<void> | null = null
|
||||
|
||||
let pollTimer: number | null = null
|
||||
const wsLoading = ref(false)
|
||||
const wsReady = ref(false)
|
||||
const wsConnected = ref(false)
|
||||
/** 同一期只提示一次“中大奖”,避免 bet.win/jackpot.hit 连续触发刷屏 */
|
||||
const lastJackpotTipPeriodNo = ref<string>('')
|
||||
const lastJackpotTipAtMs = ref<number>(0)
|
||||
const wsUrl = ref('')
|
||||
const wsTopics = ref<string[]>([])
|
||||
const wsClient = ref<WebSocket | null>(null)
|
||||
let wsReconnectTimer: number | null = null
|
||||
let wsReconnectAttempt = 0
|
||||
let wsLastConfigReloadAtMs = 0
|
||||
let wsManualClosing = false
|
||||
const isMobile = ref(false)
|
||||
const candidateSort = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'estimated_loss', order: 'ascending' })
|
||||
|
||||
function updateIsMobile(): void {
|
||||
isMobile.value = window.matchMedia('(max-width: 768px)').matches
|
||||
}
|
||||
|
||||
const tableHeight = computed(() => (isMobile.value ? 320 : 420))
|
||||
|
||||
async function reloadWsConfig(): Promise<void> {
|
||||
wsLoading.value = true
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/game.Live/wsConfig',
|
||||
method: 'get',
|
||||
showCodeMessage: true,
|
||||
})
|
||||
if (res.code !== 1 || !res.data) {
|
||||
wsReady.value = false
|
||||
return
|
||||
}
|
||||
wsUrl.value = String(res.data.ws_url || '')
|
||||
if (Array.isArray(res.data.subscribe_topics)) {
|
||||
wsTopics.value = res.data.subscribe_topics.filter((topic: unknown): topic is string => typeof topic === 'string' && topic.trim() !== '')
|
||||
} else {
|
||||
wsTopics.value = []
|
||||
}
|
||||
wsReady.value = wsUrl.value !== ''
|
||||
} finally {
|
||||
wsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleWsPayload(raw: unknown): void {
|
||||
let parsed: anyObj | null = null
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else if (raw && typeof raw === 'object') {
|
||||
parsed = raw as anyObj
|
||||
}
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
const event = typeof parsed.event === 'string' ? parsed.event : ''
|
||||
if (event === 'admin.live.snapshot' && parsed.data && typeof parsed.data === 'object') {
|
||||
mergeLiveSnapshot(parsed.data as anyObj)
|
||||
return
|
||||
}
|
||||
if (event === 'admin.live.opened') {
|
||||
const opened = parsed.data as anyObj
|
||||
if (typeof opened.result_number === 'number') {
|
||||
snapshot.result_number = opened.result_number
|
||||
calcResultNumber.value = opened.result_number
|
||||
}
|
||||
if (typeof opened.payout_until === 'number' && opened.payout_until > 0 && snapshot.record) {
|
||||
snapshot.record.payout_until = opened.payout_until
|
||||
snapshot.is_payout_phase = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event === 'admin.live.finalized' && parsed.data && typeof parsed.data === 'object') {
|
||||
const fin = parsed.data as anyObj
|
||||
snapshot.is_payout_phase = false
|
||||
snapshot.payout_remaining_seconds = 0
|
||||
if (toBool(fin.maintenance_ui) === true) {
|
||||
snapshot.maintenance_ui = true
|
||||
snapshot.record = null
|
||||
}
|
||||
if (toBool(fin.runtime_enabled) !== null) {
|
||||
snapshot.runtime_enabled = toBool(fin.runtime_enabled) === true
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event === 'period.payout' && parsed.data && typeof parsed.data === 'object') {
|
||||
mergePeriodPayoutTick(parsed.data as anyObj)
|
||||
const payoutData = parsed.data as anyObj
|
||||
if (typeof payoutData.result_number === 'number') {
|
||||
snapshot.result_number = payoutData.result_number
|
||||
calcResultNumber.value = payoutData.result_number
|
||||
}
|
||||
snapshot.is_payout_phase = true
|
||||
return
|
||||
}
|
||||
if (event === 'bet.win' && parsed.data && typeof parsed.data === 'object') {
|
||||
const winData = parsed.data as anyObj
|
||||
if (winData.is_jackpot === true) {
|
||||
showJackpotTipOnce(readString(winData.period_no) || readString(snapshot.record?.period_no))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event === 'jackpot.hit' && parsed.data && typeof parsed.data === 'object') {
|
||||
const jackpotData = parsed.data as anyObj
|
||||
const hits = Array.isArray(jackpotData.hits) ? jackpotData.hits : []
|
||||
if (hits.length > 0) {
|
||||
showJackpotTipOnce(readString(jackpotData.period_no) || readString(snapshot.record?.period_no))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event === 'period.payout.tick' && parsed.data && typeof parsed.data === 'object') {
|
||||
mergePeriodPayoutTick(parsed.data as anyObj)
|
||||
return
|
||||
}
|
||||
if (event === 'period.tick' && parsed.data && typeof parsed.data === 'object') {
|
||||
handlePeriodTickEvent(parsed.data as anyObj)
|
||||
}
|
||||
}
|
||||
|
||||
function readString(v: any): string {
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function showJackpotTipOnce(periodNo: string): void {
|
||||
const key = String(periodNo || '')
|
||||
const now = Date.now()
|
||||
// 同一期只提示一次;同时加 2s 防抖,避免同一瞬间 bet.win + jackpot.hit 连续触发刷屏
|
||||
if (key !== '' && lastJackpotTipPeriodNo.value === key) {
|
||||
return
|
||||
}
|
||||
if (now - lastJackpotTipAtMs.value < 2000) {
|
||||
return
|
||||
}
|
||||
if (key !== '') {
|
||||
lastJackpotTipPeriodNo.value = key
|
||||
}
|
||||
lastJackpotTipAtMs.value = now
|
||||
ElMessage.success(t('game.live.jackpot_hit_tip'))
|
||||
}
|
||||
|
||||
/** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP,避免与 WS 每秒 snapshot 冲突) */
|
||||
function mergePeriodTickFields(periodData: anyObj): void {
|
||||
if (typeof periodData.server_time === 'number') {
|
||||
syncServerClock(periodData.server_time)
|
||||
}
|
||||
if (typeof periodData.countdown === 'number') {
|
||||
snapshot.remaining_seconds = Math.max(0, periodData.countdown)
|
||||
}
|
||||
if (typeof periodData.bet_close_in === 'number') {
|
||||
snapshot.bet_remaining_seconds = Math.max(0, periodData.bet_close_in)
|
||||
}
|
||||
}
|
||||
|
||||
function mergePeriodPayoutTick(data: anyObj): void {
|
||||
if (typeof data.server_time === 'number') {
|
||||
syncServerClock(data.server_time)
|
||||
}
|
||||
const remain = numberValue(data.payout_remaining_seconds)
|
||||
if (remain !== null) {
|
||||
snapshot.payout_remaining_seconds = Math.max(0, remain)
|
||||
snapshot.is_payout_phase = remain > 0
|
||||
}
|
||||
const until = readPayoutUntilUnix(data)
|
||||
if (until !== null && snapshot.record && typeof snapshot.record === 'object') {
|
||||
snapshot.record.payout_until = until
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeriodTickEvent(periodData: anyObj): void {
|
||||
mergePeriodTickFields(periodData)
|
||||
const status = typeof periodData.status === 'string' ? periodData.status : ''
|
||||
const periodNo = typeof periodData.period_no === 'string' ? periodData.period_no : ''
|
||||
const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : ''
|
||||
const runtimeOff =
|
||||
toBool(snapshot.runtime_enabled) === false || toBool(periodData.runtime_enabled) === false
|
||||
if (runtimeOff && (status === 'betting' || status === 'locked')) {
|
||||
return
|
||||
}
|
||||
if (status === 'finished') {
|
||||
snapshot.is_payout_phase = false
|
||||
snapshot.payout_remaining_seconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
function connectWs(): void {
|
||||
if (!wsReady.value || !wsUrl.value) {
|
||||
return
|
||||
}
|
||||
if (wsReconnectTimer !== null) {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = null
|
||||
}
|
||||
wsManualClosing = false
|
||||
disconnectWs()
|
||||
const socket = new WebSocket(wsUrl.value)
|
||||
wsClient.value = socket
|
||||
socket.onopen = () => {
|
||||
wsConnected.value = true
|
||||
wsReconnectAttempt = 0
|
||||
const topics = wsTopics.value
|
||||
const payload = JSON.stringify({ action: 'subscribe', topics })
|
||||
socket.send(payload)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
handleWsPayload(event.data)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
wsConnected.value = false
|
||||
}
|
||||
socket.onclose = () => {
|
||||
wsConnected.value = false
|
||||
wsClient.value = null
|
||||
if (wsManualClosing) {
|
||||
return
|
||||
}
|
||||
// 断线后:先刷新 wsConfig 拿新的 admin_ws_token(避免握手 token 已过期反复失败),再重连
|
||||
const attempt = wsReconnectAttempt
|
||||
wsReconnectAttempt = Math.min(wsReconnectAttempt + 1, 10)
|
||||
const baseDelay = Math.min(30_000, 1200 * Math.pow(2, attempt))
|
||||
const delay = Math.max(1200, Math.floor(baseDelay + Math.random() * 400))
|
||||
wsReconnectTimer = window.setTimeout(async () => {
|
||||
wsReconnectTimer = null
|
||||
if (wsConnected.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const now = Date.now()
|
||||
// 避免断线风暴下疯狂请求 wsConfig(后台会报错且浏览器控制台刷屏)
|
||||
if (!wsLoading.value && now - wsLastConfigReloadAtMs > 8000) {
|
||||
wsLastConfigReloadAtMs = now
|
||||
await reloadWsConfig()
|
||||
}
|
||||
} catch {
|
||||
/* ignore;下次重连时再试 */
|
||||
}
|
||||
if (!wsConnected.value) {
|
||||
connectWs()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectWs(): void {
|
||||
wsManualClosing = true
|
||||
if (wsReconnectTimer !== null) {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = null
|
||||
}
|
||||
if (wsClient.value) {
|
||||
wsClient.value.close()
|
||||
wsClient.value = null
|
||||
}
|
||||
wsConnected.value = false
|
||||
}
|
||||
|
||||
function formatPicks(v: unknown): string {
|
||||
if (Array.isArray(v)) return JSON.stringify(v)
|
||||
@@ -263,24 +546,247 @@ function formatPicks(v: unknown): string {
|
||||
return '-'
|
||||
}
|
||||
|
||||
function parsePickNumbers(v: unknown): Array<number | string> {
|
||||
if (Array.isArray(v)) {
|
||||
return v
|
||||
.map((item) => {
|
||||
if (typeof item === 'number' || typeof item === 'string') {
|
||||
return item
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is number | string => item !== null)
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim()
|
||||
if (s === '') {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(s)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => {
|
||||
if (typeof item === 'number' || typeof item === 'string') {
|
||||
return item
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is number | string => item !== null)
|
||||
}
|
||||
} catch {
|
||||
return [s]
|
||||
}
|
||||
return [s]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function numberValue(v: unknown): number | null {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return v
|
||||
}
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v)
|
||||
if (!Number.isNaN(n) && Number.isFinite(n)) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function estimatedLossSortValue(v: unknown): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return v
|
||||
}
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v)
|
||||
if (!Number.isNaN(n) && Number.isFinite(n)) {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
const candidateNumbersSorted = computed(() => {
|
||||
const list = Array.isArray(snapshot.candidate_numbers) ? [...snapshot.candidate_numbers] : []
|
||||
list.sort((a, b) => {
|
||||
const sp = candidateSort.value.prop
|
||||
const so = candidateSort.value.order
|
||||
const dir = so === 'descending' ? -1 : 1
|
||||
|
||||
if (sp === 'number') {
|
||||
const na = numberValue(a?.number)
|
||||
const nb = numberValue(b?.number)
|
||||
if (na !== null && nb !== null && na !== nb) {
|
||||
return (na - nb) * dir
|
||||
}
|
||||
} else {
|
||||
const ea = estimatedLossSortValue(a?.estimated_loss)
|
||||
const eb = estimatedLossSortValue(b?.estimated_loss)
|
||||
if (ea !== eb) {
|
||||
return (ea - eb) * dir
|
||||
}
|
||||
}
|
||||
|
||||
const ea2 = estimatedLossSortValue(a?.estimated_loss)
|
||||
const eb2 = estimatedLossSortValue(b?.estimated_loss)
|
||||
if (ea2 !== eb2) {
|
||||
return ea2 - eb2
|
||||
}
|
||||
const na2 = numberValue(a?.number)
|
||||
const nb2 = numberValue(b?.number)
|
||||
if (na2 !== null && nb2 !== null && na2 !== nb2) {
|
||||
return na2 - nb2
|
||||
}
|
||||
return String(a?.number ?? '').localeCompare(String(b?.number ?? ''))
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
function onCandidateSortChange(arg: { prop: string; order: 'ascending' | 'descending' | null }): void {
|
||||
const prop = typeof arg?.prop === 'string' && arg.prop !== '' ? arg.prop : 'estimated_loss'
|
||||
const order = arg?.order === 'descending' || arg?.order === 'ascending' ? arg.order : 'ascending'
|
||||
candidateSort.value = { prop, order }
|
||||
}
|
||||
|
||||
function isScheduledNumber(v: unknown): boolean {
|
||||
const n = numberValue(v)
|
||||
if (n === null) {
|
||||
return false
|
||||
}
|
||||
return snapshot.pending_draw_number === n
|
||||
}
|
||||
|
||||
const displayResultNumber = computed(() => {
|
||||
const fromSnap = numberValue(snapshot.result_number)
|
||||
if (fromSnap !== null) {
|
||||
return fromSnap
|
||||
}
|
||||
const fromRec = numberValue(snapshot.record?.result_number)
|
||||
if (fromRec !== null) {
|
||||
return fromRec
|
||||
}
|
||||
return calcResultNumber.value
|
||||
})
|
||||
|
||||
function updateCalcLossFromResultNumber(): void {
|
||||
const rn = displayResultNumber.value
|
||||
if (rn === null) {
|
||||
return
|
||||
}
|
||||
const row = snapshot.candidate_numbers.find((c) => numberValue(c?.number) === rn)
|
||||
if (row && row.estimated_loss !== undefined && row.estimated_loss !== null) {
|
||||
calcEstimatedLoss.value = String(row.estimated_loss)
|
||||
}
|
||||
}
|
||||
|
||||
function formatWinAmount(row: anyObj): string {
|
||||
const st = Number(row?.bet_status ?? 0)
|
||||
const win = row?.win_amount
|
||||
if (st === 2 || st === 5) {
|
||||
return win !== undefined && win !== null && String(win) !== '' ? String(win) : '0.00'
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
function candidateRowClassName(arg: { row: anyObj }): string {
|
||||
const classes: string[] = []
|
||||
if (isScheduledNumber(arg.row?.number)) {
|
||||
classes.push('is-scheduled-row')
|
||||
}
|
||||
const result = displayResultNumber.value
|
||||
const num = numberValue(arg.row?.number)
|
||||
if (result !== null && num !== null && num === result) {
|
||||
classes.push('is-result-row')
|
||||
}
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
async function onPickSwitchChange(val: boolean, rowNumber: unknown): Promise<void> {
|
||||
const target = numberValue(rowNumber)
|
||||
if (target === null) {
|
||||
return
|
||||
}
|
||||
if (!val) {
|
||||
return
|
||||
}
|
||||
if (snapshot.pending_draw_number === target) {
|
||||
return
|
||||
}
|
||||
pendingSwitchNumber.value = target
|
||||
try {
|
||||
await onDraw(target)
|
||||
} finally {
|
||||
pendingSwitchNumber.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const canVoidPeriod = computed(() => {
|
||||
const r = snapshot.record
|
||||
if (!r) {
|
||||
return false
|
||||
}
|
||||
const s = Number(r.status)
|
||||
return s === 0 || s === 1
|
||||
return s === 0 || s === 1 || s === 3
|
||||
})
|
||||
|
||||
/** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */
|
||||
const asideOperationLocked = computed(() => snapshot.maintenance_ui === true)
|
||||
|
||||
function mergeLiveSnapshot(data: anyObj): void {
|
||||
if (data.record !== undefined) {
|
||||
snapshot.record = data.record
|
||||
function toBool(v: unknown): boolean | null {
|
||||
if (typeof v === 'boolean') {
|
||||
return v
|
||||
}
|
||||
snapshot.bets = data.bets || []
|
||||
snapshot.candidate_numbers = data.candidate_numbers || []
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
if (v === 1) return true
|
||||
if (v === 0) return false
|
||||
return null
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim().toLowerCase()
|
||||
if (s === '1' || s === 'true') return true
|
||||
if (s === '0' || s === 'false') return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mergeLiveSnapshot(data: anyObj): void {
|
||||
const prevPeriodId = snapshot.record?.id != null ? Number(snapshot.record.id) : null
|
||||
let periodChanged = false
|
||||
|
||||
if (data.record !== undefined) {
|
||||
const nextId = data.record?.id != null ? Number(data.record.id) : null
|
||||
periodChanged = prevPeriodId !== null && nextId !== null && prevPeriodId !== nextId
|
||||
const runtimeOff = toBool(data.runtime_enabled) === false || toBool(snapshot.runtime_enabled) === false
|
||||
const serverMaintenance = data.maintenance_ui === true
|
||||
if (runtimeOff && serverMaintenance) {
|
||||
snapshot.record = null
|
||||
snapshot.is_payout_phase = false
|
||||
snapshot.payout_remaining_seconds = 0
|
||||
snapshot.can_calculate = false
|
||||
snapshot.can_draw = false
|
||||
snapshot.can_schedule_draw = false
|
||||
} else {
|
||||
snapshot.record = data.record
|
||||
}
|
||||
}
|
||||
|
||||
const incomingBets = Array.isArray(data.bets) ? data.bets : null
|
||||
if (incomingBets !== null) {
|
||||
if (incomingBets.length > 0 || periodChanged || prevPeriodId === null) {
|
||||
snapshot.bets = incomingBets
|
||||
}
|
||||
}
|
||||
|
||||
const incomingCandidates = Array.isArray(data.candidate_numbers) ? data.candidate_numbers : null
|
||||
if (incomingCandidates !== null) {
|
||||
if (incomingCandidates.length > 0 || periodChanged || prevPeriodId === null) {
|
||||
snapshot.candidate_numbers = incomingCandidates
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.ai_default_number = data.ai_default_number ?? null
|
||||
snapshot.pending_draw_number = typeof data.pending_draw_number === 'number' ? data.pending_draw_number : null
|
||||
snapshot.period_seconds = data.period_seconds ?? 30
|
||||
@@ -294,12 +800,32 @@ function mergeLiveSnapshot(data: anyObj): void {
|
||||
snapshot.can_calculate = !!data.can_calculate
|
||||
snapshot.can_draw = !!data.can_draw
|
||||
snapshot.can_schedule_draw = !!(data.can_schedule_draw || data.can_draw)
|
||||
if (typeof data.runtime_enabled === 'boolean') {
|
||||
snapshot.runtime_enabled = data.runtime_enabled
|
||||
const runtimeEnabled = toBool(data.runtime_enabled)
|
||||
if (pendingRuntimeTarget.value !== null) {
|
||||
// 开关请求进行中:避免 ws 的旧快照覆盖用户刚切换的状态,导致 UI 回弹
|
||||
snapshot.runtime_enabled = pendingRuntimeTarget.value
|
||||
} else if (runtimeEnabled !== null) {
|
||||
snapshot.runtime_enabled = runtimeEnabled
|
||||
}
|
||||
if (typeof data.maintenance_ui === 'boolean') {
|
||||
snapshot.maintenance_ui = data.maintenance_ui
|
||||
if (pendingRuntimeTarget.value !== null) {
|
||||
// 开启时不应显示维护中;关闭后的维护中应由服务端在对局完全结束后下发
|
||||
snapshot.maintenance_ui = pendingRuntimeTarget.value ? false : snapshot.maintenance_ui
|
||||
} else {
|
||||
snapshot.maintenance_ui = data.maintenance_ui
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.result_number === 'number') {
|
||||
snapshot.result_number = data.result_number
|
||||
calcResultNumber.value = data.result_number
|
||||
} else if (periodChanged) {
|
||||
snapshot.result_number = null
|
||||
calcResultNumber.value = null
|
||||
calcEstimatedLoss.value = '0.00'
|
||||
}
|
||||
|
||||
updateCalcLossFromResultNumber()
|
||||
syncServerClock(data.server_time)
|
||||
}
|
||||
|
||||
@@ -343,22 +869,54 @@ const payoutRemainingLive = computed(() => {
|
||||
return snapshot.payout_remaining_seconds ?? 0
|
||||
})
|
||||
|
||||
async function loadSnapshot() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
|
||||
if (res.code === 1 && res.data) {
|
||||
mergeLiveSnapshot(res.data as anyObj)
|
||||
const dmax = res.data.draw_number_max ?? 36
|
||||
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
function isRequestCanceled(err: unknown): boolean {
|
||||
return axios.isCancel(err) || (err instanceof Error && err.name === 'CanceledError')
|
||||
}
|
||||
|
||||
async function loadSnapshot(options?: { force?: boolean }): Promise<void> {
|
||||
if (!options?.force && wsConnected.value && snapshot.record) {
|
||||
return
|
||||
}
|
||||
if (snapshotLoadPromise) {
|
||||
return snapshotLoadPromise
|
||||
}
|
||||
snapshotLoadPromise = (async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/game.Live/snapshot',
|
||||
method: 'get',
|
||||
timeout: 30 * 1000,
|
||||
showCodeMessage: false,
|
||||
showErrorMessage: false,
|
||||
cancelDuplicateRequest: false,
|
||||
})
|
||||
if (res.code === 1 && res.data) {
|
||||
mergeLiveSnapshot(res.data as anyObj)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isRequestCanceled(err)) {
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
snapshotLoadPromise = null
|
||||
}
|
||||
})()
|
||||
return snapshotLoadPromise
|
||||
}
|
||||
|
||||
async function onRuntimeSwitch(val: boolean | string | number): void {
|
||||
const on = val === true || val === 'true' || val === 1
|
||||
const on = toBool(val) === true
|
||||
if (runtimeSwitchLoading.value) {
|
||||
return
|
||||
}
|
||||
// el-switch 为受控组件(model-value 来自 snapshot),接口返回前先乐观更新,避免点击后立刻回弹
|
||||
snapshot.runtime_enabled = on
|
||||
if (on) {
|
||||
snapshot.maintenance_ui = false
|
||||
}
|
||||
pendingRuntimeTarget.value = on
|
||||
runtimeSwitchLoading.value = true
|
||||
try {
|
||||
const res = await createAxios({
|
||||
@@ -370,12 +928,13 @@ async function onRuntimeSwitch(val: boolean | string | number): void {
|
||||
if (res.code === 1 && res.data) {
|
||||
mergeLiveSnapshot(res.data as anyObj)
|
||||
} else {
|
||||
await loadSnapshot()
|
||||
await loadSnapshot({ force: true })
|
||||
}
|
||||
} catch {
|
||||
await loadSnapshot()
|
||||
await loadSnapshot({ force: true })
|
||||
} finally {
|
||||
runtimeSwitchLoading.value = false
|
||||
pendingRuntimeTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,10 +961,19 @@ async function submitVoidPeriod(): Promise<void> {
|
||||
record_id: snapshot.record.id,
|
||||
void_reason: reason,
|
||||
},
|
||||
timeout: 60 * 1000,
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
if (res.code === 1 && res.data) {
|
||||
mergeLiveSnapshot(res.data as anyObj)
|
||||
const refund = (res.data as anyObj).void_refund
|
||||
if (refund && typeof refund === 'object') {
|
||||
const orderCount = Number(refund.order_count ?? 0)
|
||||
const totalAmount = String(refund.total_amount ?? '0.00')
|
||||
if (orderCount > 0) {
|
||||
ElMessage.success(`已退款 ${orderCount} 笔注单,合计 ${totalAmount}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
voidDialogVisible.value = false
|
||||
} finally {
|
||||
@@ -422,7 +990,7 @@ async function onCalculate() {
|
||||
method: 'post',
|
||||
data: {
|
||||
record_id: snapshot.record.id,
|
||||
manual_number: manualNumber.value,
|
||||
manual_number: numberValue(snapshot.pending_draw_number) ?? numberValue(candidateNumbersSorted.value[0]?.number) ?? 1,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
@@ -437,7 +1005,7 @@ async function onCalculate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDraw() {
|
||||
async function onDrawWithNumber(targetNumber: number) {
|
||||
if (!snapshot.record) return
|
||||
drawLoading.value = true
|
||||
try {
|
||||
@@ -446,16 +1014,29 @@ async function onDraw() {
|
||||
method: 'post',
|
||||
data: {
|
||||
record_id: snapshot.record.id,
|
||||
manual_number: manualNumber.value,
|
||||
manual_number: targetNumber,
|
||||
},
|
||||
showSuccessMessage: true,
|
||||
})
|
||||
await loadSnapshot()
|
||||
await loadSnapshot({ force: true })
|
||||
} finally {
|
||||
drawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDraw(targetNumber?: number) {
|
||||
if (targetNumber !== undefined) {
|
||||
return onDrawWithNumber(targetNumber)
|
||||
}
|
||||
const pending = numberValue(snapshot.pending_draw_number)
|
||||
if (pending !== null) {
|
||||
return onDrawWithNumber(pending)
|
||||
}
|
||||
const first = candidateNumbersSorted.value[0]
|
||||
const fallback = numberValue(first?.number) ?? 1
|
||||
return onDrawWithNumber(fallback)
|
||||
}
|
||||
|
||||
const countdownParts = computed(() => {
|
||||
const bet = snapshot.bet_remaining_seconds ?? 0
|
||||
const draw = snapshot.remaining_seconds ?? 0
|
||||
@@ -467,37 +1048,38 @@ const countdownParts = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
updateIsMobile()
|
||||
window.addEventListener('resize', updateIsMobile)
|
||||
clockTimer = window.setInterval(() => {
|
||||
clockTick.value++
|
||||
}, 1000)
|
||||
await loadSnapshot()
|
||||
startPolling()
|
||||
fallbackPollTimer = window.setInterval(() => {
|
||||
if (!wsConnected.value) {
|
||||
void loadSnapshot({ force: true })
|
||||
}
|
||||
}, 15000)
|
||||
try {
|
||||
await loadSnapshot({ force: true })
|
||||
} catch {
|
||||
ElMessage.warning(t('game.live.snapshot_load_failed'))
|
||||
}
|
||||
await reloadWsConfig()
|
||||
connectWs()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
window.removeEventListener('resize', updateIsMobile)
|
||||
disconnectWs()
|
||||
if (clockTimer !== null) {
|
||||
window.clearInterval(clockTimer)
|
||||
clockTimer = null
|
||||
}
|
||||
if (fallbackPollTimer !== null) {
|
||||
window.clearInterval(fallbackPollTimer)
|
||||
fallbackPollTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer !== null) {
|
||||
return
|
||||
}
|
||||
pollTimer = window.setInterval(() => {
|
||||
void loadSnapshot()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -533,6 +1115,17 @@ function stopPolling() {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.live-top-toolbar__actions {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-top-toolbar__btn-void {
|
||||
border-color: var(--el-color-danger-light-7);
|
||||
}
|
||||
|
||||
.live-control-card {
|
||||
:deep(.el-card__body) {
|
||||
padding-top: 8px;
|
||||
@@ -540,14 +1133,10 @@ function stopPolling() {
|
||||
}
|
||||
|
||||
.live-control-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px 24px;
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.live-control-main {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -695,76 +1284,53 @@ function stopPolling() {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.live-control-aside {
|
||||
flex: 0 0 240px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
.number-tag {
|
||||
min-width: 44px;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pick-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&.is-locked {
|
||||
opacity: 0.9;
|
||||
filter: grayscale(0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.live-control-aside {
|
||||
flex: 1 1 100%;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.aside-void-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aside-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aside-field__label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
.pick-tags__item {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aside-field__input {
|
||||
width: 100%;
|
||||
.bet-user {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aside-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
.candidate-table :deep(.is-scheduled-row td) {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.aside-btns .el-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
.candidate-table :deep(.is-result-row td) {
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.aside-btns .el-button :deep(span) {
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
.candidate-table :deep(.is-result-row .number-tag) {
|
||||
border-color: var(--el-color-success);
|
||||
color: var(--el-color-success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.live-tables-row .el-col {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pick-tags {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -8,23 +8,72 @@
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
|
||||
<el-dialog class="ba-operate-dialog" :title="t('game.playRecord.review_title')" :model-value="reviewDialog.visible" width="620px" :close-on-click-modal="false" @close="closeReviewDialog">
|
||||
<div v-loading="reviewDialog.loading">
|
||||
<el-descriptions :column="2" border class="mb-12">
|
||||
<el-descriptions-item :label="t('game.playRecord.id')">{{ reviewDialog.row?.id ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.gameRecord_period_no')">{{ reviewDialog.row?.gameRecord?.period_no ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.user_username')">{{ reviewDialog.row?.user?.username ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.channel_name')">{{ reviewDialog.row?.channel?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.pick_numbers')">{{ formatPickNumbers({}, {}, reviewDialog.row?.pick_numbers) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.total_amount')">{{ formatAmount({}, {}, reviewDialog.row?.total_amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.win_amount')">{{ formatAmount({}, {}, reviewDialog.row?.win_amount) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.playRecord.status')">
|
||||
<el-tag v-if="reviewDialog.row?.status !== null && reviewDialog.row?.status !== undefined && reviewDialog.row?.status !== ''" :type="playRecordStatusTagType(reviewDialog.row.status)" effect="dark" size="small">
|
||||
{{ t(`game.playRecord.status ${reviewDialog.row.status}`) }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item :label="t('game.playRecord.review_remark')">
|
||||
<el-input
|
||||
v-model="reviewDialog.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="t('game.playRecord.review_remark_placeholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closeReviewDialog">{{ t('Cancel') }}</el-button>
|
||||
<el-button v-if="canRejectJackpot" type="danger" :loading="reviewDialog.loading" @click="submitReject">{{ t('game.playRecord.review_reject') }}</el-button>
|
||||
<el-button v-if="canApproveJackpot" type="primary" :loading="reviewDialog.loading" @click="submitApprove">{{ t('game.playRecord.review_approve') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { auth } from '/@/utils/common'
|
||||
|
||||
defineOptions({
|
||||
name: 'game/playRecord',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const canApproveJackpot = auth('approveJackpot')
|
||||
const canRejectJackpot = auth('rejectJackpot')
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons([])
|
||||
const reviewDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
id: 0,
|
||||
remark: '',
|
||||
row: null as any,
|
||||
})
|
||||
|
||||
function formatPickNumbers(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined) {
|
||||
@@ -40,6 +89,18 @@ function formatPickNumbers(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function playRecordStatusTagType(status: unknown): 'primary' | 'success' | 'info' | 'warning' | 'danger' {
|
||||
const key = status === null || status === undefined ? '' : String(status)
|
||||
const map: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
|
||||
'1': 'info',
|
||||
'2': 'success',
|
||||
'3': 'danger',
|
||||
'4': 'warning',
|
||||
'5': 'primary',
|
||||
}
|
||||
return map[key] ?? 'info'
|
||||
}
|
||||
|
||||
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
return '-'
|
||||
@@ -86,6 +147,14 @@ const baTable = new baTableClass(
|
||||
'5': t('game.playRecord.gameRecord_status 5'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('game.playRecord.result_number'),
|
||||
prop: 'gameRecord.result_number',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
operator: 'eq',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{ label: t('game.playRecord.user_id'), prop: 'user_id', align: 'center', show: false, width: 90, operator: 'RANGE' },
|
||||
{
|
||||
label: t('game.playRecord.user_username'),
|
||||
@@ -119,20 +188,21 @@ const baTable = new baTableClass(
|
||||
replaceValue: { '0': t('game.playRecord.is_auto 0'), '1': t('game.playRecord.is_auto 1') },
|
||||
},
|
||||
{ label: t('game.playRecord.win_amount'), prop: 'win_amount', align: 'center', minWidth: 110, operator: 'RANGE', formatter: formatAmount },
|
||||
{ label: t('game.playRecord.jackpot_extra_amount'), prop: 'jackpot_extra_amount', align: 'center', minWidth: 120, operator: 'RANGE', formatter: formatAmount },
|
||||
{
|
||||
label: t('game.playRecord.status'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
minWidth: 118,
|
||||
operator: 'eq',
|
||||
render: 'tag',
|
||||
effect: 'dark',
|
||||
custom: { '1': 'warning', '2': 'success', '3': 'danger' },
|
||||
custom: { '1': 'info', '2': 'success', '3': 'danger', '4': 'warning', '5': 'primary' },
|
||||
replaceValue: {
|
||||
'1': t('game.playRecord.status 1'),
|
||||
'2': t('game.playRecord.status 2'),
|
||||
'3': t('game.playRecord.status 3'),
|
||||
'4': t('game.playRecord.status 4'),
|
||||
'5': t('game.playRecord.status 5'),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -166,12 +236,113 @@ const baTable = new baTableClass(
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const openReviewDialog = (row: any) => {
|
||||
const idRaw = row?.id
|
||||
const id = typeof idRaw === 'number' ? idRaw : Number(idRaw)
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
ElMessage.error(t('Parameter error'))
|
||||
return
|
||||
}
|
||||
reviewDialog.visible = true
|
||||
reviewDialog.id = id
|
||||
reviewDialog.remark = ''
|
||||
reviewDialog.row = row ?? null
|
||||
}
|
||||
|
||||
const closeReviewDialog = (force = false) => {
|
||||
if (reviewDialog.loading && !force) return
|
||||
reviewDialog.visible = false
|
||||
reviewDialog.id = 0
|
||||
reviewDialog.remark = ''
|
||||
reviewDialog.row = null
|
||||
}
|
||||
|
||||
const submitApprove = async () => {
|
||||
if (!reviewDialog.id) return
|
||||
reviewDialog.loading = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/game.PlayRecord/approveJackpot',
|
||||
method: 'post',
|
||||
data: { id: reviewDialog.id, remark: reviewDialog.remark },
|
||||
},
|
||||
{ showSuccessMessage: false }
|
||||
)
|
||||
ElMessage.success(t('game.playRecord.review_approve_success'))
|
||||
if (reviewDialog.row) {
|
||||
reviewDialog.row.status = 2
|
||||
reviewDialog.row.can_jackpot_approve = 0
|
||||
}
|
||||
closeReviewDialog(true)
|
||||
baTable.onTableHeaderAction('refresh', { event: 'review-approve' })
|
||||
} catch (error: any) {
|
||||
const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('Operation failed')
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
reviewDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
if (!reviewDialog.id) return
|
||||
const remark = String(reviewDialog.remark ?? '').trim()
|
||||
if (!remark) {
|
||||
ElMessage.error(t('game.playRecord.review_remark_required'))
|
||||
return
|
||||
}
|
||||
reviewDialog.loading = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/game.PlayRecord/rejectJackpot',
|
||||
method: 'post',
|
||||
data: { id: reviewDialog.id, remark },
|
||||
},
|
||||
{ showSuccessMessage: false }
|
||||
)
|
||||
ElMessage.success(t('game.playRecord.review_reject_success'))
|
||||
if (reviewDialog.row) {
|
||||
reviewDialog.row.status = 4
|
||||
reviewDialog.row.can_jackpot_approve = 0
|
||||
}
|
||||
closeReviewDialog(true)
|
||||
baTable.onTableHeaderAction('refresh', { event: 'review-reject' })
|
||||
} catch (error: any) {
|
||||
const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('Operation failed')
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
reviewDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
optButtons.unshift({
|
||||
render: 'tipButton',
|
||||
name: 'approve-jackpot',
|
||||
title: 'game.playRecord.review_open',
|
||||
text: '',
|
||||
type: 'warning',
|
||||
icon: 'fa fa-check',
|
||||
disabledTip: true,
|
||||
display: (row: any) => {
|
||||
if (!canApproveJackpot && !canRejectJackpot) {
|
||||
return false
|
||||
}
|
||||
const v = row?.can_jackpot_approve
|
||||
return String(v ?? '') === '1'
|
||||
},
|
||||
click: (row: any) => {
|
||||
openReviewDialog(row)
|
||||
},
|
||||
})
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="default-main ba-table-box">
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
>
|
||||
</TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
|
||||
@@ -16,13 +17,14 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
import { routePush } from '/@/utils/router'
|
||||
defineOptions({
|
||||
name: 'game/record',
|
||||
})
|
||||
@@ -32,12 +34,35 @@ const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit'])
|
||||
|
||||
const formatCoin = (_row: any, _column: any, cellValue: number | string | null | undefined) => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') return '—'
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
|
||||
const n = Number(cellValue)
|
||||
if (Number.isNaN(n)) return '—'
|
||||
if (Number.isNaN(n)) return '-'
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
optButtons.unshift({
|
||||
render: 'tipButton',
|
||||
name: 'view-play-record',
|
||||
title: 'game.record.view_play_records',
|
||||
text: '',
|
||||
type: 'success',
|
||||
icon: 'fa fa-list',
|
||||
class: 'table-row-view-play-record',
|
||||
disabledTip: false,
|
||||
click: (row: any) => {
|
||||
const pidRaw = row?.id
|
||||
const pid = typeof pidRaw === 'number' ? pidRaw : Number(pidRaw)
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
ElMessage.error(t('Parameter error'))
|
||||
return
|
||||
}
|
||||
void routePush({
|
||||
path: '/admin/game/playRecord',
|
||||
query: { period_id: String(pid) },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/game.Record/'),
|
||||
{
|
||||
@@ -45,8 +70,25 @@ const baTable = new baTableClass(
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('game.record.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('game.record.period_no'), prop: 'period_no', align: 'center', minWidth: 180, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
|
||||
{ label: t('game.record.period_start_at'), prop: 'period_start_at', align: 'center', width: 170, render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{
|
||||
label: t('game.record.period_no'),
|
||||
prop: 'period_no',
|
||||
align: 'center',
|
||||
minWidth: 180,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('game.record.period_start_at'),
|
||||
prop: 'period_start_at',
|
||||
align: 'center',
|
||||
width: 170,
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('game.record.status'),
|
||||
prop: 'status',
|
||||
@@ -55,8 +97,16 @@ const baTable = new baTableClass(
|
||||
operator: 'eq',
|
||||
render: 'tag',
|
||||
effect: 'dark',
|
||||
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger' },
|
||||
replaceValue: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') },
|
||||
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger', '6': 'danger' },
|
||||
replaceValue: {
|
||||
'0': t('game.record.status 0'),
|
||||
'1': t('game.record.status 1'),
|
||||
'2': t('game.record.status 2'),
|
||||
'3': t('game.record.status 3'),
|
||||
'4': t('game.record.status 4'),
|
||||
'5': t('game.record.status 5'),
|
||||
'6': t('game.record.status 6'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('game.record.draw_mode'),
|
||||
@@ -68,7 +118,6 @@ const baTable = new baTableClass(
|
||||
custom: { '0': 'info', '1': 'warning' },
|
||||
replaceValue: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') },
|
||||
},
|
||||
{ label: t('game.record.preset_number'), prop: 'preset_number', align: 'center', width: 100, operator: 'RANGE' },
|
||||
{ label: t('game.record.result_number'), prop: 'result_number', align: 'center', width: 100, operator: 'RANGE' },
|
||||
{
|
||||
label: t('game.record.platform_profit_amount'),
|
||||
@@ -87,9 +136,37 @@ const baTable = new baTableClass(
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{ label: t('game.record.void_reason'), prop: 'void_reason', align: 'center', minWidth: 140, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', showOverflowTooltip: true },
|
||||
{ label: t('game.record.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{ label: t('game.record.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{
|
||||
label: t('game.record.void_reason'),
|
||||
prop: 'void_reason',
|
||||
align: 'center',
|
||||
minWidth: 140,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('game.record.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('game.record.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
prop="draw_mode"
|
||||
:input-attr="{ content: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') } }"
|
||||
/>
|
||||
<FormItem :label="t('game.record.preset_number')" type="number" v-model="baTable.form.items!.preset_number" prop="preset_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
|
||||
<FormItem :label="t('game.record.result_number')" type="number" v-model="baTable.form.items!.result_number" prop="result_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
|
||||
<FormItem :label="t('game.record.platform_profit_amount')" type="string" v-model="baTable.form.items!.platform_profit_amount" prop="platform_profit_amount" />
|
||||
<FormItem :label="t('game.record.winner_user_count')" type="number" v-model="baTable.form.items!.winner_user_count" prop="winner_user_count" :input-attr="{ step: 1, min: 0 }" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</el-alert>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="loading" @click="onSave">
|
||||
{{ t('game.ziHuaDictionary.btn_save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -39,6 +39,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const canSave = auth('save')
|
||||
|
||||
type Item = { no: number; name: string; category: string }
|
||||
|
||||
|
||||
@@ -3,21 +3,18 @@
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('operation.userNoticeRead.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
<PopupForm />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
@@ -28,14 +25,12 @@ defineOptions({
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/operation.UserNoticeRead/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('operation.userNoticeRead.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('operation.userNoticeRead.user_id'), prop: 'user_id', align: 'center', width: 100, operator: 'RANGE', show: false },
|
||||
{
|
||||
@@ -93,11 +88,7 @@ const baTable = new baTableClass(
|
||||
sortable: 'custom',
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultItems: { confirmed: 0 },
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
|
||||
<template #header>
|
||||
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
|
||||
</template>
|
||||
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
|
||||
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
|
||||
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
|
||||
<FormItem
|
||||
:label="t('operation.userNoticeRead.user_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.user_id"
|
||||
prop="user_id"
|
||||
:key="'uid-' + (baTable.form.items!.id ?? 'new')"
|
||||
:input-attr="{
|
||||
pk: 'id',
|
||||
field: 'username',
|
||||
remoteUrl: '/admin/user.User/index',
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('operation.userNoticeRead.notice_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.notice_id"
|
||||
prop="notice_id"
|
||||
:key="'nid-' + (baTable.form.items!.id ?? 'new')"
|
||||
:input-attr="{
|
||||
pk: 'id',
|
||||
field: 'title',
|
||||
remoteUrl: '/admin/operation.OperationNotice/index',
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<FormItem :label="t('operation.userNoticeRead.read_at')" type="datetime" v-model="baTable.form.items!.read_at" prop="read_at" />
|
||||
<FormItem
|
||||
:label="t('operation.userNoticeRead.confirmed')"
|
||||
type="radio"
|
||||
v-model="baTable.form.items!.confirmed"
|
||||
prop="confirmed"
|
||||
:input-attr="{ content: { '0': t('operation.userNoticeRead.confirmed 0'), '1': t('operation.userNoticeRead.confirmed 1') } }"
|
||||
/>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<template #footer>
|
||||
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
|
||||
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
|
||||
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { inject, reactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
|
||||
const config = useConfig()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
user_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.user_id') }) }],
|
||||
notice_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.notice_id') }) }],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -38,18 +38,20 @@
|
||||
</el-select>
|
||||
</div>
|
||||
<Table ref="tableRef"></Table>
|
||||
<PopupForm />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { auth } from '/@/utils/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import PopupForm from './popupForm.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'order/adminWithdrawOrder',
|
||||
@@ -69,50 +71,29 @@ const stats = reactive({
|
||||
approved_amount: '0.00',
|
||||
})
|
||||
|
||||
const onReview = async (row: anyObj, action: 'approve' | 'reject') => {
|
||||
const needReason = action === 'reject'
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
needReason ? t('order.adminWithdrawOrder.reject_reason_required') : t('order.adminWithdrawOrder.review_remark_optional'),
|
||||
action === 'approve' ? t('order.adminWithdrawOrder.review_approve_title') : t('order.adminWithdrawOrder.review_reject_title'),
|
||||
{
|
||||
confirmButtonText: t('Confirm'),
|
||||
cancelButtonText: t('Cancel'),
|
||||
inputPlaceholder: needReason ? t('order.adminWithdrawOrder.reject_reason_required') : t('order.adminWithdrawOrder.review_remark_optional'),
|
||||
inputPattern: needReason ? /.+/ : undefined,
|
||||
inputErrorMessage: needReason ? t('order.adminWithdrawOrder.reject_reason_required') : '',
|
||||
})
|
||||
await createAxios(
|
||||
{
|
||||
url: `/admin/order.AdminWithdrawOrder/${action}`,
|
||||
method: 'post',
|
||||
data: { id: row.id, remark: value || '' },
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
await loadStats()
|
||||
baTable.getData()
|
||||
}
|
||||
|
||||
const optButtons: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'approve',
|
||||
title: 'order.adminWithdrawOrder.review_btn_approve',
|
||||
name: 'review',
|
||||
title: 'order.adminWithdrawOrder.review_btn',
|
||||
text: '',
|
||||
type: 'success',
|
||||
icon: 'el-icon-Check',
|
||||
display: (row: TableRow) => Number(row.status) === 0 && Number((row as anyObj).can_review ?? 0) === 1,
|
||||
click: (row: TableRow) => void onReview(row as anyObj, 'approve'),
|
||||
},
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'reject',
|
||||
title: 'order.adminWithdrawOrder.review_btn_reject',
|
||||
text: '',
|
||||
type: 'danger',
|
||||
icon: 'el-icon-Close',
|
||||
display: (row: TableRow) => Number(row.status) === 0 && Number((row as anyObj).can_review ?? 0) === 1,
|
||||
click: (row: TableRow) => void onReview(row as anyObj, 'reject'),
|
||||
type: 'warning',
|
||||
icon: 'fa fa-check-square-o',
|
||||
display: (row: TableRow) => {
|
||||
if (Number(row.status) !== 0) {
|
||||
return false
|
||||
}
|
||||
if (auth('review')) {
|
||||
return true
|
||||
}
|
||||
return Number((row as anyObj).can_review ?? 0) === 1
|
||||
},
|
||||
click: (row: TableRow) => {
|
||||
baTable.form.operate = 'Review'
|
||||
baTable.form.operateIds = [String(row[baTable.table.pk!])]
|
||||
baTable.form.items = { ...(row as anyObj) }
|
||||
baTable.form.loading = false
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -179,7 +160,9 @@ const baTable = new baTableClass(
|
||||
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
},
|
||||
{}
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
const onStatusFilterChange = () => {
|
||||
@@ -274,4 +257,3 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
306
web/src/views/backend/order/adminWithdrawOrder/popupForm.vue
Normal file
306
web/src/views/backend/order/adminWithdrawOrder/popupForm.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="ba-operate-dialog admin-withdraw-review-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="isOpen"
|
||||
width="560px"
|
||||
@close="onDialogClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
|
||||
{{ step === 'reject' ? t('order.adminWithdrawOrder.review_reject_title') : t('order.adminWithdrawOrder.review_title') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
|
||||
<div
|
||||
class="ba-operate-form ba-edit-form"
|
||||
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'"
|
||||
>
|
||||
<el-form
|
||||
v-if="!loading && step === 'review'"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.order_no')">
|
||||
<el-input v-model="form.order_no" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.admin_username')">
|
||||
<el-input :model-value="form.admin_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.channel_name')">
|
||||
<el-input :model-value="form.channel_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.amount')">
|
||||
<el-input :model-value="form.amount_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.actual_amount')">
|
||||
<el-input :model-value="form.actual_amount_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.receive_type')">
|
||||
<el-input :model-value="form.receive_type_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.receive_account')">
|
||||
<el-input :model-value="form.receive_account" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.remark')">
|
||||
<el-input :model-value="form.remark" type="textarea" :rows="2" readonly />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form
|
||||
v-if="!loading && step === 'reject'"
|
||||
ref="rejectFormRef"
|
||||
:model="rejectForm"
|
||||
:rules="rejectRules"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<el-alert
|
||||
class="review-reject-hint"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="t('order.adminWithdrawOrder.review_reject_tip')"
|
||||
/>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.order_no')">
|
||||
<el-input v-model="form.order_no" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.adminWithdrawOrder.remark')" prop="remark">
|
||||
<el-input
|
||||
v-model="rejectForm.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:placeholder="t('order.adminWithdrawOrder.reject_reason_required')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="review-footer">
|
||||
<template v-if="step === 'review'">
|
||||
<el-button @click="onDialogClose">{{ t('Cancel') }}</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="gotoReject">{{ t('order.adminWithdrawOrder.review_btn_reject') }}</el-button>
|
||||
<el-button type="primary" v-blur :loading="submitting" @click="submitApprove">{{ t('order.adminWithdrawOrder.review_btn_approve') }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="backToReview">{{ t('order.adminWithdrawOrder.review_btn_back') }}</el-button>
|
||||
<el-button type="danger" v-blur :loading="submitting" @click="submitReject">{{ t('order.adminWithdrawOrder.review_btn_confirm_reject') }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormItemRule } from 'element-plus'
|
||||
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
|
||||
const config = useConfig()
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rejectFormRef = useTemplateRef<FormInstance>('rejectFormRef')
|
||||
|
||||
type Step = 'review' | 'reject'
|
||||
const step = ref<Step>('review')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
order_no: '',
|
||||
admin_text: '-',
|
||||
channel_text: '-',
|
||||
amount_text: '0.00',
|
||||
actual_amount_text: '0.00',
|
||||
receive_type_text: '-',
|
||||
receive_account: '-',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const rejectForm = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const receiveTypeLabels: Record<string, string> = {
|
||||
bank: 'order.adminWithdrawOrder.receive_type_bank',
|
||||
ewallet: 'order.adminWithdrawOrder.receive_type_ewallet',
|
||||
crypto: 'order.adminWithdrawOrder.receive_type_crypto',
|
||||
}
|
||||
|
||||
const isOpen = computed(() => ['Review'].includes(baTable.form.operate ?? ''))
|
||||
|
||||
watch(isOpen, (visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
rejectForm.remark = ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
|
||||
({ visible, loadingState }) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
loading.value = loadingState === true
|
||||
if (loadingState) {
|
||||
return
|
||||
}
|
||||
hydrate()
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const hydrate = () => {
|
||||
const row = baTable.form.items as Record<string, unknown> | undefined
|
||||
if (!row || !row['id']) {
|
||||
return
|
||||
}
|
||||
form.id = Number(row['id'] ?? 0)
|
||||
form.order_no = String(row['order_no'] ?? '')
|
||||
form.amount_text = formatAmount(row['amount'])
|
||||
form.actual_amount_text = formatAmount(row['actual_amount'])
|
||||
form.receive_account = String(row['receive_account'] ?? '-')
|
||||
form.remark = String(row['remark'] ?? '')
|
||||
const receiveType = String(row['receive_type'] ?? '')
|
||||
const receiveKey = receiveTypeLabels[receiveType]
|
||||
form.receive_type_text = receiveKey ? t(receiveKey) : receiveType || '-'
|
||||
form.admin_text = resolveRelationText(row, 'admin', row['admin_id'])
|
||||
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
|
||||
}
|
||||
|
||||
const rejectRules: Record<string, FormItemRule[]> = {
|
||||
remark: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_r, value, cb) => {
|
||||
const text = typeof value === 'string' ? value.trim() : ''
|
||||
if (text === '') {
|
||||
cb(new Error(t('order.adminWithdrawOrder.reject_reason_required')))
|
||||
return
|
||||
}
|
||||
cb()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const onDialogClose = () => {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
baTable.toggleForm()
|
||||
}
|
||||
|
||||
const gotoReject = () => {
|
||||
step.value = 'reject'
|
||||
rejectForm.remark = ''
|
||||
}
|
||||
|
||||
const backToReview = () => {
|
||||
step.value = 'review'
|
||||
}
|
||||
|
||||
const submitReview = async (action: 'approve' | 'reject', remark: string) => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/order.AdminWithdrawOrder/review',
|
||||
method: 'post',
|
||||
data: { id: form.id, action, remark },
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
baTable.toggleForm()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitApprove = async () => {
|
||||
await submitReview('approve', '')
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
const formEl = rejectFormRef.value
|
||||
if (!formEl) {
|
||||
return
|
||||
}
|
||||
const valid = await formEl.validate().catch(() => false)
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
await submitReview('reject', rejectForm.remark.trim())
|
||||
}
|
||||
|
||||
function formatAmount(raw: unknown): string {
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
return '0.00'
|
||||
}
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) {
|
||||
return String(raw)
|
||||
}
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
function resolveRelationText(row: Record<string, unknown>, relationKey: string, fallbackId: unknown): string {
|
||||
const rel = row[relationKey]
|
||||
if (rel && typeof rel === 'object') {
|
||||
const r = rel as Record<string, unknown>
|
||||
const name = r['username'] ?? r['name']
|
||||
if (typeof name === 'string' && name !== '') {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if (fallbackId === null || fallbackId === undefined || fallbackId === '') {
|
||||
return '-'
|
||||
}
|
||||
return 'ID: ' + String(fallbackId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-withdraw-review-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input),
|
||||
:deep(.el-textarea) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-reject-hint {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,22 @@ defineOptions({
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit'])
|
||||
const depositEditBtn = optButtons.find((b) => b.name === 'edit')
|
||||
if (depositEditBtn) {
|
||||
depositEditBtn.display = (row: TableRow) => Number(row.status) !== 3
|
||||
}
|
||||
optButtons.push({
|
||||
render: 'tipButton',
|
||||
name: 'depositReview',
|
||||
title: 'order.depositOrder.review_btn',
|
||||
text: '',
|
||||
type: 'warning',
|
||||
icon: 'fa fa-check-square-o',
|
||||
display: (row: TableRow) => Number(row.status) === 3,
|
||||
click: (row: TableRow) => {
|
||||
baTable.toggleForm('Edit', [row[baTable.table.pk!]])
|
||||
},
|
||||
})
|
||||
|
||||
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
@@ -186,7 +202,7 @@ const baTable = new baTableClass(
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: 90,
|
||||
width: 120,
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="ba-operate-dialog deposit-detail-dialog"
|
||||
class="ba-operate-dialog deposit-review-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="isOpen"
|
||||
width="640px"
|
||||
@@ -8,7 +8,13 @@
|
||||
>
|
||||
<template #header>
|
||||
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
|
||||
{{ t('order.depositOrder.detail_title') }}
|
||||
{{
|
||||
step === 'reject'
|
||||
? t('order.depositOrder.review_reject_title')
|
||||
: isPendingReview
|
||||
? t('order.depositOrder.review_title')
|
||||
: t('order.depositOrder.detail_title')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +24,7 @@
|
||||
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'"
|
||||
>
|
||||
<el-form
|
||||
v-if="!loading"
|
||||
v-if="!loading && step === 'review'"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
@@ -38,7 +44,6 @@
|
||||
<el-form-item :label="t('order.depositOrder.status')">
|
||||
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.depositOrder.amount')">
|
||||
<el-input :model-value="amountText" readonly />
|
||||
</el-form-item>
|
||||
@@ -48,7 +53,6 @@
|
||||
<el-form-item :label="t('order.depositOrder.total_credit')">
|
||||
<el-input :model-value="totalCreditText" readonly />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('order.depositOrder.pay_channel')">
|
||||
<el-input :model-value="form.pay_channel || '-'" readonly />
|
||||
</el-form-item>
|
||||
@@ -58,6 +62,15 @@
|
||||
<el-form-item :label="t('order.depositOrder.deposit_tier_id')">
|
||||
<el-input :model-value="form.deposit_tier_id || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.reject_reason" :label="t('order.depositOrder.reject_reason')">
|
||||
<el-input :model-value="form.reject_reason" type="textarea" :rows="2" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isPendingReview" :label="t('order.depositOrder.review_admin_username')">
|
||||
<el-input :model-value="form.review_admin_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isPendingReview" :label="t('order.depositOrder.review_time')">
|
||||
<el-input :model-value="form.review_time_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.remark')">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
|
||||
</el-form-item>
|
||||
@@ -65,20 +78,67 @@
|
||||
<el-input :model-value="form.create_time_text" readonly />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form
|
||||
v-if="!loading && step === 'reject'"
|
||||
ref="rejectFormRef"
|
||||
:model="rejectForm"
|
||||
:rules="rejectRules"
|
||||
:label-position="config.layout.shrink ? 'top' : 'right'"
|
||||
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
|
||||
@submit.prevent=""
|
||||
>
|
||||
<el-alert
|
||||
class="review-reject-hint"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="t('order.depositOrder.review_reject_tip')"
|
||||
/>
|
||||
<el-form-item :label="t('order.depositOrder.order_no')">
|
||||
<el-input :model-value="form.order_no" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.total_credit')">
|
||||
<el-input :model-value="totalCreditText" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.depositOrder.reject_reason')" prop="remark">
|
||||
<el-input
|
||||
v-model="rejectForm.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
:placeholder="t('order.depositOrder.review_reject_placeholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="detail-footer">
|
||||
<el-button type="primary" v-blur @click="onDialogClose">{{ t('order.depositOrder.close_btn') }}</el-button>
|
||||
<div class="review-footer">
|
||||
<template v-if="step === 'review'">
|
||||
<el-button @click="onDialogClose">{{ isPendingReview ? t('Cancel') : t('order.depositOrder.close_btn') }}</el-button>
|
||||
<template v-if="isPendingReview">
|
||||
<el-button type="danger" :loading="submitting" @click="gotoReject">{{ t('order.depositOrder.review_btn_reject') }}</el-button>
|
||||
<el-button type="primary" v-blur :loading="submitting" @click="submitApprove">{{ t('order.depositOrder.review_btn_approve') }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="backToReview">{{ t('order.depositOrder.review_btn_back') }}</el-button>
|
||||
<el-button type="danger" v-blur :loading="submitting" @click="submitReject">{{ t('order.depositOrder.review_btn_confirm_reject') }}</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormItemRule } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
|
||||
@@ -86,7 +146,12 @@ const config = useConfig()
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rejectFormRef = useTemplateRef<FormInstance>('rejectFormRef')
|
||||
|
||||
type Step = 'review' | 'reject'
|
||||
const step = ref<Step>('review')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
@@ -98,13 +163,29 @@ const form = reactive({
|
||||
pay_time_text: '-',
|
||||
deposit_tier_id: '',
|
||||
remark: '',
|
||||
reject_reason: '',
|
||||
create_time_text: '-',
|
||||
review_time_text: '-',
|
||||
review_admin_text: '-',
|
||||
amount: 0,
|
||||
bonus_amount: 0,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
const rejectForm = reactive({
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
|
||||
const isPendingReview = computed(() => form.status === 3)
|
||||
|
||||
watch(isOpen, (visible) => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
rejectForm.remark = ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
|
||||
@@ -128,13 +209,16 @@ const hydrate = () => {
|
||||
form.pay_channel = String(row['pay_channel'] ?? '')
|
||||
form.deposit_tier_id = String(row['deposit_tier_id'] ?? '')
|
||||
form.remark = String(row['remark'] ?? '')
|
||||
form.reject_reason = String(row['reject_reason'] ?? '')
|
||||
form.amount = parseNumber(row['amount'])
|
||||
form.bonus_amount = parseNumber(row['bonus_amount'])
|
||||
form.status = Number(row['status'] ?? 0)
|
||||
form.create_time_text = formatTime(row['create_time'])
|
||||
form.pay_time_text = formatTime(row['pay_time'])
|
||||
form.review_time_text = formatTime(row['review_time'])
|
||||
form.user_text = resolveRelationText(row, 'user', row['user_id'])
|
||||
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
|
||||
form.review_admin_text = resolveRelationText(row, 'reviewAdmin', row['review_admin_id'])
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => t('order.depositOrder.status ' + form.status))
|
||||
@@ -155,10 +239,91 @@ const amountText = computed(() => formatAmount(form.amount))
|
||||
const bonusText = computed(() => formatAmount(form.bonus_amount))
|
||||
const totalCreditText = computed(() => formatAmount(Number((form.amount + form.bonus_amount).toFixed(2))))
|
||||
|
||||
const rejectRules: Record<string, FormItemRule[]> = {
|
||||
remark: [
|
||||
{
|
||||
required: true,
|
||||
validator: (_r, value, cb) => {
|
||||
const text = typeof value === 'string' ? value.trim() : ''
|
||||
if (text === '') {
|
||||
cb(new Error(t('order.depositOrder.reject_reason_required')))
|
||||
return
|
||||
}
|
||||
cb()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const onDialogClose = () => {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
step.value = 'review'
|
||||
baTable.toggleForm()
|
||||
}
|
||||
|
||||
const gotoReject = () => {
|
||||
step.value = 'reject'
|
||||
rejectForm.remark = ''
|
||||
}
|
||||
|
||||
const backToReview = () => {
|
||||
step.value = 'review'
|
||||
}
|
||||
|
||||
const submitApprove = async () => {
|
||||
if (!isPendingReview.value) {
|
||||
ElMessage.warning(t('order.depositOrder.already_reviewed'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/order.DepositOrder/approve',
|
||||
method: 'POST',
|
||||
data: { id: form.id },
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
baTable.toggleForm()
|
||||
} catch (_e) {
|
||||
// axios interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
const formEl = rejectFormRef.value
|
||||
if (!formEl) return
|
||||
const valid = await formEl.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/order.DepositOrder/reject',
|
||||
method: 'POST',
|
||||
data: {
|
||||
id: form.id,
|
||||
remark: rejectForm.remark.trim(),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
baTable.onTableHeaderAction('refresh', {})
|
||||
baTable.toggleForm()
|
||||
} catch (_e) {
|
||||
// axios interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumber(raw: unknown): number {
|
||||
if (raw === null || raw === undefined || raw === '') return 0
|
||||
const n = Number(raw)
|
||||
@@ -210,22 +375,19 @@ function resolveRelationText(row: Record<string, unknown>, relationKey: string,
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.deposit-detail-dialog {
|
||||
.deposit-review-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
.review-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:deep(.deposit-detail-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: 100vw;
|
||||
}
|
||||
.review-reject-hint {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,22 @@ defineOptions({
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit'])
|
||||
const editBtn = optButtons.find((b) => b.name === 'edit')
|
||||
if (editBtn) {
|
||||
editBtn.display = (row: TableRow) => Number(row.status) !== 0
|
||||
}
|
||||
optButtons.push({
|
||||
render: 'tipButton',
|
||||
name: 'review',
|
||||
title: 'order.withdrawOrder.review_btn',
|
||||
text: '',
|
||||
type: 'warning',
|
||||
icon: 'fa fa-check-square-o',
|
||||
display: (row: TableRow) => Number(row.status) === 0,
|
||||
click: (row: TableRow) => {
|
||||
baTable.toggleForm('Edit', [row[baTable.table.pk!]])
|
||||
},
|
||||
})
|
||||
|
||||
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
@@ -79,6 +95,14 @@ const baTable = new baTableClass(
|
||||
{ label: t('order.withdrawOrder.amount'), prop: 'amount', align: 'center', minWidth: 110, operator: 'RANGE', formatter: formatAmount },
|
||||
{ label: t('order.withdrawOrder.fee'), prop: 'fee', align: 'center', minWidth: 110, operator: 'RANGE', formatter: formatAmount },
|
||||
{ label: t('order.withdrawOrder.actual_amount'), prop: 'actual_amount', align: 'center', minWidth: 110, operator: 'RANGE', formatter: formatAmount },
|
||||
{
|
||||
label: t('order.withdrawOrder.pay_channel'),
|
||||
prop: 'pay_channel',
|
||||
align: 'center',
|
||||
minWidth: 110,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('order.withdrawOrder.receive_type'),
|
||||
prop: 'receive_type',
|
||||
@@ -96,6 +120,33 @@ const baTable = new baTableClass(
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('order.withdrawOrder.receiver_name'),
|
||||
prop: 'ddpay_receiver_name',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('order.withdrawOrder.receiver_email'),
|
||||
prop: 'receiver_email',
|
||||
align: 'center',
|
||||
minWidth: 180,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('order.withdrawOrder.receiver_mobile'),
|
||||
prop: 'receiver_mobile',
|
||||
align: 'center',
|
||||
minWidth: 140,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
label: t('order.withdrawOrder.idempotency_key'),
|
||||
prop: 'idempotency_key',
|
||||
@@ -116,9 +167,9 @@ const baTable = new baTableClass(
|
||||
effect: 'dark',
|
||||
custom: {
|
||||
'0': 'info',
|
||||
'1': 'warning',
|
||||
'2': 'success',
|
||||
'3': 'danger',
|
||||
'1': 'success',
|
||||
'2': 'danger',
|
||||
'3': 'success',
|
||||
},
|
||||
replaceValue: {
|
||||
'0': t('order.withdrawOrder.status 0'),
|
||||
@@ -178,7 +229,7 @@ const baTable = new baTableClass(
|
||||
width: 170,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
{ label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -39,12 +39,24 @@
|
||||
<el-form-item :label="t('order.withdrawOrder.create_time')">
|
||||
<el-input :model-value="form.create_time_text" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.pay_channel')">
|
||||
<el-input :model-value="form.pay_channel || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.receive_type')">
|
||||
<el-input :model-value="form.receive_type || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.receive_account')">
|
||||
<el-input :model-value="form.receive_account || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.receiver_name')">
|
||||
<el-input :model-value="form.receiver_name || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.receiver_email')">
|
||||
<el-input :model-value="form.receiver_email || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.receiver_mobile')">
|
||||
<el-input :model-value="form.receiver_mobile || '-'" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('order.withdrawOrder.idempotency_key')">
|
||||
<el-input :model-value="form.idempotency_key || '-'" readonly />
|
||||
</el-form-item>
|
||||
@@ -171,8 +183,12 @@ const form = reactive({
|
||||
review_admin_text: '-',
|
||||
review_time_text: '-',
|
||||
idempotency_key: '',
|
||||
pay_channel: '',
|
||||
receive_type: '',
|
||||
receive_account: '',
|
||||
receiver_name: '',
|
||||
receiver_email: '',
|
||||
receiver_mobile: '',
|
||||
amount: 0,
|
||||
fee: 0,
|
||||
status: 0,
|
||||
@@ -220,8 +236,12 @@ const hydrate = () => {
|
||||
form.status = Number(row['status'] ?? 0)
|
||||
form.remark = String(row['remark'] ?? '')
|
||||
form.idempotency_key = String(row['idempotency_key'] ?? '')
|
||||
form.pay_channel = String(row['pay_channel'] ?? '')
|
||||
form.receive_type = String(row['receive_type'] ?? '')
|
||||
form.receive_account = String(row['receive_account'] ?? '')
|
||||
form.receiver_name = String(row['ddpay_receiver_name'] ?? '')
|
||||
form.receiver_email = String(row['receiver_email'] ?? '')
|
||||
form.receiver_mobile = String(row['receiver_mobile'] ?? '')
|
||||
form.create_time_text = formatTime(row['create_time'])
|
||||
form.review_time_text = formatTime(row['review_time'])
|
||||
form.user_text = resolveRelationText(row, 'user', row['user_id'])
|
||||
|
||||
@@ -23,6 +23,38 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="text-muted mb-8">{{ connectTip || '-' }}</div>
|
||||
<template v-if="ready && subscribeTopics.length">
|
||||
<div class="mb-4">
|
||||
<span class="config-label">{{ t('test.ws.subscribe_topics') }}:</span>
|
||||
<el-tag v-for="topic in subscribeTopics" :key="topic" size="small" class="mr-4 mb-4">{{ topic }}</el-tag>
|
||||
</div>
|
||||
<div v-if="oddsPushTopics.length" class="mb-4">
|
||||
<span class="config-label">{{ t('test.ws.odds_push_topics') }}:</span>
|
||||
<el-tag v-for="topic in oddsPushTopics" :key="'odds-' + topic" type="warning" size="small" class="mr-4 mb-4">{{ topic }}</el-tag>
|
||||
</div>
|
||||
<div v-if="playerOddsFields.length" class="mb-4">
|
||||
<span class="config-label">{{ t('test.ws.player_odds_fields') }}:</span>
|
||||
<el-tag v-for="field in playerOddsFields" :key="field" type="success" size="small" class="mr-4 mb-4">{{ field }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-card v-if="testPlayerOdds" shadow="never" class="test-odds-card">
|
||||
<template #header>
|
||||
<span>{{ t('test.ws.test_player_odds') }}</span>
|
||||
<el-tag type="info" size="small" class="ml-8">{{ testPlayerOddsSourceLabel }}</el-tag>
|
||||
</template>
|
||||
<p class="text-muted mb-8">{{ t('test.ws.test_player_odds_hint') }}</p>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="user_id">{{ testPlayerOdds.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="username">{{ testPlayerOdds.username || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="uuid">{{ testPlayerOdds.uuid || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="phone">{{ testPlayerOdds.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="coin">{{ testPlayerOdds.coin }}</el-descriptions-item>
|
||||
<el-descriptions-item label="current_streak">{{ testPlayerOdds.current_streak }}</el-descriptions-item>
|
||||
<el-descriptions-item label="streak_level">{{ testPlayerOdds.streak_level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="odds_factor">{{ testPlayerOdds.odds_factor }}</el-descriptions-item>
|
||||
<el-descriptions-item label="is_jackpot">{{ testPlayerOdds.is_jackpot ? 'true' : 'false' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
@@ -35,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
@@ -58,19 +90,39 @@ const connectTip = ref('')
|
||||
const sendMessage = ref('')
|
||||
/** 连接成功后自动订阅(由 wsConfig.subscribe_topics 下发) */
|
||||
const subscribeTopics = ref<string[]>([])
|
||||
const oddsPushTopics = ref<string[]>([])
|
||||
const playerOddsFields = ref<string[]>([])
|
||||
const testPlayerOdds = ref<Record<string, unknown> | null>(null)
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const logs = ref<Array<{ t: number; event: string; payload: string }>>([])
|
||||
|
||||
const defaultSubscribeTopics = [
|
||||
'period.tick',
|
||||
'period.opened',
|
||||
'period.locked',
|
||||
'period.opened',
|
||||
'period.payout',
|
||||
'bet.accepted',
|
||||
'period.payout.tick',
|
||||
'bet.win',
|
||||
'user.streak',
|
||||
'wallet.changed',
|
||||
'bet.accepted',
|
||||
'jackpot.hit',
|
||||
'auto.spin.progress',
|
||||
'admin.live.snapshot',
|
||||
'admin.live.opened',
|
||||
] as const
|
||||
|
||||
const testPlayerOddsSourceLabel = computed(() => {
|
||||
const source = testPlayerOdds.value?.source
|
||||
if (source === 'db_user') {
|
||||
return t('test.ws.test_source_db')
|
||||
}
|
||||
if (source === 'synthetic') {
|
||||
return t('test.ws.test_source_synthetic')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const logText = computed(() => {
|
||||
if (!logs.value.length) return t('test.ws.log_empty')
|
||||
return logs.value
|
||||
@@ -109,6 +161,20 @@ async function loadConfig() {
|
||||
} else {
|
||||
subscribeTopics.value = [...defaultSubscribeTopics]
|
||||
}
|
||||
const rawOddsTopics = res.data.odds_push_topics
|
||||
if (Array.isArray(rawOddsTopics)) {
|
||||
oddsPushTopics.value = rawOddsTopics.filter((x: unknown): x is string => typeof x === 'string' && x.trim() !== '')
|
||||
} else {
|
||||
oddsPushTopics.value = ['user.streak', 'wallet.changed', 'bet.accepted']
|
||||
}
|
||||
const rawOddsFields = res.data.player_odds_fields
|
||||
if (Array.isArray(rawOddsFields)) {
|
||||
playerOddsFields.value = rawOddsFields.filter((x: unknown): x is string => typeof x === 'string' && x.trim() !== '')
|
||||
} else {
|
||||
playerOddsFields.value = ['current_streak', 'streak_level', 'odds_factor', 'is_jackpot']
|
||||
}
|
||||
const rawTestOdds = res.data.test_player_odds
|
||||
testPlayerOdds.value = rawTestOdds && typeof rawTestOdds === 'object' ? (rawTestOdds as Record<string, unknown>) : null
|
||||
const firstSample = Array.isArray(res.data.sample_messages) && res.data.sample_messages.length ? String(res.data.sample_messages[0]) : ''
|
||||
sendMessage.value = firstSample
|
||||
ready.value = wsUrl.value !== ''
|
||||
@@ -146,10 +212,22 @@ function connectWs() {
|
||||
})
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
const raw = typeof event.data === 'string' ? event.data : JSON.stringify(event.data)
|
||||
let eventName = 'ws.message'
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { event?: string; topic?: string }
|
||||
if (typeof parsed.event === 'string' && parsed.event !== '') {
|
||||
eventName = parsed.event
|
||||
} else if (typeof parsed.topic === 'string' && parsed.topic !== '') {
|
||||
eventName = parsed.topic
|
||||
}
|
||||
} catch {
|
||||
// keep default event name
|
||||
}
|
||||
appendLog({
|
||||
t: Date.now(),
|
||||
event: 'ws.message',
|
||||
payload: typeof event.data === 'string' ? event.data : JSON.stringify(event.data),
|
||||
event: eventName,
|
||||
payload: raw,
|
||||
})
|
||||
}
|
||||
socket.onerror = () => {
|
||||
@@ -199,6 +277,10 @@ function sendWs() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadConfig()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectWs()
|
||||
clearLogs()
|
||||
@@ -217,4 +299,21 @@ onUnmounted(() => {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.config-label {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.mr-4 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.test-odds-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -277,7 +277,6 @@ function buildAdminMapsFromTree(nodes: TreeNode[]) {
|
||||
return { mapCh, mapInv }
|
||||
}
|
||||
|
||||
/** 鏄犲皠鏈懡涓椂浠庡師濮嬫爲鏌ユ壘锛堥槻姝?props 瑁佸壀鎴栧紓姝ユ椂搴忛棶棰橈級 */
|
||||
function findAdminMetaInTree(nodes: TreeNode[], adminId: string): { channel_id?: number; invite_code: string } | null {
|
||||
const target = String(adminId).trim()
|
||||
for (const n of nodes) {
|
||||
@@ -304,6 +303,28 @@ function findAdminMetaInTree(nodes: TreeNode[], adminId: string): { channel_id?:
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveAdminChannelId(adminId: string | number | null | undefined): number | undefined {
|
||||
if (adminId === undefined || adminId === null || adminId === '') {
|
||||
return undefined
|
||||
}
|
||||
const key = typeof adminId === 'number' ? String(adminId) : String(adminId).trim()
|
||||
if (key === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let channelId = adminIdToChannelId.value[key]
|
||||
if (channelId === undefined) {
|
||||
const meta = findAdminMetaInTree(adminScopeTree.value, key)
|
||||
if (meta?.channel_id !== undefined) {
|
||||
channelId = meta.channel_id
|
||||
}
|
||||
}
|
||||
if (channelId === undefined || channelId === null || Number.isNaN(Number(channelId)) || Number(channelId) <= 0) {
|
||||
return undefined
|
||||
}
|
||||
return typeof channelId === 'number' ? channelId : parseInt(String(channelId), 10)
|
||||
}
|
||||
|
||||
const loadAdminScopeTree = async () => {
|
||||
const res = await createAxios({
|
||||
url: '/admin/user.User/adminScopeTree',
|
||||
@@ -360,8 +381,13 @@ const onAdminTreeChange = (val: string | number | null) => {
|
||||
|
||||
if (channelId !== undefined) {
|
||||
baTable.form.items.channel_id = channelId
|
||||
} else {
|
||||
delete baTable.form.items.channel_id
|
||||
}
|
||||
baTable.form.items.register_invite_code = inv !== undefined && inv !== null ? inv : ''
|
||||
nextTick(() => {
|
||||
formRef.value?.validateField('admin_id').catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -435,6 +461,16 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const validatorAdminChannel = (_rule: unknown, val: string | number | null | undefined, callback: (error?: Error) => void) => {
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return callback()
|
||||
}
|
||||
if (resolveAdminChannelId(val) === undefined) {
|
||||
return callback(new Error(t('user.user.admin_no_channel_bound')))
|
||||
}
|
||||
return callback()
|
||||
}
|
||||
|
||||
const validatorGameUserPassword = (rule: any, val: string, callback: (error?: Error) => void) => {
|
||||
const operate = baTable.form.operate
|
||||
const v = typeof val === 'string' ? val.trim() : ''
|
||||
@@ -454,7 +490,10 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('user.user.username') })],
|
||||
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
|
||||
phone: [buildValidatorData({ name: 'required', title: t('user.user.phone') })],
|
||||
admin_id: [buildValidatorData({ name: 'required', title: t('user.user.admin_affiliation') })],
|
||||
admin_id: [
|
||||
buildValidatorData({ name: 'required', title: t('user.user.admin_affiliation') }),
|
||||
{ validator: validatorAdminChannel, trigger: 'change' },
|
||||
],
|
||||
create_time: [buildValidatorData({ name: 'date', title: t('user.user.create_time') })],
|
||||
update_time: [buildValidatorData({ name: 'date', title: t('user.user.update_time') })],
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
|
||||
'/admin': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/install': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/plugin': { target: 'http://localhost:7979', changeOrigin: true },
|
||||
'/ws': { target: 'http://localhost:3131', changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user