2026-07-26 14:03:32 +08:00
|
|
|
package webui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
"unicode/utf8"
|
|
|
|
|
|
2026-07-29 12:11:56 +08:00
|
|
|
"cmroubao/backend-api/internal/domain"
|
2026-07-26 15:18:48 +08:00
|
|
|
"cmroubao/backend-api/internal/transport/authcommon"
|
|
|
|
|
|
2026-07-26 14:03:32 +08:00
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
defaultListLimit = 20
|
|
|
|
|
maxRequestBytes = 21 << 20
|
|
|
|
|
maxTitleRunes = 120
|
|
|
|
|
maxTitleBytes = 2048
|
|
|
|
|
maxSKUBytes = 512
|
|
|
|
|
maxDescriptionBytes = 8192
|
2026-07-26 15:18:48 +08:00
|
|
|
csrfCookieName = authcommon.CSRFCookieName
|
|
|
|
|
csrfFormField = authcommon.CSRFFormField
|
2026-07-26 14:03:32 +08:00
|
|
|
formContentType = "text/html; charset=utf-8"
|
|
|
|
|
cssContentType = "text/css; charset=utf-8"
|
|
|
|
|
javascriptContentType = "text/javascript; charset=utf-8"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Handler struct {
|
|
|
|
|
service Service
|
|
|
|
|
renderer *Renderer
|
2026-07-29 11:08:23 +08:00
|
|
|
logEvent EventLogger
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 11:08:23 +08:00
|
|
|
type EventLogger func(string)
|
|
|
|
|
|
|
|
|
|
func NewHandler(
|
|
|
|
|
service Service,
|
|
|
|
|
renderer *Renderer,
|
|
|
|
|
loggers ...EventLogger,
|
|
|
|
|
) (*Handler, error) {
|
2026-07-26 14:03:32 +08:00
|
|
|
if service == nil {
|
|
|
|
|
return nil, errors.New("admin web service is required")
|
|
|
|
|
}
|
|
|
|
|
if renderer == nil {
|
|
|
|
|
return nil, errors.New("admin web renderer is required")
|
|
|
|
|
}
|
2026-07-29 11:08:23 +08:00
|
|
|
logEvent := EventLogger(func(string) {})
|
|
|
|
|
if len(loggers) > 0 && loggers[0] != nil {
|
|
|
|
|
logEvent = loggers[0]
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
return &Handler{
|
|
|
|
|
service: service,
|
|
|
|
|
renderer: renderer,
|
2026-07-29 11:08:23 +08:00
|
|
|
logEvent: logEvent,
|
2026-07-26 14:03:32 +08:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) Register(routes gin.IRoutes) {
|
2026-07-26 15:18:48 +08:00
|
|
|
h.RegisterStatic(routes)
|
|
|
|
|
h.RegisterProtected(routes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) RegisterStatic(routes gin.IRoutes) {
|
2026-07-26 14:03:32 +08:00
|
|
|
routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet)
|
|
|
|
|
routes.GET("/static/admin.js", SecurityHeaders(), h.Script)
|
2026-07-26 15:18:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
2026-07-26 14:03:32 +08:00
|
|
|
routes.GET("/tasks", SecurityHeaders(), h.ListTasks)
|
|
|
|
|
routes.GET("/tasks/new", SecurityHeaders(), h.NewTask)
|
|
|
|
|
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
|
|
|
|
routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail)
|
|
|
|
|
routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask)
|
2026-07-28 12:50:42 +08:00
|
|
|
routes.POST(
|
|
|
|
|
"/tasks/:id/order-authorizations",
|
|
|
|
|
SecurityHeaders(),
|
|
|
|
|
h.AuthorizeOrder,
|
|
|
|
|
)
|
2026-07-28 23:26:34 +08:00
|
|
|
if _, ok := h.service.(FreightService); ok {
|
|
|
|
|
routes.GET("/freight", SecurityHeaders(), h.ListFreight)
|
|
|
|
|
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
|
|
|
|
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
|
|
|
|
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
|
|
|
|
}
|
2026-07-28 23:51:59 +08:00
|
|
|
if _, ok := h.service.(ProcurementService); ok {
|
|
|
|
|
routes.POST(
|
|
|
|
|
"/freight/items/:id/procurement-request",
|
|
|
|
|
SecurityHeaders(),
|
|
|
|
|
h.CreateFreightProcurementRequest,
|
|
|
|
|
)
|
|
|
|
|
routes.POST(
|
|
|
|
|
"/freight/procurement-requests/:id/reference",
|
|
|
|
|
SecurityHeaders(),
|
|
|
|
|
h.BindFreightProcurementReference,
|
|
|
|
|
)
|
|
|
|
|
routes.POST(
|
|
|
|
|
"/freight/procurement-requests/:id/purchase-task",
|
|
|
|
|
SecurityHeaders(),
|
|
|
|
|
h.CreateFreightProcurementTask,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-28 23:26:34 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:51:41 +08:00
|
|
|
func (h *Handler) ERPConnection(ctx *gin.Context) {
|
|
|
|
|
service := h.service.(ERPConnectionService)
|
|
|
|
|
status, err := service.ERPConnectionStatus(ctx.Request.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderERPConnection(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusServiceUnavailable,
|
|
|
|
|
ERPConnectionStatus{},
|
|
|
|
|
"ERP 连接状态暂时无法读取。",
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.renderERPConnection(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusOK,
|
|
|
|
|
status,
|
|
|
|
|
"",
|
|
|
|
|
erpConnectionNotice(ctx.Query("notice")),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) RequestERPCaptcha(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(
|
|
|
|
|
ctx.Writer,
|
|
|
|
|
ctx.Request.Body,
|
|
|
|
|
maxLoginFormBytes,
|
|
|
|
|
)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(ERPConnectionService)
|
|
|
|
|
status, err := service.RequestERPCaptcha(ctx.Request.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderERPConnection(
|
|
|
|
|
ctx,
|
|
|
|
|
erpConnectionErrorStatus(err),
|
|
|
|
|
status,
|
|
|
|
|
erpConnectionErrorMessage(err),
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/erp?notice=captcha-ready")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) ERPCaptchaImage(ctx *gin.Context) {
|
|
|
|
|
ticket := strings.TrimSpace(ctx.Param("ticket"))
|
|
|
|
|
if !validToken(ticket) {
|
|
|
|
|
ctx.Status(http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(ERPConnectionService)
|
|
|
|
|
image, err := service.OpenERPCaptcha(ctx.Request.Context(), ticket)
|
|
|
|
|
if err != nil || !strings.HasPrefix(image.ContentType, "image/") ||
|
|
|
|
|
len(image.Content) == 0 {
|
|
|
|
|
ctx.Status(http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Header("Cache-Control", "no-store")
|
|
|
|
|
ctx.Header("Content-Type", image.ContentType)
|
|
|
|
|
ctx.Header("Content-Length", strconv.Itoa(len(image.Content)))
|
|
|
|
|
ctx.Header("Content-Disposition", "inline")
|
|
|
|
|
ctx.Data(http.StatusOK, image.ContentType, image.Content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) LoginERP(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(
|
|
|
|
|
ctx.Writer,
|
|
|
|
|
ctx.Request.Body,
|
|
|
|
|
maxLoginFormBytes,
|
|
|
|
|
)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ticket := strings.TrimSpace(ctx.PostForm("captcha_ticket"))
|
|
|
|
|
code := strings.TrimSpace(ctx.PostForm("captcha_code"))
|
|
|
|
|
service := h.service.(ERPConnectionService)
|
|
|
|
|
if !validToken(ticket) || !validERPCaptchaCode(code) {
|
|
|
|
|
status, _ := service.ERPConnectionStatus(ctx.Request.Context())
|
|
|
|
|
h.renderERPConnection(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusUnprocessableEntity,
|
|
|
|
|
status,
|
|
|
|
|
"请重新获取验证码后输入验证码。",
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
status, err := service.LoginERP(ctx.Request.Context(), ERPLoginInput{
|
|
|
|
|
CaptchaTicket: ticket,
|
|
|
|
|
CaptchaCode: code,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderERPConnection(
|
|
|
|
|
ctx,
|
|
|
|
|
erpConnectionErrorStatus(err),
|
|
|
|
|
status,
|
|
|
|
|
erpConnectionErrorMessage(err),
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/erp?notice=login-succeeded")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) renderERPConnection(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
statusCode int,
|
|
|
|
|
status ERPConnectionStatus,
|
|
|
|
|
errorMessage, notice string,
|
|
|
|
|
) {
|
|
|
|
|
token, err := csrfToken(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, statusCode, "erp-connection", erpConnectionPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "ERP 连接",
|
|
|
|
|
ERPCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
Status: status,
|
|
|
|
|
Error: errorMessage,
|
|
|
|
|
Notice: notice,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validERPCaptchaCode(value string) bool {
|
|
|
|
|
if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, character := range value {
|
|
|
|
|
if character < 32 || character == 127 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func erpConnectionErrorStatus(err error) int {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrERPNotConfigured):
|
|
|
|
|
return http.StatusUnprocessableEntity
|
|
|
|
|
case errors.Is(err, ErrERPSessionNeeded),
|
|
|
|
|
errors.Is(err, ErrERPCaptchaInvalid):
|
|
|
|
|
return http.StatusConflict
|
|
|
|
|
case errors.Is(err, ErrERPLoginRejected):
|
|
|
|
|
return http.StatusUnprocessableEntity
|
|
|
|
|
case errors.Is(err, ErrERPProtocol):
|
|
|
|
|
return http.StatusBadGateway
|
|
|
|
|
case errors.Is(err, ErrUnavailable):
|
|
|
|
|
return http.StatusServiceUnavailable
|
|
|
|
|
default:
|
|
|
|
|
return http.StatusInternalServerError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func erpConnectionErrorMessage(err error) string {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrERPNotConfigured):
|
|
|
|
|
return "ERP 服务账号尚未在后端启动环境中配置。"
|
|
|
|
|
case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPCaptchaInvalid):
|
|
|
|
|
return "验证码已失效,请重新获取后再登录。"
|
|
|
|
|
case errors.Is(err, ErrERPLoginRejected):
|
|
|
|
|
return "验证码不正确或 ERP 拒绝登录,请重新获取验证码后重试。"
|
|
|
|
|
case errors.Is(err, ErrERPProtocol):
|
|
|
|
|
return "ERP 返回格式无法确认,请稍后重试。"
|
|
|
|
|
case errors.Is(err, ErrUnavailable):
|
|
|
|
|
return "ERP 暂时不可用,请稍后重试。"
|
|
|
|
|
default:
|
|
|
|
|
return "ERP 连接操作失败,请稍后重试。"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func erpConnectionNotice(value string) string {
|
|
|
|
|
switch value {
|
|
|
|
|
case "captcha-ready":
|
|
|
|
|
return "验证码已获取,请人工读取并提交。"
|
|
|
|
|
case "login-succeeded":
|
|
|
|
|
return "ERP 会话已建立,可以返回货运导入。"
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 23:26:34 +08:00
|
|
|
func (h *Handler) ListFreight(ctx *gin.Context) {
|
|
|
|
|
service := h.service.(FreightService)
|
|
|
|
|
orders, err := service.ListFreightOrders(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
defaultListLimit,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
token, err := csrfToken(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "freight", freightPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "ERP 货运",
|
|
|
|
|
FreightCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
Orders: orders,
|
2026-07-29 12:11:56 +08:00
|
|
|
Notice: freightNotice(ctx.Query("notice")),
|
2026-07-28 23:26:34 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) ImportFreight(ctx *gin.Context) {
|
|
|
|
|
service := h.service.(FreightService)
|
|
|
|
|
token, err := csrfToken(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
key, err := newToken()
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
page := freightImportPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "导入 ERP 货运",
|
|
|
|
|
FreightCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
IdempotencyKey: key,
|
2026-07-29 00:26:05 +08:00
|
|
|
Mode: "ORDER_NUMBER",
|
|
|
|
|
}
|
|
|
|
|
if location, locationErr := time.LoadLocation("Asia/Shanghai"); locationErr == nil {
|
|
|
|
|
today := time.Now().In(location).Format(time.DateOnly)
|
|
|
|
|
page.CreatedFrom = today
|
|
|
|
|
page.CreatedTo = today
|
|
|
|
|
}
|
|
|
|
|
if watermark, watermarkErr := service.GetFreightWatermark(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
); watermarkErr == nil {
|
|
|
|
|
page.Watermark = watermark
|
2026-07-28 23:26:34 +08:00
|
|
|
}
|
|
|
|
|
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
|
|
|
|
|
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
|
|
|
|
|
if getErr == nil {
|
|
|
|
|
page.Sync = &run
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "freight-import", page)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回导入页面后重新提交。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
|
2026-07-29 00:26:05 +08:00
|
|
|
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
|
|
|
|
if mode == "" {
|
|
|
|
|
mode = "ORDER_NUMBER"
|
|
|
|
|
}
|
|
|
|
|
createdFrom := strings.TrimSpace(ctx.PostForm("created_from"))
|
|
|
|
|
createdTo := strings.TrimSpace(ctx.PostForm("created_to"))
|
|
|
|
|
syncToNow := ctx.PostForm("sync_to_now") == "true"
|
|
|
|
|
if syncToNow {
|
|
|
|
|
createdFrom = ""
|
|
|
|
|
createdTo = ""
|
|
|
|
|
}
|
2026-07-28 23:26:34 +08:00
|
|
|
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
|
2026-07-29 00:26:05 +08:00
|
|
|
validInput := validToken(key)
|
|
|
|
|
if mode == "ORDER_NUMBER" {
|
|
|
|
|
validInput = validInput && orderNumber != "" &&
|
|
|
|
|
len([]byte(orderNumber)) <= 128
|
|
|
|
|
} else if mode == "CREATED_RANGE" {
|
|
|
|
|
validInput = validInput &&
|
|
|
|
|
(syncToNow || (createdFrom != "" && createdTo != ""))
|
|
|
|
|
} else {
|
|
|
|
|
validInput = false
|
|
|
|
|
}
|
|
|
|
|
if !validInput {
|
2026-07-28 23:26:34 +08:00
|
|
|
token, _ := csrfToken(ctx)
|
|
|
|
|
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "导入 ERP 货运",
|
|
|
|
|
FreightCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
OrderNumber: orderNumber,
|
2026-07-29 00:26:05 +08:00
|
|
|
Mode: mode,
|
|
|
|
|
CreatedFrom: createdFrom,
|
|
|
|
|
CreatedTo: createdTo,
|
2026-07-28 23:26:34 +08:00
|
|
|
IdempotencyKey: key,
|
2026-07-29 00:26:05 +08:00
|
|
|
Error: "请检查同步方式和查询条件后重试。",
|
2026-07-28 23:26:34 +08:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(FreightService)
|
|
|
|
|
run, err := service.CreateFreightSync(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
CreateFreightSyncInput{
|
|
|
|
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
|
|
|
|
IdempotencyKey: key,
|
2026-07-29 00:26:05 +08:00
|
|
|
Mode: mode,
|
2026-07-28 23:26:34 +08:00
|
|
|
OrderNumber: orderNumber,
|
2026-07-29 00:26:05 +08:00
|
|
|
CreatedFrom: createdFrom,
|
|
|
|
|
CreatedTo: createdTo,
|
|
|
|
|
SyncToNow: syncToNow,
|
2026-07-28 23:26:34 +08:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
token, _ := csrfToken(ctx)
|
2026-07-29 10:58:28 +08:00
|
|
|
code, title, message := freightImportError(err)
|
2026-07-29 11:08:23 +08:00
|
|
|
status := serviceErrorStatus(err)
|
|
|
|
|
h.logFreightImportFailure(code, status)
|
|
|
|
|
h.render(ctx, status, "freight-import", freightImportPage{
|
2026-07-28 23:26:34 +08:00
|
|
|
Page: pageView{
|
|
|
|
|
Title: "导入 ERP 货运",
|
|
|
|
|
FreightCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
OrderNumber: orderNumber,
|
2026-07-29 00:26:05 +08:00
|
|
|
Mode: mode,
|
|
|
|
|
CreatedFrom: createdFrom,
|
|
|
|
|
CreatedTo: createdTo,
|
2026-07-28 23:26:34 +08:00
|
|
|
IdempotencyKey: key,
|
2026-07-29 10:48:06 +08:00
|
|
|
Error: message,
|
|
|
|
|
ErrorCode: code,
|
2026-07-29 10:58:28 +08:00
|
|
|
ErrorTitle: title,
|
2026-07-28 23:26:34 +08:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-29 12:11:56 +08:00
|
|
|
if mode == domain.FreightSyncOrderNumber && run.Status == "SUCCEEDED" {
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/freight?notice=import-succeeded")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-28 23:26:34 +08:00
|
|
|
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 11:08:23 +08:00
|
|
|
func (h *Handler) logFreightImportFailure(code string, status int) {
|
|
|
|
|
if code == "" {
|
|
|
|
|
h.logEvent("freight_import_failed status=" + strconv.Itoa(status))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.logEvent(
|
2026-07-29 12:11:56 +08:00
|
|
|
"freight_import_failed code=" + code +
|
2026-07-29 11:08:23 +08:00
|
|
|
" status=" + strconv.Itoa(status),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 10:58:28 +08:00
|
|
|
func freightImportError(err error) (string, string, string) {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrOCRServiceInvalid):
|
|
|
|
|
return "OCR_SERVICE_INVALID", "OCR 服务无效", "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。"
|
|
|
|
|
case errors.Is(err, ErrERPNotConfigured):
|
|
|
|
|
return "ERP_NOT_CONFIGURED", "ERP 凭证未配置", "ERP 账号或密码未配置,请检查 backend-api/.env 后重试。"
|
2026-07-29 12:11:56 +08:00
|
|
|
case errors.Is(err, ErrERPSessionNeeded):
|
|
|
|
|
return "ERP_SESSION_REQUIRED", "ERP 会话已失效", "ERP 会话已失效,请使用相同提交标识重试。"
|
|
|
|
|
case errors.Is(err, ErrERPFreightNotFound):
|
|
|
|
|
return "ERP_FREIGHT_NOT_FOUND", "未找到货运单", "ERP 中没有找到该完整单号,请检查后重试。"
|
2026-07-29 10:58:28 +08:00
|
|
|
case errors.Is(err, ErrERPLoginRejected):
|
|
|
|
|
return "ERP_LOGIN_REJECTED", "ERP 登录被拒绝", "请检查 ERP 账号密码及 OCR 识别结果后重试。"
|
|
|
|
|
case errors.Is(err, ErrERPProtocol):
|
|
|
|
|
return "ERP_RESPONSE_INVALID", "ERP 响应无效", "ERP 返回格式无法确认,请稍后重试。"
|
|
|
|
|
case errors.Is(err, ErrERPUnavailable):
|
|
|
|
|
return "ERP_UNAVAILABLE", "ERP 暂时不可用", "ERP 服务暂时不可用,请稍后使用相同提交标识重试。"
|
2026-07-29 12:11:56 +08:00
|
|
|
case errors.Is(err, ErrFreightSyncBusy):
|
|
|
|
|
return "FREIGHT_SYNC_BUSY", "ERP 正在同步", "已有完整单号正在同步,请等待完成后重试。"
|
|
|
|
|
case errors.Is(err, ErrFreightSyncTimeout):
|
|
|
|
|
return "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时", "完整单号同步超过 55 秒,请使用相同提交标识重试。"
|
2026-07-29 10:58:28 +08:00
|
|
|
default:
|
|
|
|
|
return "", "", "同步任务创建失败,请稍后使用相同提交标识重试。"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 23:26:34 +08:00
|
|
|
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
|
|
|
|
service := h.service.(FreightService)
|
|
|
|
|
detail, err := service.GetFreightOrder(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
strings.TrimSpace(ctx.Param("id")),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
token, _ := csrfToken(ctx)
|
2026-07-28 23:51:59 +08:00
|
|
|
for index := range detail.Items {
|
|
|
|
|
if detail.Items[index].Request == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
uploadKey, keyErr := newToken()
|
|
|
|
|
if keyErr != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
taskKey, keyErr := newToken()
|
|
|
|
|
if keyErr != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
detail.Items[index].Request.UploadKey = uploadKey
|
|
|
|
|
detail.Items[index].Request.TaskKey = taskKey
|
|
|
|
|
}
|
2026-07-28 23:26:34 +08:00
|
|
|
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "货运详情",
|
|
|
|
|
FreightCurrent: true,
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
},
|
|
|
|
|
Detail: detail,
|
2026-07-28 23:51:59 +08:00
|
|
|
Notice: freightNotice(ctx.Query("notice")),
|
2026-07-28 23:26:34 +08:00
|
|
|
})
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 23:51:59 +08:00
|
|
|
func (h *Handler) CreateFreightProcurementRequest(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
|
|
|
|
if pathEscape(orderID) == "invalid" ||
|
|
|
|
|
ctx.PostForm("confirm_procurement_needed") != "1" {
|
|
|
|
|
h.renderError(ctx, http.StatusUnprocessableEntity, "必须人工确认", "请核对来源商品后确认仍需采购。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(ProcurementService)
|
|
|
|
|
_, err := service.CreateProcurementRequest(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
CreateProcurementRequestInput{
|
|
|
|
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
|
|
|
|
FreightOrderItemID: strings.TrimSpace(ctx.Param("id")),
|
|
|
|
|
ConfirmProcurementNeeded: true,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=request-conflict",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.renderServiceError(ctx, err, "采购需求创建失败,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=request-created",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) BindFreightProcurementReference(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(
|
|
|
|
|
ctx.Writer,
|
|
|
|
|
ctx.Request.Body,
|
|
|
|
|
maxRequestBytes,
|
|
|
|
|
)
|
|
|
|
|
if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil ||
|
|
|
|
|
!validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ctx.Request.MultipartForm != nil {
|
|
|
|
|
defer ctx.Request.MultipartForm.RemoveAll()
|
|
|
|
|
}
|
|
|
|
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
|
|
|
|
uploadKey := strings.TrimSpace(ctx.PostForm("upload_key"))
|
|
|
|
|
if pathEscape(orderID) == "invalid" || !validToken(uploadKey) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
asset, err := h.uploadReference(ctx, uploadKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=image-invalid",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(ProcurementService)
|
|
|
|
|
_, err = service.BindProcurementReference(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
BindProcurementReferenceInput{
|
|
|
|
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
|
|
|
|
RequestID: strings.TrimSpace(ctx.Param("id")),
|
|
|
|
|
ImageAssetID: asset.ID,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=reference-conflict",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=reference-bound",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) CreateFreightProcurementTask(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
|
|
|
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
orderID := strings.TrimSpace(ctx.PostForm("order_id"))
|
|
|
|
|
taskKey := strings.TrimSpace(ctx.PostForm("task_key"))
|
|
|
|
|
if pathEscape(orderID) == "invalid" || !validToken(taskKey) {
|
|
|
|
|
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回货运详情后重新操作。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
service := h.service.(ProcurementService)
|
|
|
|
|
task, err := service.CreateProcurementTask(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
CreateProcurementTaskInput{
|
|
|
|
|
ActorUserID: actorUserID(ctx.Request.Context()),
|
|
|
|
|
RequestID: strings.TrimSpace(ctx.Param("id")),
|
|
|
|
|
IdempotencyKey: taskKey,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/freight/"+pathEscape(orderID)+"?notice=task-conflict",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func freightNotice(value string) string {
|
|
|
|
|
switch value {
|
|
|
|
|
case "request-created":
|
|
|
|
|
return "采购需求已创建,请补充参考图。"
|
|
|
|
|
case "request-conflict":
|
|
|
|
|
return "来源已变化或当前商品不能创建采购需求。"
|
|
|
|
|
case "image-invalid":
|
|
|
|
|
return "参考图片无效,请选择 JPG、PNG 或 WebP 后重试。"
|
|
|
|
|
case "reference-conflict":
|
|
|
|
|
return "参考图已被使用或来源已变化,请刷新后重试。"
|
|
|
|
|
case "reference-bound":
|
|
|
|
|
return "参考图已绑定,可以生成采购任务。"
|
|
|
|
|
case "task-conflict":
|
|
|
|
|
return "需求状态或来源已变化,当前不能生成任务。"
|
2026-07-29 12:11:56 +08:00
|
|
|
case "import-succeeded":
|
|
|
|
|
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
2026-07-28 23:51:59 +08:00
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:03:32 +08:00
|
|
|
func SecurityHeaders() gin.HandlerFunc {
|
|
|
|
|
return func(ctx *gin.Context) {
|
|
|
|
|
ctx.Header(
|
|
|
|
|
"Content-Security-Policy",
|
|
|
|
|
"default-src 'none'; base-uri 'none'; connect-src 'self'; "+
|
|
|
|
|
"form-action 'self'; frame-ancestors 'none'; img-src 'self' blob: data:; "+
|
|
|
|
|
"script-src 'self'; style-src 'self'",
|
|
|
|
|
)
|
|
|
|
|
ctx.Header("Cache-Control", "no-store")
|
|
|
|
|
ctx.Header("Referrer-Policy", "no-referrer")
|
|
|
|
|
ctx.Header("X-Content-Type-Options", "nosniff")
|
|
|
|
|
ctx.Header("X-Frame-Options", "DENY")
|
|
|
|
|
ctx.Next()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) Stylesheet(ctx *gin.Context) {
|
|
|
|
|
h.serveStatic(ctx, "admin.css", cssContentType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) Script(ctx *gin.Context) {
|
|
|
|
|
h.serveStatic(ctx, "admin.js", javascriptContentType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) serveStatic(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
name string,
|
|
|
|
|
contentType string,
|
|
|
|
|
) {
|
|
|
|
|
content, err := staticFile(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.Status(http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Data(http.StatusOK, contentType, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) ListTasks(ctx *gin.Context) {
|
2026-07-26 15:18:48 +08:00
|
|
|
token, err := csrfToken(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
input := ListTasksInput{
|
|
|
|
|
Query: strings.TrimSpace(ctx.Query("q")),
|
|
|
|
|
Status: strings.TrimSpace(ctx.Query("status")),
|
|
|
|
|
Cursor: strings.TrimSpace(ctx.Query("cursor")),
|
|
|
|
|
Limit: defaultListLimit,
|
|
|
|
|
}
|
|
|
|
|
result, err := h.service.ListTasks(ctx.Request.Context(), input)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderServiceError(ctx, err, "无法加载任务列表,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items := make([]taskSummaryView, 0, len(result.Items))
|
|
|
|
|
for _, item := range result.Items {
|
|
|
|
|
items = append(items, taskSummaryView{
|
|
|
|
|
ID: item.ID,
|
|
|
|
|
Title: item.Title,
|
|
|
|
|
SKU: item.SKU,
|
|
|
|
|
Status: item.Status,
|
|
|
|
|
StatusLabel: statusLabel(item.Status),
|
|
|
|
|
StatusClass: statusClass(item.Status),
|
|
|
|
|
DeviceName: fallback(item.DeviceName, "尚未分配"),
|
|
|
|
|
UpdatedAt: item.UpdatedAt,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
statusOptions := newStatusOptions(input.Status)
|
|
|
|
|
page := tasksPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "采购任务",
|
|
|
|
|
TasksCurrent: true,
|
2026-07-26 15:18:48 +08:00
|
|
|
CSRFToken: token,
|
2026-07-26 14:03:32 +08:00
|
|
|
},
|
|
|
|
|
Query: input.Query,
|
|
|
|
|
Status: input.Status,
|
|
|
|
|
StatusOptions: statusOptions,
|
|
|
|
|
Items: items,
|
|
|
|
|
NextCursor: result.NextCursor,
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "tasks", page)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) NewTask(ctx *gin.Context) {
|
2026-07-26 15:18:48 +08:00
|
|
|
token, err := csrfToken(ctx)
|
2026-07-26 14:03:32 +08:00
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
page, err := newTaskPage(token)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "task-new", page)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) CreateTask(ctx *gin.Context) {
|
|
|
|
|
ctx.Request.Body = http.MaxBytesReader(
|
|
|
|
|
ctx.Writer,
|
|
|
|
|
ctx.Request.Body,
|
|
|
|
|
maxRequestBytes,
|
|
|
|
|
)
|
|
|
|
|
if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil {
|
|
|
|
|
page := h.createPageFromRequest(ctx)
|
|
|
|
|
page.Notice = "提交内容过大或格式不正确,请检查参考图片。"
|
|
|
|
|
page.Errors.Image = "请选择符合大小限制的 JPG、PNG 或 WebP 图片。"
|
|
|
|
|
h.render(ctx, http.StatusRequestEntityTooLarge, "task-new", page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ctx.Request.MultipartForm != nil {
|
|
|
|
|
defer ctx.Request.MultipartForm.RemoveAll()
|
|
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
if !validCSRF(ctx) {
|
2026-07-26 14:03:32 +08:00
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusForbidden,
|
|
|
|
|
"请求已失效",
|
|
|
|
|
"请返回新建任务页面后重新提交。",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page := h.createPageFromRequest(ctx)
|
|
|
|
|
if validateCreateForm(&page) {
|
|
|
|
|
h.render(ctx, http.StatusUnprocessableEntity, "task-new", page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asset := UploadedAsset{
|
|
|
|
|
ID: strings.TrimSpace(ctx.PostForm("image_asset_id")),
|
|
|
|
|
}
|
|
|
|
|
if asset.ID == "" {
|
|
|
|
|
uploaded, uploadErr := h.uploadReference(ctx, page.UploadKey)
|
|
|
|
|
if uploadErr != nil {
|
|
|
|
|
page.Notice = "参考图片未通过校验,请重新选择。"
|
|
|
|
|
page.Errors.Image = "图片格式、大小或内容不符合要求。"
|
|
|
|
|
status := http.StatusUnprocessableEntity
|
|
|
|
|
if !errors.Is(uploadErr, ErrInvalidFile) &&
|
|
|
|
|
!errors.Is(uploadErr, ErrValidation) {
|
|
|
|
|
page.Notice = "参考图片上传失败,请稍后重试。"
|
|
|
|
|
status = http.StatusServiceUnavailable
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, status, "task-new", page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
asset = uploaded
|
|
|
|
|
page.UploadedAsset = uploadedAssetView{
|
|
|
|
|
ID: uploaded.ID,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
task, err := h.service.CreateTask(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
CreateTaskInput{
|
|
|
|
|
IdempotencyKey: page.CreateKey,
|
|
|
|
|
Title: page.Form.Title,
|
|
|
|
|
SKU: page.Form.SKU,
|
|
|
|
|
Description: page.Form.Description,
|
|
|
|
|
Quantity: page.Form.QuantityValue,
|
|
|
|
|
MaxBudget: page.Form.MaxBudget,
|
|
|
|
|
ImageAssetID: asset.ID,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
page.UploadedAsset = uploadedAssetView{
|
|
|
|
|
ID: asset.ID,
|
|
|
|
|
}
|
|
|
|
|
page.Notice = createErrorMessage(err)
|
|
|
|
|
status := serviceErrorStatus(err)
|
|
|
|
|
h.render(ctx, status, "task-new", page)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|
|
|
|
task, err := h.service.GetTask(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
strings.TrimSpace(ctx.Param("id")),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
token, tokenErr := csrfToken(ctx)
|
2026-07-26 14:03:32 +08:00
|
|
|
if tokenErr != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cancelKey, keyErr := newToken()
|
|
|
|
|
if keyErr != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-28 12:50:42 +08:00
|
|
|
authorizationKey, keyErr := newToken()
|
|
|
|
|
if keyErr != nil {
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
page := taskDetailPage{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "任务详情",
|
|
|
|
|
TasksCurrent: true,
|
2026-07-26 15:18:48 +08:00
|
|
|
CSRFToken: token,
|
2026-07-26 14:03:32 +08:00
|
|
|
},
|
2026-07-28 12:50:42 +08:00
|
|
|
Task: taskDetailViewFrom(task),
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
CancelKey: cancelKey,
|
|
|
|
|
AuthorizationKey: authorizationKey,
|
|
|
|
|
Notice: detailNotice(ctx.Query("notice")),
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "task-detail", page)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 12:50:42 +08:00
|
|
|
func (h *Handler) AuthorizeOrder(ctx *gin.Context) {
|
|
|
|
|
if !validCSRF(ctx) {
|
|
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusForbidden,
|
|
|
|
|
"请求已失效",
|
|
|
|
|
"请返回任务详情后重新操作。",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
taskID := strings.TrimSpace(ctx.Param("id"))
|
|
|
|
|
authorizationKey := strings.TrimSpace(
|
|
|
|
|
ctx.PostForm("authorization_key"),
|
|
|
|
|
)
|
|
|
|
|
expectedVersion, versionErr := strconv.ParseInt(
|
|
|
|
|
strings.TrimSpace(ctx.PostForm("expected_task_version")),
|
|
|
|
|
10,
|
|
|
|
|
64,
|
|
|
|
|
)
|
|
|
|
|
if !validToken(authorizationKey) || versionErr != nil ||
|
|
|
|
|
expectedVersion < 1 {
|
|
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusForbidden,
|
|
|
|
|
"请求已失效",
|
|
|
|
|
"请返回任务详情后重新操作。",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, err := h.service.AuthorizeOrder(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
AuthorizeOrderInput{
|
|
|
|
|
TaskID: taskID,
|
|
|
|
|
IdempotencyKey: authorizationKey,
|
|
|
|
|
ExpectedTaskVersion: expectedVersion,
|
|
|
|
|
CandidateKey: strings.TrimSpace(ctx.PostForm("candidate_key")),
|
|
|
|
|
SelectedReasonCode: strings.TrimSpace(ctx.PostForm("selected_reason_code")),
|
|
|
|
|
RejectedReasonCode: strings.TrimSpace(ctx.PostForm("rejected_reason_code")),
|
|
|
|
|
Note: strings.TrimSpace(ctx.PostForm("authorization_note")),
|
|
|
|
|
SupersedesAuthorizationID: strings.TrimSpace(ctx.PostForm("supersedes_authorization_id")),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/tasks/"+pathEscape(taskID)+"?notice=authorization-conflict",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.renderServiceError(ctx, err, "授权失败,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/tasks/"+pathEscape(taskID)+"?notice=authorization-created",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:03:32 +08:00
|
|
|
func (h *Handler) CancelTask(ctx *gin.Context) {
|
2026-07-26 15:18:48 +08:00
|
|
|
if !validCSRF(ctx) {
|
2026-07-26 14:03:32 +08:00
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusForbidden,
|
|
|
|
|
"请求已失效",
|
|
|
|
|
"请返回任务详情后重新操作。",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
taskID := strings.TrimSpace(ctx.Param("id"))
|
|
|
|
|
cancelKey := strings.TrimSpace(ctx.PostForm("cancel_key"))
|
|
|
|
|
if !validToken(cancelKey) {
|
|
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusForbidden,
|
|
|
|
|
"请求已失效",
|
|
|
|
|
"请返回任务详情后重新操作。",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
task, err := h.service.CancelTask(
|
2026-07-26 14:03:32 +08:00
|
|
|
ctx.Request.Context(),
|
2026-07-26 16:16:36 +08:00
|
|
|
CancelTaskInput{
|
2026-07-26 14:03:32 +08:00
|
|
|
TaskID: taskID,
|
|
|
|
|
IdempotencyKey: cancelKey,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrConflict) {
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/tasks/"+pathEscape(taskID)+"?notice=cancel-conflict",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.renderServiceError(ctx, err, "取消失败,请稍后重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
notice := "cancel-requested"
|
|
|
|
|
if task.Status == "CANCELED" {
|
|
|
|
|
notice = "canceled"
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
2026-07-26 16:16:36 +08:00
|
|
|
"/tasks/"+pathEscape(taskID)+"?notice="+notice,
|
2026-07-26 14:03:32 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) uploadReference(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
idempotencyKey string,
|
|
|
|
|
) (UploadedAsset, error) {
|
|
|
|
|
file, header, err := ctx.Request.FormFile("image")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return UploadedAsset{}, ErrInvalidFile
|
|
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
return h.service.UploadReference(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
UploadReferenceInput{
|
|
|
|
|
IdempotencyKey: idempotencyKey,
|
|
|
|
|
DeclaredType: header.Header.Get("Content-Type"),
|
|
|
|
|
DeclaredSize: header.Size,
|
|
|
|
|
Content: file,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView {
|
2026-07-26 15:18:48 +08:00
|
|
|
token, err := csrfToken(ctx)
|
2026-07-26 14:03:32 +08:00
|
|
|
if err != nil {
|
|
|
|
|
token = ""
|
|
|
|
|
}
|
|
|
|
|
quantityText := strings.TrimSpace(ctx.PostForm("quantity"))
|
|
|
|
|
quantity, _ := strconv.ParseInt(quantityText, 10, 64)
|
|
|
|
|
return newTaskPageView{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "新建采购任务",
|
|
|
|
|
NewCurrent: true,
|
2026-07-26 15:18:48 +08:00
|
|
|
CSRFToken: token,
|
2026-07-26 14:03:32 +08:00
|
|
|
},
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")),
|
|
|
|
|
CreateKey: strings.TrimSpace(ctx.PostForm("create_key")),
|
|
|
|
|
Form: createFormView{
|
|
|
|
|
Title: strings.TrimSpace(ctx.PostForm("title")),
|
|
|
|
|
SKU: strings.TrimSpace(ctx.PostForm("sku")),
|
|
|
|
|
Description: strings.TrimSpace(ctx.PostForm("description")),
|
|
|
|
|
Quantity: quantityText,
|
|
|
|
|
QuantityValue: quantity,
|
|
|
|
|
MaxBudget: strings.TrimSpace(ctx.PostForm("max_budget")),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateCreateForm(page *newTaskPageView) bool {
|
|
|
|
|
if !validToken(page.UploadKey) {
|
|
|
|
|
page.Errors.Form = "提交标识缺失,请刷新页面后重试。"
|
|
|
|
|
}
|
|
|
|
|
if !validToken(page.CreateKey) {
|
|
|
|
|
page.Errors.Form = "提交标识缺失,请刷新页面后重试。"
|
|
|
|
|
}
|
|
|
|
|
if page.CSRFToken == "" {
|
|
|
|
|
page.Errors.Form = "页面已失效,请刷新后重试。"
|
|
|
|
|
}
|
|
|
|
|
if page.Form.Title == "" {
|
|
|
|
|
page.Errors.Title = "请输入商品标题。"
|
|
|
|
|
} else if !utf8.ValidString(page.Form.Title) ||
|
|
|
|
|
utf8.RuneCountInString(page.Form.Title) > maxTitleRunes ||
|
|
|
|
|
len([]byte(page.Form.Title)) > maxTitleBytes {
|
|
|
|
|
page.Errors.Title = "商品标题不能超过 120 个字符。"
|
|
|
|
|
}
|
|
|
|
|
if page.Form.SKU == "" {
|
|
|
|
|
page.Errors.SKU = "请输入 SKU。"
|
|
|
|
|
} else if !utf8.ValidString(page.Form.SKU) ||
|
|
|
|
|
len([]byte(page.Form.SKU)) > maxSKUBytes {
|
|
|
|
|
page.Errors.SKU = "SKU 不能超过 512 个 UTF-8 字节。"
|
|
|
|
|
}
|
|
|
|
|
if !utf8.ValidString(page.Form.Description) ||
|
|
|
|
|
len([]byte(page.Form.Description)) > maxDescriptionBytes {
|
|
|
|
|
page.Errors.Description = "商品描述不能超过 8192 个 UTF-8 字节。"
|
|
|
|
|
}
|
|
|
|
|
if page.Form.Quantity == "" || page.Form.QuantityValue <= 0 {
|
|
|
|
|
page.Errors.Quantity = "数量必须是大于 0 的整数。"
|
|
|
|
|
} else if strconv.FormatInt(page.Form.QuantityValue, 10) != page.Form.Quantity {
|
|
|
|
|
page.Errors.Quantity = "数量必须是大于 0 的整数。"
|
|
|
|
|
}
|
|
|
|
|
if !validBudget(page.Form.MaxBudget) {
|
|
|
|
|
page.Errors.MaxBudget = "最高总预算必须大于 0,且最多两位小数。"
|
|
|
|
|
}
|
|
|
|
|
if page.UploadedAsset.ID == "" {
|
|
|
|
|
// The file itself is validated by the asset service. This only gives
|
|
|
|
|
// immediate feedback for a completely missing multipart field.
|
|
|
|
|
}
|
|
|
|
|
if page.Errors.any() {
|
|
|
|
|
page.Notice = "请检查表单中的错误后再创建。"
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validBudget(value string) bool {
|
|
|
|
|
if value == "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if len(value) > 20 || strings.HasPrefix(value, "+") ||
|
|
|
|
|
strings.HasPrefix(value, "-") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(value, ".")
|
|
|
|
|
if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
for _, character := range part {
|
|
|
|
|
if character < '0' || character > '9' {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
units, err := strconv.ParseUint(parts[0], 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
fraction := uint64(0)
|
|
|
|
|
if len(parts) == 2 {
|
|
|
|
|
fraction, err = strconv.ParseUint(parts[1], 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if len(parts[1]) == 1 {
|
|
|
|
|
fraction *= 10
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return units > 0 || fraction > 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 15:18:48 +08:00
|
|
|
func csrfToken(ctx *gin.Context) (string, error) {
|
|
|
|
|
cookies := csrfCookies(ctx.Request)
|
|
|
|
|
for index := len(cookies) - 1; index >= 0; index-- {
|
|
|
|
|
if validToken(cookies[index].Value) {
|
|
|
|
|
return cookies[index].Value, nil
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
return rotateCSRFToken(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func rotateCSRFToken(ctx *gin.Context) (string, error) {
|
2026-07-26 14:03:32 +08:00
|
|
|
token, err := newToken()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
http.SetCookie(ctx.Writer, &http.Cookie{
|
2026-07-26 15:18:48 +08:00
|
|
|
Name: authcommon.CSRFCookieName,
|
|
|
|
|
Value: "",
|
2026-07-26 14:03:32 +08:00
|
|
|
Path: "/tasks",
|
2026-07-26 15:18:48 +08:00
|
|
|
Expires: time.Unix(1, 0).UTC(),
|
|
|
|
|
MaxAge: -1,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: ctx.Request.TLS != nil,
|
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
|
})
|
|
|
|
|
http.SetCookie(ctx.Writer, &http.Cookie{
|
|
|
|
|
Name: authcommon.CSRFCookieName,
|
|
|
|
|
Value: token,
|
|
|
|
|
Path: "/",
|
2026-07-26 14:03:32 +08:00
|
|
|
MaxAge: 3600,
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
Secure: ctx.Request.TLS != nil,
|
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
|
})
|
|
|
|
|
return token, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 15:18:48 +08:00
|
|
|
func validCSRF(ctx *gin.Context) bool {
|
|
|
|
|
presented := ctx.PostForm(authcommon.CSRFFormField)
|
|
|
|
|
for _, cookie := range csrfCookies(ctx.Request) {
|
|
|
|
|
if authcommon.ValidCSRFPair(cookie.Value, presented) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func csrfCookies(request *http.Request) []*http.Cookie {
|
|
|
|
|
result := make([]*http.Cookie, 0, 2)
|
|
|
|
|
for _, cookie := range request.Cookies() {
|
|
|
|
|
if cookie.Name == authcommon.CSRFCookieName {
|
|
|
|
|
result = append(result, cookie)
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
return result
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validToken(value string) bool {
|
2026-07-26 15:18:48 +08:00
|
|
|
return authcommon.ValidOpaqueValue(value)
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newToken() (string, error) {
|
|
|
|
|
value := make([]byte, 32)
|
|
|
|
|
if _, err := io.ReadFull(rand.Reader, value); err != nil {
|
|
|
|
|
return "", errors.New("generate form token")
|
|
|
|
|
}
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newTaskPage(token string) (newTaskPageView, error) {
|
|
|
|
|
uploadKey, err := newToken()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return newTaskPageView{}, err
|
|
|
|
|
}
|
|
|
|
|
createKey, err := newToken()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return newTaskPageView{}, err
|
|
|
|
|
}
|
|
|
|
|
return newTaskPageView{
|
|
|
|
|
Page: pageView{
|
|
|
|
|
Title: "新建采购任务",
|
|
|
|
|
NewCurrent: true,
|
2026-07-26 15:18:48 +08:00
|
|
|
CSRFToken: token,
|
2026-07-26 14:03:32 +08:00
|
|
|
},
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
UploadKey: uploadKey,
|
|
|
|
|
CreateKey: createKey,
|
|
|
|
|
Form: createFormView{
|
|
|
|
|
Quantity: "1",
|
|
|
|
|
QuantityValue: 1,
|
|
|
|
|
},
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) render(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
status int,
|
|
|
|
|
name string,
|
|
|
|
|
data any,
|
|
|
|
|
) {
|
|
|
|
|
var output bytes.Buffer
|
|
|
|
|
if err := h.renderer.Execute(&output, name, data); err != nil {
|
|
|
|
|
ctx.Data(
|
|
|
|
|
http.StatusInternalServerError,
|
|
|
|
|
formContentType,
|
|
|
|
|
[]byte("页面暂时无法显示,请稍后重试。"),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.Data(status, formContentType, output.Bytes())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) renderServiceError(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
err error,
|
|
|
|
|
fallbackMessage string,
|
|
|
|
|
) {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrNotFound), errors.Is(err, ErrForbidden):
|
|
|
|
|
h.renderError(
|
|
|
|
|
ctx,
|
|
|
|
|
http.StatusNotFound,
|
|
|
|
|
"任务不存在",
|
|
|
|
|
"该任务不存在或当前不可访问。",
|
|
|
|
|
)
|
|
|
|
|
case errors.Is(err, ErrValidation):
|
|
|
|
|
h.renderError(ctx, http.StatusBadRequest, "请求条件不正确", "请检查输入后重试。")
|
|
|
|
|
case errors.Is(err, ErrConflict):
|
|
|
|
|
h.renderError(ctx, http.StatusConflict, "任务状态已变化", "请返回任务列表刷新状态。")
|
|
|
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
|
|
|
h.renderError(ctx, http.StatusServiceUnavailable, "请求超时", "请稍后重试。")
|
|
|
|
|
case errors.Is(err, ErrUnavailable):
|
|
|
|
|
h.renderError(ctx, http.StatusServiceUnavailable, "服务暂时不可用", "请稍后重试。")
|
|
|
|
|
default:
|
|
|
|
|
h.renderError(ctx, http.StatusInternalServerError, "操作失败", fallbackMessage)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) renderError(
|
|
|
|
|
ctx *gin.Context,
|
|
|
|
|
status int,
|
|
|
|
|
title string,
|
|
|
|
|
message string,
|
|
|
|
|
) {
|
2026-07-26 15:18:48 +08:00
|
|
|
token, _ := csrfToken(ctx)
|
2026-07-26 14:03:32 +08:00
|
|
|
h.render(ctx, status, "error", errorPage{
|
|
|
|
|
Page: pageView{
|
2026-07-26 15:18:48 +08:00
|
|
|
Title: title,
|
|
|
|
|
CSRFToken: token,
|
2026-07-26 14:03:32 +08:00
|
|
|
},
|
|
|
|
|
Heading: title,
|
|
|
|
|
Message: message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serviceErrorStatus(err error) int {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile):
|
|
|
|
|
return http.StatusUnprocessableEntity
|
2026-07-29 10:48:06 +08:00
|
|
|
case errors.Is(err, ErrOCRServiceInvalid):
|
|
|
|
|
return http.StatusServiceUnavailable
|
2026-07-29 10:58:28 +08:00
|
|
|
case errors.Is(err, ErrERPNotConfigured), errors.Is(err, ErrERPLoginRejected):
|
|
|
|
|
return http.StatusUnprocessableEntity
|
2026-07-29 12:11:56 +08:00
|
|
|
case errors.Is(err, ErrERPFreightNotFound):
|
|
|
|
|
return http.StatusNotFound
|
2026-07-29 10:58:28 +08:00
|
|
|
case errors.Is(err, ErrERPProtocol):
|
|
|
|
|
return http.StatusBadGateway
|
2026-07-29 12:11:56 +08:00
|
|
|
case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPUnavailable):
|
2026-07-29 10:58:28 +08:00
|
|
|
return http.StatusServiceUnavailable
|
2026-07-29 12:11:56 +08:00
|
|
|
case errors.Is(err, ErrFreightSyncBusy):
|
|
|
|
|
return http.StatusConflict
|
|
|
|
|
case errors.Is(err, ErrFreightSyncTimeout):
|
|
|
|
|
return http.StatusGatewayTimeout
|
2026-07-26 14:03:32 +08:00
|
|
|
case errors.Is(err, ErrConflict):
|
|
|
|
|
return http.StatusConflict
|
|
|
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
|
|
|
return http.StatusServiceUnavailable
|
|
|
|
|
case errors.Is(err, ErrUnavailable):
|
|
|
|
|
return http.StatusServiceUnavailable
|
|
|
|
|
default:
|
|
|
|
|
return http.StatusInternalServerError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createErrorMessage(err error) string {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrValidation):
|
|
|
|
|
return "任务内容未通过校验,请检查后重试。已上传的参考图片会被复用。"
|
|
|
|
|
case errors.Is(err, ErrConflict):
|
|
|
|
|
return "提交标识与原请求不一致,请刷新页面后重试。已上传的参考图片会被保留。"
|
|
|
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
|
|
|
return "创建结果暂时无法确认。请保留当前页面并使用相同提交标识重试。"
|
|
|
|
|
case errors.Is(err, ErrUnavailable):
|
|
|
|
|
return "服务暂时不可用。已上传的参考图片会被保留,请稍后重试。"
|
|
|
|
|
default:
|
|
|
|
|
return "任务创建失败,请稍后重试。已上传的参考图片会被复用。"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func detailNotice(value string) string {
|
|
|
|
|
switch value {
|
|
|
|
|
case "canceled":
|
|
|
|
|
return "任务已取消,不会自动恢复。"
|
2026-07-26 16:16:36 +08:00
|
|
|
case "cancel-requested":
|
|
|
|
|
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
|
2026-07-26 14:03:32 +08:00
|
|
|
case "cancel-conflict":
|
|
|
|
|
return "任务状态已变化,当前不能取消。"
|
2026-07-28 12:50:42 +08:00
|
|
|
case "authorization-created":
|
|
|
|
|
return "候选已确认,待投递下单授权已创建。"
|
|
|
|
|
case "authorization-conflict":
|
|
|
|
|
return "候选或任务状态已变化,请检查最新证据后重新授权。"
|
2026-07-26 14:03:32 +08:00
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fallback(value string, fallbackValue string) string {
|
|
|
|
|
if strings.TrimSpace(value) == "" {
|
|
|
|
|
return fallbackValue
|
|
|
|
|
}
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pageView struct {
|
2026-07-28 23:26:34 +08:00
|
|
|
Title string
|
|
|
|
|
TasksCurrent bool
|
|
|
|
|
NewCurrent bool
|
|
|
|
|
FreightCurrent bool
|
2026-07-29 09:51:41 +08:00
|
|
|
ERPCurrent bool
|
2026-07-28 23:26:34 +08:00
|
|
|
CSRFToken string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type freightPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Orders []FreightOrder
|
2026-07-29 12:11:56 +08:00
|
|
|
Notice string
|
2026-07-28 23:26:34 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type freightImportPage struct {
|
|
|
|
|
Page pageView
|
2026-07-29 00:26:05 +08:00
|
|
|
Mode string
|
2026-07-28 23:26:34 +08:00
|
|
|
OrderNumber string
|
2026-07-29 00:26:05 +08:00
|
|
|
CreatedFrom string
|
|
|
|
|
CreatedTo string
|
2026-07-28 23:26:34 +08:00
|
|
|
IdempotencyKey string
|
|
|
|
|
Error string
|
2026-07-29 10:48:06 +08:00
|
|
|
ErrorCode string
|
2026-07-29 10:58:28 +08:00
|
|
|
ErrorTitle string
|
2026-07-28 23:26:34 +08:00
|
|
|
Sync *FreightSync
|
2026-07-29 00:26:05 +08:00
|
|
|
Watermark *FreightWatermark
|
2026-07-28 23:26:34 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type freightDetailPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Detail FreightOrderDetail
|
2026-07-28 23:51:59 +08:00
|
|
|
Notice string
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:51:41 +08:00
|
|
|
type erpConnectionPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Status ERPConnectionStatus
|
|
|
|
|
Error string
|
|
|
|
|
Notice string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 14:03:32 +08:00
|
|
|
type statusOption struct {
|
|
|
|
|
Value string
|
|
|
|
|
Label string
|
|
|
|
|
Selected bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type tasksPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Query string
|
|
|
|
|
Status string
|
|
|
|
|
StatusOptions []statusOption
|
|
|
|
|
Items []taskSummaryView
|
|
|
|
|
NextCursor string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type taskSummaryView struct {
|
|
|
|
|
ID string
|
|
|
|
|
Title string
|
|
|
|
|
SKU string
|
|
|
|
|
Status string
|
|
|
|
|
StatusLabel string
|
|
|
|
|
StatusClass string
|
|
|
|
|
DeviceName string
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type createFormView struct {
|
|
|
|
|
Title string
|
|
|
|
|
SKU string
|
|
|
|
|
Description string
|
|
|
|
|
Quantity string
|
|
|
|
|
QuantityValue int64
|
|
|
|
|
MaxBudget string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type createFormErrors struct {
|
|
|
|
|
Form string
|
|
|
|
|
Title string
|
|
|
|
|
SKU string
|
|
|
|
|
Description string
|
|
|
|
|
Quantity string
|
|
|
|
|
MaxBudget string
|
|
|
|
|
Image string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (errors createFormErrors) any() bool {
|
|
|
|
|
return errors.Form != "" ||
|
|
|
|
|
errors.Title != "" ||
|
|
|
|
|
errors.SKU != "" ||
|
|
|
|
|
errors.Description != "" ||
|
|
|
|
|
errors.Quantity != "" ||
|
|
|
|
|
errors.MaxBudget != "" ||
|
|
|
|
|
errors.Image != ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type uploadedAssetView struct {
|
|
|
|
|
ID string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type newTaskPageView struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
CSRFToken string
|
|
|
|
|
UploadKey string
|
|
|
|
|
CreateKey string
|
|
|
|
|
Form createFormView
|
|
|
|
|
Errors createFormErrors
|
|
|
|
|
Notice string
|
|
|
|
|
UploadedAsset uploadedAssetView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type taskDetailView struct {
|
2026-07-28 12:50:42 +08:00
|
|
|
ID string
|
|
|
|
|
Title string
|
|
|
|
|
SKU string
|
|
|
|
|
Description string
|
|
|
|
|
Quantity int64
|
|
|
|
|
MaxBudget string
|
|
|
|
|
Status string
|
|
|
|
|
Version int64
|
|
|
|
|
StatusLabel string
|
|
|
|
|
StatusClass string
|
|
|
|
|
ReferenceAssetID string
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
CanCancel bool
|
|
|
|
|
CancelRequiresAck bool
|
|
|
|
|
ExecutionReport *ExecutionReport
|
|
|
|
|
Candidates []AuthorizationCandidate
|
|
|
|
|
OrderAuthorizations []OrderAuthorization
|
2026-07-28 18:16:09 +08:00
|
|
|
OrderSubmissions []OrderSubmission
|
|
|
|
|
HasReconciledOrder bool
|
2026-07-28 12:50:42 +08:00
|
|
|
CanAuthorizeOrder bool
|
|
|
|
|
ActiveAuthorizationID string
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type taskDetailPage struct {
|
2026-07-28 12:50:42 +08:00
|
|
|
Page pageView
|
|
|
|
|
Task taskDetailView
|
|
|
|
|
CSRFToken string
|
|
|
|
|
CancelKey string
|
|
|
|
|
AuthorizationKey string
|
|
|
|
|
Notice string
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type errorPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Heading string
|
|
|
|
|
Message string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func taskDetailViewFrom(task Task) taskDetailView {
|
2026-07-28 12:50:42 +08:00
|
|
|
activeAuthorizationID := ""
|
|
|
|
|
canAuthorizeOrder := task.Status == "WAITING_CONFIRMATION" &&
|
|
|
|
|
len(task.Candidates) > 0
|
|
|
|
|
for _, authorization := range task.OrderAuthorizations {
|
|
|
|
|
switch authorization.Status {
|
|
|
|
|
case "PENDING_DELIVERY":
|
|
|
|
|
activeAuthorizationID = authorization.ID
|
|
|
|
|
case "DELIVERED", "ACKNOWLEDGED", "EXECUTING":
|
|
|
|
|
canAuthorizeOrder = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-28 18:16:09 +08:00
|
|
|
hasReconciledOrder := false
|
|
|
|
|
for _, submission := range task.OrderSubmissions {
|
|
|
|
|
if submission.Status == "RECONCILED" {
|
|
|
|
|
hasReconciledOrder = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
return taskDetailView{
|
|
|
|
|
ID: task.ID,
|
|
|
|
|
Title: task.Title,
|
|
|
|
|
SKU: task.SKU,
|
|
|
|
|
Description: task.Description,
|
|
|
|
|
Quantity: task.Quantity,
|
|
|
|
|
MaxBudget: task.MaxBudget,
|
|
|
|
|
Status: task.Status,
|
2026-07-28 12:50:42 +08:00
|
|
|
Version: task.Version,
|
2026-07-26 14:03:32 +08:00
|
|
|
StatusLabel: statusLabel(task.Status),
|
|
|
|
|
StatusClass: statusClass(task.Status),
|
|
|
|
|
ReferenceAssetID: task.ReferenceAssetID,
|
|
|
|
|
CreatedAt: task.CreatedAt,
|
|
|
|
|
UpdatedAt: task.UpdatedAt,
|
2026-07-26 16:16:36 +08:00
|
|
|
CanCancel: canCancelTaskStatus(task.Status),
|
|
|
|
|
CancelRequiresAck: task.Status == "RUNNING" ||
|
|
|
|
|
task.Status == "WAITING_CONFIRMATION",
|
2026-07-28 12:50:42 +08:00
|
|
|
ExecutionReport: task.ExecutionReport,
|
|
|
|
|
Candidates: task.Candidates,
|
|
|
|
|
OrderAuthorizations: task.OrderAuthorizations,
|
2026-07-28 18:16:09 +08:00
|
|
|
OrderSubmissions: task.OrderSubmissions,
|
|
|
|
|
HasReconciledOrder: hasReconciledOrder,
|
2026-07-28 12:50:42 +08:00
|
|
|
CanAuthorizeOrder: canAuthorizeOrder,
|
|
|
|
|
ActiveAuthorizationID: activeAuthorizationID,
|
2026-07-26 16:16:36 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func canCancelTaskStatus(status string) bool {
|
|
|
|
|
switch status {
|
|
|
|
|
case "PENDING", "CLAIMED", "RUNNING", "WAITING_CONFIRMATION":
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newStatusOptions(selected string) []statusOption {
|
|
|
|
|
values := []statusOption{
|
|
|
|
|
{Label: "全部状态"},
|
|
|
|
|
{Value: "PENDING", Label: "待领取"},
|
|
|
|
|
{Value: "CLAIMED", Label: "已领取"},
|
|
|
|
|
{Value: "RUNNING", Label: "执行中"},
|
|
|
|
|
{Value: "WAITING_CONFIRMATION", Label: "等待人工确认"},
|
|
|
|
|
{Value: "SUCCEEDED", Label: "验证完成"},
|
|
|
|
|
{Value: "FAILED", Label: "失败"},
|
|
|
|
|
{Value: "CANCELED", Label: "已取消"},
|
|
|
|
|
}
|
|
|
|
|
for index := range values {
|
|
|
|
|
values[index].Selected = values[index].Value == selected
|
|
|
|
|
}
|
|
|
|
|
return values
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func statusLabel(status string) string {
|
|
|
|
|
labels := map[string]string{
|
|
|
|
|
"PENDING": "待领取",
|
|
|
|
|
"CLAIMED": "已领取",
|
|
|
|
|
"RUNNING": "执行中",
|
|
|
|
|
"WAITING_CONFIRMATION": "等待人工确认",
|
|
|
|
|
"SUCCEEDED": "验证完成",
|
|
|
|
|
"FAILED": "失败",
|
|
|
|
|
"CANCELED": "已取消",
|
|
|
|
|
}
|
|
|
|
|
return fallback(labels[status], "未知状态")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func statusClass(status string) string {
|
|
|
|
|
switch status {
|
|
|
|
|
case "PENDING", "CLAIMED", "WAITING_CONFIRMATION":
|
|
|
|
|
return "status-warn"
|
|
|
|
|
case "RUNNING":
|
|
|
|
|
return "status-info"
|
|
|
|
|
case "SUCCEEDED":
|
|
|
|
|
return "status-success"
|
|
|
|
|
case "FAILED", "CANCELED":
|
|
|
|
|
return "status-danger"
|
|
|
|
|
default:
|
|
|
|
|
return "status-neutral"
|
|
|
|
|
}
|
|
|
|
|
}
|