根据对接实施方案文档修改
This commit is contained in:
15
.env-example
15
.env-example
@@ -16,3 +16,18 @@ DATABASE_PASSWORD = 123456
|
|||||||
DATABASE_HOSTPORT = 3306
|
DATABASE_HOSTPORT = 3306
|
||||||
DATABASE_CHARSET = utf8mb4
|
DATABASE_CHARSET = utf8mb4
|
||||||
DATABASE_PREFIX =
|
DATABASE_PREFIX =
|
||||||
|
|
||||||
|
# PlayX 配置
|
||||||
|
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1)
|
||||||
|
PLAYX_POINTS_TO_CASH_RATIO=0.1
|
||||||
|
# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio(仅亏损时)
|
||||||
|
PLAYX_RETURN_RATIO=0.1
|
||||||
|
# 解锁比例:今日可领取上限 = yesterday_total_deposit * unlock_ratio
|
||||||
|
PLAYX_UNLOCK_RATIO=0.1
|
||||||
|
# Daily Push 签名校验密钥(建议从部署系统注入,避免写入代码/仓库)
|
||||||
|
PLAYX_DAILY_PUSH_SECRET=
|
||||||
|
# token 会话缓存过期时间(秒)
|
||||||
|
PLAYX_SESSION_EXPIRE_SECONDS=3600
|
||||||
|
# PlayX API(商城调用 PlayX 时使用)
|
||||||
|
PLAYX_API_BASE_URL=
|
||||||
|
PLAYX_API_SECRET_KEY=
|
||||||
|
|||||||
700
app/api/controller/v1/Playx.php
Normal file
700
app/api/controller/v1/Playx.php
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\api\controller\v1;
|
||||||
|
|
||||||
|
use app\common\controller\Api;
|
||||||
|
use app\common\model\MallItem;
|
||||||
|
use app\common\model\MallPlayxClaimLog;
|
||||||
|
use app\common\model\MallPlayxDailyPush;
|
||||||
|
use app\common\model\MallPlayxSession;
|
||||||
|
use app\common\model\MallPlayxOrder;
|
||||||
|
use app\common\model\MallPlayxUserAsset;
|
||||||
|
use support\think\Db;
|
||||||
|
use Webman\Http\Request;
|
||||||
|
use support\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayX 积分商城 API
|
||||||
|
*/
|
||||||
|
class Playx extends Api
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 从请求中解析 PlayX 会话用户ID(优先 session_id,其次 user_id)
|
||||||
|
*/
|
||||||
|
private function resolveUserIdFromRequest(Request $request): ?string
|
||||||
|
{
|
||||||
|
$sessionId = strval($request->post('session_id', $request->get('session_id', '')));
|
||||||
|
if ($sessionId !== '') {
|
||||||
|
$session = MallPlayxSession::where('session_id', $sessionId)->find();
|
||||||
|
if (!$session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$expireTime = intval($session->expire_time ?? 0);
|
||||||
|
if ($expireTime <= time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return strval($session->user_id ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = strval($request->post('user_id', $request->get('user_id', '')));
|
||||||
|
if ($userId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily Push API - PlayX 调用商城接收 T+1 数据
|
||||||
|
* POST /api/v1/playx/daily-push
|
||||||
|
*/
|
||||||
|
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) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestId = $body['request_id'] ?? '';
|
||||||
|
$date = $body['date'] ?? '';
|
||||||
|
$userId = $body['user_id'] ?? '';
|
||||||
|
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
|
||||||
|
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
|
||||||
|
|
||||||
|
if ($requestId === '' || $date === '' || $userId === '') {
|
||||||
|
return $this->error(__('Missing required fields: request_id, date, user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
|
||||||
|
}
|
||||||
|
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/playx/daily-push\n" . hash('sha256', json_encode($body));
|
||||||
|
$expected = hash_hmac('sha256', $canonical, $secret);
|
||||||
|
if (!hash_equals($expected, $sig)) {
|
||||||
|
return $this->error('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = MallPlayxDailyPush::where('user_id', $userId)->where('date', $date)->find();
|
||||||
|
if ($exists) {
|
||||||
|
return $this->success('', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'accepted' => true,
|
||||||
|
'deduped' => true,
|
||||||
|
'message' => 'duplicate input',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
MallPlayxDailyPush::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$returnRatio = config('playx.return_ratio', 0.1);
|
||||||
|
$unlockRatio = config('playx.unlock_ratio', 0.1);
|
||||||
|
|
||||||
|
$newLocked = 0;
|
||||||
|
if ($yesterdayWinLossNet < 0) {
|
||||||
|
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
|
||||||
|
}
|
||||||
|
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
|
||||||
|
|
||||||
|
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
$todayLimitDate = $date;
|
||||||
|
if ($asset) {
|
||||||
|
if ($asset->today_limit_date !== $todayLimitDate) {
|
||||||
|
$asset->today_claimed = 0;
|
||||||
|
$asset->today_limit_date = $todayLimitDate;
|
||||||
|
}
|
||||||
|
$asset->locked_points += $newLocked;
|
||||||
|
$asset->today_limit = $todayLimit;
|
||||||
|
$asset->username = $body['username'] ?? $asset->username;
|
||||||
|
$asset->save();
|
||||||
|
} else {
|
||||||
|
MallPlayxUserAsset::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'username' => $body['username'] ?? '',
|
||||||
|
'locked_points' => $newLocked,
|
||||||
|
'available_points' => 0,
|
||||||
|
'today_limit' => $todayLimit,
|
||||||
|
'today_claimed' => 0,
|
||||||
|
'today_limit_date' => $todayLimitDate,
|
||||||
|
'create_time' => time(),
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 验证 - 接收前端 token,调用 PlayX 验证(占位,待 PlayX 提供 API)
|
||||||
|
* POST /api/v1/playx/verify-token
|
||||||
|
*/
|
||||||
|
public function verifyToken(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeApi($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $request->post('token', $request->post('session', ''));
|
||||||
|
if ($token === '') {
|
||||||
|
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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'])) {
|
||||||
|
return $this->error($data['message'] ?? 'INVALID_TOKEN', 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));
|
||||||
|
MallPlayxSession::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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户资产
|
||||||
|
* GET /api/v1/playx/assets?user_id=xxx
|
||||||
|
*/
|
||||||
|
public function assets(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeApi($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
if ($userId === null) {
|
||||||
|
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
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', ''));
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
if ($claimRequestId === '' || $userId === null) {
|
||||||
|
return $this->error(__('claim_request_id and user_id/session_id required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = MallPlayxClaimLog::where('claim_request_id', $claimRequestId)->find();
|
||||||
|
if ($exists) {
|
||||||
|
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
return $this->success('', $this->formatAsset($asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
if (!$asset) {
|
||||||
|
return $this->error(__('User asset not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 {
|
||||||
|
MallPlayxClaimLog::create([
|
||||||
|
'claim_request_id' => $claimRequestId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
if ($userId === null) {
|
||||||
|
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = MallPlayxOrder::where('user_id', $userId)
|
||||||
|
->with(['mallItem'])
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(100)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return $this->success('', ['list' => $list->toArray()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatAsset(?MallPlayxUserAsset $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));
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
if ($itemId <= 0 || $userId === null) {
|
||||||
|
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 = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
if (!$asset || $asset->available_points < $item->score) {
|
||||||
|
return $this->error(__('Insufficient points'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = MallPlayxOrder::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'type' => MallPlayxOrder::TYPE_BONUS,
|
||||||
|
'status' => MallPlayxOrder::STATUS_PENDING,
|
||||||
|
'mall_item_id' => $item->id,
|
||||||
|
'points_cost' => $item->score,
|
||||||
|
'amount' => $amount,
|
||||||
|
'multiplier' => $multiplier,
|
||||||
|
'external_transaction_id' => $orderNo,
|
||||||
|
'grant_status' => MallPlayxOrder::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, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
$receiverName = $request->post('receiver_name', '');
|
||||||
|
$receiverPhone = $request->post('receiver_phone', '');
|
||||||
|
$receiverAddress = $request->post('receiver_address', '');
|
||||||
|
if ($itemId <= 0 || $userId === null || $receiverName === '' || $receiverPhone === '' || $receiverAddress === '') {
|
||||||
|
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 = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
if (!$asset || $asset->available_points < $item->score) {
|
||||||
|
return $this->error(__('Insufficient points'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$asset->available_points -= $item->score;
|
||||||
|
$asset->save();
|
||||||
|
|
||||||
|
MallPlayxOrder::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'type' => MallPlayxOrder::TYPE_PHYSICAL,
|
||||||
|
'status' => MallPlayxOrder::STATUS_PENDING,
|
||||||
|
'mall_item_id' => $item->id,
|
||||||
|
'points_cost' => $item->score,
|
||||||
|
'receiver_name' => $receiverName,
|
||||||
|
'receiver_phone' => $receiverPhone,
|
||||||
|
'receiver_address' => $receiverAddress,
|
||||||
|
'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));
|
||||||
|
$userId = $this->resolveUserIdFromRequest($request);
|
||||||
|
if ($itemId <= 0 || $userId === null) {
|
||||||
|
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 = MallPlayxUserAsset::where('user_id', $userId)->find();
|
||||||
|
if (!$asset || $asset->available_points < $item->score) {
|
||||||
|
return $this->error(__('Insufficient points'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = MallPlayxOrder::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'type' => MallPlayxOrder::TYPE_WITHDRAW,
|
||||||
|
'status' => MallPlayxOrder::STATUS_PENDING,
|
||||||
|
'mall_item_id' => $item->id,
|
||||||
|
'points_cost' => $item->score,
|
||||||
|
'amount' => $amount,
|
||||||
|
'multiplier' => $multiplier,
|
||||||
|
'external_transaction_id' => $orderNo,
|
||||||
|
'grant_status' => MallPlayxOrder::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->callPlayxBalanceCredit($order, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'status' => 'PENDING',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callPlayxBonusGrant(MallPlayxOrder $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 = MallPlayxOrder::GRANT_ACCEPTED;
|
||||||
|
$order->save();
|
||||||
|
} else {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->fail_reason = $data['message'] ?? 'unknown';
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->fail_reason = $e->getMessage();
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callPlayxBalanceCredit(MallPlayxOrder $order, string $userId): void
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
|
||||||
|
$url = config('playx.api.balance_credit_url', '/api/v1/balance/credit');
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new \GuzzleHttp\Client(['timeout' => 15]);
|
||||||
|
$res = $client->post($baseUrl . $url, [
|
||||||
|
'json' => [
|
||||||
|
'request_id' => 'mall_withdraw_' . uniqid(),
|
||||||
|
'externalTransactionId' => $order->external_transaction_id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'amount' => $order->amount,
|
||||||
|
'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 = MallPlayxOrder::GRANT_ACCEPTED;
|
||||||
|
$order->save();
|
||||||
|
} else {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->fail_reason = $data['message'] ?? 'unknown';
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->fail_reason = $e->getMessage();
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,29 @@
|
|||||||
|
|
||||||
namespace app\common\model;
|
namespace app\common\model;
|
||||||
|
|
||||||
use app\common\model\traits\TimestampInteger;
|
|
||||||
use support\think\Model;
|
use support\think\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MallItem
|
* MallItem
|
||||||
|
* type: 1=BONUS(红利), 2=PHYSICAL(实物), 3=WITHDRAW(提现档位)
|
||||||
*/
|
*/
|
||||||
class MallItem extends Model
|
class MallItem extends Model
|
||||||
{
|
{
|
||||||
use TimestampInteger;
|
|
||||||
|
|
||||||
protected $name = 'mall_item';
|
protected $name = 'mall_item';
|
||||||
|
|
||||||
protected bool $autoWriteTimestamp = true;
|
protected bool $autoWriteTimestamp = true;
|
||||||
|
|
||||||
|
public const TYPE_BONUS = 1;
|
||||||
|
public const TYPE_PHYSICAL = 2;
|
||||||
|
public const TYPE_WITHDRAW = 3;
|
||||||
|
|
||||||
|
protected array $type = [
|
||||||
|
'create_time' => 'integer',
|
||||||
|
'update_time' => 'integer',
|
||||||
|
'amount' => 'float',
|
||||||
|
'multiplier' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
public function admin(): \think\model\relation\BelongsTo
|
public function admin(): \think\model\relation\BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
|
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
|
||||||
|
|||||||
26
app/common/model/MallPlayxSession.php
Normal file
26
app/common/model/MallPlayxSession.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\model;
|
||||||
|
|
||||||
|
use support\think\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayX 会话缓存
|
||||||
|
*/
|
||||||
|
class MallPlayxSession extends Model
|
||||||
|
{
|
||||||
|
protected string $name = 'mall_playx_session';
|
||||||
|
|
||||||
|
protected bool $autoWriteTimestamp = true;
|
||||||
|
|
||||||
|
protected array $type = [
|
||||||
|
// 这里需要显式声明 create_time / update_time 为 integer,
|
||||||
|
// 否则 ThinkORM 可能把 bigint 时间戳当成字符串,导致写入时出现 now 字符串问题。
|
||||||
|
'create_time' => 'integer',
|
||||||
|
'update_time' => 'integer',
|
||||||
|
'expire_time' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
280
app/process/PlayxJobs.php
Normal file
280
app/process/PlayxJobs.php
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\process;
|
||||||
|
|
||||||
|
use app\common\model\MallItem;
|
||||||
|
use app\common\model\MallPlayxOrder;
|
||||||
|
use app\common\model\MallPlayxUserAsset;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use Workerman\Timer;
|
||||||
|
use Workerman\Worker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayX 积分商城闭环任务
|
||||||
|
* - 轮询交易终态(ACCEPTED -> COMPLETED/REJECTED)
|
||||||
|
* - 对可重试失败进行重发(依赖 external_transaction_id 幂等)
|
||||||
|
*/
|
||||||
|
class PlayxJobs
|
||||||
|
{
|
||||||
|
protected Client $http;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// 确保定时任务只在一个 worker 上运行
|
||||||
|
if (!Worker::getAllWorkers()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->http = new Client([
|
||||||
|
'timeout' => 20,
|
||||||
|
'http_errors' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Timer::add(60, [$this, 'pollTransactionStatus']);
|
||||||
|
Timer::add(60, [$this, 'retryFailedGrants']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询:已 accepted 的订单,查询终态
|
||||||
|
*/
|
||||||
|
public function pollTransactionStatus(): void
|
||||||
|
{
|
||||||
|
$baseUrl = strval(config('playx.api.base_url', ''));
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status'));
|
||||||
|
$url = rtrim($baseUrl, '/') . $path;
|
||||||
|
|
||||||
|
$list = MallPlayxOrder::where('grant_status', MallPlayxOrder::GRANT_ACCEPTED)
|
||||||
|
->where('status', MallPlayxOrder::STATUS_PENDING)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(50)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
foreach ($list as $order) {
|
||||||
|
/** @var MallPlayxOrder $order */
|
||||||
|
try {
|
||||||
|
$res = $this->http->get($url, [
|
||||||
|
'query' => [
|
||||||
|
'externalTransactionId' => $order->external_transaction_id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode(strval($res->getBody()), true) ?? [];
|
||||||
|
$pxStatus = $data['status'] ?? '';
|
||||||
|
|
||||||
|
if ($pxStatus === MallPlayxOrder::STATUS_COMPLETED) {
|
||||||
|
$order->status = MallPlayxOrder::STATUS_COMPLETED;
|
||||||
|
$order->save();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pxStatus === 'FAILED' || $pxStatus === MallPlayxOrder::STATUS_REJECTED) {
|
||||||
|
// 仅在从 PENDING 转 REJECTED 时退分,避免重复入账
|
||||||
|
$order->status = MallPlayxOrder::STATUS_REJECTED;
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
|
||||||
|
$order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed');
|
||||||
|
$order->save();
|
||||||
|
$this->refundPoints($order);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况视为仍在队列/PENDING,保持不变
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 查询失败不影响状态,下一轮重试
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试:NOT_SENT / FAILED_RETRYABLE(按 retry_count 间隔)
|
||||||
|
*/
|
||||||
|
public function retryFailedGrants(): void
|
||||||
|
{
|
||||||
|
$baseUrl = strval(config('playx.api.base_url', ''));
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bonusPath = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
|
||||||
|
$withdrawPath = strval(config('playx.api.balance_credit_url', '/api/v1/balance/credit'));
|
||||||
|
$bonusUrl = rtrim($baseUrl, '/') . $bonusPath;
|
||||||
|
$withdrawUrl = rtrim($baseUrl, '/') . $withdrawPath;
|
||||||
|
|
||||||
|
$maxRetry = 3;
|
||||||
|
$list = MallPlayxOrder::whereIn('grant_status', [
|
||||||
|
MallPlayxOrder::GRANT_NOT_SENT,
|
||||||
|
MallPlayxOrder::GRANT_FAILED_RETRYABLE,
|
||||||
|
])
|
||||||
|
->where('status', MallPlayxOrder::STATUS_PENDING)
|
||||||
|
->where('retry_count', '<', $maxRetry)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(50)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
foreach ($list as $order) {
|
||||||
|
/** @var MallPlayxOrder $order */
|
||||||
|
$allow = $this->allowRetryByInterval($order);
|
||||||
|
if (!$allow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->retry_count = intval($order->retry_count ?? 0) + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->sendGrantByOrder($order, $bonusUrl, $withdrawUrl, $maxRetry);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$order->fail_reason = $e->getMessage();
|
||||||
|
if (intval($order->retry_count) >= $maxRetry) {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
|
||||||
|
$order->status = MallPlayxOrder::STATUS_REJECTED;
|
||||||
|
$order->save();
|
||||||
|
$this->refundPoints($order);
|
||||||
|
} else {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allowRetryByInterval(MallPlayxOrder $order): bool
|
||||||
|
{
|
||||||
|
if ($order->grant_status === MallPlayxOrder::GRANT_NOT_SENT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryCount = intval($order->retry_count ?? 0);
|
||||||
|
$updatedAt = intval($order->update_time ?? 0);
|
||||||
|
$diff = time() - $updatedAt;
|
||||||
|
|
||||||
|
// retry_count: 已失败重试次数
|
||||||
|
// 0 -> 下次重试:等待 1min
|
||||||
|
// 1 -> 下次重试:等待 5min
|
||||||
|
// 2 -> 下次重试:等待 15min
|
||||||
|
if ($retryCount === 0 && $diff >= 60) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($retryCount === 1 && $diff >= 300) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($retryCount >= 2 && $diff >= 900) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendGrantByOrder(MallPlayxOrder $order, string $bonusUrl, string $withdrawUrl, int $maxRetry): void
|
||||||
|
{
|
||||||
|
$item = null;
|
||||||
|
if ($order->mallItem) {
|
||||||
|
$item = $order->mallItem;
|
||||||
|
} else {
|
||||||
|
$item = MallItem::where('id', $order->mall_item_id)->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->type === MallPlayxOrder::TYPE_BONUS) {
|
||||||
|
$rewardName = $item ? strval($item->title) : '';
|
||||||
|
$category = $item ? strval($item->category) : 'daily';
|
||||||
|
$categoryTitle = $item ? strval($item->category_title) : '';
|
||||||
|
$multiplier = intval($order->multiplier ?? 0);
|
||||||
|
if ($multiplier <= 0) {
|
||||||
|
$multiplier = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestId = 'mall_retry_bonus_' . uniqid();
|
||||||
|
$res = $this->http->post($bonusUrl, [
|
||||||
|
'json' => [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'externalTransactionId' => $order->external_transaction_id,
|
||||||
|
'user_id' => $order->user_id,
|
||||||
|
'amount' => $order->amount,
|
||||||
|
'rewardName' => $rewardName,
|
||||||
|
'category' => $category,
|
||||||
|
'categoryTitle' => $categoryTitle,
|
||||||
|
'multiplier' => $multiplier,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode(strval($res->getBody()), true) ?? [];
|
||||||
|
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
|
||||||
|
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
|
||||||
|
$order->save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->fail_reason = strval($data['message'] ?? 'PlayX bonus grant not accepted');
|
||||||
|
if (intval($order->retry_count) >= $maxRetry) {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
|
||||||
|
$order->status = MallPlayxOrder::STATUS_REJECTED;
|
||||||
|
$order->save();
|
||||||
|
$this->refundPoints($order);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->type === MallPlayxOrder::TYPE_WITHDRAW) {
|
||||||
|
$multiplier = intval($order->multiplier ?? 0);
|
||||||
|
if ($multiplier <= 0) {
|
||||||
|
$multiplier = 1;
|
||||||
|
}
|
||||||
|
$requestId = 'mall_retry_withdraw_' . uniqid();
|
||||||
|
$res = $this->http->post($withdrawUrl, [
|
||||||
|
'json' => [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'externalTransactionId' => $order->external_transaction_id,
|
||||||
|
'user_id' => $order->user_id,
|
||||||
|
'amount' => $order->amount,
|
||||||
|
'multiplier' => $multiplier,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode(strval($res->getBody()), true) ?? [];
|
||||||
|
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
|
||||||
|
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
|
||||||
|
$order->save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->fail_reason = strval($data['message'] ?? 'PlayX balance credit not accepted');
|
||||||
|
if (intval($order->retry_count) >= $maxRetry) {
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
|
||||||
|
$order->status = MallPlayxOrder::STATUS_REJECTED;
|
||||||
|
$order->save();
|
||||||
|
$this->refundPoints($order);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
|
||||||
|
$order->save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHYSICAL 目前由后台手工发货/驳回,不参与 PlayX 发放重试
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refundPoints(MallPlayxOrder $order): void
|
||||||
|
{
|
||||||
|
if ($order->points_cost <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$asset = MallPlayxUserAsset::where('user_id', $order->user_id)->find();
|
||||||
|
if (!$asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已从用户可用积分中扣除,本次失败退回可用积分
|
||||||
|
$asset->available_points += intval($order->points_cost);
|
||||||
|
$asset->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
config/playx.php
Normal file
26
config/playx.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayX 积分商城对接配置
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
// 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * 返还比例(仅亏损时)
|
||||||
|
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
|
||||||
|
// 解锁比例:今日可领取上限 = yesterday_total_deposit * 解锁比例
|
||||||
|
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
|
||||||
|
// 提现折算:积分 → 现金(如 10 分 = 1 元)
|
||||||
|
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
|
||||||
|
// Daily Push 签名校验(PlayX 调用商城时使用)
|
||||||
|
'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')),
|
||||||
|
// token 会话缓存过期时间(秒)
|
||||||
|
'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')),
|
||||||
|
// PlayX API 配置(商城调用 PlayX 时使用)
|
||||||
|
'api' => [
|
||||||
|
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
|
||||||
|
'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')),
|
||||||
|
'token_verify_url' => '/api/v1/auth/verify-token',
|
||||||
|
'bonus_grant_url' => '/api/v1/bonus/grant',
|
||||||
|
'balance_credit_url' => '/api/v1/balance/credit',
|
||||||
|
'transaction_status_url' => '/api/v1/transaction/status',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -59,4 +59,10 @@ return [
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
,
|
||||||
|
// PlayX 闭环任务:轮询交易终态/失败重试
|
||||||
|
'playx_jobs' => [
|
||||||
|
'handler' => app\process\PlayxJobs::class,
|
||||||
|
'reloadable' => false,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
|
|||||||
// api/v1 鉴权
|
// api/v1 鉴权
|
||||||
Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']);
|
Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']);
|
||||||
|
|
||||||
|
// api/v1 PlayX 积分商城
|
||||||
|
Route::post('/api/v1/playx/daily-push', [\app\api\controller\v1\Playx::class, 'dailyPush']);
|
||||||
|
Route::post('/api/v1/playx/verify-token', [\app\api\controller\v1\Playx::class, 'verifyToken']);
|
||||||
|
Route::get('/api/v1/playx/assets', [\app\api\controller\v1\Playx::class, 'assets']);
|
||||||
|
Route::post('/api/v1/playx/claim', [\app\api\controller\v1\Playx::class, 'claim']);
|
||||||
|
Route::get('/api/v1/playx/items', [\app\api\controller\v1\Playx::class, 'items']);
|
||||||
|
Route::post('/api/v1/playx/bonus/redeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']);
|
||||||
|
Route::post('/api/v1/playx/physical/redeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']);
|
||||||
|
Route::post('/api/v1/playx/withdraw/apply', [\app\api\controller\v1\Playx::class, 'withdrawApply']);
|
||||||
|
Route::get('/api/v1/playx/orders', [\app\api\controller\v1\Playx::class, 'orders']);
|
||||||
|
|
||||||
// ==================== Admin 路由 ====================
|
// ==================== Admin 路由 ====================
|
||||||
// Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
|
// Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
|
||||||
|
|
||||||
|
|||||||
45
web/src/components/table/fieldRender/date.vue
Normal file
45
web/src/components/table/fieldRender/date.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div>{{ formattedValue }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { TableColumnCtx } from 'element-plus'
|
||||||
|
import { getCellValue } from '/@/components/table/index'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: TableRow
|
||||||
|
field: TableColumn
|
||||||
|
column: TableColumnCtx<TableRow>
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
|
||||||
|
|
||||||
|
const formattedValue = computed(() => {
|
||||||
|
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
|
||||||
|
|
||||||
|
// PlayX “业务日期”在后端为 YYYY-MM-DD(或 YYYY-MM-DDTHH:mm:ssZ)。
|
||||||
|
// 这里尽量“按字符串原样”处理,避免 new Date('YYYY-MM-DD') 的时区差导致日期偏移 1 天。
|
||||||
|
const s = typeof cellValue === 'string' ? cellValue : String(cellValue)
|
||||||
|
|
||||||
|
// 常见 ISO/日期字符串截取
|
||||||
|
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||||
|
if (m) return m[1]
|
||||||
|
|
||||||
|
// 如果后端返回的是秒级时间戳,做兜底:用本地日期组件拼出 YYYY-MM-DD
|
||||||
|
const n = Number(cellValue)
|
||||||
|
if (Number.isFinite(n) && s.length === 10) {
|
||||||
|
const d = new Date(n * 1000)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${mm}-${dd}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -8,6 +8,10 @@ export default {
|
|||||||
'type 1': 'type 1',
|
'type 1': 'type 1',
|
||||||
'type 2': 'type 2',
|
'type 2': 'type 2',
|
||||||
'type 3': 'type 3',
|
'type 3': 'type 3',
|
||||||
|
amount: 'amount',
|
||||||
|
multiplier: 'multiplier',
|
||||||
|
category: 'category',
|
||||||
|
category_title: 'category_title',
|
||||||
admin_id: 'admin_id',
|
admin_id: 'admin_id',
|
||||||
admin__username: 'username',
|
admin__username: 'username',
|
||||||
image: 'show image',
|
image: 'show image',
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ export default {
|
|||||||
remark: '备注',
|
remark: '备注',
|
||||||
score: '兑换积分',
|
score: '兑换积分',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
'type 1': '奖励',
|
'type 1': '红利(BONUS)',
|
||||||
'type 2': '充值',
|
'type 2': '实物(PHYSICAL)',
|
||||||
'type 3': '实物',
|
'type 3': '提现(WITHDRAW)',
|
||||||
|
amount: '现金面值',
|
||||||
|
multiplier: '流水倍数',
|
||||||
|
category: '红利业务类别',
|
||||||
|
category_title: '类别展示名',
|
||||||
admin_id: '创建管理员',
|
admin_id: '创建管理员',
|
||||||
admin__username: '创建管理员',
|
admin__username: '创建管理员',
|
||||||
image: '展示图',
|
image: '展示图',
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog
|
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="!!row" :title="t('channel.manage.whitelist')" @close="close">
|
||||||
class="ba-operate-dialog"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
:model-value="!!row"
|
|
||||||
:title="t('channel.manage.whitelist')"
|
|
||||||
@close="close"
|
|
||||||
>
|
|
||||||
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
|
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
|
||||||
<div class="ba-operate-form">
|
<div class="ba-operate-form">
|
||||||
<div class="ba-ip-white-list">
|
<div class="ba-ip-white-list">
|
||||||
@@ -47,7 +41,7 @@ const submitLoading = ref(false)
|
|||||||
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip]) */
|
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip]) */
|
||||||
function normalizeIpWhite(val: unknown): string[] {
|
function normalizeIpWhite(val: unknown): string[] {
|
||||||
if (!val || !Array.isArray(val)) return []
|
if (!val || !Array.isArray(val)) return []
|
||||||
return val.map((item) => (typeof item === 'string' ? item : item?.value ?? ''))
|
return val.map((item) => (typeof item === 'string' ? item : (item?.value ?? '')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipWhiteList = ref<string[]>([])
|
const ipWhiteList = ref<string[]>([])
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const baTable = new baTableClass(
|
|||||||
label: t('mall.item.type'),
|
label: t('mall.item.type'),
|
||||||
prop: 'type',
|
prop: 'type',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
minWidth: 140,
|
||||||
effect: 'dark',
|
effect: 'dark',
|
||||||
custom: { 1: 'success', 2: 'primary', 3: 'info' },
|
custom: { 1: 'success', 2: 'primary', 3: 'info' },
|
||||||
operator: 'eq',
|
operator: 'eq',
|
||||||
@@ -74,6 +75,36 @@ const baTable = new baTableClass(
|
|||||||
render: 'tag',
|
render: 'tag',
|
||||||
replaceValue: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') },
|
replaceValue: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t('mall.item.amount'),
|
||||||
|
prop: 'amount',
|
||||||
|
align: 'center',
|
||||||
|
sortable: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mall.item.multiplier'),
|
||||||
|
prop: 'multiplier',
|
||||||
|
align: 'center',
|
||||||
|
sortable: false,
|
||||||
|
operator: 'eq',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mall.item.category'),
|
||||||
|
prop: 'category',
|
||||||
|
align: 'center',
|
||||||
|
sortable: false,
|
||||||
|
operator: 'LIKE',
|
||||||
|
operatorPlaceholder: t('Fuzzy query'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mall.item.category_title'),
|
||||||
|
prop: 'category_title',
|
||||||
|
align: 'center',
|
||||||
|
sortable: false,
|
||||||
|
operator: 'LIKE',
|
||||||
|
operatorPlaceholder: t('Fuzzy query'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('mall.item.status'),
|
label: t('mall.item.status'),
|
||||||
prop: 'status',
|
prop: 'status',
|
||||||
@@ -139,7 +170,15 @@ const baTable = new baTableClass(
|
|||||||
width: 160,
|
width: 160,
|
||||||
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
timeFormat: 'yyyy-mm-dd hh:MM:ss',
|
||||||
},
|
},
|
||||||
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
|
{
|
||||||
|
label: t('Operate'),
|
||||||
|
align: 'center',
|
||||||
|
width: 100,
|
||||||
|
render: 'buttons',
|
||||||
|
buttons: optButtons,
|
||||||
|
operator: false,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
dblClickNotEditColumn: [undefined, 'status'],
|
dblClickNotEditColumn: [undefined, 'status'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,40 @@
|
|||||||
:input-attr="{ content: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') } }"
|
:input-attr="{ content: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') } }"
|
||||||
:placeholder="t('Please select field', { field: t('mall.item.type') })"
|
:placeholder="t('Please select field', { field: t('mall.item.type') })"
|
||||||
/>
|
/>
|
||||||
|
<FormItem
|
||||||
|
:label="t('mall.item.amount')"
|
||||||
|
type="number"
|
||||||
|
v-model="baTable.form.items!.amount"
|
||||||
|
prop="amount"
|
||||||
|
:input-attr="{ step: 0.01 }"
|
||||||
|
:placeholder="t('Please input field', { field: t('mall.item.amount') })"
|
||||||
|
v-if="isBonusOrWithdraw"
|
||||||
|
/>
|
||||||
|
<FormItem
|
||||||
|
:label="t('mall.item.multiplier')"
|
||||||
|
type="number"
|
||||||
|
v-model="baTable.form.items!.multiplier"
|
||||||
|
prop="multiplier"
|
||||||
|
:input-attr="{ step: 1 }"
|
||||||
|
:placeholder="t('Please input field', { field: t('mall.item.multiplier') })"
|
||||||
|
v-if="isBonusOrWithdraw"
|
||||||
|
/>
|
||||||
|
<FormItem
|
||||||
|
:label="t('mall.item.category')"
|
||||||
|
type="string"
|
||||||
|
v-model="baTable.form.items!.category"
|
||||||
|
prop="category"
|
||||||
|
:placeholder="t('Please input field', { field: t('mall.item.category') })"
|
||||||
|
v-if="isBonusOrWithdraw"
|
||||||
|
/>
|
||||||
|
<FormItem
|
||||||
|
:label="t('mall.item.category_title')"
|
||||||
|
type="string"
|
||||||
|
v-model="baTable.form.items!.category_title"
|
||||||
|
prop="category_title"
|
||||||
|
:placeholder="t('Please input field', { field: t('mall.item.category_title') })"
|
||||||
|
v-if="isBonusOrWithdraw"
|
||||||
|
/>
|
||||||
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
|
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
|
||||||
<FormItem
|
<FormItem
|
||||||
:label="t('mall.item.stock')"
|
:label="t('mall.item.stock')"
|
||||||
@@ -80,6 +114,7 @@
|
|||||||
prop="stock"
|
prop="stock"
|
||||||
:input-attr="{ step: 1 }"
|
:input-attr="{ step: 1 }"
|
||||||
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
|
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
|
||||||
|
v-if="isPhysical"
|
||||||
/>
|
/>
|
||||||
<FormItem
|
<FormItem
|
||||||
:label="t('mall.item.sort')"
|
:label="t('mall.item.sort')"
|
||||||
@@ -112,7 +147,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormItemRule } from 'element-plus'
|
import type { FormItemRule } from 'element-plus'
|
||||||
import { inject, reactive, useTemplateRef } from 'vue'
|
import { computed, inject, reactive, useTemplateRef, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import FormItem from '/@/components/formItem/index.vue'
|
import FormItem from '/@/components/formItem/index.vue'
|
||||||
import { useConfig } from '/@/stores/config'
|
import { useConfig } from '/@/stores/config'
|
||||||
@@ -125,6 +160,35 @@ const baTable = inject('baTable') as baTableClass
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const itemType = computed(() => baTable.form.items!.type)
|
||||||
|
const isBonus = computed(() => itemType.value === 1 || itemType.value === '1')
|
||||||
|
const isPhysical = computed(() => itemType.value === 2 || itemType.value === '2')
|
||||||
|
const isWithdraw = computed(() => itemType.value === 3 || itemType.value === '3')
|
||||||
|
const isBonusOrWithdraw = computed(() => isBonus.value || isWithdraw.value)
|
||||||
|
|
||||||
|
// 切换类型后,清理不适用的字段,避免“隐藏字段仍保留上一次的值”导致提交脏数据
|
||||||
|
watch(
|
||||||
|
itemType,
|
||||||
|
(n, o) => {
|
||||||
|
if (o === undefined) return
|
||||||
|
if (!baTable.form.items) return
|
||||||
|
|
||||||
|
const typeNum = Number(n)
|
||||||
|
if (!Number.isFinite(typeNum)) return
|
||||||
|
|
||||||
|
if (typeNum === 2) {
|
||||||
|
baTable.form.items.amount = 0
|
||||||
|
baTable.form.items.multiplier = 0
|
||||||
|
baTable.form.items.category = ''
|
||||||
|
baTable.form.items.category_title = ''
|
||||||
|
} else {
|
||||||
|
// BONUS / WITHDRAW
|
||||||
|
baTable.form.items.stock = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||||
title: [buildValidatorData({ name: 'required', title: t('mall.item.title') })],
|
title: [buildValidatorData({ name: 'required', title: t('mall.item.title') })],
|
||||||
description: [buildValidatorData({ name: 'required', title: t('mall.item.description') })],
|
description: [buildValidatorData({ name: 'required', title: t('mall.item.description') })],
|
||||||
@@ -133,6 +197,87 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
|||||||
buildValidatorData({ name: 'required', title: t('mall.item.score') }),
|
buildValidatorData({ name: 'required', title: t('mall.item.score') }),
|
||||||
],
|
],
|
||||||
type: [buildValidatorData({ name: 'required', title: t('mall.item.type') })],
|
type: [buildValidatorData({ name: 'required', title: t('mall.item.type') })],
|
||||||
|
amount: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, val: any, callback: Function) => {
|
||||||
|
if (!isBonusOrWithdraw.value) return callback()
|
||||||
|
if (val === '' || val === null || val === undefined) {
|
||||||
|
return callback(new Error(t('Please input field', { field: t('mall.item.amount') })))
|
||||||
|
}
|
||||||
|
const num = Number(val)
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.amount') })))
|
||||||
|
}
|
||||||
|
if (num < 0) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.amount') })))
|
||||||
|
}
|
||||||
|
return callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
multiplier: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, val: any, callback: Function) => {
|
||||||
|
if (!isBonusOrWithdraw.value) return callback()
|
||||||
|
if (val === '' || val === null || val === undefined) {
|
||||||
|
return callback(new Error(t('Please input field', { field: t('mall.item.multiplier') })))
|
||||||
|
}
|
||||||
|
const num = Number(val)
|
||||||
|
if (!Number.isFinite(num) || !Number.isInteger(num)) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
|
||||||
|
}
|
||||||
|
if (num < 0) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
|
||||||
|
}
|
||||||
|
return callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
category: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, val: any, callback: Function) => {
|
||||||
|
if (!isBonusOrWithdraw.value) return callback()
|
||||||
|
if (!val) {
|
||||||
|
return callback(new Error(t('Please input field', { field: t('mall.item.category') })))
|
||||||
|
}
|
||||||
|
return callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
category_title: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, val: any, callback: Function) => {
|
||||||
|
if (!isBonusOrWithdraw.value) return callback()
|
||||||
|
if (!val) {
|
||||||
|
return callback(new Error(t('Please input field', { field: t('mall.item.category_title') })))
|
||||||
|
}
|
||||||
|
return callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stock: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, val: any, callback: Function) => {
|
||||||
|
if (!isPhysical.value) return callback()
|
||||||
|
if (val === '' || val === null || val === undefined) {
|
||||||
|
return callback(new Error(t('Please input field', { field: t('mall.item.stock') })))
|
||||||
|
}
|
||||||
|
const num = Number(val)
|
||||||
|
if (!Number.isFinite(num) || !Number.isInteger(num)) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.stock') })))
|
||||||
|
}
|
||||||
|
if (num < 0) {
|
||||||
|
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.stock') })))
|
||||||
|
}
|
||||||
|
return callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
sort: [buildValidatorData({ name: 'number', title: t('mall.item.sort') })],
|
sort: [buildValidatorData({ name: 'number', title: t('mall.item.sort') })],
|
||||||
create_time: [buildValidatorData({ name: 'date', title: t('mall.item.create_time') })],
|
create_time: [buildValidatorData({ name: 'date', title: t('mall.item.create_time') })],
|
||||||
update_time: [buildValidatorData({ name: 'date', title: t('mall.item.update_time') })],
|
update_time: [buildValidatorData({ name: 'date', title: t('mall.item.update_time') })],
|
||||||
|
|||||||
Reference in New Issue
Block a user