UserFeedbackLogic.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. <?php
  2. namespace plugin\saiadmin\app\logic\user;
  3. use plugin\saiadmin\app\model\system\SystemUser;
  4. use plugin\saiadmin\app\model\user\UserFeedback;
  5. use plugin\saiadmin\basic\think\BaseLogic;
  6. use plugin\saiadmin\exception\ApiException;
  7. use plugin\saiadmin\service\OpenSpoutWriter;
  8. use support\Response;
  9. use support\think\Db;
  10. /** 用户反馈列表、详情、回复和处理状态逻辑。 */
  11. class UserFeedbackLogic extends BaseLogic
  12. {
  13. private const TYPE_NAMES = [1 => '订单问题', 2 => '系统问题', 3 => '其他问题'];
  14. private const STATUS_NAMES = [0 => '未处理', 1 => '处理中', 2 => '已处理'];
  15. private const DEVICE_NAMES = [
  16. 1 => '充电底座', 100 => 'K霸宝', 101 => '充电宝', 102 => 'K霸宝 Blue',
  17. 103 => '密码线充', 104 => '蓝牙线充', 105 => '密码蓝牙线充', 107 => '车载充电器',
  18. ];
  19. public function __construct()
  20. {
  21. $this->model = new UserFeedback();
  22. }
  23. public function indexList(array $filters): array
  24. {
  25. $page = max(1, (int) request()->input('page', 1));
  26. $limit = min(100, max(1, (int) request()->input('limit', 20)));
  27. $result = $this->buildQuery($filters)
  28. ->order('f.create_time', 'desc')
  29. ->order('f.id', 'desc')
  30. ->paginate(['page' => $page, 'list_rows' => $limit])
  31. ->toArray();
  32. $rows = $result['data'] ?? [];
  33. $replies = $this->latestReplies(array_map('intval', array_column($rows, 'id')));
  34. $result['data'] = array_map(
  35. fn (array $row): array => $this->formatFeedback($row, $replies[(int) $row['id']] ?? null),
  36. $rows
  37. );
  38. $result['summary'] = $this->summary($filters);
  39. return $result;
  40. }
  41. public function readFeedback(int $id): array
  42. {
  43. $row = $this->buildQuery([])->where('f.id', $id)->find();
  44. if (!$row) {
  45. throw new ApiException('反馈不存在或无权查看');
  46. }
  47. $feedback = $this->formatFeedback($row);
  48. $replies = Db::table('sa_user_feedback_reply')->where('feedback_id', $id)
  49. ->whereNull('delete_time')->order('id', 'asc')->select()->toArray();
  50. $feedback['replies'] = array_map(fn (array $reply): array => [
  51. 'id' => (int) $reply['id'],
  52. 'author_type' => (int) $reply['author_type'],
  53. 'author_name' => (string) $reply['author_name'],
  54. 'content' => (string) $reply['content'],
  55. 'images' => $this->decodeImages($reply['images'] ?? ''),
  56. 'create_time' => (string) $reply['create_time'],
  57. ], $replies);
  58. return $feedback;
  59. }
  60. public function updateStatus(array $data, int $operatorId, string $operatorName): void
  61. {
  62. $id = (int) ($data['id'] ?? 0);
  63. $this->assertAccessible($id);
  64. $status = (int) $data['status'];
  65. $now = date('Y-m-d H:i:s');
  66. Db::table('sa_user_feedback')->where('id', $id)->update([
  67. 'status' => $status,
  68. 'remark' => trim((string) ($data['remark'] ?? '')),
  69. 'handled_by' => $operatorId,
  70. 'handled_name' => $operatorName,
  71. 'handled_at' => $status === 2 ? $now : null,
  72. 'update_time' => $now,
  73. ]);
  74. }
  75. public function reply(array $data, int $operatorId, string $operatorName): array
  76. {
  77. $id = (int) ($data['id'] ?? 0);
  78. $feedback = $this->assertAccessible($id);
  79. $content = trim((string) $data['content']);
  80. $now = date('Y-m-d H:i:s');
  81. $replyId = $this->transaction(function () use ($id, $feedback, $content, $operatorId, $operatorName, $now) {
  82. $replyId = Db::table('sa_user_feedback_reply')->insertGetId([
  83. 'feedback_id' => $id,
  84. 'author_type' => 0,
  85. 'author_id' => $operatorId,
  86. 'author_name' => $operatorName,
  87. 'content' => $content,
  88. 'images' => '[]',
  89. 'create_time' => $now,
  90. 'update_time' => $now,
  91. ]);
  92. $update = [
  93. 'handled_by' => $operatorId,
  94. 'handled_name' => $operatorName,
  95. 'update_time' => $now,
  96. ];
  97. if ((int) ($feedback['status'] ?? 0) === 0) {
  98. $update['status'] = 1;
  99. }
  100. Db::table('sa_user_feedback')->where('id', $id)->update($update);
  101. return $replyId;
  102. });
  103. return ['id' => (int) $replyId];
  104. }
  105. public function export(array $filters): Response
  106. {
  107. $rows = $this->buildQuery($filters)->order('f.create_time', 'desc')->order('f.id', 'desc')
  108. ->select()->toArray();
  109. $latest = $this->latestReplies(array_map('intval', array_column($rows, 'id')));
  110. $data = [];
  111. foreach ($rows as $row) {
  112. $item = $this->formatFeedback($row, $latest[(int) $row['id']] ?? null);
  113. $data[] = [
  114. 'id' => $item['id'],
  115. 'identity' => $this->excelSafe($item['identity']),
  116. 'nickname' => $this->excelSafe($item['nickname']),
  117. 'phone' => $this->excelSafe($item['phone']),
  118. 'type_name' => $item['type_name'],
  119. 'merchant_name' => $this->excelSafe($item['merchant_name']),
  120. 'device_sn' => $this->excelSafe($item['device_sn']),
  121. 'device_type_name' => $item['device_type_name'],
  122. 'content' => $this->excelSafe($item['content']),
  123. 'status_name' => $item['status_name'],
  124. 'latest_reply' => $this->excelSafe($item['latest_reply']),
  125. 'remark' => $this->excelSafe($item['remark']),
  126. 'create_time' => $item['create_time'],
  127. ];
  128. }
  129. $fileName = '用户反馈_' . date('Ymd_His') . '.xlsx';
  130. $writer = new OpenSpoutWriter($fileName);
  131. $writer->setWidth([10, 12, 18, 16, 14, 22, 22, 16, 40, 12, 35, 30, 20]);
  132. $writer->setHeader([
  133. '反馈ID', '身份', '用户昵称', '联系电话', '问题类型', '商户', '设备SN',
  134. '设备类型', '反馈内容', '处理状态', '最新回复', '备注', '反馈时间',
  135. ]);
  136. $writer->setData($data);
  137. return response()->download($writer->returnFile(), urlencode($fileName));
  138. }
  139. private function buildQuery(array $filters)
  140. {
  141. $query = Db::table('sa_user_feedback')->alias('f')
  142. ->leftJoin('sa_rental_user u', 'u.id = f.user_id AND u.delete_time IS NULL')
  143. ->leftJoin('sa_rental_order o', 'o.id = f.order_id AND o.delete_time IS NULL')
  144. ->leftJoin('sa_device d', 'd.id = f.device_id AND d.delete_time IS NULL')
  145. ->leftJoin('sa_merchant m', 'm.id = f.merchant_id AND m.delete_time IS NULL')
  146. ->whereNull('f.delete_time')
  147. ->field(implode(',', [
  148. 'f.id,f.agent_id,f.user_id,f.identity,f.type,f.order_id,f.merchant_id,f.merchant_name',
  149. 'f.device_id,f.device_sn,f.device_type,f.nickname,f.contact_phone,f.content,f.images',
  150. 'f.status,f.remark,f.handled_by,f.handled_name,f.handled_at,f.create_time,f.update_time',
  151. 'u.nickname AS user_nickname,u.phone AS user_phone',
  152. 'o.order_sn,o.transaction_id',
  153. 'd.goods_sn AS related_device_sn,d.device_code,d.goods_type AS related_device_type',
  154. 'm.name AS related_merchant_name',
  155. ]));
  156. $managerIds = $this->accessibleManagerIds();
  157. if (is_array($managerIds)) {
  158. if ($managerIds === []) {
  159. $query->where('f.id', 0);
  160. } else {
  161. $query->whereIn('f.agent_id', $managerIds);
  162. }
  163. }
  164. $status = trim((string) ($filters['status'] ?? ''));
  165. if (in_array($status, ['0', '1', '2'], true)) {
  166. $query->where('f.status', (int) $status);
  167. }
  168. $type = trim((string) ($filters['type'] ?? ''));
  169. if (in_array($type, ['1', '2', '3'], true)) {
  170. $query->where('f.type', (int) $type);
  171. }
  172. $deviceType = trim((string) ($filters['device_type'] ?? ''));
  173. if ($deviceType !== '' && ctype_digit($deviceType)) {
  174. $query->where('f.device_type', (int) $deviceType);
  175. }
  176. $this->like($query, 'f.contact_phone', $filters['phone'] ?? '');
  177. $this->like($query, 'f.merchant_name', $filters['merchant'] ?? '');
  178. $this->like($query, 'f.device_sn', $filters['device_sn'] ?? '');
  179. $this->like($query, 'f.content', $filters['content'] ?? '');
  180. $startDate = trim((string) ($filters['start_date'] ?? ''));
  181. $endDate = trim((string) ($filters['end_date'] ?? ''));
  182. if ($startDate !== '') {
  183. $query->where('f.create_time', '>=', $startDate . ' 00:00:00');
  184. }
  185. if ($endDate !== '') {
  186. $query->where('f.create_time', '<=', $endDate . ' 23:59:59');
  187. }
  188. return $query;
  189. }
  190. private function summary(array $filters): array
  191. {
  192. $summaryFilters = $filters;
  193. $summaryFilters['status'] = '';
  194. $rows = $this->buildQuery($summaryFilters)->field('f.status,COUNT(*) AS total')
  195. ->group('f.status')->select()->toArray();
  196. $summary = ['all' => 0, 'pending' => 0, 'processing' => 0, 'done' => 0];
  197. foreach ($rows as $row) {
  198. $count = (int) $row['total'];
  199. $summary['all'] += $count;
  200. if ((int) $row['status'] === 0) $summary['pending'] = $count;
  201. if ((int) $row['status'] === 1) $summary['processing'] = $count;
  202. if ((int) $row['status'] === 2) $summary['done'] = $count;
  203. }
  204. return $summary;
  205. }
  206. /** @return array<int,array> */
  207. private function latestReplies(array $feedbackIds): array
  208. {
  209. if ($feedbackIds === []) {
  210. return [];
  211. }
  212. $ids = Db::table('sa_user_feedback_reply')->whereIn('feedback_id', $feedbackIds)
  213. ->whereNull('delete_time')->field('MAX(id) AS id')->group('feedback_id')->column('id');
  214. if ($ids === []) {
  215. return [];
  216. }
  217. $rows = Db::table('sa_user_feedback_reply')->whereIn('id', $ids)->select()->toArray();
  218. $map = [];
  219. foreach ($rows as $row) {
  220. $map[(int) $row['feedback_id']] = $row;
  221. }
  222. return $map;
  223. }
  224. private function formatFeedback(array $row, ?array $latestReply = null): array
  225. {
  226. $deviceType = (int) (($row['device_type'] ?? 0) ?: ($row['related_device_type'] ?? 0));
  227. $status = (int) ($row['status'] ?? 0);
  228. $type = (int) ($row['type'] ?? 3);
  229. return [
  230. 'id' => (int) $row['id'],
  231. 'agent_id' => (int) ($row['agent_id'] ?? 0),
  232. 'user_id' => (int) ($row['user_id'] ?? 0),
  233. 'identity' => (string) (($row['identity'] ?? '') ?: '用户'),
  234. 'nickname' => (string) (($row['nickname'] ?? '') ?: ($row['user_nickname'] ?? '匿名用户')),
  235. 'phone' => (string) (($row['contact_phone'] ?? '') ?: ($row['user_phone'] ?? '')),
  236. 'type' => $type,
  237. 'type_name' => self::TYPE_NAMES[$type] ?? '其他问题',
  238. 'order_id' => (int) ($row['order_id'] ?? 0),
  239. 'order_sn' => (string) ($row['order_sn'] ?? ''),
  240. 'transaction_id' => (string) ($row['transaction_id'] ?? ''),
  241. 'merchant_id' => (int) ($row['merchant_id'] ?? 0),
  242. 'merchant_name' => (string) (($row['merchant_name'] ?? '') ?: ($row['related_merchant_name'] ?? '')),
  243. 'device_id' => (int) ($row['device_id'] ?? 0),
  244. 'device_sn' => (string) (($row['device_sn'] ?? '') ?: ($row['related_device_sn'] ?? '')),
  245. 'device_code' => (string) ($row['device_code'] ?? ''),
  246. 'device_type' => $deviceType,
  247. 'device_type_name' => self::DEVICE_NAMES[$deviceType] ?? ($deviceType > 0 ? '其他设备' : '--'),
  248. 'content' => (string) ($row['content'] ?? ''),
  249. 'images' => $this->decodeImages($row['images'] ?? ''),
  250. 'status' => $status,
  251. 'status_name' => self::STATUS_NAMES[$status] ?? '未知',
  252. 'remark' => (string) ($row['remark'] ?? ''),
  253. 'handled_by' => (int) ($row['handled_by'] ?? 0),
  254. 'handled_name' => (string) ($row['handled_name'] ?? ''),
  255. 'handled_at' => (string) ($row['handled_at'] ?? ''),
  256. 'latest_reply_identity' => (string) ($latestReply['author_name'] ?? ''),
  257. 'latest_reply' => (string) ($latestReply['content'] ?? ''),
  258. 'create_time' => (string) ($row['create_time'] ?? ''),
  259. 'update_time' => (string) ($row['update_time'] ?? ''),
  260. ];
  261. }
  262. private function assertAccessible(int $id): array
  263. {
  264. if ($id <= 0) {
  265. throw new ApiException('反馈参数错误');
  266. }
  267. $query = Db::table('sa_user_feedback')->where('id', $id)->whereNull('delete_time');
  268. $managerIds = $this->accessibleManagerIds();
  269. if (is_array($managerIds)) {
  270. if ($managerIds === []) {
  271. throw new ApiException('反馈不存在或无权操作');
  272. }
  273. $query->whereIn('agent_id', $managerIds);
  274. }
  275. $feedback = $query->find();
  276. if (!$feedback) {
  277. throw new ApiException('反馈不存在或无权操作');
  278. }
  279. return $feedback;
  280. }
  281. /** @return int[]|null */
  282. private function accessibleManagerIds(): ?array
  283. {
  284. $currentId = (int) ($this->adminInfo['id'] ?? 0);
  285. if ($currentId === 1 || (int) ($this->adminInfo['is_super'] ?? 0) === 1) {
  286. return null;
  287. }
  288. if ($currentId <= 0) {
  289. return [];
  290. }
  291. $managerIds = [$currentId];
  292. $dept = $this->adminInfo['deptList'] ?? [];
  293. if (empty($dept['id']) || !isset($dept['level'])) {
  294. return $managerIds;
  295. }
  296. $prefix = (string) $dept['level'] . (int) $dept['id'] . ',';
  297. $deptIds = Db::table('sa_system_dept')->whereNull('delete_time')
  298. ->whereLike('level', $prefix . '%')->column('id');
  299. if ($deptIds !== []) {
  300. $managerIds = array_merge($managerIds, array_map('intval', SystemUser::whereIn('dept_id', $deptIds)
  301. ->whereNull('delete_time')->column('id')));
  302. }
  303. return array_values(array_unique($managerIds));
  304. }
  305. private function decodeImages(mixed $images): array
  306. {
  307. if (is_array($images)) {
  308. return array_values(array_filter(array_map('strval', $images)));
  309. }
  310. $decoded = json_decode((string) $images, true);
  311. return is_array($decoded) ? array_values(array_filter(array_map('strval', $decoded))) : [];
  312. }
  313. private function like($query, string $field, mixed $value): void
  314. {
  315. $value = trim((string) $value);
  316. if ($value !== '') {
  317. $query->whereLike($field, '%' . $value . '%');
  318. }
  319. }
  320. private function excelSafe(mixed $value): string
  321. {
  322. $value = (string) $value;
  323. return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value;
  324. }
  325. }