15--查漏补缺

Consumer事务修补

internal/mq/consumer.go 里的 CreateOrder 手动调用了 tx.Begin()
如果在 UpdateCreate 时发生了 panic,程序直接崩溃或被 recovery 捕获,tx.Rollback()
将永远不会被执行,导致数据库连接池中的连接被死锁或泄露。

我们把CreateOrder()改为这样

// CreateOrder 消费者调用,创建订单(GORM闭包事务,自动处理 panic recover)
func CreateOrder(msg *OrderMessage) error {
	// 开启事务,保证库存扣减和订单创建原子性
	err := DB.Transaction(func(tx *gorm.DB) error {

		// 扣减商品真实库存(乐观锁,防止超卖)
		// 条件: product_id匹配 且 stock > 0
		result := tx.Model(&model.Product{}).
			Where("product_id = ? AND stock > 0", msg.ProductId).
			Update("stock", gorm.Expr("stock - 1"))
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected == 0 {
			return errors.New("stock not enough")
		}

		// 创建订单
		order := &model.Order{
			UserId:    msg.UserId,
			ProductId: msg.ProductId,
			SeckillId: msg.SeckillId,
			Amount:    msg.Amount,
			Status:    0, // 待支付
		}

		return tx.Create(order).Error
	})

	return err
}

使用GORM自带闭包逻辑优势:

  • tx.Begin() → DB.Transaction(func(tx) error):GORM 自动管理 commit/rollback
  • 移除了所有手动 tx.Rollback() 调用
  • return error 即回滚,return nil 即提交
  • 闭包内 panic 会被 GORM 内部 recover 捕获并自动回滚

MQ 消费者的无限重试死循环危险

  • internal/mq/consumer.go 中,如果 CreateOrder 失败,msg.Nack(false, true) 会将消息重新入队。
  • 缺陷:如果是因为数据库宕机或 SQL 语法错误导致的失败,这条消息会被无限次重新消费,瞬间拉满CPU 并阻塞其他消息。
  • 为此我们可以为 CreateOrder 添加重试次数上限,保证不会无限次入队

首先给internal/mq/message.go中的消息结构体添加重试次数

// OrderMessage 定义发送到RabbitMQ的订单消息结构
type OrderMessage struct {
	UserId     uint    `json:"user_id"`     // 用户ID
	SeckillId  uint    `json:"seckill_id"`  // 秒杀活动ID
	ProductId  uint    `json:"product_id"`  // 商品ID
	Amount     float64 `json:"amount"`      // 订单金额
	RetryConut int     `json:"retry_count"` // (新) 重试次数
}

回到 internal/mq/consumer.go 修改重试逻辑

// StartConsumer 启动消费者
func StartConsumer() {
	// 订阅队列
	msgs, err := Channel.Consume(
		OrderQueue, // queue
		"",         // consumer
		false,      // auto-ack
		false,      // exclusive
		false,      // no-local
		false,      // no-wait
		nil,        // args
	)
	if err != nil {
		log.Fatalf("Failed to register a consumer: %v", err)
	}

	const MaxRetryCount = 3 // 最大重试次数

	// 启动协程处理消息
	go func() {
		for msg := range msgs { // 处理消息
			var orderMsg OrderMessage

			// 将消息反序列化为订单消息
			if err := json.Unmarshal(msg.Body, &orderMsg); err != nil {
				log.Printf("Failed to unmarshal message: %v", err)
				msg.Nack(false, false) // 丢弃这条消息
				continue
			}

			// 调用服务层创建订单
			if err := CreateOrder(&orderMsg); err != nil {
				log.Printf("Failed to create order: %v", err)

				// 超过最大重试次数,丢弃消息并告警
				if orderMsg.RetryConut >= MaxRetryCount {
					log.Printf("FATAL: order message exceeded max retry, discarding: %+v", orderMsg)
					msg.Nack(false, false) // 丢弃这条消息,不重试
					continue
				}

				orderMsg.RetryConut++ // 重试次数加1
				msg.Nack(false, true) // 重新入队
				continue
			}

			msg.Ack(false) // 确认消息处理成功
			log.Printf("Order created: user=%d, seckill=%d", orderMsg.UserId, orderMsg.SeckillId)
		}
	}()
}

这里我们并没有选择将重试次数更新到消息队列的消息结构体当中,只在日志里记录 RetryCount ,因为 RetryCount 对于我们实际的业务逻辑是冗余信息,完全可以剔除

