feat: 临时调整马豆结算与兑换券释放为1分钟执行一次

这个提交包含在:
chen
2026-09-18 10:42:38 +08:00
父节点 813a36c908
当前提交 195a098101
共有 9 个文件被更改,包括 428 次插入 和 10 次删除

查看文件

@@ -13,22 +13,40 @@ class PartnerSettle extends Command
protected function configure()
{
$this->setName('shopro:partner:settle')
->addOption('date', 'd', Option::VALUE_OPTIONAL, '结算日期 Y-m-d,默认昨天')
->addOption('date', 'd', Option::VALUE_OPTIONAL, '结算日期 Y-m-d,指定日期时执行传统日结')
->addOption('rerun', 'r', Option::VALUE_NONE, '删除当天日结后重算')
->setDescription('收益中心日结(马豆/馆县均分/兑换券释放)');
->addOption('daily', null, Option::VALUE_NONE, '强制执行昨天标准日结')
->setDescription('收益中心结算(默认1分钟马豆增量结算与兑换券释放,亦支持指定日期日结)');
}
protected function execute(Input $input, Output $output)
{
$date = $input->getOption('date');
$rerun = (bool) $input->getOption('rerun');
$output->writeln('partner settle start ' . ($date ?: 'yesterday') . ($rerun ? ' rerun' : ''));
$isDaily = (bool) $input->getOption('daily');
// 指定日期或强制日结模式
if ($date || $isDaily || $rerun) {
$settleDate = $date ?: date('Y-m-d', strtotime('-1 day'));
$output->writeln('partner daily settle start ' . $settleDate . ($rerun ? ' rerun' : ''));
try {
$report = (new DailySettleService())->run($date, $rerun);
$output->writeln('partner daily settle done: ' . json_encode($report ? $report->toArray() : [], JSON_UNESCAPED_UNICODE));
return 0;
} catch (\Exception $e) {
$output->writeln('partner daily settle fail: ' . $e->getMessage());
return 1;
}
}
// 默认执行 1 分钟马豆增量结算与兑换券释放
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner minute settle start');
try {
$report = (new DailySettleService())->run($date, $rerun);
$output->writeln('partner settle done: ' . json_encode($report ? $report->toArray() : [], JSON_UNESCAPED_UNICODE));
$result = (new DailySettleService())->runMinute();
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner minute settle done: ' . json_encode($result, JSON_UNESCAPED_UNICODE));
return 0;
} catch (\Exception $e) {
$output->writeln('partner settle fail: ' . $e->getMessage());
$output->writeln('[' . date('Y-m-d H:i:s') . '] partner minute settle fail: ' . $e->getMessage());
return 1;
}
}

查看文件

