Партии: оптимистичная блокировка (version) — устаревшие правки/отмена отклоняются (фикс гонки завершить-vs-отменить)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:39:20 +03:00
co-authored by Claude Opus 4.8
parent eb34714868
commit 16452f7bbe
8 changed files with 148 additions and 13 deletions
+9 -1
View File
@@ -1507,6 +1507,8 @@ export interface components {
win_reason: "objectives" | "worlds" | "plastic" | "resources";
/** Overall Comment */
overall_comment?: string | null;
/** Expected Version */
expected_version?: string | null;
};
/** MatchFinishParticipant */
MatchFinishParticipant: {
@@ -1627,6 +1629,8 @@ export interface components {
* @default false
*/
can_modify: boolean;
/** Version */
version: string;
/**
* Participants
* @default []
@@ -1648,6 +1652,8 @@ export interface components {
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
/** Participants */
participants?: components["schemas"]["ParticipantInput"][] | null;
/** Expected Version */
expected_version?: string | null;
};
/** MeRead */
MeRead: {
@@ -2882,7 +2888,9 @@ export interface operations {
};
delete_match_api_matches__match_id__delete: {
parameters: {
query?: never;
query?: {
expected_version?: string | null;
};
header?: never;
path: {
match_id: number;
+6 -2
View File
@@ -70,10 +70,14 @@ export function useFinishMatch() {
export function useDeleteMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) =>
// expectedVersion — оптимистичная блокировка: отмена устаревшей версии вернёт 409 STALE_WRITE.
mutationFn: async (args: { matchId: number; expectedVersion?: string }) =>
unwrap(
await api.DELETE("/api/matches/{match_id}", {
params: { path: { match_id: matchId } },
params: {
path: { match_id: args.matchId },
query: args.expectedVersion ? { expected_version: args.expectedVersion } : {},
},
}),
),
onSuccess: () => qc.invalidateQueries(),
+24 -6
View File
@@ -26,7 +26,7 @@ interface FinishRow {
export function MatchDetailPage() {
const { matchId } = useParams();
const id = matchId ? Number(matchId) : null;
const { data: match, isLoading } = useMatch(id);
const { data: match, isLoading, refetch } = useMatch(id);
const finish = useFinishMatch();
const del = useDeleteMatch();
const uploadAtt = useUploadMatchAttachment(id ?? 0);
@@ -59,8 +59,11 @@ export function MatchDetailPage() {
const upd = (i: number, patch: Partial<FinishRow>) =>
setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
const submitFinish = async () => {
if (!id) return;
if (!id || !match) return;
setError(null);
try {
await finish.mutateAsync({
@@ -73,19 +76,34 @@ export function MatchDetailPage() {
})),
win_reason: winReason,
overall_comment: overall.trim() || null,
expected_version: match.version,
},
});
toast.show("Партия завершена");
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
refetch();
return;
}
setError(e instanceof ApiError ? e.message : "Не удалось завершить партию");
}
};
const remove = async () => {
if (!id) return;
await del.mutateAsync(id).catch(() => {});
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
navigate(-1);
if (!id || !match) return;
try {
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
navigate(-1);
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
refetch();
return;
}
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
}
};
const sorted = [...match.participants].sort(