PaymentConfiguration.php 18 KB

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