【项目实训个人工作记录(五)】—— 阅读商城的功能实现
日期: 2026年4月13日——2026年4月15日
项目: 阅见:基于大模型的交互式小说阅读平台
【项目实训个人工作记录(五)】—— 阅读商城的功能实现
一、概述
本阶段实现了项目的 「阅读商城」 核心能力,前端页面为 frontend/src/views/BookCoinShopView.vue(路由 /coin-shop),主要包含三块:
- 我的书币:展示余额、今日阅读时长、连续打卡天数,提供「打卡领书币」入口;
- 称号商城:展示已上架称号,支持用书币购买并标记「已拥有」;
- 最近流水:表格展示书币增减记录及原因。
该模块 不依赖大模型,属于 阅读激励 + 虚拟经济 业务;与阅读器内嵌智能体、AI 封面等能力无直接耦合。打卡是否可用依赖前一阶段已落地的 阅读日历 模块:阅读器定时上报秒数写入 vr_reading_daily_stat,商城侧只 读取 当日 total_seconds,结合用户时区 zoneId 计算自然日。
二、功能目标
-
书币与打卡
获取用户书币余额(含todayReadingSeconds、todayBaseCheckedIn、canBaseCheckIn、currentStreak);支持当日打卡领书币并返回grants赠币明细;已打卡返回alreadyClaimed=true且不重复发放。 -
称号购买
列出enabled=true的称号商品并标记owned;购买时余额不足拒绝、重复购买返回409 CONFLICT。 -
书币流水
GET /ledger?limit=拉取最近变动(后端限制 1~100 条,前端默认请求 30 条)。 -
前端体验
首屏 skeleton、按钮 loading、空状态;称号区支持手动刷新;打卡/购买后刷新钱包与流水(购买另刷新称号列表),保证数据一致。 -
鉴权
全部接口经 JWT 拦截器解析userId,无登录不可访问。
三、技术实现
(一)整体架构

