在前三篇文章中,我们已完成量化系统"数据层"的完整搭建------包括股票基础列表同步、每日日K增量同步、历史K线存量同步。有了完备的股票数据后,接下来需要面向用户层面构建核心交互功能:自选股票维护。毕竟对于量化交易者而言,关注的股票往往是少数几只,自选功能能帮助用户快速聚焦核心标的,便捷查看其数据与策略表现。
本文作为Java量化系列的第四篇,将聚焦"自选股票维护"核心模块,基于SpringBoot 3.3.8 + Mybatis-Plus + Mysql8.0技术栈,实现自选股票的查询、添加、删除、编辑笔记四大基础功能。同时涵盖表结构设计、同样适合三年以上Java开发经验,希望完善量化系统用户交互层的工程师参考。
一、核心需求与表结构设计
自选股票维护的核心诉求是"用户隔离""操作便捷""数据一致",结合量化系统的用户使用场景,我们先明确需求边界,再设计适配的表结构。
1.1 核心需求边界
- 用户隔离:不同用户的自选股票独立存储,通过
user_id字段区分,避免数据混淆; - 基础操作:支持四大核心功能------查询(按关键词模糊搜索、分页)、添加(校验股票有效性、避免重复)、删除(按股票代码移除)、编辑笔记(记录标的关注原因、策略要点等);
- 状态管控:支持自选股票的"启用/禁用"状态(
status字段)和"逻辑删除"(flag字段),避免物理删除导致数据丢失; - 异常兼容:添加时校验股票是否存在,删除时清理关联缓存,编辑时校验必填字段,确保操作合法性。
1.2 表结构设计:stock_selected
基于需求设计自选股票表stock_selected,兼顾用户隔离、状态管控和扩展需求,具体SQL如下:
CREATE TABLE `stock_selected` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键自增',
`stock_code` varchar(8) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL COMMENT '股票的编号',
`stock_name` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '股票的名称',
`user_id` int DEFAULT NULL COMMENT '用户的id',
`create_time` timestamp NULL DEFAULT NULL COMMENT '创建时间',
`code_notes` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '笔记',
`status` int NOT NULL COMMENT '状态 0为禁用 1为启用',
`flag` int DEFAULT NULL COMMENT '1是正常0为删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=520 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='股票自选表,是用户自己选择的';
关键字段说明:
user_id:核心隔离字段,关联用户表主键,确保不同用户的自选数据独立;stock_code:股票编号(如001318、600000),关联股票基础表的code字段;code_notes:笔记字段,支持用户记录关注该股票的原因(如"新能源赛道龙头""均线策略标的");status:状态字段(0禁用/1启用),支持用户隐藏部分自选股票但不删除;flag:逻辑删除标识(1正常/0删除),避免物理删除导致历史数据无法追溯;create_time:创建时间,便于用户查看自选股票的添加时间线。
1.3 核心对象映射:DO
遵循"分层设计"原则,定义核心对象完成请求参数接收、数据库交互、响应结果返回的流转,核心对象如下:
1.3.1 数据库对象:StockSelectedDo
与stock_selected表字段一一对应,用于Mybatis-Plus数据库交互,核心代码:
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("stock_selected")
public class StockSelectedDo implements Serializable {
private static final long serialVersionUID = 1L;
/** 主键自增 */
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/** 股票的编号 */
@TableField("stock_code")
private String stockCode;
/** 股票的名称 */
@TableField("stock_name")
private String stockName;
/** 用户的id */
@TableField("user_id")
private Integer userId;
/** 创建时间 */
@TableField("create_time")
private Date createTime;
/** 笔记(对应表中code_notes字段) */
@TableField("code_notes")
private String notes;
/** 是否启用,禁用 1为启用,0为禁用 */
@TableField("status")
private Integer status;
/** 是否删除 1为正常 0为删除(表中flag字段定义) */
@TableField("flag")
private Integer flag;
}
关键说明:通过@TableName关联数据库表,@TableId指定主键策略(自增),@TableField关联表字段(notes对应表中code_notes,需显式指定字段名)。
1.3.2 请求对象:StockSelectedRo
接收前端请求参数(如查询条件、添加信息、编辑笔记内容),核心字段包括userId(当前登录用户ID)、stockCode(股票代码)、notes(笔记)等,按需扩展查询关键词字段。
1.3.3 响应对象:StockSelectedVo
返回前端展示数据,在StockSelectedDo基础上屏蔽数据库字段(如flag逻辑删除标识),仅暴露用户需查看的字段(id、stockCode、stockName、notes、status、createTime)。
二、核心流程设计:四大功能的完整链路
主要是对单表的进行维护, 故只列出关键的逻辑。
自选股票维护的四大核心功能,均遵循"请求参数校验 → 业务逻辑处理 → 数据库交互 → 结果返回"的链路,不同功能的核心差异在于业务逻辑校验环节(如添加需校验股票存在性和重复性,删除需清理缓存)。整体流程概括如下:
前端请求 → 控制器接收参数 → 拦截器获取当前用户ID → 业务层校验参数合法性 → 数据层交互(查/增/删/改) → 响应结果返回
三、核心实现:分步拆解四大功能代码
3.1 控制器层:接口定义与参数预处理
首先定义自选股票维护的核心接口,采用RESTful风格,通过POST请求接收参数,同时在控制器层完成基础参数校验(如股票代码非空)和当前用户ID注入(通过getUserId()方法从上下文获取,需集成用户登录认证功能)。
核心代码实现:
@RestController
@RequestMapping("/stock/selected")
public class StockSelectedController {
@Resource
private StockSelectedBusiness stockSelectedBusiness;
/**
* 1. 查询用户自选股票(支持分页、关键词模糊搜索)
* @param stockSelectedRo 包含查询条件(股票代码、名称、状态等)的请求对象
* @return 分页后的自选股票列表
*/
@Operation(summary = "查询用户自选股票")
@PostMapping("/list")
public OutputResult<PageResponse<StockSelectedVo>> list(@RequestBody StockSelectedRo stockSelectedRo) {
// 注入当前登录用户ID,确保数据隔离
stockSelectedRo.setUserId(getUserId());
return stockSelectedBusiness.listSelected(stockSelectedRo);
}
/**
* 2. 添加股票到自选表
* @param stockSelectedRo 包含股票代码的请求对象
* @return 添加结果(成功/失败提示)
*/
@Operation(summary = "添加到自选表")
@PostMapping("/add")
public OutputResult add(@RequestBody StockSelectedRo stockSelectedRo){
// 基础校验:股票代码不能为空
if (!StrUtil.isNotBlank(stockSelectedRo.getStockCode())){
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
}
// 注入当前登录用户ID
stockSelectedRo.setUserId(getUserId());
return stockSelectedBusiness.add(stockSelectedRo);
}
/**
* 3. 根据股票代码删除自选股票(股票页面专用)
* @param codeRo 包含股票代码的请求对象
* @return 删除结果(成功/失败提示)
*/
@Operation(summary = "根据股票code进行移除,股票页面使用")
@PostMapping("/deleteByCode")
public OutputResult deleteByCode(@RequestBody CodeRo codeRo){
// 基础校验:股票代码不能为空
if (!StrUtil.isNotBlank(codeRo.getCode())){
return OutputResult.buildAlert(ResultCode.STOCK_CODE_IS_EMPTY);
}
// 转换为自选股票请求对象,注入当前登录用户ID
StockSelectedRo stockSelectedRo = new StockSelectedRo();
stockSelectedRo.setUserId(getUserId());
stockSelectedRo.setStockCode(codeRo.getCode());
return stockSelectedBusiness.deleteByCode(stockSelectedRo);
}
/**
* 4. 编辑自选股票笔记
* @param stockSelectedRo 包含记录ID/股票代码、笔记内容的请求对象
* @return 编辑结果(成功/失败提示)
*/
@Operation(summary = "根据自选记录,编辑笔记")
@PostMapping("/editNotes")
public OutputResult editNotes(@RequestBody StockSelectedRo stockSelectedRo){
// 注入当前登录用户ID
stockSelectedRo.setUserId(getUserId());
// 基础校验:必须指定记录ID或股票代码(定位要编辑的记录),笔记内容不能为空
if (stockSelectedRo.getId() == null && !StrUtil.isNotBlank(stockSelectedRo.getStockCode())) {
return OutputResult.buildAlert(ResultCode.ID_IS_EMPTY);
}
if (!StrUtil.isNotBlank(stockSelectedRo.getNotes())) {
return OutputResult.buildAlert(ResultCode.STOCK_SELECTED_NOTES_EMPTY);
}
return stockSelectedBusiness.editNotes(stockSelectedRo);
}
/**
* 从上下文获取当前登录用户ID
*/
private Integer getUserId() {
Integer userId = ThreadLocalUtils.getUserId();
if (userId != null) {
return userId;
}
return 1;
}
}
关键注意点:
- 用户ID注入:通过
getUserId()方法从用户上下文获取当前登录用户ID,确保不同用户的自选数据隔离,这是自选功能的核心设计点; - 基础参数校验:在控制器层完成简单的非空校验(如股票代码、笔记内容),避免无效请求进入业务层,提升系统效率;
- 接口文档:通过
@Operation注解生成接口文档(集成Knife4j/Swagger),便于前后端联调。
3.2 业务层:核心逻辑实现(重点)
业务层是自选功能的核心,负责完成参数合法性校验、关联数据查询(如校验股票是否存在)、业务规则执行(如避免重复添加)、缓存处理等核心逻辑。下面按四大功能分别拆解:
3.2.1 功能一:查询用户自选股票
核心逻辑:根据当前用户ID,结合查询条件(如股票代码/名称关键词、状态)进行分页查询,支持模糊搜索(通过股票代码或名称匹配关键词)。
核心代码实现:
根据 股票编码 或者 股票名称 进行查询
3.2.2 功能二:添加股票到自选表
核心逻辑:添加前需完成三重校验------① 股票是否存在(通过股票代码/名称/完整代码查询基础股票表);② 该股票是否已添加到当前用户的自选表(避免重复);③ 自选股票数量是否超限(如最多添加100只)。校验通过后,插入自选表并默认设置状态为"启用"(status=1)。
核心代码实现:
@Service
public class StockSelectedBusinessImpl implements StockSelectedBusiness {
@Resource
private StockSelectedService stockSelectedService;
@Resource
private StockService stockService;
@Override
public OutputResult add(StockSelectedRo stockSelectedRo) {
try {
// 1. 校验1:查询股票是否存在(支持通过股票代码、完整代码、名称查询)
StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
if (null == stockDto) {
// 股票不存在,返回错误提示
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
}
// 2. 统一股票代码格式(避免因完整代码/简称导致的重复)
stockSelectedRo.setStockCode(stockDto.getCode());
// 3. 校验2:该股票是否已添加到当前用户的自选表(避免重复添加)
OutputResult addValidateResult = stockSelectedService.validateAdd(stockSelectedRo, 100);
if (!addValidateResult.getSuccess()) {
return addValidateResult;
}
// 4. 校验通过,添加到自选表(默认状态:启用status=1,正常flag=1,创建时间为当前时间)
stockSelectedService.add(stockSelectedRo, stockDto.getName());
// 5. 返回添加成功提示(包含股票代码和名称)
return OutputResult.buildSucc(stockDto.getCode() + "(名称:" + stockDto.getName() + ")添加到自选成功");
} catch (Exception e) {
log.error("用户 {} 添加自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
return OutputResult.buildFail("添加自选股票失败:" + e.getMessage());
}
}
}
关键补充说明:
- 股票存在性校验:通过
stockService.getByCodeOrNameOrFullCode()方法查询基础股票表,支持股票代码、完整代码(如0.001318)、股票名称三种查询方式,提升用户操作便捷性; - 重复添加校验:
stockSelectedService.validateAdd()方法内部通过"user_id + stock_code"组合查询,判断该股票是否已在当前用户的自选表中(且状态为正常); - 数量限制校验:
validateAdd()方法第二个参数"100"为自选股票数量上限,避免用户添加过多标的导致查询效率下降。
3.2.3 功能三:根据股票代码删除自选股票
核心逻辑:删除前需查询股票是否存在,然后执行逻辑删除(更新flag字段为0,而非物理删除),同时清理该股票的价格缓存(如Redis中存储的实时价格),避免缓存数据不一致。
核心代码实现:
@Service
public class StockSelectedBusinessImpl implements StockSelectedBusiness {
@Resource
private StockSelectedService stockSelectedService;
@Resource
private StockService stockService;
@Resource
private RedisUtil redisUtil;
@Override
public OutputResult deleteByCode(StockSelectedRo stockSelectedRo) {
try {
// 1. 校验股票是否存在,获取标准股票代码
StockDto stockDto = stockService.getByCodeOrNameOrFullCode(stockSelectedRo.getStockCode());
if (null == stockDto) {
return OutputResult.buildAlert(ResultCode.STOCK_CODE_NO_EXIST);
}
String realCode = stockDto.getCode();
stockSelectedRo.setStockCode(realCode);
// 2. 执行逻辑删除(更新flag=0,而非物理删除)
OutputResult deleteResult = stockSelectedService.deleteByCode(stockSelectedRo);
if (!deleteResult.getSuccess()){
return deleteResult;
}
// 3. 清理缓存:删除该股票的实时价格缓存(避免缓存残留)
redisUtil.delete(Collections.singleton(Const.STOCK_PRICE + realCode));
return OutputResult.buildSucc("删除自选股票 " + realCode + " 成功");
} catch (Exception e) {
log.error("用户 {} 删除自选股票 {} 失败", stockSelectedRo.getUserId(), stockSelectedRo.getStockCode(), e);
return OutputResult.buildFail("删除自选股票失败:" + e.getMessage());
}
}
}
关键注意点:
- 逻辑删除:通过更新
flag字段为0实现逻辑删除,保留历史数据,便于后续数据统计和恢复; - 缓存清理:删除自选股票后,清理Redis中该股票的价格缓存(
Const.STOCK_PRICE + 股票代码为缓存key),避免用户已删除自选股票但仍能看到缓存的价格数据,确保数据一致性; - 标准代码统一:通过
stockService.getByCodeOrNameOrFullCode()获取标准股票代码,避免因用户输入完整代码(如0.001318)导致删除失败。
3.2.4 功能四:编辑自选股票笔记
核心逻辑:通过"记录ID"或"用户ID+股票代码"定位要编辑的自选记录,校验笔记内容非空后,更新notes字段(对应表中code_notes)。
核心代码实现:
修改 code_notes 表字段的信息
关键注意点:
- 记录定位:支持两种定位方式------通过记录ID(精确)或"用户ID+股票代码"(兼容无ID场景,如股票页面直接编辑),提升操作灵活性;
- 状态校验:仅允许编辑正常状态(
flag=1)的自选记录,避免编辑已删除的记录; - 字段映射:注意
notes字段对应表中code_notes,需确保StockSelectedDo中@TableField注解配置正确。
四、核心优化点与生产环境适配
上述实现已满足自选股票维护的基础需求,但在生产环境中需补充以下优化点,确保系统稳定性、效率与用户体验:
- 缓存优化:查询用户自选股票列表时,添加Redis缓存(缓存key为
USER_SELECTED_STOCK + userId),避免频繁查询数据库;添加/删除/编辑操作后,同步更新缓存(删除旧缓存或更新缓存内容); - 权限控制:集成Spring Security/Shiro等认证框架,确保
getUserId()方法能安全获取当前登录用户ID,避免未登录用户访问自选接口; - 参数校验增强:使用JSR-380注解(如
@NotBlank、@NotNull)替代手动非空校验,结合全局异常处理器统一处理校验失败信息; - 异步处理:对于批量添加/删除自选股票的场景(扩展功能),采用线程池异步处理,提升用户操作体验;
- 日志与监控:记录自选股票操作日志(用户ID、操作类型、股票代码、操作时间),便于问题排查和用户行为分析;集成监控工具(如Prometheus + Grafana)监控接口调用量和失败率。
五、系列文章预告
本文完成了量化系统"用户交互层"的核心模块------自选股票维护,实现了用户对核心标的的便捷管理。下一篇文章将聚焦 "获取股票的实时价格",为后续策略回测功能铺路。
最后,留一个思考问题:在自选股票列表查询场景中,如何设计缓存策略(如缓存过期时间、更新机制),才能在保证查询效率的同时,避免缓存与数据库数据不一致?欢迎在评论区交流~