doge 15 hours ago
parent
commit
6121c6986f
30 changed files with 1487 additions and 122 deletions
  1. 42 0
      app/Console/Commands/ImportPaymentProviderConfig.php
  2. 5 2
      app/Console/Commands/SyncPromotions.php
  3. 22 0
      app/Http/Controllers/admin/PaymentCompany.php
  4. 3 3
      app/Models/PaymentProvider.php
  5. 31 24
      app/Services/Payment/JdPayService.php
  6. 21 16
      app/Services/Payment/NoPayService.php
  7. 267 0
      app/Services/Payment/PaymentProviderConfig.php
  8. 6 1
      app/Services/Payment/PaymentProviderService.php
  9. 28 22
      app/Services/Payment/QianBaoService.php
  10. 15 13
      app/Services/Payment/SanJinService.php
  11. 37 29
      app/Services/Payment/ZimuPayService.php
  12. 18 0
      app/Services/PromotionAdminService.php
  13. 4 2
      app/Support/ActivityCatalog.php
  14. 22 0
      database/migrations/2026_11_01_120000_add_config_to_payment_providers.php
  15. 19 0
      database/migrations/2026_11_02_120000_merge_activity_lottery_category.php
  16. 127 0
      docs/前端接口/支付配置/支付公司商户配置-前端接入文档.md
  17. 36 0
      docs/前端接口/活动/活动彩票分类合并-前端变更说明.md
  18. 82 0
      docs/后端/支付公司商户配置-部署说明.md
  19. 35 0
      docs/后端/活动彩票分类合并-部署说明.md
  20. 2 0
      routes/admin.php
  21. 36 6
      tests/Integration/ActivityCatalogTest.php
  22. 144 0
      tests/Integration/PaymentProviderConfigImportTest.php
  23. 173 0
      tests/Integration/PaymentProviderConfigTest.php
  24. 84 0
      tests/Integration/PaymentProviderSnapshotTest.php
  25. 15 0
      tests/Integration/PromotionActivitiesTest.php
  26. 6 2
      tests/Integration/RechargeBonusTest.php
  27. 113 0
      tests/Regression/payment_provider_config_concurrency.php
  28. 77 0
      tests/Support/PaymentProviderTestCase.php
  29. 15 0
      tests/Support/RecordingProviderClient.php
  30. 2 2
      tests/Unit/NoPayServiceTest.php

+ 42 - 0
app/Console/Commands/ImportPaymentProviderConfig.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderConfig;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class ImportPaymentProviderConfig extends Command
+{
+    protected $signature = 'payment:config-import-env
+        {--company=* : 支付公司代码,可重复指定;省略则全部}
+        {--write : 实际导入数据库;默认仅预览}';
+
+    protected $description = '将当前生效的支付环境配置补入后台,保留已有后台值,不输出密钥明文';
+
+    public function handle(PaymentProviderConfig $config): int
+    {
+        $codes = $this->option('company');
+        if (array_diff($codes, PaymentProviderCatalog::codes()) !== []) {
+            $this->error('支付公司代码无效,可选:' . implode(', ', PaymentProviderCatalog::codes()));
+            return self::FAILURE;
+        }
+        $write = (bool)$this->option('write');
+        $this->line('读取当前生效的 Laravel 配置及默认网关;若刚修改 .env,请先执行 php artisan config:cache。');
+        try {
+            $report = $config->importDefaults($codes, $write);
+        } catch (Throwable $e) {
+            Log::error('payment_provider_config_import_failed', ['exception' => $e]);
+            $this->error('导入失败,请检查数据库迁移、数据库连接及 APP_KEY;详情见日志。');
+            return self::FAILURE;
+        }
+        $this->table(['支付公司', $write ? '已导入字段' : '待导入字段', '保留后台字段', '未配置字段'],
+            array_map(fn ($row) => [$row['payment_company'] . ' / ' . $row['label'],
+                implode(', ', $row['imported']) ?: '-', implode(', ', $row['preserved']) ?: '-', implode(', ', $row['empty']) ?: '-'], $report));
+        $count = array_sum(array_map(fn ($row) => count($row['imported']), $report));
+        $this->info($write ? "已导入 {$count} 个字段;已有后台值保留,密钥加密存储。" : "预览:将导入 {$count} 个字段,未写入数据库;添加 --write 执行。");
+        return self::SUCCESS;
+    }
+}

+ 5 - 2
app/Console/Commands/SyncPromotions.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Services\PromotionAdminService;
 use App\Services\PromotionService;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
@@ -11,11 +12,13 @@ use Illuminate\Support\Facades\Log;
 class SyncPromotions extends Command
 {
     protected $signature = 'promotions:sync';
-    protected $description = '补记转盘任务次数并解冻满足流水的新活动奖金';
+    protected $description = '到期活动自动下架,补记转盘任务次数并解冻满足流水的新活动奖金';
     public function handle(PromotionService $service): int
     {
         if (!Schema::hasTable('promotion_accounts')) return self::SUCCESS;
         $errors = 0;
+        try { $expired = PromotionAdminService::expire(); }
+        catch (\Throwable $e) { $expired = 0; ++$errors; Log::error('promotion_expire_failed', ['exception' => $e]); }
         DB::table('promotion_accounts as p')->join('promotion_activities as a', 'a.id', '=', 'p.activity_id')->where('a.type', 9)->where('a.status', 1)
             ->where('a.start_time', '<=', now()->timestamp)->where('a.end_time', '>=', now()->timestamp)->select('p.*')->orderBy('p.id')
             ->chunkById(100, function ($rows) use ($service, &$errors) {
@@ -25,7 +28,7 @@ class SyncPromotions extends Command
         foreach (DB::table('promotion_rewards')->where('status', 'locked')->distinct()->pluck('user_id') as $userId) {
             try { $service->unlock((int)$userId); } catch (\Throwable $e) { ++$errors; Log::error('promotion_unlock_failed', ['user_id' => $userId, 'exception' => $e]); }
         }
-        $this->info('活动任务和奖金同步完成,失败数:' . $errors);
+        $this->info('活动任务和奖金同步完成,到期下架:' . $expired . ',失败数:' . $errors);
         return $errors ? self::FAILURE : self::SUCCESS;
     }
 }

+ 22 - 0
app/Http/Controllers/admin/PaymentCompany.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\Payment\PaymentProviderCatalog;
+use App\Services\Payment\PaymentProviderConfig;
 use App\Services\Payment\PaymentProviderService;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Validation\Rule;
@@ -36,6 +37,27 @@ class PaymentCompany extends Controller
         });
     }
 
+    public function config(PaymentProviderConfig $config)
+    {
+        return $this->run(function () use ($config) {
+            $params = request()->validate([
+                'payment_company' => ['required', Rule::in(PaymentProviderCatalog::codes())],
+            ]);
+            return $config->show($params['payment_company']);
+        });
+    }
+
+    public function saveConfig(PaymentProviderConfig $config)
+    {
+        return $this->run(function () use ($config) {
+            $params = request()->validate([
+                'payment_company' => ['required', Rule::in(PaymentProviderCatalog::codes())],
+                'config' => ['required', 'array'],
+            ]);
+            return $config->save($params['payment_company'], $params['config']);
+        });
+    }
+
     private function run(callable $callback)
     {
         try {

+ 3 - 3
app/Models/PaymentProvider.php

@@ -5,7 +5,7 @@ namespace App\Models;
 class PaymentProvider extends BaseModel
 {
     protected $table = 'payment_providers';
-    protected $fillable = ['code', 'status'];
-    protected $hidden = [];
-    protected $casts = ['status' => 'integer'];
+    protected $fillable = ['code', 'status', 'config'];
+    protected $hidden = ['config'];
+    protected $casts = ['status' => 'integer', 'config' => 'encrypted:array'];
 }

+ 31 - 24
app/Services/Payment/JdPayService.php

@@ -17,12 +17,12 @@ class JdPayService extends BaseService
 
     public static function getMerchantId()
     {
-        return config('app.jd_pay_mch_id');
+        return PaymentProviderConfig::get('jd', 'mch_id');
     }
 
     public static function getSecret()
     {
-        return config('app.jd_pay_key');
+        return PaymentProviderConfig::get('jd', 'key');
     }
 
     public static function getNotifyUrl(): string
@@ -53,7 +53,7 @@ class JdPayService extends BaseService
     {
         $allowedUserIds = array_values(array_filter(array_map(
             'trim',
-            explode(',', (string)config('app.jd_pay_recharge_user_ids', ''))
+            explode(',', PaymentProviderConfig::get('jd', 'recharge_user_ids'))
         )));
 
         if (empty($allowedUserIds)) {
@@ -68,72 +68,77 @@ class JdPayService extends BaseService
         return number_format((float)$amount, 2, '.', '');
     }
 
-    public static function signature(array $values): string
+    public static function signature(array $values, ?string $key = null): string
     {
-        $values[] = self::getSecret();
+        $values[] = ($key ?? self::getSecret());
         return strtoupper(md5(implode('&', $values)));
     }
 
     public static function pay($amount, string $orderNo): array
     {
+        $config = PaymentProviderConfig::snapshot('jd');
         $amount = self::amount($amount);
         $data = [
-            'userCode' => self::getMerchantId(),
+            'userCode' => $config['mch_id'],
             'orderCode' => $orderNo,
             'amount' => $amount,
             'callbackUrl' => self::getNotifyUrl(),
         ];
-        $data['sign'] = self::signature([$orderNo, $amount, $data['userCode']]);
+        $data['sign'] = self::signature([$orderNo, $amount, $data['userCode']], $config['key']);
 
-        return self::post(config('app.jd_pay_gateway'), $data);
+        return self::post($config['pay_gateway'], $data);
     }
 
     public static function queryPayOrder(string $orderNo = '', string $customerOrderNo = ''): array
     {
+        $config = PaymentProviderConfig::snapshot('jd');
         $data = [
-            'userCode' => self::getMerchantId(),
+            'userCode' => $config['mch_id'],
             'orderCode' => $orderNo,
             'customerOrderCode' => $customerOrderNo,
         ];
-        $data['sign'] = self::signature([$data['orderCode'], $data['customerOrderCode'], $data['userCode']]);
+        $data['sign'] = self::signature([$data['orderCode'], $data['customerOrderCode'], $data['userCode']], $config['key']);
 
-        return self::post(config('app.jd_pay_query_gateway'), $data);
+        return self::post($config['pay_query_gateway'], $data);
     }
 
     public static function remit($amount, string $orderNo, string $address): array
     {
+        $config = PaymentProviderConfig::snapshot('jd');
         $amount = self::amount($amount);
         $data = [
-            'userCode' => self::getMerchantId(),
+            'userCode' => $config['mch_id'],
             'orderCode' => $orderNo,
             'amount' => $amount,
             'address' => $address,
             'callbackUrl' => self::getRemitNotifyUrl(),
         ];
-        $data['sign'] = self::signature([$orderNo, $amount, $address, $data['userCode']]);
+        $data['sign'] = self::signature([$orderNo, $amount, $address, $data['userCode']], $config['key']);
 
-        return self::post(config('app.jd_remit_gateway'), $data);
+        return self::post($config['remit_gateway'], $data);
     }
 
     public static function queryRemitOrder(string $orderNo = '', string $customerOrderNo = ''): array
     {
+        $config = PaymentProviderConfig::snapshot('jd');
         $data = [
-            'userCode' => self::getMerchantId(),
+            'userCode' => $config['mch_id'],
             'orderCode' => $orderNo,
             'customerOrderCode' => $customerOrderNo,
         ];
-        $data['sign'] = self::signature([$data['orderCode'], $data['customerOrderCode'], $data['userCode']]);
+        $data['sign'] = self::signature([$data['orderCode'], $data['customerOrderCode'], $data['userCode']], $config['key']);
 
-        return self::post(config('app.jd_remit_query_gateway'), $data);
+        return self::post($config['remit_query_gateway'], $data);
     }
 
     public static function balance(): array
     {
+        $config = PaymentProviderConfig::snapshot('jd');
         $timestamp = (string)round(microtime(true) * 1000);
-        $userCode = self::getMerchantId();
-        $sign = self::signature([$userCode, $timestamp]);
+        $userCode = $config['mch_id'];
+        $sign = self::signature([$userCode, $timestamp], $config['key']);
 
-        $response = self::getClient()->get(rtrim(config('app.jd_balance_gateway'), '/') . '/' . $userCode, [
+        $response = self::getClient()->get(rtrim($config['balance_gateway'], '/') . '/' . $userCode, [
             'query' => [
                 'timestamp' => $timestamp,
                 'sign' => $sign,
@@ -145,7 +150,8 @@ class JdPayService extends BaseService
 
     public static function verifyPayNotify(array $params): bool
     {
-        if (($params['userCode'] ?? '') !== self::getMerchantId()) {
+        $config = PaymentProviderConfig::snapshot('jd');
+        if (($params['userCode'] ?? '') !== $config['mch_id']) {
             return false;
         }
 
@@ -154,14 +160,15 @@ class JdPayService extends BaseService
             (string)($params['amount'] ?? ''),
             $params['userCode'] ?? '',
             (string)($params['status'] ?? ''),
-        ]);
+        ], $config['key']);
 
         return hash_equals($sign, strtoupper((string)($params['sign'] ?? '')));
     }
 
     public static function verifyRemitNotify(array $params): bool
     {
-        if (($params['userCode'] ?? '') !== self::getMerchantId()) {
+        $config = PaymentProviderConfig::snapshot('jd');
+        if (($params['userCode'] ?? '') !== $config['mch_id']) {
             return false;
         }
 
@@ -171,7 +178,7 @@ class JdPayService extends BaseService
             (string)($params['amount'] ?? ''),
             $params['userCode'] ?? '',
             (string)($params['status'] ?? ''),
-        ]);
+        ], $config['key']);
 
         return hash_equals($sign, strtoupper((string)($params['sign'] ?? '')));
     }

+ 21 - 16
app/Services/Payment/NoPayService.php

@@ -33,7 +33,7 @@ class NoPayService extends BaseService
     {
         $allowedUserIds = array_values(array_filter(array_map(
             'trim',
-            explode(',', (string)config('app.no_pay_recharge_user_ids', ''))
+            explode(',', PaymentProviderConfig::get('no', 'recharge_user_ids'))
         )));
 
         if (empty($allowedUserIds)) {
@@ -55,12 +55,12 @@ class NoPayService extends BaseService
 
     public static function getDepositMerchantId(): string
     {
-        return (string)config('app.no_pay_deposit_mch_id');
+        return PaymentProviderConfig::get('no', 'deposit_mch_id');
     }
 
     public static function getWithdrawMerchantId(): string
     {
-        return (string)config('app.no_pay_withdraw_mch_id');
+        return PaymentProviderConfig::get('no', 'withdraw_mch_id');
     }
 
     public static function getDepositNotifyUrl(): string
@@ -92,8 +92,9 @@ class NoPayService extends BaseService
 
     public static function pay($amount, string $orderNo, string $memberNo, string $channel): array
     {
+        $config = PaymentProviderConfig::snapshot('no');
         $data = self::signedData([
-            'appId' => self::getDepositMerchantId(),
+            'appId' => $config['deposit_mch_id'],
             'merchantMemberNo' => $memberNo,
             'merchantOrderNo' => $orderNo,
             'amount' => self::amount($amount),
@@ -101,28 +102,30 @@ class NoPayService extends BaseService
             'notifyUrl' => self::getDepositNotifyUrl(),
             'timestamp' => time(),
             'version' => 'v1',
-        ], (string)config('app.no_pay_deposit_key'));
+        ], $config['deposit_key']);
 
-        return self::post((string)config('app.no_pay_deposit_gateway'), $data, self::getDepositMerchantId());
+        return self::post($config['deposit_gateway'], $data, $config['deposit_mch_id']);
     }
 
     public static function queryPayOrder(string $orderNo, string $memberNo): array
     {
+        $config = PaymentProviderConfig::snapshot('no');
         $data = self::signedData([
-            'appId' => self::getDepositMerchantId(),
+            'appId' => $config['deposit_mch_id'],
             'merchantOrderNo' => $orderNo,
             'merchantMemberNo' => $memberNo,
             'timestamp' => time(),
             'version' => 'v1',
-        ], (string)config('app.no_pay_deposit_key'));
+        ], $config['deposit_key']);
 
-        return self::post((string)config('app.no_pay_deposit_query_gateway'), $data, self::getDepositMerchantId());
+        return self::post($config['deposit_query_gateway'], $data, $config['deposit_mch_id']);
     }
 
     public static function withdraw($amount, string $orderNo, string $memberNo, string $accountName, string $qAccount): array
     {
+        $config = PaymentProviderConfig::snapshot('no');
         $data = self::signedData([
-            'appId' => self::getWithdrawMerchantId(),
+            'appId' => $config['withdraw_mch_id'],
             'merchantOrderNo' => $orderNo,
             'merchantMemberNo' => $memberNo,
             'amount' => self::amount($amount),
@@ -131,29 +134,31 @@ class NoPayService extends BaseService
             'qAccount' => $qAccount,
             'timestamp' => time(),
             'version' => 'v1',
-        ], (string)config('app.no_pay_withdraw_key'));
+        ], $config['withdraw_key']);
 
