Files
webman-buildadmin-mall/app/api/controller/v1/Playx.php

1713 lines
62 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\api\controller\v1;
use ba\Random;
use app\common\controller\Api;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\model\MallItem;
use app\common\model\MallClaimLog;
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\library\MallPointsConversion;
use app\common\library\MallDailyPushRawLogger;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
/**
* PlayX 积分商城 API
*/
class Playx extends Api
{
/**
* 从请求解析 mall_user_asset.idmuser token、session、user_id 均指向资产表主键或 playx_user_id
*/
private function resolvePlayxAssetIdFromRequest(Request $request): ?int
{
$sessionId = strval($request->post('session_id', $request->get('session_id', '')));
if ($sessionId !== '') {
$session = MallSession::where('session_id', $sessionId)->find();
if ($session) {
$expireTime = intval($session->expire_time ?? 0);
if ($expireTime > time()) {
$asset = MallUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find();
if ($asset) {
return intval($asset->getKey());
}
}
}
$assetId = $this->resolveAssetIdByToken($sessionId);
if ($assetId !== null) {
return $assetId;
}
}
$token = strval($request->post('token', $request->get('token', '')));
if ($token === '') {
$token = get_auth_token(['ba', 'token'], $request);
}
if ($token !== '') {
return $this->resolveAssetIdByToken($token);
}
$userId = strval($request->post('user_id', $request->get('user_id', '')));
if ($userId === '') {
return null;
}
if (ctype_digit($userId)) {
return intval($userId);
}
$asset = MallUserAsset::where('playx_user_id', $userId)->find();
if ($asset) {
return intval($asset->getKey());
}
return null;
}
private function resolveAssetIdByToken(string $token): ?int
{
$tokenData = Token::get($token);
$tokenType = strval($tokenData['type'] ?? '');
$isMemberOrMall = $tokenType === UserAuth::TOKEN_TYPE || $tokenType === UserAuth::TOKEN_TYPE_MALL_USER;
if (!empty($tokenData)
&& $isMemberOrMall
&& intval($tokenData['expire_time'] ?? 0) > time()
&& intval($tokenData['user_id'] ?? 0) > 0
) {
return intval($tokenData['user_id']);
}
return null;
}
private function ensureAssetForPlayx(string $playxUserId, string $username, string $phone = ''): ?MallUserAsset
{
$phone = trim($phone);
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
if ($asset) {
if ($phone !== '' && trim(strval($asset->phone ?? '')) === '') {
$asset->phone = $phone;
$asset->save();
}
return $asset;
}
$effectiveUsername = trim($username);
if ($effectiveUsername === '') {
$effectiveUsername = 'playx_' . $playxUserId;
}
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
if ($byName) {
$byName->playx_user_id = $playxUserId;
if ($phone !== '' && trim(strval($byName->phone ?? '')) === '') {
$byName->phone = $phone;
}
$byName->save();
return $byName;
}
$pwd = hash_password(Random::build('alnum', 16));
$now = time();
return 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,
]);
}
private function getAssetById(int $assetId): ?MallUserAsset
{
return MallUserAsset::where('id', $assetId)->find();
}
/**
* 按每日推送记录同步用户资产主信息applyAssetDelta=true 时才落资产增量。
*/
/**
* @return array<string, mixed>
*/
private function getPointsConversionConfig(): array
{
return MallPointsConversion::configForConversion();
}
private function syncAssetByDailyPush(
string $playxUserId,
string $username,
string $date,
int $convertedPoints,
int $todayLimit,
bool $applyAssetDelta,
string $phone = ''
): ?MallUserAsset
{
$asset = $this->ensureAssetForPlayx($playxUserId, $username, $phone);
if (!$asset) {
return null;
}
$asset->playx_user_id = $playxUserId;
$uname = trim($username);
if ($uname !== '') {
$asset->username = $uname;
}
if ($applyAssetDelta) {
if ($asset->today_limit_date !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
if ($convertedPoints !== 0) {
MallPointsConversion::applyDailyConversionDelta($asset, $convertedPoints);
}
$asset->today_limit = $todayLimit;
}
$asset->save();
return $asset;
}
/**
* Daily Push API - PlayX 调用商城接收 T+1 数据
* POST /api/v1/mall/dailyPush
*/
public function dailyPush(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$rawBody = $request->rawBody();
$body = $request->post();
if (empty($body) && $rawBody !== '') {
$body = json_decode($rawBody, true) ?? [];
}
if ($rawBody === '') {
$rawBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
MallDailyPushRawLogger::log($request, $rawBody);
$secret = config('playx.daily_push_secret', '');
if ($secret !== '') {
$sig = $request->header('X-Signature', '');
$ts = $request->header('X-Timestamp', '');
$rid = $request->header('X-Request-Id', '');
if ($sig === '' || $ts === '' || $rid === '') {
return $this->error(__('Daily push signature missing or incomplete'), null, 0, ['statusCode' => 401]);
}
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', json_encode($body));
$expected = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $sig)) {
return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
}
}
// ===== 新版批量上报格式 =====
// 当前字段为 ltv_deposit、ltv_withdrawal、yesterday_total_wl同时兼容早期错误字段名。
if (isset($body['report_date']) && isset($body['member']) && is_array($body['member'])) {
$reportDate = $body['report_date'];
$date = '';
if (is_numeric($reportDate)) {
$date = date('Y-m-d', intval($reportDate));
} else {
$date = strval($reportDate);
}
$members = $body['member'];
if ($date === '' || empty($members)) {
return $this->error(__('Missing required fields: report_date, member'));
}
$requestId = strval($body['request_id'] ?? '');
if ($requestId === '') {
$requestId = 'report_' . $date;
}
$pointsConfig = $this->getPointsConversionConfig();
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$results = [];
$allDeduped = true;
foreach ($members as $m) {
$playxUserId = strval($m['member_id'] ?? '');
if ($playxUserId === '') {
return $this->error(__('Missing required fields: member_id'));
}
$username = strval($m['login'] ?? '');
$phone = trim(strval($m['phone'] ?? ($m['mobile'] ?? '')));
$yesterdayWinLossNet = $m['yesterday_total_wl'] ?? ($m['yesterday_total_w'] ?? 0);
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
$lifetimeTotalDeposit = $m['ltv_deposit'] ?? ($m['lty_deposit'] ?? 0);
$lifetimeTotalWithdraw = $m['ltv_withdrawal'] ?? ($m['lty_withdrawal'] ?? 0);
$lifetimeWinLossNet = MallPointsConversion::parseLifetimeWinLossFromMember($m);
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, false, $phone);
if (!$asset) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$results[] = [
'user_id' => $playxUserId,
'deduped' => true,
'accepted' => true,
'message' => __('Duplicate input'),
];
continue;
}
Db::startTrans();
try {
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $username,
'yesterday_win_loss_net' => $yesterdayWinLossNet,
'yesterday_total_deposit' => $yesterdayTotalDeposit,
'lifetime_total_deposit' => $lifetimeTotalDeposit,
'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
'create_time' => time(),
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
MallDailyPush::create($pushData);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, true, $phone);
if (!$asset) {
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
}
Db::commit();
$results[] = [
'user_id' => $playxUserId,
'deduped' => false,
'accepted' => true,
'message' => __('Ok'),
];
$allDeduped = false;
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => $allDeduped,
'message' => $allDeduped ? __('Duplicate input') : __('Ok'),
'results' => $results,
]);
}
// ===== 旧版单条上报格式(兼容)=====
$requestId = $body['request_id'] ?? '';
$date = $body['date'] ?? '';
$playxUserId = strval($body['user_id'] ?? '');
$phone = trim(strval($body['phone'] ?? ($body['mobile'] ?? '')));
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
if ($requestId === '' || $date === '' || $playxUserId === '') {
return $this->error(__('Missing required fields: request_id, date, user_id'));
}
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$pointsConfig = $this->getPointsConversionConfig();
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$convertedPoints,
$todayLimit,
false,
$phone
);
if (!$asset) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => true,
'message' => __('Duplicate input'),
]);
}
Db::startTrans();
try {
$pointsConfig = $this->getPointsConversionConfig();
$lifetimeWinLossNet = null;
if (isset($body['lifetime_win_loss_net']) && is_numeric($body['lifetime_win_loss_net'])) {
$lifetimeWinLossNet = floatval($body['lifetime_win_loss_net']);
}
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $body['username'] ?? '',
'yesterday_win_loss_net' => $yesterdayWinLossNet,
'yesterday_total_deposit' => $yesterdayTotalDeposit,
'lifetime_total_deposit' => $body['lifetime_total_deposit'] ?? 0,
'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
'create_time' => time(),
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
MallDailyPush::create($pushData);
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$convertedPoints,
$todayLimit,
true,
$phone
);
if (!$asset) {
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => false,
'message' => __('Ok'),
]);
}
/**
* Token 验证 - POST /api/v1/mall/verifyToken
* 配置 playx.verify_token_local_only=true 时:不向 PlayX 请求,按 .env 默认用户签发 session见 verify_token_local_default_*)。
*/
public function verifyToken(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocalOpen($request);
}
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
if ($token === '') {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$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://');
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 {
$requestId = 'mall_' . uniqid();
$clientOptions = [
'timeout' => 10,
'http_errors' => false,
];
if (!$isAbsoluteVerifyUrl) {
$clientOptions['base_uri'] = rtrim($baseUrl, '/') . '/';
}
$client = new \GuzzleHttp\Client($clientOptions);
if ($isAbsoluteVerifyUrl) {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
// 回调网关要求 Body 含 merchant_code、request_dateUnix 秒);签名字符串与 Body 参与签名字段一致HMAC 同 angpow
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$res = $client->post($targetVerifyUrl, [
'headers' => $headers,
'json' => $payload,
]);
} else {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
'X-Signature' => $signature,
'X-Request-Date' => $requestDate,
'X-Request-ID' => $requestId,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$verifyPath = ltrim($verifyUrlTrimmed, '/');
$res = $client->post($verifyPath, [
'headers' => $headers,
'json' => $payload,
]);
}
$data = json_decode(strval($res->getBody()), true);
$code = $res->getStatusCode();
if ($code !== 200 || empty($data['user_id'])) {
$remoteMsg = '';
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]);
}
$userId = strval($data['user_id']);
$username = strval($data['username'] ?? '');
$phone = trim(strval($data['phone'] ?? ($data['mobile'] ?? '')));
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
if (!empty($data['token_expire_at'])) {
$ts = strtotime(strval($data['token_expire_at']));
if ($ts !== false && $ts > 0) {
$expireAt = intval($ts);
}
}
$asset = $this->ensureAssetForPlayx($userId, $username, $phone);
if ($asset === null) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$sessionId = bin2hex(random_bytes(16));
MallSession::create([
'session_id' => $sessionId,
'user_id' => $userId,
'username' => $username,
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
]);
return $this->success('', [
'session_id' => $sessionId,
'user_id' => $userId,
'username' => $username,
'token_expire_at' => date('c', $expireAt),
]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
}
}
/**
* 本地联调:不调用 PlayX verify-token身份取自 PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_*,请求 token 仅作占位(有则通过、无则也通过)。
*/
private function verifyTokenLocalOpen(Request $request): Response
{
$playxUserId = trim(strval(config('playx.verify_token_local_default_user_id', '')));
$username = trim(strval(config('playx.verify_token_local_default_username', '')));
if ($playxUserId === '') {
return $this->error(__('PlayX verify token local default user not configured'));
}
if ($username === '') {
$username = $playxUserId;
}
$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' => $username,
'expire_time' => $expireAt,
'create_time' => $now,
'update_time' => $now,
]);
return $this->success('Success', [
'session_id' => $sessionId,
'user_id' => $playxUserId,
'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
*/
public function assets(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset) {
return $this->success('', [
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'withdrawable_cash' => 0,
]);
}
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
$withdrawableCash = round($asset->available_points * $ratio, 2);
return $this->success('', [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,
'today_limit' => $asset->today_limit,
'today_claimed' => $asset->today_claimed,
'withdrawable_cash' => $withdrawableCash,
]);
}
/**
* 领取
* POST /api/v1/playx/claim
*/
public function claim(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$claimRequestId = strval($request->post('claim_request_id', ''));
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($claimRequestId === '') {
return $this->error(__('claim_request_id and user_id/session_id required'));
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->error(__('User asset not found'));
}
$playxUserId = strval($asset->playx_user_id);
$exists = MallClaimLog::where('claim_request_id', $claimRequestId)->find();
if ($exists) {
return $this->success('', $this->formatAsset($asset));
}
$todayLimitDate = date('Y-m-d');
if ($asset->today_limit_date !== $todayLimitDate) {
$asset->today_claimed = 0;
$asset->today_limit_date = $todayLimitDate;
}
$remain = $asset->today_limit - $asset->today_claimed;
$lifetimeRemain = MallPointsConversion::remainingClaimable($asset);
if ($asset->locked_points <= 0 || $remain <= 0 || $lifetimeRemain <= 0) {
return $this->error(__('No points to claim or limit reached'));
}
$canClaim = min($asset->locked_points, $remain, $lifetimeRemain);
Db::startTrans();
try {
MallClaimLog::create([
'claim_request_id' => $claimRequestId,
'user_id' => $playxUserId,
'claimed_amount' => $canClaim,
'create_time' => time(),
]);
$asset->locked_points -= $canClaim;
$asset->available_points += $canClaim;
$asset->today_claimed += $canClaim;
$asset->total_points_claimed = max(0, intval($asset->total_points_claimed ?? 0) + $canClaim);
$asset->save();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
$asset->refresh();
return $this->success(__('Claim success'), $this->formatAsset($asset));
}
/**
* 商品列表
* GET /api/v1/playx/items?type=BONUS|PHYSICAL|WITHDRAW
*/
public function items(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$type = $request->get('type', '');
$typeMap = ['BONUS' => 1, 'PHYSICAL' => 2, 'WITHDRAW' => 3];
$query = MallItem::where('status', 1);
if ($type !== '' && isset($typeMap[$type])) {
$query->where('type', $typeMap[$type]);
}
$list = $query->order('sort', 'asc')->select();
$out = [];
foreach ($list->toArray() as $row) {
$out[] = $this->applyMallItemLocaleForApi($row);
}
return $this->success('', ['list' => $out]);
}
/**
* 红利兑换
* POST /api/v1/playx/bonus/redeem
*/
public function bonusRedeem(Request $request): Response
{
return $this->redeemBonus($request);
}
/**
* 实物兑换
* POST /api/v1/playx/physical/redeem
*/
public function physicalRedeem(Request $request): Response
{
return $this->redeemPhysical($request);
}
/**
* 提现申请
* POST /api/v1/playx/withdraw/apply
*/
public function withdrawApply(Request $request): Response
{
return $this->redeemWithdraw($request);
}
/**
* 订单列表
* GET /api/v1/playx/orders?user_id=xxx
*/
public function orders(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->success('', ['list' => []]);
}
$list = MallOrder::where('user_id', strval($asset->playx_user_id))
->with(['mallItem'])
->order('id', 'desc')
->limit(100)
->select();
$arr = $list->toArray();
foreach ($arr as &$orderRow) {
if (isset($orderRow['mallItem']) && is_array($orderRow['mallItem'])) {
$orderRow['mallItem'] = $this->applyMallItemLocaleForApi($orderRow['mallItem']);
}
}
unset($orderRow);
return $this->success('', ['list' => $arr]);
}
/**
* 按订单 ID 查询订单(仅当前登录用户本人的订单)
* GET /api/v1/mall/order?order_id=xxx
*
* 鉴权token / session_id / user_id同 assets/orders
*/
public function order(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->error(__('Record not found'));
}
$orderId = intval($request->get('order_id', $request->post('order_id', 0)));
if ($orderId <= 0) {
$orderId = intval($request->get('id', $request->post('id', 0)));
}
if ($orderId <= 0) {
return $this->error(__('Missing required fields'));
}
$playxUserId = strval($asset->playx_user_id);
$order = MallOrder::where('id', $orderId)
->where('user_id', $playxUserId)
->with(['mallItem'])
->find();
if (!$order) {
return $this->error(__('Record not found'));
}
$row = $order->toArray();
if (isset($row['mallItem']) && is_array($row['mallItem'])) {
$row['mallItem'] = $this->applyMallItemLocaleForApi($row['mallItem']);
}
return $this->success('', [
'order_id' => $order->id,
'status' => $order->status,
'order' => $row,
]);
}
/**
* 积分流水(领取/兑换/退回/管理员调整)
* GET /api/v1/mall/pointsLogs
*
* 鉴权token / session_id / user_id同 assets/orders
*
* 参数:
* - limit: 每页条数(默认 20最大 100
* - cursor: 游标(上一页返回的 next_cursor
* - direction: IN | OUT可选不传返回全部
*/
public function pointsLogs(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset || ($asset->playx_user_id ?? '') === '') {
return $this->success('', [
'list' => [],
'next_cursor' => null,
]);
}
$playxUserId = $asset->playx_user_id;
$limit = $request->get('limit', $request->post('limit', '20'));
if (!is_string($limit) || $limit === '' || !ctype_digit($limit) || $limit === '0') {
$limit = '20';
}
if (strlen($limit) > 3 || $limit > '100') {
$limit = '100';
}
$cursor = $request->get('cursor', $request->post('cursor', ''));
if (!is_string($cursor)) {
$cursor = '';
}
$direction = $request->get('direction', $request->post('direction', ''));
$direction = is_string($direction) ? strtoupper(trim($direction)) : '';
if ($direction !== '' && $direction !== 'IN' && $direction !== 'OUT') {
$direction = '';
}
$sql = <<<SQL
SELECT
t.biz_type,
t.direction,
t.points,
t.ts,
t.ref_id,
t.order_no,
t.order_status,
t.item_id,
t.item_title,
t.item_type,
t.item_score,
t.item_amount,
t.item_multiplier,
t.item_category,
t.item_category_title,
t.sort_key
FROM (
SELECT
'CLAIM' AS biz_type,
'IN' AS direction,
cl.claimed_amount AS points,
cl.create_time AS ts,
cl.claim_request_id AS ref_id,
'' AS order_no,
'' AS order_status,
0 AS item_id,
'' AS item_title,
0 AS item_type,
0 AS item_score,
0 AS item_amount,
0 AS item_multiplier,
'' AS item_category,
'' AS item_category_title,
CONCAT(LPAD(cl.create_time, 10, '0'), '_1_', LPAD(cl.id, 10, '0')) AS sort_key
FROM mall_claim_log cl
WHERE cl.user_id = :user_id_claim
UNION ALL
SELECT
CONCAT('REDEEM_', o.type) AS biz_type,
'OUT' AS direction,
o.points_cost AS points,
o.create_time AS ts,
o.external_transaction_id AS ref_id,
o.external_transaction_id AS order_no,
o.status AS order_status,
o.mall_item_id AS item_id,
COALESCE(i.title, '') AS item_title,
COALESCE(i.type, 0) AS item_type,
COALESCE(i.score, 0) AS item_score,
COALESCE(i.amount, 0) AS item_amount,
COALESCE(i.multiplier, 0) AS item_multiplier,
COALESCE(i.category, '') AS item_category,
COALESCE(i.category_title, '') AS item_category_title,
CONCAT(LPAD(o.create_time, 10, '0'), '_2_', LPAD(o.id, 10, '0')) AS sort_key
FROM mall_order o
LEFT JOIN mall_item i ON i.id = o.mall_item_id
WHERE o.user_id = :user_id_redeem AND o.points_cost > 0
UNION ALL
SELECT
'REFUND' AS biz_type,
'IN' AS direction,
o.points_cost AS points,
o.update_time AS ts,
o.external_transaction_id AS ref_id,
o.external_transaction_id AS order_no,
o.status AS order_status,
o.mall_item_id AS item_id,
COALESCE(i.title, '') AS item_title,
COALESCE(i.type, 0) AS item_type,
COALESCE(i.score, 0) AS item_score,
COALESCE(i.amount, 0) AS item_amount,
COALESCE(i.multiplier, 0) AS item_multiplier,
COALESCE(i.category, '') AS item_category,
COALESCE(i.category_title, '') AS item_category_title,
CONCAT(LPAD(o.update_time, 10, '0'), '_3_', LPAD(o.id, 10, '0')) AS sort_key
FROM mall_order o
LEFT JOIN mall_item i ON i.id = o.mall_item_id
WHERE o.user_id = :user_id_refund AND o.status = 'REJECTED' AND o.points_cost > 0 AND o.update_time IS NOT NULL
UNION ALL
SELECT
'ADMIN_ADJUST' AS biz_type,
CASE WHEN pl.score_value > 0 THEN 'IN' ELSE 'OUT' END AS direction,
ABS(pl.score_value) AS points,
pl.create_time AS ts,
pl.id AS ref_id,
'' AS order_no,
'' AS order_status,
0 AS item_id,
'' AS item_title,
0 AS item_type,
0 AS item_score,
0 AS item_amount,
0 AS item_multiplier,
'' AS item_category,
'' AS item_category_title,
CONCAT(LPAD(pl.create_time, 10, '0'), '_4_', LPAD(pl.id, 10, '0')) AS sort_key
FROM mall_points_log pl
WHERE pl.user_asset_id = :user_asset_id_adjust
) t
WHERE 1=1
SQL;
$params = [
'user_id_claim' => $playxUserId,
'user_id_redeem' => $playxUserId,
'user_id_refund' => $playxUserId,
'user_asset_id_adjust' => $assetId,
];
if ($cursor !== '') {
$sql .= "\n AND t.sort_key < :cursor";
$params['cursor'] = $cursor;
}
if ($direction !== '') {
$sql .= "\n AND t.direction = :direction";
$params['direction'] = $direction;
}
$sql .= "\nORDER BY t.sort_key DESC";
$sql .= "\nLIMIT " . $limit;
try {
$rows = Db::query($sql, $params);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
}
$list = [];
$nextCursor = null;
if (is_array($rows)) {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$memo = '';
if (($row['biz_type'] ?? '') === 'ADMIN_ADJUST') {
$memoKey = ($row['direction'] ?? '') === 'OUT'
? 'Administrator deducted %s points'
: 'Administrator added %s points';
$memo = __($memoKey, [$row['points'] ?? 0]);
}
$list[] = [
'biz_type' => $row['biz_type'] ?? '',
'direction' => $row['direction'] ?? '',
'points' => $row['points'] ?? 0,
'memo' => $memo,
'ts' => $row['ts'] ?? null,
'ref_id' => $row['ref_id'] ?? '',
'order_no' => $row['order_no'] ?? '',
'order_status' => $row['order_status'] ?? '',
'item_id' => $row['item_id'] ?? 0,
'item_title' => $row['item_title'] ?? '',
'item_type' => $row['item_type'] ?? 0,
'item_score' => $row['item_score'] ?? 0,
'mallItem' => ($row['item_id'] ?? 0) ? [
'id' => $row['item_id'] ?? 0,
'title' => $row['item_title'] ?? '',
'type' => $row['item_type'] ?? 0,
'score' => $row['item_score'] ?? 0,
'amount' => $row['item_amount'] ?? 0,
'multiplier' => $row['item_multiplier'] ?? 0,
'category' => $row['item_category'] ?? '',
'category_title' => $row['item_category_title'] ?? '',
] : null,
'cursor' => $row['sort_key'] ?? '',
];
$nextCursor = $row['sort_key'] ?? $nextCursor;
}
}
$itemsMap = $this->buildMallItemLocaleMapForPointsLog($list);
foreach ($list as &$row) {
$id = (int)($row['item_id'] ?? 0);
if ($id > 0 && isset($itemsMap[$id])) {
$row['item_title'] = $itemsMap[$id]['title'];
if (isset($row['mallItem']) && is_array($row['mallItem'])) {
$row['mallItem']['title'] = $itemsMap[$id]['title'];
}
}
}
unset($row);
return $this->success('', [
'list' => $list,
'next_cursor' => $nextCursor,
]);
}
/**
* 收货地址列表
* GET /api/v1/playx/address/list?session_id=xxx
*/
public function addressList(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$list = MallAddress::where('playx_user_asset_id', $assetId)
->order('default_setting', 'desc')
->order('id', 'desc')
->select();
return $this->success('', ['list' => $list->toArray()]);
}
/**
* 添加收货地址(可设置默认)
* POST /api/v1/playx/address/add
*/
public function addressAdd(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$phone = trim(strval($request->post('phone', '')));
$receiverName = trim(strval($request->post('receiver_name', '')));
$region = MallAddress::normalizeRegion($request->post('region', ''));
$detailAddress = trim(strval($request->post('detail_address', '')));
$defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
if ($phone === '' || $receiverName === '' || $detailAddress === '' || $region === '' || $region === null) {
return $this->error(__('Missing required fields'));
}
Db::startTrans();
try {
if ($defaultSetting === 1) {
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
}
$created = MallAddress::create([
'playx_user_asset_id' => $assetId,
'receiver_name' => $receiverName,
'phone' => $phone,
'region' => $region,
'detail_address' => $detailAddress,
'default_setting' => $defaultSetting,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', [
'id' => $created ? $created->id : null,
]);
}
/**
* 修改收货地址(包含设置默认地址)
* POST /api/v1/playx/address/edit
*/
public function addressEdit(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$id = intval($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Missing required fields'));
}
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
if (!$row) {
return $this->error(__('Record not found'));
}
$updates = [];
if ($request->post('phone', null) !== null) {
$updates['phone'] = trim(strval($request->post('phone', '')));
}
if ($request->post('receiver_name', null) !== null) {
$updates['receiver_name'] = trim(strval($request->post('receiver_name', '')));
}
if ($request->post('region', null) !== null) {
$updates['region'] = MallAddress::normalizeRegion($request->post('region', ''));
}
if ($request->post('detail_address', null) !== null) {
$updates['detail_address'] = trim(strval($request->post('detail_address', '')));
}
if ($request->post('default_setting', null) !== null) {
$updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
}
if (empty($updates)) {
return $this->success('', ['updated' => false]);
}
Db::startTrans();
try {
if (isset($updates['default_setting']) && $updates['default_setting'] === 1) {
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
}
$updates['update_time'] = time();
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->update($updates);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', ['updated' => true]);
}
/**
* 删除收货地址
* POST /api/v1/playx/address/delete
*/
public function addressDelete(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$id = intval($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Missing required fields'));
}
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
if (!$row) {
return $this->error(__('Record not found'));
}
$wasDefault = intval($row->default_setting ?? 0) === 1;
Db::startTrans();
try {
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->delete();
if ($wasDefault) {
$fallback = MallAddress::where('playx_user_asset_id', $assetId)->order('id', 'desc')->find();
if ($fallback) {
$fallback->default_setting = 1;
$fallback->update_time = time();
$fallback->save();
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', ['deleted' => true]);
}
private function formatAsset(?MallUserAsset $asset): array
{
if (!$asset) {
return [
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'withdrawable_cash' => 0,
];
}
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
return [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,
'today_limit' => $asset->today_limit,
'today_claimed' => $asset->today_claimed,
'withdrawable_cash' => round($asset->available_points * $ratio, 2),
];
}
private function redeemBonus(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($itemId <= 0) {
return $this->error(__('item_id and user_id/session_id required'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_BONUS)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
$orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_BONUS,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallOrder::GRANT_NOT_SENT,
'create_time' => time(),
'update_time' => time(),
'start_time' => date('Y-m-d H:i:s', time()),
'end_time' => date('Y-m-d H:i:s', time()+86400*3),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBonusGrant($order, $item, $playxUserId);
}
return $this->success(__('Redeem submitted, please wait about 10 minutes'), [
'order_id' => $order->id,
'status' => 'PENDING',
]);
}
private function redeemPhysical(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$addressId = intval($request->post('address_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($itemId <= 0 || $addressId <= 0) {
return $this->error(__('Missing required fields'));
}
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$addrRow = MallAddress::where('id', $addressId)->where('playx_user_asset_id', $assetId)->find();
if (!$addrRow) {
return $this->error(__('Shipping address not found'));
}
$snapshot = MallAddress::snapshotForPhysicalOrder($addrRow);
if ($snapshot['receiver_phone'] === '' || $snapshot['receiver_address'] === '' || $snapshot['receiver_name'] === '') {
return $this->error(__('Missing required fields'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_PHYSICAL)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
if (isset($item->stock) && $item->stock < 1) {
return $this->error(__('Out of stock'));
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_PHYSICAL,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'mall_address_id' => $addressId,
'receiver_name' => $snapshot['receiver_name'],
'receiver_phone' => $snapshot['receiver_phone'],
'receiver_address' => $snapshot['receiver_address'],
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(),
'update_time' => time(),
]);
if (isset($item->stock)) {
$item->stock -= 1;
$item->save();
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Redeem success'));
}
private function redeemWithdraw(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$itemId = intval($request->post('item_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($itemId <= 0) {
return $this->error(__('item_id and user_id/session_id required'));
}
$item = MallItem::where('id', $itemId)->where('type', MallItem::TYPE_WITHDRAW)->where('status', 1)->find();
if (!$item) {
return $this->error(__('Item not found or not available'));
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
$orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_WITHDRAW,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
'order_id' => $order->id,
'status' => 'PENDING',
]);
}
private function getApiLocale(): string
{
if (function_exists('locale')) {
$l = locale();
if (is_string($l) && $l !== '') {
return $l;
}
}
return 'zh-cn';
}
/**
* @param array<string,mixed> $item MallItem 行(可含 title_en/title_ms 与 description_en/description_ms
* @return array<string,mixed>
*/
private function applyMallItemLocaleForApi(array $item): array
{
$lang = $this->getApiLocale();
if ($lang === 'en') {
$te = trim((string)($item['title_en'] ?? ''));
$de = trim((string)($item['description_en'] ?? ''));
if ($te !== '') {
$item['title'] = $te;
}
if ($de !== '') {
$item['description'] = $de;
}
} elseif ($lang === 'ms') {
$tm = trim((string)($item['title_ms'] ?? ''));
$dm = trim((string)($item['description_ms'] ?? ''));
if ($tm !== '') {
$item['title'] = $tm;
}
if ($dm !== '') {
$item['description'] = $dm;
}
}
unset($item['title_en'], $item['description_en'], $item['title_ms'], $item['description_ms']);
return $item;
}
/**
* @param list<array<string,mixed>> $list pointsLogs 已组装的列表
* @return array<int,array<string,mixed>> id => item 行(已本地化并去掉 *_ms
*/
private function buildMallItemLocaleMapForPointsLog(array $list): array
{
$ids = [];
foreach ($list as $row) {
$id = (int)($row['item_id'] ?? 0);
if ($id > 0) {
$ids[$id] = true;
}
}
if ($ids === []) {
return [];
}
$idList = array_keys($ids);
$map = [];
$models = MallItem::whereIn('id', $idList)->select();
foreach ($models as $model) {
$map[intval($model->id)] = $this->applyMallItemLocaleForApi($model->toArray());
}
return $map;
}
private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
if ($baseUrl === '') {
return;
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 15]);
$res = $client->post($baseUrl . $url, [
'json' => [
'request_id' => 'mall_bonus_' . uniqid(),
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $userId,
'amount' => $order->amount,
'rewardName' => $item->title ?? '',
'category' => $item->category ?? 'daily',
'categoryTitle' => $item->category_title ?? '',
'multiplier' => $order->multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true);
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
}