package webui import ( "bytes" "context" "crypto/rand" "encoding/base64" "errors" "io" "net/http" "strconv" "strings" "time" "unicode/utf8" "cmroubao/backend-api/internal/transport/authcommon" "github.com/gin-gonic/gin" ) const ( defaultListLimit = 20 maxRequestBytes = 21 << 20 maxTitleRunes = 120 maxTitleBytes = 2048 maxSKUBytes = 512 maxDescriptionBytes = 8192 csrfCookieName = authcommon.CSRFCookieName csrfFormField = authcommon.CSRFFormField 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 } func NewHandler(service Service, renderer *Renderer) (*Handler, error) { if service == nil { return nil, errors.New("admin web service is required") } if renderer == nil { return nil, errors.New("admin web renderer is required") } return &Handler{ service: service, renderer: renderer, }, nil } func (h *Handler) Register(routes gin.IRoutes) { h.RegisterStatic(routes) h.RegisterProtected(routes) } func (h *Handler) RegisterStatic(routes gin.IRoutes) { routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet) routes.GET("/static/admin.js", SecurityHeaders(), h.Script) } func (h *Handler) RegisterProtected(routes gin.IRoutes) { 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) routes.POST( "/tasks/:id/order-authorizations", SecurityHeaders(), h.AuthorizeOrder, ) 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) } 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, ) } } 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 "" } } 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, }) } 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, 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 } 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")) 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 = "" } key := strings.TrimSpace(ctx.PostForm("idempotency_key")) 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 { token, _ := csrfToken(ctx) h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{ Page: pageView{ Title: "导入 ERP 货运", FreightCurrent: true, CSRFToken: token, }, OrderNumber: orderNumber, Mode: mode, CreatedFrom: createdFrom, CreatedTo: createdTo, IdempotencyKey: key, Error: "请检查同步方式和查询条件后重试。", }) return } service := h.service.(FreightService) run, err := service.CreateFreightSync( ctx.Request.Context(), CreateFreightSyncInput{ ActorUserID: actorUserID(ctx.Request.Context()), IdempotencyKey: key, Mode: mode, OrderNumber: orderNumber, CreatedFrom: createdFrom, CreatedTo: createdTo, SyncToNow: syncToNow, }, ) if err != nil { token, _ := csrfToken(ctx) message := "同步任务创建失败,请稍后使用相同提交标识重试。" code := "" if errors.Is(err, ErrOCRServiceInvalid) { message = "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。" code = "OCR_SERVICE_INVALID" } h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{ Page: pageView{ Title: "导入 ERP 货运", FreightCurrent: true, CSRFToken: token, }, OrderNumber: orderNumber, Mode: mode, CreatedFrom: createdFrom, CreatedTo: createdTo, IdempotencyKey: key, Error: message, ErrorCode: code, }) return } ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID)) } 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) 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 } h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{ Page: pageView{ Title: "货运详情", FreightCurrent: true, CSRFToken: token, }, Detail: detail, Notice: freightNotice(ctx.Query("notice")), }) } 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 "需求状态或来源已变化,当前不能生成任务。" default: return "" } } 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) { token, err := csrfToken(ctx) if err != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return } 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, CSRFToken: token, }, 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) { token, err := csrfToken(ctx) 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() } if !validCSRF(ctx) { 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 } token, tokenErr := csrfToken(ctx) if tokenErr != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return } cancelKey, keyErr := newToken() if keyErr != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return } authorizationKey, keyErr := newToken() if keyErr != nil { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return } page := taskDetailPage{ Page: pageView{ Title: "任务详情", TasksCurrent: true, CSRFToken: token, }, Task: taskDetailViewFrom(task), CSRFToken: token, CancelKey: cancelKey, AuthorizationKey: authorizationKey, Notice: detailNotice(ctx.Query("notice")), } h.render(ctx, http.StatusOK, "task-detail", page) } 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", ) } func (h *Handler) CancelTask(ctx *gin.Context) { if !validCSRF(ctx) { 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 } task, err := h.service.CancelTask( ctx.Request.Context(), CancelTaskInput{ 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 } notice := "cancel-requested" if task.Status == "CANCELED" { notice = "canceled" } ctx.Redirect( http.StatusSeeOther, "/tasks/"+pathEscape(taskID)+"?notice="+notice, ) } 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 { token, err := csrfToken(ctx) if err != nil { token = "" } quantityText := strings.TrimSpace(ctx.PostForm("quantity")) quantity, _ := strconv.ParseInt(quantityText, 10, 64) return newTaskPageView{ Page: pageView{ Title: "新建采购任务", NewCurrent: true, CSRFToken: token, }, 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 } 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 } } return rotateCSRFToken(ctx) } func rotateCSRFToken(ctx *gin.Context) (string, error) { token, err := newToken() if err != nil { return "", err } http.SetCookie(ctx.Writer, &http.Cookie{ Name: authcommon.CSRFCookieName, Value: "", Path: "/tasks", 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: "/", MaxAge: 3600, HttpOnly: true, Secure: ctx.Request.TLS != nil, SameSite: http.SameSiteStrictMode, }) return token, nil } 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 } } 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) } } return result } func validToken(value string) bool { return authcommon.ValidOpaqueValue(value) } 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, CSRFToken: token, }, 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, ) { token, _ := csrfToken(ctx) h.render(ctx, status, "error", errorPage{ Page: pageView{ Title: title, CSRFToken: token, }, Heading: title, Message: message, }) } func serviceErrorStatus(err error) int { switch { case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile): return http.StatusUnprocessableEntity case errors.Is(err, ErrOCRServiceInvalid): return http.StatusServiceUnavailable 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 "任务已取消,不会自动恢复。" case "cancel-requested": return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。" case "cancel-conflict": return "任务状态已变化,当前不能取消。" case "authorization-created": return "候选已确认,待投递下单授权已创建。" case "authorization-conflict": return "候选或任务状态已变化,请检查最新证据后重新授权。" default: return "" } } func fallback(value string, fallbackValue string) string { if strings.TrimSpace(value) == "" { return fallbackValue } return value } type pageView struct { Title string TasksCurrent bool NewCurrent bool FreightCurrent bool ERPCurrent bool CSRFToken string } type freightPage struct { Page pageView Orders []FreightOrder } type freightImportPage struct { Page pageView Mode string OrderNumber string CreatedFrom string CreatedTo string IdempotencyKey string Error string ErrorCode string Sync *FreightSync Watermark *FreightWatermark } type freightDetailPage struct { Page pageView Detail FreightOrderDetail Notice string } type erpConnectionPage struct { Page pageView Status ERPConnectionStatus Error string Notice string } 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 { 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 OrderSubmissions []OrderSubmission HasReconciledOrder bool CanAuthorizeOrder bool ActiveAuthorizationID string } type taskDetailPage struct { Page pageView Task taskDetailView CSRFToken string CancelKey string AuthorizationKey string Notice string } type errorPage struct { Page pageView Heading string Message string } func taskDetailViewFrom(task Task) taskDetailView { 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 } } hasReconciledOrder := false for _, submission := range task.OrderSubmissions { if submission.Status == "RECONCILED" { hasReconciledOrder = true break } } return taskDetailView{ ID: task.ID, Title: task.Title, SKU: task.SKU, Description: task.Description, Quantity: task.Quantity, MaxBudget: task.MaxBudget, Status: task.Status, Version: task.Version, StatusLabel: statusLabel(task.Status), StatusClass: statusClass(task.Status), ReferenceAssetID: task.ReferenceAssetID, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, CanCancel: canCancelTaskStatus(task.Status), CancelRequiresAck: task.Status == "RUNNING" || task.Status == "WAITING_CONFIRMATION", ExecutionReport: task.ExecutionReport, Candidates: task.Candidates, OrderAuthorizations: task.OrderAuthorizations, OrderSubmissions: task.OrderSubmissions, HasReconciledOrder: hasReconciledOrder, CanAuthorizeOrder: canAuthorizeOrder, ActiveAuthorizationID: activeAuthorizationID, } } func canCancelTaskStatus(status string) bool { switch status { case "PENDING", "CLAIMED", "RUNNING", "WAITING_CONFIRMATION": return true default: return false } } 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" } }