高并发秒杀API(三)

前言

本篇将完成Service层的设计与开发,包括:

  • 秒杀业务接口设计与实现
  • 基于Spring托管Service实现类
  • 配置Spring声明式事务
  • Service层集成测试

一、秒杀业务接口设计

在org.seckill包下创建Service层需要的包:

  • service:存放Service接口和实现类
  • exception:存放Service接口所需要的一些自定义异常,比如重复秒杀、秒杀关闭等
  • dto:数据传输层,和entity有些类似,都是存放表示数据的一些类型,entity关注的是业务上的一些封装,dto关注的是WEB和Service之间的数据传递

接着在service包下新建一个SeckillService接口

首先要做一个列表页的话,就要拿到Seckill的所有属性

    /**
     * 查询所有秒杀商品记录
     * @return
     */
    List<Seckill> getSeckillList();
    
    /**
     * 查询单个秒杀商品记录
     * @param seckillId
     * @return
     */
    Seckill getById(long seckillId);

这是基本的查询方法,接下来是一些行为的方法

    /**
     * 秒杀开启时输出秒杀接口地址
     * 否则输出系统时间和秒杀时间
     * @param seckillId
     * @return
     */
    Exposer exportSeckillUrl(long seckillId);

在秒杀活动开始之前,要保证没有人知道秒杀接口的地址,不能让用户通过url规则拼出来秒杀活动的地址,所以这个方法在秒杀活动开启时输出秒杀接口地址,否则输出系统时间和秒杀时间

那么这个方法返回的类型是什么呢?活动期间,这个方法返回的是秒杀接口地址url,不在活动期间,返回的是一些日期,都是和业务不相关的一些数据,所以我们需要一个dto

在dto包下新建一个Exposer类,用来暴露秒杀接口,或者输出日期

/**
 * 暴露秒杀地址DTO
 * @author Fzero
 *
 */
public class Exposer {
    
    //是否开启秒杀
    private boolean exposed;
    
    //加密措施
    private String md5;
    
    //id
    private long seckillId;
    
    //系统当前时间(毫秒)
    private long now;
    
    //秒杀开启时间
    private long start;
    
    //秒杀结束时间
    private long end;

    @Override
    public String toString() {
        return "Exposer [exposed=" + exposed + 
                ", md5=" + md5 + 
                ", seckillId=" + seckillId + 
                ", now=" + now + 
                ", start=" + start + 
                ", end=" + end + 
                "]";
    }
}

首先是设置一个开关exposerd,判断秒杀接口是否要暴露,对于暴露的接口,还要对接口进行一下改动,加上简单的md5,还有上面说的系统时间和秒杀时间,复写toString方法,使得日志输出的时候方便获取属性值,并生成get和set方法

为了方便初始化,需要设置几个构造方法

    public Exposer(boolean exposed, String md5, long seckillId) {
        super();
        this.exposed = exposed;
        this.md5 = md5;
        this.seckillId = seckillId;
    }

如果在秒杀活动期内,存储MD5,如果不在活动期内,MD5的值为空

    public Exposer(boolean exposed, long seckillId, long now, long start, long end) {
        super();
        this.exposed = exposed;
        this.seckillId = seckillId;
        this.now = now;
        this.start = start;
        this.end = end;
    }

如果不在秒杀活动期,返回系统时间和秒杀活动开启、关闭时间

    public Exposer(boolean exposed, long seckillId) {
        super();
        this.exposed = exposed;
        this.seckillId = seckillId;
    }

是否开启秒杀,返回seckillId

Exposer类完成,返回SeckillService接口

接下来就是执行秒杀操作的方法

    /**
     * 执行秒杀操作
     * @param seckillId
     * @param userPhone
     * @param md5
     * @return
     */
    SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
        throws SeckillException,RepeatKillException,SeckillCloseException;