(二)数据库设计
表结构定义于 backend/src/main/resources/sql/schema.sql(本功能无独立 update_book_coin_shop.sql,与 schema 一体化维护)。
| 表名 | 作用 | 关键字段 / 约束 | 在阅读商城中的使用点 |
|---|---|---|---|
vr_reading_daily_stat |
按用户 + 自然日汇总阅读秒数(已存在) | UNIQUE (user_id, calendar_date);total_seconds |
buildWallet / checkIn 读取今日秒数;门槛 MIN_READ_SECONDS=600 |
vr_user_coin_wallet |
书币余额 | user_id PK;balance;updated_at |
getBalance();applyChange() 更新余额 |
vr_user_coin_state |
连续打卡状态 | current_streak;last_streak_date;streak_milestone_mask |
打卡时更新连续天数与里程碑位掩码 |
vr_coin_ledger |
书币流水 | UNIQUE ref_key;delta;reason;calendar_date |
每次增减写入;前端「最近流水」展示 |
vr_daily_check_in |
每日基础打卡 | UNIQUE (user_id, calendar_date);base_claimed_at;streak_after_claim |
同日重复打卡判断;插入后表示今日已领基础奖 |
vr_shop_title |
称号商品 | UNIQUE code;enabled;price_coins;sort_order |
listTitles() 仅查 enabled=true |
vr_user_title |
用户已拥有称号 | UNIQUE (user_id, title_id);purchased_at |
owned 标记;防重复购买 |
种子数据(schema.sql)预置两枚称号:
| code | display_name | price_coins |
|---|---|---|
title_bookworm |
书虫 | 50 |
title_night_owl |
夜猫子读者 | 120 |
(三)后端实现
1. 书币 / 打卡 / 流水(CoinController)
前缀:/api/coins(需 JWT)
| 接口 | 说明 |
|---|---|
GET /wallet?zoneId= |
返回 CoinWalletResponse |
POST /check-in |
body:CoinCheckInRequest(仅 zoneId) |
GET /ledger?limit= |
默认 limit=20,服务端钳制为 1~100 |
CoinWalletResponse 字段含义(与代码一致):
| 字段 | 含义 |
|---|---|
balance |
当前书币余额 |
todayReadingSeconds |
今日累计阅读秒数(来自 vr_reading_daily_stat) |
canBaseCheckIn |
可读满 10 分钟且今日尚未打卡 时为 true |
todayBaseCheckedIn |
今日是否已有 vr_daily_check_in 记录 |
currentStreak |
当前连续打卡天数 |
zoneId |
回显请求时区 |
注意:
canBaseCheckIn = seconds >= 600 && existing == null,表示 可以打卡,而非「未满 10 分钟」。
2. 打卡计算与状态机(CoinCheckInService)
核心常量(类内 private static final):
| 常量 | 值 | 含义 |
|---|---|---|
MIN_READ_SECONDS |
600 | 有效阅读门槛(10 分钟) |
BASE_REWARD |
10 | 基础打卡奖励 |
BONUS_UNIT_SECONDS |
1800 | 每额外 30 分钟一档加赠 |
BONUS_UNIT_COINS |
5 | 每档加赠书币 |
BONUS_MAX_COINS |
20 | 阅读加赠封顶 |
阅读加赠计算:
extraUnits = min(4, max(0, (seconds - 600) / 1800))
readingBonus = min(20, extraUnits * 5)
示例:阅读 600s → 加赠 0;3900s(65 分钟)→ 1 档 +5;7500s(125 分钟)→ 3 档 +15;满 4 档封顶 +20。
连续里程碑(与 streak_milestone_mask 位掩码配合,每位仅发一次):
| 条件 | reason 常量 | 奖励 | 掩码位 |
|---|---|---|---|
newStreak >= 3 且未领 |
STREAK_3 |
+5 | mask | 1 |
newStreak >= 7 且未领 |
STREAK_7 |
+15 | mask | 2 |
newStreak >= 30 且未领 |
STREAK_30 |
+50 | mask | 4 |
连续天数 newStreak 规则:
last_streak_date为 null → 1;- 等于昨日 →
prevStreak + 1; - 等于今日 → 保持
prevStreak; - 其它(断签)→ 重置为 1,
streak_milestone_mask清零。
时区: zoneId 经 ZoneId.of() 解析;非法时区抛 无效的时区。today 取该时区当前自然日,与阅读日历 accumulate 口径一致。
防重复:
vr_daily_check_in已存在 → 返回alreadyClaimed=true、grants=[],不调用applyChange;- 每笔变动
ref_key全局唯一,格式示例:{userId}:{calendarDate}:{REASON};重复插入触发DuplicateKeyException→IllegalStateException。
打卡事务: checkIn() 标注 @Transactional,基础奖、阅读加赠、里程碑、插入打卡记录、更新 user_coin_state 同事务提交。
3. 书币流水与余额(CoinWalletService)
applyChange(userId, delta, reason, refKey, calendarDate):
delta == 0直接返回;ensureWallet懒创建余额行(初始 0);newBalance = balance + delta,若< 0抛书币余额不足;- 插入
vr_coin_ledger(ref_key唯一); - 更新
vr_user_coin_wallet.balance。
购买称号时 delta 为 负数,reason 为 SHOP_PURCHASE,ref_key 为 PURCHASE:{userId}:{titleId}。
4. 称号商城(ShopTitleController + ShopTitleService)
前缀:/api/shop
| 接口 | 说明 |
|---|---|
GET /titles |
ShopTitleListResponse.items[] |
POST /titles/{titleId}/purchase |
返回 TitlePurchaseResponse(balance, titleId) |
购买校验顺序:
- 商品存在且
enabled=true(否则404); vr_user_title无记录(否则409 已拥有该称号);balance >= priceCoins(否则书币余额不足);applyChange(-price)+insert user_title。
listTitles 按 sort_order 升序,仅 enabled=true。
(四)前端实现(BookCoinShopView.vue)
1. 页面结构
- 左栏 我的书币:余额大字、
formatDuration(todayReadingSeconds)、连续天数、状态提示、打卡按钮; - 右栏 称号商城:网格卡片 + 「刷新」;
- 底部 最近流水:
el-table四列(时间 / 原因 / 变动 / 业务日)。
2. 数据加载 onMounted → loadAll()
await Promise.all([loadWallet(), loadShop(), loadLedger()])
// wallet: GET /coins/wallet?zoneId=Intl 浏览器时区
// shop: GET /shop/titles
// ledger: GET /coins/ledger?limit=30
loading 控制首屏 el-skeleton。
3. 打卡 doCheckIn()
POST /coins/check-in携带{ zoneId };alreadyClaimed→ElMessage.info('今日已领取过基础打卡奖励');- 否则将
grants拼成reason: +delta成功提示; - 刷新
loadWallet()+loadLedger()(不强制刷新称号列表)。
4. 购买 purchase(t)
purchaseId === t.id控制按钮 loading;owned为 true 时按钮禁用并显示「已拥有」标签;- 成功后
Promise.all([loadWallet(), loadShop(), loadLedger()])。
5. 交互与文案(已与后端字段对齐)
| 状态 | UI |
|---|---|
todayBaseCheckedIn |
「今日已打卡(基础奖励已领取)」 |
!canBaseCheckIn 且未打卡 |
「还需阅读满 10 分钟才可打卡」 |
| 打卡按钮 | disabled 当 !canBaseCheckIn || todayBaseCheckedIn |
流水 delta |
正数绿色 +N,负数红色 |
错误提示由 http 全局拦截器统一弹出(余额不足、已拥有等)。
(五)与阅读器的衔接
阅读器 Reader.vue 通过 POST /api/reading/calendar/accumulate 上报秒数(zoneId 与商城一致,默认 Asia/Shanghai),ReadingCalendarService 写入/累加 vr_reading_daily_stat.total_seconds(单日上限 86400s,带频率窗口防刷)。
本阶段不修改上报逻辑,仅消费统计结果;用户需先在阅读器产生有效时长,再在商城打卡。
(六)业务流程总结
- 用户在阅读器阅读 → 定时累加
vr_reading_daily_stat; - 进入
/coin-shop→ 并行拉取钱包、称号、流水; - 满 10 分钟且未打卡 → 点击「打卡领书币」→ 总奖励 = 基础 10 + 阅读加赠(0~20)+ 里程碑(3/7/30 天,按位一次性);
- 用书币购买称号 → 扣币流水 +
vr_user_title记录; - 前端刷新展示最新余额与流水。
四、问题反馈
1. 重复打卡导致重复赠币
现象: 重复点击「打卡领书币」。
原因: 需保证同日仅发放一次基础及相关奖励。
处理: vr_daily_check_in (user_id, calendar_date) 唯一约束;业务层 existing != null 返回 alreadyClaimed=true 且不写流水;ref_key 唯一防止重复 applyChange。
2. 购买称号余额不足或重复购买
现象: 仅前端禁用可能失效(如并发双击)。
处理: ShopTitleService.purchase 服务端校验余额与 vr_user_title;409 / IllegalArgumentException 由全局拦截器提示。
3. 时区与自然日不一致
现象: 阅读器与商城展示的「今日」阅读时长不一致。
原因: zoneId 不一致导致 calendar_date 不同。
处理: 前端统一 Intl.DateTimeFormat().resolvedOptions().timeZone;阅读器 accumulate 与商城 wallet/check-in 传同一 zoneId。
4. canBaseCheckIn 语义误解(文档勘误)
原描述: 「是否满足今日有效阅读未满 10 分钟的解锁条件」——与代码相反。
正确: canBaseCheckIn 表示 已满 10 分钟且今日尚未打卡,为 true 时按钮可点。
五、测试与质量
单元测试覆盖(CoinCheckInServiceTest):
- 阅读 < 600s 拒绝打卡且不调用
applyChange; - 已打卡返回
alreadyClaimed、空grants; - 满门槛发放基础奖与阅读加赠;
- 固定
Clock验证上海时区自然日边界。
六、界面展示

