ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

企业微信集成开发:Java+Vue实现的公告与知识共享平台

企业微信集成开发:Java+Vue实现的公告与知识共享平台 如果你在一个三百人左右的公司待过大概率能理解一件事公司内部的信息流动从来不靠一个正式系统而是靠行政在微信群里一条又一条地所有人。公告沉底了没人翻得回去新员工的入职文档散落在个人网盘领导问某个方案当初怎么定的群聊记录翻到手指发酸。我在公司做的就是这类内部系统这次要聊的是我自己从零设计并实现的一个基于JavaVue的企业微信内部公告与知识共享平台。它解决的就两件事——公告有地方沉淀、知识有地方共享并且所有访问入口都通过企业微信完成员工不需要记任何额外的账号密码打开企业微信里的工作台就能直接用。这个项目最核心的价值在于它不是一个从零开始的全新系统而是嫁接在企业微信这个已有身份体系之上的内部应用。你不需要做用户注册、不需要做密码找回、不需要做复杂的组织架构维护企业微信把这一层都替你管好了。你要做的是把企业微信的企业信息拉下来和自己的业务数据对齐然后把公告、知识库这些真正有业务价值的功能做好。技术栈选型也比较主流后端用Java的Spring Boot框架前端用Vue前后端分离部署维护成本都不高。这篇文章我会把完整的模型设计、关键接口实现、前端对接方式以及我踩过的坑都写出来。不管你是公司内部要做类似系统的开发人员还是准备把企业微信接入自己产品的独立开发者这篇内容应该都能给你省下不少查文档的时间。1. 为什么我不自己做登录注册而是直接对接企业微信身份先说结论企业内部工具身份认证这一层绝对不能自己造轮子。做这个项目的时候领导最开始的想法是做个系统大家自己注册账号就行了。我当时就否了这个方案原因有几点都很现实。第一员工不会愿意记住第二套账号密码。企业内部系统多的时候每人有五六套账号密码很正常大部分人的处理方式是全部设为同一个密码或者干脆用浏览器记住。一旦某个人离职他的账号、密码、在系统里的数据就成了一个没人能处理的遗产。而企业微信的身份是跟着员工走的人离职了企业微信管理员一键禁用所有系统访问权限同时失效这才是合理的权限生命周期。第二组织架构是动态的。公司每年都有新员工入职、老员工调岗、部门合并拆分。如果自己做组织架构管理你要做一套部门CRUD、人员调动流程、权限同步机制工作量非常大。而企业微信已经把组织架构维护好了你的系统只需要定期同步通讯录就能拿到最新的部门树和人员列表。第三企业微信本身提供了成熟的OAuth身份验证能力。用户在聊天窗口或者工作台里点开你的应用企业微信会带着一个临时code跳转到你的系统后端拿这个code去换用户身份整个过程用户无感知连登录页面都不用做。所以我的方案很明确企业微信负责这个人是谁、在哪个部门业务系统负责这个人能看什么、能发什么。身份归身份权限归业务两边通过一个user_id字段关联。1.1 企业内部应用的整体流程整个系统的用户访问链路是这样的用户打开企业微信在工作台找到公告与知识库应用点击进入。企业微信首先判断用户是否已登录如果已登录直接带上一个code回调到我们系统的登录接口如果未登录则先让用户在企业微信内部完成身份确认再回调。后端拿到code之后调用企业微信的API换取用户在企业微信体系内的UserID然后查自己的用户表如果这个UserID存在就直接生成一个JWT作为我们系统内部的登录凭证返回给前端如果用户表里没有这个人就先自动创建一条用户记录再生成Token。这里有一步很多人第一次做会漏掉企业微信回调传过来的code是一次性的有效期只有5分钟而且只能用一次。所以后端拿到code之后要立刻换用户信息不能存下来等以后再用。另外code换UserID的接口本身也有频率限制如果生产环境的并发量很大一定要做缓存和限流。1.2 登录状态怎么维护企业内部应用通常有两种登录态方案。第一种是纯企业微信会话也就是每次请求都通过企业微信的JS-SDK来判断登录态不引入自己的Token体系。这种方案的好处是简单不需要额外维护会话但坏处是每次页面刷新都要重新走一遍企业微信的身份判断而且JS-SDK的初始化在部分非企业微信浏览器环境下不稳定。第二种是我采用的方案企业微信只负责第一次身份认证认证通过之后后端签发一个自己的JWT Token前端在所有请求里携带这个Token。这样做的好处是如果后续要开放PC端的浏览器访问登录流程可以变成先用企业微信扫码再访问业务系统业务接口完全不需要感知你是从哪个端来的。JWT的过期时间我设置的是12小时企业内部工具不需要频繁重新登录但也不能像传统管理后台那样一周都不过期。用户第二天上班打开企业微信如果Token过期了前端会收到401响应这时候自动重新发起一次企业微信OAuth流程用户体感上仍然是点开就能用。2. 平台整体架构Spring Boot和Vue各干各的活这个系统的技术栈不算新但非常稳。后端是Spring Boot 2.7 MyBatis-Plus MySQL前端是Vue 3 Vite Element Plus。我简单说一下为什么这么选。Spring Boot的好处不用多说生态成熟招人容易网上资料多。MyBatis-Plus相比JPA更直观尤其适合这种表结构相对固定、以CRUD为主的内部系统。它的LambdaQueryWrapper写起来比拼接SQL字符串舒服太多了而且分页插件开箱即用查公告列表、知识列表这些高频场景很方便。Vue 3 Vite这个组合如果是在2023年之前我可能还会犹豫一下因为Vite的生态当时还不够稳。但现在Vite已经是默认选项了开发时热更新快到几乎无感打包后体积也比Webpack小不少。Element Plus作为中后台UI库做公告列表、表单、树形分类这些界面足够用了。2.1 后端工程结构说明后端我按照常见的领域分包方式组织包结构是这样的com.company.announcement ├── config # 全局配置拦截器Jackson配置 ├── controller # 对外接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus的Mapper接口 ├── entity # 数据库实体类 ├── dto # 请求和响应对象 ├── common # 统一返回结果、异常处理、工具类 └── wework # 企业微信API相关封装重点说一下wework这个包。所有和企业微信API交互的逻辑我都收敛到了这里对外只暴露几个方法获取accessToken、通过code换取UserID、同步部门列表、同步用户列表、给用户发应用消息、上传临时素材。这套封装的价值在于如果你的系统后续还要对接企业微信的其他能力比如审批、日程、客户联系只需要往这个包里加方法就行不会污染业务代码。2.2 前端工程结构说明前端我用了Vue 3的组合式API风格目录结构如下src ├── api # 接口请求封装 ├── assets # 静态资源 ├── components # 通用组件 ├── router # 路由配置 ├── stores # Pinia状态管理 ├── views # 页面 │ ├── announcement # 公告模块页面 │ ├── knowledge # 知识库模块页面 │ └── login # 登录中间页 └── utils # 工具函数登录中间页是前端一个比较有意思的设计。由于企业微信OAuth需要跳转到一个独立的授权链接然后再跳回业务页面这个过程中间会有一个白屏时间。我在路由里加了一个LoginCallback页面专门处理这个回调拿到Token之后根据业务参数再redirect到原本要访问的页面。这样用户从企业微信点进来是哪个页面登录完成之后就还是回到哪个页面体验比较顺滑。3. 数据库模型设计公告、知识、组织架构三块核心数据数据模型是这个项目的根基我前前后后改了四版。最开始的版本把所有字段塞到一张大表里后来拆成了三块组织架构同步相关、公告业务相关、知识库业务相关。下面直接给出我个人觉得比较完善、可以直接参考的表结构。3.1 组织和用户表CREATE TABLE sys_department ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, dept_id bigint NOT NULL COMMENT 企业微信部门ID, name varchar(100) NOT NULL COMMENT 部门名称, parent_id bigint DEFAULT NULL COMMENT 父部门ID根部门为0, order_no int DEFAULT 0 COMMENT 在同级部门中的排序, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_dept_id (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表;CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, user_id varchar(64) NOT NULL COMMENT 企业微信UserID, name varchar(64) NOT NULL COMMENT 姓名, avatar_url varchar(500) DEFAULT NULL COMMENT 头像地址, department_id bigint DEFAULT NULL COMMENT 直属部门ID, position varchar(100) DEFAULT NULL COMMENT 职务, mobile varchar(20) DEFAULT NULL COMMENT 手机号, status tinyint DEFAULT 1 COMMENT 状态1启用 0禁用, is_admin tinyint DEFAULT 0 COMMENT 是否管理员, last_sync_time datetime DEFAULT NULL COMMENT 最近同步时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;注意两个细节。第一user_id这个字段我直接用了企业微信的原始UserID没有做自增。因为企业微信的UserID是员工在企业内的唯一标识用它做关联最稳定不要自己生成一套ID再和企业微信做映射。第二department_id加了这个字段方便按部门查人但没有建外键。内部系统不用建物理外键逻辑外键就够了否则后期同步数据调整部门树的时候外键约束反而碍手碍脚。3.2 公告相关表CREATE TABLE announcement ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 公告标题, content text NOT NULL COMMENT 公告正文, type tinyint DEFAULT 1 COMMENT 类型1通知 2制度 3活动 4其他, publisher_id varchar(64) NOT NULL COMMENT 发布人UserID, publish_dept_id bigint DEFAULT NULL COMMENT 发布部门ID, is_top tinyint DEFAULT 0 COMMENT 是否置顶, top_expire_time datetime DEFAULT NULL COMMENT 置顶过期时间, status tinyint DEFAULT 0 COMMENT 状态0草稿 1已发布 2已下线, publish_time datetime DEFAULT NULL COMMENT 发布时间, expire_time datetime DEFAULT NULL COMMENT 下线时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_time (status, publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT公告表;CREATE TABLE announcement_read_record ( id bigint NOT NULL AUTO_INCREMENT, announcement_id bigint NOT NULL, user_id varchar(64) NOT NULL, read_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_announcement_user (announcement_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT公告已读记录表;公告表这里有一个容易被忽略的业务细节置顶和过期。多数公告系统只会做是否置顶但实际运营中公告是有时效的。我加了expire_time字段后台定时任务每隔一段时间扫描一次把到了过期时间的已发布公告自动改为已下线状态。前端列表查询时sql条件里也会加上status 1 AND (expire_time IS NULL OR expire_time NOW())双保险保证过期的公告不会漏出来。已读记录表加唯一索引避免同一个用户对同一篇公告产生多条已读记录。但这里有一个权衡如果公司员工很多已读记录表的数据量会迅速膨胀。我实测的情况是500人规模的公司一年大概产生十几万条已读记录MySQL处理起来没有任何压力。但如果到了几千人以上建议按季度分表或者定期归档三个月前的旧数据。3.3 知识库相关表CREATE TABLE knowledge_category ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 分类名称, parent_id bigint DEFAULT 0 COMMENT 父分类ID0为根分类, sort_order int DEFAULT 0 COMMENT 排序, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识分类表;CREATE TABLE knowledge_doc ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 文档标题, summary varchar(500) DEFAULT NULL COMMENT 摘要, content longtext NOT NULL COMMENT 正文内容, category_id bigint NOT NULL COMMENT 归属分类, author_id varchar(64) NOT NULL COMMENT 作者UserID, views int DEFAULT 0 COMMENT 浏览量, is_public tinyint DEFAULT 1 COMMENT 是否全员可见, status tinyint DEFAULT 1 COMMENT 状态0草稿 1已发布, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识文档表;知识库的分类场景会有两级甚至三级分类所以parent_id设计成了自关联结构。查询分类树的时候一次性把全表查出来在内存里组树不需要递归SQL写起来也简单。知识文档的正文我用的是longtext类型。如果你需要支持上传附件和图片通常的做法是正文里存图片的URL或者引用ID附件单独建一张附件表。我这里实际做的时候就是正文存富文本HTML内容图片上传到企业微信的临时素材接口拿到URL后插入正文浏览器可以直接加载。4. 企业微信认证和通讯录同步的具体实现这一节是整个项目的技术核心也是网上资料最零散的部分。我先从企业微信后台配置说起。4.1 创建自建应用并配置可信域名在企业微信管理后台进入应用管理 - 自建新建一个应用。创建之后你会得到企业的corpId、应用的agentId和secret这三个参数是后续所有接口调用的凭证。需要说明的是corpId是整个企业统一的一个企业只有一个而agentId和secret是每个应用各自独立的一套你在调用API时用的是应用的secret不是管理后台登录密码。配置可信域名这一步非常关键。企业微信的OAuth授权和企业微信JS-SDK都需要你配置可信域名并且域名必须是ICP备案过的、公网可访问的。开发的阶段如果你没有公网环境可以用内网穿透工具临时调试但上线前一定要换成正式域名否则企业微信的校验会不通过。还有一个细节可信域名只认端口80和443如果你本地开发用的是8080端口企业微信的回调URL里不能加端口号这一点需要在后端配置里兼容处理。我当时踩过这个坑具体后面在踩坑章节细说。4.2 access_token的获取与缓存企业微信所有接口都需要携带access_token这个token的有效期是7200秒也就是两个小时而且企业微信的接口限制了获取频率。所以绝对不能每次请求都去拉取access_token必须在内存或Redis里做缓存。我的实现是在wework包下面建了一个TokenManager类逻辑如下用ConcurrentHashMap做本地缓存key是secret因为一个企业可能建了多个应用每个应用的token不同value是token信息和过期时间。每次请求先看缓存里有没有没过期的token没有就调接口获取获取成功后放入缓存并设置过期时间提前5分钟刷新。Component public class WeWorkTokenManager { private static final Logger log LoggerFactory.getLogger(WeWorkTokenManager.class); // 本地缓存生产环境建议替换为Redis支持多个实例 private final MapString, TokenHolder tokenCache new ConcurrentHashMap(); Value(${wework.corp-id}) private String corpId; Value(${wework.secret}) private String secret; public String getAccessToken() { return getAccessToken(secret); } public String getAccessToken(String appSecret) { TokenHolder holder tokenCache.get(appSecret); if (holder ! null holder.getExpireTime() System.currentTimeMillis()) { return holder.getToken(); } return refreshAccessToken(appSecret); } private synchronized String refreshAccessToken(String appSecret) { TokenHolder old tokenCache.get(appSecret); if (old ! null old.getExpireTime() System.currentTimeMillis()) { return old.getToken(); } String url https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid corpId corpsecret appSecret; RestTemplate restTemplate new RestTemplate(); MapString, Object result restTemplate.getForObject(url, Map.class); if (result ! null Integer.valueOf(0).equals(result.get(errcode))) { String token (String) result.get(access_token); Integer expiresIn (Integer) result.get(expires_in); tokenCache.put(appSecret, new TokenHolder(token, System.currentTimeMillis() (expiresIn - 300) * 1000L)); return token; } log.error(获取企业微信access_token失败: {}, result); throw new RuntimeException(获取企业微信access_token失败); } private static class TokenHolder { private String token; private long expireTime; TokenHolder(String token, long expireTime) { this.token token; this.expireTime expireTime; } public String getToken() { return token; } public long getExpireTime() { return expireTime; } } }注意refreshAccessToken方法加了synchronized防止并发场景下多个线程同时刷新同一个应用的token导致企业微信那边的频率限制报错。本地缓存的方案有个问题如果后端是多实例部署每个实例的缓存是独立的可能会造成token不一致。所以如果你的系统要做高可用集群部署建议把缓存放到Redis里所有实例共享一份token。4.3 通过code换取用户身份并签发登录Token前端跳转的OAuth链接是企业微信构造好的大致长这样https://open.weixin.qq.com/connect/oauth2/authorize?appidCORPIDredirect_uriREDIRECT_URIresponse_typecodescopesnsapi_basestateSTATE#wechat_redirect企业微信处理完之后会在redirect_uri后拼接code和state参数回调到后端接口。后端接口收到code之后调用企业微信的获取访问用户身份接口URL如下https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_tokenACCESS_TOKENcodeCODE返回的JSON里包含UserId字段这就是我们系统要用的用户标识。拿到UserId之后去本地用户表查这个人查不到就自动创建一条用户记录。然后生成JWT返回给前端。RestController RequestMapping(/api/auth) public class AuthController { Autowired private WeWorkAuthService weWorkAuthService; Autowired private SysUserService sysUserService; Autowired private JwtTokenProvider jwtTokenProvider; GetMapping(/callback) public ResultLoginResponse callback(RequestParam(code) String code, RequestParam(value state, required false) String state) { String userId weWorkAuthService.getUserIdByCode(code); if (StringUtils.isBlank(userId)) { return Result.fail(企业微信身份认证失败); } SysUser user sysUserService.findByUserId(userId); if (user null) { user new SysUser(); user.setUserId(userId); user.setName(userId); user.setStatus(1); sysUserService.save(user); } if (user.getStatus() null || user.getStatus() ! 1) { return Result.fail(账号已被禁用请联系管理员); } String token jwtTokenProvider.generateToken(user.getId(), user.getUserId()); return Result.ok(new LoginResponse(token, user.getName())); } }JWT我这里用的是jjwt库生成之后客户端存在localStorage里每次请求通过Authorization头发送。后端的拦截器统一解析Token把当前用户信息放到ThreadLocal里这样在业务代码里就能通过UserContext拿到当前登录人的user_id和name。4.4 通讯录同步的定时任务设计通讯录同步这个功能做法上有一个很重要的取舍是全量同步还是增量同步。企业微信的通讯录接口有全量拉取部门列表和用户列表的能力但数据量大时接口响应很慢对加解密性能也是考验。我的做法是每天凌晨3点做一次全量同步同时提供一个手动触发的接口给管理员在通讯录有急变时手动同步。同步的逻辑不复杂先拉部门列表对比本地部门表以企业微信的数据为准做增删改再按部门拉用户列表同样做对比同步最后处理用户被禁用的情况把状态为禁用或者已经不在任何部门下的用户在本地标记为禁用而不是直接删除。这样历史数据还在用户也能正常登录只是看不到新内容。Component public class ContactSyncTask { Autowired private WeWorkDepartmentService departmentService; Autowired private WeWorkUserService userService; Scheduled(cron 0 0 3 * * ?) public void syncAll() { ListWeWorkDepartment deparments departmentService.fetchAll(); departmentService.syncToLocal(deparments); ListWeWorkUser users userService.fetchAllByDepartment(); userService.syncToLocal(users); } }有几个细节要提一下。第一企业微信的部门列表里根部门ID是1通常代表公司本身同步到本地时要注意不要把它和正常业务部门混在一起。第二用户可能归属于多个部门但企业微信接口返回的User对象只带一个主部门如果要把员工的多部门关系也同步下来需要调用获取成员详情接口逐个补齐但大多数内部系统不需要这么复杂一个主部门就够了。第三同步的时机最好选在凌晨因为接口返回的实时数据在全量拉的时候会比较慢。5. 公告模块的核心逻辑发布、置顶、已读回执公告模块是整个系统业务逻辑最重的一个部分。很多人以为公告就是一个CRUD但从运营视角看公告需要处理的是谁看到了、谁没看到这个问题。5.1 公告发布的完整流程发布公告的接口接收一个JSON对象核心字段包括标题、正文、类型、过期时间、是否置顶、接收范围。接收范围这里我做了两类按部门发布和全员发布。如果是按部门发布需要在发布时记录一个接收范围的快照因为部门架构是动态的如果不做快照等公告发出去了部门合并你连这条公告当初是发给谁的都说不清楚。所以我额外建了一张字段表记录公告和部门的关系。发布时把当前选中部门的ID都存进去后续查询已读率时计算基数就用这个快照数据。PostMapping(/api/announcement/publish) public ResultLong publish(RequestBody Valid AnnouncementPublishRequest request) { Long announcementId announcementService.createDraft(request); announcementService.publishById(announcementId); return Result.ok(announcementId); }状态流转是这样的先创建草稿然后调用发布方法把状态从0改为1同时发布人写入publisher_id发布时间写入当前时间如果选定了置顶则写入is_top和top_expire_time。发布成功后系统还会向接收范围内的用户推送一条企业微信应用消息提示他们查看新公告。这个推送不是必须的但实际使用中非常有用否则公告发布之后员工压根不知道。5.2 已读回执的数据结构用户打开公告详情页时前端会调用一个接口标记已读。我用的逻辑是先查announcement_read_record表里有没有记录没有则插入一条。查询已读状态的时候如果读到了就返回已读时间和该公告的总人数、已读人数。这里要注意的是公告的查看和已读要分开。查看可以是打开页面就算已读更严格一点需要在页面上停留一定时间比如5秒或者用户主动滑到底部才算。我实现时用的是简单方案页面加载后停留5秒前端主动调用已读接口。真正的已读回执给管理员看就是已读/未读的人数比例列表。已读列表的查询我用的分页是MyBatis-Plus的分页插件在自定义的Mapper里面join了两张表查出来未读用户列表。数据量小没什么问题但如果公告接收范围是全公司几千人性能会差一些。这时候可以做一层缓存把已读数量统计放在Redis里每次浏览时异步更新。5.3 公告列表的展示逻辑与排序公告列表的排序规则比想象中复杂一点。如果只是按发布时间倒序置顶功能就白做了。我的排序规则是先按is_top倒序置顶的排前面再按publish_time倒序。同时置顶的公告如果过期时间到了要从置顶状态中解除。这里有两种处理方式一种是查列表的时候动态判断不读库里的is_top字段而是动态计算is_top1 AND top_expire_time NOW()另一种是提前用定时任务把过期的置顶记录改为0。我最终两个都用了列表查询时动态判断保证即时性定时任务固定的兜底保证长期数据是干净的。LambdaQueryWrapperAnnouncement wrapper Wrappers.lambdaQuery(); wrapper.eq(Announcement::getStatus, 1) .and(w - w.isNull(Announcement::getExpireTime) .or() .gt(Announcement::getExpireTime, new Date())); wrapper.orderByDesc(Announcement::getIsTop); wrapper.orderByDesc(Announcement::getPublishTime);另外有一个细节公告列表在移动端的展示正文内容只需要显示前100个字作为摘要点进去看全文。我是在后端做截断不回传整篇长文这个优化对于列表接口的响应速度帮助明显。20条公告每条正文按2000字算全文拼接在一起会传输很多不必要的数据。6. 知识库模块的实现重点分类、检索、权限知识库这个东西做功能容易做出价值难。我做的过程中最大的感受是知识库的核心不是能存东西而是能让需要的人快速找到东西。所以我对检索这个功能投入的精力最大。6.1 富文本编辑与图片上传知识文档的编辑前端用的是一个开源的富文本编辑器。它的产出是一段HTML字符串保存时直接存到content字段里。图片上传调用的是我写的一个通用上传接口接口内部调用企业微信的上传临时素材API拿到media_id之后再通过另一个接口把media_id转成图片的永久URL。这里有一个很tricky的地方企业微信的临时素材URL有效期是3天。如果用户把图片插入文档之后3天内没保存URL失效文档里的图片就会裂掉。所以我的处理是图片上传接口返回的URL并不是直接使用的临时URL而是我把图片下载下来存到自己的服务器或者对象存储再生成一个自己的URL。这样做虽然多了一次转发但文档里的图片永远是稳定的。上传接口大致逻辑PostMapping(/api/common/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String fileUrl fileStorageService.store(file); // 存到本地磁盘或OSS return Result.ok(fileUrl); }fileStorageService我这里实现了两个版本一个版本是存到本地磁盘的目录另一个版本是存到阿里云OSS。如果你们的服务器没有公网访问能力建议直接用OSS否则前端图片加载会是一个大问题。6.2 全文检索的轻量实现知识库的检索一开始我想引入Elasticsearch但细想了一下我们这种内部工具文档规模撑死也就几千篇杀鸡用牛刀还要多维护一套集群得不偿失。MyBatis-Plus的分页查询加上MySQL的LIKE模糊查询就够用了。但是LIKE查询有个问题%keyword%这种写法不会命中索引几千条数据没事等数据到了十万级查询就会慢很多。我的方案是用前缀匹配来缓解这个问题标题字段用LIKE keyword%正文用LIKE %keyword%。标题命中说明文档主题相关度高正文命中作为补充结果排列在后面。如果你想更进一步MySQL 5.7以上原生支持ngram全文索引可以不用LIKE而是用MATCH ... AGAINST对中文的支持也不错。我后来在生产环境上线时做的就是这个升级实测几万条文档搜索响应时间从几百毫秒降到几十毫秒收益很直接。6.3 文档权限控制知识库的权限我做的是一个简化版每篇文档有两个状态全员可见和仅指定部门可见。全员可见的文档所有人能看非全员可见的文档需要校验作者和当前用户的部门归属关系。在service层写入权限判断逻辑public KnowledgeDoc detail(Long id) { KnowledgeDoc doc knowledgeDocMapper.selectById(id); if (doc null) { throw new BizException(文档不存在或已删除); } if (doc.getIsPublic() 0) { Long currentUserDeptId UserContext.getCurrentUser().getDepartmentId(); if (!Objects.equals(currentUserDeptId, doc.getAuthorDeptId())) { throw new BizException(无权限查看该文档); } } doc.setViews(doc.getViews() 1); knowledgeDocMapper.updateById(doc); return doc; }这里我只做了部门层级的权限。如果你所在的企业对权限更敏感可以升级成文档可见范围单独存一张关联表支持选择多个部门甚至指定人员。但权限粒度越细前端交互就越复杂需要设置一个页面来管理文档的可见范围。我建议第一版先做粗粒度的运营一段时间后根据实际需求再细化。7. 前端Vue页面搭建与调试细节前端部分我一开始就确定了要适配企业微信内置浏览器同时兼容PC端浏览器所以页面的响应式布局和调试工具选择都有讲究。7.1 项目初始化与基础配置Vue 3项目我用Vite初始化然后依次安装Element Plus、Pinia、Vue Router、Axios。设置全局的API基准地址、请求拦截器和响应拦截器。请求拦截器往Authorization头里塞JWT Token响应拦截器统一处理401跳转和错误弹窗提示。有一个注意点Element Plus的国际化默认是英文的要在入口文件配置成中文语言包。还建议把Element Plus的按需导入配置好否则打包体积会大不少。我用的是unplugin-vue-components自动按需导入装完之后不需要在业务代码里手动引入组件直接写标签就行。7.2 关键页面的组件划分公告列表页用了一个Layout结构左侧是筛选区全部、未读、已过期右侧是公告卡片列表。公告详情页用Markdown渲染内容这里如果你们公告的正文是富文本HTML需要用v-html渲染并加上样式。注意XSS风险后端要处理掉HTML里的script标签。知识库页面左侧是两级的分类树右侧是文档列表和搜索框。分类树用的是Element Plus的el-tree组件数据在后端组装好返回前端的逻辑就是把tree的data绑定上然后监听节点点击事件。权限管理页面是给管理员用的可以手动把某个员工设为管理员也可以手动禁用某个账号。这个页面的入口要做到只有管理员能看见所以路由配置里和菜单渲染里都要做权限判断。7.3 企业微信JS-SDK的集成企业微信JS-SDK是在企业微信内置浏览器里调用原生能力的关键比如获取当前位置、调起扫一扫、隐藏返回按钮等。我们这个项目主要用到了两个能力一个是页面标题的固定和右上角菜单的配置另一个是获取签名所需的jsapi_ticket。JS-SDK的签名逻辑是后端完成的拿当前页面的完整URL不能用带hash的路径必须去掉#后面的部分实际用location.href.split(#)[0]结合jsapi_ticket和时间戳生成签名串。前端初始化配置如下wx.config({ beta: true, debug: false, appId: corpId, timestamp: timestamp, nonceStr: nonceStr, signature: signature, jsApiList: [agentConfig] });这里容易踩的坑是签名用的URL必须和后端接收到的一致。如果前端拿location.href直接传过去而location.href带上了动态参数每次刷新页面URL都变签名就会失效。最佳实践是取location.href.split(#)[0]传回后端后端用同样的逻辑生成签名。7.4 移动端适配的几个细节企业微信内置浏览器是基于Chromium的所以现代CSS特性基本都能用。但有几个移动端特有的问题要注意。第一个是100vh的问题企业微信内置浏览器里100vh不是屏幕高度而是除去顶部导航栏之后的可视区高度所以页面底部如果有固定按钮要用100dvh或者自己用JavaScript计算高度。第二个是输入框聚焦时页面会被顶上去这在长表单页面非常烦人需要给富文本编辑器设置最小高度并且防止滚动手势冲突。第三个是iOS的橡皮筋滚动效果会导致页面顶部和底部出现白边这个可以用overscroll-behavior: none禁止掉。8. 从联调到上线我踩过的那些坑这个项目从开发到上线一共花了大概三周中间有一半时间花在和企业微信API纠缠上。我把印象最深的几个坑记录下来希望能帮你绕过。8.1 企业微信API的回调域名和JS接口安全域名是两个不同的配置这个坑出现在前端做JS-SDK签名的时候。我在企业微信后台只配置了网页授权及JS-SDK的安全域名以为OAuth回调也走这个域名结果联调OAuth时一直提示redirect_uri参数错误。后来看文档才发现企业微信后台有两个地方要配一个是应用详情里的网页授权及JS-SDK配置另一个是API接收消息URL配置OAuth回调用的redirect_uri必须在后者配置的域名下否则会报错。还有一个相关的问题是如果你开发环境用的是localhost企业微信后台没法配置localhost域名必须用公司的测试域名并且该域名需要能公网解析。所以我就先在hosts里绑定一个测试域名然后在内网开发服务器上映射出去这样才顺利联调。8.2 企业微信上传临时素材接口图片会被压缩给知识文档插入图片时我发现上传到企业微信的临时素材接口后拿到的图片URL访问时会被压缩。对于需要展示细节的截图这种压缩损失很影响体验。我的解决办法是把图片先传到自己的OSS上传接口只做个转发不直接依赖企业微信的素材能力。这个方案也能绕开临时素材3天有效期的限制。8.3 MySQL的utf8mb4编码问题系统上线初期有用户反馈说公告详情页打不开我看日志发现报的是SQL异常。排查下来发现是用户的昵称里带有特殊字符比如emoji表情而这些字段在数据库里是utf8mb4编码吗问题出在我建表时只指定了CHARSETutf8mb4但是个别字段比如姓名在建表时没有显式指定继承的是库级的utf8mb4按理说应该没问题。真正的问题是数据库连接串里的characterEncoding没设置成utf8mb4导致Java端写入时被转成了utf8特殊字符就报错了。连接串改好之后插入emoji就正常了。8.4 前端路由刷新404问题Vue项目部署到Nginx后在浏览器里直接访问某个非首页路径会返回404。原因很简单history路由模式下URL路径是前端虚拟的Nginx不知道应该返回index.html。解决方法是加一条try_files规则location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }但这条规则有个副作用静态资源如果找不到也会统一返回index.html如果某个JS文件确实不存在浏览器会报错。所以最好把静态资源单独配一个location比如location ^~ /assets/直接指向文件目录不经过try_files。8.5 定时任务与集群部署的冲突通讯录同步定时任务如果系统是多实例部署比如一台测试机一台生产机会出现两个实例同时执行同步的情况导致数据竞争。这里可以用Spring的分布式锁或者ShedLock引入一个锁机制确保同一时间只有一个实例执行定时任务。如果你们没有引入分布式组件最简单的方案是把定时任务开启的开关配置在配置文件里只有一台实例设为true其他设为false。实测下来这个方案在两台机器的小集群里完全够用。8.6 公告列表的分页性能深分页问题公告列表的数据量虽然不大但如果管理员发布了很多历史公告用户滚动到第几十页的时候MySQL的深分页大offset会导致查询明显变慢。当时我用的是LIMIT offset, size到第50页之后响应开始卡顿。解决方法是改成基于游标的分页用ID作为游标条件WHERE id lastId ORDER BY id DESC LIMIT 10。这个方案页面跳转没法直接用但公告列表的实际使用场景就是不断加载下一页根本不需要跳到指定页码所以用游标分页反而更合适。我把前端的分页组件从页码跳转改成了加载更多按钮体验反而更顺了。8.7 企业微信的应用消息推送频率限制公告发布后向员工推送应用消息我一开始是每发一条公告就遍历所有接收人给每个人都调用一次message/send接口。结果发布一条全员公告几百人就有几百次API调用直接被企业微信限流了报错提示超过每分钟最大调用次数。后来我改成用企业微信的批量发送消息能力把touser参数设置为all或传部门ID数组一次请求发给整个部门/全公司API调用次数从几百降到几次。如果需要按人发送也建议分批每批不超过100人且批次之间要sleep一段时间。9. 个人实践中的一些心得与优化方向到这里系统从架构、数据模型、核心模块到踩坑经验都梳理完了。最后我想聊几个我做这个项目过程中的个人体会。第一点感受是企业微信接入的项目第一版落地速度比预期要快很多。因为身份认证、组织架构这些最繁重的事情企业微信都帮你接好了你真正要做的是聚焦业务本身。但如果愿意花一些时间把企业微信API这层封装得足够好后续扩展会非常方便——这也是我坚持建wework这个独立包的原因。后来我们团队确实在这个包上面加了很多新东西比如给离职用户交接文档、自动给新员工推送入职知识包、通过应用消息做待办提醒等等都是基于同一套基础设施做的扩展。第二点体会是这种内部工具用户体验的价值大于技术上的完美。公告这个场景很多人一上来就想着做审核流、做定时发布、做复杂的权限体系。但真给公司内部用的时候第一版能安稳跑起来最重要。我当时先做了发布、已读统计、统一搜索这三件事上线之后大家用得频繁了自然就会有人提出能不能加个置顶、能不能按部门选择可见范围再去迭代很快。如果一开始就甲方附体式的把所有功能都堆出来反而容易做成四不像。再分享一个小技巧因为这种系统是内嵌在企业微信里的用户的账号体系、访问入口都很特殊很多问题在普通浏览器里复现不了。我建议在开发阶段申请一个测试企业微信的组织在真实企业微信环境下调试OAuth流程、JS-SDK调用、样式适配这些环境相关的问题在本地浏览器里是发现不了的。如果后续想继续迭代这个系统有两个方向我认为价值最大。一个是把知识库做成类似Wiki的协作模式支持多人在线编辑、版本历史、关注提醒。另一个是把公告数据和企业微信的审批数据打通比如在公司制度类公告发布前后配上审批流程从制度发布到员工确认一条线走完。当然这都属于功能层面的增量底层的企业微信对接基础设施完全不用动直接往业务层加代码就完事了。
返回列表