package httpapi import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "strconv" "strings" "time" "ballet-island/backend/internal/identity" "ballet-island/backend/internal/practice" "github.com/jackc/pgx/v5/pgxpool" ) // IdentityVerifier is the only external identity dependency. Implementations // return an app-scoped verified identity and must never trust client user IDs. type IdentityVerifier interface { Exchange(context.Context, string) (string, error) } type app struct { store *practice.Store verifier IdentityVerifier } type operation func(http.ResponseWriter, *http.Request, string) error // NewAppHandler adds authenticated business routes while retaining independent health checks. func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() time.Time) http.Handler { a := &app{store: practice.New(pool, now), verifier: verifier} mux := http.NewServeMux() mux.Handle("/", NewHandler(pool.Ping)) mux.HandleFunc("POST /v1/session", a.login) mux.HandleFunc("GET /v1/projects", a.auth(a.projects)) mux.HandleFunc("POST /v1/projects", a.auth(a.saveProject)) mux.HandleFunc("PUT /v1/projects/order", a.auth(a.reorderProjects)) mux.HandleFunc("PUT /v1/projects/{id}", a.auth(a.saveProject)) mux.HandleFunc("DELETE /v1/projects/{id}", a.auth(a.removeProject)) mux.HandleFunc("GET /v1/records", a.auth(a.records)) mux.HandleFunc("GET /v1/records/{id}", a.auth(a.record)) mux.HandleFunc("POST /v1/records", a.auth(a.saveRecord)) mux.HandleFunc("PUT /v1/records/{id}", a.auth(a.saveRecord)) mux.HandleFunc("DELETE /v1/records/{id}", a.auth(a.deleteRecord)) mux.HandleFunc("GET /v1/review", a.auth(a.review)) return mux } func respond(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func decode(w http.ResponseWriter, r *http.Request, value any) error { r.Body = http.MaxBytesReader(w, r.Body, 32*1024) d := json.NewDecoder(r.Body) d.DisallowUnknownFields() if err := d.Decode(value); err != nil { return practice.ErrInvalid } if err := d.Decode(new(any)); err != io.EOF { return practice.ErrInvalid } return nil } func failure(w http.ResponseWriter, err error) { status, code, message := 503, "service_unavailable", "服务暂时不可用,请稍后重试" switch { case errors.Is(err, practice.ErrUnauthorized), errors.Is(err, identity.ErrInvalidCode): status, code, message = 401, "unauthorized", practice.ErrUnauthorized.Error() case errors.Is(err, practice.ErrInvalid): status, code, message = 400, "invalid_input", practice.ErrInvalid.Error() case errors.Is(err, practice.ErrUnavailable): status, code, message = 404, "unavailable", practice.ErrUnavailable.Error() case errors.Is(err, practice.ErrArchived): status, code, message = 409, "archived_project", practice.ErrArchived.Error() case errors.Is(err, practice.ErrConflict): status, code, message = 409, "submission_conflict", practice.ErrConflict.Error() case errors.Is(err, practice.ErrDeleted): status, code, message = 410, "record_deleted", practice.ErrDeleted.Error() default: // Do not log raw database/network errors: they may contain notes, credentials or URLs. slog.Error("business request failed", "category", code) } respond(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) } func (a *app) auth(fn operation) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() r = r.WithContext(ctx) header := r.Header.Get("Authorization") if !strings.HasPrefix(header, "Bearer ") { failure(w, practice.ErrUnauthorized) return } user, err := a.store.Authenticate(ctx, strings.TrimPrefix(header, "Bearer ")) if err == nil { err = fn(w, r, user) } if err != nil { failure(w, err) } } } func (a *app) login(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() var input struct { Code string `json:"code"` } if err := decode(w, r, &input); err != nil || strings.TrimSpace(input.Code) == "" || len(input.Code) > 512 { failure(w, practice.ErrInvalid) return } identity, err := a.verifier.Exchange(ctx, input.Code) if err != nil { failure(w, err) return } session, err := a.store.Login(ctx, identity) if err != nil { failure(w, err) return } respond(w, 200, session) } func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) error { projects, err := a.store.Projects(r.Context(), user, r.URL.Query().Get("includeArchived") == "true") if err == nil { respond(w, 200, map[string]any{"projects": projects, "today": a.store.Today()}) } return err } func (a *app) reorderProjects(w http.ResponseWriter, r *http.Request, user string) error { var input struct { ProjectIDs []string `json:"projectIds"` } if err := decode(w, r, &input); err != nil { return err } if err := a.store.ReorderProjects(r.Context(), user, input.ProjectIDs); err != nil { return err } respond(w, 200, map[string]string{"action": "reordered"}) return nil } func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error { var input struct { Name string `json:"name"` IconID *string `json:"iconId"` } if err := decode(w, r, &input); err != nil { return err } p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name, input.IconID) if err == nil { respond(w, 200, p) } return err } func (a *app) removeProject(w http.ResponseWriter, r *http.Request, user string) error { mode, err := a.store.RemoveProject(r.Context(), user, r.PathValue("id")) if err == nil { respond(w, 200, map[string]string{"action": mode}) } return err } func (a *app) records(w http.ResponseWriter, r *http.Request, user string) error { q := r.URL.Query() limit := 20 if q.Has("limit") { var err error limit, err = strconv.Atoi(q.Get("limit")) if err != nil { return practice.ErrInvalid } } list, err := a.store.Records(r.Context(), user, q.Get("from"), q.Get("to"), q.Get("cursor"), limit) if err == nil { respond(w, 200, list) } return err } func (a *app) record(w http.ResponseWriter, r *http.Request, user string) error { record, err := a.store.Record(r.Context(), user, r.PathValue("id")) if err == nil { respond(w, 200, record) } return err } func (a *app) saveRecord(w http.ResponseWriter, r *http.Request, user string) error { var input practice.RecordInput if err := decode(w, r, &input); err != nil { return err } record, err := a.store.SaveRecord(r.Context(), user, r.PathValue("id"), input) if err == nil { respond(w, 200, record) } return err } func (a *app) deleteRecord(w http.ResponseWriter, r *http.Request, user string) error { err := a.store.DeleteRecord(r.Context(), user, r.PathValue("id")) if err == nil { respond(w, 200, map[string]string{"action": "deleted"}) } return err } func (a *app) review(w http.ResponseWriter, r *http.Request, user string) error { review, err := a.store.Review(r.Context(), user, r.URL.Query().Get("period"), r.URL.Query().Get("date")) if err == nil { respond(w, 200, review) } return err }