七、优化思路
- 流水分页:
ledger支持offset翻页,替代固定limit=30。 - 打卡进度条:用
todayReadingSeconds展示「距 10 分钟还差 X 分 Y 秒」(后端已返回秒数,前端可算差值)。 - 称号缓存:
loadShop结合短时缓存,减少列表刷新。 - reason 中文映射:流水表
CHECK_IN_BASE等展示为用户可读文案。 - 里程碑预告:钱包接口返回下一档连续奖励说明,提升留存。
八、总结
本阶段完成阅读商城端到端落地:在 schema.sql 中新增书币钱包、连续状态、流水、每日打卡、称号商品与用户称号表,并依赖 vr_reading_daily_stat 提供今日阅读秒数;后端通过 CoinController / CoinCheckInService / CoinWalletService 实现「读时长 → 打卡赠币 → 事务写流水更新余额」,通过 ShopTitleController / ShopTitleService 实现「称号购买扣币 + 幂等防重」;前端在 BookCoinShopView.vue 并行加载三块数据,打卡/购买后刷新关键状态,配合 skeleton、loading 与禁用规则保证体验一致。
能力定位: 阅读激励与虚拟商城,非 大模型 / 智能体能力。
九、AI 提示词记录(SpecKit 流程)
/speckit.specify
请根据当前项目(阅见:基于大模型的交互式小说阅读平台)的架构与代码风格,生成一个「阅读商城(书币与称号商城)」功能需求说明。该功能面向用户在阅读达到条件后,通过「打卡领书币」获得书币,并使用书币在称号商城购买称号。
请输出:需求说明(Spec)+ 接口字段说明(请求/响应结构字段含义)+ 关键业务规则 + 边界条件。
/speckit.plan
请根据当前项目技术栈(Spring Boot 3.x + Java 17 + MyBatis-Plus + JWT 拦截器 + Vue3 + Vite + Axios + Element Plus),为「阅读商城(书币与称号商城)」给出技术实现方案与分层计划。
/speckit.tasks
请把「阅读商城(书币与称号商城)」拆成可执行任务,并按依赖顺序生成 tasks.md。任务必须覆盖:
- 数据库:基于
schema.sql的表结构,包含 vr_user_coin_wallet、vr_user_coin_state、vr_coin_ledger、vr_daily_check_in、vr_shop_title、vr_user_title 及关键唯一约束;- 后端 Controller / Service / DTO / Entity;
- 阅读日历依赖 vr_reading_daily_stat 与 zoneId 口径;
- 前端 BookCoinShopView.vue 并行加载与刷新策略。
/speckit.implement
按 tasks.md 完成实现与联调;最小修改原则;关键逻辑(奖励计算、幂等、余额扣减、阅读秒数口径)变更前确认;SQL 限定本功能表与索引。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)