列表分页

  • 查看 internal/repository/product.go 和 order.go 可以发现,GetProductList()
    GetOrderByUserID() 都是直接使用 DB.Find(&products)DB.Where(...).Find(&orders)
  • 致命缺陷:这在实际工程中是绝对不允许的。如果商品表里有 10 万条数据,或者一个用户的历史订单有几千条,直接全表扫描并加载到内存中会瞬间引发 OOM(内存溢出)和数据库卡顿。

1. 分页DTO

internal/api/handlers/dto/common.go

package dto

// PageQuery 通用分页请求参数
type PageQuery struct {
    Page  int `form:"page" binding:"required,min=1"`   // 页码,从1开始
    Size  int `form:"size" binding:"required,min=1,max=100"` // 每页条数,最大100
}

// PageResult 通用分页响应结构
type PageResult struct {
Data     interface{} `json:"data"`      // 当前页数据
Total    int64       `json:"total"`     // 总记录数
Page     int         `json:"page"`      // 当前页
Size     int         `json:"size"`      // 每页条数
TotalPage int        `json:"total_page"` // 总页数
}

2. Repository

internal/repository/product.go

// GetProductListPaginated 分页获取商品列表
func GetProductListPaginated(page, size int) ([]*model.Product, int64, error) {
    var products []*model.Product
    var total int64

    // 查询总数
    DB.Model(&model.Product{}).Count(&total)

    // 查询当前页数据
    offset := (page - 1) * size
    err := DB.Offset(offset).Limit(size).Find(&products).Error
    return products, total, err
}

internal/repository/order.go

// GetOrderListPaginated 分页获取用户订单列表
func GetOrderListPaginated(userId uint, page, size int) ([]*model.Order, int64, error) {
    var orders []*model.Order
    var total int64

    query := DB.Model(&model.Order{}).Where("user_id = ?", userId)
    query.Count(&total)

    offset := (page - 1) * size
    err := query.Offset(offset).Limit(size).Order("created_at DESC").Find(&orders).Error
    return orders, total, err
}
func GetOrderListByUserIDPaginated(userId uint, page, size int) ([]*model.Order, int64, error) {
    var orders []*model.Order
    var total int64

    query := DB.Model(&model.Order{}).Where("user_id = ?", userId)
    query.Count(&total)

    offset := (page - 1) * size
    err := query.Offset(offset).Limit(size).Order("created_at DESC").Find(&orders).Error
    return orders, total, err
}

3. Service

internal/service/product.go

// GetProductListPaginated 分页获取商品列表
func GetProductListPaginated(page, size int) ([]*model.Product, int64, error) {
    return repository.GetProductListPaginated(page, size)
}

internal/service/order.go

// GetOrderListPaginated 分页获取用户订单列表
func GetOrderListPaginated(userId, page, size int) ([]*model.Order, int64, error) {
    return repository.GetOrderListPaginated(userId, page, size)
}
func GetOrderListByUserIDPaginated(userId, page, size int) ([]*model.Order, int64, error) {
    return repository.GetOrderListByUserIDPaginated(userId, page, size)
}

修改Handler

internal/api/handlers/product.go

// GetProductList 获取商品列表(支持分页)
func GetProductList(c *gin.Context) {
    var req dto.PageQuery
    if err := c.ShouldBindQuery(&req); err != nil {
        response.Fail(c, 400, "invalid page or size params")
        return
    }

    products, total, err := service.GetProductListPaginated(req.Page, req.Size)
    if err != nil {
        response.Fail(c, 500, "failed to get product list")
        return
    }

    response.Sucess(c, gin.H{
        "data":  products,
        "total": total,
        "page":  req.Page,
        "size":  req.Size,
    })
}

internal/api/handlers/order.go