-        return self::post((string)config('app.no_pay_withdraw_gateway'), $data, self::getWithdrawMerchantId());
+        return self::post($config['withdraw_gateway'], $data, $config['withdraw_mch_id']);
     }
 
     public static function verifyDepositNotify(array $params): bool
     {
-        return self::verifyNotify($params, self::getDepositMerchantId(), (string)config('app.no_pay_deposit_key'));
+        $config = PaymentProviderConfig::snapshot('no');
+        return self::verifyNotify($params, $config['deposit_mch_id'], $config['deposit_key']);
     }
 
     public static function verifyWithdrawNotify(array $params): bool
     {
-        return self::verifyNotify($params, self::getWithdrawMerchantId(), (string)config('app.no_pay_withdraw_key'));
+        $config = PaymentProviderConfig::snapshot('no');
+        return self::verifyNotify($params, $config['withdraw_mch_id'], $config['withdraw_key']);
     }
 
     public static function depositNotifySignatureDiagnostics(array $params): array
     {
-        return self::signatureDiagnostics($params, (string)config('app.no_pay_deposit_key'));
+        return self::signatureDiagnostics($params, PaymentProviderConfig::get('no', 'deposit_key'));
     }
 
     public static function withdrawNotifySignatureDiagnostics(array $params): array
     {
-        return self::signatureDiagnostics($params, (string)config('app.no_pay_withdraw_key'));
+        return self::signatureDiagnostics($params, PaymentProviderConfig::get('no', 'withdraw_key'));
     }
 
     private static function signedData(array $data, string $key): array

+ 267 - 0
app/Services/Payment/PaymentProviderConfig.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace App\Services\Payment;
+
+use App\Models\PaymentProvider;
+use App\Models\PaymentOrder;
+use App\Services\OperationAuditService;
+use App\Services\PaymentOrderService;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+use InvalidArgumentException;
+use RuntimeException;
+use Throwable;
+
+/**
+ * 支付公司商户配置。后台保存到 payment_providers.config(加密),未保存的字段回退到 .env。
+ * 每次操作读取完整快照、不做进程缓存:后续操作使用后台最新配置。
+ */
+class PaymentProviderConfig
+{
+    // type: text 普通文本,secret 密钥(只写不回显),url 网关地址,user_ids 逗号分隔用户ID(留空=全部用户)
+    private const FIELDS = [
+        'sanjin' => [
+            'mch_id' => ['label' => '商户号', 'type' => 'text', 'env' => 'app.tree_pay_mch_id'],
+            'key' => ['label' => '商户密钥', 'type' => 'secret', 'env' => 'app.tree_pay_key'],
+            'gateway' => ['label' => '接口地址', 'type' => 'url', 'default' => 'https://jkapi-sanjin.jkcbb.com/'],
+        ],
+        'qianbao' => [
+            'merchant_id' => ['label' => '商户号', 'type' => 'text', 'env' => 'app.tree_payment_merchant_id'],
+            'secret' => ['label' => '商户密钥', 'type' => 'secret', 'env' => 'app.tree_payment_secret'],
+            'gateway' => ['label' => '接口地址', 'type' => 'url', 'default' => 'https://api.qbdf13.com/'],
+        ],
+        'jd' => [
+            'mch_id' => ['label' => '商户号', 'type' => 'text', 'env' => 'app.jd_pay_mch_id'],
+            'key' => ['label' => '商户密钥', 'type' => 'secret', 'env' => 'app.jd_pay_key'],
+            'pay_gateway' => ['label' => '充值下单地址', 'type' => 'url', 'env' => 'app.jd_pay_gateway'],
+            'pay_query_gateway' => ['label' => '充值查询地址', 'type' => 'url', 'env' => 'app.jd_pay_query_gateway'],
+            'remit_gateway' => ['label' => '代付下单地址', 'type' => 'url', 'env' => 'app.jd_remit_gateway'],
+            'remit_query_gateway' => ['label' => '代付查询地址', 'type' => 'url', 'env' => 'app.jd_remit_query_gateway'],
+            'balance_gateway' => ['label' => '余额查询地址', 'type' => 'url', 'env' => 'app.jd_balance_gateway'],
+            'recharge_user_ids' => ['label' => '充值白名单用户ID', 'type' => 'user_ids', 'env' => 'app.jd_pay_recharge_user_ids'],
+        ],
+        'no' => [
+            'deposit_mch_id' => ['label' => '充值商户号', 'type' => 'text', 'env' => 'app.no_pay_deposit_mch_id'],
+            'deposit_key' => ['label' => '充值密钥', 'type' => 'secret', 'env' => 'app.no_pay_deposit_key'],
+            'withdraw_mch_id' => ['label' => '提现商户号', 'type' => 'text', 'env' => 'app.no_pay_withdraw_mch_id'],
+            'withdraw_key' => ['label' => '提现密钥', 'type' => 'secret', 'env' => 'app.no_pay_withdraw_key'],
+            'deposit_gateway' => ['label' => '充值下单地址', 'type' => 'url', 'env' => 'app.no_pay_deposit_gateway'],
+            'deposit_query_gateway' => ['label' => '充值查询地址', 'type' => 'url', 'env' => 'app.no_pay_deposit_query_gateway'],
+            'withdraw_gateway' => ['label' => '提现下单地址', 'type' => 'url', 'env' => 'app.no_pay_withdraw_gateway'],
+            'recharge_user_ids' => ['label' => '充值白名单用户ID', 'type' => 'user_ids', 'env' => 'app.no_pay_recharge_user_ids'],
+        ],
+        'zimu' => [
+            'app_id' => ['label' => '商户AppID', 'type' => 'text', 'env' => 'app.zimu_pay_app_id'],
+            'key' => ['label' => '商户密钥', 'type' => 'secret', 'env' => 'app.zimu_pay_key'],
+            'pay_gateway' => ['label' => '充值下单地址', 'type' => 'url', 'env' => 'app.zimu_pay_gateway'],
+            'pay_query_gateway' => ['label' => '充值查询地址', 'type' => 'url', 'env' => 'app.zimu_pay_query_gateway'],
+            'balance_gateway' => ['label' => '余额查询地址', 'type' => 'url', 'env' => 'app.zimu_balance_gateway'],
+            'withdraw_gateway' => ['label' => '账户提现下单地址', 'type' => 'url', 'env' => 'app.zimu_withdraw_gateway'],
+            'withdraw_query_gateway' => ['label' => '账户提现查询地址', 'type' => 'url', 'env' => 'app.zimu_withdraw_query_gateway'],
+            'cash_gateway' => ['label' => '人民币代付下单地址', 'type' => 'url', 'env' => 'app.zimu_cash_gateway'],
+            'cash_query_gateway' => ['label' => '人民币代付查询地址', 'type' => 'url', 'env' => 'app.zimu_cash_query_gateway'],
+            'recharge_user_ids' => ['label' => '充值白名单用户ID', 'type' => 'user_ids', 'env' => 'app.zimu_pay_recharge_user_ids'],
+        ],
+    ];
+
+    public static function fields(string $code): array
+    {
+        if (!isset(self::FIELDS[$code])) throw new InvalidArgumentException('支付公司不存在');
+        return self::FIELDS[$code];
+    }
+
+    /** 运行时取值:后台已保存的值优先,否则回退 .env / 默认值。 */
+    public static function get(string $code, string $field): string
+    {
+        if (!isset(self::fields($code)[$field])) throw new InvalidArgumentException("未知支付配置 {$code}.{$field}");
+        return self::snapshot($code)[$field];
+    }
+
+    /** 同一次下单、查询或验签复用该数组,避免不同字段跨配置版本读取。 */
+    public static function snapshot(string $code): array
+    {
+        $fields = self::fields($code);
+        $stored = self::stored($code);
+        return self::resolve($fields, $stored);
+    }
+
+    public static function userIds(string $code): array
+    {
+        return array_values(array_filter(array_map('trim', explode(',', self::get($code, 'recharge_user_ids')))));
+    }
+
+    /** 后台展示:密钥只返回是否已配置和尾号,不回显明文。 */
+    public function show(string $code): array
+    {
+        $stored = self::stored($code);
+        $rows = [];
+        foreach (self::fields($code) as $field => $definition) {
+            $fromDb = array_key_exists($field, $stored);
+            $value = $fromDb ? (string)$stored[$field] : self::fallback($definition);
+            $row = [
+                'field' => $field, 'label' => $definition['label'], 'type' => $definition['type'],
+                'source' => $fromDb ? 'admin' : ($value === '' ? 'empty' : 'env'),
+            ];
+            if ($definition['type'] === 'secret') {
+                $row['value'] = '';
+                $row['has_value'] = $value !== '';
+                $row['masked'] = $value === '' ? '' : '******' . mb_substr($value, -4);
+            } else {
+                $row['value'] = $value;
+            }
+            $rows[] = $row;
+        }
+        return [
+            'payment_company' => $code,
+            'payment_company_label' => PaymentProviderCatalog::label($code),
+            'fields' => $rows,
+        ];
+    }
+
+    /**
+     * 保存后台配置。仅提交的字段会被写入;密钥留空表示不修改。
+     * 首次保存时其余字段仍按 .env 回退,不会被清空。
+     */
+    public function save(string $code, array $input): array
+    {
+        $fields = self::fields($code);
+        $unknown = array_diff(array_keys($input), array_keys($fields));
+        if ($unknown) throw ValidationException::withMessages(['config' => '未知配置项:' . implode(',', $unknown)]);
+        $values = [];
+        foreach ($input as $field => $value) {
+            if ($value !== null && !is_scalar($value)) throw ValidationException::withMessages([$field => $fields[$field]['label'] . '格式错误']);
+            $value = trim((string)$value);
+            $type = $fields[$field]['type'];
+            if ($type === 'secret' && $value === '') continue;
+            if ($type === 'url' && filter_var($value, FILTER_VALIDATE_URL) === false) {
+                throw ValidationException::withMessages([$field => $fields[$field]['label'] . '必须是有效的网址']);
+            }
+            if ($type === 'user_ids') {
+                if (!preg_match('/^[0-9,\s]*$/', $value)) {
+                    throw ValidationException::withMessages([$field => $fields[$field]['label'] . '只能填写数字ID,多个用英文逗号分隔']);
+                }
+                $value = implode(',', array_values(array_filter(array_map('trim', explode(',', $value)), 'strlen')));
+            }
+            if (mb_strlen($value) > 10000) throw ValidationException::withMessages([$field => $fields[$field]['label'] . '过长']);
+            $values[$field] = $value;
+        }
+        DB::transaction(function () use ($code, $values) {
+            $provider = PaymentProvider::query()->where('code', $code)->lockForUpdate()->firstOrFail();
+            $before = self::storedValues($provider, $code);
+            $after = array_merge($before, $values);
+            if ($after === $before) return;
+            $definitions = self::fields($code);
+            $oldValues = self::resolve($definitions, $before);
+            $newValues = self::resolve($definitions, $after);
+            foreach ($definitions as $field => $definition) {
+                if (in_array($definition['type'], ['text', 'secret'], true) && $oldValues[$field] !== $newValues[$field]) {
+                    self::assertNoPendingOrders($code);
+                    break;
+                }
+            }
+            $provider->config = $after;
+            $provider->save();
+            OperationAuditService::record('payment_company_config', (int)$provider->id, 'update', $before, $after);
+        }, 3);
+        return $this->show($code);
+    }
+
+    /** 固化当前服务器默认值:只补缺失字段,保留后台值和原始密钥,不返回明文。 */
+    public function importDefaults(array $codes = [], bool $write = false): array
+    {
+        $codes = $codes ?: PaymentProviderCatalog::codes();
+        foreach ($codes as $code) self::fields($code);
+        // 固定加锁顺序,并去掉重复公司代码。
+        $codes = array_values(array_intersect(PaymentProviderCatalog::codes(), $codes));
+        return DB::transaction(function () use ($codes, $write) {
+            $report = [];
+            foreach ($codes as $code) {
+                $query = PaymentProvider::query()->where('code', $code);
+                $provider = ($write ? $query->lockForUpdate() : $query->sharedLock())->firstOrFail();
+                $before = self::storedValues($provider, $code);
+                $after = $before;
+                $imported = $preserved = $empty = [];
+                foreach (self::fields($code) as $field => $definition) {
+                    if (array_key_exists($field, $before)) {
+                        $preserved[] = $field;
+                        continue;
+                    }
+                    $value = self::fallback($definition);
+                    if ($value === '') {
+                        $empty[] = $field;
+                        continue;
+                    }
+                    $after[$field] = $value;
+                    $imported[] = $field;
+                }
+                if ($write && $imported !== []) {
+                    // 仅将当前回退值原样固化,实际生效的商户凭证不会发生变化。
+                    $provider->config = $after;
+                    $provider->save();
+                    OperationAuditService::record('payment_company_config', (int)$provider->id, 'import_env', $before, $after,
+                        actor: ['operator_id' => null, 'operator_name' => 'console:payment:config-import-env']);
+                }
+                $report[] = ['payment_company' => $code, 'label' => PaymentProviderCatalog::label($code),
+                    'imported' => $imported, 'preserved' => $preserved, 'empty' => $empty];
+            }
+            return $report;
+        }, 3);
+    }
+
+    private static function stored(string $code): array
+    {
+        try {
+            // 共享锁与后台保存的排他锁互斥;多个支付读取仍可并行。
+            // 若调用方已在事务内,共享锁保留到其提交,防止未提交订单被漏查。
+            return DB::transaction(function () use ($code) {
+                $provider = PaymentProvider::query()->where('code', $code)->sharedLock()->firstOrFail();
+                return (array)($provider->config ?? []);
+            });
+        } catch (Throwable $e) {
+            self::readFailed($code, $e);
+        }
+    }
+
+    private static function storedValues(PaymentProvider $provider, string $code): array
+    {
+        try {
+            return (array)($provider->config ?? []);
+        } catch (Throwable $e) {
+            self::readFailed($code, $e);
+        }
+    }
+
+    private static function readFailed(string $code, Throwable $e): never
+    {
+        if (app()->bound('log')) Log::error('payment_provider_config_read_failed', ['payment_company' => $code, 'exception' => $e]);
+        throw new RuntimeException('支付商户配置读取失败,请联系管理员', 0, $e);
+    }
+
+    private static function resolve(array $fields, array $stored): array
+    {
+        $values = [];
+        foreach ($fields as $field => $definition) {
+            $values[$field] = array_key_exists($field, $stored) ? (string)$stored[$field] : self::fallback($definition);
+        }
+        return $values;
+    }
+
+    private static function assertNoPendingOrders(string $code): void
+    {
+        $orders = PaymentOrder::query()->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_PAYOUT])
+            ->whereNotIn('status', [PaymentOrderService::STATUS_SUCCESS, PaymentOrderService::STATUS_FAIL, PaymentOrderService::STATUS_CANCEL])
+            ->lockForUpdate()->get(['type', 'channel']);
+        foreach ($orders as $order) {
+            if (PaymentProviderService::providerForRequest((string)$order->channel, (int)$order->type) === $code) {
+                throw ValidationException::withMessages(['config' => '该支付公司存在未完成订单,暂不能修改商户号或密钥;请先停用公司并处理完订单']);
+            }
+        }
+    }
+
+    private static function fallback(array $definition): string
+    {
+        if (isset($definition['env'])) return (string)config($definition['env'], $definition['default'] ?? '');
+        return (string)($definition['default'] ?? '');
+    }
+}

