Skip to main content

tdm_server_rust/repository/
reward_repo.rs

1//! 奖励数据访问层 (Reward Repository)
2//!
3//! 封装 `rewardtb`、`rewardrecord`、`rewardticket` 等表的查询(SeaORM)。
4
5use crate::db::DbConn;
6use crate::entity::reward::{
7    EventBalanceDto, RewardRecordResultDto, RewardTicketResultDto, RewardTicketTransferLog,
8    RewardTicketsDetailDto, RewardWinnerDto, Rewardtb,
9};
10use crate::sea_entity::{
11    membertb, rewardrecordtb, rewardtb, rewardticketnumbertb, rewardtickettb,
12    rewardtickettransferlog,
13};
14use chrono::{DateTime, Utc};
15use sea_orm::{
16    sea_query::Expr, ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection,
17    EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
18};
19use sqlx::postgres::PgPool;
20use std::collections::HashMap;
21use uuid::Uuid;
22
23/// 奖品完整行
24#[derive(Debug, Clone)]
25pub struct RewardRow {
26    /// 奖品 ID
27    pub reward_id: i32,
28    /// 名称
29    pub name: Option<String>,
30    /// 价格
31    pub price: Option<i32>,
32    /// 类别
33    pub reward_category: Option<i16>,
34    /// 物理类别
35    pub physical_category: Option<i16>,
36    /// 库存
37    pub stock: Option<i32>,
38    /// 截止时间(API UTC)
39    pub dead_line: Option<DateTime<Utc>>,
40    /// 活动 ID
41    pub from_event_id: Option<i32>,
42}
43
44/// 奖券账户行
45#[derive(Debug, Clone)]
46pub struct TicketNumberRow {
47    /// 组员 ID
48    pub member_id: i32,
49    /// 兑换余额
50    pub exchange_balance: i32,
51    /// 抽奖余额
52    pub lucky_balance: i32,
53    /// 总券数
54    pub total: i32,
55    /// 活动 ID
56    pub event_id: Option<i32>,
57}
58
59/// 奖励仓储
60pub struct RewardRepository {
61    /// SeaORM 数据库连接
62    db: DbConn,
63}
64
65impl RewardRepository {
66    /// 从 `PgPool` 构造
67    pub fn new(pool: PgPool) -> Self {
68        Self {
69            db: crate::db::from_sqlx_pool(pool),
70        }
71    }
72
73    /// 从 `DatabaseConnection` 构造
74    pub fn from_db(db: DatabaseConnection) -> Self {
75        Self { db }
76    }
77
78    /// 查询活动奖品列表
79    #[tracing::instrument(skip_all, level = "debug")]
80    pub async fn list_rewards(&self, event_id: i32) -> crate::error::ApiResult<Vec<Rewardtb>> {
81        let rows = rewardtb::Entity::find()
82            .filter(rewardtb::Column::FromEventId.eq(event_id))
83            .order_by_asc(rewardtb::Column::RewardId)
84            .all(&self.db)
85            .await?;
86        Ok(rows.into_iter().map(model_to_rewardtb).collect())
87    }
88
89    /// 按 ID 查询奖品
90    #[tracing::instrument(skip_all, level = "debug")]
91    pub async fn get_reward_by_id(
92        &self,
93        reward_id: i32,
94    ) -> crate::error::ApiResult<Option<RewardRow>> {
95        let row = rewardtb::Entity::find_by_id(reward_id)
96            .one(&self.db)
97            .await?;
98        Ok(row.map(model_to_reward_row))
99    }
100
101    /// 扣减兑换余额
102    #[tracing::instrument(skip_all, level = "debug")]
103    pub async fn deduct_exchange_balance(
104        &self,
105        member_id: i32,
106        amount: i32,
107    ) -> crate::error::ApiResult<u64> {
108        let r = rewardticketnumbertb::Entity::update_many()
109            .col_expr(
110                rewardticketnumbertb::Column::ExchangeBalance,
111                Expr::col(rewardticketnumbertb::Column::ExchangeBalance).sub(Expr::value(amount)),
112            )
113            .filter(rewardticketnumbertb::Column::MemberId.eq(member_id))
114            .filter(rewardticketnumbertb::Column::ExchangeBalance.gte(amount))
115            .exec(&self.db)
116            .await?;
117        Ok(r.rows_affected)
118    }
119
120    /// 扣减抽奖余额
121    #[tracing::instrument(skip_all, level = "debug")]
122    pub async fn deduct_lucky_balance(
123        &self,
124        member_id: i32,
125        amount: i32,
126    ) -> crate::error::ApiResult<u64> {
127        let r = rewardticketnumbertb::Entity::update_many()
128            .col_expr(
129                rewardticketnumbertb::Column::LuckyBalance,
130                Expr::col(rewardticketnumbertb::Column::LuckyBalance).sub(Expr::value(amount)),
131            )
132            .filter(rewardticketnumbertb::Column::MemberId.eq(member_id))
133            .filter(rewardticketnumbertb::Column::LuckyBalance.gte(amount))
134            .exec(&self.db)
135            .await?;
136        Ok(r.rows_affected)
137    }
138
139    /// 查询组员奖券账户
140    #[tracing::instrument(skip_all, level = "debug")]
141    pub async fn get_ticket_number(
142        &self,
143        member_id: i32,
144    ) -> crate::error::ApiResult<Option<TicketNumberRow>> {
145        let row = rewardticketnumbertb::Entity::find()
146            .filter(rewardticketnumbertb::Column::MemberId.eq(member_id))
147            .one(&self.db)
148            .await?;
149        Ok(row.map(model_to_ticket_number))
150    }
151
152    /// 新增兑换记录
153    #[tracing::instrument(skip_all, level = "debug")]
154    pub async fn insert_reward_record(
155        &self,
156        member_id: i32,
157        reward_id: i32,
158        exchange_number: i32,
159    ) -> crate::error::ApiResult<i64> {
160        Self::insert_reward_record_with(&self.db, member_id, reward_id, exchange_number).await
161    }
162
163    /// 批量新增抽奖券
164    #[tracing::instrument(skip_all, level = "debug")]
165    pub async fn insert_lucky_tickets(
166        &self,
167        member_id: i32,
168        reward_id: i32,
169        event_id: i32,
170        physical_category: Option<i16>,
171        count: i32,
172    ) -> crate::error::ApiResult<()> {
173        let pc = physical_category.unwrap_or(0) as i32;
174        for _ in 0..count {
175            rewardtickettb::ActiveModel {
176                reward_ticket_id: Set(Uuid::new_v4().to_string()),
177                member_id: Set(member_id),
178                reward_id: Set(reward_id),
179                from_event_id: Set(event_id),
180                status: Set(1),
181                physical_category: Set(pc),
182                ..Default::default()
183            }
184            .insert(&self.db)
185            .await?;
186        }
187        Ok(())
188    }
189
190    /// 查询兑换记录
191    #[tracing::instrument(skip_all, level = "debug")]
192    pub async fn select_reward_list(
193        &self,
194        member_id: i32,
195    ) -> crate::error::ApiResult<Vec<RewardRecordResultDto>> {
196        let records = rewardrecordtb::Entity::find()
197            .filter(rewardrecordtb::Column::MemberId.eq(member_id))
198            .order_by_desc(rewardrecordtb::Column::ExchangeTime)
199            .all(&self.db)
200            .await?;
201        if records.is_empty() {
202            return Ok(vec![]);
203        }
204        let reward_ids: Vec<i32> = records.iter().map(|r| r.reward_id).collect();
205        let rewards: HashMap<i32, rewardtb::Model> = rewardtb::Entity::find()
206            .filter(rewardtb::Column::RewardId.is_in(reward_ids))
207            .all(&self.db)
208            .await?
209            .into_iter()
210            .map(|r| (r.reward_id, r))
211            .collect();
212        Ok(records
213            .iter()
214            .map(|rec| {
215                let reward = rewards.get(&rec.reward_id);
216                RewardRecordResultDto {
217                    reward_record_id: Some(rec.reward_record_id),
218                    reward_id: Some(rec.reward_id),
219                    exchange_time: rec
220                        .exchange_time
221                        .map(crate::utils::legacy_time::naive_db_to_api_utc),
222                    exchange_number: Some(rec.exchange_number),
223                    tracking_number: rec.tracking_number.clone(),
224                    reward_name: reward.map(|r| r.name.clone()),
225                    reward_image: reward.and_then(|r| r.image.clone()),
226                    get_method: reward.map(|r| r.reward_category),
227                }
228            })
229            .collect())
230    }
231
232    /// 查询奖券汇总
233    #[tracing::instrument(skip_all, level = "debug")]
234    pub async fn get_reward_tickets(
235        &self,
236        member_id: i32,
237    ) -> crate::error::ApiResult<Vec<RewardTicketResultDto>> {
238        let tickets = rewardtickettb::Entity::find()
239            .filter(rewardtickettb::Column::MemberId.eq(member_id))
240            .all(&self.db)
241            .await?;
242        if tickets.is_empty() {
243            return Ok(vec![]);
244        }
245        let reward_ids: Vec<i32> = tickets.iter().map(|t| t.reward_id).collect();
246        let rewards: HashMap<i32, rewardtb::Model> = rewardtb::Entity::find()
247            .filter(rewardtb::Column::RewardId.is_in(reward_ids))
248            .all(&self.db)
249            .await?
250            .into_iter()
251            .map(|r| (r.reward_id, r))
252            .collect();
253
254        let mut groups: HashMap<i32, Vec<&rewardtickettb::Model>> = HashMap::new();
255        for t in &tickets {
256            groups.entry(t.reward_id).or_default().push(t);
257        }
258        let mut out: Vec<RewardTicketResultDto> = groups
259            .into_iter()
260            .map(|(reward_id, group)| {
261                let reward = rewards.get(&reward_id);
262                let status = group.iter().map(|t| t.status).max();
263                let gacha_time = group
264                    .iter()
265                    .filter_map(|t| t.gacha_time)
266                    .max()
267                    .map(crate::utils::legacy_time::naive_db_to_api_utc);
268                RewardTicketResultDto {
269                    ticket_number: Some(group.len() as i32),
270                    member_id: Some(member_id),
271                    reward_id: Some(reward_id),
272                    status,
273                    reward_name: reward.map(|r| r.name.clone()),
274                    reward_image: reward.and_then(|r| r.image.clone()),
275                    gacha_time,
276                }
277            })
278            .collect();
279        out.sort_by(|a, b| b.gacha_time.cmp(&a.gacha_time));
280        Ok(out)
281    }
282
283    /// 随机抽取中奖者(Entity 加载候选券后在 Rust 中随机选取)
284    #[tracing::instrument(skip_all, level = "debug")]
285    pub async fn select_winner(
286        &self,
287        reward_id: i32,
288        event_id: i32,
289    ) -> crate::error::ApiResult<Option<RewardWinnerDto>> {
290        Self::select_random_winner_with(&self.db, reward_id, event_id, 1).await
291    }
292
293    /// 封锁中奖者同类奖券
294    #[tracing::instrument(skip_all, level = "debug")]
295    pub async fn block_winner(
296        &self,
297        event_id: i32,
298        status: i16,
299        winner_id: i32,
300        physical_category: Option<i16>,
301    ) -> crate::error::ApiResult<()> {
302        Self::block_winner_with(&self.db, event_id, status, winner_id, physical_category).await
303    }
304
305    /// 更新奖券状态
306    #[tracing::instrument(skip_all, level = "debug")]
307    pub async fn update_ticket_status(
308        &self,
309        ticket_id: &str,
310        status: i16,
311        record_id: i64,
312    ) -> crate::error::ApiResult<()> {
313        Self::update_ticket_status_with(&self.db, ticket_id, status, record_id).await
314    }
315
316    /// 扣减奖品库存
317    #[tracing::instrument(skip_all, level = "debug")]
318    pub async fn decrement_stock(
319        &self,
320        reward_id: i32,
321        event_id: i32,
322        count: i32,
323    ) -> crate::error::ApiResult<u64> {
324        Self::decrement_stock_with(&self.db, reward_id, event_id, count).await
325    }
326
327    /// 标记未中奖券
328    #[tracing::instrument(skip_all, level = "debug")]
329    pub async fn mark_non_win_tickets(&self, reward_id: i32) -> crate::error::ApiResult<()> {
330        Self::mark_non_win_tickets_with(&self.db, reward_id).await
331    }
332
333    /// 事务内:查询奖品物理类别与库存(开奖用)
334    pub(crate) async fn get_reward_draw_info_with<C: ConnectionTrait>(
335        conn: &C,
336        reward_id: i32,
337    ) -> crate::error::ApiResult<Option<(Option<i16>, i32)>> {
338        let row = rewardtb::Entity::find_by_id(reward_id).one(conn).await?;
339        Ok(row.map(|r| (Some(r.physical_category as i16), r.stock)))
340    }
341
342    /// 事务内:从奖池随机抽取一张券及组员名
343    pub(crate) async fn select_random_winner_with<C: ConnectionTrait>(
344        conn: &C,
345        reward_id: i32,
346        event_id: i32,
347        available_status: i16,
348    ) -> crate::error::ApiResult<Option<RewardWinnerDto>> {
349        let mut tickets = rewardtickettb::Entity::find()
350            .filter(rewardtickettb::Column::RewardId.eq(reward_id))
351            .filter(rewardtickettb::Column::FromEventId.eq(event_id))
352            .filter(rewardtickettb::Column::Status.eq(available_status as i32))
353            .all(conn)
354            .await?;
355        if tickets.is_empty() {
356            return Ok(None);
357        }
358        tickets.sort_by(|a, b| a.reward_ticket_id.cmp(&b.reward_ticket_id));
359        let idx = random_ticket_index(tickets.len());
360        let ticket = &tickets[idx];
361        let member = membertb::Entity::find_by_id(ticket.member_id)
362            .one(conn)
363            .await?;
364        Ok(Some(RewardWinnerDto {
365            member_id: ticket.member_id,
366            member_name: member.map(|m| m.username),
367            ticket_id: Some(ticket.reward_ticket_id.clone()),
368        }))
369    }
370
371    /// 事务内:封锁中奖者同类奖券
372    pub(crate) async fn block_winner_with<C: ConnectionTrait>(
373        conn: &C,
374        event_id: i32,
375        status: i16,
376        winner_id: i32,
377        physical_category: Option<i16>,
378    ) -> crate::error::ApiResult<()> {
379        let pc = physical_category.unwrap_or(0) as i32;
380        rewardtickettb::Entity::update_many()
381            .col_expr(rewardtickettb::Column::Status, Expr::value(status as i32))
382            .filter(rewardtickettb::Column::MemberId.eq(winner_id))
383            .filter(rewardtickettb::Column::FromEventId.eq(event_id))
384            .filter(rewardtickettb::Column::PhysicalCategory.eq(pc))
385            .exec(conn)
386            .await?;
387        Ok(())
388    }
389
390    /// 事务内:新增兑换/中奖记录
391    pub(crate) async fn insert_reward_record_with<C: ConnectionTrait>(
392        conn: &C,
393        member_id: i32,
394        reward_id: i32,
395        exchange_number: i32,
396    ) -> crate::error::ApiResult<i64> {
397        let now = chrono::Utc::now().naive_utc();
398        let inserted = rewardrecordtb::ActiveModel {
399            member_id: Set(member_id),
400            reward_id: Set(reward_id),
401            exchange_number: Set(exchange_number),
402            tracking_number: Set(Some("想起来了的话会填的喵~".to_string())),
403            exchange_time: Set(Some(now)),
404            ..Default::default()
405        }
406        .insert(conn)
407        .await?;
408        Ok(inserted.reward_record_id as i64)
409    }
410
411    /// 事务内:更新奖券状态并关联兑换记录
412    pub(crate) async fn update_ticket_status_with<C: ConnectionTrait>(
413        conn: &C,
414        ticket_id: &str,
415        status: i16,
416        record_id: i64,
417    ) -> crate::error::ApiResult<()> {
418        rewardtickettb::Entity::update_many()
419            .col_expr(rewardtickettb::Column::Status, Expr::value(status as i32))
420            .col_expr(
421                rewardtickettb::Column::RewardRecordId,
422                Expr::value(record_id as i32),
423            )
424            .filter(rewardtickettb::Column::RewardTicketId.eq(ticket_id))
425            .exec(conn)
426            .await?;
427        Ok(())
428    }
429
430    /// 事务内:扣减奖品库存
431    pub(crate) async fn decrement_stock_with<C: ConnectionTrait>(
432        conn: &C,
433        reward_id: i32,
434        event_id: i32,
435        count: i32,
436    ) -> crate::error::ApiResult<u64> {
437        let r = rewardtb::Entity::update_many()
438            .col_expr(
439                rewardtb::Column::Stock,
440                Expr::col(rewardtb::Column::Stock).sub(Expr::value(count)),
441            )
442            .filter(rewardtb::Column::RewardId.eq(reward_id))
443            .filter(rewardtb::Column::FromEventId.eq(event_id))
444            .filter(rewardtb::Column::Stock.gte(count))
445            .exec(conn)
446            .await?;
447        Ok(r.rows_affected)
448    }
449
450    /// 事务内:库存归零后将剩余待开奖券标为未中奖
451    pub(crate) async fn mark_non_win_tickets_with<C: ConnectionTrait>(
452        conn: &C,
453        reward_id: i32,
454    ) -> crate::error::ApiResult<()> {
455        rewardtickettb::Entity::update_many()
456            .col_expr(rewardtickettb::Column::Status, Expr::value(2))
457            .filter(rewardtickettb::Column::RewardId.eq(reward_id))
458            .filter(rewardtickettb::Column::Status.eq(1))
459            .exec(conn)
460            .await?;
461        Ok(())
462    }
463
464    /// 检查活动是否已经开奖
465    #[tracing::instrument(skip_all, level = "debug")]
466    pub async fn has_opened_rewards(&self, event_id: i32) -> crate::error::ApiResult<bool> {
467        let row = rewardtb::Entity::find()
468            .filter(rewardtb::Column::FromEventId.eq(event_id))
469            .filter(Expr::col(rewardtb::Column::Stock).lt(Expr::col(rewardtb::Column::TotalNumber)))
470            .one(&self.db)
471            .await?;
472        Ok(row.is_some())
473    }
474
475    /// 更新活动下所有奖品截止时间
476    #[tracing::instrument(skip_all, level = "debug")]
477    pub async fn update_event_deadline(
478        &self,
479        event_id: i32,
480        end_time: DateTime<Utc>,
481    ) -> crate::error::ApiResult<()> {
482        let naive = crate::utils::legacy_time::api_to_db_naive(end_time);
483        rewardtb::Entity::update_many()
484            .col_expr(rewardtb::Column::DeadLine, Expr::value(naive))
485            .filter(rewardtb::Column::FromEventId.eq(event_id))
486            .exec(&self.db)
487            .await?;
488        Ok(())
489    }
490
491    /// 查询活动余额
492    #[tracing::instrument(skip_all, level = "debug")]
493    pub async fn get_reward_balance(
494        &self,
495        member_id: i32,
496        event_id: i32,
497    ) -> crate::error::ApiResult<EventBalanceDto> {
498        let row = rewardticketnumbertb::Entity::find()
499            .filter(rewardticketnumbertb::Column::MemberId.eq(member_id))
500            .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
501            .one(&self.db)
502            .await?;
503        Ok(match row {
504            Some(r) => EventBalanceDto {
505                user_id: member_id,
506                event_id,
507                exchange_balance: Some(r.exchange_balance),
508                lucky_balance: Some(r.lucky_balance),
509                total_tickets: Some(r.total),
510            },
511            None => EventBalanceDto {
512                user_id: member_id,
513                event_id,
514                exchange_balance: Some(0),
515                lucky_balance: Some(0),
516                total_tickets: Some(0),
517            },
518        })
519    }
520
521    /// 查询奖券明细
522    #[tracing::instrument(skip_all, level = "debug")]
523    pub async fn get_ticket_details(
524        &self,
525        member_id: i32,
526    ) -> crate::error::ApiResult<Vec<RewardTicketsDetailDto>> {
527        let rows = rewardtickettb::Entity::find()
528            .filter(rewardtickettb::Column::MemberId.eq(member_id))
529            .all(&self.db)
530            .await?;
531        Ok(rows
532            .into_iter()
533            .map(|r| RewardTicketsDetailDto {
534                reward_ticket_id: Some(r.reward_ticket_id),
535                from_event_id: Some(r.from_event_id),
536                reward_id: Some(r.reward_id),
537                status: Some(r.status),
538            })
539            .collect())
540    }
541
542    /// 检测转账目标账户
543    #[tracing::instrument(skip_all, level = "debug")]
544    pub async fn check_account_exists(&self, member_id: i32) -> crate::error::ApiResult<bool> {
545        let n = membertb::Entity::find_by_id(member_id)
546            .count(&self.db)
547            .await?;
548        Ok(n > 0)
549    }
550
551    /// 检测指定活动余额账户是否存在
552    #[tracing::instrument(skip_all, level = "debug")]
553    pub async fn check_balance_account_exists(
554        &self,
555        member_id: i32,
556        event_id: i32,
557    ) -> crate::error::ApiResult<bool> {
558        let n = rewardticketnumbertb::Entity::find()
559            .filter(rewardticketnumbertb::Column::MemberId.eq(member_id))
560            .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
561            .count(&self.db)
562            .await?;
563        Ok(n > 0)
564    }
565
566    /// 创建余额账户
567    #[tracing::instrument(skip_all, level = "debug")]
568    pub async fn create_balance_account(
569        &self,
570        member_id: i32,
571        event_id: i32,
572    ) -> crate::error::ApiResult<()> {
573        rewardticketnumbertb::ActiveModel {
574            member_id: Set(member_id),
575            event_id: Set(event_id),
576            exchange_balance: Set(0),
577            lucky_balance: Set(0),
578            total: Set(0),
579            ..Default::default()
580        }
581        .insert(&self.db)
582        .await?;
583        Ok(())
584    }
585
586    /// 转账扣减余额
587    #[tracing::instrument(skip_all, level = "debug")]
588    pub async fn transfer_decr(
589        &self,
590        from_id: i32,
591        event_id: i32,
592        ticket_type: i32,
593        amount: i32,
594    ) -> crate::error::ApiResult<u64> {
595        let r = match ticket_type {
596            2 => {
597                rewardticketnumbertb::Entity::update_many()
598                    .col_expr(
599                        rewardticketnumbertb::Column::ExchangeBalance,
600                        Expr::col(rewardticketnumbertb::Column::ExchangeBalance)
601                            .sub(Expr::value(amount)),
602                    )
603                    .filter(rewardticketnumbertb::Column::MemberId.eq(from_id))
604                    .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
605                    .filter(rewardticketnumbertb::Column::ExchangeBalance.gte(amount))
606                    .exec(&self.db)
607                    .await?
608            }
609            3 => {
610                rewardticketnumbertb::Entity::update_many()
611                    .col_expr(
612                        rewardticketnumbertb::Column::LuckyBalance,
613                        Expr::col(rewardticketnumbertb::Column::LuckyBalance)
614                            .sub(Expr::value(amount)),
615                    )
616                    .filter(rewardticketnumbertb::Column::MemberId.eq(from_id))
617                    .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
618                    .filter(rewardticketnumbertb::Column::LuckyBalance.gte(amount))
619                    .exec(&self.db)
620                    .await?
621            }
622            _ => return Err(crate::error::AppError::business("无效的券类型")),
623        };
624        Ok(r.rows_affected)
625    }
626
627    /// 转账增加余额
628    #[tracing::instrument(skip_all, level = "debug")]
629    pub async fn transfer_incr(
630        &self,
631        to_id: i32,
632        event_id: i32,
633        ticket_type: i32,
634        amount: i32,
635    ) -> crate::error::ApiResult<()> {
636        match ticket_type {
637            2 => {
638                rewardticketnumbertb::Entity::update_many()
639                    .col_expr(
640                        rewardticketnumbertb::Column::ExchangeBalance,
641                        Expr::col(rewardticketnumbertb::Column::ExchangeBalance)
642                            .add(Expr::value(amount)),
643                    )
644                    .filter(rewardticketnumbertb::Column::MemberId.eq(to_id))
645                    .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
646                    .exec(&self.db)
647                    .await?;
648            }
649            3 => {
650                rewardticketnumbertb::Entity::update_many()
651                    .col_expr(
652                        rewardticketnumbertb::Column::LuckyBalance,
653                        Expr::col(rewardticketnumbertb::Column::LuckyBalance)
654                            .add(Expr::value(amount)),
655                    )
656                    .filter(rewardticketnumbertb::Column::MemberId.eq(to_id))
657                    .filter(rewardticketnumbertb::Column::EventId.eq(event_id))
658                    .exec(&self.db)
659                    .await?;
660            }
661            _ => return Err(crate::error::AppError::business("无效的券类型")),
662        }
663        Ok(())
664    }
665
666    /// 写入转账日志
667    #[tracing::instrument(skip_all, level = "debug")]
668    pub async fn insert_transfer_log(
669        &self,
670        from_id: i32,
671        to_id: i32,
672        event_id: i32,
673        ticket_type: i32,
674        amount: i32,
675    ) -> crate::error::ApiResult<()> {
676        rewardtickettransferlog::ActiveModel {
677            from_member_id: Set(from_id),
678            to_member_id: Set(to_id),
679            event_id: Set(event_id),
680            ticket_type: Set(ticket_type),
681            amount: Set(amount),
682            transfer_time: Set(Some(chrono::Utc::now().naive_utc())),
683            ..Default::default()
684        }
685        .insert(&self.db)
686        .await?;
687        Ok(())
688    }
689
690    /// 查询转账日志
691    #[tracing::instrument(skip_all, level = "debug")]
692    pub async fn get_transfer_logs(
693        &self,
694        member_id: i32,
695    ) -> crate::error::ApiResult<Vec<RewardTicketTransferLog>> {
696        use sea_orm::Condition;
697        let rows = rewardtickettransferlog::Entity::find()
698            .filter(
699                Condition::any()
700                    .add(rewardtickettransferlog::Column::FromMemberId.eq(member_id))
701                    .add(rewardtickettransferlog::Column::ToMemberId.eq(member_id)),
702            )
703            .order_by_desc(rewardtickettransferlog::Column::TransferTime)
704            .all(&self.db)
705            .await?;
706        Ok(rows
707            .into_iter()
708            .map(|r| RewardTicketTransferLog {
709                log_id: Some(r.log_id),
710                from_member_id: r.from_member_id,
711                to_member_id: r.to_member_id,
712                event_id: r.event_id,
713                ticket_type: r.ticket_type,
714                amount: r.amount,
715                transfer_time: r
716                    .transfer_time
717                    .map(crate::utils::legacy_time::naive_db_to_api_utc),
718            })
719            .collect())
720    }
721}
722
723/// Model 转奖品列表 DTO
724fn model_to_rewardtb(m: rewardtb::Model) -> Rewardtb {
725    Rewardtb {
726        reward_id: Some(m.reward_id),
727        name: Some(m.name),
728        image: m.image,
729        from_event: m.from_event,
730        from_event_id: Some(m.from_event_id),
731        reward_category: Some(m.reward_category as i16),
732        physical_category: Some(m.physical_category as i16),
733        price: Some(m.price),
734        total_number: Some(m.total_number),
735        stock: Some(m.stock),
736        dead_line: m
737            .dead_line
738            .map(crate::utils::legacy_time::naive_db_to_api_utc),
739        sponsor: m.sponsor,
740        sponsor_id: m.sponsor_id,
741    }
742}
743
744/// Model 转奖品行
745fn model_to_reward_row(m: rewardtb::Model) -> RewardRow {
746    RewardRow {
747        reward_id: m.reward_id,
748        name: Some(m.name),
749        price: Some(m.price),
750        reward_category: Some(m.reward_category as i16),
751        physical_category: Some(m.physical_category as i16),
752        stock: Some(m.stock),
753        dead_line: m
754            .dead_line
755            .map(crate::utils::legacy_time::naive_db_to_api_utc),
756        from_event_id: Some(m.from_event_id),
757    }
758}
759
760/// Model 转奖券账户行
761fn model_to_ticket_number(m: rewardticketnumbertb::Model) -> TicketNumberRow {
762    TicketNumberRow {
763        member_id: m.member_id,
764        exchange_balance: m.exchange_balance,
765        lucky_balance: m.lucky_balance,
766        total: m.total,
767        event_id: Some(m.event_id),
768    }
769}
770
771/// 在已排序奖券池中取随机下标(对齐原 SQL `ORDER BY ticketId` + `OFFSET random()`)
772fn random_ticket_index(len: usize) -> usize {
773    if len == 0 {
774        return 0;
775    }
776    (Uuid::new_v4().as_u128() as usize) % len
777}