From 397e36144a9ee070db9621764ffd29c98aeb9a89 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Thu, 23 Jul 2026 15:57:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=88=B7=E5=A4=96?= =?UTF-8?q?=E6=8B=9B=E8=81=98=E4=BC=9A=E7=AD=BE=E5=88=B0=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=8F=8A=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/AppOutdoorFairController.java | 75 ++++++++ .../controller/cms/OutdoorFairController.java | 21 ++ .../service/WeChatMiniProgramApiClient.java | 180 ++++++++++++++++++ .../WeChatMiniProgramQrCodeService.java | 104 ++++++++++ .../app/AppOutdoorFairControllerTest.java | 157 +++++++++++++++ .../cms/OutdoorFairControllerTest.java | 12 ++ .../WeChatMiniProgramQrCodeServiceTest.java | 86 +++++++++ ...or_fair_mini_program_qrcode_permission.sql | 36 ++++ 8 files changed, 671 insertions(+) create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java create mode 100644 ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java create mode 100644 ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java create mode 100644 sql/migration_outdoor_fair_mini_program_qrcode_permission.sql diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java index 830d6fd..9ee261d 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java @@ -194,6 +194,7 @@ public class AppOutdoorFairController extends BaseController } Map data = new HashMap<>(); data.put("fair", buildPublicFairDetail(fair)); + data.put("companies", buildPublicParticipatingCompanies(fairId)); data.put("fairH5Url", buildFairH5Url(request, fairId)); return success(data); } @@ -215,6 +216,16 @@ public class AppOutdoorFairController extends BaseController if (StringUtils.isBlank(attendee.getPhone())) { return AjaxResult.error("手机号不能为空"); } + OutdoorFair fair = outdoorFairService.getById(attendee.getFairId()); + if (fair == null) { + return AjaxResult.error("招聘会不存在"); + } + if (fair.getHoldTime() == null) { + return AjaxResult.error("招聘会开始时间未设置,暂不能签到"); + } + if (!hasFairStarted(fair, new Date())) { + return AjaxResult.error("招聘会尚未开始,暂不能签到"); + } // 同一招聘会下同手机号已签到则不重复记录 Long exists = outdoorFairAttendeeService.count(new LambdaQueryWrapper() .eq(OutdoorFairAttendee::getFairId, attendee.getFairId()) @@ -226,6 +237,12 @@ public class AppOutdoorFairController extends BaseController return toAjax(outdoorFairAttendeeService.save(attendee)); } + static boolean hasFairStarted(OutdoorFair fair, Date currentTime) + { + return fair != null && fair.getHoldTime() != null && currentTime != null + && !currentTime.before(fair.getHoldTime()); + } + private Map buildCompanyPage(Long fairId, Long companyId, OutdoorFairDevice device, HttpServletRequest request) { @@ -396,6 +413,64 @@ public class AppOutdoorFairController extends BaseController return publicFair; } + /** + * 求职者端只展示审核通过且当前有效的参会企业,不返回联系人、手机号等非必要信息。 + */ + private List> buildPublicParticipatingCompanies(Long fairId) + { + List relations = fairCompanyMapper.selectList(new LambdaQueryWrapper() + .eq(FairCompany::getJobFairId, fairId) + .eq(FairCompany::getReviewStatus, "1") + .orderByDesc(FairCompany::getId)); + if (relations.isEmpty()) { + return new ArrayList<>(); + } + + List companyIds = relations.stream() + .map(FairCompany::getCompanyId) + .filter(item -> item != null) + .distinct() + .collect(Collectors.toList()); + if (companyIds.isEmpty()) { + return new ArrayList<>(); + } + Map companyMap = companyMapper.selectBatchIds(companyIds).stream() + .filter(company -> company.getCompanyId() != null) + .collect(Collectors.toMap(Company::getCompanyId, item -> item, (a, b) -> a)); + Map bookingMap = outdoorFairBoothBookingMapper.selectList( + new LambdaQueryWrapper() + .eq(OutdoorFairBoothBooking::getFairId, fairId) + .eq(OutdoorFairBoothBooking::getStatus, "reserved")) + .stream() + .filter(item -> item.getCompanyId() != null) + .collect(Collectors.toMap(OutdoorFairBoothBooking::getCompanyId, item -> item, (a, b) -> a)); + Map jobCountMap = outdoorFairJobMapper.countJobsByCompany(fairId).stream() + .collect(Collectors.toMap( + item -> Long.valueOf(String.valueOf(item.get("companyId"))), + item -> Long.valueOf(String.valueOf(item.get("jobCount"))), + (a, b) -> a)); + + List> rows = new ArrayList<>(); + for (FairCompany relation : relations) { + Company company = companyMap.get(relation.getCompanyId()); + if (company == null || company.getStatus() == null || company.getStatus() != 1) { + continue; + } + OutdoorFairBoothBooking booking = bookingMap.get(company.getCompanyId()); + Map row = new HashMap<>(); + row.put("companyId", company.getCompanyId()); + row.put("companyName", company.getName()); + row.put("industry", company.getIndustry()); + row.put("scale", company.getScale()); + row.put("location", company.getLocation()); + row.put("description", company.getDescription()); + row.put("boothNumber", booking == null ? null : booking.getBoothNumber()); + row.put("jobCount", jobCountMap.getOrDefault(company.getCompanyId(), 0L)); + rows.add(row); + } + return rows; + } + private Date[] parseDayRange(String day) { try { diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/OutdoorFairController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/OutdoorFairController.java index 08d966c..e3545df 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/OutdoorFairController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/OutdoorFairController.java @@ -39,6 +39,7 @@ import com.ruoyi.cms.service.ICompanyService; import com.ruoyi.cms.service.IJobService; import com.ruoyi.cms.service.IOutdoorFairService; import com.ruoyi.cms.service.IVenueInfoService; +import com.ruoyi.cms.service.WeChatMiniProgramQrCodeService; import com.ruoyi.cms.util.RoleUtils; import com.ruoyi.common.annotation.Log; import com.ruoyi.common.core.controller.BaseController; @@ -114,6 +115,9 @@ public class OutdoorFairController extends BaseController @Autowired private ICompanyService companyService; + @Autowired + private WeChatMiniProgramQrCodeService weChatMiniProgramQrCodeService; + /** * 查询户外招聘会列表 */ @@ -144,6 +148,23 @@ public class OutdoorFairController extends BaseController return success(outdoorFairService.getById(id)); } + /** + * 生成招聘会详情页的长期有效微信小程序码。 + */ + @ApiOperation("获取户外招聘会微信小程序码") + @PreAuthorize("@ss.hasPermi('cms:outdoorFair:qrcode')") + @GetMapping("/{id}/mini-program-qrcode") + public AjaxResult getMiniProgramQrCode(@PathVariable("id") Long id) + { + if (RoleUtils.isCompanyAdmin()) { + return AjaxResult.error("企业用户无权查看招聘会小程序码"); + } + if (outdoorFairService.getById(id) == null) { + return AjaxResult.error("招聘会不存在"); + } + return success(weChatMiniProgramQrCodeService.generateOutdoorFairQrCode(id)); + } + /** * 企业只读查看当前招聘会的展位图。 * 仅当前企业已报名且审核通过时返回数据;企业端没有任何展位图写入入口。 diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java new file mode 100644 index 0000000..9e2d62e --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java @@ -0,0 +1,180 @@ +package com.ruoyi.cms.service; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.exception.ServiceException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信小程序服务端接口客户端。 + * + *

