doge 1 день назад
Родитель
Сommit
05e30b3edb

+ 150 - 0
tests/Integration/AdminOrderRefundTest.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\JisuGameOrder;
+use App\Http\Controllers\admin\LhcOrder;
+use App\Http\Controllers\admin\Order;
+use App\Http\Controllers\admin\SportGameOrder;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+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\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class AdminOrderRefundTest extends TestCase
+{
+    private Application $app;
+    private const CONTROLLERS = ['sport' => Order::class, 'game' => SportGameOrder::class, 'lottery' => LhcOrder::class, 'jisu' => JisuGameOrder::class];
+    private const TABLES = ['sport' => 'sport_order', 'game' => 'sport_game_order', 'lottery' => 'lhc_order', 'jisu' => 'jisu_game_order'];
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances(); Model::clearBootedModels(); Model::unsetEventDispatcher();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $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 \Tests\Support\JsonResponseFactory());
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        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()); $this->app->bind('db.schema', fn () => $db->schema());
+        $db->schema()->create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 2); $t->timestamps(); });
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('amount', 24, 2); $t->decimal('before_balance', 24, 2); $t->decimal('after_balance', 24, 2);
+            $t->string('change_type'); $t->string('remark'); $t->integer('related_id')->nullable(); $t->timestamps();
+        });
+        foreach (self::TABLES as $kind => $table) $db->schema()->create($table, function (Blueprint $t) use ($kind) {
+            $t->id(); $t->string($kind === 'sport' ? 'user_id' : 'member_id'); $t->decimal('amount', 24, 2);
+            if ($kind === 'lottery') { $t->string('ordernum'); $t->decimal('total_amount', 24, 2)->nullable(); $t->integer('lottery_status')->default(0); }
+            else $t->integer('status')->default($kind === 'sport' ? 1 : 0);
+            $t->integer('type')->default(1);
+            if ($kind === 'sport') { $t->integer('pay_status')->default(1); $t->integer('return_status')->default(0); $t->integer('settlement_status')->default(0); $t->integer('return_operation_time')->nullable(); $t->integer('create_time')->nullable(); $t->integer('update_time')->nullable(); }
+            else $t->timestamps();
+        });
+        $db->schema()->create('lhc_order_item', function (Blueprint $t) { $t->id(); $t->string('ordernum'); $t->timestamps(); });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_120000_add_promotion_coupons.php')->up();
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50']);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Model::clearBootedModels(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    public static function kinds(): array { return array_map(fn ($kind) => [$kind], array_keys(self::TABLES)); }
+
+    private function seed(string $kind, int $id = 1, bool $coupon = true, bool $missingTotal = false): string
+    {
+        $coupon = $coupon && $kind !== 'jisu';
+        $row = ['id' => $id, $kind === 'sport' ? 'user_id' : 'member_id' => '1001', 'amount' => '100'];
+        if ($kind === 'lottery') {
+            $row += ['ordernum' => 'slip-' . $id, 'total_amount' => $missingTotal ? null : '300'];
+            foreach ([1, 2, 3] as $number) DB::table('lhc_order_item')->insert(['ordernum' => $row['ordernum']]);
+        }
+        if ($coupon) $row += ['coupon_id' => $id, 'coupon_discount' => '50'];
+        DB::table(self::TABLES[$kind])->insert($row);
+        if ($coupon) {
+            DB::table('promotion_coupons')->insert(['id' => $id, 'activity_id' => 1, 'reward_id' => $id, 'user_id' => 1, 'member_id' => '1001',
+                'role' => 'inviter', 'inviter_user_id' => 1, 'seq' => 1, 'min_amount' => '100', 'discount' => '50', 'categories' => '["self:sport","self:lottery"]',
+                'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2099-12-31 23:59:59', 'status' => 'used']);
+            DB::table('promotion_coupon_uses')->insert(['coupon_id' => $id, 'source' => self::TABLES[$kind], 'order_id' => $id, 'discount' => '50']);
+        }
+        return $kind === 'lottery' ? ($coupon ? '250.00' : '300.00') : ($coupon ? '50.00' : '100.00');
+    }
+
+    private function refund(string $kind, array $ids = [1]): array
+    {
+        $this->app->instance('request', Request::create('/admin/refund', 'POST', ['id' => $ids]));
+        return (new (self::CONTROLLERS[$kind])())->refund()->getData(true);
+    }
+
+    private function snapshot(): array
+    {
+        $result = [];
+        foreach (['wallets', 'balance_logs', 'promotion_coupons', 'promotion_coupon_uses', ...array_values(self::TABLES)] as $table) $result[$table] = DB::table($table)->orderBy('id')->get()->toJson();
+        return $result;
+    }
+
+    #[DataProvider('kinds')]
+    public function test_refund_credits_paid_amount_returns_coupon_and_retries_without_duplicates(string $kind): void
+    {
+        $refund = $this->seed($kind);
+        $this->assertSame(0, $this->refund($kind)['code']);
+        $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
+        $log = DB::table('balance_logs')->first();
+        $this->assertSame($refund, bcadd((string)$log->amount, '0', 2));
+        $this->assertSame(1, (int)$log->related_id);
+        $this->assertSame('50.00', bcadd((string)$log->before_balance, '0', 2));
+        $this->assertSame(bcadd('50', $refund, 2), bcadd((string)$log->after_balance, '0', 2));
+        if ($kind !== 'jisu') $this->assertSame('unused', DB::table('promotion_coupons')->value('status'));
+        $before = $this->snapshot();
+        $this->assertSame(0, $this->refund($kind)['code']);
+        $this->assertSame($before, $this->snapshot());
+    }
+
+    #[DataProvider('kinds')]
+    public function test_refund_without_coupon_credits_the_complete_order(string $kind): void
+    {
+        $refund = $this->seed($kind, coupon: false);
+        $this->assertSame(0, $this->refund($kind)['code']);
+        $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
+    }
+
+    #[DataProvider('kinds')]
+    public function test_missing_wallet_rolls_back_terminal_state_and_coupon(string $kind): void
+    {
+        $this->seed($kind); DB::table('wallets')->delete(); $before = $this->snapshot();
+        $this->assertSame(-3, $this->refund($kind)['code']);
+        $this->assertSame($before, $this->snapshot());
+    }
+
+    #[DataProvider('kinds')]
+    public function test_later_ledger_failure_rolls_back_the_entire_refund_batch(string $kind): void
+    {
+        $this->seed($kind); $this->seed($kind, 2); $before = $this->snapshot();
+        DB::statement("CREATE TRIGGER reject_admin_refund BEFORE INSERT ON bot_balance_logs WHEN NEW.related_id = 2 BEGIN SELECT RAISE(ABORT, 'fixture ledger failure'); END");
+        $this->assertSame(-3, $this->refund($kind, [1, 2])['code']);
+        $this->assertSame($before, $this->snapshot());
+    }
+
+    public function test_legacy_lottery_missing_total_is_derived_from_its_numbers(): void
+    {
+        $this->seed('lottery', coupon: false, missingTotal: true);
+        $this->assertSame(0, $this->refund('lottery')['code']);
+        $this->assertSame('350.00', bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
+    }
+}

