日期: 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. 书币与打卡
    获取用户书币余额(含 todayReadingSecondstodayBaseCheckedIncanBaseCheckIncurrentStreak);支持当日打卡领书币并返回 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_seconds buildWallet / checkIn 读取今日秒数;门槛 MIN_READ_SECONDS=600
vr_user_coin_wallet 书币余额 user_id PK;balanceupdated_at getBalance()applyChange() 更新余额
vr_user_coin_state 连续打卡状态 current_streaklast_streak_datestreak_milestone_mask 打卡时更新连续天数与里程碑位掩码
vr_coin_ledger 书币流水 UNIQUE ref_keydeltareasoncalendar_date 每次增减写入;前端「最近流水」展示
vr_daily_check_in 每日基础打卡 UNIQUE (user_id, calendar_date)base_claimed_atstreak_after_claim 同日重复打卡判断;插入后表示今日已领基础奖
vr_shop_title 称号商品 UNIQUE codeenabledprice_coinssort_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 清零。

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

防重复:

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

打卡事务: 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_ledgerref_key 唯一);
  5. 更新 vr_user_coin_wallet.balance

购买称号时 delta负数,reason 为 SHOP_PURCHASEref_keyPURCHASE:{userId}:{titleId}

4. 称号商城(ShopTitleController + ShopTitleService

前缀:/api/shop

接口 说明
GET /titles ShopTitleListResponse.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

listTitlessort_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 }
  • alreadyClaimedElMessage.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_title409 / 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 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