Update mengyastore

This commit is contained in:
2026-05-13 12:11:46 +08:00
parent ad54b1eb7e
commit 37983f3b60
156 changed files with 10064 additions and 5681 deletions

View File

@@ -1,5 +1,6 @@
# Local secrets — use .env.example / .env.production.example as templates # Local secrets — use .env.development / .env.production as templates
.env .env
.env.development
.env.local .env.local
.env.production .env.production
*.exe *.exe

View File

@@ -0,0 +1,3 @@
.PHONY: swagger
swagger:
swag init -g main.go -o docs --parseDependency --parseInternal

View File

@@ -1,5 +1,5 @@
// migrate imports existing JSON data files into the MySQL database. // migrate 将历史上导出的 JSON 数据文件导入 MySQL。
// Run once after switching to DB storage: // 在切到数据库存储方式后执行一次即可:
// //
// go run ./cmd/migrate/main.go // go run ./cmd/migrate/main.go
package main package main
@@ -21,7 +21,7 @@ import (
) )
func main() { func main() {
// Load from env / .env (run from repo root: go run ./cmd/migrate, or set ENV_FILE). // 从环境变量 / .env.development 加载(在仓库根目录执行 go run ./cmd/migrate,或设置 ENV_FILE)。
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
@@ -34,7 +34,7 @@ func main() {
log.Fatalf("open db: %v", err) log.Fatalf("open db: %v", err)
} }
// Ensure tables exist // 确保表结构存在
if err := db.AutoMigrate( if err := db.AutoMigrate(
&database.ProductRow{}, &database.ProductRow{},
&database.ProductCodeRow{}, &database.ProductCodeRow{},
@@ -55,7 +55,7 @@ func main() {
log.Println("✅ 数据导入完成!") log.Println("✅ 数据导入完成!")
} }
// ─── Products ───────────────────────────────────────────────────────────────── // ─── 商品 ─────────────────────────────────────────────────────────────────────
type jsonProduct struct { type jsonProduct struct {
ID string `json:"id"` ID string `json:"id"`
@@ -123,7 +123,7 @@ func migrateProducts(db *gorm.DB) {
log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error) log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error)
continue continue
} }
// Codes → product_codes // 卡密写入 product_codes
for _, code := range p.Codes { for _, code := range p.Codes {
if code == "" { if code == "" {
continue continue
@@ -137,7 +137,7 @@ func migrateProducts(db *gorm.DB) {
log.Printf("[products] 导入 %d 条商品", len(products)) log.Printf("[products] 导入 %d 条商品", len(products))
} }
// ─── Orders ─────────────────────────────────────────────────────────────────── // ─── 订单 ─────────────────────────────────────────────────────────────────────
type jsonOrder struct { type jsonOrder struct {
ID string `json:"id"` ID string `json:"id"`
@@ -201,7 +201,7 @@ func migrateOrders(db *gorm.DB) {
log.Printf("[orders] 导入 %d 条订单", len(orders)) log.Printf("[orders] 导入 %d 条订单", len(orders))
} }
// ─── Wishlists ──────────────────────────────────────────────────────────────── // ─── 收藏夹 ───────────────────────────────────────────────────────────────────
func migrateWishlists(db *gorm.DB) { func migrateWishlists(db *gorm.DB) {
data, err := os.ReadFile("data/json/wishlists.json") data, err := os.ReadFile("data/json/wishlists.json")
@@ -227,7 +227,7 @@ func migrateWishlists(db *gorm.DB) {
log.Printf("[wishlists] 导入 %d 条收藏记录", count) log.Printf("[wishlists] 导入 %d 条收藏记录", count)
} }
// ─── Chats ──────────────────────────────────────────────────────────────────── // ─── 聊天 ─────────────────────────────────────────────────────────────────────
type jsonChatMsg struct { type jsonChatMsg struct {
ID string `json:"id"` ID string `json:"id"`
@@ -269,7 +269,7 @@ func migrateChats(db *gorm.DB) {
log.Printf("[chats] 导入 %d 条聊天消息", count) log.Printf("[chats] 导入 %d 条聊天消息", count)
} }
// ─── Site settings ──────────────────────────────────────────────────────────── // ─── 站点设置 ─────────────────────────────────────────────────────────────────
type jsonSite struct { type jsonSite struct {
TotalVisits int `json:"totalVisits"` TotalVisits int `json:"totalVisits"`

View File

@@ -6,7 +6,7 @@ services:
ports: ports:
- "28081:8080" - "28081:8080"
environment: environment:
# 生产环境(本地开发请使用 APP_ENV=development + 根目录 .env # 生产环境(本地开发请使用 APP_ENV=development + 根目录 .env.development或 ENV_FILE
APP_ENV: production APP_ENV: production
GIN_MODE: release GIN_MODE: release
TZ: Asia/Shanghai TZ: Asia/Shanghai
@@ -14,11 +14,11 @@ services:
DATABASE_DSN: ${DATABASE_DSN:-} DATABASE_DSN: ${DATABASE_DSN:-}
ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme} ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme}
AUTH_API_URL: ${AUTH_API_URL:-} AUTH_API_URL: ${AUTH_API_URL:-}
REDIS_ENABLED: "true" REDIS_ENABLED: "false"
REDIS_ADDR: ${REDIS_ADDR:-127.0.0.1:6379} REDIS_ADDR: ${REDIS_ADDR:-127.0.0.1:6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-} REDIS_PASSWORD: ${REDIS_PASSWORD:-}
REDIS_DB: ${REDIS_DB:-1} REDIS_DB: ${REDIS_DB:-1}
RABBITMQ_ENABLED: "true" RABBITMQ_ENABLED: "false"
RABBITMQ_ENV: prod RABBITMQ_ENV: prod
RABBITMQ_URL: ${RABBITMQ_URL} RABBITMQ_URL: ${RABBITMQ_URL}
restart: unless-stopped restart: unless-stopped

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,26 @@
module mengyastore-backend module mengyastore-backend
go 1.21.0 go 1.23.0
require ( require (
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/rabbitmq/amqp091-go v1.10.0 github.com/rabbitmq/amqp091-go v1.10.0
github.com/redis/go-redis/v9 v9.18.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -22,30 +29,36 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/swaggo/swag v1.8.12 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.22.0 // indirect golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.19.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.34.0 // indirect google.golang.org/protobuf v1.34.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -1,5 +1,15 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -20,10 +30,22 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -36,8 +58,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -47,18 +69,27 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -66,6 +97,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -81,6 +113,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -88,10 +121,19 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -99,24 +141,62 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=

View File

@@ -10,7 +10,7 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
// Config is populated entirely from environment variables (optionally set via .env — see Load). // Config 全部由环境变量填充(可通过工作目录下 .env.development 或 ENV_FILE 设置,见 Load)。
type Config struct { type Config struct {
AppEnv string AppEnv string
AdminToken string AdminToken string
@@ -31,17 +31,26 @@ type Config struct {
RabbitMQEnabled bool RabbitMQEnabled bool
RabbitMQURL string RabbitMQURL string
RabbitMQEnv string RabbitMQEnv string
WebhookMengyaSecret string
PaymentPendingTTLSecs int
// GinDebug为 true 时启用 Gin Debug打印路由表等默认 false日志更干净。
GinDebug bool
// EnableSwagger为 true 时注册 /swagger UI默认在 GIN_DEBUG 或 ENABLE_SWAGGER 为真时为 true。
EnableSwagger bool
} }
// App environment: affects defaults when DATABASE_DSN / RABBITMQ_ENV are omitted. // 应用环境:在未设置 DATABASE_DSN / RABBITMQ_ENV 时决定默认值。
// APP_ENV=production → prod DB default, RABBITMQ_ENV default prod // APP_ENV=production → 生产库 DSN、RABBITMQ_ENV 默认 prod
// Otherwise → development defaults (test DB, rabbit dev). // 否则 → 开发默认测试库、RabbitMQ 开发环境)。
const ( const (
EnvDevelopment = "development" EnvDevelopment = "development"
EnvProduction = "production" EnvProduction = "production"
) )
// Built-in DSN fallbacks when DATABASE_DSN is empty. // 内建 DSN DATABASE_DSN 为空时使用。
const ( const (
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local" TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local" ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
@@ -60,26 +69,33 @@ const (
DefaultRedisPassword = "tyh@19900420" DefaultRedisPassword = "tyh@19900420"
) )
// Load reads optional .env file(s) then builds Config from the process environment. // Load 读取可选的 env 文件,再从进程环境组装 Config。
// //
// Dotenv resolution order: // 解析 env 文件的顺序:
// 1. File named by ENV_FILE (if set) // 1. 环境变量 ENV_FILE 指定的文件(若设置了)
// 2. .env in current working directory // 2. 当前工作目录下的 .env.development不存在则忽略
// //
// Variables (all optional unless noted): // 环境变量说明(除非注明否则均可选):
// //
// APP_ENV — development | production (default: development) // APP_ENV — development | production(默认 development
// ADMIN_TOKEN — admin API token (default: changeme) // ADMIN_TOKEN — 管理端 API 令牌(默认 changeme
// AUTH_API_URL — SproutGate base URL // AUTH_API_URL — 萌芽认证中心 SproutGate URL
// DATABASE_DSN — MySQL DSN; if empty, uses TestDSN or ProdDSN from APP_ENV // DATABASE_DSN — MySQL DSN;为空则按 APP_ENV 选用 TestDSN / ProdDSN
// RABBITMQ_ENABLED — true/false // RABBITMQ_ENABLED — true/false
// RABBITMQ_URL — full amqp URL // RABBITMQ_URL — 完整 amqp URL
// RABBITMQ_ENV — dev | prod (default from APP_ENV) // RABBITMQ_ENV — dev | prod(默认随 APP_ENV
// RABBITMQ_PASSWORD — if RABBITMQ_URL empty but RABBITMQ_ENABLED, builds URL with host/vhost defaults // RABBITMQ_PASSWORD — 若已启用 MQ 但未设 RABBITMQ_URL则用默认主机/vhost 拼 URL 时需要口令
// //
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080 // HTTP_LISTEN_ADDR — 进程监听,默认 :8080
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景) // PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
// //
// WEBHOOK_MENGYA_SECRET — 若设置,到账 Webhook 须带相同请求头 X-Webhook-Secret
// PAYMENT_PENDING_SECONDS — 萌芽支付待支付超时(秒,默认 60
//
// GIN_DEBUG — 1/true/yes/onGin Debug默认关闭为真时同时开启 Swagger UI/swagger
// ENABLE_SWAGGER — 1/true/yes/on启用 Swagger UI与 GIN_DEBUG 任一为真即注册 /swagger
// WEBHOOK_LOG_VERBOSE — 1/true萌芽 Webhook 日志附带 notice/body 摘要(排查渠道用)
//
// REDIS_ENABLED — 默认 true显式 false/0/off 则关闭 // REDIS_ENABLED — 默认 true显式 false/0/off 则关闭
// REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr // REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr
// REDIS_PASSWORD — 默认 DefaultRedisPassword建议生产用环境变量覆盖 // REDIS_PASSWORD — 默认 DefaultRedisPassword建议生产用环境变量覆盖
@@ -168,10 +184,34 @@ func Load() (*Config, error) {
} }
} }
cfg.WebhookMengyaSecret = strings.TrimSpace(os.Getenv("WEBHOOK_MENGYA_SECRET"))
if sec := strings.TrimSpace(os.Getenv("PAYMENT_PENDING_SECONDS")); sec != "" {
if n, err := strconv.Atoi(sec); err == nil && n > 0 {
cfg.PaymentPendingTTLSecs = n
}
}
if cfg.PaymentPendingTTLSecs <= 0 {
cfg.PaymentPendingTTLSecs = 60
}
cfg.GinDebug = envTruthy(os.Getenv("GIN_DEBUG"))
cfg.EnableSwagger = cfg.GinDebug || envTruthy(os.Getenv("ENABLE_SWAGGER"))
return cfg, nil return cfg, nil
} }
// redisEnabledFromEnv defaults to true unless explicitly turned off. // envTruthy将 1、true、yes、on 视为真(不区分大小写)。
func envTruthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// redisEnabledFromEnv除非显式关闭否则默认启用 Redis。
func redisEnabledFromEnv(v string) bool { func redisEnabledFromEnv(v string) bool {
s := strings.ToLower(strings.TrimSpace(v)) s := strings.ToLower(strings.TrimSpace(v))
if s == "false" || s == "0" || s == "no" || s == "off" { if s == "false" || s == "0" || s == "no" || s == "off" {
@@ -188,8 +228,8 @@ func loadDotenv() {
_ = godotenv.Load(f) _ = godotenv.Load(f)
return return
} }
// Standard local file; ignore missing. // 本地开发默认文件名;不存在则忽略。
_ = godotenv.Load(filepath.Clean(".env")) _ = godotenv.Load(filepath.Clean(".env.development"))
} }
func normalizeAppEnv(s string) string { func normalizeAppEnv(s string) string {

View File

@@ -2,6 +2,8 @@ package database
import ( import (
"log" "log"
"log/slog"
"os"
"time" "time"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
@@ -11,8 +13,17 @@ import (
// Open 初始化 GORM 数据库连接并自动同步所有表结构。 // Open 初始化 GORM 数据库连接并自动同步所有表结构。
func Open(dsn string) (*gorm.DB, error) { func Open(dsn string) (*gorm.DB, error) {
gormLogger := logger.New(
log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds),
logger.Config{
SlowThreshold: 500 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), Logger: gormLogger,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -29,7 +40,7 @@ func Open(dsn string) (*gorm.DB, error) {
if err := autoMigrate(db); err != nil { if err := autoMigrate(db); err != nil {
return nil, err return nil, err
} }
log.Println("[DB] 数据库连接成功,表结构已同步") slog.Info("db", "event", "ready", "migrate", "ok")
return db, nil return db, nil
} }

View File

@@ -7,7 +7,7 @@ import (
"time" "time"
) )
// StringSlice is a JSON-serialized string slice stored as a MySQL TEXT/JSON column. // StringSlice 表示以 JSON 序列化形式存入 MySQL TEXT/JSON 列的字符串切片。
type StringSlice []string type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) { func (s StringSlice) Value() (driver.Value, error) {
@@ -31,9 +31,9 @@ func (s *StringSlice) Scan(src any) error {
return json.Unmarshal(raw, s) return json.Unmarshal(raw, s)
} }
// ─── Products ──────────────────────────────────────────────────────────────── // ─── 商品 ─────────────────────────────────────────────────────────────────────
// ProductRow is the GORM model for the `products` table. // ProductRow `products` 表的 GORM 模型。
type ProductRow struct { type ProductRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`
Name string `gorm:"size:255;not null"` Name string `gorm:"size:255;not null"`
@@ -55,12 +55,14 @@ type ProductRow struct {
FixedContent string `gorm:"type:text"` FixedContent string `gorm:"type:text"`
ShowNote bool `gorm:"default:true"` ShowNote bool `gorm:"default:true"`
ShowContact bool `gorm:"default:true"` ShowContact bool `gorm:"default:true"`
// PaymentQrURLs JSON萌芽支付等多张收款码图片链接。
PaymentQrURLs StringSlice `gorm:"column:payment_qr_urls;type:json"`
CreatedAt time.Time `gorm:"index"` CreatedAt time.Time `gorm:"index"`
} }
func (ProductRow) TableName() string { return "products" } func (ProductRow) TableName() string { return "products" }
// ProductCodeRow stores individual codes for a product (one row per code). // ProductCodeRow 单条卡密一行,按商品维度存储。
type ProductCodeRow struct { type ProductCodeRow struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
ProductID string `gorm:"size:36;not null;index"` ProductID string `gorm:"size:36;not null;index"`
@@ -69,7 +71,7 @@ type ProductCodeRow struct {
func (ProductCodeRow) TableName() string { return "product_codes" } func (ProductCodeRow) TableName() string { return "product_codes" }
// ─── Orders ────────────────────────────────────────────────────────────────── // ─── 订单 ─────────────────────────────────────────────────────────────────────
type OrderRow struct { type OrderRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`
@@ -85,14 +87,20 @@ type OrderRow struct {
ContactPhone string `gorm:"size:50"` ContactPhone string `gorm:"size:50"`
ContactEmail string `gorm:"size:255"` ContactEmail string `gorm:"size:255"`
NotifyEmail string `gorm:"size:255"` NotifyEmail string `gorm:"size:255"`
// PaymentMethod 如 mengya历史订单或免费单可能为空。
PaymentMethod string `gorm:"size:32;default:'';index"`
// PaymentExpectedTotal 待支付时应付快照(元),用于 Webhook 金额核对。
PaymentExpectedTotal float64 `gorm:"default:0"`
// PaymentExpiresAt 待支付截止时间;非待支付订单为 NULL。
PaymentExpiresAt *time.Time `gorm:"index"`
CreatedAt time.Time CreatedAt time.Time
} }
func (OrderRow) TableName() string { return "orders" } func (OrderRow) TableName() string { return "orders" }
// ─── Site settings ─────────────────────────────────────────────────────────── // ─── 站点设置 ─────────────────────────────────────────────────────────────────
// SiteSettingRow stores arbitrary key-value pairs for site-wide settings. // SiteSettingRow 站点级键值配置。
type SiteSettingRow struct { type SiteSettingRow struct {
Key string `gorm:"primaryKey;size:64"` Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
@@ -100,7 +108,7 @@ type SiteSettingRow struct {
func (SiteSettingRow) TableName() string { return "site_settings" } func (SiteSettingRow) TableName() string { return "site_settings" }
// ─── Wishlists ─────────────────────────────────────────────────────────────── // ─── 收藏夹 ───────────────────────────────────────────────────────────────────
type WishlistRow struct { type WishlistRow struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
@@ -110,7 +118,7 @@ type WishlistRow struct {
func (WishlistRow) TableName() string { return "wishlists" } func (WishlistRow) TableName() string { return "wishlists" }
// ─── Chat messages ─────────────────────────────────────────────────────────── // ─── 聊天消息 ─────────────────────────────────────────────────────────────────
type ChatMessageRow struct { type ChatMessageRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`

View File

@@ -8,6 +8,14 @@ import (
) )
// GetAllConversations 返回所有用户会话列表。 // GetAllConversations 返回所有用户会话列表。
// @Summary 全部会话列表
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat [get]
func (h *AdminHandler) GetAllConversations(c *gin.Context) { func (h *AdminHandler) GetAllConversations(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -21,6 +29,16 @@ func (h *AdminHandler) GetAllConversations(c *gin.Context) {
} }
// GetConversation 返回指定账号的全部消息记录。 // GetConversation 返回指定账号的全部消息记录。
// @Summary 指定用户聊天记录
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Success 200 {object} SwaggerMessagesWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [get]
func (h *AdminHandler) GetConversation(c *gin.Context) { func (h *AdminHandler) GetConversation(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -38,11 +56,23 @@ func (h *AdminHandler) GetConversation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
} }
type adminChatPayload struct { type AdminChatPayload struct {
Content string `json:"content"` Content string `json:"content"`
} }
// AdminReply 向指定用户发送管理员回复。 // AdminReply 向指定用户发送管理员回复。
// @Summary 管理员回复
// @Tags 管理端-聊天
// @Accept json
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Param body body AdminChatPayload true "回复内容"
// @Success 200 {object} SwaggerOneChatMsgWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [post]
func (h *AdminHandler) AdminReply(c *gin.Context) { func (h *AdminHandler) AdminReply(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -52,7 +82,7 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return return
} }
var payload adminChatPayload var payload AdminChatPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
@@ -71,6 +101,16 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
} }
// ClearConversation 清除与指定用户的全部消息记录。 // ClearConversation 清除与指定用户的全部消息记录。
// @Summary 清空会话
// @Tags 管理端-聊天
// @Produce json
// @Security AdminToken
// @Param account path string true "用户账号"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/chat/{account} [delete]
func (h *AdminHandler) ClearConversation(c *gin.Context) { func (h *AdminHandler) ClearConversation(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return

View File

@@ -6,6 +6,15 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ListAllOrders 管理端全部订单。
// @Summary 全部订单(管理)
// @Tags 管理端-订单
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerOrdersBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/orders [get]
func (h *AdminHandler) ListAllOrders(c *gin.Context) { func (h *AdminHandler) ListAllOrders(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -18,6 +27,17 @@ func (h *AdminHandler) ListAllOrders(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": orders}) c.JSON(http.StatusOK, gin.H{"data": orders})
} }
// DeleteOrder 管理端删除订单。
// @Summary 删除订单
// @Tags 管理端-订单
// @Produce json
// @Security AdminToken
// @Param id path string true "订单 ID"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/orders/{id} [delete]
func (h *AdminHandler) DeleteOrder(c *gin.Context) { func (h *AdminHandler) DeleteOrder(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return

View File

@@ -9,7 +9,7 @@ import (
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
type productPayload struct { type ProductPayload struct {
Name string `json:"name"` Name string `json:"name"`
Price float64 `json:"price"` Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"` DiscountPrice float64 `json:"discountPrice"`
@@ -17,6 +17,7 @@ type productPayload struct {
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"` Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"` ScreenshotURLs []string `json:"screenshotUrls"`
PaymentQrURLs []string `json:"paymentQrUrls"`
Description string `json:"description"` Description string `json:"description"`
Active *bool `json:"active"` Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"` RequireLogin bool `json:"requireLogin"`
@@ -28,7 +29,7 @@ type productPayload struct {
ShowContact bool `json:"showContact"` ShowContact bool `json:"showContact"`
} }
func normalizeFulfillmentPayload(payload *productPayload) string { func normalizeFulfillmentPayload(payload *ProductPayload) string {
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType)) ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
if ft == "fixed" { if ft == "fixed" {
return "fixed" return "fixed"
@@ -36,16 +37,26 @@ func normalizeFulfillmentPayload(payload *productPayload) string {
return "card" return "card"
} }
type togglePayload struct { type TogglePayload struct {
Active bool `json:"active"` Active bool `json:"active"`
} }
// VerifyAdminToken checks whether the supplied token is correct. // AdminVerifyTokenRequest 管理端校验令牌Body非 Header
// Returns {"valid": true/false} without leaking the real token value. type AdminVerifyTokenRequest struct {
Token string `json:"token"`
}
// VerifyAdminToken 校验请求中的令牌是否正确。
// @Summary 校验管理员令牌
// @Description 响应为 {"valid": true/false},不泄露真实口令。
// @Tags 管理端-认证
// @Accept json
// @Produce json
// @Param body body AdminVerifyTokenRequest true "待校验 token"
// @Success 200 {object} SwaggerValidBody
// @Router /api/admin/verify [post]
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) { func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct { var payload AdminVerifyTokenRequest
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" { if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false}) c.JSON(http.StatusOK, gin.H{"valid": false})
return return
@@ -53,6 +64,15 @@ func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken}) c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
} }
// ListAllProducts 管理端商品全量列表(含下架与卡密等敏感字段)。
// @Summary 全部商品(管理)
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerProductListBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [get]
func (h *AdminHandler) ListAllProducts(c *gin.Context) { func (h *AdminHandler) ListAllProducts(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -65,18 +85,35 @@ func (h *AdminHandler) ListAllProducts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items}) c.JSON(http.StatusOK, gin.H{"data": items})
} }
// CreateProduct 创建商品。
// @Summary 创建商品
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body ProductPayload true "商品字段"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [post]
func (h *AdminHandler) CreateProduct(c *gin.Context) { func (h *AdminHandler) CreateProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
var payload productPayload var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid { if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"}) c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return return
} }
active := true active := true
@@ -100,6 +137,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL), CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes, Codes: payload.Codes,
ScreenshotURLs: screenshotURLs, ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description, Description: payload.Description,
Active: active, Active: active,
RequireLogin: payload.RequireLogin, RequireLogin: payload.RequireLogin,
@@ -118,19 +156,38 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": created}) c.JSON(http.StatusOK, gin.H{"data": created})
} }
// UpdateProduct 更新商品。
// @Summary 更新商品
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Param body body ProductPayload true "商品字段"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [put]
func (h *AdminHandler) UpdateProduct(c *gin.Context) { func (h *AdminHandler) UpdateProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
id := c.Param("id") id := c.Param("id")
var payload productPayload var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid { if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"}) c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return return
} }
active := false active := false
@@ -154,6 +211,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL), CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes, Codes: payload.Codes,
ScreenshotURLs: screenshotURLs, ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description, Description: payload.Description,
Active: active, Active: active,
RequireLogin: payload.RequireLogin, RequireLogin: payload.RequireLogin,
@@ -172,12 +230,25 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": updated}) c.JSON(http.StatusOK, gin.H{"data": updated})
} }
// ToggleProduct 上架/下架切换。
// @Summary 切换上架状态
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Param body body TogglePayload true "{active}"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/admin/products/{id}/status [patch]
func (h *AdminHandler) ToggleProduct(c *gin.Context) { func (h *AdminHandler) ToggleProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
id := c.Param("id") id := c.Param("id")
var payload togglePayload var payload TogglePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
@@ -190,6 +261,16 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": updated}) c.JSON(http.StatusOK, gin.H{"data": updated})
} }
// DeleteProduct 删除商品。
// @Summary 删除商品
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [delete]
func (h *AdminHandler) DeleteProduct(c *gin.Context) { func (h *AdminHandler) DeleteProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -202,6 +283,26 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
} }
const maxScreenshotURLsAdmin = 6
const maxPaymentQrURLsAdmin = 6
func normalizePaymentQrURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
seen := map[string]bool{}
for _, u := range urls {
trimmed := strings.TrimSpace(u)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
if len(cleaned) > maxPaymentQrURLsAdmin {
return nil, false
}
}
return cleaned, true
}
func normalizeScreenshotURLs(urls []string) ([]string, bool) { func normalizeScreenshotURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls)) cleaned := make([]string, 0, len(urls))
for _, url := range urls { for _, url := range urls {
@@ -210,7 +311,7 @@ func normalizeScreenshotURLs(urls []string) ([]string, bool) {
continue continue
} }
cleaned = append(cleaned, trimmed) cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 { if len(cleaned) > maxScreenshotURLsAdmin {
return nil, false return nil, false
} }
} }

View File

@@ -8,16 +8,28 @@ import (
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
type maintenancePayload struct { type MaintenancePayload struct {
Maintenance bool `json:"maintenance"` Maintenance bool `json:"maintenance"`
Reason string `json:"reason"` Reason string `json:"reason"`
} }
// SetMaintenance 设置站点维护模式。
// @Summary 设置维护模式
// @Tags 管理端-站点
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body MaintenancePayload true "维护开关与原因"
// @Success 200 {object} SwaggerMaintenanceWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/maintenance [post]
func (h *AdminHandler) SetMaintenance(c *gin.Context) { func (h *AdminHandler) SetMaintenance(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
var payload maintenancePayload var payload MaintenancePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
@@ -34,6 +46,15 @@ func (h *AdminHandler) SetMaintenance(c *gin.Context) {
}) })
} }
// GetSMTPConfig 获取 SMTP 配置(密码脱敏)。
// @Summary 获取 SMTP 配置
// @Tags 管理端-站点
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerSMTPWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/smtp [get]
func (h *AdminHandler) GetSMTPConfig(c *gin.Context) { func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
@@ -51,6 +72,18 @@ func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": masked}) c.JSON(http.StatusOK, gin.H{"data": masked})
} }
// SetSMTPConfig 保存 SMTP 配置。
// @Summary 保存 SMTP 配置
// @Tags 管理端-站点
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body storage.SMTPConfig true "SMTP 字段"
// @Success 200 {object} SwaggerStringOKBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/site/smtp [post]
func (h *AdminHandler) SetSMTPConfig(c *gin.Context) { func (h *AdminHandler) SetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return

View File

@@ -17,7 +17,7 @@ import (
"mengyastore-backend/internal/mq" "mengyastore-backend/internal/mq"
) )
// SystemStatusHandler exposes admin-only operational status. // SystemStatusHandler 向管理端暴露运维状态(仅供管理员)。
type SystemStatusHandler struct { type SystemStatusHandler struct {
cfg *config.Config cfg *config.Config
db *gormDB.DB db *gormDB.DB
@@ -29,7 +29,14 @@ func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Clie
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt} return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
} }
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token. // GetSystemStatus 返回管理后台用 JSON需通过管理员令牌。
// @Summary 系统运行状态
// @Tags 管理端-系统
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} SwaggerErrorBody
// @Router /api/admin/system-status [get]
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) { func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
if !h.adminTokenOK(c) { if !h.adminTokenOK(c) {
return return
@@ -278,7 +285,7 @@ func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
return out return out
} }
// parseAMQPBroker returns host, port, vhost, user without password (for display only). // parseAMQPBroker 从 amqp URL 解析主机、端口、vhost、用户名不含口令仅展示用
func parseAMQPBroker(raw string) (host, port, vhost, user string) { func parseAMQPBroker(raw string) (host, port, vhost, user string) {
if raw == "" { if raw == "" {
return "", "", "", "" return "", "", "", ""

View File

@@ -35,6 +35,14 @@ func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok
} }
// GetMyMessages 返回当前登录用户的全部聊天消息。 // GetMyMessages 返回当前登录用户的全部聊天消息。
// @Summary 我的聊天消息
// @Tags 聊天(用户)
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwaggerMessagesWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/chat/messages [get]
func (h *ChatHandler) GetMyMessages(c *gin.Context) { func (h *ChatHandler) GetMyMessages(c *gin.Context) {
account, _, ok := h.requireChatUser(c) account, _, ok := h.requireChatUser(c)
if !ok { if !ok {
@@ -48,17 +56,30 @@ func (h *ChatHandler) GetMyMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
} }
type chatMessagePayload struct { // ChatMessagePayload 用户发送消息请求体。
type ChatMessagePayload struct {
Content string `json:"content"` Content string `json:"content"`
} }
// SendMyMessage 向管理员发送一条用户消息。 // SendMyMessage 向管理员发送一条用户消息。
// @Summary 发送用户消息
// @Tags 聊天(用户)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body ChatMessagePayload true "消息正文"
// @Success 200 {object} SwaggerOneChatMsgWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 429 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/chat/messages [post]
func (h *ChatHandler) SendMyMessage(c *gin.Context) { func (h *ChatHandler) SendMyMessage(c *gin.Context) {
account, name, ok := h.requireChatUser(c) account, name, ok := h.requireChatUser(c)
if !ok { if !ok {
return return
} }
var payload chatMessagePayload var payload ChatMessagePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,13 @@ func NewPublicHandler(store *storage.ProductStore) *PublicHandler {
return &PublicHandler{store: store} return &PublicHandler{store: store}
} }
// ListProducts 上架商品列表(公开字段,不含卡密与管理信息)。
// @Summary 上架商品列表
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerProductListBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/products [get]
func (h *PublicHandler) ListProducts(c *gin.Context) { func (h *PublicHandler) ListProducts(c *gin.Context) {
items, err := h.store.ListActive() items, err := h.store.ListActive()
if err != nil { if err != nil {
@@ -27,6 +34,14 @@ func (h *PublicHandler) ListProducts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": sanitizeForPublic(items)}) c.JSON(http.StatusOK, gin.H{"data": sanitizeForPublic(items)})
} }
// RecordProductView 记录商品浏览(去重策略由服务端指纹决定)。
// @Summary 记录商品浏览
// @Tags 公开
// @Produce json
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerProductViewWrap
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/products/{id}/view [post]
func (h *PublicHandler) RecordProductView(c *gin.Context) { func (h *PublicHandler) RecordProductView(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
fingerprint := buildViewerFingerprint(c) fingerprint := buildViewerFingerprint(c)

View File

@@ -17,6 +17,13 @@ func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStor
return &StatsHandler{orderStore: orderStore, siteStore: siteStore} return &StatsHandler{orderStore: orderStore, siteStore: siteStore}
} }
// GetStats 订单总数与站点访问总数。
// @Summary 站点统计
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerStatsWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/stats [get]
func (h *StatsHandler) GetStats(c *gin.Context) { func (h *StatsHandler) GetStats(c *gin.Context) {
totalOrders, err := h.orderStore.Count() totalOrders, err := h.orderStore.Count()
if err != nil { if err != nil {
@@ -36,6 +43,13 @@ func (h *StatsHandler) GetStats(c *gin.Context) {
}) })
} }
// RecordVisit 记录一次站点访问并返回累计访问量。
// @Summary 记录站点访问
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerVisitWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/site/visit [post]
func (h *StatsHandler) RecordVisit(c *gin.Context) { func (h *StatsHandler) RecordVisit(c *gin.Context) {
fingerprint := buildViewerFingerprint(c) fingerprint := buildViewerFingerprint(c)
totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint) totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint)
@@ -51,6 +65,13 @@ func (h *StatsHandler) RecordVisit(c *gin.Context) {
}) })
} }
// GetMaintenance 当前维护模式与原因。
// @Summary 维护模式状态
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerMaintenanceWrap
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/site/maintenance [get]
func (h *StatsHandler) GetMaintenance(c *gin.Context) { func (h *StatsHandler) GetMaintenance(c *gin.Context) {
enabled, reason, err := h.siteStore.GetMaintenance() enabled, reason, err := h.siteStore.GetMaintenance()
if err != nil { if err != nil {

View File

@@ -0,0 +1,162 @@
package handlers
import (
"time"
"mengyastore-backend/internal/models"
"mengyastore-backend/internal/storage"
)
// 以下类型仅用于 swag/OpenAPI 描述,与实际 handler 返回值形状对齐。
type SwaggerErrorBody struct {
Error string `json:"error"`
}
type SwaggerValidBody struct {
Valid bool `json:"valid"`
}
type SwaggerProductListBody struct {
Data []models.Product `json:"data"`
}
type SwaggerProductOneBody struct {
Data models.Product `json:"data"`
}
type SwaggerOrdersBody struct {
Data []models.Order `json:"data"`
}
type SwaggerSMTPWrap struct {
Data storage.SMTPConfig `json:"data"`
}
type SwaggerStringOKBody struct {
Data string `json:"data"`
}
type SwaggerBoolOKWrap struct {
Data SwaggerBoolOK `json:"data"`
}
type SwaggerBoolOK struct {
OK bool `json:"ok"`
}
type SwaggerProductViewWrap struct {
Data SwaggerProductViewData `json:"data"`
}
type SwaggerProductViewData struct {
ID string `json:"id"`
ViewCount int `json:"viewCount"`
Counted bool `json:"counted"`
}
type SwaggerStatsWrap struct {
Data SwaggerStatsData `json:"data"`
}
type SwaggerStatsData struct {
TotalOrders int `json:"totalOrders"`
TotalVisits int `json:"totalVisits"`
}
type SwaggerVisitWrap struct {
Data SwaggerVisitData `json:"data"`
}
type SwaggerVisitData struct {
TotalVisits int `json:"totalVisits"`
Counted bool `json:"counted"`
}
type SwaggerMaintenanceWrap struct {
Data SwaggerMaintenanceData `json:"data"`
}
type SwaggerMaintenanceData struct {
Maintenance bool `json:"maintenance"`
Reason string `json:"reason"`
}
type SwaggerCheckoutWrap struct {
Data SwaggerCheckoutData `json:"data"`
}
type SwaggerCheckoutData struct {
OrderID string `json:"orderId"`
QrCodeURL string `json:"qrCodeUrl"`
ProductID string `json:"productId"`
ProductQty int `json:"productQty"`
ViewCount int `json:"viewCount"`
Status string `json:"status"`
PaymentMethod string `json:"paymentMethod"`
PaymentExpectedTotal float64 `json:"paymentExpectedTotal,omitempty"`
PaymentQrURLs []string `json:"paymentQrUrls,omitempty"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt,omitempty"`
}
type SwaggerConfirmWrap struct {
Data SwaggerConfirmData `json:"data"`
}
type SwaggerConfirmData struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
DeliveryMode string `json:"deliveryMode"`
DeliveredCodes []string `json:"deliveredCodes"`
IsManual bool `json:"isManual"`
}
type SwaggerPaymentStatusWrap struct {
Data SwaggerPaymentStatusData `json:"data"`
}
type SwaggerPaymentStatusData struct {
Status string `json:"status"`
ExpectedTotal float64 `json:"expectedTotal"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt"`
PaymentMethod string `json:"paymentMethod"`
}
type SwaggerWebhookMengyaResp struct {
OK bool `json:"ok"`
Matched bool `json:"matched"`
MatchedOrderID string `json:"matched_order_id,omitempty"`
}
type SwaggerCancelWrap struct {
Data SwaggerCancelMsg `json:"data"`
}
type SwaggerCancelMsg struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
type SwaggerWishlistWrap struct {
Data SwaggerWishlistItems `json:"data"`
}
type SwaggerWishlistItems struct {
Items []string `json:"items"`
}
type SwaggerMessagesWrap struct {
Data SwaggerMessagesInner `json:"data"`
}
type SwaggerMessagesInner struct {
Messages []models.ChatMessage `json:"messages"`
}
type SwaggerOneChatMsgWrap struct {
Data SwaggerSingleChatWrap `json:"data"`
}
type SwaggerSingleChatWrap struct {
Message models.ChatMessage `json:"message"`
}

View File

@@ -34,6 +34,15 @@ func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
return result.User.Account, true return result.User.Account, true
} }
// GetWishlist 当前用户收藏的商品 ID 列表。
// @Summary 收藏列表
// @Tags 收藏
// @Produce json
// @Security BearerAuth
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist [get]
func (h *WishlistHandler) GetWishlist(c *gin.Context) { func (h *WishlistHandler) GetWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {
@@ -47,16 +56,29 @@ func (h *WishlistHandler) GetWishlist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
} }
type wishlistItemPayload struct { // WishlistItemPayload 添加收藏请求体。
type WishlistItemPayload struct {
ProductID string `json:"productId"` ProductID string `json:"productId"`
} }
// AddToWishlist 添加收藏。
// @Summary 添加收藏
// @Tags 收藏
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body WishlistItemPayload true "商品 ID"
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist [post]
func (h *WishlistHandler) AddToWishlist(c *gin.Context) { func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {
return return
} }
var payload wishlistItemPayload var payload WishlistItemPayload
if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" { if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
@@ -69,6 +91,17 @@ func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
} }
// RemoveFromWishlist 按商品 ID 移除收藏。
// @Summary 移除收藏
// @Tags 收藏
// @Produce json
// @Security BearerAuth
// @Param id path string true "商品 ID"
// @Success 200 {object} SwaggerWishlistWrap
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/wishlist/{id} [delete]
func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) { func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {

View File

@@ -16,5 +16,8 @@ type Order struct {
ContactPhone string `json:"contactPhone"` ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"` ContactEmail string `json:"contactEmail"`
NotifyEmail string `json:"notifyEmail"` NotifyEmail string `json:"notifyEmail"`
PaymentMethod string `json:"paymentMethod,omitempty"`
PaymentExpectedTotal float64 `json:"paymentExpectedTotal,omitempty"`
PaymentExpiresAt *time.Time `json:"paymentExpiresAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }

View File

@@ -11,6 +11,8 @@ type Product struct {
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
ScreenshotURLs []string `json:"screenshotUrls"` ScreenshotURLs []string `json:"screenshotUrls"`
// PaymentQrURLs 萌芽支付收款码图片链接(可多条)。
PaymentQrURLs []string `json:"paymentQrUrls"`
VerificationURL string `json:"verificationUrl"` VerificationURL string `json:"verificationUrl"`
Codes []string `json:"codes"` Codes []string `json:"codes"`
ViewCount int `json:"viewCount"` ViewCount int `json:"viewCount"`

View File

@@ -15,7 +15,7 @@ import (
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
// Client manages a single AMQP connection, a publish channel, and naming for one env. // Client 封装单条 AMQP 连接、发布用 Channel以及单环境的命名。
type Client struct { type Client struct {
conn *amqp.Connection conn *amqp.Connection
pubCh *amqp.Channel pubCh *amqp.Channel
@@ -32,7 +32,7 @@ type Client struct {
consumerSite *storage.SiteStore consumerSite *storage.SiteStore
} }
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent). // New 连接 RabbitMQ 并声明交换机 + 队列 + 绑定(幂等)。
func New(amqpURL, env string) (*Client, error) { func New(amqpURL, env string) (*Client, error) {
if amqpURL == "" { if amqpURL == "" {
return nil, fmt.Errorf("empty amqp url") return nil, fmt.Errorf("empty amqp url")
@@ -141,7 +141,7 @@ func (c *Client) passiveQueueLocked() error {
return err return err
} }
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key. // PublishOrderEmail 向 order-email 路由键发布一条持久化 JSON 消息。
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error { func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
body, err := json.Marshal(p) body, err := json.Marshal(p)
if err != nil { if err != nil {
@@ -190,7 +190,7 @@ func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) err
return nil return nil
} }
// QueueInspectInfo returns current queue depth and consumer count (passive declare). // QueueInspectInfo 返回当前队列深度与消费者数(被动声明查询)。
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) { func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -216,7 +216,7 @@ func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
return msgs, cons, err return msgs, cons, err
} }
// Ping checks that the publish channel can query the declared queue (liveness for /api/health). // Ping 检查发布 Channel 能否对声明的队列做被动查询(供 /api/health 探活)。
func (c *Client) Ping() error { func (c *Client) Ping() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -231,10 +231,10 @@ func (c *Client) Ping() error {
return err return err
} }
// Env returns the sanitized environment suffix used in exchange/queue names. // Env 返回交换机/队列名使用的环境后缀(已规范化)。
func (c *Client) Env() string { return c.env } func (c *Client) Env() string { return c.env }
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine). // StartConsumer 在 ctx 取消前运行订单邮件消费者(通常在 goroutine 中调用)。
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) { func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
c.mu.Lock() c.mu.Lock()
c.consumerCtx = ctx c.consumerCtx = ctx
@@ -248,7 +248,7 @@ func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
RunOrderEmailConsumer(ctx, conn, env, site) RunOrderEmailConsumer(ctx, conn, env, site)
} }
// Close releases the publish channel and connection. // Close 关闭发布 Channel 与连接。
func (c *Client) Close() { func (c *Client) Close() {
c.closing.Do(func() { c.closing.Do(func() {
c.mu.Lock() c.mu.Lock()

View File

@@ -11,7 +11,7 @@ import (
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine. // RunOrderEmailConsumer 运行至 ctx 结束,请在独立 goroutine 中启动。
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) { func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
if conn == nil || site == nil { if conn == nil || site == nil {
return return

View File

@@ -2,7 +2,7 @@ package mq
import "mengyastore-backend/internal/email" import "mengyastore-backend/internal/email"
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets). // OrderEmailPayload 发布到 RabbitMQ 的订单邮件 JSON 负载(不含 SMTP 口令)。
type OrderEmailPayload struct { type OrderEmailPayload struct {
ToEmail string `json:"toEmail"` ToEmail string `json:"toEmail"`
ToName string `json:"toName"` ToName string `json:"toName"`

View File

@@ -9,15 +9,15 @@ import (
const routingKeyOrderEmail = "order.email.notify" const routingKeyOrderEmail = "order.email.notify"
// OrderEmailRoutingKey is the binding / publish key for order notification messages. // OrderEmailRoutingKey 订单通知消息的绑定键 / 发布 routing key。
func OrderEmailRoutingKey() string { return routingKeyOrderEmail } func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker). // ExchangeName 返回本应用 + 环境对应的持久化 topic 交换机名(与 Broker 上其它应用隔离)。
func ExchangeName(env string) string { func ExchangeName(env string) string {
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env)) return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
} }
// QueueName returns the durable order-email queue name for this env. // QueueName 返回本环境下持久化的订单邮件队列名。
func QueueName(env string) string { func QueueName(env string) string {
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env)) return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
} }

View File

@@ -0,0 +1,143 @@
package payment
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
)
// 依次尝试:微信等常带 ¥支付宝等到账文案多为「收款x.xx元」无货币符号。
var mengyaNoticeAmountPatterns = []*regexp.Regexp{
regexp.MustCompile(`到账[¥¥]\s*([\d]+(?:\.[\d]+)?)`),
regexp.MustCompile(`到账\s*([\d]+(?:\.[\d]+)?)\s*元`),
regexp.MustCompile(`收款[¥¥]\s*([\d]+(?:\.[\d]+)?)(?:\s*元)?`),
regexp.MustCompile(`收款\s*([\d]+(?:\.[\d]+)?)\s*元`),
}
// AmountEpsilonWebhook 到账金额与订单快照金额对比时的容差。
const AmountEpsilon = 0.005
func AmountAlmostEqual(expected, actual float64) bool {
return math.Abs(expected-actual) < AmountEpsilon
}
// ParseWebhookJSON 从转发通知体中解析支付金额:
// 优先 body.notice其次顶层 notice否则在序列化后的 JSON 字符串中扫描。
func ParseWebhookJSON(raw []byte) (float64, bool) {
if len(bytes.TrimSpace(raw)) == 0 {
return 0, false
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return scanAmount(string(raw))
}
if v, ok := top["notice"]; ok {
if s := jsonString(v); s != "" {
return scanAmount(s)
}
}
if inner, ok := top["body"]; ok {
var nested map[string]json.RawMessage
if json.Unmarshal(inner, &nested) == nil {
if v, ok := nested["notice"]; ok {
if s := jsonString(v); s != "" {
return scanAmount(s)
}
}
if amt, ok := scanAmount(jsonString(inner)); ok {
return amt, ok
}
return scanAmount(string(inner))
}
if amt, ok := scanAmount(strings.Trim(strings.Trim(string(inner), "\""), `"`)); ok {
return amt, ok
}
return scanAmount(string(inner))
}
buf, _ := json.Marshal(top)
return scanAmount(string(buf))
}
func jsonString(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return ""
}
func scanAmount(text string) (float64, bool) {
if amt, ok := parseAmountRegex(text); ok {
return amt, true
}
return parseAmountRegex(strings.ReplaceAll(text, `\n`, "\n"))
}
func parseAmountRegex(text string) (float64, bool) {
for _, re := range mengyaNoticeAmountPatterns {
match := re.FindStringSubmatch(text)
if len(match) < 2 {
continue
}
s := strings.ReplaceAll(match[1], ",", "")
yuan, err := strconv.ParseFloat(s, 64)
if err != nil {
continue
}
return yuan, true
}
return 0, false
}
// NoticeFieldsForLog 从常见萌芽/转发 JSON 里抽出 notice 等字段,仅供后台日志排查(如微信/支付宝文案差异)。
func NoticeFieldsForLog(raw []byte) string {
if len(bytes.TrimSpace(raw)) == 0 {
return ""
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return ""
}
var parts []string
add := func(k, v string) {
if v == "" {
return
}
if len(v) > 600 {
v = v[:600] + "…"
}
parts = append(parts, fmt.Sprintf("%s=%s", k, strconv.Quote(v)))
}
if v, ok := top["notice"]; ok {
add("notice", jsonString(v))
}
if inner, ok := top["body"]; ok {
var nested map[string]json.RawMessage
if json.Unmarshal(inner, &nested) == nil {
if v, ok := nested["notice"]; ok {
add("body.notice", jsonString(v))
}
if v, ok := nested["content"]; ok {
add("body.content", jsonString(v))
}
if v, ok := nested["text"]; ok {
add("body.text", jsonString(v))
}
} else if s := jsonString(inner); s != "" {
add("body(string)", s)
}
}
for _, k := range []string{"title", "msg", "message", "desc", "description"} {
if v, ok := top[k]; ok {
add(k, jsonString(v))
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,3 @@
package payment
const MethodMengya = "mengya"

View File

@@ -1,15 +1,20 @@
package storage package storage
import ( import (
"errors"
"fmt" "fmt"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
const amountMatchEpsilon = 0.009
type OrderStore struct { type OrderStore struct {
db *gorm.DB db *gorm.DB
} }
@@ -20,20 +25,23 @@ func NewOrderStore(db *gorm.DB) (*OrderStore, error) {
func orderRowToModel(row database.OrderRow) models.Order { func orderRowToModel(row database.OrderRow) models.Order {
return models.Order{ return models.Order{
ID: row.ID, ID: row.ID,
ProductID: row.ProductID, ProductID: row.ProductID,
ProductName: row.ProductName, ProductName: row.ProductName,
UserAccount: row.UserAccount, UserAccount: row.UserAccount,
UserName: row.UserName, UserName: row.UserName,
Quantity: row.Quantity, Quantity: row.Quantity,
DeliveredCodes: row.DeliveredCodes, DeliveredCodes: row.DeliveredCodes,
Status: row.Status, Status: row.Status,
DeliveryMode: row.DeliveryMode, DeliveryMode: row.DeliveryMode,
Note: row.Note, Note: row.Note,
ContactPhone: row.ContactPhone, ContactPhone: row.ContactPhone,
ContactEmail: row.ContactEmail, ContactEmail: row.ContactEmail,
NotifyEmail: row.NotifyEmail, NotifyEmail: row.NotifyEmail,
CreatedAt: row.CreatedAt, PaymentMethod: row.PaymentMethod,
PaymentExpectedTotal: row.PaymentExpectedTotal,
PaymentExpiresAt: row.PaymentExpiresAt,
CreatedAt: row.CreatedAt,
} }
} }
@@ -45,24 +53,62 @@ func (s *OrderStore) Create(order models.Order) (models.Order, error) {
order.DeliveredCodes = []string{} order.DeliveredCodes = []string{}
} }
row := database.OrderRow{ row := database.OrderRow{
ID: order.ID, ID: order.ID,
ProductID: order.ProductID, ProductID: order.ProductID,
ProductName: order.ProductName, ProductName: order.ProductName,
UserAccount: order.UserAccount, UserAccount: order.UserAccount,
UserName: order.UserName, UserName: order.UserName,
Quantity: order.Quantity, Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes), DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status, Status: order.Status,
DeliveryMode: order.DeliveryMode, DeliveryMode: order.DeliveryMode,
Note: order.Note, Note: order.Note,
ContactPhone: order.ContactPhone, ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail, ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail, NotifyEmail: order.NotifyEmail,
PaymentMethod: order.PaymentMethod,
PaymentExpectedTotal: order.PaymentExpectedTotal,
PaymentExpiresAt: order.PaymentExpiresAt,
} }
if err := s.db.Create(&row).Error; err != nil { if err := s.db.Create(&row).Error; err != nil {
return models.Order{}, err return models.Order{}, err
} }
order.CreatedAt = row.CreatedAt order.CreatedAt = row.CreatedAt
order.PaymentExpiresAt = row.PaymentExpiresAt
return order, nil
}
// CreateTx 在已有事务内创建订单。
func (s *OrderStore) CreateTx(tx *gorm.DB, order models.Order) (models.Order, error) {
if order.ID == "" {
order.ID = uuid.NewString()
}
if len(order.DeliveredCodes) == 0 {
order.DeliveredCodes = []string{}
}
row := database.OrderRow{
ID: order.ID,
ProductID: order.ProductID,
ProductName: order.ProductName,
UserAccount: order.UserAccount,
UserName: order.UserName,
Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status,
DeliveryMode: order.DeliveryMode,
Note: order.Note,
ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail,
PaymentMethod: order.PaymentMethod,
PaymentExpectedTotal: order.PaymentExpectedTotal,
PaymentExpiresAt: order.PaymentExpiresAt,
}
if err := tx.Create(&row).Error; err != nil {
return models.Order{}, err
}
order.CreatedAt = row.CreatedAt
order.PaymentExpiresAt = row.PaymentExpiresAt
return order, nil return order, nil
} }
@@ -112,14 +158,13 @@ func (s *OrderStore) ListAll() ([]models.Order, error) {
func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) { func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) {
var total int64 var total int64
// 统计 pending手动待发货)和 completed 两种状态,防止用户快速下单绕过购买数量限制 // pending手动待发货pending_payment萌芽支付待到账会计入限购
err := s.db.Model(&database.OrderRow{}). err := s.db.Model(&database.OrderRow{}).
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "completed"}). Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "pending_payment", "completed"}).
Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error
return int(total), err return int(total), err
} }
// Count 返回所有订单的总数量。
func (s *OrderStore) Count() (int, error) { func (s *OrderStore) Count() (int, error) {
var count int64 var count int64
if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil { if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil {
@@ -128,13 +173,69 @@ func (s *OrderStore) Count() (int, error) {
return int(count), nil return int(count), nil
} }
// Delete 根据 ID 删除单条订单。
func (s *OrderStore) Delete(id string) error { func (s *OrderStore) Delete(id string) error {
return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error
} }
// UpdateCodes 更新订单的已发货卡密列表。
func (s *OrderStore) UpdateCodes(id string, codes []string) error { func (s *OrderStore) UpdateCodes(id string, codes []string) error {
return s.db.Model(&database.OrderRow{}).Where("id = ?", id). return s.db.Model(&database.OrderRow{}).Where("id = ?", id).
Update("delivered_codes", database.StringSlice(codes)).Error Update("delivered_codes", database.StringSlice(codes)).Error
} }
// TryFulfillMengyaPayment 在事务内按 FIFO 匹配一笔待支付订单并置为 completed、增加销量。无匹配时 ok=false。
func (s *OrderStore) TryFulfillMengyaPayment(amount float64, now time.Time) (order models.Order, ok bool, err error) {
err = s.db.Transaction(func(tx *gorm.DB) error {
var row database.OrderRow
res := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at > ?", "pending_payment", now).
Where("ABS(payment_expected_total - ?) < ?", amount, amountMatchEpsilon).
Order("created_at ASC").
First(&row)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
return nil
}
return res.Error
}
if err := tx.Model(&database.OrderRow{}).Where("id = ?", row.ID).Update("status", "completed").Error; err != nil {
return err
}
if err := tx.Model(&database.ProductRow{}).Where("id = ?", row.ProductID).
UpdateColumn("total_sold", gorm.Expr("total_sold + ?", row.Quantity)).Error; err != nil {
return err
}
row.Status = "completed"
order = orderRowToModel(row)
ok = true
return nil
})
if err != nil {
return models.Order{}, false, err
}
return order, ok, nil
}
// ListExpiredPendingPayments 返回已过期仍未支付的订单(用于释放预留库存)。
func (s *OrderStore) ListExpiredPendingPayments(now time.Time) ([]models.Order, error) {
var rows []database.OrderRow
if err := s.db.Where("status = ? AND payment_expires_at IS NOT NULL AND payment_expires_at <= ?", "pending_payment", now).
Find(&rows).Error; err != nil {
return nil, err
}
out := make([]models.Order, len(rows))
for i, r := range rows {
out[i] = orderRowToModel(r)
}
return out, nil
}
// CancelPendingStaleIfEligible 仅在仍为 pending_payment 时将订单作废并清空预留内容;返回值 ok 表示成功取消(可安全退回库存)。
func (s *OrderStore) CancelPendingStaleIfEligible(id string) (ok bool, err error) {
res := s.db.Model(&database.OrderRow{}).
Where("id = ? AND status = ?", id, "pending_payment").
Updates(map[string]interface{}{
"status": "cancelled",
"delivered_codes": database.StringSlice([]string{}),
})
return res.RowsAffected > 0, res.Error
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
@@ -16,7 +17,8 @@ import (
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png" const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
const viewCooldown = 6 * time.Hour const viewCooldown = 6 * time.Hour
const maxScreenshotURLs = 5 const maxScreenshotURLs = 6
const maxPaymentQrURLs = 6
type ProductStore struct { type ProductStore struct {
db *gorm.DB db *gorm.DB
@@ -55,6 +57,7 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
Tags: row.Tags, Tags: row.Tags,
CoverURL: row.CoverURL, CoverURL: row.CoverURL,
ScreenshotURLs: row.ScreenshotURLs, ScreenshotURLs: row.ScreenshotURLs,
PaymentQrURLs: []string(row.PaymentQrURLs),
VerificationURL: row.VerificationURL, VerificationURL: row.VerificationURL,
Description: row.Description, Description: row.Description,
Active: row.Active, Active: row.Active,
@@ -74,8 +77,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
} }
func (s *ProductStore) loadCodes(productID string) ([]string, error) { func (s *ProductStore) loadCodes(productID string) ([]string, error) {
return s.loadCodesTx(s.db, productID)
}
func (s *ProductStore) loadCodesTx(tx *gorm.DB, productID string) ([]string, error) {
var rows []database.ProductCodeRow var rows []database.ProductCodeRow
if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil { if err := tx.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
codes := make([]string, len(rows)) codes := make([]string, len(rows))
@@ -85,8 +92,46 @@ func (s *ProductStore) loadCodes(productID string) ([]string, error) {
return codes, nil return codes, nil
} }
// Transaction 在同一数据库连接上开启事务并执行 fn。
func (s *ProductStore) Transaction(fn func(tx *gorm.DB) error) error {
return s.db.Transaction(fn)
}
// GetByIDForUpdate 在事务内以 FOR UPDATE 锁定商品行(须在事务中调用)。
func (s *ProductStore) GetByIDForUpdate(tx *gorm.DB, id string) (models.Product, error) {
var row database.ProductRow
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
codes, err := s.loadCodesTx(tx, id)
if err != nil {
return models.Product{}, err
}
return rowToModel(row, codes), nil
}
// PrependReservedCodes 把曾预留在订单里的卡密按原顺序放回该商品可用库存队列前端FIFO 出库顺序)。
func (s *ProductStore) PrependReservedCodes(productID string, codes []string) error {
codes = sanitizeCodes(codes)
if len(codes) == 0 {
return nil
}
existing, err := s.loadCodes(productID)
if err != nil {
return err
}
merged := make([]string, 0, len(codes)+len(existing))
merged = append(merged, codes...)
merged = append(merged, existing...)
return s.replaceCodes(productID, merged)
}
func (s *ProductStore) replaceCodes(productID string, codes []string) error { func (s *ProductStore) replaceCodes(productID string, codes []string) error {
if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil { return s.replaceCodesTx(s.db, productID, codes)
}
func (s *ProductStore) replaceCodesTx(tx *gorm.DB, productID string, codes []string) error {
if err := tx.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
return err return err
} }
if len(codes) == 0 { if len(codes) == 0 {
@@ -96,7 +141,7 @@ func (s *ProductStore) replaceCodes(productID string, codes []string) error {
for _, code := range codes { for _, code := range codes {
rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code}) rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code})
} }
return s.db.CreateInBatches(rows, 100).Error return tx.CreateInBatches(rows, 100).Error
} }
func (s *ProductStore) ListAll() ([]models.Product, error) { func (s *ProductStore) ListAll() ([]models.Product, error) {
@@ -158,6 +203,7 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
Tags: database.StringSlice(p.Tags), Tags: database.StringSlice(p.Tags),
CoverURL: p.CoverURL, CoverURL: p.CoverURL,
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs), ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
PaymentQrURLs: database.StringSlice(p.PaymentQrURLs),
VerificationURL: p.VerificationURL, VerificationURL: p.VerificationURL,
Description: p.Description, Description: p.Description,
Active: p.Active, Active: p.Active,
@@ -198,6 +244,7 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
"tags": database.StringSlice(normalized.Tags), "tags": database.StringSlice(normalized.Tags),
"cover_url": normalized.CoverURL, "cover_url": normalized.CoverURL,
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs), "screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
"verification_url": normalized.VerificationURL, "verification_url": normalized.VerificationURL,
"description": normalized.Description, "description": normalized.Description,
"active": normalized.Active, "active": normalized.Active,
@@ -221,6 +268,45 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
return rowToModel(updated, codes), nil return rowToModel(updated, codes), nil
} }
// UpdateTx 在事务内更新商品及卡密列表(与 Update 行为一致)。
func (s *ProductStore) UpdateTx(tx *gorm.DB, id string, patch models.Product) (models.Product, error) {
var row database.ProductRow
if err := tx.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
normalized := normalizeProduct(patch)
if err := tx.Model(&row).Updates(map[string]interface{}{
"name": normalized.Name,
"price": normalized.Price,
"discount_price": normalized.DiscountPrice,
"tags": database.StringSlice(normalized.Tags),
"cover_url": normalized.CoverURL,
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
"payment_qr_urls": database.StringSlice(normalized.PaymentQrURLs),
"verification_url": normalized.VerificationURL,
"description": normalized.Description,
"active": normalized.Active,
"require_login": normalized.RequireLogin,
"max_per_account": normalized.MaxPerAccount,
"delivery_mode": normalized.DeliveryMode,
"fulfillment_type": normalized.FulfillmentType,
"fixed_content": normalized.FixedContent,
"show_note": normalized.ShowNote,
"show_contact": normalized.ShowContact,
}).Error; err != nil {
return models.Product{}, err
}
if err := s.replaceCodesTx(tx, id, normalized.Codes); err != nil {
return models.Product{}, err
}
var updated database.ProductRow
tx.First(&updated, "id = ?", id)
codes, _ := s.loadCodesTx(tx, id)
return rowToModel(updated, codes), nil
}
func (s *ProductStore) Toggle(id string, active bool) (models.Product, error) { func (s *ProductStore) Toggle(id string, active bool) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil { if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil {
return models.Product{}, err return models.Product{}, err
@@ -266,10 +352,8 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
return rowToModel(row, nil), true, nil return rowToModel(row, nil), true, nil
} }
// IncrementViewCount atomically increments view_count by 1 and returns the // IncrementViewCount 原子地将 view_count +1并返回更新后的商品不含卡密
// updated product (without codes). It is called by the handler when Redis-based // 在 Handler 已用 Redis 去重确认本次为新浏览时调用,故此处无需进程内锁。
// deduplication has already confirmed this is a new view, so no in-memory lock
// is needed here.
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) { func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id). if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil { UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
@@ -282,8 +366,8 @@ func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
return rowToModel(row, nil), nil return rowToModel(row, nil), nil
} }
// GetViewState returns the current product state without modifying any counter. // GetViewState 只读取当前商品状态,不修改任何计数。
// Used by the handler when a view is detected as duplicate via Redis. // 在 Handler 已通过 Redis 判定为重复访问时使用。
func (s *ProductStore) GetViewState(id string) (models.Product, error) { func (s *ProductStore) GetViewState(id string) (models.Product, error) {
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
@@ -309,12 +393,16 @@ func normalizeProduct(item models.Product) models.Product {
item.Tags = []string{} item.Tags = []string{}
} }
item.Tags = sanitizeTags(item.Tags) item.Tags = sanitizeTags(item.Tags)
if item.PaymentQrURLs == nil {
item.PaymentQrURLs = []string{}
}
if item.ScreenshotURLs == nil { if item.ScreenshotURLs == nil {
item.ScreenshotURLs = []string{} item.ScreenshotURLs = []string{}
} }
if len(item.ScreenshotURLs) > maxScreenshotURLs { if len(item.ScreenshotURLs) > maxScreenshotURLs {
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs] item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
} }
item.PaymentQrURLs = sanitizePaymentQrURLs(item.PaymentQrURLs)
if item.Codes == nil { if item.Codes == nil {
item.Codes = []string{} item.Codes = []string{}
} }
@@ -340,6 +428,23 @@ func normalizeProduct(item models.Product) models.Product {
return item return item
} }
func sanitizePaymentQrURLs(urls []string) []string {
clean := make([]string, 0, len(urls))
seen := map[string]bool{}
for _, u := range urls {
t := strings.TrimSpace(u)
if t == "" || seen[t] {
continue
}
seen[t] = true
clean = append(clean, t)
if len(clean) >= maxPaymentQrURLs {
break
}
}
return clean
}
func sanitizeCodes(codes []string) []string { func sanitizeCodes(codes []string) []string {
clean := make([]string, 0, len(codes)) clean := make([]string, 0, len(codes))
seen := map[string]bool{} seen := map[string]bool{}

View File

@@ -20,7 +20,7 @@ func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
func (s *SiteStore) get(key string) (string, error) { func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow var row database.SiteSettingRow
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。 // `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
// 使用 Find+Limit 而 First缺键时不产生 ErrRecordNotFound避免 GORM 默认 logger 刷「record not found」。 // 使用 Find+Limit 而不是 First缺键时不产生 ErrRecordNotFound避免 GORM 默认 logger 刷「record not found」。
if err := s.db.Where("`key` = ?", key).Limit(1).Find(&row).Error; err != nil { if err := s.db.Where("`key` = ?", key).Limit(1).Find(&row).Error; err != nil {
return "", err return "", err
} }

View File

@@ -1,13 +1,40 @@
// @title 萌芽小店 API
// @version 1.0.0-go
// @description 商品、下单、站点统计、收藏与客服聊天用户登录由萌芽账户认证中心SproutGate校验。监听地址以 HTTP_LISTEN_ADDR 为准。
// @host localhost:8080
// @BasePath /
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description 用户访问令牌,格式: Bearer 空格 + token
// @securityDefinitions.apikey AdminToken
// @in header
// @name X-Admin-Token
// @description 管理端令牌(也可使用 Authorization 头或 query token见各接口说明
// @securityDefinitions.apikey WebhookSecret
// @in header
// @name X-Webhook-Secret
// @description 与服务端 WEBHOOK_MENGYA_SECRET 一致时校验萌芽支付 Webhook
package main package main
import ( import (
"context" "context"
"log" "log"
"log/slog"
"net/http" "net/http"
"os"
"time" "time"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "mengyastore-backend/docs"
"mengyastore-backend/internal/auth" "mengyastore-backend/internal/auth"
"mengyastore-backend/internal/config" "mengyastore-backend/internal/config"
@@ -17,7 +44,98 @@ import (
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
const apiVersion = "1.0.0-go"
func initLogging() {
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key != slog.TimeKey {
return a
}
t := a.Value.Time()
if t.IsZero() {
return a
}
return slog.String("time", t.Format("2006-01-02 15:04:05"))
},
})
slog.SetDefault(slog.New(h))
}
func accessLog() gin.HandlerFunc {
return func(c *gin.Context) {
t0 := time.Now()
path := c.Request.URL.Path
q := c.Request.URL.RawQuery
c.Next()
p := path
if q != "" {
p = path + "?" + q
}
slog.Info("http",
"method", c.Request.Method,
"path", p,
"status", c.Writer.Status(),
"ms", time.Since(t0).Milliseconds(),
"ip", c.ClientIP(),
)
}
}
// rootAPIInfo 浏览器或客户端访问服务根路径时返回 API 说明JSON
// @Summary API 根信息与端点索引
// @Description 返回服务描述、主要路径指引与版本(非 OpenAPI 详尽列表,完整契约见 /swagger
// @Tags 元信息
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router / [get]
func rootAPIInfo(c *gin.Context) {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*3600)
}
c.JSON(http.StatusOK, gin.H{
"description": "萌芽小店电商后端商品、下单、站点统计、收藏与客服聊天用户登录认证由萌芽账户认证中心SproutGate校验。",
"endpoints": gin.H{
"health": "/api/health",
"public": "/api/products, /api/checkout, /api/stats, /api/site/*, POST /api/products/:id/view",
"orders": "/api/orders (Bearer), GET /api/orders/:id/payment-status, POST /api/orders/:id/confirm",
"webhooks": "POST /api/webhooks/mengya-pay (萌芽支付到账,可选 X-Webhook-Secret)",
"wishlist": "/api/wishlist (Bearer)",
"chat_user": "/api/chat/messages (Bearer)",
"chat_admin": "/api/admin/chat/* (X-Admin-Token)",
"admin": "/api/admin/* 含 verify、products、site、smtp、orders、system-status (X-Admin-Token)",
"auth": "用户认证 via 萌芽认证中心 (Authorization: Bearer)",
},
"message": "萌芽小店 后端 API 服务运行中",
"timestamp": time.Now().In(loc).Format(time.RFC3339),
"version": apiVersion,
})
}
// HealthCheck 返回进程与可选 RabbitMQ 探活结果。
// @Summary 健康检查
// @Tags 健康检查
// @Produce json
// @Success 200 {object} map[string]interface{} "status okrabbitmq 为 disabled|ok|error:..."
// @Router /api/health [get]
func HealthCheck(mqClient *mq.Client) gin.HandlerFunc {
return func(c *gin.Context) {
resp := gin.H{"status": "ok", "rabbitmq": "disabled"}
if mqClient != nil {
if err := mqClient.Ping(); err != nil {
resp["rabbitmq"] = "error: " + err.Error()
} else {
resp["rabbitmq"] = "ok"
}
}
c.JSON(http.StatusOK, resp)
}
}
func main() { func main() {
initLogging()
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("加载配置失败: %v", err) log.Fatalf("加载配置失败: %v", err)
@@ -57,47 +175,55 @@ func main() {
var mqClient *mq.Client var mqClient *mq.Client
if cfg.RabbitMQEnabled { if cfg.RabbitMQEnabled {
if cfg.RabbitMQURL == "" { if cfg.RabbitMQURL == "" {
log.Println("[MQ] 已启用但未配置连接串:请设置 RABBITMQ_URL,或设置 RABBITMQ_PASSWORD(及可选 RABBITMQ_HOST/RABBITMQ_VHOST") slog.Warn("mq", "event", "enabled_no_url", "hint", "set RABBITMQ_URL or RABBITMQ_PASSWORD")
} else { } else {
c, err := mq.New(cfg.RabbitMQURL, cfg.RabbitMQEnv) c, err := mq.New(cfg.RabbitMQURL, cfg.RabbitMQEnv)
if err != nil { if err != nil {
log.Printf("[MQ] 连接失败,将仅用直发邮件降级: %v", err) slog.Warn("mq", "event", "connect_failed", "err", err)
} else { } else {
mqClient = c mqClient = c
defer mqClient.Close() defer mqClient.Close()
go mqClient.StartConsumer(ctx, siteStore) go mqClient.StartConsumer(ctx, siteStore)
log.Printf("[MQ] 已连接 env=%s exchange=%s", cfg.RabbitMQEnv, mq.ExchangeName(cfg.RabbitMQEnv)) slog.Info("mq", "event", "connected", "env", cfg.RabbitMQEnv, "exchange", mq.ExchangeName(cfg.RabbitMQEnv))
} }
} }
} }
r := gin.Default() if cfg.GinDebug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
if err := r.SetTrustedProxies(nil); err != nil {
slog.Warn("server", "event", "trusted_proxies", "err", err)
}
r.Use(gin.Recovery())
r.Use(accessLog())
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token", "X-Webhook-Secret"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false, AllowCredentials: false,
MaxAge: 12 * time.Hour, MaxAge: 12 * time.Hour,
})) }))
r.GET("/api/health", func(c *gin.Context) { r.GET("/", rootAPIInfo)
resp := gin.H{"status": "ok", "rabbitmq": "disabled"}
if mqClient != nil { r.GET("/api/health", HealthCheck(mqClient))
if err := mqClient.Ping(); err != nil {
resp["rabbitmq"] = "error: " + err.Error() if cfg.EnableSwagger {
} else { r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
resp["rabbitmq"] = "ok" slog.Info("server", "event", "swagger", "path", "/swagger/index.html")
} }
}
c.JSON(http.StatusOK, resp)
})
authClient := auth.NewSproutGateClient(cfg.AuthAPIURL) authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
publicHandler := handlers.NewPublicHandler(store) publicHandler := handlers.NewPublicHandler(store)
adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore) adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore)
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient, mqClient) orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient, mqClient, cfg.PaymentPendingTTLSecs, cfg.WebhookMengyaSecret)
go orderHandler.RunExpiredPaymentSweep(ctx, 25*time.Second)
statsHandler := handlers.NewStatsHandler(orderStore, siteStore) statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient) wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
chatHandler := handlers.NewChatHandler(chatStore, authClient) chatHandler := handlers.NewChatHandler(chatStore, authClient)
@@ -105,12 +231,15 @@ func main() {
r.GET("/api/products", publicHandler.ListProducts) r.GET("/api/products", publicHandler.ListProducts)
r.POST("/api/checkout", orderHandler.CreateOrder) r.POST("/api/checkout", orderHandler.CreateOrder)
r.GET("/api/orders/:id/payment-status", orderHandler.GetOrderPaymentStatus)
r.POST("/api/webhooks/mengya-pay", orderHandler.MengyaPaymentWebhook)
r.POST("/api/products/:id/view", publicHandler.RecordProductView) r.POST("/api/products/:id/view", publicHandler.RecordProductView)
r.GET("/api/stats", statsHandler.GetStats) r.GET("/api/stats", statsHandler.GetStats)
r.POST("/api/site/visit", statsHandler.RecordVisit) r.POST("/api/site/visit", statsHandler.RecordVisit)
r.GET("/api/site/maintenance", statsHandler.GetMaintenance) r.GET("/api/site/maintenance", statsHandler.GetMaintenance)
r.GET("/api/orders", orderHandler.ListMyOrders) r.GET("/api/orders", orderHandler.ListMyOrders)
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder) r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
r.POST("/api/orders/:id/cancel", orderHandler.CancelOrder)
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken) r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
r.GET("/api/admin/products", adminHandler.ListAllProducts) r.GET("/api/admin/products", adminHandler.ListAllProducts)
@@ -139,8 +268,9 @@ func main() {
r.POST("/api/admin/chat/:account", adminHandler.AdminReply) r.POST("/api/admin/chat/:account", adminHandler.AdminReply)
r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation) r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation)
log.Println("萌芽小店后端启动于 http://localhost:8080") slog.Info("server", "event", "listen", "addr", cfg.HTTPListenAddr)
if err := r.Run(":8080"); err != nil { if err := r.Run(cfg.HTTPListenAddr); err != nil {
log.Fatalf("服务器启动失败: %v", err) slog.Error("server", "event", "exit", "err", err)
os.Exit(1)
} }
} }

