1 登录认证

1.1 [1]平台:3端

01.平台端
    a.登录方式
        账号密码
        手机验证码
    b.权限控制
        必须登录后才能使用

02.媒体端
    a.登录方式
        账号密码
        手机验证码
    b.权限控制
        必须登录后才能使用

03.用户端
    a.登录方式
        账号密码
        手机验证码
        微信登录
        微博登录
    b.权限控制
        查看:匿名
        动作:点赞、评论、收藏,自动调整APP登录页面

1.2 [2]防护:5种

01.添加图形验证码
    用户发送短信前,需要先输入正确的图形验证码,或拖动验证码等验证,验证通过之后,才能正常发送短信验证码
    因为图形验证码的破解难度非常大,所以就避免了自动发送短信程序的执行

02.添加IP限制
    对请求IP的发送次数进行限制,避免短信盗刷和短信轰炸的问题
    例如,每个IP每天只能发送10 条短信

03.开启IP黑名单
    限制某个IP短信发送功能,从而禁止自动发送短信程序的执行

04.限制发送频次
    一个手机号不能一直不停的发送验证码(即使更换了多个IP也不行),设置一个手机号
    每分钟内只能发送1次验证码;一小时之内,只能发送5次验证码;一天之内,只能发送10次验证码

05.开启短信提供商的防控和报警功能
    几乎所有的短信提供商都提供了,异常短信的防控和提醒功能,开启这些保护措施,可以尽可能的避免短信盗刷的问题

1.3 [2]防护:拉黑、踢人下线

01.拉黑
    a.说明
        用户的拉黑,可以看是哪种拉黑方式
        如果是单个用户的拉黑,可以提供一个后台接口
        在管理端,通过输入用户的手机号,或者用户 ID 进行拉黑
        如果是批量的,那么就需要提供一个页面,支持批量上传用户列表
    b.3种方式
        a.加黑名单
            第一种是不修改用户表的方案
            即我们单独有一张黑名单表,这里记录了用户的黑名单的列表,这么做的好处是可以和用户表解耦,互相不干预
            而且还有个好处,就是可以做很多其他的事情,比如说拉黑开始时间、拉黑结束时间等等的各种控制
        b.更新用户的状态
            在用户表上有个字段表示这个用户的被拉黑了

        c.在用户表上打标
            在用户表上有个字段表示这个用户的被拉黑了
    c.说明
        a.说明1
            如果用户量比较大,或者是拉黑的用户比较多,建议用第一种方案
            而且这个名单还可以前置给到其他的业务一起用,比如风控
            如果用户量不大的话,没必要搞这个黑名单表,用户表加一个状态或者字段就行了
        b.说明2
            然后为了提升性能,针对用户的黑名单,还可以做缓存
            将黑名单用户缓存在 Redis 或者本地缓存中,可以快速的针对黑用户进行拦截
            而且黑名单非常适合使用布隆过滤器!如果量很大,可以进一步的用布隆过滤器来做缓存

02.踢人下线
    a.说明
        只需要我们把用户登录后的 Session 给他清空就行了
        这样用户在下次访问我们的系统的时候,因为查不到 Session,就是被强制下线了
    b.实现
        至于实现方式,要看 Session 存在哪
        比如 Redis 的话,那么就找到这个用户的 ID,然后把对应的 Session 给他清空即可

1.4 [2]登录:账密+手机+图片

01.账户密码
    a.用户表设计
        账号、密码、手机号、头像、注册时间、身份认证等字段
    b.登录流程
        前端以JSON形式提交账号、密码、验证码
        请求通过网关直接转发到用户服务
        校验参数合法性,使用Redis Zset进行时间窗口限流,防止频繁登录
        查询用户数据,校验密码(使用BCrypt加密)
        密码校验通过后,封装用户数据到JWT Token中,返回给前端

02.手机验证码
    a.手机验证码发送
        用户填写手机号,前端请求发送短信接口
        Common服务校验手机号合法性和是否被拉黑
        使用Redis Zset进行限流,5分钟内3次发送短信触发限流
        调用第三方短信服务发送验证码,成功后缓存到Redis,TTL为5分钟
        两级限流:5分钟内3次触发滑块验证码,10分钟内8次直接拉黑10分钟
    b.手机验证码登录
        前端提交手机号和验证码
        后端校验手机号合法性,查询Redis中验证码并销毁
        验证码正确则查询用户表,存在则返回JWT Token
        用户不存在则自动注册,使用默认用户名和头像

03.图片验证码(先存后发)
    a.定义
        使用 Redis Hash 存储验证码及其相关信息,如生成时间和验证码类型
    b.原理
        通过哈希结构将验证码及附加信息结构化地存储在一个键下,便于管理和检索
    c.常用API
        HSET:设置哈希表中的字段值
        HGET:获取哈希表中的字段值
    d.使用步骤
        选择哈希键:选择一个合适的键来存储哈希数据,例如 captcha:user123
        定义字段:在哈希中定义存储验证码及附加信息的字段,例如 code、created_at、type
        存储数据:使用 HSET 命令将验证码及其相关信息存储到 Redis 哈希中
    e.场景示例
        # 存储验证码及附加信息
        HSET captcha:user123 code 4567
        HSET captcha:user123 created_at 1687995600
        HSET captcha:user123 type "email_verification"

        # 获取验证码
        HGET captcha:user123 code
    f.基本流程
        1.前端:访问登录页面
        2.后端:第1步,生成【key】+【code】,并将该kv键值存到redis,【code经base64输出codeBase64 Image】
                第2步,发送【key】、【codeBase64 Image】
        3.前端:发送【用户名】、【密码】、【key】、【codelnput】
        4.后端:用【key】获取【code】,然后【code比较codelnput】
        5.前端:将【比较结果】进行显示,【验证码不正确/正确】

1.5 [2]限流:账密+手机+图片

00.zset+滑动窗口
    a.key
        场景:行为:用户唯一标识(手机号、用户名)
    b.score
        时间戳
    c.value
        时间戳

01.场景
    a.账户密码登录
        登录限流:限制用户在短时间内频繁尝试登录,以防止暴力破解密码。可以设置每分钟最多允许几次登录尝试
    b.手机验证码
        发送限流:限制用户在短时间内频繁请求发送验证码,防止滥用短信服务。可以设置每5分钟最多发送3次验证码
        验证限流:限制用户在短时间内频繁尝试验证验证码,防止暴力破解验证码。可以设置每分钟最多允许几次验证尝试
    c.图片验证码
        生成限流:限制用户在短时间内频繁请求生成图片验证码,防止滥用资源。可以设置每分钟最多生成几次验证码
        验证限流:限制用户在短时间内频繁尝试验证图片验证码,防止暴力破解验证码。可以设置每分钟最多允许几次验证尝试

02.发送验证码
    a.需求
        a.介绍
            用户5分钟内只能发送3个验证码,或者10分钟内只能发送8个验证码,于是我就将用户发短信的行为设计为key
            格式为【场景:行为:用户唯一标识(可以是手机号、用户名)】,score分数值是时间戳,value值也是时间戳
        b.设计
            在业务处理流程中,使用java api进行查询判断,其实本质就是调用redis的zcount命令,这个命令可以传入起始分值和结束分值
            我就把当前时间戳作为结束分值,然后当前时间戳减去限流时间,比如说5分钟的毫秒值,求出来5分钟前的时间戳
            于是根据这两个时间戳作为分值,范围查询zset中出现的次数,就得到用户在5分钟内,这个行为一共触发了几次
    b.实现思路
        a.使用Redis Zset
            利用Zset的有序性和按分数范围查询的特性,记录用户发送验证码的时间戳
        b.时间窗口
            通过计算当前时间与过去时间的差值,确定用户在指定时间窗口内的操作次数
        c.限流判断
            根据Zset中记录的时间戳数量,判断是否超过限流阈值
    c.实现步骤
        a.初始化Redis连接
            连接到Redis服务器
        b.记录用户行为
            每次用户请求发送验证码时,将当前时间戳记录到Zset中
        c.判断限流
            使用ZCOUNT命令查询指定时间窗口内的行为次数
        d.执行限流逻辑
            如果超过限流阈值,拒绝请求;否则,允许发送验证码
        e.清理过期数据
            定期清理Zset中超出时间窗口的旧数据
    d.代码示例
        import redis.clients.jedis.Jedis;

        public class SmsRateLimiter {

            private static final String REDIS_HOST = "localhost";
            private static final int REDIS_PORT = 6379;
            private static final int TIME_WINDOW = 5 * 60 * 1000; // 5分钟
            private static final int MAX_COUNT = 3; // 最大次数

            private Jedis jedis;

            public SmsRateLimiter() {
                this.jedis = new Jedis(REDIS_HOST, REDIS_PORT);
            }

            public boolean isAllowed(String userId) {
                String key = "sms:rate:limit:" + userId;
                long currentTime = System.currentTimeMillis();
                long startTime = currentTime - TIME_WINDOW;

                // 清理过期数据
                jedis.zremrangeByScore(key, 0, startTime);

                // 统计当前时间窗口内的请求次数
                long count = jedis.zcount(key, startTime, currentTime);

                if (count >= MAX_COUNT) {
                    return false; // 超过限流阈值
                } else {
                    // 记录当前请求
                    jedis.zadd(key, currentTime, String.valueOf(currentTime));
                    return true; // 允许请求
                }
            }

            public static void main(String[] args) {
                SmsRateLimiter rateLimiter = new SmsRateLimiter();
                String userId = "1234567890";

                if (rateLimiter.isAllowed(userId)) {
                    System.out.println("允许发送验证码");
                    // 发送验证码逻辑
                } else {
                    System.out.println("限流:5分钟内只能发送3次验证码");
                }
            }
        }

1.6 [2]验证码:实战1

01.注册
    a.手机验证码注册流程
        a.前端请求和手机号码处理
            用户发起获取验证码的请求,后端接收手机号码,生成随机验证码并存储在 Redis 中,这部分流程是标准的短信验证流程
            在存储到 Redis 时明确了验证码的有效时间(5分钟)
        b.验证码发送
            验证码通过调用短信服务发送,这里需要自行选择像阿里云、华为云等短信发送平台
        c.用户验证和注册提交
            用户收到验证码后,在前端输入验证码并提交注册请求
            系统从 Redis 中获取验证码并与用户输入的验证码进行匹配
            如果匹配成功,注册流程继续进行并完成注册
            如果匹配失败,提示用户验证码错误
    b.代码实现
        a.匹配短信消息发送相关参数
            以华为云为例
        b.编写短信发送工具类
            @Component
            public class SendSmsUtil {
                @Value("${huawei.sms.url}")
                private String url;
                @Value("${huawei.sms.appKey}")
                private String appKey;
                @Value("${huawei.sms.appSecret}")
                private String appSecret;
                @Value("${huawei.sms.sender}")
                private String sender;
                @Value("${huawei.sms.signature}")
                private String signature;
                private static final String WSSE_HEADER_FORMAT = "UsernameToken Username=\"%s\",PasswordDigest=\"%s\",Nonce=\"%s\",Created=\"%s\"";
                private static final String AUTH_HEADER_VALUE = "WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\"";
                public void sendSms(String templateId,String receiver, String templateParas) throws IOException {
                    String body = buildRequestBody(sender, receiver, templateId, templateParas, "", signature);
                    String wsseHeader = buildWsseHeader(appKey, appSecret);
                    HttpsURLConnection connection = null;
                    OutputStreamWriter out = null;
                    BufferedReader in = null;
                    StringBuilder result = new StringBuilder();
                    try {
                        URL realUrl = new URL(url);
                        connection = (HttpsURLConnection) realUrl.openConnection();
                        connection.setDoOutput(true);
                        connection.setDoInput(true);
                        connection.setRequestMethod("POST");
                        connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
                        connection.setRequestProperty("Authorization", "WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\"");
                        connection.setRequestProperty("X-WSSE", wsseHeader);
                        out = new OutputStreamWriter(connection.getOutputStream());
                        out.write(body);
                        out.flush();
                        int status = connection.getResponseCode();
                        InputStream is;
                        if (status == 200) {
                            is = connection.getInputStream();
                        } else {
                            is = connection.getErrorStream();
                        }
                        in = new BufferedReader(new InputStreamReader(is, "UTF-8"));
                        String line;
                        while ((line = in.readLine()) != null) {
                            result.append(line);
                        }
                        System.out.println(result.toString());
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        if (out != null) {
                            out.close();
                        }
                        if (in != null) {
                            in.close();
                        }
                        if (connection != null) {
                            connection.disconnect();
                        }
                    }
                }
                static String buildRequestBody(String sender, String receiver, String templateId, String templateParas,
                                               String statusCallBack, String signature) {
                    if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty()
                            || templateId.isEmpty()) {
                        System.out.println("buildRequestBody(): sender, receiver or templateId is null.");
                        return null;
                    }
                    Map<String, String> map = new HashMap<String, String>();
                    map.put("from", sender);
                    map.put("to", receiver);
                    map.put("templateId", templateId);
                    if (null != templateParas && !templateParas.isEmpty()) {
                        map.put("templateParas", templateParas);
                    }
                    if (null != statusCallBack && !statusCallBack.isEmpty()) {
                        map.put("statusCallback", statusCallBack);
                    }
                    if (null != signature && !signature.isEmpty()) {
                        map.put("signature", signature);
                    }
                    StringBuilder sb = new StringBuilder();
                    String temp = "";
                    for (String s : map.keySet()) {
                        try {
                            temp = URLEncoder.encode(map.get(s), "UTF-8");
                        } catch (UnsupportedEncodingException e) {
                            e.printStackTrace();
                        }
                        sb.append(s).append("=").append(temp).append("&");
                    }
                    return sb.deleteCharAt(sb.length()-1).toString();
                }
                static String buildWsseHeader(String appKey, String appSecret) {
                    if (null == appKey || null == appSecret || appKey.isEmpty() || appSecret.isEmpty()) {
                        System.out.println("buildWsseHeader(): appKey or appSecret is null.");
                        return null;
                    }
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
                    String time = sdf.format(new Date());
                    String nonce = UUID.randomUUID().toString().replace("-", "");
                    MessageDigest md;
                    byte[] passwordDigest = null;
                    try {
                        md = MessageDigest.getInstance("SHA-256");
                        md.update((nonce + time + appSecret).getBytes());
                        passwordDigest = md.digest();
                    } catch (NoSuchAlgorithmException e) {
                        e.printStackTrace();
                    }
                    String passwordDigestBase64Str = Base64.getEncoder().encodeToString(passwordDigest);
                    return String.format(WSSE_HEADER_FORMAT, appKey, passwordDigestBase64Str, nonce, time);
                }
                static void trustAllHttpsCertificates() throws Exception {
                    TrustManager[] trustAllCerts = new TrustManager[] {
                            new X509TrustManager() {
                                public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                                    return;
                                }
                                public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                                    return;
                                }
                                public X509Certificate[] getAcceptedIssuers() {
                                    return null;
                                }
                            }
                    };
                    SSLContext sc = SSLContext.getInstance("SSL");
                    sc.init(null, trustAllCerts, null);
                    HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
                }
            }
            -------------------------------------------------------------------------------------------------
            上述工具类 SendSmsUtil 是一个用于通过华为云短信服务发送短信验证码的工具类
            它通过构建请求体和鉴权头信息,将短信发送请求发送到华为短信服务接口
            该类包含了短信发送的核心逻辑,包括生成 X-WSSE 头用于请求认证、构造请求体以及处理 HTTPS 连接的相关逻辑
            同时,工具类还包含了信任所有 HTTPS 证书的设置,以确保与华为云服务器的安全连接
        c.发送验证码函数方法
            public String sendSMS(SendSMSDTO sendSMSDTO) throws IOException {
                String phone = sendSMSDTO.getPhone();
                String captcha = generateCaptcha();
                String redisKey = sendSMSDTO.getCaptchaType().equals(0)
                        ? REDIS_REGISTER_CAPTCHA_KEY + phone
                        : REDIS_LOGIN_CAPTCHA_KEY + phone;
                String message = sendSMSDTO.getCaptchaType().equals(0)
                        ? "发送注册短信验证码:{}"
                        : "发送登录短信验证码:{}";
                sendSmsUtil.sendSms(templateId, phone, "["" + captcha + ""]");
                log.info(message, captcha);
                redisUtils.set(redisKey, captcha, 300);
                return "发送短信成功";
            }
            -------------------------------------------------------------------------------------------------
            上述代码实现了一个短信验证码发送流程
            首先,通过 generateCaptcha() 方法生成一个验证码
            并调用 sendSmsUtil.sendSms() 将验证码发送到用户的手机号码
            短信发送后,利用日志记录了发送的验证码
            接着,验证码被存储在 Redis 中,键为手机号加上特定前缀,且设置了 300 秒的有效期
            最后,返回一个短信发送成功的消息