// GetOrderList 获取当前用户订单列表(支持分页)
func GetOrderList(c *gin.Context) {
    var req dto.PageQuery
    if err := c.ShouldBindQuery(&req); err != nil {
        response.Fail(c, 400, "invalid page or size params")
        return
    }

    uid, _ := c.Get("uid")
    orders, total, err := service.GetOrderListPaginated(uid.(uint), req.Page, req.Size)
    if err != nil {
        response.Fail(c, 500, "failed to get order list")
        return
    }

    response.Sucess(c, gin.H{
        "data":  orders,
        "total": total,
        "page":  req.Page,
        "size":  req.Size,
    })
}
func GetActiveSeckillList(c *gin.Context) {
    var req dto.PageQuery
    if err := c.ShouldBindQuery(&req); err != nil {
        req.Page, req.Size = 1, 10 // 默认值
    }

    list, total, err := service.GetActiveSeckillListPaginated(req.Page, req.Size)
    if err != nil {
        response.Fail(c, 500, "failed to get active seckill list")
        return
    }
    response.Sucess(c, gin.H{"data": list, "total": total, "page": req.Page, "size": req.Size})
}
func GetAllSeckillList(c *gin.Context) {
    var req dto.PageQuery
    if err := c.ShouldBindQuery(&req); err != nil {
        req.Page, req.Size = 1, 10
    }

    list, total, err := service.GetAllSeckillListPaginated(req.Page, req.Size)
    if err != nil {
        response.Fail(c, 500, "failed to get all seckill list")
        return
    }
    response.Sucess(c, gin.H{"data": list, "total": total, "page": req.Page, "size": req.Size})
}

调用示例

# 商品列表
curl "http://localhost:8080/api/product/list?page=1&size=10" -H "Authorization: Bearer <TOKEN>"

# 进行中秒杀
curl "http://localhost:8080/api/seckill/list/active?page=1&size=10" -H "Authorization: Bearer <TOKEN>"

# admin 查询用户订单
curl "http://localhost:8080/api/order/list/1?page=1&size=10" -H "Authorization: Bearer <TOKEN>"

CRUD补充与软删除

  • 目前只有批量拉取列表的接口(如 /api/product/list),但没有获取单条记录详情的接口。在真实的购物流程中,用户在列表中看到商品后,必定要点击进入“商品详情页”查看详细描述,然后才会发起秒杀或下单。
  • 系统中除了状态流转(下单、取消、退款)涉及更新外,实体信息没有任何修改的入口。
    • 商品侧:管理员发错了商品名称、定错了原价,或者需要补货普通的商品库存,目前只能去数据库里改,这不符合后台管理系统的基本 CRUD 逻辑。
    • 用户侧:用户目前只能注册,无法“修改密码”。
  • 实体创建后就永远存在,无法下架或删除。如果一个秒杀活动配置错误(比如价格少填了一个零),管理员目前无法强行终止或删除该活动;商品也无法下架。

1. DTO追加

internal/api/handlers/dto/product.go

// UpdateProductRequest 管理员修改商品
type UpdateProductRequest struct {
	ProductId uint    `json:"product_id" binding:"required"`
	Name      string  `json:"name"`
	Desc      string  `json:"description"`
	Price     float64 `json:"price"`
	Stock     int     `json:"stock"`
}

// DeleteProductRequest 删除商品(软删除)
type DeleteProductRequest struct {
	ProductId uint `json:"product_id" binding:"required"`
}

internal/api/handlers/dto/user.go

// UpdatePasswordRequest 修改密码
type UpdatePasswordRequest struct {
	OldPassword string `json:"old_password" binding:"required"`
	NewPassword string `json:"new_password" binding:"required"`
}

2. Model追加软删除字段

GORM 的软删除默认会用 WHERE deleted_at IS NULL 过滤,DB.Delete() 会自动改 UPDATE deleted_at = now() 而不是物理删除。

internal/model/product.go