View File

@@ -12,7 +12,7 @@
| google/uuid | 订单 ID | | google/uuid | 订单 ID |
| github.com/rabbitmq/amqp091-go | RabbitMQ可选 | | github.com/rabbitmq/amqp091-go | RabbitMQ可选 |
| github.com/redis/go-redis/v9 | Redis 探测(可选) | | github.com/redis/go-redis/v9 | Redis 探测(可选) |
| github.com/joho/godotenv | `.env` 加载 | | github.com/joho/godotenv | `.env.development`(或 `ENV_FILE`加载 |
## 目录结构(与仓库一致) ## 目录结构(与仓库一致)
@@ -58,7 +58,9 @@ mengyastore-backend-go/
| GET | `/api/stats` | 订单数、访问量等 | | GET | `/api/stats` | 订单数、访问量等 |
| POST | `/api/site/visit` | 站点访问计数 | | POST | `/api/site/visit` | 站点访问计数 |
| GET | `/api/site/maintenance` | 维护状态 | | GET | `/api/site/maintenance` | 维护状态 |
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes` | | POST | `/api/checkout` | 创建订单`paymentMethod` 默认 `mengya`,见下文「萌芽支付」 |
| GET | `/api/orders/:id/payment-status` | 结账轮询:`status``paymentExpiresAt`(不回卡密) |
| POST | `/api/webhooks/mengya-pay` | 到账通知 Webhook`X-Webhook-Secret` 与配置一致时可省略 |
| GET | `/api/orders` | 当前用户订单Bearer | | GET | `/api/orders` | 当前用户订单Bearer |
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) | | POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
@@ -97,6 +99,7 @@ mengyastore-backend-go/
|------|------| |------|------|
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) | | fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 | | fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
| payment_qr_urls | JSON 数组:`paymentQrUrls`,萌芽支付收款码图片直链(最多 10管理端填写 |
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) | | delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … | | 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
@@ -106,7 +109,7 @@ mengyastore-backend-go/
### orders ### orders
`delivered_codes`JSON`notify_email``delivery_mode``status` 等。 `delivered_codes`JSON`notify_email``delivery_mode`支付方式字段 `payment_method``payment_expected_total``payment_expires_at`。**`status`** 含:`pending``pending_payment`(萌芽待到账)、`completed``cancelled`(超时未付释放库存)等。
### site_settings ### site_settings
@@ -114,27 +117,48 @@ KV如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺
## 配置说明 ## 配置说明
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定) 配置由 **`config.Load()`** 从**进程环境变量**读取。未设置 **`ENV_FILE`** 时,会在当前工作目录尝试加载 **`./.env.development`**(文件不存在则跳过,不报错)。生产或其它环境请用 **`ENV_FILE=/path/to/.env.production`** 等方式显式指定文件
要点: 要点:
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN仅开发便利 - `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN仅开发便利
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。 - RabbitMQ、Redis 开关与地址见 `internal/config/config.go` 注释。
- `HTTP_LISTEN_ADDR``PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。 - `HTTP_LISTEN_ADDR``PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
- `WEBHOOK_MENGYA_SECRET`:若设置,到账 Webhook 须在请求头带相同 `X-Webhook-Secret`
- `PAYMENT_PENDING_SECONDS`:萌芽支付待支付窗口(秒,默认 60
### API 文档Swagger UI开关
OpenAPI 规范由 **`swag`** 从代码注释生成(`docs/swagger.json`;重生成见仓库根目录 **`Makefile`** 的 `swagger` 目标)。运行期是否挂载 UI 由配置决定:
| 变量 | 说明 |
|------|------|
| **`ENABLE_SWAGGER`** | 为 `1` / `true` / `yes` / `on`(不区分大小写)时,注册 **`GET /swagger/*`**,浏览器打开 **`/swagger/index.html`**。 |
| **`GIN_DEBUG`** | 为真时除 Gin Debug 模式外,也会**开启** Swagger UI`ENABLE_SWAGGER` 二选一满足即可)。 |
生产环境若未设置上述变量,默认**不**暴露 Swagger减少接口探测面。
## 萌芽支付Webhook 核销)
- 结账 `paymentMethod: mengya`(默认)且**非免费**:预留卡密或固定交付内容,`status=pending_payment`,写入 `payment_expected_total``payment_expires_at`UTC**此时不计销量**,到账后再 `completed``IncrementSold` + 邮件。
- **`POST /api/webhooks/mengya-pay`**JSON 体与 `test/server.py` 打印结构兼容,从 `body.notice` / `notice` 等字段抽取 `到账¥x.xx` 金额与待支付订单快照匹配FIFO`ABS` 容差)。
- **等额归因**:多笔同额待支付可能被核销到最早一单,需业务规避或后续增强。
- 超时:后台定时扫描将订单 `cancelled` 并退回卡密库存(固定内容类仅清空订单预留)。
- 旧版 `paymentMethod=legacy_manual` 等请求已停用(返回 400
## 发货与订单逻辑 ## 发货与订单逻辑
### fulfillment_type = `card`(默认) ### fulfillment_type = `card`(默认)
1. `POST /api/checkout` 校验库存 ≥ 购买数量。 1. `POST /api/checkout` 校验库存 ≥ 购买数量**萌芽支付**下为预留待付,见上文)
2.`product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。 2.`product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
3. 销量 `IncrementSold`;邮件/MQ 通知。 3. 销量 `IncrementSold`**免费单**在结账时计入;**付费萌芽支付**在 Webhook 核销后 `IncrementSold`;邮件/MQ 通知。
### fulfillment_type = `fixed` ### fulfillment_type = `fixed`
1. 不校验卡密条数;不修改 `product_codes` 1. 不校验卡密条数;不修改 `product_codes`
2.`fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。 2.`fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
3.`IncrementSold`;邮件/MQ 同自动发货路径。 3.`IncrementSold`(规则同 card;邮件/MQ 同自动发货路径。
### delivery_mode订单 ### delivery_mode订单
@@ -147,10 +171,12 @@ KV如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺
## 本地开发 ## 本地开发
**`mengyastore-backend-go`** 目录放置 **`.env.development`**(可被 git 忽略),或导出环境变量后再启动。
```bash ```bash
go run . go run .
go build -o mengyastore-backend.exe . go build -o mengyastore-backend.exe .
go run ./cmd/migrate/main.go # 按需迁移旧 JSON go run ./cmd/migrate/main.go # 按需迁移旧 JSON;同样读取 .env.development 或 ENV_FILE
``` ```
--- ---

View File

@@ -1,2 +0,0 @@
/mvnw text eol=lf
*.cmd text eol=crlf

View File

@@ -1,33 +0,0 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

View File

@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

View File

@@ -1 +0,0 @@
services: { }

View File

@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

View File

@@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@@ -1,121 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.12</version>
<relativePath/>
</parent>
<groupId>com.smyhub.store</groupId>
<artifactId>mengyastore-backend-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mengyastore-backend-java</name>
<description>萌芽小店 Java Spring Boot 后端</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA / Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- RabbitMQ AMQP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JavaMail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Actuator (health, metrics) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,17 +0,0 @@
package com.smyhub.store.mengyastorebackendjava;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableScheduling
public class MengyastoreBackendJavaApplication {
public static void main(String[] args) {
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
}
}

View File

@@ -1,33 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String adminToken = "changeme";
private String authApiUrl = "https://auth.api.shumengya.top";
/** Mirrors Go APP_ENV: {@code development} | {@code production}; used by system-status panel. */
private String appEnv = "development";
private boolean rabbitmqEnabled = false;
/**
* Mirrors Go REDIS_ENABLED (default on unless explicitly disabled).
*/
private boolean redisEnabled = true;
private String rabbitmqEnv = "dev";
private String redisEnv = "dev";
private String publicApiBaseUrl = "";
private String corsAllowedOrigins = "http://localhost:5173,http://localhost:3000";
}

