Files
cmautobuy/admin/handler/web/web.go
T

190 lines
7.4 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 web 是给浏览器用的页面处理器。
//
// 和 handler/api 分开是有意的:页面出错要渲染错误页、要 CSRF 防护,
// 接口出错要返回 JSON、要认证。混在一起迟早写错。
//
// 改动本文件前必读 admin/AGENTS.md。两条最容易踩:
// - 本层**不写业务逻辑、不拼 SQL**,只做取参数 → 调 service → 渲染;
// - 删除、导入这类破坏性操作用 POST,**不得用 GET**,
// 浏览器和插件会预取 GET 链接。
package web
import (
"database/sql"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
// Handler 持有各页面共用的依赖。
type Handler struct {
db *sql.DB
// onlineThreshold 是判定客户端"在线"的时间窗。
// 取「轮询周期 + 最长任务时长」,宽松一点,
// 免得客户端执行长任务期间被误判成离线。
onlineThreshold time.Duration
aiSecrets service.AISecretStore
aiPolicy service.AIEndpointPolicy
}
// Register 把五个模块的页面路由挂上去。
//
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecrets service.AISecretStore, aiPolicy service.AIEndpointPolicy) {
h := &Handler{db: db, onlineThreshold: onlineThreshold, aiSecrets: aiSecrets, aiPolicy: aiPolicy}
// CSRF 只挂在页面路由上。初始化和登录是公开页面,但 POST 仍要 CSRF。
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
public := r.Group("/", CSRFMiddleware())
public.GET("/setup", h.SetupPage)
public.POST("/setup", h.SetupSubmit)
public.GET("/login", h.LoginPage)
public.POST("/login", h.LoginSubmit)
// 登录中间件只挂业务网页组,绝不能挂在整个 Engine。
pages := r.Group("/", CSRFMiddleware(), AuthRequired(db))
pages.POST("/logout", h.Logout)
account := pages.Group("/account", AdminRequired())
account.POST("/change-password", h.ChangePassword)
// 打开根路径直接进第一个模块
pages.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/shopee")
})
// 1. 蝦皮数据
pages.GET("/shopee", h.ShopeeList)
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/spec/save", h.ShopeeSpecSave)
pages.POST("/shopee/collect", h.ShopeeCollect)
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
shopeeAdmin := pages.Group("/shopee", AdminRequired())
shopeeAdmin.POST("/delete", h.ShopeeDelete)
shopeeAdmin.POST("/restore", h.ShopeeRestore)
// 2. PDD 商品
pages.GET("/pdd", h.PddList)
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
pages.POST("/pdd/create", h.PddCreate)
pages.POST("/pdd/import", h.PddImport)
pages.POST("/pdd/save", h.PddSave)
pages.POST("/pdd/collect", h.PddCollect)
pages.POST("/pdd/recollect", h.PddRecollect)
pages.POST("/pdd/delete", h.PddDelete)
// 3. 顺运宝数据
pages.GET("/syb", h.SybList)
pages.GET("/syb/detail", h.SybDetail) // 采购处理弹窗
pages.GET("/syb/sync-history", h.SybSyncHistory) // 弹窗局部刷新,只读本地同步记录
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
pages.POST("/syb/associate-pdd", h.SybAssociatePdd)
pages.POST("/syb/collect-pdd", h.SybCollectPdd)
pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch)
pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/ai-match", h.SybAIMatchCreate)
pages.GET("/syb/ai-match/:batch_id", h.SybAIMatchStatus)
pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete)
pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") })
// 全局店铺管理:统一维护 SYB 与蝦皮渠道名称,仅管理员可写。
shops := pages.Group("/shops", AdminRequired())
shops.GET("", h.ShopList)
shops.POST("/create", h.ShopCreate)
shops.POST("/update", h.ShopUpdate)
shops.POST("/status", h.ShopStatus)
shops.POST("/delete", h.ShopDelete)
// 4. 采集采购
pages.GET("/tasks", h.TaskList)
pages.GET("/tasks/detail", h.TaskDetail) // 双击行时前端来取弹窗内容
pages.POST("/tasks/delete", h.TaskDelete)
// 5. 客户端列表
pages.GET("/clients", h.ClientList)
clients := pages.Group("/clients", AdminRequired())
clients.POST("/assign", h.ClientAssign)
clients.POST("/unassign", h.ClientUnassign)
clients.POST("/delete", h.ClientDelete)
// 6. 档口入库码:独立于顺运宝数据页,导航位置紧跟客户端列表。
pages.GET("/inner-codes", h.InnerCodeList)
innerCodes := pages.Group("/inner-codes")
innerCodes.POST("/import", h.InnerCodeImport)
innerCodes.POST("/match", h.InnerCodeMatch)
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
users := pages.Group("/users", AdminRequired())
users.GET("", h.UserList)
users.POST("/create", h.UserCreate)
users.POST("/status", h.UserSetStatus)
users.POST("/reset-password", h.UserResetPassword)
// 8. 第三方商品目录导入记录:系统级审计仅管理员可见。
integrations := pages.Group("/integrations", AdminRequired())
integrations.GET("/catalog", h.CatalogHistory)
integrations.GET("/catalog/detail", h.CatalogHistoryDetail)
// 9. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
aiSettings := pages.Group("/settings/ai", AdminRequired())
aiSettings.GET("", h.AIConfigList)
aiSettings.POST("/save", h.AIConfigSave)
aiSettings.POST("/secret", h.AIConfigSecretSave)
aiSettings.POST("/secret/clear", h.AIConfigSecretClear)
aiSettings.POST("/test", h.AIConfigTest)
aiSettings.POST("/enable", h.AIConfigEnable)
aiSettings.POST("/disable", h.AIConfigDisable)
}
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
func page(c *gin.Context, active, title string, extra gin.H) gin.H {
actor := currentUser(c)
data := gin.H{
"Active": active,
"Title": title,
"CSRFToken": csrfToken(c),
"CurrentUser": actor,
"PasswordChangeOpen": actor != nil && actor.IsAdmin() && c.Query("change_password") == "1",
"PasswordChangeError": c.Query("password_error"),
"PasswordChangeField": c.Query("password_field"),
"PasswordReturnPath": passwordReturnPath(c.Request.URL.RequestURI()),
}
for k, v := range extra {
data[k] = v
}
return data
}
// passwordReturnPath 保留用户所在业务页和原筛选条件,但移除改密弹窗自己的反馈参数。
func passwordReturnPath(raw string) string {
parsed, err := url.Parse(safeNext(raw))
if err != nil {
return "/shopee"
}
query := parsed.Query()
query.Del("change_password")
query.Del("password_error")
query.Del("password_field")
parsed.RawQuery = query.Encode()
return parsed.RequestURI()
}
// fail 渲染一个错误页。
//
// 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。
// Go 的错误堆栈只写日志,不要贴到页面上,见 docs/admin/06-quality-security.md §5。
func fail(c *gin.Context, status int, message string) {
c.HTML(status, "partials/error", gin.H{
"Title": "出错了",
"Message": message,
})
}