chore(服务端): 初始化工程仓库
This commit is contained in:
@@ -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