+ 103 - 0
tests/Regression/coupon_refund_mysql.php

@@ -0,0 +1,103 @@
+<?php
+
+// Opt-in: use a private, network-disabled MySQL instance. Never loads either project's .env.
+$socket = getenv('COUPON_TEST_MYSQL_SOCKET');
+if (!$socket) { echo "SKIP: set COUPON_TEST_MYSQL_SOCKET to an isolated test server\n"; exit(0); }
+if (!preg_match('#^/tmp/b28coupon-fix-[a-z0-9]+\.sock$#D', $socket)) throw new RuntimeException('Use an isolated /tmp/b28coupon-fix-*.sock.');
+$root = dirname(__DIR__, 2);
+$memberRoot = getenv('COUPON_MEMBER_ROOT') ?: dirname($root) . '/melbet_sport-api';
+$family = $argv[1] ?? 'parent';
+$dir = $argv[2] ?? sys_get_temp_dir() . '/coupon-refund-barriers-' . bin2hex(random_bytes(5));
+$number = (int)($argv[3] ?? 0);
+if ($family === 'parent') {
+    mkdir($dir, 0700, true);
+    $pdo = new PDO('mysql:unix_socket=' . $socket, 'root', '');
+    $pdo->exec('CREATE DATABASE IF NOT EXISTS coupon_refund_regression');
+}
+if ($family !== 'member') {
+    $loader = require $root . '/vendor/autoload.php';
+    if ($source = getenv('COUPON_BOT_SOURCE')) $loader->addClassMap(['App\Services\PromotionCouponService' => $source . '/app/Services/PromotionCouponService.php']);
+    $app = new Illuminate\Foundation\Application($root);
+    $app->instance('config', new Illuminate\Config\Repository());
+    $app->instance('log', new Psr\Log\NullLogger());
+    Illuminate\Support\Facades\Facade::setFacadeApplication($app);
+    $db = new Illuminate\Database\Capsule\Manager($app);
+    $db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '', 'prefix' => 'bot_']);
+    $db->setAsGlobal(); $db->bootEloquent();
+    $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema());
+    $DB = Illuminate\Support\Facades\DB::class;
+    $DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+    $DB::statement('SET SESSION innodb_lock_wait_timeout = 8');
+} else {
+    $loader = require $memberRoot . '/vendor/autoload.php';
+    if ($source = getenv('COUPON_MEMBER_SOURCE')) $loader->addClassMap(['app\common\service\coupon\CouponService' => $source . '/app/common/service/coupon/CouponService.php']);
+    $app = new think\App($memberRoot . '/'); think\Container::setInstance($app);
+    $db = new think\DbManager();
+    $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
+        'type' => 'mysql', 'hostname' => 'localhost', 'database' => 'coupon_refund_regression', 'username' => 'root', 'password' => '',
+        'dsn' => 'mysql:unix_socket=' . $socket . ';dbname=coupon_refund_regression;charset=utf8mb4', 'prefix' => 'bot_',
+    ]]]);
+    $app->instance(think\DbManager::class, $db); $app->instance('log', new Psr\Log\NullLogger());
+    $DB = think\facade\Db::class;
+    $DB::execute('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
+    $DB::execute('SET SESSION innodb_lock_wait_timeout = 8');
+}
+
+function awaitFile(string $file): void {
+    $deadline = microtime(true) + 10;
+    while (!file_exists($file)) {
+        if (microtime(true) > $deadline) throw new RuntimeException('Timeout: ' . basename($file));
+        usleep(10000); clearstatcache(true, $file);
+    }
+}
+function same($actual, $expected, string $message): void {
+    if ($actual !== $expected) throw new RuntimeException($message . ': ' . json_encode($actual));
+}
+if ($family !== 'parent') {
+    if ($family === 'bot') {
+        $DB::beginTransaction();
+    } else {
+        $DB::startTrans();
+    }
+    $refund = function () use ($family, $number) {
+        if ($family === 'bot') App\Services\PromotionCouponService::release('sport_order', $number);
+        else app\common\service\coupon\CouponService::release('sport_order', $number);
+    };
+    if ($number === 1) $refund(); // Leave this real refund uncommitted while the second transaction starts.
+    elseif ($family === 'bot') $DB::table('promotion_coupon_uses')->count();
+    else $DB::table('bot_promotion_coupon_uses')->count(); // Old RR view includes the first use as not refunded.
+    touch($dir . '/' . $number . '.ready');
+    if ($number === 1) awaitFile($dir . '/release');
+    else $refund();
+    $DB::commit(); touch($dir . '/' . $number . '.done');
+    exit(0);
+}
+
+foreach ([['bot', 'bot'], ['member', 'member'], ['bot', 'member'], ['member', 'bot']] as [$first, $second]) {
+    $barrier = $dir . '/' . $first . '-' . $second; mkdir($barrier, 0700);
+    $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupon_uses');
+    $DB::statement('DROP TABLE IF EXISTS bot_promotion_coupons');
+    $DB::statement('CREATE TABLE bot_promotion_coupons (id BIGINT PRIMARY KEY,status VARCHAR(16),used_source VARCHAR(32),used_stake DECIMAL(24,2),used_at DATETIME,updated_at DATETIME) ENGINE=InnoDB');
+    $DB::statement('CREATE TABLE bot_promotion_coupon_uses (id BIGINT PRIMARY KEY,coupon_id BIGINT,source VARCHAR(32),order_id BIGINT,refunded_at DATETIME,updated_at DATETIME,UNIQUE(source,order_id),INDEX(coupon_id)) ENGINE=InnoDB');
+    $DB::table('promotion_coupons')->insert(['id' => 1, 'status' => 'used']);
+    foreach ([1, 2] as $id) $DB::table('promotion_coupon_uses')->insert(['id' => $id, 'coupon_id' => 1, 'source' => 'sport_order', 'order_id' => $id]);
+    $start = function ($family, $number) use ($barrier) {
+        $process = proc_open([PHP_BINARY, __FILE__, $family, $barrier, (string)$number], [
+            0 => ['pipe', 'r'], 1 => ['file', $barrier . '/' . $number . '.log', 'a'], 2 => ['file', $barrier . '/' . $number . '.log', 'a'],
+        ], $pipes);
+        if (!is_resource($process)) throw new RuntimeException('Cannot start worker');
+        fclose($pipes[0]); return $process;
+    };
+    $workers = [];
+    try {
+        $workers[] = $start($first, 1); awaitFile($barrier . '/1.ready');
+        $workers[] = $start($second, 2); awaitFile($barrier . '/2.ready');
+        usleep(100000); // Both transactions overlap; only the final business state is asserted.
+    } finally {
+        touch($barrier . '/release');
+        foreach ($workers as $worker) same(proc_close($worker), 0, 'Worker failed; see ' . $barrier);
+    }
+    same($DB::table('promotion_coupon_uses')->whereNull('refunded_at')->count(), 0, 'Both uses are refunded');
+    same($DB::table('promotion_coupons')->value('status'), 'unused', 'The last refund restores the coupon despite an old RR snapshot');
+    echo "PASS {$first}/{$second} concurrent coupon refunds\n";
+}

