Compare commits

...

23 Commits

Author SHA1 Message Date
8c5b1d212a 优化登录状态错误时跳转页面到admin/login 2026-05-11 15:19:33 +08:00
2f8c4f4ced 优化推送统一订单信息-playxId修改为用户名 2026-05-07 14:20:45 +08:00
afa3750035 修改visual_name=>Reward Mall 2026-05-07 12:09:30 +08:00
234c607db0 1.优化统一订单推送报错记录日志 2026-05-06 17:22:11 +08:00
6883e74cf5 1.优化统一订单推送报错记录日志 2026-05-06 17:15:47 +08:00
ddd533f436 1.优化商城参数配置菜单-以及配置生效问题 2026-05-06 15:50:42 +08:00
ec499dce0f 1.新增商城参数配置菜单-管理兑换比例 2026-05-06 15:21:59 +08:00
c2c2baeaec 优化测试verifyToken接口脚本 2026-05-06 11:05:23 +08:00
d44ce36286 优化测试verifyToken接口脚本 2026-05-06 10:59:49 +08:00
9ce351a0c7 优化测试verifyToken接口脚本 2026-05-06 10:56:25 +08:00
7acdc414fc 优化测试verifyToken接口脚本 2026-05-06 10:51:57 +08:00
907f7cb315 新增测试verifyToken接口脚本 2026-05-06 10:48:53 +08:00
713170308b 接口新增记录日志 2026-05-06 10:48:26 +08:00
438580d72c 优化Token验证接口 2026-05-06 10:25:56 +08:00
04680408e6 优化Token验证接口 2026-05-06 10:18:26 +08:00
05f2d6f084 1.重新设置Token验证接口 2026-05-06 10:03:17 +08:00
29ab883f4e 1.重新设置Token验证接口 2026-05-06 09:49:39 +08:00
ccdb58ea1d 1.优化相关文档
2.重新设置Token验证接口
2026-04-30 15:18:06 +08:00
487016e037 1.远程验证token有效性 2026-04-30 14:39:25 +08:00
d9b2ebafa9 1.远程验证token有效性 2026-04-30 14:35:51 +08:00
cf6aa7e9e9 1.远程验证token有效性 2026-04-30 14:25:36 +08:00
8017d1819a 优化验证token接口-暂时不验证token有效性 2026-04-23 09:49:58 +08:00
3b687489a0 优化验证token接口-暂时不验证token有效性 2026-04-23 09:43:10 +08:00
49 changed files with 1798 additions and 312 deletions

View File

@@ -18,6 +18,7 @@ DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
# PlayX 配置
# 以下三项比例以数据库表 mall_business_config 为准(后台 积分商城 → 商城参数配置);无记录时回退为下列 env 默认值
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1
PLAYX_POINTS_TO_CASH_RATIO=0.1
# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio仅亏损时
@@ -32,6 +33,14 @@ PLAYX_PARTNER_JWT_SECRET=
AGENT_AUTH_JWT_SECRET=
# token 会话缓存过期时间(秒)
PLAYX_SESSION_EXPIRE_SECONDS=3600
# verifyToken 是否仅本地联调false=走对方远程校验)
PLAYX_VERIFY_TOKEN_LOCAL_ONLY=false
# verifyToken完整 https URL 时 Body merchant_code+request_date+request_id+token、签名明文同序相对路径时拼基址+商户四字段
PLAYX_TOKEN_VERIFY_URL=https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token
# verifyToken 仅本地联调时的默认用户ID
PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID=testmyr
# verifyToken 仅本地联调时的默认用户名
PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME=yangyang123
# PlayX API商城调用 PlayX 时使用)
PLAYX_API_BASE_URL=
PLAYX_API_SECRET_KEY=

View File

