diff --git a/ruoyi-admin/src/main/resources/static/agreement/privacy-policy.html b/ruoyi-admin/src/main/resources/static/agreement/privacy-policy.html new file mode 100644 index 0000000..e6c09dd --- /dev/null +++ b/ruoyi-admin/src/main/resources/static/agreement/privacy-policy.html @@ -0,0 +1,53 @@ + + + + + + + JUMHABI 隐私政策 + + + +
JUMHABI · PRIVACY CENTER

JUMHABI 隐私政策

我们重视您的个人信息和隐私。本政策以清晰、易懂的方式说明 JUMHABI 可能如何处理个人信息,以及您可以如何行使相关权利。

版本:V1.0生效日期:2026年10月1日适用平台:Android
+
+ +
+
《隐私政策说明》

本政策已根据当前提供的问题解答、AndroidManifest.xml 和 Gradle 依赖进行修订。第三方服务的具体数据字段、服务协议及部分后端安全配置仍应在上线前由研发和服务商资料最终核验。

  1. 核验地图、短信、视频及海外云服务的准确服务商名称、处理数据字段和跨境处理安排。
  2. 核验服务端数据永久保存的必要性,以及备份、删除和访问控制措施。
  3. 核验录音、安装包请求、通知、图片/存储权限是否由应用实际调用及其对应功能。
+ +

一、引言

本隐私政策适用于 JUMHABI Android 应用(以下简称“本 App”)。本政策中的“我们”指深圳市聚心时代科技有限公司(个人信息处理者);“您”指本 App 用户。

在使用具体功能前,我们会按照必要、透明、最小化原则告知处理目的、方式和范围,并在依法需要时取得您的同意。您可以选择不提供非必要信息,但可能无法使用相应功能。

+ +

二、我们如何收集和使用您的个人信息

1. 注册、登录与身份验证

本 App 支持手机号和邮箱注册/登录。为创建和管理账号、验证登录及保障账号安全,我们处理您的手机号、邮箱、验证码、账号 ID 和登录 token。短信验证码由阿里云短信服务提供;邮箱验证码服务商以实际配置为准。登录设备信息、IP 地址、登录时间和操作日志是否由服务端记录及其保存期限,以服务端实际配置为准。

2. 设备绑定与管理

您可扫描设备二维码绑定设备。扫码内容主要包括设备编码、生产批次等设备信息,用于识别设备并建立账号与设备的绑定关系。扫码使用手机摄像头;您也可以通过相册选择二维码图片(如该入口可用)。

3. 远程控制与设备数据展示

您发起控制操作时,服务端会根据账号和设备绑定关系处理相应请求。设备产生的设备状态、运行数据及设备位置等信息由设备提供给服务端,本 App 从服务端获取并向您展示。我们不通过本 App 采集手机硬件信息并上传至服务器。

4. 个人中心

本 App 保存和展示的账号资料包括手机号、邮箱、昵称、头像、用户 ID 和注册时间。您可以在 App 内修改绑定手机号、邮箱及个人密码;按目前提供的信息,修改时不要求额外身份认证。

+ +

三、我们如何处理设备信息

为展示和管理您绑定的设备,本 App 从服务端读取由硬件设备提供的设备编码、生产批次、设备状态、运行数据及设备位置信息等(具体展示字段以设备和服务端实际提供为准)。这些信息可能与您的账号和绑定关系关联,用于设备展示、状态判断、远程控制和服务支持。本 App 不采集手机硬件信息上传至服务器。设备信息在服务端的保存期限见第七条。

+ +

四、实时视频功能及相关信息处理

本 App 可查看已绑定设备的实时预览画面,视频由设备端产生。本 App 仅从服务端拉取视频流进行播放,不通过手机推送视频流;按目前提供的信息,本 App 不在本地录制或保存视频。Android 工程引入了 GSYVideoPlayer 播放器依赖。设备视频流在设备、服务端和 App 之间的完整传输路径、服务端是否中转或留存及其供应商,应以实际部署为准。

Manifest 声明了手机摄像头和麦克风权限。摄像头用于扫描设备二维码;目前提供的信息未说明 App 使用手机麦克风采集音频。拒绝摄像头权限将影响扫码绑定。我们会在相关功能调用权限前说明用途。

+ +

五、位置与地图服务

当您打开地图查看设备所在地址时,本 App 会在地图上展示设备位置,并展示您手机当前的位置。为提供该功能,App 声明并可能使用 Android 精确定位权限(ACCESS_FINE_LOCATION);您可以通过系统权限设置拒绝或撤回定位授权,届时手机当前位置功能将不可用,但不影响无需定位的其他功能。设备位置由硬件设备提供给服务端,不等同于手机定位。

当前使用高德地图海外定位服务。使用地图及定位功能时,手机当前位置信息和/或用于地址展示的设备经纬度可能按功能需要提供给该服务进行地图展示或定位处理。具体传输字段、服务主体名称、存储期限和跨境处理安排应以实际接入配置及服务条款核验。

+ +

六、账号注册、登录与安全

账号密码用于账号安全。App 与服务端之间使用 HTTPS 传输。我们不会在本政策中披露密钥、服务器配置等敏感安全细节;服务端访问控制、密码保存方式及 token 的有效期和失效机制按实际安全配置执行。

您可以在 App 内修改个人密码、绑定手机号和邮箱,也可以退出登录。退出登录时,本地缓存会清理,登录 token 等本地账号信息会按应用实际退出流程清除。

+ +

七、个人信息的存储与保护

本地保存必要的用户基础信息,包括昵称、登录账号、登录方式和 token 等;退出登录时清理缓存。按目前提供的信息,服务端数据存储在阿里云海外云服务器并永久保存,直至您注销账号或依法需要删除。永久保存安排及其必要性应持续评估,并落实可执行的数据删除机制。

App 与服务端之间使用 HTTPS 传输。我们将采取与风险相适应的访问控制、最小权限和安全保护措施。备份策略、服务端访问权限及具体加密措施以实际架构和运维配置为准。发现个人信息泄露等安全事件时,我们将依法采取补救并按规定告知。

+ +

八、共享、委托处理与第三方服务

我们不会在没有必要性或法律依据的情况下出售您的个人信息。为提供地图、短信验证、视频播放和云服务器等服务,相关信息可能由对应服务商根据服务目的处理;我们会依法通过协议等方式约束受托方的处理目的和安全义务。

第三方 SDK/服务清单

SDK/服务服务商/组件用途可能涉及的数据类型处理方式
高德地图海外定位服务高德地图服务(具体海外服务主体待接入资料核验)展示地图、手机当前位置及设备地址手机当前位置、设备经纬度(按具体功能需要)由 App 按功能调用地图/定位服务;具体字段和跨境路径以接入配置为准
阿里云短信服务阿里云发送手机号验证码手机号、验证码发送请求及必要服务日志通过短信服务发送验证码
阿里云海外云服务器阿里云(具体地域待配置核验)提供服务端及存储账号资料、设备绑定信息、设备数据及服务运行所需信息服务端存储和处理
扫码组件MNMLKitScanner、Google ML Kit Barcode Scanning、AndroidX CameraX扫描设备二维码摄像头实时画面;二维码中的设备编码、生产批次等信息扫码识别;是否有组件向其供应商发送数据需以 SDK 实际行为和配置核验
视频播放器GSYVideoPlayer播放服务端下发的设备视频流视频流地址、播放请求及必要网络信息App 从服务端拉流播放;App 不推流、不在本地录制保存视频

