فهرست منبع

推广汇总没数据

doge 1 روز پیش
والد
کامیت
bdfa3567d1

+ 68 - 0
app/Console/Commands/BackfillShareCommissions.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\BalanceLog;
+use App\Services\ShareCommissionRecorder;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+
+class BackfillShareCommissions extends Command
+{
+    protected $signature = 'share:backfill-commissions
+        {--start-date= : 返现入账起始日 Y-m-d,默认结束日前30天}
+        {--end-date= : 返现入账结束日 Y-m-d,默认今天}
+        {--chunk=500 : 每批读取条数,1至2000}
+        {--write : 实际登记佣金明细;不传时只预览}';
+
+    protected $description = '从已有充值返现流水补记分享佣金明细,不修改钱包或再次发奖';
+
+    public function handle(ShareCommissionRecorder $recorder): int
+    {
+        try {
+            $end = $this->parseDate((string)($this->option('end-date') ?: date('Y-m-d')));
+            $start = $this->parseDate((string)($this->option('start-date') ?: $end->copy()->subDays(30)->toDateString()));
+            if ($start->gt($end) || $start->diffInDays($end) > 366) {
+                throw new \InvalidArgumentException('日期范围须先后有效,单次不超过366天');
+            }
+            $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT);
+            if ($chunk === false || $chunk < 1 || $chunk > 2000) {
+                throw new \InvalidArgumentException('chunk 必须是1至2000的整数');
+            }
+            $write = (bool)$this->option('write');
+            $counts = ['scanned' => 0, 'created' => 0, 'pending' => 0, 'existing' => 0, 'skipped' => 0, 'incomplete_metadata' => 0];
+            $reasons = [];
+            $this->info(($write ? '写入登记' : '只读预览') . ":{$start->toDateString()} 至 {$end->toDateString()}");
+            BalanceLog::where('change_type', '充值返现')->where('type', 1)->where('amount', '>', 0)
+                ->whereBetween('created_at', [$start->toDateString() . ' 00:00:00', $end->toDateString() . ' 23:59:59'])
+                ->chunkById($chunk, function ($rows) use ($recorder, $write, &$counts, &$reasons) {
+                    foreach ($rows as $row) {
+                        $result = $write ? $recorder->record($row) : $recorder->preview($row);
+                        $counts['scanned']++;
+                        $counts[$result['status']]++;
+                        if (!empty($result['incomplete_metadata'])) $counts['incomplete_metadata']++;
+                        if ($result['status'] === 'skipped') {
+                            $reason = $result['reason'];
+                            $reasons[$reason] = ($reasons[$reason] ?? 0) + 1;
+                        }
+                    }
+                });
+            $this->table(['项目', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($counts), array_values($counts)));
+            foreach ($reasons as $reason => $count) $this->line("跳过 {$reason}: {$count}");
+            if (!$write) $this->info('预览未写入数据;确认后使用相同日期并加 --write 登记,钱包不会再次入账。');
+            return self::SUCCESS;
+        } catch (\Throwable $e) {
+            $this->error($e->getMessage());
+            return self::FAILURE;
+        }
+    }
+
+    private function parseDate(string $value): Carbon
+    {
+        $date = Carbon::createFromFormat('!Y-m-d', $value, config('app.timezone', 'Asia/Shanghai'));
+        if (!$date || $date->format('Y-m-d') !== $value) {
+            throw new \InvalidArgumentException('日期必须为有效的 Y-m-d');
+        }
+        return $date;
+    }
+}

+ 17 - 2
app/Services/BalanceLogService.php