@@ -74,10 +74,18 @@ class Order extends Backend
[$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model
->with(['mallItem' => function ($query) {
$query->field('id,title');
}])
->visible(['mallItem' => ['title']])
->with([
'mallItem' => function ($query) {
$query->field('id,title');
},
'mallUserAsset' => function ($query) {
$query->field('playx_user_id,username');
},
])
->visible([
'mallItem' => ['title'],
'mallUserAsset' => ['username'],
])
->alias($alias)
->where($where)
->order($order)

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\mall;
use app\common\controller\Backend;
use app\common\library\MallPlayxRatios;
use app\common\model\MallBusinessConfig;
use support\Response;
use Webman\Http\Request;
/**
* 积分商城参数PlayX 比例等,读写 mall_business_config
*/
class PlayxConfig extends Backend
{
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$row = MallBusinessConfig::order('id', 'asc')->find();
if (!$row) {
$now = time();
MallBusinessConfig::create([
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
'create_time' => $now,
'update_time' => $now,
]);
$row = MallBusinessConfig::order('id', 'asc')->find();
}
if ($row) {
MallPlayxRatios::syncFromRow($row);
}
return $this->success('', [
'row' => $row ? $row->toArray() : [],
'remark' => get_route_remark(),
]);
}
public function save(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$data = $request->post();
if (empty($data) || !is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$return = $data['return_ratio'] ?? null;
$unlock = $data['unlock_ratio'] ?? null;
$cash = $data['points_to_cash_ratio'] ?? null;
if (!is_numeric($return) || !is_numeric($unlock) || !is_numeric($cash)) {
return $this->error(__('Parameter error'));
}
$returnF = floatval($return);
$unlockF = floatval($unlock);
$cashF = floatval($cash);
if ($returnF < 0 || $unlockF < 0 || $cashF < 0) {
return $this->error(__('Parameter error'));
}
$row = MallBusinessConfig::order('id', 'asc')->find();
if (!$row) {
$now = time();
MallBusinessConfig::create([
'return_ratio' => $returnF,
'unlock_ratio' => $unlockF,
'points_to_cash_ratio' => $cashF,
'create_time' => $now,
'update_time' => $now,
]);
} else {
$row->return_ratio = $returnF;
$row->unlock_ratio = $unlockF;
$row->points_to_cash_ratio = $cashF;
$row->save();
}
$fresh = MallBusinessConfig::order('id', 'asc')->find();
if ($fresh) {
MallPlayxRatios::syncFromRow($fresh);
}
return $this->success(__('The current page configuration item was updated successfully'));
}
}

View File

@@ -14,6 +14,7 @@ use app\common\model\MallDailyPush;
use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\library\MallPlayxRatios;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
@@ -231,8 +232,9 @@ class Playx extends Api
$requestId = 'report_' . $date;
}
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
$results = [];
$allDeduped = true;
@@ -332,8 +334,9 @@ class Playx extends Api
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
@@ -365,8 +368,9 @@ class Playx extends Api
]);
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
@@ -393,7 +397,7 @@ class Playx extends Api
/**
* Token 验证 - POST /api/v1/playx/verify-token
* 配置 playx.verify_token_local_only=true 时仅本地校验 token不请求 PlayX)。
* 配置 playx.verify_token_local_only=true 时:不向 PlayX 请求,且不校验传入 token联调占位)。
*/
public function verifyToken(Request $request): Response
{
@@ -402,37 +406,134 @@ class Playx extends Api
return $response;
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocalOpen();
}
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
if ($token === '') {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocal($token);
}
$baseUrl = strval(config('playx.angpow_import.base_url', ''));
$verifyUrlRaw = strval(config('playx.api.token_verify_url', '/api/v1/auth/verify-token'));
$verifyUrlTrimmed = trim($verifyUrlRaw);
$isAbsoluteVerifyUrl = str_starts_with($verifyUrlTrimmed, 'http://')
|| str_starts_with($verifyUrlTrimmed, 'https://');
$baseUrl = config('playx.api.base_url', '');
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
if ($baseUrl === '') {
return $this->error(__('PlayX API not configured'));
if ($isAbsoluteVerifyUrl) {
$targetVerifyUrl = $verifyUrlTrimmed;
} else {
$verifyPath = ltrim($verifyUrlTrimmed, '/');
if ($verifyPath === '') {
return $this->error(__('PlayX API not configured'));
}
if ($baseUrl === '') {
return $this->error(__('PlayX API not configured'));
}
$targetVerifyUrl = rtrim($baseUrl, '/') . '/' . $verifyPath;
}
try {
$client = new \GuzzleHttp\Client([
'base_uri' => rtrim($baseUrl, '/') . '/',
'timeout' => 10,
]);
$res = $client->post($verifyUrl, [
'json' => [
'request_id' => 'mall_' . uniqid(),
'token' => $token,
],
]);
$code = $res->getStatusCode();
$requestId = 'mall_' . uniqid();
$clientOptions = [
'timeout' => 10,
'http_errors' => false,
];
if (!$isAbsoluteVerifyUrl) {
$clientOptions['base_uri'] = rtrim($baseUrl, '/') . '/';
}
$client = new \GuzzleHttp\Client($clientOptions);
if ($isAbsoluteVerifyUrl) {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
// 回调网关要求 Body 含 merchant_code、request_dateUnix 秒);签名字符串与 Body 参与签名字段一致HMAC 同 angpow
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$res = $client->post($targetVerifyUrl, [
'headers' => $headers,
'json' => $payload,
]);
} else {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
'X-Signature' => $signature,
'X-Request-Date' => $requestDate,
'X-Request-ID' => $requestId,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$verifyPath = ltrim($verifyUrlTrimmed, '/');
$res = $client->post($verifyPath, [
'headers' => $headers,
'json' => $payload,
]);
}
$data = json_decode(strval($res->getBody()), true);
$code = $res->getStatusCode();
if ($code !== 200 || empty($data['user_id'])) {
$remoteMsg = $data['message'] ?? '';
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
$remoteMsg = '';
if (is_array($data)) {
$candidateMsg = $data['message'] ?? ($data['msg'] ?? ($data['error'] ?? ''));
if (is_string($candidateMsg) && $candidateMsg !== '') {
$remoteMsg = $candidateMsg;
}
}
if ($remoteMsg === '') {
$bodyText = strval($res->getBody());
if ($bodyText !== '') {
$remoteMsg = mb_substr($bodyText, 0, 300);
}
}
if ($remoteMsg === '' || str_contains(strtolower($remoteMsg), '<html')) {
$remoteMsg = __('PlayX verify upstream failed', [strval($code), $targetVerifyUrl]);
}
$msg = $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
return $this->error($msg, null, 0, ['statusCode' => 401]);
}
@@ -448,6 +549,11 @@ class Playx extends Api
}
}
$asset = $this->ensureAssetForPlayx($userId, $username);
if ($asset === null) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$sessionId = bin2hex(random_bytes(16));
MallSession::create([
'session_id' => $sessionId,
@@ -470,132 +576,75 @@ class Playx extends Api
}
/**
* 联调:合作方 JWT payload 的 base64url 解码(不验签),仅用于 dev_verify_token_exact 命中后的 session 字段
*
* @return array{sub?: string, user_fullname?: string, exp?: int}|null
* 本地联调:不校验 token按配置默认用户签发新 mall_session待 PlayX 远程校验就绪后关闭 verify_token_local_only
*/
private function parsePartnerJwtPayloadForDev(string $jwt): ?array
private function verifyTokenLocalOpen(): Response
{
$parts = explode('.', $jwt);
if (count($parts) < 2) {
return null;
}
$payload = $parts[1];
$b64 = strtr($payload, '-_', '+/');
$pad = strlen($b64) % 4;
if ($pad > 0) {
$b64 .= str_repeat('=', 4 - $pad);
}
$raw = base64_decode($b64, true);
if ($raw === false || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
/**
* 本地校验 temLogin 等写入的商城 token类型 muser写入 mall_session
*/
private function verifyTokenLocal(string $token): Response
{
$devExact = strval(config('playx.dev_verify_token_exact', ''));
if ($devExact !== '' && hash_equals($devExact, $token)) {
$payload = $this->parsePartnerJwtPayloadForDev($token);
if ($payload === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if (isset($payload['exp'])) {
$exp = intval($payload['exp']);
if ($exp > 0 && $exp <= time()) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
}
$overrideUserId = strval(config('playx.dev_verify_session_user_id', ''));
$playxUserId = $overrideUserId;
if ($playxUserId === '' && isset($payload['sub']) && is_string($payload['sub'])) {
$playxUserId = $payload['sub'];
}
if ($playxUserId === '') {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$overrideUsername = strval(config('playx.dev_verify_session_username', ''));
$username = $overrideUsername;
if ($username === '') {
if (isset($payload['user_fullname']) && is_string($payload['user_fullname']) && $payload['user_fullname'] !== '') {
$username = $payload['user_fullname'];
} elseif (isset($payload['sub']) && is_string($payload['sub'])) {
$username = $payload['sub'];
}
}
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
$sessionId = bin2hex(random_bytes(16));
MallSession::create([
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => $username,
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
]);
return $this->success('', [
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => $username,
'token_expire_at' => date('c', $expireAt),
]);
}
$tokenData = Token::get($token);
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$tokenType = strval($tokenData['type'] ?? '');
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$assetId = intval($tokenData['user_id'] ?? 0);
if ($assetId <= 0) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = MallUserAsset::where('id', $assetId)->find();
if (!$asset) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$playxUserId = strval($asset->playx_user_id ?? '');
$playxUserId = strval(config('playx.verify_token_local_default_user_id', 'testmyr'));
$username = strval(config('playx.verify_token_local_default_username', 'yangyang123'));
if ($playxUserId === '') {
$playxUserId = strval($assetId);
return $this->error(__('PlayX API not configured'));
}
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
if ($asset === null) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
$sessionId = bin2hex(random_bytes(16));
$now = time();
MallSession::create([
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'username' => $username,
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
'create_time' => $now,
'update_time' => $now,
]);
return $this->success('', [
return $this->success('Success', [
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'username' => $username,
'token_expire_at' => date('c', $expireAt),
]);
}
/**
* 生成 token verify 所需签名Base64(HMAC-SHA1(canonical, key))
*/
private function buildPlayxTokenVerifySignature(string $input, string $authKey): ?string
{
$keyBytes = null;
$maybeBase64 = base64_decode($authKey, true);
if (is_string($maybeBase64) && $maybeBase64 !== '') {
$keyBytes = $maybeBase64;
}
if ($keyBytes === null) {
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
if ($isHex) {
$hex = hex2bin($authKey);
if (is_string($hex) && $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);
}
/**
* 用户资产
* GET /api/v1/playx/assets?user_id=xxx
@@ -623,7 +672,7 @@ class Playx extends Api
]);
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
$withdrawableCash = round($asset->available_points * $ratio, 2);
return $this->success('', [
@@ -1212,7 +1261,7 @@ SQL;
'withdrawable_cash' => 0,
];
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
return [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,

View File

@@ -60,6 +60,7 @@ return [
'nicknameChsDash' => 'Username may only contain letters, numbers, underscores and dashes',
'Invalid token' => 'Invalid or expired token',
'PlayX API not configured' => 'PlayX API is not configured',
'PlayX verify upstream failed' => 'Upstream token verification failed (HTTP %s): %s',
'Duplicate input' => 'Duplicate submission',
'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Failed to map PlayX user to mall user',

View File

@@ -61,6 +61,7 @@ return [
'nicknameChsDash' => 'Nama pengguna hanya huruf, nombor, garis bawah dan sempang',
'Invalid token' => 'Token tidak sah atau tamat tempoh',
'PlayX API not configured' => 'API PlayX tidak dikonfigurasi',
'PlayX verify upstream failed' => 'Pengesahan token hulu gagal (HTTP %s): %s',
'Duplicate input' => 'Penghantaran pendua',
'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Gagal memetakan pengguna PlayX ke pengguna mall',

View File

@@ -62,6 +62,7 @@ return [
// PlayX API v1 /api/v1/*
'Invalid token' => '令牌无效或已过期',
'PlayX API not configured' => '未配置 PlayX 接口地址',
'PlayX verify upstream failed' => '上游 Token 校验失败HTTP %s%s',
'Duplicate input' => '重复提交',
'Ok' => '成功',
'Failed to map playx user to mall user' => '无法将 PlayX 用户关联到商城用户',

View File

@@ -8,6 +8,7 @@ use app\common\model\MallItem;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use support\Log;
/**
* 红利订单调用 PlayX bonus/grant与定时任务、后台手动推送共用
@@ -23,7 +24,7 @@ final class MallBonusGrantPush
if (!is_array($conf)) {
return [
'ok' => false,
'message' => 'PlayX angpow_import not configured',
'message' => 'playX angpow_import not configured',
];
}
@@ -34,7 +35,7 @@ final class MallBonusGrantPush
if ($baseUrl === '' || $path === '' || $merchantCode === '' || $authKey === '') {
return [
'ok' => false,
'message' => 'PlayX Angpow Import API not configured',
'message' => 'playX Angpow Import API not configured',
];
}
@@ -48,6 +49,14 @@ final class MallBonusGrantPush
];
}
$memberLogin = trim(strval($asset->username ?? ''));
if ($memberLogin === '') {
return [
'ok' => false,
'message' => 'User username empty',
];
}
$item = MallItem::where('id', $order->mall_item_id)->find();
if (!$item) {
return [
@@ -78,7 +87,7 @@ final class MallBonusGrantPush
'report_date' => $reportDate,
'angpow' => [
[
'member_login' => strval($asset->playx_user_id),
'member_login' => $memberLogin,
'start_time' => $start,
'end_time' => $end,
'amount' => $order->amount,
@@ -113,7 +122,33 @@ final class MallBonusGrantPush
'json' => $payload,
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
$httpStatus = 0;
if (is_object($res) && method_exists($res, 'getStatusCode')) {
$sc = $res->getStatusCode();
if (is_int($sc)) {
$httpStatus = $sc;
}
}
$rawBody = strval($res->getBody());
$data = json_decode($rawBody, true);
if (!is_array($data)) {
$jsonDetail = 'root not JSON object';
if (json_last_error() !== JSON_ERROR_NONE) {
$jem = json_last_error_msg();
$jsonDetail = is_string($jem) && $jem !== '' ? $jem : 'JSON parse error';
}
Log::error(
'[MallBonusGrantPush] response not a JSON object | order_id=' . $order->id
. ' | http=' . $httpStatus . ' | ' . $jsonDetail . ' | body=' . self::truncateForLog($rawBody)
);
return [
'ok' => false,
'message' => 'Invalid response',
];
}
if (($data['code'] ?? null) === '0' || ($data['code'] ?? null) === 0) {
return [
'ok' => true,
@@ -121,11 +156,34 @@ final class MallBonusGrantPush
];
}
$remoteMsg = $data['message'] ?? $data['msg'] ?? '';
if (!is_string($remoteMsg)) {
$remoteMsg = '';
}
if ($remoteMsg === '') {
$remoteMsg = 'playX angpow import not accepted';
}
$flags = JSON_UNESCAPED_UNICODE;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$flags = $flags | JSON_INVALID_UTF8_SUBSTITUTE;
}
$encoded = json_encode($data, $flags);
if (!is_string($encoded)) {
$encoded = $rawBody;
}
Log::error(
'[MallBonusGrantPush] angpow import rejected | order_id=' . $order->id
. ' | http=' . $httpStatus . ' | parsed=' . self::truncateForLog($encoded)
);
return [
'ok' => false,
'message' => strval($data['message'] ?? 'PlayX angpow import not accepted'),
'message' => $remoteMsg,
];
} catch (\Throwable $e) {
Log::error('[MallBonusGrantPush] request exception | order_id=' . $order->id . ' | ' . $e->getMessage());
return [
'ok' => false,
'message' => $e->getMessage(),
@@ -133,6 +191,28 @@ final class MallBonusGrantPush
}
}
private static function truncateForLog(string $text, int $maxLen = 8000): string
{
$s = trim($text);
if ($s === '') {
return '';
}
$oneLine = preg_replace('/\s+/', ' ', $s);
$snippet = is_string($oneLine) && $oneLine !== '' ? $oneLine : $s;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($snippet, 'UTF-8') > $maxLen) {
return mb_substr($snippet, 0, $maxLen, 'UTF-8') . '…';
}
return $snippet;
}
if (strlen($snippet) > $maxLen) {
return substr($snippet, 0, $maxLen) . '…';
}
return $snippet;
}
private static function buildSignature(string $input, string $authKey): ?string
{
$keyBytes = null;

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use support\Redis as RedisSupport;
use Throwable;
/**
* PlayX 与积分商城相关的比例配置:优先读 Redis未命中则读 mall_business_config 并回写 Redis无表记录时回退 .env 并写入 Redis。
*/
final class MallPlayxRatios
{
public const REDIS_KEY = 'mall:business_config:playx_ratios';
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}
*/
public static function get(): array
{
$conn = self::redisConnection();
if ($conn !== null) {
$cached = self::readFromRedis($conn);
if ($cached !== null) {
return $cached;
}
}
return self::loadFromDatabaseAndWriteRedis($conn);
}
/**
* @param array{return_ratio?: mixed, unlock_ratio?: mixed, points_to_cash_ratio?: mixed} $ratios
*/
public static function syncToRedis(array $ratios): void
{
$payload = [
'return_ratio' => self::toFloat($ratios['return_ratio'] ?? 0),
'unlock_ratio' => self::toFloat($ratios['unlock_ratio'] ?? 0),
'points_to_cash_ratio' => self::toFloat($ratios['points_to_cash_ratio'] ?? 0),
];
self::writeRedis(self::redisConnection(), $payload);
}
public static function syncFromRow(MallBusinessConfig $row): void
{
self::syncToRedis([
'return_ratio' => $row->return_ratio,
'unlock_ratio' => $row->unlock_ratio,
'points_to_cash_ratio' => $row->points_to_cash_ratio,
]);
}
public static function forget(): void
{
$conn = self::redisConnection();
if ($conn === null) {
return;
}
try {
$conn->del(self::REDIS_KEY);
} catch (Throwable) {
}
}
private static function redisConnection(): ?object
{
$cfg = config('redis');
if (!is_array($cfg) || empty($cfg)) {
return null;
}
try {
return RedisSupport::connection('default');
} catch (Throwable) {
return null;
}
}
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}|null
*/
private static function readFromRedis(object $conn): ?array
{
try {
$raw = $conn->get(self::REDIS_KEY);
} catch (Throwable) {
return null;
}
if (!is_string($raw) || $raw === '') {
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return null;
}
if (!isset($data['return_ratio'], $data['unlock_ratio'], $data['points_to_cash_ratio'])) {
return null;
}
return [
'return_ratio' => self::toFloat($data['return_ratio']),
'unlock_ratio' => self::toFloat($data['unlock_ratio']),
'points_to_cash_ratio' => self::toFloat($data['points_to_cash_ratio']),
];
}
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}
*/
private static function loadFromDatabaseAndWriteRedis(?object $conn): array
{
$row = MallBusinessConfig::order('id', 'asc')->find();
if ($row) {
$out = [
'return_ratio' => self::toFloat($row->return_ratio),
'unlock_ratio' => self::toFloat($row->unlock_ratio),
'points_to_cash_ratio' => self::toFloat($row->points_to_cash_ratio),
];
self::writeRedis($conn, $out);
return $out;
}
$out = [
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
];
self::writeRedis($conn, $out);
return $out;
}
/**
* @param array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float} $payload
*/
private static function writeRedis(?object $conn, array $payload): void
{
if ($conn === null) {
return;
}
try {
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$conn->set(self::REDIS_KEY, $json);
} catch (Throwable) {
}
}
private static function toFloat(mixed $v): float
{
if (is_numeric($v)) {
return floatval($v);
}
return 0.0;
}
}

View File

@@ -12,7 +12,7 @@ use Webman\Http\Response;
* 加载控制器语言包中间件Webman 迁移版,等价 ThinkPHP LoadLangPack
* 根据当前路由加载对应控制器的语言包到 Translator
*
* 对外 api/优先请求头 langzh / zh-cn → zh-cnen → enms → 马来语包),未传则 think-lang再默认 zh-cn不根据浏览器 Accept-Language
* 对外 api/语言优先级为 请求头 lang → GET/POST 参数 lang支持 zh/ZH、en/EN 等)→ think-lang再默认 zh-cn不根据浏览器 Accept-Language
* admin/think-lang → Accept-Language → 配置默认
*/
class LoadLangPack implements MiddlewareInterface
@@ -45,6 +45,16 @@ class LoadLangPack implements MiddlewareInterface
if ($langHeader !== '') {
$langSet = $this->normalizeLangHeader($langHeader, $allowLangList);
}
if ($langSet === null) {
$langParam = strval($request->get('lang', ''));
if ($langParam === '') {
$langParam = strval($request->post('lang', ''));
}
$langParam = trim($langParam);
if ($langParam !== '') {
$langSet = $this->normalizeLangHeader($langParam, $allowLangList);
}
}
}
// 与后台 Vue 一致的 think-lang对外 API 在 lang 未设置时仍可生效)

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* 积分商城业务配置(含 PlayX 比例等)
*/
class MallBusinessConfig extends Model
{
protected string $name = 'mall_business_config';
protected bool $autoWriteTimestamp = true;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'return_ratio' => 'float',
'unlock_ratio' => 'float',
'points_to_cash_ratio' => 'float',
];
}

View File

@@ -72,5 +72,13 @@ class MallOrder extends Model
{
return $this->belongsTo(MallAddress::class, 'mall_address_id', 'id');
}
/**
* 订单 user_id 存 playX 侧用户标识字符串,与 mall_user_asset.playx_user_id 对齐。
*/
public function mallUserAsset(): \think\model\relation\BelongsTo
{
return $this->belongsTo(MallUserAsset::class, 'user_id', 'playx_user_id');
}
}

View File

@@ -6,6 +6,7 @@ use app\common\model\MallItem;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use support\Log;
use Workerman\Timer;
use Workerman\Worker;
@@ -124,13 +125,17 @@ class AngpowImportJobs
if (!$order instanceof MallOrder) {
continue;
}
$row = $this->buildAngpowRow($order);
if ($row === null) {
$rowResult = $this->buildAngpowRow($order);
if ($rowResult === null) {
// 构造失败:直接标为可重试失败
$this->markFailedAttempt($order, 'Build payload failed');
continue;
}
$payload['angpow'][] = $row;
if (is_string($rowResult)) {
$this->markFailedAttempt($order, $rowResult);
continue;
}
$payload['angpow'][] = $rowResult;
$orderIds[] = $order->id;
}
@@ -181,6 +186,7 @@ class AngpowImportJobs
$data = json_decode($body, true);
if (!is_array($data)) {
Log::error($this->formatAngpowInvalidJsonBodyForLog($res, $body, $orderIds));
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
@@ -205,7 +211,9 @@ class AngpowImportJobs
return;
}
// 失败:整批视为失败(对方未提供逐条返回)
// 失败:整批视为失败(对方未提供逐条返回);解析后的 JSON 仅写入项目日志
$this->logAngpowPushRejectedParsedBody($orderIds, $data);
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
@@ -214,7 +222,10 @@ class AngpowImportJobs
}
}
private function buildAngpowRow(MallOrder $order): ?array
/**
* @return array<string, mixed>|string|null 成功返回行数组;用户名缺失返回错误文案字符串;其它构造失败返回 null
*/
private function buildAngpowRow(MallOrder $order): array|string|null
{
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset) {
@@ -229,6 +240,11 @@ class AngpowImportJobs
return null;
}
$memberLogin = trim(strval($asset->username ?? ''));
if ($memberLogin === '') {
return 'User username empty';
}
$item = null;
if ($order->mallItem) {
$item = $order->mallItem;
@@ -252,7 +268,7 @@ class AngpowImportJobs
$end = gmdate('Y-m-d\TH:i:s\Z', strtotime($order->end_time));
return [
'member_login' => strval($asset->playx_user_id),
'member_login' => $memberLogin,
'start_time' => $start,
'end_time' => $end,
'amount' => $order->amount,
@@ -266,6 +282,84 @@ class AngpowImportJobs
];
}
/**
* 对方返回体无法解析为「根级 JSON 对象」时,生成写入项目日志的完整说明(不落库订单 fail_reason
*
* @param list<int|string> $orderIds
* @param mixed $response Guzzle 响应或其它
*/
private function formatAngpowInvalidJsonBodyForLog(mixed $response, string $rawBody, array $orderIds): string
{
$httpPart = 'HTTP unknown';
if (is_object($response) && method_exists($response, 'getStatusCode')) {
$code = $response->getStatusCode();
if (is_int($code)) {
$httpPart = 'HTTP ' . $code;
}
}
$trimmed = trim($rawBody);
if ($trimmed === '') {
$detail = 'empty body';
} elseif (json_last_error() !== JSON_ERROR_NONE) {
$msg = json_last_error_msg();
$detail = is_string($msg) && $msg !== '' ? $msg : 'JSON parse error';
} else {
$detail = 'root is not a JSON object';
}
$idPart = implode(',', $orderIds);
$snippet = '';
if ($trimmed !== '') {
$oneLine = preg_replace('/\s+/', ' ', $trimmed);
$snippet = is_string($oneLine) && $oneLine !== '' ? $oneLine : $trimmed;
$maxLen = 8000;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($snippet, 'UTF-8') > $maxLen) {
$snippet = mb_substr($snippet, 0, $maxLen, 'UTF-8') . '…';
}
} elseif (strlen($snippet) > $maxLen) {
$snippet = substr($snippet, 0, $maxLen) . '…';
}
}
$head = '[AngpowImport] response not a JSON object | order_ids=' . $idPart . ' | ' . $httpPart . ' | ' . $detail;
if ($snippet === '') {
return $head;
}
return $head . ' | body=' . $snippet;
}
/**
* 可解析 JSON 但业务失败时,将解析结果写入项目日志(订单仍只记 message 等业务文案)。
*
* @param list<int|string> $orderIds
* @param array<mixed> $parsed
*/
private function logAngpowPushRejectedParsedBody(array $orderIds, array $parsed): void
{
$flags = JSON_UNESCAPED_UNICODE;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$flags = $flags | JSON_INVALID_UTF8_SUBSTITUTE;
}
$encoded = json_encode($parsed, $flags);
if (!is_string($encoded)) {
$encoded = 'json_encode failed';
}
$maxLen = 8000;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($encoded, 'UTF-8') > $maxLen) {
$encoded = mb_substr($encoded, 0, $maxLen, 'UTF-8') . '…';
}
} elseif (strlen($encoded) > $maxLen) {
$encoded = substr($encoded, 0, $maxLen) . '…';
}
Log::error('[AngpowImport] push rejected (parsed JSON) | order_ids=' . implode(',', $orderIds) . ' | body=' . $encoded);
}
/**
* 单条失败原因压成一行,避免异常信息自带换行导致与 attempt 分段混在一起。
*/

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\support;
use support\Log;
use support\Response;
use Webman\Http\Request as WebmanRequest;
@@ -93,6 +94,20 @@ abstract class BaseController
$statusCode = $header['statusCode'] ?? 200;
unset($header['statusCode']);
if ($code !== 1) {
$req = $this->request;
$method = $req ? $req->method() : '';
$path = $req ? trim($req->path(), '/') : '';
$ip = $req ? strval($req->getRealIp()) : '';
$msgForLog = $msg;
if (function_exists('mb_strlen') && function_exists('mb_substr') && mb_strlen($msgForLog) > 2000) {
$msgForLog = mb_substr($msgForLog, 0, 2000) . '...';
} elseif (strlen($msgForLog) > 2000) {
$msgForLog = substr($msgForLog, 0, 2000) . '...';
}
Log::error('[API] ' . $method . ' /' . $path . ' | business_code=' . strval($code) . ' http=' . strval($statusCode) . ' ip=' . $ip . ' msg=' . $msgForLog);
}
$headers = array_merge(['Content-Type' => 'application/json'], $header);
$options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {

View File

@@ -4,11 +4,12 @@
* PlayX 积分商城对接配置
*/
return [
// 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * 返还比例(仅亏损时)
/**
* 以下三项比例以数据表 mall_business_config 为准(后台「商城参数配置」)。
* 此处保留 env 仅作兼容/文档默认值;业务代码请使用 MallPlayxRatios::get()。
*/
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
// 解锁比例:今日可领取上限 = yesterday_total_deposit * 解锁比例
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
// 提现折算:积分 → 现金(如 10 分 = 1 元)
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
// Daily Push 签名校验PlayX 调用商城时使用)
'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')),
@@ -20,28 +21,20 @@ return [
// token 会话缓存过期时间(秒)
'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')),
/**
* 为 true 时:/api/v1/playx/verify-token 仅本地校验(查 token 表 + mall_playx_user_asset不请求 PlayX。
* 联调/无 PlayX 环境可开;上线对接 PlayX 后请设为 false 并配置 api.base_url
* 为 true 时verifyToken 不向 PlayX 请求;当前实现为联调占位——不校验请求中的 token
* 每次签发新 session用户标识见 verify_token_local_default_*(待对方提供校验接口后请设为 false 并走远程校验)
*/
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '1'), FILTER_VALIDATE_BOOLEAN),
/**
* 联调占位(待对方提供 token 校验接口后删除或清空verify_token_local_only 为 true 时,
* 若请求中的 token 与此字符串完全一致则视为有效合作方 JWT写入 mall_session
* 生产环境务必置空或通过环境变量覆盖为空。
*/
'dev_verify_token_exact' => strval(env(
'PLAYX_DEV_VERIFY_TOKEN_EXACT',
'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0bXlyIiwiYXV0aCI6IlJPTEVfTUVNQkVSIiwibWVyY2hhbnQiOiJwbHgiLCJ1c2VyX2Z1bGxuYW1lIjoiQW5uYSIsImN1cnJlbmN5IjoiTVlSIiwibGFuZ3VhZ2UiOiJ6aC1DTiIsInZpcCI6ZmFsc2UsImV4cCI6MTc3NjkzODI4N30.XVvNcnBcAEqdxqoNRmygkl826bLUfwH2xyBE8wiSTOSeJjv99DHKJOAgsE4ukJ-M_t1hPbraz9GO4qvOszOeDg'
)),
/** 命中 dev_verify_token_exact 时写入 session 的展示用户名(空则取 JWT user_fullname 或 sub */
'dev_verify_session_username' => strval(env('PLAYX_DEV_VERIFY_SESSION_USERNAME', 'yangyang123')),
/** 命中 dev_verify_token_exact 时写入 session 的 user_id空则取 JWT sub */
'dev_verify_session_user_id' => strval(env('PLAYX_DEV_VERIFY_SESSION_USER_ID', '')),
/** verify_token_local_only 为 true 时写入 mall_session 的 playx 用户标识 */
'verify_token_local_default_user_id' => strval(env('PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID', 'testmyr')),
/** verify_token_local_only 为 true 时写入 mall_session 的展示用户名 */
'verify_token_local_default_username' => strval(env('PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME', 'yangyang123')),
// PlayX API 配置(商城调用 PlayX 时使用)
'api' => [
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')),
'token_verify_url' => '/api/v1/auth/verify-token',
// 完整 https URL回调 Body merchant_code+request_date+request_id+token签名同序HMAC 同 angpow相对路径拼基址 + 商户体
'token_verify_url' => strval(env('PLAYX_TOKEN_VERIFY_URL', '/api/v1/auth/verify-token')),
'bonus_grant_url' => '/api/v1/bonus/grant',
'balance_credit_url' => '/api/v1/balance/credit',
'transaction_status_url' => '/api/v1/transaction/status',
@@ -62,6 +55,6 @@ return [
'verify_ssl' => filter_var(env('PLAYX_ANGPOW_IMPORT_VERIFY_SSL', '1'), FILTER_VALIDATE_BOOLEAN),
// 固定货币展示映射
'currency' => 'MYR',
'visual_name' => 'Angpow',
'visual_name' => 'Reward Mall',
],
];

