伙伴匹配系统学习笔记(知识星球-鱼皮)

目录

一.需求分析

二.技术栈

后端

三.数据库表设计

1.标签表(分类表)

2.修改用户表

四.标签搜索用户功能

1.开发后端接口

2.Java后端整合Swagger+Knife4j接口文档

3.存量用户信息导入及同步

4.看上了网页信息,怎样抓到(爬虫)

五.用户的登录信息

共享存储

六.个人信息修改功能

更新接口

七.批量导入数据

导入数据

八.主页性能优化

1.性能优化

2.Redis入门

3.设计缓存key

4.缓存预热

5.控制定时任务的执行

6.分布式锁、锁

九.组队功能

1.需求分析

2.库表设计

3.创建队伍

4.查询队伍列表

5.修改用户信息

6.用户可以加入其他队伍

7.用户可以退出队伍

8.队长可以解散队伍

十.随机匹配功能

匹配算法及介绍

性能测试及优化

十一.后端优化


一.需求分析

  1. 用户去添加标签,标签的分类(要有哪些标签、怎么把标签分类)
  2. 主动搜索:允许用户根据标签去搜索其他用户
  3. 组队
    • 创建队伍
    • 加入队伍‘
    • 根据标签查询队伍
    • 邀请其他人
  4. 允许用户修改标签
  5. 推荐
    • 相似度计算算法+本地分布式计算

二.技术栈

后端
  1. Java编程语言+SpringBoot框架
  2. SpringMVC+MyBatis+Mybatis Plus(提高开发效率)
  3. MySQL数据库
  4. Redis缓存
  5. Swagger+Knife4j接口文档

三.数据库表设计

1.标签表(分类表)

建议用标签,不要用分类,更灵活

性别:男、女

方向:Java、c++,Go、前段

目标:考研、春招、秋招、社招、考公、竞赛(蓝桥杯)

段位:初级、中级、高级、王者

身份:小学、初中、高中、大一、大二、大三、大四、学生、待业、已就业、研一、研二、研三

状态:乐观、有点丧、一般、单身、已婚、有对象

字段:

id int 主键

标签名 varchar 非空

上传标签的用户 userId int

父标签id,parentId,int (分类)

是否为父标签 isParent,tinyint(0、1)

创建时间createTime,datetime

更新时间updateTime,datetime

是否删除isDelete,tinyint(0、1)

  1. 通过控制台创建数据库表

  1. 创建成功

2.修改用户表

用户有那些标签?

  1. 直接在用户表补充tags字段
    • 优点:查询方便,不用新建关联表,标签是用户的固有属性(除了该系统,其他系统可能)节省开发成本
    • 缺点:用户表多一列,
  2. 加一个关联表,记录用户和标签的关系
    • 关联表的应用场景:查询灵活,可以正查反查
    • 缺点:要多建一个表,多维护一个表

四.标签搜索用户功能

1.开发后端接口
  1. SQL查询
    1. 允许用户传入标签,多个标签存在才搜索出来and。like '%Java%' and like '%%%C++'
    2. 允许用户传入多个标签,有任何一个标签存在就能搜出来or。like '%Java%' or like '%%%C++'
  2. 内存查询(灵活,可以通过并发进一步优化)
    1. 如果参数可以分析,根据用户的参数选择查询方式,比如标签数
    2. 如果参数不可分析,并且数据库连接足够、内存空间足够,可以兵法同时查询,谁先返回用谁
    3. 还可以SQL查询与内存计算相结合,比如先用SQL过滤掉部分tag
    4. 建议通过实际测试来分析哪种查询比较快,数据量大的时候验证效果更没明显
  3. 根据标签列表搜索用户,首先进行判空,若为空,直接抛出异常

  1. 快捷键alt+enter,可以快速拿到返回值

  1. 报红问题。跟着视频里敲的代码,结果出现了报红

经过查看,发现返回值的使用错了,大小写拼写错误,前文自动生成返回值时,没有仔细看,采取了默认的返回值。

  1. 链式调用,首先用ofNullable封装一个可能为空对象,再用orElse给出一个默认值,如果为空的话则取orElse给的值,不为空则取值

  1. 报错

检查后发现数据库建库时,对应字段拼写错误

  1. Controller层实现

  1. Apifox测试接口出错,报404

经过检查发现接口路径错误,路径少了一个”/api”,可以在环境里修改,

也可以在这里修改

再次测试,接口正确返回数据

  1. BsaeMapper:BaseMapper 是 MyBatis-Plus 框架中的一个核心接口,主要用于简化常见的数据库 CRUD (Create, Retrieve, Update, Delete) 操作。以下是 BaseMapper 的一些特点和功能:

    • 简化开发:通过继承 BaseMapper 接口,可以自动获得一系列预定义的数据访问方法,如查询、插入、更新、删除等,无需手动编写 SQL 语句。
    • 通用方法:提供了如 selectById, selectList, insert, updateById, deleteById 等方法,适用于大多数基于实体类(POJO)的操作。
    • 泛型设计:BaseMapper 是一个泛型接口,通常使用方式为 BaseMapper<T>,其中 T 是一个实体类类型,这样可以针对特定的实体类提供数据库操作。
    • 扩展性强:除了基本的 CRUD 方法外,还可以根据业务需求自定义其他方法,并结合 MyBatis-Plus 的特性进行灵活扩展。
    • 集成方便:在项目中引入 MyBatis-Plus 后,只需简单配置即可使用 BaseMapper,并可轻松集成到 Spring 或 Spring Boot 项目中。
  1. queryMapper:queryWrapper 是一个查询包装器对象,通常用于 MyBatis Plus 中来构建动态 SQL 查询条件。具体来说:

    • 封装查询条件:queryWrapper 可以用来添加各种查询条件,如等于、不等于、大于、小于、模糊查询等。
    • 支持链式调用:通过链式调用的方法,可以方便地添加多个查询条件。
    • 灵活的查询方式:可以添加排序、分组等其他查询相关设置。
    • 例如:.eq("column_name", value):添加等于条件。