02.登录
    a.手机验证码登录流程
        a.验证码发送流程
            流程依然从用户请求验证码开始,后端接收手机号并生成验证码,通过短信服务平台(如阿里云、华为云)发送验证码
        b.验证码验证及登录提交
            用户收到验证码后输入并提交登录请求,系统从 Redis 中获取存储的验证码,与用户输入的验证码进行匹配
            如果验证码匹配失败,系统会提示用户验证码错误
        c.用户信息查询及 Token 生成
            当验证码匹配成功后,系统会进一步查询用户信息,检查是否存在有效的用户账号
            如果用户信息存在,系统生成 Token 完成登录,确保用户的身份验证
        d.实现细节
            验证码登录的核心是实现 Spring Security 的 AuthenticationProvider 接口,用于自定义认证逻辑
            创建一个 SmsCodeAuthenticationToken,类似于 UsernamePasswordAuthenticationToken,用于存储手机号和验证码
            实现自定义的 AuthenticationFilter,拦截登录请求,并将手机号和验证码封装为 SmsCodeAuthenticationToken,然后交给 AuthenticationManager 进行认证
            AuthenticationProvider 中验证验证码是否正确,如果正确,则返回已认证的 Authentication 对象
    b.涉及到的 Spring Security 组件
        a.AuthenticationManager
            AuthenticationManager 是 Spring Security 认证的核心组件,负责处理不同的认证请求
            我们可以自定义一个 AuthenticationProvider 来处理手机验证码的认证逻辑
            并将其注入到 AuthenticationManager 中。这样当用户提交验证码登录请求时
            AuthenticationManager 会调用我们的自定义认证提供者进行验证
        b.AuthenticationProvider
            AuthenticationProvider 是处理认证逻辑的核心接口
            为了支持手机验证码登录,我们需要实现一个自定义的 AuthenticationProvider,其中包含以下逻辑:
            接收包含手机号和验证码的登录请求
            验证 Redis 中存储的验证码是否与用户输入的验证码匹配
            验证成功后,创建并返回 Authentication 对象,表示用户已通过认证
        c.UserDetailsService
            UserDetailsService 是 Spring Security 中用于加载用户信息的接口
            我们可以通过实现 UserDetailsService 来查询和加载用户信息,比如通过手机号查询用户的详细信息(包括权限、角色等)
            如果用户信息存在且验证码验证通过,系统将生成相应的 UserDetails 对象,并将其与 Spring Security 的认证上下文进行关联
        d.AuthenticationToken
            在 Spring Security 中,AuthenticationToken 是认证过程中传递用户凭据的对象
            我们需要自定义一个 SmsAuthenticationToken,用于封装手机号和验证码,并传递给 AuthenticationProvider 进行处理
            这个 Token 类需要继承自 AbstractAuthenticationToken,并包含手机号和验证码信息
        e.SecurityConfigurerAdapter
            SecurityConfigurerAdapter 是 Spring Security 配置的核心类,用于配置 Spring Security 的各种安全策略
            为了集成手机验证码登录,我们需要扩展 SecurityConfigurerAdapter
            在其中配置我们的 AuthenticationProvider 和自定义的登录过滤器
        f.自定义过滤器
            UsernamePasswordAuthenticationFilter 是 Spring Security 默认的用户名密码认证过滤器
            为了支持手机验证码登录,我们可以自定义一个类似的过滤器 SmsAuthenticationFilter
            在其中获取用户的手机号和验证码,然后交给 AuthenticationManager 进行处理
            这个过滤器将拦截验证码登录请求,并调用 AuthenticationProvider 进行验证
        g.SecurityContextHolder
            SecurityContextHolder 是 Spring Security 中用于存储当前认证信息的类
            在用户成功通过验证码登录认证后,系统会将 Authentication 对象存储到 SecurityContextHolder 中
            表明当前用户已经成功登录
        h.实现细节
            验证码登录的核心是实现 Spring Security 的 AuthenticationProvider 接口,用于自定义认证逻辑
            创建一个 SmsCodeAuthenticationToken,类似于 UsernamePasswordAuthenticationToken,用于存储手机号和验证码
            实现自定义的 AuthenticationFilter,拦截登录请求
            并将手机号和验证码封装为 SmsCodeAuthenticationToken,然后交给 AuthenticationManager 进行认证
            AuthenticationProvider 中验证验证码是否正确,如果正确,则返回已认证的 Authentication 对象
    c.代码实现(仅核心)
        a.编写 SmsAuthenticationFilter
            public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
                public static final String PHONE_KEY = "phone";
                public static final String CAPTCHA_KEY = "captcha";
                private boolean postOnly = true;
                private final ObjectMapper objectMapper = new ObjectMapper();
                public SmsAuthenticationFilter() {
                    super("/sms/login");
                }
                @Override
                public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
                    if (postOnly && !request.getMethod().equals("POST")) {
                        throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
                    }
                    String phone;
                    String captcha;
                    try {
                        Map<String, String> requestBody = objectMapper.readValue(request.getInputStream(), Map.class);
                        phone = requestBody.get(PHONE_KEY);
                        captcha = requestBody.get(CAPTCHA_KEY);
                    } catch (IOException e) {
                        throw new AuthenticationServiceException("Failed to parse authentication request body", e);
                    }
                    if (phone == null) {
                        phone = "";
                    }
                    if (captcha == null) {
                        captcha = "";
                    }
                    phone = phone.trim();
                    SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, captcha);
                    return this.getAuthenticationManager().authenticate(authRequest);
                }
                public void setPostOnly(boolean postOnly) {
                    this.postOnly = postOnly;
                }
            }
            -------------------------------------------------------------------------------------------------
            上述代码实现了一个 SmsAuthenticationFilter,用于处理短信验证码登录请求
            它继承了 AbstractAuthenticationProcessingFilter
            并在接收到 POST 请求时从请求体中解析手机号和验证码的 JSON 数据
            创建一个 SmsAuthenticationToken
            然后通过 Spring Security 的认证管理器进行身份验证
            如果请求不是 POST 方法或解析 JSON 失败,会抛出相应的异常
        b.编写 SmsAuthenticationProvider
            public class SmsAuthenticationProvider implements AuthenticationProvider {
                private final UserDetailsService userDetailsService;
                private final RedisUtils redisUtils;
                public SmsAuthenticationProvider(UserDetailsService userDetailsService, RedisUtils redisUtils) {
                    this.userDetailsService = userDetailsService;
                    this.redisUtils = redisUtils;
                }
                @Override
                public Authentication authenticate(Authentication authentication) throws AuthenticationException {
                    String phone = (String) authentication.getPrincipal();
                    String captcha = (String) authentication.getCredentials();
                    if(!redisUtils.hasKey(REDIS_LOGIN_CAPTCHA_KEY + phone)){
                        throw new BadCredentialsException("验证码已过期");
                    }
                    String redisCaptcha = redisUtils.get(REDIS_LOGIN_CAPTCHA_KEY + phone).toString();
                    if (redisCaptcha == null || !redisCaptcha.equals(captcha)) {
                        throw new BadCredentialsException("验证码错误");
                    }
                    UserDetails userDetails = userDetailsService.loadUserByUsername(phone);
                    if (userDetails == null) {
                        throw new BadCredentialsException("未找到对应的用户,请先注册");
                    }
                    return new SmsAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                }
                @Override
                public boolean supports(Class<?> authentication) {
                    return SmsAuthenticationToken.class.isAssignableFrom(authentication);
                }
            }
            -------------------------------------------------------------------------------------------------
            上述代码实现了一个 SmsAuthenticationProvider,用于处理短信验证码登录的身份验证逻辑
            它通过 UserDetailsService 加载用户信息,并使用 RedisUtils 从 Redis 中获取验证码进行比对
            如果验证码不存在或不匹配,会抛出 BadCredentialsException 异常
            如果验证码正确且用户存在,则生成已认证的 SmsAuthenticationToken 并返回,完成用户身份验证
            该类还定义了它支持的身份验证类型为 SmsAuthenticationToken
        c.编写SmsAuthenticationToken
            public class SmsAuthenticationToken extends AbstractAuthenticationToken {
                private final Object principal;
                private Object credentials;
                public SmsAuthenticationToken(Object principal, Object credentials) {
                    super(null);
                    this.principal = principal;
                    this.credentials = credentials;
                    setAuthenticated(false);
                }
                public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
                    super(authorities);
                    this.principal = principal;
                    this.credentials = credentials;
                    setAuthenticated(true);
                }
                @Override
                public Object getCredentials() {
                    return this.credentials;
                }
                @Override
                public Object getPrincipal() {
                    return this.principal;
                }
                @Override
                public void eraseCredentials() {
                    super.eraseCredentials();
                    this.credentials = null;
                }
            }
            -------------------------------------------------------------------------------------------------
            上述代码实现了一个自定义的 SmsAuthenticationToken,继承自 AbstractAuthenticationToken
            用于表示短信验证码登录的认证信息。它包含用户的手机号 (principal) 和验证码 (credentials) 两个字段
            并提供两种构造方法:一种用于未认证的登录请求,另一种用于已认证的用户信息
            通过 getPrincipal() 获取手机号,getCredentials() 获取验证码
            并且在调用 eraseCredentials() 时清除验证码以增强安全性
        d.配置 WebSecurityConfigurerAdapter
            a.新增验证码过滤
                // 添加短信验证码过滤器
                http.addFilterBefore(smsAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
            b.定义短信验证码认证过滤器,设置认证管理器及认证成功和失败的处理器
                @Bean
                public SmsAuthenticationFilter smsAuthenticationFilter() throws Exception {
                    SmsAuthenticationFilter filter = new SmsAuthenticationFilter();
                    filter.setAuthenticationManager(authenticationManagerBean());
                    filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler);
                    filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler);
                    return filter;
                }
            c.定义短信验证码认证提供者,注入用户详情服务和 Redis 工具类,用于处理短信验证码的认证逻辑
                @Bean
                public SmsAuthenticationProvider smsAuthenticationProvider() {
                    return new SmsAuthenticationProvider(smeUserDetailsService,redisUtils);
                }
            d.配置认证管理器,添加短信验证码、微信登录以及用户名密码的认证提供者
                @Override
                protected void configure(AuthenticationManagerBuilder auth) throws Exception {
                    auth.authenticationProvider(smsAuthenticationProvider());
                    auth.authenticationProvider(weChatAuthenticationProvider());
                    auth.authenticationProvider(daoAuthenticationProvider());
                }
    d.效果测试
        基于上述的手机验证码登录代码,我们来测试一下接口成果

1.7 [2]验证码:实战2

01.认证服务环境搭建
    a.新建项目
        新建一个认证服务项目,用于用户登录和注册功能。
    b.整合相关依赖
        引入common依赖并排除数据库相关依赖:
            <dependency>
                <groupId>com.sysg.gulimail</groupId>
                <artifactId>gulimail-common</artifactId>
                <version>0.0.1-SNAPSHOT</version>
                <exclusions>
                    <exclusion>
                        <groupId>com.baomidou</groupId>
                        <artifactId>mybatis-plus-boot-starter</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
    c.将项目配置进nacos
        a.在application.properties中配置
            spring.application.name=gulimail-auth-server
            spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
            server.port=20000
        b.在主启动类添加注解
            @EnableDiscoveryClient  // 开启服务注册发现功能
            @EnableFeignClients      // 开启服务远程调用功能
        c.开启服务
            xxx
    d.导入登录注册页面
        将静态资源放到nginx中以实现动静分离。
    e.配置网关
        配置认证服务路由:
        - id: gulimail_auth_route
          uri: lb://gulimail-auth-server
          predicates:
            - Host=auth.gulimail.com

02.springMVC映射请求到页面
    a.新建GulimailWebConfig类
        @Configuration
        public class GulimailWebConfig implements WebMvcConfigurer {
            @Override
            public void addViewControllers(ViewControllerRegistry registry) {
                registry.addViewController("/login.html").setViewName("login");
                registry.addViewController("/reg.html").setViewName("reg");
            }
        }

03.验证码倒计时
    a.功能描述
        点击发送验证码按钮后,进入60秒倒计时。
    b.前端实现
        a.添加HTML元素
            <a id="sendCode">发送验证码</a>
        b.添加JavaScript逻辑
            $(function () {
                $("#sendCode").click(function () {
                    if ($(this).hasClass("disabled")) {
                        // 正在倒计时
                    } else {
                        let phone = $("#phoneNumber").val();
                        $.get("/sms/controller?phone=" + phone);
                        timeoutChangeStyle();
                    }
                });
            });
            let num = 60;
            function timeoutChangeStyle() {
                $("#sendCode").attr("class", "disabled");
                if (num == 0) {
                    $("#sendCode").text("发送验证码");
                    num = 60;
                    $("#sendCode").attr("class", "");
                } else {
                    let str = num + "s 后再次发送";
                    $("#sendCode").text(str);
                    setTimeout("timeoutChangeStyle()", 1000);
                }
                num--;
            }

04.阿里云配置发送短信验证码
    a.登录阿里云并购买0元五次短信套餐。
    b.短信验证码接口描述
        调用地址:http(s)://dfsns.market.alicloudapi.com/data/send_sms  
        请求方式:POST  
        返回类型:JSON
    c.代码实现
        public class SmsComponent {
            private String host;
            private String path;
            private String templateId;
            private String appcode;

            public void sendSmsCode(String phone, String code) {
                String method = "POST";
                Map<String, String> headers = new HashMap<>();
                headers.put("Authorization", "APPCODE " + appcode);
                headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
                Map<String, String> bodys = new HashMap<>();
                bodys.put("content", "code:" + code);
                bodys.put("phone_number", phone);
                bodys.put("template_id", templateId);
                try {
                    HttpUtils.doPost(host, path, method, headers, null, bodys);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    d.参数动态配置
        配置文件绑定:
            spring:
              cloud:
                alicloud:
                  sms:
                    host: https://dfsns.market.alicloudapi.com
                    path: /data/send_sms
                    templateId: TPL_0000
                    appcode: 712wefwefweab39a78
              application:
                name: gulimail-third-party
              redis:
                host: 127.0.0.1
                port: 6379
    e.本地调试
        1.引入httpUtil依赖。
        2.引入processor依赖。
        3.进行本地测试。

05.短信验证码后台接口
    a.新建SmsSendController
        @RestController
        @RequestMapping("/sms")
        public class SmsSendController {
            @Autowired
            private SmsComponent smsComponent;

            @GetMapping("/sendcode")
            public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
                smsComponent.sendSmsCode(phone, code);
                return R.ok();
            }
        }
    b.在auth模块远程调用短信功能
        a.创建ThirdPartFeignService
            @FeignClient("gulimail-third-party")
            public interface ThirdPartFeignService {
                @GetMapping("/sms/sendcode")
                public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
            }
        b.生成六位随机数字
            public class RandomUUID {
                public static String randomSixNumber() {
                    StringBuilder str = new StringBuilder();
                    Random random = new Random();
                    for (int i = 0; i < 6; i++) {
                        str.append(random.nextInt(10));
                    }
                    return str.toString();
                }
            }
        c.创建发送验证码逻辑
            @Controller
            public class LoginController {
                @Autowired
                private ThirdPartFeignService thirdPartFeignService;

                @GetMapping("/sms/sendcode")
                public R sendCode(@RequestParam("phone") String phone) {
                    String code = RandomUUID.randomSixNumber();
                    thirdPartFeignService.sendCode(phone, code);
                    return R.ok();
                }
            }
        d.测试发送验证码
            let phone = $("#phoneNumber").val();
            $.get("/sms/sendcode?phone=" + phone);

06.验证码防刷校验,设置过期时间
    a.auth模块引入redis依赖。
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    b.配置redis地址
        spring.redis.host=127.0.0.1
        spring.redis.port=6379
    c.存储到redis,key-phone,value-code
        @GetMapping("/sms/sendcode")
        @ResponseBody
        public R sendCode(@RequestParam("phone") String phone) {
            String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
            if (!StringUtils.isEmpty(redisCode)) {
                long redisDate = Long.parseLong(redisCode.split("_")[1]);
                if (System.currentTimeMillis() - redisDate < 60000) {
                    return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(),
                                   BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());
                }
            }
            String code = RandomUUID.randomSixNumber();
            String dateCode = code + "_" + System.currentTimeMillis();
            stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, dateCode, 10, TimeUnit.MINUTES);
            thirdPartFeignService.sendCode(phone, code);
            return R.ok();
        }

1.8 [3]微信扫码:公众号

01.实现原理
    a.角色
        用户:用户是扫码登录的发起方,点击登录,然后扫描登录二维码
        浏览器:浏览器为用户展示二维码,然后不断的轮询扫码状态
        服务端:网站服务端需要向微信服务端获取携带 Ticket 信息的公众号二维码,在微信服务端回调时绑定用户身份信息
        微信服务端:用户扫码后,会请求到微信服务端,微信服务端会携带扫描的二维码的 Ticket 和用户身份标识回调网站服务端
    b.总结
        微信服务端回调网站服务端时,携带的用户身份信息其实只是一串无意义字符串,但是微信可以保证的是同一个微信用户
        扫码时携带的身份信息字符是相同的,以此识别用户。也因此公众号扫码登录用作身份认证非常安全

02.准备工作
    a.微信测试号
        首先你要有用于扫码登录的微信公众号,微信公众平台提供了测试平台,可以直接生成测试公众号
        微信测试号:https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login
    b.开发者文档
        a.公众号接口接入指南
            https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
        b.获取Access Token
            Access Token 是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用 Access Token
            每次获取有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的 Access Token 失效
        c.生成带Ticket二维码
            使用该接口可以获得多个带不同场景值的二维码,用户扫描后,公众号可以接收到事件推送。
        d.接收事件推送
            在用户扫码后微信服务端会回调网站服务端,开发者需要按照指定消息格式对消息进行验证处理。
            如获取二维码的 Ticket。
        e.回复文本消息
            如果想要在用户扫码完成后自动响应如 “登录成功” 之类的提示语
    c.网站服务端
        内网穿透
        云服务器

03.发起流程
    a.说明1
        a.用户点击登录按钮
        b.浏览器向服务端请求获取登录二维码
           服务端检查 Access Token 是否为 null
           如果 Access Token 为 null,则服务端向微信服务端请求获取 Access Token
           微信服务端返回 Access Token 给服务端
           服务端使用 Access Token 向微信服务端请求获取二维码 Ticket
           微信服务端返回二维码 Ticket 给服务端
           服务端将二维码(由 Ticket 生成)返回给浏览器
        c.浏览器显示二维码
        d.用户扫描二维码
        e.微信服务端通知服务端用户已扫描二维码
           服务端绑定用户的 openid 和二维码 Ticket
        f.浏览器轮询服务端获取扫码状态
           如果用户已扫描二维码,则服务端返回扫码成功状态
           浏览器停止轮询
    b.说明2
        a.由用户发起登录操作
            让WEB页面从服务端获取登录凭证
        b.前端页面拿到登录凭证后
            可以使用Ticket从公众号服务平台换取二维码
        c.用户扫码登录
            扫码后,服务端会接收到来自公众号的回调消息
            服务端再把回调消息中的 openid【用户唯一标识】和 ticket 进行绑定
            这个时候你也可以创建出 jwt token 反馈给前端,作为登录成功的存储信息,后续校验 jwt token 就可以了

04.二维码:三个阶段
    a.待扫描阶段
        首先是待扫描阶段,这个阶段是PC端跟服务端的交互过程
        每次用户打开PC端登陆请求,系统返回一个唯一的二维码ID,并将二维码ID的信息绘制成二维码返回给用户
        这里的二维码ID一定是唯一的,后续流程会将二维码ID跟身份信息绑定,不唯一的话就会造成你登陆了其他用户的账号或者其他用户登陆你的账号
        此时在PC端会启动一个定时器,轮询查询二维码是否被扫描
        如果移动端未扫描的话,那么一段时间后二维码将会失效
    b.已扫描待确认
        第二个阶段是已扫描待确认阶段,主要是移动端跟服务端交互的过程
        首先移动端扫描二维码,获取二维码ID,然后将手机端登录的凭证 (token)和二维码 ID作为参数发送给服务端
        此时的手机在之前已经是登录的,不存在没登录的情况
        服务端接受请求后,会将token与二维码ID关联,然后会生成一个临时token,这个 token会返回给移动端,临时 token 用作确认登录的凭证
        PC端的定时器,会轮询到二维码的状态已经发生变化,会会将PC端的二维码更新为已扫描,请在手机端确认
        ---------------------------------------------------------------------------------------------------------
        打断一下,这里为什么要有手机端确认的操作?
        假设没有确认这个环节,很容易就会被坏人拦截token去冒充登录
        所以二维码扫描一定要有这个确认的页面,让用户去确认是否进行登录
        另外,二维码扫描确认之后,再往用户app或手机等发送登录提醒的通知,台告知如果不是本人登录的,则建议用户立即修改密码
    c.已确认
        然后是扫码登录的最后阶段,用户点击确认登录,移动端携带上一步骤中获取的临时token访问服务端
        服务端校对完成后会更新二维码状态,并且给 PC 端生成一个正式的token
        后续PC端就是持有这个token访问服务端

05.代码实现
    a.前端对接建议
        前端通过 /auth/wechat/qrcode-url 获取二维码 URL,使用微信官方 JS 库渲染
        扫码成功后,前端通过轮询或 WebSocket 检查登录状态
    b.注册微信开放平台
        申请网站应用,获取AppID和AppSecret(需 ICP 备案域名)
        设置授权回调域名(如 www.yourdomain.com)
    c.添加 Maven 依赖
        <!-- HTTP请求工具 -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.9.3</version>
        </dependency>
        <!-- JSON解析 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.13.3</version>
        </dependency>
    d.生成微信登录二维码(Controller 层)
        @RestController
        @RequestMapping("/auth/wechat")
        public class WechatAuthController {

            // 从配置文件读取(application.yml)
            @Value("${wechat.appid}")
            private String appId;

            @Value("${wechat.callback}")
            private String callbackUrl;

            /**
             * 生成微信登录二维码的URL
             */
            @GetMapping("/qrcode-url")
            public String getQrCodeUrl() {
                // 微信开放平台生成二维码的固定URL格式
                String url = "https://open.weixin.qq.com/connect/qrconnect" +
                        "?appid=%s" +
                        "&redirect_uri=%s" +
                        "&response_type=code" +
                        "&scope=snsapi_login" +  // 固定值
                        "&state=YOUR_STATE";     // 防CSRF攻击随机字符串(需存储校验)

                return String.format(url, appId, URLEncoder.encode(callbackUrl, StandardCharsets.UTF_8));
            }
        }
    e.处理微信回调(获取用户信息)
        /**
         * 微信回调接口(需与开放平台配置的回调地址一致)
         */
        @GetMapping("/callback")
        public ResponseEntity<?> callback(@RequestParam String code, @RequestParam String state) {
            // 1. 校验state参数(防止CSRF攻击)
            if (!validateState(state)) {
                return ResponseEntity.badRequest().body("非法请求");
            }

            // 2. 用code换取access_token
            String tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" +
                    "?appid=%s" +
                    "&secret=%s" +
                    "&code=%s" +
                    "&grant_type=authorization_code";

            String response = OkHttpUtil.get(String.format(tokenUrl, appId, appSecret, code));
            JsonNode tokenJson = JsonUtil.parse(response);

            // 3. 获取用户信息
            String userInfoUrl = "https://api.weixin.qq.com/sns/userinfo" +
                    "?access_token=%s" +
                    "&openid=%s";

            String userResponse = OkHttpUtil.get(
                    String.format(userInfoUrl, tokenJson.get("access_token").asText(),
                                           tokenJson.get("openid").asText()));
            JsonNode userInfo = JsonUtil.parse(userResponse);

            // 4. 处理用户登录(示例:创建本地用户或绑定已有账号)
            User user = userService.createOrUpdateWechatUser(userInfo);

            // 5. 生成JWT或Session(此处以JWT为例)
            String jwtToken = JwtUtil.generateToken(user.getId());

            return ResponseEntity.ok().header("Authorization", jwtToken).build();
        }
    f.工具类封装
        public class OkHttpUtil {
            /**
             * 发送GET请求
             */
            public static String get(String url) throws IOException {
                OkHttpClient client = new OkHttpClient();
                Request request = new Request.Builder().url(url).build();
                try (Response response = client.newCall(request).execute()) {
                    return response.body().string();
                }
            }
        }

        public class JsonUtil {
            private static final ObjectMapper mapper = new ObjectMapper();

            /**
             * JSON字符串转对象
             */
            public static JsonNode parse(String json) throws JsonProcessingException {
                return mapper.readTree(json);
            }
        }
    g.配置示例(application.yml)
        wechat:
          appid: wx1234567890abcdef  # 微信应用ID
          app-secret: your_app_secret_here  # 微信应用密钥
          callback: https://www.yourdomain.com/auth/wechat/callback  # 授权回调地址