View File

@@ -217,6 +217,10 @@ Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::cl
// admin/mall/dailyPush
Route::post('/admin/mall/dailyPush/backfillUsers', [\app\admin\controller\mall\DailyPush::class, 'backfillUsers']);
// admin/mall/playxConfig积分商城参数
Route::get('/admin/mall.PlayxConfig/index', [\app\admin\controller\mall\PlayxConfig::class, 'index']);
Route::post('/admin/mall.PlayxConfig/save', [\app\admin\controller\mall\PlayxConfig::class, 'save']);
// admin/routine/config
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']);

View File

@@ -230,6 +230,10 @@ Body 含 `receiver_name`(收货人,建议填写;实物兑换下单快照
- `token`
- `session`
可选 **`lang`**`zh` / `ZH` / `zh-cn` 返回中文 **`msg`**(默认中文),`en` / `EN` 返回英文可通过请求头、Query 或与本接口 Body 同传的表单字段传入。
建议 **`Content-Type: application/json`**Body 示例:`{"token":"..."}`。使用 `multipart/form-data` 时同样只传 `token`(及可选 `lang`)即可。
成功返回:
- `data.session_id`
- `data.user_id`

View File

@@ -71,15 +71,18 @@ flowchart LR
1. 用户在 playX 内打开积分商城入口iframe
2. playX 前端通过 postMessage 将 **playX 下发的 token**(及必要上下文)传给商城 H5。
3. 商城 H5 调用商城后端 **`POST /api/v1/mall/verifyToken`**,由商城向 playX 的 **Token Verification API**`playX.api.base_url` + `playX.api.token_verify_url`发起校验。
4. **前提**:配置 **`playX.verify_token_local_only = false`**,且 **`playX.api.base_url`** 已配置为可访问的 playX 基地址。
3. 商城 H5 调用商城后端 **`POST /api/v1/mall/verifyToken`**(前端只传 `token`),由商城后端向 **Token Verification API** 发起校验。
4. **前提**:配置 **`playX.verify_token_local_only = false`**,且 **`PLAYX_TOKEN_VERIFY_URL`** 已配置:
- **完整 `https://...` URL**(如回调):商城 `POST` 该地址JSON Body 含 **`merchant_code``request_date`Unix 秒)、`request_id``token`****`X-Request-Signature`** 与 angpow-imports **同源算法**(密钥 **`PLAYX_ANGPOW_IMPORT_AUTH_KEY`**),签名字符串为 **`merchant_code=...&request_date=...&request_id=...&token=...`**;无需 `PLAYX_ANGPOW_IMPORT_BASE_URL`
- **相对路径**(如 `/api/v1/auth/verify-token`):需同时配置 **`PLAYX_ANGPOW_IMPORT_BASE_URL`**Body 使用 **`request_date`**,并按商户约定附带签名等。
5. playX 返回 **`user_id``username`**(及可选会话过期时间等)。
6. 商城写入 **`mall_playx_session`**`session_id` + 上述 `user_id`/`username` + 过期时间),后续 H5 可用 **`session_id`** 或 **`token`(商城临时 token见模式 B** 调用资产/领取等接口。
幂等与安全:
- H5 **不要**把 playX 的 `user_id` 当作唯一可信凭据直传下单;**以 token 换 session** 或由商城签发 token 的流程为准。
- playX 侧 Token Verification API 的鉴权/签名若有按双方约定可参考《playX-接口文档》§2.1
- `PLAYX_TOKEN_VERIFY_URL` 为**完整 https URL**Body 含 **`merchant_code``request_date``request_id``token`**`X-Request-Signature` 为 angpow 同源 HMAC明文 **`merchant_code=...&request_date=...&request_id=...&token=...`**H5 不参与签名
- 若为**相对路径 + 基地址**商户网关:鉴权/签名由商城后端完成(`merchant_code/request_date/request_id` + `X-Request-Signature`H5 不参与签名计算。
#### 4.1.3 模式 B本地 / 无 playX 环境(商城自校验,不请求 playX
@@ -211,20 +214,56 @@ flowchart LR
### 5.2 Mall → playXToken Verification API
- **目的**:商城后端校验 token/session,获取可信 `user_id``username`
- **Method/Path示例占位**`POST /api/v1/auth/verify-token`
- **目的**:商城后端校验 token获取可信 `user_id``username`
- **Method**`POST`
- **Content-Type**`application/json`
请求字段说明(建议):
#### 5.2.1 回调网关(推荐当前联调)
- **完整 URL**:由环境变量 **`PLAYX_TOKEN_VERIFY_URL`** 填完整地址,例如:
`https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token`
- **请求 Header**:与 **angpow-imports** 相同风格,仅 **`Content-Type: application/json`**、**`X-Request-Signature`**`Base64(HMAC-SHA1(canonical, PLAYX_ANGPOW_IMPORT_AUTH_KEY))`,密钥解析与 angpow 一致hex / base64 / 明文)。
- **签名明文 canonical**`merchant_code={merchant_code}&request_date={request_date}&request_id={request_id}&token={token}``request_date` 为 Unix 秒字符串,与 Body 一致)。
- **请求 Body**(回调网关实际校验;在文档 `request_id`+`token` 基础上增加对端要求的 **`merchant_code``request_date`**
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `request_id` | String | 是 | 商城系统生成的唯一请求流水号。 |
| `token``session` | String | 是 | 从带有商城的 Iframe `postMessage` 接收到的用户加密登录散列或临时会话凭证。 |
| `merchant_code` | String | 是 | `PLAYX_ANGPOW_MERCHANT_CODE` 一致。 |
| `request_date` | String | 是 | Unix 秒时间戳字符串,须与参与签名一致。 |
| `request_id` | String | 是 | 商城生成的请求追踪号。 |
| `token` | String | 是 | 前端传入的 playX 临时凭证。 |
请求示例:
```json
{
"merchant_code": "plx",
"request_date": "1700000000",
"request_id": "mall_20260319_9f1b6d",
"token": "eyJhbGciOi..."
}
```
#### 5.2.2 商户网关(相对路径 + HMAC
- **Base URL**`PLAYX_ANGPOW_IMPORT_BASE_URL`
- **Path**`PLAYX_TOKEN_VERIFY_URL`(相对路径,如 `/api/v1/auth/verify-token`
- **完整 URL**`{PLAYX_ANGPOW_IMPORT_BASE_URL}{PLAYX_TOKEN_VERIFY_URL}`
- **签名 Header**`X-Request-Signature``Base64(HMAC-SHA1(canonical, key))`
- **签名明文 canonical**`merchant_code={merchant_code}&request_date={request_date}&request_id={request_id}&token={token}`
- **request_date**Unix 时间戳字符串(秒)
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `merchant_code` | String | 是 | 商户编码。 |
| `request_date` | String | 是 | 请求时间戳(秒)。 |
| `request_id` | String | 是 | 商城系统生成的唯一请求流水号。 |
| `token` | String | 是 | 用户登录凭证。 |
```json
{
"merchant_code": "plx",
"request_date": "1700000000",
"request_id": "mall_20260319_9f1b6d",
"token": "eyJhbGciOi..."
}

View File

@@ -272,8 +272,8 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
* 仅接受商城临时登录 token类型 **`muser`**),校验 `token` 表后写入 **`mall_session`**。
* 返回的 `data.user_id`**`playx_user_id`**(与资产表一致)。
* **`verify_token_local_only = false`**(生产对接 playX
* 需配置 **`playX.api.base_url`**,由商城向 playX 发起 `POST` 校验(请求/响应约定见 **`docs/playX-接口待完善清单.md`** 第一部分 §1)。
* 若未配置 `base_url`,返回 `playX API not configured`
* 需配置 **`PLAYX_TOKEN_VERIFY_URL`**(完整 `https` 回调或相对路径)、**`PLAYX_ANGPOW_MERCHANT_CODE`**、**`PLAYX_ANGPOW_IMPORT_AUTH_KEY`** 等(见 `docs/PlayX-对接文档(积分商城).md` §5.2)。
* 相对路径时尚需 **`PLAYX_ANGPOW_IMPORT_BASE_URL`**;未配置则返回未配置类错误
#### 请求参数
@@ -281,6 +281,8 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
* `token`Body 优先;`session` 兼容字段Query 也可传 `token`
可选 **`lang`**`zh` / `ZH` / `zh-cn` 中文 **`msg`**(默认),`en` / `EN` 英文可通过请求头、Query 或与 Body 同传的字段指定。
#### 返回(成功 data
| 字段 | 类型 | 说明 |

View File

@@ -271,159 +271,369 @@ curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
---
## 5. 其他接口一览(摘要
## 5. 其他接口一览(前端联调版
> 下列均为 **BuildAdmin 通用 `code/msg/time/data` 结构**;成功时 `code=1`。
> 下列接口统一返回 `code/msg/time/data`;成功通常为 `code=1`。
> 除 `verifyToken` 外,其余用户接口均需携带 `session_id` 或 `token`(见 §4
### 5.0 `POST /api/v1/mall/dailyPush`(后端对后端)
**用途**playX 按日推送用户资产基础数据到商城(前端一般不直接调用)。
**请求示例:**
```json
{
"request_id": "report_20260430",
"rows": [
{
"user_id": "U10001",
"username": "demo_user",
"yesterday_total_deposit": 1000,
"yesterday_win_loss_net": -500
}
]
}
```
### 5.1 `POST /api/v1/mall/verifyToken`
**用途**:把 playX token 换成商城 `session_id`
用于将 **playX token**(或本地联调 token**商城 `session_id`**
**语言 `lang`(响应 `msg` 等多语言)**
- **默认中文**`zh-cn`)。
- 可通过 **请求头 `lang`**、**Query `lang`** 或 **表单/JSON 字段 `lang`** 指定:`zh` / `ZH` / `zh-cn` → 中文,`en` / `EN` → 英文(与 `LoadLangPack` 规则一致)。
| 参数位置 | 名称 | 说明 |
|----------|------|------|
| POST/GET | `token``session` | playX 或商城 token |
**前端入参约定(重要)**
- 调用本接口时 **仅需 `token`**(或兼容 `session`**不要**传 `request_id` / `request_date` / 商户字段(均由商城后端生成后请求上游)。
- 建议 **`Content-Type: application/json`**Body`{"token":"..."}`。若使用 **form-data**(如图二),请同样只传 `token`(及可选 `lang`);避免缺少 `token` 键名。
- 商城请求上游回调地址时Body 含 **`merchant_code``request_date`(秒)、`request_id``token`**`X-Request-Signature` 与 angpow-imports **同源算法**canonical 为 **`merchant_code=...&request_date=...&request_id=...&token=...`**。
**说明**`playX.verify_token_local_only=true`(默认),商城**仅本地校验** token不请求 playX 远程接口;远程模式需配置 `PLAYX_API_BASE_URL` 等。
**请求示例**
```json
{
"token": "eyJhbGciOi..."
}
```
**成功 `data` 示例:**
带英文响应示例Query
| 字段 | 说明 |
|------|------|
| `session_id` | 后续接口可带此字段 |
| `user_id` | playX 用户 ID 或映射后的标识 |
| `username` | 用户名 |
| `token_expire_at` | ISO8601 过期时间 |
```http
POST /api/v1/mall/verifyToken?lang=en
Content-Type: application/json
---
{"token":"eyJhbGciOi..."}
```
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"user_id": "U10001",
"username": "demo_user",
"token_expire_at": "2026-04-30T16:20:00+08:00"
}
}
```
### 5.2 `GET /api/v1/mall/assets`
**用途**:查询当前用户积分资产。
查询积分资产;需 **§4** 身份。
**请求示例:**
```http
GET /api/v1/mall/assets?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
**成功 `data`**
| 字段 | 说明 |
|------|------|
| `locked_points` | 待领取积分 |
| `available_points` | 可用积分 |
| `today_limit` | 今日可领取上限 |
| `today_claimed` | 今日已领取 |
| `withdrawable_cash` | 可提现现金(由积分×配置比例换算,保留小数) |
---
**成功响应示例**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"locked_points": 120,
"available_points": 350,
"today_limit": 200,
"today_claimed": 80,
"withdrawable_cash": 35
}
}
```
### 5.3 `POST /api/v1/mall/claim`
**用途**:领取积分(幂等)。
领取积分;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"claim_request_id": "claim_20260430_0001"
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `claim_request_id` | 是 | 幂等请求号 |
**成功 `data`** 与资产结构一致(含 `locked_points``available_points``today_limit``today_claimed``withdrawable_cash` 等)。
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"locked_points": 60,
"available_points": 410,
"today_limit": 200,
"today_claimed": 140,
"withdrawable_cash": 41
}
}
```
### 5.4 `GET /api/v1/mall/items`
**用途**:获取商城商品列表(可按类型筛选)。
商品列表。
**请求示例:**
```http
GET /api/v1/mall/items?type=BONUS
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `type` | 否 | `BONUS` / `PHYSICAL` / `WITHDRAW`,筛选类型 |
**成功 `data`** `{ "list": [ ... ] }`
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 101,
"title": "10 MYR Bonus",
"type": "BONUS",
"points_cost": 1000
}
]
}
}
```
### 5.5 `POST /api/v1/mall/bonusRedeem`
**用途**:兑换红利商品。
红利兑换;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 101
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 商品 ID |
**成功 `data`**`order_id``status`(如 `PENDING`)等。
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300001",
"status": "PENDING"
}
}
```
### 5.6 `POST /api/v1/mall/physicalRedeem`
**用途**:兑换实物商品(需要地址)。
实物兑换;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 202,
"address_id": 12
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 实物商品 ID |
| `address_id` | 是 | `mall_address.id`(当前用户下地址);订单保存 `mall_address_id` 与地址快照 |
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300002",
"status": "PENDING"
}
}
```
### 5.7 `POST /api/v1/mall/withdrawApply`
**用途**:发起提现档位兑换申请。
提现类兑换申请;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 303
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 提现档位商品 ID |
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300003",
"status": "PENDING"
}
}
```
### 5.8 `GET /api/v1/mall/orders`
**用途**:查询当前用户订单列表。
订单列表;需 **§4** 身份。
**请求示例:**
```http
GET /api/v1/mall/orders?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
**成功 `data`** `{ "list": [ ... ] }`(含关联商品等,以实际返回为准)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"order_id": "ORD202604300001",
"status": "PENDING",
"item_title": "10 MYR Bonus"
}
]
}
}
```
---
### 5.9 `GET /api/v1/mall/pointsLogs`
**用途**:查询积分变动日志。
### 5.9 收货地址(`mall_address`
**请求示例:**
```http
GET /api/v1/mall/pointsLogs?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
> 下列接口均需携带 **§4 身份参数**`session_id` / `token` / `user_id` 之一)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 9001,
"change_points": 50,
"type": "CLAIM",
"remark": "daily claim"
}
]
}
}
```
#### 5.9.1 获取收货地址列表
- **方法**`GET`
- **路径**`/api/v1/mall/addressList`
### 5.10 收货地址(`mall_address`
**用途**:用户收货地址 CRUD。
返回 `data.list`:地址数组(按 `default_setting` 优先,其次 id 倒序)。
#### 5.10.1 `GET /api/v1/mall/addressList`
**请求示例:**
```http
GET /api/v1/mall/addressList?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
#### 5.9.2 添加收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressAdd`
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 12,
"receiver_name": "Tom",
"phone": "0123456789",
"detail_address": "KLCC",
"default_setting": 1
}
]
}
}
```
Body表单或 JSON 均可,建议 JSON
| 字段 | 必填 | 说明 |
|------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 联系电话 |
| `region` | 是 | 地区(可传数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址(短文本) |
| `default_setting` | 否 | `1` 设为默认地址;`0` 或不传为非默认 |
#### 5.10.2 `POST /api/v1/mall/addressAdd`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"receiver_name": "Tom",
"phone": "0123456789",
"region": "Kuala Lumpur,KLCC",
"detail_address": "Tower A, 8F",
"default_setting": 1
}
```
成功返回:`data.id` 为新地址 id。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"id": 13
}
}
```
#### 5.9.3 修改收货地址(含设置默认)
- **方法**`POST`
- **路径**`/api/v1/mall/addressEdit`
#### 5.10.3 `POST /api/v1/mall/addressEdit`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"id": 13,
"detail_address": "Tower B, 10F",
"default_setting": 1
}
```
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
| `receiver_name/phone/region/detail_address/default_setting` | 否 | 需要修改的字段(只更新传入项) |
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": null
}
```
`default_setting=1`:会自动把该用户其他地址的 `default_setting` 置为 0。
#### 5.10.4 `POST /api/v1/mall/addressDelete`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"id": 13
}
```
#### 5.9.4 删除收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressDelete`
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
若删除的是默认地址:服务端会将剩余地址里 id 最大的一条自动设为默认(若存在)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": null
}
```
---
@@ -433,7 +643,10 @@ Body
|-----------------|------|
| `PLAYX_DAILY_PUSH_SECRET` | 非空则 Daily Push 必须带合法 HMAC 头 |
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 playX 远程 |
| `PLAYX_API_BASE_URL` | 商城调用 playX 接口时使用与「playX 调商城」方向相反) |
| `PLAYX_ANGPOW_IMPORT_BASE_URL` | Angpow 推送等接口基地址;**相对路径** `verify-token` 时亦作其基地址 |
| `PLAYX_TOKEN_VERIFY_URL` | **完整 `https://` URL**:回调 Body `merchant_code`+`request_date`+`request_id`+`token`,签名明文同序;**相对路径**:拼基地址 + 商户四字段 |
| `PLAYX_ANGPOW_MERCHANT_CODE` | **回调与相对路径** `verify-token` 均需 |
| `PLAYX_ANGPOW_IMPORT_AUTH_KEY` | 回调与相对路径的 HMAC 密钥(与 angpow-imports 相同) |
---

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
/**
* 命令行模拟商城服务端调用 PLAYX_TOKEN_VERIFY_URL回调 verify-token
*
* 用法(在项目根目录执行):
* php scripts/playx-verify-token-callback-test.php "<playx_token>"
*
* 可选环境变量(未设置则从项目根 .env 读取):
* PLAYX_TOKEN_VERIFY_URL 默认 https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token
* PLAYX_ANGPOW_MERCHANT_CODE 回调 Body merchant_code必填
* PLAYX_ANGPOW_IMPORT_AUTH_KEY 与 angpow-imports 相同的 HMAC 密钥
*/
$root = dirname(__DIR__);
function loadDotEnv(string $path): void
{
if (!is_file($path)) {
return;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
$pos = strpos($line, '=');
$name = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
if ($name !== '') {
$_ENV[$name] = $value;
putenv($name . '=' . $value);
}
}
}
function resolveKeyBytes(string $authKey): string
{
$maybeBase64 = base64_decode($authKey, true);
if (is_string($maybeBase64) && $maybeBase64 !== '') {
return $maybeBase64;
}
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
if ($isHex) {
$hex = hex2bin($authKey);
if (is_string($hex) && $hex !== '') {
return $hex;
}
}
return $authKey;
}
function buildSignature(string $input, string $authKey): string
{
$raw = hash_hmac('sha1', $input, resolveKeyBytes($authKey), true);
return base64_encode($raw);
}
loadDotEnv($root . DIRECTORY_SEPARATOR . '.env');
$token = $argv[1] ?? '';
if ($token === '') {
fwrite(STDERR, "用法: php scripts/playx-verify-token-callback-test.php \"<playx_token>\"\n");
exit(1);
}
$url = strval($_ENV['PLAYX_TOKEN_VERIFY_URL'] ?? getenv('PLAYX_TOKEN_VERIFY_URL') ?: '');
if ($url === '') {
$url = 'https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token';
}
$merchantCode = strval($_ENV['PLAYX_ANGPOW_MERCHANT_CODE'] ?? getenv('PLAYX_ANGPOW_MERCHANT_CODE') ?: '');
$authKey = strval($_ENV['PLAYX_ANGPOW_IMPORT_AUTH_KEY'] ?? getenv('PLAYX_ANGPOW_IMPORT_AUTH_KEY') ?: '');
if ($merchantCode === '') {
fwrite(STDERR, "缺少 PLAYX_ANGPOW_MERCHANT_CODE请在 .env 配置或导出环境变量)\n");
exit(1);
}
if ($authKey === '') {
fwrite(STDERR, "缺少 PLAYX_ANGPOW_IMPORT_AUTH_KEY请在 .env 配置或导出环境变量)\n");
exit(1);
}
$requestId = 'mall_cli_' . uniqid();
$requestDate = strval(time());
$canonical = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = buildSignature($canonical, $authKey);
$payload = json_encode([
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
fwrite(STDERR, "JSON 编码失败\n");
exit(1);
}
echo "--- 等价 curlLinux / macOS / Git Bash---\n";
echo "curl -sS -X POST '" . $url . "' \\\n";
echo " -H 'Content-Type: application/json' \\\n";
echo " -H 'X-Request-Signature: " . $signature . "' \\\n";
echo " -d '" . $payload . "'\n\n";
echo "--- 本机直接请求PHP stream---\n";
echo "request_id={$requestId}\n";
echo "request_date={$requestDate}\n";
echo "canonical={$canonical}\n\n";
$body = $payload;
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-Request-Signature: {$signature}\r\n",
'content' => $body,
'timeout' => 15,
// 4xx/5xx 仍返回响应体,便于查看对端错误说明(否则 file_get_contents 直接 false
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) {
$err = error_get_last();
fwrite(STDERR, "请求失败: " . ($err['message'] ?? 'unknown') . "\n");
exit(1);
}
$statusLine = isset($http_response_header[0]) ? $http_response_header[0] : '';
echo "--- HTTP 响应行 ---\n";
echo $statusLine . "\n";
if (isset($http_response_header[1])) {
echo "--- 响应头(节选)---\n";
$max = min(12, count($http_response_header));
for ($i = 1; $i < $max; $i++) {
echo $http_response_header[$i] . "\n";
}
}
echo "--- 响应体 ---\n";
echo $result . "\n";
if (!preg_match('/\b200\b/', $statusLine)) {
fwrite(STDERR, "\n提示:非 2xx 时请根据响应体与对端核对签名字符串、Body 字段是否与回调网关约定一致。\n");
exit(2);
}

