Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a18a50ee | ||
|
|
afee993626 | ||
|
|
d51c4c6a76 | ||
|
|
69ebf33e18 | ||
|
|
8b505d9133 |
@@ -61,6 +61,9 @@ tests/fixtures/*.json
|
||||
.worktrees/
|
||||
.zcode/
|
||||
.workbuddy/
|
||||
.mimosa/
|
||||
.trae/
|
||||
.wb_tmp_bcrypt/
|
||||
project.private.config.json
|
||||
/nul
|
||||
/infrastructure/nul
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -46,6 +48,7 @@ public class AppealService {
|
||||
|
||||
private final AppealMapper appealMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final NotificationService notificationService;
|
||||
@@ -281,6 +284,11 @@ public class AppealService {
|
||||
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
|
||||
}
|
||||
|
||||
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
|
||||
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||
// users.account_status=banned/suspended 无人回滚。
|
||||
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
|
||||
|
||||
// 通知用户
|
||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"申诉审核通过",
|
||||
@@ -371,6 +379,38 @@ public class AppealService {
|
||||
return "limited";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
|
||||
*
|
||||
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
|
||||
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
|
||||
*
|
||||
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
|
||||
*/
|
||||
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
|
||||
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
|
||||
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|
||||
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
|
||||
if (stillRestricted) {
|
||||
return;
|
||||
}
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
String oldStatus = user.getAccountStatus();
|
||||
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
|
||||
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
|
||||
return;
|
||||
}
|
||||
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
userMapper.updateById(user);
|
||||
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
|
||||
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
|
||||
"{\"accountStatus\":\"" + oldStatus + "\"}",
|
||||
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
@@ -378,6 +418,10 @@ public class AppealService {
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
|
||||
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
|
||||
auditLog.setBeforeData(beforeData);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,24 @@ public class AuthController {
|
||||
return ApiResponse.ok(authService.wechatLogin(code));
|
||||
}
|
||||
|
||||
@PostMapping("/demo/login")
|
||||
public ApiResponse<Map<String, Object>> demoLogin(@RequestBody Map<String, String> body) {
|
||||
String code = body.get("code");
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号不能为空");
|
||||
}
|
||||
return ApiResponse.ok(authService.demoLogin(code));
|
||||
}
|
||||
|
||||
@PostMapping("/demo/register")
|
||||
public ApiResponse<Map<String, Object>> demoRegister(@RequestBody Map<String, String> body) {
|
||||
String code = body.get("code");
|
||||
if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}")) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号编号无效");
|
||||
}
|
||||
return ApiResponse.ok(authService.demoRegister(code, body.get("displayName"), body.get("phone")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号
|
||||
* POST /api/v1/auth/phone/bind
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.danyang.match.platform.auth.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
|
||||
import com.danyang.match.platform.auth.service.OperatorAuthService;
|
||||
import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
@@ -23,8 +24,15 @@ import java.util.Map;
|
||||
public class OperatorAuthController {
|
||||
|
||||
private final OperatorAuthService operatorAuthService;
|
||||
private final DemoOperatorRegistrationService demoOperatorRegistrationService;
|
||||
private final OperatorCookieWriter cookieWriter;
|
||||
|
||||
@PostMapping("/demo/register")
|
||||
public ApiResponse<Map<String, Object>> demoRegister(@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(demoOperatorRegistrationService.register(
|
||||
body.get("name"), body.get("username"), body.get("password")));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
|
||||
@@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
public Map<String, String> getCodeSession(String code) {
|
||||
log.debug("[Mock] 微信登录 code: {}", code);
|
||||
|
||||
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
|
||||
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
|
||||
if (code != null && code.startsWith("demo_openid_")) {
|
||||
return Map.of(
|
||||
"openid", code,
|
||||
"unionid", "mock_unionid_" + code,
|
||||
"session_key", "mock_session_key",
|
||||
"appid", appId
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟:相同 code 返回相同 openid
|
||||
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
|
||||
? configuredOpenid
|
||||
|
||||
@@ -51,13 +51,76 @@ public class AuthService {
|
||||
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||
private long accessTokenExpireMs;
|
||||
|
||||
@Value("${demo.login.enabled:false}")
|
||||
private boolean demoLoginEnabled;
|
||||
|
||||
/**
|
||||
* 微信登录
|
||||
* 新用户自动创建账号,老用户直接返回 token
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> wechatLogin(String code) {
|
||||
Map<String, String> session = wechatLoginProvider.getCodeSession(code);
|
||||
return loginWithSession(wechatLoginProvider.getCodeSession(code));
|
||||
}
|
||||
|
||||
/** 演示环境专用登录,不调用微信 code2session。 */
|
||||
@Transactional
|
||||
public Map<String, Object> demoLogin(String code) {
|
||||
if (!demoLoginEnabled) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示登录未启用");
|
||||
}
|
||||
if (code == null || !code.startsWith("demo_openid_")) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号无效");
|
||||
}
|
||||
Map<String, String> session = Map.of(
|
||||
"openid", code,
|
||||
"unionid", "demo_unionid_" + code,
|
||||
"session_key", "demo_session_key"
|
||||
);
|
||||
return loginWithSession(session);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> demoRegister(String code, String displayName, String phone) {
|
||||
if (!demoLoginEnabled) throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用");
|
||||
if (code == null || !code.matches("demo_openid_[a-zA-Z0-9_-]{1,100}")
|
||||
|| displayName == null || displayName.isBlank() || displayName.length() > 40) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示账号或昵称格式无效");
|
||||
}
|
||||
if (phone != null && !phone.isBlank()) {
|
||||
if (!phone.matches("1[3-9]\\d{9}")) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "演示手机号格式无效");
|
||||
}
|
||||
if (userMapper.findByPhone(phone).isPresent()) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "演示手机号已注册");
|
||||
}
|
||||
}
|
||||
if (userMapper.findByWechatOpenid(code).isPresent()) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "演示账号编号已注册");
|
||||
}
|
||||
Map<String, Object> result = loginWithSession(Map.of(
|
||||
"openid", code, "unionid", "demo_unionid_" + code, "session_key", "demo_session_key"));
|
||||
String userId = (String) result.get("userId");
|
||||
if (phone != null && !phone.isBlank()) {
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setPhone(phone);
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
// 演示档案保持未完成;必填占位字段不代表完成实名认证或婚恋资料审核。
|
||||
UserProfile profile = new UserProfile();
|
||||
profile.setUserId(userId);
|
||||
profile.setDisplayName(displayName.trim());
|
||||
profile.setGender("other");
|
||||
profile.setBirthDate(java.time.LocalDate.of(2000, 1, 1));
|
||||
profile.setProfileCompletion(0);
|
||||
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
|
||||
userProfileMapper.insert(profile);
|
||||
result.put("displayName", displayName.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> loginWithSession(Map<String, String> session) {
|
||||
String openid = session.get("openid");
|
||||
String unionid = session.get("unionid");
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.danyang.match.platform.auth.service;
|
||||
|
||||
import com.danyang.match.platform.operator.service.OperatorManagementService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DemoOperatorRegistrationService {
|
||||
|
||||
private final OperatorManagementService operatorManagementService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${demo.login.enabled:false}")
|
||||
private boolean demoLoginEnabled;
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> register(String name, String username, String password) {
|
||||
if (!demoLoginEnabled) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "演示注册未启用");
|
||||
}
|
||||
if (name == null || name.isBlank() || name.length() > 100
|
||||
|| username == null || !username.matches("[a-zA-Z][a-zA-Z0-9_]{3,29}")
|
||||
|| password == null || password.length() < 8 || password.length() > 72) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "姓名、用户名或密码格式无效");
|
||||
}
|
||||
if (jdbcTemplate.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, username) > 0) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
|
||||
Operator operator = operatorManagementService.createOperator(name.trim(), username, password, null);
|
||||
jdbcTemplate.update("UPDATE operators SET status = 'active' WHERE id = ?", UUID.fromString(operator.getId()));
|
||||
operator.setStatus("active");
|
||||
String roleId = jdbcTemplate.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class);
|
||||
jdbcTemplate.update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)",
|
||||
UUID.fromString(operator.getId()), UUID.fromString(roleId));
|
||||
return Map.of("operatorId", operator.getId(), "username", operator.getUsername(),
|
||||
"name", operator.getName(), "status", operator.getStatus(), "role", "matchmaker");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
@@ -44,6 +45,15 @@ public class OperatorAuthService {
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${demo.login.enabled:false}")
|
||||
private boolean demoLoginEnabled;
|
||||
|
||||
@Value("${demo.login.password:}")
|
||||
private String demoLoginPassword;
|
||||
|
||||
@Value("${demo.login.usernames:admin,wanghongniang}")
|
||||
private String demoLoginUsernames;
|
||||
|
||||
/**
|
||||
* Operator 登录(含失败锁定)
|
||||
*/
|
||||
@@ -67,7 +77,13 @@ public class OperatorAuthService {
|
||||
throw new AccessDeniedException("账号已禁用,请联系管理员");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(password, operator.getPasswordHash())) {
|
||||
boolean passwordMatches = passwordEncoder.matches(password, operator.getPasswordHash());
|
||||
boolean demoPasswordMatches = demoLoginEnabled
|
||||
&& password.equals(demoLoginPassword)
|
||||
&& java.util.Arrays.stream(demoLoginUsernames.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(operator.getUsername()::equals);
|
||||
if (!passwordMatches && !demoPasswordMatches) {
|
||||
// 记录失败次数
|
||||
String attemptKey = "auth:operator_login_attempts:" + operator.getId();
|
||||
Long attempts = redisTemplate.opsForValue().increment(attemptKey);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.danyang.match.platform.chat.config;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 产品当前不提供用户之间的即时聊天。
|
||||
*
|
||||
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
|
||||
* 防止只隐藏小程序入口却留下可调用的通信通道。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ChatDisabledFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
String context = request.getContextPath();
|
||||
if (context != null && !context.isEmpty() && path.startsWith(context)) {
|
||||
path = path.substring(context.length());
|
||||
}
|
||||
if (path.equals("/conversations") || path.startsWith("/conversations/")
|
||||
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
|
||||
response.setStatus(HttpServletResponse.SC_GONE);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(
|
||||
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
|
||||
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.danyang.match.platform.match.controller;
|
||||
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.match.service.RelationService;
|
||||
import com.danyang.match.platform.match.service.RelationService.LikeResult;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
@@ -11,7 +9,6 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -22,18 +19,16 @@ import org.springframework.web.bind.annotation.*;
|
||||
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
|
||||
*
|
||||
* 核心规则:INV-005 未 Mutual 不可聊天
|
||||
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
|
||||
* 响应返回关系状态和 Match 结果,不创建用户会话。
|
||||
*
|
||||
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/likes")
|
||||
@RequiredArgsConstructor
|
||||
public class LikeController {
|
||||
|
||||
private final RelationService relationService;
|
||||
private final ChatService chatService;
|
||||
|
||||
@Idempotent
|
||||
@PostMapping
|
||||
@@ -44,7 +39,7 @@ public class LikeController {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
|
||||
}
|
||||
LikeResult result = relationService.like(principal.id(), req.targetUserId);
|
||||
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
|
||||
return ApiResponse.ok(LikeResponse.from(result));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,20 +57,8 @@ public class LikeController {
|
||||
/**
|
||||
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
|
||||
*
|
||||
* - getOrCreateConversation 幂等,不会重复建会话
|
||||
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
|
||||
*/
|
||||
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
|
||||
if (!result.mutual || result.match == null) return null;
|
||||
try {
|
||||
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
|
||||
return conv.getId();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
|
||||
userId, targetUserId, result.match.getId(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
|
||||
|
||||
// ==================== DTO ====================
|
||||
|
||||
@@ -89,16 +72,14 @@ public class LikeController {
|
||||
public static class LikeResponse {
|
||||
private String relationStatus;
|
||||
private boolean mutualMatch;
|
||||
private String matchedConversationId;
|
||||
private String matchId;
|
||||
|
||||
public static LikeResponse from(LikeResult r, String matchedConversationId) {
|
||||
public static LikeResponse from(LikeResult r) {
|
||||
LikeResponse resp = new LikeResponse();
|
||||
resp.relationStatus = r.mutual
|
||||
? Constants.RELATION_STATUS_MUTUAL_MATCH
|
||||
: Constants.RELATION_STATUS_LIKED;
|
||||
resp.mutualMatch = r.mutual;
|
||||
resp.matchedConversationId = matchedConversationId;
|
||||
resp.matchId = r.match != null ? r.match.getId() : null;
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -310,7 +310,11 @@ public class RecommendationEngineService {
|
||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||
record.setScore(java.math.BigDecimal.valueOf(score));
|
||||
record.setLevel(toLevel(score));
|
||||
record.setReasons(String.join(";", reasons));
|
||||
// recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
|
||||
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
|
||||
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
|
||||
// /home 聚合接口 500。改为序列化为 JSON 数组。
|
||||
record.setReasons(writeReasonsJson(reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
@@ -342,6 +346,16 @@ public class RecommendationEngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||
private String writeReasonsJson(List<String> reasons) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||
} catch (Exception e) {
|
||||
log.warn("序列化推荐理由失败", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部评分结果
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.service.UserProfileService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
|
||||
public class RecommendationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final MatchEligibilityService eligibilityService;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
@@ -61,7 +63,9 @@ public class RecommendationService {
|
||||
record.setTargetUserId(targetUserId);
|
||||
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
|
||||
record.setLevel(level);
|
||||
record.setReasons(new java.util.ArrayList<>(reasons).toString());
|
||||
// recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
|
||||
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
|
||||
record.setReasons(writeReasonsJson(reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
@@ -70,6 +74,16 @@ public class RecommendationService {
|
||||
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
||||
}
|
||||
|
||||
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
|
||||
private String writeReasonsJson(List<String> reasons) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
|
||||
} catch (Exception e) {
|
||||
log.warn("序列化推荐理由失败", e);
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户对推荐的响应(Like/Pass/Block)
|
||||
*/
|
||||
|
||||
@@ -54,7 +54,8 @@ public class MatchmakerController {
|
||||
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
||||
? "recommendation" : req.serviceType;
|
||||
String description = req.description != null ? req.description : req.requirement;
|
||||
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
|
||||
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
|
||||
req.targetUserId, req.authorizationAccepted));
|
||||
}
|
||||
|
||||
@GetMapping("/services")
|
||||
@@ -72,6 +73,15 @@ public class MatchmakerController {
|
||||
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@PostMapping("/services/{id}/withdraw")
|
||||
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
@@ -111,5 +121,10 @@ public class MatchmakerController {
|
||||
private String description;
|
||||
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
|
||||
private String requirement;
|
||||
private String targetUserId;
|
||||
private Boolean authorizationAccepted;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WithdrawRequest { private String reason; }
|
||||
}
|
||||
|
||||
@@ -155,4 +155,11 @@ public class MatchmakerOperatorController {
|
||||
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
|
||||
}
|
||||
|
||||
@PostMapping("/services/{id}/start")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<?> startService(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(matchmakerServiceService.startService(id, principal.id()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
|
||||
private String userId;
|
||||
private String serviceType;
|
||||
private String description;
|
||||
private String targetUserId;
|
||||
private java.time.Instant authorizationAt;
|
||||
private java.time.Instant withdrawnAt;
|
||||
private String withdrawReason;
|
||||
private String status;
|
||||
private String organizationId;
|
||||
private String assignedOperatorId;
|
||||
|
||||
@@ -14,13 +14,16 @@ import java.util.Optional;
|
||||
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
|
||||
|
||||
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
|
||||
"s.description, s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
|
||||
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
|
||||
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
|
||||
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
|
||||
"tu.display_number AS \"targetUserDisplayNumber\", " +
|
||||
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
|
||||
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
|
||||
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
|
||||
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
|
||||
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
|
||||
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
|
||||
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
|
||||
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
|
||||
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
|
||||
@@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
||||
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
||||
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
|
||||
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
|
||||
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
|
||||
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
|
||||
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
|
||||
|
||||
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
|
||||
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
|
||||
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
|
||||
|
||||
@@ -6,12 +6,15 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -55,9 +58,11 @@ import java.util.stream.Collectors;
|
||||
public class MatchmakerAssignmentService {
|
||||
|
||||
private static final String RESOURCE_TYPE = "matchmaker_assignment";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final MatchmakerAssignmentMapper assignmentMapper;
|
||||
private final MatchmakerServiceMapper serviceMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
@@ -87,6 +92,20 @@ public class MatchmakerAssignmentService {
|
||||
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
|
||||
|
||||
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
|
||||
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
|
||||
// 仍在进行中的服务继续按唯一主责约束返回 409。
|
||||
MatchmakerServiceRecord linked = existing.getServiceId() == null
|
||||
? null : serviceMapper.selectById(existing.getServiceId());
|
||||
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|
||||
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
|
||||
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
|
||||
existing.setEndedAt(Instant.now());
|
||||
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
|
||||
assignmentMapper.updateById(existing);
|
||||
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
|
||||
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
|
||||
return;
|
||||
}
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"该用户已存在生效中的主责分配,请先转分配或撤销");
|
||||
});
|
||||
@@ -117,6 +136,9 @@ public class MatchmakerAssignmentService {
|
||||
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
|
||||
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
|
||||
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
|
||||
if (serviceId != null && !serviceId.isBlank()) {
|
||||
notificationService.sendMatchmakerServiceAssigned(userId, serviceId);
|
||||
}
|
||||
return assignment;
|
||||
}
|
||||
|
||||
@@ -388,10 +410,15 @@ public class MatchmakerAssignmentService {
|
||||
|
||||
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
|
||||
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
|
||||
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
|
||||
+ ";scope=" + scope
|
||||
+ ";reason=" + (reason == null ? "" : reason);
|
||||
return summary.length() > 500 ? summary.substring(0, 500) : summary;
|
||||
Map<String, String> summary = Map.of(
|
||||
"matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
|
||||
"scope", scope == null ? "" : scope,
|
||||
"reason", reason == null ? "" : reason);
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(summary);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("audit afterData JSON serialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
|
||||
|
||||
@@ -64,17 +64,38 @@ public class MatchmakerService {
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
|
||||
return submitServiceRequest(userId, serviceType, description, null, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
|
||||
String targetUserId, Boolean authorizationAccepted) {
|
||||
boolean assistance = "recommendation_assistance".equals(serviceType);
|
||||
if (assistance) {
|
||||
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
|
||||
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
|
||||
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
|
||||
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
|
||||
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
|
||||
}
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(serviceType);
|
||||
svc.setDescription(description);
|
||||
svc.setTargetUserId(targetUserId);
|
||||
if (assistance) svc.setAuthorizationAt(Instant.now());
|
||||
svc.setStatus("submitted");
|
||||
serviceMapper.insert(svc);
|
||||
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
|
||||
return svc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void withdrawService(String serviceId, String userId, String reason) {
|
||||
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
|
||||
}
|
||||
|
||||
/** 查询我的红娘服务历史 */
|
||||
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
|
||||
return serviceMapper.findByUser(userId, Math.min(limit, 50));
|
||||
|
||||
@@ -247,6 +247,22 @@ public class MatchmakerServiceService {
|
||||
return followup;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerServiceRecord startService(String serviceId, String operatorId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
|
||||
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
|
||||
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能开始服务");
|
||||
}
|
||||
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
serviceMapper.updateById(service);
|
||||
saveAuditLog(operatorId, "MATCHMAKER_START", "matchmaker_service", serviceId, null, null,
|
||||
"matchmaker.service.start", assignment.getId());
|
||||
notificationService.sendMatchmakerServiceStarted(service.getUserId(), serviceId);
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成服务
|
||||
*
|
||||
@@ -271,6 +287,8 @@ public class MatchmakerServiceService {
|
||||
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
|
||||
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
|
||||
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
|
||||
notificationService.sendMatchmakerServiceCompleted(service.getUserId(), serviceId);
|
||||
notificationService.sendMatchmakerServiceReviewInvite(service.getUserId(), serviceId);
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.danyang.match.platform.notification.service;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -42,6 +43,9 @@ public class NotificationService {
|
||||
|
||||
private final NotificationMapper mapper;
|
||||
|
||||
@Value("${demo.login.enabled:false}")
|
||||
private boolean demoLoginEnabled;
|
||||
|
||||
// ==================== 通用发送 ====================
|
||||
|
||||
/**
|
||||
@@ -273,6 +277,28 @@ public class NotificationService {
|
||||
referralType, businessId);
|
||||
}
|
||||
|
||||
public void sendMatchmakerServiceAssigned(String userId, String serviceId) {
|
||||
sendDemoMatchmakerService(userId, serviceId, "红娘已接单", "您的公益红娘服务已分配,工作人员将尽快开展服务。");
|
||||
}
|
||||
|
||||
public void sendMatchmakerServiceStarted(String userId, String serviceId) {
|
||||
sendDemoMatchmakerService(userId, serviceId, "服务进行中", "您的公益红娘服务已开始,可在服务记录中查看进度。");
|
||||
}
|
||||
|
||||
public void sendMatchmakerServiceCompleted(String userId, String serviceId) {
|
||||
sendDemoMatchmakerService(userId, serviceId, "服务已完成", "您的公益红娘服务已完成,可在服务记录中查看详情。");
|
||||
}
|
||||
|
||||
public void sendMatchmakerServiceReviewInvite(String userId, String serviceId) {
|
||||
sendDemoMatchmakerService(userId, serviceId, "服务评价邀请", "欢迎查看本次公益红娘服务记录并反馈服务体验。");
|
||||
}
|
||||
|
||||
private void sendDemoMatchmakerService(String userId, String serviceId, String title, String content) {
|
||||
if (demoLoginEnabled) {
|
||||
sendAsync(userId, "matchmaker", title, content, "matchmaker_service", serviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 外部推送(微信模板消息)— P11-V11-T11 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -251,16 +251,7 @@ public class RelationshipService {
|
||||
matchMapper.insert(match);
|
||||
}
|
||||
|
||||
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理
|
||||
if (autoCreateConversation) {
|
||||
try {
|
||||
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
|
||||
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
|
||||
} catch (Exception e) {
|
||||
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
|
||||
match.getId(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
// 当前产品不提供用户间聊天;Match 仅用于结果判定和公益服务引导。
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -286,7 +277,6 @@ public class RelationshipService {
|
||||
result.put("matchReused", true);
|
||||
result.put("matchId", existingMatch.get().getId());
|
||||
result.put("status", existingMatch.get().getStatus());
|
||||
result.put("conversationAutoCreated", true);
|
||||
log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId());
|
||||
return result;
|
||||
}
|
||||
@@ -299,20 +289,10 @@ public class RelationshipService {
|
||||
// 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation)
|
||||
Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false);
|
||||
|
||||
// V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。
|
||||
// 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。
|
||||
// 现显式开启,保证互选成功后聊天入口立即可见。
|
||||
try {
|
||||
chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId());
|
||||
} catch (Exception e) {
|
||||
log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("matchCreated", true);
|
||||
result.put("matchId", match.getId());
|
||||
result.put("priorUnmatch", priorUnmatchExists);
|
||||
result.put("conversationAutoCreated", true);
|
||||
log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}",
|
||||
userId, partnerId, priorUnmatchExists, match.getId());
|
||||
return result;
|
||||
|
||||
@@ -45,5 +45,26 @@ public class ReportController {
|
||||
return ApiResponse.ok(reportService.getMyReports(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
|
||||
* GET /api/v1/risk/reports/{reportId}
|
||||
*/
|
||||
@GetMapping("/{reportId}")
|
||||
public ApiResponse<Map<String, Object>> getMyReportDetail(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String reportId) {
|
||||
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
|
||||
* POST /api/v1/risk/reports/evidence/upload-token
|
||||
*/
|
||||
@PostMapping("/evidence/upload-token")
|
||||
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.service.AuditLogService;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.risk.dto.ReportItemDTO;
|
||||
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
|
||||
import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
|
||||
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.amazonaws.HttpMethod;
|
||||
import com.amazonaws.services.s3.AmazonS3;
|
||||
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
@@ -30,9 +40,11 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
||||
|
||||
/**
|
||||
* 举报与风控服务
|
||||
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
|
||||
* - 封禁需要人工复核,不能自动永久封禁
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
|
||||
|
||||
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
|
||||
private static final int MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
|
||||
private static final int DEFAULT_MAX_EVIDENCE = 9;
|
||||
|
||||
/** 运营端按状态列表查询的单页上限。 */
|
||||
private static final int STATUS_LIST_LIMIT = 200;
|
||||
|
||||
/** 高风险举报原因:必须补充文字说明。 */
|
||||
private static final Set<String> HIGH_RISK_REASONS = Set.of(
|
||||
Constants.REPORT_REASON_FRAUD,
|
||||
Constants.REPORT_REASON_MONEY_INDUCEMENT,
|
||||
Constants.REPORT_REASON_IMPERSONATION,
|
||||
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
|
||||
);
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskEventMapper riskEventMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final AuditLogService auditLogService;
|
||||
private final NotificationService notificationService;
|
||||
private final ObjectMapper objectMapper;
|
||||
/** 证据对象的归属/存在性校验走内网客户端。 */
|
||||
private final AmazonS3 amazonS3;
|
||||
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
|
||||
private final AmazonS3 amazonS3Signer;
|
||||
|
||||
@Value("${object-storage.bucket-private}")
|
||||
private String privateBucket;
|
||||
|
||||
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
|
||||
private int maxEvidenceCount;
|
||||
|
||||
@Autowired
|
||||
public ReportService(
|
||||
ReportMapper reportMapper,
|
||||
RiskEventMapper riskEventMapper,
|
||||
RiskActionMapper riskActionMapper,
|
||||
UserProfileMapper userProfileMapper,
|
||||
UserMapper userMapper,
|
||||
AuditLogMapper auditLogMapper,
|
||||
AuditLogService auditLogService,
|
||||
NotificationService notificationService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("amazonS3") AmazonS3 amazonS3,
|
||||
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
|
||||
this.reportMapper = reportMapper;
|
||||
this.riskEventMapper = riskEventMapper;
|
||||
this.riskActionMapper = riskActionMapper;
|
||||
this.userProfileMapper = userProfileMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.auditLogMapper = auditLogMapper;
|
||||
this.auditLogService = auditLogService;
|
||||
this.notificationService = notificationService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.amazonS3 = amazonS3;
|
||||
this.amazonS3Signer = amazonS3Signer;
|
||||
}
|
||||
|
||||
// 风险评分权重
|
||||
private static final Map<String, Integer> REASON_SCORES = Map.of(
|
||||
@@ -75,10 +142,30 @@ public class ReportService {
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
|
||||
// 举报对象校验
|
||||
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
|
||||
}
|
||||
// 不允许举报自己
|
||||
if (reporterUserId.equals(dto.getReportedUserId())) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
|
||||
}
|
||||
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
|
||||
String reasonType = dto.getReasonType();
|
||||
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
|
||||
}
|
||||
// 补充说明长度 + 高风险必填
|
||||
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
|
||||
if (description.length() > MAX_DESCRIPTION_LENGTH) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
||||
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
|
||||
}
|
||||
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
|
||||
}
|
||||
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
|
||||
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
|
||||
|
||||
// 检查是否已被封禁
|
||||
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
|
||||
@@ -91,34 +178,35 @@ public class ReportService {
|
||||
Report report = new Report();
|
||||
report.setReporterUserId(reporterUserId);
|
||||
report.setReportedUserId(dto.getReportedUserId());
|
||||
report.setReasonType(dto.getReasonType());
|
||||
report.setDescription(dto.getDescription());
|
||||
if (dto.getEvidenceFileIds() != null) {
|
||||
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString());
|
||||
}
|
||||
report.setReasonType(reasonType);
|
||||
report.setDescription(description.isEmpty() ? null : description);
|
||||
// evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
|
||||
report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
|
||||
report.setStatus(Constants.REPORT_STATUS_PENDING);
|
||||
reportMapper.insert(report);
|
||||
|
||||
// 触发风险事件:被举报用户得分增加
|
||||
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5);
|
||||
int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
|
||||
RiskEvent riskEvent = new RiskEvent();
|
||||
riskEvent.setUserId(dto.getReportedUserId());
|
||||
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
|
||||
riskEvent.setScore(scoreAdd);
|
||||
riskEvent.setSource("report");
|
||||
riskEvent.setSourceId(report.getId());
|
||||
riskEvent.setNote("举报触发,原因: " + dto.getReasonType());
|
||||
riskEvent.setNote("举报触发,原因: " + reasonType);
|
||||
riskEventMapper.insert(riskEvent);
|
||||
|
||||
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
|
||||
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
|
||||
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) {
|
||||
Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
|
||||
report.setStatus(Constants.REPORT_STATUS_PROCESSING);
|
||||
reportMapper.updateById(report);
|
||||
}
|
||||
|
||||
// 审计
|
||||
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
|
||||
// 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
|
||||
// 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
|
||||
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
|
||||
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("reportId", report.getId());
|
||||
@@ -135,6 +223,45 @@ public class ReportService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
|
||||
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
|
||||
*/
|
||||
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
|
||||
Report report = reportMapper.selectById(reportId);
|
||||
if (report == null || !userId.equals(report.getReporterUserId())) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
|
||||
}
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("report", toDTO(report));
|
||||
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
|
||||
detail.put("evidenceFileKeys", evidenceFileKeys);
|
||||
detail.put("evidenceUrls", evidenceFileKeys.stream()
|
||||
.map(this::signEvidenceUrl)
|
||||
.collect(Collectors.toList()));
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
|
||||
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
|
||||
*/
|
||||
public Map<String, Object> getEvidenceUploadToken(String userId) {
|
||||
String fileKey = String.format("reports/%s/evidence/%s.jpg",
|
||||
userId, UUID.randomUUID().toString().replace("-", ""));
|
||||
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
|
||||
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
|
||||
.withMethod(HttpMethod.PUT)
|
||||
.withExpiration(expiration);
|
||||
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
|
||||
|
||||
Map<String, Object> token = new HashMap<>();
|
||||
token.put("fileKey", fileKey);
|
||||
token.put("uploadUrl", uploadUrl);
|
||||
token.put("expiresIn", 3600);
|
||||
return token;
|
||||
}
|
||||
|
||||
// ==================== 管理端举报处理 ====================
|
||||
|
||||
/**
|
||||
@@ -157,6 +284,13 @@ public class ReportService {
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("report", toDTO(report));
|
||||
|
||||
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
|
||||
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
|
||||
detail.put("evidenceFileKeys", evidenceFileKeys);
|
||||
detail.put("evidenceUrls", evidenceFileKeys.stream()
|
||||
.map(this::signEvidenceUrl)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
// 被举报人风险记录
|
||||
String reportedUserId = report.getReportedUserId();
|
||||
if (reportedUserId != null) {
|
||||
@@ -215,7 +349,10 @@ public class ReportService {
|
||||
}
|
||||
|
||||
// 通知被举报用户
|
||||
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote());
|
||||
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
|
||||
|
||||
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
|
||||
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
|
||||
|
||||
// 高风险举报(婚姻状态欺诈)额外记录风险事件
|
||||
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
|
||||
@@ -257,8 +394,20 @@ public class ReportService {
|
||||
return m;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
// For other statuses, return empty or implement full query
|
||||
return List.of();
|
||||
// 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
|
||||
return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("id", report.getId());
|
||||
m.put("reasonType", report.getReasonType());
|
||||
m.put("reasonText", getReasonText(report.getReasonType()));
|
||||
m.put("reportedUserId", report.getReportedUserId());
|
||||
m.put("status", report.getStatus());
|
||||
m.put("description", report.getDescription());
|
||||
m.put("createdAt", report.getCreatedAt());
|
||||
m.put("processResult", report.getProcessResult());
|
||||
m.put("processNote", report.getProcessNote());
|
||||
return m;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
public Map<String, Object> getReportStats() {
|
||||
@@ -298,6 +447,83 @@ public class ReportService {
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
|
||||
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
|
||||
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
if (evidenceFileIds.size() > maxEvidenceCount) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
||||
"证据截图最多 " + maxEvidenceCount + " 张");
|
||||
}
|
||||
String prefix = "reports/" + reporterUserId + "/";
|
||||
for (String key : evidenceFileIds) {
|
||||
if (key == null || !key.startsWith(prefix)) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
|
||||
}
|
||||
if (!amazonS3.doesObjectExist(privateBucket, key)) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
|
||||
}
|
||||
}
|
||||
return evidenceFileIds;
|
||||
}
|
||||
|
||||
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
|
||||
private String writeEvidenceJson(List<String> evidenceFileIds) {
|
||||
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(evidenceFileIds);
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
throw new ServerErrorException("证据序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
|
||||
private List<String> parseEvidence(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
|
||||
private String signEvidenceUrl(String fileKey) {
|
||||
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
|
||||
return amazonS3Signer.generatePresignedUrl(
|
||||
new GeneratePresignedUrlRequest(privateBucket, fileKey)
|
||||
.withMethod(HttpMethod.GET)
|
||||
.withExpiration(expiration)).toString();
|
||||
}
|
||||
|
||||
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
|
||||
private String displayNameOf(String userId) {
|
||||
if (userId == null) return null;
|
||||
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
|
||||
}
|
||||
|
||||
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
|
||||
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
|
||||
if (reporterUserId == null) return;
|
||||
String title = "举报处理结果";
|
||||
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
|
||||
? "您提交的举报经核实未发现问题,感谢您的监督。"
|
||||
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
|
||||
try {
|
||||
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
|
||||
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
|
||||
"report", reportId);
|
||||
} catch (Exception e) {
|
||||
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyRiskAction(String userId, String result, String note, String operatorId) {
|
||||
RiskAction action = new RiskAction();
|
||||
action.setUserId(userId);
|
||||
@@ -329,6 +555,34 @@ public class ReportService {
|
||||
action.setEffectiveAt(Instant.now());
|
||||
action.setReasonDetail(note);
|
||||
riskActionMapper.insert(action);
|
||||
|
||||
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
|
||||
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
|
||||
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
|
||||
String newAccountStatus = switch (result) {
|
||||
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
|
||||
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
|
||||
default -> null; // warning / feature_limit 不改变账号状态
|
||||
};
|
||||
if (newAccountStatus != null) {
|
||||
syncAccountStatus(userId, newAccountStatus, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把处置结果写入 users.account_status 并留痕。
|
||||
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
|
||||
*/
|
||||
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) return;
|
||||
String oldStatus = user.getAccountStatus();
|
||||
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
|
||||
user.setAccountStatus(newStatus);
|
||||
userMapper.updateById(user);
|
||||
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
|
||||
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
|
||||
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
|
||||
}
|
||||
|
||||
private String calculateRiskLevel(int score) {
|
||||
@@ -351,6 +605,8 @@ public class ReportService {
|
||||
dto.setProcessNote(report.getProcessNote());
|
||||
// 填充显示名称
|
||||
dto.setReasonText(getReasonText(report.getReasonType()));
|
||||
dto.setReporterName(displayNameOf(report.getReporterUserId()));
|
||||
dto.setReportedName(displayNameOf(report.getReportedUserId()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -368,7 +624,7 @@ public class ReportService {
|
||||
};
|
||||
}
|
||||
|
||||
private void sendProcessNotification(String userId, String result, String note) {
|
||||
private void sendProcessNotification(String userId, String result, String note, String businessId) {
|
||||
if (userId == null) return;
|
||||
String title = "账号处置通知";
|
||||
String content = switch (result) {
|
||||
@@ -378,8 +634,9 @@ public class ReportService {
|
||||
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
|
||||
default -> "您的账号相关处置已完成。";
|
||||
};
|
||||
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
|
||||
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
|
||||
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis());
|
||||
"risk_action", businessId);
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
|
||||
@@ -65,7 +65,8 @@ public class SecurityConfig {
|
||||
.accessDeniedHandler(forbiddenHandler(objectMapper)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// 公开接口(order=0 最高优先级)
|
||||
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll()
|
||||
.requestMatchers("/auth/wechat/login", "/auth/demo/login", "/auth/demo/register", "/auth/refresh", "/auth/operator/login", "/auth/operator/demo/register")
|
||||
.permitAll()
|
||||
.requestMatchers("/health/**").permitAll()
|
||||
.requestMatchers("/version").permitAll()
|
||||
.requestMatchers("/app/config").permitAll()
|
||||
|
||||
@@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final List<String> PERMITTED_PATHS = List.of(
|
||||
"/api/v1/auth/**",
|
||||
"/auth/**",
|
||||
"/api/v1/health/**",
|
||||
"/health/**",
|
||||
"/api/v1/version",
|
||||
"/version",
|
||||
"/api/v1/operator/auth/**",
|
||||
"/api/v1/app/**",
|
||||
"/api/v1/activities"
|
||||
"/app/**",
|
||||
"/api/v1/activities",
|
||||
"/activities"
|
||||
);
|
||||
|
||||
@Override
|
||||
@@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String contextPath = request.getContextPath();
|
||||
String servletPath = request.getServletPath();
|
||||
if (servletPath == null || servletPath.isEmpty()) {
|
||||
servletPath = request.getRequestURI();
|
||||
}
|
||||
String effectivePath = (contextPath != null && !contextPath.isEmpty())
|
||||
? contextPath + servletPath : servletPath;
|
||||
String method = request.getMethod();
|
||||
|
||||
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) {
|
||||
if ("OPTIONS".equals(method)
|
||||
|| isPermittedPath(effectivePath)
|
||||
|| isPermittedPath(request.getRequestURI())) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
@@ -96,7 +106,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
|
||||
// 运营账号禁用/待激活均不得通过过滤器。
|
||||
var operator = operatorMapper.findById(subject);
|
||||
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
|
||||
if (operator.isEmpty() || !("active".equals(operator.get().getStatus())
|
||||
|| "normal".equals(operator.get().getStatus()))) {
|
||||
sendUnauthorized(response);
|
||||
return;
|
||||
}
|
||||
@@ -136,7 +147,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
private boolean isPermittedPath(String path) {
|
||||
String normalizedPath = path.startsWith("/api/v1")
|
||||
? path
|
||||
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
|
||||
return PERMITTED_PATHS.stream()
|
||||
.anyMatch(pattern -> pathMatcher.match(pattern, path));
|
||||
.anyMatch(pattern -> pathMatcher.match(pattern, path)
|
||||
|| pathMatcher.match(pattern, normalizedPath));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ wechat:
|
||||
mock:
|
||||
openid: ${WECHAT_MOCK_OPENID:}
|
||||
|
||||
demo:
|
||||
login:
|
||||
enabled: ${DEMO_LOGIN_ENABLED:false}
|
||||
password: ${DEMO_LOGIN_PASSWORD:demo123}
|
||||
usernames: ${DEMO_LOGIN_USERNAMES:admin,wanghongniang}
|
||||
|
||||
encryption:
|
||||
key: ${ENCRYPTION_KEY}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
|
||||
-- ============================================================
|
||||
BEGIN;
|
||||
SET session_replication_role = 'replica';
|
||||
|
||||
-- 1. 组织
|
||||
INSERT INTO organizations (id, name, type, contact_name, status) VALUES
|
||||
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
|
||||
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
|
||||
|
||||
-- 2. 工作人员(V1.0原有 + V1.1新增)
|
||||
INSERT INTO operators (id, organization_id, name, username, password_hash, status)
|
||||
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
|
||||
SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='women_federation'
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='women_federation'
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='charity_center'
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='trade_union'
|
||||
-- V1.1 新增演示操作员
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='women_federation'
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='charity_center'
|
||||
UNION ALL
|
||||
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
|
||||
SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
|
||||
FROM organizations WHERE type='women_federation';
|
||||
|
||||
-- 操作员角色绑定(V1.0原有 + V1.1新增)
|
||||
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
|
||||
-- ============================================================
|
||||
-- 4. 审核任务
|
||||
-- ============================================================
|
||||
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version)
|
||||
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
|
||||
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
|
||||
(SELECT id FROM operators WHERE username='reviewer01'),
|
||||
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1
|
||||
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
|
||||
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
|
||||
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
|
||||
|
||||
-- ============================================================
|
||||
@@ -331,17 +331,17 @@ VALUES
|
||||
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
|
||||
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
|
||||
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
|
||||
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text,
|
||||
INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
|
||||
start_at, end_at, location_name, location_address,
|
||||
male_limit, female_limit, total_limit, min_age, max_age,
|
||||
registration_start_at, registration_end_at, need_review, status, created_by)
|
||||
VALUES
|
||||
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)',
|
||||
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
|
||||
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
|
||||
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
|
||||
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
|
||||
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
|
||||
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你',
|
||||
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
|
||||
'七夕节特别策划,浪漫主题相亲活动。',
|
||||
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
|
||||
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
|
||||
@@ -442,7 +442,6 @@ VALUES
|
||||
('manual_review_needed', '需人工复核', 'quality', true, 7)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
SET session_replication_role = 'origin';
|
||||
DROP TABLE IF EXISTS _conv_result;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
-- ============================================================
|
||||
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
|
||||
-- ============================================================
|
||||
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
|
||||
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
|
||||
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
|
||||
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
|
||||
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
|
||||
--
|
||||
-- 约束(与任务一致):
|
||||
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
|
||||
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
|
||||
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
|
||||
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
|
||||
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
|
||||
-- ============================================================
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
|
||||
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
|
||||
-- created_by 引用既有操作员 contenteditor
|
||||
-- ============================================================
|
||||
INSERT INTO home_banners
|
||||
(id, title, subtitle, button_text, target_type, target_value,
|
||||
audience_scope, sort_order, start_at, end_at, status, created_by)
|
||||
VALUES
|
||||
('a1111111-1111-1111-1111-111111110001'::uuid,
|
||||
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
|
||||
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor')),
|
||||
('a1111111-1111-1111-1111-111111110002'::uuid,
|
||||
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
|
||||
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
|
||||
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor')),
|
||||
('a1111111-1111-1111-1111-111111110003'::uuid,
|
||||
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
|
||||
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 婚恋课堂分类 content_categories(4 个)
|
||||
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
|
||||
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
|
||||
-- ============================================================
|
||||
INSERT INTO content_categories (id, name, code, description, sort_order, status)
|
||||
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
|
||||
FROM (VALUES
|
||||
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
|
||||
'恋爱沟通、关系经营与择偶知识', 1),
|
||||
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
|
||||
'婚恋诈骗识别与防范知识', 2),
|
||||
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
|
||||
'婚前准备与关系建立指导', 3),
|
||||
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
|
||||
'传统婚俗与文明新风尚', 4)
|
||||
) AS v(id, name, code, description, sort_order)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
|
||||
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
|
||||
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
|
||||
-- ============================================================
|
||||
INSERT INTO content_articles
|
||||
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
|
||||
VALUES
|
||||
('a3333333-3333-3333-3333-333333330001'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
|
||||
'初次见面聊什么?五个不冷场的话题',
|
||||
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
|
||||
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|
||||
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|
||||
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|
||||
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|
||||
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|
||||
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|
||||
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
|
||||
('a3333333-3333-3333-3333-333333330002'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
|
||||
'如何判断两个人是否合适',
|
||||
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
|
||||
'判断两个人是否合适,可以从三个维度观察。'
|
||||
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|
||||
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|
||||
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|
||||
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
|
||||
('a3333333-3333-3333-3333-333333330003'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
|
||||
'从喜欢到结婚:健康关系的发展节奏',
|
||||
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
|
||||
'从喜欢到结婚,往往要经历几个自然阶段。'
|
||||
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|
||||
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|
||||
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|
||||
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|
||||
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|
||||
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
|
||||
('a3333333-3333-3333-3333-333333330004'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
|
||||
'婚恋反诈指南:识别常见情感骗局',
|
||||
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
|
||||
'婚恋交友中,以下情况需要高度警惕。'
|
||||
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|
||||
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|
||||
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|
||||
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|
||||
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|
||||
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
|
||||
('a3333333-3333-3333-3333-333333330005'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
|
||||
'「杀猪盘」套路全解析',
|
||||
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
|
||||
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|
||||
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|
||||
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|
||||
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|
||||
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|
||||
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
|
||||
('a3333333-3333-3333-3333-333333330006'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
|
||||
'婚前一定要聊清楚的十个问题',
|
||||
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
|
||||
'走进婚姻前,建议和对方认真聊清以下问题。'
|
||||
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|
||||
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|
||||
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|
||||
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|
||||
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|
||||
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|
||||
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|
||||
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|
||||
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|
||||
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|
||||
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
|
||||
('a3333333-3333-3333-3333-333333330007'::uuid,
|
||||
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
|
||||
'婚事新办,文明婚俗新风尚',
|
||||
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
|
||||
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|
||||
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|
||||
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|
||||
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|
||||
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
|
||||
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
|
||||
-- ============================================================
|
||||
INSERT INTO service_locations
|
||||
(id, name, short_name, address, latitude, longitude,
|
||||
contact_phone_display, opening_hours_text, service_description,
|
||||
primary_organization_id, status)
|
||||
VALUES
|
||||
('a4444444-4444-4444-4444-444444440001'::uuid,
|
||||
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
|
||||
31.9210000, 119.6090000, '0511-86666888',
|
||||
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
|
||||
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
|
||||
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
|
||||
('a4444444-4444-4444-4444-444444440002'::uuid,
|
||||
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
|
||||
31.8600000, 119.4900000, '0511-86688866',
|
||||
'周一至周五 9:00-16:30(法定节假日除外)',
|
||||
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
|
||||
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
|
||||
-- 用户端公开列表只展示 status = active
|
||||
-- ============================================================
|
||||
INSERT INTO service_programs
|
||||
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
|
||||
VALUES
|
||||
('a5555555-5555-5555-5555-555555550001'::uuid,
|
||||
'公益红娘一对一服务计划',
|
||||
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
|
||||
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
|
||||
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
|
||||
(SELECT id FROM operators WHERE username = 'admin')),
|
||||
('a5555555-5555-5555-5555-555555550002'::uuid,
|
||||
'婚恋反诈宣传进社区',
|
||||
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
|
||||
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
|
||||
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
|
||||
(SELECT id FROM operators WHERE username = 'admin')),
|
||||
('a5555555-5555-5555-5555-555555550003'::uuid,
|
||||
'婚前辅导公益课堂(筹备中)',
|
||||
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
|
||||
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
|
||||
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
|
||||
(SELECT id FROM operators WHERE username = 'admin'))
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 婚恋咨询 counseling_services(2 条)
|
||||
-- service_type / status 取值对齐生产 CHECK 约束
|
||||
-- ============================================================
|
||||
INSERT INTO counseling_services
|
||||
(id, user_id, service_type, request_source, user_request_summary,
|
||||
organization_id, assigned_operator_id, status,
|
||||
preferred_service_mode, scheduled_start_at, completed_at)
|
||||
VALUES
|
||||
('a6666666-6666-6666-6666-666666660001'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
|
||||
'relationship_guidance', 'user_self',
|
||||
'离异后重新开始,希望了解如何建立健康的新关系。',
|
||||
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
|
||||
(SELECT id FROM operators WHERE username = 'counselor'),
|
||||
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
|
||||
('a6666666-6666-6666-6666-666666660002'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
|
||||
'emotional_support', 'user_self',
|
||||
'工作压力较大,希望有人倾听并给出情绪调节建议。',
|
||||
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
|
||||
(SELECT id FROM operators WHERE username = 'counselor'),
|
||||
'assigned', 'online', NULL, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
|
||||
-- 婚前辅导复用 counseling_services 表,无独立表
|
||||
-- ============================================================
|
||||
INSERT INTO counseling_services
|
||||
(id, user_id, service_type, request_source, user_request_summary,
|
||||
organization_id, assigned_operator_id, status,
|
||||
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
|
||||
VALUES
|
||||
('a6666666-6666-6666-6666-666666660003'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
|
||||
'premarital_guidance', 'user_self',
|
||||
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
|
||||
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
|
||||
(SELECT id FROM operators WHERE username = 'counselor'),
|
||||
'completed',
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
|
||||
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
|
||||
('a6666666-6666-6666-6666-666666660004'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
|
||||
'premarital_guidance', 'user_self',
|
||||
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
|
||||
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
|
||||
(SELECT id FROM operators WHERE username = 'counselor'),
|
||||
'scheduled',
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
|
||||
NOW() + INTERVAL '5 days', NULL, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. 本地资格 user_local_eligibilities(3 条)
|
||||
-- eligibility_type / verification_status 对齐生产 CHECK 约束
|
||||
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
|
||||
-- ============================================================
|
||||
INSERT INTO user_local_eligibilities
|
||||
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
|
||||
VALUES
|
||||
('a7777777-7777-7777-7777-777777770001'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
|
||||
'household_registration', '321181', 'verified',
|
||||
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
|
||||
('a7777777-7777-7777-7777-777777770002'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
|
||||
'employment', '321181', 'verified',
|
||||
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
|
||||
('a7777777-7777-7777-7777-777777770003'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
|
||||
'residence', '321100', 'pending_review', NULL, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
|
||||
-- ============================================================
|
||||
INSERT INTO local_eligibility_evidences
|
||||
(id, local_eligibility_id, file_key, evidence_type, status)
|
||||
VALUES
|
||||
('a8888888-8888-8888-8888-888888880001'::uuid,
|
||||
(SELECT id FROM user_local_eligibilities
|
||||
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
|
||||
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
|
||||
('a8888888-8888-8888-8888-888888880002'::uuid,
|
||||
(SELECT id FROM user_local_eligibilities
|
||||
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
|
||||
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
|
||||
-- ------------------------------------------------------------
|
||||
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
|
||||
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
|
||||
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
|
||||
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
|
||||
--
|
||||
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
|
||||
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
|
||||
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
|
||||
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
|
||||
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
|
||||
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
|
||||
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
|
||||
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
|
||||
-- ============================================================
|
||||
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
|
||||
VALUES
|
||||
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
|
||||
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
|
||||
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
|
||||
region_code, region_name, marital_status, children_status,
|
||||
education_code, occupation_code, smoking_status, drinking_status,
|
||||
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
|
||||
VALUES
|
||||
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
|
||||
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
|
||||
'是', '是', '结婚', 93, 'approved'),
|
||||
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
|
||||
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
|
||||
'是', '是', '结婚', 91, 'approved'),
|
||||
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
|
||||
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
|
||||
'是', '是', '结婚', 94, 'approved')
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
|
||||
INSERT INTO user_preferences
|
||||
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
|
||||
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
|
||||
VALUES
|
||||
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
|
||||
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
|
||||
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
|
||||
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
|
||||
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
|
||||
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
|
||||
INSERT INTO user_privacy_settings
|
||||
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
|
||||
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
|
||||
VALUES
|
||||
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
|
||||
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
|
||||
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
|
||||
INSERT INTO user_identities
|
||||
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
|
||||
VALUES
|
||||
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
|
||||
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
|
||||
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
|
||||
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
|
||||
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
|
||||
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
|
||||
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
|
||||
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
|
||||
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 照片(头像 + 生活照,与既有演示用户一致)
|
||||
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
|
||||
VALUES
|
||||
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
|
||||
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
|
||||
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
|
||||
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
|
||||
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
|
||||
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
|
||||
-- ------------------------------------------------------------
|
||||
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
|
||||
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
|
||||
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
|
||||
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
|
||||
-- 保持一致(均指向演示红娘 wanghongniang)。
|
||||
-- ============================================================
|
||||
INSERT INTO matchmaker_assignments
|
||||
(id, user_id, matchmaker_operator_id, service_id, assigned_by,
|
||||
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at)
|
||||
VALUES
|
||||
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
|
||||
('a9944444-4444-4444-4444-444444440001'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
|
||||
(SELECT id FROM operators WHERE username = 'wanghongniang'),
|
||||
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'),
|
||||
'primary', '演示:红娘小王主责跟进', 'active',
|
||||
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
||||
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
|
||||
('a9944444-4444-4444-4444-444444440002'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
|
||||
(SELECT id FROM operators WHERE username = 'wanghongniang'),
|
||||
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'),
|
||||
'primary', '演示:红娘小王主责跟进', 'active',
|
||||
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
|
||||
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
|
||||
('a9944444-4444-4444-4444-444444440003'::uuid,
|
||||
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
|
||||
(SELECT id FROM operators WHERE username = 'wanghongniang'),
|
||||
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'),
|
||||
'primary', '演示:服务已完结', 'ended',
|
||||
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 12. 服务站运营人员数据范围(service_location_operators)
|
||||
-- ------------------------------------------------------------
|
||||
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
|
||||
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
|
||||
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
|
||||
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
|
||||
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
|
||||
-- ============================================================
|
||||
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
|
||||
VALUES
|
||||
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
|
||||
(SELECT id FROM operators WHERE username = 'stationop'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
|
||||
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
|
||||
(SELECT id FROM operators WHERE username = 'stationop'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
|
||||
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
|
||||
(SELECT id FROM operators WHERE username = 'stationop'),
|
||||
(SELECT id FROM operators WHERE username = 'admin'), NOW())
|
||||
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 验证
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
|
||||
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
|
||||
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
|
||||
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO rec_cnt FROM users
|
||||
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
|
||||
AND deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
|
||||
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
|
||||
'a9900000-0000-0000-0000-000000000008'::uuid,
|
||||
'a9900000-0000-0000-0000-000000000009'::uuid);
|
||||
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
|
||||
RAISE NOTICE '========================================';
|
||||
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
|
||||
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
|
||||
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
|
||||
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
|
||||
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
|
||||
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
|
||||
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
|
||||
RAISE NOTICE '========================================';
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
|
||||
|
||||
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
|
||||
|
||||
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
|
||||
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
|
||||
|
||||
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
|
||||
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
|
||||
|
||||
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
|
||||
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
|
||||
|
||||
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active');
|
||||
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
|
||||
|
||||
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 指定对象的公益红娘协助申请(不交换联系方式)
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID;
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ;
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500);
|
||||
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
|
||||
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
|
||||
'recommend_partner', 'review_preference', 'contact_intention', 'join_activity',
|
||||
'no_match_help', 'chat_help', 'other', 'recommendation',
|
||||
'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station'
|
||||
));
|
||||
CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC);
|
||||
@@ -73,7 +73,7 @@ class ActivityMutualConsentServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)")
|
||||
void doubleConfirmCreatesMatchAndConversation() {
|
||||
void doubleConfirmCreatesMatchWithoutConversation() {
|
||||
// 第一阶段:建立互选同意记录(二阶段同意)
|
||||
consentService.createMutualConsent(activityId, userA, userB);
|
||||
|
||||
@@ -95,8 +95,8 @@ class ActivityMutualConsentServiceTest {
|
||||
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource());
|
||||
|
||||
// 会话必须已开启(Gate:互选确认后双方可沟通)
|
||||
assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(),
|
||||
"双确认后必须已开启 Conversation");
|
||||
assertTrue(conversationMapper.findByMatchId(match.getId()).isEmpty(),
|
||||
"双确认后不得创建用户聊天 Conversation");
|
||||
|
||||
// 溯源字段:consent 记录应绑定 match_id
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow();
|
||||
|
||||
@@ -15,6 +15,8 @@ import com.danyang.match.platform.risk.entity.RiskAction;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -68,6 +70,8 @@ class AppealPhase18IntegrationTest {
|
||||
@Autowired
|
||||
private NotificationMapper notificationMapper;
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
@Autowired
|
||||
private FeatureFlagConfig featureFlagConfig;
|
||||
|
||||
private boolean originalAppealEnabled;
|
||||
@@ -201,6 +205,44 @@ class AppealPhase18IntegrationTest {
|
||||
assertEquals("suspended", status.get("accountStatus"));
|
||||
}
|
||||
|
||||
// ==================== 申诉通过回滚 users.account_status(避免"通过仍锁死") ====================
|
||||
|
||||
private User insertUser(String accountStatus) {
|
||||
User user = new User();
|
||||
user.setId(UUID.randomUUID().toString());
|
||||
user.setWechatOpenid("openid-" + UUID.randomUUID());
|
||||
user.setAccountStatus(accountStatus);
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void approveRestoresAccountStatusToNormal() {
|
||||
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
|
||||
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
|
||||
|
||||
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
|
||||
process(appealId, "approve");
|
||||
|
||||
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
|
||||
userMapper.selectById(user.getId()).getAccountStatus(),
|
||||
"申诉通过且该用户已无其他生效封禁/暂停时,必须把 users.account_status 恢复为 normal");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approveKeepsAccountStatusWhenOtherRestrictionRemains() {
|
||||
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
|
||||
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
|
||||
insertAction(user.getId(), Constants.RISK_ACTION_TEMPORARY_SUSPEND, Constants.RISK_ACTION_STATUS_ACTIVE);
|
||||
|
||||
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
|
||||
process(appealId, "approve");
|
||||
|
||||
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
|
||||
userMapper.selectById(user.getId()).getAccountStatus(),
|
||||
"仍有其他生效处置时,账号状态不得被回滚为 normal");
|
||||
}
|
||||
|
||||
// ==================== Gate B:拒绝后原处置保持有效 ====================
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.danyang.match.platform.auth;
|
||||
|
||||
import com.danyang.match.platform.auth.service.DemoOperatorRegistrationService;
|
||||
import com.danyang.match.platform.operator.service.OperatorManagementService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DemoOperatorRegistrationServiceTest {
|
||||
|
||||
@Test
|
||||
void disabledDemoRegistrationDoesNotCreateAnOperator() {
|
||||
OperatorManagementService management = mock(OperatorManagementService.class);
|
||||
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class));
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", false);
|
||||
|
||||
assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "password123"));
|
||||
verifyNoInteractions(management);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsShortPasswordsBeforeWriting() {
|
||||
OperatorManagementService management = mock(OperatorManagementService.class);
|
||||
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, mock(JdbcTemplate.class));
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
||||
|
||||
assertThrows(BusinessException.class, () -> service.register("红娘", "demo_user", "short"));
|
||||
verifyNoInteractions(management);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newDemoOperatorUsesTheExistingMatchmakerRole() {
|
||||
// Registration service writes only the role link; status remains the shared normal account state.
|
||||
OperatorManagementService management = mock(OperatorManagementService.class);
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
DemoOperatorRegistrationService service = new DemoOperatorRegistrationService(management, jdbc);
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
||||
Operator operator = new Operator();
|
||||
operator.setId("916afc99-1cee-4115-8978-a93a497cb40f");
|
||||
operator.setUsername("demo_user");
|
||||
operator.setName("红娘");
|
||||
operator.setStatus("active");
|
||||
when(jdbc.queryForObject("SELECT count(*) FROM operators WHERE username = ?", Integer.class, "demo_user")).thenReturn(0);
|
||||
when(jdbc.queryForObject("SELECT id FROM roles WHERE code = 'matchmaker'", String.class)).thenReturn("00000000-0000-0000-0000-000000000005");
|
||||
when(management.createOperator("红娘", "demo_user", "password123", null)).thenReturn(operator);
|
||||
|
||||
service.register("红娘", "demo_user", "password123");
|
||||
|
||||
verify(jdbc).update("INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)",
|
||||
java.util.UUID.fromString(operator.getId()), java.util.UUID.fromString("00000000-0000-0000-0000-000000000005"));
|
||||
verify(jdbc).update("UPDATE operators SET status = 'active' WHERE id = ?", java.util.UUID.fromString(operator.getId()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.danyang.match.platform.auth;
|
||||
|
||||
import com.danyang.match.platform.auth.service.AuthService;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.capability.service.CapabilityService;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DemoUserRegistrationFieldsTest {
|
||||
@Test
|
||||
void registrationPersistsDemoNicknameAndOptionalPhone() {
|
||||
UserMapper users = mock(UserMapper.class);
|
||||
UserProfileMapper profiles = mock(UserProfileMapper.class);
|
||||
JwtTokenService jwt = mock(JwtTokenService.class);
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked") ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(users.findByWechatOpenid("demo_openid_fixed_01")).thenReturn(Optional.empty());
|
||||
when(users.findByPhone("13800138001")).thenReturn(Optional.empty());
|
||||
when(users.insert(any(User.class))).thenAnswer(call -> {
|
||||
((User) call.getArgument(0)).setId("0f11d10a-befa-4d78-a934-e98046fd3c44");
|
||||
return 1;
|
||||
});
|
||||
when(jwt.generateAccessToken(anyString(), isNull())).thenReturn("access");
|
||||
when(jwt.generateRefreshToken(anyString())).thenReturn("refresh");
|
||||
when(jwt.getRefreshExpirationMs()).thenReturn(60000L);
|
||||
AuthService service = new AuthService(users, mock(WechatLoginProvider.class), jwt, redis,
|
||||
mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class), mock(CapabilityService.class),
|
||||
profiles, mock(ReviewTaskMapper.class));
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
||||
|
||||
var result = service.demoRegister("demo_openid_fixed_01", "演示小芳", "13800138001");
|
||||
|
||||
assertEquals("演示小芳", result.get("displayName"));
|
||||
verify(users).findByPhone("13800138001");
|
||||
verify(users).updateById(argThat(user -> "13800138001".equals(user.getPhone())));
|
||||
verify(profiles).insert(argThat(profile -> "演示小芳".equals(profile.getDisplayName())
|
||||
&& "in_progress".equals(profile.getProfileStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidPhoneIsRejectedBeforeWriting() {
|
||||
UserMapper users = mock(UserMapper.class);
|
||||
AuthService service = new AuthService(users, mock(WechatLoginProvider.class), mock(JwtTokenService.class),
|
||||
mock(StringRedisTemplate.class), mock(AuditLogMapper.class), mock(LocalEligibilityMapper.class),
|
||||
mock(CapabilityService.class), mock(UserProfileMapper.class), mock(ReviewTaskMapper.class));
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> service.demoRegister("demo_openid_fixed_01", "演示小芳", "123"));
|
||||
verifyNoInteractions(users);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -61,4 +62,27 @@ class OperatorAuthServiceTest {
|
||||
verify(jwtTokenService, never()).generateOperatorToken(
|
||||
org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyMap());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void demoPasswordCanRecoverSeedHashDriftOnlyWhenDemoLoginIsEnabled() {
|
||||
Operator operator = new Operator();
|
||||
operator.setId("operator-1");
|
||||
operator.setUsername("admin");
|
||||
operator.setPasswordHash("stale-hash");
|
||||
operator.setStatus("active");
|
||||
when(operatorMapper.findByUsername("admin")).thenReturn(Optional.of(operator));
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(passwordEncoder.matches("demo123", "stale-hash")).thenReturn(false);
|
||||
when(permissionMapper.findPermissionsByOperatorId("operator-1")).thenReturn(java.util.List.of());
|
||||
when(jwtTokenService.generateOperatorToken(org.mockito.ArgumentMatchers.eq("operator-1"), org.mockito.ArgumentMatchers.anyMap()))
|
||||
.thenReturn("access");
|
||||
when(jwtTokenService.generateRefreshToken("operator-1")).thenReturn("refresh");
|
||||
ReflectionTestUtils.setField(service, "demoLoginEnabled", true);
|
||||
ReflectionTestUtils.setField(service, "demoLoginPassword", "demo123");
|
||||
ReflectionTestUtils.setField(service, "demoLoginUsernames", "admin,wanghongniang");
|
||||
|
||||
var result = service.login("admin", "demo123");
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertEquals("access", result.get("accessToken"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@AutoConfigureMockMvc
|
||||
@Disabled("用户端即时聊天已按产品方案下线")
|
||||
class ChatPhase09IntegrationTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
|
||||
@@ -40,7 +40,8 @@ class MigrationValidationTest {
|
||||
// (六态状态机 + active 部分唯一索引)
|
||||
// 用户展示编号:V058 users.display_number 及历史数据回填。
|
||||
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
|
||||
private static final int LATEST_MIGRATION = 60;
|
||||
// 红娘协助对象与授权字段:V061 matchmaker_services 扩展。
|
||||
private static final int LATEST_MIGRATION = 61;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
@@ -94,6 +94,7 @@ class P0FixVerificationTest {
|
||||
// ==================== P0-1: Block不变量验证 ====================
|
||||
|
||||
@Test
|
||||
@Disabled("用户端即时聊天已按产品方案下线")
|
||||
@DisplayName("INV-003: Block后A不可向B发送消息")
|
||||
void testBlockPreventsChat() {
|
||||
relationshipService.like(userA, userB);
|
||||
@@ -213,7 +214,7 @@ class P0FixVerificationTest {
|
||||
var convs = conversationMapper.selectList(
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
|
||||
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
|
||||
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation");
|
||||
assertTrue(convs.isEmpty(), "Match 后不得自动创建 Conversation");
|
||||
}
|
||||
|
||||
// ==================== P1-1: Required全字段过滤验证 ====================
|
||||
|
||||
@@ -258,12 +258,12 @@ class MatchmakerActionGuardTest {
|
||||
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
|
||||
@DisplayName("MM-ACTION-002(负向): operator token 打已下线会话端点 → 410")
|
||||
void operatorCannotOpenConversationFromMatch() throws Exception {
|
||||
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
|
||||
// 用户间会话已全局下线,先于角色鉴权返回 CHAT_DISABLED。
|
||||
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
|
||||
.header("Authorization", "Bearer " + operatorToken))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isGone());
|
||||
}
|
||||
|
||||
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
|
||||
|
||||
@@ -260,11 +260,11 @@ class MatchmakerAssignmentA1Test {
|
||||
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
|
||||
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
|
||||
"resourceType 必须是 matchmaker_assignment");
|
||||
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
|
||||
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason,且必须是合法 JSON
|
||||
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
|
||||
&& a.getAfterData().contains("matchmakerOperatorId=")
|
||||
&& a.getAfterData().contains("scope=")
|
||||
&& a.getAfterData().contains("reason=")),
|
||||
&& a.getAfterData().contains("\"matchmakerOperatorId\"")
|
||||
&& a.getAfterData().contains("\"scope\"")
|
||||
&& a.getAfterData().contains("\"reason\"")),
|
||||
"afterData 必须记录 operatorId/scope/reason 摘要");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapp
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.referral.service.ReferralService;
|
||||
@@ -36,6 +38,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -63,6 +66,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = "demo.login.enabled=true")
|
||||
@Transactional
|
||||
class MatchmakerDataScope005Test {
|
||||
|
||||
@@ -77,6 +81,7 @@ class MatchmakerDataScope005Test {
|
||||
@Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private MatchmakerAssignmentController assignmentController;
|
||||
@Autowired private NotificationMapper notificationMapper;
|
||||
@Autowired private ReferralService referralService;
|
||||
|
||||
private String leaderA; // 组长,org X
|
||||
@@ -190,6 +195,23 @@ class MatchmakerDataScope005Test {
|
||||
throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("演示服务分配、开始、完成均通知用户,完成后邀请评价")
|
||||
void demoServiceLifecycleCreatesUserNotifications() {
|
||||
String serviceId = createService(user1, mmB);
|
||||
createAssignment(mmB, user1, serviceId);
|
||||
matchmakerServiceService.startService(serviceId, mmB);
|
||||
matchmakerServiceService.completeService(serviceId, mmB);
|
||||
|
||||
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
|
||||
.eq(Notification::getUserId, user1)
|
||||
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER)
|
||||
.eq(Notification::getBusinessType, "matchmaker_service")
|
||||
.eq(Notification::getBusinessId, serviceId));
|
||||
assertEquals(Set.of("红娘已接单", "服务进行中", "服务已完成", "服务评价邀请"),
|
||||
notes.stream().map(Notification::getTitle).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
// ==================== MM-PRIVACY-001(负向×3) ====================
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
package com.danyang.match.platform.notification;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.notification.entity.Notification;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Mock
|
||||
private NotificationMapper mapper;
|
||||
|
||||
private NotificationService notificationService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notificationService = new NotificationService(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNotificationTypeConstants() {
|
||||
assertEquals("review", Constants.NOTIFICATION_TYPE_REVIEW);
|
||||
@@ -26,8 +50,36 @@ class NotificationServiceTest {
|
||||
|
||||
@Test
|
||||
void testNotificationNotDuplication() {
|
||||
// 同一幂等Key在5分钟内不应重复发送
|
||||
// 这是核心Invariant:业务事件不重复生成通知
|
||||
assertTrue(true); // 占位测试
|
||||
Notification existing = new Notification();
|
||||
existing.setId("notification-1");
|
||||
when(mapper.findExisting("user-1", "matchmaker", "matchmaker_service", "service-1", "服务已完成"))
|
||||
.thenReturn(existing);
|
||||
|
||||
assertSame(existing, notificationService.send("user-1", "matchmaker", "服务已完成", "服务已完成",
|
||||
"matchmaker_service", "service-1"));
|
||||
org.mockito.Mockito.verify(mapper, org.mockito.Mockito.never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchmakerServiceEventsUseServiceNotificationTypeAndBusinessId() {
|
||||
ReflectionTestUtils.setField(notificationService, "demoLoginEnabled", true);
|
||||
when(mapper.findExisting(any(), any(), any(), any(), any())).thenReturn(null);
|
||||
notificationService.sendMatchmakerServiceAssigned("user-1", "service-1");
|
||||
notificationService.sendMatchmakerServiceStarted("user-1", "service-1");
|
||||
notificationService.sendMatchmakerServiceCompleted("user-1", "service-1");
|
||||
notificationService.sendMatchmakerServiceReviewInvite("user-1", "service-1");
|
||||
|
||||
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||
verify(mapper, org.mockito.Mockito.times(4)).insert(captor.capture());
|
||||
assertEquals(4, captor.getAllValues().stream()
|
||||
.filter(n -> "matchmaker".equals(n.getType()))
|
||||
.count());
|
||||
assertTrue(captor.getAllValues().stream().allMatch(n -> "service-1".equals(n.getBusinessId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchmakerServiceEventsStayDisabledOutsideDemo() {
|
||||
notificationService.sendMatchmakerServiceAssigned("user-1", "service-1");
|
||||
org.mockito.Mockito.verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ class RelationshipProgressPhase12Test {
|
||||
// 旧 Match 有会话
|
||||
Long oldConvCount = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId()));
|
||||
assertEquals(1L, oldConvCount, "首个 Match 应有 1 个会话");
|
||||
assertEquals(0L, oldConvCount, "首个 Match 不应有用户聊天会话");
|
||||
|
||||
// Unmatch
|
||||
Match m1 = matchMapper.findById(first.getId()).orElseThrow();
|
||||
@@ -255,10 +255,10 @@ class RelationshipProgressPhase12Test {
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())),
|
||||
"新 Match 不应自动继承旧会话(旧聊天权限不恢复)");
|
||||
|
||||
// 且旧会话仍挂在旧 Match 上,不会被新 Match 复用
|
||||
assertEquals(1L, conversationMapper.selectCount(
|
||||
// 历史数据为空:本产品不再创建用户聊天会话
|
||||
assertEquals(0L, conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())),
|
||||
"旧会话应仍属于旧 Match");
|
||||
"旧 Match 也不应存在用户聊天会话");
|
||||
}
|
||||
|
||||
// ==================== 拒绝 / 恢复 / 退出 ====================
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.danyang.match.platform.risk.service.ReportService;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -76,6 +78,8 @@ class ReportAndRiskPhase17IntegrationTest {
|
||||
@Autowired
|
||||
private AuditLogMapper auditLogMapper;
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
@Autowired
|
||||
private ReportOperatorController reportOperatorController;
|
||||
@Autowired
|
||||
private RiskController riskController;
|
||||
@@ -164,6 +168,64 @@ class ReportAndRiskPhase17IntegrationTest {
|
||||
assertEquals(auditBefore + 1, auditAfter, "PROCESS_REPORT 审计应新增一条");
|
||||
}
|
||||
|
||||
// ==================== 处置必须真正生效:同步 users.account_status ====================
|
||||
|
||||
/**
|
||||
* 封禁 / 暂停若只写 risk_actions 而不落 users.account_status,则登录门禁、推荐引擎、
|
||||
* 匹配资格、能力判定全部不认这个处置 —— 被封禁用户仍可登录、仍出现在推荐/发现中。
|
||||
* 本测试用真实 users 行锁定「处置 → users.account_status」这条链路。
|
||||
*/
|
||||
@Test
|
||||
void processReportSyncsAccountStatusForBanAndSuspendButNotWarning() {
|
||||
User banUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
User suspendUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
User warnUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
|
||||
processReportFor(banUser.getId(), Constants.PROCESS_RESULT_BAN);
|
||||
processReportFor(suspendUser.getId(), Constants.PROCESS_RESULT_TEMPORARY_SUSPEND);
|
||||
processReportFor(warnUser.getId(), Constants.PROCESS_RESULT_WARNING);
|
||||
|
||||
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
|
||||
userMapper.selectById(banUser.getId()).getAccountStatus(),
|
||||
"封禁处置必须把 users.account_status 置为 banned,否则封禁不真正生效");
|
||||
assertEquals(Constants.ACCOUNT_STATUS_SUSPENDED,
|
||||
userMapper.selectById(suspendUser.getId()).getAccountStatus(),
|
||||
"暂停处置必须把 users.account_status 置为 suspended");
|
||||
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
|
||||
userMapper.selectById(warnUser.getId()).getAccountStatus(),
|
||||
"警告处置不改变账号状态");
|
||||
|
||||
// 审计 before/after 为 JSONB:必须落合法 JSON 文本(裸字符串在 PG 上会被拒绝)。
|
||||
AuditLog statusAudit = auditLogMapper.selectOne(new QueryWrapper<AuditLog>()
|
||||
.eq("action", "ACCOUNT_STATUS_CHANGE").eq("resource_id", banUser.getId()));
|
||||
assertNotNull(statusAudit, "处置同步账号状态应写入 ACCOUNT_STATUS_CHANGE 审计");
|
||||
assertTrue(statusAudit.getAfterData().contains("\"accountStatus\":\"banned\""),
|
||||
"审计 after_data 必须是合法 JSON 且记录新状态,实际: " + statusAudit.getAfterData());
|
||||
}
|
||||
|
||||
private User insertUser(String accountStatus) {
|
||||
User user = new User();
|
||||
user.setId(UUID.randomUUID().toString());
|
||||
user.setWechatOpenid("openid-" + UUID.randomUUID());
|
||||
user.setAccountStatus(accountStatus);
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void processReportFor(String reportedUserId, String processResult) {
|
||||
ReportSubmitDTO dto = new ReportSubmitDTO();
|
||||
dto.setReportedUserId(reportedUserId);
|
||||
dto.setReasonType(Constants.REPORT_REASON_FRAUD);
|
||||
dto.setDescription("处置同步账号状态测试");
|
||||
String reportId = (String) reportService.submitReport(UUID.randomUUID().toString(), dto)
|
||||
.get("reportId");
|
||||
|
||||
ReportProcessDTO pdto = new ReportProcessDTO();
|
||||
pdto.setProcessResult(processResult);
|
||||
pdto.setProcessNote("处置同步账号状态测试");
|
||||
reportService.processReport(reportId, pdto, "op-account-sync");
|
||||
}
|
||||
|
||||
// ==================== D2:人工风险标记必须审计 ====================
|
||||
|
||||
@Test
|
||||
|
||||
@@ -453,7 +453,9 @@ CREATE TABLE activity_staff_assignments (
|
||||
-- V009: matchmaker_services 红娘服务表
|
||||
CREATE TABLE matchmaker_services (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL,
|
||||
description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||
description VARCHAR(1000), target_user_id VARCHAR(36), authorization_at TIMESTAMP WITH TIME ZONE,
|
||||
withdrawn_at TIMESTAMP WITH TIME ZONE, withdraw_reason VARCHAR(500),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||
organization_id VARCHAR(36), assigned_operator_id VARCHAR(36),
|
||||
assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE,
|
||||
referral_target_type VARCHAR(32), referral_target_id VARCHAR(36),
|
||||
@@ -464,7 +466,7 @@ CREATE TABLE matchmaker_services (
|
||||
CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
|
||||
'recommend_partner', 'review_preference', 'contact_intention',
|
||||
'join_activity', 'no_match_help', 'chat_help', 'other',
|
||||
'recommendation',
|
||||
'recommendation', 'recommendation_assistance',
|
||||
'referral_counseling', 'referral_activity', 'referral_station'
|
||||
)),
|
||||
CONSTRAINT chk_matchmaker_status CHECK (status IN (
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 用户端无即时通讯边界(2026-10-15)
|
||||
|
||||
## 已确认范围
|
||||
|
||||
- 小程序全流程不展示微信群二维码,也不展示或交换手机号、微信号、QQ 等个人联系方式。
|
||||
- 小程序不提供用户之间的私信、聊天、会话列表或 WebSocket 双向通讯。
|
||||
- 双向意向匹配只产生平台系统通知,并引导用户查看公益活动、公益服务站和公益红娘服务。
|
||||
- 活动签到二维码属于活动现场核验,不属于群二维码,继续保留。
|
||||
|
||||
## 实施边界
|
||||
|
||||
- Like 和活动互选不再创建新 Conversation,不返回 `matchedConversationId`。
|
||||
- 后端 `/conversations`、`/conversations/**`、`/ws/chat` 返回 `410 CHAT_DISABLED`;历史聊天数据保留,不再新增或向用户端开放。
|
||||
- Nginx `/ws/` 直接返回 `410 CHAT_DISABLED`,避免网关继续代理聊天连接。
|
||||
- 小程序不注册消息 Tab、消息页和聊天页;历史源码暂保留用于数据迁移和回归兼容,但不在运行路由中暴露。
|
||||
|
||||
## 验收
|
||||
|
||||
- `tests/miniapp/no_user_im_contract_test.py`:通过。
|
||||
- 仍需在后端容器完成完整 `mvn test -B`,并验证运行中的 REST 聊天接口返回 410。
|
||||
@@ -899,3 +899,8 @@ docker run --rm --dns 223.5.5.5 --dns 114.114.114.114 `
|
||||
- 后端镜像已按当前源码重建成功;健康检查通过,未认证通知接口返回 401。
|
||||
- 活栈 E2E 最新结果:55 passed;剩余 8 errors 为测试容器缺少 Docker CLI,另有活动互选测试依赖容器内 `docker exec`,不能计为完整 E2E 通过。
|
||||
- 证据:`.mimosa/core-e2e-postgres-test.log`。
|
||||
|
||||
## 2026-10-15 无用户 IM 改造
|
||||
- 已按确认方案关闭小程序用户私信/聊天入口,不展示微信群二维码。
|
||||
- 匹配成功保留平台系统通知,并引导公益活动、服务站、红娘服务。
|
||||
- 后端与 Nginx 聊天入口返回 CHAT_DISABLED/410;完整 Maven 回归与运行态验收待本轮完成。
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# V1.1 演示版基线(2026-10-19)
|
||||
|
||||
## 范围
|
||||
|
||||
本批次目标是形成可重复启动、可验证的本地演示版本。允许使用 Mock 微信登录和演示账号;不把演示结果当作正式生产发布证据。
|
||||
|
||||
本批次明确不验收:真实微信账号、生产证书、正式域名、真实用户 UAT、外部备份介质。
|
||||
|
||||
## 当前基线证据
|
||||
|
||||
| 门禁 | 当前结果 |
|
||||
|---|---|
|
||||
| 后端 Maven 全量 | PASS:919 tests,0 failures,0 errors,17 skipped |
|
||||
| Admin type-check | PASS |
|
||||
| Admin build | PASS,但有 CSS URL 解析警告和大 chunk 警告 |
|
||||
| Miniapp type-check | PASS |
|
||||
| 空壳测试守卫 | PASS |
|
||||
| 本地 Compose 解析 | PASS |
|
||||
| 生产 Compose 解析 | PASS:使用临时演示变量验证通过 |
|
||||
| 活栈 Compose | PASS:`danyang-demo` 隔离栈健康,PostgreSQL/Redis/SeaweedFS/Backend/Nginx 均就绪 |
|
||||
| E2E | PASS:全新卷迁移/seed 后真实本地 PostgreSQL 活栈 `72 passed`(2026-10-19 重新验证) |
|
||||
| UI 契约与空壳守卫 | PASS:TabBar/页面/WXML/WXSS/API 行为契约通过 |
|
||||
| H5 浏览器冒烟 | PASS:活栈 `/h5` 下未登录守卫、演示用户登录、6 个核心页、Like、红娘工作台均通过(2026-10-19 重新验证) |
|
||||
|
||||
## 演示候选范围
|
||||
|
||||
- 后端 Java、数据库迁移、后端测试和 E2E fixture。
|
||||
- `infrastructure/docker-compose.yml`、staging 配置、Nginx 和演示环境变量示例。
|
||||
- `admin-web` 构建产物和类型检查范围。
|
||||
- `miniapp` 类型检查及演示路径相关代码。
|
||||
- 必要的演示验收、部署和运行文档。
|
||||
|
||||
## 明确排除
|
||||
|
||||
- `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 等机器本地临时目录。
|
||||
- 未经本批次审查的 H5 或演示新增文件。
|
||||
- 真实微信 Provider、生产证书、正式域名、真实用户数据和外部备份介质。
|
||||
- 与演示闭环无关的功能扩展和大范围重构。
|
||||
|
||||
## 演示验证命令
|
||||
|
||||
```powershell
|
||||
mvn -B -f backend/pom.xml test
|
||||
pnpm --dir admin-web type-check
|
||||
pnpm --dir admin-web build
|
||||
npm --prefix miniapp run type-check
|
||||
python scripts/check-no-empty-tests.py
|
||||
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
|
||||
$env:E2E_BASE_URL='http://localhost:8085/api/v1'
|
||||
$env:E2E_OPERATOR_PASSWORD='演示环境密码'
|
||||
python -m pytest tests/e2e -q
|
||||
$env:DEMO_H5_BASE_URL='http://localhost:6689/h5'
|
||||
npm --prefix h5-demo run smoke
|
||||
```
|
||||
|
||||
本次全新卷验证还确认:首次迁移与 Demo seed 成功,`seed-demo.ps1 -E2E` 第二次重跑幂等成功。当前 `danyang-demo` 数据卷使用创建时的临时凭据(PostgreSQL `demo-db`、Redis `demo-redis`);重建 backend 时必须向 Compose 注入同一组卷凭据,并使用长度至少 32 字节的 JWT 临时密钥。该凭据仅用于本机演示,不得用于生产。
|
||||
|
||||
## 部署到其他演示服务器
|
||||
|
||||
此流程适用于隔离的演示服务器,不适用于生产。新服务器需要 Docker Engine、Docker Compose v2、Git,以及可访问 Docker 镜像仓库和 Maven Central/阿里云 Maven 仓库的网络。后端镜像在目标机从源码构建,因此首次构建会下载依赖。
|
||||
|
||||
1. 拉取本次提交或演示标签,并在仓库根目录准备本机环境文件:
|
||||
|
||||
```powershell
|
||||
git clone http://120.27.207.134:13000/admin/danyang-match.git
|
||||
cd danyang-match
|
||||
git checkout fix-h5-demo-login-20261021
|
||||
Copy-Item infrastructure/.env.example infrastructure/.env
|
||||
```
|
||||
|
||||
2. 编辑 `infrastructure/.env`,为演示环境生成并填写 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET` 和 `ENCRYPTION_KEY`。JWT 密钥至少 32 字节;`ENCRYPTION_KEY` 使用 64 位十六进制值。设置 `MINIO_PUBLIC_ENDPOINT=http://服务器地址:8333`,使浏览器收到的照片上传/下载地址能从服务器外部访问;仅本机演示可用默认值 `http://localhost:8333`。不要将 `.env` 提交到 Git。演示环境设置 `DEMO_LOGIN_ENABLED=true`;Compose 默认启用它,并将演示口令设为 `demo123`,仅允许 `admin,wanghongniang` 使用该回退口令。正式生产配置不启用此能力。
|
||||
|
||||
3. 在仓库根目录构建并启动完整服务栈:
|
||||
|
||||
```powershell
|
||||
docker compose -f infrastructure/docker-compose.yml up -d --build
|
||||
docker compose -f infrastructure/docker-compose.yml ps
|
||||
```
|
||||
|
||||
等待 PostgreSQL、Redis、SeaweedFS 和 Backend 显示健康,再初始化一次演示业务数据:
|
||||
|
||||
```powershell
|
||||
.\scripts\seed-demo.ps1
|
||||
```
|
||||
|
||||
初始化脚本写入用户档案、预置运营账号及 V1.1 首页/内容/服务数据,可重复运行;不要删除已有数据库卷。仅首次部署的空白数据库执行完整基础 seed。已有数据库不会被脚本清空或重置。
|
||||
|
||||
4. 访问本机端口(远程演示时将 `localhost` 替换为服务器域名或 IP,并按需配置 HTTPS/防火墙):
|
||||
|
||||
- H5 用户端和红娘工作台:`http://服务器地址:6689/h5/`
|
||||
- 管理端:`http://服务器地址:6688/admin/`
|
||||
- 浏览器照片上传/下载还需要服务器防火墙开放 `8333`,且 `.env` 中 `MINIO_PUBLIC_ENDPOINT` 与外部访问域名/IP 一致。
|
||||
- H5 用户端选择“晓燕”等预置账号直接登录,无需真实微信;预置登录码对应 `demo_openid_f02` 等 seed 用户。
|
||||
- 管理员:`admin / demo123`;红娘:`wanghongniang / demo123`。
|
||||
|
||||
只有完成演示数据初始化且账号为 active,预置账号才能进入完整页面流程。`DEMO_LOGIN_ENABLED` 只开放受限演示用户登录与指定运营用户名的演示口令回退,不会创建运营账号,也不会绕过账号状态检查。
|
||||
|
||||
5. 升级本次提交后重新构建应用服务:
|
||||
|
||||
```powershell
|
||||
docker compose -f infrastructure/docker-compose.yml up -d --build backend h5-web admin-web
|
||||
```
|
||||
|
||||
本流程不配置真实微信、正式证书或生产域名;线上生产部署应使用 `docker-compose.prod.yml`,独立配置真实身份凭据,并保持演示登录关闭。
|
||||
|
||||
## 演示数据初始化
|
||||
|
||||
在 Compose 栈健康后执行一次即可;脚本可重复运行,不会清空数据库:
|
||||
|
||||
```powershell
|
||||
.\scripts\seed-demo.ps1
|
||||
```
|
||||
|
||||
需要同时准备 E2E 运营账号时:
|
||||
|
||||
```powershell
|
||||
$env:E2E_OPERATOR_PASSWORD='DemoPass123!'
|
||||
.\scripts\seed-demo.ps1 -E2E
|
||||
```
|
||||
|
||||
## 当前判断
|
||||
|
||||
演示版达到 `CONDITIONAL GO (DEMO ONLY)`:可在本机 Docker 环境演示核心用户、运营、红娘、咨询、服务站和活动互选流程。该结论不覆盖真实微信、生产证书、正式域名、真实用户 UAT 或外部备份介质,也不等于正式生产 `GO`。
|
||||
|
||||
正式生产仍保持 `NO-GO`,待上述排除项完成后另行执行生产部署、真实集成、UAT、恢复演练和发布审批。
|
||||
@@ -0,0 +1,349 @@
|
||||
# V1.1 演示发布修复实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在不依赖真实微信账号、生产证书、正式域名、真实用户 UAT 和外部备份介质的前提下,修复当前代码并形成可重复启动、可验证的 V1.1 演示版本。
|
||||
|
||||
**Architecture:** 保留现有模块化单体、Mock 微信 Provider、PostgreSQL、Redis、SeaweedFS、管理端和小程序结构。演示环境与生产环境配置分开;演示验收使用真实 HTTP、真实 PostgreSQL 和真实容器,不把 H2 或静态断言当作完整演示证据。
|
||||
|
||||
**Tech Stack:** Java 21 / Spring Boot 3 / Maven / PostgreSQL 16 / Redis 7 / SeaweedFS 4.47 / Docker Compose / Vue 3 / TypeScript / 微信原生小程序 / pytest。
|
||||
|
||||
**Spec:** `AGENTS.md`、`README.md`、`docs/PHASE26-GO-LIVE-CHECKLIST.md`、`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 演示环境允许 Mock 微信登录,但必须显式使用演示 profile,不能让生产 profile 误启用 Mock。
|
||||
- PostgreSQL 是 Source of Truth;演示 E2E 必须使用真实 PostgreSQL,不得以 H2 结果替代。
|
||||
- 活动互选必须经过双方确认后才创建 Match;红娘权限必须由服务端分配范围控制。
|
||||
- 不修改真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质相关范围;这些作为正式发布前遗留项记录。
|
||||
- 不清理或回滚用户现有未提交改动;发布候选树必须通过显式文件清单建立。
|
||||
- 不新增依赖;优先使用已有 Maven、pnpm、npm、pytest 和 Compose 工具链。
|
||||
- 每个任务完成后先跑该任务的最小测试,再进入下一任务;全量回归最后执行。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 建立演示发布基线并隔离工作区变更
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/STATUS.md`(追加本次演示修复批次状态)
|
||||
- Modify: `docs/REMAINING_WORK.md`(把真实生产风险与演示阻塞分开)
|
||||
- Create: `docs/demo/DEMO-RELEASE-BASELINE-2026-10-19.md`
|
||||
- Inspect: `git status --short`、`git diff --stat`、`VERSION`、现有 release tag
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 演示候选版本的文件清单、排除清单和验证命令。
|
||||
- Later tasks consume: 候选版本范围,避免把 `.mimosa/`、`.trae/`、`.wb_tmp_bcrypt/` 和未审查演示文件混入发布。
|
||||
|
||||
- [ ] **Step 1: 记录当前失败基线**
|
||||
|
||||
记录当前命令及结果:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git diff --stat
|
||||
mvn -B test -f backend/pom.xml
|
||||
pnpm --dir admin-web type-check
|
||||
pnpm --dir admin-web build
|
||||
npm --prefix miniapp run type-check
|
||||
python scripts/check-no-empty-tests.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 建立候选文件清单**
|
||||
|
||||
将生产代码、测试、迁移、演示 Compose、E2E 和文档分组;明确机器临时目录、H5 演示目录和新增迁移是否纳入本批次。
|
||||
|
||||
- [ ] **Step 3: 记录演示版接受边界**
|
||||
|
||||
在 `DEMO-RELEASE-BASELINE-2026-10-19.md` 写明:Mock 微信可用、HTTPS/正式域名不验收、演示账号不代表真实用户验收、备份只做本机可恢复性检查。
|
||||
|
||||
- [ ] **Step 4: 验证文档不再互相矛盾**
|
||||
|
||||
检查 README、STATUS、REMAINING_WORK、PHASE26 清单中的演示状态一致;不得把历史全绿报告写成当前证据。
|
||||
|
||||
**验收:** 文档能准确指出当前失败项、演示范围和排除范围;不改业务代码。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 修复后端当前 9 个失败门禁
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java` 或实际配置匿名路径的文件
|
||||
- Modify: `backend/src/main/java/com/danyang/match/platform/auth/controller/AuthController.java`(仅在测试证明登录入口路径配置错误时)
|
||||
- Modify: `backend/src/main/java/com/danyang/match/platform/health/controller/HealthController.java`(仅在映射存在但未放行时)
|
||||
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/AuthControllerTest.java`
|
||||
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/HealthControllerTest.java`
|
||||
- Modify: `backend/src/test/java/com/danyang/match/platform/auth/PublicMiniappApiTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 未认证健康检查和公开小程序接口返回约定的 200;受保护接口仍返回 401/403。
|
||||
- Preserves: `Authorization: Bearer`、运营端和用户端角色隔离、统一响应体格式。
|
||||
|
||||
- [ ] **Step 1: 先定位过滤链原因**
|
||||
|
||||
用失败测试和 `SecurityConfig` 的 request matcher 对照,列出每个失败 URI、期望状态、当前 matcher。不要通过降低测试断言来消除失败。
|
||||
|
||||
- [ ] **Step 2: 写最小回归断言**
|
||||
|
||||
为每个公开 URI 保留匿名 200 用例,并为一个受保护业务 URI 保留匿名 401 用例,确保放行规则没有扩大到用户数据接口。
|
||||
|
||||
- [ ] **Step 3: 修改一次共享安全配置**
|
||||
|
||||
在统一的 Security matcher 中补齐健康、版本、微信登录和公开小程序资源;不在各 Controller 增加重复鉴权绕过。
|
||||
|
||||
- [ ] **Step 4: 运行专项测试**
|
||||
|
||||
```powershell
|
||||
mvn -B -f backend/pom.xml test `
|
||||
"-Dtest=AuthControllerTest,HealthControllerTest,PublicMiniappApiTest"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行权限回归**
|
||||
|
||||
```powershell
|
||||
mvn -B -f backend/pom.xml test `
|
||||
"-Dtest=SecurityPathCoverageTest,OperatorEndpointPermissionCoverageTest,V11PermissionMatrixContractTest"
|
||||
```
|
||||
|
||||
**验收:** 上述专项测试全绿;匿名公开接口不泄露用户数据;受保护接口仍不能匿名访问。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 修复 V061 迁移与基线守卫
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/main/resources/db/migration/V061__extend_matchmaker_assistance.sql`
|
||||
- Modify: `backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java`
|
||||
- Modify: `backend/src/test/resources/schema-h2.sql`(只有实体/测试 SQL 需要同步时才改)
|
||||
- Modify: `docs/database/MIGRATION_GUIDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 唯一、连续、可被 Flyway 识别的 V061 迁移;测试基线与生产迁移目录一致。
|
||||
- Preserves: 已部署 V001-V060,不重写历史迁移,不复用已发布版本号。
|
||||
|
||||
- [ ] **Step 1: 用现有正则定位失败原因**
|
||||
|
||||
现有守卫要求 `V\\d{3}__[^_]+(?:_[^_]+)*\\.sql`;确认 `extend_matchmaker_assistance` 中的双下划线是否导致描述段不符合当前规则,并记录选择。
|
||||
|
||||
- [ ] **Step 2: 选择兼容命名方案**
|
||||
|
||||
优先把文件名改为满足现有命名规则的单下划线描述;如果业务确实需要允许双下划线,则修改守卫并新增正例/负例,不能只把 `LATEST_MIGRATION` 改成 61。
|
||||
|
||||
- [ ] **Step 3: 更新迁移基线说明**
|
||||
|
||||
在测试注释和迁移指南中登记 V061 的用途、不可重复执行要求和 `matchmaker_services` 新字段。
|
||||
|
||||
- [ ] **Step 4: 做源码和构建产物双重验证**
|
||||
|
||||
```powershell
|
||||
mvn -B -f backend/pom.xml -DskipTests package
|
||||
mvn -B -f backend/pom.xml test `
|
||||
"-Dtest=MigrationValidationTest,EntityMigrationParityTest,DeploymentTests"
|
||||
```
|
||||
|
||||
**验收:** V061 命名守卫通过;构建产物中无重复版本;空库 Flyway 可从头执行到 V061。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 修复并验证演示 Compose
|
||||
|
||||
**Files:**
|
||||
- Modify: `infrastructure/docker-compose.yml`
|
||||
- Modify: `infrastructure/docker-compose.staging.yml`
|
||||
- Modify: `infrastructure/docker-compose.prod.yml`(只修语法和清晰的演示/生产边界,不把生产风险伪装成已解决)
|
||||
- Modify: `infrastructure/.env.example`
|
||||
- Modify: `infrastructure/nginx.conf`
|
||||
- Modify: `h5-demo/`(仅当容器构建或 `/h5/` 路由失败时)
|
||||
- Create: `infrastructure/.env.demo.example`(若现有 `.env.example` 无法表达演示必需变量)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `danyang-demo` 项目可独立启动,端口、容器名、健康检查和演示 Mock 配置一致。
|
||||
- Preserves: 生产 Compose 使用必需变量和 secret;不把演示密码复制进生产文件。
|
||||
|
||||
- [ ] **Step 1: 修正生产 Compose 插值语法**
|
||||
|
||||
把 `${MINIO_PUBLIC_ENDPOINT:http://seaweedfs:8333}` 改成 Compose 合法形式 `${MINIO_PUBLIC_ENDPOINT:-http://seaweedfs:8333}`,并扫描同类错误。
|
||||
|
||||
- [ ] **Step 2: 固化演示环境变量**
|
||||
|
||||
演示 `.env` 至少提供 `DB_PASSWORD`、`REDIS_PASSWORD`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`JWT_ACCESS_SECRET`、`JWT_REFRESH_SECRET`、`ENCRYPTION_KEY` 和 `WECHAT_MOCK_OPENID`;文件中的值只用于本机演示。
|
||||
|
||||
- [ ] **Step 3: 隔离 Compose 项目名**
|
||||
|
||||
所有演示命令统一使用:
|
||||
|
||||
```powershell
|
||||
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
|
||||
docker compose -p danyang-demo -f infrastructure/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证容器健康和路由**
|
||||
|
||||
检查 PostgreSQL、Redis、SeaweedFS、storage-init、backend、admin-web、h5-web、nginx;验证 `/api/v1/health/live`、`/admin/`、`/h5/`。
|
||||
|
||||
- [ ] **Step 5: 清理演示栈**
|
||||
|
||||
```powershell
|
||||
docker compose -p danyang-demo -f infrastructure/docker-compose.yml down
|
||||
```
|
||||
|
||||
仅删除演示项目容器,不使用跨项目的 `--remove-orphans`。
|
||||
|
||||
**验收:** 本地 Compose 解析成功;演示栈可重复启动和停止;生产 Compose 至少能在提供必需变量后解析成功。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 修复演示 E2E 运行条件并完成核心闭环
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/conftest.py`(仅修环境变量、URL 或账号种子问题)
|
||||
- Modify: `tests/e2e/sql/seed_e2e_operators.sql`
|
||||
- Modify: `tests/e2e/home_v11_test.py`
|
||||
- Modify: `tests/e2e/local_eligibility_test.py`
|
||||
- Modify: `tests/e2e/activity_mutual_consent_test.py`
|
||||
- Modify: `tests/e2e/service_appointment_test.py`
|
||||
- Modify: `tests/e2e/referral_v11_test.py`
|
||||
- Modify: `tests/e2e/service_station_permission_test.py`
|
||||
- Modify: `tests/e2e/counseling_permission_test.py`
|
||||
- Modify: `tests/e2e/content_e2e_test.py`
|
||||
- Inspect: `tests/e2e/run_v11_tests.py`(不得把未收集脚本算作 pytest 通过)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 面向演示栈的真实 HTTP + PostgreSQL E2E 结果。
|
||||
- Preserves: 活栈不可达退出码 4;不使用 skip 伪装未执行;Mock 只替代微信登录。
|
||||
|
||||
- [ ] **Step 1: 先验证活栈失败路径**
|
||||
|
||||
在未启动栈时运行 `python -m pytest tests/e2e -q`,确认是明确的活栈退出,而不是大量测试被跳过。
|
||||
|
||||
- [ ] **Step 2: 启动演示栈并初始化 E2E 账号**
|
||||
|
||||
使用隔离 Compose 项目名;通过 SQL seed 创建 `e2e_admin`、`e2e_platform`、`e2e_counselor`、`e2e_station`、`e2e_matchmaker`、`e2e_content`,密码仅通过环境变量传入。
|
||||
|
||||
- [ ] **Step 3: 运行 E2E**
|
||||
|
||||
```powershell
|
||||
$env:E2E_BASE_URL='http://localhost:8085/api/v1'
|
||||
$env:E2E_OPERATOR_PASSWORD='演示环境密码'
|
||||
python -m pytest tests/e2e -q
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修复真实失败根因**
|
||||
|
||||
按失败类型处理:登录/fixture 配置、权限范围、迁移数据、HTTP 状态码、业务状态机。禁止直接放宽断言或给测试增加 skip。
|
||||
|
||||
- [ ] **Step 5: 验证核心跨端路径**
|
||||
|
||||
至少保留一条可复现证据:运营端分配红娘服务 → 用户端看到服务记录 → 用户提交服务操作 → Web 查询结果;确认服务端依据 `matchmaker_assignments` 判定范围。
|
||||
|
||||
**验收:** `tests/e2e` 在演示活栈中通过;活栈停止时套件明确失败;核心红娘跨端链路有日志和结果记录。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 演示功能冒烟与前端构建收口
|
||||
|
||||
**Files:**
|
||||
- Modify: `infrastructure/scripts/smoke-test.sh`(仅补演示端口或健康接口)
|
||||
- Modify: `docs/DEMO-VALIDATION-2026-10-13.md`(更新为当前命令和结果)
|
||||
- Modify: `docs/PHASE26-GO-LIVE-CHECKLIST.md`(增加演示版状态,不删除正式发布要求)
|
||||
- Inspect: `admin-web/src/`、`miniapp/`、`h5-demo/`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 管理端、小程序静态检查、H5 演示和后端健康检查的统一演示报告。
|
||||
- Preserves: 不把 CSS 警告或构建成功误报为完整 UI 验收。
|
||||
|
||||
- [ ] **Step 1: 运行前端门禁**
|
||||
|
||||
```powershell
|
||||
pnpm --dir admin-web type-check
|
||||
pnpm --dir admin-web build
|
||||
npm --prefix miniapp run type-check
|
||||
node ui-tests/ui-document-baseline-contracts.mjs
|
||||
python scripts/check-no-empty-tests.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 做演示端人工路径**
|
||||
|
||||
用浏览器验证 `/admin/` 登录、首页、红娘工作台、咨询、服务站、内容管理;用微信开发者工具验证 Mock 登录、首页、推荐、活动、服务记录和退出登录。
|
||||
|
||||
- [ ] **Step 3: 记录构建警告**
|
||||
|
||||
CSS `url(var(--...))` 警告和大 chunk 先作为 P2 记录;只有造成页面空白、资源加载失败或路由不可用时才提升为 P0。
|
||||
|
||||
- [ ] **Step 4: 输出演示验收报告**
|
||||
|
||||
报告按 PASS / PARTIAL / FAIL / NOT_VERIFIED 分类,附命令、时间、环境、失败截图或日志路径。
|
||||
|
||||
**验收:** 所有演示核心路径可操作;未通过项明确列出,不用“构建通过”替代人工路径。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 演示版冻结与回归门禁
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/demo/DEMO-RELEASE-REPORT-2026-10-19.md`
|
||||
- Modify: `VERSION`(仅在验收通过后)
|
||||
- Inspect: `git diff --check`、`git status --short`、发布 tag 和 bundle
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 一个干净、可追溯、可重新部署的演示版本。
|
||||
- Preserves: 不宣称正式生产发布;正式风险继续列在独立的 release blocker 区域。
|
||||
|
||||
- [ ] **Step 1: 执行全量回归**
|
||||
|
||||
```powershell
|
||||
mvn -B -f backend/pom.xml test
|
||||
pnpm --dir admin-web type-check
|
||||
pnpm --dir admin-web build
|
||||
npm --prefix miniapp run type-check
|
||||
python -m pytest tests/e2e -q
|
||||
docker compose -p danyang-demo -f infrastructure/docker-compose.yml config --quiet
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 检查发布候选树**
|
||||
|
||||
只允许本次修复和必要测试/文档进入候选树;临时目录、密码、token、数据库 dump 不得进入。
|
||||
|
||||
- [ ] **Step 3: 生成演示 tag 和 bundle**
|
||||
|
||||
```powershell
|
||||
git tag demo-v1.1.0-20261019 <release-commit>
|
||||
git bundle create demo-v1.1.0-20261019.bundle demo-v1.1.0-20261019
|
||||
git show demo-v1.1.0-20261019 --stat
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 回读验证 tag 和 bundle**
|
||||
|
||||
```powershell
|
||||
git tag --verify demo-v1.1.0-20261019
|
||||
git bundle verify demo-v1.1.0-20261019.bundle
|
||||
git status --short
|
||||
```
|
||||
|
||||
**验收:** 全量门禁满足演示版标准;tag、bundle、报告三者指向同一提交;工作区没有未解释的发布变更。
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```text
|
||||
Task 1
|
||||
↓
|
||||
Task 2 ──┐
|
||||
Task 3 ──┼─> Task 4 ─> Task 5 ─> Task 6 ─> Task 7
|
||||
┘
|
||||
```
|
||||
|
||||
## 演示版最终门禁
|
||||
|
||||
- **PASS:** 后端全量测试 0 failure / 0 error。
|
||||
- **PASS:** 演示 Compose 解析、启动、健康检查、停止均成功。
|
||||
- **PASS:** E2E 使用真实 PostgreSQL,核心场景通过,未使用 skip 伪绿。
|
||||
- **PASS:** Admin 类型检查和构建、小程序类型检查、UI 契约守卫通过。
|
||||
- **PASS:** Mock 微信登录只在演示 profile 生效。
|
||||
- **PASS:** 演示账号、密钥和数据库数据均与生产隔离。
|
||||
- **NOT_VERIFIED(允许保留):** 真实微信、生产证书、正式域名、真实用户 UAT、外部备份介质。
|
||||
|
||||
## 本计划明确跳过的内容
|
||||
|
||||
本批次不接入真实微信、不申请或验证生产证书、不配置正式域名、不进行真实用户验收、不验证外部备份介质恢复,也不借此宣称正式生产可发布。
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.mimosa
|
||||
*.local
|
||||
@@ -0,0 +1,13 @@
|
||||
# H5 演示端 Dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --ignore-scripts
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-h5.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#172A72" />
|
||||
<title>善缘丹阳</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
# H5 演示端 Nginx 配置(单页应用)
|
||||
#
|
||||
# 与 admin-web/nginx-admin.conf 同构:产物挂在 /h5/ 子路径下(vite build base=/h5/)。
|
||||
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /h5 前缀地转发到本容器,
|
||||
# 因此这里必须自己认识这个前缀。
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 根路径没有内容:H5 产物挂在 /h5/ 子路径下。显式 301 避免"打根路径拿到空白/错页"的假成功。
|
||||
location = / {
|
||||
return 301 /h5/;
|
||||
}
|
||||
|
||||
# SPA:/h5/** 映射到产物根目录。
|
||||
# try_files 兜底写成 URI(/h5/index.html)而不是文件路径:alias + try_files 的已知坑。
|
||||
location /h5/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /h5/index.html;
|
||||
}
|
||||
|
||||
# API proxy —— 演示端与管理端共用同一后端,数据天然一致(联动的关键)。
|
||||
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 未就绪就 [emerg] 退出。
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend_upstream "http://backend:8080";
|
||||
location /api/ {
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 用户间即时聊天已下线:与顶层 nginx 一致返回 410,避免演示端误以为可聊。
|
||||
location /ws/ {
|
||||
default_type application/json;
|
||||
return 410 '{"success":false,"code":"CHAT_DISABLED","message":"平台未提供用户之间的即时聊天功能","data":null}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "danyang-match-h5-demo",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "善缘丹阳 H5 演示端(小程序用户端 + 红娘端网页版,用于快速演示验证)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"demo-login-contract": "node tests/demo-login-contract.cjs",
|
||||
"demo-register-contract": "node tests/demo-register-contract.cjs",
|
||||
"ui-contract": "node tests/ui-contract.cjs",
|
||||
"smoke": "node tests/smoke-h5.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.8",
|
||||
"dayjs": "^1.11.10",
|
||||
"vant": "^4.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 872 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
onMounted(() => {
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
userStore.hydrate()
|
||||
appStore.init().catch(() => {})
|
||||
if (authStore.isLoggedIn) {
|
||||
userStore.fetchStatus().catch(() => {})
|
||||
appStore.fetchCapabilities().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
|
||||
import { releaseIdempotencyKey, generateRequestIdForHeader } from './idempotency'
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string
|
||||
requestId?: string
|
||||
status?: number
|
||||
constructor(code: string, message: string, requestId?: string, status?: number) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.requestId = requestId
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
idempotencyKey?: string
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** 会话失效回调(由 authStore 注入)。 */
|
||||
export let onSessionExpired: (() => void) | null = null
|
||||
export function setOnSessionExpired(fn: (() => void) | null): void {
|
||||
onSessionExpired = fn
|
||||
}
|
||||
|
||||
/** 维护态回调(由 appStore 注入)。 */
|
||||
export let onMaintenance: ((body: any) => void) | null = null
|
||||
export function setOnMaintenance(fn: ((body: any) => void) | null): void {
|
||||
onMaintenance = fn
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
function isAuthEndpoint(url?: string): boolean {
|
||||
return typeof url === 'string' && url.indexOf('/auth/') === 0
|
||||
}
|
||||
|
||||
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = getItem(StorageKeys.accessToken)
|
||||
config.headers = config.headers || {}
|
||||
config.headers['X-Request-Id'] = generateRequestIdForHeader()
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
/** 单飞刷新:并发 401 共享同一次刷新请求。refresh 只走 body,不依赖 Cookie。 */
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
refreshPromise = (async () => {
|
||||
const refreshTokenValue = getItem(StorageKeys.refreshToken)
|
||||
const userId = getItem(StorageKeys.userId)
|
||||
if (!refreshTokenValue) return false
|
||||
try {
|
||||
const resp = await axios.post('/api/v1/auth/refresh', { userId, refreshToken: refreshTokenValue })
|
||||
const body = resp.data || {}
|
||||
if (body.success && body.data?.accessToken) {
|
||||
setItem(StorageKeys.accessToken, body.data.accessToken)
|
||||
if (body.data.refreshToken) setItem(StorageKeys.refreshToken, body.data.refreshToken)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(resp) => {
|
||||
const config = resp.config as InternalAxiosRequestConfig & { __idempotencyKey?: string }
|
||||
const body = resp.data || {}
|
||||
if (body.success) {
|
||||
const data = body.data
|
||||
// 先取值,再做收尾:释放幂等键失败绝不能升级为业务失败(对齐 miniapp 语义)。
|
||||
try {
|
||||
releaseIdempotencyKey(config.__idempotencyKey)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return data
|
||||
}
|
||||
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
|
||||
},
|
||||
async (error) => {
|
||||
const resp = error?.response
|
||||
if (!resp) {
|
||||
throw new ApiError('NETWORK_ERROR', '网络连接失败,请检查网络后重试', undefined, undefined)
|
||||
}
|
||||
const body = resp.data || {}
|
||||
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
|
||||
|
||||
if (resp.status === 503) {
|
||||
if (typeof onMaintenance === 'function') onMaintenance(body)
|
||||
throw new ApiError('MAINTENANCE', '平台正在维护,请稍后再试', body.requestId, 503)
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
// 认证端点自身 401 不触发刷新(否则自我递归);由调用方处理。
|
||||
if (!isAuthEndpoint(config?.url) && !config?.__retried) {
|
||||
const ok = await refreshToken()
|
||||
if (ok) {
|
||||
config.__retried = true
|
||||
const token = getItem(StorageKeys.accessToken)
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return client.request(config)
|
||||
}
|
||||
removeItem(StorageKeys.accessToken)
|
||||
removeItem(StorageKeys.refreshToken)
|
||||
if (typeof onSessionExpired === 'function') onSessionExpired()
|
||||
}
|
||||
throw new ApiError(body.code || 'UNAUTHORIZED', body.message || '请重新登录', body.requestId, 401)
|
||||
}
|
||||
|
||||
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
|
||||
},
|
||||
)
|
||||
|
||||
export interface ApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
data?: any
|
||||
headers?: Record<string, string>
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export async function request<T = any>(url: string, options: ApiOptions = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
headers: options.headers,
|
||||
}
|
||||
config.__idempotencyKey = options.idempotencyKey
|
||||
return client.request(config) as Promise<T>
|
||||
}
|
||||
|
||||
export function get<T = any>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
return client.get(url, { params }) as Promise<T>
|
||||
}
|
||||
|
||||
export function post<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
|
||||
config.__idempotencyKey = opts.idempotencyKey
|
||||
return client.request(config) as Promise<T>
|
||||
}
|
||||
|
||||
export function put<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PUT', data }
|
||||
config.__idempotencyKey = opts.idempotencyKey
|
||||
return client.request(config) as Promise<T>
|
||||
}
|
||||
|
||||
export function patch<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'PATCH', data }
|
||||
config.__idempotencyKey = opts.idempotencyKey
|
||||
return client.request(config) as Promise<T>
|
||||
}
|
||||
|
||||
export function del<T = any>(url: string, data?: any, opts: RequestOptions = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'DELETE', data }
|
||||
config.__idempotencyKey = opts.idempotencyKey
|
||||
return client.request(config) as Promise<T>
|
||||
}
|
||||
|
||||
export default client
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 幂等键托管(复刻 miniapp/utils/api.js 语义)
|
||||
*
|
||||
* 语义:同一次**尚未成功**的逻辑操作复用同一把键;成功后释放,下一次点击才是新的一次。
|
||||
* 「先 resolve 再收尾」——释放失败绝不能升级为业务失败(见 client.ts)。
|
||||
* 未成功操作的键保留 10 分钟。
|
||||
*/
|
||||
const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
interface PendingEntry {
|
||||
key: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingEntry>()
|
||||
|
||||
function generateRequestId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
|
||||
}
|
||||
|
||||
export function idempotencyKey(scope: string, subject?: string | number | null): string {
|
||||
const fingerprint = `${scope}::${subject == null ? '' : subject}`
|
||||
const now = Date.now()
|
||||
const cached = pending.get(fingerprint)
|
||||
if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) {
|
||||
return cached.key
|
||||
}
|
||||
const key = `${scope}:${subject == null ? '' : subject}:${generateRequestId()}`
|
||||
pending.set(fingerprint, { key, createdAt: now })
|
||||
return key
|
||||
}
|
||||
|
||||
export function releaseIdempotencyKey(key?: string): void {
|
||||
if (!key) return
|
||||
for (const [fingerprint, entry] of pending.entries()) {
|
||||
if (entry.key === key) {
|
||||
pending.delete(fingerprint)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRequestIdForHeader(): string {
|
||||
return generateRequestId()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { getItem, setItem, removeItem, StorageKeys } from '@/utils/storage'
|
||||
import { ApiError } from './client'
|
||||
|
||||
/**
|
||||
* 红娘/工作人员端独立请求实例。
|
||||
*
|
||||
* 与用户端 client 的关键区别(见实施计划 R3):
|
||||
* - Bearer 取自 h5.operatorToken(与用户 accessToken 隔离);
|
||||
* - 刷新一律走请求体 `POST /auth/operator/refresh { operatorId, refreshToken }`,
|
||||
* **绝不读 Cookie** —— 避免与同浏览器内的 admin-web 会话串号。
|
||||
*/
|
||||
const operatorClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
operatorClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = getItem(StorageKeys.operatorToken)
|
||||
config.headers = config.headers || {}
|
||||
config.headers['X-Request-Id'] = Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
let refreshing: Promise<boolean> | null = null
|
||||
|
||||
async function refreshOperatorToken(): Promise<boolean> {
|
||||
if (refreshing) return refreshing
|
||||
refreshing = (async () => {
|
||||
const refreshToken = getItem(StorageKeys.refreshToken + '.operator')
|
||||
const operatorId = getItem(StorageKeys.operatorId)
|
||||
if (!refreshToken) return false
|
||||
try {
|
||||
const resp = await axios.post('/api/v1/auth/operator/refresh', { operatorId, refreshToken })
|
||||
const body = resp.data || {}
|
||||
if (body.success && body.data?.accessToken) {
|
||||
setItem(StorageKeys.operatorToken, body.data.accessToken)
|
||||
if (body.data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', body.data.refreshToken)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshing = null
|
||||
}
|
||||
})()
|
||||
return refreshing
|
||||
}
|
||||
|
||||
operatorClient.interceptors.response.use(
|
||||
(resp) => {
|
||||
const body = resp.data || {}
|
||||
if (body.success) return body.data
|
||||
throw new ApiError(body.code || 'BUSINESS_ERROR', body.message || '操作失败', body.requestId, resp.status)
|
||||
},
|
||||
async (error) => {
|
||||
const resp = error?.response
|
||||
if (!resp) throw new ApiError('NETWORK_ERROR', '网络连接失败,请重试')
|
||||
const body = resp.data || {}
|
||||
const config = resp.config as InternalAxiosRequestConfig & { __retried?: boolean }
|
||||
if (resp.status === 401 && !config?.__retried) {
|
||||
const ok = await refreshOperatorToken()
|
||||
if (ok) {
|
||||
config.__retried = true
|
||||
const token = getItem(StorageKeys.operatorToken)
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return operatorClient.request(config)
|
||||
}
|
||||
removeItem(StorageKeys.operatorToken)
|
||||
removeItem(StorageKeys.refreshToken + '.operator')
|
||||
}
|
||||
throw new ApiError(body.code || 'SERVER_ERROR', body.message || '服务暂时不可用', body.requestId, resp.status)
|
||||
},
|
||||
)
|
||||
|
||||
export function opGet<T = any>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
return operatorClient.get(url, { params }) as Promise<T>
|
||||
}
|
||||
export function opPost<T = any>(url: string, data?: any, opts: { idempotencyKey?: string } = {}): Promise<T> {
|
||||
const config: AxiosRequestConfig & { __idempotencyKey?: string } = { url, method: 'POST', data }
|
||||
return operatorClient.request(config) as Promise<T>
|
||||
}
|
||||
export function opPut<T = any>(url: string, data?: any): Promise<T> {
|
||||
return operatorClient.put(url, data) as Promise<T>
|
||||
}
|
||||
|
||||
export default operatorClient
|
||||
@@ -0,0 +1,74 @@
|
||||
import { get, post, del } from '../client'
|
||||
import { idempotencyKey } from '../idempotency'
|
||||
|
||||
/** 活动列表/详情/报名/我的活动/反馈/签到 */
|
||||
export const activityService = {
|
||||
getActivities(params: Record<string, any> = {}) {
|
||||
return get('/activities', params)
|
||||
},
|
||||
getActivityDetail(activityId: string) {
|
||||
return get(`/activities/${encodeURIComponent(activityId)}`)
|
||||
},
|
||||
register(activityId: string, payload: Record<string, any> = {}) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/register`, payload, {
|
||||
idempotencyKey: idempotencyKey('activity-register', activityId),
|
||||
})
|
||||
},
|
||||
cancelRegistration(activityId: string) {
|
||||
return del(`/activities/${encodeURIComponent(activityId)}/register`)
|
||||
},
|
||||
getMyActivities(params: Record<string, any> = {}) {
|
||||
// 后端实际路由为 GET /activities/my(原 /my-activities 无对应 handler → 404)
|
||||
return get('/activities/my', params)
|
||||
},
|
||||
submitFeedback(activityId: string, payload: Record<string, any>) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, {
|
||||
idempotencyKey: idempotencyKey('activity-feedback', activityId),
|
||||
})
|
||||
},
|
||||
generateCheckinToken(activityId: string) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/checkin-token`, {}, {
|
||||
idempotencyKey: idempotencyKey('checkin-token', activityId),
|
||||
})
|
||||
},
|
||||
getMutualMatches(activityId: string) {
|
||||
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual`)
|
||||
},
|
||||
checkMutualInterest(activityId: string, partnerId: string) {
|
||||
return get(`/activities/${encodeURIComponent(activityId)}/interests/mutual/${encodeURIComponent(partnerId)}`)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动互选二阶段同意(INV-014/015):
|
||||
* 互选结果 → 双方各自 confirm → 才创建 Match/沟通入口。
|
||||
* 单向意向保密:结果接口只返回双相交集。
|
||||
*/
|
||||
export const activityMutualService = {
|
||||
getResults(activityId: string) {
|
||||
return get(`/activities/${encodeURIComponent(activityId)}/mutual/results`)
|
||||
},
|
||||
createConsent(activityId: string, partnerId: string) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/mutual/consent`, { partnerId }, {
|
||||
idempotencyKey: idempotencyKey('mutual-consent', `${activityId}:${partnerId}`),
|
||||
})
|
||||
},
|
||||
confirmConnection(activityId: string, partnerId: string) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/mutual/confirm`, { partnerId }, {
|
||||
idempotencyKey: idempotencyKey('mutual-confirm', `${activityId}:${partnerId}`),
|
||||
})
|
||||
},
|
||||
declineConnection(activityId: string, partnerId: string) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/mutual/decline`, { partnerId }, {
|
||||
idempotencyKey: idempotencyKey('mutual-decline', `${activityId}:${partnerId}`),
|
||||
})
|
||||
},
|
||||
getMyConsentStatus(activityId: string, partnerId: string) {
|
||||
return get(`/activities/${encodeURIComponent(activityId)}/mutual/status/${encodeURIComponent(partnerId)}`)
|
||||
},
|
||||
confirmInterest(activityId: string, partnerId: string) {
|
||||
return post(`/activities/${encodeURIComponent(activityId)}/interests/confirm`, { partnerId }, {
|
||||
idempotencyKey: idempotencyKey('interest-confirm', `${activityId}:${partnerId}`),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { get, post } from '../client'
|
||||
import { setItem, removeItem, StorageKeys } from '@/utils/storage'
|
||||
|
||||
/** 用户端认证(对齐 miniapp/services/authService.js) */
|
||||
export const authService = {
|
||||
wechatLogin(payload: { code: string }) {
|
||||
return post('/auth/wechat/login', payload, { idempotencyKey: payload.code })
|
||||
},
|
||||
demoLogin(payload: { code: string }) {
|
||||
return post('/auth/demo/login', payload, { idempotencyKey: payload.code })
|
||||
},
|
||||
demoRegister(payload: { code: string; displayName: string; phone?: string }) {
|
||||
return post('/auth/demo/register', payload, { idempotencyKey: payload.code })
|
||||
},
|
||||
bindPhone(payload: Record<string, any>) {
|
||||
return post('/auth/phone/bind', payload, { idempotencyKey: payload.phoneCode || payload.encryptedData })
|
||||
},
|
||||
refresh(userId?: string | null, refreshToken?: string | null) {
|
||||
return post('/auth/refresh', { userId, refreshToken })
|
||||
},
|
||||
logout() {
|
||||
return post('/auth/logout', {}, { idempotencyKey: 'logout' })
|
||||
},
|
||||
}
|
||||
|
||||
/** 红娘/工作人员端认证(独立会话,Bearer 走 h5.operatorToken) */
|
||||
export const operatorService = {
|
||||
registerDemo(payload: { name: string; username: string; password: string }) {
|
||||
return post('/auth/operator/demo/register', payload)
|
||||
},
|
||||
async login(username: string, password: string): Promise<any> {
|
||||
const data = await post('/auth/operator/login', { username, password })
|
||||
if (data?.accessToken) {
|
||||
setItem(StorageKeys.operatorToken, data.accessToken)
|
||||
if (data.refreshToken) setItem(StorageKeys.refreshToken + '.operator', data.refreshToken)
|
||||
if (data.operatorId) setItem(StorageKeys.operatorId, data.operatorId)
|
||||
if (data.name) setItem(StorageKeys.operatorName, data.name)
|
||||
}
|
||||
return data
|
||||
},
|
||||
refresh(operatorId?: string | null, refreshToken?: string | null) {
|
||||
return post('/auth/operator/refresh', { operatorId, refreshToken })
|
||||
},
|
||||
logout() {
|
||||
return post('/auth/operator/logout', {}).catch(() => {})
|
||||
},
|
||||
clearSession() {
|
||||
removeItem(StorageKeys.operatorToken)
|
||||
removeItem(StorageKeys.refreshToken + '.operator')
|
||||
removeItem(StorageKeys.operatorId)
|
||||
removeItem(StorageKeys.operatorName)
|
||||
},
|
||||
get token(): string | null {
|
||||
return localStorage.getItem('h5.' + StorageKeys.operatorToken)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { authService, operatorService } from './auth'
|
||||
export { userService, profileService, verificationService, localEligibilityService, notificationService, serviceHistoryService } from './me'
|
||||
export { recommendationService, matchService, safetyService, appealService } from './match'
|
||||
export { activityService, activityMutualService } from './activity'
|
||||
export {
|
||||
serviceAppointmentService,
|
||||
serviceStationService,
|
||||
serviceProgramService,
|
||||
contentService,
|
||||
counselingService,
|
||||
} from './service'
|
||||
export { matchmakerService, operatorMatchmakerService, referralService, contactConsentService } from './matchmaker'
|
||||
export { homeService, capabilityService, appConfigService } from './misc'
|
||||
export { ApiError } from '../client'
|
||||
@@ -0,0 +1,103 @@
|
||||
import { get, post, del } from '../client'
|
||||
import { idempotencyKey } from '../idempotency'
|
||||
|
||||
/** 推荐与搜索 */
|
||||
export const recommendationService = {
|
||||
getRecommendations(params: Record<string, any> = {}) {
|
||||
return get('/recommendations', params)
|
||||
},
|
||||
getRecommendationDetail(userId: string) {
|
||||
return get(`/recommendations/${encodeURIComponent(userId)}`)
|
||||
},
|
||||
discover(params: Record<string, any> = {}) {
|
||||
return get('/discover', params)
|
||||
},
|
||||
getUserDetail(userId: string) {
|
||||
return get(`/discover/users/${encodeURIComponent(userId)}`)
|
||||
},
|
||||
}
|
||||
|
||||
/** 关系与匹配(Like/Pass/Block/缘分三榜/Unmatch/成功牵手双确认) */
|
||||
export const matchService = {
|
||||
like(userId: string) {
|
||||
return post('/likes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('like', userId) })
|
||||
},
|
||||
unlike(userId: string) {
|
||||
return del(`/likes/${encodeURIComponent(userId)}`)
|
||||
},
|
||||
pass(userId: string) {
|
||||
return post('/passes', { targetUserId: userId }, { idempotencyKey: idempotencyKey('pass', userId) })
|
||||
},
|
||||
block(userId: string, payload: { reason?: string } = {}) {
|
||||
return post('/blocks', { targetUserId: userId, reason: payload.reason }, { idempotencyKey: idempotencyKey('block', userId) })
|
||||
},
|
||||
unblock(userId: string) {
|
||||
return del(`/blocks/${encodeURIComponent(userId)}`)
|
||||
},
|
||||
getMutualList(params: Record<string, any> = {}) {
|
||||
return get('/relations/fate/mutual', params)
|
||||
},
|
||||
getLikedByMe(params: Record<string, any> = {}) {
|
||||
return get('/relations/fate/liked', params)
|
||||
},
|
||||
getLikers(params: Record<string, any> = {}) {
|
||||
return get('/relations/fate/likers', params)
|
||||
},
|
||||
getBlockedList(params: Record<string, any> = {}) {
|
||||
return get('/blocks', params)
|
||||
},
|
||||
getMatches(params: Record<string, any> = {}) {
|
||||
return get('/matches', params)
|
||||
},
|
||||
unmatch(matchId: string) {
|
||||
return post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, { idempotencyKey: idempotencyKey('unmatch', matchId) })
|
||||
},
|
||||
requestSuccess(matchId: string) {
|
||||
return post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, { idempotencyKey: idempotencyKey('success-req', matchId) })
|
||||
},
|
||||
confirmSuccess(matchId: string) {
|
||||
return post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, { idempotencyKey: idempotencyKey('success-confirm', matchId) })
|
||||
},
|
||||
declineSuccess(matchId: string) {
|
||||
return post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, { idempotencyKey: idempotencyKey('success-decline', matchId) })
|
||||
},
|
||||
}
|
||||
|
||||
/** 举报(不自动 Block) */
|
||||
export const safetyService = {
|
||||
submitReport(payload: { targetUserId: string; reportType: string; description: string; evidenceFileIds?: string[] }) {
|
||||
// 后端真实路由 POST /risk/reports,DTO 字段为 reportedUserId / reasonType(原 /reports + targetUserId/reportType 会导致 404)
|
||||
return post(
|
||||
'/risk/reports',
|
||||
{
|
||||
reportedUserId: payload.targetUserId,
|
||||
reasonType: payload.reportType,
|
||||
description: payload.description,
|
||||
evidenceFileIds: payload.evidenceFileIds,
|
||||
},
|
||||
{ idempotencyKey: idempotencyKey('report', `${payload.targetUserId}:${payload.reportType}`) },
|
||||
)
|
||||
},
|
||||
getMyReports(params: Record<string, any> = {}) {
|
||||
return get('/me/reports', params)
|
||||
},
|
||||
/** 举报详情(GET /risk/reports/{reportId},仅举报人本人可见):返回 { report, evidenceFileKeys, evidenceUrls } */
|
||||
getReportDetail(reportId: string) {
|
||||
return get(`/risk/reports/${encodeURIComponent(reportId)}`)
|
||||
},
|
||||
/** 举报证据直传凭证(POST /risk/reports/evidence/upload-token):返回 { fileKey, uploadUrl, expiresIn } */
|
||||
requestEvidenceUploadToken() {
|
||||
return post('/risk/reports/evidence/upload-token', {})
|
||||
},
|
||||
}
|
||||
|
||||
/** 申诉 */
|
||||
export const appealService = {
|
||||
submitAppeal(payload: Record<string, any>) {
|
||||
const actionKey = payload.relatedActionId || 'no_action'
|
||||
return post('/appeals', payload, { idempotencyKey: idempotencyKey('appeal', `${payload.appealType}:${actionKey}`) })
|
||||
},
|
||||
getMyAppeals(params: Record<string, any> = {}) {
|
||||
return get('/me/appeals', params)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { get, post, put } from '../client'
|
||||
import { opGet, opPost } from '../operatorClient'
|
||||
import { idempotencyKey } from '../idempotency'
|
||||
|
||||
/** 用户端红娘服务(申请/我的服务/详情/推荐) */
|
||||
export const matchmakerService = {
|
||||
applyService(payload: Record<string, any>) {
|
||||
return post('/matchmaker/services', payload, { idempotencyKey: idempotencyKey('matchmaker-apply') })
|
||||
},
|
||||
getMyServices(params: Record<string, any> = {}) {
|
||||
return get('/matchmaker/services', params)
|
||||
},
|
||||
async getServiceDetail(serviceId: string) {
|
||||
// 用户端后端未注册 GET /matchmaker/services/{id}(仅列表接口)——
|
||||
// 由列表按 id 派生,避免对不存在路由发起 404 请求。
|
||||
const result: any = await get('/matchmaker/services', { limit: 50 })
|
||||
const list: any[] = result?.items || result?.records || (Array.isArray(result) ? result : [])
|
||||
return list.find((s: any) => (s.serviceId || s.id) === serviceId) ?? null
|
||||
},
|
||||
confirmService(serviceId: string) {
|
||||
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/confirm`, {}, {
|
||||
idempotencyKey: idempotencyKey('matchmaker-confirm', serviceId),
|
||||
})
|
||||
},
|
||||
withdrawService(serviceId: string) {
|
||||
return post(`/matchmaker/services/${encodeURIComponent(serviceId)}/withdraw`, {}, {
|
||||
idempotencyKey: idempotencyKey('matchmaker-withdraw', serviceId),
|
||||
})
|
||||
},
|
||||
getRecommendations(params: Record<string, any> = {}) {
|
||||
return get('/matchmaker/recommendations', params)
|
||||
},
|
||||
markRecommendationViewed(recommendationId: string) {
|
||||
return put(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/viewed`)
|
||||
},
|
||||
acceptRecommendation(recommendationId: string) {
|
||||
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/accept`, {}, {
|
||||
idempotencyKey: idempotencyKey('mm-rec-accept', recommendationId),
|
||||
})
|
||||
},
|
||||
passRecommendation(recommendationId: string) {
|
||||
return post(`/matchmaker/recommendations/${encodeURIComponent(recommendationId)}/pass`, {}, {
|
||||
idempotencyKey: idempotencyKey('mm-rec-pass', recommendationId),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 红娘工作台(Operator 会话)。
|
||||
* 不变量:红娘只做推荐/跟进,**不可替用户 Like/Pass**(AGENTS 第 5 条)。
|
||||
*/
|
||||
export const operatorMatchmakerService = {
|
||||
dashboard() {
|
||||
return opGet('/operator/matchmaker/dashboard')
|
||||
},
|
||||
myAssignments() {
|
||||
return opGet('/operator/matchmaker/assignments/my')
|
||||
},
|
||||
recommendations() {
|
||||
return opGet('/operator/matchmaker/recommendations')
|
||||
},
|
||||
pending() {
|
||||
return opGet('/operator/matchmaker/pending')
|
||||
},
|
||||
managementServices() {
|
||||
return opGet('/operator/matchmaker/management/services')
|
||||
},
|
||||
serviceDetail(id: string) {
|
||||
return opGet(`/operator/matchmaker/services/${encodeURIComponent(id)}`)
|
||||
},
|
||||
managementServiceDetail(id: string) {
|
||||
return opGet(`/operator/matchmaker/management/services/${encodeURIComponent(id)}`)
|
||||
},
|
||||
assign(id: string, payload: { operatorId: string; reason: string }) {
|
||||
return opPost(`/operator/matchmaker/services/${encodeURIComponent(id)}/assign`, payload)
|
||||
},
|
||||
recommend(serviceId: string, payload: Record<string, any>) {
|
||||
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/recommend`, payload)
|
||||
},
|
||||
followup(serviceId: string, payload: Record<string, any>) {
|
||||
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/followup`, payload)
|
||||
},
|
||||
complete(serviceId: string) {
|
||||
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/complete`, {})
|
||||
},
|
||||
start(serviceId: string) {
|
||||
return opPost(`/operator/matchmaker/services/${encodeURIComponent(serviceId)}/start`, {})
|
||||
},
|
||||
myContactAuthorizations() {
|
||||
return opGet('/operator/matchmaker/contact-authorizations/my')
|
||||
},
|
||||
requestContactAuthorization(payload: Record<string, any>) {
|
||||
return opPost('/operator/matchmaker/contact-authorizations', payload)
|
||||
},
|
||||
revokeContactAuthorization(id: string, reason: string) {
|
||||
return opPost(`/operator/matchmaker/contact-authorizations/${encodeURIComponent(id)}/revoke`, { reason })
|
||||
},
|
||||
referralHistory() {
|
||||
return opGet('/referrals/operator/history')
|
||||
},
|
||||
referToServiceStation(payload: Record<string, any>) {
|
||||
return opPost('/referrals/service-station', payload)
|
||||
},
|
||||
referToActivity(payload: Record<string, any>) {
|
||||
return opPost('/referrals/activity', payload)
|
||||
},
|
||||
}
|
||||
|
||||
/** 红娘转介(用户侧确认) */
|
||||
export const referralService = {
|
||||
getMyReferralHistory() {
|
||||
return get('/referrals/my-history')
|
||||
},
|
||||
confirmReferral(targetId: string, targetType: 'service_station' | 'activity') {
|
||||
return post(`/referrals/${encodeURIComponent(targetId)}/confirm`, { targetType }, {
|
||||
idempotencyKey: idempotencyKey('referral-confirm', `${targetId}:${targetType}`),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/** 受控联系授权(用户单独明示同意,INV) */
|
||||
export const contactConsentService = {
|
||||
my() {
|
||||
return get('/contact-authorizations/my')
|
||||
},
|
||||
consent(id: string) {
|
||||
return post(`/contact-authorizations/${encodeURIComponent(id)}/consent`, {}, {
|
||||
idempotencyKey: idempotencyKey('contact-consent', id),
|
||||
})
|
||||
},
|
||||
decline(id: string) {
|
||||
return post(`/contact-authorizations/${encodeURIComponent(id)}/decline`, {}, {
|
||||
idempotencyKey: idempotencyKey('contact-decline', id),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { get, post, put, patch, del } from '../client'
|
||||
import { idempotencyKey } from '../idempotency'
|
||||
|
||||
/** 本人全局状态(GET /me/status) */
|
||||
export const userService = {
|
||||
getStatus() {
|
||||
return get('/me/status')
|
||||
},
|
||||
}
|
||||
|
||||
/** 本人资料 / 择偶条件 / 照片 / 隐私 / 审核 / 关系状态(对齐 miniapp/services/profileService.js) */
|
||||
export const profileService = {
|
||||
getProfile() {
|
||||
return get('/me/profile')
|
||||
},
|
||||
updateProfile(payload: Record<string, any>) {
|
||||
return put('/me/profile', payload, { idempotencyKey: idempotencyKey('profile-put', payload?.userId || 'me') })
|
||||
},
|
||||
updateBasicProfile(payload: Record<string, any>) {
|
||||
return patch('/me/profile/basic', payload, { idempotencyKey: idempotencyKey('profile-basic', 'me') })
|
||||
},
|
||||
getCompletion() {
|
||||
return get('/me/profile/completion')
|
||||
},
|
||||
getPreferences() {
|
||||
return get('/me/preferences')
|
||||
},
|
||||
updatePreferences(payload: Record<string, any>) {
|
||||
return put('/me/preferences', payload, { idempotencyKey: idempotencyKey('preferences-put') })
|
||||
},
|
||||
getPhotos() {
|
||||
return get('/me/photos')
|
||||
},
|
||||
deletePhoto(photoId: string) {
|
||||
return del(`/me/photos/${encodeURIComponent(photoId)}`)
|
||||
},
|
||||
setMainPhoto(photoId: string) {
|
||||
return put(`/me/photos/${encodeURIComponent(photoId)}/main`, {}, { idempotencyKey: idempotencyKey('photo-main', photoId) })
|
||||
},
|
||||
requestUploadToken(payload: { fileName: string; contentType?: string }) {
|
||||
return post('/me/photos/upload-token', payload, { idempotencyKey: payload.fileName })
|
||||
},
|
||||
confirmPhoto(payload: { fileKey: string; photoType: string }) {
|
||||
return post('/me/photos/confirm', payload, { idempotencyKey: idempotencyKey('photo-confirm', payload.fileKey) })
|
||||
},
|
||||
getPrivacySettings() {
|
||||
return get('/me/privacy-settings')
|
||||
},
|
||||
updatePrivacySettings(payload: Record<string, any>) {
|
||||
return put('/me/privacy-settings', payload, { idempotencyKey: idempotencyKey('privacy-put') })
|
||||
},
|
||||
getReviewStatus() {
|
||||
return get('/me/profile/review-status')
|
||||
},
|
||||
submitReview() {
|
||||
return post('/me/profile/submit-review', {}, { idempotencyKey: idempotencyKey('review-submit') })
|
||||
},
|
||||
getRelationshipStatus() {
|
||||
return get('/me/relationship-status')
|
||||
},
|
||||
pauseSearching() {
|
||||
return patch('/me/relationship-status', { status: 'paused' }, { idempotencyKey: idempotencyKey('rel-pause') })
|
||||
},
|
||||
resumeSearching() {
|
||||
return patch('/me/relationship-status', { status: 'searching' }, { idempotencyKey: idempotencyKey('rel-resume') })
|
||||
},
|
||||
markGettingToKnow() {
|
||||
return patch('/me/relationship-status', { status: 'getting_to_know' }, { idempotencyKey: idempotencyKey('rel-gtk') })
|
||||
},
|
||||
}
|
||||
|
||||
/** 实名认证 */
|
||||
export const verificationService = {
|
||||
submit(payload: { realName: string; idCardNumber: string; faceImageUrl?: string }) {
|
||||
const tail = (payload.idCardNumber || '').slice(-4).toUpperCase()
|
||||
return post('/verification/submit', payload, { idempotencyKey: idempotencyKey('verification-submit', tail) })
|
||||
},
|
||||
getStatus() {
|
||||
return get('/verification/status')
|
||||
},
|
||||
}
|
||||
|
||||
/** 本地资格核验 */
|
||||
export const localEligibilityService = {
|
||||
getStatus() {
|
||||
return get('/local-eligibility')
|
||||
},
|
||||
declare(payload: Record<string, any>) {
|
||||
return post('/local-eligibility/declaration', payload)
|
||||
},
|
||||
uploadEvidence(payload: Record<string, any>) {
|
||||
return post('/local-eligibility/evidences', payload)
|
||||
},
|
||||
submit() {
|
||||
return post('/local-eligibility/submit', {}, { idempotencyKey: idempotencyKey('local-eligibility-submit') })
|
||||
},
|
||||
}
|
||||
|
||||
/** 通知(用户端不提供聊天,仅通知) */
|
||||
export const notificationService = {
|
||||
getNotifications(params: Record<string, any> = {}) {
|
||||
return get('/notifications', params)
|
||||
},
|
||||
markNotificationRead(id: string) {
|
||||
return put(`/notifications/${encodeURIComponent(id)}/read`)
|
||||
},
|
||||
markAllNotificationsRead() {
|
||||
return put('/notifications/read-all')
|
||||
},
|
||||
getNotificationsUnreadCount() {
|
||||
// 后端实际路由为 GET /notifications/unread(返回 {count})
|
||||
return get('/notifications/unread')
|
||||
},
|
||||
}
|
||||
|
||||
/** 公益服务记录(我的服务时间线) */
|
||||
export const serviceHistoryService = {
|
||||
getMyServiceHistory() {
|
||||
return get('/me/service-history')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { get } from '../client'
|
||||
|
||||
/** 首页聚合(GET /home)—— 段序与 8 入口顺序由后端返回决定(AGENTS 第 14/15 条)。 */
|
||||
export const homeService = {
|
||||
getHome() {
|
||||
return get('/home')
|
||||
},
|
||||
}
|
||||
|
||||
/** 能力开关(AGENTS 第 26 条:入口可见性只读服务端) */
|
||||
export const capabilityService = {
|
||||
getCapabilities() {
|
||||
return get('/capabilities')
|
||||
},
|
||||
}
|
||||
|
||||
/** App 运行时配置(数量/阈值等,未确认参数一律配置化) */
|
||||
export const appConfigService = {
|
||||
async fetchAppConfig(fallback: Record<string, any> = {}) {
|
||||
try {
|
||||
const overrides = await get('/app/config')
|
||||
return { ...fallback, ...(overrides || {}) }
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { get, post } from '../client'
|
||||
import { idempotencyKey } from '../idempotency'
|
||||
|
||||
/** 服务预约 */
|
||||
export const serviceAppointmentService = {
|
||||
async getCurrentServiceLocation() {
|
||||
try {
|
||||
const home: any = await get('/home')
|
||||
const location = home?.serviceLocation
|
||||
if (!location?.id) return null
|
||||
return { id: location.id, name: location.name || '已选服务站点' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
getMyAppointments() {
|
||||
return get('/service-appointments')
|
||||
},
|
||||
getAppointmentDetail(id: string) {
|
||||
return get(`/service-appointments/${encodeURIComponent(id)}`)
|
||||
},
|
||||
createAppointment(payload: Record<string, any>) {
|
||||
return post('/service-appointments', {
|
||||
serviceLocationId: payload.serviceLocationId,
|
||||
serviceType: payload.serviceType,
|
||||
preferredDate: payload.preferredDate,
|
||||
preferredTimeSlot: payload.preferredTimeSlot,
|
||||
userNote: payload.userNote,
|
||||
})
|
||||
},
|
||||
cancelAppointment(id: string, reason?: string) {
|
||||
return post(`/service-appointments/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
|
||||
},
|
||||
}
|
||||
|
||||
/** 善缘服务站 */
|
||||
export const serviceStationService = {
|
||||
async getStation() {
|
||||
const home: any = await get('/home')
|
||||
return home?.serviceLocation || null
|
||||
},
|
||||
async getVisibleAnnouncements(locationId: string) {
|
||||
if (!locationId) return []
|
||||
try {
|
||||
const list = await get(`/service-locations/${encodeURIComponent(locationId)}/announcements`)
|
||||
return Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** 专项公益服务项目 */
|
||||
export const serviceProgramService = {
|
||||
getPrograms(params: Record<string, any> = {}) {
|
||||
return get('/service-programs', params)
|
||||
},
|
||||
getProgramDetail(id: string) {
|
||||
return get(`/service-programs/${encodeURIComponent(id)}`)
|
||||
},
|
||||
applyToProgram(id: string) {
|
||||
return post(`/service-programs/${encodeURIComponent(id)}/apply`, {}, { idempotencyKey: idempotencyKey('program-apply', id) })
|
||||
},
|
||||
getMyApplications() {
|
||||
return get('/service-programs/my')
|
||||
},
|
||||
}
|
||||
|
||||
/** 公益内容(婚恋课堂 / 反诈) */
|
||||
export const contentService = {
|
||||
getCategories() {
|
||||
return get('/content/categories')
|
||||
},
|
||||
getArticles(params: Record<string, any> = {}) {
|
||||
return get('/content/articles', params)
|
||||
},
|
||||
getArticleDetail(id: string) {
|
||||
return get(`/content/articles/${encodeURIComponent(id)}`)
|
||||
},
|
||||
}
|
||||
|
||||
/** 婚恋咨询 / 婚前辅导 */
|
||||
export const counselingService = {
|
||||
intro() {
|
||||
return get('/counseling/services/intro')
|
||||
},
|
||||
apply(payload: Record<string, any>) {
|
||||
return post('/counseling/services', payload, { idempotencyKey: idempotencyKey('counseling-apply', payload.serviceType) })
|
||||
},
|
||||
myServices(limit = 20) {
|
||||
return get('/counseling/services', { limit })
|
||||
},
|
||||
detail(id: string) {
|
||||
return get(`/counseling/services/${encodeURIComponent(id)}`)
|
||||
},
|
||||
cancel(id: string, reason?: string) {
|
||||
return post(`/counseling/services/${encodeURIComponent(id)}/cancel`, { reason: reason || '' })
|
||||
},
|
||||
confirmCompanion(id: string) {
|
||||
return post(`/counseling/services/${encodeURIComponent(id)}/companion-confirm`, {}, {
|
||||
idempotencyKey: idempotencyKey('companion-confirm', id),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="ui-avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: size * 0.4 + 'px' }">
|
||||
<img v-if="src" :src="src" alt="" class="ui-avatar__img" @error="onError" />
|
||||
<span v-else class="ui-avatar__text">{{ initial }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ src?: string | null; name?: string | null; size?: number }>(), {
|
||||
src: '',
|
||||
name: '',
|
||||
size: 48,
|
||||
})
|
||||
|
||||
const broken = ref(false)
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
broken.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const initial = computed(() => (props.name ? props.name.slice(0, 1) : '缘'))
|
||||
const src = computed(() => (props.src && !broken.value ? props.src : ''))
|
||||
|
||||
function onError() {
|
||||
broken.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-avatar {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--mini-brand-gradient);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.ui-avatar__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ui-avatar__text {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<van-empty :image="image" :description="description">
|
||||
<slot />
|
||||
</van-empty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ description?: string; image?: string }>(), {
|
||||
description: '暂无数据',
|
||||
image: 'default',
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="ui-error">
|
||||
<van-icon name="warning-o" size="40" color="#f0483e" />
|
||||
<p class="ui-error__text">{{ message }}</p>
|
||||
<van-button size="small" type="primary" @click="$emit('retry')">重试</van-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ message?: string }>(), { message: '加载失败,请稍后重试' })
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.ui-error__text {
|
||||
color: var(--mini-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="ui-loading">
|
||||
<van-loading size="22" :vertical="vertical">{{ text }}</van-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ text?: string; vertical?: boolean }>(), { text: '加载中...', vertical: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="ui-section-header">
|
||||
<h3 class="ui-section-header__title">{{ title }}</h3>
|
||||
<span v-if="moreText" class="ui-section-header__more" @click="$emit('more')">
|
||||
{{ moreText }} <van-icon name="arrow" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; moreText?: string }>()
|
||||
defineEmits<{ more: [] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
.ui-section-header__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin: 0;
|
||||
}
|
||||
.ui-section-header__more {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<span class="ui-tag" :class="`ui-tag--${type}`"><slot /></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' }>(), {
|
||||
type: 'info',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.ui-tag--primary {
|
||||
background: rgba(244, 0, 90, 0.08);
|
||||
color: var(--mini-tabbar-active);
|
||||
}
|
||||
.ui-tag--success {
|
||||
background: rgba(47, 179, 109, 0.12);
|
||||
color: var(--mini-success);
|
||||
}
|
||||
.ui-tag--warning {
|
||||
background: rgba(245, 166, 35, 0.14);
|
||||
color: #b9770e;
|
||||
}
|
||||
.ui-tag--danger {
|
||||
background: rgba(240, 72, 62, 0.12);
|
||||
color: var(--mini-danger);
|
||||
}
|
||||
.ui-tag--info {
|
||||
background: rgba(107, 112, 133, 0.1);
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="ui-user-card" @click="$emit('tap', user)">
|
||||
<UiAvatar :src="user.avatarUrl" :name="user.displayName" :size="48" />
|
||||
<div class="ui-user-card__main">
|
||||
<div class="ui-user-card__title">
|
||||
<span class="ui-user-card__name">{{ user.displayName || '未设置昵称' }}</span>
|
||||
<UiTag v-if="user.verified" type="success">已实名</UiTag>
|
||||
<span v-if="user.online" class="ui-user-card__online">在线</span>
|
||||
</div>
|
||||
<div class="ui-user-card__meta">
|
||||
<span v-if="user.age">{{ user.age }}岁</span>
|
||||
<span v-if="user.heightCm">{{ user.heightCm }}cm · </span>
|
||||
<span>{{ user.regionName || '地区未设置' }}</span>
|
||||
<span v-if="educationLabel(user.educationCode)"> · {{ educationLabel(user.educationCode) }}</span>
|
||||
</div>
|
||||
<div v-if="occupationLabel(user.occupationCode)" class="ui-user-card__meta">{{ occupationLabel(user.occupationCode) }}</div>
|
||||
<div v-if="user.recommendationReasons?.length" class="ui-user-card__reasons">
|
||||
{{ user.recommendationReasons.slice(0, 3).join(' · ') }}
|
||||
</div>
|
||||
<div v-if="maritalLabel(user.maritalStatus) || user.hasChildren !== undefined" class="ui-user-card__meta">
|
||||
<span v-if="maritalLabel(user.maritalStatus)">{{ maritalLabel(user.maritalStatus) }}</span><span v-if="user.hasChildren"> · 有孩</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showActions" class="ui-user-card__actions">
|
||||
<van-button class="ui-user-card__like" size="mini" type="primary" :loading="loading" aria-label="表达喜欢" @click.stop="$emit('like', user)">
|
||||
<img src="/assets/brand/logo-mark.png" alt="" />
|
||||
</van-button>
|
||||
<van-button v-if="showPass" size="mini" plain @click.stop="$emit('pass', user)">略过</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UiAvatar from './UiAvatar.vue'
|
||||
import UiTag from './UiTag.vue'
|
||||
const props = withDefaults(
|
||||
defineProps<{ user: any; showActions?: boolean; showPass?: boolean; loading?: boolean }>(),
|
||||
{ showActions: false, showPass: true, loading: false },
|
||||
)
|
||||
const educationLabel = (code?: string) => ({ senior_high: '高中/中专', junior_high: '初中', college: '大专', bachelor: '本科', master: '硕士', doctor: '博士' }[code || ''] || '')
|
||||
const occupationLabel = (code?: string) => ({ government: '机关事业单位', education: '教育', medical: '医疗', enterprise: '企业职员', technical: '技术人员', self_employed: '个体经营', freelance: '自由职业', service: '服务行业', agriculture: '农业', other: '其他' }[code || ''] || '')
|
||||
const maritalLabel = (code?: string) => ({ never_married: '未婚', divorced: '离异', widowed: '丧偶' }[code || ''] || '')
|
||||
defineEmits<{ tap: [any]; like: [any]; pass: [any] }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui-user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--mini-surface);
|
||||
min-height: 74px;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--mini-border);
|
||||
}
|
||||
.ui-user-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-user-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ui-user-card__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.ui-user-card__online { margin-left: auto; color: var(--mini-success, #198754); font-size: 12px; }
|
||||
.ui-user-card__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ui-user-card__reasons {
|
||||
font-size: 12px;
|
||||
color: var(--mini-tabbar-active);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-user-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ui-user-card__like { width: 36px; height: 36px; padding: 0; border-radius: 50%; }
|
||||
.ui-user-card__like img { width: 21px; height: 21px; object-fit: contain; filter: brightness(0) invert(1); }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="operator-layout">
|
||||
<van-nav-bar
|
||||
:title="title"
|
||||
left-arrow
|
||||
fixed
|
||||
placeholder
|
||||
@click-left="onBack"
|
||||
>
|
||||
<template #right>
|
||||
<span class="operator-layout__logout" @click="onLogout">退出</span>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<div class="operator-layout__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { useOperatorStore } from '@/stores/operator'
|
||||
|
||||
withDefaults(defineProps<{ title?: string }>(), { title: '红娘工作台' })
|
||||
const router = useRouter()
|
||||
const operatorStore = useOperatorStore()
|
||||
|
||||
function onBack() {
|
||||
router.replace('/operator/workbench')
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await showConfirmDialog({ title: '退出登录', message: '确定要退出红娘工作台吗?' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
operatorStore.logout()
|
||||
router.replace('/operator/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operator-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--mini-bg);
|
||||
}
|
||||
.operator-layout__body {
|
||||
min-height: calc(100vh - 46px);
|
||||
}
|
||||
.operator-layout__logout {
|
||||
color: var(--mini-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="page-layout">
|
||||
<van-nav-bar
|
||||
class="page-layout__nav"
|
||||
:title="title"
|
||||
left-arrow
|
||||
fixed
|
||||
placeholder
|
||||
@click-left="onBack"
|
||||
>
|
||||
<template v-if="$slots.right" #right>
|
||||
<slot name="right" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<div class="page-layout__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
withDefaults(defineProps<{ title?: string }>(), { title: '' })
|
||||
const router = useRouter()
|
||||
|
||||
function onBack() {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.replace('/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--mini-bg);
|
||||
}
|
||||
.page-layout__body {
|
||||
min-height: calc(100vh - 46px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="tab-layout">
|
||||
<div class="tab-layout__body">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cached">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
<van-tabbar route fixed placeholder class="tab-layout__bar">
|
||||
<van-tabbar-item to="/home" icon="wap-home-o">首页</van-tabbar-item>
|
||||
<van-tabbar-item to="/discover" icon="friends-o">找对象</van-tabbar-item>
|
||||
<van-tabbar-item to="/notifications" icon="bell-o">通知</van-tabbar-item>
|
||||
<van-tabbar-item to="/activity" icon="flag-o">活动</van-tabbar-item>
|
||||
<van-tabbar-item to="/mine" icon="user-o">我的</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 用户端只保留小程序的非聊天主导航。
|
||||
const cached = ['HomePage', 'DiscoverPage', 'ActivityPage', 'MinePage']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--mini-bg);
|
||||
}
|
||||
.tab-layout__body {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
.tab-layout__bar {
|
||||
border-top: 1px solid var(--mini-border);
|
||||
background: var(--mini-surface);
|
||||
box-shadow: 0 -8px 24px rgba(28, 30, 88, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import './theme/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { setOnSessionExpired, setOnMaintenance } from './api/client'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useUserStore } from './stores/user'
|
||||
import { useAppStore } from './stores/app'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(Vant)
|
||||
app.use(router)
|
||||
|
||||
// 会话失效:清空内存与本地登录态后回登录页(由 api client 的 401 分支触发)。
|
||||
setOnSessionExpired(() => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
const userStore = useUserStore(pinia)
|
||||
authStore.clearSession()
|
||||
userStore.userState = null
|
||||
router.replace('/login')
|
||||
})
|
||||
|
||||
// 维护态:统一提示。
|
||||
setOnMaintenance((body) => {
|
||||
useAppStore(pinia).setMaintenance(body)
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="nf">
|
||||
<van-empty image="error" description="页面不存在或已下线">
|
||||
<van-button type="primary" round @click="$router.replace('/home')">返回首页</van-button>
|
||||
</van-empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<style scoped>
|
||||
.nf {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mini-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<PageLayout title="退出登录">
|
||||
<div class="logout">
|
||||
<div class="logout__banner">
|
||||
<div class="logout__banner-icon">!</div>
|
||||
<div class="logout__banner-title">确认退出登录</div>
|
||||
</div>
|
||||
|
||||
<div class="logout__card">
|
||||
<div class="logout__section-title">退出后:</div>
|
||||
<ul class="logout__list">
|
||||
<li>需要重新登录才能继续使用推荐与公益服务</li>
|
||||
<li>已拉黑、已匹配等关系数据仍会保留</li>
|
||||
<li>个人资料不会被删除,再次登录即可恢复</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<van-button class="logout__btn" type="danger" block round :loading="submitting" @click="onConfirm">
|
||||
退出登录
|
||||
</van-button>
|
||||
<p class="logout__hint">平台不会要求您打电话或邮寄身份证,退出全程在线完成</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
async function onConfirm() {
|
||||
try {
|
||||
await showConfirmDialog({ title: '退出登录', message: '确定要退出当前账号吗?' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await authStore.logout()
|
||||
appStore.toastSuccess('已退出登录')
|
||||
router.replace('/login?clear=1')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logout {
|
||||
padding: 16px;
|
||||
}
|
||||
.logout__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(240, 72, 62, 0.08);
|
||||
border: 1px solid rgba(240, 72, 62, 0.16);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
}
|
||||
.logout__banner-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: rgba(240, 72, 62, 0.14);
|
||||
color: var(--mini-danger);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
flex: none;
|
||||
}
|
||||
.logout__banner-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-danger);
|
||||
}
|
||||
.logout__card {
|
||||
margin-top: 14px;
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
box-shadow: var(--mini-shadow);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.logout__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.logout__list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.logout__btn {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.logout__hint {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
margin: 12px 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<PageLayout title="状态明细">
|
||||
<div class="detail">
|
||||
<UiLoading v-if="loading" />
|
||||
<UiErrorState v-else-if="error" @retry="load" />
|
||||
<template v-else>
|
||||
<UiSectionHeader title="账号与实名" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="账号状态" :value="accountStatusText" />
|
||||
<van-cell title="实名认证" :value="verified ? '已实名' : '未实名'" />
|
||||
<van-cell title="手机号" :value="phoneBound ? '已绑定' : '未绑定'" />
|
||||
</van-cell-group>
|
||||
|
||||
<UiSectionHeader title="资料与本地资格" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="资料状态" :value="reviewStatusText" />
|
||||
<van-cell title="本地资格" :value="localEligibilityText" />
|
||||
</van-cell-group>
|
||||
|
||||
<UiSectionHeader title="婚恋状态" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="关系状态" :value="relationshipText" />
|
||||
<van-cell title="推荐开关" :value="recommendationEnabled ? '已开启' : '已关闭'" />
|
||||
<van-cell title="正在了解的对象" :value="matchingWith ? (matchingWith.displayName || '了解中') : '无'" />
|
||||
</van-cell-group>
|
||||
|
||||
<UiSectionHeader title="相关设置" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="隐私设置" is-link @click="router.push('/settings/privacy')" />
|
||||
<van-cell title="关系状态" is-link @click="router.push('/settings/relationship')" />
|
||||
<van-cell title="退出登录" is-link @click="router.push('/account/logout')" />
|
||||
</van-cell-group>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { profileService, userService } from '@/api/services'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
import UiSectionHeader from '@/components/UiSectionHeader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const ACCOUNT_TEXT: Record<string, string> = {
|
||||
normal: '正常',
|
||||
banned: '已封禁',
|
||||
suspended: '临时暂停',
|
||||
limited: '功能限制',
|
||||
}
|
||||
const REVIEW_TEXT: Record<string, string> = {
|
||||
not_submitted: '未提交',
|
||||
pending_review: '审核中',
|
||||
approved: '已审核',
|
||||
need_edit: '需修改',
|
||||
rejected: '未通过',
|
||||
}
|
||||
const RELATIONSHIP_TEXT: Record<string, string> = {
|
||||
searching: '正在寻找',
|
||||
getting_to_know: '正在了解',
|
||||
paused: '已暂停寻找',
|
||||
matched_successfully: '已成功牵手',
|
||||
service_exited: '已退出服务',
|
||||
}
|
||||
const LOCAL_ELIGIBILITY_TEXT: Record<string, string> = {
|
||||
not_declared: '未声明',
|
||||
pending: '待审核',
|
||||
verifying: '核验中',
|
||||
verified: '已核验',
|
||||
needs_evidence: '需补充佐证',
|
||||
rejected: '未通过',
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const status = ref<any>(null)
|
||||
const relationship = ref<any>(null)
|
||||
|
||||
const accountStatusText = computed(() => ACCOUNT_TEXT[status.value?.accountStatus] || status.value?.accountStatus || '正常')
|
||||
const verified = computed(() => !!status.value?.verified)
|
||||
const phoneBound = computed(() => !!status.value?.phoneBound)
|
||||
const reviewStatusText = computed(() => REVIEW_TEXT[status.value?.reviewStatus || status.value?.profileReviewStatus] || '未提交')
|
||||
const localEligibilityText = computed(
|
||||
() => LOCAL_ELIGIBILITY_TEXT[status.value?.localEligibilityStatus] || status.value?.localEligibilityStatus || '未声明',
|
||||
)
|
||||
const relationshipText = computed(
|
||||
() => RELATIONSHIP_TEXT[relationship.value?.status || status.value?.relationshipStatus] || '—',
|
||||
)
|
||||
const recommendationEnabled = computed(() => (relationship.value ? relationship.value.recommendationEnabled !== false : status.value?.recommendationEnabled !== false))
|
||||
const matchingWith = computed(() => relationship.value?.matchingWith || null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
status.value = await userService.getStatus()
|
||||
} catch {
|
||||
error.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
relationship.value = await profileService.getRelationshipStatus()
|
||||
} catch {
|
||||
relationship.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.detail :deep(.ui-section-header) {
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.detail :deep(.van-cell-group--inset) {
|
||||
margin: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<PageLayout title="账号状态">
|
||||
<div class="acc">
|
||||
<UiLoading v-if="loading" />
|
||||
<UiErrorState v-else-if="error" @retry="load" />
|
||||
<template v-else>
|
||||
<!-- 正常 -->
|
||||
<div v-if="!hasRestriction" class="acc__normal">
|
||||
<div class="acc__normal-icon">✓</div>
|
||||
<div class="acc__normal-title">账号状态正常</div>
|
||||
<p class="acc__normal-hint">您的账号目前无限制,可以正常使用所有功能。</p>
|
||||
</div>
|
||||
|
||||
<!-- 受限 -->
|
||||
<template v-else>
|
||||
<div class="acc__banner">
|
||||
<div class="acc__banner-icon">!</div>
|
||||
<div class="acc__banner-title">账号受限</div>
|
||||
<div class="acc__banner-status">{{ statusText }}</div>
|
||||
</div>
|
||||
<p class="acc__banner-desc">
|
||||
如认为处置有误,可提交申诉,工作人员将在 1-3 个工作日内审核。
|
||||
</p>
|
||||
<van-button type="primary" block round @click="goAppeal">提交申诉</van-button>
|
||||
</template>
|
||||
|
||||
<van-cell-group inset class="acc__links">
|
||||
<van-cell title="状态明细" is-link @click="router.push('/account/status/detail')" />
|
||||
<van-cell title="帮助中心" is-link @click="router.push('/help')" />
|
||||
</van-cell-group>
|
||||
|
||||
<p class="acc__free">本平台为公益服务,所有功能永久免费</p>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { userService } from '@/api/services'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
normal: '正常',
|
||||
banned: '已封禁',
|
||||
suspended: '临时暂停',
|
||||
limited: '功能限制',
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const status = ref<any>(null)
|
||||
|
||||
const accountStatus = computed(() => status.value?.accountStatus || 'normal')
|
||||
const hasRestriction = computed(() => accountStatus.value !== 'normal')
|
||||
const statusText = computed(() => STATUS_TEXT[accountStatus.value] || accountStatus.value)
|
||||
const appealType = computed(() => (accountStatus.value === 'banned' ? 'ban_appeal' : 'suspend_appeal'))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
status.value = await userService.getStatus()
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goAppeal() {
|
||||
router.push({ path: '/appeal', query: { type: appealType.value } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acc {
|
||||
padding: 16px;
|
||||
}
|
||||
.acc__normal,
|
||||
.acc__banner {
|
||||
text-align: center;
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
box-shadow: var(--mini-shadow);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.acc__normal-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(47, 179, 109, 0.14);
|
||||
color: var(--mini-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.acc__normal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.acc__normal-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.acc__banner-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(240, 72, 62, 0.12);
|
||||
color: var(--mini-danger);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.acc__banner-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.acc__banner-status {
|
||||
margin-top: 6px;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(240, 72, 62, 0.12);
|
||||
color: var(--mini-danger);
|
||||
}
|
||||
.acc__banner-desc {
|
||||
margin: 12px 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.acc__links {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.acc :deep(.van-cell-group--inset) {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
.acc__free {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<PageLayout title="活动签到">
|
||||
<UiLoading v-if="loading" text="生成签到码中…" />
|
||||
<UiErrorState v-else-if="error" :message="errorMessage" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="ck">
|
||||
<div class="ck__card">
|
||||
<div class="ck__activity">
|
||||
<div class="ck__title">{{ token.activityTitle || '活动签到' }}</div>
|
||||
<div v-if="token.locationName" class="ck__location">📍 {{ token.locationName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__qr">
|
||||
<div class="ck__qr-icon">▣</div>
|
||||
<div class="ck__qr-hint">请向工作人员展示此签到码</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__token">
|
||||
<div class="ck__token-label">签到码</div>
|
||||
<div class="ck__token-value">{{ token.token }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__expire">有效期至 {{ formatDateTime(token.expiresAt) || '—' }}</div>
|
||||
</div>
|
||||
|
||||
<van-button type="primary" block round @click="copyToken">复制签到码</van-button>
|
||||
<van-button class="ck__refresh" plain block round :loading="refreshing" @click="refresh">
|
||||
刷新签到码
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const token = ref<Record<string, any>>({})
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMessage = ref('生成签到码失败')
|
||||
|
||||
async function fetchToken(isRefresh: boolean) {
|
||||
if (isRefresh) refreshing.value = true
|
||||
else loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
error.value = true
|
||||
errorMessage.value = '缺少活动信息'
|
||||
return
|
||||
}
|
||||
token.value = (await activityService.generateCheckinToken(activityId.value)) || {}
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
errorMessage.value = err?.message || '生成签到码失败'
|
||||
if (isRefresh) appStore.toastError(errorMessage.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
fetchToken(false)
|
||||
}
|
||||
function refresh() {
|
||||
fetchToken(true)
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const value = token.value?.token
|
||||
if (!value) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = value
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
appStore.toastSuccess('签到码已复制')
|
||||
} catch {
|
||||
appStore.toastError('复制失败,请长按手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ck {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
.ck__card {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ck__activity {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ck__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.ck__location {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ck__qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.ck__qr-icon {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--mini-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: var(--mini-text-muted);
|
||||
}
|
||||
.ck__qr-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ck__token {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--mini-bg);
|
||||
border-radius: var(--mini-radius);
|
||||
}
|
||||
.ck__token-label {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ck__token-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
word-break: break-all;
|
||||
margin-top: 6px;
|
||||
-webkit-user-select: all;
|
||||
user-select: all;
|
||||
}
|
||||
.ck__expire {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ck__refresh {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<PageLayout title="活动详情">
|
||||
<UiLoading v-if="loading" text="正在加载活动…" />
|
||||
<UiErrorState v-else-if="error" message="活动信息加载失败" @retry="load" />
|
||||
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
|
||||
<template v-else>
|
||||
<div class="ad">
|
||||
<div class="ad__hero">
|
||||
<div class="ad__badge">{{ typeLabel }}</div>
|
||||
<div class="ad__title">{{ activity.title }}</div>
|
||||
<div class="ad__slogan">真实相识 · 公益免费 · 尊重意愿</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="活动信息" />
|
||||
<div class="ad__card">
|
||||
<div v-if="activity.activityId" class="ad__row">
|
||||
<span class="ad__label">活动编号</span>
|
||||
<span class="ad__value">{{ activity.activityId }}</span>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">时</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ formatDateTime(activity.startAt) || '时间待定' }}</div>
|
||||
<div class="ad__feature-desc">请提前 15 分钟到场签到</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">址</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ activity.locationName || '地点待定' }}</div>
|
||||
<div class="ad__feature-desc">
|
||||
{{ activity.locationAddress || '报名通过后显示详细地址' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">额</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ quotaText }}</div>
|
||||
<div class="ad__feature-desc">报名提交后需等待工作人员审核</div>
|
||||
</div>
|
||||
<UiTag type="primary">公益免费</UiTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activity.description" class="ad__block">
|
||||
<UiSectionHeader title="活动介绍" />
|
||||
<div class="ad__card">
|
||||
<p class="ad__body">{{ activity.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="报名须知" />
|
||||
<div class="ad__card">
|
||||
<div v-for="(r, i) in requirements" :key="i" class="ad__rule">
|
||||
<span class="ad__rule-index">{{ i + 1 }}</span>
|
||||
<span class="ad__rule-text">{{ r }}</span>
|
||||
</div>
|
||||
<div class="ad__notice">
|
||||
不同活动类型的资格要求可能不同,最终以本活动页面和审核结果为准。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="主办信息" />
|
||||
<div class="ad__card">
|
||||
<div class="ad__feature-title">善缘丹阳公益婚恋服务平台</div>
|
||||
<div class="ad__feature-desc">主办单位关系不会自动授予用户资料访问权限。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__action">
|
||||
<template v-if="!isRegistered">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:disabled="!!activity.isFull"
|
||||
@click="goRegister"
|
||||
>
|
||||
{{ activity.isFull ? '名额已满' : '报名参加活动' }}
|
||||
</van-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="ad__action-status">
|
||||
<UiTag :type="statusTag.type">{{ statusTag.text }}</UiTag>
|
||||
<span class="ad__action-hint">{{ checkinText }}</span>
|
||||
</div>
|
||||
<div class="ad__action-btns">
|
||||
<van-button size="small" plain @click="goMyActivities">我的活动</van-button>
|
||||
<van-button v-if="canCheckin" size="small" type="primary" @click="goCheckin">签到</van-button>
|
||||
<van-button size="small" plain type="primary" @click="goFeedback">活动反馈</van-button>
|
||||
<van-button
|
||||
v-if="canCancel"
|
||||
size="small"
|
||||
plain
|
||||
type="danger"
|
||||
:loading="cancelling"
|
||||
@click="onCancel"
|
||||
>
|
||||
取消报名
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiSectionHeader from '@/components/UiSectionHeader.vue'
|
||||
import UiTag from '@/components/UiTag.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiEmpty from '@/components/UiEmpty.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const activity = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const cancelling = ref(false)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
matchmaking: '公益联谊',
|
||||
interest_social: '兴趣社交',
|
||||
relationship_education: '婚恋课堂',
|
||||
premarital_guidance: '婚前辅导',
|
||||
marriage_custom_culture: '婚俗文化',
|
||||
anti_fraud: '反诈宣传',
|
||||
}
|
||||
const typeLabel = computed(
|
||||
() => TYPE_LABELS[activity.value?.activityType] || activity.value?.typeLabel || '公益活动',
|
||||
)
|
||||
|
||||
const registrationStatus = computed(() => String(activity.value?.registrationStatus || ''))
|
||||
const isRegistered = computed(
|
||||
() => !!activity.value?.registered || ['pending', 'approved'].includes(registrationStatus.value),
|
||||
)
|
||||
const checkinStatus = computed(() => String(activity.value?.checkinStatus || ''))
|
||||
const canCheckin = computed(
|
||||
() => registrationStatus.value === 'approved' && checkinStatus.value !== 'checked_in',
|
||||
)
|
||||
const canCancel = computed(() => ['pending', 'approved'].includes(registrationStatus.value))
|
||||
|
||||
const statusTag = computed<{ type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string }>(
|
||||
() => {
|
||||
switch (registrationStatus.value) {
|
||||
case 'pending':
|
||||
return { type: 'warning', text: '报名待审核' }
|
||||
case 'approved':
|
||||
return { type: 'success', text: '报名已通过' }
|
||||
case 'rejected':
|
||||
return { type: 'danger', text: '报名未通过' }
|
||||
case 'cancelled':
|
||||
return { type: 'info', text: '已取消报名' }
|
||||
default:
|
||||
return { type: 'primary', text: '已报名' }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const checkinText = computed(() => {
|
||||
if (checkinStatus.value === 'checked_in') return '已签到'
|
||||
if (registrationStatus.value === 'approved') return '待签到'
|
||||
return ''
|
||||
})
|
||||
|
||||
const quotaText = computed(() => {
|
||||
const a = activity.value || {}
|
||||
if (typeof a.remainingSeats === 'number') return `剩余 ${a.remainingSeats} 个名额`
|
||||
if (a.quotaText) return a.quotaText
|
||||
return '名额以审核结果为准'
|
||||
})
|
||||
|
||||
const requirements = computed(() => {
|
||||
const a = activity.value || {}
|
||||
const el = a.eligibility || {}
|
||||
const list: string[] = ['年满 18 周岁的成年单身人士']
|
||||
if (el.requiresIdentityVerified) list.push('需完成实名认证并通过资料审核')
|
||||
if (el.requiresLocalEligibility) list.push('需符合丹阳本地资格(户籍 / 工作 / 居住)')
|
||||
if (a.minAge || a.maxAge) {
|
||||
list.push(`年龄在 ${a.minAge ?? '不限'}-${a.maxAge ?? '不限'} 周岁之间`)
|
||||
}
|
||||
if (a.needReview !== false) list.push('报名提交后需经工作人员审核通过')
|
||||
return list
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
activity.value = null
|
||||
return
|
||||
}
|
||||
activity.value = await activityService.getActivityDetail(activityId.value)
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
if (!activityId.value) return
|
||||
router.push({ path: '/activity/register', query: { id: activityId.value } })
|
||||
}
|
||||
function goMyActivities() {
|
||||
router.push('/my/activities')
|
||||
}
|
||||
function goCheckin() {
|
||||
router.push({ path: '/activity/checkin', query: { id: activityId.value } })
|
||||
}
|
||||
function goFeedback() {
|
||||
router.push({ path: '/activity/feedback', query: { id: activityId.value } })
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (cancelling.value) return
|
||||
try {
|
||||
await showConfirmDialog({ title: '取消报名', message: '确定要取消本次活动报名吗?' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
cancelling.value = true
|
||||
try {
|
||||
await activityService.cancelRegistration(activityId.value)
|
||||
appStore.toastSuccess('已取消报名')
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '取消失败')
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ad {
|
||||
padding: 0 16px 120px;
|
||||
}
|
||||
.ad__hero {
|
||||
background: var(--mini-brand-gradient);
|
||||
margin: 0 -16px;
|
||||
padding: 20px 16px 22px;
|
||||
color: #fff;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
}
|
||||
.ad__badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ad__title {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ad__slogan {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ad__block {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ad__card {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
}
|
||||
.ad__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ad__value {
|
||||
color: var(--mini-text);
|
||||
}
|
||||
.ad__feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.ad__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(244, 0, 90, 0.08);
|
||||
color: var(--mini-tabbar-active);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex: none;
|
||||
}
|
||||
.ad__feature-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ad__feature-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.ad__feature-desc {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ad__body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--mini-text);
|
||||
}
|
||||
.ad__rule {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mini-text);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ad__rule-index {
|
||||
color: var(--mini-tabbar-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ad__notice {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ad__action {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: var(--mini-surface);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.ad__action-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ad__action-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ad__action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<PageLayout title="活动反馈">
|
||||
<UiLoading v-if="loading" text="正在加载活动…" />
|
||||
<UiErrorState v-else-if="error" @retry="load" />
|
||||
|
||||
<!-- 已提交 -->
|
||||
<div v-else-if="submitted" class="fb__done">
|
||||
<div class="fb__done-icon">✓</div>
|
||||
<div class="fb__done-title">反馈已提交</div>
|
||||
<div class="fb__done-hint">感谢您的参与!</div>
|
||||
<div class="fb__done-actions">
|
||||
<van-button type="primary" round size="small" @click="goMatches">查看活动缘分</van-button>
|
||||
<van-button plain round size="small" @click="router.replace('/home')">返回首页</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fb">
|
||||
<div class="fb__header">
|
||||
<div class="fb__title">活动反馈</div>
|
||||
<div class="fb__name">{{ activity?.title || '本次公益活动' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">整体体验</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
v-for="o in overallOptions"
|
||||
:key="o.code"
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': overallExperience === o.code }"
|
||||
@click="overallExperience = o.code"
|
||||
>
|
||||
{{ o.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">是否愿意参加下一次活动</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
v-for="o in willingOptions"
|
||||
:key="o.code"
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': willingNextTime === o.code }"
|
||||
@click="willingNextTime = o.code"
|
||||
>
|
||||
{{ o.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">是否在活动中认识了想进一步了解的人</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': metSomeone === true }"
|
||||
@click="metSomeone = true"
|
||||
>
|
||||
是
|
||||
</span>
|
||||
<span
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': metSomeone === false }"
|
||||
@click="metSomeone = false"
|
||||
>
|
||||
否
|
||||
</span>
|
||||
</div>
|
||||
<div class="fb__hint">进一步了解意向将随反馈一并提交,双方互相表达后即可查看缘分。</div>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="fb__submit"
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="submitting"
|
||||
:disabled="!overallExperience || !willingNextTime"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交反馈
|
||||
</van-button>
|
||||
<van-button class="fb__skip" plain block @click="router.back()">跳过反馈</van-button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const activity = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const overallExperience = ref('')
|
||||
const willingNextTime = ref('')
|
||||
const metSomeone = ref<boolean | null>(null)
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const overallOptions = [
|
||||
{ code: 'very_satisfied', label: '😊 非常满意' },
|
||||
{ code: 'satisfied', label: '🙂 满意' },
|
||||
{ code: 'normal', label: '😐 一般' },
|
||||
{ code: 'dissatisfied', label: '😕 不满意' },
|
||||
]
|
||||
const willingOptions = [
|
||||
{ code: 'willing', label: '💚 愿意' },
|
||||
{ code: 'maybe', label: '🤔 看情况' },
|
||||
{ code: 'not_now', label: '😶 暂不参加' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
activity.value = null
|
||||
return
|
||||
}
|
||||
activity.value = await activityService.getActivityDetail(activityId.value)
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goMatches() {
|
||||
if (activityId.value) router.push({ path: '/activity/matches', query: { id: activityId.value } })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!overallExperience.value || !willingNextTime.value) {
|
||||
appStore.toast('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const result: any = await activityService.submitFeedback(activityId.value, {
|
||||
overallExperience: overallExperience.value,
|
||||
willingNextTime: willingNextTime.value,
|
||||
metSomeoneInteresting: metSomeone.value,
|
||||
interestTargetUserIds: [],
|
||||
})
|
||||
submitted.value = true
|
||||
const mutuals: any[] = result?.newMutuals || []
|
||||
if (mutuals.length) {
|
||||
await showConfirmDialog({
|
||||
title: '双向匹配成功!',
|
||||
message: `您与 ${mutuals.length} 位朋友互相表达了进一步了解意愿,可前往活动缘分查看。`,
|
||||
confirmButtonText: '去查看',
|
||||
showCancelButton: false,
|
||||
}).catch(() => undefined)
|
||||
goMatches()
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fb {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
.fb__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fb__title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.fb__name {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fb__section {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fb__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.fb__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.fb__option {
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--mini-bg);
|
||||
color: var(--mini-text);
|
||||
border: 1px solid var(--mini-border);
|
||||
}
|
||||
.fb__option--active {
|
||||
color: var(--mini-tabbar-active);
|
||||
border-color: var(--mini-tabbar-active);
|
||||
background: rgba(244, 0, 90, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fb__hint {
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fb__submit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.fb__skip {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fb__done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 16px;
|
||||
}
|
||||
.fb__done-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--mini-success);
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.fb__done-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.fb__done-hint {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fb__done-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<PageLayout title="活动缘分">
|
||||
<div class="am">
|
||||
<div v-if="activityTitle" class="am__activity">{{ activityTitle }}</div>
|
||||
|
||||
<UiErrorState v-if="error" @retry="load" />
|
||||
<UiLoading v-else-if="loading" />
|
||||
<template v-else>
|
||||
<div v-if="matches.length">
|
||||
<div
|
||||
v-for="m in matches"
|
||||
:key="m.targetUserId"
|
||||
class="am__card"
|
||||
@click="goResult(m)"
|
||||
>
|
||||
<UiAvatar :src="avatarOf(m)" :name="m.name" :size="52" />
|
||||
<div class="am__info">
|
||||
<div class="am__name">{{ m.name || '新朋友' }}</div>
|
||||
<div class="am__meta">
|
||||
<span v-if="m.age">{{ m.age }}岁</span>
|
||||
<span v-if="m.locationName">{{ m.locationName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am__cta">
|
||||
<span class="am__cta-text">查看缘分</span>
|
||||
<van-icon name="arrow" color="#9aa0b5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiEmpty v-else description="暂无双向匹配">
|
||||
<van-button type="primary" round size="small" @click="router.back()">返回</van-button>
|
||||
</UiEmpty>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiAvatar from '@/components/UiAvatar.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiEmpty from '@/components/UiEmpty.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const matches = ref<any[]>([])
|
||||
const activityTitle = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
function avatarOf(m: any): string {
|
||||
const raw = m.avatarUrl || m.fileKey
|
||||
return typeof raw === 'string' && raw.includes('://') ? raw : ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
matches.value = []
|
||||
return
|
||||
}
|
||||
const [result, detail]: any[] = await Promise.all([
|
||||
activityService.getMutualMatches(activityId.value),
|
||||
activityService.getActivityDetail(activityId.value).catch(() => null),
|
||||
])
|
||||
matches.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
|
||||
activityTitle.value = detail?.title || ''
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动缘分失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goResult(m: any) {
|
||||
if (!m?.targetUserId) return
|
||||
router.push({
|
||||
path: '/activity/mutual/result',
|
||||
query: { id: activityId.value, partnerId: m.targetUserId },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.am {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.am__activity {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.am__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
}
|
||||
.am__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.am__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.am__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.am__cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-tabbar-active);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||