@@ -200,8 +200,23 @@ class BalanceLogService extends BaseService
                     if (!empty($agent_user_code)) {
                         $agent_member_id = User::where('user_code', $agent_user_code)->value('member_id');
                         if (!empty($agent_member_id)) {
-                            $balanceData = WalletService::updateBalance($agent_member_id, $add_amount);
-                            BalanceLogService::addLog($agent_member_id, $add_amount, $balanceData['before_balance'], $balanceData['after_balance'], '充值返现', $related_id, '被邀请人:'.$memberId."充值金额为:{$remark_amount}");
+                            DB::transaction(function () use ($agent_member_id, $add_amount, $related_id, $memberId, $remark_amount, $user, $amount, $rate) {
+                                $wallet = Wallet::where('member_id', $agent_member_id)->lockForUpdate()->first();
+                                if (!$wallet) throw new \RuntimeException('推广人钱包不存在');
+                                $before = (string)$wallet->available_balance;
+                                $wallet->available_balance = bcadd($before, (string)$add_amount, 10);
+                                $wallet->save();
+                                $rebate = BalanceLogService::addLog($agent_member_id, $add_amount, $before, $wallet->available_balance,
+                                    '充值返现', $related_id, '被邀请人:'.$memberId."充值金额为:{$remark_amount}");
+                                $record = (new ShareCommissionRecorder())->record($rebate, [
+                                    'invitee_user_id' => $user->id,
+                                    'base_amount' => $amount,
+                                    'rate' => $rate,
+                                ]);
+                                if ($record['status'] === 'skipped') {
+                                    throw new \RuntimeException('返现佣金明细登记失败:' . $record['reason']);
+                                }
+                            });
                         }
                     }
                 }

+ 126 - 0
app/Services/ShareCommissionRecorder.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\BalanceLog;
+use App\Models\ShareCommissionRecord;
+use App\Models\User;
+use Brick\Math\BigDecimal;
+use Brick\Math\RoundingMode;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+
+/** Registers rebates that have already been paid; never credits a wallet. */
+class ShareCommissionRecorder
+{
+    public const SOURCE_TYPE = 'recharge_rebate_log';
+
+    public function preview(BalanceLog $rebate): array
+    {
+        $result = $this->prepare($rebate);
+        if ($result['status'] === 'skipped') return $result;
+        $result['status'] = $this->recordQuery($result['attributes'])->exists() ? 'existing' : 'pending';
+        return $result;
+    }
+
+    /** Context supplies the original base and percentage during a new rebate payout. */
+    public function record(BalanceLog $rebate, array $context = []): array
+    {
+        return DB::transaction(function () use ($rebate, $context) {
+            $locked = BalanceLog::where('id', $rebate->id)->lockForUpdate()->first();
+            if (!$locked) return ['status' => 'skipped', 'reason' => 'missing_log'];
+            $result = $this->prepare($locked, $context);
+            if ($result['status'] === 'skipped') return $result;
+            // Current read also protects callers whose transaction snapshot predates a backfill.
+            if ($this->recordQuery($result['attributes'])->lockForUpdate()->first()) {
+                $result['status'] = 'existing';
+                return $result;
+            }
+            ShareCommissionRecord::create($result['attributes']);
+            $result['status'] = 'created';
+            return $result;
+        });
+    }
+
+    private function recordQuery(array $attributes): Builder
+    {
+        return ShareCommissionRecord::where('source_type', self::SOURCE_TYPE)
+            ->where('source_id', $attributes['source_id'])
+            ->where('type', 'commission');
+    }
+
+    private function prepare(BalanceLog $rebate, array $context = []): array
+    {
+        if (!$rebate->exists || $rebate->change_type !== '充值返现' || (int)$rebate->type !== 1) {
+            return ['status' => 'skipped', 'reason' => 'not_recharge_rebate'];
+        }
+        $amount = $this->decimal($rebate->amount);
+        $before = $this->decimal($rebate->before_balance, 10);
+        $after = $this->decimal($rebate->after_balance, 10);
+        if ($amount === null || bccomp($amount, '0', 4) <= 0) {
+            return ['status' => 'skipped', 'reason' => 'invalid_amount'];
+        }
+        if ($before === null || $after === null || $this->decimal(bcsub($after, $before, 10)) !== $amount) {
+            return ['status' => 'skipped', 'reason' => 'unbalanced_log'];
+        }
+        $promoters = User::where('member_id', $rebate->member_id)->limit(2)->get(['id']);
+        if ($promoters->count() !== 1) {
+            return ['status' => 'skipped', 'reason' => $promoters->isEmpty() ? 'missing_promoter' : 'ambiguous_promoter'];
+        }
+        $rawDate = $rebate->getRawOriginal('created_at');
+        if (!$rawDate) return ['status' => 'skipped', 'reason' => 'missing_date'];
+        try {
+            $zone = config('app.timezone', 'Asia/Shanghai');
+            $date = Carbon::parse($rawDate, $zone)->setTimezone($zone)->toDateString();
+        } catch (\Throwable $e) {
+            return ['status' => 'skipped', 'reason' => 'invalid_date'];
+        }
+
+        $inviteeId = null;
+        $base = '0.0000';
+        $rate = '0.0000';
+        if ($context) {
+            $base = $this->decimal($context['base_amount'] ?? null);
+            $rate = $this->decimal($context['rate'] ?? null);
+            $inviteeId = $context['invitee_user_id'] ?? null;
+            if ($base === null || bccomp($base, '0', 4) <= 0 || $rate === null
+                || bccomp($rate, '0', 4) < 0 || bccomp($rate, '10000', 4) >= 0 || !$inviteeId) {
+                return ['status' => 'skipped', 'reason' => 'invalid_context'];
+            }
+        } elseif (preg_match('/^被邀请人:([0-9]{1,64})充值金额为:(\d{1,16}(?:\.\d{1,10})?)$/u', (string)$rebate->remark, $match)) {
+            // Use the historical receipt, not today's rate or the invitee's current referral link.
+            $base = $this->decimal($match[2]) ?? '0.0000';
+            $invitees = User::where('member_id', $match[1])->limit(2)->get(['id']);
+            if ($invitees->count() === 1) $inviteeId = $invitees->first()->id;
+            if (bccomp($base, '0', 4) > 0) {
+                $effectiveRate = $this->decimal(bcdiv(bcmul($amount, '100', 10), $base, 10));
+                if ($effectiveRate !== null && bccomp($effectiveRate, '10000', 4) < 0) $rate = $effectiveRate;
+            }
+        }
+        return [
+            'status' => 'pending',
+            'incomplete_metadata' => $inviteeId === null || bccomp($base, '0', 4) <= 0,
+            'attributes' => [
+                'stat_date' => $date,
+                'promoter_user_id' => $promoters->first()->id,
+                'invitee_user_id' => $inviteeId,
+                'type' => 'commission',
+                'category' => 'recharge',
+                'base_amount' => $base,
+                'rate' => $rate,
+                'amount' => $amount,
+                'source_type' => self::SOURCE_TYPE,
+                'source_id' => (string)$rebate->id,
+                'status' => 1,
+            ],
+        ];
+    }
+
+    private function decimal($value, int $scale = 4): ?string
+    {
+        if (is_float($value) && is_finite($value)) $value = sprintf('%.10F', $value);
+        if (!is_scalar($value) || !preg_match('/^-?\d{1,16}(?:\.\d+)?$/', (string)$value)) return null;
+        return (string)BigDecimal::of((string)$value)->toScale($scale, RoundingMode::HALF_UP);
+    }
+}

