Compare commits

...
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 fc5d9edfa6 feat(task): 批量取消按设备限定,采购员只能取消本人设备任务 (#387)
- 批量取消新增可选 deviceId;非管理员必填且须为本人设备,否则 INVALID_REQUEST/FORBIDDEN
- 单条取消对非管理员强制设备归属(未指定设备、他人设备一律拒绝),管理员不变
- 新增 FORBIDDEN 错误码(HTTP 403)
- Web:去掉选设备禁用按钮;采购员未选设备点击只提示;数量/预览/提交带 deviceId;弹窗显示设备范围

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:14:40 +08:00
QiuSWandClaude Opus 5.5 59a483f53a docs: mirror wiki for Agent APK signing per device (#385 #386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:38:38 +08:00
QiuSWandClaude Opus 5.5 328afde919 chore(android): bump Agent to 0.9.71 (84) (#385 #386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:22 +08:00
QiuSWandClaude Opus 5.5 afa03ef3e4 merge: drop diagnostic record lock to avoid deadlock (#386)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:16 +08:00
QiuSWandClaude Opus 5.5 71165b1fe7 fix(android): drop object lock on diagnostic record to avoid deadlock (#386)
AgentDiagnosticStore.record() held the helper monitor (@Synchronized) while
waiting for the SQLite write connection, while failure-snapshot and sync
diagnostic transactions held the connection and re-entered
getWritableDatabase() (same monitor). The inverted lock order deadlocked and
blocked the sync/heartbeat thread forever (seen on purchase device 1).
record() only touches locals inside its own transaction and is called from a
single-thread executor, so the monitor is unnecessary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:22:04 +08:00
QiuSWandClaude Opus 5.5 7c0d37b512 merge: pause task claiming when accessibility is not ready (#385)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-13 09:21:50 +08:00
9 changed files with 366 additions and 21 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 83
versionName = "0.9.70"
versionCode = 84
versionName = "0.9.71"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -166,7 +166,6 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
}
}
@Synchronized
fun record(event: AgentDiagnosticEvent) {
require(event.taskId > 0)
require(event.taskType in setOf("collection", "purchase"))
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 7e91eea0ec077f5af813375bdd94d8878e7e16d7
synchronized_at: 2026-10-12T15:16:03Z
wiki_revision: 37630e95b7e03248546467826525f45921033f74
synchronized_at: 2026-10-13T01:37:19Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -347,6 +347,25 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。
- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。
### Agent 装机签名对照(2026-10-13 核对)
采购机和测试机上已装的 Agent 签名不同,装包前必须按目标设备选对签名:
| 设备 | 序列号 | 已装证书 SHA-256 | 签名文件 |
|---|---|---|---|
| 采购1(PKG110) | `3B65BD02H7F00000` | `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec` | `D:/OPC/goauto/debug.keystore`(私有,不提交) |
| 采购2(PLY110) | `3B663A00JW500000` | 同采购1 | 同采购1 |
| 测试机(PLY110) | `3B668T01GF400000` | `3eab2527dc3ce592fe22f2f8cc31e4d0fa07dd1fa54b6209948ea6ce9cd7c4f6` | `C:/Users/Qiu/.android/debug.keystore` |
- 本机 Gradle 调试构建产物已经是采购机签名(由上面的 init 脚本强制),可以直接装采购机;装测试机前需用 apksigner 以测试机签名改签。
- 装包前逐台核对:
- `ro.serialno` 确认目标设备(USB 连接的手机不一定是测试机);
- 用 `pm path` 拉取已装 `base.apk`,比较它与待装 APK 的 `apksigner verify --print-certs`;
- 设备空闲,没有待执行或执行中的任务。
- 签名不一致会报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`,**不得卸载重装**:卸载会清掉设备凭据和本地采购数据。
- 装完后手动打开 Agent,等待心跳恢复;无障碍显示未连接时,在系统设置中把 GoAuto 关闭后重新打开(#385)。
- 2026-10-13 记录:0.9.71(84,main `328afde`)测试机包 SHA256 `10ad5a2b898230897b15a54f73212ae56a11fcf2f73beffc0392321699d7cf00`,采购1 包 SHA256 `3004cda3784182381e98ce9953cabcdba4017eb535c8578a2b317b3a7303e6ab`,均以 `install -r` 覆盖安装,未卸载。
## Android 商品导航诊断(#373,诊断阶段)
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func (handler Handler) AdminList(c *gin.Context) {
@@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) {
if !ok {
return
}
response, err := service.Cancel(c.Request.Context(), id, request)
response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c))
if err != nil {
writeError(c, err)
return
@@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
if !ok {
return
}
response, err := service.BatchCancel(c.Request.Context(), request)
response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c))
if err != nil {
writeError(c, err)
return
@@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
// ownerScope 返回取消操作的归属限制:管理员返回 nil(不限制),其他角色返回
// 当前用户 id(取不到时为 0,此时任何设备都不算本人设备)。读取方式与
// device/handler.go 一致(JWT rolekey / identity,#333)。
func ownerScope(c *gin.Context) *uint64 {
if role, _ := jwt.ExtractClaims(c)["rolekey"].(string); role == "admin" {
return nil
}
id := currentUserID(c)
return &id
}
func currentUserID(c *gin.Context) uint64 {
switch id := jwt.ExtractClaims(c)["identity"].(type) {
case float64:
if id > 0 {
return uint64(id)
}
case int:
if id > 0 {
return uint64(id)
}
case int64:
if id > 0 {
return uint64(id)
}
case uint64:
return id
}
return 0
}
func decodeStrict(c *gin.Context, destination any) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
+64 -5
View File
@@ -13,6 +13,20 @@ import (
const maxBatchCancelItems = 500
const messageNotOwnDevice = "只能取消归属自己设备的任务"
// deviceOwnedBy 判断设备是否归属指定用户;设备为空、不存在或未归属都视为否。
func deviceOwnedBy(db *gorm.DB, deviceID *uint64, ownerUserID uint64) (bool, error) {
if deviceID == nil || *deviceID == 0 || ownerUserID == 0 {
return false, nil
}
var count int64
if err := db.Model(&models.AgentDevice{}).Where("id = ? AND owner_user_id = ?", *deviceID, ownerUserID).Count(&count).Error; err != nil {
return false, internalError(err)
}
return count > 0, nil
}
// CancelResponse describes the outcome of cancelling one pending collection task.
type CancelResponse struct {
TaskID uint64 `json:"taskId"`
@@ -31,6 +45,9 @@ type BatchCancelRequest struct {
// 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用
// 「筛选 + 一个按钮」想避免的事(#298)。
GoodsID string `json:"goodsId,omitempty"`
// DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含
// 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。
DeviceID uint64 `json:"deviceId,omitempty"`
}
type BatchCancelSkippedItem struct {
@@ -57,6 +74,13 @@ type BatchCancelResponse struct {
// failure #292 already paid for once. The semantics are "stop what has not
// started, let what is running finish".
func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) {
return service.CancelOwnedBy(ctx, taskID, request, nil)
}
// CancelOwnedBy 与 Cancel 相同,但 ownerUserID 非空时只允许取消归属该用户设备
// 的任务(#387)。未指定设备、设备不存在或归属他人的任务一律返回同一个拒绝,
// 不区分任务当前状态,避免向采购员泄露不属于他的任务信息。
func (service *Service) CancelOwnedBy(ctx context.Context, taskID uint64, request ActionRequest, ownerUserID *uint64) (CancelResponse, error) {
if err := validateAction(taskID, request); err != nil {
return CancelResponse{}, err
}
@@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
}
return internalError(err)
}
if ownerUserID != nil {
owned, err := deviceOwnedBy(tx, record.DeviceID, *ownerUserID)
if err != nil {
return err
}
if !owned {
return serviceError(CodeForbidden, messageNotOwnDevice)
}
}
if record.Status == models.TaskStatusCancelled {
response = CancelResponse{TaskID: taskID, Cancelled: true}
return nil
@@ -88,11 +121,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
// 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。
// RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。
now := service.Now()
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now).
Updates(map[string]any{
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
})
update := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).
Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now)
if ownerUserID != nil {
// 上面已校验归属;这里在写入条件里再兜一层,防止校验与更新之间归属变化。
update = update.Where("device_id IN (SELECT id FROM agent_device WHERE owner_user_id = ?)", *ownerUserID)
}
result := update.Updates(map[string]any{
"status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil,
})
if result.Error != nil {
return internalError(result.Error)
}
@@ -115,6 +152,25 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio
// 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或
// 全无——批量越大,中途有一条被领走的概率越高。
func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) {
return service.BatchCancelOwnedBy(ctx, request, nil)
}
// BatchCancelOwnedBy 与 BatchCancel 相同,但 ownerUserID 非空(非管理员)时
// 必须指定 DeviceID,且该设备必须归属 ownerUserID,否则不取消任何任务(#387)。
func (service *Service) BatchCancelOwnedBy(ctx context.Context, request BatchCancelRequest, ownerUserID *uint64) (BatchCancelResponse, error) {
if ownerUserID != nil {
if request.DeviceID == 0 {
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "请先选择设备")
}
deviceID := request.DeviceID
owned, err := deviceOwnedBy(service.DB.WithContext(ctx), &deviceID, *ownerUserID)
if err != nil {
return BatchCancelResponse{}, err
}
if !owned {
return BatchCancelResponse{}, serviceError(CodeForbidden, messageNotOwnDevice)
}
}
source := strings.TrimSpace(request.Source)
if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch {
return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效")
@@ -135,6 +191,9 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ
if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" {
query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%")
}
if request.DeviceID > 0 {
query = query.Where("device_id = ?", request.DeviceID)
}
var candidates []models.CollectionTask
// 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。
if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil {
+224
View File
@@ -0,0 +1,224 @@
package task
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"gorm.io/gorm"
)
// #387:批量取消按设备限定,单条/批量取消对非管理员强制设备归属。
func ownDevice(t *testing.T, db *gorm.DB, name string, owner uint64) models.AgentDevice {
t.Helper()
record, _ := registerTaskDevice(t, db, name)
if owner > 0 {
if err := db.Model(&models.AgentDevice{}).Where("id = ?", record.ID).Update("owner_user_id", owner).Error; err != nil {
t.Fatalf("set owner: %v", err)
}
}
return record
}
func taskStatus(t *testing.T, db *gorm.DB, id uint64) string {
t.Helper()
var stored models.CollectionTask
if err := db.First(&stored, id).Error; err != nil {
t.Fatalf("load task: %v", err)
}
return stored.Status
}
func callCancel(t *testing.T, db *gorm.DB, claims jwt.MapClaims, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
handler := Handler{DB: db}
setClaims := func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, claims)
c.Set("userId", 0)
c.Next()
}
engine.POST("/batch-cancel", setClaims, handler.AdminBatchCancel)
engine.POST("/:taskId/cancel", setClaims, handler.AdminCancel)
payload, _ := json.Marshal(body)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload)))
var decoded map[string]any
_ = json.Unmarshal(recorder.Body.Bytes(), &decoded)
return recorder, decoded
}
func purchaser(id float64) jwt.MapClaims {
return jwt.MapClaims{"rolekey": "purchaser", "identity": id}
}
func adminClaims() jwt.MapClaims { return jwt.MapClaims{"rolekey": "admin", "identity": float64(1)} }
func TestBatchCancelWithDeviceIDOnlyCancelsThatDevice(t *testing.T) {
db := openTaskDatabase(t)
service := newTaskService(db)
one := ownDevice(t, db, "one", 3)
two := ownDevice(t, db, "two", 3)
onOne := createTask(t, db, &one.ID)
onTwo := createTask(t, db, &two.ID)
unassigned := createTask(t, db, nil)
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{DeviceID: one.ID})
if err != nil {
t.Fatalf("batch cancel: %v", err)
}
if response.CancelledCount != 1 || response.CancelledIDs[0] != onOne.ID {
t.Fatalf("expected only device one task cancelled, got %+v", response)
}
if taskStatus(t, db, onTwo.ID) != models.TaskStatusPending || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
t.Fatalf("other device and unassigned tasks must stay pending")
}
}
func TestBatchCancelWithoutDeviceIDKeepsFullScope(t *testing.T) {
db := openTaskDatabase(t)
service := newTaskService(db)
one := ownDevice(t, db, "one", 3)
createTask(t, db, &one.ID)
createTask(t, db, nil)
response, err := service.BatchCancel(context.Background(), BatchCancelRequest{})
if err != nil {
t.Fatalf("batch cancel: %v", err)
}
if response.CancelledCount != 2 {
t.Fatalf("expected both tasks cancelled including unassigned, got %+v", response)
}
}
func TestBatchCancelPurchaserRequiresDeviceID(t *testing.T) {
db := openTaskDatabase(t)
one := ownDevice(t, db, "one", 3)
record := createTask(t, db, &one.ID)
for _, body := range []map[string]any{{}, {"deviceId": 0}} {
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", body)
if recorder.Code != http.StatusUnprocessableEntity || decoded["code"] != "INVALID_REQUEST" || decoded["message"] != "请先选择设备" {
t.Fatalf("expected INVALID_REQUEST, got %d %v", recorder.Code, decoded)
}
}
if taskStatus(t, db, record.ID) != models.TaskStatusPending {
t.Fatalf("no task may be cancelled")
}
}
func TestBatchCancelPurchaserOwnDeviceSucceeds(t *testing.T) {
db := openTaskDatabase(t)
own := ownDevice(t, db, "own", 3)
other := ownDevice(t, db, "other", 4)
mine := createTask(t, db, &own.ID)
theirs := createTask(t, db, &other.ID)
recorder, _ := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": own.ID})
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if taskStatus(t, db, mine.ID) != models.TaskStatusCancelled || taskStatus(t, db, theirs.ID) != models.TaskStatusPending {
t.Fatalf("expected only own device task cancelled")
}
}
func TestBatchCancelPurchaserRejectsForeignUnownedAndMissingDevices(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
unowned := ownDevice(t, db, "unowned", 0)
foreignTask := createTask(t, db, &foreign.ID)
unownedTask := createTask(t, db, &unowned.ID)
for name, id := range map[string]uint64{"foreign": foreign.ID, "unowned": unowned.ID, "missing": 999999} {
recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": id})
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
t.Fatalf("%s: expected 403 FORBIDDEN, got %d %v", name, recorder.Code, decoded)
}
}
if taskStatus(t, db, foreignTask.ID) != models.TaskStatusPending || taskStatus(t, db, unownedTask.ID) != models.TaskStatusPending {
t.Fatalf("rejected requests must not cancel anything")
}
}
func TestBatchCancelMissingIdentityIsRejected(t *testing.T) {
db := openTaskDatabase(t)
unowned := ownDevice(t, db, "unowned", 0)
record := createTask(t, db, &unowned.ID)
recorder, _ := callCancel(t, db, jwt.MapClaims{"rolekey": "purchaser"}, "/batch-cancel", map[string]any{"deviceId": unowned.ID})
if recorder.Code != http.StatusForbidden || taskStatus(t, db, record.ID) != models.TaskStatusPending {
t.Fatalf("expected rejection, got %d", recorder.Code)
}
}
func TestBatchCancelAdminAnyDeviceAndUnassigned(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
onForeign := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
recorder, _ := callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{"deviceId": foreign.ID})
if recorder.Code != http.StatusOK || taskStatus(t, db, onForeign.ID) != models.TaskStatusCancelled || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending {
t.Fatalf("admin with deviceId should cancel only that device, code=%d", recorder.Code)
}
recorder, _ = callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{})
if recorder.Code != http.StatusOK || taskStatus(t, db, unassigned.ID) != models.TaskStatusCancelled {
t.Fatalf("admin without deviceId should cancel unassigned tasks, code=%d", recorder.Code)
}
}
func TestSingleCancelPurchaserOwnership(t *testing.T) {
db := openTaskDatabase(t)
own := ownDevice(t, db, "own", 3)
foreign := ownDevice(t, db, "foreign", 4)
mine := createTask(t, db, &own.ID)
theirs := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
body := map[string]any{"requestId": uuid.NewString()}
for name, id := range map[string]uint64{"foreign": theirs.ID, "unassigned": unassigned.ID} {
recorder, decoded := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", id), body)
if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" {
t.Fatalf("%s: expected 403, got %d %v", name, recorder.Code, decoded)
}
if taskStatus(t, db, id) != models.TaskStatusPending {
t.Fatalf("%s: status must be unchanged", name)
}
}
recorder, _ := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", mine.ID), body)
if recorder.Code != http.StatusOK || taskStatus(t, db, mine.ID) != models.TaskStatusCancelled {
t.Fatalf("own device task should cancel, code=%d body=%s", recorder.Code, recorder.Body.String())
}
// 他人设备上已取消的任务也不能通过幂等分支探测。
if _, err := newTaskService(db).Cancel(context.Background(), theirs.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil {
t.Fatalf("seed: %v", err)
}
recorder, _ = callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", theirs.ID), body)
if recorder.Code != http.StatusForbidden {
t.Fatalf("foreign cancelled task must still be forbidden, got %d", recorder.Code)
}
}
func TestSingleCancelAdminUnaffected(t *testing.T) {
db := openTaskDatabase(t)
foreign := ownDevice(t, db, "foreign", 4)
theirs := createTask(t, db, &foreign.ID)
unassigned := createTask(t, db, nil)
for _, id := range []uint64{theirs.ID, unassigned.ID} {
recorder, _ := callCancel(t, db, adminClaims(), fmt.Sprintf("/%d/cancel", id), map[string]any{"requestId": uuid.NewString()})
if recorder.Code != http.StatusOK || taskStatus(t, db, id) != models.TaskStatusCancelled {
t.Fatalf("admin cancel of %d failed: %d", id, recorder.Code)
}
}
}
+1 -1
View File
@@ -317,7 +317,7 @@ func writeError(context *gin.Context, err error) {
status = http.StatusUnprocessableEntity
case device.CodeTokenInvalid:
status = http.StatusUnauthorized
case device.CodeDeviceDisabled:
case device.CodeDeviceDisabled, CodeForbidden:
status = http.StatusForbidden
case CodeTaskNotFound:
status = http.StatusNotFound
+1
View File
@@ -20,6 +20,7 @@ const (
DefaultLeaseDuration = 2 * time.Minute
DefaultTaskTimeout = 120
CodeForbidden = "FORBIDDEN"
CodeTaskNotFound = "TASK_NOT_FOUND"
CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED"
CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE"
@@ -14,7 +14,7 @@
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span class="action-count">{{ pendingCancelCount }}</span></el-button>
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="!cancelNeedsDevice && pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span v-if="!cancelNeedsDevice" class="action-count">{{ pendingCancelCount }}</span></el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
@@ -47,7 +47,7 @@
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
<el-alert
:title="`将取消整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下所有待执行任务,不只是当前页。`"
:title="`将取消${cancelDeviceLabel}上、整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下的所有待执行任务,不只是当前页。`"
type="warning" :closable="false" show-icon class="dialog-alert" />
<div v-loading="cancelBatch.previewLoading">
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
@@ -114,6 +114,16 @@ export default {
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
}
},
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
// #387:采购员未选设备时不能批量取消(服务端同样拒绝);管理员不受限。
cancelNeedsDevice() { return !this.isAdmin && !this.query.deviceId },
cancelDeviceLabel() {
if (!this.query.deviceId) return '全部设备(含未指定设备)'
const found = this.deviceFilterOptions.find(item => item.id === this.query.deviceId)
return `设备「${found ? found.name : this.query.deviceId}」`
}
},
created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() },
watch: {
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
@@ -135,20 +145,21 @@ export default {
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
// 与实际可取消数量无关的数字误导采购员。
// 批量取消接口不支持设备维度;选了设备筛选时禁用,避免取消范围超出眼前列表。
cancelScopeStatus() { if (this.query.deviceId) return null; return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
// #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。
cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null },
async refreshPendingCancelCount() {
const status = this.cancelScopeStatus()
if (!status) { this.pendingCancelCount = 0; return }
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return }
const r = await listCollectionTasks({ page: 1, pageSize: 1, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
this.pendingCancelCount = r.data.total
},
async openCancelBatch() {
if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return }
const status = this.cancelScopeStatus()
if (!status) return
this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 }
try {
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
const r = await listCollectionTasks({ page: 1, pageSize: 50, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined })
this.cancelBatch.preview = r.data.items
this.cancelBatch.previewTotal = r.data.total
} finally { this.cancelBatch.previewLoading = false }
@@ -158,7 +169,7 @@ export default {
if (!status) return
this.cancelBatch.submitting = true
try {
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, goodsId: this.query.goodsId || undefined })
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, deviceId: this.query.deviceId || undefined, goodsId: this.query.goodsId || undefined })
const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : ''
ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`)
if (response.data.hasMore) {