+ 56 - 0
tests/Regression/coupon_refund_regression.php

@@ -0,0 +1,56 @@
+<?php
+// Isolated SQLite: execute real refunds/coupon writes, replacing only notification dispatch and the ledger sink.
+namespace App\Jobs { class SendTelegramMessageJob { public static int $sent = 0; public static function dispatch(...$args) { ++self::$sent; } } }
+namespace App\Services { class BalanceLogService { public static array $logs = []; public static function addLog(...$args) { self::$logs[] = $args; } } }
+namespace {
+    require dirname(__DIR__, 2) . '/vendor/autoload.php';
+    $app=new \Illuminate\Foundation\Application(dirname(__DIR__, 2));
+    $app->instance('config',new \Illuminate\Config\Repository(['admin_realtime'=>['enabled'=>false]]));
+    $app->instance('log',new \Psr\Log\NullLogger());
+    \Illuminate\Support\Facades\Facade::setFacadeApplication($app);
+    $db=new \Illuminate\Database\Capsule\Manager($app);
+    $db->addConnection(['driver'=>'sqlite','database'=>':memory:','prefix'=>'bot_']); $db->setAsGlobal();$db->bootEloquent();
+    $app->instance('db',$db->getDatabaseManager());$app->bind('db.schema',fn()=>$db->schema());
+    $sql=[
+        'CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,created_at TEXT,updated_at TEXT)',
+        'CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,issue TEXT,type INTEGER,lottery_status INTEGER,amount TEXT,total_amount TEXT,coupon_discount TEXT,coupon_id INTEGER,ordernum TEXT,number TEXT,created_at INTEGER,updated_at INTEGER)',
+        'CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,ordernum TEXT,lottery_status INTEGER,updated_at INTEGER)',
+        'CREATE TABLE bot_promotion_coupons (id INTEGER PRIMARY KEY,status TEXT,used_source TEXT,used_stake TEXT,used_at TEXT,updated_at TEXT)',
+        'CREATE TABLE bot_promotion_coupon_uses (id INTEGER PRIMARY KEY,coupon_id INTEGER,source TEXT,order_id INTEGER,discount TEXT,refunded_at TEXT,updated_at TEXT)',
+    ];
+    foreach($sql as $q) \Illuminate\Support\Facades\DB::statement($q);
+    $DB=\Illuminate\Support\Facades\DB::class;
+    $DB::table('wallets')->insert(['id'=>1,'member_id'=>'1001','available_balance'=>'0']);
+    $DB::table('promotion_coupons')->insert(['id'=>1,'status'=>'used']);
+    foreach([1=>['1','0'],2=>['10000','1']] as $id=>[$amount,$discount]) {
+        $DB::table('lhc_order')->insert(['id'=>$id,'member_id'=>'1001','issue'=>'issue','type'=>5,'lottery_status'=>0,'amount'=>$amount,'total_amount'=>$id===1?'2':$amount,'coupon_discount'=>$discount,'coupon_id'=>1,'ordernum'=>'order'.$id,'created_at'=>time(),'updated_at'=>time()]);
+        $DB::table('lhc_order_item')->insert(['id'=>$id,'ordernum'=>'order'.$id,'lottery_status'=>0]);
+        $DB::table('promotion_coupon_uses')->insert(['id'=>$id,'coupon_id'=>1,'source'=>'lhc_order','order_id'=>$id,'discount'=>$discount]);
+    }
+    $DB::table('lhc_order_item')->insert(['id'=>3,'ordernum'=>'order1','lottery_status'=>0]);
+    \App\Services\BetService::betFail('issue');
+    $checks = 0;
+    function same($actual, $expected, string $message): void {
+        global $checks; ++$checks;
+        if ($actual !== $expected) throw new \RuntimeException($message . ': ' . json_encode($actual));
+    }
+    same(bcadd((string)$DB::table('wallets')->value('available_balance'), '0', 2), '10001.00', 'All paid stakes, including zero-discount groups, are refunded');
+    same($DB::table('promotion_coupons')->value('status'), 'unused', 'Coupon returns after all orders are refunded');
+    same($DB::table('promotion_coupon_uses')->whereNull('refunded_at')->count(), 0, 'Zero-discount use is also refunded');
+    same($DB::table('lhc_order')->orderBy('id')->pluck('lottery_status')->all(), [1, 1], 'Existing failed-draw terminal states are preserved');
+    same(count(\App\Services\BalanceLogService::$logs), 2, 'One ledger entry per order');
+    same(bcadd((string)\App\Services\BalanceLogService::$logs[0][1], '0', 2), '2.00', 'First ledger uses the complete paid group stake');
+    same(\App\Jobs\SendTelegramMessageJob::$sent, 2, 'Notifications are stubbed, never sent externally');
+
+    $DB::table('promotion_coupons')->update(['status' => 'used', 'used_source' => 'sport_order', 'used_stake' => '100']);
+    $DB::table('promotion_coupon_uses')->insert(['id' => 3, 'coupon_id' => 1, 'source' => 'sport_order', 'order_id' => 9, 'discount' => '1']);
+    \App\Services\PromotionCouponService::release('lhc_order', 1);
+    same($DB::table('promotion_coupons')->value('status'), 'used', 'Retrying an old refund never restores a reused coupon');
+    $DB::beginTransaction();
+    \App\Services\PromotionCouponService::release('sport_order', 9);
+    same($DB::table('promotion_coupons')->value('status'), 'unused', 'Nested release completes inside refund transaction');
+    $DB::rollBack();
+    same($DB::table('promotion_coupons')->value('status'), 'used', 'Outer rollback restores coupon state');
+    same($DB::table('promotion_coupon_uses')->where('id', 3)->value('refunded_at'), null, 'Outer rollback restores use state');
+    echo "PASS {$checks} coupon failed-draw refund checks\n";
+}