数据库

下载数据库文件,创建leadnews_wemedia数据库,执行下载的SQL脚本

或,使用脚本创建leadnews_wemedia数据库

/*
Navicat MySQL Data Transfer

Source Server : localhost
Source Server Version : 50721
Source Host : localhost:3306
Source Database : leadnews_wemedia

Target Server Type : MYSQL
Target Server Version : 50721
File Encoding : 65001

Date: 2021-04-26 11:33:55
*/

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for wm_channel
-- ----------------------------
DROP TABLE IF EXISTS `wm_channel`;
CREATE TABLE `wm_channel` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '频道名称',
`description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '频道描述',
`is_default` tinyint(1) unsigned DEFAULT NULL COMMENT '是否默认频道',
`status` tinyint(1) unsigned DEFAULT NULL,
`ord` tinyint(3) unsigned DEFAULT NULL COMMENT '默认排序',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='频道信息表';

-- ----------------------------
-- Records of wm_channel
-- ----------------------------
INSERT INTO `wm_channel` VALUES ('0', '其他', '其他', '1', '1', '12', '2021-04-18 10:55:41');
INSERT INTO `wm_channel` VALUES ('1', 'java', '后端框架', '1', '1', '1', '2021-04-18 12:25:30');
INSERT INTO `wm_channel` VALUES ('2', 'Mysql', '轻量级数据库', '1', '1', '4', '2021-04-18 10:55:41');
INSERT INTO `wm_channel` VALUES ('3', 'Vue', '阿里前端框架', '1', '1', '5', '2021-04-18 10:55:41');
INSERT INTO `wm_channel` VALUES ('4', 'Python', '未来的语言', '1', '1', '6', '2021-04-18 10:55:41');
INSERT INTO `wm_channel` VALUES ('5', 'Weex', '向未来致敬', '1', '1', '7', '2021-04-18 10:55:41');
INSERT INTO `wm_channel` VALUES ('6', '大数据', '不会,则不要说自己是搞互联网的', '1', '1', '10', '2021-04-18 10:55:41');

-- ----------------------------
-- Table structure for wm_fans_statistics
-- ----------------------------
DROP TABLE IF EXISTS `wm_fans_statistics`;
CREATE TABLE `wm_fans_statistics` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(11) unsigned DEFAULT NULL COMMENT '主账号ID',
`article` int(11) unsigned DEFAULT NULL COMMENT '子账号ID',
`read_count` int(11) unsigned DEFAULT NULL,
`comment` int(11) unsigned DEFAULT NULL,
`follow` int(11) unsigned DEFAULT NULL,
`collection` int(11) unsigned DEFAULT NULL,
`forward` int(11) unsigned DEFAULT NULL,
`likes` int(11) unsigned DEFAULT NULL,
`unlikes` int(11) unsigned DEFAULT NULL,
`unfollow` int(11) unsigned DEFAULT NULL,
`burst` varchar(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_time` date DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_user_id_time` (`user_id`,`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体粉丝数据统计表';

-- ----------------------------
-- Records of wm_fans_statistics
-- ----------------------------

