JB3-4-SpringBoot(五)

Java道经第3卷 - 第4阶 - SpringBoot(五)


传送门:JB3-4-SpringBoot(一)
传送门:JB3-4-SpringBoot(二)
传送门:JB3-4-SpringBoot(三)
传送门:JB3-4-SpringBoot(四)
传送门:JB3-4-SpringBoot(五)
传送门:JB3-4-SpringBoot(六)

S14. 整合支付宝沙箱

武技:开发 springboot-alipay 子项目

  1. 引入三方依赖:
<dependencies>
	<!--spring-boot-starter-web-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<!--qrcode:hutool工具的二维码需要该依赖-->
	<dependency>
		<groupId>com.google.zxing</groupId>
		<artifactId>core</artifactId>
		<version>${qrcode-core.version}</version>
	</dependency>
	<!--alipay-easysdk:支付宝简易版,二选一即可-->
	<dependency>
		<groupId>com.alipay.sdk</groupId>
		<artifactId>alipay-easysdk</artifactId>
		<version>${alipay-easysdk.version}</version>
	</dependency>
	<!--alipay-sdk-java:支付宝通用版,二选一即可-->
	<dependency>
		<groupId>com.alipay.sdk</groupId>
		<artifactId>alipay-sdk-java</artifactId>
		<version>${alipay-sdk-java.version}</version>
	</dependency>
</dependencies>
  1. 开发主配文件:
server:
  port: 13415 # 项目端口
spring:
  application:
    name: springboot-alipay # 项目名
  web:
    resources:
      static-locations: classpath:/static/ # 静态资源路径
  1. 开发启动类:
package com.joezhou;

/** @author 周航宇 */
@SpringBootApplication
public class AlipayApp {
    public static void main(String[] args) {
        SpringApplication.run(AlipayApp.class, args);
    }
}

E01. 二维码工具

1. 下载二维码文件

武技:开发下载二维码文件到本地的 API 接口

  1. 开发控制器:GET 请求和 POST 请求均可:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/qr")
public class QrTestController {

    /** 生成二维码图片,并下载到本地 */
    @GetMapping("/genQrCodeToFile")
    public Object genQrCodeToFile() {
        QrConfig qrConfig = new QrConfig();
        // 二维码纠错级别:L(Low)< M(Medium)< Q(Quartile)< H(High)
        // 级别越低,像素块越大,识别距离越远,可容忍的遮挡范围越小
        qrConfig.setErrorCorrection(ErrorCorrectionLevel.L);
        // 二维码宽度,高度和颜色
        qrConfig.setWidth(200);
        qrConfig.setHeight(300);
        qrConfig.setBackColor(Color.white);
        // 文件存在时删除
        File file = new File("D:\\qr.jpg");
        if (file.exists()) {
            file.delete();
        }
        // 生成二维码到指定文件
        QrCodeUtil.generate("http://www.baidu.com", qrConfig, file);
        return "二维码生成完毕,请查看 D:\\qr.jpg 文件";
    }
}
  1. 测试控制器:
# 访问 genQrCodeToFile 方法
GET http://localhost:13415/api/v1/qr/genQrCodeToFile

2. 响应二维码图片

武技:开发响应二维码图片到前端的 API 接口

  1. 开发控制器:GET 请求和 POST 请求均可:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/qr")
public class QrTestController {

	/** 生成二维码图片,并响应给前端 */
    @SneakyThrows
    @GetMapping("/genQrCodeToFront")
    public void genQrCodeToFront(HttpServletResponse resp) {

        // 设置响应头:响应类型为图片,不缓存(addHeader 项是为了兼容老版本浏览器)
        resp.setContentType(MediaType.IMAGE_JPEG_VALUE);
        resp.setDateHeader("Expires", 0);
        resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        resp.addHeader("Cache-Control", "post-check=0, pre-check=0");

        // 生成二维码图片
        String content = "http://www.baidu.com";
        QrConfig qrConfig = new QrConfig(500, 500);
        BufferedImage bufferedImage = QrCodeUtil.generate(content, qrConfig);

        // 将图片写入响应输出流
        try (ServletOutputStream outputStream = resp.getOutputStream()) {
            ImageIO.write(bufferedImage, "jpg", outputStream);
            outputStream.flush();
        }
    }
}
  1. 测试控制器:
# 访问 genQrCodeToFile 方法
GET http://localhost:13415/api/v1/qr/genQrCodeToFront

3. JS前端代码

武技:开发纯 JS 版前端测试代码,需要自行引入 AjaxUtil 工具。

  1. 封装 AjaxUtil 工具:
/*
 * AJAX远程调用,参数是一个JSON对象
 *
 * param url         请求地址,默认空字符串
 * param type        请求方式,可选 get/post,默认 get
 * param data        请求参数,仅在 post 请求时生效,必须是JSON格式
 * param contentType 请求参数的格式,可选 json/qs/file,默认 qs
 * param dataType    响应数据的格式,可选 json/text/blob,默认 json
 * param success     回调函数,函数的第一个参数为响应数据
 * param async       是否开启异步,默认 true
*/
function ajax(params) {

    // 创建AJAX核心对象: 该对象负责与服务器进行通信
    let xhr = new XMLHttpRequest();

    // 处理参数,添加默认值
    let url = params['url'] ? params['url'] : '';
    let type = params['type'] ? params['type'] : 'get';
    let contentType = params['contentType'] ? params['contentType'] : 'qs';
    let dataType = params['dataType'] ? params['dataType'] : 'json';
    let async = params['async'] ? params['async'] : true;

    // 挂载状态监听事件: 该事件在请求状态码发生改变时触发,必须写于 xhr.open() 之前
    xhr.onreadystatechange = () => {
        // 请求已完成,且响应成功,且存在回调函数
        if (xhr.readyState === 4 && xhr.status === 200 && params['success']) {
            // 根据 dataType 判断是否需要解析响应数据
            let resp;
            if ('json' === dataType) {
                resp = xhr.responseType;
            } else if ('text' === dataType) {
                resp = JSON.parse(xhr.responseText);
            } else if ('blob' === dataType) {
                resp = xhr.responseURL;
            }
            // 执行回调函数
            params['success'](resp);
        }
    }

    // 建立异步通道: 参数分别为请求类型,请求地址以及是否开启异步
    xhr.open(type, url, async);

    // 若请求类型为GET
    if ('get' === type) {
        // 发送GET请求,请求参数直接在URL后面附加查询串即可
        xhr.send();
    }
    // 若请求类型为POST
    else {
        // 表单数据,对应后台 `@RequestParam` 注解 + 简单类型参数
        if ('qs' === contentType) {
            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
            xhr.send(jsonToQs(params['data']));
        }
        // JSON数据,对应后台 `@RequestBody` 注解 + 实体类型参数
        else if ('json' === contentType) {
            xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
            xhr.send(jsonToStr(params['data']));
        }
        // 二进制数据,对应后台 `@RequestParam` 注解 + MultipartFile类型参数
        else if ('file' === contentType) {
            xhr.send(jsonToFormData(params['data']));
        }
    }
}

/*将JSON数据转为QS字符串*/
function jsonToQs(json) {
    return Object.keys(json).map(key => {
        if (Array.isArray(json[key])) {
            return json[key].map(e => key + '=' + e).join('&');
        }
        return key + '=' + json[key]
    }).join('&');
}

/*将JSON数据转为JSON字符串*/
function jsonToStr(json) {
    return JSON.stringify(json);
}

/*将JSON数据转为FormData*/
function jsonToFormData(json) {
    const formData = new FormData();
    Object.keys(json).map(key => {
        formData.append(key, json[key]);
    });
    return formData;
}
  1. 开发前端页面 static/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app">
    <div style="border:1px solid red;">
        <img src="/api/v1/qr/test" alt="">
        <img id="img" alt="" src=""/>
    </div>
    <button onclick="openQrCode()">打开二维码</button>
</div>

<script src="ajax-util.js"></script>
<script>
    function openQrCode() {
        ajax({
            url: '/api/v1/qr/test',
            dataType: 'blob',
            success: function (res) {
                document.querySelector('#img').src = res;
            }
        })
    }
</script>
</body>
</html>

4. VUE前端代码

武技:开发 VUE 版前端测试代码。

let config = { responseType: 'blob' };
axios.get('/api/v1/qr/test', config).then(response => {
	const url = URL.createObjectURL(response.data);
	document.getElementById('qrCodeImg').src = url;
}).catch(error => console.error(error));

5. 微信小程序代码

武技:开发微信小程序测试代码。

// 订单创建完毕后,获取支付二维码
wx.request({
	url: GATEWAY_HOST + '/order-server/api/v1/order/getQrCode',
	method: 'POST',
	data: {
		'sn': sn,
		'payAmount': this.data.payAmount
	},
	header: {'token': wx.getStorageSync('token')},
	responseType: 'arraybuffer',
	success(res) {
		if (res.statusCode !== 200) {
			util.error('获取二维码失败');
			return;
		}
		// 显示二维码
		that.setData({
			'qrCodeImage': 'data:image/png;base64,' + wx.arrayBufferToBase64(res.data),
			'payDialogShow': true,
		});

		// 每隔2秒钟轮询查询订单状态
		let timer = setInterval(function () {
			api.get('order', '/selectBySn/' + sn).then(res => {
				if (res === true) {
					util.success('支付成功');
					clearInterval(timer);
					that.setData({'payDialogShow': false});
					// 跳转到订单页面
					util.tab('/pages/user/user');
				}
			}).catch(err => util.error('查询订单状态失败', err));
		}, 2000);
	},
});