1.9 [3]微信登录:小程序

00.常见场景
    a.示例
        飞猪小程序
        顺丰小程序
    b.流程
        登录首页面:就属于经典的手机号登陆
        点击快速登录:小程序会迅速调用用户的手机号授权
        用户信息授权登录:该登陆主要为了获取用户信息(姓名、性别、地址、昵称等等),用于给个人中心模块做铺垫

01.第一种:无感登录(拿小程序token的过程)
    a.流程
        首先无感登录是最简单的,步骤只有两步,
        第一步是前端调用官方文档API——wx.login,拿到登陆凭证code,通过wx.request()发起网络请求,随即传给后端。
        第二步,后端那边利用code + appid + appsecret这三个数值,调用微信的auth.code2Session接口,拿到用户唯一标识openid 和 会话密钥session_key,随即定义token,将之与openid和session_key关联,最后再返回给前端。
        前端拿到token,就很简单了,按照正常操作即可,比如拿token设置请求头、存入vuex、pinia等等,顺理成章直接写即可,大家都能明白。
    b.具体流程
        a.用户操作
            用户在小程序端点击登录,前端调用微信的wx.login()方法,用户需要在微信端点击允许登录
        b.获取临时票据
            微信服务器返回一个临时票据code,前端将其发送给后端
        c.后端请求微信服务器
            后端使用AppID、App秘钥和临时票据code向微信服务器请求,获取SessionKey和OpenId
            SessionKey:会话秘钥,用于校验会话数据的签名和解密
            OpenId:用户的唯一标识,用于判断用户的唯一性
        d.用户信息处理
            如果用户是首次登录,插入新的用户信息。由于新版微信API关闭了getUserInfo接口,使用OpenId+随机字符串作为默认用户名
            查询用户关联的团长信息和自提点信息
        e.生成Token
            根据用户ID和用户名生成自定义的JWT格式Token,返回给前端
    c.示例
        a.代码
            wx.login({
              success (res) {
                if (res.code) {
                  //发起网络请求
                  wx.request({
                    url: 'https://example.com/onLogin',
                    data: {
                      code: res.code
                    }
                  })
                } else {
                  console.log('登录失败!' + res.errMsg)
                }
              }
            })
        b.说明
            wx.login的传参+返回值,重点关注success和fail,一个是成功回调,一个是失败回调
            返回值是code,有时效限制,这里要注意的是,前端的appId,要和后端的appId一致
            有的人拿不同的appId去调用接口,最后会导致500报错

02.第二种:手机号登录(付费)
    a.流程
        需要注意的是,个人账号,无法使用手机号登录功能,并且该功能是收费的
        标准单价每次组件调用成功,收0.03元,每个小程序账号将有1000次体验额度,该1000次的体验额度为正式版、体验版和开发版小程序共用,超额后,体验版和开发版小程序调用同正式版小程序一样,均收费
        这一要说明一点的是,相信很多人在网上都看到类似encryptedData、iv获取手机号的方法,25年为止,微信又改版了,手机号登录的流程又得到了简化。(前提是使用付费服务)
        流程为:调用bindgetphonenumber,返还code,这个code是限时+一次性的,服务器只需要拿着这个code去和微信换手机号就可以了
    b.说明
        传送门一:官方手机组件
        传送门二:获取手机号最新方法
    c.示例
        <button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>
        这里要注意一点,如果你用的是uniapp,那么bindgetphonenumber需要换为@getphonenumber
        还是通过wx.login拿code,然后调用这个接口,具体要和后端商量,前端的工作并不多,调用而已
        所以手机号登录没那么复杂,重点是需要付费,不付费的话,让用户自行输入表单,也行,看具体业务实现方式

03.第三种:用户信息授权登录
    a.流程
        对于用户授权登录的问题,那么就绕不过wx.getUserInfo和wx.getUserProfile的历史渊源了
        早期的小程序开发,大家都是通过wx.getUserInfo拿到用户头像昵称,结果2021年4月,微信社区改版,导致getUserInfo不再有授权流程,开发者只能获取到匿名信息
        比如名字,大家都叫做“微信用户”,而头像,接口返回的都是统一灰色头像
        可这样就带来一个问题,那就是不同用户,昵称头像都一样,完全不方面管理,所以wx.getUserProfile接口应运而生
        -----------------------------------------------------------------------------------------------------
        这一有一个行为,大家要注意,wx.getUserInfo获取用户信息,不会有底部弹窗,而wx.getUserProfile则会出现下方的底部弹窗(样式看开头),根据你的需求自行选择
        再到2022年10月,微信社区又改版了,就连wx.getUserProfile这个接口,也不给开发者权限了,用户名+头像,全部变成了统一的“微信用户”+灰色头像
    b.示例
        如果你实在想获取用户信息,那么利用组件,让用户自行填写,是不错的选择
        -----------------------------------------------------------------------------------------------------
        getUserProfile(e) {
          wx.getUserProfile({
            desc: '用于完善会员资料',
            success: (res) => {
              this.setData({
                userInfo: res.userInfo,
                hasUserInfo: true
              })
            }
          })
        }

1.10 [3]微信支付:商户平台

01.前期准备
    a.注册微信支付商户账号
        前往微信支付商户平台注册并完成实名认证,获取以下关键信息
        appid:公众号或小程序的AppID
        mch_id:微信支付商户号
        api_key:商户平台设置的API密钥(用于签名)
        notify_url:支付结果回调地址(需公网可访问)
    b.配置开发环境
        确保项目使用Java 8+和Spring Boot 2.x
        配置域名和HTTPS(微信支付要求回调地址必须为HTTPS)

02.代码实战
    a.依赖
        <!-- HTTP客户端 -->
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>
        <!-- XML处理 -->
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-xml</artifactId>
        </dependency>
        <!-- 其他Spring Boot基础依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    b.配置参数
        a.yml配置
            wxpay:
              appid: your_appid
              mch-id: your_mch_id
              api-key: your_api_key
              notify-url: https://your-domain.com/pay/notify
        b.类配置
            @Configuration
            @ConfigurationProperties(prefix = "wxpay")
            @Data
            public class WxPayConfig {
                private String appid;
                private String mchId;
                private String apiKey;
                private String notifyUrl;
            }
    c.实现工具类
        a.签名工具
            public class WxPayUtil {
                public static String generateSign(Map<String, String> data, String apiKey) {
                    // 按参数名ASCII字典序排序
                    List<String> keyList = new ArrayList<>(data.keySet());
                    Collections.sort(keyList);

                    StringBuilder sb = new StringBuilder();
                    for (String key : keyList) {
                        if (!key.equals("sign") && data.get(key) != null && !data.get(key).isEmpty()) {
                            sb.append(key).append("=").append(data.get(key)).append("&");
                        }
                    }
                    sb.append("key=").append(apiKey);
                    // MD5签名(或使用HMAC-SHA256)
                    return DigestUtils.md5Hex(sb.toString()).toUpperCase();
                }
            }
        b.HTTP请求工具
            public class WxPayHttpClient {
                public static String post(String url, String xmlData) throws IOException {
                    CloseableHttpClient client = HttpClients.createDefault();
                    HttpPost post = new HttpPost(url);
                    post.setEntity(new StringEntity(xmlData, "UTF-8"));
                    post.setHeader("Content-Type", "application/xml");

                    CloseableHttpResponse response = client.execute(post);
                    return EntityUtils.toString(response.getEntity(), "UTF-8");
                }
            }
    d.统一下单接口
        a.Controller入口
            @RestController
            @RequestMapping("/pay")
            public class WxPayController {
                @Autowired
                private WxPayConfig wxPayConfig;

                @PostMapping("/create")
                public String createOrder(@RequestBody OrderRequest orderRequest) throws Exception {
                    Map<String, String> data = new HashMap<>();
                    data.put("appid", wxPayConfig.getAppid());
                    data.put("mch_id", wxPayConfig.getMchId());
                    data.put("nonce_str", UUID.randomUUID().toString().replace("-", ""));
                    data.put("body", orderRequest.getBody());
                    data.put("out_trade_no", orderRequest.getOrderNo());
                    data.put("total_fee", String.valueOf(orderRequest.getTotalFee())); // 单位:分
                    data.put("spbill_create_ip", "123.12.12.123");
                    data.put("notify_url", wxPayConfig.getNotifyUrl());
                    data.put("trade_type", "NATIVE"); // JSAPI、APP等

                    // 生成签名
                    String sign = WxPayUtil.generateSign(data, wxPayConfig.getApiKey());
                    data.put("sign", sign);

                    // 转换为XML
                    String xmlData = mapToXml(data);

                    // 调用微信统一下单接口
                    String response = WxPayHttpClient.post("https://api.mch.weixin.qq.com/pay/unifiedorder", xmlData);

                    // 解析返回的XML,获取code_url或prepay_id
                    Map<String, String> respData = parseXml(response);
                    return respData.get("code_url");
                }
            }
    e.处理支付回调
        a.回调接口实现
            @PostMapping("/notify")
            public String payNotify(HttpServletRequest request) throws Exception {
                // 读取回调数据
                String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8");
                Map<String, String> notifyData = parseXml(xmlData);

                // 验证签名
                String sign = notifyData.get("sign");
                String localSign = WxPayUtil.generateSign(notifyData, wxPayConfig.getApiKey());
                if (!sign.equals(localSign)) {
                    return "<xml><return_code><![CDATA[FAIL]]></return_code></xml>";
                }

                // 处理业务逻辑(如更新订单状态)
                String orderNo = notifyData.get("out_trade_no");
                orderService.updateOrderPaid(orderNo);

                // 返回成功响应
                return "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
            }
    f.其他功能
        a.订单查询
            调用https://api.mch.weixin.qq.com/pay/orderquery接口,传递订单号查询状态
        b.退款
            调用https://api.mch.weixin.qq.com/secapi/pay/refund接口,需使用商户证书(.p12文件)
        c.注意事项
            金额单位:微信支付以分为单位(如100表示1元)
            超时处理:设置合理的超时时间和重试机制
            幂等性:处理回调时确保重复通知不会导致重复业务操作
            日志记录:记录关键步骤日志,便于排查问题
            沙箱测试:使用微信支付沙箱环境进行测试

1.11 [4]实现原理:长轮询

01.定义
    长轮询是一种客户端与服务器之间的通信模式,客户端发送请求后,服务器保持连接直到有数据返回或超时
    微信扫码登录可以利用长轮询实现实时的登录状态更新

02.原理
    a.长轮询机制
        客户端发送请求后,服务器保持连接,直到有新的数据或状态变化时返回结果
        客户端在收到结果后立即发送新的请求,形成连续的轮询
    b.微信扫码登录流程
        用户在微信端扫码后,服务器更新登录状态,客户端通过长轮询获取最新的登录状态

03.常用API
    HTTP请求:使用标准的HTTP请求进行长轮询
    服务器端处理:服务器端保持连接,监听状态变化
    客户端处理:客户端定期发送请求,获取最新状态

04.使用步骤
    初始化扫码登录:生成二维码,用户扫码后获取临时登录凭证
    客户端发送长轮询请求:客户端发送请求到服务器,等待登录状态更新
    服务器处理请求:服务器保持连接,监听扫码登录状态变化
    返回结果:当登录状态更新时,服务器返回结果,客户端处理登录成功或失败
    重复请求:客户端收到结果后,立即发送新的请求,继续轮询

05.场景代码示例
    a.场景1:初始化扫码登录
        // 生成二维码
        public String generateQRCode() {
            String loginToken = UUID.randomUUID().toString();
            // 将loginToken与用户信息关联,生成二维码
            return "https://example.com/login?token=" + loginToken;
        }
    b.场景2:客户端发送长轮询请求
        // 客户端长轮询请求
        function startLongPolling(loginToken) {
            function poll() {
                fetch(`/checkLoginStatus?token=${loginToken}`)
                    .then(response => response.json())
                    .then(data => {
                        if (data.status === 'logged_in') {
                            // 登录成功处理
                            console.log('Login successful');
                        } else {
                            // 继续轮询
                            setTimeout(poll, 3000); // 每3秒发送一次请求
                        }
                    })
                    .catch(error => console.error('Error:', error));
            }
            poll();
        }
    c.场景3:服务器处理请求
        // 服务器端处理长轮询请求
        @GetMapping("/checkLoginStatus")
        public ResponseEntity<Map<String, String>> checkLoginStatus(@RequestParam String token) {
            // 检查登录状态
            String status = loginService.getLoginStatus(token);
            Map<String, String> response = new HashMap<>();
            response.put("status", status);
            return ResponseEntity.ok(response);
        }

2 功能点

2.1 [0]项目说明

01.使用平台
    用户端:面向普通用户的,App、小程序、网页端
    用户自媒体平台:面向自媒体机构/自媒体人的,App、网页端
    管理平台:面向平台管理员、审核员的,网页端

02.业务模块
    用户移动端:内容推荐模块、内容搜索、频道管理、内容展示、内容社交、登录注册、个人首页、实名认证、个人中心、系统设置
    用户自媒体平台:内容发布、内容管理、粉丝管理、评论管理、权限管理、个人看板、粉丝画像、私信管理、素材管理、系统设置
    管理平台:用户管理、内容管理、数据统计、标签管理、公告管理、系统管理
    支撑系统:爬虫系统、广告系统、推荐系统、计算系统、知识系统

03.拆分的微服务及其模块
    用户个人中心服务:用户登录注册、个人信息查询修改、点赞、浏览历史查看等
    文章服务:文章发布、文章收藏、文章信息、热点文章计算、实名认证
    自媒体服务:自媒体文章CRUD、文章审核、素材管理、自媒体数据看板(各项数据&粉丝画像等等)、实名认证
    用户行为服务:点赞、阅读、不喜欢流程
    用户检索服务:文章内容检索
    评论服务:评论发布、评论回复、评论点赞
    平台管理服务
    任务调度服务
    图片管理服务

04.技术栈
    SpringCloud Alibaba系列微服务组件:Nacos、Sentinel、OpenFeign、Ribbon、Gateway、SkyWalking、SpringCloud Admin;
    业务服务:SpringBoot、MybatisPlus、XXL-JOB
    数据层:MySQL、Redis、Elasticsearch、MongoDB
    中间件:Kafka、MinIO、ELK日志框架
    其他工具:Jenkins、Docker、GitLab
    开发环境:JDK8、MySQL8、Maven3、Redis5
    前端:Vue、Echarts、uniapp、ELementUI

2.2 [1]文章查询:推荐查询、静态化

01.你是如何设计文章存储的数据库结构的?
    我设计了三张表来存储文章信息,这三张表之间是一对一的映射关系
    文章基本信息表(ap_article)、文章配置表(ap_article_config)和文章内容表(ap_article_content)
    最初设计是将所有文章相关字段放在一张表中,但为了避免单表在数据量增多时的压力,进行了垂直分割,按照查询的业务维度进行拆分

02.首页推荐查询是如何实现的?
    首页推荐查询主要依赖于用户在个人中心配置的感兴趣标签、大数据系统分析的用户画像以及系统本身的推荐
    (如管理员设置的推荐、广告推荐、随机热点资讯)
    每次用户在App中下拉刷新时,后端会调用大数据推荐接口,获取推荐的30条文章信息ID,并返回给前端
    前端以每页10条的方式分页展示。当用户上拉到第三页时,前端会再次请求后端获取后30条推荐文章

03.频道栏目查询的逻辑是什么?
    当用户切换频道栏目时,查询不再依赖推荐算法,而是按照发布时间进行查询
    用户下拉刷新时,会查询上次刷新后的最新资讯;如果没有更新,则返回最新列表
    上拉时,按照时间进行反向分页查找,先返回最近发布的10条数据,浏览到底部时自动查询更早的10条数据

04.你在实现过程中有哪些技术亮点?
    文章内容静态化:使用Freemarker将文章内容静态化,生成静态HTML资源,减少每次请求时的DB查询和模版渲染,提升响应速度
    分布式文件系统:将静态资源上传到公司内部的MinIO分布式文件系统中,用户点击文章标题时直接从MinIO获取页面数据,无需查询DB

2.3 [1]文章评论:mongodb

01.你是如何设计文章评论系统的?
    文章评论系统是通过单独的评论微服务实现的,所有评论数据,包括评论回复和点赞,都是存储在MongoDB中
    选择MongoDB的原因是评论系统需要处理高并发,并且数据是非结构化的
    MongoDB适合这种基于内存的实现方式,并且支持复杂的CRUD操作

02.为什么选择MongoDB而不是关系型数据库?
    选择MongoDB是因为它能够更好地处理高并发和非结构化数据
    传统关系型数据库在处理评论和回复时需要频繁的JOIN操作,这在高并发场景下会消耗大量性能
    而MongoDB支持灵活的数据结构和高效的查询方式,适合我们的需求

03.文章评论系统的数据库结构是怎样的?
    在MongoDB中,我们创建了四张表:
    ap_comment:评论表
    ap_comment_like:评论点赞表
    ap_comment_repay:评论回复表
    ap_comment_repay_like:评论回复点赞表

04.如何实现评论的查询和展示?
    查询文章评论时,我们进行分页查询,默认查询前10条评论
    使用MongoDB的管道聚合查询,将评论的点赞和回复信息一并查询出来,返回给前端展示
    用户的评论、回复、点赞操作都会更新MongoDB中的数据

05.评论系统中有哪些业务逻辑需要注意?
    内容审核:通过项目内部的敏感词过滤、阿里云第三方内容审核服务以及人工审核进行管理
    权限校验:作者和用户在进行评论、删除等操作时,需要先校验身份和权限
              例如,作者删除评论时,需要验证文章是否由其上传;用户删除评论时,需要验证用户状态和阅读记录

06.如何处理用户和作者的评论操作?
    用户和作者的评论操作需要经过一系列校验
    用户发布或删除评论时,先请求用户行为服务,验证用户状态和文章阅读记录
    作者发布或删除评论时,先请求自媒体微服务,验证作者身份和文章归属,然后再调用评论微服务进行操作

2.4 [2]文章发布:xxl-job定时发布

01.文章发布的整体流程是怎样的?
    a.文章发布
        自媒体人在自媒体平台端发布文章,这是发生在自媒体微服务中的
        当文章审核通过后,自媒体微服务远程调用文章微服务,将审核通过后的文章发送到文章微服务中进行文章的正式发布
    b.文章审核
        当页面点击文章发布后,紧接着进行审核,审核可能通过,也可能不通过
        我们使用MQ解耦当前请求与审核流程的耦合,这也是发生在自媒体微服务中的

02.文章发布的具体实现步骤是什么?
    a.文章编辑
        在媒体平台系统中,前端使用富文本编辑器,支持自动保存功能
        每隔10秒,自动保存编辑内容到自媒体文章表中。文章信息包括标题、频道、标签、内容、封面图片等
    b.图片素材管理
        图片素材单独管理,存储在MinIO中。文章与素材通过中间表维护多对多关系,便于素材复用和管理
    c.提交审核
        编辑完成后,提交审核。审核通过后,根据选项决定立即发布或定时发布
        使用定时任务调度技术,每分钟扫描审核通过的文章表

03.为什么选择使用MinIO进行图片素材管理?
    MinIO提供了高效的对象存储解决方案,适合管理大量图片素材
    通过将素材与文章分离管理,可以实现素材的复用和便捷的人工检查
    素材管理模块允许自媒体人提前上传和整理素材,提升工作效率

