diff --git a/README.md b/README.md index 7355c5f..6078fc2 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ docker compose up -d postgres - `/display/:token`:单项目只读公示屏。 - `/admin`:管理端运营概览。 - `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。 -- `/admin/display`:管理端大屏中心,可全屏展示多项目的公开运行状态。 +- `/admin/display`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。 ## 质量检查 diff --git a/findings.md b/findings.md index e4c119e..13142e9 100644 --- a/findings.md +++ b/findings.md @@ -593,3 +593,12 @@ - 根因已确认:跨营业日创建新场次时,旧场次可能仍保留 `RUNNING/PAUSED`;手机号查询没有像员工队列和公屏一样选择最新场次,因此把旧、新场次中的同号同时返回。 - 修复在数据库查询层增加按项目关联的“最新活动场次”子查询,不采用结果数组去重;这样旧场次同号会被排除,当前场次中同手机号的 `00001`、`00002` 等合法不同号码仍全部返回。 - 本次不需要数据库迁移,也不修改历史数据;重新发布 API 后查询立即按新口径生效。 + +# 2026-07-31 管理端大屏中心免登录访问 + +- `/admin/display` 当前被 `AuthProvider portal="admin"` 与 `RequireAdmin` 包裹,未登录会跳转 `/admin/login`。 +- `AdminPage` 无条件轮询 `/api/admin/overview`;该接口包含 `active_tickets` 的完整手机号、姓氏等授权数据,不能直接取消后端鉴权。 +- 大屏组件实际只消费项目 ID、名称、状态、当前公开票号、等待票/人数、累计取号/体验人数、预计等待等字段。 +- 采用新公开接口返回字段白名单,并让公开路由使用独立页面壳;其他 `/admin/*` 页面和 `/api/admin/*` 接口保持原鉴权。 +- 工作区已有 `DisplayPage.tsx` 与对应测试的未提交语音播报改动,本轮不覆盖、不整理这些用户改动。 +- 线上 URL 在普通读取器中被安全校验拒绝,应用内浏览器两次只读打开均超时;没有据此推断线上当前页面状态,也未执行部署。 diff --git a/progress.md b/progress.md index 7489085..8aa44cd 100644 --- a/progress.md +++ b/progress.md @@ -678,3 +678,14 @@ - 已更新生产交接文档,补充结构化日志字段、脱敏规则、正文上限及 Ingress URI 脱敏要求。 - 最终验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./cmd/api`、`git diff --check`。 - `go test -race` 因当前 Windows Go 环境关闭 CGO 未运行;未发现由此遗留的功能失败。 + +# Session: 2026-07-31(管理端大屏中心免登录访问) + +- 已定位前端登录跳转和后端数据依赖:路由受 `RequireAdmin` 保护,页面数据来自含游客个人信息的 `/api/admin/overview`。 +- 已确定安全边界:保留所有管理接口鉴权,新增公开字段白名单接口,并为 `/admin/display` 提供不依赖管理员会话的页面。 +- 已确认工作区原有两处公示屏语音播报改动,后续实现将避开并保留。 +- 后端新增 `GET /api/display/overview`,响应只投影大屏所需的公开字段;`/api/admin/overview` 仍由 `requireAdmin` 包裹。 +- 前端新增公开大屏页面,路由不再挂载管理员 `AuthProvider` 或 `RequireAdmin`,且页面不显示退出与管理导航。 +- 聚焦验证通过:Go 公开 DTO 测试 2 项;前端路由、API、公开页面及既有管理页共 4 个文件、19 项测试;TypeScript 检查通过。 +- 全量验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./...`;前端 18 个文件、59 项测试与 Vite 生产构建全部通过;`git diff --check` 无格式错误。 +- 线上 `https://queue.nianxx.cn/admin/display` 的只读核对连续超时;本轮未获授权发布生产环境,因此线上效果需在部署本次代码后复验。 diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index 3c7bb24..a3f80a3 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -444,6 +444,41 @@ func newDisplayBatchDTO(batch model.CallBatch, tickets []displayTicketDTO) displ } } +func publicDisplayProjectView(project map[string]any) map[string]any { + return map[string]any{ + "id": project["id"], + "name": project["name"], + "status": project["status"], + "waiting_count": project["waiting_count"], + "waiting_ticket_count": project["waiting_ticket_count"], + "waiting_people_count": project["waiting_people_count"], + "issued_ticket_count": project["issued_ticket_count"], + "latest_ticket_number": project["latest_ticket_number"], + "experienced_people": project["experienced_people"], + "current_batch": project["current_batch"], + "estimated_wait": project["estimated_wait"], + "last_updated_at": project["last_updated_at"], + } +} + +func (s *Server) displayOverview(w http.ResponseWriter, r *http.Request) { + var projects []model.Project + if err := s.db.WithContext(r.Context()).Order("name ASC").Find(&projects).Error; err != nil { + writeError(w, err) + return + } + views := make([]map[string]any, 0, len(projects)) + for _, project := range projects { + projection, _, _, _, _, err := s.adminProjectProjection(r.Context(), project) + if err != nil { + writeError(w, err) + return + } + views = append(views, publicDisplayProjectView(projection)) + } + writeJSON(w, http.StatusOK, map[string]any{"projects": views, "server_time": s.now()}) +} + func (s *Server) events(w http.ResponseWriter, r *http.Request) { projectID := strings.TrimSpace(r.URL.Query().Get("project_id")) if err := validateUUID(projectID); err != nil { diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index 782450f..eb17f5a 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -31,6 +31,33 @@ func TestDisplayBatchDTOCannotSerializePersonalFields(t *testing.T) { } } +func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) { + view := publicDisplayProjectView(map[string]any{ + "id": "project-1", "name": "东门观光车", "status": model.ProjectRunning, + "waiting_count": 3, "waiting_ticket_count": 3, "waiting_people_count": 7, + "issued_ticket_count": 15, "latest_ticket_number": "00015", "experienced_people": 12, + "current_batch": map[string]any{"tickets": []map[string]any{{"ticket_number": "00013"}}}, + "estimated_wait": map[string]any{"available": true}, "last_updated_at": time.Unix(100, 0).UTC(), + "phone": "13800138000", "last_name": "张", "visitor_notice": "internal", + "device_status": map[string]any{"status": "FAILURE"}, "display_token_hash": "secret", + }) + body, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + encoded := string(body) + for _, forbidden := range []string{"phone", "last_name", "visitor_notice", "device_status", "display_token_hash", "13800138000"} { + if strings.Contains(encoded, forbidden) { + t.Fatalf("public display overview leaked forbidden field %q: %s", forbidden, encoded) + } + } + for _, required := range []string{`"name":"东门观光车"`, `"ticket_number":"00013"`, `"waiting_people_count":7`} { + if !strings.Contains(encoded, required) { + t.Fatalf("public display overview missing field %q: %s", required, encoded) + } + } +} + func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) { server := &Server{config: config.Config{Environment: "production"}} recorder := httptest.NewRecorder() diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 9f1c0a9..b72ad0d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -89,6 +89,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/public/projects/{id}/tickets", s.publicCreateTicket) mux.HandleFunc("POST /api/public/status/search", s.publicStatusByPhone) mux.Handle("POST /api/internal/status/search", s.requireInternalNetwork(http.HandlerFunc(s.internalStatusByPhone))) + mux.HandleFunc("GET /api/display/overview", s.displayOverview) mux.HandleFunc("GET /api/display/{token}/snapshot", s.displaySnapshot) mux.Handle("GET /api/events", s.requireStaff(http.HandlerFunc(s.events))) mux.Handle("GET /api/admin/overview", s.requireAdmin(http.HandlerFunc(s.adminOverview))) diff --git a/task_plan.md b/task_plan.md index 4fb86f0..651cf04 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 42(运营统计模块标题区呼吸空间优化) +Phase 45(管理端大屏中心免登录访问) ## Phases @@ -550,3 +550,25 @@ Phase 42(运营统计模块标题区呼吸空间优化) | Error | Attempt | Resolution | |---|---:|---| | PowerShell 下向 `rg` 传入 Unix 风格 `*_test.go` 路径通配符失败 | 1 | 后续改由 `rg` 自身的 `-g` 过滤文件,不再让 Windows 解析目录通配符 | + +### Phase 45: 管理端大屏中心免登录访问(2026-07-31) +- [x] 核对 `/admin/display` 前端路由、页面依赖与后端鉴权边界 +- [x] 新增仅包含公开大屏字段的免登录接口 +- [x] 将 `/admin/display` 切换为无需管理员会话的独立页面 +- [x] 补充路由、公开字段白名单与页面回归测试 +- [x] 完成前后端测试、类型检查和生产构建验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 管理端总览接口 | `/api/admin/overview` 继续强制管理员登录 | 该接口含活动游客联系方式和后台配置,不能为大屏免登录而整体放开 | +| 公开大屏数据 | 新增字段白名单接口,仅返回项目名、公开票号、等待量、预计等待与刷新时间 | 满足多项目大屏展示,同时维持隐私与后台权限边界 | +| 前端入口 | `/admin/display` 使用独立公开页面,不挂载管理员鉴权 Provider | 未登录访问不产生登录跳转或无意义的管理员会话探测 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---:|---| +| 首次组合补丁把内部网络路由误写为 `HandleFunc`,上下文未匹配 | 1 | 读取真实路由片段后按现有 `Handle` 行重新应用,未产生部分代码改动 | +| 普通网页读取器因域名安全校验拒绝打开线上 URL | 1 | 改用只读应用内浏览器核对,不绕过安全校验 | +| 应用内浏览器两次打开线上 URL 均超时 | 2 | 按有界重试停止继续访问;保留“代码已完成、线上尚未部署验证”的明确边界 | diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..8868654 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { authProvider } = vi.hoisted(() => ({ + authProvider: vi.fn(({ children }: { children: React.ReactNode }) => <>{children}>), +})); + +vi.mock("./auth/AuthContext", () => ({ + AuthProvider: authProvider, + useAuth: () => ({ user: null, loading: false }), +})); +vi.mock("./pages/PublicDisplayCenterPage", () => ({ + PublicDisplayCenterPage: () =>