1143 lines
41 KiB
PHP
1143 lines
41 KiB
PHP
<?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\model\MallAddress;
|
||
use support\think\Db;
|
||
use Webman\Http\Request;
|
||
use support\Response;
|
||
|
||
/**
|
||
* PlayX 积分商城 API
|
||
*/
|
||
class Playx extends Api
|
||
{
|
||
/**
|
||
* 从请求解析 mall_user_asset.id(muser 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 buildTempPhone(): ?string
|
||
{
|
||
for ($i = 0; $i < 8; $i++) {
|
||
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
|
||
if (!MallUserAsset::where('phone', $candidate)->find()) {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallUserAsset
|
||
{
|
||
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
|
||
if ($asset) {
|
||
return $asset;
|
||
}
|
||
|
||
$effectiveUsername = trim($username);
|
||
if ($effectiveUsername === '') {
|
||
$effectiveUsername = 'playx_' . $playxUserId;
|
||
}
|
||
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
|
||
if ($byName) {
|
||
$byName->playx_user_id = $playxUserId;
|
||
$byName->save();
|
||
|
||
return $byName;
|
||
}
|
||
|
||
$phone = $this->buildTempPhone();
|
||
if ($phone === null) {
|
||
return null;
|
||
}
|
||
$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();
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
$body = $request->post();
|
||
if (empty($body)) {
|
||
$raw = $request->rawBody();
|
||
if ($raw) {
|
||
$body = json_decode($raw, true) ?? [];
|
||
}
|
||
}
|
||
|
||
$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]);
|
||
}
|
||
}
|
||
|
||
// ===== 新版批量上报格式 =====
|
||
// 兼容你们截图:{ report_date, member:[{member_id, login, lty_deposit, lty_withdrawal, yesterday_total_w, yesterday_total_deposit}, ...] }
|
||
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;
|
||
}
|
||
|
||
$returnRatio = config('playx.return_ratio', 0.1);
|
||
$unlockRatio = config('playx.unlock_ratio', 0.1);
|
||
|
||
$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'] ?? '');
|
||
$yesterdayWinLossNet = $m['yesterday_total_w'] ?? 0;
|
||
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
|
||
$lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
|
||
$lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;
|
||
|
||
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
|
||
if ($exists) {
|
||
$results[] = [
|
||
'user_id' => $playxUserId,
|
||
'deduped' => true,
|
||
'accepted' => true,
|
||
'message' => __('Duplicate input'),
|
||
];
|
||
continue;
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
MallDailyPush::create([
|
||
'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(),
|
||
]);
|
||
|
||
$newLocked = 0;
|
||
if ($yesterdayWinLossNet < 0) {
|
||
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
|
||
}
|
||
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
|
||
|
||
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
|
||
if (!$asset) {
|
||
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
|
||
}
|
||
|
||
if ($asset->today_limit_date !== $date) {
|
||
$asset->today_claimed = 0;
|
||
$asset->today_limit_date = $date;
|
||
}
|
||
|
||
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
|
||
$asset->today_limit = $todayLimit;
|
||
$asset->playx_user_id = $playxUserId;
|
||
|
||
$uname = trim($username);
|
||
if ($uname !== '') {
|
||
$asset->username = $uname;
|
||
}
|
||
$asset->save();
|
||
|
||
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'] ?? '');
|
||
$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) {
|
||
return $this->success('', [
|
||
'request_id' => $requestId,
|
||
'accepted' => true,
|
||
'deduped' => true,
|
||
'message' => __('Duplicate input'),
|
||
]);
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
MallDailyPush::create([
|
||
'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(),
|
||
]);
|
||
|
||
$newLocked = 0;
|
||
$returnRatio = config('playx.return_ratio', 0.1);
|
||
$unlockRatio = config('playx.unlock_ratio', 0.1);
|
||
if ($yesterdayWinLossNet < 0) {
|
||
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
|
||
}
|
||
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
|
||
|
||
$asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? ''));
|
||
if (!$asset) {
|
||
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
|
||
}
|
||
|
||
if ($asset->today_limit_date !== $date) {
|
||
$asset->today_claimed = 0;
|
||
$asset->today_limit_date = $date;
|
||
}
|
||
|
||
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
|
||
$asset->today_limit = $todayLimit;
|
||
$asset->playx_user_id = $playxUserId;
|
||
|
||
$uname = trim(strval($body['username'] ?? ''));
|
||
if ($uname !== '') {
|
||
$asset->username = $uname;
|
||
}
|
||
$asset->save();
|
||
|
||
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/playx/verify-token
|
||
* 配置 playx.verify_token_local_only=true 时仅本地校验 token(不请求 PlayX)。
|
||
*/
|
||
public function verifyToken(Request $request): Response
|
||
{
|
||
$response = $this->initializeApi($request);
|
||
if ($response !== null) {
|
||
return $response;
|
||
}
|
||
|
||
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
|
||
if ($token === '') {
|
||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||
}
|
||
|
||
if (config('playx.verify_token_local_only', false)) {
|
||
return $this->verifyTokenLocal($token);
|
||
}
|
||
|
||
$baseUrl = config('playx.api.base_url', '');
|
||
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
|
||
if ($baseUrl === '') {
|
||
return $this->error(__('PlayX API not configured'));
|
||
}
|
||
|
||
try {
|
||
$client = new \GuzzleHttp\Client([
|
||
'base_uri' => rtrim($baseUrl, '/') . '/',
|
||
'timeout' => 10,
|
||
]);
|
||
$res = $client->post($verifyUrl, [
|
||
'json' => [
|
||
'request_id' => 'mall_' . uniqid(),
|
||
'token' => $token,
|
||
],
|
||
]);
|
||
$code = $res->getStatusCode();
|
||
$data = json_decode(strval($res->getBody()), true);
|
||
if ($code !== 200 || empty($data['user_id'])) {
|
||
$remoteMsg = $data['message'] ?? '';
|
||
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
|
||
|
||
return $this->error($msg, null, 0, ['statusCode' => 401]);
|
||
}
|
||
|
||
$userId = strval($data['user_id']);
|
||
$username = strval($data['username'] ?? '');
|
||
|
||
$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);
|
||
}
|
||
}
|
||
|
||
$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]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本地校验 temLogin 等写入的商城 token(类型 muser),写入 mall_session
|
||
*/
|
||
private function verifyTokenLocal(string $token): Response
|
||
{
|
||
$tokenData = Token::get($token);
|
||
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
|
||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||
}
|
||
$tokenType = strval($tokenData['type'] ?? '');
|
||
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
|
||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||
}
|
||
$assetId = intval($tokenData['user_id'] ?? 0);
|
||
if ($assetId <= 0) {
|
||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||
}
|
||
|
||
$asset = MallUserAsset::where('id', $assetId)->find();
|
||
if (!$asset) {
|
||
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
|
||
}
|
||
|
||
$playxUserId = strval($asset->playx_user_id ?? '');
|
||
if ($playxUserId === '') {
|
||
$playxUserId = strval($assetId);
|
||
}
|
||
|
||
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
|
||
$sessionId = bin2hex(random_bytes(16));
|
||
MallSession::create([
|
||
'session_id' => $sessionId,
|
||
'user_id' => $playxUserId,
|
||
'username' => strval($asset->username ?? ''),
|
||
'expire_time' => $expireAt,
|
||
'create_time' => time(),
|
||
'update_time' => time(),
|
||
]);
|
||
|
||
return $this->success('', [
|
||
'session_id' => $sessionId,
|
||
'user_id' => $playxUserId,
|
||
'username' => strval($asset->username ?? ''),
|
||
'token_expire_at' => date('c', $expireAt),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 用户资产
|
||
* 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 = config('playx.points_to_cash_ratio', 0.1);
|
||
$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;
|
||
if ($asset->locked_points <= 0 || $remain <= 0) {
|
||
return $this->error(__('No points to claim or limit reached'));
|
||
}
|
||
|
||
$canClaim = min($asset->locked_points, $remain);
|
||
|
||
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->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();
|
||
|
||
return $this->success('', ['list' => $list->toArray()]);
|
||
}
|
||
|
||
/**
|
||
* 红利兑换
|
||
* 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();
|
||
|
||
return $this->success('', ['list' => $list->toArray()]);
|
||
}
|
||
|
||
/**
|
||
* 收货地址列表
|
||
* 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 = $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'] = $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 = config('playx.points_to_cash_ratio', 0.1);
|
||
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(),
|
||
]);
|
||
|
||
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 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->playx_transaction_id = $data['playx_transaction_id'] ?? '';
|
||
$order->grant_status = MallOrder::GRANT_ACCEPTED;
|
||
$order->save();
|
||
} else {
|
||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||
$order->fail_reason = $data['message'] ?? 'unknown';
|
||
$order->save();
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
|
||
$order->fail_reason = $e->getMessage();
|
||
$order->save();
|
||
}
|
||
}
|
||
|
||
}
|