quota-design.md 8.3 KB

user_person_quota 表结构设计

配额体系:按不同角色控制"可咨询人数上限" + "每人每日聊天次数上限"
状态:v1.0 Draft,方案 B 推荐


一、业务规则回顾

角色 可咨询人数 每人聊天次数
普通用户 3 人 3 次/天
C端会员 9 人 不限
家庭套餐 9 人 不限
超级会员 9 人 不限(暂缓)
能量师 不限 不限

"次数"以 API 调用 /api/chat/send 为准,能量盘创建本身不限次数。
数在每天 00:00 按用户本地时区(或北京时间)自动重置。


二、两种方案对比

方案 A:JSON 字段(存 users 表)

ALTER TABLE users ADD COLUMN consultation_quota JSON COMMENT '咨询配额数据';

JSON 结构示例:

{
  "persons": ["张三", "李四", "王五"],
  "dailyCounts": {
    "2026-06-05": {"张三": 3, "李四": 1},
    "2026-06-04": {"张三": 2}
  },
  "chatLimitPerDay": 3,
  "lastResetDate": "2026-06-05"
}
维度 方案 A — JSON 方案 B — 独立表
查询「已用人数」 JSON_LENGTH(persons) — 快 SELECT COUNT(DISTINCT person_name) — 快
查询「今日次数」 需解析 JSON — 慢 WHERE last_chat_date = CURDATE() — 快
并发更新 整字段读写,锁冲突风险 单行更新,无冲突
每日重置 需 JSON 重构 UPDATE ... SET chat_count_today = 0 WHERE last_chat_date < TODAY
数据一致性 弱(应用层保证) 强(DB 约束)
扩展性 差(新增字段需改 JSON schema) 好(加字段即可)
历史分析 难(每日计数仅保留最近 N 天) 易(可保留全量记录)
MySQL 版本要求 5.7.8+(JSON 函数) 无要求

结论: 方案 A 适合快速原型,数据量小、一致性要求低的场景。
本项目有付费交易,配额数据直接影响用户体验,推荐方案 B。


方案 B:独立表 + 每日重置(推荐)

CREATE TABLE `user_consultation_quota` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `person_name` VARCHAR(50) NOT NULL COMMENT '被咨询者姓名',
  `chat_count_today` INT NOT NULL DEFAULT 0 COMMENT '今日已用聊天次数',
  `last_chat_date` DATE COMMENT '最后聊天日期(用于判断是否跨天重置)',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_user_person` (`user_id`, `person_name`),
  INDEX `idx_user_date` (`user_id`, `last_chat_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心查询:

-- 1. 查询已咨询人数(检查人数上限)
SELECT COUNT(DISTINCT person_name) 
FROM user_consultation_quota 
WHERE user_id = ?

-- 2. 查询某人在今日已聊天次数(检查次数上限)
SELECT chat_count_today, last_chat_date
FROM user_consultation_quota
WHERE user_id = ? AND person_name = ?

-- 3. 新增被咨询人(首次咨询某人)
INSERT IGNORE INTO user_consultation_quota (user_id, person_name, last_chat_date) 
VALUES (?, ?, CURDATE())

-- 4. 聊天次数 +1(原子操作)
UPDATE user_consultation_quota
SET chat_count_today = chat_count_today + 1,
    last_chat_date = CURDATE()
WHERE user_id = ? AND person_name = ?
  AND (last_chat_date != CURDATE() OR chat_count_today = 0)
-- 注意:跨天时自动重置为 0 再 +1

-- 5. 每日重置(定时任务 00:00)
UPDATE user_consultation_quota
SET chat_count_today = 0,
    last_chat_date = CURDATE()
WHERE last_chat_date < CURDATE()

数据生命周期:

  • 正常情况:每日 chat_count_today 归零,数据保留(历史可用于分析)
  • 建议:每月清理 30 天未访问过的 persons 记录,或保留全量用于数据挖掘

三、配额检查流程(后端 API 层)

