PaymentConfiguration.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <?php
  2. namespace App\Http\Controllers\admin;
  3. use App\Constants\HttpStatus;
  4. use App\Http\Controllers\Controller;
  5. use App\Models\OperationAudit;
  6. use App\Models\PaymentCollectionChannel;
  7. use App\Models\PaymentDirectRecharge;
  8. use App\Models\PaymentGateway;
  9. use App\Models\RechargeChannel;
  10. use App\Models\RechargeChannelGroup;
  11. use App\Services\OperationAuditService;
  12. use App\Services\PaymentChannelLinkService;
  13. use App\Services\Payment\PaymentProviderCatalog;
  14. use App\Services\Payment\PaymentProviderService;
  15. use Illuminate\Database\Eloquent\Builder;
  16. use Illuminate\Support\Facades\DB;
  17. use Illuminate\Validation\Rule;
  18. use Illuminate\Validation\ValidationException;
  19. use Throwable;
  20. class PaymentConfiguration extends Controller
  21. {
  22. private PaymentChannelLinkService $channelLinks;
  23. public function __construct(PaymentChannelLinkService $channelLinks)
  24. {
  25. parent::__construct();
  26. $this->channelLinks = $channelLinks;
  27. }
  28. public function options()
  29. {
  30. return $this->run(function () {
  31. $filters = request()->validate([
  32. 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'collection_scan', 'collection_third_party'])],
  33. 'payment_company' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
  34. 'provider_name' => ['nullable', Rule::in(PaymentProviderCatalog::codes())],
  35. 'payment_method' => ['nullable', 'string', 'max:64'],
  36. 'collection_method' => ['nullable', 'string', 'max:64'],
  37. ]);
  38. return (new PaymentProviderCatalog(PaymentProviderService::statuses()))->options(
  39. RechargeChannel::query()->orderBy('data_type')->orderBy('sort')->orderBy('id')->get()->toArray(),
  40. RechargeChannelGroup::query()->orderBy('id')->get()->toArray(),
  41. $filters
  42. );
  43. });
  44. }
  45. public function directList()
  46. {
  47. return $this->run(function () {
  48. $params = request()->validate($this->listRules([
  49. 'name' => ['nullable', 'string', 'max:100'],
  50. 'recharge_channel_id' => ['nullable', 'integer'],
  51. 'group_id' => ['nullable', 'integer'],
  52. 'status' => ['nullable', Rule::in([0, 1])],
  53. 'start_date' => ['nullable', 'date_format:Y-m-d'],
  54. 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  55. ]));
  56. $query = PaymentDirectRecharge::query()->with('rechargeChannel');
  57. $this->applyDateAndLike($query, $params, 'name');
  58. $this->applyChannelFilters($query, $params);
  59. return $this->paginatePayment($query->orderByDesc('id'), $params);
  60. });
  61. }
  62. public function directSave()
  63. {
  64. return $this->run(function () {
  65. $params = request()->validate([
  66. 'id' => ['nullable', 'integer'],
  67. 'recharge_channel_id' => ['required', 'integer'],
  68. 'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
  69. 'recharge_channel_group_ids.*' => ['integer'],
  70. 'recharge_code' => ['required', 'string', 'max:64'],
  71. 'name' => ['required', 'string', 'max:100'],
  72. 'amount' => ['required', 'numeric', 'gt:0'],
  73. 'status' => ['nullable', Rule::in([0, 1])],
  74. ]);
  75. [, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
  76. (int)$params['recharge_channel_id'],
  77. $params['recharge_channel_group_ids'],
  78. 1
  79. );
  80. $id = (int)($params['id'] ?? 0);
  81. $duplicate = PaymentDirectRecharge::query()->where('recharge_code', $params['recharge_code']);
  82. if ($id) $duplicate->where('id', '<>', $id);
  83. if ($duplicate->exists()) throw new \RuntimeException('充值ID已存在');
  84. return $this->saveModel(PaymentDirectRecharge::class, $params, 'payment_direct_recharge');
  85. });
  86. }
  87. public function directStatus()
  88. {
  89. return $this->setStatus(PaymentDirectRecharge::class, 'payment_direct_recharge');
  90. }
  91. public function directDelete()
  92. {
  93. return $this->deleteModel(PaymentDirectRecharge::class, 'payment_direct_recharge');
  94. }
  95. public function gatewayList()
  96. {
  97. return $this->run(function () {
  98. $params = request()->validate($this->listRules([
  99. 'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
  100. 'recharge_channel_id' => ['nullable', 'integer'],
  101. 'group_id' => ['nullable', 'integer'],
  102. 'payment_company' => ['nullable', 'string', 'max:100'],
  103. 'payment_method' => ['nullable', 'string', 'max:64'],
  104. 'status' => ['nullable', Rule::in([0, 1])],
  105. ]));
  106. $query = PaymentGateway::query()->with('rechargeChannel')->where('kind', $params['kind']);
  107. foreach (['payment_company', 'payment_method', 'status'] as $field) {
  108. if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
  109. $query->where($field, $params[$field]);
  110. }
  111. }
  112. $this->applyChannelFilters($query, $params);
  113. return $this->paginatePayment($query->orderByDesc('id'), $params);
  114. });
  115. }
  116. public function gatewaySave()
  117. {
  118. return $this->run(function () {
  119. $params = request()->validate([
  120. 'id' => ['nullable', 'integer'],
  121. 'recharge_channel_id' => ['required', 'integer'],
  122. 'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
  123. 'recharge_channel_group_ids.*' => ['integer'],
  124. 'kind' => ['required', Rule::in(['deposit', 'withdraw'])],
  125. 'currency' => ['nullable', 'string', 'max:16'],
  126. 'payment_company' => ['required', Rule::in(PaymentProviderCatalog::codes())],
  127. 'merchant_no' => ['required', 'string', 'max:128'],
  128. 'payment_method' => ['required', 'string', 'max:64'],
  129. 'channel_code' => ['nullable', 'string', 'max:64'],
  130. 'channel_name' => ['nullable', 'string', 'max:100'],
  131. 'withdrawal_secret' => ['nullable', 'string', 'max:10000'],
  132. 'deposit_secret' => ['nullable', 'string', 'max:10000'],
  133. 'status' => ['nullable', Rule::in([0, 1])],
  134. ]);
  135. $expectedDataType = $params['kind'] === 'withdraw' ? 2 : 1;
  136. [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
  137. (int)$params['recharge_channel_id'],
  138. $params['recharge_channel_group_ids'],
  139. $expectedDataType
  140. );
  141. PaymentProviderCatalog::validateSelection($channel->toArray(), $params['payment_company'], $params['payment_method'], $expectedDataType);
  142. return $this->saveModel(PaymentGateway::class, $params, 'payment_gateway');
  143. });
  144. }
  145. public function gatewayStatus()
  146. {
  147. return $this->setStatus(PaymentGateway::class, 'payment_gateway');
  148. }
  149. public function gatewayDelete()
  150. {
  151. return $this->deleteModel(PaymentGateway::class, 'payment_gateway');
  152. }
  153. public function collectionList()
  154. {
  155. return $this->run(function () {
  156. $params = request()->validate($this->listRules([
  157. 'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
  158. 'recharge_channel_id' => ['nullable', 'integer'],
  159. 'group_id' => ['nullable', 'integer'],
  160. 'provider_name' => ['nullable', 'string', 'max:100'],
  161. 'collection_method' => ['nullable', 'string', 'max:64'],
  162. 'status' => ['nullable', Rule::in([0, 1])],
  163. ]));
  164. $query = PaymentCollectionChannel::query()->with('rechargeChannel')->where('kind', $params['kind']);
  165. foreach (['provider_name', 'collection_method', 'status'] as $field) {
  166. if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
  167. $query->where($field, $params[$field]);
  168. }
  169. }
  170. $this->applyChannelFilters($query, $params);
  171. return $this->paginatePayment($query->orderByDesc('id'), $params);
  172. });
  173. }
  174. public function collectionSave()
  175. {
  176. return $this->run(function () {
  177. $params = request()->validate([
  178. 'id' => ['nullable', 'integer'],
  179. 'recharge_channel_id' => ['required', 'integer'],
  180. 'recharge_channel_group_ids' => ['required', 'array', 'min:1'],
  181. 'recharge_channel_group_ids.*' => ['integer'],
  182. 'kind' => ['required', Rule::in(['collection_scan', 'collection_third_party'])],
  183. 'currency' => ['nullable', 'string', 'max:16'],
  184. 'provider_name' => ['required', Rule::in(PaymentProviderCatalog::codes())],
  185. 'group_name' => ['required', 'string', 'max:100'],
  186. 'collection_method' => ['required', 'string', 'max:64'],
  187. 'channel_code' => ['required', 'string', 'max:64'],
  188. 'channel_name' => ['required', 'string', 'max:100'],
  189. 'status' => ['nullable', Rule::in([0, 1])],
  190. ]);
  191. [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
  192. (int)$params['recharge_channel_id'],
  193. $params['recharge_channel_group_ids'],
  194. 1
  195. );
  196. PaymentProviderCatalog::validateSelection($channel->toArray(), $params['provider_name'], $params['collection_method'], 1);
  197. return $this->saveModel(PaymentCollectionChannel::class, $params, 'payment_collection_channel');
  198. });
  199. }
  200. public function collectionStatus()
  201. {
  202. return $this->setStatus(PaymentCollectionChannel::class, 'payment_collection_channel');
  203. }
  204. public function collectionDelete()
  205. {
  206. return $this->deleteModel(PaymentCollectionChannel::class, 'payment_collection_channel');
  207. }
  208. public function logs()
  209. {
  210. return $this->run(function () {
  211. $params = request()->validate($this->listRules([
  212. 'resource_type' => ['required', Rule::in([
  213. 'payment_direct_recharge', 'payment_gateway', 'payment_collection_channel',
  214. ])],
  215. 'resource_id' => ['required', 'integer'],
  216. 'operator_name' => ['nullable', 'string', 'max:100'],
  217. 'start_date' => ['nullable', 'date_format:Y-m-d'],
  218. 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  219. ]));
  220. $query = OperationAudit::query()
  221. ->where('resource_type', $params['resource_type'])
  222. ->where('resource_id', $params['resource_id']);
  223. if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . $params['operator_name'] . '%');
  224. if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
  225. if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
  226. return $this->paginate($query->orderByDesc('id'), $params);
  227. });
  228. }
  229. private function saveModel(string $modelClass, array $params, string $resourceType): array
  230. {
  231. return DB::transaction(function () use ($modelClass, $params, $resourceType) {
  232. $id = (int)($params['id'] ?? 0);
  233. unset($params['id']);
  234. $params += OperationAuditService::actor();
  235. // Serialize config binding with channel identity changes, then recheck the current channel.
  236. $direction = $modelClass === PaymentGateway::class && $params['kind'] === 'withdraw' ? 2 : 1;
  237. [$channel, $params['recharge_channel_group_ids']] = $this->channelLinks->validate(
  238. (int)$params['recharge_channel_id'], $params['recharge_channel_group_ids'], $direction, true
  239. );
  240. if ($modelClass === PaymentGateway::class) {
  241. PaymentProviderCatalog::validateSelection($channel->toArray(), $params['payment_company'], $params['payment_method'], $direction);
  242. } elseif ($modelClass === PaymentCollectionChannel::class) {
  243. PaymentProviderCatalog::validateSelection($channel->toArray(), $params['provider_name'], $params['collection_method'], $direction);
  244. }
  245. $model = $id ? $modelClass::query()->lockForUpdate()->findOrFail($id) : new $modelClass();
  246. if ($model instanceof PaymentGateway) $params = $this->gatewayCredentials($model, $params);
  247. $before = $model->exists ? $model->replicate()->setRawAttributes($model->getRawOriginal()) : [];
  248. $model->fill($params);
  249. $model->save();
  250. OperationAuditService::record($resourceType, (int)$model->id, $id ? 'update' : 'create', $before, $model);
  251. return $this->channelLinks->formatOne($model->fresh()->load('rechargeChannel'));
  252. });
  253. }
  254. private function setStatus(string $modelClass, string $resourceType)
  255. {
  256. return $this->run(function () use ($modelClass, $resourceType) {
  257. $params = request()->validate(['id' => ['required', 'integer'], 'status' => ['required', Rule::in([0, 1])]]);
  258. return DB::transaction(function () use ($modelClass, $resourceType, $params) {
  259. $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
  260. $before = $model->toArray();
  261. $model->fill(['status' => $params['status']] + OperationAuditService::actor())->save();
  262. OperationAuditService::record($resourceType, (int)$model->id, 'status', $before, $model);
  263. return $this->channelLinks->formatOne($model->fresh()->load('rechargeChannel'));
  264. });
  265. });
  266. }
  267. private function gatewayCredentials(PaymentGateway $model, array $params): array
  268. {
  269. if ($model->exists && $model->kind !== $params['kind']) {
  270. throw ValidationException::withMessages(['kind' => '网关类型创建后不能修改']);
  271. }
  272. $secretField = $params['kind'] === 'deposit' ? 'deposit_secret' : 'withdrawal_secret';
  273. $otherField = $params['kind'] === 'deposit' ? 'withdrawal_secret' : 'deposit_secret';
  274. $hasSecret = !empty($model->getRawOriginal($secretField));
  275. $identityChanged = $model->exists && ($model->payment_company !== $params['payment_company']
  276. || $model->merchant_no !== $params['merchant_no']);
  277. $newSecret = $params[$secretField] ?? null;
  278. if ($newSecret === null || $newSecret === '') {
  279. if (!$hasSecret || $identityChanged) {
  280. throw ValidationException::withMessages([$secretField => '新增商户或切换公司/商户号时必须填写签名密钥']);
  281. }
  282. unset($params[$secretField]);
  283. }
  284. $params[$otherField] = null;
  285. return $params;
  286. }
  287. private function deleteModel(string $modelClass, string $resourceType)
  288. {
  289. return $this->run(function () use ($modelClass, $resourceType) {
  290. $params = request()->validate(['id' => ['required', 'integer']]);
  291. return DB::transaction(function () use ($modelClass, $resourceType, $params) {
  292. $model = $modelClass::query()->lockForUpdate()->findOrFail($params['id']);
  293. $before = $model->toArray();
  294. $model->delete();
  295. OperationAuditService::record($resourceType, (int)$model->id, 'delete', $before, []);
  296. return [];
  297. });
  298. });
  299. }
  300. private function listRules(array $extra): array
  301. {
  302. return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']] + $extra;
  303. }
  304. private function paginate(Builder $query, array $params): array
  305. {
  306. $page = (int)($params['page'] ?? 1);
  307. $limit = (int)($params['limit'] ?? 20);
  308. return ['total' => (clone $query)->count(), 'data' => $query->forPage($page, $limit)->get()];
  309. }
  310. private function paginatePayment(Builder $query, array $params): array
  311. {
  312. $page = (int)($params['page'] ?? 1);
  313. $limit = (int)($params['limit'] ?? 20);
  314. $total = (clone $query)->count();
  315. $models = $query->forPage($page, $limit)->get();
  316. return ['total' => $total, 'data' => $this->channelLinks->formatMany($models)];
  317. }
  318. private function applyChannelFilters(Builder $query, array $params): void
  319. {
  320. if (!empty($params['recharge_channel_id'])) {
  321. $query->where('recharge_channel_id', (int)$params['recharge_channel_id']);
  322. }
  323. if (!empty($params['group_id'])) {
  324. $query->whereJsonContains('recharge_channel_group_ids', (int)$params['group_id']);
  325. }
  326. if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
  327. $query->where('status', (int)$params['status']);
  328. }
  329. }
  330. private function applyDateAndLike(Builder $query, array $params, string $likeField): void
  331. {
  332. if (!empty($params[$likeField])) $query->where($likeField, 'like', '%' . $params[$likeField] . '%');
  333. if (!empty($params['start_date'])) $query->where('created_at', '>=', $params['start_date'] . ' 00:00:00');
  334. if (!empty($params['end_date'])) $query->where('created_at', '<=', $params['end_date'] . ' 23:59:59');
  335. }
  336. private function run(callable $callback)
  337. {
  338. try {
  339. return $this->success($callback());
  340. } catch (ValidationException $e) {
  341. return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
  342. } catch (Throwable $e) {
  343. return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
  344. }
  345. }
  346. }