Files
cmroubao/backend-api/internal/transport/webui/handler.go
T

1325 lines
36 KiB
Go

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) 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)
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: "同步任务创建失败,请稍后使用相同提交标识重试。",
})
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, 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
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
Sync *FreightSync
Watermark *FreightWatermark
}
type freightDetailPage struct {
Page pageView
Detail FreightOrderDetail
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"
}
}