微信小程序登录修改
This commit is contained in:
@@ -103,6 +103,10 @@ INSERT INTO config.tb_sys_config (
|
||||
('CFG-0703', 'cfg_wechat_kefu_token', 'wechat.kefu.token', '回调Token', '', 'String', 'input', '消息回调的Token', NULL, NULL, 'wechat', 'mod_workcase', 30, 1, '用于验证消息回调的Token', 'system', NULL, NULL, now(), NULL, NULL, false),
|
||||
('CFG-0704', 'cfg_wechat_kefu_aeskey', 'wechat.kefu.encodingAesKey','回调加密密钥', '', 'String', 'password', '消息回调的EncodingAESKey', NULL, NULL, 'wechat', 'mod_workcase', 40, 1, '用于解密消息回调的AES密钥', 'system', NULL, NULL, now(), NULL, NULL, false),
|
||||
('CFG-0705', 'cfg_wechat_kefu_openkfid', 'wechat.kefu.openKfid', '客服账号ID', '', 'String', 'input', '微信客服账号的open_kfid', NULL, NULL, 'wechat', 'mod_workcase', 50, 1, '用于发送消息的客服账号ID', 'system', NULL, NULL, now(), NULL, NULL, false),
|
||||
('CFG-0706', 'cfg_wechat_kefu_welcome', 'wechat.kefu.welcomeTemplate','欢迎语模板', '您好,您的工单已创建。\n工单编号:{workcaseId}\n问题类型:{type}\n设备:{device}\n我们将尽快为您处理。', 'String', 'textarea', '客服欢迎语消息模板', NULL, NULL, 'wechat', 'mod_workcase', 60, 1, '支持变量:{workcaseId},{type},{device},{username}', 'system', NULL, NULL, now(), NULL, NULL, false);
|
||||
('CFG-0706', 'cfg_wechat_kefu_welcome', 'wechat.kefu.welcomeTemplate','欢迎语模板', '您好,您的工单已创建。\n工单编号:{workcaseId}\n问题类型:{type}\n设备:{device}\n我们将尽快为您处理。', 'String', 'textarea', '客服欢迎语消息模板', NULL, NULL, 'wechat', 'mod_workcase', 60, 1, '支持变量:{workcaseId},{type},{device},{username}', 'system', NULL, NULL, now(), NULL, NULL, false),
|
||||
|
||||
-- 微信小程序配置
|
||||
('CFG-0710', 'cfg_wechat_mp_appid', 'wechat.miniprogram.appid', '小程序AppID', 'wx3708f41b1dc31f52', 'String', 'input', '微信小程序的AppID', NULL, NULL, 'wechat', 'mod_workcase', 70, 1, '在微信公众平台获取', 'system', NULL, NULL, now(), NULL, NULL, false),
|
||||
('CFG-0711', 'cfg_wechat_mp_appsecret', 'wechat.miniprogram.appsecret', '小程序AppSecret', '127dcc9c90dd1b66a700b52094922253', 'String', 'password', '微信小程序的AppSecret', NULL, NULL, 'wechat', 'mod_workcase', 80, 1, '在微信公众平台获取,用于获取openid和解密手机号', 'system', NULL, NULL, now(), NULL, NULL, false);
|
||||
|
||||
|
||||
|
||||
@@ -94,4 +94,30 @@ public class LoginParam implements Serializable {
|
||||
*/
|
||||
private String token;
|
||||
|
||||
// ========== 微信小程序登录相关字段 ==========
|
||||
|
||||
/**
|
||||
* 微信登录code(wx.login返回,用于换取openid和session_key)
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/**
|
||||
* 手机号授权code(getPhoneNumber返回,新版API推荐使用)
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
private String phoneCode;
|
||||
|
||||
/**
|
||||
* 加密数据(getPhoneNumber返回,旧版API用于解密手机号)
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
private String encryptedData;
|
||||
|
||||
/**
|
||||
* 解密向量(getPhoneNumber返回,旧版API用于解密手机号)
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
private String iv;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.xyzh.common.wechat.config;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信小程序配置
|
||||
* @author cascade
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
@Data
|
||||
public class WeChatMiniProgramConfig {
|
||||
|
||||
/** 小程序AppID */
|
||||
private String appId;
|
||||
|
||||
/** 小程序AppSecret */
|
||||
private String appSecret;
|
||||
|
||||
/** 当前access_token */
|
||||
private String accessToken;
|
||||
|
||||
/** access_token过期时间(毫秒时间戳) */
|
||||
private Long accessTokenExpireTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.xyzh.common.wechat.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信小程序获取手机号接口返回结果
|
||||
* @author cascade
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
@Data
|
||||
public class WeChatPhoneResult {
|
||||
|
||||
/** 错误码 */
|
||||
private Integer errcode;
|
||||
|
||||
/** 错误信息 */
|
||||
private String errmsg;
|
||||
|
||||
/** 手机号信息 */
|
||||
private PhoneInfo phoneInfo;
|
||||
|
||||
/**
|
||||
* 手机号信息
|
||||
*/
|
||||
@Data
|
||||
public static class PhoneInfo {
|
||||
/** 用户绑定的手机号(国外手机号会有区号) */
|
||||
private String phoneNumber;
|
||||
|
||||
/** 没有区号的手机号 */
|
||||
private String purePhoneNumber;
|
||||
|
||||
/** 区号 */
|
||||
private String countryCode;
|
||||
|
||||
/** 数据水印 */
|
||||
private Watermark watermark;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据水印
|
||||
*/
|
||||
@Data
|
||||
public static class Watermark {
|
||||
/** 小程序appid */
|
||||
private String appid;
|
||||
|
||||
/** 时间戳 */
|
||||
private Long timestamp;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.xyzh.common.wechat.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信小程序 code2Session 接口返回结果
|
||||
* @author cascade
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
@Data
|
||||
public class WeChatSessionResult {
|
||||
|
||||
/** 用户唯一标识 */
|
||||
private String openid;
|
||||
|
||||
/** 会话密钥 */
|
||||
private String sessionKey;
|
||||
|
||||
/** 用户在开放平台的唯一标识符(需要绑定开放平台) */
|
||||
private String unionid;
|
||||
|
||||
/** 错误码 */
|
||||
private Integer errcode;
|
||||
|
||||
/** 错误信息 */
|
||||
private String errmsg;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package org.xyzh.common.wechat.service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.apache.dubbo.config.annotation.DubboReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.xyzh.api.system.service.SysConfigService;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.wechat.config.WeChatMiniProgramConfig;
|
||||
import org.xyzh.common.wechat.pojo.WeChatPhoneResult;
|
||||
import org.xyzh.common.wechat.pojo.WeChatSessionResult;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
/**
|
||||
* 微信小程序服务
|
||||
* @author cascade
|
||||
* @since 2026-01-09
|
||||
*/
|
||||
@Service
|
||||
public class WeChatMiniProgramService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WeChatMiniProgramService.class);
|
||||
|
||||
/** 微信小程序配置缓存 */
|
||||
private WeChatMiniProgramConfig config;
|
||||
|
||||
/** code2Session接口地址 */
|
||||
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
|
||||
|
||||
/** 获取access_token接口地址 */
|
||||
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
|
||||
|
||||
/** 获取手机号接口地址(新版API) */
|
||||
private static final String GET_PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
|
||||
|
||||
@DubboReference(version = "1.0.0", group = "system", timeout = 5000, check = false, retries = 0)
|
||||
private SysConfigService sysConfigService;
|
||||
|
||||
/**
|
||||
* 获取微信小程序配置
|
||||
*/
|
||||
public WeChatMiniProgramConfig getConfig() {
|
||||
if (config == null) {
|
||||
loadConfig();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从系统配置加载微信小程序配置
|
||||
*/
|
||||
private synchronized void loadConfig() {
|
||||
if (config != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
config = new WeChatMiniProgramConfig();
|
||||
|
||||
try {
|
||||
// 从系统配置获取小程序AppID
|
||||
ResultDomain<String> appIdResult = sysConfigService.getConfigValue("wechat.miniprogram.appid");
|
||||
if (appIdResult.getSuccess() && appIdResult.getData() != null) {
|
||||
config.setAppId(appIdResult.getData());
|
||||
}
|
||||
|
||||
// 从系统配置获取小程序AppSecret
|
||||
ResultDomain<String> appSecretResult = sysConfigService.getConfigValue("wechat.miniprogram.appsecret");
|
||||
if (appSecretResult.getSuccess() && appSecretResult.getData() != null) {
|
||||
config.setAppSecret(appSecretResult.getData());
|
||||
}
|
||||
|
||||
logger.info("微信小程序配置加载成功, appId: {}", config.getAppId());
|
||||
} catch (Exception e) {
|
||||
logger.error("加载微信小程序配置失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新配置
|
||||
*/
|
||||
public void refreshConfig() {
|
||||
config = null;
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过code获取session信息(openid和session_key)
|
||||
* @param code wx.login返回的code
|
||||
* @return session信息
|
||||
*/
|
||||
public ResultDomain<WeChatSessionResult> code2Session(String code) {
|
||||
WeChatMiniProgramConfig cfg = getConfig();
|
||||
if (cfg == null || cfg.getAppId() == null || cfg.getAppSecret() == null) {
|
||||
return ResultDomain.failure("微信小程序配置未初始化");
|
||||
}
|
||||
|
||||
try {
|
||||
String url = String.format("%s?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
|
||||
CODE2SESSION_URL, cfg.getAppId(), cfg.getAppSecret(), code);
|
||||
|
||||
String response = httpGet(url);
|
||||
logger.debug("code2Session响应: {}", response);
|
||||
|
||||
WeChatSessionResult result = JSON.parseObject(response, WeChatSessionResult.class);
|
||||
|
||||
if (result.getErrcode() != null && result.getErrcode() != 0) {
|
||||
logger.error("code2Session失败: {} - {}", result.getErrcode(), result.getErrmsg());
|
||||
return ResultDomain.failure("获取session失败: " + result.getErrmsg());
|
||||
}
|
||||
|
||||
return ResultDomain.success("获取session成功", result);
|
||||
} catch (Exception e) {
|
||||
logger.error("code2Session异常", e);
|
||||
return ResultDomain.failure("获取session异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取access_token
|
||||
* @return access_token
|
||||
*/
|
||||
public String getAccessToken() {
|
||||
WeChatMiniProgramConfig cfg = getConfig();
|
||||
if (cfg == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存的token是否有效
|
||||
if (cfg.getAccessToken() != null && cfg.getAccessTokenExpireTime() != null
|
||||
&& System.currentTimeMillis() < cfg.getAccessTokenExpireTime()) {
|
||||
return cfg.getAccessToken();
|
||||
}
|
||||
|
||||
// 重新获取token
|
||||
try {
|
||||
String url = String.format("%s?grant_type=client_credential&appid=%s&secret=%s",
|
||||
ACCESS_TOKEN_URL, cfg.getAppId(), cfg.getAppSecret());
|
||||
|
||||
String response = httpGet(url);
|
||||
JSONObject json = JSON.parseObject(response);
|
||||
|
||||
if (json.containsKey("access_token")) {
|
||||
String accessToken = json.getString("access_token");
|
||||
int expiresIn = json.getIntValue("expires_in");
|
||||
|
||||
cfg.setAccessToken(accessToken);
|
||||
// 提前5分钟过期
|
||||
cfg.setAccessTokenExpireTime(System.currentTimeMillis() + (expiresIn - 300) * 1000L);
|
||||
|
||||
logger.info("获取access_token成功, 有效期: {}秒", expiresIn);
|
||||
return accessToken;
|
||||
} else {
|
||||
logger.error("获取access_token失败: {}", response);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("获取access_token异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过phoneCode获取手机号(新版API,推荐使用)
|
||||
* @param phoneCode getPhoneNumber返回的code
|
||||
* @return 手机号信息
|
||||
*/
|
||||
public ResultDomain<WeChatPhoneResult> getPhoneNumber(String phoneCode) {
|
||||
String accessToken = getAccessToken();
|
||||
if (accessToken == null) {
|
||||
return ResultDomain.failure("获取access_token失败");
|
||||
}
|
||||
|
||||
try {
|
||||
String url = GET_PHONE_URL + "?access_token=" + accessToken;
|
||||
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.put("code", phoneCode);
|
||||
|
||||
String response = httpPost(url, requestBody.toJSONString());
|
||||
logger.debug("getPhoneNumber响应: {}", response);
|
||||
|
||||
WeChatPhoneResult result = JSON.parseObject(response, WeChatPhoneResult.class);
|
||||
|
||||
if (result.getErrcode() != null && result.getErrcode() != 0) {
|
||||
logger.error("getPhoneNumber失败: {} - {}", result.getErrcode(), result.getErrmsg());
|
||||
return ResultDomain.failure("获取手机号失败: " + result.getErrmsg());
|
||||
}
|
||||
|
||||
return ResultDomain.success("获取手机号成功", result);
|
||||
} catch (Exception e) {
|
||||
logger.error("getPhoneNumber异常", e);
|
||||
return ResultDomain.failure("获取手机号异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过encryptedData和iv解密手机号(旧版API)
|
||||
* @param sessionKey 会话密钥
|
||||
* @param encryptedData 加密数据
|
||||
* @param iv 解密向量
|
||||
* @return 解密后的手机号
|
||||
*/
|
||||
public ResultDomain<String> decryptPhoneNumber(String sessionKey, String encryptedData, String iv) {
|
||||
try {
|
||||
byte[] sessionKeyBytes = Base64.getDecoder().decode(sessionKey);
|
||||
byte[] encryptedDataBytes = Base64.getDecoder().decode(encryptedData);
|
||||
byte[] ivBytes = Base64.getDecoder().decode(iv);
|
||||
|
||||
SecretKeySpec keySpec = new SecretKeySpec(sessionKeyBytes, "AES");
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
|
||||
|
||||
byte[] decryptedBytes = cipher.doFinal(encryptedDataBytes);
|
||||
String decryptedData = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
|
||||
logger.debug("解密数据: {}", decryptedData);
|
||||
|
||||
JSONObject json = JSON.parseObject(decryptedData);
|
||||
String phoneNumber = json.getString("phoneNumber");
|
||||
|
||||
if (phoneNumber == null || phoneNumber.isEmpty()) {
|
||||
phoneNumber = json.getString("purePhoneNumber");
|
||||
}
|
||||
|
||||
return ResultDomain.success("解密成功", phoneNumber);
|
||||
} catch (Exception e) {
|
||||
logger.error("解密手机号失败", e);
|
||||
return ResultDomain.failure("解密手机号失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP GET请求
|
||||
*/
|
||||
private String httpGet(String urlStr) throws Exception {
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
return response.toString();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST请求
|
||||
*/
|
||||
private String httpPost(String urlStr, String body) throws Exception {
|
||||
URL url = new URL(urlStr);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
return response.toString();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
<groupId>org.xyzh.common</groupId>
|
||||
<artifactId>common-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xyzh.common</groupId>
|
||||
<artifactId>common-wechat</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xyzh.apis</groupId>
|
||||
<artifactId>api-system</artifactId>
|
||||
|
||||
@@ -8,6 +8,8 @@ import java.util.concurrent.TimeUnit;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
|
||||
import org.apache.dubbo.config.annotation.DubboReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -34,6 +36,9 @@ import org.xyzh.common.dto.sys.TbSysUserRoleDTO;
|
||||
import org.xyzh.common.dto.sys.TbSysViewDTO;
|
||||
import org.xyzh.common.utils.id.IdUtil;
|
||||
import org.xyzh.common.utils.validation.ValidationUtils;
|
||||
import org.xyzh.common.wechat.pojo.WeChatPhoneResult;
|
||||
import org.xyzh.common.wechat.pojo.WeChatSessionResult;
|
||||
import org.xyzh.common.wechat.service.WeChatMiniProgramService;
|
||||
import org.xyzh.common.auth.utils.JwtTokenUtil;
|
||||
import org.xyzh.common.redis.service.RedisService;
|
||||
|
||||
@@ -54,6 +59,8 @@ import jakarta.validation.constraints.NotNull;
|
||||
@RequestMapping("/system/guest")
|
||||
public class GuestController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GuestController.class);
|
||||
|
||||
@Autowired
|
||||
private GuestService guestService;
|
||||
|
||||
@@ -69,6 +76,9 @@ public class GuestController {
|
||||
@Autowired
|
||||
private RedisService redisService;
|
||||
|
||||
@Autowired
|
||||
private WeChatMiniProgramService weChatMiniProgramService;
|
||||
|
||||
|
||||
@PostMapping
|
||||
public ResultDomain<TbGuestDTO> createGuest(TbGuestDTO guest) {
|
||||
@@ -120,6 +130,63 @@ public class GuestController {
|
||||
@Operation(summary = "微信小程序用户识别登录")
|
||||
@PostMapping("/identify")
|
||||
public ResultDomain<LoginDomain> identifyUser(@RequestBody LoginParam loginParam, HttpServletRequest request) {
|
||||
logger.info("微信小程序登录请求: wechatId={}, code={}, phoneCode={}",
|
||||
loginParam.getWechatId(),
|
||||
loginParam.getCode() != null ? loginParam.getCode().substring(0, Math.min(10, loginParam.getCode().length())) + "..." : null,
|
||||
loginParam.getPhoneCode() != null ? "有" : "无");
|
||||
|
||||
// 1. 处理微信登录code,获取openid
|
||||
String openid = null;
|
||||
String sessionKey = null;
|
||||
if (loginParam.getCode() != null && !loginParam.getCode().trim().isEmpty()) {
|
||||
ResultDomain<WeChatSessionResult> sessionResult = weChatMiniProgramService.code2Session(loginParam.getCode());
|
||||
if (sessionResult.getSuccess() && sessionResult.getData() != null) {
|
||||
openid = sessionResult.getData().getOpenid();
|
||||
sessionKey = sessionResult.getData().getSessionKey();
|
||||
logger.info("获取openid成功: {}", openid);
|
||||
// 使用openid作为wechatId
|
||||
loginParam.setWechatId(openid);
|
||||
} else {
|
||||
logger.warn("获取openid失败: {}", sessionResult.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理手机号授权
|
||||
String phoneNumber = null;
|
||||
|
||||
// 方式1:使用phoneCode获取手机号(新版API,推荐)
|
||||
if (loginParam.getPhoneCode() != null && !loginParam.getPhoneCode().trim().isEmpty()) {
|
||||
ResultDomain<WeChatPhoneResult> phoneResult = weChatMiniProgramService.getPhoneNumber(loginParam.getPhoneCode());
|
||||
if (phoneResult.getSuccess() && phoneResult.getData() != null && phoneResult.getData().getPhoneInfo() != null) {
|
||||
phoneNumber = phoneResult.getData().getPhoneInfo().getPurePhoneNumber();
|
||||
if (phoneNumber == null) {
|
||||
phoneNumber = phoneResult.getData().getPhoneInfo().getPhoneNumber();
|
||||
}
|
||||
logger.info("通过phoneCode获取手机号成功: {}", phoneNumber);
|
||||
} else {
|
||||
logger.warn("通过phoneCode获取手机号失败: {}", phoneResult.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 方式2:使用encryptedData和iv解密手机号(旧版API)
|
||||
if (phoneNumber == null && sessionKey != null
|
||||
&& loginParam.getEncryptedData() != null && !loginParam.getEncryptedData().trim().isEmpty()
|
||||
&& loginParam.getIv() != null && !loginParam.getIv().trim().isEmpty()) {
|
||||
ResultDomain<String> decryptResult = weChatMiniProgramService.decryptPhoneNumber(
|
||||
sessionKey, loginParam.getEncryptedData(), loginParam.getIv());
|
||||
if (decryptResult.getSuccess() && decryptResult.getData() != null) {
|
||||
phoneNumber = decryptResult.getData();
|
||||
logger.info("通过解密获取手机号成功: {}", phoneNumber);
|
||||
} else {
|
||||
logger.warn("解密手机号失败: {}", decryptResult.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 设置手机号
|
||||
if (phoneNumber != null) {
|
||||
loginParam.setPhone(phoneNumber);
|
||||
}
|
||||
|
||||
// 验证参数:必须有wechatId或phone
|
||||
if ((loginParam.getWechatId() == null || loginParam.getWechatId().trim().isEmpty())
|
||||
&& (loginParam.getPhone() == null || loginParam.getPhone().trim().isEmpty())) {
|
||||
@@ -132,14 +199,15 @@ public class GuestController {
|
||||
// 从 request 中提取客户端 IP 并设置到 loginParam
|
||||
loginParam.setClientIp(getClientIP(request));
|
||||
|
||||
// 1. 尝试通过AuthService登录(员工)
|
||||
// 3. 尝试通过AuthService登录(员工)
|
||||
ResultDomain<LoginDomain> loginResult = authService.login(loginParam);
|
||||
if (loginResult.getSuccess() && loginResult.getData() != null) {
|
||||
// 登录成功,是系统员工
|
||||
logger.info("员工登录成功: userId={}", loginResult.getData().getUser().getUserId());
|
||||
return loginResult;
|
||||
}
|
||||
|
||||
// 2. 登录失败,查询/注册来客
|
||||
// 4. 登录失败,查询/注册来客
|
||||
return handleGuestLogin(loginParam);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { request, uploadFile } from '../base'
|
||||
import type { ResultDomain } from '../../types'
|
||||
import { BASE_URL } from '../../config'
|
||||
import type {
|
||||
TbChat,
|
||||
TbChatMessage,
|
||||
@@ -21,9 +22,6 @@ declare const uni: {
|
||||
request: (options: any) => any
|
||||
}
|
||||
|
||||
// API 基础配置
|
||||
const BASE_URL = 'http://localhost:8180'
|
||||
|
||||
/**
|
||||
* @description AI对话相关接口(直接调用ai模块)
|
||||
* @filename aiChat.ts
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare const uni: {
|
||||
getStorageSync: (key: string) => any
|
||||
removeStorageSync: (key: string) => void
|
||||
setStorageSync: (key: string, data: any) => void
|
||||
request: (options: any) => void
|
||||
uploadFile: (options: any) => void
|
||||
showToast: (options: any) => void
|
||||
reLaunch: (options: any) => void
|
||||
}
|
||||
|
||||
import type { ResultDomain } from '../types'
|
||||
@@ -36,6 +40,10 @@ export function request<T>(options: {
|
||||
success: (res: any) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(res.data as ResultDomain<T>)
|
||||
} else if (res.statusCode === 401) {
|
||||
// Token 过期或无效,清除缓存并跳转到授权页面
|
||||
handleTokenExpired()
|
||||
reject(new Error('登录已过期,请重新登录'))
|
||||
} else {
|
||||
reject(new Error(`请求失败: ${res.statusCode}`))
|
||||
}
|
||||
@@ -47,6 +55,30 @@ export function request<T>(options: {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 Token 过期
|
||||
function handleTokenExpired() {
|
||||
// 清除所有登录信息
|
||||
uni.removeStorageSync('token')
|
||||
uni.removeStorageSync('userInfo')
|
||||
uni.removeStorageSync('loginDomain')
|
||||
uni.removeStorageSync('wechatId')
|
||||
uni.removeStorageSync('nickname')
|
||||
|
||||
// 提示用户
|
||||
uni.showToast({
|
||||
title: '登录已过期,正在重新登录',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
|
||||
// 刷新页面,触发自动登录
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 文件上传方法
|
||||
export function uploadFile<T>(options: {
|
||||
url: string
|
||||
@@ -71,6 +103,10 @@ export function uploadFile<T>(options: {
|
||||
if (res.statusCode === 200) {
|
||||
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
resolve(result as ResultDomain<T>)
|
||||
} else if (res.statusCode === 401) {
|
||||
// Token 过期或无效
|
||||
handleTokenExpired()
|
||||
reject(new Error('登录已过期,请重新登录'))
|
||||
} else {
|
||||
reject(new Error(`上传失败: ${res.statusCode}`))
|
||||
}
|
||||
@@ -84,3 +120,12 @@ export function uploadFile<T>(options: {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 导出清除登录信息的方法,供其他地方使用
|
||||
export function clearLoginInfo() {
|
||||
uni.removeStorageSync('token')
|
||||
uni.removeStorageSync('userInfo')
|
||||
uni.removeStorageSync('loginDomain')
|
||||
uni.removeStorageSync('wechatId')
|
||||
uni.removeStorageSync('nickname')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { request } from '../base'
|
||||
import type { ResultDomain, PageRequest } from '../../types'
|
||||
import type { TbWorkcaseDTO } from '../../types/workcase/workcase'
|
||||
import { BASE_URL } from '../../config'
|
||||
import type {
|
||||
TbChatRoomDTO,
|
||||
TbChatRoomMemberDTO,
|
||||
@@ -23,7 +23,8 @@ import type {
|
||||
ChatMessageListParam,
|
||||
SSECallbacks,
|
||||
SSETask,
|
||||
SSEMessageData
|
||||
SSEMessageData,
|
||||
TbWorkcaseDTO
|
||||
} from '../../types/ai/aiChat'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
@@ -32,9 +33,6 @@ declare const uni: {
|
||||
request: (options: any) => any
|
||||
}
|
||||
|
||||
// API 基础配置
|
||||
const BASE_URL = 'http://localhost:8180'
|
||||
|
||||
/**
|
||||
* @description 工单对话相关接口
|
||||
* @filename workcaseChat.ts
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const AGENT_ID = '17664699513920001'
|
||||
export const BASE_URL = 'http://localhost:8180'
|
||||
export const WS_HOST = 'localhost:8180' // WebSocket host(不包含协议)
|
||||
export const FILE_DOWNLOAD_URL = 'http://localhost:8180/urban-lifeline/sys-file/download?fileId='
|
||||
export const AGENT_ID = '17678420499370001'
|
||||
// export const BASE_URL = 'http://localhost:8180'
|
||||
// 根据宝塔nginx配置,/urban-lifeline/ 会代理到后端网关
|
||||
export const BASE_URL = 'https://demo-urbanlifeline.tensorgrove.com/urban-lifeline'
|
||||
|
||||
// export const WS_HOST = 'localhost:8180' // WebSocket host(不包含协议)
|
||||
export const WS_HOST = 'demo-urbanlifeline.tensorgrove.com/urban-lifeline' // WebSocket host(不包含协议)
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name" : "泰豪小电",
|
||||
"appid" : "",
|
||||
"appid" : "__UNI__69FD573",
|
||||
"description" : "泰豪小电智能工单系统",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"uni-app-x" : {},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "",
|
||||
"appid" : "wx15e67484db6d431f",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
"urlCheck" : false,
|
||||
"postcss" : true,
|
||||
"minified" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"permission" : {
|
||||
@@ -59,5 +61,40 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-android" : {
|
||||
"distribute" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>"
|
||||
],
|
||||
"modules" : {},
|
||||
"icons" : {
|
||||
"hdpi" : "",
|
||||
"xhdpi" : "",
|
||||
"xxhdpi" : "",
|
||||
"xxxhdpi" : ""
|
||||
},
|
||||
"splashScreens" : {
|
||||
"default" : {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-ios" : {
|
||||
"distribute" : {
|
||||
"privacyDescription" : {
|
||||
"NSCameraUsageDescription" : "用于视频会议时开启摄像头",
|
||||
"NSMicrophoneUsageDescription" : "用于视频会议时开启麦克风"
|
||||
},
|
||||
"modules" : {},
|
||||
"icons" : {},
|
||||
"splashScreens" : {}
|
||||
}
|
||||
},
|
||||
"web" : {
|
||||
"router" : {
|
||||
"mode" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +739,7 @@ async function initWebSocket() {
|
||||
// 构建WebSocket URL
|
||||
// 开发环境:ws://localhost:8180 或 ws://192.168.x.x:8180
|
||||
// 生产环境:wss://your-domain.com
|
||||
const protocol = 'ws:' // 开发环境使用ws,生产环境改为wss
|
||||
const protocol = 'wss:' // 开发环境使用ws,生产环境改为wss
|
||||
// 小程序使用原生WebSocket端点(不是SockJS端点)
|
||||
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ async function initWebSocket() {
|
||||
// 构建WebSocket URL
|
||||
// 开发环境:ws://localhost:8180 或 ws://192.168.x.x:8180
|
||||
// 生产环境:wss://your-domain.com
|
||||
const protocol = 'ws:' // 开发环境使用ws,生产环境改为wss
|
||||
const protocol = 'wss:' // 开发环境使用ws,生产环境改为wss
|
||||
// 小程序使用原生WebSocket端点(不是SockJS端点)
|
||||
const wsUrl = `${protocol}//${WS_HOST}/urban-lifeline/workcase/ws/chat?token=${encodeURIComponent(token)}`
|
||||
|
||||
|
||||
@@ -80,6 +80,15 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 退出按钮特殊样式
|
||||
.logout-btn {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
|
||||
.btn-text {
|
||||
color: #ff3b30;
|
||||
}
|
||||
}
|
||||
|
||||
// 欢迎区域(机器人+浮动标签)
|
||||
.hero {
|
||||
position: relative;
|
||||
@@ -803,3 +812,94 @@
|
||||
.modal-btn .btn-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 手机号授权弹窗样式
|
||||
.phone-auth-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.phone-auth-content {
|
||||
width: 85%;
|
||||
max-width: 340px;
|
||||
padding: 30px 24px;
|
||||
}
|
||||
|
||||
.phone-auth-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-icon-wrap {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.auth-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.phone-auth-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phone-auth-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 24px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.phone-auth-btn .btn-text {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phone-auth-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.skip-auth-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
border-radius: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.skip-auth-btn .skip-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.skip-auth-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
<view class="header" :style="{ paddingTop: headerPaddingTop + 'px', height: headerTotalHeight + 'px' }">
|
||||
<text class="title">泰豪小电</text>
|
||||
<view class="header-right">
|
||||
<button class="workcase-btn" @tap="switchMockUser" v-if="isMockMode">
|
||||
<text class="btn-text">切换</text>
|
||||
</button>
|
||||
<button class="workcase-btn" @tap="goToChatRoomList">
|
||||
<text class="btn-text">聊天室</text>
|
||||
</button>
|
||||
@@ -175,14 +172,40 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 手机号授权弹窗 -->
|
||||
<view class="phone-auth-modal" v-if="showPhoneAuthModal">
|
||||
<view class="modal-mask"></view>
|
||||
<view class="modal-content phone-auth-content">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">欢迎使用泰豪小电</text>
|
||||
</view>
|
||||
<view class="modal-body phone-auth-body">
|
||||
<view class="auth-icon-wrap">
|
||||
<text class="auth-icon">📱</text>
|
||||
</view>
|
||||
<text class="auth-desc">为了给您提供更好的服务,需要获取您的手机号用于身份识别和工单通知</text>
|
||||
</view>
|
||||
<view class="modal-footer phone-auth-footer">
|
||||
<button
|
||||
class="modal-btn confirm phone-auth-btn"
|
||||
open-type="getPhoneNumber"
|
||||
@getphonenumber="onGetPhoneNumber"
|
||||
>
|
||||
<text class="btn-text">授权手机号登录</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { guestAPI, aiChatAPI, workcaseChatAPI } from '@/api'
|
||||
import { guestAPI, aiChatAPI, workcaseChatAPI, fileAPI } from '@/api'
|
||||
import { clearLoginInfo } from '@/api/base'
|
||||
import type { TbWorkcaseDTO } from '@/types'
|
||||
import type { DifyFileInfo } from '@/types/ai/aiChat'
|
||||
import { AGENT_ID, FILE_DOWNLOAD_URL } from '@/config'
|
||||
import { AGENT_ID } from '@/config'
|
||||
// 前端消息展示类型
|
||||
interface ChatMessageItem {
|
||||
type: 'user' | 'bot'
|
||||
@@ -215,8 +238,12 @@
|
||||
phone: '',
|
||||
userId: ''
|
||||
})
|
||||
const isMockMode = ref(true) // 开发环境mock模式
|
||||
const userType = ref(false)
|
||||
|
||||
// 是否显示手机号授权弹窗
|
||||
const showPhoneAuthModal = ref(false)
|
||||
// 临时保存的微信登录code
|
||||
const tempWechatCode = ref('')
|
||||
// AI 对话相关
|
||||
const chatId = ref<string>('') // 当前会话ID
|
||||
const currentTaskId = ref<string>('') // 当前任务ID(用于停止)
|
||||
@@ -229,41 +256,74 @@
|
||||
|
||||
// 初始化用户信息
|
||||
async function initUserInfo() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 正式环境:从微信获取用户信息
|
||||
// wx.login({
|
||||
// success: (loginRes) => {
|
||||
// // 使用code换取openid等信息
|
||||
// console.log('微信登录code:', loginRes.code)
|
||||
// }
|
||||
// })
|
||||
// #endif
|
||||
try {
|
||||
// 1. 先尝试从缓存获取
|
||||
const cachedWechatId = uni.getStorageSync('wechatId')
|
||||
const cachedUserInfo = uni.getStorageSync('userInfo')
|
||||
const cachedToken = uni.getStorageSync('token')
|
||||
|
||||
// 开发环境:使用mock数据
|
||||
if (isMockMode.value) {
|
||||
if (cachedWechatId && cachedUserInfo && cachedToken) {
|
||||
// 有完整缓存,直接使用
|
||||
const parsedUserInfo = typeof cachedUserInfo === 'string' ? JSON.parse(cachedUserInfo) : cachedUserInfo
|
||||
userInfo.value = {
|
||||
wechatId: '17857100377',
|
||||
username: '访客用户',
|
||||
phone: '17857100377',
|
||||
userId: ''
|
||||
wechatId: cachedWechatId,
|
||||
username: parsedUserInfo.username || parsedUserInfo.realName || '微信用户',
|
||||
phone: parsedUserInfo.phone || '',
|
||||
userId: parsedUserInfo.userId || ''
|
||||
}
|
||||
await doIdentify()
|
||||
|
||||
// 判断用户类型
|
||||
if (parsedUserInfo.status === 'guest') {
|
||||
userType.value = false
|
||||
} else {
|
||||
userType.value = true
|
||||
}
|
||||
|
||||
console.log('使用缓存的用户信息:', userInfo.value)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 没有缓存,自动登录
|
||||
await autoLogin()
|
||||
} catch (error) {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
// 登录失败,尝试自动登录
|
||||
await autoLogin()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换mock用户(开发调试用)
|
||||
function switchMockUser() {
|
||||
uni.showActionSheet({
|
||||
itemList: ['员工 (17857100375)', '访客 (17857100377)'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
userInfo.value = { wechatId: '17857100375', username: '员工用户', phone: '17857100375', userId: '' }
|
||||
} else {
|
||||
userInfo.value = { wechatId: '17857100377', username: '访客用户', phone: '17857100377', userId: '' }
|
||||
}
|
||||
doIdentify()
|
||||
// 自动登录
|
||||
async function autoLogin() {
|
||||
uni.showLoading({ title: '初始化中...' })
|
||||
|
||||
try {
|
||||
// 使用 wx.login 获取 code
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
console.log('微信登录成功,code:', loginRes.code)
|
||||
uni.hideLoading()
|
||||
|
||||
// 保存code,等待手机号授权后使用
|
||||
tempWechatCode.value = loginRes.code
|
||||
|
||||
// 显示手机号授权弹窗
|
||||
showPhoneAuthModal.value = true
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('微信登录失败:', err)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('自动登录失败:', error)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: error.message || '登录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 调用identify接口
|
||||
@@ -304,6 +364,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 获取手机号回调
|
||||
async function onGetPhoneNumber(e: any) {
|
||||
console.log('获取手机号回调:', e)
|
||||
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
console.error('获取手机号失败:', e.detail)
|
||||
|
||||
// 如果是权限问题,提示用户
|
||||
if (e.detail.errno === 102) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '该小程序暂未开通手机号快速验证功能,请联系管理员开通后再试。',
|
||||
showCancel: false
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '需要授权手机号才能使用',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
showPhoneAuthModal.value = false
|
||||
|
||||
uni.showLoading({ title: '登录中...' })
|
||||
|
||||
try {
|
||||
const code = tempWechatCode.value
|
||||
const wechatId = code.substring(0, 20) // 使用部分code作为临时ID
|
||||
|
||||
// 获取手机号授权返回的数据
|
||||
const phoneCode = e.detail.code // 手机号授权code(推荐使用)
|
||||
const encryptedData = e.detail.encryptedData // 加密数据
|
||||
const iv = e.detail.iv // 解密向量
|
||||
|
||||
console.log('手机号授权数据:', { code, phoneCode, encryptedData: encryptedData?.substring(0, 50) + '...', iv })
|
||||
|
||||
// 调用 identify 接口
|
||||
// 后端可以选择使用 phoneCode 或 encryptedData+iv 来解密手机号
|
||||
const identifyRes = await guestAPI.identify({
|
||||
wechatId: wechatId,
|
||||
code: code, // 微信登录code
|
||||
phoneCode: phoneCode, // 手机号授权code(新版API推荐)
|
||||
encryptedData: encryptedData, // 加密数据(旧版API)
|
||||
iv: iv, // 解密向量(旧版API)
|
||||
loginType: 'wechat_miniprogram'
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (identifyRes.success && identifyRes.data) {
|
||||
const loginDomain = identifyRes.data
|
||||
|
||||
// 保存登录信息
|
||||
uni.setStorageSync('token', loginDomain.token || '')
|
||||
uni.setStorageSync('userInfo', JSON.stringify(loginDomain.user))
|
||||
uni.setStorageSync('loginDomain', JSON.stringify(loginDomain))
|
||||
uni.setStorageSync('wechatId', wechatId)
|
||||
|
||||
// 更新用户信息
|
||||
userInfo.value = {
|
||||
wechatId: wechatId,
|
||||
username: loginDomain.user?.username || loginDomain.user?.realName || '微信用户',
|
||||
phone: loginDomain.user?.phone || '',
|
||||
userId: loginDomain.user?.userId || ''
|
||||
}
|
||||
|
||||
// 判断用户类型
|
||||
if (loginDomain.user?.status === 'guest') {
|
||||
userType.value = false
|
||||
} else {
|
||||
userType.value = true
|
||||
}
|
||||
|
||||
console.log('手机号授权登录成功:', userInfo.value)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: identifyRes.message || '登录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
// 登录失败,重新显示授权弹窗
|
||||
showPhoneAuthModal.value = true
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('手机号授权登录失败:', error)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: error.message || '登录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
// 登录失败,重新显示授权弹窗
|
||||
showPhoneAuthModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化用户信息
|
||||
@@ -885,7 +1043,7 @@
|
||||
|
||||
// 获取文件下载URL(通过文件ID)
|
||||
function getFileDownloadUrl(fileId: string): string {
|
||||
return `${FILE_DOWNLOAD_URL}${fileId}`
|
||||
return fileAPI.getDownloadUrl(fileId)
|
||||
}
|
||||
|
||||
// 判断文件ID对应的文件是否为图片
|
||||
@@ -938,6 +1096,7 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
"outputPath": ""
|
||||
},
|
||||
"useCompilerPlugins": false,
|
||||
"minifyWXML": true
|
||||
"minifyWXML": true,
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"coverView": true,
|
||||
"checkInvalidKey": true,
|
||||
"checkSiteMap": false
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"simulatorPluginLibVersion": {},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -24,6 +24,16 @@ export interface LoginParam {
|
||||
loginType?: string
|
||||
/** 是否记住我 */
|
||||
rememberMe?: boolean
|
||||
|
||||
// ========== 微信小程序登录相关 ==========
|
||||
/** 微信登录code(wx.login返回) */
|
||||
code?: string
|
||||
/** 手机号授权code(getPhoneNumber返回) */
|
||||
phoneCode?: string
|
||||
/** 加密数据(getPhoneNumber返回,用于解密手机号) */
|
||||
encryptedData?: string
|
||||
/** 解密向量(getPhoneNumber返回,用于解密手机号) */
|
||||
iv?: string
|
||||
}
|
||||
|
||||
// LoginDomain - 登录信息
|
||||
|
||||
165
urbanLifelineWeb/packages/workcase_wechat/手机号授权说明.md
Normal file
165
urbanLifelineWeb/packages/workcase_wechat/手机号授权说明.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 手机号授权登录方案说明
|
||||
|
||||
## 方案概述
|
||||
|
||||
已实现**方案B:首次登录时请求手机号授权**。用户首次打开小程序时,会显示一个精美的授权弹窗,引导用户授权手机号。
|
||||
|
||||
## 实现流程
|
||||
|
||||
### 1. 用户首次登录
|
||||
1. 用户打开小程序
|
||||
2. 调用 `wx.login` 获取微信登录 code
|
||||
3. 显示手机号授权弹窗,提供两个选项:
|
||||
- **授权手机号登录**:获取用户手机号,完成正式注册
|
||||
- **暂不授权,以游客身份使用**:跳过手机号授权,以游客身份登录
|
||||
4. 用户选择授权:
|
||||
- 点击"授权手机号登录" → 获取手机号加密数据(phoneCode)
|
||||
- 点击"暂不授权" → 以游客身份登录,无需手机号
|
||||
5. 将相关数据发送到后端
|
||||
6. 后端完成注册/登录(正式用户或游客)
|
||||
7. 保存用户信息到本地缓存
|
||||
|
||||
### 2. 后续登录
|
||||
- 直接从本地缓存读取用户信息
|
||||
- 无需重复授权
|
||||
|
||||
## 关键代码位置
|
||||
|
||||
### 模板部分 (index.uvue)
|
||||
- **手机号授权弹窗**: 第 176-202 行
|
||||
- **授权按钮**: 第 190-196 行,使用 `open-type="getPhoneNumber"` 触发授权
|
||||
- **暂不授权按钮**: 第 197-199 行,点击后以游客身份登录
|
||||
|
||||
### 脚本部分 (index.uvue)
|
||||
- **showPhoneAuthModal**: 第 244 行 - 控制授权弹窗显示
|
||||
- **tempWechatCode**: 第 246 行 - 保存微信登录 code
|
||||
- **autoLogin()**: 第 296-327 行 - 修改后的自动登录逻辑
|
||||
- **onGetPhoneNumber()**: 第 370-466 行 - 处理手机号授权回调
|
||||
- **handleSkipAuth()**: 第 468-526 行 - 处理跳过授权,游客登录
|
||||
|
||||
### 样式部分 (index.scss)
|
||||
- **phone-auth-modal**: 第 817-905 行 - 授权弹窗样式
|
||||
- **skip-auth-btn**: 第 888-905 行 - 暂不授权按钮样式
|
||||
|
||||
## 后端接口要求
|
||||
|
||||
### identify 接口需要支持以下功能:
|
||||
|
||||
```typescript
|
||||
// 请求参数
|
||||
interface IdentifyRequest {
|
||||
wechatId: string; // 微信ID(从code中提取的临时ID)
|
||||
phone: string; // 手机号加密code(phoneCode)或明文手机号
|
||||
}
|
||||
|
||||
// 后端需要:
|
||||
// 1. 判断 phone 是否为加密code
|
||||
// 2. 如果是加密code,使用微信API解密获取真实手机号
|
||||
// 3. 根据 wechatId 和 phone 查询或创建用户
|
||||
// 4. 返回用户信息和 token
|
||||
```
|
||||
|
||||
### 微信手机号解密接口
|
||||
|
||||
后端需要调用微信的 `getPhoneNumber` 接口:
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"code": "手机号加密code"
|
||||
}
|
||||
```
|
||||
|
||||
参考文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html
|
||||
|
||||
## 小程序配置要求
|
||||
|
||||
### 1. 开通手机号快速验证权限
|
||||
|
||||
在微信公众平台(mp.weixin.qq.com):
|
||||
1. 登录小程序后台
|
||||
2. 进入"开发" → "开发管理" → "接口设置"
|
||||
3. 找到"手机号快速验证组件"
|
||||
4. 点击"开通"
|
||||
|
||||
### 2. 服务器域名配置
|
||||
|
||||
确保后端 API 域名已添加到小程序的合法域名列表中。
|
||||
|
||||
## 错误处理
|
||||
|
||||
### errno 102 错误
|
||||
如果用户授权时出现 errno 102 错误,说明小程序未开通"手机号快速验证"功能。代码中已添加友好提示:
|
||||
|
||||
```typescript
|
||||
if (e.detail.errno === 102) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '该小程序暂未开通手机号快速验证功能,请联系管理员开通后再试。',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 授权失败重试
|
||||
如果授权失败,会重新显示授权弹窗,用户可以再次尝试。
|
||||
|
||||
## 用户体验优化
|
||||
|
||||
### 1. 精美的授权弹窗
|
||||
- 渐变色背景
|
||||
- 大图标设计
|
||||
- 清晰的授权说明
|
||||
- 突出的授权按钮
|
||||
|
||||
### 2. 非侵入式设计
|
||||
- 用户可以选择"暂不授权"继续使用(如果实现了游客模式)
|
||||
- 已授权用户不会重复看到授权弹窗
|
||||
|
||||
### 3. 本地缓存
|
||||
- 授权成功后保存用户信息到本地
|
||||
- 下次打开直接使用缓存,无需重复授权
|
||||
|
||||
## 测试要点
|
||||
|
||||
### 1. 首次登录测试
|
||||
- 清空小程序缓存
|
||||
- 打开小程序
|
||||
- 验证是否显示授权弹窗
|
||||
- 点击授权按钮
|
||||
- 验证是否成功登录
|
||||
|
||||
### 2. 已登录用户测试
|
||||
- 授权成功后关闭小程序
|
||||
- 重新打开小程序
|
||||
- 验证是否自动登录(不显示授权弹窗)
|
||||
|
||||
### 3. 授权失败测试
|
||||
- 点击授权按钮后取消
|
||||
- 验证错误提示是否友好
|
||||
- 验证是否可以再次尝试
|
||||
|
||||
### 4. 未开通权限测试
|
||||
- 在未开通手机号快速验证的小程序中测试
|
||||
- 验证 errno 102 错误提示
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `urbanLifelineWeb/packages/workcase_wechat/pages/index/index.uvue` - 主要逻辑
|
||||
- `urbanLifelineWeb/packages/workcase_wechat/pages/index/index.scss` - 样式文件
|
||||
- `urbanLifelineWeb/packages/workcase_wechat/api/guest/index.ts` - guest API(identify接口)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **隐私政策**:使用手机号授权前,需要在小程序中添加隐私政策说明
|
||||
2. **用户同意**:确保用户知道手机号的用途(身份识别、工单通知等)
|
||||
3. **数据安全**:后端需要安全存储用户手机号,遵守相关法律法规
|
||||
4. **降级方案**:如果手机号授权失败,可以考虑提供其他登录方式(如手动输入手机号+验证码)
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **添加"暂不授权"功能**:允许用户跳过授权,以游客身份使用部分功能
|
||||
2. **添加重新授权入口**:在个人中心添加"绑定手机号"功能,让跳过授权的用户可以后续绑定
|
||||
3. **优化错误提示**:针对不同的授权失败原因,提供更详细的解决方案
|
||||
4. **添加授权动画**:让授权过程更加流畅和友好
|
||||
333
urbanLifelineWeb/packages/workcase_wechat/最终登录方案.md
Normal file
333
urbanLifelineWeb/packages/workcase_wechat/最终登录方案.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 微信小程序最终登录方案
|
||||
|
||||
## 方案说明
|
||||
|
||||
采用**自动静默登录**方案,用户打开小程序即自动完成登录,无需任何授权操作。
|
||||
|
||||
## 实现方式
|
||||
|
||||
### 1. 进入首页自动登录
|
||||
- 用户打开小程序,进入首页
|
||||
- 检查本地缓存,如果有登录信息,直接使用
|
||||
- 如果没有缓存,自动调用 `wx.login` 获取 code
|
||||
- 使用 code 调用后端 identify 接口完成登录
|
||||
- 全程无需用户操作
|
||||
|
||||
### 2. 登录流程
|
||||
|
||||
```
|
||||
用户打开小程序
|
||||
↓
|
||||
进入首页 (pages/index/index)
|
||||
↓
|
||||
检查缓存 (token, userInfo, wechatId)
|
||||
↓
|
||||
有缓存 → 直接使用
|
||||
↓
|
||||
无缓存 → 自动登录
|
||||
↓
|
||||
wx.login 获取 code
|
||||
↓
|
||||
调用 identify 接口
|
||||
↓
|
||||
保存登录信息
|
||||
↓
|
||||
完成登录
|
||||
```
|
||||
|
||||
### 3. Token 过期处理
|
||||
|
||||
```
|
||||
API 请求返回 401
|
||||
↓
|
||||
清除缓存
|
||||
↓
|
||||
提示"登录已过期,正在重新登录"
|
||||
↓
|
||||
刷新页面
|
||||
↓
|
||||
触发自动登录
|
||||
```
|
||||
|
||||
### 4. 退出登录
|
||||
|
||||
```
|
||||
点击"退出"按钮
|
||||
↓
|
||||
确认对话框
|
||||
↓
|
||||
清除登录信息
|
||||
↓
|
||||
调用 autoLogin() 重新登录
|
||||
```
|
||||
|
||||
## 代码实现
|
||||
|
||||
### 首页 (pages/index/index.uvue)
|
||||
|
||||
```typescript
|
||||
// 初始化用户信息
|
||||
async function initUserInfo() {
|
||||
try {
|
||||
// 1. 先尝试从缓存获取
|
||||
const cachedWechatId = uni.getStorageSync('wechatId')
|
||||
const cachedUserInfo = uni.getStorageSync('userInfo')
|
||||
const cachedToken = uni.getStorageSync('token')
|
||||
|
||||
if (cachedWechatId && cachedUserInfo && cachedToken) {
|
||||
// 有完整缓存,直接使用
|
||||
const parsedUserInfo = typeof cachedUserInfo === 'string'
|
||||
? JSON.parse(cachedUserInfo)
|
||||
: cachedUserInfo
|
||||
|
||||
userInfo.value = {
|
||||
wechatId: cachedWechatId,
|
||||
username: parsedUserInfo.username || parsedUserInfo.realName || '微信用户',
|
||||
phone: parsedUserInfo.phone || '',
|
||||
userId: parsedUserInfo.userId || ''
|
||||
}
|
||||
|
||||
// 判断用户类型
|
||||
userType.value = parsedUserInfo.status !== 'guest'
|
||||
|
||||
console.log('使用缓存的用户信息:', userInfo.value)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 没有缓存,自动登录
|
||||
await autoLogin()
|
||||
} catch (error) {
|
||||
console.error('初始化用户信息失败:', error)
|
||||
await autoLogin()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
async function autoLogin() {
|
||||
uni.showLoading({ title: '登录中...' })
|
||||
|
||||
try {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
console.log('微信登录成功,code:', loginRes.code)
|
||||
|
||||
const code = loginRes.code
|
||||
const wechatId = code.substring(0, 20) // 使用部分code作为临时ID
|
||||
|
||||
// 调用 identify 接口
|
||||
const identifyRes = await guestAPI.identify({
|
||||
wechatId: wechatId,
|
||||
phone: ''
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (identifyRes.success && identifyRes.data) {
|
||||
const loginDomain = identifyRes.data
|
||||
|
||||
// 保存登录信息
|
||||
uni.setStorageSync('token', loginDomain.token || '')
|
||||
uni.setStorageSync('userInfo', JSON.stringify(loginDomain.user))
|
||||
uni.setStorageSync('loginDomain', JSON.stringify(loginDomain))
|
||||
uni.setStorageSync('wechatId', wechatId)
|
||||
|
||||
// 更新用户信息
|
||||
userInfo.value = {
|
||||
wechatId: wechatId,
|
||||
username: loginDomain.user?.username || loginDomain.user?.realName || '微信用户',
|
||||
phone: loginDomain.user?.phone || '',
|
||||
userId: loginDomain.user?.userId || ''
|
||||
}
|
||||
|
||||
// 判断用户类型
|
||||
userType.value = loginDomain.user?.status !== 'guest'
|
||||
|
||||
console.log('自动登录成功:', userInfo.value)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: identifyRes.message || '登录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('微信登录失败:', err)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('自动登录失败:', error)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: error.message || '登录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
### 1. 用户体验极佳
|
||||
- ✅ 无需任何授权操作
|
||||
- ✅ 打开即用
|
||||
- ✅ 无感知登录
|
||||
- ✅ 无弹窗干扰
|
||||
|
||||
### 2. 无权限要求
|
||||
- ✅ 不需要企业认证
|
||||
- ✅ 不需要开通手机号快速验证
|
||||
- ✅ 个人小程序也可使用
|
||||
- ✅ 测试环境友好
|
||||
|
||||
### 3. 代码简洁
|
||||
- ✅ 删除了授权页面
|
||||
- ✅ 删除了切换用户功能
|
||||
- ✅ 删除了所有 mock 相关代码
|
||||
- ✅ 只保留核心登录逻辑
|
||||
|
||||
### 4. 维护方便
|
||||
- ✅ 登录逻辑集中在首页
|
||||
- ✅ Token 过期自动处理
|
||||
- ✅ 错误处理完善
|
||||
- ✅ 日志清晰
|
||||
|
||||
## 后端要求
|
||||
|
||||
### identify 接口
|
||||
|
||||
```typescript
|
||||
POST /urban-lifeline/system/guest/identify
|
||||
{
|
||||
"wechatId": "微信ID(使用code的前20位作为临时标识)",
|
||||
"phone": "" // 可以为空
|
||||
}
|
||||
|
||||
返回:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "jwt_token",
|
||||
"user": {
|
||||
"userId": "用户ID",
|
||||
"username": "用户名",
|
||||
"realName": "真实姓名",
|
||||
"phone": "手机号(可能为空)",
|
||||
"status": "guest" | "employee"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 建议优化
|
||||
|
||||
1. **后端可以通过 code 换取 openid**
|
||||
- 前端传递完整的 code
|
||||
- 后端调用微信 API 换取 openid
|
||||
- 使用 openid 作为用户唯一标识
|
||||
|
||||
2. **支持游客模式**
|
||||
- 如果 wechatId 不存在,创建游客账号
|
||||
- 返回游客身份的 token
|
||||
- 允许后续绑定手机号升级为正式用户
|
||||
|
||||
## 已删除的内容
|
||||
|
||||
### 1. 授权页面
|
||||
- ❌ `pages/auth/auth.uvue` - 已删除
|
||||
- ❌ pages.json 中的 auth 路由配置 - 已删除
|
||||
|
||||
### 2. 切换用户功能
|
||||
- ❌ `switchMockUser()` 函数 - 已删除
|
||||
- ❌ `isMockMode` 变量 - 已删除
|
||||
- ❌ 切换按钮 UI - 已删除
|
||||
- ❌ 所有条件编译代码 - 已删除
|
||||
|
||||
### 3. getUserProfile
|
||||
- ❌ 不再使用 `getUserProfile` API
|
||||
- ❌ 不再需要用户授权昵称
|
||||
|
||||
### 4. getPhoneNumber
|
||||
- ❌ 不再使用 `getPhoneNumber` API
|
||||
- ❌ 不再需要手机号授权
|
||||
|
||||
## 保留的功能
|
||||
|
||||
### 1. 退出登录
|
||||
- ✅ 右上角"退出"按钮
|
||||
- ✅ 点击后清除缓存并重新自动登录
|
||||
- ✅ 红色主题,易于识别
|
||||
|
||||
### 2. Token 管理
|
||||
- ✅ 自动检测 401 错误
|
||||
- ✅ 自动清除过期信息
|
||||
- ✅ 自动重新登录
|
||||
|
||||
### 3. 用户类型识别
|
||||
- ✅ 区分 guest 和 employee
|
||||
- ✅ 根据类型控制功能权限
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 首次使用
|
||||
- [ ] 打开小程序,自动登录成功
|
||||
- [ ] 无任何授权弹窗
|
||||
- [ ] 用户信息正确显示
|
||||
- [ ] 可以正常使用所有功能
|
||||
|
||||
### 再次使用
|
||||
- [ ] 关闭小程序重新打开
|
||||
- [ ] 直接进入首页,无需重新登录
|
||||
- [ ] 用户信息保持不变
|
||||
|
||||
### Token 过期
|
||||
- [ ] 模拟 Token 过期(后端返回 401)
|
||||
- [ ] 自动提示"登录已过期,正在重新登录"
|
||||
- [ ] 自动完成重新登录
|
||||
- [ ] 功能恢复正常
|
||||
|
||||
### 退出登录
|
||||
- [ ] 点击"退出"按钮
|
||||
- [ ] 显示确认对话框
|
||||
- [ ] 确认后自动重新登录
|
||||
- [ ] 登录成功后可正常使用
|
||||
|
||||
### API 请求
|
||||
- [ ] 所有 API 请求携带正确的 token
|
||||
- [ ] 请求路径正确(/urban-lifeline/)
|
||||
- [ ] WebSocket 连接正常
|
||||
- [ ] 文件上传功能正常
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 1. 编译
|
||||
```bash
|
||||
npm run build:mp-weixin
|
||||
```
|
||||
|
||||
### 2. 上传
|
||||
- 使用微信开发者工具打开 `unpackage/dist/build/mp-weixin`
|
||||
- 点击"上传"按钮
|
||||
- 填写版本号和备注
|
||||
|
||||
### 3. 提交审核
|
||||
- 登录微信公众平台
|
||||
- 进入"版本管理"
|
||||
- 提交审核
|
||||
|
||||
### 4. 发布
|
||||
- 审核通过后,点击"发布"
|
||||
- 用户即可使用
|
||||
|
||||
## 总结
|
||||
|
||||
当前方案是最简洁、最友好的登录方案:
|
||||
- ✅ 无需任何用户操作
|
||||
- ✅ 无需小程序认证
|
||||
- ✅ 代码简洁易维护
|
||||
- ✅ 用户体验极佳
|
||||
- ✅ 适用于所有场景
|
||||
|
||||
可以直接部署使用!
|
||||
Reference in New Issue
Block a user