优化每日推送和积分转化逻辑

This commit is contained in:
2026-07-24 11:12:29 +08:00
parent b2b4fd4fbc
commit 4bad91b8a3
29 changed files with 661 additions and 102 deletions

View File

@@ -30,9 +30,11 @@ class DailyPush extends Backend
'date',
'username',
'yesterday_win_loss_net',
'converted_points',
'yesterday_total_deposit',
'lifetime_total_deposit',
'lifetime_total_withdraw',
'lifetime_win_loss_net',
'create_time',
];

View File

@@ -6,8 +6,10 @@ namespace app\admin\controller\mall;
use app\common\controller\Backend;
use app\common\library\MallPlayxRatios;
use app\common\library\MallPointsConversion;
use app\common\model\MallBusinessConfig;
use support\Response;
use support\think\Db;
use Webman\Http\Request;
/**
@@ -22,25 +24,15 @@ class PlayxConfig extends Backend
return $response;
}
$row = MallBusinessConfig::order('id', 'asc')->find();
if (!$row) {
$now = time();
MallBusinessConfig::create([
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
'create_time' => $now,
'update_time' => $now,
]);
$row = MallBusinessConfig::order('id', 'asc')->find();
}
$row = MallBusinessConfig::ensureRow();
if ($row) {
MallPlayxRatios::syncFromRow($row);
}
MallPlayxRatios::syncFromRow($row);
$payload = $row->toArray();
$payload['active_mall_open_date'] = MallPointsConversion::resolveActiveMallOpenDate($row);
return $this->success('', [
'row' => $row ? $row->toArray() : [],
'row' => $payload,
'remark' => get_route_remark(),
]);
}
@@ -71,20 +63,54 @@ class PlayxConfig extends Backend
return $this->error(__('Parameter error'));
}
$dailyEnabled = !empty($data['daily_points_enabled']) ? 1 : 0;
$lifetimeEnabled = !empty($data['lifetime_points_enabled']) ? 1 : 0;
$lifetimeRatio = $data['lifetime_points_ratio'] ?? null;
if (!is_numeric($lifetimeRatio)) {
return $this->error(__('Parameter error'));
}
$lifetimeRatioF = floatval($lifetimeRatio);
if ($lifetimeRatioF < 0) {
return $this->error(__('Parameter error'));
}
$mallOpenDate = MallPointsConversion::normalizeDate($data['mall_open_date'] ?? null);
$row = MallBusinessConfig::order('id', 'asc')->find();
$now = time();
if (!$row) {
$now = time();
$effectiveOn = $mallOpenDate !== null ? MallPointsConversion::nextCalendarDate() : null;
MallBusinessConfig::create([
'return_ratio' => $returnF,
'unlock_ratio' => $unlockF,
'points_to_cash_ratio' => $cashF,
'create_time' => $now,
'update_time' => $now,
'return_ratio' => $returnF,
'unlock_ratio' => $unlockF,
'points_to_cash_ratio' => $cashF,
'daily_points_enabled' => $dailyEnabled,
'lifetime_points_enabled' => $lifetimeEnabled,
'lifetime_points_ratio' => $lifetimeRatioF,
'mall_open_date' => $mallOpenDate,
'mall_open_date_previous' => null,
'mall_open_date_effective_on' => $effectiveOn,
'create_time' => $now,
'update_time' => $now,
]);
} else {
$previousOpen = MallPointsConversion::resolveActiveMallOpenDate($row);
$currentPending = MallPointsConversion::normalizeDate($row->mall_open_date ?? null);
$openDateChanged = $mallOpenDate !== $currentPending;
$row->return_ratio = $returnF;
$row->unlock_ratio = $unlockF;
$row->points_to_cash_ratio = $cashF;
$row->daily_points_enabled = $dailyEnabled;
$row->lifetime_points_enabled = $lifetimeEnabled;
$row->lifetime_points_ratio = $lifetimeRatioF;
if ($openDateChanged) {
$row->mall_open_date_previous = $previousOpen;
$row->mall_open_date = $mallOpenDate;
$row->mall_open_date_effective_on = $mallOpenDate !== null ? MallPointsConversion::nextCalendarDate() : null;
}
$row->save();
}
@@ -95,4 +121,32 @@ class PlayxConfig extends Backend
return $this->success(__('The current page configuration item was updated successfully'));
}
/**
* 一键按最新终身总输赢与比例折算积分(重复执行会先扣旧比例再加新比例)
*/
public function calculateLifetimePoints(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$row = MallBusinessConfig::order('id', 'asc')->find();
if (!$row) {
return $this->error(__('Parameter error'));
}
Db::startTrans();
try {
$stats = MallPointsConversion::recalculateAllLifetimePoints($row);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Operation completed'), $stats);
}
}