.like("column_name", value):添加模糊查询条件。

    • 总之,queryWrapper 用于灵活地构建复杂的查询条件,并将其传递给 selectCount 方法,从而获取符合条件的记录总数。
  1. 解析JSON字符串

序列化:Java对象转为json

反序列化:把json转为Java对象

Java json序列化库有很多:

      1. fastjson(快,但是漏洞太多)
      2. gson()
      3. jsckson
      4. kryo
  1. Java8特性
    1. stream/parallelStream:
    2. Optional可选类:

2.Java后端整合Swagger+Knife4j接口文档
  1. Swagger接口文档-CSDN博客
    • 添加依赖

    • 如果springboot version>=2.6,需要添加如下配置
spring:
 mvc:
  pathmatch:
   matching-strategy:ANT_PATH_MATCHER
  1. 接口文档,文档中的内容即为接口的信息,每条接口包括:
    1. 请求参数
    2. 响应参数
      • 错误码
    3. 接口地址
    4. 接口名称
    5. 请求类型
    6. 请求格式
    7. 备注
  2. 接口文档便于前段和后端开发对接,前后端联调的介质。
  3. Swagger接口文档原理
    1. 自定义Swagger配置类
    2. 定义需要生成接口文档的代码位置(Controller)
  4. 线上环境注意不要暴露接口位置!!可以通过在SwaggerConfig配置文件开头加上@Profile({"dev","test"}),
  5. 启动即可
  6. 可以通过在controller方法上添加@Api、@AplimplicitParam(name="name",value="姓名",required=true) @ApiOperation(value="向客人问好")等主角儿来自定义申海成的接口描述信息
3.存量用户信息导入及同步
  1. 把所有星球用户信息的导入
  2. 把写了自我介绍的同学的用户信息导入

4.看上了网页信息,怎样抓到(爬虫)
  1. 分析原网站是怎样获取这些信息的
  2. 用程序去调用接口(Java/python都可以)
  3. 处理(清洗)一下数据,之后就可以写到数据库里
  4. 流程:
    1. 从excel中导入全量用户数据,判重。 easyexcel
    2. (例)抓取写了自我介绍的同学信息,提取出用户昵称、用户唯一id、自我介绍信息
    3. 从自我介绍中提取信息,然后写入到数据库中 
  5. easyexcel读Excel | Easy Excel 官网两种读对象的方式:
    1. 确定表头:建立对象
    2. 不确定表头:每一行数据映射为Map<String,Object>
  6. 两种读取模式:
    1. 监听器:先创建监听器,在读取文件时绑定监听器。单独抽离处理逻辑,代码清晰易于维护;一条一条处理,适用于数据量大的场景。
    2. 同步读:无需创建监听器,要获取完整数据。方便简单,但是数据量大时会有等待时常,也可能内存溢出。
  7. 使用流处理userInfoList列表。过滤掉用户名(username)为空的用户信息。将过滤后的用户信息按照用户名进行分组,并收集到一个Map中,其中键为用户名,值为用户名对应的用户信息列表。

  1. 注解@Profile:可以通过在SwaggerConfig配置文件开头加上@Profile({"dev","test"}),

五.用户的登录信息

共享存储
  1. 如何共享存储?
  2. Redis(基于内存的K/V数据库)此处选择Redis,因为用户信息读取/是否登录判断极其频繁,Redis基于内存,读写性能很高,简单的数据单机qps5w-10w
    1. Redis管理工具-quick Redis
    2. 引入Redis,能操作redis,安装quickredis

    1. 引入spring-session和redis的整合,使自动将session存储到redis中
    2. 修改spring-session存储配置spring.session.store-type,默认是none,表示存储在单台服务器
    3. store-type:redis,表示从redis读写session
    4. 在quickredis可以看到序列化后的session

  1. MySQL
  2. 文件服务器ceph

六.个人信息修改功能

更新接口
  1. 在service中写方法,获取用户登录信息

  1. 检查传入的user对象ID是否有效,无效则抛出参数错误异常。检查是否有更新操作的权限:管理员可更新任意用户,普通用户只能更新自身信息,否则抛出无权限异常。通过ID查询旧用户信息,若不存在,则抛出空值异常。最后,执行用户信息更新并返回影响行数。

  1. controller:处理POST请求/update。检查请求体中的User对象是否为空,若为空则抛出业务异常。假定调用者已验证管理员权限。从请求中获取已登录用户信息,并调用userService.updateUser方法更新用户信息。返回更新结果。

  1. 测试
    • 报错:"user login failed, userAccount cannot match userPassword"
    • 原因:存入数据库时对密码进行了加密,而我但是查看数据库时以为出现了乱码,进行了修改。从而造成了密码验证不对。

七.批量导入数据

导入数据
  1. 用可视化界面:适合一次性导入,数据量可控
  2. 写循环:for循环,建议分批,不要一把梭哈,要保证可控
  3. 执行SQL语句:适用于小数据量
  4. @EnableScheduling可以在Springboot中开启对定时任务的支持
    1. fixedDelay=3000//每隔3秒执行一次
    2. initialDelay=5000//首次执行的延迟为5秒

    1. 成功插入数据十万条,花费时间29秒

  1. 编写一次性任务:
    1. stopwatch:用于任务时间监控,在SPring及apache中均提供类似的任务时间监控功能。
  2. for循环插入数据的问题:
    1. 建立和释放数据库连接(批量查询解决,大幅提高插入效率)
      • 20s十万条数据(批量例子)
    2. for循环是绝对线性的()
  3. 并发批量插入用户数据
    1. 并发插入数据

    1. 并发请求过多时,数据库崩了!

    1. 十万条数据分十组,每组一万条数据
    2. join():
    3. 并发要注意执行的先后顺序无所谓,不要用到并发类的集合。
    4. cup密集型:分配的核心线程数=CPU-1
    5. IO密集型:分配的核心线程可以大于CPU核数

