feat: 完善户外招聘会签到详情及小程序码

This commit is contained in:
2026-07-23 15:57:37 +08:00
parent cd80052eee
commit 397e36144a
8 changed files with 671 additions and 0 deletions

View File

@@ -194,6 +194,7 @@ public class AppOutdoorFairController extends BaseController
}
Map<String, Object> 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<OutdoorFairAttendee>()
.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<String, Object> buildCompanyPage(Long fairId, Long companyId, OutdoorFairDevice device,
HttpServletRequest request)
{
@@ -396,6 +413,64 @@ public class AppOutdoorFairController extends BaseController
return publicFair;
}
/**
* 求职者端只展示审核通过且当前有效的参会企业,不返回联系人、手机号等非必要信息。
*/
private List<Map<String, Object>> buildPublicParticipatingCompanies(Long fairId)
{
List<FairCompany> relations = fairCompanyMapper.selectList(new LambdaQueryWrapper<FairCompany>()
.eq(FairCompany::getJobFairId, fairId)
.eq(FairCompany::getReviewStatus, "1")
.orderByDesc(FairCompany::getId));
if (relations.isEmpty()) {
return new ArrayList<>();
}
List<Long> companyIds = relations.stream()
.map(FairCompany::getCompanyId)
.filter(item -> item != null)
.distinct()
.collect(Collectors.toList());
if (companyIds.isEmpty()) {
return new ArrayList<>();
}
Map<Long, Company> companyMap = companyMapper.selectBatchIds(companyIds).stream()
.filter(company -> company.getCompanyId() != null)
.collect(Collectors.toMap(Company::getCompanyId, item -> item, (a, b) -> a));
Map<Long, OutdoorFairBoothBooking> bookingMap = outdoorFairBoothBookingMapper.selectList(
new LambdaQueryWrapper<OutdoorFairBoothBooking>()
.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<Long, Long> 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<Map<String, Object>> 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<String, Object> 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 {

View File

@@ -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));
}
/**
* 企业只读查看当前招聘会的展位图。
* 仅当前企业已报名且审核通过时返回数据;企业端没有任何展位图写入入口。

View File

@@ -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;
/**
* 微信小程序服务端接口客户端。
*
* <p>该客户端只负责单次远程调用,不记录 appSecret、access token 或微信响应中的敏感内容。</p>
*/
@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<String, Object> 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;
}
}
}

View File

@@ -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<Long, String> 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<String, Object> 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<String, Object> 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;
}
}
}

View File

@@ -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<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
List<Map<String, Object>> companies = (List<Map<String, Object>>) 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;
}
}

View File

@@ -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
{

View File

@@ -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<String, Object> first = service.generateOutdoorFairQrCode(12L);
Map<String, Object> 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());
}
}

View File

@@ -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"
);