Files
webman-buildadmin-mall/app/api/controller/v1/Playx.php
2026-04-09 15:08:19 +08:00

1349 lines
48 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\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 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/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
) t
WHERE 1=1
SQL;
$params = [
'user_id_claim' => $playxUserId,
'user_id_redeem' => $playxUserId,
'user_id_refund' => $playxUserId,
];
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;
}
$list[] = [
'biz_type' => $row['biz_type'] ?? '',
'direction' => $row['direction'] ?? '',
'points' => $row['points'] ?? 0,
'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;
}
}
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 = $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(),
'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 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();
}
}
}