八.主页性能优化

1.性能优化
  1. 预加载缓存:定时更新缓存
    • 多个机器都要执行任务吗?

分布式锁:控制同一时间只有一台机器去执行定时任务,其他机器不用重复执行了。

  1. 数据查询慢怎么办?
    • 用缓存:提前把数据取出来保存好(通常可以保存在读写更快的介质,比如内存),就可以更快地读写。cache>内存>外存
  2. 缓存的实现
    1. Redis(分布式缓存)
    2. memcached(分布式)
    3. ehcache(单机)
    4. 本地缓存(Java内存Map)
    5. caffeine(java内存缓存,高性能)
    6. Google Guava
2.Redis入门
  1. NoSQL数据库,key-value存储系统(区别于MySQL,他储存的是键值对)
  2. Redis数据结构
    1. String字符串类型:name:”yupi”
    2. List列表:names:["yupi","yupi1","yupi"]
    3. Set集合:names:["yupi","yupi1"](值不能重复)
    4. Hash哈希:nameAge:{ "yupi":1,"yupi2":2 }
    5. Zset集合:names:{ yupi-9,yupi2-12 }(适合做排行榜)
  3. java里的实现方式:
    1. Spring Data Redis(推荐):通用的数据访问框架,定义一组增删改查的接口
      1. 引用
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.6.4</version>
</dependency>
      1. 配置Redis地址
spring:
  # redis 配置
  redis:
    port: 6379
    host: localhost
    database: 0
      1. 自定义序列化
package com.yupi.yupao.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisTemplateConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        return redisTemplate;
    }
}
    1. Jedis
      1. 独立于Spring操作Redis的
    2. Lettuce
      1. 高阶的操作Redis的java客户端
      2. 异步,连接池
    3. Redisson
      1. 分布式操作Redis的java客户端
    4. JetCache
    5. 对比:
      1. 如果用的是并且没有过多定制化要求,可以用Spring Data Redis,最方便
      2. 如果用的不是Spring,并且追求简单,并且没有过高的性能要求,可以哦那你换个Jedis+Jedis Pool
      3. 如果项目不是Spring,并且追求高性能、高定制化、可以用Lettuce、支持异步,连接池
      4. 如果项目是分布式的,需要用到一些分布式的特性(比如分布式锁,分布式集合),推荐用redisson 

3.设计缓存key

不同用户看到的数据不同

redis内存不能无限增加,一定要设计过期时间!!!

4.缓存预热
  1. 优点:解决上述的问题,可以让用户始终访问很快
  2. 缺点:
    1. 增加开发成本。
    2. 预热的时机和时间如果错了,可能缓存的数据不对或者太老
    3. 需要占用额外空间
  3. 如何缓存预热
    1. 定时
    2. 模拟触发(手动触发)
  4. 定时任务实现
    1. Spring Scheduler(spring boot默认整合了)
    2. Quartz(独立于Spring存在的定时任务框架)
    3. XXL-Job之类的分布式任务调度平台(界面+sdk)
  5. 用定时任务每天刷新所有用户的推荐列表
    1. 缓存预热的意义(新增少,总用户多)
    2. 缓存0的空间不能太大,要预留其他缓存空间
    3. 缓存数据的周期(此处每天一次)
  6. 第一种方式实现
    1. 主类开启@EnableScheduling
    2. 给要定时执行的方法添加@Scheduling注解,指定corn表达式或者执行频率
5.控制定时任务的执行
  1. 原因
    1. 浪费资源,想象10000台服服务器一起“打鸣“
    2. 脏数据,比如重复插入
  2. 方法
    1. 分离定时任务程序,只在一个服务器运行定时任务。成本太大
    2. 写死配置,每个服务器都执行定时任务,但是ip符合配置的服务器才真实执行业务逻辑,其他的直接返回。成本最低;但是我们的ip可能不是固定的,把ip写的太死了。
    3. 动态配置,配置是可以轻松的,很方便地更新(代码无需重启),但是只有ip符合配置的服务器才真实执行业务逻辑。
      1. 数据库
      2. Redis
      3. 配置中心(Nacos,Apollo,Spring Cloud Config)
      4. 问题:服务器多了,ip不可控还是很麻烦,还是需要人工修改
6.分布式锁、锁
  1. 分布式锁,只有抢到锁的服务器才能执行业务逻辑
    1. 坏处:增加成本
    2. 好处:不用手动匹配值,多少个服务器都一样。
  2. 锁:有限的资源的情况下,控制同一时间段只有某些线程(用户/服务器)才能访问资源。
    1. Java实现锁:synchronized关键字,并发包的类
    2. 问题:只对单个JVM有效
  3. 抢锁机制
    1. 核心思想:先来的人把数据改成自己的标识(服务器ip),后来的人发现标识已存在,就抢锁败,继续等待,等想来的人执行方法结束,把标识清空,其他的人继续抢锁。
    2. MySQL数据库:select for update 行级锁(最简单)
    3. (乐观锁)
    4. Redis实现:内存数据库,读写速度快。支持setnx、lua脚本,比较方便我们实现分布式锁。
    5. Zookeeper实现
  4. 注意事项
    1. 用完的锁要释放
    2. 一定要设置过期时间
    3. 如果方法执行过长,锁提前过期了
      1. 问题:
        1. 连锁效应:释放掉别人的锁
        2. 这样还是会存在多个方法同时执行的情况
      2. 解决方案
        1. 续期
    4. 释放锁的时候,有可能判断出是自己的锁,但这时锁过期了,最后还是释放了别人的锁
  5. redisson实现分布式锁
    1. redission是一个Java操作Redis的客户端,提供了大量的分布式数据来简化对Redis的操作和使用,可以让开发者像使用本地集合一样使用Redis,完全感知不到Redis的存在
    2. 2种引入方式
      1. Spring boot starter引入(内部推荐,版本迭代太快,容易冲突)
      2. 直接引入
        1. maven引入

        1. Redisson配置:yml配置文件中已经写好了port和host

        1. 所以,直接使用@ConfigurationProperties(prefix = "spring.redis"):注解用于将配置文件中的属性值自动绑定到类的字段上

  1. 定时任务+锁
    1. waitTime设置时间为0,只抢一次,抢不到就放弃
    2. 注意释放锁要写在finally中

  1. 看门狗机制(redisson中提供的续期机制)
    1. Redisson 分布式锁的watch dog自动续期机制_redisson续期-CSDN博客
    2. 开一个监听线程,,如果方法还没执行完,就帮你重置redis锁的过期时间。
    3. 原理:监听当前线程,默认时间是30s,每10s续期一次,如果续期线程挂掉,则不会续期。
    4. 下面是quickredis中可以查看到的当前线程的ttl,经过时间过去刷新,可以看到但ttl减少到20时,会自动刷新到30.

