支持项目编码访问单项目公示屏

需求:无需查询公示 Token,直接通过项目编码打开单项目公示页。

实现:规范化项目编码并按 code 查询,同时保留既有 Token 哈希查询;补充前后端及集成回归测试。
This commit is contained in:
2026-07-31 17:11:21 +08:00
parent 78df07e074
commit 9948c23bef
8 changed files with 108 additions and 8 deletions

View File

@@ -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`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。

View File

@@ -602,3 +602,10 @@
- 采用新公开接口返回字段白名单,并让公开路由使用独立页面壳;其他 `/admin/*` 页面和 `/api/admin/*` 接口保持原鉴权。
- 工作区已有 `DisplayPage.tsx` 与对应测试的未提交语音播报改动,本轮不覆盖、不整理这些用户改动。
- 线上 URL 在普通读取器中被安全校验拒绝,应用内浏览器两次只读打开均超时;没有据此推断线上当前页面状态,也未执行部署。
# 2026-07-31 单项目大屏项目编码路由
- 前端已有 `/display/:token` 通配路由,传入项目编码时无需新增 React 路由;当前阻断点是后端只接受 40128 位 Token 并按哈希查项目。
- 项目编码规则为 224 位大写字母、数字、下划线或连字符;可复用既有校验规则,并对 URL 输入统一转大写。
- 已有有效 Token 最短 40 位,不会与最长 24 位的项目编码冲突,因此可在同一个快照接口中安全区分两类标识。
- 后台新建项目不生成公示 Token但项目编码必有值改用项目编码后新建项目也能直接打开单项目只读大屏。

View File

@@ -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` 无格式错误。

View File

@@ -288,14 +288,27 @@ type displaySnapshotDTO struct {
LastUpdatedAt time.Time `json:"last_updated_at"`
}
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) {
token := strings.TrimSpace(r.PathValue("token"))
if len(token) < 40 || len(token) > 128 {
identifier := strings.TrimSpace(r.PathValue("token"))
var project model.Project
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
}
var project model.Project
err := s.db.WithContext(r.Context()).Where("display_token_hash = ?", security.HashToken(token)).First(&project).Error
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

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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路径可直接配置到现场屏幕 |
| 大小写 | 项目编码路由大小写不敏感,服务端统一转大写查询 | 降低手工录入错误 |
| 兼容策略 | 40128 位既有 Token 继续按哈希查询 | 不破坏已经部署的单项目公示链接 |
#### Errors Encountered
| Error | Attempt | Resolution |
|---|---:|---|
| 新增回归测试在旧实现下找不到 `normalizeDisplayProjectCode` | 1 | 作为项目编码解析缺口的失败基线,随后实现解析函数 |
| 解析函数对非法编码返回了规范化文本和 `false`,与失败契约不一致 | 1 | 非法输入统一返回空字符串和 `false`,避免调用方误用无效编码 |

View File

@@ -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", () => {