Explorar o código

feat(quota): 新增UserConsultationQuota entity+repository+QuotaService(方案B独立表,角色分流每日重置)

liaoxg hai 3 meses
pai
achega
c5021eeb74

+ 66 - 0
num-server/src/main/java/com/etotem/num/entity/UserConsultationQuota.java

@@ -0,0 +1,66 @@
+package com.etotem.num.entity;
+
+import javax.persistence.*;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * Tracks per-user, per-person consultation quota.
+ * <p>
+ * Each row represents one distinct person consulted by a user.
+ * {@code chat_count_today} is reset daily by {@code resetDailyQuota()}
+ * or automatically via the inline CASE WHEN logic in the atomic UPDATE.
+ * <p>
+ * Unique key (user_id, person_name) ensures one row per user-person pair.
+ */
+@Entity
+@Table(name = "user_consultation_quota",
+       uniqueConstraints = @UniqueConstraint(name = "uk_user_person", columnNames = {"user_id", "person_name"}),
+       indexes = @Index(name = "idx_user_date", columnList = "user_id, last_chat_date"))
+public class UserConsultationQuota {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "user_id", nullable = false)
+    private Long userId;
+
+    @Column(name = "person_name", nullable = false, length = 50)
+    private String personName;
+
+    @Column(name = "chat_count_today")
+    private Integer chatCountToday = 0;
+
+    @Column(name = "last_chat_date")
+    private LocalDate lastChatDate;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt = LocalDateTime.now();
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt = LocalDateTime.now();
+
+    // -- Getters and Setters --
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+
+    public String getPersonName() { return personName; }
+    public void setPersonName(String personName) { this.personName = personName; }
+
+    public Integer getChatCountToday() { return chatCountToday; }
+    public void setChatCountToday(Integer chatCountToday) { this.chatCountToday = chatCountToday; }
+
+    public LocalDate getLastChatDate() { return lastChatDate; }
+    public void setLastChatDate(LocalDate lastChatDate) { this.lastChatDate = lastChatDate; }
+
+    public LocalDateTime getCreatedAt() { return createdAt; }
+    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+
+    public LocalDateTime getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
+}

+ 75 - 0
num-server/src/main/java/com/etotem/num/repository/UserConsultationQuotaRepository.java

@@ -0,0 +1,75 @@
+package com.etotem.num.repository;
+
+import com.etotem.num.entity.UserConsultationQuota;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+/**
+ * Repository for {@link UserConsultationQuota}.
+ * <p>
+ * Provides person-count lookup, atomic chat-count increment with
+ * cross-day reset, and batch daily reset for scheduled tasks.
+ */
+@Repository
+public interface UserConsultationQuotaRepository extends JpaRepository<UserConsultationQuota, Long> {
+
+    /**
+     * Find quota record for a specific user-person pair.
+     */
+    Optional<UserConsultationQuota> findByUserIdAndPersonName(Long userId, String personName);
+
+    /**
+     * Count distinct persons a user has ever consulted.
+     * Equivalent to COUNT(DISTINCT person_name) due to uk_user_person uniqueness.
+     */
+    long countByUserId(Long userId);
+
+    /**
+     * INSERT IGNORE — create a quota row if it does not already exist.
+     * Sets last_chat_date to today so the subsequent UPDATE correctly
+     * handles cross-day vs. same-day increment.
+     */
+    @Modifying
+    @Transactional
+    @Query(value = "INSERT IGNORE INTO user_consultation_quota (user_id, person_name, last_chat_date) "
+           + "VALUES (:userId, :personName, CURDATE())", nativeQuery = true)
+    void insertIgnore(@Param("userId") Long userId, @Param("personName") String personName);
+
+    /**
+     * Atomically increment today's chat count with cross-day awareness.
+     * <ul>
+     *   <li>Same day: {@code chat_count_today + 1}</li>
+     *   <li>Cross day: reset to 1 ({@code ELSE 1})</li>
+     * </ul>
+     * Always sets {@code last_chat_date = CURDATE()}.
+     *
+     * @return number of rows updated (should be 1)
+     */
+    @Modifying
+    @Transactional
+    @Query(value = "UPDATE user_consultation_quota "
+           + "SET chat_count_today = CASE WHEN last_chat_date = CURDATE() THEN chat_count_today + 1 ELSE 1 END, "
+           + "last_chat_date = CURDATE() "
+           + "WHERE user_id = :userId AND person_name = :personName", nativeQuery = true)
+    int incrementChatCount(@Param("userId") Long userId, @Param("personName") String personName);
+
+    /**
+     * Batch-reset all quota rows whose last_chat_date is before today.
+     * <p>
+     * Intended for use in a {@code @Scheduled} cron job.
+     *
+     * @return number of affected rows
+     */
+    @Modifying
+    @Transactional
+    @Query(value = "UPDATE user_consultation_quota "
+           + "SET chat_count_today = 0, last_chat_date = CURDATE() "
+           + "WHERE last_chat_date < CURDATE()", nativeQuery = true)
+    int resetAllDailyQuota();
+}

+ 174 - 0
num-server/src/main/java/com/etotem/num/service/QuotaService.java