九.组队功能

1.需求分析
  1. 用户可以创建一个队伍,设置队伍的人数、队伍名称(标题)、描述、超时时间P0
  2. 修改队伍信息
  3. 用户可以加入队伍(其他人,未满、未过期)
  4. 用户可以退出队伍(如果队长退出,权限转移给第二早加入的用户--先来后到)、
  5. 队长可以解散队伍
  6. 分享队伍=》邀请其他用户加入队伍
2.库表设计
  1. 队伍表team字段
    • id 主键 bigint(最简单,连续,放url上比较简单,但缺点是怕爬虫)
    • name 队伍名称
    • discription 描述
    • maxNum 最大人数
    • expireTime 过期时间
    • userId 用户id
    • status 0-公开,1-私有,2-加密
    • password 密码
    • createTime 创建时间
    • updateTime 更新时间
    • isDelete 是否删除
create table team
(
    id           bigint auto_increment comment 'id'
        primary key,
    name   varchar(256)                   not null comment '队伍名称',
    description varchar(1024)                      null comment '描述',
    maxNum    int      default 1                 not null comment '最大人数',
    expireTime    datetime  null comment '过期时间',
    userId            bigint comment '用户id',
    status    int      default 0                 not null comment '0 - 公开,1 - 私有,2 - 加密',
    password varchar(512)                       null comment '密码',
    
        createTime   datetime default CURRENT_TIMESTAMP null comment '创建时间',
    updateTime   datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP,
    isDelete     tinyint  default 0                 not null comment '是否删除'
)
    comment '队伍';
  1. 成功建立队伍表

  1. 使用MyBatisX-Generator根据数据库表生成代码

  1. 用户-队伍表user_team
    • id 主键
    • userId 用户id
    • teamId 队伍id
    • joinTime 加入时间
    • createTime 创建时间
    • updateTime 更新时间
    • isDelete 是否删除
create table user_team
(
    id           bigint auto_increment comment 'id'
        primary key,
    userId            bigint comment '用户id',
    teamId            bigint comment '队伍id',
    joinTime datetime  null comment '加入时间',
    createTime   datetime default CURRENT_TIMESTAMP null comment '创建时间',
    updateTime   datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP,
    isDelete     tinyint  default 0                 not null comment '是否删除'
)
    comment '用户队伍关系';
  1. 成功建立用户-队伍关系表

  1. 两个关系
    • 用户加了哪些队伍
    • 队伍有那些用户
  2. 方式
    • 建立用户表-队伍关系表teamId userId(查询性能高一些,可以选择这个,不用遍历全表)
    • 用户表补充已加入的队伍字段,队伍表补充已加入的用户字段(不用写多对多的代码,可以直接根据队伍查用户,根据用户查队伍)
  3. 增删改查
  4. 业务逻辑开发
3.创建队伍
  1. 请求参数是否为空

  1. 是否登录,未登录不允许创建

  1. 校验信息
    1. 队伍人数>且<=20

    1. 队伍标题<=20

    1. 描述<=512

    1. status是否公开(int)不传默认为0(公开)

    1. 如果status时加密状态,一定要有密码,且密码<=32

创建一个队伍状态枚举TeamStatusEnum

校验状态是否加密

    1. 超时时间>当前时间

    1. 校验用户最多创建五个队伍

    1. 插入队伍信息到队伍表

开启事务,如果插入失败则立即回滚

插入创建队伍

    1. 插入用户=>队伍关系到关系表

    1. controller层

    1. 测试接口报错:

org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.util.Date` from String "2011-07-09 19:02:46": not a valid representation (error: Failed to parse Date value '2011-07-09 19:02:46': Cannot parse date "2011-07-09 19:02:46": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSX', parsing fails (leniency? null)); nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.util.Date` from String "2011-07-09 19:02:46": not a valid representation (error: Failed to parse Date value '2011-07-09 19:02:46': Cannot parse date "2011-07-09 19:02:46": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSX', parsing fails (leniency? null))

at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 5, column: 19] (through reference chain: com.yupi.yupao.model.request.TeamAddRequest["expireTime"])

    1. 报错原因:

    1. 修改:

    1. 测试成功

    1. 成功创建队伍

4.查询队伍列表
  1. 需求分析:展示队伍列表,根据名称搜索队伍PO,信息流中不展示已过期的队伍
    1. 从请求参数中取出队伍名称,如果存在则作为查询条件
    2. 不展示已过期的队伍(根据过期时间筛选)
    3. 只有管理员才能查看加密还有非公开的房间
    4. 关联查询已加入队伍的用户信息
  2. 接口实现
    1. 建立返回给前端的封装类Vo

    1. 组合查询条件