+ 6 - 1
app/Services/Payment/PaymentProviderService.php

@@ -24,13 +24,18 @@ class PaymentProviderService
     }
 
     public static function requestEnabled(string $channel, int $direction): bool
+    {
+        return (self::statuses()[self::providerForRequest($channel, $direction)] ?? 0) === 1;
+    }
+
+    public static function providerForRequest(string $channel, int $direction): string
     {
         // Mirror actual dispatch, including historical aliases accepted by provider services.
         if (JdPayService::isChannel($channel)) $code = 'jd';
         elseif ($direction === 1 ? NoPayService::isRechargeChannel($channel) : NoPayService::isWithdrawChannel($channel)) $code = 'no';
         elseif ($direction === 1 ? ZimuPayService::isRechargeChannel($channel) : ZimuPayService::isPayoutChannel($channel)) $code = 'zimu';
         else $code = $direction === 1 ? 'sanjin' : 'qianbao';
-        return (self::statuses()[$code] ?? 0) === 1;
+        return $code;
     }
 
     public function listing(array $params): array

+ 28 - 22
app/Services/Payment/QianBaoService.php

@@ -10,7 +10,6 @@ use App\Services\BaseService;
 class QianBaoService extends BaseService
 {
     // SanJin payment service methods would go here
-    const REQUEST_URL = 'https://api.qbdf13.com/';
 
     // 提现通道
     const ALIPAY_TO_CARD = 'DF001'; // 支付宝转卡
@@ -45,12 +44,12 @@ class QianBaoService extends BaseService
     // 获取商户ID
     public static function getMerchantId()
     {
-        return config('app.tree_payment_merchant_id');
+        return PaymentProviderConfig::get('qianbao', 'merchant_id');
     }
     // 获取商户ID
     public static function getSecret()
     {
-        return config('app.tree_payment_secret');
+        return PaymentProviderConfig::get('qianbao', 'secret');
     }
 
 
@@ -59,10 +58,11 @@ class QianBaoService extends BaseService
      * @description: 获取请求客户端
      * @return {*}
      */
-    public static function getClient(): Client
+    public static function getClient(?array $config = null): Client
     {
+        $config ??= PaymentProviderConfig::snapshot('qianbao');
         return new Client([
-            'base_uri' => self::REQUEST_URL,
+            'base_uri' => rtrim($config['gateway'], '/') . '/',
             'timeout' => 5.0,
         ]);
     }
@@ -78,15 +78,16 @@ class QianBaoService extends BaseService
      */    
     public static function findBalance()
     {
-        $merchant_id  = self::getMerchantId();
-        $secret = self::getSecret();
+        $config = PaymentProviderConfig::snapshot('qianbao');
+        $merchant_id  = $config['merchant_id'];
+        $secret = $config['secret'];
         $sign = md5($merchant_id . $secret);
         $data = [
             'merchantNum' => $merchant_id,
             'sign' => $sign,
         ];
 
-        $client = self::getClient();
+        $client = self::getClient($config);
         $response = $client->get('api/findBalance', [
             'query' => $data,
         ]);
@@ -101,15 +102,16 @@ class QianBaoService extends BaseService
      */    
     public static function findPayoutOrderInfo($order_no)
     {
-        $merchant_id  = self::getMerchantId();
-        $secret = self::getSecret();
+        $config = PaymentProviderConfig::snapshot('qianbao');
+        $merchant_id  = $config['merchant_id'];
+        $secret = $config['secret'];
 
         $data = [];
         $data['merchantNum'] = $merchant_id;
         $data['orderNo'] = $order_no;
         $sign = md5($merchant_id.$order_no.$secret);
         $data['sign'] = $sign;
-        $client = self::getClient();
+        $client = self::getClient($config);
         $response = $client->get('api/findPayoutOrderInfo', [
             'query' => $data,
         ]);
@@ -124,15 +126,16 @@ class QianBaoService extends BaseService
      */    
     public static function findPayOrderInfo($order_no)
     {
-        $merchant_id  = self::getMerchantId();
-        $secret = self::getSecret();
+        $config = PaymentProviderConfig::snapshot('qianbao');
+        $merchant_id  = $config['merchant_id'];
+        $secret = $config['secret'];
 
         $data = [];
         $data['merchantNum'] = $merchant_id;
         $data['orderNo'] = $order_no;
         $sign = md5($merchant_id.$order_no.$secret);
         $data['sign'] = $sign;
-        $client = self::getClient();
+        $client = self::getClient($config);
         $response = $client->get('api/findPayOrderInfo', [
             'query' => $data,
         ]);
@@ -143,9 +146,10 @@ class QianBaoService extends BaseService
     // 代收
     public static function pay($amount, $order_no, $payType = self::PAY_ZFB1)
     {
+        $config = PaymentProviderConfig::snapshot('qianbao');
         $data = [];
-        $merchant_id  = self::getMerchantId();
-        $secret = self::getSecret();
+        $merchant_id  = $config['merchant_id'];
+        $secret = $config['secret'];
         $notify_url = self::getNotifyUrl();
         $data['merchantNum'] = $merchant_id;
         $data['amount'] = $amount;
@@ -158,7 +162,7 @@ class QianBaoService extends BaseService
         $sign = md5($signStr);
         $data['sign'] = $sign;
         
-        $client = self::getClient();
+        $client = self::getClient($config);
         $response = $client->post('api/pay', [
                 'form_params' => $data,
                 'headers' => [
@@ -174,9 +178,10 @@ class QianBaoService extends BaseService
     // 代付
     public static function payout($amount, $order_no, $bank_name, $account, $card_no, $payType = self::ALIPAY_TO_ALIPAY)
     {
+        $config = PaymentProviderConfig::snapshot('qianbao');
         $data = [];
-        $merchant_id  = self::getMerchantId();
-        $secret = self::getSecret();
+        $merchant_id  = $config['merchant_id'];
+        $secret = $config['secret'];
         $notify_url = self::getNotifyUrl();
 
         $data['merchantNum'] = $merchant_id;
@@ -203,7 +208,7 @@ class QianBaoService extends BaseService
         $data['cardNumber'] = $card_no;
         
 
-        $client = self::getClient();
+        $client = self::getClient($config);
         $response = $client->post('api/payout', [
                 'form_params' => $data,
                 'headers' => [
@@ -225,8 +230,9 @@ class QianBaoService extends BaseService
      */    
     public static function verifyNotifySign($state,$order_no,$amount)
     {
-        $merchant_id = self::getMerchantId();
-        $secret = self::getSecret();
+        $config = PaymentProviderConfig::snapshot('qianbao');
+        $merchant_id = $config['merchant_id'];
+        $secret = $config['secret'];
         $sign = md5($state.$merchant_id.$order_no.$amount.$secret);
         return $sign;
     }

+ 15 - 13
app/Services/Payment/SanJinService.php

@@ -12,7 +12,6 @@ use App\Models\RechargeChannelGroup;
 
 class SanJinService extends BaseService
 {
-    const REQUEST_URL = 'https://jkapi-sanjin.jkcbb.com/';
 
     
     const PRODUCT_TEST = 'T888'; // 测试支付通道
@@ -146,12 +145,12 @@ class SanJinService extends BaseService
     // 获取商户ID
     public static function getMerchantId()
     {
-        return config('app.tree_pay_mch_id');
+        return PaymentProviderConfig::get('sanjin', 'mch_id');
     }
     // 获取商户秘钥
     public static function getSecret()
     {
-        return config('app.tree_pay_key');
+        return PaymentProviderConfig::get('sanjin', 'key');
     }
 
     // 获取异步的通知地址
@@ -166,16 +165,17 @@ class SanJinService extends BaseService
      * @description: 获取请求客户端
      * @return {*}
      */
-    public static function getClient(): Client
+    public static function getClient(?array $config = null): Client
     {
+        $config ??= PaymentProviderConfig::snapshot('sanjin');
         return new Client([
-            'base_uri' => self::REQUEST_URL,
+            'base_uri' => rtrim($config['gateway'], '/') . '/',
             'timeout' => 5.0,
         ]);
     }
 
     // 签名
-    public static function signature($params = [],$must = [])
+    public static function signature($params = [],$must = [], ?string $key = null)
     {
         if($must){
             foreach($params as $k => $v){
@@ -191,7 +191,7 @@ class SanJinService extends BaseService
         foreach($params as $k => $v){
             array_push($parts,$k.'='.$v);
         }
-        $mch_key = self::getSecret();
+        $mch_key = $key ?? self::getSecret();
         $parts[] = "key=".$mch_key;
 
         $sign = md5(implode('&',$parts));
@@ -207,9 +207,10 @@ class SanJinService extends BaseService
      */    
     public static function pay($amount,$orderNo,$type = self::PRODUCT_TEST)
     {
+        $config = PaymentProviderConfig::snapshot('sanjin');
 
         $must = ['mchId','productId','outTradeNo','amount','reqTime','notifyUrl'];
-        $mch_id = self::getMerchantId();
+        $mch_id = $config['mch_id'];
 
         
         
@@ -222,8 +223,8 @@ class SanJinService extends BaseService
         $data['productId'] = $type;
 
         
-        $data['sign'] = self::signature($data,$must);
-        $client = self::getClient();
+        $data['sign'] = self::signature($data,$must, $config['key']);
+        $client = self::getClient($config);
         $response = $client->post('api/v1/pay/unifiedOrder', [
             'json' => $data,
             'headers' => [
@@ -242,9 +243,10 @@ class SanJinService extends BaseService
      */    
     public static function queryOrder($orderNo)
     {
+        $config = PaymentProviderConfig::snapshot('sanjin');
 
         $must = ['mchId','outTradeNo','reqTime'];
-        $mch_id = self::getMerchantId();
+        $mch_id = $config['mch_id'];
 
         
         
@@ -253,8 +255,8 @@ class SanJinService extends BaseService
         $data['outTradeNo'] = $orderNo;
         $data['reqTime'] = time() * 1000;
         
-        $data['sign'] = self::signature($data,$must);
-        $client = self::getClient();
+        $data['sign'] = self::signature($data,$must, $config['key']);
+        $client = self::getClient($config);
         $response = $client->post('api/v1/pay/queryOrder', [
             'json' => $data,
             'headers' => [

+ 37 - 29
app/Services/Payment/ZimuPayService.php

@@ -51,7 +51,7 @@ class ZimuPayService extends BaseService
     {
         $allowedUserIds = array_values(array_filter(array_map(
             'trim',
-            explode(',', (string)config('app.zimu_pay_recharge_user_ids', ''))
+            explode(',', PaymentProviderConfig::get('zimu', 'recharge_user_ids'))
         )));
 
         if (empty($allowedUserIds)) {
@@ -68,12 +68,12 @@ class ZimuPayService extends BaseService
 
     public static function getAppId(): string
     {
-        return (string)config('app.zimu_pay_app_id');
+        return PaymentProviderConfig::get('zimu', 'app_id');
     }
 
     public static function getSecret(): string
     {
-        return (string)config('app.zimu_pay_key');
+        return PaymentProviderConfig::get('zimu', 'key');
     }
 
     public static function getPayNotifyUrl(): string
@@ -105,28 +105,31 @@ class ZimuPayService extends BaseService
 
     public static function pay($amount, string $orderNo, string $member): array
     {
+        $config = PaymentProviderConfig::snapshot('zimu');
         $data = self::signedData([
-            'appId' => self::getAppId(),
+            'appId' => $config['app_id'],
             'mchOrderNo' => $orderNo,
             'amount' => self::amount($amount),
             'member' => $member,
             'notifyUrl' => self::getPayNotifyUrl(),
             'timestamp' => self::timestamp(),
             'nonce' => self::nonce(),
-        ]);
+        ], $config['key']);
 
-        return self::post((string)config('app.zimu_pay_gateway'), $data);
+        return self::post($config['pay_gateway'], $data);
     }
 
     public static function queryPayOrder(string $orderNo): array
     {
-        return self::post((string)config('app.zimu_pay_query_gateway'), self::queryData($orderNo));
+        $config = PaymentProviderConfig::snapshot('zimu');
+        return self::post($config['pay_query_gateway'], self::queryData($orderNo, $config));
     }
 
     public static function withdraw($amount, string $orderNo, string $member, string $receiveAccount, string $realName = ''): array
     {
+        $config = PaymentProviderConfig::snapshot('zimu');
         $data = self::signedData([
-            'appId' => self::getAppId(),
+            'appId' => $config['app_id'],
             'member' => $member,
             'realName' => $realName,
             'mchOrderNo' => $orderNo,
@@ -135,20 +138,22 @@ class ZimuPayService extends BaseService
             'timestamp' => self::timestamp(),
             'nonce' => self::nonce(),
             'receiveAccount' => $receiveAccount,
-        ]);
+        ], $config['key']);
 
-        return self::post((string)config('app.zimu_withdraw_gateway'), $data);
+        return self::post($config['withdraw_gateway'], $data);
     }
 
     public static function queryWithdrawOrder(string $orderNo): array
     {
-        return self::post((string)config('app.zimu_withdraw_query_gateway'), self::queryData($orderNo));
+        $config = PaymentProviderConfig::snapshot('zimu');
+        return self::post($config['withdraw_query_gateway'], self::queryData($orderNo, $config));
     }
 
     public static function cash($amount, string $orderNo, string $accountName, string $accountNo, string $bank, string $payMethod = ''): array
     {
+        $config = PaymentProviderConfig::snapshot('zimu');
         $data = self::signedData([
-            'appId' => self::getAppId(),
+            'appId' => $config['app_id'],
             'mchOrderNo' => $orderNo,
             'amount' => self::amount($amount),
             'notifyUrl' => self::getPayoutNotifyUrl(),
@@ -158,34 +163,37 @@ class ZimuPayService extends BaseService
             'payMethod' => $payMethod,
             'timestamp' => self::timestamp(),
             'nonce' => self::nonce(),
-        ]);
+        ], $config['key']);
 
-        return self::post((string)config('app.zimu_cash_gateway'), $data);
+        return self::post($config['cash_gateway'], $data);
     }
 
     public static function queryCashOrder(string $orderNo): array
     {
-        return self::post((string)config('app.zimu_cash_query_gateway'), self::queryData($orderNo));
+        $config = PaymentProviderConfig::snapshot('zimu');
+        return self::post($config['cash_query_gateway'], self::queryData($orderNo, $config));
     }
 
     public static function balance(): array
     {
+        $config = PaymentProviderConfig::snapshot('zimu');
         $data = self::signedData([
-            'appId' => self::getAppId(),
+            'appId' => $config['app_id'],
             'timestamp' => self::timestamp(),
             'nonce' => self::nonce(),
-        ]);
+        ], $config['key']);
 
-        return self::post((string)config('app.zimu_balance_gateway'), $data);
+        return self::post($config['balance_gateway'], $data);
     }
 
     public static function verifyNotify(array $params): bool
     {
-        if (self::getAppId() === '' || ($params['appId'] ?? '') !== self::getAppId() || empty($params['sign'])) {
+        $config = PaymentProviderConfig::snapshot('zimu');
+        if ($config['app_id'] === '' || ($params['appId'] ?? '') !== $config['app_id'] || empty($params['sign'])) {
             return false;
         }
 
-        return hash_equals(self::signature($params), strtoupper((string)$params['sign']));
+        return hash_equals(self::signature($params, $config['key']), strtoupper((string)$params['sign']));
     }
 
     public static function notifySignatureDiagnostics(array $params): array
@@ -217,19 +225,19 @@ class ZimuPayService extends BaseService
         return '1';
     }
 
-    private static function queryData(string $orderNo): array
+    private static function queryData(string $orderNo, array $config): array
     {
         return self::signedData([
-            'appId' => self::getAppId(),
+            'appId' => $config['app_id'],
             'mchOrderNo' => $orderNo,
             'timestamp' => self::timestamp(),
             'nonce' => self::nonce(),
-        ]);
+        ], $config['key']);
     }
 
-    private static function signedData(array $data): array
+    private static function signedData(array $data, string $key): array
     {
-        $data['sign'] = self::signature($data);
+        $data['sign'] = self::signature($data, $key);
         return $data;
     }
 
@@ -250,7 +258,7 @@ class ZimuPayService extends BaseService
 
         Log::info('ZIMU支付接口请求', [
             'url' => $url,
-            'app_id' => self::getAppId(),
+            'app_id' => ($data['appId'] ?? ''),
             'data' => $logData,
         ]);
 
@@ -266,7 +274,7 @@ class ZimuPayService extends BaseService
 
             Log::info('ZIMU支付接口响应', [
                 'url' => $url,
-                'app_id' => self::getAppId(),
+                'app_id' => ($data['appId'] ?? ''),
                 'http_status' => $response->getStatusCode(),
                 'body' => $body,
             ]);
@@ -278,7 +286,7 @@ class ZimuPayService extends BaseService
 
             Log::error('ZIMU支付接口请求失败', [
                 'url' => $url,
-                'app_id' => self::getAppId(),
+                'app_id' => ($data['appId'] ?? ''),
                 'http_status' => $response ? $response->getStatusCode() : null,
                 'body' => $body,
                 'error' => $e->getMessage(),
@@ -288,7 +296,7 @@ class ZimuPayService extends BaseService
         } catch (\Throwable $e) {
             Log::error('ZIMU支付接口异常', [
                 'url' => $url,
-                'app_id' => self::getAppId(),
+                'app_id' => ($data['appId'] ?? ''),
                 'error' => $e->getMessage(),
             ]);
 

+ 18 - 0
app/Services/PromotionAdminService.php

@@ -92,6 +92,7 @@ class PromotionAdminService
             $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
             $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
             $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']);
+            if ((int)$values['status'] === 1 && $values['end_time'] < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
             if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
             $row->fill($values); $row->save(); PromotionService::publish($row);
             if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $row->id, $before ? 'update' : 'create', $before, $row->toArray());
@@ -119,12 +120,29 @@ class PromotionAdminService
             $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
             if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
             if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()));
+            if ($status === 1 && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
             $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
             if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
             return $this->format($row);
         }, 3);
     }
 
+    /** Takes ended activities off the shelf; each change is audited like a manual status switch. */
+    public static function expire(): int
+    {
+        $expired = 0;
+        foreach (PromotionActivity::where('status', 1)->where('end_time', '<', now()->timestamp)->pluck('id') as $id) {
+            $expired += DB::transaction(function () use ($id) {
+                $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
+                if (!$row || (int)$row->status !== 1 || (int)$row->end_time >= now()->timestamp) return 0;
+                $before = $row->toArray(); $row->status = 0; $row->save();
+                if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'expire', $before, $row->toArray(), null, ['operator_id' => null, 'operator_name' => '系统']);
+                return 1;
+            }, 3);
+        }
+        return $expired;
+    }
+
     public function records(array $input): array
     {
         $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'],

+ 4 - 2
app/Support/ActivityCatalog.php

@@ -4,7 +4,7 @@ namespace App\Support;
 
 use App\Services\Sharing\ShareDefaults;
 
-/** Activity display categories include hot/other in addition to the shared game categories. */
+/** Activity display categories include hot/other and a single F1 lottery category. */
 final class ActivityCatalog
 {
     public static function activityTypes(): array
@@ -14,7 +14,9 @@ final class ActivityCatalog
 
     public static function gameTypes(): array
     {
-        return ['hot' => '热门'] + ShareDefaults::categories() + ['other' => '其他'];
+        $categories = ShareDefaults::categories();
+        unset($categories['third:3']);
+        return ['hot' => '热门'] + $categories + ['other' => '其他'];
     }
 
     public static function options(): array

+ 22 - 0
database/migrations/2026_11_01_120000_add_config_to_payment_providers.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasColumn('payment_providers', 'config')) {
+            // 加密 JSON:商户号、密钥、网关等;未保存的字段继续回退到 .env
+            Schema::table('payment_providers', fn (Blueprint $table) => $table->text('config')->nullable()->after('status'));
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('payment_providers', 'config')) {
+            Schema::table('payment_providers', fn (Blueprint $table) => $table->dropColumn('config'));
+        }
+    }
+};

+ 19 - 0
database/migrations/2026_11_02_120000_merge_activity_lottery_category.php

@@ -0,0 +1,19 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (Schema::hasTable('activity_rewards') && Schema::hasColumn('activity_rewards', 'game_category')) {
+            DB::table('activity_rewards')->where('game_category', 'third:3')->update(['game_category' => 'self:lottery']);
+        }
+    }
+
+    public function down(): void
+    {
+        // 原有 F1 彩票与合并后的记录无法区分;回滚代码时保留兼容的 self:lottery。
+    }
+};

+ 127 - 0
docs/前端接口/支付配置/支付公司商户配置-前端接入文档.md

@@ -0,0 +1,127 @@
+# 支付公司商户配置-前端接入文档
+
+在「充值通道 → 支付公司管理」列表每行增加「配置」按钮,用于查看和修改该支付公司的商户号、密钥、接口地址、充值白名单。覆盖全部已接入公司:三斤支付 `sanjin`、钱宝支付 `qianbao`、JD支付 `jd`、NO支付 `no`、808支付 `zimu`。
+
+- **保存后下次操作生效**:后端每次发起支付/代付/查询/验签都会读取完整配置,该次操作始终使用同一份配置。无需重启服务或清缓存。
+- **在途订单保护**:该公司还有未完成的充值或代付订单时,不允许更换商户号、商户 AppID 或密钥;原值保存、密钥留空、白名单和网关地址编辑仍允许。需更换凭证时,先停用公司以停止新订单,再按现有业务流程处理完未完成订单,最后修改配置并重新启用。
+- 使用现有管理员 Token;与「支付配置」一样仅运营管理员可用,无权限时返回 `code=-1, msg=无运营配置管理权限`,前端可隐藏按钮或直接提示 `msg`。
+- 返回格式仍为 `code/timestamp/msg/data`,`code=0` 为成功,其他值直接提示 `msg`。
+
+## 1. 查看配置
+
+`GET /admin/paymentCompany/config`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `payment_company` | string | 是 | 公司代码,取支付公司列表行的 `payment_company` |
+
+返回示例(JD支付):
+
+```json
+{
+  "code": 0,
+  "msg": "",
+  "data": {
+    "payment_company": "jd",
+    "payment_company_label": "JD支付",
+    "fields": [
+      {"field":"mch_id","label":"商户号","type":"text","source":"admin","value":"M100200"},
+      {"field":"key","label":"商户密钥","type":"secret","source":"env","value":"","has_value":true,"masked":"******a8f3"},
+      {"field":"pay_gateway","label":"充值下单地址","type":"url","source":"env","value":"https://openapi.jdpayapi.com/jdpayOpen/api/pay"},
+      {"field":"recharge_user_ids","label":"充值白名单用户ID","type":"user_ids","source":"empty","value":""}
+    ]
+  }
+}
+```
+
+### 字段说明
+
+| 字段 | 说明 |
+|---|---|
+| `field` | 配置项代码,保存时作为 `config` 的键 |
+| `label` | 直接作为表单标签显示 |
+| `type` | 控件类型,见下表 |
+| `source` | 当前生效值来源:`admin` 后台已保存;`env` 服务器 .env 默认值;`empty` 未配置。建议在标签旁以小字标注「后台配置 / 服务器默认 / 未配置」 |
+| `value` | 当前生效值。`secret` 类型恒为空字符串 |
+| `has_value` | 仅 `secret`:是否已配置 |
+| `masked` | 仅 `secret`:脱敏显示,如 `******a8f3`;未配置为空 |
+
+### 控件类型
+
+| `type` | 控件 | 规则 |
+|---|---|---|
+| `text` | 单行输入框 | 回显 `value`;可为空 |
+| `secret` | 密码输入框 | **不回显明文**。placeholder 显示 `masked`(已配置)或「未配置」。留空提交表示不修改 |
+| `url` | 单行输入框 | 回显 `value`;必须是完整网址(含 `https://`),不能为空 |
+| `user_ids` | 单行输入框 | 回显 `value`;会员 ID 用英文逗号分隔,如 `12,34`。**留空表示全部会员可用**,请在输入框下方提示这一点 |
+
+字段列表和顺序以接口返回为准,前端按 `fields` 顺序动态渲染,不要写死。各公司当前字段如下(供核对):
+
+| 公司 | 字段 |
+|---|---|
+| 三斤 `sanjin` | 商户号、商户密钥、接口地址 |
+| 钱宝 `qianbao` | 商户号、商户密钥、接口地址 |
+| JD `jd` | 商户号、商户密钥、充值下单/查询地址、代付下单/查询地址、余额查询地址、充值白名单 |
+| NO `no` | 充值商户号、充值密钥、提现商户号、提现密钥、充值下单/查询地址、提现下单地址、充值白名单 |
+| 808 `zimu` | 商户AppID、商户密钥、充值下单/查询地址、余额查询地址、账户提现下单/查询地址、人民币代付下单/查询地址、充值白名单 |
+
+## 2. 保存配置
+
+`POST /admin/paymentCompany/config/save`
+
+```json
+{
+  "payment_company": "jd",
+  "config": {
+    "mch_id": "M100200",
+    "key": "",
+    "pay_gateway": "https://openapi.jdpayapi.com/jdpayOpen/api/pay",
+    "recharge_user_ids": "12,34"
+  }
+}
+```
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `payment_company` | string | 是 | 公司代码 |
+| `config` | object | 是 | 键为 `field`,值为字符串 |
+
+规则:
+
+- 只会写入 `config` 中提交的字段;未提交的字段保持原状(包括继续使用服务器默认值)。建议直接提交整张表单。
+- `secret` 字段提交空字符串 = 不修改原密钥;填写新值才会覆盖。
+- 一旦某字段在后台保存过,就以后台值为准(`source=admin`),不再跟随服务器 .env。
+- 前后空格会被自动去除;白名单会自动去掉空项,例如 ` 9 , ,10` 保存为 `9,10`。
+- 不允许提交 `fields` 之外的键。
+- 未完成订单拦截发生在保存之前;被拒绝时不会部分保存其他字段,前端保留原输入并提示 `msg`。
+- 配置读取或解密失败会明确返回错误,不会改用服务器默认商户或放开充值白名单。
+
+成功返回与「查看配置」相同的 `data`,直接用它刷新表单(密钥输入框清空、更新 `masked`)。
+
+常见错误 `msg`:
+
+| msg | 原因 |
+|---|---|
+| `xxx必须是有效的网址` | `url` 字段为空或格式错误 |
+| `充值白名单用户ID只能填写数字ID,多个用英文逗号分隔` | 包含中文逗号、字母等 |
+| `未知配置项:xxx` | 提交了不存在的字段 |
+| `无运营配置管理权限` | 当前管理员非运营管理员 |
+| `该支付公司存在未完成订单,暂不能修改商户号或密钥;请先停用公司并处理完订单` | 修改商户凭证会影响在途订单;先完成或按既有流程取消该公司的未完成订单 |
+| `支付商户配置读取失败,请联系管理员` | 配置无法读取或解密;停止保存并联系后端排查 |
+
+## 3. 交互建议
+
+1. 列表行「配置」→ 打开弹窗/抽屉,调用查看接口渲染表单。
+2. 修改密钥、商户号、网关前二次确认:「保存后从下一次操作生效,修改商户号或密钥须先处理完未完成订单,是否继续?」
+3. 提交期间禁用保存按钮;失败保留用户输入并提示 `msg`。
+4. 修改记录写入操作日志(`resource_type=payment_company_config`),密钥在日志中显示为 `******`。
+
+## 4. 联调检查
+
+- 打开 5 家公司的配置弹窗,字段与上表一致,密钥只显示脱敏值。
+- 只改商户号、密钥留空保存 → 密钥 `masked` 不变。
+- 填写新密钥保存 → `masked` 尾号变化,`source` 变为 `admin`。
+- 白名单清空保存 → 所有会员都可以使用该公司充值(仍受层级组合控制)。
+- 网关填 `abc` → 返回错误,配置不变。
+- 有该公司未完成订单时修改商户号或密钥 → 明确拒绝,旧订单合法回调仍可验签;其他公司的订单不阻止本公司修改。
+- 处理完未完成订单后修改凭证 → 保存成功,下一次操作使用新配置。

+ 36 - 0
docs/前端接口/活动/活动彩票分类合并-前端变更说明.md

@@ -0,0 +1,36 @@
+# 活动彩票分类合并:只保留 F1彩票
+
+本说明更新活动相关接口的分类选项:删除 `third:3`(彩票),保留 `self:lottery`(F1彩票)。历史说明中活动包含“彩票”的选项示例以本说明为准。
+
+## 影响接口
+
+| 接口 | 变化 |
+| --- | --- |
+| 会员 `GET /api/index/activity` | `data.extend.game_types` 不再返回“彩票”;`game_category` 筛选只接受保留的分类 |
+| 会员 `GET /api/index/activityInfo` | 迁移后的历史彩票活动返回 `game_category=self:lottery`、`game_category_name=F1彩票` |
+| 后台 `GET /admin/ActivityReward/options` | `data.game_types` 不再返回“彩票” |
+| 后台 `GET /admin/ActivityReward` | `game_category=third:3` 不再是合法筛选条件 |
+| 后台 `POST /admin/ActivityReward/submit` | 新增、编辑均不再接受 `game_category=third:3`,请选择 `self:lottery` |
+
+活动分类选项共 10 项,顺序如下;会员接口每项仍包含现有 `icon` 字段:
+
+```json
+[
+  {"value":"hot","label":"热门"},
+  {"value":"third:1","label":"真人"},
+  {"value":"third:2","label":"电子"},
+  {"value":"third:4","label":"体育"},
+  {"value":"third:5","label":"电竞"},
+  {"value":"third:6","label":"捕鱼"},
+  {"value":"third:7","label":"棋牌"},
+  {"value":"self:sport","label":"F1体育"},
+  {"value":"self:lottery","label":"F1彩票"},
+  {"value":"other","label":"其他"}
+]
+```
+
+前端按接口返回值渲染分类,不再硬编码或补入 `third:3`。已有本地保存的 `third:3` 筛选值改为 `self:lottery`;新增、编辑表单同样使用保留的值。
+
+F1彩票继续使用现有彩票分类图标,PC、H5、APP 的图标选择规则保持原样。历史活动由后端迁移归入 F1彩票,活动记录、图片、点击行为、奖励参数和资格规则均保留。
+
+返佣规则及游戏平台自身的分类不属于这次调整范围,其分类键、统计与计佣方式保持原样。

+ 82 - 0
docs/后端/支付公司商户配置-部署说明.md

@@ -0,0 +1,82 @@
+# 支付公司商户配置:后端部署说明
+
+适用项目:bot-28。前端接口和交互见[支付公司商户配置-前端接入文档](../前端接口/支付配置/支付公司商户配置-前端接入文档.md)。本次无需修改会员 API,也不新增后台常驻进程。
+
+## 首次部署
+
+1. 备份数据库并保留当前环境中的 `APP_KEY`。商户配置使用它加密,所有处理支付的 Web 实例、队列和计划任务必须使用相同的密钥。
+2. 更新代码后,在 bot-28 项目根目录执行迁移:
+
+   ```bash
+   php artisan migrate --force
+   ```
+
+   新迁移 `2026_11_01_120000_add_config_to_payment_providers.php` 给 `payment_providers` 表新增可空的 `config` 列。它不自动导入或覆盖现有商户配置。
+
+3. 由运营管理员打开支付公司配置。未保存过的字段继续使用现有服务器配置或内置默认网关;密钥不回显明文。
+4. 按支付公司逐一核对并保存。后台保存后的下一次操作读取新值,不需要清缓存或为此重启队列。
+
+不要重新生成 `APP_KEY`。修改服务器 `.env` 默认值时,仍按项目既有配置缓存和进程更新流程处理;后台保存的字段优先级高于服务器默认值。
+
+## 从服务器环境配置导入后台
+
+未保存的字段本来就会自动沿用服务器配置,不要求必须导入。需要将当前支付配置固化到后台数据库时,在完成上述迁移后执行:
+
+```bash
+# 预览全部公司:只显示字段名,不写数据库、不输出配置值或密钥
+php artisan payment:config-import-env
+
+# 实际导入全部公司
+php artisan payment:config-import-env --write
+
+# 只导入 JD;--company 可重复指定
+php artisan payment:config-import-env --company=jd --write
+```
+
+公司代码为 `sanjin`、`qianbao`、`jd`、`no`、`zimu`,不指定则处理全部。
+
+导入规则:
+
+- 读取当前生效的 Laravel 支付配置,包括 `.env` 对应值及内置默认网关;不是直接导出原始 `.env` 文件。如果刚修改 `.env`,先执行 `php artisan config:cache`,并确认处理支付的实例使用一致的配置,再预览和导入。
+- 只补齐后台尚未保存的非空字段。已有后台值一律保留,包括明确保存为空的充值白名单;服务器也未配置的空字段跳过。
+- 密钥按当前生效值原样复制并加密存储,不去除可能影响签名的前后空格。终端仅显示字段名,操作日志中的密钥脱敏。
+- 实际生效的商户号、密钥、网关和白名单不变,因此有未完成订单时也可执行此导入。后续真正更换凭证仍受下节的在途订单保护。
+- 重复执行不会覆盖或重复记录已导入字段。整批导入放在同一事务中,任一公司读取、解密、保存或审计失败时整批回滚。
+- 修改日志使用 `resource_type=payment_company_config`、`action=import_env`,操作人标记为 `console:payment:config-import-env`。
+
+## 更换商户号或密钥
+
+1. 通过现有公司启停接口 `POST /admin/paymentCompany/status` 将该公司停用,阻止新的充值和代付。
+2. 按既有流程处理该公司的未完成订单。公司停用不阻止回调和查询。充值、代付中除成功、失败、已撤回之外的订单均视为未完成;人工充值和人工提现记录不计入此限制。
+3. 未完成订单清理完毕后保存新商户号、商户 AppID 或密钥,再启用公司。
+
+有未完成订单时,改变实际生效的商户凭证会返回 `code=-3`,整次保存不写入,也不产生成功修改日志。原值保存、密钥留空、充值白名单和网关地址编辑不受此限制。
+
+## 一致性与故障处理
+
+- 每次下单、代付、查询或验签读取一份完整配置快照,该次操作不会混用不同版本的商户号、密钥和网关。
+- 支付读取使用短事务共享锁,后台保存使用排他锁;已有外层事务中的共享锁保留到提交。多个支付读取可以并行,保存须等待已有事务结束后检查未完成订单。
+- 数据库读取失败、加密配置损坏或密钥不匹配时,操作会明确失败并记录 `payment_provider_config_read_failed`,不会静默切回 `.env`。检查数据库连接、配置列及各实例使用的加密密钥;不要通过清空配置绕过报错。
+- 商户配置在数据库中加密保存,操作日志中的密钥脱敏。常规支付公司列表不返回 `config`。
+
+## 验证
+
+相关测试使用隔离 SQLite 与模拟支付传输,不连接真实支付公司:
+
+```bash
+vendor/bin/phpunit --no-configuration --bootstrap tests/bootstrap-agent.php \
+  tests/Integration/PaymentProviderConfigImportTest.php \
+  tests/Integration/PaymentProviderConfigTest.php \
+  tests/Integration/PaymentProviderSnapshotTest.php \
+  tests/Integration/PaymentChannelOptionsTest.php \
+  tests/Unit/NoPayServiceTest.php tests/Unit/ZimuPayServiceTest.php
+```
+
+MySQL 并发检查为显式启用的独立脚本,要求事先创建仅供测试、禁用网络的临时 MySQL 实例,脚本只接受 `/tmp/b28paycfg-*.sock`:
+
+```bash
+PAYMENT_TEST_MYSQL_SOCKET=/tmp/b28paycfg-test.sock \
+  php tests/Regression/payment_provider_config_concurrency.php
+```
+
+该脚本会在测试实例的 `bot28_payment_config_test` 数据库内创建和重建测试表,覆盖可重复读及读已提交两种隔离级别的竞争场景。禁止将它接到业务数据库。真实商户的下单、支付、回调、代付和查询仍需在受控环境完成联调。

+ 35 - 0
docs/后端/活动彩票分类合并-部署说明.md

@@ -0,0 +1,35 @@
+# 活动彩票分类合并:后端部署
+
+本次需更新 bot-28 和 melbet_sport-api 两个项目的活动分类代码:活动仅保留 `self:lottery`(F1彩票),移除 `third:3`(彩票)选项。接口变更见[前端变更说明](../前端接口/活动/活动彩票分类合并-前端变更说明.md)。
+
+在 bot-28 项目根目录执行迁移:
+
+```bash
+php artisan migrate --force
+```
+
+新增迁移:`2026_11_02_120000_merge_activity_lottery_category.php`。请在开放新版活动接口前完成迁移,并按既有流程发布两个项目。
+
+迁移仅将共享 `activity_rewards` 表中 `game_category=third:3` 的记录改为 `self:lottery`;数据库表前缀由 Laravel 配置处理。不删除活动,不修改 `params`、奖励、预算、份数、资格条件、平台、排序、图片、链接或时间字段。重复执行不会产生额外变化。
+
+回滚代码时保留已归并的 `self:lottery` 值;迁移的 `down` 不反向修改记录,避免将原有 F1彩票活动误改回旧分类。旧版代码本身已识别 `self:lottery`。
+
+部署后确认:
+
+- 会员活动列表 `data.extend.game_types` 和后台活动选项中均只有 F1彩票,没有独立“彩票”,选项总数为 10。
+- 历史彩票活动能通过 `game_category=self:lottery` 查到,详情名称为 F1彩票,原奖励参数和图片保留。
+- 后台新增、编辑和列表筛选拒绝旧值 `third:3`;会员活动列表同样拒绝旧筛选值。
+- 返佣九类游戏和游戏入口自身的分类未改变。
+
+本地验证命令:
+
+```bash
+# bot-28:后台接口、迁移和活动资格回归
+vendor/bin/phpunit --no-configuration --bootstrap tests/bootstrap-agent.php \
+  tests/Integration/ActivityCatalogTest.php tests/Integration/ActivityRewardEligibilityTest.php
+
+# melbet_sport-api:在该项目根目录执行会员列表、详情、筛选和图标回归
+php tests/activity_click_integration.php
+```
+
+这些测试使用隔离 SQLite,不连接业务数据库。生产迁移和生产接口联调需在发布时执行。

+ 2 - 0
routes/admin.php

@@ -425,6 +425,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/paymentCompany')->group(function () {
             Route::get('/list', [\App\Http\Controllers\admin\PaymentCompany::class, 'list']);
             Route::post('/status', [\App\Http\Controllers\admin\PaymentCompany::class, 'status']);
+            Route::get('/config', [\App\Http\Controllers\admin\PaymentCompany::class, 'config'])->middleware('operations.admin');
+            Route::post('/config/save', [\App\Http\Controllers\admin\PaymentCompany::class, 'saveConfig'])->middleware('operations.admin');
         });
 
         Route::prefix('/user')->group(function () {

+ 36 - 6
tests/Integration/ActivityCatalogTest.php

@@ -169,7 +169,7 @@ class ActivityCatalogTest extends TestCase
         }
     }
 
-    public function test_options_match_all_shared_game_categories_and_five_activity_types_on_an_authenticated_route(): void
+    public function test_activity_options_keep_only_f1_lottery_without_changing_financial_categories(): void
     {
         $this->rawActivity(['type' => 1]);
         $before = DB::table('activity_rewards')->get()->toJson();
@@ -183,11 +183,16 @@ class ActivityCatalogTest extends TestCase
         $this->assertCount(9, ShareDefaults::categories());
         $this->assertArrayNotHasKey('other', ShareDefaults::categories());
         $this->assertArrayNotHasKey('hot', ShareDefaults::categories());
-        $this->assertSame(['hot' => '热门'] + $financialCategories + ['other' => '其他'], ActivityCatalog::gameTypes());
+        $activityCategories = $financialCategories;
+        unset($activityCategories['third:3']);
+        $this->assertSame(['hot' => '热门'] + $activityCategories + ['other' => '其他'], ActivityCatalog::gameTypes());
         $expectedGames = [];
-        foreach (['hot' => '热门'] + $financialCategories + ['other' => '其他'] as $value => $label) $expectedGames[] = compact('value', 'label');
-        $this->assertCount(11, $expectedGames);
+        foreach (['hot' => '热门'] + $activityCategories + ['other' => '其他'] as $value => $label) $expectedGames[] = compact('value', 'label');
+        $this->assertCount(10, $expectedGames);
         $this->assertSame($expectedGames, $body['data']['game_types']);
+        $this->assertNotContains('third:3', array_column($body['data']['game_types'], 'value'));
+        $this->assertNotContains('彩票', array_column($body['data']['game_types'], 'label'));
+        $this->assertContains(['value' => 'self:lottery', 'label' => 'F1彩票'], $body['data']['game_types']);
         $this->assertNotContains('', array_column($body['data']['game_types'], 'value'));
         $this->assertNotContains('wallet', array_column($body['data']['game_types'], 'value'));
         $this->assertNotContains('all', array_column($body['data']['game_types'], 'value'));
@@ -373,7 +378,7 @@ class ActivityCatalogTest extends TestCase
                 $this->assertSame($label, $row['game_category_name']);
             }
         }
-        $this->assertSame(22, $this->listing()['total']);
+        $this->assertSame(20, $this->listing()['total']);
     }
 
     public function test_other_category_is_preserved_on_omitted_edit_and_can_be_cleared_for_type_two_and_three(): void
@@ -410,7 +415,7 @@ class ActivityCatalogTest extends TestCase
     {
         $id = $this->rawActivity(['game_category' => 'third:1']);
         $before = DB::table('activity_rewards')->get()->toJson();
-        foreach (['unknown', 'third:8', 'wallet', 'all', '其他', '1', 1, ['third:1']] as $category) {
+        foreach (['unknown', 'third:3', 'third:8', 'wallet', 'all', '其他', '1', 1, ['third:1']] as $category) {
             foreach ([2, 3] as $type) {
                 $result = $this->save(['id' => $id, 'type' => $type, 'title' => '不应保存', 'game_category' => $category]);
                 $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
@@ -526,6 +531,31 @@ class ActivityCatalogTest extends TestCase
         $this->assertSame([2, 3, 4, 5, 6], array_column($options, 'value'));
     }
 
+    public function test_lottery_merge_migration_preserves_activities_and_their_business_settings(): void
+    {
+        $ordinary = $this->rawActivity(['type' => 2, 'game_category' => 'third:3', 'params' => '{"reward_ratio":5,"game_category":"third:3"}']);
+        $promotion = $this->rawActivity(['type' => 3, 'game_category' => 'third:3', 'sort' => 21, 'show_ineligible' => false]);
+        $existingF1 = $this->rawActivity(['game_category' => 'self:lottery']);
+        $unrelated = $this->rawActivity(['game_category' => 'third:1']);
+        $before = DB::table('activity_rewards')->orderBy('id')->get()->keyBy('id');
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_02_120000_merge_activity_lottery_category.php';
+        $migration->up();
+        $this->assertSame(0, DB::table('activity_rewards')->where('game_category', 'third:3')->count());
+        foreach ([$ordinary, $promotion, $existingF1, $unrelated] as $id) {
+            $expected = (array) $before[$id];
+            if (in_array($id, [$ordinary, $promotion], true)) $expected['game_category'] = 'self:lottery';
+            $this->assertSame($expected, (array) DB::table('activity_rewards')->where('id', $id)->first());
+        }
+        $result = $this->listing(['game_category' => 'self:lottery']);
+        $this->assertSame(3, $result['total']);
+        foreach ($result['data'] as $row) $this->assertSame('F1彩票', $row['game_category_name']);
+        $this->assertSame(HttpStatus::OK, $this->save(['game_category' => 'self:lottery'])['code']);
+        $this->assertSame(4, $this->listing(['game_category' => 'self:lottery'])['total']);
+        $snapshot = DB::table('activity_rewards')->orderBy('id')->get()->toJson();
+        $migration->up(); $migration->down();
+        $this->assertSame($snapshot, DB::table('activity_rewards')->orderBy('id')->get()->toJson());
+    }
+
     public function test_category_migration_adds_nullable_index_without_changing_existing_rows_and_rolls_back(): void
     {
         $this->migration()->down();

+ 144 - 0
tests/Integration/PaymentProviderConfigImportTest.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\ImportPaymentProviderConfig;
+use App\Services\Payment\JdPayService;
+use App\Services\Payment\PaymentProviderConfig;
+use Illuminate\Encryption\Encrypter;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Symfony\Component\Console\Tester\CommandTester;
+use Tests\Support\PaymentProviderTestCase;
+
+class PaymentProviderConfigImportTest extends PaymentProviderTestCase
+{
+    private function command(array $options = []): CommandTester
+    {
+        $command = new ImportPaymentProviderConfig();
+        $command->setLaravel($this->app);
+        $tester = new CommandTester($command);
+        $tester->execute($options);
+        return $tester;
+    }
+
+    private function configs(): array
+    {
+        return DB::table('payment_providers')->orderBy('code')->pluck('config', 'code')->all();
+    }
+
+    public function test_default_preview_lists_only_field_names_and_never_writes_or_discloses_values(): void
+    {
+        $before = $this->configs();
+        $result = $this->command();
+        $this->assertSame(0, $result->getStatusCode());
+        $output = $result->getDisplay();
+        $this->assertStringContainsString('未写入数据库', $output);
+        $this->assertStringContainsString('mch_id', $output);
+        $this->assertStringContainsString('config:cache', $output);
+        foreach (['sanjin', 'qianbao', 'jd', 'no', 'zimu'] as $code) $this->assertStringContainsString($code, $output);
+        foreach (['env-jd-mch', 'env-jd-secret-1234', 'https://env.example.com/pay'] as $value) $this->assertStringNotContainsString($value, $output);
+        $this->assertSame($before, $this->configs());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public function test_selected_company_import_preserves_exact_active_credentials_and_pending_callbacks(): void
+    {
+        config(['app.jd_pay_key' => '  fixture-key-with-spaces  ', 'app.jd_pay_recharge_user_ids' => ' 7, 8 ']);
+        $before = PaymentProviderConfig::snapshot('jd');
+        DB::table('payment_orders')->insert(['type' => 1, 'channel' => 'JDpay', 'status' => 1]);
+        $callback = ['userCode' => JdPayService::getMerchantId(), 'orderCode' => 'pending-order', 'amount' => '100.00', 'status' => '3'];
+        $callback['sign'] = JdPayService::signature([$callback['orderCode'], $callback['amount'], $callback['userCode'], $callback['status']]);
+        $result = $this->command(['--company' => ['jd'], '--write' => true]);
+        $this->assertSame(0, $result->getStatusCode());
+        $this->assertSame($before, PaymentProviderConfig::snapshot('jd'));
+        $this->assertTrue(JdPayService::verifyPayNotify($callback));
+        $this->assertSame('  fixture-key-with-spaces  ', JdPayService::getSecret());
+        $raw = $this->configs();
+        $this->assertNotNull($raw['jd']);
+        foreach (['no', 'sanjin', 'qianbao', 'zimu'] as $code) $this->assertNull($raw[$code]);
+        $this->assertStringNotContainsString('fixture-key-with-spaces', $raw['jd']);
+        $this->assertStringNotContainsString('fixture-key-with-spaces', $result->getDisplay());
+        $audit = DB::table('operation_audits')->first();
+        $this->assertSame('import_env', $audit->action);
+        $this->assertSame('console:payment:config-import-env', $audit->operator_name);
+        $changes = json_decode($audit->changes, true, 512, JSON_THROW_ON_ERROR);
+        $this->assertSame('******', $changes['after']['key']);
+    }
+
+    public function test_existing_admin_values_including_empty_allowlist_win_and_repeated_import_is_idempotent(): void
+    {
+        (new PaymentProviderConfig())->save('jd', ['mch_id' => 'admin-merchant', 'key' => 'admin-secret', 'recharge_user_ids' => '']);
+        config(['app.jd_pay_recharge_user_ids' => '99']);
+        $before = PaymentProviderConfig::snapshot('jd');
+        $result = $this->command(['--company' => ['jd'], '--write' => true]);
+        $this->assertSame(0, $result->getStatusCode());
+        $this->assertSame($before, PaymentProviderConfig::snapshot('jd'));
+        $this->assertSame('admin-merchant', JdPayService::getMerchantId());
+        $this->assertSame('admin-secret', JdPayService::getSecret());
+        $this->assertSame('', PaymentProviderConfig::get('jd', 'recharge_user_ids'));
+        $raw = $this->configs();
+        $audits = DB::table('operation_audits')->count();
+        $this->assertSame(0, $this->command(['--company' => ['jd', 'jd'], '--write' => true])->getStatusCode());
+        $this->assertSame($raw, $this->configs());
+        $this->assertSame($audits, DB::table('operation_audits')->count());
+    }
+
+    public function test_bulk_import_copies_only_nonempty_defaults_and_changes_no_effective_values(): void
+    {
+        $before = [];
+        foreach (array_keys($this->configs()) as $code) $before[$code] = PaymentProviderConfig::snapshot($code);
+        $this->assertSame(0, $this->command(['--write' => true])->getStatusCode());
+        foreach ($before as $code => $snapshot) $this->assertSame($snapshot, PaymentProviderConfig::snapshot($code));
+        $fields = array_column((new PaymentProviderConfig())->show('jd')['fields'], null, 'field');
+        $this->assertSame('admin', $fields['mch_id']['source']);
+        $this->assertSame('admin', $fields['key']['source']);
+        $this->assertSame('empty', $fields['pay_query_gateway']['source']);
+        $this->assertNull($this->configs()['zimu']);
+        $this->assertSame('7,8', PaymentProviderConfig::get('no', 'recharge_user_ids'));
+    }
+
+    public function test_invalid_company_is_rejected_without_writing(): void
+    {
+        $before = $this->configs();
+        $result = $this->command(['--company' => ['unknown'], '--write' => true]);
+        $this->assertSame(1, $result->getStatusCode());
+        $this->assertStringContainsString('代码无效', $result->getDisplay());
+        $this->assertSame($before, $this->configs());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public function test_unreadable_later_company_rolls_back_the_whole_batch(): void
+    {
+        (new PaymentProviderConfig())->save('jd', ['mch_id' => 'admin-merchant', 'key' => 'admin-secret']);
+        $before = $this->configs();
+        $audits = DB::table('operation_audits')->count();
+        $this->app->instance('encrypter', new Encrypter(str_repeat('x', 32), 'AES-256-CBC'));
+        Facade::clearResolvedInstance('encrypter');
+        $result = $this->command(['--write' => true]);
+        $this->assertSame(1, $result->getStatusCode());
+        $this->assertSame($before, $this->configs());
+        $this->assertSame($audits, DB::table('operation_audits')->count());
+        $this->assertStringNotContainsString('MAC', $result->getDisplay());
+        $this->assertStringNotContainsString('admin-secret', $result->getDisplay());
+    }
+
+    public function test_missing_schema_returns_a_controlled_failure(): void
+    {
+        DB::statement('DROP TABLE bot_payment_providers');
+        $result = $this->command(['--write' => true]);
+        $this->assertSame(1, $result->getStatusCode());
+        $this->assertStringContainsString('导入失败', $result->getDisplay());
+        $this->assertStringNotContainsString('SQLSTATE', $result->getDisplay());
+    }
+
+    public function test_audit_failure_rolls_back_imported_secrets(): void
+    {
+        $before = $this->configs();
+        DB::statement('DROP TABLE bot_operation_audits');
+        $result = $this->command(['--company' => ['jd'], '--write' => true]);
+        $this->assertSame(1, $result->getStatusCode());
+        $this->assertSame($before, $this->configs());
+        $this->assertStringNotContainsString('env-jd-secret-1234', $result->getDisplay());
+    }
+}

+ 173 - 0
tests/Integration/PaymentProviderConfigTest.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
+use App\Services\Payment\PaymentProviderConfig;
+use App\Services\Payment\SanJinService;
+use App\Services\Payment\ZimuPayService;
+use Illuminate\Contracts\Encryption\DecryptException;
+use Illuminate\Encryption\Encrypter;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use RuntimeException;
+use Tests\Support\PaymentProviderTestCase;
+
+class PaymentProviderConfigTest extends PaymentProviderTestCase
+{
+    public function test_unsaved_fields_fall_back_to_env_and_secrets_are_masked(): void
+    {
+        $result = $this->call('config', '/admin/paymentCompany/config', ['payment_company' => 'jd']);
+        $this->assertSame(0, $result['code']);
+        $fields = array_column($result['data']['fields'], null, 'field');
+        $this->assertSame('env-jd-mch', $fields['mch_id']['value']);
+        $this->assertSame('env', $fields['mch_id']['source']);
+        $this->assertSame('', $fields['key']['value']);
+        $this->assertTrue($fields['key']['has_value']);
+        $this->assertSame('******1234', $fields['key']['masked']);
+        $this->assertSame('empty', $fields['recharge_user_ids']['source']);
+        $this->assertSame('env-jd-mch', JdPayService::getMerchantId());
+    }
+
+    public function test_saved_values_take_effect_immediately_and_blank_secret_keeps_existing(): void
+    {
+        $result = $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'jd', 'config' => [
+            'mch_id' => ' db-mch ', 'key' => 'db-secret-9999', 'pay_gateway' => 'https://db.example.com/pay',
+        ]]);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame('db-mch', JdPayService::getMerchantId());
+        $this->assertSame('db-secret-9999', JdPayService::getSecret());
+        $this->assertSame('https://db.example.com/pay', PaymentProviderConfig::get('jd', 'pay_gateway'));
+        // 未提交字段继续回退 .env
+        $fields = array_column((new PaymentProviderConfig())->show('jd')['fields'], null, 'field');
+        $this->assertSame('admin', $fields['mch_id']['source']);
+        $this->assertNotSame('admin', $fields['pay_query_gateway']['source']);
+
+        $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'jd', 'config' => ['key' => '', 'mch_id' => 'db-mch-2']]);
+        $this->assertSame('db-secret-9999', JdPayService::getSecret());
+        $this->assertSame('db-mch-2', JdPayService::getMerchantId());
+
+        $raw = DB::table('payment_providers')->where('code', 'jd')->value('config');
+        $this->assertStringNotContainsString('db-secret-9999', $raw);
+        $audit = DB::table('operation_audits')->where('resource_type', 'payment_company_config')->orderByDesc('id')->value('changes');
+        $this->assertStringNotContainsString('db-secret-9999', $audit);
+        $this->assertSame(2, DB::table('operation_audits')->where('resource_type', 'payment_company_config')->count());
+    }
+
+    public function test_recharge_whitelist_can_be_cleared_from_admin_to_allow_everyone(): void
+    {
+        $this->assertFalse(NoPayService::canUserRecharge(9));
+        // 全角逗号不合法
+        $this->assertNotSame(0, $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'no', 'config' => ['recharge_user_ids' => '9,10']])['code']);
+        $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'no', 'config' => ['recharge_user_ids' => ' 9 , ,10']]);
+        $this->assertSame(['9', '10'], PaymentProviderConfig::userIds('no'));
+        $this->assertTrue(NoPayService::canUserRecharge(9));
+        $this->assertFalse(NoPayService::canUserRecharge(7));
+        $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'no', 'config' => ['recharge_user_ids' => '']]);
+        $this->assertTrue(NoPayService::canUserRecharge(7));
+    }
+
+    public function test_invalid_input_is_rejected_without_writing(): void
+    {
+        $cases = [
+            ['payment_company' => 'zimu', 'config' => ['pay_gateway' => 'not a url']],
+            ['payment_company' => 'zimu', 'config' => ['recharge_user_ids' => 'abc']],
+            ['payment_company' => 'zimu', 'config' => ['unknown' => 'x']],
+            ['payment_company' => 'zimu', 'config' => ['app_id' => ['nested']]],
+            ['payment_company' => 'nope', 'config' => ['app_id' => 'x']],
+        ];
+        foreach ($cases as $params) {
+            $this->assertNotSame(0, $this->call('saveConfig', '/admin/paymentCompany/config/save', $params)['code']);
+        }
+        $this->assertNull(DB::table('payment_providers')->where('code', 'zimu')->value('config'));
+        $this->assertSame('', ZimuPayService::getAppId());
+    }
+
+    public function test_sanjin_gateway_is_editable_with_builtin_default(): void
+    {
+        $this->assertSame('https://jkapi-sanjin.jkcbb.com/', (string)SanJinService::getClient()->getConfig('base_uri'));
+        $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'sanjin', 'config' => ['gateway' => 'https://new.example.com/v1']]);
+        $this->assertSame('https://new.example.com/v1/', (string)SanJinService::getClient()->getConfig('base_uri'));
+    }
+
+    public function test_unreadable_config_never_falls_back_to_env_or_opens_the_allowlist(): void
+    {
+        $config = new PaymentProviderConfig();
+        $config->save('jd', ['mch_id' => 'active-merchant', 'key' => 'active-secret']);
+        $config->save('no', ['recharge_user_ids' => '9']);
+        $this->app->instance('encrypter', new Encrypter(str_repeat('x', 32), 'AES-256-CBC'));
+        Facade::clearResolvedInstance('encrypter');
+        foreach ([fn () => JdPayService::getMerchantId(), fn () => NoPayService::canUserRecharge(7)] as $read) {
+            try {
+                $read();
+                $this->fail('An unreadable override must stop the operation.');
+            } catch (RuntimeException $e) {
+                $this->assertSame('支付商户配置读取失败,请联系管理员', $e->getMessage());
+                $this->assertInstanceOf(DecryptException::class, $e->getPrevious());
+            }
+        }
+        $response = $this->call('config', '/admin/paymentCompany/config', ['payment_company' => 'jd']);
+        $this->assertSame(-3, $response['code']);
+        $this->assertStringNotContainsString('MAC', $response['msg']);
+    }
+
+    public function test_database_read_failure_is_not_treated_as_unsaved_config(): void
+    {
+        DB::statement('DROP TABLE bot_payment_providers');
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('支付商户配置读取失败');
+        PaymentProviderConfig::snapshot('jd');
+    }
+
+    public function test_pending_orders_block_credential_changes_for_all_providers_and_directions(): void
+    {
+        foreach ([['jd', 'mch_id', 'key', 'JDpay', 1], ['jd', 'mch_id', 'key', 'JD钱包', 2],
+            ['no', 'deposit_mch_id', 'deposit_key', 'NOpay12', 1], ['no', 'withdraw_mch_id', 'withdraw_key', 'NOwithdraw', 2],
+            ['zimu', 'app_id', 'key', '808pay', 1], ['zimu', 'app_id', 'key', 'ZIMUcash', 2],
+            ['sanjin', 'mch_id', 'key', 'ylsm', 1], ['qianbao', 'merchant_id', 'secret', 'DF001', 2]] as [$code, $merchant, $secret, $channel, $type]) {
+            DB::table('payment_orders')->delete();
+            (new PaymentProviderConfig())->save($code, [$merchant => 'original', $secret => 'original-secret']);
+            $audits = DB::table('operation_audits')->count();
+            foreach ([0, 1, 4, 5] as $status) {
+                DB::table('payment_orders')->delete();
+                DB::table('payment_orders')->insert(compact('type', 'channel', 'status'));
+                foreach ([$merchant, $secret] as $field) {
+                    $result = $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => $code, 'config' => [$field => 'changed']]);
+                    $this->assertSame(-3, $result['code']);
+                    $this->assertStringContainsString('存在未完成订单', $result['msg']);
+                    $this->assertSame('original', PaymentProviderConfig::get($code, $merchant));
+                    $this->assertSame('original-secret', PaymentProviderConfig::get($code, $secret));
+                    $this->assertSame($audits, DB::table('operation_audits')->count());
+                }
+            }
+        }
+    }
+
+    public function test_rejected_merchant_change_preserves_a_valid_pending_order_callback(): void
+    {
+        $config = new PaymentProviderConfig();
+        $config->save('jd', ['mch_id' => 'merchant-A', 'key' => 'secret-A']);
+        $callback = ['userCode' => 'merchant-A', 'orderCode' => 'pending-order', 'amount' => '100.00', 'status' => '3'];
+        $callback['sign'] = JdPayService::signature([$callback['orderCode'], $callback['amount'], $callback['userCode'], $callback['status']]);
+        DB::table('payment_orders')->insert(['type' => 1, 'channel' => 'JDpay', 'status' => 1]);
+        $result = $this->call('saveConfig', '/admin/paymentCompany/config/save', ['payment_company' => 'jd', 'config' => ['mch_id' => 'merchant-B', 'key' => 'secret-B']]);
+        $this->assertSame(-3, $result['code']);
+        $this->assertTrue(JdPayService::verifyPayNotify($callback));
+        // 白名单、修正网关及原值保存不会中断已有订单。
+        $config->save('jd', ['mch_id' => 'merchant-A', 'key' => '', 'recharge_user_ids' => '9', 'pay_gateway' => 'https://new.example.test/pay']);
+        $this->assertTrue(JdPayService::verifyPayNotify($callback));
+        DB::table('payment_orders')->update(['status' => 2]);
+        $config->save('jd', ['mch_id' => 'merchant-B', 'key' => 'secret-B']);
+        $this->assertSame('merchant-B', JdPayService::getMerchantId());
+    }
+
+    public function test_terminal_manual_and_other_provider_orders_do_not_block_updates(): void
+    {
+        foreach ([2, 3, 6] as $status) DB::table('payment_orders')->insert(['type' => 1, 'channel' => 'JDpay', 'status' => $status]);
+        foreach ([3, 4] as $type) DB::table('payment_orders')->insert(['type' => $type, 'channel' => 'JDpay', 'status' => 0]);
+        DB::table('payment_orders')->insert(['type' => 2, 'channel' => 'NOwithdraw', 'status' => 1]);
+        (new PaymentProviderConfig())->save('jd', ['mch_id' => 'new-merchant', 'key' => 'new-secret']);
+        $this->assertSame('new-merchant', JdPayService::getMerchantId());
+    }
+}