Gradle 文件还包含 Retrofit、OkHttp、Gson、Luban、AndroidX 和界面组件等通用依赖。仅凭依赖声明不能确认这些组件是否会收集或向第三方传输个人信息;我们会根据实际功能调用和 SDK 行为核验并在需要时补充披露。

+ +

九、您的个人信息权利

在符合法律规定的前提下,您享有知情、决定、查阅、复制、更正、删除、撤回同意、注销账号及要求解释说明个人信息处理规则等权利。您可以在 App 内修改手机号、邮箱和个人密码;其他个人信息请求可通过 App 设置中的联系方式或本政策列明的客服邮箱提交。我们可能为核实请求而进行必要身份确认。对于无法满足的请求,我们会说明理由。

+ +

十、用户账号注销

您可以在 App 个人中心发起账号注销。申请前需先取消所有已绑定设备;在您确认注销后,服务端将在数据库删除该用户信息。注销后账号及其关联服务将无法继续使用。备份数据的清除时间、依法必须保留或争议处理所需信息的范围和期限,以实际数据删除及备份机制为准;如需保留,我们会依法限制其用途并在保存期限届满后删除或匿名化。

+ +

十一、未成年人保护

本 App 可以面向未成年人。未成年人使用本 App 或提供个人信息,应在监护人指导下阅读本政策,并在法律要求时取得监护人同意。监护人如对未成年人个人信息处理有疑问或希望行使相关权利,可通过下方联系方式联系我们,我们将依法处理。

+ +

十二、隐私政策更新与联系我们

我们可能根据业务、法律法规或功能变化更新本政策。重大变更将通过 App 弹窗、页面提示或其他适当方式通知,并在页面标注最新版本和生效日期。您可在 App 的“隐私政策”入口查看当前版本。

个人信息处理者:深圳市聚心时代科技有限公司
联系邮箱:huzuming2@gmail.com
公司地址:深圳市宝安区沙井街道马安山社区马安山鞍胜路2号C栋203
隐私负责人:深圳市聚心时代科技有限公司(联系邮箱同上)

生效日期:2026年10月1日

+ +

上线前核验清单

+
检查项目当前已知情况仍需核验核验内容
Android 权限Manifest 声明网络、请求安装包、通知、摄像头、录音、精确定位、读取图片/旧版外部存储权限是确认运行时申请时机、拒绝影响,以及录音、安装包请求、通知和图片/存储权限的实际功能
第三方 SDKGradle 包含 MNMLKitScanner、Google ML Kit Barcode Scanning、CameraX、GSYVideoPlayer、Retrofit/OkHttp 等是核验 SDK 实际数据收集行为、供应商、隐私链接及是否发生对外传输
地图与定位高德地图海外定位服务;地图展示设备地址及手机当前位置是补充准确服务主体、位置字段流向、海外处理/跨境安排及服务商保存期限
视频从服务端拉流播放;不推流、不本地录制保存;依赖 GSYVideoPlayer是确认流媒体服务商、服务器是否中转/留存及视频流保存期限
服务器与删除阿里云海外云服务器;目前说明为永久保存;注销后服务端数据库删除用户信息是确认云服务器具体地域、永久保存必要性、备份删除时限和法定保留范围
账号与本地数据本地保存昵称、登录账号、登录方式、token;退出登录清缓存是核验 token 失效方式、缓存范围、日志/Crash 字段及保存期限
未成年人App 可以面向未成年人是确认监护人同意及未成年人个人信息保护流程如何在产品中落实
主体与联系方式深圳市聚心时代科技有限公司;邮箱和地址已提供,不留电话否确认邮箱可正常接收和处理权利请求
+ +

上线前隐私合规风险清单

级别风险与原因建议处理
高风险阿里云海外服务器永久保存数据,且地图使用海外定位服务,涉及境外存储/处理及可能的数据跨境合规要求。核实具体国家或地区、数据类别和流向,评估并依法落实个人信息出境告知、单独同意及适用的出境合规机制。
高风险Manifest 声明录音、请求安装包等权限,但现有说明未指出对应业务用途。确认应用是否实际调用;不必要权限应从 Manifest 移除,必要权限应在申请前清楚说明目的。
中风险设备位置、手机当前位置、视频流及服务商的实际字段和流向尚未由代码/服务商资料验证。核验 SDK 和网络数据流,补齐服务主体、处理字段、保存期限及必要的单独同意。
中风险服务端称永久保存,注销后数据库删除,但备份和法定保留规则尚未说明。评估保存期限的必要性,明确注销和备份删除时限,落实删除/匿名化及受限保留机制。
中风险面向未成年人,但当前未提供年龄识别和监护人同意流程。上线前落实未成年人个人信息保护及监护人同意机制。
+

返回顶部 ↑

+
+
+ diff --git a/ruoyi-admin/src/main/resources/static/agreement/user-agreement.html b/ruoyi-admin/src/main/resources/static/agreement/user-agreement.html new file mode 100644 index 0000000..fd4d413 --- /dev/null +++ b/ruoyi-admin/src/main/resources/static/agreement/user-agreement.html @@ -0,0 +1,53 @@ + + + + + + + JUMHABI 用户协议 + + + +
JUMHABI · USER AGREEMENT

JUMHABI 用户协议

本协议说明您使用 JUMHABI Android 应用及相关设备服务时的权利、义务和责任。

版本:V1.0生效日期:2026年10月1日适用平台:Android
+
+ +
+
请在使用前阅读

请您在注册、登录或使用 JUMHABI 前认真阅读本协议及《JUMHABI 隐私政策》。当您点击同意、注册、登录、绑定设备或实际使用本 App 的服务,即表示您已阅读、理解并同意接受本协议。若您不同意本协议,请停止注册或使用相关服务。

+ +

一、协议接受与适用

本协议由深圳市聚心时代科技有限公司(以下简称“我们”)与 JUMHABI App 用户(以下简称“您”)订立,适用于您通过 Android App 使用账号、设备管理、远程控制、实时视频、地图及其他相关功能。

本协议与《JUMHABI 隐私政策》共同构成您使用本服务的规则。隐私政策说明我们如何处理个人信息;如两者对个人信息处理事项有不同表述,以隐私政策为准。

您应当具备与使用本服务相适应的民事行为能力。若您未达到所在地法律规定的相应年龄,应在监护人指导和同意下使用本服务。

+ +

二、账号注册与使用

1. 注册信息

本 App 支持使用手机号或邮箱注册、登录。您应提供真实、准确、完整且有效的信息,并及时更新。账号资料包括手机号、邮箱、昵称、头像、用户 ID 和注册时间等,具体以 App 实际功能为准。

2. 账号安全

