This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::api::{admin, context};
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::{header::CACHE_CONTROL, HeaderMap};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::collections::HashSet;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -25,18 +28,47 @@ pub fn router() -> Router<AppState> {
|
||||
"/admin/redemption-codes",
|
||||
get(list_admin_codes).post(create_codes),
|
||||
)
|
||||
.route("/admin/redemption-codes/{code_id}", put(update_code_status))
|
||||
.route("/admin/redemption-codes/batch", post(batch_update_codes))
|
||||
.route(
|
||||
"/admin/redemption-codes/{code_id}",
|
||||
put(update_code_status).delete(delete_code),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PagingQuery {
|
||||
struct AdminCodesQuery {
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
status: Option<String>,
|
||||
benefit_kind: Option<String>,
|
||||
plan_id: Option<Uuid>,
|
||||
keyword: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
#[derive(Debug, FromRow)]
|
||||
struct AdminCodeRow {
|
||||
id: Uuid,
|
||||
code_encrypted: Option<String>,
|
||||
code_hash: String,
|
||||
code_hint: String,
|
||||
benefit_kind: String,
|
||||
plan_id: Option<Uuid>,
|
||||
plan_name: Option<String>,
|
||||
units: Option<i32>,
|
||||
duration_days: i32,
|
||||
redeem_before: Option<DateTime<Utc>>,
|
||||
is_active: bool,
|
||||
note: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
redeemed_at: Option<DateTime<Utc>>,
|
||||
redeemed_by: Option<Uuid>,
|
||||
redeemed_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AdminCodeView {
|
||||
id: Uuid,
|
||||
code: Option<String>,
|
||||
code_hint: String,
|
||||
benefit_kind: String,
|
||||
plan_id: Option<Uuid>,
|
||||
@@ -64,24 +96,67 @@ async fn list_admin_codes(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<PagingQuery>,
|
||||
) -> Result<Json<Envelope<AdminCodesResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, _admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||
request_headers: HeaderMap,
|
||||
Query(query): Query<AdminCodesQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let ip = context::client_ip(&request_headers, addr.ip());
|
||||
let (_jar, _admin_id) = admin::require_admin(&state, jar, &request_headers, ip).await?;
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = (page - 1) * limit;
|
||||
let status = normalize_admin_status(query.status.as_deref())?;
|
||||
let benefit_kind = normalize_benefit_filter(query.benefit_kind.as_deref())?;
|
||||
let keyword = normalize_admin_keyword(query.keyword.as_deref())?;
|
||||
let keyword_pattern = keyword.as_deref().map(contains_pattern);
|
||||
let code_hash = match keyword.as_deref().and_then(normalize_code) {
|
||||
Some(compact) => Some(context::api_key_hash(
|
||||
&compact,
|
||||
&state.config.api_key_pepper,
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM redemption_codes")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM redemption_codes c
|
||||
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE (
|
||||
$1::text IS NULL
|
||||
OR ($1 = 'available' AND r.id IS NULL AND c.is_active = true
|
||||
AND (c.redeem_before IS NULL OR c.redeem_before > NOW()))
|
||||
OR ($1 = 'redeemed' AND r.id IS NOT NULL)
|
||||
OR ($1 = 'disabled' AND r.id IS NULL AND c.is_active = false)
|
||||
OR ($1 = 'expired' AND r.id IS NULL AND c.is_active = true
|
||||
AND c.redeem_before <= NOW())
|
||||
)
|
||||
AND ($2::text IS NULL OR c.benefit_kind = $2)
|
||||
AND ($3::uuid IS NULL OR c.plan_id = $3)
|
||||
AND (
|
||||
$4::text IS NULL
|
||||
OR c.code_hint ILIKE $4 ESCAPE E'\\'
|
||||
OR COALESCE(c.note, '') ILIKE $4 ESCAPE E'\\'
|
||||
OR COALESCE(u.username, '') ILIKE $4 ESCAPE E'\\'
|
||||
OR c.code_hash = $5
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(status)
|
||||
.bind(benefit_kind.as_deref())
|
||||
.bind(query.plan_id)
|
||||
.bind(keyword_pattern.as_deref())
|
||||
.bind(code_hash.as_deref())
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||
|
||||
let codes = sqlx::query_as::<_, AdminCodeView>(
|
||||
let rows = sqlx::query_as::<_, AdminCodeRow>(
|
||||
r#"
|
||||
SELECT
|
||||
c.id,
|
||||
c.code_encrypted,
|
||||
c.code_hash,
|
||||
c.code_hint,
|
||||
c.benefit_kind,
|
||||
c.plan_id,
|
||||
@@ -99,25 +174,145 @@ async fn list_admin_codes(
|
||||
LEFT JOIN plans p ON p.id = c.plan_id
|
||||
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE (
|
||||
$1::text IS NULL
|
||||
OR ($1 = 'available' AND r.id IS NULL AND c.is_active = true
|
||||
AND (c.redeem_before IS NULL OR c.redeem_before > NOW()))
|
||||
OR ($1 = 'redeemed' AND r.id IS NOT NULL)
|
||||
OR ($1 = 'disabled' AND r.id IS NULL AND c.is_active = false)
|
||||
OR ($1 = 'expired' AND r.id IS NULL AND c.is_active = true
|
||||
AND c.redeem_before <= NOW())
|
||||
)
|
||||
AND ($2::text IS NULL OR c.benefit_kind = $2)
|
||||
AND ($3::uuid IS NULL OR c.plan_id = $3)
|
||||
AND (
|
||||
$4::text IS NULL
|
||||
OR c.code_hint ILIKE $4 ESCAPE E'\\'
|
||||
OR COALESCE(c.note, '') ILIKE $4 ESCAPE E'\\'
|
||||
OR COALESCE(u.username, '') ILIKE $4 ESCAPE E'\\'
|
||||
OR c.code_hash = $5
|
||||
)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
LIMIT $6 OFFSET $7
|
||||
"#,
|
||||
)
|
||||
.bind(status)
|
||||
.bind(benefit_kind.as_deref())
|
||||
.bind(query.plan_id)
|
||||
.bind(keyword_pattern.as_deref())
|
||||
.bind(code_hash.as_deref())
|
||||
.bind(i64::from(limit))
|
||||
.bind(i64::from(offset))
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: AdminCodesResponse {
|
||||
codes,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
},
|
||||
}))
|
||||
let codes = rows
|
||||
.into_iter()
|
||||
.map(|row| admin_code_view(&state, row))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok((
|
||||
[(CACHE_CONTROL, "no-store")],
|
||||
Json(Envelope {
|
||||
success: true,
|
||||
data: AdminCodesResponse {
|
||||
codes,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
},
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_admin_status(input: Option<&str>) -> Result<Option<&'static str>, AppError> {
|
||||
match input
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"" | "all" => Ok(None),
|
||||
"available" => Ok(Some("available")),
|
||||
"redeemed" => Ok(Some("redeemed")),
|
||||
"disabled" => Ok(Some("disabled")),
|
||||
"expired" => Ok(Some("expired")),
|
||||
_ => Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"status 仅支持 available/redeemed/disabled/expired",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_benefit_filter(input: Option<&str>) -> Result<Option<String>, AppError> {
|
||||
let value = input.map(str::trim).unwrap_or("").to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"" | "all" => Ok(None),
|
||||
"plan" | "units" => Ok(Some(value)),
|
||||
_ => Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"benefit_kind 仅支持 plan/units",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_admin_keyword(input: Option<&str>) -> Result<Option<String>, AppError> {
|
||||
let value = input.map(str::trim).unwrap_or("");
|
||||
if value.chars().count() > 100 {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"keyword 不能超过 100 字",
|
||||
));
|
||||
}
|
||||
Ok((!value.is_empty()).then(|| value.to_string()))
|
||||
}
|
||||
|
||||
fn contains_pattern(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len() + 2);
|
||||
escaped.push('%');
|
||||
for ch in value.chars() {
|
||||
if matches!(ch, '\\' | '%' | '_') {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(ch);
|
||||
}
|
||||
escaped.push('%');
|
||||
escaped
|
||||
}
|
||||
|
||||
fn admin_code_view(state: &AppState, row: AdminCodeRow) -> Result<AdminCodeView, AppError> {
|
||||
let code = match row.code_encrypted.as_deref() {
|
||||
Some(encrypted) => {
|
||||
let plain = settings::decrypt_secret(state, encrypted)?;
|
||||
let compact = normalize_code(&plain)
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "兑换码密文内容格式错误"))?;
|
||||
let expected = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||
if expected != row.code_hash {
|
||||
return Err(AppError::new(ErrorCode::Internal, "兑换码密文与哈希不匹配"));
|
||||
}
|
||||
Some(plain)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(AdminCodeView {
|
||||
id: row.id,
|
||||
code,
|
||||
code_hint: row.code_hint,
|
||||
benefit_kind: row.benefit_kind,
|
||||
plan_id: row.plan_id,
|
||||
plan_name: row.plan_name,
|
||||
units: row.units,
|
||||
duration_days: row.duration_days,
|
||||
redeem_before: row.redeem_before,
|
||||
is_active: row.is_active,
|
||||
note: row.note,
|
||||
created_at: row.created_at,
|
||||
redeemed_at: row.redeemed_at,
|
||||
redeemed_by: row.redeemed_by,
|
||||
redeemed_username: row.redeemed_username,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -150,7 +345,7 @@ async fn create_codes(
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateCodesRequest>,
|
||||
) -> Result<Json<Envelope<CreateCodesResponse>>, AppError> {
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||
let quantity = req.quantity.unwrap_or(1);
|
||||
@@ -242,17 +437,19 @@ async fn create_codes(
|
||||
let compact = normalize_code(&code)
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Internal, "生成兑换码格式失败"))?;
|
||||
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||
let code_encrypted = settings::encrypt_secret(&state, &code)?;
|
||||
let code_hint = code_hint(&compact);
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO redemption_codes (
|
||||
code_hash, code_hint, benefit_kind, plan_id, units,
|
||||
code_hash, code_encrypted, code_hint, benefit_kind, plan_id, units,
|
||||
duration_days, redeem_before, note, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(code_hash)
|
||||
.bind(code_encrypted)
|
||||
.bind(&code_hint)
|
||||
.bind(&benefit_kind)
|
||||
.bind(plan_id)
|
||||
@@ -297,13 +494,16 @@ async fn create_codes(
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: CreateCodesResponse {
|
||||
message: "兑换码已生成,完整码仅显示本次".to_string(),
|
||||
codes: generated,
|
||||
},
|
||||
}))
|
||||
Ok((
|
||||
[(CACHE_CONTROL, "no-store")],
|
||||
Json(Envelope {
|
||||
success: true,
|
||||
data: CreateCodesResponse {
|
||||
message: "兑换码已生成,可随时在管理列表中查看".to_string(),
|
||||
codes: generated,
|
||||
},
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -331,17 +531,20 @@ async fn update_code_status(
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
let updated =
|
||||
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
|
||||
.bind(code_id)
|
||||
.bind(req.is_active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||
if lock_code_and_check_redeemed(&mut tx, code_id).await? {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"已兑换的兑换码不能修改状态",
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
|
||||
.bind(code_id)
|
||||
.bind(req.is_active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||
@@ -372,6 +575,271 @@ async fn update_code_status(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_code(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Path(code_id): Path<Uuid>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
|
||||
if lock_code_and_check_redeemed(&mut tx, code_id).await? {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"已兑换的兑换码不能删除",
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM redemption_codes WHERE id = $1")
|
||||
.bind(code_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "删除兑换码失败").with_source(err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||
VALUES ($1, 'redemption_code_deleted', 'redemption_code', $2, '{}'::jsonb, $3::inet)
|
||||
"#,
|
||||
)
|
||||
.bind(admin_id)
|
||||
.bind(code_id)
|
||||
.bind(ip.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "兑换码已删除".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BatchCodesRequest {
|
||||
ids: Vec<Uuid>,
|
||||
action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BatchCodesResponse {
|
||||
message: String,
|
||||
action: String,
|
||||
requested: usize,
|
||||
matched: usize,
|
||||
affected: u64,
|
||||
skipped_redeemed: usize,
|
||||
not_found: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum BatchCodeAction {
|
||||
Enable,
|
||||
Disable,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl BatchCodeAction {
|
||||
fn parse(value: &str) -> Result<Self, AppError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"enable" => Ok(Self::Enable),
|
||||
"disable" => Ok(Self::Disable),
|
||||
"delete" => Ok(Self::Delete),
|
||||
_ => Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"action 仅支持 enable/disable/delete",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enable => "enable",
|
||||
Self::Disable => "disable",
|
||||
Self::Delete => "delete",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enable => "启用",
|
||||
Self::Disable => "停用",
|
||||
Self::Delete => "删除",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn batch_update_codes(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<BatchCodesRequest>,
|
||||
) -> Result<Json<Envelope<BatchCodesResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||
if req.ids.is_empty() || req.ids.len() > 200 {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"ids 数量需在 1-200 之间",
|
||||
));
|
||||
}
|
||||
let action = BatchCodeAction::parse(&req.action)?;
|
||||
let mut ids = req.ids;
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let requested = ids.len();
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
let existing = sqlx::query_scalar::<_, Uuid>(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM redemption_codes
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换码失败").with_source(err))?;
|
||||
if existing.is_empty() {
|
||||
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||
}
|
||||
|
||||
let redeemed_ids = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT code_id FROM redemption_records WHERE code_id = ANY($1::uuid[])",
|
||||
)
|
||||
.bind(&existing)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换状态失败").with_source(err))?;
|
||||
let redeemed = redeemed_ids.iter().copied().collect::<HashSet<_>>();
|
||||
let eligible = existing
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !redeemed.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let affected = if eligible.is_empty() {
|
||||
0
|
||||
} else {
|
||||
match action {
|
||||
BatchCodeAction::Enable | BatchCodeAction::Disable => sqlx::query(
|
||||
r#"
|
||||
UPDATE redemption_codes
|
||||
SET is_active = $2, updated_at = NOW()
|
||||
WHERE id = ANY($1::uuid[])
|
||||
"#,
|
||||
)
|
||||
.bind(&eligible)
|
||||
.bind(matches!(action, BatchCodeAction::Enable))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "批量更新兑换码失败").with_source(err)
|
||||
})?
|
||||
.rows_affected(),
|
||||
BatchCodeAction::Delete => {
|
||||
sqlx::query("DELETE FROM redemption_codes WHERE id = ANY($1::uuid[])")
|
||||
.bind(&eligible)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "批量删除兑换码失败").with_source(err)
|
||||
})?
|
||||
.rows_affected()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let skipped_redeemed = redeemed_ids.len();
|
||||
let not_found = requested.saturating_sub(existing.len());
|
||||
let message = format!(
|
||||
"批量{}完成:处理 {} 个,跳过已兑换 {} 个,未找到 {} 个",
|
||||
action.label(),
|
||||
affected,
|
||||
skipped_redeemed,
|
||||
not_found
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, details, ip_address)
|
||||
VALUES ($1, 'redemption_codes_batch', 'redemption_code', $2, $3::inet)
|
||||
"#,
|
||||
)
|
||||
.bind(admin_id)
|
||||
.bind(serde_json::json!({
|
||||
"action": action.as_str(),
|
||||
"code_ids": existing,
|
||||
"requested": requested,
|
||||
"affected": affected,
|
||||
"skipped_redeemed": skipped_redeemed,
|
||||
"not_found": not_found,
|
||||
}))
|
||||
.bind(ip.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: BatchCodesResponse {
|
||||
message,
|
||||
action: action.as_str().to_string(),
|
||||
requested,
|
||||
matched: existing.len(),
|
||||
affected,
|
||||
skipped_redeemed,
|
||||
not_found,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn lock_code_and_check_redeemed(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
code_id: Uuid,
|
||||
) -> Result<bool, AppError> {
|
||||
let existing =
|
||||
sqlx::query_scalar::<_, Uuid>("SELECT id FROM redemption_codes WHERE id = $1 FOR UPDATE")
|
||||
.bind(code_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换码失败").with_source(err))?;
|
||||
if existing.is_none() {
|
||||
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||
}
|
||||
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM redemption_records WHERE code_id = $1)")
|
||||
.bind(code_id)
|
||||
.fetch_one(&mut **tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换状态失败").with_source(err))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RedeemCodeRequest {
|
||||
code: String,
|
||||
@@ -880,4 +1348,41 @@ mod tests {
|
||||
fn code_hint_only_exposes_the_last_group() {
|
||||
assert_eq!(code_hint("IMGABCDEFGHJKMNPQRS"), "IMG-...-PQRS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_filters_are_normalized_and_validated() {
|
||||
assert_eq!(normalize_admin_status(Some(" ALL ")).unwrap(), None);
|
||||
assert_eq!(
|
||||
normalize_admin_status(Some("Available")).unwrap(),
|
||||
Some("available")
|
||||
);
|
||||
assert!(normalize_admin_status(Some("unknown")).is_err());
|
||||
assert_eq!(
|
||||
normalize_benefit_filter(Some(" PLAN ")).unwrap(),
|
||||
Some("plan".to_string())
|
||||
);
|
||||
assert!(normalize_benefit_filter(Some("gift")).is_err());
|
||||
assert!(normalize_admin_keyword(Some(&"字".repeat(101))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_pattern_treats_sql_wildcards_as_literals() {
|
||||
assert_eq!(
|
||||
contains_pattern(r"sale_100%\batch"),
|
||||
r"%sale\_100\%\\batch%"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_actions_only_accept_supported_operations() {
|
||||
assert!(matches!(
|
||||
BatchCodeAction::parse(" ENABLE ").unwrap(),
|
||||
BatchCodeAction::Enable
|
||||
));
|
||||
assert!(matches!(
|
||||
BatchCodeAction::parse("delete").unwrap(),
|
||||
BatchCodeAction::Delete
|
||||
));
|
||||
assert!(BatchCodeAction::parse("redeem").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user