View File

@@ -0,0 +1,28 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/mall.PlayxConfig/'
export const actionUrl = new Map([
['index', url + 'index'],
['save', url + 'save'],
])
export function index() {
return createAxios({
url: actionUrl.get('index'),
method: 'get',
})
}
export function save(data: anyObj) {
return createAxios(
{
url: actionUrl.get('save'),
method: 'post',
data,
},
{
showSuccessMessage: true,
}
)
}

View File

@@ -0,0 +1,33 @@
<template>
<el-alert class="ba-table-alert mall-page-intro" type="info" show-icon :closable="false">
<template #title>{{ title }}</template>
<div class="mall-page-intro__desc">{{ desc }}</div>
</el-alert>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
/** 与 mall.pageIntro.<pageKey> 对应,如 dailyPush、userAsset */
pageKey: string
}>()
const { t } = useI18n()
const title = computed(() => t(`mall.pageIntro.${props.pageKey}.title`))
const desc = computed(() => t(`mall.pageIntro.${props.pageKey}.desc`))
</script>
<style scoped lang="scss">
.mall-page-intro {
margin-bottom: 12px;
}
.mall-page-intro__desc {
margin: 0;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: pre-line;
}
</style>

View File

@@ -1,13 +1,12 @@
export default {
id: 'id',
user_id: 'user_id',
date: 'date',
username: 'username',
yesterday_win_loss_net: 'yesterday_win_loss_net',
yesterday_total_deposit: 'yesterday_total_deposit',
lifetime_total_deposit: 'lifetime_total_deposit',
lifetime_total_withdraw: 'lifetime_total_withdraw',
create_time: 'create_time',
'quick Search Fields': 'id',
id: 'ID',
user_id: 'PlayX user ID',
date: 'Business date',
username: 'Username',
yesterday_win_loss_net: 'Yesterday net win/loss',
yesterday_total_deposit: 'Yesterday total deposit',
lifetime_total_deposit: 'Lifetime total deposit',
lifetime_total_withdraw: 'Lifetime total withdraw',
create_time: 'Created at',
'quick Search Fields': 'ID',
}