04.文章审核流程中使用了哪些技术?
    审核流程涉及微服务调用、阿里云内容服务调用以及Kafka服务解耦
    通过这些技术,我们实现了高效的审核流程管理,确保文章内容符合规范

05.如何处理草稿箱文章与素材的关联关系?
    草稿箱文章与素材也应该在中间表中维护关联关系,以防止素材被随意删除导致草稿箱内容丢失
    这样,即便是草稿箱文章引用的素材,也不能随意删除,确保编辑体验的完整性

06.关于封面设置,有哪些改进?
    我们不再支持自动设置文章内部图片作为封面
    封面需要单独设置,通过上传指定尺寸的封面图片进行设置
    这一改进确保了封面的专业性和一致性,符合专业媒体人的需求

2.5 [2]文章审核:rabbitmq审核队列

01.文章审核流程是如何设计的?
    文章审核流程涉及服务之间的调用和第三方接口的调用
    自媒体人在确认文章编辑无误后提交审核,触发审核流程
    无论是新增文章还是修改文章内容,都会执行审核流程
    审核流程包括敏感词过滤、OCR识别、内容审核、图片审核等多个环节

02.如何处理文章审核的异步流程?
    当文章提交审核后,状态码设置为待审核
    文章基本信息以JSON格式发送到MQ中的审核队列,由消费者进行异步审核
    审核流程包括OCR识别、敏感词过滤、阿里云内容审核等
    异步处理避免了请求等待整个审核流程结束,提高了响应效率

03.文章审核过程中使用了哪些技术?
    OCR识别:使用Tess4J实现图片文字识别,防止敏感词汇以图片形式出现
    敏感词过滤:利用DFA算法进行词项匹配,判断文章内容是否包含敏感词
    内容审核:使用阿里云内容审核服务进行文本和图片审核
    定时任务调度:使用XXL-JOB实现文章的定时发布

04.如何处理人工审核流程?
    如果审核结果不确定或失败,需要人工审核
    人工审核由平台运营人员进行,查询状态码为待人工审核的文章进行审核
    我们使用XXL-JOB配置定时任务,每隔10分钟扫描等待人工审核超过一天的文章,将状态码改为审核失败

05.如何判断文章是新增还是修改?
    自媒体服务调用文章服务发布文章时,参数中的文章数据如果有ID,则认为是修改;如果没有ID,则认为是新增

06.微服务调用过程中有哪些注意事项?
    微服务调用过程中需要注意限流、异常、超时。限流配置防止服务过载导致异常和超时
    异常和超时处理通过Sentinel整合,配置了降级类,在OpenFeign的FeignClient注解中配置fallback降级子类,作为兜底策略

07.ID生成策略是如何选择的?
    在文章服务保存文章数据时,我们使用雪花算法生成ID
    对于数据量较少的表,使用自增主键实现
    雪花算法确保ID的唯一性和分布式环境下的高效生成

2.6 [2]文章上下架:kafka发送文章ID

01.文章上下架功能是如何实现的?
    文章发布且审核成功后,默认自动上架
    自媒体人可以在自媒体端修改文章的上下架状态
    由于上下架操作频繁且不要求强一致性,我们使用消息队列(MQ)进行解耦
    具体操作是向Kafka中发送文章ID及上下架状态码的消息

02.为什么选择使用消息队列进行解耦?
    使用消息队列可以有效解耦服务之间的直接调用,降低系统耦合度,提高系统的可扩展性和可靠性
    对于频繁的上下架操作,MQ可以缓解数据库的直接压力,并确保消息的可靠传递

03.文章上下架流程的具体步骤是什么?
    自媒体人选择上架或下架文章时,首先检查文章是否存在及是否已发布
    如果状态正常,向Kafka发送一条包含文章ID和上下架状态码的消息
    文章微服务配置消费者监听队列消息,手动确认消息
    当本地数据库成功更新文章状态后,确认消息队列中的消息

04.如何确保消息的可靠性?
    在文章微服务端,我们将Kafka的自动确认改为手动确认
    只有在本地数据库成功更新文章状态后,才确认消息队列中的消息
    这种方式确保了消息的可靠处理,避免因处理失败导致消息丢失

05.如果换成RabbitMQ,流程会有什么变化吗?
    换成RabbitMQ,整体流程和逻辑保持不变
    主要区别在于消息中间件的配置和使用细节上
    RabbitMQ也支持手动确认机制,可以确保消息的可靠处理
    选择哪个消息中间件主要取决于团队的熟悉程度和项目需求

2.7 [3]文章搜索:es+ik分词器

01.文章搜索功能是如何实现的?
    我们在项目中设计了单独的检索微服务,使用Elasticsearch进行文章搜索
    首先创建文章索引,设置内容和标题为可检索字段,并引入IK分词器支持中文分词
    检索微服务提供搜索相关服务,处理客户端请求并返回搜索结果

02.检索微服务的具体流程是什么?
    客户端请求先到达网关,检索文章的接口直接放行,不校验用户登录状态
    请求转发到检索服务,根据前端提交的参数拼接查询条件,包括关键字、时间段、分页等
    支持按照发布时间排序、高亮显示关键字
    从Elasticsearch返回的数据中解析命中数据,封装为预设好的VO对象,返回给前端展示

03.如何处理Elasticsearch的数据同步问题?
    在文章服务中,触发文章发布、修改、上下架等操作时,会更新Elasticsearch中的数据
    由于没有强一致性需求且可能有并发情况,我们使用Kafka进行解耦
    文章微服务作为消息生产者发送更新事件到Kafka,搜索微服务作为消费者订阅消息并更新Elasticsearch索引库

04.如何更新Elasticsearch中的数据?
    更新时使用文章ID作为Elasticsearch中的文档ID。根据文章ID进行更新操作,确保数据的一致性和准确性

05.你是如何整合Elasticsearch的?
    我使用Elasticsearch官方提供的依赖进行整合。声明RestHighLevelClient客户端对象,在使用位置进行注入
    通过Java API方法进行条件封装,最终转为HTTP查询。查询结果返回后,封装为SearchResponse对象进行处理

06.为什么选择使用IK分词器?
    IK分词器支持中文分词,能够提高中文搜索的准确性和效率。通过自定义Mapping规则,确保内容和标题字段能够被有效检索

2.8 [3]文章搜索记录:mongodb

00.汇总
    a.输入关键词
        用户在搜索框中输入关键词
    b.搜索
        系统执行搜索操作,并返回搜索结果给用户
    c.记录关键词(异步请求)
        搜索操作的同时,异步记录用户输入的关键词
    d.查询搜索记录
        检查MongoDB中是否已有该用户的搜索记录
    e.判断是否存在
        如果搜索记录存在,则更新到最新时间
        如果搜索记录不存在,则继续下一步
    f.总数据量是否超过10
        检查该用户的搜索记录总量是否超过10条
    g.替换最后一条数据
        如果搜索记录总量超过10条,则删除最早的搜索记录,并保存新的关键词
    h.保存关键词
        将新的关键词添加到用户的搜索记录中

01.文章搜索记录功能是如何设计的?
    文章搜索记录功能用于采集并保存用户的搜索行为数据,方便用户重复利用历史搜索关键字
    我们展示用户的10条搜索记录,按照搜索时间倒序排序,并支持删除搜索记录
    默认只保存每个用户的10条历史记录,超出的记录会优先删除最久的

02.为什么选择使用MongoDB来存储搜索记录?
    我们选择MongoDB是因为它支持高并发读写需求,热数据基于内存存储,支持动态扩展和条件查询
    相比传统关系型数据库和其他NoSQL选项,MongoDB更适合处理这种需要快速加载和查询的场景

03.搜索记录保存的具体流程是什么?
    判断用户是否登录,依据请求头中的用户ID。如果未登录,不执行保存流程
    如果已登录,查询MongoDB中是否存在该用户的搜索记录
    如果存在,更新记录时间为最新时间
    如果不存在,查询用户的历史搜索记录并排序
    判断记录条数,小于10则新增记录;等于10则替换最早的记录
    搜索记录保存流程设置为异步,通过@Async注解实现,避免请求同步等待

04.如何处理搜索记录的异步保存?
    搜索记录保存操作设置为异步,使用Spring的@Async注解实现。这种方式避免了请求同步等待,提高了系统响应效率

05.如何确保搜索记录的有效性和及时更新?
    通过MongoDB的条件查询和更新机制,确保搜索记录的有效性
    每次用户搜索时,更新记录时间为最新,保持记录的及时性和准确性

2.9 [3]文章热度计算:kafka、xxl-job、redis

01.数据收集与Kafka Stream处理
    a.用户行为数据收集
        当用户进行阅读、评论、点赞、收藏等操作时,相关数据会被发送到Kafka的特定主题中
    b.Kafka Stream实时处理
        消息格式重置:将文章ID作为key,行为类型和分值作为value
        分组与聚合:按照文章ID分组,聚合同一篇文章的行为数据,计算实时热度分值
        输出结果:将聚合后的结果输出到另一个Kafka主题,供后续处理

02.xxl-job定时计算
    a.定时任务设置
        每天凌晨2点,XXL-JOB执行定时任务,统计前5天发布的文章热度
    b.计算逻辑
        查询最近5天发布的文章
        根据用户行为权重计算文章热度分值
        将计算结果缓存到Redis,作为当天的基础热度数据

03.Redis缓存与更新
    a.初始缓存
        定时计算结果存储在Redis中,提供基础数据
    b.实时更新
        Kafka Stream处理的实时计算结果用于更新Redis中的热度分值
    c.动态调整
        实时计算结果与定时计算结果结合,动态调整文章热度,确保推荐的准确性

2.10 [4]联想词功能:词库+搜索历史记录

01.联想词功能是如何设计的?
    联想词功能在用户搜索时,根据输入的关键字智能匹配以前搜索过的相关记录
    实现方式是将联想词词库存储在MongoDB中,前端触发输入事件后,携带关键字请求后端
    后端从MongoDB中查询符合关键字的10条联想词并返回

02.联想词的数据来源有哪些?
    公司以前维护的词库数据,这些数据被导入到MongoDB的联想词表(ap_associate_words)
    用户的文章搜索历史记录表中的数据

03.联想词的查询流程是怎样的?
    优先查询文章搜索历史记录表,获取与当前关键字匹配的历史搜索记录
    如果历史搜索记录大于等于10条,直接返回这10条记录
    如果小于10条,则从联想词表中查询补充,直到凑够10条联想词数据,然后返回给前端

04.为什么选择使用MongoDB来存储联想词?
    MongoDB支持高效的条件查询和动态扩展,适合存储和查询联想词这种需要快速响应的场景
    相比传统关系型数据库,MongoDB在处理非结构化数据和高并发查询时表现更优

2.11 [4]点赞喜欢阅读:redis的zset结构,时间戳作为score

01.点赞、喜欢、阅读功能是如何设计的?
    点赞、喜欢、阅读等用户行为数据存储在用户行为微服务中
    我们使用Redis缓存这些数据,并定期将其批量更新到MySQL中
    用户行为数据通过基本的CRUD操作记录在中间表中,标记用户对某篇文章的具体行为

02.为什么选择使用Redis而不是直接使用MySQL?
    Redis适合处理高并发场景,能够快速响应用户操作
    我们使用Redis缓存用户行为数据,减少对MySQL的频繁读写压力
    每隔10秒,通过XXL-JOB定时任务将Redis中的数据批量更新到MySQL中,确保数据的持久化

03.点赞、喜欢、阅读数据的处理流程是什么?
    用户行为触发后,数据首先更新到Redis中,使用Zset类型存储,时间戳作为score
    前端立即响应用户操作,标记成功
    每隔10秒,XXL-JOB定时任务异步处理Redis中的数据
    更新到MySQL的数据表中
    将用户行为数据发送到Kafka中,供其他服务使用
    更新Redis缓存,记录用户对文章的行为数据,设置TTL过期时间为30天

04.如何处理用户行为数据的缓存和更新?
    用户行为数据存储在Redis中,使用hash类型记录每个用户对文章的行为
    每次查询后,重置TTL过期时间
    用户的点赞列表缓存使用Redis list类型实现,确保用户查询时能够快速返回结果

05.为什么选择Redis而不是Kafka来处理这些数据?
    Redis适合处理同一服务内部的更新和数据非强一致性的场景
    对于点赞、阅读、不喜欢等行为,处理流程不需要跨服务通信,且丢失少量数据无关紧要
    因此选择Redis+XXL-JOB定时任务实现,而Kafka更适合异步解耦复杂业务流程和跨服务通信

06.应用场景
    a.场景1:添加用户行为数据
        // 初始化Redis连接
        Jedis jedis = new Jedis("localhost");
        // 添加用户行为数据,时间戳作为分数
        long timestamp = System.currentTimeMillis();
        jedis.zadd("user:actions", timestamp, "like:article:123");
        jedis.zadd("user:actions", timestamp, "read:article:456");
    b.场景2:查询最近的用户行为
        // 查询最近的用户行为,按时间戳逆序
        Set<String> recentActions = jedis.zrevrange("user:actions", 0, 9);
        for (String action : recentActions) {
            System.out.println(action);
        }
    c.场景3:更新用户行为分数
        // 增加用户行为的分数(模拟行为频率)
        jedis.zincrby("user:actions", 1, "like:article:123");
    d.场景4:删除过期的用户行为
        // 删除指定的用户行为
        jedis.zrem("user:actions", "read:article:456");

2.12 [5]订单:状态机

01.硬编码问题
    a.描述
        在业务代码中对订单状态进行硬编码,导致系统扩展和维护困难。
    b.示例代码
        public void paySuccess(TradeStatusMsg tradeStatusMsg) {
            ...
            if (ObjectUtil.equal(0, orders.getOrdersStatus())) {
                ...
            }
        }
        // 订单取消示例
        update(id,已关闭)
        if(订单状态==服务中){
            update(id,已关闭)
        }
    c.问题
        业务逻辑更改需要修改代码。
        订单状态管理分散,不便于统一管理和维护。

02.使用状态机解决问题
    a.什么是状态机?
        a.定义
            状态机是一种数学模型,用于对状态进行统一管理。
        b.应用
            状态机设计模式在软件中描述了对象在内部状态变化时如何改变其行为。
        c.四要素
            现态:当前所处的状态。
            事件:触发状态变更的条件。
            动作:事件发生时执行的操作。
            次态:条件满足后迁往的新状态。
    b.使用状态机优化代码
        a.支付成功代码优化
            if(支付状态==支付成功){
                orderStateMachine.changeStatus(id,支付成功事件);
            }
        b.订单取消代码优化
            orderStateMachine.changeStatus(id,订单完成时取消订单事件);

03.实现订单状态机
    a.订单状态枚举类
        a.定义
            @Getter
            @AllArgsConstructor
            public enum OrderStatusEnum implements StatusDefine {
                NO_PAY(0, "待支付", "NO_PAY"),
                DISPATCHING(100, "派单中", "DISPATCHING"),
                NO_SERVE(200, "待服务", "NO_SERVE"),
                SERVING(300, "服务中", "SERVING"),
                FINISHED(500, "已完成", "FINISHED"),
                CANCELED(600, "已取消", "CANCELED"),
                CLOSED(700, "已关闭", "CLOSED");
                private final Integer status;
                private final String desc;
                private final String code;
                public static OrderStatusEnum codeOf(Integer status) {
                    for (OrderStatusEnum orderStatusEnum : values()) {
                        if (orderStatusEnum.status.equals(status)) {
                            return orderStatusEnum;
                        }
                    }
                    return null;
                }
            }
        b.接口
            public interface StatusDefine {
                Integer getStatus();
                String getDesc();
                String getCode();
            }
    b.状态变更事件枚举类
        a.定义
            @Getter
            @AllArgsConstructor
            public enum OrderStatusChangeEventEnum implements StatusChangeEvent {
                PAYED(OrderStatusEnum.NO_PAY, OrderStatusEnum.DISPATCHING, "支付成功", "payed"),
                DISPATCH(OrderStatusEnum.DISPATCHING, OrderStatusEnum.NO_SERVE, "接单/抢单成功", "dispatch"),
                START_SERVE(OrderStatusEnum.NO_SERVE, OrderStatusEnum.SERVING, "开始服务", "start_serve"),
                COMPLETE_SERVE(OrderStatusEnum.SERVING, OrderStatusEnum.FINISHED, "完成服务", "complete_serve"),
                CANCEL(OrderStatusEnum.NO_PAY, OrderStatusEnum.CANCELED, "取消订单", "cancel"),
                SERVE_PROVIDER_CANCEL(OrderStatusEnum.NO_SERVE, OrderStatusEnum.DISPATCHING, "服务人员/机构取消订单", "serve_provider_cancel"),
                CLOSE_DISPATCHING_ORDER(OrderStatusEnum.DISPATCHING, OrderStatusEnum.CLOSED, "派单中订单关闭", "close_dispatching_order"),
                CLOSE_NO_SERVE_ORDER(OrderStatusEnum.NO_SERVE, OrderStatusEnum.CLOSED, "待服务订单关闭", "close_no_serve_order"),
                CLOSE_SERVING_ORDER(OrderStatusEnum.SERVING, OrderStatusEnum.CLOSED, "服务中订单关闭", "close_serving_order"),
                CLOSE_FINISHED_ORDER(OrderStatusEnum.FINISHED, OrderStatusEnum.CLOSED, "已完成订单关闭", "close_finished_order");
                private final OrderStatusEnum sourceStatus;
                private final OrderStatusEnum targetStatus;
                private final String desc;
                private final String code;
            }
    c.定义订单快照类
        a.订单快照类
            @Data
            @Builder
            @NoArgsConstructor
            @AllArgsConstructor
            public class OrderSnapshotDTO extends StateMachineSnapshot {
                @Override
                public String getSnapshotId() {
                    return String.valueOf(id);
                }
                @Override
                public Integer getSnapshotStatus() {
                    return ordersStatus;
                }
                @Override
                public void setSnapshotId(String snapshotId) {
                    this.id = Long.parseLong(snapshotId);
                }
                @Override
                public void setSnapshotStatus(Integer snapshotStatus) {
                    this.ordersStatus = snapshotStatus;
                }
            }
        b.快照基础类型
            public abstract class StateMachineSnapshot {
                public abstract String getSnapshotId();
                public abstract Integer getSnapshotStatus();
                public abstract void setSnapshotId(String snapshotId);
                public abstract void setSnapshotStatus(Integer snapshotStatus);
            }
    d.定义事件变更动作类
        a.订单支付成功动作类
            @Slf4j
            @Component("order_payed")
            public class OrderPayedHandler implements StatusChangeHandler<OrderSnapshotDTO> {
                @Resource
                private IOrdersCommonService ordersService;
                @Override
                public void handler(String bizId, StatusChangeEvent statusChangeEventEnum, OrderSnapshotDTO bizSnapshot) {
                    log.info("支付成功事件处理逻辑开始,订单号:{}", bizId);
                }
            }
    e.定义订单状态机类
        a.订单状态机类
            @Component
            public class OrderStateMachine extends AbstractStateMachine<OrderSnapshotDTO> {
                public OrderStateMachine(StateMachinePersister stateMachinePersister, BizSnapshotService bizSnapshotService, RedisTemplate redisTemplate) {
                    super(stateMachinePersister, bizSnapshotService, redisTemplate);
                }
                @Override
                protected String getName() {
                    return "order";
                }
                @Override
                protected void postProcessor(OrderSnapshotDTO orderSnapshotDTO) {
                }
                @Override
                protected OrderStatusEnum getInitState() {
                    return OrderStatusEnum.NO_PAY;
                }
            }

2.13 [5]优惠券:添加、下单

