|
@@ -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'] ?? '');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|