+ 84 - 0
tests/Integration/PaymentProviderSnapshotTest.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
+use App\Services\Payment\PaymentProviderConfig;
+use App\Services\Payment\QianBaoService;
+use App\Services\Payment\SanJinService;
+use App\Services\Payment\ZimuPayService;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\PreserveGlobalState;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
+use Tests\Support\RecordingProviderClient;
+
+class PaymentProviderSnapshotTest extends \Tests\Support\PaymentProviderTestCase
+{
+    public static function providers(): array
+    {
+        return array_combine(['jd', 'no', 'sanjin', 'qianbao', 'zimu'], array_map(fn ($code) => [$code], ['jd', 'no', 'sanjin', 'qianbao', 'zimu']));
+    }
+
+    #[DataProvider('providers')]
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_each_payment_request_uses_one_snapshot_and_the_next_request_sees_new_config(string $code): void
+    {
+        // Replace only the transport; execute the real provider payload and signing code.
+        $this->assertFalse(class_exists(\GuzzleHttp\Client::class, false));
+        class_alias(RecordingProviderClient::class, \GuzzleHttp\Client::class);
+        $old = $new = [];
+        foreach (PaymentProviderConfig::fields($code) as $field => $definition) {
+            $old[$field] = match ($definition['type']) {
+                'url' => 'https://merchant-a.example.test/', 'user_ids' => '', 'secret' => 'secret-A', default => 'merchant-A',
+            };
+            $new[$field] = match ($definition['type']) {
+                'url' => 'https://merchant-b.example.test/', 'user_ids' => '', 'secret' => 'secret-B', default => 'merchant-B',
+            };
+        }
+        $config = new PaymentProviderConfig();
+        $config->save($code, $old);
+        config(['app.url' => 'https://local.example.test']);
+        DB::connection()->setEventDispatcher(new Dispatcher(Facade::getFacadeApplication()));
+        $switched = false;
+        DB::listen(function ($event) use (&$switched, $config, $code, $new) {
+            if (!$switched && str_starts_with($event->sql, 'select') && str_contains($event->sql, 'payment_providers')) {
+                $switched = true;
+                $config->save($code, $new);
+            }
+        });
+        $pay = match ($code) {
+            'jd' => fn () => JdPayService::pay('100', 'order'),
+            'no' => fn () => NoPayService::pay('100', 'order', 'member', 'NOpay12'),
+            'sanjin' => fn () => SanJinService::pay('100', 'order'),
+            'qianbao' => fn () => QianBaoService::payout('100', 'order', 'bank', 'account', 'card'),
+            'zimu' => fn () => ZimuPayService::pay('100', 'order', 'member'),
+        };
+        $pay();
+        $this->assertTrue($switched);
+        $this->assertRequestUses(RecordingProviderClient::$requests[0], $code, 'A');
+        $pay();
+        $this->assertRequestUses(RecordingProviderClient::$requests[1], $code, 'B');
+    }
+
+    private function assertRequestUses(array $request, string $code, string $version): void
+    {
+        $this->assertSame('https://merchant-' . strtolower($version) . '.example.test/', $request['base_uri'] ?? $request['url']);
+        $data = $request['options']['form_params'] ?? $request['options']['json'];
+        $merchantField = match ($code) { 'jd' => 'userCode', 'sanjin' => 'mchId', 'qianbao' => 'merchantNum', default => 'appId' };
+        $this->assertSame('merchant-' . $version, $data[$merchantField]);
+        $key = 'secret-' . $version;
+        $expected = match ($code) {
+            'jd' => JdPayService::signature([$data['orderCode'], $data['amount'], $data['userCode']], $key),
+            'no' => NoPayService::signature($data, $key),
+            'sanjin' => SanJinService::signature(array_diff_key($data, ['sign' => true]), [], $key),
+            'qianbao' => md5($data['merchantNum'] . $data['orderNo'] . $data['amount'] . $key),
+            'zimu' => ZimuPayService::signature($data, $key),
+        };
+        $this->assertSame($expected, $data['sign']);
+    }
+}