01.添加优惠券
    a.目标
        商家在主页上添加一个优惠券的抢购链接,可以点击抢购按钮抢购优惠券
    b.普通优惠券
        a.定义
            日常可获取的资源
        b.代码实现
            @PostMapping
            public Result addVoucher(@RequestBody Voucher voucher) {
                voucherService.save(voucher);
                return Result.ok(voucher.getId());
            }
    c.限量优惠券
        a.定义
            限制数量,需要设置时间限制、面对高并发请求的资源
        b.下单流程
            查询优惠券:通过 voucherId 查询优惠券
            时间判断:判断是否在抢购优惠券的固定时间范围内
            库存判断:判断优惠券库存是否 ≥ 1
            扣减库存
            创建订单:创建订单 VoucherOrder 对象,指定订单号,指定全局唯一 voucherId,指定用户 id
            保存订单:保存订单到数据库
            返回结果:Result.ok(orderId)
        c.代码实现
            a.VoucherController
                @PostMapping("seckill")
                public Result addSeckillVoucher( @RequestBody Voucher voucher ){
                    voucherService.addSeckillVoucher(voucher);
                    return Result.o(voucher.getId());
                }
            b.VoucherServiceImpl
                @Override
                @Transactional
                public void addSeckillVoucher(Voucher voucher) {
                    // 保存优惠券到数据库
                    save(voucher);
                    // 保存优惠券信息
                    SeckillVoucher seckillVoucher = new SeckillVoucher();
                    seckillVoucher.setVoucherId(voucher.getId());
                    seckillVoucher.setStock(voucher.getStock());
                    seckillVoucher.setBeginTime(voucher.getBeginTime());
                    seckillVoucher.setEndTime(voucher.getEndTime());
                    seckillVoucherService.save(seckillVoucher);
                    // 保存优惠券到Redis中
                    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
                }