之前说过,唯一确定一个用户的方法是seckillId和phone,seckillId加上自定义的盐值,通过MD5加密,一定程度上能够防止用户通过url规则拼出秒杀接口地址,因为Exposer方法先被调用,里面有MD5的值,传递进来的MD5的值要和内部生成的一个MD5规则进行比较,如果不吻合,说明用户的url被篡改了,不执行秒杀操作,返回的类型还要再封装一个DTO接口,用来封装秒杀执行后的结果,那么秒杀执行后的结果需要什么数据呢?秒杀成功了还是失败了?如果失败了,还要知道为什么失败了

在dto包下新建一个SeckillExecution类

public class SeckillExecution {
    
    private long seckillId;
    
    //秒杀结果执行后的状态
    private int state;
    
    //状态信息
    private String stateInfo;

    //秒杀成功对象
    private SuccessKilled successKilled;
    
    @Override
    public String toString() {
        return "SeckillExecution [seckillId=" + 
                "" + seckillId + 
                ", state=" + state + 
                ", stateInfo=" + stateInfo+ 
                ", successKilled=" + successKilled + 
                "]";
    }
}

这里需要实例化SuccessKilled对象,如果秒杀成功,要能获得成功秒杀商品的用户信息,接着直接生成get和set方法,还要初始化几个构造方法

    public SeckillExecution(long seckillId, SeckillStateEnum stateEnum, SuccessKilled successKilled) {
        super();
        this.seckillId = seckillId;
        this.state = stateEnum.getState();
        this.stateInfo = stateEnum.getStateString();
        this.successKilled = successKilled;
    }

秒杀成功后,返回所有的属性值

在数据库success_killed表中,有state作为状态标识,不同的数字表示不同的状态,在接下来的代码中,也经常用到这些状态标识,但是这些状态标识和状态信息,应该是输出给前端的,这个是属于数据字典中的,没必要在代码中频繁出现,像这种常量,建议使用枚举表示,所以在org.seckill包下新建一个枚举包enums,新建一个枚举类型的文件SeckillStateEnum

public enum SeckillStateEnum {

    SUCCESS(1, "秒杀成功"),
    END(0, "秒杀结束"),
    REPEAT_KILL(-1, "重复秒杀"),
    INNER_ERROR(-2, "系统异常"),
    DATA_REWRITE(-2, "数据篡改");
    
    private int state;
    
    private String stateString;

    private SeckillStateEnum(int state, String stateString) {
        this.state = state;
        this.stateString = stateString;
    }
}

初始化一个构造方法,并生成get方法

接着创建一个方法

    public static SeckillStateEnum stateOf(int index){
        for(SeckillStateEnum state : values()){
            if(state.getState() == index){
                return state;
            }
        }
        return null;
    }

返回state状态标识,可以通过状态标识获得相应的状态信息,本项目的数据字典已经完成了

在dto包下的SeckillExecution类还要再初始化一个构造方法

    public SeckillExecution(long seckillId, SeckillStateEnum stateEnum) {
        super();
        this.seckillId = seckillId;
        this.state = stateEnum.getState();
        this.stateInfo = stateEnum.getStateString();
    }

秒杀失败后返回相应的状态信息

这时还有个小问题,就是当执行异常的时候,要告知接口使用方这个方法可能会输出什么样的异常,因为跟业务相关的异常可以利用起来

在存放异常的exception包下新建一个SeckillException类,异常一般是作为通用的接口或类,解决某一层面的问题

/**
 * 秒杀业务相关异常
 * @author Fzero
 *
 */
public class SeckillException extends RuntimeException {

    public SeckillException(String message, Throwable cause) {
        super(message, cause);
        // TODO Auto-generated constructor stub
    }

    public SeckillException(String message) {
        super(message);
        // TODO Auto-generated constructor stub
    }

}

继承与RuntimeException,初始化两个构造方法

这个异常本质上是一个运行期异常,运行期异常不需要手动的try/catch,使用运行期异常还有个重要的原因,就是** Spring的声明式事务,它只接收运行期异常回滚策略,也就是当抛出一个非运行期异常,Spring的声明式事务不会帮我们做回滚的 **

接着可以定义一些业务中比较常见的异常,可以单独捕捉或处理

新建一个RepeatKillException类,从名字可以看出,处理的是重复秒杀的异常

/**
 * 重复秒杀异常(运行期异常)
 * @author Fzero
 *
 */
