Преглед изворни кода

fix: normalize face images and harden department creation

Sheep пре 2 недеља
родитељ
комит
50dc11b810

+ 3 - 3
national-motion-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java

@@ -341,8 +341,8 @@ public class OssBootUtil {
             filePath = "https://" + bucketName + "." + endPoint + SymbolConstant.SINGLE_SLASH + fileUrl;
         }
         PutObjectResult result = ossClient.putObject(bucketName, fileUrl.toString(),stream);
-        // 设置权限(公开读)
-        ossClient.setBucketAcl(bucketName, CannedAccessControlList.PublicRead);
+        // Bucket ACL is configured administratively; changing it per object upload
+        // requires an unnecessary PutBucketAcl permission and may fail after putObject.
         if (result != null) {
             log.info("------OSS文件上传成功------" + fileUrl);
         }
@@ -372,4 +372,4 @@ public class OssBootUtil {
         log.info("------replacePrefix---替换后---objectName:{}",objectName);
         return objectName;
     }
-}
+}

+ 21 - 19
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/app/controller/my/AppFamilyMembersController.java

@@ -5,9 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.extern.java.Log;
 import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.compress.utils.IOUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.shiro.SecurityUtils;
 import org.jeecg.common.api.vo.Result;
@@ -16,6 +14,7 @@ import org.jeecg.common.system.vo.LoginUser;
 import org.jeecg.modules.app.dto.AddFamilyMembersDTO;
 import org.jeecg.modules.app.dto.FindFamilyMembersDTO;
 import org.jeecg.modules.app.form.RealNameAuthenticationForm;
+import org.jeecg.modules.app.service.FaceImageNormalizationService;
 import org.jeecg.modules.system.app.entity.FamilyMembers;
 import org.jeecg.modules.system.app.service.IFamilyMembersService;
 import org.jeecg.modules.system.entity.SysUser;
