Compare commits
54 Commits
9effe705b3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fdc1e2e88 | |||
| 186af5a55f | |||
| 6bec4e7758 | |||
| f9e4e61d93 | |||
| 8b6727dac1 | |||
| b97d33a24f | |||
| 6f12afcd10 | |||
| 5ab85d1d53 | |||
| 941f0f4a8c | |||
| 520e950dc5 | |||
| 2868899253 | |||
| 8305dbfcfd | |||
| 1cd5c3142d | |||
| 2686c54781 | |||
| e38fd660e2 | |||
| 6c402d4221 | |||
| ae7d7940d9 | |||
| c2ce8085c2 | |||
| 2d561f81b5 | |||
| 7db09b119e | |||
| d9dc31e388 | |||
| 179d67cb0e | |||
| 43a6c9ee47 | |||
| e08239dacf | |||
| a0d114fbc4 | |||
| e4e5a5cae2 | |||
| 8669a20dc8 | |||
| 09ef8edd15 | |||
| 4a42899bfe | |||
| b30ef21780 | |||
| 5d8a0564b4 | |||
| ed5665cb85 | |||
| c74b029436 | |||
| 2e0ecbaebe | |||
| 1bce279345 | |||
| 05312b3417 | |||
| 0d62c915bd | |||
| 0b0821c5c7 | |||
| 5c71376713 | |||
| 4f61c9d7fc | |||
| 019b536a89 | |||
| 3556ea42dd | |||
| eb0be3fba6 | |||
| 6b8dfcc441 | |||
| 6e06d6fe39 | |||
| f3d65280e7 | |||
| 20d89089ca | |||
| 0c51e4f8ec | |||
| a02eb8465c | |||
| b4c0835a78 | |||
| ca50e5a19a | |||
| 25e48063fa | |||
| abcb9b7b9a | |||
| 9b9ff7f13a |
32
.env-example
32
.env-example
@@ -16,3 +16,35 @@ DATABASE_PASSWORD = 123456
|
||||
DATABASE_HOSTPORT = 3306
|
||||
DATABASE_CHARSET = utf8mb4
|
||||
DATABASE_PREFIX =
|
||||
|
||||
# PlayX 配置
|
||||
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1)
|
||||
PLAYX_POINTS_TO_CASH_RATIO=0.1
|
||||
# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio(仅亏损时)
|
||||
PLAYX_RETURN_RATIO=0.1
|
||||
# 解锁比例:今日可领取上限 = yesterday_total_deposit * unlock_ratio
|
||||
PLAYX_UNLOCK_RATIO=0.1
|
||||
# Daily Push 签名校验密钥(HMAC,建议从部署系统注入,避免写入代码/仓库)
|
||||
PLAYX_DAILY_PUSH_SECRET=
|
||||
# 合作方回调 JWT 验签密钥(HS256,与对端私发密钥一致;与上一项可同时配置,则两种均需通过)
|
||||
PLAYX_PARTNER_JWT_SECRET=
|
||||
# Agent authtoken(/api/v1/authToken)JWT 签名密钥;留空则使用下方 buildadmin.token.key
|
||||
AGENT_AUTH_JWT_SECRET=
|
||||
# token 会话缓存过期时间(秒)
|
||||
PLAYX_SESSION_EXPIRE_SECONDS=3600
|
||||
# PlayX API(商城调用 PlayX 时使用)
|
||||
PLAYX_API_BASE_URL=
|
||||
PLAYX_API_SECRET_KEY=
|
||||
|
||||
# 推送订单url
|
||||
PLAYX_ANGPOW_IMPORT_BASE_URL=https://ss2-staging2.ttwd8.com
|
||||
# 推送订单接口
|
||||
PLAYX_ANGPOW_IMPORT_PATH=/cashmarket/v3/merchant-api/angpow-imports
|
||||
# 商户编码merchant_code
|
||||
PLAYX_ANGPOW_MERCHANT_CODE=plx
|
||||
# HMAC 密钥(与对端一致)
|
||||
PLAYX_ANGPOW_IMPORT_AUTH_KEY=
|
||||
# HTTPS:CA 证书包绝对路径(推荐下载 https://curl.se/ca/cacert.pem 后填写,解决 cURL error 60)
|
||||
PLAYX_ANGPOW_IMPORT_CACERT=
|
||||
# 是否校验 SSL(1=校验;0=不校验,仅本地调试,生产勿用)
|
||||
PLAYX_ANGPOW_IMPORT_VERIFY_SSL=1
|
||||
@@ -191,7 +191,7 @@ php webman migrate
|
||||
## 六、路由说明
|
||||
|
||||
- **后台 API**:`/admin/{module}.{Controller}/{action}`
|
||||
- 示例:`/admin/mall.Player/index` → `app\admin\controller\mall\Player::index`
|
||||
- 示例:`/admin/mall.User/index` → `app\admin\controller\mall\User::index`
|
||||
- **前台 API**:`/api/...`
|
||||
- **安装**:`/api/Install/...`
|
||||
|
||||
@@ -205,7 +205,7 @@ php webman migrate
|
||||
| 语言 key 命名错误 | `quick Search Fields` 改为 `quickSearchFields` |
|
||||
| 编辑时密码必填 | 编辑时密码可选,仅新增时必填;后端支持密码加密与重置 |
|
||||
| 多余表单校验 | 移除 `create_time`、`update_time` 的表单校验 |
|
||||
| mall_player 表缺失 | 新增迁移文件 `20250318120000_mall_player.php` |
|
||||
| mall_user 与 PlayX 资产口径混用 | 新增重构迁移并改为 `mall_user` 一对一扩展资产表 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ declare(strict_types=1);
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\model\MallClaimLog;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
@@ -15,8 +19,78 @@ class Dashboard extends Backend
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) return $response;
|
||||
|
||||
$now = time();
|
||||
$todayStart = strtotime(date('Y-m-d', $now) . ' 00:00:00');
|
||||
$yesterdayStart = $todayStart - 86400;
|
||||
|
||||
$newPlayersToday = MallUserAsset::where('create_time', '>=', $todayStart)
|
||||
->where('create_time', '<=', $now)
|
||||
->count();
|
||||
|
||||
$yesterdayPointsClaimed = MallClaimLog::where('create_time', '>=', $yesterdayStart)
|
||||
->where('create_time', '<', $todayStart)
|
||||
->sum('claimed_amount');
|
||||
|
||||
$yesterdayRedeemQuery = MallOrder::where('create_time', '>=', $yesterdayStart)
|
||||
->where('create_time', '<', $todayStart);
|
||||
|
||||
$yesterdayRedeemCount = (clone $yesterdayRedeemQuery)->count();
|
||||
$yesterdayRedeemPointsCostSum = (clone $yesterdayRedeemQuery)->sum('points_cost');
|
||||
$yesterdayRedeemAmountSum = (clone $yesterdayRedeemQuery)->sum('amount');
|
||||
$yesterdayRedeemCompletedCount = (clone $yesterdayRedeemQuery)
|
||||
->where('status', MallOrder::STATUS_COMPLETED)
|
||||
->count();
|
||||
$yesterdayRedeemRejectedCount = (clone $yesterdayRedeemQuery)
|
||||
->where('status', MallOrder::STATUS_REJECTED)
|
||||
->count();
|
||||
|
||||
$yesterdayRedeemByItem = Db::name('mall_order')
|
||||
->alias('o')
|
||||
->leftJoin('mall_item i', 'i.id = o.mall_item_id')
|
||||
->where('o.create_time', '>=', $yesterdayStart)
|
||||
->where('o.create_time', '<', $todayStart)
|
||||
->group('o.mall_item_id, i.title')
|
||||
->field([
|
||||
'o.mall_item_id',
|
||||
'i.title',
|
||||
Db::raw('COUNT(*) as order_count'),
|
||||
Db::raw('SUM(o.points_cost) as points_cost_sum'),
|
||||
Db::raw('SUM(o.amount) as amount_sum'),
|
||||
Db::raw('SUM(CASE WHEN o.status = "COMPLETED" THEN 1 ELSE 0 END) as completed_count'),
|
||||
Db::raw('SUM(CASE WHEN o.status = "REJECTED" THEN 1 ELSE 0 END) as rejected_count'),
|
||||
])
|
||||
->orderRaw('order_count DESC')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$pendingPhysicalToShip = MallOrder::where('type', MallOrder::TYPE_PHYSICAL)
|
||||
->where('status', MallOrder::STATUS_PENDING)
|
||||
->count();
|
||||
$grantFailedRetryableCount = MallOrder::where('type', MallOrder::TYPE_BONUS)
|
||||
->where('grant_status', MallOrder::GRANT_FAILED_RETRYABLE)
|
||||
->count();
|
||||
|
||||
return $this->success('', [
|
||||
'remark' => get_route_remark()
|
||||
'remark' => get_route_remark(),
|
||||
'playx' => [
|
||||
'time_range' => [
|
||||
'today_start' => $todayStart,
|
||||
'yesterday_start' => $yesterdayStart,
|
||||
'now' => $now,
|
||||
],
|
||||
'new_players_today' => $newPlayersToday,
|
||||
'yesterday_points_claimed' => $yesterdayPointsClaimed,
|
||||
'yesterday_redeem' => [
|
||||
'order_count' => $yesterdayRedeemCount,
|
||||
'points_cost_sum' => $yesterdayRedeemPointsCostSum,
|
||||
'amount_sum' => $yesterdayRedeemAmountSum,
|
||||
'completed_count' => $yesterdayRedeemCompletedCount,
|
||||
'rejected_count' => $yesterdayRedeemRejectedCount,
|
||||
'by_item' => $yesterdayRedeemByItem,
|
||||
],
|
||||
'pending_physical_to_ship' => $pendingPhysicalToShip,
|
||||
'grant_failed_retryable' => $grantFailedRetryableCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class Admin extends Backend
|
||||
{
|
||||
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 $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'agent_id', 'agent_api_secret', 'channel_id'];
|
||||
|
||||
protected array|string $quickSearchField = ['username', 'nickname'];
|
||||
|
||||
@@ -44,7 +44,7 @@ class Admin extends Backend
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withoutField('login_failure,password,salt')
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
@@ -98,6 +98,15 @@ class Admin extends Backend
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
$result = $this->model->save($data);
|
||||
if ($result !== false) {
|
||||
$agentId = strtolower(md5($this->model->username . $this->model->id));
|
||||
$agentSecret = strtoupper(md5($this->model->username . $this->model->id));
|
||||
// 使用原生 SQL,避免 ThinkORM 按当前表结构校验字段时因未迁移缺少 agent_api_secret 列而报错
|
||||
Db::execute(
|
||||
'UPDATE `admin` SET `agent_id` = ?, `agent_api_secret` = ? WHERE `id` = ?',
|
||||
[$agentId, $agentSecret, $this->model->id]
|
||||
);
|
||||
}
|
||||
if (!empty($data['group_arr'])) {
|
||||
$groupAccess = [];
|
||||
foreach ($data['group_arr'] as $datum) {
|
||||
@@ -263,11 +272,61 @@ class Admin extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉(Admin 无自定义,走父类默认列表)
|
||||
* 远程下拉(返回管理员列表供 remoteSelect 使用)
|
||||
*/
|
||||
public function select(Request $request): Response
|
||||
{
|
||||
return parent::select($request);
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withoutField('login_failure,password,salt')
|
||||
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉已废弃的渠道字段,避免组合搜索生成对不存在列的条件
|
||||
*/
|
||||
protected function filterSearchArray(array $search): array
|
||||
{
|
||||
return array_values(array_filter($search, static function ($item) {
|
||||
if (!is_array($item) || !isset($item['field'])) {
|
||||
return true;
|
||||
}
|
||||
$f = (string) $item['field'];
|
||||
if ($f === 'channel_id' || str_ends_with($f, '.channel_id')) {
|
||||
return false;
|
||||
}
|
||||
if ($f === 'channel.name' || str_starts_with($f, 'channel.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
public function queryOrderBuilder(): array
|
||||
{
|
||||
$order = parent::queryOrderBuilder();
|
||||
foreach (array_keys($order) as $key) {
|
||||
if ($key === 'channel_id' || (is_string($key) && str_contains($key, 'channel.'))) {
|
||||
unset($order[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function checkGroupAuth(array $groups): ?Response
|
||||
|
||||
@@ -143,6 +143,10 @@ class Crud extends Backend
|
||||
$this->langTsData['zh-cn']['quick Search Fields'] = implode('、', $quickSearchFieldZhCnTitle);
|
||||
$this->controllerData['attr']['quickSearchField'] = $quickSearchField;
|
||||
|
||||
if (array_key_exists('admin_id', $fieldsMap)) {
|
||||
$this->controllerData['attr']['autoFillAdminId'] = true;
|
||||
}
|
||||
|
||||
$weighKey = array_search('weigh', $fieldsMap);
|
||||
if ($weighKey !== false) {
|
||||
$this->indexVueData['enableDragSort'] = true;
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall\pints;
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
|
||||
/**
|
||||
* 积分订单
|
||||
* 收获地址管理
|
||||
*/
|
||||
class Order extends Backend
|
||||
class Address extends Backend
|
||||
{
|
||||
/**
|
||||
* MallPintsOrder模型对象
|
||||
* MallAddress模型对象
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallPintsOrder|null
|
||||
* @phpstan-var \app\common\model\MallAddress|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected array $withJoinTable = ['mallUser'];
|
||||
protected array $withJoinTable = ['playxUserAsset'];
|
||||
|
||||
protected string|array $quickSearchField = ['id'];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallPintsOrder();
|
||||
$this->model = new \app\common\model\MallAddress();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +52,10 @@ class Order extends Backend
|
||||
*/
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->visible(['mallUser' => ['username']])
|
||||
->with(['playxUserAsset' => function ($query) {
|
||||
$query->field('id,username');
|
||||
}])
|
||||
->visible(['playxUserAsset' => ['username']])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
57
app/admin/controller/mall/ClaimLog.php
Normal file
57
app/admin/controller/mall/ClaimLog.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 领取记录(后台列表)
|
||||
*/
|
||||
class ClaimLog extends Backend
|
||||
{
|
||||
/**
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallClaimLog|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected string|array $quickSearchField = ['user_id', 'claim_request_id'];
|
||||
|
||||
protected string|array $indexField = [
|
||||
'id',
|
||||
'claim_request_id',
|
||||
'user_id',
|
||||
'claimed_amount',
|
||||
'create_time',
|
||||
];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallClaimLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
return $this->select($request);
|
||||
}
|
||||
|
||||
return $this->_index();
|
||||
}
|
||||
}
|
||||
|
||||
61
app/admin/controller/mall/DailyPush.php
Normal file
61
app/admin/controller/mall/DailyPush.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 每日推送数据(后台列表)
|
||||
*/
|
||||
class DailyPush extends Backend
|
||||
{
|
||||
/**
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallDailyPush|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected string|array $quickSearchField = ['user_id', 'username', 'date'];
|
||||
|
||||
protected string|array $indexField = [
|
||||
'id',
|
||||
'user_id',
|
||||
'date',
|
||||
'username',
|
||||
'yesterday_win_loss_net',
|
||||
'yesterday_total_deposit',
|
||||
'lifetime_total_deposit',
|
||||
'lifetime_total_withdraw',
|
||||
'create_time',
|
||||
];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallDailyPush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
return $this->select($request);
|
||||
}
|
||||
|
||||
return $this->_index();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 商品管理
|
||||
@@ -21,7 +22,10 @@ class Item extends Backend
|
||||
|
||||
protected array $withJoinTable = ['admin'];
|
||||
|
||||
protected string|array $quickSearchField = ['id'];
|
||||
protected string|array $quickSearchField = ['id', 'title'];
|
||||
|
||||
/** 添加时自动填充 admin_id */
|
||||
protected bool $autoFillAdminId = true;
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
@@ -31,9 +35,8 @@ class Item extends Backend
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(\Webman\Http\Request $request): \support\Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
@@ -41,15 +44,9 @@ class Item extends Backend
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
$this->_select();
|
||||
return $this->success();
|
||||
return $this->select($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
|
||||
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
|
||||
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
|
||||
*/
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
@@ -67,6 +64,27 @@ class Item extends Backend
|
||||
}
|
||||
|
||||
/**
|
||||
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
|
||||
* 远程下拉选择数据(供 remoteSelect 使用)
|
||||
*/
|
||||
public function select(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->field('id,title')
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
318
app/admin/controller/mall/Order.php
Normal file
318
app/admin/controller/mall/Order.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\MallBonusGrantPush;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 统一订单(后台列表)
|
||||
*/
|
||||
class Order extends Backend
|
||||
{
|
||||
/**
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallOrder|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected array $withJoinTable = ['mallItem'];
|
||||
|
||||
protected string|array $quickSearchField = ['user_id', 'external_transaction_id', 'playx_transaction_id'];
|
||||
|
||||
protected string|array $indexField = [
|
||||
'id',
|
||||
'user_id',
|
||||
'type',
|
||||
'status',
|
||||
'mall_item_id',
|
||||
'points_cost',
|
||||
'amount',
|
||||
'multiplier',
|
||||
'external_transaction_id',
|
||||
'playx_transaction_id',
|
||||
'grant_status',
|
||||
'fail_reason',
|
||||
'reject_reason',
|
||||
'shipping_company',
|
||||
'shipping_no',
|
||||
'receiver_name',
|
||||
'receiver_phone',
|
||||
'receiver_address',
|
||||
'mall_address_id',
|
||||
'create_time',
|
||||
'update_time',
|
||||
];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallOrder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
return $this->select($request);
|
||||
}
|
||||
|
||||
[$where, $alias, $limit, $order] = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->with(['mallItem' => function ($query) {
|
||||
$query->field('id,title');
|
||||
}])
|
||||
->visible(['mallItem' => ['title']])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PHYSICAL 发货:更新 shipping_company/shipping_no,并将状态置为 SHIPPED
|
||||
*/
|
||||
public function ship(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$data = $request->post();
|
||||
$id = $data['id'] ?? 0;
|
||||
$shippingCompany = $data['shipping_company'] ?? '';
|
||||
$shippingNo = $data['shipping_no'] ?? '';
|
||||
|
||||
if (!$id || $shippingCompany === '' || $shippingNo === '') {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$order = MallOrder::where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if ($order->type !== MallOrder::TYPE_PHYSICAL) {
|
||||
return $this->error(__('Order type not PHYSICAL'));
|
||||
}
|
||||
if ($order->status !== MallOrder::STATUS_PENDING) {
|
||||
return $this->error(__('Order status must be PENDING'));
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$order->shipping_company = $shippingCompany;
|
||||
$order->shipping_no = $shippingNo;
|
||||
$order->status = MallOrder::STATUS_SHIPPED;
|
||||
$order->save();
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Shipped successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过(非 PHYSICAL):更新状态为 COMPLETED
|
||||
*/
|
||||
public function approve(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$id = $request->post('id', 0);
|
||||
if (!$id) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$order = MallOrder::where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if ($order->status !== MallOrder::STATUS_PENDING) {
|
||||
return $this->error(__('Order status must be PENDING'));
|
||||
}
|
||||
if ($order->type === MallOrder::TYPE_PHYSICAL) {
|
||||
return $this->error(__('Order type not supported'));
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$order->status = MallOrder::STATUS_COMPLETED;
|
||||
$order->update_time = time();
|
||||
$order->save();
|
||||
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Approved successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回:更新状态为 REJECTED,并退回积分到 available_points(所有类型通用)
|
||||
*/
|
||||
public function reject(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$data = $request->post();
|
||||
$id = $data['id'] ?? 0;
|
||||
$rejectReason = $data['reject_reason'] ?? '';
|
||||
|
||||
if (!$id) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$order = MallOrder::where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if ($order->status !== MallOrder::STATUS_PENDING) {
|
||||
return $this->error(__('Order status must be PENDING'));
|
||||
}
|
||||
|
||||
if ($order->type === MallOrder::TYPE_PHYSICAL && $rejectReason === '') {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$asset = MallUserAsset::where('playx_user_id', $order->user_id ?? '')->find();
|
||||
if (!$asset) {
|
||||
throw new \RuntimeException('User asset not found');
|
||||
}
|
||||
|
||||
$refund = $order->points_cost ?? 0;
|
||||
if ($refund > 0) {
|
||||
$asset->available_points += $refund;
|
||||
$asset->save();
|
||||
}
|
||||
|
||||
$order->status = MallOrder::STATUS_REJECTED;
|
||||
$order->reject_reason = $rejectReason;
|
||||
if ($order->type === MallOrder::TYPE_BONUS) {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||||
} else {
|
||||
$order->grant_status = MallOrder::GRANT_NOT_APPLICABLE;
|
||||
}
|
||||
$order->update_time = time();
|
||||
$order->save();
|
||||
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Rejected successfully'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动推送红利(同步调用 PlayX,不限制自动重试次数;成功则 ACCEPTED,失败写入 fail_reason)
|
||||
*/
|
||||
public function retry(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$id = $request->post('id', 0);
|
||||
if (!$id) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$order = MallOrder::where('id', $id)->find();
|
||||
if (!$order) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
if ($order->type !== MallOrder::TYPE_BONUS) {
|
||||
return $this->error(__('Only BONUS can retry'));
|
||||
}
|
||||
if ($order->status !== MallOrder::STATUS_PENDING) {
|
||||
return $this->error(__('Order status must be PENDING'));
|
||||
}
|
||||
|
||||
$allowedStatuses = [
|
||||
MallOrder::GRANT_NOT_SENT,
|
||||
MallOrder::GRANT_SENT_PENDING,
|
||||
MallOrder::GRANT_FAILED_RETRYABLE,
|
||||
MallOrder::GRANT_FAILED_FINAL,
|
||||
];
|
||||
if (!in_array($order->grant_status, $allowedStatuses, true)) {
|
||||
return $this->error(__('Current grant status cannot be manually pushed'));
|
||||
}
|
||||
|
||||
if (strval(config('playx.api.base_url', '')) === '') {
|
||||
return $this->error(__('PlayX API not configured'));
|
||||
}
|
||||
|
||||
$result = MallBonusGrantPush::push($order);
|
||||
if ($result['ok']) {
|
||||
$order->grant_status = MallOrder::GRANT_ACCEPTED;
|
||||
$order->playx_transaction_id = $result['playx_transaction_id'];
|
||||
$order->fail_reason = null;
|
||||
$order->update_time = time();
|
||||
$order->save();
|
||||
|
||||
return $this->success(__('Push succeeded'));
|
||||
}
|
||||
|
||||
$failReason = __('Manual push failed') . ': ' . $result['message'];
|
||||
$order->fail_reason = $failReason;
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||||
$order->update_time = time();
|
||||
$order->save();
|
||||
|
||||
return $this->error($failReason);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/admin/controller/mall/PintsOrder.php
Normal file
69
app/admin/controller/mall/PintsOrder.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
|
||||
/**
|
||||
* 积分订单
|
||||
*/
|
||||
class PintsOrder extends Backend
|
||||
{
|
||||
/**
|
||||
* MallPintsOrder模型对象
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallPintsOrder|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected array $withJoinTable = ['playxUserAsset'];
|
||||
|
||||
protected string|array $quickSearchField = ['id'];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallPintsOrder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(\Webman\Http\Request $request): \support\Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
$this->_select();
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->with(['playxUserAsset' => function ($query) {
|
||||
$query->field('id,username');
|
||||
}])
|
||||
->visible(['playxUserAsset' => ['username']])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 积分商城用户
|
||||
*/
|
||||
class Player extends Backend
|
||||
{
|
||||
/**
|
||||
* Player模型对象
|
||||
* @var object|null
|
||||
* @phpstan-var \app\admin\model\mall\Player|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'password'];
|
||||
|
||||
protected string|array $quickSearchField = ['id'];
|
||||
|
||||
/** 列表不返回密码字段 */
|
||||
protected string|array $indexField = ['id', 'username', 'create_time', 'update_time', 'score'];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\admin\model\mall\Player();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加(重写以支持密码加密)
|
||||
*/
|
||||
public function add(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response instanceof Response) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
$this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$data = $request->post();
|
||||
if (!$data) {
|
||||
$this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
|
||||
$passwd = $data['password'] ?? '';
|
||||
if (empty($passwd)) {
|
||||
$this->error(__('Parameter %s can not be empty', [__('Password')]));
|
||||
}
|
||||
|
||||
$data = $this->applyInputFilter($data);
|
||||
$data = $this->excludeFields($data);
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
|
||||
if (class_exists($validate)) {
|
||||
$validate = new $validate();
|
||||
if ($this->modelSceneValidate) {
|
||||
$validate->scene('add');
|
||||
}
|
||||
$validate->check($data);
|
||||
}
|
||||
}
|
||||
$result = $this->model->save($data);
|
||||
if ($result !== false && $passwd) {
|
||||
$this->model->resetPassword((int) $this->model->id, $passwd);
|
||||
}
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->model->rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑(重写以支持编辑时密码可选)
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response instanceof Response) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$pk = $this->model->getPk();
|
||||
$id = $request->post($pk) ?? $request->get($pk);
|
||||
$row = $this->model->find($id);
|
||||
if (!$row) {
|
||||
$this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST') {
|
||||
$data = $request->post();
|
||||
if (!$data) {
|
||||
$this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$this->model->resetPassword((int) $row->id, $data['password']);
|
||||
}
|
||||
|
||||
$data = $this->applyInputFilter($data);
|
||||
$data = $this->excludeFields($data);
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
|
||||
if (class_exists($validate)) {
|
||||
$validate = new $validate();
|
||||
if ($this->modelSceneValidate) {
|
||||
$validate->scene('edit');
|
||||
}
|
||||
$validate->check(array_merge($data, [$pk => $row[$pk]]));
|
||||
}
|
||||
}
|
||||
$result = $row->save($data);
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->model->rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
|
||||
}
|
||||
|
||||
unset($row['password']);
|
||||
$row['password'] = '';
|
||||
$this->success('', ['row' => $row]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 若需重写查看、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall\redemption;
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
@@ -8,7 +8,7 @@ use app\common\controller\Backend;
|
||||
/**
|
||||
* 兑换订单
|
||||
*/
|
||||
class Order extends Backend
|
||||
class RedemptionOrder extends Backend
|
||||
{
|
||||
/**
|
||||
* MallRedemptionOrder模型对象
|
||||
@@ -19,7 +19,7 @@ class Order extends Backend
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected array $withJoinTable = ['mallUser', 'mallItem'];
|
||||
protected array $withJoinTable = ['playxUserAsset', 'mallItem'];
|
||||
|
||||
protected string|array $quickSearchField = ['id'];
|
||||
|
||||
@@ -52,8 +52,10 @@ class Order extends Backend
|
||||
*/
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->visible(['mallUser' => ['username'], 'mallItem' => ['title']])
|
||||
->with(['playxUserAsset' => function ($query) {
|
||||
$query->field('id,username');
|
||||
}])
|
||||
->visible(['playxUserAsset' => ['username'], 'mallItem' => ['title']])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
@@ -1,196 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 商城用户
|
||||
*/
|
||||
class User extends Backend
|
||||
{
|
||||
/**
|
||||
* @var \app\common\model\MallUser|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'password'];
|
||||
|
||||
protected array $withJoinTable = ['admin'];
|
||||
|
||||
protected string|array $quickSearchField = ['id', 'username', 'phone'];
|
||||
|
||||
/** 列表不返回密码 */
|
||||
protected string|array $indexField = ['id', 'username', 'phone', 'score', 'daily_claim', 'daily_claim_use', 'available_for_withdrawal', 'admin_id', 'create_time', 'update_time'];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->get('select') || $request->post('select')) {
|
||||
$this->_select();
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->withoutField('password')
|
||||
->withJoin($this->withJoinTable, $this->withJoinType)
|
||||
->visible(['admin' => ['username']])
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $res->items(),
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加(密码加密)
|
||||
*/
|
||||
public function add(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($request->method() !== 'POST') {
|
||||
return $this->error(__('Parameter error'));
|
||||
}
|
||||
|
||||
$data = $request->post();
|
||||
if (!$data) {
|
||||
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
|
||||
$passwd = $data['password'] ?? '';
|
||||
if (empty($passwd)) {
|
||||
return $this->error(__('Parameter %s can not be empty', [__('Password')]));
|
||||
}
|
||||
|
||||
$data = $this->applyInputFilter($data);
|
||||
$data = $this->excludeFields($data);
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
|
||||
if (class_exists($validate)) {
|
||||
$validate = new $validate();
|
||||
if ($this->modelSceneValidate) {
|
||||
$validate->scene('add');
|
||||
}
|
||||
$validate->check($data);
|
||||
}
|
||||
}
|
||||
$result = $this->model->save($data);
|
||||
if ($result !== false && $passwd) {
|
||||
$this->model->resetPassword((int) $this->model->id, $passwd);
|
||||
}
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->model->rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑(密码可选更新)
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$pk = $this->model->getPk();
|
||||
$id = $request->post($pk) ?? $request->get($pk);
|
||||
$row = $this->model->find($id);
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$dataLimitAdminIds = $this->getDataLimitAdminIds();
|
||||
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
|
||||
return $this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST') {
|
||||
$data = $request->post();
|
||||
if (!$data) {
|
||||
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$this->model->resetPassword((int) $row->id, $data['password']);
|
||||
}
|
||||
|
||||
$data = $this->applyInputFilter($data);
|
||||
$data = $this->excludeFields($data);
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
|
||||
if (class_exists($validate)) {
|
||||
$validate = new $validate();
|
||||
if ($this->modelSceneValidate) {
|
||||
$validate->scene('edit');
|
||||
}
|
||||
$validate->check(array_merge($data, [$pk => $row[$pk]]));
|
||||
}
|
||||
}
|
||||
$result = $row->save($data);
|
||||
$this->model->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->model->rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
|
||||
}
|
||||
|
||||
unset($row['password']);
|
||||
$row['password'] = '';
|
||||
return $this->success('', ['row' => $row]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除
|
||||
*/
|
||||
public function del(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
return $this->_del();
|
||||
}
|
||||
}
|
||||
82
app/admin/controller/mall/UserAsset.php
Normal file
82
app/admin/controller/mall/UserAsset.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
* 用户资产(后台列表)
|
||||
*/
|
||||
class UserAsset extends Backend
|
||||
{
|
||||
/**
|
||||
* @var object|null
|
||||
* @phpstan-var \app\common\model\MallUserAsset|null
|
||||
*/
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
|
||||
|
||||
protected string|array $quickSearchField = [
|
||||
'playx_user_id',
|
||||
'username',
|
||||
'phone',
|
||||
];
|
||||
|
||||
protected string|array $indexField = [
|
||||
'id',
|
||||
'playx_user_id',
|
||||
'username',
|
||||
'phone',
|
||||
'locked_points',
|
||||
'available_points',
|
||||
'today_limit',
|
||||
'today_claimed',
|
||||
'today_limit_date',
|
||||
'create_time',
|
||||
'update_time',
|
||||
];
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
parent::initialize();
|
||||
$this->model = new \app\common\model\MallUserAsset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程下拉:资产主键 id + 用户名(用于地址/订单等关联)
|
||||
*/
|
||||
public function select(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
[$where, $alias, $limit, $order] = $this->queryBuilder();
|
||||
$res = $this->model
|
||||
->field('id,username')
|
||||
->alias($alias)
|
||||
->where($where)
|
||||
->order($order)
|
||||
->paginate($limit);
|
||||
|
||||
$list = [];
|
||||
foreach ($res->items() as $row) {
|
||||
$arr = $row->toArray();
|
||||
$list[] = [
|
||||
'id' => $arr['id'] ?? 0,
|
||||
'username' => $arr['username'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => $res->total(),
|
||||
'remark' => get_route_remark(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class AdminInfo extends Backend
|
||||
{
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status'];
|
||||
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id', 'agent_id', 'agent_api_secret'];
|
||||
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
|
||||
|
||||
protected function initController(Request $request): ?Response
|
||||
|
||||
@@ -14,7 +14,7 @@ class Attachment extends Backend
|
||||
protected ?object $model = null;
|
||||
|
||||
protected array|string $quickSearchField = 'name';
|
||||
protected array $withJoinTable = ['admin', 'user'];
|
||||
protected array $withJoinTable = ['admin'];
|
||||
protected array|string $defaultSortField = ['last_upload_time' => 'desc'];
|
||||
|
||||
protected function initController(Request $request): ?Response
|
||||
|
||||
@@ -95,4 +95,9 @@ return [
|
||||
'%d records and files have been deleted' => '%d records and files have been deleted',
|
||||
'Please input correct username' => 'Please enter the correct username',
|
||||
'Group Name Arr' => 'Group Name Arr',
|
||||
'Push succeeded' => 'Push succeeded',
|
||||
'Manual push failed' => 'Manual push failed',
|
||||
'PlayX API not configured' => 'PlayX API not configured',
|
||||
'Current grant status cannot be manually pushed' => 'Current grant status cannot be manually pushed',
|
||||
'Order status must be PENDING' => 'Order status must be PENDING',
|
||||
];
|
||||
@@ -114,4 +114,9 @@ return [
|
||||
'%d records and files have been deleted' => '已删除%d条记录和文件',
|
||||
'Please input correct username' => '请输入正确的用户名',
|
||||
'Group Name Arr' => '分组名称数组',
|
||||
'Push succeeded' => '推送成功',
|
||||
'Manual push failed' => '手动推送失败',
|
||||
'PlayX API not configured' => 'PlayX 接口未配置',
|
||||
'Current grant status cannot be manually pushed' => '当前发放状态不可手动推送',
|
||||
'Order status must be PENDING' => '订单状态须为处理中',
|
||||
];
|
||||
@@ -101,6 +101,7 @@ class Helper
|
||||
'withJoinTable' => 'array',
|
||||
'defaultSortField' => 'string|array',
|
||||
'weighField' => 'string',
|
||||
'autoFillAdminId' => 'bool',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -692,7 +693,14 @@ class Helper
|
||||
$modelMethodList = isset($modelData['relationMethodList']) ? array_merge($modelData['methods'], $modelData['relationMethodList']) : $modelData['methods'];
|
||||
$modelData['methods'] = $modelMethodList ? "\n" . implode("\n", $modelMethodList) : '';
|
||||
$modelData['append'] = self::buildModelAppend($modelData['append'] ?? []);
|
||||
$modelData['fieldType'] = self::buildModelFieldType($modelData['fieldType'] ?? []);
|
||||
$fieldType = $modelData['fieldType'] ?? [];
|
||||
if ($modelData['autoWriteTimestamp'] == 'true') {
|
||||
$fieldType = array_merge(
|
||||
['create_time' => 'integer', 'update_time' => 'integer'],
|
||||
$fieldType
|
||||
);
|
||||
}
|
||||
$modelData['fieldType'] = self::buildModelFieldType($fieldType);
|
||||
|
||||
if (isset($modelData['beforeInsertMixins']['snowflake'])) {
|
||||
$modelData['beforeInsert'] = self::assembleStub('mixins/model/beforeInsert', [
|
||||
@@ -726,6 +734,9 @@ class Helper
|
||||
$attrType = self::$attrType['controller'][$key] ?? '';
|
||||
if (is_array($item)) {
|
||||
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = ['" . implode("', '", $item) . "'];\n";
|
||||
} elseif ($attrType === 'bool') {
|
||||
$val = ($item === true || $item === 'true') ? 'true' : 'false';
|
||||
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = $val;\n";
|
||||
} elseif ($item) {
|
||||
$controllerAttr .= "\n" . self::tab() . "protected $attrType \$$key = '$item';\n";
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ class {%className%} extends Backend
|
||||
{%methods%}
|
||||
|
||||
/**
|
||||
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
|
||||
* add、edit、del、sortable 已由父类 Backend 实现,无需重写即可直接使用
|
||||
* 若需重写,请确保调用 initializeBackend($request) 并传入 Request 参数
|
||||
* 若模型有 admin_id 字段需自动填充,可设置 protected bool $autoFillAdminId = true
|
||||
*/
|
||||
}
|
||||
@@ -97,6 +97,9 @@ trait Backend
|
||||
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||
$data[$this->dataLimitField] = $this->auth->id;
|
||||
}
|
||||
if ($this->autoFillAdminId && $this->dataLimitField === 'admin_id') {
|
||||
$data['admin_id'] = $this->auth->id;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
$this->model->startTrans();
|
||||
|
||||
@@ -21,11 +21,23 @@ use support\think\Db;
|
||||
* @property string $password 密码密文
|
||||
* @property string $salt 密码盐
|
||||
* @property string $status 状态:enable=启用,disable=禁用
|
||||
* @property string $agent_id 代理 ID(API 鉴权)
|
||||
* @property string $agent_api_secret Agent API 密钥
|
||||
*/
|
||||
class Admin extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
/**
|
||||
* 已移除的 channel_id 等若仍被旧请求/缓存传入,禁止参与读写
|
||||
*/
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'disuse' => ['channel_id'],
|
||||
];
|
||||
}
|
||||
|
||||
protected string $table = 'admin';
|
||||
protected string $pk = 'id';
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\model\mall;
|
||||
|
||||
use app\common\model\traits\TimestampInteger;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* Player
|
||||
*/
|
||||
class Player extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
// 表名
|
||||
protected $name = 'mall_player';
|
||||
|
||||
// 自动写入时间戳字段
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
public function resetPassword(int $id, string $newPassword): bool
|
||||
{
|
||||
return $this->where(['id' => $id])->update(['password' => hash_password($newPassword)]) !== false;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,9 @@ class Common extends Api
|
||||
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE . '-refresh') {
|
||||
Token::set($newToken, UserAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)config('buildadmin.user_token_keep_time', 259200));
|
||||
}
|
||||
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE_MALL_USER . '-refresh') {
|
||||
Token::set($newToken, UserAuth::TOKEN_TYPE_MALL_USER, $refreshToken['user_id'], (int)config('buildadmin.user_token_keep_time', 259200));
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'type' => $refreshToken['type'],
|
||||
|
||||
165
app/api/controller/v1/Auth.php
Normal file
165
app/api/controller/v1/Auth.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use ba\Random;
|
||||
use Throwable;
|
||||
use app\common\controller\Api;
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth as UserAuth;
|
||||
use app\common\library\AgentJwt;
|
||||
use app\common\model\MallUserAsset;
|
||||
use app\admin\model\Admin;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* API v1 鉴权接口
|
||||
*/
|
||||
class Auth extends Api
|
||||
{
|
||||
/**
|
||||
* Agent Token 类型
|
||||
*/
|
||||
public const TOKEN_TYPE = 'agent';
|
||||
|
||||
/**
|
||||
* 时间戳有效范围(秒),防止重放攻击
|
||||
*/
|
||||
protected int $timeTolerance = 300;
|
||||
|
||||
/**
|
||||
* 临时登录 token 有效期(秒)
|
||||
*/
|
||||
protected int $tempTokenExpire = 86400;
|
||||
|
||||
/**
|
||||
* 获取鉴权 Token(GET 请求)
|
||||
* 参数仅从 Query 读取:signature、secret、agent_id、time
|
||||
* 返回:authtoken;失败返回 code=0 及失败信息
|
||||
*/
|
||||
public function authToken(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$signature = $request->get('signature', '');
|
||||
$secret = $request->get('secret', '');
|
||||
$agentId = $request->get('agent_id', '');
|
||||
$time = $request->get('time', '');
|
||||
|
||||
if ($signature === '' || $secret === '' || $agentId === '' || $time === '') {
|
||||
return $this->error(__('Parameter signature/secret/agent_id/time can not be empty'));
|
||||
}
|
||||
|
||||
$timestamp = intval($time);
|
||||
if ($timestamp <= 0) {
|
||||
return $this->error(__('Invalid timestamp'));
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if ($timestamp < $now - $this->timeTolerance || $timestamp > $now + $this->timeTolerance) {
|
||||
return $this->error(__('Timestamp expired'));
|
||||
}
|
||||
|
||||
$admin = Admin::where('agent_id', $agentId)->find();
|
||||
if (!$admin) {
|
||||
return $this->error(__('Agent not found'));
|
||||
}
|
||||
|
||||
$apiSecret = strval($admin->agent_api_secret ?? '');
|
||||
if ($apiSecret === '') {
|
||||
return $this->error(__('Agent not found'));
|
||||
}
|
||||
|
||||
if ($apiSecret !== $secret) {
|
||||
return $this->error(__('Invalid agent or secret'));
|
||||
}
|
||||
|
||||
$expectedSignature = strtoupper(md5($agentId . $secret . $time));
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
return $this->error(__('Invalid signature'));
|
||||
}
|
||||
|
||||
$expire = intval(config('buildadmin.agent_auth.token_expire', 86400));
|
||||
$payload = [
|
||||
'agent_id' => $agentId,
|
||||
'admin_id' => $admin->id,
|
||||
];
|
||||
$authtoken = AgentJwt::encode($payload, $expire);
|
||||
|
||||
return $this->success('', [
|
||||
'authtoken' => $authtoken,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 临时登录(GET/POST)
|
||||
* 参数:username
|
||||
* 写入或复用 mall_user_asset;签发 muser 类型 token(user_id 为资产表主键)
|
||||
*/
|
||||
public function temLogin(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$enabled = config('buildadmin.agent_auth.temp_login_enable', false);
|
||||
if (!$enabled) {
|
||||
return $this->error(__('Temp login is disabled'));
|
||||
}
|
||||
|
||||
$username = trim(strval($request->get('username', $request->post('username', ''))));
|
||||
// 兼容:querystring 中未编码的 '+' 会被解析为空格(application/x-www-form-urlencoded 规则)
|
||||
// 例如:/api/v1/temLogin?username=+607... 期望保留 '+',则从原始 querystring 提取并还原
|
||||
if ($username !== '' && str_contains($username, ' ')) {
|
||||
$qs = $request->queryString();
|
||||
if (is_string($qs) && $qs !== '') {
|
||||
foreach (explode('&', $qs) as $pair) {
|
||||
if ($pair === '' || !str_contains($pair, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = explode('=', $pair, 2);
|
||||
if (rawurldecode($k) === 'username') {
|
||||
// 先把 %xx 解码;注意这里不把 '+' 当空格处理,从而保留 '+'
|
||||
$username = trim(rawurldecode($v));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($username === '') {
|
||||
return $this->error(__('Parameter username can not be empty'));
|
||||
}
|
||||
|
||||
try {
|
||||
$asset = MallUserAsset::ensureForUsername($username);
|
||||
} catch (Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$token = Random::uuid();
|
||||
$refreshToken = Random::uuid();
|
||||
$expire = config('buildadmin.agent_auth.temp_login_expire', $this->tempTokenExpire);
|
||||
$assetId = intval($asset->getKey());
|
||||
Token::set($token, UserAuth::TOKEN_TYPE_MALL_USER, $assetId, $expire);
|
||||
Token::set($refreshToken, UserAuth::TOKEN_TYPE_MALL_USER . '-refresh', $assetId, 2592000);
|
||||
|
||||
return $this->success('', [
|
||||
'userInfo' => [
|
||||
'id' => $assetId,
|
||||
'username' => strval($asset->username ?? ''),
|
||||
'nickname' => strval($asset->username ?? ''),
|
||||
'playx_user_id' => strval($asset->playx_user_id ?? ''),
|
||||
'token' => $token,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => $expire,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
1144
app/api/controller/v1/Playx.php
Normal file
1144
app/api/controller/v1/Playx.php
Normal file
@@ -0,0 +1,1144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use ba\Random;
|
||||
use app\common\controller\Api;
|
||||
use app\common\facade\Token;
|
||||
use app\common\library\Auth as UserAuth;
|
||||
use app\common\model\MallItem;
|
||||
use app\common\model\MallClaimLog;
|
||||
use app\common\model\MallDailyPush;
|
||||
use app\common\model\MallSession;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use app\common\model\MallAddress;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* PlayX 积分商城 API
|
||||
*/
|
||||
class Playx extends Api
|
||||
{
|
||||
/**
|
||||
* 从请求解析 mall_user_asset.id(muser token、session、user_id 均指向资产表主键或 playx_user_id)
|
||||
*/
|
||||
private function resolvePlayxAssetIdFromRequest(Request $request): ?int
|
||||
{
|
||||
$sessionId = strval($request->post('session_id', $request->get('session_id', '')));
|
||||
if ($sessionId !== '') {
|
||||
$session = MallSession::where('session_id', $sessionId)->find();
|
||||
if ($session) {
|
||||
$expireTime = intval($session->expire_time ?? 0);
|
||||
if ($expireTime > time()) {
|
||||
$asset = MallUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find();
|
||||
if ($asset) {
|
||||
return intval($asset->getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
$assetId = $this->resolveAssetIdByToken($sessionId);
|
||||
if ($assetId !== null) {
|
||||
return $assetId;
|
||||
}
|
||||
}
|
||||
|
||||
$token = strval($request->post('token', $request->get('token', '')));
|
||||
if ($token === '') {
|
||||
$token = get_auth_token(['ba', 'token'], $request);
|
||||
}
|
||||
if ($token !== '') {
|
||||
return $this->resolveAssetIdByToken($token);
|
||||
}
|
||||
|
||||
$userId = strval($request->post('user_id', $request->get('user_id', '')));
|
||||
if ($userId === '') {
|
||||
return null;
|
||||
}
|
||||
if (ctype_digit($userId)) {
|
||||
return intval($userId);
|
||||
}
|
||||
|
||||
$asset = MallUserAsset::where('playx_user_id', $userId)->find();
|
||||
if ($asset) {
|
||||
return intval($asset->getKey());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveAssetIdByToken(string $token): ?int
|
||||
{
|
||||
$tokenData = Token::get($token);
|
||||
$tokenType = strval($tokenData['type'] ?? '');
|
||||
$isMemberOrMall = $tokenType === UserAuth::TOKEN_TYPE || $tokenType === UserAuth::TOKEN_TYPE_MALL_USER;
|
||||
if (!empty($tokenData)
|
||||
&& $isMemberOrMall
|
||||
&& intval($tokenData['expire_time'] ?? 0) > time()
|
||||
&& intval($tokenData['user_id'] ?? 0) > 0
|
||||
) {
|
||||
return intval($tokenData['user_id']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildTempPhone(): ?string
|
||||
{
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
|
||||
if (!MallUserAsset::where('phone', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallUserAsset
|
||||
{
|
||||
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
|
||||
if ($asset) {
|
||||
return $asset;
|
||||
}
|
||||
|
||||
$effectiveUsername = trim($username);
|
||||
if ($effectiveUsername === '') {
|
||||
$effectiveUsername = 'playx_' . $playxUserId;
|
||||
}
|
||||
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
|
||||
if ($byName) {
|
||||
$byName->playx_user_id = $playxUserId;
|
||||
$byName->save();
|
||||
|
||||
return $byName;
|
||||
}
|
||||
|
||||
$phone = $this->buildTempPhone();
|
||||
if ($phone === null) {
|
||||
return null;
|
||||
}
|
||||
$pwd = hash_password(Random::build('alnum', 16));
|
||||
$now = time();
|
||||
|
||||
return MallUserAsset::create([
|
||||
'playx_user_id' => $playxUserId,
|
||||
'username' => $effectiveUsername,
|
||||
'phone' => $phone,
|
||||
'password' => $pwd,
|
||||
'admin_id' => 0,
|
||||
'locked_points' => 0,
|
||||
'available_points' => 0,
|
||||
'today_limit' => 0,
|
||||
'today_claimed' => 0,
|
||||
'today_limit_date' => null,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getAssetById(int $assetId): ?MallUserAsset
|
||||
{
|
||||
return MallUserAsset::where('id', $assetId)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily Push API - PlayX 调用商城接收 T+1 数据
|
||||
* POST /api/v1/mall/dailyPush
|
||||
*/
|
||||
public function dailyPush(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$body = $request->post();
|
||||
if (empty($body)) {
|
||||
$raw = $request->rawBody();
|
||||
if ($raw) {
|
||||
$body = json_decode($raw, true) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$secret = config('playx.daily_push_secret', '');
|
||||
if ($secret !== '') {
|
||||
$sig = $request->header('X-Signature', '');
|
||||
$ts = $request->header('X-Timestamp', '');
|
||||
$rid = $request->header('X-Request-Id', '');
|
||||
if ($sig === '' || $ts === '' || $rid === '') {
|
||||
return $this->error(__('Daily push signature missing or incomplete'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', json_encode($body));
|
||||
$expected = hash_hmac('sha256', $canonical, $secret);
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 新版批量上报格式 =====
|
||||
// 兼容你们截图:{ report_date, member:[{member_id, login, lty_deposit, lty_withdrawal, yesterday_total_w, yesterday_total_deposit}, ...] }
|
||||
if (isset($body['report_date']) && isset($body['member']) && is_array($body['member'])) {
|
||||
$reportDate = $body['report_date'];
|
||||
$date = '';
|
||||
if (is_numeric($reportDate)) {
|
||||
$date = date('Y-m-d', intval($reportDate));
|
||||
} else {
|
||||
$date = strval($reportDate);
|
||||
}
|
||||
|
||||
$members = $body['member'];
|
||||
if ($date === '' || empty($members)) {
|
||||
return $this->error(__('Missing required fields: report_date, member'));
|
||||
}
|
||||
|
||||
$requestId = strval($body['request_id'] ?? '');
|
||||
if ($requestId === '') {
|
||||
$requestId = 'report_' . $date;
|
||||
}
|
||||
|
||||
$returnRatio = config('playx.return_ratio', 0.1);
|
||||
$unlockRatio = config('playx.unlock_ratio', 0.1);
|
||||
|
||||
$results = [];
|
||||
$allDeduped = true;
|
||||
|
||||
foreach ($members as $m) {
|
||||
$playxUserId = strval($m['member_id'] ?? '');
|
||||
if ($playxUserId === '') {
|
||||
return $this->error(__('Missing required fields: member_id'));
|
||||
}
|
||||
|
||||
$username = strval($m['login'] ?? '');
|
||||
$yesterdayWinLossNet = $m['yesterday_total_w'] ?? 0;
|
||||
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
|
||||
$lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
|
||||
$lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;
|
||||
|
||||
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
|
||||
if ($exists) {
|
||||
$results[] = [
|
||||
'user_id' => $playxUserId,
|
||||
'deduped' => true,
|
||||
'accepted' => true,
|
||||
'message' => __('Duplicate input'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
MallDailyPush::create([
|
||||
'user_id' => $playxUserId,
|
||||
'date' => $date,
|
||||
'username' => $username,
|
||||
'yesterday_win_loss_net' => $yesterdayWinLossNet,
|
||||
'yesterday_total_deposit' => $yesterdayTotalDeposit,
|
||||
'lifetime_total_deposit' => $lifetimeTotalDeposit,
|
||||
'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$newLocked = 0;
|
||||
if ($yesterdayWinLossNet < 0) {
|
||||
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
|
||||
}
|
||||
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
|
||||
|
||||
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
|
||||
if (!$asset) {
|
||||
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
|
||||
}
|
||||
|
||||
if ($asset->today_limit_date !== $date) {
|
||||
$asset->today_claimed = 0;
|
||||
$asset->today_limit_date = $date;
|
||||
}
|
||||
|
||||
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
|
||||
$asset->today_limit = $todayLimit;
|
||||
$asset->playx_user_id = $playxUserId;
|
||||
|
||||
$uname = trim($username);
|
||||
if ($uname !== '') {
|
||||
$asset->username = $uname;
|
||||
}
|
||||
$asset->save();
|
||||
|
||||
Db::commit();
|
||||
|
||||
$results[] = [
|
||||
'user_id' => $playxUserId,
|
||||
'deduped' => false,
|
||||
'accepted' => true,
|
||||
'message' => __('Ok'),
|
||||
];
|
||||
$allDeduped = false;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'request_id' => $requestId,
|
||||
'accepted' => true,
|
||||
'deduped' => $allDeduped,
|
||||
'message' => $allDeduped ? __('Duplicate input') : __('Ok'),
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 旧版单条上报格式(兼容)=====
|
||||
$requestId = $body['request_id'] ?? '';
|
||||
$date = $body['date'] ?? '';
|
||||
$playxUserId = strval($body['user_id'] ?? '');
|
||||
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
|
||||
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
|
||||
|
||||
if ($requestId === '' || $date === '' || $playxUserId === '') {
|
||||
return $this->error(__('Missing required fields: request_id, date, user_id'));
|
||||
}
|
||||
|
||||
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
|
||||
if ($exists) {
|
||||
return $this->success('', [
|
||||
'request_id' => $requestId,
|
||||
'accepted' => true,
|
||||
'deduped' => true,
|
||||
'message' => __('Duplicate input'),
|
||||
]);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
MallDailyPush::create([
|
||||
'user_id' => $playxUserId,
|
||||
'date' => $date,
|
||||
'username' => $body['username'] ?? '',
|
||||
'yesterday_win_loss_net' => $yesterdayWinLossNet,
|
||||
'yesterday_total_deposit' => $yesterdayTotalDeposit,
|
||||
'lifetime_total_deposit' => $body['lifetime_total_deposit'] ?? 0,
|
||||
'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$newLocked = 0;
|
||||
$returnRatio = config('playx.return_ratio', 0.1);
|
||||
$unlockRatio = config('playx.unlock_ratio', 0.1);
|
||||
if ($yesterdayWinLossNet < 0) {
|
||||
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
|
||||
}
|
||||
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
|
||||
|
||||
$asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? ''));
|
||||
if (!$asset) {
|
||||
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
|
||||
}
|
||||
|
||||
if ($asset->today_limit_date !== $date) {
|
||||
$asset->today_claimed = 0;
|
||||
$asset->today_limit_date = $date;
|
||||
}
|
||||
|
||||
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
|
||||
$asset->today_limit = $todayLimit;
|
||||
$asset->playx_user_id = $playxUserId;
|
||||
|
||||
$uname = trim(strval($body['username'] ?? ''));
|
||||
if ($uname !== '') {
|
||||
$asset->username = $uname;
|
||||
}
|
||||
$asset->save();
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'request_id' => $requestId,
|
||||
'accepted' => true,
|
||||
'deduped' => false,
|
||||
'message' => __('Ok'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 验证 - POST /api/v1/playx/verify-token
|
||||
* 配置 playx.verify_token_local_only=true 时仅本地校验 token(不请求 PlayX)。
|
||||
*/
|
||||
public function verifyToken(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
|
||||
if ($token === '') {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
if (config('playx.verify_token_local_only', false)) {
|
||||
return $this->verifyTokenLocal($token);
|
||||
}
|
||||
|
||||
$baseUrl = config('playx.api.base_url', '');
|
||||
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
|
||||
if ($baseUrl === '') {
|
||||
return $this->error(__('PlayX API not configured'));
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client([
|
||||
'base_uri' => rtrim($baseUrl, '/') . '/',
|
||||
'timeout' => 10,
|
||||
]);
|
||||
$res = $client->post($verifyUrl, [
|
||||
'json' => [
|
||||
'request_id' => 'mall_' . uniqid(),
|
||||
'token' => $token,
|
||||
],
|
||||
]);
|
||||
$code = $res->getStatusCode();
|
||||
$data = json_decode(strval($res->getBody()), true);
|
||||
if ($code !== 200 || empty($data['user_id'])) {
|
||||
$remoteMsg = $data['message'] ?? '';
|
||||
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
|
||||
|
||||
return $this->error($msg, null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$userId = strval($data['user_id']);
|
||||
$username = strval($data['username'] ?? '');
|
||||
|
||||
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
|
||||
if (!empty($data['token_expire_at'])) {
|
||||
$ts = strtotime(strval($data['token_expire_at']));
|
||||
if ($ts !== false && $ts > 0) {
|
||||
$expireAt = intval($ts);
|
||||
}
|
||||
}
|
||||
|
||||
$sessionId = bin2hex(random_bytes(16));
|
||||
MallSession::create([
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
'username' => $username,
|
||||
'expire_time' => $expireAt,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->success('', [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
'username' => $username,
|
||||
'token_expire_at' => date('c', $expireAt),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地校验 temLogin 等写入的商城 token(类型 muser),写入 mall_session
|
||||
*/
|
||||
private function verifyTokenLocal(string $token): Response
|
||||
{
|
||||
$tokenData = Token::get($token);
|
||||
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$tokenType = strval($tokenData['type'] ?? '');
|
||||
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$assetId = intval($tokenData['user_id'] ?? 0);
|
||||
if ($assetId <= 0) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$asset = MallUserAsset::where('id', $assetId)->find();
|
||||
if (!$asset) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$playxUserId = strval($asset->playx_user_id ?? '');
|
||||
if ($playxUserId === '') {
|
||||
$playxUserId = strval($assetId);
|
||||
}
|
||||
|
||||
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
|
||||
$sessionId = bin2hex(random_bytes(16));
|
||||
MallSession::create([
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $playxUserId,
|
||||
'username' => strval($asset->username ?? ''),
|
||||
'expire_time' => $expireAt,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return $this->success('', [
|
||||
'session_id' => $sessionId,
|
||||
'user_id' => $playxUserId,
|
||||
'username' => strval($asset->username ?? ''),
|
||||
'token_expire_at' => date('c', $expireAt),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资产
|
||||
* GET /api/v1/playx/assets?user_id=xxx
|
||||
*/
|
||||
public function assets(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset) {
|
||||
return $this->success('', [
|
||||
'locked_points' => 0,
|
||||
'available_points' => 0,
|
||||
'today_limit' => 0,
|
||||
'today_claimed' => 0,
|
||||
'withdrawable_cash' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$ratio = config('playx.points_to_cash_ratio', 0.1);
|
||||
$withdrawableCash = round($asset->available_points * $ratio, 2);
|
||||
|
||||
return $this->success('', [
|
||||
'locked_points' => $asset->locked_points,
|
||||
'available_points' => $asset->available_points,
|
||||
'today_limit' => $asset->today_limit,
|
||||
'today_claimed' => $asset->today_claimed,
|
||||
'withdrawable_cash' => $withdrawableCash,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取
|
||||
* POST /api/v1/playx/claim
|
||||
*/
|
||||
public function claim(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$claimRequestId = strval($request->post('claim_request_id', ''));
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
if ($claimRequestId === '') {
|
||||
return $this->error(__('claim_request_id and user_id/session_id required'));
|
||||
}
|
||||
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
|
||||
return $this->error(__('User asset not found'));
|
||||
}
|
||||
$playxUserId = strval($asset->playx_user_id);
|
||||
|
||||
$exists = MallClaimLog::where('claim_request_id', $claimRequestId)->find();
|
||||
if ($exists) {
|
||||
return $this->success('', $this->formatAsset($asset));
|
||||
}
|
||||
|
||||
$todayLimitDate = date('Y-m-d');
|
||||
if ($asset->today_limit_date !== $todayLimitDate) {
|
||||
$asset->today_claimed = 0;
|
||||
$asset->today_limit_date = $todayLimitDate;
|
||||
}
|
||||
|
||||
$remain = $asset->today_limit - $asset->today_claimed;
|
||||
if ($asset->locked_points <= 0 || $remain <= 0) {
|
||||
return $this->error(__('No points to claim or limit reached'));
|
||||
}
|
||||
|
||||
$canClaim = min($asset->locked_points, $remain);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
MallClaimLog::create([
|
||||
'claim_request_id' => $claimRequestId,
|
||||
'user_id' => $playxUserId,
|
||||
'claimed_amount' => $canClaim,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$asset->locked_points -= $canClaim;
|
||||
$asset->available_points += $canClaim;
|
||||
$asset->today_claimed += $canClaim;
|
||||
$asset->save();
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$asset->refresh();
|
||||
return $this->success(__('Claim success'), $this->formatAsset($asset));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品列表
|
||||
* GET /api/v1/playx/items?type=BONUS|PHYSICAL|WITHDRAW
|
||||
*/
|
||||
public function items(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$type = $request->get('type', '');
|
||||
$typeMap = ['BONUS' => 1, 'PHYSICAL' => 2, 'WITHDRAW' => 3];
|
||||
$query = MallItem::where('status', 1);
|
||||
if ($type !== '' && isset($typeMap[$type])) {
|
||||
$query->where('type', $typeMap[$type]);
|
||||
}
|
||||
$list = $query->order('sort', 'asc')->select();
|
||||
|
||||
return $this->success('', ['list' => $list->toArray()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 红利兑换
|
||||
* POST /api/v1/playx/bonus/redeem
|
||||
*/
|
||||
public function bonusRedeem(Request $request): Response
|
||||
{
|
||||
return $this->redeemBonus($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实物兑换
|
||||
* POST /api/v1/playx/physical/redeem
|
||||
*/
|
||||
public function physicalRedeem(Request $request): Response
|
||||
{
|
||||
return $this->redeemPhysical($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提现申请
|
||||
* POST /api/v1/playx/withdraw/apply
|
||||
*/
|
||||
public function withdrawApply(Request $request): Response
|
||||
{
|
||||
return $this->redeemWithdraw($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表
|
||||
* GET /api/v1/playx/orders?user_id=xxx
|
||||
*/
|
||||
public function orders(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
|
||||
return $this->success('', ['list' => []]);
|
||||
}
|
||||
|
||||
$list = MallOrder::where('user_id', strval($asset->playx_user_id))
|
||||
->with(['mallItem'])
|
||||
->order('id', 'desc')
|
||||
->limit(100)
|
||||
->select();
|
||||
|
||||
return $this->success('', ['list' => $list->toArray()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收货地址列表
|
||||
* GET /api/v1/playx/address/list?session_id=xxx
|
||||
*/
|
||||
public function addressList(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$list = MallAddress::where('playx_user_asset_id', $assetId)
|
||||
->order('default_setting', 'desc')
|
||||
->order('id', 'desc')
|
||||
->select();
|
||||
|
||||
return $this->success('', ['list' => $list->toArray()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收货地址(可设置默认)
|
||||
* POST /api/v1/playx/address/add
|
||||
*/
|
||||
public function addressAdd(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$phone = trim(strval($request->post('phone', '')));
|
||||
$receiverName = trim(strval($request->post('receiver_name', '')));
|
||||
$region = $request->post('region', '');
|
||||
$detailAddress = trim(strval($request->post('detail_address', '')));
|
||||
$defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
|
||||
|
||||
if ($phone === '' || $receiverName === '' || $detailAddress === '' || $region === '' || $region === null) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($defaultSetting === 1) {
|
||||
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
|
||||
}
|
||||
|
||||
$created = MallAddress::create([
|
||||
'playx_user_asset_id' => $assetId,
|
||||
'receiver_name' => $receiverName,
|
||||
'phone' => $phone,
|
||||
'region' => $region,
|
||||
'detail_address' => $detailAddress,
|
||||
'default_setting' => $defaultSetting,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'id' => $created ? $created->id : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改收货地址(包含设置默认地址)
|
||||
* POST /api/v1/playx/address/edit
|
||||
*/
|
||||
public function addressEdit(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$id = intval($request->post('id', 0));
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if ($request->post('phone', null) !== null) {
|
||||
$updates['phone'] = trim(strval($request->post('phone', '')));
|
||||
}
|
||||
if ($request->post('receiver_name', null) !== null) {
|
||||
$updates['receiver_name'] = trim(strval($request->post('receiver_name', '')));
|
||||
}
|
||||
if ($request->post('region', null) !== null) {
|
||||
$updates['region'] = $request->post('region', '');
|
||||
}
|
||||
if ($request->post('detail_address', null) !== null) {
|
||||
$updates['detail_address'] = trim(strval($request->post('detail_address', '')));
|
||||
}
|
||||
if ($request->post('default_setting', null) !== null) {
|
||||
$updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
return $this->success('', ['updated' => false]);
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if (isset($updates['default_setting']) && $updates['default_setting'] === 1) {
|
||||
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
|
||||
}
|
||||
$updates['update_time'] = time();
|
||||
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->update($updates);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('', ['updated' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收货地址
|
||||
* POST /api/v1/playx/address/delete
|
||||
*/
|
||||
public function addressDelete(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$id = intval($request->post('id', 0));
|
||||
if ($id <= 0) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
|
||||
if (!$row) {
|
||||
return $this->error(__('Record not found'));
|
||||
}
|
||||
|
||||
$wasDefault = intval($row->default_setting ?? 0) === 1;
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->delete();
|
||||
|
||||
if ($wasDefault) {
|
||||
$fallback = MallAddress::where('playx_user_asset_id', $assetId)->order('id', 'desc')->find();
|
||||
if ($fallback) {
|
||||
$fallback->default_setting = 1;
|
||||
$fallback->update_time = time();
|
||||
$fallback->save();
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success('', ['deleted' => true]);
|
||||
}
|
||||
|
||||
private function formatAsset(?MallUserAsset $asset): array
|
||||
{
|
||||
if (!$asset) {
|
||||
return [
|
||||
'locked_points' => 0,
|
||||
'available_points' => 0,
|
||||
'today_limit' => 0,
|
||||
'today_claimed' => 0,
|
||||
'withdrawable_cash' => 0,
|
||||
];
|
||||
}
|
||||
$ratio = config('playx.points_to_cash_ratio', 0.1);
|
||||
return [
|
||||
'locked_points' => $asset->locked_points,
|
||||
'available_points' => $asset->available_points,
|
||||
'today_limit' => $asset->today_limit,
|
||||
'today_claimed' => $asset->today_claimed,
|
||||
'withdrawable_cash' => round($asset->available_points * $ratio, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function redeemBonus(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$itemId = intval($request->post('item_id', 0));
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
if ($itemId <= 0) {
|
||||
return $this->error(__('item_id and user_id/session_id required'));
|
||||
}
|
||||
|
||||
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_BONUS)->where('status', 1)->find();
|
||||
if (!$item) {
|
||||
return $this->error(__('Item not found or not available'));
|
||||
}
|
||||
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
|
||||
return $this->error(__('Insufficient points'));
|
||||
}
|
||||
$playxUserId = strval($asset->playx_user_id);
|
||||
|
||||
$multiplier = intval($item->multiplier ?? 0);
|
||||
if ($multiplier <= 0) {
|
||||
$multiplier = 1;
|
||||
}
|
||||
$amount = floatval($item->amount ?? 0);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$asset->available_points -= $item->score;
|
||||
$asset->save();
|
||||
|
||||
$orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
$order = MallOrder::create([
|
||||
'user_id' => $playxUserId,
|
||||
'type' => MallOrder::TYPE_BONUS,
|
||||
'status' => MallOrder::STATUS_PENDING,
|
||||
'mall_item_id' => $item->id,
|
||||
'points_cost' => $item->score,
|
||||
'amount' => $amount,
|
||||
'multiplier' => $multiplier,
|
||||
'external_transaction_id' => $orderNo,
|
||||
'grant_status' => MallOrder::GRANT_NOT_SENT,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
'start_time' => date('Y-m-d H:i:s', time()),
|
||||
'end_time' => date('Y-m-d H:i:s', time()+86400*3),
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$baseUrl = config('playx.api.base_url', '');
|
||||
if ($baseUrl !== '') {
|
||||
$this->callPlayxBonusGrant($order, $item, $playxUserId);
|
||||
}
|
||||
|
||||
return $this->success(__('Redeem submitted, please wait about 10 minutes'), [
|
||||
'order_id' => $order->id,
|
||||
'status' => 'PENDING',
|
||||
]);
|
||||
}
|
||||
|
||||
private function redeemPhysical(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$itemId = intval($request->post('item_id', 0));
|
||||
$addressId = intval($request->post('address_id', 0));
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($itemId <= 0 || $addressId <= 0) {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$addrRow = MallAddress::where('id', $addressId)->where('playx_user_asset_id', $assetId)->find();
|
||||
if (!$addrRow) {
|
||||
return $this->error(__('Shipping address not found'));
|
||||
}
|
||||
$snapshot = MallAddress::snapshotForPhysicalOrder($addrRow);
|
||||
if ($snapshot['receiver_phone'] === '' || $snapshot['receiver_address'] === '' || $snapshot['receiver_name'] === '') {
|
||||
return $this->error(__('Missing required fields'));
|
||||
}
|
||||
|
||||
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_PHYSICAL)->where('status', 1)->find();
|
||||
if (!$item) {
|
||||
return $this->error(__('Item not found or not available'));
|
||||
}
|
||||
if (isset($item->stock) && $item->stock < 1) {
|
||||
return $this->error(__('Out of stock'));
|
||||
}
|
||||
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
|
||||
return $this->error(__('Insufficient points'));
|
||||
}
|
||||
$playxUserId = strval($asset->playx_user_id);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$asset->available_points -= $item->score;
|
||||
$asset->save();
|
||||
|
||||
MallOrder::create([
|
||||
'user_id' => $playxUserId,
|
||||
'type' => MallOrder::TYPE_PHYSICAL,
|
||||
'status' => MallOrder::STATUS_PENDING,
|
||||
'mall_item_id' => $item->id,
|
||||
'points_cost' => $item->score,
|
||||
'mall_address_id' => $addressId,
|
||||
'receiver_name' => $snapshot['receiver_name'],
|
||||
'receiver_phone' => $snapshot['receiver_phone'],
|
||||
'receiver_address' => $snapshot['receiver_address'],
|
||||
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
if (isset($item->stock)) {
|
||||
$item->stock -= 1;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Redeem success'));
|
||||
}
|
||||
|
||||
private function redeemWithdraw(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeApi($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$itemId = intval($request->post('item_id', 0));
|
||||
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
|
||||
if ($assetId === null) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
if ($itemId <= 0) {
|
||||
return $this->error(__('item_id and user_id/session_id required'));
|
||||
}
|
||||
|
||||
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_WITHDRAW)->where('status', 1)->find();
|
||||
if (!$item) {
|
||||
return $this->error(__('Item not found or not available'));
|
||||
}
|
||||
|
||||
$asset = $this->getAssetById($assetId);
|
||||
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
|
||||
return $this->error(__('Insufficient points'));
|
||||
}
|
||||
$playxUserId = strval($asset->playx_user_id);
|
||||
|
||||
$multiplier = intval($item->multiplier ?? 0);
|
||||
if ($multiplier <= 0) {
|
||||
$multiplier = 1;
|
||||
}
|
||||
$amount = floatval($item->amount ?? 0);
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$asset->available_points -= $item->score;
|
||||
$asset->save();
|
||||
|
||||
$orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
$order = MallOrder::create([
|
||||
'user_id' => $playxUserId,
|
||||
'type' => MallOrder::TYPE_WITHDRAW,
|
||||
'status' => MallOrder::STATUS_PENDING,
|
||||
'mall_item_id' => $item->id,
|
||||
'points_cost' => $item->score,
|
||||
'amount' => $amount,
|
||||
'multiplier' => $multiplier,
|
||||
'external_transaction_id' => $orderNo,
|
||||
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
|
||||
'order_id' => $order->id,
|
||||
'status' => 'PENDING',
|
||||
]);
|
||||
}
|
||||
|
||||
private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
|
||||
{
|
||||
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
|
||||
$url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
|
||||
if ($baseUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new \GuzzleHttp\Client(['timeout' => 15]);
|
||||
$res = $client->post($baseUrl . $url, [
|
||||
'json' => [
|
||||
'request_id' => 'mall_bonus_' . uniqid(),
|
||||
'externalTransactionId' => $order->external_transaction_id,
|
||||
'user_id' => $userId,
|
||||
'amount' => $order->amount,
|
||||
'rewardName' => $item->title ?? '',
|
||||
'category' => $item->category ?? 'daily',
|
||||
'categoryTitle' => $item->category_title ?? '',
|
||||
'multiplier' => $order->multiplier,
|
||||
],
|
||||
]);
|
||||
$data = json_decode(strval($res->getBody()), true);
|
||||
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
|
||||
$order->playx_transaction_id = $data['playx_transaction_id'] ?? '';
|
||||
$order->grant_status = MallOrder::GRANT_ACCEPTED;
|
||||
$order->save();
|
||||
} else {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->fail_reason = $data['message'] ?? 'unknown';
|
||||
$order->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->fail_reason = $e->getMessage();
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,10 +12,46 @@ return [
|
||||
'Please login first' => 'Please login first!',
|
||||
'You have no permission' => 'No permission to operate!',
|
||||
'Captcha error' => 'Captcha error!',
|
||||
'Parameter %s can not be empty' => 'Parameter %s can not be empty',
|
||||
'Parameter signature/secret/agent_id/time can not be empty' => 'Parameter signature/secret/agent_id/time can not be empty',
|
||||
'Invalid timestamp' => 'Invalid timestamp',
|
||||
'Timestamp expired' => 'Timestamp expired',
|
||||
'Invalid agent or secret' => 'Invalid agent or secret',
|
||||
'Invalid signature' => 'Invalid signature',
|
||||
'Agent not found' => 'Agent not found',
|
||||
'Temp login is disabled' => 'Temp login is disabled',
|
||||
'Failed to create temp account' => 'Failed to allocate a unique phone number, please retry later',
|
||||
'Parameter username can not be empty' => 'Parameter username can not be empty',
|
||||
'Token expiration' => 'Session expired, please login again.',
|
||||
// Member center account
|
||||
'Data updated successfully~' => 'Data updated successfully~',
|
||||
'Password has been changed~' => 'Password has been changed~',
|
||||
'Password has been changed, please login again~' => 'Password has been changed, please login again~',
|
||||
'already exists' => 'already exists',
|
||||
'nicknameChsDash' => 'Usernames can only be Chinese characters, letters, numbers, underscores_ and dashes-.',
|
||||
// PlayX API v1 /api/v1/*
|
||||
'Invalid token' => 'Invalid or expired token',
|
||||
'PlayX API not configured' => 'PlayX API is not configured',
|
||||
'Duplicate input' => 'Duplicate submission',
|
||||
'Ok' => 'OK',
|
||||
'Failed to map playx user to mall user' => 'Failed to map PlayX user to mall user',
|
||||
'Missing required fields: request_id, date, user_id' => 'Missing required fields: request_id, date, user_id',
|
||||
'Missing required fields: report_date, member' => 'Missing required fields: report_date, member',
|
||||
'Missing required fields: member_id' => 'Missing required fields: member_id',
|
||||
'Daily push signature missing or incomplete' => 'Daily push signature missing or incomplete',
|
||||
'Daily push signature verification failed' => 'Daily push signature verification failed',
|
||||
'Failed to ensure PlayX user asset' => 'Failed to ensure PlayX user asset',
|
||||
'claim_request_id and user_id/session_id required' => 'claim_request_id and user_id/session_id/token are required',
|
||||
'User asset not found' => 'User asset not found',
|
||||
'No points to claim or limit reached' => 'No points to claim or daily limit reached',
|
||||
'Claim success' => 'Claim successful',
|
||||
'item_id and user_id/session_id required' => 'item_id and user_id/session_id/token are required',
|
||||
'Item not found or not available' => 'Item not found or not available',
|
||||
'Insufficient points' => 'Insufficient points',
|
||||
'Redeem submitted, please wait about 10 minutes' => 'Redeem submitted, please wait about 10 minutes',
|
||||
'Missing required fields' => 'Missing required fields',
|
||||
'Shipping address not found' => 'Shipping address not found',
|
||||
'Out of stock' => 'Out of stock',
|
||||
'Redeem success' => 'Redeem successful',
|
||||
'Withdraw submitted, please wait about 10 minutes' => 'Withdrawal submitted, please wait about 10 minutes',
|
||||
];
|
||||
@@ -42,6 +42,16 @@ return [
|
||||
'Please login first' => '请先登录!',
|
||||
'You have no permission' => '没有权限操作!',
|
||||
'Parameter error' => '参数错误!',
|
||||
'Parameter %s can not be empty' => '参数%s不能为空',
|
||||
'Parameter signature/secret/agent_id/time can not be empty' => '参数 signature/secret/agent_id/time 不能为空',
|
||||
'Invalid timestamp' => '无效的时间戳',
|
||||
'Timestamp expired' => '时间戳已过期',
|
||||
'Invalid agent or secret' => '代理或密钥无效',
|
||||
'Invalid signature' => '签名无效',
|
||||
'Agent not found' => '代理不存在',
|
||||
'Temp login is disabled' => '临时登录已关闭',
|
||||
'Failed to create temp account' => '无法生成唯一手机号,请稍后重试',
|
||||
'Parameter username can not be empty' => '参数 username 不能为空',
|
||||
'Token expiration' => '登录态过期,请重新登录!',
|
||||
'Captcha error' => '验证码错误!',
|
||||
// 会员中心 account
|
||||
@@ -50,4 +60,30 @@ return [
|
||||
'Password has been changed, please login again~' => '密码已修改,请重新登录~',
|
||||
'already exists' => '已存在',
|
||||
'nicknameChsDash' => '用户名只能是汉字、字母、数字和下划线_及破折号-',
|
||||
// PlayX API v1 /api/v1/*
|
||||
'Invalid token' => '令牌无效或已过期',
|
||||
'PlayX API not configured' => '未配置 PlayX 接口地址',
|
||||
'Duplicate input' => '重复提交',
|
||||
'Ok' => '成功',
|
||||
'Failed to map playx user to mall user' => '无法将 PlayX 用户关联到商城用户',
|
||||
'Missing required fields: request_id, date, user_id' => '缺少必填字段:request_id、date、user_id',
|
||||
'Missing required fields: report_date, member' => '缺少必填字段:report_date、member',
|
||||
'Missing required fields: member_id' => '缺少必填字段:member_id',
|
||||
'Daily push signature missing or incomplete' => '签名缺失或不完整',
|
||||
'Daily push signature verification failed' => '签名校验失败',
|
||||
'Failed to ensure PlayX user asset' => '创建/映射用户资产失败',
|
||||
'claim_request_id and user_id/session_id required' => '缺少 claim_request_id,或未提供有效的 user_id/session_id/token',
|
||||
'User asset not found' => '未找到用户资产',
|
||||
'No points to claim or limit reached' => '暂无可领取积分或已达今日上限',
|
||||
'Claim success' => '领取成功',
|
||||
'item_id and user_id/session_id required' => '缺少 item_id,或未提供有效的 user_id/session_id/token',
|
||||
'Item not found or not available' => '商品不存在或已下架',
|
||||
'Insufficient points' => '积分不足',
|
||||
'Redeem submitted, please wait about 10 minutes' => '兑换已提交,请等待约 10 分钟',
|
||||
'Missing required fields' => '缺少必填字段',
|
||||
'Out of stock' => '库存不足',
|
||||
'Record not found' => '记录不存在',
|
||||
'Shipping address not found' => '收货地址不存在',
|
||||
'Redeem success' => '兑换成功',
|
||||
'Withdraw submitted, please wait about 10 minutes' => '提现申请已提交,请等待约 10 分钟',
|
||||
];
|
||||
@@ -80,6 +80,11 @@ class Backend extends Api
|
||||
*/
|
||||
protected bool $dataLimitFieldAutoFill = true;
|
||||
|
||||
/**
|
||||
* 添加时自动填充 admin_id(当模型有 admin_id 字段但无数据权限限制时使用)
|
||||
*/
|
||||
protected bool $autoFillAdminId = false;
|
||||
|
||||
/**
|
||||
* 查看请求返回的主表字段
|
||||
*/
|
||||
@@ -233,6 +238,7 @@ class Backend extends Api
|
||||
$limit = is_numeric($limit) ? intval($limit) : 10;
|
||||
$search = $this->request->get('search', []);
|
||||
$search = is_array($search) ? $search : [];
|
||||
$search = $this->filterSearchArray($search);
|
||||
$initKey = $this->request->get('initKey', $pk);
|
||||
$initValue = $this->request->get('initValue', '');
|
||||
$initOperator = $this->request->get('initOperator', 'in');
|
||||
@@ -347,6 +353,14 @@ class Backend extends Api
|
||||
return [$where, $alias, $limit, $this->queryOrderBuilder()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合搜索条件过滤(子类可覆盖,例如去掉已删除的数据库字段)
|
||||
*/
|
||||
protected function filterSearchArray(array $search): array
|
||||
{
|
||||
return $search;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询的排序参数构建器
|
||||
*/
|
||||
@@ -357,8 +371,10 @@ class Backend extends Api
|
||||
$order = $order ?: $this->defaultSortField;
|
||||
|
||||
if ($order && is_string($order)) {
|
||||
$order = explode(',', $order);
|
||||
$order = [$order[0] => $order[1] ?? 'asc'];
|
||||
$orderParts = explode(',', $order);
|
||||
$orderField = trim($orderParts[0] ?? '');
|
||||
$orderDir = trim($orderParts[1] ?? 'asc');
|
||||
$order = $orderField !== '' ? [$orderField => $orderDir ?: 'asc'] : [];
|
||||
}
|
||||
if (!$this->orderGuarantee) {
|
||||
$this->orderGuarantee = [$pk => 'desc'];
|
||||
|
||||
67
app/common/library/AgentJwt.php
Normal file
67
app/common/library/AgentJwt.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
|
||||
/**
|
||||
* Agent 鉴权 JWT 工具
|
||||
*/
|
||||
class AgentJwt
|
||||
{
|
||||
public const ALG = 'HS256';
|
||||
|
||||
/**
|
||||
* 生成 JWT authtoken
|
||||
* @param array $payload agent_id、admin_id 等
|
||||
* @param int $expire 有效期(秒)
|
||||
*/
|
||||
public static function encode(array $payload, int $expire = 86400): string
|
||||
{
|
||||
$now = time();
|
||||
$payload['iat'] = $now;
|
||||
$payload['exp'] = $now + $expire;
|
||||
$secret = self::getSecret();
|
||||
return JWT::encode($payload, $secret, self::ALG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证 JWT,返回 payload
|
||||
* @return array payload,失败返回空数组
|
||||
*/
|
||||
public static function decode(string $token): array
|
||||
{
|
||||
if ($token === '') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$secret = self::getSecret();
|
||||
$decoded = JWT::decode($token, new Key($secret, self::ALG));
|
||||
return (array) $decoded;
|
||||
} catch (ExpiredException|SignatureInvalidException|\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JWT 是否有效
|
||||
*/
|
||||
public static function verify(string $token): bool
|
||||
{
|
||||
return !empty(self::decode($token));
|
||||
}
|
||||
|
||||
private static function getSecret(): string
|
||||
{
|
||||
$secret = config('buildadmin.agent_auth.jwt_secret', '');
|
||||
if ($secret === '') {
|
||||
$secret = config('buildadmin.token.key', '');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ class Auth extends \ba\Auth
|
||||
public const LOGGED_IN = 'logged in';
|
||||
public const TOKEN_TYPE = 'user';
|
||||
|
||||
/**
|
||||
* 积分商城用户(mall_playx_user_asset 主键)Token 类型,与会员 user 表区分
|
||||
*/
|
||||
public const TOKEN_TYPE_MALL_USER = 'muser';
|
||||
|
||||
protected bool $loginEd = false;
|
||||
protected string $error = '';
|
||||
protected ?User $model = null;
|
||||
|
||||
85
app/common/library/MallBonusGrantPush.php
Normal file
85
app/common/library/MallBonusGrantPush.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use app\common\model\MallItem;
|
||||
use app\common\model\MallOrder;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
/**
|
||||
* 红利订单调用 PlayX bonus/grant(与定时任务、后台手动推送共用)
|
||||
*/
|
||||
final class MallBonusGrantPush
|
||||
{
|
||||
/**
|
||||
* @return array{ok: bool, message: string, playx_transaction_id: string}
|
||||
*/
|
||||
public static function push(MallOrder $order): array
|
||||
{
|
||||
$baseUrl = rtrim(strval(config('playx.api.base_url', '')), '/');
|
||||
if ($baseUrl === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'PlayX base_url not configured',
|
||||
'playx_transaction_id' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$path = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
|
||||
$url = $baseUrl . $path;
|
||||
|
||||
$item = MallItem::where('id', $order->mall_item_id)->find();
|
||||
$rewardName = $item ? strval($item->title) : '';
|
||||
$category = $item ? strval($item->category) : 'daily';
|
||||
$categoryTitle = $item ? strval($item->category_title) : '';
|
||||
$multiplier = intval($order->multiplier ?? 0);
|
||||
if ($multiplier <= 0) {
|
||||
$multiplier = 1;
|
||||
}
|
||||
|
||||
$client = new Client([
|
||||
'timeout' => 20,
|
||||
'http_errors' => false,
|
||||
]);
|
||||
|
||||
$requestId = 'mall_bonus_' . uniqid();
|
||||
|
||||
try {
|
||||
$res = $client->post($url, [
|
||||
'json' => [
|
||||
'request_id' => $requestId,
|
||||
'externalTransactionId' => $order->external_transaction_id,
|
||||
'user_id' => $order->user_id,
|
||||
'amount' => $order->amount,
|
||||
'rewardName' => $rewardName,
|
||||
'category' => $category,
|
||||
'categoryTitle' => $categoryTitle,
|
||||
'multiplier' => $multiplier,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode(strval($res->getBody()), true) ?? [];
|
||||
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => '',
|
||||
'playx_transaction_id' => strval($data['playx_transaction_id'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => strval($data['message'] ?? 'PlayX bonus grant not accepted'),
|
||||
'playx_transaction_id' => '',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'playx_transaction_id' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/common/library/PlayxInboundJwt.php
Normal file
48
app/common/library/PlayxInboundJwt.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
use Firebase\JWT\SignatureInvalidException;
|
||||
|
||||
/**
|
||||
* PlayX / 合作方回调:校验 Authorization: Bearer JWT(HS256)
|
||||
*/
|
||||
class PlayxInboundJwt
|
||||
{
|
||||
public const ALG = 'HS256';
|
||||
|
||||
/**
|
||||
* 从 Authorization 头解析 Bearer token 并校验签名与有效期
|
||||
*/
|
||||
public static function verifyBearer(string $authorizationHeader, string $secret): bool
|
||||
{
|
||||
if ($secret === '') {
|
||||
return false;
|
||||
}
|
||||
$token = self::extractBearer($authorizationHeader);
|
||||
if ($token === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JWT::decode($token, new Key($secret, self::ALG));
|
||||
|
||||
return true;
|
||||
} catch (ExpiredException|SignatureInvalidException|\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function extractBearer(string $authorizationHeader): string
|
||||
{
|
||||
if (preg_match('/Bearer\s+(\S+)/i', $authorizationHeader, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,15 @@ use Exception;
|
||||
*/
|
||||
class TokenExpirationException extends Exception
|
||||
{
|
||||
protected array $data = [];
|
||||
|
||||
public function __construct(
|
||||
protected string $message = '',
|
||||
protected int $code = 409,
|
||||
protected array $data = [],
|
||||
string $message = '',
|
||||
int $code = 409,
|
||||
array $data = [],
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
$this->data = $data;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
|
||||
96
app/common/library/token/driver/Redis.php
Normal file
96
app/common/library/token/driver/Redis.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\token\driver;
|
||||
|
||||
use app\common\library\token\Driver;
|
||||
use support\Redis as RedisConnection;
|
||||
|
||||
/**
|
||||
* Token Redis 驱动(提升鉴权接口等高频调用的性能)
|
||||
* @see Driver
|
||||
*/
|
||||
class Redis extends Driver
|
||||
{
|
||||
protected array $options = [];
|
||||
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$this->options = array_merge([
|
||||
'prefix' => 'tk:',
|
||||
'expire' => 2592000,
|
||||
], $options);
|
||||
$this->handler = RedisConnection::connection('default');
|
||||
}
|
||||
|
||||
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
|
||||
{
|
||||
if ($expire === null) {
|
||||
$expire = $this->options['expire'] ?? 2592000;
|
||||
}
|
||||
$expireTime = $expire !== 0 ? time() + $expire : 0;
|
||||
$key = $this->getKey($token);
|
||||
$data = [
|
||||
'token' => $token,
|
||||
'type' => $type,
|
||||
'user_id' => $userId,
|
||||
'create_time' => time(),
|
||||
'expire_time' => $expireTime,
|
||||
];
|
||||
$ttl = $expire !== 0 ? $expire : 365 * 86400;
|
||||
$this->handler->setEx($key, $ttl, json_encode($data));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function get(string $token): array
|
||||
{
|
||||
$key = $this->getKey($token);
|
||||
$raw = $this->handler->get($key);
|
||||
if ($raw === false || $raw === null) {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function check(string $token, string $type, int $userId): bool
|
||||
{
|
||||
$data = $this->get($token);
|
||||
if (!$data || ($data['expire_time'] && $data['expire_time'] <= time())) {
|
||||
return false;
|
||||
}
|
||||
return $data['type'] === $type && (int) $data['user_id'] === $userId;
|
||||
}
|
||||
|
||||
public function delete(string $token): bool
|
||||
{
|
||||
$this->handler->del($this->getKey($token));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clear(string $type, int $userId): bool
|
||||
{
|
||||
$pattern = $this->options['prefix'] . '*';
|
||||
$keys = $this->handler->keys($pattern);
|
||||
foreach ($keys as $key) {
|
||||
$raw = $this->handler->get($key);
|
||||
if ($raw !== false && $raw !== null) {
|
||||
$data = json_decode($raw, true);
|
||||
if (is_array($data) && ($data['type'] ?? '') === $type && (int) ($data['user_id'] ?? 0) === $userId) {
|
||||
$this->handler->del($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getKey(string $token): string
|
||||
{
|
||||
return $this->options['prefix'] . $this->getEncryptedToken($token);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ 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, think-lang',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang, lang, server',
|
||||
];
|
||||
$origin = $request->header('origin');
|
||||
if (is_array($origin)) {
|
||||
|
||||
@@ -11,6 +11,9 @@ use Webman\Http\Response;
|
||||
/**
|
||||
* 加载控制器语言包中间件(Webman 迁移版,等价 ThinkPHP LoadLangPack)
|
||||
* 根据当前路由加载对应控制器的语言包到 Translator
|
||||
*
|
||||
* 对外 api/:优先请求头 lang(zh / zh-cn → 中文包 zh-cn,en → 英文包),未传则 think-lang,再默认 zh-cn(不根据浏览器 Accept-Language)
|
||||
* admin/:think-lang → Accept-Language → 配置默认
|
||||
*/
|
||||
class LoadLangPack implements MiddlewareInterface
|
||||
{
|
||||
@@ -25,14 +28,49 @@ class LoadLangPack implements MiddlewareInterface
|
||||
|
||||
protected function loadLang(Request $request): void
|
||||
{
|
||||
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应)
|
||||
// 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值
|
||||
$headerLang = $request->header('think-lang');
|
||||
$path = trim($request->path(), '/');
|
||||
$isApi = str_starts_with($path, 'api/');
|
||||
$isAdmin = str_starts_with($path, 'admin/');
|
||||
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
|
||||
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) {
|
||||
$langSet = str_replace('_', '-', strtolower($headerLang));
|
||||
} else {
|
||||
|
||||
$langSet = null;
|
||||
|
||||
// 对外 API(PlayX、H5 等):优先 lang 请求头,默认中文 zh-cn,不跟随浏览器 Accept-Language
|
||||
if ($isApi) {
|
||||
$langHeader = $request->header('lang');
|
||||
if (is_array($langHeader)) {
|
||||
$langHeader = $langHeader[0] ?? '';
|
||||
}
|
||||
$langHeader = is_string($langHeader) ? trim($langHeader) : '';
|
||||
if ($langHeader !== '') {
|
||||
$langSet = $this->normalizeLangHeader($langHeader, $allowLangList);
|
||||
}
|
||||
}
|
||||
|
||||
// 与后台 Vue 一致的 think-lang(对外 API 在 lang 未设置时仍可生效)
|
||||
if ($langSet === null) {
|
||||
$headerLang = $request->header('think-lang');
|
||||
if (is_array($headerLang)) {
|
||||
$headerLang = $headerLang[0] ?? '';
|
||||
}
|
||||
$headerLang = is_string($headerLang) ? trim($headerLang) : '';
|
||||
if ($headerLang !== '') {
|
||||
$normalized = str_replace('_', '-', strtolower($headerLang));
|
||||
if (in_array($normalized, $allowLangList, true)) {
|
||||
$langSet = $normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($langSet === null) {
|
||||
if ($isApi) {
|
||||
$langSet = 'zh-cn';
|
||||
} elseif ($isAdmin) {
|
||||
$acceptLang = $request->header('accept-language', '');
|
||||
if (is_array($acceptLang)) {
|
||||
$acceptLang = $acceptLang[0] ?? '';
|
||||
}
|
||||
$acceptLang = is_string($acceptLang) ? $acceptLang : '';
|
||||
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
|
||||
$langSet = 'zh-cn';
|
||||
} elseif (preg_match('/^en/i', $acceptLang)) {
|
||||
@@ -40,7 +78,11 @@ class LoadLangPack implements MiddlewareInterface
|
||||
} else {
|
||||
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
|
||||
}
|
||||
$langSet = str_replace('_', '-', strtolower($langSet));
|
||||
$langSet = str_replace('_', '-', strtolower((string) $langSet));
|
||||
} else {
|
||||
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
|
||||
$langSet = str_replace('_', '-', strtolower((string) $langSet));
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前请求的翻译语言,使 __() 和 trans() 使用正确的语言
|
||||
@@ -48,7 +90,6 @@ class LoadLangPack implements MiddlewareInterface
|
||||
locale($langSet);
|
||||
}
|
||||
|
||||
$path = trim($request->path(), '/');
|
||||
$parts = explode('/', $path);
|
||||
$app = $parts[0] ?? 'api';
|
||||
|
||||
@@ -81,4 +122,26 @@ class LoadLangPack implements MiddlewareInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 lang 请求头取值映射为语言包标识(zh / zh-cn → zh-cn,en → en)
|
||||
*/
|
||||
private function normalizeLangHeader(string $raw, array $allowLangList): ?string
|
||||
{
|
||||
$s = str_replace('_', '-', strtolower(trim($raw)));
|
||||
if ($s === '') {
|
||||
return null;
|
||||
}
|
||||
if (in_array($s, $allowLangList, true)) {
|
||||
return $s;
|
||||
}
|
||||
if (str_starts_with($s, 'en')) {
|
||||
return in_array('en', $allowLangList, true) ? 'en' : null;
|
||||
}
|
||||
if ($s === 'zh' || str_starts_with($s, 'zh-')) {
|
||||
return in_array('zh-cn', $allowLangList, true) ? 'zh-cn' : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,4 @@ class Attachment extends Model
|
||||
{
|
||||
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
|
||||
86
app/common/model/MallAddress.php
Normal file
86
app/common/model/MallAddress.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\model\traits\TimestampInteger;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* MallAddress
|
||||
*/
|
||||
class MallAddress extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
// 表名
|
||||
protected $name = 'mall_address';
|
||||
|
||||
// 自动写入时间戳字段
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
// 追加属性
|
||||
protected $append = [
|
||||
'region_text',
|
||||
];
|
||||
|
||||
|
||||
public function getregionAttr($value): array
|
||||
{
|
||||
if ($value === '' || $value === null) return [];
|
||||
if (!is_array($value)) {
|
||||
return explode(',', $value);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function setregionAttr($value): string
|
||||
{
|
||||
return is_array($value) ? implode(',', $value) : $value;
|
||||
}
|
||||
|
||||
public function getregionTextAttr($value, $row): string
|
||||
{
|
||||
if ($row['region'] === '' || $row['region'] === null) return '';
|
||||
$region = $row['region'];
|
||||
$ids = $region;
|
||||
if (!is_array($ids)) {
|
||||
$ids = explode(',', (string) $ids);
|
||||
}
|
||||
$ids = array_values(array_filter(array_map('trim', $ids), static function ($s) {
|
||||
return $s !== '';
|
||||
}));
|
||||
if (empty($ids)) {
|
||||
return '';
|
||||
}
|
||||
$cityNames = \support\think\Db::name('area')->whereIn('id', $ids)->column('name');
|
||||
return $cityNames ? implode(',', $cityNames) : '';
|
||||
}
|
||||
|
||||
public function playxUserAsset(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实物订单收货快照(写入 mall_order.receiver_*,与 mall_address 当前内容一致)
|
||||
*
|
||||
* @return array{receiver_name: string, receiver_phone: string, receiver_address: string}
|
||||
*/
|
||||
public static function snapshotForPhysicalOrder(self $addr): array
|
||||
{
|
||||
$regionText = $addr->region_text ?? '';
|
||||
$parts = array_filter([
|
||||
trim($regionText),
|
||||
trim($addr->detail_address ?? ''),
|
||||
], static function ($s) {
|
||||
return $s !== '';
|
||||
});
|
||||
$receiverAddress = implode(' ', $parts);
|
||||
|
||||
return [
|
||||
'receiver_name' => trim($addr->receiver_name ?? ''),
|
||||
'receiver_phone' => trim($addr->phone ?? ''),
|
||||
'receiver_address' => $receiverAddress,
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/common/model/MallClaimLog.php
Normal file
21
app/common/model/MallClaimLog.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 领取记录(幂等)
|
||||
*/
|
||||
class MallClaimLog extends Model
|
||||
{
|
||||
protected string $name = 'mall_claim_log';
|
||||
|
||||
protected array $type = [
|
||||
'claimed_amount' => 'integer',
|
||||
'create_time' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
24
app/common/model/MallDailyPush.php
Normal file
24
app/common/model/MallDailyPush.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 每日推送数据
|
||||
*/
|
||||
class MallDailyPush extends Model
|
||||
{
|
||||
protected string $name = 'mall_daily_push';
|
||||
|
||||
protected array $type = [
|
||||
'yesterday_win_loss_net' => 'float',
|
||||
'yesterday_total_deposit' => 'float',
|
||||
'lifetime_total_deposit' => 'float',
|
||||
'lifetime_total_withdraw' => 'float',
|
||||
'create_time' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,15 +6,24 @@ use support\think\Model;
|
||||
|
||||
/**
|
||||
* MallItem
|
||||
* type: 1=BONUS(红利), 2=PHYSICAL(实物), 3=WITHDRAW(提现档位)
|
||||
*/
|
||||
class MallItem extends Model
|
||||
{
|
||||
// 表名
|
||||
protected $name = 'mall_item';
|
||||
|
||||
// 自动写入时间戳字段
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
public const TYPE_BONUS = 1;
|
||||
public const TYPE_PHYSICAL = 2;
|
||||
public const TYPE_WITHDRAW = 3;
|
||||
|
||||
protected array $type = [
|
||||
'create_time' => 'integer',
|
||||
'update_time' => 'integer',
|
||||
'amount' => 'float',
|
||||
'multiplier' => 'integer',
|
||||
];
|
||||
|
||||
public function admin(): \think\model\relation\BelongsTo
|
||||
{
|
||||
|
||||
77
app/common/model/MallOrder.php
Normal file
77
app/common/model/MallOrder.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 统一订单
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $user_id
|
||||
* @property string $type
|
||||
* @property string $status
|
||||
* @property int $mall_item_id
|
||||
* @property int $points_cost
|
||||
* @property float $amount
|
||||
* @property int $multiplier
|
||||
* @property string $external_transaction_id
|
||||
* @property string $playx_transaction_id
|
||||
* @property string $grant_status
|
||||
* @property string|null $fail_reason
|
||||
* @property int $retry_count
|
||||
* @property string $reject_reason
|
||||
* @property string $shipping_company
|
||||
* @property string $shipping_no
|
||||
* @property string $receiver_name
|
||||
* @property string $receiver_phone
|
||||
* @property string|null $receiver_address
|
||||
* @property int|null $mall_address_id
|
||||
*/
|
||||
class MallOrder extends Model
|
||||
{
|
||||
protected string $name = 'mall_order';
|
||||
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
public const TYPE_BONUS = 'BONUS';
|
||||
public const TYPE_PHYSICAL = 'PHYSICAL';
|
||||
public const TYPE_WITHDRAW = 'WITHDRAW';
|
||||
|
||||
public const STATUS_PENDING = 'PENDING';
|
||||
public const STATUS_COMPLETED = 'COMPLETED';
|
||||
public const STATUS_SHIPPED = 'SHIPPED';
|
||||
public const STATUS_REJECTED = 'REJECTED';
|
||||
|
||||
public const GRANT_NOT_SENT = 'NOT_SENT';
|
||||
public const GRANT_SENT_PENDING = 'SENT_PENDING';
|
||||
public const GRANT_ACCEPTED = 'ACCEPTED';
|
||||
public const GRANT_FAILED_RETRYABLE = 'FAILED_RETRYABLE';
|
||||
public const GRANT_FAILED_FINAL = 'FAILED_FINAL';
|
||||
|
||||
/** 非红利订单不参与 PlayX/Angpow 推送,固定为该占位值 */
|
||||
public const GRANT_NOT_APPLICABLE = '---';
|
||||
|
||||
protected array $type = [
|
||||
'create_time' => 'integer',
|
||||
'update_time' => 'integer',
|
||||
'points_cost' => 'integer',
|
||||
'amount' => 'float',
|
||||
'multiplier' => 'integer',
|
||||
'retry_count' => 'integer',
|
||||
'mall_address_id' => 'integer',
|
||||
];
|
||||
|
||||
public function mallItem(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MallItem::class, 'mall_item_id', 'id');
|
||||
}
|
||||
|
||||
public function mallAddress(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MallAddress::class, 'mall_address_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\model\traits\TimestampInteger;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
@@ -9,6 +10,8 @@ use support\think\Model;
|
||||
*/
|
||||
class MallPintsOrder extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
// 表名
|
||||
protected $name = 'mall_pints_order';
|
||||
|
||||
@@ -16,8 +19,8 @@ class MallPintsOrder extends Model
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
|
||||
public function mallUser(): \think\model\relation\BelongsTo
|
||||
public function playxUserAsset(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
|
||||
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\model\traits\TimestampInteger;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
@@ -9,6 +10,8 @@ use support\think\Model;
|
||||
*/
|
||||
class MallRedemptionOrder extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
// 表名
|
||||
protected $name = 'mall_redemption_order';
|
||||
|
||||
@@ -16,9 +19,9 @@ class MallRedemptionOrder extends Model
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
|
||||
public function mallUser(): \think\model\relation\BelongsTo
|
||||
public function playxUserAsset(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
|
||||
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
|
||||
}
|
||||
|
||||
public function mallItem(): \think\model\relation\BelongsTo
|
||||
|
||||
24
app/common/model/MallSession.php
Normal file
24
app/common/model/MallSession.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 会话缓存
|
||||
*/
|
||||
class MallSession extends Model
|
||||
{
|
||||
protected string $name = 'mall_session';
|
||||
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
protected array $type = [
|
||||
'create_time' => 'integer',
|
||||
'update_time' => 'integer',
|
||||
'expire_time' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\model\traits\TimestampInteger;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 商城用户模型
|
||||
*/
|
||||
class MallUser extends Model
|
||||
{
|
||||
use TimestampInteger;
|
||||
|
||||
protected string $name = 'mall_user';
|
||||
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
public function admin(): \think\model\relation\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(加密存储)
|
||||
*/
|
||||
public function resetPassword(int $id, string $newPassword): bool
|
||||
{
|
||||
return $this->where(['id' => $id])->update(['password' => hash_password($newPassword)]) !== false;
|
||||
}
|
||||
}
|
||||
85
app/common/model/MallUserAsset.php
Normal file
85
app/common/model/MallUserAsset.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use ba\Random;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
* 用户资产(积分商城用户主表,含登录账号字段)
|
||||
*/
|
||||
class MallUserAsset extends Model
|
||||
{
|
||||
protected string $name = 'mall_user_asset';
|
||||
|
||||
protected bool $autoWriteTimestamp = true;
|
||||
|
||||
protected array $type = [
|
||||
'create_time' => 'integer',
|
||||
'update_time' => 'integer',
|
||||
'locked_points' => 'integer',
|
||||
'available_points' => 'integer',
|
||||
'today_limit' => 'integer',
|
||||
'today_claimed' => 'integer',
|
||||
'admin_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* H5 临时登录:按用户名查找或创建资产行,playx_user_id 使用 mall_{id}
|
||||
*/
|
||||
public static function ensureForUsername(string $username): self
|
||||
{
|
||||
$username = trim($username);
|
||||
$existing = self::where('username', $username)->find();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// 创建用户时:phone 与 username 同值(H5 临时账号)
|
||||
$phone = $username;
|
||||
$phoneExisting = self::where('phone', $phone)->find();
|
||||
if ($phoneExisting) {
|
||||
// 若历史数据存在“手机号=用户名”的行,直接复用;若不一致则拒绝创建,避免污染
|
||||
if (trim((string) ($phoneExisting->username ?? '')) === $username) {
|
||||
return $phoneExisting;
|
||||
}
|
||||
throw new \RuntimeException('Username is already used by another account');
|
||||
}
|
||||
|
||||
$pwd = hash_password(Random::build('alnum', 16));
|
||||
$now = time();
|
||||
$temporaryPlayxId = 'tmp_' . bin2hex(random_bytes(16));
|
||||
$created = self::create([
|
||||
'playx_user_id' => $temporaryPlayxId,
|
||||
'username' => $username,
|
||||
'phone' => $phone,
|
||||
'password' => $pwd,
|
||||
'admin_id' => 0,
|
||||
'locked_points' => 0,
|
||||
'available_points' => 0,
|
||||
'today_limit' => 0,
|
||||
'today_claimed' => 0,
|
||||
'today_limit_date' => null,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
if (!$created) {
|
||||
throw new \RuntimeException('Failed to create mall_user_asset');
|
||||
}
|
||||
|
||||
$id = $created->getKey();
|
||||
$finalPlayxId = 'mall_' . $id;
|
||||
if (self::where('playx_user_id', $finalPlayxId)->where('id', '<>', $id)->find()) {
|
||||
$finalPlayxId = 'mall_' . $id . '_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
$created->playx_user_id = $finalPlayxId;
|
||||
$created->save();
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
// allocateUniquePhone 已废弃:临时登录场景下 phone=用户名
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class User extends Model
|
||||
|
||||
public function getAvatarAttr($value): string
|
||||
{
|
||||
return full_url($value, false, config('buildadmin.default_avatar'));
|
||||
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
|
||||
}
|
||||
|
||||
public function setAvatarAttr($value): string
|
||||
|
||||
31
app/common/validate/MallAddress.php
Normal file
31
app/common/validate/MallAddress.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class MallAddress extends Validate
|
||||
{
|
||||
protected $failException = true;
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
*/
|
||||
protected $rule = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 提示消息
|
||||
*/
|
||||
protected $message = [
|
||||
];
|
||||
|
||||
/**
|
||||
* 验证场景
|
||||
*/
|
||||
protected $scene = [
|
||||
'add' => [],
|
||||
'edit' => [],
|
||||
];
|
||||
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class MallUser extends Validate
|
||||
{
|
||||
protected $failException = true;
|
||||
|
||||
protected $rule = [
|
||||
'username' => 'require',
|
||||
'phone' => 'require',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'username.require' => '用户名不能为空',
|
||||
'phone.require' => '手机号不能为空',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['username', 'phone'],
|
||||
'edit' => ['username', 'phone'],
|
||||
];
|
||||
}
|
||||
@@ -165,6 +165,18 @@ if (!function_exists('get_auth_token')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_agent_jwt_payload')) {
|
||||
/**
|
||||
* 解析 Agent JWT authtoken,返回 payload(agent_id、admin_id 等)
|
||||
* @param string $token authtoken
|
||||
* @return array 成功返回 payload,失败返回空数组
|
||||
*/
|
||||
function get_agent_jwt_payload(string $token): array
|
||||
{
|
||||
return \app\common\library\AgentJwt::decode($token);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('get_controller_path')) {
|
||||
/**
|
||||
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath)
|
||||
@@ -188,7 +200,8 @@ if (!function_exists('get_controller_path')) {
|
||||
$parts = explode('\\', $relative);
|
||||
$path = [];
|
||||
foreach ($parts as $p) {
|
||||
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
|
||||
// 与 admin_rule.name / 前端 path 一致:驼峰首字母小写(如 AdminInfo -> adminInfo),不用 snake_case
|
||||
$path[] = lcfirst($p);
|
||||
}
|
||||
return implode('/', $path);
|
||||
}
|
||||
@@ -204,7 +217,19 @@ if (!function_exists('get_controller_path')) {
|
||||
if (count($parts) < 2) {
|
||||
return $parts[0] ?? null;
|
||||
}
|
||||
return implode('/', array_slice($parts, 1, -1)) ?: $parts[1];
|
||||
// admin/routine.Attachment/index -> routine/attachment(ThinkPHP 风格 URL 中带点的控制器段)
|
||||
$middle = array_slice($parts, 1, -1);
|
||||
$segments = [];
|
||||
foreach ($middle as $m) {
|
||||
if (str_contains($m, '.')) {
|
||||
foreach (explode('.', $m) as $dotPart) {
|
||||
$segments[] = lcfirst($dotPart);
|
||||
}
|
||||
} else {
|
||||
$segments[] = lcfirst($m);
|
||||
}
|
||||
}
|
||||
return $segments !== [] ? implode('/', $segments) : ($parts[1] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
app/process/AngpowImportJobs.php
Normal file
328
app/process/AngpowImportJobs.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\model\MallItem;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use GuzzleHttp\Client;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Angpow 导入推送任务
|
||||
* - 数据源:mall_order(type=BONUS)
|
||||
* - 推送频率:每 30 秒
|
||||
* - 批量:每次最多 100 条(对方文档限制)
|
||||
* - 幂等:merchant_code + report_date 级别签名;每条订单通过 external_transaction_id 在本地控制只推送一次
|
||||
*/
|
||||
class AngpowImportJobs
|
||||
{
|
||||
private const TIMER_SECONDS = 30;
|
||||
private const BATCH_LIMIT = 100;
|
||||
private const MAX_RETRY = 3;
|
||||
|
||||
protected Client $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 确保定时任务只在一个 worker 上运行
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->http = new Client($this->buildGuzzleOptions());
|
||||
|
||||
Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guzzle 默认校验 HTTPS;Windows 未配置 CA 时会出现 cURL error 60。
|
||||
* 优先使用 PLAYX_ANGPOW_IMPORT_CACERT 指向 cacert.pem;否则可按环境关闭校验(仅开发)。
|
||||
*/
|
||||
private function buildGuzzleOptions(): array
|
||||
{
|
||||
$options = [
|
||||
'timeout' => 20,
|
||||
'http_errors' => false,
|
||||
];
|
||||
|
||||
$conf = config('playx.angpow_import');
|
||||
if (!is_array($conf)) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
$caFile = $conf['ca_file'] ?? '';
|
||||
if (is_string($caFile) && $caFile !== '' && is_file($caFile)) {
|
||||
$options['verify'] = $caFile;
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
$verifySsl = $conf['verify_ssl'] ?? true;
|
||||
if ($verifySsl === false) {
|
||||
$options['verify'] = false;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function pushPendingOrders(): void
|
||||
{
|
||||
$conf = config('playx.angpow_import');
|
||||
if (!is_array($conf)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseUrl = $conf['base_url'] ?? '';
|
||||
$path = $conf['path'] ?? '';
|
||||
$merchantCode = $conf['merchant_code'] ?? '';
|
||||
$authKey = $conf['auth_key'] ?? '';
|
||||
|
||||
if (!is_string($baseUrl) || $baseUrl === '' || !is_string($path) || $path === '' || !is_string($merchantCode) || $merchantCode === '') {
|
||||
return;
|
||||
}
|
||||
if (!is_string($authKey) || $authKey === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = rtrim($baseUrl, '/') . $path;
|
||||
|
||||
$orders = MallOrder::where('type', MallOrder::TYPE_BONUS)
|
||||
->whereIn('grant_status', [MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_FAILED_RETRYABLE])
|
||||
->where('status', MallOrder::STATUS_PENDING)
|
||||
->where('retry_count', '<', self::MAX_RETRY)
|
||||
->order('id', 'asc')
|
||||
->limit(self::BATCH_LIMIT)
|
||||
->select();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reportDate = strval(time());
|
||||
$signatureInput = 'merchant_code=' . $merchantCode . '&report_date=' . $reportDate;
|
||||
$signature = $this->buildSignature($signatureInput, $authKey);
|
||||
if ($signature === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'merchant_code' => $merchantCode,
|
||||
'report_date' => $reportDate,
|
||||
'angpow' => [],
|
||||
'currency_visual' => [
|
||||
[
|
||||
'currency' => strval($conf['currency'] ?? 'MYR'),
|
||||
'visual_name' => strval($conf['visual_name'] ?? 'Angpow'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$orderIds = [];
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$row = $this->buildAngpowRow($order);
|
||||
if ($row === null) {
|
||||
// 构造失败:直接标为可重试失败
|
||||
$this->markFailedAttempt($order, 'Build payload failed');
|
||||
continue;
|
||||
}
|
||||
$payload['angpow'][] = $row;
|
||||
$orderIds[] = $order->id;
|
||||
}
|
||||
|
||||
if (empty($payload['angpow'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先标记“已尝试发送”,避免并发重复推送;同时只在这里累加 retry_count(一次推送=一次尝试)
|
||||
$now = time();
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($order->id, $orderIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$retry = $order->retry_count ?? 0;
|
||||
if (!is_int($retry)) {
|
||||
$retry = is_numeric($retry) ? intval($retry) : 0;
|
||||
}
|
||||
$order->retry_count = $retry + 1;
|
||||
$order->grant_status = MallOrder::GRANT_SENT_PENDING;
|
||||
$order->update_time = $now;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
$res = null;
|
||||
$body = '';
|
||||
try {
|
||||
$res = $this->http->post($url, [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Request-Signature' => $signature,
|
||||
],
|
||||
'json' => $payload,
|
||||
]);
|
||||
$body = strval($res->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
// 网络/异常:对这一批订单记一次失败尝试
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, $e->getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
if (!is_array($data)) {
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, 'Invalid response');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$code = $data['code'] ?? null;
|
||||
$message = $data['message'] ?? '';
|
||||
$msg = is_string($message) ? $message : 'Request failed';
|
||||
|
||||
// 成功:code=0
|
||||
if ($code === '0' || $code === 0) {
|
||||
MallOrder::whereIn('id', $orderIds)->update([
|
||||
'grant_status' => MallOrder::GRANT_ACCEPTED,
|
||||
'fail_reason' => null,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 失败:整批视为失败(对方未提供逐条返回)
|
||||
foreach ($orders as $order) {
|
||||
if (!$order instanceof MallOrder) {
|
||||
continue;
|
||||
}
|
||||
$this->markFailedAttempt($order, $msg);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAngpowRow(MallOrder $order): ?array
|
||||
{
|
||||
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
|
||||
if (!$asset) {
|
||||
if (is_string($order->user_id) && ctype_digit($order->user_id)) {
|
||||
$byId = MallUserAsset::where('id', $order->user_id)->find();
|
||||
if ($byId) {
|
||||
$asset = $byId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$asset || !is_string($asset->playx_user_id ?? null) || strval($asset->playx_user_id) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = null;
|
||||
if ($order->mallItem) {
|
||||
$item = $order->mallItem;
|
||||
} else {
|
||||
$item = MallItem::where('id', $order->mall_item_id)->find();
|
||||
}
|
||||
if (!$item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// $createTime = $order->create_time ?? null;
|
||||
// if (!is_int($createTime)) {
|
||||
// if (is_numeric($createTime)) {
|
||||
// $createTime = intval($createTime);
|
||||
// } else {
|
||||
// $createTime = time();
|
||||
// }
|
||||
// }
|
||||
|
||||
$start = gmdate('Y-m-d H:i:s', strtotime($order->start_time));
|
||||
$end = gmdate('Y-m-d H:i:s', strtotime($order->end_time));
|
||||
|
||||
return [
|
||||
'member_login' => strval($asset->playx_user_id),
|
||||
'start_time' => $start,
|
||||
'end_time' => $end,
|
||||
'amount' => $order->amount,
|
||||
'reward_name' => strval($item->title ?? ''),
|
||||
'description' => strval($item->description ?? ''),
|
||||
'member_inbox_message' => 'Congratulations! You received an angpow.',
|
||||
'category' => strval($item->category ?? ''),
|
||||
'category_title' => strval($item->category_title ?? ''),
|
||||
'one_time_turnover' => 'yes',
|
||||
'multiplier' => $order->multiplier,
|
||||
];
|
||||
}
|
||||
|
||||
private function markFailedAttempt(MallOrder $order, string $reason): void
|
||||
{
|
||||
// retry_count 在“准备发送”阶段已 +1;此处用当前 retry_count 作为 attempt 编号
|
||||
$retryCount = $order->retry_count ?? 0;
|
||||
$attempt = is_int($retryCount) ? $retryCount : (is_numeric($retryCount) ? intval($retryCount) : 0);
|
||||
if ($attempt <= 0) {
|
||||
$attempt = 1;
|
||||
$order->retry_count = 1;
|
||||
}
|
||||
|
||||
$prev = $order->fail_reason;
|
||||
$prefix = 'attempt ' . $attempt . ': ';
|
||||
$line = $prefix . $reason;
|
||||
$newReason = $line;
|
||||
if (is_string($prev) && $prev !== '') {
|
||||
$newReason = $prev . "\n" . $line;
|
||||
}
|
||||
|
||||
$final = $attempt >= self::MAX_RETRY;
|
||||
$order->grant_status = $final ? MallOrder::GRANT_FAILED_FINAL : MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->fail_reason = $newReason;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成对方要求的 Base64(HMAC-SHA1)
|
||||
* - 文档中示例 python 会 base64_decode(key) 后参与 hmac
|
||||
* - 生产 key 由 BA 提供,可能是 base64 或 hex;这里做兼容处理
|
||||
*/
|
||||
private function buildSignature(string $input, string $authKey): ?string
|
||||
{
|
||||
$keyBytes = null;
|
||||
|
||||
$maybeBase64 = base64_decode($authKey, true);
|
||||
if ($maybeBase64 !== false && $maybeBase64 !== '') {
|
||||
$keyBytes = $maybeBase64;
|
||||
}
|
||||
|
||||
if ($keyBytes === null) {
|
||||
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
|
||||
if ($isHex) {
|
||||
$hex = hex2bin($authKey);
|
||||
if ($hex !== false && $hex !== '') {
|
||||
$keyBytes = $hex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyBytes === null) {
|
||||
$keyBytes = $authKey;
|
||||
}
|
||||
|
||||
$raw = hash_hmac('sha1', $input, $keyBytes, true);
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return base64_encode($raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace app\process;
|
||||
|
||||
use Webman\App;
|
||||
use Webman\Http\Response;
|
||||
|
||||
class Http extends App
|
||||
{
|
||||
/**
|
||||
* 在父类处理前拦截 OPTIONS 预检,直接返回 CORS 头(避免预检未命中路由时无 CORS)
|
||||
* 必须与 AllowCrossDomain::optionsResponse 一致,否则会覆盖中间件里对 Allow-Headers(如 server)的配置
|
||||
*/
|
||||
public function onMessage($connection, $request): void
|
||||
{
|
||||
@@ -18,19 +18,8 @@ class Http extends App
|
||||
$path = is_string($path) ? trim($path, '/') : '';
|
||||
$isApiOrAdmin = $path !== '' && (str_starts_with($path, 'api') || str_starts_with($path, 'admin'));
|
||||
if ($isApiOrAdmin) {
|
||||
$origin = $request->header('origin');
|
||||
$origin = is_array($origin) ? ($origin[0] ?? '') : (is_string($origin) ? trim($origin) : '');
|
||||
if ($origin === '') {
|
||||
$origin = '*';
|
||||
}
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'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, think-lang',
|
||||
];
|
||||
$connection->send(new Response(204, $headers, ''));
|
||||
$response = \app\common\middleware\AllowCrossDomain::optionsResponse($request);
|
||||
$connection->send($response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
207
app/process/PlayxJobs.php
Normal file
207
app/process/PlayxJobs.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\library\MallBonusGrantPush;
|
||||
use app\common\model\MallOrder;
|
||||
use app\common\model\MallUserAsset;
|
||||
use GuzzleHttp\Client;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* PlayX 积分商城闭环任务
|
||||
* - 轮询交易终态(ACCEPTED -> COMPLETED/REJECTED)
|
||||
* - 对可重试失败进行重发(依赖 external_transaction_id 幂等)
|
||||
*/
|
||||
class PlayxJobs
|
||||
{
|
||||
protected Client $http;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// 确保定时任务只在一个 worker 上运行
|
||||
if (!Worker::getAllWorkers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->http = new Client([
|
||||
'timeout' => 20,
|
||||
'http_errors' => false,
|
||||
]);
|
||||
|
||||
Timer::add(60, [$this, 'pollTransactionStatus']);
|
||||
Timer::add(60, [$this, 'retryFailedGrants']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询:已 accepted 的订单,查询终态
|
||||
*/
|
||||
public function pollTransactionStatus(): void
|
||||
{
|
||||
$baseUrl = strval(config('playx.api.base_url', ''));
|
||||
if ($baseUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status'));
|
||||
$url = rtrim($baseUrl, '/') . $path;
|
||||
|
||||
$list = MallOrder::where('type', MallOrder::TYPE_BONUS)
|
||||
->where('grant_status', MallOrder::GRANT_ACCEPTED)
|
||||
->where('status', MallOrder::STATUS_PENDING)
|
||||
->order('id', 'desc')
|
||||
->limit(50)
|
||||
->select();
|
||||
|
||||
foreach ($list as $order) {
|
||||
/** @var MallOrder $order */
|
||||
try {
|
||||
$res = $this->http->get($url, [
|
||||
'query' => [
|
||||
'externalTransactionId' => $order->external_transaction_id,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode(strval($res->getBody()), true) ?? [];
|
||||
$pxStatus = $data['status'] ?? '';
|
||||
|
||||
if ($pxStatus === MallOrder::STATUS_COMPLETED) {
|
||||
$order->status = MallOrder::STATUS_COMPLETED;
|
||||
$order->save();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($pxStatus === 'FAILED' || $pxStatus === MallOrder::STATUS_REJECTED) {
|
||||
// 仅在从 PENDING 转 REJECTED 时退分,避免重复入账
|
||||
$order->status = MallOrder::STATUS_REJECTED;
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||||
$order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed');
|
||||
$order->save();
|
||||
$this->refundPoints($order);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他情况视为仍在队列/PENDING,保持不变
|
||||
} catch (\Throwable $e) {
|
||||
// 查询失败不影响状态,下一轮重试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试:NOT_SENT / FAILED_RETRYABLE(按 retry_count 间隔)
|
||||
*/
|
||||
public function retryFailedGrants(): void
|
||||
{
|
||||
$baseUrl = strval(config('playx.api.base_url', ''));
|
||||
if ($baseUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxRetry = 3;
|
||||
$list = MallOrder::where('type', MallOrder::TYPE_BONUS)
|
||||
->whereIn('grant_status', [
|
||||
MallOrder::GRANT_NOT_SENT,
|
||||
MallOrder::GRANT_FAILED_RETRYABLE,
|
||||
])
|
||||
->where('status', MallOrder::STATUS_PENDING)
|
||||
->where('retry_count', '<', $maxRetry)
|
||||
->order('id', 'desc')
|
||||
->limit(50)
|
||||
->select();
|
||||
|
||||
foreach ($list as $order) {
|
||||
/** @var MallOrder $order */
|
||||
$allow = $this->allowRetryByInterval($order);
|
||||
if (!$allow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$order->retry_count = intval($order->retry_count ?? 0) + 1;
|
||||
|
||||
try {
|
||||
$this->sendGrantByOrder($order, $maxRetry);
|
||||
} catch (\Throwable $e) {
|
||||
$order->fail_reason = $e->getMessage();
|
||||
if (intval($order->retry_count) >= $maxRetry) {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||||
$order->save();
|
||||
} else {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function allowRetryByInterval(MallOrder $order): bool
|
||||
{
|
||||
if ($order->grant_status === MallOrder::GRANT_NOT_SENT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$retryCount = intval($order->retry_count ?? 0);
|
||||
$updatedAt = intval($order->update_time ?? 0);
|
||||
$diff = time() - $updatedAt;
|
||||
|
||||
// retry_count: 已失败重试次数
|
||||
// 0 -> 下次重试:等待 1min
|
||||
// 1 -> 下次重试:等待 5min
|
||||
// 2 -> 下次重试:等待 15min
|
||||
if ($retryCount === 0 && $diff >= 60) {
|
||||
return true;
|
||||
}
|
||||
if ($retryCount === 1 && $diff >= 300) {
|
||||
return true;
|
||||
}
|
||||
if ($retryCount >= 2 && $diff >= 900) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function sendGrantByOrder(MallOrder $order, int $maxRetry): void
|
||||
{
|
||||
if ($order->type !== MallOrder::TYPE_BONUS) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = MallBonusGrantPush::push($order);
|
||||
if ($result['ok']) {
|
||||
$order->grant_status = MallOrder::GRANT_ACCEPTED;
|
||||
$order->playx_transaction_id = $result['playx_transaction_id'];
|
||||
$order->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$order->fail_reason = $result['message'];
|
||||
if (intval($order->retry_count) >= $maxRetry) {
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
|
||||
$order->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||||
$order->save();
|
||||
}
|
||||
|
||||
private function refundPoints(MallOrder $order): void
|
||||
{
|
||||
if ($order->points_cost <= 0) {
|
||||
return;
|
||||
}
|
||||
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
|
||||
if (!$asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 已从用户可用积分中扣除,本次失败退回可用积分
|
||||
$asset->available_points += intval($order->points_cost);
|
||||
$asset->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
"robmorgan/phinx": "^0.15",
|
||||
"nelexa/zip": "^4.0.0",
|
||||
"voku/anti-xss": "^4.1",
|
||||
"topthink/think-validate": "^3.0"
|
||||
"topthink/think-validate": "^3.0",
|
||||
"firebase/php-jwt": "^7.0",
|
||||
"guzzlehttp/guzzle": "^7.10"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
return [
|
||||
// 允许跨域访问的域名(* 表示任意;开发可用 *,生产建议填具体域名)
|
||||
'cors_request_domain' => '*',
|
||||
'cors_request_domain' => '*,playx-admin.cjdhr.top',
|
||||
// 是否开启会员登录验证码
|
||||
'user_login_captcha' => true,
|
||||
'user_login_captcha' => false,
|
||||
// 是否开启管理员登录验证码
|
||||
'admin_login_captcha' => true,
|
||||
'admin_login_captcha' => false,
|
||||
// 会员登录失败可重试次数,false则无限
|
||||
'user_login_retry' => 10,
|
||||
// 管理员登录失败可重试次数,false则无限
|
||||
@@ -39,9 +39,9 @@ return [
|
||||
],
|
||||
// 代理服务器IP(Request 类将尝试获取这些代理服务器发送过来的真实IP)
|
||||
'proxy_server_ip' => [],
|
||||
// Token 配置
|
||||
// Token 配置(鉴权接口 authtoken 等高频调用建议使用 redis 提升性能)
|
||||
'token' => [
|
||||
// 默认驱动方式
|
||||
// 默认驱动:mysql | redis(redis 需确保 config/redis.php 已配置且 phpredis 扩展可用)
|
||||
'default' => 'mysql',
|
||||
// 加密key
|
||||
'key' => 'L1iYVS0PChKA9pjcFdmOGb4zfDIHo5xw',
|
||||
@@ -81,6 +81,21 @@ return [
|
||||
'cdn_url' => '',
|
||||
// 内容分发网络URL参数,将自动添加 `?`,之后拼接到 cdn_url 的结尾(例如 `imageMogr2/format/heif`)
|
||||
'cdn_url_params' => '',
|
||||
// 代理鉴权配置(/api/v1/authToken)
|
||||
'agent_auth' => [
|
||||
// agent_id => secret 映射
|
||||
'agents' => [
|
||||
// 'agent_001' => 'your_secret_key',
|
||||
],
|
||||
// JWT 签名密钥(留空则使用 token.key);建议 AGENT_AUTH_JWT_SECRET 注入
|
||||
'jwt_secret' => strval(env('AGENT_AUTH_JWT_SECRET', '')),
|
||||
// 是否启用 H5 临时登录接口 /api/v1/temLogin
|
||||
'temp_login_enable' => true,
|
||||
// Token 有效期(秒),默认 24 小时
|
||||
'token_expire' => 86400,
|
||||
// 临时登录 token 有效期(秒),默认 1 天
|
||||
'temp_login_expire' => 86400,
|
||||
],
|
||||
// 版本号
|
||||
'version' => 'v2.3.6',
|
||||
// 中心接口地址(用于请求模块市场的数据等用途)
|
||||
|
||||
54
config/playx.php
Normal file
54
config/playx.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PlayX 积分商城对接配置
|
||||
*/
|
||||
return [
|
||||
// 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * 返还比例(仅亏损时)
|
||||
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
|
||||
// 解锁比例:今日可领取上限 = yesterday_total_deposit * 解锁比例
|
||||
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
|
||||
// 提现折算:积分 → 现金(如 10 分 = 1 元)
|
||||
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
|
||||
// Daily Push 签名校验(PlayX 调用商城时使用)
|
||||
'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')),
|
||||
/**
|
||||
* 合作方 JWT 验签密钥(HS256)。非空时:dailyPush 等回调需带 Authorization: Bearer
|
||||
* 仅写入部署环境变量,勿提交仓库。
|
||||
*/
|
||||
'partner_jwt_secret' => strval(env('PLAYX_PARTNER_JWT_SECRET', '')),
|
||||
// token 会话缓存过期时间(秒)
|
||||
'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')),
|
||||
/**
|
||||
* 为 true 时:/api/v1/playx/verify-token 仅本地校验(查 token 表 + mall_playx_user_asset),不请求 PlayX。
|
||||
* 联调/无 PlayX 环境可开;上线对接 PlayX 后请设为 false 并配置 api.base_url。
|
||||
*/
|
||||
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '1'), FILTER_VALIDATE_BOOLEAN),
|
||||
// PlayX API 配置(商城调用 PlayX 时使用)
|
||||
'api' => [
|
||||
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
|
||||
'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')),
|
||||
'token_verify_url' => '/api/v1/auth/verify-token',
|
||||
'bonus_grant_url' => '/api/v1/bonus/grant',
|
||||
'balance_credit_url' => '/api/v1/balance/credit',
|
||||
'transaction_status_url' => '/api/v1/transaction/status',
|
||||
],
|
||||
// Angpow Import API(商城调用对方 cashmarket/merchant-api 时使用)
|
||||
'angpow_import' => [
|
||||
// 对方 base_url,例如 https://ss2-staging2.ttwd8.com
|
||||
'base_url' => strval(env('PLAYX_ANGPOW_IMPORT_BASE_URL', '')),
|
||||
// 路径:文档示例为 /api/v3/merchant/angpow-imports;对方 curl 示例为 /cashmarket/v3/merchant-api/angpow-imports
|
||||
'path' => strval(env('PLAYX_ANGPOW_IMPORT_PATH', '/api/v3/merchant/angpow-imports')),
|
||||
// merchant_code(固定 plx 或按环境配置)
|
||||
'merchant_code' => strval(env('PLAYX_ANGPOW_MERCHANT_CODE', 'plx')),
|
||||
// HMAC-SHA1 的 auth key(生产环境由 BA 提供)
|
||||
'auth_key' => strval(env('PLAYX_ANGPOW_IMPORT_AUTH_KEY', '')),
|
||||
// HTTPS:指定 CA 证书包路径(推荐,下载 https://curl.se/ca/cacert.pem 后填绝对路径,可避免 cURL error 60)
|
||||
'ca_file' => strval(env('PLAYX_ANGPOW_IMPORT_CACERT', '')),
|
||||
// 是否校验 SSL(生产必须为 true;本地无 CA 时可临时 false,勿用于生产)
|
||||
'verify_ssl' => filter_var(env('PLAYX_ANGPOW_IMPORT_VERIFY_SSL', '1'), FILTER_VALIDATE_BOOLEAN),
|
||||
// 固定货币展示映射
|
||||
'currency' => 'MYR',
|
||||
'visual_name' => 'Angpow',
|
||||
],
|
||||
];
|
||||
@@ -15,6 +15,7 @@
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
use app\process\Http;
|
||||
use app\process\AngpowImportJobs;
|
||||
|
||||
global $argv;
|
||||
|
||||
@@ -59,4 +60,15 @@ return [
|
||||
]
|
||||
]
|
||||
]
|
||||
,
|
||||
// PlayX 闭环任务:轮询交易终态/失败重试
|
||||
'playx_jobs' => [
|
||||
'handler' => app\process\PlayxJobs::class,
|
||||
'reloadable' => false,
|
||||
],
|
||||
// Angpow 导入推送任务:订单兑换后推送到对方平台
|
||||
'angpow_import_jobs' => [
|
||||
'handler' => AngpowImportJobs::class,
|
||||
'reloadable' => false,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -108,6 +108,25 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
|
||||
// api/ems
|
||||
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
|
||||
|
||||
// api/v1 鉴权
|
||||
Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']);
|
||||
Route::get('/api/v1/temLogin', [\app\api\controller\v1\Auth::class, 'temLogin']);
|
||||
|
||||
// api/v1 PlayX 积分商城
|
||||
Route::post('/api/v1/mall/dailyPush', [\app\api\controller\v1\Playx::class, 'dailyPush']);
|
||||
Route::post('/api/v1/mall/verifyToken', [\app\api\controller\v1\Playx::class, 'verifyToken']);
|
||||
Route::get('/api/v1/mall/assets', [\app\api\controller\v1\Playx::class, 'assets']);
|
||||
Route::post('/api/v1/mall/claim', [\app\api\controller\v1\Playx::class, 'claim']);
|
||||
Route::get('/api/v1/mall/items', [\app\api\controller\v1\Playx::class, 'items']);
|
||||
Route::post('/api/v1/mall/bonusRedeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']);
|
||||
Route::post('/api/v1/mall/physicalRedeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']);
|
||||
Route::post('/api/v1/mall/withdrawApply', [\app\api\controller\v1\Playx::class, 'withdrawApply']);
|
||||
Route::get('/api/v1/mall/orders', [\app\api\controller\v1\Playx::class, 'orders']);
|
||||
Route::get('/api/v1/mall/addressList', [\app\api\controller\v1\Playx::class, 'addressList']);
|
||||
Route::post('/api/v1/mall/addressAdd', [\app\api\controller\v1\Playx::class, 'addressAdd']);
|
||||
Route::post('/api/v1/mall/addressEdit', [\app\api\controller\v1\Playx::class, 'addressEdit']);
|
||||
Route::post('/api/v1/mall/addressDelete', [\app\api\controller\v1\Playx::class, 'addressDelete']);
|
||||
|
||||
// ==================== Admin 路由 ====================
|
||||
// Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
|
||||
|
||||
@@ -245,11 +264,11 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
|
||||
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
|
||||
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
|
||||
|
||||
// ==================== 兼容 ThinkPHP 风格 URL(module.Controller/action) ====================
|
||||
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
|
||||
// ==================== 兼容 ThinkPHP 风格 URL(module.Controller/action 或 module.sub.Controller/action) ====================
|
||||
// 前端使用 /admin/user.Rule/index、/admin/mall.pints.Order/index 等格式,需转换为控制器调用
|
||||
Route::add(
|
||||
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
|
||||
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action}',
|
||||
'/admin/{controllerPart:[a-zA-Z0-9]+(?:\\.[a-zA-Z0-9]+)+}/{action}',
|
||||
function (\Webman\Http\Request $request, string $controllerPart, string $action) {
|
||||
$pos = strpos($controllerPart, '.');
|
||||
if ($pos === false) {
|
||||
@@ -257,7 +276,9 @@ Route::add(
|
||||
}
|
||||
$module = substr($controllerPart, 0, $pos);
|
||||
$controller = substr($controllerPart, $pos + 1);
|
||||
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controller;
|
||||
// 支持多级路径:pints.Order -> pints\Order,redemption.Order -> redemption\Order
|
||||
$controllerClass = str_replace('.', '\\', $controller);
|
||||
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controllerClass;
|
||||
if (!class_exists($class)) {
|
||||
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
/**
|
||||
* 积分商城用户表 mall_player
|
||||
*/
|
||||
class MallPlayer extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
if (!$this->hasTable('mall_player')) {
|
||||
$table = $this->table('mall_player', [
|
||||
'id' => false,
|
||||
'comment' => '积分商城用户表',
|
||||
'row_format' => 'DYNAMIC',
|
||||
'primary_key' => 'id',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
]);
|
||||
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
|
||||
->addColumn('username', 'string', ['limit' => 50, 'default' => '', 'comment' => '用户名', 'null' => false])
|
||||
->addColumn('password', 'string', ['limit' => 255, 'default' => '', 'comment' => '密码', 'null' => false])
|
||||
->addColumn('score', 'integer', ['signed' => false, 'default' => 0, 'comment' => '积分', 'null' => false])
|
||||
->addColumn('create_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
|
||||
->addColumn('update_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '修改时间'])
|
||||
->addIndex(['username'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
}
|
||||
205
docs/H5-积分商城接口文档.md
Normal file
205
docs/H5-积分商城接口文档.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# H5 积分商城接口文档(含流程说明)
|
||||
|
||||
> 面向:H5(活动页/积分商城前台)调用
|
||||
> 基础路径:`/api/v1`
|
||||
> 返回结构:BuildAdmin 通用 `code/msg/time/data`(成功 `code=1`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体流程说明
|
||||
|
||||
### 1.1 流程 A:H5 临时登录(推荐)
|
||||
|
||||
适用场景:H5 只需要“用户名级别”的轻量登录,不依赖 PlayX 的 token。
|
||||
|
||||
1. H5 调用 `GET/POST /api/v1/temLogin?username=xxx` 获取 **商城 token**(类型 `muser`)
|
||||
2. H5 后续请求统一携带该 token(推荐放在 Header:`token: <muser_token>`,也可用参数 `token`)
|
||||
3. H5 调用:
|
||||
- `GET /api/v1/mall/assets` 获取资产
|
||||
- `POST /api/v1/mall/claim` 领取积分(幂等)
|
||||
- `GET /api/v1/mall/items` 获取商品
|
||||
- `POST /api/v1/mall/bonusRedeem` / `physicalRedeem` / `withdrawApply` 提交兑换/提现
|
||||
- `GET /api/v1/mall/orders` 查询订单
|
||||
- `GET/POST /api/v1/mall/address*` 管理地址(addressList/addressAdd/addressEdit/addressDelete)
|
||||
|
||||
### 1.2 流程 B:PlayX token 换取 session(兼容)
|
||||
|
||||
适用场景:H5 已经拿到了 PlayX 下发的 token,希望换取商城侧 `session_id`。
|
||||
|
||||
1. H5 调用 `POST /api/v1/mall/verifyToken`(传 `token` 或 `session`)
|
||||
2. 服务端返回 `data.session_id`
|
||||
3. H5 后续请求携带 `session_id`(优先级高于 token)
|
||||
|
||||
---
|
||||
|
||||
## 2. 身份与鉴权(重要)
|
||||
|
||||
以下接口都会通过服务端逻辑解析“当前资产主体”,优先级如下:
|
||||
|
||||
1. **`session_id`**(GET/POST):对应表 `mall_session`,未过期则可映射到资产主体
|
||||
2. **`token`**(GET/POST 或 Header):支持会员 token 或 `muser` token(H5 临时登录签发)
|
||||
3. **`user_id`**(GET/POST):
|
||||
- 纯数字:视为 `mall_user_asset.id`
|
||||
- 非纯数字:按 `mall_user_asset.playx_user_id` 查找
|
||||
|
||||
推荐做法:
|
||||
- H5 统一只用 **Header `token`**(值为 `muser` token),避免 URL 泄露与参数歧义。
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口列表(H5 常用)
|
||||
|
||||
### 3.1 H5 临时登录
|
||||
|
||||
**GET/POST** ` /api/v1/temLogin `
|
||||
|
||||
参数:
|
||||
- `username`:必填,用户名(字符串)
|
||||
|
||||
成功返回 `data.userInfo`:
|
||||
- `id`:资产主键(`mall_user_asset.id`)
|
||||
- `username`:用户名
|
||||
- `playx_user_id`:映射的 PlayX 用户标识(字符串)
|
||||
- `token`:**muser token**(后续请求使用)
|
||||
- `refresh_token`:刷新 token(当前前端未强依赖可不接)
|
||||
- `expires_in`:秒
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl "https://{域名}/api/v1/temLogin?username=test001"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 资产查询
|
||||
|
||||
**GET** ` /api/v1/mall/assets `
|
||||
|
||||
鉴权:携带 `token` 或 `session_id` 或 `user_id`
|
||||
|
||||
成功返回 `data`:
|
||||
- `locked_points`:待领取积分
|
||||
- `available_points`:可用积分
|
||||
- `today_limit`:今日可领取上限
|
||||
- `today_claimed`:今日已领取
|
||||
- `withdrawable_cash`:可提现现金(积分按配置比例换算)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 领取积分(幂等)
|
||||
|
||||
**POST** ` /api/v1/mall/claim `
|
||||
|
||||
参数:
|
||||
- `claim_request_id`:必填,幂等键(建议:`{业务前缀}_{assetId}_{毫秒时间戳}`)
|
||||
- 身份参数:`token` / `session_id` / `user_id` 三选一(推荐 `token`)
|
||||
|
||||
说明:
|
||||
- 同一个 `claim_request_id` 重复提交会直接返回成功(不会重复入账)
|
||||
- 会受 `today_limit/today_claimed/locked_points` 限制
|
||||
|
||||
---
|
||||
|
||||
### 3.4 商品列表
|
||||
|
||||
**GET** ` /api/v1/mall/items `
|
||||
|
||||
参数:
|
||||
- `type`:可选,`BONUS | PHYSICAL | WITHDRAW`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 红利兑换(提交订单)
|
||||
|
||||
**POST** ` /api/v1/mall/bonusRedeem `
|
||||
|
||||
参数:
|
||||
- `item_id`:必填
|
||||
- 身份参数:`token` / `session_id` / `user_id`
|
||||
|
||||
返回:
|
||||
- `data.order_id`
|
||||
- `data.status`(通常 `PENDING`)
|
||||
|
||||
---
|
||||
|
||||
### 3.6 实物兑换(提交订单)
|
||||
|
||||
**POST** ` /api/v1/mall/physicalRedeem `
|
||||
|
||||
参数:
|
||||
- `item_id`:必填
|
||||
- `address_id`:必填,收货地址主键(`mall_address.id`,须为当前用户资产下地址)
|
||||
- 身份参数:`token` / `session_id` / `user_id`
|
||||
|
||||
说明:服务端会将该地址在下单时刻的 **收货人 / 电话 / 完整地址** 写入订单字段(快照),并写入 `mall_order.mall_address_id` 关联所选地址。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 提现申请(提交订单)
|
||||
|
||||
**POST** ` /api/v1/mall/withdrawApply `
|
||||
|
||||
参数:
|
||||
- `item_id`:必填
|
||||
- 身份参数:`token` / `session_id` / `user_id`
|
||||
|
||||
---
|
||||
|
||||
### 3.8 订单列表
|
||||
|
||||
**GET** ` /api/v1/mall/orders `
|
||||
|
||||
鉴权:`token` / `session_id` / `user_id`
|
||||
|
||||
说明:
|
||||
- 返回最多 100 条
|
||||
- 订单里包含 `mallItem`(商品信息)
|
||||
|
||||
---
|
||||
|
||||
## 4. 地址管理(H5)
|
||||
|
||||
> 地址与资产主体通过 `playx_user_asset_id` 关联(即 `mall_user_asset.id`)。
|
||||
|
||||
### 4.1 地址列表
|
||||
**GET** ` /api/v1/mall/addressList `
|
||||
|
||||
### 4.2 新增地址
|
||||
**POST** ` /api/v1/mall/addressAdd `
|
||||
|
||||
Body 含 `receiver_name`(收货人,建议填写;实物兑换下单快照需要非空的收货人、电话与拼接后的完整地址)。
|
||||
|
||||
### 4.3 编辑地址
|
||||
**POST** ` /api/v1/mall/addressEdit `
|
||||
|
||||
### 4.4 删除地址
|
||||
**POST** ` /api/v1/mall/addressDelete `
|
||||
|
||||
---
|
||||
|
||||
## 5. session 换取(可选)
|
||||
|
||||
### 5.1 token 换 session
|
||||
|
||||
**POST** ` /api/v1/mall/verifyToken `
|
||||
|
||||
参数(二选一):
|
||||
- `token`
|
||||
- `session`
|
||||
|
||||
成功返回:
|
||||
- `data.session_id`
|
||||
- `data.user_id`
|
||||
- `data.username`
|
||||
- `data.token_expire_at`
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见错误与排查
|
||||
|
||||
- **401 登录态过期**:token/session 过期或不匹配;请重新 `temLogin` 或重新 `verifyToken`
|
||||
- **提示缺少必填字段**:按各接口参数补齐(如 `claim_request_id`、`item_id`、`address_id`(实物)、地址中收货人/电话/完整地址等)
|
||||
- **积分不足/无可领取积分**:`locked_points<=0` 或已达 `today_limit`
|
||||
|
||||
536
docs/PlayX-对接文档(积分商城).md
Normal file
536
docs/PlayX-对接文档(积分商城).md
Normal file
@@ -0,0 +1,536 @@
|
||||
## 0. 交付说明(给 PlayX)
|
||||
|
||||
- **交付物**:本文件(接口清单 + 业务流程 + 联调验收清单)。
|
||||
- **建议联调顺序**:Token 验证(远程 PlayX 或本地 `verify_token_local_only`)→ 每日推送 → 领取 → 红利发放 → 提现入账 → 实物后台处理。
|
||||
- **约定**:接口 URL、字段最终表、签名细节以 PlayX 提供的最终口径为准;本文档负责把流程、幂等、重试与最小字段集合先对齐。
|
||||
|
||||
## 1. 文档目的与范围
|
||||
|
||||
本文档用于 PlayX 与积分商城(Points Mall)联调对接。范围仅包含:
|
||||
|
||||
- 前端:PlayX 以内嵌 Iframe 打开商城 H5,使用 postMessage 传递 token/session。
|
||||
- 后端:商城后端独立部署;与 PlayX 后端通过 REST API 通讯。
|
||||
- 数据同步:仅 PlayX 每日 Cron 推送(T+1)玩家数据到商城,用于计算“待领取积分/今日可领取上限”。
|
||||
- 发放方式:商城在红利兑换/提现(回平台余额)下单后,直接调用 PlayX API 发放/入账;PlayX 侧每 10 分钟 Cron 执行 5.9 adjustment/最终入账。
|
||||
|
||||
不在本文档范围内:
|
||||
|
||||
- 任何实时 webhook(充值、外部积分、流水等)。
|
||||
- 会员端“同步额度/同步流水”按钮触发的对接链路。
|
||||
|
||||
## 2. 系统边界与调用方向
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
PlayXFrontend["PlayXFrontend"] -->|"postMessage(token/session)"| MallFrontend["MallFrontend(Iframe)"]
|
||||
MallFrontend -->|"API(商城后端)"| MallBackend["MallBackend"]
|
||||
MallBackend -->|"TokenVerificationAPI"| PlayXBackend["PlayXBackend"]
|
||||
PlayXBackend -->|"DailyPushAPI(T+1)"| MallBackend
|
||||
MallBackend -->|"BonusGrantAPI/BalanceCreditAPI"| PlayXBackend
|
||||
```
|
||||
|
||||
> 当 **`playx.verify_token_local_only=true`** 时,「Token 验证」一步在商城内完成,**不经过** `PlayXBackend` 的 Token Verification API;详见 **§4.1**。
|
||||
|
||||
## 3. 关键业务对象与状态机
|
||||
|
||||
### 3.1 资产口径(最小集合)
|
||||
|
||||
- **LockedPoints(待领取积分)**:由 PlayX 每日推送的“昨日输赢净额”在商城端按规则计算得到,未领取前不可消费。
|
||||
- **AvailablePoints(可用积分)**:领取后可用于兑换/提现的积分余额。
|
||||
- **TodayLimit(今日可领取上限)**:由 PlayX 每日推送的“昨日总存款”按规则计算得到。
|
||||
- **TodayClaimed(今日已领取)**:当日累计领取量(用于进度条与上限控制)。
|
||||
|
||||
### 3.2 订单类型
|
||||
|
||||
- **BONUS**:红利兑换
|
||||
- **PHYSICAL**:实物兑换
|
||||
- **WITHDRAW**:提现回平台余额(非现金出款)
|
||||
|
||||
### 3.3 统一订单状态
|
||||
|
||||
- **PENDING(处理中)**:订单已创建,等待发放/审核/发货等后续处理
|
||||
- **COMPLETED(已发放)**:红利到账或提现入账完成
|
||||
- **SHIPPED(已发货)**:实物已发货,包含物流公司与单号
|
||||
- **REJECTED(已驳回)**:失败或人工拒绝;积分需退回(退回规则见 6.2)
|
||||
|
||||
## 4. 端到端流程(6 条)
|
||||
|
||||
### 4.1 登录鉴权(Iframe + token)
|
||||
|
||||
> **接口与字段细节**以代码为准,完整说明见同目录《PlayX-接口文档.md》(§3 H5、§3.2 `temLogin`、§3.3 `verify-token`)。
|
||||
|
||||
#### 4.1.1 身份与数据模型(商城侧)
|
||||
|
||||
- **商城用户**:表 `mall_user`(H5 临时登录、后台创建等均落此表)。
|
||||
- **PlayX 资产扩展**:表 `mall_playx_user_asset`,与 `mall_user` **一对一**(`mall_user_id`、`playx_user_id` 均唯一)。
|
||||
- **业务侧用户标识**:对外接口中的 `user_id`(字符串)在多数场景下即 **`playx_user_id`**(PlayX 玩家 ID)。
|
||||
- 若用户仅通过商城 **临时登录** 进入、尚无 PlayX 正式 ID,商城会生成占位 ID,形如 **`mall_{mall_user.id}`**,与每日推送中的真实 `user_id` 区分(避免与纯数字 ID 混淆)。
|
||||
- **H5 调业务接口时**:服务端内部统一解析为 **`mall_user.id`**,再查资产与订单(解析规则见《PlayX-接口文档》§3.1)。
|
||||
|
||||
#### 4.1.2 模式 A:联调 PlayX(生产/预发,远程校验 token)
|
||||
|
||||
1. 用户在 PlayX 内打开积分商城入口(iframe)。
|
||||
2. PlayX 前端通过 postMessage 将 **PlayX 下发的 token**(及必要上下文)传给商城 H5。
|
||||
3. 商城 H5 调用商城后端 **`POST /api/v1/playx/verify-token`**,由商城向 PlayX 的 **Token Verification API**(`playx.api.base_url` + `playx.api.token_verify_url`)发起校验。
|
||||
4. **前提**:配置 **`playx.verify_token_local_only = false`**,且 **`playx.api.base_url`** 已配置为可访问的 PlayX 基地址。
|
||||
5. PlayX 返回 **`user_id`、`username`**(及可选会话过期时间等)。
|
||||
6. 商城写入 **`mall_playx_session`**(`session_id` + 上述 `user_id`/`username` + 过期时间),后续 H5 可用 **`session_id`** 或 **`token`(商城临时 token,见模式 B)** 调用资产/领取等接口。
|
||||
|
||||
幂等与安全:
|
||||
|
||||
- H5 **不要**把 PlayX 的 `user_id` 当作唯一可信凭据直传下单;**以 token 换 session** 或由商城签发 token 的流程为准。
|
||||
- PlayX 侧 Token Verification API 的鉴权/签名(若有)按双方约定(可参考《PlayX-接口文档》§2.1)。
|
||||
|
||||
#### 4.1.3 模式 B:本地 / 无 PlayX 环境(商城自校验,不请求 PlayX)
|
||||
|
||||
用于开发、联调前自测、或 PlayX 接口未就绪时:
|
||||
|
||||
1. 配置 **`playx.verify_token_local_only = true`**(环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**,默认可为开启,以项目 `config/playx.php` 为准)。
|
||||
2. 此时 **`/api/v1/playx/verify-token` 不会访问 PlayX**,仅在商城内校验 **商城临时 token**(token 表类型 **`muser`**,由下方 `temLogin` 签发)。
|
||||
3. 调用 **`GET/POST /api/v1/temLogin?username=...`**(需 **`buildadmin.agent_auth.temp_login_enable = true`**):不存在则创建 **`mall_user`**,并保证存在 **`mall_playx_user_asset`**(含 `playx_user_id`,默认 **`mall_{id}`**),返回 **`userInfo.token`**、**`playx_user_id`**、**`expires_in`** 等。
|
||||
4. 再用该 token 调用 **`verify-token`** 可得到 **`session_id`**,与模式 A 一样供后续接口使用;或直接带 **`token` / `ba-token`** 调资产等接口(见《PlayX-接口文档》§3.1)。
|
||||
|
||||
#### 4.1.4 会话续期与前端约定
|
||||
|
||||
- **会话续期**:玩家停留时间较长时,若商城 API 返回 token/session 失效(如 401),H5 可通过 postMessage 请 PlayX 父页面 **重新派发 PlayX token**(模式 A);模式 B 下可重新 **`temLogin`** 或走 **`/api/common/refreshToken`**(`muser-refresh`)换取新 access token。
|
||||
- 具体错误码与 Header(如 `ba-token`)以前端与《PlayX-接口文档》为准。
|
||||
|
||||
### 4.2 每日 T+1 入池(PlayX → 商城)
|
||||
|
||||
1. PlayX 在每日固定时间向商城调用 **Daily Push API**,推送昨日玩家数据。(**注:请确认并约定好 `date` 字段对应的具体时区边界,如以 UTC+8 为准**)。
|
||||
2. 商城按 `user_id + date` 幂等去重入库。由于不支持通过重复推送做数据修正,**若 PlayX 发现个别账单算错了,请联系商城运营在后台进行人工调账处理**,勿重复推送。
|
||||
3. 商城计算:
|
||||
- 新增保障金(待领取积分增量)
|
||||
- 今日可领取上限
|
||||
4. 会员次日进入商城时,可在首页看到更新后的 LockedPoints 与 TodayLimit。
|
||||
|
||||
### 4.3 领取流程(Locked → Available)
|
||||
|
||||
1. 会员在首页点击“领取”。
|
||||
2. 商城后端校验:LockedPoints > 0,且 TodayLimit - TodayClaimed > 0。
|
||||
3. 商城计算 `canClaim = min(LockedPoints, TodayLimit - TodayClaimed)`,并原子更新:
|
||||
- LockedPoints -= canClaim
|
||||
- AvailablePoints += canClaim
|
||||
- TodayClaimed += canClaim
|
||||
4. 返回最新资产,前端刷新。
|
||||
|
||||
幂等:
|
||||
|
||||
- 领取操作建议使用 `claim_request_id`(由前端生成或后端生成返回)实现幂等,避免重复点击导致重复领取。
|
||||
|
||||
### 4.4 红利兑换(商城 → PlayX 发放)
|
||||
|
||||
1. 会员在“红利”商品点击兑换并确认(**为避免客诉,商城前端会提示会员:红利发放预计在此后约 10 分钟内入账,请耐心等待**)。
|
||||
2. 商城创建 BONUS 订单(PENDING),并校验/扣减可用积分(原子扣减)。
|
||||
3. 商城调用 PlayX **Bonus Grant API**,传递红利发放信息(字段见 5.3)。
|
||||
4. 若 PlayX API 返回初步排队接收成功(HTTP 200 且 `status="accepted"`):
|
||||
- 商城订单保持 PENDING(等待 PlayX 侧 10 分钟 Cron 最终发放/入账)。
|
||||
- 记录 `playx_transaction_id`(或外部流水号)用于后续追踪。
|
||||
- **商城后端将通过调用 PlayX 的 “交易状态查询 API”(见 5.5)来轮询获取最终结果**,最终确认为成功后,商城订单才会流转闭环为 COMPLETED。
|
||||
5. 若 PlayX API 返回失败:
|
||||
- 订单保持 PENDING,并记录失败原因与下一次可重试时间
|
||||
- 支持后台“手动重试”(见 6.3)
|
||||
- 若经过 N 次重试仍失败或确认 PlayX 侧不可达成:订单转 REJECTED 并退回积分(见 6.2)
|
||||
|
||||
### 4.5 实物兑换(商城后台人工处理)
|
||||
|
||||
1. 会员选择实物并填写收货信息(姓名/电话/地址)。
|
||||
2. 商城创建 PHYSICAL 订单(PENDING),并原子扣减可用积分。
|
||||
3. 后台运营:
|
||||
- 发货:录入物流公司与单号 → 状态 SHIPPED
|
||||
- 驳回:录入原因 → 状态 REJECTED → 自动退回积分
|
||||
|
||||
### 4.6 提现回平台余额(商城 → PlayX 入账)
|
||||
|
||||
1. 会员在“提现到平台余额”商品点击提现并确认(**前端同样需向用户提示约 10 分钟入账预期**)。
|
||||
2. 商城创建 WITHDRAW 订单(PENDING),并原子扣减可用积分。
|
||||
3. 商城调用 PlayX **Balance Credit API**(或同一发放接口的提现模式),传入入账信息。
|
||||
4. 若 PlayX API 返回初步排队接收成功(HTTP 200 且 `status="accepted"`):
|
||||
- 商城订单保持 PENDING(等待 PlayX 侧 10 分钟 Cron 最终入账)。
|
||||
- 记录 `playx_transaction_id`(或外部流水号)用于后续追踪。
|
||||
- **商城后端通过「交易状态查询 API」(见 5.5)轮询获取终态**,确认成功后订单才流转为 COMPLETED。
|
||||
5. 若 PlayX API 返回失败(非 200 或 `status` 非 `accepted`):失败处理同 4.4。
|
||||
|
||||
## 5. 接口清单(按调用方向)
|
||||
|
||||
> 说明:以下为接口“结构与字段清单”。具体 URL、Header、签名算法、错误码需 PlayX 提供或双方确认后固化。
|
||||
|
||||
### 5.1 PlayX → Mall:Daily Push API(每日推送)
|
||||
|
||||
- **目的**:推送昨日玩家数据,用于 T+1 计算入池与领取上限。
|
||||
- **幂等键**:`user_id + date`(date 建议为 PlayX 业务日)
|
||||
- **Method/Path(建议)**:`POST /api/v1/playx/daily-push`
|
||||
|
||||
请求字段说明(最小集合,来自现有资料):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 请求的唯一网关流水号,商城端用于日志追踪和外层防重。 |
|
||||
| `date` | String | 是 | 数据归属的业务日期(如 `"2026-03-18"`),用于限定该批数据的生效周期。 |
|
||||
| `user_id` | String | 是 | 玩家在 PlayX 的唯一标识 ID,在此商城体系中以此作为核心绑定主键。 |
|
||||
| `username` | String | 否 | 玩家展示名,仅用于后台日志人工可读性或冗余展示,不作业务主键。 |
|
||||
| `lifetime_total_deposit` | Decimal | 否 | 玩家历史总充值(如有需要用于玩家 VIP 分层,当前传值保留即可)。 |
|
||||
| `lifetime_total_withdraw` | Decimal | 否 | 玩家历史总提现(储备字段)。 |
|
||||
| `yesterday_win_loss_net` | Decimal | 是 | 昨日净输赢金额(如果玩家亏损,应为负数)。**注:务必是已扣除返点、红利、奖励、推荐佣金、VIP Bonus 的税后净额**,严格代表玩家的真实净负盈利。 |
|
||||
| `yesterday_total_deposit` | Decimal | 是 | 昨日玩家总充值金额,积分商城专门用此字段来计算“今日可领取上限(TodayLimit)”。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "px_20260319_000001",
|
||||
"date": "2026-03-18",
|
||||
"user_id": "U123",
|
||||
"username": "demo_user_01",
|
||||
"lifetime_total_deposit": 5000.0,
|
||||
"lifetime_total_withdraw": 2000.0,
|
||||
"yesterday_win_loss_net": -120.5,
|
||||
"yesterday_total_deposit": 50.0
|
||||
}
|
||||
```
|
||||
|
||||
响应字段说明(建议):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 完全透传原请求的 `request_id`,便于双向日志匹配追踪。 |
|
||||
| `accepted` | Boolean | 是 | `true` 标识商城已成功接收并解析了该批数据。 |
|
||||
| `deduped` | Boolean | 是 | 若 `true`,标识该条数据因 `user_id + date` 已存在而被商城系统幂等静默丢弃(去重)。 |
|
||||
| `message` | String | 否 | 成功或失败的补充说明(如 `"ok"` 或 `"duplicate input"` 等异常提示)。 |
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "px_20260319_000001",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Mall → PlayX:Token Verification API
|
||||
|
||||
- **目的**:商城后端校验 token/session,获取可信 `user_id` 与 `username`。
|
||||
- **Method/Path(示例占位)**:`POST /api/v1/auth/verify-token`
|
||||
|
||||
请求字段说明(建议):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 商城系统生成的唯一请求流水号。 |
|
||||
| `token` 或 `session` | String | 是 | 从带有商城的 Iframe `postMessage` 接收到的用户加密登录散列或临时会话凭证。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_20260319_9f1b6d",
|
||||
"token": "eyJhbGciOi..."
|
||||
}
|
||||
```
|
||||
|
||||
响应字段说明(建议):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 透传请求时的 `request_id`。 |
|
||||
| `user_id` | String | 是 | 该凭证解密后对应的、在 PlayX 平台具有唯一性的玩家专属 ID。 |
|
||||
| `username` | String | 否 | 该玩家显示名,用于加载商城的界面头部“欢迎:xxx”渲染。 |
|
||||
| `token_expire_at` | String | 否 | Token 的物理过期时间(如 ISO8601),用于商城前端预判是否到了需要执行无感续期重置的底线时间。 |
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_20260319_9f1b6d",
|
||||
"user_id": "U123",
|
||||
"username": "demo_user_01",
|
||||
"token_expire_at": "2026-03-19T10:12:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Mall → PlayX:Bonus Grant API(红利发放)
|
||||
|
||||
来自 PlayX 现有字段清单(待 PlayX 确认最终口径):
|
||||
|
||||
请求字段说明(待 PlayX 确认最终口径):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 商城发起的 HTTP 请求流水号(纯用于网关层)。 |
|
||||
| `externalTransactionId` | String | 是 | **核心防重键:强制要求 PlayX 凭此字段做完全的幂等拦截**。这是商城侧派发红利的唯一本地订单号(如 `"BONUS_ORD001"`)。 |
|
||||
| `user_id` | String | 是 | 要派发红利的玩家在 PlayX 的基础 ID,这需对齐每日推送。 |
|
||||
| `memberLogin` | String | 否 | 玩家登录名(若当前 PlayX 核心接口必须传登录名,则商城会补充;若以 `user_id` 为准,此项可废弃)。 |
|
||||
| `amount` | Decimal | 是 | 实际加给玩家游戏余额或红利钱包的具体现金数字。 |
|
||||
| `rewardName` | String | 否 | 商城中对应的该红利商品名称,用于让用户后续在 PlayX 流水里看懂这笔钱从何而来。 |
|
||||
| `description` | String | 否 | 系统行为备注说明(如 `"PointsMall bonus"`)。 |
|
||||
| `memberInboxMessage` | String | 否 | 是否需借调此时机向玩家发送站内站群信内容提示。 |
|
||||
| `category` | String | 是 | 标明该红利在游戏侧的所属业务类别的枚举代码(如 `daily`)。 |
|
||||
| `categoryTitle` | String | 否 | 该红利业务类别的中文展示名称。 |
|
||||
| `multiplier` (或 `turnover`) | Int | 是 | 款项入账后,玩家需完成的打码流水约束倍数(如 1 倍或 5 倍)。 |
|
||||
| `startTime` / `endTime` | String | 否 | 红利生效时间窗口(起止时间,视 PlayX 规则传参)。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_bonus_20260319_000001",
|
||||
"externalTransactionId": "BONUS_ORD20260319_000001",
|
||||
"user_id": "U123",
|
||||
"memberLogin": "demo_user_01",
|
||||
"amount": 50.0,
|
||||
"rewardName": "每日回馈 50",
|
||||
"description": "PointsMall bonus redemption",
|
||||
"memberInboxMessage": "红利已提交,预计 10 分钟内到账",
|
||||
"category": "daily",
|
||||
"categoryTitle": "每日回馈",
|
||||
"multiplier": 1,
|
||||
"startTime": "2026-03-19T00:00:00Z",
|
||||
"endTime": "2026-03-19T23:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
响应字段说明(建议):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 透传请求号。 |
|
||||
| `playx_transaction_id` | String | 否 | PlayX 内部初创的接收入列单号或派发流水号,商城会将其归档以备应对极端客诉争议寻找记录用。 |
|
||||
| `status` | String | 是 | 核心状态枚举。若为 `accepted`,表示请求成功列入 10 分钟 Cron,商城中止重试;其他值皆触发商城的补偿拦截网。 |
|
||||
| `message` | String | 否 | 对入列状态的额外提示信息内容。 |
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_bonus_20260319_000001",
|
||||
"playx_transaction_id": "PX_TX_778899",
|
||||
"status": "accepted",
|
||||
"message": "queued"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Mall → PlayX:Balance Credit API(提现回平台余额)
|
||||
|
||||
字段建议与 5.3 保持结构一致,至少包含:
|
||||
|
||||
请求字段说明(建议与 5.3 保持结构一致):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `request_id` | String | 是 | 商城下发的网络请求追溯号。 |
|
||||
| `externalTransactionId` | String | 是 | **提现唯一单号,提现接口也需要基于此值做绝对拦截幂等功能**。 |
|
||||
| `user_id` | String | 是 | 申请提现的玩家 ID。 |
|
||||
| `memberLogin` | String | 否 | 玩家名(视老接口历史包袱兼容)。 |
|
||||
| `amount` | Decimal | 是 | 本次提现要充入 PlayX 主游戏平台真金余额池的具体现金。 |
|
||||
| `multiplier` (或 `turnover_rule`) | Int | 是 | 本真金提现入账后的硬性流水锁定要求倍数限制。 |
|
||||
| `description` | String | 否 | 日志源记录说明(如 `"PointsMall withdraw"`)。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_withdraw_20260319_000001",
|
||||
"externalTransactionId": "WITHDRAW_ORD20260319_000002",
|
||||
"user_id": "U123",
|
||||
"memberLogin": "demo_user_01",
|
||||
"amount": 100.0,
|
||||
"multiplier": 1,
|
||||
"description": "PointsMall withdraw to PlayX balance"
|
||||
}
|
||||
```
|
||||
|
||||
响应说明与 5.3 (Bonus Grant API) 保持一致,主要接收 `status="accepted"` 作为暂挂确认。
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_withdraw_20260319_000001",
|
||||
"playx_transaction_id": "PX_TX_889900",
|
||||
"status": "accepted",
|
||||
"message": "queued"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Mall → PlayX:Transaction Status Query API(交易终态查询)
|
||||
|
||||
- **目的**:红利/提现申请经 PlayX 接收后(即返回 `accepted` 后)可能处于排队发放下款状态(如 10 分钟 Cron)。商城将通过此接口查询最终业务结果,用于闭环商城自身的 PENDING 订单。
|
||||
- **Method/Path(预留)**:`GET /api/v1/transaction/status`
|
||||
- **传参方式**:使用 **Query String** 传递查询主键(若 PlayX 更倾向 POST,可改为 `POST` + JSON body,但需在联调前双方定稿一种即可)。
|
||||
|
||||
示例:
|
||||
|
||||
- `GET /api/v1/transaction/status?externalTransactionId=WITHDRAW_ORD20260319_000002`
|
||||
- `GET /api/v1/transaction/status?playx_transaction_id=PX_TX_889900`(与 `externalTransactionId` 二选一,推荐优先 `externalTransactionId`)
|
||||
|
||||
**轮询建议(商城侧)**:首次调用可在入队成功后约 1 分钟开始;之后间隔约 **60 秒** 查询一次,直至 `status` 为 `COMPLETED` 或 `FAILED`,或累计轮询达到约 **15~20 分钟**(与 10 分钟 Cron 留足余量)仍未终态则告警并转人工跟进。
|
||||
|
||||
请求字段说明(建议选其一作主键):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `externalTransactionId` | String | 是* | 商城之前提报请求时创建挂钩的原始提现单号,推荐此查维优先。 |
|
||||
| `playx_transaction_id` | String | 否 | 如果之前排队响应抛出了内部派发流水,也可以持此作为二级兜底查询条件。(二选一必填) |
|
||||
|
||||
响应字段说明(建议):
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `status` | String | 是 | 该笔资产调拨定时任务执行的彻底终态。只有两种预期终点:**`COMPLETED`**(入账成功) 或 **`FAILED`**(发放彻底阻断:如平台风控/未通过规则/账号封禁)。如果返回 `PENDING` 表示该 10 分钟 Cron 仍然没碰这笔单。 |
|
||||
| `amount` | Decimal | 否 | 最后实际结算派发的精准明细金额数。 |
|
||||
| `message` | String | 否 | 若拦截至 `FAILED` 终态,该字段负责说明 PlayX 端驳回的业务层原因,便于商城后端登记审计并自动回退积分。 |
|
||||
|
||||
## 6. 一致性、幂等与退回规则
|
||||
|
||||
### 6.1 幂等原则
|
||||
|
||||
- **每日推送**:以 `user_id + date` 去重,重复推送不得导致重复入池。
|
||||
- **兑换/提现交易**:以 `externalTransactionId` 幂等(商城生成并传给 PlayX)。
|
||||
- **领取**:以 `claim_request_id` 幂等,避免重复领取。
|
||||
|
||||
### 6.2 退积分规则(建议统一)
|
||||
|
||||
- **红利/提现**:
|
||||
- PlayX API 调用失败:订单保持 PENDING,进入可重试队列(不立即退积分,避免“退了但 PlayX 已受理/最终入账”的不一致)。
|
||||
- 当订单被判定为“最终失败”(例如超过最大重试次数或 PlayX 返回不可恢复错误)时:订单转 REJECTED,退回积分并记录原因。
|
||||
- **实物**:
|
||||
- 驳回必须退回积分,并记录 `reject_reason`。
|
||||
|
||||
### 6.3 重试与后台操作边界
|
||||
|
||||
- 仅允许对 “尚未收到 `status = \"accepted\"` 响应、且可以确认未成功发放/未入账” 的订单发起重试。
|
||||
- 每次重试必须生成并记录 `retry_request_id` 与操作者审计日志。
|
||||
- **强制防重约定**:**PlayX 必须根据 `externalTransactionId` 提供严格的幂等拦截能力!**由于网络请求存在“Read Timeout(读超时)”的黑盒场景,即 PlayX 实际已处理但响应由于网络中断未抵达商城,商城将会发起重试保护。如果 PlayX 不去拦截此重发单号,将必然出现给用户发双份钱的高危资损事故。
|
||||
|
||||
接收成功与终态闭环判定(关键约定):
|
||||
|
||||
- **第一步(接收排队)**:本系统调用发放类 API,仅当收到 HTTP 200 且 `status = "accepted"` 时,视为 PlayX 已接收入队成功,此时商城**绝不再对发放接口发起新的成功路径请求**。若返回非 200、或响应超时、或未能解析出明确 `accepted`:商城可对**同一** `externalTransactionId` 进行有限次重试;**PlayX 须对该单号严格幂等**——重复请求不得产生第二笔发放,且应返回与首次受理一致或可识别的幂等结果(如再次返回 `accepted` 或明确 `DUPLICATE_REQUEST` 等,由双方约定响应形态)。
|
||||
- **第二步(确认终态闭环)**:针对已入队返回 accepted 的订单,商城将调用**“交易终态查询 API”(5.5)**验证 PlayX 后台的最终发放结果实现闭环更新。
|
||||
|
||||
默认重试策略(建议):
|
||||
|
||||
- **自动重试**:对 `PLAYX_UPSTREAM_ERROR`/超时类错误,最多 3 次(间隔 1min/5min/15min)。
|
||||
- **不重试**:`INVALID_SIGNATURE`、`REQUEST_EXPIRED`、`RULE_NOT_SATISFIED`、`INVALID_TOKEN`(需要修复请求或重新鉴权)。
|
||||
- **人工重试**:后台按钮触发,要求输入原因并记录审计。
|
||||
|
||||
## 7. 安全要求(Shared Secret Key)
|
||||
|
||||
建议所有 PlayX ↔ Mall 的后端调用统一:
|
||||
|
||||
- Header:
|
||||
- `X-Request-Id`
|
||||
- `X-Timestamp`
|
||||
- `X-Signature`
|
||||
- 签名:使用共享 `SecretKey`,对 request body + timestamp + requestId 进行 HMAC(具体算法由双方定稿)。
|
||||
- 时效:timestamp 允许偏差窗口(例如 5 分钟),超出拒绝。
|
||||
|
||||
签名建议(可直接落地的默认):
|
||||
|
||||
- `X-Signature = HMAC_SHA256(secret, canonical_string)`
|
||||
- `canonical_string = X-Timestamp + \"\\n\" + X-Request-Id + \"\\n\" + HTTP_METHOD + \"\\n\" + PATH + \"\\n\" + SHA256(REQUEST_BODY_JSON)`
|
||||
|
||||
其中:
|
||||
|
||||
- `PATH` 不含域名与 querystring(例如 `/api/v1/playx/daily-push`)。
|
||||
- `REQUEST_BODY_JSON` 使用原始 request body(不做 key 排序时,需双方约定序列化方式;更推荐双方统一为“key 排序后的紧凑 JSON”)。
|
||||
|
||||
## 8. 错误码与可观测(建议)
|
||||
|
||||
最低要求:
|
||||
|
||||
- `INVALID_SIGNATURE`
|
||||
- `REQUEST_EXPIRED`
|
||||
- `INVALID_TOKEN`
|
||||
- `DUPLICATE_REQUEST`
|
||||
- `INSUFFICIENT_POINTS`
|
||||
- `RULE_NOT_SATISFIED`
|
||||
- `PLAYX_UPSTREAM_ERROR`
|
||||
|
||||
错误码返回结构(建议统一):
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "xxx",
|
||||
"code": "PLAYX_UPSTREAM_ERROR",
|
||||
"message": "timeout",
|
||||
"retryable": true
|
||||
}
|
||||
```
|
||||
|
||||
### 8.1 幂等:同一 `externalTransactionId` 重复调用(Bonus Grant / Balance Credit)
|
||||
|
||||
PlayX 须保证:**同一** `externalTransactionId` 无论被调用多少次,**资金侧最多只入账一次**。商城在「读超时重试」或联调压测时会重复提交同一单号,响应须符合以下 **两种约定之一**(联调前择一写死,避免双方解析不一致)。
|
||||
|
||||
**模式 A(推荐):再次请求仍返回 HTTP 200,且与首次受理语义一致**
|
||||
|
||||
- 第二次及以后请求:`status` 仍为 `"accepted"`(或文档约定的等价成功态),**不得**再次触发新的发放队列条目导致双发。
|
||||
- 建议同时带回**首次**的 `playx_transaction_id`(若与首次不同,须在联调中禁止或说明兼容规则)。
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_bonus_20260319_000099",
|
||||
"playx_transaction_id": "PX_TX_778899",
|
||||
"status": "accepted",
|
||||
"message": "duplicate externalTransactionId, already accepted"
|
||||
}
|
||||
```
|
||||
|
||||
**模式 B:显式重复错误码(HTTP 状态可与 PlayX 规范一致,如 200 或 409,联调前约定)**
|
||||
|
||||
- `code` 为 `DUPLICATE_REQUEST`(或双方统一的幂等冲突码),`retryable` 为 `false`,提示商城勿再重试发放接口、改查 5.5 终态。
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_bonus_20260319_000099",
|
||||
"code": "DUPLICATE_REQUEST",
|
||||
"message": "externalTransactionId already processed",
|
||||
"retryable": false,
|
||||
"playx_transaction_id": "PX_TX_778899"
|
||||
}
|
||||
```
|
||||
|
||||
日志与审计:
|
||||
|
||||
- 每次跨系统调用必须落 `request_id`、入参摘要、响应摘要、耗时、结果码。
|
||||
|
||||
## 9. 联调与验收清单
|
||||
|
||||
### 9.1 鉴权
|
||||
|
||||
- token 正常/过期/无效/重复请求
|
||||
- postMessage 未收到 token 的超时提示
|
||||
|
||||
### 9.2 每日推送
|
||||
|
||||
- 正常推送 1 次
|
||||
- 同一 `user_id+date` 重复推送(应 dedup)
|
||||
- 跨时区日期边界(按约定业务日)
|
||||
|
||||
### 9.3 领取
|
||||
|
||||
- locked=0 不可领取
|
||||
- 上限不足部分领取
|
||||
- 幂等:重复点击不重复加积分
|
||||
|
||||
### 9.4 红利/提现
|
||||
|
||||
- 发放接口:HTTP 200 且 `status="accepted"` 后,订单 PENDING,记录 `playx_transaction_id`,**不再对发放接口重放**(终态靠 5.5)。
|
||||
- 发放接口:非 200 / 超时 / 非 `accepted`:失败原因落库,可自动或人工重试;**PlayX 对同一 `externalTransactionId` 须严格幂等**。
|
||||
- **交易终态查询(5.5)**:按 `externalTransactionId` 查询,验证返回 `COMPLETED` / `FAILED` / `PENDING`;长时间 `PENDING` 走告警与人工。
|
||||
- 幂等联调:同一 `externalTransactionId` 连续发送 2 次,PlayX 侧**不得重复入账**,第二次响应须符合双方约定的幂等语义。
|
||||
|
||||
### 9.5 实物
|
||||
|
||||
- 提交收货信息
|
||||
- 发货录入物流单号
|
||||
- 驳回退积分并展示原因
|
||||
|
||||
## 10. 需要 PlayX 提供/确认的信息清单(用于联调收口)
|
||||
|
||||
- **Token Verification API**:URL、请求/响应字段、错误码、token 有效期/刷新策略、是否支持 session。
|
||||
- **Daily Push API**:推送时间点、时区、date 口径(业务日还是自然日)、失败重发策略、字段定义(特别是 `yesterday_win_loss_net` 的扣项范围)。
|
||||
- **Bonus Grant API / Balance Credit API**:URL、鉴权签名要求、字段最终表、**确认以 `externalTransactionId` 作为拦截幂等键**,以及返回的 `playx_transaction_id` 定义与查询方式。
|
||||
- **交易终态查询 API(如适用)**:提供专门供商城拉取订单最终入账结果的查询接口 URL 及返回结构。
|
||||
- **发送站内信 API(如适用)**:在管理后台手动处理实物商品发货/驳回时,如需通过信箱通知用户,请提供外部触发站内信的 API 渠道。
|
||||
- **枚举值配置**:请尽早提供发放接口中 `category` 等字段的固定枚举值字典,以便商城后台完成商品发货配置项的落库。
|
||||
|
||||
828
docs/PlayX-接口文档.md
Normal file
828
docs/PlayX-接口文档.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# PlayX 接口文档(按调用方向拆分)
|
||||
|
||||
说明:本文档严格依据当前代码 `app/api/controller/v1/Playx.php`、`app/api/controller/v1/Auth.php`(临时登录)、`config/playx.php` 与定时任务 `app/process/PlayxJobs.php` 整理。
|
||||
|
||||
三类接口分别为:
|
||||
- `积分商城 -> PlayX`(PlayX 调用商城)
|
||||
- `PlayX -> 积分商城`(商城调用 PlayX)
|
||||
- `积分商城 -> H5`(H5 调用商城)
|
||||
|
||||
---
|
||||
|
||||
## 1. 积分商城 -> PlayX(PlayX 调用商城)
|
||||
|
||||
### 1.1 Daily Push API
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/dailyPush`
|
||||
|
||||
#### Header(多语言,可选)
|
||||
- `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文
|
||||
|
||||
#### Header(签名校验:HMAC 必填)
|
||||
当 `playx.daily_push_secret` 配置非空时,需要携带(HMAC):
|
||||
- `X-Request-Id`:请求 ID
|
||||
- `X-Timestamp`:时间戳
|
||||
- `X-Signature`:签名(HMAC_SHA256)
|
||||
|
||||
服务端签名计算:
|
||||
- `canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/mall/dailyPush\n" + sha256(json_body)`
|
||||
- `expected = hash_hmac('sha256', canonical, daily_push_secret)`
|
||||
- 校验:`hash_equals(expected, X-Signature)`
|
||||
|
||||
说明:
|
||||
- 本项目对接方案为 **仅启用 HMAC**,不使用 `Authorization` 头做校验。
|
||||
|
||||
#### Body
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 外部推送请求号(原样返回) |
|
||||
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_playx_daily_push.date`) |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID(用于幂等;入库 `mall_playx_daily_push.user_id` 等;服务端会映射/创建 `mall_user` 与 `mall_playx_user_asset`) |
|
||||
| `username` | string | 否 | 展示冗余(同步到商城用户侧逻辑时使用) |
|
||||
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢(仅当 `< 0` 时计算新增保障金) |
|
||||
| `yesterday_total_deposit` | number | 否 | 昨日总充值(用于计算今日可领取上限) |
|
||||
| `lifetime_total_deposit` | number | 否 | 历史总充值 |
|
||||
| `lifetime_total_withdraw` | number | 否 | 历史总提现 |
|
||||
|
||||
##### 格式 B:新版批量上报(兼容你截图)
|
||||
新版 body 形如:
|
||||
```json
|
||||
{
|
||||
"report_date": "1700000000",
|
||||
"member": [
|
||||
{
|
||||
"member_id": "123456",
|
||||
"login": "john",
|
||||
"lty_deposit": 15230.75,
|
||||
"lty_withdrawal": 12400.50,
|
||||
"yesterday_total_w": -320.25,
|
||||
"yesterday_total_deposit": 500.00
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段映射(服务端内部会转换成旧字段再计算):
|
||||
- `report_date` -> `date`(若为 Unix 秒则转为 `YYYY-MM-DD`)
|
||||
- `member[].member_id` -> `user_id`
|
||||
- `member[].login` -> `username`
|
||||
- `member[].yesterday_total_w` -> `yesterday_win_loss_net`
|
||||
- `member[].yesterday_total_deposit` -> `yesterday_total_deposit`
|
||||
- `member[].lty_deposit` -> `lifetime_total_deposit`
|
||||
- `member[].lty_withdrawal` -> `lifetime_total_withdraw`
|
||||
|
||||
返回补充:
|
||||
- 批量模式会在 `data` 里增加 `results[]`,每个成员一条结果(是否 `deduped`)。
|
||||
|
||||
#### 幂等规则
|
||||
* 幂等键:`user_id + date`
|
||||
* 重复推送:不会重复入账,返回 `data.deduped=true`
|
||||
|
||||
#### 返回(Response)
|
||||
外层通用返回结构:`{ code, msg, time, data }`
|
||||
|
||||
成功(首次入库):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.request_id` | string | 原样返回 |
|
||||
| `data.accepted` | boolean | `true` |
|
||||
| `data.deduped` | boolean | `false` |
|
||||
| `data.message` | string | `ok` |
|
||||
|
||||
成功(重复推送):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.request_id` | string | 原样返回 |
|
||||
| `data.accepted` | boolean | `true` |
|
||||
| `data.deduped` | boolean | `true` |
|
||||
| `data.message` | string | `duplicate input` |
|
||||
|
||||
失败:
|
||||
* 当缺少必填字段:code=0,msg 为缺少字段错误
|
||||
* 当签名不正确:HTTP 401,code=0,msg 为 `INVALID_SIGNATURE`
|
||||
|
||||
#### 示例(未开启签名校验)
|
||||
请求:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"request_id":"req_1001",
|
||||
"date":"2026-03-18",
|
||||
"user_id":"U123",
|
||||
"username":"demo_user",
|
||||
"yesterday_win_loss_net":-120.5,
|
||||
"yesterday_total_deposit":50,
|
||||
"lifetime_total_deposit":5000,
|
||||
"lifetime_total_withdraw":2000
|
||||
}'
|
||||
```
|
||||
|
||||
响应(首次):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"request_id": "req_1001",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "ok"
|
||||
},
|
||||
"time": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### 示例(新版批量上报)
|
||||
请求:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"report_date": "1700000000",
|
||||
"member": [
|
||||
{
|
||||
"member_id": "123456",
|
||||
"login": "john",
|
||||
"lty_deposit": 15230.75,
|
||||
"lty_withdrawal": 12400.50,
|
||||
"yesterday_total_w": -320.25,
|
||||
"yesterday_total_deposit": 500.00
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
返回(首次写入至少一个成员时的示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 0,
|
||||
"data": {
|
||||
"request_id": "report_2023-11-14",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "Ok",
|
||||
"results": [
|
||||
{
|
||||
"user_id": "123456",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "Ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. PlayX -> 积分商城(商城调用 PlayX)
|
||||
|
||||
> 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果。
|
||||
> **说明**:H5 调商城的 **`/api/v1/mall/verifyToken`** 在配置 **`playx.verify_token_local_only=true`**(默认)时**不会请求**本节接口,而是在商城内校验 `muser` token;远程对接 PlayX 时见 **3.3** 与下文 **2.1**。
|
||||
|
||||
### 2.1 Token Verification API(PlayX 侧实现,远程验证时使用)
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.token_verify_url}`
|
||||
* 默认:`/api/v1/auth/verify-token`
|
||||
|
||||
#### 请求 Body(商城侧发送)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
|
||||
| `token` | string | 是 | 前端传入的 PlayX token |
|
||||
|
||||
#### 返回(期望)
|
||||
商城侧校验:
|
||||
* HTTP 状态码必须为 `200`
|
||||
* 且响应体中必须包含 `user_id`
|
||||
|
||||
期望字段(示例):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `user_id` | string | 必选 |
|
||||
| `username` | string | 可选 |
|
||||
| `token_expire_at` | string | 可选(能被 `strtotime` 解析) |
|
||||
|
||||
示例(成功):
|
||||
```json
|
||||
{
|
||||
"user_id": "U123",
|
||||
"username": "demo_user",
|
||||
"token_expire_at": "2026-04-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
示例(失败):
|
||||
```json
|
||||
{
|
||||
"message": "invalid token"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Bonus Grant API
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.bonus_grant_url}`
|
||||
* 默认:`/api/v1/bonus/grant`
|
||||
|
||||
#### 请求 Body(商城侧发送)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 形如 `mall_bonus_{uniqid}` |
|
||||
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID |
|
||||
| `amount` | number | 是 | `MallPlayxOrder.amount` |
|
||||
| `rewardName` | string | 是 | `mall_item.title` |
|
||||
| `category` | string | 是 | `mall_item.category`(默认 `daily`) |
|
||||
| `categoryTitle` | string | 是 | `mall_item.category_title` |
|
||||
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
|
||||
|
||||
#### 返回(期望)
|
||||
商城侧判定:
|
||||
* HTTP 状态码 `200`
|
||||
* 且 `data.status === "accepted"`
|
||||
|
||||
成功时读取:
|
||||
* `data.playx_transaction_id`
|
||||
|
||||
失败时读取:
|
||||
* `data.message` 写入订单 `fail_reason`
|
||||
|
||||
示例(accepted):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"playx_transaction_id": "PX_TX_001"
|
||||
}
|
||||
```
|
||||
|
||||
示例(rejected):
|
||||
```json
|
||||
{
|
||||
"status": "rejected",
|
||||
"message": "insufficient balance"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Balance Credit API
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.balance_credit_url}`
|
||||
* 默认:`/api/v1/balance/credit`
|
||||
|
||||
#### 请求 Body(商城侧发送)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 形如 `mall_withdraw_{uniqid}` |
|
||||
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID |
|
||||
| `amount` | number | 是 | `MallPlayxOrder.amount` |
|
||||
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
|
||||
|
||||
#### 返回(期望)
|
||||
与 Bonus Grant 一致:
|
||||
* `data.status === "accepted"` -> 读取 `playx_transaction_id`
|
||||
* 否则 -> 读取 `message` 写入 `fail_reason`
|
||||
|
||||
示例(accepted):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"playx_transaction_id": "PX_TX_002"
|
||||
}
|
||||
```
|
||||
|
||||
示例(rejected):
|
||||
```json
|
||||
{
|
||||
"status": "rejected",
|
||||
"message": "insufficient balance"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Transaction Status Query API(交易终态查询)
|
||||
* 方法:`GET`
|
||||
* URL:`${playx.api.base_url}${playx.api.transaction_status_url}`
|
||||
* 默认:`/api/v1/transaction/status`
|
||||
|
||||
#### Query 参数
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `externalTransactionId` | string | 是 | 订单幂等键 `external_transaction_id` |
|
||||
|
||||
#### 返回(期望)
|
||||
定时任务读取 `data.status`:
|
||||
* `COMPLETED`:商城将订单 `status` 更新为 `COMPLETED`
|
||||
* `FAILED` 或 `REJECTED`:商城将订单 `status=REJECTED`、`grant_status=FAILED_FINAL`,并退回积分
|
||||
* 失败信息取 `data.message` 写入订单 `fail_reason`
|
||||
|
||||
示例(completed):
|
||||
```json
|
||||
{ "status": "COMPLETED" }
|
||||
```
|
||||
|
||||
示例(failed):
|
||||
```json
|
||||
{ "status": "FAILED", "message": "grant rejected by PlayX" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 积分商城 -> H5(服务端提供给 H5 的接口)
|
||||
|
||||
### 3.0 数据模型说明(与代码一致)
|
||||
|
||||
* **商城用户**:表 `mall_user`(主键 `id`)。
|
||||
* **PlayX 资产扩展**:表 `mall_playx_user_asset`,与 `mall_user` **一对一**(`mall_user_id` 唯一,`playx_user_id` 唯一)。
|
||||
* **对外业务 ID**:接口里返回或订单里使用的 `user_id` 字符串多为 **PlayX 侧用户 ID**(`playx_user_id`);H5 临时登录场景若尚无真实 PlayX ID,会生成形如 **`mall_{mall_user.id}`** 的占位 ID(见 `temLogin`)。
|
||||
* **服务端内部**:`Playx` 控制器内部用 **`mall_user.id`**(整型)解析资产;`session_id` / `token` / `user_id` 会映射到该 `mall_user`。
|
||||
|
||||
### 3.1 鉴权解析规则(`resolveMallUserIdFromRequest`)
|
||||
|
||||
以下接口在服务端最终都会解析出 **商城用户 `mall_user.id`**,再按该用户查询 `mall_playx_user_asset` 等。
|
||||
|
||||
优先级(由高到低):
|
||||
|
||||
1. **`session_id`**(`post` 优先,`get` 兼容)
|
||||
* 在 `mall_playx_session` 中存在且未过期:用会话里的 `user_id`(即 `playx_user_id`)在 `mall_playx_user_asset` 反查 `mall_user_id`。
|
||||
* 若会话无效:兼容把 `session_id` 参数误当作 **商城 token** 再试一次(UUID 形态 token)。
|
||||
2. **`token`**(`post` / `get` 或请求头 **`ba-token`** / **`token`**)
|
||||
* 校验 `token` 表:类型为会员 `user` 或商城临时 **`muser`**(`mall_user` 登录),未过期则 `user_id` 字段即为 **`mall_user.id`**。
|
||||
3. **`user_id`**(`post` / `get` 兼容)
|
||||
* **纯数字**:视为 **`mall_user.id`**。
|
||||
* **非纯数字**:视为 **`playx_user_id`**,在 `mall_playx_user_asset` 查找对应 `mall_user_id`。
|
||||
|
||||
> 注意:请求参数的取值方式是 `post()` 优先,`get()` 兼容(即同字段既可传 post 也可传 get)。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 临时登录(获取商城 token)
|
||||
|
||||
* 方法:`GET`(推荐)或 `POST`
|
||||
* 路径:`/api/v1/temLogin`
|
||||
* 开关:`config/buildadmin.php` → `agent_auth.temp_login_enable` 为 `true`;有效期 `agent_auth.temp_login_expire`(秒)。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `username` | string | 是 | 商城用户名(唯一);不存在则自动创建 `mall_user` |
|
||||
|
||||
#### 行为说明
|
||||
|
||||
* 若 `mall_user` 不存在:创建用户(随机占位手机号、随机密码等,与后台「商城用户」一致)。
|
||||
* **无论是否新用户**:保证存在 **`mall_playx_user_asset`** 一条记录(`MallPlayxUserAsset::ensureForMallUser`),`playx_user_id` 默认 **`mall_{mall_user.id}`**(与 PlayX 真实 ID 冲突概率低)。
|
||||
* 签发 **商城 token**(类型 **`muser`**,非会员表 `user`),并签发 `muser-refresh` 刷新令牌。
|
||||
|
||||
#### 返回(成功 data.userInfo)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int | `mall_user.id` |
|
||||
| `username` | string | 用户名 |
|
||||
| `nickname` | string | 同 `username` |
|
||||
| `playx_user_id` | string | 资产表中的 `playx_user_id`(如 `mall_12`) |
|
||||
| `token` | string | 访问 H5 接口时携带 |
|
||||
| `refresh_token` | string | 调用 `/api/common/refreshToken` 时使用(类型 `muser-refresh`) |
|
||||
| `expires_in` | int | token 有效秒数 |
|
||||
|
||||
#### 示例
|
||||
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_h5'
|
||||
```
|
||||
|
||||
用户名含 `+` 等号时需 URL 编码(如 `%2B60123456789`)。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Token 验证(换 session)
|
||||
|
||||
* 方法:`POST`(推荐 `GET` 传 `token` 亦可)
|
||||
* 路径:`/api/v1/mall/verifyToken`
|
||||
|
||||
#### 配置:本地验证 vs 远程 PlayX
|
||||
|
||||
* 配置项:`config/playx.php` → **`verify_token_local_only`**(环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**,未设置时默认为 **`1` / 开启本地验证)。
|
||||
* **`verify_token_local_only = true`(默认)**
|
||||
* **不请求** PlayX HTTP。
|
||||
* 仅接受商城临时登录 token(类型 **`muser`**),校验 `token` 表后,根据 `mall_user` 与 `mall_playx_user_asset` 写入 `mall_playx_session`。
|
||||
* 返回的 `data.user_id` 为 **`playx_user_id`**(无资产记录时回退为 `mall_user.id` 字符串,一般 temLogin 后已有资产)。
|
||||
* **`verify_token_local_only = false`**(生产对接 PlayX)
|
||||
* 需配置 **`playx.api.base_url`**,由商城向 PlayX 发起 `POST` 校验(见下文「远程模式」)。
|
||||
* 若未配置 `base_url`,返回 `PlayX API not configured`。
|
||||
|
||||
#### 请求参数
|
||||
|
||||
必填其一:
|
||||
|
||||
* `token`(Body 优先;`session` 兼容字段;Query 也可传 `token`)
|
||||
|
||||
#### 返回(成功 data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `session_id` | string | 写入 `mall_playx_session` |
|
||||
| `user_id` | string | PlayX 用户 ID(即 `playx_user_id`,会话内与订单/推送一致) |
|
||||
| `username` | string | 用户名 |
|
||||
| `token_expire_at` | string | ISO 字符串(服务端 `date('c', expireAt)`) |
|
||||
|
||||
失败:
|
||||
|
||||
* token 为空:HTTP 401,msg=`INVALID_TOKEN`
|
||||
* 远程模式且 PlayX 未配置:`msg=PlayX API not configured`
|
||||
|
||||
#### 示例(本地验证)
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'token=上一步TemLogin返回的token'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.x 收货地址(`mall_address`)
|
||||
|
||||
> 下面接口用于 H5 维护收货地址。鉴权同本章其他接口:携带 `session_id` 或 `token` 或 `user_id`。
|
||||
|
||||
#### 3.x.1 地址列表
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/addressList`
|
||||
|
||||
返回:`data.list` 为地址数组。
|
||||
|
||||
#### 3.x.2 添加地址
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/addressAdd`
|
||||
|
||||
Body:
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `receiver_name` | 是 | 收货人 |
|
||||
| `phone` | 是 | 电话 |
|
||||
| `region` | 是 | 地区(数组或逗号分隔字符串) |
|
||||
| `detail_address` | 是 | 详细地址 |
|
||||
| `default_setting` | 否 | `1` 设为默认地址 |
|
||||
|
||||
#### 3.x.3 修改地址(含设为默认)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/addressEdit`
|
||||
|
||||
Body:`id` 必填,其余字段按需传入更新。
|
||||
|
||||
#### 3.x.4 删除地址
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/addressDelete`
|
||||
|
||||
Body:`id` 必填。若删除默认地址,服务端会自动挑选一条剩余地址设为默认(如存在)。
|
||||
|
||||
#### 远程模式(`verify_token_local_only=false` + 已配置 `base_url`)
|
||||
|
||||
商城侧请求 URL:`${playx.api.base_url}${playx.api.token_verify_url}`(默认路径 `/api/v1/auth/verify-token`)。
|
||||
|
||||
#### 请求 Body(商城侧发送)——仅远程模式
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
|
||||
| `token` | string | 是 | 前端传入的 PlayX token |
|
||||
|
||||
#### 返回(期望)——仅远程模式
|
||||
|
||||
* HTTP 状态码必须为 `200`
|
||||
* 且响应体中必须包含 `user_id`
|
||||
|
||||
响应(成功示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"session_id": "7b1c....",
|
||||
"user_id": "U123",
|
||||
"username": "demo_user",
|
||||
"token_expire_at": "2026-04-01T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 用户资产(Assets)
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/assets`
|
||||
|
||||
#### 请求参数(鉴权)
|
||||
|
||||
以下任选其一即可(与 **3.1 鉴权解析规则** 一致):
|
||||
|
||||
* `session_id`
|
||||
* `token`(或请求头 `ba-token` / `token`)
|
||||
* `user_id`(纯数字为 `mall_user.id`,否则为 `playx_user_id`)
|
||||
|
||||
#### 返回(成功 data)
|
||||
若未找到资产:返回 0。
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `locked_points` | int | 待领取积分 |
|
||||
| `available_points` | int | 可用积分 |
|
||||
| `today_limit` | int | 今日可领取上限 |
|
||||
| `today_claimed` | int | 今日已领取 |
|
||||
| `withdrawable_cash` | number(2) | `available_points * points_to_cash_ratio`(保留 2 位) |
|
||||
|
||||
#### 示例
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'token=上一步temLogin返回的token'
|
||||
```
|
||||
|
||||
响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 1712345678,
|
||||
"data": {
|
||||
"locked_points": 100,
|
||||
"available_points": 50,
|
||||
"today_limit": 200,
|
||||
"today_claimed": 80,
|
||||
"withdrawable_cash": 5.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 领取(Claim)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/claim`
|
||||
|
||||
#### 请求 Body
|
||||
必填:
|
||||
|
||||
* `claim_request_id`:幂等键(string,唯一)
|
||||
|
||||
鉴权:同 **3.1**(`session_id` / `token` / `user_id`)
|
||||
|
||||
#### 返回(成功 data)
|
||||
与 `用户资产` 返回字段一致(资产快照)。
|
||||
|
||||
幂等:
|
||||
* `claim_request_id` 已存在:不会重复入账,直接返回当前资产快照
|
||||
|
||||
#### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'claim_request_id=claim_001' \
|
||||
--data-urlencode 'token=上一步temLogin返回的token'
|
||||
```
|
||||
|
||||
响应(首次领取,示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Claim success",
|
||||
"time": 1712345679,
|
||||
"data": {
|
||||
"locked_points": 60,
|
||||
"available_points": 90,
|
||||
"today_limit": 200,
|
||||
"today_claimed": 120,
|
||||
"withdrawable_cash": 9.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应(幂等重复,示例:可能 msg 为空):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 1712345680,
|
||||
"data": {
|
||||
"locked_points": 60,
|
||||
"available_points": 90,
|
||||
"today_limit": 200,
|
||||
"today_claimed": 120,
|
||||
"withdrawable_cash": 9.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 商品列表
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/items`
|
||||
|
||||
#### 请求参数
|
||||
* `type`(可选):`BONUS` | `PHYSICAL` | `WITHDRAW`
|
||||
|
||||
不传或空:返回 `mall_item.status=1` 且不过滤 type 的商品列表。
|
||||
|
||||
#### 返回(成功 data)
|
||||
* `list`:商品列表(直接返回 `MallItem` 的字段数组;包含扩展字段:`amount/multiplier/category/category_title` 等)
|
||||
|
||||
#### 示例
|
||||
请求:
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=WITHDRAW'
|
||||
```
|
||||
|
||||
响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 1712345685,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 321,
|
||||
"title": "提现档位A",
|
||||
"type": 3,
|
||||
"score": 1000,
|
||||
"amount": 100.0,
|
||||
"multiplier": 1,
|
||||
"category": "withdraw",
|
||||
"category_title": "提现"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 红利兑换(Bonus Redeem)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/bonusRedeem`
|
||||
|
||||
#### 请求 Body
|
||||
必填:
|
||||
* `item_id`:商品 ID(要求 `mall_item.type=BONUS` 且 `status=1`)
|
||||
鉴权:同 **3.1**(`session_id` / `token` / `user_id`)
|
||||
|
||||
#### 返回(成功)
|
||||
* `msg`:`Redeem submitted, please wait about 10 minutes`
|
||||
* `data.order_id`:订单 ID
|
||||
* `data.status`:`PENDING`
|
||||
|
||||
#### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=123' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Redeem submitted, please wait about 10 minutes",
|
||||
"time": 1712345686,
|
||||
"data": {
|
||||
"order_id": 456,
|
||||
"status": "PENDING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 实物兑换(Physical Redeem)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/physicalRedeem`
|
||||
|
||||
#### 请求 Body
|
||||
必填:
|
||||
* `item_id`:商品 ID(要求 `mall_item.type=PHYSICAL` 且 `status=1`)
|
||||
* `address_id`:收货地址 ID(`mall_address.id`,须属于当前用户资产;下单时写入 `mall_order.mall_address_id`,并将该地址快照写入 `receiver_name` / `receiver_phone` / `receiver_address`)
|
||||
鉴权:同 **3.1**(`session_id` / `token` / `user_id`)
|
||||
|
||||
#### 返回(成功)
|
||||
* `msg`:`Redeem success`
|
||||
* `data`:`null`
|
||||
|
||||
#### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=200' \
|
||||
--data-urlencode 'address_id=10' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Redeem success",
|
||||
"time": 1712345687,
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.9 提现申请(Withdraw Apply)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/withdrawApply`
|
||||
|
||||
#### 请求 Body
|
||||
必填:
|
||||
* `item_id`:商品 ID(要求 `mall_item.type=WITHDRAW` 且 `status=1`)
|
||||
鉴权:同 **3.1**(`session_id` / `token` / `user_id`)
|
||||
|
||||
#### 返回(成功)
|
||||
* `msg`:`Withdraw submitted, please wait about 10 minutes`
|
||||
* `data.order_id`:订单 ID
|
||||
* `data.status`:`PENDING`
|
||||
|
||||
#### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=321' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Withdraw submitted, please wait about 10 minutes",
|
||||
"time": 1712345688,
|
||||
"data": {
|
||||
"order_id": 789,
|
||||
"status": "PENDING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.10 订单列表
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/orders`
|
||||
|
||||
#### 请求参数(鉴权)
|
||||
|
||||
同 **3.1**(`session_id` / `token` / `user_id`)。
|
||||
|
||||
#### 返回(成功 data)
|
||||
|
||||
* `list`:订单列表(最多 100 条),并包含关联的 `mallItem`(关系对象)
|
||||
* 列表项中的 `user_id` 为 **PlayX 侧 `playx_user_id`**(字符串),与 `mall_playx_order.user_id` 一致
|
||||
|
||||
#### 示例
|
||||
请求:
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'token=上一步temLogin返回的token'
|
||||
```
|
||||
|
||||
响应(示例,简化):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 1712345689,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 456,
|
||||
"user_id": "U123",
|
||||
"type": "BONUS",
|
||||
"status": "PENDING",
|
||||
"mall_item_id": 123,
|
||||
"points_cost": 100,
|
||||
"amount": 10.0,
|
||||
"external_transaction_id": "BONUS_ORD2026....",
|
||||
"grant_status": "NOT_SENT",
|
||||
"mallItem": {
|
||||
"id": 123,
|
||||
"title": "每日红利",
|
||||
"type": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.11 同步额度(可选)
|
||||
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`。
|
||||
|
||||
447
docs/PlayX-调用积分商城接口说明.md
Normal file
447
docs/PlayX-调用积分商城接口说明.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# PlayX 调用积分商城接口说明
|
||||
|
||||
本文档描述 **PlayX 平台(或 PlayX 侧脚本/服务)如何调用积分商城已开放的 HTTP 接口**:基础约定、推荐流程、鉴权方式、请求参数与返回结构。
|
||||
|
||||
实现依据:`config/route.php`、`app/api/controller/v1/Playx.php`、`config/playx.php`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础约定
|
||||
|
||||
### 1.1 Base URL
|
||||
|
||||
将下列路径拼在积分商城对外域名之后,例如:
|
||||
|
||||
`https://{商城域名}/api/v1/mall/dailyPush`
|
||||
|
||||
(联调时请向商城方索取正式环境与测试环境地址。)
|
||||
|
||||
### 1.2 通用响应结构(JSON)
|
||||
|
||||
所有接口成功或失败,响应体均为:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | int | `1` 表示业务成功;`0` 表示业务失败 |
|
||||
| `msg` | string | 提示信息(失败时为错误原因) |
|
||||
| `time` | int | Unix 时间戳(秒) |
|
||||
| `data` | object/array/null | 业务数据;失败时可能为 `null` |
|
||||
|
||||
部分错误场景会通过 HTTP 状态码区分(如 **401**),此时 `code` 仍为 `0`,请同时判断 HTTP 状态与 `code`。
|
||||
|
||||
**成功示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 1730000000,
|
||||
"data": { }
|
||||
}
|
||||
```
|
||||
|
||||
**失败示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "错误原因",
|
||||
"time": 1730000000,
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Content-Type
|
||||
|
||||
- 本文档中 **POST** 且带 JSON Body 的接口,请使用:`Content-Type: application/json`。
|
||||
|
||||
### 1.4 多语言(响应文案)
|
||||
|
||||
可通过请求头 `lang` 控制返回文案语言:
|
||||
|
||||
| Header | 值 | 说明 |
|
||||
|--------|----|------|
|
||||
| `lang` | `zh` / `zh-cn` | 返回中文(默认) |
|
||||
| `lang` | `en` | 返回英文 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用流程(推荐)
|
||||
|
||||
### 2.1 PlayX 服务端 → 商城:每日数据推送(主流程)
|
||||
|
||||
适用于 T+1 等业务数据由 **PlayX 服务端**主动推送到积分商城。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PX as PlayX 服务端
|
||||
participant M as 积分商城
|
||||
|
||||
Note over PX,M: 按约定配置 HMAC 密钥
|
||||
PX->>M: POST /api/v1/mall/dailyPush(JSON Body)
|
||||
M-->>PX: code=1, data.accepted / deduped
|
||||
```
|
||||
|
||||
1. 与商城方约定 **商城 Base URL** 与 **HMAC**(`X-Signature` 等)密钥。
|
||||
2. 按 **§3** 构造请求并推送。
|
||||
3. 根据返回 `data.deduped` 判断是否为幂等重复推送。
|
||||
|
||||
### 2.2 用户侧(H5 / 内嵌页)→ 商城:会话与业务接口
|
||||
|
||||
以下接口多由 **用户在浏览器内**打开积分商城 H5 后调用,通过 **`session_id`**(先调 `verifyToken` 获取)或 **`token`**(商城 `muser` 类 token)标识用户,**不一定由 PlayX 后端直接调用**:
|
||||
|
||||
- `POST /api/v1/mall/verifyToken`:用 PlayX token 换商城 `session_id`
|
||||
- `GET /api/v1/mall/assets`:查询资产
|
||||
- `POST /api/v1/mall/claim`:领取积分
|
||||
- `GET /api/v1/mall/items`:商品列表
|
||||
- `POST /api/v1/mall/bonusRedeem` / `physicalRedeem` / `withdrawApply`:兑换与提现申请
|
||||
- `GET /api/v1/mall/orders`:订单列表
|
||||
|
||||
若 PlayX 后端需要代替用户调用上述接口,须同样携带有效的 `session_id` 或 `token`,并遵守同一用户身份规则(见 **§4 身份说明**)。
|
||||
|
||||
### 2.3 代理鉴权(非 PlayX 通用)
|
||||
|
||||
`GET /api/v1/authToken` 为 **渠道/代理**签名换 JWT(`authtoken`),与 PlayX 用户体系不同,一般不在本文「PlayX 平台对接」主流程中展开;需要时由运营向商城索取单独说明。
|
||||
|
||||
---
|
||||
|
||||
## 3. PlayX 服务端推送:Daily Push
|
||||
|
||||
### 3.1 概要
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 方法 | `POST` |
|
||||
| 路径 | `/api/v1/mall/dailyPush` |
|
||||
|
||||
### 3.2 鉴权(按商城部署配置,可组合)
|
||||
|
||||
#### 推荐方案:仅启用 HMAC(当前对接采用)
|
||||
|
||||
商城侧配置:设置环境变量 **`PLAYX_DAILY_PUSH_SECRET`** 为非空(启用 HMAC 校验)。
|
||||
|
||||
#### HMAC 签名(必填)
|
||||
|
||||
当商城配置 **`PLAYX_DAILY_PUSH_SECRET`** 非空时,需同时携带:
|
||||
|
||||
| Header | 说明 |
|
||||
|--------|------|
|
||||
| `X-Request-Id` | 请求 ID(建议与 Body 内可追溯字段一致) |
|
||||
| `X-Timestamp` | Unix 时间戳(秒,字符串) |
|
||||
| `X-Signature` | 签名(十六进制小写或大写需与实现一致,以下为十六进制字符串) |
|
||||
|
||||
签名原文与计算:
|
||||
|
||||
```
|
||||
canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/mall/dailyPush\n" + sha256(json_body)
|
||||
expected = HMAC_SHA256( canonical , PLAYX_DAILY_PUSH_SECRET )
|
||||
```
|
||||
|
||||
其中 `json_body` 为 **实际发送的 JSON 原始字符串** 计算出的 SHA256(十六进制);与 PHP `hash('sha256', $rawBody)` 一致。
|
||||
|
||||
校验:`hash_equals(expected, X-Signature)`。
|
||||
|
||||
#### Header 填写清单(HMAC 模式)
|
||||
- 必填:`X-Request-Id`、`X-Timestamp`、`X-Signature`
|
||||
|
||||
#### 重要注意:`json_body` 必须与实际发送一致
|
||||
为了保证签名可验通过:用于计算 sha256 的 `json_body` 必须是**实际发送到 HTTP body 的原始 JSON 字符串**(字节级一致)。\
|
||||
建议:在发送端先序列化 JSON 得到字符串 `rawBody`,用该 `rawBody` 做 sha256 与 HMAC,再把同一个 `rawBody` 作为请求 body 发送。
|
||||
|
||||
### 3.3 Body 参数(JSON)
|
||||
|
||||
`/api/v1/mall/dailyPush` 支持 **两种入参格式**(按字段自动识别):
|
||||
|
||||
#### 格式 A:旧版单条上报(兼容)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 本次推送请求号;响应中原样返回 |
|
||||
| `date` | string | 是 | 业务日期,格式 `YYYY-MM-DD` |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID(幂等键之一) |
|
||||
| `username` | string | 否 | 展示名;用于同步/创建商城侧用户资产展示信息 |
|
||||
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢;**小于 0** 时按配置比例计入待领取保障金(`locked_points`) |
|
||||
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算当日可领取上限等 |
|
||||
| `lifetime_total_deposit` | number | 否 | 历史总充值(冗余入库) |
|
||||
| `lifetime_total_withdraw` | number | 否 | 历史总提现(冗余入库) |
|
||||
|
||||
#### 格式 B:新版批量上报(你图中格式)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `report_date` | string/number | 是 | 报表日期;可以为 Unix 秒时间戳(如 `1700000000`)或 `YYYY-MM-DD` |
|
||||
| `member` | array | 是 | 成员列表,每个成员包含一名 PlayX 用户数据 |
|
||||
|
||||
成员元素字段:
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `member_id` | string | 是 | PlayX 用户 ID(幂等键之一) |
|
||||
| `login` | string | 否 | 用户展示名 |
|
||||
| `yesterday_total_w` | number | 否 | 昨日净输赢;小于 0 才会累加到 `locked_points` |
|
||||
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算 `today_limit` |
|
||||
| `lty_deposit` | number | 否 | 历史总充值(冗余入库) |
|
||||
| `lty_withdrawal` | number | 否 | 历史总提现(冗余入库) |
|
||||
|
||||
#### Body 填写要求(批量模式)
|
||||
- **必须有**:`report_date`、`member`(数组且至少 1 个元素)、`member[].member_id`。
|
||||
- **允许缺省**:成员的 `login/yesterday_total_w/yesterday_total_deposit/lty_deposit/lty_withdrawal`;缺省时按 `0` 或空字符串处理。
|
||||
- **日期**:`report_date` 传 Unix 秒会自动转换成 `YYYY-MM-DD`;如果直接传 `YYYY-MM-DD` 也支持。
|
||||
|
||||
### 3.4 幂等
|
||||
|
||||
- 幂等键:**`user_id` + `date`**
|
||||
- 重复推送:不重复入账,返回 `data.deduped = true`
|
||||
|
||||
### 3.5 返回 `data` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `request_id` | string | 与请求一致 |
|
||||
| `accepted` | bool | 是否受理成功 |
|
||||
| `deduped` | bool | 是否为重复推送(幂等命中) |
|
||||
| `message` | string | 说明文案 |
|
||||
|
||||
#### 格式 B:批量上报的返回补充
|
||||
批量模式会在 `data` 中增加:`results`。
|
||||
|
||||
`data.results` 为数组,元素字段如下:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `user_id` | string | 对应成员的 `member_id` |
|
||||
| `accepted` | bool | 是否受理成功 |
|
||||
| `deduped` | bool | 该成员是否为重复推送 |
|
||||
| `message` | string | `ok` 或 `duplicate input` |
|
||||
|
||||
**HTTP 401**:HMAC 不通过(签名缺失/不完整/校验失败)。
|
||||
|
||||
### 3.6 请求示例
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Request-Id: req_1700000000_123456' \
|
||||
-H 'X-Timestamp: 1700000000' \
|
||||
-H 'X-Signature: <按本文档 canonical 计算出的 HMAC_SHA256>' \
|
||||
-d '{
|
||||
"report_date": "1700000000",
|
||||
"member": [
|
||||
{
|
||||
"member_id": "123456",
|
||||
"login": "john",
|
||||
"lty_deposit": 15230.75,
|
||||
"lty_withdrawal": 12400.50,
|
||||
"yesterday_total_w": -320.25,
|
||||
"yesterday_total_deposit": 500.00
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
响应示例(首次写入,至少有一个成员非重复):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"time": 0,
|
||||
"data": {
|
||||
"request_id": "report_2023-11-14",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "Ok",
|
||||
"results": [
|
||||
{
|
||||
"user_id": "123456",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "Ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 身份说明(`session_id` / `token` / `user_id`)
|
||||
|
||||
以下接口通过 **`resolvePlayxAssetIdFromRequest`** 解析当前用户,优先级如下:
|
||||
|
||||
1. **`session_id`**(POST/GET):对应商城表 `mall_playx_session`,未过期则映射到 `mall_playx_user_asset`。
|
||||
2. 若 `session_id` 实际是 **`muser` 类型 token**(历史兼容),也会按 token 解析。
|
||||
3. **`token`**(POST/GET 或标准鉴权头):商城 token 表内类型为会员或 **`muser`** 且未过期时,`user_id` 为 **`mall_playx_user_asset.id`**(资产表主键)。
|
||||
4. **`user_id`**(POST/GET):
|
||||
- 若**纯数字**:视为 **`mall_playx_user_asset.id`**;
|
||||
- 否则:按 **`playx_user_id`** 查找资产行。
|
||||
|
||||
无法解析身份时,通常返回 **401** 或参数错误提示。
|
||||
|
||||
---
|
||||
|
||||
## 5. 其他接口一览(摘要)
|
||||
|
||||
> 下列均为 **BuildAdmin 通用 `code/msg/time/data` 结构**;成功时 `code=1`。
|
||||
|
||||
### 5.1 `POST /api/v1/mall/verifyToken`
|
||||
|
||||
用于将 **PlayX token**(或本地联调 token)换 **商城 `session_id`**。
|
||||
|
||||
| 参数位置 | 名称 | 说明 |
|
||||
|----------|------|------|
|
||||
| POST/GET | `token` 或 `session` | PlayX 或商城 token |
|
||||
|
||||
**说明:** 若 `playx.verify_token_local_only=true`(默认),商城**仅本地校验** token,不请求 PlayX 远程接口;远程模式需配置 `PLAYX_API_BASE_URL` 等。
|
||||
|
||||
**成功 `data` 示例:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `session_id` | 后续接口可带此字段 |
|
||||
| `user_id` | PlayX 用户 ID 或映射后的标识 |
|
||||
| `username` | 用户名 |
|
||||
| `token_expire_at` | ISO8601 过期时间 |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 `GET /api/v1/mall/assets`
|
||||
|
||||
查询积分资产;需 **§4** 身份。
|
||||
|
||||
**成功 `data`:**
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `locked_points` | 待领取积分 |
|
||||
| `available_points` | 可用积分 |
|
||||
| `today_limit` | 今日可领取上限 |
|
||||
| `today_claimed` | 今日已领取 |
|
||||
| `withdrawable_cash` | 可提现现金(由积分×配置比例换算,保留小数) |
|
||||
|
||||
---
|
||||
|
||||
### 5.3 `POST /api/v1/mall/claim`
|
||||
|
||||
领取积分;需 **§4** 身份。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `claim_request_id` | 是 | 幂等请求号 |
|
||||
|
||||
**成功 `data`:** 与资产结构一致(含 `locked_points`、`available_points`、`today_limit`、`today_claimed`、`withdrawable_cash` 等)。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 `GET /api/v1/mall/items`
|
||||
|
||||
商品列表。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | 否 | `BONUS` / `PHYSICAL` / `WITHDRAW`,筛选类型 |
|
||||
|
||||
**成功 `data`:** `{ "list": [ ... ] }`
|
||||
|
||||
---
|
||||
|
||||
### 5.5 `POST /api/v1/mall/bonusRedeem`
|
||||
|
||||
红利兑换;需 **§4** 身份。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `item_id` | 是 | 商品 ID |
|
||||
|
||||
**成功 `data`:** 含 `order_id`、`status`(如 `PENDING`)等。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 `POST /api/v1/mall/physicalRedeem`
|
||||
|
||||
实物兑换;需 **§4** 身份。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `item_id` | 是 | 实物商品 ID |
|
||||
| `address_id` | 是 | `mall_address.id`(当前用户下地址);订单保存 `mall_address_id` 与地址快照 |
|
||||
|
||||
---
|
||||
|
||||
### 5.7 `POST /api/v1/mall/withdrawApply`
|
||||
|
||||
提现类兑换申请;需 **§4** 身份。
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `item_id` | 是 | 提现档位商品 ID |
|
||||
|
||||
---
|
||||
|
||||
### 5.8 `GET /api/v1/mall/orders`
|
||||
|
||||
订单列表;需 **§4** 身份。
|
||||
|
||||
**成功 `data`:** `{ "list": [ ... ] }`(含关联商品等,以实际返回为准)。
|
||||
|
||||
---
|
||||
|
||||
### 5.9 收货地址(`mall_address`)
|
||||
|
||||
> 下列接口均需携带 **§4 身份参数**(`session_id` / `token` / `user_id` 之一)。
|
||||
|
||||
#### 5.9.1 获取收货地址列表
|
||||
- **方法**:`GET`
|
||||
- **路径**:`/api/v1/mall/addressList`
|
||||
|
||||
返回 `data.list`:地址数组(按 `default_setting` 优先,其次 id 倒序)。
|
||||
|
||||
#### 5.9.2 添加收货地址
|
||||
- **方法**:`POST`
|
||||
- **路径**:`/api/v1/mall/addressAdd`
|
||||
|
||||
Body(表单或 JSON 均可,建议 JSON):
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `receiver_name` | 是 | 收货人 |
|
||||
| `phone` | 是 | 联系电话 |
|
||||
| `region` | 是 | 地区(可传数组或逗号分隔字符串) |
|
||||
| `detail_address` | 是 | 详细地址(短文本) |
|
||||
| `default_setting` | 否 | `1` 设为默认地址;`0` 或不传为非默认 |
|
||||
|
||||
成功返回:`data.id` 为新地址 id。
|
||||
|
||||
#### 5.9.3 修改收货地址(含设置默认)
|
||||
- **方法**:`POST`
|
||||
- **路径**:`/api/v1/mall/addressEdit`
|
||||
|
||||
Body:
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 地址 id |
|
||||
| `receiver_name/phone/region/detail_address/default_setting` | 否 | 需要修改的字段(只更新传入项) |
|
||||
|
||||
当 `default_setting=1`:会自动把该用户其他地址的 `default_setting` 置为 0。
|
||||
|
||||
#### 5.9.4 删除收货地址
|
||||
- **方法**:`POST`
|
||||
- **路径**:`/api/v1/mall/addressDelete`
|
||||
|
||||
Body:
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 地址 id |
|
||||
|
||||
若删除的是默认地址:服务端会将剩余地址里 id 最大的一条自动设为默认(若存在)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 配置项(供运维/对方技术对照)
|
||||
|
||||
| 环境变量 / 配置 | 作用 |
|
||||
|-----------------|------|
|
||||
| `PLAYX_DAILY_PUSH_SECRET` | 非空则 Daily Push 必须带合法 HMAC 头 |
|
||||
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 PlayX 远程 |
|
||||
| `PLAYX_API_BASE_URL` | 商城调用 PlayX 接口时使用(与「PlayX 调商城」方向相反) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本与变更
|
||||
|
||||
- 文档与仓库代码同步维护;接口路径以 `config/route.php` 为准。
|
||||
- 若后续升级鉴权策略(例如叠加 JWT),以部署环境变量与最新文档为准。
|
||||
683
docs/积分商城-PlayX对接实施方案.md
Normal file
683
docs/积分商城-PlayX对接实施方案.md
Normal file
@@ -0,0 +1,683 @@
|
||||
# 积分商城 PlayX 对接实施方案
|
||||
|
||||
> 基于《积分商城-内部对接与流程说明.md》和《PlayX-对接文档(积分商城).md》整理,结合当前项目结构给出具体落地方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、接口创建
|
||||
|
||||
### 1.1 商城需对外提供的接口(PlayX 调用商城)
|
||||
#### Daily Push API
|
||||
接收 PlayX 每日 T+1 数据推送。
|
||||
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/dailyPush`
|
||||
|
||||
##### 请求(Header)
|
||||
当配置了 `playx.daily_push_secret`(Daily Push 签名校验)时,需要携带:
|
||||
* `X-Request-Id`:请求 ID
|
||||
* `X-Timestamp`:时间戳
|
||||
* `X-Signature`:签名(HMAC_SHA256)
|
||||
|
||||
签名计算逻辑(服务端):
|
||||
* canonical:`{X-Timestamp}\n{X-Request-Id}\nPOST\n/api/v1/mall/dailyPush\n{sha256(json_body)}`
|
||||
* expected:`hash_hmac('sha256', canonical, daily_push_secret)`
|
||||
|
||||
##### 请求(Body)
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 外部推送请求号(原样返回) |
|
||||
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_playx_daily_push.date`) |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID(幂等键组成部分) |
|
||||
| `username` | string | 否 | 展示冗余 |
|
||||
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢(仅当 `< 0` 时计算新增保障金) |
|
||||
| `yesterday_total_deposit` | number | 否 | 昨日总充值(用于计算今日可领取上限) |
|
||||
| `lifetime_total_deposit` | number | 否 | 历史总充值 |
|
||||
| `lifetime_total_withdraw` | number | 否 | 历史总提现 |
|
||||
|
||||
##### 幂等
|
||||
* 幂等键:`user_id + date`
|
||||
* 重复推送时不会重复入账,返回 `data.deduped = true`
|
||||
|
||||
##### 返回(Response)
|
||||
通用返回包结构:`{ code, msg, time, data }`
|
||||
|
||||
成功返回(data):
|
||||
* `data.request_id`:原样返回
|
||||
* `data.accepted`:`true`
|
||||
* `data.deduped`:是否幂等命中(`false`=首次入库,`true`=重复推送)
|
||||
* `data.message`:首次为 `ok`,重复为 `duplicate input`
|
||||
|
||||
##### 示例
|
||||
无签名校验(`PLAYX_DAILY_PUSH_SECRET` 为空):
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"request_id":"req_1001",
|
||||
"date":"2026-03-18",
|
||||
"user_id":"U123",
|
||||
"username":"demo_user",
|
||||
"yesterday_win_loss_net":-120.5,
|
||||
"yesterday_total_deposit":50,
|
||||
"lifetime_total_deposit":5000,
|
||||
"lifetime_total_withdraw":2000
|
||||
}'
|
||||
```
|
||||
|
||||
成功响应(首次):
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"request_id": "req_1001",
|
||||
"accepted": true,
|
||||
"deduped": false,
|
||||
"message": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 商城需调用的 PlayX 接口(外部,由 PlayX 提供)
|
||||
以下为商城侧调用(由 PlayX 提供)。
|
||||
|
||||
#### Token Verification API
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.token_verify_url}`(默认 `/api/v1/auth/verify-token`)
|
||||
|
||||
##### 请求 Body
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 服务端生成,如 `mall_{uniqid}` |
|
||||
| `token` | string | 是 | 前端传入的 PlayX token |
|
||||
|
||||
##### 返回(期望)
|
||||
HTTP 状态码 `200`,响应体需包含:
|
||||
* `user_id`(必选)
|
||||
* `username`(可选)
|
||||
* `token_expire_at`(可选,能被 `strtotime` 解析)
|
||||
|
||||
##### 示例
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_abc123",
|
||||
"token": "PLAYX_TOKEN_XXX"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "U123",
|
||||
"username": "demo_user",
|
||||
"token_expire_at": "2026-04-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Bonus Grant API
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.bonus_grant_url}`(默认 `/api/v1/bonus/grant`)
|
||||
|
||||
##### 请求 Body
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 如 `mall_bonus_{uniqid}` |
|
||||
| `externalTransactionId` | string | 是 | 订单幂等键:`external_transaction_id` |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID |
|
||||
| `amount` | number | 是 | 订单金额:`MallPlayxOrder.amount` |
|
||||
| `rewardName` | string | 是 | `mall_item.title` |
|
||||
| `category` | string | 是 | `mall_item.category`,默认 `daily` |
|
||||
| `categoryTitle` | string | 是 | `mall_item.category_title` |
|
||||
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
|
||||
|
||||
##### 示例(请求)
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_bonus_abc123",
|
||||
"externalTransactionId": "BONUS_ORD2026....",
|
||||
"user_id": "U123",
|
||||
"amount": 10.0,
|
||||
"rewardName": "每日红利",
|
||||
"category": "daily",
|
||||
"categoryTitle": "每日",
|
||||
"multiplier": 1
|
||||
}
|
||||
```
|
||||
|
||||
##### 返回(期望)
|
||||
商城侧以 `data.status` 判断:
|
||||
* `status = "accepted"`:读取 `playx_transaction_id`
|
||||
* 否则:读取 `message` 写入 `fail_reason`
|
||||
|
||||
示例(accepted):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"playx_transaction_id": "PX_TX_001"
|
||||
}
|
||||
```
|
||||
|
||||
示例(reject):
|
||||
```json
|
||||
{
|
||||
"status": "rejected",
|
||||
"message": "insufficient balance"
|
||||
}
|
||||
```
|
||||
|
||||
#### Balance Credit API
|
||||
* 方法:`POST`
|
||||
* URL:`${playx.api.base_url}${playx.api.balance_credit_url}`(默认 `/api/v1/balance/credit`)
|
||||
|
||||
##### 请求 Body
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `request_id` | string | 是 | 如 `mall_withdraw_{uniqid}` |
|
||||
| `externalTransactionId` | string | 是 | 订单幂等键:`external_transaction_id` |
|
||||
| `user_id` | string | 是 | PlayX 用户 ID |
|
||||
| `amount` | number | 是 | 订单金额:`MallPlayxOrder.amount` |
|
||||
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
|
||||
|
||||
##### 返回(期望)
|
||||
与 Bonus Grant 一致:
|
||||
* 成功:`status="accepted"` + `playx_transaction_id`
|
||||
* 失败:`message` 写入 `fail_reason`
|
||||
|
||||
##### 示例
|
||||
请求(示意,由商城侧发起,实际 URL 以配置为准):
|
||||
```json
|
||||
{
|
||||
"request_id": "mall_withdraw_abc123",
|
||||
"externalTransactionId": "WITHDRAW_ORD2026....",
|
||||
"user_id": "U123",
|
||||
"amount": 100.0,
|
||||
"multiplier": 1
|
||||
}
|
||||
```
|
||||
|
||||
响应(accepted):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"playx_transaction_id": "PX_TX_002"
|
||||
}
|
||||
```
|
||||
|
||||
响应(rejected):
|
||||
```json
|
||||
{
|
||||
"status": "rejected",
|
||||
"message": "insufficient balance"
|
||||
}
|
||||
```
|
||||
|
||||
#### Transaction Status Query API(交易终态查询)
|
||||
* 方法:`GET`
|
||||
* URL:`${playx.api.base_url}${playx.api.transaction_status_url}`(默认 `/api/v1/transaction/status`)
|
||||
|
||||
##### Query
|
||||
* `externalTransactionId`:订单幂等键
|
||||
|
||||
##### 示例(请求)
|
||||
```bash
|
||||
curl -G '${playx.api.base_url}/api/v1/transaction/status' \
|
||||
--data-urlencode 'externalTransactionId=BONUS_ORD2026....'
|
||||
```
|
||||
|
||||
##### 返回(期望)
|
||||
商城读取 `data.status`:
|
||||
* `COMPLETED`:设置订单 `status=COMPLETED`
|
||||
* `FAILED` 或 `REJECTED`:设置订单 `status=REJECTED`、`grant_status=FAILED_FINAL`,并退回积分;失败信息取 `data.message`
|
||||
|
||||
示例(completed):
|
||||
```json
|
||||
{ "status": "COMPLETED" }
|
||||
```
|
||||
|
||||
示例(failed):
|
||||
```json
|
||||
{ "status": "FAILED", "message": "grant rejected by PlayX" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 商城内部 API(供 H5 前端调用)
|
||||
#### Token 验证
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/verifyToken`
|
||||
|
||||
请求(Body):
|
||||
* `token`(必填,优先读取)
|
||||
* 兼容:`session`(当 `token` 为空时当作 token 使用)
|
||||
|
||||
成功返回(data):
|
||||
* `session_id`
|
||||
* `user_id`
|
||||
* `username`
|
||||
* `token_expire_at`(ISO 时间字符串)
|
||||
|
||||
示例:
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'token=PLAYX_TOKEN_XXX'
|
||||
```
|
||||
|
||||
#### 用户资产
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/assets`
|
||||
|
||||
请求参数(二选一):
|
||||
* `session_id`(优先):从 `mall_playx_session` 查 user_id(并校验过期)
|
||||
* `user_id`:直接使用(兼容)
|
||||
|
||||
成功返回(data):
|
||||
* `locked_points`
|
||||
* `available_points`
|
||||
* `today_limit`
|
||||
* `today_claimed`
|
||||
* `withdrawable_cash`(`available_points * points_to_cash_ratio`,保留 2 位)
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"locked_points": 100,
|
||||
"available_points": 50,
|
||||
"today_limit": 200,
|
||||
"today_claimed": 80,
|
||||
"withdrawable_cash": 5.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 领取(Claim)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/claim`
|
||||
|
||||
请求:
|
||||
* `claim_request_id`:幂等键(string,必填且唯一)
|
||||
* 鉴权:`session_id` 或 `user_id`
|
||||
|
||||
成功返回(data):与资产接口一致(`locked_points/available_points/today_limit/today_claimed/withdrawable_cash`)
|
||||
|
||||
##### 示例
|
||||
(首次领取成功,可能返回 `msg=Claim success`;若幂等重复,`msg` 可能为空)
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'claim_request_id=claim_001' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Claim success",
|
||||
"data": {
|
||||
"locked_points": 60,
|
||||
"available_points": 90,
|
||||
"today_limit": 200,
|
||||
"today_claimed": 120,
|
||||
"withdrawable_cash": 9.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 商品列表
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/items`
|
||||
|
||||
请求(可选):
|
||||
* `type=BONUS|PHYSICAL|WITHDRAW`
|
||||
|
||||
成功返回(data):
|
||||
* `list`:`mall_item` 列表(包含 `amount/multiplier/category/category_title` 等字段)
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=BONUS'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "每日红利",
|
||||
"type": 1,
|
||||
"score": 100,
|
||||
"amount": 10.0,
|
||||
"multiplier": 1,
|
||||
"category": "daily",
|
||||
"category_title": "每日"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 红利兑换(Bonus Redeem)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/bonusRedeem`
|
||||
|
||||
请求:
|
||||
* `item_id`:商品 ID(BONUS)
|
||||
* 鉴权:`session_id` 或 `user_id`
|
||||
|
||||
成功返回(data):
|
||||
* `order_id`
|
||||
* `status`:`PENDING`
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=123' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Redeem submitted, please wait about 10 minutes",
|
||||
"data": {
|
||||
"order_id": 456,
|
||||
"status": "PENDING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 实物兑换(Physical Redeem)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/physicalRedeem`
|
||||
|
||||
请求:
|
||||
* `item_id`:商品 ID(PHYSICAL)
|
||||
* `address_id`:`mall_address.id`(当前用户资产下地址;订单写入 `mall_address_id` 与收货快照)
|
||||
* 鉴权:`session_id` 或 `user_id`
|
||||
|
||||
成功返回:
|
||||
* `msg`:`Redeem success`
|
||||
* `data`:null
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=200' \
|
||||
--data-urlencode 'address_id=10' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Redeem success",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 提现申请(Withdraw Apply)
|
||||
* 方法:`POST`
|
||||
* 路径:`/api/v1/mall/withdrawApply`
|
||||
|
||||
请求:
|
||||
* `item_id`:商品 ID(WITHDRAW)
|
||||
* 鉴权:`session_id` 或 `user_id`
|
||||
|
||||
成功返回(data):
|
||||
* `order_id`
|
||||
* `status`:`PENDING`
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'item_id=321' \
|
||||
--data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "Withdraw submitted, please wait about 10 minutes",
|
||||
"data": {
|
||||
"order_id": 789,
|
||||
"status": "PENDING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 订单列表
|
||||
* 方法:`GET`
|
||||
* 路径:`/api/v1/mall/orders`
|
||||
|
||||
请求:
|
||||
* `session_id` 或 `user_id`
|
||||
|
||||
成功返回(data):
|
||||
* `list`:订单列表(最多 100 条,包含关联的 `mallItem`)
|
||||
|
||||
##### 示例
|
||||
```bash
|
||||
curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=7b1c....'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 456,
|
||||
"type": "BONUS",
|
||||
"status": "PENDING",
|
||||
"mall_item_id": 123,
|
||||
"points_cost": 100,
|
||||
"amount": 10.0,
|
||||
"external_transaction_id": "BONUS_ORD2026....",
|
||||
"grant_status": "NOT_SENT",
|
||||
"mallItem": { "id": 123, "title": "每日红利", "type": 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 同步额度
|
||||
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`。
|
||||
如需补齐,请在接口设计阶段新增对应实现与 PlayX API 对接。
|
||||
|
||||
---
|
||||
|
||||
## 二、后台修改
|
||||
|
||||
### 2.1 商品管理(mall_item)
|
||||
|
||||
- **类型**:需与文档一致
|
||||
- `1` = BONUS(红利)
|
||||
- `2` = PHYSICAL(实物)
|
||||
- `3` = WITHDRAW(提现档位)
|
||||
- **新增字段**(红利/提现档位):
|
||||
- `amount`:现金面值(元)
|
||||
- `multiplier`:流水倍数
|
||||
- `category`:红利业务类别(如 daily)
|
||||
- `category_title`:类别展示名
|
||||
- **库存**:实物需 `stock`;红利/提现可不限制或按业务配置
|
||||
|
||||
### 2.2 订单管理(统一订单表)
|
||||
|
||||
- **订单类型**:BONUS / PHYSICAL / WITHDRAW
|
||||
- **订单状态**:PENDING / COMPLETED / SHIPPED / REJECTED
|
||||
- **实物订单**:
|
||||
- 发货:录入物流公司、单号 → `SHIPPED`
|
||||
- 驳回:录入驳回原因 → `REJECTED`,自动退回积分
|
||||
- **红利/提现订单**:
|
||||
- 展示 `external_transaction_id`、`playx_transaction_id`、推送playx
|
||||
- 手动重试:仅对 `FAILED_RETRYABLE` 状态,记录 `retry_request_id`、操作者、原因
|
||||
|
||||
### 2.3 用户资产与人工调账
|
||||
|
||||
- **用户资产**:按 `user_id` 展示 `locked_points`、`available_points`、`today_limit`、`today_claimed`
|
||||
- **人工调账**:针对 T+1 推送异常或客诉,支持对 `locked_points`、`available_points` 手动加减,并记录审计日志
|
||||
|
||||
### 2.4 每日推送数据
|
||||
|
||||
- 后台可查看 `mall_playx_daily_push` 表数据,按 `user_id`、`date` 查询,便于排查异常
|
||||
|
||||
---
|
||||
|
||||
## 三、数据库修改
|
||||
|
||||
### 3.1 用户资产表(改造 mall_player 或新建)
|
||||
|
||||
**方案 A:改造 mall_player**
|
||||
|
||||
- 将 `user_id` 作为主键(PlayX 的 user_id)或与现有 id 并存
|
||||
- 新增字段:
|
||||
- `locked_points`(待领取积分)
|
||||
- `available_points`(可用积分)
|
||||
- `today_limit`(今日可领取上限)
|
||||
- `today_claimed`(今日已领取)
|
||||
- `today_limit_date`(今日上限所属日期,用于每日重置)
|
||||
|
||||
**方案 B:新建 mall_playx_user_asset**
|
||||
|
||||
- `user_id`(PlayX 用户 ID,主键或唯一)
|
||||
- `username`(冗余展示)
|
||||
- `locked_points`、`available_points`、`today_limit`、`today_claimed`、`today_limit_date`
|
||||
- `create_time`、`update_time`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 每日推送数据表(新建)
|
||||
|
||||
**表名**:`mall_playx_daily_push`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键 |
|
||||
| user_id | varchar(64) | 玩家 ID |
|
||||
| date | date | 业务日期 |
|
||||
| username | varchar(100) | 展示名 |
|
||||
| yesterday_win_loss_net | decimal(15,2) | 昨日净输赢 |
|
||||
| yesterday_total_deposit | decimal(15,2) | 昨日总充值 |
|
||||
| lifetime_total_deposit | decimal(15,2) | 历史总充值 |
|
||||
| lifetime_total_withdraw | decimal(15,2) | 历史总提现 |
|
||||
| create_time | bigint | 创建时间 |
|
||||
|
||||
**唯一索引**:`(user_id, date)` 幂等去重
|
||||
|
||||
---
|
||||
|
||||
### 3.3 领取记录表(幂等)
|
||||
|
||||
**表名**:`mall_playx_claim_log`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 主键 |
|
||||
| claim_request_id | varchar(64) | 幂等键,唯一 |
|
||||
| user_id | varchar(64) | 用户 ID |
|
||||
| claimed_amount | int | 领取积分 |
|
||||
| create_time | bigint | 创建时间 |
|
||||
|
||||
**唯一索引**:`claim_request_id`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 统一订单表(改造或新建)
|
||||
|
||||
**表名**:`mall_playx_order`(或统一改造 mall_pints_order / mall_redemption_order)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|----------------------------------------------------------------------|
|
||||
| id | int | 主键 |
|
||||
| user_id | varchar(64) | 用户 ID |
|
||||
| type | enum | BONUS / PHYSICAL / WITHDRAW |
|
||||
| status | enum | PENDING / COMPLETED / SHIPPED / REJECTED |
|
||||
| mall_item_id | int | 商品 ID |
|
||||
| points_cost | int | 消耗积分 |
|
||||
| amount | decimal(15,2) | 现金面值(红利/提现) |
|
||||
| multiplier | int | 流水倍数 |
|
||||
| external_transaction_id | varchar(64) | 订单号 |
|
||||
| playx_transaction_id | varchar(64) | PlayX 流水号 |
|
||||
| grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL |
|
||||
| fail_reason | text | 失败原因 |
|
||||
| retry_count | int | 重试次数 |
|
||||
| reject_reason | varchar(255) | 驳回原因(实物) |
|
||||
| shipping_company | varchar(50) | 物流公司 |
|
||||
| shipping_no | varchar(64) | 物流单号 |
|
||||
| mall_address_id | int unsigned, NULL | 实物兑换所选 `mall_address.id`(快照仍见 `receiver_*`) |
|
||||
| receiver_name | varchar(50) | 收货人 |
|
||||
| receiver_phone | varchar(20) | 收货电话 |
|
||||
| receiver_address | text | 收货地址 |
|
||||
| create_time | bigint | 创建时间 |
|
||||
| update_time | bigint | 更新时间 |
|
||||
|
||||
**索引**:`user_id`、`external_transaction_id`、`type`、`status`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 商品表(mall_item)扩展
|
||||
|
||||
新增字段(若不存在):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| amount | decimal(15,2) | 现金面值(红利/提现档位) |
|
||||
| multiplier | int | 流水倍数 |
|
||||
| category | varchar(32) | 红利业务类别 |
|
||||
| category_title | varchar(64) | 类别展示名 |
|
||||
|
||||
---
|
||||
|
||||
## 四、业务规则
|
||||
|
||||
### 4.1 计算规则(需配置)
|
||||
|
||||
- **返还比例**:`新增保障金 = ABS(yesterday_win_loss_net) * 返还比例`(仅 `yesterday_win_loss_net < 0` 时)
|
||||
- **解锁比例**:`今日可领取上限 = yesterday_total_deposit * 解锁比例`
|
||||
- **提现折算**:积分 → 现金(如 10 分 = 1 元),用于前端展示
|
||||
|
||||
### 4.2 每日重置
|
||||
|
||||
- `today_claimed` 与 `today_limit` 按业务日重置(`today_limit_date` 变化时)
|
||||
|
||||
### 4.3 发放重试
|
||||
|
||||
- 自动重试:1min / 5min / 15min,最多 3 次,使用同一 `externalTransactionId`
|
||||
- 仅对 `NOT_SENT`、`FAILED_RETRYABLE` 重试
|
||||
- 收到 `accepted` 后不再重试,改轮询交易终态查询 API
|
||||
|
||||
---
|
||||
|
||||
## 五、实施顺序建议
|
||||
|
||||
1. **数据库**:新增迁移(`mall_playx_daily_push`、`mall_playx_claim_log`、`mall_playx_order`),扩展 `mall_item`、`mall_player`(或新建资产表)
|
||||
2. **模型**:`MallPlayxDailyPush`、`MallPlayxClaimLog`、`MallPlayxOrder`、扩展 `MallItem`、`MallPlayer`
|
||||
3. **接口**:Daily Push API(含签名校验)→ Token 验证 → 资产/领取 → 商品列表 → 红利/实物/提现 → 订单列表
|
||||
4. **后台**:商品扩展、订单管理(含发货/驳回/重试)、人工调账、每日推送数据查看
|
||||
5. **定时任务**:轮询交易终态、自动重试失败发放
|
||||
|
||||
---
|
||||
|
||||
## 六、待确认事项
|
||||
|
||||
- PlayX 提供的 **Token Verification API**、**Bonus Grant API**、**Balance Credit API**、**交易终态查询 API** 的 URL、鉴权方式、字段最终表
|
||||
- `date` 的时区定义(如 UTC+8)
|
||||
- 返还比例、解锁比例、提现折算的具体数值
|
||||
- 是否启用「同步额度」功能(需 PlayX 提供对应 API)
|
||||
318
docs/积分商城-内部对接与流程说明.md
Normal file
318
docs/积分商城-内部对接与流程说明.md
Normal file
@@ -0,0 +1,318 @@
|
||||
## 1. 文档目的与读者
|
||||
|
||||
本文件为 **内部使用** 的完整说明,用于:
|
||||
|
||||
- 梳理积分商城与 PlayX 之间的 **全量业务流程**(含当前选型与备选方案)。
|
||||
- 统一后台实现口径(资产计算、订单状态机、幂等、重试、对账)。
|
||||
- 为后续版本扩展(实时 webhook、同步按钮、PlayX 拉取模式)预留空间。
|
||||
|
||||
对外给 PlayX 的精简版请参考:`PlayX-对接文档(积分商城).md`。
|
||||
|
||||
## 2. 当前选型概览(V1.0)
|
||||
|
||||
- **主键标识**:`user_id`(贯穿每日推送、资产、订单、发放接口)。
|
||||
- **集成方式**:
|
||||
- 前端:商城 H5 以 Iframe 嵌入 PlayX,postMessage 传 token/session。
|
||||
- 后端:商城独立服务,通过 REST API 与 PlayX 通讯。
|
||||
- **数据来源**:
|
||||
- 资产池:仅使用 **每日 Cron 推送(T+1)** 的历史输赢与充值数据。
|
||||
- 不引入实时充值/流水 webhook 作为资产来源。
|
||||
- **发放模式**:
|
||||
- 商城在红利/提现下单后,**直接调用 PlayX 发放接口**(Bonus Grant / Balance Credit)。
|
||||
- PlayX 侧每 10 分钟 Cron 执行 5.9 adjustment / 最终入账。
|
||||
- **幂等责任分工(方案 A,与对外文档一致)**:
|
||||
- **PlayX**:对 Bonus Grant / Balance Credit 按 **`externalTransactionId` 严格幂等**——同一单号重复请求不得产生第二笔发放;须返回与首次受理一致或可识别的幂等结果(具体 HTTP 体字段由联调约定)。
|
||||
- **商城**:为每笔红利/提现生成**全局唯一** `externalTransactionId`;收到 HTTP 200 且 `status="accepted"` 后**不再向发放接口重放**;对网络超时等“未知是否受理”场景,可在有限次内重试**同一** `externalTransactionId`,依赖 PlayX 幂等保证不双发(见第 6 章)。
|
||||
|
||||
## 3. 角色、系统与对象
|
||||
|
||||
### 3.1 角色
|
||||
|
||||
- **会员**:在 PlayX 内通过 Iframe 使用积分商城。
|
||||
- **运营/客服**:使用商城后台管理商品、订单、调账。
|
||||
- **PlayX 平台**:数据源与权益发放执行方。
|
||||
|
||||
### 3.2 主要对象与前端看板展示映射
|
||||
|
||||
- **目前可提现(现金)**:
|
||||
- 前端展示专用虚拟字段,由可用积分按固定比例折算(如:10 分 = 1 元),用于给玩家呈现直观价值,非底层独立资产。
|
||||
- **待领取积分 (LockedPoints)**:
|
||||
- **定义**:基于昨日玩家亏损转化来的“保障池”,未领取前不可消费。
|
||||
- **数据源**:PlayX 每日推送的 `Yesterday Player Win loss`(取绝对值 × 返还比例)。
|
||||
- **可用积分 (AvailablePoints)**:
|
||||
- **定义**:玩家当前拥有的、可立即抵扣兑换和提现的真实积分资产。
|
||||
- **交互消耗**:所有兑换、提现操作均扣减该字段。
|
||||
- **今日可领取上限 (TodayLimit)**:
|
||||
- **定义**:限制玩家当日最多能“挽回”多少积分。
|
||||
- **数据源**:PlayX 每日推送的 `Yesterday Total Deposit`(昨日总充值 × 解锁比例)。
|
||||
- **今日已领取 (TodayClaimed)**:
|
||||
- **定义**:记录玩家当日累计已领取的积分规模,辅助校验上限,每日重置。
|
||||
- **订单 (Order)**:
|
||||
- 类型:BONUS / PHYSICAL / WITHDRAW
|
||||
- 状态:PENDING / COMPLETED / SHIPPED / REJECTED
|
||||
|
||||
## 4. 端到端流程(内部视角)
|
||||
|
||||
### 4.1 登录与会话建立
|
||||
|
||||
1. 会员在 PlayX 点击“积分商城”入口。
|
||||
2. 父页面加载 Iframe,商城前端进入“连接中/鉴权中”态。
|
||||
3. PlayX 前端通过 postMessage 发送:
|
||||
- `token` 或 session 标识。
|
||||
4. 商城前端将 token 传给后端,后端调用 PlayX 的 **Token Verification API** 验证身份:
|
||||
- **请求参数**:
|
||||
- `token`:接收到的用户会话凭证。
|
||||
- `request_id`:系统发起的鉴权校验流水号。
|
||||
- **响应参数**:
|
||||
- `user_id`:解析出的玩家在平台的唯一标识主键。
|
||||
- `username`:仅用于前端展示的昵称。
|
||||
- `token_expire_at`:用于判断并在濒临过期时触发换取新 Token(无感续期)。
|
||||
5. 商城根据 `user_id` 加载本地资产与订单数据,渲染首页。
|
||||
|
||||
**安全与会话续期要点**:
|
||||
|
||||
- **安全拦截**:不信任前端传入的 `user_id`,必须通过 Token Verification API 获取可信 `user_id`。
|
||||
- **Token 续期**:如果用户在商城停留过久导致 Token 过期(接口返回 `401 / INVALID_TOKEN`),商城前端需通过 `postMessage` 通知 PlayX 父页面重新派发新 Token 以实现静默续期;若无法自动续期,则需弹窗引导用户重新进入商城。
|
||||
|
||||
### 4.2 每日 T+1 数据推送与资产入池
|
||||
|
||||
数据来源:PlayX 每日 Cron。
|
||||
|
||||
- **交互字段说明(T+1 核心输入)**:
|
||||
- `date`:归属业务定日(用于限定该批数据的生效周期)。
|
||||
- `user_id`:玩家平台唯一标识主键。
|
||||
- `yesterday_win_loss_net`:昨日净输赢金额(核心数据源:负数绝对值作为计算新增保障积分类的基准)。
|
||||
- `yesterday_total_deposit`:昨日总充值(核心数据源:作为计算今日领取该积分上限的阈值基准)。
|
||||
- `lifetime_total_deposit` / `lifetime_total_withdraw` / `username`:非运算强制性字段,用于冗余显示或阶层判断预留。
|
||||
|
||||
商城处理逻辑:
|
||||
|
||||
1. 按 `user_id + date` 幂等入库,避免重复处理。(**注:需与 PlayX 明确 `date` 的时区定义,如 UTC+8 等**)。
|
||||
2. 根据业务规则计算:
|
||||
- `新增保障金 = ABS(yesterday_win_loss_net) * 返还比例`(仅当 yesterday_win_loss_net < 0 时产生)。
|
||||
- `今日可领取上限 = yesterday_total_deposit * 解锁比例`。**注意:今日上限每日独立计算,不结转至次日。**
|
||||
3. 将新增保障金累加到 `LockedPoints`,更新 `TodayLimit`。
|
||||
|
||||
**数据修正机制**:
|
||||
|
||||
- T+1 推送不支持覆盖更新(冲正)。如果 PlayX 上游数据算错导致推送有误,商城在二次推送时会触发去重拦截(`deduped`)。此类异常数据统一由**商城后台人工调账**处理。
|
||||
|
||||
### 4.3 领取流程(从待挽回转化为可用)
|
||||
|
||||
触发:会员在首页核心看板上查看领取进度(基于 `昨日充值` 计算),如果进度符合预期,点击“立即领取”。
|
||||
|
||||
- **前端交互**:
|
||||
- 判断逻辑:若 `昨日充值`(即 TodayLimit)均不足导致上限不够,按钮可能置灰并提示“昨日存款不足,无法全额领取”。
|
||||
- 操作反馈:点击后从底部呼出二次确认层(“将待领取积分划转为可用积分后,即可兑换或提现。确定领取?”)。
|
||||
- 确认层点击“确定”后发出后端请求。
|
||||
- **后端校验条件**:
|
||||
- `LockedPoints > 0`
|
||||
- `TodayLimit - TodayClaimed > 0`
|
||||
- **后端计算与执行**:
|
||||
- `canClaim = min(LockedPoints, TodayLimit - TodayClaimed)`
|
||||
- **原子更新数据库**:
|
||||
- `LockedPoints -= canClaim`
|
||||
- `AvailablePoints += canClaim`
|
||||
- `TodayClaimed += canClaim`
|
||||
- **防重与幂等**:
|
||||
- 接口入参包含 `claim_request_id`,同一 `claim_request_id` 重复提交不重复扣减。
|
||||
- **前端成功响应**:
|
||||
- 后端处理完成后,前端弹出“领取成功,积分已到账”状态框。
|
||||
- **即时刷新**看板中的 `待领取积分` 与 `可用积分` 与 `目前可提现现金`。
|
||||
- **积分有效期说明**:
|
||||
- V1.0 版本积分无有效期限制,`LockedPoints` 和 `AvailablePoints` 永久有效并持续累加,后续视财务成本压力再引入年底清零/周期过期机制。
|
||||
|
||||
### 4.4 红利兑换(BONUS)
|
||||
|
||||
1. 会员在前端选择红利商品(配置来自后台商品管理)。
|
||||
2. 前端展示二次确认弹层(金额、流水倍数、说明),**并需在 UI 上显式提示会员:“发放预计需等待约 10 分钟内到账”**。
|
||||
3. 提交后,前端进入“兑换中”类似状态,**不可直接提示“兑换成功”**,随后商城后端执行:
|
||||
- 校验积分余额是否足够。
|
||||
- 校验商品状态(上架、库存)。
|
||||
- 校验订单状态(防重复提交)。
|
||||
4. 创建订单:
|
||||
- `type = BONUS`
|
||||
- `status = PENDING`
|
||||
- `user_id`
|
||||
- 商品信息 & 消耗积分
|
||||
5. 原子扣减 `AvailablePoints`。
|
||||
6. 生成 `externalTransactionId`(例如 `BONUS_ORD{订单号}`)。
|
||||
7. 调用 PlayX Bonus Grant API(核心透传字段,详见外部对接文档):
|
||||
- `externalTransactionId`:本单派发流水号(**防重**:用于要求下游强制幂等拦截)。
|
||||
- `user_id`:玩家标示。
|
||||
- `amount`:红利发放切实的现金面值。
|
||||
- `multiplier`:这笔款项后续提款的流水约束倍数。
|
||||
- `rewardName`:展示给玩家此笔红利来源名称。
|
||||
- `category`:便于平台统计对账的红利业务大类。
|
||||
8. PlayX 返回:
|
||||
- 若 HTTP 200 且 `status = "accepted"`:
|
||||
- 记录 `playx_transaction_id`。
|
||||
- 订单保持 PENDING(等待对方系统最终发放)。
|
||||
- **发起请求动作不再重试**。但商城侧需定时调用 PlayX 提供的 **交易状态查询 API** 轮询确认该订单最终结果,成功则转 `COMPLETED`,失败则转 `REJECTED` 并退分。
|
||||
- 否则:
|
||||
- 记录失败原因。
|
||||
- 进入“可重试队列”(自动/人工重试,见第 6 章)。
|
||||
|
||||
### 4.5 实物兑换(PHYSICAL)
|
||||
|
||||
1. 会员选择实物商品并填写收货信息。
|
||||
2. 商城后端:
|
||||
- 校验库存/积分。
|
||||
- 创建 PENDING 订单,扣减 `AvailablePoints`。
|
||||
3. 后台处理:
|
||||
- 发货:录入物流公司与单号,状态 → SHIPPED。**(可选:调用 PlayX Inbox API 给用户发送发货通知站内信)**。
|
||||
- 驳回:录入驳回原因,状态 → REJECTED,并退回积分。**(可选:调用 PlayX Inbox API 告知用户驳回原因)**。
|
||||
|
||||
### 4.6 提现回平台余额(WITHDRAW 操作逻辑)
|
||||
|
||||
本流程旨在将兑换所得的虚拟积分,按照规定“提现”为充入 PlayX 平台账户的真实金额。
|
||||
|
||||
1. **前端选择与展示**:
|
||||
- 会员在首页或“提现到平台”类别列表中选择特定提现档位(如:提现 100 元,需要 1000 积分,1倍流水要求)。
|
||||
- 点击“提现”按钮后,前端唤出底部二次确认弹层。
|
||||
- 弹层内容展示所选档位**所需消耗的积分值**以及对应的**流水要求倍数**,等待二次确认提交。
|
||||
- (**前端同时需在弹层或说明中向用户提示:提现申请预计约 10 分钟在平台入账**)。
|
||||
2. **后端可用性校验**:
|
||||
- 用户确认后,前端请求后端。商城后端比较当前用户的 `AvailablePoints` 是否 `>= item.points`。如果不足,阻断流程并返回前端 `积分不足` 的报错浮层。
|
||||
3. **资产扣减与订单落库**:
|
||||
- 原子扣减数据库内该会员的 `AvailablePoints`。
|
||||
- 创建 WITHDRAW 暂挂订单(状态 `PENDING`),记录该单提现对应的 `amount` 与 `multiplier`。
|
||||
- 生成外部交易单号 `externalTransactionId`(如 `WITHDRAW_ORD{订单号}`)。
|
||||
4. **调用 PlayX API 发放(核心参数解析)**:
|
||||
- 商城发包调用 PlayX 的 Balance Credit API:
|
||||
- `externalTransactionId`:本提现申请的订单号(**提现防重唯一拦截键**)。
|
||||
- `user_id`:发起提现的玩家 ID。
|
||||
- `amount`:要充入平台余额池的真金面值。
|
||||
- `multiplier/turnover_rule`:该笔提现资金入账后锁定的打码流水倍数条件。
|
||||
5. **异步等待终态与 UI 回显**:
|
||||
- 收到 API `accepted` 响应后,商城将不间断返回前端“提交成功,预计 10 分钟内处理”。
|
||||
- 商城内部保持该订单为 PENDING,并进入定时轮询状态,监控 PlayX 10 分钟 Cron 执行后的“最终业务发货结果”,闭环完成后才转入 COMPLETED 或对失败按规则退分。
|
||||
|
||||
## 5. 扩展与备选方案(暂不对外)
|
||||
|
||||
本章为 **扩展设计/备选方案**,当前版本不对 PlayX 提出实现要求,只在内部保留。
|
||||
|
||||
### 5.1 实时充值 webhook(备选)
|
||||
|
||||
用途:让“今日可领取上限”不仅依赖 T+1 数据,还能实时响应充值行为。
|
||||
|
||||
示例设计:
|
||||
|
||||
- PlayX 在充值成功后,调用商城的充值 webhook:
|
||||
- 字段:`user_id`、`amount`、`transaction_id`、`occurred_at`。
|
||||
- 商城:
|
||||
- 以 `transaction_id` 幂等入库。
|
||||
- 更新“当日实时充值统计”,供风控或前端展示使用。
|
||||
|
||||
当前状态:
|
||||
|
||||
- V1.0 不启用此对接;所有领取逻辑仅基于每日推送(T+1)。
|
||||
|
||||
### 5.2 外部积分来源 webhook(任务/轮盘)
|
||||
|
||||
用途:把 PlayX 任务、幸运轮盘等活动产出的积分汇总到商城。
|
||||
|
||||
示例设计:
|
||||
|
||||
- PlayX 调用 webhook:
|
||||
- 字段:`user_id`、`points`、`source`、`transaction_id`。
|
||||
- 商城:
|
||||
- 以 `transaction_id` 幂等增加 `AvailablePoints` 或某个“活动积分池”。
|
||||
|
||||
当前状态:
|
||||
|
||||
- V1.0 不做接入;防止资产口径复杂化。
|
||||
|
||||
### 5.3 “同步额度”交互逻辑(手动拉取实时充值)
|
||||
|
||||
**功能目的**:由于积分商城的基础计算依赖的是 T+1 每日推送,如果不做任何补充,今日充值的玩家将无法即刻提升“今日可领取上限(TodayLimit)”。因此原型中设置了“同步额度”按钮,作为手动拉取实施更新的一种补救路径。
|
||||
|
||||
**前端交互逻辑**:
|
||||
- 用户在首页面板点击“同步额度”次要操作按钮。
|
||||
- 前端短暂 loading,调用后端接口拉取后,弹出轻量级反馈:“已同步最新额度”。
|
||||
- 页面数据看板数字重新渲染刷新。
|
||||
|
||||
**后端业务逻辑选型推荐**:
|
||||
|
||||
- **方案 A(建议,商城做拉取请求)**:
|
||||
- 会员点击同步按钮,商城后端拦截并向 PlayX 系统调用一个**“查询今日实时余额/重算存款 API”**。
|
||||
- 获取到最新存款后,累加或覆盖当前的 `TodayLimit`。
|
||||
- **方案 B(PlayX 控制权)**:
|
||||
- 点击后,通过 iframe 的 `postMessage` 向父级 PlayX 窗口发送同步指令。
|
||||
- PlayX 在自身域内统计今日所有游戏存款流水进行汇总归集(甚至一键转账入主钱包的操作)。
|
||||
- 处理完成后 PlayX 主动请求积分商城的`更新 webhook`来给 `TodayLimit` 加额,最后前端获取成功事件刷新。
|
||||
- **当前定案落地方向**:
|
||||
- 根据原型要求,本功能**必须落地**。推荐使用方案 A。商城侧需准备一个接受通知的 API,或 PlayX 需要支持实时提供玩家当日总存款的只读 API,以供点击拉取。对于 V1.0 对外文档里若不打算实现,需与 PlayX 进一步交涉决定是否阉割掉此按钮。
|
||||
|
||||
### 5.4 发放模式备选:PlayX 定时拉取
|
||||
|
||||
备选方案:
|
||||
|
||||
- 由 PlayX 每 10 分钟调用商城查询接口,拉取待发放订单列表,然后自行发放。
|
||||
|
||||
需要新增:
|
||||
|
||||
- 商城提供“待发放订单列表”接口(分页、过滤、幂等标记)。
|
||||
- 双方需要就“已拉取但未发放”、“重复拉取”等边界做严格定义。
|
||||
|
||||
当前选型:
|
||||
|
||||
- 考虑到复杂度与 PlayX 当前发放系统形态,最终选择 **商城主动调用 PlayX 发放 API** 的模式,拉取模式仅保留在内部文档中作为备选。
|
||||
|
||||
## 6. 幂等、重试与状态机(内部实现口径)
|
||||
|
||||
### 6.1 幂等键
|
||||
|
||||
- 每日推送:`user_id + date`。
|
||||
- 充值/外部积分 webhook(如启用):`transaction_id`。
|
||||
- 领取:`claim_request_id`。
|
||||
- 红利/提现发放:`externalTransactionId`。
|
||||
|
||||
### 6.2 发放请求状态机(商城内部)
|
||||
|
||||
针对每个订单(BONUS/WITHDRAW),在商城内部维护发放子状态,例如:
|
||||
|
||||
- `NOT_SENT`:未发送给 PlayX。
|
||||
- `SENT_PENDING`:已发送,等待 PlayX 响应。
|
||||
- `ACCEPTED`:收到 HTTP 200 且 `status = "accepted"`。
|
||||
- `FAILED_RETRYABLE`:失败且可重试(如超时、上游错误)。
|
||||
- `FAILED_FINAL`:最终失败(达到重试上限或 PlayX 返回不可恢复错误)。
|
||||
|
||||
关键规则:
|
||||
|
||||
- 只有在 `NOT_SENT` 或 `FAILED_RETRYABLE` 状态下才允许“再次发送”。
|
||||
- 一旦进入 `ACCEPTED`,不得再发请求(自动或人工)。
|
||||
- 订单业务状态(PENDING/COMPLETED/REJECTED)与发放子状态之间要有清晰映射:
|
||||
- `ACCEPTED` + 对账确认成功 → `COMPLETED`。
|
||||
- `FAILED_FINAL` → `REJECTED`(并退积分)。
|
||||
|
||||
### 6.3 重试策略(内部)与防重底线
|
||||
|
||||
- **前提**:PlayX 已承诺按 **`externalTransactionId` 严格幂等**(方案 A)。在此前提下,读超时后使用**同一** `externalTransactionId` 的有限次自动重试是安全的。
|
||||
- 自动重试:
|
||||
- 仅针对网络错误、Read Timeout(读超时)、`PLAYX_UPSTREAM_ERROR` 等,且**尚未收到** HTTP 200 + `status="accepted"`。
|
||||
- 建议间隔:1min / 5min / 15min,最多 3 次;**每次重试必须使用原订单的同一** `externalTransactionId`,不得生成新单号冒充新单。
|
||||
- 人工重试:
|
||||
- 仅允许在 `FAILED_RETRYABLE` 状态下触发。
|
||||
- 每次需记录 `retry_request_id`、操作者、原因。若自动重试耗尽仍失败,可先通过 **交易终态查询 API** 核对再决定是否人工干预。
|
||||
- 绝不重试场景:
|
||||
- 发放请求已明确收到 `status = "accepted"`(后续只走 5.5 轮询与客诉流程,不向发放接口重放)。
|
||||
- 明确业务拒绝类错误(如参数非法、规则不满足)。
|
||||
|
||||
## 7. 对账与问题排查
|
||||
|
||||
- 对账来源:
|
||||
- 商城订单表(含 externalTransactionId、playx_transaction_id)。
|
||||
- PlayX 提供的对账/流水查询(如有)。
|
||||
- 常见问题场景:
|
||||
- 商城显示 REJECTED 但会员反馈已收到红利:需检查是否在“发放后回滚积分”链路出错。
|
||||
- 商城显示 PENDING 时间过长:需排查 PlayX 侧 10 分钟 Cron 是否正常。
|
||||
|
||||
## 8. 与对外文档的关系
|
||||
|
||||
- 本文档:覆盖“能想到的所有对接与流程设计”,供产品、后端、运营、商务内部统一认识。
|
||||
- `PlayX-对接文档(积分商城).md`:
|
||||
- 仅暴露 PlayX V1.0 必须提供/实现的部分。
|
||||
- 承诺最小闭环,不在主文中提及实时 webhook、同步按钮、拉取模式。
|
||||
- **同步额度等能力**若产品仍要落地,以本文 **5.3** 与商务/PlayX 结论为准;对外文档不写不代表产品一定不做。
|
||||
|
||||
@@ -85,6 +85,15 @@ class Auth
|
||||
}
|
||||
} elseif (in_array($rule, $name)) {
|
||||
$list[] = $rule;
|
||||
} else {
|
||||
// 仅勾选父级菜单(如 auth/admin)时,允许访问子路径(auth/admin/index、add 等)
|
||||
$baseRule = preg_replace('/\?.*$/U', '', $rule);
|
||||
foreach ((array) $name as $n) {
|
||||
if ($baseRule !== '' && str_starts_with((string) $n, $baseRule . '/')) {
|
||||
$list[] = $rule;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($relation === 'or' && !empty($list)) {
|
||||
|
||||
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
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.3 KiB |
@@ -1,102 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
|
||||
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
|
||||
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAC+lBMVEX///9No/1Dnv1Dnv1D
|
||||
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Fn/1En/1GoP1Dnv1Dnv1Dnv1Dnv1Dnv1D
|
||||
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Jof1Dnv1Dnv1Zqf1Dnv1cq/1OpP1Qpf1Tp/1Mo/1PpP1En/2antB2
|
||||
w/9+vfeAvvVxvv6FltWas+F41f+Dyv+FzP9zwP5PpP1Vp/1Vp/1erP1erP1ss/1Fn/1Vp/3TlW9i
|
||||
tP6/mLFfsv5Lov1psv1Mo/2Gzf+ck8Nruv5Jof1Spv28nrpJov1jr/1ttP1Tpv3Clpywj7Vbqv1M
|
||||
o/1nmud7xf99x/9Rpf1Iof22ka9br/68k69Kov1OpP1VqP3FpoZ2ldhUp/2VkM5rs/3EmZZouP5K
|
||||
ov1Qpf11leJMnfe3psVOpP1nsf1Vp/3AkaSIzv9bq/xRpf1Up/1bmu+Cyv+qkbpNpP3Gn4+TksRW
|
||||
qP1HoP1GoP1dsf5Tpv1/yP+7kqtNpP3EqYJUp/2lk7xMo/1Spv1WqP1Uqv1WqP1jvP7FjqXifIWn
|
||||
msOQmM3neH/LiZ6BpuLWgZFwqe7ag5DThpatl72UotS8hKJSpv05mf0/nP114f+Fzv+I0P+B1v+K
|
||||
z/89m/1Anf1Dnv2Z5XaF0PyLz/+Uxe1+2f9Cnv07mv3BwVCd52d43v8+m/2v4zqq12aHzfnBsnqp
|
||||
7jSurtKMzf+G1P86mv2h6Vm12jymtty2w2212kCGzvSj412s2l+s2GK9uXSh61Zz5P9Cnf2m3l6r
|
||||
3lqu11+otNiwzmeo41at2F+Zv+p83f+k6FWz2kSKz+OCzf+v2FGLz96Pyvaz2kmw2U6N0Naos9qQ
|
||||
xvLFrnWy2U+Q0c2fu+SS0cGw2VWV0bmV0ras2GGX0q+w2ViY0qr+YVz/XVb5Z2b/V03Nk6iawOH8
|
||||
ZGL0bm37Z2WG6bT1bGzXiZmd8lv/XWL4amm/u2r/XWn/WlHFtWL/V277Y27rc3jDnLar3F/LrGSZ
|
||||
+VTya23////Mjm1rAAAAlnRSTlMABAsRHCczOkFGST43LyQXDwhTX2ZvdWlkW08sVQJiIBJYaty+
|
||||
zuHWd7zDwsW0l+Xl5uy6i7axf28x/cD4meOV8kKE8amp9NjwfGEjmdy7lO+FzdOQ98SP2X/npvaL
|
||||
yaE24qPq4ox4/odNotP5jd7Gf+K26uyeqnr7k9TZzoH3nbHtlLqGrqPe+smh/t+r753z6c68y0O0
|
||||
Xjp1AAAAAWJLR0QAiAUdSAAAAAd0SU1FB+YEBBcDHr5guP0AAA+vSURBVHja7V15fFTVFQ7ZzL6R
|
||||
vABJALUI2koVWUOriIo7SgVqQFErm4hgRQOlYBBLpdpFu8wgcTIVzVhSsNKgUkRJQYjWarW2tNTW
|
||||
2oRYhqaaGitd+KPzZubet913z/dme6G/fD9//hTecr+593z3nHvOuzctrR/96Ec/+hHGgPSMzKzs
|
||||
U3Jy8/LycvMLCouKS0rdbpNTlBQV5pcNLK9QjKgcNHhIQVXmyUEnPaugulyRomJgXnZmjdsNlaG0
|
||||
KH9ghYKhsjo70+32ilFSWIaSYCjPLeprHZNeWO2QBOuY3CK3265D0RCnfWHol/xitwmEUVowNA4W
|
||||
EVRnuc0iLSMnns7QMDTbVWvJyE0IizAqCwa4RaMkgTTCVLJdoTEgPzGDyjDAXLCVqsqE01BRnWIF
|
||||
K45x2gCQk0qrPyVpNEIoH5ay7hicTB4h5KamUwqSTCOEQSnwJ0vLks8jhIJk88hMjlhZUZbc6bEw
|
||||
RTRCKE+mEOekjkcISZsda1JjHhqSZCglA1PMI6TDyeAxPPGuFY2yxPModoOHopyacB6u0Ahh8P8J
|
||||
jwQzyXCPR0JHV7o79sGQOIuPf5kkPuQniEfygigUhU6ae9rpnxJjxOkMZ4wcxZ995lmfFuAzZ6Nt
|
||||
G/3Zc84VYcx57Iqx48ZzTJgoxmkCIpNqPV4Knsm8IZ/b9OhmET4PEjm/cfNjQlzArpgCtGe8qEsu
|
||||
9DX5CXibpnIiP3z80S2CdlyE8bj4iSe3CHlcwq4Y52km4A9ME/v6kz0UEb/nUj40nvrRo0Im2OC6
|
||||
bKuYxxh2weW1LVRjmgNXiK3kyiYvdW+T7yr2pqt/vG27kMnlAI/p2xuJgTWC/lUDI+3s/Rrg5mvZ
|
||||
m2Y8/RMxky/QPGY8s3WHkMd17IqZXnKcN3kn2REZMMtHD67x7F2zt9n0yRdJItc3/lTcIXXsijnA
|
||||
MJ9rL8FXtDZTt/tq+dC5YeezQibnUjxufM7GQrh9zQ2QDWnxD5dMJqcH6B9iHnvbTc/v3LXtZwIm
|
||||
lxBEzt8qll5N8ZChMUE2K97cAgzNsex1t+x+YdceAZOLRkl5TLeT3i+xKwD9DNwqn9/HA2NzPnvf
|
||||
ghdf2iPsk/NkPBYu2iu2dD4kF9Py2ea7TU5kuJ9+RmAJe+PtP98nZLL0DgmR2Y/bWDoXiWXAr7k8
|
||||
jcAEoFensaFz59Mv7Rcy+bLE0l+2kV4u21chLsaVFJGaabSdBeayd9514KCYib3Ldf6TYktfyqV3
|
||||
BaA4SygeqgSTj/FO4RJ898729j0Cix9jx+OeV14lnKx6eg5oqR1OE0kbCYzQEeyto5//xf72Pa+1
|
||||
//J1U/PsXK7LtooNhDNfuQqQ3okAj7RJgATrXK7d+0J98tobvzIzEbtcd71pI71fYVesBoz0DIQH
|
||||
JMGt3OVS7T3E5K1fm5lcJ+KxcNEmMY+vsivGemnPNXAhRgSQYIPLtS/C5G0TE5HLtcZOeteyK+YD
|
||||
lj4Z4wFJsM7lulftkhCTt0x9InC5bvzNJrH0nuXA0gHp5bgV+Fm4vasSHGbyhsnirRJ8vU18u5Rd
|
||||
MAqx9GtgHmkTA23kQNVcLlWCI0yMfTLG7HLd84rNXMidrOXAWFhVghMpn+/E5QpLcLvA4s0ul530
|
||||
cq8XcLL8rfU5MI9MRDvaAg3s/SGXK9onJouvM/BY91sb6eVjEHCyWu9TKmAiZYoyD3G5uAQfCtt7
|
||||
2OLfft1Ogu9ctJWQ3oYAMIOtVxS06K4UnV/vZy2Yvu1glMnvXvu9vk/0ErymkZLeKYDGrA5dVw0S
|
||||
Cadux9Fd0uJfyZpwQ8TeLUx0EkzHt8BE7G0LL6yBlcOnwj8Pl+CbovYeZSLQI1uvl3OFBkFkqRNb
|
||||
Cy6NPHeJIwlm9m5mspRJ8D2HbbxevpIFmKVvVeRxWMqEFQUgEjyHW/LTUXs3M2Fztp30OnGymltZ
|
||||
IASNLZZNB0S9WQuxuL2rTN76g8ZkrVx6v6bgv1sr/92qAB4DKhz0dWAWu3ght3eVyS6NSfgnt11w
|
||||
4E5WA72S1eSdya4eAhAZxs1zZa0TCdbsPcTknV1/5EzUSGP2drH0LuW6Nw2U3giQOVFXb7IB0MNm
|
||||
PgvcsvsFrU/eefZPjMkYidfL49v7gVf5dY4CUNClL9S4D/iZeKJhwYvc3tvb39WYbP668kAjIb11
|
||||
zUAIdL+uZXSZSqnuamU9vTAT9hkiWMMl2MBk8znrXraRXj7xI/HtFH3L6Mm9SH+5cinkxTEJPqTr
|
||||
EpXJn6N98opYebWVrJn0MkGbr0HfMNpIjAWkUyG/ml297sBBPZP3oky2NG4OdYhgcPEgE1nJmm9o
|
||||
mEKWpZly0cBysm8Vl567NQmOMPkLs/gdT1rnEe5kIfGtd7GxYeRMYq5ZRByg5ezi0ToJ1vfJYzs2
|
||||
PbfIbCfO4tvlpnZR0VW66XplLu1yeZt5rvdqvb1rfbJj7xMdndtNfeIkieCbZQ6bqVqbIjMRZT69
|
||||
hKpJ8Ay9BHMmIR5HOrve32sgwte5p7Y5cYUYKgki1jJSZMVMk+DZ7x60Mtn716OdwWMdTxgCEp55
|
||||
AJTRs8LSLCVdTiTfegeQKNYkeOG9L7W3m5j8TeXRHez6+yadcnHpReYq73prs4gKekEBDeRybWBX
|
||||
X3zA2CX73/3gqQ8/DHZ3d/d0HtYcFR6nKNc6crJQ2aoRVZICLpdBgt/T83jhg3989JHKozvYu/FV
|
||||
7jqe7eDhLf46QavkTkq68OtUxOXiud4FL+okOMTj439+EuYRQtczzJl3lkQYJ2qUXH8zhLVyQC5M
|
||||
GPWaeAQ7jzdGKzf4SpajZScD5N5WpvAeSFi4C6FFvXoe6r+DXdH4Sotvgfytlns1QD6RZImJTKW9
|
||||
bN37WNRr6o/QPx3BzeEucRLfepaJ2zRUSiRbfBOUwecjIBr1GnlEyHT9S+0SHt8CKzUWJ4uhXErE
|
||||
7uOpUUhNBbfJmw6E7H3/vy08uruPHnvz1S1LudeLxLfzbJokn9ptv6uoR1SSN/CW3ftCPP5j4RHq
|
||||
kvf3ak4WsppZuzImIvbfeiLzFl94XHBop5BHd/eRjsPnsKuAIjkb6VUhD63ybIkg5QiaBE/f9l8h
|
||||
j1CXbOSWjhTJTVESTQSKevkCmnLvx0Ie3d2933Dw07RpafDEEQGi3mbN5Xrwk6MiHsHOh7iThfgL
|
||||
y5QYici+h3YW9X7zRI+ASE/nt9gFSBKhaXGsRPIlRKCyNi6WM44fEXRI77fZ30OWvlzSGrlqST/C
|
||||
BQoNdS7XmhNWJkc6v8P+GrB0lkSIhYj8O8M5TlZtFj5kIRLsup797Vi6HFa3ziTCICmRItmtSDGu
|
||||
Lte77vgxE4+OHq1Sw0nYKcRAKRHiEyRnK5sP95jsvetM9ldIOsy3XtoWufdbIv8Utw6xUL7WPNrY
|
||||
JcHOjfxBs2KMb3WQp0hKB8nvHkevDekCU6ME9/Ry6QWU3CuMb3UgPvOhvrlH/FXucs3Q23uw9xHe
|
||||
sUgSYTLREmLLjiHE7YAE61yu2bouOdbBV7Lill4VxCfJ5PYBSPW9tnC+8Ygmvd9lfwiIHyG9Koic
|
||||
VRV1P5JF1qLei5m9BztOcK93DrAIu4JqRwXx4X4m9QDI+Z7Gr36ESXDX99gfAUkEXf7WDvL5UJ+c
|
||||
tgPkJOkk+EhUetmYh6Lm1VQr6K8sB5GPAAJUnXbeHrb3nl7uZAHS20JJbwinUETyyEc4W629U5Xg
|
||||
YO8D7P+RIjlD/tYG5D4K2fQz0PqwCO46cSx49Ci3dKBIThLfaiihiNDWjlXsabLzcGhgOYpvAw10
|
||||
E8opHmlpwJfSi2kJ9nvq2dUPHu89wQNHZFjOp1ug5NFEkE+MoapWPjU/0uEsvh0LtAAoPUO2BoKS
|
||||
AVqu9/vsv+qQnNE8oAFKBk0E2hWh3gNUfk+13AY5WSuB9wMmYs20C4G4XJeab0JK2VrnIq+Hapih
|
||||
LfKAmLupxRziIR+BzUHejlQ56SvPZACiXnPQ3QDEty2kk6UCrMWGtqqo89OfaZt88Vjzt1YA4qsC
|
||||
8FIUyOUyGi5QJIc4WSrAXeqwsQVVOOuWCoEulCQRjCML3aMO2walwUfnzXSLt46L5OwBb/IE7gfm
|
||||
6CMTIL5tRpwsFfBeaCXY86DcMmubs1hfiqEoD1bmTwJwudhoqQdWX2RJBD2QMmxn5g6Vvo2Dr1yO
|
||||
vRU2dRXl2DM3AN/1hhXVWZJIDkc7CaHbGIK5XqBITveJJgFn++eDG1EhEuxdnEjpRWd1hmzwsYgf
|
||||
OALJq/hA6aVKAGPtEsQz99cCA2sZ9j6nHYJbCVBwBSwAwdLruEPA+AqLemmg0otFVEYUgY+upyWY
|
||||
BJ1EiIJauhYC3Wf5vviZ0EmEKBzt4caA7mm4nv5mguJBJhGioJbgbYBuiguU20hB5W81xLoFMGjv
|
||||
a4GUoAzW5RYbxLzZLLIOrAIIYiUg87cM+OffFmDRu6IAu9tIOgRIIoQRz9bl4OACcr220L7JTNbA
|
||||
UoHuKrsiZgluDtRjr4BWSe0B7nYPfDthA1h6gWVrKcCNTAFHXQggfxuBg/hWjBrMTOqA3W1E0Oo9
|
||||
5EjANt/gBB+bBKPSm5BtmEHvEahcEnQIJr2ViTmfIBt6GVBLZgEa38Zr6AzYoULAwqMJUP5Wgdes
|
||||
AUBnEQDlNuaBhS0txuS72wBaegQyhEZLx+LbxJ6wgERZlwM5W0OHQPlbsugkCUzGO3JUsKXFRPPA
|
||||
RheQ+9EAOVnJOLmDKndU1IVH3OWC8rfJOTQtn34xHvVCTlayDn3MJt+8to1OFEY7hM7fVg5PEo+0
|
||||
tGHkQiqQPwgDyN+emsxjazPI422ACudwh5D526Qca6MDFcYvgaJe2smKO/4gQa1uA1lP2skamIrT
|
||||
BDPkc+NMpLyOcLKSPawY5KcI0i4X4WSl7hzBtGLZNE9XOMuTCLkpPQQ1WyLEVLmN1MkanOoTqdMl
|
||||
0RbhcrVusL2zwo2DXIttwy15hVnA/iOwHJeObh9mZyqyqLfJ9vvb3ESF5jGgSExFFvXa5W/dpKEi
|
||||
U+jd20uwWHor80vcpaEiI9+6GLnSbxf1iqR3cKFrx04bUZNlsXs7CbZKb2VuqgVXivTCauPMYlNu
|
||||
Y8rfVuYVuXqcuZhL1RDdGBN/X2FIIgzN6YMsIqjJLKhmZETlNlr+dlBeocsqRZMprsoZXC4ut1Hj
|
||||
24qhZQVZfZ2EhvRhVdatVLz+H2RlujR5x4EBllyv/HygvgvzSSbU+UB9F8aTTNoCt8X/SHdwoeE7
|
||||
P89qt9sTO/SJRa/3ZrebEzuKdRLsqXe7NfHgDt4l2PlAfRbaYVInq/QysPP8Tl7pZRjZGnGyTlrp
|
||||
ZZgUrsKmj2br+1D3+HRyPlCfhXqYlJPzgfouJnh8q04+l1eEWz1XxP+QvoCJ4NFsfR5Xnhb/M/rR
|
||||
j370I4L/AYWr1zfavhjTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIyLTA0LTA0VDE1OjAzOjMwKzA4
|
||||
OjAwP7kofQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMi0wNC0wNFQxNTowMzozMCswODowME7kkMEA
|
||||
AAAgdEVYdHNvZnR3YXJlAGh0dHBzOi8vaW1hZ2VtYWdpY2sub3JnvM8dnQAAABh0RVh0VGh1bWI6
|
||||
OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpIZWlnaHQAMjAwfdcV
|
||||
aQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAyMDDuJkU0AAAAGXRFWHRUaHVtYjo6TWltZXR5
|
||||
cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2NDkwNTU4MTD76yvxAAAAEnRF
|
||||
WHRUaHVtYjo6U2l6ZQA0NTEzQkLz/Q6yAAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBwL3Rt
|
||||
cC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTY0Nzg0ODMyNDI4NTI2NzhfODZfWzBdhSsH1AAAAABJRU5E
|
||||
rkJggg==" ></image>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,54 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/install/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BuildAdmin-安装</title>
|
||||
<script>
|
||||
(function(){
|
||||
var urls = { adminUrl: '', frontUrl: '' };
|
||||
fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){
|
||||
if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; }
|
||||
}).catch(function(){});
|
||||
function applyUrls() {
|
||||
if (!urls.adminUrl && !urls.frontUrl) return;
|
||||
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
|
||||
var v = (inp.value || '').trim();
|
||||
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0) && v.indexOf('#/') >= 0) {
|
||||
inp.value = urls.adminUrl;
|
||||
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; });
|
||||
document.querySelectorAll('a[href*="#/"]').forEach(function(a){
|
||||
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
|
||||
else setInterval(applyUrls, 800);
|
||||
})();
|
||||
(function(){
|
||||
function closeMigrateModal() {
|
||||
if (!document.body) return;
|
||||
var txt = document.body.innerText || document.body.textContent || '';
|
||||
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
|
||||
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
var b = btns[i];
|
||||
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
|
||||
}
|
||||
}
|
||||
var obs = new MutationObserver(closeMigrateModal);
|
||||
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } }
|
||||
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
|
||||
setInterval(closeMigrateModal, 150);
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/install/assets/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
27
tmp_sig.php
Normal file
27
tmp_sig.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
//脚本执行指令 php tmp_sig.php
|
||||
$secret = '5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca';
|
||||
$ts = '1775525663';
|
||||
$rid = 'req_1775525663_123';
|
||||
$body = [
|
||||
'report_date' => '1775525663',
|
||||
'member' => [
|
||||
[
|
||||
'member_id' => '123',
|
||||
'login' => 'zhenhui',
|
||||
'ltv_deposit' => 1500,
|
||||
'ltv_withdrawal' => 1800,
|
||||
'yesterday_total_wl' => -300,
|
||||
'yesterday_total_deposit' => 600,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$json = json_encode($body);
|
||||
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', $json);
|
||||
|
||||
echo "json={$json}\n";
|
||||
echo "sha256=" . hash('sha256', $json) . "\n";
|
||||
echo "X-Signature=" . hash_hmac('sha256', $canonical, $secret) . "\n";
|
||||
|
||||
@@ -8,4 +8,4 @@ VITE_BASE_PATH = '/'
|
||||
VITE_OUT_DIR = 'dist'
|
||||
|
||||
# 线上环境接口地址 - 'getCurrentDomain:表示获取当前域名'
|
||||
VITE_AXIOS_BASE_URL = 'getCurrentDomain'
|
||||
VITE_AXIOS_BASE_URL = 'https://playx-api.cjdhr.top'
|
||||
|
||||
@@ -232,14 +232,16 @@ const getData = debounce((initValue: valueTypes = '') => {
|
||||
state.params.initValue = initValue
|
||||
getSelectData(props.remoteUrl, state.keyword, state.params)
|
||||
.then((res) => {
|
||||
let opts = res.data.options ? res.data.options : res.data.list
|
||||
const data = res?.data ?? {}
|
||||
let opts = data.options ?? data.list ?? []
|
||||
opts = Array.isArray(opts) ? opts : []
|
||||
if (typeof props.labelFormatter === 'function') {
|
||||
for (const key in opts) {
|
||||
opts[key][props.field] = props.labelFormatter(opts[key], key)
|
||||
}
|
||||
}
|
||||
state.options = opts
|
||||
state.total = res.data.total ?? 0
|
||||
state.total = data.total ?? 0
|
||||
state.optionValidityFlag = state.keyword || (typeof initValue === 'object' ? !isEmpty(initValue) : initValue) ? false : true
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
45
web/src/components/table/fieldRender/date.vue
Normal file
45
web/src/components/table/fieldRender/date.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>{{ formattedValue }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { TableColumnCtx } from 'element-plus'
|
||||
import { getCellValue } from '/@/components/table/index'
|
||||
|
||||
interface Props {
|
||||
row: TableRow
|
||||
field: TableColumn
|
||||
column: TableColumnCtx<TableRow>
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
|
||||
|
||||
// PlayX “业务日期”在后端为 YYYY-MM-DD(或 YYYY-MM-DDTHH:mm:ssZ)。
|
||||
// 这里尽量“按字符串原样”处理,避免 new Date('YYYY-MM-DD') 的时区差导致日期偏移 1 天。
|
||||
const s = typeof cellValue === 'string' ? cellValue : String(cellValue)
|
||||
|
||||
// 常见 ISO/日期字符串截取
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (m) return m[1]
|
||||
|
||||
// 如果后端返回的是秒级时间戳,做兜底:用本地日期组件拼出 YYYY-MM-DD
|
||||
const n = Number(cellValue)
|
||||
if (Number.isFinite(n) && s.length === 10) {
|
||||
const d = new Date(n * 1000)
|
||||
const y = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
return s
|
||||
})
|
||||
</script>
|
||||
|
||||
40
web/src/components/table/fieldRender/ipWhiteList.vue
Normal file
40
web/src/components/table/fieldRender/ipWhiteList.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="ba-ip-white-display">
|
||||
<template v-if="displayIps.length">
|
||||
<div v-for="(ip, idx) in displayIps" :key="idx" class="ba-ip-white-line">{{ ip }}</div>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TableColumnCtx } from 'element-plus'
|
||||
import { computed } from 'vue'
|
||||
import { getCellValue } from '/@/components/table/index'
|
||||
|
||||
interface Props {
|
||||
row: TableRow
|
||||
field: TableColumn
|
||||
column: TableColumnCtx<TableRow>
|
||||
index: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
|
||||
|
||||
const displayIps = computed(() => {
|
||||
if (!cellValue || !Array.isArray(cellValue)) return []
|
||||
return cellValue.map((item) => (typeof item === 'string' ? item : item?.value ?? '')).filter(Boolean)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-ip-white-display {
|
||||
white-space: pre-line;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ba-ip-white-line {
|
||||
margin: 2px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,18 +3,18 @@
|
||||
<el-switch
|
||||
v-if="field.prop"
|
||||
@change="onChange"
|
||||
:model-value="cellValue"
|
||||
:model-value="displayValue"
|
||||
:loading="loading"
|
||||
active-value="1"
|
||||
inactive-value="0"
|
||||
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue, column, index })"
|
||||
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue: displayValue, column, index })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TableColumnCtx } from 'element-plus'
|
||||
import { inject, ref } from 'vue'
|
||||
import { inject, ref, onMounted } from 'vue'
|
||||
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
|
||||
@@ -28,13 +28,27 @@ interface Props {
|
||||
const loading = ref(false)
|
||||
const props = defineProps<Props>()
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const cellValue = ref(getCellValue(props.row, props.field, props.column, props.index))
|
||||
|
||||
if (typeof cellValue.value === 'number') {
|
||||
cellValue.value = cellValue.value.toString()
|
||||
const rawValue = getCellValue(props.row, props.field, props.column, props.index)
|
||||
const normalized = (v: unknown) => {
|
||||
if (typeof v === 'number') return v.toString()
|
||||
if (v === null || v === undefined || v === '') return '0'
|
||||
return String(v)
|
||||
}
|
||||
const displayValue = ref(normalized(rawValue))
|
||||
|
||||
const mountedAt = ref(0)
|
||||
onMounted(() => {
|
||||
mountedAt.value = Date.now()
|
||||
})
|
||||
|
||||
const onChange = (value: string | number | boolean) => {
|
||||
const newVal = String(value)
|
||||
const prevVal = normalized(rawValue)
|
||||
if (prevVal === newVal) return
|
||||
if ([null, undefined, ''].includes(rawValue) && newVal === '0') return
|
||||
if (Date.now() - mountedAt.value < 150) return
|
||||
|
||||
loading.value = true
|
||||
baTable.api
|
||||
.postData('edit', {
|
||||
@@ -42,7 +56,7 @@ const onChange = (value: string | number | boolean) => {
|
||||
[props.field.prop!]: value,
|
||||
})
|
||||
.then(() => {
|
||||
cellValue.value = value
|
||||
displayValue.value = newVal
|
||||
baTable.onTableAction('field-change', { value: value, ...props })
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* backend common language package
|
||||
*/
|
||||
import menu from './en/menu'
|
||||
|
||||
export default {
|
||||
menu,
|
||||
Balance: 'Balance',
|
||||
Integral: 'Integral',
|
||||
Connection: 'connection',
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
mobile: 'Mobile Number',
|
||||
'Last login': 'Last login',
|
||||
Password: 'Password',
|
||||
agent_id: 'agent',
|
||||
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
|
||||
'Personal signature': 'Personal Signature',
|
||||
'Administrator login': 'Administrator Login Name',
|
||||
|
||||
@@ -36,4 +36,23 @@ export default {
|
||||
second: 'Second',
|
||||
day: 'Day',
|
||||
'Number of attachments Uploaded': 'Number of attachments upload',
|
||||
Today: 'Today',
|
||||
Yesterday: 'Yesterday',
|
||||
Orders: 'Orders',
|
||||
Pending: 'Pending',
|
||||
'Daily new players': 'Daily new players',
|
||||
'Yesterday points': 'Yesterday points (claimed)',
|
||||
'Yesterday redeem': 'Yesterday redeem',
|
||||
'Pending physical to ship': 'Pending physical to ship',
|
||||
'Yesterday item redeem stat': 'Yesterday item redeem stat',
|
||||
'Yesterday redeem points sum': 'Yesterday redeem points sum',
|
||||
'Yesterday redeem amount sum': 'Yesterday redeem amount sum',
|
||||
'Grant failed retryable': 'Grant failed retryable',
|
||||
'Item ID': 'Item ID',
|
||||
'Item title': 'Item title',
|
||||
'Order count': 'Order count',
|
||||
Completed: 'Completed',
|
||||
Rejected: 'Rejected',
|
||||
'Points sum': 'Points sum',
|
||||
'Amount sum': 'Amount sum',
|
||||
}
|
||||
|
||||
15
web/src/lang/backend/en/mall/address.ts
Normal file
15
web/src/lang/backend/en/mall/address.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
playx_user_asset_id: 'PlayX user asset',
|
||||
playxuserasset__username: 'username',
|
||||
receiver_name: 'receiver name',
|
||||
phone: 'phone',
|
||||
region: 'region',
|
||||
detail_address: 'detail_address',
|
||||
default_setting: 'Default address',
|
||||
'default_setting 0': '--',
|
||||
'default_setting 1': 'YES',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
9
web/src/lang/backend/en/mall/claimLog.ts
Normal file
9
web/src/lang/backend/en/mall/claimLog.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
claim_request_id: 'claim_request_id',
|
||||
user_id: 'user_id',
|
||||
claimed_amount: 'claimed_amount',
|
||||
create_time: 'create_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
|
||||
13
web/src/lang/backend/en/mall/dailyPush.ts
Normal file
13
web/src/lang/backend/en/mall/dailyPush.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
user_id: 'user_id',
|
||||
date: 'date',
|
||||
username: 'username',
|
||||
yesterday_win_loss_net: 'yesterday_win_loss_net',
|
||||
yesterday_total_deposit: 'yesterday_total_deposit',
|
||||
lifetime_total_deposit: 'lifetime_total_deposit',
|
||||
lifetime_total_withdraw: 'lifetime_total_withdraw',
|
||||
create_time: 'create_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ export default {
|
||||
description: 'description',
|
||||
remark: 'remark',
|
||||
score: 'score',
|
||||
'类型': '类型',
|
||||
'类型 1': '类型 1',
|
||||
'类型 2': '类型 2',
|
||||
'类型 3': '类型 3',
|
||||
type: 'type',
|
||||
'type 1': 'type 1',
|
||||
'type 2': 'type 2',
|
||||
'type 3': 'type 3',
|
||||
amount: 'amount',
|
||||
multiplier: 'multiplier',
|
||||
category: 'category',
|
||||
category_title: 'category_title',
|
||||
admin_id: 'admin_id',
|
||||
admin__username: 'username',
|
||||
image: 'show image',
|
||||
stock: 'stock',
|
||||
sort: 'sort',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
|
||||
43
web/src/lang/backend/en/mall/order.ts
Normal file
43
web/src/lang/backend/en/mall/order.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
approve: 'Review',
|
||||
manual_retry: 'Retry grant',
|
||||
retry_confirm: 'Queue this order for grant retry?',
|
||||
id: 'Order ID',
|
||||
user_id: 'User ID',
|
||||
type: 'Type',
|
||||
'type BONUS': 'Bonus',
|
||||
'type PHYSICAL': 'Physical',
|
||||
'type WITHDRAW': 'Withdraw',
|
||||
status: 'Status',
|
||||
'status PENDING': 'Pending',
|
||||
'status COMPLETED': 'Completed',
|
||||
'status SHIPPED': 'Shipped',
|
||||
'status REJECTED': 'Rejected',
|
||||
mall_item_id: 'Product ID',
|
||||
mallitem__title: 'Product title',
|
||||
points_cost: 'Points spent',
|
||||
amount: 'Cash amount',
|
||||
multiplier: 'Turnover multiplier',
|
||||
external_transaction_id: 'Order number',
|
||||
playx_transaction_id: 'PlayX transaction ID',
|
||||
grant_status: 'Grant status',
|
||||
'grant_status NOT_SENT': 'Not sent',
|
||||
'grant_status SENT_PENDING': 'Sent (queued)',
|
||||
'grant_status ACCEPTED': 'Accepted',
|
||||
'grant_status FAILED_RETRYABLE': 'Failed (retryable)',
|
||||
'grant_status FAILED_FINAL': 'Failed (final)',
|
||||
'grant_status ---': '—',
|
||||
fail_reason: 'Failure reason',
|
||||
reject_reason: 'Rejection reason',
|
||||
shipping_company: 'Carrier',
|
||||
shipping_no: 'Tracking number',
|
||||
receiver_name: 'Recipient name',
|
||||
receiver_phone: 'Recipient phone',
|
||||
receiver_address: 'Shipping address',
|
||||
mall_address_id: 'Address ID',
|
||||
start_time: 'Redemption time',
|
||||
end_time: 'Collection end time',
|
||||
create_time: 'Created at',
|
||||
update_time: 'Updated at',
|
||||
'quick Search Fields': 'Order ID',
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
order: 'order',
|
||||
mall_user_id: 'mall_user_id',
|
||||
malluser__username: 'username',
|
||||
playx_user_asset_id: 'PlayX user asset',
|
||||
playxuserasset__username: 'username',
|
||||
type: 'type',
|
||||
'type 1': 'type 1',
|
||||
'type 2': 'type 2',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
score: 'score',
|
||||
quickSearchFields: 'id',
|
||||
}
|
||||
8
web/src/lang/backend/en/mall/playxCenter.ts
Normal file
8
web/src/lang/backend/en/mall/playxCenter.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
title: 'PlayX Integration Center',
|
||||
desc: 'Manage orders, daily push, claim logs and user assets in one place.',
|
||||
orders: 'Orders',
|
||||
dailyPush: 'Daily Push',
|
||||
claimLog: 'Claim Log',
|
||||
userAsset: 'User Asset',
|
||||
}
|
||||
8
web/src/lang/backend/en/mall/playxClaimLog.ts
Normal file
8
web/src/lang/backend/en/mall/playxClaimLog.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
claim_request_id: 'claim_request_id',
|
||||
user_id: 'user_id',
|
||||
claimed_amount: 'claimed_amount',
|
||||
create_time: 'create_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
12
web/src/lang/backend/en/mall/playxDailyPush.ts
Normal file
12
web/src/lang/backend/en/mall/playxDailyPush.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
user_id: 'user_id',
|
||||
date: 'date',
|
||||
username: 'username',
|
||||
yesterday_win_loss_net: 'yesterday_win_loss_net',
|
||||
yesterday_total_deposit: 'yesterday_total_deposit',
|
||||
lifetime_total_deposit: 'lifetime_total_deposit',
|
||||
lifetime_total_withdraw: 'lifetime_total_withdraw',
|
||||
create_time: 'create_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
37
web/src/lang/backend/en/mall/playxOrder.ts
Normal file
37
web/src/lang/backend/en/mall/playxOrder.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
user_id: 'user_id',
|
||||
type: 'type',
|
||||
'type BONUS': 'Bonus(BONUS)',
|
||||
'type PHYSICAL': 'Physical(PHYSICAL)',
|
||||
'type WITHDRAW': 'Withdraw(WITHDRAW)',
|
||||
status: 'status',
|
||||
'status PENDING': 'Pending(PENDING)',
|
||||
'status COMPLETED': 'Completed(COMPLETED)',
|
||||
'status SHIPPED': 'Shipped(SHIPPED)',
|
||||
'status REJECTED': 'Rejected(REJECTED)',
|
||||
mall_item_id: 'mall_item_id',
|
||||
mallitem__title: 'title',
|
||||
points_cost: 'points_cost',
|
||||
amount: 'amount',
|
||||
multiplier: 'multiplier',
|
||||
external_transaction_id: 'external_transaction_id',
|
||||
playx_transaction_id: 'playx_transaction_id',
|
||||
grant_status: 'grant_status',
|
||||
'grant_status NOT_SENT': 'NOT_SENT',
|
||||
'grant_status SENT_PENDING': 'SENT_PENDING',
|
||||
'grant_status ACCEPTED': 'ACCEPTED',
|
||||
'grant_status FAILED_RETRYABLE': 'FAILED_RETRYABLE',
|
||||
'grant_status FAILED_FINAL': 'FAILED_FINAL',
|
||||
'grant_status ---': '---',
|
||||
fail_reason: 'fail_reason',
|
||||
reject_reason: 'reject_reason',
|
||||
shipping_company: 'shipping_company',
|
||||
shipping_no: 'shipping_no',
|
||||
receiver_name: 'receiver_name',
|
||||
receiver_phone: 'receiver_phone',
|
||||
receiver_address: 'receiver_address',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
14
web/src/lang/backend/en/mall/playxUserAsset.ts
Normal file
14
web/src/lang/backend/en/mall/playxUserAsset.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
phone: 'phone',
|
||||
playx_user_id: 'playx_user_id',
|
||||
locked_points: 'locked_points',
|
||||
available_points: 'available_points',
|
||||
today_limit: 'today_limit',
|
||||
today_claimed: 'today_claimed',
|
||||
today_limit_date: 'today_limit_date',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
'quick Search Fields': 'id, playx_user_id, username, phone',
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
order: 'order',
|
||||
mall_user_id: 'mall_user_id',
|
||||
malluser__username: 'username',
|
||||
playx_user_asset_id: 'PlayX user asset',
|
||||
playxuserasset__username: 'username',
|
||||
status: 'status',
|
||||
'status 0': 'status 0',
|
||||
'status 1': 'status 1',
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
phone: 'phone',
|
||||
password: 'password',
|
||||
score: 'score',
|
||||
daily_claim: 'daily_claim',
|
||||
daily_claim_use: 'daily_claim_use',
|
||||
available_for_withdrawal: 'available_for_withdrawal',
|
||||
admin_id: 'admin_id',
|
||||
admin__username: 'username',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
'quick Search Fields': 'id',
|
||||
}
|
||||
15
web/src/lang/backend/en/mall/userAsset.ts
Normal file
15
web/src/lang/backend/en/mall/userAsset.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
id: 'id',
|
||||
username: 'username',
|
||||
phone: 'phone',
|
||||
playx_user_id: 'playx_user_id',
|
||||
locked_points: 'locked_points',
|
||||
available_points: 'available_points',
|
||||
today_limit: 'today_limit',
|
||||
today_claimed: 'today_claimed',
|
||||
today_limit_date: 'today_limit_date',
|
||||
create_time: 'create_time',
|
||||
update_time: 'update_time',
|
||||
'quick Search Fields': 'id, playx_user_id, username, phone',
|
||||
}
|
||||
|
||||
119
web/src/lang/backend/en/menu.ts
Normal file
119
web/src/lang/backend/en/menu.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Admin menu titles (admin_rule.name → menu.names.{name with / as _})
|
||||
*/
|
||||
export default {
|
||||
names: {
|
||||
dashboard: 'Dashboard',
|
||||
dashboard_index: 'Browse',
|
||||
dashboard_dashboard: 'Dashboard',
|
||||
auth: 'Access control',
|
||||
auth_group: 'Admin groups',
|
||||
auth_group_index: 'Browse',
|
||||
auth_group_add: 'Add',
|
||||
auth_group_edit: 'Edit',
|
||||
auth_group_del: 'Delete',
|
||||
auth_admin: 'Administrators',
|
||||
auth_admin_index: 'Browse',
|
||||
auth_admin_add: 'Add',
|
||||
auth_admin_edit: 'Edit',
|
||||
auth_admin_del: 'Delete',
|
||||
auth_rule: 'Menu rules',
|
||||
auth_rule_index: 'Browse',
|
||||
auth_rule_add: 'Add',
|
||||
auth_rule_edit: 'Edit',
|
||||
auth_rule_del: 'Delete',
|
||||
auth_rule_sortable: 'Sort',
|
||||
auth_adminLog: 'Admin logs',
|
||||
auth_adminLog_index: 'Browse',
|
||||
user: 'Members',
|
||||
user_user: 'Members',
|
||||
user_user_index: 'Browse',
|
||||
user_user_add: 'Add',
|
||||
user_user_edit: 'Edit',
|
||||
user_user_del: 'Delete',
|
||||
user_group: 'Member groups',
|
||||
user_group_index: 'Browse',
|
||||
user_group_add: 'Add',
|
||||
user_group_edit: 'Edit',
|
||||
user_group_del: 'Delete',
|
||||
user_rule: 'Member rules',
|
||||
user_rule_index: 'Browse',
|
||||
user_rule_add: 'Add',
|
||||
user_rule_edit: 'Edit',
|
||||
user_rule_del: 'Delete',
|
||||
user_rule_sortable: 'Sort',
|
||||
user_moneyLog: 'Balance logs',
|
||||
user_moneyLog_index: 'Browse',
|
||||
user_moneyLog_add: 'Add',
|
||||
user_scoreLog: 'Points logs',
|
||||
user_scoreLog_index: 'Browse',
|
||||
user_scoreLog_add: 'Add',
|
||||
routine: 'General',
|
||||
routine_config: 'System config',
|
||||
routine_config_index: 'Browse',
|
||||
routine_config_edit: 'Edit',
|
||||
routine_config_add: 'Add',
|
||||
routine_config_del: 'Delete',
|
||||
routine_attachment: 'Attachments',
|
||||
routine_attachment_index: 'Browse',
|
||||
routine_attachment_edit: 'Edit',
|
||||
routine_attachment_del: 'Delete',
|
||||
routine_adminInfo: 'Profile',
|
||||
routine_adminInfo_index: 'Browse',
|
||||
routine_adminInfo_edit: 'Edit',
|
||||
security: 'Data security',
|
||||
security_dataRecycleLog: 'Recycle bin',
|
||||
security_dataRecycleLog_index: 'Browse',
|
||||
security_dataRecycleLog_del: 'Delete',
|
||||
security_dataRecycleLog_restore: 'Restore',
|
||||
security_dataRecycleLog_info: 'Details',
|
||||
security_sensitiveDataLog: 'Sensitive data logs',
|
||||
security_sensitiveDataLog_index: 'Browse',
|
||||
security_sensitiveDataLog_del: 'Delete',
|
||||
security_sensitiveDataLog_rollback: 'Rollback',
|
||||
security_sensitiveDataLog_info: 'Details',
|
||||
security_dataRecycle: 'Recycle rules',
|
||||
security_dataRecycle_index: 'Browse',
|
||||
security_dataRecycle_add: 'Add',
|
||||
security_dataRecycle_edit: 'Edit',
|
||||
security_dataRecycle_del: 'Delete',
|
||||
security_sensitiveData: 'Sensitive field rules',
|
||||
security_sensitiveData_index: 'Browse',
|
||||
security_sensitiveData_add: 'Add',
|
||||
security_sensitiveData_edit: 'Edit',
|
||||
security_sensitiveData_del: 'Delete',
|
||||
buildadmin: 'BuildAdmin',
|
||||
buildadmin_buildadmin: 'BuildAdmin',
|
||||
moduleStore_moduleStore: 'Module store',
|
||||
moduleStore_moduleStore_index: 'Browse',
|
||||
moduleStore_moduleStore_install: 'Install',
|
||||
moduleStore_moduleStore_changeState: 'Change state',
|
||||
moduleStore_moduleStore_uninstall: 'Uninstall',
|
||||
moduleStore_moduleStore_update: 'Update',
|
||||
crud_crud: 'CRUD generator',
|
||||
crud_crud_index: 'Browse',
|
||||
crud_crud_generate: 'Generate',
|
||||
crud_crud_delete: 'Delete',
|
||||
mall: 'Points mall',
|
||||
mall_userAsset: 'User assets',
|
||||
mall_userAsset_index: 'Browse',
|
||||
mall_userAsset_edit: 'Edit',
|
||||
mall_userAsset_del: 'Delete',
|
||||
mall_address: 'Shipping addresses',
|
||||
mall_order: 'Orders',
|
||||
mall_order_add: 'Add',
|
||||
mall_order_edit: 'Edit',
|
||||
mall_order_del: 'Delete',
|
||||
mall_order_approve: 'Approve',
|
||||
mall_dailyPush: 'Daily push',
|
||||
mall_claimLog: 'Claim log',
|
||||
mall_item: 'Products',
|
||||
mall_playxOrder: 'PlayX orders',
|
||||
mall_playxCenter: 'PlayX center',
|
||||
mall_playxClaimLog: 'PlayX claim log',
|
||||
mall_playxDailyPush: 'PlayX daily push',
|
||||
mall_playxUserAsset: 'PlayX user assets',
|
||||
mall_pintsOrder: 'Points orders',
|
||||
mall_redemptionOrder: 'Redemption orders',
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
'Upload administrator': 'Upload administrator',
|
||||
'Upload user': 'Upload member',
|
||||
'Storage mode': 'Storage mode',
|
||||
'Physical path': 'Physical path',
|
||||
'image width': 'Picture width',
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
* 后台公共语言包
|
||||
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key
|
||||
*/
|
||||
import menu from './zh-cn/menu'
|
||||
|
||||
export default {
|
||||
menu,
|
||||
Balance: '余额',
|
||||
Integral: '积分',
|
||||
Connection: '连接标识',
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
mobile: '手机号',
|
||||
'Last login': '最后登录',
|
||||
Password: '密码',
|
||||
agent_id: '代理',
|
||||
'Please leave blank if not modified': '不修改请留空',
|
||||
'Personal signature': '个性签名',
|
||||
'Administrator login': '管理员登录名',
|
||||
|
||||
@@ -36,4 +36,23 @@ export default {
|
||||
second: '秒',
|
||||
day: '天',
|
||||
'Number of attachments Uploaded': '附件上传量',
|
||||
Today: '今日',
|
||||
Yesterday: '昨日',
|
||||
Orders: '订单',
|
||||
Pending: '待处理',
|
||||
'Daily new players': '每日新增玩家',
|
||||
'Yesterday points': '昨日积分(领取)',
|
||||
'Yesterday redeem': '昨日兑换',
|
||||
'Pending physical to ship': '待发货实物单',
|
||||
'Yesterday item redeem stat': '昨日商品兑换统计',
|
||||
'Yesterday redeem points sum': '昨日兑换消耗积分合计',
|
||||
'Yesterday redeem amount sum': '昨日兑换现金面值合计',
|
||||
'Grant failed retryable': '发放失败待重试',
|
||||
'Item ID': '商品ID',
|
||||
'Item title': '商品名称',
|
||||
'Order count': '兑换次数',
|
||||
Completed: '已完成',
|
||||
Rejected: '已驳回',
|
||||
'Points sum': '消耗积分合计',
|
||||
'Amount sum': '现金面值合计',
|
||||
}
|
||||
|
||||
15
web/src/lang/backend/zh-cn/mall/address.ts
Normal file
15
web/src/lang/backend/zh-cn/mall/address.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
playx_user_asset_id: '用户资产',
|
||||
playxuserasset__username: '用户名',
|
||||
receiver_name: '收货人',
|
||||
phone: '电话',
|
||||
region: '地区',
|
||||
detail_address: '详细地址',
|
||||
default_setting: '默认地址',
|
||||
'default_setting 0': '--',
|
||||
'default_setting 1': '是',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
9
web/src/lang/backend/zh-cn/mall/claimLog.ts
Normal file
9
web/src/lang/backend/zh-cn/mall/claimLog.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
claim_request_id: '领取订单号',
|
||||
user_id: '用户ID',
|
||||
claimed_amount: '领取积分',
|
||||
create_time: '创建时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
|
||||
13
web/src/lang/backend/zh-cn/mall/dailyPush.ts
Normal file
13
web/src/lang/backend/zh-cn/mall/dailyPush.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
user_id: '用户ID',
|
||||
date: '业务日期',
|
||||
username: '用户名',
|
||||
yesterday_win_loss_net: '昨日净输赢',
|
||||
yesterday_total_deposit: '昨日总充值',
|
||||
lifetime_total_deposit: '历史总充值',
|
||||
lifetime_total_withdraw: '历史总提现',
|
||||
create_time: '创建时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
|
||||
@@ -4,13 +4,22 @@ export default {
|
||||
description: '描述',
|
||||
remark: '备注',
|
||||
score: '兑换积分',
|
||||
'类型': '类型',
|
||||
'类型 1': '奖励',
|
||||
'类型 2': '充值',
|
||||
'类型 3': '实物',
|
||||
type: '类型',
|
||||
'type 1': '红利(BONUS)',
|
||||
'type 2': '实物(PHYSICAL)',
|
||||
'type 3': '提现(WITHDRAW)',
|
||||
amount: '现金面值',
|
||||
multiplier: '流水倍数',
|
||||
category: '红利业务类别',
|
||||
category_title: '类别展示名',
|
||||
admin_id: '创建管理员',
|
||||
admin__username: '用户名',
|
||||
admin__username: '创建管理员',
|
||||
image: '展示图',
|
||||
stock: '库存',
|
||||
sort: '排序',
|
||||
status: '状态',
|
||||
'status 0': '禁用',
|
||||
'status 1': '启用',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID',
|
||||
|
||||
43
web/src/lang/backend/zh-cn/mall/order.ts
Normal file
43
web/src/lang/backend/zh-cn/mall/order.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export default {
|
||||
approve: '审核',
|
||||
manual_retry: '手动重试',
|
||||
retry_confirm: '确认将该订单加入重试队列?',
|
||||
id: 'ID',
|
||||
user_id: '用户ID',
|
||||
type: '类型',
|
||||
'type BONUS': '红利(BONUS)',
|
||||
'type PHYSICAL': '实物(PHYSICAL)',
|
||||
'type WITHDRAW': '提现(WITHDRAW)',
|
||||
status: '状态',
|
||||
'status PENDING': '处理中(PENDING)',
|
||||
'status COMPLETED': '已完成(COMPLETED)',
|
||||
'status SHIPPED': '已发货(SHIPPED)',
|
||||
'status REJECTED': '已驳回(REJECTED)',
|
||||
mall_item_id: '商品ID',
|
||||
mallitem__title: '商品标题',
|
||||
points_cost: '消耗积分',
|
||||
amount: '现金面值',
|
||||
multiplier: '流水倍数',
|
||||
external_transaction_id: '订单号',
|
||||
playx_transaction_id: 'PlayX流水号',
|
||||
grant_status: '推送playx状态',
|
||||
'grant_status NOT_SENT': '未发送',
|
||||
'grant_status SENT_PENDING': '已发送排队',
|
||||
'grant_status ACCEPTED': '已接收',
|
||||
'grant_status FAILED_RETRYABLE': '失败可重试',
|
||||
'grant_status FAILED_FINAL': '失败最终',
|
||||
'grant_status ---': '---',
|
||||
fail_reason: '失败原因',
|
||||
reject_reason: '驳回原因',
|
||||
shipping_company: '物流公司',
|
||||
shipping_no: '物流单号',
|
||||
receiver_name: '收货人',
|
||||
receiver_phone: '收货电话',
|
||||
receiver_address: '收货地址',
|
||||
mall_address_id: '地址ID',
|
||||
start_time: '兑换时间',
|
||||
end_time: '领取结束时间',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
order: '订单编号',
|
||||
mall_user_id: '用户',
|
||||
malluser__username: '用户名',
|
||||
playx_user_asset_id: '用户资产',
|
||||
playxuserasset__username: '用户名',
|
||||
type: '类型',
|
||||
'type 1': '奖励',
|
||||
'type 2': '充值',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
score: '积分',
|
||||
quickSearchFields: 'ID',
|
||||
}
|
||||
9
web/src/lang/backend/zh-cn/mall/playxCenter.ts
Normal file
9
web/src/lang/backend/zh-cn/mall/playxCenter.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
title: 'PlayX 对接中心',
|
||||
desc: '集中管理积分商城与 PlayX 的订单、推送、领取与资产数据。建议优先处理“推送playx状态=失败可重试”的订单。',
|
||||
orders: '统一订单',
|
||||
dailyPush: '每日推送',
|
||||
claimLog: '领取记录',
|
||||
userAsset: '用户资产',
|
||||
}
|
||||
|
||||
9
web/src/lang/backend/zh-cn/mall/playxClaimLog.ts
Normal file
9
web/src/lang/backend/zh-cn/mall/playxClaimLog.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
claim_request_id: '领取订单号',
|
||||
user_id: '用户ID',
|
||||
claimed_amount: '领取积分',
|
||||
create_time: '创建时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
|
||||
13
web/src/lang/backend/zh-cn/mall/playxDailyPush.ts
Normal file
13
web/src/lang/backend/zh-cn/mall/playxDailyPush.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
user_id: '用户ID',
|
||||
date: '业务日期',
|
||||
username: '用户名',
|
||||
yesterday_win_loss_net: '昨日净输赢',
|
||||
yesterday_total_deposit: '昨日总充值',
|
||||
lifetime_total_deposit: '历史总充值',
|
||||
lifetime_total_withdraw: '历史总提现',
|
||||
create_time: '创建时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
|
||||
38
web/src/lang/backend/zh-cn/mall/playxOrder.ts
Normal file
38
web/src/lang/backend/zh-cn/mall/playxOrder.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
user_id: '用户ID',
|
||||
type: '类型',
|
||||
'type BONUS': '红利(BONUS)',
|
||||
'type PHYSICAL': '实物(PHYSICAL)',
|
||||
'type WITHDRAW': '提现(WITHDRAW)',
|
||||
status: '状态',
|
||||
'status PENDING': '处理中(PENDING)',
|
||||
'status COMPLETED': '已完成(COMPLETED)',
|
||||
'status SHIPPED': '已发货(SHIPPED)',
|
||||
'status REJECTED': '已驳回(REJECTED)',
|
||||
mall_item_id: '商品ID',
|
||||
mallitem__title: '商品标题',
|
||||
points_cost: '消耗积分',
|
||||
amount: '现金面值',
|
||||
multiplier: '流水倍数',
|
||||
external_transaction_id: '订单号',
|
||||
playx_transaction_id: 'PlayX流水号',
|
||||
grant_status: '推送playx状态',
|
||||
'grant_status NOT_SENT': '未发送',
|
||||
'grant_status SENT_PENDING': '已发送排队',
|
||||
'grant_status ACCEPTED': '已接收(accepted)',
|
||||
'grant_status FAILED_RETRYABLE': '失败可重试',
|
||||
'grant_status FAILED_FINAL': '失败最终',
|
||||
'grant_status ---': '---',
|
||||
fail_reason: '失败原因',
|
||||
reject_reason: '驳回原因',
|
||||
shipping_company: '物流公司',
|
||||
shipping_no: '物流单号',
|
||||
receiver_name: '收货人',
|
||||
receiver_phone: '收货电话',
|
||||
receiver_address: '收货地址',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
|
||||
14
web/src/lang/backend/zh-cn/mall/playxUserAsset.ts
Normal file
14
web/src/lang/backend/zh-cn/mall/playxUserAsset.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
username: '用户名',
|
||||
phone: '手机号',
|
||||
playx_user_id: 'PlayX用户ID',
|
||||
locked_points: '待领取积分',
|
||||
available_points: '可用积分',
|
||||
today_limit: '今日可领取上限',
|
||||
today_claimed: '今日已领取',
|
||||
today_limit_date: '今日上限日期',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID、PlayX用户ID、用户名、手机号',
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
order: '订单号',
|
||||
mall_user_id: '用户',
|
||||
malluser__username: '用户名',
|
||||
playx_user_asset_id: '用户资产',
|
||||
playxuserasset__username: '用户名',
|
||||
status: '状态',
|
||||
'status 0': '待发放',
|
||||
'status 1': '已发放',
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
username: '用户名',
|
||||
phone: '手机号',
|
||||
password: '密码',
|
||||
score: '积分',
|
||||
daily_claim: '每日限额',
|
||||
daily_claim_use: '每日限额(已使用)',
|
||||
available_for_withdrawal: '可提现金额',
|
||||
admin_id: '归属管理员',
|
||||
admin__username: '用户名',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID',
|
||||
}
|
||||
15
web/src/lang/backend/zh-cn/mall/userAsset.ts
Normal file
15
web/src/lang/backend/zh-cn/mall/userAsset.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
id: 'ID',
|
||||
username: '用户名',
|
||||
phone: '手机号',
|
||||
playx_user_id: 'PlayX用户ID',
|
||||
locked_points: '待领取积分',
|
||||
available_points: '可用积分',
|
||||
today_limit: '今日可领取上限',
|
||||
today_claimed: '今日已领取',
|
||||
today_limit_date: '今日上限日期',
|
||||
create_time: '创建时间',
|
||||
update_time: '修改时间',
|
||||
'quick Search Fields': 'ID、PlayX用户ID、用户名、手机号',
|
||||
}
|
||||
|
||||
120
web/src/lang/backend/zh-cn/menu.ts
Normal file
120
web/src/lang/backend/zh-cn/menu.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 后台菜单标题(与 admin_rule.name 对应:menu.names.{name 中 / 改为 _})
|
||||
*/
|
||||
export default {
|
||||
names: {
|
||||
/** version202 后菜单 name 为 dashboard,不再使用 dashboard/dashboard */
|
||||
dashboard: '控制台',
|
||||
dashboard_index: '查看',
|
||||
dashboard_dashboard: '控制台',
|
||||
auth: '权限管理',
|
||||
auth_group: '角色组管理',
|
||||
auth_group_index: '查看',
|
||||
auth_group_add: '添加',
|
||||
auth_group_edit: '编辑',
|
||||
auth_group_del: '删除',
|
||||
auth_admin: '管理员管理',
|
||||
auth_admin_index: '查看',
|
||||
auth_admin_add: '添加',
|
||||
auth_admin_edit: '编辑',
|
||||
auth_admin_del: '删除',
|
||||
auth_rule: '菜单规则管理',
|
||||
auth_rule_index: '查看',
|
||||
auth_rule_add: '添加',
|
||||
auth_rule_edit: '编辑',
|
||||
auth_rule_del: '删除',
|
||||
auth_rule_sortable: '快速排序',
|
||||
auth_adminLog: '管理员日志管理',
|
||||
auth_adminLog_index: '查看',
|
||||
user: '会员管理',
|
||||
user_user: '会员管理',
|
||||
user_user_index: '查看',
|
||||
user_user_add: '添加',
|
||||
user_user_edit: '编辑',
|
||||
user_user_del: '删除',
|
||||
user_group: '会员分组管理',
|
||||
user_group_index: '查看',
|
||||
user_group_add: '添加',
|
||||
user_group_edit: '编辑',
|
||||
user_group_del: '删除',
|
||||
user_rule: '会员规则管理',
|
||||
user_rule_index: '查看',
|
||||
user_rule_add: '添加',
|
||||
user_rule_edit: '编辑',
|
||||
user_rule_del: '删除',
|
||||
user_rule_sortable: '快速排序',
|
||||
user_moneyLog: '会员余额管理',
|
||||
user_moneyLog_index: '查看',
|
||||
user_moneyLog_add: '添加',
|
||||
user_scoreLog: '会员积分管理',
|
||||
user_scoreLog_index: '查看',
|
||||
user_scoreLog_add: '添加',
|
||||
routine: '常规管理',
|
||||
routine_config: '系统配置',
|
||||
routine_config_index: '查看',
|
||||
routine_config_edit: '编辑',
|
||||
routine_config_add: '添加',
|
||||
routine_config_del: '删除',
|
||||
routine_attachment: '附件管理',
|
||||
routine_attachment_index: '查看',
|
||||
routine_attachment_edit: '编辑',
|
||||
routine_attachment_del: '删除',
|
||||
routine_adminInfo: '个人资料',
|
||||
routine_adminInfo_index: '查看',
|
||||
routine_adminInfo_edit: '编辑',
|
||||
security: '数据安全管理',
|
||||
security_dataRecycleLog: '数据回收站',
|
||||
security_dataRecycleLog_index: '查看',
|
||||
security_dataRecycleLog_del: '删除',
|
||||
security_dataRecycleLog_restore: '还原',
|
||||
security_dataRecycleLog_info: '查看详情',
|
||||
security_sensitiveDataLog: '敏感数据修改记录',
|
||||
security_sensitiveDataLog_index: '查看',
|
||||
security_sensitiveDataLog_del: '删除',
|
||||
security_sensitiveDataLog_rollback: '回滚',
|
||||
security_sensitiveDataLog_info: '查看详情',
|
||||
security_dataRecycle: '数据回收规则管理',
|
||||
security_dataRecycle_index: '查看',
|
||||
security_dataRecycle_add: '添加',
|
||||
security_dataRecycle_edit: '编辑',
|
||||
security_dataRecycle_del: '删除',
|
||||
security_sensitiveData: '敏感字段规则管理',
|
||||
security_sensitiveData_index: '查看',
|
||||
security_sensitiveData_add: '添加',
|
||||
security_sensitiveData_edit: '编辑',
|
||||
security_sensitiveData_del: '删除',
|
||||
buildadmin: 'BuildAdmin',
|
||||
buildadmin_buildadmin: 'BuildAdmin',
|
||||
moduleStore_moduleStore: '模块市场',
|
||||
moduleStore_moduleStore_index: '查看',
|
||||
moduleStore_moduleStore_install: '安装',
|
||||
moduleStore_moduleStore_changeState: '调整状态',
|
||||
moduleStore_moduleStore_uninstall: '卸载',
|
||||
moduleStore_moduleStore_update: '更新',
|
||||
crud_crud: 'CRUD代码生成',
|
||||
crud_crud_index: '查看',
|
||||
crud_crud_generate: '生成',
|
||||
crud_crud_delete: '删除',
|
||||
mall: '积分商城',
|
||||
mall_userAsset: '用户资产',
|
||||
mall_userAsset_index: '查看',
|
||||
mall_userAsset_edit: '编辑',
|
||||
mall_userAsset_del: '删除',
|
||||
mall_address: '收货地址管理',
|
||||
mall_order: '统一订单',
|
||||
mall_order_add: '新增',
|
||||
mall_order_edit: '编辑',
|
||||
mall_order_del: '删除',
|
||||
mall_order_approve: '审核通过',
|
||||
mall_dailyPush: '每日推送',
|
||||
mall_claimLog: '领取记录',
|
||||
mall_item: '商品管理',
|
||||
mall_playxOrder: 'PlayX订单',
|
||||
mall_playxCenter: 'PlayX中心',
|
||||
mall_playxClaimLog: 'PlayX领取记录',
|
||||
mall_playxDailyPush: 'PlayX每日推送',
|
||||
mall_playxUserAsset: 'PlayX用户资产',
|
||||
mall_pintsOrder: '积分订单',
|
||||
mall_redemptionOrder: '兑换订单',
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
'Upload administrator': '上传管理员',
|
||||
'Upload user': '上传会员',
|
||||
'Storage mode': '存储方式',
|
||||
'Physical path': '物理路径',
|
||||
'image width': '图片宽度',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
|
||||
<template #title>
|
||||
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
|
||||
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
|
||||
<span>{{ menuTitleFromRoute(menu) }}</span>
|
||||
</template>
|
||||
<MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" />
|
||||
</el-sub-menu>
|
||||
@@ -12,7 +12,7 @@
|
||||
<template v-else>
|
||||
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
|
||||
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
|
||||
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
|
||||
<span>{{ menuTitleFromRoute(menu) }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
@@ -23,6 +23,7 @@ import { ElNotification } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { menuTitleFromRoute } from '/@/utils/menuI18n'
|
||||
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:ref="tabsRefs.set"
|
||||
:key="idx"
|
||||
>
|
||||
{{ item.meta.title }}
|
||||
{{ menuTitleFromRoute(item) }}
|
||||
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in">
|
||||
<Icon v-show="navTabs.state.tabsView.length > 1" class="close-icon" @click.stop="closeTab(item)" size="15" name="el-icon-Close" />
|
||||
</transition>
|
||||
@@ -29,6 +29,7 @@ import type { ContextMenuItem, ContextMenuItemClickEmitArg } from '/@/components
|
||||
import useCurrentInstance from '/@/utils/useCurrentInstance'
|
||||
import Contextmenu from '/@/components/contextmenu/index.vue'
|
||||
import horizontalScroll from '/@/utils/horizontalScroll'
|
||||
import { menuTitleFromRoute } from '/@/utils/menuI18n'
|
||||
import { getFirstRoute, routePush } from '/@/utils/router'
|
||||
import { adminBaseRoutePath } from '/@/router/static/adminBase'
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
|
||||
timeout: 1000 * 10,
|
||||
headers: {
|
||||
'think-lang': config.lang.defaultLang,
|
||||
server: true,
|
||||
},
|
||||
responseType: 'json',
|
||||
})
|
||||
|
||||
33
web/src/utils/menuI18n.ts
Normal file
33
web/src/utils/menuI18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
|
||||
import { i18n } from '/@/lang/index'
|
||||
|
||||
/**
|
||||
* 后台菜单/标签标题:优先按路由 name(对应 admin_rule.name)匹配 menu.names.*
|
||||
*/
|
||||
export function menuI18nKeyFromName(name: string | symbol | null | undefined): string {
|
||||
if (name == null || name === '') {
|
||||
return ''
|
||||
}
|
||||
const n = String(name).trim()
|
||||
if (!n) {
|
||||
return ''
|
||||
}
|
||||
return `menu.names.${n.replace(/\//g, '_')}`
|
||||
}
|
||||
|
||||
export function menuTitleFromName(name: string | symbol | null | undefined, fallback?: string): string {
|
||||
const key = menuI18nKeyFromName(name)
|
||||
if (key && i18n.global.te(key)) {
|
||||
return String(i18n.global.t(key))
|
||||
}
|
||||
if (fallback && i18n.global.te(fallback)) {
|
||||
return String(i18n.global.t(fallback))
|
||||
}
|
||||
return fallback || ''
|
||||
}
|
||||
|
||||
export function menuTitleFromRoute(route: RouteRecordRaw | RouteLocationNormalized): string {
|
||||
const name = route.name
|
||||
const metaTitle = route.meta && typeof route.meta.title === 'string' ? route.meta.title : ''
|
||||
return menuTitleFromName(name, metaTitle) || metaTitle || String(i18n.global.t('noTitle'))
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const baTable = new baTableClass(
|
||||
{ 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') },
|
||||
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' },
|
||||
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', minWidth: 120, operator: false, render: 'tags' },
|
||||
{ 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') },
|
||||
@@ -61,6 +61,13 @@ const baTable = new baTableClass(
|
||||
operator: 'RANGE',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.agent_id'),
|
||||
prop: 'agent_id',
|
||||
align: 'center',
|
||||
width: '160',
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
|
||||
{
|
||||
label: t('State'),
|
||||
|
||||
@@ -28,119 +28,88 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="12" :lg="6">
|
||||
<div class="small-panel user-reg suspension">
|
||||
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
|
||||
<div class="small-panel-title">{{ t('dashboard.Daily new players') }}</div>
|
||||
<div class="small-panel-content">
|
||||
<div class="content-left">
|
||||
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
|
||||
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
|
||||
</div>
|
||||
<div class="content-right">+14%</div>
|
||||
<div class="content-right color-info">{{ t('dashboard.Today') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :sm="12" :lg="6">
|
||||
<div class="small-panel file suspension">
|
||||
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
|
||||
<div class="small-panel-title">{{ t('dashboard.Yesterday points') }}</div>
|
||||
<div class="small-panel-content">
|
||||
<div class="content-left">
|
||||
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
|
||||
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
|
||||
</div>
|
||||
<div class="content-right">+50%</div>
|
||||
<div class="content-right color-info">{{ t('dashboard.Yesterday') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :sm="12" :lg="6">
|
||||
<div class="small-panel users suspension">
|
||||
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
|
||||
<div class="small-panel-title">{{ t('dashboard.Yesterday redeem') }}</div>
|
||||
<div class="small-panel-content">
|
||||
<div class="content-left">
|
||||
<Icon color="#74A8B5" size="20" name="fa fa-users" />
|
||||
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
|
||||
</div>
|
||||
<div class="content-right">+28%</div>
|
||||
<div class="content-right color-info">{{ t('dashboard.Orders') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :sm="12" :lg="6">
|
||||
<div class="small-panel addons suspension">
|
||||
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
|
||||
<div class="small-panel-title">{{ t('dashboard.Pending physical to ship') }}</div>
|
||||
<div class="small-panel-content">
|
||||
<div class="content-left">
|
||||
<Icon color="#F48595" size="20" name="fa fa-object-group" />
|
||||
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
|
||||
</div>
|
||||
<div class="content-right">+88%</div>
|
||||
<div class="content-right color-info">{{ t('dashboard.Pending') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="growth-chart">
|
||||
<el-row :gutter="20">
|
||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
||||
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
|
||||
<div class="user-growth-chart" :ref="chartRefs.set"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
||||
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
|
||||
<div class="file-growth-chart" :ref="chartRefs.set"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="6">
|
||||
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
|
||||
<div class="new-user-growth">
|
||||
<el-scrollbar>
|
||||
<div class="new-user-item">
|
||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
||||
<div class="new-user-base">
|
||||
<div class="new-user-name">妙码生花</div>
|
||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
||||
</div>
|
||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
||||
</div>
|
||||
<div class="new-user-item">
|
||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
||||
<div class="new-user-base">
|
||||
<div class="new-user-name">码上生花</div>
|
||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
||||
</div>
|
||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
||||
</div>
|
||||
<div class="new-user-item">
|
||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
||||
<div class="new-user-base">
|
||||
<div class="new-user-name">Admin</div>
|
||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
||||
</div>
|
||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
||||
</div>
|
||||
<div class="new-user-item">
|
||||
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
|
||||
<div class="new-user-base">
|
||||
<div class="new-user-name">纯属虚构</div>
|
||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
||||
</div>
|
||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="growth-chart">
|
||||
<el-row :gutter="20">
|
||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
||||
<el-card shadow="hover" :header="t('dashboard.Member source')">
|
||||
<div class="user-source-chart" :ref="chartRefs.set"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
||||
<el-card shadow="hover" :header="t('dashboard.Member last name')">
|
||||
<div class="user-surname-chart" :ref="chartRefs.set"></div>
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24">
|
||||
<el-card shadow="hover" :header="t('dashboard.Yesterday item redeem stat')">
|
||||
<div class="playx-kpis">
|
||||
<div class="playx-kpi">
|
||||
<div class="playx-kpi-title">{{ t('dashboard.Yesterday redeem points sum') }}</div>
|
||||
<div class="playx-kpi-value">{{ state.playx?.yesterday_redeem?.points_cost_sum ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="playx-kpi">
|
||||
<div class="playx-kpi-title">{{ t('dashboard.Yesterday redeem amount sum') }}</div>
|
||||
<div class="playx-kpi-value">{{ state.playx?.yesterday_redeem?.amount_sum ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="playx-kpi">
|
||||
<div class="playx-kpi-title">{{ t('dashboard.Grant failed retryable') }}</div>
|
||||
<div class="playx-kpi-value">{{ state.playx?.grant_failed_retryable ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="state.playxLoading"
|
||||
:data="state.playx?.yesterday_redeem?.by_item ?? []"
|
||||
size="small"
|
||||
style="width: 100%; margin-top: 12px"
|
||||
>
|
||||
<el-table-column prop="mall_item_id" :label="t('dashboard.Item ID')" width="100" />
|
||||
<el-table-column prop="title" :label="t('dashboard.Item title')" min-width="220" />
|
||||
<el-table-column prop="order_count" :label="t('dashboard.Order count')" width="120" />
|
||||
<el-table-column prop="completed_count" :label="t('dashboard.Completed')" width="120" />
|
||||
<el-table-column prop="rejected_count" :label="t('dashboard.Rejected')" width="120" />
|
||||
<el-table-column prop="points_cost_sum" :label="t('dashboard.Points sum')" width="140" />
|
||||
<el-table-column prop="amount_sum" :label="t('dashboard.Amount sum')" width="140" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -149,17 +118,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
|
||||
import * as echarts from 'echarts'
|
||||
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
|
||||
import { useTransition } from '@vueuse/core'
|
||||
import { CSSProperties, onMounted, onUnmounted, reactive, toRefs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { index } from '/@/api/backend/dashboard'
|
||||
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
|
||||
import headerSvg from '/@/assets/dashboard/header-1.svg'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
|
||||
import { useNavTabs } from '/@/stores/navTabs'
|
||||
import { fullUrl, getGreet } from '/@/utils/common'
|
||||
import { getGreet } from '/@/utils/common'
|
||||
import { Local } from '/@/utils/storage'
|
||||
let workTimer: number
|
||||
|
||||
@@ -169,20 +136,20 @@ defineOptions({
|
||||
|
||||
const d = new Date()
|
||||
const { t } = useI18n()
|
||||
const navTabs = useNavTabs()
|
||||
const adminInfo = useAdminInfo()
|
||||
const chartRefs = useTemplateRefsList<HTMLDivElement>()
|
||||
|
||||
const state: {
|
||||
charts: any[]
|
||||
remark: string
|
||||
workingTimeFormat: string
|
||||
pauseWork: boolean
|
||||
playx: any | null
|
||||
playxLoading: boolean
|
||||
} = reactive({
|
||||
charts: [],
|
||||
remark: 'dashboard.Loading',
|
||||
workingTimeFormat: '',
|
||||
pauseWork: false,
|
||||
playx: null,
|
||||
playxLoading: true,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -206,301 +173,20 @@ const statisticValueStyle: CSSProperties = {
|
||||
|
||||
index().then((res) => {
|
||||
state.remark = res.data.remark
|
||||
state.playx = res.data.playx ?? null
|
||||
state.playxLoading = false
|
||||
initCountUp()
|
||||
}).catch(() => {
|
||||
state.playxLoading = false
|
||||
})
|
||||
|
||||
const initCountUp = () => {
|
||||
// 虚拟数据
|
||||
countUpRefs.userRegNumber.value = 5456
|
||||
countUpRefs.fileNumber.value = 1234
|
||||
countUpRefs.usersNumber.value = 9486
|
||||
countUpRefs.addonsNumber.value = 875
|
||||
}
|
||||
|
||||
const initUserGrowthChart = () => {
|
||||
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
|
||||
const option = {
|
||||
grid: {
|
||||
top: 40,
|
||||
right: 0,
|
||||
bottom: 20,
|
||||
left: 40,
|
||||
},
|
||||
xAxis: {
|
||||
data: [
|
||||
t('dashboard.Monday'),
|
||||
t('dashboard.Tuesday'),
|
||||
t('dashboard.Wednesday'),
|
||||
t('dashboard.Thursday'),
|
||||
t('dashboard.Friday'),
|
||||
t('dashboard.Saturday'),
|
||||
t('dashboard.Sunday'),
|
||||
],
|
||||
},
|
||||
yAxis: {},
|
||||
legend: {
|
||||
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
|
||||
textStyle: {
|
||||
color: '#73767a',
|
||||
},
|
||||
top: 0,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.Visits'),
|
||||
data: [100, 160, 280, 230, 190, 200, 480],
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: '#8595F4',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t('dashboard.Registration volume'),
|
||||
data: [45, 180, 146, 99, 210, 127, 288],
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: '#F48595',
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
userGrowthChart.setOption(option)
|
||||
state.charts.push(userGrowthChart)
|
||||
}
|
||||
|
||||
const initFileGrowthChart = () => {
|
||||
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
|
||||
const option = {
|
||||
grid: {
|
||||
top: 30,
|
||||
right: 0,
|
||||
bottom: 20,
|
||||
left: 0,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
bottom: 0,
|
||||
data: (function () {
|
||||
var list = []
|
||||
for (var i = 1; i <= 28; i++) {
|
||||
list.push(i + 2000 + '')
|
||||
}
|
||||
return list
|
||||
})(),
|
||||
textStyle: {
|
||||
color: '#73767a',
|
||||
},
|
||||
},
|
||||
visualMap: {
|
||||
top: 'middle',
|
||||
right: 10,
|
||||
color: ['red', 'yellow'],
|
||||
calculable: true,
|
||||
},
|
||||
radar: {
|
||||
indicator: [
|
||||
{ name: t('dashboard.picture') },
|
||||
{ name: t('dashboard.file') },
|
||||
{ name: t('dashboard.table') },
|
||||
{ name: t('dashboard.Compressed package') },
|
||||
{ name: t('dashboard.other') },
|
||||
],
|
||||
},
|
||||
series: (function () {
|
||||
var series = []
|
||||
for (var i = 1; i <= 28; i++) {
|
||||
series.push({
|
||||
type: 'radar',
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
emphasis: {
|
||||
areaStyle: {
|
||||
color: 'rgba(0,250,0,0.3)',
|
||||
},
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
|
||||
name: i + 2000 + '',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return series
|
||||
})(),
|
||||
}
|
||||
fileGrowthChart.setOption(option)
|
||||
state.charts.push(fileGrowthChart)
|
||||
}
|
||||
|
||||
const initUserSourceChart = () => {
|
||||
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
|
||||
const pathSymbols = {
|
||||
reindeer:
|
||||
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
|
||||
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
|
||||
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
|
||||
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
|
||||
}
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'none',
|
||||
},
|
||||
formatter: function (params: any) {
|
||||
return params[0].name + ': ' + params[0].value
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: '#e54035',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
color: ['#e54035'],
|
||||
series: [
|
||||
{
|
||||
name: 'hill',
|
||||
type: 'pictorialBar',
|
||||
barCategoryGap: '-130%',
|
||||
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
|
||||
itemStyle: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
data: [123, 60, 25, 80],
|
||||
z: 10,
|
||||
},
|
||||
{
|
||||
name: 'glyph',
|
||||
type: 'pictorialBar',
|
||||
barGap: '-100%',
|
||||
symbolPosition: 'end',
|
||||
symbolSize: 50,
|
||||
symbolOffset: [0, '-120%'],
|
||||
data: [
|
||||
{
|
||||
value: 123,
|
||||
symbol: pathSymbols.reindeer,
|
||||
symbolSize: [60, 60],
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
symbol: pathSymbols.rocket,
|
||||
symbolSize: [50, 60],
|
||||
},
|
||||
{
|
||||
value: 25,
|
||||
symbol: pathSymbols.plane,
|
||||
symbolSize: [65, 35],
|
||||
},
|
||||
{
|
||||
value: 80,
|
||||
symbol: pathSymbols.train,
|
||||
symbolSize: [50, 30],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
UserSourceChart.setOption(option)
|
||||
state.charts.push(UserSourceChart)
|
||||
}
|
||||
|
||||
const initUserSurnameChart = () => {
|
||||
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
|
||||
const data = genData(20)
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b} : {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
right: 10,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
data: data.legendData,
|
||||
textStyle: {
|
||||
color: '#73767a',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.full name'),
|
||||
type: 'pie',
|
||||
radius: '55%',
|
||||
center: ['40%', '50%'],
|
||||
data: data.seriesData,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
function genData(count: any) {
|
||||
// prettier-ignore
|
||||
const nameList = [
|
||||
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
|
||||
];
|
||||
const legendData = []
|
||||
const seriesData = []
|
||||
for (var i = 0; i < count; i++) {
|
||||
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
|
||||
legendData.push(name)
|
||||
seriesData.push({
|
||||
name: name,
|
||||
value: Math.round(Math.random() * 100000),
|
||||
})
|
||||
}
|
||||
return {
|
||||
legendData: legendData,
|
||||
seriesData: seriesData,
|
||||
}
|
||||
function makeWord(max: any, min: any) {
|
||||
const nameLen = Math.ceil(Math.random() * max + min)
|
||||
const name = []
|
||||
for (var i = 0; i < nameLen; i++) {
|
||||
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
|
||||
}
|
||||
return name.join('')
|
||||
}
|
||||
}
|
||||
userSurnameChart.setOption(option)
|
||||
state.charts.push(userSurnameChart)
|
||||
}
|
||||
|
||||
const echartsResize = () => {
|
||||
nextTick(() => {
|
||||
for (const key in state.charts) {
|
||||
state.charts[key].resize()
|
||||
}
|
||||
})
|
||||
const playx = state.playx ?? {}
|
||||
const yesterdayRedeem = playx.yesterday_redeem ?? {}
|
||||
countUpRefs.userRegNumber.value = playx.new_players_today ?? 0
|
||||
countUpRefs.fileNumber.value = playx.yesterday_points_claimed ?? 0
|
||||
countUpRefs.usersNumber.value = yesterdayRedeem.order_count ?? 0
|
||||
countUpRefs.addonsNumber.value = playx.pending_physical_to_ship ?? 0
|
||||
}
|
||||
|
||||
const onChangeWorkState = () => {
|
||||
@@ -594,36 +280,13 @@ const formatSeconds = (seconds: number) => {
|
||||
return result
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
echartsResize()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
startWork()
|
||||
initCountUp()
|
||||
initUserGrowthChart()
|
||||
initFileGrowthChart()
|
||||
initUserSourceChart()
|
||||
initUserSurnameChart()
|
||||
useEventListener(window, 'resize', echartsResize)
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
for (const key in state.charts) {
|
||||
state.charts[key].dispose()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(workTimer)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => navTabs.state.tabFullScreen,
|
||||
() => {
|
||||
echartsResize()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -751,48 +414,27 @@ watch(
|
||||
.growth-chart {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.user-growth-chart,
|
||||
.file-growth-chart {
|
||||
height: 260px;
|
||||
.playx-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.new-user-growth {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.user-source-chart,
|
||||
.user-surname-chart {
|
||||
height: 400px;
|
||||
}
|
||||
.new-user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin: 10px 15px;
|
||||
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
|
||||
.playx-kpi {
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
.new-user-avatar {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--ba-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
padding: 12px;
|
||||
}
|
||||
.new-user-base {
|
||||
margin-left: 10px;
|
||||
color: #2c3f5d;
|
||||
.new-user-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
.new-user-time {
|
||||
.playx-kpi-title {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.playx-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.new-user-arrow {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.new-user-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 425px) {
|
||||
.welcome-img {
|
||||
display: none;
|
||||
|
||||
161
web/src/views/backend/mall/address/index.vue
Normal file
161
web/src/views/backend/mall/address/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<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('mall.address.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
|
||||
<!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
|
||||
<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'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/address',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
|
||||
const hasChinese = (s: string) => /[\u4e00-\u9fa5]/.test(s)
|
||||
|
||||
const formatRegion = (raw: string) => {
|
||||
const s = raw.toString().trim()
|
||||
if (!s) return ''
|
||||
if (hasChinese(s)) {
|
||||
return s.replace(/[,,\s]+/g, ',')
|
||||
}
|
||||
return s.replace(/[,,\s]+/g, ',')
|
||||
}
|
||||
|
||||
/**
|
||||
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
|
||||
*/
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.Address/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.address.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('mall.address.playxuserasset__username'),
|
||||
prop: 'playxUserAsset.username',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
showOverflowTooltip: true,
|
||||
operator: 'LIKE',
|
||||
comSearchRender: 'string',
|
||||
},
|
||||
{
|
||||
label: t('mall.address.receiver_name'),
|
||||
prop: 'receiver_name',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.address.phone'),
|
||||
prop: 'phone',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.address.region'),
|
||||
prop: 'region_text',
|
||||
align: 'center',
|
||||
operator: false,
|
||||
showOverflowTooltip: true,
|
||||
formatter: (row: TableRow, _column: TableColumn, cellValue: string) => {
|
||||
const raw = (cellValue || row.region || '').toString()
|
||||
return formatRegion(raw)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('mall.address.detail_address'),
|
||||
prop: 'detail_address',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.address.default_setting'),
|
||||
prop: 'default_setting',
|
||||
align: 'center',
|
||||
effect: 'dark',
|
||||
custom: { 0: 'error', 1: 'primary' },
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: { '0': t('mall.address.default_setting 0'), '1': t('mall.address.default_setting 1') },
|
||||
},
|
||||
{
|
||||
label: t('mall.address.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('mall.address.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined, 'default_setting'],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -30,26 +30,47 @@
|
||||
:rules="rules"
|
||||
>
|
||||
<FormItem
|
||||
:label="t('mall.player.username')"
|
||||
:label="t('mall.address.playx_user_asset_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.playx_user_asset_id"
|
||||
prop="playx_user_asset_id"
|
||||
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.address.playx_user_asset_id') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.address.receiver_name')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.username"
|
||||
prop="username"
|
||||
:placeholder="t('Please input field', { field: t('mall.player.username') })"
|
||||
v-model="baTable.form.items!.receiver_name"
|
||||
prop="receiver_name"
|
||||
:placeholder="t('Please input field', { field: t('mall.address.receiver_name') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.player.password')"
|
||||
type="password"
|
||||
v-model="baTable.form.items!.password"
|
||||
prop="password"
|
||||
:placeholder="t('Please input field', { field: t('mall.player.password') })"
|
||||
:label="t('mall.address.phone')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.phone"
|
||||
prop="phone"
|
||||
:placeholder="t('Please input field', { field: t('mall.address.phone') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.player.score')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.score"
|
||||
prop="score"
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.player.score') })"
|
||||
:label="t('mall.address.region')"
|
||||
type="city"
|
||||
v-model="baTable.form.items!.region"
|
||||
prop="region"
|
||||
:placeholder="t('Please select field', { field: t('mall.address.region') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.address.detail_address')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.detail_address"
|
||||
prop="detail_address"
|
||||
:placeholder="t('Please input field', { field: t('mall.address.detail_address') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.address.default_setting')"
|
||||
type="switch"
|
||||
v-model="baTable.form.items!.default_setting"
|
||||
prop="default_setting"
|
||||
:input-attr="{ content: { '0': t('mall.address.default_setting 0'), '1': t('mall.address.default_setting 1') } }"
|
||||
/>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -72,7 +93,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
import { buildValidatorData, regularPassword } from '/@/utils/validate'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
|
||||
const config = useConfig()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
@@ -81,28 +102,9 @@ const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('mall.player.username') })],
|
||||
password: [
|
||||
{
|
||||
validator: (_rule: unknown, val: string, callback: (error?: Error) => void) => {
|
||||
if (baTable.form.operate === 'Add') {
|
||||
if (!val) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.player.password') })))
|
||||
}
|
||||
} else {
|
||||
if (!val) {
|
||||
return callback()
|
||||
}
|
||||
}
|
||||
if (!regularPassword(val)) {
|
||||
return callback(new Error(t('validate.Please enter the correct password')))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
score: [buildValidatorData({ name: 'number', title: t('mall.player.score') })],
|
||||
phone: [buildValidatorData({ name: 'required', title: t('mall.address.phone') })],
|
||||
create_time: [buildValidatorData({ name: 'date', title: t('mall.address.create_time') })],
|
||||
update_time: [buildValidatorData({ name: 'date', title: t('mall.address.update_time') })],
|
||||
})
|
||||
</script>
|
||||
|
||||
61
web/src/views/backend/mall/claimLog/index.vue
Normal file
61
web/src/views/backend/mall/claimLog/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<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('mall.claimLog.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/claimLog',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.ClaimLog/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.claimLog.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.claimLog.claim_request_id'), prop: 'claim_request_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.claimLog.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.claimLog.claimed_amount'), prop: 'claimed_amount', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.claimLog.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
123
web/src/views/backend/mall/dailyPush/index.vue
Normal file
123
web/src/views/backend/mall/dailyPush/index.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<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('mall.dailyPush.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/dailyPush',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.DailyPush/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.dailyPush.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('mall.dailyPush.user_id'),
|
||||
prop: 'user_id',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.date'),
|
||||
prop: 'date',
|
||||
align: 'center',
|
||||
render: 'date',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'date',
|
||||
sortable: 'custom',
|
||||
width: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.username'),
|
||||
prop: 'username',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.yesterday_win_loss_net'),
|
||||
prop: 'yesterday_win_loss_net',
|
||||
align: 'center',
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.yesterday_total_deposit'),
|
||||
prop: 'yesterday_total_deposit',
|
||||
align: 'center',
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.lifetime_total_deposit'),
|
||||
prop: 'lifetime_total_deposit',
|
||||
align: 'center',
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.lifetime_total_withdraw'),
|
||||
prop: 'lifetime_total_withdraw',
|
||||
align: 'center',
|
||||
minWidth: 95,
|
||||
operator: 'RANGE',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: t('mall.dailyPush.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -48,20 +48,103 @@ const baTable = new baTableClass(
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.item.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.item.title'), prop: 'title', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.item.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{
|
||||
label: t('mall.item.类型'),
|
||||
prop: '类型',
|
||||
label: t('mall.item.description'),
|
||||
prop: 'description',
|
||||
align: 'center',
|
||||
minWidth: 80,
|
||||
// showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.score'),
|
||||
prop: 'score',
|
||||
align: 'center',
|
||||
minWidth: 90,
|
||||
sortable: false,
|
||||
operator: 'RANGE',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.type'),
|
||||
prop: 'type',
|
||||
align: 'center',
|
||||
minWidth: 140,
|
||||
effect: 'dark',
|
||||
custom: { 1: 'success', 2: 'primary', 3: 'info' },
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: { '1': t('mall.item.类型 1'), '2': t('mall.item.类型 2'), '3': t('mall.item.类型 3') },
|
||||
replaceValue: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') },
|
||||
},
|
||||
{
|
||||
label: t('mall.item.amount'),
|
||||
prop: 'amount',
|
||||
align: 'center',
|
||||
sortable: false,
|
||||
operator: 'RANGE',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.multiplier'),
|
||||
prop: 'multiplier',
|
||||
align: 'center',
|
||||
sortable: false,
|
||||
operator: 'eq',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.category'),
|
||||
prop: 'category',
|
||||
align: 'center',
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.item.category_title'),
|
||||
prop: 'category_title',
|
||||
align: 'center',
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.item.status'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'switch',
|
||||
replaceValue: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') },
|
||||
},
|
||||
{
|
||||
label: t('mall.item.remark'),
|
||||
prop: 'remark',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.image'),
|
||||
prop: 'image',
|
||||
align: 'center',
|
||||
render: 'image',
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('mall.item.stock'),
|
||||
prop: 'stock',
|
||||
align: 'center',
|
||||
sortable: false,
|
||||
operator: 'RANGE',
|
||||
},
|
||||
{
|
||||
label: t('mall.item.admin__username'),
|
||||
prop: 'admin.username',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
render: 'tags',
|
||||
operator: 'LIKE',
|
||||
@@ -90,12 +173,23 @@ const baTable = new baTableClass(
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
dblClickNotEditColumn: [undefined, 'status'],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
defaultItems: {
|
||||
stock: 0,
|
||||
sort: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
:label-width="baTable.form.labelWidth + 'px'"
|
||||
:rules="rules"
|
||||
>
|
||||
<!-- 先选类型:未选类型时只显示标题与类型,避免看到无关字段 -->
|
||||
<FormItem
|
||||
:label="t('mall.item.title')"
|
||||
type="string"
|
||||
@@ -36,6 +37,16 @@
|
||||
prop="title"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.title') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.type')"
|
||||
type="select"
|
||||
v-model="baTable.form.items!.type"
|
||||
prop="type"
|
||||
:input-attr="{ content: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') } }"
|
||||
:placeholder="t('Please select field', { field: t('mall.item.type') })"
|
||||
/>
|
||||
|
||||
<template v-if="hasItemType">
|
||||
<FormItem
|
||||
:label="t('mall.item.description')"
|
||||
type="textarea"
|
||||
@@ -46,6 +57,62 @@
|
||||
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.description') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.score')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.score"
|
||||
prop="score"
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.score') })"
|
||||
/>
|
||||
|
||||
<!-- BONUS / WITHDRAW:现金面值、流水倍数 -->
|
||||
<FormItem
|
||||
v-if="isBonusOrWithdraw"
|
||||
:label="t('mall.item.amount')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.amount"
|
||||
prop="amount"
|
||||
:input-attr="{ step: 0.01 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.amount') })"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="isBonusOrWithdraw"
|
||||
:label="t('mall.item.multiplier')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.multiplier"
|
||||
prop="multiplier"
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.multiplier') })"
|
||||
/>
|
||||
<!-- 仅 BONUS:Bonus Grant 需要 -->
|
||||
<FormItem
|
||||
v-if="isBonus"
|
||||
:label="t('mall.item.category')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.category"
|
||||
prop="category"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.category') })"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="isBonus"
|
||||
:label="t('mall.item.category_title')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.category_title"
|
||||
prop="category_title"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.category_title') })"
|
||||
/>
|
||||
<!-- 仅 PHYSICAL:库存 -->
|
||||
<FormItem
|
||||
v-if="isPhysical"
|
||||
:label="t('mall.item.stock')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.stock"
|
||||
prop="stock"
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
|
||||
/>
|
||||
|
||||
<FormItem
|
||||
:label="t('mall.item.remark')"
|
||||
type="textarea"
|
||||
@@ -56,30 +123,7 @@
|
||||
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.remark') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.score')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.score"
|
||||
prop="score"
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.score') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.类型')"
|
||||
type="select"
|
||||
v-model="baTable.form.items!.类型"
|
||||
prop="类型"
|
||||
:input-attr="{ content: { '1': t('mall.item.类型 1'), '2': t('mall.item.类型 2'), '3': t('mall.item.类型 3') } }"
|
||||
:placeholder="t('Please select field', { field: t('mall.item.类型') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.admin_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.admin_id"
|
||||
prop="admin_id"
|
||||
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.item.admin_id') })"
|
||||
/>
|
||||
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
|
||||
<FormItem
|
||||
:label="t('mall.item.sort')"
|
||||
type="number"
|
||||
@@ -88,6 +132,14 @@
|
||||
:input-attr="{ step: 1 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.item.sort') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.item.status')"
|
||||
type="switch"
|
||||
v-model="baTable.form.items!.status"
|
||||
prop="status"
|
||||
:input-attr="{ content: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') } }"
|
||||
/>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
@@ -104,7 +156,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { inject, reactive, useTemplateRef } from 'vue'
|
||||
import { computed, inject, reactive, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
@@ -117,14 +169,156 @@ const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemType = computed(() => baTable.form.items!.type)
|
||||
const isBonus = computed(() => itemType.value === 1 || itemType.value === '1')
|
||||
const isPhysical = computed(() => itemType.value === 2 || itemType.value === '2')
|
||||
const isWithdraw = computed(() => itemType.value === 3 || itemType.value === '3')
|
||||
const isBonusOrWithdraw = computed(() => isBonus.value || isWithdraw.value)
|
||||
|
||||
/** 已选择商品类型(1/2/3)后,才展示其余表单项 */
|
||||
const hasItemType = computed(() => isBonus.value || isPhysical.value || isWithdraw.value)
|
||||
|
||||
// 切换类型后,清理不适用的字段(严格:WITHDRAW 不保留红利类别字段)
|
||||
watch(
|
||||
itemType,
|
||||
(n, o) => {
|
||||
if (o === undefined) return
|
||||
if (!baTable.form.items) return
|
||||
|
||||
const typeNum = Number(n)
|
||||
if (!Number.isFinite(typeNum)) return
|
||||
|
||||
if (typeNum === 1) {
|
||||
// BONUS:实物库存不适用
|
||||
baTable.form.items.stock = 0
|
||||
} else if (typeNum === 2) {
|
||||
// PHYSICAL:现金/倍数/类别不适用
|
||||
baTable.form.items.amount = 0
|
||||
baTable.form.items.multiplier = 0
|
||||
baTable.form.items.category = ''
|
||||
baTable.form.items.category_title = ''
|
||||
} else if (typeNum === 3) {
|
||||
// WITHDRAW:提现接口不使用 category,清空避免误存
|
||||
baTable.form.items.stock = 0
|
||||
baTable.form.items.category = ''
|
||||
baTable.form.items.category_title = ''
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
title: [buildValidatorData({ name: 'required', title: t('mall.item.title') })],
|
||||
description: [buildValidatorData({ name: 'required', title: t('mall.item.description') })],
|
||||
score: [
|
||||
buildValidatorData({ name: 'number', title: t('mall.item.score') }),
|
||||
buildValidatorData({ name: 'required', title: t('mall.item.score') }),
|
||||
description: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!hasItemType.value) return callback()
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.description') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
score: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!hasItemType.value) return callback()
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.score') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num)) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.score') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
type: [buildValidatorData({ name: 'required', title: t('mall.item.type') })],
|
||||
amount: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!isBonusOrWithdraw.value) return callback()
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.amount') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num)) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.amount') })))
|
||||
}
|
||||
if (num < 0) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.amount') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
multiplier: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!isBonusOrWithdraw.value) return callback()
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.multiplier') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num) || !Number.isInteger(num)) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
|
||||
}
|
||||
if (num < 0) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
category: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!isBonus.value) return callback()
|
||||
if (!val) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.category') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
category_title: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!isBonus.value) return callback()
|
||||
if (!val) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.category_title') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
stock: [
|
||||
{
|
||||
validator: (rule: any, val: any, callback: Function) => {
|
||||
if (!isPhysical.value) return callback()
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
return callback(new Error(t('Please input field', { field: t('mall.item.stock') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num) || !Number.isInteger(num)) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.stock') })))
|
||||
}
|
||||
if (num < 0) {
|
||||
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.stock') })))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
类型: [buildValidatorData({ name: 'required', title: t('mall.item.类型') })],
|
||||
sort: [buildValidatorData({ name: 'number', title: t('mall.item.sort') })],
|
||||
create_time: [buildValidatorData({ name: 'date', title: t('mall.item.create_time') })],
|
||||
update_time: [buildValidatorData({ name: 'date', title: t('mall.item.update_time') })],
|
||||
|
||||
318
web/src/views/backend/mall/order/index.vue
Normal file
318
web/src/views/backend/mall/order/index.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<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('mall.order.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 { baTableApi } from '/@/api/common'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/order',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']).map((btn) =>
|
||||
btn.name === 'edit'
|
||||
? {
|
||||
...btn,
|
||||
title: t('mall.order.approve'),
|
||||
type: 'primary',
|
||||
class: 'table-row-edit',
|
||||
icon: 'fa fa-check',
|
||||
}
|
||||
: btn
|
||||
)
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.Order/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.order.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('mall.order.user_id'),
|
||||
prop: 'user_id',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.order.type'),
|
||||
prop: 'type',
|
||||
align: 'center',
|
||||
effect: 'light',
|
||||
custom: { BONUS: 'success', PHYSICAL: 'primary', WITHDRAW: 'info' },
|
||||
minWidth: 140,
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
BONUS: t('mall.order.type BONUS'),
|
||||
PHYSICAL: t('mall.order.type PHYSICAL'),
|
||||
WITHDRAW: t('mall.order.type WITHDRAW'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('mall.order.status'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
effect: 'dark',
|
||||
custom: { PENDING: 'success', COMPLETED: 'primary', SHIPPED: 'info', REJECTED: 'loading' },
|
||||
minWidth: 160,
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
PENDING: t('mall.order.status PENDING'),
|
||||
COMPLETED: t('mall.order.status COMPLETED'),
|
||||
SHIPPED: t('mall.order.status SHIPPED'),
|
||||
REJECTED: t('mall.order.status REJECTED'),
|
||||
},
|
||||
},
|
||||
{ label: t('mall.order.mall_item_id'), prop: 'mall_item_id', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{
|
||||
label: t('mall.order.mallitem__title'),
|
||||
prop: 'mallItem.title',
|
||||
align: 'center',
|
||||
minWidth: 90,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{ label: t('mall.order.points_cost'), prop: 'points_cost', align: 'center', minWidth: 90, operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.order.amount'), prop: 'amount', align: 'center', minWidth: 90, operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.order.multiplier'), prop: 'multiplier', align: 'center', minWidth: 90, operator: 'eq', sortable: false },
|
||||
{
|
||||
label: t('mall.order.external_transaction_id'),
|
||||
prop: 'external_transaction_id',
|
||||
align: 'center',
|
||||
minWidth: 80,
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.order.playx_transaction_id'),
|
||||
prop: 'playx_transaction_id',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.order.grant_status'),
|
||||
prop: 'grant_status',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
custom: {
|
||||
NOT_SENT: 'info',
|
||||
SENT_PENDING: 'primary',
|
||||
ACCEPTED: 'primary',
|
||||
FAILED_RETRYABLE: 'error',
|
||||
FAILED_FINAL: 'error',
|
||||
'---': 'info',
|
||||
},
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
NOT_SENT: t('mall.order.grant_status NOT_SENT'),
|
||||
SENT_PENDING: t('mall.order.grant_status SENT_PENDING'),
|
||||
ACCEPTED: t('mall.order.grant_status ACCEPTED'),
|
||||
FAILED_RETRYABLE: t('mall.order.grant_status FAILED_RETRYABLE'),
|
||||
FAILED_FINAL: t('mall.order.grant_status FAILED_FINAL'),
|
||||
'---': t('mall.order.grant_status ---'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('mall.order.fail_reason'),
|
||||
prop: 'fail_reason',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.order.reject_reason'),
|
||||
prop: 'reject_reason',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.order.shipping_company'),
|
||||
prop: 'shipping_company',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.order.shipping_no'),
|
||||
prop: 'shipping_no',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.order.receiver_name'),
|
||||
prop: 'receiver_name',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.order.receiver_phone'),
|
||||
prop: 'receiver_phone',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.order.receiver_address'),
|
||||
prop: 'receiver_address',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{ label: t('mall.order.mall_address_id'), prop: 'mall_address_id', align: 'center', width: 100, operator: 'eq', sortable: false },
|
||||
{
|
||||
label: t('mall.order.start_time'),
|
||||
prop: 'start_time',
|
||||
align: 'center',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
label: t('mall.order.end_time'),
|
||||
prop: 'end_time',
|
||||
align: 'center',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
label: t('mall.order.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('mall.order.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
show: false,
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: 'buttons',
|
||||
buttons: [
|
||||
...optButtons,
|
||||
{
|
||||
render: 'confirmButton',
|
||||
name: 'retry',
|
||||
title: t('mall.order.manual_retry'),
|
||||
text: '',
|
||||
type: 'primary',
|
||||
class: 'table-row-edit',
|
||||
icon: 'fa fa-refresh',
|
||||
display: (row: TableRow) =>
|
||||
row.type === 'BONUS' &&
|
||||
row.status === 'PENDING' &&
|
||||
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
|
||||
popconfirm: {
|
||||
title: t('mall.order.retry_confirm'),
|
||||
confirmButtonText: t('Confirm'),
|
||||
cancelButtonText: t('Cancel'),
|
||||
confirmButtonType: 'primary',
|
||||
},
|
||||
click: async (row: TableRow) => {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/mall.Order/retry',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: row.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
await baTable.getData()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
304
web/src/views/backend/mall/order/popupForm.vue
Normal file
304
web/src/views/backend/mall/order/popupForm.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<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"
|
||||
>
|
||||
<!-- PENDING:两页审核流程(PHYSICAL 显示收货信息,其它类型只显示基本信息) -->
|
||||
<template v-if="usePagedActions && page === 1">
|
||||
<FormItem :label="t('mall.order.type')" type="string" v-model="baTable.form.items!.type" prop="type" :input-attr="{ disabled: true }" />
|
||||
<FormItem :label="t('mall.order.status')" type="string" v-model="baTable.form.items!.status" prop="status" :input-attr="{ disabled: true }" />
|
||||
<template v-if="isPhysical">
|
||||
<FormItem
|
||||
:label="t('mall.order.receiver_name')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.receiver_name"
|
||||
prop="receiver_name"
|
||||
:input-attr="{ disabled: true }"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.order.receiver_phone')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.receiver_phone"
|
||||
prop="receiver_phone"
|
||||
:input-attr="{ disabled: true }"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.order.receiver_address')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.receiver_address"
|
||||
prop="receiver_address"
|
||||
:input-attr="{ disabled: true }"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="usePagedActions">
|
||||
<template v-if="action === 'approveShip' && isPhysical">
|
||||
<FormItem
|
||||
:label="t('mall.order.shipping_company')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.shipping_company"
|
||||
prop="shipping_company"
|
||||
:placeholder="t('Please input field', { field: t('mall.order.shipping_company') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.order.shipping_no')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.shipping_no"
|
||||
prop="shipping_no"
|
||||
:placeholder="t('Please input field', { field: t('mall.order.shipping_no') })"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="action === 'reject'">
|
||||
<FormItem
|
||||
v-if="isPhysical"
|
||||
:label="t('mall.order.reject_reason')"
|
||||
type="textarea"
|
||||
v-model="baTable.form.items!.reject_reason"
|
||||
prop="reject_reason"
|
||||
:input-attr="{ rows: 3 }"
|
||||
@keyup.enter.stop=""
|
||||
:placeholder="t('Please input field', { field: t('mall.order.reject_reason') })"
|
||||
/>
|
||||
<el-alert v-else type="info" :closable="false" show-icon>
|
||||
确认后将驳回该订单并退回积分(红利/提现订单无需填写驳回原因)。
|
||||
</el-alert>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 其它订单:保留常规可编辑表单 + 保存 -->
|
||||
<template v-else>
|
||||
<FormItem :label="t('mall.order.type')" type="string" v-model="baTable.form.items!.type" prop="type" :input-attr="{ disabled: true }" />
|
||||
<FormItem
|
||||
:label="t('mall.order.status')"
|
||||
type="select"
|
||||
v-model="baTable.form.items!.status"
|
||||
prop="status"
|
||||
:input-attr="{ content: { PENDING: t('mall.order.status PENDING'), COMPLETED: t('mall.order.status COMPLETED'), SHIPPED: t('mall.order.status SHIPPED'), REJECTED: t('mall.order.status REJECTED') } }"
|
||||
:placeholder="t('Please select field', { field: t('mall.order.status') })"
|
||||
/>
|
||||
<template v-if="isPhysical">
|
||||
<FormItem
|
||||
:label="t('mall.order.shipping_company')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.shipping_company"
|
||||
prop="shipping_company"
|
||||
:placeholder="t('Please input field', { field: t('mall.order.shipping_company') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.order.shipping_no')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.shipping_no"
|
||||
prop="shipping_no"
|
||||
:placeholder="t('Please input field', { field: t('mall.order.shipping_no') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.order.reject_reason')"
|
||||
type="textarea"
|
||||
v-model="baTable.form.items!.reject_reason"
|
||||
prop="reject_reason"
|
||||
:input-attr="{ rows: 3 }"
|
||||
@keyup.enter.stop=""
|
||||
:placeholder="t('Please input field', { field: t('mall.order.reject_reason') })"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
|
||||
<el-button @click="onCancel">{{ t('Cancel') }}</el-button>
|
||||
|
||||
<template v-if="usePagedActions && page === 1">
|
||||
<el-button v-if="canApprove" @click="onApprove" type="success">审核通过</el-button>
|
||||
<el-button @click="goReject" type="danger">驳回</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="usePagedActions">
|
||||
<el-button @click="backToFirst">返回</el-button>
|
||||
<el-button
|
||||
v-if="action === 'approveShip'"
|
||||
v-blur
|
||||
:loading="submitting"
|
||||
@click="submitShip"
|
||||
type="success"
|
||||
>
|
||||
提交发货
|
||||
</el-button>
|
||||
<el-button v-if="action === 'reject'" v-blur :loading="submitting" @click="submitReject" type="danger">提交驳回</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
const config = useConfig()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const isEdit = computed(() => baTable.form.operate === 'Edit')
|
||||
const isPending = computed(() => isEdit.value && baTable.form.items?.status === 'PENDING')
|
||||
const isPhysical = computed(() => baTable.form.items?.type === 'PHYSICAL')
|
||||
const canApprove = computed(() => isPending.value)
|
||||
const usePagedActions = computed(() => isPending.value)
|
||||
|
||||
const page = ref<1 | 2>(1)
|
||||
const action = ref<'approveShip' | 'reject' | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const resetPager = () => {
|
||||
page.value = 1
|
||||
action.value = null
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => baTable.form.operate,
|
||||
() => {
|
||||
resetPager()
|
||||
}
|
||||
)
|
||||
|
||||
const onCancel = () => {
|
||||
resetPager()
|
||||
baTable.toggleForm()
|
||||
}
|
||||
|
||||
const backToFirst = () => {
|
||||
page.value = 1
|
||||
action.value = null
|
||||
}
|
||||
|
||||
const onApprove = async () => {
|
||||
if (!isPending.value) {
|
||||
return
|
||||
}
|
||||
if (isPhysical.value) {
|
||||
page.value = 2
|
||||
action.value = 'approveShip'
|
||||
return
|
||||
}
|
||||
const id = baTable.form.items?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios({ url: '/admin/mall.Order/approve', method: 'post', data: { id } }, { showSuccessMessage: true })
|
||||
resetPager()
|
||||
baTable.toggleForm()
|
||||
await baTable.getData()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goReject = () => {
|
||||
page.value = 2
|
||||
action.value = 'reject'
|
||||
}
|
||||
|
||||
const submitShip = async () => {
|
||||
const id = baTable.form.items?.id
|
||||
const shippingCompany = (baTable.form.items?.shipping_company || '').toString().trim()
|
||||
const shippingNo = (baTable.form.items?.shipping_no || '').toString().trim()
|
||||
if (!id || shippingCompany === '' || shippingNo === '') {
|
||||
ElMessage.error('请填写物流公司与物流单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{ url: '/admin/mall.Order/ship', method: 'post', data: { id, shipping_company: shippingCompany, shipping_no: shippingNo } },
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
resetPager()
|
||||
baTable.toggleForm()
|
||||
await baTable.getData()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
const id = baTable.form.items?.id
|
||||
const rejectReason = (baTable.form.items?.reject_reason || '').toString().trim()
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
if (isPhysical.value && rejectReason === '') {
|
||||
ElMessage.error('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createAxios(
|
||||
{ url: '/admin/mall.Order/reject', method: 'post', data: { id, reject_reason: rejectReason } },
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
resetPager()
|
||||
baTable.toggleForm()
|
||||
await baTable.getData()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const rules = computed<Partial<Record<string, FormItemRule[]>>>(() => {
|
||||
if (!isPhysical.value) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
shipping_company: [buildValidatorData({ name: 'required', title: t('mall.order.shipping_company') })],
|
||||
shipping_no: [buildValidatorData({ name: 'required', title: t('mall.order.shipping_no') })],
|
||||
reject_reason: [buildValidatorData({ name: 'required', title: t('mall.order.reject_reason') })],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -41,7 +41,7 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
|
||||
*/
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.pints.Order/'),
|
||||
new baTableApi('/admin/mall.PintsOrder/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
@@ -56,9 +56,10 @@ const baTable = new baTableClass(
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.pintsOrder.malluser__username'),
|
||||
prop: 'mallUser.username',
|
||||
label: t('mall.pintsOrder.playxuserasset__username'),
|
||||
prop: 'playxUserAsset.username',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
render: 'tags',
|
||||
operator: 'LIKE',
|
||||
@@ -68,6 +69,8 @@ const baTable = new baTableClass(
|
||||
label: t('mall.pintsOrder.type'),
|
||||
prop: 'type',
|
||||
align: 'center',
|
||||
effect: 'dark',
|
||||
custom: { 1: 'success', 2: 'primary', 3: 'info' },
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
:placeholder="t('Please input field', { field: t('mall.pintsOrder.order') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.pintsOrder.mall_user_id')"
|
||||
:label="t('mall.pintsOrder.playx_user_asset_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.mall_user_id"
|
||||
prop="mall_user_id"
|
||||
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.pintsOrder.mall_user_id') })"
|
||||
v-model="baTable.form.items!.playx_user_asset_id"
|
||||
prop="playx_user_asset_id"
|
||||
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.pintsOrder.playx_user_asset_id') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.pintsOrder.type')"
|
||||
@@ -93,7 +93,7 @@ const { t } = useI18n()
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
order: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.order') })],
|
||||
mall_user_id: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.mall_user_id') })],
|
||||
playx_user_asset_id: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.playx_user_asset_id') })],
|
||||
type: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.type') })],
|
||||
score: [buildValidatorData({ name: 'number', title: t('mall.pintsOrder.score') })],
|
||||
create_time: [buildValidatorData({ name: 'date', title: t('mall.pintsOrder.create_time') })],
|
||||
|
||||
102
web/src/views/backend/mall/playxCenter/index.vue
Normal file
102
web/src/views/backend/mall/playxCenter/index.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="default-main">
|
||||
<!-- 语言包注入是异步的:把 t() 调用和子组件渲染都延后到注入完成后,避免 intlify Not found -->
|
||||
<template v-if="langReady">
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<template #header>
|
||||
<div class="card-header">{{ t('mall.playxCenter.title') }}</div>
|
||||
</template>
|
||||
<div class="desc">{{ t('mall.playxCenter.desc') }}</div>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeName" type="border-card">
|
||||
<el-tab-pane :label="t('mall.playxCenter.orders')" name="orders">
|
||||
<PlayxOrderIndex />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('mall.playxCenter.dailyPush')" name="dailyPush">
|
||||
<PlayxDailyPushIndex />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('mall.playxCenter.claimLog')" name="claimLog">
|
||||
<PlayxClaimLogIndex />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('mall.playxCenter.userAsset')" name="userAsset">
|
||||
<PlayxUserAssetIndex />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PlayxOrderIndex from '/@/views/backend/mall/playxOrder/index.vue'
|
||||
import PlayxDailyPushIndex from '/@/views/backend/mall/playxDailyPush/index.vue'
|
||||
import PlayxClaimLogIndex from '/@/views/backend/mall/playxClaimLog/index.vue'
|
||||
import PlayxUserAssetIndex from '/@/views/backend/mall/playxUserAsset/index.vue'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { mergeMessage } from '/@/lang/index'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/playxCenter',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeName = ref('orders')
|
||||
const langReady = ref(false)
|
||||
|
||||
/**
|
||||
* 由于语言包是按“路由”懒加载的:
|
||||
* playxCenter 内部直接渲染了多个子页面组件(订单/推送/领取/资产),
|
||||
* 如果对应菜单被禁用,子页面路由可能不会触发语言包加载,导致 t('mall.playx*') 显示为 key。
|
||||
* 关键点:子页面组件在首次渲染阶段就会执行 t()。
|
||||
* 因此语言包需要在首次渲染前(而不是 onMounted)完成 merge。
|
||||
*/
|
||||
async function ensurePlayxCenterLang() {
|
||||
const config = useConfig()
|
||||
const locale = config.lang.defaultLang
|
||||
const prefix = `./backend/${locale}`
|
||||
|
||||
const loadPaths = [
|
||||
`${prefix}/mall/playxCenter.ts`,
|
||||
`${prefix}/mall/playxOrder.ts`,
|
||||
`${prefix}/mall/playxDailyPush.ts`,
|
||||
`${prefix}/mall/playxClaimLog.ts`,
|
||||
`${prefix}/mall/playxUserAsset.ts`,
|
||||
]
|
||||
|
||||
// 某些场景下 i18n 初始化可能晚于组件创建,这里做一次最小等待。
|
||||
// 不会阻塞很久:通常 window.loadLangHandle 会在应用启动阶段就准备好。
|
||||
while (!window.loadLangHandle) {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
|
||||
const handleMap = window.loadLangHandle
|
||||
for (const p of loadPaths) {
|
||||
const loader = handleMap[p]
|
||||
if (!loader) continue
|
||||
const mod = await loader()
|
||||
// 与 getLangFileMessage 的命名规则一致:namespace 应为 `mall/playxOrder` 这类路径
|
||||
const pathName = p.slice(p.lastIndexOf(prefix) + (prefix.length + 1), p.lastIndexOf('.'))
|
||||
mergeMessage(mod?.default ?? {}, pathName)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensurePlayxCenterLang()
|
||||
langReady.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
.desc {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
61
web/src/views/backend/mall/playxClaimLog/index.vue
Normal file
61
web/src/views/backend/mall/playxClaimLog/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<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('mall.playxClaimLog.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/playxClaimLog',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.PlayxClaimLog/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.playxClaimLog.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.playxClaimLog.claim_request_id'), prop: 'claim_request_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxClaimLog.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxClaimLog.claimed_amount'), prop: 'claimed_amount', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxClaimLog.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
},
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
65
web/src/views/backend/mall/playxDailyPush/index.vue
Normal file
65
web/src/views/backend/mall/playxDailyPush/index.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<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('mall.playxDailyPush.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/playxDailyPush',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.PlayxDailyPush/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.playxDailyPush.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.playxDailyPush.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxDailyPush.date'), prop: 'date', align: 'center', render: 'date', operator: 'RANGE', comSearchRender: 'date', sortable: 'custom', width: 120, operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxDailyPush.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxDailyPush.yesterday_win_loss_net'), prop: 'yesterday_win_loss_net', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxDailyPush.yesterday_total_deposit'), prop: 'yesterday_total_deposit', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxDailyPush.lifetime_total_deposit'), prop: 'lifetime_total_deposit', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxDailyPush.lifetime_total_withdraw'), prop: 'lifetime_total_withdraw', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxDailyPush.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
},
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
243
web/src/views/backend/mall/playxOrder/index.vue
Normal file
243
web/src/views/backend/mall/playxOrder/index.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<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('mall.playxOrder.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import createAxios from '/@/utils/axios'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/playxOrder',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.PlayxOrder/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.playxOrder.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.playxOrder.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{
|
||||
label: t('mall.playxOrder.type'),
|
||||
prop: 'type',
|
||||
align: 'center',
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
BONUS: t('mall.playxOrder.type BONUS'),
|
||||
PHYSICAL: t('mall.playxOrder.type PHYSICAL'),
|
||||
WITHDRAW: t('mall.playxOrder.type WITHDRAW'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('mall.playxOrder.status'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
PENDING: t('mall.playxOrder.status PENDING'),
|
||||
COMPLETED: t('mall.playxOrder.status COMPLETED'),
|
||||
SHIPPED: t('mall.playxOrder.status SHIPPED'),
|
||||
REJECTED: t('mall.playxOrder.status REJECTED'),
|
||||
},
|
||||
},
|
||||
{ label: t('mall.playxOrder.mall_item_id'), prop: 'mall_item_id', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxOrder.mallitem__title'), prop: 'mallItem.title', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxOrder.points_cost'), prop: 'points_cost', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxOrder.amount'), prop: 'amount', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxOrder.multiplier'), prop: 'multiplier', align: 'center', operator: 'eq', sortable: false },
|
||||
{ label: t('mall.playxOrder.external_transaction_id'), prop: 'external_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.playxOrder.playx_transaction_id'), prop: 'playx_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{
|
||||
label: t('mall.playxOrder.grant_status'),
|
||||
prop: 'grant_status',
|
||||
align: 'center',
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
replaceValue: {
|
||||
NOT_SENT: t('mall.playxOrder.grant_status NOT_SENT'),
|
||||
SENT_PENDING: t('mall.playxOrder.grant_status SENT_PENDING'),
|
||||
ACCEPTED: t('mall.playxOrder.grant_status ACCEPTED'),
|
||||
FAILED_RETRYABLE: t('mall.playxOrder.grant_status FAILED_RETRYABLE'),
|
||||
FAILED_FINAL: t('mall.playxOrder.grant_status FAILED_FINAL'),
|
||||
'---': t('mall.playxOrder.grant_status ---'),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('mall.playxOrder.fail_reason'),
|
||||
prop: 'fail_reason',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{ label: t('mall.playxOrder.reject_reason'), prop: 'reject_reason', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.shipping_company'), prop: 'shipping_company', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.shipping_no'), prop: 'shipping_no', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.receiver_name'), prop: 'receiver_name', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.receiver_phone'), prop: 'receiver_phone', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.receiver_address'), prop: 'receiver_address', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('mall.playxOrder.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{ label: t('mall.playxOrder.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: 220,
|
||||
render: 'buttons',
|
||||
buttons: [
|
||||
{
|
||||
render: 'confirmButton',
|
||||
name: 'retry',
|
||||
title: 'Retry',
|
||||
text: '手动重试',
|
||||
type: 'warning',
|
||||
icon: '',
|
||||
display: (row: TableRow) =>
|
||||
row.type === 'BONUS' &&
|
||||
row.status === 'PENDING' &&
|
||||
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
|
||||
popconfirm: {
|
||||
title: '确认将该订单加入重试队列?',
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonType: 'warning',
|
||||
},
|
||||
click: async (row: TableRow) => {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/mall.Order/retry',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: row.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
await baTable.getData()
|
||||
},
|
||||
},
|
||||
{
|
||||
render: 'basicButton',
|
||||
name: 'ship',
|
||||
title: 'Ship',
|
||||
text: '发货',
|
||||
type: 'success',
|
||||
icon: '',
|
||||
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
|
||||
click: async (row: TableRow) => {
|
||||
try {
|
||||
const shippingNoRes = await ElMessageBox.prompt('请输入物流单号', '发货', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
const shippingCompanyRes = await ElMessageBox.prompt('请输入物流公司', '发货', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
|
||||
const shippingNo = shippingNoRes.value
|
||||
const shippingCompany = shippingCompanyRes.value
|
||||
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/mall.PlayxOrder/ship',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: row.id,
|
||||
shipping_company: shippingCompany,
|
||||
shipping_no: shippingNo,
|
||||
},
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
await baTable.getData()
|
||||
} catch {
|
||||
// 用户取消弹窗:不做任何提示,避免控制台报错
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
render: 'basicButton',
|
||||
name: 'reject',
|
||||
title: 'Reject',
|
||||
text: '驳回',
|
||||
type: 'danger',
|
||||
icon: '',
|
||||
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
|
||||
click: async (row: TableRow) => {
|
||||
try {
|
||||
const res = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/mall.PlayxOrder/reject',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: row.id,
|
||||
reject_reason: res.value,
|
||||
},
|
||||
},
|
||||
{
|
||||
showSuccessMessage: true,
|
||||
}
|
||||
)
|
||||
await baTable.getData()
|
||||
} catch {
|
||||
// 用户取消:不提示
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
},
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
117
web/src/views/backend/mall/playxUserAsset/index.vue
Normal file
117
web/src/views/backend/mall/playxUserAsset/index.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<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('mall.playxUserAsset.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<Table ref="tableRef"></Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/playxUserAsset',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.PlayxUserAsset/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.playxUserAsset.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('mall.playxUserAsset.username'),
|
||||
prop: 'username',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.playxUserAsset.phone'),
|
||||
prop: 'phone',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.playxUserAsset.playx_user_id'),
|
||||
prop: 'playx_user_id',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{ label: t('mall.playxUserAsset.locked_points'), prop: 'locked_points', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxUserAsset.available_points'), prop: 'available_points', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxUserAsset.today_limit'), prop: 'today_limit', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.playxUserAsset.today_claimed'), prop: 'today_claimed', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{
|
||||
label: t('mall.playxUserAsset.today_limit_date'),
|
||||
prop: 'today_limit_date',
|
||||
align: 'center',
|
||||
render: 'date',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'date',
|
||||
sortable: 'custom',
|
||||
width: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.playxUserAsset.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('mall.playxUserAsset.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'datetime',
|
||||
sortable: 'custom',
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -41,7 +41,7 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
|
||||
*/
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.redemption.Order/'),
|
||||
new baTableApi('/admin/mall.RedemptionOrder/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
@@ -56,9 +56,10 @@ const baTable = new baTableClass(
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.redemptionOrder.malluser__username'),
|
||||
prop: 'mallUser.username',
|
||||
label: t('mall.redemptionOrder.playxuserasset__username'),
|
||||
prop: 'playxUserAsset.username',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
render: 'tags',
|
||||
operator: 'LIKE',
|
||||
@@ -68,9 +69,10 @@ const baTable = new baTableClass(
|
||||
label: t('mall.redemptionOrder.status'),
|
||||
prop: 'status',
|
||||
align: 'center',
|
||||
custom: { 0: 'info', 1: 'primary' },
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'switch',
|
||||
render: 'tag',
|
||||
replaceValue: { '0': t('mall.redemptionOrder.status 0'), '1': t('mall.redemptionOrder.status 1') },
|
||||
},
|
||||
{
|
||||
@@ -102,6 +104,8 @@ const baTable = new baTableClass(
|
||||
label: t('mall.redemptionOrder.type'),
|
||||
prop: 'type',
|
||||
align: 'center',
|
||||
effect: 'dark',
|
||||
custom: { 1: 'success', 2: 'primary', 3: 'info' },
|
||||
operator: 'eq',
|
||||
sortable: false,
|
||||
render: 'tag',
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
:placeholder="t('Please input field', { field: t('mall.redemptionOrder.order') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.redemptionOrder.mall_user_id')"
|
||||
:label="t('mall.redemptionOrder.playx_user_asset_id')"
|
||||
type="remoteSelect"
|
||||
v-model="baTable.form.items!.mall_user_id"
|
||||
prop="mall_user_id"
|
||||
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.redemptionOrder.mall_user_id') })"
|
||||
v-model="baTable.form.items!.playx_user_asset_id"
|
||||
prop="playx_user_asset_id"
|
||||
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
|
||||
:placeholder="t('Please select field', { field: t('mall.redemptionOrder.playx_user_asset_id') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.redemptionOrder.status')"
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<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('mall.user.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
|
||||
<!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
|
||||
<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'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/user',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
|
||||
/**
|
||||
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
|
||||
*/
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.User/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.user.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
|
||||
{ label: t('mall.user.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{ label: t('mall.user.daily_claim'), prop: 'daily_claim', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{ label: t('mall.user.daily_claim_use'), prop: 'daily_claim_use', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{ label: t('mall.user.available_for_withdrawal'), prop: 'available_for_withdrawal', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{ label: t('mall.user.admin__username'), prop: 'admin.username', align: 'center', operatorPlaceholder: t('Fuzzy query'), render: 'tags', operator: 'LIKE', comSearchRender: 'string' },
|
||||
{ label: t('mall.user.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{ label: t('mall.user.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
|
||||
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
{
|
||||
defaultItems: {},
|
||||
}
|
||||
)
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
baTable.dragSort()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-input 等输入组件 -->
|
||||
<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('mall.user.username')" type="string" v-model="baTable.form.items!.username" prop="username" :placeholder="t('Please input field', { field: t('mall.user.username') })" />
|
||||
<FormItem :label="t('mall.user.phone')" type="string" v-model="baTable.form.items!.phone" prop="phone" :placeholder="t('Please input field', { field: t('mall.user.phone') })" />
|
||||
<FormItem :label="t('mall.user.password')" type="password" v-model="baTable.form.items!.password" prop="password" :placeholder="t('Please input field', { field: t('mall.user.password') })" />
|
||||
<FormItem :label="t('mall.user.score')" type="number" v-model="baTable.form.items!.score" prop="score" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.score') })" />
|
||||
<FormItem :label="t('mall.user.daily_claim')" type="number" v-model="baTable.form.items!.daily_claim" prop="daily_claim" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.daily_claim') })" />
|
||||
<FormItem :label="t('mall.user.daily_claim_use')" type="number" v-model="baTable.form.items!.daily_claim_use" prop="daily_claim_use" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.daily_claim_use') })" />
|
||||
<FormItem :label="t('mall.user.available_for_withdrawal')" type="number" v-model="baTable.form.items!.available_for_withdrawal" prop="available_for_withdrawal" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('mall.user.available_for_withdrawal') })" />
|
||||
<FormItem :label="t('mall.user.admin_id')" type="remoteSelect" v-model="baTable.form.items!.admin_id" prop="admin_id" :input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }" :placeholder="t('Please select field', { field: t('mall.user.admin_id') })" />
|
||||
</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'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
|
||||
const config = useConfig()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('mall.user.username') })],
|
||||
phone: [buildValidatorData({ name: 'required', title: t('mall.user.phone') })],
|
||||
password: [buildValidatorData({ name: 'password', title: t('mall.user.password') })],
|
||||
score: [buildValidatorData({ name: 'number', title: t('mall.user.score') })],
|
||||
daily_claim: [buildValidatorData({ name: 'number', title: t('mall.user.daily_claim') })],
|
||||
daily_claim_use: [buildValidatorData({ name: 'number', title: t('mall.user.daily_claim_use') })],
|
||||
available_for_withdrawal: [buildValidatorData({ name: 'number', title: t('mall.user.available_for_withdrawal') })],
|
||||
create_time: [buildValidatorData({ name: 'date', title: t('mall.user.create_time') })],
|
||||
update_time: [buildValidatorData({ name: 'date', title: t('mall.user.update_time') })],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -2,19 +2,13 @@
|
||||
<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('mall.player.quickSearchFields') })"
|
||||
:buttons="['refresh', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.userAsset.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
|
||||
<!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
|
||||
<Table ref="tableRef"></Table>
|
||||
|
||||
<!-- 表单 -->
|
||||
<PopupForm />
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,25 +24,22 @@ import Table from '/@/components/table/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'mall/player',
|
||||
name: 'mall/userAsset',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
|
||||
|
||||
/**
|
||||
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
|
||||
*/
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/mall.Player/'),
|
||||
new baTableApi('/admin/mall.UserAsset/'),
|
||||
{
|
||||
pk: 'id',
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('mall.player.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{ label: t('mall.userAsset.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
|
||||
{
|
||||
label: t('mall.player.username'),
|
||||
label: t('mall.userAsset.username'),
|
||||
prop: 'username',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
@@ -56,7 +47,38 @@ const baTable = new baTableClass(
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.player.create_time'),
|
||||
label: t('mall.userAsset.phone'),
|
||||
prop: 'phone',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{
|
||||
label: t('mall.userAsset.playx_user_id'),
|
||||
prop: 'playx_user_id',
|
||||
align: 'center',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
sortable: false,
|
||||
operator: 'LIKE',
|
||||
},
|
||||
{ label: t('mall.userAsset.locked_points'), prop: 'locked_points', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.userAsset.available_points'), prop: 'available_points', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.userAsset.today_limit'), prop: 'today_limit', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{ label: t('mall.userAsset.today_claimed'), prop: 'today_claimed', align: 'center', operator: 'RANGE', sortable: false },
|
||||
{
|
||||
label: t('mall.userAsset.today_limit_date'),
|
||||
prop: 'today_limit_date',
|
||||
align: 'center',
|
||||
render: 'date',
|
||||
operator: 'RANGE',
|
||||
comSearchRender: 'date',
|
||||
sortable: 'custom',
|
||||
width: 120,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('mall.userAsset.create_time'),
|
||||
prop: 'create_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
@@ -67,7 +89,7 @@ const baTable = new baTableClass(
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{
|
||||
label: t('mall.player.update_time'),
|
||||
label: t('mall.userAsset.update_time'),
|
||||
prop: 'update_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
@@ -77,8 +99,7 @@ const baTable = new baTableClass(
|
||||
width: 160,
|
||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||
},
|
||||
{ label: t('mall.player.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
|
||||
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
|
||||
{ label: t('Operate'), align: 'center', fixed: 'right', width: 80, render: 'buttons', buttons: optButtons, operator: false },
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
},
|
||||
124
web/src/views/backend/mall/userAsset/popupForm.vue
Normal file
124
web/src/views/backend/mall/userAsset/popupForm.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="ba-operate-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="['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('mall.userAsset.id')" type="number" v-model="baTable.form.items!.id" prop="id" :input-attr="{ disabled: true }" />
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.playx_user_id')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.playx_user_id"
|
||||
prop="playx_user_id"
|
||||
:input-attr="{ disabled: true }"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.username')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.username"
|
||||
prop="username"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.username') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.phone')"
|
||||
type="string"
|
||||
v-model="baTable.form.items!.phone"
|
||||
prop="phone"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.phone') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.locked_points')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.locked_points"
|
||||
prop="locked_points"
|
||||
:input-attr="{ step: 1, min: 0 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.locked_points') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.available_points')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.available_points"
|
||||
prop="available_points"
|
||||
:input-attr="{ step: 1, min: 0 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.available_points') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.today_limit')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.today_limit"
|
||||
prop="today_limit"
|
||||
:input-attr="{ step: 1, min: 0 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.today_limit') })"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('mall.userAsset.today_claimed')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.today_claimed"
|
||||
prop="today_claimed"
|
||||
:input-attr="{ step: 1, min: 0 }"
|
||||
:placeholder="t('Please input field', { field: t('mall.userAsset.today_claimed') })"
|
||||
/>
|
||||
</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">
|
||||
{{ t('Save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, reactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import type { FormItemRule } from 'element-plus'
|
||||
import { buildValidatorData } from '/@/utils/validate'
|
||||
|
||||
const config = useConfig()
|
||||
const formRef = useTemplateRef('formRef')
|
||||
const baTable = inject('baTable') as baTableClass
|
||||
const { t } = useI18n()
|
||||
|
||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('mall.userAsset.username') })],
|
||||
phone: [buildValidatorData({ name: 'required', title: t('mall.userAsset.phone') })],
|
||||
locked_points: [buildValidatorData({ name: 'required', title: t('mall.userAsset.locked_points') })],
|
||||
available_points: [buildValidatorData({ name: 'required', title: t('mall.userAsset.available_points') })],
|
||||
today_limit: [buildValidatorData({ name: 'required', title: t('mall.userAsset.today_limit') })],
|
||||
today_claimed: [buildValidatorData({ name: 'required', title: t('mall.userAsset.today_claimed') })],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -82,13 +82,6 @@ const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('routine.attachment.Upload user'),
|
||||
prop: 'user.nickname',
|
||||
align: 'center',
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('utils.size'),
|
||||
prop: 'size',
|
||||
|
||||
2
web/types/tableRenderer.d.ts
vendored
2
web/types/tableRenderer.d.ts
vendored
@@ -4,10 +4,12 @@ type TableRenderer =
|
||||
| 'color'
|
||||
| 'customRender'
|
||||
| 'customTemplate'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'icon'
|
||||
| 'image'
|
||||
| 'images'
|
||||
| 'ipWhiteList'
|
||||
| 'switch'
|
||||
| 'tag'
|
||||
| 'tags'
|
||||
|
||||
Reference in New Issue
Block a user