feat: sync project

This commit is contained in:
2026-03-20 20:58:24 +08:00
parent 04bb11dfff
commit 9a6ebe80c5
32 changed files with 3613 additions and 156 deletions

View 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
}

View File

@@ -8,6 +8,7 @@ import (
type Config struct {
AdminToken string `json:"adminToken"`
AuthAPIURL string `json:"authApiUrl"`
}
func Load(path string) (*Config, error) {

View File

@@ -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
}

View 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
}

View File

@@ -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
}

View 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,
},
})
}

View 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"`
}

View File

@@ -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"`
}

View File

@@ -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)
}
}
}

View 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
}

View 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
}