diff --git a/.gitignore b/.gitignore
index 986fb12..8b12b4b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,7 @@ coverage/
.env
.env.*
*.log
+*.exe
+*.exe~
+*~
.DS_Store
diff --git a/mengyastore-backend/.dockerignore b/mengyastore-backend/.dockerignore
new file mode 100644
index 0000000..4c13373
--- /dev/null
+++ b/mengyastore-backend/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.gitignore
+node_modules
+dist
+debug-logs
+*.exe
+*.exe~
+Dockerfile
+docker-compose.yml
diff --git a/mengyastore-backend/Dockerfile b/mengyastore-backend/Dockerfile
new file mode 100644
index 0000000..9047a3c
--- /dev/null
+++ b/mengyastore-backend/Dockerfile
@@ -0,0 +1,23 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o /out/mengyastore-backend .
+
+FROM alpine:3.20
+
+RUN apk add --no-cache ca-certificates tzdata
+
+WORKDIR /app
+
+COPY --from=builder /out/mengyastore-backend ./mengyastore-backend
+
+EXPOSE 8080
+
+ENV GIN_MODE=release
+
+CMD ["./mengyastore-backend"]
diff --git a/mengyastore-backend/data/json/orders.json b/mengyastore-backend/data/json/orders.json
new file mode 100644
index 0000000..8abe671
--- /dev/null
+++ b/mengyastore-backend/data/json/orders.json
@@ -0,0 +1,119 @@
+[
+ {
+ "id": "0bea9606-51aa-4fe2-a932-ab0e36ee33ca",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "LINUX-INVITE-001"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-19T17:23:46.1743551+08:00"
+ },
+ {
+ "id": "5be3ecbd-873b-4ea2-9209-e96f6eb528cd",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "LINUX-INVITE-002"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-19T17:24:07.6045189+08:00"
+ },
+ {
+ "id": "c0cbb6c7-76be-49ef-9e67-8d2ae890e555",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "啊伟大伟大伟大我"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-19T22:28:28.5393405+08:00"
+ },
+ {
+ "id": "f299bbb4-0de4-4824-84ab-d1ccfb3b35dd",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "啊伟大伟大伟大伟大"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-20T10:32:38.352837+08:00"
+ },
+ {
+ "id": "413931af-2867-4855-89af-515747d4b5e5",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "你是傻逼哈哈哈被骗了吧"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-20T10:32:55.2785291+08:00"
+ },
+ {
+ "id": "59ab54e0-8b98-48d3-bf63-a843ef2c95a4",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "唐"
+ ],
+ "status": "pending",
+ "createdAt": "2026-03-20T10:39:37.9977301+08:00"
+ },
+ {
+ "id": "94e82c71-8237-429f-b593-2530314b72af",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "",
+ "userName": "",
+ "quantity": 1,
+ "deliveredCodes": [
+ "原神牛逼"
+ ],
+ "status": "completed",
+ "createdAt": "2026-03-20T10:40:45.3820749+08:00"
+ },
+ {
+ "id": "058cad17-608c-4108-b012-af42f688a047",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "shumengya",
+ "userName": "树萌芽",
+ "quantity": 1,
+ "deliveredCodes": [
+ "123123123131"
+ ],
+ "status": "completed",
+ "createdAt": "2026-03-20T10:44:21.375082+08:00"
+ },
+ {
+ "id": "e95f30ab-da4f-4dec-872c-3c9047cd8193",
+ "productId": "seed-1",
+ "productName": "Linux Do 邀请码",
+ "userAccount": "shumengya",
+ "userName": "树萌芽",
+ "quantity": 1,
+ "deliveredCodes": [
+ "131231231231231"
+ ],
+ "status": "completed",
+ "createdAt": "2026-03-20T10:57:13.3436565+08:00"
+ }
+]
\ No newline at end of file
diff --git a/mengyastore-backend/data/json/products.json b/mengyastore-backend/data/json/products.json
index d7d626c..d3a9363 100644
--- a/mengyastore-backend/data/json/products.json
+++ b/mengyastore-backend/data/json/products.json
@@ -3,66 +3,179 @@
"id": "seed-1",
"name": "Linux Do 邀请码",
"price": 7,
- "quantity": 10,
+ "discountPrice": 4,
+ "tags": [
+ "邀请码",
+ "LinuxDo"
+ ],
+ "quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 10,
"description": "Linux.do论坛邀请码 默认每天可以生成一个,先到先得.",
"active": true,
"createdAt": "2026-03-15T10:00:00+08:00",
- "updatedAt": "2026-03-18T22:04:26.9875796+08:00"
+ "updatedAt": "2026-03-20T11:37:16.2219815+08:00"
},
{
"id": "seed-2",
"name": "ChatGPT普号",
"price": 1,
- "quantity": 20,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 2,
"description": "ChatGPT 普号 纯手工注册 数量不多",
"active": true,
"createdAt": "2026-03-15T10:05:00+08:00",
- "updatedAt": "2026-03-18T22:04:44.9242847+08:00"
+ "updatedAt": "2026-03-20T11:34:54.3522714+08:00"
},
{
"id": "2b6b6051-bca7-42da-b127-c7b721c50c06",
"name": "谷歌账号",
"price": 20,
- "quantity": 8,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 1,
"description": "谷歌账号 现货 可绑定F2A验证",
"active": true,
"createdAt": "2026-03-15T20:52:52.0381722+08:00",
- "updatedAt": "2026-03-18T21:55:26.3289587+08:00"
+ "updatedAt": "2026-03-19T19:33:05.6844325+08:00"
},
{
"id": "b9922892-c197-44be-be87-637ccb6bebeb",
"name": "萌芽币",
"price": 999999,
- "quantity": 888,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 1,
"description": "非买品 仅展示",
"active": true,
"createdAt": "2026-03-15T21:03:00.0164528+08:00",
- "updatedAt": "2026-03-18T22:04:48.6124945+08:00"
+ "updatedAt": "2026-03-19T19:33:07.508758+08:00"
},
{
"id": "ee8e0140-221c-4bfa-b10a-13b1f98ea4e5",
"name": "Keep校园跑 代刷4公里",
"price": 1,
- "quantity": 999,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 1,
"description": "keep校园跑带刷 每天4-5公里 下单后直接联系我发账号",
"active": true,
"createdAt": "2026-03-15T21:06:11.9820102+08:00",
- "updatedAt": "2026-03-18T22:04:53.0357081+08:00"
+ "updatedAt": "2026-03-19T19:33:09.1800225+08:00"
},
{
"id": "00bbf5db-b99e-4e88-a8ee-e7747b5969fe",
- "name": "学习通/慕课挂科脚本",
+ "name": "学习通/慕课挂课脚本",
"price": 25,
+ "discountPrice": 0,
+ "tags": [],
"quantity": 0,
"coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 1,
"description": "学习通,慕课挂科脚本 手机 电脑都可以挂 不会弄可联系教你",
"active": true,
"createdAt": "2026-03-15T21:06:45.3807471+08:00",
- "updatedAt": "2026-03-18T22:04:56.3154497+08:00"
+ "updatedAt": "2026-03-19T19:33:02.9673884+08:00"
+ },
+ {
+ "id": "6c7bf494-ef2c-4221-9bf7-ec3c94070d25",
+ "name": "smyhub.com后缀域名邮箱",
+ "price": 5,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 0,
+ "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [],
+ "viewCount": 1,
+ "description": "纪念意义,比如我自己的mail@smyhub.com 目前已经续费了5年到2031年",
+ "active": true,
+ "createdAt": "2026-03-18T22:17:41.3034538+08:00",
+ "updatedAt": "2026-03-19T19:32:26.7674929+08:00"
+ },
+ {
+ "id": "a30a2275-1c9c-49e4-a402-3e446e3e0f5c",
+ "name": "萌芽账号邀请码",
+ "price": 10,
+ "discountPrice": 8,
+ "tags": [],
+ "quantity": 1,
+ "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [
+ "原神牛逼"
+ ],
+ "viewCount": 0,
+ "description": "萌芽统一账号登录平台邀请码",
+ "active": true,
+ "createdAt": "2026-03-20T11:04:05.5787516+08:00",
+ "updatedAt": "2026-03-20T11:04:05.5787516+08:00"
+ },
+ {
+ "id": "bcd5d73b-6ad9-4ed9-8e18-42ea0482ceb3",
+ "name": "Keep 代跑脚本",
+ "price": 50,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 1,
+ "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [
+ "傻逼"
+ ],
+ "viewCount": 0,
+ "description": "Keep 校园跑脚本",
+ "active": true,
+ "createdAt": "2026-03-20T11:17:36.1915376+08:00",
+ "updatedAt": "2026-03-20T11:17:36.1915376+08:00"
+ },
+ {
+ "id": "7ab90d55-92c1-49d3-9d0a-01e5b1c08340",
+ "name": "原神牛逼",
+ "price": 0,
+ "discountPrice": 0,
+ "tags": [],
+ "quantity": 1,
+ "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png",
+ "screenshotUrls": [],
+ "verificationUrl": "",
+ "codes": [
+ "原神牛逼"
+ ],
+ "viewCount": 0,
+ "description": "购买后直接发送一句原神牛逼",
+ "active": true,
+ "createdAt": "2026-03-20T11:36:36.6726035+08:00",
+ "updatedAt": "2026-03-20T11:42:05.3303102+08:00"
}
]
\ No newline at end of file
diff --git a/mengyastore-backend/data/json/site.json b/mengyastore-backend/data/json/site.json
new file mode 100644
index 0000000..05c3c4a
--- /dev/null
+++ b/mengyastore-backend/data/json/site.json
@@ -0,0 +1,3 @@
+{
+ "totalVisits": 3
+}
\ No newline at end of file
diff --git a/mengyastore-backend/docker-compose.yml b/mengyastore-backend/docker-compose.yml
new file mode 100644
index 0000000..6df578c
--- /dev/null
+++ b/mengyastore-backend/docker-compose.yml
@@ -0,0 +1,13 @@
+services:
+ backend:
+ build:
+ context: .
+ container_name: mengyastore-backend
+ ports:
+ - "28081:8080"
+ environment:
+ GIN_MODE: release
+ TZ: Asia/Shanghai
+ volumes:
+ - ./data:/app/data
+ restart: unless-stopped
diff --git a/mengyastore-backend/internal/auth/sproutgate.go b/mengyastore-backend/internal/auth/sproutgate.go
new file mode 100644
index 0000000..124e671
--- /dev/null
+++ b/mengyastore-backend/internal/auth/sproutgate.go
@@ -0,0 +1,64 @@
+package auth
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "time"
+)
+
+const defaultAuthAPIURL = "https://auth.api.shumengya.top"
+
+type SproutGateClient struct {
+ apiURL string
+ httpClient *http.Client
+}
+
+type VerifyResult struct {
+ Valid bool `json:"valid"`
+ User *SproutGateUser `json:"user"`
+}
+
+type SproutGateUser struct {
+ Account string `json:"account"`
+ Username string `json:"username"`
+ AvatarURL string `json:"avatarUrl"`
+}
+
+func NewSproutGateClient(apiURL string) *SproutGateClient {
+ if apiURL == "" {
+ apiURL = defaultAuthAPIURL
+ }
+ return &SproutGateClient{
+ apiURL: apiURL,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+func (c *SproutGateClient) VerifyToken(token string) (*VerifyResult, error) {
+ body, _ := json.Marshal(map[string]string{"token": token})
+ resp, err := c.httpClient.Post(c.apiURL+"/api/auth/verify", "application/json", bytes.NewReader(body))
+ if err != nil {
+ log.Printf("[SproutGate] verify request failed: %v", err)
+ return nil, fmt.Errorf("verify request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ rawBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Printf("[SproutGate] read response body failed: %v", err)
+ return nil, fmt.Errorf("read verify response: %w", err)
+ }
+
+ log.Printf("[SproutGate] verify response status=%d body=%s", resp.StatusCode, string(rawBody))
+
+ var result VerifyResult
+ if err := json.Unmarshal(rawBody, &result); err != nil {
+ log.Printf("[SproutGate] decode response failed: %v", err)
+ return nil, fmt.Errorf("decode verify response: %w", err)
+ }
+ return &result, nil
+}
diff --git a/mengyastore-backend/internal/config/config.go b/mengyastore-backend/internal/config/config.go
index 37b484a..fdc8266 100644
--- a/mengyastore-backend/internal/config/config.go
+++ b/mengyastore-backend/internal/config/config.go
@@ -8,6 +8,7 @@ import (
type Config struct {
AdminToken string `json:"adminToken"`
+ AuthAPIURL string `json:"authApiUrl"`
}
func Load(path string) (*Config, error) {
diff --git a/mengyastore-backend/internal/handlers/admin.go b/mengyastore-backend/internal/handlers/admin.go
index bea0970..c704cf1 100644
--- a/mengyastore-backend/internal/handlers/admin.go
+++ b/mengyastore-backend/internal/handlers/admin.go
@@ -19,8 +19,10 @@ type AdminHandler struct {
type productPayload struct {
Name string `json:"name"`
Price float64 `json:"price"`
- Quantity int `json:"quantity"`
+ DiscountPrice float64 `json:"discountPrice"`
+ Tags string `json:"tags"`
CoverURL string `json:"coverUrl"`
+ Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
@@ -61,7 +63,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
- c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 10 or fewer"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
return
}
active := true
@@ -71,8 +73,10 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
product := models.Product{
Name: payload.Name,
Price: payload.Price,
- Quantity: payload.Quantity,
+ DiscountPrice: payload.DiscountPrice,
+ Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
+ Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
@@ -97,7 +101,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
- c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 10 or fewer"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
return
}
active := false
@@ -107,8 +111,10 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
patch := models.Product{
Name: payload.Name,
Price: payload.Price,
- Quantity: payload.Quantity,
+ DiscountPrice: payload.DiscountPrice,
+ Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL),
+ Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
Description: payload.Description,
Active: active,
@@ -171,9 +177,34 @@ func normalizeScreenshotURLs(urls []string) ([]string, bool) {
continue
}
cleaned = append(cleaned, trimmed)
- if len(cleaned) > 10 {
+ if len(cleaned) > 5 {
return nil, false
}
}
return cleaned, true
}
+
+func normalizeTags(tagsCSV string) []string {
+ if tagsCSV == "" {
+ return []string{}
+ }
+ parts := strings.Split(tagsCSV, ",")
+ clean := make([]string, 0, len(parts))
+ seen := map[string]bool{}
+ for _, p := range parts {
+ t := strings.TrimSpace(p)
+ if t == "" {
+ continue
+ }
+ key := strings.ToLower(t)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ clean = append(clean, t)
+ if len(clean) >= 20 {
+ break
+ }
+ }
+ return clean
+}
diff --git a/mengyastore-backend/internal/handlers/order.go b/mengyastore-backend/internal/handlers/order.go
new file mode 100644
index 0000000..32e1d9e
--- /dev/null
+++ b/mengyastore-backend/internal/handlers/order.go
@@ -0,0 +1,183 @@
+package handlers
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "mengyastore-backend/internal/auth"
+ "mengyastore-backend/internal/models"
+ "mengyastore-backend/internal/storage"
+)
+
+const qrSize = "320x320"
+
+type OrderHandler struct {
+ productStore *storage.JSONStore
+ orderStore *storage.OrderStore
+ authClient *auth.SproutGateClient
+}
+
+type checkoutPayload struct {
+ ProductID string `json:"productId"`
+ Quantity int `json:"quantity"`
+}
+
+func NewOrderHandler(productStore *storage.JSONStore, orderStore *storage.OrderStore, authClient *auth.SproutGateClient) *OrderHandler {
+ return &OrderHandler{productStore: productStore, orderStore: orderStore, authClient: authClient}
+}
+
+func (h *OrderHandler) tryExtractUser(c *gin.Context) (string, string) {
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
+ log.Println("[Order] 无 Authorization header,匿名下单")
+ return "", ""
+ }
+ userToken := strings.TrimPrefix(authHeader, "Bearer ")
+ log.Printf("[Order] 检测到用户 token,正在验证 (长度=%d)", len(userToken))
+
+ result, err := h.authClient.VerifyToken(userToken)
+ if err != nil {
+ log.Printf("[Order] 验证 token 失败: %v", err)
+ return "", ""
+ }
+ if !result.Valid {
+ log.Println("[Order] token 验证返回 valid=false")
+ return "", ""
+ }
+ if result.User == nil {
+ log.Println("[Order] token 验证成功但 user 为空")
+ return "", ""
+ }
+
+ log.Printf("[Order] 用户身份验证成功: account=%s username=%s", result.User.Account, result.User.Username)
+ return result.User.Account, result.User.Username
+}
+
+func (h *OrderHandler) CreateOrder(c *gin.Context) {
+ userAccount, userName := h.tryExtractUser(c)
+
+ var payload checkoutPayload
+ if err := c.ShouldBindJSON(&payload); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
+ return
+ }
+
+ payload.ProductID = strings.TrimSpace(payload.ProductID)
+ if payload.ProductID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"})
+ return
+ }
+ if payload.Quantity <= 0 {
+ payload.Quantity = 1
+ }
+
+ product, err := h.productStore.GetByID(payload.ProductID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ if !product.Active {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "product is not available"})
+ return
+ }
+ if product.Quantity < payload.Quantity {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
+ return
+ }
+
+ deliveredCodes, ok := extractCodes(&product, payload.Quantity)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
+ return
+ }
+ product.Quantity = len(product.Codes)
+ updatedProduct, err := h.productStore.Update(product.ID, product)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ order := models.Order{
+ ProductID: updatedProduct.ID,
+ ProductName: updatedProduct.Name,
+ UserAccount: userAccount,
+ UserName: userName,
+ Quantity: payload.Quantity,
+ DeliveredCodes: deliveredCodes,
+ Status: "pending",
+ }
+ created, err := h.orderStore.Create(order)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ qrPayload := fmt.Sprintf("order:%s:%s", created.ID, created.ProductID)
+ qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
+
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "orderId": created.ID,
+ "qrCodeUrl": qrURL,
+ "productId": created.ProductID,
+ "productQty": created.Quantity,
+ "viewCount": updatedProduct.ViewCount,
+ "status": created.Status,
+ },
+ })
+}
+
+func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
+ orderID := c.Param("id")
+ order, err := h.orderStore.Confirm(orderID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "orderId": order.ID,
+ "status": order.Status,
+ "deliveredCodes": order.DeliveredCodes,
+ },
+ })
+}
+
+func (h *OrderHandler) ListMyOrders(c *gin.Context) {
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ userToken := strings.TrimPrefix(authHeader, "Bearer ")
+ result, err := h.authClient.VerifyToken(userToken)
+ if err != nil || !result.Valid || result.User == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
+ return
+ }
+
+ orders, err := h.orderStore.ListByAccount(result.User.Account)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"data": orders})
+}
+
+func extractCodes(product *models.Product, count int) ([]string, bool) {
+ if count <= 0 {
+ return nil, false
+ }
+ if len(product.Codes) < count {
+ return nil, false
+ }
+ delivered := make([]string, count)
+ copy(delivered, product.Codes[:count])
+ product.Codes = product.Codes[count:]
+ return delivered, true
+}
diff --git a/mengyastore-backend/internal/handlers/public.go b/mengyastore-backend/internal/handlers/public.go
index 55b90af..4224247 100644
--- a/mengyastore-backend/internal/handlers/public.go
+++ b/mengyastore-backend/internal/handlers/public.go
@@ -2,9 +2,11 @@ package handlers
import (
"net/http"
+ "strings"
"github.com/gin-gonic/gin"
+ "mengyastore-backend/internal/models"
"mengyastore-backend/internal/storage"
)
@@ -22,5 +24,38 @@ func (h *PublicHandler) ListProducts(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, gin.H{"data": items})
+ c.JSON(http.StatusOK, gin.H{"data": sanitizeForPublic(items)})
+}
+
+func (h *PublicHandler) RecordProductView(c *gin.Context) {
+ id := c.Param("id")
+ fingerprint := buildViewerFingerprint(c)
+ product, counted, err := h.store.IncrementView(id, fingerprint)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "id": product.ID,
+ "viewCount": product.ViewCount,
+ "counted": counted,
+ },
+ })
+}
+
+func buildViewerFingerprint(c *gin.Context) string {
+ clientIP := strings.TrimSpace(c.ClientIP())
+ userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
+ language := strings.TrimSpace(c.GetHeader("Accept-Language"))
+ return clientIP + "|" + userAgent + "|" + language
+}
+
+func sanitizeForPublic(items []models.Product) []models.Product {
+ out := make([]models.Product, len(items))
+ for i, item := range items {
+ item.Codes = nil
+ out[i] = item
+ }
+ return out
}
diff --git a/mengyastore-backend/internal/handlers/stats.go b/mengyastore-backend/internal/handlers/stats.go
new file mode 100644
index 0000000..97ba25c
--- /dev/null
+++ b/mengyastore-backend/internal/handlers/stats.go
@@ -0,0 +1,52 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "mengyastore-backend/internal/storage"
+)
+
+type StatsHandler struct {
+ orderStore *storage.OrderStore
+ siteStore *storage.SiteStore
+}
+
+func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStore) *StatsHandler {
+ return &StatsHandler{orderStore: orderStore, siteStore: siteStore}
+}
+
+func (h *StatsHandler) GetStats(c *gin.Context) {
+ totalOrders, err := h.orderStore.Count()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ totalVisits, err := h.siteStore.GetTotalVisits()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "totalOrders": totalOrders,
+ "totalVisits": totalVisits,
+ },
+ })
+}
+
+func (h *StatsHandler) RecordVisit(c *gin.Context) {
+ fingerprint := buildViewerFingerprint(c)
+ totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": gin.H{
+ "totalVisits": totalVisits,
+ "counted": counted,
+ },
+ })
+}
diff --git a/mengyastore-backend/internal/models/order.go b/mengyastore-backend/internal/models/order.go
new file mode 100644
index 0000000..12b3a0e
--- /dev/null
+++ b/mengyastore-backend/internal/models/order.go
@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+type Order struct {
+ ID string `json:"id"`
+ ProductID string `json:"productId"`
+ ProductName string `json:"productName"`
+ UserAccount string `json:"userAccount"`
+ UserName string `json:"userName"`
+ Quantity int `json:"quantity"`
+ DeliveredCodes []string `json:"deliveredCodes"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"createdAt"`
+}
diff --git a/mengyastore-backend/internal/models/product.go b/mengyastore-backend/internal/models/product.go
index 33975c8..6553f8b 100644
--- a/mengyastore-backend/internal/models/product.go
+++ b/mengyastore-backend/internal/models/product.go
@@ -3,14 +3,19 @@ package models
import "time"
type Product struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Price float64 `json:"price"`
- Quantity int `json:"quantity"`
- CoverURL string `json:"coverUrl"`
- ScreenshotURLs []string `json:"screenshotUrls"`
- Description string `json:"description"`
- Active bool `json:"active"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Price float64 `json:"price"`
+ DiscountPrice float64 `json:"discountPrice"`
+ Tags []string `json:"tags"`
+ Quantity int `json:"quantity"`
+ CoverURL string `json:"coverUrl"`
+ ScreenshotURLs []string `json:"screenshotUrls"`
+ VerificationURL string `json:"verificationUrl"`
+ Codes []string `json:"codes"`
+ ViewCount int `json:"viewCount"`
+ Description string `json:"description"`
+ Active bool `json:"active"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
}
diff --git a/mengyastore-backend/internal/storage/jsonstore.go b/mengyastore-backend/internal/storage/jsonstore.go
index ee2dca8..273c15b 100644
--- a/mengyastore-backend/internal/storage/jsonstore.go
+++ b/mengyastore-backend/internal/storage/jsonstore.go
@@ -1,6 +1,7 @@
package storage
import (
+ "crypto/sha256"
"encoding/json"
"fmt"
"os"
@@ -15,20 +16,26 @@ import (
)
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
+const viewCooldown = 6 * time.Hour
+const maxScreenshotURLs = 5
type JSONStore struct {
- path string
- mu sync.Mutex
+ path string
+ mu sync.Mutex
+ recentViews map[string]time.Time
}
func NewJSONStore(path string) (*JSONStore, error) {
- if err := ensureFile(path); err != nil {
+ if err := ensureProductsFile(path); err != nil {
return nil, err
}
- return &JSONStore{path: path}, nil
+ return &JSONStore{
+ path: path,
+ recentViews: make(map[string]time.Time),
+ }, nil
}
-func ensureFile(path string) error {
+func ensureProductsFile(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir data dir: %w", err)
@@ -72,6 +79,22 @@ func (s *JSONStore) ListActive() ([]models.Product, error) {
return active, nil
}
+func (s *JSONStore) GetByID(id string) (models.Product, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return models.Product{}, err
+ }
+ for _, item := range items {
+ if item.ID == id {
+ return item, nil
+ }
+ }
+ return models.Product{}, fmt.Errorf("product not found")
+}
+
func (s *JSONStore) Create(p models.Product) (models.Product, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -100,13 +123,18 @@ func (s *JSONStore) Update(id string, patch models.Product) (models.Product, err
}
for i, item := range items {
if item.ID == id {
- item.Name = patch.Name
- item.Price = patch.Price
- item.Quantity = patch.Quantity
- item.CoverURL = patch.CoverURL
- item.ScreenshotURLs = normalizeProduct(patch).ScreenshotURLs
- item.Description = patch.Description
- item.Active = patch.Active
+ normalized := normalizeProduct(patch)
+ item.Name = normalized.Name
+ item.Price = normalized.Price
+ item.DiscountPrice = normalized.DiscountPrice
+ item.Tags = normalized.Tags
+ item.CoverURL = normalized.CoverURL
+ item.ScreenshotURLs = normalized.ScreenshotURLs
+ item.VerificationURL = normalized.VerificationURL
+ item.Codes = normalized.Codes
+ item.Quantity = normalized.Quantity
+ item.Description = normalized.Description
+ item.Active = normalized.Active
item.UpdatedAt = time.Now()
items[i] = item
if err := s.writeAll(items); err != nil {
@@ -139,6 +167,43 @@ func (s *JSONStore) Toggle(id string, active bool) (models.Product, error) {
return models.Product{}, fmt.Errorf("product not found")
}
+func (s *JSONStore) IncrementView(id, fingerprint string) (models.Product, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return models.Product{}, false, err
+ }
+
+ now := time.Now()
+ s.cleanupRecentViews(now)
+ key := buildViewKey(id, fingerprint)
+ if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown {
+ for _, item := range items {
+ if item.ID == id {
+ return item, false, nil
+ }
+ }
+ return models.Product{}, false, fmt.Errorf("product not found")
+ }
+
+ for i, item := range items {
+ if item.ID == id {
+ item.ViewCount++
+ item.UpdatedAt = now
+ items[i] = item
+ s.recentViews[key] = now
+ if err := s.writeAll(items); err != nil {
+ return models.Product{}, false, err
+ }
+ return item, true, nil
+ }
+ }
+
+ return models.Product{}, false, fmt.Errorf("product not found")
+}
+
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -192,8 +257,75 @@ func normalizeProduct(item models.Product) models.Product {
if item.CoverURL == "" {
item.CoverURL = defaultCoverURL
}
+ if item.Tags == nil {
+ item.Tags = []string{}
+ }
+ item.Tags = sanitizeTags(item.Tags)
if item.ScreenshotURLs == nil {
item.ScreenshotURLs = []string{}
}
+ if len(item.ScreenshotURLs) > maxScreenshotURLs {
+ item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
+ }
+ if item.Codes == nil {
+ item.Codes = []string{}
+ }
+ if item.DiscountPrice <= 0 || item.DiscountPrice >= item.Price {
+ item.DiscountPrice = 0
+ }
+ item.VerificationURL = strings.TrimSpace(item.VerificationURL)
+ item.Codes = sanitizeCodes(item.Codes)
+ item.Quantity = len(item.Codes)
return item
}
+
+func sanitizeCodes(codes []string) []string {
+ clean := make([]string, 0, len(codes))
+ seen := map[string]bool{}
+ for _, code := range codes {
+ trimmed := strings.TrimSpace(code)
+ if trimmed == "" {
+ continue
+ }
+ if seen[trimmed] {
+ continue
+ }
+ seen[trimmed] = true
+ clean = append(clean, trimmed)
+ }
+ return clean
+}
+
+func sanitizeTags(tags []string) []string {
+ clean := make([]string, 0, len(tags))
+ seen := map[string]bool{}
+ for _, tag := range tags {
+ t := strings.TrimSpace(tag)
+ if t == "" {
+ continue
+ }
+ key := strings.ToLower(t)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ clean = append(clean, t)
+ if len(clean) >= 20 {
+ break
+ }
+ }
+ return clean
+}
+
+func buildViewKey(id, fingerprint string) string {
+ sum := sha256.Sum256([]byte(id + "|" + fingerprint))
+ return fmt.Sprintf("%x", sum)
+}
+
+func (s *JSONStore) cleanupRecentViews(now time.Time) {
+ for key, lastViewedAt := range s.recentViews {
+ if now.Sub(lastViewedAt) >= viewCooldown {
+ delete(s.recentViews, key)
+ }
+ }
+}
diff --git a/mengyastore-backend/internal/storage/orderstore.go b/mengyastore-backend/internal/storage/orderstore.go
new file mode 100644
index 0000000..2df24ec
--- /dev/null
+++ b/mengyastore-backend/internal/storage/orderstore.go
@@ -0,0 +1,140 @@
+package storage
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "mengyastore-backend/internal/models"
+)
+
+type OrderStore struct {
+ path string
+ mu sync.Mutex
+}
+
+func NewOrderStore(path string) (*OrderStore, error) {
+ if err := ensureOrdersFile(path); err != nil {
+ return nil, err
+ }
+ return &OrderStore{path: path}, nil
+}
+
+func (s *OrderStore) Count() (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return 0, err
+ }
+ return len(items), nil
+}
+
+func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return nil, err
+ }
+ matched := make([]models.Order, 0)
+ for i := len(items) - 1; i >= 0; i-- {
+ if items[i].UserAccount == account {
+ matched = append(matched, items[i])
+ }
+ }
+ return matched, nil
+}
+
+func (s *OrderStore) Confirm(id string) (models.Order, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return models.Order{}, err
+ }
+ for i, item := range items {
+ if item.ID == id {
+ if item.Status == "completed" {
+ return item, nil
+ }
+ items[i].Status = "completed"
+ if err := s.writeAll(items); err != nil {
+ return models.Order{}, err
+ }
+ return items[i], nil
+ }
+ }
+ return models.Order{}, fmt.Errorf("order not found")
+}
+
+func (s *OrderStore) Create(order models.Order) (models.Order, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ items, err := s.readAll()
+ if err != nil {
+ return models.Order{}, err
+ }
+
+ order.ID = uuid.NewString()
+ order.CreatedAt = time.Now()
+ items = append(items, order)
+ if err := s.writeAll(items); err != nil {
+ return models.Order{}, err
+ }
+ return order, nil
+}
+
+func (s *OrderStore) readAll() ([]models.Order, error) {
+ bytes, err := os.ReadFile(s.path)
+ if err != nil {
+ return nil, fmt.Errorf("read orders: %w", err)
+ }
+ var items []models.Order
+ if err := json.Unmarshal(bytes, &items); err != nil {
+ return nil, fmt.Errorf("parse orders: %w", err)
+ }
+ return items, nil
+}
+
+func (s *OrderStore) writeAll(items []models.Order) error {
+ bytes, err := json.MarshalIndent(items, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode orders: %w", err)
+ }
+ if err := os.WriteFile(s.path, bytes, 0o644); err != nil {
+ return fmt.Errorf("write orders: %w", err)
+ }
+ return nil
+}
+
+func ensureOrdersFile(path string) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir data dir: %w", err)
+ }
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("stat data file: %w", err)
+ }
+
+ initial := []models.Order{}
+ bytes, err := json.MarshalIndent(initial, "", " ")
+ if err != nil {
+ return fmt.Errorf("init json: %w", err)
+ }
+ if err := os.WriteFile(path, bytes, 0o644); err != nil {
+ return fmt.Errorf("write init json: %w", err)
+ }
+ return nil
+}
diff --git a/mengyastore-backend/internal/storage/sitestore.go b/mengyastore-backend/internal/storage/sitestore.go
new file mode 100644
index 0000000..984a1e5
--- /dev/null
+++ b/mengyastore-backend/internal/storage/sitestore.go
@@ -0,0 +1,128 @@
+package storage
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+const visitCooldown = 6 * time.Hour
+
+type siteData struct {
+ TotalVisits int `json:"totalVisits"`
+}
+
+type SiteStore struct {
+ path string
+ mu sync.Mutex
+ recentVisits map[string]time.Time
+}
+
+func NewSiteStore(path string) (*SiteStore, error) {
+ if err := ensureSiteFile(path); err != nil {
+ return nil, err
+ }
+ return &SiteStore{
+ path: path,
+ recentVisits: make(map[string]time.Time),
+ }, nil
+}
+
+func (s *SiteStore) RecordVisit(fingerprint string) (int, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := time.Now()
+ s.cleanupRecentVisits(now)
+
+ key := buildSiteVisitKey(fingerprint)
+ if last, ok := s.recentVisits[key]; ok && now.Sub(last) < visitCooldown {
+ data, err := s.read()
+ if err != nil {
+ return 0, false, err
+ }
+ return data.TotalVisits, false, nil
+ }
+
+ data, err := s.read()
+ if err != nil {
+ return 0, false, err
+ }
+ data.TotalVisits++
+ s.recentVisits[key] = now
+ if err := s.write(data); err != nil {
+ return 0, false, err
+ }
+ return data.TotalVisits, true, nil
+}
+
+func (s *SiteStore) GetTotalVisits() (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ data, err := s.read()
+ if err != nil {
+ return 0, err
+ }
+ return data.TotalVisits, nil
+}
+
+func (s *SiteStore) read() (siteData, error) {
+ bytes, err := os.ReadFile(s.path)
+ if err != nil {
+ return siteData{}, fmt.Errorf("read site data: %w", err)
+ }
+ var data siteData
+ if err := json.Unmarshal(bytes, &data); err != nil {
+ return siteData{}, fmt.Errorf("parse site data: %w", err)
+ }
+ return data, nil
+}
+
+func (s *SiteStore) write(data siteData) error {
+ bytes, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode site data: %w", err)
+ }
+ if err := os.WriteFile(s.path, bytes, 0o644); err != nil {
+ return fmt.Errorf("write site data: %w", err)
+ }
+ return nil
+}
+
+func (s *SiteStore) cleanupRecentVisits(now time.Time) {
+ for key, last := range s.recentVisits {
+ if now.Sub(last) >= visitCooldown {
+ delete(s.recentVisits, key)
+ }
+ }
+}
+
+func buildSiteVisitKey(fingerprint string) string {
+ sum := sha256.Sum256([]byte("site|" + fingerprint))
+ return fmt.Sprintf("%x", sum)
+}
+
+func ensureSiteFile(path string) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir data dir: %w", err)
+ }
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("stat site file: %w", err)
+ }
+ initial := siteData{TotalVisits: 0}
+ bytes, err := json.MarshalIndent(initial, "", " ")
+ if err != nil {
+ return fmt.Errorf("init site json: %w", err)
+ }
+ if err := os.WriteFile(path, bytes, 0o644); err != nil {
+ return fmt.Errorf("write site json: %w", err)
+ }
+ return nil
+}
diff --git a/mengyastore-backend/main.go b/mengyastore-backend/main.go
index 36a3d77..1f519b8 100644
--- a/mengyastore-backend/main.go
+++ b/mengyastore-backend/main.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
+ "mengyastore-backend/internal/auth"
"mengyastore-backend/internal/config"
"mengyastore-backend/internal/handlers"
"mengyastore-backend/internal/storage"
@@ -23,6 +24,14 @@ func main() {
if err != nil {
log.Fatalf("init store failed: %v", err)
}
+ orderStore, err := storage.NewOrderStore("data/json/orders.json")
+ if err != nil {
+ log.Fatalf("init order store failed: %v", err)
+ }
+ siteStore, err := storage.NewSiteStore("data/json/site.json")
+ if err != nil {
+ log.Fatalf("init site store failed: %v", err)
+ }
r := gin.Default()
r.Use(cors.New(cors.Config{
@@ -38,10 +47,20 @@ func main() {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
+ authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
+
publicHandler := handlers.NewPublicHandler(store)
adminHandler := handlers.NewAdminHandler(store, cfg)
+ orderHandler := handlers.NewOrderHandler(store, orderStore, authClient)
+ statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
r.GET("/api/products", publicHandler.ListProducts)
+ r.POST("/api/checkout", orderHandler.CreateOrder)
+ r.POST("/api/products/:id/view", publicHandler.RecordProductView)
+ r.GET("/api/stats", statsHandler.GetStats)
+ r.POST("/api/site/visit", statsHandler.RecordVisit)
+ r.GET("/api/orders", orderHandler.ListMyOrders)
+ r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
r.GET("/api/admin/token", adminHandler.GetAdminToken)
r.GET("/api/admin/products", adminHandler.ListAllProducts)
diff --git a/mengyastore-frontend/index.html b/mengyastore-frontend/index.html
index d2ef675..8c1f5dc 100644
--- a/mengyastore-frontend/index.html
+++ b/mengyastore-frontend/index.html
@@ -4,6 +4,8 @@
萌芽小店
+
+
diff --git a/mengyastore-frontend/src/App.vue b/mengyastore-frontend/src/App.vue
index 63a6c44..074066e 100644
--- a/mengyastore-frontend/src/App.vue
+++ b/mengyastore-frontend/src/App.vue
@@ -1,45 +1,174 @@
-
-
+
+
+

+
萌芽小店
-
+
+
+
+
![]()
+
+ {{ (authState.username || authState.account || '?').charAt(0) }}
+
+
{{ authState.username || authState.account }}
+
+
+
+
萌芽账号登录
+
+
+
+
+
+
+
管理员验证
+
请输入管理员令牌以访问后台
+
+
+
+
+
+
+
+
diff --git a/mengyastore-frontend/src/assets/styles.css b/mengyastore-frontend/src/assets/styles.css
index 36d95c2..b5bd1b7 100644
--- a/mengyastore-frontend/src/assets/styles.css
+++ b/mengyastore-frontend/src/assets/styles.css
@@ -45,7 +45,6 @@ p {
display: flex;
flex-direction: column;
min-height: 100vh;
- padding: 28px 5vw 36px;
}
.top-bar {
@@ -53,21 +52,160 @@ p {
align-items: center;
justify-content: space-between;
gap: 20px;
- padding: 20px 26px;
- background: var(--glass);
- border-radius: var(--radius);
- border: 1px solid var(--line);
- box-shadow: var(--shadow);
- backdrop-filter: blur(18px);
+ padding: 16px 5vw;
+ background: var(--glass-strong);
+ border-bottom: 1px solid var(--line);
+ box-shadow: 0 4px 24px rgba(33, 33, 40, 0.08);
+ backdrop-filter: blur(20px);
position: sticky;
- top: 20px;
- z-index: 10;
+ top: 0;
+ z-index: 100;
+}
+
+.app-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 28px 5vw 36px;
}
.brand {
display: flex;
gap: 16px;
align-items: center;
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.admin-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(44, 43, 45, 0.35);
+ backdrop-filter: blur(8px);
+}
+
+.admin-modal {
+ position: relative;
+ width: 360px;
+ max-width: 90vw;
+ padding: 36px 32px 28px;
+ background: var(--glass-strong);
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ box-shadow: 0 24px 60px rgba(33, 33, 40, 0.22);
+ backdrop-filter: blur(20px);
+ text-align: center;
+}
+
+.admin-modal-close {
+ position: absolute;
+ top: 12px;
+ right: 16px;
+ background: none;
+ border: none;
+ font-size: 22px;
+ color: var(--muted);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 8px;
+ line-height: 1;
+ transition: color 0.2s ease;
+}
+
+.admin-modal-close:hover {
+ color: var(--text);
+ transform: none;
+}
+
+.admin-modal-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ color: white;
+ margin-bottom: 16px;
+}
+
+.admin-modal h3 {
+ font-size: 20px;
+ margin-bottom: 6px;
+}
+
+.admin-modal-hint {
+ font-size: 13px;
+ color: var(--muted);
+ margin-bottom: 20px;
+}
+
+.admin-modal-input {
+ width: 100%;
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.65);
+ font-family: 'Source Sans 3', sans-serif;
+ font-size: 15px;
+ text-align: center;
+ letter-spacing: 2px;
+ outline: none;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.admin-modal-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
+}
+
+.admin-modal-error {
+ font-size: 13px;
+ color: #d4566a;
+ margin-top: 8px;
+}
+
+.admin-modal-btn {
+ width: 100%;
+ margin-top: 16px;
+ padding: 12px;
+ font-size: 15px;
+}
+
+.admin-modal-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.modal-enter-active,
+.modal-leave-active {
+ transition: opacity 0.25s ease;
+}
+
+.modal-enter-active .admin-modal,
+.modal-leave-active .admin-modal {
+ transition: transform 0.25s ease, opacity 0.25s ease;
+}
+
+.modal-enter-from,
+.modal-leave-to {
+ opacity: 0;
+}
+
+.modal-enter-from .admin-modal {
+ transform: scale(0.92) translateY(10px);
+ opacity: 0;
+}
+
+.modal-leave-to .admin-modal {
+ transform: scale(0.95);
+ opacity: 0;
}
.brand h1 {
@@ -84,13 +222,87 @@ p {
width: 46px;
height: 46px;
border-radius: 16px;
- background: linear-gradient(140deg, var(--accent), var(--accent-2));
- box-shadow: inset 0 0 18px rgba(255, 255, 255, 0.6);
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ background: var(--glass-strong);
+ box-shadow: 0 12px 24px rgba(33, 33, 40, 0.18);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.brand-mark img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
}
.top-actions {
display: flex;
gap: 12px;
+ align-items: center;
+}
+
+.user-badge {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px 6px 6px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.6);
+ border: 1px solid var(--line);
+ cursor: pointer;
+ transition: background 0.2s ease;
+}
+
+.user-badge:hover {
+ background: rgba(255, 255, 255, 0.85);
+}
+
+.user-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 1px solid var(--line);
+}
+
+.user-avatar-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ color: white;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.user-name {
+ font-size: 13px;
+ color: var(--text);
+ font-weight: 500;
+ max-width: 100px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.login-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 18px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
+ color: white;
+ text-decoration: none;
+ font-family: 'Source Sans 3', sans-serif;
+ font-size: 14px;
+ box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.login-btn:hover {
+ transform: translateY(-1px);
}
button {
@@ -121,23 +333,106 @@ button.ghost {
.main-content {
flex: 1;
padding: 28px 0;
+ min-height: 0;
}
.footer {
+ margin-top: 12px;
+ padding: 24px 26px;
+ background: var(--glass);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ backdrop-filter: blur(16px);
+ box-shadow: 0 -4px 30px rgba(33, 33, 40, 0.06);
+}
+
+.footer-inner {
display: flex;
- justify-content: space-between;
- color: var(--muted);
- font-size: 13px;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+}
+
+.footer-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.footer-logo {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ object-fit: contain;
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ box-shadow: 0 4px 12px rgba(33, 33, 40, 0.1);
+}
+
+.footer-title {
+ font-family: 'Playfair Display', serif;
+ font-size: 17px;
+ font-weight: 600;
+ color: var(--text);
+ letter-spacing: 0.5px;
+}
+
+.footer-divider {
+ width: 40px;
+ height: 2px;
+ border-radius: 2px;
+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
+ opacity: 0.5;
+}
+
+.footer-links {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ flex-wrap: wrap;
+ justify-content: center;
}
.footer-mail {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
color: var(--accent-2);
text-decoration: none;
- font-weight: 600;
+ font-size: 13px;
+ font-weight: 500;
+ padding: 5px 12px;
+ border-radius: 8px;
+ background: rgba(145, 168, 208, 0.08);
+ transition: background 0.2s ease, color 0.2s ease;
}
.footer-mail:hover {
- text-decoration: underline;
+ background: rgba(145, 168, 208, 0.18);
+ color: var(--text);
+}
+
+.footer-stat {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--accent);
+ padding: 5px 12px;
+ border-radius: 8px;
+ background: rgba(180, 154, 203, 0.08);
+}
+
+.footer-stat-visit {
+ color: var(--accent-2);
+ background: rgba(145, 168, 208, 0.08);
+}
+
+.footer-copy {
+ font-size: 12px;
+ color: var(--muted);
+ opacity: 0.7;
+ letter-spacing: 0.3px;
}
.page-card {
@@ -186,6 +481,26 @@ button.ghost {
color: var(--accent-2);
}
+.product-actions {
+ margin-top: 12px;
+}
+
+.secondary-link {
+ display: inline-flex;
+ justify-content: center;
+ padding: 8px 14px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ background: rgba(255, 255, 255, 0.6);
+ color: var(--text);
+ font-size: 13px;
+ transition: background 0.2s ease;
+}
+
+.secondary-link:hover {
+ background: rgba(255, 255, 255, 0.85);
+}
+
.badge {
padding: 4px 10px;
border-radius: 10px;
@@ -284,14 +599,15 @@ button.ghost {
}
@media (max-width: 900px) {
- .app {
+ .app-body {
padding: 18px 4vw 28px;
}
.top-bar {
flex-direction: column;
align-items: flex-start;
- padding: 16px 18px;
+ padding: 14px 4vw;
+ gap: 12px;
}
.grid {
@@ -312,8 +628,12 @@ button.ghost {
}
.footer {
+ padding: 20px 18px;
+ }
+
+ .footer-links {
flex-direction: column;
- gap: 8px;
+ gap: 10px;
}
.page-card {
diff --git a/mengyastore-frontend/src/modules/admin/AdminPage.vue b/mengyastore-frontend/src/modules/admin/AdminPage.vue
index 037924e..598841f 100644
--- a/mengyastore-frontend/src/modules/admin/AdminPage.vue
+++ b/mengyastore-frontend/src/modules/admin/AdminPage.vue
@@ -3,56 +3,94 @@
管理员后台
-
默认地址:/admin?token=shumengya520
+
共 {{ products.length }} 件商品
-
-
-
-
+
-
+
-
{{ message }}
+
{{ message }}
+
{{ message }}
-
-
-
- | 商品 |
- 价格 |
- 库存 |
- 状态 |
- 操作 |
-
-
-
-
- |
- {{ item.name }}
- {{ item.id }}
- |
- ¥ {{ item.price.toFixed(2) }} |
- {{ item.quantity }} |
-
- {{ item.active ? '上架' : '下架' }}
- |
-
-
-
-
-
-
- |
-
-
-
+
+
+
+
+ | 商品 |
+ 价格 |
+ 库存 |
+ 浏览量 |
+ 状态 |
+ 操作 |
+
+
+
+
+
+
+ ![]()
+
+ {{ item.name }}
+ {{ item.id }}
+
+
+ |
+
+
+ 免费
+
+
+ ¥{{ item.price.toFixed(2) }}
+ ¥{{ item.discountPrice.toFixed(2) }}
+
+ ¥{{ item.price.toFixed(2) }}
+ |
+
+
+ {{ item.quantity }}
+
+ |
+
+ {{ item.viewCount || 0 }}
+ |
+
+
+ {{ item.active ? '上架' : '下架' }}
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
@@ -60,7 +98,7 @@
@@ -72,12 +110,13 @@
@@ -85,7 +124,32 @@
+
@@ -80,7 +90,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it'
-import { fetchProducts } from '../shared/api'
+import { fetchProducts, recordProductView } from '../shared/api'
const route = useRoute()
const router = useRouter()
@@ -89,6 +99,16 @@ const loading = ref(true)
const product = ref(null)
const currentImageIndex = ref(0)
+const getPayPrice = (p) => {
+ if (!p) return 0
+ if (p.price === 0) return 0
+ if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
+ return p.price
+}
+
+const unitPrice = computed(() => getPayPrice(product.value))
+const isFree = computed(() => unitPrice.value === 0)
+
const renderMarkdown = (content) => md.render(content || '')
const galleryImages = computed(() => {
@@ -124,8 +144,11 @@ const goBack = () => {
router.push('/')
}
-const buy = () => {
- alert('已加入购买清单(示例)')
+const goCheckout = () => {
+ if (!product.value) {
+ return
+ }
+ router.push(`/checkout/${product.value.id}`)
}
const prevImage = () => {
@@ -157,6 +180,19 @@ onMounted(async () => {
try {
const list = await fetchProducts()
product.value = list.find((item) => item.id === route.params.id) || null
+ if (product.value) {
+ try {
+ const result = await recordProductView(product.value.id)
+ if (typeof result.viewCount === 'number') {
+ product.value = {
+ ...product.value,
+ viewCount: result.viewCount
+ }
+ }
+ } catch (error) {
+ console.warn('record product view failed', error)
+ }
+ }
} finally {
loading.value = false
}
@@ -269,6 +305,11 @@ onMounted(async () => {
color: var(--muted);
}
+.free-price {
+ color: #3a9a68;
+ font-weight: 900;
+}
+
@media (max-width: 900px) {
.detail-thumb {
padding: 6px;
diff --git a/mengyastore-frontend/src/modules/store/StorePage.vue b/mengyastore-frontend/src/modules/store/StorePage.vue
index 912ec96..2af4c54 100644
--- a/mengyastore-frontend/src/modules/store/StorePage.vue
+++ b/mengyastore-frontend/src/modules/store/StorePage.vue
@@ -3,28 +3,83 @@
所有商品
+
+
+
+
+
+
+
+
+
加载中...
-
-
-