if (teamQuery != null) {
//获取id
    Long id = teamQuery.getId();
    if (id != null && id > 0) {
        queryWrapper.eq("id", id);
    }
    List<Long> idList = teamQuery.getIdList();
    if (CollectionUtils.isNotEmpty(idList)) {
        queryWrapper.in("id", idList);
    }
    String searchText = teamQuery.getSearchText();
    if (StringUtils.isNotBlank(searchText)) {
        //整个表达式构建了一个复合查询条件:name字段包含searchText或者description字段包含searchText,
        // 并且这个复合条件作为整体与之前的条件(如果有)通过AND逻辑连接
        queryWrapper.and(qw -> qw.like("name", searchText).or().like("description", searchText));
    }
    //获取名称
    String name = teamQuery.getName();
    if (StringUtils.isNotBlank(name)) {
    //用like查询,允许模糊匹配
        queryWrapper.like("name", name);
    }
    //获取描述
    String description = teamQuery.getDescription();
    if (StringUtils.isNotBlank(description)) {
        queryWrapper.like("description", description);
    }
    Integer maxNum = teamQuery.getMaxNum();
    // 查询最大人数相等的
    if (maxNum != null && maxNum > 0) {
        queryWrapper.eq("maxNum", maxNum);
    }
    Long userId = teamQuery.getUserId();
    // 根据创建人来查询
    if (userId != null && userId > 0) {
        queryWrapper.eq("userId", userId);
    }
    // 根据状态来查询
    Integer status = teamQuery.getStatus();
    TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status);
    if (statusEnum == null) {
        statusEnum = TeamStatusEnum.PUBLIC;
    }
    if (!isAdmin && statusEnum.equals(TeamStatusEnum.PRIVATE)) {
        throw new BusinessException(ErrorCode.NO_AUTH);
    }
    queryWrapper.eq("status", statusEnum.getValue());
}
    1. 不展示已过期的队伍
// expireTime is null or expireTime > now()
//最终查询结果为所有未过期的记录加上没有设置过期时间的记录
queryWrapper.and(qw -> qw.gt("expireTime", new Date()).or().isNull("expireTime"));
//根据给定的查询条件从数据库中检索出所有符合要求的团队信息,并将结果存储在teamList变量中。
List<Team> teamList = this.list(queryWrapper);
if (CollectionUtils.isEmpty(teamList)) {
    return new ArrayList<>();
}
List<TeamUserVO> teamUserVOList = new ArrayList<>();
    1. 关联查询创建人的用户信息
//从团队列表中获取每个团队的信息,并创建一个包含团队信息的新对象 TeamUserVO。
// 如果某个团队的用户ID为空,则跳过该团队。
for (Team team : teamList) {
    Long userId = team.getUserId();
    if (userId == null) {
        continue;
    }
    User user = userService.getById(userId);
    TeamUserVO teamUserVO = new TeamUserVO();
    BeanUtils.copyProperties(team, teamUserVO);
    // 脱敏用户信息
    //根据非空的 user 实体创建并设置 teamUserVO 的创建者信息,
    // 然后将其累积到一个列表中,该列表用于收集处理过的团队用户视图对象。
    if (user != null) {
        UserVO userVO = new UserVO();
        BeanUtils.copyProperties(user, userVO);
        teamUserVO.setCreateUser(userVO);
    }
    teamUserVOList.add(teamUserVO);
}
    1. 根据状态查询
// 根据状态来查询
Integer status = teamQuery.getStatus();
//根据输入的状态值获取相应的团队状态枚举,若无匹配项则使用公共状态作为默认值。
TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status);
if (statusEnum == null) {
    statusEnum = TeamStatusEnum.PUBLIC;
}
//如果用户不是管理员且团队状态为私有,则抛出无权限的业务异常
if (!isAdmin && statusEnum.equals(TeamStatusEnum.PRIVATE)) {
    throw new BusinessException(ErrorCode.NO_AUTH);
}
queryWrapper.eq("status", statusEnum.getValue());
    1. 测试接口

5.修改用户信息
  1. 分析
    1. 判断请求参数是否为空
    2. 查询队伍是否存在
    3. 只有管理员或者队伍的创建者可以修改
    4. 如果用户传入的新值与老值一直,就不用update了,(可自行实现,降低数据库使用次数)
    5. 更新成功
  2. 实现
    1. controller层
@PostMapping("/update")
public BaseResponse<Boolean> updateTeam(@RequestBody TeamUpdateRequest teamUpdateRequest, HttpServletRequest request) {
    if (teamUpdateRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    boolean result = teamService.updateTeam(teamUpdateRequest, loginUser);
    if (!result) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新失败");
    }
    return ResultUtils.success(true);
}
    1. serviceImpl
public boolean updateTeam(TeamUpdateRequest teamUpdateRequest, User loginUser) {
    if (teamUpdateRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    Long id = teamUpdateRequest.getId();
    if (id == null || id <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    Team oldTeam = this.getById(id);
    if (oldTeam == null) {
        throw new BusinessException(ErrorCode.NULL_ERROR, "队伍不存在");
    }
    // 如果不是创建者并且不是管理员,则抛出异常
    if (oldTeam.getUserId() != loginUser.getId() && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH);
    }
    TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(teamUpdateRequest.getStatus());
    if (statusEnum.equals(TeamStatusEnum.SECRET)) {
        if (StringUtils.isBlank(teamUpdateRequest.getPassword())) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "加密房间必须要设置密码");
        }
    }
    Team updateTeam = new Team();
    BeanUtils.copyProperties(teamUpdateRequest, updateTeam);
    return this.updateById(updateTeam);
}
  1. 测试
    1. 接口测试成功

    1. 数据库修改完成