@@ -0,0 +1,174 @@
+package com.etotem.num.service;
+
+import com.etotem.num.common.BizException;
+import com.etotem.num.entity.User;
+import com.etotem.num.entity.UserConsultationQuota;
+import com.etotem.num.repository.UserConsultationQuotaRepository;
+import com.etotem.num.repository.UserRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+/**
+ * Consultation quota enforcement service.
+ * <p>
+ * Manages per-user, per-person daily chat quotas based on the user's
+ * role (vipType). All limits are read dynamically from sys_config.
+ * <p>
+ * Cross-day reset is handled atomically within the UPDATE statement,
+ * and a batch {@link #resetDailyQuota()} method is provided for
+ * {@code @Scheduled} cron jobs.
+ */
+@Service
+public class QuotaService {
+
+    @Autowired
+    private UserConsultationQuotaRepository quotaRepository;
+
+    @Autowired
+    private ConfigService configService;
+
+    @Autowired
+    private UserRepository userRepository;
+
+    /**
+     * Check consultation quota and atomically consume one chat count.
+     * <p>
+     * Validates both person limit (max distinct persons) and daily chat
+     * limit (max chats per person per day) based on the user's role.
+     * Limits are read from sys_config dynamically via {@link ConfigService}.
+     * Cross-day reset is handled automatically within the atomic UPDATE
+     * (CASE WHEN in SET clause).
+     *
+     * @param userId     the consulting user's ID
+     * @param personName the name of the person being consulted
+     * @throws BizException with code 403 if person limit or daily chat limit is exceeded
+     */
+    public void checkAndConsume(Long userId, String personName) {
+        User user = userRepository.findById(userId)
+                .orElseThrow(() -> new BizException(1002, "User not found"));
+        String roleKey = getRoleKey(user);
+        int personLimit = getPersonLimit(roleKey);
+        int chatLimit = getChatLimit(roleKey);
+
+        Optional<UserConsultationQuota> existing =
+                quotaRepository.findByUserIdAndPersonName(userId, personName);
+
+        if (existing.isEmpty()) {
+            // --- New person: enforce person limit ---
+            if (personLimit > 0) {
+                long currentPersonCount = quotaRepository.countByUserId(userId);
+                if (currentPersonCount >= personLimit) {
+                    throw new BizException(403, "已达咨询人数上限,最多可咨询" + personLimit + "人");
+                }
+            }
+        } else {
+            // --- Existing person: enforce daily chat limit ---
+            if (chatLimit > 0) {
+                UserConsultationQuota quota = existing.get();
+                // If same day and count already at-or-above limit, block
+                if (quota.getLastChatDate() != null
+                        && LocalDate.now().equals(quota.getLastChatDate())
+                        && quota.getChatCountToday() >= chatLimit) {
+                    throw new BizException(403, "今日咨询次数已用完");
+                }
+                // Cross-day: treat as 0 (will be reset to 1 by the UPDATE)
+            }
+        }
+
+        // INSERT IGNORE — safe for both new and existing persons
+        quotaRepository.insertIgnore(userId, personName);
+
+        // Atomic increment with cross-day awareness
+        int updated = quotaRepository.incrementChatCount(userId, personName);
+        if (updated == 0) {
+            throw new BizException(500, "配额更新失败");
+        }
+    }
+
+    /**
+     * Returns the number of distinct persons the user has ever consulted.
+     *
+     * @param userId the user's ID
+     * @return distinct person count
+     */
+    public int getPersonCount(Long userId) {
+        return (int) quotaRepository.countByUserId(userId);
+    }
+
+    /**
+     * Returns today's chat count for a specific person consulted by the user.
+     * <p>
+     * Returns 0 if no record exists (new person) or if the last chat was on
+     * a previous day (cross-day auto-reset).
+     *
+     * @param userId     the user's ID
+     * @param personName the person's name
+     * @return today's chat count (0 if new person or cross-day)
+     */
+    public int getTodayChatCount(Long userId, String personName) {
+        return quotaRepository.findByUserIdAndPersonName(userId, personName)
+                .filter(q -> q.getLastChatDate() != null
+                        && LocalDate.now().equals(q.getLastChatDate()))
+                .map(UserConsultationQuota::getChatCountToday)
+                .orElse(0);
+    }
+
+    /**
+     * Batch-reset all quota records whose last_chat_date is before today.
+     * <p>
+     * Sets {@code chat_count_today = 0} and {@code last_chat_date = CURDATE()}.
+     * Intended for use with {@code @Scheduled(cron = "...")} at midnight.
+     *
+     * @return number of affected rows
+     */
+    public int resetDailyQuota() {
+        return quotaRepository.resetAllDailyQuota();
+    }
+
+    // -- Private helpers --
+
+    /**
+     * Maps the user's vipType to a role key for looking up quota limits in sys_config.
+     * <pre>
+     *   null          → "normal"
+     *   "annual"       → "annual"
+     *   "family_plan"  → "family"
+     *   "super_annual" → "super"
+     *   "practitioner" → "practitioner"
+     * </pre>
+     */
+    private String getRoleKey(User user) {
+        String vipType = user.getVipType();
+        if (vipType == null) return "normal";
+        switch (vipType) {
+            case "annual":       return "annual";
+            case "family_plan":  return "family";
+            case "super_annual": return "super";
+            case "practitioner": return "practitioner";
+            default:             return "normal";
+        }
+    }
+
+    /**
+     * Reads the person limit for a given role key from sys_config.
+     * <p>
+     * Config key: {@code quota.person_limit.{roleKey}}<br>
+     * Default: 3 (standard normal-user limit).
+     */
+    private int getPersonLimit(String roleKey) {
+        return configService.getInt("quota.person_limit." + roleKey, 3);
+    }
+
+    /**
+     * Reads the daily chat limit for a given role key from sys_config.
+     * <p>
+     * Config key: {@code quota.chat_limit.{roleKey}}<br>
+     * Default: 3 (standard normal-user limit).
+     */
+    private int getChatLimit(String roleKey) {
+        return configService.getInt("quota.chat_limit." + roleKey, 3);
+    }
+}