RechargeChannelConfigurationService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <?php
  2. namespace App\Services\Payment;
  3. use App\Models\RechargeChannel;
  4. use App\Models\OperationAudit;
  5. use App\Models\RechargeChannelGroup;
  6. use App\Services\OperationAuditService;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Schema;
  9. use RuntimeException;
  10. class RechargeChannelConfigurationService
  11. {
  12. public function saveGroup(array $params): array
  13. {
  14. return DB::transaction(function () use ($params) {
  15. $id = (int)($params['id'] ?? 0);
  16. unset($params['id']);
  17. $validTypes = [];
  18. foreach (['recharge_type' => 1, 'withdraw_type' => 2] as $field => $direction) {
  19. if (!array_key_exists($field, $params)) continue;
  20. $params[$field] = $this->normalizeTypes((array)($params[$field] ?? []));
  21. // Match deletion's lock order: channel rows, then the group being edited.
  22. $validTypes[$field] = $params[$field] === [] ? [] : RechargeChannel::query()
  23. ->where('data_type', $direction)->whereIn('type', $params[$field])
  24. ->orderBy('id')->lockForUpdate()->get(['type'])->pluck('type')->map(fn ($type) => (string)$type)->all();
  25. }
  26. $group = $id ? RechargeChannelGroup::query()->lockForUpdate()->findOrFail($id) : new RechargeChannelGroup();
  27. $before = $group->exists ? $group->toArray() : [];
  28. $removed = [];
  29. foreach ($validTypes as $field => $valid) {
  30. $invalid = array_values(array_diff($params[$field], $valid));
  31. $previous = $group->exists ? $this->normalizeTypes((array)$group->{$field}) : [];
  32. $newInvalid = array_values(array_diff($invalid, $previous));
  33. if ($newInvalid !== []) {
  34. throw new RuntimeException($field . ' 包含不存在或已删除的通道类型:'
  35. . implode(',', $newInvalid) . ';请提交选项的 type,不是通道 ID 或公司代码');
  36. }
  37. if ($invalid !== []) $removed[$field] = $invalid;
  38. $params[$field] = array_values(array_intersect($params[$field], $valid));
  39. }
  40. $group->fill($params);
  41. $group->save();
  42. if ($removed !== []) {
  43. OperationAuditService::record('recharge_channel_group', (int)$group->id, 'update', $before,
  44. ['group' => $group->toArray(), 'removed_types' => $removed]);
  45. }
  46. return $removed;
  47. }, 3);
  48. }
  49. public function deleteGroup(int $id): void
  50. {
  51. if ($id === 1) throw new RuntimeException('默认通道组合不能删除');
  52. DB::transaction(function () use ($id) {
  53. $group = RechargeChannelGroup::withTrashed()->lockForUpdate()->findOrFail($id);
  54. if ($group->trashed()) return;
  55. if (Schema::hasTable('users') && Schema::hasColumn('users', 'recharge_channel_group_id')
  56. && DB::table('users')->where('recharge_channel_group_id', $id)->lockForUpdate()->first(['id'])) {
  57. throw new RuntimeException('该层级仍有会员使用,请先将会员调整到其他层级');
  58. }
  59. foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
  60. if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_group_ids')) continue;
  61. DB::table($table)->whereNull('deleted_at')->whereNotNull('recharge_channel_group_ids')
  62. ->select(['id', 'recharge_channel_group_ids'])
  63. ->orderBy('id')->lockForUpdate()->chunkById(200, function ($rows) use ($id) {
  64. foreach ($rows as $row) {
  65. // Match runtime's integer normalization, including legacy string IDs such as "02".
  66. $ids = array_map('intval', (array)json_decode($row->recharge_channel_group_ids, true));
  67. if (in_array($id, $ids, true)) throw new RuntimeException('该层级仍被支付配置引用,请先解除关联');
  68. }
  69. }, 'id', 'id');
  70. }
  71. $before = $group->toArray();
  72. $group->delete();
  73. OperationAuditService::record('recharge_channel_group', $id, 'delete', $before, $group);
  74. }, 3);
  75. }
  76. private function normalizeTypes(array $values): array
  77. {
  78. $types = [];
  79. foreach ($values as $value) {
  80. if ($value !== null && !is_string($value) && !is_int($value)) {
  81. throw new RuntimeException('通道类型应提交 type 字符串数组,不能提交选项对象');
  82. }
  83. $type = trim((string)$value);
  84. if ($type !== '') $types[] = $type;
  85. }
  86. return array_values(array_unique($types));
  87. }
  88. public function options(int $dataType, ?int $id = null): array
  89. {
  90. $options = PaymentProviderCatalog::channelFormOptions($dataType, PaymentProviderService::statuses());
  91. $reason = '';
  92. if ($id) {
  93. $channel = RechargeChannel::query()->findOrFail($id);
  94. if ((int)$channel->data_type !== $dataType) throw new RuntimeException('通道方向与编辑记录不一致');
  95. $reason = $this->identityLockReason($channel);
  96. }
  97. return $options + ['identity_editable' => $reason === '', 'identity_edit_reason' => $reason];
  98. }
  99. public function save(array $params): RechargeChannel
  100. {
  101. if (array_key_exists('logo', $params)) $params['logo'] = RechargeChannelLogoService::normalize($params['logo']);
  102. return DB::transaction(function () use ($params) {
  103. $id = (int)($params['id'] ?? 0);
  104. unset($params['id']);
  105. $channel = $id ? RechargeChannel::query()->lockForUpdate()->findOrFail($id) : new RechargeChannel();
  106. if ($channel->exists && (int)$channel->data_type !== (int)$params['data_type']) {
  107. throw new RuntimeException('不能修改通道所属的充值或提现方向');
  108. }
  109. $isNew = !$channel->exists;
  110. $before = $isNew ? [] : $channel->toArray();
  111. $params = PaymentProviderCatalog::normalizeChannelSelection($params);
  112. $params = $this->amountConfiguration($params, $channel);
  113. $params = $this->feeConfiguration($params, $channel);
  114. $identityChanged = !$channel->exists || (int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'];
  115. $identity = PaymentProviderCatalog::channelIdentity($params);
  116. if ($identityChanged && $identity) PaymentProviderService::assertEnabled($identity['provider_code']);
  117. if ($channel->exists && ((int)$channel->from !== $params['from'] || (string)$channel->type !== (string)$params['type'])) {
  118. $reason = $this->identityLockReason($channel, true);
  119. if ($reason !== '') throw new RuntimeException($reason);
  120. }
  121. foreach (['name', 'key'] as $field) {
  122. if (array_key_exists($field, $params)) $params[$field] = trim((string)$params[$field]);
  123. if ((!$channel->exists || array_key_exists($field, $params)) && ($params[$field] ?? '') === '') {
  124. throw new RuntimeException($field === 'name' ? '请填写通道名称' : '请填写通道标识Key');
  125. }
  126. }
  127. foreach (['status', 'sort'] as $field) {
  128. if (array_key_exists($field, $params) && $params[$field] === null) unset($params[$field]);
  129. }
  130. if (!$channel->exists) $params += ['status' => 1, 'sort' => 0];
  131. if ($identityChanged && RechargeChannelLogoService::supportsDirection((int)$params['data_type']) && !array_key_exists('logo', $params)) {
  132. $dataType = (int)$params['data_type'];
  133. $type = (string)$params['type'];
  134. $logos = $dataType === 1 ? RechargeChannelLogoService::configured([$type]) : [];
  135. $peer = RechargeChannel::where('data_type', $dataType)->where('type', $type)->orderBy('sort')->orderBy('id')->first();
  136. $params['logo'] = array_key_exists($type, $logos) ? $logos[$type] : ($peer ? $peer->getRawOriginal('logo') : null);
  137. }
  138. $channel->fill($params);
  139. $channel->save();
  140. if (RechargeChannelLogoService::supportsDirection((int)$channel->data_type) && array_key_exists('logo', $params)) {
  141. RechargeChannelLogoService::store((string)$channel->type, $params['logo'], (int)$channel->data_type);
  142. }
  143. $after = $channel->fresh()->toArray();
  144. if ($isNew || self::diff($before, $after) !== []) {
  145. OperationAuditService::record('recharge_channel', (int)$channel->id, $isNew ? 'create' : 'update', $before, $after);
  146. }
  147. return $channel;
  148. }, 3);
  149. }
  150. /**
  151. * 三方手续费组合:fee_mode 为 rate(只按百分比)/ fixed(只收每笔固定费用)/ rate_fixed(两者结合),
  152. * 不传 fee_mode 时按提交的 rate、fee_fixed 原样保存(0 表示不收该部分)。
  153. */
  154. private function feeConfiguration(array $params, RechargeChannel $channel): array
  155. {
  156. $mode = $params['fee_mode'] ?? null;
  157. unset($params['fee_mode']);
  158. if (array_key_exists('rate', $params) && $params['rate'] === null) {
  159. if ($channel->exists) unset($params['rate']); else $params['rate'] = 0;
  160. }
  161. if (array_key_exists('fee_fixed', $params) && $params['fee_fixed'] === null) $params['fee_fixed'] = 0;
  162. if ($mode === null) return $params;
  163. $rate = (string)($params['rate'] ?? ($channel->exists ? $channel->rate : 0));
  164. $fixed = (string)($params['fee_fixed'] ?? ($channel->exists ? $channel->fee_fixed : 0));
  165. if ($mode === 'fixed') $rate = '0';
  166. if ($mode === 'rate') $fixed = '0';
  167. if ($mode !== 'fixed' && bccomp($rate, '0', 6) <= 0) {
  168. throw \Illuminate\Validation\ValidationException::withMessages(['rate' => '请填写手续费百分比']);
  169. }
  170. if ($mode !== 'rate' && bccomp($fixed, '0', 4) <= 0) {
  171. throw \Illuminate\Validation\ValidationException::withMessages(['fee_fixed' => '请填写每笔固定手续费']);
  172. }
  173. return ['rate' => $rate, 'fee_fixed' => $fixed] + $params;
  174. }
  175. private function amountConfiguration(array $params, RechargeChannel $channel): array
  176. {
  177. $fixedMode = (int)$params['data_type'] === 1 && $params['type'] === 'zfbge';
  178. if ($fixedMode || array_key_exists('fixed', $params)) {
  179. $value = array_key_exists('fixed', $params) ? $params['fixed'] : ($channel->type === 'zfbge' ? $channel->getRawOriginal('fixed') : null);
  180. if (is_string($value)) $values = explode(',', str_replace(',', ',', $value));
  181. elseif (is_array($value) && array_values($value) === $value) $values = $value;
  182. elseif ($value === null) $values = [];
  183. else throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额请提交金额数组或逗号分隔字符串']);
  184. $amounts = [];
  185. foreach ($values as $amount) {
  186. if (!is_string($amount) && !is_int($amount) && !is_float($amount)) throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额格式错误']);
  187. $amount = trim((string)$amount);
  188. if ($amount === '') continue;
  189. if (!preg_match('/^\d{1,12}(\.\d{1,2}0*)?$/D', $amount) || bccomp($amount, '0', 2) <= 0) {
  190. throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '固定金额必须大于0,最多两位小数']);
  191. }
  192. $amount = bcadd($amount, '0', 2);
  193. if (str_ends_with($amount, '.00')) $amount = substr($amount, 0, -3);
  194. if (!in_array($amount, $amounts, true)) $amounts[] = $amount;
  195. }
  196. if ($fixedMode && !$amounts) throw \Illuminate\Validation\ValidationException::withMessages(['fixed' => '请设置固定充值金额']);
  197. $params['fixed'] = $amounts ? implode(',', $amounts) : null;
  198. } elseif ($channel->exists && $channel->type !== $params['type']) {
  199. $params['fixed'] = null;
  200. }
  201. if ($fixedMode) {
  202. $params['min'] = $params['max'] = null;
  203. } else {
  204. $range = ['min' => array_key_exists('min', $params) ? $params['min'] : $channel->min,
  205. 'max' => array_key_exists('max', $params) ? $params['max'] : $channel->max];
  206. $rules = ['min' => ['nullable', 'numeric', 'min:0'], 'max' => ['nullable', 'numeric', 'min:0']];
  207. if ($range['min'] !== null && $range['max'] !== null) $rules['max'][] = 'gte:min';
  208. \Illuminate\Support\Facades\Validator::make($range, $rules)->validate();
  209. }
  210. return $params;
  211. }
  212. public function delete(int $id): void
  213. {
  214. DB::transaction(function () use ($id) {
  215. $channel = RechargeChannel::withTrashed()->lockForUpdate()->findOrFail($id);
  216. if (!in_array((int)$channel->data_type, [1, 2], true)) throw new RuntimeException('只支持删除充值或提现通道');
  217. if ($channel->trashed()) return;
  218. if ($this->identityLockReason($channel, true) !== '') {
  219. throw new RuntimeException('该通道已被支付配置引用,请先解除关联再删除');
  220. }
  221. $before = $channel->toArray();
  222. $channel->status = 0;
  223. $channel->save();
  224. $channel->delete();
  225. // Groups grant access by type, so retain the grant while another row of that type remains.
  226. $remaining = RechargeChannel::query()->where('data_type', $channel->data_type)
  227. ->where('type', $channel->type)->lockForUpdate()->first(['id']);
  228. $groupChanges = [];
  229. if (!$remaining) {
  230. $field = (int)$channel->data_type === 1 ? 'recharge_type' : 'withdraw_type';
  231. foreach (RechargeChannelGroup::query()->orderBy('id')->lockForUpdate()->get() as $group) {
  232. $types = (array)$group->{$field};
  233. $updated = array_values(array_filter($types, fn ($type) => trim((string)$type) !== (string)$channel->type));
  234. if ($types === $updated) continue;
  235. $groupChanges[] = ['id' => (int)$group->id, 'field' => $field, 'before' => $types, 'after' => $updated];
  236. $group->{$field} = $updated;
  237. $group->save();
  238. }
  239. }
  240. OperationAuditService::record('recharge_channel', $id, 'delete', $before,
  241. ['channel' => $channel->toArray(), 'group_changes' => $groupChanges]);
  242. }, 3);
  243. }
  244. /** 通道操作日志中展示的字段(顺序即展示顺序) */
  245. private const AUDIT_FIELDS = [
  246. 'name' => '通道名称', 'key' => '通道标识Key', 'from' => '来源', 'type' => '通道类型', 'rate' => '费率', 'fee_fixed' => '每笔固定手续费',
  247. 'min' => '最低金额', 'max' => '最高金额', 'fixed' => '固定金额', 'status' => '状态', 'sort' => '排序', 'logo' => 'Logo',
  248. ];
  249. private const AUDIT_ACTIONS = ['create' => '新增', 'update' => '修改', 'delete' => '删除', 'save' => '修改Logo'];
  250. /** 充值(1)/提现(2)通道操作日志:谁、什么时间、改了什么 */
  251. public function logs(array $params): array
  252. {
  253. $dataType = (int)$params['data_type'];
  254. $channels = RechargeChannel::withTrashed()->where('data_type', $dataType)->get(['id', 'name', 'type'])->keyBy('id');
  255. $query = OperationAudit::query()->where(function ($q) use ($channels, $dataType) {
  256. $q->where('resource_type', 'recharge_channel')->whereIn('resource_id', $channels->keys()->all() ?: [0]);
  257. // 充值方式Logo按充值类型统一配置,归入充值通道日志
  258. if ($dataType === 1) $q->orWhere('resource_type', 'recharge_channel_logo');
  259. });
  260. if (!empty($params['channel_id'])) {
  261. $query->where('resource_type', 'recharge_channel')->where('resource_id', (int)$params['channel_id']);
  262. }
  263. if (!empty($params['operator_name'])) $query->where('operator_name', 'like', '%' . trim($params['operator_name']) . '%');
  264. if (!empty($params['action'])) $query->where('action', $params['action']);
  265. if (!empty($params['start_time'])) $query->where('created_at', '>=', $params['start_time']);
  266. if (!empty($params['end_time'])) $query->where('created_at', '<=', $params['end_time']);
  267. $total = (clone $query)->count();
  268. $rows = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
  269. $data = $rows->map(function (OperationAudit $log) use ($channels) {
  270. $changes = (array)$log->changes;
  271. $isLogo = $log->resource_type === 'recharge_channel_logo';
  272. if ($isLogo) {
  273. $before = ['logo' => $changes['before']['logo'] ?? ''];
  274. $after = ['logo' => $changes['after']['logo'] ?? ''];
  275. $type = (string)($changes['after']['channel_type'] ?? $changes['before']['channel_type'] ?? '');
  276. } else {
  277. $before = (array)($changes['before'] ?? []);
  278. // 删除日志的 after 为 {channel, group_changes}
  279. $after = $log->action === 'delete' ? [] : (array)($changes['after'] ?? []);
  280. $type = (string)($after['type'] ?? $before['type'] ?? '');
  281. }
  282. $channel = $isLogo ? null : $channels->get($log->resource_id);
  283. $diff = self::diff($before, $after);
  284. $created = $log->getRawOriginal('created_at');
  285. return [
  286. 'id' => (int)$log->id,
  287. 'channel_id' => $isLogo ? null : (int)$log->resource_id,
  288. 'channel_name' => (string)($after['name'] ?? $before['name'] ?? ($channel->name ?? '')),
  289. 'channel_type' => $type,
  290. 'action' => $isLogo ? 'logo' : (string)$log->action,
  291. 'action_label' => self::AUDIT_ACTIONS[$log->action] ?? (string)$log->action,
  292. 'operator_id' => $log->operator_id === null ? null : (int)$log->operator_id,
  293. 'operator_name' => (string)$log->operator_name,
  294. 'created_at' => $created ? date('Y-m-d H:i:s', strtotime((string)$created)) : null,
  295. 'changes' => $diff,
  296. 'summary' => implode(';', array_map(fn ($c) => "{$c['label']}:{$c['before_text']} → {$c['after_text']}", $diff)),
  297. ];
  298. })->all();
  299. return ['total' => $total, 'data' => $data];
  300. }
  301. private static function diff(array $before, array $after): array
  302. {
  303. $result = [];
  304. foreach (self::AUDIT_FIELDS as $field => $label) {
  305. if (!array_key_exists($field, $before) && !array_key_exists($field, $after)) continue;
  306. $old = self::auditValue($field, $before[$field] ?? null);
  307. $new = self::auditValue($field, $after[$field] ?? null);
  308. if ($old === $new) continue;
  309. $result[] = ['field' => $field, 'label' => $label, 'before' => $old, 'after' => $new,
  310. 'before_text' => self::auditText($field, $old), 'after_text' => self::auditText($field, $new)];
  311. }
  312. return $result;
  313. }
  314. private static function auditValue(string $field, $value)
  315. {
  316. if ($value === null || $value === '' || $value === []) return null;
  317. if ($field === 'fixed') return implode(',', is_array($value) ? $value : explode(',', (string)$value));
  318. if (in_array($field, ['from', 'status', 'sort'], true)) return (int)$value;
  319. if (in_array($field, ['rate', 'fee_fixed', 'min', 'max'], true)) return rtrim(rtrim(bcadd((string)$value, '0', 4), '0'), '.');
  320. return (string)$value;
  321. }
  322. private static function auditText(string $field, $value): string
  323. {
  324. if ($value === null) return '空';
  325. if ($field === 'status') return $value === 1 ? '启用' : '停用';
  326. if ($field === 'from') return [1 => '第三方支付', 2 => 'USDT', 3 => '人工'][$value] ?? (string)$value;
  327. return (string)$value;
  328. }
  329. private function identityLockReason(RechargeChannel $channel, bool $lock = false): string
  330. {
  331. foreach (['payment_gateways', 'payment_collection_channels', 'payment_direct_recharges'] as $table) {
  332. if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'recharge_channel_id')) continue;
  333. $query = DB::table($table)->where('recharge_channel_id', $channel->id)->whereNull('deleted_at');
  334. if ($lock) $query->lockForUpdate();
  335. if ($query->first(['id'])) return '该通道已被支付配置引用,请先解除关联或新增通道,再更换公司或类型';
  336. }
  337. return '';
  338. }
  339. }