PaymentProviderCatalog.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <?php
  2. namespace App\Services\Payment;
  3. use InvalidArgumentException;
  4. /** 支付商及协议目录。独立于商户配置表;只登记已实现的支付服务。 */
  5. class PaymentProviderCatalog
  6. {
  7. public function __construct(private ?array $providerStatuses = null)
  8. {
  9. }
  10. private const PROVIDERS = [
  11. 'sanjin' => ['label' => '三斤支付', 'directions' => [1], 'signature' => 'MD5'],
  12. 'qianbao' => ['label' => '钱宝支付', 'directions' => [2], 'signature' => 'MD5'],
  13. 'jd' => ['label' => 'JD支付', 'directions' => [1, 2], 'signature' => 'MD5'],
  14. 'no' => ['label' => 'NO支付', 'directions' => [1, 2], 'signature' => 'SHA256'],
  15. 'zimu' => ['label' => '808支付', 'directions' => [1, 2], 'signature' => 'MD5'],
  16. ];
  17. // data_type => 通道 type => [支付商代码, 支付方式名称]
  18. private const METHODS = [
  19. 1 => [
  20. 'wxsm' => ['sanjin', '微信扫码'],
  21. 'ylsm' => ['sanjin', '银联扫码'],
  22. 'szrmb' => ['sanjin', '数字人民币'],
  23. 'zfbsm' => ['sanjin', '支付宝扫码'],
  24. 'zfbge' => ['sanjin', '支付宝固额'],
  25. 'zfbzk' => ['sanjin', '支付宝转卡'],
  26. 'sdjt' => ['sanjin', '手动金条'],
  27. 'JDpay' => ['jd', 'JD钱包'],
  28. 'NOpay12' => ['no', 'NO扫码支付'],
  29. 'NOpay13' => ['no', 'NO余额支付'],
  30. 'ZIMUpay' => ['zimu', '808充值'],
  31. ],
  32. 2 => [
  33. 'DF001' => ['qianbao', '银行卡'],
  34. 'DF002' => ['qianbao', '支付宝'],
  35. 'DF005' => ['qianbao', '数字人民币'],
  36. 'JDpay' => ['jd', 'JD钱包'],
  37. 'NOwithdraw' => ['no', 'NO快捷提现'],
  38. 'ZIMUwithdraw' => ['zimu', '808账户提现'],
  39. 'ZIMUcash' => ['zimu', '808人民币代付'],
  40. ],
  41. ];
  42. public static function codes(): array
  43. {
  44. return array_keys(self::PROVIDERS);
  45. }
  46. public static function definitions(): array
  47. {
  48. return self::PROVIDERS;
  49. }
  50. public static function label(string $code): string
  51. {
  52. return self::PROVIDERS[$code]['label'] ?? $code;
  53. }
  54. public static function channelTypesForProvider(string $provider, ?int $direction = null): array
  55. {
  56. $types = [];
  57. foreach (self::METHODS as $dataType => $methods) {
  58. if ($direction !== null && $direction !== $dataType) continue;
  59. foreach ($methods as $type => [$code]) {
  60. if ($code === $provider) $types[] = $type;
  61. }
  62. }
  63. return array_values(array_unique($types));
  64. }
  65. /** Definitions for creating channels, intentionally independent of existing channels/groups. */
  66. public static function channelFormOptions(int $dataType, ?array $statuses = null): array
  67. {
  68. if (!in_array($dataType, [1, 2, 3], true)) throw new InvalidArgumentException('通道方向错误');
  69. $companies = [];
  70. $types = [];
  71. foreach (self::PROVIDERS as $code => $provider) {
  72. if (in_array($dataType, $provider['directions'], true)) {
  73. $enabled = $statuses === null || ($statuses[$code] ?? 0) === 1;
  74. $companies[] = ['value' => $code, 'label' => $provider['label'], 'status' => $enabled ? 1 : 0,
  75. 'disabled' => !$enabled, 'disabled_reason' => $enabled ? '' : '支付公司已禁用'];
  76. }
  77. }
  78. foreach (self::METHODS[$dataType] ?? [] as $type => [$company, $label]) {
  79. $enabled = $statuses === null || ($statuses[$company] ?? 0) === 1;
  80. $types[] = ['value' => $type, 'label' => $label, 'from' => 1,
  81. 'payment_company' => $company, 'payment_company_label' => self::label($company),
  82. 'disabled' => !$enabled, 'disabled_reason' => $enabled ? '' : '支付公司已禁用'];
  83. }
  84. $local = match ($dataType) {
  85. 1 => ['usdt' => [2, 'USDT充值'], 'rgcz' => [3, '人工充值']],
  86. 2 => ['usdt' => [2, 'USDT提现'], 'rgtx' => [3, '人工提现']],
  87. 3 => ['recharge' => [1, '即充即送'], 'old_user' => [1, '老用户回归'], 'yuebao' => [1, '余额宝']],
  88. };
  89. foreach ($local as $type => [$from, $label]) {
  90. $types[] = ['value' => $type, 'label' => $label, 'from' => $from,
  91. 'payment_company' => null, 'payment_company_label' => '', 'disabled' => false, 'disabled_reason' => ''];
  92. }
  93. return [
  94. 'data_type' => $dataType,
  95. 'from_options' => $dataType === 3 ? [['value' => 1, 'label' => '活动']] : [
  96. ['value' => 1, 'label' => '第三方支付'], ['value' => 2, 'label' => 'USDT'], ['value' => 3, 'label' => '人工'],
  97. ],
  98. 'payment_companies' => $companies,
  99. 'types' => $types,
  100. ];
  101. }
  102. public static function normalizeChannelSelection(array $data): array
  103. {
  104. $definitions = array_column(self::channelFormOptions((int)$data['data_type'])['types'], null, 'value');
  105. $definition = $definitions[(string)$data['type']] ?? null;
  106. if (!$definition) throw new InvalidArgumentException('请选择已接入的通道类型');
  107. $company = (string)($data['payment_company'] ?? '');
  108. if ($company !== '' && $company !== $definition['payment_company']) {
  109. throw new InvalidArgumentException('支付公司与通道类型不匹配,请重新选择类型');
  110. }
  111. if (isset($data['from']) && (int)$data['from'] !== $definition['from']) {
  112. throw new InvalidArgumentException('通道来源与类型不匹配');
  113. }
  114. $data['from'] = $definition['from'];
  115. unset($data['payment_company']);
  116. return $data;
  117. }
  118. public static function channelDisplay(array $channel, ?array $statuses = null): array
  119. {
  120. $identity = self::channelIdentity($channel);
  121. $label = $identity ? self::label($identity['provider_code']) : '';
  122. $companyStatus = $identity ? ($statuses === null ? 1 : (int)($statuses[$identity['provider_code']] ?? 0)) : null;
  123. return [
  124. 'payment_company' => $identity['provider_code'] ?? null,
  125. 'payment_company_label' => $label,
  126. 'display_name' => ($label === '' ? '' : $label . ' / ') . (string)$channel['name'],
  127. 'company_status' => $companyStatus,
  128. 'effective_status' => (int)($channel['status'] ?? 1) === 1 && $companyStatus !== 0 && empty($channel['deleted_at']) ? 1 : 0,
  129. ];
  130. }
  131. public static function direction(?string $kind): ?int
  132. {
  133. return match ($kind) {
  134. null, '' => null,
  135. 'deposit', 'collection_scan', 'collection_third_party' => 1,
  136. 'withdraw' => 2,
  137. default => throw new InvalidArgumentException('不支持的支付配置类型'),
  138. };
  139. }
  140. public static function channelIdentity(array $channel): ?array
  141. {
  142. if ((int)($channel['from'] ?? 0) !== 1) return null;
  143. $type = (string)($channel['type'] ?? '');
  144. $method = self::METHODS[(int)($channel['data_type'] ?? 0)][$type] ?? null;
  145. if (!$method) return null;
  146. return ['provider_code' => $method[0], 'method_code' => $type, 'method_label' => $method[1]];
  147. }
  148. public static function validateSelection(array $channel, string $provider, string $method, int $direction): void
  149. {
  150. $identity = self::channelIdentity($channel);
  151. if ((int)($channel['data_type'] ?? 0) !== $direction || !$identity) {
  152. throw new InvalidArgumentException('该通道不属于已接入的第三方支付通道');
  153. }
  154. if ($identity['provider_code'] !== $provider) {
  155. throw new InvalidArgumentException('支付公司与所选通道不匹配');
  156. }
  157. if ($identity['method_code'] !== $method) {
  158. throw new InvalidArgumentException('支付方式与所选通道不匹配');
  159. }
  160. }
  161. public function options(array $channels, array $groups, array $filters = []): array
  162. {
  163. $direction = self::direction($filters['kind'] ?? null);
  164. $isCollection = in_array($filters['kind'] ?? '', ['collection_scan', 'collection_third_party'], true);
  165. $company = (string)($filters['payment_company'] ?? '');
  166. $collector = (string)($filters['provider_name'] ?? '');
  167. $selectedProvider = $isCollection ? $collector : $company;
  168. $selectedMethod = (string)($isCollection ? ($filters['collection_method'] ?? '') : ($filters['payment_method'] ?? ''));
  169. $groups = array_map(function ($group) {
  170. foreach (['recharge_type', 'withdraw_type', 'activity_type'] as $field) {
  171. $group[$field] = self::types($group[$field] ?? []);
  172. }
  173. return ['id' => (int)$group['id'], 'name' => (string)$group['name']] + $group;
  174. }, $groups);
  175. $formatted = [];
  176. foreach ($channels as $channel) {
  177. $dataType = (int)$channel['data_type'];
  178. $groupField = [1 => 'recharge_type', 2 => 'withdraw_type', 3 => 'activity_type'][$dataType] ?? null;
  179. $ids = [];
  180. foreach ($groups as $group) {
  181. if ($groupField && in_array((string)$channel['type'], $group[$groupField], true)) $ids[] = $group['id'];
  182. }
  183. $identity = self::channelIdentity($channel);
  184. $enabled = (int)$channel['status'] === 1;
  185. $companyEnabled = !$identity || $this->companyEnabled($identity['provider_code']);
  186. $formatted[] = [
  187. 'id' => (int)$channel['id'], 'data_type' => $dataType,
  188. 'name' => (string)$channel['name'], 'key' => (string)$channel['key'], 'type' => (string)$channel['type'],
  189. 'rate' => bcadd((string)$channel['rate'], '0', 4),
  190. 'fee_rate' => bcmul((string)$channel['rate'], '100', 4),
  191. 'min_amount' => $channel['min'] ?? null, 'max_amount' => $channel['max'] ?? null,
  192. 'fixed_amounts' => self::types($channel['fixed'] ?? []),
  193. 'sort' => (int)$channel['sort'], 'status' => (int)$channel['status'],
  194. 'company_status' => $identity ? ($companyEnabled ? 1 : 0) : null,
  195. 'available_group_ids' => $ids,
  196. 'provider_code' => $identity['provider_code'] ?? null,
  197. 'payment_method' => $identity['method_code'] ?? null,
  198. 'payment_method_label' => $identity['method_label'] ?? null,
  199. 'selectable' => $identity && $companyEnabled && $enabled && $ids !== [],
  200. 'unavailable_reason' => !$identity ? '不属于第三方支付通道' : (!$companyEnabled ? '支付公司已禁用' : (!$enabled ? '通道已停用' : ($ids === [] ? '没有包含此通道的组合' : ''))),
  201. ];
  202. }
  203. $selectedChannels = array_values(array_filter($formatted, static function ($channel) use ($direction, $selectedProvider, $selectedMethod) {
  204. if ($direction !== null && ($channel['data_type'] !== $direction || !$channel['provider_code'])) return false;
  205. if ($selectedProvider !== '' && $channel['provider_code'] !== $selectedProvider) return false;
  206. return $selectedMethod === '' || $channel['payment_method'] === $selectedMethod;
  207. }));
  208. return [
  209. 'payment_companies' => $this->companies($formatted, $direction),
  210. 'payment_methods' => $this->methods($formatted, $direction, $company),
  211. 'collection_providers' => $this->companies($formatted, 1),
  212. 'collection_methods' => $this->methods($formatted, 1, $collector),
  213. 'recharge_channels' => $selectedChannels,
  214. 'channel_groups' => $groups,
  215. 'levels' => array_map(static fn ($group) => ['value' => $group['id'], 'label' => $group['name']], $groups),
  216. 'credential_source' => 'environment',
  217. ];
  218. }
  219. private function companies(array $channels, ?int $direction): array
  220. {
  221. $result = [];
  222. foreach (self::PROVIDERS as $code => $provider) {
  223. if ($direction !== null && !in_array($direction, $provider['directions'], true)) continue;
  224. $matching = array_filter($channels, static fn ($channel) => $channel['provider_code'] === $code
  225. && ($direction === null || $channel['data_type'] === $direction));
  226. $result[] = [
  227. 'value' => $code, 'label' => $provider['label'],
  228. 'signature_algorithm' => $provider['signature'],
  229. 'secret_field' => $direction === null ? null : ($direction === 2 ? 'withdrawal_secret' : 'deposit_secret'),
  230. ] + $this->companyAvailability($code, $matching);
  231. }
  232. return $result;
  233. }
  234. private function methods(array $channels, ?int $direction, string $provider): array
  235. {
  236. $result = [];
  237. foreach (self::METHODS as $dataType => $methods) {
  238. if ($direction !== null && $direction !== $dataType) continue;
  239. foreach ($methods as $code => [$company, $label]) {
  240. if ($provider !== '' && $provider !== $company) continue;
  241. $matching = array_filter($channels, static fn ($channel) => $channel['provider_code'] === $company
  242. && $channel['data_type'] === $dataType && $channel['payment_method'] === $code);
  243. $selectable = array_filter($matching, static fn ($channel) => $channel['selectable']);
  244. $result[] = [
  245. 'value' => $code, 'label' => $label, 'provider_code' => $company, 'data_type' => $dataType,
  246. 'channel_ids' => array_values(array_column($matching, 'id')),
  247. 'selectable_channel_ids' => array_values(array_column($selectable, 'id')),
  248. ] + $this->companyAvailability($company, $matching);
  249. }
  250. }
  251. return $result;
  252. }
  253. private function companyEnabled(string $code): bool
  254. {
  255. return $this->providerStatuses === null || ($this->providerStatuses[$code] ?? 0) === 1;
  256. }
  257. private function companyAvailability(string $code, array $channels): array
  258. {
  259. if (!$this->companyEnabled($code)) {
  260. return ['disabled' => true, 'disabled_reason_code' => 'provider_disabled', 'disabled_reason' => '支付公司已禁用'];
  261. }
  262. return $this->availability($channels);
  263. }
  264. private function availability(array $channels): array
  265. {
  266. if (array_filter($channels, static fn ($channel) => $channel['selectable']) !== []) {
  267. return ['disabled' => false, 'disabled_reason_code' => '', 'disabled_reason' => ''];
  268. }
  269. if ($channels === []) {
  270. $code = 'channel_missing';
  271. $reason = '未配置匹配的通道';
  272. } elseif (array_filter($channels, static fn ($channel) => $channel['status'] === 1) === []) {
  273. $code = 'channel_disabled';
  274. $reason = '匹配通道均已停用';
  275. } else {
  276. $code = 'group_missing';
  277. $reason = '已启用通道尚未加入通道组合';
  278. }
  279. return ['disabled' => true, 'disabled_reason_code' => $code, 'disabled_reason' => $reason];
  280. }
  281. private static function types($value): array
  282. {
  283. $values = is_array($value) ? $value : explode(',', (string)$value);
  284. return array_values(array_filter(array_map(static fn ($item) => trim((string)$item), $values), static fn ($item) => $item !== ''));
  285. }
  286. }