ThirdPartyDepositService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\HttpStatus;
  4. use App\Models\BalanceLog;
  5. use App\Models\PaymentOrder;
  6. use App\Models\UserLogin;
  7. use App\Services\Payment\JdPayService;
  8. use App\Services\Payment\NoPayService;
  9. use App\Services\Payment\ZimuPayService;
  10. use Carbon\Carbon;
  11. use Exception;
  12. use Illuminate\Database\Eloquent\Builder;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Schema;
  15. class ThirdPartyDepositService
  16. {
  17. private const COMPANIES = [
  18. 'sanjin' => '三斤支付',
  19. 'jd' => 'JD支付',
  20. 'no' => 'NO支付',
  21. 'zimu' => '808支付',
  22. ];
  23. private const STATUSES = [
  24. PaymentOrderService::STATUS_STAY => '待处理',
  25. PaymentOrderService::STATUS_PROCESS => '处理中',
  26. PaymentOrderService::STATUS_SUCCESS => '成功',
  27. PaymentOrderService::STATUS_FAIL => '失败',
  28. ];
  29. private static ?bool $hasPlatform = null;
  30. private static ?bool $hasLoginStatus = null;
  31. public static function list(array $params): array
  32. {
  33. $page = max(1, (int)($params['page'] ?? 1));
  34. $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
  35. $query = self::query($params);
  36. $total = (clone $query)->count('payment_orders.id');
  37. $rows = $query
  38. ->orderByDesc('payment_orders.id')
  39. ->forPage($page, $limit)
  40. ->get();
  41. $creditLogs = self::creditLogs($rows->pluck('id')->all());
  42. return [
  43. 'total' => $total,
  44. 'data' => $rows->map(static fn(PaymentOrder $row): array => self::format(
  45. $row,
  46. false,
  47. $creditLogs[(int)$row->id] ?? null
  48. ))->all(),
  49. 'options' => self::options(),
  50. ];
  51. }
  52. public static function detail(int $id): array
  53. {
  54. $row = self::query(['id' => $id], true)->first();
  55. if (!$row) {
  56. throw new Exception('第三方入款记录不存在', HttpStatus::CUSTOM_ERROR);
  57. }
  58. $creditLogs = self::creditLogs([$id]);
  59. return self::format($row, true, $creditLogs[$id] ?? null);
  60. }
  61. public static function paymentCompany(?string $channel): array
  62. {
  63. if (ZimuPayService::isRechargeChannel($channel)) {
  64. $code = 'zimu';
  65. } elseif (NoPayService::isRechargeChannel($channel)) {
  66. $code = 'no';
  67. } elseif (JdPayService::isChannel($channel)) {
  68. $code = 'jd';
  69. } else {
  70. $code = 'sanjin';
  71. }
  72. return [
  73. 'code' => $code,
  74. 'name' => self::COMPANIES[$code],
  75. ];
  76. }
  77. public static function statusText(int $status): string
  78. {
  79. return self::STATUSES[$status] ?? '未知';
  80. }
  81. private static function query(array $params = [], bool $withRawData = false): Builder
  82. {
  83. $query = PaymentOrder::query()
  84. ->join('users', 'users.member_id', '=', 'payment_orders.member_id')
  85. ->where('payment_orders.type', PaymentOrderService::TYPE_PAY)
  86. ->select([
  87. 'payment_orders.id',
  88. 'payment_orders.member_id',
  89. 'payment_orders.order_no',
  90. 'payment_orders.pay_no',
  91. 'payment_orders.amount',
  92. 'payment_orders.fee',
  93. 'payment_orders.channel',
  94. 'payment_orders.bank_name',
  95. 'payment_orders.status',
  96. 'payment_orders.created_at as submitted_at',
  97. 'users.user_id',
  98. 'users.username',
  99. 'users.first_name',
  100. ]);
  101. if ($withRawData) {
  102. $query->addSelect([
  103. 'payment_orders.pay_data',
  104. 'payment_orders.callback_data',
  105. 'payment_orders.remark',
  106. 'payment_orders.admin_note',
  107. ]);
  108. }
  109. self::selectPlatform($query);
  110. self::applyFilters($query, $params);
  111. return $query;
  112. }
  113. /**
  114. * @return array<int, BalanceLog>
  115. */
  116. private static function creditLogs(array $orderIds): array
  117. {
  118. if ($orderIds === []) {
  119. return [];
  120. }
  121. $logs = [];
  122. $rows = BalanceLog::query()
  123. ->select(['id', 'related_id', 'before_balance', 'after_balance', 'created_at'])
  124. ->whereIn('related_id', $orderIds)
  125. ->where('balance_logs.change_type', '三方充值')
  126. ->orderBy('balance_logs.id')
  127. ->get();
  128. foreach ($rows as $row) {
  129. $logs[(int)$row->related_id] = $row;
  130. }
  131. return $logs;
  132. }
  133. private static function selectPlatform(Builder $query): void
  134. {
  135. if (!self::hasPlatformColumn()) {
  136. $query->selectRaw("'' AS platform");
  137. return;
  138. }
  139. $platformSql = self::platformSql();
  140. $query->selectRaw("({$platformSql}) AS platform");
  141. }
  142. private static function applyFilters(Builder $query, array $params): void
  143. {
  144. if (!empty($params['id'])) {
  145. $query->where('payment_orders.id', (int)$params['id']);
  146. }
  147. if (!empty($params['member_id'])) {
  148. $query->where('payment_orders.member_id', (string)$params['member_id']);
  149. }
  150. if (!empty($params['payment_company'])) {
  151. self::filterPaymentCompany($query, (string)$params['payment_company']);
  152. }
  153. if (!empty($params['payment_method'])) {
  154. $query->where('payment_orders.bank_name', (string)$params['payment_method']);
  155. }
  156. if (!empty($params['channel'])) {
  157. $query->where('payment_orders.channel', (string)$params['channel']);
  158. }
  159. if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
  160. $query->where('payment_orders.status', (int)$params['status']);
  161. }
  162. if (!empty($params['submit_start_date']) && !empty($params['submit_end_date'])) {
  163. [$start, $end] = self::dateRange($params['submit_start_date'], $params['submit_end_date']);
  164. $query->whereBetween('payment_orders.created_at', [$start, $end]);
  165. }
  166. if (!empty($params['credit_start_date']) && !empty($params['credit_end_date'])) {
  167. [$start, $end] = self::dateRange($params['credit_start_date'], $params['credit_end_date']);
  168. $query->whereIn('payment_orders.id', function ($subquery) use ($start, $end) {
  169. $subquery->select('related_id')
  170. ->from('balance_logs')
  171. ->where('balance_logs.change_type', '三方充值')
  172. ->whereBetween('balance_logs.created_at', [$start, $end]);
  173. });
  174. }
  175. if (!empty($params['platform'])) {
  176. if (!self::hasPlatformColumn()) {
  177. $query->whereRaw('1 = 0');
  178. } else {
  179. $query->whereRaw('(' . self::platformSql() . ') = ?', [(string)$params['platform']]);
  180. }
  181. }
  182. }
  183. private static function filterPaymentCompany(Builder $query, string $company): void
  184. {
  185. $zimuChannels = [
  186. ZimuPayService::CHANNEL_RECHARGE,
  187. '808pay',
  188. '808支付',
  189. ];
  190. $noChannels = [
  191. NoPayService::CHANNEL_SCAN,
  192. NoPayService::CHANNEL_BALANCE,
  193. ];
  194. $jdChannels = [
  195. JdPayService::CHANNEL,
  196. 'JD钱包',
  197. ];
  198. $knownChannels = array_merge($zimuChannels, $noChannels, $jdChannels);
  199. if ($company === 'zimu') {
  200. $query->whereIn('payment_orders.channel', $zimuChannels);
  201. } elseif ($company === 'no') {
  202. $query->whereIn('payment_orders.channel', $noChannels);
  203. } elseif ($company === 'jd') {
  204. $query->whereIn('payment_orders.channel', $jdChannels);
  205. } else {
  206. $query->where(function (Builder $companyQuery) use ($knownChannels) {
  207. $companyQuery->whereNull('payment_orders.channel')
  208. ->orWhereNotIn('payment_orders.channel', $knownChannels);
  209. });
  210. }
  211. }
  212. private static function format(PaymentOrder $row, bool $withRawData, ?BalanceLog $creditLog): array
  213. {
  214. $company = self::paymentCompany((string)$row->channel);
  215. $callbackData = self::decodeJson($row->callback_data ?? null);
  216. $payData = self::decodeJson($row->pay_data ?? null);
  217. $data = [
  218. 'id' => (int)$row->id,
  219. 'member_id' => (string)$row->member_id,
  220. 'username' => (string)($row->username ?? ''),
  221. 'first_name' => (string)($row->first_name ?? ''),
  222. 'payment_company' => $company['code'],
  223. 'payment_company_name' => $company['name'],
  224. 'payment_method' => (string)($row->bank_name ?? ''),
  225. 'channel_code' => (string)($row->channel ?? ''),
  226. 'amount' => self::money($row->amount),
  227. 'fee' => self::money($row->fee),
  228. 'before_balance' => self::nullableMoney($creditLog?->before_balance),
  229. 'after_balance' => self::nullableMoney($creditLog?->after_balance),
  230. 'order_no' => (string)($row->order_no ?? ''),
  231. 'gateway_order_no' => (string)($row->pay_no ?? ''),
  232. 'gateway_response' => self::gatewayReference($callbackData, $payData, (string)($row->pay_no ?? '')),
  233. 'submitted_at' => self::formatTime($row->submitted_at ?? null),
  234. 'credited_at' => self::formatTime($creditLog?->created_at),
  235. 'platform' => (string)($row->platform ?? ''),
  236. 'status' => (int)$row->status,
  237. 'status_text' => self::statusText((int)$row->status),
  238. ];
  239. if ($withRawData) {
  240. $data['remark'] = (string)($row->remark ?? '');
  241. $data['admin_note'] = (string)($row->admin_note ?? '');
  242. $data['pay_data'] = self::sanitizePayload($payData);
  243. $data['callback_data'] = self::sanitizePayload($callbackData);
  244. }
  245. return $data;
  246. }
  247. private static function options(): array
  248. {
  249. $paymentMethods = PaymentOrder::query()
  250. ->where('type', PaymentOrderService::TYPE_PAY)
  251. ->whereNotNull('bank_name')
  252. ->where('bank_name', '<>', '')
  253. ->distinct()
  254. ->orderBy('bank_name')
  255. ->pluck('bank_name')
  256. ->map(static fn(string $name): array => ['label' => $name, 'value' => $name])
  257. ->values()
  258. ->all();
  259. $platforms = [];
  260. if (self::hasPlatformColumn()) {
  261. $platforms = UserLogin::query()
  262. ->whereNotNull('platform')
  263. ->where('platform', '<>', '')
  264. ->distinct()
  265. ->orderBy('platform')
  266. ->pluck('platform')
  267. ->map(static fn(string $platform): array => ['label' => $platform, 'value' => $platform])
  268. ->values()
  269. ->all();
  270. }
  271. return [
  272. 'payment_companies' => array_map(static fn(string $code, string $name): array => [
  273. 'label' => $name,
  274. 'value' => $code,
  275. ], array_keys(self::COMPANIES), array_values(self::COMPANIES)),
  276. 'payment_methods' => $paymentMethods,
  277. 'statuses' => array_map(static fn(int $value, string $label): array => [
  278. 'label' => $label,
  279. 'value' => $value,
  280. ], array_keys(self::STATUSES), array_values(self::STATUSES)),
  281. 'platforms' => $platforms,
  282. ];
  283. }
  284. private static function platformSql(): string
  285. {
  286. $prefix = DB::getTablePrefix();
  287. $login = $prefix . 'user_login';
  288. $users = $prefix . 'users';
  289. $paymentOrders = $prefix . 'payment_orders';
  290. $statusCondition = self::hasLoginStatusColumn()
  291. ? 'AND ul.status = ' . (int)UserLogin::STATUS_SUCCESS
  292. : '';
  293. return "SELECT ul.platform
  294. FROM `{$login}` ul
  295. WHERE ul.user_id COLLATE utf8mb4_general_ci = `{$users}`.`user_id` COLLATE utf8mb4_general_ci
  296. {$statusCondition}
  297. AND ul.created_at <= `{$paymentOrders}`.`created_at`
  298. ORDER BY ul.id DESC
  299. LIMIT 1";
  300. }
  301. private static function hasPlatformColumn(): bool
  302. {
  303. if (self::$hasPlatform === null) {
  304. self::$hasPlatform = Schema::hasTable('user_login')
  305. && Schema::hasColumn('user_login', 'platform');
  306. }
  307. return self::$hasPlatform;
  308. }
  309. private static function hasLoginStatusColumn(): bool
  310. {
  311. if (self::$hasLoginStatus === null) {
  312. self::$hasLoginStatus = Schema::hasTable('user_login')
  313. && Schema::hasColumn('user_login', 'status');
  314. }
  315. return self::$hasLoginStatus;
  316. }
  317. private static function decodeJson($value)
  318. {
  319. if (is_array($value) || $value === null || $value === '') {
  320. return $value ?: null;
  321. }
  322. $decoded = json_decode((string)$value, true);
  323. return json_last_error() === JSON_ERROR_NONE ? $decoded : (string)$value;
  324. }
  325. private static function gatewayReference($callbackData, $payData, string $fallback): string
  326. {
  327. if ($fallback !== '') {
  328. return $fallback;
  329. }
  330. $keys = [
  331. 'tradeNo', 'trade_no', 'transactionId', 'transaction_id', 'txid',
  332. 'gatewayOrderNo', 'gateway_order_no', 'platformOrderNo', 'platform_order_no',
  333. 'orderNo', 'order_no', 'orderId', 'order_id',
  334. ];
  335. foreach ([$callbackData, $payData] as $payload) {
  336. $reference = self::findValueByKeys($payload, $keys);
  337. if ($reference !== '') {
  338. return $reference;
  339. }
  340. }
  341. return '';
  342. }
  343. private static function findValueByKeys($payload, array $keys): string
  344. {
  345. if (!is_array($payload)) {
  346. return '';
  347. }
  348. foreach ($keys as $key) {
  349. if (isset($payload[$key]) && is_scalar($payload[$key]) && (string)$payload[$key] !== '') {
  350. return (string)$payload[$key];
  351. }
  352. }
  353. foreach ($payload as $value) {
  354. $reference = self::findValueByKeys($value, $keys);
  355. if ($reference !== '') {
  356. return $reference;
  357. }
  358. }
  359. return '';
  360. }
  361. private static function sanitizePayload($payload)
  362. {
  363. if (!is_array($payload)) {
  364. return $payload;
  365. }
  366. $sensitiveKeys = ['sign', 'signature', 'key', 'secret', 'password'];
  367. foreach ($payload as $key => $value) {
  368. if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
  369. unset($payload[$key]);
  370. continue;
  371. }
  372. if (is_array($value)) {
  373. $payload[$key] = self::sanitizePayload($value);
  374. }
  375. }
  376. return $payload;
  377. }
  378. private static function money($amount): string
  379. {
  380. return number_format((float)$amount, 2, '.', '');
  381. }
  382. private static function nullableMoney($amount): ?string
  383. {
  384. return $amount === null || $amount === '' ? null : self::money($amount);
  385. }
  386. private static function formatTime($value): ?string
  387. {
  388. if (empty($value)) {
  389. return null;
  390. }
  391. return Carbon::parse($value)
  392. ->setTimezone(config('app.timezone', 'Asia/Shanghai'))
  393. ->format('Y-m-d H:i:s');
  394. }
  395. /**
  396. * @return array{0: Carbon, 1: Carbon}
  397. */
  398. private static function dateRange(string $startDate, string $endDate): array
  399. {
  400. $timezone = config('app.timezone', 'Asia/Shanghai');
  401. return [
  402. Carbon::createFromFormat('Y-m-d', $startDate, $timezone)->startOfDay(),
  403. Carbon::createFromFormat('Y-m-d', $endDate, $timezone)->endOfDay(),
  404. ];
  405. }
  406. }