用户调用 POST /api/chat/send
│
├─ 1. 查用户 vipType → 获取角色配额规则
│   (从 sys_config 或内存缓存读取)
│
├─ 2. 检查人数配额
│   ├─ SELECT COUNT(DISTINCT person_name) FROM user_consultation_quota WHERE user_id = ?
│   ├─ IF 已用人数 >= 角色上限 → 返回 403「已达咨询人数上限」
│   └─ IF 角色上限 = 0(不限)→ 跳过
│
├─ 3. 检查次数配额
│   ├─ SELECT chat_count_today FROM user_consultation_quota 
│   │   WHERE user_id = ? AND person_name = ?
│   ├─ IF last_chat_date != TODAY → 视为 0 次(跨天自动重置)
│   ├─ IF 次数 >= 角色上限 → 返回 403「今日咨询次数已用完」
│   └─ IF 角色上限 = 0(不限)→ 跳过
│
├─ 4. 记录消耗
│   ├─ INSERT IGNORE INTO user_consultation_quota (user_id, person_name, last_chat_date)
│   └─ UPDATE user_consultation_quota SET chat_count_today = chat_count_today + 1
│       WHERE user_id = ? AND person_name = ?
│
└─ 5. 继续执行业务逻辑(调用 Dify / 返回缓存结果)

并发安全:UPDATE ... WHERE 带条件的原子更新,避免超发。
幂等性: 同一聊天消息不应重复扣配额,由消息幂等键(message_id)保证。


四、角色配额规则(sys_config)

INSERT INTO sys_config (`key`, `value`, `desc`, `value_type`) VALUES
-- 可咨询人数上限(0=不限制)
('quota.person_limit.normal', '3', '普通用户可咨询人数上限', 'number'),
('quota.person_limit.annual', '9', 'C端会员可咨询人数上限', 'number'),
('quota.person_limit.family', '9', '家庭套餐可咨询人数上限', 'number'),
('quota.person_limit.super', '9', '超级会员可咨询人数上限(暂缓)', 'number'),
('quota.person_limit.practitioner', '0', '能量师可咨询人数上限(0=不限)', 'number'),
-- 每人每日聊天次数上限(0=不限制)
('quota.chat_limit.normal', '3', '普通用户每人每日聊天次数上限', 'number'),
('quota.chat_limit.annual', '0', 'C端会员每人每日聊天次数上限(0=不限)', 'number'),
('quota.chat_limit.family', '0', '家庭套餐每人每日聊天次数上限', 'number'),
('quota.chat_limit.super', '0', '超级会员每人每日聊天次数上限(暂缓)', 'number'),
('quota.chat_limit.practitioner', '0', '能量师每人每日聊天次数上限', 'number'),
-- 每日重置时间(小时,北京时间)
('quota.reset_hour', '0', '配额每日重置小时(0=00:00)', 'number');

五、每日重置定时任务

@Scheduled(cron = "0 0 ${quota.reset_hour} * * *") // 每天 N 点执行
public void resetDailyQuota() {
    int resetHour = configService.getInt("quota.reset_hour", 0);
    LocalDate today = LocalDate.now();
    
    // 方式1:UPDATE 批量重置(高效,适合数据量 < 100 万)
    jdbcTemplate.update(
        "UPDATE user_consultation_quota SET chat_count_today = 0, last_chat_date = ? " +
        "WHERE last_chat_date < ?",
        today, today
    );
    
    // 方式2(备选):清理超过 90 天未使用的人员记录
    jdbcTemplate.update(
        "DELETE FROM user_consultation_quota WHERE last_chat_date < DATE_SUB(?, INTERVAL 90 DAY)",
        today
    );
}

六、与 chart_record 的关系

user_consultation_quota 追踪"聊天次数限制",chart_record 存储"能量盘记录"。

场景 chart_record user_consultation_quota
用户输入生日 → 生成能量盘 ✅ 插入 不扣(能量盘创建不占配额)
用户在能量盘页发消息聊天 ❌ 不插入 ✅ 计数 +1
用户首次咨询某个人 ✅ 插入 ✅ person_name 记录
同一人对同一人重复咨询 ✅ 返回历史 ✅ chat_count_today +1

人数上限以 user_consultation_quota.person_name 去重计数,不依赖 chart_record,
因为用户可能在 chart_record 中注册了多人,但从未聊天。


七、方案选择建议

维度 推荐
数据结构 方案 B(独立表)
重置策略 每日定时 UPDATE 归零
并发控制 UPDATE WHERE 原子操作
数据保留 保留全量,每月清理 90 天未活跃记录
sys_config 全部参数化,支持后台动态调整
与 chart_record 解耦,各自承担不同职责