public class RepeatKillException extends SeckillException {

    public RepeatKillException(String message, Throwable cause) {
        super(message, cause);
        // TODO Auto-generated constructor stub
    }

    public RepeatKillException(String message) {
        super(message);
        // TODO Auto-generated constructor stub
    }

}

再新建一个SeckillCloseException类,秒杀关闭异常

/**
 * 秒杀关闭异常
 * @author Fzero
 *
 */
public class SeckillCloseException extends SeckillException {

    public SeckillCloseException(String message, Throwable cause) {
        super(message, cause);
        // TODO Auto-generated constructor stub
    }

    public SeckillCloseException(String message) {
        super(message);
        // TODO Auto-generated constructor stub
    }

}

秒杀活动关闭后,秒杀的执行不能被允许,比如时间到了,或者库存消耗光了

二、秒杀接口实现

在service包下新建一个impl包,用于存放实现Service接口的实现类,新建一个SeckillService接口的实现类SeckillServiceImpl,然后复写SeckillService的四个方法

public class SeckillServiceImpl implements SeckillService {
    
    private Logger logger = LoggerFactory.getLogger(this.getClass());
    
    private SeckillDao seckillDao;

    private SuccessKilledDao successKilledDao;
    
    //MD5盐值字符串,用户混淆MD5
    private final String slat = "sldjflsjdfwiejfwjefi$&**(&*&";

    public List<Seckill> getSeckillList() {
        return seckillDao.queryAll(0, 4);
    }

    public Seckill getById(long seckillId) {
        return seckillDao.queryById(seckillId);
    }
}

这里要用DAO中的接口,把之前设计的两个接口实例化并生成get方法,但不初始化,因为所有DAO接口的实现类都在Spring容器当中,然后是实例化日志对象,导入org.slf4j包,接着修改复写的方法

    public Exposer exportSeckillUrl(long seckillId) {
        Seckill seckill = seckillDao.queryById(seckillId);
        if(seckill == null){
            return new Exposer(false, seckillId);
        }
        Date startTime = seckill.getStartTime();
        Date endTime = seckill.getEndTime();
        Date nowTime = new Date();//系统当前时间
        if(nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()){
            return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime());
        }
        String md5 = getMD5(seckillId);
        return new Exposer(true, md5, seckillId);
    }

这个方法就是用于显示秒杀接口的地址,首先要进行一个判断,如果seckill为空,就返回一个Exposer,使用Exposer中的构造方法,如果不为空,但是不在秒杀活动期内,就返回秒杀活动的开始时间、结束时间和系统时间,最后既然都判断完了,时间在秒杀活动内,就要返回秒杀开启的一些信息,通过返回的MD5才能与用户输入的地址是否正确

为了不让用户猜到返回值,前面已经定义了一个用于混淆的值slat,越复杂越好,然后创建一个方法,生成MD5

    private String getMD5(long seckillId){
        String base = seckillId + "/" + slat;
        String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
        return md5;
    }

如果只使用id进行MD5加密的话,实际上用户是可以通过算法跑出来的,如果加上混淆的字符串,几乎是不可能的

最后要完成的是执行秒杀方法的实现

    public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) {
        if(md5 == null || !md5.equals(getMD5(seckillId))){
            throw new SeckillException("seckill data rewrite");
        }
        //执行秒杀逻辑:减库存 + 记录购买行为
        Date nowTime = new Date();
        try {
            //减库存
            int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
            if(updateCount <= 0){
                //没有更新记录,即秒杀活动结束
                throw new SeckillCloseException("seckill is closed");
            } else {
                //记录购买行为
                int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
                if(insertCount <= 0){
                    //重复秒杀
                    throw new RepeatKillException("seckill repeated");
                } else {
                    //秒杀成功
                    SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
                    return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, successKilled);
                }
            }
        } catch(SeckillCloseException e1) {
            throw e1;
        } catch(RepeatKillException e2){
            throw e2;
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
            //所有编译期异常转化为运行期异常
            throw new SeckillException("seckill inner error: " + e.getMessage());
        }
    }

