feat(t215): add admin order authorization
This commit is contained in:
@@ -66,6 +66,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
@@ -264,20 +269,85 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
||||
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,
|
||||
Notice: detailNotice(ctx.Query("notice")),
|
||||
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(
|
||||
@@ -652,6 +722,10 @@ func detailNotice(value string) string {
|
||||
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
|
||||
case "cancel-conflict":
|
||||
return "任务状态已变化,当前不能取消。"
|
||||
case "authorization-created":
|
||||
return "候选已确认,待投递下单授权已创建。"
|
||||
case "authorization-conflict":
|
||||
return "候选或任务状态已变化,请检查最新证据后重新授权。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -742,29 +816,35 @@ type newTaskPageView struct {
|
||||
}
|
||||
|
||||
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
|
||||
CancelRequiresAck bool
|
||||
ExecutionReport *ExecutionReport
|
||||
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
|
||||
CanAuthorizeOrder bool
|
||||
ActiveAuthorizationID string
|
||||
}
|
||||
|
||||
type taskDetailPage struct {
|
||||
Page pageView
|
||||
Task taskDetailView
|
||||
CSRFToken string
|
||||
CancelKey string
|
||||
Notice string
|
||||
Page pageView
|
||||
Task taskDetailView
|
||||
CSRFToken string
|
||||
CancelKey string
|
||||
AuthorizationKey string
|
||||
Notice string
|
||||
}
|
||||
|
||||
type errorPage struct {
|
||||
@@ -774,6 +854,17 @@ type errorPage struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return taskDetailView{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
@@ -782,6 +873,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
||||
Quantity: task.Quantity,
|
||||
MaxBudget: task.MaxBudget,
|
||||
Status: task.Status,
|
||||
Version: task.Version,
|
||||
StatusLabel: statusLabel(task.Status),
|
||||
StatusClass: statusClass(task.Status),
|
||||
ReferenceAssetID: task.ReferenceAssetID,
|
||||
@@ -790,7 +882,11 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
||||
CanCancel: canCancelTaskStatus(task.Status),
|
||||
CancelRequiresAck: task.Status == "RUNNING" ||
|
||||
task.Status == "WAITING_CONFIRMATION",
|
||||
ExecutionReport: task.ExecutionReport,
|
||||
ExecutionReport: task.ExecutionReport,
|
||||
Candidates: task.Candidates,
|
||||
OrderAuthorizations: task.OrderAuthorizations,
|
||||
CanAuthorizeOrder: canAuthorizeOrder,
|
||||
ActiveAuthorizationID: activeAuthorizationID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -509,6 +509,124 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) {
|
||||
firstKey := strings.Repeat("a", 64)
|
||||
secondKey := strings.Repeat("b", 64)
|
||||
service := &fakeService{
|
||||
getResult: Task{
|
||||
ID: testTaskID,
|
||||
Title: "候选确认任务",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: 2,
|
||||
Status: "WAITING_CONFIRMATION",
|
||||
Version: 7,
|
||||
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
TaskContentSHA256: strings.Repeat("c", 64),
|
||||
Candidates: []AuthorizationCandidate{
|
||||
{
|
||||
CandidateKey: firstKey,
|
||||
Ordinal: 1,
|
||||
Title: "候选一",
|
||||
SKUText: "BLACK-L",
|
||||
PriceText: "20.00",
|
||||
EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"},
|
||||
},
|
||||
{
|
||||
CandidateKey: secondKey,
|
||||
Ordinal: 2,
|
||||
Title: "候选二",
|
||||
SKUText: "BLACK-XL",
|
||||
PriceText: "22.00",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
detail := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK {
|
||||
t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"候选确认与下单授权",
|
||||
"系统只创建待付款订单",
|
||||
"候选一",
|
||||
"BLACK-XL",
|
||||
`name="candidate_key"`,
|
||||
} {
|
||||
if !strings.Contains(detail.Body.String(), expected) {
|
||||
t.Fatalf("authorization detail missing %q", expected)
|
||||
}
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
authorizationKey := hiddenValue(
|
||||
t,
|
||||
detail.Body.String(),
|
||||
"authorization_key",
|
||||
)
|
||||
form := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"authorization_key": {authorizationKey},
|
||||
"expected_task_version": {"7"},
|
||||
"candidate_key": {firstKey},
|
||||
"selected_reason_code": {"SKU_MATCH"},
|
||||
"rejected_reason_code": {"NOT_BEST_MATCH"},
|
||||
"authorization_note": {"已核对图片、规格与价格"},
|
||||
"supersedes_authorization_id": {""},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tasks/"+testTaskID+"/order-authorizations",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/tasks/"+testTaskID+"?notice=authorization-created" {
|
||||
t.Fatalf(
|
||||
"status/location = %d/%q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.authorizeInput.TaskID != testTaskID ||
|
||||
service.authorizeInput.IdempotencyKey != authorizationKey ||
|
||||
service.authorizeInput.ExpectedTaskVersion != 7 ||
|
||||
service.authorizeInput.CandidateKey != firstKey ||
|
||||
service.authorizeInput.SelectedReasonCode != "SKU_MATCH" ||
|
||||
service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" {
|
||||
t.Fatalf("authorize input = %+v", service.authorizeInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) {
|
||||
view := taskDetailViewFrom(Task{
|
||||
Status: "WAITING_CONFIRMATION",
|
||||
Candidates: []AuthorizationCandidate{{
|
||||
CandidateKey: strings.Repeat("a", 64),
|
||||
}},
|
||||
OrderAuthorizations: []OrderAuthorization{{
|
||||
ID: "00000000-0000-4000-8000-000000000010",
|
||||
Status: "DELIVERED",
|
||||
}},
|
||||
})
|
||||
if view.CanAuthorizeOrder {
|
||||
t.Fatal("delivered authorization remains editable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
@@ -614,23 +732,26 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
listErr error
|
||||
getResult Task
|
||||
getErr error
|
||||
uploadResult UploadedAsset
|
||||
uploadErr error
|
||||
uploadInput UploadReferenceInput
|
||||
uploadBody []byte
|
||||
uploadCalls int
|
||||
createResult Task
|
||||
createErr error
|
||||
createInput CreateTaskInput
|
||||
createCalls int
|
||||
cancelResult Task
|
||||
cancelErr error
|
||||
cancelInput CancelTaskInput
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
listErr error
|
||||
getResult Task
|
||||
getErr error
|
||||
uploadResult UploadedAsset
|
||||
uploadErr error
|
||||
uploadInput UploadReferenceInput
|
||||
uploadBody []byte
|
||||
uploadCalls int
|
||||
createResult Task
|
||||
createErr error
|
||||
createInput CreateTaskInput
|
||||
createCalls int
|
||||
cancelResult Task
|
||||
cancelErr error
|
||||
cancelInput CancelTaskInput
|
||||
authorizeResult OrderAuthorization
|
||||
authorizeErr error
|
||||
authorizeInput AuthorizeOrderInput
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
@@ -679,6 +800,14 @@ func (service *fakeService) CancelTask(
|
||||
return service.cancelResult, service.cancelErr
|
||||
}
|
||||
|
||||
func (service *fakeService) AuthorizeOrder(
|
||||
_ context.Context,
|
||||
input AuthorizeOrderInput,
|
||||
) (OrderAuthorization, error) {
|
||||
service.authorizeInput = input
|
||||
return service.authorizeResult, service.authorizeErr
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T, service Service) http.Handler {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -101,6 +101,157 @@ textarea {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.authorization-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading-row h2 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.candidate-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authorization-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.candidate-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.candidate-fieldset legend {
|
||||
margin-bottom: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authorization-candidates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.authorization-candidate {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.authorization-candidate:has(input:checked) {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px rgba(11, 107, 80, 0.14);
|
||||
}
|
||||
|
||||
.candidate-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--brand-dark);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.candidate-choice input,
|
||||
.authorization-confirm input {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.candidate-key {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.candidate-evidence {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.candidate-evidence img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.authorization-reasons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.authorization-reasons label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authorization-note {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.authorization-confirm {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border-left: 4px solid var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.authorization-form > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.authorization-unavailable {
|
||||
padding: 12px;
|
||||
border-left: 4px solid var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.authorization-history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.authorization-history li {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.authorization-history span,
|
||||
.authorization-history time {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
@@ -901,10 +1052,15 @@ tbody tr:last-child td {
|
||||
|
||||
.upload-layout,
|
||||
.detail-layout,
|
||||
.requirement-layout {
|
||||
.requirement-layout,
|
||||
.authorization-reasons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.authorization-note {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -978,6 +1134,19 @@ tbody tr:last-child td {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authorization-candidates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.authorization-form > .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.dialog-actions {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
@@ -73,6 +73,108 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{if .Task.Candidates}}
|
||||
<section class="content-section authorization-section" aria-labelledby="authorization-heading">
|
||||
<div class="section-heading-row">
|
||||
<div>
|
||||
<h2 id="authorization-heading">候选确认与下单授权</h2>
|
||||
<p class="section-note">选择只会授权设备创建一笔待付款订单,不授权付款。</p>
|
||||
</div>
|
||||
<span class="candidate-count">{{len .Task.Candidates}} 个候选</span>
|
||||
</div>
|
||||
|
||||
{{if .Task.CanAuthorizeOrder}}
|
||||
<form class="authorization-form" method="post"
|
||||
action="/tasks/{{pathPart .Task.ID}}/order-authorizations">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="authorization_key" value="{{.AuthorizationKey}}">
|
||||
<input type="hidden" name="expected_task_version" value="{{.Task.Version}}">
|
||||
<input type="hidden" name="supersedes_authorization_id"
|
||||
value="{{.Task.ActiveAuthorizationID}}">
|
||||
|
||||
<fieldset class="candidate-fieldset">
|
||||
<legend>选择要采购的商品</legend>
|
||||
<div class="authorization-candidates">
|
||||
{{range .Task.Candidates}}
|
||||
<label class="authorization-candidate">
|
||||
<span class="candidate-choice">
|
||||
<input type="radio" name="candidate_key" value="{{.CandidateKey}}" required>
|
||||
<span>候选 {{.Ordinal}}</span>
|
||||
</span>
|
||||
<strong>{{.Title}}</strong>
|
||||
<span>规格:{{if .SKUText}}{{.SKUText}}{{else}}未读取{{end}}</span>
|
||||
<span>组合价格:{{if .PriceText}}{{.PriceText}}{{else}}未读取{{end}}</span>
|
||||
<code class="candidate-key">{{.CandidateKey}}</code>
|
||||
{{if .EvidenceURLs}}
|
||||
<span class="candidate-evidence">
|
||||
{{range .EvidenceURLs}}<img src="{{.}}" alt="候选受控证据截图">{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="authorization-reasons">
|
||||
<label>
|
||||
<span>所选候选理由</span>
|
||||
<select name="selected_reason_code" required>
|
||||
<option value="">请选择</option>
|
||||
<option value="SKU_MATCH">SKU 匹配</option>
|
||||
<option value="IMAGE_MATCH">图片匹配</option>
|
||||
{{if .Task.MaxBudget}}<option value="PRICE_ACCEPTABLE">价格可接受</option>{{end}}
|
||||
<option value="EVIDENCE_SUFFICIENT">证据充分</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>其余候选拒绝理由</span>
|
||||
<select name="rejected_reason_code" required>
|
||||
<option value="">请选择</option>
|
||||
<option value="NOT_BEST_MATCH">不是最佳匹配</option>
|
||||
<option value="SKU_MISMATCH">SKU 不匹配</option>
|
||||
<option value="IMAGE_MISMATCH">图片不匹配</option>
|
||||
{{if .Task.MaxBudget}}<option value="PRICE_TOO_HIGH">价格过高</option>{{end}}
|
||||
<option value="OUT_OF_STOCK">无库存</option>
|
||||
<option value="EVIDENCE_INSUFFICIENT">证据不足</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="authorization-note">
|
||||
<span>授权备注(可选)</span>
|
||||
<textarea name="authorization_note" rows="3" maxlength="200"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="authorization-confirm">
|
||||
<input type="checkbox" required>
|
||||
<span>我确认商品、SKU、数量和证据;系统只创建待付款订单,付款由人员在拼多多完成。</span>
|
||||
</label>
|
||||
<button class="button primary" type="submit">
|
||||
{{if .Task.ActiveAuthorizationID}}改选并创建新授权{{else}}确认商品并授权下单{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="authorization-unavailable" role="status">
|
||||
当前候选不能创建或修改授权,请查看下方授权状态或刷新任务。
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Task.OrderAuthorizations}}
|
||||
<h3>授权历史</h3>
|
||||
<ol class="authorization-history">
|
||||
{{range .Task.OrderAuthorizations}}
|
||||
<li>
|
||||
<strong>版本 {{.Version}} · {{.Status}}</strong>
|
||||
<span>候选 {{.CandidateKey}}</span>
|
||||
<span>规格 {{if .CandidateSKUText}}{{.CandidateSKUText}}{{else}}未读取{{end}} ·
|
||||
数量 {{.Quantity}} · 价格 {{if .CandidatePriceText}}{{.CandidatePriceText}}{{else}}未读取{{end}}</span>
|
||||
<time datetime="{{machineTime .CreatedAt}}">{{displayTime .CreatedAt}}</time>
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{with .Task.ExecutionReport}}
|
||||
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
|
||||
<h2 id="execution-audit-heading">执行审计</h2>
|
||||
|
||||
@@ -24,6 +24,7 @@ type Service interface {
|
||||
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
||||
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
||||
CancelTask(context.Context, CancelTaskInput) (Task, error)
|
||||
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
@@ -48,17 +49,43 @@ type TaskSummary struct {
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExecutionReport *ExecutionReport
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
Version int64
|
||||
ExecutionID string
|
||||
TaskContentSHA256 string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExecutionReport *ExecutionReport
|
||||
Candidates []AuthorizationCandidate
|
||||
OrderAuthorizations []OrderAuthorization
|
||||
}
|
||||
|
||||
type AuthorizationCandidate struct {
|
||||
CandidateKey string
|
||||
Ordinal int
|
||||
Title string
|
||||
SKUText string
|
||||
PriceText string
|
||||
EvidenceURLs []string
|
||||
}
|
||||
|
||||
type OrderAuthorization struct {
|
||||
ID string
|
||||
Version int
|
||||
CandidateKey string
|
||||
CandidateSKUText string
|
||||
CandidatePriceText string
|
||||
Quantity int
|
||||
Status string
|
||||
SupersedesID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
@@ -130,3 +157,14 @@ type CancelTaskInput struct {
|
||||
TaskID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AuthorizeOrderInput struct {
|
||||
TaskID string
|
||||
IdempotencyKey string
|
||||
ExpectedTaskVersion int64
|
||||
CandidateKey string
|
||||
SelectedReasonCode string
|
||||
RejectedReasonCode string
|
||||
Note string
|
||||
SupersedesAuthorizationID string
|
||||
}
|
||||
|
||||
@@ -14,20 +14,23 @@ import (
|
||||
const localAdminSubject = "local-admin"
|
||||
|
||||
type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
authorizations *usecase.OrderAuthorizationService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
authorizations *usecase.OrderAuthorizationService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil {
|
||||
if tasks == nil || assets == nil || authorizations == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
authorizations: authorizations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -146,6 +149,70 @@ func (adapter *UsecaseAdapter) CancelTask(
|
||||
return taskFromPurchase(task), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) AuthorizeOrder(
|
||||
ctx context.Context,
|
||||
input AuthorizeOrderInput,
|
||||
) (OrderAuthorization, error) {
|
||||
detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID)
|
||||
if err != nil {
|
||||
return OrderAuthorization{}, mapUsecaseError(err)
|
||||
}
|
||||
if detail.Task.Version != input.ExpectedTaskVersion ||
|
||||
detail.Execution == nil ||
|
||||
detail.Report == nil ||
|
||||
detail.Report.DecisionDataset == nil {
|
||||
return OrderAuthorization{}, ErrConflict
|
||||
}
|
||||
var supersedes *string
|
||||
if input.SupersedesAuthorizationID != "" {
|
||||
value := input.SupersedesAuthorizationID
|
||||
supersedes = &value
|
||||
}
|
||||
items := make(
|
||||
[]usecase.OrderAuthorizationItemInput,
|
||||
0,
|
||||
len(detail.Report.DecisionDataset.Observations),
|
||||
)
|
||||
for _, observation := range detail.Report.DecisionDataset.Observations {
|
||||
if observation.Identity == nil {
|
||||
return OrderAuthorization{}, ErrConflict
|
||||
}
|
||||
label := "REJECT"
|
||||
reason := input.RejectedReasonCode
|
||||
if observation.Identity.CandidateKey == input.CandidateKey {
|
||||
label = "ACCEPT"
|
||||
reason = input.SelectedReasonCode
|
||||
}
|
||||
items = append(items, usecase.OrderAuthorizationItemInput{
|
||||
CandidateKey: observation.Identity.CandidateKey,
|
||||
Label: label,
|
||||
PrimaryReasonCode: reason,
|
||||
ReasonCodes: []string{reason},
|
||||
})
|
||||
}
|
||||
result, err := adapter.authorizations.Create(
|
||||
ctx,
|
||||
usecase.CreateOrderAuthorizationCommand{
|
||||
ActorUserID: actorUserID(ctx),
|
||||
TaskID: input.TaskID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
ExecutionID: detail.Execution.ID,
|
||||
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
|
||||
ExpectedTaskVersion: input.ExpectedTaskVersion,
|
||||
CandidateKey: input.CandidateKey,
|
||||
ReasonSchemaVersion: 1,
|
||||
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||
Note: input.Note,
|
||||
SupersedesAuthorizationID: supersedes,
|
||||
Items: items,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return OrderAuthorization{}, mapUsecaseError(err)
|
||||
}
|
||||
return orderAuthorizationFrom(result.Authorization), nil
|
||||
}
|
||||
|
||||
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
|
||||
return TaskSummary{
|
||||
ID: task.ID,
|
||||
@@ -177,11 +244,64 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
|
||||
|
||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||
task := taskFromPurchase(detail.Task)
|
||||
task.Version = detail.Task.Version
|
||||
task.ReferenceAssetID = detail.Asset.ID
|
||||
task.ExecutionReport = executionReportFrom(detail.Report)
|
||||
task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task)
|
||||
if detail.Execution != nil {
|
||||
task.ExecutionID = detail.Execution.ID
|
||||
}
|
||||
if detail.Report != nil && detail.Report.DecisionDataset != nil {
|
||||
for _, observation := range detail.Report.DecisionDataset.Observations {
|
||||
if observation.Identity == nil {
|
||||
continue
|
||||
}
|
||||
candidate := AuthorizationCandidate{
|
||||
CandidateKey: observation.Identity.CandidateKey,
|
||||
Ordinal: observation.Ordinal,
|
||||
Title: observation.Title,
|
||||
SKUText: observation.SKUText,
|
||||
PriceText: observation.PriceText,
|
||||
EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)),
|
||||
}
|
||||
for _, evidenceID := range observation.EvidenceAssetIDs {
|
||||
candidate.EvidenceURLs = append(
|
||||
candidate.EvidenceURLs,
|
||||
"/api/v1/tasks/"+detail.Task.ID+
|
||||
"/evidence/"+evidenceID+"/content",
|
||||
)
|
||||
}
|
||||
task.Candidates = append(task.Candidates, candidate)
|
||||
}
|
||||
}
|
||||
for _, authorization := range detail.OrderAuthorizations {
|
||||
task.OrderAuthorizations = append(
|
||||
task.OrderAuthorizations,
|
||||
orderAuthorizationFrom(authorization),
|
||||
)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func orderAuthorizationFrom(
|
||||
authorization domain.OrderAuthorization,
|
||||
) OrderAuthorization {
|
||||
result := OrderAuthorization{
|
||||
ID: authorization.ID,
|
||||
Version: authorization.AuthorizationVersion,
|
||||
CandidateKey: authorization.CandidateKey,
|
||||
CandidateSKUText: authorization.CandidateSKUText,
|
||||
CandidatePriceText: authorization.CandidatePriceText,
|
||||
Quantity: authorization.Quantity,
|
||||
Status: string(authorization.Status),
|
||||
CreatedAt: authorization.CreatedAt,
|
||||
}
|
||||
if authorization.SupersedesAuthorizationID != nil {
|
||||
result.SupersedesID = *authorization.SupersedesAuthorizationID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
|
||||
if report == nil {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user