Files
yuxuanhui b0c62d461e feat: add project icon picker component and integrate with project management
- Implemented ProjectIconPicker component for selecting project icons.
- Created ProjectIcon component to display selected icons.
- Updated calendar page to remove unused PageHeading component.
- Modified growth page to enhance UI and improve text clarity.
- Refactored index page to include project icons and improve layout.
- Added mine page for user profile management with avatar selection.
- Introduced projects-sort page for managing project order with drag-and-drop functionality.
- Enhanced projects page to allow icon selection when adding or editing projects.
- Updated record page to streamline user experience and improve form actions.
- Adjusted practice service to support project icon management.
- Added tests for project saving functionality to ensure correct handling of icons.
- Updated global type definitions to support SVG and PNG imports.
2026-09-29 18:40:25 +08:00

226 lines
7.2 KiB
Go

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
}