View File

@@ -3,7 +3,7 @@ export default {
manual_retry: 'Retry grant',
retry_confirm: 'Queue this order for grant retry?',
id: 'Order ID',
user_id: 'User ID',
user_id: 'Username',
type: 'Type',
'type BONUS': 'Bonus',
'type PHYSICAL': 'Physical',

View File

@@ -0,0 +1,58 @@
export default {
userAsset: {
title: 'About this page',
desc: 'Manage mall user asset records, including PlayX binding and points fields used for claims and redemptions.',
},
address: {
title: 'About this page',
desc: 'View and manage user shipping addresses for physical orders.',
},
order: {
title: 'About this page',
desc: 'Central list of mall orders (approval, shipping, points changes) with search and admin actions.',
},
dailyPush: {
title: 'About this page',
desc: 'Daily T+1 snapshots pushed by the partner; rows are stored here and drive user-asset related updates.',
},
claimLog: {
title: 'About this page',
desc: 'History of users moving points from locked/pending to available, filterable by time and user.',
},
item: {
title: 'About this page',
desc: 'Manage catalog items, stock, and multilingual display text for the points mall.',
},
pintsOrder: {
title: 'About this page',
desc: 'Orders paid with points—use for redemption and fulfillment checks.',
},
redemptionOrder: {
title: 'About this page',
desc: 'Redemption-style orders (e.g. bonus payouts) for issuance and reconciliation.',
},
playxCenter: {
title: 'About this page',
desc: 'PlayX hub: orders, daily push, claim logs, and user assets in one place for integration and troubleshooting.',
},
playxOrder: {
title: 'About this page',
desc: 'PlayX-facing order list for cross-checking with the mall.',
},
playxDailyPush: {
title: 'About this page',
desc: 'PlayX daily push rows in admin (compare with the standalone Daily push menu when sources align).',
},
playxClaimLog: {
title: 'About this page',
desc: 'PlayX users claim history on the mall side for amount and frequency checks.',
},
playxUserAsset: {
title: 'About this page',
desc: 'PlayX user assets in list form—cross-check with the main User assets screen when needed.',
},
playxConfig: {
title: 'About this page',
desc: 'Edit return/unlock/points-to-cash ratios used by daily push and asset APIs; values are stored in mall_business_config and apply immediately after save.',
},
}

View File

@@ -0,0 +1,8 @@
export default {
return_ratio: 'Return ratio (return_ratio)',
return_ratio_tip: 'In daily push, when yesterday net win/loss is negative: locked increment = |net| × this ratio.',
unlock_ratio: 'Unlock ratio (unlock_ratio)',
unlock_ratio_tip: 'In daily push: daily claim cap = yesterday total deposit × this ratio.',
points_to_cash_ratio: 'Points to cash ratio',
points_to_cash_ratio_tip: 'For withdrawable cash display: cash ≈ available points × this ratio.',
}

View File

@@ -115,5 +115,8 @@ export default {
mall_playxUserAsset: 'playX user assets',
mall_pintsOrder: 'Points orders',
mall_redemptionOrder: 'Redemption orders',
mall_playxConfig: 'Mall parameters',
mall_playxConfig_index: 'View',
mall_playxConfig_save: 'Save',
},
}