您应妥善保管账号、密码、验证码及登录 token,不得将账号出借、出租、出售或以其他方式交由他人使用。因您保管不善、主动披露或使用不当导致的损失,由您自行承担;发现异常登录或安全问题时,请立即通过 App 设置中的联系方式联系我们。

3. 账号操作

您可以在 App 内修改绑定手机号、邮箱和个人密码,也可以退出登录。您应对使用账号进行的操作承担相应责任。账号注销规则见本协议第十一条及隐私政策相关说明。

+ +

三、设备绑定与控制

1. 设备绑定

您可以通过扫描设备二维码绑定硬件设备。二维码主要包含设备编码、生产批次等信息。您只能绑定自己拥有、管理或已获得合法授权使用的设备,不得通过技术手段绕过权限、绑定他人设备或获取未经授权的设备数据。

2. 设备数据

设备状态、运行数据、设备位置和实时视频等内容由硬件设备或服务端提供。设备数据可能受到设备供电、网络连接、服务端状态、地理环境和其他不可控因素影响,我们不保证设备数据始终实时、完整或无误。

3. 远程控制

您发起远程控制前,应确认目标设备、控制内容及操作后果。对于可能影响人身、财产、动物、设施或公共安全的设备,请在现场采取必要的安全措施,不应仅依赖 App 的远程控制结果。因网络延迟、设备故障、错误操作或未经授权使用造成的损失,按法律规定及实际责任承担。

+ +

四、内容与服务使用

本 App 可能提供设备管理、实时视频、地图展示、账号管理和相关技术服务。我们会根据产品运营、维护和安全需要对功能进行调整、暂停或升级,并尽量通过 App 页面或其他适当方式通知。

实时视频由设备端产生,本 App 仅从服务端拉取并播放视频流,不提供手机端推流功能;按目前功能说明,本 App 不在本地录制或保存视频。您应确保观看和使用设备视频符合设备所在地法律及他人隐私、肖像和财产权利。

地图及定位功能用于展示设备地址和手机当前位置。地图、定位结果可能因服务商、网络或定位精度而存在误差,不应作为安全、导航、应急救援或专业决策的唯一依据。

+ +

五、系统权限与第三方服务

为实现扫码、地图定位、图片选择、通知或其他功能,App 可能申请 Android 摄像头、精确定位、麦克风、通知、图片/存储等系统权限。您可以在系统设置中管理权限;拒绝非必要权限通常只会影响对应功能,但拒绝必要权限可能导致相关功能无法使用。

本 App 可能使用地图、短信、云服务器、扫码识别、视频播放及网络通信等第三方服务。第三方服务按照其自身协议和隐私规则提供服务;相关处理方式和信息范围请参阅《JUMHABI 隐私政策》中的第三方服务清单。

+ +

六、用户行为规范

您不得利用本服务从事违反法律法规、侵害他人权益或破坏服务安全的行为,包括但不限于:

  • 未经授权访问、控制、绑定或监视他人设备、账号或视频内容;
  • 上传、传播违法、有害、欺诈、侵权、骚扰或侵犯他人隐私的内容;
  • 反向工程、反编译、破解、抓取、干扰或绕过 App、服务端或设备的安全机制;
  • 利用本服务发送恶意程序、垃圾请求,或实施可能影响网络、设备和其他用户的行为;
  • 将本服务用于法律禁止的用途或用于需要专业许可但未取得许可的活动。

如您违反本条,我们可以根据风险采取提醒、限制功能、暂停服务、解绑设备、注销账号或依法向有关部门报告等措施。

+ +

七、服务安全与中断

我们会采取合理的技术和管理措施维护服务安全,并通过 HTTPS 等方式保护 App 与服务端之间的传输。但互联网、移动网络、设备系统和第三方服务均可能出现故障、攻击或不可预见的中断,我们不承诺服务始终连续、无延迟、无错误或完全安全。

因系统维护、升级、网络故障、设备故障、供应商服务变化、法律要求、不可抗力或其他合理原因导致服务暂时不可用的,我们会在可行范围内进行通知和恢复。

+ +

八、知识产权

JUMHABI App 的软件、界面、文字、图形、标识、页面设计和相关技术成果的知识产权归我们或相关权利人所有。除法律允许或经权利人书面许可外,您不得复制、修改、传播、出租、出售、反向工程或用于本协议约定之外的用途。

您在使用本服务过程中产生或上传的内容,仍由您或相应权利人享有权利。您应确保自己拥有必要授权,并授予我们为提供、维护、改进和保障本服务所必需的使用权限;具体个人信息处理以隐私政策为准。

+ +

九、责任限制

在法律允许的范围内,我们不对因以下原因造成的间接损失、预期利益损失或数据损失承担责任:设备自身缺陷或断电、网络和定位误差、第三方服务中断、用户错误操作、账号泄露、未经授权使用、不可抗力或其他非我们可合理控制的原因。

本条不排除或限制法律规定不得排除或限制的责任。若本 App 提供的具体服务另有单独规则,该规则与本协议不一致的,以单独规则为准。

+ +

十、未成年人保护

本 App 可以面向未成年人。未成年人应在监护人指导下阅读并使用本服务,监护人应监督其账号、设备和个人信息使用情况。若监护人发现未成年人未经适当同意使用本服务或提交个人信息,请通过本协议列明的联系方式联系我们。

+ +

十一、协议终止与账号注销

您可以停止使用本服务,并在 App 个人中心申请注销账号。申请注销前需取消所有已绑定设备;确认注销后,服务端将按实际流程删除用户信息。注销后账号及关联服务可能无法恢复,相关数据删除、备份清理和依法保留事项以隐私政策及实际数据处理规则为准。

如您严重违反本协议、法律法规或危害服务安全,我们可以暂停或终止向您提供全部或部分服务。协议终止后,依其性质应继续有效的条款仍然有效。

+ +

十二、协议更新

我们可能因法律法规、业务变化、产品功能或安全需要更新本协议。重大变更将通过 App 弹窗、页面提示或其他适当方式通知,并在本页面标注最新版本和生效日期。更新后的协议生效后,您继续使用本服务即视为接受更新内容;如您不同意,应停止使用并注销账号。

+ +

十三、联系我们

协议主体:深圳市聚心时代科技有限公司
联系邮箱:huzuming2@gmail.com
公司地址:深圳市宝安区沙井街道马安山社区马安山鞍胜路2号C栋203

如您对本协议、账号安全、设备服务或用户权利有疑问,可通过上述邮箱联系我们。我们将在合理期限内处理您的咨询或请求。

生效日期:2026年10月1日

+ +

返回顶部 ↑

