feat: fulfillment modes, MQ, admin status, docs; scrub compose secrets
Made-with: Cursor
This commit is contained in:
5
mengyastore-backend-go/.gitignore
vendored
Normal file
5
mengyastore-backend-go/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Local secrets — use .env.example / .env.production.example as templates
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.exe
|
||||
@@ -1,256 +0,0 @@
|
||||
# 萌芽小店 · 后端
|
||||
|
||||
基于 **Go + Gin + GORM** 构建的 RESTful API 服务,负责商品管理、订单处理、用户认证、聊天消息等核心业务。
|
||||
|
||||
## 技术依赖
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| gin | v1.9 | HTTP 路由框架 |
|
||||
| gorm | v1.31 | ORM |
|
||||
| gorm/driver/mysql | v1.6 | MySQL 驱动 |
|
||||
| go-sql-driver/mysql | v1.9 | 底层 MySQL 连接 |
|
||||
| gin-contrib/cors | latest | CORS 中间件 |
|
||||
| google/uuid | latest | UUID 生成 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mengyastore-backend/
|
||||
├── main.go # 程序入口,路由注册
|
||||
├── cmd/
|
||||
│ └── migrate/
|
||||
│ └── main.go # 一次性 JSON→MySQL 数据迁移脚本
|
||||
├── data/
|
||||
│ └── json/
|
||||
│ └── settings.json # 服务配置(adminToken、DSN 等)
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go # 配置加载
|
||||
│ ├── database/
|
||||
│ │ ├── db.go # GORM 初始化 + AutoMigrate
|
||||
│ │ └── models.go # 数据库行结构体(GORM 模型)
|
||||
│ ├── models/
|
||||
│ │ ├── product.go # 业务模型 Product
|
||||
│ │ ├── order.go # 业务模型 Order
|
||||
│ │ └── chat.go # 业务模型 ChatMessage
|
||||
│ ├── storage/
|
||||
│ │ ├── jsonstore.go # 商品存储(GORM 实现)
|
||||
│ │ ├── orderstore.go # 订单存储
|
||||
│ │ ├── sitestore.go # 站点设置存储
|
||||
│ │ ├── wishliststore.go # 收藏夹存储
|
||||
│ │ └── chatstore.go # 聊天消息存储(含内存级频率限制)
|
||||
│ ├── handlers/
|
||||
│ │ ├── admin.go # AdminHandler 结构体 + requireAdmin
|
||||
│ │ ├── admin_product.go # 商品 CRUD 接口
|
||||
│ │ ├── admin_site.go # 维护模式接口
|
||||
│ │ ├── admin_orders.go # 订单管理接口
|
||||
│ │ ├── admin_chat.go # 管理员聊天接口
|
||||
│ │ ├── public.go # 公开接口(商品列表、浏览量)
|
||||
│ │ ├── order.go # 下单、确认订单接口
|
||||
│ │ ├── stats.go # 统计信息接口
|
||||
│ │ ├── wishlist.go # 收藏夹接口(用户)
|
||||
│ │ └── chat.go # 聊天接口(用户)
|
||||
│ └── auth/
|
||||
│ └── sproutgate.go # SproutGate OAuth 客户端
|
||||
```
|
||||
|
||||
## API 路由一览
|
||||
|
||||
### 公开接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 健康检查 |
|
||||
| GET | `/api/products` | 获取商品列表(仅 active) |
|
||||
| POST | `/api/products/:id/view` | 记录商品浏览量 |
|
||||
| GET | `/api/stats` | 获取总订单数和总访问量 |
|
||||
| POST | `/api/site/visit` | 记录站点访问 |
|
||||
| GET | `/api/site/maintenance` | 获取维护状态 |
|
||||
| POST | `/api/checkout` | 创建订单(生成支付二维码) |
|
||||
| GET | `/api/orders` | 获取当前用户订单(需 Bearer token) |
|
||||
| POST | `/api/orders/:id/confirm` | 确认付款(触发发货) |
|
||||
|
||||
### 收藏夹(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/wishlist` | 获取收藏商品 ID 列表 |
|
||||
| POST | `/api/wishlist` | 添加收藏 |
|
||||
| DELETE | `/api/wishlist/:id` | 取消收藏 |
|
||||
|
||||
### 聊天(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/chat/messages` | 获取自己的聊天记录 |
|
||||
| POST | `/api/chat/messages` | 发送消息(1 秒频率限制) |
|
||||
|
||||
### 管理员接口(需 `?token=xxx` 或 `Authorization: <token>`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/admin/token` | 获取令牌(用于验证) |
|
||||
| GET | `/api/admin/products` | 获取全部商品(含卡密) |
|
||||
| POST | `/api/admin/products` | 创建商品 |
|
||||
| PUT | `/api/admin/products/:id` | 编辑商品 |
|
||||
| PATCH | `/api/admin/products/:id/status` | 切换上下架 |
|
||||
| DELETE | `/api/admin/products/:id` | 删除商品 |
|
||||
| POST | `/api/admin/site/maintenance` | 设置维护模式 |
|
||||
| GET | `/api/admin/orders` | 获取全部订单 |
|
||||
| DELETE | `/api/admin/orders/:id` | 删除订单 |
|
||||
| GET | `/api/admin/chat` | 获取全部用户对话 |
|
||||
| GET | `/api/admin/chat/:account` | 获取指定用户对话 |
|
||||
| POST | `/api/admin/chat/:account` | 管理员回复 |
|
||||
| DELETE | `/api/admin/chat/:account` | 清除对话 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### products
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| name | varchar(255) | 商品名称 |
|
||||
| price | double | 原价 |
|
||||
| discount_price | double | 折扣价(0 = 无折扣)|
|
||||
| tags | json | 标签数组 |
|
||||
| cover_url | varchar(500) | 封面图 URL |
|
||||
| screenshot_urls | json | 截图 URL 数组(最多 5 张)|
|
||||
| verification_url | varchar(500) | 验证链接 |
|
||||
| description | text | Markdown 描述 |
|
||||
| active | tinyint(1) | 是否上架 |
|
||||
| require_login | tinyint(1) | 是否必须登录购买 |
|
||||
| max_per_account | bigint | 每账户最大购买数(0=不限)|
|
||||
| total_sold | bigint | 累计销量 |
|
||||
| view_count | bigint | 累计浏览量 |
|
||||
| delivery_mode | varchar(20) | 发货模式:`auto` / `manual` |
|
||||
| show_note | tinyint(1) | 下单时显示备注输入框 |
|
||||
| show_contact | tinyint(1) | 下单时显示联系方式输入框 |
|
||||
| created_at | datetime(3) | 创建时间 |
|
||||
|
||||
### product_codes
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | bigint unsigned | 自增主键 |
|
||||
| product_id | varchar(36) | 关联商品 ID(索引)|
|
||||
| code | text | 卡密内容 |
|
||||
|
||||
### orders
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| product_id | varchar(36) | 商品 ID(索引)|
|
||||
| product_name | varchar(255) | 商品名称快照 |
|
||||
| user_account | varchar(255) | 用户账号(可空,匿名)|
|
||||
| user_name | varchar(255) | 用户昵称 |
|
||||
| quantity | bigint | 购买数量 |
|
||||
| delivered_codes | json | 已发放卡密 |
|
||||
| status | varchar(20) | `pending` / `completed` |
|
||||
| delivery_mode | varchar(20) | `auto` / `manual` |
|
||||
| note | text | 用户备注 |
|
||||
| contact_phone | varchar(50) | 联系手机号 |
|
||||
| contact_email | varchar(255) | 联系邮箱 |
|
||||
| created_at | datetime(3) | 下单时间 |
|
||||
|
||||
### site_settings
|
||||
|
||||
键值对存储,当前使用的键:
|
||||
|
||||
| Key | 说明 |
|
||||
|-----|------|
|
||||
| `totalVisits` | 总访问量 |
|
||||
| `maintenance` | 维护模式(`true` / `false`)|
|
||||
| `maintenanceReason` | 维护原因文本 |
|
||||
|
||||
### wishlists
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | bigint unsigned | 自增主键 |
|
||||
| account_id | varchar(255) | 用户账号(唯一索引)|
|
||||
| product_id | varchar(36) | 商品 ID(联合唯一)|
|
||||
|
||||
### chat_messages
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | varchar(36) | UUID 主键 |
|
||||
| account_id | varchar(255) | 用户账号(索引)|
|
||||
| account_name | varchar(255) | 用户昵称 |
|
||||
| content | text | 消息内容 |
|
||||
| sent_at | datetime(3) | 发送时间 |
|
||||
| from_admin | tinyint(1) | 是否来自管理员 |
|
||||
|
||||
## 配置文件
|
||||
|
||||
`data/json/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"adminToken": "你的管理员令牌",
|
||||
"authApiUrl": "https://auth.api.shumengya.top",
|
||||
"databaseDsn": ""
|
||||
}
|
||||
```
|
||||
|
||||
`databaseDsn` 为空时自动使用测试数据库。也可以通过环境变量 `DATABASE_DSN` 覆盖。
|
||||
|
||||
## 发货逻辑
|
||||
|
||||
### 自动发货(`deliveryMode = "auto"`)
|
||||
|
||||
1. `POST /api/checkout` → 从 `product_codes` 提取指定数量的卡密
|
||||
2. 商品 `quantity` 减少,卡密从数据库删除
|
||||
3. 卡密保存到订单 `delivered_codes`
|
||||
4. 用户 `POST /api/orders/:id/confirm` 确认付款后,订单状态变为 `completed`,响应中返回卡密内容
|
||||
5. 同时调用 `IncrementSold` 增加销量统计
|
||||
|
||||
### 手动发货(`deliveryMode = "manual"`)
|
||||
|
||||
1. `POST /api/checkout` → 创建订单,不提取卡密
|
||||
2. 用户 `POST /api/orders/:id/confirm` 后,订单变为 `completed`,但 `delivered_codes` 为空
|
||||
3. 管理员在后台查看订单的备注、手机号、邮箱后手动发货
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
go run . # 启动服务(默认 :8080)
|
||||
go build -o mengyastore-backend.exe . # 构建可执行文件
|
||||
go run ./cmd/migrate/main.go # 迁移旧 JSON 数据到数据库
|
||||
```
|
||||
|
||||
### 切换数据库
|
||||
|
||||
```bash
|
||||
# 测试库(默认)
|
||||
# host: 10.1.1.100:3306 / db: mengyastore-test
|
||||
|
||||
# 生产库
|
||||
set DATABASE_DSN=mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local
|
||||
./mengyastore-backend.exe
|
||||
```
|
||||
|
||||
## 认证说明
|
||||
|
||||
### 用户认证
|
||||
|
||||
通过 SproutGate OAuth 服务验证 Bearer Token:
|
||||
|
||||
```go
|
||||
result, err := authClient.VerifyToken(token)
|
||||
// result.Valid, result.User.Account, result.User.Username
|
||||
```
|
||||
|
||||
### 管理员认证
|
||||
|
||||
管理员令牌通过查询参数或 Authorization 头传入:
|
||||
|
||||
```
|
||||
GET /api/admin/products?token=xxx
|
||||
Authorization: xxx
|
||||
```
|
||||
|
||||
令牌与 `settings.json` 中的 `adminToken` 比对。
|
||||
@@ -1,304 +1,305 @@
|
||||
// migrate imports existing JSON data files into the MySQL database.
|
||||
// Run once after switching to DB storage:
|
||||
//
|
||||
// go run ./cmd/migrate/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("../../config.json")
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.DatabaseDSN), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
// Ensure tables exist
|
||||
if err := db.AutoMigrate(
|
||||
&database.ProductRow{},
|
||||
&database.ProductCodeRow{},
|
||||
&database.OrderRow{},
|
||||
&database.SiteSettingRow{},
|
||||
&database.WishlistRow{},
|
||||
&database.ChatMessageRow{},
|
||||
); err != nil {
|
||||
log.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
log.Println("数据库连接成功,开始导入...")
|
||||
migrateProducts(db)
|
||||
migrateOrders(db)
|
||||
migrateWishlists(db)
|
||||
migrateChats(db)
|
||||
migrateSite(db)
|
||||
log.Println("✅ 数据导入完成!")
|
||||
}
|
||||
|
||||
// ─── Products ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonProduct struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags []string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
TotalSold int `json:"totalSold"`
|
||||
ViewCount int `json:"viewCount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
Codes []string `json:"codes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func migrateProducts(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/products.json")
|
||||
if err != nil {
|
||||
log.Printf("[products] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var products []jsonProduct
|
||||
if err := json.Unmarshal(data, &products); err != nil {
|
||||
log.Printf("[products] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, p := range products {
|
||||
if p.ID == "" {
|
||||
continue
|
||||
}
|
||||
if p.DeliveryMode == "" {
|
||||
p.DeliveryMode = "auto"
|
||||
}
|
||||
row := database.ProductRow{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Price: p.Price,
|
||||
DiscountPrice: p.DiscountPrice,
|
||||
Tags: database.StringSlice(p.Tags),
|
||||
CoverURL: p.CoverURL,
|
||||
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
|
||||
Description: p.Description,
|
||||
Active: p.Active,
|
||||
RequireLogin: p.RequireLogin,
|
||||
MaxPerAccount: p.MaxPerAccount,
|
||||
TotalSold: p.TotalSold,
|
||||
ViewCount: p.ViewCount,
|
||||
DeliveryMode: p.DeliveryMode,
|
||||
ShowNote: p.ShowNote,
|
||||
ShowContact: p.ShowContact,
|
||||
CreatedAt: p.CreatedAt,
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
}
|
||||
result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if result.Error != nil {
|
||||
log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error)
|
||||
continue
|
||||
}
|
||||
// Codes → product_codes
|
||||
for _, code := range p.Codes {
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ProductCodeRow{
|
||||
ProductID: p.ID,
|
||||
Code: code,
|
||||
})
|
||||
}
|
||||
}
|
||||
log.Printf("[products] 导入 %d 条商品", len(products))
|
||||
}
|
||||
|
||||
// ─── Orders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonOrder struct {
|
||||
ID string `json:"id"`
|
||||
ProductID string `json:"productId"`
|
||||
ProductName string `json:"productName"`
|
||||
UserAccount string `json:"userAccount"`
|
||||
UserName string `json:"userName"`
|
||||
Quantity int `json:"quantity"`
|
||||
DeliveredCodes []string `json:"deliveredCodes"`
|
||||
Status string `json:"status"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
Note string `json:"note"`
|
||||
ContactPhone string `json:"contactPhone"`
|
||||
ContactEmail string `json:"contactEmail"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func migrateOrders(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/orders.json")
|
||||
if err != nil {
|
||||
log.Printf("[orders] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var orders []jsonOrder
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
log.Printf("[orders] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, o := range orders {
|
||||
if o.ID == "" {
|
||||
continue
|
||||
}
|
||||
if o.DeliveryMode == "" {
|
||||
o.DeliveryMode = "auto"
|
||||
}
|
||||
if o.DeliveredCodes == nil {
|
||||
o.DeliveredCodes = []string{}
|
||||
}
|
||||
row := database.OrderRow{
|
||||
ID: o.ID,
|
||||
ProductID: o.ProductID,
|
||||
ProductName: o.ProductName,
|
||||
UserAccount: o.UserAccount,
|
||||
UserName: o.UserName,
|
||||
Quantity: o.Quantity,
|
||||
DeliveredCodes: database.StringSlice(o.DeliveredCodes),
|
||||
Status: o.Status,
|
||||
DeliveryMode: o.DeliveryMode,
|
||||
Note: o.Note,
|
||||
ContactPhone: o.ContactPhone,
|
||||
ContactEmail: o.ContactEmail,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
}
|
||||
if result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row); result.Error != nil {
|
||||
log.Printf("[orders] 导入 %s 失败: %v", o.ID, result.Error)
|
||||
}
|
||||
}
|
||||
log.Printf("[orders] 导入 %d 条订单", len(orders))
|
||||
}
|
||||
|
||||
// ─── Wishlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
func migrateWishlists(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/wishlists.json")
|
||||
if err != nil {
|
||||
log.Printf("[wishlists] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var wl map[string][]string
|
||||
if err := json.Unmarshal(data, &wl); err != nil {
|
||||
log.Printf("[wishlists] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for account, productIDs := range wl {
|
||||
for _, pid := range productIDs {
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.WishlistRow{
|
||||
AccountID: account,
|
||||
ProductID: pid,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Printf("[wishlists] 导入 %d 条收藏记录", count)
|
||||
}
|
||||
|
||||
// ─── Chats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonChatMsg struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Content string `json:"content"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
FromAdmin bool `json:"fromAdmin"`
|
||||
}
|
||||
|
||||
func migrateChats(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/chats.json")
|
||||
if err != nil {
|
||||
log.Printf("[chats] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var convs map[string][]jsonChatMsg
|
||||
if err := json.Unmarshal(data, &convs); err != nil {
|
||||
log.Printf("[chats] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, msgs := range convs {
|
||||
for _, m := range msgs {
|
||||
if m.ID == "" {
|
||||
continue
|
||||
}
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ChatMessageRow{
|
||||
ID: m.ID,
|
||||
AccountID: m.AccountID,
|
||||
AccountName: m.AccountName,
|
||||
Content: m.Content,
|
||||
SentAt: m.SentAt,
|
||||
FromAdmin: m.FromAdmin,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Printf("[chats] 导入 %d 条聊天消息", count)
|
||||
}
|
||||
|
||||
// ─── Site settings ────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonSite struct {
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
Maintenance bool `json:"maintenance"`
|
||||
MaintenanceReason string `json:"maintenanceReason"`
|
||||
}
|
||||
|
||||
func migrateSite(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/site.json")
|
||||
if err != nil {
|
||||
log.Printf("[site] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var site jsonSite
|
||||
if err := json.Unmarshal(data, &site); err != nil {
|
||||
log.Printf("[site] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
upsert := func(key, value string) {
|
||||
db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&database.SiteSettingRow{Key: key, Value: value})
|
||||
}
|
||||
upsert("totalVisits", strconv.Itoa(site.TotalVisits))
|
||||
maintenance := "false"
|
||||
if site.Maintenance {
|
||||
maintenance = "true"
|
||||
}
|
||||
upsert("maintenance", maintenance)
|
||||
upsert("maintenanceReason", site.MaintenanceReason)
|
||||
log.Printf("[site] 站点设置导入完成(访问量: %d)", site.TotalVisits)
|
||||
}
|
||||
// migrate imports existing JSON data files into the MySQL database.
|
||||
// Run once after switching to DB storage:
|
||||
//
|
||||
// go run ./cmd/migrate/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load from env / .env (run from repo root: go run ./cmd/migrate, or set ENV_FILE).
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.DatabaseDSN), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
// Ensure tables exist
|
||||
if err := db.AutoMigrate(
|
||||
&database.ProductRow{},
|
||||
&database.ProductCodeRow{},
|
||||
&database.OrderRow{},
|
||||
&database.SiteSettingRow{},
|
||||
&database.WishlistRow{},
|
||||
&database.ChatMessageRow{},
|
||||
); err != nil {
|
||||
log.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
|
||||
log.Println("数据库连接成功,开始导入...")
|
||||
migrateProducts(db)
|
||||
migrateOrders(db)
|
||||
migrateWishlists(db)
|
||||
migrateChats(db)
|
||||
migrateSite(db)
|
||||
log.Println("✅ 数据导入完成!")
|
||||
}
|
||||
|
||||
// ─── Products ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonProduct struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags []string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
TotalSold int `json:"totalSold"`
|
||||
ViewCount int `json:"viewCount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
Codes []string `json:"codes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func migrateProducts(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/products.json")
|
||||
if err != nil {
|
||||
log.Printf("[products] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var products []jsonProduct
|
||||
if err := json.Unmarshal(data, &products); err != nil {
|
||||
log.Printf("[products] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, p := range products {
|
||||
if p.ID == "" {
|
||||
continue
|
||||
}
|
||||
if p.DeliveryMode == "" {
|
||||
p.DeliveryMode = "auto"
|
||||
}
|
||||
row := database.ProductRow{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Price: p.Price,
|
||||
DiscountPrice: p.DiscountPrice,
|
||||
Tags: database.StringSlice(p.Tags),
|
||||
CoverURL: p.CoverURL,
|
||||
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
|
||||
Description: p.Description,
|
||||
Active: p.Active,
|
||||
RequireLogin: p.RequireLogin,
|
||||
MaxPerAccount: p.MaxPerAccount,
|
||||
TotalSold: p.TotalSold,
|
||||
ViewCount: p.ViewCount,
|
||||
DeliveryMode: p.DeliveryMode,
|
||||
ShowNote: p.ShowNote,
|
||||
ShowContact: p.ShowContact,
|
||||
CreatedAt: p.CreatedAt,
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
}
|
||||
result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
|
||||
if result.Error != nil {
|
||||
log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error)
|
||||
continue
|
||||
}
|
||||
// Codes → product_codes
|
||||
for _, code := range p.Codes {
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ProductCodeRow{
|
||||
ProductID: p.ID,
|
||||
Code: code,
|
||||
})
|
||||
}
|
||||
}
|
||||
log.Printf("[products] 导入 %d 条商品", len(products))
|
||||
}
|
||||
|
||||
// ─── Orders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonOrder struct {
|
||||
ID string `json:"id"`
|
||||
ProductID string `json:"productId"`
|
||||
ProductName string `json:"productName"`
|
||||
UserAccount string `json:"userAccount"`
|
||||
UserName string `json:"userName"`
|
||||
Quantity int `json:"quantity"`
|
||||
DeliveredCodes []string `json:"deliveredCodes"`
|
||||
Status string `json:"status"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
Note string `json:"note"`
|
||||
ContactPhone string `json:"contactPhone"`
|
||||
ContactEmail string `json:"contactEmail"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func migrateOrders(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/orders.json")
|
||||
if err != nil {
|
||||
log.Printf("[orders] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var orders []jsonOrder
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
log.Printf("[orders] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, o := range orders {
|
||||
if o.ID == "" {
|
||||
continue
|
||||
}
|
||||
if o.DeliveryMode == "" {
|
||||
o.DeliveryMode = "auto"
|
||||
}
|
||||
if o.DeliveredCodes == nil {
|
||||
o.DeliveredCodes = []string{}
|
||||
}
|
||||
row := database.OrderRow{
|
||||
ID: o.ID,
|
||||
ProductID: o.ProductID,
|
||||
ProductName: o.ProductName,
|
||||
UserAccount: o.UserAccount,
|
||||
UserName: o.UserName,
|
||||
Quantity: o.Quantity,
|
||||
DeliveredCodes: database.StringSlice(o.DeliveredCodes),
|
||||
Status: o.Status,
|
||||
DeliveryMode: o.DeliveryMode,
|
||||
Note: o.Note,
|
||||
ContactPhone: o.ContactPhone,
|
||||
ContactEmail: o.ContactEmail,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
}
|
||||
if result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row); result.Error != nil {
|
||||
log.Printf("[orders] 导入 %s 失败: %v", o.ID, result.Error)
|
||||
}
|
||||
}
|
||||
log.Printf("[orders] 导入 %d 条订单", len(orders))
|
||||
}
|
||||
|
||||
// ─── Wishlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
func migrateWishlists(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/wishlists.json")
|
||||
if err != nil {
|
||||
log.Printf("[wishlists] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var wl map[string][]string
|
||||
if err := json.Unmarshal(data, &wl); err != nil {
|
||||
log.Printf("[wishlists] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for account, productIDs := range wl {
|
||||
for _, pid := range productIDs {
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.WishlistRow{
|
||||
AccountID: account,
|
||||
ProductID: pid,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Printf("[wishlists] 导入 %d 条收藏记录", count)
|
||||
}
|
||||
|
||||
// ─── Chats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonChatMsg struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Content string `json:"content"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
FromAdmin bool `json:"fromAdmin"`
|
||||
}
|
||||
|
||||
func migrateChats(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/chats.json")
|
||||
if err != nil {
|
||||
log.Printf("[chats] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var convs map[string][]jsonChatMsg
|
||||
if err := json.Unmarshal(data, &convs); err != nil {
|
||||
log.Printf("[chats] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, msgs := range convs {
|
||||
for _, m := range msgs {
|
||||
if m.ID == "" {
|
||||
continue
|
||||
}
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ChatMessageRow{
|
||||
ID: m.ID,
|
||||
AccountID: m.AccountID,
|
||||
AccountName: m.AccountName,
|
||||
Content: m.Content,
|
||||
SentAt: m.SentAt,
|
||||
FromAdmin: m.FromAdmin,
|
||||
})
|
||||
count++
|
||||
}
|
||||
}
|
||||
log.Printf("[chats] 导入 %d 条聊天消息", count)
|
||||
}
|
||||
|
||||
// ─── Site settings ────────────────────────────────────────────────────────────
|
||||
|
||||
type jsonSite struct {
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
Maintenance bool `json:"maintenance"`
|
||||
MaintenanceReason string `json:"maintenanceReason"`
|
||||
}
|
||||
|
||||
func migrateSite(db *gorm.DB) {
|
||||
data, err := os.ReadFile("data/json/site.json")
|
||||
if err != nil {
|
||||
log.Printf("[site] 文件不存在,跳过: %v", err)
|
||||
return
|
||||
}
|
||||
var site jsonSite
|
||||
if err := json.Unmarshal(data, &site); err != nil {
|
||||
log.Printf("[site] JSON 解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
upsert := func(key, value string) {
|
||||
db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&database.SiteSettingRow{Key: key, Value: value})
|
||||
}
|
||||
upsert("totalVisits", strconv.Itoa(site.TotalVisits))
|
||||
maintenance := "false"
|
||||
if site.Maintenance {
|
||||
maintenance = "true"
|
||||
}
|
||||
upsert("maintenance", maintenance)
|
||||
upsert("maintenanceReason", site.MaintenanceReason)
|
||||
log.Printf("[site] 站点设置导入完成(访问量: %d)", site.TotalVisits)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
container_name: mengyastore-backend
|
||||
ports:
|
||||
- "28081:8080"
|
||||
environment:
|
||||
GIN_MODE: release
|
||||
TZ: Asia/Shanghai
|
||||
# 生产环境 MySQL DSN,使用内网地址。
|
||||
# 本地开发请修改为测试库地址,或通过 .env 文件覆盖。
|
||||
#mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local
|
||||
DATABASE_DSN: "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
restart: unless-stopped
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
container_name: mengyastore-backend
|
||||
ports:
|
||||
- "28081:8080"
|
||||
environment:
|
||||
# 生产环境(本地开发请使用 APP_ENV=development + 根目录 .env)
|
||||
APP_ENV: production
|
||||
GIN_MODE: release
|
||||
TZ: Asia/Shanghai
|
||||
# 通过宿主机 .env 或 export 注入,勿在仓库中写真实 DSN/口令
|
||||
DATABASE_DSN: ${DATABASE_DSN:-}
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-changeme}
|
||||
AUTH_API_URL: ${AUTH_API_URL:-}
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ADDR: ${REDIS_ADDR:-127.0.0.1:6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
REDIS_DB: ${REDIS_DB:-1}
|
||||
RABBITMQ_ENABLED: "true"
|
||||
RABBITMQ_ENV: prod
|
||||
RABBITMQ_URL: ${RABBITMQ_URL}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -6,6 +6,10 @@ require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -44,6 +48,4 @@ require (
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
google.golang.org/protobuf v1.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -45,6 +45,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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/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=
|
||||
@@ -68,6 +70,8 @@ github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtos
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
@@ -90,6 +94,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
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=
|
||||
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=
|
||||
@@ -101,8 +107,6 @@ 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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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=
|
||||
|
||||
@@ -1,45 +1,246 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminToken string `json:"adminToken"`
|
||||
AuthAPIURL string `json:"authApiUrl"`
|
||||
|
||||
// 数据库 DSN,为空时回退到测试数据库。
|
||||
// 格式:user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
DatabaseDSN string `json:"databaseDsn"`
|
||||
}
|
||||
|
||||
// 各环境默认 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"
|
||||
)
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
|
||||
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr)
|
||||
}
|
||||
}
|
||||
// 文件不存在时使用默认值,环境变量在下方仍优先生效。
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
cfg.AdminToken = "changeme"
|
||||
}
|
||||
// DATABASE_DSN 环境变量优先于配置文件。
|
||||
if dsn := os.Getenv("DATABASE_DSN"); dsn != "" {
|
||||
cfg.DatabaseDSN = dsn
|
||||
}
|
||||
if cfg.DatabaseDSN == "" {
|
||||
cfg.DatabaseDSN = TestDSN
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config is populated entirely from environment variables (optionally set via .env — see Load).
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
AdminToken string
|
||||
AuthAPIURL string
|
||||
DatabaseDSN string
|
||||
|
||||
// 进程与对外访问(管理后台「系统状态」展示)
|
||||
HTTPListenAddr string
|
||||
PublicAPIBaseURL string
|
||||
|
||||
// Redis(可选,用于缓存时配置;未启用则不连接)
|
||||
RedisEnabled bool
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisEnv string
|
||||
|
||||
RabbitMQEnabled bool
|
||||
RabbitMQURL string
|
||||
RabbitMQEnv string
|
||||
}
|
||||
|
||||
// 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).
|
||||
const (
|
||||
EnvDevelopment = "development"
|
||||
EnvProduction = "production"
|
||||
)
|
||||
|
||||
// Built-in DSN fallbacks when DATABASE_DSN is empty.
|
||||
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"
|
||||
|
||||
DefaultRabbitMQHost = "10.1.1.233"
|
||||
DefaultRabbitMQPort = "5672"
|
||||
DefaultRabbitVHost = "mengyastore-dev"
|
||||
DefaultRabbitMQUser = "admin"
|
||||
ProdRabbitMQHost = "192.168.1.100"
|
||||
DefaultRabbitVHostProd = "mengyastore-prod"
|
||||
|
||||
// Redis:与 MySQL 同网段;未设置 REDIS_ADDR / REDIS_PASSWORD 时按 APP_ENV 填入
|
||||
TestRedisAddr = "10.1.1.100:6379"
|
||||
ProdRedisAddr = "192.168.1.100:6379"
|
||||
// 内网默认口令(可用环境变量 REDIS_PASSWORD 覆盖)
|
||||
DefaultRedisPassword = "tyh@19900420"
|
||||
)
|
||||
|
||||
// Load reads optional .env file(s) then builds Config from the process environment.
|
||||
//
|
||||
// Dotenv resolution order:
|
||||
// 1. File named by ENV_FILE (if set)
|
||||
// 2. .env in current working directory
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
//
|
||||
// HTTP_LISTEN_ADDR — 进程监听,默认 :8080
|
||||
// PUBLIC_API_BASE_URL — 对外 API 基地址(反向代理场景)
|
||||
//
|
||||
// REDIS_ENABLED — 默认 true;显式 false/0/off 则关闭
|
||||
// REDIS_ADDR — 默认 development→TestRedisAddr,production→ProdRedisAddr
|
||||
// REDIS_PASSWORD — 默认 DefaultRedisPassword(建议生产用环境变量覆盖)
|
||||
// REDIS_DB — 逻辑库编号,默认 1
|
||||
// REDIS_ENV — dev|prod(展示用 Key 前缀环境)
|
||||
func Load() (*Config, error) {
|
||||
loadDotenv()
|
||||
|
||||
appEnv := normalizeAppEnv(os.Getenv("APP_ENV"))
|
||||
|
||||
cfg := &Config{
|
||||
AppEnv: appEnv,
|
||||
AdminToken: strings.TrimSpace(os.Getenv("ADMIN_TOKEN")),
|
||||
AuthAPIURL: strings.TrimSpace(os.Getenv("AUTH_API_URL")),
|
||||
DatabaseDSN: strings.TrimSpace(os.Getenv("DATABASE_DSN")),
|
||||
RabbitMQURL: strings.TrimSpace(os.Getenv("RABBITMQ_URL")),
|
||||
}
|
||||
|
||||
if cfg.AdminToken == "" {
|
||||
cfg.AdminToken = "changeme"
|
||||
}
|
||||
|
||||
if cfg.DatabaseDSN == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.DatabaseDSN = ProdDSN
|
||||
} else {
|
||||
cfg.DatabaseDSN = TestDSN
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("RABBITMQ_ENABLED"); v != "" {
|
||||
cfg.RabbitMQEnabled = v == "true" || v == "1"
|
||||
}
|
||||
|
||||
cfg.RabbitMQEnv = strings.TrimSpace(os.Getenv("RABBITMQ_ENV"))
|
||||
if cfg.RabbitMQEnv == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RabbitMQEnv = "prod"
|
||||
} else {
|
||||
cfg.RabbitMQEnv = "dev"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.RabbitMQURL == "" && cfg.RabbitMQEnabled {
|
||||
cfg.RabbitMQURL = composeRabbitMQURL(cfg)
|
||||
}
|
||||
|
||||
if v := os.Getenv("HTTP_LISTEN_ADDR"); v != "" {
|
||||
cfg.HTTPListenAddr = strings.TrimSpace(v)
|
||||
}
|
||||
if cfg.HTTPListenAddr == "" {
|
||||
cfg.HTTPListenAddr = ":8080"
|
||||
}
|
||||
cfg.PublicAPIBaseURL = strings.TrimSpace(os.Getenv("PUBLIC_API_BASE_URL"))
|
||||
|
||||
cfg.RedisEnabled = redisEnabledFromEnv(os.Getenv("REDIS_ENABLED"))
|
||||
cfg.RedisAddr = strings.TrimSpace(os.Getenv("REDIS_ADDR"))
|
||||
cfg.RedisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
if v := os.Getenv("REDIS_DB"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.RedisDB = n
|
||||
}
|
||||
}
|
||||
cfg.RedisEnv = strings.TrimSpace(os.Getenv("REDIS_ENV"))
|
||||
if cfg.RedisEnv == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RedisEnv = "prod"
|
||||
} else {
|
||||
cfg.RedisEnv = "dev"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.RedisEnabled {
|
||||
if cfg.RedisAddr == "" {
|
||||
if appEnv == EnvProduction {
|
||||
cfg.RedisAddr = ProdRedisAddr
|
||||
} else {
|
||||
cfg.RedisAddr = TestRedisAddr
|
||||
}
|
||||
}
|
||||
if cfg.RedisPassword == "" {
|
||||
cfg.RedisPassword = DefaultRedisPassword
|
||||
}
|
||||
if cfg.RedisDB == 0 {
|
||||
cfg.RedisDB = 1
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// redisEnabledFromEnv defaults to true unless explicitly turned off.
|
||||
func redisEnabledFromEnv(v string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
if s == "false" || s == "0" || s == "no" || s == "off" {
|
||||
return false
|
||||
}
|
||||
if s == "true" || s == "1" || s == "yes" || s == "on" {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func loadDotenv() {
|
||||
if f := strings.TrimSpace(os.Getenv("ENV_FILE")); f != "" {
|
||||
_ = godotenv.Load(f)
|
||||
return
|
||||
}
|
||||
// Standard local file; ignore missing.
|
||||
_ = godotenv.Load(filepath.Clean(".env"))
|
||||
}
|
||||
|
||||
func normalizeAppEnv(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "production", "prod":
|
||||
return EnvProduction
|
||||
default:
|
||||
return EnvDevelopment
|
||||
}
|
||||
}
|
||||
|
||||
func composeRabbitMQURL(cfg *Config) string {
|
||||
pass := os.Getenv("RABBITMQ_PASSWORD")
|
||||
if pass == "" {
|
||||
return ""
|
||||
}
|
||||
host := os.Getenv("RABBITMQ_HOST")
|
||||
port := os.Getenv("RABBITMQ_PORT")
|
||||
user := os.Getenv("RABBITMQ_USER")
|
||||
vhost := os.Getenv("RABBITMQ_VHOST")
|
||||
if host == "" {
|
||||
if cfg.RabbitMQEnv == "prod" {
|
||||
host = ProdRabbitMQHost
|
||||
} else {
|
||||
host = DefaultRabbitMQHost
|
||||
}
|
||||
}
|
||||
if port == "" {
|
||||
port = DefaultRabbitMQPort
|
||||
}
|
||||
if user == "" {
|
||||
user = DefaultRabbitMQUser
|
||||
}
|
||||
if vhost == "" {
|
||||
if cfg.RabbitMQEnv == "prod" {
|
||||
vhost = DefaultRabbitVHostProd
|
||||
} else {
|
||||
vhost = DefaultRabbitVHost
|
||||
}
|
||||
}
|
||||
var path string
|
||||
if vhost == "/" {
|
||||
path = "/%2F"
|
||||
} else {
|
||||
path = "/" + url.PathEscape(vhost)
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: "amqp",
|
||||
User: url.UserPassword(user, pass),
|
||||
Host: host + ":" + port,
|
||||
Path: path,
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ type ProductRow struct {
|
||||
TotalSold int `gorm:"default:0"`
|
||||
ViewCount int `gorm:"default:0"`
|
||||
DeliveryMode string `gorm:"size:20;default:'auto'"`
|
||||
// FulfillmentType: card=卡密库存 / fixed=固定内容(不限库存,内容见 FixedContent)
|
||||
FulfillmentType string `gorm:"size:16;default:card;index"`
|
||||
FixedContent string `gorm:"type:text"`
|
||||
ShowNote bool `gorm:"default:true"`
|
||||
ShowContact bool `gorm:"default:true"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
|
||||
@@ -1,228 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/cache"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// invalidateProductCache removes the cached product list so that the next
|
||||
// public request rebuilds it from the freshest DB state (Write-Invalidate).
|
||||
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
|
||||
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
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 条"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
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 条"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
FulfillmentType string `json:"fulfillmentType"`
|
||||
FixedContent string `json:"fixedContent"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
func normalizeFulfillmentPayload(payload *productPayload) string {
|
||||
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
|
||||
if ft == "fixed" {
|
||||
return "fixed"
|
||||
}
|
||||
return "card"
|
||||
}
|
||||
|
||||
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.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
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 条"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
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 条"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
ft := normalizeFulfillmentPayload(&payload)
|
||||
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
|
||||
return
|
||||
}
|
||||
dm := strings.TrimSpace(payload.DeliveryMode)
|
||||
if dm == "" {
|
||||
dm = "auto"
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: dm,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: payload.FixedContent,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
310
mengyastore-backend-go/internal/handlers/admin_status.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/redis/go-redis/v9"
|
||||
gormDB "gorm.io/gorm"
|
||||
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/mq"
|
||||
)
|
||||
|
||||
// SystemStatusHandler exposes admin-only operational status.
|
||||
type SystemStatusHandler struct {
|
||||
cfg *config.Config
|
||||
db *gormDB.DB
|
||||
mq *mq.Client
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler {
|
||||
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
|
||||
}
|
||||
|
||||
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
|
||||
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
|
||||
if !h.adminTokenOK(c) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"backend": h.backendInfo(c),
|
||||
"mysql": h.mysqlInfo(ctx),
|
||||
"redis": h.redisInfo(ctx),
|
||||
"rabbitmq": h.rabbitmqInfo(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool {
|
||||
token := c.GetHeader("X-Admin-Token")
|
||||
if token == "" {
|
||||
token = c.GetHeader("Authorization")
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
if token != "" && token == h.cfg.AdminToken {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.Request.Host
|
||||
publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL)
|
||||
if publicBase == "" && host != "" {
|
||||
publicBase = proto + "://" + host
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"status": "ok",
|
||||
"appEnv": h.cfg.AppEnv,
|
||||
"ginMode": gin.Mode(),
|
||||
"requestHost": host,
|
||||
"listenAddr": h.cfg.HTTPListenAddr,
|
||||
"publicBaseUrl": publicBase,
|
||||
"uptimeSeconds": int(time.Since(h.start).Seconds()),
|
||||
"authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "",
|
||||
"rabbitmqEnabled": h.cfg.RabbitMQEnabled,
|
||||
"redisEnabled": h.cfg.RedisEnabled,
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H {
|
||||
out := gin.H{}
|
||||
parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out)
|
||||
|
||||
if h.db == nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = "数据库未初始化"
|
||||
return out
|
||||
}
|
||||
sqlDB, err := h.db.DB()
|
||||
if err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
var ver string
|
||||
if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "连接正常但读取版本失败: " + err.Error()
|
||||
stats := sqlDB.Stats()
|
||||
out["openConnections"] = stats.OpenConnections
|
||||
out["inUse"] = stats.InUse
|
||||
out["idle"] = stats.Idle
|
||||
return out
|
||||
}
|
||||
|
||||
stats := sqlDB.Stats()
|
||||
out["status"] = "ok"
|
||||
out["version"] = strings.TrimSpace(ver)
|
||||
out["openConnections"] = stats.OpenConnections
|
||||
out["inUse"] = stats.InUse
|
||||
out["idle"] = stats.Idle
|
||||
out["waitCount"] = stats.WaitCount
|
||||
return out
|
||||
}
|
||||
|
||||
func parseMysqlDSNForDisplay(dsn string, out gin.H) {
|
||||
if dsn == "" {
|
||||
out["configured"] = false
|
||||
return
|
||||
}
|
||||
mc, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
out["configured"] = false
|
||||
out["dsnParseError"] = err.Error()
|
||||
return
|
||||
}
|
||||
out["configured"] = true
|
||||
out["user"] = mc.User
|
||||
out["database"] = mc.DBName
|
||||
host, port, err := net.SplitHostPort(mc.Addr)
|
||||
if err != nil {
|
||||
out["host"] = mc.Addr
|
||||
out["port"] = ""
|
||||
if mc.Net == "unix" {
|
||||
out["socket"] = mc.Addr
|
||||
}
|
||||
} else {
|
||||
out["host"] = host
|
||||
out["port"] = port
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H {
|
||||
out := gin.H{
|
||||
"enabled": h.cfg.RedisEnabled,
|
||||
"env": h.cfg.RedisEnv,
|
||||
}
|
||||
if h.cfg.RedisAddr != "" {
|
||||
host, port, err := net.SplitHostPort(h.cfg.RedisAddr)
|
||||
if err != nil {
|
||||
out["host"] = h.cfg.RedisAddr
|
||||
out["port"] = "6379"
|
||||
} else {
|
||||
out["host"] = host
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
out["port"] = port
|
||||
}
|
||||
}
|
||||
out["dbIndex"] = h.cfg.RedisDB
|
||||
|
||||
if !h.cfg.RedisEnabled {
|
||||
out["status"] = "disabled"
|
||||
return out
|
||||
}
|
||||
if h.cfg.RedisAddr == "" {
|
||||
out["status"] = "misconfigured"
|
||||
out["message"] = "已启用但未配置 REDIS_ADDR"
|
||||
return out
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: h.cfg.RedisAddr,
|
||||
Password: h.cfg.RedisPassword,
|
||||
DB: h.cfg.RedisDB,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
info, err := rdb.Info(ctx, "server", "memory").Result()
|
||||
if err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "PING 成功但无法读取 INFO: " + err.Error()
|
||||
return out
|
||||
}
|
||||
dbsize, _ := rdb.DBSize(ctx).Result()
|
||||
|
||||
out["status"] = "ok"
|
||||
out["redisVersion"] = parseRedisInfoField(info, "redis_version:")
|
||||
out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:")
|
||||
out["keysApprox"] = dbsize
|
||||
return out
|
||||
}
|
||||
|
||||
func parseRedisInfoField(block, key string) string {
|
||||
for _, line := range strings.Split(block, "\r\n") {
|
||||
if strings.HasPrefix(line, key) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
|
||||
out := gin.H{
|
||||
"enabled": h.cfg.RabbitMQEnabled,
|
||||
"env": h.cfg.RabbitMQEnv,
|
||||
"exchange": mq.ExchangeName(h.cfg.RabbitMQEnv),
|
||||
"queue": mq.QueueName(h.cfg.RabbitMQEnv),
|
||||
"routingKey": mq.OrderEmailRoutingKey(),
|
||||
}
|
||||
host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL)
|
||||
out["brokerHost"] = host
|
||||
out["brokerPort"] = port
|
||||
out["vhost"] = vhost
|
||||
out["brokerUser"] = user
|
||||
|
||||
if !h.cfg.RabbitMQEnabled {
|
||||
out["status"] = "disabled"
|
||||
return out
|
||||
}
|
||||
|
||||
if h.cfg.RabbitMQURL == "" {
|
||||
out["status"] = "misconfigured"
|
||||
out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD"
|
||||
return out
|
||||
}
|
||||
|
||||
if h.mq == nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = "连接未建立,请检查 Broker 与 vhost 权限"
|
||||
return out
|
||||
}
|
||||
|
||||
if err := h.mq.Ping(); err != nil {
|
||||
out["status"] = "error"
|
||||
out["message"] = err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
msgs, cons, err := h.mq.QueueInspectInfo()
|
||||
if err != nil {
|
||||
out["status"] = "degraded"
|
||||
out["message"] = "通道可用但无法读取队列统计: " + err.Error()
|
||||
return out
|
||||
}
|
||||
|
||||
out["status"] = "ok"
|
||||
out["messagesReady"] = msgs
|
||||
out["consumers"] = cons
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
|
||||
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
|
||||
if raw == "" {
|
||||
return "", "", "", ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", "", ""
|
||||
}
|
||||
host = u.Hostname()
|
||||
port = u.Port()
|
||||
if port == "" {
|
||||
port = "5672"
|
||||
}
|
||||
if u.User != nil {
|
||||
user = u.User.Username()
|
||||
}
|
||||
vpath := strings.TrimPrefix(u.Path, "/")
|
||||
if vpath != "" {
|
||||
if dec, err := url.PathUnescape(vpath); err == nil {
|
||||
vhost = dec
|
||||
} else {
|
||||
vhost = vpath
|
||||
}
|
||||
}
|
||||
if vhost == "" {
|
||||
vhost = "/"
|
||||
}
|
||||
return host, port, vhost, user
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ func sanitizeForPublic(items []models.Product) []models.Product {
|
||||
out := make([]models.Product, len(items))
|
||||
for i, item := range items {
|
||||
item.Codes = nil
|
||||
item.FixedContent = ""
|
||||
out[i] = item
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -20,6 +20,8 @@ type Product struct {
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
TotalSold int `json:"totalSold"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
FulfillmentType string `json:"fulfillmentType"`
|
||||
FixedContent string `json:"fixedContent"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
268
mengyastore-backend-go/internal/mq/client.go
Normal file
268
mengyastore-backend-go/internal/mq/client.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
||||
type Client struct {
|
||||
conn *amqp.Connection
|
||||
pubCh *amqp.Channel
|
||||
exchange string
|
||||
queue string
|
||||
env string
|
||||
amqpURL string
|
||||
mu sync.Mutex
|
||||
closing sync.Once
|
||||
connected bool
|
||||
|
||||
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
||||
consumerCtx context.Context
|
||||
consumerSite *storage.SiteStore
|
||||
}
|
||||
|
||||
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
||||
func New(amqpURL, env string) (*Client, error) {
|
||||
if amqpURL == "" {
|
||||
return nil, fmt.Errorf("empty amqp url")
|
||||
}
|
||||
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amqp dial: %w", err)
|
||||
}
|
||||
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("amqp channel: %w", err)
|
||||
}
|
||||
|
||||
exchange, queue, err := declareTopology(pubCh, env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
pubCh: pubCh,
|
||||
exchange: exchange,
|
||||
queue: queue,
|
||||
env: sanitizeEnv(env),
|
||||
amqpURL: amqpURL,
|
||||
connected: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isRecoverableAMQP(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var e *amqp.Error
|
||||
if errors.As(err, &e) {
|
||||
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
||||
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "channel/connection is not open") ||
|
||||
strings.Contains(s, "connection closed") ||
|
||||
strings.Contains(s, "use of closed network connection") ||
|
||||
strings.Contains(s, "eof")
|
||||
}
|
||||
|
||||
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
||||
func (c *Client) reconnectLocked() error {
|
||||
if !c.connected || c.amqpURL == "" {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil && !c.conn.IsClosed() {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.conn = nil
|
||||
|
||||
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
||||
Heartbeat: 30 * time.Second,
|
||||
Locale: "en_US",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("amqp reconnect dial: %w", err)
|
||||
}
|
||||
pubCh, err := conn.Channel()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("amqp reconnect channel: %w", err)
|
||||
}
|
||||
exchange, queue, err := declareTopology(pubCh, c.env)
|
||||
if err != nil {
|
||||
pubCh.Close()
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
c.conn = conn
|
||||
c.pubCh = pubCh
|
||||
c.exchange = exchange
|
||||
c.queue = queue
|
||||
|
||||
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
||||
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
||||
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) passiveQueueLocked() error {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
||||
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return fmt.Errorf("mq client closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
tryPublish := func() error {
|
||||
return c.pubCh.PublishWithContext(ctx,
|
||||
c.exchange,
|
||||
routingKeyOrderEmail,
|
||||
false,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
DeliveryMode: amqp.Persistent,
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
err = tryPublish()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr == nil {
|
||||
err = tryPublish()
|
||||
} else {
|
||||
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
||||
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
try := func() (int, int, error) {
|
||||
if c.pubCh == nil || !c.connected {
|
||||
return 0, 0, fmt.Errorf("mq client closed")
|
||||
}
|
||||
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
return q.Messages, q.Consumers, nil
|
||||
}
|
||||
|
||||
msgs, cons, err := try()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return try()
|
||||
}
|
||||
return msgs, cons, err
|
||||
}
|
||||
|
||||
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
||||
func (c *Client) Ping() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
err := c.passiveQueueLocked()
|
||||
if err != nil && isRecoverableAMQP(err) {
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||
}
|
||||
return c.passiveQueueLocked()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Env returns the sanitized environment suffix used in exchange/queue names.
|
||||
func (c *Client) Env() string { return c.env }
|
||||
|
||||
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
||||
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
||||
c.mu.Lock()
|
||||
c.consumerCtx = ctx
|
||||
c.consumerSite = site
|
||||
conn := c.conn
|
||||
env := c.env
|
||||
c.mu.Unlock()
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
RunOrderEmailConsumer(ctx, conn, env, site)
|
||||
}
|
||||
|
||||
// Close releases the publish channel and connection.
|
||||
func (c *Client) Close() {
|
||||
c.closing.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.connected = false
|
||||
c.consumerCtx = nil
|
||||
c.consumerSite = nil
|
||||
if c.pubCh != nil {
|
||||
_ = c.pubCh.Close()
|
||||
c.pubCh = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
101
mengyastore-backend-go/internal/mq/consumer.go
Normal file
101
mengyastore-backend-go/internal/mq/consumer.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
||||
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
||||
if conn == nil || site == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consumer channel: %v", err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if _, _, err := declareTopology(ch, env); err != nil {
|
||||
log.Printf("[MQ] consumer declare topology: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ch.Qos(1, 0, false); err != nil {
|
||||
log.Printf("[MQ] consumer qos: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
queue := QueueName(env)
|
||||
const tag = "mengyastore-order-email"
|
||||
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] consume %s: %v", queue, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MQ] consumer started queue=%s", queue)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ch.Cancel(tag, false)
|
||||
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleDelivery(site, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
||||
var payload OrderEmailPayload
|
||||
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
||||
log.Printf("[MQ] bad message: %v", err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
if payload.ToEmail == "" {
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := site.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
|
||||
emailCfg := email.Config{
|
||||
SMTPHost: cfg.Host,
|
||||
SMTPPort: cfg.Port,
|
||||
From: cfg.Email,
|
||||
Password: cfg.Password,
|
||||
FromName: cfg.FromName,
|
||||
}
|
||||
data := payload.ToNotifyData()
|
||||
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
||||
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
||||
if d.Redelivered {
|
||||
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
26
mengyastore-backend-go/internal/mq/payload.go
Normal file
26
mengyastore-backend-go/internal/mq/payload.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package mq
|
||||
|
||||
import "mengyastore-backend/internal/email"
|
||||
|
||||
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
||||
type OrderEmailPayload struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
ToName string `json:"toName"`
|
||||
ProductName string `json:"productName"`
|
||||
OrderID string `json:"orderId"`
|
||||
Quantity int `json:"quantity"`
|
||||
Codes []string `json:"codes"`
|
||||
IsManual bool `json:"isManual"`
|
||||
}
|
||||
|
||||
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
||||
return email.OrderNotifyData{
|
||||
ToEmail: p.ToEmail,
|
||||
ToName: p.ToName,
|
||||
ProductName: p.ProductName,
|
||||
OrderID: p.OrderID,
|
||||
Quantity: p.Quantity,
|
||||
Codes: p.Codes,
|
||||
IsManual: p.IsManual,
|
||||
}
|
||||
}
|
||||
71
mengyastore-backend-go/internal/mq/topology.go
Normal file
71
mengyastore-backend-go/internal/mq/topology.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package mq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const routingKeyOrderEmail = "order.email.notify"
|
||||
|
||||
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
||||
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
||||
|
||||
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same 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.
|
||||
func QueueName(env string) string {
|
||||
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
||||
}
|
||||
|
||||
func sanitizeEnv(env string) string {
|
||||
e := strings.TrimSpace(strings.ToLower(env))
|
||||
if e == "prod" || e == "production" {
|
||||
return "prod"
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
||||
exchange = ExchangeName(env)
|
||||
queue = QueueName(env)
|
||||
|
||||
if err = ch.ExchangeDeclare(
|
||||
exchange,
|
||||
"topic",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("exchange declare: %w", err)
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
queue,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue declare: %w", err)
|
||||
}
|
||||
|
||||
if err = ch.QueueBind(
|
||||
queue,
|
||||
routingKeyOrderEmail,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", "", fmt.Errorf("queue bind: %w", err)
|
||||
}
|
||||
|
||||
return exchange, queue, nil
|
||||
}
|
||||
@@ -31,8 +31,22 @@ func NewProductStore(db *gorm.DB) (*ProductStore, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rowToModel 将数据库行(含卡密)转换为业务模型。
|
||||
func effectiveFulfillment(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "fixed":
|
||||
return "fixed"
|
||||
default:
|
||||
return "card"
|
||||
}
|
||||
}
|
||||
|
||||
// rowToModel 将数据库行(含卡密)转换为业务模型。固定内容类商品 Quantity 为 -1 表示不限库存。
|
||||
func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
ft := effectiveFulfillment(row.FulfillmentType)
|
||||
qty := len(codes)
|
||||
if ft == "fixed" {
|
||||
qty = -1
|
||||
}
|
||||
return models.Product{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
@@ -49,10 +63,12 @@ func rowToModel(row database.ProductRow, codes []string) models.Product {
|
||||
TotalSold: row.TotalSold,
|
||||
ViewCount: row.ViewCount,
|
||||
DeliveryMode: row.DeliveryMode,
|
||||
FulfillmentType: ft,
|
||||
FixedContent: row.FixedContent,
|
||||
ShowNote: row.ShowNote,
|
||||
ShowContact: row.ShowContact,
|
||||
Codes: codes,
|
||||
Quantity: len(codes),
|
||||
Quantity: qty,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -103,13 +119,17 @@ func (s *ProductStore) ListActive() ([]models.Product, error) {
|
||||
}
|
||||
products := make([]models.Product, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// 公开接口不暴露卡密,但需要统计剩余库存数量
|
||||
var count int64
|
||||
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
|
||||
row.Active = true
|
||||
ft := effectiveFulfillment(row.FulfillmentType)
|
||||
p := rowToModel(row, nil)
|
||||
p.Quantity = int(count)
|
||||
p.Codes = nil
|
||||
if ft == "fixed" {
|
||||
p.Quantity = -1
|
||||
p.FixedContent = ""
|
||||
} else {
|
||||
var count int64
|
||||
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
|
||||
p.Quantity = int(count)
|
||||
}
|
||||
products = append(products, p)
|
||||
}
|
||||
return products, nil
|
||||
@@ -146,6 +166,8 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
|
||||
TotalSold: p.TotalSold,
|
||||
ViewCount: p.ViewCount,
|
||||
DeliveryMode: p.DeliveryMode,
|
||||
FulfillmentType: p.FulfillmentType,
|
||||
FixedContent: p.FixedContent,
|
||||
ShowNote: p.ShowNote,
|
||||
ShowContact: p.ShowContact,
|
||||
CreatedAt: now,
|
||||
@@ -156,8 +178,10 @@ func (s *ProductStore) Create(p models.Product) (models.Product, error) {
|
||||
if err := s.replaceCodes(p.ID, p.Codes); err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
p.Quantity = len(p.Codes)
|
||||
return p, nil
|
||||
var createdRow database.ProductRow
|
||||
s.db.First(&createdRow, "id = ?", p.ID)
|
||||
codes, _ := s.loadCodes(p.ID)
|
||||
return rowToModel(createdRow, codes), nil
|
||||
}
|
||||
|
||||
func (s *ProductStore) Update(id string, patch models.Product) (models.Product, error) {
|
||||
@@ -168,20 +192,22 @@ func (s *ProductStore) Update(id string, patch models.Product) (models.Product,
|
||||
normalized := normalizeProduct(patch)
|
||||
|
||||
if err := s.db.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),
|
||||
"verification_url": normalized.VerificationURL,
|
||||
"description": normalized.Description,
|
||||
"active": normalized.Active,
|
||||
"require_login": normalized.RequireLogin,
|
||||
"max_per_account": normalized.MaxPerAccount,
|
||||
"delivery_mode": normalized.DeliveryMode,
|
||||
"show_note": normalized.ShowNote,
|
||||
"show_contact": normalized.ShowContact,
|
||||
"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),
|
||||
"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
|
||||
}
|
||||
@@ -296,8 +322,18 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.DiscountPrice = 0
|
||||
}
|
||||
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
item.FulfillmentType = effectiveFulfillment(item.FulfillmentType)
|
||||
if item.FulfillmentType == "fixed" {
|
||||
item.FixedContent = strings.TrimSpace(item.FixedContent)
|
||||
item.Codes = []string{}
|
||||
} else {
|
||||
item.FixedContent = ""
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
}
|
||||
item.Quantity = len(item.Codes)
|
||||
if item.FulfillmentType == "fixed" {
|
||||
item.Quantity = -1
|
||||
}
|
||||
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
|
||||
item.DeliveryMode = "auto"
|
||||
}
|
||||
|
||||
@@ -1,146 +1,150 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
type SiteStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
|
||||
return &SiteStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) get(key string) (string, error) {
|
||||
var row database.SiteSettingRow
|
||||
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
|
||||
if err := s.db.Where("`key` = ?", key).First(&row).Error; err != nil {
|
||||
return "", nil // 键不存在时返回零值
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) set(key, value string) error {
|
||||
return s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&database.SiteSettingRow{Key: key, Value: value}).Error
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetTotalVisits() (int, error) {
|
||||
v, err := s.get("totalVisits")
|
||||
if err != nil || v == "" {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) IncrementVisits() (int, error) {
|
||||
current, err := s.GetTotalVisits()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
current++
|
||||
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
|
||||
v, err := s.get("maintenance")
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
enabled = v == "true"
|
||||
reason, err = s.get("maintenanceReason")
|
||||
return enabled, reason, err
|
||||
}
|
||||
|
||||
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
|
||||
v := "false"
|
||||
if enabled {
|
||||
v = "true"
|
||||
}
|
||||
if err := s.set("maintenance", v); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.set("maintenanceReason", reason)
|
||||
}
|
||||
|
||||
// RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。
|
||||
// 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。
|
||||
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
|
||||
total, err := s.IncrementVisits()
|
||||
return total, true, err
|
||||
}
|
||||
|
||||
// SMTPConfig 存储数据库中的发件 SMTP 配置。
|
||||
type SMTPConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FromName string `json:"fromName"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
// IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。
|
||||
func (c SMTPConfig) IsConfiguredEmail() bool {
|
||||
return c.Enabled && c.Email != "" && c.Password != "" && c.Host != ""
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
|
||||
cfg := SMTPConfig{
|
||||
Enabled: true, // 默认启用
|
||||
Host: "smtp.qq.com",
|
||||
Port: "465",
|
||||
}
|
||||
if v, _ := s.get("smtpEnabled"); v == "false" {
|
||||
cfg.Enabled = false
|
||||
}
|
||||
if v, _ := s.get("smtpEmail"); v != "" {
|
||||
cfg.Email = v
|
||||
}
|
||||
if v, _ := s.get("smtpPassword"); v != "" {
|
||||
cfg.Password = v
|
||||
}
|
||||
if v, _ := s.get("smtpFromName"); v != "" {
|
||||
cfg.FromName = v
|
||||
}
|
||||
if v, _ := s.get("smtpHost"); v != "" {
|
||||
cfg.Host = v
|
||||
}
|
||||
if v, _ := s.get("smtpPort"); v != "" {
|
||||
cfg.Port = v
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error {
|
||||
enabledVal := "true"
|
||||
if !cfg.Enabled {
|
||||
enabledVal = "false"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"smtpEnabled", enabledVal},
|
||||
{"smtpEmail", cfg.Email},
|
||||
{"smtpPassword", cfg.Password},
|
||||
{"smtpFromName", cfg.FromName},
|
||||
{"smtpHost", cfg.Host},
|
||||
{"smtpPort", cfg.Port},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if err := s.set(p[0], p[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"mengyastore-backend/internal/database"
|
||||
)
|
||||
|
||||
type SiteStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
|
||||
return &SiteStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) get(key string) (string, error) {
|
||||
var row database.SiteSettingRow
|
||||
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
|
||||
// 使用 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
|
||||
}
|
||||
if row.Key == "" {
|
||||
return "", nil
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) set(key, value string) error {
|
||||
return s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&database.SiteSettingRow{Key: key, Value: value}).Error
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetTotalVisits() (int, error) {
|
||||
v, err := s.get("totalVisits")
|
||||
if err != nil || v == "" {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) IncrementVisits() (int, error) {
|
||||
current, err := s.GetTotalVisits()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
current++
|
||||
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
|
||||
v, err := s.get("maintenance")
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
enabled = v == "true"
|
||||
reason, err = s.get("maintenanceReason")
|
||||
return enabled, reason, err
|
||||
}
|
||||
|
||||
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
|
||||
v := "false"
|
||||
if enabled {
|
||||
v = "true"
|
||||
}
|
||||
if err := s.set("maintenance", v); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.set("maintenanceReason", reason)
|
||||
}
|
||||
|
||||
// RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。
|
||||
// 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。
|
||||
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
|
||||
total, err := s.IncrementVisits()
|
||||
return total, true, err
|
||||
}
|
||||
|
||||
// SMTPConfig 存储数据库中的发件 SMTP 配置。
|
||||
type SMTPConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FromName string `json:"fromName"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
}
|
||||
|
||||
// IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。
|
||||
func (c SMTPConfig) IsConfiguredEmail() bool {
|
||||
return c.Enabled && c.Email != "" && c.Password != "" && c.Host != ""
|
||||
}
|
||||
|
||||
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
|
||||
cfg := SMTPConfig{
|
||||
Enabled: true, // 默认启用
|
||||
Host: "smtp.qq.com",
|
||||
Port: "465",
|
||||
}
|
||||
if v, _ := s.get("smtpEnabled"); v == "false" {
|
||||
cfg.Enabled = false
|
||||
}
|
||||
if v, _ := s.get("smtpEmail"); v != "" {
|
||||
cfg.Email = v
|
||||
}
|
||||
if v, _ := s.get("smtpPassword"); v != "" {
|
||||
cfg.Password = v
|
||||
}
|
||||
if v, _ := s.get("smtpFromName"); v != "" {
|
||||
cfg.FromName = v
|
||||
}
|
||||
if v, _ := s.get("smtpHost"); v != "" {
|
||||
cfg.Host = v
|
||||
}
|
||||
if v, _ := s.get("smtpPort"); v != "" {
|
||||
cfg.Port = v
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error {
|
||||
enabledVal := "true"
|
||||
if !cfg.Enabled {
|
||||
enabledVal = "false"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"smtpEnabled", enabledVal},
|
||||
{"smtpEmail", cfg.Email},
|
||||
{"smtpPassword", cfg.Password},
|
||||
{"smtpFromName", cfg.FromName},
|
||||
{"smtpHost", cfg.Host},
|
||||
{"smtpPort", cfg.Port},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
if err := s.set(p[0], p[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,114 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/handlers"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("config.json")
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化数据库连接
|
||||
db, err := database.Open(cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
|
||||
store, err := storage.NewProductStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化商品存储失败: %v", err)
|
||||
}
|
||||
orderStore, err := storage.NewOrderStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化订单存储失败: %v", err)
|
||||
}
|
||||
siteStore, err := storage.NewSiteStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化站点存储失败: %v", err)
|
||||
}
|
||||
wishlistStore, err := storage.NewWishlistStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化收藏夹存储失败: %v", err)
|
||||
}
|
||||
chatStore, err := storage.NewChatStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化聊天存储失败: %v", err)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
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"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
|
||||
|
||||
publicHandler := handlers.NewPublicHandler(store)
|
||||
adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore)
|
||||
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient)
|
||||
statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
|
||||
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
|
||||
chatHandler := handlers.NewChatHandler(chatStore, authClient)
|
||||
|
||||
r.GET("/api/products", publicHandler.ListProducts)
|
||||
r.POST("/api/checkout", orderHandler.CreateOrder)
|
||||
r.POST("/api/products/:id/view", publicHandler.RecordProductView)
|
||||
r.GET("/api/stats", statsHandler.GetStats)
|
||||
r.POST("/api/site/visit", statsHandler.RecordVisit)
|
||||
r.GET("/api/site/maintenance", statsHandler.GetMaintenance)
|
||||
r.GET("/api/orders", orderHandler.ListMyOrders)
|
||||
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
|
||||
|
||||
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
|
||||
r.GET("/api/admin/products", adminHandler.ListAllProducts)
|
||||
r.POST("/api/admin/products", adminHandler.CreateProduct)
|
||||
r.PUT("/api/admin/products/:id", adminHandler.UpdateProduct)
|
||||
r.PATCH("/api/admin/products/:id/status", adminHandler.ToggleProduct)
|
||||
r.DELETE("/api/admin/products/:id", adminHandler.DeleteProduct)
|
||||
r.POST("/api/admin/site/maintenance", adminHandler.SetMaintenance)
|
||||
r.GET("/api/admin/site/smtp", adminHandler.GetSMTPConfig)
|
||||
r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig)
|
||||
r.GET("/api/admin/orders", adminHandler.ListAllOrders)
|
||||
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
|
||||
|
||||
r.GET("/api/wishlist", wishlistHandler.GetWishlist)
|
||||
r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
|
||||
r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist)
|
||||
|
||||
// 用户聊天路由
|
||||
r.GET("/api/chat/messages", chatHandler.GetMyMessages)
|
||||
r.POST("/api/chat/messages", chatHandler.SendMyMessage)
|
||||
|
||||
// 管理员聊天路由
|
||||
r.GET("/api/admin/chat", adminHandler.GetAllConversations)
|
||||
r.GET("/api/admin/chat/:account", adminHandler.GetConversation)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/database"
|
||||
"mengyastore-backend/internal/handlers"
|
||||
"mengyastore-backend/internal/mq"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
startedAt := time.Now()
|
||||
|
||||
// 初始化数据库连接
|
||||
db, err := database.Open(cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化数据库失败: %v", err)
|
||||
}
|
||||
|
||||
store, err := storage.NewProductStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化商品存储失败: %v", err)
|
||||
}
|
||||
orderStore, err := storage.NewOrderStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化订单存储失败: %v", err)
|
||||
}
|
||||
siteStore, err := storage.NewSiteStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化站点存储失败: %v", err)
|
||||
}
|
||||
wishlistStore, err := storage.NewWishlistStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化收藏夹存储失败: %v", err)
|
||||
}
|
||||
chatStore, err := storage.NewChatStore(db)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化聊天存储失败: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var mqClient *mq.Client
|
||||
if cfg.RabbitMQEnabled {
|
||||
if cfg.RabbitMQURL == "" {
|
||||
log.Println("[MQ] 已启用但未配置连接串:请设置 RABBITMQ_URL,或设置 RABBITMQ_PASSWORD(及可选 RABBITMQ_HOST/RABBITMQ_VHOST)")
|
||||
} else {
|
||||
c, err := mq.New(cfg.RabbitMQURL, cfg.RabbitMQEnv)
|
||||
if err != nil {
|
||||
log.Printf("[MQ] 连接失败,将仅用直发邮件降级: %v", 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
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"},
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
|
||||
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
|
||||
chatHandler := handlers.NewChatHandler(chatStore, authClient)
|
||||
statusHandler := handlers.NewSystemStatusHandler(cfg, db, mqClient, startedAt)
|
||||
|
||||
r.GET("/api/products", publicHandler.ListProducts)
|
||||
r.POST("/api/checkout", orderHandler.CreateOrder)
|
||||
r.POST("/api/products/:id/view", publicHandler.RecordProductView)
|
||||
r.GET("/api/stats", statsHandler.GetStats)
|
||||
r.POST("/api/site/visit", statsHandler.RecordVisit)
|
||||
r.GET("/api/site/maintenance", statsHandler.GetMaintenance)
|
||||
r.GET("/api/orders", orderHandler.ListMyOrders)
|
||||
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
|
||||
|
||||
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
|
||||
r.GET("/api/admin/products", adminHandler.ListAllProducts)
|
||||
r.POST("/api/admin/products", adminHandler.CreateProduct)
|
||||
r.PUT("/api/admin/products/:id", adminHandler.UpdateProduct)
|
||||
r.PATCH("/api/admin/products/:id/status", adminHandler.ToggleProduct)
|
||||
r.DELETE("/api/admin/products/:id", adminHandler.DeleteProduct)
|
||||
r.POST("/api/admin/site/maintenance", adminHandler.SetMaintenance)
|
||||
r.GET("/api/admin/site/smtp", adminHandler.GetSMTPConfig)
|
||||
r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig)
|
||||
r.GET("/api/admin/orders", adminHandler.ListAllOrders)
|
||||
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
|
||||
r.GET("/api/admin/system-status", statusHandler.GetSystemStatus)
|
||||
|
||||
r.GET("/api/wishlist", wishlistHandler.GetWishlist)
|
||||
r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
|
||||
r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist)
|
||||
|
||||
// 用户聊天路由
|
||||
r.GET("/api/chat/messages", chatHandler.GetMyMessages)
|
||||
r.POST("/api/chat/messages", chatHandler.SendMyMessage)
|
||||
|
||||
// 管理员聊天路由
|
||||
r.GET("/api/admin/chat", adminHandler.GetAllConversations)
|
||||
r.GET("/api/admin/chat/:account", adminHandler.GetConversation)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
@@ -1,37 +0,0 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -1,39 +0,0 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '4.0.4'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.smyhub.store'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
description = 'mengyastore-backend-java'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
services: { }
|
||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed 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
|
||||
#
|
||||
# https://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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
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
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -1,93 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1 +0,0 @@
|
||||
rootProject.name = 'mengyastore-backend-java'
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.smyhub.store.mengyastorebackendjava;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class MengyastoreBackendJavaApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
spring:
|
||||
application:
|
||||
name: mengyastore-backend-java
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.smyhub.store.mengyastorebackendjava;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class MengyastoreBackendJavaApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
158
mengyastore-backend-go/后端文档.md
Normal file
158
mengyastore-backend-go/后端文档.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 萌芽小店 · 后端(mengyastore-backend-go)
|
||||
|
||||
基于 **Go + Gin + GORM** 的 REST API:商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
|
||||
|
||||
## 技术依赖(核心)
|
||||
|
||||
| 包 | / 用途 |
|
||||
|----|--------|
|
||||
| gin | HTTP 路由 |
|
||||
| gorm.io/gorm + driver/mysql | MySQL |
|
||||
| gin-contrib/cors | CORS |
|
||||
| google/uuid | 订单 ID |
|
||||
| github.com/rabbitmq/amqp091-go | RabbitMQ(可选) |
|
||||
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
|
||||
| github.com/joho/godotenv | `.env` 加载 |
|
||||
|
||||
## 目录结构(与仓库一致)
|
||||
|
||||
```
|
||||
mengyastore-backend-go/
|
||||
├── main.go # 路由注册、MQ 生命周期
|
||||
├── docker-compose.yml
|
||||
├── init.sql # 可选:建库参考
|
||||
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
|
||||
├── internal/
|
||||
│ ├── auth/sproutgate.go
|
||||
│ ├── cache/ # Redis 等扩展
|
||||
│ ├── config/config.go # 环境变量与默认值(APP_ENV、DSN、MQ、Redis…)
|
||||
│ ├── database/db.go # Open + AutoMigrate
|
||||
│ ├── database/models.go # GORM 表模型
|
||||
│ ├── email/
|
||||
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
|
||||
│ ├── models/ # JSON 业务模型
|
||||
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
|
||||
│ └── handlers/
|
||||
│ ├── public.go
|
||||
│ ├── order.go
|
||||
│ ├── stats.go
|
||||
│ ├── wishlist.go
|
||||
│ ├── chat.go
|
||||
│ ├── admin.go
|
||||
│ ├── admin_product.go
|
||||
│ ├── admin_orders.go
|
||||
│ ├── admin_site.go
|
||||
│ ├── admin_chat.go
|
||||
│ └── admin_status.go # GET /api/admin/system-status
|
||||
```
|
||||
|
||||
## API 路由一览
|
||||
|
||||
### 公开
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
|
||||
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent`) |
|
||||
| POST | `/api/products/:id/view` | 记录浏览 |
|
||||
| GET | `/api/stats` | 订单数、访问量等 |
|
||||
| POST | `/api/site/visit` | 站点访问计数 |
|
||||
| GET | `/api/site/maintenance` | 维护状态 |
|
||||
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes`) |
|
||||
| GET | `/api/orders` | 当前用户订单(Bearer) |
|
||||
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
|
||||
|
||||
### 收藏夹(Bearer)
|
||||
|
||||
| GET | `/api/wishlist` |
|
||||
| POST | `/api/wishlist` |
|
||||
| DELETE | `/api/wishlist/:id` |
|
||||
|
||||
### 聊天(Bearer)
|
||||
|
||||
| GET | `/api/chat/messages` |
|
||||
| POST | `/api/chat/messages` |
|
||||
|
||||
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`)
|
||||
|
||||
| POST | `/api/admin/verify` | 校验 token,返回 `valid` |
|
||||
| GET/POST | `/api/admin/products` … | 商品 CRUD(见 payload 含 `fulfillmentType`、`fixedContent`) |
|
||||
| PUT | `/api/admin/products/:id` |
|
||||
| PATCH | `/api/admin/products/:id/status` |
|
||||
| DELETE | `/api/admin/products/:id` |
|
||||
| POST | `/api/admin/site/maintenance` |
|
||||
| GET/POST | `/api/admin/site/smtp` |
|
||||
| GET | `/api/admin/orders` |
|
||||
| DELETE | `/api/admin/orders/:id` |
|
||||
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
|
||||
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
|
||||
|
||||
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
|
||||
|
||||
## 数据库表(字段摘要)
|
||||
|
||||
### products
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
|
||||
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
|
||||
| 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 … |
|
||||
|
||||
### product_codes
|
||||
|
||||
卡密一行一条;`fulfillment_type = fixed` 时可为空。
|
||||
|
||||
### orders
|
||||
|
||||
含 `delivered_codes`(JSON)、`notify_email`、`delivery_mode`、`status` 等。
|
||||
|
||||
### site_settings
|
||||
|
||||
KV:如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound:`Find` 替代 `First`)。
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
|
||||
|
||||
要点:
|
||||
|
||||
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN(仅开发便利)。
|
||||
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
|
||||
- `HTTP_LISTEN_ADDR`、`PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
|
||||
|
||||
## 发货与订单逻辑
|
||||
|
||||
### fulfillment_type = `card`(默认)
|
||||
|
||||
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
|
||||
2. 从 `product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
|
||||
3. 销量 `IncrementSold`;邮件/MQ 通知。
|
||||
|
||||
### fulfillment_type = `fixed`
|
||||
|
||||
1. 不校验卡密条数;不修改 `product_codes`。
|
||||
2. 将 `fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
|
||||
3. 仍 `IncrementSold`;邮件/MQ 同自动发货路径。
|
||||
|
||||
### delivery_mode(订单)
|
||||
|
||||
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
|
||||
|
||||
## RabbitMQ 说明
|
||||
|
||||
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
|
||||
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
go run .
|
||||
go build -o mengyastore-backend.exe .
|
||||
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
维护时请以 **`main.go` 与 `internal/database/models.go`** 为最终准据;本文随版本迭代更新。
|
||||
Reference in New Issue
Block a user