mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
transactions + unique-conflict
This commit is contained in:
1 parent
f8caef64c9
commit
1e0ccf53a4
4 files changed
+48
-10
No files matched your search
@@ -68,6 +68,17 @@ jobs:
|
||||
backend-rs/target
|
||||
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
|
||||
|
||||
- name: Fetch common-passwords list
|
||||
run: |
|
||||
mkdir -p assets
|
||||
curl -fsSL -o assets/common-passwords.txt.gz \
|
||||
"https://raw.githubusercontent.com/django/django/${DJANGO_PASSWORDS_TAG}/django/contrib/auth/common-passwords.txt.gz"
|
||||
echo "${DJANGO_PASSWORDS_SHA256} assets/common-passwords.txt.gz" | sha256sum -c -
|
||||
working-directory: backend-rs
|
||||
env:
|
||||
DJANGO_PASSWORDS_TAG: "5.2"
|
||||
DJANGO_PASSWORDS_SHA256: "3c1baed62596de36860824eb3f436d5932d37ca8b06e59df78f5a44ec175afe4"
|
||||
|
||||
- name: Format Check
|
||||
run: cargo fmt --check
|
||||
working-directory: backend-rs
|
||||
|
||||
@@ -89,3 +89,10 @@ impl IntoResponse for ApiError {
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
/// True if a `DbErr` is a SQLite UNIQUE-constraint violation. SeaORM surfaces the
|
||||
/// underlying sqlx message verbatim; matching on it lets handlers turn a racing
|
||||
/// duplicate insert into a clean 400 instead of a generic 500.
|
||||
pub fn is_unique_violation(e: &sea_orm::DbErr) -> bool {
|
||||
e.to_string().contains("UNIQUE constraint failed")
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::{
|
||||
ActiveValue::{NotSet, Set},
|
||||
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter,
|
||||
};
|
||||
|
||||
use crate::entities::prelude::*;
|
||||
@@ -47,8 +47,8 @@ fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_mentions(
|
||||
db: &DatabaseConnection,
|
||||
pub async fn parse_mentions<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
text: &str,
|
||||
actor_id: i32,
|
||||
rating_id: Option<i32>,
|
||||
|
||||
@@ -196,7 +196,13 @@ async fn create(
|
||||
}
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
let model = rating::ActiveModel {
|
||||
// Insert + mention fan-out in one transaction so a parse_mentions failure
|
||||
// can't leave a rating with no notifications. The DB's UNIQUE(user_id,
|
||||
// flavor_id) index is the real guard against a racing double-rate (the
|
||||
// pre-check above is best-effort and not atomic): map that violation to the
|
||||
// same clean validation error instead of a 500.
|
||||
let txn = state.db.begin().await?;
|
||||
let model = match (rating::ActiveModel {
|
||||
id: NotSet,
|
||||
user_id: Set(uid),
|
||||
flavor_id: Set(flavor_id),
|
||||
@@ -204,12 +210,22 @@ async fn create(
|
||||
comment: Set(comment.clone()),
|
||||
created_at: Set(now),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await?;
|
||||
.insert(&txn)
|
||||
.await)
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) if crate::error::is_unique_violation(&e) => {
|
||||
return Err(ApiError::Validation(json!([
|
||||
"You have already rated this flavor."
|
||||
])));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
|
||||
parse_mentions(&state.db, text, uid, Some(model.id), None).await?;
|
||||
parse_mentions(&txn, text, uid, Some(model.id), None).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
|
||||
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||
@@ -430,6 +446,9 @@ async fn reply(
|
||||
}
|
||||
|
||||
let now = crate::datetime::now_micros();
|
||||
// Reply + owner-notification + mention fan-out are one unit: a failure in any
|
||||
// step must not persist a partial reply (orphaned reply, missing notification).
|
||||
let txn = state.db.begin().await?;
|
||||
let reply_model = reply::ActiveModel {
|
||||
id: NotSet,
|
||||
user_id: Set(uid),
|
||||
@@ -437,7 +456,7 @@ async fn reply(
|
||||
text: Set(text.to_string()),
|
||||
created_at: Set(now),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
if rating.user_id != uid {
|
||||
@@ -453,11 +472,12 @@ async fn reply(
|
||||
notification_type: Set("reply".to_string()),
|
||||
profile_comment_id: Set(None),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?;
|
||||
parse_mentions(&txn, text, uid, Some(rating.id), Some(reply_model.id)).await?;
|
||||
txn.commit().await?;
|
||||
|
||||
let mut dtos = build_replies(&state, vec![reply_model]).await?;
|
||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||
|
||||
Reference in new issue
Block a user