@@ -25,9 +24,6 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
 
 /**
  * 家庭成员
@@ -44,6 +40,9 @@ public class AppFamilyMembersController {
     @Autowired
     ISysUserService iSysUserService;
 
+    @Autowired
+    FaceImageNormalizationService faceImageNormalizationService;
+
 
     @GetMapping("/findFamilyMembers")
     @Operation(summary = "分页查询家庭成员")
@@ -72,18 +71,13 @@ public class AppFamilyMembersController {
         if (addFamilyMembersDTO.getRealNameStatus()!=1){
             return Result.error("该添加人员未实名,无法进行添加");
         }
-        // 校验实名认证图片
+        // 海康终端对像素、色彩空间和JPEG编码有严格限制,入库前统一标准化。
         if (StringUtils.isNotEmpty(addFamilyMembersDTO.getRealNameImg())) {
             try {
-                byte[] imageBytes = downloadImage(addFamilyMembersDTO.getRealNameImg());
-                if (imageBytes.length < 60 * 1024) {
-                    return Result.error("图片太小,请重新上传大于60KB的图片");
-                } else if (imageBytes.length > 200 * 1024) {
-                    return Result.error("图片太大,请重新上传小于200KB的图片");
-                }
+                addFamilyMembersDTO.setRealNameImg(faceImageNormalizationService.normalizeAndUpload(addFamilyMembersDTO.getRealNameImg()));
             } catch (Exception e) {
                 log.error("处理实名认证图片失败", e);
-                return Result.error("实名认证图片处理失败");
+                return Result.error("实名认证图片处理失败,请上传清晰的正面照片");
             }
         }
         LoginUser principal = (LoginUser) SecurityUtils.getSubject().getPrincipal();
@@ -124,12 +118,6 @@ public class AppFamilyMembersController {
             return iFamilyMembersService.addFamilyMembers(addFamilyMembersDTO);
         }
     }
-    private byte[] downloadImage(String imageUrl) throws IOException {
-        URL url = new URL(imageUrl);
-        try (InputStream in = url.openStream()) {
-            return IOUtils.toByteArray(in);
-        }
-    }
     @GetMapping("/FindByIdFamilyMembers")
     @Operation(summary = "根据id查询家庭成员")
     public Result<FamilyMembers> addFamilyMembers(@RequestParam("id") String id){
@@ -139,6 +127,20 @@ public class AppFamilyMembersController {
     @PostMapping("/editFamilyMembers")
     @Operation(summary = "修改家庭成员")
     public Result<String> editFamilyMembers(@Validated @RequestBody AddFamilyMembersDTO addFamilyMembersDTO){
+        if (StringUtils.isNotBlank(addFamilyMembersDTO.getRealNameImg())) {
+            FamilyMembers existing = iFamilyMembersService.getById(addFamilyMembersDTO.getId());
+            if (existing == null) {
+                return Result.error("修改家庭成员失败,未查询到家庭成员");
+            }
+            if (!StringUtils.equals(existing.getRealNameImg(), addFamilyMembersDTO.getRealNameImg())) {
+                try {
+                    addFamilyMembersDTO.setRealNameImg(faceImageNormalizationService.normalizeAndUpload(addFamilyMembersDTO.getRealNameImg()));
+                } catch (Exception e) {
+                    log.error("处理实名认证图片失败", e);
+                    return Result.error("实名认证图片处理失败,请上传清晰的正面照片");
+                }
+            }
+        }
         return iFamilyMembersService.editFamilyMembers(addFamilyMembersDTO);
     }
 

+ 119 - 0
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/app/service/FaceImageNormalizationService.java

@@ -0,0 +1,119 @@
+package org.jeecg.modules.app.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.jeecg.common.exception.JeecgBootException;
+import org.jeecg.common.util.CommonUtils;
+import org.jeecg.modules.app.util.FaceImageNormalizer;
+import org.jeecg.modules.system.app.entity.FamilyMembers;
+import org.jeecg.modules.system.app.mapper.FamilyMembersMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Date;
+
+/**
+ * Normalizes face photos once and stores the compatible URL for subsequent
+ * Hikvision device enrolments.
+ */
+@Slf4j
+@Service
+public class FaceImageNormalizationService {
+
+    private static final String FACE_IMAGE_BIZ_PATH = "opt/upFiles/face";
+    private static final String NORMALIZED_PATH_MARKER = "opt/upFiles/face/";
+    private static final int CONNECT_TIMEOUT_MILLIS = 10_000;
+    private static final int READ_TIMEOUT_MILLIS = 15_000;
+
+    @Value("${jeecg.path.upload}")
+    private String uploadPath;
+
+    @Value("${jeecg.uploadType}")
+    private String uploadType;
+
+    @Resource
+    private FamilyMembersMapper familyMembersMapper;
+
+    public String normalizeAndUpload(String imageUrl) throws IOException {
+        byte[] source = downloadImage(imageUrl);
+        byte[] normalized = FaceImageNormalizer.normalize(source);
+        String normalizedUrl = CommonUtils.uploadOnlineImage(
+                normalized, uploadPath, FACE_IMAGE_BIZ_PATH, uploadType);
+        if (StringUtils.isBlank(normalizedUrl)) {
+            throw new IOException("标准化人脸图片上传失败");
+        }
+        return normalizedUrl;
+    }
+
+    public String ensureNormalized(FamilyMembers familyMembers) {
+        if (familyMembers == null || StringUtils.isBlank(familyMembers.getRealNameImg())) {
+            throw new JeecgBootException("未找到可用的人脸图片,请重新上传");
+        }
+        String currentUrl = familyMembers.getRealNameImg();
+        if (isNormalizedUrl(currentUrl)) {
+            return currentUrl;
+        }
+
+        try {
+            String normalizedUrl = normalizeAndUpload(currentUrl);
+            int updated = familyMembersMapper.update(null, Wrappers.<FamilyMembers>lambdaUpdate()
+                    .eq(FamilyMembers::getId, familyMembers.getId())
+                    .eq(FamilyMembers::getRealNameImg, currentUrl)
+                    .set(FamilyMembers::getRealNameImg, normalizedUrl)
+                    .set(FamilyMembers::getUpdateTime, new Date()));
+            if (updated == 0) {
+                FamilyMembers latest = familyMembersMapper.selectById(familyMembers.getId());
+                if (latest != null && isNormalizedUrl(latest.getRealNameImg())) {
+                    familyMembers.setRealNameImg(latest.getRealNameImg());
+                    return latest.getRealNameImg();
+                }
+                log.warn("人脸图片已标准化但数据库URL未更新,familyMemberId={}", familyMembers.getId());
+            }
+            familyMembers.setRealNameImg(normalizedUrl);
+            return normalizedUrl;
+        } catch (Exception e) {
+            log.error("历史人脸图片标准化失败,familyMemberId={}", familyMembers.getId(), e);
+            throw new JeecgBootException("人脸图片处理失败,请重新上传清晰的正面照片");
+        }
+    }
+
+    private boolean isNormalizedUrl(String imageUrl) {
+        return StringUtils.replaceChars(imageUrl, '\\', '/').contains(NORMALIZED_PATH_MARKER);
+    }
+
+    private byte[] downloadImage(String imageUrl) throws IOException {
+        URL url = new URL(imageUrl);
+        if (!"http".equalsIgnoreCase(url.getProtocol()) && !"https".equalsIgnoreCase(url.getProtocol())) {
+            throw new IOException("仅支持HTTP或HTTPS图片地址");
+        }
+        URLConnection connection = url.openConnection();
+        connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
+        connection.setReadTimeout(READ_TIMEOUT_MILLIS);
+        long contentLength = connection.getContentLengthLong();
+        if (contentLength > FaceImageNormalizer.MAX_SOURCE_BYTES) {
+            throw new IOException("人脸图片不能超过2MB");
+        }
+
+        try (InputStream input = connection.getInputStream();
+             ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+            byte[] buffer = new byte[8192];
+            int total = 0;
+            int read;
+            while ((read = input.read(buffer)) != -1) {
+                total += read;
+                if (total > FaceImageNormalizer.MAX_SOURCE_BYTES) {
+                    throw new IOException("人脸图片不能超过2MB");
+                }
+                output.write(buffer, 0, read);
+            }
+            return output.toByteArray();
+        }
+    }
+}

