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;
}
}
}