-- ----------------------------
-- Table structure for wm_material
-- ----------------------------
DROP TABLE IF EXISTS `wm_material`;
CREATE TABLE `wm_material` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(11) unsigned DEFAULT NULL COMMENT '自媒体用户ID',
`url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '图片地址',
`type` tinyint(1) unsigned DEFAULT NULL COMMENT '素材类型\r\n 0 图片\r\n 1 视频',
`is_collection` tinyint(1) DEFAULT NULL COMMENT '是否收藏',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=72 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体图文素材信息表';

-- ----------------------------
-- Records of wm_material
-- ----------------------------
INSERT INTO `wm_material` VALUES ('67', '1102', 'http://192.168.200.130:9000/leadnews/2021/04/26/a73f5b60c0d84c32bfe175055aaaac40.jpg', '0', '0', '2021-04-26 00:14:01');
INSERT INTO `wm_material` VALUES ('68', '1102', 'http://192.168.200.130:9000/leadnews/2021/04/26/d4f6ef4c0c0546e69f70bd3178a8c140.jpg', '0', '0', '2021-04-26 00:18:20');
INSERT INTO `wm_material` VALUES ('69', '1102', 'http://192.168.200.130:9000/leadnews/2021/04/26/5ddbdb5c68094ce393b08a47860da275.jpg', '0', '0', '2021-04-26 00:18:27');
INSERT INTO `wm_material` VALUES ('70', '1102', 'http://192.168.200.130:9000/leadnews/2021/04/26/9f8a93931ab646c0a754475e0c4b0a98.jpg', '0', '0', '2021-04-26 00:18:34');
INSERT INTO `wm_material` VALUES ('71', '1102', 'http://192.168.200.130:9000/leadnews/2021/04/26/ef3cbe458db249f7bd6fb4339e593e55.jpg', '0', '0', '2021-04-26 00:18:39');

-- ----------------------------
-- Table structure for wm_news
-- ----------------------------
DROP TABLE IF EXISTS `wm_news`;
CREATE TABLE `wm_news` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(11) unsigned DEFAULT NULL COMMENT '自媒体用户ID',
`title` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '标题',
`content` longtext COLLATE utf8mb4_unicode_ci COMMENT '图文内容',
`type` tinyint(1) unsigned DEFAULT NULL COMMENT '文章布局\r\n 0 无图文章\r\n 1 单图文章\r\n 3 多图文章',
`channel_id` int(11) unsigned DEFAULT NULL COMMENT '图文频道ID',
`labels` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`submited_time` datetime DEFAULT NULL COMMENT '提交时间',
`status` tinyint(2) unsigned DEFAULT NULL COMMENT '当前状态\r\n 0 草稿\r\n 1 提交(待审核)\r\n 2 审核失败\r\n 3 人工审核\r\n 4 人工审核通过\r\n 8 审核通过(待发布)\r\n 9 已发布',
`publish_time` datetime DEFAULT NULL COMMENT '定时发布时间,不定时则为空',
`reason` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '拒绝理由',
`article_id` bigint(20) unsigned DEFAULT NULL COMMENT '发布库文章ID',
`images` longtext COLLATE utf8mb4_unicode_ci COMMENT '//图片用逗号分隔',
`enable` tinyint(1) unsigned DEFAULT '1',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=6232 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体图文内容信息表';

-- ----------------------------
-- Records of wm_news
-- ----------------------------
INSERT INTO `wm_news` VALUES ('6225', '1102', '“真”项目课程对找工作有什么帮助?', '[{\"type\":\"text\",\"value\":\"找工作,企业重点问的是项目经验,更是HR筛选的“第一门槛”,直接决定了你是否有机会进入面试环节。\\n\\n  项目经验更是评定“个人能力/技能”真实性的“证据”,反映了求职者某个方面的实际动手能力、对某个领域或某种技能的掌握程度。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/7d0911a41a3745efa8509a87f234813c.jpg\"},{\"type\":\"text\",\"value\":\"很多经过培训期望快速上岗的程序员,靠着培训机构“辅导”顺利经过面试官对于“项目经验”的考核上岗后,在面对“有限时间”“复杂业务”“新项目需求”等多项标签加持的工作任务,却往往不知从何下手或开发进度极其缓慢。最终结果就是:熬不过试用期。\\n\\n  从而也引发了企业对于“培训出身程序员”的“有色眼光”。你甚至也一度怀疑“IT培训班出来的人真的不行吗?”\"}]', '1', '1', '项目课程', '2021-04-19 00:08:10', '2021-04-19 00:08:10', '9', '2021-04-19 00:08:05', '审核成功', '1383828014629179393', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/7d0911a41a3745efa8509a87f234813c.jpg', '1');
INSERT INTO `wm_news` VALUES ('6226', '1102', '学IT,为什么要学项目课程?', '[{\"type\":\"text\",\"value\":\"在选择IT培训机构时,你应该有注意到,很多机构都将“项目课程”作为培训中的重点。那么,为什么要学习项目课程?为什么项目课程才是IT培训课程的核心?\\n\\n  1\\n\\n  在这个靠“技术经验说话”的IT行业里,假如你是一个计算机或IT相关专业毕业生,在没有实际项目开发经验的情况下,“找到第一份全职工作”可能是你职业生涯中遇到的最大挑战。\\n\\n  为什么说找第一份工作很难?\\n\\n  主要在于:实际企业中用到的软件开发知识和在学校所学的知识是完全不同的。假设你已经在学校和同学做过周期长达2-3个月的项目,但真正工作中的团队协作与你在学校中经历的协作也有很多不同。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/e8113ad756a64ea6808f91130a6cd934.jpg\"},{\"type\":\"text\",\"value\":\"在实际团队中,每一位成员彼此团结一致,为项目的交付而努力,这也意味着你必须要理解好在项目中负责的那部分任务,在规定时间交付还需确保你负责的功能,在所有环境中都能很好地发挥作用,而不仅仅是你的本地机器。\\n\\n  这需要你对项目中的每一行代码严谨要求。学校练习的项目中,对bug的容忍度很大,而在实际工作中是绝对不能容忍的。项目中的任何一个环节都涉及公司利益,任何一个bug都可能影响公司的收入及形象。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/c7c3d36d25504cf6aecdcd5710261773.jpg\"}]', '3', '1', '项目课程', '2021-04-19 00:13:58', '2021-04-19 00:13:58', '9', '2021-04-19 00:10:48', '审核成功', '1383827995813531650', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/7d0911a41a3745efa8509a87f234813c.jpg,http://192.168.200.130:9000/leadnews/2021/4/20210418/c7c3d36d25504cf6aecdcd5710261773.jpg,http://192.168.200.130:9000/leadnews/2021/4/20210418/e8113ad756a64ea6808f91130a6cd934.jpg', '1');
INSERT INTO `wm_news` VALUES ('6227', '1102', '小白如何辨别其真与伪&好与坏?', '[{\"type\":\"text\",\"value\":\"通过上篇《IT培训就业艰难,行业乱象频发,如何破解就业难题?》一文,相信大家已初步了解“项目课程”对程序员能否就业且高薪就业的重要性。\\n\\n  因此,小白在选择IT培训机构时,关注的重点就在于所学“项目课程”能否真正帮你增加就业筹码。当然,前提必须是学到“真”项目。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/1818283261e3401892e1383c1bd00596.jpg\"}]', '1', '1', '小白', '2021-04-19 00:15:05', '2021-04-19 00:15:05', '9', '2021-04-19 00:14:58', '审核成功', '1383827976310018049', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/1818283261e3401892e1383c1bd00596.jpg', '1');
INSERT INTO `wm_news` VALUES ('6228', '1102', '工作线程数是不是设置的越大越好', '[{\"type\":\"text\",\"value\":\"根据经验来看,jdk api 一般推荐的线程数为CPU核数的2倍。但是有些书籍要求可以设置为CPU核数的8倍,也有的业务设置为CPU核数的32倍。\\n“工作线程数”的设置依据是什么,到底设置为多少能够最大化CPU性能,是本文要讨论的问题。\\n\\n工作线程数是不是设置的越大越好\\n显然不是的。使用java.lang.Thread类或者java.lang.Runnable接口编写代码来定义、实例化和启动新线程。\\n一个Thread类实例只是一个对象,像Java中的任何其他对象一样,具有变量和方法,生死于堆上。\\nJava中,每个线程都有一个调用栈,即使不在程序中创建任何新的线程,线程也在后台运行着。\\n一个Java应用总是从main()方法开始运行,main()方法运行在一个线程内,它被称为主线程。\\n一旦创建一个新的线程,就产生一个新的调用栈。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/a3f0bc438c244f788f2df474ed8ecdc1.jpg\"}]', '1', '1', '11', '2021-04-19 00:16:57', '2021-04-19 00:16:57', '9', '2021-04-19 00:16:52', '审核成功', '1383827952326987778', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/a3f0bc438c244f788f2df474ed8ecdc1.jpg', '1');
INSERT INTO `wm_news` VALUES ('6229', '1102', 'Base64编解码原理', '[{\"type\":\"text\",\"value\":\"我在面试过程中,问过很多高级java工程师,是否了解Base64?部分人回答了解,部分人直接回答不了解。而说了解的那部分人却回答不上来它的原理。\\n\\nBase64 的由来\\nBase64是网络上最常见的用于传输8Bit字节代码的编码方式之一,大家可以查看RFC2045~RFC2049,上面有MIME的详细规范。它是一种基于用64个可打印字符来表示二进制数据的表示方法。它通常用作存储、传输一些二进制数据编码方法!也是MIME(多用途互联网邮件扩展,主要用作电子邮件标准)中一种可打印字符表示二进制数据的常见编码方法!它其实只是定义用可打印字符传输内容一种方法,并不会产生新的字符集!\\n\\n传统的邮件只支持可见字符的传送,像ASCII码的控制字符就 不能通过邮件传送。这样用途就受到了很大的限制,比如图片二进制流的每个字节不可能全部是可见字符,所以就传送不了。最好的方法就是在不改变传统协议的情 况下,做一种扩展方案来支持二进制文件的传送。把不可打印的字符也能用可打印字符来表示,问题就解决了。Base64编码应运而生,Base64就是一种 基于64个可打印字符来表示二进制数据的表示方法。\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/b44c65376f12498e873223d9d6fdf523.jpg\"},{\"type\":\"text\",\"value\":\"请在这里输入正文\"}]', '1', '1', '11', '2021-04-19 00:17:44', '2021-04-19 00:17:44', '9', '2021-04-19 00:17:42', '审核成功', '1383827911810011137', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/b44c65376f12498e873223d9d6fdf523.jpg', '1');
INSERT INTO `wm_news` VALUES ('6230', '1102', '为什么项目经理不喜欢重构?', '[{\"type\":\"text\",\"value\":\"经常听到开发人员抱怨 ,“这么烂的代码,我来重构一下!”,“这代码怎么能这么写呢?谁来重构一下?”,“这儿有个坏味道,重构吧!”\\n\\n作为一名项目经理,每次听到“重构”两个字,既想给追求卓越代码的开发人员点个赞,同时又会感觉非常紧张,为什么又要重构?马上就要上线了,怎么还要改?是不是应该阻止开发人员做重构?\\n\\n重构几乎是开发人员最喜欢的一项实践了,可项目经理们却充满了顾虑,那么为什么项目经理不喜欢重构呢?\\n\\n老功能被破坏\\n不止一次遇到这样的场景,某一天一个老功能突然被破坏了,项目经理们感到奇怪,产品这块儿的功能已经很稳定了,也没有在这部分开发什么新功能,为什么突然出问题了呢?\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/e8113ad756a64ea6808f91130a6cd934.jpg\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/4a498d9cf3614570ac0cb2da3e51c164.jpg\"},{\"type\":\"text\",\"value\":\"请在这里输入正文\"}]', '1', '1', '11', '2021-04-19 00:19:23', '2021-04-19 00:19:23', '9', '2021-04-19 00:19:09', '审核成功', '1383827888816836609', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/4a498d9cf3614570ac0cb2da3e51c164.jpg', '1');
INSERT INTO `wm_news` VALUES ('6231', '1102', 'Kafka文件的存储机制', '[{\"type\":\"text\",\"value\":\"Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制Kafka文件的存储机制\"},{\"type\":\"image\",\"value\":\"http://192.168.200.130:9000/leadnews/2021/4/20210418/4a498d9cf3614570ac0cb2da3e51c164.jpg\"},{\"type\":\"text\",\"value\":\"请在这里输入正文\"}]', '1', '1', '11', '2021-04-19 00:58:47', '2021-04-19 00:58:47', '9', '2021-04-19 00:20:17', '审核成功', '1383827787629252610', 'http://192.168.200.130:9000/leadnews/2021/4/20210418/4a498d9cf3614570ac0cb2da3e51c164.jpg', '1');

-- ----------------------------
-- Table structure for wm_news_material
-- ----------------------------
DROP TABLE IF EXISTS `wm_news_material`;
CREATE TABLE `wm_news_material` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`material_id` int(11) unsigned DEFAULT NULL COMMENT '素材ID',
`news_id` int(11) unsigned DEFAULT NULL COMMENT '图文ID',
`type` tinyint(1) unsigned DEFAULT NULL COMMENT '引用类型\r\n 0 内容引用\r\n 1 主图引用',
`ord` tinyint(1) unsigned DEFAULT NULL COMMENT '引用排序',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=281 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体图文引用素材信息表';

-- ----------------------------
-- Records of wm_news_material
-- ----------------------------
INSERT INTO `wm_news_material` VALUES ('255', '61', '6232', '0', '0');
INSERT INTO `wm_news_material` VALUES ('256', '61', '6232', '1', '0');
INSERT INTO `wm_news_material` VALUES ('263', '61', '6231', '0', '0');
INSERT INTO `wm_news_material` VALUES ('264', '61', '6231', '1', '0');
INSERT INTO `wm_news_material` VALUES ('265', '57', '6230', '0', '0');
INSERT INTO `wm_news_material` VALUES ('266', '61', '6230', '0', '1');
INSERT INTO `wm_news_material` VALUES ('267', '61', '6230', '1', '0');
INSERT INTO `wm_news_material` VALUES ('268', '58', '6229', '0', '0');
INSERT INTO `wm_news_material` VALUES ('269', '58', '6229', '1', '0');
INSERT INTO `wm_news_material` VALUES ('270', '62', '6228', '0', '0');
INSERT INTO `wm_news_material` VALUES ('271', '62', '6228', '1', '0');
INSERT INTO `wm_news_material` VALUES ('272', '66', '6227', '0', '0');
INSERT INTO `wm_news_material` VALUES ('273', '66', '6227', '1', '0');
INSERT INTO `wm_news_material` VALUES ('274', '57', '6226', '0', '0');
INSERT INTO `wm_news_material` VALUES ('275', '64', '6226', '0', '1');
INSERT INTO `wm_news_material` VALUES ('276', '65', '6226', '1', '0');
INSERT INTO `wm_news_material` VALUES ('277', '64', '6226', '1', '1');
INSERT INTO `wm_news_material` VALUES ('278', '57', '6226', '1', '2');
INSERT INTO `wm_news_material` VALUES ('279', '65', '6225', '0', '0');
INSERT INTO `wm_news_material` VALUES ('280', '65', '6225', '1', '0');

-- ----------------------------
-- Table structure for wm_news_statistics
-- ----------------------------
DROP TABLE IF EXISTS `wm_news_statistics`;
CREATE TABLE `wm_news_statistics` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(11) unsigned DEFAULT NULL COMMENT '主账号ID',
`article` int(11) unsigned DEFAULT NULL COMMENT '子账号ID',
`read_count` int(11) unsigned DEFAULT NULL COMMENT '阅读量',
`comment` int(11) unsigned DEFAULT NULL COMMENT '评论量',
`follow` int(11) unsigned DEFAULT NULL COMMENT '关注量',
`collection` int(11) unsigned DEFAULT NULL COMMENT '收藏量',
`forward` int(11) unsigned DEFAULT NULL COMMENT '转发量',
`likes` int(11) unsigned DEFAULT NULL COMMENT '点赞量',
`unlikes` int(11) unsigned DEFAULT NULL COMMENT '不喜欢',
`unfollow` int(11) unsigned DEFAULT NULL COMMENT '取消关注量',
`burst` varchar(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_time` date DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_user_id_time` (`user_id`,`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体图文数据统计表';

-- ----------------------------
-- Records of wm_news_statistics
-- ----------------------------