+ 10 - 0
docs/前端接口/分享赚钱/推广汇总/接口.md

@@ -51,3 +51,13 @@
 | `highest_fee_waiver_rate` | 筛选区间最高减免比例 |
 
 该接口只汇总 `share_commission_records` 中已经生成的记录,不根据截图样例临时计算或直接改会员余额。
+
+## 明细来源与历史数据
+
+- 新的「充值返现」沿用现有 `recharge_rate` 比例及原发放条件,实际返现入账时同步登记佣金明细,随后本接口即可读取。无需增加前端参数或触发结算请求。
+- `date` 为返现实际入账日;`members` 接口按被邀请人注册日统计,两者日期口径不同。
+- 历史已入账返现需由后端执行一次明细回填。回填仅补统计记录,不修改钱包或重复发钱,可重复执行。
+- 历史基数从原返现流水备注还原;历史比例是实际返现与当时记载基数的有效比例,不使用今天的配置重新计算。无法还原时,佣金仍按真实已付金额登记,`deposit` 可为 `0.0000`,表示该部分计算基数缺失。
+- 仅有推荐注册或充值、尚未实际产生返现的会员,不会生成虚拟佣金行。一级/二级新分配规则及手续费减免仍沿用现有实现边界,本次没有新增资金规则。
+
+后端回填与排查步骤见 [推广汇总明细登记与历史回填](../../../后端/分享赚钱-推广汇总明细登记与历史回填.md)。