View File

@@ -3,7 +3,7 @@ export default {
manual_retry: '手动重试',
retry_confirm: '确认将该订单加入重试队列?',
id: 'ID',
user_id: 'playX-ID',
user_id: '用户名',
type: '类型',
'type BONUS': '红利(BONUS)',
'type PHYSICAL': '实物(PHYSICAL)',

View File

@@ -0,0 +1,58 @@
export default {
userAsset: {
title: '页面说明',
desc: '维护积分商城用户资产主数据,含 PlayX 用户绑定、积分字段等,用于前台领取与兑换鉴权。',
},
address: {
title: '页面说明',
desc: '查看与管理用户收货地址,供实物类订单发货使用。',
},
order: {
title: '页面说明',
desc: '集中查看商城订单(含审核、发货、积分变动等),支持按条件检索与后台操作。',
},
dailyPush: {
title: '页面说明',
desc: '展示合作方 T+1 每日推送的经营数据快照;接口写入后在此留痕,并用于同步用户资产相关字段。',
},
claimLog: {
title: '页面说明',
desc: '用户从待领取积分划转至可用积分的操作流水,可按时间与用户追溯。',
},
item: {
title: '页面说明',
desc: '维护积分商城上架商品、库存与多语言展示信息。',
},
pintsOrder: {
title: '页面说明',
desc: '以积分支付的订单列表,用于核对兑换与发货状态。',
},
redemptionOrder: {
title: '页面说明',
desc: '兑换类业务订单(如礼金等)列表,用于核对发放与对账。',
},
playxCenter: {
title: '页面说明',
desc: 'PlayX 相关能力的聚合入口:订单、每日推送、领取记录与用户资产分栏查看,便于联调与排障。',
},
playxOrder: {
title: '页面说明',
desc: '从 PlayX 视角查看与商城关联的订单数据,用于对接核对。',
},
playxDailyPush: {
title: '页面说明',
desc: 'PlayX 每日推送记录在后台的列表视图(与独立「每日推送」菜单数据源一致时可对照查看)。',
},
playxClaimLog: {
title: '页面说明',
desc: 'PlayX 用户在商城侧的领取流水,用于核对领取额度与频次。',
},
playxUserAsset: {
title: '页面说明',
desc: 'PlayX 用户资产在后台的列表视图,可与「用户资产」主菜单交叉核对。',
},
playxConfig: {
title: '页面说明',
desc: '配置每日推送用到的返还比例、解锁比例及积分折算现金比例保存后立即作用于接口逻辑mall_business_config。',
},
}

View File

@@ -0,0 +1,8 @@
export default {
return_ratio: '返还比例return_ratio',
return_ratio_tip: '每日推送中,仅当昨日净输赢为负时:待领取增量 = |净输赢| × 本比例。',
unlock_ratio: '解锁比例unlock_ratio',
unlock_ratio_tip: '每日推送中:今日可领取上限 = 昨日总充值 × 本比例。',
points_to_cash_ratio: '积分折算现金比例',
points_to_cash_ratio_tip: '用于资产接口中「可提现现金」等展示:现金 ≈ 可用积分 × 本比例。',
}

View File

@@ -116,5 +116,8 @@ export default {
mall_playxUserAsset: 'playX用户资产',
mall_pintsOrder: '积分订单',
mall_redemptionOrder: '兑换订单',
mall_playxConfig: '商城参数配置',
mall_playxConfig_index: '查看',
mall_playxConfig_save: '保存',
},
}

