chore(服务端): 初始化工程仓库

This commit is contained in:
leon_mac
2026-10-18 15:11:44 +08:00
commit 13c8bcbf3c
23 changed files with 2543 additions and 0 deletions
+43
View File
@@ -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);
+246
View File
@@ -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)
}
+104
View File
@@ -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)
}
+190
View File
@@ -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)
}
}
+181
View File
@@ -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)
}
+305
View File
@@ -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
}
+96
View File
@@ -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"`, "&lt;script&gt;", "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")
}
}
}
+59
View File
@@ -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)
}
+88
View File
@@ -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
}
+177
View File
@@ -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)
}
+266
View File
@@ -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
}
}
}
}
+196
View File
@@ -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")
}
}
+179
View File
@@ -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)
}
}