@@ -55,6 +55,148 @@ class DailySettleService
}
}
/**
* 临时分钟级调度:执行未结算订单的马豆分红,并释放一期兑换券
*/
public function runMinute()
{
$lockDir = RUNTIME_PATH . 'lock';
if (!is_dir($lockDir)) {
@mkdir($lockDir, 0755, true);
}
$lockFile = $lockDir . '/partner_settle_minute.lock';
$fp = fopen($lockFile, 'c');
if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
return ['status' => 'skipped', 'message' => '上一次分钟任务仍在执行中'];
}
try {
$today = date('Y-m-d');
$rateService = new RateService();
$rewardService = new PartnerRewardService();
$activeService = new ActiveService();
// 1. 兑换券 1 分钟释放一次
$voucherTotal = (new VoucherService())->releaseMinute();
// 2. 马豆 1 分钟结算一次(提取已支付但尚未结算马豆的合格订单)
$allQualifiedOrders = $rateService->qualifiedOrderQuery()->field('o.id,o.pay_fee,o.user_id,o.ext,o.paid_time')->select();
$unsettledOrders = [];
$madowGmv = 0;
foreach ($allQualifiedOrders as $o) {
$ext = $o['ext'] ?? [];
if (is_string($ext)) {
$ext = json_decode($ext, true) ?: [];
}
if (empty($ext['madow_settled'])) {
$unsettledOrders[] = $o;
$qFee = $rewardService->getQualifiedPayFee($o);
$madowGmv = bcadd((string) $madowGmv, (string) $qFee, 2);
}
}
$madowRes = ['pool' => 0, 'count' => 0, 'each' => 0];
$settledOrderIds = [];
if ((float) $madowGmv > 0 && !empty($unsettledOrders)) {
$settledOrderIds = array_column($unsettledOrders, 'id');
$windowStart = time() - (PartnerConfig::activeDays() - 1) * 86400;
$tasterIds = $activeService->activeUserIds($windowStart, time());
$madowRes = (new MadowService())->shareMinute($madowGmv, $tasterIds, $settledOrderIds);
// 标记订单已结算马豆
foreach ($unsettledOrders as $o) {
$ext = $o['ext'] ?? [];
if (is_string($ext)) {
$ext = json_decode($ext, true) ?: [];
}
$ext['madow_settled'] = 1;
$ext['madow_settle_time'] = time();
\think\Db::name('shopro_order')->where('id', $o['id'])->update([
'ext' => json_encode($ext, JSON_UNESCAPED_UNICODE),
]);
}
// 清除汇率缓存,联动大盘最新动态汇率
RateService::clearCache();
}
// 3. 统计今日最新大盘并同步/创建今日 DailyReport
$todayStart = strtotime($today . ' 00:00:00');
$todayEnd = strtotime($today . ' 23:59:59');
$todayGmv = $rateService->qualifiedGmv($todayStart, $todayEnd);
$todayOrderCount = $rateService->qualifiedOrderQuery($todayStart, $todayEnd)->count();
$orderCountN = $rateService->countN();
$currentRate = $rateService->calcRate($orderCountN);
// 当日累计发放兑换券总额
$todayVoucherReleased = (float) \think\Db::name('shopro_partner_reward')
->whereBetween('createtime', [$todayStart, $todayEnd])
->where('reward_type', 'voucher_release')
->where('status', 1)
->sum('amount');
// 当日累计发放马豆总额
$todayMadowPool = (float) \think\Db::name('shopro_partner_reward')
->whereBetween('createtime', [$todayStart, $todayEnd])
->where('reward_type', 'madow_share')
->where('status', 1)
->sum('madow');
$existingTodayReport = DailyReport::where('settle_date', $today)->find();
if ($existingTodayReport) {
$existingTodayReport->order_count = $todayOrderCount;
$existingTodayReport->order_count_n = $orderCountN;
$existingTodayReport->gmv = $todayGmv;
$existingTodayReport->rate = $currentRate;
$existingTodayReport->madow_pool = $todayMadowPool;
$existingTodayReport->voucher_release_total = $todayVoucherReleased;
$existingTodayReport->updatetime = time();
$existingTodayReport->save();
} else {
DailyReport::create([
'settle_date' => $today,
'order_count' => $todayOrderCount,
'order_count_n' => $orderCountN,
'gmv' => $todayGmv,
'rate' => $currentRate,
'madow_pool' => $todayMadowPool,
'share_user_count' => $madowRes['count'] ?? 0,
'venue_pool' => 0,
'venue_user_count' => 0,
'county_pool' => 0,
'county_user_count' => 0,
'voucher_release_total' => $todayVoucherReleased,
'status' => 1,
'error_msg' => '',
'createtime' => time(),
'updatetime' => time(),
]);
}
// 更新汇率日志
$rateService->snapshot($today, $todayGmv, [
'madow_pool' => $todayMadowPool,
'share_user_count' => $madowRes['count'] ?? 0,
]);
return [
'status' => 'success',
'time' => date('Y-m-d H:i:s'),
'voucher_released' => $voucherTotal,
'madow_settled_gmv' => (float) $madowGmv,
'madow_pool' => $madowRes['pool'],
'madow_each' => $madowRes['each'],
'settled_orders' => $settledOrderIds,
];
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
protected function settle($settleDate)
{
$start = strtotime($settleDate . ' 00:00:00');

查看文件

@@ -82,6 +82,86 @@ class MadowService
return ['pool' => $pool, 'count' => $count, 'each' => $each];
}
/**
* 临时分钟级马豆结算均分
*/
public function shareMinute($gmv, $userIds, $orderIds = [])
{
$count = count($userIds);
$pool = (float) $gmv;
if ($pool <= 0) {
return ['pool' => 0, 'count' => $count, 'each' => 0];
}
$minId = (int) \think\Db::name('shopro_partner_reward')->where('order_id', '<', 0)->min('order_id');
$virtualOrderId = min(-100000, $minId - 1);
if ($count <= 0) {
PartnerRewardService::write([
'order_id' => $virtualOrderId,
'buyer_id' => 0,
'user_id' => 0,
'role' => 'system',
'reward_type' => 'madow_share',
'amount' => 0,
'madow' => $pool,
'base_amount' => $pool,
'settle_date' => null,
'ext' => ['formula' => "分母为0,马豆池 {$pool} 进平台", 'orders' => $orderIds],
'status' => 1,
]);
return ['pool' => $pool, 'count' => 0, 'each' => 0];
}
$each = function_exists('bcdiv') ? bcdiv((string) $pool, (string) $count, 8) : number_format($pool / $count, 8, '.', '');
$distributed = 0;
$now = time();
$chunks = array_chunk($userIds, 500);
foreach ($chunks as $chunk) {
AgentModel::whereIn('user_id', $chunk)->setInc('madow', $each);
$batchRows = [];
foreach ($chunk as $userId) {
$batchRows[] = [
'order_id' => $virtualOrderId,
'buyer_id' => 0,
'user_id' => $userId,
'role' => 'taster',
'reward_type' => 'madow_share',
'amount' => 0,
'madow' => $each,
'base_amount' => $pool,
'settle_date' => null,
'ext' => json_encode(['formula' => "GMV {$pool} / {$count} = {$each}", 'share_user_count' => $count, 'gmv' => $pool, 'orders' => $orderIds], JSON_UNESCAPED_UNICODE),
'status' => 1,
'createtime' => $now,
'updatetime' => $now,
];
$distributed = function_exists('bcadd') ? bcadd((string) $distributed, (string) $each, 8) : ($distributed + $each);
}
\think\Db::name('shopro_partner_reward')->insertAll($batchRows);
}
$tail = function_exists('bcsub') ? bcsub((string) $pool, (string) $distributed, 8) : round($pool - $distributed, 8);
if ((float) $tail > 0) {
PartnerRewardService::write([
'order_id' => $virtualOrderId,
'buyer_id' => 0,
'user_id' => 0,
'role' => 'system',
'reward_type' => 'madow_share',
'amount' => 0,
'madow' => $tail,
'base_amount' => $pool,
'settle_date' => null,
'ext' => ['formula' => "尾差 {$tail} 进平台", 'orders' => $orderIds],
'status' => 1,
]);
}
return ['pool' => $pool, 'count' => $count, 'each' => $each];
}
public function rollback($settleDate)
{
$rows = \app\admin\model\shopro\partner\Reward::where('settle_date', $settleDate)

查看文件

@@ -144,7 +144,8 @@ class VoucherService
return null;
}
$daily = round($total / $days, 2);
$startDate = date('Y-m-d', strtotime('+1 day', $this->orderTime($order)));
// 临时模式:当天立即生效起算,支持 1 分钟释放联调
$startDate = date('Y-m-d', $this->orderTime($order));
$plan = VoucherPlan::create([
'user_id' => $buyerId,
@@ -183,6 +184,73 @@ class VoucherService
return $plan;
}
/**
* 临时分钟级释放兑换券:每 1 分钟对所有释放中的计划释放一期
*
* @return float 本次释放总金额
*/
public function releaseMinute()
{
$plans = VoucherPlan::where('status', 1)->select();
$totalReleased = 0;
foreach ($plans as $plan) {
if ($plan->released_days >= $plan->total_days || bccomp((string) $plan->released_amount, (string) $plan->total_amount, 2) >= 0) {
$plan->status = 2;
$plan->save();
continue;
}
$leftDays = $plan->total_days - $plan->released_days;
if ($leftDays <= 1) {
$amount = round((float) $plan->total_amount - (float) $plan->released_amount, 2);
$plan->status = 2;
} else {
$amount = (float) $plan->daily_amount;
}
if ($amount <= 0) {
$plan->status = 2;
$plan->save();
continue;
}
$plan->released_amount = round((float) $plan->released_amount + $amount, 2);
$plan->released_days += 1;
if (bccomp((string) $plan->released_amount, (string) $plan->total_amount, 2) >= 0) {
$plan->status = 2;
}
$plan->save();
$agent = AgentModel::where('user_id', $plan->user_id)->find();
if ($agent) {
$agent->setInc('voucher_released', $amount);
}
// 唯一负数 order_id 规避与历史冲突;settle_date 设为 NULL 规避 uk_daily_user_type 唯一索引
$virtualOrderId = -(intval($plan->id) * 1000 + (intval($plan->released_days) % 1000));
PartnerRewardService::write([
'order_id' => $virtualOrderId,
'buyer_id' => $plan->user_id,
'user_id' => $plan->user_id,
'role' => 'taster',
'reward_type' => 'voucher_release',
'amount' => $amount,
'base_amount' => $amount,
'settle_date' => null,
'ext' => [
'formula' => "兑换券释放 {$amount}(第{$plan->released_days}/{$plan->total_days}期)",
'plan_id' => $plan->id,
'order_id' => $plan->order_id,
'released_days' => $plan->released_days,
],
'status' => 1,
]);
$totalReleased = bcadd((string) $totalReleased, (string) $amount, 2);
}
return (float) $totalReleased;
}
public function releaseByDate($settleDate)
{
$plans = VoucherPlan::where('status', 1)

查看文件

@@ -141,12 +141,18 @@ class Dashboard extends Common
$currentRate = $rateService->currentRate();
$totalMadowCny = round($totalMadow * $currentRate, 2);
$historyOrderN = $rateService->countN();
$rawBase = PartnerConfig::get('partner_madow_base_rate', '0.00000075');
$baseRate = IdentityService::formatRate($rawBase);
$stepOrders = intval(PartnerConfig::get('partner_madow_step_orders', 1));
$stepOrders = $stepOrders > 0 ? $stepOrders : 1;
$latestReport = DailyReport::order('settle_date', 'desc')->find();
$yesterdayMadowPool = $latestReport ? (float) $latestReport->madow_pool : 0.00;
$madowData = [
'total_madow' => IdentityService::formatMadow($totalMadow),
'base_rate' => $baseRate,
'step_orders' => $stepOrders,
'current_rate' => number_format($currentRate, 10, '.', ''),
'rate_per_wan' => number_format($currentRate * 10000, 4, '.', ''),
'total_madow_cny' => number_format($totalMadowCny, 2, '.', ''),

查看文件

@@ -518,7 +518,14 @@
</div>
<div class="finance-sub-metric sa-flex sa-row-between">
<span>③ 汇率计算公式:</span>
<strong class="text-primary" style="font-size: 12px;">0.00000075 × {{ state.madow.history_order_n }} 单</strong>
<strong class="text-primary" style="font-size: 12px;">
<template v-if="(state.madow.step_orders || 1) > 1">
{{ state.madow.base_rate || '0.00000075' }} × ⌊{{ state.madow.history_order_n }}/{{ state.madow.step_orders }}⌋
</template>
<template v-else>
{{ state.madow.base_rate || '0.00000075' }} × {{ state.madow.history_order_n }} 单
</template>
</strong>
</div>
<div class="sa-flex sa-row-between pt-2" style="font-size: 12px; color: var(--sa-subfont); border-top: 1px dashed var(--sa-space);">
<span>算法:基础汇率 × 累计完成单数 N</span>

查看文件

@@ -24,6 +24,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
},
madow: {
total_madow: '0',
base_rate: '0.00000075',
step_orders: 1,
current_rate: '0.0000000000',
rate_per_wan: '0.0000',
total_madow_cny: '0.00',

查看文件

@@ -96,6 +96,14 @@ assert(
dashboardControllerContent.includes("'history_order_n' => $historyOrderN"),
'大盘接口输出历史累计完成订单数 history_order_n'
);
assert(
dashboardControllerContent.includes("'base_rate' => $baseRate"),
'大盘接口动态输出系统配置的基础汇率 base_rate'
);
assert(
dashboardControllerContent.includes("'step_orders' => $stepOrders"),
'大盘接口动态输出步进订单数 step_orders'
);
// 6. 验证前端仪表盘模板 application/admin/view/shopro/dashboard/index.html 卡片设计
const dashboardViewPath = path.resolve(__dirname, '../application/admin/view/shopro/dashboard/index.html');
@@ -127,8 +135,8 @@ assert(
'大盘视图展示每万豆价值 rate_per_wan'
);
assert(
dashboardViewContent.includes('0.00000075 × {{ state.madow.history_order_n }} 单'),
'大盘视图直观呈现汇率计算公式 (0.00000075 × N 单)'
dashboardViewContent.includes("{{ state.madow.base_rate || '0.00000075' }} × {{ state.madow.history_order_n }} 单"),
'大盘视图呈现动态汇率计算公式 (动态 base_rate × N 单)'
);
assert(
dashboardViewContent.includes('日结池明细 →'),

查看文件

@@ -0,0 +1,87 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
console.log('========================================================');
console.log(' 马豆 1 分钟增量结算与兑换券 1 分钟释放规范自动化测试');
console.log('========================================================\n');
let passCount = 0;
function test(name, fn) {
try {
fn();
console.log(` [PASS] ${name}`);
passCount++;
} catch (err) {
console.error(` [FAIL] ${name}: ${err.message}`);
process.exit(1);
}
}
// 1. VoucherService 验证
const voucherServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/VoucherService.php');
assert(fs.existsSync(voucherServicePath), 'VoucherService.php 文件存在');
const voucherContent = fs.readFileSync(voucherServicePath, 'utf-8');
test('VoucherService 包含 releaseMinute() 分钟级释放方法', () => {
assert(voucherContent.includes('function releaseMinute()'), '必须定义 releaseMinute()');
});
test('VoucherService 包含立即生效起算逻辑', () => {
assert(voucherContent.includes("date('Y-m-d', $this->orderTime($order))"), '起算日期当天立即生效');
});
test('VoucherService 分钟释放使用 NULL 结算日规避唯一索引冲突', () => {
assert(voucherContent.includes("'settle_date' => null"), 'settle_date 必须设为 null 避开 uk_daily_user_type 限制');
assert(voucherContent.includes("$virtualOrderId = -(intval($plan->id) * 1000"), 'order_id 必须使用负数虚拟 ID 避开 uk_order_user_type 限制');
});
// 2. MadowService 验证
const madowServicePath = path.resolve(__dirname, '../addons/shopro/service/partner/MadowService.php');
assert(fs.existsSync(madowServicePath), 'MadowService.php 文件存在');
const madowContent = fs.readFileSync(madowServicePath, 'utf-8');
test('MadowService 包含 shareMinute() 分钟级马豆分红方法', () => {
assert(madowContent.includes('function shareMinute('), '必须定义 shareMinute()');
});
test('MadowService 分钟分红使用防重虚拟 order_id 与 NULL 结算日', () => {
assert(madowContent.includes("'settle_date' => null"), 'settle_date 必须设为 null');
assert(madowContent.includes("$virtualOrderId = min(-100000,"), '必须保证递减全局虚拟 order_id');
});
// 3. DailySettleService 验证
const dailySettlePath = path.resolve(__dirname, '../addons/shopro/service/partner/DailySettleService.php');
assert(fs.existsSync(dailySettlePath), 'DailySettleService.php 文件存在');
const settleContent = fs.readFileSync(dailySettlePath, 'utf-8');
test('DailySettleService 包含 runMinute() 总调度入口', () => {
assert(settleContent.includes('function runMinute()'), '必须定义 runMinute()');
});
test('DailySettleService runMinute 调度兑换券与马豆结算', () => {
assert(settleContent.includes('releaseMinute()'), '调用 releaseMinute() 释放兑换券');
assert(settleContent.includes('shareMinute('), '调用 shareMinute() 发放马豆');
assert(settleContent.includes("'madow_settled'] = 1"), '订单必须打上 madow_settled 标记防止重复均分');
assert(settleContent.includes('partner_settle_minute.lock'), '包含独立互斥锁保障并发安全');
});
// 4. PartnerSettle.php 控制台命令验证
const partnerSettlePath = path.resolve(__dirname, '../addons/shopro/console/PartnerSettle.php');
assert(fs.existsSync(partnerSettlePath), 'PartnerSettle.php 文件存在');
const cmdContent = fs.readFileSync(partnerSettlePath, 'utf-8');
test('PartnerSettle 默认执行 runMinute() 分钟级调度', () => {
assert(cmdContent.includes('runMinute()'), '默认调用 runMinute()');
assert(cmdContent.includes('partner minute settle done'), '输出分钟级执行结果日志');
});
test('PartnerSettle 保留 --date / --rerun / --daily 传统日结兼容', () => {
assert(cmdContent.includes("$input->getOption('date')"), '兼容 -d/--date 参数');
assert(cmdContent.includes("$input->getOption('rerun')"), '兼容 -r/--rerun 参数');
assert(cmdContent.includes("$input->getOption('daily')"), '兼容 --daily 参数');
});
console.log('\n--------------------------------------------------------');
console.log(`测试执行结果: 通过 ${passCount} 项, 失败 0 项`);
console.log('========================================================\n');