View File

@@ -53,8 +53,11 @@ export async function loadLang(app: App) {
*/
if (locale == 'zh-cn') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/zh-cn/**/*.ts', { eager: true }), locale))
// 积分商城后台页表格列在 setup 阶段即 t(),若仅依赖路由懒加载会出现列头显示为 key
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./backend/zh-cn/mall/**/*.ts', { eager: true }), locale))
} else if (locale == 'en') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/en/**/*.ts', { eager: true }), locale))
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./backend/en/mall/**/*.ts', { eager: true }), locale))
}
const messages = {

View File

@@ -34,6 +34,7 @@ function resolveAdminThinkLang(): string {
window.requests = []
window.tokenRefreshing = false
window.authRedirecting = false
const pendingMap = new Map()
const loadingInstance: LoadingInstance = {
target: null,
@@ -245,20 +246,119 @@ export default createAxios
* 处理异常
* @param {*} error
*/
/** 解析 axios 错误响应体(可能已是对象或 JSON 字符串) */
function normalizeAxiosErrorBody(data: unknown): anyObj | null {
if (data === undefined || data === null) {
return null
}
if (typeof data === 'string') {
try {
return JSON.parse(data) as anyObj
} catch {
return null
}
}
if (typeof data === 'object') {
return data as anyObj
}
return null
}
/**
* 后端未登录时通过 HTTP 303 + body.data.type === 'need login' 提示登录(见 Auth::LOGIN_RESPONSE_CODE
* Axios 将 303 视为失败,不会进入成功拦截器里对 code==303 的分支,需在此与之一致:清 token 并跳登录页。
*/
function redirectToLoginIfNeedLoginFromHttp303(error: any): boolean {
if (!error?.response || error.response.status !== 303) {
return false
}
const body = normalizeAxiosErrorBody(error.response.data)
if (!body || typeof body.data !== 'object' || body.data === null) {
return false
}
const needType = (body.data as anyObj).type
if (needType !== 'need login') {
return false
}
if (window.authRedirecting) {
return true
}
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (router.currentRoute.value.name === loginRouteName) {
return true
}
window.authRedirecting = true
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router.replace({ name: loginRouteName, query: to ? { to } : {} }).finally(() => {
window.authRedirecting = false
})
}
return true
}
function httpErrorStatusHandle(error: any) {
// 处理被取消的请求
if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message)
if (redirectToLoginIfNeedLoginFromHttp303(error)) {
return
}
let message = ''
if (error && error.response) {
switch (error.response.status) {
case 302:
message = i18n.global.t('axios.Interface redirected!')
break
case 303: {
const body303 = normalizeAxiosErrorBody(error.response.data)
const serverMsg303 = body303 && typeof body303.msg === 'string' ? body303.msg : ''
message = serverMsg303 !== '' ? serverMsg303 : i18n.global.t('axios.Interface redirected!')
break
}
case 400:
message = i18n.global.t('axios.Incorrect parameter!')
break
case 401:
message = i18n.global.t('axios.You do not have permission to operate!')
{
const data = error.response.data
const serverMsg = data && typeof data.msg === 'string' ? data.msg : ''
if (serverMsg) {
message = serverMsg
}
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) {
window.authRedirecting = true
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router
.replace({ name: loginRouteName, query: to ? { to } : {} })
.finally(() => {
window.authRedirecting = false
})
}
}
}
break
case 403:
message = i18n.global.t('axios.You do not have permission to operate!')
@@ -271,6 +371,49 @@ function httpErrorStatusHandle(error: any) {
break
case 409:
message = i18n.global.t('axios.The same data already exists in the system!')
{
const data = error.response.data
const serverMsg = data && typeof data.msg === 'string' ? data.msg : ''
const authExpiredHints = [
'Token expiration',
'token expiration',
'登录态过期',
'请重新登录',
'Session expired',
'session expired',
'please login again',
'sila log masuk semula',
]
const looksLikeAuthExpired =
serverMsg !== '' && authExpiredHints.some((hint) => serverMsg.toLowerCase().includes(hint.toLowerCase()))
if (looksLikeAuthExpired) {
message = serverMsg
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) {
window.authRedirecting = true
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router
.replace({ name: loginRouteName, query: to ? { to } : {} })
.finally(() => {
window.authRedirecting = false
})
}
}
}
}
break
case 500:
message = i18n.global.t('axios.Server internal error!')

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="address" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="claimLog" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="dailyPush" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="item" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="order" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -18,6 +19,7 @@ import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import createAxios from '/@/utils/axios'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -58,6 +60,17 @@ const baTable = new baTableClass(
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
formatter: (row: TableRow, _column: TableColumn, cellValue: string) => {
const r = row as Record<string, unknown>
const rel = r.mallUserAsset ?? r.mall_user_asset
if (rel && typeof rel === 'object' && 'username' in rel) {
const u = (rel as { username?: unknown }).username
if (typeof u === 'string' && u.trim() !== '') {
return u
}
}
return (cellValue ?? row.user_id ?? '').toString()
},
},
{
label: t('mall.order.type'),

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="pintsOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -2,12 +2,7 @@
<div class="default-main">
<!-- 语言包注入是异步的 t() 调用和子组件渲染都延后到注入完成后避免 intlify Not found -->
<template v-if="langReady">
<el-card shadow="never" class="mb-4">
<template #header>
<div class="card-header">{{ t('mall.playxCenter.title') }}</div>
</template>
<div class="desc">{{ t('mall.playxCenter.desc') }}</div>
</el-card>
<MallPageIntro page-key="playxCenter" class="mb-4" />
<el-tabs v-model="activeName" type="border-card">
<el-tab-pane :label="t('mall.playxCenter.orders')" name="orders">
@@ -34,6 +29,7 @@ import PlayxOrderIndex from '/@/views/backend/mall/playxOrder/index.vue'
import PlayxDailyPushIndex from '/@/views/backend/mall/playxDailyPush/index.vue'
import PlayxClaimLogIndex from '/@/views/backend/mall/playxClaimLog/index.vue'
import PlayxUserAssetIndex from '/@/views/backend/mall/playxUserAsset/index.vue'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import { useConfig } from '/@/stores/config'
import { mergeMessage } from '/@/lang/index'
@@ -92,11 +88,5 @@ onMounted(async () => {
.mb-4 {
margin-bottom: 16px;
}
.card-header {
font-weight: 600;
}
.desc {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxClaimLog" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -0,0 +1,144 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxConfig" />
<el-alert class="ba-table-alert" v-if="remark" :title="remark" type="info" show-icon />
<el-card shadow="never" v-loading="loading">
<el-form
v-if="!loading"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent=""
@keyup.enter="onSubmit()"
>
<el-form-item :label="t('mall.playxConfig.return_ratio')" prop="return_ratio">
<el-input-number
v-model="form.return_ratio"
:min="0"
:max="100"
:step="0.01"
:precision="2"
controls-position="right"
class="w100"
/>
<div class="form-tip">{{ t('mall.playxConfig.return_ratio_tip') }}</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.unlock_ratio')" prop="unlock_ratio">
<el-input-number
v-model="form.unlock_ratio"
:min="0"
:max="100"
:step="0.01"
:precision="2"
controls-position="right"
class="w100"
/>
<div class="form-tip">{{ t('mall.playxConfig.unlock_ratio_tip') }}</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.points_to_cash_ratio')" prop="points_to_cash_ratio">
<el-input-number
v-model="form.points_to_cash_ratio"
:min="0"
:max="100"
:step="0.01"
:precision="2"
controls-position="right"
class="w100"
/>
<div class="form-tip">{{ t('mall.playxConfig.points_to_cash_ratio_tip') }}</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit()">{{ t('Save') }}</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { onMounted, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { index, save } from '/@/api/backend/mall/playxConfig'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
defineOptions({
name: 'mall/playxConfig',
})
const { t } = useI18n()
const formRef = useTemplateRef<FormInstance>('formRef')
const loading = ref(true)
const submitting = ref(false)
const remark = ref('')
const form = reactive({
return_ratio: 0.1,
unlock_ratio: 0.1,
points_to_cash_ratio: 0.1,
})
const rules: FormRules = {
return_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.return_ratio') }) }],
unlock_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.unlock_ratio') }) }],
points_to_cash_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.points_to_cash_ratio') }) }],
}
const loadData = () => {
loading.value = true
index()
.then((res) => {
remark.value = res.data.remark ?? ''
const row = res.data.row ?? {}
if (row.return_ratio !== undefined && row.return_ratio !== null) {
form.return_ratio = parseFloat(String(row.return_ratio))
}
if (row.unlock_ratio !== undefined && row.unlock_ratio !== null) {
form.unlock_ratio = parseFloat(String(row.unlock_ratio))
}
if (row.points_to_cash_ratio !== undefined && row.points_to_cash_ratio !== null) {
form.points_to_cash_ratio = parseFloat(String(row.points_to_cash_ratio))
}
})
.finally(() => {
loading.value = false
})
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (!valid) return
submitting.value = true
save({
return_ratio: form.return_ratio,
unlock_ratio: form.unlock_ratio,
points_to_cash_ratio: form.points_to_cash_ratio,
})
.then(() => {
loadData()
})
.finally(() => {
submitting.value = false
})
})
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.w100 {
width: 100%;
}
.form-tip {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxDailyPush" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import createAxios from '/@/utils/axios'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxUserAsset" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="redemptionOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="userAsset" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -19,6 +20,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -3,6 +3,7 @@ interface Window {
lazy: number
unique: number
tokenRefreshing: boolean
authRedirecting: boolean
requests: Function[]
eventSource: EventSource
loadLangHandle: Record<string, any>