该客户端只负责单次远程调用,不记录 appSecret、access token 或微信响应中的敏感内容。

+ */ +@Component +public class WeChatMiniProgramApiClient +{ + private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; + private static final String UNLIMITED_QR_CODE_URL = + "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token="; + private static final int HTTP_TIMEOUT_MILLIS = 15000; + + public AccessTokenResult getAccessToken(String appId, String appSecret) + { + Map params = new HashMap<>(); + params.put("grant_type", "client_credential"); + params.put("appid", appId); + params.put("secret", appSecret); + String body = HttpUtil.get(ACCESS_TOKEN_URL, params, HTTP_TIMEOUT_MILLIS); + JSONObject json = parseJson(body, "获取微信 access token 失败"); + String accessToken = json.getString("access_token"); + Integer expiresIn = json.getInteger("expires_in"); + if (accessToken == null || accessToken.trim().isEmpty()) { + throw weChatError("获取微信 access token 失败", json); + } + return new AccessTokenResult(accessToken, expiresIn == null ? 7200 : expiresIn); + } + + public QrCodeImage getUnlimitedQrCode(String accessToken, String scene, String page) + { + JSONObject requestBody = new JSONObject(); + requestBody.put("scene", scene); + requestBody.put("page", page); + // 新页面尚未随小程序版本发布时也允许先生成码;扫码仍会进入正式版小程序。 + requestBody.put("check_path", false); + requestBody.put("env_version", "release"); + requestBody.put("width", 430); + + HttpResponse response = null; + try { + response = HttpRequest.post(UNLIMITED_QR_CODE_URL + accessToken) + .timeout(HTTP_TIMEOUT_MILLIS) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(requestBody.toJSONString()) + .execute(); + byte[] body = response.bodyBytes(); + String contentType = response.header(HttpHeaders.CONTENT_TYPE); + if (response.isOk() && body != null && body.length > 0 + && isImageResponse(contentType, body)) { + return new QrCodeImage(body, normalizeImageContentType(contentType, body)); + } + String errorBody = body == null ? "" : new String(body, StandardCharsets.UTF_8); + JSONObject errorJson = parseJson(errorBody, "生成微信小程序码失败"); + throw weChatError("生成微信小程序码失败", errorJson); + } finally { + if (response != null) { + response.close(); + } + } + } + + private boolean isImageResponse(String contentType, byte[] body) + { + return (contentType != null && contentType.toLowerCase().startsWith("image/")) + || isPng(body) || isJpeg(body); + } + + private String normalizeImageContentType(String contentType, byte[] body) + { + if (contentType != null && contentType.toLowerCase().startsWith("image/")) { + int separator = contentType.indexOf(';'); + return separator > 0 ? contentType.substring(0, separator) : contentType; + } + return isJpeg(body) ? MediaType.IMAGE_JPEG_VALUE : MediaType.IMAGE_PNG_VALUE; + } + + private boolean isPng(byte[] body) + { + return body.length >= 8 + && (body[0] & 0xff) == 0x89 + && body[1] == 0x50 + && body[2] == 0x4e + && body[3] == 0x47; + } + + private boolean isJpeg(byte[] body) + { + return body.length >= 3 + && (body[0] & 0xff) == 0xff + && (body[1] & 0xff) == 0xd8 + && (body[2] & 0xff) == 0xff; + } + + private JSONObject parseJson(String body, String action) + { + try { + JSONObject json = JSONObject.parseObject(body); + if (json == null) { + throw new IllegalArgumentException("empty json"); + } + return json; + } catch (Exception exception) { + throw new ServiceException(action + ",微信接口返回了无法识别的响应"); + } + } + + private ServiceException weChatError(String action, JSONObject json) + { + Integer errorCode = json.getInteger("errcode"); + String errorMessage = json.getString("errmsg"); + StringBuilder message = new StringBuilder(action); + if (errorCode != null) { + message.append("(错误码:").append(errorCode).append(')'); + } + if (errorMessage != null && !errorMessage.trim().isEmpty()) { + message.append(":").append(errorMessage); + } + return new ServiceException(message.toString()); + } + + public static class AccessTokenResult + { + private final String accessToken; + private final int expiresIn; + + public AccessTokenResult(String accessToken, int expiresIn) + { + this.accessToken = accessToken; + this.expiresIn = expiresIn; + } + + public String getAccessToken() + { + return accessToken; + } + + public int getExpiresIn() + { + return expiresIn; + } + } + + public static class QrCodeImage + { + private final byte[] content; + private final String contentType; + + public QrCodeImage(byte[] content, String contentType) + { + this.content = content; + this.contentType = contentType; + } + + public byte[] getContent() + { + return content; + } + + public String getContentType() + { + return contentType; + } + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java new file mode 100644 index 0000000..969fba3 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java @@ -0,0 +1,104 @@ +package com.ruoyi.cms.service; + +import com.ruoyi.common.exception.ServiceException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * 生成可长期使用的微信小程序码,并负责 access token 的提前刷新。 + */ +@Service +public class WeChatMiniProgramQrCodeService +{ + public static final String OUTDOOR_FAIR_PAGE = "packageA/pages/outdoorFair/detail"; + private static final long TOKEN_REFRESH_AHEAD_MILLIS = 300_000L; + + private final WeChatMiniProgramApiClient apiClient; + private final String appId; + private final String appSecret; + private final LongSupplier currentTimeMillis; + private final Map qrCodeDataUrlCache = new ConcurrentHashMap<>(); + + private volatile String accessToken; + private volatile long accessTokenExpiresAt; + + @Autowired + public WeChatMiniProgramQrCodeService(WeChatMiniProgramApiClient apiClient, + @Value("${wx.appid}") String appId, + @Value("${wx.secret}") String appSecret) + { + this(apiClient, appId, appSecret, System::currentTimeMillis); + } + + WeChatMiniProgramQrCodeService(WeChatMiniProgramApiClient apiClient, String appId, + String appSecret, LongSupplier currentTimeMillis) + { + this.apiClient = apiClient; + this.appId = appId; + this.appSecret = appSecret; + this.currentTimeMillis = currentTimeMillis; + } + + public Map generateOutdoorFairQrCode(Long fairId) + { + if (fairId == null || fairId <= 0) { + throw new ServiceException("招聘会参数不正确"); + } + String scene = "fairId=" + fairId; + String imageData = qrCodeDataUrlCache.get(fairId); + if (imageData == null) { + synchronized (qrCodeDataUrlCache) { + imageData = qrCodeDataUrlCache.get(fairId); + if (imageData == null) { + WeChatMiniProgramApiClient.QrCodeImage image = apiClient.getUnlimitedQrCode( + getValidAccessToken(), scene, OUTDOOR_FAIR_PAGE); + imageData = "data:" + image.getContentType() + ";base64," + + Base64.getEncoder().encodeToString(image.getContent()); + qrCodeDataUrlCache.put(fairId, imageData); + } + } + } + + Map data = new LinkedHashMap<>(); + data.put("fairId", fairId); + data.put("page", OUTDOOR_FAIR_PAGE); + data.put("scene", scene); + data.put("imageData", imageData); + // getUnlimitedQRCode 生成的小程序码长期有效;需要轮换的是 access token,而不是已经生成的码。 + data.put("permanent", true); + data.put("rotationRequired", false); + return data; + } + + private String getValidAccessToken() + { + long now = currentTimeMillis.getAsLong(); + if (accessToken != null && now < accessTokenExpiresAt) { + return accessToken; + } + synchronized (this) { + now = currentTimeMillis.getAsLong(); + if (accessToken != null && now < accessTokenExpiresAt) { + return accessToken; + } + if (appId == null || appId.trim().isEmpty() + || appSecret == null || appSecret.trim().isEmpty()) { + throw new ServiceException("微信小程序配置不完整,请联系管理员"); + } + WeChatMiniProgramApiClient.AccessTokenResult tokenResult = + apiClient.getAccessToken(appId, appSecret); + accessToken = tokenResult.getAccessToken(); + long usableMillis = Math.max(1_000L, + tokenResult.getExpiresIn() * 1_000L - TOKEN_REFRESH_AHEAD_MILLIS); + accessTokenExpiresAt = now + usableMillis; + return accessToken; + } + } +} diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java new file mode 100644 index 0000000..8997526 --- /dev/null +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java @@ -0,0 +1,157 @@ +package com.ruoyi.cms.controller.app; + +import com.ruoyi.cms.domain.FairCompany; +import com.ruoyi.cms.domain.OutdoorFair; +import com.ruoyi.cms.domain.OutdoorFairAttendee; +import com.ruoyi.cms.mapper.CompanyMapper; +import com.ruoyi.cms.mapper.FairCompanyMapper; +import com.ruoyi.cms.mapper.OutdoorFairBoothBookingMapper; +import com.ruoyi.cms.mapper.OutdoorFairBoothMapper; +import com.ruoyi.cms.mapper.OutdoorFairJobMapper; +import com.ruoyi.cms.service.IOutdoorFairAttendeeService; +import com.ruoyi.cms.service.IOutdoorFairService; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.Company; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AppOutdoorFairControllerTest +{ + @Test + void fairStartingInFutureCannotCheckIn() throws Exception + { + IOutdoorFairService fairService = mock(IOutdoorFairService.class); + IOutdoorFairAttendeeService attendeeService = mock(IOutdoorFairAttendeeService.class); + OutdoorFair fair = fairAt(new Date(System.currentTimeMillis() + 60_000L)); + when(fairService.getById(12L)).thenReturn(fair); + AppOutdoorFairController controller = controllerWith(fairService, attendeeService); + + AjaxResult result = controller.checkIn(attendee(12L)); + + assertEquals(500, result.get(AjaxResult.CODE_TAG)); + assertEquals("招聘会尚未开始,暂不能签到", result.get(AjaxResult.MSG_TAG)); + verify(attendeeService, never()).save(any(OutdoorFairAttendee.class)); + } + + @Test + void fairCanCheckInAtOrAfterStartTime() throws Exception + { + IOutdoorFairService fairService = mock(IOutdoorFairService.class); + IOutdoorFairAttendeeService attendeeService = mock(IOutdoorFairAttendeeService.class); + OutdoorFair fair = fairAt(new Date(System.currentTimeMillis() - 1_000L)); + when(fairService.getById(12L)).thenReturn(fair); + when(attendeeService.count(any())).thenReturn(0L); + when(attendeeService.save(any(OutdoorFairAttendee.class))).thenReturn(true); + AppOutdoorFairController controller = controllerWith(fairService, attendeeService); + + AjaxResult result = controller.checkIn(attendee(12L)); + + assertEquals(200, result.get(AjaxResult.CODE_TAG)); + verify(attendeeService).save(any(OutdoorFairAttendee.class)); + assertTrue(AppOutdoorFairController.hasFairStarted(fair, fair.getHoldTime())); + assertFalse(AppOutdoorFairController.hasFairStarted( + fair, new Date(fair.getHoldTime().getTime() - 1L))); + } + + @SuppressWarnings("unchecked") + @Test + void publicDetailIncludesOnlySafeCompanyCardFields() throws Exception + { + IOutdoorFairService fairService = mock(IOutdoorFairService.class); + IOutdoorFairAttendeeService attendeeService = mock(IOutdoorFairAttendeeService.class); + FairCompanyMapper fairCompanyMapper = mock(FairCompanyMapper.class); + CompanyMapper companyMapper = mock(CompanyMapper.class); + OutdoorFairBoothMapper boothMapper = mock(OutdoorFairBoothMapper.class); + OutdoorFairBoothBookingMapper bookingMapper = mock(OutdoorFairBoothBookingMapper.class); + OutdoorFairJobMapper jobMapper = mock(OutdoorFairJobMapper.class); + + OutdoorFair fair = fairAt(new Date()); + fair.setId(12L); + fair.setTitle("测试招聘会"); + when(fairService.getById(12L)).thenReturn(fair); + when(boothMapper.selectCount(any())).thenReturn(0L); + + FairCompany relation = new FairCompany(); + relation.setId(21L); + relation.setJobFairId(12L); + relation.setCompanyId(31L); + relation.setReviewStatus("1"); + when(fairCompanyMapper.selectList(any())).thenReturn(Collections.singletonList(relation)); + + Company company = new Company(); + company.setCompanyId(31L); + company.setName("参会企业"); + company.setIndustry("1001"); + company.setScale("2"); + company.setStatus(1); + company.setContactPerson("不应公开"); + company.setContactPersonPhone("13800000000"); + when(companyMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(company)); + when(bookingMapper.selectList(any())).thenReturn(Collections.emptyList()); + when(jobMapper.countJobsByCompany(12L)).thenReturn(Collections.emptyList()); + + AppOutdoorFairController controller = controllerWith(fairService, attendeeService); + setField(controller, "fairCompanyMapper", fairCompanyMapper); + setField(controller, "companyMapper", companyMapper); + setField(controller, "outdoorFairBoothMapper", boothMapper); + setField(controller, "outdoorFairBoothBookingMapper", bookingMapper); + setField(controller, "outdoorFairJobMapper", jobMapper); + + AjaxResult result = controller.detail(12L, new MockHttpServletRequest()); + Map data = (Map) result.get(AjaxResult.DATA_TAG); + List> companies = (List>) data.get("companies"); + + assertEquals(1, companies.size()); + assertEquals("参会企业", companies.get(0).get("companyName")); + assertFalse(companies.get(0).containsKey("contactPerson")); + assertFalse(companies.get(0).containsKey("contactPhone")); + } + + private AppOutdoorFairController controllerWith(IOutdoorFairService fairService, + IOutdoorFairAttendeeService attendeeService) + throws Exception + { + AppOutdoorFairController controller = new AppOutdoorFairController(); + setField(controller, "outdoorFairService", fairService); + setField(controller, "outdoorFairAttendeeService", attendeeService); + return controller; + } + + private void setField(Object target, String name, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private OutdoorFairAttendee attendee(Long fairId) + { + OutdoorFairAttendee attendee = new OutdoorFairAttendee(); + attendee.setFairId(fairId); + attendee.setName("测试人员"); + attendee.setPhone("13800000000"); + return attendee; + } + + private OutdoorFair fairAt(Date holdTime) + { + OutdoorFair fair = new OutdoorFair(); + fair.setHoldTime(holdTime); + return fair; + } +} diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/cms/OutdoorFairControllerTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/cms/OutdoorFairControllerTest.java index 013e2a6..d1d3d23 100644 --- a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/cms/OutdoorFairControllerTest.java +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/cms/OutdoorFairControllerTest.java @@ -6,9 +6,21 @@ import org.springframework.mock.web.MockHttpServletRequest; import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; class OutdoorFairControllerTest { + @Test + void miniProgramQrCodeEndpointRequiresDedicatedPermission() throws Exception + { + Method method = OutdoorFairController.class.getDeclaredMethod("getMiniProgramQrCode", Long.class); + org.springframework.security.access.prepost.PreAuthorize annotation = + method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class); + + assertNotNull(annotation); + assertEquals("@ss.hasPermi('cms:outdoorFair:qrcode')", annotation.value()); + } + @Test void companyQrCodeUrlUsesPublicForwardedHost() throws Exception { diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java new file mode 100644 index 0000000..9a38915 --- /dev/null +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java @@ -0,0 +1,86 @@ +package com.ruoyi.cms.service; + +import com.ruoyi.common.exception.ServiceException; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +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; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WeChatMiniProgramQrCodeServiceTest +{ + @Test + void generatesPermanentOutdoorFairCodeAndCachesImage() + { + WeChatMiniProgramApiClient client = mock(WeChatMiniProgramApiClient.class); + when(client.getAccessToken("app-id", "app-secret")) + .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-1", 7200)); + when(client.getUnlimitedQrCode("token-1", "fairId=12", + WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE)) + .thenReturn(new WeChatMiniProgramApiClient.QrCodeImage( + "image-content".getBytes(StandardCharsets.UTF_8), "image/png")); + + WeChatMiniProgramQrCodeService service = new WeChatMiniProgramQrCodeService( + client, "app-id", "app-secret", () -> 1_000L); + Map first = service.generateOutdoorFairQrCode(12L); + Map second = service.generateOutdoorFairQrCode(12L); + + assertEquals(12L, first.get("fairId")); + assertEquals("fairId=12", first.get("scene")); + assertEquals("packageA/pages/outdoorFair/detail", first.get("page")); + assertTrue((Boolean) first.get("permanent")); + assertFalse((Boolean) first.get("rotationRequired")); + assertTrue(String.valueOf(first.get("imageData")).startsWith("data:image/png;base64,")); + assertEquals(first.get("imageData"), second.get("imageData")); + verify(client, times(1)).getAccessToken("app-id", "app-secret"); + verify(client, times(1)).getUnlimitedQrCode( + "token-1", "fairId=12", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + } + + @Test + void refreshesAccessTokenBeforeItExpires() + { + AtomicLong now = new AtomicLong(1_000L); + WeChatMiniProgramApiClient client = mock(WeChatMiniProgramApiClient.class); + when(client.getAccessToken("app-id", "app-secret")) + .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-1", 7200)) + .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-2", 7200)); + when(client.getUnlimitedQrCode(anyString(), anyString(), anyString())) + .thenReturn(new WeChatMiniProgramApiClient.QrCodeImage(new byte[]{1, 2, 3}, "image/png")); + + WeChatMiniProgramQrCodeService service = new WeChatMiniProgramQrCodeService( + client, "app-id", "app-secret", now::get); + service.generateOutdoorFairQrCode(1L); + now.addAndGet(7_000_000L); + service.generateOutdoorFairQrCode(2L); + + verify(client, times(2)).getAccessToken("app-id", "app-secret"); + verify(client).getUnlimitedQrCode( + "token-1", "fairId=1", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + verify(client).getUnlimitedQrCode( + "token-2", "fairId=2", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + } + + @Test + void rejectsMissingConfigurationWithoutCallingWechat() + { + WeChatMiniProgramApiClient client = mock(WeChatMiniProgramApiClient.class); + WeChatMiniProgramQrCodeService service = new WeChatMiniProgramQrCodeService( + client, "", "", () -> 1_000L); + + ServiceException exception = assertThrows( + ServiceException.class, () -> service.generateOutdoorFairQrCode(1L)); + + assertEquals("微信小程序配置不完整,请联系管理员", exception.getMessage()); + } +} diff --git a/sql/migration_outdoor_fair_mini_program_qrcode_permission.sql b/sql/migration_outdoor_fair_mini_program_qrcode_permission.sql new file mode 100644 index 0000000..ded59b3 --- /dev/null +++ b/sql/migration_outdoor_fair_mini_program_qrcode_permission.sql @@ -0,0 +1,36 @@ +-- 户外招聘会微信小程序码按钮权限 +-- 瀚高数据库 / PostgreSQL 兼容;可重复执行 + +INSERT INTO "sys_menu" ( + "menu_id", "menu_name", "parent_id", "order_num", "path", "component", "query", "route_name", + "is_frame", "is_cache", "menu_type", "visible", "status", "perms", "icon", + "create_by", "create_time", "update_by", "update_time", "remark" +) +SELECT + (SELECT COALESCE(MAX("menu_id"), 0) + 1 FROM "sys_menu"), + '招聘会小程序码', + list_menu."parent_id", + 3, + '', '', '', '', + 1, 0, 'F', '0', '0', 'cms:outdoorFair:qrcode', '#', + 'system', CURRENT_TIMESTAMP, '', NULL, '户外招聘会列表-查看微信小程序码' +FROM "sys_menu" list_menu +WHERE list_menu."perms" = 'cms:outdoorFair:list' + AND NOT EXISTS ( + SELECT 1 FROM "sys_menu" WHERE "perms" = 'cms:outdoorFair:qrcode' + ) +LIMIT 1; + +-- 仅授予超级管理员;其他后台角色需要时可由管理员显式分配。 +INSERT INTO "sys_role_menu" ("role_id", "menu_id") +SELECT role."role_id", menu."menu_id" +FROM "sys_role" role +CROSS JOIN "sys_menu" menu +WHERE role."role_key" = 'admin' + AND menu."perms" = 'cms:outdoorFair:qrcode' + AND NOT EXISTS ( + SELECT 1 + FROM "sys_role_menu" role_menu + WHERE role_menu."role_id" = role."role_id" + AND role_menu."menu_id" = menu."menu_id" + );