View File

@@ -1,44 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
import java.util.List;
@Configuration
@RequiredArgsConstructor
public class CorsConfig {
private final AppProperties appProperties;
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
String originsRaw = appProperties.getCorsAllowedOrigins();
if (StringUtils.hasText(originsRaw)) {
List<String> origins = Arrays.asList(originsRaw.split(","));
config.setAllowedOrigins(origins);
} else {
config.addAllowedOriginPattern("*");
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Content-Type", "Authorization", "X-Admin-Token",
"X-Requested-With", "Accept", "Accept-Language"
));
config.setAllowCredentials(true);
config.setMaxAge(86400L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}

View File

@@ -1,63 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.core.AcknowledgeMode;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
public class RabbitMQConfig {
private final AppProperties appProperties;
@Bean
public Jackson2JsonMessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(jsonMessageConverter());
return template;
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(jsonMessageConverter());
factory.setPrefetchCount(1);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
return factory;
}
@Bean
public TopicExchange orderEmailExchange() {
String name = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
return ExchangeBuilder.topicExchange(name).durable(true).build();
}
@Bean
public Queue orderEmailQueue() {
String name = RabbitMQTopology.queueName(appProperties.getRabbitmqEnv());
return QueueBuilder.durable(name).build();
}
@Bean
public Binding orderEmailBinding() {
return BindingBuilder
.bind(orderEmailQueue())
.to(orderEmailExchange())
.with(RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY);
}
}

