fix(M09-A): 收口保洁任务事务与并发
This commit is contained in:
@@ -72,6 +72,7 @@ interface CountRow extends RowDataPacket { total: number }
|
||||
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
|
||||
interface AmountRow extends RowDataPacket { amount: number | null }
|
||||
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
|
||||
interface ReclaimRow extends CurrentStatusRow { id: string }
|
||||
interface FinishedOrderRow extends RowDataPacket {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
@@ -232,16 +233,28 @@ export class CleaningTaskRepository {
|
||||
async claim(input: CleaningActor & { taskId: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL
|
||||
AND deleted_at IS NULL`,
|
||||
[input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
|
||||
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
|
||||
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', '');
|
||||
await this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId]
|
||||
);
|
||||
if (rows[0]?.status !== 'WAITING' || rows[0].cleanerUserId) {
|
||||
throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', ''
|
||||
);
|
||||
});
|
||||
return this.getMineTask(input, input.taskId);
|
||||
}
|
||||
|
||||
@@ -259,38 +272,58 @@ export class CleaningTaskRepository {
|
||||
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = COALESCE(claimed_at, UTC_TIMESTAMP(3)),
|
||||
reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ? AND status IN ('WAITING', 'CLAIMED', 'REJECTED')
|
||||
AND deleted_at IS NULL`,
|
||||
[input.cleanerUserId, input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
|
||||
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
|
||||
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? '');
|
||||
await this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId]
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current || !['WAITING', 'CLAIMED', 'REJECTED'].includes(current.status)) {
|
||||
throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
|
||||
}
|
||||
await this.assertCleanerUserForTask(
|
||||
input.tenantId, input.taskId, input.cleanerUserId, connection
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
|
||||
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
|
||||
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.cleanerUserId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, current.status, 'CLAIMED', 'ASSIGN', input.note ?? ''
|
||||
);
|
||||
});
|
||||
return this.getTask(input, input.taskId);
|
||||
}
|
||||
|
||||
async complete(input: CleaningActor & { taskId: string; note?: string }) {
|
||||
const task = await this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? '');
|
||||
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
|
||||
return task;
|
||||
return this.moveManaged(
|
||||
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
async reject(input: CleaningActor & { taskId: string; reason: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'REJECTED', reject_reason = ?
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
|
||||
[input.reason.slice(0, 512), input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEvent(input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason);
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'REJECTED', reject_reason = ?
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
|
||||
[input.reason.slice(0, 512), input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
|
||||
);
|
||||
});
|
||||
return this.getTask(input, input.taskId);
|
||||
}
|
||||
|
||||
@@ -753,54 +786,69 @@ export class CleaningTaskRepository {
|
||||
|
||||
async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_cleaning_tasks t
|
||||
WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED')
|
||||
AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3))
|
||||
AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')}
|
||||
ORDER BY t.updated_at ASC, t.id ASC
|
||||
LIMIT ?`,
|
||||
[input.tenantId, input.olderThanMinutes, input.limit]
|
||||
);
|
||||
const ids = rows.map((row) => String(row.id));
|
||||
if (ids.length === 0) return { reclaimed: 0, taskIds: [] as string[] };
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL,
|
||||
started_at = NULL, reject_reason = ''
|
||||
WHERE tenant_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
|
||||
[input.tenantId, ...ids]
|
||||
);
|
||||
for (const taskId of ids) {
|
||||
await this.recordEvent(input, taskId, 'CLAIMED', 'WAITING', 'TIMEOUT_RECLAIM', '');
|
||||
}
|
||||
return { reclaimed: ids.length, taskIds: ids };
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<ReclaimRow[]>(
|
||||
`SELECT t.id, t.status, t.cleaner_user_id AS cleanerUserId
|
||||
FROM qipai_cleaning_tasks t
|
||||
WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED')
|
||||
AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3))
|
||||
AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')}
|
||||
ORDER BY t.updated_at ASC, t.id ASC
|
||||
LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
|
||||
[input.tenantId, input.olderThanMinutes]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const taskId = String(row.id);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL,
|
||||
started_at = NULL, submitted_at = NULL, completed_at = NULL,
|
||||
settled_at = NULL, cancelled_at = NULL, photo_urls_json = JSON_ARRAY(),
|
||||
reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ? AND status = ?`,
|
||||
[input.tenantId, taskId, row.status]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_members
|
||||
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND task_id = ? AND removed_at IS NULL AND settled_at IS NULL`,
|
||||
[input.tenantId, taskId]
|
||||
);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, taskId, row.status, 'WAITING', 'TIMEOUT_RECLAIM', ''
|
||||
);
|
||||
}
|
||||
return { reclaimed: rows.length, taskIds: rows.map((row) => String(row.id)) };
|
||||
});
|
||||
}
|
||||
|
||||
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
|
||||
const [rows] = await this.pool.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
|
||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
}
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
|
||||
photo_urls_json = ?, reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
|
||||
AND deleted_at IS NULL`,
|
||||
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId,
|
||||
input.userId, current.status]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEvent(input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? '');
|
||||
await this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
|
||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
|
||||
photo_urls_json = ?, reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
|
||||
);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
|
||||
);
|
||||
});
|
||||
return this.getMineTask(input, input.taskId);
|
||||
}
|
||||
|
||||
@@ -1104,14 +1152,19 @@ export class CleaningTaskRepository {
|
||||
) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
||||
[to, input.tenantId, input.taskId, from]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEvent(input, input.taskId, from, to, action, note);
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
||||
[to, input.tenantId, input.taskId, from]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, from, to, action, note
|
||||
);
|
||||
});
|
||||
return this.getTask(input, input.taskId);
|
||||
}
|
||||
|
||||
@@ -1136,15 +1189,19 @@ export class CleaningTaskRepository {
|
||||
extraParams.push(extra.reject_reason);
|
||||
}
|
||||
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
|
||||
AND deleted_at IS NULL`,
|
||||
[to, ...extraParams, input.tenantId, input.taskId, input.userId, from]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEvent(input, input.taskId, from, to, action, note);
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
|
||||
AND deleted_at IS NULL`,
|
||||
[to, ...extraParams, input.tenantId, input.taskId, input.userId, from]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, from, to, action, note
|
||||
);
|
||||
});
|
||||
return this.getMineTask(input, input.taskId);
|
||||
}
|
||||
|
||||
@@ -1236,8 +1293,13 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCleanerUserForTask(tenantId: string, taskId: string, cleanerUserId: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
private async assertCleanerUserForTask(
|
||||
tenantId: string,
|
||||
taskId: string,
|
||||
cleanerUserId: string,
|
||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
|
||||
) {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT 1
|
||||
FROM qipai_cleaning_tasks t
|
||||
INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ?
|
||||
@@ -1254,22 +1316,6 @@ export class CleaningTaskRepository {
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID');
|
||||
}
|
||||
|
||||
private async recordEvent(
|
||||
input: CleaningActor,
|
||||
taskId: string,
|
||||
from: CleaningTaskStatus,
|
||||
to: CleaningTaskStatus,
|
||||
action: string,
|
||||
note: string
|
||||
) {
|
||||
await this.pool.execute(
|
||||
`INSERT IGNORE INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||
[input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)]
|
||||
);
|
||||
}
|
||||
|
||||
private async recordEventWithConnection(
|
||||
connection: PoolConnection,
|
||||
input: CleaningActor,
|
||||
@@ -1280,7 +1326,7 @@ export class CleaningTaskRepository {
|
||||
note: string
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_cleaning_task_events
|
||||
`INSERT INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||
[input.tenantId, taskId, from, to, action, input.userId,
|
||||
|
||||
Reference in New Issue
Block a user