// Product 定义了商品的结构体,包含商品ID、名称、描述、价格、库存数量和创建时间等字段
type Product struct {
	ProductId   uint           `gorm:"primaryKey;column:product_id;autoIncrement" json:"product_id"` // 商品ID,主键,自增
	Name        string         `gorm:"column:name;type:varchar(100);not null" json:"name"`           // 商品名称,字符串类型,最大长度100,不能为空
	Description string         `gorm:"column:description;type:text" json:"description"`              // 商品描述,文本类型
	Price       float64        `gorm:"column:price;type:decimal(10,2);not null" json:"price"`        // 商品价格,十进制类型,精度10位,小数点后2位,不能为空
	Stock       int            `gorm:"column:stock;type:int;not null" json:"stock"`                  // 商品库存数量,整数类型,不能为空
	CreatedAt   time.Time      `gorm:"column:created_at;autoCreateTime" json:"created_at"`           // 商品创建时间,自动设置为当前时间
	DeletedAt   gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`                             // (新) 商品删除时间(软删除)
}

internal/model/seckill.go

// Seckill 定义了秒杀活动的结构体,包含秒杀活动ID、关联的商品ID、秒杀活动名称、库存数量、开始时间、结束时间、状态和创建时间等字段
type Seckill struct {
	SeckillId uint      `gorm:"primaryKey;column:seckill_id;autoIncrement" json:"seckill_id"` // 秒杀活动ID,主键,自增
	ProductId uint      `gorm:"column:product_id;not null" json:"product_id"`                 // 关联的商品ID,不能为空
	Name      string    `gorm:"column:name;type:varchar(200);not null" json:"name"`           // 秒杀活动名称,字符串类型,最大长度200,不能为空
	Price     float64   `gorm:"column:price;type:decimal(10,2);not null" json:"price"`        // 秒杀价格,十进制类型,精度10位,小数点后2位,不能为空
	Stock     int       `gorm:"column:stock;not null" json:"stock"`                           // 秒杀活动库存数量,不能为空
	StartTime time.Time `gorm:"column:start_time;not null" json:"start_time"`                 // 秒杀活动开始时间,不能为空
	EndTime   time.Time `gorm:"column:end_time;not null" json:"end_time"`                     // 秒杀活动结束时间,不能为空
	Status    int       `gorm:"column:status;type:int;default:0" json:"status"`               // 秒杀活动状态,整数类型,不能为空
	// (0:未开始,1:进行中,2:已结束)
	CreatedAt time.Time      `gorm:"column:created_at;autoCreateTime" json:"created_at"` // 秒杀活动创建时间,自动设置为当前时间
	DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`                   // (新) 秒杀活动删除时间(软删除)
}

3. Repository

注意:DB.Delete 在有 gorm.DeletedAt 字段的 Model 上会自动做软删除(设 deleted_at),不会物理删除。GetProductByID 默认不返回已软删除记录。

internal/repository/product.go

// UpdateProduct 管理员修改商品信息
func UpdateProduct(id uint, name string, desc string, price float64, stock int) error {
	updates := map[string]interface{}{}
	if name != "" {
		updates["name"] = name
	}
	if desc != "" {
		updates["description"] = desc
	}
	if price > 0 {
		updates["price"] = price
	}
	if stock >= 0 {
		updates["stock"] = stock
	}

	return DB.Model(&model.Product{}).Where("id = ?", id).Updates(updates).Error
}

// DeleteProduct 软删除商品
func DeleteProduct(id uint) error {
	return DB.Delete(&model.Product{}, "product_id = ?", id).Error
}

internal/repository/user.go

// GetUserIdByUid 根据UID获取用户
func GetUserIdByUid(uid uint) (*model.User, error) {
	var user model.User
	err := DB.First(&user, uid).Error
	return &user, err
}

// UpdatePassword 更新用户密码
func UpdatePassword(uid uint, hashedPwd string) error {
	return DB.Model(&model.User{}).Where("uid = ?", uid).Update("password", hashedPwd).Error
}

4. Service

internal/service/product.go

// GetProductDetail 获取商品详情
func GetProductDetail(id uint) (*model.Product, error) {
	return repository.GetProductByID(id)
}

// UpdateProduct 管理员修改商品
func UpdateProduct(req *dto.UpdateProductRequest) error {
	return repository.UpdateProduct(req.ProductId, req.Name, req.Desc, req.Price, req.Stock)
}

// DeleteProduct 管理员下架商品(软删除)
func DeleteProduct(id uint) error {
	return repository.DeleteProduct(id)
}

internal/service/user.go

// UpdatePassword 修改密码
func UpdatePassword(uid uint, oldPwd, newPwd string) error {
	userId, err := repository.GetUserIdByUid(uid)
	if err != nil {
		return errors.New("user not found")
	}

	// 校验旧密码
	if err := bcrypt.CompareHashAndPassword([]byte(userId.Password), []byte(oldPwd)); err != nil {
		return errors.New("old password incorrect")
	}

	// 哈希新密码
	hashed, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
	if err != nil {
		return err
	}

	// 更新密码
	return repository.UpdatePassword(userId.Uid, string(hashed))
}

internal/service/order.go

func GetOrderDetail(orderId uint) (*model.Order, error) {
	return repository.GetOrderByID(orderId)
}

5. Handlers

internal/api/handlers/product.go