6.用户可以加入其他队伍
  1. 分析
    1. 用户最多加入5个队伍
    2. 队伍必须存在,只能加入未满,未过期的队伍
    3. 不能重复加入已加入的队伍(幂等性)
    4. 禁止接入私有的队伍
    5. 如果加入的队伍是私密的,必须密码匹配才可以
    6. 修改队伍信息,补充人数
    7. 新增队伍-用户关联信息
  2. 实现
    1. 建立用户加入队伍实体请求类
@Data
public class TeamJoinRequest implements Serializable {

private static final long serialVersionUID = 3191241716373120793L;

/**
     * id
     */
    private Long teamId;

/**
     * 密码
     */
    private String password;
}
    1. controller层
      • 接收JSON格式的TeamJoinRequest对象和HttpServletRequest对象作为参数。
      • 检查TeamJoinRequest是否为空,若为空则抛出业务异常。
      • 从请求中获取已登录用户信息。
      • 调用teamService的joinTeam方法,传入请求参数和登录用户信息,返回是否加入团队成功的结果。
      • 将结果封装为成功响应并返回
@PostMapping("/join")
public BaseResponse<Boolean> joinTeam(@RequestBody TeamJoinRequest teamJoinRequest, HttpServletRequest request) {
    if (teamJoinRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    boolean result = teamService.joinTeam(teamJoinRequest, loginUser);
    return ResultUtils.success(result);
}
    1. serviceImpl实现
    2. 参数校验:检查teamJoinRequest是否为空,如果为空,则抛出业务异常BusinessException,错误码为PARAMS_ERROR。
    3. 获取团队信息:通过teamId获取团队信息team。
    4. 检查团队是否过期:获取团队的过期时间expireTime,如果过期时间不为空且早于当前时间,则抛出业务异常,错误信息为“队伍已过期”。
    5. 检查团队状态:获取团队的状态status,并转换为枚举类型TeamStatusEnum。如果团队状态为PRIVATE(私有),则抛出业务异常,错误信息为“禁止加入私有队伍”。
    6. 验证团队密码:如果团队状态为SECRET(加密):检查密码password是否为空或与团队密码不匹配。如果密码不正确,则抛出业务异常,错误信息为“密码错误”
//判空
if (teamJoinRequest == null) {
    throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Long teamId = teamJoinRequest.getTeamId();
Team team = getTeamById(teamId);
//获取队伍过期时间,判断是否过期,若过期则抛出异常
Date expireTime = team.getExpireTime();
if (expireTime != null && expireTime.before(new Date())) {
    throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍已过期");
}
//获取当前队伍状态,若为私有状态,则抛出异常
Integer status = team.getStatus();
TeamStatusEnum teamStatusEnum = TeamStatusEnum.getEnumByValue(status);
if (TeamStatusEnum.PRIVATE.equals(teamStatusEnum)) {
    throw new BusinessException(ErrorCode.PARAMS_ERROR, "禁止加入私有队伍");
}
// 获取队伍的密码,若为加密状态,则判断密码是否为空,若不为空则判断密码是否正确,若不正确则抛出异常
String password = teamJoinRequest.getPassword();
if (TeamStatusEnum.SECRET.equals(teamStatusEnum)) {
    if (StringUtils.isBlank(password) || !password.equals(team.getPassword())) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
    }
}
    1. 测试接口
      1. 测试加入队伍
        • 这是添加前的5号队伍

        • 这是添加后的5号队伍,添加成功

      1. 若用户已经加入该队伍,则会返回

7.用户可以退出队伍
  1. 分析
    1. 校验请求参数
    2. 校验队伍是否存在
    3. 校验我是否已加入队伍
    4. 如果是队长
      1. 如果队伍只剩一人,队伍解散
      2. 如果队伍还有其他人,权限转移给第二早加入的用户-先来后到
  2. 实现
    1. controller层
@PostMapping("/quit")
public BaseResponse<Boolean> quitTeam(@RequestBody TeamQuitRequest teamQuitRequest, HttpServletRequest request) {
    if (teamQuitRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    boolean result = teamService.quitTeam(teamQuitRequest, loginUser);
    return ResultUtils.success(result);
}
    1. serviceImpl(约定用户iduserId为队长id)
@Transactional(rollbackFor = Exception.class)
public boolean quitTeam(TeamQuitRequest teamQuitRequest, User loginUser) {
    if (teamQuitRequest == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    Long teamId = teamQuitRequest.getTeamId();
    Team team = getTeamById(teamId);
    long userId = loginUser.getId();
    UserTeam queryUserTeam = new UserTeam();
    queryUserTeam.setTeamId(teamId);
    queryUserTeam.setUserId(userId);
    QueryWrapper<UserTeam> queryWrapper = new QueryWrapper<>(queryUserTeam);
    long count = userTeamService.count(queryWrapper);
    if (count == 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "未加入队伍");
    }
    long teamHasJoinNum = this.countTeamUserByTeamId(teamId);
    // 队伍只剩一人,解散
    if (teamHasJoinNum == 1) {
    // 删除队伍
        this.removeById(teamId);
    } else {
    // 队伍还剩至少两人
        // 是队长
        if (team.getUserId() == userId) {
    // 把队伍转移给最早加入的用户
            // 1. 查询已加入队伍的所有用户和加入时间
            QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
            userTeamQueryWrapper.eq("teamId", teamId);
            userTeamQueryWrapper.last("order by id asc limit 2");
            List<UserTeam> userTeamList = userTeamService.list(userTeamQueryWrapper);
            if (CollectionUtils.isEmpty(userTeamList) || userTeamList.size() <= 1) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR);
            }
            UserTeam nextUserTeam = userTeamList.get(1);
            Long nextTeamLeaderId = nextUserTeam.getUserId();
            // 更新当前队伍的队长
            Team updateTeam = new Team();
            updateTeam.setId(teamId);
            updateTeam.setUserId(nextTeamLeaderId);
            boolean result = this.updateById(updateTeam);
            if (!result) {
                throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新队伍队长失败");
            }
        }
    }
    // 移除关系
    return userTeamService.remove(queryWrapper);
}
8.队长可以解散队伍
  1. 分析
    1. 校验请求参数
    2. 校验队伍是否存在
    3. 校验你是不是队长
    4. 移除所有加入队伍的关键信息
    5. 删除队伍
  2. 实现
    1. controller