E02. 内网穿透工具

1. 安装cpolar

心法:NAT 穿透,也叫内网穿透,主要目的就是让在局域网内部(内网)的设备能够被外部网络(公网)访问。

cpolar 极点云是一个优秀的内网穿透工具,只需一行命令,就可以将内网站点发布至公网,方便给客户演示,高效调试微信公众号、小程序、对接支付宝网关等云端服务,提高您的编程效率。

武技:安装 cpolar

  1. 登录 cpolar官网,注册登录,如 yy06200210@163.com/TMa 等。
  2. 选择购买 0 元套餐,然后点击 Download for Windows 按钮下载 cpolar 工具,解压缩后,直接双击运行安装。
  3. 持久化认证 Token 令牌到 cpolar.yml 文件中,该文件默认在 C:\Users\JoeZhou 目录中:
# 使用 CMD 执行如下命令
cpolar authtoken OTUyYjk4YTctZWYzMy00ZDcwLTgzOWYtYmE3ZTA4NWJmZjYw

2. 开发控制器

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@RestController
@RequestMapping("/api/v1/start")
public class StartController {

    @GetMapping("/hello")
    public Object hello() {
        return "success";
    }
}
  1. 测试控制器:
# 访问 hello 方法
GET http://localhost:13415/api/v1/start/hello

3. 运行cpolar

  1. 在 cpolar 的家目录开启 CMD 命令行,然后使用命令启动 cpolar 工具(不要双击启动):
# 开启内网穿透
cpolar http 13415
  1. 使用穿透地址访问控制方法(http开头的):
# 访问 hello 方法
GET http://xxxx.xx.cpolar.top/api/v1/start/hello

E03. 支付宝沙箱

心法:支付宝支付整体交互流程图

图

武技:准备支付宝沙箱数据

  1. 登录 支付宝开放平台:注册登录。在这里插入图片描述
  2. 点击 控制台 -> 沙箱,进入 沙箱应用控制台 页面,记录下 APPID 的值,密钥和网关地址。

在这里插入图片描述

  1. 下载沙箱工具(只有安卓能下载),然后使用沙箱账号进行登录:

在这里插入图片描述

1. 预支付工具类

武技:封装支付宝沙箱预支付工具类

package com.joezhou.util;

/** @author 周航宇 */
public class AlipayUtil {

    /** 应用ID */
    private static final String APPID = "9021000142628640";
    /** 异步通知接口(下单成功后支付宝回调) */
    private static final String NOTIFY_URL = "http://970c3c7.r5.cpolar.top/api/v1/order/notify";
    /** 支付宝公钥 */
    private static final String ALIPAY_PUBLIC_KEY = "xxx";
    /** 应用私钥 */
    private static final String MERCHANT_PRIVATE_KEY = "xxx";
    /** 单例的Alipay配置对象 */
    private static volatile Config config;
    /** 单例对外方法 */
    public static Config getConfig() {
        if (config == null) {
            synchronized (AlipayUtil.class) {
                if (config == null) {
                    config = new Config();
                    config.protocol = "https";
                    config.gatewayHost = "openapi-sandbox.dl.alipaydev.com";
                    config.signType = "RSA2";
                    config.ignoreSSL = true;
                    config.appId = APPID;
                    config.alipayPublicKey = ALIPAY_PUBLIC_KEY;
                    config.merchantPrivateKey = MERCHANT_PRIVATE_KEY;
                    config.notifyUrl = NOTIFY_URL;
                }
            }
        }
        return config;
    }
}

2. 开发预支付接口

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/order")
public class OrderController {

    @SneakyThrows
    @GetMapping("/prePay")
    public Object prePay(HttpServletResponse resp) {

        // 订单流水号:用于唯一标识每一笔交易。
        String outTradeNo = UUID.randomUUID().toString();
        // 交易的主题:通常是对交易内容的一个简要描述,比如购买的商品名称或服务名称等。
        String subject = "小米手机";
        // 交易总金额:以字符串形式传递,精确到小数点后2位。
        String totalAmount = "5000.50";
        // 初始化配置
        Factory.setOptions(AlipayUtil.getConfig());
        // 发起预支付请求
        AlipayTradePrecreateResponse alipayTradePrecreateResponse = Factory.Payment
                // 专门用于处理面对面支付业务
                .FaceToFace()
                // 创建一个预支付请求
                .preCreate(subject, outTradeNo, totalAmount);
        log.info("已发起预支付请求,请求参数:subject: {}, outTradeNo: {}, totalAmount: {}",
                subject, outTradeNo, totalAmount);
        // 解析预支付响应
        String httpBody = alipayTradePrecreateResponse.getHttpBody();
        JSONObject response = JSONUtil.parseObj(httpBody)
                .getJSONObject("alipay_trade_precreate_response");
        log.info("已收到预支付响应,响应内容:{}", response);
        // 响应成功:生成二维码图片
        if (response.get("code").toString().equals("10000")) {
            // 设置响应头:响应类型为图片,不缓存(addHeader 项是为了兼容老版本浏览器)
            resp.setContentType(MediaType.IMAGE_JPEG_VALUE);
            resp.setDateHeader("Expires", 0);
            resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
            resp.addHeader("Cache-Control", "post-check=0, pre-check=0");
            // 生成二维码图片
            String content = response.get("qr_code").toString();
            QrConfig qrConfig = new QrConfig(500, 500);
            BufferedImage bufferedImage = QrCodeUtil.generate(content, qrConfig);
            // 将图片写入响应输出流
            try (ServletOutputStream outputStream = resp.getOutputStream()) {
                ImageIO.write(bufferedImage, "jpg", outputStream);
                outputStream.flush();
            }
        }
        // 响应失败:返回响应体字符串
        return httpBody;
    }
}
  1. 测试控制器:查看是否成功响应了二维码图片:
# 访问 prePay 方法
GET http://localhost:13415/api/v1/order/prePay

3. 开发回调接口

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/order")
public class OrderController {

    @PostMapping("/notify")
    public Object notify(HttpServletRequest request) {
        if (request.getParameter("trade_status").equals("TRADE_SUCCESS")) {
            System.out.println("交易主题: " + request.getParameter("subject"));
            System.out.println("交易状态: " + request.getParameter("trade_status"));
            System.out.println("交易凭证: " + request.getParameter("trade_no"));
            System.out.println("订单编号: " + request.getParameter("out_trade_no"));
            System.out.println("交易金额: " + request.getParameter("total_amount"));
            System.out.println("买家编号: " + request.getParameter("buyer_id"));
            System.out.println("付款时间: " + request.getParameter("gmt_payment"));
            System.out.println("付款金额: " + request.getParameter("buyer_pay_amount"));
        }
        // todo 修改订单状态为已支付
        return "success";
    }
}
  1. 启动 cpolar 穿透工具,然后再修改 AlipayUtil 工具中的 notify_url 地址为穿透地址,因为每次启动 cpolar 的地址都会变换。

  2. 启动 springboot 项目,访问预支付接口,查看是否成功响应了二维码图片。

# 访问 prePay 方法
GET http://localhost:13415/api/v1/order/prePay
  1. 使用支付宝沙箱扫码付款,付款成功后查看控制台日志,是否触发了回调方法。

4. 开发查订单接口

  1. 开发控制器:
package com.joezhou.controller;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/order")
public class OrderController {

    @GetMapping("/select/{orderNo}")
    public Object select(@PathVariable("orderNo") String orderNo) throws Exception {
        // 初始化配置
        Factory.setOptions(AlipayUtil.getConfig());
        // 查询订单
        AlipayTradeQueryResponse query = Factory.Payment.Common().query(orderNo);
        // 响应HttpBody
        return query.getHttpBody();
    }
}
  1. 测试控制器:
# 访问 select 方法
GET http://localhost:13415/api/v1/order/select/xxx

S15. 协同过滤推荐

心法:协同过滤推荐算法是一种在推荐系统中广泛应用的算法,简单来说,就是通过分析用户的行为数据(如购买记录、浏览记录、评分等),找到与目标用户兴趣相似的其他用户,然后根据这些相似用户的行为来为目标用户推荐物品。

E01. 基础概念

1. 用户协同过滤

心法:用户协同过滤的原理是,先找到和目标用户兴趣相似的其他用户,然后看这些相似用户喜欢什么物品,就把这些物品推荐给目标用户。

假设有两个用户,分别叫 A 和 B,这两个人都喜欢某几部电影,都给这些电影打了很高的分数,那就可以认为这两个用户兴趣相似。

那么,当知道 A 还喜欢另外一些 B 没看过的电影时,就可以把这些电影推荐给 B,因为基于他们之前相似的喜好,B 很可能也会喜欢这些电影。

2. 商品协同过滤

心法:商品协同过滤的原理是,先分析物品之间的相似性,比如两部电影的类型、演员、剧情等方面很相似,如果一个用户喜欢其中一部电影,那么就可以把另一部电影推荐给他。

假设一共 3 个用户,感兴趣的文章列表如图所示:

用户编号感兴趣的文章列表
19, 6, 1, 3, 10, 2, 7
25, 6, 4, 8, 2, 7, 10, 12
39, 6, 5, 4, 12, 8, 11, 2, 10, 13, 7

第一步:共现二维矩阵:根据全部用户的兴趣实现二维矩阵:

  • 若有 3 个用户同时对 A07 和 A02 感兴趣,则将矩阵中的 (A07, A02) = (A02, A07) 记录为 3。
  • 若有 2 个用户同时对 A05 和 A04 感兴趣,则将矩阵中的 (A05, A04) = (A04, A05) 记录为 2。
  • 若有 1 个用户同时对 A02 和 A01 感兴趣,则将矩阵中的 (A02, A01) = (A01, A02) 记录为 1。
  • 若没有任何用户同时对 A03 和 A04 感兴趣,则将矩阵中的 (A03, A04) = (A04, A03) 记录为 0。