View File

@@ -42,6 +42,8 @@ class UserAsset extends Backend
'today_limit',
'today_claimed',
'today_limit_date',
'points_claim_cap',
'total_points_claimed',
'create_time',
'update_time',
];

View File

@@ -27,6 +27,7 @@ return [
'Added successfully' => 'Added successfully!',
'Deleted successfully' => 'Deleted successfully!',
'Parameter error' => 'Parameter error!',
'Lifetime points source is disabled' => 'Lifetime points source is disabled',
'File uploaded successfully' => 'File uploaded successfully',
'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.',

View File

@@ -27,6 +27,7 @@ return [
'Added successfully' => '添加成功!',
'Deleted successfully' => '删除成功!',
'Parameter error' => '参数错误!',
'Lifetime points source is disabled' => '终身总输赢积分来源未启用',
'Please use the %s field to sort before operating' => '请使用 %s 字段排序后再操作',
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',

View File

@@ -15,6 +15,7 @@ use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\library\MallPlayxRatios;
use app\common\library\MallPointsConversion;
use app\common\library\MallDailyPushRawLogger;
use app\common\model\MallAddress;
use support\think\Db;
@@ -143,11 +144,19 @@ class Playx extends Api
/**
* 按每日推送记录同步用户资产主信息applyAssetDelta=true 时才落资产增量。
*/
/**
* @return array<string, mixed>
*/
private function getPointsConversionConfig(): array
{
return MallPointsConversion::configForConversion();
}
private function syncAssetByDailyPush(
string $playxUserId,
string $username,
string $date,
int $newLocked,
int $convertedPoints,
int $todayLimit,
bool $applyAssetDelta,
string $phone = ''
@@ -169,7 +178,9 @@ class Playx extends Api
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
if ($convertedPoints !== 0) {
MallPointsConversion::applyDailyConversionDelta($asset, $convertedPoints);
}
$asset->today_limit = $todayLimit;
}
@@ -235,9 +246,8 @@ class Playx extends Api
$requestId = 'report_' . $date;
}
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
$pointsConfig = $this->getPointsConversionConfig();
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$results = [];
$allDeduped = true;
@@ -254,15 +264,13 @@ class Playx extends Api
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
$lifetimeTotalDeposit = $m['ltv_deposit'] ?? ($m['lty_deposit'] ?? 0);
$lifetimeTotalWithdraw = $m['ltv_withdrawal'] ?? ($m['lty_withdrawal'] ?? 0);
$lifetimeWinLossNet = MallPointsConversion::parseLifetimeWinLossFromMember($m);
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $newLocked, $todayLimit, false, $phone);
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, false, $phone);
if (!$asset) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
@@ -278,7 +286,7 @@ class Playx extends Api
Db::startTrans();
try {
MallDailyPush::create([
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $username,
@@ -287,15 +295,17 @@ class Playx extends Api
'lifetime_total_deposit' => $lifetimeTotalDeposit,
'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
'create_time' => time(),
]);
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
MallDailyPush::create($pushData);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $newLocked, $todayLimit, true, $phone);
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, true, $phone);
if (!$asset) {
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
}
@@ -338,19 +348,15 @@ class Playx extends Api
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$newLocked = 0;
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
$pointsConfig = $this->getPointsConversionConfig();
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$newLocked,
$convertedPoints,
$todayLimit,
false,
$phone
@@ -369,7 +375,12 @@ class Playx extends Api
Db::startTrans();
try {
MallDailyPush::create([
$pointsConfig = $this->getPointsConversionConfig();
$lifetimeWinLossNet = null;
if (isset($body['lifetime_win_loss_net']) && is_numeric($body['lifetime_win_loss_net'])) {
$lifetimeWinLossNet = floatval($body['lifetime_win_loss_net']);
}
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $body['username'] ?? '',
@@ -378,22 +389,22 @@ class Playx extends Api
'lifetime_total_deposit' => $body['lifetime_total_deposit'] ?? 0,
'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
'create_time' => time(),
]);
$newLocked = 0;
$ratios = MallPlayxRatios::get();
$returnRatio = $ratios['return_ratio'];
$unlockRatio = $ratios['unlock_ratio'];
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
MallDailyPush::create($pushData);
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$newLocked,
$convertedPoints,
$todayLimit,
true,
$phone
@@ -744,11 +755,12 @@ class Playx extends Api
}
$remain = $asset->today_limit - $asset->today_claimed;
if ($asset->locked_points <= 0 || $remain <= 0) {
$lifetimeRemain = MallPointsConversion::remainingClaimable($asset);
if ($asset->locked_points <= 0 || $remain <= 0 || $lifetimeRemain <= 0) {
return $this->error(__('No points to claim or limit reached'));
}
$canClaim = min($asset->locked_points, $remain);
$canClaim = min($asset->locked_points, $remain, $lifetimeRemain);
Db::startTrans();
try {
@@ -762,6 +774,7 @@ class Playx extends Api
$asset->locked_points -= $canClaim;
$asset->available_points += $canClaim;
$asset->today_claimed += $canClaim;
$asset->total_points_claimed = max(0, intval($asset->total_points_claimed ?? 0) + $canClaim);
$asset->save();
Db::commit();

View File

@@ -30,9 +30,11 @@ class MallDailyPushExport
'date' => '业务日期',
'username' => '用户名',
'yesterday_win_loss_net' => '昨日净输赢',
'converted_points' => '转换积分',
'yesterday_total_deposit' => '昨日总充值',
'lifetime_total_deposit' => '历史总充值',
'lifetime_total_withdraw' => '历史总提现',
'lifetime_total_deposit' => '终身总充值',
'lifetime_total_withdraw' => '终身总提现',
'lifetime_win_loss_net' => '终身总输赢',
'create_time' => '创建时间',
];
@@ -45,9 +47,11 @@ class MallDailyPushExport
'date' => 'Business date',
'username' => 'Username',
'yesterday_win_loss_net' => 'Yesterday net win/loss',
'converted_points' => 'Converted points',
'yesterday_total_deposit' => 'Yesterday total deposit',
'lifetime_total_deposit' => 'Lifetime total deposit',
'lifetime_total_withdraw' => 'Lifetime total withdraw',
'lifetime_win_loss_net' => 'Lifetime net win/loss',
'create_time' => 'Created at',
];

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use app\common\model\MallDailyPush;
use app\common\model\MallUserAsset;
use ba\Random;
@@ -28,9 +29,9 @@ class MallDailyPushLogReplay
$stats['unique_members'] = count($records);
[$dailyMap, $assetByPlayxId, $assetByUsername] = $this->preloadExistingData($records);
$ratios = MallPlayxRatios::get();
$returnRatio = floatval($ratios['return_ratio'] ?? 0);
$unlockRatio = floatval($ratios['unlock_ratio'] ?? 0);
$configRow = MallBusinessConfig::ensureRow();
$pointsConfig = MallPointsConversion::configFromRow($configRow);
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
[$backupHandle, $backupPath] = $this->openBackup($dryRun);
$stats['backup_file'] = $backupPath;
@@ -45,7 +46,7 @@ class MallDailyPushLogReplay
$record,
$daily,
$asset,
$returnRatio,
$pointsConfig,
$unlockRatio,
$dryRun,
$backupHandle,
@@ -249,21 +250,29 @@ class MallDailyPushLogReplay
array $record,
?MallDailyPush $daily,
?MallUserAsset $asset,
float $returnRatio,
array $pointsConfig,
float $unlockRatio,
bool $dryRun,
$backupHandle,
array &$stats
): array {
$oldWinLossNet = $daily ? floatval($daily->yesterday_win_loss_net ?? 0) : 0.0;
$lockedDelta = $this->lockedContribution($record['yesterday_win_loss_net'], $returnRatio)
- $this->lockedContribution($oldWinLossNet, $returnRatio);
$newLocked = MallPointsConversion::dailyConvertedPoints(
$record['date'],
floatval($record['yesterday_win_loss_net']),
$pointsConfig
);
$oldLocked = $daily
? MallPointsConversion::dailyConvertedPoints($record['date'], $oldWinLossNet, $pointsConfig)
: 0;
$lockedDelta = $newLocked - $oldLocked;
$todayLimit = intval(round($record['yesterday_total_deposit'] * $unlockRatio));
$dailyData = [
'user_id' => $record['user_id'],
'date' => $record['date'],
'username' => $record['username'],
'yesterday_win_loss_net' => $record['yesterday_win_loss_net'],
'converted_points' => $newLocked,
'yesterday_total_deposit' => $record['yesterday_total_deposit'],
'lifetime_total_deposit' => $record['lifetime_total_deposit'],
'lifetime_total_withdraw' => $record['lifetime_total_withdraw'],
@@ -317,6 +326,10 @@ class MallDailyPushLogReplay
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'points_claim_cap' => 0,
'total_points_claimed' => 0,
'lifetime_converted_points' => 0,
'daily_converted_points' => 0,
'create_time' => $record['create_time'],
'update_time' => time(),
]);
@@ -336,7 +349,7 @@ class MallDailyPushLogReplay
if ($assetWillUpdate) {
if ($lockedDelta !== 0) {
$asset->locked_points = max(0, intval($asset->locked_points ?? 0) + $lockedDelta);
MallPointsConversion::applyDailyConversionDelta($asset, $lockedDelta);
}
$this->applyDailyLimit($asset, $record['date'], $todayLimit);
$asset->save();
@@ -404,6 +417,7 @@ class MallDailyPushLogReplay
}
foreach ([
'yesterday_win_loss_net',
'converted_points',
'yesterday_total_deposit',
'lifetime_total_deposit',
'lifetime_total_withdraw',

View File

@@ -9,7 +9,7 @@ use support\Redis as RedisSupport;
use Throwable;
/**
* PlayX 与积分商城相关的比例配置:优先读 Redis未命中则读 mall_business_config 并回写 Redis无表记录时回退 .env 并写入 Redis。
* PlayX 与积分商城相关的比例配置:优先读 Redis未命中则读 mall_business_config 并回写 Redis。
*/
final class MallPlayxRatios
{
@@ -124,9 +124,9 @@ final class MallPlayxRatios
}
$out = [
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
'return_ratio' => MallBusinessConfig::DEFAULT_RETURN_RATIO,
'unlock_ratio' => MallBusinessConfig::DEFAULT_UNLOCK_RATIO,
'points_to_cash_ratio' => MallBusinessConfig::DEFAULT_POINTS_TO_CASH_RATIO,
];
self::writeRedis($conn, $out);

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use app\common\model\MallDailyPush;
use app\common\model\MallUserAsset;
use support\think\Db;
/**
* 积分来源:昨日净输赢 / 终身总输赢 折算与领取上限
*/
final class MallPointsConversion
{
/**
* @return array{
* return_ratio: float,
* unlock_ratio: float,
* points_to_cash_ratio: float,
* daily_points_enabled: bool,
* lifetime_points_enabled: bool,
* lifetime_points_ratio: float,
* mall_open_date: ?string,
* mall_open_date_previous: ?string,
* mall_open_date_effective_on: ?string,
* active_mall_open_date: ?string
* }
*/
public static function configFromRow(MallBusinessConfig $row): array
{
return [
'return_ratio' => floatval($row->return_ratio ?? 0),
'unlock_ratio' => floatval($row->unlock_ratio ?? 0),
'points_to_cash_ratio' => floatval($row->points_to_cash_ratio ?? 0),
'daily_points_enabled' => intval($row->daily_points_enabled ?? 1) === 1,
'lifetime_points_enabled' => intval($row->lifetime_points_enabled ?? 0) === 1,
'lifetime_points_ratio' => floatval($row->lifetime_points_ratio ?? 0),
'mall_open_date' => self::normalizeDate($row->mall_open_date ?? null),
'mall_open_date_previous' => self::normalizeDate($row->mall_open_date_previous ?? null),
'mall_open_date_effective_on' => self::normalizeDate($row->mall_open_date_effective_on ?? null),
'active_mall_open_date' => self::resolveActiveMallOpenDate($row),
];
}
public static function lossToPoints(float $winLossNet, float $ratio): int
{
if ($winLossNet >= 0 || $ratio <= 0) {
return 0;
}
return self::lossIntegerToPoints($winLossNet, $ratio);
}
/**
* 仅统计输钱:取净输赢绝对值的整数部分,再按比例折算为正整数积分
*/
public static function lossIntegerToPoints(float $winLossNet, float $ratio): int
{
if ($winLossNet >= 0 || $ratio <= 0) {
return 0;
}
$lossInteger = intval(floor(abs($winLossNet)));
if ($lossInteger <= 0) {
return 0;
}
return intval(round($lossInteger * $ratio));
}
/**
* 每日推送:昨日净输赢折算积分(受开关、商城开放日约束)
*
* @param array<string, mixed> $config
*/
public static function dailyConvertedPoints(string $businessDate, float $yesterdayWinLossNet, array $config): int
{
if (empty($config['daily_points_enabled'])) {
return 0;
}
$openDate = $config['active_mall_open_date'] ?? null;
if ($openDate !== null && $openDate !== '' && $businessDate < $openDate) {
return 0;
}
$ratio = floatval($config['return_ratio'] ?? 0);
return self::lossIntegerToPoints($yesterdayWinLossNet, $ratio);
}
/**
* @param array<string, mixed> $config
* @deprecated 使用 dailyConvertedPoints
*/
public static function dailyLockedIncrement(string $businessDate, float $yesterdayWinLossNet, array $config): int
{
return self::dailyConvertedPoints($businessDate, $yesterdayWinLossNet, $config);
}
/**
* @return array<string, mixed>
*/
public static function configForConversion(): array
{
return self::configFromRow(MallBusinessConfig::ensureRow());
}
public static function syncClaimCap(MallUserAsset $asset): void
{
$lifetime = max(0, intval($asset->lifetime_converted_points ?? 0));
$daily = max(0, intval($asset->daily_converted_points ?? 0));
$asset->points_claim_cap = $lifetime + $daily;
}
public static function applyDailyConversionDelta(MallUserAsset $asset, int $delta): void
{
if ($delta === 0) {
return;
}
$asset->daily_converted_points = max(0, intval($asset->daily_converted_points ?? 0) + $delta);
self::syncClaimCap($asset);
$newLocked = intval($asset->locked_points ?? 0) + $delta;
$asset->locked_points = max(0, $newLocked);
}
public static function applyLifetimeConversion(MallUserAsset $asset, int $targetLifetimePoints): void
{
$current = max(0, intval($asset->lifetime_converted_points ?? 0));
$delta = $targetLifetimePoints - $current;
if ($delta === 0) {
return;
}
$asset->lifetime_converted_points = $targetLifetimePoints;
self::syncClaimCap($asset);
$newLocked = intval($asset->locked_points ?? 0) + $delta;
$asset->locked_points = max(0, $newLocked);
}
/**
* @return array{processed: int, updated: int, skipped: int}
*/
public static function recalculateAllLifetimePoints(MallBusinessConfig $row): array
{
if (intval($row->lifetime_points_enabled ?? 0) !== 1) {
throw new \RuntimeException(__('Lifetime points source is disabled'));
}
$config = self::configFromRow($row);
$ratio = floatval($config['lifetime_points_ratio'] ?? 0);
if ($ratio < 0) {
throw new \RuntimeException(__('Parameter error'));
}
$stats = ['processed' => 0, 'updated' => 0, 'skipped' => 0];
$rows = Db::query(
'SELECT d.* FROM mall_daily_push d INNER JOIN (
SELECT user_id, MAX(`date`) AS max_date FROM mall_daily_push GROUP BY user_id
) latest ON d.user_id = latest.user_id AND d.date = latest.max_date'
);
foreach ($rows as $pushRow) {
$stats['processed']++;
$playxUserId = trim(strval($pushRow['user_id'] ?? ''));
if ($playxUserId === '') {
$stats['skipped']++;
continue;
}
$lifetimeWl = $pushRow['lifetime_win_loss_net'] ?? null;
if ($lifetimeWl === null || $lifetimeWl === '') {
$stats['skipped']++;
continue;
}
$target = self::lossToPoints(floatval($lifetimeWl), $ratio);
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->lock(true)->find();
if (!$asset) {
$stats['skipped']++;
continue;
}
$before = intval($asset->lifetime_converted_points ?? 0);
if ($before === $target) {
$stats['skipped']++;
continue;
}
self::applyLifetimeConversion($asset, $target);
$asset->save();
$stats['updated']++;
}
return $stats;
}
public static function remainingClaimable(MallUserAsset $asset): int
{
$cap = max(0, intval($asset->points_claim_cap ?? 0));
$claimed = max(0, intval($asset->total_points_claimed ?? 0));
return max(0, $cap - $claimed);
}
public static function resolveActiveMallOpenDate(MallBusinessConfig $row): ?string
{
$pending = self::normalizeDate($row->mall_open_date ?? null);
$previous = self::normalizeDate($row->mall_open_date_previous ?? null);
$effectiveOn = self::normalizeDate($row->mall_open_date_effective_on ?? null);
$today = date('Y-m-d');
if ($pending === null) {
return $previous;
}
if ($effectiveOn === null || $today >= $effectiveOn) {
return $pending;
}
return $previous;
}
public static function normalizeDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
$str = trim(strval($value));
if ($str === '') {
return null;
}
if (strlen($str) >= 10) {
$str = substr($str, 0, 10);
}
$parsed = \DateTime::createFromFormat('Y-m-d', $str);
return $parsed && $parsed->format('Y-m-d') === $str ? $str : null;
}
public static function nextCalendarDate(string $fromDate = ''): string
{
$base = $fromDate !== '' ? $fromDate : date('Y-m-d');
$dt = \DateTime::createFromFormat('Y-m-d', $base);
if (!$dt) {
$dt = new \DateTime();
}
$dt->modify('+1 day');
return $dt->format('Y-m-d');
}
public static function parseLifetimeWinLossFromMember(array $member): ?float
{
if (!array_key_exists('ltv_wl', $member) && !array_key_exists('lifetime_total_wl', $member) && !array_key_exists('ltv_total_wl', $member)) {
return null;
}
$raw = $member['ltv_wl'] ?? ($member['lifetime_total_wl'] ?? ($member['ltv_total_wl'] ?? null));
if ($raw === null || $raw === '') {
return null;
}
if (!is_numeric($raw)) {
return null;
}
return floatval($raw);
}
}

