feat: 固定创建真实采购任务 (#112)
This commit is contained in:
@@ -66,7 +66,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
"读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||
return
|
||||
}
|
||||
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
|
||||
assignableClients, err := service.ListLivePurchaseClients(h.db, currentUser(c), h.onlineThreshold)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。")
|
||||
return
|
||||
@@ -630,12 +630,10 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
|
||||
}
|
||||
requests = append(requests, service.PurchaseTaskRequest{SybID: sybID, MaxPriceCent: cent})
|
||||
}
|
||||
result, err := service.CreatePurchaseTasksWithOptions(h.db, currentUser(c), requests, service.PurchaseTaskOptions{
|
||||
ClientID: c.PostForm("client_id"),
|
||||
ExecutionMode: model.TaskExecutionMode(c.PostForm("execution_mode")),
|
||||
LiveAcknowledged: c.PostForm("live_acknowledged") == "1",
|
||||
LiveConfirmation: c.PostForm("live_confirmation"),
|
||||
})
|
||||
result, err := service.CreatePurchaseTasksWithOptions(
|
||||
h.db, currentUser(c), requests,
|
||||
purchaseTaskOptionsFromForm(c, time.Now().UTC().Add(-h.onlineThreshold).Format(model.TimeLayout)),
|
||||
)
|
||||
if err != nil {
|
||||
h.sybRedirect(c, "采购任务没有创建:"+err.Error())
|
||||
return
|
||||
@@ -644,6 +642,18 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
|
||||
h.sybRedirect(c, purchaseTaskResultMessage(result))
|
||||
}
|
||||
|
||||
func purchaseTaskOptionsFromForm(c *gin.Context, clientSeenAfter string) service.PurchaseTaskOptions {
|
||||
return service.PurchaseTaskOptions{
|
||||
ClientID: c.PostForm("client_id"),
|
||||
// Admin 新建采购任务固定为 live。不能相信浏览器提交的 execution_mode,
|
||||
// 否则篡改表单就能绕过真实采购确认和审计。
|
||||
ExecutionMode: model.TaskExecutionLive,
|
||||
LiveAcknowledged: c.PostForm("live_acknowledged") == "1",
|
||||
LiveConfirmation: c.PostForm("live_confirmation"),
|
||||
ClientSeenAfter: clientSeenAfter,
|
||||
}
|
||||
}
|
||||
|
||||
func purchaseTaskResultMessage(result service.PurchaseTaskResult) string {
|
||||
parts := []string{fmt.Sprintf("已创建 %d 个采购任务", result.Created)}
|
||||
if len(result.Failures) > 0 {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
func sybPostContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
@@ -147,3 +149,23 @@ func TestSybHistoryPartialPagination_翻页继续使用片段路由(t *testing.T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTaskOptionsFromForm_忽略篡改模式并固定Live(t *testing.T) {
|
||||
context, _ := sybPostContext(t, url.Values{
|
||||
"client_id": {"CLIENT-LIVE"},
|
||||
"execution_mode": {"dry_run"},
|
||||
"live_acknowledged": {"1"},
|
||||
"live_confirmation": {"创建未付款订单"},
|
||||
})
|
||||
|
||||
options := purchaseTaskOptionsFromForm(context, "2026-08-10T09:00:00Z")
|
||||
if options.ExecutionMode != model.TaskExecutionLive {
|
||||
t.Fatalf("篡改 execution_mode 不得创建演练任务,实际 %q", options.ExecutionMode)
|
||||
}
|
||||
if options.ClientID != "CLIENT-LIVE" || !options.LiveAcknowledged || options.LiveConfirmation != "创建未付款订单" {
|
||||
t.Fatalf("真实采购确认字段读取错误: %+v", options)
|
||||
}
|
||||
if options.ClientSeenAfter != "2026-08-10T09:00:00Z" {
|
||||
t.Fatalf("在线截止时间未传给服务层: %+v", options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
`name="date_from"`, `name="date_to"`,
|
||||
`name="stage"`, "处理阶段", "下一步", `data-detail-url=`,
|
||||
"按顺运宝货运单创建日期(UTC+8)同步",
|
||||
"真实下单安全确认", "确认创建真实采购任务",
|
||||
`name="live_acknowledged"`, `name="live_confirmation"`,
|
||||
} {
|
||||
if !strings.Contains(sybResponse.Body.String(), want) {
|
||||
t.Errorf("顺运宝页面缺少 %q", want)
|
||||
@@ -147,6 +149,11 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
if strings.Contains(sybResponse.Body.String(), "指定日期同步") {
|
||||
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
|
||||
}
|
||||
for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单"} {
|
||||
if strings.Contains(sybResponse.Body.String(), removed) {
|
||||
t.Errorf("固定真实采购弹窗不应显示 %q", removed)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = repository.UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: "SYB-PDD-ID-INPUT", OrderNo: "ORDER-PDD-ID-INPUT", Title: "输入测试商品",
|
||||
|
||||
@@ -22,6 +22,18 @@ func ClientPurchaseMode(q Execer, clientID string) (string, error) {
|
||||
return ParseClientPurchaseMode(raw.String), nil
|
||||
}
|
||||
|
||||
// ClientSeenAfter 判断客户端是否在指定 UTC 时间之后上报过活动。
|
||||
func ClientSeenAfter(q Execer, clientID, cutoff string) (bool, error) {
|
||||
var online bool
|
||||
if err := q.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM clients WHERE client_id = ? AND last_seen_at >= ?)`,
|
||||
clientID, cutoff,
|
||||
).Scan(&online); err != nil {
|
||||
return false, fmt.Errorf("检查客户端 %s 在线状态失败: %w", clientID, err)
|
||||
}
|
||||
return online, nil
|
||||
}
|
||||
|
||||
// ParseClientPurchaseMode 把登记能力转成安全的固定值,供列表和创建校验共用。
|
||||
func ParseClientPurchaseMode(raw string) string {
|
||||
var capabilities struct {
|
||||
|
||||
@@ -325,6 +325,36 @@ func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, _ := prepareClientAssignmentUsers(t, db)
|
||||
now := time.Now().UTC()
|
||||
clients := []model.Client{
|
||||
{ClientID: "live-online", Name: "真实在线", LastSeenAt: now.Format(model.TimeLayout), Capabilities: `{"purchase_mode":"live"}`},
|
||||
{ClientID: "dry-online", Name: "演练在线", LastSeenAt: now.Format(model.TimeLayout), Capabilities: `{"purchase_mode":"dry_run"}`},
|
||||
{ClientID: "live-offline", Name: "真实离线", LastSeenAt: now.Add(-time.Hour).Format(model.TimeLayout), Capabilities: `{"purchase_mode":"live"}`},
|
||||
}
|
||||
for _, client := range clients {
|
||||
if err := RegisterClient(db, client, true); err != nil {
|
||||
t.Fatalf("登记客户端失败: %v", err)
|
||||
}
|
||||
if _, _, err := AssignClient(db, admin, client.ClientID, buyerA.UserID, now); err != nil {
|
||||
t.Fatalf("绑定客户端失败: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, now.Add(-time.Hour).Format(model.TimeLayout), "live-offline"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := ListLivePurchaseClients(db, buyerA, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("读取真实采购客户端失败: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ClientID != "live-online" {
|
||||
t.Fatalf("只应返回在线 live 客户端,实际 %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAssignment_转交解绑保留审计且不改任务(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||
|
||||
@@ -170,6 +170,7 @@ type PurchaseTaskOptions struct {
|
||||
ExecutionMode model.TaskExecutionMode
|
||||
LiveAcknowledged bool
|
||||
LiveConfirmation string
|
||||
ClientSeenAfter string
|
||||
}
|
||||
|
||||
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
|
||||
@@ -212,6 +213,15 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
|
||||
if !visible {
|
||||
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围")
|
||||
}
|
||||
if options.ClientSeenAfter != "" {
|
||||
online, err := repository.ClientSeenAfter(tx, clientID, options.ClientSeenAfter)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !online {
|
||||
return result, fmt.Errorf("所选客户端已离线,请刷新页面后选择在线客户端")
|
||||
}
|
||||
}
|
||||
executionMode := options.ExecutionMode
|
||||
if executionMode == "" {
|
||||
executionMode = model.TaskExecutionDryRun
|
||||
|
||||
@@ -426,6 +426,26 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒绝(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
key := seedPurchasableWorkflow(t, db, "SYB-OFFLINE")
|
||||
SaveSybMapping(db, "SYB-OFFLINE", key, "USR-1")
|
||||
admin, _, _ := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "CLIENT-OFFLINE", Capabilities: `{"purchase_mode":"live"}`}, true)
|
||||
if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, "2026-08-10T08:00:00Z", "CLIENT-OFFLINE"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CreatePurchaseTasksWithOptions(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-OFFLINE", MaxPriceCent: 4200}},
|
||||
PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive,
|
||||
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation,
|
||||
ClientSeenAfter: "2026-08-10T09:00:00Z"})
|
||||
if err == nil || !strings.Contains(err.Error(), "已离线") {
|
||||
t.Fatalf("离线客户端必须在创建事务内被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriceYuanToCent_精确转换(t *testing.T) {
|
||||
for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} {
|
||||
got, err := ParsePriceYuanToCent(raw)
|
||||
|
||||
@@ -205,6 +205,24 @@ func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duratio
|
||||
return ListClientViewsForUser(db, actor, "", threshold)
|
||||
}
|
||||
|
||||
// ListLivePurchaseClients 返回当前用户能分配真实采购任务的在线客户端。
|
||||
//
|
||||
// 页面过滤只用于减少误选;创建任务时仍会在事务内重新校验可见范围和 live 能力。
|
||||
func ListLivePurchaseClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
|
||||
clients, err := ListClientViewsForUser(db, actor, "", threshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ClientView, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if client.Status != "在线" || client.PurchaseMode != string(model.TaskExecutionLive) {
|
||||
continue
|
||||
}
|
||||
result = append(result, client)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListActivePurchasers 返回管理员可选择的绑定目标。
|
||||
func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
|
||||
+8
-29
@@ -140,33 +140,9 @@
|
||||
var openButtons = document.querySelectorAll("[data-purchase-open]");
|
||||
var form = document.querySelector("[data-purchase-form]");
|
||||
if (!openButtons.length || !form) return;
|
||||
var modeInputs = form.querySelectorAll("[data-execution-mode]");
|
||||
var livePanel = form.querySelector("[data-live-confirm-panel]");
|
||||
var liveAcknowledged = form.querySelector("[data-live-acknowledged]");
|
||||
var liveConfirmation = form.querySelector("[data-live-confirmation]");
|
||||
var clientSelect = form.querySelector("select[name=client_id]");
|
||||
function updateExecutionMode() {
|
||||
var selected = form.querySelector("[data-execution-mode]:checked");
|
||||
var isLive = selected && selected.value === "live";
|
||||
if (livePanel) livePanel.hidden = !isLive;
|
||||
if (liveAcknowledged) liveAcknowledged.required = isLive;
|
||||
if (liveConfirmation) liveConfirmation.required = isLive;
|
||||
if (clientSelect) {
|
||||
clientSelect.querySelectorAll("option[data-purchase-mode]").forEach(function (option) {
|
||||
option.disabled = isLive && option.getAttribute("data-purchase-mode") !== "live";
|
||||
});
|
||||
if (clientSelect.selectedOptions.length && clientSelect.selectedOptions[0].disabled) {
|
||||
clientSelect.value = "";
|
||||
}
|
||||
}
|
||||
form.setAttribute("data-confirm-submit", isLive
|
||||
? "确认创建真实的未付款订单?系统不会自动支付。"
|
||||
: "确认创建采购演练任务?任务创建后将等待所选客户端领取。");
|
||||
}
|
||||
modeInputs.forEach(function (input) {
|
||||
input.addEventListener("change", updateExecutionMode);
|
||||
});
|
||||
updateExecutionMode();
|
||||
var submitButton = form.querySelector("[data-purchase-submit]");
|
||||
openButtons.forEach(function (openButton) {
|
||||
openButton.addEventListener("click", function () {
|
||||
var selected = {};
|
||||
@@ -190,14 +166,17 @@
|
||||
var empty = form.querySelector("[data-purchase-empty]");
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
|
||||
/* 每次打开都回到演练模式,避免上次关闭弹窗后遗留 live 选择。 */
|
||||
var dryRun = form.querySelector("[data-execution-mode][value=dry_run]");
|
||||
if (dryRun) dryRun.checked = true;
|
||||
/* 每次打开都清空真实采购确认,不能沿用上一次未提交的确认。 */
|
||||
if (liveAcknowledged) liveAcknowledged.checked = false;
|
||||
if (liveConfirmation) liveConfirmation.value = "";
|
||||
updateExecutionMode();
|
||||
});
|
||||
});
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (event.defaultPrevented || !submitButton) return;
|
||||
submitButton.disabled = true;
|
||||
submitButton.setAttribute("aria-busy", "true");
|
||||
submitButton.textContent = "创建中…";
|
||||
});
|
||||
}
|
||||
|
||||
/* PDD 批量采集弹窗只同步已选数量并提供提交反馈。
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<form method="post" action="/syb/create-task" data-purchase-form
|
||||
data-confirm-submit="确认按下面的人民币价格上限创建采购任务?任务创建后将等待所选客户端领取。">
|
||||
data-confirm-submit="确认创建真实的未付款订单?系统不会自动支付。">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<input type="hidden" name="stage" value="{{.StageFilter}}">
|
||||
@@ -172,32 +172,21 @@
|
||||
<div class="field">
|
||||
<label for="purchase-client">执行客户端</label>
|
||||
<select id="purchase-client" name="client_id" required autofocus>
|
||||
<option value="">请选择当前账号可见的客户端</option>
|
||||
{{range .AssignableClients}}<option value="{{.ClientID}}" data-purchase-mode="{{.PurchaseMode}}">{{.Name}}({{.ClientID}},{{.Status}},{{if eq .PurchaseMode "live"}}支持真实下单{{else}}仅演练{{end}})</option>{{end}}
|
||||
<option value="">请选择可执行真实采购的客户端</option>
|
||||
{{range .AssignableClients}}<option value="{{.ClientID}}">{{.Name}}({{.ClientID}})</option>{{end}}
|
||||
</select>
|
||||
{{if not .AssignableClients}}<small class="missing">当前账号没有可用客户端,请先让管理员完成客户端绑定。</small>{{end}}
|
||||
{{if not .AssignableClients}}<small class="missing">当前没有可执行真实采购的在线客户端。请检查客户端绑定、在线状态和真实采购授权。</small>{{end}}
|
||||
</div>
|
||||
<fieldset class="execution-mode-fieldset">
|
||||
<legend>执行模式</legend>
|
||||
<label class="choice-row">
|
||||
<input type="radio" name="execution_mode" value="dry_run" checked data-execution-mode>
|
||||
<span><strong>采购演练</strong><small>只验证采购流程,不在拼多多创建订单。</small></span>
|
||||
</label>
|
||||
<label class="choice-row">
|
||||
<input type="radio" name="execution_mode" value="live" data-execution-mode>
|
||||
<span><strong>真实下单(不支付)</strong><small>会在拼多多创建真实的未付款订单。</small></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="live-confirm-panel" data-live-confirm-panel hidden>
|
||||
<div class="live-confirm-panel" data-live-confirm-panel>
|
||||
<strong>真实下单安全确认</strong>
|
||||
<p>这会产生真实未付款订单。必须选择声明 live 能力的客户端;系统不会自动支付。</p>
|
||||
<label class="choice-row">
|
||||
<input type="checkbox" name="live_acknowledged" value="1" data-live-acknowledged>
|
||||
<input type="checkbox" name="live_acknowledged" value="1" data-live-acknowledged required>
|
||||
<span>我确认本次操作会创建真实未付款订单</span>
|
||||
</label>
|
||||
<label for="live-confirmation">输入“创建未付款订单”继续</label>
|
||||
<input id="live-confirmation" name="live_confirmation" type="text"
|
||||
autocomplete="off" data-live-confirmation>
|
||||
autocomplete="off" data-live-confirmation required>
|
||||
</div>
|
||||
<p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
|
||||
{{range .Rows}}
|
||||
@@ -213,7 +202,7 @@
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>取消</button>
|
||||
<button type="submit" class="primary" {{if not .AssignableClients}}disabled{{end}}>确认创建</button>
|
||||
<button type="submit" class="primary" data-purchase-submit {{if not .AssignableClients}}disabled{{end}}>确认创建真实采购任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user