feat: add job seeker notice settings API

This commit is contained in:
2026-07-22 20:29:59 +08:00
parent 0dc3ff96fc
commit c8c70652ac
13 changed files with 546 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# 移动端消息接收设置接口
## 通用约定
- 基础地址:沿用移动端当前后端地址。
- 请求头:`Authorization: Bearer <移动端求职者登录 Token>`
- 仅移动端**求职者**可调用;未携带或失效 Token 返回 HTTP 401企业及其他角色返回业务码 `403`
- 四个字段均为布尔值,`true` 表示接收该类消息,`false` 表示不接收。保存时必须一次传齐四项。
## 查看设置
`GET /app/notice/settings`
无需请求参数或请求体。首次查看(尚未保存过)会返回四项均开启的默认设置,且不会创建数据库记录。
成功响应示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"jobUpdateEnabled": true,
"appointmentReminderEnabled": true,
"systemNotificationEnabled": true,
"interviewNotificationEnabled": true
}
}
```
字段说明:
| 字段 | 对应按钮 |
| --- | --- |
| `jobUpdateEnabled` | 职位上新 |
| `appointmentReminderEnabled` | 预约提醒 |
| `systemNotificationEnabled` | 系统通知 |
| `interviewNotificationEnabled` | 面试通知 |
## 保存设置
`PUT /app/notice/settings`
请求头 `Content-Type: application/json`,请求体示例:
```json
{
"jobUpdateEnabled": true,
"appointmentReminderEnabled": false,
"systemNotificationEnabled": true,
"interviewNotificationEnabled": false
}
```
成功响应:
```json
{
"code": 200,
"msg": "操作成功"
}
```
接口按登录 Token 自动识别当前用户,不接收也不允许客户端传入用户 ID。任一开关字段缺失或为 `null` 时,返回参数校验错误。

View File

@@ -2,16 +2,27 @@ package com.ruoyi.cms.controller.app;
import com.ruoyi.cms.domain.Job;
import com.ruoyi.cms.domain.Notice;
import com.ruoyi.cms.domain.dto.NoticeSettingRequest;
import com.ruoyi.cms.domain.vo.NoticeSettingVO;
import com.ruoyi.cms.service.IAppNoticeService;
import com.ruoyi.cms.service.IAppUserNoticeSettingService;
import com.ruoyi.common.annotation.BussinessLog;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AppUser;
import com.ruoyi.common.core.domain.model.LoginSiteUser;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.cms.util.StringUtil;
import com.ruoyi.common.utils.SiteSecurityUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import java.util.List;
@RestController
@@ -21,6 +32,35 @@ public class AppNoticeInfoController extends BaseController {
@Autowired
private IAppNoticeService appNoticeService;
@Autowired
private IAppUserNoticeSettingService appUserNoticeSettingService;
@ApiOperation("查看消息接收设置")
@GetMapping("/settings")
public AjaxResult getSettings()
{
Long userId = currentJobSeekerId();
if (userId == null)
{
return AjaxResult.error(HttpStatus.FORBIDDEN, "仅求职者可查看消息接收设置");
}
NoticeSettingVO settings = appUserNoticeSettingService.getSettings(userId);
return AjaxResult.success(settings);
}
@ApiOperation("保存消息接收设置")
@PutMapping("/settings")
@BussinessLog(title = "保存消息接收设置")
public AjaxResult saveSettings(@Validated @RequestBody NoticeSettingRequest request)
{
Long userId = currentJobSeekerId();
if (userId == null)
{
return AjaxResult.error(HttpStatus.FORBIDDEN, "仅求职者可保存消息接收设置");
}
appUserNoticeSettingService.saveSettings(userId, request);
return AjaxResult.success();
}
@ApiOperation("消息列表")
@GetMapping("/info")
public AjaxResult listNotRead(@RequestParam(required = false) Integer isRead)
@@ -60,4 +100,24 @@ public class AppNoticeInfoController extends BaseController {
List<Notice> list = appNoticeService.sysNoticeList();
return getDataTable(list);
}
/**
* 仅允许经移动端 Token 认证的求职者读写自己的设置。
*/
private Long currentJobSeekerId()
{
Authentication authentication = SiteSecurityUtils.getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginSiteUser))
{
return null;
}
LoginSiteUser loginUser = (LoginSiteUser) authentication.getPrincipal();
AppUser appUser = loginUser.getUser();
if (loginUser.getUserId() == null || appUser == null
|| !StringUtil.IS_JOB_REQUEST_USER.equals(appUser.getIsCompanyUser()))
{
return null;
}
return loginUser.getUserId();
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.cms.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
import lombok.Data;
/**
* 移动端求职者消息接收设置。
*/
@Data
@ApiModel("移动端求职者消息接收设置")
@TableName("app_user_notice_setting")
public class AppUserNoticeSetting extends BaseEntity
{
@TableId(type = IdType.AUTO)
private Long id;
/** APP 用户 ID每名用户仅有一条设置。 */
private Long userId;
/** 职位上新。 */
private Boolean jobUpdateEnabled;
/** 招聘会预约提醒。 */
private Boolean appointmentReminderEnabled;
/** 平台系统通知。 */
private Boolean systemNotificationEnabled;
/** 企业面试通知。 */
private Boolean interviewNotificationEnabled;
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.cms.domain.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 移动端消息接收设置保存请求。
*/
@Data
@ApiModel("移动端消息接收设置保存请求")
public class NoticeSettingRequest
{
@NotNull(message = "职位上新开关不能为空")
@ApiModelProperty(value = "是否接收职位上新通知", required = true, example = "true")
private Boolean jobUpdateEnabled;
@NotNull(message = "预约提醒开关不能为空")
@ApiModelProperty(value = "是否接收招聘会预约提醒", required = true, example = "true")
private Boolean appointmentReminderEnabled;
@NotNull(message = "系统通知开关不能为空")
@ApiModelProperty(value = "是否接收系统通知", required = true, example = "true")
private Boolean systemNotificationEnabled;
@NotNull(message = "面试通知开关不能为空")
@ApiModelProperty(value = "是否接收面试通知", required = true, example = "true")
private Boolean interviewNotificationEnabled;
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.cms.domain.vo;
import com.ruoyi.cms.domain.AppUserNoticeSetting;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 移动端消息接收设置返回对象。
*/
@Data
@ApiModel("移动端消息接收设置")
public class NoticeSettingVO
{
@ApiModelProperty(value = "是否接收职位上新通知", example = "true")
private Boolean jobUpdateEnabled;
@ApiModelProperty(value = "是否接收招聘会预约提醒", example = "true")
private Boolean appointmentReminderEnabled;
@ApiModelProperty(value = "是否接收系统通知", example = "true")
private Boolean systemNotificationEnabled;
@ApiModelProperty(value = "是否接收面试通知", example = "true")
private Boolean interviewNotificationEnabled;
public static NoticeSettingVO from(AppUserNoticeSetting setting)
{
NoticeSettingVO result = new NoticeSettingVO();
result.setJobUpdateEnabled(setting.getJobUpdateEnabled());
result.setAppointmentReminderEnabled(setting.getAppointmentReminderEnabled());
result.setSystemNotificationEnabled(setting.getSystemNotificationEnabled());
result.setInterviewNotificationEnabled(setting.getInterviewNotificationEnabled());
return result;
}
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.cms.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.cms.domain.AppUserNoticeSetting;
/**
* 移动端用户消息接收设置 Mapper。
*/
public interface AppUserNoticeSettingMapper extends BaseMapper<AppUserNoticeSetting>
{
/**
* 保存设置。user_id 唯一约束保证并发首次保存时只会保留一条记录。
*/
int upsert(AppUserNoticeSetting setting);
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.cms.service;
import com.ruoyi.cms.domain.dto.NoticeSettingRequest;
import com.ruoyi.cms.domain.vo.NoticeSettingVO;
/**
* 移动端求职者消息接收设置服务。
*/
public interface IAppUserNoticeSettingService
{
/**
* 查询用户设置;尚未保存过时返回四项均开启的默认设置。
*/
NoticeSettingVO getSettings(Long userId);
/**
* 保存用户的完整四项设置。
*/
void saveSettings(Long userId, NoticeSettingRequest request);
}

View File

@@ -0,0 +1,62 @@
package com.ruoyi.cms.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.cms.domain.AppUserNoticeSetting;
import com.ruoyi.cms.domain.dto.NoticeSettingRequest;
import com.ruoyi.cms.domain.vo.NoticeSettingVO;
import com.ruoyi.cms.mapper.AppUserNoticeSettingMapper;
import com.ruoyi.cms.service.IAppUserNoticeSettingService;
import org.springframework.stereotype.Service;
/**
* 移动端求职者消息接收设置服务实现。
*/
@Service
public class AppUserNoticeSettingServiceImpl
extends ServiceImpl<AppUserNoticeSettingMapper, AppUserNoticeSetting>
implements IAppUserNoticeSettingService
{
private final AppUserNoticeSettingMapper noticeSettingMapper;
public AppUserNoticeSettingServiceImpl(AppUserNoticeSettingMapper noticeSettingMapper)
{
this.noticeSettingMapper = noticeSettingMapper;
}
@Override
public NoticeSettingVO getSettings(Long userId)
{
AppUserNoticeSetting setting = noticeSettingMapper.selectOne(
Wrappers.<AppUserNoticeSetting>lambdaQuery()
.eq(AppUserNoticeSetting::getUserId, userId));
if (setting == null)
{
setting = defaultSetting(userId);
}
return NoticeSettingVO.from(setting);
}
@Override
public void saveSettings(Long userId, NoticeSettingRequest request)
{
AppUserNoticeSetting setting = new AppUserNoticeSetting();
setting.setUserId(userId);
setting.setJobUpdateEnabled(request.getJobUpdateEnabled());
setting.setAppointmentReminderEnabled(request.getAppointmentReminderEnabled());
setting.setSystemNotificationEnabled(request.getSystemNotificationEnabled());
setting.setInterviewNotificationEnabled(request.getInterviewNotificationEnabled());
noticeSettingMapper.upsert(setting);
}
private AppUserNoticeSetting defaultSetting(Long userId)
{
AppUserNoticeSetting setting = new AppUserNoticeSetting();
setting.setUserId(userId);
setting.setJobUpdateEnabled(Boolean.TRUE);
setting.setAppointmentReminderEnabled(Boolean.TRUE);
setting.setSystemNotificationEnabled(Boolean.TRUE);
setting.setInterviewNotificationEnabled(Boolean.TRUE);
return setting;
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.cms.mapper.AppUserNoticeSettingMapper">
<insert id="upsert" parameterType="AppUserNoticeSetting">
INSERT INTO app_user_notice_setting
(user_id, job_update_enabled, appointment_reminder_enabled,
system_notification_enabled, interview_notification_enabled,
create_time, update_time, del_flag)
VALUES
(#{userId}, #{jobUpdateEnabled}, #{appointmentReminderEnabled},
#{systemNotificationEnabled}, #{interviewNotificationEnabled},
NOW(), NOW(), '0')
ON CONFLICT (user_id) DO UPDATE
SET job_update_enabled = EXCLUDED.job_update_enabled,
appointment_reminder_enabled = EXCLUDED.appointment_reminder_enabled,
system_notification_enabled = EXCLUDED.system_notification_enabled,
interview_notification_enabled = EXCLUDED.interview_notification_enabled,
update_time = NOW(),
del_flag = '0'
</insert>
</mapper>

View File

@@ -0,0 +1,89 @@
package com.ruoyi.cms.controller.app;
import com.ruoyi.cms.domain.dto.NoticeSettingRequest;
import com.ruoyi.cms.domain.vo.NoticeSettingVO;
import com.ruoyi.cms.service.IAppUserNoticeSettingService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.AppUser;
import com.ruoyi.common.core.domain.model.LoginSiteUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.lang.reflect.Field;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
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 AppNoticeInfoControllerTest
{
@AfterEach
void clearSecurityContext()
{
SecurityContextHolder.clearContext();
}
@Test
void jobSeekerCanReadOwnSettings() throws Exception
{
IAppUserNoticeSettingService service = mock(IAppUserNoticeSettingService.class);
NoticeSettingVO expected = new NoticeSettingVO();
expected.setJobUpdateEnabled(true);
expected.setAppointmentReminderEnabled(true);
expected.setSystemNotificationEnabled(false);
expected.setInterviewNotificationEnabled(true);
when(service.getSettings(12L)).thenReturn(expected);
authenticateSiteUser(12L, "1");
AppNoticeInfoController controller = controllerWith(service);
AjaxResult result = controller.getSettings();
assertEquals(200, result.get(AjaxResult.CODE_TAG));
assertEquals(expected, result.get(AjaxResult.DATA_TAG));
verify(service).getSettings(12L);
}
@Test
void nonJobSeekerCannotSaveSettings() throws Exception
{
IAppUserNoticeSettingService service = mock(IAppUserNoticeSettingService.class);
authenticateSiteUser(12L, "0");
NoticeSettingRequest request = new NoticeSettingRequest();
request.setJobUpdateEnabled(true);
request.setAppointmentReminderEnabled(true);
request.setSystemNotificationEnabled(true);
request.setInterviewNotificationEnabled(true);
AppNoticeInfoController controller = controllerWith(service);
AjaxResult result = controller.saveSettings(request);
assertEquals(403, result.get(AjaxResult.CODE_TAG));
verify(service, never()).saveSettings(eq(12L), any(NoticeSettingRequest.class));
}
private AppNoticeInfoController controllerWith(IAppUserNoticeSettingService service) throws Exception
{
AppNoticeInfoController controller = new AppNoticeInfoController();
Field field = AppNoticeInfoController.class.getDeclaredField("appUserNoticeSettingService");
field.setAccessible(true);
field.set(controller, service);
return controller;
}
private void authenticateSiteUser(Long userId, String userType)
{
AppUser appUser = new AppUser();
appUser.setIsCompanyUser(userType);
LoginSiteUser loginUser = new LoginSiteUser(userId, appUser);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
loginUser, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

View File

@@ -0,0 +1,77 @@
package com.ruoyi.cms.service.impl;
import com.ruoyi.cms.domain.AppUserNoticeSetting;
import com.ruoyi.cms.domain.dto.NoticeSettingRequest;
import com.ruoyi.cms.domain.vo.NoticeSettingVO;
import com.ruoyi.cms.mapper.AppUserNoticeSettingMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.verify;
import static org.mockito.Mockito.when;
class AppUserNoticeSettingServiceImplTest
{
@Test
void returnsAllEnabledDefaultsWhenUserHasNotSavedSettings()
{
AppUserNoticeSettingMapper mapper = mock(AppUserNoticeSettingMapper.class);
when(mapper.selectOne(any())).thenReturn(null);
AppUserNoticeSettingServiceImpl service = new AppUserNoticeSettingServiceImpl(mapper);
NoticeSettingVO result = service.getSettings(12L);
assertTrue(result.getJobUpdateEnabled());
assertTrue(result.getAppointmentReminderEnabled());
assertTrue(result.getSystemNotificationEnabled());
assertTrue(result.getInterviewNotificationEnabled());
}
@Test
void returnsPersistedSettingsForCurrentUser()
{
AppUserNoticeSettingMapper mapper = mock(AppUserNoticeSettingMapper.class);
AppUserNoticeSetting setting = new AppUserNoticeSetting();
setting.setJobUpdateEnabled(false);
setting.setAppointmentReminderEnabled(true);
setting.setSystemNotificationEnabled(false);
setting.setInterviewNotificationEnabled(true);
when(mapper.selectOne(any())).thenReturn(setting);
AppUserNoticeSettingServiceImpl service = new AppUserNoticeSettingServiceImpl(mapper);
NoticeSettingVO result = service.getSettings(12L);
assertFalse(result.getJobUpdateEnabled());
assertTrue(result.getAppointmentReminderEnabled());
assertFalse(result.getSystemNotificationEnabled());
assertTrue(result.getInterviewNotificationEnabled());
}
@Test
void savesAllFourSettingsForTheAuthenticatedUser()
{
AppUserNoticeSettingMapper mapper = mock(AppUserNoticeSettingMapper.class);
AppUserNoticeSettingServiceImpl service = new AppUserNoticeSettingServiceImpl(mapper);
NoticeSettingRequest request = new NoticeSettingRequest();
request.setJobUpdateEnabled(true);
request.setAppointmentReminderEnabled(false);
request.setSystemNotificationEnabled(true);
request.setInterviewNotificationEnabled(false);
service.saveSettings(12L, request);
ArgumentCaptor<AppUserNoticeSetting> captor = ArgumentCaptor.forClass(AppUserNoticeSetting.class);
verify(mapper).upsert(captor.capture());
AppUserNoticeSetting saved = captor.getValue();
assertEquals(12L, saved.getUserId());
assertTrue(saved.getJobUpdateEnabled());
assertFalse(saved.getAppointmentReminderEnabled());
assertTrue(saved.getSystemNotificationEnabled());
assertFalse(saved.getInterviewNotificationEnabled());
}
}

View File

@@ -118,6 +118,9 @@ public class SecurityConfig
"/cms/policyInfo/detail","/cms/policyInfo/list","/cms/industry/treeselect").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
// 移动端消息接收设置包含用户个人偏好,必须使用站点 Token 访问。
// 该规则需位于 /app/** 的公共查询规则之前。
.antMatchers("/app/notice/settings").authenticated()
// 移动端公用查询,可匿名访问
.antMatchers("/app/common/**").permitAll()
.antMatchers("/app/**").permitAll()

View File

@@ -0,0 +1,30 @@
-- 移动端求职者消息接收设置。
-- 瀚高 / PostgreSQL 兼容;重复执行安全。表放在 shz schema应用通过 search_path 访问。
CREATE SEQUENCE IF NOT EXISTS shz.app_user_notice_setting_id_seq
START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
CREATE TABLE IF NOT EXISTS shz.app_user_notice_setting (
id BIGINT NOT NULL DEFAULT nextval('shz.app_user_notice_setting_id_seq'::regclass) PRIMARY KEY,
user_id BIGINT NOT NULL,
job_update_enabled BOOLEAN NOT NULL DEFAULT TRUE,
appointment_reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
system_notification_enabled BOOLEAN NOT NULL DEFAULT TRUE,
interview_notification_enabled BOOLEAN NOT NULL DEFAULT TRUE,
create_by VARCHAR(64) DEFAULT '',
create_time TIMESTAMP(0),
update_by VARCHAR(64) DEFAULT '',
update_time TIMESTAMP(0),
remark VARCHAR(500),
del_flag CHAR(1) DEFAULT '0'
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_app_user_notice_setting_user_id
ON shz.app_user_notice_setting (user_id);
COMMENT ON TABLE shz.app_user_notice_setting IS '移动端求职者消息接收设置';
COMMENT ON COLUMN shz.app_user_notice_setting.user_id IS 'APP 用户 ID每用户唯一';
COMMENT ON COLUMN shz.app_user_notice_setting.job_update_enabled IS '是否接收职位上新通知';
COMMENT ON COLUMN shz.app_user_notice_setting.appointment_reminder_enabled IS '是否接收招聘会预约提醒';
COMMENT ON COLUMN shz.app_user_notice_setting.system_notification_enabled IS '是否接收系统通知';
COMMENT ON COLUMN shz.app_user_notice_setting.interview_notification_enabled IS '是否接收面试通知';