View File

@@ -1,15 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}

View File

@@ -1,20 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(8))
.build();
}
}

View File

@@ -1,31 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.config;
import com.smyhub.store.mengyastorebackendjava.security.AdminTokenInterceptor;
import com.smyhub.store.mengyastorebackendjava.security.BearerTokenInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final AdminTokenInterceptor adminTokenInterceptor;
private final BearerTokenInterceptor bearerTokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminTokenInterceptor)
.addPathPatterns("/api/admin/**")
.excludePathPatterns("/api/admin/verify");
registry.addInterceptor(bearerTokenInterceptor)
.addPathPatterns(
"/api/orders",
"/api/wishlist",
"/api/wishlist/**",
"/api/chat/messages"
);
}
}

View File

@@ -1,38 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.request.ChatMessageRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
@GetMapping("/messages")
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getMessages() {
String account = CurrentUserHolder.get().getAccount();
return ResponseEntity.ok(ApiResponse.ok(chatService.getUserMessages(account)));
}
@PostMapping("/messages")
public ResponseEntity<ApiResponse<ChatMessageResponse>> sendMessage(
@Valid @RequestBody ChatMessageRequest req) {
SproutGateUser user = CurrentUserHolder.get();
ChatMessageResponse msg = chatService.sendUserMessage(
user.getAccount(), user.getUsername(), req.getContent()
);
return ResponseEntity.ok(ApiResponse.ok(msg));
}
}