最终共现矩阵如下

A01A02A03A04A05A06A07A08A09A10A11A12A13
A01-110011011000
A021-12233223121
A0311-0011011000
A04020-222212121
A050202-22212121
A0613122-3223121
A07131223-223121
A080202222-12121
A0912111221-2111
A10131223322-121
A110101111111-11
A1202022222121-1
A13010111111111-

第二步:查询相似商品:根据矩阵,分别查询 1 号用户和 2 号用户感兴趣的每个商品的相似商品(相似度大于0)列表:

1号用户感兴趣的商品相似度 3 的商品相似度 2 的商品相似度 1 的商品
A092, 6, 7, 101, 3, 4, 5, 8, 11, 12, 13
A062, 7, 104, 5, 8, 9, 121, 3, 11, 13
A012, 3, 6, 7, 9, 10
A031, 2, 6, 7, 9, 10
A102, 6, 74, 5, 8, 9, 121, 3, 11, 13
A026, 7, 104, 5, 8, 9, 121, 3, 11, 13
A072, 6, 104, 5, 8, 9, 121, 3, 11, 13
2号用户感兴趣的商品相似度 3 的商品相似度 2 的商品相似度 1 的商品
A052, 4, 6, 7, 8, 10, 129, 11, 13
A062, 7, 104, 5, 8, 9, 121 3, 11, 13
A042, 5, 6, 7, 8, 10, 129, 11, 13
A082, 4, 5, 6, 7, 10, 129, 11, 13
A024, 5, 8, 9, 121, 3, 11, 13
A072, 6, 104, 5, 8, 9, 121, 3, 11, 13
A102, 6, 74, 5, 8, 9, 121, 3, 11, 13
A122, 4, 5, 6, 7, 8, 109, 11, 13

第三步:计算相似度:计算公式为 相似度 乘以 该相似度下商品出现次数 该公式仅为了方便理解,实际算法底层其实采用的是无中心余弦相似度算法,该算法常用于计算文本相似度、图像相似度等领域,且计算的结果和顺序可能会与其它算法不同。

1 号用户排除自己感兴趣的商品,剩余(4, 5, 8, 11, 12)商品可以推荐,计算每个其他商品的相似度得分,结果如下:

商品相似度计算(模拟)最终得分
A04相似度3分 * 共出现0次 + 相似度2分 * 共出现4次 + 相似度1分 * 共出现1次2*4+1*1=9
A05相似度3分 * 共出现0次 + 相似度2分 * 共出现4次 + 相似度1分 * 共出现1次2*4+1*1=9
A08相似度3分 * 共出现0次 + 相似度2分 * 共出现4次 + 相似度1分 * 共出现1次2*4+1*1=9
A11相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现5次1*5=5
A12相似度3分 * 共出现0次 + 相似度2分 * 共出现4次 + 相似度1分 * 共出现1次2*4+1*1=9
A13相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现5次1*5=5

最终向 1 号用户推荐结果 A04,A05,A08,A12,A11,A13,不同算法下,推荐顺序可能不相同。

2 号用户排除自己感兴趣的商品,剩余(1, 3, 9, 11, 13),计算每个其他商品的相似度得分,结果如下:

商品相似度计算(模拟)最终得分
A01相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现4次1*4=4
A03相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现4次1*4=4
A09相似度3分 * 共出现0次 + 相似度2分 * 共出现4次 + 相似度1分 * 共出现4次2*4+1*4=12
A11相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现8次1*8=8
A13相似度3分 * 共出现0次 + 相似度2分 * 共出现0次 + 相似度1分 * 共出现8次1*8=8

最终向 2 号用户推荐结果 A09,A11,A13,A01,A03,不同算法下,推荐顺序可能不相同。

E02. 整合流程

武技:开发 springboot-mahout 子项目

1. 开发SQL数据

drop table if exists `user_article_op`;
create table `user_article_op`
(
    `id`         bigint auto_increment comment '主键',
    `user_id`    bigint not null comment '用户ID',
    `article_id` bigint not null comment '文章ID',
    `op_type`    int not null comment '用户对文章的行为,0点赞,1收藏,2评论',
    primary key (`id`)
) comment '用户文章行为分析表';

