Compare commits
25 Commits
7af40bdd1f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c5b1d212a | |||
| 2f8c4f4ced | |||
| afa3750035 | |||
| 234c607db0 | |||
| 6883e74cf5 | |||
| ddd533f436 | |||
| ec499dce0f | |||
| c2c2baeaec | |||
| d44ce36286 | |||
| 9ce351a0c7 | |||
| 7acdc414fc | |||
| 907f7cb315 | |||
| 713170308b | |||
| 438580d72c | |||
| 04680408e6 | |||
| 05f2d6f084 | |||
| 29ab883f4e | |||
| ccdb58ea1d | |||
| 487016e037 | |||
| d9b2ebafa9 | |||
| cf6aa7e9e9 | |||
| 8017d1819a | |||
| 3b687489a0 | |||
| 0a1109c109 | |||
| eb3bdaa005 |
@@ -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=
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\admin\controller\mall;
|
||||
|
||||
use Throwable;
|
||||
use app\common\library\MallDailyPushBackfill;
|
||||
use app\common\controller\Backend;
|
||||
use support\Response;
|
||||
use Webman\Http\Request;
|
||||
@@ -57,5 +58,55 @@ class DailyPush extends Backend
|
||||
|
||||
return $this->_index();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按历史 mall_daily_push 记录回补用户信息
|
||||
*/
|
||||
public function backfillUsers(Request $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$dateFrom = trim(strval($request->post('date_from', $request->get('date_from', ''))));
|
||||
$dateTo = trim(strval($request->post('date_to', $request->get('date_to', ''))));
|
||||
$limitRaw = strval($request->post('limit', $request->get('limit', '0')));
|
||||
$dryRunRaw = strval($request->post('dry_run', $request->get('dry_run', '0')));
|
||||
|
||||
if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) {
|
||||
return $this->error('date_from 格式错误,需为 YYYY-MM-DD');
|
||||
}
|
||||
if ($dateTo !== '' && !$this->isValidDate($dateTo)) {
|
||||
return $this->error('date_to 格式错误,需为 YYYY-MM-DD');
|
||||
}
|
||||
if (!is_numeric($limitRaw)) {
|
||||
return $this->error('limit 必须是数字');
|
||||
}
|
||||
$limit = intval($limitRaw);
|
||||
if ($limit < 0) {
|
||||
return $this->error('limit 不能小于 0');
|
||||
}
|
||||
$dryRun = in_array(strtolower($dryRunRaw), ['1', 'true', 'yes', 'on'], true);
|
||||
|
||||
$service = new MallDailyPushBackfill();
|
||||
$result = $service->backfill(
|
||||
$dateFrom === '' ? null : $dateFrom,
|
||||
$dateTo === '' ? null : $dateTo,
|
||||
$limit,
|
||||
$dryRun
|
||||
);
|
||||
|
||||
return $this->success('', $result);
|
||||
}
|
||||
|
||||
private function isValidDate(string $value): bool
|
||||
{
|
||||
$dt = \DateTime::createFromFormat('Y-m-d', $value);
|
||||
if (!$dt) {
|
||||
return false;
|
||||
}
|
||||
return $dt->format('Y-m-d') === $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
98
app/admin/controller/mall/PlayxConfig.php
Normal file
98
app/admin/controller/mall/PlayxConfig.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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_date(Unix 秒);签名字符串与 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,52 +576,75 @@ class Playx extends Api
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地校验 temLogin 等写入的商城 token(类型 muser),写入 mall_session
|
||||
* 本地联调:不校验 token,按配置默认用户签发新 mall_session(待 PlayX 远程校验就绪后关闭 verify_token_local_only)。
|
||||
*/
|
||||
private function verifyTokenLocal(string $token): Response
|
||||
private function verifyTokenLocalOpen(): Response
|
||||
{
|
||||
$tokenData = Token::get($token);
|
||||
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$tokenType = strval($tokenData['type'] ?? '');
|
||||
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
$assetId = intval($tokenData['user_id'] ?? 0);
|
||||
if ($assetId <= 0) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$asset = MallUserAsset::where('id', $assetId)->find();
|
||||
if (!$asset) {
|
||||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||||
}
|
||||
|
||||
$playxUserId = strval($asset->playx_user_id ?? '');
|
||||
$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
|
||||
@@ -543,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('', [
|
||||
@@ -1132,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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 用户关联到商城用户',
|
||||
|
||||
91
app/command/MallDailyPushBackfillUsers.php
Normal file
91
app/command/MallDailyPushBackfillUsers.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\library\MallDailyPushBackfill;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand('mall:daily-push:backfill-users', '根据历史每日推送回补用户信息')]
|
||||
class MallDailyPushBackfillUsers extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, '开始日期(含),格式 YYYY-MM-DD');
|
||||
$this->addOption('to', null, InputOption::VALUE_OPTIONAL, '结束日期(含),格式 YYYY-MM-DD');
|
||||
$this->addOption('limit', null, InputOption::VALUE_OPTIONAL, '最多处理用户数(按 user_id 去重后)', '0');
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, '仅预览,不落库');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$dateFrom = strval($input->getOption('from') ?? '');
|
||||
$dateTo = strval($input->getOption('to') ?? '');
|
||||
$limitRaw = strval($input->getOption('limit') ?? '0');
|
||||
$dryRun = boolval($input->getOption('dry-run'));
|
||||
|
||||
if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) {
|
||||
$output->writeln('<error>参数 --from 日期格式错误,需为 YYYY-MM-DD</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($dateTo !== '' && !$this->isValidDate($dateTo)) {
|
||||
$output->writeln('<error>参数 --to 日期格式错误,需为 YYYY-MM-DD</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_numeric($limitRaw)) {
|
||||
$output->writeln('<error>参数 --limit 必须是数字</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$limit = intval($limitRaw);
|
||||
if ($limit < 0) {
|
||||
$output->writeln('<error>参数 --limit 不能小于 0</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$service = new MallDailyPushBackfill();
|
||||
$result = $service->backfill(
|
||||
$dateFrom === '' ? null : $dateFrom,
|
||||
$dateTo === '' ? null : $dateTo,
|
||||
$limit,
|
||||
$dryRun
|
||||
);
|
||||
|
||||
$output->writeln('<info>DailyPush 用户回补完成</info>');
|
||||
$output->writeln('dry_run: ' . ($result['dry_run'] ? 'true' : 'false'));
|
||||
$output->writeln('scanned_rows: ' . strval($result['scanned_rows']));
|
||||
$output->writeln('target_users: ' . strval($result['target_users']));
|
||||
$output->writeln('processed_users: ' . strval($result['processed_users']));
|
||||
$output->writeln('created_users: ' . strval($result['created_users']));
|
||||
$output->writeln('updated_users: ' . strval($result['updated_users']));
|
||||
$output->writeln('unchanged_users: ' . strval($result['unchanged_users']));
|
||||
$output->writeln('failed_users: ' . strval($result['failed_users']));
|
||||
|
||||
$errors = $result['errors'];
|
||||
if (is_array($errors) && !empty($errors)) {
|
||||
$output->writeln('<comment>错误明细:</comment>');
|
||||
foreach ($errors as $err) {
|
||||
$uid = strval($err['user_id'] ?? '');
|
||||
$msg = strval($err['message'] ?? '');
|
||||
$output->writeln("- user_id={$uid}, message={$msg}");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isValidDate(string $value): bool
|
||||
{
|
||||
$dt = \DateTime::createFromFormat('Y-m-d', $value);
|
||||
if (!$dt) {
|
||||
return false;
|
||||
}
|
||||
return $dt->format('Y-m-d') === $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
160
app/common/library/MallDailyPushBackfill.php
Normal file
160
app/common/library/MallDailyPushBackfill.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
use app\common\model\MallDailyPush;
|
||||
use app\common\model\MallUserAsset;
|
||||
use ba\Random;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 基于历史每日推送记录回补/同步用户资产主信息。
|
||||
*/
|
||||
class MallDailyPushBackfill
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function backfill(?string $dateFrom, ?string $dateTo, int $limit = 0, bool $dryRun = false): array
|
||||
{
|
||||
$query = MallDailyPush::order('create_time', 'desc')->order('id', 'desc');
|
||||
if ($dateFrom !== null && $dateFrom !== '') {
|
||||
$query->where('date', '>=', $dateFrom);
|
||||
}
|
||||
if ($dateTo !== null && $dateTo !== '') {
|
||||
$query->where('date', '<=', $dateTo);
|
||||
}
|
||||
|
||||
$rows = $query->select();
|
||||
$seenUserIds = [];
|
||||
|
||||
$stats = [
|
||||
'dry_run' => $dryRun,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'limit' => $limit,
|
||||
'scanned_rows' => 0,
|
||||
'target_users' => 0,
|
||||
'processed_users' => 0,
|
||||
'created_users' => 0,
|
||||
'updated_users' => 0,
|
||||
'unchanged_users' => 0,
|
||||
'failed_users' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$stats['scanned_rows']++;
|
||||
$playxUserId = trim(strval($row->user_id ?? ''));
|
||||
if ($playxUserId === '' || isset($seenUserIds[$playxUserId])) {
|
||||
continue;
|
||||
}
|
||||
$seenUserIds[$playxUserId] = true;
|
||||
$stats['target_users']++;
|
||||
|
||||
if ($limit > 0 && $stats['target_users'] > $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$username = trim(strval($row->username ?? ''));
|
||||
try {
|
||||
$result = $this->ensureAssetForPlayx($playxUserId, $username, $dryRun);
|
||||
$stats['processed_users']++;
|
||||
if ($result['created']) {
|
||||
$stats['created_users']++;
|
||||
} elseif ($result['updated']) {
|
||||
$stats['updated_users']++;
|
||||
} else {
|
||||
$stats['unchanged_users']++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$stats['failed_users']++;
|
||||
$stats['errors'][] = [
|
||||
'user_id' => $playxUserId,
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created:bool,updated:bool}
|
||||
*/
|
||||
private function ensureAssetForPlayx(string $playxUserId, string $username, bool $dryRun): array
|
||||
{
|
||||
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
|
||||
if ($asset) {
|
||||
$updated = false;
|
||||
$uname = trim($username);
|
||||
if ($uname !== '' && strval($asset->username ?? '') !== $uname) {
|
||||
$asset->username = $uname;
|
||||
$updated = true;
|
||||
}
|
||||
if ($updated && !$dryRun) {
|
||||
$asset->save();
|
||||
}
|
||||
return ['created' => false, 'updated' => $updated];
|
||||
}
|
||||
|
||||
$effectiveUsername = trim($username);
|
||||
if ($effectiveUsername === '') {
|
||||
$effectiveUsername = 'playx_' . $playxUserId;
|
||||
}
|
||||
|
||||
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
|
||||
if ($byName) {
|
||||
$updated = strval($byName->playx_user_id ?? '') !== $playxUserId;
|
||||
if ($updated && !$dryRun) {
|
||||
$byName->playx_user_id = $playxUserId;
|
||||
$byName->save();
|
||||
}
|
||||
return ['created' => false, 'updated' => $updated];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
return ['created' => true, 'updated' => false];
|
||||
}
|
||||
|
||||
$phone = $this->buildTempPhone();
|
||||
if ($phone === null) {
|
||||
throw new \RuntimeException('Failed to allocate phone for playx user');
|
||||
}
|
||||
$pwd = hash_password(Random::build('alnum', 16));
|
||||
$now = time();
|
||||
$created = MallUserAsset::create([
|
||||
'playx_user_id' => $playxUserId,
|
||||
'username' => $effectiveUsername,
|
||||
'phone' => $phone,
|
||||
'password' => $pwd,
|
||||
'admin_id' => 0,
|
||||
'locked_points' => 0,
|
||||
'available_points' => 0,
|
||||
'today_limit' => 0,
|
||||
'today_claimed' => 0,
|
||||
'today_limit_date' => null,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
if (!$created) {
|
||||
throw new \RuntimeException('Failed to create mall_user_asset');
|
||||
}
|
||||
return ['created' => true, 'updated' => false];
|
||||
}
|
||||
|
||||
private function buildTempPhone(): ?string
|
||||
{
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
|
||||
if (!MallUserAsset::where('phone', $candidate)->find()) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
159
app/common/library/MallPlayxRatios.php
Normal file
159
app/common/library/MallPlayxRatios.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Webman\Http\Response;
|
||||
* 加载控制器语言包中间件(Webman 迁移版,等价 ThinkPHP LoadLangPack)
|
||||
* 根据当前路由加载对应控制器的语言包到 Translator
|
||||
*
|
||||
* 对外 api/:优先请求头 lang(zh / zh-cn → zh-cn,en → en,ms → 马来语包),未传则 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 未设置时仍可生效)
|
||||
|
||||
25
app/common/model/MallBusinessConfig.php
Normal file
25
app/common/model/MallBusinessConfig.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 分段混在一起。
|
||||
*/
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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,15 +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),
|
||||
/** 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',
|
||||
@@ -49,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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -214,6 +214,13 @@ Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::cl
|
||||
// admin/user/moneyLog
|
||||
Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']);
|
||||
|
||||
// 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']);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 → playX:Token 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..."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|
||||
@@ -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 相同) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
159
scripts/playx-verify-token-callback-test.php
Normal file
159
scripts/playx-verify-token-callback-test.php
Normal 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 "--- 等价 curl(Linux / 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);
|
||||
}
|
||||
28
web/src/api/backend/mall/playxConfig.ts
Normal file
28
web/src/api/backend/mall/playxConfig.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
33
web/src/components/mall/MallPageIntro.vue
Normal file
33
web/src/components/mall/MallPageIntro.vue
Normal 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>
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
58
web/src/lang/backend/en/mall/pageIntro.ts
Normal file
58
web/src/lang/backend/en/mall/pageIntro.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
8
web/src/lang/backend/en/mall/playxConfig.ts
Normal file
8
web/src/lang/backend/en/mall/playxConfig.ts
Normal 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.',
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
58
web/src/lang/backend/zh-cn/mall/pageIntro.ts
Normal file
58
web/src/lang/backend/zh-cn/mall/pageIntro.ts
Normal 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)。',
|
||||
},
|
||||
}
|
||||
8
web/src/lang/backend/zh-cn/mall/playxConfig.ts
Normal file
8
web/src/lang/backend/zh-cn/mall/playxConfig.ts
Normal 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: '用于资产接口中「可提现现金」等展示:现金 ≈ 可用积分 × 本比例。',
|
||||
}
|
||||
@@ -116,5 +116,8 @@ export default {
|
||||
mall_playxUserAsset: 'playX用户资产',
|
||||
mall_pintsOrder: '积分订单',
|
||||
mall_redemptionOrder: '兑换订单',
|
||||
mall_playxConfig: '商城参数配置',
|
||||
mall_playxConfig_index: '查看',
|
||||
mall_playxConfig_save: '保存',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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!')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
144
web/src/views/backend/mall/playxConfig/index.vue
Normal file
144
web/src/views/backend/mall/playxConfig/index.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
1
web/types/global.d.ts
vendored
1
web/types/global.d.ts
vendored
@@ -3,6 +3,7 @@ interface Window {
|
||||
lazy: number
|
||||
unique: number
|
||||
tokenRefreshing: boolean
|
||||
authRedirecting: boolean
|
||||
requests: Function[]
|
||||
eventSource: EventSource
|
||||
loadLangHandle: Record<string, any>
|
||||
|
||||
Reference in New Issue
Block a user