+ 15 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -437,6 +437,21 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame('10.12', $saved['reward_amount']);
     }
 
+    public function test_ended_activities_are_taken_off_the_shelf_and_cannot_be_relisted(): void
+    {
+        $ended = $this->activity(8); $running = $this->activity(9);
+        DB::table('promotion_activities')->where('id', $ended)->update(['end_time' => Carbon::parse('2026-10-26 11:59:59')->timestamp]);
+        $this->assertSame(1, PromotionAdminService::expire());
+        $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $ended)->value('status'));
+        $this->assertSame(1, (int) DB::table('promotion_activities')->where('id', $running)->value('status'));
+        $this->assertSame(0, PromotionAdminService::expire());
+        $admin = new PromotionAdminService();
+        $this->invalid(fn () => $admin->status($ended, 1), '活动已结束');
+        $input = ['id' => $ended, 'translations' => $this->texts(), 'type' => 8, 'status' => 1, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-10-26 11:00:00'];
+        $this->invalid(fn () => $admin->save($input), '活动已结束');
+        $this->assertSame(0, (int) $admin->save(['status' => 0] + $input)['status']);
+    }
+
     public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
     {
         $this->assertSame(2, DB::table('promotion_activities')->count());

+ 6 - 2
tests/Integration/RechargeBonusTest.php

@@ -48,7 +48,7 @@ class RechargeBonusTest extends TestCase
             'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER,status INTEGER DEFAULT 0,coin TEXT,net TEXT,txid TEXT,amount TEXT,exchange_rate TEXT,created_at TEXT,updated_at TEXT',
             'recharge_channel' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,data_type INTEGER,type TEXT,name TEXT,sort INTEGER DEFAULT 0,status INTEGER DEFAULT 1,deleted_at TEXT,min TEXT,max TEXT,fixed TEXT,rate TEXT DEFAULT "0.02",created_at TEXT,updated_at TEXT',
             'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,recharge_type TEXT,withdraw_type TEXT,deleted_at TEXT',
-            'payment_providers' => 'id INTEGER PRIMARY KEY,code TEXT,status INTEGER',
+            'payment_providers' => 'id INTEGER PRIMARY KEY,code TEXT,status INTEGER,config TEXT',
             'operation_audits' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT',
             'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
             'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
@@ -67,7 +67,11 @@ class RechargeBonusTest extends TestCase
             DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'from' => $from, 'language' => 'zh']);
             DB::table('wallets')->insert(['member_id' => 'm' . $id]);
         }