+ 8 - 3
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/app/service/impl/OrderServiceImpl.java

@@ -30,6 +30,7 @@ import org.jeecg.common.util.DateUtils;
 import org.jeecg.common.util.DictAnnotationUtil;
 import org.jeecg.modules.app.dto.AppOrderInfoDTO;
 import org.jeecg.modules.app.form.*;
+import org.jeecg.modules.app.service.FaceImageNormalizationService;
 import org.jeecg.modules.app.service.IOrderService;
 import org.jeecg.modules.app.service.WeChatPayService;
 import org.jeecg.modules.app.vo.AppGameScheduleVO;
@@ -97,6 +98,8 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
     @Resource
     private FamilyMembersMapper familyMembersMapper;
     @Resource
+    private FaceImageNormalizationService faceImageNormalizationService;
+    @Resource
     private AppTeachingTimeMapper appTeachingTimeMapper;
     @Resource
     private InsureOrderInfoMapper insureOrderInfoMapper;
@@ -1218,6 +1221,7 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
                                 .isNotNull(AppOrderProInfo::getFrameTimeStr));
                         
                         FamilyMembers familyMembers = familyMembersMapper.selectById(appOrderProInfo.getFamilyUserId());
+                        faceImageNormalizationService.ensureNormalized(familyMembers);
                         
                         if (CollUtil.isNotEmpty(allCourseOrders)) {
                             // 解析所有订单的frameTimeStr,找出最早和最晚的日期
@@ -1276,7 +1280,7 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
                                                 appDevice.getDeviceSerial(),
                                                 familyMembers.getId(),
                                                 familyMembers.getRealNameImg())).getAsJsonObject();
-                                        if (addUserJson.get("code").getAsInt() != 0 && addFaceJson.get("code").getAsInt() != 0) {
+                                        if (addUserJson.get("code").getAsInt() != 0 || addFaceJson.get("code").getAsInt() != 0) {
                                             throw new JeecgBootException("设备录入用户信息失败!请联系管理员");
                                         }
                                     }
@@ -1297,7 +1301,7 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
                                             appDevice.getDeviceSerial(),
                                             familyMembers.getId(),
                                             familyMembers.getRealNameImg())).getAsJsonObject();
-                                    if (addUserJson.get("code").getAsInt() != 0 && addFaceJson.get("code").getAsInt() != 0) {
+                                    if (addUserJson.get("code").getAsInt() != 0 || addFaceJson.get("code").getAsInt() != 0) {
                                         throw new JeecgBootException("设备录入用户信息失败!请联系管理员");
                                     }
                                 }
@@ -1370,6 +1374,7 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
                         appOrderProInfo.getUseDateStr(), appOrderProInfo.getFrameTimeStr());
                 
                 FamilyMembers familyMembers = familyMembersMapper.selectById(appOrderProInfo.getFamilyUserId());
