Update mengyastore

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,26 @@
module mengyastore-backend
go 1.21.0
go 1.23.0
require (
github.com/gin-contrib/cors v1.7.2
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/joho/godotenv v1.5.1
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/gorm v1.31.1
)
require (
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/loader v0.1.1 // 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/gabriel-vasile/mimetype v1.4.3 // 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/universal-translator v0.18.1 // 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/jinzhu/inflection v1.0.0 // 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/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/mailru/easyjson v0.7.6 // 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/reflect2 v1.0.2 // 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/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.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
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,5 +1,15 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
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/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/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/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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
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/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.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
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/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/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/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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
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/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
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.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.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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
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/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/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/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
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.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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/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-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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=

View File

@@ -10,7 +10,7 @@ import (
"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 {
AppEnv string
AdminToken string
@@ -31,17 +31,26 @@ type Config struct {
RabbitMQEnabled bool
RabbitMQURL 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.
// APP_ENV=production → prod DB default, RABBITMQ_ENV default prod
// Otherwise → development defaults (test DB, rabbit dev).
// 应用环境:在未设置 DATABASE_DSN / RABBITMQ_ENV 时决定默认值。
// APP_ENV=production → 生产库 DSN、RABBITMQ_ENV 默认 prod
// 否则 → 开发默认测试库、RabbitMQ 开发环境)。
const (
EnvDevelopment = "development"
EnvProduction = "production"
)
// Built-in DSN fallbacks when DATABASE_DSN is empty.
// 内建 DSN DATABASE_DSN 为空时使用。
const (
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"
@@ -60,26 +69,33 @@ const (
DefaultRedisPassword = "tyh@19900420"
)
// Load reads optional .env file(s) then builds Config from the process environment.
// Load 读取可选的 env 文件,再从进程环境组装 Config。
//
// Dotenv resolution order:
// 1. File named by ENV_FILE (if set)
// 2. .env in current working directory
// 解析 env 文件的顺序:
// 1. 环境变量 ENV_FILE 指定的文件(若设置了)
// 2. 当前工作目录下的 .env.development不存在则忽略
//
// Variables (all optional unless noted):
// 环境变量说明(除非注明否则均可选):
//
// APP_ENV — development | production (default: development)
// ADMIN_TOKEN — admin API token (default: changeme)
// AUTH_API_URL — SproutGate base URL
// DATABASE_DSN — MySQL DSN; if empty, uses TestDSN or ProdDSN from APP_ENV
// APP_ENV — development | production(默认 development
// ADMIN_TOKEN — 管理端 API 令牌(默认 changeme
// AUTH_API_URL — 萌芽认证中心 SproutGate URL
// DATABASE_DSN — MySQL DSN;为空则按 APP_ENV 选用 TestDSN / ProdDSN
// RABBITMQ_ENABLED — true/false
// RABBITMQ_URL — full amqp URL
// RABBITMQ_ENV — dev | prod (default from APP_ENV)
// RABBITMQ_PASSWORD — if RABBITMQ_URL empty but RABBITMQ_ENABLED, builds URL with host/vhost defaults
// RABBITMQ_URL — 完整 amqp URL
// RABBITMQ_ENV — dev | prod(默认随 APP_ENV
// RABBITMQ_PASSWORD — 若已启用 MQ 但未设 RABBITMQ_URL则用默认主机/vhost 拼 URL 时需要口令
//
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
//
// WEBHOOK_MENGYA_SECRET — 若设置,到账 Webhook 须带相同请求头 X-Webhook-Secret
// PAYMENT_PENDING_SECONDS — 萌芽支付待支付超时(秒,默认 60
//
// GIN_DEBUG — 1/true/yes/onGin Debug默认关闭为真时同时开启 Swagger UI/swagger
// ENABLE_SWAGGER — 1/true/yes/on启用 Swagger UI与 GIN_DEBUG 任一为真即注册 /swagger
// WEBHOOK_LOG_VERBOSE — 1/true萌芽 Webhook 日志附带 notice/body 摘要(排查渠道用)
//
// REDIS_ENABLED — 默认 true显式 false/0/off 则关闭
// REDIS_ADDR — 默认 development→TestRedisAddrproduction→ProdRedisAddr
// 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
}
// 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 {
s := strings.ToLower(strings.TrimSpace(v))
if s == "false" || s == "0" || s == "no" || s == "off" {
@@ -188,8 +228,8 @@ func loadDotenv() {
_ = godotenv.Load(f)
return
}
// Standard local file; ignore missing.
_ = godotenv.Load(filepath.Clean(".env"))
// 本地开发默认文件名;不存在则忽略。
_ = godotenv.Load(filepath.Clean(".env.development"))
}
func normalizeAppEnv(s string) string {

View File

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

View File

@@ -7,7 +7,7 @@ import (
"time"
)
// StringSlice is a JSON-serialized string slice stored as a MySQL TEXT/JSON column.
// StringSlice 表示以 JSON 序列化形式存入 MySQL TEXT/JSON 列的字符串切片。
type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) {
@@ -31,9 +31,9 @@ func (s *StringSlice) Scan(src any) error {
return json.Unmarshal(raw, s)
}
// ─── Products ────────────────────────────────────────────────────────────────
// ─── 商品 ─────────────────────────────────────────────────────────────────────
// ProductRow is the GORM model for the `products` table.
// ProductRow `products` 表的 GORM 模型。
type ProductRow struct {
ID string `gorm:"primaryKey;size:36"`
Name string `gorm:"size:255;not null"`
@@ -55,12 +55,14 @@ type ProductRow struct {
FixedContent string `gorm:"type:text"`
ShowNote 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"`
}
func (ProductRow) TableName() string { return "products" }
// ProductCodeRow stores individual codes for a product (one row per code).
// ProductCodeRow 单条卡密一行,按商品维度存储。
type ProductCodeRow struct {
ID uint `gorm:"primaryKey;autoIncrement"`
ProductID string `gorm:"size:36;not null;index"`
@@ -69,7 +71,7 @@ type ProductCodeRow struct {
func (ProductCodeRow) TableName() string { return "product_codes" }
// ─── Orders ──────────────────────────────────────────────────────────────────
// ─── 订单 ─────────────────────────────────────────────────────────────────────
type OrderRow struct {
ID string `gorm:"primaryKey;size:36"`
@@ -85,14 +87,20 @@ type OrderRow struct {
ContactPhone string `gorm:"size:50"`
ContactEmail 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
}
func (OrderRow) TableName() string { return "orders" }
// ─── Site settings ───────────────────────────────────────────────────────────
// ─── 站点设置 ─────────────────────────────────────────────────────────────────
// SiteSettingRow stores arbitrary key-value pairs for site-wide settings.
// SiteSettingRow 站点级键值配置。
type SiteSettingRow struct {
Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"type:text"`
@@ -100,7 +108,7 @@ type SiteSettingRow struct {
func (SiteSettingRow) TableName() string { return "site_settings" }
// ─── Wishlists ───────────────────────────────────────────────────────────────
// ─── 收藏夹 ───────────────────────────────────────────────────────────────────
type WishlistRow struct {
ID uint `gorm:"primaryKey;autoIncrement"`
@@ -110,7 +118,7 @@ type WishlistRow struct {
func (WishlistRow) TableName() string { return "wishlists" }
// ─── Chat messages ───────────────────────────────────────────────────────────
// ─── 聊天消息 ─────────────────────────────────────────────────────────────────
type ChatMessageRow struct {
ID string `gorm:"primaryKey;size:36"`

View File

@@ -8,6 +8,14 @@ import (
)
// 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) {
if !h.requireAdmin(c) {
return
@@ -21,6 +29,16 @@ func (h *AdminHandler) GetAllConversations(c *gin.Context) {
}
// 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) {
if !h.requireAdmin(c) {
return
@@ -38,11 +56,23 @@ func (h *AdminHandler) GetConversation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
}
type adminChatPayload struct {
type AdminChatPayload struct {
Content string `json:"content"`
}
// 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) {
if !h.requireAdmin(c) {
return
@@ -52,7 +82,7 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return
}
var payload adminChatPayload
var payload AdminChatPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -71,6 +101,16 @@ func (h *AdminHandler) AdminReply(c *gin.Context) {
}
// 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) {
if !h.requireAdmin(c) {
return

View File

@@ -6,6 +6,15 @@ import (
"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) {
if !h.requireAdmin(c) {
return
@@ -18,6 +27,17 @@ func (h *AdminHandler) ListAllOrders(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return

View File

@@ -9,7 +9,7 @@ import (
"mengyastore-backend/internal/models"
)
type productPayload struct {
type ProductPayload struct {
Name string `json:"name"`
Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"`
@@ -17,6 +17,7 @@ type productPayload struct {
CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"`
PaymentQrURLs []string `json:"paymentQrUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"`
@@ -28,7 +29,7 @@ type productPayload struct {
ShowContact bool `json:"showContact"`
}
func normalizeFulfillmentPayload(payload *productPayload) string {
func normalizeFulfillmentPayload(payload *ProductPayload) string {
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
if ft == "fixed" {
return "fixed"
@@ -36,16 +37,26 @@ func normalizeFulfillmentPayload(payload *productPayload) string {
return "card"
}
type togglePayload struct {
type TogglePayload struct {
Active bool `json:"active"`
}
// VerifyAdminToken checks whether the supplied token is correct.
// Returns {"valid": true/false} without leaking the real token value.
// AdminVerifyTokenRequest 管理端校验令牌Body非 Header
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) {
var payload struct {
Token string `json:"token"`
}
var payload AdminVerifyTokenRequest
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false})
return
@@ -53,6 +64,15 @@ func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return
@@ -65,18 +85,35 @@ func (h *AdminHandler) ListAllProducts(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return
}
var payload productPayload
var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
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
}
active := true
@@ -100,6 +137,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
@@ -118,19 +156,38 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload productPayload
var payload ProductPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
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
}
active := false
@@ -154,6 +211,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes,
ScreenshotURLs: screenshotURLs,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
@@ -172,12 +230,25 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return
}
id := c.Param("id")
var payload togglePayload
var payload TogglePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -190,6 +261,16 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return
@@ -202,6 +283,26 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
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) {
cleaned := make([]string, 0, len(urls))
for _, url := range urls {
@@ -210,7 +311,7 @@ func normalizeScreenshotURLs(urls []string) ([]string, bool) {
continue
}
cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 {
if len(cleaned) > maxScreenshotURLsAdmin {
return nil, false
}
}

View File

@@ -8,16 +8,28 @@ import (
"mengyastore-backend/internal/storage"
)
type maintenancePayload struct {
type MaintenancePayload struct {
Maintenance bool `json:"maintenance"`
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) {
if !h.requireAdmin(c) {
return
}
var payload maintenancePayload
var payload MaintenancePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
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) {
if !h.requireAdmin(c) {
return
@@ -51,6 +72,18 @@ func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
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) {
if !h.requireAdmin(c) {
return

View File

@@ -17,7 +17,7 @@ import (
"mengyastore-backend/internal/mq"
)
// SystemStatusHandler exposes admin-only operational status.
// SystemStatusHandler 向管理端暴露运维状态(仅供管理员)。
type SystemStatusHandler struct {
cfg *config.Config
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}
}
// 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) {
if !h.adminTokenOK(c) {
return
@@ -278,7 +285,7 @@ func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
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) {
if raw == "" {
return "", "", "", ""

View File

@@ -35,6 +35,14 @@ func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok
}
// 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) {
account, _, ok := h.requireChatUser(c)
if !ok {
@@ -48,17 +56,30 @@ func (h *ChatHandler) GetMyMessages(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
}
type chatMessagePayload struct {
// ChatMessagePayload 用户发送消息请求体。
type ChatMessagePayload struct {
Content string `json:"content"`
}
// 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) {
account, name, ok := h.requireChatUser(c)
if !ok {
return
}
var payload chatMessagePayload
var payload ChatMessagePayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,13 @@ func NewPublicHandler(store *storage.ProductStore) *PublicHandler {
return &PublicHandler{store: store}
}
// ListProducts 上架商品列表(公开字段,不含卡密与管理信息)。
// @Summary 上架商品列表
// @Tags 公开
// @Produce json
// @Success 200 {object} SwaggerProductListBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/products [get]
func (h *PublicHandler) ListProducts(c *gin.Context) {
items, err := h.store.ListActive()
if err != nil {
@@ -27,6 +34,14 @@ func (h *PublicHandler) ListProducts(c *gin.Context) {
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) {
id := c.Param("id")
fingerprint := buildViewerFingerprint(c)

View File

@@ -17,6 +17,13 @@ func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStor
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) {
totalOrders, err := h.orderStore.Count()
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) {
fingerprint := buildViewerFingerprint(c)
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) {
enabled, reason, err := h.siteStore.GetMaintenance()
if err != nil {

View File

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

View File

@@ -34,6 +34,15 @@ func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
return result.User.Account, true
}
// 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) {
account, ok := h.requireUser(c)
if !ok {
@@ -47,16 +56,29 @@ func (h *WishlistHandler) GetWishlist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
}
type wishlistItemPayload struct {
// WishlistItemPayload 添加收藏请求体。
type WishlistItemPayload struct {
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) {
account, ok := h.requireUser(c)
if !ok {
return
}
var payload wishlistItemPayload
var payload WishlistItemPayload
if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
@@ -69,6 +91,17 @@ func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
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) {
account, ok := h.requireUser(c)
if !ok {

View File

@@ -16,5 +16,8 @@ type Order struct {
ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"`
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"`
}

View File

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

View File

@@ -15,7 +15,7 @@ import (
"mengyastore-backend/internal/storage"
)
// Client manages a single AMQP connection, a publish channel, and naming for one env.
// Client 封装单条 AMQP 连接、发布用 Channel以及单环境的命名。
type Client struct {
conn *amqp.Connection
pubCh *amqp.Channel
@@ -32,7 +32,7 @@ type Client struct {
consumerSite *storage.SiteStore
}
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
// New 连接 RabbitMQ 并声明交换机 + 队列 + 绑定(幂等)。
func New(amqpURL, env string) (*Client, error) {
if amqpURL == "" {
return nil, fmt.Errorf("empty amqp url")
@@ -141,7 +141,7 @@ func (c *Client) passiveQueueLocked() error {
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 {
body, err := json.Marshal(p)
if err != nil {
@@ -190,7 +190,7 @@ func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) err
return nil
}
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
// QueueInspectInfo 返回当前队列深度与消费者数(被动声明查询)。
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -216,7 +216,7 @@ func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
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 {
c.mu.Lock()
defer c.mu.Unlock()
@@ -231,10 +231,10 @@ func (c *Client) Ping() error {
return err
}
// Env returns the sanitized environment suffix used in exchange/queue names.
// 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) {
c.mu.Lock()
c.consumerCtx = ctx
@@ -248,7 +248,7 @@ func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
RunOrderEmailConsumer(ctx, conn, env, site)
}
// Close releases the publish channel and connection.
// Close 关闭发布 Channel 与连接。
func (c *Client) Close() {
c.closing.Do(func() {
c.mu.Lock()

View File

@@ -11,7 +11,7 @@ import (
"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) {
if conn == nil || site == nil {
return

View File

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

View File

@@ -9,15 +9,15 @@ import (
const routingKeyOrderEmail = "order.email.notify"
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
// OrderEmailRoutingKey 订单通知消息的绑定键 / 发布 routing key。
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 {
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 {
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"mengyastore-backend/internal/database"
"mengyastore-backend/internal/models"
@@ -16,7 +17,8 @@ import (
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
const viewCooldown = 6 * time.Hour
const maxScreenshotURLs = 5
const maxScreenshotURLs = 6
const maxPaymentQrURLs = 6
type ProductStore struct {
db *gorm.DB
@@ -55,6 +57,7 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
Tags: row.Tags,
CoverURL: row.CoverURL,
ScreenshotURLs: row.ScreenshotURLs,
PaymentQrURLs: []string(row.PaymentQrURLs),
VerificationURL: row.VerificationURL,
Description: row.Description,
Active: row.Active,
@@ -74,8 +77,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
}
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
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
}
codes := make([]string, len(rows))
@@ -85,8 +92,46 @@ func (s *ProductStore) loadCodes(productID string) ([]string, error) {
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 {
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
}
if len(codes) == 0 {
@@ -96,7 +141,7 @@ func (s *ProductStore) replaceCodes(productID string, codes []string) error {
for _, code := range codes {
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) {
@@ -158,6 +203,7 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
Tags: database.StringSlice(p.Tags),
CoverURL: p.CoverURL,
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
PaymentQrURLs: database.StringSlice(p.PaymentQrURLs),
VerificationURL: p.VerificationURL,
Description: p.Description,
Active: p.Active,
@@ -198,6 +244,7 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
"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,
@@ -221,6 +268,45 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
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) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil {
return models.Product{}, err
@@ -266,10 +352,8 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
return rowToModel(row, nil), true, nil
}
// IncrementViewCount atomically increments view_count by 1 and returns the
// updated product (without codes). It is called by the handler when Redis-based
// deduplication has already confirmed this is a new view, so no in-memory lock
// is needed here.
// IncrementViewCount 原子地将 view_count +1并返回更新后的商品不含卡密
// 在 Handler 已用 Redis 去重确认本次为新浏览时调用,故此处无需进程内锁。
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
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
}
// GetViewState returns the current product state without modifying any counter.
// Used by the handler when a view is detected as duplicate via Redis.
// GetViewState 只读取当前商品状态,不修改任何计数。
// 在 Handler 已通过 Redis 判定为重复访问时使用。
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
var row database.ProductRow
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 = sanitizeTags(item.Tags)
if item.PaymentQrURLs == nil {
item.PaymentQrURLs = []string{}
}
if item.ScreenshotURLs == nil {
item.ScreenshotURLs = []string{}
}
if len(item.ScreenshotURLs) > maxScreenshotURLs {
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
}
item.PaymentQrURLs = sanitizePaymentQrURLs(item.PaymentQrURLs)
if item.Codes == nil {
item.Codes = []string{}
}
@@ -340,6 +428,23 @@ func normalizeProduct(item models.Product) models.Product {
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 {
clean := make([]string, 0, len(codes))
seen := map[string]bool{}

View File

@@ -20,7 +20,7 @@ func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow
// `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 {
return "", err
}

View File

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

View File

@@ -12,7 +12,7 @@
| google/uuid | 订单 ID |
| github.com/rabbitmq/amqp091-go | RabbitMQ可选 |
| 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` | 订单数、访问量等 |
| POST | `/api/site/visit` | 站点访问计数 |
| 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 |
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
@@ -97,6 +99,7 @@ mengyastore-backend-go/
|------|------|
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
| payment_qr_urls | JSON 数组:`paymentQrUrls`,萌芽支付收款码图片直链(最多 10管理端填写 |
| 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 … |
@@ -106,7 +109,7 @@ mengyastore-backend-go/
### 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
@@ -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仅开发便利
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
- RabbitMQ、Redis 开关与地址见 `internal/config/config.go` 注释。
- `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`(默认)
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
1. `POST /api/checkout` 校验库存 ≥ 购买数量**萌芽支付**下为预留待付,见上文)
2.`product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
3. 销量 `IncrementSold`;邮件/MQ 通知。
3. 销量 `IncrementSold`**免费单**在结账时计入;**付费萌芽支付**在 Webhook 核销后 `IncrementSold`;邮件/MQ 通知。
### fulfillment_type = `fixed`
1. 不校验卡密条数;不修改 `product_codes`
2.`fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
3.`IncrementSold`;邮件/MQ 同自动发货路径。
3.`IncrementSold`(规则同 card;邮件/MQ 同自动发货路径。
### delivery_mode订单
@@ -147,10 +171,12 @@ KV如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺
## 本地开发
**`mengyastore-backend-go`** 目录放置 **`.env.development`**(可被 git 忽略),或导出环境变量后再启动。
```bash
go run .
go build -o mengyastore-backend.exe .
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
go run ./cmd/migrate/main.go # 按需迁移旧 JSON;同样读取 .env.development 或 ENV_FILE
```
---