feat: 完善户外招聘会签到详情及小程序码
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业只读查看当前招聘会的展位图。
|
||||
* 仅当前企业已报名且审核通过时返回数据;企业端没有任何展位图写入入口。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
Reference in New Issue
Block a user