12306项目选座购票业务逻辑
文章目录
项目分享
近期在跟视频做SpringBoot微服务项目12306,可点击查看项目。可私信分享项目资料。
此篇记录12306中的重点业务逻辑流程,选座购票业务逻辑。
选座逻辑
分成选座和不选座
- 不选座,以购买一等座为例:遍历一等座车厢,每个车厢从1号座位开始找,未被购买的,就选中它
- 选座,以购买两张一等座AB为例:遍历一等座车厢,每个车厢从1号座位开始找A列座位,未被购买的,就预选中它;再挑它旁边的B,如果也未被购买,则最终选中这两个座位,如果B已被购买,则回到第一步,继续找未被购买的A座。
从第二个座位开始,需要计算和第一个座位的偏移值,可以减少循环,提高选座效率
选择C1,D2,偏移值为[0, 5]
选择B1,D2,偏移值为[0, 7]
选择A1,B1,C1,则偏移值为[0,1,2]
由此可见,偏移值都是针对第一个选座位置的偏移量,并且第一个永远是0
购票逻辑
计算某座位在区间内是否可卖
例:
有站点ABCDEF,则设置sell=10001,1表示座位在区间已卖出,0表示未卖出
本次购买区间站1~4,则区间已售000,全部是0,表示这个区间可买;只要有1,就表示区间内已售过票
选中座位后,应完成:
选中后,要计算购票后的sell,比如原来是10001,本次购买区间站1~4
方案:构造本次购票造成的售卖信息01110,和原sell10001按位或,最终得到11111
以上转化为字符串的截取和位运算
更新余票逻辑
重点逻辑
购买一张票将会影响的库存:本次选座之前没卖过票的,和本次购买的区间有交集的区间
假设10个站,本次买4~7站
原售:001000001
购买:000011100
新售:001011101
影响:XXX11111X
Integer startIndex = 4;
Integer endIndex = 7;
Integer minStartIndex = startIndex - (往前碰到的最后一个0);
Integer maxStartIndex = endIndex - 1;
Integer minEndIndex = startIndex + 1;
Integer maxEndIndex = endIndex + (往后碰到的最后一个0);
因此要更新的余票为:
minStartIndex <= start_index <= maxStartIndex &&
minEndIndex <= end_index <= maxEndIndex
用户选座功能
前端传递数据:
{
passengerId: 123,
passengerType: "1",
passengerName: "张三",
passengerIdCard: "12323132132",
seatTypeCode: "2",
seat: "C1"
}
用户分为选座和不选座,不选座则seat
字段为空,由后端自动分配座位;选座则seat字段为所选座位信息,一等座,则seatTypeCode
为1,二等座为2,自动提供两排备选座位。
C1表示选择第1排的C号座位。
服务器售票功能
0. 业务数据校验
主要目的是防止恶意请求,直接绕过前端访问后端接口
车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过
1. 保存确认订单表,状态初始化
2. 查出余票记录,需要得到真是的库存
DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
3. 扣减余票数量,并判断余票是否足够
private static void reduceTickets(ConfirmOrderDoReq req, DailyTrainTicket dailyTrainTicket) {
for (ConfirmOrderTicketReq ticketReq : req.getTickets()) {
String seatTypeCode = ticketReq.getSeatTypeCode();
SeatTypeEnum seatTypeEnum = EnumUtil.getBy(SeatTypeEnum::getCode, seatTypeCode);
switch (seatTypeEnum) {
case YDZ -> {
int countLeft = dailyTrainTicket.getYdz() - 1;
if (countLeft < 0) {
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
}
dailyTrainTicket.setYdz(countLeft);
}
case EDZ -> {
int countLeft = dailyTrainTicket.getEdz() - 1;
if (countLeft < 0) {
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
}
dailyTrainTicket.setEdz(countLeft);
}
case RW -> {
int countLeft = dailyTrainTicket.getRw() - 1;
if (countLeft < 0) {
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
}
dailyTrainTicket.setRw(countLeft);
}
case YW -> {
int countLeft = dailyTrainTicket.getYw() - 1;
if (countLeft < 0) {
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR);
}
dailyTrainTicket.setYw(countLeft);
}
}
}
}
4. 选座开始
计算相对第一个座位的偏移值
eg:
一等座选择C1,D2,则偏移值为[0,5]
二等座选择A1,B1,C1,则偏移值为[0,1,2]
根据订单中订票座位类型,构造出和前端两排选座一样的列表,用于作参考的座位列表,例如:
referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
referSeatList = {A1, B1, C1, D1, F1, A2, B2, C2, D2, F2}
接着再根据订单中的座位,计算全部座位的绝对偏移值,即在referSeatList 数组中的下标位置,最后每一个座位减去首个座位的下标位置,可以得到偏移值数组。
// 计算相对第一个座位的偏移值
// 比如选择的是C1,D2,则偏移值是:[0,5]
// 比如选择的是A1,B1,C1,则偏移值是:[0,1,2]
ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
if (StrUtil.isNotBlank(ticketReq0.getSeat())) {
LOG.info("本次购票有选座");
// 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值
List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
LOG.info("本次选座的座位类型包含的列:{}", colEnumList);
// 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
List<String> referSeatList = new ArrayList<>();
for (int i = 1; i <= 2; i++) {
for (SeatColEnum seatColEnum : colEnumList) {
referSeatList.add(seatColEnum.getCode() + i);
}
}
LOG.info("用于作参照的两排座位:{}", referSeatList);
List<Integer> offsetList = new ArrayList<>();
// 绝对偏移值,即:在参照座位列表中的位置
List<Integer> aboluteOffsetList = new ArrayList<>();
for (ConfirmOrderTicketReq ticketReq : tickets) {
int index = referSeatList.indexOf(ticketReq.getSeat());
aboluteOffsetList.add(index);
}
LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList);
for (Integer index : aboluteOffsetList) {
int offset = index - aboluteOffsetList.get(0);
offsetList.add(offset);
}
LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList);
getSeat(finalSeatList,
date,
trainCode,
ticketReq0.getSeatTypeCode(),
ticketReq0.getSeat().split("")[0], // 从A1得到A
offsetList,
dailyTrainTicket.getStartIndex(),
dailyTrainTicket.getEndIndex()
);
}
打印结果:
4.1 一个车厢一个车厢获取座位数据
根据对应的车座类型,循环查找车次的对应车厢,如一等组车厢
再在每个车厢中,寻找符合偏移值条件的车座
4.2 挑选符合条件的座位,如果这个车厢不满足,则进入下一个车厢(多个选座应在一个车厢)
对于每一个未售卖且列号等于初始座位的座位,选中其。
接着判断各偏移量座位,只有全部偏移量座位都可选中,才能选中这些座位,否则要退出重选。
同时注意偏移量座位的座位号不能大于车厢座位数量,因为要保证在同一车厢。
/**
* 挑座位,如果有选座,则一次性挑完,如果无选座,则一个一个挑
* @param date
* @param trainCode
* @param seatType
* @param column
* @param offsetList
*/
private void getSeat(List<DailyTrainSeat> finalSeatList, Date date, String trainCode, String seatType, String column, List<Integer> offsetList, Integer startIndex, Integer endIndex) {
List<DailyTrainSeat> getSeatList = new ArrayList<>();
List<DailyTrainCarriage> carriageList = dailyTrainCarriageService.selectBySeatType(date, trainCode, seatType);
LOG.info("共查出{}个符合条件的车厢", carriageList.size());
// 一个车箱一个车箱的获取座位数据
for (DailyTrainCarriage dailyTrainCarriage : carriageList) {
LOG.info("开始从车厢{}选座", dailyTrainCarriage.getIndex());
getSeatList = new ArrayList<>();
List<DailyTrainSeat> seatList = dailyTrainSeatService.selectByCarriage(date, trainCode, dailyTrainCarriage.getIndex());
LOG.info("车厢{}的座位数:{}", dailyTrainCarriage.getIndex(), seatList.size());
for (int i = 0; i < seatList.size(); i++) {
DailyTrainSeat dailyTrainSeat = seatList.get(i);
Integer seatIndex = dailyTrainSeat.getCarriageSeatIndex();
String col = dailyTrainSeat.getCol();
// 判断当前座位不能被选中过
boolean alreadyChooseFlag = false;
for (DailyTrainSeat finalSeat : finalSeatList){
if (finalSeat.getId().equals(dailyTrainSeat.getId())) {
alreadyChooseFlag = true;
break;
}
}
if (alreadyChooseFlag) {
LOG.info("座位{}被选中过,不能重复选中,继续判断下一个座位", seatIndex);
continue;
}
// 判断column,有值的话要比对列号
if (StrUtil.isBlank(column)) {
LOG.info("无选座");
} else {
if (!column.equals(col)) {
LOG.info("座位{}列值不对,继续判断下一个座位,当前列值:{},目标列值:{}", seatIndex, col, column);
continue;
}
}
boolean isChoose = calSell(dailyTrainSeat, startIndex, endIndex);
if (isChoose) {
LOG.info("选中座位");
getSeatList.add(dailyTrainSeat);
} else {
continue;
}
// 根据offset选剩下的座位
boolean isGetAllOffsetSeat = true;
if (CollUtil.isNotEmpty(offsetList)) {
LOG.info("有偏移值:{},校验偏移的座位是否可选", offsetList);
// 从索引1开始,索引0就是当前已选中的票
for (int j = 1; j < offsetList.size(); j++) {
Integer offset = offsetList.get(j);
// 座位在库的索引是从1开始
// int nextIndex = seatIndex + offset - 1;
int nextIndex = i + offset;
// 有选座时,一定是在同一个车箱
if (nextIndex >= seatList.size()) {
LOG.info("座位{}不可选,偏移后的索引超出了这个车箱的座位数", nextIndex);
isGetAllOffsetSeat = false;
break;
}
DailyTrainSeat nextDailyTrainSeat = seatList.get(nextIndex);
boolean isChooseNext = calSell(nextDailyTrainSeat, startIndex, endIndex);
if (isChooseNext) {
LOG.info("座位{}被选中", nextDailyTrainSeat.getCarriageSeatIndex());
getSeatList.add(nextDailyTrainSeat);
} else {
LOG.info("座位{}不可选", nextDailyTrainSeat.getCarriageSeatIndex());
isGetAllOffsetSeat = false;
break;
}
}
}
if (!isGetAllOffsetSeat) {
getSeatList = new ArrayList<>();
continue;
}
// 保存选好的座位
finalSeatList.addAll(getSeatList);
return;
}
}
}
计算某座位在区间内是否可卖:
/**
* 计算某座位在区间内是否可卖
* 例:sell=10001,本次购买区间站1~4,则区间已售000
* 全部是0,表示这个区间可买;只要有1,就表示区间内已售过票
*
* 选中后,要计算购票后的sell,比如原来是10001,本次购买区间站1~4
* 方案:构造本次购票造成的售卖信息01110,和原sell 10001按位与,最终得到11111
*/
private boolean calSell(DailyTrainSeat dailyTrainSeat, Integer startIndex, Integer endIndex) {
// 00001, 00000
String sell = dailyTrainSeat.getSell();
// 000, 000
String sellPart = sell.substring(startIndex, endIndex);
if (Integer.parseInt(sellPart) > 0) {
LOG.info("座位{}在本次车站区间{}~{}已售过票,不可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
return false;
} else {
LOG.info("座位{}在本次车站区间{}~{}未售过票,可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex);
// 111, 111
String curSell = sellPart.replace('0', '1');
// 0111, 0111
curSell = StrUtil.fillBefore(curSell, '0', endIndex);
// 01110, 01110
curSell = StrUtil.fillAfter(curSell, '0', sell.length());
// 当前区间售票信息curSell 01110与库里的已售信息sell 00001按位与,即可得到该座位卖出此票后的售票详情
// 15(01111), 14(01110 = 01110|00000)
int newSellInt = NumberUtil.binaryToInt(curSell) | NumberUtil.binaryToInt(sell);
// 1111, 1110
String newSell = NumberUtil.getBinaryStr(newSellInt);
// 01111, 01110
newSell = StrUtil.fillBefore(newSell, '0', sell.length());
LOG.info("座位{}被选中,原售票信息:{},车站区间:{}~{},即:{},最终售票信息:{}"
, dailyTrainSeat.getCarriageSeatIndex(), sell, startIndex, endIndex, curSell, newSell);
dailyTrainSeat.setSell(newSell);
return true;
}
}
5. 选中座位后事务处理
5.1 座位表修改售卖情况为sell
5.2 余票详情表修改余票
5.3 为会员增加购票记录
5.4 更新确认订单为成功
/**
* 选中座位后事务处理:
* 座位表修改售卖情况sell;
* 余票详情表修改余票;
* 为会员增加购票记录
* 更新确认订单为成功
*/
// @Transactional
// @GlobalTransactional
public void afterDoConfirm(DailyTrainTicket dailyTrainTicket, List<DailyTrainSeat> finalSeatList, List<ConfirmOrderTicketReq> tickets, ConfirmOrder confirmOrder) throws Exception {
// LOG.info("se ata全局事务ID: {}", RootContext.getXID());
for (int j = 0; j < finalSeatList.size(); j++) {
DailyTrainSeat dailyTrainSeat = finalSeatList.get(j);
DailyTrainSeat seatForUpdate = new DailyTrainSeat();
seatForUpdate.setId(dailyTrainSeat.getId());
seatForUpdate.setSell(dailyTrainSeat.getSell());
.setUpdateTime(new Date());
dailyTrainSeatMapper.updateByPrimaryKeySelective(seatForUpdate);
// 计算这个站卖出去后,影响了哪些站的余票库存
// 参照2-3节 如何保证不超卖、不少卖,还要能承受极高的并发 10:30左右
// 影响的库存:本次选座之前没卖过票的,和本次购买的区间有交集的区间
// 假设10个站,本次买4~7站
// 原售:001000001
// 购买:000011100
// 新售:001011101
// 影响:XXX11111X
// Integer startIndex = 4;
// Integer endIndex = 7;
// Integer minStartIndex = startIndex - 往前碰到的最后一个0;
// Integer maxStartIndex = endIndex - 1;
// Integer minEndIndex = startIndex + 1;
// Integer maxEndIndex = endIndex + 往后碰到的最后一个0;
Integer startIndex = dailyTrainTicket.getStartIndex();
Integer endIndex = dailyTrainTicket.getEndIndex();
char[] chars = seatForUpdate.getSell().toCharArray();
Integer maxStartIndex = endIndex - 1;
Integer minEndIndex = startIndex + 1;
Integer minStartIndex = 0;
for (int i = startIndex - 1; i >= 0; i--) {
char aChar = chars[i];
if (aChar == '1') {
minStartIndex = i + 1;
break;
}
}
LOG.info("影响出发站区间:" + minStartIndex + "-" + maxStartIndex);
Integer maxEndIndex = seatForUpdate.getSell().length();
for (int i = endIndex; i < seatForUpdate.getSell().length(); i++) {
char aChar = chars[i];
if (aChar == '1') {
maxEndIndex = i;
break;
}
}
LOG.info("影响到达站区间:" + minEndIndex + "-" + maxEndIndex);
dailyTrainTicketMapperCust.updateCountBySell(
dailyTrainSeat.getDate(),
dailyTrainSeat.getTrainCode(),
dailyTrainSeat.getSeatType(),
minStartIndex,
maxStartIndex,
minEndIndex,
maxEndIndex);
// 调用会员服务接口,为会员增加一张车票
MemberTicketReq memberTicketReq = new MemberTicketReq();
memberTicketReq.setMemberId(confirmOrder.getMemberId());
memberTicketReq.setPassengerId(tickets.get(j).getPassengerId());
memberTicketReq.setPassengerName(tickets.get(j).getPassengerName());
memberTicketReq.setTrainDate(dailyTrainTicket.getDate());
memberTicketReq.setTrainCode(dailyTrainTicket.getTrainCode());
memberTicketReq.setCarriageIndex(dailyTrainSeat.getCarriageIndex());
memberTicketReq.setSeatRow(dailyTrainSeat.getRow());
memberTicketReq.setSeatCol(dailyTrainSeat.getCol());
memberTicketReq.setStartStation(dailyTrainTicket.getStart());
memberTicketReq.setStartTime(dailyTrainTicket.getStartTime());
memberTicketReq.setEndStation(dailyTrainTicket.getEnd());
memberTicketReq.setEndTime(dailyTrainTicket.getEndTime());
memberTicketReq.setSeatType(dailyTrainSeat.getSeatType());
CommonResp<Object> commonResp = memberFeign.save(memberTicketReq);
LOG.info("调用member接口,返回:{}", commonResp);
// 更新订单状态为成功
ConfirmOrder confirmOrderForUpdate = new ConfirmOrder();
confirmOrderForUpdate.setId(confirmOrder.getId());
confirmOrderForUpdate.setUpdateTime(new Date());
confirmOrderForUpdate.setStatus(ConfirmOrderStatusEnum.SUCCESS.getCode());
confirmOrderMapper.updateByPrimaryKeySelective(confirmOrderForUpdate);
}
}
同时利用feign调用member模块的save接口,保存购票信息
@FeignClient(name = "member", url = "http://127.0.0.1:8001")
public interface MemberFeign {
@GetMapping("/member/feign/ticket/save")
CommonResp<Object> save(@RequestBody MemberTicketReq req);
}
member模块中:
@RestController
@RequestMapping("/feign/ticket")
public class FeignTicketController {
@Autowired
private TicketService ticketService;
@PostMapping("/save")
public CommonResp<Object> save(@Valid @RequestBody MemberTicketReq req) throws Exception {
ticketService.save(req);
return new CommonResp<>();
}
}