From 79ce184be342e77087d65791bc3ef9abb030f6eb Mon Sep 17 00:00:00 2001 From: zhangrui Date: Wed, 14 Oct 2026 09:16:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E5=91=98=E7=AE=A1=E7=90=86=E6=95=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/sys/controller/SysDictController.java | 7 +- .../app/sys/controller/SysHomeController.java | 49 +-- .../com/budwk/app/sys/models/Sys_dict.java | 10 + .../app/sys/services/SysDictService.java | 7 + .../app/sys/services/SysHomeNewsService.java | 24 ++ .../sys/services/impl/SysDictServiceImpl.java | 65 +++- .../services/impl/SysHomeNewsServiceImpl.java | 159 ++++++++++ .../ext/handler/WkTomcatConfiguration.java | 2 + .../resources/views/layouts/platform_h5.html | 5 +- .../views/platform/sys/dict/index.html | 202 +++++++----- .../views/platform/zhghh5/sys/home/home.js | 300 ++++++++++++++---- 11 files changed, 652 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java create mode 100644 src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java diff --git a/src/main/java/com/budwk/app/sys/controller/SysDictController.java b/src/main/java/com/budwk/app/sys/controller/SysDictController.java index 3bf4b2f6..50cad0bf 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysDictController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysDictController.java @@ -3,6 +3,7 @@ package com.budwk.app.sys.controller; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckPermission; import com.budwk.app.base.annotation.SLog; +import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_dict; import com.budwk.app.sys.services.SysDictService; @@ -119,6 +120,8 @@ public class SysDictController { sysDictService.save(dict, parentId); sysDictService.clearCache(); return Result.success("system.success"); + } catch (BaseException e) { + return Result.error(e.getMessage()); } catch (Exception e) { return Result.error("system.error"); } @@ -142,9 +145,11 @@ public class SysDictController { public Object editDo(@Param("..") Sys_dict dict, @Param("parentId") String parentId, HttpServletRequest req) { try { dict.setUpdatedBy(SecurityUtil.getUserId()); - sysDictService.updateIgnoreNull(dict); + sysDictService.updateDict(dict); sysDictService.clearCache(); return Result.success(); + } catch (BaseException e) { + return Result.error(e.getMessage()); } catch (Exception e) { return Result.error(); } diff --git a/src/main/java/com/budwk/app/sys/controller/SysHomeController.java b/src/main/java/com/budwk/app/sys/controller/SysHomeController.java index 13258155..9395da60 100644 --- a/src/main/java/com/budwk/app/sys/controller/SysHomeController.java +++ b/src/main/java/com/budwk/app/sys/controller/SysHomeController.java @@ -11,26 +11,21 @@ import com.budwk.app.base.result.Result; import com.budwk.app.sys.models.Sys_home_activity; import com.budwk.app.sys.models.Sys_home_template; import com.budwk.app.sys.models.Sys_menu; +import com.budwk.app.sys.services.SysHomeNewsService; import com.budwk.app.sys.services.SysMenuService; import com.budwk.app.sys.services.SysUserService; import com.budwk.app.web.commons.auth.utils.AuthUtil; import com.budwk.app.web.commons.auth.utils.SecurityUtil; import com.budwk.app.zhgh.activity.basic.service.ActivityBasicScopeService; import io.swagger.annotations.ApiOperation; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.FieldFilter; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; import org.nutz.dao.util.Daos; -import org.nutz.integration.jedis.RedisService; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; import org.nutz.lang.Lang; import org.nutz.lang.Strings; import org.nutz.lang.util.NutMap; @@ -42,7 +37,6 @@ import org.nutz.mvc.annotation.Param; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; -import java.io.IOException; import java.util.*; import java.util.stream.Collectors; @@ -58,9 +52,9 @@ public class SysHomeController { @Inject private SysUserService sysUserService; @Inject - private RedisService redisService; - @Inject private ActivityBasicScopeService activityBasicScopeService; + @Inject + private SysHomeNewsService sysHomeNewsService; @At("") @Ok("re") @@ -293,34 +287,17 @@ public class SysHomeController { @SaCheckLogin @ApiOperation("获取新闻") @Ok("json") -// @CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "web_news", isHash = true) -// @CacheResult(cacheKey = "news", ignoreNull = true, cacheLiveTime = 60 * 60 * 24) public Result getNews() { - try { - String url = "https://xgh.cug.edu.cn/"; - Document document = Jsoup.connect(url).get(); - Elements homeElements = document.select(".gh,.jc,.fc,.jg"); + return Result.success(sysHomeNewsService.getWebsiteNews()); + } - List> news = homeElements.stream().map(homeElement -> { - //获取板块名称 - String name = homeElement.selectFirst(".title").text(); - Elements liElements = homeElement.select("li"); - List ele = liElements.stream().map(liElement -> { - NutMap row = NutMap.NEW(); - String href = liElement.selectFirst("a").attr("href"); - String time = liElement.selectFirst("span").text(); - String title = liElement.selectFirst("a").attr("title"); - row.put("href", href); - row.put("time", time); - row.put("text", title); - return row; - }).toList(); - return Map.of("label", name, "value", ele); - }).collect(Collectors.toList()); - redisService.setex("gonghui_news", 60 * 60 * 24, Json.toJson(news)); - return Result.success(news); - } catch (IOException e) { - throw new RuntimeException(e); - } + /** + * href 传首页新闻项原始链接;成功结果 data 为图片地址字符串,空字符串表示没有可用图片。 + */ + @At + @SaCheckLogin + @Ok("json") + public Result getNewsImage(@Param("href") String href) { + return Result.success(sysHomeNewsService.getNewsImage(href)); } } diff --git a/src/main/java/com/budwk/app/sys/models/Sys_dict.java b/src/main/java/com/budwk/app/sys/models/Sys_dict.java index e3cdbd3a..018b0f5d 100644 --- a/src/main/java/com/budwk/app/sys/models/Sys_dict.java +++ b/src/main/java/com/budwk/app/sys/models/Sys_dict.java @@ -52,6 +52,16 @@ public class Sys_dict extends BaseModel implements Serializable { @ColDefine(type = ColType.VARCHAR, width = 100) private String code; + @Column + @Comment("关联人员类型编码") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String personType; + + @Column + @Comment("人员属性业务编码") + @ColDefine(type = ColType.VARCHAR, width = 100) + private String userAttributeCode; + @Column @Comment("启用状态") @ColDefine(type = ColType.BOOLEAN) diff --git a/src/main/java/com/budwk/app/sys/services/SysDictService.java b/src/main/java/com/budwk/app/sys/services/SysDictService.java index 20177f33..5043c54d 100644 --- a/src/main/java/com/budwk/app/sys/services/SysDictService.java +++ b/src/main/java/com/budwk/app/sys/services/SysDictService.java @@ -79,6 +79,13 @@ public interface SysDictService extends BaseService { */ void save(Sys_dict dict, String pid); + /** + * 更新数据字典,并对人员属性直属子项的人员类型和业务编码进行校验。 + * + * @param dict 待更新字典;id 为必填项,personType 与 userAttributeCode 可为空 + */ + void updateDict(Sys_dict dict); + /** * 保存数据字典 * diff --git a/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java b/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java new file mode 100644 index 00000000..a0b3dc61 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/SysHomeNewsService.java @@ -0,0 +1,24 @@ +package com.budwk.app.sys.services; + +import org.nutz.lang.util.NutMap; + +import java.util.List; + +/** + * 首页工会网站新闻服务。 + */ +public interface SysHomeNewsService { + + /** + * 获取学校工会网站首页新闻。 + * + * @return 新闻栏目集合;每个栏目包含 label 栏目名称和 value 新闻列表, + * 新闻项包含 href 跳转地址、time 发布时间、text 新闻标题和 thumbUrl 缩略图绝对地址(无图时为空) + */ + List getWebsiteNews(); + + /** + * href 必须是工会首页返回的新闻链接;返回正文首图的绝对地址,无图或不可访问时为空字符串。 + */ + String getNewsImage(String href); +} diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java index d57b9556..141ae72d 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysDictServiceImpl.java @@ -27,6 +27,9 @@ import java.util.Map; @IocBean(args = {"refer:dao"}) @CacheDefaults(cacheName = RedisConstant.PLATFORM_REDIS_WKCACHE_PREFIX + "sys_dict", isHash = true) public class SysDictServiceImpl extends BaseServiceImpl implements SysDictService { + private static final String USER_ATTRIBUTE_DICT_CODE = "USER_ATTRIBUTE"; + private static final String USER_PERSON_TYPE_DICT_CODE = "USER_PERSON_TYPE"; + public SysDictServiceImpl(Dao dao) { super(dao); } @@ -132,18 +135,76 @@ public class SysDictServiceImpl extends BaseServiceImpl implements Sys @Aop(TransAop.READ_COMMITTED) public void save(Sys_dict dict, String pid) { String path = ""; + Sys_dict parentDict = null; if (!Strings.isEmpty(pid)) { - Sys_dict pp = this.fetch(pid); - path = pp.getPath(); + parentDict = this.fetch(pid); + path = parentDict.getPath(); } dict.setPath(getSubPath("sys_dict", "path", path)); dict.setParentId(pid); + validateUserAttribute(dict, parentDict); dao().insert(dict); if (!Strings.isEmpty(pid)) { this.update(Chain.make("hasChildren", true), Cnd.where("id", "=", pid)); } } + /** + * 更新字典记录。人员属性关联字段允许为空;填写后校验人员类型有效性和业务编码唯一性。 + * + * @param dict 待更新字典,id 用于定位原记录并在唯一性校验中排除自身 + */ + @Override + @Aop(TransAop.READ_COMMITTED) + public void updateDict(Sys_dict dict) { + Sys_dict current = this.fetch(dict.getId()); + if (current == null) { + throw new BaseException("字典数据不存在"); + } + Sys_dict parentDict = Strings.isBlank(current.getParentId()) ? null : this.fetch(current.getParentId()); + dict.setParentId(current.getParentId()); + validateUserAttribute(dict, parentDict); + this.updateIgnoreNull(dict); + } + + /** + * 校验 USER_ATTRIBUTE 直属子项的扩展关系。 + * personType 为空时允许保存;有值时必须属于 USER_PERSON_TYPE 的启用子项。 + * userAttributeCode 为空时允许保存;有值时在 USER_ATTRIBUTE 直属子项中必须唯一。 + */ + private void validateUserAttribute(Sys_dict dict, Sys_dict parentDict) { + if (parentDict == null || !USER_ATTRIBUTE_DICT_CODE.equals(parentDict.getCode())) { + return; + } + String personType = Strings.sNull(dict.getPersonType()).trim(); + String userAttributeCode = Strings.sNull(dict.getUserAttributeCode()).trim(); + dict.setPersonType(personType); + dict.setUserAttributeCode(userAttributeCode); + + if (!Strings.isBlank(personType)) { + Sys_dict personTypeRoot = this.fetch(Cnd.where("code", "=", USER_PERSON_TYPE_DICT_CODE)); + boolean personTypeValid = personTypeRoot != null && this.count( + Cnd.where("parentId", "=", personTypeRoot.getId()) + .and("code", "=", personType) + .and("disabled", "=", false) + ) > 0; + if (!personTypeValid) { + throw new BaseException("人员类型不存在或已禁用"); + } + } + + if (!Strings.isBlank(userAttributeCode)) { + Cnd codeCondition = Cnd.where("parentId", "=", parentDict.getId()) + .and("userAttributeCode", "=", userAttributeCode); + if (!Strings.isBlank(dict.getId())) { + codeCondition.and("id", "!=", dict.getId()); + } + if (this.count(codeCondition) > 0) { + throw new BaseException("人员属性编码已存在"); + } + } + } + @Aop(TransAop.READ_COMMITTED) public void saveByParentCode(Sys_dict dict, String parentCode) { Sys_dict parentDict = fetch(Cnd.where("code", "=", parentCode)); diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java new file mode 100644 index 00000000..54078180 --- /dev/null +++ b/src/main/java/com/budwk/app/sys/services/impl/SysHomeNewsServiceImpl.java @@ -0,0 +1,159 @@ +package com.budwk.app.sys.services.impl; + +import cn.hutool.core.util.StrUtil; +import com.budwk.app.sys.services.SysHomeNewsService; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.Map; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 首页工会网站新闻服务实现,统一为PC端和移动端提供同一份新闻数据。 + */ +@IocBean +public class SysHomeNewsServiceImpl implements SysHomeNewsService { + + private static final String WEBSITE_URL = "https://xgh.cug.edu.cn/"; + private static final String NEWS_CACHE_KEY = "gonghui_news"; + private static final int NEWS_CACHE_SECONDS = 60 * 60 * 24; + + @Inject + private RedisService redisService; + + /** + * 抓取学校工会网站首页的工会要闻、基层动态等栏目。 + * 外部网站暂时不可用时优先返回最近一次缓存,避免PC端和移动端同时显示为空。 + * + * @return 新闻栏目集合;栏目 value 中的新闻项包含 href、time、text + */ + @Override + public List getWebsiteNews() { + try { + Document document = Jsoup.connect(WEBSITE_URL).timeout(10000).get(); + List news = parseWebsiteNews(document.select(".gh,.jc,.fc,.jg")); + if (!news.isEmpty()) { + redisService.setex(NEWS_CACHE_KEY, NEWS_CACHE_SECONDS, Json.toJson(news)); + return news; + } + return getCachedNews(); + } catch (IOException e) { + List cachedNews = getCachedNews(); + if (!cachedNews.isEmpty()) { + return cachedNews; + } + throw new RuntimeException("学校工会网站新闻获取失败", e); + } + } + + /** + * 将外部网站栏目节点转换为前端统一使用的栏目和新闻结构。 + */ + private List parseWebsiteNews(Elements homeElements) { + List sections = new ArrayList<>(); + for (Element homeElement : homeElements) { + Element titleElement = homeElement.selectFirst(".title"); + if (titleElement == null || StrUtil.isBlank(titleElement.text())) { + continue; + } + List rows = new ArrayList<>(); + for (Element itemElement : homeElement.select("li")) { + Element linkElement = itemElement.selectFirst("a"); + if (linkElement == null || StrUtil.isBlank(linkElement.attr("href"))) { + continue; + } + Element timeElement = itemElement.selectFirst("span"); + // 仅使用当前新闻条目的缩略图,按网页基址补全相对路径。 + Element imageElement = itemElement.selectFirst("img"); + String thumbUrl = imageElement == null ? "" : imageElement.absUrl("src"); + if (!thumbUrl.startsWith("https://") && !thumbUrl.startsWith("http://")) { + thumbUrl = ""; + } + String title = StrUtil.blankToDefault(linkElement.attr("title"), linkElement.text()); + rows.add(NutMap.NEW() + .addv("href", linkElement.attr("href")) + .addv("time", timeElement == null ? "" : timeElement.text()) + .addv("text", title) + .addv("thumbUrl", thumbUrl)); + } + sections.add(NutMap.NEW().addv("label", titleElement.text()).addv("value", rows)); + } + return sections; + } + + /** + * 读取最近一次成功抓取的数据;缓存为空或内容异常时返回空集合。 + */ + private List getCachedNews() { + String cached = redisService.get(NEWS_CACHE_KEY); + if (StrUtil.isBlank(cached)) { + return Collections.emptyList(); + } + try { + return Json.fromJsonAsList(NutMap.class, cached); + } catch (RuntimeException e) { + return Collections.emptyList(); + } + } + + /** + * 仅补取缓存新闻列表中已存在的公开文章,禁止任意地址抓取和自动重定向。 + * 正文首图独立缓存;无图短暂缓存,防止每次栏目切换重复访问外站。 + */ + @Override + public String getNewsImage(String href) { + if (StrUtil.isBlank(href) || href.length() > 2048) return ""; + boolean listed = false; + for (NutMap section : getCachedNews()) { + Object values = section.get("value"); + if (!(values instanceof List)) continue; + for (Object value : (List) values) { + if (value instanceof Map && href.equals(((Map) value).get("href"))) { + listed = true; + break; + } + } + } + if (!listed) return ""; + try { + URI uri = URI.create(WEBSITE_URL).resolve(href); + String host = uri.getHost(); + // 来源限制为学校公开网站和微信公众号,端口及凭据均不接受自定义。 + if (host == null || !(host.equals("cug.edu.cn") || host.endsWith(".cug.edu.cn") + || host.equals("mp.weixin.qq.com")) || uri.getUserInfo() != null + || uri.getPort() != -1 || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()))) return ""; + String cacheKey = "gonghui_news_image:" + UUID.nameUUIDFromBytes(uri.toString().getBytes(StandardCharsets.UTF_8)); + String cached = redisService.get(cacheKey); + if (cached != null) return "-".equals(cached) ? "" : cached; + String image = ""; + try { + Document article = Jsoup.connect(uri.toString()).timeout(5000).maxBodySize(2 * 1024 * 1024) + .followRedirects(false).get(); + Element first = article.selectFirst(".v_news_content img, #js_content img"); + if (first != null) { + image = first.hasAttr("data-src") ? first.absUrl("data-src") : first.absUrl("src"); + } + if (!image.startsWith("https://") && !image.startsWith("http://")) image = ""; + } catch (IOException ignored) { + // 外站失败不影响首页新闻文字和栏目切换,五分钟后允许再次补取。 + } + redisService.setex(cacheKey, image.isEmpty() ? 300 : NEWS_CACHE_SECONDS, image.isEmpty() ? "-" : image); + return image; + } catch (IllegalArgumentException e) { + return ""; + } + } +} diff --git a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java index 67fed5c6..653b76e5 100644 --- a/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java +++ b/src/main/java/com/budwk/app/web/commons/ext/handler/WkTomcatConfiguration.java @@ -51,6 +51,8 @@ public class WkTomcatConfiguration implements NbAppEventListener { // NutzBoot 使用编程式 Context,必须显式运行官方 WebSocket 初始化器。 StandardContext context = (StandardContext) tomcat.getHost().findChild(starter.getContextPath()); + // 为静态 SVG 补齐响应类型,避免浏览器收到文件后仍无法按图片解码。 + context.addMimeMapping("svg", "image/svg+xml"); context.setUseHttpOnly(conf.getBoolean("tomcat.session.cookie.httponly", true)); context.addServletContainerInitializer(new WsSci(), Set.of(WkWebSocket.class)); // Tomcat 的错误页再次 forward 时仍保持 ERROR 派发;让 MVC 渲染原业务错误页。 diff --git a/src/main/resources/views/layouts/platform_h5.html b/src/main/resources/views/layouts/platform_h5.html index c77bb843..f9c25543 100644 --- a/src/main/resources/views/layouts/platform_h5.html +++ b/src/main/resources/views/layouts/platform_h5.html @@ -323,7 +323,8 @@ let h5GlobalLoading = null let h5PendingRequestCount = 0 - function showH5GlobalLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg") { + // 更新资源版本,避免继续命中缺少 SVG 响应类型的旧缓存。 + function showH5GlobalLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg?v=20261014") { if (!h5GlobalLoading) { h5GlobalLoading = vant.Toast.loading({ icon: icon, @@ -375,7 +376,7 @@ }) } - function createListLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg") { + function createListLoading(icon = "/assets/mobile/img/common/smart-union-loading-red.svg?v=20261014") { showH5GlobalLoading(icon) const closeLoading = function() { diff --git a/src/main/resources/views/platform/sys/dict/index.html b/src/main/resources/views/platform/sys/dict/index.html index b0969b91..7d16480b 100644 --- a/src/main/resources/views/platform/sys/dict/index.html +++ b/src/main/resources/views/platform/sys/dict/index.html @@ -35,6 +35,14 @@ layout("/layouts/platform.html"){ + + + + + +