insert into `user_article_op`(`id`, `user_id`, `article_id`, `op_type`)
values (1, 1, 9, 2), (2, 1, 6, 2), (3, 1, 1, 1), (4, 1, 2, 0), (5, 1, 3, 1),
       (6, 1, 3, 2), (7, 1, 9, 1), (8, 1, 2, 1), (9, 1, 6, 0), (10, 1, 7, 2),
       (11, 1, 10, 1), (12, 1, 1, 0), (13, 1, 10, 1), (14, 1, 9, 0), (15, 1, 1, 2),
       (16, 2, 8, 2), (17, 2, 4, 2), (18, 2, 7, 1), (19, 2, 2, 0), (20, 2, 5, 1),
       (21, 2, 5, 2), (22, 2, 8, 1), (23, 2, 2, 1), (24, 2, 12, 0), (25, 2, 6, 2),
       (26, 2, 2, 1), (27, 2, 7, 0), (28, 2, 10, 1), (29, 2, 8, 0), (30, 2, 4, 1),
       (31, 3, 11, 2), (32, 3, 12, 2), (33, 3, 6, 1), (34, 3, 4, 0), (35, 3, 13, 1),
       (36, 3, 5, 2), (37, 3, 8, 1), (38, 3, 4, 1), (39, 3, 12, 0), (40, 3, 6, 2),
       (41, 3, 2, 1), (42, 3, 6, 0), (43, 3, 10, 1), (44, 3, 8, 0), (45, 3, 4, 2),
       (46, 3, 7, 0), (47, 1, 6, 1), (48, 2, 6, 1), (49, 2, 6, 0), (50, 1, 6, 2),
       (51, 1, 9, 2), (52, 2, 6, 2), (53, 2, 5, 0), (54, 2, 5, 2), (55, 2, 4, 2),
       (56, 3, 6, 2), (57, 3, 5, 2), (58, 3, 5, 2), (59, 3, 4, 1), (64, 3, 9, 2),
       (65, 3, 9, 2), (66, 3, 9, 2);

2. 引入三方依赖

<dependencies>
	<!--spring-boot-starter-web-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<!--spring-boot-starter-test-->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
	</dependency>
	<!--mysql-connector-j-->
	<dependency>
		<groupId>com.mysql</groupId>
		<artifactId>mysql-connector-j</artifactId>
		<version>${mysql-connector-j.version}</version>
		<scope>runtime</scope>
	</dependency>
	<!--mybatis-spring-boot-starter-->
	<dependency>
		<groupId>org.mybatis.spring.boot</groupId>
		<artifactId>mybatis-spring-boot-starter</artifactId>
		<version>${mybatis-spring-boot-starter.version}</version>
	</dependency>
	<!--mahout-mr-->
	<dependency>
		<groupId>org.apache.mahout</groupId>
		<artifactId>mahout-mr</artifactId>
		<version>${mahout-mr.version}</version>
	</dependency>
</dependencies>

3. 开发主配文件

server:
  port: 13416 # 端口号
spring:
  application:
    name: springboot-mahout # 项目名称
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.40.77:3306/springboot?
      serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8
    username: root
    password: root
    type: com.zaxxer.hikari.HikariDataSource

# mybatis相关
mybatis:
  configuration:
    map-underscore-to-camel-case: true # 下划线转驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台SQL
  type-aliases-package: com.joezhou.entity # 别名包扫描

4. 开发启动类

package com.joezhou;

@MapperScan("com.joezhou.mapper")  
@SpringBootApplication  
public class SpringBootMahoutApp {  
    public static void main(String[] args) {  
        SpringApplication.run(SpringBootMahoutApp.class, args);  
    }  
}

5. 开发工具类

package com.joezhou.util;

/** @author 周航宇 */
public class RecommendUtil {

    /** 用于存储用户对商品的操作 */
    @Data
    public static class Operation {
        /** 用户ID */
        Long userId;
        /** 商品ID */
        Long itemId;
        /** 操作得分 */
        Float value;
    }

    /**
     * 基于用户协同过滤算法向指定用户推荐商品
     *
     * @param userId       指定用户
     * @param operations   用户对商品的操作得分列表
     * @param neighborNum  邻居数量
     * @param recommendNum 推荐商品数量
     */
    public static List<Long> userCF(Long userId,
                                    List<RecommendUtil.Operation> operations,
                                    int neighborNum,
                                    int recommendNum) throws TasteException {
        // 获取每个用户的操作 Map,格式为 {"userId": "GenericUserPreferenceArray用户偏好数组"}
        FastByIDMap<PreferenceArray> fastByIdMap = getFastByIdMap(operations);
        // 获取推荐器:用户协同过滤需要邻居数量
        Recommender recommender = getUserRecommender(fastByIdMap, neighborNum);
        // 推荐3篇文章,并将 id 收集到一个 list 中返回,其中 RecommendedItem 类表示推荐的文章
        return recommender.recommend(userId, recommendNum)
                .stream()
                .map(RecommendedItem::getItemID)
                .collect(Collectors.toList());
    }

