From 13c8bcbf3cdac2d89ce0d67043eb1c5dd3531902 Mon Sep 17 00:00:00 2001 From: leon_mac Date: Sun, 18 Oct 2026 15:11:44 +0800 Subject: [PATCH] =?UTF-8?q?chore(=E6=9C=8D=E5=8A=A1=E7=AB=AF):=20=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E5=B7=A5=E7=A8=8B=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + .env.example | 12 + .gitignore | 8 + Dockerfile | 12 + cmd/server/main.go | 40 +++ go.mod | 50 ++++ go.sum | 111 ++++++++ internal/domain/domain.go | 126 +++++++++ internal/domain/domain_test.go | 51 ++++ internal/server/demo.go | 43 +++ internal/server/migrations/001_initial.sql | 1 + internal/server/patterns.go | 246 +++++++++++++++++ internal/server/print.go | 104 +++++++ internal/server/regression_test.go | 190 +++++++++++++ internal/server/requests.go | 181 ++++++++++++ internal/server/server.go | 305 +++++++++++++++++++++ internal/server/server_test.go | 96 +++++++ internal/server/static.go | 59 ++++ internal/server/storage.go | 88 ++++++ internal/server/store.go | 177 ++++++++++++ internal/server/websocket.go | 266 ++++++++++++++++++ internal/server/websocket_requests_test.go | 196 +++++++++++++ internal/server/websocket_test.go | 179 ++++++++++++ 23 files changed, 2543 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/domain/domain.go create mode 100644 internal/domain/domain_test.go create mode 100644 internal/server/demo.go create mode 100644 internal/server/migrations/001_initial.sql create mode 100644 internal/server/patterns.go create mode 100644 internal/server/print.go create mode 100644 internal/server/regression_test.go create mode 100644 internal/server/requests.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/static.go create mode 100644 internal/server/storage.go create mode 100644 internal/server/store.go create mode 100644 internal/server/websocket.go create mode 100644 internal/server/websocket_requests_test.go create mode 100644 internal/server/websocket_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7be0e15 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.env* +server diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b53d5c --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +APP_ENV=development +LISTEN_ADDR=:8080 +DATABASE_URL=postgres://perler:replace-this-password@localhost:5432/perler?sslmode=disable +REDIS_URL=redis://localhost:6379/0 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-at-least-12-characters +WECHAT_APP_ID= +WECHAT_APP_SECRET= +CORS_ORIGINS=http://localhost:5173,http://localhost:5174 +SEED_DEMO=true +# Optional: serve built webui and SPA routes directly from backend +WEBUI_DIR= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd83cec --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.env +.env.local +.env.*.local +!.env.example +*.log +coverage/ +/server diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b757f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /server ./cmd/server +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app +USER app +COPY --from=build /server /server +EXPOSE 8080 +ENTRYPOINT ["/server"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..76413f1 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "perler-beads-server/internal/server" + "syscall" + "time" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + s, e := server.New(ctx) + cancel() + if e != nil { + log.Fatal(e) + } + defer s.DB.Close() + defer s.Redis.Close() + addr := os.Getenv("LISTEN_ADDR") + if addr == "" { + addr = ":8080" + } + h := &http.Server{Addr: addr, Handler: s.Router(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + go func() { + if e := h.ListenAndServe(); e != nil && e != http.ErrServerClosed { + log.Fatal(e) + } + }() + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + s.CloseWebSockets() + h.Shutdown(ctx) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eab5ee1 --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module perler-beads-server + +go 1.25 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.7.6 + github.com/redis/go-redis/v9 v9.12.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..746d4ae --- /dev/null +++ b/go.sum @@ -0,0 +1,111 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..eaf7d6a --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,126 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +type Color struct { + Code string `json:"code"` + Name string `json:"name"` + Hex string `json:"hex"` + Location string `json:"location"` + Available bool `json:"available"` +} +type Pattern struct { + ID string `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Width int `json:"width"` + Height int `json:"height"` + Cells []*string `json:"cells"` + Colors []Color `json:"colors"` + BeadCount int `json:"beadCount"` + OwnerID string `json:"ownerId,omitempty"` + Visibility string `json:"visibility"` + CreatedAt string `json:"createdAt"` + StoreID string `json:"storeId"` + Demo bool `json:"demo"` +} +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + StoreID string `json:"storeId"` +} +type Store struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Hours string `json:"hours"` + Notice string `json:"notice"` + LocalBaseURL string `json:"localBaseUrl"` + PublicBaseURL string `json:"publicBaseUrl"` +} +type Table struct { + Code string `json:"code"` + Label string `json:"label"` + Enabled bool `json:"enabled"` +} +type Visit struct { + ID string `json:"id"` + StoreID string `json:"storeId"` + TableCode string `json:"tableCode"` + Status string `json:"status"` +} +type Request struct { + ID string `json:"id"` + StoreID string `json:"storeId"` + UserID string `json:"userId"` + TableCode string `json:"tableCode"` + Kind string `json:"kind"` + Note string `json:"note"` + Status string `json:"status"` + PatternID string `json:"patternId,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func ValidateColors(cs []Color) error { + seen := map[string]bool{} + if len(cs) == 0 || len(cs) > 512 { + return fmt.Errorf("色板需包含 1–512 色") + } + for _, c := range cs { + if strings.TrimSpace(c.Code) == "" || len(c.Code) > 32 || seen[c.Code] || !hexColor.MatchString(c.Hex) { + return fmt.Errorf("色号重复或颜色格式错误") + } + seen[c.Code] = true + } + return nil +} +func (p *Pattern) Validate() error { + if strings.TrimSpace(p.Title) == "" || len(p.Title) > 200 || p.Width < 1 || p.Height < 1 || p.Width > 200 || p.Height > 200 || len(p.Cells) != p.Width*p.Height { + return fmt.Errorf("图纸名称或尺寸无效(最大 200×200)") + } + if p.Visibility != "public" && p.Visibility != "private" { + return fmt.Errorf("可见性无效") + } + if err := ValidateColors(p.Colors); err != nil { + return err + } + cs := map[string]bool{} + for _, c := range p.Colors { + cs[c.Code] = true + } + p.BeadCount = 0 + for _, c := range p.Cells { + if c != nil { + if !cs[*c] { + return fmt.Errorf("网格含未知色号") + } + p.BeadCount++ + } + } + return nil +} +func Transition(role, old, next string, own bool) bool { + if role == "admin" || role == "staff" { + return old == "pending" && next == "accepted" || old == "accepted" && next == "completed" + } + return own && old == "pending" && next == "cancelled" +} +func Fingerprint(kind, note, pattern string) string { + b, _ := json.Marshal([]string{kind, note, pattern}) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} +func CanRead(p Pattern, u User) bool { + return p.Visibility == "public" || p.OwnerID == u.ID && u.ID != "" || (u.Role == "admin" || u.Role == "staff") && p.StoreID == u.StoreID +} diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..ca7b964 --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,51 @@ +package domain + +import "testing" + +func TestPatternValidation(t *testing.T) { + p := Pattern{Title: "demo", Width: 2, Height: 1, Cells: []*string{str("R"), nil}, Colors: []Color{{Code: "R", Hex: "#ff0000"}}, Visibility: "private"} + if err := p.Validate(); err != nil { + t.Fatal(err) + } + p.Cells[0] = str("missing") + if p.Validate() == nil { + t.Fatal("unknown color accepted") + } + p.Cells = []*string{} + if p.Validate() == nil { + t.Fatal("wrong grid size accepted") + } +} +func str(s string) *string { return &s } +func TestTransitionAuthorization(t *testing.T) { + cases := []struct { + role, old, next string + own bool + ok bool + }{{"guest", "pending", "cancelled", true, true}, {"guest", "accepted", "cancelled", true, false}, {"guest", "pending", "cancelled", false, false}, {"staff", "pending", "completed", false, false}, {"staff", "pending", "accepted", false, true}, {"admin", "accepted", "completed", false, true}} + for _, c := range cases { + if Transition(c.role, c.old, c.next, c.own) != c.ok { + t.Fatalf("%+v", c) + } + } +} +func TestIdempotencyContent(t *testing.T) { + if Fingerprint("help", "one", "") == Fingerprint("help", "two", "") { + t.Fatal("different contents collide") + } + if Fingerprint("help", "one", "") != Fingerprint("help", "one", "") { + t.Fatal("unstable fingerprint") + } +} +func TestPrivateIsolation(t *testing.T) { + p := Pattern{OwnerID: "owner", Visibility: "private", StoreID: "s1"} + if CanRead(p, User{ID: "stranger", Role: "guest", StoreID: "s1"}) { + t.Fatal("private pattern leaked") + } + if CanRead(p, User{ID: "admin", Role: "admin", StoreID: "s2"}) { + t.Fatal("cross-store admin read") + } + if !CanRead(p, User{ID: "owner"}) { + t.Fatal("owner denied") + } +} diff --git a/internal/server/demo.go b/internal/server/demo.go new file mode 100644 index 0000000..60d4765 --- /dev/null +++ b/internal/server/demo.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + "perler-beads-server/internal/domain" + "strings" +) + +func demoColors() []domain.Color { + defs := [][3]string{{"DEMO-R", "演示红", "#E85555"}, {"DEMO-Y", "演示黄", "#FFD166"}, {"DEMO-B", "演示蓝", "#4A90D9"}, {"DEMO-G", "演示绿", "#4DAA7A"}, {"DEMO-W", "演示白", "#FFFFFF"}, {"DEMO-K", "演示黑", "#25282D"}, {"DEMO-O", "演示橙", "#F59F45"}, {"DEMO-P", "演示紫", "#9370DB"}, {"DEMO-C", "演示青", "#63C8CE"}, {"DEMO-S", "演示粉", "#F2A7BC"}} + out := []domain.Color{} + for i, d := range defs { + out = append(out, domain.Color{Code: d[0], Name: d[1], Hex: d[2], Location: fmt.Sprintf("A%d", i+1), Available: true}) + } + return out +} +func demoPatterns(store string, colors []domain.Color) []domain.Pattern { + defs := []struct{ title, rows string }{{"原创像素心(演示)", ".RR.RR./RRRRRRR/RRRRRRR/.RRRRR./..RRR../...R.../......."}, {"原创像素菱形(演示)", "...Y.../..YYY../.YYOYY./YYOOOYY/.YYOYY./..YYY../...Y..."}, {"原创像素小花(演示)", "..SSS../.SSYSS./..SSS../...G.../...G.../..GGG../...G..."}, {"原创像素星(演示)", "...B.../...B.../BBBBBBB/.BCCCB./..BBB../.BB.BB./B.....B"}} + lookup := map[rune]string{} + for _, c := range colors { + lookup[[]rune(c.Code)[len(c.Code)-1]] = c.Code + } + out := []domain.Pattern{} + for k, d := range defs { + cells := []*string{} + for _, row := range strings.Split(d.rows, "/") { + for _, v := range row { + if v == '.' { + cells = append(cells, nil) + } else { + code := lookup[v] + cells = append(cells, &code) + } + } + } + p := domain.Pattern{ID: fmt.Sprintf("demo-%d", k+1), Title: d.title, Category: "原创演示", Width: 7, Height: 7, Cells: cells, Colors: colors, Visibility: "public", StoreID: store, CreatedAt: "2026-10-03T00:00:00Z", Demo: true} + if e := p.Validate(); e != nil { + panic(e) + } + out = append(out, p) + } + return out +} diff --git a/internal/server/migrations/001_initial.sql b/internal/server/migrations/001_initial.sql new file mode 100644 index 0000000..dae6a1c --- /dev/null +++ b/internal/server/migrations/001_initial.sql @@ -0,0 +1 @@ +CREATE TABLE IF NOT EXISTS documents(kind text NOT NULL,id text NOT NULL,store_id text NOT NULL DEFAULT '',owner_id text NOT NULL DEFAULT '',body jsonb NOT NULL,PRIMARY KEY(kind,id)); CREATE TABLE IF NOT EXISTS request_keys(user_id text NOT NULL,request_id uuid NOT NULL,fingerprint text NOT NULL,request_body jsonb NOT NULL,PRIMARY KEY(user_id,request_id)); CREATE TABLE IF NOT EXISTS visits(user_id text PRIMARY KEY,body jsonb NOT NULL); CREATE TABLE IF NOT EXISTS favorites(user_id text NOT NULL,pattern_id text NOT NULL,PRIMARY KEY(user_id,pattern_id)); CREATE INDEX IF NOT EXISTS documents_store ON documents(kind,store_id); diff --git a/internal/server/patterns.go b/internal/server/patterns.go new file mode 100644 index 0000000..a9b83d9 --- /dev/null +++ b/internal/server/patterns.go @@ -0,0 +1,246 @@ +package server + +import ( + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "perler-beads-server/internal/domain" + "strings" + "time" +) + +func (s *Server) patternList(c *gin.Context) { + admin := strings.HasPrefix(c.FullPath(), "/api/admin/") + mine := c.FullPath() == "/api/my/patterns" + if admin && !need(c, "admin") || mine && !need(c, "") { + return + } + raw, e := s.list(c, "pattern") + if e != nil { + fail(c, e) + return + } + out := []domain.Pattern{} + u := user(c) + for _, b := range raw { + var p domain.Pattern + if json.Unmarshal(b, &p) != nil { + continue + } + if mine { + if p.OwnerID != u.ID { + continue + } + } else if admin { + if p.StoreID != u.StoreID { + continue + } + } else { + if p.Visibility != "public" || p.StoreID != storeID(c) { + continue + } + } + if q := strings.ToLower(c.Query("q")); q != "" && !strings.Contains(strings.ToLower(p.Title+" "+p.Category), q) { + continue + } + out = append(out, p) + } + ok(c, out) +} +func (s *Server) readPattern(c *gin.Context) (domain.Pattern, bool) { + var p domain.Pattern + if e := s.get(c, "pattern", c.Param("id"), &p); e != nil { + reply(c, 404, "图纸不存在", nil) + return p, false + } + if !domain.CanRead(p, user(c)) { + if user(c).ID == "" { + reply(c, 403, "请先登录", nil) + } else { + reply(c, 401, "无权访问图纸", nil) + } + return p, false + } + return p, true +} +func (s *Server) patternGet(c *gin.Context) { + if p, yes := s.readPattern(c); yes { + ok(c, p) + } +} +func (s *Server) patternSave(c *gin.Context) { + admin := strings.HasPrefix(c.FullPath(), "/api/admin/") + role := "" + if admin { + role = "admin" + } + if !need(c, role) { + return + } + var p domain.Pattern + if !bind(c, &p) { + return + } + u := user(c) + if p.StoreID == "" { + p.StoreID = u.StoreID + } + if p.StoreID != u.StoreID { + reply(c, 401, "无权写入其他门店", nil) + return + } + var store domain.Store + if s.get(c, "store", p.StoreID, &store) != nil { + fail(c, errors.New("门店不存在")) + return + } + if !admin { + p.Visibility = "private" + } + if e := p.Validate(); e != nil { + fail(c, e) + return + } + // 持久图纸只能引用当前门店已维护的色号,禁止客户端伪造物料位置。 + var palette []domain.Color + if s.get(c, "palette", p.StoreID, &palette) != nil { + fail(c, errors.New("门店色板不存在")) + return + } + authoritative := map[string]domain.Color{} + for _, color := range palette { + authoritative[color.Code] = color + } + for i, color := range p.Colors { + saved, exists := authoritative[color.Code] + if !exists || !strings.EqualFold(saved.Hex, color.Hex) { + fail(c, errors.New("图纸色号与门店色板不符")) + return + } + p.Colors[i] = saved + } + p.ID = uuid.NewString() + p.OwnerID = u.ID + p.CreatedAt = time.Now().UTC().Format(time.RFC3339) + p.Demo = false + if e := s.put(c, "pattern", p.ID, p.StoreID, p.OwnerID, p); e != nil { + fail(c, e) + return + } + ok(c, p) +} +func (s *Server) patternDelete(c *gin.Context) { + if !need(c, "") { + return + } + p, yes := s.readPattern(c) + if !yes { + return + } + if p.OwnerID != user(c).ID { + reply(c, 401, "只能删除自己的图纸", nil) + return + } + if _, e := s.DB.Exec(c, `DELETE FROM documents WHERE kind='pattern' AND id=$1 AND owner_id=$2`, p.ID, user(c).ID); e != nil { + fail(c, e) + return + } + ok(c, nil) +} +func (s *Server) patternPatch(c *gin.Context) { + if !need(c, "admin") { + return + } + p, yes := s.readPattern(c) + if !yes { + return + } + if p.StoreID != user(c).StoreID { + reply(c, 401, "门店无权限", nil) + return + } + var in struct { + Title *string `json:"title"` + Category *string `json:"category"` + Visibility *string `json:"visibility"` + } + if !bind(c, &in) { + return + } + if in.Title != nil { + p.Title = *in.Title + } + if in.Category != nil { + p.Category = *in.Category + } + if in.Visibility != nil { + p.Visibility = *in.Visibility + } + if e := p.Validate(); e != nil { + fail(c, e) + return + } + if e := s.put(c, "pattern", p.ID, p.StoreID, p.OwnerID, p); e != nil { + fail(c, e) + return + } + ok(c, p) +} +func (s *Server) favoriteList(c *gin.Context) { + if !need(c, "") { + return + } + rows, e := s.DB.Query(c, `SELECT d.body FROM favorites f JOIN documents d ON d.kind='pattern' AND d.id=f.pattern_id WHERE f.user_id=$1 ORDER BY f.pattern_id`, user(c).ID) + if e != nil { + fail(c, e) + return + } + defer rows.Close() + out := []string{} + for rows.Next() { + var b []byte + if e = rows.Scan(&b); e != nil { + fail(c, e) + return + } + var p domain.Pattern + if e = json.Unmarshal(b, &p); e != nil { + fail(c, e) + return + } + if domain.CanRead(p, user(c)) { + out = append(out, p.ID) + } + } + if e = rows.Err(); e != nil { + fail(c, e) + return + } + ok(c, out) +} +func (s *Server) favoritePut(c *gin.Context) { + if !need(c, "") { + return + } + if _, yes := s.readPattern(c); !yes { + return + } + var in struct { + Active bool `json:"active"` + } + if !bind(c, &in) { + return + } + var e error + if in.Active { + _, e = s.DB.Exec(c, `INSERT INTO favorites VALUES($1,$2) ON CONFLICT DO NOTHING`, user(c).ID, c.Param("id")) + } else { + _, e = s.DB.Exec(c, `DELETE FROM favorites WHERE user_id=$1 AND pattern_id=$2`, user(c).ID, c.Param("id")) + } + if e != nil { + fail(c, e) + return + } + ok(c, nil) +} diff --git a/internal/server/print.go b/internal/server/print.go new file mode 100644 index 0000000..2f97ba8 --- /dev/null +++ b/internal/server/print.go @@ -0,0 +1,104 @@ +package server + +import ( + "fmt" + "github.com/gin-gonic/gin" + "html" + "io" + "math" + "os" + "perler-beads-server/internal/domain" + "sort" + "strconv" + "time" +) + +func wrapText(text string, limit int) []string { + if limit < 1 { + limit = 1 + } + r := []rune(text) + out := []string{} + for len(r) > limit { + out = append(out, string(r[:limit])) + r = r[limit:] + } + return append(out, string(r)) +} +func renderSVG(w io.Writer, p domain.Pattern, pitch float64) error { + width := math.Max(100, float64(p.Width)*pitch+30) + titleLines := wrapText(p.Title, int((width-20)/4)) + gridTop := 20 + float64(len(titleLines)-1)*5 + height := gridTop + float64(p.Height)*pitch + 70 + float64(len(p.Colors))*5 + fmt.Fprintf(w, ``, width, height, width, height) + for i, line := range titleLines { + fmt.Fprintf(w, `%s`, 8+float64(i)*5, html.EscapeString(line)) + } + fmt.Fprintf(w, `%d × %d / %d beads`, gridTop-7, p.Width, p.Height, p.BeadCount) + colors := map[string]string{} + counts := map[string]int{} + for _, c := range p.Colors { + colors[c.Code] = c.Hex + } + for i, v := range p.Cells { + x := float64(i%p.Width)*pitch + 15 + y := float64(i/p.Width)*pitch + gridTop + fill := "#fff" + label := "" + if v != nil { + fill = colors[*v] + label = *v + counts[*v]++ + } + fmt.Fprintf(w, `%s`, x, y, pitch, pitch, fill, x+pitch/2, y+pitch*.6, math.Min(math.Min(pitch*.2, 1.5), pitch*.85/math.Max(1, float64(len([]rune(label)))*.65)), html.EscapeString(label)) + } + for x := 0; x < p.Width; x++ { + fmt.Fprintf(w, `%d`, 15+float64(x)*pitch, gridTop-1, x+1) + } + for y := 0; y < p.Height; y++ { + fmt.Fprintf(w, `%d`, gridTop+float64(y)*pitch+pitch/2, y+1) + } + baseline := gridTop + float64(p.Height)*pitch + 8 + fmt.Fprintf(w, `50 mm calibration`, baseline, baseline+7) + keys := []string{} + for code := range counts { + keys = append(keys, code) + } + sort.Strings(keys) + fmt.Fprintf(w, `Print at 100%%. Disable fit-to-page.`, baseline+13) + for i, code := range keys { + fmt.Fprintf(w, `%s: %d`, baseline+20+float64(i)*5, html.EscapeString(code), counts[code]) + } + if width > 210 || height > 297 { + fmt.Fprintf(w, `Oversize: use poster/tile mode.Keep 100%% scale and add overlap.`, height-13, height-8) + } + _, e := fmt.Fprint(w, "") + return e +} +func (s *Server) print(c *gin.Context) { + p, yes := s.readPattern(c) + if !yes { + return + } + pitch, e := strconv.ParseFloat(c.DefaultQuery("pitchMm", "5"), 64) + if e != nil || math.IsNaN(pitch) || math.IsInf(pitch, 0) || pitch < 2 || pitch > 10 { + reply(c, 400, "pitchMm 需为 2–10", nil) + return + } + f, e := os.CreateTemp("", "perler-print-*.svg") + if e != nil { + fail(c, e) + return + } + path := f.Name() + defer func() { time.AfterFunc(time.Minute, func() { os.Remove(path) }) }() + if e = renderSVG(f, p, pitch); e != nil { + f.Close() + fail(c, e) + return + } + f.Close() + c.Header("Content-Disposition", `attachment; filename="perler-pattern.svg"`) + c.Header("Content-Type", "image/svg+xml") + c.File(path) +} diff --git a/internal/server/regression_test.go b/internal/server/regression_test.go new file mode 100644 index 0000000..c334b9f --- /dev/null +++ b/internal/server/regression_test.go @@ -0,0 +1,190 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "io" + "net/http/httptest" + "os" + "perler-beads-server/internal/domain" + "strconv" + "strings" + "testing" + "time" +) + +func TestRequestCancellationFallback(t *testing.T) { + r := (&Server{}).Router() + r.GET("/cancel-probe", func(c *gin.Context) { + if c.Err() != context.Canceled { + t.Error("Gin must propagate request cancellation") + } + if _, yes := c.Deadline(); !yes { + t.Error("request operation deadline missing") + } + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/cancel-probe", nil).WithContext(ctx)) +} +func TestPrintGeometryBounds(t *testing.T) { + for _, pitch := range []float64{2, 5, 10} { + code := "R" + p := domain.Pattern{Title: strings.Repeat("标题", 45), Width: 1, Height: 1, Cells: []*string{&code}, Colors: []domain.Color{{Code: code, Hex: "#ff0000"}}} + var b bytes.Buffer + renderSVG(&b, p, pitch) + d := xml.NewDecoder(&b) + width, height := 0.0, 0.0 + gridFound := false + for { + token, e := d.Token() + if e == io.EOF { + break + } + if e != nil { + t.Fatal(e) + } + el, yes := token.(xml.StartElement) + if !yes { + continue + } + attrs := map[string]string{} + for _, a := range el.Attr { + attrs[a.Name.Local] = a.Value + } + if el.Name.Local == "svg" { + width, _ = strconv.ParseFloat(strings.TrimSuffix(attrs["width"], "mm"), 64) + height, _ = strconv.ParseFloat(strings.TrimSuffix(attrs["height"], "mm"), 64) + if width < 75 { + t.Fatal("ruler exceeds page") + } + } + if el.Name.Local == "text" { + x, _ := strconv.ParseFloat(attrs["x"], 64) + y, _ := strconv.ParseFloat(attrs["y"], 64) + if x >= width || y >= height { + t.Fatalf("text outside page: %+v", attrs) + } + } + if el.Name.Local == "rect" && attrs["stroke"] == "#666" { + w, _ := strconv.ParseFloat(attrs["width"], 64) + h, _ := strconv.ParseFloat(attrs["height"], 64) + if w != pitch || h != pitch { + t.Fatal("actual bead pitch changed") + } + gridFound = true + } + } + if !gridFound { + t.Fatal("no grid") + } + } +} +func TestSingleConnectionHandlers(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL absent; PostgreSQL handler regression not run") + } + cfg, e := pgxpool.ParseConfig(dsn) + if e != nil { + t.Fatal(e) + } + if !strings.Contains(cfg.ConnConfig.Database, "test") { + t.Fatal("requires independent test database") + } + cfg.MaxConns = 1 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + db, e := pgxpool.NewWithConfig(ctx, cfg) + if e != nil { + t.Fatal(e) + } + defer db.Close() + s := &Server{DB: db} + if e = Migrate(ctx, db); e != nil { + t.Fatal(e) + } + id := uuid.NewString() + store := "regression-" + id + u := domain.User{ID: id, Role: "guest", StoreID: store} + p := demoPatterns(store, demoColors())[0] + p.ID = "pattern-" + id + p.OwnerID = id + p.Visibility = "private" + if e = s.put(ctx, "pattern", p.ID, store, id, p); e != nil { + t.Fatal(e) + } + if e = s.put(ctx, "tables", store, store, "", []domain.Table{{Code: "T1", Label: "Test", Enabled: true}}); e != nil { + t.Fatal(e) + } + v, _ := json.Marshal(domain.Visit{ID: id, StoreID: store, TableCode: "T1", Status: "active"}) + if _, e = db.Exec(ctx, `INSERT INTO visits VALUES($1,$2)`, id, v); e != nil { + t.Fatal(e) + } + if _, e = db.Exec(ctx, `INSERT INTO favorites VALUES($1,$2)`, id, p.ID); e != nil { + t.Fatal(e) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + db.Exec(cleanup, `DELETE FROM favorites WHERE user_id=$1`, id) + db.Exec(cleanup, `DELETE FROM visits WHERE user_id=$1`, id) + db.Exec(cleanup, `DELETE FROM request_keys WHERE user_id=$1`, id) + db.Exec(cleanup, `DELETE FROM documents WHERE store_id=$1`, store) + }() + r := gin.New() + r.ContextWithFallback = true + r.Use(func(c *gin.Context) { c.Set("user", u); c.Next() }) + r.GET("/favorites", s.favoriteList) + r.POST("/requests", s.requestCreate) + body := `{"kind":"print","patternId":"` + p.ID + `","requestId":"` + uuid.NewString() + `"}` + for _, call := range []struct{ method, path, body string }{{"GET", "/favorites", ""}, {"POST", "/requests", body}, {"POST", "/requests", body}} { + w := httptest.NewRecorder() + request := httptest.NewRequest(call.method, call.path, strings.NewReader(call.body)).WithContext(ctx) + request.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, request) + var result struct{ Code int } + if json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Code != 200 { + t.Fatalf("%s %s", call.path, w.Body) + } + if ctx.Err() != nil { + t.Fatal("single pool connection handler exceeded deadline") + } + } +} +func TestWebUIStaticAndSPAFallback(t *testing.T) { + dir := t.TempDir() + if e := os.WriteFile(dir+"/index.html", []byte("SPA"), 0600); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(dir+"/app.js", []byte("console.log('static')"), 0600); e != nil { + t.Fatal(e) + } + outside := t.TempDir() + os.WriteFile(outside+"/secret.txt", []byte("SECRET"), 0600) + if e := os.Symlink(outside+"/secret.txt", dir+"/leak.txt"); e != nil { + t.Fatal(e) + } + r := (&Server{WebUIDir: dir}).Router() + for _, item := range []struct{ path, want string }{{"/patterns/editor", "SPA"}, {"/app.js", "static"}, {"/api/unknown", `"code":404`}, {"/api", `"code":404`}, {"/leak.txt", `"code":404`}, {"/../secret.txt", `"code":404`}} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", item.path, nil)) + if !strings.Contains(w.Body.String(), item.want) || strings.Contains(w.Body.String(), "SECRET") { + t.Fatalf("%s: %s", item.path, w.Body) + } + } +} +func TestRecoveryUsesEnvelope(t *testing.T) { + r := (&Server{}).Router() + r.GET("/panic-probe", func(c *gin.Context) { panic("test panic") }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/panic-probe", nil)) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":500`) { + t.Fatalf("%d %s", w.Code, w.Body) + } +} diff --git a/internal/server/requests.go b/internal/server/requests.go new file mode 100644 index 0000000..0d17453 --- /dev/null +++ b/internal/server/requests.go @@ -0,0 +1,181 @@ +package server + +import ( + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "perler-beads-server/internal/domain" + "time" +) + +func (s *Server) requestList(c *gin.Context) { + if !need(c, "") { + return + } + raw, e := s.list(c, "request") + if e != nil { + fail(c, e) + return + } + out := []domain.Request{} + u := user(c) + for _, b := range raw { + var v domain.Request + if json.Unmarshal(b, &v) != nil { + continue + } + if u.Role == "admin" || u.Role == "staff" { + if v.StoreID != u.StoreID { + continue + } + } else if v.UserID != u.ID { + continue + } + out = append(out, v) + } + ok(c, out) +} +func (s *Server) requestCreate(c *gin.Context) { + if !need(c, "") { + return + } + var in struct { + Kind string `json:"kind"` + Note string `json:"note"` + PatternID string `json:"patternId"` + RequestID string `json:"requestId"` + } + if !bind(c, &in) { + return + } + if _, e := uuid.Parse(in.RequestID); e != nil { + fail(c, errors.New("requestId 必须为 UUID")) + return + } + if in.Kind != "help" && in.Kind != "refill" && in.Kind != "ironing" && in.Kind != "print" || len(in.Note) > 2000 { + fail(c, errors.New("服务类型或备注无效")) + return + } + u := user(c) + fingerprint := domain.Fingerprint(in.Kind, in.Note, in.PatternID) + tx, e := s.DB.Begin(c) + if e != nil { + fail(c, e) + return + } + defer rollback(tx) // Serialize same user's keys and visit changes while checking persistent idempotency. + if _, e = tx.Exec(c, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, u.ID); e != nil { + fail(c, e) + return + } + var existing []byte + var oldHash string + e = tx.QueryRow(c, `SELECT fingerprint,request_body FROM request_keys WHERE user_id=$1 AND request_id=$2`, u.ID, in.RequestID).Scan(&oldHash, &existing) + if e == nil { + if oldHash != fingerprint { + reply(c, 409, "重复请求标识对应不同内容", nil) + return + } + ok(c, json.RawMessage(existing)) + return + } + var visitBytes []byte + if e = tx.QueryRow(c, `SELECT body FROM visits WHERE user_id=$1 FOR UPDATE`, u.ID).Scan(&visitBytes); e != nil { + fail(c, errors.New("请先扫码到店")) + return + } + var v domain.Visit + if json.Unmarshal(visitBytes, &v) != nil || v.Status != "active" { + fail(c, errors.New("请先扫码到店")) + return + } + var tables []domain.Table + if getTx(c, tx, "tables", v.StoreID, &tables) != nil { + fail(c, errors.New("门店桌位不可用")) + return + } + enabled := false + for _, t := range tables { + if t.Code == v.TableCode && t.Enabled { + enabled = true + } + } + if !enabled { + fail(c, errors.New("桌位已停用,请重新扫码")) + return + } + if in.Kind == "print" && in.PatternID == "" { + fail(c, errors.New("打印需要图纸")) + return + } + if in.PatternID != "" { + var p domain.Pattern + if getTx(c, tx, "pattern", in.PatternID, &p) != nil || !domain.CanRead(p, u) || p.StoreID != v.StoreID { + reply(c, 401, "图纸无权限或不属于当前门店", nil) + return + } + } + now := time.Now().UTC().Format(time.RFC3339Nano) + r := domain.Request{ID: uuid.NewString(), StoreID: v.StoreID, UserID: u.ID, TableCode: v.TableCode, Kind: in.Kind, Note: in.Note, PatternID: in.PatternID, Status: "pending", CreatedAt: now, UpdatedAt: now} + b, _ := json.Marshal(r) + if _, e = tx.Exec(c, `INSERT INTO documents(kind,id,store_id,owner_id,body) VALUES('request',$1,$2,$3,$4)`, r.ID, r.StoreID, r.UserID, b); e == nil { + _, e = tx.Exec(c, `INSERT INTO request_keys VALUES($1,$2,$3,$4)`, u.ID, in.RequestID, fingerprint, b) + } + if e != nil { + fail(c, e) + return + } + if e = tx.Commit(c); e != nil { + fail(c, e) + return + } + s.notifyTasks(r.StoreID) + ok(c, r) +} +func (s *Server) requestPatch(c *gin.Context) { + if !need(c, "") { + return + } + var in struct { + Status string `json:"status"` + } + if !bind(c, &in) { + return + } + tx, e := s.DB.Begin(c) + if e != nil { + fail(c, e) + return + } + defer rollback(tx) + var b []byte + if e = tx.QueryRow(c, `SELECT body FROM documents WHERE kind='request' AND id=$1 FOR UPDATE`, c.Param("id")).Scan(&b); e != nil { + reply(c, 404, "任务不存在", nil) + return + } + var r domain.Request + if json.Unmarshal(b, &r) != nil { + fail(c, errors.New("任务数据无效")) + return + } + u := user(c) + staff := u.Role == "admin" || u.Role == "staff" + if staff && r.StoreID != u.StoreID || !domain.Transition(u.Role, r.Status, in.Status, r.UserID == u.ID) { + reply(c, 401, "无权进行此状态变更", nil) + return + } + r.Status = in.Status + r.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + b, _ = json.Marshal(r) + if _, e = tx.Exec(c, `UPDATE documents SET body=$1 WHERE kind='request' AND id=$2`, b, r.ID); e != nil { + fail(c, e) + return + } + if e = tx.Commit(c); e != nil { + fail(c, e) + return + } + s.notifyTasks(r.StoreID) + ok(c, r) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e7a03e0 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,305 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "net/http" + "net/url" + "os" + "perler-beads-server/internal/domain" + "strings" + "sync" + "time" +) + +type Server struct { + DB *pgxpool.Pool + Redis *redis.Client + Env string + AdminUsername string + AdminPassword string + WeChatID string + WeChatSecret string + Origins []string + WebUIDir string + wsHub *taskHub + wsOnce sync.Once +} + +func reply(c *gin.Context, code int, msg string, v any) { + c.JSON(200, gin.H{"code": code, "msg": msg, "data": v}) +} +func ok(c *gin.Context, v any) { reply(c, 200, "ok", v) } +func fail(c *gin.Context, e error) { reply(c, 400, e.Error(), nil) } +func bind(c *gin.Context, v any) bool { + if e := c.ShouldBindJSON(v); e != nil { + fail(c, errors.New("请求格式无效")) + return false + } + return true +} +func user(c *gin.Context) domain.User { v, _ := c.Get("user"); u, _ := v.(domain.User); return u } +func need(c *gin.Context, role string) bool { + u := user(c) + if u.ID == "" { + reply(c, 403, "请先登录", nil) + return false + } + if role == "admin" && u.Role != "admin" || role == "staff" && u.Role != "admin" && u.Role != "staff" { + reply(c, 401, "无权限", nil) + return false + } + return true +} +func (s *Server) Router() *gin.Engine { + s.wsOnce.Do(func() { s.wsHub = newTaskHub() }) + r := gin.New() + r.ContextWithFallback = true + r.SetTrustedProxies(nil) + r.Use(gin.CustomRecovery(func(c *gin.Context, recovered any) { reply(c, 500, "服务暂不可用", nil); c.Abort() }), func(c *gin.Context) { + if !(c.Request.URL.Path == "/api/ws" && websocket.IsWebSocketUpgrade(c.Request)) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + c.Request = c.Request.WithContext(ctx) + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<20) + origin := c.GetHeader("Origin") + for _, allowed := range s.Origins { + if origin == allowed { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Headers", "Authorization,Content-Type") + c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + } + } + if c.Request.Method == "OPTIONS" { + c.Status(204) + c.Abort() + return + } + token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if token != "" { + h := sha256.Sum256([]byte(token)) + b, e := s.Redis.Get(c.Request.Context(), "session:"+hex.EncodeToString(h[:])).Bytes() + if e == nil { + var u domain.User + if json.Unmarshal(b, &u) == nil { + c.Set("user", u) + } + } + } + c.Next() + }) + a := r.Group("/api") + a.GET("/ws", s.websocket) + a.GET("/health", func(c *gin.Context) { + ok(c, gin.H{"service": "perler-beads", "storeId": "store-001", "version": "0.1.0"}) + }) + a.GET("/capabilities", func(c *gin.Context) { + ok(c, gin.H{"payment": gin.H{"enabled": false}, "storedValue": gin.H{"enabled": false}}) + }) + a.POST("/auth/login", s.login) + a.POST("/auth/admin", s.adminLogin) + a.GET("/me", func(c *gin.Context) { + if need(c, "") { + ok(c, user(c)) + } + }) + a.GET("/stores", func(c *gin.Context) { + v, e := s.list(c, "store") + if e != nil { + fail(c, e) + return + } + ok(c, v) + }) + a.GET("/palettes", func(c *gin.Context) { + var v []domain.Color + if e := s.get(c, "palette", storeID(c), &v); e != nil { + fail(c, errors.New("门店色板不存在")) + return + } + ok(c, v) + }) + a.GET("/patterns", s.patternList) + a.GET("/my/patterns", s.patternList) + a.GET("/patterns/:id", s.patternGet) + a.GET("/patterns/:id/print", s.print) + a.POST("/patterns", s.patternSave) + a.DELETE("/patterns/:id", s.patternDelete) + a.GET("/favorites", s.favoriteList) + a.PUT("/favorites/:id", s.favoritePut) + a.POST("/visits", s.visitCreate) + a.GET("/visits/current", s.visitGet) + a.DELETE("/visits/current", s.visitClose) + a.GET("/tables", s.tableList) + a.GET("/service-requests", s.requestList) + a.POST("/service-requests", s.requestCreate) + a.PATCH("/service-requests/:id", s.requestPatch) + a.GET("/admin/patterns", s.patternList) + a.POST("/admin/patterns", s.patternSave) + a.PATCH("/admin/patterns/:id", s.patternPatch) + a.PUT("/admin/palette", s.palettePut) + a.PUT("/admin/store", s.storePut) + a.PUT("/admin/tables", s.tablePut) + r.NoRoute(s.staticFallback) + return r +} +func storeID(c *gin.Context) string { + id := c.Query("storeId") + if id == "" { + id = "store-001" + } + return id +} +func (s *Server) rate(c *gin.Context) bool { + key := "login-limit:" + c.ClientIP() + n, e := s.Redis.Incr(c, key).Result() + if e != nil { + reply(c, 503, "登录服务暂不可用", nil) + return false + } + if n == 1 { + s.Redis.Expire(c, key, time.Minute) + } + if n > 20 { + reply(c, 429, "登录过于频繁", nil) + return false + } + return true +} +func (s *Server) session(c *gin.Context, u domain.User) { + b := make([]byte, 32) + if _, e := rand.Read(b); e != nil { + fail(c, e) + return + } + token := hex.EncodeToString(b) + h := sha256.Sum256([]byte(token)) + payload, _ := json.Marshal(u) + if e := s.Redis.Set(c, "session:"+hex.EncodeToString(h[:]), payload, 7*24*time.Hour).Err(); e != nil { + reply(c, 503, "登录服务暂不可用", nil) + return + } + ok(c, gin.H{"token": token, "user": u}) +} +func (s *Server) adminLogin(c *gin.Context) { + if !s.rate(c) { + return + } + var in struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !bind(c, &in) { + return + } + a := sha256.Sum256([]byte(in.Password)) + b := sha256.Sum256([]byte(s.AdminPassword)) + if s.AdminPassword == "" || in.Username != s.AdminUsername || subtle.ConstantTimeCompare(a[:], b[:]) != 1 { + reply(c, 403, "账号或密码错误", nil) + return + } + s.session(c, domain.User{ID: "admin-001", Name: "管理员", Role: "admin", StoreID: "store-001"}) +} +func (s *Server) login(c *gin.Context) { + if !s.rate(c) { + return + } + var in struct { + Code string `json:"code"` + } + if !bind(c, &in) { + return + } + openid := "" + if in.Code == "dev-guest" { + if s.Env != "development" { + reply(c, 403, "生产环境禁止开发登录", nil) + return + } + openid = "development-guest" + } else { + if in.Code == "" || s.WeChatID == "" || s.WeChatSecret == "" { + reply(c, 403, "微信登录尚未配置", nil) + return + } + endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + url.Values{"appid": {s.WeChatID}, "secret": {s.WeChatSecret}, "js_code": {in.Code}, "grant_type": {"authorization_code"}}.Encode() + req, e := http.NewRequestWithContext(c, http.MethodGet, endpoint, nil) + if e != nil { + fail(c, e) + return + } + resp, e := (&http.Client{Timeout: 8 * time.Second}).Do(req) + if e != nil { + reply(c, 503, "微信登录暂不可用", nil) + return + } + defer resp.Body.Close() + var data struct { + OpenID string `json:"openid"` + ErrCode int `json:"errcode"` + } + if e = json.NewDecoder(resp.Body).Decode(&data); e != nil || resp.StatusCode != 200 || data.ErrCode != 0 || data.OpenID == "" { + reply(c, 403, "微信登录凭证无效", nil) + return + } + openid = data.OpenID + } + h := sha256.Sum256([]byte("wechat:" + openid)) + u := domain.User{ID: "user-" + hex.EncodeToString(h[:16]), Name: "拼豆顾客", Role: "guest", StoreID: "store-001"} + if e := s.put(c, "user", u.ID, u.StoreID, u.ID, u); e != nil { + reply(c, 503, "用户持久化失败", nil) + return + } + s.session(c, u) +} +func New(ctx context.Context) (*Server, error) { + db, e := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) + if e != nil { + return nil, e + } + if e = db.Ping(ctx); e != nil { + return nil, e + } + opt, e := redis.ParseURL(os.Getenv("REDIS_URL")) + if e != nil { + return nil, e + } + r := redis.NewClient(opt) + if e = r.Ping(ctx).Err(); e != nil { + return nil, e + } + s := &Server{DB: db, Redis: r, Env: os.Getenv("APP_ENV"), AdminUsername: os.Getenv("ADMIN_USERNAME"), AdminPassword: os.Getenv("ADMIN_PASSWORD"), WeChatID: os.Getenv("WECHAT_APP_ID"), WeChatSecret: os.Getenv("WECHAT_APP_SECRET"), Origins: strings.Split(os.Getenv("CORS_ORIGINS"), ",")} + s.WebUIDir = os.Getenv("WEBUI_DIR") + if s.WebUIDir != "" { + if e = validateWebUIDir(s.WebUIDir); e != nil { + return nil, e + } + } + if s.AdminUsername == "" || len(s.AdminPassword) < 12 { + return nil, fmt.Errorf("admin credentials required (password at least 12 characters)") + } + if s.Env != "development" && (s.WeChatID == "" || s.WeChatSecret == "") { + return nil, errors.New("production requires WeChat credentials") + } + if e = Migrate(ctx, db); e != nil { + return nil, e + } + if os.Getenv("SEED_DEMO") == "true" { + if e = s.Seed(ctx); e != nil { + return nil, e + } + } + return s, nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..ca2bfec --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,96 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "github.com/jackc/pgx/v5/pgxpool" + "net/http/httptest" + "os" + "perler-beads-server/internal/domain" + "strings" + "testing" +) + +func TestAnonymousPermissions(t *testing.T) { + r := (&Server{}).Router() + for _, path := range []string{"/api/me", "/api/my/patterns", "/api/favorites", "/api/tables", "/api/service-requests", "/api/admin/patterns"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + var v struct{ Code int } + if json.Unmarshal(w.Body.Bytes(), &v) != nil || w.Code != 200 || v.Code != 403 { + t.Fatalf("%s: %s", path, w.Body) + } + } +} +func TestPrintMillimetersEscapes(t *testing.T) { + code := "R" + p := domain.Pattern{Title: "