// GetProductDetail 获取商品详情
func GetProductDetail(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.ParseUint(idStr, 10, 64)
	if err != nil {
		response.Fail(c, 400, "invalid product id")
		return
	}

	product, err := service.GetProductDetail(uint(id))
	if err != nil {
		response.Fail(c, 500, "failed to get product detail")
		return
	}

	response.Sucess(c, product)
}

// UpdateProduct 管理员修改商品
func UpdateProduct(c *gin.Context) {
	var req dto.UpdateProductRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Fail(c, 400, "invalid request")
		return
	}

	if err := service.UpdateProduct(&req); err != nil {
		response.Fail(c, 500, err.Error())
		return
	}

	response.Sucess(c, gin.H{"message": "product updated"})
}

// DeleteProduct 管理员下架商品(软删除)
func DeleteProduct(c *gin.Context) {
	var req dto.DeleteProductRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Fail(c, 400, "invalid request")
		return
	}

	if err := service.DeleteProduct(req.ProductId); err != nil {
		response.Fail(c, 500, err.Error())
		return
	}

	response.Sucess(c, gin.H{"message": "product deleted"})
}

internal/api/handlers/user.go

func UpdatePassword(c *gin.Context) {
	var req dto.UpdatePasswordRequest
	if err := c.ShouldBindBodyWithJSON(&req); err != nil {
		response.Fail(c, 400, "invalid request")
		return
	}

	uid, _ := c.Get("uid")
	if err := service.UpdatePassword(uid.(uint), req.OldPassword, req.NewPassword); err != nil {
		response.Fail(c, 500, err.Error())
		return
	}

	response.Sucess(c, gin.H{"message": "password updated"})
}

internal/api/handlers/order.go

// GetOrderDetail 获取订单详情
func GetOrderDetail(c *gin.Context) {
	idStr := c.Param("id")
	id, err := strconv.ParseUint(idStr, 10, 64)
	if err != nil {
		response.Fail(c, 400, "invalid order id")
		return
	}

	uid, _ := c.Get("uid")
	role, _ := c.Get("role")

	order, err := service.GetOrderDetail(uint(id))
	if err != nil {
		response.Fail(c, 404, "order not found")
		return
	}

	// 普通用户只能查自己的订单
	if role != "admin" && order.UserId != uid.(uint) {
		response.Fail(c, 403, "permission denied")
		return
	}

	response.Sucess(c, order)
}

6. 路由

cmd/scego-mall/main.go

// ...
	// 需要鉴权的私有路由
	protected := r.Group("/api")
	protected.Use(middleware.JWTAuth()) // 使用JWT鉴权中间件
	{
		// ... 其余路由 ...
		
		// 获取订单详情
		protected.GET("/order/info/:id", handlers.GetOrderDetail)

		// 商品管理
		protected.GET("/product/info/:id", handlers.GetProductDetail)                          // 商品详情
		protected.PUT("/product/update", middleware.RequireAdmin(), handlers.UpdateProduct)    // 修改商品
		protected.DELETE("/product/delete", middleware.RequireAdmin(), handlers.DeleteProduct) // 删除商品(软删除)

		// 用户
		protected.PUT("/user/password", handlers.UpdatePassword) // 修改密码
	}
// ...

调用示例

商品详情

curl http://localhost:8080/api/product/info/1 \
  -H "Authorization: Bearer <TOKEN>"

修改商品(admin)

curl -X PUT http://localhost:8080/api/product/update \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <ADMIN_TOKEN>" \
  -d '{"product_id":1,"name":"iPhone 16 Pro","desc":"新配色","price":9999.00,"stock":50}'

删除商品(admin,软删除)

curl -X DELETE http://localhost:8080/api/product/delete \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <ADMIN_TOKEN>" \
  -d '{"product_id":1}'

修改密码

curl -X PUT http://localhost:8080/api/user/password \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TOKEN>" \
  -d '{"old_password":"111111","new_password":"666666"}'

订单号的雪花算法

需求

在真实的秒杀系统中,订单号千万不能用数据库自增。
一是会暴露业务量(竞对下两单减一下就知道你一天卖多少);
二是分库分表时不方便;三是高并发下数据库自增锁可能成为瓶颈。
引入雪花算法 (Snowflake) 或类似的发号器。可以目录下实现一个 Snowflake ID 生成工具,在创建订单时手动赋给 OrderId

Snowflake 原理

64 bit 分成三段:

  • 41 bit:时间戳(毫秒),可用 69 年
  • 10 bit:机器 ID(datacenter + worker)
  • 12 bit:序列号,每毫秒最多 4096 个 ID