+ 79 - 0
docs/后端/分享赚钱-推广汇总明细登记与历史回填.md

@@ -0,0 +1,79 @@
+# 分享赚钱:推广汇总明细登记与历史回填
+
+`/admin/shareEarning/members` 读取推荐注册关系,`/admin/shareEarning/summary` 读取已生成的 `share_commission_records`。会员列表有数据、佣金明细表为空时,汇总列表会为空。
+
+本次接通已有邀请返现的明细登记,保留现有 `config.recharge_rate`、真实用户充值及原推荐关系的发放条件,不新增一级/二级、投注返佣、手续费减免或其他资金规则。
+
+## 新返现
+
+真实充值或三方充值触发原「充值返现」时,在一个事务内锁定推广人钱包并完成:
+
+1. 按原比例计算的返现金额入账。
+2. 写入 `balance_logs.change_type=充值返现` 的余额流水。
+3. 登记对应的佣金明细,供后台汇总和会员端佣金读取。
+
+钱包、返现流水或明细任一步失败,以上返现写入一起回滚。已有外层支付事务时,服从调用方最终提交或回滚。没有外层事务时,本次返现事务不会撤销此前已完成的充值本金记录。
+
+明细 `source_type=recharge_rebate_log`,`source_id` 为返现余额流水的内部 ID;`promoter_user_id`、`invitee_user_id` 使用会员表内部 `id`,不是业务 `member_id`。既有唯一键和源流水锁保证同一条返现不会被重复登记;已作废的同源佣金明细不会被回填重新激活。
+
+本修复使用已有 `share_commission_records` 表及其唯一键 `uniq_share_commission_source`,没有新增数据库迁移。部署前确认表结构与原运营配置迁移一致。
+
+## 历史回填
+
+先发布新代码,在连接正确业务数据库的后端目录执行预览。以下范围对应 2026-10-23 的默认最近 31 天:
+
+```bash
+php artisan share:backfill-commissions --start-date=2026-09-23 --end-date=2026-10-23
+```
+
+默认是只读预览,不写佣金明细。核对输出后,使用相同日期增加 `--write`:
+
+```bash
+php artisan share:backfill-commissions --start-date=2026-09-23 --end-date=2026-10-23 --write
+```
+
+命令只登记已有的正数「充值返现」流水,**不增加钱包余额、不修改原流水、不再次发奖**。可以重复执行,已登记记录会跳过。
+
+| 参数 | 含义 |
+|---|---|
+| `--start-date` | 返现流水入账起始日,格式 `Y-m-d`;默认结束日前 30 天 |
+| `--end-date` | 入账结束日;默认应用服务器当天 |
+| `--chunk` | 每批读取条数,默认 `500`,范围 `1`~`2000` |
+| `--write` | 实际登记;省略时仅预览 |
+
+单次日期范围不超过 366 天。历史更长时按月份或年度分批执行。默认范围与汇总接口一致,但统计的是返现流水日期,不是会员注册日期。
+
+## 输出与缺失数据
+
+| 输出项 | 含义 |
+|---|---|
+| `scanned` | 本次检查的正数返现流水数 |
+| `pending` | 预览中预计可新登记的条数 |
+| `created` | 实际新登记的条数 |
+| `existing` | 相同来源已登记,包含原记录已作废的情况 |
+| `skipped` | 无法安全登记的条数,后面显示原因 |
+| `incomplete_metadata` | 推广人及已付金额明确,但被邀请人或计算基数无法完整还原的条数 |
+
+历史入账日期和返现数额取原流水;计算基数从原格式「被邀请人:会员ID充值金额为:金额」的备注还原。历史备注只有两位小数时保留当时记载精度;历史比例由已付金额与该基数计算,不读取现在的返现比例,不按会员当前推荐关系逆推历史推广人。
+
+备注缺失或被邀请会员已不存在时,允许登记明确的已付佣金;无法还原的被邀请人留空,无法还原的基数、比例为 `0.0000`。这种 `deposit=0` 表示计算基数缺失,不代表本次又发放了佣金或重新计算历史充值。可还原的基数与新登记数值按明细表的四位小数精度记账。
+
+推广人无法确定、流水金额非正数或余额差额与金额不符时不登记。源数据问题通过 `skipped` 原因核对,不能将注册人数或充值金额直接当成已经支付的佣金。
+
+## 验证
+
+回填完成后重新调用:
+
+```text
+/admin/shareEarning/summary?start_date=2026-09-23&end_date=2026-10-23&account=&page=1&limit=10
+```
+
+成功码为 `0`,查看 `data.total`、`data.data` 及 `data.summary.total_commission`。如果命令 `scanned=0`,说明范围内没有已付返现可补;推荐会员有数据不等于已经产生佣金。
+
+`tests/Integration/ShareCommissionRecordingTest.php` 使用真实控制器、资金服务、登记服务和命令,连接隔离的 SQLite 内存库,覆盖新返现、接口汇总、原发放条件、故障回滚、历史日期、比例变更、缺失元数据、只读预览和重复回填;不会连接生产库或执行真实充值。
+
+```bash
+php -d error_reporting=24575 vendor/bin/phpunit --bootstrap tests/bootstrap-agent.php tests/Integration/ShareCommissionRecordingTest.php --do-not-cache-result
+```
+
+可以使用 `AGENT_TEST_AUTOLOAD` 指向独立依赖目录。本次未在线上执行回填、迁移或部署;真实 MySQL 并发及完整支付商回调需在相应隔离环境验证。

