2026-09-29 13:47:26 +08:00
|
|
|
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))
|
2026-09-29 18:40:25 +08:00
|
|
|
mux.HandleFunc("PUT /v1/projects/order", a.auth(a.reorderProjects))
|
2026-09-29 13:47:26 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-29 18:40:25 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-29 13:47:26 +08:00
|
|
|
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
|
|
|
|
|
var input struct {
|
2026-09-29 18:40:25 +08:00
|
|
|
Name string `json:"name"`
|
|
|
|
|
IconID *string `json:"iconId"`
|
2026-09-29 13:47:26 +08:00
|
|
|
}
|
|
|
|
|
if err := decode(w, r, &input); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-09-29 18:40:25 +08:00
|
|
|
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name, input.IconID)
|
2026-09-29 13:47:26 +08:00
|
|
|
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
|
|
|
|
|
}
|