View File

@@ -1,55 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.HealthResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api")
public class HealthController {
private final AppProperties appProperties;
@Autowired(required = false)
private RabbitTemplate rabbitTemplate;
public HealthController(AppProperties appProperties) {
this.appProperties = appProperties;
}
@GetMapping("/health")
public ResponseEntity<ApiResponse<HealthResponse>> health() {
HealthResponse body = HealthResponse.builder()
.status("ok")
.rabbitmq(checkRabbitMQ())
.build();
return ResponseEntity.ok(ApiResponse.ok(body));
}
private String checkRabbitMQ() {
if (!appProperties.isRabbitmqEnabled()) {
return "disabled";
}
if (rabbitTemplate == null) {
return "disabled";
}
try {
rabbitTemplate.execute(channel -> {
channel.basicQos(1);
return null;
});
return "ok";
} catch (Exception e) {
log.debug("[Health] RabbitMQ ping failed: {}", e.getMessage());
return "error";
}
}
}

View File

@@ -1,67 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PublicOrderController {
private final OrderService orderService;
private final SproutGateAuthService authService;
@PostMapping("/checkout")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkout(
@Valid @RequestBody CheckoutRequest req,
HttpServletRequest request) {
SproutGateUser user = tryResolveUser(request);
OrderService.CheckoutResult result = orderService.checkout(req, user);
String qrPayload = "order:" + result.orderId() + ":" + result.productId();
String qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=320x320&data="
+ java.net.URLEncoder.encode(qrPayload, java.nio.charset.StandardCharsets.UTF_8);
return ResponseEntity.ok(ApiResponse.ok(Map.of(
"orderId", result.orderId(),
"qrCodeUrl", qrUrl,
"productId", result.productId(),
"productQty", result.qty(),
"viewCount", result.viewCount(),
"status", result.status()
)));
}
@PostMapping("/orders/{id}/confirm")
public ResponseEntity<ApiResponse<Map<String, Object>>> confirmOrder(@PathVariable String id) {
var order = orderService.confirmOrder(id);
return ResponseEntity.ok(ApiResponse.ok(Map.of(
"orderId", order.getId(),
"status", order.getStatus(),
"deliveryMode", order.getDeliveryMode(),
"deliveredCodes", order.getDeliveredCodes(),
"isManual", "manual".equals(order.getDeliveryMode())
)));
}
private SproutGateUser tryResolveUser(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) return null;
String token = header.substring(7);
SproutGateVerifyResult result = authService.verifyToken(token);
return (result.isValid() && result.getUser() != null) ? result.getUser() : null;
}
}

