1. 尚庭公寓项目移动端后端开发实战
作为一名长期从事Java后端开发的工程师,最近我参与了尚庭公寓项目的移动端后端开发工作。这个项目是一个完整的公寓租赁系统,包含后台管理系统和移动端应用。在本文中,我将详细分享移动端后端开发的关键实现细节和实战经验,特别是登录认证、短信验证码、地区信息查询和房间信息管理等核心功能的实现。
1.1 项目初始配置
1.1.1 SpringBoot基础配置
我们使用SpringBoot作为基础框架,在web-app模块中创建了标准的SpringBoot应用结构。首先在src/main/resources目录下创建了application.yml配置文件:
yaml复制server:
port: 8081
这个配置指定了应用运行的端口号为8081。选择8081端口是为了避免与可能运行在同一机器上的其他服务冲突。同时创建了SpringBoot启动类:
java复制package org.example.lease;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AppWebApplication {
public static void main(String[] args) {
SpringApplication.run(AppWebApplication.class);
}
}
提示:在实际项目中,建议使用
@SpringBootApplication(scanBasePackages = "org.example.lease")明确指定扫描路径,避免因包结构复杂导致的组件扫描问题。
1.1.2 Mybatis-Plus数据库配置
为了与数据库交互,我们配置了Mybatis-Plus:
yaml复制spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://<hostname>:<port>/<database>?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=GMT%2b8
username: <username>
password: <password>
hikari:
connection-test-query: SELECT 1
connection-timeout: 60000
idle-timeout: 500000
max-lifetime: 540000
maximum-pool-size: 12
minimum-idle: 10
pool-name: SPHHikariPool
jackson:
time-zone: GMT+8
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
这里有几个关键点需要注意:
- 使用HikariCP作为连接池,这是SpringBoot 2.x后的默认选择,性能优于传统的DBCP
- 连接池大小(maximum-pool-size)设置为12,这是根据我们预估的并发量调整的
- 开启了Mybatis-Plus的SQL日志输出,方便开发阶段调试
1.1.3 Knife4j接口文档配置
为了方便前后端协作,我们集成了Knife4j作为API文档工具:
java复制@Configuration
public class Knife4jConfiguration {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("APP接口")
.version("1.0")
.description("用户端APP接口")
.termsOfService("http://doc.xiaominfo.com")
.license(new License().name("Apache 2.0")
.url("http://doc.xiaominfo.com")));
}
@Bean
public GroupedOpenApi loginAPI() {
return GroupedOpenApi.builder().group("登录信息")
.pathsToMatch("/app/login/**", "/app/info")
.build();
}
// 其他API分组...
}
并在application.yml中添加了配置:
yaml复制springdoc:
default-flat-param-object: true
经验分享:在实际项目中,我们通常会按业务模块对API进行分组,这样前端开发人员可以更快地找到需要的接口。同时,建议为每个接口添加详细的@Operation注解说明,包括参数含义、返回值示例等。
2. 登录管理模块实现
2.1 短信验证码功能
2.1.1 阿里云短信服务集成
我们使用阿里云的短信服务来实现验证码发送功能。首先在pom.xml中添加依赖:
xml复制<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
<version>2.0.0</version>
</dependency>
然后在application.yml中配置阿里云访问密钥:
yaml复制aliyun:
sms:
access-key-id: <access-key-id>
access-key-secret: <access-key-secret>
endpoint: dysmsapi.aliyuncs.com
创建配置类来初始化短信客户端:
java复制@Configuration
@EnableConfigurationProperties(AliyunSMSProperties.class)
@ConditionalOnProperty(name = "aliyun.sms.endpoint")
public class AliyunSmsConfiguration {
@Autowired
private AliyunSMSProperties properties;
@Bean
public Client smsClient() {
Config config = new Config();
config.setAccessKeyId(properties.getAccessKeyId());
config.setAccessKeySecret(properties.getAccessKeySecret());
config.setEndpoint(properties.getEndpoint());
try {
return new Client(config);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
2.1.2 验证码发送实现
验证码服务接口实现:
java复制@Service
public class SmsServiceImpl implements SmsService {
@Autowired
private Client client;
@Override
public void sendCode(String phone, String verifyCode) {
SendSmsVerifyCodeRequest request = new SendSmsVerifyCodeRequest()
.setPhoneNumber(phone)
.setSignName("速通互联验证码")
.setTemplateCode("100001")
.setTemplateParam("{\"code\":\"" + verifyCode + "\",\"min\":\"5\"}");
try {
RuntimeOptions runtimeOptions = new RuntimeOptions();
client.sendSmsVerifyCodeWithOptions(request,runtimeOptions);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
注意事项:阿里云短信服务对模板内容有严格审核,必须提前在控制台申请并通过审核才能使用。个人开发者可能无法申请某些类型的模板,建议使用企业账号。
2.1.3 验证码业务逻辑
完整的验证码获取逻辑实现:
java复制@Override
public void getSMSCode(String phone) {
// 1.检查手机号码是否为空
if (!StringUtils.hasText(phone)) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_PHONE_EMPTY);
}
// 2.检查Redis中是否已经存在该手机号码的key
String key = RedisConstant.APP_LOGIN_PREFIX + phone;
boolean hasKey = stringRedisTemplate.hasKey(key);
if (hasKey) {
// 若存在,则检查其存在的时间
Long expire = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
if (RedisConstant.APP_LOGIN_CODE_TTL_SEC - expire < RedisConstant.APP_LOGIN_CODE_RESEND_TIME_SEC) {
// 若存在时间不足一分钟,响应发送过于频繁
throw new LeaseException(ResultCodeEnum.APP_SEND_SMS_TOO_OFTEN);
}
}
// 3.发送短信,并将验证码存入Redis
String verifyCode = VerifyCodeUtil.getVerifyCode(6);
smsService.sendCode(phone, verifyCode);
stringRedisTemplate.opsForValue().set(key, verifyCode, RedisConstant.APP_LOGIN_CODE_TTL_SEC, TimeUnit.SECONDS);
}
这里有几个关键设计点:
- 使用Redis缓存验证码,并设置5分钟的有效期
- 实现了防刷机制,同一手机号60秒内只能发送一次验证码
- 验证码为6位随机数字,使用工具类生成
2.2 登录与注册功能
2.2.1 登录接口实现
登录接口的业务逻辑:
java复制@Override
public String login(LoginVo loginVo) {
// 1.判断手机号码和验证码是否为空
if (!StringUtils.hasText(loginVo.getPhone())) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_PHONE_EMPTY);
}
if (!StringUtils.hasText(loginVo.getCode())) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_CODE_EMPTY);
}
// 2.校验验证码
String key = RedisConstant.APP_LOGIN_PREFIX + loginVo.getPhone();
String code = stringRedisTemplate.opsForValue().get(key);
if (code == null) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_CODE_EXPIRED);
}
if (!code.equals(loginVo.getCode())) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_CODE_ERROR);
}
// 3.判断用户是否存在,不存在则注册
LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfo::getPhone, loginVo.getPhone());
UserInfo userInfo = userInfoService.getOne(queryWrapper);
if (userInfo == null) {
userInfo = new UserInfo();
userInfo.setPhone(loginVo.getPhone());
userInfo.setStatus(BaseStatus.ENABLE);
userInfo.setNickname("用户" + loginVo.getPhone().substring(6));
userInfoService.save(userInfo);
}
// 4.判断用户是否被禁
if (userInfo.getStatus().equals(BaseStatus.DISABLE)) {
throw new LeaseException(ResultCodeEnum.APP_ACCOUNT_DISABLED_ERROR);
}
// 5.创建JWT并返回
return JwtUtil.creatToken(userInfo.getId(), loginVo.getPhone());
}
2.2.2 JWT认证拦截器
为了实现接口鉴权,我们创建了JWT认证拦截器:
java复制@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("access-token");
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
LoginUserHolder.setLoginUser(new LoginUser(userId, username));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
LoginUserHolder.clear();
}
}
并在WebMvcConfigurer中注册:
java复制@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private AuthenticationInterceptor authenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.authenticationInterceptor)
.addPathPatterns("/app/**")
.excludePathPatterns("/app/login/**");
}
}
实战经验:在实现JWT认证时,我们使用了ThreadLocal来保存当前登录用户信息,这样在业务代码中可以通过LoginUserHolder.getLoginUser()方便地获取用户信息。但一定要注意在afterCompletion中清理ThreadLocal,否则可能导致内存泄漏。
3. 地区信息管理
3.1 地区信息查询接口
地区信息采用省-市-区三级结构,我们实现了以下接口:
java复制@Operation(summary = "查询省份信息列表")
@GetMapping("province/list")
public Result<List<ProvinceInfo>> listProvince() {
List<ProvinceInfo> list = provinceInfoService.list();
return Result.ok(list);
}
@Operation(summary = "根据省份id查询城市信息列表")
@GetMapping("city/listByProvinceId")
public Result<List<CityInfo>> listCityInfoByProvinceId(@RequestParam Long id) {
LambdaQueryWrapper<CityInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(CityInfo::getProvinceId, id);
List<CityInfo> list = cityInfoService.list(queryWrapper);
return Result.ok(list);
}
@GetMapping("district/listByCityId")
@Operation(summary = "根据城市id查询区县信息")
public Result<List<DistrictInfo>> listDistrictInfoByCityId(@RequestParam Long id) {
LambdaQueryWrapper<DistrictInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DistrictInfo::getCityId, id);
List<DistrictInfo> list = districtInfoService.list(queryWrapper);
return Result.ok(list);
}
性能优化:地区信息通常是相对静态的数据,可以考虑使用Redis缓存查询结果,减少数据库访问压力。我们实际项目中为这些接口添加了@Cacheable注解,缓存时间为24小时。
4. 房间信息管理
4.1 房间列表分页查询
房间列表查询是一个复杂的多表关联查询,我们使用Mybatis-Plus的分页功能实现:
java复制@Operation(summary = "分页查询房间列表")
@GetMapping("pageItem")
public Result<IPage<RoomItemVo>> pageItem(@RequestParam long current,
@RequestParam long size,
RoomQueryVo queryVo) {
Page<RoomItemVo> page = new Page<>(current, size);
IPage<RoomItemVo> list = roomInfoService.pageRoomItemByQuery(page, queryVo);
return Result.ok(list);
}
对应的Mapper XML配置:
xml复制<resultMap id="RoomItemVoMap" type="org.example.lease.web.app.vo.room.RoomItemVo" autoMapping="true">
<id column="id" property="id"/>
<association property="apartmentInfo" javaType="org.example.lease.model.entity.ApartmentInfo"
autoMapping="true">
<id column="id" property="id"/>
</association>
<collection property="graphVoList" ofType="org.example.lease.web.app.vo.graph.GraphVo"
select="selectGraphVoListByRoomId" column="id"/>
<collection property="labelInfoList" ofType="org.example.lease.model.entity.LabelInfo"
select="selectLabelInfoListByRoomId" column="id"/>
</resultMap>
<select id="pageRoomItemByQuery" resultMap="RoomItemVoMap">
select
ri.id,
ri.room_number,
ri.rent,
ai.id apartment_id,
ai.name,
ai.introduction,
ai.district_id,
ai.district_name,
ai.city_id,
ai.city_name,
ai.province_id,
ai.province_name,
ai.address_detail,
ai.latitude,
ai.longitude,
ai.phone,
ai.is_release
from room_info ri
left join apartment_info ai on ri.apartment_id = ai.id and ai.is_deleted = 0
<where>
ri.is_deleted = 0
and ri.is_release = 1
and ri.id not in(
select room_id
from lease_agreement
where is_deleted = 0
and status in(2,5))
<if test="queryVo.provinceId != null">
and ai.province_id = #{queryVo.provinceId}
</if>
<!-- 其他查询条件 -->
</where>
<if test="queryVo.orderType == 'desc' or queryVo.orderType == 'asc'">
order by ri.rent ${queryVo.orderType}
</if>
</select>
4.2 房间详情查询
房间详情需要查询多个关联表的数据:
java复制@Override
public RoomDetailVo getDetailById(Long id) {
// 1.查询房间信息
RoomInfo roomInfo = roomInfoMapper.selectById(id);
if (roomInfo == null) {
return null;
}
// 2.查询房间图片
List<GraphVo> graphVoList = graphInfoMapper.selectListByItemTypeAndId(ItemType.ROOM, id);
// 3.查询租期
List<LeaseTerm> leaseTermList = leaseTermMapper.selectListByRoomId(id);
// 4.查询配套
List<FacilityInfo> facilityInfoList = facilityInfoMapper.selectListByRoomId(id);
// 5.查询标签
List<LabelInfo> labelInfoList = labelInfoMapper.selectListByRoomId(id);
// 6.查询支付方式
List<PaymentType> paymentTypeList = paymentTypeMapper.selectListByRoomId(id);
// 7.查询基本属性
List<AttrValueVo> attrValueVoList = attrValueMapper.selectListByRoomId(id);
// 8.查询杂费信息
List<FeeValueVo> feeValueVoList = feeValueMapper.selectListByApartmentId(roomInfo.getApartmentId());
// 9.查询公寓信息
ApartmentItemVo apartmentItemVo = apartmentInfoService.selectApartmentItemVoById(roomInfo.getApartmentId());
// 组装返回结果
return new RoomDetailVo(roomInfo, graphVoList, leaseTermList, facilityInfoList,
labelInfoList, paymentTypeList, attrValueVoList,
feeValueVoList, apartmentItemVo);
}
性能优化:房间详情查询涉及多个表的关联查询,在实际项目中我们做了以下优化:
- 对不常变的数据如标签、配套设施等使用缓存
- 对图片等大字段数据单独查询,避免影响主查询性能
- 使用Mybatis的懒加载特性,按需加载关联数据
5. 支付方式管理
5.1 支付方式查询接口
我们实现了两个支付方式查询接口:
java复制@Operation(summary = "获取全部支付方式列表")
@GetMapping("list")
public Result<List<PaymentType>> list() {
List<PaymentType> list = paymentTypeService.list();
return Result.ok(list);
}
@Operation(summary = "根据房间id获取可选支付方式列表")
@GetMapping("listByRoomId")
public Result<List<PaymentType>> list(@RequestParam Long id) {
List<PaymentType> list = paymentTypeService.listByRoomId(id);
return Result.ok(list);
}
对应的Service实现:
java复制@Override
public List<PaymentType> listByRoomId(Long id) {
return paymentTypeMapper.selectListByRoomId(id);
}
Mapper XML配置:
xml复制<select id="selectListByRoomId" resultType="org.example.lease.model.entity.PaymentType">
select id,
name,
pay_month_count,
additional_info
from payment_type
where is_deleted = 0
and id in (select payment_type_id
from room_payment_type
where is_deleted = 0
and room_id = #{id})
</select>
在实际项目中,支付方式通常与房间的租赁策略相关,我们通过中间表room_payment_type来维护房间与支付方式的关系,这样可以根据业务需求灵活配置每个房间支持的支付方式。
6. 项目总结与经验分享
在尚庭公寓移动端后端开发过程中,我们积累了一些有价值的经验:
-
短信服务集成:阿里云短信服务有多个产品线(dysmsapi和dypnsapi),接入前要明确自己开通的是哪个产品,对应的SDK和调用方式也不同。个人开发者可能无法申请某些类型的模板,建议使用企业账号。
-
验证码安全:验证码功能必须做好防刷措施,包括:
- 限制同一手机号的发送频率
- 设置合理的有效期(通常5分钟)
- 验证码使用后立即失效
-
JWT实践:使用JWT进行认证时要注意:
- Token中不要存储敏感信息
- 设置合理的过期时间
- 使用HTTPS传输防止被截获
- 实现Token刷新机制
-
复杂查询优化:对于像房间详情这样的复杂查询:
- 合理使用Mybatis的关联查询和懒加载
- 对静态数据使用缓存
- 考虑将大字段数据分离查询
-
API文档:使用Knife4j等工具维护良好的API文档,可以显著提高前后端协作效率。建议:
- 按业务模块分组接口
- 为每个接口添加详细的参数和返回值说明
- 提供示例请求和响应
这个项目让我对SpringBoot、Mybatis-Plus等框架的实际应用有了更深的理解,特别是在处理复杂业务逻辑和性能优化方面积累了宝贵经验。希望这些分享对正在开发类似项目的同行有所帮助。