Compare commits

...

16 Commits

78 changed files with 3687 additions and 1210 deletions

View File

@@ -27,7 +27,7 @@ PLAYX_UNLOCK_RATIO=0.1
# Daily Push 签名校验密钥HMAC建议从部署系统注入避免写入代码/仓库) # Daily Push 签名校验密钥HMAC建议从部署系统注入避免写入代码/仓库)
PLAYX_DAILY_PUSH_SECRET= PLAYX_DAILY_PUSH_SECRET=
# 合作方回调 JWT 验签密钥HS256与对端私发密钥一致与上一项可同时配置则两种均需通过 # 合作方回调 JWT 验签密钥HS256与对端私发密钥一致与上一项可同时配置则两种均需通过
PLAYX_PARTNER_JWT_SECRET=5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca PLAYX_PARTNER_JWT_SECRET=
# Agent authtoken/api/v1/authTokenJWT 签名密钥;留空则使用下方 buildadmin.token.key # Agent authtoken/api/v1/authTokenJWT 签名密钥;留空则使用下方 buildadmin.token.key
AGENT_AUTH_JWT_SECRET= AGENT_AUTH_JWT_SECRET=
# token 会话缓存过期时间(秒) # token 会话缓存过期时间(秒)
@@ -35,3 +35,16 @@ PLAYX_SESSION_EXPIRE_SECONDS=3600
# PlayX API商城调用 PlayX 时使用) # PlayX API商城调用 PlayX 时使用)
PLAYX_API_BASE_URL= PLAYX_API_BASE_URL=
PLAYX_API_SECRET_KEY= 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=
# HTTPSCA 证书包绝对路径(推荐下载 https://curl.se/ca/cacert.pem 后填写,解决 cURL error 60
PLAYX_ANGPOW_IMPORT_CACERT=
# 是否校验 SSL1=校验0=不校验,仅本地调试,生产勿用)
PLAYX_ANGPOW_IMPORT_VERIFY_SSL=1

View File

@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace app\admin\controller; namespace app\admin\controller;
use app\common\controller\Backend; 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 Webman\Http\Request;
use support\Response; use support\Response;
@@ -15,8 +19,78 @@ class Dashboard extends Backend
$response = $this->initializeBackend($request); $response = $this->initializeBackend($request);
if ($response !== null) return $response; 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('', [ 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,
],
]); ]);
} }
} }

View File

@@ -8,13 +8,13 @@ use support\Response;
use Webman\Http\Request; use Webman\Http\Request;
/** /**
* PlayX 领取记录(后台列表) * 领取记录(后台列表)
*/ */
class PlayxClaimLog extends Backend class ClaimLog extends Backend
{ {
/** /**
* @var object|null * @var object|null
* @phpstan-var \app\common\model\MallPlayxClaimLog|null * @phpstan-var \app\common\model\MallClaimLog|null
*/ */
protected ?object $model = null; protected ?object $model = null;
@@ -33,7 +33,7 @@ class PlayxClaimLog extends Backend
public function initialize(): void public function initialize(): void
{ {
parent::initialize(); parent::initialize();
$this->model = new \app\common\model\MallPlayxClaimLog(); $this->model = new \app\common\model\MallClaimLog();
} }
/** /**

View File

@@ -8,13 +8,13 @@ use support\Response;
use Webman\Http\Request; use Webman\Http\Request;
/** /**
* PlayX 每日推送数据(后台列表) * 每日推送数据(后台列表)
*/ */
class PlayxDailyPush extends Backend class DailyPush extends Backend
{ {
/** /**
* @var object|null * @var object|null
* @phpstan-var \app\common\model\MallPlayxDailyPush|null * @phpstan-var \app\common\model\MallDailyPush|null
*/ */
protected ?object $model = null; protected ?object $model = null;
@@ -37,7 +37,7 @@ class PlayxDailyPush extends Backend
public function initialize(): void public function initialize(): void
{ {
parent::initialize(); parent::initialize();
$this->model = new \app\common\model\MallPlayxDailyPush(); $this->model = new \app\common\model\MallDailyPush();
} }
/** /**

View File

@@ -4,20 +4,21 @@ namespace app\admin\controller\mall;
use Throwable; use Throwable;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\model\MallPlayxOrder; use app\common\library\MallBonusGrantPush;
use app\common\model\MallPlayxUserAsset; use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use support\think\Db; use support\think\Db;
use support\Response; use support\Response;
use Webman\Http\Request; use Webman\Http\Request;
/** /**
* PlayX 统一订单(后台列表) * 统一订单(后台列表)
*/ */
class PlayxOrder extends Backend class Order extends Backend
{ {
/** /**
* @var object|null * @var object|null
* @phpstan-var \app\common\model\MallPlayxOrder|null * @phpstan-var \app\common\model\MallOrder|null
*/ */
protected ?object $model = null; protected ?object $model = null;
@@ -46,6 +47,7 @@ class PlayxOrder extends Backend
'receiver_name', 'receiver_name',
'receiver_phone', 'receiver_phone',
'receiver_address', 'receiver_address',
'mall_address_id',
'create_time', 'create_time',
'update_time', 'update_time',
]; ];
@@ -53,7 +55,7 @@ class PlayxOrder extends Backend
public function initialize(): void public function initialize(): void
{ {
parent::initialize(); parent::initialize();
$this->model = new \app\common\model\MallPlayxOrder(); $this->model = new \app\common\model\MallOrder();
} }
/** /**
@@ -71,7 +73,7 @@ class PlayxOrder extends Backend
return $this->select($request); return $this->select($request);
} }
list($where, $alias, $limit, $order) = $this->queryBuilder(); [$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model $res = $this->model
->with(['mallItem' => function ($query) { ->with(['mallItem' => function ($query) {
$query->field('id,title'); $query->field('id,title');
@@ -104,22 +106,22 @@ class PlayxOrder extends Backend
} }
$data = $request->post(); $data = $request->post();
$id = intval($data['id'] ?? 0); $id = $data['id'] ?? 0;
$shippingCompany = strval($data['shipping_company'] ?? ''); $shippingCompany = $data['shipping_company'] ?? '';
$shippingNo = strval($data['shipping_no'] ?? ''); $shippingNo = $data['shipping_no'] ?? '';
if ($id <= 0 || $shippingCompany === '' || $shippingNo === '') { if (!$id || $shippingCompany === '' || $shippingNo === '') {
return $this->error(__('Missing required fields')); return $this->error(__('Missing required fields'));
} }
$order = MallPlayxOrder::where('id', $id)->find(); $order = MallOrder::where('id', $id)->find();
if (!$order) { if (!$order) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
} }
if ($order->type !== MallPlayxOrder::TYPE_PHYSICAL) { if ($order->type !== MallOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not PHYSICAL')); return $this->error(__('Order type not PHYSICAL'));
} }
if ($order->status !== MallPlayxOrder::STATUS_PENDING) { if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING')); return $this->error(__('Order status must be PENDING'));
} }
@@ -127,7 +129,7 @@ class PlayxOrder extends Backend
try { try {
$order->shipping_company = $shippingCompany; $order->shipping_company = $shippingCompany;
$order->shipping_no = $shippingNo; $order->shipping_no = $shippingNo;
$order->status = MallPlayxOrder::STATUS_SHIPPED; $order->status = MallOrder::STATUS_SHIPPED;
$order->save(); $order->save();
Db::commit(); Db::commit();
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -139,7 +141,52 @@ class PlayxOrder extends Backend
} }
/** /**
* PHYSICAL 驳回:更新状态为 REJECTED并退回积分到 available_points * 审核通过(非 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 public function reject(Request $request): Response
{ {
@@ -153,40 +200,46 @@ class PlayxOrder extends Backend
} }
$data = $request->post(); $data = $request->post();
$id = intval($data['id'] ?? 0); $id = $data['id'] ?? 0;
$rejectReason = strval($data['reject_reason'] ?? ''); $rejectReason = $data['reject_reason'] ?? '';
if ($id <= 0 || $rejectReason === '') { if (!$id) {
return $this->error(__('Missing required fields')); return $this->error(__('Missing required fields'));
} }
$order = MallPlayxOrder::where('id', $id)->find(); $order = MallOrder::where('id', $id)->find();
if (!$order) { if (!$order) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
} }
if ($order->type !== MallPlayxOrder::TYPE_PHYSICAL) { if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order type not PHYSICAL'));
}
if ($order->status !== MallPlayxOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING')); return $this->error(__('Order status must be PENDING'));
} }
if ($order->type === MallOrder::TYPE_PHYSICAL && $rejectReason === '') {
return $this->error(__('Missing required fields'));
}
Db::startTrans(); Db::startTrans();
try { try {
$asset = MallPlayxUserAsset::where('playx_user_id', strval($order->user_id ?? ''))->find(); $asset = MallUserAsset::where('playx_user_id', $order->user_id ?? '')->find();
if (!$asset) { if (!$asset) {
throw new \RuntimeException('User asset not found'); throw new \RuntimeException('User asset not found');
} }
$refund = intval($order->points_cost ?? 0); $refund = $order->points_cost ?? 0;
if ($refund > 0) { if ($refund > 0) {
$asset->available_points += $refund; $asset->available_points += $refund;
$asset->save(); $asset->save();
} }
$order->status = MallPlayxOrder::STATUS_REJECTED; $order->status = MallOrder::STATUS_REJECTED;
$order->reject_reason = $rejectReason; $order->reject_reason = $rejectReason;
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; 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(); $order->save();
Db::commit(); Db::commit();
@@ -199,7 +252,7 @@ class PlayxOrder extends Backend
} }
/** /**
* 手动重试(仅红利/提现,且必须 FAILED_RETRYABLE * 手动推送红利(同步调用 PlayX不限制自动重试次数成功则 ACCEPTED失败写入 fail_reason
*/ */
public function retry(Request $request): Response public function retry(Request $request): Response
{ {
@@ -212,29 +265,54 @@ class PlayxOrder extends Backend
return $this->error(__('Parameter error')); return $this->error(__('Parameter error'));
} }
$id = intval($request->post('id', 0)); $id = $request->post('id', 0);
if ($id <= 0) { if (!$id) {
return $this->error(__('Missing required fields')); return $this->error(__('Missing required fields'));
} }
$order = MallPlayxOrder::where('id', $id)->find(); $order = MallOrder::where('id', $id)->find();
if (!$order) { if (!$order) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
} }
if (!in_array($order->type, [MallPlayxOrder::TYPE_BONUS, MallPlayxOrder::TYPE_WITHDRAW], true)) { if ($order->type !== MallOrder::TYPE_BONUS) {
return $this->error(__('Only BONUS/WITHDRAW can retry')); return $this->error(__('Only BONUS can retry'));
} }
if ($order->grant_status !== MallPlayxOrder::GRANT_FAILED_RETRYABLE) { if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Only FAILED_RETRYABLE can retry')); return $this->error(__('Order status must be PENDING'));
}
if (intval($order->retry_count) >= 3) {
return $this->error(__('Retry count exceeded'));
} }
$order->grant_status = MallPlayxOrder::GRANT_NOT_SENT; $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(); $order->save();
return $this->success(__('Retry queued')); return $this->error($failReason);
} }
} }

View File

