Update mengyastore
This commit is contained in:
3
mengyastore-backend-go/.gitignore
vendored
3
mengyastore-backend-go/.gitignore
vendored
@@ -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
|
||||||
|
|||||||
3
mengyastore-backend-go/Makefile
Normal file
3
mengyastore-backend-go/Makefile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.PHONY: swagger
|
||||||
|
swagger:
|
||||||
|
swag init -g main.go -o docs --parseDependency --parseInternal
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2193
mengyastore-backend-go/docs/docs.go
Normal file
2193
mengyastore-backend-go/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2174
mengyastore-backend-go/docs/swagger.json
Normal file
2174
mengyastore-backend-go/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1390
mengyastore-backend-go/docs/swagger.yaml
Normal file
1390
mengyastore-backend-go/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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/on:Gin 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→TestRedisAddr,production→ProdRedisAddr
|
// REDIS_ADDR — 默认 development→TestRedisAddr,production→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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "", "", "", ""
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
162
mengyastore-backend-go/internal/handlers/swagger_models.go
Normal file
162
mengyastore-backend-go/internal/handlers/swagger_models.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
143
mengyastore-backend-go/internal/payment/mengya.go
Normal file
143
mengyastore-backend-go/internal/payment/mengya.go
Normal 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*元`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// AmountEpsilon:Webhook 到账金额与订单快照金额对比时的容差。
|
||||||
|
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, "; ")
|
||||||
|
}
|
||||||
3
mengyastore-backend-go/internal/payment/types.go
Normal file
3
mengyastore-backend-go/internal/payment/types.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
const MethodMengya = "mengya"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ok;rabbitmq 为 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
2
mengyastore-backend-java/.gitattributes
vendored
2
mengyastore-backend-java/.gitattributes
vendored
@@ -1,2 +0,0 @@
|
|||||||
/mvnw text eol=lf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
33
mengyastore-backend-java/.gitignore
vendored
33
mengyastore-backend-java/.gitignore
vendored
@@ -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/
|
|
||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
services: { }
|
|
||||||
295
mengyastore-backend-java/mvnw
vendored
295
mengyastore-backend-java/mvnw
vendored
@@ -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 "$@"
|
|
||||||
189
mengyastore-backend-java/mvnw.cmd
vendored
189
mengyastore-backend-java/mvnw.cmd
vendored
@@ -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"
|
|
||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() : "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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<>();
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class AdminVerifyRequest {
|
|
||||||
private String token;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class MaintenanceRequest {
|
|
||||||
private boolean enabled;
|
|
||||||
private String reason = "";
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class ProductStatusRequest {
|
|
||||||
private boolean active;
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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("服务器内部错误"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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> {
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user