fix(api): decrement SKU stock only when remaining quantity is sufficient

Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-18 17:41:06 +08:00
co-authored by Cursor
parent 53548196f8
commit 835afbe5e0
4 changed files with 32 additions and 6 deletions
+11 -5
View File
@@ -184,11 +184,16 @@ pub async fn insert_item(
}
pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> ApiResult<()> {
sqlx::query("UPDATE skus SET stock = stock - $2 WHERE id = $1")
.bind(sku_id)
.bind(qty)
.execute(&mut *tx)
.await?;
let result = sqlx::query(
"UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2",
)
.bind(sku_id)
.bind(qty)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::Conflict("insufficient stock".into()));
}
Ok(())
}
@@ -329,6 +334,7 @@ pub async fn lock_purchasable_skus(
JOIN shops sh ON sh.id = p.shop_id
WHERE s.id = ANY($1) AND s.active = TRUE
AND p.status = 'published' AND sh.status = 'active'
ORDER BY s.id
FOR UPDATE OF s",
)
.bind(sku_ids)
+19
View File
@@ -79,6 +79,25 @@ async fn checkout_rejects_empty_cart() {
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
}
#[tokio::test]
#[serial]
async fn decrement_stock_rejects_when_guard_fails() {
let state = common::spawn_state().await;
let sku_id = sellable_sku(&state, "guard", 100, 1).await;
let mut tx = state.db.begin().await.unwrap();
let err = order::repo::decrement_stock(&mut tx, sku_id, 2)
.await
.unwrap_err();
tx.rollback().await.unwrap();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
let left: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1")
.bind(sku_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(left, 1);
}
#[tokio::test]
#[serial]
async fn checkout_rejects_insufficient_stock() {