    /**
     * 基于商品协同过滤算法向指定用户推荐商品
     *
     * @param userId       指定用户
     * @param operations   用户对商品的操作得分列表
     * @param recommendNum 推荐商品数量
     */
    public static List<Long> itemCF(Long userId,
                                    List<RecommendUtil.Operation> operations,
                                    int recommendNum) throws TasteException {
        // 获取每个用户的操作 Map,格式为 {"userId": "GenericUserPreferenceArray用户偏好数组"}
        FastByIDMap<PreferenceArray> fastByIdMap = getFastByIdMap(operations);
        // 获取推荐器:商品协同过滤不需要邻居数量
        Recommender recommender = getItemRecommender(fastByIdMap);
        // 推荐文章,并将 id 收集到一个 list 中返回,其中 RecommendedItem 类表示推荐的文章
        return recommender.recommend(userId, recommendNum)
                .stream()
                .map(RecommendedItem::getItemID)
                .collect(Collectors.toList());
    }

    /**
     * 获取每个用户的操作 Map,格式为 {"用户ID": "GenericUserPreferenceArray用户偏好数组"}
     * 1. 按照 Operation 对象的 userId 属性进行分组。
     * 2. 将每个用户的 ID 作为键,将该用户的所有操作记录作为值,存入 Map。
     * 3. 对每个用户的操作记录列表进行遍历,处理每个用户的偏好信息。
     * 4. 存储当前用户的所有偏好信息,数组的大小为该用户的操作记录数量。
     * 5. 遍历该用户的每条操作记录,创建每个用户对某商品的偏好对象,包含用户ID,商品ID和操作评分,然后存入偏好数组。
     * 6. 将该用户的偏好数组存储到 FastByIDMap 中。
     * 7. 重复步骤 3-6,直到所有用户的操作记录都被处理完毕。
     * 8. 返回 FastByIDMap,其中包含了每个用户的操作信息。
     *
     * @param operations 用户对商品的操作实体类
     * @return 每个用户的操作 Map,格式为 {"用户ID": "用户偏好数组"}
     */
    private static FastByIDMap<PreferenceArray> getFastByIdMap(Collection<RecommendUtil.Operation> operations) {
        // FastByIDMap 是 Mahout 库提供的一种高效的数据结构,用于快速查找和存储 “用户ID -> 用户偏好数组” 的映射。
        FastByIDMap<PreferenceArray> fastByIdMap = new FastByIDMap<>();
        // 按照 Operation 对象的 userId 属性进行分组,然后将每个用户的 ID 作为键,将该用户的所有操作记录作为值,存入 Map
        Map<Long, List<RecommendUtil.Operation>> map = operations
                .stream()
                .collect(Collectors.groupingBy(RecommendUtil.Operation::getUserId));
        // 对每个用户的操作记录列表进行遍历,处理每个用户的偏好信息
        for (List<RecommendUtil.Operation> list : map.values()) {
            // 存储当前用户的所有偏好信息,数组的大小为该用户的操作记录数量
            GenericPreference[] preferences = new GenericPreference[list.size()];
            // 遍历该用户的每条操作记录
            for (int i = 0, j = list.size(); i < j; i++) {
                // 创建每个用户对某商品的偏好对象,包含用户ID,商品ID和操作评分,然后存入偏好数组
                preferences[i] = new GenericPreference(
                        list.get(i).getUserId(),
                        list.get(i).getItemId(),
                        list.get(i).getValue());
            }
            // 将该用户的偏好数组存储到 FastByIDMap 中
            long key = preferences[0].getUserID();
            PreferenceArray value = new GenericUserPreferenceArray(Arrays.asList(preferences));
            fastByIdMap.put(key, value);
        }
        return fastByIdMap;
    }

    /**
     * 获取基于用户的推荐器
     *
     * @param fastByIdMap 每个用户的操作 Map,格式为 {"用户ID": "用户偏好数组"}
     * @param neighborNum 邻居数量
     * @return 基于用户的推荐器对象
     */
    private static Recommender getUserRecommender(FastByIDMap<PreferenceArray> fastByIdMap,
                                                  int neighborNum) throws TasteException {
        // DataModel 是 Mahout 提供的一个通用的数据模型实现,用于存储用户和物品的偏好信息
        DataModel dataModel = new GenericDataModel(fastByIdMap);
        // 获取用户相似程度:通过 DataModel 对象中的数据,基于无中心余弦相似度算法来计算用户之间的相似度
        UserSimilarity userSimilarity = new UncenteredCosineSimilarity(dataModel);
        // 获取用户邻居:基于用户相似度,从 DataModel 对象中找到和指定用户最相似的 neighborhoodNum 个邻居
        UserNeighborhood userNeighborhood = new NearestNUserNeighborhood(neighborNum, userSimilarity, dataModel);
        // 构建基于用户的推荐器:依据 DataModel、UserNeighborhood 和 UserSimilarity 来为指定用户生成推荐结果
        return new GenericUserBasedRecommender(dataModel, userNeighborhood, userSimilarity);
    }