1. 雪花算法工具

pkg/snowflake/snowflake.go(新建)

package snowflake

import (
    "errors"
    "sync"
    "time"
)

const (
    epoch        = int64(1609459200000) // 2021-01-01T00:00:00Z,毫秒级时间戳起点
    datacenterBits = 5                  // 数据中心占 5 bit
    workerBits     = 5                  // 机器 ID 占 5 bit
    sequenceBits   = 12                 // 序列号占 12 bit

    maxDatacenter = int64(-1) ^ (int64(-1) << datacenterBits) // 31
    maxWorker     = int64(-1) ^ (int64(-1) << workerBits)     // 31
    maxSequence  = int64(-1) ^ (int64(-1) << sequenceBits)   // 4095
)

type Generator struct {
    mu         sync.Mutex
    datacenter int64
    worker    int64
    sequence  int64
    lastTime  int64
}

var defaultGenerator *Generator

// Init 初始化全局雪花生成器
// datacenterId: 数据中心 ID(0-31)
// workerId: 机器 ID(0-31)
func Init(datacenterId, workerId int64) error {
    if datacenterId < 0 || datacenterId > maxDatacenter {
        return errors.New("datacenter ID out of range")
    }
    if workerId < 0 || workerId > maxWorker {
        return errors.New("worker ID out of range")
    }
    defaultGenerator = &Generator{
        datacenter: datacenterId,
        worker:     workerId,
        lastTime:   -1,
        sequence:   0,
    }
    return nil
}

// Generate 生成一个新的雪花 ID
func Generate() int64 {
    return defaultGenerator.generate()
}

func (g *Generator) generate() int64 {
    g.mu.Lock()
    defer g.mu.Unlock()

    now := time.Now().UnixNano() / 1e6 // 毫秒时间戳

    if now == g.lastTime {
        g.sequence = (g.sequence + 1) & maxSequence
        if g.sequence == 0 {
            // 同一毫秒内序列号用完,等待下一毫秒
            for now <= g.lastTime {
                now = time.Now().UnixNano() / 1e6
            }
        }
    } else {
        g.sequence = 0
    }

    g.lastTime = now

    // 拼装 ID:时间戳偏移到最高位 | 数据中心 | 机器ID | 序列号
    id := (now-epoch)<<(datacenterBits+workerBits+sequenceBits) |
        g.datacenter<<(workerBits+sequenceBits) |
        g.worker<<sequenceBits |
        g.sequence

    return id
}

注意:雪花 ID 是 int64,但数据库用无符号 uint 也能存得下,JSON 序列化时统一输出为十进制数字。

2. Model

internal/model/order.go

移除 autoIncrement,改为手动赋值:

type Order struct {
    OrderId   uint     `gorm:"primaryKey;column:order_id" json:"order_id"` // 雪花算法,手动赋值
    UserId    uint      `gorm:"column:user_id;not null" json:"user_id"`
    ProductId uint      `gorm:"column:product_id;not null" json:"product_id"`
    SeckillId uint      `gorm:"column:seckill_id;not null" json:"seckill_id"`
    Amount    float64   `gorm:"column:amount;type:decimal(10,2);not null" json:"amount"`
    Status    int       `gorm:"column:status;type:int;default:0" json:"status"`
    CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
    UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
}

3. Consumer中赋值

internal/mq/consumer.go

import "github.com/Chuan81/secgo-mall/pkg/snowflake"

// CreateOrder 内,创建订单前手动生成雪花 ID
order := &model.Order{
    OrderId:   uint(snowflake.Generate()), // 手动赋值雪花 ID
    UserId:    msg.UserId,
    ProductId: msg.ProductId,
    SeckillId: msg.SeckillId,
    Amount:    msg.Amount,
    Status:    0,
}

4. 初始化

cmd/secgo-mall/main.go

import "github.com/Chuan81/secgo-mall/pkg/snowflake"

// database.InitMySQL() 之后添加
if err := snowflake.Init(1, 1); err != nil {
    slog.Error("snowflake init failed", "error", err.Error())
    os.Exit(1)
}
slog.Info("snowflake initialized", "datacenter", 1, "worker", 1)

验证方法

服务启动后,下单并查看数据库:

SELECT order_id FROM `order` ORDER BY order_id DESC LIMIT 10;

