chore(服务端): 初始化工程仓库
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.env*
|
||||||
|
server
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
/server
|
||||||
+12
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2fmm" height="%.2fmm" viewBox="0 0 %.2f %.2f"><rect width="100%%" height="100%%" fill="white"/><g font-family="sans-serif" fill="#222">`, width, height, width, height)
|
||||||
|
for i, line := range titleLines {
|
||||||
|
fmt.Fprintf(w, `<text x="10" y="%.2f" font-size="4">%s</text>`, 8+float64(i)*5, html.EscapeString(line))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<text x="10" y="%.2f" font-size="2">%d × %d / %d beads</text>`, 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, `<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="#666" stroke-width="0.15"/><text x="%.2f" y="%.2f" font-size="%.2f" text-anchor="middle" fill="black" stroke="white" stroke-width="0.06" paint-order="stroke">%s</text>`, 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, `<text x="%.2f" y="%.2f" font-size="1.7">%d</text>`, 15+float64(x)*pitch, gridTop-1, x+1)
|
||||||
|
}
|
||||||
|
for y := 0; y < p.Height; y++ {
|
||||||
|
fmt.Fprintf(w, `<text x="8" y="%.2f" font-size="1.7">%d</text>`, gridTop+float64(y)*pitch+pitch/2, y+1)
|
||||||
|
}
|
||||||
|
baseline := gridTop + float64(p.Height)*pitch + 8
|
||||||
|
fmt.Fprintf(w, `<path d="M15 %.2f h50 m-50 -2 v4 m50 -4 v4" stroke="black" fill="none"/><text x="15" y="%.2f" font-size="3">50 mm calibration</text>`, baseline, baseline+7)
|
||||||
|
keys := []string{}
|
||||||
|
for code := range counts {
|
||||||
|
keys = append(keys, code)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
fmt.Fprintf(w, `<text x="15" y="%.2f" font-size="3">Print at 100%%. Disable fit-to-page.</text>`, baseline+13)
|
||||||
|
for i, code := range keys {
|
||||||
|
fmt.Fprintf(w, `<text x="15" y="%.2f" font-size="3">%s: %d</text>`, baseline+20+float64(i)*5, html.EscapeString(code), counts[code])
|
||||||
|
}
|
||||||
|
if width > 210 || height > 297 {
|
||||||
|
fmt.Fprintf(w, `<text x="15" y="%.2f" font-size="3">Oversize: use poster/tile mode.</text><text x="15" y="%.2f" font-size="3">Keep 100%% scale and add overlap.</text>`, height-13, height-8)
|
||||||
|
}
|
||||||
|
_, e := fmt.Fprint(w, "</g></svg>")
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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("<html>SPA</html>"), 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: "<script>", Width: 1, Height: 1, Cells: []*string{&code}, Colors: []domain.Color{{Code: code, Hex: "#ff0000"}}, BeadCount: 1}
|
||||||
|
var b bytes.Buffer
|
||||||
|
if e := renderSVG(&b, p, 5); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
svg := b.String()
|
||||||
|
for _, want := range []string{`width="100.00mm"`, `height="100.00mm"`, "<script>", "50 mm calibration", "R: 1"} {
|
||||||
|
if !strings.Contains(svg, want) {
|
||||||
|
t.Fatalf("missing %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(svg, "<script>") {
|
||||||
|
t.Fatal("unescaped title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPostgresPersistentIdempotency(t *testing.T) {
|
||||||
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL absent; dedicated PostgreSQL integration database required")
|
||||||
|
}
|
||||||
|
cfg, e := pgxpool.ParseConfig(dsn)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg.ConnConfig.Database, "test") {
|
||||||
|
t.Fatal("test database name must contain test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
db, e := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if e = Migrate(ctx, db); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
tx, e := db.Begin(ctx)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
id := "integration-" + t.Name()
|
||||||
|
_, e = tx.Exec(ctx, `INSERT INTO request_keys VALUES($1,'9e7aec78-f5a0-4f00-81fa-7606a8d9f7c0',$2,'{}')`, id, domain.Fingerprint("help", "a", ""))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
_, e = tx.Exec(ctx, `INSERT INTO request_keys VALUES($1,'9e7aec78-f5a0-4f00-81fa-7606a8d9f7c0',$2,'{}')`, id, domain.Fingerprint("help", "b", ""))
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("persistent unique key allowed duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestDemoFixtures(t *testing.T) {
|
||||||
|
cs := demoColors()
|
||||||
|
if len(cs) < 8 {
|
||||||
|
t.Fatal("too few demo colors")
|
||||||
|
}
|
||||||
|
ps := demoPatterns("store-001", cs)
|
||||||
|
if len(ps) != 4 {
|
||||||
|
t.Fatal("need four motifs")
|
||||||
|
}
|
||||||
|
for _, p := range ps {
|
||||||
|
if e := p.Validate(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if !p.Demo || p.BeadCount == 0 {
|
||||||
|
t.Fatal("demo must be marked and nonempty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateWebUIDir(dir string) error {
|
||||||
|
info, e := os.Stat(filepath.Join(dir, "index.html"))
|
||||||
|
if e != nil || info.IsDir() {
|
||||||
|
return fmt.Errorf("WEBUI_DIR must contain index.html")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func within(root, path string) bool {
|
||||||
|
rel, e := filepath.Rel(root, path)
|
||||||
|
return e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
func (s *Server) staticFallback(c *gin.Context) {
|
||||||
|
if c.Request.URL.Path == "/api" || strings.HasPrefix(c.Request.URL.Path, "/api/") || s.WebUIDir == "" || c.Request.Method != "GET" && c.Request.Method != "HEAD" {
|
||||||
|
reply(c, 404, "接口不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, e := filepath.EvalSymlinks(s.WebUIDir)
|
||||||
|
if e != nil {
|
||||||
|
reply(c, 404, "页面不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, e = filepath.Abs(root)
|
||||||
|
if e != nil {
|
||||||
|
reply(c, 404, "页面不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(c.Request.URL.Path, "/")))
|
||||||
|
if !within(root, candidate) {
|
||||||
|
reply(c, 404, "页面不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resolved, e := filepath.EvalSymlinks(candidate); e == nil {
|
||||||
|
if !within(root, resolved) {
|
||||||
|
reply(c, 404, "页面不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, e := os.Stat(resolved); e == nil && !info.IsDir() {
|
||||||
|
c.File(resolved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index, e := filepath.EvalSymlinks(filepath.Join(root, "index.html"))
|
||||||
|
if e != nil || !within(root, index) {
|
||||||
|
reply(c, 404, "页面不存在", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.File(index)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"perler-beads-server/internal/domain"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/001_initial.sql
|
||||||
|
var migration string
|
||||||
|
|
||||||
|
func (s *Server) put(ctx context.Context, kind, id, store, owner string, v any) error {
|
||||||
|
b, e := json.Marshal(v)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
_, e = s.DB.Exec(ctx, `INSERT INTO documents(kind,id,store_id,owner_id,body) VALUES($1,$2,$3,$4,$5) ON CONFLICT(kind,id) DO UPDATE SET body=EXCLUDED.body,store_id=EXCLUDED.store_id,owner_id=EXCLUDED.owner_id`, kind, id, store, owner, b)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (s *Server) get(ctx context.Context, kind, id string, v any) error {
|
||||||
|
var b []byte
|
||||||
|
e := s.DB.QueryRow(ctx, `SELECT body FROM documents WHERE kind=$1 AND id=$2`, kind, id).Scan(&b)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
func getTx(ctx context.Context, tx pgx.Tx, kind, id string, v any) error {
|
||||||
|
var b []byte
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT body FROM documents WHERE kind=$1 AND id=$2`, kind, id).Scan(&b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
func rollback(tx pgx.Tx) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
func (s *Server) list(ctx context.Context, kind string) ([]json.RawMessage, error) {
|
||||||
|
rows, e := s.DB.Query(ctx, `SELECT body FROM documents WHERE kind=$1 ORDER BY id`, kind)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []json.RawMessage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b []byte
|
||||||
|
if e = rows.Scan(&b); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
func Migrate(ctx context.Context, db *pgxpool.Pool) error { _, e := db.Exec(ctx, migration); return e }
|
||||||
|
func (s *Server) Seed(ctx context.Context) error {
|
||||||
|
var n int
|
||||||
|
if e := s.DB.QueryRow(ctx, `SELECT count(*) FROM documents WHERE kind='store'`).Scan(&n); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
store := domain.Store{ID: "store-001", Name: "拼豆体验店(演示)", Address: "请在后台配置真实地址", Hours: "10:00–20:00(演示)", Notice: "演示色板非品牌真实色号"}
|
||||||
|
cs := demoColors()
|
||||||
|
|
||||||
|
if e := s.put(ctx, "store", store.ID, store.ID, "", store); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := s.put(ctx, "palette", store.ID, store.ID, "", cs); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := s.put(ctx, "tables", store.ID, store.ID, "", []domain.Table{{Code: "T01", Label: "1 号桌(演示)", Enabled: true}, {Code: "T02", Label: "2 号桌(演示)", Enabled: true}}); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, p := range demoPatterns(store.ID, cs) {
|
||||||
|
if e := s.put(ctx, "pattern", p.ID, p.StoreID, "", p); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"net/url"
|
||||||
|
"perler-beads-server/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) visitCreate(c *gin.Context) {
|
||||||
|
if !need(c, "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
StoreID string `json:"storeId"`
|
||||||
|
TableCode string `json:"tableCode"`
|
||||||
|
}
|
||||||
|
if !bind(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tables []domain.Table
|
||||||
|
if s.get(c, "tables", in.StoreID, &tables) != nil {
|
||||||
|
fail(c, errors.New("门店不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := false
|
||||||
|
for _, t := range tables {
|
||||||
|
if t.Code == in.TableCode && t.Enabled {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
fail(c, errors.New("桌码未启用"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := domain.Visit{ID: uuid.NewString(), StoreID: in.StoreID, TableCode: in.TableCode, Status: "active"}
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
if _, e := s.DB.Exec(c, `INSERT INTO visits(user_id,body) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET body=EXCLUDED.body`, user(c).ID, b); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, v)
|
||||||
|
}
|
||||||
|
func (s *Server) visit(c *gin.Context) (domain.Visit, error) {
|
||||||
|
var b []byte
|
||||||
|
var v domain.Visit
|
||||||
|
e := s.DB.QueryRow(c, `SELECT body FROM visits WHERE user_id=$1`, user(c).ID).Scan(&b)
|
||||||
|
if e == nil {
|
||||||
|
e = json.Unmarshal(b, &v)
|
||||||
|
}
|
||||||
|
return v, e
|
||||||
|
}
|
||||||
|
func (s *Server) visitGet(c *gin.Context) {
|
||||||
|
if !need(c, "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.visit(c)
|
||||||
|
if e != nil || v.Status != "active" {
|
||||||
|
ok(c, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, v)
|
||||||
|
}
|
||||||
|
func (s *Server) visitClose(c *gin.Context) {
|
||||||
|
if !need(c, "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, e := s.DB.Exec(c, `DELETE FROM visits WHERE user_id=$1`, user(c).ID); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, nil)
|
||||||
|
}
|
||||||
|
func (s *Server) tableList(c *gin.Context) {
|
||||||
|
if !need(c, "staff") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if storeID(c) != user(c).StoreID {
|
||||||
|
reply(c, 401, "门店无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v []domain.Table
|
||||||
|
if e := s.get(c, "tables", storeID(c), &v); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, v)
|
||||||
|
}
|
||||||
|
func (s *Server) palettePut(c *gin.Context) {
|
||||||
|
if !need(c, "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
StoreID string `json:"storeId"`
|
||||||
|
Colors []domain.Color `json:"colors"`
|
||||||
|
}
|
||||||
|
if !bind(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.StoreID != user(c).StoreID {
|
||||||
|
reply(c, 401, "门店无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := domain.ValidateColors(in.Colors); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := s.put(c, "palette", in.StoreID, in.StoreID, "", in.Colors); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, in.Colors)
|
||||||
|
}
|
||||||
|
func (s *Server) tablePut(c *gin.Context) {
|
||||||
|
if !need(c, "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
StoreID string `json:"storeId"`
|
||||||
|
Tables []domain.Table `json:"tables"`
|
||||||
|
}
|
||||||
|
if !bind(c, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.StoreID != user(c).StoreID {
|
||||||
|
reply(c, 401, "门店无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
if len(in.Tables) > 200 {
|
||||||
|
fail(c, errors.New("桌位超过限制"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, t := range in.Tables {
|
||||||
|
if t.Code == "" || len(t.Code) > 64 || seen[t.Code] || t.Label == "" {
|
||||||
|
fail(c, errors.New("桌码或名称无效/重复"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[t.Code] = true
|
||||||
|
}
|
||||||
|
if e := s.put(c, "tables", in.StoreID, in.StoreID, "", in.Tables); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, in.Tables)
|
||||||
|
}
|
||||||
|
func endpointValid(v string) bool {
|
||||||
|
if v == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, e := url.Parse(v)
|
||||||
|
return e == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && u.RawQuery == "" && u.Fragment == ""
|
||||||
|
}
|
||||||
|
func (s *Server) storePut(c *gin.Context) {
|
||||||
|
if !need(c, "admin") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v domain.Store
|
||||||
|
if !bind(c, &v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.ID != user(c).StoreID {
|
||||||
|
reply(c, 401, "门店无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Name == "" || !endpointValid(v.LocalBaseURL) || !endpointValid(v.PublicBaseURL) {
|
||||||
|
fail(c, errors.New("门店名称或 HTTPS 端点无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := s.put(c, "store", v.ID, v.ID, "", v); e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"perler-beads-server/internal/domain"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsAuthTimeout = 5 * time.Second
|
||||||
|
wsHeartbeat = 20 * time.Second
|
||||||
|
wsReadTimeout = 45 * time.Second
|
||||||
|
wsWriteTimeout = 5 * time.Second
|
||||||
|
wsRedisTimeout = 3 * time.Second
|
||||||
|
wsMessageLimit = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
type wsClose struct {
|
||||||
|
code int
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
type taskSocket struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
user domain.User
|
||||||
|
token string
|
||||||
|
changed chan struct{}
|
||||||
|
pong chan string
|
||||||
|
stop chan wsClose
|
||||||
|
}
|
||||||
|
type taskHub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
stores map[string]map[*taskSocket]struct{}
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskHub() *taskHub { return &taskHub{stores: map[string]map[*taskSocket]struct{}{}} }
|
||||||
|
func (h *taskHub) add(client *taskSocket) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.closed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h.stores[client.user.StoreID] == nil {
|
||||||
|
h.stores[client.user.StoreID] = map[*taskSocket]struct{}{}
|
||||||
|
}
|
||||||
|
h.stores[client.user.StoreID][client] = struct{}{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func (h *taskHub) remove(client *taskSocket) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
store := h.stores[client.user.StoreID]
|
||||||
|
delete(store, client)
|
||||||
|
if len(store) == 0 {
|
||||||
|
delete(h.stores, client.user.StoreID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity one merges invalidations; slow browsers never block a committed business request.
|
||||||
|
func (h *taskHub) notify(storeID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for client := range h.stores[storeID] {
|
||||||
|
select {
|
||||||
|
case client.changed <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (h *taskHub) shutdown() {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.closed = true
|
||||||
|
for _, store := range h.stores {
|
||||||
|
for client := range store {
|
||||||
|
client.close(1001, "server shutting down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (c *taskSocket) close(code int, reason string) {
|
||||||
|
select {
|
||||||
|
case c.stop <- wsClose{code, reason}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) notifyTasks(storeID string) {
|
||||||
|
if s.wsHub != nil {
|
||||||
|
s.wsHub.notify(storeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) CloseWebSockets() {
|
||||||
|
if s.wsHub != nil {
|
||||||
|
s.wsHub.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) wsOrigin(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" || len(r.Header.Values("Origin")) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, e := url.Parse(origin)
|
||||||
|
if e != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.EqualFold(u.Host, r.Host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, allowed := range s.Origins {
|
||||||
|
if allowed != "" && origin == allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func (s *Server) wsUser(token string) (domain.User, error) {
|
||||||
|
var u domain.User
|
||||||
|
if token == "" || len(token) > 256 || s.Redis == nil {
|
||||||
|
return u, errors.New("invalid session")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), wsRedisTimeout)
|
||||||
|
defer cancel()
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
b, e := s.Redis.Get(ctx, "session:"+hex.EncodeToString(h[:])).Bytes()
|
||||||
|
if e != nil {
|
||||||
|
return u, e
|
||||||
|
}
|
||||||
|
if e = json.Unmarshal(b, &u); e != nil {
|
||||||
|
return u, e
|
||||||
|
}
|
||||||
|
if u.ID == "" || u.StoreID == "" || (u.Role != "admin" && u.Role != "staff") {
|
||||||
|
return u, errors.New("staff session required")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
func (s *Server) websocket(c *gin.Context) {
|
||||||
|
if !s.wsOrigin(c.Request) {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Request.URL.RawQuery != "" {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upgrader := websocket.Upgrader{HandshakeTimeout: wsAuthTimeout, ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: s.wsOrigin}
|
||||||
|
conn, e := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetReadLimit(wsMessageLimit)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(wsAuthTimeout))
|
||||||
|
conn.SetCloseHandler(func(code int, text string) error { return &websocket.CloseError{Code: code, Text: text} })
|
||||||
|
var auth struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
kind, payload, e := conn.ReadMessage()
|
||||||
|
if e != nil {
|
||||||
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "authentication required"), time.Now().Add(wsWriteTimeout))
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind != websocket.TextMessage || json.Unmarshal(payload, &auth) != nil || auth.Type != "auth" {
|
||||||
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "authentication required"), time.Now().Add(wsWriteTimeout))
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, e := s.wsUser(auth.Token)
|
||||||
|
if e != nil {
|
||||||
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(4403, "invalid staff session"), time.Now().Add(wsWriteTimeout))
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &taskSocket{conn: conn, user: u, token: auth.Token, changed: make(chan struct{}, 1), pong: make(chan string, 4), stop: make(chan wsClose, 1)}
|
||||||
|
if !s.wsHub.add(client) {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer s.wsHub.remove(client)
|
||||||
|
readerDone := make(chan struct{})
|
||||||
|
writerDone := make(chan struct{})
|
||||||
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
||||||
|
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) })
|
||||||
|
conn.SetPingHandler(func(data string) error {
|
||||||
|
select {
|
||||||
|
case client.pong <- data:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return errors.New("too many pings")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
go func() { defer close(writerDone); s.writeSocket(client, readerDone) }()
|
||||||
|
defer func() { close(readerDone); <-writerDone }()
|
||||||
|
for {
|
||||||
|
kind, payload, e = conn.ReadMessage()
|
||||||
|
if e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if kind != websocket.TextMessage || json.Unmarshal(payload, &message) != nil || message.Type != "pong" {
|
||||||
|
client.close(4400, "unexpected message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (s *Server) writeSocket(client *taskSocket, readerDone <-chan struct{}) {
|
||||||
|
defer client.conn.Close()
|
||||||
|
write := func(kind string) error {
|
||||||
|
client.conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
|
||||||
|
return client.conn.WriteJSON(map[string]string{"type": kind})
|
||||||
|
}
|
||||||
|
closeFrame := func(reason wsClose) {
|
||||||
|
client.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(reason.code, reason.reason), time.Now().Add(wsWriteTimeout))
|
||||||
|
}
|
||||||
|
if e := write("ready"); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(wsHeartbeat)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case reason := <-client.stop:
|
||||||
|
closeFrame(reason)
|
||||||
|
return
|
||||||
|
case <-readerDone:
|
||||||
|
select {
|
||||||
|
case reason := <-client.stop:
|
||||||
|
closeFrame(reason)
|
||||||
|
default:
|
||||||
|
closeFrame(wsClose{1000, "connection closed"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case data := <-client.pong:
|
||||||
|
if e := client.conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(wsWriteTimeout)); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-client.changed:
|
||||||
|
if e := write("tasks.changed"); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
u, e := s.wsUser(client.token)
|
||||||
|
if e != nil || u.ID != client.user.ID || u.Role != client.user.Role || u.StoreID != client.user.StoreID {
|
||||||
|
closeFrame(wsClose{4403, "session expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := client.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(wsWriteTimeout)); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := write("heartbeat"); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"perler-beads-server/internal/domain"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func wsEvents(c *websocket.Conn) <-chan map[string]any {
|
||||||
|
events := make(chan map[string]any, 8)
|
||||||
|
go func() {
|
||||||
|
defer close(events)
|
||||||
|
for {
|
||||||
|
var v map[string]any
|
||||||
|
if e := c.ReadJSON(&v); e != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events <- v
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
func expectChanged(t *testing.T, events <-chan map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case event, open := <-events:
|
||||||
|
if !open || event["type"] != "tasks.changed" || len(event) != 1 {
|
||||||
|
t.Fatalf("expected private-free invalidation, got %+v", event)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("committed task did not push notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func expectQuiet(t *testing.T, events <-chan map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case event := <-events:
|
||||||
|
t.Fatalf("unexpected notification: %+v", event)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketCommittedTasksStoreIsolationAndReplay(t *testing.T) {
|
||||||
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL absent; actual committed task push test skipped")
|
||||||
|
}
|
||||||
|
cfg, e := pgxpool.ParseConfig(dsn)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg.ConnConfig.Database, "test") {
|
||||||
|
t.Fatal("requires isolated test database")
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 1
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
db, e := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if e = Migrate(ctx, db); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
s.DB = db
|
||||||
|
store := "ws-" + uuid.NewString()
|
||||||
|
other := store + "-other"
|
||||||
|
guest := wsToken(t, s, "guest", store)
|
||||||
|
admin := wsToken(t, s, "admin", store)
|
||||||
|
worker := wsConnect(t, h, h.URL)
|
||||||
|
wsAuth(t, worker, admin)
|
||||||
|
remote := wsConnect(t, h, h.URL)
|
||||||
|
wsAuth(t, remote, wsToken(t, s, "staff", other))
|
||||||
|
events, remoteEvents := wsEvents(worker), wsEvents(remote)
|
||||||
|
if e = s.put(ctx, "tables", store, store, "", []domain.Table{{Code: "T1", Label: "Test", Enabled: true}}); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer stop()
|
||||||
|
var ids []string
|
||||||
|
rows, e := db.Query(cleanup, `SELECT DISTINCT owner_id FROM documents WHERE kind='request' AND store_id=$1`, store)
|
||||||
|
if e == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
rows.Scan(&id)
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
db.Exec(cleanup, `DELETE FROM request_keys WHERE user_id=$1`, id)
|
||||||
|
db.Exec(cleanup, `DELETE FROM visits WHERE user_id=$1`, id)
|
||||||
|
}
|
||||||
|
db.Exec(cleanup, `DELETE FROM documents WHERE store_id=$1`, store)
|
||||||
|
}()
|
||||||
|
call := func(method, path, token, body string) (int, json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
request, e := http.NewRequestWithContext(ctx, method, h.URL+path, strings.NewReader(body))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
response, e := http.DefaultClient.Do(request)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
var result struct {
|
||||||
|
Code int
|
||||||
|
Data json.RawMessage
|
||||||
|
}
|
||||||
|
if e = json.NewDecoder(response.Body).Decode(&result); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
return result.Code, result.Data
|
||||||
|
}
|
||||||
|
if code, _ := call("POST", "/api/visits", guest, `{"storeId":"`+store+`","tableCode":"T1"}`); code != 200 {
|
||||||
|
t.Fatalf("visit failed%d", code)
|
||||||
|
}
|
||||||
|
requestID := uuid.NewString()
|
||||||
|
body := `{"kind":"help","note":"private note","requestId":"` + requestID + `"}`
|
||||||
|
code, data := call("POST", "/api/service-requests", guest, body)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("create failed%d %s", code, data)
|
||||||
|
}
|
||||||
|
var task domain.Request
|
||||||
|
json.Unmarshal(data, &task)
|
||||||
|
expectChanged(t, events)
|
||||||
|
expectQuiet(t, remoteEvents)
|
||||||
|
var stored domain.Request
|
||||||
|
if e = s.get(ctx, "request", task.ID, &stored); e != nil || stored.ID != task.ID {
|
||||||
|
t.Fatal("push before durable commit")
|
||||||
|
}
|
||||||
|
if code, replay := call("POST", "/api/service-requests", guest, body); code != 200 || string(replay) != string(data) { // JSON object key order can change in PostgreSQL; compare ID instead.
|
||||||
|
var repeated domain.Request
|
||||||
|
json.Unmarshal(replay, &repeated)
|
||||||
|
if code != 200 || repeated.ID != task.ID {
|
||||||
|
t.Fatalf("bad replay%d %s", code, replay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectQuiet(t, events)
|
||||||
|
conflict := strings.Replace(body, "private note", "different note", 1)
|
||||||
|
if code, _ := call("POST", "/api/service-requests", guest, conflict); code != 409 {
|
||||||
|
t.Fatalf("conflict%d", code)
|
||||||
|
}
|
||||||
|
expectQuiet(t, events)
|
||||||
|
if code, _ := call("PATCH", "/api/service-requests/"+task.ID, admin, `{"status":"accepted"}`); code != 200 {
|
||||||
|
t.Fatalf("patch%d", code)
|
||||||
|
}
|
||||||
|
expectChanged(t, events)
|
||||||
|
expectQuiet(t, remoteEvents)
|
||||||
|
if code, _ := call("PATCH", "/api/service-requests/"+task.ID, guest, `{"status":"cancelled"}`); code != 401 {
|
||||||
|
t.Fatalf("unauthorized%d", code)
|
||||||
|
}
|
||||||
|
expectQuiet(t, events)
|
||||||
|
}
|
||||||
|
func TestTaskHubCoalescesWithoutBlocking(t *testing.T) {
|
||||||
|
h := newTaskHub()
|
||||||
|
c := &taskSocket{user: domain.User{StoreID: "one"}, changed: make(chan struct{}, 1), stop: make(chan wsClose, 1)}
|
||||||
|
if !h.add(c) {
|
||||||
|
t.Fatal("add")
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
h.notify("one")
|
||||||
|
h.notify("other")
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("slow browser blocked broadcaster")
|
||||||
|
}
|
||||||
|
if len(c.changed) != 1 {
|
||||||
|
t.Fatal("notifications must merge")
|
||||||
|
}
|
||||||
|
h.remove(c)
|
||||||
|
h.notify("one")
|
||||||
|
h.shutdown()
|
||||||
|
if h.add(c) {
|
||||||
|
t.Fatal("shutdown hub accepted client")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"perler-beads-server/internal/domain"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func wsTestServer(t *testing.T) (*Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_REDIS_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_REDIS_URL absent; real Redis websocket integration skipped")
|
||||||
|
}
|
||||||
|
options, e := redis.ParseURL(dsn)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
client := redis.NewClient(options)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if e = client.Ping(ctx).Err(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
s := &Server{Redis: client, Origins: []string{"https://staff.example.test"}}
|
||||||
|
httpServer := httptest.NewServer(s.Router())
|
||||||
|
t.Cleanup(func() { httpServer.Close(); client.Close() })
|
||||||
|
return s, httpServer
|
||||||
|
}
|
||||||
|
func wsToken(t *testing.T, s *Server, role, store string) string {
|
||||||
|
t.Helper()
|
||||||
|
token := uuid.NewString()
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
key := "session:" + hex.EncodeToString(h[:])
|
||||||
|
b, _ := json.Marshal(domain.User{ID: uuid.NewString(), Role: role, StoreID: store})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if e := s.Redis.Set(ctx, key, b, time.Minute).Err(); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
s.Redis.Del(ctx, key)
|
||||||
|
})
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
func wsConnect(t *testing.T, h *httptest.Server, origin string) *websocket.Conn {
|
||||||
|
t.Helper()
|
||||||
|
headers := http.Header{}
|
||||||
|
if origin != "" {
|
||||||
|
headers.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
c, resp, e := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(h.URL, "http")+"/api/ws", headers)
|
||||||
|
if e != nil {
|
||||||
|
if resp != nil {
|
||||||
|
t.Fatalf("websocket upgrade: %v (status %d)", e, resp.StatusCode)
|
||||||
|
}
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
func wsAuth(t *testing.T, c *websocket.Conn, token string) {
|
||||||
|
t.Helper()
|
||||||
|
c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if e := c.WriteJSON(map[string]string{"type": "auth", "token": token}); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var v map[string]any
|
||||||
|
if e := c.ReadJSON(&v); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if v["type"] != "ready" || len(v) != 1 {
|
||||||
|
t.Fatalf("bad ready: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketAuthenticationAndOrigin(t *testing.T) {
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
token := wsToken(t, s, "admin", "s1")
|
||||||
|
wsAuth(t, wsConnect(t, h, h.URL), token)
|
||||||
|
wsAuth(t, wsConnect(t, h, "https://staff.example.test"), token)
|
||||||
|
for _, origin := range []string{"https://evil.example", "null", h.URL + "/path", ""} {
|
||||||
|
headers := http.Header{}
|
||||||
|
if origin != "" {
|
||||||
|
headers.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
conn, resp, e := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(h.URL, "http")+"/api/ws", headers)
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
if e == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("origin %q should be forbidden: %v %+v", origin, e, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketGuestInvalidAuthAndTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
for _, token := range []string{"missing", wsToken(t, s, "guest", "s1"), wsToken(t, s, "admin", "")} {
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
c.WriteJSON(map[string]string{"type": "auth", "token": token})
|
||||||
|
c.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||||
|
_, _, e := c.ReadMessage()
|
||||||
|
if !websocket.IsCloseError(e, 4403) {
|
||||||
|
t.Fatalf("auth must close4403: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
c.SetReadDeadline(time.Now().Add(7 * time.Second))
|
||||||
|
_, _, e := c.ReadMessage()
|
||||||
|
if !websocket.IsCloseError(e, 4403) {
|
||||||
|
t.Fatalf("auth timeout must close4403: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketLivesPastHTTPDeadline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
wsAuth(t, c, wsToken(t, s, "staff", "s1"))
|
||||||
|
c.SetReadDeadline(time.Now().Add(24 * time.Second))
|
||||||
|
var event map[string]any
|
||||||
|
if e := c.ReadJSON(&event); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if event["type"] != "heartbeat" {
|
||||||
|
t.Fatalf("expected heartbeat beyond15s, got%+v", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketRevokedSessionCloses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
token := wsToken(t, s, "admin", "s1")
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
wsAuth(t, c, token)
|
||||||
|
hash := sha256.Sum256([]byte(token))
|
||||||
|
s.Redis.Del(context.Background(), "session:"+hex.EncodeToString(hash[:]))
|
||||||
|
c.SetReadDeadline(time.Now().Add(25 * time.Second))
|
||||||
|
for {
|
||||||
|
_, _, e := c.ReadMessage()
|
||||||
|
if e != nil {
|
||||||
|
if !websocket.IsCloseError(e, 4403) {
|
||||||
|
t.Fatalf("revocation should close4403: %v", e)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWebSocketOversizeAndProtocol(t *testing.T) {
|
||||||
|
s, h := wsTestServer(t)
|
||||||
|
for _, payload := range []string{strings.Repeat("x", 4097), `{"type":"other","token":"missing"}`} {
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
c.WriteMessage(websocket.TextMessage, []byte(payload))
|
||||||
|
c.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||||
|
_, _, e := c.ReadMessage()
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("invalid client message accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := wsConnect(t, h, h.URL)
|
||||||
|
wsAuth(t, c, wsToken(t, s, "admin", "s1"))
|
||||||
|
c.WriteJSON(map[string]string{"type": "auth", "token": "anything"})
|
||||||
|
c.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||||
|
_, _, e := c.ReadMessage()
|
||||||
|
if !websocket.IsCloseError(e, 4400) {
|
||||||
|
t.Fatalf("reauth must close4400: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user