diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
index 8f32945..fe7c534 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
@@ -12,6 +12,7 @@ import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
+import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
@@ -240,6 +241,19 @@ public class UserController {
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
}
+ /**
+ * 本人联系方式修改(手机号/邮箱):body {userId, phone, email},phone/email 至少一项非空,
+ * 空白=不修改该字段;只更新 sys_user 的 phone/email 两字段(其余一律不动),
+ * 手机号/邮箱格式与长度校验、手机号唯一查重在 Service 层。返回更新后的用户
+ * (getUserInfoWithDeptById,事务提交后缓存已被 UserChangedEvent 驱逐,回读即新值)。
+ * 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
+ */
+ @PostMapping("self/contact")
+ public BoaUser selfContact(@RequestBody @Validated SysUserContactRequest request) {
+ sysUserService.updateSelfContact(request.getUserId(), request.getPhone(), request.getEmail());
+ return userCenterService.getUserInfoWithDeptById(request.getUserId());
+ }
+
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。
diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserContactRequest.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserContactRequest.java
new file mode 100644
index 0000000..2c66432
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserContactRequest.java
@@ -0,0 +1,35 @@
+package com.yunhai.userbase.model.sysuser;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+/**
+ * 本人联系方式修改请求(oapi {@code /oapi/user/self/contact} 专用)。
+ * 手机号/邮箱至少一项非空(Service 层校验);字段级格式、长度与唯一性校验同在 Service 层
+ * (手机号口径与 {@code validateProfile} 一致,另做邮箱格式 + 手机号查重)。
+ *
+ * @author chenx
+ */
+@Data
+public class SysUserContactRequest {
+
+ /**
+ * 用户ID(必填)。
+ */
+ @NotNull
+ private Integer userId;
+
+ /**
+ * 手机号(可选;非空时走 1[3-9] 段位格式校验 + 全库唯一查重)。
+ */
+ @Size(max = 20, message = "手机号长度不能超过20字符")
+ private String phone;
+
+ /**
+ * 邮箱(可选;非空时走邮箱格式校验)。
+ */
+ @Size(max = 100, message = "邮箱长度不能超过100字符")
+ private String email;
+}
diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java
index 77cb6cf..9774880 100644
--- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java
@@ -218,6 +218,19 @@ public interface ISysUserService {
*/
void changePassword(Integer userId, String oldPassword, String newPassword);
+ /**
+ * 修改本人联系方式(手机号/邮箱,oapi 开放接口 {@code /oapi/user/self/contact} 专用)。
+ * 只写 sys_user 的 phone/email 两字段,其余字段一律不动;phone/email 至少一项非空,
+ * 空白字段视为「不修改该字段」(不写库不清空);手机号改值时做全库唯一查重(phone 是
+ * byPhone 身份维度,重复会破坏手机号找人)。更新后发 UserChangedEvent 精确驱逐 boaUser
+ * 缓存(含旧 phone: 维度 key 提示),调用方紧跟着的 byId 回读即新值。
+ *
+ * @param userId 用户ID(目标用户本人)
+ * @param phone 新手机号(可空=不改)
+ * @param email 新邮箱(可空=不改)
+ */
+ void updateSelfContact(Integer userId, String phone, String email);
+
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
diff --git a/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java b/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java
index c39f649..6c0046d 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java
@@ -92,8 +92,10 @@ public class BoaUserLoader {
boa.setIdcard(user.getIdcard());
boa.setPhone(user.getPhone());
boa.setProfilePicture(user.getProfilePicture());
- // 用户表无 email / gender 列,置 null
- boa.setEmail(null);
+ // email 列已存在(SysUser.email,管理端编辑/批量导入均已读写):
+ // 本人联系方式修改(oapi self/contact)后回读 BoaUser 须携带新 email,旧注释「用户表无 email 列」已过时;
+ // gender 列虽在实体上,但当前无写入来源,保持置 null
+ boa.setEmail(user.getEmail());
boa.setGender(null);
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
index cb76044..17fd987 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
@@ -96,6 +96,10 @@ public class SysUserService implements ISysUserService {
private static final Set SUPER_ADMIN_ACCOUNTS =
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
+ /** 邮箱格式(本人联系方式修改用):本地段常规字符 + 点分域名,总长另由请求 @Size 限制 */
+ private static final java.util.regex.Pattern CONTACT_EMAIL =
+ java.util.regex.Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$");
+
@Autowired
private SysUserRepository sysUserRepository;
@@ -289,6 +293,52 @@ public class SysUserService implements ISysUserService {
sysUserRepository.save(entity);
}
+ /**
+ * 修改本人联系方式(oapi /oapi/user/self/contact):只写 phone/email 两字段,其余不动。
+ * 空白入参=「不修改该字段」(不清空——清空语义不在本接口范围);手机号是 byPhone 身份维度,
+ * 改值前全库查重;更新后发 UPDATE_USER 推送 + UserChangedEvent(带旧 phone: key 提示)驱逐
+ * boaUser 缓存,控制器紧跟着的 getUserInfoWithDeptById 回读即新值(AFTER_COMMIT 后于回读执行)。
+ */
+ @Override
+ @Transactional
+ public void updateSelfContact(Integer userId, String phone, String email) {
+ SysUser entity = sysUserRepository.findById(userId)
+ .orElseThrow(() -> new DException("系统用户不存在"));
+ String newPhone = StringUtils.trimToNull(phone);
+ String newEmail = StringUtils.trimToNull(email);
+ if (newPhone == null && newEmail == null) {
+ throw new DException("手机号与邮箱至少填写一项");
+ }
+ if (newPhone != null && !ValidatorUtil.isPhone(newPhone)) {
+ throw new DException("手机号格式不正确");
+ }
+ if (newEmail != null && !CONTACT_EMAIL.matcher(newEmail).matches()) {
+ throw new DException("邮箱格式不正确");
+ }
+ if (newPhone != null && !newPhone.equals(entity.getPhone())) {
+ SysUser samePhone = sysUserRepository.findByPhone(newPhone);
+ if (samePhone != null && !samePhone.getId().equals(userId)) {
+ throw new DException("手机号已被其他用户使用");
+ }
+ }
+ // 改手机号后旧 phone: 维度缓存 key 无处回查,须随事件显式驱逐(同 editInternal 口径);
+ // 仅改邮箱时手机号未变,不加提示(避免无谓多驱逐一个 key)
+ Set staleKeyHints = new HashSet<>();
+ if (newPhone != null && StringUtils.isNotBlank(entity.getPhone())
+ && !entity.getPhone().equals(newPhone)) {
+ staleKeyHints.add("phone:" + entity.getPhone());
+ }
+ if (newPhone != null) {
+ entity.setPhone(newPhone);
+ }
+ if (newEmail != null) {
+ entity.setEmail(newEmail);
+ }
+ entity.setUpdateUser(SYSTEM_USER_ID);
+ sysUserRepository.save(entity);
+ publishUserEvent(PushInterfaceType.UPDATE_USER, userId, staleKeyHints);
+ }
+
/**
* 开放接口的用户身份写入(追加语义,只加不删):
* 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);