'订单问题', 2 => '系统问题', 3 => '其他问题']; private const STATUS_NAMES = [0 => '未处理', 1 => '处理中', 2 => '已处理']; private const DEVICE_NAMES = [ 1 => '充电底座', 100 => 'K霸宝', 101 => '充电宝', 102 => 'K霸宝 Blue', 103 => '密码线充', 104 => '蓝牙线充', 105 => '密码蓝牙线充', 107 => '车载充电器', ]; public function __construct() { $this->model = new UserFeedback(); } public function indexList(array $filters): array { $page = max(1, (int) request()->input('page', 1)); $limit = min(100, max(1, (int) request()->input('limit', 20))); $result = $this->buildQuery($filters) ->order('f.create_time', 'desc') ->order('f.id', 'desc') ->paginate(['page' => $page, 'list_rows' => $limit]) ->toArray(); $rows = $result['data'] ?? []; $replies = $this->latestReplies(array_map('intval', array_column($rows, 'id'))); $result['data'] = array_map( fn (array $row): array => $this->formatFeedback($row, $replies[(int) $row['id']] ?? null), $rows ); $result['summary'] = $this->summary($filters); return $result; } public function readFeedback(int $id): array { $row = $this->buildQuery([])->where('f.id', $id)->find(); if (!$row) { throw new ApiException('反馈不存在或无权查看'); } $feedback = $this->formatFeedback($row); $replies = Db::table('sa_user_feedback_reply')->where('feedback_id', $id) ->whereNull('delete_time')->order('id', 'asc')->select()->toArray(); $feedback['replies'] = array_map(fn (array $reply): array => [ 'id' => (int) $reply['id'], 'author_type' => (int) $reply['author_type'], 'author_name' => (string) $reply['author_name'], 'content' => (string) $reply['content'], 'images' => $this->decodeImages($reply['images'] ?? ''), 'create_time' => (string) $reply['create_time'], ], $replies); return $feedback; } public function updateStatus(array $data, int $operatorId, string $operatorName): void { $id = (int) ($data['id'] ?? 0); $this->assertAccessible($id); $status = (int) $data['status']; $now = date('Y-m-d H:i:s'); Db::table('sa_user_feedback')->where('id', $id)->update([ 'status' => $status, 'remark' => trim((string) ($data['remark'] ?? '')), 'handled_by' => $operatorId, 'handled_name' => $operatorName, 'handled_at' => $status === 2 ? $now : null, 'update_time' => $now, ]); } public function reply(array $data, int $operatorId, string $operatorName): array { $id = (int) ($data['id'] ?? 0); $feedback = $this->assertAccessible($id); $content = trim((string) $data['content']); $now = date('Y-m-d H:i:s'); $replyId = $this->transaction(function () use ($id, $feedback, $content, $operatorId, $operatorName, $now) { $replyId = Db::table('sa_user_feedback_reply')->insertGetId([ 'feedback_id' => $id, 'author_type' => 0, 'author_id' => $operatorId, 'author_name' => $operatorName, 'content' => $content, 'images' => '[]', 'create_time' => $now, 'update_time' => $now, ]); $update = [ 'handled_by' => $operatorId, 'handled_name' => $operatorName, 'update_time' => $now, ]; if ((int) ($feedback['status'] ?? 0) === 0) { $update['status'] = 1; } Db::table('sa_user_feedback')->where('id', $id)->update($update); return $replyId; }); return ['id' => (int) $replyId]; } public function export(array $filters): Response { $rows = $this->buildQuery($filters)->order('f.create_time', 'desc')->order('f.id', 'desc') ->select()->toArray(); $latest = $this->latestReplies(array_map('intval', array_column($rows, 'id'))); $data = []; foreach ($rows as $row) { $item = $this->formatFeedback($row, $latest[(int) $row['id']] ?? null); $data[] = [ 'id' => $item['id'], 'identity' => $this->excelSafe($item['identity']), 'nickname' => $this->excelSafe($item['nickname']), 'phone' => $this->excelSafe($item['phone']), 'type_name' => $item['type_name'], 'merchant_name' => $this->excelSafe($item['merchant_name']), 'device_sn' => $this->excelSafe($item['device_sn']), 'device_type_name' => $item['device_type_name'], 'content' => $this->excelSafe($item['content']), 'status_name' => $item['status_name'], 'latest_reply' => $this->excelSafe($item['latest_reply']), 'remark' => $this->excelSafe($item['remark']), 'create_time' => $item['create_time'], ]; } $fileName = '用户反馈_' . date('Ymd_His') . '.xlsx'; $writer = new OpenSpoutWriter($fileName); $writer->setWidth([10, 12, 18, 16, 14, 22, 22, 16, 40, 12, 35, 30, 20]); $writer->setHeader([ '反馈ID', '身份', '用户昵称', '联系电话', '问题类型', '商户', '设备SN', '设备类型', '反馈内容', '处理状态', '最新回复', '备注', '反馈时间', ]); $writer->setData($data); return response()->download($writer->returnFile(), urlencode($fileName)); } private function buildQuery(array $filters) { $query = Db::table('sa_user_feedback')->alias('f') ->leftJoin('sa_rental_user u', 'u.id = f.user_id AND u.delete_time IS NULL') ->leftJoin('sa_rental_order o', 'o.id = f.order_id AND o.delete_time IS NULL') ->leftJoin('sa_device d', 'd.id = f.device_id AND d.delete_time IS NULL') ->leftJoin('sa_merchant m', 'm.id = f.merchant_id AND m.delete_time IS NULL') ->whereNull('f.delete_time') ->field(implode(',', [ 'f.id,f.agent_id,f.user_id,f.identity,f.type,f.order_id,f.merchant_id,f.merchant_name', 'f.device_id,f.device_sn,f.device_type,f.nickname,f.contact_phone,f.content,f.images', 'f.status,f.remark,f.handled_by,f.handled_name,f.handled_at,f.create_time,f.update_time', 'u.nickname AS user_nickname,u.phone AS user_phone', 'o.order_sn,o.transaction_id', 'd.goods_sn AS related_device_sn,d.device_code,d.goods_type AS related_device_type', 'm.name AS related_merchant_name', ])); $managerIds = $this->accessibleManagerIds(); if (is_array($managerIds)) { if ($managerIds === []) { $query->where('f.id', 0); } else { $query->whereIn('f.agent_id', $managerIds); } } $status = trim((string) ($filters['status'] ?? '')); if (in_array($status, ['0', '1', '2'], true)) { $query->where('f.status', (int) $status); } $type = trim((string) ($filters['type'] ?? '')); if (in_array($type, ['1', '2', '3'], true)) { $query->where('f.type', (int) $type); } $deviceType = trim((string) ($filters['device_type'] ?? '')); if ($deviceType !== '' && ctype_digit($deviceType)) { $query->where('f.device_type', (int) $deviceType); } $this->like($query, 'f.contact_phone', $filters['phone'] ?? ''); $this->like($query, 'f.merchant_name', $filters['merchant'] ?? ''); $this->like($query, 'f.device_sn', $filters['device_sn'] ?? ''); $this->like($query, 'f.content', $filters['content'] ?? ''); $startDate = trim((string) ($filters['start_date'] ?? '')); $endDate = trim((string) ($filters['end_date'] ?? '')); if ($startDate !== '') { $query->where('f.create_time', '>=', $startDate . ' 00:00:00'); } if ($endDate !== '') { $query->where('f.create_time', '<=', $endDate . ' 23:59:59'); } return $query; } private function summary(array $filters): array { $summaryFilters = $filters; $summaryFilters['status'] = ''; $rows = $this->buildQuery($summaryFilters)->field('f.status,COUNT(*) AS total') ->group('f.status')->select()->toArray(); $summary = ['all' => 0, 'pending' => 0, 'processing' => 0, 'done' => 0]; foreach ($rows as $row) { $count = (int) $row['total']; $summary['all'] += $count; if ((int) $row['status'] === 0) $summary['pending'] = $count; if ((int) $row['status'] === 1) $summary['processing'] = $count; if ((int) $row['status'] === 2) $summary['done'] = $count; } return $summary; } /** @return array */ private function latestReplies(array $feedbackIds): array { if ($feedbackIds === []) { return []; } $ids = Db::table('sa_user_feedback_reply')->whereIn('feedback_id', $feedbackIds) ->whereNull('delete_time')->field('MAX(id) AS id')->group('feedback_id')->column('id'); if ($ids === []) { return []; } $rows = Db::table('sa_user_feedback_reply')->whereIn('id', $ids)->select()->toArray(); $map = []; foreach ($rows as $row) { $map[(int) $row['feedback_id']] = $row; } return $map; } private function formatFeedback(array $row, ?array $latestReply = null): array { $deviceType = (int) (($row['device_type'] ?? 0) ?: ($row['related_device_type'] ?? 0)); $status = (int) ($row['status'] ?? 0); $type = (int) ($row['type'] ?? 3); return [ 'id' => (int) $row['id'], 'agent_id' => (int) ($row['agent_id'] ?? 0), 'user_id' => (int) ($row['user_id'] ?? 0), 'identity' => (string) (($row['identity'] ?? '') ?: '用户'), 'nickname' => (string) (($row['nickname'] ?? '') ?: ($row['user_nickname'] ?? '匿名用户')), 'phone' => (string) (($row['contact_phone'] ?? '') ?: ($row['user_phone'] ?? '')), 'type' => $type, 'type_name' => self::TYPE_NAMES[$type] ?? '其他问题', 'order_id' => (int) ($row['order_id'] ?? 0), 'order_sn' => (string) ($row['order_sn'] ?? ''), 'transaction_id' => (string) ($row['transaction_id'] ?? ''), 'merchant_id' => (int) ($row['merchant_id'] ?? 0), 'merchant_name' => (string) (($row['merchant_name'] ?? '') ?: ($row['related_merchant_name'] ?? '')), 'device_id' => (int) ($row['device_id'] ?? 0), 'device_sn' => (string) (($row['device_sn'] ?? '') ?: ($row['related_device_sn'] ?? '')), 'device_code' => (string) ($row['device_code'] ?? ''), 'device_type' => $deviceType, 'device_type_name' => self::DEVICE_NAMES[$deviceType] ?? ($deviceType > 0 ? '其他设备' : '--'), 'content' => (string) ($row['content'] ?? ''), 'images' => $this->decodeImages($row['images'] ?? ''), 'status' => $status, 'status_name' => self::STATUS_NAMES[$status] ?? '未知', 'remark' => (string) ($row['remark'] ?? ''), 'handled_by' => (int) ($row['handled_by'] ?? 0), 'handled_name' => (string) ($row['handled_name'] ?? ''), 'handled_at' => (string) ($row['handled_at'] ?? ''), 'latest_reply_identity' => (string) ($latestReply['author_name'] ?? ''), 'latest_reply' => (string) ($latestReply['content'] ?? ''), 'create_time' => (string) ($row['create_time'] ?? ''), 'update_time' => (string) ($row['update_time'] ?? ''), ]; } private function assertAccessible(int $id): array { if ($id <= 0) { throw new ApiException('反馈参数错误'); } $query = Db::table('sa_user_feedback')->where('id', $id)->whereNull('delete_time'); $managerIds = $this->accessibleManagerIds(); if (is_array($managerIds)) { if ($managerIds === []) { throw new ApiException('反馈不存在或无权操作'); } $query->whereIn('agent_id', $managerIds); } $feedback = $query->find(); if (!$feedback) { throw new ApiException('反馈不存在或无权操作'); } return $feedback; } /** @return int[]|null */ private function accessibleManagerIds(): ?array { $currentId = (int) ($this->adminInfo['id'] ?? 0); if ($currentId === 1 || (int) ($this->adminInfo['is_super'] ?? 0) === 1) { return null; } if ($currentId <= 0) { return []; } $managerIds = [$currentId]; $dept = $this->adminInfo['deptList'] ?? []; if (empty($dept['id']) || !isset($dept['level'])) { return $managerIds; } $prefix = (string) $dept['level'] . (int) $dept['id'] . ','; $deptIds = Db::table('sa_system_dept')->whereNull('delete_time') ->whereLike('level', $prefix . '%')->column('id'); if ($deptIds !== []) { $managerIds = array_merge($managerIds, array_map('intval', SystemUser::whereIn('dept_id', $deptIds) ->whereNull('delete_time')->column('id'))); } return array_values(array_unique($managerIds)); } private function decodeImages(mixed $images): array { if (is_array($images)) { return array_values(array_filter(array_map('strval', $images))); } $decoded = json_decode((string) $images, true); return is_array($decoded) ? array_values(array_filter(array_map('strval', $decoded))) : []; } private function like($query, string $field, mixed $value): void { $value = trim((string) $value); if ($value !== '') { $query->whereLike($field, '%' . $value . '%'); } } private function excelSafe(mixed $value): string { $value = (string) $value; return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value; } }