> */ private const REQUIRED = [ 'alipay' => ['site.base_url', 'alipay.app_id', 'alipay.private_key', 'alipay.public_key'], 'wechat' => [ 'site.base_url', 'wechat.app_id', 'wechat.mch_id', 'wechat.mch_serial_no', 'wechat.mch_private_key', 'wechat.api_v3_key', 'wechat.platform_cert', ], ]; /** @var array */ public const PARAM_FIELDS = [ 'site.base_url' => [ 'label' => 'API 外网地址', 'hint' => '须 HTTPS 外网可达,用于支付 notify 回调', ], 'site.front_base_url' => [ 'label' => '前端站点地址', 'hint' => '支付宝支付完成后的 return_url 跳转', ], 'alipay.app_id' => [ 'label' => '支付宝 APPID', 'hint' => '开放平台应用 ID', ], 'alipay.sandbox' => [ 'label' => '支付宝环境', 'hint' => '沙箱仅联调使用', ], 'wechat.app_id' => [ 'label' => '微信 AppID', 'hint' => '关联支付的公众号/小程序/移动应用 AppID', ], 'wechat.mch_id' => [ 'label' => '微信商户号', 'hint' => '微信支付商户号 mch_id', ], 'wechat.mch_serial_no' => [ 'label' => '商户证书序列号', 'hint' => '商户 API 证书序列号,用于 V3 签名', ], 'wechat.sandbox' => [ 'label' => '微信环境', 'hint' => '当前实现共用正式网关,仅作标记', ], 'payment.display_channels' => [ 'label' => '前端展示渠道', 'hint' => '用户端可选的支付方式,默认仅支付宝', ], ]; /** @return list */ public static function displayChannels(): array { $raw = Config::get('payment.display_channels'); if ($raw === null || $raw === '') { return ['alipay']; } $parts = is_array($raw) ? $raw : (preg_split('/[\s,]+/', (string)$raw, -1, PREG_SPLIT_NO_EMPTY) ?: []); $allowed = ['alipay', 'wechat']; $out = []; foreach ($parts as $p) { $p = strtolower(trim((string)$p)); if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) { $out[] = $p; } } return $out !== [] ? $out : ['alipay']; } public static function assertChannelAllowed(string $channel): void { if (!in_array($channel, self::displayChannels(), true)) { Json::fail('bad_request', '该支付方式暂未开放', 400); } } public static function reloadRuntime(): void { $config = Config::all(); PaymentConfigLoader::merge($config); SettingsConfigLoader::merge($config); Config::init($config); } /** @return array */ public static function paramValues(): array { $out = []; foreach (SettingsConfigLoader::PAYMENT_KEYS as $key) { $out[$key] = self::stringValue($key, Config::get($key)); } return $out; } /** @param list $items */ public static function saveParams(int $adminId, array $items): array { $allowed = array_flip(SettingsConfigLoader::PAYMENT_KEYS); $pdo = Db::pdo(); $stmt = $pdo->prepare( 'INSERT INTO settings (`key`, `value`, updated_at) VALUES (:k, :v, :ts) ' . 'ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), updated_at=VALUES(updated_at)' ); $saved = []; $now = date('Y-m-d H:i:s'); foreach ($items as $item) { if (!is_array($item)) { continue; } $key = trim((string)($item['key'] ?? '')); if ($key === '' || !isset($allowed[$key])) { continue; } $value = trim((string)($item['value'] ?? '')); if ($key === 'site.base_url' && $value !== '' && !str_starts_with(strtolower($value), 'https://')) { Json::fail('bad_request', 'API 外网地址须以 https:// 开头', 400); } if (str_ends_with($key, '.sandbox')) { $value = in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true) ? '1' : '0'; } if ($key === 'payment.display_channels') { $parsed = self::parseDisplayChannelsValue($value); if ($parsed === []) { Json::fail('bad_request', '至少选择一种前端展示支付方式', 400); } $value = implode(',', $parsed); } $stmt->execute(['k' => $key, 'v' => $value, 'ts' => $now]); $saved[] = $key; } if ($saved === []) { Json::fail('bad_request', '无有效配置项', 400); } AuditService::log($adminId, 'payment.config.save', 'payment', ['keys' => $saved]); self::reloadRuntime(); return ['keys' => $saved]; } /** @return array */ public static function adminSnapshot(): array { $baseUrl = rtrim((string)Config::get('site.base_url', ''), '/'); return [ 'params' => self::paramValues(), 'display_channels' => self::displayChannels(), 'readiness' => self::readiness(), 'notify_urls' => [ 'alipay' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/alipay/notify' : '', 'wechat' => $baseUrl !== '' ? $baseUrl . '/api/v1/pay/wechat/notify' : '', ], 'return_url_hint' => rtrim((string)Config::get('site.front_base_url', Config::get('site.base_url', '')), '/') . '/pages/member.web.html?paid={order_no}', ]; } /** @return array}> */ public static function readiness(): array { $out = []; foreach (self::REQUIRED as $channel => $keys) { $missing = []; foreach ($keys as $key) { if (!self::isPresent($key)) { $missing[] = self::missingLabel($key); } } $out[$channel] = ['ready' => $missing === [], 'missing' => $missing]; } return $out; } private static function isPresent(string $key): bool { $val = Config::get($key); if (is_bool($val)) { return true; } if (is_string($val)) { return trim($val) !== ''; } return $val !== null && $val !== ''; } private static function missingLabel(string $key): string { if (isset(self::PARAM_FIELDS[$key])) { return self::PARAM_FIELDS[$key]['label']; } $map = [ 'alipay.private_key' => '应用私钥', 'alipay.public_key' => '支付宝公钥', 'wechat.mch_private_key' => '商户私钥', 'wechat.api_v3_key' => 'APIv3 密钥', 'wechat.platform_cert' => '平台证书', ]; return $map[$key] ?? $key; } private static function stringValue(string $key, mixed $value): string { if (str_ends_with($key, '.sandbox')) { return $value ? '1' : '0'; } if ($key === 'payment.display_channels') { if (is_array($value)) { return implode(',', self::parseDisplayChannelsList($value)); } $parsed = self::parseDisplayChannelsValue((string)($value ?? '')); return $parsed !== [] ? implode(',', $parsed) : 'alipay'; } return is_string($value) ? $value : (string)($value ?? ''); } /** @return list */ private static function parseDisplayChannelsValue(string $value): array { return self::parseDisplayChannelsList( preg_split('/[\s,]+/', trim($value), -1, PREG_SPLIT_NO_EMPTY) ?: [] ); } /** @param list $parts @return list */ private static function parseDisplayChannelsList(array $parts): array { $allowed = ['alipay', 'wechat']; $out = []; foreach ($parts as $p) { $p = strtolower(trim((string)$p)); if ($p !== '' && in_array($p, $allowed, true) && !in_array($p, $out, true)) { $out[] = $p; } } return $out; } }