预期:order_id 是类似 1179304385089531904 的长整数,且单调递增(但无规律可猜)。

和自增 ID 的对比:

自增 ID 雪花 ID
形如 1, 2, 3 1179304385089531904
可猜测
分库分表
暴露业务量

优雅启停

现状:cmd/secgo-mall/main.go 中直接 r.Run(addr) 阻塞到死。
缺陷:如果是在 K8sDocker 中重新部署,发版时进程被杀,正在处理的 HTTP 请求、正在消费的
MQ 消息会被强制中断,导致数据不一致。
优化方案:使用 http.Server 配合 os.Signal 监听 SIGINTSIGTERM,调用
srv.Shutdown(),并在关闭前关闭 DB 连接和 MQ Channel。这是后端开发的必备基操。

cmd/secgo-mall/main.go 修改 main函数末尾部分

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/Chuan81/secgo-mall/config"
	"github.com/Chuan81/secgo-mall/internal/api/handlers"
	"github.com/Chuan81/secgo-mall/internal/cache"
	"github.com/Chuan81/secgo-mall/internal/middleware"
	"github.com/Chuan81/secgo-mall/internal/mq"
	database "github.com/Chuan81/secgo-mall/internal/repository"
	"github.com/Chuan81/secgo-mall/internal/service"
	"github.com/Chuan81/secgo-mall/pkg/response"
	"github.com/Chuan81/secgo-mall/pkg/snowflake"
	"github.com/gin-gonic/gin"
)

func main() {
	// ... 路由注册 ...

	addr := fmt.Sprintf(":%d", config.GlobalConfig.Server.Port)

	srv := &http.Server{
		Addr:    addr,
		Handler: r,
	}

	// goroutine中启动HTTP服务
	go func() {
		slog.Info("Secgo-Mall server starting", "addr", addr)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("Secgo-Mall server error", "error", err.Error())
			os.Exit(1)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	slog.Info("Secgo-Mall server shutting down")

	// 给现有请求最多 5 秒超时完成
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// 关闭 HTTP 服务(停止接收新请求,等待已有请求处理完毕)
	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("server shutdown error", "error", err.Error())
	}

	// 关闭数据库连接
	sqlDB, err := database.DB.DB()
	if err == nil {
		sqlDB.Close()
		slog.Info("database connection closed")
	}

	// 关闭 RabbitMQ Channel
	if mq.Channel != nil {
		mq.Channel.Close()
		slog.Info("RabbitMQ channel closed")
	}

	slog.Info("server exited")

	// // 启动服务器,监听在8080端口
	// if err := r.Run(addr); err != nil {
	// 	log.Fatalf("Failed to start server: %v", err)
	// }
}
原来 改造后
r.Run(addr) 阻塞直到进程终止 srv.ListenAndServe() 在 goroutine 中运行
Ctrl+C 直接杀进程 信号被捕获,触发优雅关闭
无连接收尾 等已有请求完成(最多 5 秒),再关闭 DB 和 MQ

TraceID

中间件

internal/middleware/trace.go

package middleware

import (
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

func TraceID() gin.HandlerFunc {
	return func(c *gin.Context) {
		traceId := uuid.New().String()
		c.Set("trace_id", traceId)

		// 将 trace_id 注入响应头,方便排查
		c.Header("X-Trace-ID", traceId)

		c.Next()
	}
}

注册到 main.go

protected.Use(middleware.JWTAuth()) 之前加一行:

protected.Use(middleware.TraceID()) // 链路追踪,需在最前面
protected.Use(middleware.JWTAuth())

顺序很重要,TraceID 要在最前面,这样后续所有中间件和 handler 都能从 Context 读到 trace_id。

验证

请求任意接口,响应头里会出现 X-Trace-ID

curl -I http://localhost:8080/api/user/info \
  -H "Authorization: Bearer <TOKEN>"
X-Trace-ID: a1b2c3d4-e5f6-...

服务日志里 Gin 的 Logger 会自动打出每个请求的 trace_id(因为 Gin 内置 Logger 在 TraceID 之后执行)。

如果想在 service 层日志里也带上 trace_id

每个 service 函数都要从 context 取 trace_id 再打日志,比较繁琐。面试时能说出"请求进来生成 UUID 塞 context,响应时带在日志里"这个思路就够了。

posted @ 2026-04-17 17:48  Chuan81  阅读(13)  评论(0)    收藏  举报