首先要判断用户传递过来的MD5是否和我们设置的MD5的值是否相同,不相同或是空的话,抛出一个SeckillException异常

成功秒杀后,就要执行秒杀逻辑,即减库存+记录购买行为,以系统当前时间为秒杀时间,reduceNumber方法还能判断秒杀时间是否在活动时间内,如果返回的值小于等于0,说明没有更新记录,即秒杀活动结束了

成功减库存后,就要记录购买行为了,同样进行判断,如果返回值小于等于0 说明重复秒杀,insert语句不执行,如果秒杀成功,返回SuccessKilled对象

最后,整个逻辑要try/catch,因为可能会有其他的异常,比如超时、链接断开等,并抛出业务异常,因为SeckillException继承自RuntimeException,所以这里就把编译期异常,转化为运行期异常,这样Spring的声明式事务可以帮我们做回滚操作,因为之前方法中会可能有指定的异常,比如重复秒杀,秒杀关闭等,所以在SeckillException父类异常之前catch住,然后抛出

至此,Service层接口的实现类完成了

三、基于Spring管理Service依赖

管理Service,本质上是通过Spring IOC功能,即依赖注入


Spring IOC

对于Spring IOC,首先会有一个创建对象的过程,也就是对象工厂,这个工厂可以创建SeckillService的实现

而SeckillService也需要众多依赖,比如DAO层的依赖,所以Spring IOC不但帮我们创建SeckillDAO和SuccessKilledDAO的实现,还要创建Service层的实现,并且把这些实现组成依赖,最终提供一个一直的访问接口,通过这个接口,我们可以访问工厂当中任意的实例

业务对象依赖

SeckillService依赖DAO层的两个接口,DAO的接口依赖于SqlSessionFactory,而SqlSessionFactory也需要数据源等一些依赖,整个这个初始化过程,都是由Spring自动组装好,最后给我们的是一个SeckillService,一个完整的实例

在resources/spring包下新建一个spring-service.xml,可以把spring-dao.xml中beans标签中的内容复制过来,当然,在这基础场在添加三行内容,关于tx的命名空间

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx.xsd">
<beans>

首先配置的是扫描service包下所有使用注解的类型

    <!-- 扫描service包下所有使用注解的依赖 -->
    <context:component-scan base-package="org.seckill.service"/>

扫描后会初始化这些类型,然后放到Spring容器当中,如果这些类型中有其他的依赖,比如SeckillServiceImpl类中有SeckillDao和SuccessKilledDao的依赖,这个配置也会初始化这些依赖

那么是哪些注解?首先是@Component,代表所有的组件,当不清楚这个类或者属性是属于DAO或者是Service的时候,可以使用这个,当然还有@Service、@Dao、@Conroller等

对于SeckillServiceImpl,很明显,在类上添加@Service注解

@Service
public class SeckillServiceImpl implements SeckillService

同样,在SeckillServiceImpl方法中,对于seckillDao属性和successKillDao属性

    //注入Service依赖
    @Autowired
    private SeckillDao seckillDao;
    
    @Autowired
    private SuccessKilledDao successKilledDao;

MyBatis和Spring整合之后,MyBatis下所有的DAO接口都会使用mapper的方式初始化好,然后放到Spring容器当中,要在Spring容器中获取DAO的实例,并注入到Service中,可以使用Spring提供的注解@Autowired,使用之后,它就会在Spring容器中查找SeckillDao的实例,因为之前MyBatis的mapper已经初始化好并放在Spring容器中,所以就会注入到Service的属性中,不需要手动的new一个

四、Spring声明式事务

声明式事务基本流程

声明式事务的基本流程,先是开启事务,然后修改SQL语句,最后再提交或回滚事务

由于** Spring只有在抛出运行期异常(RuntimeException)时才会执行回滚操作 **,所以在代码中要小心使用try/catch,当异常被捕捉到的时候,Spring接收不到异常,就不会执行回滚操作

打开spring-service.xml,开始配置Spring声明式事务

    <!-- 配置事务管理器 -->
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <!-- 注入数据库连接池 -->
        <property name="dataSource" ref="dataSource"></property>
    </bean>

