日期: 2026年4月13日——2026年4月15日
项目: 阅见:基于大模型的交互式小说阅读平台


【项目实训个人工作记录(五)】—— 阅读商城的功能实现

一、概述

本阶段实现了项目的 「阅读商城」 核心能力,前端页面为 frontend/src/views/BookCoinShopView.vue(路由 /coin-shop),主要包含三块:

  1. 我的书币:展示余额、今日阅读时长、连续打卡天数,提供「打卡领书币」入口;
  2. 称号商城:展示已上架称号,支持用书币购买并标记「已拥有」;
  3. 最近流水:表格展示书币增减记录及原因。

该模块 不依赖大模型,属于 阅读激励 + 虚拟经济 业务;与阅读器内嵌智能体、AI 封面等能力无直接耦合。打卡是否可用依赖前一阶段已落地的 阅读日历 模块:阅读器定时上报秒数写入 vr_reading_daily_stat,商城侧只 读取 当日 total_seconds,结合用户时区 zoneId 计算自然日。


二、功能目标

  1. 书币与打卡
    获取用户书币余额(含 todayReadingSeconds、todayBaseCheckedIn、canBaseCheckIn、currentStreak);支持当日打卡领书币并返回 grants 赠币明细;已打卡返回 alreadyClaimed=true 且不重复发放。

  2. 称号购买
    列出 enabled=true 的称号商品并标记 owned;购买时余额不足拒绝、重复购买返回 409 CONFLICT。

  3. 书币流水
    GET /ledger?limit= 拉取最近变动(后端限制 1~100 条,前端默认请求 30 条)。

  4. 前端体验
    首屏 skeleton、按钮 loading、空状态;称号区支持手动刷新;打卡/购买后刷新钱包与流水(购买另刷新称号列表),保证数据一致。

  5. 鉴权
    全部接口经 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_secondsbuildWallet / checkIn 读取今日秒数;门槛 MIN_READ_SECONDS=600
vr_user_coin_wallet书币余额user_id PK;balance;updated_atgetBalance();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_orderlistTitles() 仅查 enabled=true
vr_user_title用户已拥有称号UNIQUE (user_id, title_id);purchased_atowned 标记;防重复购买

种子数据(schema.sql)预置两枚称号:

codedisplay_nameprice_coins
title_bookworm书虫50
title_night_owl夜猫子读者120

(三)后端实现

1. 书币 / 打卡 / 流水(CoinController)

前缀:/api/coins(需 JWT)

接口说明
GET /wallet?zoneId=返回 CoinWalletResponse
POST /check-inbody: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_SECONDS600有效阅读门槛(10 分钟)
BASE_REWARD10基础打卡奖励
BONUS_UNIT_SECONDS1800每额外 30 分钟一档加赠
BONUS_UNIT_COINS5每档加赠书币
BONUS_MAX_COINS20阅读加赠封顶

阅读加赠计算:

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+5mask | 1
newStreak >= 7 且未领STREAK_7+15mask | 2
newStreak >= 30 且未领STREAK_30+50mask | 4

连续天数 newStreak 规则:

  • last_streak_date 为 null → 1;
  • 等于昨日 → prevStreak + 1;
  • 等于今日 → 保持 prevStreak;
  • 其它(断签)→ 重置为 1,streak_milestone_mask 清零。

时区: zoneId 经 ZoneId.of() 解析;非法时区抛 无效的时区。today 取该时区当前自然日,与阅读日历 accumulate 口径一致。

防重复:

  1. vr_daily_check_in 已存在 → 返回 alreadyClaimed=true、grants=[],不调用 applyChange;
  2. 每笔变动 ref_key 全局唯一,格式示例:{userId}:{calendarDate}:{REASON};重复插入触发 DuplicateKeyException → IllegalStateException。

打卡事务: checkIn() 标注 @Transactional,基础奖、阅读加赠、里程碑、插入打卡记录、更新 user_coin_state 同事务提交。

3. 书币流水与余额(CoinWalletService)

applyChange(userId, delta, reason, refKey, calendarDate):

  1. delta == 0 直接返回;
  2. ensureWallet 懒创建余额行(初始 0);
  3. newBalance = balance + delta,若 < 0 抛 书币余额不足;
  4. 插入 vr_coin_ledger(ref_key 唯一);
  5. 更新 vr_user_coin_wallet.balance。

购买称号时 delta 为 负数,reason 为 SHOP_PURCHASE,ref_key 为 PURCHASE:{userId}:{titleId}。

4. 称号商城(ShopTitleController + ShopTitleService)

前缀:/api/shop

接口说明
GET /titlesShopTitleListResponse.items[]
POST /titles/{titleId}/purchase返回 TitlePurchaseResponse(balance, titleId)

购买校验顺序:

  1. 商品存在且 enabled=true(否则 404);
  2. vr_user_title 无记录(否则 409 已拥有该称号);
  3. balance >= priceCoins(否则 书币余额不足);
  4. 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,带频率窗口防刷)。

本阶段不修改上报逻辑,仅消费统计结果;用户需先在阅读器产生有效时长,再在商城打卡。


(六)业务流程总结

  1. 用户在阅读器阅读 → 定时累加 vr_reading_daily_stat;
  2. 进入 /coin-shop → 并行拉取钱包、称号、流水;
  3. 满 10 分钟且未打卡 → 点击「打卡领书币」→ 总奖励 = 基础 10 + 阅读加赠(0~20)+ 里程碑(3/7/30 天,按位一次性);
  4. 用书币购买称号 → 扣币流水 + vr_user_title 记录;
  5. 前端刷新展示最新余额与流水。

四、问题反馈

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 验证上海时区自然日边界。

六、界面展示

阅读商城界面


七、优化思路

  1. 流水分页:ledger 支持 offset 翻页,替代固定 limit=30。
  2. 打卡进度条:用 todayReadingSeconds 展示「距 10 分钟还差 X 分 Y 秒」(后端已返回秒数,前端可算差值)。
  3. 称号缓存:loadShop 结合短时缓存,减少列表刷新。
  4. reason 中文映射:流水表 CHECK_IN_BASE 等展示为用户可读文案。
  5. 里程碑预告:钱包接口返回下一档连续奖励说明,提升留存。

八、总结

本阶段完成阅读商城端到端落地:在 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。任务必须覆盖:

  1. 数据库:基于 schema.sql 的表结构,包含 vr_user_coin_wallet、vr_user_coin_state、vr_coin_ledger、vr_daily_check_in、vr_shop_title、vr_user_title 及关键唯一约束;
  2. 后端 Controller / Service / DTO / Entity;
  3. 阅读日历依赖 vr_reading_daily_stat 与 zoneId 口径;
  4. 前端 BookCoinShopView.vue 并行加载与刷新策略。

/speckit.implement
按 tasks.md 完成实现与联调;最小修改原则;关键逻辑(奖励计算、幂等、余额扣减、阅读秒数口径)变更前确认;SQL 限定本功能表与索引。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