RechargeChannelConfigurationService.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. namespace App\Services\Payment;
  3. use App\Models\RechargeChannel;
  4. use App\Models\RechargeChannelGroup;
  5. use App\Services\OperationAuditService;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Schema;
  8. use RuntimeException;
  9. class RechargeChannelConfigurationService
  10. {
  11. public function saveGroup(array $params): array
  12. {
  13. return DB::transaction(function () use ($params) {
  14. $id = (int)($params['id'] ?? 0);
  15. unset($params['id']);
  16. $validTypes = [];
  17. foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $direction) {
  18. if (!array_key_exists($field, $params)) continue;
  19. $params[$field] = $this->normalizeTypes((array)($params[$field] ?? []));
  20. // Match deletion's lock order: channel rows, then the group being edited.
  21. $validTypes[$field] = $params[$field] === [] ? [] : RechargeChannel::query()
  22. ->where('data_type', $direction)->whereIn('type', $params[$field])
  23. ->orderBy('id')->lockForUpdate()->get(['type'])->pluck('type')->map(fn ($type) => (string)$type)->all();
  24. }
  25. $group = $id ? RechargeChannelGroup::query()->lockForUpdate()->findOrFail($id) : new RechargeChannelGroup();
  26. $before = $group->exists ? $group->toArray() : [];
  27. $removed = [];
  28. foreach ($validTypes as $field => $valid) {
  29. $invalid = array_values(array_diff($params[$field], $valid));
  30. $previous = $group->exists ? $this->normalizeTypes((array)$group->{$field}) : [];
  31. $newInvalid = array_values(array_diff($invalid, $previous));
  32. if ($newInvalid !== []) {
  33. throw new RuntimeException($field . ' 包含不存在或已删除的通道类型:'
  34. . implode(',', $newInvalid) . ';请提交选项的 type,不是通道 ID 或公司代码');
  35. }
  36. if ($invalid !== []) $removed[$field] = $invalid;
  37. $params[$field] = array_values(array_intersect($params[$field], $valid));
  38. }
  39. $group->fill($params);
  40. $group->save();
  41. if ($removed !== []) {
  42. OperationAuditService::record('recharge_channel_group', (int)$group->id, 'update', $before,
  43. ['group' => $group->toArray(), 'removed_types' => $removed]);
  44. }
  45. return $removed;
  46. }, 3);
  47. }
  48. public function deleteGroup(int $id): void
  49. {
  50. if ($id === 1) throw new RuntimeException('默认通道组合不能删除');
  51. DB::transaction(function () use ($id) {
  52. $group = RechargeChannelGroup::withTrashed()->lockForUpdate()->findOrFail($id);
  53. if ($group->trashed()) return;
  54. if (Schema::hasTable('users') && Schema::hasColumn('users', 'recharge_channel_group_id')
  55. && DB::table('users')->where('recharge_channel_group_id', $id)->lockForUpdate()->first(['id'])) {
  56. throw new RuntimeException('该层级仍有会员使用,请先将会员调整到其他层级');
  57. }
  58. foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
  59. if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_group_ids')) continue;
  60. DB::table($table)->whereNull('deleted_at')->whereNotNull('recharge_channel_group_ids')
  61. ->select(['id', 'recharge_channel_group_ids'])
  62. ->orderBy('id')->lockForUpdate()->chunkById(200, function ($rows) use ($id) {
  63. foreach ($rows as $row) {
  64. // Match runtime's integer normalization, including legacy string IDs such as "02".
  65. $ids = array_map('intval', (array)json_decode($row->recharge_channel_group_ids, true));
  66. if (in_array($id, $ids, true)) throw new RuntimeException('该层级仍被支付配置引用,请先解除关联');
  67. }
  68. }, 'id', 'id');
  69. }
  70. $before = $group->toArray();
  71. $group->delete();
  72. OperationAuditService::record('recharge_channel_group', $id, 'delete', $before, $group);
  73. }, 3);
  74. }
  75. private function normalizeTypes(array $values): array
  76. {
  77. $types = [];
  78. foreach ($values as $value) {
  79. if ($value !== null && !is_string($value) && !is_int($value)) {
  80. throw new RuntimeException('通道类型应提交 type 字符串数组,不能提交选项对象');
  81. }
  82. $type = trim((string)$value);
  83. if ($type !== '') $types[] = $type;
  84. }
  85. return array_values(array_unique($types));
  86. }
  87. public function options(int $dataType, ?int $id = null): array
  88. {
  89. $options = PaymentProviderCatalog::channelFormOptions($dataType, PaymentProviderService::statuses());
  90. $reason = '';
  91. if ($id) {
  92. $channel = RechargeChannel::query()->findOrFail($id);
  93. if ((int)$channel->data_type !== $dataType) throw new RuntimeException('通道方向与编辑记录不一致');
  94. $reason = $this->identityLockReason($channel);
  95. }
  96. return $options + ['identity_editable' => $reason === '', 'identity_edit_reason' => $reason];
  97. }
  98. public function save(array $params): RechargeChannel
  99. {
  100. return DB::transaction(function () use ($params) {
  101. $id = (int)($params['id'] ?? 0);
  102. unset($params['id']);
  103. $channel = $id ? RechargeChannel::query()->lockForUpdate()->findOrFail($id) : new RechargeChannel();
  104. if ($channel->exists && (int)$channel->data_type !== (int)$params['data_type']) {
  105. throw new RuntimeException('不能修改通道所属的充值、提现或活动方向');
  106. }
  107. $params = PaymentProviderCatalog::normalizeChannelSelection($params);
  108. $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
  109. $identity = PaymentProviderCatalog::channelIdentity($params);
  110. if ($identityChanged && $identity) PaymentProviderService::assertEnabled($identity['provider_code']);
  111. if ($channel->exists && ((int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'])) {
  112. $reason = $this->identityLockReason($channel, true);
  113. if ($reason !== '') throw new RuntimeException($reason);
  114. }
  115. foreach (['name', 'key'] as $field) {
  116. if (array_key_exists($field, $params)) $params[$field] = trim((string)$params[$field]);
  117. if ((!$channel->exists || array_key_exists($field, $params)) && ($params[$field] ?? '') === '') {
  118. throw new RuntimeException($field === 'name' ? '请填写通道名称' : '请填写通道标识Key');
  119. }
  120. }
  121. foreach (['status', 'sort'] as $field) {
  122. if (array_key_exists($field, $params) && $params[$field] === null) unset($params[$field]);
  123. }
  124. if (!$channel->exists) $params += ['status' => 1, 'sort' => 0];
  125. $channel->fill($params);
  126. $channel->save();
  127. return $channel;
  128. }, 3);
  129. }
  130. public function delete(int $id): void
  131. {
  132. DB::transaction(function () use ($id) {
  133. $channel = RechargeChannel::withTrashed()->lockForUpdate()->findOrFail($id);
  134. if (!in_array((int)$channel->data_type, [1, 2], true)) throw new RuntimeException('只支持删除充值或提现通道');
  135. if ($channel->trashed()) return;
  136. if ($this->identityLockReason($channel, true) !== '') {
  137. throw new RuntimeException('该通道已被支付配置引用,请先解除关联再删除');
  138. }
  139. $before = $channel->toArray();
  140. $channel->status = 0;
  141. $channel->save();
  142. $channel->delete();
  143. // Groups grant access by type, so retain the grant while another row of that type remains.
  144. $remaining = RechargeChannel::query()->where('data_type', $channel->data_type)
  145. ->where('type', $channel->type)->lockForUpdate()->first(['id']);
  146. $groupChanges = [];
  147. if (!$remaining) {
  148. $field = (int)$channel->data_type === 1 ? 'recharge_type' : 'withdraw_type';
  149. foreach (RechargeChannelGroup::query()->orderBy('id')->lockForUpdate()->get() as $group) {
  150. $types = (array)$group->{$field};
  151. $updated = array_values(array_filter($types, fn ($type) => trim((string)$type) !== (string)$channel->type));
  152. if ($types === $updated) continue;
  153. $groupChanges[] = ['id' => (int)$group->id, 'field' => $field, 'before' => $types, 'after' => $updated];
  154. $group->{$field} = $updated;
  155. $group->save();
  156. }
  157. }
  158. OperationAuditService::record('recharge_channel', $id, 'delete', $before,
  159. ['channel' => $channel->toArray(), 'group_changes' => $groupChanges]);
  160. }, 3);
  161. }
  162. private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
  163. {
  164. foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
  165. if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_id')) continue;
  166. $query = DB::table($table)->where('recharge_channel_id', $channel->id)->whereNull('deleted_at');
  167. if ($lock) $query->lockForUpdate();
  168. if ($query->first(['id'])) return '该通道已被支付配置引用,请先解除关联或新增通道,再更换公司或类型';
  169. }
  170. return '';
  171. }
  172. }