只要用到事务,首先配置的就是事务管理器,MyBatis默认的是jdbc的事务管理器,接着注入数据库连接池

然后配置基于注解的声明式事务,这个配置的作用就是默认使用注解来管理事务行为

    <!-- 配置基于注解的声明式事务:默认使用注解来管理事务行为 -->
    <tx:annotation-driven transaction-manager="transactionManager"/>

打开SeckillServiceImpl类,在executeSeckill方法上添加@Transactional注解

    @Transactional
    public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)

不是所有的方法都要在事务的控制之下,比如只读,所以只在会对数据库进行更新操作的方法上添加Spring的声明式事务注解,这样就可以保证该方法运行在事务的控制之下,防止数据被错误的修改

五、Service层集成测试

对SeckillService类生成相应的测试类,选中所有的方法,同样在类上加上@RunWith和@ContextConfiguration注解

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({
    "classpath:spring/spring-dao.xml",
    "classpath:spring/spring-service.xml"
})
public class SeckillServiceTest

然后通过Spring依赖注入的方式,将测试类的属性注入到当前测试类下,在测试之前,先把日志的输出定义一下,导入org.slf4j包

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    @Autowired
    private SeckillService seckillService;

不过slf4j只是接口,要实现日志需要logback,在resources目录下新建一个logback.xml文件,然后打开logback官网,找到相关配置示例,复制过来

logback官网

先测试两个获取Seckill数据的方法

    @Test
    public void testGetSeckillList() {
        List<Seckill> list = seckillService.getSeckillList();
        logger.info("list={}", list);
    }

    @Test
    public void testGetById() {
        long id = 1000;
        Seckill seckill = seckillService.getById(id);
        logger.info("seckill={}", seckill);
    }
getSeckillList方法测试结果

可以看到最下面拿到了List

getById方法测试结果

接着测试exportSeckillUrl方法,用于显示秒杀接口地址或者是日期

    @Test
    public void testExportSeckillUrl() {
        long id = 1000;
        Exposer exposer = seckillService.exportSeckillUrl(id);
        logger.info("exposer={}", exposer);
    }
exportSeckillUrl方法测试结果

最下面显示了MD5的值,如果返回的MD5为null,说明不在秒杀活动期内,想看到MD5的话,在控制台进入MySQL,把seckill表中的start_time/end_time改一下

最后是executeSeckill方法

    @Test
    public void testExecuteSeckill() {
        long id = 1000;
        long userPhone = 13587456321L;
        String md5 = "f540e8a6bf6016cea1d929ca265dfed3";
        try {
            SeckillExecution execution = seckillService.executeSeckill(id, userPhone, md5);
            logger.info("result={}", execution);
        } catch (RepeatKillException e) {
            logger.error(e.getMessage());
        } catch (SeckillCloseException e) {
            logger.error(e.getMessage());
        }
    }
executeSeckill方法测试结果

第一条是之前测试的数据

方法中的MD5的值是上个方法测试后日志输出时的数据

当然,最后两个方法可以放在一个测试方法中,免得要手动复制MD5

    @Test
    public void testSeckillLogic() {
        long id = 1001;
        Exposer exposer = seckillService.exportSeckillUrl(id);
        if(exposer.isExposed()){
            logger.info("exposer={}", exposer);
            long userPhone = 13587456321L;
            String md5 = exposer.getMd5();
            try {
                SeckillExecution execution = seckillService.executeSeckill(id, userPhone, md5);
                logger.info("result={}", execution);
            } catch (RepeatKillException e) {
                logger.error(e.getMessage());
            } catch (SeckillCloseException e) {
                logger.error(e.getMessage());
            }
        } else {
            //秒杀未开启
            logger.warn("exposer={}", exposer);
        }

这里的try/catch其实是为了保证测试类的通过,因为像重复秒杀异常(RepeatKillException)、秒杀关闭异常(SeckillCloseException)都是自定义的异常,这些异常报错,也说明测试通过了,所以为了统一,把这些用于测试的异常也catch住并打印

至此,Service层接口的设计与实现完成了

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容