View File

@@ -15,11 +15,50 @@ class MallBusinessConfig extends Model
protected bool $autoWriteTimestamp = true;
public const DEFAULT_RETURN_RATIO = 0.1;
public const DEFAULT_UNLOCK_RATIO = 0.1;
public const DEFAULT_POINTS_TO_CASH_RATIO = 0.1;
public const DEFAULT_LIFETIME_POINTS_RATIO = 0.1;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'return_ratio' => 'float',
'unlock_ratio' => 'float',
'points_to_cash_ratio' => 'float',
'create_time' => 'integer',
'update_time' => 'integer',
'return_ratio' => 'float',
'unlock_ratio' => 'float',
'points_to_cash_ratio' => 'float',
'daily_points_enabled' => 'integer',
'lifetime_points_enabled' => 'integer',
'lifetime_points_ratio' => 'float',
'mall_open_date' => 'string',
'mall_open_date_previous' => 'string',
'mall_open_date_effective_on' => 'string',
];
/**
* 保证存在一条商城参数配置(比例仅来自后台 mall_business_config不再读 .env
*/
public static function ensureRow(): self
{
$row = self::order('id', 'asc')->find();
if ($row) {
return $row;
}
$now = time();
$created = self::create([
'return_ratio' => self::DEFAULT_RETURN_RATIO,
'unlock_ratio' => self::DEFAULT_UNLOCK_RATIO,
'points_to_cash_ratio' => self::DEFAULT_POINTS_TO_CASH_RATIO,
'daily_points_enabled' => 1,
'lifetime_points_enabled' => 0,
'lifetime_points_ratio' => self::DEFAULT_LIFETIME_POINTS_RATIO,
'create_time' => $now,
'update_time' => $now,
]);
if (!$created) {
throw new \RuntimeException('Failed to create mall_business_config');
}
return $created;
}
}

View File

@@ -15,9 +15,11 @@ class MallDailyPush extends Model
protected array $type = [
'yesterday_win_loss_net' => 'float',
'converted_points' => 'integer',
'yesterday_total_deposit' => 'float',
'lifetime_total_deposit' => 'float',
'lifetime_total_withdraw' => 'float',
'lifetime_win_loss_net' => 'float',
'create_time' => 'integer',
];
}

View File

@@ -24,6 +24,10 @@ class MallUserAsset extends Model
'today_limit' => 'integer',
'today_claimed' => 'integer',
'admin_id' => 'integer',
'points_claim_cap' => 'integer',
'total_points_claimed' => 'integer',
'lifetime_converted_points' => 'integer',
'daily_converted_points' => 'integer',
];
/**
@@ -62,6 +66,10 @@ class MallUserAsset extends Model
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'points_claim_cap' => 0,
'total_points_claimed' => 0,
'lifetime_converted_points' => 0,
'daily_converted_points' => 0,
'create_time' => $now,
'update_time' => $now,
]);