13 Commits
Author SHA1 Message Date
滕勇志 1d4b22dc1a chore(version): sync to 2.3
- SystemConstant.VERSION: v2.2 -> v2.3
- admin footer: Version 2.2 -> 2.3
- playedu-api artifact: 2.2 -> 2.3
- admin/pc/h5 package.json -> 2.3.0
2026-10-11 23:30:45 -07:00
滕勇志 3866a9c5dc Merge gitee/main into main for 2.3 2026-10-11 23:30:36 -07:00
滕勇志 c3422327ce docs(changelog): 精简 2.1 版本条目 2026-10-11 23:28:52 -07:00
滕勇志 920ddbaa34 docs(changelog): 整理 2.2、2.3 版本条目 2026-10-11 23:27:00 -07:00
滕勇志 e466bdc738 docs(changelog): 补充 b3c7092 安全修复条目 2026-10-11 23:24:54 -07:00
滕勇志 902ac9d05d docs: 新增 CLAUDE.md 开发规范 2026-10-11 23:23:45 -07:00
白书科技 b3c709214f !16 修复:修复头像上传注入漏洞
* 修复:登录IP限流可绕过及代码评审问题
* 修复:修复头像上传注入漏洞
* 修复:修复LDAP登录过滤器注入漏洞
2026-10-12 06:22:00 +00:00
吴宇寒 60df4f2b74 h5 苹果手机夸克浏览器环境下学习视频时提示’当前夸克浏览器环境下无法记录学习数据,请前往其他浏览器(如 Safari、Chrome)打开学习‘弹窗,点击确认返回上一页 2026-06-04 09:58:15 +08:00
吴宇寒 57e10f1b55 h5 苹果手机夸克浏览器环境下学习视频时提示’当前夸克浏览器环境下无法记录学习数据,请前往其他浏览器(如 Safari、Chrome)打开学习‘弹窗,点击确认返回上一页 2026-06-04 09:50:06 +08:00
吴宇寒 5c453d1cfc h5 苹果手机夸克浏览器环境下学习视频时提示’当前夸克浏览器环境下无法记录学习数据,请前往其他浏览器(如 Safari、Chrome)打开学习‘弹窗,点击确认返回上一页 2026-06-04 09:48:28 +08:00
吴宇寒 616f23aa30 pc、h5 课程详情报错优化 2026-06-03 16:19:31 +08:00
吴宇寒 e08a38399f h5 课程详情报错优化 2026-06-03 16:17:47 +08:00
吴宇寒 db2bf9a410 h5 播放页面ping接口传递参数hourId优化 2026-06-01 15:44:43 +08:00
19 changed files with 2406 additions and 2111 deletions

No files matched your search