+                faceImageNormalizationService.ensureNormalized(familyMembers);
                 // 查询该用户所有待使用的场地订单,找出最早和最晚的使用日期
                 List<AppOrderProInfo> allFieldOrders = appOrderProInfoMapper.selectList(Wrappers.<AppOrderProInfo>lambdaQuery()
                         .eq(AppOrderProInfo::getFamilyUserId, appOrderProInfo.getFamilyUserId())
@@ -1439,7 +1444,7 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
                                 
                                 JsonObject addUserJson = JsonParser.parseString(addUser).getAsJsonObject();
                                 JsonObject addFaceJson = JsonParser.parseString(addFace).getAsJsonObject();
-                                if (addUserJson.get("code").getAsInt() != 0 && addFaceJson.get("code").getAsInt() != 0)
+                                if (addUserJson.get("code").getAsInt() != 0 || addFaceJson.get("code").getAsInt() != 0)
                                     throw new JeecgBootException("设备录入用户信息失败!请联系管理员");
                             }
                         }

+ 259 - 0
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/app/util/FaceImageNormalizer.java

@@ -0,0 +1,259 @@
+package org.jeecg.modules.app.util;
+
+import javax.imageio.IIOImage;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.ImageWriteParam;
+import javax.imageio.ImageWriter;
+import javax.imageio.stream.ImageInputStream;
+import javax.imageio.stream.ImageOutputStream;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.geom.AffineTransform;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Iterator;
+
+/**
+ * Converts a user supplied face photo to the conservative JPEG format accepted
+ * by Hikvision face terminals.
+ */
+public final class FaceImageNormalizer {
+
+    private static final int MAX_EDGE = 1024;
+    private static final int MIN_SHORT_EDGE = 480;
+    private static final int MIN_LONG_EDGE = 640;
+    private static final long MAX_SOURCE_PIXELS = 25_000_000L;
+    private static final int MAX_OUTPUT_BYTES = 190 * 1024;
+    public static final int MAX_SOURCE_BYTES = 2 * 1024 * 1024;
+    private static final float[] JPEG_QUALITIES = {0.95f, 0.90f, 0.85f, 0.80f, 0.75f, 0.70f, 0.65f, 0.60f, 0.55f};
+
+    private FaceImageNormalizer() {
+    }
+
+    public static byte[] normalize(byte[] sourceBytes) throws IOException {
+        if (sourceBytes == null || sourceBytes.length == 0) {
+            throw new IOException("人脸图片为空");
+        }
+        if (sourceBytes.length > MAX_SOURCE_BYTES) {
+            throw new IOException("人脸图片不能超过2MB");
+        }
+
+        BufferedImage source;
+        int sourceWidth;
+        int sourceHeight;
+        try (ByteArrayInputStream input = new ByteArrayInputStream(sourceBytes);
+             ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
+            if (imageInput == null) {
+                throw new IOException("无法读取人脸图片");
+            }
+            Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInput);
+            if (!readers.hasNext()) {
+                throw new IOException("无法识别人脸图片格式");
+            }
+            ImageReader reader = readers.next();
+            try {
+                reader.setInput(imageInput, true, true);
+                sourceWidth = reader.getWidth(0);
+                sourceHeight = reader.getHeight(0);
+                validateDimensions(sourceWidth, sourceHeight);
+                source = reader.read(0);
+            } finally {
+                reader.dispose();
+            }
+        }
+        if (source == null) {
+            throw new IOException("无法解码人脸图片");
+        }
+
+        int orientation = readExifOrientation(sourceBytes);
+        int orientedWidth = orientation >= 5 && orientation <= 8 ? sourceHeight : sourceWidth;
+        int orientedHeight = orientation >= 5 && orientation <= 8 ? sourceWidth : sourceHeight;
+        int longEdge = Math.max(orientedWidth, orientedHeight);
+        double scale = Math.min(1.0d, (double) MAX_EDGE / longEdge);
+        int width = Math.max(1, (int) Math.round(orientedWidth * scale));
+        int height = Math.max(1, (int) Math.round(orientedHeight * scale));
+        BufferedImage normalized = renderAsSrgbJpegImage(source, width, height, orientation);
+
+        for (float quality : JPEG_QUALITIES) {
+            byte[] attempt = writeBaselineJpeg(normalized, quality);
+            if (attempt.length <= MAX_OUTPUT_BYTES) {
+                return attempt;
+            }
+        }
+
+        throw new IOException("人脸图片压缩后仍超过190KB");
+    }
+
+    private static void validateDimensions(int sourceWidth, int sourceHeight) throws IOException {
+        int shortEdge = Math.min(sourceWidth, sourceHeight);
+        int longEdge = Math.max(sourceWidth, sourceHeight);
+        if (shortEdge < MIN_SHORT_EDGE || longEdge < MIN_LONG_EDGE) {
+            throw new IOException("人脸图片分辨率过低,至少需要640×480");
+        }
+        if ((long) sourceWidth * sourceHeight > MAX_SOURCE_PIXELS) {
+            throw new IOException("人脸图片分辨率过高");
+        }
+    }
+
+    private static BufferedImage renderAsSrgbJpegImage(BufferedImage source, int width, int height, int orientation) {
+        BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+        Graphics2D graphics = target.createGraphics();
+        try {
+            graphics.setColor(Color.WHITE);
+            graphics.fillRect(0, 0, width, height);
+            graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+            graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+            graphics.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY);
+            int orientedWidth = orientation >= 5 && orientation <= 8 ? source.getHeight() : source.getWidth();
+            int orientedHeight = orientation >= 5 && orientation <= 8 ? source.getWidth() : source.getHeight();
+            AffineTransform transform = AffineTransform.getScaleInstance(
+                    (double) width / orientedWidth, (double) height / orientedHeight);
+            transform.concatenate(orientationTransform(orientation, source.getWidth(), source.getHeight()));
+            graphics.drawImage(source, transform, null);
+        } finally {
+            graphics.dispose();
+        }
+        return target;
+    }
+
+    private static AffineTransform orientationTransform(int orientation, int width, int height) {
+        switch (orientation) {
+            case 2:
+                return new AffineTransform(-1, 0, 0, 1, width, 0);
+            case 3:
+                return new AffineTransform(-1, 0, 0, -1, width, height);
+            case 4:
+                return new AffineTransform(1, 0, 0, -1, 0, height);
+            case 5:
+                return new AffineTransform(0, 1, 1, 0, 0, 0);
+            case 6:
+                return new AffineTransform(0, 1, -1, 0, height, 0);
+            case 7:
+                return new AffineTransform(0, -1, -1, 0, height, width);
+            case 8:
+                return new AffineTransform(0, -1, 1, 0, 0, width);
+            default:
+                return new AffineTransform();
+        }
+    }
+
+    private static int readExifOrientation(byte[] jpeg) {
+        if (jpeg.length < 4 || (jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) {
+            return 1;
+        }
+        int offset = 2;
+        while (offset + 4 <= jpeg.length) {
+            while (offset < jpeg.length && (jpeg[offset] & 0xFF) == 0xFF) {
+                offset++;
+            }
+            if (offset >= jpeg.length) {
+                break;
+            }
+            int marker = jpeg[offset++] & 0xFF;
+            if (marker == 0xDA || marker == 0xD9) {
+                break;
+            }
+            if (marker >= 0xD0 && marker <= 0xD7) {
+                continue;
+            }
+            if (offset + 2 > jpeg.length) {
+                break;
+            }
+            int segmentLength = readUnsignedShort(jpeg, offset, false);
+            if (segmentLength < 2 || offset + segmentLength > jpeg.length) {
+                break;
+            }
+            int content = offset + 2;
+            if (marker == 0xE1 && segmentLength >= 14
+                    && jpeg[content] == 'E' && jpeg[content + 1] == 'x'
+                    && jpeg[content + 2] == 'i' && jpeg[content + 3] == 'f'
+                    && jpeg[content + 4] == 0 && jpeg[content + 5] == 0) {
+                int orientation = readTiffOrientation(jpeg, content + 6, offset + segmentLength);
+                if (orientation >= 1 && orientation <= 8) {
+                    return orientation;
+                }
+            }
+            offset += segmentLength;
+        }
+        return 1;
+    }
+
+    private static int readTiffOrientation(byte[] data, int tiffStart, int limit) {
+        if (tiffStart + 8 > limit) {
+            return 1;
+        }
+        boolean littleEndian;
+        if (data[tiffStart] == 'I' && data[tiffStart + 1] == 'I') {
+            littleEndian = true;
+        } else if (data[tiffStart] == 'M' && data[tiffStart + 1] == 'M') {
+            littleEndian = false;
+        } else {
+            return 1;
+        }
+        if (readUnsignedShort(data, tiffStart + 2, littleEndian) != 42) {
+            return 1;
+        }
+        long ifdOffset = readUnsignedInt(data, tiffStart + 4, littleEndian);
+        long ifdPosition = (long) tiffStart + ifdOffset;
+        if (ifdPosition < tiffStart || ifdPosition + 2 > limit) {
+            return 1;
+        }
+        int ifd = (int) ifdPosition;
+        int entries = readUnsignedShort(data, ifd, littleEndian);
+        for (int i = 0; i < entries; i++) {
+            int entry = ifd + 2 + i * 12;
+            if (entry + 12 > limit) {
+                return 1;
+            }
+            int tag = readUnsignedShort(data, entry, littleEndian);
+            if (tag == 0x0112
+                    && readUnsignedShort(data, entry + 2, littleEndian) == 3
+                    && readUnsignedInt(data, entry + 4, littleEndian) == 1) {
+                return readUnsignedShort(data, entry + 8, littleEndian);
+            }
+        }
+        return 1;
+    }
+
+    private static int readUnsignedShort(byte[] data, int offset, boolean littleEndian) {
+        int first = data[offset] & 0xFF;
+        int second = data[offset + 1] & 0xFF;
+        return littleEndian ? first | (second << 8) : (first << 8) | second;
+    }
+
+    private static long readUnsignedInt(byte[] data, int offset, boolean littleEndian) {
+        long first = data[offset] & 0xFFL;
+        long second = data[offset + 1] & 0xFFL;
+        long third = data[offset + 2] & 0xFFL;
+        long fourth = data[offset + 3] & 0xFFL;
+        return littleEndian
+                ? first | (second << 8) | (third << 16) | (fourth << 24)
+                : (first << 24) | (second << 16) | (third << 8) | fourth;
+    }
+
+    private static byte[] writeBaselineJpeg(BufferedImage image, float quality) throws IOException {
+        Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
+        if (!writers.hasNext()) {
+            throw new IOException("当前环境不支持JPEG编码");
+        }
+
+        ImageWriter writer = writers.next();
+        try (ByteArrayOutputStream output = new ByteArrayOutputStream();
+             ImageOutputStream imageOutput = ImageIO.createImageOutputStream(output)) {
+            ImageWriteParam params = writer.getDefaultWriteParam();
+            params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
+            params.setCompressionQuality(quality);
+            params.setProgressiveMode(ImageWriteParam.MODE_DISABLED);
+            writer.setOutput(imageOutput);
+            writer.write(null, new IIOImage(image, null, null), params);
+            imageOutput.flush();
+            return output.toByteArray();
+        } finally {
+            writer.dispose();
+        }
+    }
+}