View File

@@ -1,57 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PublicProductController {
private final ProductService productService;
@GetMapping("/products")
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
return ResponseEntity.ok(ApiResponse.ok(productService.listPublicProducts()));
}
@PostMapping("/products/{id}/view")
public ResponseEntity<ApiResponse<Map<String, Object>>> recordView(
@PathVariable String id,
HttpServletRequest request) {
String fingerprint = buildFingerprint(request);
ProductResponse p = productService.recordView(id, fingerprint);
return ResponseEntity.ok(ApiResponse.ok(Map.of(
"id", p.getId(),
"viewCount", p.getViewCount(),
"counted", true
)));
}
private String buildFingerprint(HttpServletRequest request) {
String ip = getClientIp(request);
String ua = nvl(request.getHeader("User-Agent"));
String lang = nvl(request.getHeader("Accept-Language"));
return ip + "|" + ua + "|" + lang;
}
private String getClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].strip();
}
return request.getRemoteAddr();
}
private String nvl(String s) {
return s != null ? s.strip() : "";
}
}

View File

@@ -1,42 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.StatsResponse;
import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class StatsController {
private final OrderRepository orderRepository;
private final SiteService siteService;
@GetMapping("/stats")
public ResponseEntity<ApiResponse<StatsResponse>> getStats() {
long total = orderRepository.count();
int visits = siteService.getTotalVisits();
return ResponseEntity.ok(ApiResponse.ok(StatsResponse.builder()
.totalOrders(total)
.totalVisits(visits)
.build()));
}
@PostMapping("/site/visit")
public ResponseEntity<ApiResponse<Map<String, Object>>> recordVisit() {
int total = siteService.incrementVisits();
return ResponseEntity.ok(ApiResponse.ok(Map.of("total", total, "counted", true)));
}
@GetMapping("/site/maintenance")
public ResponseEntity<ApiResponse<MaintenanceResponse>> getMaintenance() {
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
}
}

