From 9948c23bef95583d1832140cb435ef8be8f1172f Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 31 Jul 2026 17:11:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A1=B9=E7=9B=AE=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E8=AE=BF=E9=97=AE=E5=8D=95=E9=A1=B9=E7=9B=AE=E5=85=AC?= =?UTF-8?q?=E7=A4=BA=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:无需查询公示 Token,直接通过项目编码打开单项目公示页。 实现:规范化项目编码并按 code 查询,同时保留既有 Token 哈希查询;补充前后端及集成回归测试。 --- README.md | 2 +- findings.md | 7 ++++++ progress.md | 9 +++++++ server/internal/httpapi/public.go | 25 ++++++++++++++----- server/internal/httpapi/public_test.go | 20 +++++++++++++++ .../httpapi/rollover_integration_test.go | 16 ++++++++++++ task_plan.md | 23 ++++++++++++++++- web/src/api.test.ts | 14 +++++++++++ 8 files changed, 108 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6078fc2..011abe7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ docker compose up -d postgres - `/visitor`:游客自助取号与手机号查询入口,通过页面顶部的“取号 / 查号” Tab 切换;手机号查询仍是运营测试入口,正式上线前需替换为验证码或外部身份接口。 - `/visitor/phone`:手机号查询后的游客号码状态页,不再显示手机号输入框。 - `/visitor/:token`:游客私密状态页。 -- `/display/:token`:单项目只读公示屏。 +- `/display/:projectCode`:按项目编码打开单项目只读公示屏,例如 `/display/RAFT`;已有公示 Token 地址继续兼容。 - `/admin`:管理端运营概览。 - `/admin/projects`:配置项目支持的叫号方式、单号人数范围、两种方式各自的默认值与防误触单次上限,以及按单人间隔计算的预计等待时间。 - `/admin/display`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。 diff --git a/findings.md b/findings.md index 13142e9..99d132c 100644 --- a/findings.md +++ b/findings.md @@ -602,3 +602,10 @@ - 采用新公开接口返回字段白名单,并让公开路由使用独立页面壳;其他 `/admin/*` 页面和 `/api/admin/*` 接口保持原鉴权。 - 工作区已有 `DisplayPage.tsx` 与对应测试的未提交语音播报改动,本轮不覆盖、不整理这些用户改动。 - 线上 URL 在普通读取器中被安全校验拒绝,应用内浏览器两次只读打开均超时;没有据此推断线上当前页面状态,也未执行部署。 + +# 2026-07-31 单项目大屏项目编码路由 + +- 前端已有 `/display/:token` 通配路由,传入项目编码时无需新增 React 路由;当前阻断点是后端只接受 40–128 位 Token 并按哈希查项目。 +- 项目编码规则为 2–24 位大写字母、数字、下划线或连字符;可复用既有校验规则,并对 URL 输入统一转大写。 +- 已有有效 Token 最短 40 位,不会与最长 24 位的项目编码冲突,因此可在同一个快照接口中安全区分两类标识。 +- 后台新建项目不生成公示 Token,但项目编码必有值;改用项目编码后,新建项目也能直接打开单项目只读大屏。 diff --git a/progress.md b/progress.md index 8aa44cd..aca7316 100644 --- a/progress.md +++ b/progress.md @@ -689,3 +689,12 @@ - 聚焦验证通过: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` 的只读核对连续超时;本轮未获授权发布生产环境,因此线上效果需在部署本次代码后复验。 + +# Session: 2026-07-31(单项目大屏支持项目编码路由) + +- 已确认目标路径采用 `/display/{项目编码}`,项目编码大小写不敏感。 +- 实现将只调整公开快照的项目定位逻辑,保留既有 Token 查询和公开字段边界。 +- 成功标准:项目编码与 Token 均能定位项目,无效短标识仍返回 404,前后端全量验证通过。 +- 已实现项目编码规范化与查询分支:合法短编码按 `projects.code` 查询,其他标识仍按原 Token 长度与哈希规则处理。 +- 已补充单元测试、前端 API 路径测试及 PostgreSQL 项目编码路由集成回归。 +- 全量验证通过:`go test ./... -count=1`、`go vet ./...`、`go build ./...`;前端 18 个测试文件共 61 项通过,TypeScript 检查与 Vite 生产构建成功;`git diff --check` 无格式错误。 diff --git a/server/internal/httpapi/public.go b/server/internal/httpapi/public.go index a3f80a3..ca2a933 100644 --- a/server/internal/httpapi/public.go +++ b/server/internal/httpapi/public.go @@ -288,14 +288,27 @@ type displaySnapshotDTO struct { LastUpdatedAt time.Time `json:"last_updated_at"` } -func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { - token := strings.TrimSpace(r.PathValue("token")) - if len(token) < 40 || len(token) > 128 { - writeError(w, &apiError{Status: http.StatusNotFound, Code: "DISPLAY_NOT_FOUND", Message: "公示屏绑定不存在"}) - return +func normalizeDisplayProjectCode(value string) (string, bool) { + code := strings.ToUpper(strings.TrimSpace(value)) + if !projectCodePattern.MatchString(code) { + return "", false } + return code, true +} + +func (s *Server) displaySnapshot(w http.ResponseWriter, r *http.Request) { + identifier := strings.TrimSpace(r.PathValue("token")) var project model.Project - err := s.db.WithContext(r.Context()).Where("display_token_hash = ?", security.HashToken(token)).First(&project).Error + var err error + if code, ok := normalizeDisplayProjectCode(identifier); ok { + err = s.db.WithContext(r.Context()).Where("code = ?", code).First(&project).Error + } else { + if len(identifier) < 40 || len(identifier) > 128 { + writeError(w, &apiError{Status: http.StatusNotFound, Code: "DISPLAY_NOT_FOUND", Message: "公示屏绑定不存在"}) + return + } + err = s.db.WithContext(r.Context()).Where("display_token_hash = ?", security.HashToken(identifier)).First(&project).Error + } if err != nil { writeError(w, mapNotFound(err, "DISPLAY_NOT_FOUND", "公示屏绑定不存在")) return diff --git a/server/internal/httpapi/public_test.go b/server/internal/httpapi/public_test.go index eb17f5a..a1dc977 100644 --- a/server/internal/httpapi/public_test.go +++ b/server/internal/httpapi/public_test.go @@ -58,6 +58,26 @@ func TestPublicDisplayProjectViewOnlySerializesDisplayFields(t *testing.T) { } } +func TestNormalizeDisplayProjectCode(t *testing.T) { + tests := []struct { + input string + want string + ok bool + }{ + {input: "raft", want: "RAFT", ok: true}, + {input: " east-ride ", want: "EAST-RIDE", ok: true}, + {input: "A", ok: false}, + {input: "bad code", ok: false}, + {input: strings.Repeat("a", 40), ok: false}, + } + for _, test := range tests { + got, ok := normalizeDisplayProjectCode(test.input) + if got != test.want || ok != test.ok { + t.Fatalf("normalizeDisplayProjectCode(%q) = %q, %v; want %q, %v", test.input, got, ok, test.want, test.ok) + } + } +} + func TestPublicPhoneLookupIsDisabledInProduction(t *testing.T) { server := &Server{config: config.Config{Environment: "production"}} recorder := httptest.NewRecorder() diff --git a/server/internal/httpapi/rollover_integration_test.go b/server/internal/httpapi/rollover_integration_test.go index e03c40d..c94de37 100644 --- a/server/internal/httpapi/rollover_integration_test.go +++ b/server/internal/httpapi/rollover_integration_test.go @@ -158,6 +158,22 @@ func TestCurrentDayViewsIgnoreYesterdayRunningSessionPostgresIntegration(t *test } }) + t.Run("display by project code", func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/display/"+strings.ToLower(project.Code)+"/snapshot", nil) + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var response displaySnapshotDTO + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Project.ID != project.ID { + t.Fatalf("project = %q, want %q", response.Project.ID, project.ID) + } + }) + t.Run("admin project", func(t *testing.T) { projection, _, _, _, _, err := server.adminProjectProjection(context.Background(), project) if err != nil { diff --git a/task_plan.md b/task_plan.md index 651cf04..140cca5 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 45(管理端大屏中心免登录访问) +Phase 46(单项目大屏支持项目编码路由) ## Phases @@ -572,3 +572,24 @@ Phase 45(管理端大屏中心免登录访问) | 首次组合补丁把内部网络路由误写为 `HandleFunc`,上下文未匹配 | 1 | 读取真实路由片段后按现有 `Handle` 行重新应用,未产生部分代码改动 | | 普通网页读取器因域名安全校验拒绝打开线上 URL | 1 | 改用只读应用内浏览器核对,不绕过安全校验 | | 应用内浏览器两次打开线上 URL 均超时 | 2 | 按有界重试停止继续访问;保留“代码已完成、线上尚未部署验证”的明确边界 | + +### Phase 46: 单项目大屏支持项目编码路由(2026-07-31) +- [x] 核对现有 `/display/:token` 路由与项目编码规则 +- [x] 后端允许用项目编码读取公开单项目快照 +- [x] 保留已有公示 Token 路由兼容 +- [x] 补充编码规范化、API 路径与回归测试 +- [x] 完成全量测试、类型检查和构建验证 +- **Status:** complete + +#### Confirmed Decisions +| Decision | Result | Why it matters | +|---|---|---| +| 单项目 URL | `/display/{项目编码}`,例如 `/display/RAFT` | 无需查找不可恢复的原始公示 Token,路径可直接配置到现场屏幕 | +| 大小写 | 项目编码路由大小写不敏感,服务端统一转大写查询 | 降低手工录入错误 | +| 兼容策略 | 40–128 位既有 Token 继续按哈希查询 | 不破坏已经部署的单项目公示链接 | + +#### Errors Encountered +| Error | Attempt | Resolution | +|---|---:|---| +| 新增回归测试在旧实现下找不到 `normalizeDisplayProjectCode` | 1 | 作为项目编码解析缺口的失败基线,随后实现解析函数 | +| 解析函数对非法编码返回了规范化文本和 `false`,与失败契约不一致 | 1 | 非法输入统一返回空字符串和 `false`,避免调用方误用无效编码 | diff --git a/web/src/api.test.ts b/web/src/api.test.ts index 4436f69..17947a9 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -120,6 +120,20 @@ describe("public display overview", () => { expect(fetchMock.mock.calls[0][0]).toBe("/api/display/overview"); }); + + it("loads one project display using its project code", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + project_name: "峡谷漂流", + status: "RUNNING", + current_batch: null, + recent_batches: [], + })); + vi.stubGlobal("fetch", fetchMock); + + await api.display("RAFT"); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/display/RAFT/snapshot"); + }); }); describe("isolated portal authentication", () => {