+ 19 - 14
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/quartz/utils/HolidayUtil.java

@@ -2,6 +2,7 @@ package org.jeecg.modules.quartz.utils;
 
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.jeecg.common.util.DateUtils;
 
@@ -21,6 +22,7 @@ import java.util.*;
  * @description 判断今天是工作日/周末/节假日 工具类
  * 0上班 1周末 2节假日
  */
+@Slf4j
 public class HolidayUtil {
 
     //判断是否为节假日
@@ -29,6 +31,8 @@ public class HolidayUtil {
      * @return
      */
     public static String isWorkingDay(String time,List<String> holiday,List<String> extraWorkDay) throws ParseException {
+        holiday = holiday == null ? Collections.emptyList() : holiday;
+        extraWorkDay = extraWorkDay == null ? Collections.emptyList() : extraWorkDay;
         Date parse = null;
         //为空则返回当前时间
         if (StringUtils.isNotBlank(time)) {
@@ -81,37 +85,38 @@ public class HolidayUtil {
     public static Map<String,List<String>> getYearHoliday(String date) {
 
         HashMap<String , List<String>> map = new HashMap<>();
+        map.put("holiday", new ArrayList<>());
+        map.put("extraWorkDay", new ArrayList<>());
 
         //获取免费api地址
         String httpUrl = "https://timor.tech/api/holiday/year/" + date;
-        BufferedReader reader = null;
-        String result = null;
         StringBuffer sbf = new StringBuffer();
 
         try {
             URL url = new URL(httpUrl);
             URLConnection connection = url.openConnection();
+            connection.setConnectTimeout(3000);
+            connection.setReadTimeout(3000);
             //connection.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
             connection.setRequestProperty("User-Agent", "Mozilla/4.76");
             //使用Get方式请求数据
             //connection.setRequestMethod("GET");
             //connection.connect();
-            InputStream is = connection.getInputStream();
-            reader = new BufferedReader(new InputStreamReader(is, "UTF-8"));
-
-            String strRead = null;
-            while ((strRead = reader.readLine()) != null) {
-                sbf.append(strRead);
-                sbf.append("\r\n");
+            try (InputStream is = connection.getInputStream();
+                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"))) {
+                String strRead;
+                while ((strRead = reader.readLine()) != null) {
+                    sbf.append(strRead);
+                    sbf.append("\r\n");
+                }
             }
-            reader.close();
 
             //字符串转json
             JSONObject json = JSON.parseObject(sbf.toString());
             //根据holiday字段获取jsonObject内容
             JSONObject holiday = json.getJSONObject("holiday");
-            if (holiday.size() == 0) {
-                return null;
+            if (holiday == null || holiday.isEmpty()) {
+                return map;
             }
             List hoList = new ArrayList<>();
             List extraList = new ArrayList<>();
@@ -131,7 +136,7 @@ public class HolidayUtil {
             map.put("extraWorkDay", extraList);
 
         } catch (Exception e) {
-            e.printStackTrace();
+            log.warn("获取{}年节假日失败,按普通周末规则生成教学日: {}", date, e.getMessage());
         }
         return map;
     }
@@ -200,4 +205,4 @@ public class HolidayUtil {
         }
     }
 
-}
+}

+ 5 - 1
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/system/app/service/impl/AppCoureseServiceImpl.java

@@ -19,6 +19,7 @@ import org.jeecg.common.exception.JeecgBootException;
 import org.jeecg.common.system.vo.LoginUser;
 import org.jeecg.modules.app.form.ClassPostponeForm;
 import org.jeecg.modules.app.form.TemporaryCourseForm;
+import org.jeecg.modules.app.service.FaceImageNormalizationService;
 import org.jeecg.modules.app.vo.AppCoursesPageVO;
 import org.jeecg.modules.app.vo.CoursesPriceRulesVO;
 import org.jeecg.modules.app.vo.FamilyUserVO;
@@ -70,6 +71,8 @@ public class AppCoureseServiceImpl extends ServiceImpl<AppCoursesMapper, AppCour
     @Resource
     private FamilyMembersMapper familyMembersMapper;
     @Resource
+    private FaceImageNormalizationService faceImageNormalizationService;
+    @Resource
     private AppOrderMapper appOrderMapper;
     @Autowired
     WxNotificationService wxNotificationService;
@@ -531,6 +534,7 @@ public class AppCoureseServiceImpl extends ServiceImpl<AppCoursesMapper, AppCour
             if(appCoursesVerificationRecordMapper.insert(verificationRecord) > 0){
                 AppSite appSite = siteMapper.selectById(appCoursesMapper.selectById(verificationRecord.getCoursesId()).getAddressSiteId());
                 if(appSite.getType() == 0){
+                    faceImageNormalizationService.ensureNormalized(familyMembers);
                     for (AppDevice appDevice : appDeviceMapper.selectList(Wrappers.<AppDevice>lambdaQuery().eq(AppDevice::getSiteId, appSite.getId()))){
                         if(null != appDevice){
                             JsonObject addUserJson = JsonParser.parseString(addUser(new DateTime(verificationRecord.getCoursesStartTime()),
@@ -539,7 +543,7 @@ public class AppCoureseServiceImpl extends ServiceImpl<AppCoursesMapper, AppCour
                                     familyMembers.getId(),new DateTime(verificationRecord.getCoursesEndTime()), null)).getAsJsonObject();
                             JsonObject addFaceJson = JsonParser.parseString(addFace(appDevice.getDeviceSerial(), familyMembers.getId(),
                                     familyMembers.getRealNameImg())).getAsJsonObject();
-                            if (addUserJson.get("code").getAsInt() != 0 && addFaceJson.get("code").getAsInt() != 0){
+                            if (addUserJson.get("code").getAsInt() != 0 || addFaceJson.get("code").getAsInt() != 0){
                                 throw new JeecgBootException("设备录入用户信息失败!请联系管理员");
                             }
                         }

+ 116 - 0
national-motion-module-system/national-motion-system-biz/src/test/java/org/jeecg/modules/app/util/FaceImageNormalizerTest.java

@@ -0,0 +1,116 @@
+package org.jeecg.modules.app.util;
+
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FaceImageNormalizerTest {
+
+    @Test
+    void normalizesLargeImageToBaselineJpeg() throws Exception {
+        BufferedImage source = new BufferedImage(3072, 4096, BufferedImage.TYPE_INT_ARGB);
+        Graphics2D graphics = source.createGraphics();
+        try {
+            graphics.setColor(new Color(245, 220, 195));
+            graphics.fillRect(0, 0, source.getWidth(), source.getHeight());
+            graphics.setColor(new Color(70, 100, 150, 180));
+            for (int y = 0; y < source.getHeight(); y += 80) {
+                graphics.drawLine(0, y, source.getWidth(), source.getHeight() - y);
+            }
+        } finally {
+            graphics.dispose();
+        }
+
+        ByteArrayOutputStream input = new ByteArrayOutputStream();
+        ImageIO.write(source, "png", input);
+        byte[] normalized = FaceImageNormalizer.normalize(input.toByteArray());
+        BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(normalized));
+
+        assertEquals(768, decoded.getWidth());
+        assertEquals(1024, decoded.getHeight());
+        assertTrue(normalized.length <= 190 * 1024);
+        assertTrue(containsMarker(normalized, 0xC0), "baseline SOF0 marker is required");
+        assertFalse(containsMarker(normalized, 0xC2), "progressive SOF2 marker is not allowed");
+    }
+
+    @Test
+    void rejectsUnreadableImage() {
+        assertThrows(IOException.class, () -> FaceImageNormalizer.normalize(new byte[]{1, 2, 3}));
+    }
+
+    @Test
+    void rejectsLowResolutionImage() throws Exception {
+        BufferedImage source = new BufferedImage(320, 480, BufferedImage.TYPE_INT_RGB);
+        ByteArrayOutputStream input = new ByteArrayOutputStream();
+        ImageIO.write(source, "jpeg", input);
+        assertThrows(IOException.class, () -> FaceImageNormalizer.normalize(input.toByteArray()));
+    }
+
+    @Test
+    void appliesExifOrientationBeforeStrippingMetadata() throws Exception {
+        BufferedImage source = new BufferedImage(640, 480, BufferedImage.TYPE_INT_RGB);
+        Graphics2D graphics = source.createGraphics();
+        try {
+            graphics.setColor(Color.BLACK);
+            graphics.fillRect(0, 0, 640, 480);
+            graphics.setColor(Color.RED);
+            graphics.fillRect(0, 0, 100, 100);
+        } finally {
+            graphics.dispose();
+        }
+        ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
+        ImageIO.write(source, "jpeg", jpeg);
+
+        byte[] normalized = FaceImageNormalizer.normalize(withExifOrientation(jpeg.toByteArray(), 6));
+        BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(normalized));
+
+        assertEquals(480, decoded.getWidth());
+        assertEquals(640, decoded.getHeight());
+        Color rotatedTopRight = new Color(decoded.getRGB(450, 30));
+        assertTrue(rotatedTopRight.getRed() > 150);
+        assertTrue(rotatedTopRight.getRed() > rotatedTopRight.getBlue());
+    }
+
+    @Test
+    void rejectsOversizedSourceBeforeDecoding() {
+        byte[] oversized = new byte[FaceImageNormalizer.MAX_SOURCE_BYTES + 1];
+        assertThrows(IOException.class, () -> FaceImageNormalizer.normalize(oversized));
+    }
+
+    private boolean containsMarker(byte[] jpeg, int marker) {
+        for (int i = 0; i < jpeg.length - 1; i++) {
+            if ((jpeg[i] & 0xFF) == 0xFF && (jpeg[i + 1] & 0xFF) == marker) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private byte[] withExifOrientation(byte[] jpeg, int orientation) {
+        byte[] exif = new byte[]{
+                (byte) 0xFF, (byte) 0xE1, 0x00, 0x22,
+                'E', 'x', 'i', 'f', 0x00, 0x00,
+                'I', 'I', 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00,
+                0x01, 0x00,
+                0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00,
+                (byte) orientation, 0x00, 0x00, 0x00,
+                0x00, 0x00, 0x00, 0x00
+        };
+        byte[] result = new byte[jpeg.length + exif.length];
+        System.arraycopy(jpeg, 0, result, 0, 2);
+        System.arraycopy(exif, 0, result, 2, exif.length);
+        System.arraycopy(jpeg, 2, result, 2 + exif.length, jpeg.length - 2);
+        return result;
+    }
+}

+ 15 - 0
national-motion-module-system/national-motion-system-biz/src/test/java/org/jeecg/modules/quartz/utils/HolidayUtilTest.java

@@ -0,0 +1,15 @@
+package org.jeecg.modules.quartz.utils;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class HolidayUtilTest {
+
+    @Test
+    void fallsBackToWeekdayAndWeekendRulesWhenHolidayApiDataIsUnavailable() throws Exception {
+        assertEquals("0", HolidayUtil.isWorkingDay("2026-09-10", null, null));
+        assertEquals("1", HolidayUtil.isWorkingDay("2026-09-12", null, null));
+        assertEquals("1", HolidayUtil.isWorkingDay("2026-09-13", null, null));
+    }
+}