feat: sync project
This commit is contained in:
9
mengyastore-backend/.dockerignore
Normal file
9
mengyastore-backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
debug-logs
|
||||
*.exe
|
||||
*.exe~
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
23
mengyastore-backend/Dockerfile
Normal file
23
mengyastore-backend/Dockerfile
Normal file
@@ -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"]
|
||||
119
mengyastore-backend/data/json/orders.json
Normal file
119
mengyastore-backend/data/json/orders.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
3
mengyastore-backend/data/json/site.json
Normal file
3
mengyastore-backend/data/json/site.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"totalVisits": 3
|
||||
}
|
||||
13
mengyastore-backend/docker-compose.yml
Normal file
13
mengyastore-backend/docker-compose.yml
Normal file
@@ -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
|
||||
64
mengyastore-backend/internal/auth/sproutgate.go
Normal file
64
mengyastore-backend/internal/auth/sproutgate.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
AdminToken string `json:"adminToken"`
|
||||
AuthAPIURL string `json:"authApiUrl"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
183
mengyastore-backend/internal/handlers/order.go
Normal file
183
mengyastore-backend/internal/handlers/order.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
52
mengyastore-backend/internal/handlers/stats.go
Normal file
52
mengyastore-backend/internal/handlers/stats.go
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
15
mengyastore-backend/internal/models/order.go
Normal file
15
mengyastore-backend/internal/models/order.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
mengyastore-backend/internal/storage/orderstore.go
Normal file
140
mengyastore-backend/internal/storage/orderstore.go
Normal file
@@ -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
|
||||
}
|
||||
128
mengyastore-backend/internal/storage/sitestore.go
Normal file
128
mengyastore-backend/internal/storage/sitestore.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user