Compare commits

..

10 Commits

49 changed files with 2575 additions and 487 deletions

View File

@@ -15,7 +15,7 @@ DATABASE_USERNAME = webman-buildadmin-mall
DATABASE_PASSWORD = 123456 DATABASE_PASSWORD = 123456
DATABASE_HOSTPORT = 3306 DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4 DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX = DATABASE_PREFIX =
# PlayX 配置 # PlayX 配置
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1 # 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1
@@ -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

@@ -66,7 +66,7 @@ class Dashboard extends Backend
$pendingPhysicalToShip = MallOrder::where('type', MallOrder::TYPE_PHYSICAL) $pendingPhysicalToShip = MallOrder::where('type', MallOrder::TYPE_PHYSICAL)
->where('status', MallOrder::STATUS_PENDING) ->where('status', MallOrder::STATUS_PENDING)
->count(); ->count();
$grantFailedRetryableCount = MallOrder::whereIn('type', [MallOrder::TYPE_BONUS, MallOrder::TYPE_WITHDRAW]) $grantFailedRetryableCount = MallOrder::where('type', MallOrder::TYPE_BONUS)
->where('grant_status', MallOrder::GRANT_FAILED_RETRYABLE) ->where('grant_status', MallOrder::GRANT_FAILED_RETRYABLE)
->count(); ->count();

View File