@@ -7,13 +7,13 @@ use support\Response;
use Webman\Http\Request; use Webman\Http\Request;
/** /**
* PlayX 用户资产(后台列表) * 用户资产(后台列表)
*/ */
class PlayxUserAsset extends Backend class UserAsset extends Backend
{ {
/** /**
* @var object|null * @var object|null
* @phpstan-var \app\common\model\MallPlayxUserAsset|null * @phpstan-var \app\common\model\MallUserAsset|null
*/ */
protected ?object $model = null; protected ?object $model = null;
@@ -42,7 +42,7 @@ class PlayxUserAsset extends Backend
public function initialize(): void public function initialize(): void
{ {
parent::initialize(); parent::initialize();
$this->model = new \app\common\model\MallPlayxUserAsset(); $this->model = new \app\common\model\MallUserAsset();
} }
/** /**
@@ -55,7 +55,7 @@ class PlayxUserAsset extends Backend
return $response; return $response;
} }
list($where, $alias, $limit, $order) = $this->queryBuilder(); [$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model $res = $this->model
->field('id,username') ->field('id,username')
->alias($alias) ->alias($alias)
@@ -67,8 +67,8 @@ class PlayxUserAsset extends Backend
foreach ($res->items() as $row) { foreach ($res->items() as $row) {
$arr = $row->toArray(); $arr = $row->toArray();
$list[] = [ $list[] = [
'id' => intval($arr['id'] ?? 0), 'id' => $arr['id'] ?? 0,
'username' => strval($arr['username'] ?? ''), 'username' => $arr['username'] ?? '',
]; ];
} }
@@ -79,3 +79,4 @@ class PlayxUserAsset extends Backend
]); ]);
} }
} }

View File

@@ -95,4 +95,9 @@ return [
'%d records and files have been deleted' => '%d records and files have been deleted', '%d records and files have been deleted' => '%d records and files have been deleted',
'Please input correct username' => 'Please enter the correct username', 'Please input correct username' => 'Please enter the correct username',
'Group Name Arr' => 'Group Name Arr', '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',
]; ];

View File

@@ -114,4 +114,9 @@ return [
'%d records and files have been deleted' => '已删除%d条记录和文件', '%d records and files have been deleted' => '已删除%d条记录和文件',
'Please input correct username' => '请输入正确的用户名', 'Please input correct username' => '请输入正确的用户名',
'Group Name Arr' => '分组名称数组', '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' => '订单状态须为处理中',
]; ];

View File

@@ -10,7 +10,7 @@ use app\common\controller\Api;
use app\common\facade\Token; use app\common\facade\Token;
use app\common\library\Auth as UserAuth; use app\common\library\Auth as UserAuth;
use app\common\library\AgentJwt; use app\common\library\AgentJwt;
use app\common\model\MallPlayxUserAsset; use app\common\model\MallUserAsset;
use app\admin\model\Admin; use app\admin\model\Admin;
use Webman\Http\Request; use Webman\Http\Request;
use support\Response; use support\Response;
@@ -100,7 +100,7 @@ class Auth extends Api
/** /**
* H5 临时登录GET/POST * H5 临时登录GET/POST
* 参数username * 参数username
* 写入或复用 mall_playx_user_asset签发 muser 类型 tokenuser_id 为资产表主键) * 写入或复用 mall_user_asset签发 muser 类型 tokenuser_id 为资产表主键)
*/ */
public function temLogin(Request $request): Response public function temLogin(Request $request): Response
{ {
@@ -115,12 +115,30 @@ class Auth extends Api
} }
$username = trim(strval($request->get('username', $request->post('username', '')))); $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 === '') { if ($username === '') {
return $this->error(__('Parameter username can not be empty')); return $this->error(__('Parameter username can not be empty'));
} }
try { try {
$asset = MallPlayxUserAsset::ensureForUsername($username); $asset = MallUserAsset::ensureForUsername($username);
} catch (Throwable $e) { } catch (Throwable $e) {
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }

View File

@@ -9,12 +9,12 @@ use app\common\controller\Api;
use app\common\facade\Token; use app\common\facade\Token;
use app\common\library\Auth as UserAuth; use app\common\library\Auth as UserAuth;
use app\common\model\MallItem; use app\common\model\MallItem;
use app\common\model\MallPlayxClaimLog; use app\common\model\MallClaimLog;
use app\common\model\MallPlayxDailyPush; use app\common\model\MallDailyPush;
use app\common\model\MallPlayxSession; use app\common\model\MallSession;
use app\common\model\MallPlayxOrder; use app\common\model\MallOrder;
use app\common\model\MallPlayxUserAsset; use app\common\model\MallUserAsset;
use app\common\library\PlayxInboundJwt; use app\common\model\MallAddress;
use support\think\Db; use support\think\Db;
use Webman\Http\Request; use Webman\Http\Request;
use support\Response; use support\Response;
@@ -25,17 +25,17 @@ use support\Response;
class Playx extends Api class Playx extends Api
{ {
/** /**
* 从请求解析 mall_playx_user_asset.idmuser token、session、user_id 均指向资产表主键或 playx_user_id * 从请求解析 mall_user_asset.idmuser token、session、user_id 均指向资产表主键或 playx_user_id
*/ */
private function resolvePlayxAssetIdFromRequest(Request $request): ?int private function resolvePlayxAssetIdFromRequest(Request $request): ?int
{ {
$sessionId = strval($request->post('session_id', $request->get('session_id', ''))); $sessionId = strval($request->post('session_id', $request->get('session_id', '')));
if ($sessionId !== '') { if ($sessionId !== '') {
$session = MallPlayxSession::where('session_id', $sessionId)->find(); $session = MallSession::where('session_id', $sessionId)->find();
if ($session) { if ($session) {
$expireTime = intval($session->expire_time ?? 0); $expireTime = intval($session->expire_time ?? 0);
if ($expireTime > time()) { if ($expireTime > time()) {
$asset = MallPlayxUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find(); $asset = MallUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find();
if ($asset) { if ($asset) {
return intval($asset->getKey()); return intval($asset->getKey());
} }
@@ -63,7 +63,7 @@ class Playx extends Api
return intval($userId); return intval($userId);
} }
$asset = MallPlayxUserAsset::where('playx_user_id', $userId)->find(); $asset = MallUserAsset::where('playx_user_id', $userId)->find();
if ($asset) { if ($asset) {
return intval($asset->getKey()); return intval($asset->getKey());
} }
@@ -91,7 +91,7 @@ class Playx extends Api
{ {
for ($i = 0; $i < 8; $i++) { for ($i = 0; $i < 8; $i++) {
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT); $candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
if (!MallPlayxUserAsset::where('phone', $candidate)->find()) { if (!MallUserAsset::where('phone', $candidate)->find()) {
return $candidate; return $candidate;
} }
} }
@@ -99,9 +99,9 @@ class Playx extends Api
return null; return null;
} }
private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallPlayxUserAsset private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallUserAsset
{ {
$asset = MallPlayxUserAsset::where('playx_user_id', $playxUserId)->find(); $asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
if ($asset) { if ($asset) {
return $asset; return $asset;
} }
@@ -110,7 +110,7 @@ class Playx extends Api
if ($effectiveUsername === '') { if ($effectiveUsername === '') {
$effectiveUsername = 'playx_' . $playxUserId; $effectiveUsername = 'playx_' . $playxUserId;
} }
$byName = MallPlayxUserAsset::where('username', $effectiveUsername)->find(); $byName = MallUserAsset::where('username', $effectiveUsername)->find();
if ($byName) { if ($byName) {
$byName->playx_user_id = $playxUserId; $byName->playx_user_id = $playxUserId;
$byName->save(); $byName->save();
@@ -125,7 +125,7 @@ class Playx extends Api
$pwd = hash_password(Random::build('alnum', 16)); $pwd = hash_password(Random::build('alnum', 16));
$now = time(); $now = time();
return MallPlayxUserAsset::create([ return MallUserAsset::create([
'playx_user_id' => $playxUserId, 'playx_user_id' => $playxUserId,
'username' => $effectiveUsername, 'username' => $effectiveUsername,
'phone' => $phone, 'phone' => $phone,
@@ -141,14 +141,14 @@ class Playx extends Api
]); ]);
} }
private function getAssetById(int $assetId): ?MallPlayxUserAsset private function getAssetById(int $assetId): ?MallUserAsset
{ {
return MallPlayxUserAsset::where('id', $assetId)->find(); return MallUserAsset::where('id', $assetId)->find();
} }
/** /**
* Daily Push API - PlayX 调用商城接收 T+1 数据 * Daily Push API - PlayX 调用商城接收 T+1 数据
* POST /api/v1/playx/daily-push * POST /api/v1/mall/dailyPush
*/ */
public function dailyPush(Request $request): Response public function dailyPush(Request $request): Response
{ {
@@ -157,14 +157,6 @@ class Playx extends Api
return $response; return $response;
} }
$partnerJwtSecret = strval(config('playx.partner_jwt_secret', ''));
if ($partnerJwtSecret !== '') {
$authHeader = strval($request->header('authorization', ''));
if (!PlayxInboundJwt::verifyBearer($authHeader, $partnerJwtSecret)) {
return $this->error(__('Invalid or missing JWT'), null, 0, ['statusCode' => 401]);
}
}
$body = $request->post(); $body = $request->post();
if (empty($body)) { if (empty($body)) {
$raw = $request->rawBody(); $raw = $request->rawBody();
@@ -179,12 +171,12 @@ class Playx extends Api
$ts = $request->header('X-Timestamp', ''); $ts = $request->header('X-Timestamp', '');
$rid = $request->header('X-Request-Id', ''); $rid = $request->header('X-Request-Id', '');
if ($sig === '' || $ts === '' || $rid === '') { if ($sig === '' || $ts === '' || $rid === '') {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 401]); return $this->error(__('Daily push signature missing or incomplete'), null, 0, ['statusCode' => 401]);
} }
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/playx/daily-push\n" . hash('sha256', json_encode($body)); $canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', json_encode($body));
$expected = hash_hmac('sha256', $canonical, $secret); $expected = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $sig)) { if (!hash_equals($expected, $sig)) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 401]); return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
} }
} }
@@ -227,7 +219,7 @@ class Playx extends Api
$lifetimeTotalDeposit = $m['lty_deposit'] ?? 0; $lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
$lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0; $lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;
$exists = MallPlayxDailyPush::where('user_id', $playxUserId)->where('date', $date)->find(); $exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) { if ($exists) {
$results[] = [ $results[] = [
'user_id' => $playxUserId, 'user_id' => $playxUserId,
@@ -240,7 +232,7 @@ class Playx extends Api
Db::startTrans(); Db::startTrans();
try { try {
MallPlayxDailyPush::create([ MallDailyPush::create([
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'date' => $date, 'date' => $date,
'username' => $username, 'username' => $username,
@@ -259,7 +251,7 @@ class Playx extends Api
$asset = $this->ensureAssetForPlayx($playxUserId, $username); $asset = $this->ensureAssetForPlayx($playxUserId, $username);
if (!$asset) { if (!$asset) {
throw new \RuntimeException(__('Failed to map playx user to mall user')); throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
} }
if ($asset->today_limit_date !== $date) { if ($asset->today_limit_date !== $date) {
@@ -312,7 +304,7 @@ class Playx extends Api
return $this->error(__('Missing required fields: request_id, date, user_id')); return $this->error(__('Missing required fields: request_id, date, user_id'));
} }
$exists = MallPlayxDailyPush::where('user_id', $playxUserId)->where('date', $date)->find(); $exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) { if ($exists) {
return $this->success('', [ return $this->success('', [
'request_id' => $requestId, 'request_id' => $requestId,
@@ -324,7 +316,7 @@ class Playx extends Api
Db::startTrans(); Db::startTrans();
try { try {
MallPlayxDailyPush::create([ MallDailyPush::create([
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'date' => $date, 'date' => $date,
'username' => $body['username'] ?? '', 'username' => $body['username'] ?? '',
@@ -345,7 +337,7 @@ class Playx extends Api
$asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? '')); $asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? ''));
if (!$asset) { if (!$asset) {
throw new \RuntimeException(__('Failed to map playx user to mall user')); throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
} }
if ($asset->today_limit_date !== $date) { if ($asset->today_limit_date !== $date) {
@@ -390,7 +382,7 @@ class Playx extends Api
$token = strval($request->post('token', $request->post('session', $request->get('token', '')))); $token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
if ($token === '') { if ($token === '') {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
if (config('playx.verify_token_local_only', false)) { if (config('playx.verify_token_local_only', false)) {
@@ -418,7 +410,7 @@ class Playx extends Api
$data = json_decode(strval($res->getBody()), true); $data = json_decode(strval($res->getBody()), true);
if ($code !== 200 || empty($data['user_id'])) { if ($code !== 200 || empty($data['user_id'])) {
$remoteMsg = $data['message'] ?? ''; $remoteMsg = $data['message'] ?? '';
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Invalid token'); $msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
return $this->error($msg, null, 0, ['statusCode' => 401]); return $this->error($msg, null, 0, ['statusCode' => 401]);
} }
@@ -435,7 +427,7 @@ class Playx extends Api
} }
$sessionId = bin2hex(random_bytes(16)); $sessionId = bin2hex(random_bytes(16));
MallPlayxSession::create([ MallSession::create([
'session_id' => $sessionId, 'session_id' => $sessionId,
'user_id' => $userId, 'user_id' => $userId,
'username' => $username, 'username' => $username,
@@ -456,26 +448,26 @@ class Playx extends Api
} }
/** /**
* 本地校验 temLogin 等写入的商城 token类型 muser写入 mall_playx_session * 本地校验 temLogin 等写入的商城 token类型 muser写入 mall_session
*/ */
private function verifyTokenLocal(string $token): Response private function verifyTokenLocal(string $token): Response
{ {
$tokenData = Token::get($token); $tokenData = Token::get($token);
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) { if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$tokenType = strval($tokenData['type'] ?? ''); $tokenType = strval($tokenData['type'] ?? '');
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) { if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$assetId = intval($tokenData['user_id'] ?? 0); $assetId = intval($tokenData['user_id'] ?? 0);
if ($assetId <= 0) { if ($assetId <= 0) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$asset = MallPlayxUserAsset::where('id', $assetId)->find(); $asset = MallUserAsset::where('id', $assetId)->find();
if (!$asset) { if (!$asset) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$playxUserId = strval($asset->playx_user_id ?? ''); $playxUserId = strval($asset->playx_user_id ?? '');
@@ -485,7 +477,7 @@ class Playx extends Api
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600)); $expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
$sessionId = bin2hex(random_bytes(16)); $sessionId = bin2hex(random_bytes(16));
MallPlayxSession::create([ MallSession::create([
'session_id' => $sessionId, 'session_id' => $sessionId,
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''), 'username' => strval($asset->username ?? ''),
@@ -515,7 +507,7 @@ class Playx extends Api
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) { if ($assetId === null) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$asset = $this->getAssetById($assetId); $asset = $this->getAssetById($assetId);
@@ -554,7 +546,10 @@ class Playx extends Api
$claimRequestId = strval($request->post('claim_request_id', '')); $claimRequestId = strval($request->post('claim_request_id', ''));
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($claimRequestId === '' || $assetId === null) { 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')); return $this->error(__('claim_request_id and user_id/session_id required'));
} }
@@ -564,7 +559,7 @@ class Playx extends Api
} }
$playxUserId = strval($asset->playx_user_id); $playxUserId = strval($asset->playx_user_id);
$exists = MallPlayxClaimLog::where('claim_request_id', $claimRequestId)->find(); $exists = MallClaimLog::where('claim_request_id', $claimRequestId)->find();
if ($exists) { if ($exists) {
return $this->success('', $this->formatAsset($asset)); return $this->success('', $this->formatAsset($asset));
} }
@@ -584,7 +579,7 @@ class Playx extends Api
Db::startTrans(); Db::startTrans();
try { try {
MallPlayxClaimLog::create([ MallClaimLog::create([
'claim_request_id' => $claimRequestId, 'claim_request_id' => $claimRequestId,
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'claimed_amount' => $canClaim, 'claimed_amount' => $canClaim,
@@ -668,14 +663,14 @@ class Playx extends Api
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) { if ($assetId === null) {
return $this->error(__('Invalid token'), null, 0, ['statusCode' => 401]); return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
} }
$asset = $this->getAssetById($assetId); $asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') { if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->success('', ['list' => []]); return $this->success('', ['list' => []]);
} }
$list = MallPlayxOrder::where('user_id', strval($asset->playx_user_id)) $list = MallOrder::where('user_id', strval($asset->playx_user_id))
->with(['mallItem']) ->with(['mallItem'])
->order('id', 'desc') ->order('id', 'desc')
->limit(100) ->limit(100)
@@ -684,7 +679,198 @@ class Playx extends Api
return $this->success('', ['list' => $list->toArray()]); return $this->success('', ['list' => $list->toArray()]);
} }
private function formatAsset(?MallPlayxUserAsset $asset): array /**
* 收货地址列表
* 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) { if (!$asset) {
return [ return [
@@ -714,7 +900,10 @@ class Playx extends Api
$itemId = intval($request->post('item_id', 0)); $itemId = intval($request->post('item_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($itemId <= 0 || $assetId === null) { 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')); return $this->error(__('item_id and user_id/session_id required'));
} }
@@ -741,18 +930,20 @@ class Playx extends Api
$asset->save(); $asset->save();
$orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999); $orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([ $order = MallOrder::create([
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'type' => MallPlayxOrder::TYPE_BONUS, 'type' => MallOrder::TYPE_BONUS,
'status' => MallPlayxOrder::STATUS_PENDING, 'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id, 'mall_item_id' => $item->id,
'points_cost' => $item->score, 'points_cost' => $item->score,
'amount' => $amount, 'amount' => $amount,
'multiplier' => $multiplier, 'multiplier' => $multiplier,
'external_transaction_id' => $orderNo, 'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT, 'grant_status' => MallOrder::GRANT_NOT_SENT,
'create_time' => time(), 'create_time' => time(),
'update_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(); Db::commit();
@@ -780,11 +971,21 @@ class Playx extends Api
} }
$itemId = intval($request->post('item_id', 0)); $itemId = intval($request->post('item_id', 0));
$addressId = intval($request->post('address_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
$receiverName = $request->post('receiver_name', ''); if ($itemId <= 0 || $addressId <= 0) {
$receiverPhone = $request->post('receiver_phone', ''); return $this->error(__('Missing required fields'));
$receiverAddress = $request->post('receiver_address', ''); }
if ($itemId <= 0 || $assetId === null || $receiverName === '' || $receiverPhone === '' || $receiverAddress === '') { 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')); return $this->error(__('Missing required fields'));
} }
@@ -807,15 +1008,17 @@ class Playx extends Api
$asset->available_points -= $item->score; $asset->available_points -= $item->score;
$asset->save(); $asset->save();
MallPlayxOrder::create([ MallOrder::create([
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'type' => MallPlayxOrder::TYPE_PHYSICAL, 'type' => MallOrder::TYPE_PHYSICAL,
'status' => MallPlayxOrder::STATUS_PENDING, 'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id, 'mall_item_id' => $item->id,
'points_cost' => $item->score, 'points_cost' => $item->score,
'receiver_name' => $receiverName, 'mall_address_id' => $addressId,
'receiver_phone' => $receiverPhone, 'receiver_name' => $snapshot['receiver_name'],
'receiver_address' => $receiverAddress, 'receiver_phone' => $snapshot['receiver_phone'],
'receiver_address' => $snapshot['receiver_address'],
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(), 'create_time' => time(),
'update_time' => time(), 'update_time' => time(),
]); ]);
@@ -843,7 +1046,10 @@ class Playx extends Api
$itemId = intval($request->post('item_id', 0)); $itemId = intval($request->post('item_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request); $assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($itemId <= 0 || $assetId === null) { 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')); return $this->error(__('item_id and user_id/session_id required'));
} }
@@ -870,16 +1076,16 @@ class Playx extends Api
$asset->save(); $asset->save();
$orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999); $orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([ $order = MallOrder::create([
'user_id' => $playxUserId, 'user_id' => $playxUserId,
'type' => MallPlayxOrder::TYPE_WITHDRAW, 'type' => MallOrder::TYPE_WITHDRAW,
'status' => MallPlayxOrder::STATUS_PENDING, 'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id, 'mall_item_id' => $item->id,
'points_cost' => $item->score, 'points_cost' => $item->score,
'amount' => $amount, 'amount' => $amount,
'multiplier' => $multiplier, 'multiplier' => $multiplier,
'external_transaction_id' => $orderNo, 'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT, 'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(), 'create_time' => time(),
'update_time' => time(), 'update_time' => time(),
]); ]);
@@ -890,18 +1096,13 @@ class Playx extends Api
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBalanceCredit($order, $playxUserId);
}
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [ return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
'order_id' => $order->id, 'order_id' => $order->id,
'status' => 'PENDING', 'status' => 'PENDING',
]); ]);
} }
private function callPlayxBonusGrant(MallPlayxOrder $order, MallItem $item, string $userId): void private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
{ {
$baseUrl = rtrim(config('playx.api.base_url', ''), '/'); $baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'); $url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
@@ -926,53 +1127,18 @@ class Playx extends Api
$data = json_decode(strval($res->getBody()), true); $data = json_decode(strval($res->getBody()), true);
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') { if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->playx_transaction_id = $data['playx_transaction_id'] ?? ''; $order->playx_transaction_id = $data['playx_transaction_id'] ?? '';
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED; $order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->save(); $order->save();
} else { } else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown'; $order->fail_reason = $data['message'] ?? 'unknown';
$order->save(); $order->save();
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage(); $order->fail_reason = $e->getMessage();
$order->save(); $order->save();
} }
} }
private function callPlayxBalanceCredit(MallPlayxOrder $order, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.balance_credit_url', '/api/v1/balance/credit');
if ($baseUrl === '') {
return;
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 15]);
$res = $client->post($baseUrl . $url, [
'json' => [
'request_id' => 'mall_withdraw_' . uniqid(),
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $userId,
'amount' => $order->amount,
'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 = MallPlayxOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
} }

View File

@@ -22,6 +22,7 @@ return [
'Temp login is disabled' => 'Temp login is disabled', 'Temp login is disabled' => 'Temp login is disabled',
'Failed to create temp account' => 'Failed to allocate a unique phone number, please retry later', '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', 'Parameter username can not be empty' => 'Parameter username can not be empty',
'Token expiration' => 'Session expired, please login again.',
// Member center account // Member center account
'Data updated successfully~' => 'Data updated successfully~', 'Data updated successfully~' => 'Data updated successfully~',
'Password has been changed~' => 'Password has been changed~', 'Password has been changed~' => 'Password has been changed~',
@@ -35,6 +36,11 @@ return [
'Ok' => 'OK', 'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Failed to map PlayX user to mall user', '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: 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', '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', 'User asset not found' => 'User asset not found',
'No points to claim or limit reached' => 'No points to claim or daily limit reached', 'No points to claim or limit reached' => 'No points to claim or daily limit reached',
@@ -44,6 +50,7 @@ return [
'Insufficient points' => 'Insufficient points', 'Insufficient points' => 'Insufficient points',
'Redeem submitted, please wait about 10 minutes' => 'Redeem submitted, please wait about 10 minutes', 'Redeem submitted, please wait about 10 minutes' => 'Redeem submitted, please wait about 10 minutes',
'Missing required fields' => 'Missing required fields', 'Missing required fields' => 'Missing required fields',
'Shipping address not found' => 'Shipping address not found',
'Out of stock' => 'Out of stock', 'Out of stock' => 'Out of stock',
'Redeem success' => 'Redeem successful', 'Redeem success' => 'Redeem successful',
'Withdraw submitted, please wait about 10 minutes' => 'Withdrawal submitted, please wait about 10 minutes', 'Withdraw submitted, please wait about 10 minutes' => 'Withdrawal submitted, please wait about 10 minutes',

View File

@@ -67,6 +67,11 @@ return [
'Ok' => '成功', 'Ok' => '成功',
'Failed to map playx user to mall user' => '无法将 PlayX 用户关联到商城用户', '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: 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', 'claim_request_id and user_id/session_id required' => '缺少 claim_request_id或未提供有效的 user_id/session_id/token',
'User asset not found' => '未找到用户资产', 'User asset not found' => '未找到用户资产',
'No points to claim or limit reached' => '暂无可领取积分或已达今日上限', 'No points to claim or limit reached' => '暂无可领取积分或已达今日上限',
@@ -77,6 +82,8 @@ return [
'Redeem submitted, please wait about 10 minutes' => '兑换已提交,请等待约 10 分钟', 'Redeem submitted, please wait about 10 minutes' => '兑换已提交,请等待约 10 分钟',
'Missing required fields' => '缺少必填字段', 'Missing required fields' => '缺少必填字段',
'Out of stock' => '库存不足', 'Out of stock' => '库存不足',
'Record not found' => '记录不存在',
'Shipping address not found' => '收货地址不存在',
'Redeem success' => '兑换成功', 'Redeem success' => '兑换成功',
'Withdraw submitted, please wait about 10 minutes' => '提现申请已提交,请等待约 10 分钟', 'Withdraw submitted, please wait about 10 minutes' => '提现申请已提交,请等待约 10 分钟',
]; ];

View 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' => '',
];
}
}
}

View File

@@ -30,7 +30,7 @@ class AllowCrossDomain implements MiddlewareInterface
'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800', 'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang, server', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang, lang, server',
]; ];
$origin = $request->header('origin'); $origin = $request->header('origin');
if (is_array($origin)) { if (is_array($origin)) {

View File

@@ -41,12 +41,46 @@ class MallAddress extends Model
public function getregionTextAttr($value, $row): string public function getregionTextAttr($value, $row): string
{ {
if ($row['region'] === '' || $row['region'] === null) return ''; if ($row['region'] === '' || $row['region'] === null) return '';
$cityNames = \support\think\Db::name('area')->whereIn('id', $row['region'])->column('name'); $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) : ''; return $cityNames ? implode(',', $cityNames) : '';
} }
public function playxUserAsset(): \think\model\relation\BelongsTo public function playxUserAsset(): \think\model\relation\BelongsTo
{ {
return $this->belongsTo(\app\common\model\MallPlayxUserAsset::class, 'playx_user_asset_id', 'id'); 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,
];
} }
} }

View File

@@ -7,14 +7,15 @@ namespace app\common\model;
use support\think\Model; use support\think\Model;
/** /**
* PlayX 领取记录(幂等) * 领取记录(幂等)
*/ */
class MallPlayxClaimLog extends Model class MallClaimLog extends Model
{ {
protected string $name = 'mall_playx_claim_log'; protected string $name = 'mall_claim_log';
protected array $type = [ protected array $type = [
'claimed_amount' => 'integer', 'claimed_amount' => 'integer',
'create_time' => 'integer', 'create_time' => 'integer',
]; ];
} }

View File

@@ -7,11 +7,11 @@ namespace app\common\model;
use support\think\Model; use support\think\Model;
/** /**
* PlayX 每日推送数据 * 每日推送数据
*/ */
class MallPlayxDailyPush extends Model class MallDailyPush extends Model
{ {
protected string $name = 'mall_playx_daily_push'; protected string $name = 'mall_daily_push';
protected array $type = [ protected array $type = [
'yesterday_win_loss_net' => 'float', 'yesterday_win_loss_net' => 'float',
@@ -21,3 +21,4 @@ class MallPlayxDailyPush extends Model
'create_time' => 'integer', 'create_time' => 'integer',
]; ];
} }

View File

@@ -7,7 +7,7 @@ namespace app\common\model;
use support\think\Model; use support\think\Model;
/** /**
* PlayX 统一订单 * 统一订单
* *
* @property int $id * @property int $id
* @property string $user_id * @property string $user_id
@@ -28,10 +28,11 @@ use support\think\Model;
* @property string $receiver_name * @property string $receiver_name
* @property string $receiver_phone * @property string $receiver_phone
* @property string|null $receiver_address * @property string|null $receiver_address
* @property int|null $mall_address_id
*/ */
class MallPlayxOrder extends Model class MallOrder extends Model
{ {
protected string $name = 'mall_playx_order'; protected string $name = 'mall_order';
protected bool $autoWriteTimestamp = true; protected bool $autoWriteTimestamp = true;
@@ -50,17 +51,27 @@ class MallPlayxOrder extends Model
public const GRANT_FAILED_RETRYABLE = 'FAILED_RETRYABLE'; public const GRANT_FAILED_RETRYABLE = 'FAILED_RETRYABLE';
public const GRANT_FAILED_FINAL = 'FAILED_FINAL'; public const GRANT_FAILED_FINAL = 'FAILED_FINAL';
/** 非红利订单不参与 PlayX/Angpow 推送,固定为该占位值 */
public const GRANT_NOT_APPLICABLE = '---';
protected array $type = [ protected array $type = [
'create_time' => 'integer', 'create_time' => 'integer',
'update_time' => 'integer', 'update_time' => 'integer',
'points_cost' => 'integer', 'points_cost' => 'integer',
'amount' => 'float', 'amount' => 'float',
'multiplier' => 'integer', 'multiplier' => 'integer',
'retry_count' => 'integer', 'retry_count' => 'integer',
'mall_address_id' => 'integer',
]; ];
public function mallItem(): \think\model\relation\BelongsTo public function mallItem(): \think\model\relation\BelongsTo
{ {
return $this->belongsTo(MallItem::class, 'mall_item_id', 'id'); 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');
}
} }

View File

@@ -21,6 +21,6 @@ class MallPintsOrder extends Model
public function playxUserAsset(): \think\model\relation\BelongsTo public function playxUserAsset(): \think\model\relation\BelongsTo
{ {
return $this->belongsTo(\app\common\model\MallPlayxUserAsset::class, 'playx_user_asset_id', 'id'); return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
} }
} }

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* PlayX 会话缓存
*/
class MallPlayxSession extends Model
{
protected string $name = 'mall_playx_session';
protected bool $autoWriteTimestamp = true;
protected array $type = [
// 这里需要显式声明 create_time / update_time 为 integer
// 否则 ThinkORM 可能把 bigint 时间戳当成字符串,导致写入时出现 now 字符串问题。
'create_time' => 'integer',
'update_time' => 'integer',
'expire_time' => 'integer',
];
}

View File

@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use ba\Random;
use support\think\Model;
/**
* PlayX 用户资产(积分商城用户主表,含登录账号字段)
*/
class MallPlayxUserAsset extends Model
{
protected string $name = 'mall_playx_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 = self::allocateUniquePhone();
if ($phone === null) {
throw new \RuntimeException('Failed to allocate unique phone');
}
$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_playx_user_asset');
}
$id = intval($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;
}
private static function allocateUniquePhone(): ?string
{
for ($i = 0; $i < 8; $i++) {
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
if (!self::where('phone', $candidate)->find()) {
return $candidate;
}
}
return null;
}
}

View File

@@ -21,7 +21,7 @@ class MallRedemptionOrder extends Model
public function playxUserAsset(): \think\model\relation\BelongsTo public function playxUserAsset(): \think\model\relation\BelongsTo
{ {
return $this->belongsTo(\app\common\model\MallPlayxUserAsset::class, 'playx_user_asset_id', 'id'); return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
} }
public function mallItem(): \think\model\relation\BelongsTo public function mallItem(): \think\model\relation\BelongsTo

View 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',
];
}

View 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=用户名
}

View 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_ordertype=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 默认校验 HTTPSWindows 未配置 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);
}
}

View File

@@ -2,9 +2,9 @@
namespace app\process; namespace app\process;
use app\common\model\MallItem; use app\common\library\MallBonusGrantPush;
use app\common\model\MallPlayxOrder; use app\common\model\MallOrder;
use app\common\model\MallPlayxUserAsset; use app\common\model\MallUserAsset;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Workerman\Timer; use Workerman\Timer;
use Workerman\Worker; use Workerman\Worker;
@@ -47,14 +47,15 @@ class PlayxJobs
$path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status')); $path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status'));
$url = rtrim($baseUrl, '/') . $path; $url = rtrim($baseUrl, '/') . $path;
$list = MallPlayxOrder::where('grant_status', MallPlayxOrder::GRANT_ACCEPTED) $list = MallOrder::where('type', MallOrder::TYPE_BONUS)
->where('status', MallPlayxOrder::STATUS_PENDING) ->where('grant_status', MallOrder::GRANT_ACCEPTED)
->where('status', MallOrder::STATUS_PENDING)
->order('id', 'desc') ->order('id', 'desc')
->limit(50) ->limit(50)
->select(); ->select();
foreach ($list as $order) { foreach ($list as $order) {
/** @var MallPlayxOrder $order */ /** @var MallOrder $order */
try { try {
$res = $this->http->get($url, [ $res = $this->http->get($url, [
'query' => [ 'query' => [
@@ -65,16 +66,16 @@ class PlayxJobs
$data = json_decode(strval($res->getBody()), true) ?? []; $data = json_decode(strval($res->getBody()), true) ?? [];
$pxStatus = $data['status'] ?? ''; $pxStatus = $data['status'] ?? '';
if ($pxStatus === MallPlayxOrder::STATUS_COMPLETED) { if ($pxStatus === MallOrder::STATUS_COMPLETED) {
$order->status = MallPlayxOrder::STATUS_COMPLETED; $order->status = MallOrder::STATUS_COMPLETED;
$order->save(); $order->save();
continue; continue;
} }
if ($pxStatus === 'FAILED' || $pxStatus === MallPlayxOrder::STATUS_REJECTED) { if ($pxStatus === 'FAILED' || $pxStatus === MallOrder::STATUS_REJECTED) {
// 仅在从 PENDING 转 REJECTED 时退分,避免重复入账 // 仅在从 PENDING 转 REJECTED 时退分,避免重复入账
$order->status = MallPlayxOrder::STATUS_REJECTED; $order->status = MallOrder::STATUS_REJECTED;
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; $order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed'); $order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed');
$order->save(); $order->save();
$this->refundPoints($order); $this->refundPoints($order);
@@ -98,24 +99,20 @@ class PlayxJobs
return; return;
} }
$bonusPath = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
$withdrawPath = strval(config('playx.api.balance_credit_url', '/api/v1/balance/credit'));
$bonusUrl = rtrim($baseUrl, '/') . $bonusPath;
$withdrawUrl = rtrim($baseUrl, '/') . $withdrawPath;
$maxRetry = 3; $maxRetry = 3;
$list = MallPlayxOrder::whereIn('grant_status', [ $list = MallOrder::where('type', MallOrder::TYPE_BONUS)
MallPlayxOrder::GRANT_NOT_SENT, ->whereIn('grant_status', [
MallPlayxOrder::GRANT_FAILED_RETRYABLE, MallOrder::GRANT_NOT_SENT,
MallOrder::GRANT_FAILED_RETRYABLE,
]) ])
->where('status', MallPlayxOrder::STATUS_PENDING) ->where('status', MallOrder::STATUS_PENDING)
->where('retry_count', '<', $maxRetry) ->where('retry_count', '<', $maxRetry)
->order('id', 'desc') ->order('id', 'desc')
->limit(50) ->limit(50)
->select(); ->select();
foreach ($list as $order) { foreach ($list as $order) {
/** @var MallPlayxOrder $order */ /** @var MallOrder $order */
$allow = $this->allowRetryByInterval($order); $allow = $this->allowRetryByInterval($order);
if (!$allow) { if (!$allow) {
continue; continue;
@@ -124,25 +121,23 @@ class PlayxJobs
$order->retry_count = intval($order->retry_count ?? 0) + 1; $order->retry_count = intval($order->retry_count ?? 0) + 1;
try { try {
$this->sendGrantByOrder($order, $bonusUrl, $withdrawUrl, $maxRetry); $this->sendGrantByOrder($order, $maxRetry);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$order->fail_reason = $e->getMessage(); $order->fail_reason = $e->getMessage();
if (intval($order->retry_count) >= $maxRetry) { if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL; $order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->save(); $order->save();
$this->refundPoints($order);
} else { } else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE; $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->save(); $order->save();
} }
} }
} }
} }
private function allowRetryByInterval(MallPlayxOrder $order): bool private function allowRetryByInterval(MallOrder $order): bool
{ {
if ($order->grant_status === MallPlayxOrder::GRANT_NOT_SENT) { if ($order->grant_status === MallOrder::GRANT_NOT_SENT) {
return true; return true;
} }
@@ -167,107 +162,39 @@ class PlayxJobs
return false; return false;
} }
private function sendGrantByOrder(MallPlayxOrder $order, string $bonusUrl, string $withdrawUrl, int $maxRetry): void private function sendGrantByOrder(MallOrder $order, int $maxRetry): void
{ {
$item = null; if ($order->type !== MallOrder::TYPE_BONUS) {
if ($order->mallItem) {
$item = $order->mallItem;
} else {
$item = MallItem::where('id', $order->mall_item_id)->find();
}
if ($order->type === MallPlayxOrder::TYPE_BONUS) {
$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;
}
$requestId = 'mall_retry_bonus_' . uniqid();
$res = $this->http->post($bonusUrl, [
'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') {
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
$order->save();
return;
}
$order->fail_reason = strval($data['message'] ?? 'PlayX bonus grant not accepted');
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->save();
return; return;
} }
if ($order->type === MallPlayxOrder::TYPE_WITHDRAW) { $result = MallBonusGrantPush::push($order);
$multiplier = intval($order->multiplier ?? 0); if ($result['ok']) {
if ($multiplier <= 0) { $order->grant_status = MallOrder::GRANT_ACCEPTED;
$multiplier = 1; $order->playx_transaction_id = $result['playx_transaction_id'];
}
$requestId = 'mall_retry_withdraw_' . uniqid();
$res = $this->http->post($withdrawUrl, [
'json' => [
'request_id' => $requestId,
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $order->user_id,
'amount' => $order->amount,
'multiplier' => $multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
$order->save();
return;
}
$order->fail_reason = strval($data['message'] ?? 'PlayX balance credit not accepted');
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->save(); $order->save();
return; return;
} }
// PHYSICAL 目前由后台手工发货/驳回,不参与 PlayX 发放重试 $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(MallPlayxOrder $order): void private function refundPoints(MallOrder $order): void
{ {
if ($order->points_cost <= 0) { if ($order->points_cost <= 0) {
return; return;
} }
$asset = MallPlayxUserAsset::where('playx_user_id', $order->user_id)->find(); $asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset) { if (!$asset) {
return; return;
} }

View File

@@ -40,7 +40,8 @@
"nelexa/zip": "^4.0.0", "nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1", "voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0", "topthink/think-validate": "^3.0",
"firebase/php-jwt": "^7.0" "firebase/php-jwt": "^7.0",
"guzzlehttp/guzzle": "^7.10"
}, },
"suggest": { "suggest": {
"ext-event": "For better performance. " "ext-event": "For better performance. "

View File

@@ -13,7 +13,7 @@ return [
// Daily Push 签名校验PlayX 调用商城时使用) // Daily Push 签名校验PlayX 调用商城时使用)
'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')), 'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')),
/** /**
* 合作方 JWT 验签密钥HS256。非空时daily-push 等回调需带 Authorization: Bearer * 合作方 JWT 验签密钥HS256。非空时dailyPush 等回调需带 Authorization: Bearer
* 仅写入部署环境变量,勿提交仓库。 * 仅写入部署环境变量,勿提交仓库。
*/ */
'partner_jwt_secret' => strval(env('PLAYX_PARTNER_JWT_SECRET', '')), 'partner_jwt_secret' => strval(env('PLAYX_PARTNER_JWT_SECRET', '')),
@@ -33,4 +33,22 @@ return [
'balance_credit_url' => '/api/v1/balance/credit', 'balance_credit_url' => '/api/v1/balance/credit',
'transaction_status_url' => '/api/v1/transaction/status', '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',
],
]; ];

View File

@@ -15,6 +15,7 @@
use support\Log; use support\Log;
use support\Request; use support\Request;
use app\process\Http; use app\process\Http;
use app\process\AngpowImportJobs;
global $argv; global $argv;
@@ -65,4 +66,9 @@ return [
'handler' => app\process\PlayxJobs::class, 'handler' => app\process\PlayxJobs::class,
'reloadable' => false, 'reloadable' => false,
], ],
// Angpow 导入推送任务:订单兑换后推送到对方平台
'angpow_import_jobs' => [
'handler' => AngpowImportJobs::class,
'reloadable' => false,
],
]; ];

View File

@@ -113,15 +113,19 @@ Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken'
Route::get('/api/v1/temLogin', [\app\api\controller\v1\Auth::class, 'temLogin']); Route::get('/api/v1/temLogin', [\app\api\controller\v1\Auth::class, 'temLogin']);
// api/v1 PlayX 积分商城 // api/v1 PlayX 积分商城
Route::post('/api/v1/playx/daily-push', [\app\api\controller\v1\Playx::class, 'dailyPush']); Route::post('/api/v1/mall/dailyPush', [\app\api\controller\v1\Playx::class, 'dailyPush']);
Route::post('/api/v1/playx/verify-token', [\app\api\controller\v1\Playx::class, 'verifyToken']); Route::post('/api/v1/mall/verifyToken', [\app\api\controller\v1\Playx::class, 'verifyToken']);
Route::get('/api/v1/playx/assets', [\app\api\controller\v1\Playx::class, 'assets']); Route::get('/api/v1/mall/assets', [\app\api\controller\v1\Playx::class, 'assets']);
Route::post('/api/v1/playx/claim', [\app\api\controller\v1\Playx::class, 'claim']); Route::post('/api/v1/mall/claim', [\app\api\controller\v1\Playx::class, 'claim']);
Route::get('/api/v1/playx/items', [\app\api\controller\v1\Playx::class, 'items']); Route::get('/api/v1/mall/items', [\app\api\controller\v1\Playx::class, 'items']);
Route::post('/api/v1/playx/bonus/redeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']); Route::post('/api/v1/mall/bonusRedeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']);
Route::post('/api/v1/playx/physical/redeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']); Route::post('/api/v1/mall/physicalRedeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']);
Route::post('/api/v1/playx/withdraw/apply', [\app\api\controller\v1\Playx::class, 'withdrawApply']); Route::post('/api/v1/mall/withdrawApply', [\app\api\controller\v1\Playx::class, 'withdrawApply']);
Route::get('/api/v1/playx/orders', [\app\api\controller\v1\Playx::class, 'orders']); 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 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容 // Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容

View File

@@ -0,0 +1,205 @@
# H5 积分商城接口文档(含流程说明)
> 面向H5活动页/积分商城前台)调用
> 基础路径:`/api/v1`
> 返回结构BuildAdmin 通用 `code/msg/time/data`(成功 `code=1`
---
## 1. 总体流程说明
### 1.1 流程 AH5 临时登录(推荐)
适用场景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 流程 BPlayX 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` tokenH5 临时登录签发)
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`

View File

@@ -13,19 +13,25 @@
### 1.1 Daily Push API ### 1.1 Daily Push API
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/daily-push` * 路径:`/api/v1/mall/dailyPush`
#### Header签名校验:可选) #### Header多语言,可选)
`playx.daily_push_secret` 配置非空时,需要携带: - `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文
#### Header签名校验HMAC 必填)
`playx.daily_push_secret` 配置非空时需要携带HMAC
- `X-Request-Id`:请求 ID - `X-Request-Id`:请求 ID
- `X-Timestamp`:时间戳 - `X-Timestamp`:时间戳
- `X-Signature`签名HMAC_SHA256 - `X-Signature`签名HMAC_SHA256
服务端签名计算: 服务端签名计算:
- `canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/playx/daily-push\n" + sha256(json_body)` - `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)` - `expected = hash_hmac('sha256', canonical, daily_push_secret)`
- 校验:`hash_equals(expected, X-Signature)` - 校验:`hash_equals(expected, X-Signature)`
说明:
- 本项目对接方案为 **仅启用 HMAC**,不使用 `Authorization` 头做校验。
#### Body #### Body
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -98,7 +104,7 @@
#### 示例(未开启签名校验) #### 示例(未开启签名校验)
请求: 请求:
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"request_id":"req_1001", "request_id":"req_1001",
@@ -130,7 +136,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
#### 示例(新版批量上报) #### 示例(新版批量上报)
请求: 请求:
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"report_date": "1700000000", "report_date": "1700000000",
@@ -175,7 +181,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
## 2. PlayX -> 积分商城(商城调用 PlayX ## 2. PlayX -> 积分商城(商城调用 PlayX
> 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果。 > 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果。
> **说明**H5 调商城的 **`/api/v1/playx/verify-token`** 在配置 **`playx.verify_token_local_only=true`**(默认)时**不会请求**本节接口,而是在商城内校验 `muser` token远程对接 PlayX 时见 **3.3** 与下文 **2.1**。 > **说明**H5 调商城的 **`/api/v1/mall/verifyToken`** 在配置 **`playx.verify_token_local_only=true`**(默认)时**不会请求**本节接口,而是在商城内校验 `muser` token远程对接 PlayX 时见 **3.3** 与下文 **2.1**。
### 2.1 Token Verification APIPlayX 侧实现,远程验证时使用) ### 2.1 Token Verification APIPlayX 侧实现,远程验证时使用)
* 方法:`POST` * 方法:`POST`
@@ -400,7 +406,7 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
### 3.3 Token 验证(换 session ### 3.3 Token 验证(换 session
* 方法:`POST`(推荐 `GET``token` 亦可) * 方法:`POST`(推荐 `GET``token` 亦可)
* 路径:`/api/v1/playx/verify-token` * 路径:`/api/v1/mall/verifyToken`
#### 配置:本地验证 vs 远程 PlayX #### 配置:本地验证 vs 远程 PlayX
@@ -436,11 +442,48 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
#### 示例(本地验证) #### 示例(本地验证)
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \ curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=上一步TemLogin返回的token' --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` #### 远程模式(`verify_token_local_only=false` + 已配置 `base_url`
商城侧请求 URL`${playx.api.base_url}${playx.api.token_verify_url}`(默认路径 `/api/v1/auth/verify-token`)。 商城侧请求 URL`${playx.api.base_url}${playx.api.token_verify_url}`(默认路径 `/api/v1/auth/verify-token`)。
@@ -476,7 +519,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
### 3.4 用户资产Assets ### 3.4 用户资产Assets
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/assets` * 路径:`/api/v1/mall/assets`
#### 请求参数(鉴权) #### 请求参数(鉴权)
@@ -498,7 +541,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
#### 示例 #### 示例
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'token=上一步temLogin返回的token' curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'token=上一步temLogin返回的token'
``` ```
响应(示例): 响应(示例):
@@ -521,7 +564,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'token=上
### 3.5 领取Claim ### 3.5 领取Claim
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/claim` * 路径:`/api/v1/mall/claim`
#### 请求 Body #### 请求 Body
必填: 必填:
@@ -538,7 +581,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'token=上
#### 示例 #### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/claim' \ curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'claim_request_id=claim_001' \ --data-urlencode 'claim_request_id=claim_001' \
--data-urlencode 'token=上一步temLogin返回的token' --data-urlencode 'token=上一步temLogin返回的token'
@@ -580,7 +623,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
### 3.6 商品列表 ### 3.6 商品列表
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/items` * 路径:`/api/v1/mall/items`
#### 请求参数 #### 请求参数
* `type`(可选):`BONUS` | `PHYSICAL` | `WITHDRAW` * `type`(可选):`BONUS` | `PHYSICAL` | `WITHDRAW`
@@ -593,7 +636,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
#### 示例 #### 示例
请求: 请求:
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDRAW' curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=WITHDRAW'
``` ```
响应(示例): 响应(示例):
@@ -623,7 +666,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDR
### 3.7 红利兑换Bonus Redeem ### 3.7 红利兑换Bonus Redeem
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/bonus/redeem` * 路径:`/api/v1/mall/bonusRedeem`
#### 请求 Body #### 请求 Body
必填: 必填:
@@ -637,7 +680,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDR
#### 示例 #### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \ curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=123' \ --data-urlencode 'item_id=123' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
@@ -660,14 +703,12 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
### 3.8 实物兑换Physical Redeem ### 3.8 实物兑换Physical Redeem
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/physical/redeem` * 路径:`/api/v1/mall/physicalRedeem`
#### 请求 Body #### 请求 Body
必填: 必填:
* `item_id`:商品 ID要求 `mall_item.type=PHYSICAL``status=1` * `item_id`:商品 ID要求 `mall_item.type=PHYSICAL``status=1`
* `receiver_name`:收货人 * `address_id`:收货地址 ID`mall_address.id`,须属于当前用户资产;下单时写入 `mall_order.mall_address_id`,并将该地址快照写入 `receiver_name` / `receiver_phone` / `receiver_address`
* `receiver_phone`:收货电话
* `receiver_address`:收货地址
鉴权:同 **3.1**`session_id` / `token` / `user_id` 鉴权:同 **3.1**`session_id` / `token` / `user_id`
#### 返回(成功) #### 返回(成功)
@@ -676,12 +717,10 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
#### 示例 #### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \ curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=200' \ --data-urlencode 'item_id=200' \
--data-urlencode 'receiver_name=张三' \ --data-urlencode 'address_id=10' \
--data-urlencode 'receiver_phone=18800001111' \
--data-urlencode 'receiver_address=北京市朝阳区XX路XX号' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
``` ```
@@ -699,7 +738,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
### 3.9 提现申请Withdraw Apply ### 3.9 提现申请Withdraw Apply
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/withdraw/apply` * 路径:`/api/v1/mall/withdrawApply`
#### 请求 Body #### 请求 Body
必填: 必填:
@@ -713,7 +752,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
#### 示例 #### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \ curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=321' \ --data-urlencode 'item_id=321' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
@@ -736,7 +775,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
### 3.10 订单列表 ### 3.10 订单列表
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/orders` * 路径:`/api/v1/mall/orders`
#### 请求参数(鉴权) #### 请求参数(鉴权)
@@ -750,7 +789,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
#### 示例 #### 示例
请求: 请求:
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'token=上一步temLogin返回的token' curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'token=上一步temLogin返回的token'
``` ```
响应(示例,简化): 响应(示例,简化):
@@ -785,5 +824,5 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'token=上
--- ---
### 3.11 同步额度(可选) ### 3.11 同步额度(可选)
当前代码未实现并未注册路由:`/api/v1/playx/sync-limit` 当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`

View 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/dailyPushJSON 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以部署环境变量与最新文档为准。

View File

@@ -11,7 +11,7 @@
接收 PlayX 每日 T+1 数据推送。 接收 PlayX 每日 T+1 数据推送。
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/daily-push` * 路径:`/api/v1/mall/dailyPush`
##### 请求Header ##### 请求Header
当配置了 `playx.daily_push_secret`Daily Push 签名校验)时,需要携带: 当配置了 `playx.daily_push_secret`Daily Push 签名校验)时,需要携带:
@@ -20,7 +20,7 @@
* `X-Signature`签名HMAC_SHA256 * `X-Signature`签名HMAC_SHA256
签名计算逻辑(服务端): 签名计算逻辑(服务端):
* canonical`{X-Timestamp}\n{X-Request-Id}\nPOST\n/api/v1/playx/daily-push\n{sha256(json_body)}` * 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)` * expected`hash_hmac('sha256', canonical, daily_push_secret)`
##### 请求Body ##### 请求Body
@@ -51,7 +51,7 @@
##### 示例 ##### 示例
无签名校验(`PLAYX_DAILY_PUSH_SECRET` 为空): 无签名校验(`PLAYX_DAILY_PUSH_SECRET` 为空):
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{ -d '{
"request_id":"req_1001", "request_id":"req_1001",
@@ -246,7 +246,7 @@ curl -G '${playx.api.base_url}/api/v1/transaction/status' \
### 1.3 商城内部 API供 H5 前端调用) ### 1.3 商城内部 API供 H5 前端调用)
#### Token 验证 #### Token 验证
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/verify-token` * 路径:`/api/v1/mall/verifyToken`
请求Body 请求Body
* `token`(必填,优先读取) * `token`(必填,优先读取)
@@ -260,14 +260,14 @@ curl -G '${playx.api.base_url}/api/v1/transaction/status' \
示例: 示例:
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \ curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=PLAYX_TOKEN_XXX' --data-urlencode 'token=PLAYX_TOKEN_XXX'
``` ```
#### 用户资产 #### 用户资产
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/assets` * 路径:`/api/v1/mall/assets`
请求参数(二选一): 请求参数(二选一):
* `session_id`(优先):从 `mall_playx_session` 查 user_id并校验过期 * `session_id`(优先):从 `mall_playx_session` 查 user_id并校验过期
@@ -282,7 +282,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
##### 示例 ##### 示例
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id=7b1c....' curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'session_id=7b1c....'
``` ```
```json ```json
@@ -301,7 +301,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
#### 领取Claim #### 领取Claim
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/claim` * 路径:`/api/v1/mall/claim`
请求: 请求:
* `claim_request_id`幂等键string必填且唯一 * `claim_request_id`幂等键string必填且唯一
@@ -312,7 +312,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
##### 示例 ##### 示例
(首次领取成功,可能返回 `msg=Claim success`;若幂等重复,`msg` 可能为空) (首次领取成功,可能返回 `msg=Claim success`;若幂等重复,`msg` 可能为空)
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/claim' \ curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'claim_request_id=claim_001' \ --data-urlencode 'claim_request_id=claim_001' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
@@ -334,7 +334,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
#### 商品列表 #### 商品列表
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/items` * 路径:`/api/v1/mall/items`
请求(可选): 请求(可选):
* `type=BONUS|PHYSICAL|WITHDRAW` * `type=BONUS|PHYSICAL|WITHDRAW`
@@ -344,7 +344,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
##### 示例 ##### 示例
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS' curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=BONUS'
``` ```
```json ```json
@@ -370,7 +370,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS'
#### 红利兑换Bonus Redeem #### 红利兑换Bonus Redeem
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/bonus/redeem` * 路径:`/api/v1/mall/bonusRedeem`
请求: 请求:
* `item_id`:商品 IDBONUS * `item_id`:商品 IDBONUS
@@ -382,7 +382,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS'
##### 示例 ##### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \ curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=123' \ --data-urlencode 'item_id=123' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
@@ -401,13 +401,11 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
#### 实物兑换Physical Redeem #### 实物兑换Physical Redeem
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/physical/redeem` * 路径:`/api/v1/mall/physicalRedeem`
请求: 请求:
* `item_id`:商品 IDPHYSICAL * `item_id`:商品 IDPHYSICAL
* `receiver_name` * `address_id``mall_address.id`(当前用户资产下地址;订单写入 `mall_address_id` 与收货快照)
* `receiver_phone`
* `receiver_address`
* 鉴权:`session_id``user_id` * 鉴权:`session_id``user_id`
成功返回: 成功返回:
@@ -416,12 +414,10 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
##### 示例 ##### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \ curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=200' \ --data-urlencode 'item_id=200' \
--data-urlencode 'receiver_name=张三' \ --data-urlencode 'address_id=10' \
--data-urlencode 'receiver_phone=18800001111' \
--data-urlencode 'receiver_address=北京市朝阳区XX路XX号' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
``` ```
@@ -435,7 +431,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
#### 提现申请Withdraw Apply #### 提现申请Withdraw Apply
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/withdraw/apply` * 路径:`/api/v1/mall/withdrawApply`
请求: 请求:
* `item_id`:商品 IDWITHDRAW * `item_id`:商品 IDWITHDRAW
@@ -447,7 +443,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
##### 示例 ##### 示例
```bash ```bash
curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \ curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=321' \ --data-urlencode 'item_id=321' \
--data-urlencode 'session_id=7b1c....' --data-urlencode 'session_id=7b1c....'
@@ -466,7 +462,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
#### 订单列表 #### 订单列表
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/orders` * 路径:`/api/v1/mall/orders`
请求: 请求:
* `session_id``user_id` * `session_id``user_id`
@@ -476,7 +472,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
##### 示例 ##### 示例
```bash ```bash
curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id=7b1c....' curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=7b1c....'
``` ```
```json ```json
@@ -502,7 +498,7 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
``` ```
#### 同步额度 #### 同步额度
当前代码未实现并未注册路由:`/api/v1/playx/sync-limit` 当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`
如需补齐,请在接口设计阶段新增对应实现与 PlayX API 对接。 如需补齐,请在接口设计阶段新增对应实现与 PlayX API 对接。
--- ---
@@ -530,7 +526,7 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
- 发货:录入物流公司、单号 → `SHIPPED` - 发货:录入物流公司、单号 → `SHIPPED`
- 驳回:录入驳回原因 → `REJECTED`,自动退回积分 - 驳回:录入驳回原因 → `REJECTED`,自动退回积分
- **红利/提现订单** - **红利/提现订单**
- 展示 `external_transaction_id``playx_transaction_id`发放子状态 - 展示 `external_transaction_id``playx_transaction_id`推送playx
- 手动重试:仅对 `FAILED_RETRYABLE` 状态,记录 `retry_request_id`、操作者、原因 - 手动重试:仅对 `FAILED_RETRYABLE` 状态,记录 `retry_request_id`、操作者、原因
### 2.3 用户资产与人工调账 ### 2.3 用户资产与人工调账
@@ -607,29 +603,30 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
**表名**`mall_playx_order`(或统一改造 mall_pints_order / mall_redemption_order **表名**`mall_playx_order`(或统一改造 mall_pints_order / mall_redemption_order
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|----------------------------------------------------------------------|
| id | int | 主键 | | id | int | 主键 |
| user_id | varchar(64) | 用户 ID | | user_id | varchar(64) | 用户 ID |
| type | enum | BONUS / PHYSICAL / WITHDRAW | | type | enum | BONUS / PHYSICAL / WITHDRAW |
| status | enum | PENDING / COMPLETED / SHIPPED / REJECTED | | status | enum | PENDING / COMPLETED / SHIPPED / REJECTED |
| mall_item_id | int | 商品 ID | | mall_item_id | int | 商品 ID |
| points_cost | int | 消耗积分 | | points_cost | int | 消耗积分 |
| amount | decimal(15,2) | 现金面值(红利/提现) | | amount | decimal(15,2) | 现金面值(红利/提现) |
| multiplier | int | 流水倍数 | | multiplier | int | 流水倍数 |
| external_transaction_id | varchar(64) | 外部交易幂等键 | | external_transaction_id | varchar(64) | 订单号 |
| playx_transaction_id | varchar(64) | PlayX 流水号 | | playx_transaction_id | varchar(64) | PlayX 流水号 |
| grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL | | grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL |
| fail_reason | text | 失败原因 | | fail_reason | text | 失败原因 |
| retry_count | int | 重试次数 | | retry_count | int | 重试次数 |
| reject_reason | varchar(255) | 驳回原因(实物) | | reject_reason | varchar(255) | 驳回原因(实物) |
| shipping_company | varchar(50) | 物流公司 | | shipping_company | varchar(50) | 物流公司 |
| shipping_no | varchar(64) | 物流单号 | | shipping_no | varchar(64) | 物流单号 |
| receiver_name | varchar(50) | 收货人 | | mall_address_id | int unsigned, NULL | 实物兑换所选 `mall_address.id`(快照仍见 `receiver_*` |
| receiver_phone | varchar(20) | 收货电话 | | receiver_name | varchar(50) | 收货 |
| receiver_address | text | 收货地址 | | receiver_phone | varchar(20) | 收货电话 |
| create_time | bigint | 创建时间 | | receiver_address | text | 收货地址 |
| update_time | bigint | 更新时间 | | create_time | bigint | 创建时间 |
| update_time | bigint | 更新时间 |
**索引**`user_id``external_transaction_id``type``status` **索引**`user_id``external_transaction_id``type``status`

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

View File

@@ -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

View File

@@ -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
View 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";

View File

@@ -1,7 +1,10 @@
/** /**
* backend common language package * backend common language package
*/ */
import menu from './en/menu'
export default { export default {
menu,
Balance: 'Balance', Balance: 'Balance',
Integral: 'Integral', Integral: 'Integral',
Connection: 'connection', Connection: 'connection',

View File

@@ -36,4 +36,23 @@ export default {
second: 'Second', second: 'Second',
day: 'Day', day: 'Day',
'Number of attachments Uploaded': 'Number of attachments upload', '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',
} }

View File

@@ -2,12 +2,12 @@ export default {
id: 'id', id: 'id',
playx_user_asset_id: 'PlayX user asset', playx_user_asset_id: 'PlayX user asset',
playxuserasset__username: 'username', playxuserasset__username: 'username',
receiver_name: 'receiver name',
phone: 'phone', phone: 'phone',
region: 'region', region: 'region',
detail_address: 'detail_address', detail_address: 'detail_address',
address: 'address',
default_setting: 'Default address', default_setting: 'Default address',
'default_setting 0': 'NO', 'default_setting 0': '--',
'default_setting 1': 'YES', 'default_setting 1': 'YES',
create_time: 'create_time', create_time: 'create_time',
update_time: 'update_time', update_time: 'update_time',

View 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',
}

View 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',
}

View 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',
}

View File

@@ -23,6 +23,7 @@ export default {
'grant_status ACCEPTED': 'ACCEPTED', 'grant_status ACCEPTED': 'ACCEPTED',
'grant_status FAILED_RETRYABLE': 'FAILED_RETRYABLE', 'grant_status FAILED_RETRYABLE': 'FAILED_RETRYABLE',
'grant_status FAILED_FINAL': 'FAILED_FINAL', 'grant_status FAILED_FINAL': 'FAILED_FINAL',
'grant_status ---': '---',
fail_reason: 'fail_reason', fail_reason: 'fail_reason',
reject_reason: 'reject_reason', reject_reason: 'reject_reason',
shipping_company: 'shipping_company', shipping_company: 'shipping_company',

View 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',
}

View 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',
},
}

View File

@@ -2,7 +2,10 @@
* 后台公共语言包 * 后台公共语言包
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key * 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key
*/ */
import menu from './zh-cn/menu'
export default { export default {
menu,
Balance: '余额', Balance: '余额',
Integral: '积分', Integral: '积分',
Connection: '连接标识', Connection: '连接标识',

View File

@@ -36,4 +36,23 @@ export default {
second: '秒', second: '秒',
day: '天', day: '天',
'Number of attachments Uploaded': '附件上传量', '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': '现金面值合计',
} }

View File

@@ -2,12 +2,12 @@ export default {
id: 'ID', id: 'ID',
playx_user_asset_id: '用户资产', playx_user_asset_id: '用户资产',
playxuserasset__username: '用户名', playxuserasset__username: '用户名',
receiver_name: '收货人',
phone: '电话', phone: '电话',
region: '地区', region: '地区',
detail_address: '详细地址', detail_address: '详细地址',
address: '地址',
default_setting: '默认地址', default_setting: '默认地址',
'default_setting 0': '', 'default_setting 0': '--',
'default_setting 1': '是', 'default_setting 1': '是',
create_time: '创建时间', create_time: '创建时间',
update_time: '修改时间', update_time: '修改时间',

View File

@@ -0,0 +1,9 @@
export default {
id: 'ID',
claim_request_id: '领取订单号',
user_id: '用户ID',
claimed_amount: '领取积分',
create_time: '创建时间',
'quick Search Fields': 'ID',
}

View 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',
}

View 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',
}

View File

@@ -1,6 +1,6 @@
export default { export default {
title: 'PlayX 对接中心', title: 'PlayX 对接中心',
desc: '集中管理积分商城与 PlayX 的订单、推送、领取与资产数据。建议优先处理“发放子状态=失败可重试”的订单。', desc: '集中管理积分商城与 PlayX 的订单、推送、领取与资产数据。建议优先处理“推送playx状态=失败可重试”的订单。',
orders: '统一订单', orders: '统一订单',
dailyPush: '每日推送', dailyPush: '每日推送',
claimLog: '领取记录', claimLog: '领取记录',

View File

@@ -1,6 +1,6 @@
export default { export default {
id: 'ID', id: 'ID',
claim_request_id: '领取幂等键', claim_request_id: '领取订单号',
user_id: '用户ID', user_id: '用户ID',
claimed_amount: '领取积分', claimed_amount: '领取积分',
create_time: '创建时间', create_time: '创建时间',

View File

@@ -15,14 +15,15 @@ export default {
points_cost: '消耗积分', points_cost: '消耗积分',
amount: '现金面值', amount: '现金面值',
multiplier: '流水倍数', multiplier: '流水倍数',
external_transaction_id: '外部交易幂等键', external_transaction_id: '订单号',
playx_transaction_id: 'PlayX流水号', playx_transaction_id: 'PlayX流水号',
grant_status: '发放子状态', grant_status: '推送playx状态',
'grant_status NOT_SENT': '未发送', 'grant_status NOT_SENT': '未发送',
'grant_status SENT_PENDING': '已发送排队', 'grant_status SENT_PENDING': '已发送排队',
'grant_status ACCEPTED': '已接收(accepted)', 'grant_status ACCEPTED': '已接收(accepted)',
'grant_status FAILED_RETRYABLE': '失败可重试', 'grant_status FAILED_RETRYABLE': '失败可重试',
'grant_status FAILED_FINAL': '失败最终', 'grant_status FAILED_FINAL': '失败最终',
'grant_status ---': '---',
fail_reason: '失败原因', fail_reason: '失败原因',
reject_reason: '驳回原因', reject_reason: '驳回原因',
shipping_company: '物流公司', shipping_company: '物流公司',

View 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、用户名、手机号',
}

View 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: '兑换订单',
},
}

View File

@@ -4,7 +4,7 @@
<el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)"> <el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<template #title> <template #title>
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" /> <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> </template>
<MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" /> <MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" />
</el-sub-menu> </el-sub-menu>
@@ -12,7 +12,7 @@
<template v-else> <template v-else>
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)"> <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" /> <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> </el-menu-item>
</template> </template>
</template> </template>
@@ -23,6 +23,7 @@ import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { menuTitleFromRoute } from '/@/utils/menuI18n'
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router' import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -9,7 +9,7 @@
:ref="tabsRefs.set" :ref="tabsRefs.set"
:key="idx" :key="idx"
> >
{{ item.meta.title }} {{ menuTitleFromRoute(item) }}
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in"> <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" /> <Icon v-show="navTabs.state.tabsView.length > 1" class="close-icon" @click.stop="closeTab(item)" size="15" name="el-icon-Close" />
</transition> </transition>
@@ -29,6 +29,7 @@ import type { ContextMenuItem, ContextMenuItemClickEmitArg } from '/@/components
import useCurrentInstance from '/@/utils/useCurrentInstance' import useCurrentInstance from '/@/utils/useCurrentInstance'
import Contextmenu from '/@/components/contextmenu/index.vue' import Contextmenu from '/@/components/contextmenu/index.vue'
import horizontalScroll from '/@/utils/horizontalScroll' import horizontalScroll from '/@/utils/horizontalScroll'
import { menuTitleFromRoute } from '/@/utils/menuI18n'
import { getFirstRoute, routePush } from '/@/utils/router' import { getFirstRoute, routePush } from '/@/utils/router'
import { adminBaseRoutePath } from '/@/router/static/adminBase' import { adminBaseRoutePath } from '/@/router/static/adminBase'

33
web/src/utils/menuI18n.ts Normal file
View 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'))
}

View File

@@ -28,119 +28,88 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :sm="12" :lg="6"> <el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension"> <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="small-panel-content">
<div class="content-left"> <div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-line-chart" /> <Icon color="#8595F4" size="20" name="fa fa-line-chart" />
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" /> <el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
</div> </div>
<div class="content-right">+14%</div> <div class="content-right color-info">{{ t('dashboard.Today') }}</div>
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :lg="6"> <el-col :sm="12" :lg="6">
<div class="small-panel file suspension"> <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="small-panel-content">
<div class="content-left"> <div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-file-text" /> <Icon color="#AD85F4" size="20" name="fa fa-file-text" />
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" /> <el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
</div> </div>
<div class="content-right">+50%</div> <div class="content-right color-info">{{ t('dashboard.Yesterday') }}</div>
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :lg="6"> <el-col :sm="12" :lg="6">
<div class="small-panel users suspension"> <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="small-panel-content">
<div class="content-left"> <div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-users" /> <Icon color="#74A8B5" size="20" name="fa fa-users" />
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" /> <el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
</div> </div>
<div class="content-right">+28%</div> <div class="content-right color-info">{{ t('dashboard.Orders') }}</div>
</div> </div>
</div> </div>
</el-col> </el-col>
<el-col :sm="12" :lg="6"> <el-col :sm="12" :lg="6">
<div class="small-panel addons suspension"> <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="small-panel-content">
<div class="content-left"> <div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-object-group" /> <Icon color="#F48595" size="20" name="fa fa-object-group" />
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" /> <el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
</div> </div>
<div class="content-right">+88%</div> <div class="content-right color-info">{{ t('dashboard.Pending') }}</div>
</div> </div>
</div> </div>
</el-col> </el-col>
</el-row> </el-row>
</div> </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"> <div class="growth-chart">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12"> <el-col :xs="24" :sm="24" :md="24" :lg="24">
<el-card shadow="hover" :header="t('dashboard.Member source')"> <el-card shadow="hover" :header="t('dashboard.Yesterday item redeem stat')">
<div class="user-source-chart" :ref="chartRefs.set"></div> <div class="playx-kpis">
</el-card> <div class="playx-kpi">
</el-col> <div class="playx-kpi-title">{{ t('dashboard.Yesterday redeem points sum') }}</div>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12"> <div class="playx-kpi-value">{{ state.playx?.yesterday_redeem?.points_cost_sum ?? 0 }}</div>
<el-card shadow="hover" :header="t('dashboard.Member last name')"> </div>
<div class="user-surname-chart" :ref="chartRefs.set"></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-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -149,17 +118,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core' import { useTransition } from '@vueuse/core'
import * as echarts from 'echarts' import { CSSProperties, onMounted, onUnmounted, reactive, toRefs } from 'vue'
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard' import { index } from '/@/api/backend/dashboard'
import coffeeSvg from '/@/assets/dashboard/coffee.svg' import coffeeSvg from '/@/assets/dashboard/coffee.svg'
import headerSvg from '/@/assets/dashboard/header-1.svg' import headerSvg from '/@/assets/dashboard/header-1.svg'
import { useAdminInfo } from '/@/stores/adminInfo' import { useAdminInfo } from '/@/stores/adminInfo'
import { WORKING_TIME } from '/@/stores/constant/cacheKey' import { WORKING_TIME } from '/@/stores/constant/cacheKey'
import { useNavTabs } from '/@/stores/navTabs' import { getGreet } from '/@/utils/common'
import { fullUrl, getGreet } from '/@/utils/common'
import { Local } from '/@/utils/storage' import { Local } from '/@/utils/storage'
let workTimer: number let workTimer: number
@@ -169,20 +136,20 @@ defineOptions({
const d = new Date() const d = new Date()
const { t } = useI18n() const { t } = useI18n()
const navTabs = useNavTabs()
const adminInfo = useAdminInfo() const adminInfo = useAdminInfo()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const state: { const state: {
charts: any[]
remark: string remark: string
workingTimeFormat: string workingTimeFormat: string
pauseWork: boolean pauseWork: boolean
playx: any | null
playxLoading: boolean
} = reactive({ } = reactive({
charts: [],
remark: 'dashboard.Loading', remark: 'dashboard.Loading',
workingTimeFormat: '', workingTimeFormat: '',
pauseWork: false, pauseWork: false,
playx: null,
playxLoading: true,
}) })
/** /**
@@ -206,301 +173,20 @@ const statisticValueStyle: CSSProperties = {
index().then((res) => { index().then((res) => {
state.remark = res.data.remark state.remark = res.data.remark
state.playx = res.data.playx ?? null
state.playxLoading = false
initCountUp()
}).catch(() => {
state.playxLoading = false
}) })
const initCountUp = () => { const initCountUp = () => {
// 虚拟数据 const playx = state.playx ?? {}
countUpRefs.userRegNumber.value = 5456 const yesterdayRedeem = playx.yesterday_redeem ?? {}
countUpRefs.fileNumber.value = 1234 countUpRefs.userRegNumber.value = playx.new_players_today ?? 0
countUpRefs.usersNumber.value = 9486 countUpRefs.fileNumber.value = playx.yesterday_points_claimed ?? 0
countUpRefs.addonsNumber.value = 875 countUpRefs.usersNumber.value = yesterdayRedeem.order_count ?? 0
} countUpRefs.addonsNumber.value = playx.pending_physical_to_ship ?? 0
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 onChangeWorkState = () => { const onChangeWorkState = () => {
@@ -594,36 +280,13 @@ const formatSeconds = (seconds: number) => {
return result return result
} }
onActivated(() => {
echartsResize()
})
onMounted(() => { onMounted(() => {
startWork() startWork()
initCountUp()
initUserGrowthChart()
initFileGrowthChart()
initUserSourceChart()
initUserSurnameChart()
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
}) })
onUnmounted(() => { onUnmounted(() => {
clearInterval(workTimer) clearInterval(workTimer)
}) })
watch(
() => navTabs.state.tabFullScreen,
() => {
echartsResize()
}
)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -751,48 +414,27 @@ watch(
.growth-chart { .growth-chart {
margin-bottom: 20px; margin-bottom: 20px;
} }
.user-growth-chart, .playx-kpis {
.file-growth-chart { display: grid;
height: 260px; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
} }
.new-user-growth { .playx-kpi {
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);
background-color: var(--ba-bg-color-overlay); background-color: var(--ba-bg-color-overlay);
.new-user-avatar { border: 1px solid var(--ba-border-color);
height: 48px; border-radius: var(--el-border-radius-base);
width: 48px; padding: 12px;
border-radius: 50%;
}
.new-user-base {
margin-left: 10px;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
}
.new-user-time {
font-size: 13px;
}
}
.new-user-arrow {
margin-left: auto;
}
} }
.new-user-card :deep(.el-card__body) { .playx-kpi-title {
padding: 0; 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);
} }
@media screen and (max-width: 425px) { @media screen and (max-width: 425px) {
.welcome-img { .welcome-img {
display: none; display: none;

View File

@@ -37,6 +37,17 @@ const { t } = useI18n()
const tableRef = useTemplateRef('tableRef') const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']) 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 注入给了后代组件 * baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/ */
@@ -53,10 +64,19 @@ const baTable = new baTableClass(
align: 'center', align: 'center',
minWidth: 120, minWidth: 120,
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
render: 'tags', showOverflowTooltip: true,
operator: 'LIKE', operator: 'LIKE',
comSearchRender: 'string', 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'), label: t('mall.address.phone'),
prop: 'phone', prop: 'phone',
@@ -65,7 +85,17 @@ const baTable = new baTableClass(
sortable: false, sortable: false,
operator: 'LIKE', operator: 'LIKE',
}, },
{ label: t('mall.address.region'), prop: 'region_text', align: 'center', operator: false }, {
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'), label: t('mall.address.detail_address'),
prop: 'detail_address', prop: 'detail_address',
@@ -107,7 +137,7 @@ const baTable = new baTableClass(
width: 160, width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss', 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, 'default_setting'], dblClickNotEditColumn: [undefined, 'default_setting'],
}, },

View File

@@ -34,9 +34,16 @@
type="remoteSelect" type="remoteSelect"
v-model="baTable.form.items!.playx_user_asset_id" v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id" prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_playx_user_asset.id', field: 'username', remoteUrl: '/admin/mall.PlayxUserAsset/select' }" :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') })" :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!.receiver_name"
prop="receiver_name"
:placeholder="t('Please input field', { field: t('mall.address.receiver_name') })"
/>
<FormItem <FormItem
:label="t('mall.address.phone')" :label="t('mall.address.phone')"
type="string" type="string"
@@ -58,16 +65,6 @@
prop="detail_address" prop="detail_address"
:placeholder="t('Please input field', { field: t('mall.address.detail_address') })" :placeholder="t('Please input field', { field: t('mall.address.detail_address') })"
/> />
<FormItem
:label="t('mall.address.address')"
type="textarea"
v-model="baTable.form.items!.address"
prop="address"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.address.address') })"
/>
<FormItem <FormItem
:label="t('mall.address.default_setting')" :label="t('mall.address.default_setting')"
type="switch" type="switch"

View 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>

View 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>

View File

@@ -52,6 +52,8 @@ const baTable = new baTableClass(
label: t('mall.item.description'), label: t('mall.item.description'),
prop: 'description', prop: 'description',
align: 'center', align: 'center',
minWidth: 80,
// showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
sortable: false, sortable: false,
operator: 'LIKE', operator: 'LIKE',
@@ -60,6 +62,7 @@ const baTable = new baTableClass(
label: t('mall.item.score'), label: t('mall.item.score'),
prop: 'score', prop: 'score',
align: 'center', align: 'center',
minWidth: 90,
sortable: false, sortable: false,
operator: 'RANGE', operator: 'RANGE',
}, },
@@ -173,7 +176,7 @@ const baTable = new baTableClass(
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: 100, width: 80,
render: 'buttons', render: 'buttons',
buttons: optButtons, buttons: optButtons,
operator: false, operator: false,

View 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>

View 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>

View File

@@ -41,7 +41,7 @@
type="remoteSelect" type="remoteSelect"
v-model="baTable.form.items!.playx_user_asset_id" v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id" prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_playx_user_asset.id', field: 'username', remoteUrl: '/admin/mall.PlayxUserAsset/select' }" :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') })" :placeholder="t('Please select field', { field: t('mall.pintsOrder.playx_user_asset_id') })"
/> />
<FormItem <FormItem

View File

@@ -83,6 +83,7 @@ const baTable = new baTableClass(
ACCEPTED: t('mall.playxOrder.grant_status ACCEPTED'), ACCEPTED: t('mall.playxOrder.grant_status ACCEPTED'),
FAILED_RETRYABLE: t('mall.playxOrder.grant_status FAILED_RETRYABLE'), FAILED_RETRYABLE: t('mall.playxOrder.grant_status FAILED_RETRYABLE'),
FAILED_FINAL: t('mall.playxOrder.grant_status FAILED_FINAL'), FAILED_FINAL: t('mall.playxOrder.grant_status FAILED_FINAL'),
'---': t('mall.playxOrder.grant_status ---'),
}, },
}, },
{ {
@@ -116,7 +117,9 @@ const baTable = new baTableClass(
type: 'warning', type: 'warning',
icon: '', icon: '',
display: (row: TableRow) => display: (row: TableRow) =>
(row.type === 'BONUS' || row.type === 'WITHDRAW') && row.grant_status === 'FAILED_RETRYABLE' && row.status === 'PENDING', row.type === 'BONUS' &&
row.status === 'PENDING' &&
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
popconfirm: { popconfirm: {
title: '确认将该订单加入重试队列?', title: '确认将该订单加入重试队列?',
confirmButtonText: '确认', confirmButtonText: '确认',
@@ -126,7 +129,7 @@ const baTable = new baTableClass(
click: async (row: TableRow) => { click: async (row: TableRow) => {
await createAxios( await createAxios(
{ {
url: '/admin/mall.PlayxOrder/retry', url: '/admin/mall.Order/retry',
method: 'post', method: 'post',
data: { data: {
id: row.id, id: row.id,

View File

@@ -41,7 +41,7 @@
type="remoteSelect" type="remoteSelect"
v-model="baTable.form.items!.playx_user_asset_id" v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id" prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_playx_user_asset.id', field: 'username', remoteUrl: '/admin/mall.PlayxUserAsset/select' }" :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') })" :placeholder="t('Please select field', { field: t('mall.redemptionOrder.playx_user_asset_id') })"
/> />
<FormItem <FormItem

View 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', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.userAsset.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/userAsset',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/mall.UserAsset/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.userAsset.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.userAsset.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
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',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('mall.userAsset.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', fixed: 'right', width: 80, 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>

View 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>