+
+
+ + + diff --git a/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java b/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java index 2f8e262..3ef2af3 100644 --- a/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java +++ b/ruoyi-api/src/main/java/org/dromara/system/api/AppUserApiService.java @@ -58,7 +58,7 @@ public interface AppUserApiService { * * @param phoneNumber 手机号 * @param rawPassword 明文密码 - * @param nickName 昵称(为空时默认取手机号) + * @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳}) * @return 新建用户主键 */ Long registerByPhone(String phoneNumber, String rawPassword, String nickName); @@ -82,6 +82,15 @@ public interface AppUserApiService { */ boolean existsByEmail(String email); + /** + * 更新 APP 用户邮箱;仅更新邮箱字段,昵称保持不变。 + * + * @param userId 用户主键 + * @param newEmail 新邮箱地址(已归一化) + * @return true 修改成功 + */ + boolean updateEmail(Long userId, String newEmail); + /** * 以邮箱注册一个 APP 用户(邮箱注册入口)。 *

@@ -94,7 +103,7 @@ public interface AppUserApiService { * * @param email 邮箱地址(已归一化) * @param rawPassword 明文密码 - * @param nickName 昵称(为空时默认取邮箱) + * @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳}) * @return 新建用户主键 */ Long registerByEmail(String email, String rawPassword, String nickName); @@ -109,7 +118,7 @@ public interface AppUserApiService { boolean resetPassword(Long userId, String rawPassword); /** - * 换绑手机号。 + * 换绑手机号;仅更新手机号字段,昵称保持不变。 * * @param userId 用户主键 * @param newPhoneNumber 新手机号 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java index b0ca069..44beaa3 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppConstants.java @@ -80,6 +80,14 @@ public final class AppConstants { */ public static final String SCENE_BIND = "bind"; + /** + * 场景:换绑前确认原手机号。 + *

+ * 仅登录后的 {@code POST /app/user/phone/old-code} 可发送;公共 {@code /app/auth/sms/code} 拒绝本场景, + * 避免客户端指定任意号码冒充原号通道。与 {@link #SCENE_REBIND}(新号)缓存 key 隔离。 + */ + public static final String SCENE_REBIND_OLD = "rebindOld"; + // ---------------- 协议类型 ---------------- /** @@ -109,6 +117,21 @@ public final class AppConstants { */ public static final String SMS_FAIL_KEY_PREFIX = "app:sms:fail:"; + /** + * 换绑原号核验状态 key 前缀,完整 key = 前缀 + userId。 + *

+ * {@code POST /app/user/phone/verify-old} 写入,{@code PUT /app/user/phone} 换绑成功后删除; + * TTL 与短信验证码有效期一致。 + */ + public static final String REBIND_OLD_OK_KEY_PREFIX = "app:rebind:old-ok:"; + + /** + * 手机号绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。 + *

+ * 将原号核验状态检查、手机号更新和状态失效串行化,避免同一账号并发换绑重复消费一次核验状态。 + */ + public static final String PHONE_REBIND_LOCK_KEY_PREFIX = "app:user:phone-rebind:lock:"; + /** * 「错误尝试」限速窗口内允许的最大失败次数(P2 加固后语义)。 *

@@ -160,4 +183,14 @@ public final class AppConstants { */ public static final String EMAIL_REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:email:"; + /** + * 换绑原邮箱核验状态 key 前缀,完整 key = 前缀 + userId。 + */ + public static final String EMAIL_REBIND_OLD_OK_KEY_PREFIX = "app:rebind:email:old-ok:"; + + /** + * 邮箱绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。 + */ + public static final String EMAIL_REBIND_LOCK_KEY_PREFIX = "app:user:email-rebind:lock:"; + } diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java index fa7c2e9..b672e7e 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/constant/AppMsgConstants.java @@ -73,16 +73,20 @@ public final class AppMsgConstants { public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册"; /** - * 邮箱注册成功(提示继续绑定手机号)。 - *

「注册后仍须绑手机号」为本迭代的账号规则,故注册成功文案直接给出下一步引导。 + * 邮箱注册成功,注册后可以直接使用邮箱登录;绑定手机号仅在绑定设备前要求。 */ - public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请绑定手机号"; + public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请登录"; /** * 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。 */ public static final String PHONE_NOT_BOUND = "请先绑定手机号"; + /** + * 当前账号尚未绑定邮箱,无法发送原邮箱核验码。 + */ + public static final String EMAIL_NOT_BOUND = "请先绑定邮箱"; + // ---------------- 账号 ---------------- /** @@ -160,6 +164,36 @@ public final class AppMsgConstants { */ public static final String PHONE_SAME_AS_CURRENT = "新手机号与当前手机号相同"; + /** + * 原手机号验证码校验成功(换绑第 1 步)。 + */ + public static final String OLD_PHONE_VERIFY_SUCCESS = "验证成功"; + + /** + * 换绑第 2 步缺少有效的原号核验状态。 + */ + public static final String OLD_PHONE_NOT_VERIFIED = "请先验证原手机号"; + + /** + * 邮箱更换成功。 + */ + public static final String EMAIL_UPDATE_SUCCESS = "邮箱更换成功"; + + /** + * 新邮箱与当前邮箱相同。 + */ + public static final String EMAIL_SAME_AS_CURRENT = "新邮箱与当前邮箱相同"; + + /** + * 原邮箱验证码校验成功。 + */ + public static final String OLD_EMAIL_VERIFY_SUCCESS = "验证成功"; + + /** + * 换绑邮箱缺少有效的原邮箱核验状态。 + */ + public static final String OLD_EMAIL_NOT_VERIFIED = "请先验证原邮箱"; + /** * 协议类型不合法。 *

TODO 阶段 7 按原型核对。 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java index d0838a0..1b224cc 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java @@ -107,15 +107,16 @@ public class AppAuthController extends BaseController { */ @PostMapping("/email/code") public R sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) { - appAuthService.sendEmailCode(bo); - return R.ok(AppMsgConstants.EMAIL_CODE_SENT); + String code = appAuthService.sendEmailCode(bo); + // 开发环境 mock 模式与短信接口保持一致:把验证码放入 msg;真实邮件仍只返回标准成功文案。 + return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code); } /** * 邮箱注册。 *

- * 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号, - * 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。 + * 邮箱注册完成后可直接使用邮箱登录;未绑定手机号的账号仅在绑定设备时受限 + * (服务端硬拦截,见 {@code IAppDeviceService#bind})。 */ @PostMapping("/email/register") public R emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) { diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java index d57e98a..261765a 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java @@ -1,10 +1,15 @@ package org.dromara.device.controller.app; +import cn.dev33.satoken.annotation.SaIgnore; import lombok.RequiredArgsConstructor; import org.dromara.common.core.domain.R; +import org.dromara.common.core.utils.MessageUtils; import org.dromara.common.web.core.BaseController; import org.dromara.device.constant.AppMsgConstants; +import org.dromara.device.domain.bo.AppOldPhoneCodeBo; +import org.dromara.device.domain.bo.AppOldEmailCodeBo; import org.dromara.device.domain.bo.AppPasswordBo; +import org.dromara.device.domain.bo.AppEmailBo; import org.dromara.device.domain.bo.AppPhoneBo; import org.dromara.device.domain.vo.AppAgreementVo; import org.dromara.device.domain.vo.AppUserProfileVo; @@ -12,6 +17,7 @@ import org.dromara.device.service.IAppUserService; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,8 +27,8 @@ import org.springframework.web.bind.annotation.RestController; /** * APP 个人中心 Controller(前缀 {@code /app/user})。 *

- * 本组接口需登录态;当前用户取自 {@code LoginHelper.getUserId()}(登录标识 {@code app_user:{userId}}), - * 仅能操作本人数据,不做后台数据权限。 + * 除协议查询接口外,本组接口均需登录态;当前用户取自 {@code LoginHelper.getUserId()} + * (登录标识 {@code app_user:{userId}}),仅能操作本人数据,不做后台数据权限。 * * @author Kou * @date 2026-09-16 @@ -53,7 +59,25 @@ public class AppUserController extends BaseController { } /** - * 换绑手机号(新手机号 + 验证码)。 + * 向当前账号原手机号发送换绑确认验证码。 + */ + @PostMapping("/phone/old-code") + public R sendOldPhoneCode() { + String code = appUserService.sendOldPhoneCode(); + return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code); + } + + /** + * 校验原手机号验证码,通过后写入换绑核验状态,APP 再进入绑定新号页。 + */ + @PostMapping("/phone/verify-old") + public R verifyOldPhone(@Validated @RequestBody AppOldPhoneCodeBo bo) { + appUserService.verifyOldPhone(bo); + return R.ok(AppMsgConstants.OLD_PHONE_VERIFY_SUCCESS); + } + + /** + * 首次绑定或更换手机号(新手机号 + 验证码)。换绑须先完成原号核验。 */ @PutMapping("/phone") public R rebindPhone(@Validated @RequestBody AppPhoneBo bo) { @@ -61,6 +85,34 @@ public class AppUserController extends BaseController { return R.ok(AppMsgConstants.PHONE_UPDATE_SUCCESS); } + /** + * 向当前账号原邮箱发送换绑确认验证码。 + */ + @PostMapping("/email/old-code") + public R sendOldEmailCode() { + String code = appUserService.sendOldEmailCode(); + // 模拟邮件模式把验证码放入 msg,真实邮件模式只返回通用成功文案。 + return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code); + } + + /** + * 校验原邮箱验证码,通过后才允许更换已有邮箱。 + */ + @PostMapping("/email/verify-old") + public R verifyOldEmail(@Validated @RequestBody AppOldEmailCodeBo bo) { + appUserService.verifyOldEmail(bo); + return R.ok(AppMsgConstants.OLD_EMAIL_VERIFY_SUCCESS); + } + + /** + * 首次绑定或更换邮箱(新邮箱 + 验证码)。 + */ + @PutMapping("/email") + public R rebindEmail(@Validated @RequestBody AppEmailBo bo) { + appUserService.rebindEmail(bo); + return R.ok(AppMsgConstants.EMAIL_UPDATE_SUCCESS); + } + /** * 注销账号(先解绑全部设备,再停用账号)。 */ @@ -71,10 +123,11 @@ public class AppUserController extends BaseController { } /** - * 查询协议内容(URL + 版本 + 更新时间)。 + * 查询协议内容(URL + 版本 + 更新时间),供登录、注册等未登录页面使用。 * * @param type 协议类型:user / privacy */ + @SaIgnore @GetMapping("/agreements") public R agreements(@RequestParam("type") String type) { return R.ok(appUserService.agreements(type)); diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailBo.java new file mode 100644 index 0000000..91d301a --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailBo.java @@ -0,0 +1,39 @@ +package org.dromara.device.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.dromara.device.constant.AppConstants; + +import java.io.Serial; +import java.io.Serializable; + +/** + * APP 首次绑定或换绑邮箱入参。 + * + * @author Kou + * @date 2026-09-23 + */ +@Data +public class AppEmailBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 新邮箱地址。 + */ + @NotBlank(message = "新邮箱不能为空") + @Size(max = 50, message = "邮箱长度不能超过{max}个字符") + @Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址") + private String newEmail; + + /** + * 邮箱验证码(发送至新邮箱)。 + */ + @NotBlank(message = "验证码不能为空") + @Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确") + private String code; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java index b581309..9a40ab1 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppEmailRegisterBo.java @@ -14,9 +14,9 @@ import java.io.Serializable; /** * APP 邮箱注册入参。 *

- * 不含手机号:按本迭代账号规则「注册后仍须绑手机号」,邮箱注册先建号 - * ({@code phone_number} 为 {@code NULL}),再由 APP 引导调用 {@code PUT /app/user/phone} - * 完成绑定;未绑手机号的账号无法绑定设备(服务端硬拦截)。 + * 不含手机号:邮箱注册先建号({@code phone_number} 为 {@code NULL}),注册后可直接登录; + * 用户绑定设备前再按需调用 {@code PUT /app/user/phone} 完成手机号绑定,未绑手机号的账号 + * 无法绑定设备(服务端硬拦截)。 *

* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。 * @@ -52,7 +52,7 @@ public class AppEmailRegisterBo implements Serializable { private String password; /** - * 昵称(选填,为空时默认取邮箱)。 + * 昵称(选填,为空时默认生成 user_ 加当前时间戳)。 */ @Xss(message = "昵称不能包含脚本字符") @Size(max = 30, message = "昵称长度不能超过{max}个字符") diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldEmailCodeBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldEmailCodeBo.java new file mode 100644 index 0000000..ec02c6d --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldEmailCodeBo.java @@ -0,0 +1,30 @@ +package org.dromara.device.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.dromara.device.constant.AppConstants; + +import java.io.Serial; +import java.io.Serializable; + +/** + * APP 换绑第 1 步:校验原邮箱验证码入参。 + * + * @author Kou + * @date 2026-09-23 + */ +@Data +public class AppOldEmailCodeBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 邮箱验证码(6 位数字,发送至账号原邮箱)。 + */ + @NotBlank(message = "验证码不能为空") + @Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确") + private String oldCode; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldPhoneCodeBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldPhoneCodeBo.java new file mode 100644 index 0000000..704d083 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppOldPhoneCodeBo.java @@ -0,0 +1,32 @@ +package org.dromara.device.domain.bo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.dromara.device.constant.AppConstants; + +import java.io.Serial; +import java.io.Serializable; + +/** + * APP 换绑第 1 步:校验原手机号验证码入参。 + *

+ * 验证码由 {@code POST /app/user/phone/old-code} 发到账号当前手机号(scene = rebindOld)。 + * + * @author Kou + * @date 2026-09-22 + */ +@Data +public class AppOldPhoneCodeBo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 短信验证码(6 位数字,发送至账号原手机号)。 + */ + @NotBlank(message = "验证码不能为空") + @Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确") + private String oldCode; + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java index 1911ee2..5eef492 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppRegisterBo.java @@ -47,7 +47,7 @@ public class AppRegisterBo implements Serializable { private String password; /** - * 昵称(选填,为空时默认取手机号)。 + * 昵称(选填,为空时默认生成 user_ 加当前时间戳)。 */ @Xss(message = "昵称不能包含脚本字符") @Size(max = 30, message = "昵称长度不能超过{max}个字符") diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java index d81e032..8192944 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/AppSmsCodeBo.java @@ -28,7 +28,7 @@ public class AppSmsCodeBo implements Serializable { private String phoneNumber; /** - * 场景:login / register / resetPwd / rebind。 + * 场景:login / register / resetPwd / bind / rebind。{@code rebindOld} 仅登录后原号发码接口可用。 */ @NotBlank(message = "{app.sms.scene.not.blank}") private String scene; diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java index cf9b1a7..b4820e5 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserAdminVo.java @@ -36,7 +36,7 @@ public class AppUserAdminVo implements Serializable { private String userName; /** - * 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱) + * 昵称(注册时未填写则默认生成 user_ 加当前时间戳) */ private String nickName; diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java index 3e3c6df..7a195ba 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/AppUserProfileVo.java @@ -45,8 +45,7 @@ public class AppUserProfileVo implements Serializable { /** * 是否已绑定手机号。 *

- * 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号, - * 未绑手机号的账号无法绑定设备)。 + * 供 APP 在绑定设备前判断是否需要引导绑手机号;未绑手机号不影响登录和其他个人中心操作。 */ private Boolean phoneBound; diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java index 5a608d0..20b3dae 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java @@ -47,8 +47,9 @@ public class AppEmailService { * * @param email 邮箱地址(大小写不敏感) * @param scene 场景(register / login / resetPwd) + * @return mock 模式下返回验证码供接口联调,真实邮件模式返回 {@code null} */ - public void sendCode(String email, String scene) { + public String sendCode(String email, String scene) { if (!EmailSceneEnum.isValid(scene)) { throw new ServiceException(AppMsgConstants.EMAIL_SCENE_INVALID); } @@ -69,6 +70,8 @@ public class AppEmailService { // 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算) RedisUtils.deleteObject(AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to); send(to, scene, code); + // 仅模拟发送时向上返回验证码,避免真实邮件模式通过接口泄露验证码。 + return cfg.isMock() ? code : null; } /** diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java index 127bba2..138b0eb 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/EmailSceneEnum.java @@ -7,12 +7,11 @@ import org.dromara.common.core.utils.StringUtils; /** * APP 邮箱验证码场景枚举。 *

- * 场景编码复用 {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd), + * 场景编码复用 {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd / bind / rebind / rebindOld), * 与短信通道保持一致的语义;但由于 Redis key 前缀不同({@code app:email:*} 与 {@code app:sms:*}), * 两个通道的验证码与限流互不影响。 *

- * 为什么没有 rebind / bind:手机号的绑定与换绑必须由新手机号收到的短信验证码证明归属, - * 邮箱验证码无法证明手机号归属,故邮箱通道不提供这两个场景。 + * 邮箱换绑同样采用「原邮箱核验 + 新邮箱验证码」两步流程;首次绑定邮箱使用 bind 场景。 * * @author Kou * @date 2026-09-17 @@ -34,7 +33,22 @@ public enum EmailSceneEnum { /** * 忘记密码。 */ - RESET_PWD("resetPwd", "忘记密码"); + RESET_PWD("resetPwd", "忘记密码"), + + /** + * 首次绑定邮箱。 + */ + BIND("bind", "绑定邮箱"), + + /** + * 更换已有邮箱。 + */ + REBIND("rebind", "换绑邮箱"), + + /** + * 换绑前确认原邮箱。 + */ + REBIND_OLD("rebindOld", "验证原邮箱"); /** * 场景编码。 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java index cbbbb2a..e28669e 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/enums/SmsSceneEnum.java @@ -40,7 +40,12 @@ public enum SmsSceneEnum { /** * 绑定手机(首次绑定:邮箱注册的账号补绑手机号时使用)。 */ - BIND("bind", "绑定手机"); + BIND("bind", "绑定手机"), + + /** + * 换绑前确认原手机号(仅登录后的原号发码接口使用)。 + */ + REBIND_OLD("rebindOld", "确认原手机号"); /** * 场景编码。 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java index 84f7f9d..555b68a 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppAuthService.java @@ -68,14 +68,15 @@ public interface IAppAuthService { // ---------------- 邮箱通道(迭代:邮箱注册) ---------------- /** - * 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。 + * 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效);模拟模式返回验证码,真实发送模式返回 {@code null}。 * - * @param bo 验证码入参(邮箱 + 场景) + * @param bo 邮箱验证码请求 + * @return 模拟验证码或 {@code null} */ - void sendEmailCode(AppEmailCodeBo bo); + String sendEmailCode(AppEmailCodeBo bo); /** - * 邮箱注册(注册后账号尚无手机号,须按账号规则继续绑定手机号)。 + * 邮箱注册(注册后可直接邮箱登录,绑定手机号仅在绑定设备前要求)。 * * @param bo 注册入参 */ diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java index fe2b73f..af63ba4 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/IAppUserService.java @@ -1,6 +1,9 @@ package org.dromara.device.service; +import org.dromara.device.domain.bo.AppOldPhoneCodeBo; +import org.dromara.device.domain.bo.AppOldEmailCodeBo; import org.dromara.device.domain.bo.AppPasswordBo; +import org.dromara.device.domain.bo.AppEmailBo; import org.dromara.device.domain.bo.AppPhoneBo; import org.dromara.device.domain.vo.AppAgreementVo; import org.dromara.device.domain.vo.AppUserProfileVo; @@ -28,12 +31,49 @@ public interface IAppUserService { void updatePassword(AppPasswordBo bo); /** - * 换绑手机号(新手机号 + 验证码)。 + * 向当前账号原手机号发送换绑确认验证码(scene = rebindOld)。 + * + * @return mock 模式下返回验证码,真实短信模式返回 {@code null} + */ + String sendOldPhoneCode(); + + /** + * 校验原手机号验证码,通过后写入换绑核验状态。 + * + * @param bo 原号验证码 + */ + void verifyOldPhone(AppOldPhoneCodeBo bo); + + /** + * 绑定或换绑手机号(新手机号 + 验证码)。 + *

+ * 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态。 * * @param bo 换绑入参 */ void rebindPhone(AppPhoneBo bo); + /** + * 向当前账号原邮箱发送换绑确认验证码。 + * + * @return mock 模式下返回验证码,真实邮件模式返回 {@code null} + */ + String sendOldEmailCode(); + + /** + * 校验原邮箱验证码,通过后写入换绑核验状态。 + * + * @param bo 原邮箱验证码 + */ + void verifyOldEmail(AppOldEmailCodeBo bo); + + /** + * 首次绑定或更换邮箱;已有邮箱的换绑必须先通过原邮箱核验。 + * + * @param bo 邮箱换绑入参 + */ + void rebindEmail(AppEmailBo bo); + /** * 注销账号:先解绑其下全部设备,再停用账号,最后按 loginId 全端下线。 *

diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java index 70713c3..740e42c 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppAuthServiceImpl.java @@ -67,6 +67,9 @@ public class AppAuthServiceImpl implements IAppAuthService { */ @Override public String sendSmsCode(AppSmsCodeBo bo) { + if (AppConstants.SCENE_REBIND_OLD.equals(bo.getScene())) { + throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID); + } return appSmsService.sendCode(AppPhoneNumbers.canonical(bo.getPhoneNumber()), bo.getScene()); } @@ -180,8 +183,8 @@ public class AppAuthServiceImpl implements IAppAuthService { * @param bo 验证码入参(邮箱 + 场景) */ @Override - public void sendEmailCode(AppEmailCodeBo bo) { - appEmailService.sendCode(bo.getEmail(), bo.getScene()); + public String sendEmailCode(AppEmailCodeBo bo) { + return appEmailService.sendCode(bo.getEmail(), bo.getScene()); } /** diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java index 25b72d4..0df347f 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppDeviceServiceImpl.java @@ -117,10 +117,8 @@ public class AppDeviceServiceImpl implements IAppDeviceService { /** * 绑定设备(条件更新防并发双绑;成功才写 bind 流水)。 *

- * 前置硬约束(本迭代新增):账号须已绑定手机号。邮箱注册的账号在建号时 - * {@code phone_number} 为空,按账号规则「注册后仍须绑手机号」, - * 此处服务端拦截而非依赖前端引导——否则「必须绑手机号」只是产品约定, - * 绕过 APP 直接调用接口即可跳过。 + * 前置硬约束:账号须已绑定手机号。邮箱注册账号可以先登录和使用其他 APP 功能, + * 但在绑定设备时由服务端拦截未绑定手机号的请求,避免绕过前端直接调用接口。 * * @param bo 绑定入参(imei + name) * @param userId 当前用户主键 diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java index 3bbb658..f9a423c 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/AppUserServiceImpl.java @@ -7,11 +7,15 @@ import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.utils.DesensitizedUtils; import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.device.config.AppProperties; import org.dromara.device.constant.AppConstants; import org.dromara.device.constant.AppMsgConstants; import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.bo.AppOldPhoneCodeBo; +import org.dromara.device.domain.bo.AppOldEmailCodeBo; +import org.dromara.device.domain.bo.AppEmailBo; import org.dromara.device.domain.bo.AppPasswordBo; import org.dromara.device.domain.bo.AppPhoneBo; import org.dromara.device.domain.vo.AppAgreementVo; @@ -20,11 +24,15 @@ import org.dromara.device.service.IAppUserService; import org.dromara.device.service.IDeviceBindService; import org.dromara.device.sms.AppPhoneNumbers; import org.dromara.device.sms.AppSmsService; +import org.dromara.device.email.AppEmailService; import org.dromara.system.api.AppUserApiService; import org.dromara.system.api.UserService; import org.dromara.system.api.domain.UserDTO; +import org.redisson.api.RLock; import org.springframework.stereotype.Service; +import java.time.Duration; + /** * APP 个人中心服务实现。 *

@@ -52,6 +60,7 @@ public class AppUserServiceImpl implements IAppUserService { private final UserService userService; private final IDeviceBindService deviceBindService; private final AppSmsService appSmsService; + private final AppEmailService appEmailService; private final AppProperties appProperties; /** @@ -71,8 +80,7 @@ public class AppUserServiceImpl implements IAppUserService { vo.setNickName(user.getNickName()); vo.setPhoneNumber(DesensitizedUtils.mobilePhone(user.getPhoneNumber())); vo.setEmail(DesensitizedUtils.email(user.getEmail())); - // 账号规则:邮箱注册的账号注册后仍须绑定手机号;未绑定时 APP 需据此强制引导, - // 且服务端已禁止未绑手机号的账号绑定设备(见 IAppDeviceService#bind)。 + // 邮箱注册账号可以先登录和使用个人中心;未绑定手机号时仅禁止绑定设备,绑定设备前再引导补绑。 vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber())); vo.setUserType(user.getUserType()); vo.setCreateTime(user.getCreateTime()); @@ -96,39 +104,163 @@ public class AppUserServiceImpl implements IAppUserService { appUserApiService.resetPassword(userId, bo.getNewPassword()); } + /** + * 向当前账号原手机号发送换绑确认验证码。 + * + * @return mock 模式下返回验证码,真实短信模式返回 {@code null} + */ + @Override + public String sendOldPhoneCode() { + UserDTO current = requireCurrentUser(); + return appSmsService.sendCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD); + } + + /** + * 校验原手机号验证码,通过后写入 Redis 核验状态(TTL 与短信有效期一致)。 + * + * @param bo 原号验证码 + */ + @Override + public void verifyOldPhone(AppOldPhoneCodeBo bo) { + Long userId = currentUserId(); + RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId); + lock.lock(); + try { + // 与换绑共用用户锁,避免核验状态在换绑成功删除后又被并发写回。 + UserDTO current = requireCurrentUser(); + appSmsService.verifyCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode()); + RedisUtils.setCacheObject(AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE, + Duration.ofMinutes(appProperties.getSms().getExpiryMinutes())); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + /** * 绑定 / 换绑手机号:先拦截「新号==当前号」,再校验新号未被占用,再校验新号收到的验证码,最后落库。 *

- * 场景自适应(本迭代新增):按当前账号是否已有手机号选择验证码场景—— + * 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态;首次绑定不查该状态。 + *

+ * 场景自适应:按当前账号是否已有手机号选择验证码场景—— *

- * 两个场景的验证码缓存 key 互相隔离,故 APP 必须按账号当前状态请求对应场景的验证码, - * 用错场景会得到「验证码已过期」。这样划分是为了让「首次绑定」与「更换手机号」在审计与文案上可区分。 * * @param bo 换绑入参 */ @Override public void rebindPhone(AppPhoneBo bo) { Long userId = currentUserId(); - UserDTO current = userService.selectById(userId); - if (current == null) { - throw new ServiceException(AppMsgConstants.NOT_LOGIN); + RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId); + lock.lock(); + try { + // 锁内重新读取账号,保证核验状态检查与手机号更新针对同一份最新账号数据。 + UserDTO current = requireCurrentUser(); + boolean firstBind = StringUtils.isBlank(current.getPhoneNumber()); + String rebindOldVerifiedKey = AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId; + if (!firstBind && RedisUtils.getCacheObject(rebindOldVerifiedKey) == null) { + throw new ServiceException(AppMsgConstants.OLD_PHONE_NOT_VERIFIED); + } + String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber()); + // P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」) + // 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。 + if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) { + throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT); + } + if (appUserApiService.existsByPhone(newPhone)) { + throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED); + } + appSmsService.verifyCode(newPhone, + firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode()); + // 换绑只更新手机号,昵称保持用户原值,不重新生成默认昵称。 + appUserApiService.updatePhone(userId, newPhone); + if (!firstBind) { + // 与上面的状态检查处于同一用户锁内,确保一次核验只允许一次换绑成功。 + RedisUtils.deleteObject(rebindOldVerifiedKey); + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } - boolean firstBind = StringUtils.isBlank(current.getPhoneNumber()); - String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber()); - // P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」) - // 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。 - if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) { - throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT); + } + + /** + * 向当前账号原邮箱发送换绑确认验证码。 + * + * @return mock 模式下返回验证码,真实邮件模式返回 {@code null} + */ + @Override + public String sendOldEmailCode() { + UserDTO current = requireCurrentUser(); + return appEmailService.sendCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD); + } + + /** + * 校验原邮箱验证码,通过后写入 Redis 核验状态;状态与邮箱换绑共用同一把用户锁。 + * + * @param bo 原邮箱验证码 + */ + @Override + public void verifyOldEmail(AppOldEmailCodeBo bo) { + Long userId = currentUserId(); + RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId); + lock.lock(); + try { + UserDTO current = requireCurrentUser(); + appEmailService.verifyCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode()); + RedisUtils.setCacheObject(AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE, + Duration.ofMinutes(appProperties.getEmail().getExpiryMinutes())); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } - if (appUserApiService.existsByPhone(newPhone)) { - throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED); + } + + /** + * 绑定 / 换绑邮箱:先校验原邮箱核验状态,再校验新邮箱唯一性与验证码,最后更新账号。 + *

+ * 当前账号没有邮箱时视为首次绑定,不要求原邮箱核验,验证码场景使用 bind;已有邮箱时使用 rebind。 + * + * @param bo 新邮箱与验证码 + */ + @Override + public void rebindEmail(AppEmailBo bo) { + Long userId = currentUserId(); + RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId); + lock.lock(); + try { + UserDTO current = requireCurrentUser(); + boolean firstBind = StringUtils.isBlank(current.getEmail()); + String oldVerifiedKey = AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId; + if (!firstBind && RedisUtils.getCacheObject(oldVerifiedKey) == null) { + throw new ServiceException(AppMsgConstants.OLD_EMAIL_NOT_VERIFIED); + } + String newEmail = appEmailService.normalize(bo.getNewEmail()); + if (!firstBind && StringUtils.equals(current.getEmail(), newEmail)) { + throw new ServiceException(AppMsgConstants.EMAIL_SAME_AS_CURRENT); + } + if (appUserApiService.existsByEmail(newEmail)) { + throw new ServiceException(DeviceMsgConstants.EMAIL_REGISTERED); + } + appEmailService.verifyCode(newEmail, + firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode()); + // 换绑只更新邮箱,昵称保持用户原值,不重新生成默认昵称。 + if (!appUserApiService.updateEmail(userId, newEmail)) { + throw new ServiceException(AppMsgConstants.NOT_LOGIN); + } + if (!firstBind) { + RedisUtils.deleteObject(oldVerifiedKey); + } + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } - appSmsService.verifyCode(newPhone, - firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode()); - appUserApiService.updatePhone(userId, newPhone); } /** @@ -198,4 +330,26 @@ public class AppUserServiceImpl implements IAppUserService { return userId; } + private UserDTO requireCurrentUser() { + UserDTO current = userService.selectById(currentUserId()); + if (current == null) { + throw new ServiceException(AppMsgConstants.NOT_LOGIN); + } + return current; + } + + private String requireBoundPhone(UserDTO current) { + if (StringUtils.isBlank(current.getPhoneNumber())) { + throw new ServiceException(AppMsgConstants.PHONE_NOT_BOUND); + } + return current.getPhoneNumber(); + } + + private String requireBoundEmail(UserDTO current) { + if (StringUtils.isBlank(current.getEmail())) { + throw new ServiceException(AppMsgConstants.EMAIL_NOT_BOUND); + } + return appEmailService.normalize(current.getEmail()); + } + } diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java index 0b3a5bf..eb80313 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java @@ -47,7 +47,7 @@ public class AppSmsService { * 生成并按场景发送短信验证码(含 60 秒重发限流)。 * * @param phoneNumber 手机号 - * @param scene 场景(login / register / resetPwd / rebind) + * @param scene 场景(login / register / resetPwd / bind / rebind / rebindOld) * @return mock 模式下返回验证码供接口联调,真实短信模式返回 {@code null} */ public String sendCode(String phoneNumber, String scene) { diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java index fbe53e1..03e6d2b 100644 --- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java +++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AppUserApiServiceImpl.java @@ -46,6 +46,13 @@ public class AppUserApiServiceImpl implements AppUserApiService { */ private static final String USER_NAME_PREFIX = "app_"; + /** + * 生成 APP 用户默认昵称,避免直接暴露手机号或邮箱。 + */ + private static String defaultNickName() { + return "user_" + System.currentTimeMillis(); + } + private final SysUserMapper userMapper; /** @@ -138,11 +145,12 @@ public class AppUserApiServiceImpl implements AppUserApiService { *

  • 与后台 {@code sys_user} 的账号名冲突需其恰好命名为 {@code app_<雪花ID>},非自然产生。
  • * * 说明:APP 登录仅依赖 {@code phone_number}(见 {@code selectByPhone}),不依赖 {@code user_name}; - * 展示用昵称取 {@code nickName}(默认手机号),故 user_name 采用内部派生值不影响用户展示。 + * 展示用昵称取 {@code nickName}(未传时默认生成 {@code user_ + 当前时间戳}),故 user_name + * 采用内部派生值不影响用户展示。 * * @param phoneNumber 手机号 * @param rawPassword 明文密码 - * @param nickName 昵称(为空时默认取手机号) + * @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳}) * @return 新建用户主键 */ @Override @@ -151,7 +159,7 @@ public class AppUserApiServiceImpl implements AppUserApiService { SysUser user = new SysUser(); user.setUserId(userId); user.setUserName(USER_NAME_PREFIX + userId); - user.setNickName(StringUtils.isBlank(nickName) ? phoneNumber : nickName); + user.setNickName(StringUtils.isBlank(nickName) ? defaultNickName() : nickName); user.setPhoneNumber(phoneNumber); user.setUserType(UserType.APP_USER.getUserType()); user.setPassword(BCrypt.hashpw(rawPassword)); @@ -210,6 +218,22 @@ public class AppUserApiServiceImpl implements AppUserApiService { .exists(); } + /** + * 更新 APP 用户邮箱;调用方负责完成验证码和唯一性校验。 + *

    仅写入邮箱字段,不改动昵称及其他用户资料。

    + */ + @Override + public boolean updateEmail(Long userId, String newEmail) { + if (userId == null || StringUtils.isBlank(newEmail)) { + return false; + } + return userMapper.lambda() + .set(SysUser::getEmail, newEmail) + .eq(SysUser::getUserId, userId) + .eq(SysUser::getUserType, UserType.APP_USER.getUserType()) + .updateCount() > 0; + } + /** * 以邮箱注册一个 APP 用户。 *

    @@ -224,7 +248,7 @@ public class AppUserApiServiceImpl implements AppUserApiService { * * @param email 邮箱地址(已归一化) * @param rawPassword 明文密码 - * @param nickName 昵称(为空时默认取邮箱) + * @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳}) * @return 新建用户主键 */ @Override @@ -233,7 +257,7 @@ public class AppUserApiServiceImpl implements AppUserApiService { SysUser user = new SysUser(); user.setUserId(userId); user.setUserName(USER_NAME_PREFIX + userId); - user.setNickName(StringUtils.isBlank(nickName) ? email : nickName); + user.setNickName(StringUtils.isBlank(nickName) ? defaultNickName() : nickName); user.setEmail(email); user.setUserType(UserType.APP_USER.getUserType()); user.setPassword(BCrypt.hashpw(rawPassword)); @@ -271,7 +295,7 @@ public class AppUserApiServiceImpl implements AppUserApiService { } /** - * 换绑手机号。 + * 换绑手机号;仅写入手机号字段,不改动昵称及其他用户资料。 * * @param userId 用户主键 * @param newPhoneNumber 新手机号