feat: add growth tracking page and related functionality

- Implemented a new Growth page to track practice time and trends.
- Added API integration for fetching review data.
- Created components for displaying practice statistics and trends.
- Updated navigation titles for the main index and growth pages.
- Removed unused styles from the index page.
- Introduced a Projects management page for adding and editing practice projects.
- Developed a Record form for logging practice sessions with validation.
- Added utility functions for date manipulation and duration formatting.
- Implemented error handling and session management in the practice service.
- Created unit tests for the practice service to ensure reliability.
This commit is contained in:
yuxuanhui
2026-09-29 13:47:26 +08:00
parent 4b42b55928
commit 6224ef5980
49 changed files with 2824 additions and 71 deletions
+4
View File
@@ -11,3 +11,7 @@ HTTP_ADDR=127.0.0.1:8080
API_PORT=8080
API_BIND=127.0.0.1
# Server-only WeChat credentials. Never put AppSecret in the mini program bundle.
# Without these, health checks work but identity exchange returns 503.
WECHAT_APP_ID=wx6dfbf1021db8aee0
WECHAT_APP_SECRET=
+7 -2
View File
@@ -11,7 +11,9 @@ import (
"syscall"
"time"
"ballet-island/backend/internal/database"
"ballet-island/backend/internal/httpapi"
"ballet-island/backend/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -40,8 +42,11 @@ func run() error {
}
defer pool.Close()
startupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
startupCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
err = pool.Ping(startupCtx)
if err == nil {
err = database.Migrate(startupCtx, pool)
}
cancel()
if err != nil {
return fmt.Errorf("connect database: %w", err)
@@ -53,7 +58,7 @@ func run() error {
}
server := &http.Server{
Addr: addr,
Handler: httpapi.NewHandler(pool.Ping),
Handler: httpapi.NewAppHandler(pool, &identity.WeChat{AppID: os.Getenv("WECHAT_APP_ID"), AppSecret: os.Getenv("WECHAT_APP_SECRET")}, nil),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
+2
View File
@@ -32,6 +32,8 @@ services:
PGUSER: ${PGUSER:?Set PGUSER in .env}
PGPASSWORD: ${PGPASSWORD:?Set PGPASSWORD in .env}
PGSSLMODE: disable
WECHAT_APP_ID: ${WECHAT_APP_ID:-}
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
ports:
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
depends_on:
@@ -0,0 +1,47 @@
CREATE TABLE users (
id text PRIMARY KEY,
wechat_identity text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
token_hash text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
expires_at timestamptz NOT NULL
);
CREATE INDEX sessions_expiry ON sessions(expires_at);
CREATE TABLE projects (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 40),
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
position bigint GENERATED ALWAYS AS IDENTITY,
UNIQUE (user_id, id)
);
CREATE INDEX projects_owner ON projects(user_id, position);
CREATE TABLE records (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
project_id text NOT NULL,
practice_date date NOT NULL CHECK (practice_date >= DATE '0001-01-01'),
minutes integer NOT NULL CHECK (minutes > 0),
note text NOT NULL DEFAULT '' CHECK (char_length(note) <= 2000),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (user_id, project_id) REFERENCES projects(user_id, id) ON DELETE RESTRICT
);
CREATE INDEX records_owner_date ON records(user_id, practice_date DESC, id DESC);
CREATE INDEX records_owner_updated ON records(user_id, updated_at DESC, id DESC);
CREATE INDEX records_project ON records(user_id, project_id);
-- Keep submission receipts after a record is deleted so a delayed retry cannot resurrect it.
CREATE TABLE submissions (
user_id text NOT NULL REFERENCES users(id),
request_id text NOT NULL,
payload_hash text NOT NULL,
record_id text NOT NULL,
PRIMARY KEY (user_id, request_id)
);
+41
View File
@@ -0,0 +1,41 @@
package database
import (
"context"
_ "embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed 001_practice.sql
var practiceSQL string
// Migrate applies versioned migrations atomically. A transaction-scoped lock keeps
// concurrent service starts from applying the same version twice.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock(684127930)"); err != nil {
return err
}
if _, err = tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())"); err != nil {
return err
}
var applied bool
if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=1)").Scan(&applied); err != nil {
return err
}
if !applied {
if _, err = tx.Exec(ctx, practiceSQL); err != nil {
return fmt.Errorf("migration 1: %w", err)
}
if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES (1)"); err != nil {
return err
}
}
return tx.Commit(ctx)
}
+210
View File
@@ -0,0 +1,210 @@
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/{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) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
var input struct {
Name string `json:"name"`
}
if err := decode(w, r, &input); err != nil {
return err
}
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name)
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
}
+397
View File
@@ -0,0 +1,397 @@
package httpapi_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"ballet-island/backend/internal/database"
"ballet-island/backend/internal/httpapi"
"ballet-island/backend/internal/identity"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type testIdentity struct{}
func (testIdentity) Exchange(_ context.Context, code string) (string, error) {
return "test-app:" + code, nil
}
type harness struct {
t *testing.T
h http.Handler
now time.Time
pool *pgxpool.Pool
}
func setup(t *testing.T) *harness {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
if os.Getenv("REQUIRE_TEST_DATABASE") == "1" {
t.Fatal("TEST_DATABASE_URL is required")
}
t.Skip("PostgreSQL integration: set TEST_DATABASE_URL or run scripts/test-integration.sh")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(admin.Close)
schema := fmt.Sprintf("practice_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
if _, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE") })
config, err := pgxpool.ParseConfig(url)
if err != nil {
t.Fatal(err)
}
config.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
// The same migration path is safe on every service restart.
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
h := &harness{t: t, pool: pool, now: time.Date(2026, 9, 28, 2, 0, 0, 0, time.UTC)}
h.h = httpapi.NewAppHandler(pool, testIdentity{}, func() time.Time { return h.now })
return h
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestWeChatExchangeAndSessionExpiry(t *testing.T) {
h := setup(t)
verifier := &identity.WeChat{AppID: "test-app", AppSecret: "server-only", Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Scheme != "https" || r.URL.Host != "api.weixin.qq.com" || r.URL.Query().Get("secret") != "server-only" || r.URL.Query().Get("grant_type") != "authorization_code" {
t.Fatal("invalid identity exchange")
}
body := `{"openid":"verified-alice","session_key":"never-return-this"}`
if r.URL.Query().Get("js_code") == "invalid" {
body = `{"errcode":40029,"errmsg":"invalid code"}`
}
if r.URL.Query().Get("js_code") == "upstream-down" {
body = `{"errcode":-1,"errmsg":"system error"}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}}
h.h = httpapi.NewAppHandler(h.pool, verifier, func() time.Time { return h.now })
h.request("POST", "/v1/session", "", `{"code":"invalid"}`, 401)
h.request("POST", "/v1/session", "", `{"code":"upstream-down"}`, 503)
session := h.request("POST", "/v1/session", "", `{"code":"valid"}`, 200)
serialized, _ := json.Marshal(session)
if strings.Contains(string(serialized), "verified-alice") || strings.Contains(string(serialized), "never-return-this") {
t.Fatal("private identity details leaked")
}
token := session["token"].(string)
h.request("GET", "/v1/projects", token, "", 200)
h.now = h.now.Add(31 * 24 * time.Hour)
h.request("GET", "/v1/projects", token, "", 401)
h.request("GET", "/v1/projects", h.login("valid"), "", 200)
}
func (h *harness) request(method, path, token, body string, want int) map[string]any {
h.t.Helper()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
if w.Code != want {
h.t.Fatalf("%s %s: got %d %s, want %d", method, path, w.Code, w.Body.String(), want)
}
result := map[string]any{}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
h.t.Fatal(err)
}
return result
}
func (h *harness) login(code string) string {
h.t.Helper()
return h.request("POST", "/v1/session", "", `{"code":"`+code+`"}`, 200)["token"].(string)
}
func TestFirstLoginSeedsOnceAndSessionsRestoreIdentity(t *testing.T) {
h := setup(t)
token := h.login("alice")
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
if len(projects) != 8 {
t.Fatalf("got %d projects", len(projects))
}
first := projects[0].(map[string]any)
if first["name"] != "零基础" {
t.Fatal(first)
}
h.request("DELETE", "/v1/projects/"+first["id"].(string), token, "", 200)
projects = h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any)
if len(projects) != 7 {
t.Fatal("login must not restore removed presets")
}
h.request("GET", "/v1/projects", "", "", 401)
h.request("GET", "/v1/projects", "invented-session", "", 401)
}
func (h *harness) project(token, name string) string {
h.t.Helper()
body, _ := json.Marshal(map[string]string{"name": name})
return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string)
}
func recordBody(project, date string, minutes int, note, key string) string {
body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key})
return string(body)
}
func (h *harness) summary(token string, minutes, days float64) map[string]any {
h.t.Helper()
review := h.request("GET", "/v1/review?period=week&date=2026-09-28", token, "", 200)
total := review["allTime"].(map[string]any)
if total["totalMinutes"] != minutes || total["practiceDays"] != days {
h.t.Fatalf("unexpected total: %v", total)
}
var projectMinutes float64
for _, raw := range total["projects"].([]any) {
projectMinutes += raw.(map[string]any)["minutes"].(float64)
}
if projectMinutes != minutes {
h.t.Fatalf("project distribution: %v", total)
}
return review
}
func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
h := setup(t)
token := h.login("alice")
class := h.project(token, "晚课")
extra := h.project(token, "课后练习")
h.summary(token, 0, 0)
one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200)
two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200)
h.summary(token, 105, 1)
list := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28", token, "", 200)["records"].([]any)
if len(list) != 2 {
t.Fatal(list)
}
id1, id2 := one["id"].(string), two["id"].(string)
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200)
review := h.summary(token, 105, 2)
if review["period"].(map[string]any)["totalMinutes"] != float64(15) {
t.Fatal("Sunday must belong to the previous week")
}
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-28", 60, "", ""), 200)
h.summary(token, 75, 1)
h.request("DELETE", "/v1/records/"+id2, token, "", 200)
h.summary(token, 60, 1)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.summary(token, 0, 0)
}
func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) {
h := setup(t)
alice, bob := h.login("alice"), h.login("bob")
p := h.project(alice, "旧课名")
active := h.project(alice, "新练习")
r := h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 45, "私人笔记", "archive-000000001"), 200)
id := r["id"].(string)
h.request("GET", "/v1/records/"+id, bob, "", 404)
h.request("PUT", "/v1/records/"+id, bob, recordBody(p, "2026-09-20", 60, "偷改", ""), 404)
h.request("DELETE", "/v1/records/"+id, bob, "", 200)
h.request("PUT", "/v1/projects/"+p, bob, `{"name":"偷改"}`, 404)
h.request("DELETE", "/v1/projects/"+p, bob, "", 404)
h.request("POST", "/v1/records", bob, recordBody(p, "2026-09-20", 60, "", "cross-user-000001"), 404)
h.summary(bob, 0, 0)
h.request("PUT", "/v1/projects/"+p, alice, `{"name":"基础晚课"}`, 200)
removed := h.request("DELETE", "/v1/projects/"+p, alice, "", 200)
if removed["action"] != "archived" {
t.Fatal(removed)
}
record := h.request("GET", "/v1/records/"+id, alice, "", 200)
if record["projectName"] != "基础晚课" || record["note"] != "私人笔记" || record["archived"] != true {
t.Fatal(record)
}
for _, p0 := range h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) {
if p0.(map[string]any)["id"] == p {
t.Fatal("archived project in active choices")
}
}
summary := h.summary(alice, 45, 1)["allTime"].(map[string]any)
if summary["projects"].([]any)[0].(map[string]any)["name"] != "基础晚课" {
t.Fatal(summary)
}
h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 30, "", "archive-000000002"), 409)
h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "仍保留归档项目", ""), 200)
h.request("PUT", "/v1/records/"+id, alice, recordBody(active, "2026-09-21", 30, "改入活跃项目", ""), 200)
h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "不能移回归档项目", ""), 409)
// A fresh handler models a second process/device reading the same persisted account.
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
h.summary(h.login("alice"), 30, 1)
}
func TestSubmissionRetriesAreIdempotentAndCannotResurrectDeletedRecords(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
body := recordBody(p, "2026-09-28", 45, "", "retry-0000000001")
first := h.request("POST", "/v1/records", token, body, 200)
retry := h.request("POST", "/v1/records", h.login("alice"), body, 200)
if first["id"] != retry["id"] {
t.Fatal("response-loss retry duplicated the record")
}
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 46, "", "retry-0000000001"), 409)
h.summary(token, 45, 1)
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 45, "", "retry-0000000002"), 200)
h.summary(token, 90, 1)
h.request("DELETE", "/v1/records/"+first["id"].(string), token, "", 200)
h.request("POST", "/v1/records", token, body, 410)
h.summary(token, 45, 1)
}
func TestInvalidFormsNeverPersistAndDateBoundariesUseShanghai(t *testing.T) {
h := setup(t)
h.now = time.Date(2026, 9, 27, 16, 1, 0, 0, time.UTC) // already Monday in Shanghai
token := h.login("alice")
p := h.project(token, "课程")
for i, entry := range []struct {
date string
minutes int
note string
}{
{"2026-09-28", 0, ""}, {"2026-09-28", -1, ""}, {"2026-09-29", 30, ""},
{"2026-02-30", 30, ""}, {"2026-9-1", 30, ""}, {"0000-01-01", 30, ""},
{"2026-09-28", 30, strings.Repeat("字", 2001)}, {"2026-09-28", 30, "bad\x00note"},
} {
h.request("POST", "/v1/records", token, recordBody(p, entry.date, entry.minutes, entry.note, fmt.Sprintf("invalid-%016d", i)), 400)
}
h.request("POST", "/v1/records", token, `{"projectId":"`+p+`","date":"2026-09-28","minutes":1.5,"requestId":"invalid-decimal1"}`, 400)
h.request("POST", "/v1/records", token, recordBody("", "2026-09-28", 30, "", "missing-00000001"), 400)
h.request("POST", "/v1/projects", token, `{"name":"  \n "}`, 400)
longName, _ := json.Marshal(map[string]string{"name": strings.Repeat("字", 41)})
h.request("POST", "/v1/projects", token, string(longName), 400)
h.summary(token, 0, 0)
h.request("POST", "/v1/records", token, recordBody(p, "2026-08-31", 45, "", "boundary-0000001"), 200)
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-01", 30, "", "boundary-0000002"), 200)
h.summary(token, 75, 2)
aug := h.request("GET", "/v1/review?period=month&date=2026-08-31", token, "", 200)
sep := h.request("GET", "/v1/review?period=month&date=2026-09-28", token, "", 200)
if aug["period"].(map[string]any)["totalMinutes"] != float64(45) || sep["period"].(map[string]any)["totalMinutes"] != float64(30) || len(sep["days"].([]any)) != 30 {
t.Fatal(aug, sep)
}
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 1, "", "boundary-0000003"), 200)
}
func TestRecordPaginationReturnsEveryEntryOnce(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
for i := 0; i < 5; i++ {
h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", i+1, "", fmt.Sprintf("page-%016d", i)), 200)
}
latest := h.request("POST", "/v1/records", token, recordBody(p, "2026-08-01", 45, "补记也应出现在近期记录顶部", "page-backdated-1"), 200)
recent := h.request("GET", "/v1/records?limit=1", token, "", 200)["records"].([]any)
if recent[0].(map[string]any)["id"] != latest["id"] {
t.Fatal("a saved backdated entry must appear in recent records")
}
seen := map[string]bool{}
cursor := ""
for {
page := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28&limit=2&cursor="+cursor, token, "", 200)
for _, raw := range page["records"].([]any) {
id := raw.(map[string]any)["id"].(string)
if seen[id] {
t.Fatal("duplicate entry")
}
seen[id] = true
}
cursor = page["nextCursor"].(string)
if cursor == "" {
break
}
}
if len(seen) != 5 {
t.Fatal(seen)
}
}
func TestConcurrentRetriesAndProjectRemovalKeepHistory(t *testing.T) {
h := setup(t)
token := h.login("alice")
p := h.project(token, "课程")
body := recordBody(p, "2026-09-28", 90, "", "concurrent-00001")
responses := make(chan *httptest.ResponseRecorder, 8)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Go(func() {
r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
responses <- w
})
}
wg.Wait()
close(responses)
for w := range responses {
if w.Code != 200 {
t.Fatalf("concurrent save: %d %s", w.Code, w.Body.String())
}
}
h.summary(token, 90, 1)
// Removing a project and creating a record race for the same user. Whichever
// arrives first, the original history survives and totals remain exact.
var create, remove *httptest.ResponseRecorder
wg.Go(func() {
r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(recordBody(p, "2026-09-28", 15, "", "concurrent-00002")))
r.Header.Set("Authorization", "Bearer "+token)
create = httptest.NewRecorder()
h.h.ServeHTTP(create, r)
})
wg.Go(func() {
r := httptest.NewRequest("DELETE", "/v1/projects/"+p, nil)
r.Header.Set("Authorization", "Bearer "+token)
remove = httptest.NewRecorder()
h.h.ServeHTTP(remove, r)
})
wg.Wait()
if remove.Code != 200 || (create.Code != 200 && create.Code != 409) {
t.Fatal(remove.Code, create.Code)
}
want := float64(90)
if create.Code == 200 {
want = 105
}
h.summary(token, want, 1)
}
func TestDatabaseFailureIsNotAnEmptyHistory(t *testing.T) {
h := setup(t)
token := h.login("alice")
h.pool.Close()
h.request("GET", "/v1/records", token, "", 503)
h.request("GET", "/v1/review?period=week", token, "", 503)
h.request("GET", "/healthz", "", "", 200)
h.request("GET", "/readyz", "", "", 503)
}
+61
View File
@@ -0,0 +1,61 @@
package identity
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"time"
)
var ErrInvalidCode = errors.New("WeChat login code is invalid or expired")
var errUnavailable = errors.New("WeChat identity verification is unavailable")
// WeChat exchanges wx.login codes at the server only. Client is an optional
// network dependency for tests; production uses a bounded HTTPS client.
type WeChat struct {
AppID string
AppSecret string
Client *http.Client
}
// Exchange returns an app-scoped openid after WeChat verifies the code. It never
// returns session_key or wraps URL errors, since exchange URLs contain secrets.
func (w *WeChat) Exchange(ctx context.Context, code string) (string, error) {
if w.AppID == "" || w.AppSecret == "" {
return "", errUnavailable
}
query := url.Values{"appid": {w.AppID}, "secret": {w.AppSecret}, "js_code": {code}, "grant_type": {"authorization_code"}}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+query.Encode(), nil)
if err != nil {
return "", errUnavailable
}
client := w.Client
if client == nil {
client = &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
response, err := client.Do(request)
if err != nil {
return "", errUnavailable
}
defer response.Body.Close()
if response.StatusCode != 200 {
return "", errUnavailable
}
var result struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
if err = json.NewDecoder(io.LimitReader(response.Body, 16*1024)).Decode(&result); err != nil {
return "", errUnavailable
}
if result.ErrCode == 40029 || result.ErrCode == 40163 {
return "", ErrInvalidCode
}
if result.ErrCode != 0 || result.OpenID == "" {
return "", errUnavailable
}
return w.AppID + ":" + result.OpenID, nil
}
+199
View File
@@ -0,0 +1,199 @@
package practice
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
type RecordInput struct {
ProjectID string `json:"projectId"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
RequestID string `json:"requestId"`
}
type Record struct {
updatedAt time.Time
ID string `json:"id"`
ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
Archived bool `json:"archived"`
Date string `json:"date"`
Minutes int `json:"minutes"`
Note string `json:"note"`
}
type RecordList struct {
Records []Record `json:"records"`
NextCursor string `json:"nextCursor"`
}
var requestIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,128}$`)
var objectIDPattern = regexp.MustCompile(`^[A-Z2-7]{26}$`)
func validDate(date string) bool {
t, err := time.Parse(time.DateOnly, date)
return err == nil && t.Year() >= 1 && t.Format(time.DateOnly) == date
}
const recordSelect = `SELECT r.id,r.project_id,p.name,p.archived,r.practice_date::text,r.minutes,r.note,r.updated_at
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id`
func scanRecord(row pgx.Row) (Record, error) {
var r Record
err := row.Scan(&r.ID, &r.ProjectID, &r.ProjectName, &r.Archived, &r.Date, &r.Minutes, &r.Note, &r.updatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrUnavailable
}
return r, err
}
func (s *Store) Record(ctx context.Context, user, id string) (Record, error) {
return scanRecord(s.db.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
}
// Records bounds every response. The updated-at/id cursor permits complete day lists
// without silently truncating them or loading an account's entire history.
// Recent entries include just-saved corrections and backdated practices first.
func (s *Store) Records(ctx context.Context, user, from, to, cursor string, limit int) (RecordList, error) {
if limit < 1 || limit > 100 {
return RecordList{}, ErrInvalid
}
if from == "" {
from = "0001-01-01"
}
if to == "" {
to = s.Today()
}
if !validDate(from) || !validDate(to) || from > to {
return RecordList{}, ErrInvalid
}
cursorTime, cursorID := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), "~"
if cursor != "" {
parts := strings.Split(cursor, "~")
if len(parts) != 2 || !objectIDPattern.MatchString(parts[1]) {
return RecordList{}, ErrInvalid
}
var err error
cursorTime, err = time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
return RecordList{}, ErrInvalid
}
cursorID = parts[1]
}
rows, err := s.db.Query(ctx, recordSelect+` WHERE r.user_id=$1 AND r.practice_date BETWEEN $2::date AND $3::date
AND (r.updated_at,r.id)<($4,$5) ORDER BY r.updated_at DESC,r.id DESC LIMIT $6`, user, from, to, cursorTime, cursorID, limit+1)
if err != nil {
return RecordList{}, err
}
defer rows.Close()
result := RecordList{Records: []Record{}}
for rows.Next() {
r, err := scanRecord(rows)
if err != nil {
return RecordList{}, err
}
result.Records = append(result.Records, r)
}
if err := rows.Err(); err != nil {
return RecordList{}, err
}
if len(result.Records) > limit {
result.Records = result.Records[:limit]
last := result.Records[limit-1]
result.NextCursor = last.updatedAt.UTC().Format(time.RFC3339Nano) + "~" + last.ID
}
return result, nil
}
// SaveRecord commits the record and submission receipt together. Retrying an
// uncertain create keeps its ID; a different payload using the same key is rejected.
func (s *Store) SaveRecord(ctx context.Context, user, id string, input RecordInput) (Record, error) {
if !validDate(input.Date) || input.Date > s.Today() || input.Minutes < 1 || input.Minutes > MinuteLimit ||
input.ProjectID == "" || utf8.RuneCountInString(input.Note) > NoteLimit || strings.ContainsRune(input.Note, 0) {
return Record{}, ErrInvalid
}
if id == "" && !requestIDPattern.MatchString(input.RequestID) {
return Record{}, ErrInvalid
}
var result Record
err := s.write(ctx, user, func(tx pgx.Tx) error {
payload := input
payload.RequestID = ""
encoded, _ := json.Marshal(payload)
hash := digest(string(encoded))
if id == "" {
var savedHash, savedID string
err := tx.QueryRow(ctx, "SELECT payload_hash,record_id FROM submissions WHERE user_id=$1 AND request_id=$2", user, input.RequestID).Scan(&savedHash, &savedID)
if err == nil {
if savedHash != hash {
return ErrConflict
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, savedID))
if errors.Is(err, ErrUnavailable) {
return ErrDeleted
}
return err
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
}
originalProject := ""
if id != "" {
err := tx.QueryRow(ctx, "SELECT project_id FROM records WHERE user_id=$1 AND id=$2", user, id).Scan(&originalProject)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
}
var archived bool
err := tx.QueryRow(ctx, "SELECT archived FROM projects WHERE user_id=$1 AND id=$2", user, input.ProjectID).Scan(&archived)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if archived && originalProject != input.ProjectID {
return ErrArchived
}
if id == "" {
id = newID()
if _, err = tx.Exec(ctx, `INSERT INTO records(id,user_id,project_id,practice_date,minutes,note)
VALUES($1,$2,$3,$4::date,$5,$6)`, id, user, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
if _, err = tx.Exec(ctx, "INSERT INTO submissions(user_id,request_id,payload_hash,record_id) VALUES($1,$2,$3,$4)", user, input.RequestID, hash, id); err != nil {
return err
}
} else {
if _, err = tx.Exec(ctx, `UPDATE records SET project_id=$3,practice_date=$4::date,minutes=$5,note=$6,updated_at=now()
WHERE user_id=$1 AND id=$2`, user, id, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil {
return err
}
}
result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id))
return err
})
return result, err
}
// DeleteRecord is scoped to the session owner and safe to retry, including after
// the last record of a date has gone. Submission receipts deliberately remain.
func (s *Store) DeleteRecord(ctx context.Context, user, id string) error {
return s.write(ctx, user, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, "DELETE FROM records WHERE user_id=$1 AND id=$2", user, id)
return err
})
}
+122
View File
@@ -0,0 +1,122 @@
package practice
import (
"context"
"sort"
"time"
)
type ProjectMinutes struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Archived bool `json:"archived"`
Minutes int64 `json:"minutes"`
}
type Summary struct {
TotalMinutes int64 `json:"totalMinutes"`
PracticeDays int `json:"practiceDays"`
RecordCount int64 `json:"recordCount"`
Projects []ProjectMinutes `json:"projects"`
}
type DayMinutes struct {
Date string `json:"date"`
Minutes int64 `json:"minutes"`
}
type Review struct {
Today string `json:"today"`
From string `json:"from"`
To string `json:"to"`
AllTime Summary `json:"allTime"`
Period Summary `json:"period"`
Days []DayMinutes `json:"days"`
}
type summaryBuilder struct {
summary Summary
dates map[string]bool
projects map[string]ProjectMinutes
}
func newSummary() *summaryBuilder {
return &summaryBuilder{dates: map[string]bool{}, projects: map[string]ProjectMinutes{}}
}
func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) {
b.summary.TotalMinutes += project.Minutes
b.summary.RecordCount += count
b.dates[date] = true
previous := b.projects[project.ProjectID]
project.Minutes += previous.Minutes
b.projects[project.ProjectID] = project
}
func (b *summaryBuilder) result() Summary {
b.summary.PracticeDays = len(b.dates)
b.summary.Projects = []ProjectMinutes{}
for _, p := range b.projects {
b.summary.Projects = append(b.summary.Projects, p)
}
sort.Slice(b.summary.Projects, func(i, j int) bool {
a, c := b.summary.Projects[i], b.summary.Projects[j]
if a.Minutes == c.Minutes {
return a.ProjectID < c.ProjectID
}
return a.Minutes > c.Minutes
})
return b.summary
}
// Review derives totals, daily minutes and project shares from one SQL snapshot.
// Archived projects participate and always use their current name. Monday starts a week.
func (s *Store) Review(ctx context.Context, user, period, date string) (Review, error) {
if date == "" {
date = s.Today()
}
if !validDate(date) || date > s.Today() {
return Review{}, ErrInvalid
}
anchor, _ := time.Parse(time.DateOnly, date)
var start, end time.Time
switch period {
case "week":
start = anchor.AddDate(0, 0, -(int(anchor.Weekday())+6)%7)
end = start.AddDate(0, 0, 6)
case "month":
start = time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, time.UTC)
end = start.AddDate(0, 1, -1)
default:
return Review{}, ErrInvalid
}
result := Review{Today: s.Today(), From: start.Format(time.DateOnly), To: end.Format(time.DateOnly), Days: []DayMinutes{}}
// A single grouped query keeps all-time and selected-period summaries consistent
// during concurrent edits, without storing a second set of cumulative balances.
rows, err := s.db.Query(ctx, `SELECT r.practice_date::text,p.id,p.name,p.archived,sum(r.minutes)::bigint,count(*)
FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id
WHERE r.user_id=$1 GROUP BY r.practice_date,p.id,p.name,p.archived`, user)
if err != nil {
return Review{}, err
}
defer rows.Close()
all, selected := newSummary(), newSummary()
daily := map[string]int64{}
for rows.Next() {
var date string
var p ProjectMinutes
var count int64
if err := rows.Scan(&date, &p.ProjectID, &p.Name, &p.Archived, &p.Minutes, &count); err != nil {
return Review{}, err
}
all.add(date, p, count)
if date >= result.From && date <= result.To {
selected.add(date, p, count)
daily[date] += p.Minutes
}
}
if err := rows.Err(); err != nil {
return Review{}, err
}
result.AllTime = all.result()
result.Period = selected.result()
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
date := d.Format(time.DateOnly)
result.Days = append(result.Days, DayMinutes{Date: date, Minutes: daily[date]})
}
return result, nil
}
+201
View File
@@ -0,0 +1,201 @@
package practice
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const NameLimit = 40
const NoteLimit = 2000
const MinuteLimit = 2147483647 // PostgreSQL integer storage limit; minutes remain exact.
var (
ErrInvalid = errors.New("输入无效,请检查后重试")
ErrUnavailable = errors.New("项目或记录不可用,请刷新后重试")
ErrUnauthorized = errors.New("登录已失效,请重新登录")
ErrArchived = errors.New("该项目已移出常用列表,请选择其他项目")
ErrConflict = errors.New("这次提交已保存了不同内容,请刷新记录后更正")
ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建")
)
var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"}
type Store struct {
db *pgxpool.Pool
now func() time.Time
}
// New keeps all ownership, dates, archival and persistence rules behind business operations.
// now is injectable so business-day boundaries can be verified without changing the host clock.
func New(db *pgxpool.Pool, now func() time.Time) *Store {
if now == nil {
now = time.Now
}
return &Store{db: db, now: now}
}
func newID() string { return rand.Text() }
func digest(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) }
// Today is a calendar date in the fixed first-release business timezone, not the device timezone.
func (s *Store) Today() string {
return s.now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.DateOnly)
}
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Login accepts only a server-verified, app-scoped WeChat identity. User creation,
// presets and the session commit together; deleted presets never reappear on login.
func (s *Store) Login(ctx context.Context, identity string) (Session, error) {
if identity == "" {
return Session{}, ErrUnauthorized
}
tx, err := s.db.Begin(ctx)
if err != nil {
return Session{}, err
}
defer tx.Rollback(ctx)
id := newID()
result, err := tx.Exec(ctx, "INSERT INTO users(id,wechat_identity) VALUES($1,$2) ON CONFLICT(wechat_identity) DO NOTHING", id, identity)
if err != nil {
return Session{}, err
}
if result.RowsAffected() == 1 {
for _, name := range presets {
if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); err != nil {
return Session{}, err
}
}
} else if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE wechat_identity=$1", identity).Scan(&id); err != nil {
return Session{}, err
}
// Serialize session maintenance for this user as well as business writes.
if _, err = tx.Exec(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", id); err != nil {
return Session{}, err
}
if _, err = tx.Exec(ctx, "DELETE FROM sessions WHERE user_id=$1 AND expires_at <= $2", id, s.now()); err != nil {
return Session{}, err
}
session := Session{Token: newID() + newID(), ExpiresAt: s.now().Add(30 * 24 * time.Hour)}
if _, err = tx.Exec(ctx, "INSERT INTO sessions(token_hash,user_id,expires_at) VALUES($1,$2,$3)", digest(session.Token), id, session.ExpiresAt); err != nil {
return Session{}, err
}
if err = tx.Commit(ctx); err != nil {
return Session{}, err
}
return session, nil
}
// Authenticate never accepts a client-provided user ID. Only opaque, unexpired
// bearer tokens are usable; their hashes, not tokens, are kept in PostgreSQL.
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
if len(token) != 52 {
return "", ErrUnauthorized
}
var user string
err := s.db.QueryRow(ctx, "SELECT user_id FROM sessions WHERE token_hash=$1 AND expires_at>$2", digest(token), s.now()).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUnauthorized
}
return user, err
}
// write serializes a user's mutations. Project removal, record writes and
// idempotency receipts therefore see a consistent state even across devices.
func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var id string
if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", user).Scan(&id); err != nil {
return err
}
if err = fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Archived bool `json:"archived"`
HasRecords bool `json:"hasRecords"`
}
func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) {
rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id)
FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) {
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) {
return Project{}, ErrInvalid
}
p := Project{ID: id, Name: name}
err := s.write(ctx, user, func(tx pgx.Tx) error {
if id == "" {
p.ID = newID()
_, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", p.ID, user, name)
return err
}
err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived,
EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&p.Archived, &p.HasRecords)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
return err
})
return p, err
}
func (s *Store) RemoveProject(ctx context.Context, user, id string) (string, error) {
mode := "deleted"
err := s.write(ctx, user, func(tx pgx.Tx) error {
var used bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)
FROM projects WHERE user_id=$1 AND id=$2`, user, id).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
return ErrUnavailable
}
if err != nil {
return err
}
if used {
mode = "archived"
_, err = tx.Exec(ctx, "UPDATE projects SET archived=true WHERE user_id=$1 AND id=$2", user, id)
} else {
_, err = tx.Exec(ctx, "DELETE FROM projects WHERE user_id=$1 AND id=$2", user, id)
}
return err
})
return mode, err
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
backend_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$backend_dir"
# A supplied test database must permit isolated CREATE SCHEMA. Tests never use
# PG* application settings or touch existing tables; every test owns its schema.
if [ -n "${TEST_DATABASE_URL:-}" ]; then
REQUIRE_TEST_DATABASE=1 go test -race ./...
exit
fi
# Disposable memory-backed database, loopback only, no existing Compose volumes.
test_container=$(docker run --detach --rm --publish 127.0.0.1::5432 \
--env POSTGRES_USER=ballet_test --env POSTGRES_PASSWORD=local-isolated-test \
--env POSTGRES_DB=ballet_test --tmpfs /var/lib/postgresql postgres:18-alpine)
trap 'docker stop "$test_container" >/dev/null' EXIT HUP INT TERM
attempt=0
until docker exec "$test_container" pg_isready -U ballet_test -d ballet_test >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then printf '%s\n' 'Test PostgreSQL did not become ready.' >&2; exit 1; fi
sleep 1
done
test_port=$(docker port "$test_container" 5432 | sed 's/.*://')
TEST_DATABASE_URL="postgres://ballet_test:local-isolated-test@127.0.0.1:$test_port/ballet_test?sslmode=disable" \
REQUIRE_TEST_DATABASE=1 go test -race ./...