Skip to main content

tdm_server_rust/service/
reward_service.rs

1//! 奖励业务服务 (Reward Service)
2//!
3//! 悬赏/奖励/抽奖系统的业务逻辑:
4//! - 活动余额查询
5//! - 奖品兑换与库存扣减
6//! - 奖券抽选(随机中奖者)
7//! - 券转账与日志
8
9use crate::{
10    app::AppState,
11    entity::reward::{
12        EventBalanceDto, RewardRecordResultDto, RewardTicketResultDto, RewardTicketTransferLog,
13        RewardTicketTransferRequest, RewardTicketsDetailDto, RewardWinnerDto, Rewardtb,
14    },
15    error::{ApiResult, AppError},
16    repository::reward_repo::RewardRepository,
17};
18use chrono::NaiveDateTime;
19use dashmap::{mapref::entry::Entry, DashMap};
20use sqlx::Row;
21use std::sync::OnceLock;
22
23/// 兑换类奖品
24const REWARD_CATEGORY_EXCHANGE: i16 = 2;
25/// 抽奖类奖品
26const REWARD_CATEGORY_LUCKY: i16 = 3;
27/// 有效且待开奖
28const TICKET_STATUS_AVAILABLE: i16 = 1;
29/// 有效但未中奖
30const TICKET_STATUS_NON_WIN: i16 = 2;
31/// 因中同类奖作废
32const TICKET_STATUS_SAME_TYPE_UNAVAILABLE: i16 = 3;
33/// 已中奖
34const TICKET_STATUS_WIN: i16 = 4;
35/// 抽奖主持人组员 ID
36const REWARD_HOST_MEMBER_ID: i32 = 154;
37
38/// 正在处理中的转账请求
39static TRANSFER_REQUESTS: OnceLock<DashMap<String, ()>> = OnceLock::new();
40
41/// 奖励服务
42pub struct RewardService;
43
44impl RewardService {
45    /// 查询活动余额
46    #[tracing::instrument(skip_all, level = "debug")]
47    pub async fn get_reward_balance(
48        state: &AppState,
49        member_id: i32,
50        event_id: i32,
51    ) -> ApiResult<EventBalanceDto> {
52        let repo = RewardRepository::new(state.db.clone());
53        if !repo.check_account_exists(member_id).await? {
54            repo.create_balance_account(member_id, event_id).await?;
55        }
56        repo.get_reward_balance(member_id, event_id).await
57    }
58
59    /// 奖品列表(默认活动 1)
60    #[tracing::instrument(skip_all, level = "debug")]
61    pub async fn get_rewards_list(state: &AppState) -> ApiResult<Vec<Rewardtb>> {
62        RewardRepository::new(state.db.clone())
63            .list_rewards(1)
64            .await
65    }
66
67    /// 兑换奖品:兑换类写获奖记录,抽奖类写入奖池。
68    ///
69    /// # Errors
70    ///
71    /// - `AppError::business("奖品不存在喵")` — reward_id 无效
72    /// - `AppError::business("余额不足喵")` — 兑换余额不足
73    /// - `AppError::Database` — 数据库操作失败
74    #[tracing::instrument(skip_all, level = "debug")]
75    pub async fn exchange(
76        state: &AppState,
77        member_id: i32,
78        reward_id: i32,
79        exchange_number: i32,
80        event_id: i32,
81    ) -> ApiResult<()> {
82        let repo = RewardRepository::new(state.db.clone());
83        let reward = repo
84            .get_reward_by_id(reward_id)
85            .await?
86            .ok_or_else(|| AppError::business("奖品不存在喵"))?;
87        if is_deadline_expired(reward.dead_line) {
88            return Err(AppError::business("已经超过兑换时间了哦~"));
89        }
90
91        let price = reward.price.unwrap_or(0) * exchange_number;
92        match reward.reward_category.unwrap_or_default() {
93            REWARD_CATEGORY_EXCHANGE => {
94                if repo.deduct_exchange_balance(member_id, price).await? == 0 {
95                    return Err(AppError::business("余额不足喵,下次再加油吧~"));
96                }
97                repo.insert_reward_record(member_id, reward_id, exchange_number)
98                    .await?;
99            }
100            REWARD_CATEGORY_LUCKY => {
101                let ticket_number = repo
102                    .get_ticket_number(member_id)
103                    .await?
104                    .ok_or_else(|| AppError::business("这位猫娘没有奖券呢,下次一起参加活动吧~"))?;
105                if ticket_number.total < 12 {
106                    return Err(AppError::business("奖券没有达到参与标准哦,下次加油吧~"));
107                }
108                if ticket_number.lucky_balance < price {
109                    return Err(AppError::business("抽奖券不够啦,请耐心等待结果哟亲亲~"));
110                }
111                if repo.deduct_lucky_balance(member_id, price).await? == 0 {
112                    return Err(AppError::business("剩余抽奖券不足了哦~"));
113                }
114                repo.insert_lucky_tickets(
115                    member_id,
116                    reward_id,
117                    event_id,
118                    reward.physical_category,
119                    exchange_number,
120                )
121                .await?;
122            }
123            _ => {
124                return Err(AppError::business(
125                    "奖品类型有误哦,请确认后再兑换或联系幻廊",
126                ));
127            }
128        }
129        Ok(())
130    }
131
132    /// 兑换记录
133    #[tracing::instrument(skip_all, level = "debug")]
134    pub async fn get_reward_record(
135        state: &AppState,
136        member_id: i32,
137    ) -> ApiResult<Vec<RewardRecordResultDto>> {
138        RewardRepository::new(state.db.clone())
139            .select_reward_list(member_id)
140            .await
141    }
142
143    /// 奖券列表
144    #[tracing::instrument(skip_all, level = "debug")]
145    pub async fn get_reward_tickets(
146        state: &AppState,
147        member_id: i32,
148    ) -> ApiResult<Vec<RewardTicketResultDto>> {
149        RewardRepository::new(state.db.clone())
150            .get_reward_tickets(member_id)
151            .await
152    }
153
154    /// 奖券详情
155    #[tracing::instrument(skip_all, level = "debug")]
156    pub async fn get_tickets_details(
157        state: &AppState,
158        member_id: i32,
159    ) -> ApiResult<Vec<RewardTicketsDetailDto>> {
160        RewardRepository::new(state.db.clone())
161            .get_ticket_details(member_id)
162            .await
163    }
164
165    /// 抽取中奖者
166    #[tracing::instrument(skip_all, level = "debug")]
167    pub async fn select_winner(
168        state: &AppState,
169        member_id: i32,
170        reward_id: i32,
171        event_id: i32,
172        winner_number: i32,
173    ) -> ApiResult<Vec<RewardWinnerDto>> {
174        if member_id != REWARD_HOST_MEMBER_ID {
175            return Err(AppError::business("请不要冒充主持人哦~"));
176        }
177        let mut tx = state.db.begin().await?;
178        let reward_row = sqlx::query(
179            "SELECT rewardId, physicalCategory, stock FROM rewardtb WHERE rewardId = ?",
180        )
181        .bind(reward_id)
182        .fetch_optional(&mut *tx)
183        .await?
184        .ok_or_else(|| AppError::business("奖品不存在,请确认选项或联系管理员"))?;
185        let physical_category: Option<i16> = reward_row.try_get("physicalCategory").ok();
186        let mut stock: i32 = reward_row.try_get("stock").unwrap_or(0);
187        let mut winners = Vec::new();
188
189        for _ in 0..winner_number.max(0) {
190            let winner_row = sqlx::query(
191                "SELECT rt.memberId, m.username AS memberName, rt.rewardTicketId AS ticketId \
192                 FROM rewardtickettb rt LEFT JOIN membertb m ON rt.memberId = m.Id \
193                 WHERE rt.rewardId = ? AND rt.fromEventId = ? AND rt.status = ? \
194                 ORDER BY RAND() LIMIT 1",
195            )
196            .bind(reward_id)
197            .bind(event_id)
198            .bind(TICKET_STATUS_AVAILABLE)
199            .fetch_optional(&mut *tx)
200            .await?;
201
202            let Some(winner_row) = winner_row else {
203                break;
204            };
205            let winner = RewardWinnerDto {
206                member_id: winner_row.get("memberId"),
207                member_name: winner_row.try_get("memberName").ok(),
208                ticket_id: winner_row.try_get("ticketId").ok(),
209            };
210            sqlx::query(
211                "UPDATE rewardtickettb SET status = ? \
212                 WHERE memberId = ? AND fromEventId = ? AND physicalCategory = ?",
213            )
214            .bind(TICKET_STATUS_SAME_TYPE_UNAVAILABLE)
215            .bind(winner.member_id)
216            .bind(event_id)
217            .bind(physical_category)
218            .execute(&mut *tx)
219            .await?;
220
221            let record = sqlx::query(
222                "INSERT INTO rewardrecordtb(memberId, rewardId, exchangeNumber, trackingNumber, exchangeTime) \
223                 VALUES (?, ?, 1, '想起来了的话会填的喵~', NOW())",
224            )
225            .bind(winner.member_id)
226            .bind(reward_id)
227            .execute(&mut *tx)
228            .await?;
229
230            if let Some(ticket_id) = winner.ticket_id.as_deref() {
231                sqlx::query(
232                    "UPDATE rewardtickettb SET status = ?, rewardRecordID = ? WHERE rewardTicketId = ?",
233                )
234                .bind(TICKET_STATUS_WIN)
235                .bind(record.last_insert_id() as i64)
236                .bind(ticket_id)
237                .execute(&mut *tx)
238                .await?;
239            }
240            winners.push(winner);
241        }
242
243        let winner_count = winners.len() as i32;
244        let stock_update = sqlx::query(
245            "UPDATE rewardtb SET stock = stock - ? WHERE rewardId = ? AND fromEventId = ? AND stock >= ?",
246        )
247        .bind(winner_count)
248        .bind(reward_id)
249        .bind(event_id)
250        .bind(winner_count)
251        .execute(&mut *tx)
252        .await?;
253        if stock_update.rows_affected() != 1 {
254            return Err(AppError::business("奖品信息不正确或者名额不足啦"));
255        }
256        stock -= winner_count;
257        if stock == 0 {
258            sqlx::query("UPDATE rewardtickettb SET status = ? WHERE rewardId = ? AND status = ?")
259                .bind(TICKET_STATUS_NON_WIN)
260                .bind(reward_id)
261                .bind(TICKET_STATUS_AVAILABLE)
262                .execute(&mut *tx)
263                .await?;
264        }
265
266        tx.commit().await?;
267        Ok(winners)
268    }
269
270    /// 修改活动结束时间(对齐 Java:更新 rewardtb.deadLine)
271    #[tracing::instrument(skip_all, level = "debug")]
272    pub async fn change_end_time(
273        state: &AppState,
274        member_id: i32,
275        event_id: &str,
276        end_time: &str,
277    ) -> ApiResult<()> {
278        if member_id != REWARD_HOST_MEMBER_ID {
279            return Err(AppError::business("请不要冒充主持人哦~"));
280        }
281        let event_id: i32 = event_id
282            .parse()
283            .map_err(|_| AppError::business("活动ID无效喵"))?;
284        let repo = RewardRepository::new(state.db.clone());
285        if repo.has_opened_rewards(event_id).await? {
286            return Err(AppError::business(
287                "已经开过奖了,再修改时间会导致违反抽奖规则的情况出现,商量一下具体怎么处理吧喵~",
288            ));
289        }
290        let new_end_time = NaiveDateTime::parse_from_str(end_time, "%Y-%m-%d %H:%M:%S")
291            .map_err(|_| AppError::business("时间格式不正确设置不正确,转换失败,请联系幻廊"))?;
292        repo.update_event_deadline(event_id, new_end_time).await?;
293        Ok(())
294    }
295
296    /// 转账
297    #[tracing::instrument(skip_all, level = "debug")]
298    pub async fn transfer(
299        state: &AppState,
300        from_id: i32,
301        body: RewardTicketTransferRequest,
302    ) -> ApiResult<()> {
303        let repo = RewardRepository::new(state.db.clone());
304        if body.target_member_id == 0 {
305            return Err(AppError::business("怎么没有设置目标组员呀"));
306        }
307        if body.amount == 0 {
308            return Err(AppError::business("别转账0张券为难程序员呀"));
309        }
310        let request_key = format!(
311            "{}_{}_{}_{}_{}",
312            body.event_id, from_id, body.target_member_id, body.ticket_type, body.amount
313        );
314        let _guard = TransferRequestGuard::try_acquire(request_key)?;
315        if !repo.check_account_exists(body.target_member_id).await? {
316            return Err(AppError::business("转账目标的组员不存在,请确认后再执行"));
317        }
318        if !repo
319            .check_balance_account_exists(body.target_member_id, body.event_id)
320            .await?
321        {
322            repo.create_balance_account(body.target_member_id, body.event_id)
323                .await?;
324        }
325        if repo
326            .transfer_decr(from_id, body.event_id, body.ticket_type, body.amount)
327            .await?
328            == 0
329        {
330            return Err(AppError::business("余额不足喵"));
331        }
332        repo.transfer_incr(
333            body.target_member_id,
334            body.event_id,
335            body.ticket_type,
336            body.amount,
337        )
338        .await?;
339        repo.insert_transfer_log(
340            from_id,
341            body.target_member_id,
342            body.event_id,
343            body.ticket_type,
344            body.amount,
345        )
346        .await
347    }
348
349    /// 转账日志
350    #[tracing::instrument(skip_all, level = "debug")]
351    pub async fn get_transfer_log(
352        state: &AppState,
353        member_id: i32,
354    ) -> ApiResult<Vec<RewardTicketTransferLog>> {
355        RewardRepository::new(state.db.clone())
356            .get_transfer_logs(member_id)
357            .await
358    }
359}
360
361/// 判断奖品是否超过兑换截止时间
362fn is_deadline_expired(dead_line: Option<NaiveDateTime>) -> bool {
363    dead_line.is_some_and(|deadline| {
364        let now = chrono::Utc::now()
365            .with_timezone(&crate::utils::shanghai_time::shanghai_offset())
366            .naive_local();
367        now > deadline
368    })
369}
370
371/// 获取全局转账请求表
372fn transfer_request_map() -> &'static DashMap<String, ()> {
373    TRANSFER_REQUESTS.get_or_init(DashMap::new)
374}
375
376/// 转账防重复请求守卫
377struct TransferRequestGuard {
378    /// 防重复请求键
379    key: String,
380}
381
382impl TransferRequestGuard {
383    /// 尝试占用转账请求键
384    fn try_acquire(key: String) -> ApiResult<Self> {
385        match transfer_request_map().entry(key.clone()) {
386            Entry::Vacant(entry) => {
387                entry.insert(());
388                Ok(Self { key })
389            }
390            Entry::Occupied(_) => Err(AppError::business("正在转账中,请稍后")),
391        }
392    }
393}
394
395impl Drop for TransferRequestGuard {
396    /// 释放转账请求键
397    fn drop(&mut self) {
398        if let Some(map) = TRANSFER_REQUESTS.get() {
399            map.remove(&self.key);
400        }
401    }
402}