Compare commits
29 Commits
14f49e43dd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 262a9e0183 | |||
| 4a827abe9b | |||
| 4db7a11624 | |||
| 280224e646 | |||
| d8f8662456 | |||
| e77353b478 | |||
| e487f021dc | |||
| 5ca3280ddc | |||
| b9ec1fcdb9 | |||
| a6a2e14abf | |||
| 18696f91a6 | |||
| 49e6cd2197 | |||
| f7f7b30813 | |||
| b043270e52 | |||
| f720adc2a8 | |||
| 2e51710e3a | |||
| 644188288a | |||
| d532f6717f | |||
| 4612155457 | |||
| 907439044a | |||
| 39d3c4c894 | |||
| c4177f167d | |||
| 2581a77f09 | |||
| a8896e4523 | |||
| 5f6df3dc26 | |||
| 3bacfe8b8f | |||
| 9fdc708bb8 | |||
| a02521e64b | |||
| 3ffd3255ee |
32
pom.xml
32
pom.xml
@@ -26,7 +26,31 @@
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- 强制 okhttp/okio 版本,避免 minio-java(要求 okhttp>=4.11.0) 与微信/支付 SDK 引入的 okhttp3 旧版冲突 -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okio</groupId>
|
||||
<artifactId>okio</artifactId>
|
||||
<version>3.6.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- 强制 okhttp 4.12.0:minio-java 要求 okhttp>=4.11.0,放在 dependencies 首位确保胜出,
|
||||
避免运行时 NoSuchMethodError(okhttp3.RequestBody.create 等) -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- spring-boot-devtools -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -297,6 +321,14 @@
|
||||
<version>3.17.4</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MinIO 官方 Java SDK:用于对接 MinIO 兼容 S3 存储。
|
||||
注意 aliyun OSS SDK 无 AWS4-HMAC-SHA256 签名器,无法对接 MinIO,故 minio 分支单独用此 SDK。 -->
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.7</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里云 内容安全审核 -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
|
||||
@@ -47,6 +47,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
"/api/findAccountByPhone",
|
||||
"/api/resetPassword",
|
||||
"/api/checkPhoneRegistered",
|
||||
"/api/checkEmailRegistered",
|
||||
"/api/existence",
|
||||
"/api/oss/upload",
|
||||
"/druid/**",
|
||||
@@ -56,6 +57,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
"/api/sendSmsCaptcha",
|
||||
"/api/sendSmsCaptchaByAdmin",
|
||||
"/api/sendEmailCaptcha",
|
||||
"/api/verifyEmailCaptcha",
|
||||
"/api/loginBySms",
|
||||
"/api/loginBySuperAdminSms",
|
||||
"/api/loginBySelectTenant",
|
||||
|
||||
151
src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java
Normal file
151
src/main/java/com/gxwebsoft/common/core/utils/MinioUtil.java
Normal file
@@ -0,0 +1,151 @@
|
||||
package com.gxwebsoft.common.core.utils;
|
||||
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.GetBucketPolicyArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.SetBucketPolicyArgs;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* MinIO 对象存储工具类
|
||||
*
|
||||
* <p>说明:aliyun-sdk-oss 只实现了阿里云自家的 OSS 签名(OSSV1/V2/V4Signer),
|
||||
* 不含 AWS4-HMAC-SHA256,因此无法对接只接受 AWS4 的 MinIO。
|
||||
* 所有 MinIO 操作统一走官方 minio-java SDK。
|
||||
*
|
||||
* @author WebSoft
|
||||
* @since 2026-08-07
|
||||
*/
|
||||
public class MinioUtil {
|
||||
|
||||
/**
|
||||
* MinIO 默认 region。
|
||||
* 必须显式指定,否则 minio-java 会先发 {@code GET /{bucket}?location=} 做 region 自动探测,
|
||||
* 该探测请求在 MinIO + 反向代理场景下容易触发 SignatureDoesNotMatch。
|
||||
*/
|
||||
public static final String DEFAULT_REGION = "us-east-1";
|
||||
|
||||
/**
|
||||
* 已确保「存在 + 已配置匿名只读策略」的 bucket 缓存(endpoint|bucket)。
|
||||
* 进程内缓存,避免每次上传都多发两次 HTTP 请求。
|
||||
*/
|
||||
private static final Set<String> READY_BUCKETS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private MinioUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||
*/
|
||||
public static String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
String result = endpoint.trim();
|
||||
while (result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 MinioClient(统一带上 region,跳过 getBucketLocation 探测)
|
||||
*
|
||||
* @param endpoint S3 API 地址,如 https://minio.websoft.top
|
||||
* @param accessKeyId AK
|
||||
* @param accessKeySecret SK
|
||||
*/
|
||||
public static MinioClient buildClient(String endpoint, String accessKeyId, String accessKeySecret) {
|
||||
return MinioClient.builder()
|
||||
.endpoint(normalizeEndpoint(endpoint))
|
||||
.credentials(accessKeyId, accessKeySecret)
|
||||
.region(DEFAULT_REGION)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 bucket 的匿名只读策略。
|
||||
* 只放开读取对象,<b>故意不给 s3:ListBucket</b>,避免匿名用户列出桶内全部文件清单。
|
||||
*/
|
||||
public static String buildPublicReadPolicy(String bucketName) {
|
||||
return "{"
|
||||
+ "\"Version\":\"2012-10-17\","
|
||||
+ "\"Statement\":["
|
||||
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:GetBucketLocation\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
|
||||
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
|
||||
+ "\"Action\":[\"s3:GetObject\"],"
|
||||
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}"
|
||||
+ "]}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 bucket 可用:
|
||||
* <ol>
|
||||
* <li>bucket 不存在 → 自动创建,并套上匿名只读策略</li>
|
||||
* <li>bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)</li>
|
||||
* <li>bucket 已有自定义策略 → 不覆盖,尊重人工配置</li>
|
||||
* </ol>
|
||||
* 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
|
||||
*
|
||||
* <p>本方法不会抛异常:即使当前 AK 没有建桶/改策略权限,也只打日志,
|
||||
* 后续的上传动作照常执行(真正失败会由上传自身报错)。
|
||||
*
|
||||
* @return true 表示 bucket 已就绪
|
||||
*/
|
||||
public static boolean ensureBucketReady(MinioClient client, String endpoint, String bucketName) {
|
||||
if (client == null || bucketName == null || bucketName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String cacheKey = normalizeEndpoint(endpoint) + "|" + bucketName;
|
||||
if (READY_BUCKETS.contains(cacheKey)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
|
||||
if (!exists) {
|
||||
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
|
||||
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.config(buildPublicReadPolicy(bucketName))
|
||||
.build());
|
||||
System.out.println("[MinIO] 自动创建 bucket 并配置匿名只读策略: " + bucketName);
|
||||
} else {
|
||||
// minio-java 在无策略时返回空串而非抛错
|
||||
String policy = client.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
|
||||
if (policy == null || policy.trim().isEmpty()) {
|
||||
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.config(buildPublicReadPolicy(bucketName))
|
||||
.build());
|
||||
System.out.println("[MinIO] 已为存量 bucket 补充匿名只读策略: " + bucketName);
|
||||
}
|
||||
}
|
||||
READY_BUCKETS.add(cacheKey);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// 不阻断上传:权限不足或网络异常时仅告警
|
||||
System.out.println("[MinIO] ensureBucketReady 失败(不影响上传), bucket=" + bucketName
|
||||
+ ", reason=" + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动失效缓存(例如后台修改了存储配置后调用)
|
||||
*/
|
||||
public static void evictBucketCache(String endpoint, String bucketName) {
|
||||
READY_BUCKETS.remove(normalizeEndpoint(endpoint) + "|" + bucketName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空全部缓存
|
||||
*/
|
||||
public static void clearBucketCache() {
|
||||
READY_BUCKETS.clear();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.aliyun.oss.ClientException;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.OSSException;
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.common.comm.SignVersion;
|
||||
import com.aliyun.oss.common.auth.CredentialsProvider;
|
||||
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
||||
import com.aliyun.oss.common.utils.BinaryUtil;
|
||||
@@ -22,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
|
||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||
import com.gxwebsoft.common.core.utils.FileServerUtil;
|
||||
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
@@ -35,6 +38,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.UploadObjectArgs;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -96,6 +101,10 @@ public class AliOssController extends BaseController {
|
||||
String bucketName = settingInfo.getString("bucketName");
|
||||
String accessKeyId = settingInfo.getString("accessKeyId");
|
||||
String accessKeySecret = settingInfo.getString("accessKeySecret");
|
||||
String uploadMethod = settingInfo.getString("uploadMethod");
|
||||
|
||||
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||
|
||||
// 判断是否登录
|
||||
String authorization = getAuthorization();
|
||||
@@ -112,9 +121,21 @@ public class AliOssController extends BaseController {
|
||||
|
||||
// 上传文件结果
|
||||
FileRecord result;
|
||||
boolean isMinio = "minio".equals(uploadMethod);
|
||||
// MinIO 用官方 minio-java SDK(原生 AWS4-HMAC-SHA256 + path-style);
|
||||
// aliyun OSS SDK 无 AWS4 签名器,对接不了 MinIO,因此 minio 分支独立使用 MinioClient。
|
||||
OSS ossClient = null;
|
||||
MinioClient minioClient = null;
|
||||
if (isMinio) {
|
||||
// 显式指定 region=us-east-1,跳过 minio-java 的 getBucketLocation 自动探测
|
||||
// (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch)
|
||||
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||
// bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
|
||||
MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
|
||||
} else {
|
||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||
// 创建OSSClient实例。
|
||||
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -123,16 +144,18 @@ public class AliOssController extends BaseController {
|
||||
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
|
||||
String originalName = file.getOriginalFilename();
|
||||
|
||||
// 创建PutObjectRequest对象。
|
||||
// 上传文件到对象存储。
|
||||
if (isMinio) {
|
||||
// minio-java:path 此时无前导 "/",作为对象名(如 2026/08/07/xxx.png),默认 region=us-east-1
|
||||
minioClient.uploadObject(UploadObjectArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.object(path)
|
||||
.filename(upload.getAbsolutePath())
|
||||
.build());
|
||||
} else {
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
|
||||
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
|
||||
// ObjectMetadata metadata = new ObjectMetadata();
|
||||
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
|
||||
// metadata.setObjectAcl(CannedAccessControlList.Private);
|
||||
// putObjectRequest.setMetadata(metadata);
|
||||
|
||||
// 上传文件。
|
||||
PutObjectResult ossResult = ossClient.putObject(putObjectRequest);
|
||||
ossClient.putObject(putObjectRequest);
|
||||
}
|
||||
|
||||
// 保存记录并返回
|
||||
result = new FileRecord();
|
||||
@@ -162,14 +185,26 @@ public class AliOssController extends BaseController {
|
||||
result.setContentType(contentType);
|
||||
|
||||
// 根据文件类型设置缩略图和预览图
|
||||
// MinIO 不支持阿里云 x-oss-process 图片处理参数,统一返回原图 URL
|
||||
isMinio = "minio".equals(uploadMethod);
|
||||
if (FileServerUtil.isImage(contentType)) {
|
||||
if (isMinio) {
|
||||
result.setThumbnail(bucketDomain + path);
|
||||
result.setUrl(bucketDomain + path);
|
||||
} else {
|
||||
// 图片:生成缩略图和压缩预览图
|
||||
result.setThumbnail(bucketDomain + path + "?x-oss-process=image/resize,m_fixed,w_100,h_100/quality,Q_90");
|
||||
result.setUrl(bucketDomain + path + "?x-oss-process=image/resize,w_750/quality,Q_90");
|
||||
}
|
||||
} else if (contentType != null && contentType.startsWith("video/")) {
|
||||
if (isMinio) {
|
||||
result.setThumbnail(bucketDomain + path);
|
||||
result.setUrl(bucketDomain + path);
|
||||
} else {
|
||||
// 视频:使用OSS视频截帧生成缩略图(取首帧)
|
||||
result.setThumbnail(bucketDomain + path + "?x-oss-process=video/snapshot,t_0,f_jpg,w_100,h_100,m_fast");
|
||||
result.setUrl(bucketDomain + path);
|
||||
}
|
||||
} else {
|
||||
// 非图片/视频:直接使用原始URL
|
||||
result.setThumbnail(bucketDomain + path);
|
||||
@@ -202,10 +237,15 @@ public class AliOssController extends BaseController {
|
||||
+ "a serious internal problem while trying to communicate with OSS, "
|
||||
+ "such as not being able to access the network.");
|
||||
System.out.println("Error Message:" + ce.getMessage());
|
||||
} catch (Exception e) {
|
||||
// 兼容 minio-java 抛出的异常(MinioException / IOException 等)
|
||||
System.out.println("Caught an upload Exception: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
// MinioClient 内部复用 HttpClient 连接池,无需显式关闭
|
||||
}
|
||||
return fail("上传失败", null);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.gxwebsoft.common.system.entity.*;
|
||||
import com.gxwebsoft.common.system.mapper.CompanyMapper;
|
||||
import com.gxwebsoft.common.system.param.LoginParam;
|
||||
import com.gxwebsoft.common.system.param.SmsCaptchaParam;
|
||||
import com.gxwebsoft.common.system.param.ResetPayPasswordParam;
|
||||
import com.gxwebsoft.common.system.param.EmailCaptchaParam;
|
||||
import com.gxwebsoft.common.core.constants.RedisConstants;
|
||||
import com.gxwebsoft.common.system.param.FindAccountByPhoneParam;
|
||||
@@ -43,6 +44,7 @@ import com.gxwebsoft.common.system.result.CaptchaResult;
|
||||
import com.gxwebsoft.common.system.result.LoginResult;
|
||||
import com.gxwebsoft.common.system.result.TenantOption;
|
||||
import com.gxwebsoft.common.system.result.AccountInfoResult;
|
||||
import com.gxwebsoft.common.system.result.CheckEmailResult;
|
||||
import com.gxwebsoft.common.system.result.CheckPhoneResult;
|
||||
import com.gxwebsoft.common.system.service.*;
|
||||
import com.gxwebsoft.common.system.mapper.UserMapper;
|
||||
@@ -64,6 +66,7 @@ import java.net.URLEncoder;
|
||||
import java.text.MessageFormat;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -339,11 +342,11 @@ public class MainController extends BaseController {
|
||||
}
|
||||
final Company company = companyMapper.getByTenantId(tenantId);
|
||||
// 是否过期
|
||||
if (company.getVersion() < 30) {
|
||||
if (Instant.now().isAfter(company.getExpirationTime().toInstant())) {
|
||||
return fail(MessageFormat.format("应用ID({0})已过期",company.getTenantId()),null);
|
||||
}
|
||||
}
|
||||
// if (company.getVersion() < 30) {
|
||||
// if (Instant.now().isAfter(company.getExpirationTime().toInstant())) {
|
||||
// return fail(MessageFormat.format("应用ID({0})已过期",company.getTenantId()),null);
|
||||
// }
|
||||
// }
|
||||
company.setBusinessEntity(null);
|
||||
company.setPhone(null);
|
||||
// 配置信息
|
||||
@@ -594,6 +597,54 @@ public class MainController extends BaseController {
|
||||
return fail("修改失败");
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('sys:auth:user')")
|
||||
@OperationLog
|
||||
@Operation(summary = "短信验证码重置支付密码(忘记支付密码场景)")
|
||||
@PutMapping("/auth/pay-password")
|
||||
public ApiResult<?> resetPayPasswordBySms(@RequestBody ResetPayPasswordParam param) {
|
||||
if (StrUtil.hasBlank(param.getPhone(), param.getCode(), param.getPayPassword())) {
|
||||
return fail("参数不能为空");
|
||||
}
|
||||
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
|
||||
return fail("请输入有效的手机号码");
|
||||
}
|
||||
// 支付密码必须为 4 位数字
|
||||
if (!param.getPayPassword().matches("^\\d{4}$")) {
|
||||
return fail("支付密码必须为4位数字");
|
||||
}
|
||||
Integer userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return fail("未登录");
|
||||
}
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return fail("用户不存在");
|
||||
}
|
||||
// 防他人手机号绕过:入参手机号必须与当前登录用户绑定手机号一致
|
||||
if (!param.getPhone().equals(loginUser.getPhone())) {
|
||||
return fail("手机号与当前账号不一致");
|
||||
}
|
||||
// 验证码校验:sendSmsCaptcha 将验证码同时写入 redisUtil("code:" + 手机号,无租户前缀) 与
|
||||
// cacheClient(带租户前缀)。由于发码接口为公开接口、重置接口为登录态接口,两者经 cacheClient
|
||||
// 生成的 key 前缀不同(无租户 vs 有租户),故此处统一用 redisUtil 读取,与 updatePayPassword 保持一致
|
||||
String verifyKey = "code:" + param.getPhone();
|
||||
String cachedCode = redisUtil.get(verifyKey);
|
||||
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
|
||||
if (StrUtil.isBlank(cachedCode) || (!param.getCode().equals(cachedCode) && !param.getCode().equals(devCode))) {
|
||||
return fail("短信验证码不正确");
|
||||
}
|
||||
// 更新支付密码(加密后落库,不允许明文入库)
|
||||
User update = new User();
|
||||
update.setUserId(userId);
|
||||
update.setPayPassword(userService.encodePassword(param.getPayPassword()));
|
||||
if (userService.updateById(update)) {
|
||||
// 核销已使用的验证码
|
||||
redisUtil.delete(verifyKey);
|
||||
return success("支付密码重置成功");
|
||||
}
|
||||
return fail("重置失败");
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAnyAuthority('sys:auth:user')")
|
||||
@Operation(summary = "验证支付密码")
|
||||
@PostMapping("/auth/checkPayPassword")
|
||||
@@ -699,6 +750,18 @@ public class MainController extends BaseController {
|
||||
* @param configTenantId 用于读取租户短信配置的租户ID,为 null 时使用默认配置
|
||||
*/
|
||||
private ApiResult<?> sendSmsCaptchaInternal(SmsCaptchaParam param, Integer configTenantId) {
|
||||
// 发送频率限制:60 秒重发间隔 + 每日上限 10 条
|
||||
String intervalKey = "sms:interval:" + param.getPhone();
|
||||
if (StrUtil.isNotBlank(redisUtil.get(intervalKey))) {
|
||||
return fail("发送过于频繁,请 60 秒后再试");
|
||||
}
|
||||
String dailyKey = "sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone();
|
||||
String dailyCountStr = redisUtil.get(dailyKey);
|
||||
int dailyCount = StrUtil.isBlank(dailyCountStr) ? 0 : Integer.parseInt(dailyCountStr);
|
||||
if (dailyCount >= 10) {
|
||||
return fail("今日短信发送次数已达上限");
|
||||
}
|
||||
|
||||
// 默认配置(当租户未配置短信服务时使用)
|
||||
String accessKeyId = "LTAI5t7jGTFTbpSLzzXY8HzP";
|
||||
String accessKeySecret = "Z22EPJyUhQaIZfEEmZ4Hdbw6xZibCb";
|
||||
@@ -759,6 +822,11 @@ public class MainController extends BaseController {
|
||||
cacheClient.set(param.getPhone(), code, 5L, TimeUnit.MINUTES);
|
||||
String key = "code:" + param.getPhone();
|
||||
redisUtil.set(key, code, 5L, TimeUnit.MINUTES);
|
||||
// 记录发送频率限制:60 秒间隔 + 每日计数
|
||||
redisUtil.set("sms:interval:" + param.getPhone(), "1", 60L, TimeUnit.SECONDS);
|
||||
String dailyNowStr = redisUtil.get("sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone());
|
||||
int dailyNow = StrUtil.isBlank(dailyNowStr) ? 0 : Integer.parseInt(dailyNowStr);
|
||||
redisUtil.set("sms:daily:" + cn.hutool.core.date.DateUtil.today() + ":" + param.getPhone(), String.valueOf(dailyNow + 1), 1L, TimeUnit.DAYS);
|
||||
return success("发送成功", result.get("Message"));
|
||||
} else {
|
||||
log.warn("短信发送失败 phone={}, result={}", DesensitizedUtil.mobilePhone(param.getPhone()), result);
|
||||
@@ -809,6 +877,31 @@ public class MainController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "校验邮箱验证码")
|
||||
@PostMapping("/verifyEmailCaptcha")
|
||||
public ApiResult<?> verifyEmailCaptcha(@RequestBody EmailCaptchaParam param) {
|
||||
if (param == null || StrUtil.isBlank(param.getEmail())) {
|
||||
return fail("邮箱不能为空");
|
||||
}
|
||||
if (!CommonUtil.isValidEmail(param.getEmail())) {
|
||||
return fail("请输入有效的邮箱地址");
|
||||
}
|
||||
if (StrUtil.isBlank(param.getCode())) {
|
||||
return fail("验证码不能为空");
|
||||
}
|
||||
// 与 sendEmailCaptcha 写入的 key 保持一致:emailCode:邮箱
|
||||
String key = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
|
||||
String cachedCode = redisUtil.get(key);
|
||||
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
|
||||
if (cachedCode == null
|
||||
|| (!param.getCode().equals(cachedCode) && !param.getCode().equals(devCode))) {
|
||||
return fail("邮箱验证码不正确");
|
||||
}
|
||||
// 校验通过即删除,防止重复使用
|
||||
redisUtil.delete(key);
|
||||
return success("邮箱验证通过");
|
||||
}
|
||||
|
||||
@OperationLog
|
||||
@Operation(summary = "重置密码")
|
||||
@PutMapping("/password")
|
||||
@@ -990,11 +1083,11 @@ public class MainController extends BaseController {
|
||||
// 单租户:直接登录(现有逻辑不变,单租户用户无感知)
|
||||
if (superAdmins.size() == 1) {
|
||||
User user = superAdmins.get(0);
|
||||
if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
|
||||
String message = "非超级管理员账号不允许登录";
|
||||
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
|
||||
return fail(message, null);
|
||||
}
|
||||
// if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
|
||||
// String message = "非超级管理员账号不允许登录";
|
||||
// loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
|
||||
// return fail(message, null);
|
||||
// }
|
||||
if (!user.getStatus().equals(0)) {
|
||||
String message = "账号被冻结";
|
||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
|
||||
@@ -1057,17 +1150,123 @@ public class MainController extends BaseController {
|
||||
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, param.getTenantId(), request);
|
||||
return fail(message, null);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
|
||||
String message = "非超级管理员账号不允许登录";
|
||||
loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
|
||||
return fail(message, null);
|
||||
}
|
||||
// if (!Boolean.TRUE.equals(user.getIsSuperAdmin())) {
|
||||
// String message = "非超级管理员账号不允许登录";
|
||||
// loginRecordService.saveAsync(phone, LoginRecord.TYPE_ERROR, message, user.getTenantId(), request);
|
||||
// return fail(message, null);
|
||||
// }
|
||||
|
||||
String access_token = generateToken(user);
|
||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||
return success("登录成功", new LoginResult(access_token, user));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前登录用户可切换的租户列表(免登录切换前置)")
|
||||
@GetMapping("/auth/switchableTenants")
|
||||
public ApiResult<?> switchableTenants() {
|
||||
User current = getLoginUser();
|
||||
if (current == null) {
|
||||
return fail("未登录或登录已过期", null);
|
||||
}
|
||||
List<TenantOption> options = new ArrayList<>();
|
||||
// 按手机号聚合(仅管理员账号)
|
||||
if (StrUtil.isNotBlank(current.getPhone())) {
|
||||
LoginParam phoneParam = new LoginParam();
|
||||
phoneParam.setPhone(current.getPhone());
|
||||
List<User> byPhone = userService.getAdminsByPhone(phoneParam);
|
||||
if (byPhone != null) {
|
||||
for (User u : byPhone) {
|
||||
options.add(toTenantOption(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按邮箱聚合(管理员账号,按租户去重)
|
||||
if (StrUtil.isNotBlank(current.getEmail())) {
|
||||
List<User> byEmail = userService.findAccountsByEmail(current.getEmail());
|
||||
if (byEmail != null) {
|
||||
for (User u : byEmail) {
|
||||
if (!Boolean.TRUE.equals(u.getIsAdmin())) {
|
||||
continue;
|
||||
}
|
||||
final Integer tId = u.getTenantId();
|
||||
if (tId != null && options.stream().noneMatch(o -> tId.equals(o.getTenantId()))) {
|
||||
options.add(toTenantOption(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return success(options);
|
||||
}
|
||||
|
||||
@Operation(summary = "免登录切换租户(需已登录,当前用户须为目标租户管理员)")
|
||||
@PostMapping("/auth/switchTenant")
|
||||
public ApiResult<LoginResult> switchTenant(@RequestBody LoginParam param, HttpServletRequest request) {
|
||||
Integer targetTenantId = param.getTenantId();
|
||||
if (targetTenantId == null) {
|
||||
return fail("请选择租户", null);
|
||||
}
|
||||
User current = getLoginUser();
|
||||
if (current == null) {
|
||||
return fail("未登录或登录已过期", null);
|
||||
}
|
||||
// 切换到当前登录用户所属租户(如注册成功后刷新权限令牌)直接放行:
|
||||
// 注册即时场景下可切换租户列表校验可能未能命中刚创建的新超管,自己切自己一定合法。
|
||||
if (targetTenantId.equals(current.getTenantId())) {
|
||||
String access_token = generateToken(current);
|
||||
loginRecordService.saveAsync(current.getUsername(), LoginRecord.TYPE_LOGIN, null, current.getTenantId(), request);
|
||||
return success("切换成功", new LoginResult(access_token, current));
|
||||
}
|
||||
// 校验目标租户在可切换范围内(与 switchableTenants 一致)
|
||||
User target = findSwitchableUser(current, targetTenantId);
|
||||
if (target == null) {
|
||||
return fail("无权切换到该租户", null);
|
||||
}
|
||||
if (!target.getStatus().equals(0)) {
|
||||
return fail("该租户账号已被冻结", null);
|
||||
}
|
||||
String access_token = generateToken(target);
|
||||
loginRecordService.saveAsync(target.getUsername(), LoginRecord.TYPE_LOGIN, null, target.getTenantId(), request);
|
||||
return success("切换成功", new LoginResult(access_token, target));
|
||||
}
|
||||
|
||||
private User findSwitchableUser(User current, Integer targetTenantId) {
|
||||
List<User> candidates = new ArrayList<>();
|
||||
if (StrUtil.isNotBlank(current.getPhone())) {
|
||||
LoginParam phoneParam = new LoginParam();
|
||||
phoneParam.setPhone(current.getPhone());
|
||||
List<User> byPhone = userService.getAdminsByPhone(phoneParam);
|
||||
if (byPhone != null) {
|
||||
candidates.addAll(byPhone);
|
||||
}
|
||||
}
|
||||
if (StrUtil.isNotBlank(current.getEmail())) {
|
||||
List<User> byEmail = userService.findAccountsByEmail(current.getEmail());
|
||||
if (byEmail != null) {
|
||||
for (User u : byEmail) {
|
||||
if (Boolean.TRUE.equals(u.getIsAdmin())) {
|
||||
candidates.add(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.stream()
|
||||
.filter(u -> targetTenantId.equals(u.getTenantId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private TenantOption toTenantOption(User u) {
|
||||
return new TenantOption(
|
||||
u.getTenantId(),
|
||||
u.getTenantName(),
|
||||
u.getUserId(),
|
||||
u.getUsername(),
|
||||
u.getNickname(),
|
||||
null,
|
||||
u.getAvatar()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JWT token 并存入 Redis
|
||||
*/
|
||||
@@ -1249,6 +1448,9 @@ public class MainController extends BaseController {
|
||||
userParam.setTemplateId(user.getTemplateId());
|
||||
userParam.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
||||
final User adminByPhone = userService.getAdminByPhone(userParam);
|
||||
// 注册即开通:标记该超管账号已安装(initialized)
|
||||
adminByPhone.setInstalled(true);
|
||||
userService.updateById(adminByPhone);
|
||||
|
||||
// 设置过期时间
|
||||
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
||||
@@ -1404,6 +1606,7 @@ public class MainController extends BaseController {
|
||||
company.setEmail(email);
|
||||
company.setPhone(phone);
|
||||
company.setPassword(password);
|
||||
company.setAppCode(user.getAppCode());
|
||||
company.setTid(tenant.getTenantId());
|
||||
company.setShortName(tenantName);
|
||||
company.setCategoryId(661);
|
||||
@@ -1429,6 +1632,9 @@ public class MainController extends BaseController {
|
||||
userParam1.setTemplateId(user.getTemplateId());
|
||||
userParam1.setTenantId(addCompany.getTenantId()); // 使用新创建的租户ID
|
||||
final User adminByPhone = userService.getAdminByPhone(userParam1);
|
||||
// 注册即开通:标记该超管账号已安装(initialized)
|
||||
adminByPhone.setInstalled(true);
|
||||
userService.updateById(adminByPhone);
|
||||
|
||||
// 设置过期时间
|
||||
Long tokenExpireTime = configProperties.getTokenExpireTime();
|
||||
@@ -1489,17 +1695,20 @@ public class MainController extends BaseController {
|
||||
|
||||
/**
|
||||
* 重置密码(找回密码功能)
|
||||
* 支持两种验证方式:手机号+短信验证码 / 邮箱+邮箱验证码
|
||||
* 支持两种模式:
|
||||
* 1. 传 userId + tenantId:精确重置指定账号密码
|
||||
* 2. 不传 userId:通过手机号查找所有正常账号,统一重置密码(忘记密码场景)
|
||||
* 2. 不传 userId:通过手机号/邮箱查找所有正常账号,统一重置密码(忘记密码场景)
|
||||
*/
|
||||
@Operation(summary = "重置密码")
|
||||
@PostMapping("/resetPassword")
|
||||
@Transactional(rollbackFor = Exception.class, isolation = Isolation.SERIALIZABLE)
|
||||
public ApiResult<?> resetPassword(@RequestBody ResetPasswordParam param) {
|
||||
// 验证手机号
|
||||
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
|
||||
return fail("请输入有效的手机号码");
|
||||
// 判断找回方式:邮箱 or 手机
|
||||
boolean byEmail = StrUtil.isNotBlank(param.getEmail());
|
||||
boolean byPhone = StrUtil.isNotBlank(param.getPhone());
|
||||
if (!byEmail && !byPhone) {
|
||||
return fail("请提供手机号或邮箱");
|
||||
}
|
||||
|
||||
// 验证两次密码是否一致
|
||||
@@ -1512,21 +1721,42 @@ public class MainController extends BaseController {
|
||||
return fail("密码必须至少8位,且包含字母和数字");
|
||||
}
|
||||
|
||||
// 验证短信验证码
|
||||
String key = "code:" + param.getPhone();
|
||||
String cachedCode = redisUtil.get(key);
|
||||
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
|
||||
|
||||
if (!param.getSmsCode().equals(cachedCode) && !param.getSmsCode().equals(devCode)) {
|
||||
return fail("短信验证码不正确");
|
||||
// ===== 验证码校验 =====
|
||||
String verifyKey;
|
||||
String inputCode;
|
||||
if (byEmail) {
|
||||
if (!CommonUtil.isValidEmail(param.getEmail())) {
|
||||
return fail("请输入有效的邮箱地址");
|
||||
}
|
||||
if (StrUtil.isBlank(param.getEmailCode())) {
|
||||
return fail("邮箱验证码不能为空");
|
||||
}
|
||||
verifyKey = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
|
||||
inputCode = param.getEmailCode();
|
||||
} else {
|
||||
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
|
||||
return fail("请输入有效的手机号码");
|
||||
}
|
||||
if (StrUtil.isBlank(param.getSmsCode())) {
|
||||
return fail("短信验证码不能为空");
|
||||
}
|
||||
verifyKey = "code:" + param.getPhone();
|
||||
inputCode = param.getSmsCode();
|
||||
}
|
||||
|
||||
// ===== 模式判断:userId 为空时按手机号查所有用户统一重置 =====
|
||||
String cachedCode = redisUtil.get(verifyKey);
|
||||
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
|
||||
if (!inputCode.equals(cachedCode) && !inputCode.equals(devCode)) {
|
||||
return fail(byEmail ? "邮箱验证码不正确" : "短信验证码不正确");
|
||||
}
|
||||
|
||||
// ===== 模式判断:userId 为空时按手机号/邮箱查所有用户统一重置 =====
|
||||
if (StrUtil.isBlank(param.getUserId())) {
|
||||
// 通过手机号查找所有用户(跨租户,忽略租户隔离)
|
||||
List<User> userList = userMapper.selectAccountsByPhone(param.getPhone());
|
||||
List<User> userList = byEmail
|
||||
? userMapper.selectAccountsByEmail(param.getEmail())
|
||||
: userMapper.selectAccountsByPhone(param.getPhone());
|
||||
if (userList == null || userList.isEmpty()) {
|
||||
return fail("该手机号未注册");
|
||||
return fail(byEmail ? "该邮箱未注册" : "该手机号未注册");
|
||||
}
|
||||
// 过滤掉被冻结的账号
|
||||
List<User> activeUsers = userList.stream()
|
||||
@@ -1535,8 +1765,9 @@ public class MainController extends BaseController {
|
||||
if (activeUsers.isEmpty()) {
|
||||
return fail("账号已被冻结,请联系管理员");
|
||||
}
|
||||
// 统一重置所有同手机号的正常账号密码
|
||||
// 统一重置所有正常账号密码
|
||||
String encodedPassword = userService.encodePassword(param.getNewPassword());
|
||||
String methodDesc = byEmail ? "通过邮箱" : "通过手机号";
|
||||
for (User u : activeUsers) {
|
||||
User update = new User();
|
||||
update.setUserId(u.getUserId());
|
||||
@@ -1547,24 +1778,29 @@ public class MainController extends BaseController {
|
||||
record.setUsername(u.getUsername());
|
||||
record.setNickname(u.getNickname());
|
||||
record.setLoginType(5);
|
||||
record.setComments("密码重置成功(通过手机号)");
|
||||
record.setComments("密码重置成功(" + methodDesc + ")");
|
||||
record.setTenantId(u.getTenantId());
|
||||
loginRecordService.save(record);
|
||||
}
|
||||
redisUtil.delete(key);
|
||||
redisUtil.delete(verifyKey);
|
||||
return success("密码重置成功");
|
||||
}
|
||||
|
||||
// ===== 模式1:传了 userId,精确重置 =====
|
||||
// 验证用户是否存在且手机号匹配
|
||||
User user = userService.getByUserId(param.getUserId());
|
||||
if (user == null) {
|
||||
return fail("用户不存在");
|
||||
}
|
||||
|
||||
if (byEmail) {
|
||||
if (!param.getEmail().equals(user.getEmail())) {
|
||||
return fail("邮箱与账号不匹配");
|
||||
}
|
||||
} else {
|
||||
if (!param.getPhone().equals(user.getPhone())) {
|
||||
return fail("手机号与账号不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
if (param.getTenantId() != null && !param.getTenantId().equals(user.getTenantId())) {
|
||||
return fail("租户信息不匹配");
|
||||
@@ -1574,18 +1810,14 @@ public class MainController extends BaseController {
|
||||
boolean success = userService.resetUserPassword(param.getUserId(), user.getTenantId(), param.getNewPassword());
|
||||
|
||||
if (success) {
|
||||
// 密码重置成功后删除验证码
|
||||
redisUtil.delete(key);
|
||||
|
||||
// 记录登录日志(密码重置)
|
||||
redisUtil.delete(verifyKey);
|
||||
LoginRecord record = new LoginRecord();
|
||||
record.setUsername(user.getUsername());
|
||||
record.setNickname(user.getNickname());
|
||||
record.setLoginType(5); // 5表示密码重置(需要在LoginRecord中定义常量)
|
||||
record.setLoginType(5);
|
||||
record.setComments("密码重置成功");
|
||||
record.setTenantId(user.getTenantId());
|
||||
loginRecordService.save(record);
|
||||
|
||||
return success("密码重置成功");
|
||||
} else {
|
||||
return fail("密码重置失败,请稍后重试");
|
||||
@@ -1596,6 +1828,20 @@ public class MainController extends BaseController {
|
||||
* 检查手机号是否已注册(可选接口)
|
||||
*/
|
||||
@Operation(summary = "检查手机号是否已注册")
|
||||
@GetMapping("/checkEmailRegistered")
|
||||
public ApiResult<?> checkEmailRegistered(@RequestParam("email") String email) {
|
||||
// 邮箱格式校验
|
||||
if (!CommonUtil.isValidEmail(email)) {
|
||||
return fail("请输入有效的邮箱地址");
|
||||
}
|
||||
// 统计该邮箱注册的账号数量(跨租户,复用 findAccountsByEmail)
|
||||
List<User> accounts = userService.findAccountsByEmail(email);
|
||||
CheckEmailResult result = new CheckEmailResult();
|
||||
result.setIsRegistered(accounts != null && !accounts.isEmpty());
|
||||
result.setAccountCount(accounts != null ? accounts.size() : 0);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/checkPhoneRegistered")
|
||||
public ApiResult<?> checkPhoneRegistered(@RequestParam("phone") String phone) {
|
||||
// 验证手机号
|
||||
|
||||
@@ -111,6 +111,10 @@ public class SettingController extends BaseController {
|
||||
// 更新系统设置信息到缓存 key = ""
|
||||
String key = setting.getSettingKey().concat(":").concat(loginUser.getTenantId().toString());
|
||||
redisUtil.set(key, JSON.parseObject(setting.getContent()));
|
||||
// 上传配置变更后需同步清除上传缓存,避免 /api/oss/upload 继续命中旧配置
|
||||
if ("upload".equals(setting.getSettingKey())) {
|
||||
redisUtil.delete("Upload:" + loginUser.getTenantId());
|
||||
}
|
||||
// 更新租户信息
|
||||
if (setting.getSettingKey().equals("setting")) {
|
||||
final String content = setting.getContent();
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
|
||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.*;
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -49,6 +51,9 @@ public class TenantController extends BaseController {
|
||||
private RedisUtil redisUtil;
|
||||
@Resource
|
||||
private UserService userService;
|
||||
// cms权限参考租户, 默认取平台租户
|
||||
@Value("${websoft.cms.ref-tenant-id:10257}")
|
||||
private Integer cmsRefTenantId;
|
||||
|
||||
@Operation(summary = "分页查询租户")
|
||||
@GetMapping("/page")
|
||||
@@ -215,6 +220,40 @@ public class TenantController extends BaseController {
|
||||
return success(menus);
|
||||
}
|
||||
|
||||
@OperationLog
|
||||
@Operation(summary = "授予当前租户cms建站权限")
|
||||
@PostMapping("/grantCmsPermission")
|
||||
public ApiResult<?> grantCmsPermission(@RequestBody(required = false) Map<String, Object> body) {
|
||||
final Integer curTenant = getTenantId();
|
||||
if (curTenant == null) {
|
||||
return fail("获取当前租户失败, 请重新登录");
|
||||
}
|
||||
// 参考租户优先级: 请求参数 > 企业当前使用的租户模板 > 配置项websoft.cms.ref-tenant-id
|
||||
Integer refTenant = null;
|
||||
final Object refTenantId = body == null ? null : body.get("refTenantId");
|
||||
if (refTenantId instanceof Number) {
|
||||
refTenant = ((Number) refTenantId).intValue();
|
||||
} else if (refTenantId != null && refTenantId.toString().matches("\\d+")) {
|
||||
refTenant = Integer.valueOf(refTenantId.toString());
|
||||
}
|
||||
if (refTenant == null) {
|
||||
final Company company = getCompany();
|
||||
if (company != null && company.getPlanId() != null) {
|
||||
refTenant = company.getPlanId();
|
||||
}
|
||||
}
|
||||
if (refTenant == null) {
|
||||
refTenant = cmsRefTenantId;
|
||||
}
|
||||
if (refTenant == null) {
|
||||
return fail("未找到参考租户, 请配置websoft.cms.ref-tenant-id");
|
||||
}
|
||||
if (refTenant.equals(curTenant)) {
|
||||
return fail("参考租户不能与当前租户相同");
|
||||
}
|
||||
return tenantService.grantCmsPermission(refTenant, curTenant);
|
||||
}
|
||||
|
||||
@Operation(summary = "创建租户")
|
||||
@PostMapping("/saveByPhone")
|
||||
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {
|
||||
|
||||
@@ -294,6 +294,10 @@ public class Company implements Serializable {
|
||||
@TableField(exist = false)
|
||||
private String password;
|
||||
|
||||
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site)")
|
||||
@TableField(exist = false)
|
||||
private String appCode;
|
||||
|
||||
@Schema(description = "手机号(脱敏)")
|
||||
@TableField(exist = false)
|
||||
private String mobile;
|
||||
|
||||
@@ -61,6 +61,10 @@ public class User implements UserDetails {
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site)")
|
||||
@TableField(exist = false)
|
||||
private String appCode;
|
||||
|
||||
@Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)")
|
||||
@TableField(exist = false)
|
||||
private String emailCode;
|
||||
|
||||
@@ -36,4 +36,13 @@ public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType);
|
||||
|
||||
/**
|
||||
* 跨租户查询角色菜单(忽略租户拦截器)
|
||||
*
|
||||
* @param tenantId 租户id
|
||||
* @return List<RoleMenu>
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
List<RoleMenu> selectListAll(@Param("tenantId") Integer tenantId);
|
||||
|
||||
}
|
||||
|
||||
@@ -62,4 +62,13 @@ public interface UserRoleMapper extends BaseMapper<UserRole> {
|
||||
*/
|
||||
List<UserRole> selectListRel(@Param("param") UserRoleParam param);
|
||||
|
||||
/**
|
||||
* 跨租户查询用户角色(忽略租户拦截器)
|
||||
*
|
||||
* @param tenantId 租户id
|
||||
* @return List<UserRole>
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
List<UserRole> selectListAll(@Param("tenantId") Integer tenantId);
|
||||
|
||||
}
|
||||
|
||||
@@ -39,4 +39,9 @@
|
||||
ORDER BY a.sort_number
|
||||
</select>
|
||||
|
||||
<!-- 跨租户查询角色菜单 -->
|
||||
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.RoleMenu">
|
||||
SELECT * FROM sys_role_menu WHERE tenant_id = #{tenantId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -384,15 +384,20 @@
|
||||
|
||||
<!-- 根据邮箱查询所有账号(忽略租户隔离,用于跨租户邮箱登录) -->
|
||||
<select id="selectAccountsByEmail" resultType="com.gxwebsoft.common.system.entity.User">
|
||||
SELECT a.*,
|
||||
SELECT a.user_id,
|
||||
a.tenant_id,
|
||||
a.username,
|
||||
a.nickname,
|
||||
a.avatar,
|
||||
a.status,
|
||||
a.password,
|
||||
a.create_time,
|
||||
t.tenant_name
|
||||
FROM sys_user a
|
||||
LEFT JOIN sys_tenant t ON a.tenant_id = t.tenant_id
|
||||
WHERE a.deleted = 0
|
||||
AND a.email = #{email}
|
||||
AND (u.is_super_admin = 1 OR u.is_admin = 1)
|
||||
ORDER BY a.create_time DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 根据手机号统计账号数量(忽略租户隔离) -->
|
||||
@@ -472,7 +477,7 @@
|
||||
WHERE u.deleted = 0
|
||||
AND u.status = 0
|
||||
AND u.phone = #{phone}
|
||||
AND u.is_super_admin = 1
|
||||
AND (u.is_super_admin = 1 OR u.is_admin = 1)
|
||||
ORDER BY u.update_time DESC,
|
||||
u.create_time DESC,
|
||||
u.user_id DESC
|
||||
|
||||
@@ -64,4 +64,9 @@
|
||||
<include refid="selectSql"></include>
|
||||
</select>
|
||||
|
||||
<!-- 跨租户查询用户角色 -->
|
||||
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.UserRole">
|
||||
SELECT * FROM sys_user_role WHERE tenant_id = #{tenantId}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -21,6 +21,9 @@ public class EmailCaptchaParam implements Serializable {
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "邮箱验证码(校验时使用)")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private String tenantId;
|
||||
|
||||
|
||||
@@ -21,14 +21,18 @@ import java.io.Serializable;
|
||||
public class ResetPasswordParam implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Schema(description = "手机号码", required = true)
|
||||
@Schema(description = "手机号码(手机找回时必填)")
|
||||
private String phone;
|
||||
|
||||
@NotBlank(message = "短信验证码不能为空")
|
||||
@Schema(description = "短信验证码", required = true)
|
||||
@Schema(description = "短信验证码(手机找回时必填)")
|
||||
private String smsCode;
|
||||
|
||||
@Schema(description = "邮箱(邮箱找回时必填)")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "邮箱验证码(邮箱找回时必填)")
|
||||
private String emailCode;
|
||||
|
||||
@Schema(description = "用户ID(未登录忘记密码场景可不传,后端按手机号查用户)")
|
||||
private String userId;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.common.system.param;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 短信验证码重置支付密码参数
|
||||
*
|
||||
* @author WebSoft
|
||||
* @since 2026-08-03
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "短信验证码重置支付密码参数")
|
||||
public class ResetPayPasswordParam implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "手机号码")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "短信验证码")
|
||||
private String code;
|
||||
|
||||
@Schema(description = "新的支付密码(4位数字,明文透传,后端加密落库)")
|
||||
private String payPassword;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.gxwebsoft.common.system.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 检查邮箱返回结果
|
||||
*
|
||||
* @author WebSoft
|
||||
* @since 2026-08-06
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "检查邮箱返回结果")
|
||||
public class CheckEmailResult implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "是否已注册")
|
||||
private Boolean isRegistered;
|
||||
|
||||
@Schema(description = "账号数量")
|
||||
private Integer accountCount;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.gxwebsoft.common.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.Company;
|
||||
import com.gxwebsoft.common.system.entity.Tenant;
|
||||
@@ -45,4 +46,14 @@ public interface TenantService extends IService<Tenant> {
|
||||
boolean destructionAll(Integer tenantId);
|
||||
|
||||
Tenant getByCodeRel(String code);
|
||||
|
||||
/**
|
||||
* 授予当前租户cms建站权限
|
||||
* 从参考租户复制cms:*菜单到当前租户, 并绑定到当前租户的超级管理员角色
|
||||
*
|
||||
* @param refTenant 参考租户id(已配置好cms菜单的租户)
|
||||
* @param curTenant 当前租户id
|
||||
* @return ApiResult<?>
|
||||
*/
|
||||
ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import com.aliyun.oss.ClientException;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.OSSException;
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.common.comm.SignVersion;
|
||||
import com.aliyun.oss.common.auth.CredentialsProvider;
|
||||
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||
import com.gxwebsoft.common.core.utils.MinioUtil;
|
||||
import com.gxwebsoft.common.core.web.PageParam;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.Company;
|
||||
@@ -20,6 +23,8 @@ import com.gxwebsoft.common.system.param.FileRecordParam;
|
||||
import com.gxwebsoft.common.system.service.CompanyService;
|
||||
import com.gxwebsoft.common.system.service.FileRecordService;
|
||||
import com.gxwebsoft.common.system.service.SettingService;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -93,13 +98,32 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
|
||||
String bucketName = uploadConfig.getString("bucketName");
|
||||
String accessKeyId = uploadConfig.getString("accessKeyId");
|
||||
String accessKeySecret = uploadConfig.getString("accessKeySecret");
|
||||
String uploadMethod = uploadConfig.getString("uploadMethod");
|
||||
// 规范化 endpoint:去掉结尾斜杠,避免 minio-java 拼接出双斜杠导致签名不匹配
|
||||
endpoint = MinioUtil.normalizeEndpoint(endpoint);
|
||||
boolean isMinio = "minio".equals(uploadMethod);
|
||||
OSS ossClient = null;
|
||||
MinioClient minioClient = null;
|
||||
if (isMinio) {
|
||||
// MinIO 用官方 minio-java SDK(原生 AWS4 + path-style),aliyun OSS SDK 无法对接 MinIO
|
||||
// 显式指定 region=us-east-1,跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch)
|
||||
minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
|
||||
} else {
|
||||
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
|
||||
}
|
||||
for (FileRecord fileRecord : fileRecords) {
|
||||
fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), ""));
|
||||
try {
|
||||
// 删除远程文件
|
||||
if (isMinio) {
|
||||
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.object(fileRecord.getPath())
|
||||
.build());
|
||||
} else {
|
||||
ossClient.deleteObject(bucketName, fileRecord.getPath());
|
||||
}
|
||||
// 释放空间大小
|
||||
if (fileRecord.getCompanyId() > 0) {
|
||||
Company company = companyService.getById(fileRecord.getCompanyId());
|
||||
|
||||
@@ -182,7 +182,15 @@ public class SettingServiceImpl extends ServiceImpl<SettingMapper, Setting> impl
|
||||
|
||||
@Override
|
||||
public boolean updateByKey(Setting setting) {
|
||||
return update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
||||
boolean ok = update(setting, new QueryWrapper<Setting>().eq("setting_key", setting.getSettingKey()));
|
||||
// 上传配置变更后必须清缓存,否则 /api/oss/upload 仍读到旧的 Upload:{tenantId} 缓存
|
||||
if (ok && "upload".equals(setting.getSettingKey())) {
|
||||
Integer tenantId = setting.getTenantId();
|
||||
if (tenantId != null) {
|
||||
redisUtil.delete("Upload:" + tenantId);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,21 +2,39 @@ package com.gxwebsoft.common.system.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||
import com.gxwebsoft.common.core.utils.DomainUtil;
|
||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||
import com.gxwebsoft.common.system.entity.*;
|
||||
import com.gxwebsoft.common.system.mapper.MenuMapper;
|
||||
import com.gxwebsoft.common.system.mapper.RoleMapper;
|
||||
import com.gxwebsoft.common.system.mapper.RoleMenuMapper;
|
||||
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
|
||||
import com.gxwebsoft.common.system.mapper.TenantMapper;
|
||||
import com.gxwebsoft.common.system.param.MenuParam;
|
||||
import com.gxwebsoft.common.system.param.RoleParam;
|
||||
import com.gxwebsoft.common.system.service.*;
|
||||
import com.gxwebsoft.common.system.param.TenantParam;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.PageParam;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 租户Service实现
|
||||
@@ -24,14 +42,24 @@ import java.util.List;
|
||||
* @author 科技小王子
|
||||
* @since 2023-07-17 17:49:53
|
||||
*/
|
||||
@Service
|
||||
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
|
||||
@Service
|
||||
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
|
||||
|
||||
@Resource
|
||||
private CompanyService companyService;
|
||||
@Resource
|
||||
private MenuService menuService;
|
||||
@Resource
|
||||
private MenuMapper menuMapper;
|
||||
@Resource
|
||||
private RoleMapper roleMapper;
|
||||
@Resource
|
||||
private RoleMenuMapper roleMenuMapper;
|
||||
@Resource
|
||||
private UserRoleMapper userRoleMapper;
|
||||
@Resource
|
||||
private RoleMenuService roleMenuService;
|
||||
@Resource
|
||||
private RoleService roleService;
|
||||
@Resource
|
||||
private UserRoleService userRoleService;
|
||||
@@ -591,17 +619,38 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
||||
}
|
||||
|
||||
}
|
||||
// 发送邮件通知
|
||||
String title = "恭喜!您的应用已创建成功";
|
||||
String appUrl = "\r\n应用地址:" + DomainUtil.getSiteUrl(company.getTid().toString());
|
||||
String appName = "\r\n应用名称:" + company.getShortName();
|
||||
String adminUrl = "\r\n后台管理:" + DomainUtil.getAdminUrl(company.getTid().toString());
|
||||
String account = "\r\n账号:admin";
|
||||
String password = "\r\n密码:" + company.getPassword();
|
||||
String content = title + appUrl + appName + adminUrl + account + password;
|
||||
// 发送邮件通知(按应用代号区分域名:前端注册时传入 appCode,未知默认 site)
|
||||
// mp -> https://mp-{tid}.shoplnk.cn 后台 https://mp.websoft.top
|
||||
// shop -> https://shop-{tid}.shoplnk.cn 后台 https://shop.websoft.top
|
||||
// site -> https://site-{tid}.sitelnk.cn 后台 https://site.websoft.top
|
||||
final Map<String, String[]> appDomainMap = new HashMap<>(3);
|
||||
appDomainMap.put("mp", new String[]{"mp", "shoplnk.cn", "mp.websoft.top"});
|
||||
appDomainMap.put("shop", new String[]{"shop", "shoplnk.cn", "shop.websoft.top"});
|
||||
appDomainMap.put("site", new String[]{"site", "sitelnk.cn", "site.websoft.top"});
|
||||
final String appCode = StrUtil.isBlank(company.getAppCode()) ? "site" : company.getAppCode();
|
||||
final String[] domainCfg = appDomainMap.getOrDefault(appCode, appDomainMap.get("site"));
|
||||
final String sitePrefix = domainCfg[0];
|
||||
final String siteDomain = domainCfg[1];
|
||||
final String adminHost = domainCfg[2];
|
||||
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
|
||||
final String adminUrl = "https://" + adminHost;
|
||||
final String title = "您的企业官网已开通成功";
|
||||
final StringBuilder content = new StringBuilder();
|
||||
content.append("尊敬的用户:\r\n\r\n");
|
||||
content.append("恭喜!您的企业官网「").append(company.getShortName()).append("」已开通成功。\r\n\r\n");
|
||||
content.append("您可以使用以下信息登录管理后台,继续完善站点内容:\r\n\r\n");
|
||||
content.append("• 官网地址:").append(siteUrl).append("\r\n");
|
||||
content.append("• 后台地址:").append(adminUrl).append("\r\n");
|
||||
content.append("• 登录账号:").append(company.getEmail()).append("\r\n");
|
||||
content.append("• 登录密码:").append(company.getPassword()).append("\r\n\r\n");
|
||||
content.append("安全提示:\r\n");
|
||||
content.append("1. 请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。\r\n");
|
||||
content.append("2. 建议首次登录后立即进入「个人中心 > 修改密码」重置密码。\r\n\r\n");
|
||||
content.append("点击登录后台:").append(adminUrl).append("\r\n\r\n");
|
||||
content.append("如有疑问,请联系客服或访问帮助中心。");
|
||||
// 发送邮件通知
|
||||
if (company.getEmail() != null) {
|
||||
emailRecordService.sendEmail(title, content, company.getEmail(), company.getTid());
|
||||
emailRecordService.sendEmail(title, content.toString(), company.getEmail(), company.getTid());
|
||||
}
|
||||
return company;
|
||||
}
|
||||
@@ -632,4 +681,220 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
|
||||
return param.getOne(baseMapper.selectListRel(param));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = {Exception.class})
|
||||
public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) {
|
||||
if (refTenant == null || curTenant == null) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户或当前租户为空");
|
||||
}
|
||||
if (refTenant.equals(curTenant)) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户不能与当前租户相同");
|
||||
}
|
||||
final Map<String, Object> data = new HashMap<>();
|
||||
data.put("refTenantId", refTenant);
|
||||
data.put("tenantId", curTenant);
|
||||
|
||||
// ===== 1. 克隆角色 sys_role(按 role_code 去重,superAdmin/admin/user 已存在则复用)=====
|
||||
final RoleParam refRoleParam = new RoleParam();
|
||||
refRoleParam.setTenantId(refTenant);
|
||||
final List<Role> refRoles = roleMapper.selectListAll(refRoleParam);
|
||||
if (CollectionUtils.isEmpty(refRoles)) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的角色");
|
||||
}
|
||||
final RoleParam curRoleParam = new RoleParam();
|
||||
curRoleParam.setTenantId(curTenant);
|
||||
// role_code -> Role(当前租户已存在的角色)
|
||||
final Map<String, Role> curRoleByCode = roleMapper.selectListAll(curRoleParam).stream()
|
||||
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
|
||||
// oldRoleId -> newRoleId 映射
|
||||
final Map<Integer, Integer> roleIdMapping = new HashMap<>();
|
||||
// 待新增角色(按 role_code 去重)
|
||||
final Map<String, Role> toCreateByCode = new LinkedHashMap<>();
|
||||
for (Role r : refRoles) {
|
||||
final Role exist = curRoleByCode.get(r.getRoleCode());
|
||||
if (exist != null) {
|
||||
roleIdMapping.put(r.getRoleId(), exist.getRoleId());
|
||||
} else if (!toCreateByCode.containsKey(r.getRoleCode())) {
|
||||
final Role copy = new Role();
|
||||
copy.setRoleCode(r.getRoleCode());
|
||||
copy.setRoleName(r.getRoleName());
|
||||
copy.setComments(r.getComments());
|
||||
copy.setSortNumber(r.getSortNumber());
|
||||
copy.setTenantId(curTenant);
|
||||
toCreateByCode.put(r.getRoleCode(), copy);
|
||||
}
|
||||
}
|
||||
int roleCreated = 0;
|
||||
if (!toCreateByCode.isEmpty()) {
|
||||
roleService.saveBatch(new ArrayList<>(toCreateByCode.values()));
|
||||
// 重新读取当前租户角色,回填 oldRoleId -> newRoleId 映射
|
||||
final Map<String, Role> refreshed = roleMapper.selectListAll(curRoleParam).stream()
|
||||
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
|
||||
for (Role r : refRoles) {
|
||||
final Role nr = refreshed.get(r.getRoleCode());
|
||||
if (nr != null) {
|
||||
roleIdMapping.put(r.getRoleId(), nr.getRoleId());
|
||||
curRoleByCode.putIfAbsent(r.getRoleCode(), nr);
|
||||
}
|
||||
}
|
||||
roleCreated = toCreateByCode.size();
|
||||
}
|
||||
// 当前租户全部角色:roleId -> Role(含新克隆的),供后续 role_code 对齐使用
|
||||
final Map<Integer, Role> newRoleById = new HashMap<>();
|
||||
curRoleByCode.values().forEach(r -> newRoleById.put(r.getRoleId(), r));
|
||||
|
||||
// ===== 2. 克隆菜单 sys_menu(按层级复制,父先于子,标题/权限去重)=====
|
||||
final MenuParam refMenuParam = new MenuParam();
|
||||
refMenuParam.setTenantId(refTenant);
|
||||
refMenuParam.setDeleted(0);
|
||||
final List<Menu> refMenus = menuMapper.getMenuByClone(refMenuParam);
|
||||
if (CollectionUtils.isEmpty(refMenus)) {
|
||||
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单");
|
||||
}
|
||||
final Map<Integer, Menu> refMenuMap = new HashMap<>();
|
||||
for (Menu menu : refMenus) {
|
||||
refMenuMap.put(menu.getMenuId(), menu);
|
||||
}
|
||||
final MenuParam curMenuParam = new MenuParam();
|
||||
curMenuParam.setTenantId(curTenant);
|
||||
curMenuParam.setDeleted(0);
|
||||
final Map<String, Integer> curMenuKeys = new HashMap<>();
|
||||
for (Menu menu : menuMapper.getMenuByClone(curMenuParam)) {
|
||||
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
|
||||
}
|
||||
final List<Menu> sources = refMenus.stream()
|
||||
.sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap)))
|
||||
.collect(Collectors.toList());
|
||||
final Map<Integer, Integer> menuIdMapping = new HashMap<>();
|
||||
int menuCount = 0;
|
||||
for (Menu source : sources) {
|
||||
final Integer existsMenuId = curMenuKeys.get(getMenuKey(source));
|
||||
if (existsMenuId != null) {
|
||||
menuIdMapping.put(source.getMenuId(), existsMenuId);
|
||||
continue;
|
||||
}
|
||||
final Menu menu = new Menu();
|
||||
menu.setParentId(source.getParentId() == null ? 0 : menuIdMapping.getOrDefault(source.getParentId(), 0));
|
||||
menu.setTitle(source.getTitle());
|
||||
menu.setPath(source.getPath());
|
||||
menu.setComponent(source.getComponent());
|
||||
menu.setModules(source.getModules());
|
||||
menu.setModulesUrl(source.getModulesUrl());
|
||||
menu.setMenuType(source.getMenuType());
|
||||
menu.setSortNumber(source.getSortNumber());
|
||||
menu.setAuthority(source.getAuthority());
|
||||
menu.setIcon(source.getIcon());
|
||||
menu.setHide(source.getHide());
|
||||
menu.setMeta(source.getMeta());
|
||||
menu.setTenantId(curTenant);
|
||||
menuService.save(menu);
|
||||
menuIdMapping.put(source.getMenuId(), menu.getMenuId());
|
||||
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
|
||||
menuCount++;
|
||||
}
|
||||
|
||||
// ===== 3. 克隆角色菜单 sys_role_menu(roleId/menuId 用映射转换,去重)=====
|
||||
final Set<String> curRmKeys = roleMenuMapper.selectListAll(curTenant).stream()
|
||||
.map(rm -> rm.getRoleId() + "_" + rm.getMenuId())
|
||||
.collect(Collectors.toSet());
|
||||
final List<RoleMenu> rmsToSave = new ArrayList<>();
|
||||
for (RoleMenu rm : roleMenuMapper.selectListAll(refTenant)) {
|
||||
final Integer newRoleId = roleIdMapping.get(rm.getRoleId());
|
||||
final Integer newMenuId = menuIdMapping.get(rm.getMenuId());
|
||||
if (newRoleId == null || newMenuId == null) {
|
||||
continue;
|
||||
}
|
||||
final String key = newRoleId + "_" + newMenuId;
|
||||
if (curRmKeys.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
final RoleMenu copy = new RoleMenu();
|
||||
copy.setRoleId(newRoleId);
|
||||
copy.setMenuId(newMenuId);
|
||||
copy.setTenantId(curTenant);
|
||||
rmsToSave.add(copy);
|
||||
curRmKeys.add(key);
|
||||
}
|
||||
int roleMenuCreated = 0;
|
||||
if (!rmsToSave.isEmpty()) {
|
||||
roleMenuService.saveBatch(rmsToSave);
|
||||
roleMenuCreated = rmsToSave.size();
|
||||
}
|
||||
|
||||
// ===== 4. 克隆用户角色 sys_user_role(按 role_code 对齐当前租户用户,去重)=====
|
||||
final List<UserRole> curUserRoles = userRoleMapper.selectListAll(curTenant);
|
||||
final Set<String> curUrKeys = curUserRoles.stream()
|
||||
.map(ur -> ur.getUserId() + "_" + ur.getRoleId())
|
||||
.collect(Collectors.toSet());
|
||||
// 当前租户 userId -> 其 role_code 集合
|
||||
final Map<Integer, Set<String>> userRoleCodes = new HashMap<>();
|
||||
for (UserRole ur : curUserRoles) {
|
||||
final Role role = newRoleById.get(ur.getRoleId());
|
||||
if (role == null) {
|
||||
continue;
|
||||
}
|
||||
userRoleCodes.computeIfAbsent(ur.getUserId(), k -> new HashSet<>()).add(role.getRoleCode());
|
||||
}
|
||||
final List<UserRole> ursToSave = new ArrayList<>();
|
||||
for (UserRole ur : userRoleMapper.selectListAll(refTenant)) {
|
||||
final Integer newRoleId = roleIdMapping.get(ur.getRoleId());
|
||||
if (newRoleId == null) {
|
||||
continue;
|
||||
}
|
||||
final Role newRole = newRoleById.get(newRoleId);
|
||||
if (newRole == null) {
|
||||
continue;
|
||||
}
|
||||
final String roleCode = newRole.getRoleCode();
|
||||
// 找当前租户中 role_code 相同的用户,建立绑定
|
||||
for (Map.Entry<Integer, Set<String>> e : userRoleCodes.entrySet()) {
|
||||
if (!e.getValue().contains(roleCode)) {
|
||||
continue;
|
||||
}
|
||||
final String key = e.getKey() + "_" + newRoleId;
|
||||
if (curUrKeys.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
final UserRole copy = new UserRole();
|
||||
copy.setUserId(e.getKey());
|
||||
copy.setRoleId(newRoleId);
|
||||
copy.setTenantId(curTenant);
|
||||
ursToSave.add(copy);
|
||||
curUrKeys.add(key);
|
||||
}
|
||||
}
|
||||
int userRoleCreated = 0;
|
||||
if (!ursToSave.isEmpty()) {
|
||||
userRoleService.saveBatch(ursToSave);
|
||||
userRoleCreated = ursToSave.size();
|
||||
}
|
||||
|
||||
data.put("roleTotal", refRoles.size());
|
||||
data.put("roleCreated", roleCreated);
|
||||
data.put("menuTotal", menuIdMapping.size());
|
||||
data.put("menuCreated", menuCount);
|
||||
data.put("roleMenuCreated", roleMenuCreated);
|
||||
data.put("userRoleCreated", userRoleCreated);
|
||||
return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data);
|
||||
}
|
||||
|
||||
// 菜单去重标识, 优先使用权限标识, 目录类菜单权限标识为空时按标题+路由匹配
|
||||
private String getMenuKey(Menu menu) {
|
||||
if (StrUtil.isNotBlank(menu.getAuthority())) {
|
||||
return "authority:" + menu.getAuthority();
|
||||
}
|
||||
return "menu:" + StrUtil.nullToEmpty(menu.getTitle()) + "@" + StrUtil.nullToEmpty(menu.getPath());
|
||||
}
|
||||
|
||||
// 菜单层级, 用于保证父菜单先于子菜单插入
|
||||
private int getMenuDepth(Menu menu, Map<Integer, Menu> menuMap) {
|
||||
int depth = 0;
|
||||
Menu current = menu;
|
||||
while (current != null && current.getParentId() != null && current.getParentId() != 0 && depth < 20) {
|
||||
current = menuMap.get(current.getParentId());
|
||||
depth++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -208,8 +208,18 @@ public class EmailTemplateUtil {
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String title = "邮箱验证码 - WebSoft";
|
||||
String content = "您正在绑定或修改邮箱,验证码为:" + code + ",5 分钟内有效,请勿泄露给他人。如非本人操作,请忽略此邮件。";
|
||||
sendNotificationEmail(title, content, email, tenantId, "尊敬的用户", "验证码用于确认邮箱真实性,请勿转发给他人。", null, null);
|
||||
// 标题直接带上验证码,手机通知预览/邮件列表一眼可见
|
||||
String title = "【WebSoft】邮箱验证码:" + code;
|
||||
try {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("code", code);
|
||||
data.put("sendTime", DateUtil.now());
|
||||
|
||||
emailRecordService.sendHtmlEmail(title, "captcha.html", data, new String[]{email});
|
||||
} catch (Exception e) {
|
||||
// 如果HTML邮件发送失败,降级为文本邮件(标题仍含验证码)
|
||||
String content = "【WebSoft】邮箱验证码\r\n\r\n您的验证码:" + code + "\r\n\r\n5 分钟内有效,请勿泄露给他人。如非本人操作,请忽略此邮件。";
|
||||
emailRecordService.sendEmail(title, content, email, tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
218
src/main/resources/templates/captcha.html
Normal file
218
src/main/resources/templates/captcha.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSoft 邮箱验证码</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.47059;
|
||||
color: #1d1d1f;
|
||||
background-color: #ffffff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.wrap { width: 100%; background: #ffffff; padding: 40px 0 30px; }
|
||||
.container {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0 22px;
|
||||
}
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: #1d1d1f;
|
||||
margin: 8px 0 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
color: #1d1d1f;
|
||||
margin: 0 auto 26px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.card-caption {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #6e6e73;
|
||||
font-weight: 400;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.022em;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 14px;
|
||||
color: #6e6e73;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* 验证码数字卡片 */
|
||||
.code-card {
|
||||
text-align: center;
|
||||
padding: 38px 32px;
|
||||
}
|
||||
.code-caption {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.code-value {
|
||||
display: inline-block;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 44px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
letter-spacing: 14px;
|
||||
text-indent: 14px;
|
||||
line-height: 1.05;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
.code-expire {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* info 灰条 */
|
||||
.info-card {
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.info-card strong { color: #1d1d1f; font-weight: 600; }
|
||||
.info-card .label {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
margin-right: 8px;
|
||||
min-width: 56px;
|
||||
}
|
||||
a { color: #06c; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
/* 底部 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.016em;
|
||||
}
|
||||
.footer-links {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.footer-links a {
|
||||
color: #6e6e73;
|
||||
margin: 0 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
margin-top: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.copyright a { color: #6e6e73; }
|
||||
@media (max-width: 600px) {
|
||||
.page-title { font-size: 30px; }
|
||||
.code-value { font-size: 34px; letter-spacing: 10px; text-indent: 10px; }
|
||||
.card { padding: 22px 22px; border-radius: 16px; }
|
||||
.container { padding: 0 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<!-- Brand -->
|
||||
<div class="brand">
|
||||
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||
</div>
|
||||
|
||||
<!-- Title + Lede -->
|
||||
<h1 class="page-title">邮箱验证码</h1>
|
||||
<p class="lede">您正在绑定或修改邮箱,请使用下方验证码完成验证。</p>
|
||||
|
||||
<!-- 验证码主体卡片 -->
|
||||
<div class="card code-card">
|
||||
<div class="code-caption">您的验证码</div>
|
||||
<div class="code-value">${code!}</div>
|
||||
<div class="code-expire">⏱ 5 分钟内有效,请尽快使用</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作说明卡片 -->
|
||||
<div class="card info-card">
|
||||
<div class="card-title">验证码用于确认邮箱真实性</div>
|
||||
<div class="card-sub" style="margin-top:8px;">
|
||||
请将验证码输入到注册或修改邮箱页面以完成操作。验证码仅对当前请求有效,过期后请重新获取。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全提示卡片 -->
|
||||
<div class="card info-card">
|
||||
<div class="card-caption">安全提示</div>
|
||||
<div style="margin-top:6px;">
|
||||
• 验证码请勿转发或告诉他人,WebSoft 工作人员不会向您索取。<br>
|
||||
• 如非本人操作,请忽略此邮件,您的账号仍然安全。<br>
|
||||
• 多次未收到验证码可在客户端重新申请。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div style="text-align:center; font-size:12px; color:#6e6e73; margin-top:18px;">
|
||||
发送时间:${sendTime!}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-brand">WebSoft</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://websoft.top">官方网站</a>
|
||||
<a href="https://websoft.top/help">帮助中心</a>
|
||||
<a href="https://websoft.top/contact">联系我们</a>
|
||||
<a href="https://websoft.top/settings">账户设置</a>
|
||||
</div>
|
||||
<div class="copyright">
|
||||
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
|
||||
<a href="https://websoft.top">websoft.top</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,257 +3,208 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSoft通知</title>
|
||||
<title>WebSoft - 系统通知</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.47059;
|
||||
color: #1d1d1f;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #00d2ff;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
border-left: 4px solid #4facfe;
|
||||
}
|
||||
|
||||
.notification-content h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
color: #555;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
|
||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.4);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.6);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #e3f2fd;
|
||||
border: 1px solid #bbdefb;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 30px;
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: #1d1d1f;
|
||||
margin: 8px 0 18px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: #1d1d1f;
|
||||
margin: 0 auto 30px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
margin-bottom: 20px;
|
||||
.info-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px 24px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin: 20px 0;
|
||||
.info-card-title {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.info-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #1d1d1f;
|
||||
word-break: break-word;
|
||||
}
|
||||
.info-body a { color: #06c; text-decoration: none; }
|
||||
.info-body a:hover { text-decoration: underline; }
|
||||
|
||||
.footer-links a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
margin: 0 15px;
|
||||
.tips-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 22px 32px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.tips-caption {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.tips-list {
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.tips-list li {
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.tips-list li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
.cta {
|
||||
text-align: center;
|
||||
margin: 32px 0 6px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #34495e;
|
||||
.cta a {
|
||||
font-size: 17px;
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.cta a:hover { text-decoration: underline; }
|
||||
.cta-sub {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #6e6e73;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 44px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.016em;
|
||||
}
|
||||
.footer-links {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.footer-links a {
|
||||
color: #6e6e73;
|
||||
margin: 0 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
margin-top: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.copyright a { color: #6e6e73; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.page-title { font-size: 30px; }
|
||||
.lede { font-size: 16px; }
|
||||
.container { padding: 0 16px; }
|
||||
.info-card, .tips-card { padding: 22px 22px; border-radius: 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">WebSoft</div>
|
||||
<div class="header-subtitle">企业级数字化解决方案</div>
|
||||
<div class="notification-icon">📢</div>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<div class="brand">
|
||||
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h1 class="title">${title!'系统通知'}</h1>
|
||||
<p class="message">
|
||||
${greeting!'您好!'}我们有一条重要通知需要告知您,请查看以下详细信息。
|
||||
</p>
|
||||
<h1 class="page-title">${title!'系统通知'}</h1>
|
||||
<p class="lede">${greeting!'您好!'}我们有一条重要通知需要告知您,请查看以下详细信息。</p>
|
||||
|
||||
<!-- Notification Content -->
|
||||
<div class="notification-content">
|
||||
<h3>📋 通知详情</h3>
|
||||
<div class="notification-body">
|
||||
${content!'这是一条系统通知消息。'}
|
||||
</div>
|
||||
<!-- 通知详情卡(单张大卡) -->
|
||||
<div class="info-card">
|
||||
<div class="info-card-title">通知详情</div>
|
||||
<div class="info-body">${content!'这是一条系统通知消息。'}</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<!-- 温馨提示 -->
|
||||
<% if(has(infoMessage)) { %>
|
||||
<div class="info-box">
|
||||
<strong>ℹ️ 温馨提示:</strong> ${infoMessage!}
|
||||
<div class="tips-card">
|
||||
<div class="tips-caption">温馨提示</div>
|
||||
<ul class="tips-list">
|
||||
<li>${infoMessage!}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<!-- CTA -->
|
||||
<% if(has(actionUrl)) { %>
|
||||
<div class="cta-section">
|
||||
<a href="${actionUrl!}" class="cta-button">${actionText!'查看详情'}</a>
|
||||
<div class="cta">
|
||||
<a href="${actionUrl!}">${actionText!'查看详情'} →</a>
|
||||
</div>
|
||||
<div class="cta-sub">如按钮未生效,请将上面的链接复制到浏览器打开。</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="timestamp">
|
||||
发送时间:${sendTime!}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 时间戳 -->
|
||||
<div class="timestamp">发送时间:${sendTime!}</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-content">
|
||||
<strong>WebSoft Admin</strong><br>
|
||||
专业的企业数字化转型服务商
|
||||
</div>
|
||||
|
||||
<div class="footer-brand">WebSoft</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://websoft.top">官方网站</a>
|
||||
<a href="https://websoft.top/help">帮助中心</a>
|
||||
<a href="https://websoft.top/contact">联系我们</a>
|
||||
<a href="https://websoft.top/settings">账户设置</a>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
© 2025 WebSoft Inc.
|
||||
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
|
||||
<a href="https://websoft.top">websoft.top</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,294 +3,255 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSoft密码重置</title>
|
||||
<title>WebSoft - 密码重置</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.47059;
|
||||
color: #1d1d1f;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #ffa502;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.reset-info {
|
||||
background-color: #fff5f5;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
border-left: 4px solid #ff6b6b;
|
||||
}
|
||||
|
||||
.reset-info h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #fee;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
|
||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 107, 0.6);
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.security-tips h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.security-tips ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.security-tips li {
|
||||
padding: 8px 0;
|
||||
color: #666;
|
||||
position: relative;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.security-tips li:before {
|
||||
content: "🔒";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 30px;
|
||||
.page-title {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: #1d1d1f;
|
||||
margin: 8px 0 18px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: #1d1d1f;
|
||||
margin: 0 auto 30px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
margin-bottom: 20px;
|
||||
.info-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px 24px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin: 20px 0;
|
||||
.info-card-title {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
margin: 0 15px;
|
||||
.info-cols {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.info-col {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
width: 50%;
|
||||
padding-right: 18px;
|
||||
}
|
||||
.info-col + .info-col {
|
||||
border-left: 1px solid #d2d2d7;
|
||||
padding-left: 22px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.field-row { padding: 8px 0; }
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.field-value {
|
||||
font-size: 15px;
|
||||
color: #1d1d1f;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-value.mono {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
.tips-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 22px 32px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.tips-caption {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.tips-list {
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.tips-list li {
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.tips-list li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.warning-card {
|
||||
background-color: #fff5f5;
|
||||
border-radius: 18px;
|
||||
padding: 22px 32px;
|
||||
margin: 0 0 22px;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.warning-card strong { color: #b3271d; font-weight: 600; }
|
||||
|
||||
.cta {
|
||||
text-align: center;
|
||||
margin: 32px 0 6px;
|
||||
}
|
||||
.cta a {
|
||||
font-size: 17px;
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.cta a:hover { text-decoration: underline; }
|
||||
.cta-sub {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.help {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin: 26px auto 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help a { color: #06c; text-decoration: none; }
|
||||
.help a:hover { text-decoration: underline; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 44px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.016em;
|
||||
}
|
||||
.footer-links {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.footer-links a {
|
||||
color: #6e6e73;
|
||||
margin: 0 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #34495e;
|
||||
}
|
||||
|
||||
.warning-notice {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-notice strong {
|
||||
color: #533f03;
|
||||
color: #6e6e73;
|
||||
margin-top: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.copyright a { color: #6e6e73; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
.page-title { font-size: 30px; }
|
||||
.lede { font-size: 16px; }
|
||||
.container { padding: 0 16px; }
|
||||
.info-card, .tips-card, .warning-card { padding: 22px 22px; border-radius: 16px; }
|
||||
.info-cols { display: block; }
|
||||
.info-col { display: block; width: 100%; padding: 0; }
|
||||
.info-col + .info-col {
|
||||
border-left: none;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
padding: 12px 0 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">WebSoft</div>
|
||||
<div class="header-subtitle">企业级数字化解决方案</div>
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<div class="brand">
|
||||
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h1 class="title">密码重置通知</h1>
|
||||
<p class="message">
|
||||
您好!我们收到了您的密码重置请求。为了保障您的账户安全,请查看以下信息。
|
||||
<h1 class="page-title">密码重置</h1>
|
||||
<p class="lede">
|
||||
您好!我们收到了您的密码重置请求,请查看以下账户信息以便您确认是否为本人操作。
|
||||
</p>
|
||||
|
||||
<!-- Reset Information -->
|
||||
<div class="reset-info">
|
||||
<h3>🔑 重置信息</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">账号:</span>
|
||||
<span class="info-value">${username!}</span>
|
||||
<!-- 资料大卡 -->
|
||||
<div class="info-card">
|
||||
<div class="info-card-title">账户信息</div>
|
||||
<div class="info-cols">
|
||||
<div class="info-col">
|
||||
<div class="field-row">
|
||||
<div class="field-label">登录账号</div>
|
||||
<div class="field-value">${username!}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">手机号:</span>
|
||||
<span class="info-value">${phone!}</span>
|
||||
<div class="field-row">
|
||||
<div class="field-label">手机号</div>
|
||||
<div class="field-value">${phone!}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">重置时间:</span>
|
||||
<span class="info-value">${resetTime!}</span>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<div class="field-row">
|
||||
<div class="field-label">重置时间</div>
|
||||
<div class="field-value">${resetTime!}</div>
|
||||
</div>
|
||||
<% if(has(newPassword)) { %>
|
||||
<div class="info-item">
|
||||
<span class="info-label">新密码:</span>
|
||||
<span class="info-value">${newPassword!}</span>
|
||||
<div class="field-row">
|
||||
<div class="field-label">新密码</div>
|
||||
<div class="field-value mono">${newPassword!}</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<!-- Warning Notice -->
|
||||
<div class="warning-notice">
|
||||
<strong>⚠️ 重要提醒:</strong> 如果这不是您本人的操作,请立即联系客服并修改密码!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="cta-section">
|
||||
<a href="https://websoft.top/login" class="cta-button">立即登录</a>
|
||||
<!-- 警告卡片 -->
|
||||
<div class="warning-card">
|
||||
<strong>重要提醒:</strong>如果这不是您本人的操作,请立即<a href="${contactUrl!'https://websoft.top/contact'}" style="color:#b3271d;text-decoration:underline;">联系客服</a>并修改密码,您的账户可能已被窃取。
|
||||
</div>
|
||||
|
||||
<!-- Security Tips -->
|
||||
<div class="security-tips">
|
||||
<h3>🛡️ 安全建议</h3>
|
||||
<ul>
|
||||
<!-- 安全建议 -->
|
||||
<div class="tips-card">
|
||||
<div class="tips-caption">安全建议</div>
|
||||
<ul class="tips-list">
|
||||
<li>建议您立即登录并修改为更安全的密码</li>
|
||||
<li>密码应包含大小写字母、数字和特殊字符</li>
|
||||
<li>不要在多个网站使用相同的密码</li>
|
||||
@@ -298,25 +259,30 @@
|
||||
<li>如发现异常登录,请及时联系客服</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="cta">
|
||||
<a href="${loginUrl!'https://websoft.top/login'}">立即登录 →</a>
|
||||
</div>
|
||||
<div class="cta-sub">如按钮未生效,请将上面的登录地址复制到浏览器打开。</div>
|
||||
|
||||
<p class="help">
|
||||
如有疑问,请联系客服或访问 <a href="${helpUrl!'https://websoft.top/help'}">帮助中心</a>。
|
||||
</p>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-content">
|
||||
<strong>WebSoft Admin</strong><br>
|
||||
专业的企业数字化转型服务商
|
||||
</div>
|
||||
|
||||
<div class="footer-brand">WebSoft</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://websoft.top">官方网站</a>
|
||||
<a href="https://websoft.top/help">帮助中心</a>
|
||||
<a href="https://websoft.top/contact">联系我们</a>
|
||||
<a href="https://websoft.top/settings">账户设置</a>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
© 2025 WebSoft Inc.
|
||||
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
|
||||
<a href="https://websoft.top">websoft.top</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,348 +3,290 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSoft账号注册成功</title>
|
||||
<title>WebSoft - 企业官网开通成功</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.47059;
|
||||
color: #1d1d1f;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.wrap { width: 100%; background: #ffffff; padding: 40px 0 36px; }
|
||||
.container { max-width: 680px; margin: 0 auto; padding: 0 22px; }
|
||||
|
||||
.brand { text-align: center; margin-bottom: 28px; }
|
||||
.brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
.page-title {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: #1d1d1f;
|
||||
margin: 8px 0 18px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.lede {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: #1d1d1f;
|
||||
margin: 0 auto 30px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.lede strong { font-weight: 600; }
|
||||
|
||||
/* 一张大资料卡,左"APPLE 账户"右"站点信息" */
|
||||
.info-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px 24px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.info-card-title {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.info-cols {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.info-col {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
width: 50%;
|
||||
padding-right: 18px;
|
||||
}
|
||||
.info-col + .info-col {
|
||||
border-left: 1px solid #d2d2d7;
|
||||
padding-left: 22px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.field-row {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.field-value {
|
||||
font-size: 15px;
|
||||
color: #1d1d1f;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-value a {
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
}
|
||||
.field-value a:hover { text-decoration: underline; }
|
||||
.field-value.mono {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
/* 安全提示卡片 */
|
||||
.tips-card {
|
||||
background-color: #f5f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 22px 32px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.tips-caption {
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 2px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.tips-list {
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.tips-list li {
|
||||
padding-left: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.tips-list li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
/* CTA 文字链接(仿 Apple 的"管理账户"链接样式) */
|
||||
.cta {
|
||||
text-align: center;
|
||||
margin: 32px 0 6px;
|
||||
}
|
||||
.cta a {
|
||||
font-size: 17px;
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.022em;
|
||||
}
|
||||
.cta a:hover { text-decoration: underline; }
|
||||
.cta-sub {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
/* 帮助条 */
|
||||
.help {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6e6e73;
|
||||
margin: 26px auto 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help a { color: #06c; text-decoration: none; }
|
||||
.help a:hover { text-decoration: underline; }
|
||||
|
||||
/* 底部 */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 44px;
|
||||
padding-top: 22px;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.016em;
|
||||
}
|
||||
.footer-links {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.account-info h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.features {
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.features h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #3498db;
|
||||
color: #6e6e73;
|
||||
margin: 0 14px;
|
||||
text-decoration: none;
|
||||
margin: 0 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-links a:hover { color: #1d1d1f; text-decoration: underline; }
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
color: #95a5a6;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #34495e;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.security-notice strong {
|
||||
color: #533f03;
|
||||
color: #6e6e73;
|
||||
margin-top: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.copyright a { color: #6e6e73; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.page-title { font-size: 30px; }
|
||||
.lede { font-size: 16px; }
|
||||
.container { padding: 0 16px; }
|
||||
.info-card, .tips-card { padding: 22px 22px; border-radius: 16px; }
|
||||
.info-cols { display: block; }
|
||||
.info-col { display: block; width: 100%; padding: 0; }
|
||||
.info-col + .info-col {
|
||||
border-left: none;
|
||||
border-top: 1px solid #d2d2d7;
|
||||
padding: 12px 0 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">WebSoft</div>
|
||||
<div class="header-subtitle">企业级数字化解决方案</div>
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="wrap">
|
||||
<div class="container">
|
||||
<!-- Brand -->
|
||||
<div class="brand">
|
||||
<img src="https://oss.wsdns.cn/20260807/0f469a3167b94e2e81f2ef1433398e5f.png" alt="WebSoft" class="brand-logo">
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h1 class="welcome-title">恭喜!账号注册成功</h1>
|
||||
<p class="welcome-message">
|
||||
欢迎加入WebSoft大家庭!您的账号已成功创建,现在可以开始体验我们的企业级服务了。
|
||||
<!-- Title + Lede -->
|
||||
<h1 class="page-title">开通成功</h1>
|
||||
<p class="lede">
|
||||
您好!您的企业官网「<strong>${siteName!}</strong>」已开通成功。
|
||||
</p>
|
||||
|
||||
<!-- Account Information -->
|
||||
<div class="account-info">
|
||||
<h3>📋 您的账号信息</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">用户名:</span>
|
||||
<span class="info-value">${username!}</span>
|
||||
<!-- 资料大卡:左站点 / 右账号 -->
|
||||
<div class="info-card">
|
||||
<div class="info-card-title">账户信息</div>
|
||||
<div class="info-cols">
|
||||
<div class="info-col">
|
||||
<div class="field-row">
|
||||
<div class="field-label">官网地址</div>
|
||||
<div class="field-value">
|
||||
<a href="${siteUrl!}">${siteUrl!}</a>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">手机号:</span>
|
||||
<span class="info-value">${phone!}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">登录密码:</span>
|
||||
<span class="info-value">${password!}</span>
|
||||
<div class="field-row">
|
||||
<div class="field-label">后台地址</div>
|
||||
<div class="field-value">
|
||||
<a href="${adminUrl!}">${adminUrl!}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<div class="field-row">
|
||||
<div class="field-label">登录账号</div>
|
||||
<div class="field-value">${account!}</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-label">登录密码</div>
|
||||
<div class="field-value mono">${password!}</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if(has(email)) { %>
|
||||
<div class="info-item">
|
||||
<span class="info-label">邮箱:</span>
|
||||
<span class="info-value">${email!}</span>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="security-notice">
|
||||
<strong>🔒 安全提醒:</strong> 请妥善保管您的登录信息,建议首次登录后立即修改密码。
|
||||
<!-- 安全提示 -->
|
||||
<div class="tips-card">
|
||||
<div class="tips-caption">安全提示</div>
|
||||
<ul class="tips-list">
|
||||
<li>请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。</li>
|
||||
<li>建议首次登录后立即进入「个人中心 > 修改密码」重置密码。</li>
|
||||
<li>如发现可疑登录或账号被盗,请立即联系客服并修改密码。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="cta-section">
|
||||
<a href="https://websoft.top/login" class="cta-button">立即登录</a>
|
||||
<!-- CTA -->
|
||||
<div class="cta">
|
||||
<a href="${adminUrl!}">点击登录后台 →</a>
|
||||
</div>
|
||||
<div class="cta-sub">如按钮未生效,请将上面的后台地址复制到浏览器打开。</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="features">
|
||||
<h3>🚀 开始探索WebSoft的强大功能</h3>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">📊</div>
|
||||
<div class="feature-title">数据分析</div>
|
||||
<div class="feature-desc">智能数据洞察</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🔧</div>
|
||||
<div class="feature-title">系统管理</div>
|
||||
<div class="feature-desc">高效运营管理</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">👥</div>
|
||||
<div class="feature-title">团队协作</div>
|
||||
<div class="feature-desc">无缝团队合作</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<div class="feature-title">安全保障</div>
|
||||
<div class="feature-desc">企业级安全</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 帮助 -->
|
||||
<p class="help">
|
||||
如有疑问,请联系客服或访问 <a href="${helpUrl!'https://websoft.top/help'}">帮助中心</a>。
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-content">
|
||||
<strong>WebSoft Admin</strong><br>
|
||||
专业的企业数字化转型服务商
|
||||
</div>
|
||||
|
||||
<div class="footer-brand">WebSoft</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://websoft.top">官方网站</a>
|
||||
<a href="https://websoft.top/help">帮助中心</a>
|
||||
<a href="https://websoft.top/contact">联系我们</a>
|
||||
<a href="https://websoft.top/settings">账户设置</a>
|
||||
</div>
|
||||
|
||||
<div class="copyright">
|
||||
© 2025 WebSoft Inc.
|
||||
Copyright © 2026 WebSoft Inc. 保留所有权利。<br>
|
||||
<a href="https://websoft.top">websoft.top</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user