    /**
     * 获取基于物品的推荐器
     *
     * @param fastByIdMap 每个用户的操作 Map,格式为 {"用户ID": "用户偏好数组"}
     * @return 基于物品的推荐器对象
     */
    private static Recommender getItemRecommender(FastByIDMap<PreferenceArray> fastByIdMap) throws TasteException {
        // DataModel 是 Mahout 提供的一个通用的数据模型实现,用于存储用户和物品的偏好信息
        DataModel dataModel = new GenericDataModel(fastByIdMap);
        // 获取物品相似程度:通过 DataModel 对象中的数据,基于无中心余弦相似度算法来计算物品之间的相似度
        ItemSimilarity itemSimilarity = new UncenteredCosineSimilarity(dataModel);
        // 基于商品的推荐器
        return new GenericItemBasedRecommender(dataModel, itemSimilarity);
    }
}

6. 开发数据层

  1. 开发 Mapper 接口:
package com.joezhou.mapper;

/** @author 周航宇 */
public interface UserArticleOpMapper {

    /**
     * 获取所有用户对文章的操作得分,并按照用户ID和文章ID分组
     *
     * @return 用户对文章的操作得分列表,其中:
     * 行为代码 0 视为点赞,得 2 分;
     * 行为代码 1 视为收藏,得 3 分;
     * 行为代码 2 视为评论,得 5 分;
     * 其他代码不得分。
     */
    @Select("""
            select user_id, article_id as item_id, SUM(case op_type
                when 0 then 2
                when 1 then 3
                when 2 then 5
                else 0 end
            ) as value
            from user_article_op
            group by user_id, article_id
            """)
    List<RecommendUtil.Operation> listOperations();
}
  1. 测试 Mapper 接口:
package mapper;

/** @author 周航宇 */
@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringBootMahoutApp.class)
public class UserArticleOpMapperTest {

    @Resource
    private UserArticleOpMapper userArticleOpMapper;

    @Test
    public void listOperations() {
        System.out.println(userArticleOpMapper.listOperations());
    }

    /**
     * 根据SQL语句,数据如下:
     * 1号用户对 9 6 1 3 10 2 7 号文章感兴趣,兴趣值递减
     * 2号用户对 5 6 4 8 2 7 10 12 号文章感兴趣,兴趣值递减
     * 3号用户对 9 6 5 4 12 8 11 2 10 13 7 号文章感兴趣,兴趣值递减
     */
	@SneakyThrows
    @Test
    public void userCF(){
        List<RecommendUtil.Operation> operations = userArticleOpMapper.listOperations();
        int neighborNum = 2;
        int recommendNum = 3;

        // 计算邻居兴趣的交集:(5 6 4 8 2 7 10 12) 交 (9 6 5 4 12 8 11 2 10 13 7) = (6 5 4 8 2 7 10 12)
        // 排除自己本身的兴趣:(6 5 4 8 2 7 10 12) 差 (9 6 1 3 10 2 7) = (5 4 8 12)
        // 设定是只推荐前 3 个,最终结果为 5 4 8
        Long userId01 = 1L;
        List<Long> ids01 = RecommendUtil.userCF(userId01, operations, neighborNum, recommendNum);
        System.out.println("向 1 号用户推荐:" + ids01);

        // 计算邻居兴趣的交集:(9 6 1 3 10 2 7) 交 (9 6 5 4 12 8 11 2 10 13 7) = (9 6 5 4 12 8 2 10 7)
        // 排除自己本身的兴趣:(9 6 5 4 12 8 2 10 7) 差 (5 6 4 8 2 7 10 12) = (9)
        // 设定是只推荐前 3 个,最终结果为 9
        Long userId02 = 2L;
        List<Long> ids02 = RecommendUtil.userCF(userId02, operations, neighborNum, recommendNum);
        System.out.println("向 2 号用户推荐:" + ids02);
    }

    @SneakyThrows
    @Test
    public void itemCF(){
        List<RecommendUtil.Operation> operations = userArticleOpMapper.listOperations();
        int recommendNum = 6;

        Long userId01 = 1L;
        List<Long> ids01 = RecommendUtil.itemCF(userId01, operations, recommendNum);
        System.out.println("向 1 号用户推荐:" + ids01);

        Long userId02 = 2L;
        List<Long> ids02 = RecommendUtil.itemCF(userId02, operations, recommendNum);
        System.out.println("向 2 号用户推荐:" + ids02);
    }
}

Java道经第3卷 - 第4阶 - SpringBoot(五)


传送门:JB3-4-SpringBoot(一)
传送门:JB3-4-SpringBoot(二)
传送门:JB3-4-SpringBoot(三)
传送门:JB3-4-SpringBoot(四)
传送门:JB3-4-SpringBoot(五)
传送门:JB3-4-SpringBoot(六)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值