-        DB::table('payment_providers')->insert(['id' => 1, 'code' => 'jd', 'status' => 1]);
+        DB::table('payment_providers')->insert([
+            ['id' => 1, 'code' => 'jd', 'status' => 1],
+            ['id' => 3, 'code' => 'no', 'status' => 1],
+            ['id' => 4, 'code' => 'zimu', 'status' => 1],
+        ]);
         $this->configure('JDpay'); $this->configure('rgcz'); $this->configure('usdt');
     }
     protected function tearDown(): void { date_default_timezone_set($this->originalTimezone); Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }

+ 113 - 0
tests/Regression/payment_provider_config_concurrency.php

@@ -0,0 +1,113 @@
+<?php
+
+// Opt-in isolated MySQL only. Never loads .env or connects to the application's database.
+$socket = getenv('PAYMENT_TEST_MYSQL_SOCKET');
+if (!$socket) { echo "SKIP: set PAYMENT_TEST_MYSQL_SOCKET to an isolated test server\n"; exit(0); }
+if (!preg_match('#^/tmp/b28paycfg-[a-z0-9]+\.sock$#D', $socket)) throw new RuntimeException('Use an isolated /tmp/b28paycfg-*.sock server.');
+require dirname(__DIR__, 2) . '/vendor/autoload.php';
+
+use App\Services\Payment\PaymentProviderConfig;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Encryption\Encrypter;
+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 Illuminate\Validation\ValidationException;
+
+$mode = $argv[1] ?? 'parent';
+$dir = $argv[2] ?? sys_get_temp_dir() . '/payment-config-barriers-' . bin2hex(random_bytes(5));
+$case = $argv[3] ?? 'early';
+if ($mode === 'parent') {
+    mkdir($dir, 0700, true);
+    $pdo = new PDO('mysql:unix_socket=' . $socket, 'root', '');
+    $pdo->exec('CREATE DATABASE IF NOT EXISTS bot28_payment_config_test');
+}
+$app = new Application(dirname(__DIR__, 2));
+$app->instance('config', new Repository(['app' => ['locale' => 'zh']]));
+$app->instance('request', Request::create('/'));
+$app->instance('encrypter', new Encrypter(str_repeat('k', 32), 'AES-256-CBC'));
+$app->instance('log', new Psr\Log\NullLogger());
+$translator = new Translator(new ArrayLoader(), 'zh');
+$app->instance('translator', $translator);
+$app->instance('validator', new Factory($translator, $app));
+Facade::setFacadeApplication($app);
+$db = new Manager($app);
+$db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => 'bot28_payment_config_test', 'username' => 'root', 'password' => '', 'prefix' => 'bot_']);
+$db->setAsGlobal(); $db->bootEloquent();
+$app->instance('db', $db->getDatabaseManager());
+DB::statement('SET SESSION innodb_lock_wait_timeout = 8');
+DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL ' . ($case === 'late' ? 'READ COMMITTED' : 'REPEATABLE READ'));
+
+function awaitFile(string $file): void
+{
+    $deadline = microtime(true) + 10;
+    while (!file_exists($file)) {
+        if (microtime(true) > $deadline) throw new RuntimeException('Timed out waiting for ' . basename($file));
+        usleep(10000);
+    }
+}
+function check($actual, $expected, string $label): void
+{
+    if ($actual !== $expected) throw new RuntimeException($label . ': ' . json_encode($actual));
+}
+if ($mode === 'reader') {
+    touch($dir . '/reader-started');
+    if ($case === 'early') DB::beginTransaction();
+    DB::table('payment_orders')->insert(['type' => 1, 'channel' => 'JDpay', 'status' => 1]);
+    $snapshot = PaymentProviderConfig::snapshot('jd');
+    touch($dir . '/reader-ready');
+    if ($case === 'early') { awaitFile($dir . '/release'); DB::commit(); }
+    file_put_contents($dir . '/reader-result', $snapshot['mch_id']);
+    exit(0);
+}
+if ($mode === 'writer') {
+    touch($dir . '/writer-started');
+    try {
+        if ($case === 'late') DB::beginTransaction();
+        (new PaymentProviderConfig())->save('jd', ['mch_id' => 'merchant-B', 'key' => 'secret-B']);
+        if ($case === 'late') { touch($dir . '/writer-ready'); awaitFile($dir . '/release'); DB::commit(); }
+        file_put_contents($dir . '/writer-result', 'saved');
+    } catch (ValidationException $e) {
+        file_put_contents($dir . '/writer-result', 'blocked');
+    }
+    exit(0);
+}
+
+foreach (['early', 'late'] as $case) {
+    $barrier = $dir . '/' . $case;
+    mkdir($barrier, 0700);
+    foreach (['operation_audits', 'payment_orders', 'payment_providers'] as $table) $db->schema()->dropIfExists($table);
+    DB::statement('CREATE TABLE bot_payment_providers (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,code VARCHAR(32) UNIQUE,status TINYINT DEFAULT 1,config TEXT NULL,created_at DATETIME NULL,updated_at DATETIME NULL) ENGINE=InnoDB');
+    DB::statement('CREATE TABLE bot_payment_orders (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,type INT,channel VARCHAR(32),status INT) ENGINE=InnoDB');
+    DB::statement('CREATE TABLE bot_operation_audits (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,resource_type VARCHAR(64),resource_id BIGINT NULL,action VARCHAR(32),changes JSON,operator_id BIGINT NULL,operator_name VARCHAR(64),created_at DATETIME NULL,updated_at DATETIME NULL) ENGINE=InnoDB');
+    DB::table('payment_providers')->insert(['code' => 'jd']);
+    (new PaymentProviderConfig())->save('jd', ['mch_id' => 'merchant-A', 'key' => 'secret-A']);
+    $start = function (string $worker) use ($barrier, $case) {
+        $process = proc_open([PHP_BINARY, __FILE__, $worker, $barrier, $case], [0 => ['pipe', 'r'], 1 => ['file', $barrier . '/' . $worker . '.log', 'a'], 2 => ['file', $barrier . '/' . $worker . '.log', 'a']], $pipes);
+        if (!is_resource($process)) throw new RuntimeException('Cannot start test worker.');
+        fclose($pipes[0]);
+        return $process;
+    };
+    $workers = [];
+    try {
+        $first = $case === 'early' ? 'reader' : 'writer';
+        $second = $case === 'early' ? 'writer' : 'reader';
+        $workers[] = $start($first);
+        awaitFile($barrier . '/' . $first . '-ready');
+        $workers[] = $start($second);
+        awaitFile($barrier . '/' . $second . '-started');
+        usleep(200000);
+        check(file_exists($barrier . '/' . $second . '-result'), false, 'Concurrent operation waits for configuration lock');
+    } finally {
+        touch($barrier . '/release');
+        foreach ($workers as $worker) check(proc_close($worker), 0, 'Worker exits successfully; inspect ' . $barrier . ' on failure');
+    }
+    check(file_get_contents($barrier . '/reader-result'), $case === 'early' ? 'merchant-A' : 'merchant-B', 'Payment uses a consistent committed configuration');
+    check(file_get_contents($barrier . '/writer-result'), $case === 'early' ? 'blocked' : 'saved', 'Pending-order protection survives concurrent transactions');
+    echo 'PASS ' . $case . " configuration/transaction race\n";
+}

