package com.talentcard.server.business.agreement; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.safety.Safelist; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class AgreementPortalService { private final JdbcTemplate jdbc; public AgreementPortalService(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Transactional(readOnly = true) public Map detail(String type) { if (!List.of("user", "privacy").contains(type)) throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的协议类型"); List> rows = DatabaseRows.normalize(jdbc.queryForList(""" select agreement_type, title, version, effective_date, valid_until, content_html, status from app_agreement where agreement_type = ? limit 1 """, type)); if (rows.isEmpty()) return unavailable(type, "协议暂未发布"); Map row = rows.get(0); Map result = new LinkedHashMap<>(); result.put("type", row.get("agreementType")); result.put("title", row.get("title")); result.put("version", row.get("version")); result.put("effectiveDate", row.get("effectiveDate")); if (((Number) row.getOrDefault("status", 0)).intValue() != 1) { result.put("state", "UNAVAILABLE"); result.put("message", "协议暂未发布"); result.put("contentHtml", ""); return result; } Object validUntil = row.get("validUntil"); if (validUntil instanceof java.time.LocalDate date && date.isBefore(java.time.LocalDate.now())) { result.put("state", "EXPIRED"); result.put("message", "当前协议版本已失效,请等待新版本发布"); result.put("contentHtml", ""); return result; } result.put("state", "ACTIVE"); result.put("message", ""); result.put("contentHtml", sanitizeHtml(String.valueOf(row.get("contentHtml")))); return result; } private Map unavailable(String type, String message) { Map result = new LinkedHashMap<>(); result.put("type", type); result.put("title", "user".equals(type) ? "用户协议" : "隐私与权限"); result.put("version", ""); result.put("effectiveDate", ""); result.put("state", "UNAVAILABLE"); result.put("message", message); result.put("contentHtml", ""); return result; } String sanitizeHtml(String html) { // 图片 src 不走 Jsoup 的协议白名单:/uploads/xxx.png 这种相对地址会因为"不是 http/https" // 被整条删掉,人才端就拿不到图片(后台协议里传的图显示不出来)。这里手动挡掉危险协议。 Safelist safelist = new Safelist() .addTags("a", "abbr", "b", "blockquote", "br", "caption", "cite", "code", "col", "colgroup", "dd", "del", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "li", "ol", "p", "pre", "q", "small", "span", "strong", "sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", "video", "source") .addAttributes(":all", "style", "class") .addAttributes("a", "href", "title") .addAttributes("img", "src", "alt", "title", "width", "height") .addAttributes("video", "src", "controls", "poster", "width", "height") .addAttributes("source", "src", "type") .addProtocols("a", "href", "http", "https"); Document source = Jsoup.parseBodyFragment(html == null ? "" : html); source.outputSettings().prettyPrint(false); // 小程序 rich-text 不认 //,换成它支持的 /,后台设的样式才能生效。 source.select("s, strike").forEach(node -> node.tagName("del")); source.select("u").forEach(node -> node.tagName("ins")); source.select("img[src]").forEach(image -> { String src = image.attr("src").trim().toLowerCase(Locale.ROOT); if (src.startsWith("javascript:") || src.startsWith("vbscript:")) image.removeAttr("src"); }); Document.OutputSettings outputSettings = new Document.OutputSettings().prettyPrint(false); Document document = Jsoup.parseBodyFragment(Jsoup.clean(source.body().html(), "", safelist, outputSettings)); document.outputSettings().prettyPrint(false); document.select("img").forEach(image -> { image.attr("style", appendResponsiveStyle(image.attr("style"), "max-width:100%;height:auto;")); }); document.select("video").forEach(video -> video.attr("style", appendResponsiveStyle(video.attr("style"), "max-width:100%;height:auto;"))); return document.body().html(); } private String appendResponsiveStyle(String style, String responsiveStyle) { if (style == null || style.isBlank()) return responsiveStyle; return style.endsWith(";") ? style + responsiveStyle : style + ";" + responsiveStyle; } }