Files
jiu/backend/internal/router/router.go
T
wangjia 73955a139c feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引
- 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删
- 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验)
- List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选
- Summary:SKU/货值/数量排除已售,新增 sold_count
- 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选
- 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例
- 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:35:03 +08:00

333 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/handler"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
)
func Setup(r *gin.Engine, db *gorm.DB) {
// 服务层
authSvc := service.NewAuthService(db)
licenseSvc := service.NewLicenseService(db)
stockSvc := service.NewStockService(db)
paySvc := service.NewPayService(db, config.C.Pay.BaseURL, config.C.Pay.Secret, config.C.Pay.ReturnURL)
service.StartPayReconcile(paySvc) // 查单兜底(PAY_SECRET 未配置时空转不启动)
// 处理器
authH := handler.NewAuthHandler(authSvc, licenseSvc)
sessionH := handler.NewSessionHandler(authSvc)
licenseH := handler.NewLicenseHandler(licenseSvc)
payH := handler.NewPayHandler(paySvc)
productH := handler.NewProductHandler(db)
warehouseH := handler.NewWarehouseHandler(db)
partnerH := handler.NewPartnerHandler(db)
stockInH := handler.NewStockInHandler(db, stockSvc)
stockOutH := handler.NewStockOutHandler(db, stockSvc)
inventoryH := handler.NewInventoryHandler(db)
importH := handler.NewImportHandler(db)
userH := handler.NewUserHandler(db)
productOptH := handler.NewProductOptionHandler(db)
productAttrH := handler.NewProductAttrHandler(db)
productImageH := handler.NewProductImageHandler(db)
financeH := handler.NewFinanceHandler(db)
numberRuleH := handler.NewNumberRuleHandler(db)
publicH := handler.NewPublicHandler(db)
adminH := handler.NewAdminHandler(db)
shopH := handler.NewShopHandler(db)
errorReportH := handler.NewErrorReportHandler(db)
feedbackH := handler.NewFeedbackHandler(db)
// 健康检查(无需认证,用于前端连通性探测)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 版本信息(无需认证,用于客户端更新检查)
r.GET("/version", handler.GetVersion)
// 限流维度(按真实客户端 IP,依赖 main.go 的 SetTrustedProxies)。
// 防爆破/防刷写/防爬:未鉴权接口逐条挂 per-IP 限流,公开商品列表最紧。
rl := config.C.RateLimit
publicReadIP := middleware.RateLimitByIP(middleware.PerMinute(rl.PublicReadPerMin), rl.PublicReadPerMin)
shopListIP := middleware.RateLimitByIP(middleware.PerMinute(rl.ShopListPerMin), rl.ShopListPerMin)
loginIP := middleware.RateLimitByIP(middleware.PerMinute(rl.LoginPerMin), rl.LoginPerMin)
refreshIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RefreshPerMin), rl.RefreshPerMin)
registerIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RegisterPerMin), rl.RegisterPerMin)
errorsIP := middleware.RateLimitByIP(middleware.PerMinute(rl.ErrorsPerMin), rl.ErrorsPerMin)
// 日配额二级闸(同一 IP 自然日累计,反爬):单品 API 与 OG 页同一配额池
dqProduct := middleware.DailyQuota("product", func() int { return config.C.RateLimit.DailyProductPerIP })
dqShopList := middleware.DailyQuota("shoplist", func() int { return config.C.RateLimit.DailyShopListPerIP })
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicReadIP, dqProduct, publicH.ProductPage)
// 店铺公开页 SSR(挂与 shops API 同一套闸:分钟限流 + 日配额)
r.GET("/shop/:shop_code", shopListIP, dqShopList, publicH.ShopPage)
v1 := r.Group("/api/v1")
// 公开路由(无需登录):登录/刷新按 IP 限流(与 service 层失败锁定互补)
auth := v1.Group("/auth")
{
auth.POST("/login", loginIP, authH.Login)
auth.POST("/refresh", refreshIP, authH.Refresh)
// App→网页免登录:一次性票据兑换(公开;票据 60s 单次可用,复用 refresh 限流)
auth.POST("/web-ticket/exchange", refreshIP, authH.WebTicketExchange)
}
// 公开接口(无需登录):读接口防爬、写接口防刷
public := v1.Group("/public")
{
public.GET("/products/:public_id", publicReadIP, dqProduct, publicH.GetProduct)
public.GET("/shops/:shop_code/products", shopListIP, dqShopList, publicH.ListShopProducts)
public.GET("/release", publicReadIP, publicH.GetRelease)
public.POST("/errors", errorsIP, errorReportH.Submit)
public.POST("/register", registerIP, authH.Register)
}
// pay 支付成功 webhook(公开路由,HMAC 验签在 handler 内;按 IP 限流防噪)
v1.POST("/pay/callback", publicReadIP, payH.Callback)
// 需要 JWT 的基础路由组
api := v1.Group("")
api.Use(middleware.JWT(db))
// 每店限流(按 shop_id,挂在 JWT 之后):防单店打爆共享后端,保障多租户公平
api.Use(middleware.RateLimitByShop(middleware.PerSecond(rl.ShopRPS), rl.ShopBurst))
// 会话/在线状态(豁免 LicenseGuard:登出/心跳/在线列表在锁定期也要可用)
{
api.POST("/auth/logout", authH.Logout)
api.POST("/auth/ping", authH.Ping)
// App→网页免登录:签发一次性票据(需登录)
api.POST("/auth/web-ticket", authH.WebTicket)
// 在线会话列表:所有登录用户只读
api.GET("/sessions", sessionH.List)
// 强制下线:仅 admin/superadmin
api.DELETE("/sessions/:id", middleware.AdminOnly(), sessionH.ForceLogout)
}
// 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活)
license := api.Group("/license")
license.Use(middleware.ReadOnly())
{
license.GET("/info", licenseH.Info)
license.POST("/activate", licenseH.Activate)
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
license.GET("/devices", licenseH.Devices)
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
license.POST("/purchase", payH.Purchase)
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
license.GET("/promo-status", payH.PromoStatus)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
{
api.Use(middleware.ReadOnly(), middleware.LicenseGuard(db))
// 商品
products := api.Group("/products")
{
products.GET("", productH.List)
products.POST("", productH.Create)
products.POST("/find-or-create", productH.FindOrCreate)
products.GET("/:id/detail", productH.Detail)
products.GET("/:id/price-history", productH.PriceHistory)
products.GET("/:id/qrcode", productH.QRCode)
products.PUT("/:id", productH.Update)
products.DELETE("/:id", productH.Delete)
products.POST("/:id/images", productImageH.Upload)
products.DELETE("/:id/images/:image_id", productImageH.Delete)
}
// 仓库
warehouses := api.Group("/warehouses")
{
warehouses.GET("", warehouseH.List)
warehouses.POST("", warehouseH.Create)
warehouses.PUT("/:id", warehouseH.Update)
warehouses.DELETE("/:id", warehouseH.Delete)
}
// 往来单位
partners := api.Group("/partners")
{
partners.GET("", partnerH.List)
partners.POST("", partnerH.Create)
partners.PUT("/:id", partnerH.Update)
partners.DELETE("/:id", partnerH.Delete)
}
// 入库
stockIn := api.Group("/stock-in")
{
stockIn.GET("/orders", stockInH.List)
stockIn.GET("/summary", stockInH.Summary)
stockIn.GET("/orders/:id", stockInH.Get)
stockIn.POST("/orders", stockInH.Create)
stockIn.PUT("/orders/:id", stockInH.Update)
stockIn.DELETE("/orders/:id", stockInH.Delete)
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
// 退单(已审核单退货,库存删除+冲应付):仅管理员/超管(service 内判权)
stockIn.POST("/orders/:id/return", stockInH.Return)
// 确认进价(暂估0价→真实价,前向补偿):仅管理员/超管(service 内判权)
stockIn.POST("/orders/:id/confirm-cost", stockInH.ConfirmCost)
}
// 出库
stockOut := api.Group("/stock-out")
{
stockOut.GET("/orders", stockOutH.List)
stockOut.GET("/summary", stockOutH.Summary)
stockOut.GET("/orders/:id", stockOutH.Get)
stockOut.POST("/orders", stockOutH.Create)
stockOut.PUT("/orders/:id", stockOutH.Update)
stockOut.DELETE("/orders/:id", stockOutH.Delete)
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
// 退单(已审核单退货,库存加回+冲应收):管理员/超管 或 本人(service 内判权)
stockOut.POST("/orders/:id/return", stockOutH.Return)
// 确认售价(先出后定价,重算应收):仅管理员/超管(service 内判权)
stockOut.POST("/orders/:id/confirm-sale", stockOutH.ConfirmSale)
}
// 库存
inventory := api.Group("/inventory")
{
inventory.GET("", inventoryH.List)
inventory.GET("/summary", inventoryH.Summary)
inventory.GET("/logs", inventoryH.Logs)
inventory.PUT("/:id/remark", inventoryH.UpdateRemark)
inventory.PUT("/:id/status", inventoryH.UpdateStatus)
inventory.POST("/checks", inventoryH.CreateCheck)
inventory.GET("/checks/:id", inventoryH.GetCheck)
inventory.PUT("/checks/:id/complete", inventoryH.CompleteCheck)
}
// 用户管理(仅管理员)
users := api.Group("/users")
users.Use(middleware.AdminOnly())
{
users.GET("", userH.List)
users.POST("", userH.Create)
users.PUT("/:id", userH.Update)
users.PUT("/:id/reset-password", userH.ResetPassword)
}
// 财务
finance := api.Group("/finance")
{
finance.GET("/records", financeH.ListRecords)
finance.POST("/records", financeH.Create)
finance.PUT("/records/:id/close", financeH.Close)
finance.PUT("/records/close-by-ref", financeH.CloseByRef)
finance.GET("/summary", financeH.Summary)
finance.GET("/trend", financeH.Trend)
}
// 酒行信息
shop := api.Group("/shop")
{
shop.GET("/info", shopH.GetInfo)
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
}
// 意见反馈(文字 + 附图,直接提交后台)
feedback := api.Group("/feedback")
{
feedback.POST("", feedbackH.Submit)
feedback.POST("/images", feedbackH.UploadImage)
}
// 编号规则
numberRules := api.Group("/number-rules")
{
numberRules.GET("", numberRuleH.List)
numberRules.PUT("/:id", numberRuleH.Update)
}
// 导入
imp := api.Group("/import")
{
imp.POST("/products", importH.ImportProducts)
imp.POST("/partners", importH.ImportPartners)
imp.POST("/product-names", importH.ImportProductNames)
imp.POST("/product-series", importH.ImportProductSeries)
imp.POST("/product-specs", importH.ImportProductSpecs)
imp.POST("/product-codes", importH.ImportProductCodes)
imp.POST("/stock-in", importH.ImportStockIn)
imp.POST("/stock-out", importH.ImportStockOut)
imp.POST("/inventory", importH.ImportInventory)
}
// 基础数据选项(名称/系列/规格)
opts := api.Group("/product-options")
{
opts.GET("/names", productOptH.ListNames)
opts.POST("/names", productOptH.CreateName)
opts.PUT("/names/:id", productOptH.UpdateName)
opts.DELETE("/names/:id", productOptH.DeleteName)
opts.GET("/series", productOptH.ListSeries)
opts.POST("/series", productOptH.CreateSeries)
opts.PUT("/series/:id", productOptH.UpdateSeries)
opts.DELETE("/series/:id", productOptH.DeleteSeries)
opts.GET("/specs", productOptH.ListSpecs)
opts.POST("/specs", productOptH.CreateSpec)
opts.PUT("/specs/:id", productOptH.UpdateSpec)
opts.DELETE("/specs/:id", productOptH.DeleteSpec)
// 香型 / 分类字典(ProductCategory
opts.GET("/categories", productOptH.ListCategories)
opts.POST("/categories", productOptH.CreateCategory)
opts.PUT("/categories/:id", productOptH.UpdateCategory)
opts.DELETE("/categories/:id", productOptH.DeleteCategory)
// 商品属性字典(产地/保质期/储存方式/描述文档)
opts.GET("/origins", productAttrH.ListOrigins)
opts.POST("/origins", productAttrH.CreateOrigin)
opts.PUT("/origins/:id", productAttrH.UpdateOrigin)
opts.DELETE("/origins/:id", productAttrH.DeleteOrigin)
opts.GET("/shelf-lives", productAttrH.ListShelfLives)
opts.POST("/shelf-lives", productAttrH.CreateShelfLife)
opts.PUT("/shelf-lives/:id", productAttrH.UpdateShelfLife)
opts.DELETE("/shelf-lives/:id", productAttrH.DeleteShelfLife)
opts.GET("/storages", productAttrH.ListStorages)
opts.POST("/storages", productAttrH.CreateStorage)
opts.PUT("/storages/:id", productAttrH.UpdateStorage)
opts.DELETE("/storages/:id", productAttrH.DeleteStorage)
opts.GET("/description-docs", productAttrH.ListDescriptionDocs)
opts.POST("/description-docs", productAttrH.CreateDescriptionDoc)
opts.PUT("/description-docs/:id", productAttrH.UpdateDescriptionDoc)
opts.DELETE("/description-docs/:id", productAttrH.DeleteDescriptionDoc)
}
// 超级管理员专属
superAdmin := api.Group("/admin")
superAdmin.Use(middleware.SuperAdminOnly())
{
superAdmin.POST("/clear-data", adminH.ClearData)
superAdmin.GET("/reconcile", adminH.ReconcileInventory)
superAdmin.GET("/errors", errorReportH.List)
superAdmin.GET("/feedback", feedbackH.List)
superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus)
}
}
}