+ 77 - 0
tests/Support/PaymentProviderTestCase.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Support;
+
+use App\Http\Controllers\admin\PaymentCompany;
+use App\Services\Payment\PaymentProviderConfig;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Encryption\Encrypter;
+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\TestCase;
+
+abstract class PaymentProviderTestCase extends TestCase
+{
+    protected Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => [
+            'locale' => 'zh',
+            'jd_pay_mch_id' => 'env-jd-mch',
+            'jd_pay_key' => 'env-jd-secret-1234',
+            'jd_pay_gateway' => 'https://env.example.com/pay',
+            'no_pay_recharge_user_ids' => '7,8',
+        ]]));
+        Facade::setFacadeApplication($this->app);
+        $this->app->instance('encrypter', new Encrypter(str_repeat('k', 32), 'AES-256-CBC'));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $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());
+        $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();
+        });
+        $this->app->instance('request', Request::create('/'));
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_01_120000_add_config_to_payment_providers.php')->up();
+        $db->schema()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->integer('type'); $t->string('channel'); $t->integer('status');
+        });
+        $db->schema()->create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->unsignedBigInteger('resource_id')->nullable();
+            $t->string('action'); $t->json('changes'); $t->unsignedBigInteger('operator_id')->nullable();
+            $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    protected function call(string $method, string $uri, array $params): array
+    {
+        $this->app->instance('request', Request::create($uri, $method === 'config' ? 'GET' : 'POST', $params));
+        return (new PaymentCompany())->{$method}(new PaymentProviderConfig())->getData(true);
+    }
+
+}

+ 15 - 0
tests/Support/RecordingProviderClient.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Tests\Support;
+
+/** Test transport only; provider request building and signing still execute normally. */
+class RecordingProviderClient
+{
+    public static array $requests = [];
+    public function __construct(private array $config = []) {}
+    public function post(string $url, array $options): \GuzzleHttp\Psr7\Response
+    {
+        self::$requests[] = ['url' => $url, 'options' => $options] + $this->config;
+        return new \GuzzleHttp\Psr7\Response(200, [], '{}');
+    }
+}

+ 2 - 2
tests/Unit/NoPayServiceTest.php

@@ -3,9 +3,9 @@
 namespace Tests\Unit;
 
 use App\Services\Payment\NoPayService;
-use PHPUnit\Framework\TestCase;
+use Tests\Support\PaymentProviderTestCase;
 
-class NoPayServiceTest extends TestCase
+class NoPayServiceTest extends PaymentProviderTestCase
 {
     public function test_signature_matches_document_example(): void
     {