Compare commits

...

11 Commits

43 changed files with 1117 additions and 77 deletions

View File

@@ -18,6 +18,7 @@ DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
# PlayX 配置
# 以下三项比例以数据库表 mall_business_config 为准(后台 积分商城 → 商城参数配置);无记录时回退为下列 env 默认值
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1
PLAYX_POINTS_TO_CASH_RATIO=0.1
# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio仅亏损时
@@ -34,7 +35,7 @@ AGENT_AUTH_JWT_SECRET=
PLAYX_SESSION_EXPIRE_SECONDS=3600
# verifyToken 是否仅本地联调false=走对方远程校验)
PLAYX_VERIFY_TOKEN_LOCAL_ONLY=false
# verifyToken完整 https URL 时 Body request_id+token、签名明文同;相对路径时拼基址+商户四字段
# 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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ use app\common\model\MallDailyPush;
use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\library\MallPlayxRatios;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
@@ -231,8 +232,9 @@ class Playx extends Api
$requestId = 'report_' . $date;
}
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
$results = [];
$allDeduped = true;
@@ -332,8 +334,9 @@ class Playx extends Api
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
@@ -365,8 +368,9 @@ class Playx extends Api
]);
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
@@ -443,13 +447,18 @@ class Playx extends Api
$client = new \GuzzleHttp\Client($clientOptions);
if ($isAbsoluteVerifyUrl) {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($authKey === '') {
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
// PlayX 文档Body 仅 request_id + token。X-Request-Signature 与 angpow-imports 同源算法HMAC-SHA1→Base64、密钥解析同 angpow明文与 Body 字段一致
$signatureInput = 'request_id=' . $requestId . '&token=' . $token;
// 回调网关要求 Body 含 merchant_code、request_dateUnix 秒);签名字符串与 Body 参与签名字段一致HMAC 同 angpow
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
@@ -460,8 +469,10 @@ class Playx extends Api
'X-Request-Signature' => $signature,
];
$payload = [
'request_id' => $requestId,
'token' => $token,
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$res = $client->post($targetVerifyUrl, [
'headers' => $headers,
@@ -538,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,
@@ -656,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('', [
@@ -1245,7 +1261,7 @@ SQL;
'withdrawable_cash' => 0,
];
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
return [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,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', '')),
@@ -32,7 +33,7 @@ return [
'api' => [
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')),
// 完整 https URL回调 Body 仅 request_id+token签名 request_id&tokenHMAC 同 angpow相对路径拼基址 + 商户体
// 完整 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',
@@ -54,6 +55,6 @@ return [
'verify_ssl' => filter_var(env('PLAYX_ANGPOW_IMPORT_VERIFY_SSL', '1'), FILTER_VALIDATE_BOOLEAN),
// 固定货币展示映射
'currency' => 'MYR',
'visual_name' => 'Angpow',
'visual_name' => 'Reward Mall',
],
];

View File

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

View File

@@ -73,7 +73,7 @@ flowchart LR
2. playX 前端通过 postMessage 将 **playX 下发的 token**(及必要上下文)传给商城 H5。
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 **仅** PlayX 文档约定的 **`request_id``token`****`X-Request-Signature`** 与 angpow-imports **同源算法**(密钥 **`PLAYX_ANGPOW_IMPORT_AUTH_KEY`**),签名字符串为 **`request_id={id}&token={token}`**;无需 `PLAYX_ANGPOW_MERCHANT_CODE`无需 `PLAYX_ANGPOW_IMPORT_BASE_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** 调用资产/领取等接口。
@@ -81,7 +81,7 @@ flowchart LR
幂等与安全:
- H5 **不要**把 playX 的 `user_id` 当作唯一可信凭据直传下单;**以 token 换 session** 或由商城签发 token 的流程为准。
-`PLAYX_TOKEN_VERIFY_URL` 为**完整 https URL**Body **`request_id``token`**(与 PlayX 文档一致)`X-Request-Signature` 为 angpow 同源 HMAC明文 **`request_id=...&token=...`**H5 不参与签名。
-`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
@@ -223,11 +223,13 @@ flowchart LR
- **完整 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**`request_id={request_id}&token={token}`(与 JSON Body 字段一致,无 `merchant_code` / `request_date`)。
- **请求 Body**与 PlayX 平台 Token 校验文档一致
- **签名明文 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`**
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `merchant_code` | String | 是 | 与 `PLAYX_ANGPOW_MERCHANT_CODE` 一致。 |
| `request_date` | String | 是 | Unix 秒时间戳字符串,须与参与签名一致。 |
| `request_id` | String | 是 | 商城生成的请求追踪号。 |
| `token` | String | 是 | 前端传入的 playX 临时凭证。 |
@@ -235,6 +237,8 @@ flowchart LR
```json
{
"merchant_code": "plx",
"request_date": "1700000000",
"request_id": "mall_20260319_9f1b6d",
"token": "eyJhbGciOi..."
}

View File

@@ -272,7 +272,7 @@ 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_TOKEN_VERIFY_URL`**(完整 `https` 回调或相对路径)**`PLAYX_ANGPOW_IMPORT_AUTH_KEY`** 等(见 `docs/PlayX-对接文档(积分商城).md` §5.2)。
* 需配置 **`PLAYX_TOKEN_VERIFY_URL`**(完整 `https` 回调或相对路径)、**`PLAYX_ANGPOW_MERCHANT_CODE`**、**`PLAYX_ANGPOW_IMPORT_AUTH_KEY`** 等(见 `docs/PlayX-对接文档(积分商城).md` §5.2)。
* 相对路径时尚需 **`PLAYX_ANGPOW_IMPORT_BASE_URL`**;未配置则返回未配置类错误。
#### 请求参数

View File

@@ -304,7 +304,7 @@ curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
**前端入参约定(重要)**
- 调用本接口时 **仅需 `token`**(或兼容 `session`**不要**传 `request_id` / `request_date` / 商户字段(均由商城后端生成后请求上游)。
- 建议 **`Content-Type: application/json`**Body`{"token":"..."}`。若使用 **form-data**(如图二),请同样只传 `token`(及可选 `lang`);避免缺少 `token` 键名。
- 商城请求上游回调地址时Body **仅** **`request_id``token`**(与 PlayX 文档一致)`X-Request-Signature` 与 angpow-imports **同源算法**canonical 为 **`request_id=...&token=...`**。
- 商城请求上游回调地址时Body **`merchant_code``request_date`(秒)、`request_id``token`**`X-Request-Signature` 与 angpow-imports **同源算法**canonical 为 **`merchant_code=...&request_date=...&request_id=...&token=...`**。
**请求示例:**
```json
@@ -644,8 +644,8 @@ GET /api/v1/mall/addressList?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
| `PLAYX_DAILY_PUSH_SECRET` | 非空则 Daily Push 必须带合法 HMAC 头 |
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 playX 远程 |
| `PLAYX_ANGPOW_IMPORT_BASE_URL` | Angpow 推送等接口基地址;**相对路径** `verify-token` 时亦作其基地址 |
| `PLAYX_TOKEN_VERIFY_URL` | **完整 `https://` URL**:回调 Body `request_id`+`token`,签名明文同字段**相对路径**:拼基地址 + 商户四字段 |
| `PLAYX_ANGPOW_MERCHANT_CODE` | **相对路径** `verify-token` |
| `PLAYX_TOKEN_VERIFY_URL` | **完整 `https://` URL**:回调 Body `merchant_code`+`request_date`+`request_id`+`token`,签名明文同**相对路径**:拼基地址 + 商户四字段 |
| `PLAYX_ANGPOW_MERCHANT_CODE` | **回调与相对路径** `verify-token` 需 |
| `PLAYX_ANGPOW_IMPORT_AUTH_KEY` | 回调与相对路径的 HMAC 密钥(与 angpow-imports 相同) |
---

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
*
* 可选环境变量(未设置则从项目根 .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 密钥
*/
@@ -75,28 +76,36 @@ 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();
$canonical = 'request_id=' . $requestId . '&token=' . $token;
$requestDate = strval(time());
$canonical = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = buildSignature($canonical, $authKey);
$payload = json_encode([
'request_id' => $requestId,
'token' => $token,
'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);
}
$escapedPayload = str_replace("'", "'\\''", $payload);
$escapedSig = str_replace("'", "'\\''", $signature);
echo "--- 等价 curlLinux / macOS / Git Bash---\n";
echo "curl -sS -X POST '" . $url . "' \\\n";
echo " -H 'Content-Type: application/json' \\\n";
@@ -105,15 +114,18 @@ 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,
'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,
@@ -128,4 +140,20 @@ if ($result === false) {
exit(1);
}
$statusLine = isset($http_response_header[0]) ? $http_response_header[0] : '';
echo "--- HTTP 响应行 ---\n";
echo $statusLine . "\n";
if (isset($http_response_header[1])) {
echo "--- 响应头(节选)---\n";
$max = min(12, count($http_response_header));
for ($i = 1; $i < $max; $i++) {
echo $http_response_header[$i] . "\n";
}
}
echo "--- 响应体 ---\n";
echo $result . "\n";
if (!preg_match('/\b200\b/', $statusLine)) {
fwrite(STDERR, "\n提示:非 2xx 时请根据响应体与对端核对签名字符串、Body 字段是否与回调网关约定一致。\n");
exit(2);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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