-- ----------------------------
-- Table structure for wm_user
-- ----------------------------
DROP TABLE IF EXISTS `wm_user`;
CREATE TABLE `wm_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`ap_user_id` int(11) DEFAULT NULL,
`ap_author_id` int(11) DEFAULT NULL,
`name` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登录用户名',
`password` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登录密码',
`salt` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盐',
`nickname` varchar(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '昵称',
`image` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像',
`location` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '归属地',
`phone` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号',
`status` tinyint(11) unsigned DEFAULT NULL COMMENT '状态\r\n 0 暂时不可用\r\n 1 永久不可用\r\n 9 正常可用',
`email` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
`type` tinyint(1) unsigned DEFAULT NULL COMMENT '账号类型\r\n 0 个人 \r\n 1 企业\r\n 2 子账号',
`score` tinyint(3) unsigned DEFAULT NULL COMMENT '运营评分',
`login_time` datetime DEFAULT NULL COMMENT '最后一次登录时间',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1120 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自媒体用户信息表';

-- ----------------------------
-- Records of wm_user
-- ----------------------------
INSERT INTO `wm_user` VALUES ('1100', null, null, 'zhangsan', 'ab8c7c1e66a164ab6891b927550ea39a', 'abc', '小张', null, null, '13588996789', '1', null, null, null, '2020-02-17 23:51:15', '2020-02-17 23:51:18');
INSERT INTO `wm_user` VALUES ('1101', null, null, 'lisi', 'a6ecab0c246bbc87926e0fba442cc014', 'def', '小李', null, null, '13234567656', '1', null, null, null, null, null);
INSERT INTO `wm_user` VALUES ('1102', null, null, 'admin', 'a66abb5684c45962d887564f08346e8d', '123456', '管理', null, null, '13234567657', '1', null, null, null, null, '2020-03-14 09:35:13');
INSERT INTO `wm_user` VALUES ('1118', null, null, 'lisi1', '123', '123', null, null, null, null, null, null, null, null, null, null);
INSERT INTO `wm_user` VALUES ('1119', null, null, 'shaseng', '1234', null, null, null, null, null, null, null, null, null, null, null);

自媒体后端搭建

创建模块

leadnews-service中创建子模块leadnews-wemedia,子模块中创建包com.swx.wemedia

创建启动类

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

配置文件

bootstrap.yaml
server:
port: 51803
spring:
application:
name: leadnews-wemedia
cloud:
nacos:
discovery:
server-addr: xxx.xxx.xxx.xxx:8848
config:
server-addr: xxx.xxx.xxx.xxx:8848
file-extension: yml

在Nacos配置中心添加如下配置:http://ip:8848/nacos

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/leadnews_wemedia?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=utf-8&allowPublicKeyRetrieval=true
username: root
password: xxxxxxxx
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.swx.model.wemedia.pojo

代码生成

在test中创建代码生成工具类,生成基本代码:

public class CodeGenerate {
public static void OnMac() {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();

// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setAuthor("sw-code");
gc.setOpen(false); // 是否打开文件资源管理器
gc.setFileOverride(true); // 是否覆盖
gc.setServiceName("%sService"); // 去Service的I前缀
gc.setSwagger2(false); // 实体属性 Swagger2 注解
gc.setIdType(IdType.AUTO); // 主键策略
gc.setDateType(DateType.ONLY_DATE); // 定义生成的实体类中日期类型
mpg.setGlobalConfig(gc);

// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql:///leadnews_wemedia?useSSL=false&serverTimezone=UTC&characterEncoding=utf-8&nullCatalogMeansCurrent=true");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("xxxxxx");
mpg.setDataSource(dsc);

/*
* 包配置
* 简单来讲 就是写绝对路径
*/
PackageConfig pc = new PackageConfig();
// pc.setModuleName("code");
pc.setParent("com.swx");
//指定生成文件的绝对路径
Map<String, String> pathInfo = new HashMap<>();
String packageName = "wemedia";
String parentPath = "/src/main/java/com/swx";
String otherPath ="/leadnews-service/leadnews-wemedia/src/main/java/com/swx/" + packageName;

String entityPackageName = "wemedia";
pc.setEntity("model." + entityPackageName + ".pojo");
pc.setMapper(packageName + ".mapper");
pc.setService(packageName + ".service");
pc.setServiceImpl(packageName + ".service.impl");
pc.setController(packageName + ".controller.v1");

String entityPath = projectPath.concat("/leadnews-model").concat(parentPath).concat("/model/" + entityPackageName + "/pojo");
String mapper_path = projectPath.concat(otherPath).concat("/mapper");
String mapper_xml_path = projectPath.concat("/leadnews-service/leadnews-wemedia").concat("/src/main/resources/mapper");
String service_path = projectPath.concat(otherPath).concat("/service");
String service_impl_path = projectPath.concat(otherPath).concat("/service/impl");
String controller_path = projectPath.concat(otherPath).concat("/controller/v1");

pathInfo.put("entity_path",entityPath);
pathInfo.put("mapper_path",mapper_path);
pathInfo.put("xml_path",mapper_xml_path);
pathInfo.put("service_path",service_path);
pathInfo.put("service_impl_path",service_impl_path);
pathInfo.put("controller_path",controller_path);
pc.setPathInfo(pathInfo);
mpg.setPackageInfo(pc);

// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setInclude("wm_user");
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
// 字段注解
strategy.setEntityTableFieldAnnotationEnable(true);
strategy.setRestControllerStyle(true);
strategy.setControllerMappingHyphenStyle(true);
mpg.setStrategy(strategy);

mpg.execute();
}

public static void main(String[] args) {
OnMac();
}
}

编写登陆接口

接受参数实体类

wemedia.dto.WmLoginDTO
@Data
public class WmLoginDTO {
private String name;
private String password;
}

定义Service接口

WmUserService
public interface WmUserService extends IService<WmUser> {

/**
* 自媒体端登录
* @param dto 参数:用户名和密码
* @return 用户信息
*/
public Map<String,Object> login(WmLoginDTO dto);
}

实现Service接口

public class WmUserServiceImpl extends ServiceImpl<WmUserMapper, WmUser> implements WmUserService {

@Override
public Map<String,Object> login(WmLoginDTO dto) {
//1.检查参数
if(StringUtils.isBlank(dto.getName()) || StringUtils.isBlank(dto.getPassword())){
throw new BizException(ResultCodeEnum.PARAM_INVALID.code(), "用户名或密码为空");
}

//2.查询用户
WmUser wmUser = getOne(Wrappers.<WmUser>lambdaQuery().eq(WmUser::getName, dto.getName()));
if(wmUser == null){
throw new BizException(ResultCodeEnum.DATA_NOT_EXIST);
}

//3.比对密码
String salt = wmUser.getSalt();
String pswd = dto.getPassword();
pswd = DigestUtils.md5DigestAsHex((pswd + salt).getBytes());
if(pswd.equals(wmUser.getPassword())){
//4.返回数据 jwt
Map<String,Object> map = new HashMap<>();
map.put("token", AppJwtUtil.getToken(wmUser.getId().longValue()));
wmUser.setSalt("");
wmUser.setPassword("");
map.put("user",wmUser);
return map;
}else {
throw new BizException(ResultCodeEnum.LOGIN_PWD_ERROR);
}
}
}

编写Controller

LoginController
/**
* <p>
* 自媒体用户信息表 前端控制器
* </p>
*
* @author sw-code
* @since 2023-08-06
*/
@RestController
@ResponseResult
@RequestMapping("/login")
public class LoginController {

private final WmUserService wmUserService;

public LoginController(WmUserService wmUserService) {
this.wmUserService = wmUserService;
}

@PostMapping("/in")
public Map<String, Object> login(@RequestBody WmLoginDTO dto) {
return wmUserService.login(dto);
}
}

自媒体前端搭建

下载前端项目,下载完成后,解压到Nginx的html目录

下载地址:https://wwab.lanzoue.com/ibO7U14n48ta

编辑配置文件

Mac的Nginx配置文件路径:/usr/local/etc/nginx/

leadnews.conf文件下创建新的配置文件leadnews-wemedia.conf,内容如下:

upstream leadnews-wemedia-gateway {
server localhost:51602;
}

server {
listen 8802;
location / {
root html/wemedia-web/;
index index.html;
}

location ~/wemedia/MEDIA/(.*) {
proxy_pass http://leadnews-wemedia-gateway/$1;
proxy_set_header HOST $host; # 不改变源请求头的值
proxy_pass_request_body on; # 开启获取请求体
proxy_pass_request_headers on; # 开启获取请求头
proxy_set_header X-Real-IP $remote_addr; # 记录真实发出请求的客户端IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 记录代理信息
}
}

重启Nginx,浏览器访问:http://localhost:8802

自媒体素材管理

展示素材信息,可以进行图片上传

对应sql表为wm_material,表结构如下:

CREATE TABLE `wm_material` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int unsigned DEFAULT NULL COMMENT '自媒体用户ID',
`url` varchar(255) CHARACTER DEFAULT NULL COMMENT '图片地址',
`type` tinyint unsigned DEFAULT NULL COMMENT '素材类型 0图片 1视频',
`is_collection` tinyint(1) DEFAULT NULL COMMENT '是否收藏',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB COMMENT='自媒体图文素材信息表';

实现思路

修改网关的过滤器,在放行前添加如下代码

// 获取用户信息
Object userId = claimsBody.get("id");
ServerHttpRequest serverHttpRequest = request.mutate().headers(httpHeaders -> {
httpHeaders.add("userId", userId + "");
}).build();
// 重置请求
exchange.mutate().request(serverHttpRequest);

完善自媒体微服务

首先使用代码生成工具生成对应的基础文件

创建拦截器

WmTokenInterceptor
public class WmTokenInterceptor implements HandlerInterceptor {

/**
* 获取header中的用户信息,并且存入到当前线程中
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String userId = request.getHeader("userId");
if (userId != null) {
// 存入到当前线程中
IdThreadUtil.setId(Long.valueOf(userId));
}
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
IdThreadUtil.clear();
}
}

其中线程工具可在leadnews-utils模块中创建

thread.IdThreadUtil
package com.swx.utils.thread;

public class IdThreadUtil {

private final static ThreadLocal<Long> USER_ID_THREAD_LOCAL = new ThreadLocal<>();

public static void setId(Long userId) {
USER_ID_THREAD_LOCAL.set(userId);
}

// 从线程中获取
public static Long getId() {
return USER_ID_THREAD_LOCAL.get();
}

// 清理
public static void clear() {
USER_ID_THREAD_LOCAL.remove();
}
}

注册拦截器

创建配置文件:WebMvcConfig,内容如下:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new WmTokenInterceptor())
.addPathPatterns("/**");
}
}

图片上传

接口定义

说明
接口路径 /api/v1/material/upload_picture
请求方式 POST
参数 MultipartFile
响应结果 R

添加MinIO

首先在leadnews-wemedia的pom文件中添加依赖信息

pom.xml
<dependencies>
<dependency>
<groupId>com.swx</groupId>
<artifactId>leadnews-file-starter</artifactId>
</dependency>
</dependencies>

在Nacos中找到leadnews-wemedia配置,添加MinIO配置信息

minio:
accessKey: minio
secretKey: minio123
bucket: leadnews
endpoint: http://ip:9000
readPath: http://ip:9000

注意修改IP

定义Service接口

WmMaterialService
public interface WmMaterialService extends IService<WmMaterial> {

/**
* 图片上传
*
* @param multipartFile 文件信息
* @return
*/
public WmMaterial uploadPicture(MultipartFile multipartFile);

}

实现Service接口

WmMaterialServiceImpl
@Slf4j
@Service
public class WmMaterialServiceImpl extends ServiceImpl<WmMaterialMapper, WmMaterial> implements WmMaterialService {

private final FileStorageService fileStorageService;

public WmMaterialServiceImpl(FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}

/**
* 图片上传
*
* @param multipartFile 文件信息
* @return
*/
@Override
public WmMaterial uploadPicture(MultipartFile multipartFile) {
// 参数校验
if (multipartFile == null || multipartFile.getSize() == 0) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}

// 上传图片到minIO中
String filename = UUID.randomUUID().toString().replace("-", "");
String originalFilename = multipartFile.getOriginalFilename();
String prefix = originalFilename.substring(originalFilename.lastIndexOf("."));
String fileId = null;
try {
fileId = fileStorageService.uploadImgFile("", filename + prefix, multipartFile.getInputStream());
log.info("上传图片到MinIO中,fileId: {}", fileId);
} catch (IOException e) {
e.printStackTrace();
log.info("WmMaterialServiceImpl-上传文件失败");
}

// 保持到数据库中
WmMaterial wmMaterial = new WmMaterial();
// 获取userId
wmMaterial.setUserId(IdThreadUtil.getId().intValue());
wmMaterial.setUrl(fileId);
wmMaterial.setIsCollection((short)0);
wmMaterial.setType(0);
wmMaterial.setCreatedTime(new Date());
save(wmMaterial);

return wmMaterial;

}
}

完善图片上传服务接口Controller

WmMaterialController
@RestController
@ResponseResult
@RequestMapping("/api/v1/material")
public class WmMaterialController {

private final WmMaterialService wmMaterialService;

public WmMaterialController(WmMaterialService wmMaterialService) {
this.wmMaterialService = wmMaterialService;
}

@PostMapping("/upload_picture")
public WmMaterial uploadPicture(MultipartFile multipartFile) {
return wmMaterialService.uploadPicture(multipartFile);
}

}

素材列表

接口定义

说明
接口路径 /api/v1/material/list
请求方式 POST
参数 WmMaterialDTO
响应结果 PageR

定义Service方法

WmMaterialService
/**
* 素材列表查询
*
* @param dto 参数
*/
public PageR findList(WmMaterialDTO dto);

实现Service方法

/**
* 素材列表查询
*
* @param dto 参数
*/
@Override
public PageR findList(WmMaterialDTO dto) {
// 检查参数
dto.checkParam();

// 分页查询
IPage<WmMaterial> page = new Page<>(dto.getPage(), dto.getSize());
LambdaQueryWrapper<WmMaterial> wrapper = new LambdaQueryWrapper<>();
if (dto.getIsCollection() != null && dto.getIsCollection() == 1) {
wrapper.eq(WmMaterial::getIsCollection, dto.getIsCollection());
}

// 安装用户查询
wrapper.eq(WmMaterial::getUserId, IdThreadUtil.getId()).orderByDesc(WmMaterial::getCreatedTime);
page = page(page, wrapper);

PageR pageR = new PageR(dto.getPage(), dto.getSize(), (int) page.getTotal());
pageR.setData(page.getRecords());
return pageR;
}

完善列表服务接口Controller

@PostMapping("/list")
public PageR findList(@RequestBody WmMaterialDTO dto) {
return wmMaterialService.findList(dto);
}

频道列表

接口定义

说明
接口路径 /api/v1/channel/channels
请求方式 GET
响应结果 R

使用代码生成器生成wm_channel表的基础代码

完善Controller,提供查询所有频道服务

WmChannelController
@RestController
@ResponseResult
@RequestMapping("/api/v1/channel")
public class WmChannelController {

private final WmChannelService wmChannelService;

public WmChannelController(WmChannelService wmChannelService) {
this.wmChannelService = wmChannelService;
}

@GetMapping("/channels")
public List<WmChannel> findAll() {
return wmChannelService.list();
}

}

文章列表

接口定义

说明
接口路径 /api/v1/news/list
请求方式 POST
参数 WmNewsDTO
响应结果 PageR

使用代码生成器生成wm_news表的基础代码

实体类中添加枚举

@Alias("WmNewsStatus")
public enum Status {
NORMAL((short) 0, "草稿"),
SUBMIT((short) 1, "提交(待审核)"),
FAIL((short) 2, "审核失败"),
ADMIN_AUTH((short) 3, "人工审核"),
ADMIN_SUCCESS((short) 4, "人工审核通过"),
SUCCESS((short) 8, "审核通过(待发布)"),
PUBLISHED((short) 9, "已发布");
short code;
String desc;
Status(short code, String desc) {
this.code = code;
}
public short getCode() {
return this.code;
}
}

创建参数DTO

WmNewsPageReqDTO
@Data
@EqualsAndHashCode(callSuper = true)
public class WmNewsPageReqDTO extends PageDTO {

/**
* 状态
*/
private Short status;

/**
* 开始时间
*/
private Date beginPubData;

/**
* 结束时间
*/
private Date endPubData;

/**
* 所属频道
*/
private Integer channelId;

/**
* 关键字
*/
private String keyword;
}

Service接口方法

WmNewsService
/**
* 条件查询文章列表
* @param dto 查询条件
* @return 分页信息
*/
public PageR findList(WmNewsPageReqDTO dto);

实现Service方法

WmNewsServiceImpl
/**
* 条件查询文章列表
*
* @param dto 查询条件
* @return 分页信息
*/
@Override
public PageR findList(WmNewsPageReqDTO dto) {
// 检查参数
dto.checkParam();

IPage<WmNews> page = new Page<>(dto.getPage(), dto.getSize());

LambdaQueryWrapper<WmNews> wrapper = new LambdaQueryWrapper<>();

wrapper
// 状态精确查询
.eq(dto.getStatus() != null, WmNews::getStatus, dto.getStatus())
// 频道精确查询
.eq(dto.getChannelId() != null, WmNews::getChannelId, dto.getChannelId())
// 时间范围查询
.between(dto.getBeginPubData() != null && dto.getEndPubData() != null,
WmNews::getPublishTime, dto.getBeginPubData(), dto.getEndPubData())
// 关键字的模糊查询
.like(StringUtils.hasText(dto.getKeyword()), WmNews::getTitle, dto.getKeyword())
// 按照发布时间倒叙查询
.eq(WmNews::getUserId, IdThreadUtil.getId())
.orderByDesc(WmNews::getCreatedTime);

page = page(page, wrapper);
PageR pageR = new PageR(dto.getPage(), dto.getSize(), (int)page.getTotal());
pageR.setData(page.getRecords());
return pageR;
}

提供条件查询文章列表的服务

WmNewsController
@RestController
@ResponseResult
@RequestMapping("/api/v1/news")
public class WmNewsController {

private final WmNewsService wmNewsService;

public WmNewsController(WmNewsService wmNewsService) {
this.wmNewsService = wmNewsService;
}

@PostMapping("/list")
public PageR findAll(@RequestBody WmNewsPageReqDTO dto) {
return wmNewsService.findList(dto);
}

}

文章发布、修改、保存草稿

该功能为保存、修改(是否有id)、保存草稿的共有方法

接口定义

说明
接口路径 /api/v1/news/submit
请求方式 POST
参数 WmNewsDto
响应结果 R

接受参数

WmNewsDTO
@Data
public class WmNewsDTO {
private Integer id;

/**
* 标题
*/
private String title;

/**
* 频道ID
*/
private Integer channelId;

/**
* 文章标签
*/
private String labels;

/**
* 发布时间
*/
private Data publishTime;

/**
* 文章内容
*/
private String content;

/**
* 文章封面类型,0 无图 1 单图 3 多图 -1 自动
*/
private Short type;

// 封面图片列表,多张图以逗号分隔开
private List<String> images;
}

定义Service接口

WmNewsSubmitService
/**
* 处理文章提交的逻辑,解决循环依赖问题
*/
public interface WmNewsSubmitService {

/**
* 保存、修改文章或保存为草稿
* @param dto 文章信息
*/
public void submitNews(WmNewsDTO dto);
}

实现该方法

WmNewsSubmitServiceImpl
@Slf4j
@Service
public class WmNewsSubmitServiceImpl implements WmNewsSubmitService {

private final WmNewsMaterialMapper wmNewsMaterialMapper;
private final WmMaterialMapper wmMaterialMapper;
private final WmNewsTaskService wmNewsTaskService;
private final WmNewsService wmNewsService;

public WmNewsSubmitServiceImpl(WmNewsMaterialMapper wmNewsMaterialMapper, WmMaterialMapper wmMaterialMapper, WmNewsTaskService wmNewsTaskService, WmNewsService wmNewsService) {
this.wmNewsMaterialMapper = wmNewsMaterialMapper;
this.wmMaterialMapper = wmMaterialMapper;
this.wmNewsTaskService = wmNewsTaskService;
this.wmNewsService = wmNewsService;
}

/**
* 保存、修改文章或保存为草稿
*
* @param dto 文章信息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void submitNews(WmNewsDTO dto) {
if (dto == null || dto.getContent() == null) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}

WmNews wmNews = new WmNews();
BeanUtils.copyProperties(dto, wmNews);
if (dto.getImages() != null && !dto.getImages().isEmpty()) {
String imgStr = StringUtils.join(dto.getImages(), ",");
wmNews.setImages(imgStr);
}

if (dto.getType().equals(WmNewsTypeEnum.AUTO.getType())) {
wmNews.setType(null);
}
saveOrUpdateWmNews(wmNews);

// 草稿
if (dto.getStatus().equals(WmNews.Status.NORMAL.code())) {
return;
}

// 不是草稿,保存文章内容图片与素材的关系
List<String> materials = extractUrlInfo(dto.getContent());
saveRelativeInfoForContent(materials, wmNews.getId());

// 不是草稿,保存文章封面与素材的关系,布局自动,需要从内容中提起封面
saveRelativeInfoForCover(dto, wmNews, materials);
}

/**
* 保存或修改文章
*
* @param wmNews 文章
*/
public void saveOrUpdateWmNews(WmNews wmNews) {
wmNews.setUserId(IdThreadUtil.getId().intValue());
wmNews.setCreatedTime(new Date());
wmNews.setSubmitedTime(new Date());
wmNews.setEnable((short) 1);

if (wmNews.getId() == null) {
// 保存
wmNewsService.save(wmNews);
} else {
// 修改
// 删除文章图片与素材的关系
wmNewsMaterialMapper.delete(Wrappers.<WmNewsMaterial>lambdaQuery().eq(WmNewsMaterial::getNewsId, wmNews.getId()));
wmNewsService.updateById(wmNews);
}
}

/**
* 提取文章内容中图片信息
*
* @param content 文章内容
* @return 图片URL列表
*/
private List<String> extractUrlInfo(String content) {
List<String> materials = new ArrayList<>();
List<Map> maps = JSON.parseArray(content, Map.class);
for (Map map : maps) {
if (map.get("type").equals("image")) {
String imgUrl = (String) map.get("value");
materials.add(imgUrl);
}
}
return materials;
}

/**
* 1.封面类型为自动,则从内容中提取封面信息
* 提取规则
* - 内容素材大于等于1,小于3 单图 type=1
* - 内容素材大于等于3 多图 type=3
* - 如果内容没有素材 无图 type=0
* 2.保存封面图片和素材的关系
*
* @param dto 封面类型
* @param wmNews 文章
* @param materials 内容中的素材
*/
private void saveRelativeInfoForCover(WmNewsDTO dto, WmNews wmNews, List<String> materials) {
List<String> covers = dto.getImages();
if (dto.getType().equals(WmNewsTypeEnum.AUTO.getType())) {
// 多图
if (materials.size() >= 3) {
wmNews.setType(WmNewsTypeEnum.MULTI_IMAGE.getType());
covers = materials.stream().limit(3).collect(Collectors.toList());
} else if (!materials.isEmpty()) {
// 单图
wmNews.setType(WmNewsTypeEnum.SINGLE_IMAGE.getType());
covers = materials.stream().limit(1).collect(Collectors.toList());
} else {
// 无图
wmNews.setType(WmNewsTypeEnum.NONE_IMAGE.getType());
}

// 修改文章
if (covers != null && !covers.isEmpty()) {
wmNews.setImages(StringUtils.join(covers, ","));
}
wmNewsService.updateById(wmNews);
}
saveRelativeInfo(covers, wmNews.getId(), WmNewsMaterialReferenceEnum.COVER_REFERENCE.getRef());
}

/**
* 保存内容中素材和文章的关系
*
* @param materials 素材url
* @param newsId 文章ID
*/
public void saveRelativeInfoForContent(List<String> materials, Integer newsId) {
saveRelativeInfo(materials, newsId, WmNewsMaterialReferenceEnum.CONTENT_REFERENCE.getRef());
}

/**
* 保存文章与素材的关系
*
* @param materials 素材
* @param newsId 文章ID
* @param ref 哪种关系
*/
private void saveRelativeInfo(List<String> materials, Integer newsId, Short ref) {
if (materials == null || materials.isEmpty()) {
return;
}

List<WmMaterial> dbMaterials = wmMaterialMapper.selectList(Wrappers.<WmMaterial>lambdaQuery().in(WmMaterial::getUrl, materials));

// 判断素材是否有效
if (dbMaterials == null || dbMaterials.isEmpty()) {
throw new BizException(ResultCodeEnum.MATERIAL_REFERENCE_FAIL);
}

if (materials.size() != dbMaterials.size()) {
throw new BizException(ResultCodeEnum.MATERIAL_REFERENCE_FAIL);
}

List<Integer> idList = dbMaterials.stream().map(WmMaterial::getId).collect(Collectors.toList());
wmNewsMaterialMapper.saveRelations(idList, newsId, WmNewsMaterialReferenceEnum.CONTENT_REFERENCE.getRef());
}

}

文章与素材关联表的操作Mapper

WmNewsMaterialMapper
@Mapper
public interface WmNewsMaterialMapper extends BaseMapper<WmNewsMaterial> {

void saveRelations(@Param("materialIds")List<Integer> materialIds, @Param("newsId") Integer newsId, @Param("type") Short type);
}

实现该Mapper的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.swx.wemedia.mapper.WmNewsMaterialMapper">

<insert id="saveRelations">
INSERT INTO wm_news_material (material_id, news_id, type, ord)
VALUES
<foreach collection="materialIds" index="ord" item="mid" separator=",">
(#{mid}, #{newsId}, #{type}, #{ord})
</foreach>
</insert>

</mapper>

Service中使用的枚举类

文章布局类型

WmNewsTypeEnum
public enum WmNewsTypeEnum {
NONE_IMAGE((short) 0, "无图"),
SINGLE_IMAGE((short) 1, "单图"),
MULTI_IMAGE((short) 2, "多图"),
AUTO((short) -1, "自动");

private final Short type;
private final String desc;

WmNewsMaterialTypeEnum(Short type, String desc) {
this.type = type;
this.desc = desc;
}

public Short getType() {
return type;
}

public String getDesc() {
return desc;
}
}

文章素材引用关系

WmNewsMaterialReferenceEnum
public enum WmNewsMaterialReferenceEnum {

CONTENT_REFERENCE((short) 0, "内容引用"),
COVER_REFERENCE((short) 1, "封面引用");

private final Short ref;
private final String desc;

WmNewsMaterialReferenceEnum(Short ref, String desc) {
this.ref = ref;
this.desc = desc;
}

public Short getRef() {
return ref;
}

public String getDesc() {
return desc;
}
}

提供文章发布、修改、保存草稿的服务

WmNewsController
@PostMapping("/submit")
public void submitNews(@RequestBody WmNewsDTO dto) {
wmNewsService.submitNews(dto);
}

文章审核

自媒体保存的文章,用户查询到需要经历审核步骤:包括文本审核和图片审核。

审核设计的关键技术

  1. 第三方内容安全审核接口
  2. 分布式主键
  3. 异步调用
  4. feign远程接口
  5. 熔断降级

自动审核:文章发布之后,系统自动审核,主要是通过第三方接口对文章内容进行审核(成功、失败、不确定)。

人工审核:待自动审核返回不确定信息时,转到人工审核,由平台管理员进行审核。

自动审核的流程

阿里云的内容审核需要企业认证,这里直接跳过

APP端文章保存

随着业务的增长,文章表可能要占用很大的物理存储空间,为了解决该问题,后期使用数据库分片技术。将一个数据库进行拆分,通过数据库中间件连接。如果数据库中该表选用ID自增策略,则可能产生重复的ID,此时应该使用分布式ID生成策略来生成ID。

分布式ID-技术选型

方案 优势 劣势
redis (INCR)生成一个全局连续递增 的数字类型主键 增加了一个外部组件的依赖,Redis不可用,则整个数据库将无法在插入
UUID 全局唯一,Mysql也有UUID实现 36个字符组成,占用空间大
snowflake算法 全局唯一 ,数字类型,存储成本低 机器规模大于1024台无法支持

项目中使用雪花(snowflake)算法解决分布式ID

ApArticleApArticleConfigApArticleContent实体类的主键策略更改为ASSIGN_ID

@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;

在配置中心中配置数据中心ID和机器ID

mybatis-plus:
global-config:
datacenter-id: 1
workerId: 1

接口定义

说明
接口路径 /api/v1/article/save
请求方式 POST
参数 ArticleDto
响应结果 R

定义Service方法

ApArticleService
/**
* 保存APP端相关文章
*
* @param dto 文章信息
*/
public Long saveArticle(ArticleDTO dto);

实现Service方法

/**
* 保存APP端相关文章
*
* @param dto 文章信息
*/
@Transactional(rollbackFor = RuntimeException.class)
@Override
public Long saveArticle(ArticleDTO dto) {
if (dto == null) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}

ApArticle apArticle = new ApArticle();
BeanUtils.copyProperties(dto, apArticle);

if (dto.getId() == null) {
// 不存在ID,保存
save(apArticle);

// 保存文章配置
ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
apArticleConfigMapper.insert(apArticleConfig);

// 保存文章内容
ApArticleContent apArticleContent = new ApArticleContent();
apArticleContent.setArticleId(apArticle.getId());
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.insert(apArticleContent);

} else {
// 存在ID,修改
updateById(apArticle);

// 修改文章内容
ApArticleContent apArticleContent = apArticleContentMapper.selectOne(
Wrappers.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, apArticle.getId()));
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.updateById(apArticleContent);

}

return apArticle.getId();
}

Feign接口定义

leadnews-feign-api模块中创建包com.swx.apis.article

在该包下创建IArticleClient接口,内容如下:

IArticleClient
@FeignClient("leadnews-article")
public interface IArticleClient {

@PostMapping("/api/v1/article/save")
public R saveArticle(ArticleDTO dto);
}

leadnews-article模块中实现该方法

feign.ArticleClient
@RestController
public class ArticleClient implements IArticleClient {

private final ApArticleService apArticleService;

public ArticleClient(ApArticleService apArticleService) {
this.apArticleService = apArticleService;
}

@PostMapping("/api/v1/article/save")
@Override
public R saveArticle(@RequestBody ArticleDTO dto) {
Long articleId = apArticleService.saveArticle(dto);
return R.success(articleId);
}
}

百度云自动审核

文本审核
接口地址:https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined
请求方式:POST
接口名称:内容审核平台-文本
响应结果:

{
"log_id": 15556561295920002,
"conclusion": "合规",
"conclusionType": 1
}

图像审核
接口地址:https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined
请求方式:POST
接口名称:内容审核平台-图像
响应结果:

{
"log_id": 15556561295920003,
"conclusion": "合规",
"conclusionType": 1
}

申请百度云资源

内容审核需要申请到百度云的权限

百度云资源免费领取:领取地址

获取AccessToken

leadnews-common模块中添加com.swx.common.baiduyun包,包下创建AithUtil

public class AuthUtil {

private static Calendar expireDate;
private static boolean flag = false;
private static String accessToken;

static final OkHttpClient HTTP_CLIENT = new OkHttpClient().newBuilder().build();

private static Boolean needAuth() {
Calendar c = Calendar.getInstance();
c.add(5, 1);
return Boolean.valueOf(!flag || c.after(expireDate));
}

/**
* 获取权限token
* @return access_token
*/
public static String getAccessToken(String clientId, String clientSecret) throws Exception {
if (needAuth()) {
flag = true;
accessToken = getAuth(clientId, clientSecret);
}
return accessToken;
}

/**
* 从用户的AK,SK生成鉴权签名(Access Token)
*
* @return 鉴权签名(Access Token)
* @throws IOException IO异常
*/
private static String getAuth(String clientId, String clientSecret) throws Exception {
MediaType mediaType = MediaType.parse("application/json");
RequestBody body = RequestBody.create(mediaType, "");
Request request = new Request.Builder()
.url(String.format(URLConstants.BAIDU_AUTH_TOKEN, clientId, clientSecret))
.method("POST", body)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.build();
Response response = HTTP_CLIENT.newCall(request).execute();
if (response.code() != HttpStatus.OK.value()) {
throw new RuntimeException("百度云AccessToken获取失败");
}
JSONObject result = JSONObject.parseObject(response.body().string());
String accessToken = result.getString("access_token");
return accessToken;
}
}

需要添加okHttp3依赖:

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>

创建自动配置类,内容审核服务

将所需接口地址定义为常量,可以放到leadnews-common模块中的com.swx.common.constants包下

URLConstants
public class URLConstants {

public static final String BAIDU_AUTH_TOKEN = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s";
public static final String BAIDU_TEXT_CENSOR = "https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token=%s";

public static final String BAIDU_IMG_CENSOR = "https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined?access_token=%s";
}

将百度审核结果类型定义为枚举,可以放到leadnews-common模块中的com.swx.common.enums包下

public enum TextCensorResultEnum {

COMPLIANCE((short) 1, "合规"),
NON_COMPLIANCE((short) 2, "不合规"),
SUSPECTED((short) 3, "疑似"),
AUDIT_FAILED((short) 4, "审核失败");

private final Short type;
private final String desc;

TextCensorResultEnum(Short type, String desc) {
this.type = type;
this.desc = desc;
}

public Short type() {
return type;
}

public String desc() {
return desc;
}
}

内容审核服务

@Getter
@Setter
@Slf4j
@Component
@ConfigurationProperties(prefix = "baiduyun")
public class ContentCensor {

private String clientId;
private String clientSecret;

/**
* 文本审核
*
* @param text 待审核文本
* @return 审核结果 { log_id, conclusion, conclusionType }
* @throws Exception 审核异常
*/
public Map<String, Object> textCensor(String text) throws Exception {

String accessToken = AuthUtil.getAccessToken(clientId, clientSecret);
FormBody formBody = new FormBody.Builder().add("text", text).build();
Request request = new Request.Builder()
.url(String.format(URLConstants.BAIDU_TEXT_CENSOR, accessToken))
.method("POST", formBody)
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.addHeader("Accept", "application/json")
.build();

Response response = AuthUtil.HTTP_CLIENT.newCall(request).execute();
String strBody = response.body().string();
if (response.code() != HttpStatus.OK.value()) {
JSONObject errObj = JSONObject.parseObject(strBody);
log.error("百度文本审核接口调用失败:error_code: {}, error_msg: {}", errObj.getString("error_code"), errObj.getString("error_msg"));
throw new RuntimeException("百度文本审核接口调用失败");
}

JSONObject resultObj = JSONObject.parseObject(strBody);
Map<String, Object> result = new HashMap<>();
result.put("log_id", resultObj.getString("log_id"));
result.put("conclusion", resultObj.getString("conclusion"));
result.put("conclusionType", resultObj.getShortValue("conclusionType"));
return result;
}

/**
* 图像审核
*
* @param imgParams 待审核图像列表
* @return 审核结果 { log_id, conclusion, conclusionType }
* @throws Exception 审核异常
*/
public Map<String, Object> imgCensor(List<String> imgParams) throws Exception {

String accessToken = AuthUtil.getAccessToken(clientId, clientSecret);
Request.Builder builder = new Request.Builder()
.url(String.format(URLConstants.BAIDU_IMG_CENSOR, accessToken))
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.addHeader("Accept", "application/json");

Map<String, Object> result = new HashMap<>();
for (String imgParam : imgParams) {
FormBody formBody = new FormBody.Builder().add("image", imgParam).build();
Request request = builder.method("POST", formBody).build();
Response response = AuthUtil.HTTP_CLIENT.newCall(request).execute();
String strBody = response.body().string();
if (response.code() != HttpStatus.OK.value()) {
JSONObject errObj = JSONObject.parseObject(strBody);
log.error("百度图像审核接口调用失败:error_code: {}, error_msg: {}", errObj.getString("error_code"), errObj.getString("error_msg"));
throw new RuntimeException("百度文本审核接口调用失败");
}
JSONObject resultObj = JSONObject.parseObject(strBody);
Short conclusionType = resultObj.getShort("conclusionType");
// 审核不是通过
if (!Objects.equals(conclusionType, TextCensorResultEnum.COMPLIANCE.type())) {
result.put("log_id", resultObj.getString("log_id"));
result.put("conclusion", resultObj.getString("conclusion"));
result.put("conclusionType", resultObj.getShortValue("conclusionType"));
return result;
}
}
result.put("conclusionType", TextCensorResultEnum.COMPLIANCE.type());
return result;
}
}

文章自动审核

为了方便使用,定义一下几个枚举类

文章内容类型:

WmNewsContentTypeEnum
public enum WmNewsContentTypeEnum {
TEXT("text", "文本"),
IMAGE("image", "图片");

private String type;
private String desc;

WmNewsContentTypeEnum(String type, String desc) {
this.type = type;
this.desc = desc;
}

public String getType() {
return type;
}

public String getDesc() {
return desc;
}
}

定义文章自动审核Service

WmNewsAutoScanService
public interface WmNewsAutoScanService {

/**
* 自媒体文章审核
*
* @param id 自媒体文章ID
*/
public void autoScanWmNews(Integer id);
}

实现该接口

@Service
public class WmNewsAutoScanServiceImpl implements WmNewsAutoScanService {

private final WmNewsService wmNewsService;
private final ContentCensor contentCensor;
private final FileStorageService fileStorageService;
private final IArticleClient articleClient;
private final WmChannelService wmChannelService;
private final WmUserService wmUserService;

public WmNewsAutoScanServiceImpl(WmNewsService wmNewsService, ContentCensor contentCensor, FileStorageService fileStorageService, IArticleClient articleClient, WmChannelService wmChannelService, WmUserService wmUserService) {
this.wmNewsService = wmNewsService;
this.contentCensor = contentCensor;
this.fileStorageService = fileStorageService;
this.articleClient = articleClient;
this.wmChannelService = wmChannelService;
this.wmUserService = wmUserService;
}

/**
* 自媒体文章审核
*
* @param id 自媒体文章ID
*/
@Override
@Transactional(rollbackFor = RuntimeException.class)
public void autoScanWmNews(Integer id) {
// 查询文章
WmNews wmNews = wmNewsService.getById(id);
if (wmNews == null) {
throw new RuntimeException("WmNewsAutoScanServiceImpl-文章不存在");
}

if (!wmNews.getStatus().equals(WmNews.Status.SUBMIT.code())) {
return;
}

// 从内容中提取文本和图片
Map<String, Object> textAndImages = handleTextAndImages(wmNews);
// 审核文本内容
boolean isTextScan = handleTextScan((String) textAndImages.get("text"), wmNews);
if (!isTextScan) return;
// 审核图片
boolean isImageScan = handleImageScan((List<String>) textAndImages.get("image"), wmNews);
if (!isImageScan) return;

// 审核成功,保存APP端的相关文章数据
R r = saveAppArticle(wmNews);
if (!r.getCode().equals(ResultCodeEnum.SUCCESS.code())) {
throw new RuntimeException("WmNewsAutoScanServiceImpl-文章审核失败, 保存app端相关文章数据失败");
}
// 回填article_id
wmNews.setArticleId((Long) r.getData());
updateWmNews(wmNews, WmNews.Status.PUBLISHED.code(), "审核成功");
}

/**
* 保存APP端的相关文章数据
* @param wmNews 文章数据
*/
private R saveAppArticle(WmNews wmNews) {
ArticleDTO articleDTO = new ArticleDTO();
BeanUtils.copyProperties(wmNews, articleDTO);

articleDTO.setLayout(wmNews.getType());

WmChannel wmChannel = wmChannelService.getById(wmNews.getChannelId());
if (wmChannel != null) {
articleDTO.setChannelName(wmChannel.getName());
}

articleDTO.setAuthorId(wmNews.getUserId());
WmUser wmUser = wmUserService.getById(wmNews.getUserId());
if (wmUser != null) {
articleDTO.setAuthorName(wmUser.getName());
}

if (wmNews.getArticleId() != null) {
articleDTO.setId(wmNews.getArticleId());
}
articleDTO.setCreatedTime(new Date());

return articleClient.saveArticle(articleDTO);
}

/**
* 审核图片内容
*
* @param images 图片列表
* @param wmNews 文章信息
* @return 是否审核通过
*/
private boolean handleImageScan(List<String> images, WmNews wmNews) {

if (images == null || images.isEmpty()) return true;

List<String> imgParams = new ArrayList<>();
// 下载MinIO
// 图片去重
images = images.stream().distinct().collect(Collectors.toList());
for (String image : images) {
byte[] imgData = fileStorageService.downLoadFile(image);
String imgStr = Base64Util.encode(imgData);
String imgParam = URLEncoder.encode(imgStr, StandardCharsets.UTF_8);
imgParams.add(imgParam);
}

try {
Map<String, Object> map = contentCensor.imgCensor(imgParams);
Short conclusionType = (Short) map.get("conclusionType");
// 存在违规内容
if (conclusionType.equals(TextCensorResultEnum.NON_COMPLIANCE.type())) {
updateWmNews(wmNews, WmNews.Status.FAIL.code(), "当前素材中存在违规内容");
}

// 文章疑似有违规内容
if (conclusionType.equals(TextCensorResultEnum.SUSPECTED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "当前素材中存在不确定性内容");
}

// 自动审核失败,转人工审核
if (conclusionType.equals(TextCensorResultEnum.AUDIT_FAILED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "自动审核失败");
}
return conclusionType.equals(TextCensorResultEnum.COMPLIANCE.type());
} catch (Exception e) {
e.printStackTrace();
return false;
}

}

/**
* 审核文本内容
*
* @param text 文本内容
* @param wmNews 文章信息
* @return 是否审核通过
*/
private boolean handleTextScan(String text, WmNews wmNews) {
if ((wmNews.getTitle() + text).isEmpty()) return true;
try {
Map<String, Object> map = contentCensor.textCensor(wmNews.getTitle() + "-" + text);
Short conclusionType = (Short) map.get("conclusionType");
// 存在违规内容
if (conclusionType.equals(TextCensorResultEnum.NON_COMPLIANCE.type())) {
updateWmNews(wmNews, WmNews.Status.FAIL.code(), "当前文章中存在违规内容");
}

// 文章疑似有违规内容
if (conclusionType.equals(TextCensorResultEnum.SUSPECTED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "当前文章中存在不确定性内容");
}

// 自动审核失败,转人工审核
if (conclusionType.equals(TextCensorResultEnum.AUDIT_FAILED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "自动审核失败");
}

return conclusionType.equals(TextCensorResultEnum.COMPLIANCE.type());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

/**
* 修改文章内容
*
* @param wmNews 文章
* @param status 待修改文章状态
* @param reason 待修改文章审核原因
*/
private void updateWmNews(WmNews wmNews, short status, String reason) {
wmNews.setStatus(status);
wmNews.setReason(reason);
wmNewsService.updateById(wmNews);
}

/**
* 从内容和封面中提取文本和图片
*
* @param wnNews 内容
* @return 文本和图片
*/
private Map<String, Object> handleTextAndImages(WmNews wnNews) {
if (!StringUtils.hasText(wnNews.getContent())) {
return null;
}

// 存储纯文本内容
StringBuilder texts = new StringBuilder();
ArrayList<String> images = new ArrayList<>();

List<Map> maps = JSON.parseArray(wnNews.getContent(), Map.class);
for (Map map : maps) {
if (map.get("type").equals(WmNewsContentTypeEnum.TEXT.getType())) {
texts.append(map.get("value"));
}
if (map.get("type").equals(WmNewsContentTypeEnum.IMAGE.getType())) {
images.add((String) map.get("value"));
}
}

// 提取封面
if (StringUtils.hasText(wnNews.getImages())) {
String[] split = wnNews.getImages().split(",");
images.addAll(Arrays.asList(split));
}

Map<String, Object> result = new HashMap<>();
result.put("text", texts.toString());
result.put("images", images);
return result;
}
}

Base64工具类

Base64Util
/**
* Base64 工具类
*/
public class Base64Util {
private static final char last2byte = (char) Integer.parseInt("00000011", 2);
private static final char last4byte = (char) Integer.parseInt("00001111", 2);
private static final char last6byte = (char) Integer.parseInt("00111111", 2);
private static final char lead6byte = (char) Integer.parseInt("11111100", 2);
private static final char lead4byte = (char) Integer.parseInt("11110000", 2);
private static final char lead2byte = (char) Integer.parseInt("11000000", 2);
private static final char[] encodeTable = new char[]{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'};

public Base64Util() {
}

public static String encode(byte[] from) {
StringBuilder to = new StringBuilder((int) ((double) from.length * 1.34D) + 3);
int num = 0;
char currentByte = 0;

int i;
for (i = 0; i < from.length; ++i) {
for (num %= 8; num < 8; num += 6) {
switch (num) {
case 0:
currentByte = (char) (from[i] & lead6byte);
currentByte = (char) (currentByte >>> 2);
case 1:
case 3:
case 5:
default:
break;
case 2:
currentByte = (char) (from[i] & last6byte);
break;
case 4:
currentByte = (char) (from[i] & last4byte);
currentByte = (char) (currentByte << 2);
if (i + 1 < from.length) {
currentByte = (char) (currentByte | (from[i + 1] & lead2byte) >>> 6);
}
break;
case 6:
currentByte = (char) (from[i] & last2byte);
currentByte = (char) (currentByte << 4);
if (i + 1 < from.length) {
currentByte = (char) (currentByte | (from[i + 1] & lead4byte) >>> 4);
}
}

to.append(encodeTable[currentByte]);
}
}

if (to.length() % 4 != 0) {
for (i = 4 - to.length() % 4; i > 0; --i) {
to.append("=");
}
}

return to.toString();
}
}

服务降级

服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃

服务降级虽然会导致请求失败,但是不会导致阻塞。

leadnews-feign-api模块中的com.swx.apis.article下新建包fallback

实现IArticleClient

@Component
public class IArticleClientFallback implements IArticleClient {
@Override
public R saveArticle(ArticleDTO dto) {
return R.fail(ResultCodeEnum.SERVER_ERROR.code(), "获取数据失败");
}
}

指定实现的Fallback

@FeignClient(value = "leadnews-article", fallback = IArticleClientFallback.class)
public interface IArticleClient {

@PostMapping("/api/v1/article/save")
public R saveArticle(ArticleDTO dto);
}

leadnews-wemedia模块中增加配置,扫描发现Fallback实现类

config.InitConfig
@Configuration
@ComponentScan("com.swx.apis.article.fallback")
public class InitConfig {
}

leadnews-wemedia的Nacos配置中增加如下配置:

feign:
hystrix:
enabled: true
client:
config:
default:
connect-timeout: 2000
read-timeout: 2000

异步调用

使用异步线程的方式实现异步调用

首先在启动类中添加开启异步的注解

@EnableAsync
public class WemediaApplication {
public static void main(String[] args) {
SpringApplication.run(WemediaApplication.class, args);
}
}

在方法上添加异步注解

@Override
@Async
@Transactional(rollbackFor = RuntimeException.class)
public void autoScanWmNews(Integer id) {
....
}

在处理保存文章的方法中添加异步调用文章审核的逻辑

WmNewsSubmitServiceImpl
/**
* 保存、修改文章或保存为草稿
*
* @param dto 文章信息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void submitNews(WmNewsDTO dto) {
....;
// 异步调用自动审核
wmNewsAutoScanService.autoScanWmNews(wmNews.getId());
}

自管理敏感词过滤

文字敏感词过滤

维护一个敏感词表

CREATE TABLE `wm_sensitive` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`sensitive` varchar(10) DEFAULT NULL COMMENT '敏感词',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2;

匹配工具方法

SensitiveWordUtil
public class SensitiveWordUtil {

public static Map<String, Object> dictionaryMap = new HashMap<>();


/**
* 生成关键词字典库
* @param words
* @return
*/
public static void initMap(Collection<String> words) {
if (words == null) {
System.out.println("敏感词列表不能为空");
return ;
}

// map初始长度words.size(),整个字典库的入口字数(小于words.size(),因为不同的词可能会有相同的首字)
Map<String, Object> map = new HashMap<>(words.size());
// 遍历过程中当前层次的数据
Map<String, Object> curMap = null;
Iterator<String> iterator = words.iterator();

while (iterator.hasNext()) {
String word = iterator.next();
curMap = map;
int len = word.length();
for (int i =0; i < len; i++) {
// 遍历每个词的字
String key = String.valueOf(word.charAt(i));
// 当前字在当前层是否存在, 不存在则新建, 当前层数据指向下一个节点, 继续判断是否存在数据
Map<String, Object> wordMap = (Map<String, Object>) curMap.get(key);
if (wordMap == null) {
// 每个节点存在两个数据: 下一个节点和isEnd(是否结束标志)
wordMap = new HashMap<>(2);
wordMap.put("isEnd", "0");
curMap.put(key, wordMap);
}
curMap = wordMap;
// 如果当前字是词的最后一个字,则将isEnd标志置1
if (i == len -1) {
curMap.put("isEnd", "1");
}
}
}

dictionaryMap = map;
}

/**
* 搜索文本中某个文字是否匹配关键词
* @param text
* @param beginIndex
* @return
*/
private static int checkWord(String text, int beginIndex) {
if (dictionaryMap == null) {
throw new RuntimeException("字典不能为空");
}
boolean isEnd = false;
int wordLength = 0;
Map<String, Object> curMap = dictionaryMap;
int len = text.length();
// 从文本的第beginIndex开始匹配
for (int i = beginIndex; i < len; i++) {
String key = String.valueOf(text.charAt(i));
// 获取当前key的下一个节点
curMap = (Map<String, Object>) curMap.get(key);
if (curMap == null) {
break;
} else {
wordLength ++;
if ("1".equals(curMap.get("isEnd"))) {
isEnd = true;
}
}
}
if (!isEnd) {
wordLength = 0;
}
return wordLength;
}

/**
* 获取匹配的关键词和命中次数
* @param text
* @return
*/
public static Map<String, Integer> matchWords(String text) {
Map<String, Integer> wordMap = new HashMap<>();
int len = text.length();
for (int i = 0; i < len; i++) {
int wordLength = checkWord(text, i);
if (wordLength > 0) {
String word = text.substring(i, i + wordLength);
// 添加关键词匹配次数
if (wordMap.containsKey(word)) {
wordMap.put(word, wordMap.get(word) + 1);
} else {
wordMap.put(word, 1);
}

i += wordLength - 1;
}
}
return wordMap;
}

public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("法轮");
list.add("法轮功");
list.add("冰毒");
initMap(list);
String content="我是一个好人,并不会卖冰毒,也不操练法轮功,我真的不卖冰毒";
Map<String, Integer> map = matchWords(content);
System.out.println(map);
}
}

使用该方法

WmNewsAutoScanServiceImpl
/**
* 自管理的敏感词审核
*
* @param text 文本
* @param wmNews 文章信息
* @return 是否审核通过
*/
private boolean handleSensitiveScan(String text, WmNews wmNews) {
// 获取所有敏感词
List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers.<WmSensitive>lambdaQuery().select(WmSensitive::getSensitive));
List<String> sensitives = wmSensitives.stream().map(WmSensitive::getSensitive).collect(Collectors.toList());

// 初始化敏感词库
SensitiveWordUtil.initMap(sensitives);

// 查看文章中是否存在敏感词库
Map<String, Integer> map = SensitiveWordUtil.matchWords(text);
if (!map.isEmpty()) {
updateWmNews(wmNews, WmNews.Status.FAIL.code(), "当前文章存在违规内容" + map);
return false;
}
return true;
}

图片敏感词过滤

图片识别

引入tess4j的依赖

pom.xml
<dependency>
<groupId>net.sourceforge.tess4j</groupId>
<artifactId>tess4j</artifactId>
<version>5.7.0</version>
</dependency>

封装一下:

Tess4jClient
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "tess4j")
public class Tess4jClient {

private String dataPath;
private String language;

public String doOCR(BufferedImage image) throws TesseractException {
ITesseract tesseract = new Tesseract();
tesseract.setDatapath(dataPath);
tesseract.setLanguage(language);
String result = tesseract.doOCR(image);
result = result.replaceAll("\\r|\\n", "-").replaceAll(" ", "");
return result;
}
}

自动装配

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.swx.common.tees4j.Tess4jClient

使用,首先配置信息

bootstrap.yaml
tess4j:
data-path: classpath:tessdata/
language: chi_sim

修改图片审核的逻辑

handleImageScan
/**
* 审核图片内容
*
* @param images 图片列表
* @param wmNews 文章信息
* @return 是否审核通过
*/
private boolean handleImageScan(List<String> images, WmNews wmNews) {

if (images == null || images.isEmpty()) return true;

List<String> imgParams = new ArrayList<>();
try {
// 图片去重
images = images.stream().distinct().collect(Collectors.toList());
for (String image : images) {
// 下载MinIO
byte[] imgData = fileStorageService.downLoadFile(image);
ByteArrayInputStream in = new ByteArrayInputStream(imgData);
BufferedImage bufferedImage = ImageIO.read(in);

// 图片识别
String result = tess4jClient.doOCR(bufferedImage);
// 过滤文字
boolean isSensitive = handleSensitiveScan(result, wmNews);
if (!isSensitive) {
return false;
}
// 转Base64
String imgStr = Base64Util.encode(imgData);
String imgParam = URLEncoder.encode(imgStr, StandardCharsets.UTF_8);
imgParams.add(imgParam);
}
} catch (Exception e) {
e.printStackTrace();
}

try {
Map<String, Object> map = contentCensor.imgCensor(imgParams);
Short conclusionType = (Short) map.get("conclusionType");
// 存在违规内容
if (conclusionType.equals(TextCensorResultEnum.NON_COMPLIANCE.type())) {
updateWmNews(wmNews, WmNews.Status.FAIL.code(), "当前素材中存在违规内容");
}

// 图片疑似有违规内容
if (conclusionType.equals(TextCensorResultEnum.SUSPECTED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "当前素材中存在不确定性内容");
}

// 自动审核失败,转人工审核
if (conclusionType.equals(TextCensorResultEnum.AUDIT_FAILED.type())) {
updateWmNews(wmNews, WmNews.Status.ADMIN_AUTH.code(), "自动审核失败");
}
return conclusionType.equals(TextCensorResultEnum.COMPLIANCE.type());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

APP端静态模版

文章保存之后,应该调用生成静态模版并上传至MinIO的逻辑,可以使用异步调用,不干扰文章的保存进度。

为了防止循环依赖,这里将保存APP端文章的逻辑提取到ApArticleSaveService中

创建ApArticleSaveService,代码从原ApArticleService中拷贝即可:

public interface ApArticleSaveService {

/**
* 保存APP端相关文章
*
* @param dto 文章信息
*/
public Long saveArticle(ArticleDTO dto);
}

实现该接口

@Service
public class ApArticleSaveServiceImpl implements ApArticleSaveService {

private final ApArticleConfigMapper apArticleConfigMapper;
private final ApArticleContentMapper apArticleContentMapper;
private final ArticleFreemarkerService articleFreemarkerService;
private final ApArticleService apArticleService;


public ApArticleSaveServiceImpl(ApArticleConfigMapper apArticleConfigMapper, ApArticleContentMapper apArticleContentMapper,
ArticleFreemarkerService articleFreemarkerService, ApArticleService apArticleService) {
this.apArticleConfigMapper = apArticleConfigMapper;
this.apArticleContentMapper = apArticleContentMapper;
this.articleFreemarkerService = articleFreemarkerService;
this.apArticleService = apArticleService;
}

/**
* 保存APP端相关文章
*
* @param dto 文章信息
*/
@Transactional(rollbackFor = RuntimeException.class)
@Override
public Long saveArticle(ArticleDTO dto) {
if (dto == null) {
throw new BizException(ResultCodeEnum.PARAM_INVALID);
}

ApArticle apArticle = new ApArticle();
BeanUtils.copyProperties(dto, apArticle);

if (dto.getId() == null) {
// 不存在ID,保存
apArticleService.save(apArticle);

// 保存文章配置
ApArticleConfig apArticleConfig = new ApArticleConfig(apArticle.getId());
apArticleConfigMapper.insert(apArticleConfig);

// 保存文章内容
ApArticleContent apArticleContent = new ApArticleContent();
apArticleContent.setArticleId(apArticle.getId());
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.insert(apArticleContent);

} else {
// 存在ID,修改
apArticleService.updateById(apArticle);

// 修改文章内容
ApArticleContent apArticleContent = apArticleContentMapper.selectOne(
Wrappers.<ApArticleContent>lambdaQuery().eq(ApArticleContent::getArticleId, apArticle.getId()));
apArticleContent.setContent(dto.getContent());
apArticleContentMapper.updateById(apArticleContent);

}

// 异步调用,生成静态文件上传到MinIOn中
articleFreemarkerService.buildArticleToMinIO(apArticle, dto.getContent());

return apArticle.getId();
}
}

修改Feign远程调用的Client

ArticleClient
@RestController
public class ArticleClient implements IArticleClient {

private final ApArticleSaveService apArticleSaveService;

public ArticleClient(ApArticleSaveService apArticleSaveService) {
this.apArticleSaveService = apArticleSaveService;
}

@PostMapping("/api/v1/article/save")
@Override
public R saveArticle(@RequestBody ArticleDTO dto) {
Long articleId = apArticleSaveService.saveArticle(dto);
return R.success(articleId);
}
}

下面,实现生成模版并上传到MinIO的逻辑,回填MinIO返回的URL到数据库中

ArticleFreemarkerService
public interface ArticleFreemarkerService {

/**
* 生成静态文件上传到MinIO中
*
* @param apArticle 文章
* @param content 内容
*/
public void buildArticleToMinIO(ApArticle apArticle, String content);
}

实现该接口

ArticleFreemarkerServiceImpl
@Service
public class ArticleFreemarkerServiceImpl implements ArticleFreemarkerService {

private final Configuration configuration;
private final FileStorageService fileStorageService;
private final ApArticleService apArticleService;

public ArticleFreemarkerServiceImpl(Configuration configuration, FileStorageService fileStorageService, ApArticleService apArticleService) {
this.configuration = configuration;
this.fileStorageService = fileStorageService;
this.apArticleService = apArticleService;
}

/**
* 生成静态文件上传到MinIO中
*
* @param apArticle 文章
* @param content 内容
*/
@Async
@Override
public void buildArticleToMinIO(ApArticle apArticle, String content) {

if (!StringUtils.hasText(content)) {
return;
}

// 文章内容通过freemarker生成html文件
Template template = null;
try {
template = configuration.getTemplate("article.ftl");
StringWriter out = new StringWriter();
// 数据模型
HashMap<String, Object> params = new HashMap<>();
params.put("content", JSONArray.parseArray(content));
// 合成
template.process(params, out);

// 把html文件上传到minio中
InputStream is = new ByteArrayInputStream(out.toString().getBytes());
String url = fileStorageService.uploadHtmlFile("", apArticle.getId() + ".html", is);

// 保存URL到数据库
apArticleService.update(Wrappers.<ApArticle>lambdaUpdate()
.eq(ApArticle::getId, apArticle.getId())
.set(ApArticle::getStaticUrl, url));

} catch (Exception e) {
throw new RuntimeException(e);
}

}
}

分布式事务

自媒体微服务异步调用文章微服务,如果文章微服务出现了问题,自媒体微服务并不知道,一致性无法保证,这时候就需要使用分布式事务。

文章下架

这部分使用消息队列实现微服务间的异步远程调用,使用消息队列可以解耦

消息中间件

中间件技术对比

特性 ActiveMQ RabbitMQ RocketMQ Kafka
开发语言 java erlang java scala
单机吞吐量 万级 万级 10万级 100万级
时效性 ms us ms ms级以内
可用性 高(主从) 高(主从) 非常高(分布式) 非常高(分布式)
功能特性 成熟的产品、较全的文档、各种协议支持好 并发能力强、性能好、延迟低 MQ功能比较完善,扩展性佳 只支持主要的MQ功能,主要应用于大数据领域

选择建议

消息中间件 建议
Kafka 追求高吞吐量,适合产生大量数据的互联网服务的数据收集业务
RocketMQ 可靠性要求很高的金融互联网领域,稳定性高,经历了多次阿里双11考验
RabbitMQ 性能较好,社区活跃度高,数据量没有那么大,优先选择功能比较完备的RabbitMQ

Kafka

  • producer:发布消息的对象称之为主题生产者(Kafka topic producer)

  • topic:Kafka将消息分门别类,每一类的消息称之为一个主题(Topic)

  • consumer:订阅消息并处理发布的消息的对象称之为主题消费者(consumers)

  • broker:已发布的消息保存在一组服务器中,称之为Kafka集群。集群中的每一个服务器都是一个代理(Broker)。 消费者可以订阅一个或多个主题(topic),并从Broker拉数据,从而消费这些已发布的消息。

安装Zookeeper

Kafka对于zookeeper是强依赖,保存kafka相关的节点数据,所以安装Kafka之前必须先安装zookeeper

下载镜像

docker pull zookeeper:3.6

创建容器

docker run -d --name zookeeper -p 2181:2181 zookeeper:3.6

安装Kafka

下载镜像

docker pull wurstmeister/kafka:2.12-2.5.0

创建容器,替换IP地址

docker run -d --name kafka \
--env KAFKA_ADVERTISED_HOST_NAME=xxx.xxx.xxx.xxx \
--env KAFKA_ZOOKEEPER_CONNECT=xxx.xxx.xxx.xxx:2181 \
--env KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://xxx.xxx.xxx.xxx:9092 \
--env KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \
--env KAFKA_HEAP_OPTS="-Xmx256M -Xms256M" \
--net=host wurstmeister/kafka:2.12-2.5.0