View File

@@ -1,27 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserOrderController {
private final OrderService orderService;
@GetMapping("/orders")
public ResponseEntity<ApiResponse<List<OrderResponse>>> myOrders() {
String account = CurrentUserHolder.get().getAccount();
return ResponseEntity.ok(ApiResponse.ok(orderService.getMyOrders(account)));
}
}

View File

@@ -1,45 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
import com.smyhub.store.mengyastorebackendjava.service.WishlistService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wishlist")
@RequiredArgsConstructor
public class WishlistController {
private final WishlistService wishlistService;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, List<String>>>> getWishlist() {
String account = CurrentUserHolder.get().getAccount();
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
@PostMapping
public ResponseEntity<ApiResponse<Map<String, List<String>>>> addToWishlist(
@RequestBody Map<String, String> body) {
String productId = body.get("productId");
String account = CurrentUserHolder.get().getAccount();
wishlistService.addToWishlist(account, productId);
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
@DeleteMapping("/{productId}")
public ResponseEntity<ApiResponse<Map<String, List<String>>>> removeFromWishlist(
@PathVariable String productId) {
String account = CurrentUserHolder.get().getAccount();
wishlistService.removeFromWishlist(account, productId);
List<String> items = wishlistService.getWishlistIds(account);
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
}
}

View File

@@ -1,46 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminChatMessageRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/chat")
@RequiredArgsConstructor
public class AdminChatController {
private final ChatService chatService;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, List<ChatMessageResponse>>>> listConversations() {
return ResponseEntity.ok(ApiResponse.ok(chatService.listAllConversations()));
}
@GetMapping("/{account}")
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getConversation(
@PathVariable String account) {
return ResponseEntity.ok(ApiResponse.ok(chatService.getConversation(account)));
}
@PostMapping("/{account}")
public ResponseEntity<ApiResponse<ChatMessageResponse>> replyToUser(
@PathVariable String account,
@Valid @RequestBody AdminChatMessageRequest req) {
ChatMessageResponse msg = chatService.sendAdminMessage(account, req.getContent());
return ResponseEntity.ok(ApiResponse.ok(msg));
}
@DeleteMapping("/{account}")
public ResponseEntity<ApiResponse<Void>> clearConversation(@PathVariable String account) {
chatService.clearConversation(account);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -1,29 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/orders")
@RequiredArgsConstructor
public class AdminOrderController {
private final OrderService orderService;
@GetMapping
public ResponseEntity<ApiResponse<List<OrderResponse>>> listOrders() {
return ResponseEntity.ok(ApiResponse.ok(orderService.listAllOrders()));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteOrder(@PathVariable String id) {
orderService.deleteOrder(id);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -1,52 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest;
import com.smyhub.store.mengyastorebackendjava.dto.request.ProductStatusRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/products")
@RequiredArgsConstructor
public class AdminProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
return ResponseEntity.ok(ApiResponse.ok(productService.listAllProducts()));
}
@PostMapping
public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
@Valid @RequestBody AdminProductRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.createProduct(req)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ProductResponse>> updateProduct(
@PathVariable String id,
@Valid @RequestBody AdminProductRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.updateProduct(id, req)));
}
@PatchMapping("/{id}/status")
public ResponseEntity<ApiResponse<ProductResponse>> toggleStatus(
@PathVariable String id,
@RequestBody ProductStatusRequest req) {
return ResponseEntity.ok(ApiResponse.ok(productService.toggleStatus(id, req.isActive())));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteProduct(@PathVariable String id) {
productService.deleteProduct(id);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -1,38 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.request.MaintenanceRequest;
import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/admin/site")
@RequiredArgsConstructor
public class AdminSiteController {
private final SiteService siteService;
@PostMapping("/maintenance")
public ResponseEntity<ApiResponse<MaintenanceResponse>> setMaintenance(
@RequestBody MaintenanceRequest req) {
siteService.setMaintenance(req.isEnabled(), req.getReason());
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
}
@GetMapping("/smtp")
public ResponseEntity<ApiResponse<SmtpConfigResponse>> getSmtp() {
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
}
@PostMapping("/smtp")
public ResponseEntity<ApiResponse<SmtpConfigResponse>> setSmtp(
@RequestBody SmtpConfigRequest req) {
siteService.setSmtpConfig(req);
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
}
}

View File

@@ -1,25 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import com.smyhub.store.mengyastorebackendjava.service.SystemStatusService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminStatusController {
private final SystemStatusService systemStatusService;
@GetMapping("/system-status")
public ResponseEntity<ApiResponse<Map<String, Object>>> getSystemStatus(HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(systemStatusService.buildStatus(request)));
}
}

View File

@@ -1,30 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.controller.admin;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminVerifyRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminVerifyController {
private final AppProperties appProperties;
/**
* Matches Go backend: top-level {@code {"valid": true/false}} (frontend reads {@code response.data.valid} from axios).
*/
@PostMapping("/verify")
public ResponseEntity<Map<String, Boolean>> verify(@RequestBody(required = false) AdminVerifyRequest req) {
if (req == null || !StringUtils.hasText(req.getToken())) {
return ResponseEntity.ok(Map.of("valid", false));
}
boolean valid = req.getToken().equals(appProperties.getAdminToken());
return ResponseEntity.ok(Map.of("valid", valid));
}
}

View File

@@ -1,11 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class AdminChatMessageRequest {
@NotBlank(message = "消息内容不能为空")
private String content;
}

View File

@@ -1,46 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AdminProductRequest {
@NotBlank(message = "商品名称不能为空")
private String name;
private double price;
private double discountPrice;
private List<String> tags = new ArrayList<>();
private String coverUrl;
private List<String> screenshotUrls = new ArrayList<>();
private String verificationUrl;
private String description;
private boolean active = true;
private boolean requireLogin;
private int maxPerAccount;
private String deliveryMode = "auto";
private String fulfillmentType = "card";
private String fixedContent;
private boolean showNote = true;
private boolean showContact = true;
private List<String> codes = new ArrayList<>();
}

View File

@@ -1,8 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class AdminVerifyRequest {
private String token;
}

View File

@@ -1,11 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ChatMessageRequest {
@NotBlank(message = "消息内容不能为空")
private String content;
}

View File

@@ -1,21 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CheckoutRequest {
@NotBlank(message = "productId 不能为空")
private String productId;
private int quantity = 1;
private String note;
private String contactPhone;
private String contactEmail;
private String notifyEmail;
}

View File

@@ -1,9 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class MaintenanceRequest {
private boolean enabled;
private String reason = "";
}

View File

@@ -1,8 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class ProductStatusRequest {
private boolean active;
}

View File

@@ -1,13 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.request;
import lombok.Data;
@Data
public class SmtpConfigRequest {
private boolean enabled = true;
private String email;
private String password;
private String fromName;
private String host = "smtp.qq.com";
private String port = "465";
}

View File

@@ -1,25 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private final T data;
private final String error;
private ApiResponse(T data, String error) {
this.data = data;
this.error = error;
}
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(data, null);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(null, message);
}
}

View File

@@ -1,18 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class ChatMessageResponse {
private String id;
private String accountId;
private String accountName;
private String content;
private LocalDateTime sentAt;
private boolean fromAdmin;
}

View File

@@ -1,11 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class HealthResponse {
private String status;
private String rabbitmq;
}

View File

@@ -1,11 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class MaintenanceResponse {
private boolean maintenance;
private String reason;
}

View File

@@ -1,27 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
public class OrderResponse {
private String id;
private String productId;
private String productName;
private String userAccount;
private String userName;
private int quantity;
private List<String> deliveredCodes;
private String status;
private String deliveryMode;
private String note;
private String contactPhone;
private String contactEmail;
private String notifyEmail;
private LocalDateTime createdAt;
}

View File

@@ -1,37 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProductResponse {
private String id;
private String name;
private double price;
private double discountPrice;
private List<String> tags;
private int quantity;
private String coverUrl;
private List<String> screenshotUrls;
private String verificationUrl;
private List<String> codes;
private int viewCount;
private String description;
private boolean active;
private boolean requireLogin;
private int maxPerAccount;
private int totalSold;
private String deliveryMode;
private String fulfillmentType;
private String fixedContent;
private boolean showNote;
private boolean showContact;
private LocalDateTime createdAt;
}

View File

@@ -1,15 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SmtpConfigResponse {
private boolean enabled;
private String email;
private String password;
private String fromName;
private String host;
private String port;
}

View File

@@ -1,11 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class StatsResponse {
private long totalOrders;
private int totalVisits;
}

View File

@@ -1,12 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WishlistResponse {
private Long id;
private String accountId;
private String productId;
}

View File

@@ -1,33 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "chat_messages")
@Getter
@Setter
public class ChatMessage {
@Id
@Column(length = 36)
private String id;
@Column(name = "account_id", length = 255, nullable = false)
private String accountId;
@Column(name = "account_name", length = 255)
private String accountName;
@Column(columnDefinition = "text", nullable = false)
private String content;
@Column(name = "sent_at", nullable = false)
private LocalDateTime sentAt;
@Column(name = "from_admin", columnDefinition = "tinyint(1) default 0")
private boolean fromAdmin;
}

View File

@@ -1,61 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@Column(length = 36)
private String id;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
@Column(name = "product_name", length = 255, nullable = false)
private String productName;
@Column(name = "user_account", length = 255)
private String userAccount;
@Column(name = "user_name", length = 255)
private String userName;
@Column(nullable = false, columnDefinition = "int default 1")
private int quantity;
@Convert(converter = StringListConverter.class)
@Column(name = "delivered_codes", columnDefinition = "json")
private List<String> deliveredCodes = new ArrayList<>();
@Column(length = 20, nullable = false, columnDefinition = "varchar(20) default 'pending'")
private String status = "pending";
@Column(name = "delivery_mode", length = 20, columnDefinition = "varchar(20) default 'auto'")
private String deliveryMode = "auto";
@Column(columnDefinition = "text")
private String note;
@Column(name = "contact_phone", length = 50)
private String contactPhone;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "notify_email", length = 255)
private String notifyEmail;
@Column(name = "created_at")
private LocalDateTime createdAt;
}

