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-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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
},
|
|
|
|
|
Task: taskDetailViewFrom(task),
|
|
|
|
|
CSRFToken: token,
|
|
|
|
|
CancelKey: cancelKey,
|
|
|
|
|
Notice: detailNotice(ctx.Query("notice")),
|
|
|
|
|
}
|
|
|
|
|
h.render(ctx, http.StatusOK, "task-detail", page)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
_, err := h.service.CancelPending(
|
|
|
|
|
ctx.Request.Context(),
|
|
|
|
|
CancelPendingInput{
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
ctx.Redirect(
|
|
|
|
|
http.StatusSeeOther,
|
|
|
|
|
"/tasks/"+pathEscape(taskID)+"?notice=canceled",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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-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
|
2026-07-26 15:18:48 +08:00
|
|
|
CSRFToken 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 {
|
|
|
|
|
ID string
|
|
|
|
|
Title string
|
|
|
|
|
SKU string
|
|
|
|
|
Description string
|
|
|
|
|
Quantity int64
|
|
|
|
|
MaxBudget string
|
|
|
|
|
Status string
|
|
|
|
|
StatusLabel string
|
|
|
|
|
StatusClass string
|
|
|
|
|
ReferenceAssetID string
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
CanCancel bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type taskDetailPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Task taskDetailView
|
|
|
|
|
CSRFToken string
|
|
|
|
|
CancelKey string
|
|
|
|
|
Notice string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type errorPage struct {
|
|
|
|
|
Page pageView
|
|
|
|
|
Heading string
|
|
|
|
|
Message string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func taskDetailViewFrom(task Task) taskDetailView {
|
|
|
|
|
return taskDetailView{
|
|
|
|
|
ID: task.ID,
|
|
|
|
|
Title: task.Title,
|
|
|
|
|
SKU: task.SKU,
|
|
|
|
|
Description: task.Description,
|
|
|
|
|
Quantity: task.Quantity,
|
|
|
|
|
MaxBudget: task.MaxBudget,
|
|
|
|
|
Status: task.Status,
|
|
|
|
|
StatusLabel: statusLabel(task.Status),
|
|
|
|
|
StatusClass: statusClass(task.Status),
|
|
|
|
|
ReferenceAssetID: task.ReferenceAssetID,
|
|
|
|
|
CreatedAt: task.CreatedAt,
|
|
|
|
|
UpdatedAt: task.UpdatedAt,
|
|
|
|
|
CanCancel: task.Status == "PENDING",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|