支持项目编码访问单项目公示屏
需求:无需查询公示 Token,直接通过项目编码打开单项目公示页。 实现:规范化项目编码并按 code 查询,同时保留既有 Token 哈希查询;补充前后端及集成回归测试。
This commit is contained in:
@@ -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`:无需登录的多项目只读大屏中心,可全屏展示公开运行状态。
|
||||
|
||||
@@ -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,但项目编码必有值;改用项目编码后,新建项目也能直接打开单项目只读大屏。
|
||||
|
||||
@@ -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` 无格式错误。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
23
task_plan.md
23
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`,避免调用方误用无效编码 |
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user