@@ -4,6 +4,7 @@ namespace app\admin\controller\mall;
use Throwable; use Throwable;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\library\MallBonusGrantPush;
use app\common\model\MallOrder; use app\common\model\MallOrder;
use app\common\model\MallUserAsset; use app\common\model\MallUserAsset;
use support\think\Db; use support\think\Db;
@@ -46,6 +47,7 @@ class Order 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',
]; ];
@@ -139,7 +141,52 @@ class Order 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
{ {
@@ -156,7 +203,7 @@ class Order extends Backend
$id = $data['id'] ?? 0; $id = $data['id'] ?? 0;
$rejectReason = $data['reject_reason'] ?? ''; $rejectReason = $data['reject_reason'] ?? '';
if (!$id || $rejectReason === '') { if (!$id) {
return $this->error(__('Missing required fields')); return $this->error(__('Missing required fields'));
} }
@@ -164,13 +211,14 @@ class Order extends Backend
if (!$order) { if (!$order) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
} }
if ($order->type !== MallOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not PHYSICAL'));
}
if ($order->status !== MallOrder::STATUS_PENDING) { if ($order->status !== MallOrder::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 = MallUserAsset::where('playx_user_id', $order->user_id ?? '')->find(); $asset = MallUserAsset::where('playx_user_id', $order->user_id ?? '')->find();
@@ -186,7 +234,12 @@ class Order extends Backend
$order->status = MallOrder::STATUS_REJECTED; $order->status = MallOrder::STATUS_REJECTED;
$order->reject_reason = $rejectReason; $order->reject_reason = $rejectReason;
$order->grant_status = MallOrder::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 Order extends Backend
} }
/** /**
* 手动重试(仅红利/提现,且必须 FAILED_RETRYABLE * 手动推送红利(同步调用 PlayX不限制自动重试次数成功则 ACCEPTED失败写入 fail_reason
*/ */
public function retry(Request $request): Response public function retry(Request $request): Response
{ {
@@ -221,20 +274,45 @@ class Order extends Backend
if (!$order) { if (!$order) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
} }
if (!in_array($order->type, [MallOrder::TYPE_BONUS, MallOrder::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 !== MallOrder::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 (($order->retry_count ?? 0) >= 3) {
return $this->error(__('Retry count exceeded'));
} }
$order->grant_status = MallOrder::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

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

@@ -115,6 +115,24 @@ 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'));
} }

View File

@@ -148,7 +148,7 @@ class Playx extends Api
/** /**
* 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
{ {
@@ -173,7 +173,7 @@ class Playx extends Api
if ($sig === '' || $ts === '' || $rid === '') { if ($sig === '' || $ts === '' || $rid === '') {
return $this->error(__('Daily push signature missing or incomplete'), 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(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]); return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
@@ -382,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)) {
@@ -410,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]);
} }
@@ -454,20 +454,20 @@ class Playx extends Api
{ {
$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 = MallUserAsset::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 ?? '');
@@ -507,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);
@@ -546,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'));
} }
@@ -660,7 +663,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);
if (!$asset || strval($asset->playx_user_id ?? '') === '') { if (!$asset || strval($asset->playx_user_id ?? '') === '') {
@@ -689,7 +692,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]);
} }
$list = MallAddress::where('playx_user_asset_id', $assetId) $list = MallAddress::where('playx_user_asset_id', $assetId)
@@ -713,16 +716,16 @@ 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]);
} }
$phone = trim(strval($request->post('phone', ''))); $phone = trim(strval($request->post('phone', '')));
$receiverName = trim(strval($request->post('receiver_name', '')));
$region = $request->post('region', ''); $region = $request->post('region', '');
$detailAddress = trim(strval($request->post('detail_address', ''))); $detailAddress = trim(strval($request->post('detail_address', '')));
$address = trim(strval($request->post('address', '')));
$defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0; $defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
if ($phone === '' || $detailAddress === '' || $address === '' || $region === '' || $region === null) { if ($phone === '' || $receiverName === '' || $detailAddress === '' || $region === '' || $region === null) {
return $this->error(__('Missing required fields')); return $this->error(__('Missing required fields'));
} }
@@ -734,10 +737,10 @@ class Playx extends Api
$created = MallAddress::create([ $created = MallAddress::create([
'playx_user_asset_id' => $assetId, 'playx_user_asset_id' => $assetId,
'receiver_name' => $receiverName,
'phone' => $phone, 'phone' => $phone,
'region' => $region, 'region' => $region,
'detail_address' => $detailAddress, 'detail_address' => $detailAddress,
'address' => $address,
'default_setting' => $defaultSetting, 'default_setting' => $defaultSetting,
'create_time' => time(), 'create_time' => time(),
'update_time' => time(), 'update_time' => time(),
@@ -767,7 +770,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]);
} }
$id = intval($request->post('id', 0)); $id = intval($request->post('id', 0));
@@ -784,15 +787,15 @@ class Playx extends Api
if ($request->post('phone', null) !== null) { if ($request->post('phone', null) !== null) {
$updates['phone'] = trim(strval($request->post('phone', ''))); $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) { if ($request->post('region', null) !== null) {
$updates['region'] = $request->post('region', ''); $updates['region'] = $request->post('region', '');
} }
if ($request->post('detail_address', null) !== null) { if ($request->post('detail_address', null) !== null) {
$updates['detail_address'] = trim(strval($request->post('detail_address', ''))); $updates['detail_address'] = trim(strval($request->post('detail_address', '')));
} }
if ($request->post('address', null) !== null) {
$updates['address'] = trim(strval($request->post('address', '')));
}
if ($request->post('default_setting', null) !== null) { if ($request->post('default_setting', null) !== null) {
$updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0; $updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
} }
@@ -830,7 +833,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]);
} }
$id = intval($request->post('id', 0)); $id = intval($request->post('id', 0));
@@ -897,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'));
} }
@@ -963,11 +969,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'));
} }
@@ -996,9 +1012,11 @@ class Playx extends Api
'status' => MallOrder::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(),
]); ]);
@@ -1026,7 +1044,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'));
} }
@@ -1062,7 +1083,7 @@ class Playx extends Api
'amount' => $amount, 'amount' => $amount,
'multiplier' => $multiplier, 'multiplier' => $multiplier,
'external_transaction_id' => $orderNo, 'external_transaction_id' => $orderNo,
'grant_status' => MallOrder::GRANT_NOT_SENT, 'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(), 'create_time' => time(),
'update_time' => time(), 'update_time' => time(),
]); ]);
@@ -1073,11 +1094,6 @@ 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',
@@ -1123,39 +1139,4 @@ class Playx extends Api
} }
} }
private function callPlayxBalanceCredit(MallOrder $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 = MallOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
} }

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~',
@@ -49,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

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

@@ -41,7 +41,18 @@ 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) : '';
} }
@@ -49,4 +60,27 @@ class MallAddress extends Model
{ {
return $this->belongsTo(\app\common\model\MallUserAsset::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

@@ -28,6 +28,7 @@ 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 MallOrder extends Model class MallOrder extends Model
{ {
@@ -50,18 +51,27 @@ class MallOrder 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

@@ -37,9 +37,15 @@ class MallUserAsset extends Model
return $existing; return $existing;
} }
$phone = self::allocateUniquePhone(); // 创建用户时phone 与 username 同值H5 临时账号)
if ($phone === null) { $phone = $username;
throw new \RuntimeException('Failed to allocate unique phone'); $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)); $pwd = hash_password(Random::build('alnum', 16));
@@ -74,16 +80,6 @@ class MallUserAsset extends Model
return $created; return $created;
} }
private static function allocateUniquePhone(): ?string // allocateUniquePhone 已废弃:临时登录场景下 phone=用户名
{
for ($i = 0; $i < 8; $i++) {
$candidate = '13' . str_pad(mt_rand(0, 999999999), 9, '0', STR_PAD_LEFT);
if (!self::where('phone', $candidate)->find()) {
return $candidate;
}
}
return null;
}
} }

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\TH:i:s\Z', $createTime);
$end = gmdate('Y-m-d\TH:i:s\Z', $createTime + 86400);
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,7 +2,7 @@
namespace app\process; namespace app\process;
use app\common\model\MallItem; use app\common\library\MallBonusGrantPush;
use app\common\model\MallOrder; use app\common\model\MallOrder;
use app\common\model\MallUserAsset; use app\common\model\MallUserAsset;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@@ -47,7 +47,8 @@ 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 = MallOrder::where('grant_status', MallOrder::GRANT_ACCEPTED) $list = MallOrder::where('type', MallOrder::TYPE_BONUS)
->where('grant_status', MallOrder::GRANT_ACCEPTED)
->where('status', MallOrder::STATUS_PENDING) ->where('status', MallOrder::STATUS_PENDING)
->order('id', 'desc') ->order('id', 'desc')
->limit(50) ->limit(50)
@@ -98,13 +99,9 @@ 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 = MallOrder::whereIn('grant_status', [ $list = MallOrder::where('type', MallOrder::TYPE_BONUS)
->whereIn('grant_status', [
MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_NOT_SENT,
MallOrder::GRANT_FAILED_RETRYABLE, MallOrder::GRANT_FAILED_RETRYABLE,
]) ])
@@ -124,14 +121,12 @@ 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 = MallOrder::GRANT_FAILED_FINAL; $order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->status = MallOrder::STATUS_REJECTED;
$order->save(); $order->save();
$this->refundPoints($order);
} else { } else {
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE; $order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->save(); $order->save();
@@ -167,99 +162,31 @@ class PlayxJobs
return false; return false;
} }
private function sendGrantByOrder(MallOrder $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 === MallOrder::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 = MallOrder::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 = MallOrder::GRANT_FAILED_FINAL;
$order->status = MallOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->save();
return; return;
} }
if ($order->type === MallOrder::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 = MallOrder::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 = MallOrder::GRANT_FAILED_FINAL;
$order->status = MallOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallOrder::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(MallOrder $order): void private function refundPoints(MallOrder $order): void

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,19 +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/playx/address/list', [\app\api\controller\v1\Playx::class, 'addressList']); Route::get('/api/v1/mall/addressList', [\app\api\controller\v1\Playx::class, 'addressList']);
Route::post('/api/v1/playx/address/add', [\app\api\controller\v1\Playx::class, 'addressAdd']); Route::post('/api/v1/mall/addressAdd', [\app\api\controller\v1\Playx::class, 'addressAdd']);
Route::post('/api/v1/playx/address/edit', [\app\api\controller\v1\Playx::class, 'addressEdit']); Route::post('/api/v1/mall/addressEdit', [\app\api\controller\v1\Playx::class, 'addressEdit']);
Route::post('/api/v1/playx/address/delete', [\app\api\controller\v1\Playx::class, 'addressDelete']); 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,7 +13,7 @@
### 1.1 Daily Push API ### 1.1 Daily Push API
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/daily-push` * 路径:`/api/v1/mall/dailyPush`
#### Header多语言可选 #### Header多语言可选
- `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文 - `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文
@@ -25,7 +25,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)`
- 校验:`hash_equals(expected, X-Signature)` - 校验:`hash_equals(expected, X-Signature)`
@@ -104,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",
@@ -136,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",
@@ -181,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`
@@ -406,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
@@ -442,7 +442,7 @@ 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'
``` ```
@@ -455,32 +455,32 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
#### 3.x.1 地址列表 #### 3.x.1 地址列表
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/address/list` * 路径:`/api/v1/mall/addressList`
返回:`data.list` 为地址数组。 返回:`data.list` 为地址数组。
#### 3.x.2 添加地址 #### 3.x.2 添加地址
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/address/add` * 路径:`/api/v1/mall/addressAdd`
Body Body
| 字段 | 必填 | 说明 | | 字段 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 电话 | | `phone` | 是 | 电话 |
| `region` | 是 | 地区(数组或逗号分隔字符串) | | `region` | 是 | 地区(数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址 | | `detail_address` | 是 | 详细地址 |
| `address` | 是 | 地址补充 |
| `default_setting` | 否 | `1` 设为默认地址 | | `default_setting` | 否 | `1` 设为默认地址 |
#### 3.x.3 修改地址(含设为默认) #### 3.x.3 修改地址(含设为默认)
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/address/edit` * 路径:`/api/v1/mall/addressEdit`
Body`id` 必填,其余字段按需传入更新。 Body`id` 必填,其余字段按需传入更新。
#### 3.x.4 删除地址 #### 3.x.4 删除地址
* 方法:`POST` * 方法:`POST`
* 路径:`/api/v1/playx/address/delete` * 路径:`/api/v1/mall/addressDelete`
Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩余地址设为默认(如存在)。 Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩余地址设为默认(如存在)。
@@ -519,7 +519,7 @@ Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩
### 3.4 用户资产Assets ### 3.4 用户资产Assets
* 方法:`GET` * 方法:`GET`
* 路径:`/api/v1/playx/assets` * 路径:`/api/v1/mall/assets`
#### 请求参数(鉴权) #### 请求参数(鉴权)
@@ -541,7 +541,7 @@ Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩
#### 示例 #### 示例
```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'
``` ```
响应(示例): 响应(示例):
@@ -564,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
必填: 必填:
@@ -581,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'
@@ -623,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`
@@ -636,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'
``` ```
响应(示例): 响应(示例):
@@ -666,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
必填: 必填:
@@ -680,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....'
@@ -703,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`
#### 返回(成功) #### 返回(成功)
@@ -719,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....'
``` ```
@@ -742,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
必填: 必填:
@@ -756,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....'
@@ -779,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`
#### 请求参数(鉴权) #### 请求参数(鉴权)
@@ -793,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'
``` ```
响应(示例,简化): 响应(示例,简化):
@@ -828,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`

View File

@@ -1,13 +1,14 @@
<?php <?php
//脚本执行指令 php tmp_sig.php
$secret = '5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca'; $secret = '5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca';
$ts = '1700000123'; $ts = '1775525663';
$rid = 'req_1700000000_234567'; $rid = 'req_1775525663_123';
$body = [ $body = [
'report_date' => '1700000123', 'report_date' => '1775525663',
'member' => [ 'member' => [
[ [
'member_id' => '234567', 'member_id' => '123',
'login' => 'zhenhui', 'login' => 'zhenhui',
'ltv_deposit' => 1500, 'ltv_deposit' => 1500,
'ltv_withdrawal' => 1800, 'ltv_withdrawal' => 1800,
@@ -18,7 +19,7 @@ $body = [
]; ];
$json = json_encode($body); $json = json_encode($body);
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/playx/daily-push\n" . hash('sha256', $json); $canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', $json);
echo "json={$json}\n"; echo "json={$json}\n";
echo "sha256=" . hash('sha256', $json) . "\n"; echo "sha256=" . hash('sha256', $json) . "\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

@@ -2,10 +2,10 @@ 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': '--', 'default_setting 0': '--',
'default_setting 1': 'YES', 'default_setting 1': 'YES',

View File

@@ -1,37 +1,41 @@
export default { export default {
id: 'id', approve: 'Review',
user_id: 'user_id', manual_retry: 'Retry grant',
type: 'type', retry_confirm: 'Queue this order for grant retry?',
'type BONUS': 'Bonus(BONUS)', id: 'Order ID',
'type PHYSICAL': 'Physical(PHYSICAL)', user_id: 'User ID',
'type WITHDRAW': 'Withdraw(WITHDRAW)', type: 'Type',
status: 'status', 'type BONUS': 'Bonus',
'status PENDING': 'Pending(PENDING)', 'type PHYSICAL': 'Physical',
'status COMPLETED': 'Completed(COMPLETED)', 'type WITHDRAW': 'Withdraw',
'status SHIPPED': 'Shipped(SHIPPED)', status: 'Status',
'status REJECTED': 'Rejected(REJECTED)', 'status PENDING': 'Pending',
mall_item_id: 'mall_item_id', 'status COMPLETED': 'Completed',
mallitem__title: 'title', 'status SHIPPED': 'Shipped',
points_cost: 'points_cost', 'status REJECTED': 'Rejected',
amount: 'amount', mall_item_id: 'Product ID',
multiplier: 'multiplier', mallitem__title: 'Product title',
external_transaction_id: 'external_transaction_id', points_cost: 'Points spent',
playx_transaction_id: 'playx_transaction_id', amount: 'Cash amount',
grant_status: 'grant_status', multiplier: 'Turnover multiplier',
'grant_status NOT_SENT': 'NOT_SENT', external_transaction_id: 'Order number',
'grant_status SENT_PENDING': 'SENT_PENDING', playx_transaction_id: 'PlayX transaction ID',
'grant_status ACCEPTED': 'ACCEPTED', grant_status: 'Grant status',
'grant_status FAILED_RETRYABLE': 'FAILED_RETRYABLE', 'grant_status NOT_SENT': 'Not sent',
'grant_status FAILED_FINAL': 'FAILED_FINAL', 'grant_status SENT_PENDING': 'Sent (queued)',
fail_reason: 'fail_reason', 'grant_status ACCEPTED': 'Accepted',
reject_reason: 'reject_reason', 'grant_status FAILED_RETRYABLE': 'Failed (retryable)',
shipping_company: 'shipping_company', 'grant_status FAILED_FINAL': 'Failed (final)',
shipping_no: 'shipping_no', 'grant_status ---': '—',
receiver_name: 'receiver_name', fail_reason: 'Failure reason',
receiver_phone: 'receiver_phone', reject_reason: 'Rejection reason',
receiver_address: 'receiver_address', shipping_company: 'Carrier',
create_time: 'create_time', shipping_no: 'Tracking number',
update_time: 'update_time', receiver_name: 'Recipient name',
'quick Search Fields': 'ID', receiver_phone: 'Recipient phone',
receiver_address: 'Shipping address',
mall_address_id: 'Address ID',
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,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

@@ -2,10 +2,10 @@ 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': '是',

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

@@ -1,4 +1,7 @@
export default { export default {
approve: '审核',
manual_retry: '手动重试',
retry_confirm: '确认将该订单加入重试队列?',
id: 'ID', id: 'ID',
user_id: '用户ID', user_id: '用户ID',
type: '类型', type: '类型',
@@ -15,14 +18,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: '物流公司',
@@ -30,6 +34,7 @@ export default {
receiver_name: '收货人', receiver_name: '收货人',
receiver_phone: '收货电话', receiver_phone: '收货电话',
receiver_address: '收货地址', receiver_address: '收货地址',
mall_address_id: '地址ID',
create_time: '创建时间', create_time: '创建时间',
update_time: '修改时间', update_time: '修改时间',
'quick Search Fields': 'ID', '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,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

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

@@ -37,6 +37,13 @@
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/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

@@ -33,14 +33,73 @@ const baTable = new baTableClass(
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { 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.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.user_id'),
{ label: t('mall.dailyPush.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' }, prop: 'user_id',
{ label: t('mall.dailyPush.yesterday_win_loss_net'), prop: 'yesterday_win_loss_net', align: 'center', operator: 'RANGE', sortable: false }, align: 'center',
{ label: t('mall.dailyPush.yesterday_total_deposit'), prop: 'yesterday_total_deposit', align: 'center', operator: 'RANGE', sortable: false }, operatorPlaceholder: t('Fuzzy query'),
{ label: t('mall.dailyPush.lifetime_total_deposit'), prop: 'lifetime_total_deposit', align: 'center', operator: 'RANGE', sortable: false }, sortable: false,
{ label: t('mall.dailyPush.lifetime_total_withdraw'), prop: 'lifetime_total_withdraw', align: 'center', operator: 'RANGE', sortable: false }, operator: 'LIKE',
{ 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' }, },
{
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], dblClickNotEditColumn: [undefined],
}, },
@@ -62,4 +121,3 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"></style> <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

@@ -3,11 +3,13 @@
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon /> <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader <TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']" :buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.order.quick Search Fields') })" :quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.order.quick Search Fields') })"
></TableHeader> ></TableHeader>
<Table ref="tableRef"></Table> <Table ref="tableRef"></Table>
<PopupForm />
</div> </div>
</template> </template>
@@ -19,7 +21,8 @@ import createAxios from '/@/utils/axios'
import TableHeader from '/@/components/table/header/index.vue' import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue' import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable' import baTableClass from '/@/utils/baTable'
import { ElMessageBox } from 'element-plus' import PopupForm from './popupForm.vue'
import { defaultOptButtons } from '/@/components/table'
defineOptions({ defineOptions({
name: 'mall/order', name: 'mall/order',
@@ -27,6 +30,17 @@ defineOptions({
const { t } = useI18n() const { t } = useI18n()
const tableRef = useTemplateRef('tableRef') 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( const baTable = new baTableClass(
new baTableApi('/admin/mall.Order/'), new baTableApi('/admin/mall.Order/'),
@@ -35,11 +49,21 @@ const baTable = new baTableClass(
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { 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.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.user_id'),
prop: 'user_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ {
label: t('mall.order.type'), label: t('mall.order.type'),
prop: 'type', prop: 'type',
align: 'center', align: 'center',
effect: 'light',
custom: { BONUS: 'success', PHYSICAL: 'primary', WITHDRAW: 'info' },
minWidth: 140,
operator: 'eq', operator: 'eq',
sortable: false, sortable: false,
render: 'tag', render: 'tag',
@@ -53,6 +77,9 @@ const baTable = new baTableClass(
label: t('mall.order.status'), label: t('mall.order.status'),
prop: 'status', prop: 'status',
align: 'center', align: 'center',
effect: 'dark',
custom: { PENDING: 'success', COMPLETED: 'primary', SHIPPED: 'info', REJECTED: 'loading' },
minWidth: 160,
operator: 'eq', operator: 'eq',
sortable: false, sortable: false,
render: 'tag', render: 'tag',
@@ -64,16 +91,49 @@ const baTable = new baTableClass(
}, },
}, },
{ label: t('mall.order.mall_item_id'), prop: 'mall_item_id', align: 'center', operator: 'RANGE', sortable: false }, { 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', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' }, {
{ label: t('mall.order.points_cost'), prop: 'points_cost', align: 'center', operator: 'RANGE', sortable: false }, label: t('mall.order.mallitem__title'),
{ label: t('mall.order.amount'), prop: 'amount', align: 'center', operator: 'RANGE', sortable: false }, prop: 'mallItem.title',
{ label: t('mall.order.multiplier'), prop: 'multiplier', align: 'center', operator: 'eq', sortable: false }, align: 'center',
{ label: t('mall.order.external_transaction_id'), prop: 'external_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' }, minWidth: 90,
{ label: t('mall.order.playx_transaction_id'), prop: 'playx_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' }, 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'), label: t('mall.order.grant_status'),
prop: 'grant_status', prop: 'grant_status',
align: 'center', align: 'center',
minWidth: 100,
custom: {
NOT_SENT: 'info',
SENT_PENDING: 'primary',
ACCEPTED: 'primary',
FAILED_RETRYABLE: 'error',
FAILED_FINAL: 'error',
'---': 'info',
},
operator: 'eq', operator: 'eq',
sortable: false, sortable: false,
render: 'tag', render: 'tag',
@@ -83,37 +143,120 @@ const baTable = new baTableClass(
ACCEPTED: t('mall.order.grant_status ACCEPTED'), ACCEPTED: t('mall.order.grant_status ACCEPTED'),
FAILED_RETRYABLE: t('mall.order.grant_status FAILED_RETRYABLE'), FAILED_RETRYABLE: t('mall.order.grant_status FAILED_RETRYABLE'),
FAILED_FINAL: t('mall.order.grant_status FAILED_FINAL'), 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.fail_reason'),
{ label: t('mall.order.shipping_company'), prop: 'shipping_company', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, prop: 'fail_reason',
{ label: t('mall.order.shipping_no'), prop: 'shipping_no', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, align: 'center',
{ label: t('mall.order.receiver_name'), prop: 'receiver_name', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, showOverflowTooltip: true,
{ label: t('mall.order.receiver_phone'), prop: 'receiver_phone', align: 'center', showOverflowTooltip: true, sortable: false, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, 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') }, sortable: false,
{ 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' }, operator: 'LIKE',
{ label: t('mall.order.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('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.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',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: 220, width: 120,
fixed: 'right',
render: 'buttons', render: 'buttons',
buttons: [ buttons: [
...optButtons,
{ {
render: 'confirmButton', render: 'confirmButton',
name: 'retry', name: 'retry',
title: 'Retry', title: t('mall.order.manual_retry'),
text: '手动重试', text: '',
type: 'warning', type: 'primary',
icon: '', class: 'table-row-edit',
icon: 'fa fa-refresh',
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: t('mall.order.retry_confirm'),
confirmButtonText: '确认', confirmButtonText: t('Confirm'),
cancelButtonText: '取消', cancelButtonText: t('Cancel'),
confirmButtonType: 'warning', confirmButtonType: 'primary',
}, },
click: async (row: TableRow) => { click: async (row: TableRow) => {
await createAxios( await createAxios(
@@ -131,81 +274,6 @@ const baTable = new baTableClass(
await baTable.getData() await baTable.getData()
}, },
}, },
{
render: 'basicButton',
name: 'ship',
title: 'Ship',
text: '发货',
type: 'success',
icon: '',
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
click: async (row: TableRow) => {
try {
const shippingNoRes = await ElMessageBox.prompt('请输入物流单号', '发货', {
confirmButtonText: '确认',
cancelButtonText: '取消',
})
const shippingCompanyRes = await ElMessageBox.prompt('请输入物流公司', '发货', {
confirmButtonText: '确认',
cancelButtonText: '取消',
})
const shippingNo = shippingNoRes.value
const shippingCompany = shippingCompanyRes.value
await createAxios(
{
url: '/admin/mall.Order/ship',
method: 'post',
data: {
id: row.id,
shipping_company: shippingCompany,
shipping_no: shippingNo,
},
},
{
showSuccessMessage: true,
}
)
await baTable.getData()
} catch {
// 用户取消弹窗:不做任何提示,避免控制台报错
}
},
},
{
render: 'basicButton',
name: 'reject',
title: 'Reject',
text: '驳回',
type: 'danger',
icon: '',
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
click: async (row: TableRow) => {
try {
const res = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认',
cancelButtonText: '取消',
})
await createAxios(
{
url: '/admin/mall.Order/reject',
method: 'post',
data: {
id: row.id,
reject_reason: res.value,
},
},
{
showSuccessMessage: true,
}
)
await baTable.getData()
} catch {
// 用户取消:不提示
}
},
},
], ],
}, },
], ],
@@ -229,4 +297,3 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"></style> <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

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

@@ -3,18 +3,22 @@
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon /> <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader <TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']" :buttons="['refresh', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.userAsset.quick Search Fields') })" :quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.userAsset.quick Search Fields') })"
></TableHeader> ></TableHeader>
<Table ref="tableRef"></Table> <Table ref="tableRef"></Table>
<PopupForm />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue' import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common' import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue' import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue' import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable' import baTableClass from '/@/utils/baTable'
@@ -25,6 +29,7 @@ defineOptions({
const { t } = useI18n() const { t } = useI18n()
const tableRef = useTemplateRef('tableRef') const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass( const baTable = new baTableClass(
new baTableApi('/admin/mall.UserAsset/'), new baTableApi('/admin/mall.UserAsset/'),
@@ -33,16 +38,68 @@ const baTable = new baTableClass(
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { 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.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.username'),
{ label: t('mall.userAsset.playx_user_id'), prop: 'playx_user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' }, 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.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.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_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_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.today_limit_date'),
{ 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' }, 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], dblClickNotEditColumn: [undefined],
}, },
@@ -64,4 +121,3 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"></style> <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>