开放多项目大屏免登录访问

需求描述:/admin/display 需要无需管理员登录即可直接查看多项目公开运行状态。

实现思路:新增公开字段白名单接口与独立大屏页面,移除该路由的管理员会话依赖,同时保留 /api/admin/overview 的鉴权并补充隐私、路由和页面回归测试。

验证:Go 全量测试通过;前端 18 个测试文件共 59 项通过,TypeScript 检查通过。
This commit is contained in:
2026-07-31 16:36:03 +08:00
parent 17d296263c
commit d782227a23
15 changed files with 262 additions and 4 deletions

View File

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

View File

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

View File

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