首页
留言
友链
关于
Search
1
思源笔记docker私有化部署及使用体验分享
2,879 阅读
2
windows11 远程提示:为安全考虑,已锁定该用户帐户,原因是登录尝试或密码更改尝试过多。
1,230 阅读
3
解决 nginxProxyManager 申请证书时的SSL失败问题
882 阅读
4
Pointer-Focus:一款功能强大的教学、录屏辅助软件
857 阅读
5
使用cspell对项目做拼写规范检查
720 阅读
Web前端
CSS
JavaScript
交互
Vue
小程序
后端
Java
运维
项目
生活
其他
转载
软件
职场
登录
Search
标签搜索
docker
DevOps
magic-boot
Linux
酷壳
frp
RabbitMQ
gitlab
Node
git
工具
MybatisPlus
clickhouse
Syncthing
规范
前端
产品
nginx
markdown
axios
朱治龙
累计撰写
153
篇文章
累计收到
10
条评论
首页
栏目
Web前端
CSS
JavaScript
交互
Vue
小程序
后端
Java
运维
项目
生活
其他
转载
软件
职场
页面
留言
友链
关于
搜索到
26
篇与
后端
的结果
2025-06-11
一个收费图标网站的整站png图标爬取过程记录
近期发现一个还不错的3D图标库(https://www.thiings.co/things)图标偏写实风格,都是png格式,也便于在其他项目中引用,看项目的介绍,有1900个图标,都是偏日常生活物品,可以考虑下载下来,打印给崽做干预训练。官网提供了下载全部的功能,但下载的时候提示需要29美元,且不支持我仅有的国内的银行卡及在线支付方式,又不得不让我望而却步了。所以不得不祭出国内程序员的大杀器:白嫖。以下便是白嫖全过程。页面分析经过对页面源码的分析,发现该网页为使用 Next 开发的单页应用,页面使用 Webpack 打包,滚动页面过程中没有发起异步请求,那相关的文件路径应该是打包在工程里的。经浏览一个图标,发现如下规律:可通过如下URL下载到原始图片:https://lftz25oez4aqbxpq.public.blob.vercel-storage.com/image-UnbACooeDKA5ggSg36Zi8JizoYINpv.png,经过进一步分析,URL 中的 UnbACooeDKA5ggSg36Zi8JizoYINpv 对应的图片的ID。经过进一步分析,我们在源码底部找到了相关资源定义的json数据,经过清洗得到如下精简后的json格式的定义数据:{ "currentPage": "COLLECTION", "shouldShuffle": true, "rootPath": "/things", "canDownload": true, "categories": ["Everyday", "Nature", "Technology", "Sponsors"], "forceLength": 1900, "items": [ { "id": "amphitheater", "name": "Amphitheater", "categories": ["places \u0026 structures", "entertainment", "urban"], "fileId": "oS7mN2q1OPFw7HHZGO30XoFqCjQJba", "shareUrl": "https://www.thiings.co/things/amphitheater" }, { "id": "sofa", "name": "Sofa", "categories": ["everyday life", "furniture", "seating"], "fileId": "cRuokpRSq9ekpqqbJA3w5Tg4DWOyLv", "shareUrl": "https://www.thiings.co/things/sofa" }, { "id": "toaster", "name": "Toaster", "categories": ["everyday life", "appliance", "kitchen"], "fileId": "UnbACooeDKA5ggSg36Zi8JizoYINpv", "shareUrl": "https://www.thiings.co/things/toaster" }, { "id": "bookshelf", "name": "Bookshelf", "categories": ["everyday life", "furniture", "storage"], "fileId": "4r2tNFAParX1lIOupxBEg3fPYP7GuT", "shareUrl": "https://www.thiings.co/things/bookshelf" } ] }由此我们可以遍历json中的items数组获取所有资源,然后拼凑图片URL并逐个下载对应的png原图即可。资源下载这种数据爬取方面的操作,还是采用我最熟练的 magic-api 来实现,主要分两个步骤:1、解析json文件并将文件入库为了便于后续资源利用,我们将上面的json数据保存到MySQL中,建表语句如下:-- zzl_resources.things_icons definition CREATE TABLE `things_icons` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID', `oid` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '原始id', `name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL COMMENT '名称', `categories` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'json数组', `categories_str` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '便于按类别搜索的字符串', `file_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '文件Id', `share_url` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '详情URL', `note` varchar(2000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '详情页的描述信息', `add_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='https://www.thiings.co/things 网站的图标爬取专用';由于json数据较大,我们将数据保存在磁盘文件中(things_resources.json),然后读取解析保存入库,代码如下:import cn.hutool.core.io.FileUtil import cn.hutool.core.date.DateTime import cn.hutool.core.date.DateUtil import org.jsoup.helper.DataUtil import cn.hutool.json.JSONUtil import cn.hutool.core.util.ObjectUtil import cn.hutool.core.util.StrUtil import log var timer = DateUtil.timer(); var jsonStr = FileUtil.readString("/cosmos/apps/uploads/things_resources.json","UTF-8"); var things = JSONUtil.parseArray(jsonStr) // log.info('jsonStr:' + jsonStr) for (thingItem in things) { var categoriesStr = '' if (thingItem.categories && thingItem.categories.length > 0) { categoriesStr = "," + thingItem.categories.join(',') + "," } if (!thingItem.shareUrl) { log.info('跳过入库:' + thingItem.fileId) continue } var data = { oid: thingItem.id, name: thingItem.name, categories: JSONUtil.toJsonStr(thingItem.categories), categoriesStr: categoriesStr, fileId: thingItem.fileId, shareUrl: thingItem.shareUrl } log.info(JSONUtil.toJsonStr(data)) db['ZR'].table('things_icons').insert(data) } log.info(`Cost Time: ${timer.intervalPretty()}.`); return '操作成功'2、下载相关的原图数据保存入库后,我们可以遍历记录,然后下载到指定目录,一下是下载代码:import cn.hutool.http.HttpUtil import cn.hutool.core.io.FileUtil import cn.hutool.core.date.DateTime import cn.hutool.core.date.DateUtil import org.jsoup.helper.DataUtil import cn.hutool.json.JSONUtil import log import http var list = db['ZR'].table('things_icons').select() var index = 0 var size = list.size() var timer = DateUtil.timer(); for ( item in list) { var fileId = item.fileId var url = `https://lftz25oez4aqbxpq.public.blob.vercel-storage.com/image-${fileId}.png` try { HttpUtil.downloadFileFromUrl(url, `/cosmos/apps/uploads/things-icons/${item.name}__${fileId}.png`) } catch(e) { log.info(`something error:${item.fileId}`) } index++ log.info(`index/size:${index}/${size}`) } log.info(`Cost Time: ${timer.intervalPretty()}.`); return 'success'执行该方法后,耗时 1小时46分。下载了 1908 张图片,共计 2.78G通过对下载的图片进行统计,发现所有png图片均为1024*1024的分辨率,每张图片基本上都在1M以上,只要不涉及商用版权纠纷的场景,这样规范的图片可以直接用在很多地方。
2025年06月11日
49 阅读
1 评论
0 点赞
2025-04-18
Spring Gateway 使用 Resilience4j 实现限流
背景说明近期接触到公司一个使用 Spring GateWay 搭建的网关服务,为了保障下游服务,防止用户恶意刷接口造成系统过载崩溃等情况,需要在网关层面引入限流机制。经过初步调研,不少大佬都推荐使用 Resilience4j 这款轻量级的 Java 容错库来做,但是网上能找到的实践层面的资料比较少,针对特定场景官网的文档也显得捉襟见肘,所以在边学习边实践过程种便有了这篇流水账式的文章。初体验1.添加依赖公司项目使用 Maven 进行依赖管理,在工程的 pom.xml 的 dependencies 节点中添加如下依赖项: <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-cloud2</artifactId> <version>2.3.0</version> </dependency>2.创建限流器工厂类package com.paratera.aicloud.gateway.filter; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; @Component public class Resilience4jRateLimiterGatewayFilterFactory extends AbstractGatewayFilterFactory<Resilience4jRateLimiterGatewayFilterFactory.Config> { private final RateLimiterRegistry rateLimiterRegistry; public Resilience4jRateLimiterGatewayFilterFactory(RateLimiterRegistry rateLimiterRegistry) { super(Config.class); this.rateLimiterRegistry = rateLimiterRegistry; } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter(config.getName()); return Mono.fromCallable(() -> { boolean permitted = rateLimiter.acquirePermission(); if (!permitted) { throw new RuntimeException("Rate limit exceeded"); } return permitted; }).then(chain.filter(exchange)) .onErrorResume(RuntimeException.class, e -> { exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS); return exchange.getResponse().setComplete(); }); }; } public static class Config { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } } }3.添加限流相关配置resilience4j: ratelimiter: instances: default: limitForPeriod: 50 # 每个周期允许的请求数 limitRefreshPeriod: 5s # 限流周期 timeoutDuration: 5s # 等待令牌的超时时间 drainPermissionsOnResult: false # 周期结束后是否清空许可次数 writableStackTraceEnabled: true # 是否生成堆栈跟踪, 可用于减少日志冗余 limit50: limitForPeriod: 50 limitRefreshPeriod: 300s timeoutDuration: 500ms writableStackTraceEnabled: false4. 路由信息中关联限流配置在 spring.cloud.gateway.routes 对应的实例节点中添加过滤器,示例如下:spring: cloud: gateway: routes: - id: test-RateLimiter uri: http://localhost:8896 predicates: - Path=/message/** filters: - StripPrefix=1 - name: Resilience4jRateLimiter args: name: limit50本示例核心配置信息如下:5. 验证Apifox 自带的自动化测试功能可以很方面的进行并发测试,我们的测试配置如下:点击 运行 按钮后,得到如下图所示的测试结果,从结果来看式符合限流预期的:查看请求详情,可以看到请求周期内达到限流场景时,接口是响应的预期内的429状态码:LinksResilience4j 官网文档:https://resilience4j.readme.io/
2025年04月18日
12 阅读
0 评论
0 点赞
2024-09-05
Java工程使用ulid初体验
背景从公众号推文看到不少博主推荐使用 ulid 替换 uuid,提及的 ulid 的优点如下:1、 全局唯一性 :通过结合时间戳和随机数,ULID能够确保生成的标识符在全球范围内是唯一的。2、 可排序性 :由于ULID中包含了时间戳信息,因此它们可以按照时间顺序进行排序,这在数据处理和分析中非常有用。3、 高性能 :ULID的生成速度远快于传统的UUID,且其编码方式更为高效。4、 易读性 :虽然ULID也是由一串字符组成,但其采用的编码方式使得它相对更易于人类阅读和记忆。ULID(Universally Unique Lexicographically Sortable Identifier)是一种用于生成全局唯一、可按字典序排序的标识符的格式。ULID结合了时间戳和随机数的特性,旨在提供高性能、低碰撞、可排序和易读的标识符。引入依赖<dependency> <groupId>com.github.f4b6a3</groupId> <artifactId>ulid-creator</artifactId> <version>5.2.3</version> </dependency>生成随机字符串 Ulid ulid = UlidCreator.getUlid(); String randomId = ulid.toString();测试代码package net.x2m.pms.collect; import com.github.f4b6a3.ulid.Ulid; import com.github.f4b6a3.ulid.UlidCreator; public class UlIdTest { public static void main(String[] args) { for (int i = 0; i < 10; i++) { Ulid ulid = UlidCreator.getUlid(); System.out.println(ulid.toString()); } } }运行以上代码后生成示例数据如下:01J70X9YDC7YSTJAPWPDFDH2CK 01J70X9YQS0NCCFCWEZ5RVXEXP 01J70X9YQS48ES2ETYT7FHQG2H 01J70X9YQTSGF792XY07W2TDZ2 01J70X9YQTEW2KTFX5VJRSAT9R 01J70X9YQTFBPZCMJWDQVNV0MA 01J70X9YQTSEX81YK0S7CB6X6W 01J70X9YQV4XAR9MHDN2RDJH8A 01J70X9YQV8HMMX2DYZ2Q5Y559 01J70X9YQV0R5PN0P0536NZM4Z
2024年09月05日
37 阅读
0 评论
0 点赞
2024-08-19
HTTPS代理访问DataGear登录失败的问题
背景说明在一些项目中我们有用到 DataGear 来做大屏展示,将 DataGear 部署到产线后,发现通过HTTPS协议访问应用正常,但是登录的时候,账号密码正确的情况下,提示Error,如下图所示:问题分析打开浏览器开发者工具,查看网络请求,我们发现登录的时候请求的 https://xxx.com/login/doLogin ,登录成功后 302 重定向到了 http://xxx.com/login/success 地址,由于HTTP协议不一致,存在跨域,导致Ajax无法正常获取到响应数据:部署说明DataGear 使用 Docker 部署,使用 Nginx 配置SSL代理对外提供服务。问题修复通过前面的分析,我想可能是 Nginx 到 DataGear 的过程中在登录的时候网络协议丢失了,通过查找SpringBoot 的官方文档,有找到如下章节:14.3. Enable HTTPS When Running behind a Proxy Server。从标题我们就可以看到,这就是我们碰到的使用场景:根据提示也是跟代理的一些HTTP头信息相关,我们根据提示,在datagear-web 工程的 application.properties 文件中,添加了如下配置信息:# 处理https重定向不正确的问题 server.tomcat.remoteip.protocol-header=X-Forwarded-Proto server.tomcat.remoteip.remote-ip-header=X-Forwarded-For重新构建Docker镜像并重新运行DataGear容器,然后在nginx 代理配置中添加如下设置代理头信息:proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme;如果使用 Nginx Proxy Manager ,可以在 ProxyHost 中,将上面的代理配置信息加入到自定义Nginx配置中,如下图所示:保存重启Nginx后,重新登录可以看到,能响应正确的重定向地址,至此HTTPS代理登录问题完美解决:
2024年08月19日
185 阅读
2 评论
2 点赞
2024-06-29
对接支付宝实现门户网站在线支付功能
前言近期,我所在团队负责的控制台项目计划新增在线充值功能。为实现此功能,我们首先需完成在线支付环节的对接。初期,我们将优先接入支付宝支付服务。值得一提的是,我上一次进行支付宝对接,还是在十多年前参与的笨鸟旅行项目,那时我们采用的是 jsp + mybatis 技术栈。与现在便捷的SDK调用相比,当时的开发条件相对简陋,整个项目甚至连 maven 包管理工具都未使用,前端工程化也仅处于起步阶段,CI/CD流程更是无从谈起。回顾过去,不禁让人感叹技术发展带来的日新月异。支付宝支付流程支付宝支付流程大致如下:1、 注册商家并绑定开发人员 :支付宝商家平台( https://b.alipay.com/ )注册商家,多说一嘴,注册成功后,可以在商家平台的账号中心 → 员工列表 添加员工为子账号,这样后续就不需要老是麻烦Boss帮忙扫码或提供验证码了:2、 申请开通支付产品: 商家平台的产品中心根据业务场景需要,申请开通相关支付产品。如我们申请开通了「电脑网站支付」、「手机网站支付」两款支付产品,申请后一般几分钟就开通了。3、 创建应用: 开通产品后就可以创建应用了,单击对应的产品可以进入关联应用页面创建应用并关联,这个界面需要超级管理员将开发人员添加为开放平台的管理员,这样开发人员就可以在开放平台进行应用相关配置了。4、 开发配置: 进入支付宝开放平台,进入应用详情,可在「开发设置」模块中根据流程配置加密证书等信息{gird column="2" gap="10"}{gird-item}{mtitle title="根据提示下载工具生成CSR文件"/}{/gird-item}{gird-item}{mtitle title="上传生成的CSR文件"/}{/gird-item}{gird-item}{mtitle title="手机验证"/}{/gird-item}{gird-item}{mtitle title="证书生成结果"/}{/gird-item}{/gird}5、发布上线:申请完的应用都是开发中状态,前期验证阶段踩了些小坑,以为配置好并生成证书就可以着手开发对接了,后面才发现状态为开发中的应用,是无法调用线上正式接口的,需要应用上线才行,所以我们在「开发设置」中配置好以后就直接提交审核就好,一般提交审核一天左右就可以审核通过,审核通过就可以正式开发对接了。6、开发对接:开发对接根据支付宝官网的文档来就好,现在对接支付宝可以直接使用高度封装的 SDK,开发速度上快了不少。电脑网站支付产品的文档链接为:https://opendocs.alipay.com/open/270/105898?pathHash=b3b2b667 。前期尝试了使用 v3 版本的对接方式(文档链接为: https://opendocs.alipay.com/open-v3/05w3qc ),但一直报证书方面的错误,后改用旧版本的 SDK 没发现问题。直接上代码码了那么多字,总算到最简单的环节了,我们的后端服务核心技术栈:JDK17 + SpringBoot 2.7.x + lombok + Mybatis-plus + MySQL + Hutool,使用 Maven 构建项目,所以我们这个示例工程也是基于上面的技术栈。以下是支付相关的核心代码:一、准备工作1、引入 Alipay SDK在项目 pom.xml 文件 的 dependencies 节点中加入 Alipay SDK 的依赖:<dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.39.104.ALL</version> </dependency>由于 V3 版本没有验证通过,这里我们没有根据文档站中的建议采用 V3 版本。2、定义配置类跟支付宝对接相关的信息我们统一写到 application.yml 配置文件中, 使用一个配置类来快速加载配置文件中的数据。package com.paratera.console.pay.config; import com.alipay.api.AlipayConfig; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration @ConfigurationProperties(prefix = "alipay") @Data public class AlipayProperties extends AlipayConfig { /** * 支付成功通知URL */ private String notifyUrl; }然后,我们在application.yml 中添加如下配置信息:alipay: appId: 2021004153624250 serverUrl: https://openapi.alipay.com/gateway.do notifyUrl: https://paydemo.work.pojian.online/pay/ali/notify privateKey: MIIEvgIBADANBgkqhkiG9w0BAQEF....RMXJeS alipayPublicCertPath: /alipay/alipayCertPublicKey_RSA2.crt rootCertPath: /alipay/alipayRootCert.crt appCertPath: /alipay/appCertPublicKey_2021004153624250.crt encryptKey: EfaaDErfTRvO2islsjk3thAQ==3、定义签名校验方法为避免一些没必要的安全隐患,后端接收到支付宝推送的通知内容需要做验签操作,验签通过后再进行核心业务操作。/** * 验签 * @param paramMap * @return */ public boolean checkSign(Map<String, String> paramMap) { try { String alipayPublicKey = alipayProperties.getAlipayPublicKey(); if (ObjectUtil.isEmpty(alipayPublicKey)) { String alipayPublicCertPath = alipayProperties.getAlipayPublicCertPath(); alipayPublicKey = AlipaySignature.getAlipayPublicKey(alipayPublicCertPath); alipayProperties.setAlipayPublicKey(alipayPublicKey); } return AlipaySignature.rsaCheckV1(paramMap, alipayPublicKey, alipayProperties.getCharset(), "RSA2"); } catch (AlipayApiException e) { throw new RuntimeException(e); } }二、对接「统一收单下单并支付页面接口」1、controller 定义支付申请入口@PostMapping(path="/pay-prepare") @ResponseBody public String pay(PayForm payForm) throws AlipayApiException { String payHtml = alipayService.payPrepare(payForm); if (ObjectUtil.isEmpty(payHtml)) { return "支付失败"; } return payHtml; }PayForm 主要定义充值相关的字段,如充值金额、用户Id、邮箱、备注等,根据业务自己来就好,我这里采用最简的数据:package com.paratera.console.pay.model.form; import lombok.Data; import javax.validation.constraints.NotBlank; /** * 支付表单 */ @Data public class PayForm { /**0 * 用户Id */ @NotBlank(message = "用户Id不能为空") private String userId; /** * 支付金额 */ @NotBlank(message = "支付金额不能为空") private String money; }2、业务实现 AlipayService.payPrepare()public String payPrepare(PayForm payForm) throws AlipayApiException { Faker faker = new Faker(Locale.CHINA); CertAlipayRequest alipayConfig = new CertAlipayRequest(); alipayConfig.setServerUrl(alipayProperties.getServerUrl()); alipayConfig.setAppId(alipayProperties.getAppId()); alipayConfig.setPrivateKey(alipayProperties.getPrivateKey()); alipayConfig.setAlipayPublicCertPath(alipayProperties.getAlipayPublicCertPath()); alipayConfig.setCertPath(alipayProperties.getAppCertPath()); alipayConfig.setSignType("RSA2"); alipayConfig.setCharset("UTF-8"); alipayConfig.setFormat("json"); alipayConfig.setRootCertPath(alipayProperties.getRootCertPath()); alipayConfig.setEncryptor(alipayProperties.getEncryptKey()); AlipayClient alipayClient = new DefaultAlipayClient(alipayConfig); // 实例化客户端 AlipayTradePagePayRequest payRequest = new AlipayTradePagePayRequest(); payRequest.setReturnUrl("https://paydemo.work.pojian.online/payResult.html"); payRequest.setNotifyUrl(alipayProperties.getNotifyUrl()); AlipayTradePagePayModel alipayTradePayModel = new AlipayTradePagePayModel(); // 调用 alipay.trade.pay LocalDateTime payTime = LocalDateTimeUtil.now(); String orderNo = "PAY" + DateUtil.format(payTime, DatePattern.PURE_DATE_PATTERN) + faker.number().digits(6); alipayTradePayModel.setOutTradeNo(orderNo); alipayTradePayModel.setTotalAmount(payForm.getMoney()); alipayTradePayModel.setSubject("控制台充值-" + DateUtil.format(payTime, DatePattern.PURE_DATE_PATTERN) + "-" + payForm.getMoney() + "元"); alipayTradePayModel.setProductCode("FAST_INSTANT_TRADE_PAY"); payRequest.setBizModel(alipayTradePayModel); AlipayTradePagePayResponse response = alipayClient.pageExecute(payRequest, "POST"); // 发起调用 String pageRedirectionData = response.getBody(); // log.info(pageRedirectionData); if (response.isSuccess()) { // log.info("调用成功"); // 保存充值记录 AddRechargePayForm addForm = new AddRechargePayForm(); addForm.setOrderNo(orderNo); addForm.setSubject(alipayTradePayModel.getSubject()); addForm.setPayMethod("ALIPAY"); addForm.setUserId(payForm.getUserId()); // 原单位是元,需要转换为分 BigDecimal money = new BigDecimal(payForm.getMoney()).multiply(BigDecimal.valueOf(100)); addForm.setPayMoney(money); rechargePayService.addRechargePay(addForm); return pageRedirectionData; } else { log.error("调用失败"); // sdk版本是"4.38.0.ALL"及以上,可以参考下面的示例获取诊断链接 String diagnosisUrl = DiagnosisUtils.getDiagnosisUrl(response); log.error(diagnosisUrl); } return null; }上面的过程主要是生成如下所示的包含签名数据的html代码,在网页里通过自动提交表单的方式跳转到支付宝的付款页面:<form name="punchout_form" method="post" action="https://openapi.alipay.com/gateway.do?app_cert_sn=653da2afe61d430fcc9376b008245959&charset=UTF-8&alipay_root_cert_sn=687b59193f3f462dd5336e5abf83c5d8_02941eef3187dddf3d3b83462e1dfcf6&method=alipay.trade.page.pay&sign=gQOZ2g8xU9lyA2k55FJNSTbleinvaFA%2FjrfuUInElycpytnWweBVDECnC0q7d2tZ%2FOQvxQc5%2Fyxm2OBTc7ERa%2Fs5%2Fi4vTAD2QyukrlKLN1SleGZP5%2FihTpcB%2BM5WkpHyQjsRrnHeFHB3i8Nzfc%2B9ICdz5xJCx8dlu%2BVN9qb51hx9y4eyTvWd2XY4%2BfmW3RHsE8PYQ6VU9YtMP8wjFonaPMExY9WnpJn74fNVj8wLHmxYemxkW80qXhbOxGik2qHY9NWfkWZsYhzMbBu5%2BUBXWdprlbakThSPqXjNpagUMmdPNiGjvLWy3XYtFHfYsOQdbIIPrPlhuQIYmmUfJF9M3w%3D%3D&return_url=https%3A%2F%2Fpaydemo.work.pojian.online%2FpayResult.html¬ify_url=https%3A%2F%2Fpaydemo.work.pojian.online%2Fpay%2Fali%2Fnotify&version=1.0&app_id=2021004153624250&sign_type=RSA2×tamp=2024-06-26+18%3A15%3A01&alipay_sdk=alipay-sdk-java-4.39.104.ALL&format=json"> <input type="hidden" name="biz_content" value="{"out_trade_no":"PAY20240626445677","product_code":"FAST_INSTANT_TRADE_PAY","subject":"控制台充值-20240626-0.1元","total_amount":"0.1"}"> <input type="submit" value="立即支付" style="display:none" > </form> <script>document.forms[0].submit();</script>二、对接支付结果的异步通知发起支付操作的时候我们有指定两个URL:return_url:用于支付完成后跳转到我们的页面给用户展示支付结果notify_url:用于支付完成后通知后端更新支付结果,由于return_url给的支付结果一般不可信,所以我们一般采用这个地址告诉支付宝,用户支付完后异步通知我们哪个接口1、定义接收通知的接口/** * 支付宝付款通知 * * https://opendocs.alipay.com/open/270/105902?pathHash=d5cd617e&ref=api * @return */ @RequestMapping("/notify") public String payNotify(HttpServletRequest request) throws UnsupportedEncodingException { log.info("====== 开始接收支付宝支付回调通知 ======"); Map<String, String> paramMap = new HashMap<>(); Map<String, String[]> requestParams = request.getParameterMap(); log.info("获取支付宝POST过来反馈信息"); for (String name : requestParams.keySet()) { String[] values = requestParams.get(name); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } paramMap.put(name, valueStr); } log.info("通知请求数据:{}", JSONUtil.toJsonStr(paramMap)); if(ObjectUtil.isNotEmpty(paramMap)) { log.info("支付宝回调URL参数:{}", JSONUtil.toJsonStr(paramMap)); boolean signVerified = alipayService.checkSign(paramMap); if (signVerified) { if ("TRADE_SUCCESS".equals(paramMap.get("trade_status"))) { rechargePayService.paid(paramMap.get("out_trade_no"), JSONUtil.toJsonStr(paramMap)); } } // {"gmt_create":["2024-06-25 15:18:10"],"charset":["UTF-8"],"gmt_payment":["2024-06-25 15:18:22"],"notify_time":["2024-06-25 15:18:23"],"subject":["Jacob Have I Loved"],"sign":["CM2qWtZ4rCmvqFyRucgANQJAz2k0SsroaU3uDEK28kgxkcHC29a64jquy4/qRfEiX4VuJKdmxkHG3+hEAC/9/qOgl8mG/GGSPlq+B90fg25aymYlKoM6wYOzEqooOLj2LCtj8Nq1WKaw9pTwYK/GlDdVLsUB8bSpkpjZ6vrhhzjeXK38wDgZuNKle9JdFCQLO/f1vcyM+h4SQpkurg/jTYB7oQQWk/ZsQAqC/XM/laKrPMPrA4SYb8hcC0UIQD8BLlCZiXaEj/+Q6CTJQffGyW2q6trTEhySgb4I3OwTjqtbn1BlafFCTm1OYgMlVLZu5U+iwvhPW2Xmz8A2v3RWIQ=="],"merchant_app_id":["2021004153624250"],"buyer_open_id":["037QDGdQKn6R2z7On4z_OJrBOzaNN0z5Obtjr_T4aftVdU9"],"invoice_amount":["0.10"],"version":["1.0"],"notify_id":["2024062501222151823038371452266751"],"fund_bill_list":["[{\"amount\":\"0.10\",\"fundChannel\":\"ALIPAYACCOUNT\"}]"],"notify_type":["trade_status_sync"],"out_trade_no":["PAY20240625635868"],"total_amount":["0.10"],"trade_status":["TRADE_SUCCESS"],"trade_no":["2024062522001438371427121571"],"auth_app_id":["2021004153624250"],"receipt_amount":["0.10"],"point_amount":["0.00"],"buyer_pay_amount":["0.10"],"app_id":["2021004153624250"],"sign_type":["RSA2"],"seller_id":["2088641848623112"]} } return "success"; }2、核心逻辑说明alipayService.checkSign()这个验签方法在前面的章节已经定义过,这里就不着重介绍了。rechargePayService.paid()是内部业务逻辑,本示例中主要是存储支付宝推送的数据并改变订单状态,在这里就不做代码罗列了。示例工程的几个核心页面效果为更好的展示示例效果,在示例工程开发过程中做了些界面展示,可通过如下链接体验完整功能:https://paydemo.work.pojian.online/相关核心界面截图如下:{gird column="2" gap="10"}{gird-item}{mtitle title="发起支付"/}{/gird-item}{gird-item}{mtitle title="用户使用支付宝完成支付"/}{/gird-item}{gird-item}{mtitle title="支付成功截图"/}{/gird-item}{gird-item}{mtitle title="订单列表"/}{/gird-item}{/gird}
2024年06月29日
68 阅读
0 评论
0 点赞
2024-05-12
RabbitMQ学习:③基本使用
初始配置添加用户使用默认的 guest 账号登录后,可以在 Admin → Users中添加用户:添加 Virtual Host在Admin → Virtual Hosts 中添加虚拟机:给用户授权点击 Virtual Host 的名称,进入详情界面,可在Permissions中给新建的用户设置权限:建立连接1、新建Maven 项目2、导入依赖<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>net.x2m</groupId> <artifactId>rabbitmq</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>com.rabbitmq</groupId> <artifactId>amqp-client</artifactId> <version>5.21.0</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> </dependencies> </project>3、创建工具类连接 RabbitMQpublic static Connection getConnection() { ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost"); factory.setPort(5672); factory.setUsername("zhuzl"); factory.setPassword("123456"); factory.setVirtualHost("/test"); Connection conn = null; try { conn = factory.newConnection(); } catch (IOException e) { throw new RuntimeException(e); } catch (TimeoutException e) { throw new RuntimeException(e); } return conn; }4、代码层面获取连接后,在管理界面可以看到如下连接信息:5、点击Name 可查看连接详情如下:6、调试界面的连接信息:
2024年05月12日
25 阅读
0 评论
0 点赞
2024-05-12
RabbitMQ学习:②基本概念
本文核心内容参加官网链接:https://www.rabbitmq.com/tutorials/amqp-conceptsPublisher:生产者(发布消息到R啊波比跳MQ中的Exchange)Consumer:消费者(监听RabbitMQ中的Queue中的消息)Exchange:交换机(和生产者建立连接并接收生产者的消息)Queue:队列(Exchange会将消息分发到指定的Queue,Queue和消费者进行交互)Routes:路由(交换机以什么样的策略将消息发布到 Queue)RabbitMQ 的完整架构图:
2024年05月12日
55 阅读
0 评论
0 点赞
2024-05-12
RabbitMQ学习:①安装
背景近期参与公司的在线充值业务的功能开发,该业务涉及多个系统交互,采用MQ的方式实现跨系统通讯:而在我既往的项目经验中还未使用过 MQ,便利用工作之余对相关的知识点进行补充学习。本系列内容即是我的一个0基础入门学习记录,仅做参考。RabbitMQ 基本介绍RabbitMQ 是一个由 Erlang 语言开发的 AMQP 的开源实现。Erlang是为电话交换机编写的语言,天然对分布式和高并发支持良好。常用MQ对比比较项RabbitMQActiveMQRocketMQKafka公司/社区Broadcom Inc.Apache阿里Apache开发语言ErlangJavaJavaScala&Java协议支持AMQP,XMPP,SMTP,STOMPoPENwIRE,stomp,REST,XMPP,AMQP自定义自定义协议,社区封装了http协议支持客户端支持语言官方支持Erlang,Java,Ruby,PHP,.NET,GO,JS等,社区产出多种API,几乎支持所有语言Java,C,C++,Python,PHP,Perl,.NET等Java,C++官方支持Java,社区产出多种API,如PHP,Python等单机吞吐量万级(其次)万级(最差)十万级(最好)十万级(次之)消息延迟微秒级毫秒级毫秒级毫秒以内功能特性并发能力强,性能极其好,延时低,社区活跃,管理界面丰富老牌产品,成熟度高,文档较多MQ功能必要完备,扩展性佳只支持主要的MQ功能,主要为大数据领域场景安装 RabbitMQ为快速安装部署,使用 docker compose 方式运行,docker-compose.yaml文件内容如下:services: rabbitmq: image: rabbitmq:management restart: always container_name: rabbitmq ports: - 5672:5672 - 15672:15672 volumes: - ./data:/var/lib/rabbitmq使用 docker compose up -d 启动后,使用浏览器访问如下地址:http://localhost:15672/,显示如下界面则表示 RabbitMQ 运行成功:在上面的登录界面使用 guest 作为用户名和密码登录,打开如下图所示的主界面:
2024年05月12日
38 阅读
0 评论
0 点赞
2024-04-07
基于 Magic-api + Clickhouse 实现业务数据更新的项目记录
背景介绍项目有用到 Clickhouse 作为数仓,存储一些用户日常业务产生的大数据,下面先简单介绍一下我们这个任务的需求背景:我们的每个用户都会归属于某个用户组,并基于用户所在的计费组织实现产品使用过程中的消费等情况。而按照系统的设定用户初始注册时是没有归属用户组的,计费组织的主账号可以在控制台将用户绑定到该计费组下,也可以解绑,解绑后也可以绑定到其他用户组。为了更好的这个变更情况,我们在 Clickhouse 添加了一张名为 user_type 的表,每次该数据变更都会新增一条记录,该表的结构如下:CREATE TABLE user_type ( `user_id` Nullable(String), `present_type` Nullable(String), `pay_type` Nullable(String), `group_type` Nullable(String), `start_date` Nullable(Date), `end_date` Nullable(Date), `uni_key` Nullable(String) ) ENGINE = Log;实现方案本项目初期由使用 dbt + Clickhouse 的方式来实现,但是经实践运行一段时间后发现 dbt 做数据同步很方便,但是要添加一些业务逻辑就显得很棘手。为了解决 dbt 的问题,我们使用已搭建的 magic-api 来实现这个数据的更新,由于相关数据仅需一天一更新即可,所以我们可以直接利用 magic-api 自带的定时任务机制来实现更新。技术细节为便于相关业务逻辑在接口和定时任务中复用,我们将核心代码写在函数模块中:相关步骤核心代码如下步骤:1、从计费系统获取最新的userType信息var statSQL = `select e.*, CONCAT(e.user_id,'-',e.present_type,'-',e.pay_type,'-',e.group_type,'-',date_format(e.start_date,'%Y-%m-%d')) as uni_key FROM( SELECT a.user_id, CASE WHEN EXISTS ( SELECT 1 FROM ( SELECT t1.user_id user_id from b_contract t1 LEFT JOIN b_contract_item t2 ON t1.id = t2.contract_id WHERE t2.is_present = 0 and t2.received_payments > 0 GROUP BY t1.user_id UNION SELECT u2.user_id user_id from b_user as u1, b_user as u2 where u1.group_id=u2.group_id AND u1.user_id != u2.user_id AND EXISTS( SELECT 1 FROM (SELECT t1.user_id from b_contract t1 LEFT JOIN b_contract_item t2 ON t1.id = t2.contract_id WHERE t2.is_present = 0 and t2.received_payments > 0 GROUP BY t1.user_id) c WHERE c.user_id = u1.user_id ) ) d WHERE a.user_id = d.user_id ) then 'pay' else 'no pay' END as present_type, CASE WHEN EXISTS( SELECT 1 FROM( SELECT t1.user_id FROM b_user t1 , b_group t2 WHERE t1.user_id=t2.pay_user_id AND t2.pay_user_id IS NOT NULL )b WHERE a.user_id = b.user_id ) THEN 'master' ELSE 'slave' END as pay_type, CASE WHEN EXISTS(SELECT 1 FROM(SELECT t1.user_id FROM b_user t1 WHERE t1.group_id IS NOT NULL)b WHERE a.user_id = b.user_id) THEN 'group' ELSE 'no group' END as group_type, CURRENT_DATE as start_date, DATE(null) as end_date FROM b_user a )e` return db['NB'].select(statSQL)2、将上一步获取到的信息存储到Clickhouse 的一张临时表import log; import cn.hutool.core.date.DateUtil; import '@/statForProduction/userTypeStat/getLatestUserTypeData' as getLatestUserType; // ------------------- 一、创建临时表 ------------------- const TEMP_TABLE_NAME = 'user_type_temp' var checkExistRes = db['CH'].select(`SELECT 1 FROM system.tables WHERE database = 'dw' AND name = '${TEMP_TABLE_NAME}'`) log.info(checkExistRes.size() + '') // 不存在表的话就基于 user_type 表创建一张临时表 if (checkExistRes.size() === 0) { var initTemporaryTableSQL = `CREATE TABLE ${TEMP_TABLE_NAME} as user_type` db['CH'].update(initTemporaryTableSQL) } else { // 临时表存在则先清空临时表的数据,便于下一步将输入存入临时表 var truncateTemporaryTableSQL = `truncate table ${TEMP_TABLE_NAME}` db['CH'].update(truncateTemporaryTableSQL) } // ------------------- 二、获取最新的用户类型数据 ------------------- log.info(`============ 开始从计费系统获取最新的用户类型数据,该操作耗时较长,请耐心等待 ============`) var timer = DateUtil.timer() const userTypeList = getLatestUserType() log.info(`getLatestUserType cost time: ${timer.intervalPretty()}.`) // ------------------- 三、将数据存入临时表 ------------------- const BATCH_INSERT_COUNT = 1000 // 分批次入临时表,一次插入记录条数 var timer = DateUtil.timer() const allDataCount = userTypeList.size() if (allDataCount > 0) { log.info(`开始导入数据到临时表,待导入的总记录数为:${allDataCount},预计分${Math.ceil(allDataCount/BATCH_INSERT_COUNT)::int}批导入。`) const willInsertArr = [] var insertSQL = `insert into ${TEMP_TABLE_NAME}(user_id,present_type,pay_type,group_type,start_date,end_date,uni_key)` // 分批次插入临时表 for (index,userTypeItem in userTypeList) { willInsertArr.push(`('${userTypeItem.userId}','${userTypeItem.presentType}','${userTypeItem.payType}','${userTypeItem.groupType}','${userTypeItem.startDate}', null,'${userTypeItem.uniKey}')`) if (willInsertArr.size() === BATCH_INSERT_COUNT) { db['CH'].update(`${insertSQL} values${willInsertArr.join(',')}`) // 清空数据 willInsertArr.clear() log.info('Batch insert:' + index) } } // 不满整批次数据单独处理 if (willInsertArr.size() > 0) { db['CH'].update(`${insertSQL} values${willInsertArr.join(',')}`) // 清空数据 willInsertArr.clear() } } log.info(`insert latest user type to Temporary Table cost time: ${timer.intervalPretty()}.`) return true3、将临时表数据跟前一次最新的用户数据对比后,将有变更和新增的数据写入user_type表import log; import cn.hutool.core.date.DateUtil; const LATEST_TABLE_NAME = 'user_type_latest' // 用户最新类型数据表 const TEMP_TABLE_NAME = 'user_type_temp' // 该表存储从计费表获取到用户当前的用户类型数据,已在上一步获取数据完毕 // 一、从user_type表获取所有用户最新的用户类型数据并插入到用于计算的临时表 // 1.1 新建临时表,用于存储每个用户user_type 表中最新的用户类型数据 var checkExistRes = db['CH'].select(`SELECT 1 FROM system.tables WHERE database = 'dw' AND name = '${LATEST_TABLE_NAME}'`) log.info(checkExistRes.size() + '') // 不存在表的话就基于 user_type 表创建一张临时表 if (checkExistRes.size() === 0) { var initTemporaryTableSQL = `CREATE TABLE ${LATEST_TABLE_NAME} as user_type` db['CH'].update(initTemporaryTableSQL) } else { // 临时表存在则先清空临时表的数据,便于下一步将输入存入临时表 var truncateTemporaryTableSQL = `truncate table ${LATEST_TABLE_NAME}` db['CH'].update(truncateTemporaryTableSQL) } // 1.2 将最新数据写入临时表 // 该方式在数据量较大的情况下极有可能导致内存溢出,拟采取其他方案:在user_type 数据初始化的时候,将最新的用户类型数据存储到user_type_latest表,对比更新完成后将临时表的数据更新到user_type_latest便于下次对比 // const insertLatestDataSQL = `insert into ${LATEST_TABLE_NAME} SELECT user_type.user_id uid,user_type.present_type ,user_type.pay_type ,user_type.group_type,user_type.start_date,user_type.end_date,user_type.uni_key // FROM user_type, (SELECT user_type.user_id uid2,max(user_type.start_date) AS latestDate FROM user_type GROUP BY user_type.user_id) AS temp // WHERE user_type.start_date = temp.latestDate and uid = temp.uid2` // db['CH'].update(insertLatestDataSQL) // 二、两个临时表的数据做对比,并将最新数据更新到 user_type var timer = DateUtil.timer() // 2.1 更新有变更的数据 const changedInsertSQL = `insert into user_type select tuts.* from ${LATEST_TABLE_NAME} tutl left join ${TEMP_TABLE_NAME} tuts on tutl.user_id =tuts.user_id where tutl.present_type != tuts.present_type or tutl.pay_type != tuts.pay_type or tutl.group_type != tuts.group_type` timer.start("insertChangeData") db['CH'].update(changedInsertSQL) // 2.2 新增用户数据直接插入 timer.start("insertNewData") const insertNewUserSQL = `insert into user_type select * from ${TEMP_TABLE_NAME} tuts where tuts.user_id not in (select tutl.user_id from ${LATEST_TABLE_NAME} tutl) ` db['CH'].update(insertNewUserSQL) // 三、如果有数据更新,则将临时表的数据替换latest表 // 3.1 清理已有的数据 const truncateLatestTableSQL = `truncate table ${LATEST_TABLE_NAME}` db['CH'].update(truncateLatestTableSQL) // 3.2 从临时表导入最新的数据 const initialLatestTableDataSQL = `insert into ${LATEST_TABLE_NAME} select * from ${TEMP_TABLE_NAME}` db['CH'].update(initialLatestTableDataSQL) log.info(`insertChangeData cost time: ${timer.intervalPretty('insertChangeData')}`) log.info(`insertNewUser cost time: ${timer.intervalPretty('insertNewData')}`) // 四、清理临时表 const dropTempTableSQL = `drop table ${TEMP_TABLE_NAME}` db['CH'].update(dropTempTableSQL) return true 定义好相关函数后,我们可以直接在接口中用起来了,为此我定义了两个接口,一个接口用于数据初始化,一个接口用于手动更新数据:接口定义01数据初始化import log; import '@/statForProduction/userTypeStat/maintenance/clearUserTypeData' as clearUserTypeData import '@/statForProduction/userTypeStat/saveToTemporaryTable' as saveToTemporaryTable const LATEST_TABLE_NAME = 'user_type_latest' // 用户最新类型数据表 const TEMP_TABLE_NAME = 'user_type_temp' // 该表存储从计费表获取到用户当前的用户类型数据 // 一、清空所有user_type表的数据 clearUserTypeData() // 二、一次性写入所有 saveToTemporaryTable() // 三、将临时表的所有数据一次性写入user_type 表作为初始数据 const initialUserTypeDataSQL = `insert into user_type select * from ${TEMP_TABLE_NAME}` db['CH'].update(initialUserTypeDataSQL) // 四、将数据写入最新用户类型表,便于下一次做数据比对 // 4.1 基于 user_type 表 创建 user_type_latest 表 var checkExistRes = db['CH'].select(`SELECT 1 FROM system.tables WHERE database = 'dw' AND name = '${LATEST_TABLE_NAME}'`) log.info(checkExistRes.size() + '') // 不存在表的话就基于 user_type 表创建一张 if (checkExistRes.size() === 0) { var createLatestTableSQL = `CREATE TABLE ${LATEST_TABLE_NAME} as user_type` db['CH'].update(createLatestTableSQL) } else { // 表存在则先清空表的数据,便于下一步将最新的用户类型数据存入该表 var truncateLatestTableSQL = `truncate table ${LATEST_TABLE_NAME}` db['CH'].update(truncateLatestTableSQL) } // 4.2 插入该表的初始数据 const initialLatestTableDataSQL = `insert into ${LATEST_TABLE_NAME} select * from ${TEMP_TABLE_NAME}` db['CH'].update(initialLatestTableDataSQL) // 五、清理临时表 const dropTempTableSQL = `drop table ${TEMP_TABLE_NAME}` db['CH'].update(dropTempTableSQL) 02手工同步用户类型数据/** * 本接口用于手工临时同步数据用,日常使用定时任务自动同步操作即可 */ import '@/statForProduction/userTypeStat/saveToTemporaryTable' as saveToTemporaryTable import '@/statForProduction/userTypeStat/updateUserTypeData' as updateUserTypeData saveToTemporaryTable() updateUserTypeData()添加定时任务本任务用到的部分 Clickhouse SQL-- 判断数据表是否存在 SELECT 1 FROM system.tables WHERE database = 'dw' AND name = 'temp_user_type_session' -- 根据user_type 表创建一张名为 temp_user_type_session 的临时表 CREATE TABLE temp_user_type_session as user_type; -- 清空某数据表中的所有内容 truncate table temp_user_type_session; -- 查询所有用户最新的用户类型数据 SELECT user_type.user_id uid,user_type.present_type ,user_type.pay_type ,user_type.group_type,user_type.start_date,user_type.end_date,user_type.uni_key FROM user_type, (SELECT user_type.user_id uid2,max(user_type.start_date) AS latestDate FROM user_type GROUP BY user_type.user_id) AS temp WHERE user_type.start_date = temp.latestDate and uid = temp.uid2; -- 获取有差异的数据 select tutl.*,tuts.user_id user_id2, tuts.present_type present_type2, tuts.pay_type pay_type2, tuts.group_type group_type2, tuts.start_date start_date2,tuts.uni_key uni_key2 from temp_user_type_latest tutl left join temp_user_type_session tuts on tutl.user_id =tuts.user_id where tutl.present_type != tuts.present_type or tutl.pay_type != tuts.pay_type or tutl.group_type != tuts.group_type;
2024年04月07日
94 阅读
0 评论
0 点赞
2024-02-28
酷壳(coolshell.cn)镜像站建设经验分享之五——镜像站制作
为了能保障镜像站能永久提供服务,不依赖于后端的数据库及中间件,我们需要将爬取到的正文内容做静态化发布,静态化发布后,我们可以将内容托管到 Gitee 或 Github。
2024年02月28日
55 阅读
0 评论
0 点赞
2024-02-28
酷壳(coolshell.cn)镜像站建设经验分享之四——解析文章正文html中的图片并离线下载图片到本地
上一步我们已经得到了文章详情所需的数据,要想保障酷壳站停掉后,我们仍然能完整的查看内容,我们需要将正文中的图片也都下载到本地,然后后续发布成镜像站的时候就不用外链图片资源了,这样也在最大程度保障原站无法访问的情况下,能通过镜像站完整的浏览博客。
2024年02月28日
114 阅读
0 评论
0 点赞
2024-02-28
酷壳(coolshell.cn)镜像站建设经验分享之三——分析爬取的文章html得到正文及相关的元数据
这个过程主要是分析文章 html,得到文章如下元数据:正文内容、作者、发布时间、分类列表、Tag词列表、浏览次数、评论次数,并将相关元数据存储到 crawler_article 表的对应字段里。
2024年02月28日
101 阅读
0 评论
0 点赞
2024-02-28
酷壳(coolshell.cn)镜像站建设经验分享之二——整站内容爬取
在前面的数据准备章节咱们已经在 magic-boot 基础工程上整合了 jsoup,而 jsoup 正是 java 领域数据爬取及 html 解析的神器,对于 html 的处理如同 JS 中的 jQuery 一般神一样的存在,而且一些 API 跟 jQuery 也比较接近,使用起来不得不说那叫一个丝滑。废话少说,咱们开始 show me the code。
2024年02月28日
26 阅读
0 评论
0 点赞
2024-02-25
酷壳(coolshell.cn)镜像站建设经验分享之一——准备工作
看酷壳网的一些评论,也有不少人像我一样,在担心以后域名或服务器到期后网站不能访问,为了解决这个后顾之忧,我便在工作之余开启了酷壳网站镜像之路,这一系列的文章也就是将这一过程做一个记录,也便于帮助有需要的朋友能够做到举一反三。
2024年02月25日
103 阅读
2 评论
0 点赞
2024-02-21
magic-boot 整合 Clickhouse 及在 magic-api 中的基本使用
项目有用到 Clickhouse 作为数仓, magic-boot 作为万金油般的存在,肯定是需要整合 Clickhouse 获取数据的,下面我们就开始吧。一、整合 clickhouse-jdbc 驱动根据clickhouse 官方文档的指引,在项目的 Maven 依赖管理文件(pom.xml)中的 dependencies 节点添加如下依赖项: <dependency> <groupId>com.clickhouse</groupId> <artifactId>clickhouse-jdbc</artifactId> <classifier>all</classifier> <version>0.6.0</version> </dependency>注:dependency 中一定要添加 <classifier>all</classifier>,否则会出现找不到依赖的className的异常二、magic-api 中添加数据源在 magic-api 主界面右侧的 DataSource 面板中,单击「+」按钮,打开「创建数据源」弹出层,如下图所示:相关表单项填写如下:名称:任意,只要自己能区别数据源即可Key:为便于在代码中引用,尽量采用简写URL:jdbc:(ch|clickhouse)[:<protocol>]://endpoint1,endpoint2,...?param1=value1¶m2=value2用户名:用户名密码:密码驱动类:com.clickhouse.jdbc.ClickHouseDriver类型:com.zaxxer.hikari.HikariDataSource。用Hikari 和 Druid 连接池测试都没碰到问题。本次测试填写后的连接池示例如下图所示:在 magic-api 中写测试代码进行功能验证创建数据表db['CH'].update(""" CREATE TABLE test_for_magic_boot ( `id` UUID, `user_name` String, `real_name` String, `birthday` Date, `gender` String ) ENGINE = MergeTree ORDER BY birthday SETTINGS index_granularity = 8192; """);添加测试数据// 添加数据要使用 update方法,使用insert 方法会报错。 // https://gitee.com/ssssssss-team/magic-api/issues/I4SQYW db['CH'].update(`insert into test_for_magic_boot(id,user_name,real_name,birthday,gender) values(#{uuid()},'shiyu', '时羽','1991-12-15', 'F'),(#{uuid()},'lint', '李宁涛','1985-11-19', 'M'),(#{uuid()},'gaowz', '高文中','1968-01-23', 'M')`)修改测试数据db['CH'].update(`update test_no_index set real_name='时大款' where user_name='shiyu'`) Clickhouse 更新操作有一些限制索引列不能进行更新分布式表不能进行更新不适合频繁更新或point更新查询数据return db['CH'].select('select * from test_for_magic_boot')删除数据db['CH'].update(`delete from test_for_magic_boot where user_name='lint'`)删除测试数据表db['CH'].update('drop table test_for_magic_boot');
2024年02月21日
206 阅读
0 评论
0 点赞
2023-12-13
Jeepay开源版使用过程中踩过的坑
1、商户系统登录问题添加商户的时候有设置登录名,但是没有设置账号密码的位置,好不容易找到对商户重置密码的地方,但是那个勾选重置密码的复选框又超级容易被理解为用户下次登录需重置密码的配置项。勾选后有提示重置为默认密码,但是又没有说明默认密码是什么,然后非得要查看源码才知道是通过常量设置的默认密码为:jeepay6662、证书文件不存在问题好不容易登录商户系统了,进行支付测试的功能验证,提示证书文件不存在:整个应用部署过程完全是基于官方提供的 docker-compose.yml 文件,最后发现默认配置的 /home/jeepay/upload 目录根本就没有挂载到宿主机,修改 docker-compose.yml ,payment、manager、merchant 应用的 volumes 应用均挂载 /home/jeepay 目录,如: volumes: - ./jeepayData:/home/jeepay3、支付测试不显示二维码的问题支付测试时不显示支付二维码,发现HTTP请求中有个 404 请求:检查代码,确定应用存在对应的接口路径:查看docker 日志复现如下 error 信息:基于该信息可得知,nginx在接收到二维码图片请求时根本就没有请求到 jeepay-payment 这个后端服务,而是直接请求了root 目录中的文件,由此我们调整一下 代理的api接口的优先级,修改 jeepay-ui 根目录下的 default.conf.template 文件,在/api/ 前添加 ^~ ,nginx的路径匹配规则如下:/:通用匹配,任何请求都可以匹配=:用于不含正则表达式的uri前,要求请求字符串与uri严格匹配,如果匹配成功,就停止继续向下搜索并立即处理该请求。~:用于表示uri包含正则表达式,并且区分大小写。~*:用于表示uri包含正则表达式,并且不区分大小写。^~:用于不包含正则表达式的uri前,要求nginx服务器找到标识uri和请求字符串匹配度最高的location后,立即使用此location处理请求,而不再使用location块中的正则uri与请求字符串做匹配。!~和!~*:分别表示区分大小写不匹配和不区分大小写不匹配的正则优先级:= --> ^~ --> /* #当有多个包含/进行正则匹配时,选择正则表达式最长的location配置执行。多个location配置的情况下匹配顺序为: 首先匹配 =,其次匹配^~, 其次是按文件中顺序的正则匹配,最后是交给 /通用匹配。当有匹配成功时候,停止匹配,按当前匹配规则处理请求。注意:如果uri包含正则表达式,则必须要有 ~ 或者 ~ 标识。修改后的 default.conf.template 文件如下所示:server { listen 80; listen [::]:80; server_name localhost; root /workspace/; try_files $uri $uri/ /index.html; location ^~ /api/ { proxy_next_upstream http_502 http_504 error timeout invalid_header; proxy_pass http://$BACKEND_HOST; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } # favicon.ico location = /favicon.ico { log_not_found off; access_log off; } # robots.txt location = /robots.txt { log_not_found off; access_log off; } # assets, media location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv)$ { expires 7d; access_log off; } # svg, fonts location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff2?)$ { add_header Access-Control-Allow-Origin "*"; expires 7d; access_log off; } # gzip gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; } 4、公众号/小程序支付的URL多了一级/cashier应用部署完毕,进行支付测试时,采用「微信支付二维码」的方式已支付成功,但是采用「公众号/小程序」的支付方式,在扫码后,发现扫码后的页面显示空白,进一步排查,发现是由于页面的css和js资源文件404导致的,进一步排查,是由于请求的资源多了一级path,一下是问题排查过程:系统配置中的支付网关地址填写的是 https://jeepay-cashier.work.zhuzhilong.com:但是在使用支付测试功能,支付方式采用「公众号/小程序」进行支付测试时,生成的二维码如下:二维码识别后的地址为:https://jeepay-cashier.work.zhuzhilong.com/cashier/index.html#/hub/78d439f3140fe4047c7f8f6cda1048313636890021b83c0c167270dbce4fc2ff根据应用部署情况,比预期的访问路径多了 /cashier,查看源代码后,发现这个路径是写死在 com.jeequan.jeepay.core.model.DBApplicationConfig.java中的:去掉相关方法中的 /cashier 后,根据 docker-compose.yml 重新构建镜像及重启服务后,可正常支付。
2023年12月13日
207 阅读
0 评论
0 点赞
2023-10-31
magic-boot/magic-api 使用随记
magic-api 是一款非常优秀的快速开发框架,在做大屏的过程中找到的宝贝应用,可以用类JS 语法快速开发接口,能非常方便的操作数据库及处理一些复杂的业务逻辑,而 magic-boot 是基于 magic-api 开发的一款快速开发平台,提供了基本的用户鉴权、后台管理等功能。在实际项目过程中我基于 magic-boot 做了如下事项:1、通过Matomo的API定时同步数据至数仓做大数据分析2、采集coolshell.cn整站数据3、每周五定时推送企业微信消息,提醒同事写周报更多功能待进一步挖掘……下面是我在项目中有用到的技术点的一个记录,会在项目过程中不断更新,便于后续有其他项目用到的话,能快速查找运用。获取 系统设置/配置中心 模块设置的配置项import '@/configure/getBykey' as configure; var baseURL = configure('matomo.base-url'); var authToken = configure('matomo.auth-token');http请求数据示例import cn.hutool.json.JSONUtil import org.springframework.util.StringUtils import http; import log; import '@/configure/getBykey' as configure; // 从配置中心获取接口所需数据 var baseURL = configure('matomo.base-url'); var authToken = configure('matomo.auth-token'); // 组转请求URL var reqURL = `${baseURL}?module=API&method=${method}&format=JSON&token_auth=${authToken}`; if (!StringUtils.isEmpty(params)) { reqURL += `&${params}`; } log.info(`reqURL:${reqURL}`); // 请求数据 var resData = http.connect(reqURL).contentType('application/json').get().getBody(); return resData;在原基础上增加http请求出错重试机制import cn.hutool.json.JSONUtil import org.springframework.util.StringUtils import cn.hutool.core.date.DateUtil import cn.hutool.core.thread.ThreadUtil import http import log import '@/configure/getBykey' as configure; // 从配置中心获取接口所需数据 var baseURL = configure('matomo.base-url'); var authToken = configure('matomo.auth-token'); // 组转请求URL var reqURL = `${baseURL}?module=API&method=${method}&format=JSON&token_auth=${authToken}`; if (!StringUtils.isEmpty(params)) { reqURL += `&${params}`; } // log.info(`reqURL:${reqURL}`); var requestStartTime = DateUtil.now(); var successFlag = '' var exceptionContent = '' // 请求数据 var resData = '' // 最大重试次数 var MAX_RETRY_COUNT = 5 // 当前重试次数 var retryCount = 0; while(retryCount < MAX_RETRY_COUNT && successFlag !== 'Y') { try { resData = http.connect(reqURL).contentType('application/json').get().getBody(); successFlag = 'Y' } catch(e) { successFlag = 'N' ThreadUtil.sleep(1000); retryCount++ exceptionContent = e.getMessage(); } } var requestEndTime = DateUtil.now(); db.table('matomo_sync_log').insert({ 'apiMethod' : method, 'requestParams' : params, 'responseContent': resData.asString(), 'exceptionContent': exceptionContent, 'requestTime':requestStartTime, 'responseTime': requestEndTime, 'retryCount':retryCount, 'successFlag': successFlag}); return resData;分页获取数据在获取一些详情数据的时候,存在数据量超大的情况,一次性获取所有数据极有可能会导致数据库及应用挂掉,即便不挂掉的情况下,也会超长事件才会响应结果,所以采用分页获取还是很有必要的。下面的代码是在实际项目中分页调用Matomo的接口获取输入然后将接口返回的数据,结构化处理后,保存到本地数据库。 import cn.hutool.json.JSONUtil import log; import '@/dmcfns/sendMatomoRequest' as getMatomoData; var PAGE_SIZE=10 // 每页获取记录数,获取后批量入库 var currentPage = 0// 当前页 var needLoad = true // 继续加载数据标识,当当前页加载的内容小于PAGE_SIZE时则不再加载 while(needLoad) { var resData = getMatomoData('Live.getLastVisitsDetails', `period=day&date=${date}&idSite=${siteId}&doNotFetchActions=1&filter_offset=${PAGE_SIZE * currentPage}&filter_limit=${PAGE_SIZE}`) if (resData.asString().startsWith("[")) { var siteDatas = JSONUtil.parseArray(resData); var siteData = []; for (index, site in siteDatas) { var siteObj = siteDatas.getJSONObject(index); var visitId = siteObj.getStr("idVisit"); var visitorId = siteObj.getStr("visitorId"); var visitIp = siteObj.getStr("visitIp"); var longitude = siteObj.getStr("longitude"); var latitude = siteObj.getStr("latitude"); var userId = siteObj.getStr("userId"); var country = siteObj.getStr("country"); var referrerName = siteObj.getStr("referrerName"); var visitProps = JSONUtil.toJsonStr(siteObj); db.table('matomo_daily_visit').insert({ date, siteId, visitId, visitorId, visitIp, longitude, latitude, userId, country, referrerName, visitProps }) } if (siteDatas.size() === PAGE_SIZE) { currentPage++ } else { needLoad = false } } }使用多数据源操作数据库db['ZR'].table('crawler_list').insert({ pageURL: linkURL, articleTitle:linkTitle })根据主键更新部分字段内容var updateMap = { id: visitItem.id, ipCountry: country, ipProvince: province, ipCity: city } db.table('matomo_daily_visit').primary('id').update(updateMap)修改某个字段的值db.table("sys_user").column("isLogin", isLogin).where().eq("id",id).update()推送消息至企业微信机器人import http import log // 测试机器人 // var ROBOT_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' // 超算云研发部2023 微信群的eHour 机器人 var ROBOT_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx' var msg = { "msgtype": "text", "text": { "content": """为便于公司开展项目成本核算相关工作,请各位同事及时登录eHour系统录入本周工作工时,如有系统使用相关问题可联系 XXX,感谢配合[抱拳][抱拳]\neHour系统链接如下:http://172.18.3.xxx/""", "mentioned_list":["@all"] } } http.connect(ROBOT_URL).body(msg).post(); log.info('eHour消息推送成功') // 以下cron表达式为每周五16:30分执行 00 30 16 * * 05读取Excel文件并转换为jsonimport cn.hutool.poi.excel.ExcelUtil import request import log var datas = ExcelUtil.getReader(new ByteArrayInputStream(request.getFile('file').getBytes())).readAll() var sourceDatas = datas::stringify::json导出Excelimport cn.hutool.poi.excel.ExcelWriter; import cn.hutool.poi.excel.ExcelUtil; import cn.hutool.json.JSONUtil; import log; import response import java.io.*; var list = db['MDC'].select("select * from crawler_resource limit 10") //通过hutool工具创建的excel的writer,默认为xls格式 ExcelWriter writer= ExcelUtil.getWriter(); var bos = new ByteArrayOutputStream(); log.info("List:\n" + JSONUtil.toJsonPrettyStr(list)) try { //一次性写出内容,使用默认样式,强制输出标题 writer.write(list,true); writer.flush(bos,true); } finally { bos.flush(); writer.close(); } return response.download(bos.toByteArray(), "crawler_resource_list.xlsx");导出Excel时自定义头部import cn.hutool.poi.excel.ExcelWriter; import cn.hutool.poi.excel.ExcelUtil; import cn.hutool.json.JSONUtil; import cn.hutool.core.date.DateUtil import log; import response import java.io.*; var currentDb = db['MS'] var taskObj = currentDb.table('ai_resource_task').where().eq("id",taskId).selectOne() if (taskObj == null) { return "分析任务Id不存在,请修改请求接口后重试" } var pageList = currentDb.select('select * from ai_resource_cost where task_id = #{taskId}') const res = [] pageList.forEach(item => { var userObj = { "用户ID": item.userId, "集群ID":item.clusterId, "资源ID": item.resourceUuid, "资源状态": item.recordType, "开始时间": DateUtil.formatDateTime(DateUtil.date(item.startTime)), "结束时间": DateUtil.formatDateTime(DateUtil.date(item.endTime)), "耗时(单位:秒)": item.costSeconds, "耗时": item.costTimeShow, "计费类型": item.billingType, "资源规格": item.resourceScale, } res.push(userObj) }) ExcelWriter writer= ExcelUtil.getWriter(); writer.setColumnWidth(-1, 18); var bos = new ByteArrayOutputStream(); try { //一次性写出内容,使用默认样式,强制输出标题 writer.write(res,true); writer.flush(bos,true); } finally { bos.flush(); writer.close(); } return response.download(bos.toByteArray(), taskObj.taskName + "_分析结果.xlsx");数据库事务处理db.transaction(() => { if (archived_at == null) { //项目取消归档 //推送取消归档的通知 db.table("project_tasks").primary("project_id").primary("archived_follow").update({ project_id: project_id, archived_follow: 1, archived_at: null, archived_follow: 0, }) } else { //项目归档 db.table("project_tasks").primary("project_id").primary("archived_at").update({ project_id: project_id, archived_at: null, archived_at: archived_at, archived_follow: 1, }) } db.table("projects").primary("id").update({ id: project_id, archived_at: archived_at, archived_userid: userid }) })
2023年10月31日
168 阅读
0 评论
0 点赞
2023-10-19
Mybatis-Plus 分页功能实现流程
Mybatis 自带分页功能,但是该分页功能是基于内存的分页,也就是会讲所有符合条件的数据查询出来,然后在从内存中获取当前页的信息,这种方式在数据量大的情况下会存在严重的性能问题。我们通过 Mybatis-Plus 自带的分页插件可以很好的解决这个问题,实现步骤记录如下:1、 添加配置类,示例内容如下:package com.paratera.protect.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * Mybatis Plus 配置类,主要用于继承分页插件 * @author 朱治龙 * @date 2023-10-19 23:14:00 */ @Configuration public class MyBatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } } 2、使用分页方法查询2.1、selectPage2.1.1 示例代码 @Test void testSelectPage() { QueryWrapper<Staff> qw = Wrappers.query(); qw.ge("age", 30); Page<Staff> page = new Page<Staff>(1, 2); // 不查记录数 // page.setSearchCount(false); Page<Staff> pageData = staffMapper.selectPage(page, qw); System.out.println("总页数:" + pageData.getPages()); System.out.println("总记录数:" + pageData.getTotal()); pageData.getRecords().forEach(System.out::println); }2.1.2 查询结果DEBUG==> Preparing: SELECT COUNT(*) AS total FROM staff WHERE (age >= ?) DEBUG==> Parameters: 30(Integer) TRACE<== Columns: total TRACE<== Row: 4 DEBUG<== Total: 1 DEBUG==> Preparing: SELECT id,name,age,email,mobile,manager_id,create_time FROM staff WHERE (age >= ?) LIMIT ? DEBUG==> Parameters: 30(Integer), 2(Long) TRACE<== Columns: id, name, age, email, mobile, manager_id, create_time TRACE<== Row: 1087982257332887553, 大boss, 40, 001@paratera.com, 13888886666, null, 2019-01-11 14:20:20 TRACE<== Row: 1094590409767661570, 张雨琪, 31, zjq@blsc.com, 18684700070, 1088248166370832385, 2019-01-14 09:15:15 DEBUG<== Total: 2 总页数:2 总记录数:4 Staff(id=1087982257332887553, name=大boss, age=40, email=001@paratera.com, mobile=13888886666, managerId=null, createTime=2019-01-11T14:20:20) Staff(id=1094590409767661570, name=张雨琪, age=31, email=zjq@blsc.com, mobile=18684700070, managerId=1088248166370832385, createTime=2019-01-14T09:15:15) 2.2、selectMapsPage2.2.1 示例代码 @Test void testSelectMapsPage() { QueryWrapper<Staff> qw = Wrappers.query(); qw.ge("age", 30); Page<Map<String, Object>> page2 = new Page<>(1, 2); Page<Map<String, Object>> pageData2 = staffMapper.selectMapsPage(page2, qw); System.out.println("总页数:" + pageData2.getPages()); System.out.println("总记录数:" + pageData2.getTotal()); pageData2.getRecords().forEach(System.out::println); }2.2.2 查询结果DEBUG==> Preparing: SELECT COUNT(*) AS total FROM staff WHERE (age >= ?) DEBUG==> Parameters: 30(Integer) TRACE<== Columns: total TRACE<== Row: 4 DEBUG<== Total: 1 DEBUG==> Preparing: SELECT id,name,age,email,mobile,manager_id,create_time FROM staff WHERE (age >= ?) LIMIT ? DEBUG==> Parameters: 30(Integer), 2(Long) TRACE<== Columns: id, name, age, email, mobile, manager_id, create_time TRACE<== Row: 1087982257332887553, 大boss, 40, 001@paratera.com, 13888886666, null, 2019-01-11 14:20:20 TRACE<== Row: 1094590409767661570, 张雨琪, 31, zjq@blsc.com, 18684700070, 1088248166370832385, 2019-01-14 09:15:15 DEBUG<== Total: 2 总页数:2 总记录数:4 {create_time=2019-01-11T14:20:20, name=大boss, mobile=13888886666, id=1087982257332887553, age=40, email=001@paratera.com} {create_time=2019-01-14T09:15:15, manager_id=1088248166370832385, name=张雨琪, mobile=18684700070, id=1094590409767661570, age=31, email=zjq@blsc.com} 3、附加说明使用分页插件查询时默认是会执行两条 SQL,一条获取当前页的数据,一条获取总记录数。在某些场景下(如瀑布流模式),只需要获取当前页的内容即可,不需要总记录数相关分页数值,此时可在创建 Page 时,第三个参数给值为 false:Page(long current, long size, boolean searchCount)。也可使用 page.setSearchCount(false);
2023年10月19日
110 阅读
0 评论
0 点赞
2023-10-18
Mybatis-Plus 自定义SQL
有的时候使用条件构造器自定义SQL满足不了我们的需求,我们既想使用 Wrapper,又想使用SQL,MP 对这种方式也提供了支持,MP 版本号应≥3.0.7。下面是该方案的实现记录:实现方案一:Mapper接口中使用@Select注解1、Mapper 示例代码如下:import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.paratera.protect.entity.Staff; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; /** * @author 朱治龙 * @date 2023-10-17 11:46:00 */ public interface StaffMapper extends BaseMapper<Staff> { @Select("select * from staff ${ew.customSqlSegment}") List<Staff> selectAll(@Param(Constants.WRAPPER)Wrapper<Staff> wrapper); }2、调用示例代码: @Test void testSelfSQL() { LambdaQueryWrapper<Staff> lqw = Wrappers.lambdaQuery(Staff.class); lqw.eq(Staff::getName, "朱治龙").and(wq2 -> wq2.lt(Staff::getAge, 40).or().isNotNull(Staff::getEmail)); List<Staff> staffList = staffMapper.selectAll(lqw); staffList.forEach(System.out::println); }3、输出结果DEBUG==> Preparing: select * from staff WHERE (name = ? AND (age < ? OR email IS NOT NULL)) DEBUG==> Parameters: 朱治龙(String), 40(Integer) TRACE<== Columns: id, name, age, email, mobile, manager_id, create_time TRACE<== Row: 1714166763984199681, 朱治龙, 36, zhuzl@blsc.cn, 15084978453, 1088248166370832385, 2023-10-17 14:29:38 DEBUG<== Total: 1 Staff(id=1714166763984199681, name=朱治龙, age=36, email=zhuzl@blsc.cn, mobile=15084978453, managerId=1088248166370832385, createTime=2023-10-17T14:29:38)实现方案二:使用xml1、配置xml文件的存放路径。再application.yml中添加 xml 文件的引用路径配置mybatis-plus: mapper-locations: - classpath:/mapper/*Mapper.xml2、在resources目录下添加 mapper 目录,并新建 mapper 文件,如文件名为 StaffMapper.xml,示例内容为:<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.paratera.protect.dao.StaffMapper"> <select id="selectByXml" resultType="com.paratera.protect.entity.Staff"> select * from staff ${ew.customSqlSegment} </select> </mapper>3、Mapper 接口中添加方法,示例代码如下:package com.paratera.protect.dao; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import com.paratera.protect.entity.Staff; import org.apache.ibatis.annotations.Param; import java.util.List; /** * @author 朱治龙 * @date 2023-10-17 11:46:00 */ public interface StaffMapper extends BaseMapper<Staff> { List<Staff> selectByXml(@Param(Constants.WRAPPER)Wrapper<Staff> wrapper); }4、调用示例代码: @Test void testSelfSQL2() { LambdaQueryWrapper<Staff> lqw = Wrappers.lambdaQuery(Staff.class); lqw.eq(Staff::getName, "朱治龙").and(wq2 -> wq2.lt(Staff::getAge, 50).or().isNotNull(Staff::getEmail)); List<Staff> staffList = staffMapper.selectByXml(lqw); staffList.forEach(System.out::println); }5、输出结果DEBUG==> Preparing: select * from staff WHERE (name = ? AND (age < ? OR email IS NOT NULL)) DEBUG==> Parameters: 朱治龙(String), 50(Integer) TRACE<== Columns: id, name, age, email, mobile, manager_id, create_time TRACE<== Row: 1714166763984199681, 朱治龙, 36, zhuzl@blsc.cn, 15084978453, 1088248166370832385, 2023-10-17 14:29:38 DEBUG<== Total: 1 Staff(id=1714166763984199681, name=朱治龙, age=36, email=zhuzl@blsc.cn, mobile=15084978453, managerId=1088248166370832385, createTime=2023-10-17T14:29:38)
2023年10月18日
57 阅读
0 评论
0 点赞
2022-09-20
记一次KBS项目实施经验分享
背景介绍KBS 是我入职并行后接触的第一个项目,该项目之前有同事基于 OpenKB(采用 Node + Express + Mongodb技术栈的开源知识库应用,github地址:https://github.com/mrvautin/openKB) 搭建过一个简单的知识库,但是初步实施后的效果不是很符合预期,便安排我着手知识库相关的后续工作。以前在 TRS 的时候有接触过三一项目的知识库项目,该项目基于 TRS 自主研发的企业知识门户产品 —— TRS EKP V6.5 进行实施,是整合了公司的EKP、全文检索系统、数据网关系统等产品综合实施的一套企业内部知识分享的平台,是一个将公司内部各领域专家们的经验进行梳理沉淀的知识管理工具。经过前期的了解,我们将要做的知识库跟之前实施的三一知识库项目有非常大的出入,主要是用于一些产品文档的发布,类似阿里云、腾讯云的产品文档生成工具。最开始有考虑采用纯前端的方式实现,如当前较主流的VuePress实现,这样可以很方便的实现markdown文档编写 + algolia全文检索,并能基于git很方便达到文档版本控制的目的,但在深入沟通后,发现该方式不能满足其他的需求,如支持审核发布、支持用户认证后才可浏览。所以只能考虑其他的解决方案了。经过沟通后,决定先找个开源的 CMS,做个基础效果,再论证可行性技术调研及产品选型结合以往工作经验,经过沟通,最终考虑采用CMS的方式来实现我们的知识库项目。可考虑开源 CMS 或低成本的采购商业 CMS。以下是一些当时(2021-07-05)调研的一些开源 CMS 情况:经过沟通,最终选择了基于 jspxCMS 来进行项目可行性验证实施工作,主要有如下原因:1、jspxCMS 采用 SpringBoot + FreeMarker 跟我们团队技术栈较接近2、编辑器支持Ueditor 和 Markdown 两种编辑模式,适合给运营人员进行富文本排版及程序员轻量内容排版3、可商用,但需在页面底部保留jspxCMS版权声明,购买商业版后可去除,商业版价格也就几千块钱,且商业版支持站群模式,功能试用满足运营要求的话,可购买商业版License。项目实施过程1、搭建示例效果项目初期在本地部署了 jspxCMS 应用,准备基于该CMS实现基本的内容管理及网站发布工作。有阿里云文档做参考,主要是仿站,经过两天的实施工作,在没有UI参与的情况下实现了站点的最初的界面效果:知识库首页产品首页内容详情页搜索结果页按照目前的规划,KBS 系统可以通过栏目的方式很方便的做成公司整个产品体系的知识库门户。2、系统功能改造经过需求梳理,整理了如下待办事项:1、知识库站点模板{x} 知识库首页模板 zzl{x} 产品页模板 zzl{x} 内容页模板 zzl{x} 实现检索功能 zzl{x} 用户登录、认证对接 zzl{x} 北龙超云版知识库模板(优先级低) zzl2、后台改造{x} 改造成并行蓝风格 zzl{x} 改造左侧菜单导航样式 zzl{x} 改造顶部显示逻辑 zzl{x} 将列表中的操作列调整到表格最后一列 zzl{x} 去掉左侧菜单中商业版相关菜单入口 zzl{x} 去掉系统业务界面中商业版相关的功能入口 zzl{x} 将启动方式改成 spring-boot 模式,即 java -jar [xxx].war ,不用放到 tomcat 里 hx{x} 调研是否能支持多例 hx{x} 用户认证对接 hx{x} 支持多环境配置,即有生产,stage 和本地。 使用配置中心 hx{x} 支持 JDK 17 hx{x} 购买商业版后整合商业版相关功能模块 zzl{x} 应用资源文件分离改造。将上传文件目录、模板目录、索引文件目录从war包中分离 zzl{x} 后台界面支持并行蓝风格和北龙红风格 zzl3、技术实现细节完成该项目后主要有如下可圈可点的部分技术细节用于分享1、知识库前台模板制作网站模板的工作其实主要就是在html静态文件中插入 CMS 的标签,并结合CMS的静态资源引入机制改写页面相关的静态资源文件。一般会提取一些公共底部、公共底部模板之类的。CMS 实施过程中常用的标签其实并不多,主要也就是获取栏目列表、内容列表、栏目信息、内容信息这些最基础的。经过实践,发现jspxCMS的标签还是非常灵活的,支持模板嵌套、标签嵌套等jspxCMS 使用的freemarker实现静态化,自带了非常灵活的判断、循环等表达式。经过整理,部分标签使用记录如下:获取栏目列表及栏目信息以下是本项目中获取知识库左侧栏目结构树的模板示例<ul class="menu-list-container"> [@NodeList parentId=node.id;list] [#list list as pnode] <li class="level1" data-node-id="${pnode.id}"> <a href="javascript:void(0);"> <i class="icon-triangle kbsIconfont kbs-triangle"></i> <span class="menu-item-text">${pnode.name}</span> </a> <ul> [@NodeList parentId=pnode.id;subNodes] [#list subNodes as subNode] <li class="level2"> <a href="javascript:void(0);"> <i class="icon-triangle kbsIconfont kbs-triangle"></i> <span class="menu-item-text">${subNode.name}</span> </a> <ul> [@InfoList nodeId=subNode.id;subNodeDocs] [#list subNodeDocs as info] <li class="level3"> <a href="${info.url}" target="_self"> <span class="menu-item-text">${info.title}</span> </a> </li> [/#list] [/@InfoList] </ul> </li> [/#list] [/@NodeList] [@InfoList nodeId=pnode.id;infos] [#list infos as info] <li class="level2"> <a href="${info.url}" target="_self"> <span class="menu-item-text">${info.title}</span> </a> </li> [/#list] [/@InfoList] </ul> </li> [/#list] [/@NodeList] [@InfoList nodeId=node.id;infos] [#list infos as info] <li class="level1"> <a href="${info.url}" target="_self"> <span class="menu-item-text">${info.title}</span> </a> </li> [/#list] [/@InfoList] </ul>获取内容列表及内容信息 <div class="box-product-docs"> <div class="box"> <h3 class="box-title">最新发布</h3> <div class="box-content row"> [@InfoList nodeId=node.id limit='6' isIncludeChildren='true';list] [#list list as info] <div class="col-md-4 col-sm-6"> <div class="list-body"> <a href="${info.url}"> <p class="list-title text-left">${info.title}</p> <p class="list-content">${info.description}</p> <span class="list-more">详情+</span> <span class="list-date">更新时间:${info.publishDate?string('yyyy-MM-dd HH:mm')}</span> </a> </div> </div> [/#list] [/@InfoList] </div> </div> <div class="box"> <h3 class="box-title">热门知识</h3> <div class="box-content row"> [@InfoList nodeId=node.id sort='views desc' limit='6' isIncludeChildren='true';list] [#list list as info] <div class="col-md-4 col-sm-6"> <div class="list-body"> <a href="${info.url}"> <p class="list-title text-left">${info.title}</p> <p class="list-content">${info.description}</p> <span class="list-more">详情+</span> <span class="list-date">浏览次数:${info.views}</span> </a> </div> </div> [/#list] [/@InfoList] </div> </div> </div> 模板嵌套[#include "include_header.html"/]当前位置<div class="body-position"> <i class=" kbsIconfont kbs-home"></i> [#list node.hierarchy as n]<a href="${n.url}">${n.name}</a>[#if n_has_next] > [/#if][/#list] </div>内容分页[@InfoPage nodeId=node.id pageSize='15';pagedList] <ul class="list-unstyled mt10"> [#list pagedList.content as info] <li style="padding:15px 0;border-bottom:1px dotted #ccc;"> [#if info.withImage] <div class="left" style="padding:3px 10px 3px 0;width:22%;"> <a href="${info.url}" target="_blank"><img src="${info.smallImageUrl}" width="138" height="92"/></a> </div> [/#if] <div class="left" style="[#if info.withImage]width:75%;[/#if]"> <div>[@A bean=info class='ff-yh fs18 a c-000' target="_blank"/]</div> <div class="" style="line-height:1.8;padding:2px 0;color:#818181;">${substring(info.description,100,'...')}</div> <div class="" style="padding:2px;color:#a1a1a1;">${info.publishDate?string('yyyy-MM-dd HH:mm:ss')}</div> </div> <div class="clear"></div> </li> [/#list] </ul> <div class="mt20"> [#include "inc_page.html"/] </div> [/@InfoPage] 分页嵌套模板(inc_page.html)[#escape x as (x)!?html] <div class="pager"> [#if page>1]<a href="${paging(1)}" class="page"><i class="kbsIconfont kbs-first"></i></a>[/#if] [#if page>1] <a href="${paging(page-1)}" class="page"><i class="kbsIconfont kbs-prev"></i></a> [/#if] [#assign start=page-4/][#if start<1][#assign start=1/][/#if] [#assign end=start+8/][#if end>pagedList.totalPages][#assign end=pagedList.totalPages/][/#if][#if end<1][#assign end=1/][/#if] [#if end-start<8][#assign start=end-8/][/#if][#if start<1][#assign start=1/][/#if] [#list start..end as p] <a[#if page!=p] href="${paging(p)}"[/#if] class="[#if page!=p]page[#else]page-curr[/#if]">${p}</a> [/#list] [#if page < pagedList.totalPages] <a href="${paging(page+1)}" class="page"><i class="kbsIconfont kbs-next"></i></a> <a href="${paging(pagedList.totalPages)}" class="page" ><i class="kbsIconfont kbs-last"></i></a> [/#if] </div> [/#escape] 超级方便的app自由模版app自由模板,主要是可以传参给模板获取一些指定的内容片段,这就极大的扩展了模板的功能,目前我们用得最多的场景是不同系统间通过app模板获取KBS的内容列表及内容详情等json数据,然后在异构系统中进行个性化展现。app自由模板的官网介绍如下(https://www.ujcms.com/documentation/269.html):整理了部分自由模板内容以作备忘:1、内容列表页(app_newsPage.html)[#-- 栏目内容分页列表数据,用于列表新品快报、公告更多页面分页显示数据 --]{ [@InfoPage node=Param.nodeCode pageSize=Param.pageSize page=Param.pageNum;pagedList] "total":${pagedList.totalElements}, "size":${pagedList.size}, "pages":${pagedList.totalPages}, "current":${pagedList.number + 1}, "records": [ [#list pagedList.content as info] { "id":"${info.id}", "title":"${info.title?js_string}", "publishDate":"${info.publishDate?string('yyyy-MM-dd HH:mm:ss')}", "url":"${info.url?js_string}" }[#if info_has_next],[/#if] [/#list] ] [/@InfoPage] } 2、内容详情页(app_newsDetail.html)[#-- 内容详情接口,用于点击查看内容详情 --][@Info id=Param.articleId;bean] { "id":"${bean.id}", "url":"${bean.url?js_string}", "title":"${bean.title?js_string}", "publishDate":"${bean.publishDate?string('yyyy-MM-dd HH:mm')}", "content":"${bean.text?replace("/uploads/1/","${site.domain}/uploads/1/")?js_string}", "editorType":"${bean.customs.text_editor_type}" } [/@Info] 2、应用资源文件分离改造jspxCMS 默认的部署方式是将war包部署到tomcat下,相关的应用资源文件(模板文件、后台上传的图片及音视频文件、静态化文件等)也都在应用根目录下,功能强大的jspxCMS提供了发布点功能,将上传的资源文件、静态化的html文件生成到指定的目录下,但是该方式功能有限,不能完全满足我们的需求,如索引文件目录、站点模板文件等均不可配置。我们的实际需求是需要将应用采用docker部署并使用java -jar 的方式进行启动,那样就需要将原有跟应用整合在一起的资源文件分离出来,独立部署的其他存储目录中。充分理解该需求及使用场景后,特制定了本改造方案。改造过程如下:1、application.yml 增加分离文件相关根目录配置信息经过前期梳理,考虑到应用实际情况,将分离文件拆分为两个目录:kbs.appDataPath:应用数据(临时文件/日志/缓存/索引等)的存放路径kbs.resourceRootPath:需本地持久化保存应用资源数据(模板数据/上传文件/静态化文件等)的存放路径目录支持%{Parent}、%{Self}这样的相对路径进行占位,默认为应用当前目录下的 appdata 和 KBSData 目录。亦可在启动应用时增加启动参数的方式修改路径,如--kbs.resourceRootPath=c:/workspace/KBSData/kbs: appDataPath: '%{Parent}/appdata/' resourceRootPath: '%{Parent}/KBSData/'2、启动应用时对目录进行初始化阅读源代码后,发现应用在启动时,有调用Constants.loadEnvironment()方法,我们可以利用该方法作为入口,调用我们的目录初始化方法 KbsConfig.init(),在应用启动时初始化相关目录,保障相关资源目录是绝对存在的。KbsConfig 相关核心代码如下:/** * 知识库初始化配置 * @author 朱治龙 * @Date 2021-08-27 10:18:32 */ @Component public class KbsConfig { private static String appDataPathInConfig; private static String resourceRootPathInConfig; /** * 应用数据目录 */ protected static String appDataPath = null; /** * 资源数据目录 */ protected static String resourceDataPath = null; public static void init(Environment env) { appDataPathInConfig = env.getProperty("kbs.appDataPath"); resourceRootPathInConfig = env.getProperty("kbs.resourceRootPath"); initPaths(); } /** * 初始化相关文件路径 */ private static void initPaths() { // 初始化应用数据目录 String applicationRealPath = KbsUtil.getApplicationRealPath(); String tempAppDataPath = applicationRealPath; if (ObjectUtil.isNotEmpty(appDataPathInConfig)) { tempAppDataPath = appDataPathInConfig; tempAppDataPath = KbsUtil.replacePathHolder(tempAppDataPath); } else { tempAppDataPath += "appdata/"; } appDataPath = tempAppDataPath; AppDataPath.initialAppDataPath(); // 初始化资源目录 String tempResourceDataPath = applicationRealPath; if (ObjectUtil.isNotEmpty(resourceRootPathInConfig)) { tempResourceDataPath = resourceRootPathInConfig; tempResourceDataPath = KbsUtil.replacePathHolder(tempResourceDataPath); } else { tempResourceDataPath += "KBSData"; } resourceDataPath = tempResourceDataPath; ResourceDataPath.initialResourcePath(); } /** * 获取应用数据目录 * @return */ public static String getAppDataPath() { if(ObjectUtil.isEmpty(appDataPath)) { initPaths(); } return appDataPath; } /** * 获取应用数据目录 * @return */ public static String getResourceRootPath() { if(ObjectUtil.isEmpty(resourceDataPath)) { initPaths(); } return resourceDataPath; } }3、改造模板、索引文件、上传资源资源文件的代码逻辑相关逻辑主要涉及 jspxCMS 的核心代码,均在阅读相关源码后进行改造,此处就不做代码罗列了。4、系统资源目录中的静态资源文件提供对外访问服务/** * 系统资源目录中的静态资源文件提供对外访问服务 * @author 朱治龙 * @Date 2021-08-27 14:48:36 */ @Configuration public class KbsWebMvcConfigurer implements WebMvcConfigurer { /** * 资源目录相关文件提供后台访问 */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**").addResourceLocations("file:" + ResourceDataPath.getUploadsPath() + "/"); registry.addResourceHandler("/template/**").addResourceLocations("file:" + ResourceDataPath.getTemplatePath() + "/"); } /** * 默认首页跳转到index.html */ @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("redirect:/index.html"); } }3、后台界面支持并行蓝风格和北龙红风格在之前对 jspxCMS 改造为并行蓝风格时,有将并行蓝风格相关的样式文件提取到paratera-kbs.css中,项目在升级改造过程中后端的同事有整合Spring Cloud 及配置中心组件spring cloud config,可在配置中心中设置当前主题信息,然后应用拿到主题信息后调用不同的样式文件,即可达到同一套后台代码展现不同风格的目的。具体实现过程如下:1、配置中心增加 kbs.theme-mode 配置项值为 blsc 或 paratera,若不配置则默认为 paratera2、全局注入主题风格变量,名称为themeMode在阅读源码后,修改后台拦截器 BackInterceptor 相关的代码逻辑,全局注入themeMode变量,便于在后台jsp文件中拿到该值进行主题相关业务逻辑判断3、修改后台jsp视图文件主要修改代码为:实施成果后台效果1、并行知识库2、北龙知识库前台效果并行知识库1、站点首页2、产品页3、内容详情页4、检索结果页北龙知识库1、站点首页2、产品页3、内容详情页4、检索结果页
2022年09月20日
95 阅读
0 评论
0 点赞
1
2