RentalUserLogic.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. <?php
  2. namespace plugin\saiadmin\app\logic\user;
  3. use plugin\saiadmin\app\model\system\SystemUser;
  4. use plugin\saiadmin\app\model\user\RentalUser;
  5. use plugin\saiadmin\basic\think\BaseLogic;
  6. use plugin\saiadmin\exception\ApiException;
  7. use support\think\Db;
  8. /**
  9. * 租借用户列表与订单记录。
  10. *
  11. * 参照旧项目 Search::query_users():用户资料独立存储,租借次数和消费金额
  12. * 从订单实时汇总。非超级管理员仅能看到本人及递归下级管理订单涉及的用户。
  13. */
  14. class RentalUserLogic extends BaseLogic
  15. {
  16. private const RECHARGE_BALANCE_COLUMNS = [
  17. 'wallet' => 'wallet_balance',
  18. 'card_gift' => 'card_gift_balance',
  19. ];
  20. private const ORDER_STATUSES = [
  21. 0 => '待支付',
  22. 1 => '使用中',
  23. 2 => '已完成',
  24. 3 => '超时完成',
  25. 4 => '租借失败',
  26. 5 => '扣款失败',
  27. 6 => '申请退款',
  28. ];
  29. private const MEMBER_CARD_TYPE_NAMES = [
  30. 0 => '月卡',
  31. 1 => '季卡',
  32. 2 => '半年卡',
  33. 3 => '年卡',
  34. ];
  35. public function __construct()
  36. {
  37. $this->model = new RentalUser();
  38. }
  39. public function indexList(array $filters): array
  40. {
  41. $page = max(1, (int) request()->input('page', 1));
  42. $limit = min(100, max(1, (int) request()->input('limit', 20)));
  43. $managerIds = $this->accessibleManagerIds();
  44. $query = Db::table('sa_rental_user')->alias('u')->whereNull('u.delete_time');
  45. if (is_array($managerIds)) {
  46. $userIds = $managerIds === [] ? [] : Db::table('sa_rental_order')
  47. ->whereNull('delete_time')
  48. ->whereIn('manage_agent_id', $managerIds)
  49. ->where('user_id', '>', 0)
  50. ->distinct(true)
  51. ->column('user_id');
  52. if ($userIds === []) {
  53. $query->where('u.id', 0);
  54. } else {
  55. $query->whereIn('u.id', array_map('intval', $userIds));
  56. }
  57. }
  58. $source = trim((string) ($filters['source'] ?? ''));
  59. if (in_array($source, ['1', '2'], true)) {
  60. $query->where('u.source', (int) $source);
  61. }
  62. $userId = (int) ($filters['user_id'] ?? 0);
  63. if ($userId > 0) {
  64. $query->where('u.id', $userId);
  65. }
  66. $this->like($query, 'u.nickname', $filters['nickname'] ?? '');
  67. $this->like($query, 'u.phone', $filters['phone'] ?? '');
  68. $result = $query->field([
  69. 'u.id', 'u.agent_id', 'u.source', 'u.nickname', 'u.phone', 'u.avatar',
  70. 'u.deposit_amount', 'u.wallet_balance', 'u.card_balance', 'u.card_gift_balance',
  71. 'u.member_expire_at', 'u.member_card_type',
  72. 'u.status', 'u.register_at', 'u.create_time',
  73. ])->order('u.register_at', 'desc')->order('u.id', 'desc')
  74. ->paginate(['page' => $page, 'list_rows' => $limit])->toArray();
  75. $rows = $result['data'] ?? [];
  76. $stats = $this->orderStats(array_map('intval', array_column($rows, 'id')), $managerIds);
  77. $result['data'] = array_map(function (array $row) use ($stats): array {
  78. $userId = (int) $row['id'];
  79. $stat = $stats[$userId] ?? ['rent_count' => 0, 'amount' => 0.0];
  80. $cardType = (int) ($row['member_card_type'] ?? 0);
  81. $expireAt = (string) ($row['member_expire_at'] ?? '');
  82. $memberActive = $expireAt !== '' && strtotime($expireAt) > time();
  83. return [
  84. 'id' => $userId,
  85. 'agent_id' => (int) ($row['agent_id'] ?? 0),
  86. 'source' => (int) ($row['source'] ?? 1),
  87. 'source_name' => (int) ($row['source'] ?? 1) === 2 ? '支付宝' : '微信',
  88. 'nickname' => (string) (($row['nickname'] ?? '') ?: '未设置昵称'),
  89. 'phone' => (string) ($row['phone'] ?? ''),
  90. 'avatar' => (string) ($row['avatar'] ?? ''),
  91. 'deposit_amount' => (float) ($row['deposit_amount'] ?? 0),
  92. 'rent_count' => (int) $stat['rent_count'],
  93. 'amount' => (float) $stat['amount'],
  94. 'wallet_balance' => (float) ($row['wallet_balance'] ?? 0),
  95. 'card_balance' => (float) ($row['card_balance'] ?? 0),
  96. 'card_gift_balance' => (float) ($row['card_gift_balance'] ?? 0),
  97. 'member_expire_at' => $expireAt,
  98. 'member_card_type' => $cardType,
  99. 'member_card_type_name' => self::MEMBER_CARD_TYPE_NAMES[$cardType] ?? '未开通',
  100. 'member_active' => $memberActive,
  101. 'status' => (int) ($row['status'] ?? 1),
  102. 'register_at' => (string) (($row['register_at'] ?? '') ?: ($row['create_time'] ?? '')),
  103. ];
  104. }, $rows);
  105. return $result;
  106. }
  107. /**
  108. * 管理员对钱包余额或储值卡赠额加款/扣款并写流水。
  109. *
  110. * 沿用 list/orders 的可见范围:超级管理员可对任意用户充值;
  111. * 普通账号只能对本人及递归下级管理订单涉及的用户充值。事务内行锁防并发,
  112. * 扣款时禁止减为负数。
  113. *
  114. * 旧系统的平台“储值卡充值”只调整赠额;实付本金必须来自支付回调,
  115. * 因此管理端不开放 card_balance 手工调整。
  116. *
  117. * @param array $data user_id, balance_type(wallet/card_gift), amount, remark
  118. */
  119. public function recharge(array $data, int $operatorId, string $operatorName): array
  120. {
  121. $userId = (int) ($data['user_id'] ?? 0);
  122. if ($userId <= 0) {
  123. throw new ApiException('用户参数错误');
  124. }
  125. $balanceType = trim((string) ($data['balance_type'] ?? ''));
  126. if ($balanceType === '') {
  127. $balanceType = 'wallet';
  128. }
  129. if (!isset(self::RECHARGE_BALANCE_COLUMNS[$balanceType])) {
  130. throw new ApiException('账户类型不支持');
  131. }
  132. $amountInput = trim((string) ($data['amount'] ?? ''));
  133. if (!preg_match('/^-?\d{1,10}(?:\.\d{1,2})?$/D', $amountInput)) {
  134. throw new ApiException('调整金额最多 10 位整数、2 位小数');
  135. }
  136. $amount = (float) $amountInput;
  137. if ($amount === 0.0) {
  138. throw new ApiException('调整金额不能为 0');
  139. }
  140. $remark = trim((string) ($data['remark'] ?? ''));
  141. $managerIds = $this->accessibleManagerIds();
  142. $this->assertUserAccessible($userId, $managerIds);
  143. return $this->transaction(function () use ($userId, $balanceType, $amount, $remark, $operatorId, $operatorName) {
  144. $user = Db::table('sa_rental_user')
  145. ->where('id', $userId)
  146. ->whereNull('delete_time')
  147. ->lock(true)
  148. ->find();
  149. if (!$user) {
  150. throw new ApiException('用户不存在');
  151. }
  152. $column = self::RECHARGE_BALANCE_COLUMNS[$balanceType];
  153. $before = round((float) ($user[$column] ?? 0), 2);
  154. $after = round($before + $amount, 2);
  155. if ($after < 0) {
  156. throw new ApiException('余额不足,无法扣减');
  157. }
  158. if ($after > 9999999999.99) {
  159. throw new ApiException('调整后余额超出系统上限');
  160. }
  161. $direction = $amount > 0 ? 1 : -1;
  162. $now = date('Y-m-d H:i:s');
  163. Db::table('sa_rental_user')->where('id', $userId)->update([
  164. $column => $after,
  165. 'update_time' => $now,
  166. ]);
  167. Db::table('sa_rental_user_wallet_log')->insert([
  168. 'user_id' => $userId,
  169. 'balance_type' => $balanceType,
  170. 'direction' => $direction,
  171. 'amount' => $amount,
  172. 'before_balance' => $before,
  173. 'after_balance' => $after,
  174. 'source' => 'admin_recharge',
  175. 'remark' => $remark,
  176. 'operator_id' => $operatorId,
  177. 'operator_name' => $operatorName,
  178. 'created_at' => $now,
  179. ]);
  180. return [
  181. 'user_id' => $userId,
  182. 'balance_type' => $balanceType,
  183. 'amount' => $amount,
  184. 'before_balance' => $before,
  185. 'after_balance' => $after,
  186. ];
  187. });
  188. }
  189. /**
  190. * 管理员对租借用户开通或续费会员。
  191. *
  192. * 沿用 list/orders 的可见范围(超级管理员可对任意用户,
  193. * 普通账号只能对本人及递归下级管理订单涉及的用户)。事务内:
  194. * 1. 写 sa_member_activation_record(source=3 后台开通)
  195. * 2. 同步 sa_rental_user.member_expire_at / member_card_type 快照
  196. *
  197. * 退订/撤销流程不在本期范围。开通金额允许为 0(管理员手工赠送),
  198. * 但不能为负。日期超长到期(>9999-12-31)由 validate 拒绝。
  199. *
  200. * @param array $data user_id, card_type(0-3), pay_amount, expire_time, activation_time?, remark?
  201. */
  202. public function activateMember(array $data, int $operatorId, string $operatorName): array
  203. {
  204. $userId = (int) ($data['user_id'] ?? 0);
  205. if ($userId <= 0) {
  206. throw new ApiException('用户参数错误');
  207. }
  208. $cardType = (int) ($data['card_type'] ?? -1);
  209. if (!isset(self::MEMBER_CARD_TYPE_NAMES[$cardType])) {
  210. throw new ApiException('会员卡类型不支持');
  211. }
  212. $payAmountInput = trim((string) ($data['pay_amount'] ?? ''));
  213. if (!preg_match('/^\d{1,10}(?:\.\d{1,2})?$/D', $payAmountInput)) {
  214. throw new ApiException('支付金额最多 10 位整数、2 位小数');
  215. }
  216. $payAmount = (float) $payAmountInput;
  217. $expireTime = trim((string) ($data['expire_time'] ?? ''));
  218. if ($expireTime === '' || !strtotime($expireTime)) {
  219. throw new ApiException('到期时间不合法');
  220. }
  221. if (strtotime($expireTime) <= time()) {
  222. throw new ApiException('到期时间必须晚于当前时间');
  223. }
  224. $activationTime = trim((string) ($data['activation_time'] ?? ''));
  225. if ($activationTime === '') {
  226. $activationTime = date('Y-m-d H:i:s');
  227. } elseif (!strtotime($activationTime)) {
  228. throw new ApiException('开通时间格式不正确');
  229. }
  230. $remark = trim((string) ($data['remark'] ?? ''));
  231. if (mb_strlen($remark) > 500) {
  232. throw new ApiException('备注不能超过 500 个字符');
  233. }
  234. $managerIds = $this->accessibleManagerIds();
  235. $this->assertUserAccessible($userId, $managerIds);
  236. return $this->transaction(function () use ($userId, $cardType, $payAmount, $expireTime, $activationTime, $remark, $operatorId, $operatorName) {
  237. $user = Db::table('sa_rental_user')
  238. ->where('id', $userId)
  239. ->whereNull('delete_time')
  240. ->lock(true)
  241. ->find();
  242. if (!$user) {
  243. throw new ApiException('用户不存在');
  244. }
  245. $now = date('Y-m-d H:i:s');
  246. $orderSn = 'ADMIN-MEMBER-' . $userId . '-' . date('YmdHis') . sprintf('%04d', mt_rand(0, 9999));
  247. Db::table('sa_member_activation_record')->insert([
  248. 'merchant_order_sn' => $orderSn,
  249. 'transaction_sn' => '',
  250. 'user_id' => $userId,
  251. 'manage_agent_id' => $operatorId,
  252. 'source' => 3,
  253. 'device_type' => 0,
  254. 'merchant_id' => 0,
  255. 'merchant_name' => '',
  256. 'pay_amount' => $payAmount,
  257. 'card_type' => $cardType,
  258. 'activation_time' => $activationTime,
  259. 'expire_time' => $expireTime,
  260. 'status' => 1,
  261. 'remark' => $remark,
  262. 'created_by' => $operatorId,
  263. 'updated_by' => $operatorId,
  264. 'create_time' => $now,
  265. 'update_time' => $now,
  266. ]);
  267. Db::table('sa_rental_user')->where('id', $userId)->update([
  268. 'member_expire_at' => $expireTime,
  269. 'member_card_type' => $cardType,
  270. 'update_time' => $now,
  271. ]);
  272. return [
  273. 'user_id' => $userId,
  274. 'card_type' => $cardType,
  275. 'card_type_name' => self::MEMBER_CARD_TYPE_NAMES[$cardType],
  276. 'pay_amount' => round($payAmount, 2),
  277. 'activation_time' => $activationTime,
  278. 'expire_time' => $expireTime,
  279. 'merchant_order_sn' => $orderSn,
  280. ];
  281. });
  282. }
  283. public function orderList(int $userId): array
  284. {
  285. if ($userId <= 0) {
  286. throw new ApiException('用户参数错误');
  287. }
  288. $managerIds = $this->accessibleManagerIds();
  289. $this->assertUserAccessible($userId, $managerIds);
  290. $page = max(1, (int) request()->input('page', 1));
  291. $limit = min(100, max(1, (int) request()->input('limit', 20)));
  292. $query = Db::table('sa_rental_order')->alias('o')
  293. ->leftJoin('sa_device d', 'd.id = o.device_id AND d.delete_time IS NULL')
  294. ->leftJoin('sa_merchant m', 'm.id = o.rent_merchant_id AND m.delete_time IS NULL')
  295. ->whereNull('o.delete_time')->where('o.user_id', $userId);
  296. if (is_array($managerIds)) {
  297. $query->whereIn('o.manage_agent_id', $managerIds);
  298. }
  299. $result = $query->field([
  300. 'o.id', 'o.order_sn', 'o.device_type', 'o.rent_at', 'o.return_at', 'o.status',
  301. 'o.paid_amount', 'o.refund_amount', 'o.manage_agent_id',
  302. 'd.goods_name AS device_name', 'd.goods_sn AS device_sn', 'm.name AS merchant_name',
  303. ])->order('o.rent_at', 'desc')->order('o.id', 'desc')
  304. ->paginate(['page' => $page, 'list_rows' => $limit])->toArray();
  305. $result['data'] = array_map(fn (array $row): array => [
  306. 'id' => (int) $row['id'],
  307. 'order_sn' => (string) $row['order_sn'],
  308. 'device_type' => (int) ($row['device_type'] ?? 0),
  309. 'device_name' => (string) ($row['device_name'] ?? ''),
  310. 'device_sn' => (string) ($row['device_sn'] ?? ''),
  311. 'merchant_name' => (string) ($row['merchant_name'] ?? ''),
  312. 'rent_at' => (string) ($row['rent_at'] ?? ''),
  313. 'return_at' => (string) ($row['return_at'] ?? ''),
  314. 'status' => (int) ($row['status'] ?? 0),
  315. 'status_name' => self::ORDER_STATUSES[(int) ($row['status'] ?? 0)] ?? '未知状态',
  316. 'paid_amount' => (float) ($row['paid_amount'] ?? 0),
  317. 'refund_amount' => (float) ($row['refund_amount'] ?? 0),
  318. 'manage_agent_id' => (int) ($row['manage_agent_id'] ?? 0),
  319. ], $result['data'] ?? []);
  320. return $result;
  321. }
  322. /** @param int[]|null $managerIds */
  323. private function orderStats(array $userIds, ?array $managerIds): array
  324. {
  325. if ($userIds === []) {
  326. return [];
  327. }
  328. $query = Db::table('sa_rental_order')->whereNull('delete_time')->whereIn('user_id', $userIds);
  329. if (is_array($managerIds)) {
  330. $query->whereIn('manage_agent_id', $managerIds);
  331. }
  332. $rows = $query->field('user_id,COUNT(*) AS rent_count,COALESCE(SUM(paid_amount),0) AS amount')
  333. ->group('user_id')->select()->toArray();
  334. $result = [];
  335. foreach ($rows as $row) {
  336. $result[(int) $row['user_id']] = [
  337. 'rent_count' => (int) $row['rent_count'],
  338. 'amount' => (float) $row['amount'],
  339. ];
  340. }
  341. return $result;
  342. }
  343. /** @param int[]|null $managerIds */
  344. private function assertUserAccessible(int $userId, ?array $managerIds): void
  345. {
  346. $exists = Db::table('sa_rental_user')->where('id', $userId)->whereNull('delete_time')->count();
  347. if ($exists === 0) {
  348. throw new ApiException('用户不存在');
  349. }
  350. if (is_array($managerIds)) {
  351. if ($managerIds === []) {
  352. throw new ApiException('用户不存在或无权查看');
  353. }
  354. $orderExists = Db::table('sa_rental_order')->where('user_id', $userId)
  355. ->whereIn('manage_agent_id', $managerIds)->whereNull('delete_time')->count();
  356. if ($orderExists === 0) {
  357. throw new ApiException('用户不存在或无权查看');
  358. }
  359. }
  360. }
  361. /**
  362. * null 表示超级管理员可查看全部;数组表示本人及递归下级管理人范围。
  363. * @return int[]|null
  364. */
  365. private function accessibleManagerIds(): ?array
  366. {
  367. $currentId = (int) ($this->adminInfo['id'] ?? 0);
  368. if ($currentId === 1) {
  369. return null;
  370. }
  371. if ($currentId <= 0) {
  372. return [];
  373. }
  374. $managerIds = [$currentId];
  375. $dept = $this->adminInfo['deptList'] ?? [];
  376. if (empty($dept['id']) || !isset($dept['level'])) {
  377. return $managerIds;
  378. }
  379. $prefix = (string) $dept['level'] . (int) $dept['id'] . ',';
  380. $deptIds = Db::table('sa_system_dept')->whereNull('delete_time')
  381. ->whereLike('level', $prefix . '%')->column('id');
  382. if ($deptIds !== []) {
  383. $managerIds = array_merge($managerIds, array_map('intval', SystemUser::whereIn('dept_id', $deptIds)
  384. ->whereNull('delete_time')->column('id')));
  385. }
  386. return array_values(array_unique($managerIds));
  387. }
  388. private function like($query, string $field, mixed $value): void
  389. {
  390. $value = trim((string) $value);
  391. if ($value !== '') {
  392. $query->whereLike($field, '%' . $value . '%');
  393. }
  394. }
  395. }