@PostMapping("/delete")
public BaseResponse<Boolean> deleteTeam(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
    //传入一个修改的id,如果这个id小于等于0,直接抛出参数错误
    if (deleteRequest == null || deleteRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    long id = deleteRequest.getId();
    User loginUser = userService.getLoginUser(request);
    boolean result = teamService.deleteTeam(id, loginUser);
    if (!result) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除失败");
    }
return ResultUtils.success(true);
}
    1. serviceImpl,加入事务回滚,更保险
@Transactional(rollbackFor = Exception.class)
public boolean deleteTeam(long id, User loginUser) {
    // 校验队伍是否存在
    Team team = getTeamById(id);
    long teamId = team.getId();
    // 校验你是不是队伍的队长
    if (team.getUserId() != loginUser.getId()) {
        throw new BusinessException(ErrorCode.NO_AUTH, "无访问权限");
    }
    // 移除所有加入队伍的关联信息
    QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
    userTeamQueryWrapper.eq("teamId", teamId);
    boolean result = userTeamService.remove(userTeamQueryWrapper);
    if (!result) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除队伍关联信息失败");
    }
    // 删除队伍
    return this.removeById(teamId);
}

十.随机匹配功能

匹配算法及介绍
  1. 怎么匹配
    1. 标签tags
  2. 本质:找到相似标签的用户
  3. 找到共同标签最多的用户
  4. 共同标签越多,分数越高,越排在前面
  5. 如果没有匹配的思路,随机推荐几个(降级方案)
  6. 编辑距离算法详解编辑距离算法-Levenshtein Distance-CSDN博客
    1. 字符串1最少可以通过多少次增删改可以变成字符串2

  1. 编辑距离算法工具类
public class AlgorithmUtils {
/**
     * 编辑距离算法(用于计算最相似的两组标签)
     * 原理:https://blog.csdn.net/DBC_121/article/details/104198838
     * @param tagList1
     * @param tagList2
     * @return
     */
    public static int minDistance(List<String> tagList1, List<String> tagList2) {
        int n = tagList1.size();
        int m = tagList2.size();

        if (n * m == 0) {
            return n + m;
        }

        int[][] d = new int[n + 1][m + 1];
        for (int i = 0; i < n + 1; i++) {
            d[i][0] = i;
        }

        for (int j = 0; j < m + 1; j++) {
            d[0][j] = j;
        }

        for (int i = 1; i < n + 1; i++) {
        for (int j = 1; j < m + 1; j++) {
        int left = d[i - 1][j] + 1;
        int down = d[i][j - 1] + 1;
        int left_down = d[i - 1][j - 1];
        if (!Objects.equals(tagList1.get(i - 1), tagList2.get(j - 1))) {
            left_down += 1;
                }
        d[i][j] = Math.min(left, Math.min(down, left_down));
            }
        }
        return d[n][m];
    }


/**
     * 编辑距离算法(用于计算最相似的两个字符串)
     * 原理:https://blog.csdn.net/DBC_121/article/details/104198838
     *
     * @param word1
     * @param word2
     * @return
     */
    public static int minDistance(String word1, String word2) {
        int n = word1.length();
        int m = word2.length();

        if (n * m == 0) {
            return n + m;
        }

        int[][] d = new int[n + 1][m + 1];
        for (int i = 0; i < n + 1; i++) {
            d[i][0] = i;
        }

        for (int j = 0; j < m + 1; j++) {
            d[0][j] = j;
        }

        for (int i = 1; i < n + 1; i++) {
            for (int j = 1; j < m + 1; j++) {
                int left = d[i - 1][j] + 1;
                int down = d[i][j - 1] + 1;
                int left_down = d[i - 1][j - 1];
                if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
                    left_down += 1;
                }
                d[i][j] = Math.min(left, Math.min(down, left_down));
            }
        }
        return d[n][m];
    }
}
  1. serviceImpl
public List<User> matchUsers(long num, User loginUser) {
    QueryWrapper<User> queryWrapper = new QueryWrapper<>();
    queryWrapper.select("id", "tags");
    queryWrapper.isNotNull("tags");
    List<User> userList = this.list(queryWrapper);
    String tags = loginUser.getTags();
    Gson gson = new Gson();
    List<String> tagList = gson.fromJson(tags, new TypeToken<List<String>>() {
    }.getType());
    // 用户列表的下标 => 相似度
    List<Pair<User, Long>> list = new ArrayList<>();
    // 依次计算所有用户和当前用户的相似度
    for (int i = 0; i < userList.size(); i++) {
        User user = userList.get(i);
        String userTags = user.getTags();
        // 无标签或者为当前用户自己
        if (StringUtils.isBlank(userTags) || user.getId() == loginUser.getId()) {
            continue;
        }
        List<String> userTagList = gson.fromJson(userTags, new TypeToken<List<String>>() {
        }.getType());
        // 计算分数
        long distance = AlgorithmUtils.minDistance(tagList, userTagList);
        list.add(new Pair<>(user, distance));
     }
    // 按编辑距离由小到大排序
    List<Pair<User, Long>> topUserPairList = list.stream()
            .sorted((a, b) -> (int) (a.getValue() - b.getValue()))
            .limit(num)
            .collect(Collectors.toList());
    // 原本顺序的 userId 列表
    List<Long> userIdList = topUserPairList.stream().map(pair -> pair.getKey().getId()).collect(Collectors.toList());
    QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
    userQueryWrapper.in("id", userIdList);
    // 1, 3, 2
    // User1、User2、User3
    // 1 => User1, 2 => User2, 3 => User3
    Map<Long, List<User>> userIdUserListMap = this.list(userQueryWrapper)
            .stream()
            .map(user -> getSafetyUser(user))
            .collect(Collectors.groupingBy(User::getId));
    List<User> finalUserList = new ArrayList<>();
    for (Long userId : userIdList) {
        finalUserList.add(userIdUserListMap.get(userId).get(0));
    }
    return finalUserList;
}
  1. controller