+ 3 - 1
docs/后端/运营配置-实现差异与未完成项.md

@@ -38,7 +38,9 @@
 
 ### 2. 分享佣金自动结算和入账
 
-已创建 `share_commission_records` 明细表并提供汇总读取接口,但没有根据截图示例自动生成资金佣金,也没有直接修改会员钱包。原因是截图未确定:
+已创建 `share_commission_records` 明细表并提供汇总读取接口。现已接入已有「充值返现」的明细登记:沿用 `config.recharge_rate` 和原发放条件,返现钱包、流水和明细在同一事务中提交;历史已付返现可用只登记、不再次入账的命令回填。操作见 [推广汇总明细登记与历史回填](分享赚钱-推广汇总明细登记与历史回填.md)。
+
+`share_settings` 中一级/二级分配及手续费减免的新资金规则仍未启用,不能把截图示例比例自动替换为真实发奖公式。以下规则仍待确定:
 
 - 一级/二级比例分别作用于充值、有效投注还是平台手续费;
 - “非卖家单笔阈值”“销售额”“手续费减免”的精确公式;

+ 335 - 0
tests/Integration/ShareCommissionRecordingTest.php

@@ -0,0 +1,335 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\BackfillShareCommissions;
+use App\Http\Controllers\admin\ShareEarning;
+use App\Models\BalanceLog;
+use App\Models\ShareCommissionRecord;
+use App\Services\BalanceLogService;
+use App\Services\ShareCommissionRecorder;
+use App\Services\ShareEarningReportService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/** Real ledger, report controller and command against an isolated in-memory database. */
+class ShareCommissionRecordingTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        $db->schema()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->string('account');
+            $t->string('first_name'); $t->string('user_code'); $t->string('agent_user_code')->nullable();
+            $t->integer('from')->default(1); $t->integer('level')->default(0);
+            $t->integer('recharge_channel_group_id')->default(1); $t->timestamps();
+        });
+        $db->schema()->create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique();
+            $t->decimal('available_balance', 24, 10)->default(0);
+            $t->decimal('frozen_balance', 24, 10)->default(0); $t->timestamps();
+        });
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(1);
+            $t->string('change_type'); $t->decimal('amount', 24, 10);
+            $t->decimal('before_balance', 24, 10); $t->decimal('after_balance', 24, 10);
+            $t->integer('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps();
+        });
+        $db->schema()->create('share_commission_records', function (Blueprint $t) {
+            $t->id(); $t->date('stat_date'); $t->integer('promoter_user_id');
+            $t->integer('invitee_user_id')->nullable(); $t->string('type'); $t->string('category');
+            $t->decimal('base_amount', 20, 4); $t->decimal('rate', 8, 4);
+            $t->decimal('amount', 20, 4); $t->string('source_type'); $t->string('source_id');
+            $t->integer('status'); $t->timestamps();
+            $t->unique(['source_type', 'source_id', 'type', 'promoter_user_id'], 'uniq_share_source');
+        });
+        $db->schema()->create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->string('val'); $t->timestamps();
+        });
+        $db->schema()->create('level', function (Blueprint $t) {
+            $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); $t->timestamps();
+        });
+        $db->schema()->create('recharge_channel_group', function (Blueprint $t) {
+            $t->id(); $t->text('activity_type')->nullable(); $t->softDeletes(); $t->timestamps();
+        });
+        $db->schema()->create('backflow', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('date');
+            foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) {
+                $t->decimal($field, 20, 2)->default(0);
+            }
+            $t->timestamps();
+        });
+        DB::table('config')->insert([
+            ['field' => 'recharge_rate', 'val' => '2.5000'],
+            ['field' => 'huishui_percentage', 'val' => '0'],
+            ['field' => 'huishui_restriction', 'val' => '999999'],
+        ]);
+        DB::table('level')->insert(['level' => 0, 'recharge' => 0]);
+        DB::table('recharge_channel_group')->insert(['id' => 1, 'activity_type' => '']);
+        DB::table('backflow')->insert(['member_id' => '1002', 'date' => date('Y-m')]);
+        DB::table('users')->insert([
+            ['id' => 7, 'member_id' => '1001', 'account' => 'promoter', 'first_name' => '推广人', 'user_code' => 'PROMO', 'agent_user_code' => null],
+            ['id' => 42, 'member_id' => '1002', 'account' => 'invitee', 'first_name' => '被邀请人', 'user_code' => 'CHILD', 'agent_user_code' => 'PROMO'],
+        ]);
+        DB::table('wallets')->insert([
+            ['member_id' => '1001', 'available_balance' => '20.00'],
+            ['member_id' => '1002', 'available_balance' => '100.00'],
+        ]);
+        $this->app->instance('request', Request::create('/admin/shareEarning/summary', 'GET', []));
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    public static function depositTypes(): array
+    {
+        return [['充值'], ['三方充值']];
+    }
+
+    /** @dataProvider depositTypes */
+    public function test_existing_rebate_payment_registers_real_commission_and_is_visible_in_summary(string $type): void
+    {
+        BalanceLogService::addLog('1002', '100.00', '0.00', '100.00', $type, 88, '');
+        $rebate = BalanceLog::where('change_type', '充值返现')->firstOrFail();
+        $record = ShareCommissionRecord::firstOrFail();
+        $this->assertSame(7, (int)$record->promoter_user_id);
+        $this->assertSame(42, (int)$record->invitee_user_id);
+        $this->assertSame('100.0000', $record->base_amount);
+        $this->assertSame('2.5000', $record->rate);
+        $this->assertSame('2.5000', $record->amount);
+        $this->assertSame(ShareCommissionRecorder::SOURCE_TYPE, $record->source_type);
+        $this->assertSame((string)$rebate->id, $record->source_id);
+        $this->assertEquals('22.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+
+        $body = (new ShareEarning())->summary(new ShareEarningReportService())->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertSame(1, $body['data']['total']);
+        $this->assertSame('promoter', $body['data']['data'][0]['promoter_account']);
+        $this->assertSame('100.0000', $body['data']['data'][0]['deposit']);
+        $this->assertSame('2.5000', $body['data']['summary']['total_commission']);
+    }
+
+    public function test_live_record_keeps_original_rate_and_base_precision(): void
+    {
+        BalanceLogService::addLog('1002', '100.1256789012', '0.00', '100.1256789012', '充值', 88, '');
+        $record = ShareCommissionRecord::firstOrFail();
+        $this->assertSame('100.1257', $record->base_amount);
+        $this->assertSame('2.5000', $record->rate);
+        $this->assertSame('2.5000', $record->amount);
+    }
+
+    public static function unrelatedDeposits(): array
+    {
+        return [['人工充值', 'PROMO', 1], ['优惠活动', 'PROMO', 1], ['充值', '', 1], ['充值', 'MISSING', 1], ['充值', 'PROMO', 2]];
+    }
+
+    /** @dataProvider unrelatedDeposits */
+    public function test_recording_does_not_expand_the_existing_payment_rules(string $type, string $code, int $from): void
+    {
+        DB::table('users')->where('member_id', '1002')->update(['agent_user_code' => $code, 'from' => $from]);
+        BalanceLogService::addLog('1002', '100.00', '0.00', '100.00', $type, 88, '');
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, BalanceLog::where('change_type', '充值返现')->count());
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+    }
+
+    public function test_commission_insert_failure_rolls_back_the_rebate_wallet_and_ledger(): void
+    {
+        DB::statement("CREATE TRIGGER fail_share_insert BEFORE INSERT ON bot_share_commission_records
+            BEGIN SELECT RAISE(ABORT, 'injected share failure'); END");
+        try {
+            BalanceLogService::addLog('1002', '100.00', '0.00', '100.00', '充值', 88, '');
+            $this->fail('Commission failure must abort the rebate payment.');
+        } catch (\Illuminate\Database\QueryException $e) {
+            $this->assertStringContainsString('injected share failure', $e->getMessage());
+        }
+        $this->assertSame(0, DB::transactionLevel());
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(0, BalanceLog::where('change_type', '充值返现')->count());
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(1, BalanceLog::where('change_type', '充值')->count());
+    }
+
+    public function test_live_record_respects_the_callers_transaction_rollback(): void
+    {
+        DB::beginTransaction();
+        BalanceLogService::addLog('1002', '100.00', '0.00', '100.00', '三方充值', 88, '');
+        $this->assertSame(1, DB::transactionLevel());
+        $this->assertSame(1, ShareCommissionRecord::count());
+        DB::rollBack();
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertSame(0, ShareCommissionRecord::count());
+    }
+
+    private function historicalRebate(array $extra = []): BalanceLog
+    {
+        DB::table('wallets')->where('member_id', '1001')->update(['available_balance' => '22.50']);
+        $log = new BalanceLog();
+        $log->forceFill($extra + [
+            'member_id' => '1001', 'type' => 1, 'change_type' => '充值返现', 'related_id' => 88,
+            'amount' => '2.50', 'before_balance' => '20.00', 'after_balance' => '22.50',
+            'remark' => '被邀请人:1002充值金额为:100.00',
+            'created_at' => '2026-10-01 01:02:03', 'updated_at' => '2026-10-01 01:02:03',
+        ])->save();
+        return $log;
+    }
+
+    public function test_historical_registration_is_idempotent_and_never_credits_again(): void
+    {
+        $rebate = $this->historicalRebate();
+        DB::table('config')->where('field', 'recharge_rate')->update(['val' => '99']);
+        DB::table('users')->where('member_id', '1002')->update(['agent_user_code' => 'CHANGED']);
+        $wallets = DB::table('wallets')->orderBy('id')->get()->toJson();
+        $ledger = DB::table('balance_logs')->orderBy('id')->get()->toJson();
+        $recorder = new ShareCommissionRecorder();
+        $this->assertSame('pending', $recorder->preview($rebate)['status']);
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame('created', $recorder->record($rebate)['status']);
+        $this->assertSame('existing', $recorder->record($rebate)['status']);
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $record = ShareCommissionRecord::firstOrFail();
+        $this->assertSame('2026-10-01', $record->stat_date->format('Y-m-d'));
+        $this->assertSame('2.5000', $record->rate);
+        $this->assertSame('100.0000', $record->base_amount);
+        $this->assertSame(42, (int)$record->invitee_user_id);
+        $this->assertSame($wallets, DB::table('wallets')->orderBy('id')->get()->toJson());
+        $this->assertSame($ledger, DB::table('balance_logs')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_unknown_historical_details_keep_actual_payment_without_inventing_a_base(): void
+    {
+        $rebate = $this->historicalRebate(['remark' => '历史返现']);
+        $result = (new ShareCommissionRecorder())->record($rebate);
+        $this->assertSame('created', $result['status']);
+        $this->assertTrue($result['incomplete_metadata']);
+        $record = ShareCommissionRecord::firstOrFail();
+        $this->assertNull($record->invitee_user_id);
+        $this->assertSame('0.0000', $record->base_amount);
+        $this->assertSame('0.0000', $record->rate);
+        $this->assertSame('2.5000', $record->amount);
+    }
+
+    public function test_historical_balance_comparison_uses_delta_before_rounding(): void
+    {
+        $rebate = $this->historicalRebate(['before_balance' => '20.0000499999', 'after_balance' => '22.5000500001']);
+        $result = (new ShareCommissionRecorder())->record($rebate);
+        $this->assertSame('created', $result['status']);
+        $this->assertSame('2.5000', ShareCommissionRecord::firstOrFail()->amount);
+    }
+
+    public function test_same_payout_source_is_not_duplicated_when_user_rows_are_replaced(): void
+    {
+        $rebate = $this->historicalRebate();
+        $recorder = new ShareCommissionRecorder();
+        $recorder->record($rebate);
+        DB::table('users')->where('id', 7)->delete();
+        DB::table('users')->insert(['id' => 9, 'member_id' => '1001', 'account' => 'reimported', 'first_name' => '重建账户', 'user_code' => 'PROMO']);
+        $this->assertSame('existing', $recorder->record($rebate)['status']);
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $this->assertSame(7, (int)ShareCommissionRecord::firstOrFail()->promoter_user_id);
+    }
+
+    public static function invalidHistoricalLogs(): array
+    {
+        return [
+            [['amount' => '0.00', 'after_balance' => '20.00'], 'invalid_amount'],
+            [['after_balance' => '20.00'], 'unbalanced_log'],
+            [['member_id' => '9999'], 'missing_promoter'],
+            [['type' => 2], 'not_recharge_rebate'],
+            [['change_type' => '优惠活动'], 'not_recharge_rebate'],
+        ];
+    }
+
+    /** @dataProvider invalidHistoricalLogs */
+    public function test_invalid_historical_logs_are_reported_instead_of_booked(array $extra, string $reason): void
+    {
+        $rebate = $this->historicalRebate($extra);
+        $result = (new ShareCommissionRecorder())->record($rebate);
+        $this->assertSame('skipped', $result['status']);
+        $this->assertSame($reason, $result['reason']);
+        $this->assertSame(0, ShareCommissionRecord::count());
+    }
+
+    public function test_backfill_does_not_reactivate_a_manually_voided_record(): void
+    {
+        $rebate = $this->historicalRebate();
+        $recorder = new ShareCommissionRecorder();
+        $recorder->record($rebate);
+        ShareCommissionRecord::query()->update(['status' => 0]);
+        $this->assertSame('existing', $recorder->record($rebate)['status']);
+        $this->assertSame(0, ShareCommissionRecord::firstOrFail()->status);
+    }
+
+    private function command(array $options): CommandTester
+    {
+        $command = new BackfillShareCommissions();
+        $command->setLaravel($this->app);
+        $tester = new CommandTester($command);
+        $tester->execute($options);
+        return $tester;
+    }
+
+    public function test_backfill_command_defaults_to_preview_and_write_is_repeatable(): void
+    {
+        $this->historicalRebate();
+        $options = ['--start-date' => '2026-10-01', '--end-date' => '2026-10-02', '--chunk' => 1];
+        $wallets = DB::table('wallets')->orderBy('id')->get()->toJson();
+        $preview = $this->command($options);
+        $this->assertSame(0, $preview->getStatusCode());
+        $this->assertStringContainsString('只读预览', $preview->getDisplay());
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $written = $this->command($options + ['--write' => true]);
+        $this->assertSame(0, $written->getStatusCode(), $written->getDisplay());
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $again = $this->command($options + ['--write' => true]);
+        $this->assertSame(0, $again->getStatusCode());
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $this->assertSame($wallets, DB::table('wallets')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_backfill_command_rejects_invalid_ranges_without_writing(): void
+    {
+        $this->historicalRebate();
+        $result = $this->command(['--start-date' => '2026-10-03', '--end-date' => '2026-10-01', '--write' => true]);
+        $this->assertSame(1, $result->getStatusCode());
+        $this->assertSame(0, ShareCommissionRecord::count());
+    }
+}