+16 -7
View File
@@ -1,15 +1,24 @@
## 2.3
- 修复:LDAP 登录过滤器注入漏洞
- 修复:学员头像上传未校验文件类型
- 修复:更换账号可绕过登录限流
- 新增:[H5]iOS 夸克浏览器提示更换浏览器学习
- 优化:[PC/H5]课程详情报错处理
- 优化:[H5]播放页 ping 接口传递 hourId
## 2.2
- 修复:IP 地理位置解析改用 `ip2region` 本地离线库,移除对外部接口 `whois.pconline.com.cn` 的依赖,消除 "Unexpected end of file from server" 等因外部接口抖动导致的异常日志与出网调用
- 修复:IP 地理位置解析改用 `ip2region` 离线库
## 2.1
- 优化:Dockerfile 拆分依赖层,前端先 `COPY package.json/pnpm-lock.yaml` 再 `pnpm i`,Java 先 `COPY pom.xml` 并 `dependency:go-offline` 预热依赖,源码变更不再触发依赖重装,显著缩短增量构建耗时
- 优化:新增 `playedu-api/.mvn/settings.xml` 与 `.mvn/maven.config`,默认走阿里云 Maven 镜像并启用 `-T 1C` 并行构建,加速 `mvnw` 依赖下载与编译
- 修复:后台批量导入学员、章节/课时/附件排序接口缺少权限注解导致的越权问题
- 优化:后台配置项改为由 `application.yml` 直接读取环境变量,移除 Dockerfile 中的命令行参数映射
- 新增:数据库连接池可通过环境变量配置 (`DB_POOL_MIN_IDLE` / `DB_POOL_MAX_SIZE` / `DB_POOL_CONN_TIMEOUT` / `DB_POOL_IDLE_TIMEOUT` / `DB_POOL_MAX_LIFETIME`)
- 新增:`compose.yml` 中 MySQL 密码改为通过 `PLAYEDU_DB_PASS` 环境变量配置,与应用端 `DB_PASS` 共用同一变量
- 新增:数据库连接池支持环境变量配置
- 新增:`compose.yml` 的 MySQL 密码支持环境变量配置
- 修复:后台导入与排序接口越权问题
- 优化:Dockerfile 拆分依赖层以复用构建缓存
- 优化:Maven 默认阿里云镜像并启用并行构建
- 优化:配置项改由 `application.yml` 读取环境变量
## 2.0
+6
View File
@@ -0,0 +1,6 @@
# CLAUDE.md
## 开发规范
1. 禁止写代码注释。
2. changelog 条目单条内容长度不超过 10 个单词。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "playedu-admin-interface",
"private": false,
"version": "2.2.0",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -36,7 +36,7 @@ export const Footer: React.FC<PropInterface> = ({ type }) => {
className="ml-5"
style={{ color: "#D7D7D7", fontSize: 12, marginTop: -5 }}
>
Version 2.2
Version 2.3
</span>
</Link>
</Layout.Footer>
+1 -1
View File
@@ -10,7 +10,7 @@
</parent>
<artifactId>playedu-api</artifactId>
<version>2.2</version>
<version>2.3</version>
<dependencies>
<dependency>
@@ -30,20 +30,40 @@ public class LoginLimitCache {
@Autowired private PlayEduConfig playEduConfig;
public void check(String email) throws ServiceException {
String limitKey = cacheKey(email);
Long reqCount = rateLimiterService.current(limitKey, 600L);
if (reqCount >= 10 && !playEduConfig.getTesting()) {
Long exp = MemoryCacheUtil.ttlWithoutPrefix(limitKey);
String msg = String.format("您的账号已被锁定,请%s后重试", exp > 60 ? exp / 60 + "分钟" : exp + "秒");
throw new ServiceException(msg);
}
checkKey(cacheKey(email), 10L, "您的账号已被锁定");
}
// 按客户端IP计数,避免旋转username绕过账号维度的限流
public void checkIp(String ip) throws ServiceException {
checkKey(ipCacheKey(ip), 30L, "登录尝试过于频繁");
}
public void destroy(String email) {
MemoryCacheUtil.del(cacheKey(email));
}
// 登录成功仅退还本次计数,不清零,防止攻击者用自有账号登录重置整个IP的失败计数
public void releaseIp(String ip) {
String limitKey = ipCacheKey(ip);
if (MemoryCacheUtil.exists(limitKey)) {
MemoryCacheUtil.increment(limitKey, -1L, 600L);
}
}
private void checkKey(String limitKey, long limit, String reason) throws ServiceException {
Long reqCount = rateLimiterService.current(limitKey, 600L);
if (reqCount >= limit && !playEduConfig.getTesting()) {
Long exp = MemoryCacheUtil.ttlWithoutPrefix(limitKey);
String msg = String.format("%s,请%s后重试", reason, exp > 60 ? exp / 60 + "分钟" : exp + "秒");
throw new ServiceException(msg);
}
}
private String cacheKey(String email) {
return "login-limit:" + email;
}
private String ipCacheKey(String ip) {
return "login-limit-ip:" + ip;
}
}
@@ -69,6 +69,10 @@ public class LoginController {
return JsonResponse.error("请使用LDAP登录");
}
// IP维度限流需在查询用户之前执行,否则不存在的邮箱会绕过计数
String ip = IpUtil.getClientIp();
loginLimitCache.checkIp(ip);
String email = req.getEmail();
User user = userService.find(email);
@@ -87,6 +91,7 @@ public class LoginController {
}
loginLimitCache.destroy(email);
loginLimitCache.releaseIp(ip);
return JsonResponse.data(loginBus.tokenByUser(user));
}
@@ -107,6 +112,8 @@ public class LoginController {
}
// 限流控制
String ip = IpUtil.getClientIp();
loginLimitCache.checkIp(ip);
loginLimitCache.check(username);
// 锁控制-防止并发登录重复写入数据
@@ -125,6 +132,7 @@ public class LoginController {
// 删除限流控制
loginLimitCache.destroy(username);
loginLimitCache.releaseIp(ip);
return JsonResponse.data(data);
} catch (ServiceException e) {
@@ -15,6 +15,7 @@
*/
package xyz.playedu.api.controller.frontend;
import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;
import lombok.SneakyThrows;
@@ -25,6 +26,7 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import xyz.playedu.api.request.frontend.ChangePasswordRequest;
import xyz.playedu.common.constant.BackendConstant;
import xyz.playedu.common.constant.CommonConstant;
import xyz.playedu.common.constant.FrontendConstant;
import xyz.playedu.common.context.FCtx;
@@ -38,6 +40,7 @@ import xyz.playedu.common.types.JsonResponse;
import xyz.playedu.common.types.UploadFileInfo;
import xyz.playedu.common.types.config.S3Config;
import xyz.playedu.common.types.mapper.UserCourseHourRecordCourseCountMapper;
import xyz.playedu.common.util.HelperUtil;
import xyz.playedu.common.util.StringUtil;
import xyz.playedu.course.domain.*;
import xyz.playedu.course.service.*;
@@ -100,8 +103,33 @@ public class UserController {
return JsonResponse.data(data);
}
@SneakyThrows
@PutMapping("/avatar")
public JsonResponse changeAvatar(MultipartFile file) {
if (file == null || file.isEmpty() || StringUtil.isEmpty(file.getOriginalFilename())) {
throw new ServiceException("请上传文件");
}
// 扩展名
String ext = HelperUtil.fileExt(file.getOriginalFilename());
// 头像仅支持图片格式
if (!BackendConstant.RESOURCE_TYPE_IMAGE.equals(
BackendConstant.RESOURCE_EXT_2_TYPE.get(ext))) {
return JsonResponse.error("当前格式不支持");
}
// 头像大小限制
if (file.getSize() > FrontendConstant.AVATAR_MAX_SIZE) {
return JsonResponse.error("头像大小不能超过5MB");
}
// 校验文件头(魔数)与扩展名一致
byte[] header;
try (InputStream in = file.getInputStream()) {
header = in.readNBytes(8);
}
if (!HelperUtil.matchImageHeader(header, ext)) {
return JsonResponse.error("当前格式不支持");
}
// 校验存储配置是否完整
S3Config s3Config = appConfigService.getS3Config();
if (StringUtil.isEmpty(s3Config.getAccessKey())
@@ -40,4 +40,6 @@ public class FrontendConstant {
public static final String USER_UPLOAD_IMAGE_SCENE_AVATAR = "avatar";
public static final String DIR_AVATAR = "user/avatar/";
public static final int AVATAR_MAX_SIZE = 5 * 1024 * 1024;
}
@@ -17,7 +17,7 @@ package xyz.playedu.common.constant;
public class SystemConstant {
public static final String VERSION = "v2.2";
public static final String VERSION = "v2.3";
public static final String ENV_PROD = "prod";
@@ -185,4 +185,40 @@ public class HelperUtil {
String[] array = filename.split("\\.");
return array[array.length - 1].toLowerCase();
}
/**
* 校验文件头部(魔数)与图片扩展名是否匹配
*
* @param header 文件头部字节,建议至少8字节
* @param extension 文件扩展名,小写,不含点
* @return boolean
*/
public static boolean matchImageHeader(byte[] header, String extension) {
if (header == null || extension == null) {
return false;
}
switch (extension) {
case "png":
return startsWith(header, 0, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
case "jpg":
case "jpeg":
return startsWith(header, 0, 0xFF, 0xD8, 0xFF);
case "gif":
return startsWith(header, 0, 0x47, 0x49, 0x46, 0x38);
default:
return false;
}
}
private static boolean startsWith(byte[] data, int offset, int... prefix) {
if (data.length < offset + prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if ((data[offset + i] & 0xFF) != prefix[i]) {
return false;
}
}
return true;
}
}
@@ -87,6 +87,47 @@ public class IpUtil {
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : getMultistageReverseProxyIp(ip);
}
/**
* 获取可信的客户端IP,用于限流等安全场景
*
* <p>getIpAddress 直接信任客户端可伪造的请求头,不能用于安全判断。
*
* <p>这里以TCP连接的remoteAddr为起点,仅当其为内网/回环地址(受信代理)时才从X-Forwarded-For最右侧逐级回溯,
* 遇到第一个非受信地址即为客户端IP,客户端在X-Forwarded-For左侧伪造的值不会被采用。
*
* @return IP地址
*/
public static String getClientIp() {
HttpServletRequest request = RequestUtil.handler();
if (request == null) {
return "unknown";
}
String ip = request.getRemoteAddr();
String xff = request.getHeader("X-Forwarded-For");
if (StringUtil.isNotBlank(xff)) {
String[] hops = xff.split(",");
for (int i = hops.length - 1; i >= 0 && isTrustedProxy(ip); i--) {
String hop = hops[i].trim();
if (isUnknown(hop)) {
break;
}
ip = hop;
}
}
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
private static boolean isTrustedProxy(String ip) {
if (ip == null) {
return false;
}
if ("0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip)) {
return true;
}
byte[] address = textToNumericFormatV4(ip);
return address != null && (address[0] == 0x7F || internalIp(address));
}
/**
* 根据IP获取所在地址
*
@@ -236,12 +236,15 @@ public class LdapUtil {
controls.setReturningObjFlag(true);
controls.setCountLimit(1);
String userFilter = "";
// 使用JNDI参数化过滤器,由JNDI按RFC 4515转义用户输入,防止过滤器注入
String userFilter;
Object[] filterArgs;
if (StringUtil.isNotEmpty(mail)) {
userFilter =
String.format("(|(mail=%s)(email=%s)(userPrincipalName=%s))", mail, mail, mail);
} else if (StringUtil.isNotEmpty(uid)) {
userFilter = String.format("(|(uid=%s)(sAMAccountName=%s))", uid, uid);
userFilter = "(|(mail={0})(email={0})(userPrincipalName={0}))";
filterArgs = new Object[] {mail};
} else {
userFilter = "(|(uid={0})(sAMAccountName={0}))";
filterArgs = new Object[] {uid};
}
String filter = String.format("(&%s%s)", userFilter, USER_OBJECT_CLASS);
@@ -251,7 +254,7 @@ public class LdapUtil {
ldapConfig.getUrl(), ldapConfig.getAdminUser(), ldapConfig.getAdminPass());
NamingEnumeration<SearchResult> result = null;
try {
result = ldapContext.search(ldapConfig.getBaseDN(), filter, controls);
result = ldapContext.search(ldapConfig.getBaseDN(), filter, filterArgs, controls);
} catch (NamingException e) {
log.error("LDAP-通过mail或uid登录失败", e);
} finally {
@@ -326,12 +329,18 @@ public class LdapUtil {
if (attributes.get("uSNCreated") != null) {
// Window AD域
ldapUser.setId((String) attributes.get("uSNCreated").get());
ldapUser.setUid((String) attributes.get("sAMAccountName").get());
ldapUser.setId(getAttribute(attributes, "uSNCreated"));
ldapUser.setUid(getAttribute(attributes, "sAMAccountName"));
} else {
// OpenLDAP
ldapUser.setId((String) attributes.get("entryUUID").get());
ldapUser.setUid((String) attributes.get("uid").get());
ldapUser.setId(getAttribute(attributes, "entryUUID"));
ldapUser.setUid(getAttribute(attributes, "uid"));
}
// 关键属性缺失时按无效用户处理,避免异常信息暴露条目属性差异
if (StringUtil.isEmpty(ldapUser.getId()) || StringUtil.isEmpty(ldapUser.getUid())) {
log.info("LDAP-用户属性缺失|id={}|uid={}", ldapUser.getId(), ldapUser.getUid());
return null;
}
// ou计算
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "playedu-h5-interface",
"private": false,
"version": "2.2.0",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+2155 -2053
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -39,7 +39,7 @@ const CoursePage = () => {
const [chapters, setChapters] = useState<ChapterModel[]>([]);
const [hours, setHours] = useState<LocalCourseHour | null>(null);
const [learnRecord, setLearnRecord] = useState<UserLearnRecordModel | null>(
null
null,
);
const [learnHourRecord, setLearnHourRecord] =
useState<LocalUserLearnHourRecordModel>({});
@@ -61,7 +61,9 @@ const CoursePage = () => {
document.title = courseItem.title || "课程详情";
setCourse(courseItem);
setChapters(res.data.chapters);
setHours(res.data.hours);
if (!isEmptyObject(res.data.hours)) {
setHours(res.data.hours);
}
if (res.data.learn_record) {
setLearnRecord(res.data.learn_record);
}
+47 -17
View File
@@ -3,13 +3,35 @@ import styles from "./video.module.scss";
import { useParams, useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import { course as Course } from "../../api/index";
import { Toast, Image } from "antd-mobile";
import { Toast, Image, Dialog } from "antd-mobile";
import backIcon from "../../assets/images/commen/icon-back-n.png";
import { Empty } from "../../components";
import { HourCompenent } from "./compenents/videoHour";
declare const window: any;
/**
* 检测是否为 iOS 设备
*/
const isIOS = (): boolean => {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
};
/**
* 检测是否为夸克浏览器
*/
const isQuarkBrowser = (): boolean => {
const ua = navigator.userAgent.toLowerCase();
return ua.includes("quark") || ua.includes("qbrowse");
};
/**
* 检测是否为 iOS 夸克浏览器
*/
const isIOSQuarkBrowser = (): boolean => {
return isIOS() && isQuarkBrowser();
};
type LocalUserLearnHourRecordModel = {
[key: number]: UserLearnHourRecordModel;
};
@@ -24,7 +46,6 @@ const CoursePlayPage = () => {
const systemConfig = useSelector((state: any) => state.systemConfig.value);
const user = useSelector((state: any) => state.loginUser.value.user);
const [playUrl, setPlayUrl] = useState("");
const [playDuration, setPlayDuration] = useState(0);
const [playendedStatus, setPlayendedStatus] = useState(false);
const [lastSeeValue, setLastSeeValue] = useState({});
const [course, setCourse] = useState<CourseModel | null>(null);
@@ -42,16 +63,27 @@ const CoursePlayPage = () => {
const playRef = useRef(0);
const watchRef = useRef(0);
const totalRef = useRef(0);
const hourIdRef = useRef<number>(0);
useEffect(() => {
// 检测是否为 iOS 夸克浏览器
if (isIOSQuarkBrowser()) {
Dialog.alert({
content:
"当前夸克浏览器环境下无法记录学习数据,请前往其他浏览器(如 Safari、Chrome)打开学习",
confirmText: "知道了",
onConfirm: () => {
navigate(-1);
},
});
return;
}
myRef.current = 0;
hourIdRef.current = Number(params.hourId);
getCourse();
getDetail();
}, [params.courseId, params.hourId]);
useEffect(() => {
myRef.current = playDuration;
}, [playDuration]);
useEffect(() => {
playRef.current = playingTime;
}, [playingTime]);
@@ -87,7 +119,7 @@ const CoursePlayPage = () => {
}
//判断是否是最后的课时
const index = totalHours.findIndex(
(i: any) => i.id === Number(params.hourId)
(i: any) => i.id === Number(params.hourId),
);
if (index === totalHours.length - 1) {
setIsLastpage(true);
@@ -134,7 +166,7 @@ const CoursePlayPage = () => {
window.player && window.player.destroy();
setPlayUrl(res.data.resource_url[rid]);
initDPlayer(res.data.resource_url[rid], 0, data);
}
},
);
};
@@ -200,21 +232,19 @@ const CoursePlayPage = () => {
const playTimeUpdate = (duration: number, isEnd: boolean) => {
if (duration - myRef.current >= 10 || isEnd === true) {
setPlayDuration(duration);
Course.record(
Number(params.courseId),
Number(params.hourId),
duration
).then((res: any) => {});
Course.playPing(Number(params.courseId), Number(params.hourId)).then(
(res: any) => {}
myRef.current = duration;
Course.record(Number(params.courseId), hourIdRef.current, duration).then(
(res: any) => {},
);
Course.playPing(Number(params.courseId), hourIdRef.current).then(
(res: any) => {},
);
}
};
const goNextVideo = () => {
const index = totalHours.findIndex(
(i: any) => i.id === Number(params.hourId)
(i: any) => i.id === Number(params.hourId),
);
if (index === totalHours.length - 1) {
setIsLastpage(true);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "playedu-pc-interface",
"private": false,
"version": "2.2.0",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+11 -9
View File
@@ -52,10 +52,10 @@ const CoursePage = () => {
const [chapters, setChapters] = useState<ChapterModel[]>([]);
const [hours, setHours] = useState<HoursModel>({});
const [learnRecord, setLearnRecord] = useState<CourseRecordModel | null>(
null
null,
);
const [learnHourRecord, setLearnHourRecord] = useState<LearnHourRecordsModel>(
{}
{},
);
const [tabKey, setTabKey] = useState(Number(result.get("tab") || 1));
const [attachments, setAttachments] = useState<AttachModel[]>([]);
@@ -73,7 +73,9 @@ const CoursePage = () => {
document.title = res.data.course.title;
setCourse(res.data.course);
setChapters(res.data.chapters);
setHours(res.data.hours);
if (JSON.stringify(res.data.hours) !== "{}") {
setHours(res.data.hours);
}
setResourceUrl(res.data.resource_url);
if (res.data.learn_record) {
setLearnRecord(res.data.learn_record);
@@ -114,7 +116,7 @@ const CoursePage = () => {
id: number,
rid: number,
fileName: string,
type: string
type: string,
) => {
Course.downloadAttachment(cid, id).then((res: any) => {
if (type === "TXT") {
@@ -172,10 +174,10 @@ const CoursePage = () => {
course.thumb === -1
? defaultThumb1
: course.thumb === -2
? defaultThumb2
: course.thumb === -3
? defaultThumb3
: resourceUrl[course.thumb]
? defaultThumb2
: course.thumb === -3
? defaultThumb3
: resourceUrl[course.thumb]
}
/>
) : null}
@@ -374,7 +376,7 @@ const CoursePage = () => {
item.id,
item.rid,
`${item.title}.${item.ext}`,
item.type
item.type,
)
}
>