diff --git a/BLOCKED.md b/BLOCKED.md index af86b953..de48cc48 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -18,6 +18,12 @@ - **数据库测试**:新表 `user_consents`(`20260930010000_user_consents.sql`,只加不改)没有跑 `npm run test:db`(本机无 Docker);staging 部署时由迁移步骤首次真实执行,部署后需用受控账号登录一次核对写入。 - **真机**:登录页勾选、协议页阅读、账户菜单链接未在真机走查(清单 `docs/testing/legal-consent-20260930.md`)。 +## 反馈与投诉 + 回复评价(2026-09-30):真实 PostgreSQL 与真机待验 + +- 缺什么:本机无 Docker,`npm run test:db` 跑不了;迁移 `20260930030000_user_feedback.sql` 里的函数(五次 / 小时限流、会话归属校验、管理员权限与审计写入)只有源码合同测试,没有真实库执行证据。无登录态与实体手机,弹窗与后台列表未在真实环境点过。 +- 替代证据:`frontend/tests/feedback-complaints-20260930.test.tsx`(校验、错误映射、弹窗提交与限流提示、评价按文本哈希恢复、SQL 与 RBAC 合同);部署到 staging 时迁移会被真实执行一次。 +- 解除条件:staging 迁移成功后,用受控账号提交一条反馈、连续提交第 6 条看到限流提示、在后台把它改为「已解决」并在审计日志看到记录;点赞后刷新对话仍在。 + ## TASK-chart-surface-polish:受控登录、实体手机与基线全量失败(2026-09-29) - 本轮 Chrome、Edge、Docker 均可用,不套用历史“无 Chrome / 无 Docker”。真实 Chrome + golden 的本地组件验收及骨架高度修复后复验已完成(70+8 项通过);没有受控线上登录账号、实体手机和读屏实测;不能代替完整账户/人物/请求链路。清单见 `docs/testing/chart-surface-polish-20260929.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b4ade1..b7e19d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ - 运营主体、地址、客服邮箱目前是占位,产品提供后替换一处即可。 - Skill 版本不 bump。新增一张表 `user_consents`(只加不改)。 +## 2026-09-30 — 新增「反馈与投诉」,回答下面的赞 / 踩会保存(待验收) + +- 账户菜单新增「反馈与投诉」:选类型(问题反馈 / 内容举报 / 退款与扣点 / 其他),写下发生了什么,可留联系方式,也可勾选附上当前对话(只附编号)。提交后告诉你 3 个工作日内处理,并给出客服邮箱。同一账号一小时最多提交 5 次。 +- 回答下面的赞 / 踩现在会保存,重新打开这段对话还在;重新生成的回答不会沿用旧的评价。 +- 管理后台新增「反馈与投诉」列表:按状态或类型筛选,新的内容举报排在最前面并标红;可以改处理状态、写内部备注,每次处理都记入审计日志。 +- 新增数据库表 `user_feedback`、`reply_ratings`(只加不改)。Skill 版本不 bump。 + ## 2026-09-30 — 首页去掉校正提示、星盘类型文字完整显示、加载改为轨道环动画、「那一刻的天空」换小星座图标(待验收) - 首页不再显示「上次那次校正还没完成,可以在历史对话里接着做。」(BUG-1111)。 diff --git a/CONTEXT.md b/CONTEXT.md index 53e0084f..6103784c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,10 @@ _避免使用_:授权、签约 负向回复评价附带的一个或多个原因分类,可包含用户补充说明。 _避免使用_:投诉、差评文本 +**反馈与投诉**: +用户从账户菜单主动提交的一条问题反馈、内容举报、退款与扣点诉求或其他意见,由后台「反馈与投诉」处理,状态为待处理、处理中、已解决或不予处理。只保存用户自己写的内容、选填的联系方式和(勾选时)会话编号,不保存对话原文。它与「回复评价」「对话质量记录」是两回事。 +_避免使用_:工单(泛指时)、差评、客服记录 + **对话质量记录**: 管理后台中供管理员排查或审阅的一项 Agent 执行故障或负向回复评价。 _避免使用_:聊天日志、客服工单 diff --git a/docs/tasks/PROGRESS-feedback-complaints-20260930.md b/docs/tasks/PROGRESS-feedback-complaints-20260930.md new file mode 100644 index 00000000..258df990 --- /dev/null +++ b/docs/tasks/PROGRESS-feedback-complaints-20260930.md @@ -0,0 +1,31 @@ +# PROGRESS · 反馈与投诉 + 回复评价持久化 · 2026-09-30 + +> 合规轮第四项之一(产品 2026-09-30「合规与法律的前四个你可以帮我做吗」,选定「站内反馈表单 + 后台处理」)。执行:Claude fork 子代理 C。分支 `codex/feedback-complaints-20260930`,基于 `codex/compliance-base-20260930`(`2cd37720`,含 `lib/legal-entity.ts` 占位)。 + +## 复用与新增 + +| 项 | 复用 | 新增 | +|---|---|---| +| 账户菜单 | `app-sidebar.tsx` 的 Menu(只加一行「反馈与投诉」) | `components/feedback-dialog.tsx`(侧栏自持状态,任何页面原地打开,不经 `/?account=`、不动 Home) | +| 写入与限流 | 服务端 `createAdminSupabaseClient().rpc` 模式(同 telemetry) | 迁移 `20260930030000_user_feedback.sql`:`user_feedback` 表、`submit_user_feedback()`(五次 / 滚动一小时、会话归属校验) | +| 回复评价 | 页面既有的 `messageFeedback` 状态与 `toggleChatMessageFeedback`;术语沿用 CONTEXT「回复评价」 | `reply_ratings` 表(库里原先没有评价表)、`set_reply_rating()` / `list_reply_ratings()`、`/api/reply-ratings`、`lib/reply-ratings.ts`、`hooks/use-reply-ratings-sync.ts`(Home 的 useState / useRef 数不变) | +| 后台 | RBAC 表、`admin_has_permission`、`audit.admin_audit_logs`、`ResourceTable`、refine 资源权限映射 | 权限 `support.feedback.read/write`(owner / operations / support 读写,auditor 只读);`admin_list_user_feedback()` / `admin_update_user_feedback()`(带审计);`/api/admin/feedback`、`/admin/feedback` | + +## 决定 + +- 评价按「会话 + 第几条消息」存,附回答文本 SHA-256 前 16 位;重新打开对话时只恢复文本哈希一致的评价,重新生成的回答不继承旧评价。未保存的本地会话(非 UUID)不写库。 +- 反馈只存用户自己写的内容、选填联系方式、(勾选时)会话编号;表上没有任何对话原文列。 +- 两张表都开 RLS、对所有运行时角色撤销表权限,只能经 SECURITY DEFINER 函数读写;随账号(auth.users)与会话级联删除。 +- 新内容举报在后台排最前、标红。 + +## 验证(Linux,Node 22.14;基线 = `origin/staging` `5208f19b` 的全量结果) + +| 项 | 结果 | +|---|---| +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,126 warning(同基线) | +| `npm test` 全量 | 4,427 / fail 24;失败名单与基线逐条相同(无 Docker 的数据库 / 部署套件);新增 9 条,消失 0 条 | +| 新测试 `feedback-complaints-20260930.test.tsx` | 9 / 9:校验、错误映射(限流 429)、弹窗提交与成功态、限流提示、无会话时不出现附带勾选、菜单只有一个入口、评价保存与按文本哈希恢复、SQL 限流 / 归属 / 撤权 / 审计、RBAC 映射 | +| `next build` | `/` 仍 `○ Static` | +| 首屏 gzip-9 | 646,480 B → 648,901 B(+0.37%;侧栏首屏多了反馈弹窗组件) | +| 真实数据库 / 真机 | 未验证,见 `BLOCKED.md`「反馈与投诉 + 回复评价」 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 18b9deb1..0a1f8a03 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -709,6 +709,11 @@ page. Three parts now, in reading order: - **States:** closed, open, hover, focus-visible, and logout confirmation. - **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return. - **Billing:** the 账户与点数 row opens the settings dialog on the billing pane. It does not leave the homepage. +- **反馈与投诉 (2026-09-30):** one row after 账户与点数 (icon `MessageSquareWarning`). It opens the sidebar's own dialog in place on every page — no `/?account=` hop, no Home state. The dialog uses the export sheet's language: bottom sheet on phones (up to 90dvh), a centred 520px panel from 860px; a four-way type segment (问题反馈 / 内容举报 / 退款与扣点 / 其他; 2×2 below 480px), a description (10–2000 characters), an optional contact prefilled with the account email, and — only when a saved conversation is open — 「附上当前对话」 which attaches the session id, never its text. Submit reads 「正在提交…」 while sending; errors sit above the button as an alert; success replaces the form with the promised reply window and the contact email. + +### Reply ratings persist (2026-09-30) + +The 👍 / 👎 under an answer (回复评价) is saved per conversation and message position with a 16-hex hash of the answer text, and restored when that conversation is opened again — but only onto the same text, so a regenerated answer starts unrated. Saving is fire-and-forget: a failed save never disturbs the conversation. ### Settings dialog diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 89b96c61..2c57f8b6 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -105,6 +105,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 这四句只在活动行里,是「流式生成中」的一部分:不进正文、不进历史、刷新后不出现;只往前走,不回头;不写秒数、不写「请稍候」,不承诺多久。 +## 反馈与投诉(2026-09-30) + +- 菜单与弹窗标题「反馈与投诉」;类型「问题反馈 / 内容举报 / 退款与扣点 / 其他」;输入框标签「发生了什么」「联系方式(选填)」;勾选「附上当前对话(只附编号,便于我们查看)」;按钮「提交」,发送中「正在提交…」。 +- 太短:「请至少写 10 个字,说清楚发生了什么。」太频繁:「提交得有点频繁,请一小时后再试;急事可以直接发邮件给我们。」 +- 成功:「已收到,我们会在 3 个工作日内处理。」「急事也可以发邮件到 {客服邮箱}。」按钮「好的」。邮箱只从 `lib/legal-entity.ts` 读,不在文案里写死。 + ## 星盘图标说明与能力列(2026-09-29) - 盘面不显示引擎品牌,也不替换成「自研引擎」「本站引擎」。岁差、交点等计算口径与准确性边界照旧。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index c917db37..6a3a0ece 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -166,6 +166,7 @@ import { import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run"; import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { markHomeReadyAfterNewChat } from "@/lib/new-chat-timing"; +import { useReplyRatingsSync } from "@/hooks/use-reply-ratings-sync"; import { homeWarmStartInput, mountedHomeWarmStart, @@ -346,6 +347,7 @@ export default function Home() { const activeSession = sessions.find((session) => session.id === activeSessionId) ?? pendingWarmLandingSession(activeSessionId) ?? sessions.find((session) => session.sessionType !== "birth_time_rectification" && sessionMatchesSubject(session, readCurrentSubjectId())); + useReplyRatingsSync(activeSession, setMessageFeedback); const activeRectificationSession = composerLocksAsRectification(activeSession, sessions); const rectificationSurfaceOpen = Boolean(activeRectificationSession && activeSession && activeSession.id === rectificationSessionId); const rectificationComposerLocked = Boolean(activeRectificationSession && !rectificationSurfaceOpen); diff --git a/frontend/src/app/admin/feedback/page.tsx b/frontend/src/app/admin/feedback/page.tsx new file mode 100644 index 00000000..bab2b5e5 --- /dev/null +++ b/frontend/src/app/admin/feedback/page.tsx @@ -0,0 +1,2 @@ +import { FeedbackResource } from "@/components/admin/feedback-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/api/admin/feedback/route.ts b/frontend/src/app/api/admin/feedback/route.ts new file mode 100644 index 00000000..cdab68cd --- /dev/null +++ b/frontend/src/app/api/admin/feedback/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePermission } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http"; +import { FEEDBACK_STATUSES, parseFeedbackFilter } from "@/lib/user-feedback"; + +export const runtime = "nodejs"; + +/** + * 反馈与投诉 in the admin console (compliance round 2026-09-30). Reads and + * updates go only through admin_list_user_feedback / admin_update_user_feedback, + * which re-check support.feedback.* and audit every change. The list shows the + * user's own words and, if they attached one, a session id — never message text. + * `status` filter accepts a status or `type:`. + */ +const updateSchema = z.object({ + id: z.string().uuid(), + status: z.enum(FEEDBACK_STATUSES), + adminNote: z.string().trim().max(2000).optional(), +}); + +type Row = { + id: string; + user_id: string; + email: string | null; + feedback_type: string; + body: string; + contact: string | null; + session_id: string | null; + status: string; + admin_note: string | null; + handled_by: string | null; + created_at: Date; + updated_at: Date; + total_count: string; +}; + +export async function GET(request: Request) { + try { + const session = await requirePermission("support.feedback.read"); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const filter = parseFeedbackFilter(parsed.data.status); + const rows = await queryAdminRows( + "select * from public.admin_list_user_feedback($1::uuid,$2::text,$3::text,$4::text,$5::integer,$6::integer)", + [ + session.user.id, + filter.type, + filter.status, + parsed.data.q ? `%${parsed.data.q}%` : null, + parsed.data.pageSize, + pageOffset(parsed.data.page, parsed.data.pageSize), + ], + ); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + type: row.feedback_type, + body: row.body, + contact: row.contact, + sessionId: row.session_id, + status: row.status, + adminNote: row.admin_note, + handledBy: row.handled_by, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function POST(request: Request) { + try { + const session = await requireAdminMutation(request, "support.feedback.write"); + const parsed = updateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const rows = await queryAdminRows<{ id: string; status: string; admin_note: string | null; updated_at: Date }>( + "select * from public.admin_update_user_feedback($1::uuid,$2::uuid,$3::text,$4::text,$5::text)", + [session.user.id, parsed.data.id, parsed.data.status, parsed.data.adminNote ?? null, requestId(request)], + ); + const row = rows[0]; + return NextResponse.json({ + data: row ? { id: row.id, status: row.status, adminNote: row.admin_note, updatedAt: row.updated_at.toISOString() } : null, + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/feedback/route.ts b/frontend/src/app/api/feedback/route.ts new file mode 100644 index 00000000..606d3cfe --- /dev/null +++ b/frontend/src/app/api/feedback/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { feedbackRpcErrorStatus, feedbackSubmissionSchema } from "@/lib/user-feedback"; + +export const runtime = "nodejs"; + +/** + * POST /api/feedback — 反馈与投诉 from the account menu (compliance round + * 2026-09-30). The write goes through submit_user_feedback(), which enforces + * the hourly limit and that an attached session belongs to the caller. + */ +export async function POST(request: Request) { + let supabase: Awaited>; + let service: ReturnType; + try { + supabase = await createServerSupabaseClient(); + service = createAdminSupabaseClient(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "POST /api/feedback"); + } + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + const parsed = feedbackSubmissionSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "请选择类型,并写至少 10 个字的描述。" }, { status: 400 }); + } + const { data, error } = await service.rpc("submit_user_feedback", { + p_user_id: user.id, + p_type: parsed.data.type, + p_body: parsed.data.body, + p_contact: parsed.data.contact ?? null, + p_session_id: parsed.data.sessionId ?? null, + }); + if (error) { + const mapped = feedbackRpcErrorStatus(error.message); + if (mapped.status === 503) console.error("[feedback] submit failed", error.code ?? "unknown"); + return NextResponse.json({ error: mapped.error }, { status: mapped.status }); + } + return NextResponse.json({ id: typeof data === "string" ? data : null }, { status: 201 }); +} diff --git a/frontend/src/app/api/reply-ratings/route.ts b/frontend/src/app/api/reply-ratings/route.ts new file mode 100644 index 00000000..8a112623 --- /dev/null +++ b/frontend/src/app/api/reply-ratings/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +/** + * 回复评价 (CONTEXT.md): the 👍 / 👎 on one assistant reply, persisted per + * session + message position (compliance round 2026-09-30). Reads and writes + * go through list_reply_ratings() / set_reply_rating(), which check that the + * session belongs to the caller. + */ +const putSchema = z.object({ + sessionId: z.string().uuid(), + messageIndex: z.number().int().min(0).max(100_000), + rating: z.enum(["up", "down"]).nullable(), + answerSha256: z.string().regex(/^[a-f0-9]{16,64}$/), +}); + +async function clients() { + const supabase = await createServerSupabaseClient(); + const service = createAdminSupabaseClient(); + return { supabase, service }; +} + +export async function GET(request: Request) { + let context: Awaited>; + try { + context = await clients(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "GET /api/reply-ratings"); + } + const { data: { user } } = await context.supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; + if (!z.string().uuid().safeParse(sessionId).success) { + return NextResponse.json({ error: "会话编号不正确" }, { status: 400 }); + } + const { data, error } = await context.service.rpc("list_reply_ratings", { p_user_id: user.id, p_session_id: sessionId }); + if (error) return NextResponse.json({ error: "暂时读不到评价" }, { status: 503 }); + const rows = Array.isArray(data) ? data : []; + return NextResponse.json({ + ratings: rows.flatMap((row: Record) => ( + typeof row.message_index === "number" && (row.rating === "up" || row.rating === "down") && typeof row.answer_sha256 === "string" + ? [{ messageIndex: row.message_index, rating: row.rating, answerSha256: row.answer_sha256 }] + : [] + )), + }); +} + +export async function PUT(request: Request) { + let context: Awaited>; + try { + context = await clients(); + } catch (error) { + return jsonForSupabaseSetupFailure(error, "PUT /api/reply-ratings"); + } + const { data: { user } } = await context.supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = putSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "评价格式不正确" }, { status: 400 }); + const { error } = await context.service.rpc("set_reply_rating", { + p_user_id: user.id, + p_session_id: parsed.data.sessionId, + p_message_index: parsed.data.messageIndex, + p_rating: parsed.data.rating, + p_answer_sha256: parsed.data.answerSha256, + }); + if (error) { + const notOwned = error.message?.includes("rating_session_not_owned"); + return NextResponse.json({ error: notOwned ? "会话不属于当前账号" : "暂时没能保存评价" }, { status: notOwned ? 403 : 503 }); + } + return NextResponse.json({ saved: true }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4f4b7103..89a4cb7d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4077,6 +4077,50 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .report-export-footer > button { width: 100%; } .report-export-footer p { margin: 0 0 var(--space-2); color: var(--color-danger); font-size: var(--type-body-sm); } @media print { .report-export-drawer { display: none !important; } } +/* 反馈与投诉 (2026-09-30): the same sheet language as 导出报告 — bottom sheet on + phones, a centred 520px panel from 860px. Type is a four-way segment, then + the description, an optional contact and the attach-conversation opt-in. */ +.feedback-dialog { + position: fixed; + inset: auto 0 0; + width: min(760px, 100%); + max-width: none; + max-height: 90dvh; + margin: 0 auto; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-canvas); + color: var(--color-ink); + box-shadow: var(--shadow-elevated); + overflow: hidden; +} +.feedback-dialog::backdrop { background: var(--color-scrim); } +.feedback-dialog-panel { display: flex; flex-direction: column; max-height: 90dvh; } +.feedback-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) var(--space-3) var(--space-1) var(--space-5); flex-shrink: 0; } +.feedback-dialog-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 600; } +.feedback-dialog-body { display: grid; gap: var(--space-4); min-height: 0; overflow-y: auto; padding: var(--space-2) var(--space-5) max(var(--space-5), env(safe-area-inset-bottom)); } +.feedback-dialog-types { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.feedback-dialog-types button { min-height: 40px; border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--color-ink-secondary); font: inherit; font-size: var(--type-body-sm); cursor: pointer; } +.feedback-dialog-types button[aria-checked="true"] { background: var(--color-canvas); color: var(--color-ink); font-weight: 600; box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent); } +.feedback-dialog-field { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); color: var(--color-ink-secondary); } +.feedback-dialog-field textarea, +.feedback-dialog-field input { width: 100%; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; font-size: var(--type-body-md); } +.feedback-dialog-field textarea { resize: vertical; min-height: 120px; } +.feedback-dialog-check { display: flex; align-items: center; gap: var(--space-2); min-height: 44px; font-size: var(--type-body-sm); color: var(--color-ink-secondary); } +.feedback-dialog-error { margin: 0; color: var(--color-danger); font-size: var(--type-body-sm); } +.feedback-dialog-caption { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } +.feedback-dialog-done p { margin: 0; } +.feedback-dialog button:focus-visible, +.feedback-dialog textarea:focus-visible, +.feedback-dialog input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +@media (min-width: 860px) { + .feedback-dialog { inset: 0; width: min(520px, 92vw); max-height: 80vh; margin: auto; border-radius: var(--radius-lg); } + .feedback-dialog-panel { max-height: calc(80vh - 2px); } +} +@media (max-width: 480px) { .feedback-dialog-types { grid-template-columns: repeat(2, 1fr); } } +@media print { .feedback-dialog { display: none !important; } } + /* ============================================================ personal-report: unique block — editorial reader + A4 print diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index d90d2d8f..cf9d86f9 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -112,6 +112,7 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, { name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: } }, + { name: "feedback", list: "/admin/feedback", meta: { label: "反馈与投诉", icon: } }, { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } }, { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } }, diff --git a/frontend/src/components/admin/feedback-resource.tsx b/frontend/src/components/admin/feedback-resource.tsx new file mode 100644 index 00000000..38e96e24 --- /dev/null +++ b/frontend/src/components/admin/feedback-resource.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useGetIdentity, useInvalidate } from "@refinedev/core"; +import { App, Button, Input, Modal, Select, Space, Tag, Typography, type TableColumnsType } from "antd"; +import { useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { + FEEDBACK_STATUS_LABELS, + FEEDBACK_STATUSES, + FEEDBACK_TYPE_LABELS, + FEEDBACK_TYPES, + type FeedbackStatus, + type FeedbackType, +} from "@/lib/user-feedback"; +import { formatAdminDate, ResourceTable } from "./resource-table"; + +const { Text, Paragraph } = Typography; + +type Feedback = { + id: string; + userId: string; + email: string | null; + type: FeedbackType; + body: string; + contact: string | null; + sessionId: string | null; + status: FeedbackStatus; + adminNote: string | null; + createdAt: string; + updatedAt: string; +}; + +const STATUS_COLOR: Record = { new: "gold", in_progress: "blue", resolved: "green", rejected: "default" }; + +/** + * 反馈与投诉 (compliance round 2026-09-30). New content reports sort first + * and are tagged red. Handling = a status plus an internal note; every change + * is audited by the database function. + */ +export function FeedbackResource() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const invalidate = useInvalidate(); + const canHandle = Boolean(identity?.permissions.includes("support.feedback.write")); + const [editing, setEditing] = useState(null); + const [status, setStatus] = useState("in_progress"); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + + function open(item: Feedback) { + setEditing(item); + setStatus(item.status === "new" ? "in_progress" : item.status); + setNote(item.adminNote ?? ""); + } + + async function save() { + if (!editing) return; + setSaving(true); + try { + await adminRequestJson("/api/admin/feedback", { + method: "POST", + headers: { "x-request-id": crypto.randomUUID() }, + body: JSON.stringify({ id: editing.id, status, adminNote: note }), + }); + await invalidate({ resource: "feedback", invalidates: ["list"] }); + message.success("已更新处理状态"); + setEditing(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "更新失败"); + } finally { + setSaving(false); + } + } + + const columns: TableColumnsType = [ + { + title: "类型", + render: (_, item) => {FEEDBACK_TYPE_LABELS[item.type]}, + }, + { + title: "内容", + render: (_, item) => {item.body}, + }, + { title: "用户", render: (_, item) => item.email ?? item.userId }, + { title: "联系方式", dataIndex: "contact", render: (value) => value ?? "—" }, + { title: "附带会话", dataIndex: "sessionId", render: (value) => (value ? {value} : "—") }, + { title: "状态", render: (_, item) => {FEEDBACK_STATUS_LABELS[item.status]} }, + { title: "提交时间", dataIndex: "createdAt", render: formatAdminDate }, + { + title: "处理", + fixed: "right", + render: (_, item) => (canHandle ? open(item)}>处理 : null), + }, + ]; + + return ( + <> + + resource="feedback" + title="反馈与投诉" + columns={columns} + statusOptions={[ + ...FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] })), + ...FEEDBACK_TYPES.map((value) => ({ value: `type:${value}`, label: `类型:${FEEDBACK_TYPE_LABELS[value]}` })), + ]} + /> + setEditing(null)} onOk={() => void save()} okText="保存" confirmLoading={saving}> + {editing ? ( + + {FEEDBACK_TYPE_LABELS[editing.type]} · {formatAdminDate(editing.createdAt)} + {editing.body} + + value={status} + onChange={setStatus} + options={FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] }))} + style={{ width: 200 }} + /> + setNote(event.target.value)} maxLength={2000} rows={4} placeholder="内部备注(用户看不到)" /> + + ) : null} + + > + ); +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index c270ca4f..a3b49904 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { FeedbackDialog } from "@/components/feedback-dialog"; import { Menu } from "@base-ui/react/menu"; import { CalendarDays, @@ -10,6 +11,7 @@ import { Settings, WalletCards, MessageSquareText, + MessageSquareWarning, SquarePen, Star, UserRound, @@ -151,6 +153,8 @@ export function AppSidebar({ }: Partial = controls ?? {}; /* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */ const [ownMenuOpen, setOwnMenuOpen] = useState(false); + /* 反馈与投诉 is the sidebar's own dialog, so it opens the same way on every page. */ + const [feedbackOpen, setFeedbackOpen] = useState(false); const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen; const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen; const hasMoreSessions = sessionControls?.hasMore ?? false; @@ -368,6 +372,9 @@ export function AppSidebar({ )} + {feedbackOpen && account ? ( + setFeedbackOpen(false)} /> + ) : null} {account ? ( /* One account menu on every page. On `/` Home controls it and its @@ -412,6 +419,9 @@ export function AppSidebar({ onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); }, onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); }, })} + { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}> + 反馈与投诉 + diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index 1c72ec07..ab163cc5 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -7,6 +7,7 @@ import { type ChatMessageFeedback, } from "@/components/chat-message-actions"; import { ConversationFollowUps } from "@/components/conversation-follow-ups"; +import { persistReplyRating } from "@/lib/reply-ratings"; import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint"; import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; import type { ConsultationDomain } from "@/lib/consultation-domain-registry"; @@ -116,7 +117,11 @@ function MessageEntry({ feedback={messageFeedback[feedbackKey]} copied={copiedMessageKey === feedbackKey} canRegenerate={message.renderKey === latestRegeneratableKey} - onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)} + onFeedback={(requested) => { + // 回复评价 is saved here, where the current value and the reply text are both at hand. + void persistReplyRating(feedbackKey, toggleChatMessageFeedback(messageFeedback[feedbackKey], requested), message.text); + actionsRef.current.onFeedback(feedbackKey, requested); + }} onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)} onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)} /> diff --git a/frontend/src/components/feedback-dialog.tsx b/frontend/src/components/feedback-dialog.tsx new file mode 100644 index 00000000..2d664b33 --- /dev/null +++ b/frontend/src/components/feedback-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { LEGAL_ENTITY } from "@/lib/legal-entity"; +import { + FEEDBACK_BODY_MAX, + FEEDBACK_RESPONSE_WORKING_DAYS, + FEEDBACK_TYPE_LABELS, + FEEDBACK_TYPES, + feedbackBodyProblem, + type FeedbackType, +} from "@/lib/user-feedback"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 反馈与投诉 (compliance round 2026-09-30): opened from the account menu on + * every page. Type, a description, an optional contact and, when a saved + * conversation is open, an opt-in to attach it (its id only). Submitting + * stores the row for the admin console; the success view promises a reply + * window and gives the contact email. + */ +export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{ + accountEmail?: string | null; + sessionId?: string | null; + onClose: () => void; +}>) { + const dialog = useRef(null); + const titleId = useId(); + const [type, setType] = useState("problem"); + const [body, setBody] = useState(""); + const [contact, setContact] = useState(accountEmail ?? ""); + const [attach, setAttach] = useState(false); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + const canAttach = Boolean(sessionId && UUID.test(sessionId)); + + useEffect(() => { + const element = dialog.current; + const previous = document.activeElement; + element?.showModal(); + return () => { + element?.close(); + if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + + async function submit() { + const problem = feedbackBodyProblem(body); + if (problem) { + setError(problem); + return; + } + setSending(true); + setError(null); + try { + const response = await fetch("/api/feedback", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + body, + ...(contact.trim() ? { contact: contact.trim() } : {}), + ...(attach && canAttach && sessionId ? { sessionId } : {}), + }), + }); + if (!response.ok) { + const json = await response.json().catch(() => null) as { error?: string } | null; + setError(json?.error ?? "暂时没能提交,请稍后再试。"); + return; + } + setSent(true); + } catch { + setError("网络不太稳,没能提交,请稍后再试。"); + } finally { + setSending(false); + } + } + + return ( + { event.preventDefault(); onClose(); }} + onClick={(event) => { if (event.target === event.currentTarget) onClose(); }} + > + + + 反馈与投诉 + + + {sent ? ( + + 已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。 + 急事也可以发邮件到 {LEGAL_ENTITY.contactEmail}。 + 好的 + + ) : ( + { event.preventDefault(); void submit(); }}> + + {FEEDBACK_TYPES.map((option) => ( + setType(option)}> + {FEEDBACK_TYPE_LABELS[option]} + + ))} + + + 发生了什么 + setBody(event.target.value)} + /> + + + 联系方式(选填) + setContact(event.target.value)} placeholder="邮箱或手机号" /> + + {canAttach ? ( + + setAttach(event.target.checked)} /> + 附上当前对话(只附编号,便于我们查看) + + ) : null} + {error ? {error} : null} + {sending ? "正在提交…" : "提交"} + + )} + + + ); +} diff --git a/frontend/src/hooks/use-reply-ratings-sync.ts b/frontend/src/hooks/use-reply-ratings-sync.ts new file mode 100644 index 00000000..7943d58f --- /dev/null +++ b/frontend/src/hooks/use-reply-ratings-sync.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect, type Dispatch, type SetStateAction } from "react"; + +import type { ChatMessageFeedback } from "@/components/chat-message-actions"; +import { loadReplyRatings } from "@/lib/reply-ratings"; + +type SessionLike = Readonly<{ + id: string; + messagesHydrated?: boolean; + messages: readonly Readonly<{ role: string; text: string }>[]; +}>; + +/** + * Brings the stored 回复评价 of the open conversation into the page's + * feedback map once its messages are loaded (compliance round 2026-09-30). + * Owns no state; ratings the user sets in this visit win over stored ones. + */ +export function useReplyRatingsSync( + session: SessionLike | undefined, + setMessageFeedback: Dispatch>>, +): void { + const sessionId = session?.id ?? ""; + const ready = Boolean(session?.messagesHydrated); + const count = session?.messages.length ?? 0; + const texts = session?.messages.map((message) => (message.role === "assistant" ? message.text : "")) ?? []; + const textKey = texts.join("\u0000"); + useEffect(() => { + if (!sessionId || !ready || count === 0) return; + let cancelled = false; + void loadReplyRatings(sessionId, textKey.split("\u0000")) + .then((stored) => { + if (cancelled || Object.keys(stored).length === 0) return; + setMessageFeedback((current) => ({ ...stored, ...current })); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [sessionId, ready, count, textKey, setMessageFeedback]); +} diff --git a/frontend/src/lib/admin/auth-policy.ts b/frontend/src/lib/admin/auth-policy.ts index bcb86ca6..def97b74 100644 --- a/frontend/src/lib/admin/auth-policy.ts +++ b/frontend/src/lib/admin/auth-policy.ts @@ -29,6 +29,8 @@ export const adminPermissions = [ "models.rollback", "ops.flags.write", "audit.read", + "support.feedback.read", + "support.feedback.write", ] as const; export type AdminPermission = (typeof adminPermissions)[number]; diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index 2947b766..aea4acdb 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -203,6 +203,7 @@ const resourcePermissions: Record = { orders: { read: "billing.orders.read", write: "billing.adjustments.write" }, usage: { read: "billing.orders.read" }, "rectification-telemetry": { read: "admin.customers.read" }, + feedback: { read: "support.feedback.read", write: "support.feedback.write" }, models: { read: "models.read", write: "models.write" }, "model-releases": { read: "models.read", write: "models.publish" }, "feature-flags": { read: "admin.access", write: "ops.flags.write" }, diff --git a/frontend/src/lib/reply-ratings.ts b/frontend/src/lib/reply-ratings.ts new file mode 100644 index 00000000..2d221e0f --- /dev/null +++ b/frontend/src/lib/reply-ratings.ts @@ -0,0 +1,59 @@ +import type { ChatMessageFeedback } from "@/components/chat-message-actions"; + +/** + * Client side of 回复评价 persistence (compliance round 2026-09-30). The key + * the transcript uses is `${sessionId}:message-${index}`; the server keys a + * rating by session + index and keeps a short hash of the reply text, so a + * rating is not shown on a different (regenerated) answer at the same index. + */ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function parseFeedbackKey(feedbackKey: string): { sessionId: string; messageIndex: number } | null { + const match = /^(.+):message-(\d+)$/.exec(feedbackKey); + if (!match || !UUID.test(match[1]!)) return null; + return { sessionId: match[1]!, messageIndex: Number(match[2]) }; +} + +export function feedbackKeyFor(sessionId: string, messageIndex: number): string { + return `${sessionId}:message-${messageIndex}`; +} + +/** First 16 hex characters of SHA-256 over the reply text. */ +export async function answerHash(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Fire-and-forget; a failed save never disturbs the conversation. */ +export function persistReplyRating(feedbackKey: string, rating: ChatMessageFeedback | undefined, text: string): Promise { + const key = parseFeedbackKey(feedbackKey); + if (!key) return Promise.resolve(); + return answerHash(text) + .then((answerSha256) => fetch("/api/reply-ratings", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...key, rating: rating ?? null, answerSha256 }), + })) + .then(() => undefined, () => undefined); +} + +type StoredRating = { messageIndex: number; rating: ChatMessageFeedback; answerSha256: string }; + +/** + * Stored ratings for one session, keeping only those whose reply text still + * hashes the same. `texts[i]` is the i-th message's text ("" for user turns). + */ +export async function loadReplyRatings(sessionId: string, texts: readonly string[]): Promise> { + if (!UUID.test(sessionId)) return {}; + const response = await fetch(`/api/reply-ratings?sessionId=${encodeURIComponent(sessionId)}`, { credentials: "same-origin" }); + if (!response.ok) return {}; + const json = await response.json().catch(() => null) as { ratings?: StoredRating[] } | null; + const result: Record = {}; + for (const stored of json?.ratings ?? []) { + const text = texts[stored.messageIndex]; + if (!text) continue; + if (await answerHash(text) === stored.answerSha256) result[feedbackKeyFor(sessionId, stored.messageIndex)] = stored.rating; + } + return result; +} diff --git a/frontend/src/lib/user-feedback.ts b/frontend/src/lib/user-feedback.ts new file mode 100644 index 00000000..09c24396 --- /dev/null +++ b/frontend/src/lib/user-feedback.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +/** + * 反馈与投诉 (compliance round 2026-09-30). Shared by the account-menu dialog, + * POST /api/feedback and the admin list. The table holds the user's words, an + * optional contact and at most a session id — never conversation text. + */ +export const FEEDBACK_TYPES = ["problem", "content_report", "refund", "other"] as const; +export type FeedbackType = (typeof FEEDBACK_TYPES)[number]; + +export const FEEDBACK_TYPE_LABELS: Readonly> = { + problem: "问题反馈", + content_report: "内容举报", + refund: "退款与扣点", + other: "其他", +}; + +export const FEEDBACK_STATUSES = ["new", "in_progress", "resolved", "rejected"] as const; +export type FeedbackStatus = (typeof FEEDBACK_STATUSES)[number]; + +export const FEEDBACK_STATUS_LABELS: Readonly> = { + new: "待处理", + in_progress: "处理中", + resolved: "已解决", + rejected: "不予处理", +}; + +export const FEEDBACK_BODY_MIN = 10; +export const FEEDBACK_BODY_MAX = 2000; +export const FEEDBACK_CONTACT_MAX = 200; +/** Promised reply time on the success screen, in working days. */ +export const FEEDBACK_RESPONSE_WORKING_DAYS = 3; +/** Mirrors submit_user_feedback(): five submissions per rolling hour. */ +export const FEEDBACK_HOURLY_LIMIT = 5; + +export const feedbackSubmissionSchema = z.object({ + type: z.enum(FEEDBACK_TYPES), + body: z.string().trim().min(FEEDBACK_BODY_MIN).max(FEEDBACK_BODY_MAX), + contact: z.string().trim().max(FEEDBACK_CONTACT_MAX).optional().transform((value) => value || undefined), + sessionId: z.string().uuid().optional(), +}); + +export type FeedbackSubmission = z.infer; + +/** Client-side check with the same limits the server enforces; null when valid. */ +export function feedbackBodyProblem(body: string): string | null { + const length = body.trim().length; + if (length < FEEDBACK_BODY_MIN) return `请至少写 ${FEEDBACK_BODY_MIN} 个字,说清楚发生了什么。`; + if (length > FEEDBACK_BODY_MAX) return `最多 ${FEEDBACK_BODY_MAX} 个字。`; + return null; +} + +export const FEEDBACK_RATE_LIMITED_COPY = "提交得有点频繁,请一小时后再试;急事可以直接发邮件给我们。"; + +/** Maps the database's error text to an HTTP answer; unknown errors are 503. */ +export function feedbackRpcErrorStatus(message: string | undefined): { status: number; error: string } { + if (message?.includes("feedback_rate_limited")) return { status: 429, error: FEEDBACK_RATE_LIMITED_COPY }; + if (message?.includes("feedback_session_not_owned")) return { status: 400, error: "附上的对话不属于当前账号,请去掉勾选后再提交。" }; + return { status: 503, error: "暂时没能提交,请稍后再试。" }; +} + +/** Admin list filter: a status, or `type:`. Unknown values filter nothing. */ +export function parseFeedbackFilter(value: string | undefined): { type: string | null; status: string | null } { + if (!value) return { type: null, status: null }; + if (value.startsWith("type:")) { + const type = value.slice(5); + return { type: (FEEDBACK_TYPES as readonly string[]).includes(type) ? type : null, status: null }; + } + return { type: null, status: (FEEDBACK_STATUSES as readonly string[]).includes(value) ? value : null }; +} diff --git a/frontend/supabase/migrations/20260930030000_user_feedback.sql b/frontend/supabase/migrations/20260930030000_user_feedback.sql new file mode 100644 index 00000000..03ecb76c --- /dev/null +++ b/frontend/supabase/migrations/20260930030000_user_feedback.sql @@ -0,0 +1,298 @@ +-- In-app feedback / complaints and persisted reply ratings +-- (docs/tasks/PROGRESS-feedback-complaints-20260930.md, compliance round 2026-09-30). +-- +-- user_feedback: one row per submission from the account menu's 「反馈与投诉」. +-- Holds the user's own words, an optional contact, and at most a session id +-- when the user ticks 「附上当前对话」 — never message text. Admins handle rows +-- through permission-checked functions; there is no direct table privilege. +-- reply_ratings: the 👍 / 👎 on one assistant reply (「回复评价」 in CONTEXT.md), +-- keyed by session + message position, with a short hash of the reply text +-- so a rating is not carried over onto a regenerated answer. +-- +-- Access: +-- * both tables enable RLS and grant no table privilege to any runtime role; +-- * the web server writes/reads through SECURITY DEFINER functions executable +-- by service_role only; each checks that the session belongs to the user; +-- * the admin console reads and updates feedback through SECURITY DEFINER +-- functions executable by admin_runtime, gated by new permissions +-- support.feedback.read / support.feedback.write, and every update writes +-- audit.admin_audit_logs. +-- Rows cascade with the account (auth.users) and with the session. +-- +-- Backward compatibility: additive only (two tables, functions, permission +-- rows and role grants). Nothing existing changes. + +begin; + +create table if not exists public.user_feedback ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + feedback_type text not null + check (feedback_type in ('problem', 'content_report', 'refund', 'other')), + body text not null check (char_length(btrim(body)) between 10 and 2000), + contact text check (contact is null or char_length(contact) between 1 and 200), + session_id uuid references public.chat_sessions(id) on delete set null, + status text not null default 'new' + check (status in ('new', 'in_progress', 'resolved', 'rejected')), + admin_note text check (admin_note is null or char_length(admin_note) <= 2000), + handled_by uuid, + created_at timestamptz not null default clock_timestamp(), + updated_at timestamptz not null default clock_timestamp() +); + +create index if not exists user_feedback_user_created_idx + on public.user_feedback (user_id, created_at desc); +create index if not exists user_feedback_status_created_idx + on public.user_feedback (status, created_at desc); + +create table if not exists public.reply_ratings ( + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid not null references public.chat_sessions(id) on delete cascade, + message_index integer not null check (message_index between 0 and 100000), + rating text not null check (rating in ('up', 'down')), + answer_sha256 text not null check (answer_sha256 ~ '^[a-f0-9]{16,64}$'), + created_at timestamptz not null default clock_timestamp(), + updated_at timestamptz not null default clock_timestamp(), + primary key (user_id, session_id, message_index) +); + +alter table public.user_feedback enable row level security; +alter table public.reply_ratings enable row level security; + +revoke all on table public.user_feedback from public, anon, authenticated, service_role; +revoke all on table public.reply_ratings from public, anon, authenticated, service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'app_runtime') then + revoke all on table public.user_feedback from app_runtime; + revoke all on table public.reply_ratings from app_runtime; + end if; + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + revoke all on table public.user_feedback from admin_runtime; + revoke all on table public.reply_ratings from admin_runtime; + end if; +end; +$$; + +-- --------------------------------------------------------------------------- +-- User side (service_role only) +-- --------------------------------------------------------------------------- + +create or replace function public.submit_user_feedback( + p_user_id uuid, + p_type text, + p_body text, + p_contact text, + p_session_id uuid +) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_id uuid; + v_recent integer; +begin + if p_user_id is null then + raise exception 'feedback_user_required' using errcode = '22023'; + end if; + -- Five submissions per rolling hour per account. + select count(*) into v_recent + from public.user_feedback + where user_id = p_user_id + and created_at > clock_timestamp() - interval '1 hour'; + if v_recent >= 5 then + raise exception 'feedback_rate_limited' using errcode = 'P0001'; + end if; + if p_session_id is not null and not exists ( + select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id + ) then + raise exception 'feedback_session_not_owned' using errcode = '42501'; + end if; + insert into public.user_feedback (user_id, feedback_type, body, contact, session_id) + values (p_user_id, p_type, btrim(p_body), nullif(btrim(coalesce(p_contact, '')), ''), p_session_id) + returning id into v_id; + return v_id; +end; +$$; + +create or replace function public.set_reply_rating( + p_user_id uuid, + p_session_id uuid, + p_message_index integer, + p_rating text, + p_answer_sha256 text +) +returns boolean +language plpgsql +security definer +set search_path = '' +as $$ +begin + if not exists ( + select 1 from public.chat_sessions s where s.id = p_session_id and s.user_id = p_user_id + ) then + raise exception 'rating_session_not_owned' using errcode = '42501'; + end if; + if p_rating is null then + delete from public.reply_ratings + where user_id = p_user_id and session_id = p_session_id and message_index = p_message_index; + return true; + end if; + insert into public.reply_ratings (user_id, session_id, message_index, rating, answer_sha256) + values (p_user_id, p_session_id, p_message_index, p_rating, p_answer_sha256) + on conflict (user_id, session_id, message_index) do update + set rating = excluded.rating, + answer_sha256 = excluded.answer_sha256, + updated_at = clock_timestamp(); + return true; +end; +$$; + +create or replace function public.list_reply_ratings(p_user_id uuid, p_session_id uuid) +returns table (message_index integer, rating text, answer_sha256 text) +language sql +stable +security definer +set search_path = '' +as $$ + select r.message_index, r.rating, r.answer_sha256 + from public.reply_ratings r + where r.user_id = p_user_id and r.session_id = p_session_id + order by r.message_index; +$$; + +-- --------------------------------------------------------------------------- +-- Admin side (admin_runtime only, permission-checked, audited) +-- --------------------------------------------------------------------------- + +insert into public.admin_permissions (permission_key, description) values + ('support.feedback.read', '查看用户反馈与投诉'), + ('support.feedback.write', '处理用户反馈与投诉') +on conflict (permission_key) do update set description = excluded.description; + +with role_grants(role_code, permission_key) as (values + ('owner', 'support.feedback.read'), ('owner', 'support.feedback.write'), + ('operations', 'support.feedback.read'), ('operations', 'support.feedback.write'), + ('support', 'support.feedback.read'), ('support', 'support.feedback.write'), + ('auditor', 'support.feedback.read') +) +insert into public.admin_role_permissions (role_id, permission_id) +select r.id, p.id +from role_grants g +join public.admin_roles r on r.code = g.role_code +join public.admin_permissions p on p.permission_key = g.permission_key +on conflict do nothing; + +create or replace function public.admin_list_user_feedback( + p_actor_user_id uuid, + p_type text, + p_status text, + p_query text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, user_id uuid, email text, feedback_type text, body text, contact text, + session_id uuid, status text, admin_note text, handled_by uuid, + created_at timestamptz, updated_at timestamptz, total_count bigint +) +language plpgsql +stable +security definer +set search_path = '' +as $$ +begin + if not public.admin_has_permission(p_actor_user_id, 'support.feedback.read') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + return query + select f.id, f.user_id, u.email, f.feedback_type, f.body, f.contact, + f.session_id, f.status, f.admin_note, f.handled_by, + f.created_at, f.updated_at, count(*) over() as total_count + from public.user_feedback f + left join identity.users u on u.id = f.user_id + where (p_type is null or f.feedback_type = p_type) + and (p_status is null or f.status = p_status) + and (p_query is null or f.body ilike p_query or u.email ilike p_query or f.user_id::text ilike p_query) + order by (f.feedback_type = 'content_report' and f.status = 'new') desc, f.created_at desc + limit greatest(1, least(coalesce(p_limit, 20), 100)) + offset greatest(0, coalesce(p_offset, 0)); +end; +$$; + +create or replace function public.admin_update_user_feedback( + p_actor_user_id uuid, + p_feedback_id uuid, + p_status text, + p_admin_note text, + p_request_id text +) +returns table (id uuid, status text, admin_note text, handled_by uuid, updated_at timestamptz) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_actor_email text; + v_before jsonb; +begin + if not public.admin_has_permission(p_actor_user_id, 'support.feedback.write') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if p_status not in ('new', 'in_progress', 'resolved', 'rejected') then + raise exception 'feedback_status_invalid' using errcode = '22023'; + end if; + if p_admin_note is not null and char_length(p_admin_note) > 2000 then + raise exception 'feedback_note_too_long' using errcode = '22023'; + end if; + select jsonb_build_object('status', f.status, 'admin_note', f.admin_note) + into v_before + from public.user_feedback f where f.id = p_feedback_id for update; + if v_before is null then + raise exception 'feedback_not_found' using errcode = '22023'; + end if; + select lower(btrim(u.email)) into v_actor_email from identity.users u where u.id = p_actor_user_id; + update public.user_feedback f + set status = p_status, + admin_note = nullif(btrim(coalesce(p_admin_note, '')), ''), + handled_by = p_actor_user_id, + updated_at = clock_timestamp() + where f.id = p_feedback_id; + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + before_value, after_value, request_id, permission_used, reason + ) values ( + p_actor_user_id, coalesce(v_actor_email, 'unknown@invalid'), 'admin', + 'user_feedback.update', 'user_feedback', p_feedback_id, + v_before, jsonb_build_object('status', p_status), + p_request_id, 'support.feedback.write', 'feedback handling' + ) on conflict do nothing; + return query + select f.id, f.status, f.admin_note, f.handled_by, f.updated_at + from public.user_feedback f where f.id = p_feedback_id; +end; +$$; + +revoke all on function public.submit_user_feedback(uuid, text, text, text, uuid) from public, anon, authenticated; +revoke all on function public.set_reply_rating(uuid, uuid, integer, text, text) from public, anon, authenticated; +revoke all on function public.list_reply_ratings(uuid, uuid) from public, anon, authenticated; +revoke all on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) from public, anon, authenticated; +revoke all on function public.admin_update_user_feedback(uuid, uuid, text, text, text) from public, anon, authenticated; + +grant execute on function public.submit_user_feedback(uuid, text, text, text, uuid) to service_role; +grant execute on function public.set_reply_rating(uuid, uuid, integer, text, text) to service_role; +grant execute on function public.list_reply_ratings(uuid, uuid) to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.admin_list_user_feedback(uuid, text, text, text, integer, integer) to admin_runtime; + grant execute on function public.admin_update_user_feedback(uuid, uuid, text, text, text) to admin_runtime; + end if; +end; +$$; + +commit; diff --git a/frontend/tests/feedback-complaints-20260930.test.tsx b/frontend/tests/feedback-complaints-20260930.test.tsx new file mode 100644 index 00000000..a61b5262 --- /dev/null +++ b/frontend/tests/feedback-complaints-20260930.test.tsx @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; + +import { FeedbackDialog } from "../src/components/feedback-dialog"; +import { LEGAL_ENTITY } from "../src/lib/legal-entity"; +import { answerHash, feedbackKeyFor, loadReplyRatings, parseFeedbackKey, persistReplyRating } from "../src/lib/reply-ratings"; +import { + FEEDBACK_RATE_LIMITED_COPY, + FEEDBACK_RESPONSE_WORKING_DAYS, + feedbackBodyProblem, + feedbackRpcErrorStatus, + feedbackSubmissionSchema, + parseFeedbackFilter, +} from "../src/lib/user-feedback"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; + +// 反馈与投诉 + 回复评价 persistence (compliance round 2026-09-30). +Object.assign(globalThis, { React }); +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const SESSION = "11111111-1111-4111-8111-111111111111"; +const migration = read("../supabase/migrations/20260930030000_user_feedback.sql"); + +test("feedback validation: type required, 10–2000 characters, contact optional", () => { + assert.equal(feedbackBodyProblem("太短了"), "请至少写 10 个字,说清楚发生了什么。"); + assert.equal(feedbackBodyProblem("报告里的第三章有一段内容看起来不对劲"), null); + assert.equal(feedbackBodyProblem("字".repeat(2001)), "最多 2000 个字。"); + assert.equal(feedbackSubmissionSchema.safeParse({ type: "problem", body: "短" }).success, false); + assert.equal(feedbackSubmissionSchema.safeParse({ type: "nope", body: "这是一段足够长的反馈内容" }).success, false); + const ok = feedbackSubmissionSchema.parse({ type: "content_report", body: " 这是一段足够长的反馈内容 ", contact: " " }); + assert.equal(ok.body, "这是一段足够长的反馈内容"); + assert.equal(ok.contact, undefined); + assert.equal(feedbackSubmissionSchema.safeParse({ type: "problem", body: "这是一段足够长的反馈内容", sessionId: "not-a-uuid" }).success, false); +}); + +test("database errors map to friendly answers; the hourly limit is a 429", () => { + assert.deepEqual(feedbackRpcErrorStatus("ERROR: feedback_rate_limited"), { status: 429, error: FEEDBACK_RATE_LIMITED_COPY }); + assert.equal(feedbackRpcErrorStatus("feedback_session_not_owned").status, 400); + assert.equal(feedbackRpcErrorStatus("connection reset").status, 503); + assert.equal(feedbackRpcErrorStatus(undefined).status, 503); +}); + +test("the database enforces five per hour, session ownership, and has no direct table access", () => { + assert.match(migration, /interval '1 hour'/); + assert.match(migration, /if v_recent >= 5 then\s+raise exception 'feedback_rate_limited'/); + assert.match(migration, /feedback_session_not_owned/); + assert.match(migration, /rating_session_not_owned/); + assert.match(migration, /revoke all on table public\.user_feedback from public, anon, authenticated, service_role;/); + assert.match(migration, /revoke all on table public\.reply_ratings from public, anon, authenticated, service_role;/); + assert.match(migration, /grant execute on function public\.submit_user_feedback\(uuid, text, text, text, uuid\) to service_role;/); + assert.doesNotMatch(migration, /\b(drop table|alter table public\.(?!user_feedback|reply_ratings))/i, "additive only"); + // Only the session id is stored, never conversation text. + const table = migration.slice(migration.indexOf("create table if not exists public.user_feedback"), migration.indexOf("create index if not exists user_feedback_user_created_idx")); + assert.doesNotMatch(table, /message|transcript|text\[\]/); +}); + +test("admin access: new permissions, role grants, permission-checked functions and an audit row", () => { + assert.match(read("../src/lib/admin/auth-policy.ts"), /"support\.feedback\.read",\n "support\.feedback\.write",/); + assert.match(read("../src/lib/admin/providers.ts"), /feedback: \{ read: "support\.feedback\.read", write: "support\.feedback\.write" \}/); + assert.match(migration, /\('support', 'support\.feedback\.read'\), \('support', 'support\.feedback\.write'\)/); + assert.match(migration, /\('auditor', 'support\.feedback\.read'\)\n\)/); + assert.match(migration, /admin_has_permission\(p_actor_user_id, 'support\.feedback\.read'\)/); + assert.match(migration, /admin_has_permission\(p_actor_user_id, 'support\.feedback\.write'\)/); + assert.match(migration, /'user_feedback\.update', 'user_feedback', p_feedback_id/); + const route = read("../src/app/api/admin/feedback/route.ts"); + assert.match(route, /requirePermission\("support\.feedback\.read"\)/); + assert.match(route, /requireAdminMutation\(request, "support\.feedback\.write"\)/); + assert.deepEqual(parseFeedbackFilter("type:content_report"), { type: "content_report", status: null }); + assert.deepEqual(parseFeedbackFilter("resolved"), { type: null, status: "resolved" }); + assert.deepEqual(parseFeedbackFilter("type:drop table"), { type: null, status: null }); + // New content reports sort first. + assert.match(migration, /order by \(f\.feedback_type = 'content_report' and f\.status = 'new'\) desc, f\.created_at desc/); +}); + +test("the user API signs in first and writes only through submit_user_feedback", () => { + const route = read("../src/app/api/feedback/route.ts"); + assert.ok(route.indexOf("auth.getUser()") < route.indexOf("feedbackSubmissionSchema.safeParse")); + assert.match(route, /status: 401/); + assert.match(route, /service\.rpc\("submit_user_feedback"/); + assert.doesNotMatch(route, /\.from\("user_feedback"\)/); +}); + +type Harness = ReturnType; +const find = (h: Harness, predicate: (node: ReturnType[number]) => boolean) => h.elements().find(predicate); + +test("the dialog validates, submits, shows the promised reply time and the contact email", async () => { + const calls: { url: string; body: Record }[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(JSON.stringify({ id: "x" }), { status: 201 }); + }) as typeof fetch; + const h = createClientLifecycleHarness(); + try { + await h.render( {}} />); + const form = find(h, (node) => node.tagName === "FORM")!; + await h.event(form, "onSubmit"); + assert.equal(find(h, (node) => node.getAttribute("role") === "alert")?.text, "请至少写 10 个字,说清楚发生了什么。"); + assert.equal(calls.length, 0); + await h.event(find(h, (node) => node.getAttribute("role") === "radio" && node.text === "内容举报")!); + await h.event(find(h, (node) => node.tagName === "TEXTAREA")!, "onChange", { target: { value: "回答里有一段话让我很不舒服,希望处理" } }); + await h.event(find(h, (node) => node.tagName === "INPUT" && node.props.type === "checkbox")!, "onChange", { target: { checked: true } }); + await h.event(find(h, (node) => node.tagName === "FORM")!, "onSubmit"); + await h.idle(); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.url, "/api/feedback"); + assert.deepEqual(calls[0]!.body, { type: "content_report", body: "回答里有一段话让我很不舒服,希望处理", contact: "user@example.test", sessionId: SESSION }); + assert.ok(h.container.text.includes(`已收到,我们会在 ${FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。`)); + assert.ok(h.container.text.includes(LEGAL_ENTITY.contactEmail)); + assert.deepEqual(h.errors, []); + } finally { + globalThis.fetch = originalFetch; + await h.close(); + } +}); + +test("the dialog shows the rate-limit message and offers no attach box without a saved conversation", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ error: FEEDBACK_RATE_LIMITED_COPY }), { status: 429 })) as unknown as typeof fetch; + const h = createClientLifecycleHarness(); + try { + await h.render( {}} />); + assert.equal(find(h, (node) => node.tagName === "INPUT" && node.props.type === "checkbox"), undefined); + await h.event(find(h, (node) => node.tagName === "TEXTAREA")!, "onChange", { target: { value: "扣点好像扣了两次,想确认一下" } }); + await h.event(find(h, (node) => node.tagName === "FORM")!, "onSubmit"); + await h.idle(); + assert.equal(find(h, (node) => node.getAttribute("role") === "alert")?.text, FEEDBACK_RATE_LIMITED_COPY); + } finally { + globalThis.fetch = originalFetch; + await h.close(); + } +}); + +test("the account menu has exactly one feedback entry and the sidebar owns the dialog", () => { + const sidebar = read("../src/components/app-sidebar.tsx"); + assert.equal((sidebar.match(/反馈与投诉<\/span>/g) ?? []).length, 1); + assert.match(sidebar, / { + assert.deepEqual(parseFeedbackKey(`${SESSION}:message-3`), { sessionId: SESSION, messageIndex: 3 }); + assert.equal(parseFeedbackKey("local-draft:message-3"), null, "unsaved conversations are not persisted"); + assert.match(await answerHash("你好"), /^[a-f0-9]{16}$/); + + const calls: { method: string; body?: Record }[] = []; + const originalFetch = globalThis.fetch; + const hashA = await answerHash("第一版回答"); + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + calls.push({ method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return new Response(JSON.stringify({ ratings: [ + { messageIndex: 1, rating: "up", answerSha256: hashA }, + { messageIndex: 3, rating: "down", answerSha256: hashA }, + ] }), { status: 200 }); + }) as typeof fetch; + try { + await persistReplyRating(`${SESSION}:message-1`, "up", "第一版回答"); + await persistReplyRating(`${SESSION}:message-1`, undefined, "第一版回答"); + assert.deepEqual(calls.map((call) => [call.method, call.body?.rating]), [["PUT", "up"], ["PUT", null]]); + const restored = await loadReplyRatings(SESSION, ["", "第一版回答", "", "重新生成后的另一版"]); + assert.deepEqual(restored, { [feedbackKeyFor(SESSION, 1)]: "up" }, "index 3 was regenerated, so its old rating is not shown"); + } finally { + globalThis.fetch = originalFetch; + } + assert.match(read("../src/components/chat-transcript.tsx"), /persistReplyRating\(feedbackKey, toggleChatMessageFeedback\(messageFeedback\[feedbackKey\], requested\), message\.text\)/); + assert.match(read("../src/app/(app)/page.tsx"), /useReplyRatingsSync\(activeSession, setMessageFeedback\);/); +});
已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。
急事也可以发邮件到 {LEGAL_ENTITY.contactEmail}。
{error}