@GetMapping("/match")
public BaseResponse<List<User>> matchUsers(long num, HttpServletRequest request) {
    //限制数量,保证数据库安全
    if (num <= 0 || num > 20) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User user = userService.getLoginUser(request);
    return ResultUtils.success(userService.matchUsers(num, user));
}
性能测试及优化
  1. 怎么取出所有用户,一次和当前当前用户计算分数,取TOP N
  2. 优化方法:
    1. 切记不要在数据量大的时候输出日志
    2. Map存了素偶有的分数信息,占用内存
      1. 解决:维护一个固定长度的集合,只保留分数最高的几个用户
    3. 细节:剔除自己
    4. 尽量只查需要的用户
      1. 过滤掉标签为空的用户
      2. 过滤掉部分标签取用户
      3. 只查需要的数据(如id和tags)
    5. 提前查?
      1. 提前把所有用户给缓存(不适用于经常更新的数据)
      2. 提前运算出来结果,缓存(针对一些重点用户,提前缓存)
  3. 类比大数据推荐机制

  1. 测试
    1. 数据库修改标签

    1. 接口返回值:

登录1号的账户,1号的标签为["java","研发中心","男","编程"],匹配到的用户为2号。

十一.后端优化

  1. 队伍操作权限控制
    1. 加入队伍:仅非队伍创建人,且未加入队伍的人可见
    2. 更新队伍:仅创建人可见
    3. 解散队伍:仅创建人可见
    4. 退出队伍:创建人不可见,仅已加入队伍的人可见
  2. 重复加入多个队伍的问题
    1. 加锁:
    2. 获取分布式锁:使用Redisson客户端从Redis中获取一个名为yupao:join_team的锁,确保同一时刻只有一个线程能够执行后续的业务逻辑,防止并发操作导致的数据不一致。
    3. 循环尝试获取锁:通过一个while(true)循环不断地尝试获取锁,直到成功为止。尝试获取锁时不设置等待时间(0毫秒),意味着立即尝试获取,但如果无法立即获取到锁,则会一直循环尝试。参数-1表示锁没有超时时间,即除非手动释放,否则将一直保持锁定状态。
    4. 检查用户队伍数量:查询当前用户已经加入的队伍数量,如果超过5个,则抛出BusinessException异常,提示用户最多只能创建和加入5个队伍。
    5. 避免重复加入队伍:检查该用户是否已经加入指定的队伍,如果已经加入,则抛出异常,防止重复加入同一队伍。
    6. 检查队伍容量:计算指定队伍当前已加入的用户数量,如果达到队伍的最大人数限制,则抛出异常,告知队伍已满。
    7. 加入队伍并保存信息:如果以上所有条件检查都通过,说明用户可以合法地加入队伍。此时创建一个新的UserTeam对象记录用户的加入信息(包括用户ID、队伍ID以及加入时间),并调用userTeamService.save()方法保存至数据库,返回保存操作的结果(true表示成功,false表示失败)。
    8. 异常处理:如果在尝试获取锁过程中被中断(比如通过interrupt()方法),捕获InterruptedException异常,并记录错误日志,直接返回false表示操作失败。
    9. 释放锁:在finally块中检查当前线程是否持有锁,如果是,则解锁,确保锁能够被正确释放,即使在保存用户加入队伍信息过程中发生异常也是如此。打印解锁的日志信息以供调试跟踪。
// 该用户已加入的队伍数量
long userId = loginUser.getId();
// 只有一个线程能获取到锁
RLock lock = redissonClient.getLock("yupao:join_team");
try {
// 抢到锁并执行
    while (true) {
        if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
            System.out.println("getLock: " + Thread.currentThread().getId());
            QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
            userTeamQueryWrapper.eq("userId", userId);
            long hasJoinNum = userTeamService.count(userTeamQueryWrapper);
            if (hasJoinNum > 5) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "最多创建和加入 5 个队伍");
            }
            // 不能重复加入已加入的队伍
            userTeamQueryWrapper = new QueryWrapper<>();
            userTeamQueryWrapper.eq("userId", userId);
            userTeamQueryWrapper.eq("teamId", teamId);
            long hasUserJoinTeam = userTeamService.count(userTeamQueryWrapper);
            if (hasUserJoinTeam > 0) {
                throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户已加入该队伍");
            }
            // 已加入队伍的人数
            long teamHasJoinNum = this.countTeamUserByTeamId(teamId);
            if (teamHasJoinNum >= team.getMaxNum()) {
                throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍已满");
            }
            // 修改队伍信息
            UserTeam userTeam = new UserTeam();
            userTeam.setUserId(userId);
            userTeam.setTeamId(teamId);
            userTeam.setJoinTime(new Date());
            return userTeamService.save(userTeam);
        }
    }
} catch (InterruptedException e) {
    log.error("doCacheRecommendUser error", e);
    return false;
} finally {
    // 只能释放自己的锁
    if (lock.isHeldByCurrentThread()) {
        System.out.println("unLock: " + Thread.currentThread().getId());
        lock.unlock();
    }
}
  1. 后端发布(免备案)
    1. 微信云托管(部署服务器的平台)

    • 数据库需要修改,把localhost改成线上公网可访问的数据库。
  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值