02.优惠券下单
    a.目标
        用户抢购代金券,保证用户成功获得优惠券,保证效率并且避免超卖
    b.工作流程
        提交优惠券 ID
        查询优惠券信息 (下单时间是否合法,下单时库存是否充足)
        扣减库存,创建订单
        返回订单 ID
    c.代码实现:在分布式环境下仍然存在超卖问题
        public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{

            @Resource
            private ISeckillVoucherService seckillVoucherService;

            @Override
            public Result seckillVoucher(Long voucherId) {

                // 查询优惠券
                SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

                // 优惠券抢购时间判断
                if(voucher.getBeginTime().isAfter(LocalDateTime.now) || voucher.getEndTime().isBefore(LocalDateTime.now()){
                    return Result.fail("当前不在抢购时间!");
                }

                // 库存判断
                if(voucher.getStock() < 1){
                    return Result.fail("库存不足!");
                }

                // !!! 实现一人一单功能 !!!
                Long userId = UserHolder.getUser().getId();
                synchronized (userId.toString().intern()) {
                    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                    return proxy.createVoucherOrder(voucherId);
                }
            }

            @Transactional
            public Result createVoucherOrder(Long userId) {
                Long userId = UserHolder.getUser().getId();

                // 查询当前用户是否已经购买过优惠券
                int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
                if( count > 0 ) {
                    return Result.fail("当前用户不可重复购买!");

                // !!! 实现乐观锁 !!!
                // 扣减库存
                boolean success = seckillVoucherService.update()
                                                                .setSql("stock = stock - 1")                       // set stock = stock - 1;
                                                                .eq("voucher_id", voucherId).gt("stock", 0)        // where voucher_id = voucherId and stock > 0;
                                                                .update();
                if(!success) {
                    return Result.fail("库存不足!");
                }

                // 创建订单
                VoucherOrder voucherOrder = new VoucherOrder();
                voucherOrder.setId(redisIdWorker.nextId("order"));
                voucherOrder.setUserId(UserHolder.getUser().getId());
                voucherOrder.setVoucherId(voucherId);
                save(voucherOrder);

                // 返回订单id
                return Result.ok(orderId);
        }

2.14 [5]订单超时:7种

00.汇总
    1.使用延时队列,如DelayQueue
    2.基于数据库轮询
    3.基于Redis队列
    4.RedisKey过期回调
    5.基于消息队列,如RabbitMQ
    6.使用定时任务框架
    7.基于触发式事件流处理
    项目规模较小,延时队列+Redis
    大型高并发系统:消息队列+事件流处理

01.使用延时队列(DelayQueue)
    a.适用场景
        订单数量较少,系统并发量不高
    b.原理
        延时队列是Java并发包(java.util.concurrent)中的一个数据结构,专门用于处理延时任务
        订单在创建时,将其放入延时队列,并设置超时时间
        延时时间到了以后,队列会触发消费逻辑,执行取消操作
    c.示例代码
        import java.util.concurrent.*;

        public class OrderCancelService {
            private static final DelayQueue<OrderTask> delayQueue = new DelayQueue<>();

            public static void main(String[] args) throws InterruptedException {
                // 启动消费者线程
                new Thread(() -> {
                    while (true) {
                        try {
                            OrderTask task = delayQueue.take(); // 获取到期任务
                            System.out.println("取消订单:" + task.getOrderId());
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    }
                }).start();

                // 模拟订单创建
                for (int i = 1; i <= 5; i++) {
                    delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
                    System.out.println("订单" + i + "已创建");
                }
            }

            static class OrderTask implements Delayed {
                private final long expireTime;
                private final int orderId;

                public OrderTask(int orderId, long expireTime) {
                    this.orderId = orderId;
                    this.expireTime = expireTime;
                }

                public int getOrderId() {
                    return orderId;
                }

                @Override
                public long getDelay(TimeUnit unit) {
                    return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
                }

                @Override
                public int compareTo(Delayed o) {
                    return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
                }
            }
        }
    d.优点
        实现简单,逻辑清晰
    e.缺点
        依赖内存,系统重启会丢失任务
        随着订单量增加,内存占用会显著上升

02.基于数据库轮询
    a.适用场景
        订单数量较多,但系统对实时性要求不高
    b.原理
        轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”
    c.示例代码
        public void cancelExpiredOrders() {
            String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
            try (Connection conn = dataSource.getConnection();
                 PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
                int affectedRows = ps.executeUpdate();
                System.out.println("取消订单数量:" + affectedRows);
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    d.优点
        数据可靠性强,不依赖内存
        实现成本低,无需引入第三方组件
    e.缺点
        频繁扫描数据库,会带来较大的性能开销
        实时性较差(通常定时任务间隔为分钟级别)
    f.优化建议
        为相关字段加索引,避免全表扫描
        结合分表分库策略,减少单表压力

03.基于Redis队列
    a.适用场景
        适合对实时性有要求的中小型项目
    b.原理
        Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列
        我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消
    c.示例代码
        public void addOrderToQueue(String orderId, long expireTime) {
            jedis.zadd("order_delay_queue", expireTime, orderId);
        }

        public void processExpiredOrders() {
            long now = System.currentTimeMillis();
            Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
            for (String orderId : expiredOrders) {
                System.out.println("取消订单:" + orderId);
                jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
            }
        }
    d.优点
        实时性高
        Redis 的性能优秀,延迟小
    e.缺点
        Redis 容量有限,适合中小规模任务
        需要额外处理 Redis 宕机或数据丢失的问题

04.Redis Key 过期回调
    a.适用场景
        对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度
    b.原理
        Redis 提供了 Key 的过期功能,结合 keyevent 事件通知机制,可以实现订单的自动取消逻辑
        当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理
    c.示例代码
        a.设置订单的过期时间
            public void setOrderWithExpiration(String orderId, long expireSeconds) {
                jedis.setex("order:" + orderId, expireSeconds, "PENDING");
            }
        b.订阅 Redis 的过期事件
            public void subscribeToExpirationEvents() {
                Jedis jedis = new Jedis("localhost");
                jedis.psubscribe(new JedisPubSub() {
                    @Override
                    public void onPMessage(String pattern, String channel, String message) {
                        if (channel.equals("__keyevent@0__:expired")) {
                            System.out.println("接收到过期事件,取消订单:" + message);
                            // 执行取消订单的业务逻辑
                        }
                    }
                }, "__keyevent@0__:expired"); // 订阅过期事件
            }
    d.优点
        实现简单,直接利用 Redis 的过期机制
        实时性高,过期事件触发后立即响应
    e.缺点
        依赖 Redis 的事件通知功能,需要开启 notify-keyspace-events 配置
        如果 Redis 中大量使用过期 Key,可能导致性能问题
    f.注意事项
        要使用 Key 过期事件,需要确保 Redis 配置文件中 notify-keyspace-events 的值包含 Ex。比如:
        notify-keyspace-events Ex

05.基于消息队列(如RabbitMQ)
    a.适用场景
        高并发系统,实时性要求高
    b.原理
        订单创建时,将订单消息发送到延迟队列(如RabbitMQ 的 x-delayed-message 插件)
        延迟时间到了以后,消息会重新投递到消费者,消费者执行取消操作
    c.示例代码(以RabbitMQ为例)
        public void sendOrderToDelayQueue(String orderId, long delay) {
            Map<String, Object> args = new HashMap<>();
            args.put("x-delayed-type", "direct");
            ConnectionFactory factory = new ConnectionFactory();
            try (Connection connection = factory.newConnection();
                 Channel channel = connection.createChannel()) {
                channel.exchangeDeclare("delayed_exchange", "x-delayed-message", true, false, args);
                channel.queueDeclare("delay_queue", true, false, false, null);
                channel.queueBind("delay_queue", "delayed_exchange", "order.cancel");

                AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
                        .headers(Map.of("x-delay", delay)) // 延迟时间
                        .build();
                channel.basicPublish("delayed_exchange", "order.cancel", props, orderId.getBytes());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    d.优点
        消息队列支持分布式,高并发下表现优秀
        数据可靠性高,不容易丢消息
    e.缺点
        引入消息队列增加了系统复杂性
        需要处理队列堆积的问题

06.使用定时任务框架
    a.适用场景
        订单取消操作复杂,需要分布式支持
    b.原理
        定时任务框架,比如:Quartz、Elastic-Job,能够高效地管理任务调度,适合处理批量任务
        比如 Quartz 可以通过配置 Cron 表达式,定时执行订单取消逻辑
    c.示例代码
        @Scheduled(cron = "0 */5 * * * ?")
        public void scanAndCancelOrders() {
            System.out.println("开始扫描并取消过期订单");
            // 这里调用数据库更新逻辑
        }
    d.优点
        成熟的调度框架支持复杂任务调度
        灵活性高,支持分布式扩展
    e.缺点
        对实时性支持有限
        框架本身较复杂

07.基于触发式事件流处理
    a.适用场景
        需要处理实时性较高的订单取消,同时结合复杂业务逻辑,例如根据用户行为动态调整超时时间
    b.原理
        可以借助事件流处理框架(如 Apache Flink 或 Spark Streaming),实时地处理订单状态,并触发超时事件
        每个订单生成后,可以作为事件流的一部分,订单未支付时通过流计算触发超时取消逻辑
    c.示例代码(以 Apache Flink 为例)
        DataStream<OrderEvent> orderStream = env.fromCollection(orderEvents);

        orderStream
            .keyBy(OrderEvent::getOrderId)
            .process(new KeyedProcessFunction<String, OrderEvent, Void>() {
                @Override
                public void processElement(OrderEvent event, Context ctx, Collector<Void> out) throws Exception {
                    // 注册一个定时器
                    ctx.timerService().registerProcessingTimeTimer(event.getTimestamp() + 30000); // 30秒超时
                }

                @Override
                public void onTimer(long timestamp, OnTimerContext ctx, Collector<Void> out) throws Exception {
                    // 定时器触发,执行订单取消逻辑
                    System.out.println("订单超时取消,订单ID:" + ctx.getCurrentKey());
                }
            });
    d.优点
        实时性高,支持复杂事件处理逻辑
        适合动态调整超时时间,满足灵活的业务需求
    e.缺点
        引入了流计算框架,系统复杂度增加
        对运维要求较高

2.15 [5]订单超时:DelayQueue+Redis

01.关于过期订单自动关闭
    a.概述
        简单来说,需要在某用户订单过期时主动生产(推送消息),服务端接收到消息进行消费(需要关心消费的正确性)
        使用实际的技术选型,具体需要结合用户量和实际架构进行设计
    b.DelayedQueue简述
        a.源码分析
            DelayedQueue是Java集合框架的成员。它实现了BlockingQueue<E>接口,拥有阻塞队列的特性(线程安全)
        b.元素要求
            DelayedQueue中的元素需继承Delayed类(DelayQueue<E extends Delayed>),而Delayed继承自Comparable
            为使用该队列时比较元素中的延迟时间并获取最先到期的元素打下基础(需要重写getDelay、compareTo两个方法)

02.案例代码
    a.DelayedTask类
        a.说明
            创建一个DelayedTask延迟任务类实现Delayed接口并重写方法,DelayedTask后续用于进入队列中。
        b.代码
            @Data
            public class DelayedTask<T> implements Delayed {

                private T data;

                // 此处有歧义,应为createTime开始放入队列的时间
                private long startTime;

                // 单位毫秒
                private long delayTime;

                // 判断 延长时间 - 当前时间和 元素.开始放入队列时间 的区别
                @Override
                public long getDelay(@NotNull TimeUnit unit) {
                    long diff = delayTime - (System.currentTimeMillis() - startTime);
                    return unit.convert(diff, TimeUnit.MILLISECONDS);
                }

                // 与其他元素进行对比,getDelay对比后其他元素先出队列
                @Override
                public int compareTo(@NotNull Delayed o) {
                    if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) {
                        return -1;
                    }
                    if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {
                        return 1;
                    }
                    return 0;
                }
            }
    b.订单处理器
        a.说明
            定义一个订单处理器,和订单过期处理队列,进行订单过期处理
        b.代码
            @Component
            @Slf4j
            @RequiredArgsConstructor
            public class UserPayOrderQueueHandler {

                private final RedisUtils redisUtils;

                private final DelayQueue<DelayedTask<BizPayOrder>> payOrderQueue = new DelayQueue<>();

                private final IBizPayOrderService payOrderService;

                public static final String NON_PAYMENT_ORDER_KEY_PREFIX = "NON-PAYMENT-ORDER:";

                private final BusinessConfig businessConfig;

                // 项目运行时,向线程池提交检查订单过期
                // 配置化,假如yml中配置使用DelayedQueue队列检查则开启一个内部不断运行的检查方法线程
                @PostConstruct
                public void handleOrderExpire() {
                    if (HandleOrderExpireType.DELAYED_QUEUE.getName().equals(businessConfig.getCheckType())) {
                        HutuThreadPoolExecutor.threadPool.submit(this::doOrderExpire);
                        log.info("向线程池提交检查订单过期线程");
                    }
                }

                // 订单过期检查方法
                public void doOrderExpire() {
                    while (true) {
                        try {
                            // DelayedQueue.take()方法没有过期元素时(getDelayed != -1)时阻塞
                            DelayedTask<BizPayOrder> delayedTask = payOrderQueue.take();
                            // 获取先前DelayedTask<T>中T实际声明的类
                            BizPayOrder data = delayedTask.getData();
                            log.info("开始处理过期订单信息,订单id:[{}]", data.getId());
                            Long userId = data.getUserId();
                            Object object = redisUtils.hget(NON_PAYMENT_ORDER_KEY_PREFIX + userId, data.getId() + "");
                            // 此处业务逻辑,省略...
                            // 实际上Redis的作用是存储当时初始化的订单
                            // 订单支付成功时需要删除当前用户下的未支付订单列表
                            if (HutuUtils.isEmpty(object)) {
                                log.info("订单已完成,无需处理");
                                return;
                            }
                            LambdaUpdateWrapper<BizPayOrder> wrapper = new LambdaUpdateWrapper<>();
                            wrapper.eq(BizPayOrder::getId, data.getId())
                                    .set(BizPayOrder::getOrderStatus, OrderStatusEnum.EXPIRE.getVal());
                            payOrderService.update(wrapper);
                            redisUtils.hdel(NON_PAYMENT_ORDER_KEY_PREFIX + userId, data.getId());
                            log.info("订单已超时,设置为过期状态...");
                        } catch (InterruptedException e) {
                            log.error(e.getLocalizedMessage(), e);
                        }
                    }
                }

                // 预留方法给具体订单业务放入订单过期延时检查任务类
                public void pushData2Queue(BizPayOrder data, long expireTime) {
                    DelayedTask<BizPayOrder> settleOrderVoDelayedTask = new DelayedTask<>();
                    settleOrderVoDelayedTask.setData(data);
                    // 放入时间取订单创建时间
                    settleOrderVoDelayedTask.setStartTime(data.getCreateTime().getTime());
                    // 使用配置化的过期时长
                    settleOrderVoDelayedTask.setDelayTime(TimeUnit.MILLISECONDS.convert(expireTime, TimeUnit.SECONDS));
                    // 向队列中放入元素DelayedQueue.offer()
                    // DelayedQueue是无底队列,offer和take都可以
                    payOrderQueue.offer(settleOrderVoDelayedTask);
                }
            }
    c.生产订单数据
        a.说明
            使用预留的pushData2Queue方法,在订单初始化(用户下单)正确处理完业务逻辑后时放入。
            因DelayedQueue是运行在jvm中,当项目停止时,内存信息消失,会丢失未处理的队列元素内容。
            作为补偿,项目启动时,主动查询一次当日未完成订单,并放入到监听队列里。
        b.代码
            @Service
            @Slf4j
            public class BizPayOrderServiceImpl extends ServiceImpl<BizPayOrderMapper, BizPayOrder> implements IBizPayOrderService {

               ...

               @PostConstruct
               public void pushUnFinishOrder2Queue() {
                   // 业务逻辑,省略
                   List<BizPayOrder> unPayOrders = this.list(wrapper);
                   if (HutuUtils.isNotEmpty(unPayOrders)) {
                       unPayOrders.forEach(order -> {
                           userPayOrderQueueHandler.pushData2Queue(order, businessConfig.getOrderExpire());
                           redisUtils.hset(UserPayOrderQueueHandler.NON_PAYMENT_ORDER_KEY_PREFIX + order.getUserId(), order.getId() + "", JSONObject.toJSONString(order));
                       });
                       log.info("投入了{}个当日未完成订单到监听队列", unPayOrders.size());
                   }
               }
            }

03.实际运行
    a.延迟时间设置
        延迟时间设置为5秒,过期订单检查类型为延迟队列。
    b.操作步骤
        下单,等待5秒。
        设置成30秒,重启,下单后马上重启应用。

2.16 [5]商品超卖:加锁排队、update语句限制、数据库乐观锁、临时表、redis提前存入库存

00.汇总
    加锁排队
    update语句限制
    数据库乐观锁
    临时表
    redis提前存入库存

01.超卖问题
    a.定义
        超卖问题是指在库存管理中,系统允许的销售数量超过了实际库存数量,导致订单无法履行的情况
        这通常发生在高并发的电商场景中,多个用户同时购买同一商品时,库存未能及时更新
    b.原理
        超卖问题通常是由于并发控制不当导致的
        在高并发情况下,多个请求同时读取库存信息并进行扣减操作,而库存信息未能及时更新,导致库存被多次扣减
    c.常用API
        数据库锁机制(如乐观锁、悲观锁)
        Redis分布式锁
        Redis原子操作(如DECRBY)
    d.使用步骤
        1.识别需要并发控制的关键操作(如库存扣减)
        2.选择合适的并发控制策略(如数据库锁、Redis锁)
        3.实现并发控制逻辑,确保库存信息的一致性
        4.测试并发场景,验证超卖问题是否解决

02.解决方案
    a.加锁排队
        通过加锁的方式让线程排队处理业务,这种方式实现简单,但是在高并发下效率不高
    b.update语句限制
        在扣减库存的时候,SQL更新上将库存大于0作为一个条件更新数据然后返回的影响行数
        如果影响行数 > 0,表示扣减库存成功
        如果影响行数 <= 0,表示扣减库存失败
        通过这种方式可以很好的防止超卖问题的出现,但是本方案不适用于在高并发场景下的使用,因为数据库将成为瓶颈
    c.数据库乐观锁方式
        在商品表中增加一个字段version,每次在更新的时候带上version字段作为更新的条件,然后返回的影响行数
        如果影响行数 > 0,表示扣减库存成功
        如果影响行数 <= 0,表示扣减库存失败
    d.临时表的方式
        扣减库存的时候,都要先查询日志表(第一次时日志表没有的时候让线程创建一条数据插入到数据库)
        然后执行扣减库存的操作。在扣减库存的时候可能会出现超卖的问题
        但是在更新日志表的版本时判断当前的版本是否被其他的线程操作过
        如果被其他线程操作过就提示扣减库存失败,本次操作无效并会回滚数据库数据。这样可以防止超卖的问题
    e.redis提前存入库存方式
        使用定时任务(如xxl-job)在商品开售的之前将商品的库存信息存放到Redis中(key是商品的id,value为商品的库存)
        用户下单的时候先在Redis扣减(使用Redis的decrby命令)库存,如果扣减后大于0,就允许用户下单,反之不可以让用户下单
        这样也可以很好地防止超卖现象的问题发生

03.每个场景对应的代码示例
    a.使用数据库乐观锁
        在商品表中增加一个字段version,每次在更新的时候带上version字段作为更新的条件
        UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?
        如果影响行数 > 0,表示扣减库存成功;否则,表示扣减库存失败
    b.使用Redis分布式锁
        使用Redis的分布式锁机制,确保同一时间只有一个请求可以进行库存扣减操作
        String lockKey = "product_lock_" + productId;
        boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS);
        if (lockAcquired) {
            try {
                // 执行库存扣减操作
            } finally {
                redisTemplate.delete(lockKey);
            }
        }
    c.使用Redis原子操作
        使用Redis的DECRBY命令进行库存扣减,确保操作的原子性
        Long stock = redisTemplate.opsForValue().decrement("product_stock_" + productId);
        if (stock != null && stock >= 0) {
            // 执行订单创建操作
        } else {
            // 库存不足,回滚操作
        }

2.17 [5]一人一单:单系统、分布式系统

01.单系统
    a.需求
        每个人只能抢购一张大额优惠券,避免同一用户购买多张优惠券。
    b.重点
        事务:库存扣减操作必须在事务中执行
        粒度:事务粒度必须够小,避免影响性能
        锁:事务开启时必须确保拿到当前下单用户的订单,并依据用户 Id 加锁
        找到事务的代理对象,避免 Spring 事务注解失效 (需要给启动类加 @EnableAspectJAutoProxy(exposeProxy = true) 注解)
    c.实现逻辑
        获取优惠券 id、当前登录用户 id
        查询数据库的优惠券表(voucher_order)
        如果存在优惠券 id 和当前登录用户 id 都匹配的 order 则拒绝创建订单,返回 fail()
        如果不存在则创建订单 voucherOrder 并保存至 voucher_order 表中,返回 ok()

02.分布式系统解决方案 (通过 Lua 脚本保证原子性)
    a.优惠券下单逻辑
        使用 Lua 脚本保证下单逻辑的原子性
    b.代码实现 (Lua脚本)
        --1. 参数列表
        --1.1. 优惠券id
        local voucherId = ARGV[1]
        --1.2. 用户id
        local userId = ARGV[2]
        --1.3. 订单id
        local orderId = ARGV[3]

        --2. 数据key
        --2.1. 库存key
        local stockKey = 'seckill:stock:' .. voucherId
        --2.2. 订单key
        local orderKey = 'seckill:order' .. voucherId

        --3. 脚本业务
        --3.1. 判断库存是否充足 get stockKey
        if( tonumber( redis.call('get', stockKey) ) <= 0 ) then
            return 1
        end
        --3.2. 判断用户是否下单 SISMEMBER orderKey userId
        if( redis.call( 'sismember', orderKey, userId ) == 1 ) then
            return 2
        end
        --3.4 扣库存: stockKey 的库存 -1
        redis.call( 'incrby', stockKey, -1 )
        --3.5 下单(保存用户): orderKey 集合中添加 userId
        redis.call( 'sadd', orderKey, userId )
        -- 3.6. 发送消息到队列中
        redis.call( 'xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId )
    c.加载 Lua 脚本
        a.RedisScript 接口
            用于绑定一个具体的 Lua 脚本。
        b.DefaultRedisScript 实现类
            定义:RedisScript 接口的实现类
            功能:提前加载 Lua 脚本
        c.示例
            // 创建Lua脚本对象
            private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

            // Lua脚本初始化 (通过静态代码块)
            static {
                SECKILL_SCRIPT = new DefaultRedisScript<>();
                SECKILL_SCRIPT.setLocation(new ClassPathResource("/path/to/lua_script.lua"));
                SECKILL_SCRIPT.setResultType(Long.class);
            }
    d.执行 Lua 脚本
        a.调用Lua脚本 API
            StringRedisTemplate.execute( RedisScript script, List keys, Object… args )
        b.示例
            执行 ”下单脚本” (此时不需要 key,因为下单时只需要用 userId 和 voucherId 查询是否有锁)
            Long result = stringRedisTemplate.execute(
                    SECKILL_SCRIPT,                                                        // 要执行的脚本
                    Collections.emptyList(),                                               // KEY
                    voucherId.toString(), userId.toString(), String.valueOf(orderId)       // VALUES
            );

3 设计题

3.1 [1]红包雨

00.汇总
    a.两个目标
        1.用户马上可以看到抢了多少金额,也能尽快看到总金额,以及把金额放入账户,这就达到了
        2.可以通过提前拆分红包来实现,那接下来看看红包要怎么拆
    b.红包拆分算法
        1.二倍均值法
        2.线段切割法

01.介绍
    a.背景
        首先这个场景很典型。电商大促和春晚红包是中国互联网的两个流量巅峰,新的应用与玩法层出不穷
        也不断地给互联网技术带来各种挑战,特别是春节抢红包活动,并发流量一年比一年高
    b.面试官究竟考查哪些维度?
        系统设计,不像算法题或者基础知识题,考查单一维度的能力,而是会考查一个人的综合能力,这更接近真正工作时的表现。一般,面试官更看重以下几方面
        沟通表达能力:面试官一开始给的信息一般不全面,例如红包雨的每个红包额度大小是否相等,每个用户抢到的红包数量有没有限制等,这些条件都需要你在面试过程中不断细化,逐步沟通清楚
        知识广度和深度:系统设计会涉及很多中间件、数据库的知识,包括这些中间件的优缺点,应用场景,支撑的数据量等
        技术和业务上的取舍:在实际业务中,我们很难把系统设计得十足完美,那么就要做出一些取舍,例如很多互联网场景为了追求高性能高吞吐通常放弃数据强一致性,只做到最终一致也是可以接受的。你在面试中可以结合实际经验来作方案的补充
    c.系统设计目标是什么?
        看到题目后,你首先要跟面试官确认有哪些重要的设计目标。一般来说,抢红包场景下,至少要考虑下面几点
        高性能:主要是为了保证用户体验,即用户能尽快看到结果,尽快把抢到金额加到账户
        高可靠:不能超发,红包超发或者促销活动超卖,都会给企业带来损失,所以这点是肯定要保证的
        高可用:活动期间保证服务不挂。
        接下来,我会从这 3 个设计目标出发,给你提供一些设计思路。其中,高性能和高可靠在实际业务中是设计方案的重点,这两方面跟抢红包的业务结合比较密切,所以我会详细阐述

02.如何实现高性能?
    a.机房流量调度
        看题目的性能指标,百万级的 TPS。如果流量入口是同一个的话那么肯定压力很大,用 Nginx 做负载均衡是顶不住的,要考虑 F5 这种商业设备
        Nginx 的性能是万级,一般的 Linux 服务器上装个 Nginx 大概能到 5 万/秒;LVS 的性能是十万级,据说可达到 80万/秒;F5 性能是百万级,从 200 万/秒到 800 万/秒都有
        如果我们不想用这种商业负载均衡设备,或者想尽量减少网络延迟,那么可以这样设计
        直接把红包根据某种规则拆分好,放在不同的机房。不同地区的用户,在活动开始前就已经分配好了机房,可以是用 HTTPDNS 或者不同的域名来实现机房流量调度
        当用户抢红包或者查询红包结果时,只需在本地机房做处理就可以,最后再把结果通过 MQ 异步/同步到账户服务,完成最后一步
        当然,这里对业务是做出了一定牺牲的,红包金额同步到用户账户有一定延迟,用户红包没办法马上入账
        这样处理,我们就可以把 TPS 的数量级降下来。如果机房足够多,甚至以后边缘计算发展起来,后端抢红包服务基本上都不需要太关注大流量的问题了
    b.后端服务设计
        通过机房流量调度,我们可以把并发压力降下来,但是即使降了一个数量级,每个机房可能还有几十万的 TPS,怎么办呢?
        继续分而治之
        存储用到 Redis,这里的集群是指直接部署多个不同的实例,假设要达到 50万 TPS,按照单机 8万 TPS 来算,只需要 7 个实例,可以冗余部署到 10 个。然后在应用层做轮询,如果应用层发现有实例挂了马上剔除掉。每个 Redis 实例都会存储一个拆分好的 红包id+红包金额 list,抢红包的时候用 lua 脚本从 list 里面 pop 一个元素,同时记录到另外一个 list 里面,存储 uid+红包 id+红包金额
        定时任务集群不断从 Redis 集群里面取 uid+金额数据,批量插入到 MySQL 集群,同时发送 MQ 通知账户服务入库。当然也可以在活动结束时,读取 MySQL 查询每个用户汇总结果,同步给账户服务,这样还能减少消息量
        MySQL 集群我们这里用 uid 为维度来分库,主要是用来查询用户已抢到的红包列表

03.如何实现高可靠:红包拆分算法
    a.说明
        你能够把流量分散到不同地方,达到高性能的效果
        用户马上可以看到抢了多少金额,也能尽快看到总金额,以及把金额放入账户,这就达到了第一个设计目标
        而第二个目标,可以通过提前拆分红包来实现,那接下来看看红包要怎么拆
    b.二倍均值法
        剩余红包金额为 M,剩余人数为 N,那么有如下公式:
        每次抢到的金额=随机区间(0,M/N X 2)
        这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平
        假设有 10 个人,红包总额 100 元。100/10X2=20,所以第一个人的随机范围是(0,20),平均可以抢到 10 元
        如果第一个人随机抢到 10 元,那么剩余金额是 100-10=90 元。90/9X2=20 元,所以第二个人的随机范围同样是(0,20),平均可以抢到 10 元
        如果第二个人随机抢到 10 元,那么剩余金额是 90-10=80 元。80/8X2=20 元,所以第三个人的随机范围同样是(0,20),平均可以抢到 10 元
        -----------------------------------------------------------------------------------------------------
        以此类推,每一次随机范围的均值是相等的。可以参考以下 demo 代码:
        public static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
                List<Integer> amountList = new ArrayList<Integer>();
                Integer restAmount = totalAmount;
                Integer restPeopleNum = totalPeopleNum;
                Random random = new Random();
                for (int i = 0; i < totalPeopleNum - 1; i++) {
                    //不能从0开始随机 我们还得保证每个人分得的钱至少是0.01元
                    //随机范围:[1,剩余人均金额的两倍),左闭右开
                    int amount = random.nextInt(restAmount / restPeopleNum * 2 - 1) + 1;
                    restAmount -= amount;
                    restPeopleNum--;
                    amountList.add(amount);
                }
                amountList.add(restAmount);
                return amountList;
            }

            public static void main(String[] args) {
                List<Integer> amountList = divideRedPackage(5000, 30);
                for (Integer amount : amountList) {
                    System.out.println("抢到:" + new BigDecimal(amount).divide(new BigDecimal(100)) + "元");
                }
            }
    c.线段切割法
        把红包总金额想象成一条很长的线段,而每个人抢到的金额,则是这条主线段所拆分出的若干子线段
        当 N 个人一起抢红包的时候,就需要确定 N-1 个切割点
        因此,当 N 个人一起抢总金额为 M 的红包时,我们需要做 N-1 次随机运算,以此确定 N-1 个切割点
        随机的范围区间是(1,100*M)。当所有切割点确定以后,子线段的长度也随之确定。这样每个人来抢红包的时候,只需要顺次领取与子线段长度等价的红包金额即可
        -----------------------------------------------------------------------------------------------------
        private static List<Integer> divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
                List<Integer> boards = new ArrayList<>();
                boards.add(0);
                boards.add(totalAmount);
                while (boards.size() <= totalPeopleNum) {
                    int index = new Random().nextInt(totalAmount - 1) + 1;
                    if (boards.contains(index)) {
                        //保证切割点的位置不相同
                        continue;
                    }
                    boards.add(index);
                }

                Collections.sort(boards);
                List<Integer> list = new ArrayList<>();
                for (int i = 0; i < boards.size() - 1; i++) {
                    Integer e = boards.get(i + 1) - boards.get(i);
                    list.add(e);
                }
                return list;

            }

         public static void main(String[] args) {
            List<Integer> amountList = divideRedPackage(5000, 30);
            for (Integer amount : amountList) {
                System.out.println("抢到:" + new BigDecimal(amount).divide(new BigDecimal(100)) + "元");
            }
        }
    d.两种算法优缺点
        在面试中,你要先将这两种算法的优缺点进行比较说明,然后针对具体情况选择其中一种方法来实现
        二倍均值法除了最后一次,任何一次抢到的金额都不会超过人均金额的两倍,相对来说不会给用户太多惊喜,它的优点是实现简单,空间复杂度低;线段切割法则相反
        如开篇面试题的情况,红包总个数过大,那么可以考虑先分段,再用多个线程来拆分,可以提高效率
        两种算法各有千秋,所以你在面试过程中,要先和面试官沟通好,这个红包系统想要什么效果,再见招拆招
    e.技术与业务的取舍:风控防刷
        说到红包系统,还有一个重要设计方面是风控。根据实际业务经验来看,每年都会有一些囤积大量账号准备在春晚大发横财的公司和个人,而风控规则是很复杂的,需要从各个维度来做
        但是你需要注意一点,复杂风控规则跟高并发是矛盾的,每次请求都要走一遍是很耗性能的。那要如何权衡呢?
        这里我们可以把复杂的风控规则后置。因为 抢红包——>金额加入账户——>使用账户金额,整个链路需要经过一段时间,所以可以利用这段时间来跑异步风控规则(例如跑风控模型等),实时规则只取简单的即可(例如控制频率、黑白名单等)
        这是技术和业务取舍之后的结果,你在面试中说出这些,能体现出你的经验丰富度和更多的思考

3.2 [1]拼团活动

01.角色
    a.商户管理员
        拥有拼团活动的商品管理权限,包含:添加活动商品、更改活动库存、拼团订单发货和查看拼团记录
    b.平台管理员
        拥有拼团活动管理权限,包含:拼团活动设置、拼团商品添加、店铺平台商品审核
        查看拼团记录和拼团订单发货
    c.发起人
        拼团活动的发起人,选择拼团商品发起拼团,拼团成功发货,拼团失败自动退款
    d.参团人
        拼团活动的参与者,加入已存在的拼团活动

02.功能模块
    拼团活动
    ├── 平台端
    │   ├── 拼团设置
    │   ├── 商品设置
    │   ├── 拼团记录
    │   └── 订单管理
    ├── 商户端
    │   ├── 商品管理
    │   ├── 拼团记录
    │   └── 订单管理
    └── 用户端
        ├── 拼团主页
        │   ├── 商品详情
        │   │   ├── 发起拼团
        │   │   ├── 单独购买
        │   │   └── 加入拼团
        │   └── 拼团信息
        │       └── 预热中
        ├── 我的拼团
        └── 拼团订单

3.3 [2]订单模块

00.汇总
    a.问题
        订单ID
        并行与异步
        超时问题
        分布式事务
    b.设计
        订单
        ├── 账户
        │   ├── 买方
        │   ├── 卖方
        │   └── 平台
        ├── 支付
        │   ├── 渠道对接
        │   ├── 资金
        │   └── 结算中心
        └── 运营
            ├── 会员
            │   ├── 促销活动
            │   └── 优惠券
            └── 主体
                ├── 商品
                ├── 订单信息
                └── 售后

01.订单ID
    订单主体的唯一ID标识,在数据体量不大的情况下,使用表的自增ID主键即可
    从长期看的话并不友好,如果订单量比较大,可能涉及分库分表的流程,则需要制定ID生成策略
    ---------------------------------------------------------------------------------------------------------
    UUID:生成唯一字符串识别码,订单ID直接使用即可
    雪花算法:分布式ID生成算法策略,生成的ID遵循时间的顺序
    自定义ID:除了唯一的属性外,在订单ID中添加其他的关键业务标识

02.并行与异步
    并行操作
    在订单详情的加载过程中,涉及到的查询信息非常多
    比如:商品、商户、订单、用户等,可以通过并行的方式,提高响应的时间,如果采用串行的方式,则接口性能会差很多
    ---------------------------------------------------------------------------------------------------------
    异步操作
    订单是个复杂的流程,显然不可能在一次流程中完成所有逻辑,流程分段异步常规手段,就是借助MQ消息的方式
    同样可以极大的提升服务性能;不论是订单的正逆向流程,都可以基于状态、事件、动作进行异步解耦处理

03.超时问题
    订单超时问题的本质在于,指定时间段之后需要执行一个动作
    比如最经典的场景,下单之后超过15||30分钟未支付,订单自动取消并且被关闭,释放商品的库存,并通知用户
    ---------------------------------------------------------------------------------------------------------
    实现一个动作延迟执行的方式有很多,比如延期队列,过期监听,消息延时消费等
    不过这些方式在复杂的订单系统中并不常见,主流的话还是采用定时任务调度的方式
    ---------------------------------------------------------------------------------------------------------
    任务调度时,对订单的处理,同样要确保业务流程操作的幂等性,数据层面的一致性等问题,如果出现异常单则进行重试,分析异常原因不断优化流程也同样重要
    如果订单体量大,任务调度能完成吗?
    订单体量和订单实时量不是一个概念,系统沉淀的订单量和任务要处理的量不是一个等级,常规的数据体量做好分库分表的设计和查询优化即可,不会成为调度任务的瓶颈问题
    如果订单数据实时体量大,比如每天超千万的水平?
    这就更不是应用的问题了,订单体量能达到每日千万的规模,公司会提前很长时间就把数据团队拉到应用团队中,解决这种核心的棘手问题,此前在数据公司搬砖时,每日单量刚过百万,就安排数据团队做解决方案了

04.分布式事务
    订单涉及支付对接、库存管理、结算对账等各种复杂的流程,自然对数据一致性有极高的要求
    如果数据层面出现问题导致异常单出现,难免需要人工介入处理,所以对流程的各阶段做好细致的事务和逻辑管理极其重要
    ---------------------------------------------------------------------------------------------------------
    订单流程是异步解耦的方式推进的,在分布式事务的策略上追求的是最终结果一致性即可,不过这并不妨碍在分段的流程中
    进行局部的事务管理,事务成功,流程正向推进,事务失败,流程重试或逆向回滚

3.4 [2]支付系统

00.流程
    a.用户选择商品提交订单
        用户在平台选择自己想要的商品,并提交订单
    b.商家服务器与支付宝交互生成订单
        商家服务器将商品信息和所需金额发给支付宝,生成支付宝订单
    c.支付宝返回支付页面
        支付宝订单返回成功后,生成支付页面,方便用户进行手机支付或网页支付
    d.手机调起支付宝 App 进行支付
        用户通过手机调起支付宝 App 进行支付操作
    e.用户输入支付密码并发送给支付宝
        用户输入支付密码,通过支付宝服务器发送支付请求
    f.支付宝转账成功并通知商家服务器
        支付宝确认转账成功后,通知商家服务器,表示订单金额已经转账成功
    g.商户验证支付结果并处理订单
        商户服务器验证支付宝回调通知,确认支付结果后处理订单
    h.用户收到支付成功反馈,商户开始发货或提供服务
        用户收到支付成功的反馈后,商户开始发货或提供服务

01.问题1
    a.说明
        商户的服务器和支付宝或者微信的服务器在进行交互的过程之中,传输的数据是异常的敏感的
        所以,在交互时必须防止中间人对于信息的篡改
        怎么防止进行纂改呢?这就需要我们使用到加密算法对交互的数据进行加密
    b.单向加密(不可逆)
        a.算法
            一般有MD5、SHAD算法,这种算法只能加密,不能解密
            但是MD5加密可能会出现哈希碰撞的情况
            比如一个字符串"123456"和另外一个字符串"234567"经过MD5加密后可能会出现加密值相等的情况
            这时候我们就需要通过MD5+盐来解决哈希碰撞
        b.应用
            这种单向加密的发生的业务场景一般是在一张用户表里面存放用户的隐私数据
            比如用户的密码,在数据库中不能明文存储
    c.对称加密(可逆)
        a.算法
            AES、DES 等
        b.加密流程
            这个是服务端和用户端使用同一对密钥。密钥又分为私钥和公钥,私钥是用来加密的(签名),公钥是用来解密的(验签)
            流程是客户端用私钥进行加密传输给服务端,服务端拿着公钥解密,然后将响应的数据在通过私钥进行加密返回给服务端
            服务端在通过公钥解密响应数据
        c.应用
            这种对称加密最常用的经常就是用到了HTTPS协议之中了
    d.非对称加密(可逆)
        a.算法
            RSA2 等
        b.加密流程
            这个是服务端和客户端分别使用一对密钥
            服务端拿着的是服务端的私钥和客户端的公钥,客户端拿着的是客户端的私钥和服务端的公钥
            流程是:客户端用自己本地的私钥将交互的数据进行加密传输给服务端,服务端用客户端的公钥来解密传输的数据
            然后将响应的数据用服务端本地的私钥进行加密,响应给客户端,客户端在得到响应的数据后
            会拿着服务端的公钥进行解密,从而完成在数据交互时候,数据的加密处理
        c.应用
            见的即使我们在支付宝支付或者微信支付的时候,我们商家的服务器和支付宝或者微信的服务器在进行数据交互的时候
            就用非对称加密进行数据防纂该的

02.问题2
    a.说明
        如果QPS过高会出现“超卖的问题”
    b.解决
        加锁排队
        update语句限制
        数据库乐观锁
        临时表
        redis提前存入库存

03.问题3
    a.说明
        支付失败或者是超时的时候,我们应该怎么处理?
    b.解决
        延迟队列
        使用Redis的缓存过期策略TTL

3.5 [2]秒杀系统

00.总结
    a.问题
        a.瞬时并发量大
            在秒杀开始的时候就会有大量的用户,在同一个时间进行抢购,那我们的系统,就需要面对瞬时的激增流量
            如果没有处理好这些激增的流量,会打垮我们的系统,造成系统的血崩
        b.库存有限
            就是秒杀的库存一般会比较少,我们需要保证库存不会被超卖,另外,就是订单的数量跟库存的数量要保持一致
            那么当你能够挖掘出你所需要设计系统的痛点之后,那你就可以再进行架构的设计了
    b.4个步骤
        a.第1步:访问层
            静态化商品页
            按钮设计
            秒杀后,排队体验
        b.第2步:中间转发层
            nginx
            docker、K8S
            限流
        c.第3步:服务器
            redis缓存
            接口幂等性
        d.第4步:数据库
            通过MQ的方式异步去扣紧库存
            分库分表
    c.考虑的点
        CDN
        静态化
        集群部署
        负载均衡
        网关
        限流
        分布式缓存
        防重
        消息队列
        读写分离或分库分表
        分布式锁

01.第1步:访问层
    a.静态化商品页
        当用户访问我们的商品页的时候,肯定要将商品页给他静态化
        因为如果是一个动态的商品页,所有的静态资源,就需要交给我们的服务端进行管理
        你像js,css,这些图片,都需要去访问服务端,那么这对于本身压力就很大的服务端无疑。是雪上加霜
        所以我们就需要将商品页进行静态化,可以将生成出来的这个静态商品页同步到CDN服务器当中
        那么这样不仅用户他的访问速度可以提升上来,并且还可以减轻服务器的压力
        那服务端只需要一心的去处理秒杀的请求即可
    b.按钮设计
        1.活动前禁用按钮
        2.点击后禁用按钮
        3.滑动验证码防羊毛党
        4.排队处理提升用户体验
        -----------------------------------------------------------------------------------------------------
        在访问层,我们还需要面对秒杀按钮的处理问题
        首先在秒杀活动之前,我们应该把秒杀按钮给它禁掉,这样可以减少一些不必要的请求带来的服务器资源的浪费
        另外,就是秒杀按钮点击之后,我们应该给它禁用掉,防止用户的重复提交
        -----------------------------------------------------------------------------------------------------
        还有,我们可以去在用户点击完秒杀之后增加前端的滑动验证码
        这样,可以从一定程度上防止一些羊毛党,什么羊毛党,就是那些一个人控制几十台上百台的手机
        它通过这种所谓的猫池,就可以在同一个时间,一起去点击秒杀按钮,然后去薅我们商品的羊毛
        所以,在前端添加这种滑动的验证码,就可以防止羊毛的进而,也可以减轻我们服务端的一个压力
    c.秒杀后,排队体验
        最后,我还建议增加排队的体验,因为当用户点击完秒杀按钮,如果他没有任何的感觉,那他下一步可能会怎么样
        他就会不断的去按F5刷新页面,然后再去秒杀,那么这毫无疑问也会增加服务端的一个压力
        所以我们如果在用户点击完秒杀之后,给他提供排队的体验,这不仅提升了用户的一个体验,来减轻服务端的一个压力

02.第2步:中间转发层
    a.nginx
        我们通常会通过Nginx来进行负载均衡,当然单台Nginx,我们通常处理的并发量是两三万左右
        所以如果你的并发量超过了两三万的话,我们通常都会对Nginx进行集群部署
        那么一旦Nginx进行了集群的话,在它的上层,就需要进行部署硬件级别的负载均衡器
        -----------------------------------------------------------------------------------------------------
        像我们的F5或者LVS都可以去实现,接着就是通过Nginx负载均衡到服务网关之后
        在服务网关层,还会通过客户端的负载均衡器,你向ribbon本来进行客户端的一个负载均分发
        像我们这里的四级负载均衡基本就可以处理每秒上十万以上的QPS的并发量了
        所以,这里你就可以针对面试它让你设计多少QPS的这样的一个系统来进行回答了
    b.docker、K8S
        当然像我们的这种秒杀系统,我们通还会结合多docker、K8S来进行云服务器的动态伸缩的部署
        因为像这种秒杀,它并不是实时都会出现,只有当秒杀开始的时候,我们就可以自动扩容我们的服务器节点数量
        当秒杀结束了,我们又可以自动的缩减服务器节点的数量,这样可以有效的去利用我们服务器的资源,节省服务器的成本
    c.限流
        那么另外,我们还需要去做好限流,因为像这种秒杀场景,通常QPS是平常的好几倍
        并且里面还会掺杂着很多爬虫、羊毛党等这些无效的请求量,所以首先在Nginx这一端,就需要配置好限流
        防止一些恶意的绕过了我们前端的攻击,然后,我们还需要在网关层,比如说我们通过sentinel对不同的服务节点
        去设置限流以及熔断的机制,最后,我们在秒杀服务当中,也可以通过MQ来去做削峰填谷
        通过MQ我们可以减轻下游的一个压力,防止这种激增流量,打垮我们下游的数据库

03.第3步:服务器
    a.redis缓存
        在服务端,我们肯定不能让它直接去操作数据库,那我们通常会利用redis来去做缓存,进而减轻数据库的一个压力
        那利用redis的话,我们需要注意这些问题,那在秒杀开始之前,可以通过一些比如说定时器将秒杀需要用到的一些商品
        库存,这些信息给它预热到redis中,防止redis被击穿从而打垮我们的数据库
        那么另外,我们可以通过redis结合lua脚本来去操作库存
        -----------------------------------------------------------------------------------------------------
        因为在用户进行秒杀下单的时候,是分好几步操作
        我们首先要判断库存是否大于0,在库存大于0的时候,我们需要将已有的库存进行减一,然后在替换已有的库存数量
        所以说,这里是三步操作,那我们为了保证它的原子性,就可以通过lua脚本来进行保证
    b.接口幂等性
        那么另外就是防重,那虽然我们之前说可以在前端通过一些禁用按钮或者一些验证码来防止用户的重复提交
        但是那种方式,只能防一些君子,不能防小人,依然会有一些绕过我们的前端的恶意请求
        所以这个时候呢。我们可以通过redist setNX命令,比如说我们可以针对同一个用户,他的token或者同一个IP
        再加上当前商品的url,就可以保证,在同一个时间、同一个token、同一个商品只能有一个有效
        -----------------------------------------------------------------------------------------------------
        我们还可以使用分布式锁,也能保证请求它的一个原子性,那么利用redis,也可以去实现分布式锁
        当用户秒杀成功减完库存之后,那这个时候需要进行下单操作,如果你让他直接去访问数据库的话
        那这个瞬时的激增流量数据库它是扛不住的,那我们就可以通过MQ在秒杀成功之后,给他发送一个MQ的消息
        然后再利用MQ呢来进行异步的下单,这样,我们可以达到一个限流,从而将这种激增的请求给他进行削峰填股

04.第4步:数据库
    a.通过MQ的方式异步去扣紧库存
        当然在我们的这种秒杀场景,其实不会跟数据库去打太多的交道,我们最多就是通过MQ的方式异步去扣紧库存
        所以,说在数据库这一端,我们只需要进行读写分离即可,当然如果你的数据量很大的话
    b.分库分表
        那我们也需要去采用一些分库分表的手段来进行优化

3.6 [2]优惠券系统

01.需求分析
    目标:为业务服务,满足用户和运营人员的需求
    用户功能:领取优惠券、使用优惠券、优惠券列表展示
    管理功能:优惠券信息配置、发布、查看使用数据统计

02.系统设计
    优惠券类型设计:设计多种类型的优惠券(满减券、折扣券、兑换券),明确适用范围和规则
    领取方式设计:用户主动领取和系统被动发放
    数据库设计:记录优惠券发放、使用、核销信息,确保数据安全性和可靠性
    系统架构:采用微服务架构,提高系统扩展性和稳定性,支持高并发和大规模数据处理
    用户界面设计:设计简洁易用的界面,确保用户体验友好

03.功能实现
    优惠券发放:通过后台或API实现批量和个性化发放
    优惠券领取:用户参与活动或满足条件后领取优惠券
    优惠券使用:购物过程中使用优惠券,系统自动计算优惠金额
    优惠券核销:核销已使用的优惠券,确保有效性
    数据统计与分析:统计和分析优惠券数据,为优化策略提供依据
    分布式架构考虑:处理分布式事务、锁、ID、分库分表等问题,结合项目和公司技术架构进行技术选型

04.系统监控
    工具:使用Prometheus、Grafana、Zabbix进行系统监控
    目标:确保系统稳定运行,及时发现和解决问题

3.7 [3]打车系统

01.需求分析
    网约车系统(比如滴滴)的核心功能是把乘客的打车订单发送给附件的网约车司机,司机接单后,到上车点接送乘客,乘客下车后完成订单
    其中,司机通过平台约定的比例抽取分成(70%-80%不等),乘客可以根据第三方平台的信用值(比如支付宝)来开通免密支付,在下车后自动支付订单
    乘客和司机都有注册登录功能,分属于乘客用户模块和司机用户模块。网约车系统的另外核心功能是乘客打车,订单分配,以及司机送单

02.概要设计
    a.乘客视角
        如上所示:乘客在手机 App 注册成为用户后,可以选择出发地和目的地,进行打车
        打车请求通过负载均衡服务器,经过请求转发等一系列筛选,然后到达 HTTP 网关集群,再由网关集群进行业务校验,调用相应的微服务
        例如,乘客在手机上获取个人用户信息,收藏的地址信息等,可以将请求转发到用户系统。需要叫车时,将出发地、目的地、个人位置等信息发送至打车系统
    b.司机视角
        如上图所示:司机在手机 App 注册成为用户并开始接单后,打开手机的位置信息,通过 TCP 长连接定时将自己的位置信息发送给平台,同时也接收平台发布的订单消息
        司机 App 采用 TCP 长连接是因为要定时发送和接收系统消息,若采用 HTTP 推送
        一方面对实时性有影响,另一方面每次通信都得重新建立一次连接会有失体面(耗费资源)
        司机 App:每 3~5 秒向平台发送一次当前的位置信息,包括车辆经纬度,车头朝向等。TCP 服务器集群相当于网关,只是以 TCP 长连接的方式向 App 提供接入服务,地理位置服务负责管理司机的位置信息
    c.订单接收
        网关集群充当业务系统的注册中心,负责安全过滤,业务限流,请求转发等工作
        业务由一个个独立部署的网关服务器组成,当请求过多时,可以通过负载均衡服务器将流量压力分散到不同的网关服务器上
        当用户打车时,通过负载均衡服务器将请求打到某一个网关服务器上,网关首先会调用订单系统,为用户创建一个打车订单(订单状态为 “已创建”),并存库
        然后网关服务器调用打车系统,打车系统将用户信息、用户位置、出发地、目的地等数据封装到一个消息包中,发送到消息队列(比如 RabbitMQ),等待系统为用户订单分配司机
    d.订单分配
        订单分配系统作为消息队列的消费者,会实时监听队列中的订单。当获取到新的订单消息时,订单分配系统会将订单状态修改为 “订单分配中”,并存库
        然后,订单分配系统将用户信息、用户位置、出发地、目的地等信息发送给订单推送 SDK
        接着,订单推送 SDK 调用地理位置系统,获取司机的实时位置,再结合用户的上车点,选择最合适的司机进行派单,然后把订单消息发送到消息告警系统。这时,订单分配系统将订单状态修改为 “司机已接单” 状态
        订单消息通过专门的消息告警系统进行推送,通过 TCP 长连接将订单推送到匹配上的司机手机 App
    e.拒单和抢单
        订单推送 SDK 在分配司机时,会考虑司机当前的订单是否完成。当分配到最合适的司机时,司机也可以根据自身情况选择 “拒单”,但是平台会记录下来评估司机的接单效率
        打车平台里,司机如果拒单太多,就可能在后续的一段时间里将分配订单的权重分数降低,影响自身的业绩
        订单分派逻辑也可以修改为允许附加的司机抢单,具体实现为
        当订单创建后,由订单推送 SDK 将订单消息推送到一定的地理位置范围内的司机 App,在范围内的司机接收到订单消息后可以抢单,抢单完成后,订单状态变为“已派单”

03.详细设计
    a.长连接的优势
        除了网页上常用的 HTTP 短连接请求,比如:百度搜索一下,输入关键词就发起一个 HTTP 请求,这就是最常用的短连接
        但是大型 APP,尤其是涉及到消息推送的应用(如 QQ、微信、美团等应用),几乎都会搭建一套完整的 TCP 长连接通道
        -----------------------------------------------------------------------------------------------------
        相比短连接,长连接优势有三:
        连接成功率高
        网络延时低
        收发消息稳定,不易丢失
    b.长连接管理
        前面说到了长连接的优势是实时性高,收发消息稳定,而打车系统里司机需要定期发送自身的位置信息,并实时接收订单数据,所以司机 App 采用 TCP 长连接的方式来接入系统
        和 HTTP 无状态连接不同的是,TCP 长连接是有状态的连接。所谓无状态,是指每次用户请求可以随意发送到某一台服务器上,且每台服务器的返回相同,用户不关心是哪台服务器处理的请求
        当然,现在 HTTP2.0 也可以是有状态的长连接,我们此处默认是 HTTP1.x 的情况
        而 TCP 长连接为了保证传输效率和实时性,服务器和用户的手机 App 需要保持长连接的状态,即有状态的连接
        所以司机 App 每次信息上报或消息推送时,都会通过一个特定的连接通道,司机 App 接收消息和发送消息的连接通道是固定不变的
        因此,司机端的 TCP 长连接需要进行专门管理,处理司机 App 和服务器的连接信息
        -----------------------------------------------------------------------------------------------------
        为了保证每次消息的接收和推送都能找到对应通道,我们需要维护一个司机 App 到 TCP 服务器的映射关系,可以用 Redis 进行保存
        当司机 App 第一次登录,或者和服务器断开连接(比如服务器宕机、用户切换网络、后台关闭手机 App 等),需要重连时
        司机 App 会通过用户长连接管理系统重新申请一个服务器连接(可用地址存储在 Zookeeper 中)
        TCP 连接服务器后再刷新 Redis 的缓存。
    c.地址算法
        当乘客打车后,订单推送 SDK 会结合司机所在地理位置,结合一个地址算法,计算出最适合的司机进行派单
        目前,手机收集地理位置一般是收集经纬度信息。经度范围是东经 180 到西经 180,纬度范围是南纬 90 到北纬 90
        我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成4个部分
        根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识司机和乘客的位置信息。再通过 Redis 的 GeoHash 算法,来获取乘客附加的所有司机信息
        GeoHash 算法的原理是将乘客的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有司机
        它的实现用到了跳表数据结构,具体实现为:
        将某个市区的一块范围作为 GeoHash 的 key,这个市区范围内所有的司机存储到一个跳表中,当乘客的地理位置出现在这个市区范围时,获取该范围内所有的司机信息。然后进一步筛选出最近的司机信息,进行派单
    d.体验优化
        a.距离算法
            作为线上派单,通过距离运算来分配订单效果一定会比较差,因为 Redis 计算的是两点之间的空间距离,但司机必须沿道路行驶过来,在复杂的城市路况下,也许几十米的空间距离行驶十几分钟也未可知
            所以,后续需综合行驶距离(而非空间距离)、司机车头朝向以及上车点进行路径规划,来计算区域内每个司机到达乘客的距离和时间
            更进一步,如果区域内有多个乘客和司机,就要考虑所有人的等待时间,以此来优化用户体验,节省派单时间,提升盈利额
        b.订单优先级
            如果打车订单频繁取消,可根据司机或乘客行为进行判责。判责后给乘客和司机计算信誉分,并告知用户信誉分会影响乘客和司机的使用体验,且关联到派单的优先级
            -------------------------------------------------------------------------------------------------
            司机接单优先级
            综合考虑司机的信誉分,投诉次数,司机的接单数等等,来给不同信誉分的司机分配不同的订单优先级
            -------------------------------------------------------------------------------------------------
            乘客派单优先级
            根据乘客的打车时间段,打车距离,上车点等信息,做成用户画像,以合理安排司机,或者适当杀熟

3.8 [3]短链系统

01.什么是短链接和长链接
    a.为什么需要短链接?
        a.短链通常较短,字符数量少,更加节省字数
        b.各种平台发文有字数限制
        c.节约成本,如短信发送时短链接就不需要拆成多条文本发送
        d.占用空间少
    b.状态码301和302的区别
        a.状态码301
            301 是永久重定向
            301 跳转会默认被浏览器缓存,当用户第一次访问某个短链后,如果服务器返回 301 状态码
            则这个用户在后后续多次访问同一短链接地址,浏览器会直接请求缓存中的跳转地址
            不会再请求短链服务重新获取地址。这么做的优点是降低了服务器的压力,但是无法统计短链接的点击次数
        b.状态码302
            302是临时重定向
            302 跳转默认不会被浏览器缓存,除非提示浏览器缓存
            因此用户每次访问同一短链地址,浏览器都会去duan链服务器上重新取长链接的地址
            此方式优点是能够统计到短链接被点击的次数,但是服务器的压力变大了

02.实现短链接和长链接的映射
    a.哈希法
        哈希法推荐 Google 出品的 MurmurHash 算法,MurmurHash 是一种非加密型哈希函数,非加密性能较高
        将长链接通过哈希算法映射成一个短值
        当前请求短链服务器的时候(https://www.longxia/v/short?id=longxia)
        通过id=longxia到数据匹配到长链接,然后将长链接返回
        哈希法的会存在哈希冲突的问题(即就是两个不同的URL可能会生成相同的短链接),为了解决这个问题我可以采用增加salt字段
        将短链字段设置为唯一键,重复短链插入数据库抛出异常时候进行处理: 增加盐值到长链后面,然后重新使用这个拼接的长链进行hah计算
        此方式最多重试三次,尽最大努力解决哈希冲突问题。访问短链时,一并取出salt值,将长链处理后进行返回
    b.id映射方案
        a.说明
            可以使用Mysql的自增主键值来映射长链接
            当访问短链接(https://www.longxia/v/short/1)可以通过这个短链后面的1来定位到长链,然后返回给服务消费方
        b.数据库自增id映射方案的缺点:
            a.id太规律了
                容易被攻击
            b.id自增到一定程度后,数字还是很长
                由于主键id直接暴露存在一定的问题,所以使用redis、uuid、雪花算法等替换方案
            c.redis方案
                在Redis中创建一个键,使用INCR命令递增该键的值,并将递增后的值作为唯一ID返回
                由于Redis的INCR命令是原子性操作,所以可以确保每次生成的ID都是唯一的
                为了增加ID的安全性,一般不建议使用Redis自增的数值,而是拼接一些其它信息,方案如下
            d.uuid方案
                 UUID是通过一系列算法生成的128位数字,通常基于时间戳、计算机硬件标识符、随机数等元素
                 uuid方案实现简单,无需网络交互就能保证了ID的唯一性
            e.雪花算法方案
                雪花算法生成一个64位的大小的整数
                雪花算法的生效id的效率高,但是雪花算法要避免时钟回拨的问题(会出现id重复的问题)

03.设计短链服务
    1.LVS使用keepalived来保证高可用,LVS是工作在第四层并且其负载能力强,它负责将请求分发到nginx上
    2.nginx单机的并发5万,针对百万的并发至少需要20台nginx来处理大的流量,nginx将请求转发到公司的网关上
    3.网关根据服务的URL来解析地址找到对应的服务器,由于是百万流量所以网关也需要做集群来处理请求
    4.网关将请求转发到真实的短链服务上,短链服务自身使用了sentinel来限流、使用本地缓存(常见的是Guava、caffeine)、分布式缓存(如redis)来缓存数据,使用布隆过滤器过滤无效的请求
    5.有效的请求未命中缓存,此时就查询数据库,由于数据库抗并发能力弱,所以对数据库做了主从模式、读写分离方式来应对高并发
    6.数据库中查询出来的数据要同步到缓存中,以便于下次同样的短链请求可以不查询数据库而直接给消费者提供数据响应

3.9 [3]健康码

00.总结
    个人信息登记注册以及修改:由用户端驱动,既有读操作也有写操作,实时性要求较高,写操作需要立即得到结果,但是并发量不大(毕竟大家同时修改个人信息的概率比较低)
    个人健康信息查询:由用户端驱动,只有读操作,实时性要求较高,并发量比较大(因为大家同时刷健康码的概率非常大,这次崩溃的就是这个服务)
    个人行程信息记录:由用户端驱动,只有写操作,写操作不需要立即得到结果给用户,实时性要求较不高,并发量不大(毕竟疫情期间蜂拥而出的情况不多)
    后台修改个人数据:非用户驱动,应该是由后台的 job 或者相关工作人员来驱动的,只有写操作,并发量不大(毕竟非用户驱动的操作还是可控的)

01.数据中心
    对于这种 mission critical 的系统还是建议从数据中心的角度建立多个 site,每个数据中心的接入点都申请不同的 FQDN 域名,从接入层就利用 DNS 的来分流到多个数据中心
    当然针对这个需求,这里可以不必那么复杂,不必引入 GTM 把流量基于地理位置分发到不同的地区的数据中心,毕竟大家都在一个地区

02.接入层负载均衡以及CDN
    对于每个数据中心的服务来说一定是有负载均衡的。负载均衡基于不同的维度有很多种类,有三四层负载均衡,七层负载均衡,基于应用的负载均衡,基于操作系统内核的负载均衡,还有基于硬件的负载均衡
    这个系统在接入层也不需要有复杂的负载均衡策略,可以追求速度,所以可以选择更快的三四层负载均衡,或者硬件负载均衡
    另外系统一定是有静态资源的,例如图片或者 html/css 等等,这些资源可以完全放在 CDN 来管理,以减轻系统负载,加速静态资源访问

03.服务层拆分
    经过上面的需求分析,我们可以根据基本需求的读写特性和并发量从业务上拆分不同的服务
    个人信息登记注册以及修改:读写实时性较高,但是并发量不大,所以这个服务可以直接访问我们的存储 storage
    个人健康信息查询:并发量比较大,这个服务不可以直接访问我们的存储,需要引入缓存来加速访问
    个人行程信息记录:写操作不需要立即得到结果给用户,实时性要求较不高,并发量不大,所以可以引入消息队列 MQ 来加速并解耦这个服务和存储
    后台修改个人数据:和上面的个人行程信息记录一样
    上面的服务层一定需要有快速的动态扩容和发布的能力,所以可以考虑基于当前比较流行的 Kunbernetes 平台或者 Service Mesh 平台。另外对于服务的协议,如果追求速度可以考虑使用二进制的 RPC 协议(例如 GRPC)来代替传统的 HTTPS+JSON 格式的协议

04.缓存的引入
    缓存容量:西安常住人口大约 1200万 人,一个人分配 10KB 的缓存估算,大约就需要 120GB,在加上 25% 的 Buffer,所以需要大约总共 150GB 的缓存。当然这么大的缓存不可能是单机的,一定是分布式的,需要利用一些基于缓存数据分片的 sharding 方式把他们均匀的缓存在不同的机器上
    缓存预加载:我们不可以指望通过应用程序查询缓存,发现缓存里没有数据的时候,再从存储里获取,放到缓存里,这样在高并发的时候会有大量请求直接访问存储,导致响应比较慢,所以需要有缓存的预加载过程,当然我们可以基于数据 sharing 分片的方式去加载,例如可以基于人所属的区域,分不同的批次做,这样也能提高效率
    缓存击穿:如果查询一个不存在的对象,例如不存在的缓存 key,即使缓存里没有也依然会去访问存储的。所以对于缓存击穿的情况,我们可以给它设置一个短暂的缓存时间,以及一个空的值
    缓存雪崩:当我们设置缓存的时候,如果不注意缓存过期时间,当同一时刻大批量的缓存失效时,就会有大量的访问同时进入存储。所以我们可以基于数据 sharing 分片设置不同的缓存时间。另外我们还可以有一个缓存续约服务,对于那些没有数据更新的缓存,定期批量地延长缓存时间。当然这个服务也可以基于数据 sharing 分片提高效率
    缓存同步:有缓存就有缓存同步的问题,我们可以引入缓存同步服务,来定期把有更改的数据批量同步到缓存里。当然这里的数据一定不是实时性要求很高的数据,比方说红绿码变更,近期核酸检测结果等。对于实时性高的数据,例如个人信息登记和修改,一定是要同时更新存储和缓存的

05.存储的引入
    对于存储这部分,数据量一定是比较大的,而且根据不同时期的防御政策一定会有不同的动态数据加入,数据结构变化可能比较频繁,所以可以引入 NoSQL 来做数据存储。另外,不仅仅有存储的问题,也一定会有大数据的分析需求,有实时性要求比较高的流处理,可以进行等待的批处理,以及将数据汇报给国家防疫平台的处理等,这里我们不展开讨论

06.监控和预警的引入
    对于这种 mission critical 的系统一定要有完善的监控和预警,需要从不同维度上来对整个系统来监控和预警的引入
    基础设施和操作系统维度:也就是我们经常会提到的计算维度的 CPU、存储维度的 Memory/Disk、网络维度的吞吐量等等
    中间件维度:对各种中间件的监控,例如缓存、线程池、连接池、数据库、消息队列、应用服务器、负载均衡器等等
    应用程序维度:对应用程序本身的监控,也就是我们常常所说的 APM 这个概念,可以更细节地了解应用本身的运行

3.10 [4]RPC框架

00.背景
    RPC全程为Remote procedure call 远程过程调用,简单的说就是:像调用本地方法一样调用远程服务】

01.案例
    a.一个用户操作服务
        public interface UserService{
            String findUserNameById(Integer userId);
        }
        @Service
        public class UserServiceImpl implements UserService{
            String findUserNameById(Integer userId){
                //查数据或查缓存获取到用户名
                return "田哥"
            }
        }
    b.现在一个controller里想调用UserServiceImpl的findUserNameById方法获取用户名。
        @RestController
        public class UserController{
            @Resource
            private UserService userService;

            @GetMapping("/test")
            public String test(){
                return userService.findUserNameById(1);
            }
        }
    c.说明
        假设UserController、UserServiceImpl、UserService三个类都在同一个项目里,controller想调用findUserNameById方法是非常简单的
        但是,如果controller是另外一个项目,也想像上面那样调用(细微的不同,感觉还是一样),这时候我们就可以用到了RPC框架
        1.需要把接口UserService放在一个单独项目里,我们通常称之为api项目
        2.需要把UserServiceImpl放在一个单独项目里,我们通常称之为叫做provider项目
        3.controller嘛,通过是web之类的(consumer)项目里
        4.把api打成jar包,然后consumer项目、provider项目都引用它
        市面上的RPC框架,比如说:Dubbo (阿里)、Thrift(FaceBook)、gRpc(Google)、brpc (百度)等都在不同侧重点去解决最初的目的,有的想极致完美,有的追求极致性能,有的偏向极致简单

02.RPC原理
    a.回到前面我们说的像调用本地一样的调用远程服务,到底需要哪些技术支撑呢?
        动态代理,因为我们consumer项目里只有接口UserService定义,没有实现类,想要调用一个接口的方法?那就只能搞个代理对象了
        编解码,也就是consumer需要把请求参数传到provider里去,网络传输过程先把我们的参数进行编码,然后传到provider,provider再对传过来的参数进行解码
        网络通信,跨进程肯定就会涉及到网络通信
        网络传输协议,consumer和provider 对于来回的参数信息,肯定得有有个标准,你按照什么样的方式传给我,不然我怎么知道你想表达什么
        序列化和反序列化
        数据压缩,在进行数据网络传输时,如果数据太大了,我们得考虑能不能对数据进行压缩
        注册中心,如果provider 进行集群部署(同样服务部署多个机器上),这时候我们需要在consumer进行手工维护,如果量一旦大起来了,这工作量可想而知
        动态感知服务上下线,如果provider 存在宕机或者部署了新的节点,这时候consumer就需要知道哪些服务下线了,哪些服务上线了
        动态路由,如果provider 进行集群部署(同样服务部署多个机器上),我们不可能让consumer都落在同一个节点上,这样资源不能充分利用了。动态路由那就会涉及到各种各样的算法,比如随机,轮询,权重等
    b.为什么需要注册中心?
        略

03.自制RPC:mink框架
    mink框架用到的技术点(后期会加入线程池,其他序列化技术、监控):
    工厂模式、动态代理、模板方法模式、注册式饿汉式单列模式
    底层通信用的是netty框架、自定义协议、序列化与反序列化(java原生态和json)、编解码
    集成SpringBoot、Spring扩展点
    集成zookeeper实现服务注册、服务上下线动态感知
    自定义注解、自定义String工具类、自定义Collection工具类、自定义数组工具类等

3.11 [4]注册中心

00.总结
    服务注册
    服务消费:consumer如何知道provider
    注册中心高可用:服务注册中心如何高可用
    动态感知服务上下线:服务上下线,消费端如何动态感知

01.背景
    a.怎么调用?
        方法1:商品系统开发的朋友告诉你对应的地址。
        方法2:商品系统开发的朋友把对应API地址存放到某个地方。
        方法3:直接通过Nginx,使用域名进行转发到某个实例上。
    b.问题来了
        实际线上环境中,很少是单体机构的,很多都是做了集群的,也就是说每个服务会有N个实例,少则几个几十个,
        多则几百上千上万。如果此时我们还用上面三种方法,当我们的商品系统某个服务下线(宕机了),
        或者新增实例,此时是非常的头疼。所以,注册中心就来了。
    c.注册中心来了
        我们能不能搞一个第三方的节点,这个节点就用来存放我们商品系统的服务信息,这样一来,其他系统需要服务信息,
        直接去第三方节点上去获取即可。此时,其他系统只要知道这个第三方的节点地址就可以了。这个第三方的节点,
        我们也称之为注册中心。下面我们用服务提供方(商品系统)称之为provider,服务调用方(订单系统)我们称之为consumer。

02.如何设计一个注册中心
    a.服务注册
        当我们把服务信息注册上去后
        服务列表保存通常有三种方式:本地内存、数据库、第三方缓存系统注册上去后,
        consumer需要服务地址的时候,就可以用相应key去注册中心获取对应的服务列表。
        同一个服务注册中心,我们可以注册多个服务,比如用户服务、商品服务、订单服务...
    b.服务消费:consumer如何知道provider
        consumer端通过key获取指定的服务地址列表。
        以上的还是蛮简单的吧,简单来说,我们就是引用了一个第三方的服务来存放我们的服务提供者列表。
        并且以key-value的形式存储,key我们可以理解为服务名称,就是服务实例列表
    c.注册中心高可用:服务注册中心如何高可用
        高可用无非就是做集群,我们可以对注册中心部署多个节点。
        在消费端consumer只需要知道一个服务注册中心集群地址cluster-url即可。
        是AP模型还是CP模型?强一致性还是最终一致性?可以参照Nacos和Eureka原理
    d.动态感知服务上下线:服务上下线,消费端如何动态感知
        consumer拿到服务列表后,会把服务列表保存起来,保存到本地缓存里。
        consumer通过一定的负载均衡算法,选择出一个地址,最后发起远程的调用。
        -----------------------------------------------------------------------------------------------------
        如果我们的服务节点挂掉一个了,怎么办?
        此时,服务注册中心的服务列表还是之前的列表,如果consumer调用到过掉的节点上,那岂不是会出问题呀。
        所以,我们的服务注册中心需要知道哪个服务节点挂了,然后从对应服务列表里删除。
        有种办法叫做心跳检测heartBeat,即就是服务注册中心,每隔一定时间去监测一下provider,
        如果监测到某个服务挂了,那就把对应服务地址从服务列表中删除。根据心跳检测,来提出无效服务。
        可是不对呀,此时consumer端本地列表里还有过掉的服务地址,怎么办呢?或者是,在增加一个新的服务节点
        -----------------------------------------------------------------------------------------------------
        对于服务注册中心来说,就是服务列表里增加一个服务地址。但是在消费端存在同样的问题,
        就是服务注册中心的服务列表和consumer端的服务列表不一样了。如何让consumer端也动态感知呢?
        其实很简单,此时,我们得思维换一下,因为consumer的服务列表是来自于服务注册中心,
        我们就可以把consumer理解为消费端,服务注册中心理解为服务端。此时,consumer端就可以去服务端
        (服务注册中心)拉取provider服务列表。通常有两种方案:push和pull
        push:服务注册中心主动推送服务列表给consumer。
        pull:consumer主动从注册中心拉取服务列表。
        -----------------------------------------------------------------------------------------------------
        不管是push还是pull,都会存在consumer和服务注册中心的通信管道。如果他们之间断开了,那就无法获取服务列表了。
        还有就是服务注册中心知道consumer的地址,比如我得知道你的微信好友,不然我怎么把我手里的资源发给你我们的网络通信,
        必然会存在监听的动作。如果服务注册中心要push到consumer,此时他们之间需要建立一个会话,
        -----------------------------------------------------------------------------------------------------
        所以,在服务注册中心会维护一个会话管理的模块。
        还有一种方式就是consumer提供一个API,这个API给服务注册中心进行回调。
        本质是我们是使用HTTP协议还是使用Socket监听push有个不好点,那就是服务注册中心需要维护大量的会话,
        而且还需要对每个会话维持一个心跳,一遍知晓这些会话状态,得确保这些consumer能收到数据,
        另外就是pull,pull其实就相对push就简单多了。pull和我们前面说的心跳机制是类似的,
        consumer端启动定时任务,每个多久拉取服务注册中心的服务列表。pull也不需要去维护大量的会话,
        我只需要每隔多久调用接口拉取服务列表即可。但是这里还是会存在一个问题,
        因为是定时去拉取,所以会存在一定的数据延迟,比如consumer刚刚拉取服务列表,
        但就在拉取结束的后,某个服务provider挂了,consumer就要等下次拉取才知道对应服务provider挂了。
        -----------------------------------------------------------------------------------------------------
        如果定时任务是每隔30秒拉去一次,那就是说,延迟最长时间是30秒。
        还有一种方式long-pull,也叫长轮询,是上面两种方案的优化方案,consumer发起拉取请求时,
        先把这个请求hold住,当服务注册中心有发生变化后,consumer端能立马感知。
        -----------------------------------------------------------------------------------------------------
        关于长轮询:与简单轮询相似,只是在服务端在没有新的返回数据情况下不会立即响应,
        而会挂起,直到有数据或即将超时优点:实现也不复杂,同时相对轮询,节约带宽缺点:
        还是存在占用服务端资源的问题,虽然及时性比轮询要高,但是会在没有数据的时候在服务端挂起,
        所以会一直占用服务端资源,处理能力变少应用:一些早期的对及时性有一些要求的应用:web IM 聊天这样,
        我们就搞定了所谓的服务上下线动态感知。通过上面的服务注册、服务消费、注册中心高可用以及动态感知服务的上下线,
        这就是我们去实现一个服务注册中心的通用模型。

3.12 [4]消息队列

00.总结
    服务端:那么为什么需要Broker呢?
    存储
    主从
    分片
    注册中心
    后台管理

02.设计队列
    a.服务端
        我们从日常使用消息队列来入手,看设计一个消息队列到底要有哪些关键的点。
        当你要用消息队列的时候首先肯定是下载部署包,然后部署在服务器上。
        部署的这个程序我们就理解它是消息队列的服务端程序。在其他消息队列里面都有一个固定的名称:Broker。
        -----------------------------------------------------------------------------------------------------
        那么为什么需要Broker呢?
        你的消息要发送出去,必然得有接收方,这个接收方就是Broker。
        Broker收到消息后不是直接转给消费方,而是要先落盘,存储起来。这样才能保证消息不丢失,不影响业务。
        同时还有一些其他的业务操作,比如消息的查询。
    b.存储
        既然说到存储,我们做业务的时候,都会用三方存储,也就是数据库,比如Mysql。
        但是MQ的存储,基本上都不会用三方存储,而是直接采用写磁盘的方式,
        也就是自己要设计要存储格式,自己写,自己解析等等一系列操作。
        -----------------------------------------------------------------------------------------------------
        当然,也不是说不能用三方存储去实现,下篇文章我们再给大家讲讲如何用数据库做消息队列的存储。
        用数据库做存储其实也就是利用已有的实现来解决复杂度,涉及到底层存储这块,
        而且还要考虑高性能,其实对技术要求很高的。
        -----------------------------------------------------------------------------------------------------
        像RocketMQ中的存储就涉及到CommitLog,ConsumeQueue,IndexFile等概念。
        最重要的是磁盘操作我们都知道很慢,而我们经常用的Mysql为了提高性能也是有一套很复杂的设计,
        比如redo log,buffer pool等,所以如果直接用数据库做存储,是不是相当于站在巨人的肩上去摘果实呢!
    c.主从
        我们设计了一个Broker,使用过程中万一这个Broker挂掉了怎么办?这里是不是得考虑下高可用性,所以Broker还需要有主从的设计。
        主节点的数据会同步给从节点,主节点出问题后,从节点可以顶上来提供服务,同时从节点也可以提供读的操作,为主节点减轻压力。
    d.分片
        一个Broker是部署在某一台服务器上面,这个服务的磁盘存储空间是有限制的,不可能无限扩容。所以当消息量很大的时候,如果只是一直往机器的本地磁盘写数据,最终会写不进去的。
        在设计的时候还要考虑数据分片的场景,一个Topic的数据可以分成很多份进行存储,分别存储在不同的Broker上,这样当磁盘不够的时候,可以通过增加Broker的节点来扩容。
        -----------------------------------------------------------------------------------------------------
        那么问题来了,客户端写入的时候怎么知道这个Topic有哪些分片的存储信息,怎么知道有哪些Broker是在线的呢?
        这就要引入另一个设计:注册中心,在RocketMq中叫NameServer。
    e.注册中心
        NameServer叫注册中心或者路由中心都可以,本质上都一样。Broker启动的时候需要将自身的信息告诉NameServer,同时也要保持一个心跳检查,这样NameServer才能知道Broker当前是否处于正常状态。
        NameServer也要支持水平扩展,这样才能保证高可用性。既然要支持水平扩展,那么必然得无状态才行,但是NameServer本身就会存储一些数据,比如Broker信息。
        -----------------------------------------------------------------------------------------------------
        这里有几个实现方式:
        Broker启动的时候轮流向所有的NameServer进行注册,这样每个NameServer中都有全量的信息,即使某个节点挂了也不影响。RocketMQ就是使用的这种方式。
        Broker启动的时候只向某一台NameServer进行注册,立马返回,然后NameServer之间再进行相互同步,Eureka就是使用的这种方式。
        Broker启动的时候只向某一台NameServer进行注册,NameServer会同步向其他的NameServer进行数据的同步操作,等待所有写入成功或者半数写入成功,然后再返回给客户端。Zookeeper就是使用的这种方式。
        服务端有了,还有一个必须要有的设计就是SDK了。应用程序通过依赖SDK就可以直接发送消息和消费消息。SDK同时可以考虑支持多语言,这样使用场景更广泛。
        SDK主要是用来跟Broker通信的,所以对于网络通信我们也要选择一个合适的框架,比如Netty就非常合适,你要是觉得太难,直接用Http协议也可以,或者直接支持多协议,这些都是需要考虑的场景。
    f.后台管理
        后台管理可以实现很多治理的工作,方便我们在使用消息队列的时候去排查各种问题。
        核心功能点:
        当前集群状态的查看
        消息的查询
        消息的消费轨迹查询
        消息的重复投递
        消息生产的监控大盘
        消息消费的监控大盘
        SDK消费线程数的动态调整