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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user