View File

@@ -1,80 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "products")
@Getter
@Setter
public class Product {
@Id
@Column(length = 36)
private String id;
@Column(length = 255, nullable = false)
private String name;
@Column(nullable = false, columnDefinition = "double default 0")
private double price;
@Column(columnDefinition = "double default 0")
private double discountPrice;
@Convert(converter = StringListConverter.class)
@Column(columnDefinition = "json")
private List<String> tags = new ArrayList<>();
@Column(length = 500)
private String coverUrl;
@Convert(converter = StringListConverter.class)
@Column(columnDefinition = "json")
private List<String> screenshotUrls = new ArrayList<>();
@Column(length = 500, columnDefinition = "varchar(500) default ''")
private String verificationUrl;
@Column(columnDefinition = "text")
private String description;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean active = true;
@Column(columnDefinition = "tinyint(1) default 0")
private boolean requireLogin;
@Column(columnDefinition = "int default 0")
private int maxPerAccount;
@Column(columnDefinition = "int default 0")
private int totalSold;
@Column(columnDefinition = "int default 0")
private int viewCount;
@Column(length = 20, columnDefinition = "varchar(20) default 'auto'")
private String deliveryMode = "auto";
@Column(length = 16, columnDefinition = "varchar(16) default 'card'")
private String fulfillmentType = "card";
@Column(columnDefinition = "text")
private String fixedContent;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean showNote = true;
@Column(columnDefinition = "tinyint(1) default 1")
private boolean showContact = true;
@Column
private LocalDateTime createdAt;
}

View File

@@ -1,29 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "product_codes")
@Getter
@Setter
@NoArgsConstructor
public class ProductCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
@Column(columnDefinition = "text", nullable = false)
private String code;
public ProductCode(String productId, String code) {
this.productId = productId;
this.code = code;
}
}

View File

@@ -1,26 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "site_settings")
@Getter
@Setter
@NoArgsConstructor
public class SiteSetting {
@Id
@Column(name = "`key`", length = 64)
private String key;
@Column(columnDefinition = "text")
private String value;
public SiteSetting(String key, String value) {
this.key = key;
this.value = value;
}
}

View File

@@ -1,30 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "wishlists",
uniqueConstraints = @UniqueConstraint(name = "idx_wishlist", columnNames = {"account_id", "product_id"}))
@Getter
@Setter
@NoArgsConstructor
public class Wishlist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id", length = 255, nullable = false)
private String accountId;
@Column(name = "product_id", length = 36, nullable = false)
private String productId;
public Wishlist(String accountId, String productId) {
this.accountId = accountId;
this.productId = productId;
}
}

View File

@@ -1,39 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.entity.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null) {
return "[]";
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
return "[]";
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return new ArrayList<>();
}
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
}

View File

@@ -1,21 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.exception;
import org.springframework.http.HttpStatus;
public class BusinessException extends RuntimeException {
private final HttpStatus status;
public BusinessException(String message) {
this(message, HttpStatus.BAD_REQUEST);
}
public BusinessException(String message, HttpStatus status) {
super(message);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}

View File

@@ -1,35 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.exception;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException ex) {
return ResponseEntity.status(ex.getStatus()).body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getAllErrors().stream()
.findFirst()
.map(e -> e.getDefaultMessage())
.orElse("请求参数有误");
return ResponseEntity.badRequest().body(ApiResponse.error(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("服务器内部错误"));
}
}

View File

@@ -1,64 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import com.rabbitmq.client.Channel;
import com.smyhub.store.mengyastorebackendjava.service.EmailService;
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
public class OrderEmailConsumer {
private final SiteService siteService;
private final EmailService emailService;
@RabbitListener(queues = "#{T(com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology).queueName(@appProperties.rabbitmqEnv)}", ackMode = "MANUAL")
public void onMessage(OrderEmailPayload payload, Message message, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws Exception {
if (payload == null || payload.getToEmail() == null || payload.getToEmail().isBlank()) {
channel.basicAck(deliveryTag, false);
return;
}
SiteService.SmtpConfig cfg = siteService.getSmtpConfig();
if (!cfg.isConfigured()) {
log.info("[MQ] skip email order={}: smtp not configured", payload.getOrderId());
channel.basicAck(deliveryTag, false);
return;
}
EmailService.OrderNotifyData data = new EmailService.OrderNotifyData();
data.toEmail = payload.getToEmail();
data.toName = payload.getToName();
data.productName = payload.getProductName();
data.orderId = payload.getOrderId();
data.quantity = payload.getQuantity();
data.codes = payload.getCodes();
data.isManual = payload.isManual();
try {
emailService.sendOrderNotify(cfg, data);
channel.basicAck(deliveryTag, false);
log.info("[MQ] email ok order={} to={}", payload.getOrderId(), payload.getToEmail());
} catch (Exception e) {
log.warn("[MQ] send email fail order={}: {}", payload.getOrderId(), e.getMessage());
boolean redelivered = message.getMessageProperties().isRedelivered();
if (redelivered) {
log.warn("[MQ] drop order={} after failed redelivery", payload.getOrderId());
channel.basicAck(deliveryTag, false);
} else {
channel.basicNack(deliveryTag, false, true);
}
}
}
}

View File

@@ -1,21 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderEmailPayload {
private String toEmail;
private String toName;
private String productName;
private String orderId;
private int quantity;
private List<String> codes;
private boolean isManual;
}

View File

@@ -1,36 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.mq;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailProducer {
private final RabbitTemplate rabbitTemplate;
private final AppProperties appProperties;
/**
* Publishes an order email payload to RabbitMQ.
* Returns true on success, false on failure (caller should fall back to direct email).
*/
public boolean publish(OrderEmailPayload payload) {
if (!appProperties.isRabbitmqEnabled()) {
return false;
}
String exchange = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
try {
rabbitTemplate.convertAndSend(exchange, RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY, payload);
log.info("[MQ] queued order email order={} to={}", payload.getOrderId(), payload.getToEmail());
return true;
} catch (AmqpException e) {
log.warn("[MQ] publish failed, will fallback to direct email: {}", e.getMessage());
return false;
}
}
}

View File

@@ -1,16 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.mq;
public final class RabbitMQTopology {
private RabbitMQTopology() {}
public static final String ORDER_EMAIL_ROUTING_KEY = "order.email.notify";
public static String exchangeName(String env) {
return "ex.mengyastore." + env + ".events";
}
public static String queueName(String env) {
return "q.mengyastore." + env + ".order_email";
}
}

View File

@@ -1,19 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessage, String> {
List<ChatMessage> findByAccountIdOrderBySentAtAsc(String accountId);
List<ChatMessage> findAllByOrderByAccountIdAscSentAtAsc();
@Transactional
void deleteByAccountId(String accountId);
}

View File

@@ -1,20 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface OrderRepository extends JpaRepository<Order, String> {
List<Order> findByUserAccountOrderByCreatedAtDesc(String userAccount);
List<Order> findAllByOrderByCreatedAtDesc();
@Query("SELECT COALESCE(SUM(o.quantity), 0) FROM Order o WHERE o.userAccount = :account AND o.productId = :productId AND o.status <> 'cancelled'")
int sumQuantityByUserAccountAndProductId(@Param("account") String account, @Param("productId") String productId);
}

View File

@@ -1,19 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.ProductCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ProductCodeRepository extends JpaRepository<ProductCode, Long> {
List<ProductCode> findByProductId(String productId);
long countByProductId(String productId);
@Transactional
void deleteByProductId(String productId);
}

View File

@@ -1,29 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface ProductRepository extends JpaRepository<Product, String> {
List<Product> findByActiveTrueOrderByCreatedAtDesc();
List<Product> findAllByOrderByCreatedAtDesc();
@Transactional
@Modifying
@Query("UPDATE Product p SET p.totalSold = p.totalSold + :count WHERE p.id = :id")
void incrementTotalSold(@Param("id") String id, @Param("count") int count);
@Transactional
@Modifying
@Query("UPDATE Product p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")
void incrementViewCount(@Param("id") String id);
}

View File

@@ -1,9 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SiteSettingRepository extends JpaRepository<SiteSetting, String> {
}

View File

@@ -1,22 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.repository;
import com.smyhub.store.mengyastorebackendjava.entity.Wishlist;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Repository
public interface WishlistRepository extends JpaRepository<Wishlist, Long> {
List<Wishlist> findByAccountId(String accountId);
Optional<Wishlist> findByAccountIdAndProductId(String accountId, String productId);
boolean existsByAccountIdAndProductId(String accountId, String productId);
@Transactional
void deleteByAccountIdAndProductId(String accountId, String productId);
}

View File

@@ -1,46 +0,0 @@
package com.smyhub.store.mengyastorebackendjava.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class AdminTokenInterceptor implements HandlerInterceptor {
private final AppProperties appProperties;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = resolveToken(request);
if (StringUtils.hasText(token) && token.equals(appProperties.getAdminToken())) {
return true;
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(), ApiResponse.error("unauthorized"));
return false;
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("X-Admin-Token");
if (StringUtils.hasText(token)) {
return token;
}
token = request.getHeader("Authorization");
if (StringUtils.hasText(token)) {
return token;
}
return request.getParameter("token");
}
}

Some files were not shown because too many files have changed in this diff Show More