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

1155 lines
43 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
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
"cmautobuy/admin/syb"
)
// ---------- 2. 顺运宝数据 ----------
// SybList 渲染货运单列表页。
// 「匹配状态」是根据 spec_mappings 和当前 PDD 选项算出来的,不是存的字段。
func (h *Handler) SybList(c *gin.Context) {
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
}
// renderSybList 是 SybList、SybSync、SybLoginAndSync 跳转回来共用的渲染逻辑。
func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) {
h.renderSybListWithLoginReason(c, keyword, pageRaw, msg, c.Query("login_reason"))
}
// sybLoginHint 是会话无效时顶部那行提示。
//
// `[必须]` 配了 OCR 和没配 OCR 的文案要分开(工单 #48):
// 配了却提示"需要手工输入"会让人以为配置没生效;没配却提示"会自动登录",
// 点下去弹出验证码框会让人莫名其妙。
//
// `[必须]` 这不是错误,是状态说明,用 .hint 不用 .missing——
// 红色留给「PDD 链接未填写」那种需要立刻处理的。
func sybLoginHint(ocrURL string) string {
if strings.TrimSpace(ocrURL) != "" {
return "顺运宝未登录。点「同步」时会自动识别验证码并登录。"
}
return "顺运宝未登录。点「同步」时需要手工输入验证码。"
}
// renderSybListWithLoginReason 同 renderSybList,额外带一条"登录弹窗里要
// 显示的原因"——工单 #47:自动识别验证码失败或降级时,操作员需要知道
// 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。
func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) {
pageNum := service.ParsePage(pageRaw)
pageSize := service.ParsePageSize(c.Query("page_size"))
shop := strings.TrimSpace(c.Query("shop"))
stage := service.ParseSybStage(c.Query("stage"))
result, err := service.ListSybOrdersViewWithPageSize(h.db, keyword, shop, stage, pageNum, pageSize)
if err != nil {
if !service.IsValidationError(err) || result == nil {
fail(c, http.StatusInternalServerError,
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
}
orderNoError := ""
if err != nil {
orderNoError = err.Error()
} else {
// 有效输入统一回显成逗号分隔,避免 Excel 换行进入隐藏表单和分页 URL。
keyword = result.OrderNoQuery
}
purchaseRows := append([]service.SybOrderView(nil), result.Rows...)
openSybID := strings.TrimSpace(c.Query("open_syb_id"))
openPurchaseSybID := ""
if openSybID != "" && c.Query("purchase_intent") == "1" {
focused, err := service.GetSybOrderView(h.db, openSybID)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取原货运单采购信息失败,数据没有被改动。刷新页面重试。")
return
}
if focused != nil && focused.CanPurchase {
found := false
for _, row := range purchaseRows {
if row.SybID == focused.SybID {
found = true
break
}
}
if !found {
purchaseRows = append(purchaseRows, *focused)
}
openPurchaseSybID = focused.SybID
openSybID = ""
}
}
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
if err != nil {
fail(c, http.StatusInternalServerError,
"读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
purchaseClients, err := service.ListPurchaseClientOptions(h.db, currentUser(c), h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。")
return
}
enabledShopCount, err := service.CountEnabledSybAllowedShops(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "读取顺运宝同步店铺数量失败,数据没有被改动。刷新后重试。")
return
}
syncStatus := service.GetSybSyncStatus()
status := msg
if status == "" {
status = sybStatusLine(result)
}
if syncStatus.Running && (msg == "" || strings.Contains(msg, "同步已开始")) {
status = "同步正在进行,请耐心等待;可打开同步记录刷新查看结果"
}
values := url.Values{}
if keyword != "" {
values.Set("order_no", keyword)
}
if stage != "" {
values.Set("stage", stage)
}
if shop != "" {
values.Set("shop", shop)
}
values.Set("page_size", strconv.Itoa(pageSize))
detailValues := url.Values{}
if keyword != "" {
detailValues.Set("order_no", keyword)
}
if stage != "" {
detailValues.Set("stage", stage)
}
if shop != "" {
detailValues.Set("shop", shop)
}
detailValues.Set("page_size", strconv.Itoa(pageSize))
detailValues.Set("page", strconv.Itoa(result.Page))
// `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。
// 这里只取 username(只读展示)和 base_url 是否配置正确。
username := ""
configProblem := ""
loginHint := ""
// `[必须]` 登录弹窗**只在自动登录失败后跳回来时才弹**(工单 #48)。
//
// 改之前是"本地会话过期就弹",导致配了 ocr_url 也会在**进入页面**时
// 被拦一次——因为 OCR 自动登录只接在「点同步」那条路径上。
//
// 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作;
// 真正会登录的只有「同步」这一个按钮。这与 Client 侧
// 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。
needLogin := c.Query("login") == "1"
now := time.Now()
today := service.SybToday(now)
defaults, err := service.DefaultSybSyncRange(h.db, now)
if err != nil {
fail(c, http.StatusInternalServerError,
"计算默认同步日期失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
rangeWarning := ""
if dateFrom == "" {
dateFrom = defaults.From
}
if dateTo == "" {
dateTo = defaults.To
}
if c.Query("date_from") == "" && c.Query("date_to") == "" {
rangeWarning = defaults.Warning
}
actor := currentUser(c)
aiBatchID := strings.TrimSpace(c.Query("ai_batch_id"))
var aiBatch *service.AIMatchBatchView
if aiBatchID != "" {
aiBatch, err = service.GetAIMatchBatchView(h.db, actor, aiBatchID)
if errors.Is(err, service.ErrForbidden) {
fail(c, http.StatusForbidden, "没有权限查看这个 AI 匹配批次。")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, "读取 AI 匹配批次失败,请刷新页面后重试。")
return
}
if aiBatch == nil {
fail(c, http.StatusNotFound, "AI 匹配批次不存在,可能已被清理。")
return
}
}
latestAIBatchID, err := service.LatestAIMatchBatchID(h.db, actor)
if err != nil {
fail(c, http.StatusInternalServerError, "读取最近 AI 匹配批次失败,请刷新页面后重试。")
return
}
aiBatchPageURL := func(id string) string {
query := url.Values{"ai_batch_id": {id}}
if keyword != "" {
query.Set("order_no", keyword)
}
if shop != "" {
query.Set("shop", shop)
}
if stage != "" {
query.Set("stage", stage)
}
query.Set("page", strconv.Itoa(result.Page))
query.Set("page_size", strconv.Itoa(pageSize))
query.Set("date_from", dateFrom)
query.Set("date_to", dateTo)
return "/syb?" + query.Encode()
}
cfg, cfgErr := config.Load()
switch {
case cfgErr != nil:
configProblem = cfgErr.Error()
default:
username = cfg.Syb.Username
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
configProblem = "顺运宝 base_url 配置有误: " + err.Error()
} else {
// 这里仍然要判会话,但结果只用来决定"要不要给一行提示",
// 不再用来决定"弹不弹窗"。EnsureSybSession 只读本地缓存、
// 本地判过期,不发外部请求,页面渲染不会因此变慢。
sessErr := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now())
if errors.Is(sessErr, service.ErrSybLoginRequired) {
loginHint = sybLoginHint(cfg.Syb.OcrURL)
}
}
}
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
"Keyword": keyword,
"OrderNoError": orderNoError,
"OrderNoFeedback": sybOrderNoFeedback(result),
"ShopFilter": shop,
"StageFilter": stage,
"StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"Rows": result.Rows,
"PurchaseRows": purchaseRows,
"OpenSybID": openSybID,
"OpenPurchaseSybID": openPurchaseSybID,
"Status": status,
"HasAny": result.HasAny,
"IsFiltered": result.IsFiltered,
"NeedLogin": needLogin,
"NeedLoginReason": loginReason,
"LoginHint": loginHint,
"Username": username,
"ConfigProblem": configProblem,
"Today": today,
"SyncDateFrom": dateFrom,
"SyncDateTo": dateTo,
"RangeError": c.Query("range_error"),
"RangeWarning": rangeWarning,
"SyncRunning": syncStatus.Running,
"SyncHistory": history,
"NeedSyncHistory": c.Query("history") == "1",
"HistoryFetchURL": sybHistoryPartialURL(history.Page,
keyword, shop, stage, dateFrom, dateTo),
"HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages,
keyword, shop, stage, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, shop, stage, dateFrom, dateTo),
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()),
"DetailURL": "/syb/detail?" + detailValues.Encode(),
"AssignableClients": purchaseClients.Rows,
"PurchaseClientCount": purchaseClients.SelectableCount,
"EnabledSyncShopCount": enabledShopCount,
"AIMatchBatch": aiBatch,
"AIMatchError": strings.TrimSpace(c.Query("ai_error")),
"AIMatchBatchFetchURL": func() string {
if aiBatch == nil {
return ""
}
return "/syb/ai-match/" + url.PathEscape(aiBatch.BatchID)
}(),
"LatestAIMatchBatchURL": func() string {
if latestAIBatchID == "" {
return ""
}
return aiBatchPageURL(latestAIBatchID)
}(),
}))
}
// SybDetail 返回一条货运单明细的采购处理弹窗片段。
func (h *Handler) SybDetail(c *gin.Context) {
detail, err := service.GetSybProcessingDetail(h.db, c.Query("id"))
if err != nil {
fail(c, http.StatusInternalServerError, "读取顺运宝处理详情失败:"+err.Error())
return
}
if detail == nil {
fail(c, http.StatusNotFound, "顺运宝货运单明细不存在。")
return
}
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。")
return
}
c.HTML(http.StatusOK, "syb/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c), "AssignableClients": assignableClients,
"Keyword": c.Query("order_no"), "ShopFilter": c.Query("shop"), "StageFilter": c.Query("stage"),
"CurrentPage": service.ParsePage(c.Query("page")),
"CurrentPageSize": service.ParsePageSize(c.Query("page_size")),
})
}
// SybSyncHistory 只返回同步记录弹窗内部的 HTML。它只读本地数据库,不调用
// 顺运宝;完整页面和局部刷新共用同一份模板,避免字段或分页标记漂移。
func (h *Handler) SybSyncHistory(c *gin.Context) {
c.Header("Cache-Control", "no-store")
keyword := c.Query("order_no")
shop := strings.TrimSpace(c.Query("shop"))
stage := service.ParseSybStage(c.Query("stage"))
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
if err != nil {
c.String(http.StatusInternalServerError, "读取同步记录失败,请稍后重试")
return
}
c.HTML(http.StatusOK, "syb/history_partial", gin.H{
"SyncHistory": history,
"HistoryFetchURL": sybHistoryPartialURL(history.Page,
keyword, shop, stage, dateFrom, dateTo),
"HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages,
keyword, shop, stage, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, shop, stage, dateFrom, dateTo),
})
}
func sybHistoryPagination(pageNum, totalPages int, keyword, shop, stage, dateFrom, dateTo string) service.PaginationView {
link := func(target int) string { return sybHistoryURL(target, keyword, shop, stage, dateFrom, dateTo) }
view := service.PaginationView{
Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages,
FirstURL: link(1), LastURL: link(totalPages),
}
if view.HasPrev {
view.PrevURL = link(pageNum - 1)
}
if view.HasNext {
view.NextURL = link(pageNum + 1)
}
return view
}
func sybHistoryURL(pageNum int, keyword, shop, stage, dateFrom, dateTo string) string {
values := url.Values{"history": {"1"}, "history_page": {strconv.Itoa(pageNum)}}
if keyword != "" {
values.Set("order_no", keyword)
}
if shop != "" {
values.Set("shop", shop)
}
if stage != "" {
values.Set("stage", stage)
}
values.Set("date_from", dateFrom)
values.Set("date_to", dateTo)
return "/syb?" + values.Encode()
}
func sybHistoryPartialPagination(pageNum, totalPages int, keyword, shop, stage, dateFrom, dateTo string) service.PaginationView {
link := func(target int) string { return sybHistoryPartialURL(target, keyword, shop, stage, dateFrom, dateTo) }
view := service.PaginationView{
Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages,
FirstURL: link(1), LastURL: link(totalPages),
}
if view.HasPrev {
view.PrevURL = link(pageNum - 1)
}
if view.HasNext {
view.NextURL = link(pageNum + 1)
}
return view
}
func sybHistoryPartialURL(pageNum int, keyword, shop, stage, dateFrom, dateTo string) string {
values := url.Values{"history_page": {strconv.Itoa(pageNum)}}
if keyword != "" {
values.Set("order_no", keyword)
}
if shop != "" {
values.Set("shop", shop)
}
if stage != "" {
values.Set("stage", stage)
}
values.Set("date_from", dateFrom)
values.Set("date_to", dateTo)
return "/syb/sync-history?" + values.Encode()
}
// sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
func sybStatusLine(result *service.SybListResult) string {
prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total)
if result.OrderNoCount > 0 {
prefix = fmt.Sprintf("输入 %d 个订单号,当前筛选命中 %d 个订单、%d 条商品明细",
result.OrderNoCount, result.MatchedOrderCount, result.Total)
} else if label := service.SybStageLabel(result.Stage); label != "" {
prefix = fmt.Sprintf("%s:%d 条", label, result.Total)
} else if result.IsFiltered {
prefix = fmt.Sprintf("筛选结果:%d 条货运单明细", result.Total)
}
if !result.HasAny {
return "还没有货运单明细。点上方「同步」从顺运宝拉取。"
}
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
}
func sybOrderNoFeedback(result *service.SybListResult) string {
if result == nil || result.OrderNoCount == 0 {
return ""
}
feedback := fmt.Sprintf("已输入 %d 个订单号;当前筛选命中 %d 个订单、%d 条商品明细。",
result.OrderNoCount, result.MatchedOrderCount, result.Total)
if len(result.UnmatchedOrderNos) > 0 {
feedback += " 当前筛选未命中:" + strings.Join(result.UnmatchedOrderNos, "、")
}
return feedback
}
// SybSync 点「同步」按钮的入口。
//
// 流程见工单 #46/#47:
//
// 会话有效 ─────────────────────────→ 直接开始同步(后台跑,立即跳转回列表页)
// 会话无效/过期 ──→ OCR 已配置 ──→ 自动识别验证码登录成功 ──→ 直接开始同步,无人值守
// │ └→ 识别失败/OCR 不可达 ──┐
// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框
func (h *Handler) SybSync(c *gin.Context) {
options, err := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
if err != nil {
h.sybRedirectRange(c, err.Error())
return
}
if err := service.EnsureEnabledSybAllowedShops(h.db); err != nil {
h.sybRedirect(c, "同步没有启动:"+err.Error())
return
}
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
fail(c, http.StatusBadRequest, "顺运宝 base_url 配置有误: "+err.Error())
return
}
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
if !errors.Is(err, service.ErrSybLoginRequired) {
fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error())
return
}
// `[必须]` 会话过期后先尝试 OCR 自动登录(工单 #47),
// 识别不出来或 OCR 不可达再降级弹手工输入框——不是直接报错。
reason, ok := h.attemptSybAutoLogin(c, client, *cfg)
if !ok {
h.sybRedirectLogin(c, reason)
return
}
started, startErr := h.startSybSync(client, *cfg, options, currentUser(c))
if startErr != nil {
h.sybRedirect(c, "同步没有启动:"+startErr.Error())
return
}
if !started {
h.sybRedirect(c, "同步正在进行,请耐心等待,请勿重复点击")
return
}
h.sybRedirectAfterStart(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果")
return
}
started, startErr := h.startSybSync(client, *cfg, options, currentUser(c))
if startErr != nil {
h.sybRedirect(c, "同步没有启动:"+startErr.Error())
return
}
if !started {
h.sybRedirect(c, "同步正在进行,请耐心等待,请勿重复点击")
return
}
h.sybRedirectAfterStart(c, "同步已开始,请稍后刷新页面查看结果")
}
// attemptSybAutoLogin 尝试用配置的 OCR 服务自动识别验证码并登录。
//
// `[必须]` cfg.Syb.OcrURL 留空 = 禁用,直接返回 ok=false、reason=""——
// 这种情况下弹出的手工登录框不应该带任何"失败原因"文案,因为压根没
// 尝试过自动识别,见工单 #47 验收标准「ocr_url 留空 → 直接走手工,不报错」。
//
// 返回 ok=true 时表示登录已成功、会话已缓存,调用方可以直接发起同步;
// ok=false 时 reason 是给操作员看的降级原因,调用方应该带着它弹手工输入框。
func (h *Handler) attemptSybAutoLogin(c *gin.Context, client *syb.Client, cfg config.Config) (reason string, ok bool) {
if strings.TrimSpace(cfg.Syb.OcrURL) == "" {
return "", false
}
ocrClient, err := syb.NewOcrClient(cfg.Syb.OcrURL, syb.DefaultOcrTimeout)
if err != nil {
// `[必须]` OCR 客户端本身构造失败(配置有误)也是"服务不可用"的
// 一种,同样降级到手工,不报错,见工单 #47。
return "验证码识别服务配置有误(" + err.Error() + "),请手工输入", false
}
result, degradeReason := client.LoginWithOCR(
c.Request.Context(), ocrClient, cfg.Syb.Username, cfg.Syb.Password, cfg.Syb.OcrMaxAttempts)
if degradeReason != "" {
return degradeReason, false
}
// `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步,
// 和 SybLoginAndSync 手工登录路径的处理方式一致。
if err := service.SaveSybLoginSession(h.db, client, result.User.Username, result.ExpiresAt); err != nil {
log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err)
}
return "", true
}
// SybCaptcha 返回一张新的顺运宝验证码图片。
//
// `[必须]` 验证码和随后提交的登录必须用同一个 Cookie Jar(08 §3.2),
// 所以这里新建的 syb.Client 要缓存起来(service.NewPendingSybLogin),
// 供 SybLoginAndSync 复用,不能各请求各建一个客户端。
func (h *Handler) SybCaptcha(c *gin.Context) {
cfg, err := config.Load()
if err != nil {
c.String(http.StatusBadRequest, "%s", err.Error())
return
}
client, err := service.NewPendingSybLogin(cfg.Syb.BaseURL)
if err != nil {
c.String(http.StatusBadRequest, "%s", err.Error())
return
}
captcha, err := client.FetchCaptcha(c.Request.Context())
if err != nil {
c.String(http.StatusBadGateway, "获取验证码失败:%s", err.Error())
return
}
c.Data(http.StatusOK, captcha.ContentType, captcha.Image)
}
// SybLoginAndSync 提交验证码登录,成功后立即发起同步。
//
// `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志——
// 界面上只有验证码是操作员手输的,见工单 #46。
func (h *Handler) SybLoginAndSync(c *gin.Context) {
options, optionErr := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now())
if optionErr != nil {
h.sybRedirectRange(c, optionErr.Error())
return
}
if err := service.EnsureEnabledSybAllowedShops(h.db); err != nil {
h.sybRedirect(c, "同步没有启动:"+err.Error())
return
}
cfg, err := config.Load()
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
code := strings.TrimSpace(c.PostForm("code"))
if code == "" {
h.sybRedirect(c, "验证码不能为空,请重新输入")
return
}
client := service.PendingSybLoginClient()
if client == nil {
h.sybRedirect(c, "验证码已过期,请重新获取后再试")
return
}
defer service.ClearPendingSybLogin()
result, err := client.Login(c.Request.Context(), cfg.Syb.Username, cfg.Syb.Password, code)
if err != nil {
// `[必须]` err 来自 syb.Client,错误信息本身不含密码,可以直接展示。
h.sybRedirect(c, "登录失败:"+err.Error())
return
}
// `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步。
if err := service.SaveSybLoginSession(h.db, client, result.User.Username, result.ExpiresAt); err != nil {
log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err)
}
started, startErr := h.startSybSync(client, *cfg, options, currentUser(c))
if startErr != nil {
h.sybRedirect(c, "登录成功,但同步没有启动:"+startErr.Error())
return
}
if !started {
h.sybRedirect(c, "同步正在进行,请耐心等待,请勿重复点击")
return
}
h.sybRedirectAfterStart(c, "登录成功,同步已开始,请稍后刷新页面查看结果")
}
// startSybSync 尝试拿互斥标志并在后台协程里跑同步;已经在跑时返回 false。
//
// `[必须]` 同步是长任务,不能阻塞 HTTP 请求线程直到结束,也不引入后台
// 协程池——一次只允许一个同步在跑,靠 service.TryStartSybSync 这个互斥
// 标志挡住重复点击,见工单 #46。
//
// `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()——
// 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。
func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions, actor *model.User) (bool, error) {
if !service.TryStartSybSync() {
return false, nil
}
runID, err := service.CreateSybSyncRun(h.db, actor, options, time.Now())
if err != nil {
service.FinishSybSync(service.SyncReport{Err: err, FinishedAt: time.Now().UTC()})
return false, err
}
db := h.db
go func() {
report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options)
if err := service.FinishSybSyncRun(db, runID, report); err != nil {
log.Printf("syb_sync_run_finish_failed run_id=%s err=%v", runID, err)
if report.Err == nil {
report.Err = fmt.Errorf("同步数据已处理,但保存同步记录失败,请联系维护者核对:%w", err)
}
}
service.FinishSybSync(report)
}()
return true, nil
}
// sybRedirect 处理完写操作后跳回列表页,带上当前筛选和提示信息。
// 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。
func (h *Handler) sybRedirect(c *gin.Context, msg string) {
params := url.Values{}
appendSybFormState(params, c)
if msg != "" {
params.Set("msg", msg)
}
target := "/syb"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
// sybRedirectAfterStart 不保留本次日期。后台任务结束后刷新干净的列表 URL,
// 页面就会按新的覆盖游标给出下一段默认范围,避免误点后重复上一段。
func (h *Handler) sybRedirectAfterStart(c *gin.Context, msg string) {
params := url.Values{"msg": {msg}}
if keyword := strings.TrimSpace(c.PostForm("order_no")); keyword != "" {
params.Set("order_no", keyword)
}
if shop := strings.TrimSpace(c.PostForm("shop")); shop != "" {
params.Set("shop", shop)
}
if stage := strings.TrimSpace(c.PostForm("stage")); stage != "" {
params.Set("stage", stage)
}
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
}
// sybRedirectLogin 同 sybRedirect,但把 reason 放进 login_reason 参数,
// 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要
// 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时
// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。
func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) {
params := url.Values{}
appendSybFormState(params, c)
params.Set("login", "1")
if reason != "" {
params.Set("login_reason", reason)
}
target := "/syb"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
// sybRedirectRange 把服务端日期校验错误带回工具栏日期表单。输入值一并保留,
// 操作员只需改错的那一端,不必重新填写。
func (h *Handler) sybRedirectRange(c *gin.Context, message string) {
params := url.Values{}
appendSybFormState(params, c)
params.Set("range_error", message)
c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode())
}
// appendSybFormState 保存同步/登录跳转后仍需要的页面状态。
func appendSybFormState(params url.Values, c *gin.Context) {
for _, name := range []string{"order_no", "shop", "stage", "page", "date_from", "date_to"} {
if value := strings.TrimSpace(c.PostForm(name)); value != "" {
params.Set(name, value)
}
}
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
}
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
func (h *Handler) SybAssociatePdd(c *gin.Context) {
goodsID, err := service.AssociateSybPdd(h.db, c.PostForm("syb_id"), c.PostForm("pdd_url"),
c.PostForm("confirm_replace") == "1")
if err != nil {
h.sybRedirect(c, "PDD 关联未保存:"+err.Error()+"。请核对链接后重试。")
return
}
h.sybRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID))
}
// SybCollectPdd 为货运单当前关联的 PDD 商品创建采集任务。
func (h *Handler) SybCollectPdd(c *gin.Context) {
result, err := service.CreateSybPddCollectTasksForUser(
h.db, currentUser(c), []string{c.PostForm("syb_id")}, c.PostForm("client_id"))
if err != nil {
h.sybRedirect(c, "采集任务未创建:"+err.Error())
return
}
h.sybRedirect(c, service.FormatCollectTaskMessage(result))
}
// SybCollectPddBatch 为勾选的顺运宝明细批量创建 PDD 采集任务。
// Service 会重新校验阶段、按 PDD 商品去重并核对指定客户端的可见范围。
func (h *Handler) SybCollectPddBatch(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.sybRedirect(c, "没有选择可采集的顺运宝明细,没有创建任务。")
return
}
result, err := service.CreateSybPddCollectTasksForUser(
h.db, currentUser(c), ids, c.PostForm("client_id"))
if err != nil {
message := "批量采集任务未创建:系统没有改动这一批数据,请稍后重试。"
if service.IsValidationError(err) {
message = "批量采集任务未创建:" + err.Error()
}
h.sybRedirect(c, message)
return
}
h.sybRedirect(c, service.FormatCollectTaskMessage(result))
}
// SybMatch 保存规格匹配结果。
//
// 关键:保存的是**可复用的商品规格映射**(写 spec_mappings),
// 不是这一张订单的临时数据。下次遇到同一个商品和规格原文自动带出,
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
func (h *Handler) SybMatch(c *gin.Context) {
actor := currentUser(c)
operator := ""
if actor != nil {
operator = actor.UserID
}
if err := service.SaveSybMapping(h.db, c.PostForm("syb_id"), c.PostForm("pdd_option_key"), operator, c.PostForm("context_version")); err != nil {
h.sybRedirect(c, "规格映射未保存:"+err.Error())
return
}
h.sybRedirect(c, "规格映射已保存;相同蝦皮商品、顺运宝规格和 PDD 商品会自动复用。")
}
// SybCreateTask 由勾选的货运单创建采购任务。
//
// 六条校验缺一不可,见 docs/admin/01-requirements.md §5:
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有商品规格映射
// 4. 数量 > 0
// 5. **单价上限已填且 > 0**,后端按最新数量计算订单总价上限
// 6. 已选择分配的客户端
//
// 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么。
func (h *Handler) SybCreateTask(c *gin.Context) {
ids := c.PostFormArray("ids")
unitPrices := c.PostFormArray("unit_price_limit_yuan")
mappingKeys := c.PostFormArray("mapping_option_key")
contextVersions := c.PostFormArray("context_version")
if len(ids) == 0 {
h.sybRedirect(c, "没有选择货运单明细,没有创建采购任务。")
return
}
requests := make([]service.PurchaseTaskRequest, 0, len(ids))
preFailures := make([]service.TaskCreateError, 0)
for i, sybID := range ids {
if i >= len(unitPrices) {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币单价上限不能为空"})
continue
}
if i >= len(mappingKeys) || i >= len(contextVersions) ||
strings.TrimSpace(mappingKeys[i]) == "" || strings.TrimSpace(contextVersions[i]) == "" {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "规格确认信息已失效,请刷新页面后重试"})
continue
}
cent, err := service.ParsePriceYuanToCent(unitPrices[i])
if err != nil {
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: err.Error()})
continue
}
requests = append(requests, service.PurchaseTaskRequest{
SybID: sybID, UnitPriceLimitCent: cent,
MappingOptionKey: strings.TrimSpace(mappingKeys[i]),
ContextVersion: strings.TrimSpace(contextVersions[i]),
})
}
result, err := service.CreatePurchaseTasksWithOptions(
h.db, currentUser(c), requests,
purchaseTaskOptionsFromForm(c),
)
if err != nil {
h.sybRedirect(c, "采购任务没有创建:"+err.Error())
return
}
result.Failures = append(preFailures, result.Failures...)
h.sybRedirect(c, purchaseTaskResultMessage(result))
}
func purchaseTaskOptionsFromForm(c *gin.Context) service.PurchaseTaskOptions {
return service.PurchaseTaskOptions{
ClientID: c.PostForm("client_id"),
// Admin 新建采购任务固定为 live。不能相信浏览器提交的 execution_mode,
// 否则篡改表单就能改变任务的不可变执行模式。
ExecutionMode: model.TaskExecutionLive,
}
}
func purchaseTaskResultMessage(result service.PurchaseTaskResult) string {
parts := []string{fmt.Sprintf("已创建 %d 个采购任务", result.Created)}
if len(result.Failures) > 0 {
failures := make([]string, 0, len(result.Failures))
for _, failure := range result.Failures {
failures = append(failures, failure.SybID+"("+failure.Reason+")")
}
parts = append(parts, "未创建:"+strings.Join(failures, ";"))
}
return strings.Join(parts, "。")
}
// SybDelete 批量删除货运单。
func (h *Handler) SybDelete(c *gin.Context) {
// TODO(骨架)
fail(c, http.StatusNotImplemented, "删除功能尚未实现。")
}
// ---------- 3. 采集采购 ----------
//
// `tasks` 是一张表,用 task_type 区分采集和采购,这个模块把两种任务放在
// 同一个列表里显示,靠「目标」一列概括各自的业务信息,见 #19。
// 拼接和翻译逻辑全在 service/task.go,这里只做取参数 → 调 service → 渲染。
// TaskList 渲染「采集采购」列表页。
func (h *Handler) TaskList(c *gin.Context) {
actor := currentUser(c)
pageSize := service.ParsePageSize(c.Query("page_size"))
creatorFilter := c.Query("creator")
filter := repository.TaskFilter{
Type: service.ParseTaskType(c.Query("type")),
Status: service.ParseTaskStatus(c.Query("status")),
Keyword: c.Query("q"),
}
result, err := service.ListTasksViewForUserWithPageSize(h.db, actor, filter, creatorFilter,
service.ParsePage(c.Query("page")), pageSize)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
creatorOptions, err := service.TaskCreatorOptions(h.db, actor)
if err != nil {
fail(c, http.StatusInternalServerError, "读取任务创建人筛选失败,请刷新页面重试。")
return
}
// 底部状态条平时显示统计,刚做完删除操作时先显示操作结果,
// 跳转带过来的 msg 参数,见 h.taskRedirect。
statusLine := result.StatusLine()
if msg := c.Query("msg"); msg != "" {
statusLine = msg + " · " + statusLine
}
values := url.Values{}
if filter.Type != "" {
values.Set("type", string(filter.Type))
}
if filter.Status != "" {
values.Set("status", string(filter.Status))
}
if strings.TrimSpace(filter.Keyword) != "" {
values.Set("q", filter.Keyword)
}
if actor.IsAdmin() && creatorFilter != "" {
values.Set("creator", creatorFilter)
}
values.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
"Rows": result.Rows,
"Keyword": filter.Keyword,
"Status": statusLine,
"TypeFilter": string(filter.Type),
"StatusFilter": string(filter.Status),
"TypeOptions": service.TaskTypeOptions(),
"StatusOptions": service.TaskStatusOptions(),
"ShowCreator": actor.IsAdmin(),
"CreatorFilter": creatorFilter,
"CreatorOptions": creatorOptions,
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()),
}))
}
// TaskDetail 渲染双击行弹出的只读详情弹窗内容(不是整页)。
//
// `[必须]` 字段只读,不提供改派 / 重试 / 取消或直接创建入口。失败/取消采购
// 只生成返回原顺运宝明细的导航链接,真正创建仍走原确认表单和服务端门禁。
// 弹窗机制复用 #18 已有的那套(static/js/app.js 的 setupRowDetail),
// 不新造一套。
func (h *Handler) TaskDetail(c *gin.Context) {
taskID := c.Query("id")
if taskID == "" {
fail(c, http.StatusBadRequest, "任务编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetTaskDetailForUser(h.db, currentUser(c), taskID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取任务详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个任务不存在,请刷新页面。")
return
}
c.HTML(http.StatusOK, "task/detail_modal", gin.H{"D": detail})
}
// TaskDelete 批量删除任务。
//
// `tasks` 表没有软删除列——加一列是数据库结构变更,不在本工单范围内,
// 见 repository.DeleteTasks 的注释。二次确认在前端做(data-confirm-delete)。
func (h *Handler) TaskDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.taskRedirect(c, "没有勾选任何任务,没有删除")
return
}
n, err := service.DeleteTasksForUser(h.db, currentUser(c), ids)
if err != nil {
if errors.Is(err, service.ErrTaskNotVisible) {
fail(c, http.StatusBadRequest, "删除失败:所选任务不存在或不在当前账号可见范围。没有删除任何记录。")
return
}
fail(c, http.StatusInternalServerError,
"删除失败:"+err.Error()+"。没有删除任何记录。")
return
}
h.taskRedirect(c, fmt.Sprintf("已删除 %d 条", n))
}
// taskRedirect 处理完写操作后跳回列表页,带上当前筛选条件,
// 免得操作员每做一次删除就要重新筛一遍。用 303 而不是直接渲染,
// 是为了让浏览器地址栏变成 GET /tasks——按 F5 不会重复提交刚才的删除。
func (h *Handler) taskRedirect(c *gin.Context, msg string) {
params := url.Values{}
if t := c.PostForm("type"); t != "" {
params.Set("type", t)
}
if s := c.PostForm("status"); s != "" {
params.Set("status", s)
}
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if p := c.PostForm("page"); p != "" {
params.Set("page", p)
}
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
if creator := c.PostForm("creator"); creator != "" && currentUser(c).IsAdmin() {
params.Set("creator", creator)
}
if msg != "" {
params.Set("msg", msg)
}
target := "/tasks"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}
// ---------- 4. 客户端列表 ----------
// ClientList 渲染客户端列表页。
//
// 「状态」是**算出来的**:last_seen_at 在 N 分钟内为在线,否则离线。
// 数据库里没有 status 字段,存成字段会和真实情况不同步。
//
// 客户端执行长任务期间不调 claim,可能显示为离线,属正常现象
// (没有心跳是有意的,见 docs/admin/04-client-api.md §3)。
func (h *Handler) ClientList(c *gin.Context) {
keyword := c.Query("name")
actor := currentUser(c)
pageSize := service.ParsePageSize(c.Query("page_size"))
result, err := service.ListClientPageForUserWithPageSize(h.db, actor, keyword, h.onlineThreshold,
service.ParsePage(c.Query("page")), pageSize)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
return
}
status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d · 第 %d/%d 页",
result.Total, result.Online, result.Offline, result.Page, result.TotalPages)
purchasers := []model.User{}
if actor != nil && actor.IsAdmin() {
purchasers, err = service.ListActivePurchasers(h.db, actor)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取可绑定采购员失败,数据没有被改动。请稍后重试。")
return
}
}
values := url.Values{}
if strings.TrimSpace(keyword) != "" {
values.Set("name", keyword)
}
values.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{
"Keyword": keyword, "Rows": result.Rows, "Status": status,
"Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"),
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()),
"IsFiltered": strings.TrimSpace(keyword) != "",
}))
}
// ClientAssign 由管理员绑定或转交客户端。只改归属历史,不改 tasks。
func (h *Handler) ClientAssign(c *gin.Context) {
changed, transferred, err := service.AssignClient(h.db, currentUser(c),
c.PostForm("client_id"), c.PostForm("user_id"), time.Now())
if err != nil {
h.clientActionFailure(c, err, "绑定客户端失败")
return
}
message := "客户端已经绑定给该采购员,无需重复操作"
if changed {
message = "客户端绑定成功"
}
if transferred {
message = "客户端已转交,新负责人立即生效;既有任务保持不变"
}
log.Printf("client_assignment_changed actor=%s client_id=%s user_id=%s transferred=%t changed=%t",
currentUser(c).Username, c.PostForm("client_id"), c.PostForm("user_id"), transferred, changed)
redirectClients(c, message, "")
}
// ClientUnassign 由管理员解绑客户端。前端确认用于防误触,服务端权限仍是硬边界。
func (h *Handler) ClientUnassign(c *gin.Context) {
err := service.UnassignClient(h.db, currentUser(c), c.PostForm("client_id"), time.Now())
if err != nil {
h.clientActionFailure(c, err, "解绑客户端失败")
return
}
log.Printf("client_assignment_ended actor=%s client_id=%s",
currentUser(c).Username, c.PostForm("client_id"))
redirectClients(c, "客户端已解绑;归属历史和既有任务均已保留", "")
}
func (h *Handler) clientActionFailure(c *gin.Context, err error, fallback string) {
if service.IsValidationError(err) || errors.Is(err, repository.ErrClientNotFound) ||
errors.Is(err, repository.ErrPurchaserNotActive) || errors.Is(err, repository.ErrClientNotAssigned) {
redirectClients(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, fallback+",已有归属和任务数据保持不变。刷新后重试。")
}
func redirectClients(c *gin.Context, message, errorMessage string) {
query := url.Values{}
if name := c.PostForm("name"); name != "" {
query.Set("name", name)
}
if page := c.PostForm("page"); page != "" {
query.Set("page", page)
}
query.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
if message != "" {
query.Set("msg", message)
}
if errorMessage != "" {
query.Set("error", errorMessage)
}
target := "/clients"
if encoded := query.Encode(); encoded != "" {
target += "?" + encoded
}
c.Redirect(http.StatusSeeOther, target)
}
// ClientDelete 批量删除客户端。
//
// 二次确认在前端做(见 static/js/app.js),这里直接删。
// 删掉之后客户端下次调 claim 会重新登记,属于正常行为。
func (h *Handler) ClientDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
fail(c, http.StatusBadRequest, "没有选中任何客户端,请勾选后再删除。")
return
}
n, err := service.DeleteClients(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"删除失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
return
}
log.Printf("clients_deleted count=%d", n)
redirectClients(c, fmt.Sprintf("已删除 %d 台客户端", n), "")
}