chore: establish V1.1 recovery baseline

This commit is contained in:
2026-09-09 22:05:00 +08:00
commit b46b48eb33
947 changed files with 155471 additions and 0 deletions
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
+13
View File
@@ -0,0 +1,13 @@
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
RUN apk add --no-cache maven
COPY pom.xml .
COPY maven-settings.xml /root/.m2/settings.xml
COPY src ./src
RUN mvn compile -B -s /root/.m2/settings.xml && mvn package -DskipTests -Dmaven.test.skip=true -B -s /root/.m2/settings.xml
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=${SPRING_PROFILES_ACTIVE:docker}"]
+77
View File
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.danyang.match</groupId>
<artifactId>temp-dependencies</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>9.22.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>central</id>
<url>https://repo1.maven.org/maven2</url>
</repository>
</repositories>
</project>
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import os
import urllib.request
import ssl
import xml.etree.ElementTree as ET
ssl._create_default_https_context = ssl._create_unverified_context
REPO_URL = "https://repo1.maven.org/maven2"
LOCAL_REPO = "E:/develop/公益婚恋服务平台/backend/local-maven"
# Dependencies to download (groupId:artifactId:version)
DEPS = [
("org.springframework.boot", "spring-boot-starter-web", "3.2.5"),
("org.springframework.boot", "spring-boot-starter-validation", "3.2.5"),
("org.springframework.boot", "spring-boot-starter-security", "3.2.5"),
("org.springframework.boot", "spring-boot-starter-data-redis", "3.2.5"),
("org.springframework.boot", "spring-boot-starter-websocket", "3.2.5"),
("org.postgresql", "postgresql", "42.7.1"),
("com.baomidou", "mybatis-plus-spring-boot3-starter", "3.5.5"),
("org.flywaydb", "flyway-core", "9.22.3"),
("io.jsonwebtoken", "jjwt-api", "0.12.5"),
("io.jsonwebtoken", "jjwt-impl", "0.12.5"),
("io.jsonwebtoken", "jjwt-jackson", "0.12.5"),
("org.projectlombok", "lombok", "1.18.30"),
("org.mapstruct", "mapstruct", "1.5.5.Final"),
("org.mapstruct", "mapstruct-processor", "1.5.5.Final"),
("org.springdoc", "springdoc-openapi-starter-webmvc-ui", "2.3.0"),
]
def download_artifact(group_id, artifact_id, version, classifier=""):
rel_path = group_id.replace(".", "/")
filename = f"{artifact_id}-{version}"
if classifier:
filename += f"-{classifier}"
filename += ".jar"
url = f"{REPO_URL}/{rel_path}/{artifact_id}/{version}/{filename}"
local_path = f"{LOCAL_REPO}/{rel_path}/{artifact_id}/{version}/{filename}"
os.makedirs(os.path.dirname(local_path), exist_ok=True)
try:
print(f"Downloading {filename}...")
urllib.request.urlretrieve(url, local_path)
print(f" Saved to {local_path}")
return True
except Exception as e:
print(f" Failed: {e}")
return False
if __name__ == "__main__":
print("Downloading Maven dependencies...")
for gid, aid, ver in DEPS:
download_artifact(gid, aid, ver)
print("Done!")
+16
View File
@@ -0,0 +1,16 @@
-- Fix matches table
ALTER TABLE matches DROP CONSTRAINT IF EXISTS matches_user_a_id_fkey;
ALTER TABLE matches DROP CONSTRAINT IF EXISTS matches_user_b_id_fkey;
ALTER TABLE matches ALTER COLUMN user_a_id TYPE VARCHAR(36) USING user_a_id::text;
ALTER TABLE matches ALTER COLUMN user_b_id TYPE VARCHAR(36) USING user_b_id::text;
ALTER TABLE matches ADD CONSTRAINT matches_user_a_id_fkey FOREIGN KEY (user_a_id) REFERENCES users(id);
ALTER TABLE matches ADD CONSTRAINT matches_user_b_id_fkey FOREIGN KEY (user_b_id) REFERENCES users(id);
-- Fix user_relations table
ALTER TABLE user_relations DROP CONSTRAINT IF EXISTS user_relations_user_id_fkey;
ALTER TABLE user_relations DROP CONSTRAINT IF EXISTS user_relations_target_user_id_fkey;
ALTER TABLE user_relations ALTER COLUMN user_id TYPE VARCHAR(36) USING user_id::text;
ALTER TABLE user_relations ALTER COLUMN target_user_id TYPE VARCHAR(36) USING target_user_id::text;
ALTER TABLE user_relations ADD CONSTRAINT user_relations_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ALTER TABLE user_relations ADD CONSTRAINT user_relations_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES users(id);
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors>
<mirror>
<id>central</id>
<mirrorOf>central</mirrorOf>
<name>Maven Central</name>
<url>https://repo1.maven.org/maven2</url>
</mirror>
</mirrors>
</settings>
BIN
View File
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
#
# Copyright 2002-2019 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
##############################################################################
#
# Maven Start Up Batch script
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
while [ -h "$PRG" ]; do
ls=$(ls -ld "$PRG")
link=$(expr "$ls" : '.*-> \(.*\)$')
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=$(dirname "$PRG")/"$link"
fi
done
APP_HOME=$(cd "$(dirname "$PRG")" > /dev/null; pwd)
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support.
cygwin=false
msys=false
darwin=false
nonstop=false
case "$(uname)" in
CYGWIN*) cygwin=true ;;
Darwin*) darwin=true ;;
MSYS* | MINGW*) msys=true ;;
NONSTOP*) nonstop=true ;;
esac
# For Cygwin, ensure paths are in UNIX format before anything is touched.
if $cygwin; then
[ -n "$MAVEN_HOME" ] && MAVEN_HOME=$(cygpath --unix "$MAVEN_HOME")
[ -n "$CLASSPATH" ] && CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
fi
# Attempt to find MAVEN_HOME
if [ -z "$MAVEN_HOME" ]; then
MAVEN_HOME=/usr/local/maven
fi
if [ -e "$MAVEN_HOME/bin/mvn" ]; then
exec "$MAVEN_HOME/bin/mvn" "$@"
elif [ -x /usr/bin/mvn ]; then
exec /usr/bin/mvn "$@"
elif [ -x /usr/local/bin/mvn ]; then
exec /usr/local/bin/mvn "$@"
else
die "ERROR: Could not find Maven installation. Please install Maven or set JAVA_HOME and MAVEN_HOME."
fi
+194
View File
@@ -0,0 +1,194 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jjwt.version>0.12.5</jjwt.version>
<flyway.version>9.22.3</flyway.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<lombok.version>1.18.34</lombok.version>
<bcprov.version>1.77</bcprov.version>
<resilience4j.version>2.1.0</resilience4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>${flyway.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bcprov.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-aws</artifactId>
<version>2.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.9</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>${flyway.version}</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,69 @@
package com.danyang.match.platform.shared.dto;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 应用配置控制器
* 提供小程序启动时所需的全局配置信息
*/
@RestController
@RequestMapping("/app")
public class AppController {
/**
* 获取应用配置
* GET /api/v1/app/config
*/
@GetMapping("/config")
public ApiResponse<Map<String, Object>> getConfig() {
Map<String, Object> config = new HashMap<>();
// 平台基本信息
config.put("platformName", "丹阳公益婚恋服务平台");
config.put("version", "1.0.0");
config.put("buildTime", "2026-09-01");
// 功能开关
Map<String, Boolean> features = new HashMap<>();
features.put("activityEnabled", true);
features.put("matchmakerEnabled", true);
features.put("imageChatEnabled", true);
features.put("appealEnabled", true);
features.put("checkinEnabled", true);
config.put("features", features);
// 业务限制参数
Map<String, Object> businessLimits = new HashMap<>();
businessLimits.put("dailyRecommendationLimit", 6);
businessLimits.put("maxProfilePhotos", 6);
businessLimits.put("maxInterestTags", 8);
businessLimits.put("selfIntroMaxLength", 500);
businessLimits.put("recommendationCooldownDays", 180);
businessLimits.put("matchScoreThreshold", 60);
config.put("businessLimits", businessLimits);
// 审核相关
Map<String, Object> reviewConfig = new HashMap<>();
reviewConfig.put("autoApproveAfterDays", 7);
reviewConfig.put("maxResubmitCount", 3);
config.put("reviewConfig", reviewConfig);
return ApiResponse.ok(config);
}
/**
* 健康检查
* GET /api/v1/app/health
*/
@GetMapping("/health")
public ApiResponse<Map<String, String>> getHealth() {
Map<String, String> health = new HashMap<>();
health.put("status", "healthy");
health.put("service", "danyang-match-platform");
health.put("version", "1.0.0");
return ApiResponse.ok(health);
}
}
@@ -0,0 +1,12 @@
package com.danyang.match.platform;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DanyangMatchPlatformApplication {
public static void main(String[] args) {
SpringApplication.run(DanyangMatchPlatformApplication.class, args);
}
}
@@ -0,0 +1,104 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.AccountLifecycleService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 账号生命周期控制器(用户端)
*/
@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {
private final AccountLifecycleService accountService;
private final JwtTokenService jwtTokenService;
/**
* 退出婚恋服务(暂停推荐)
* POST /api/v1/account/withdraw
*/
@PostMapping("/withdraw")
public ApiResponse<Map<String, Object>> withdrawService(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(accountService.withdrawDatingService(userId, userId));
}
/**
* 重新开启婚恋服务
* POST /api/v1/account/resume
*/
@PostMapping("/resume")
public ApiResponse<Map<String, Object>> resumeService(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(accountService.resumeDatingService(userId));
}
/**
* 注销前检查
* GET /api/v1/account/can-withdraw
*/
@GetMapping("/can-withdraw")
public ApiResponse<Map<String, Object>> canWithdraw(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(accountService.checkCanWithdraw(userId));
}
/**
* 执行注销
* POST /api/v1/account/close
* Body: { "reason": "..." }
*/
@PostMapping("/close")
public ApiResponse<Map<String, Object>> closeAccount(
@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String reason = body.getOrDefault("reason", "");
return ApiResponse.ok(accountService.closeAccount(userId, userId, reason));
}
/**
* 获取隐私设置
* GET /api/v1/account/privacy-settings
*/
@GetMapping("/privacy-settings")
public ApiResponse<Map<String, Object>> getPrivacySettings(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(accountService.getPrivacySettings(userId));
}
/**
* 更新隐私设置
* PUT /api/v1/account/privacy-settings
*/
@PutMapping("/privacy-settings")
public ApiResponse<Map<String, Object>> updatePrivacySettings(
@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, Object> settings) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(accountService.updatePrivacySettings(userId, settings));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,54 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.AccountLifecycleService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 账号生命周期管理控制器(Operator端)
*/
@RestController
@RequestMapping("/operator/account")
@RequiredArgsConstructor
public class AccountOperatorController {
private final AccountLifecycleService accountService;
private final JwtTokenService jwtTokenService;
/**
* 强制注销用户账号(管理端)
* POST /api/v1/operator/account/{userId}/close
*/
@PostMapping("/{userId}/close")
public ApiResponse<Map<String, Object>> closeAccount(
@RequestHeader("Authorization") String authHeader,
@PathVariable String userId,
@RequestBody Map<String, String> body) {
String operatorId = extractOperatorId(authHeader);
String reason = body.getOrDefault("reason", "管理员强制注销");
return ApiResponse.ok(accountService.closeAccount(userId, operatorId, reason));
}
/**
* 检查用户是否可以注销
* GET /api/v1/operator/account/{userId}/can-withdraw
*/
@GetMapping("/{userId}/can-withdraw")
public ApiResponse<Map<String, Object>> canWithdraw(
@RequestHeader("Authorization") String authHeader,
@PathVariable String userId) {
return ApiResponse.ok(accountService.checkCanWithdraw(userId));
}
private String extractOperatorId(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new RuntimeException("未认证");
}
String token = authHeader.substring(7);
return jwtTokenService.getUserIdFromToken(token);
}
}
@@ -0,0 +1,23 @@
package com.danyang.match.platform.account.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 同意记录
* 记录用户对隐私政策的同意状态
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("consent_records")
public class ConsentRecord extends BaseEntity {
private String userId;
private String consentType; // privacy_policy, user_agreement, marketing_consent
private Boolean agreed;
private String ipAddress;
private String userAgent;
private java.time.Instant agreedAt;
}
@@ -0,0 +1,17 @@
package com.danyang.match.platform.account.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.account.entity.ConsentRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
@Mapper
public interface ConsentRecordMapper extends BaseMapper<ConsentRecord> {
@Select("SELECT * FROM consent_records WHERE user_id = #{userId} AND consent_type = #{type} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
Optional<ConsentRecord> findByUserAndType(@Param("userId") String userId,
@Param("type") String type);
}
@@ -0,0 +1,464 @@
package com.danyang.match.platform.account.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.account.entity.ConsentRecord;
import com.danyang.match.platform.account.mapper.ConsentRecordMapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
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 com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
/**
* 账号生命周期服务
*
* 核心 Invariant:
* - 注销必须在线便捷,不设置障碍(不需要打电话/邮寄身份证)
* - 注销后资料停止展示,显示"用户已注销"
* - 注销前检查是否有进行中的业务(红娘服务/活动报名/Match进行中)
* - 敏感数据脱敏后保留必要审计记录
*/
@Service
@RequiredArgsConstructor
public class AccountLifecycleService {
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final ActivityRegistrationMapper registrationMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ConversationMapper conversationMapper;
private final ReportMapper reportMapper;
private final RiskActionMapper riskActionMapper;
private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService;
private final ConsentRecordMapper consentRecordMapper;
private final ServiceAppointmentMapper serviceAppointmentMapper;
private final CounselingServiceMapper counselingServiceMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final LocalEligibilityMapper localEligibilityMapper;
// ==================== 退出婚恋服务(暂停推荐) ====================
/**
* 退出婚恋服务:保留账号,关闭所有婚恋相关功能
* 用户可随时重新开启
*/
@Transactional
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 检查是否有进行中的Match(不能直接退出)
// 仅当没有 active match 时允许退出
// (Match进行中需双方解除后才能退出)
// 停用推荐和资料展示
user.setRecommendationEnabled(false);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_PAUSED);
userMapper.updateById(user);
// 通知
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
"退出婚恋服务",
"您的婚恋服务已暂停,资料将不再展示给他人。您可随时重新开启。",
"account", null,
"withdraw:" + userId + ":" + System.currentTimeMillis());
saveAuditLog(operatorId, "WITHDRAW_DATING_SERVICE", "user", userId, null, null);
log.info("用户退出婚恋服务: userId={}", userId);
Map<String, Object> result = new HashMap<>();
result.put("withdrawn", true);
result.put("recommendationEnabled", false);
result.put("relationshipStatus", Constants.RELATIONSHIP_STATUS_PAUSED);
return result;
}
// ==================== 重新开启服务 ====================
/**
* 重新开启婚恋服务
*/
@Transactional
public Map<String, Object> resumeDatingService(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
user.setRecommendationEnabled(true);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
userMapper.updateById(user);
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
"重新开启婚恋服务",
"您已重新开启婚恋服务,资料将重新进入推荐池。",
"account", null,
"resume:" + userId + ":" + System.currentTimeMillis());
Map<String, Object> result = new HashMap<>();
result.put("resumed", true);
result.put("recommendationEnabled", true);
result.put("relationshipStatus", Constants.RELATIONSHIP_STATUS_SEARCHING);
return result;
}
// ==================== 注销账号 ====================
/**
* 注销前检查:是否有进行中的业务不能注销
*/
public Map<String, Object> checkCanWithdraw(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
Map<String, Object> blockers = new HashMap<>();
// 检查是否有进行中的红娘服务
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
if (activeMatchmakerServices > 0) {
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
}
// 检查是否有进行中的活动报名(pending/approved 且未取消)
long activeRegistrations = registrationMapper.selectCount(
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getUserId, userId)
.in(ActivityRegistration::getStatus,
Constants.REGISTRATION_STATUS_PENDING,
Constants.REGISTRATION_STATUS_APPROVED));
if (activeRegistrations > 0) {
blockers.put("activityRegistration", "您有进行中的活动报名,请先完成或取消");
}
// V1.1: 检查是否有待确认的服务预约
long activeAppointments = serviceAppointmentMapper.selectCount(
new LambdaQueryWrapper<ServiceAppointment>()
.eq(ServiceAppointment::getUserId, userId)
.in(ServiceAppointment::getStatus,
Constants.APPOINTMENT_STATUS_SUBMITTED,
Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION));
if (activeAppointments > 0) {
blockers.put("serviceAppointment", "您有已提交但未确认的服务预约,请先完成或取消");
}
// V1.1: 检查是否有进行中的咨询
long activeCounselings = counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingService>()
.eq(CounselingService::getUserId, userId)
.in(CounselingService::getStatus,
Constants.COUNSELING_STATUS_PENDING,
Constants.COUNSELING_STATUS_ASSIGNED,
Constants.COUNSELING_STATUS_SCHEDULED,
Constants.COUNSELING_STATUS_IN_PROGRESS));
if (activeCounselings > 0) {
blockers.put("counselingService", "您有进行中的心理咨询服务,请先完成或取消");
}
// V1.1: 检查是否有进行中的专项服务项目
long activePrograms = serviceProgramMemberMapper.selectCount(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getUserId, userId)
.eq(ServiceProgramMember::getStatus, "approved"));
if (activePrograms > 0) {
blockers.put("serviceProgram", "您有进行中的专项服务项目,请先退出或完成");
}
// V1.1: 检查本地资格核验状态
Optional<LocalEligibility> localElig = localEligibilityMapper.findByUserId(userId);
if (localElig.isPresent() &&
(Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) ||
Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) {
blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待");
}
// 检查是否有 pending 的成功牵手请求
// 简化处理
Map<String, Object> result = new HashMap<>();
result.put("canWithdraw", blockers.isEmpty());
result.put("blockers", blockers);
return result;
}
/**
* 执行账号注销
* 核心:注销后立即停止所有业务,敏感数据脱敏
*/
@Transactional
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 1. 检查是否可以注销
Map<String, Object> checkResult = checkCanWithdraw(userId);
if (!(boolean) checkResult.get("canWithdraw")) {
throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers"));
}
// 2. 停止所有进行中的业务
// 2a. 停止推荐
user.setRecommendationEnabled(false);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SERVICE_EXITED);
user.setAccountStatus(Constants.ACCOUNT_STATUS_CLOSED);
userMapper.updateById(user);
// 2b. 通知所有活跃 Match 对方
notifyActiveMatches(user.getId(), "account_closed");
// 2c. 通知红娘(如果有)
notifyMatchmaker(user.getId());
// 2d. 取消所有未开始的活动报名
cancelPendingActivityRegistrations(userId);
// 2e. V1.1: 取消服务预约
cancelServiceAppointments(userId);
// 2f. V1.1: 取消咨询记录
cancelCounselingServices(userId);
// 2g. V1.1: 退出专项服务项目
exitServicePrograms(userId);
// 2h. V1.1: 失效本地资格
invalidateLocalEligibility(userId);
// 3. 敏感数据脱敏(保留审计所需的最小信息)
sanitizeUserData(user.getId());
// 4. 记录退出时间
Instant now = Instant.now();
user.setUpdatedAt(now);
userMapper.updateById(user);
// 5. 审计
String afterJson = reason != null
? "{\"reason\":" + escapeJsonString(reason) + "}"
: "{\"reason\":null}";
saveAuditLog(operatorId, "CLOSE_ACCOUNT", "user", userId, null, afterJson);
log.info("用户注销账号: userId={}, reason={}", userId, reason);
// 6. 通知用户
notificationService.send(userId, Constants.NOTIFICATION_TYPE_ACCOUNT,
"账号已注销",
"您的账号已成功注销。根据平台规则,部分必要信息将被脱敏保留以符合法律要求。",
"account", userId,
"account_closed:" + userId + ":" + System.currentTimeMillis());
Map<String, Object> result = new HashMap<>();
result.put("accountClosed", true);
result.put("closedAt", now);
result.put("dataRetentionNote", "必要信息已脱敏保留,其他数据已删除");
return result;
}
/**
* 撤回同意
*/
@Transactional
public Map<String, Object> withdrawConsent(String userId, String consentType) {
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
if (record == null) {
throw new RuntimeException("未找到该同意记录");
}
record.setAgreed(false);
consentRecordMapper.updateById(record);
saveAuditLog(userId, "WITHDRAW_CONSENT", "consent", record.getId(), null, consentType);
log.info("用户撤回同意: userId={}, type={}", userId, consentType);
Map<String, Object> result = new HashMap<>();
result.put("withdrawn", true);
result.put("consentType", consentType);
return result;
}
// ==================== 隐私设置 ====================
/**
* 获取隐私设置
*/
public Map<String, Object> getPrivacySettings(String userId) {
// TODO: Phase 21 实现 user_privacy_settings 查询
Map<String, Object> settings = new HashMap<>();
settings.put("visibleInRecommendations", true);
settings.put("allowActivityInvitation", true);
settings.put("showLastOnline", false);
return settings;
}
/**
* 更新隐私设置
*/
@Transactional
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
// TODO: Phase 21 实现 user_privacy_settings 更新
Map<String, Object> result = new HashMap<>();
result.put("updated", true);
return result;
}
// ==================== 私有辅助方法 ====================
/**
* 通知所有活跃 Match 对方账号已关闭
*/
private void notifyActiveMatches(String userId, String reason) {
// TODO: 查询该用户的所有 active match,通知对方
log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason);
}
/**
* 通知红娘服务已终止
*/
private void notifyMatchmaker(String userId) {
// TODO: 查询该用户的红娘服务,通知相关红娘
log.info("通知红娘服务终止: userId={}", userId);
}
/**
* 取消所有未开始的活动报名
*/
private void cancelPendingActivityRegistrations(String userId) {
// TODO: 查询 activity_registrations 中 pending/approved 且活动未开始的记录,将其状态设为 cancelled
log.info("取消用户活动报名: userId={}", userId);
}
/**
* 敏感数据脱敏
* 核心:身份证/手机号脱敏,保留审计所需的最小信息
*/
private void sanitizeUserData(String userId) {
// 脱敏手机号
User user = userMapper.selectById(userId);
if (user != null && user.getPhone() != null) {
// 保留后4位,其余替换为 *
String phone = user.getPhone();
if (phone.length() >= 4) {
user.setPhone(phone.substring(0, phone.length() - 4) + "****");
} else {
user.setPhone("****");
}
userMapper.updateById(user);
}
// TODO: 同步更新 user_identities 中的身份证号码
log.info("敏感数据脱敏: userId={}", userId);
}
private String escapeJsonString(String s) {
if (s == null) return "null";
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\"";
}
private void cancelServiceAppointments(String userId) {
List<ServiceAppointment> appointments = serviceAppointmentMapper.findByUserId(userId);
int cancelled = 0;
for (ServiceAppointment appt : appointments) {
if (Constants.APPOINTMENT_STATUS_SUBMITTED.equals(appt.getStatus()) ||
Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION.equals(appt.getStatus())) {
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
appt.setUpdatedAt(Instant.now());
serviceAppointmentMapper.updateById(appt);
cancelled++;
}
}
log.info("取消用户服务预约: userId={}, cancelled={}", userId, cancelled);
}
private void cancelCounselingServices(String userId) {
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
int cancelled = 0;
for (CounselingService cs : services) {
if (Constants.COUNSELING_STATUS_PENDING.equals(cs.getStatus()) ||
Constants.COUNSELING_STATUS_ASSIGNED.equals(cs.getStatus()) ||
Constants.COUNSELING_STATUS_SCHEDULED.equals(cs.getStatus()) ||
Constants.COUNSELING_STATUS_IN_PROGRESS.equals(cs.getStatus())) {
cs.setStatus(Constants.COUNSELING_STATUS_CANCELLED);
cs.setUpdatedAt(Instant.now());
counselingServiceMapper.updateById(cs);
cancelled++;
}
}
log.info("取消用户咨询服务: userId={}, cancelled={}", userId, cancelled);
}
private void exitServicePrograms(String userId) {
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getUserId, userId)
.eq(ServiceProgramMember::getStatus, "approved"));
int exited = 0;
for (ServiceProgramMember member : members) {
member.setStatus("withdrawn");
serviceProgramMemberMapper.updateById(member);
exited++;
}
log.info("用户退出服务项目: userId={}, exited={}", userId, exited);
}
private void invalidateLocalEligibility(String userId) {
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
if (elig.isPresent()) {
LocalEligibility le = elig.get();
le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED);
localEligibilityMapper.updateById(le);
log.info("失效用户本地资格: userId={}", userId);
}
}
private void anonymizeServiceHistory(String userId) {
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
for (CounselingService cs : services) {
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
cs.setUserRequestSummary("***已脱敏***");
counselingServiceMapper.updateById(cs);
}
}
log.info("服务历史记录脱敏完成: userId={}", userId);
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,136 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.activity.service.ActivityService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 用户端活动控制器
*/
@RestController
@RequestMapping("/activities")
@RequiredArgsConstructor
public class ActivityController {
private final ActivityService activityService;
private final JwtTokenService jwtTokenService;
@GetMapping
public ApiResponse<?> getActivities(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(activityService.getActivities(userId));
}
@GetMapping("/upcoming")
public ApiResponse<?> getUpcoming(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(activityService.getUpcomingActivities(userId));
}
@GetMapping("/{id}")
public ApiResponse<Map<String, Object>> getActivityDetail(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
Map<String, Object> detail = activityService.getActivityDetail(id, userId);
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
}
return ApiResponse.ok(detail);
}
@PostMapping("/{id}/register")
public ApiResponse<Map<String, Object>> register(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String emergencyContactName = body.get("emergencyContactName");
String emergencyContactPhone = body.get("emergencyContactPhone");
return ApiResponse.ok(activityService.registerActivity(userId, id, emergencyContactName, emergencyContactPhone));
}
@DeleteMapping("/{id}/register")
public ApiResponse<Void> cancelRegistration(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
activityService.cancelRegistration(userId, id);
return ApiResponse.ok();
}
@GetMapping("/my")
public ApiResponse<List<Map<String, Object>>> getMyActivities(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(activityService.getMyActivities(userId));
}
// ---- V1.1 Phase 14: 多类型活动扩展 ----
@GetMapping("/types/{activityType}")
public ApiResponse<?> getActivitiesByType(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityType) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(activityService.getActivitiesByType(userId, activityType));
}
@GetMapping("/{id}/organizations")
public ApiResponse<List<Map<String, Object>>> getActivityOrganizations(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
return ApiResponse.ok(activityService.getActivityOrganizations(id));
}
@GetMapping("/{id}/capacity-groups")
public ApiResponse<List<Map<String, Object>>> getActivityCapacityGroups(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
}
@PostMapping("/operator")
public ApiResponse<?> createActivityOperator(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody Map<String, Object> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
}
@PostMapping("/operator/{id}/organizations")
public ApiResponse<?> addActivityOrganization(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId));
}
@PostMapping("/operator/{id}/capacity-groups")
public ApiResponse<?> addCapacityGroup(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, Object> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId));
}
private String extractUserId(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return null;
}
String token = authHeader.substring(7);
if (!jwtTokenService.validateToken(token)) {
return null;
}
return jwtTokenService.getUserIdFromToken(token);
}
}
@@ -0,0 +1,127 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.activity.dto.ActivityFeedbackDTO;
import com.danyang.match.platform.activity.dto.ActivityMatchItemDTO;
import com.danyang.match.platform.activity.service.ActivityInterestService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 活动反馈与互选控制器
*/
@RestController
@RequestMapping("/activities")
@RequiredArgsConstructor
public class ActivityInterestController {
private final ActivityInterestService interestService;
private final RelationshipService relationshipService;
private final JwtTokenService jwtTokenService;
/**
* 提交活动反馈 + 表达意向
* POST /api/v1/activities/{id}/feedback
*/
@PostMapping("/{id}/feedback")
public ApiResponse<Map<String, Object>> submitFeedback(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id,
@RequestBody ActivityFeedbackDTO body) {
String userId = extractUserId(authHeader);
Map<String, Object> result = interestService.submitFeedback(
userId,
id,
body.getOverallExperience(),
body.getWillingNextTime(),
body.getMetSomeoneInteresting(),
body.getInterestTargetUserIds()
);
return ApiResponse.ok(result);
}
/**
* 获取活动中的互选结果
* GET /api/v1/activities/{id}/interests/mutual
*/
@GetMapping("/{id}/interests/mutual")
public ApiResponse<List<ActivityMatchItemDTO>> getMutualMatches(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
}
/**
* 查询与某人在活动中的互选状态
* GET /api/v1/activities/{id}/interests/mutual/{partnerId}
*/
@GetMapping("/{id}/interests/mutual/{partnerId}")
public ApiResponse<Map<String, Object>> checkMutualInterest(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id,
@PathVariable String partnerId) {
String userId = extractUserId(authHeader);
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
Map<String, Object> result = new HashMap<>();
result.put("hasMutual", mutual);
result.put("canConfirm", mutual);
return ApiResponse.ok(result);
}
/**
* 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation
*/
@PostMapping("/{id}/interests/confirm")
public ApiResponse<Map<String, Object>> confirmInterest(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String partnerId = body.get("partnerId");
if (partnerId == null || partnerId.isBlank()) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空");
}
// 验证互选状态
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选");
}
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
}
/**
* 获取活动互选统计(管理端)
* GET /api/v1/operator/activities/{id}/interest-stats
*/
@GetMapping("/operator/{id}/interest-stats")
public ApiResponse<Map<String, Object>> getActivityInterestStats(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
return ApiResponse.ok(interestService.getActivityInterestStats(id));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,161 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.activity.service.ActivityService;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
import com.danyang.match.platform.activity.entity.Activity;
import java.util.Map;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.constant.Constants;
/**
* 后台活动管理控制器
*/
@RestController
@RequestMapping("/operator/activities")
@RequiredArgsConstructor
public class ActivityOperatorController {
private final ActivityService activityService;
private final ActivityMapper activityMapper;
private final JwtTokenService jwtTokenService;
/**
* 创建活动
* POST /api/v1/operator/activities
*/
@PostMapping
@Transactional
public ApiResponse<Activity> createActivity(
@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, Object> body) {
String operatorId = extractOperatorId(authHeader);
Activity activity = new Activity();
activity.setTitle((String) body.get("title"));
activity.setDescription((String) body.get("description"));
activity.setLocationName((String) body.get("locationName"));
activity.setLocationAddress((String) body.get("locationAddress"));
activity.setOrganizerOrgId(operatorId);
activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50));
activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25));
activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25));
activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue());
activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue());
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
activity.setCreatedBy(operatorId);
// 默认时间:明天开始,持续3小时
java.time.LocalDateTime now = java.time.LocalDateTime.now();
activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
activityMapper.insert(activity);
return ApiResponse.ok(activity);
}
/**
* 发布活动
* PUT /api/v1/operator/activities/{id}/publish
*/
@PutMapping("/{id}/publish")
public ApiResponse<?> publishActivity(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
String operatorId = extractOperatorId(authHeader);
return ApiResponse.ok(activityService.publishActivity(id, operatorId));
}
/**
* 取消活动
* PUT /api/v1/operator/activities/{id}/cancel
*/
@PutMapping("/{id}/cancel")
public ApiResponse<?> cancelActivity(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
String operatorId = extractOperatorId(authHeader);
return ApiResponse.ok(activityService.cancelActivity(id, operatorId));
}
/**
* 获取活动列表
* GET /api/v1/operator/activities
*/
@GetMapping
public ApiResponse<List<Map<String, Object>>> getActivities(
@RequestHeader("Authorization") String authHeader) {
List<Activity> activities = activityMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Activity>()
.eq(Activity::getDeletedAt, 0)
.orderByDesc(Activity::getCreatedAt)
);
return ApiResponse.ok(activities.stream().map(a -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", a.getId());
m.put("title", a.getTitle());
m.put("status", a.getStatus());
m.put("startAt", a.getStartAt());
m.put("endAt", a.getEndAt());
m.put("locationName", a.getLocationName());
m.put("totalLimit", a.getTotalLimit());
m.put("createdAt", a.getCreatedAt());
return m;
}).collect(java.util.stream.Collectors.toList()));
}
/**
* 获取活动报名列表
* GET /api/v1/operator/activities/{id}/registrations
*/
@GetMapping("/{id}/registrations")
public ApiResponse<List<Map<String, Object>>> getRegistrations(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
return ApiResponse.ok(activityService.getRegistrations(id));
}
/**
* 审核通过报名
* POST /api/v1/operator/activities/registrations/{rid}/approve
*/
@PostMapping("/registrations/{rid}/approve")
public ApiResponse<?> approveRegistration(
@RequestHeader("Authorization") String authHeader,
@PathVariable String rid) {
String operatorId = extractOperatorId(authHeader);
return ApiResponse.ok(activityService.approveRegistration(rid, operatorId));
}
/**
* 审核拒绝报名
* POST /api/v1/operator/activities/registrations/{rid}/reject
* Body: { "reason": "..." }
*/
@PostMapping("/registrations/{rid}/reject")
public ApiResponse<?> rejectRegistration(
@RequestHeader("Authorization") String authHeader,
@PathVariable String rid,
@RequestBody Map<String, String> body) {
String operatorId = extractOperatorId(authHeader);
String reason = body.get("reason");
return ApiResponse.ok(activityService.rejectRegistration(rid, operatorId, reason));
}
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,97 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.activity.service.CheckinService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 签到控制器
*/
@RestController
@RequestMapping("/activities")
@RequiredArgsConstructor
public class CheckinController {
private final CheckinService checkinService;
private final JwtTokenService jwtTokenService;
/**
* 生成签到码
* POST /api/v1/activities/{id}/checkin-token
*/
@PostMapping("/{id}/checkin-token")
public ApiResponse<Map<String, Object>> generateCheckinToken(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(checkinService.generateCheckinToken(userId, id));
}
/**
* 工作人员扫码签到
* POST /api/v1/operator/activities/checkin/scan
* Body: { "token": "..." }
*/
@PostMapping("/operator/checkin/scan")
public ApiResponse<Map<String, Object>> scanCheckin(
@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, String> body) {
String operatorId = extractOperatorId(authHeader);
String token = body.get("token");
if (token == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "token不能为空");
}
return ApiResponse.ok(checkinService.scanCheckin(token, operatorId));
}
/**
* 手工签到
* POST /api/v1/operator/activities/{id}/checkin/manual
* Body: { "userId": "..." }
*/
@PostMapping("/operator/{id}/checkin/manual")
public ApiResponse<Map<String, Object>> manualCheckin(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = extractOperatorId(authHeader);
String userId = body.get("userId");
if (userId == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "userId不能为空");
}
return ApiResponse.ok(checkinService.manualCheckin(userId, id, operatorId));
}
/**
* 获取签到统计
* GET /api/v1/operator/activities/{id}/checkin-stats
*/
@GetMapping("/operator/{id}/checkin-stats")
public ApiResponse<Map<String, Object>> getCheckinStats(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
return ApiResponse.ok(checkinService.getCheckinStats(id));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,26 @@
package com.danyang.match.platform.activity.dto;
import java.util.List;
/**
* 活动反馈 DTO
*/
public class ActivityFeedbackDTO {
private String activityId;
private String overallExperience;
private String willingNextTime;
private Boolean metSomeoneInteresting;
private List<String> interestTargetUserIds;
public String getActivityId() { return activityId; }
public void setActivityId(String activityId) { this.activityId = activityId; }
public String getOverallExperience() { return overallExperience; }
public void setOverallExperience(String overallExperience) { this.overallExperience = overallExperience; }
public String getWillingNextTime() { return willingNextTime; }
public void setWillingNextTime(String willingNextTime) { this.willingNextTime = willingNextTime; }
public Boolean getMetSomeoneInteresting() { return metSomeoneInteresting; }
public void setMetSomeoneInteresting(Boolean metSomeoneInteresting) { this.metSomeoneInteresting = metSomeoneInteresting; }
public List<String> getInterestTargetUserIds() { return interestTargetUserIds; }
public void setInterestTargetUserIds(List<String> interestTargetUserIds) { this.interestTargetUserIds = interestTargetUserIds; }
}
@@ -0,0 +1,25 @@
package com.danyang.match.platform.activity.dto;
import lombok.Data;
import java.time.Instant;
import java.util.Map;
/**
* 活动列表项DTO
*/
@Data
public class ActivityListItemDTO {
private String activityId;
private String title;
private String coverUrl;
private Instant startAt;
private Instant endAt;
private String locationName;
private String locationAddress;
private int maleCount;
private int femaleCount;
private int totalLimit;
private int remainingSeats;
private boolean registered;
private String registrationStatus;
}
@@ -0,0 +1,27 @@
package com.danyang.match.platform.activity.dto;
/**
* 活动互选结果 DTO
*/
public class ActivityMatchItemDTO {
private String targetUserId;
private String name;
private String fileKey;
private String age;
private String locationName;
private java.time.Instant mutualConfirmedAt;
public String getTargetUserId() { return targetUserId; }
public void setTargetUserId(String targetUserId) { this.targetUserId = targetUserId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getFileKey() { return fileKey; }
public void setFileKey(String fileKey) { this.fileKey = fileKey; }
public String getAge() { return age; }
public void setAge(String age) { this.age = age; }
public String getLocationName() { return locationName; }
public void setLocationName(String locationName) { this.locationName = locationName; }
public java.time.Instant getMutualConfirmedAt() { return mutualConfirmedAt; }
public void setMutualConfirmedAt(java.time.Instant mutualConfirmedAt) { this.mutualConfirmedAt = mutualConfirmedAt; }
}
@@ -0,0 +1,50 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 公益活动实体
* V1.1 Phase 14: 新增 activity_type / registration_policy / target_group / industry_scope / post_event_interest_enabled
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activities")
public class Activity extends BaseEntity {
private String title;
private String coverFileId;
private String description;
private String organizerOrgId;
private String coorganizerText;
private Instant startAt;
private Instant endAt;
private String locationName;
private String locationAddress;
private Integer maleLimit;
private Integer femaleLimit;
private Integer totalLimit;
private Short minAge;
private Short maxAge;
private Instant registrationStartAt;
private Instant registrationEndAt;
private Boolean needReview;
private String status;
private String createdBy;
// V1.1 Phase 14 新增字段
/** 活动类型:matchmaking / interest_social / relationship_education / premarital_guidance / marriage_custom_culture / anti_fraud */
private String activityType;
/** 报名政策(JSON snapshot: requires_identity_verified, requires_local_eligibility 等) */
private String registrationPolicy;
/** 目标人群 */
private String targetGroup;
/** 行业范围 */
private String industryScope;
/** 是否启用活动后意向采集 */
private Boolean postEventInterestEnabled;
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 活动容量分组
* V1.1 Phase 14
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activity_capacity_groups")
public class ActivityCapacityGroup extends BaseEntity {
private String activityId;
private String groupCode; // male / female / general
private String ruleDefinition;
private Integer capacity;
private Integer approvedCount;
}
@@ -0,0 +1,22 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 活动互选记录
* 核心 Invariant: 单向意向保密,仅双方确认后才进入 mutual 状态
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activity_interests")
public class ActivityInterest extends BaseEntity {
private String sourceUserId;
private String targetUserId;
private String activityId;
private String status; // expressed(单方向,保密), mutual(双向确认)
private java.time.Instant confirmedAt;
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 活动多组织关联
* V1.1 Phase 14
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activity_organizations")
public class ActivityOrganization extends BaseEntity {
private String activityId;
private String organizationId;
private String role; // host, organizer, co_organizer, partner, venue_partner
}
@@ -0,0 +1,38 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 活动报名表
* V1.1 Phase 13: 新增转介追踪字段 source/request_source/created_by_operator_id/user_confirmed_at
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activity_registrations")
public class ActivityRegistration extends BaseEntity {
private String activityId;
private String userId;
private String status; // pending, approved, rejected, cancelled
private Instant registeredAt;
private String reviewedBy;
private Instant reviewedAt;
private String rejectReason;
private String checkinStatus; // not_checked_in, checked_in
private Instant checkinAt;
private String emergencyContactName;
private String emergencyContactPhoneEnc;
/** V1.1: 来源类型(user_self/matchmaker_referral/operator_assisted等) */
private String source;
/** V1.1: 同source,用于兼容性保留 */
private String requestSource;
/** V1.1: 协助创建的工作人员ID(operator_assisted时填写) */
private String createdByOperatorId;
/** V1.1: 用户确认时间(转介场景需用户确认后激活) */
private Instant userConfirmedAt;
}
@@ -0,0 +1,9 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ActivityCapacityGroupMapper extends BaseMapper<ActivityCapacityGroup> {
}
@@ -0,0 +1,49 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityInterest;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface ActivityInterestMapper extends BaseMapper<ActivityInterest> {
/**
* 查询 A 对 B 是否已表达过意向
*/
@Select("SELECT * FROM activity_interests WHERE source_user_id = #{sourceUserId} " +
"AND target_user_id = #{targetUserId} AND activity_id = #{activityId} AND deleted_at IS NULL LIMIT 1")
Optional<ActivityInterest> findByActivityPair(@Param("sourceUserId") String sourceUserId,
@Param("targetUserId") String targetUserId,
@Param("activityId") String activityId);
/**
* 查询 A 对本活动所有其他人的意向记录
*/
@Select("SELECT * FROM activity_interests WHERE source_user_id = #{userId} " +
"AND activity_id = #{activityId} AND status != 'mutual' AND deleted_at IS NULL ORDER BY created_at DESC")
List<ActivityInterest> findExpressedByUser(@Param("userId") String userId,
@Param("activityId") String activityId);
/**
* 查询谁对目标用户表达了意向(仅 expressed,不含 mutual,保证保密性)
*/
@Select("SELECT ai.*, u.name AS source_name, u.phone_masked " +
"FROM activity_interests ai " +
"JOIN users u ON u.id = ai.source_user_id " +
"WHERE ai.target_user_id = #{targetUserId} AND ai.activity_id = #{activityId} " +
"AND ai.status = 'expressed' AND ai.deleted_at IS NULL")
List<ActivityInterest> findExpressedToUser(@Param("targetUserId") String targetUserId,
@Param("activityId") String activityId);
/**
* 查询本活动中所有 mutual 状态记录
*/
@Select("SELECT * FROM activity_interests WHERE activity_id = #{activityId} " +
"AND status = 'mutual' AND deleted_at IS NULL")
List<ActivityInterest> findMutualsByActivity(@Param("activityId") String activityId);
}
@@ -0,0 +1,26 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.Activity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ActivityMapper extends BaseMapper<Activity> {
int countRemainingCapacity(String activityId);
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
List<Activity> findOpenActivities();
@Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10")
List<Activity> findUpcomingActivities();
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
int countApprovedRegistrations(@Param("activityId") String activityId);
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender);
}
@@ -0,0 +1,18 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityOrganization;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ActivityOrganizationMapper extends BaseMapper<ActivityOrganization> {
@Select("SELECT * FROM activity_organizations WHERE activity_id = #{activityId}")
List<ActivityOrganization> findByActivityId(String activityId);
@Select("SELECT * FROM activity_organizations WHERE organization_id = #{orgId}")
List<ActivityOrganization> findByOrganizationId(String orgId);
}
@@ -0,0 +1,31 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistration> {
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<ActivityRegistration> findByActivityAndUser(@Param("activityId") String activityId,
@Param("userId") String userId);
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
List<ActivityRegistration> findApprovedByActivity(String activityId);
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
List<ActivityRegistration> findByUserId(String userId);
/**
* 原子检查名额:返回剩余名额数
*/
@Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " +
"(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining")
int countRemainingCapacity(@Param("activityId") String activityId);
}
@@ -0,0 +1,255 @@
package com.danyang.match.platform.activity.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.activity.dto.ActivityMatchItemDTO;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityInterestMapper;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.relationship.service.RelationshipService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
/**
* 活动互选服务
*
* 核心 Invariant:
* - 单向意向保密(expressed 状态,目标用户不可见)
* - 仅双方均表达后建立 mutual,通知双方
* - 活动结束后才能反馈
*/
@Service
@RequiredArgsConstructor
public class ActivityInterestService {
private final ActivityInterestMapper interestMapper;
private final ActivityRegistrationMapper registrationMapper;
private final ActivityMapper activityMapper;
private final UserProfileMapper userProfileMapper;
private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper;
private final UserPhotoMapper userPhotoMapper;
private final RelationshipService relationshipService;
/**
* 提交活动反馈 + 表达意向
* 活动结束后开放,单用户对每个目标用户最多表达一次
*/
@Transactional
public Map<String, Object> submitFeedback(String userId, String activityId,
String overallExperience,
String willingNextTime,
Boolean metSomeoneInteresting,
List<String> interestTargetUserIds) {
// 1. 验证活动已结束
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
}
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
throw new RuntimeException("活动尚未结束,暂不支持反馈");
}
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未通过审核,无法提交反馈");
}
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
if (interestTargetUserIds == null) {
interestTargetUserIds = List.of();
}
Set<String> validTargets = getApprovedParticipants(activityId).stream()
.filter(uid -> !uid.equals(userId))
.collect(Collectors.toSet());
List<String> validInterests = interestTargetUserIds.stream()
.filter(validTargets::contains)
.distinct()
.collect(Collectors.toList());
// 4. 逐个创建/更新意向记录
List<Map<String, Object>> createdMutuals = new ArrayList<>();
for (String targetId : validInterests) {
Optional<ActivityInterest> existing = interestMapper.findByActivityPair(userId, targetId, activityId);
if (existing.isPresent()) {
continue; // 已表达过,跳过(幂等)
}
ActivityInterest interest = new ActivityInterest();
interest.setSourceUserId(userId);
interest.setTargetUserId(targetId);
interest.setActivityId(activityId);
interest.setStatus(Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED);
interestMapper.insert(interest);
// 检查是否双向 → 若双向则创建 mutual
Optional<ActivityInterest> reverse = interestMapper.findByActivityPair(targetId, userId, activityId);
if (reverse.isPresent()) {
activateMutual(userId, targetId, activityId);
createdMutuals.add(buildMutualDTO(userId, targetId, activityId));
}
}
// 5. 审计
saveAuditLog(userId, "SUBMIT_ACTIVITY_FEEDBACK", "activity_interest", activityId,
null, null);
Map<String, Object> result = new HashMap<>();
result.put("feedbackSubmitted", true);
result.put("newMutuals", createdMutuals);
result.put("totalInterestsExpressed", validInterests.size());
return result;
}
/**
* 激活双向互选
* 将双方记录更新为 mutual 状态,并发送通知
*/
@Transactional
public void activateMutual(String userIdA, String userIdB, String activityId) {
ActivityInterest ai1 = interestMapper.selectOne(new LambdaQueryWrapper<ActivityInterest>()
.eq(ActivityInterest::getSourceUserId, userIdA)
.eq(ActivityInterest::getTargetUserId, userIdB)
.eq(ActivityInterest::getActivityId, activityId)
.eq(ActivityInterest::getStatus, Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED));
if (ai1 != null) {
ai1.setStatus(Constants.ACTIVITY_INTEREST_STATUS_MUTUAL);
ai1.setConfirmedAt(Instant.now());
interestMapper.updateById(ai1);
}
ActivityInterest ai2 = interestMapper.selectOne(new LambdaQueryWrapper<ActivityInterest>()
.eq(ActivityInterest::getSourceUserId, userIdB)
.eq(ActivityInterest::getTargetUserId, userIdA)
.eq(ActivityInterest::getActivityId, activityId)
.eq(ActivityInterest::getStatus, Constants.ACTIVITY_INTEREST_STATUS_EXPRESSED));
if (ai2 != null) {
ai2.setStatus(Constants.ACTIVITY_INTEREST_STATUS_MUTUAL);
ai2.setConfirmedAt(Instant.now());
interestMapper.updateById(ai2);
}
// 获取双方用户资料
UserProfile profileA = userProfileMapper.findByUserId(userIdA).orElse(null);
UserProfile profileB = userProfileMapper.findByUserId(userIdB).orElse(null);
String nameA = profileA != null ? profileA.getDisplayName() : "一位朋友";
String nameB = profileB != null ? profileB.getDisplayName() : "一位朋友";
// 通知 A
notificationService.send(userIdA, Constants.NOTIFICATION_TYPE_ACTIVITY,
"活动双向匹配",
"您在「" + getActivityTitle(activityId) + "」中与" + nameB + "互相表达了进一步了解意愿。"
+ "双方确认后才可以开启线上沟通。",
"activity_interest", activityId,
"activity_mutual:" + activityId + ":" + userIdA + ":" + userIdB);
// 通知 B
notificationService.send(userIdB, Constants.NOTIFICATION_TYPE_ACTIVITY,
"活动双向匹配",
"您在「" + getActivityTitle(activityId) + "」中与" + nameA + "互相表达了进一步了解意愿。"
+ "双方确认后才可以开启线上沟通。",
"activity_interest", activityId,
"activity_mutual:" + activityId + ":" + userIdB + ":" + userIdA);
}
/**
* 查询A和B是否在活动中形成了双向互选
* 用于前端判断是否显示"开启沟通"按钮
*/
public boolean hasMutualInterest(String userId, String partnerId, String activityId) {
Optional<ActivityInterest> ai1 = interestMapper.findByActivityPair(userId, partnerId, activityId);
Optional<ActivityInterest> ai2 = interestMapper.findByActivityPair(partnerId, userId, activityId);
return ai1.isPresent() && Constants.ACTIVITY_INTEREST_STATUS_MUTUAL.equals(ai1.get().getStatus())
&& ai2.isPresent() && Constants.ACTIVITY_INTEREST_STATUS_MUTUAL.equals(ai2.get().getStatus());
}
/**
* 获取用户在某活动中的互选列表
*/
public List<ActivityMatchItemDTO> getMutualMatches(String userId, String activityId) {
List<ActivityInterest> interests = interestMapper.findMutualsByActivity(activityId);
return interests.stream()
.filter(i -> userId.equals(i.getSourceUserId()) || userId.equals(i.getTargetUserId()))
.map(i -> {
String partnerId = userId.equals(i.getSourceUserId()) ? i.getTargetUserId() : i.getSourceUserId();
return toMatchDTO(partnerId, i.getConfirmedAt());
})
.collect(Collectors.toList());
}
/**
* 获取某活动中所有 mutual 统计
*/
public Map<String, Object> getActivityInterestStats(String activityId) {
List<ActivityInterest> mutuals = interestMapper.findMutualsByActivity(activityId);
Map<String, Object> stats = new HashMap<>();
stats.put("activityId", activityId);
stats.put("mutualCount", mutuals.size());
stats.put("mutualPairs", mutuals.size() / 2);
return stats;
}
// ==================== 私有辅助方法 ====================
private List<String> getApprovedParticipants(String activityId) {
return registrationMapper.findApprovedByActivity(activityId).stream()
.map(ActivityRegistration::getUserId)
.collect(Collectors.toList());
}
private ActivityMatchItemDTO toMatchDTO(String userId, Instant confirmedAt) {
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
UserPhoto primaryPhoto = userPhotoMapper.findAvatar(userId);
ActivityMatchItemDTO dto = new ActivityMatchItemDTO();
dto.setTargetUserId(userId);
dto.setName(profile != null ? profile.getDisplayName() : "");
dto.setFileKey(primaryPhoto != null ? primaryPhoto.getFileKey() : null);
dto.setAge(profile != null && profile.getBirthDate() != null
? String.valueOf(java.time.Year.now().getValue() - profile.getBirthDate().getYear()) : "");
dto.setLocationName(profile != null ? profile.getRegionName() : "");
dto.setMutualConfirmedAt(confirmedAt);
return dto;
}
private Map<String, Object> buildMutualDTO(String userIdA, String userIdB, String activityId) {
UserProfile profileB = userProfileMapper.findByUserId(userIdB).orElse(null);
Map<String, Object> item = new HashMap<>();
item.put("userId", userIdB);
item.put("name", profileB != null ? profileB.getDisplayName() : "");
item.put("confirmedAt", Instant.now());
return item;
}
private String getActivityTitle(String activityId) {
Activity activity = activityMapper.selectById(activityId);
return activity != null ? activity.getTitle() : activityId;
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,526 @@
package com.danyang.match.platform.activity.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.activity.dto.ActivityListItemDTO;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.activity.entity.ActivityOrganization;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
import com.danyang.match.platform.organization.entity.Organization;
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ActivityService {
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
private final ActivityMapper activityMapper;
private final ActivityRegistrationMapper registrationMapper;
private final UserProfileMapper userProfileMapper;
private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper;
private final ActivityOrganizationMapper activityOrganizationMapper;
private final ActivityCapacityGroupMapper capacityGroupMapper;
private final OrganizationMapper organizationMapper;
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
/**
* 创建活动(后台)
*/
@Transactional
public Activity createActivity(Activity activity, String operatorId) {
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
activity.setCreatedBy(operatorId);
activityMapper.insert(activity);
saveAuditLog(operatorId, "CREATE_ACTIVITY", "activity", activity.getId(), null, null);
log.info("创建活动: activityId={}, operatorId={}", activity.getId(), operatorId);
return activity;
}
/**
* 发布活动
*/
@Transactional
public Activity publishActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许发布");
}
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
if (activity.getRegistrationStartAt() == null) {
activity.setRegistrationStartAt(Instant.now());
}
activityMapper.updateById(activity);
saveAuditLog(operatorId, "PUBLISH_ACTIVITY", "activity", activityId, null, null);
return activity;
}
/**
* 取消活动
*/
@Transactional
public Activity cancelActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
}
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
activityMapper.updateById(activity);
// 通知所有已审核通过的用户
registrationMapper.findApprovedByActivity(activityId).forEach(reg -> {
if (Constants.REGISTRATION_STATUS_APPROVED.equals(reg.getStatus())) {
notificationService.sendAsync(reg.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
"活动已取消", "您报名的「" + activity.getTitle() + "」已取消,如有疑问请联系工作人员。",
"activity_cancel", activityId);
}
});
saveAuditLog(operatorId, "CANCEL_ACTIVITY", "activity", activityId, null, null);
return activity;
}
/**
* 获取活动列表
*/
public List<ActivityListItemDTO> getActivities(String userId) {
List<Activity> activities = activityMapper.findOpenActivities();
return activities.stream().map(a -> toListItemDTO(userId, a)).collect(Collectors.toList());
}
/**
* 获取即将开始的活动
*/
public List<ActivityListItemDTO> getUpcomingActivities(String userId) {
List<Activity> activities = activityMapper.findUpcomingActivities();
return activities.stream().map(a -> toListItemDTO(userId, a)).collect(Collectors.toList());
}
/**
* 获取活动详情
*/
public Map<String, Object> getActivityDetail(String activityId, String userId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) return null;
Map<String, Object> detail = new HashMap<>();
detail.put("activityId", activity.getId());
detail.put("title", activity.getTitle());
detail.put("description", activity.getDescription());
detail.put("startAt", activity.getStartAt());
detail.put("endAt", activity.getEndAt());
detail.put("locationName", activity.getLocationName());
detail.put("locationAddress", activity.getLocationAddress());
detail.put("maleLimit", activity.getMaleLimit());
detail.put("femaleLimit", activity.getFemaleLimit());
detail.put("totalLimit", activity.getTotalLimit());
detail.put("minAge", activity.getMinAge());
detail.put("maxAge", activity.getMaxAge());
detail.put("needReview", activity.getNeedReview());
detail.put("status", activity.getStatus());
detail.put("organizerOrgId", activity.getOrganizerOrgId());
detail.put("coorganizerText", activity.getCoorganizerText());
// 报名统计
int approvedCount = activityMapper.countApprovedRegistrations(activityId);
int remaining = activityMapper.countRemainingCapacity(activityId);
detail.put("approvedCount", approvedCount);
detail.put("remainingSeats", Math.max(0, remaining));
detail.put("isFull", remaining <= 0);
// 用户报名状态
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> {
detail.put("registered", true);
detail.put("registrationStatus", reg.getStatus());
detail.put("checkinStatus", reg.getCheckinStatus());
});
return detail;
}
/**
* 报名活动(含名额并发控制)
*/
@Transactional
public Map<String, Object> registerActivity(String userId, String activityId,
String emergencyContactName,
String emergencyContactPhoneEnc) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
}
// 检查是否已报名
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(existing -> {
if (!Constants.REGISTRATION_STATUS_REJECTED.equals(existing.getStatus()) &&
!Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.getStatus())) {
throw new RuntimeException("已经报名过该活动");
}
});
// 检查名额(事务内原子检查)
int remaining = activityMapper.countRemainingCapacity(activityId);
if (remaining <= 0) {
throw new RuntimeException("活动名额已满");
}
// 检查年龄范围
if (activity.getMinAge() != null) {
// TODO: 获取用户年龄并校验
}
// 创建报名记录
ActivityRegistration registration = new ActivityRegistration();
registration.setActivityId(activityId);
registration.setUserId(userId);
registration.setStatus(activity.getNeedReview() ?
Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED);
registration.setRegisteredAt(Instant.now());
registration.setEmergencyContactName(emergencyContactName);
registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
registrationMapper.insert(registration);
// P17-T03: 线下活动风险检测
try {
String riskLevel = riskReferralService.detectActivityRisk(userId, emergencyContactName, null);
if (riskLevel != null) {
log.warn("活动报名触发风险: userId={}, activityId={}, riskLevel={}", userId, activityId, riskLevel);
}
} catch (Exception e) {
log.warn("活动风险检测异常: {}", e.getMessage());
}
log.info("用户报名活动: userId={}, activityId={}, status={}", userId, activityId, registration.getStatus());
saveAuditLog(userId, "ACTIVITY_REGISTER", "activity", activityId, null, null);
Map<String, Object> result = new HashMap<>();
result.put("registrationId", registration.getId());
result.put("status", registration.getStatus());
result.put("needReview", activity.getNeedReview());
return result;
}
/**
* 审核通过报名
*/
@Transactional
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核通过");
}
// 再次检查名额(防止并发超卖)
int remaining = activityMapper.countRemainingCapacity(registration.getActivityId());
if (remaining <= 0) {
throw new RuntimeException("活动名额已满,无法通过审核");
}
registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED);
registration.setReviewedBy(operatorId);
registration.setReviewedAt(Instant.now());
registrationMapper.updateById(registration);
// 通知用户
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
"活动报名审核通过", "您报名的活动已通过审核,请留意活动通知。",
"activity_approval", registrationId);
log.info("审核通过报名: registrationId={}, operatorId={}", registrationId, operatorId);
saveAuditLog(operatorId, "APPROVE_REGISTRATION", "activity_registration", registrationId, null, null);
return registration;
}
/**
* 审核拒绝报名
*/
@Transactional
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
}
registration.setStatus(Constants.REGISTRATION_STATUS_REJECTED);
registration.setRejectReason(reason);
registration.setReviewedBy(operatorId);
registration.setReviewedAt(Instant.now());
registrationMapper.updateById(registration);
// 通知用户
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
"活动报名未通过", "您报名的活动未通过审核:" + reason,
"activity_rejection", registrationId);
log.info("审核拒绝报名: registrationId={}, operatorId={}", registrationId, operatorId);
saveAuditLog(operatorId, "REJECT_REGISTRATION", "activity_registration", registrationId, null, null);
return registration;
}
/**
* 用户取消报名
*/
@Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不能取消");
}
registration.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
registrationMapper.updateById(registration);
log.info("用户取消报名: userId={}, activityId={}", userId, activityId);
return registration;
}
/**
* 获取我的活动列表
*/
public List<Map<String, Object>> getMyActivities(String userId) {
return registrationMapper.findByUserId(userId).stream()
.map(reg -> {
Activity activity = activityMapper.selectById(reg.getActivityId());
Map<String, Object> map = new HashMap<>();
map.put("activityId", activity.getId());
map.put("title", activity.getTitle());
map.put("startAt", activity.getStartAt());
map.put("locationName", activity.getLocationName());
map.put("status", reg.getStatus());
map.put("checkinStatus", reg.getCheckinStatus());
map.put("registeredAt", reg.getRegisteredAt());
map.put("reviewedAt", reg.getReviewedAt());
return map;
})
.collect(Collectors.toList());
}
/**
* 获取活动报名列表(后台)
*/
public List<Map<String, Object>> getRegistrations(String activityId) {
return registrationMapper.findApprovedByActivity(activityId).stream()
.map(reg -> {
Map<String, Object> map = new HashMap<>();
map.put("registrationId", reg.getId());
map.put("userId", reg.getUserId());
map.put("status", reg.getStatus());
map.put("checkinStatus", reg.getCheckinStatus());
map.put("registeredAt", reg.getRegisteredAt());
map.put("reviewedAt", reg.getReviewedAt());
map.put("emergencyContactName", reg.getEmergencyContactName());
// 获取用户资料
userProfileMapper.findByUserId(reg.getUserId()).ifPresent(profile -> {
map.put("displayName", profile.getDisplayName());
map.put("gender", profile.getGender());
});
return map;
})
.collect(Collectors.toList());
}
private ActivityListItemDTO toListItemDTO(String userId, Activity activity) {
ActivityListItemDTO dto = new ActivityListItemDTO();
dto.setActivityId(activity.getId());
dto.setTitle(activity.getTitle());
dto.setStartAt(activity.getStartAt());
dto.setEndAt(activity.getEndAt());
dto.setLocationName(activity.getLocationName());
dto.setTotalLimit(activity.getTotalLimit());
long approvedCount = registrationMapper.selectCount(
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getActivityId, activity.getId())
.eq(ActivityRegistration::getStatus, "approved"));
// 注:性别在 user_profiles 中,此处简化处理(男女各半)
dto.setMaleCount((int)(approvedCount / 2));
dto.setFemaleCount((int)(approvedCount - approvedCount / 2));
dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ?
activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0));
dto.setRegistered(false);
return dto;
}
/** V1.1 Phase 14: 按活动类型过滤 */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
List<Activity> activities = activityMapper.findOpenActivities();
return activities.stream()
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
.map(a -> (Map<String, Object>) convertToMap(toListItemDTO(userId, a)))
.toList();
}
private Map<String, Object> convertToMap(ActivityListItemDTO dto) {
Map<String, Object> m = new java.util.HashMap<>();
m.put("activityId", dto.getActivityId());
m.put("title", dto.getTitle());
m.put("startAt", dto.getStartAt());
m.put("endAt", dto.getEndAt());
m.put("locationName", dto.getLocationName());
m.put("totalLimit", dto.getTotalLimit());
m.put("maleCount", dto.getMaleCount());
m.put("femaleCount", dto.getFemaleCount());
m.put("remainingSeats", dto.getRemainingSeats());
m.put("registered", dto.isRegistered());
return m;
}
/** V1.1 Phase 14: 获取活动组织列表 */
public List<Map<String, Object>> getActivityOrganizations(String activityId) {
List<ActivityOrganization> orgs = activityOrganizationMapper.findByActivityId(activityId);
return orgs.stream().map(o -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("organizationId", o.getOrganizationId());
m.put("role", o.getRole());
Organization org = organizationMapper.selectById(o.getOrganizationId());
if (org != null) m.put("name", org.getName());
return m;
}).toList();
}
/** V1.1 Phase 14: 获取活动容量分组 */
public List<Map<String, Object>> getActivityCapacityGroups(String activityId) {
List<ActivityCapacityGroup> groups = capacityGroupMapper.selectList(
new LambdaQueryWrapper<ActivityCapacityGroup>()
.eq(ActivityCapacityGroup::getActivityId, activityId)
);
return groups.stream().map(g -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("groupCode", g.getGroupCode());
m.put("capacity", g.getCapacity());
m.put("approvedCount", g.getApprovedCount());
m.put("ruleDefinition", g.getRuleDefinition());
return m;
}).toList();
}
/** V1.1 Phase 14: 从后台创建带多组织和类型的活动 */
@Transactional
public Map<String, Object> createActivityFromAdmin(Map<String, Object> body, String operatorId) {
Activity activity = new Activity();
activity.setTitle((String) body.get("title"));
activity.setDescription((String) body.get("description"));
activity.setCoverFileId((String) body.get("coverFileId"));
activity.setStartAt(Instant.parse((String) body.get("startAt")));
activity.setEndAt(Instant.parse((String) body.get("endAt")));
activity.setLocationName((String) body.get("locationName"));
activity.setLocationAddress((String) body.get("locationAddress"));
activity.setTotalLimit(((Number) body.get("totalLimit")).intValue());
activity.setMinAge(((Number) body.get("minAge")).shortValue());
activity.setMaxAge(((Number) body.get("maxAge")).shortValue());
Object regStart = body.get("registrationStartAt");
Object regEnd = body.get("registrationEndAt");
activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null);
activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null);
activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString()));
activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking"));
activity.setRegistrationPolicy((String) body.get("registrationPolicy"));
activity.setTargetGroup((String) body.get("targetGroup"));
activity.setIndustryScope((String) body.get("industryScope"));
activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString()));
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
activity.setCreatedBy(operatorId);
activityMapper.insert(activity);
// 保存主组织
String orgId = (String) body.get("organizerOrgId");
if (orgId != null) {
ActivityOrganization hostOrg = new ActivityOrganization();
hostOrg.setActivityId(activity.getId());
hostOrg.setOrganizationId(orgId);
hostOrg.setRole(Constants.ORG_ROLE_HOST);
activityOrganizationMapper.insert(hostOrg);
}
// 保存协作组织
@SuppressWarnings("unchecked")
java.util.List<java.util.Map<String, String>> orgList = (java.util.List<java.util.Map<String, String>>) body.get("partnerOrganizations");
if (orgList != null) {
for (java.util.Map<String, String> orgEntry : orgList) {
ActivityOrganization org = new ActivityOrganization();
org.setActivityId(activity.getId());
org.setOrganizationId(orgEntry.get("organizationId"));
org.setRole(orgEntry.getOrDefault("role", Constants.ORG_ROLE_PARTNER));
activityOrganizationMapper.insert(org);
}
}
log.info("后台创建活动(V1.1): activityId={}, type={}, operatorId={}", activity.getId(), activity.getActivityType(), operatorId);
saveAuditLog(operatorId, "CREATE_ACTIVITY_V11", "activity", activity.getId(), null, null);
Map<String, Object> result = new java.util.HashMap<>();
result.put("id", activity.getId());
result.put("activityType", activity.getActivityType());
result.put("status", activity.getStatus());
return result;
}
/** V1.1 Phase 14: 添加活动组织关联 */
@Transactional
public Map<String, Object> addActivityOrganization(String activityId, String organizationId, String role, String operatorId) {
ActivityOrganization org = new ActivityOrganization();
org.setActivityId(activityId);
org.setOrganizationId(organizationId);
org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER);
activityOrganizationMapper.insert(org);
log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role);
Map<String, Object> result = new java.util.HashMap<>();
result.put("activityId", activityId);
result.put("organizationId", organizationId);
result.put("role", org.getRole());
return result;
}
/** V1.1 Phase 14: 添加容量分组 */
@Transactional
public Map<String, Object> addCapacityGroup(String activityId, Map<String, Object> body, String operatorId) {
ActivityCapacityGroup group = new ActivityCapacityGroup();
group.setActivityId(activityId);
group.setGroupCode((String) body.get("groupCode"));
group.setCapacity(((Number) body.get("capacity")).intValue());
group.setRuleDefinition((String) body.get("ruleDefinition"));
group.setApprovedCount(0);
capacityGroupMapper.insert(group);
log.info("添加容量分组: activityId={}, groupCode={}", activityId, group.getGroupCode());
Map<String, Object> result = new java.util.HashMap<>();
result.put("id", group.getId());
result.put("groupCode", group.getGroupCode());
result.put("capacity", group.getCapacity());
return result;
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,233 @@
package com.danyang.match.platform.activity.service;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 签到服务
* 使用短期签名Token,二维码不包含敏感明文
*/
@Service
@RequiredArgsConstructor
public class CheckinService {
@Value("${checkin.token.secret:danyang-match-checkin-secret-change-in-production}")
private String tokenSecret;
@Value("${checkin.token.ttl-minutes:30}")
private int tokenTtlMinutes;
private final ActivityRegistrationMapper registrationMapper;
private final ActivityMapper activityMapper;
private final AuditLogMapper auditLogMapper;
private final StringRedisTemplate redisTemplate;
/**
* 生成签到Token(短时有效,二维码内容)
* Token格式: base64(userId:activityId:timestamp: HMAC)
*/
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
// 检查是否有有效的报名记录
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名尚未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("您已经签到过了");
}
// 检查活动是否在进行中
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
}
Instant now = Instant.now();
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
throw new RuntimeException("活动未开始或已结束,无法签到");
}
// 生成签名Token
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
String signature = sign(payload);
String token = Base64.getEncoder().encodeToString((payload + ":" + signature).getBytes(StandardCharsets.UTF_8));
// 缓存Token(防止重放攻击)
String cacheKey = "checkin:token:" + token;
redisTemplate.opsForValue().set(cacheKey, "used", tokenTtlMinutes, TimeUnit.MINUTES);
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("expiresAt", now.plusSeconds(tokenTtlMinutes * 60L).toString());
result.put("activityTitle", activity.getTitle());
result.put("locationName", activity.getLocationName());
return result;
}
/**
* 工作人员扫码签到
*/
@Transactional
public Map<String, Object> scanCheckin(String token, String staffOperatorId) {
// 1. 解码Token
String decoded;
try {
decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("无效的签到码");
}
String[] parts = decoded.split(":");
if (parts.length != 4) {
throw new RuntimeException("无效的签到码格式");
}
String userId = parts[0];
String activityId = parts[1];
long timestamp = Long.parseLong(parts[2]);
String signature = parts[3];
// 2. 验证签名
String expectedPayload = userId + ":" + activityId + ":" + timestamp;
String expectedSignature = sign(expectedPayload);
if (!expectedSignature.equals(signature)) {
throw new RuntimeException("签到码签名验证失败");
}
// 3. 检查Token是否已使用(防重放)
String cacheKey = "checkin:token:" + token;
String cached = redisTemplate.opsForValue().get(cacheKey);
if ("used".equals(cached)) {
throw new RuntimeException("该签到码已使用过");
}
// 4. 检查Token是否过期
Instant tokenTime = Instant.ofEpochMilli(timestamp);
if (Instant.now().isAfter(tokenTime.plusSeconds(tokenTtlMinutes * 60L))) {
throw new RuntimeException("签到码已过期");
}
// 5. 检查报名记录
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("该用户已经签到");
}
// 6. 执行签到
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
registration.setCheckinAt(Instant.now());
registrationMapper.updateById(registration);
// 7. 标记Token为已使用
redisTemplate.opsForValue().set(cacheKey, "used", 1, TimeUnit.HOURS);
// 8. 审计
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("userId", userId);
result.put("activityId", activityId);
result.put("checkedInAt", Instant.now());
return result;
}
/**
* 手工签到(工作人员手动输入)
*/
@Transactional
public Map<String, Object> manualCheckin(String userId, String activityId, String staffOperatorId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未审核通过");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("该用户已经签到");
}
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
registration.setCheckinAt(Instant.now());
registrationMapper.updateById(registration);
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("checkedInAt", Instant.now());
return result;
}
/**
* 获取活动签到统计
*/
public Map<String, Object> getCheckinStats(String activityId) {
ActivityRegistrationMapper regMapper = registrationMapper;
// 统计已签到人数
long checkedInCount = registrationMapper.findApprovedByActivity(activityId).stream()
.filter(r -> Constants.CHECKIN_STATUS_CHECKED_IN.equals(r.getCheckinStatus()))
.count();
Activity activity = activityMapper.selectById(activityId);
Map<String, Object> stats = new HashMap<>();
stats.put("activityId", activityId);
stats.put("totalApproved", registrationMapper.findApprovedByActivity(activityId).size());
stats.put("checkedInCount", checkedInCount);
stats.put("remainingCount", Math.max(0, (activity.getTotalLimit() != null ? activity.getTotalLimit() : 0) - checkedInCount));
return stats;
}
/**
* 计算HMAC-SHA256签名
*/
private String sign(String payload) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(tokenSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(keySpec);
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
}
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,115 @@
package com.danyang.match.platform.activity_mutual.controller;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 活动互选二阶段同意控制器
* V1.1 Phase 16
*/
@RestController
@RequestMapping("/activities/{activityId}/mutual")
@RequiredArgsConstructor
public class ActivityMutualConsentController {
private final ActivityMutualConsentService consentService;
private final JwtTokenService jwtTokenService;
/**
* P16-T03: 检测双向互选结果
* GET /api/v1/activities/{id}/mutual/results
*/
@GetMapping("/results")
public ApiResponse<List<MutualInterestResultDTO>> getMutualResults(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityId) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(consentService.detectMutualResults(activityId));
}
/**
* P16-T04: 创建互选同意记录
* POST /api/v1/activities/{id}/mutual/consent
*/
@PostMapping("/consent")
public ApiResponse<?> createConsent(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityId,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String partnerId = body.get("partnerId");
return ApiResponse.ok(consentService.createMutualConsent(activityId, userId, partnerId));
}
/**
* P16-T05/T06: 用户确认/拒绝开启沟通
* POST /api/v1/activities/{id}/mutual/confirm
*/
@PostMapping("/confirm")
public ApiResponse<Map<String, Object>> confirmConnection(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityId,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String partnerId = body.get("partnerId");
return ApiResponse.ok(consentService.confirmConnection(userId, activityId, partnerId));
}
/**
* P16-T12: 用户拒绝开启沟通
* POST /api/v1/activities/{id}/mutual/decline
*/
@PostMapping("/decline")
public ApiResponse<Map<String, Object>> declineConnection(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityId,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String partnerId = body.get("partnerId");
return ApiResponse.ok(consentService.declineConnection(userId, activityId, partnerId));
}
/**
* 查询我的互选状态
* GET /api/v1/activities/{id}/mutual/status/{partnerId}
*/
@GetMapping("/status/{partnerId}")
public ApiResponse<Map<String, Object>> getMyConsentStatus(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String activityId,
@PathVariable String partnerId) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(consentService.getMyConsentStatus(userId, activityId, partnerId));
}
/**
* 获取我的活动互选结果列表
* GET /api/v1/mutual/my-results
*/
@GetMapping("/my-results")
public ApiResponse<List<MutualInterestResultDTO>> getMyMutualResults(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(consentService.getMyMutualResults(userId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,17 @@
package com.danyang.match.platform.activity_mutual.dto;
import lombok.Data;
import java.time.Instant;
@Data
public class MutualConsentDTO {
private String id;
private String activityId;
private String userAId;
private String userBId;
private String userAStatus;
private String userBStatus;
private Instant bothConfirmedAt;
private Instant createdAt;
private Instant updatedAt;
}
@@ -0,0 +1,25 @@
package com.danyang.match.platform.activity_mutual.dto;
import lombok.Data;
import java.time.Instant;
import java.util.List;
/**
* 活动互选结果DTO(供Miniapp展示)
*/
@Data
public class MutualInterestResultDTO {
private String partnerId;
private String partnerName;
private String partnerAvatarFileId;
private String activityId;
private Instant bothConfirmedAt;
/** 是否双方都已确认 */
private boolean bothConfirmed;
/** 当前用户是否需要确认 */
private boolean needsMyConfirmation;
/** 对方是否已拒绝 */
private boolean partnerDeclined;
/** 是否已有活跃Match */
private boolean hasActiveMatch;
}
@@ -0,0 +1,28 @@
package com.danyang.match.platform.activity_mutual.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 活动互选二阶段同意记录
* V1.1 Phase 16: 每次互选结果只记录一对,独立追踪双方同意状态
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("activity_mutual_connection_consents")
public class ActivityMutualConsent extends BaseEntity {
private String activityId;
private String userAId;
private String userBId;
/** A的同意状态:pending / accepted / declined */
private String userAStatus;
/** B的同意状态:pending / accepted / declined */
private String userBStatus;
/** 双方都同意时的时间戳 */
private Instant bothConfirmedAt;
}
@@ -0,0 +1,31 @@
package com.danyang.match.platform.activity_mutual.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualConsent> {
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE activity_id = #{activityId} " +
"AND ((user_a_id = #{userId} AND user_b_id = #{partnerId}) " +
"OR (user_a_id = #{partnerId} AND user_b_id = #{userId})) " +
"LIMIT 1")
Optional<ActivityMutualConsent> findByActivityPair(@Param("activityId") String activityId,
@Param("userId") String userId,
@Param("partnerId") String partnerId);
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
List<ActivityMutualConsent> findBothConfirmedByActivity(String activityId);
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
List<ActivityMutualConsent> findBothConfirmedByUser(String userId);
}
@@ -0,0 +1,129 @@
package com.danyang.match.platform.activity_mutual.service;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class ActivityMutualConsentService {
private final ActivityMutualConsentMapper consentMapper;
private final com.danyang.match.platform.relationship.mapper.MatchMapper matchMapper;
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
return bothConfirmed.stream().map(c -> toResultDTO(c, activityId)).toList();
}
public ActivityMutualConsent createMutualConsent(String activityId, String userId, String partnerId) {
Optional<ActivityMutualConsent> existing = consentMapper.findByActivityPair(activityId, userId, partnerId);
ActivityMutualConsent consent;
if (existing.isPresent()) {
consent = existing.get();
} else {
consent = new ActivityMutualConsent();
consent.setId(UUID.randomUUID().toString());
consent.setActivityId(activityId);
if (userId.compareTo(partnerId) < 0) {
consent.setUserAId(userId);
consent.setUserBId(partnerId);
} else {
consent.setUserAId(partnerId);
consent.setUserBId(userId);
}
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
consentMapper.insert(consent);
}
return consent;
}
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
} else {
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
}
consent.setUpdatedAt(Instant.now());
consentMapper.updateById(consent);
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
Map<String, Object> result = new HashMap<>();
result.put("myStatus", userId.equals(consent.getUserAId()) ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
result.put("partnerStatus", userId.equals(consent.getUserAId()) ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
if (aAccepted && bAccepted) {
consent.setBothConfirmedAt(Instant.now());
consentMapper.updateById(consent);
result.put("bothConfirmed", true);
log.info("双方确认活动互选: activityId={}, userA={}, userB={}", activityId, consent.getUserAId(), consent.getUserBId());
} else {
result.put("bothConfirmed", false);
}
return result;
}
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else {
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
}
consent.setUpdatedAt(Instant.now());
consentMapper.updateById(consent);
log.info("用户拒绝活动互选: userId={}, activityId={}, partnerId={}", userId, activityId, partnerId);
Map<String, Object> result = new HashMap<>();
result.put("myStatus", "declined");
return result;
}
public boolean hasActiveMatch(String userId, String partnerId) {
return matchMapper.findActiveMatch(userId, partnerId).isPresent();
}
public List<MutualInterestResultDTO> getMyMutualResults(String userId) {
List<ActivityMutualConsent> consents = consentMapper.findBothConfirmedByUser(userId);
return consents.stream().map(c -> toResultDTO(c, c.getActivityId())).toList();
}
public Map<String, Object> getMyConsentStatus(String userId, String activityId, String partnerId) {
Optional<ActivityMutualConsent> consentOpt = consentMapper.findByActivityPair(activityId, userId, partnerId);
if (consentOpt.isEmpty()) {
Map<String, Object> result = new HashMap<>();
result.put("exists", false);
return result;
}
ActivityMutualConsent consent = consentOpt.get();
boolean myAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()))
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()));
boolean partnerAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()));
Map<String, Object> result = new HashMap<>();
result.put("exists", true);
result.put("myStatus", myAccepted ? "accepted" : "pending");
result.put("partnerStatus", partnerAccepted ? "accepted" : "pending");
result.put("bothConfirmed", myAccepted && partnerAccepted);
result.put("bothConfirmedAt", consent.getBothConfirmedAt());
return result;
}
private MutualInterestResultDTO toResultDTO(ActivityMutualConsent consent, String activityId) {
MutualInterestResultDTO dto = new MutualInterestResultDTO();
dto.setActivityId(activityId);
dto.setBothConfirmedAt(consent.getBothConfirmedAt());
dto.setBothConfirmed(consent.getBothConfirmedAt() != null);
return dto;
}
}
@@ -0,0 +1,113 @@
package com.danyang.match.platform.analytics;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 数据概览控制器
*/
@RestController
@RequestMapping("/operator/analytics")
@RequiredArgsConstructor
public class AnalyticsController {
private final AnalyticsService analyticsService;
@GetMapping("/dashboard")
public ApiResponse<Map<String, Object>> getDashboard() {
return ApiResponse.ok(analyticsService.getDashboardStats());
}
@GetMapping("/user-funnel")
public ApiResponse<Map<String, Object>> getUserFunnel(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate until) {
return ApiResponse.ok(analyticsService.getUserFunnel(since, until));
}
@GetMapping("/match-funnel")
public ApiResponse<Map<String, Object>> getMatchFunnel(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate until) {
return ApiResponse.ok(analyticsService.getMatchFunnel(since, until));
}
@GetMapping("/daily-summary")
public ApiResponse<List<Map<String, Object>>> getDailySummary(
@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(analyticsService.getDailySummary(days));
}
@GetMapping("/safety")
public ApiResponse<Map<String, Object>> getSafetyStats() {
return ApiResponse.ok(analyticsService.getSafetyStats());
}
// ==================== V1.1 公益项目统计 ====================
@GetMapping("/v11-dashboard")
public ApiResponse<Map<String, Object>> getV11ServiceDashboard() {
return ApiResponse.ok(analyticsService.getV11ServiceDashboard());
}
@GetMapping("/v11/banners")
public ApiResponse<Map<String, Object>> getBannerStats() {
return ApiResponse.ok(analyticsService.getBannerStats());
}
@GetMapping("/v11/service-stations")
public ApiResponse<Map<String, Object>> getServiceStationStats() {
return ApiResponse.ok(analyticsService.getServiceStationStats());
}
@GetMapping("/v11/appointments")
public ApiResponse<Map<String, Object>> getServiceAppointmentStats() {
return ApiResponse.ok(analyticsService.getServiceAppointmentStats());
}
@GetMapping("/v11/counseling")
public ApiResponse<Map<String, Object>> getCounselingStats() {
return ApiResponse.ok(analyticsService.getCounselingStats());
}
@GetMapping("/v11/premarital")
public ApiResponse<Map<String, Object>> getPremaritalStats() {
return ApiResponse.ok(analyticsService.getPremaritalStats());
}
@GetMapping("/v11/content")
public ApiResponse<Map<String, Object>> getContentStats() {
return ApiResponse.ok(analyticsService.getContentStats());
}
@GetMapping("/v11/local-eligibility")
public ApiResponse<Map<String, Object>> getLocalEligibilityStats() {
return ApiResponse.ok(analyticsService.getLocalEligibilityStats());
}
@GetMapping("/v11/service-programs")
public ApiResponse<Map<String, Object>> getServiceProgramStats() {
return ApiResponse.ok(analyticsService.getServiceProgramStats());
}
@GetMapping("/v11/registration-sources")
public ApiResponse<Map<String, Object>> getRegistrationSourceStats() {
return ApiResponse.ok(analyticsService.getRegistrationSourceStats());
}
@GetMapping("/v11/unique-service-users")
public ApiResponse<Map<String, Object>> getUniqueServiceUsersStats() {
return ApiResponse.ok(analyticsService.getUniqueServiceUsersStats());
}
@GetMapping("/v11/effective-connections")
public ApiResponse<Map<String, Object>> getEffectiveConnectionStats() {
return ApiResponse.ok(analyticsService.getEffectiveConnectionStats());
}
}
@@ -0,0 +1,545 @@
package com.danyang.match.platform.analytics;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.match.entity.RecommendationRecord;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.messaging.entity.Conversation;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.relationship.entity.Match;
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
import com.danyang.match.platform.relationship.mapper.MatchMapper;
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
import com.danyang.match.platform.risk.entity.Report;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
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 com.danyang.match.platform.banner.mapper.HomeBannerMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.content.entity.ContentArticle;
import com.danyang.match.platform.service_program.entity.ServiceProgram;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
/**
* Analytics 服务
* 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据
*/
@Service
@RequiredArgsConstructor
public class AnalyticsService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final MatchMapper matchMapper;
private final RelationshipSuccessRequestMapper successRequestMapper;
private final ReportMapper reportMapper;
private final RiskActionMapper riskActionMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ConversationMapper conversationMapper;
private final MatchmakerServiceMapper matchmakerServiceMapper;
private final HomeBannerMapper homeBannerMapper;
private final CounselingServiceMapper counselingServiceMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ServiceAppointmentMapper serviceAppointmentMapper;
private final ActivityMutualConsentMapper activityMutualConsentMapper;
private final ContentArticleMapper contentArticleMapper;
private final ServiceProgramMapper serviceProgramMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final ActivityRegistrationMapper activityRegistrationMapper;
// ==================== Dashboard ====================
public Map<String, Object> getDashboardStats() {
Map<String, Object> stats = new HashMap<>();
stats.putAll(userBasicStats());
stats.putAll(matchFunnelStats());
stats.putAll(safetyStats());
stats.putAll(activityFunnelStats());
return stats;
}
// ==================== 用户漏斗 ====================
public Map<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
if (since == null) since = LocalDate.now().minusMonths(1);
if (until == null) until = LocalDate.now();
Map<String, Object> result = new HashMap<>();
// 各阶段总量
long totalRegistered = userMapper.selectCount(null);
long verifiedCount = userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>()
.ne(UserProfile::getProfileStatus, "rejected"));
long profileApproved = userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>()
.eq(UserProfile::getProfileStatus, "approved"));
long activeUsers = userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()));
result.put("totalRegistered", totalRegistered);
result.put("verifiedCount", verifiedCount);
result.put("profileApproved", profileApproved);
result.put("activeUsers", activeUsers);
// 转化率
result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%");
result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%");
result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%");
// 每日新增趋势
List<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
result.put("dailyTrend", dailyTrend);
// 男女比例
Map<String, Long> genderRatio = userProfileMapper.selectList(
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
.stream()
.collect(Collectors.groupingBy(
UserProfile::getGender, Collectors.counting()));
result.put("genderRatio", genderRatio);
// 年龄分布
List<UserProfile> profiles = userProfileMapper.selectList(
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
Map<String, Long> ageDist = new LinkedHashMap<>();
for (int i = 0; i < breaks.size() - 1; i++) {
int min = breaks.get(i);
int max = breaks.get(i + 1);
long count = profiles.stream()
.filter(p -> p.getBirthDate() != null)
.filter(p -> {
int age = LocalDate.now().getYear() - p.getBirthDate().getYear();
return age >= min && age < max;
})
.count();
ageDist.put(min + "-" + (max - 1), count);
}
result.put("ageDistribution", ageDist);
return result;
}
// ==================== Match漏斗 ====================
public Map<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
if (since == null) since = LocalDate.now().minusMonths(1);
if (until == null) until = LocalDate.now();
Map<String, Object> result = new HashMap<>();
// 总量
long totalRecommended = recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>()
.isNotNull(RecommendationRecord::getShownAt));
long totalLiked = recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>()
.eq(RecommendationRecord::getAction, "liked"));
long totalMatches = matchMapper.selectCount(null);
long chatsStarted = conversationMapper.selectCount(null);
long gettingToKnow = matchMapper.selectCount(
new LambdaQueryWrapper<Match>()
.eq(Match::getStatus, "getting_to_know"));
long successPairs = successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>()
.eq(RelationshipSuccessRequest::getStatus, "confirmed"));
result.put("totalRecommended", totalRecommended);
result.put("totalLiked", totalLiked);
result.put("totalMatches", totalMatches);
result.put("chatsStarted", chatsStarted);
result.put("gettingToKnow", gettingToKnow);
result.put("successPairs", successPairs);
// 各阶段转化率
result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%");
result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%");
result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%");
result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%");
// 每日新增匹配趋势
List<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
result.put("dailyTrend", dailyTrend);
return result;
}
// ==================== 活动漏斗 ====================
public Map<String, Object> getActivityFunnel() {
Map<String, Object> result = new HashMap<>();
// 活动数量(简化:统计 distinct activity_id)
long activityCount = userMapper.selectCount(
new LambdaQueryWrapper<>() {}); // placeholder
// 报名数、通过数、签到数需要从 activity_registrations 统计
// 简化处理
result.put("totalActivities", 0);
result.put("totalRegistrations", 0);
result.put("approvedRegistrations", 0);
result.put("checkedInCount", 0);
return result;
}
// ==================== 安全统计 ====================
public Map<String, Object> getSafetyStats() {
Map<String, Object> stats = new HashMap<>();
long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
long highRiskUsers = riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.in(RiskAction::getActionType,
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
.eq(RiskAction::getStatus, "active"));
long bannedUsers = riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
.eq(RiskAction::getStatus, "active"));
stats.put("pendingReports", pendingReports);
stats.put("resolvedReports", resolvedReports);
stats.put("highRiskUsers", highRiskUsers);
stats.put("bannedUsers", bannedUsers);
return stats;
}
// ==================== 每日汇总 ====================
public List<Map<String, Object>> getDailySummary(int days) {
List<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
for (int i = 0; i < days; i++) {
LocalDate date = today.minusDays(days - 1 - i);
Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> day = new HashMap<>();
day.put("date", date.toString());
day.put("newUsers", countEntityByDateRange(start, end));
day.put("newMatches", countMatchByDateRange(start, end));
day.put("newConversations", countConversationByDateRange(start, end));
day.put("newReports", countReportByDateRange(start, end));
summary.add(day);
}
return summary;
}
// ==================== 私有辅助方法 ====================
private Map<String, Object> userBasicStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalUsers", userMapper.selectCount(null));
s.put("verifiedUsers", userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
s.put("approvedProfiles", userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
s.put("activeUsers7d", userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
return s;
}
private Map<String, Object> matchFunnelStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalLikes", recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
s.put("totalMatches", matchMapper.selectCount(null));
s.put("gettingToKnow", matchMapper.selectCount(
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
s.put("successPairs", successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
return s;
}
private Map<String, Object> safetyStats() {
Map<String, Object> s = new HashMap<>();
s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING));
s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED));
s.put("highRiskUsers", riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
.eq(RiskAction::getStatus, "active")));
s.put("bannedUsers", riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
.eq(RiskAction::getStatus, "active")));
return s;
}
private Map<String, Object> activityFunnelStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalActivities", 0);
s.put("totalRegistrations", 0);
return s;
}
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
List<Map<String, Object>> trend = new ArrayList<>();
LocalDate current = since;
while (!current.isAfter(until)) {
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> point = new HashMap<>();
point.put("date", current.toString());
point.put("value", countEntityByDateRange(start, end));
trend.add(point);
current = current.plusDays(1);
}
return trend;
}
// ==================== V1.1 公益项目统计 ====================
public Map<String, Object> getBannerStats() {
Map<String, Object> stats = new HashMap<>();
long totalBanners = homeBannerMapper.selectCount(null);
Instant now = Instant.now();
Instant finalNow = now;
long activeBanners = homeBannerMapper.selectCount(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.danyang.match.platform.banner.entity.HomeBanner>()
.eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active")
.le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow)
.ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow)
);
stats.put("totalBanners", totalBanners);
stats.put("activeBanners", activeBanners);
stats.put("impressions", 0);
stats.put("clicks", 0);
return stats;
}
public Map<String, Object> getServiceStationStats() {
Map<String, Object> stats = new HashMap<>();
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>()
.isNotNull(ServiceAppointment::getServiceLocationId)
.ne(ServiceAppointment::getStatus, "cancelled")
);
Set<String> uniqueLocations = new HashSet<>();
for (ServiceAppointment a : appointments) {
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
}
stats.put("activeStations", uniqueLocations.size());
stats.put("totalAppointments", appointments.size());
return stats;
}
public Map<String, Object> getServiceAppointmentStats() {
Map<String, Object> stats = new HashMap<>();
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt)
);
stats.put("total", all.size());
stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count());
stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count());
stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count());
stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count());
return stats;
}
public Map<String, Object> getCounselingStats() {
Map<String, Object> stats = new HashMap<>();
List<CounselingService> all = counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt)
);
stats.put("total", all.size());
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count());
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count());
stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count());
return stats;
}
public Map<String, Object> getPremaritalStats() {
Map<String, Object> stats = new HashMap<>();
List<CounselingService> all = counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingService>()
.eq(CounselingService::getServiceType, "premarital_guidance")
.isNull(CounselingService::getDeletedAt)
);
stats.put("totalRequests", all.size());
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count());
return stats;
}
public Map<String, Object> getContentStats() {
Map<String, Object> stats = new HashMap<>();
List<ContentArticle> articles = contentArticleMapper.selectList(
new LambdaQueryWrapper<ContentArticle>()
.eq(ContentArticle::getStatus, "published")
.isNull(ContentArticle::getDeletedAt)
);
long totalViews = articles.stream()
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
stats.put("totalPublishedArticles", articles.size());
stats.put("totalViews", totalViews);
articles.sort((a, b) -> Integer.compare(
b.getViewCount() != null ? b.getViewCount() : 0,
a.getViewCount() != null ? a.getViewCount() : 0));
List<Map<String, Object>> top = articles.stream().limit(5)
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
.toList();
stats.put("topArticles", top);
return stats;
}
public Map<String, Object> getLocalEligibilityStats() {
Map<String, Object> stats = new HashMap<>();
List<LocalEligibility> all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>());
stats.put("total", all.size());
stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count());
stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count());
stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count());
return stats;
}
public Map<String, Object> getServiceProgramStats() {
Map<String, Object> stats = new HashMap<>();
List<ServiceProgram> programs = serviceProgramMapper.selectList(
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(null);
stats.put("totalPrograms", programs.size());
stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count());
stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count());
stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count());
return stats;
}
public Map<String, Object> getRegistrationSourceStats() {
Map<String, Object> stats = new HashMap<>();
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
Map<String, Long> bySource = regs.stream()
.collect(java.util.stream.Collectors.groupingBy(
r -> r.getSource() != null ? r.getSource() : "unknown",
java.util.stream.Collectors.counting()));
stats.put("totalRegistrations", regs.size());
stats.put("bySource", bySource);
return stats;
}
public Map<String, Object> getUniqueServiceUsersStats() {
Map<String, Object> stats = new HashMap<>();
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt))
.stream().map(CounselingService::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> pu = serviceProgramMemberMapper.selectList(
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>())
.stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet());
stats.put("counselingUniqueUsers", cu.size());
stats.put("appointmentUniqueUsers", au.size());
stats.put("programUniqueUsers", pu.size());
stats.put("eligibilityUniqueUsers", eu.size());
Set<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
stats.put("totalUniqueServiceUsers", all.size());
return stats;
}
public Map<String, Object> getEffectiveConnectionStats() {
Map<String, Object> stats = new HashMap<>();
long activeMatches = matchMapper.selectCount(
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
long matchesWithChat = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
long successRequests = successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
stats.put("activeMatches", activeMatches);
stats.put("matchesWithChat", matchesWithChat);
stats.put("successConnections", successRequests);
double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0;
stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0);
return stats;
}
public Map<String, Object> getV11ServiceDashboard() {
Map<String, Object> d = new HashMap<>();
d.put("banners", getBannerStats());
d.put("serviceStations", getServiceStationStats());
d.put("serviceAppointments", getServiceAppointmentStats());
d.put("counseling", getCounselingStats());
d.put("premaritalGuidance", getPremaritalStats());
d.put("content", getContentStats());
d.put("localEligibility", getLocalEligibilityStats());
d.put("servicePrograms", getServiceProgramStats());
d.put("registrationSources", getRegistrationSourceStats());
d.put("uniqueServiceUsers", getUniqueServiceUsersStats());
d.put("effectiveConnections", getEffectiveConnectionStats());
return d;
}
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
List<Map<String, Object>> trend = new ArrayList<>();
LocalDate current = since;
while (!current.isAfter(until)) {
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> point = new HashMap<>();
point.put("date", current.toString());
point.put("value", countMatchByDateRange(start, end));
trend.add(point);
current = current.plusDays(1);
}
return trend;
}
private long countEntityByDateRange(Instant start, Instant end) {
return userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getCreatedAt, start)
.le(User::getCreatedAt, end));
}
private long countMatchByDateRange(Instant start, Instant end) {
return matchMapper.selectCount(
new LambdaQueryWrapper<Match>()
.ge(Match::getMatchedAt, start)
.le(Match::getMatchedAt, end));
}
private long countConversationByDateRange(Instant start, Instant end) {
return conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>()
.ge(Conversation::getCreatedAt, start)
.le(Conversation::getCreatedAt, end));
}
private long countReportByDateRange(Instant start, Instant end) {
return reportMapper.selectCount(
new LambdaQueryWrapper<Report>()
.ge(Report::getCreatedAt, start)
.le(Report::getCreatedAt, end));
}
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.appeal.controller;
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
import com.danyang.match.platform.appeal.service.AppealService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 申诉控制器(用户端)
*/
@RestController
@RequestMapping("/appeals")
@RequiredArgsConstructor
public class AppealController {
private final AppealService appealService;
private final JwtTokenService jwtTokenService;
/**
* 获取账号状态(含是否可申诉)
* GET /api/v1/appeals/account-status
*/
@GetMapping("/account-status")
public ApiResponse<Map<String, Object>> getAccountStatus(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(appealService.getAccountStatus(userId));
}
/**
* 提交申诉
* POST /api/v1/appeals
*/
@PostMapping
public ApiResponse<Map<String, Object>> submitAppeal(
@RequestHeader("Authorization") String authHeader,
@RequestBody AppealSubmitDTO body) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(appealService.submitAppeal(userId, body));
}
/**
* 获取我的申诉列表
* GET /api/v1/appeals/my
*/
@GetMapping("/my")
public ApiResponse<Object> getMyAppeals(@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(appealService.getMyAppeals(userId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,89 @@
package com.danyang.match.platform.appeal.controller;
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
import com.danyang.match.platform.appeal.service.AppealService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 申诉管理控制器(Operator端)
*/
@RestController
@RequestMapping("/operator/appeals")
@RequiredArgsConstructor
public class AppealOperatorController {
private final AppealService appealService;
private final JwtTokenService jwtTokenService;
/**
* 获取待处理申诉
* GET /api/v1/operator/appeals/pending
*/
@GetMapping("/pending")
public ApiResponse<Object> getPendingAppeals(@RequestHeader("Authorization") String authHeader) {
return ApiResponse.ok(appealService.getPendingAppeals());
}
/**
* 获取申诉详情
* GET /api/v1/operator/appeals/{id}
*/
@GetMapping("/{id}")
public ApiResponse<Object> getAppealDetail(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id) {
return ApiResponse.ok(appealService.getAppealDetail(id));
}
/**
* 处理申诉
* POST /api/v1/operator/appeals/{id}/process
* Body: { "decision": "approve|reject|escalate", "decisionNote": "..." }
*/
@PostMapping("/{id}/process")
public ApiResponse<Map<String, Object>> processAppeal(
@RequestHeader("Authorization") String authHeader,
@PathVariable String id,
@RequestBody AppealProcessDTO body) {
String operatorId = extractOperatorId(authHeader);
return ApiResponse.ok(appealService.processAppeal(id, body, operatorId));
}
/**
* 获取申诉统计
* GET /api/v1/operator/appeals/stats
*/
@GetMapping("/stats")
public ApiResponse<Map<String, Object>> getAppealStats(@RequestHeader("Authorization") String authHeader) {
return ApiResponse.ok(appealService.getAppealStats());
}
/**
* 获取用户所有申诉
* GET /api/v1/operator/appeals/users/{userId}
*/
@GetMapping("/users/{userId}")
public ApiResponse<Object> getUserAppeals(
@RequestHeader("Authorization") String authHeader,
@PathVariable String userId) {
return ApiResponse.ok(appealService.getUserAppeals(userId));
}
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader);
return token;
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,52 @@
package com.danyang.match.platform.appeal.dto;
import java.time.Instant;
/**
* 申诉列表项 DTO
*/
public class AppealItemDTO {
private String id;
private String userId;
private String userName;
private String appealType;
private String appealTypeText;
private String relatedActionId;
private String reason;
private String evidence;
private String contactMethod;
private String status;
private Instant createdAt;
private String decisionNote;
private String reviewedBy;
private Instant reviewedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getAppealType() { return appealType; }
public void setAppealType(String appealType) { this.appealType = appealType; }
public String getAppealTypeText() { return appealTypeText; }
public void setAppealTypeText(String appealTypeText) { this.appealTypeText = appealTypeText; }
public String getRelatedActionId() { return relatedActionId; }
public void setRelatedActionId(String relatedActionId) { this.relatedActionId = relatedActionId; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public String getEvidence() { return evidence; }
public void setEvidence(String evidence) { this.evidence = evidence; }
public String getContactMethod() { return contactMethod; }
public void setContactMethod(String contactMethod) { this.contactMethod = contactMethod; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public String getDecisionNote() { return decisionNote; }
public void setDecisionNote(String decisionNote) { this.decisionNote = decisionNote; }
public String getReviewedBy() { return reviewedBy; }
public void setReviewedBy(String reviewedBy) { this.reviewedBy = reviewedBy; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
}
@@ -0,0 +1,14 @@
package com.danyang.match.platform.appeal.dto;
/**
* 申诉处理请求 DTO
*/
public class AppealProcessDTO {
private String decision; // approve, reject, escalate
private String decisionNote;
public String getDecision() { return decision; }
public void setDecision(String decision) { this.decision = decision; }
public String getDecisionNote() { return decisionNote; }
public void setDecisionNote(String decisionNote) { this.decisionNote = decisionNote; }
}
@@ -0,0 +1,23 @@
package com.danyang.match.platform.appeal.dto;
/**
* 申诉提交 DTO
*/
public class AppealSubmitDTO {
private String appealType; // ban_appeal, suspend_appeal
private String relatedActionId;
private String reason;
private String evidence; // JSON array of file keys
private String contactMethod;
public String getAppealType() { return appealType; }
public void setAppealType(String appealType) { this.appealType = appealType; }
public String getRelatedActionId() { return relatedActionId; }
public void setRelatedActionId(String relatedActionId) { this.relatedActionId = relatedActionId; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public String getEvidence() { return evidence; }
public void setEvidence(String evidence) { this.evidence = evidence; }
public String getContactMethod() { return contactMethod; }
public void setContactMethod(String contactMethod) { this.contactMethod = contactMethod; }
}
@@ -0,0 +1,27 @@
package com.danyang.match.platform.appeal.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 申诉记录
* 核心 Invariant: 申诉仅针对 ban/temporary_suspend 类处置,不支持申诉为 warning/feature_limit
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("appeals")
public class Appeal extends BaseEntity {
private String userId;
private String appealType; // ban_appeal, suspend_appeal
private String relatedActionId; // 关联的 risk_action id
private String reason;
private String evidence; // JSON array of file keys
private String contactMethod; // 用户可提供的联系方式
private String status; // pending, reviewing, approved, rejected, escalated
private String decisionNote;
private String reviewedBy;
private java.time.Instant reviewedAt;
}
@@ -0,0 +1,36 @@
package com.danyang.match.platform.appeal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.appeal.entity.Appeal;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface AppealMapper extends BaseMapper<Appeal> {
@Select("SELECT * FROM appeals WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT 20")
List<Appeal> findByUserId(String userId);
@Select("SELECT * FROM appeals WHERE status = 'pending' ORDER BY created_at ASC")
List<Appeal> findPending();
@Select("SELECT * FROM appeals WHERE status = #{status} ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
List<Appeal> findByStatusPaged(@Param("status") String status,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("SELECT COUNT(*) FROM appeals WHERE status = #{status} AND deleted_at IS NULL")
long countByStatus(@Param("status") String status);
/**
* 防重:同一用户对同一 action 只能有一次 pending/reviewing 申诉
*/
@Select("SELECT * FROM appeals WHERE user_id = #{userId} AND related_action_id = #{actionId} " +
"AND status IN ('pending', 'reviewing') AND deleted_at IS NULL LIMIT 1")
Optional<Appeal> findActiveByUserAndAction(@Param("userId") String userId,
@Param("actionId") String actionId);
}
@@ -0,0 +1,341 @@
package com.danyang.match.platform.appeal.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.appeal.dto.AppealItemDTO;
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
import com.danyang.match.platform.appeal.entity.Appeal;
import com.danyang.match.platform.appeal.mapper.AppealMapper;
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.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 lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
/**
* 申诉服务
*
* 核心 Invariant:
* - 申诉功能受 FeatureFlag 控制,默认关闭
* - 仅 ban / temporary_suspend 类处置可申诉,warning/feature_limit 不可申诉
* - 同一用户对同一 action 只能有一次 pending 申诉(幂等)
* - 申诉通过 → 解除对应风险处置
* - 申诉被拒 → 维持原处置
* - 升级人工 → 标记 escalated,通知风控员
*/
@Service
@RequiredArgsConstructor
public class AppealService {
private static final Logger log = LoggerFactory.getLogger(AppealService.class);
private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService;
private final FeatureFlagConfig featureFlagConfig;
// ==================== 用户端 ====================
/**
* 检查是否支持申诉(FeatureFlag + 账号状态)
*/
public Map<String, Object> getAccountStatus(String userId) {
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(userId);
Map<String, Object> result = new HashMap<>();
if (activeActions.isEmpty()) {
result.put("hasRestriction", false);
result.put("canAppeal", false);
result.put("appealEnabled", featureFlagConfig.isAppealEnabled());
result.put("accountStatus", "normal");
return result;
}
// 检查是否有可申诉的处置
boolean hasAppealable = activeActions.stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()) ||
Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
result.put("hasRestriction", true);
result.put("canAppeal", hasAppealable && featureFlagConfig.isAppealEnabled());
result.put("appealEnabled", featureFlagConfig.isAppealEnabled());
result.put("accountStatus", calculateAccountStatus(activeActions));
result.put("actions", activeActions.stream().map(a -> {
Map<String, Object> m = new HashMap<>();
m.put("actionType", a.getActionType());
m.put("reason", a.getReason());
m.put("effectiveAt", a.getEffectiveAt());
m.put("expiresAt", a.getExpiresAt());
m.put("canAppeal", Constants.RISK_ACTION_BAN.equals(a.getActionType()) ||
Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
return m;
}).collect(Collectors.toList()));
return result;
}
/**
* 提交申诉
*/
@Transactional
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
// FeatureFlag 检查
if (!featureFlagConfig.isAppealEnabled()) {
throw new RuntimeException("申诉功能暂未开放");
}
// 验证关联的 risk_action 存在且仍有效
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
if (targetAction == null) {
throw new RuntimeException("关联的处置记录不存在");
}
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
throw new RuntimeException("该处置已失效,无法申诉");
}
if (!userId.equals(targetAction.getUserId())) {
throw new RuntimeException("无权对此处置提交申诉");
}
// 必须是 ban 或 suspend 类型
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
throw new RuntimeException("当前处置类型不支持申诉");
}
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
if (existing.isPresent()) {
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
}
Appeal appeal = new Appeal();
appeal.setUserId(userId);
appeal.setAppealType(dto.getAppealType());
appeal.setRelatedActionId(dto.getRelatedActionId());
appeal.setReason(dto.getReason());
appeal.setEvidence(dto.getEvidence());
appeal.setContactMethod(dto.getContactMethod());
appeal.setStatus(Constants.APPEAL_STATUS_PENDING);
appealMapper.insert(appeal);
saveAuditLog(userId, "SUBMIT_APPEAL", "appeal", appeal.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("appealId", appeal.getId());
result.put("status", appeal.getStatus());
result.put("submittedAt", Instant.now());
return result;
}
/**
* 获取我的申诉列表
*/
public List<AppealItemDTO> getMyAppeals(String userId) {
return appealMapper.findByUserId(userId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
// ==================== 管理端 ====================
/**
* 获取待处理申诉列表
*/
public List<AppealItemDTO> getPendingAppeals() {
return appealMapper.findPending().stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
/**
* 获取申诉详情
*/
public AppealItemDTO getAppealDetail(String appealId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null) {
throw new RuntimeException("申诉不存在");
}
return toDTO(appeal);
}
/**
* 处理申诉
*/
@Transactional
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null) {
throw new RuntimeException("申诉不存在");
}
if (!Constants.APPEAL_STATUS_PENDING.equals(appeal.getStatus()) &&
!Constants.APPEAL_STATUS_REVIEWING.equals(appeal.getStatus())) {
throw new RuntimeException("该申诉已处理完毕");
}
appeal.setStatus(Constants.APPEAL_STATUS_REVIEWING);
appealMapper.updateById(appeal);
// 根据决策执行
switch (dto.getDecision()) {
case "approve" -> approveAppeal(appeal, dto.getDecisionNote(), operatorId);
case "reject" -> rejectAppeal(appeal, dto.getDecisionNote(), operatorId);
case "escalate" -> escalateAppeal(appeal, dto.getDecisionNote(), operatorId);
default -> throw new RuntimeException("无效的申诉决策");
}
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
Map<String, Object> result = new HashMap<>();
result.put("appealId", appealId);
result.put("decision", dto.getDecision());
result.put("processedAt", Instant.now());
return result;
}
/**
* 获取申诉统计
*/
public Map<String, Object> getAppealStats() {
Map<String, Object> stats = new HashMap<>();
stats.put("pendingCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_PENDING));
stats.put("reviewingCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_REVIEWING));
stats.put("approvedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_APPROVED));
stats.put("rejectedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_REJECTED));
stats.put("escalatedCount", appealMapper.countByStatus(Constants.APPEAL_STATUS_ESCALATED));
return stats;
}
/**
* 获取用户所有申诉(含已处理的)
*/
public List<AppealItemDTO> getUserAppeals(String userId) {
return appealMapper.findByUserId(userId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
// ==================== 私有辅助方法 ====================
private void approveAppeal(Appeal appeal, String note, String operatorId) {
appeal.setStatus(Constants.APPEAL_STATUS_APPROVED);
appeal.setDecisionNote(note);
appeal.setReviewedBy(operatorId);
appeal.setReviewedAt(Instant.now());
appealMapper.updateById(appeal);
// 解除对应风险处置
if (appeal.getRelatedActionId() != null) {
RiskAction ra = riskActionMapper.selectById(appeal.getRelatedActionId());
if (ra != null) {
ra.setStatus("resolved");
ra.setUpdatedAt(Instant.now());
riskActionMapper.updateById(ra);
}
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
}
// 通知用户
UserProfile profile = userProfileMapper.findByUserId(appeal.getUserId()).orElse(null);
String userName = profile != null ? profile.getDisplayName() : "用户";
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过",
"您的申诉已审核通过,相关限制已解除。",
"appeal", appeal.getId(),
"appeal_approved:" + appeal.getId());
}
private void rejectAppeal(Appeal appeal, String note, String operatorId) {
appeal.setStatus(Constants.APPEAL_STATUS_REJECTED);
appeal.setDecisionNote(note);
appeal.setReviewedBy(operatorId);
appeal.setReviewedAt(Instant.now());
appealMapper.updateById(appeal);
// 维持原处置,不变更 risk_action
log.info("申诉驳回,维持原处置: appealId={}", appeal.getId());
// 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核结果",
"您的申诉未通过审核,原处置维持。如有疑问请联系客服。",
"appeal", appeal.getId(),
"appeal_rejected:" + appeal.getId());
}
private void escalateAppeal(Appeal appeal, String note, String operatorId) {
appeal.setStatus(Constants.APPEAL_STATUS_ESCALATED);
appeal.setDecisionNote("升级人工处理:" + note);
appeal.setReviewedBy(operatorId);
appeal.setReviewedAt(Instant.now());
appealMapper.updateById(appeal);
log.info("申诉升级人工处理: appealId={}", appeal.getId());
// 通知风控员
notificationService.send(operatorId, Constants.NOTIFICATION_TYPE_SYSTEM,
"申诉升级处理",
"用户申诉已升级人工处理,请核实后决策。",
"appeal_escalated", appeal.getId(),
"appeal_escalated:" + appeal.getId());
}
private AppealItemDTO toDTO(Appeal appeal) {
AppealItemDTO dto = new AppealItemDTO();
dto.setId(appeal.getId());
dto.setUserId(appeal.getUserId());
dto.setAppealType(appeal.getAppealType());
dto.setAppealTypeText(getAppealTypeText(appeal.getAppealType()));
dto.setRelatedActionId(appeal.getRelatedActionId());
dto.setReason(appeal.getReason());
dto.setEvidence(appeal.getEvidence());
dto.setContactMethod(appeal.getContactMethod());
dto.setStatus(appeal.getStatus());
dto.setCreatedAt(appeal.getCreatedAt());
dto.setDecisionNote(appeal.getDecisionNote());
dto.setReviewedBy(appeal.getReviewedBy());
dto.setReviewedAt(appeal.getReviewedAt());
// 填充用户名
UserProfile profile = userProfileMapper.findByUserId(appeal.getUserId()).orElse(null);
dto.setUserName(profile != null ? profile.getDisplayName() : "");
return dto;
}
private String getAppealTypeText(String type) {
return "ban_appeal".equals(type) ? "封禁申诉" : "暂停申诉";
}
private String calculateAccountStatus(List<RiskAction> actions) {
boolean hasBan = actions.stream().anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()));
if (hasBan) return "banned";
boolean hasSuspend = actions.stream().anyMatch(a -> Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
if (hasSuspend) return "suspended";
return "limited";
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,29 @@
package com.danyang.match.platform.audit.controller;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 审计日志控制器
*/
@RestController
@RequestMapping("/operator/audit")
@RequiredArgsConstructor
public class AuditLogController {
private final AuditLogService auditLogService;
@GetMapping("/logs")
public ApiResponse<Map<String, Object>> listLogs(
@RequestParam(required = false) String action,
@RequestParam(required = false) String resourceType,
@RequestParam(required = false) String operatorId,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) {
return ApiResponse.ok(auditLogService.listAuditLogs(action, resourceType, operatorId, page, pageSize));
}
}
@@ -0,0 +1,22 @@
package com.danyang.match.platform.audit.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("audit_logs")
public class AuditLog extends BaseEntity {
private String operatorId;
private String organizationId;
private String action;
private String resourceType;
private String resourceId;
private String beforeData;
private String afterData;
private String ip;
private String userAgent;
}
@@ -0,0 +1,24 @@
package com.danyang.match.platform.audit.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 敏感数据访问日志
* 记录 Operator 对身份证、本地资格佐证、咨询备注等敏感数据的访问行为
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sensitive_access_logs")
public class SensitiveAccessLog extends BaseEntity {
private String operatorId;
private String userId;
/** 被访问的数据类型:id_number / local_eligibility_evidence / counseling_sensitive_note / ... */
private String dataType;
/** 访问原因 */
private String reason;
private String ip;
}
@@ -0,0 +1,9 @@
package com.danyang.match.platform.audit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLog> {
}
@@ -0,0 +1,9 @@
package com.danyang.match.platform.audit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SensitiveAccessLogMapper extends BaseMapper<SensitiveAccessLog> {
}
@@ -0,0 +1,64 @@
package com.danyang.match.platform.audit.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuditLogService {
private final AuditLogMapper auditLogMapper;
public Map<String, Object> listAuditLogs(String action, String resourceType,
String operatorId, Integer page, Integer pageSize) {
// Count query without ORDER BY (PostgreSQL requirement)
LambdaQueryWrapper<AuditLog> countQuery = new LambdaQueryWrapper<>();
if (action != null && !action.isBlank()) countQuery.eq(AuditLog::getAction, action);
if (resourceType != null && !resourceType.isBlank()) countQuery.eq(AuditLog::getResourceType, resourceType);
if (operatorId != null && !operatorId.isBlank()) countQuery.eq(AuditLog::getOperatorId, operatorId);
long total = auditLogMapper.selectCount(countQuery);
// Data query with ORDER BY
LambdaQueryWrapper<AuditLog> dataQuery = new LambdaQueryWrapper<AuditLog>()
.orderByDesc(AuditLog::getCreatedAt);
if (action != null && !action.isBlank()) dataQuery.eq(AuditLog::getAction, action);
if (resourceType != null && !resourceType.isBlank()) dataQuery.eq(AuditLog::getResourceType, resourceType);
if (operatorId != null && !operatorId.isBlank()) dataQuery.eq(AuditLog::getOperatorId, operatorId);
int limit = pageSize != null ? pageSize : 20;
int pageNum = page != null ? page : 0;
List<AuditLog> logs = auditLogMapper.selectPage(new Page<>(pageNum, limit), dataQuery).getRecords();
List<Map<String, Object>> items = logs.stream().map(log -> {
Map<String, Object> m = new HashMap<>();
m.put("id", log.getId());
m.put("operatorId", log.getOperatorId());
m.put("action", log.getAction());
m.put("resourceType", log.getResourceType());
m.put("resourceId", log.getResourceId());
m.put("ip", log.getIp());
m.put("userAgent", log.getUserAgent());
m.put("createdAt", log.getCreatedAt());
return m;
}).collect(java.util.stream.Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("list", items);
result.put("total", total);
result.put("page", pageNum);
result.put("pageSize", limit);
return result;
}
public Map<String, Object> getSensitiveAccessLogs(String operatorId, Integer page, Integer pageSize) {
return listAuditLogs(null, null, operatorId, page, pageSize);
}
}
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.service.AuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtTokenService jwtTokenService;
/**
* 微信登录
* POST /api/v1/auth/wechat/login
* Body: { "code": "wx_login_code" }
*/
@PostMapping("/wechat/login")
public ApiResponse<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.isBlank()) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空");
}
return ApiResponse.ok(authService.wechatLogin(code));
}
/**
* 绑定手机号
* POST /api/v1/auth/phone/bind
* Header: Authorization: Bearer <token>
* Body: { "phone": "13800138000" }
*/
@PostMapping("/phone/bind")
public ApiResponse<Void> bindPhone(@RequestHeader("Authorization") String authHeader,
@RequestBody Map<String, String> body) {
String userId = extractUserId(authHeader);
String phone = body.get("phone");
if (phone == null || phone.isBlank()) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空");
}
authService.bindPhone(userId, phone);
return ApiResponse.ok();
}
/**
* 刷新 Token
* POST /api/v1/auth/refresh
* Body: { "userId": "...", "refreshToken": "..." }
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> body) {
String userId = body.get("userId");
String refreshToken = body.get("refreshToken");
if (userId == null || refreshToken == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失");
}
return ApiResponse.ok(authService.refreshToken(userId, refreshToken));
}
/**
* 登出
* POST /api/v1/auth/logout
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
String token = extractToken(authHeader);
authService.logout(userId, token);
return ApiResponse.ok();
}
/**
* 获取当前用户状态
* GET /api/v1/me/status
*/
@GetMapping("/me/status")
public ApiResponse<Map<String, Object>> getMeStatus(@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(authService.getMeStatus(userId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,25 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.shared.dto.ApiResponse;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 统一 Token 提取工具 Controller
* 提供公共的 token 解析逻辑,供各业务 Controller 复用
*/
@RestController
public class TokenUtils {
/**
* 从 Authorization header 中提取 token
*/
public static String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,49 @@
package com.danyang.match.platform.auth.provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Mock 微信登录 Provider(开发/测试环境使用)
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@Value("${wechat.app-id:dev-app-id}")
private String appId;
// 模拟 openid 映射
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
@Override
public Map<String, String> getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code);
// 模拟:相同 code 返回相同 openid
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
return Map.of(
"openid", openid,
"unionid", "mock_unionid_" + UUID.randomUUID().toString().substring(0, 8),
"session_key", "mock_session_key",
"appid", appId
);
}
@Override
public boolean isMock() {
return true;
}
}
@@ -0,0 +1,25 @@
package com.danyang.match.platform.auth.provider;
import java.util.Map;
/**
* 微信登录 Provider 接口
* 支持 Mock 实现用于开发环境
*/
public interface WechatLoginProvider {
/**
* 通过微信 code 获取 openid 和 unionid
*
* @param code 微信登录临时凭证
* @return openid, unionid(可为空), sessionKey
*/
Map<String, String> getCodeSession(String code);
/**
* 判断是否为 mock provider
*/
default boolean isMock() {
return false;
}
}
@@ -0,0 +1,214 @@
package com.danyang.match.platform.auth.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.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserMapper userMapper;
private final WechatLoginProvider wechatLoginProvider;
private final JwtTokenService jwtTokenService;
private final StringRedisTemplate redisTemplate;
private final AuditLogMapper auditLogMapper;
private final LocalEligibilityMapper localEligibilityMapper;
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessTokenExpireMs;
/**
* 微信登录
* 新用户自动创建账号,老用户直接返回 token
*/
@Transactional
public Map<String, Object> wechatLogin(String code) {
Map<String, String> session = wechatLoginProvider.getCodeSession(code);
String openid = session.get("openid");
String unionid = session.get("unionid");
if (openid == null || openid.isBlank()) {
throw new RuntimeException("微信登录失败:无法获取 openid");
}
// 查找或创建用户
User user = userMapper.findByWechatOpenid(openid).orElseGet(() -> {
User newUser = new User();
newUser.setWechatOpenid(openid);
newUser.setWechatUnionid(unionid);
newUser.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
newUser.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
newUser.setRecommendationEnabled(true);
userMapper.insert(newUser);
log.info("新用户注册: userId={}", newUser.getId());
return newUser;
});
// 检查账号状态
checkAccountStatus(user);
// 更新最后登录时间
user.setLastLoginAt(java.time.Instant.now());
userMapper.updateById(user);
// 签发 Token
String accessToken = jwtTokenService.generateAccessToken(user.getId(), null);
String refreshToken = jwtTokenService.generateRefreshToken(user.getId());
// 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
// 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
result.put("userId", user.getId());
result.put("isNewUser", user.getPhone() == null);
return result;
}
/**
* 绑定手机号
*/
@Transactional
public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw new RuntimeException("该手机号已被其他账号绑定");
}
});
user.setPhone(phoneCode);
userMapper.updateById(user);
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
log.info("用户绑定手机: userId={}, phone={}", userId, maskPhone(phoneCode));
}
/**
* 刷新 Token
*/
public Map<String, Object> refreshToken(String userId, String refreshToken) {
// 验证 Redis 中存储的 refresh token
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效或已过期");
}
// 验证 JWT
if (!jwtTokenService.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
}
// 签发新 Token
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
return result;
}
/**
* 登出
*/
public void logout(String userId, String token) {
// 将 accessToken 加入黑名单,立即失效
jwtTokenService.blacklistToken(token);
redisTemplate.delete("auth:refresh_token:" + userId);
saveAuditLog(userId, null, "LOGOUT", "user", userId, null, null);
log.info("用户登出: userId={}", userId);
}
/**
* 获取当前用户状态
*/
public Map<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId());
status.put("accountStatus", user.getAccountStatus());
status.put("relationshipStatus", user.getRelationshipStatus());
status.put("recommendationEnabled", user.getRecommendationEnabled());
status.put("phoneBound", user.getPhone() != null && !user.getPhone().isBlank());
status.put("verified", false); // Phase 03 填充
status.put("profileStatus", "not_started"); // Phase 04 填充
status.put("reviewStatus", "not_submitted"); // Phase 05 填充
// V1.1: 本地资格状态
com.danyang.match.platform.local_eligibility.entity.LocalEligibility el =
localEligibilityMapper.findByUserId(userId).orElse(null);
if (el != null) {
status.put("localEligibilityStatus", el.getVerificationStatus());
status.put("localEligibilityType", el.getEligibilityType());
status.put("localEligibilityVerifiedAt", el.getVerifiedAt());
} else {
status.put("localEligibilityStatus", "not_declared");
}
return status;
}
private void checkAccountStatus(User user) {
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已被封禁");
}
if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已暂停");
}
}
private void saveAuditLog(String userId, String operatorId, String action,
String resourceType, String resourceId,
String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return "****";
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}
@@ -0,0 +1,191 @@
package com.danyang.match.platform.auth.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.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.mapper.PermissionMapper;
import com.danyang.match.platform.permission.entity.Permission;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class OperatorAuthService {
private static final Logger log = LoggerFactory.getLogger(OperatorAuthService.class);
// 登录失败锁定参数
private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final Duration LOCKOUT_DURATION = Duration.ofMinutes(15);
private final OperatorMapper operatorMapper;
private final PermissionMapper permissionMapper;
private final JwtTokenService jwtTokenService;
private final StringRedisTemplate redisTemplate;
private final AuditLogMapper auditLogMapper;
private final PasswordEncoder passwordEncoder;
/**
* Operator 登录(含失败锁定)
*/
public Map<String, Object> login(String username, String password) {
Operator operator = operatorMapper.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
// 检查账号是否已被锁定
String lockKey = "auth:operator_login_lock:" + operator.getId();
String lockedUntil = (String) redisTemplate.opsForValue().get(lockKey);
if (lockedUntil != null) {
long remainingSeconds = java.time.Duration.between(
java.time.Instant.now(), java.time.Instant.parse(lockedUntil)).getSeconds();
if (remainingSeconds > 0) {
throw new RuntimeException("账号已锁定,请 " + remainingSeconds / 60 + " 分钟后重试");
}
}
if (!Constants.ACCOUNT_STATUS_NORMAL.equals(operator.getStatus())) {
throw new RuntimeException("账号已禁用,请联系管理员");
}
if (!passwordEncoder.matches(password, operator.getPasswordHash())) {
// 记录失败次数
String attemptKey = "auth:operator_login_attempts:" + operator.getId();
Long attempts = redisTemplate.opsForValue().increment(attemptKey);
if (attempts == null) attempts = 1L;
if (attempts == 1) {
redisTemplate.expire(attemptKey, LOCKOUT_DURATION);
}
if (attempts >= MAX_LOGIN_ATTEMPTS) {
// 达到最大失败次数,锁定账户
String lockUntil = java.time.Instant.now().plus(LOCKOUT_DURATION).toString();
redisTemplate.opsForValue().set(lockKey, lockUntil, LOCKOUT_DURATION);
redisTemplate.delete(attemptKey);
log.warn("Operator 账号被锁定: username={}, ip=unknown", username);
}
log.warn("登录失败: username={}, ip={}", username, "unknown");
throw new RuntimeException("用户名或密码错误");
}
// 登录成功:清除失败计数和锁定
redisTemplate.delete("auth:operator_login_attempts:" + operator.getId());
redisTemplate.delete("auth:operator_login_lock:" + operator.getId());
// 更新最后登录时间
operator.setLastLoginAt(java.time.Instant.now());
operatorMapper.updateById(operator);
// 获取权限
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operator.getId());
List<String> permissionCodes = permissions.stream()
.map(Permission::getCode)
.toList();
// 签发 Token
Map<String, Object> claims = new HashMap<>();
claims.put("operatorId", operator.getId());
claims.put("organizationId", operator.getOrganizationId());
claims.put("username", operator.getUsername());
claims.put("permissions", permissionCodes);
String accessToken = jwtTokenService.generateOperatorToken(operator.getId(), claims);
String refreshToken = jwtTokenService.generateRefreshToken(operator.getId());
// Redis 存储
String key = "auth:operator_refresh:" + operator.getId();
redisTemplate.opsForValue().set(key, refreshToken, Duration.ofDays(14));
// 审计
saveAuditLog(operator.getId(), "LOGIN", "operator", operator.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("operatorId", operator.getId());
result.put("name", operator.getName());
result.put("organizationId", operator.getOrganizationId());
result.put("permissions", permissionCodes);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
return result;
}
/**
* Operator 登出
*/
public void logout(String operatorId, String token) {
// 将 accessToken 加入黑名单,立即失效
jwtTokenService.blacklistToken(token);
redisTemplate.delete("auth:operator_refresh:" + operatorId);
saveAuditLog(operatorId, "LOGOUT", "operator", operatorId, null, null);
log.info("Operator 登出: operatorId={}", operatorId);
}
/**
* 获取当前 Operator 信息
*/
public Map<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream()
.map(Permission::getCode)
.toList();
Map<String, Object> result = new HashMap<>();
result.put("operatorId", operator.getId());
result.put("name", operator.getName());
result.put("username", operator.getUsername());
result.put("organizationId", operator.getOrganizationId());
result.put("status", operator.getStatus());
result.put("permissions", permissionCodes);
result.put("lastLoginAt", operator.getLastLoginAt());
return result;
}
/**
* 刷新 Operator Token
*/
public Map<String, Object> refreshToken(String operatorId, String refreshToken) {
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
}
Map<String, Object> operatorInfo = getMe(operatorId);
String accessToken = jwtTokenService.generateOperatorToken(operatorId,
Map.of("permissions", operatorInfo.get("permissions")));
String newRefreshToken = jwtTokenService.generateRefreshToken(operatorId);
redisTemplate.opsForValue().set("auth:operator_refresh:" + operatorId, newRefreshToken,
Duration.ofDays(14));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
return result;
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,150 @@
package com.danyang.match.platform.auth.token;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class JwtTokenService {
private final TokenBlacklistService tokenBlacklistService;
@Value("${jwt.access-token-secret}")
private String accessSecret;
@Value("${jwt.refresh-token-secret}")
private String refreshSecret;
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessExpirationMs;
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
private long refreshExpirationMs;
private SecretKey getAccessKey() {
return Keys.hmacShaKeyFor(accessSecret.getBytes(StandardCharsets.UTF_8));
}
private SecretKey getRefreshKey() {
return Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8));
}
/**
* 签发用户 Access Token
*/
public String generateAccessToken(String userId, Map<String, Object> claims) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessExpirationMs);
JwtBuilder builder = Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiry)
.claim("type", "access")
.signWith(getAccessKey());
if (claims != null) {
claims.forEach(builder::claim);
}
return builder.compact();
}
/**
* 签发 Refresh Token
*/
public String generateRefreshToken(String userId) {
Date now = new Date();
Date expiry = new Date(now.getTime() + refreshExpirationMs);
return Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiry)
.claim("type", "refresh")
.signWith(getRefreshKey())
.compact();
}
/**
* 签发 Operator Access Token
*/
public String generateOperatorToken(String operatorId, Map<String, Object> claims) {
Date now = new Date();
Date expiry = new Date(now.getTime() + accessExpirationMs);
JwtBuilder builder = Jwts.builder()
.subject(operatorId)
.issuedAt(now)
.expiration(expiry)
.claim("type", "operator_access")
.signWith(getAccessKey());
if (claims != null) {
claims.forEach(builder::claim);
}
return builder.compact();
}
/**
* 验证并解析 Token
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getAccessKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 验证 Token 是否有效(含黑名单检查)
*/
public boolean validateToken(String token) {
try {
if (tokenBlacklistService.isBlacklisted(token)) {
return false;
}
parseToken(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 将 Token 加入黑名单(登出时使用)
*/
public void blacklistToken(String token) {
try {
Date expiry = parseToken(token).getExpiration();
long ttlSeconds = Math.max(0, (expiry.getTime() - System.currentTimeMillis()) / 1000);
tokenBlacklistService.blacklist(token, ttlSeconds);
} catch (Exception e) {
// token 已过期或无效,无需加入黑名单
}
}
/**
* 从 Token 获取用户 ID
*/
public String getUserIdFromToken(String token) {
return parseToken(token).getSubject();
}
/**
* 获取 Token 过期时间
*/
public long getExpirationMs() {
return accessExpirationMs;
}
}
@@ -0,0 +1,46 @@
package com.danyang.match.platform.auth.token;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* JWT Token 黑名单服务
* 用于登出时使 Token 立即失效,无需等待过期
*/
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final StringRedisTemplate redisTemplate;
/**
* 检查 Token 是否在黑名单中
*/
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey("token:blacklist:" + token));
}
/**
* 将 Token 加入黑名单,TTL 为原始 token 剩余有效期
*/
public void blacklist(String token, long ttlSeconds) {
String key = "token:blacklist:" + token;
if (ttlSeconds > 0) {
redisTemplate.opsForValue().set(key, "1", Duration.ofSeconds(ttlSeconds));
} else {
// 若已过期或无法获取剩余时间,默认保留2小时
redisTemplate.opsForValue().set(key, "1", Duration.ofHours(2));
}
}
/**
* 批量加入黑名单(用于强制登出某用户所有设备)
*/
public void blacklistAllForUser(String userId, long ttlSeconds) {
String key = "token:blacklist:user:" + userId;
redisTemplate.opsForValue().set(key, "1", Duration.ofSeconds(Math.max(ttlSeconds, 3600)));
}
}
@@ -0,0 +1,29 @@
package com.danyang.match.platform.banner.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("home_banners")
public class HomeBanner extends BaseEntity {
private String title;
private String subtitle;
private String imageFileId;
private String buttonText;
/** activity / matchmaker / counseling / premarital / service_location / article / internal_page / none */
private String targetType;
private String targetValue;
/** all / onboarding / searching / getting_to_know / successful */
private String audienceScope;
private Integer sortOrder;
private java.time.Instant startAt;
private java.time.Instant endAt;
/** draft / scheduled / active / inactive / expired */
private String status;
private String createdBy;
private String updatedBy;
}
@@ -0,0 +1,38 @@
package com.danyang.match.platform.banner.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.banner.entity.HomeBanner;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface HomeBannerMapper extends BaseMapper<HomeBanner> {
/**
* 查询当前应展示的Banner列表(按时间+状态+受众过滤)
* 由调用方根据用户状态二次过滤audience_scope
*/
@Select("""
SELECT * FROM home_banners
WHERE status IN ('active', 'scheduled')
AND (start_at IS NULL OR start_at <= NOW())
AND (end_at IS NULL OR end_at > NOW())
ORDER BY sort_order ASC
""")
List<HomeBanner> findActiveBanners();
/**
* 按受众范围查询Banner
*/
@Select("""
SELECT * FROM home_banners
WHERE status IN ('active', 'scheduled')
AND (start_at IS NULL OR start_at <= NOW())
AND (end_at IS NULL OR end_at > NOW())
AND (audience_scope = 'all' OR audience_scope = #{scope})
ORDER BY sort_order ASC
""")
List<HomeBanner> findActiveBannersByScope(String scope);
}
@@ -0,0 +1,55 @@
package com.danyang.match.platform.banner.service;
import com.danyang.match.platform.banner.entity.HomeBanner;
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* Banner 查询服务
* 仅负责查询,不含写操作(写操作由后台管理模块处理)
*/
@Service
@RequiredArgsConstructor
public class HomeBannerService {
private final HomeBannerMapper bannerMapper;
/**
* 根据用户状态获取首页Banner列表
* audience_scope 映射到 relationship_status
*/
public List<HomeBanner> getActiveBannersForUser(String userStatus) {
// 将relationship_status映射到audience_scope
String scope = mapStatusToScope(userStatus);
return bannerMapper.findActiveBannersByScope(scope);
}
/**
* 获取所有Banner(管理端用)
*/
public List<HomeBanner> getAllActiveBanners() {
return bannerMapper.findActiveBanners();
}
// ==================== 私有映射 ====================
private String mapStatusToScope(String userStatus) {
if (userStatus == null) return "all";
switch (userStatus) {
case Constants.RELATIONSHIP_STATUS_SEARCHING:
return "searching";
case Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW:
return "getting_to_know";
case Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY:
return "successful";
default:
// onboarding / paused / 未登录等 → all
return "all";
}
}
}
@@ -0,0 +1,97 @@
package com.danyang.match.platform.content.controller;
import com.danyang.match.platform.content.dto.ContentArticleDTO;
import com.danyang.match.platform.content.dto.ContentCategoryDTO;
import com.danyang.match.platform.content.service.ContentService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/content")
@RequiredArgsConstructor
public class ContentController {
private final ContentService contentService;
private final JwtTokenService jwtTokenService;
@GetMapping("/categories")
public ApiResponse<List<ContentCategoryDTO>> getCategories() {
return ApiResponse.ok(contentService.getAllCategories());
}
@GetMapping("/articles")
public ApiResponse<List<ContentArticleDTO>> getArticles(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestParam(required = false) String categoryId) {
return ApiResponse.ok(contentService.getPublishedArticles(categoryId));
}
@GetMapping("/articles/{id}")
public ApiResponse<ContentArticleDTO> getArticleDetail(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
ContentArticleDTO detail = contentService.getArticleDetail(id);
if (detail == null) return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
return ApiResponse.ok(detail);
}
// ---- Admin ----
@PostMapping("/operator/categories")
public ApiResponse<ContentCategoryDTO> createCategory(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(contentService.createCategory(body));
}
@PutMapping("/operator/categories/{id}")
public ApiResponse<ContentCategoryDTO> updateCategory(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id, @RequestBody Map<String, Object> body) {
return ApiResponse.ok(contentService.updateCategory(id, body));
}
@PostMapping("/operator/articles")
public ApiResponse<ContentArticleDTO> createArticle(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody Map<String, Object> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(contentService.createArticle(body, operatorId));
}
@PutMapping("/operator/articles/{id}")
public ApiResponse<ContentArticleDTO> updateArticle(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id, @RequestBody Map<String, Object> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(contentService.updateArticle(id, body, operatorId));
}
@PostMapping("/operator/articles/{id}/publish")
public ApiResponse<ContentArticleDTO> publishArticle(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(contentService.publishArticle(id, operatorId));
}
@PostMapping("/operator/articles/{id}/offline")
public ApiResponse<ContentArticleDTO> offlineArticle(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(contentService.offlineArticle(id, operatorId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,11 @@
package com.danyang.match.platform.content.dto;
import lombok.Data;
import java.time.Instant;
@Data
public class ContentArticleDTO {
private String id; private String categoryId; private String title;
private String summary; private String coverFileId;
private String status; private Instant publishAt;
private Integer viewCount; private Integer sortOrder;
private Instant createdAt;
}
@@ -0,0 +1,9 @@
package com.danyang.match.platform.content.dto;
import lombok.Data;
import java.time.Instant;
@Data
public class ContentCategoryDTO {
private String id; private String name; private String code;
private String description; private Integer sortOrder;
private String status; private Instant createdAt;
}
@@ -0,0 +1,20 @@
package com.danyang.match.platform.content.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
@Data @EqualsAndHashCode(callSuper = true)
@TableName("content_articles")
public class ContentArticle extends BaseEntity {
private String categoryId;
private String title;
private String summary;
private String content;
private String coverFileId;
private String authorOperatorId;
private String status;
private Instant publishAt;
private Integer viewCount;
private Integer sortOrder;
}
@@ -0,0 +1,14 @@
package com.danyang.match.platform.content.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data @EqualsAndHashCode(callSuper = true)
@TableName("content_categories")
public class ContentCategory extends BaseEntity {
private String name;
private String code;
private String description;
private Integer sortOrder;
private String status;
}
@@ -0,0 +1,14 @@
package com.danyang.match.platform.content.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.content.entity.ContentArticle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ContentArticleMapper extends BaseMapper<ContentArticle> {
@Select("SELECT * FROM content_articles WHERE category_id = #{categoryId} AND status = 'published' AND deleted_at IS NULL ORDER BY sort_order ASC, created_at DESC LIMIT 20")
List<ContentArticle> findPublishedByCategory(@Param("categoryId") String categoryId);
@Select("SELECT * FROM content_articles WHERE status = 'published' AND publish_at <= NOW() AND deleted_at IS NULL ORDER BY sort_order ASC, created_at DESC LIMIT 20")
List<ContentArticle> findPublishedAll();
}
@@ -0,0 +1,6 @@
package com.danyang.match.platform.content.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.content.entity.ContentCategory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContentCategoryMapper extends BaseMapper<ContentCategory> {}
@@ -0,0 +1,159 @@
package com.danyang.match.platform.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.content.dto.ContentArticleDTO;
import com.danyang.match.platform.content.dto.ContentCategoryDTO;
import com.danyang.match.platform.content.entity.ContentArticle;
import com.danyang.match.platform.content.entity.ContentCategory;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.content.mapper.ContentCategoryMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContentService {
private final ContentCategoryMapper categoryMapper;
private final ContentArticleMapper articleMapper;
public List<ContentCategoryDTO> getAllCategories() {
return categoryMapper.selectList(
new LambdaQueryWrapper<ContentCategory>().eq(ContentCategory::getStatus, "active")
).stream().map(this::toCategoryDTO).toList();
}
public List<ContentArticleDTO> getPublishedArticles(String categoryId) {
List<ContentArticle> articles;
if (categoryId != null && !categoryId.isBlank()) {
articles = articleMapper.findPublishedByCategory(categoryId);
} else {
articles = articleMapper.findPublishedAll();
}
return articles.stream().map(this::toArticleDTO).toList();
}
public ContentArticleDTO getArticleDetail(String articleId) {
ContentArticle article = articleMapper.selectById(articleId);
if (article == null || !"published".equals(article.getStatus())) return null;
// 增加阅读量
article.setViewCount((article.getViewCount() != null ? article.getViewCount() : 0) + 1);
articleMapper.updateById(article);
return toArticleDTO(article);
}
@Transactional
public ContentArticleDTO createArticle(Map<String, Object> body, String operatorId) {
ContentArticle article = new ContentArticle();
article.setId(java.util.UUID.randomUUID().toString());
article.setCategoryId((String) body.get("categoryId"));
article.setTitle((String) body.get("title"));
article.setSummary((String) body.get("summary"));
article.setContent((String) body.get("content"));
article.setCoverFileId((String) body.get("coverFileId"));
article.setAuthorOperatorId(operatorId);
article.setStatus("draft");
article.setSortOrder(((Number) body.getOrDefault("sortOrder", 0)).intValue());
article.setViewCount(0);
article.setCreatedAt(Instant.now());
articleMapper.insert(article);
log.info("创建内容文章: articleId={}, operatorId={}", article.getId(), operatorId);
return toArticleDTO(article);
}
@Transactional
public ContentArticleDTO updateArticle(String articleId, Map<String, Object> body, String operatorId) {
ContentArticle article = articleMapper.selectById(articleId);
if (article == null) throw new RuntimeException("文章不存在");
if (body.get("title") != null) article.setTitle((String) body.get("title"));
if (body.get("summary") != null) article.setSummary((String) body.get("summary"));
if (body.get("content") != null) article.setContent((String) body.get("content"));
if (body.get("categoryId") != null) article.setCategoryId((String) body.get("categoryId"));
if (body.get("coverFileId") != null) article.setCoverFileId((String) body.get("coverFileId"));
if (body.get("sortOrder") != null) article.setSortOrder(((Number) body.get("sortOrder")).intValue());
article.setUpdatedAt(Instant.now());
articleMapper.updateById(article);
return toArticleDTO(article);
}
@Transactional
public ContentArticleDTO publishArticle(String articleId, String operatorId) {
ContentArticle article = articleMapper.selectById(articleId);
if (article == null) throw new RuntimeException("文章不存在");
article.setStatus(Constants.ARTICLE_STATUS_PUBLISHED);
article.setPublishAt(Instant.now());
article.setUpdatedAt(Instant.now());
articleMapper.updateById(article);
log.info("发布文章: articleId={}, operatorId={}", articleId, operatorId);
return toArticleDTO(article);
}
@Transactional
public ContentArticleDTO offlineArticle(String articleId, String operatorId) {
ContentArticle article = articleMapper.selectById(articleId);
if (article == null) throw new RuntimeException("文章不存在");
article.setStatus(Constants.ARTICLE_STATUS_OFFLINE);
article.setUpdatedAt(Instant.now());
articleMapper.updateById(article);
return toArticleDTO(article);
}
@Transactional
public ContentCategoryDTO createCategory(Map<String, Object> body) {
ContentCategory category = new ContentCategory();
category.setId(java.util.UUID.randomUUID().toString());
category.setName((String) body.get("name"));
category.setCode((String) body.get("code"));
category.setDescription((String) body.get("description"));
category.setSortOrder(((Number) body.getOrDefault("sortOrder", 0)).intValue());
category.setStatus("active");
categoryMapper.insert(category);
return toCategoryDTO(category);
}
@Transactional
public ContentCategoryDTO updateCategory(String categoryId, Map<String, Object> body) {
ContentCategory category = categoryMapper.selectById(categoryId);
if (category == null) throw new RuntimeException("分类不存在");
if (body.get("name") != null) category.setName((String) body.get("name"));
if (body.get("description") != null) category.setDescription((String) body.get("description"));
if (body.get("sortOrder") != null) category.setSortOrder(((Number) body.get("sortOrder")).intValue());
if (body.get("status") != null) category.setStatus((String) body.get("status"));
categoryMapper.updateById(category);
return toCategoryDTO(category);
}
private ContentCategoryDTO toCategoryDTO(ContentCategory c) {
ContentCategoryDTO dto = new ContentCategoryDTO();
dto.setId(c.getId());
dto.setName(c.getName());
dto.setCode(c.getCode());
dto.setDescription(c.getDescription());
dto.setSortOrder(c.getSortOrder());
dto.setStatus(c.getStatus());
dto.setCreatedAt(c.getCreatedAt());
return dto;
}
private ContentArticleDTO toArticleDTO(ContentArticle a) {
ContentArticleDTO dto = new ContentArticleDTO();
dto.setId(a.getId());
dto.setCategoryId(a.getCategoryId());
dto.setTitle(a.getTitle());
dto.setSummary(a.getSummary());
dto.setCoverFileId(a.getCoverFileId());
dto.setStatus(a.getStatus());
dto.setPublishAt(a.getPublishAt());
dto.setViewCount(a.getViewCount());
dto.setSortOrder(a.getSortOrder());
dto.setCreatedAt(a.getCreatedAt());
return dto;
}
}
@@ -0,0 +1,153 @@
package com.danyang.match.platform.counseling.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.counseling.dto.CounselingDTO;
import com.danyang.match.platform.counseling.dto.CounselingRequest;
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 公益婚恋咨询控制器
* V1.1 Phase 13A
*/
@RestController
@RequestMapping("/counseling/services")
@RequiredArgsConstructor
public class CounselingController {
private final CounselingServiceApp counselingServiceApp;
private final JwtTokenService jwtTokenService;
// ---- 用户端 ----
@GetMapping("/intro")
public ApiResponse<List<Map<String, Object>>> getServiceIntro() {
return ApiResponse.ok(counselingServiceApp.getServiceIntro());
}
@PostMapping
public ApiResponse<CounselingDTO> create(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody CounselingRequest request) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.createCounseling(userId, request));
}
@GetMapping
public ApiResponse<List<CounselingDTO>> getMyCounselings(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.getMyCounselings(userId));
}
@GetMapping("/{id}")
public ApiResponse<CounselingDTO> getDetail(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
CounselingDTO detail = counselingServiceApp.getCounselingDetail(id, userId);
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
}
return ApiResponse.ok(detail);
}
@PostMapping("/{id}/cancel")
public ApiResponse<CounselingDTO> cancel(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.cancelCounseling(id, userId));
}
// ---- 后台端 ----
@GetMapping("/operator/queue")
public ApiResponse<List<CounselingDTO>> getPendingQueue(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.getPendingQueue());
}
@PostMapping("/operator/{id}/assign")
public ApiResponse<CounselingDTO> assignCounselor(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = extractUserId(authHeader);
String counselorId = body.get("counselorId");
return ApiResponse.ok(counselingServiceApp.assignCounselor(id, operatorId, counselorId));
}
@PostMapping("/operator/{id}/schedule")
public ApiResponse<CounselingDTO> schedule(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
Instant startAt = Instant.parse(body.get("startAt"));
Instant endAt = Instant.parse(body.get("endAt"));
return ApiResponse.ok(counselingServiceApp.scheduleCounseling(id, startAt, endAt));
}
@PostMapping("/operator/{id}/start")
public ApiResponse<CounselingDTO> startCounseling(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
return ApiResponse.ok(counselingServiceApp.startCounseling(id));
}
@PostMapping("/operator/{id}/followup")
public ApiResponse<Object> addFollowup(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.addFollowup(
id, operatorId,
body.get("followupType"), body.get("content"),
body.get("resultCode"),
body.get("nextFollowupAt") != null ? Instant.parse(body.get("nextFollowupAt")) : null));
}
@PostMapping("/operator/{id}/complete")
public ApiResponse<CounselingDTO> complete(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id) {
return ApiResponse.ok(counselingServiceApp.completeCounseling(id));
}
@PostMapping("/operator/{id}/close")
public ApiResponse<CounselingDTO> close(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(counselingServiceApp.closeCounseling(id, body.getOrDefault("reason", "")));
}
// ---- Counselor端(INV-012: 只看分配给自己的)----
@GetMapping("/operator/assigned")
public ApiResponse<List<CounselingDTO>> getAssigned(
@RequestHeader(value = "Authorization", required = false) String authHeader) {
String operatorId = extractUserId(authHeader);
return ApiResponse.ok(counselingServiceApp.getCounselorAssignedServices(operatorId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,31 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
import java.time.Instant;
import java.util.List;
/**
* 咨询响应DTO
*/
@Data
public class CounselingDTO {
private String id;
private String userId;
private String serviceType;
private String requestSource;
private String userRequestSummary;
private String status;
private String assignedOperatorId;
private String assignedOperatorName;
private String preferredServiceMode;
private String preferredTime;
private Instant scheduledStartAt;
private Instant scheduledEndAt;
private String companionUserId;
private String companionUserName;
private Instant completedAt;
private String closedReason;
private Instant createdAt;
private Instant updatedAt;
private List<FollowupDTO> followups;
}
@@ -0,0 +1,18 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
/**
* 用户申请咨询请求
*/
@Data
public class CounselingRequest {
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance
private String requestSource;
private String userRequestSummary;
private String preferredServiceMode;
private String preferredTime;
/** 伴侣用户ID(婚前辅导时可选填写) */
private String companionUserId;
private String createdByOperatorId;
}
@@ -0,0 +1,14 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
import java.time.Instant;
@Data
public class FollowupDTO {
private String id;
private String followupType;
private String content;
private String resultCode;
private Instant nextFollowupAt;
private Instant createdAt;
}
@@ -0,0 +1,24 @@
package com.danyang.match.platform.counseling.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 咨询跟进记录
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("counseling_followups")
public class CounselingFollowup extends BaseEntity {
private String counselingId;
private String operatorId;
private String followupType;
private String content;
private String resultCode;
private Instant nextFollowupAt;
}
@@ -0,0 +1,38 @@
package com.danyang.match.platform.counseling.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 公益婚恋咨询服务实体
* V1.1 Phase 13A
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("counseling_services")
public class CounselingService extends BaseEntity {
private String userId;
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance
private String requestSource; // user_self / matchmaker_referral / operator_assisted 等
private String userRequestSummary;
private String organizationId;
private String assignedOperatorId;
private String status; // pending / assigned / scheduled / in_progress / completed / cancelled / closed
private String preferredServiceMode; // online / offline
private String preferredTime;
private Instant scheduledStartAt;
private Instant scheduledEndAt;
private String consentVersion;
private Instant consentedAt;
private Instant completedAt;
private String closedReason;
/** 伴侣用户ID(婚前辅导可选) */
private String companionUserId;
private String createdByOperatorId;
private Instant userConfirmedAt;
}
@@ -0,0 +1,15 @@
package com.danyang.match.platform.counseling.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CounselingFollowupMapper extends BaseMapper<CounselingFollowup> {
@Select("SELECT * FROM counseling_followups WHERE counseling_id = #{counselingId} ORDER BY created_at DESC")
List<CounselingFollowup> findByCounselingId(String counselingId);
}
@@ -0,0 +1,25 @@
package com.danyang.match.platform.counseling.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.counseling.entity.CounselingService;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CounselingServiceMapper extends BaseMapper<CounselingService> {
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findByUserId(@Param("userId") String userId);
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findByAssignedOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND status IN ('pending','assigned','scheduled','in_progress') AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findActiveByAssignedOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM counseling_services WHERE status = 'pending' AND deleted_at IS NULL ORDER BY created_at ASC")
List<CounselingService> findPendingAll();
}
@@ -0,0 +1,264 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.counseling.dto.CounselingDTO;
import com.danyang.match.platform.counseling.dto.CounselingRequest;
import com.danyang.match.platform.counseling.dto.FollowupDTO;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
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.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 公益婚恋咨询服务
* V1.1 Phase 13A
* INV-012: Counselor只能访问被分配的服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CounselingServiceApp {
private final CounselingServiceMapper counselingMapper;
private final CounselingFollowupMapper followupMapper;
private final OperatorMapper operatorMapper;
private final NotificationService notificationService;
private final UserMapper userMapper;
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
/** P13A-T05: 获取咨询介绍 */
public List<Map<String, Object>> getServiceIntro() {
List<Map<String, Object>> services = new ArrayList<>();
services.add(Map.of("type", "relationship_guidance", "name", "恋爱困惑指导",
"description", "针对恋爱中的困惑、沟通问题提供专业指导"));
services.add(Map.of("type", "emotional_support", "name", "情感支持",
"description", "为情感困扰、焦虑情绪提供心理支持"));
services.add(Map.of("type", "premarital_guidance", "name", "婚前辅导",
"description", "为即将步入婚姻的情侣提供婚前指导,可邀请伴侣共同参与"));
services.add(Map.of("type", "anti_fraud_guidance", "name", "婚恋反诈指导",
"description", "提供婚恋诈骗识别与防范指导"));
return services;
}
/** P13A-T06: 用户申请咨询 */
@Transactional
public CounselingDTO createCounseling(String userId, CounselingRequest request) {
CounselingService cs = new CounselingService();
cs.setId(UUID.randomUUID().toString());
cs.setUserId(userId);
cs.setServiceType(request.getServiceType());
cs.setRequestSource(request.getRequestSource() != null ? request.getRequestSource() : "user_self");
cs.setUserRequestSummary(request.getUserRequestSummary());
cs.setPreferredServiceMode(request.getPreferredServiceMode());
cs.setPreferredTime(request.getPreferredTime());
cs.setCompanionUserId(request.getCompanionUserId());
cs.setStatus(Constants.COUNSELING_STATUS_PENDING);
cs.setCreatedByOperatorId(request.getCreatedByOperatorId());
cs.setCreatedAt(Instant.now());
counselingMapper.insert(cs);
// P17-T01: 咨询内容风险检测
try {
String riskLevel = riskReferralService.detectCounselingRisk(userId, request.getUserRequestSummary());
if ("high".equals(riskLevel)) {
log.warn("咨询内容触发高危风险: userId={}, summary={}", userId, request.getUserRequestSummary());
}
} catch (Exception e) {
log.warn("咨询风险检测异常: {}", e.getMessage());
}
try {
// Skip notification for operator users (operators are not in users table)
User platformUser = userMapper.findById(userId).orElse(null);
if (platformUser != null) {
notificationService.sendCounselingAssigned(userId, cs.getId());
} else {
log.info("跳过咨询通知: userId={} 不是平台用户", userId);
}
} catch (Exception e) {
log.warn("发送咨询通知失败(不影响主流程): {}", e.getMessage());
}
return toDTO(cs);
}
/** P13A-T07: 获取我的咨询列表 */
public List<CounselingDTO> getMyCounselings(String userId) {
return counselingMapper.findByUserId(userId).stream().map(this::toDTO).toList();
}
/** P13A-T08: 获取咨询详情(用户只能查看自己的) */
public CounselingDTO getCounselingDetail(String counselingId, String viewerUserId) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) return null;
if (!viewerUserId.equals(cs.getUserId())) {
throw new RuntimeException("无权查看该咨询记录");
}
return toDTO(cs);
}
/** P13A-T09: 取消咨询 */
@Transactional
public CounselingDTO cancelCounseling(String counselingId, String userId) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null || !userId.equals(cs.getUserId())) {
throw new RuntimeException("咨询不存在或无权操作");
}
if (!"pending".equals(cs.getStatus()) && !"assigned".equals(cs.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
}
cs.setStatus(Constants.COUNSELING_STATUS_CANCELLED);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
// ---- 后台管理方法 ----
/** P13A-T19: 待分配队列 */
public List<CounselingDTO> getPendingQueue() {
return counselingMapper.findPendingAll().stream().map(this::toDTO).toList();
}
/** P13A-T21: 分配咨询师 */
@Transactional
public CounselingDTO assignCounselor(String counselingId, String operatorId, String counselorId) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_ASSIGNED);
cs.setAssignedOperatorId(counselorId);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
log.info("分配咨询师: counselingId={}, counselorId={}", counselingId, counselorId);
return toDTO(cs);
}
/** P13A-T22: 安排时间 */
@Transactional
public CounselingDTO scheduleCounseling(String counselingId, Instant startAt, Instant endAt) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_SCHEDULED);
cs.setScheduledStartAt(startAt);
cs.setScheduledEndAt(endAt);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
/** P13A-T23: 开始咨询 */
@Transactional
public CounselingDTO startCounseling(String counselingId) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_IN_PROGRESS);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
/** P13A-T24: 添加跟进记录 */
@Transactional
public FollowupDTO addFollowup(String counselingId, String operatorId,
String followupType, String content,
String resultCode, Instant nextFollowupAt) {
CounselingFollowup fu = new CounselingFollowup();
fu.setId(UUID.randomUUID().toString());
fu.setCounselingId(counselingId);
fu.setOperatorId(operatorId);
fu.setFollowupType(followupType);
fu.setContent(content);
fu.setResultCode(resultCode);
fu.setNextFollowupAt(nextFollowupAt);
fu.setCreatedAt(Instant.now());
followupMapper.insert(fu);
return toFollowupDTO(fu);
}
/** P13A-T25: 完成咨询 */
@Transactional
public CounselingDTO completeCounseling(String counselingId) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_COMPLETED);
cs.setCompletedAt(Instant.now());
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
try {
notificationService.sendCounselingCompleted(cs.getUserId(), cs.getId());
} catch (Exception e) {
log.warn("发送完成通知失败: {}", e.getMessage());
}
return toDTO(cs);
}
/** P13A-T26: 关闭咨询 */
@Transactional
public CounselingDTO closeCounseling(String counselingId, String reason) {
CounselingService cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_CLOSED);
cs.setClosedReason(reason);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
/** P13A-T27: Counselor只获取被分配的服务(INV-012) */
public List<CounselingDTO> getCounselorAssignedServices(String operatorId) {
return counselingMapper.findByAssignedOperator(operatorId).stream().map(this::toDTO).toList();
}
private CounselingDTO toDTO(CounselingService cs) {
CounselingDTO dto = new CounselingDTO();
dto.setId(cs.getId());
dto.setUserId(cs.getUserId());
dto.setServiceType(cs.getServiceType());
dto.setRequestSource(cs.getRequestSource());
dto.setUserRequestSummary(cs.getUserRequestSummary());
dto.setStatus(cs.getStatus());
dto.setAssignedOperatorId(cs.getAssignedOperatorId());
dto.setPreferredServiceMode(cs.getPreferredServiceMode());
dto.setPreferredTime(cs.getPreferredTime());
dto.setScheduledStartAt(cs.getScheduledStartAt());
dto.setScheduledEndAt(cs.getScheduledEndAt());
dto.setCompanionUserId(cs.getCompanionUserId());
dto.setCompletedAt(cs.getCompletedAt());
dto.setClosedReason(cs.getClosedReason());
dto.setCreatedAt(cs.getCreatedAt());
dto.setUpdatedAt(cs.getUpdatedAt());
if (cs.getAssignedOperatorId() != null) {
Operator op = operatorMapper.selectById(cs.getAssignedOperatorId());
if (op != null) dto.setAssignedOperatorName(op.getName());
}
List<CounselingFollowup> followups = followupMapper.findByCounselingId(cs.getId());
dto.setFollowups(followups.stream().map(this::toFollowupDTO).toList());
return dto;
}
private FollowupDTO toFollowupDTO(CounselingFollowup fu) {
FollowupDTO dto = new FollowupDTO();
dto.setId(fu.getId());
dto.setFollowupType(fu.getFollowupType());
dto.setContent(fu.getContent());
dto.setResultCode(fu.getResultCode());
dto.setNextFollowupAt(fu.getNextFollowupAt());
dto.setCreatedAt(fu.getCreatedAt());
return dto;
}
}
@@ -0,0 +1,112 @@
package com.danyang.match.platform.demo;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMemberMapper;
import com.danyang.match.platform.messaging.mapper.MessageMapper;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.relationship.mapper.MatchMapper;
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
/**
* Demo 数据初始化
*
* 用途: 开发/演示环境一键填充演示数据
* 通过环境变量 DEMO_SEED=true 启用,生产环境默认关闭
*/
@Slf4j
@Component
@Order(100)
@RequiredArgsConstructor
public class DemoDataSeeder implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final UserPreferenceMapper preferenceMapper;
private final UserPhotoMapper photoMapper;
private final UserRelationMapper relationMapper;
private final MatchMapper matchMapper;
private final ConversationMapper conversationMapper;
private final ConversationMemberMapper memberMapper;
private final MessageMapper messageMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final NotificationMapper notificationMapper;
private final RelationshipSuccessRequestMapper successRequestMapper;
private final AuditLogMapper auditLogMapper;
@Override
public void run(ApplicationArguments args) {
String demoSeed = System.getenv("DEMO_SEED");
if (!"true".equalsIgnoreCase(demoSeed)) {
log.debug("DEMO_SEED 未启用,跳过 Demo 数据填充");
return;
}
log.info("🌱 开始填充 Demo 数据...");
try {
long before = countTotal();
seedDemoData();
long after = countTotal();
log.info("✅ Demo 数据填充完成: 新增 {} 条记录", after - before);
printStats();
} catch (Exception e) {
log.warn("⚠️ Demo 数据填充失败(不影响运行): {}", e.getMessage());
}
}
@Transactional
protected void seedDemoData() {
// 如果已存在用户数据则跳过(幂等)
long existingUsers = userMapper.selectCount(null);
if (existingUsers > 0) {
log.info("已有 {} 条用户数据,跳过 Demo 填充", existingUsers);
return;
}
// 实际数据由 demo-seed.sql 脚本填充
// 此 Service 主要用于统计验证
}
private long countTotal() {
int users = userMapper.selectCount(null).intValue();
int profiles = userProfileMapper.selectCount(null).intValue();
int matches = matchMapper.selectCount(null).intValue();
return users + profiles + matches;
}
private void printStats() {
Map<String, Object> stats = jdbcTemplate.queryForMap(
"""
SELECT
(SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS users,
(SELECT COUNT(*) FROM organizations) AS organizations,
(SELECT COUNT(*) FROM operators) AS operators,
(SELECT COUNT(*) FROM matches) AS matches,
(SELECT COUNT(*) FROM user_relations) AS relations,
(SELECT COUNT(*) FROM activities WHERE deleted_at IS NULL) AS activities,
(SELECT COUNT(*) FROM activity_registrations) AS registrations,
(SELECT COUNT(*) FROM conversations) AS conversations,
(SELECT COUNT(*) FROM messages) AS messages,
(SELECT COUNT(*) FROM notifications) AS notifications
"""
);
log.info("📊 Demo 数据统计:");
stats.forEach((k, v) -> log.info(" {}: {}", k, v));
}
}
@@ -0,0 +1,99 @@
package com.danyang.match.platform.discover.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.discover.service.DiscoverService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
/**
* 找对象控制器
*/
@RestController
@RequestMapping("/discover")
@RequiredArgsConstructor
public class DiscoverController {
private final DiscoverService discoverService;
private final JwtTokenService jwtTokenService;
private final UserProfileMapper userProfileMapper;
/**
* 搜索用户(临时筛选)
* GET /api/v1/discover?minAge=25&maxAge=35&regionCode=320000&page=1&pageSize=20
*/
@GetMapping
public ApiResponse<Map<String, Object>> search(
@RequestHeader("Authorization") String authHeader,
@RequestParam(required = false) Integer minAge,
@RequestParam(required = false) Integer maxAge,
@RequestParam(required = false) String regionCode,
@RequestParam(required = false) String maritalStatus,
@RequestParam(required = false) String educationCode,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
String userId = extractUserId(authHeader);
Map<String, Object> myProfile = getMyProfile(userId);
String myGender = (String) myProfile.get("gender");
Map<String, Object> tempFilter = new java.util.HashMap<>();
if (minAge != null) tempFilter.put("minAge", minAge);
if (maxAge != null) tempFilter.put("maxAge", maxAge);
if (regionCode != null) tempFilter.put("regionCode", regionCode);
if (maritalStatus != null) tempFilter.put("maritalStatus", maritalStatus);
if (educationCode != null) tempFilter.put("educationCode", educationCode);
return ApiResponse.ok(discoverService.searchUsers(myGender, tempFilter, page, pageSize));
}
/**
* 查看用户详情
* GET /api/v1/discover/users/{userId}
*/
@GetMapping("/users/{userId}")
public ApiResponse<Map<String, Object>> getProfile(
@RequestHeader("Authorization") String authHeader,
@PathVariable String userId) {
Map<String, Object> profile = discoverService.getPublicProfile(userId);
if (profile == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
}
return ApiResponse.ok(profile);
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
private Map<String, Object> getMyProfile(String userId) {
return userProfileMapper.findByUserId(userId)
.map(p -> {
Map<String, Object> map = new java.util.HashMap<>();
map.put("gender", p.getGender());
map.put("age", calculateAge(p.getBirthDate()));
map.put("regionName", p.getRegionName());
map.put("profileStatus", p.getProfileStatus());
return map;
})
.orElse(new java.util.HashMap<>());
}
private int calculateAge(java.time.LocalDate birthDate) {
if (birthDate == null) return 0;
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
}
}
@@ -0,0 +1,123 @@
package com.danyang.match.platform.discover.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 找对象搜索服务
* 支持临时筛选(不修改用户择偶条件)
*/
@Service
@RequiredArgsConstructor
public class DiscoverService {
private final UserProfileMapper userProfileMapper;
/**
* 搜索用户列表(临时筛选)
*
* @param myGender 当前用户性别(用于过滤相反性别)
* @param tempFilter 临时筛选条件(不影响用户择偶条件)
* @param page 页码
* @param pageSize 每页数量
* @return 搜索结果
*/
public Map<String, Object> searchUsers(String myGender, Map<String, Object> tempFilter,
int page, int pageSize) {
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(UserProfile::getGender, myGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.eq(UserProfile::getDeletedAt, 0);
// 应用临时筛选条件
if (tempFilter.containsKey("minAge")) {
Integer minAge = (Integer) tempFilter.get("minAge");
wrapper.apply("EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM birth_date) >= {0}", minAge);
}
if (tempFilter.containsKey("maxAge")) {
Integer maxAge = (Integer) tempFilter.get("maxAge");
wrapper.apply("EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM birth_date) <= {0}", maxAge);
}
if (tempFilter.containsKey("regionCode")) {
wrapper.eq(UserProfile::getRegionCode, tempFilter.get("regionCode"));
}
if (tempFilter.containsKey("maritalStatus")) {
wrapper.eq(UserProfile::getMaritalStatus, tempFilter.get("maritalStatus"));
}
if (tempFilter.containsKey("educationCode")) {
wrapper.eq(UserProfile::getEducationCode, tempFilter.get("educationCode"));
}
long total = userProfileMapper.selectCount(wrapper);
wrapper.orderByDesc(UserProfile::getProfileCompletion)
.last("LIMIT " + pageSize + " OFFSET " + (page - 1) * pageSize);
List<UserProfile> profiles = userProfileMapper.selectList(wrapper);
List<Map<String, Object>> results = profiles.stream().map(p -> {
Map<String, Object> map = new HashMap<>();
map.put("userId", p.getUserId());
map.put("displayName", p.getDisplayName());
map.put("age", calculateAge(p.getBirthDate()));
map.put("heightCm", p.getHeightCm());
map.put("regionName", p.getRegionName());
map.put("educationCode", p.getEducationCode());
map.put("occupationCode", p.getOccupationCode());
map.put("maritalStatus", p.getMaritalStatus());
map.put("childrenStatus", p.getChildrenStatus());
map.put("verified", true);
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(p.getProfileStatus()));
return map;
}).collect(Collectors.toList());
Map<String, Object> response = new HashMap<>();
response.put("records", results);
response.put("total", total);
response.put("page", page);
response.put("pageSize", pageSize);
response.put("totalPages", (int) Math.ceil((double) total / pageSize));
return response;
}
/**
* 获取单个用户公开详情
*/
public Map<String, Object> getPublicProfile(String userId) {
return userProfileMapper.findByUserId(userId)
.map(profile -> {
Map<String, Object> map = new HashMap<>();
map.put("userId", profile.getUserId());
map.put("displayName", profile.getDisplayName());
map.put("age", calculateAge(profile.getBirthDate()));
map.put("heightCm", profile.getHeightCm());
map.put("regionName", profile.getRegionName());
map.put("educationCode", profile.getEducationCode());
map.put("occupationCode", profile.getOccupationCode());
map.put("maritalStatus", profile.getMaritalStatus());
map.put("childrenStatus", profile.getChildrenStatus());
map.put("smokingStatus", profile.getSmokingStatus());
map.put("drinkingStatus", profile.getDrinkingStatus());
map.put("selfIntro", profile.getSelfIntro());
map.put("relationshipGoal", profile.getRelationshipGoal());
map.put("marriagePlan", profile.getMarriagePlan());
map.put("longTermDanyang", profile.getLongTermDanyang());
map.put("futureDanyang", profile.getFutureDanyang());
map.put("verified", true);
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
return map;
})
.orElse(null);
}
private int calculateAge(java.time.LocalDate birthDate) {
if (birthDate == null) return 0;
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
}
}
@@ -0,0 +1,44 @@
package com.danyang.match.platform.home.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.home.dto.HomeDTO;
import com.danyang.match.platform.home.service.HomeApplicationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 首页聚合控制器
* GET /api/v1/home — 聚合Banner/推荐/活动/服务站/内容等所有首页数据
*/
@RestController
@RequestMapping("/home")
@RequiredArgsConstructor
public class HomeController {
private final HomeApplicationService homeApplicationService;
private final JwtTokenService jwtTokenService;
/**
* 获取首页聚合数据
* GET /api/v1/home
*/
@GetMapping
public ApiResponse<HomeDTO> getHome(@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(homeApplicationService.getHome(userId));
}
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
}
@@ -0,0 +1,148 @@
package com.danyang.match.platform.home.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 首页聚合响应 DTO
*/
@Data
public class HomeDTO {
/** 用户状态信息 */
private UserState userState;
/** 首页Banner列表 */
private List<BannerItem> banners;
/** 8个核心入口(固定结构) */
private List<QuickEntry> quickEntries;
/** 今日推荐列表 */
private List<RecommendationItem> recommendations;
/** 善缘公益服务建议(按用户状态动态) */
private List<ServiceRecommendation> serviceRecommendations;
/** 近期公益活动(1~2个) */
private List<ActivityPreview> activities;
/** 善缘服务站卡片 */
private ServiceLocationCard serviceLocation;
/** 婚恋课堂文章(2~3条) */
private List<ArticlePreview> articles;
/** 安全提醒 */
private SafetyNotice safetyNotice;
/** 能力开关 */
private Capabilities capabilities;
// ==================== 内部类 ====================
@Data
public static class UserState {
private String userId;
private String accountStatus;
private String relationshipStatus;
private Boolean recommendationEnabled;
private Boolean phoneBound;
private Boolean verified;
private String profileStatus;
private String reviewStatus;
private String localEligibilityStatus;
private String localEligibilityType;
}
@Data
public static class BannerItem {
private String id;
private String title;
private String subtitle;
private String imageFileId;
private String buttonText;
private String targetType;
private String targetValue;
private String audienceScope;
}
@Data
public static class QuickEntry {
private String code;
private String label;
private String icon;
private boolean enabled;
private String targetPage;
}
@Data
public static class RecommendationItem {
private String userId;
private String recommendationLevel;
private List<String> recommendationReasons;
}
@Data
public static class ServiceRecommendation {
private String type;
private String title;
private String description;
private String ctaText;
private String targetPage;
}
@Data
public static class ActivityPreview {
private String id;
private String title;
private String coverFileId;
private String locationName;
private java.time.Instant startAt;
private String status;
}
@Data
public static class ServiceLocationCard {
private String id;
private String name;
private String shortName;
private String address;
private String openingHoursText;
private String contactPhoneDisplay;
private List<String> services;
}
@Data
public static class ArticlePreview {
private String id;
private String title;
private String summary;
private String coverFileId;
}
@Data
public static class SafetyNotice {
private String text;
private String ctaText;
private String ctaTarget;
}
@Data
public static class Capabilities {
private boolean canReceiveRecommendations;
private boolean canSearchUsers;
private boolean canLike;
private boolean canChat;
private boolean canApplyMatchmakingActivity;
private boolean canApplyCounseling;
private boolean canApplyMatchmaker;
public boolean isCanSearchUsers() { return canSearchUsers; }
public boolean isCanLike() { return canLike; }
public boolean isCanChat() { return canChat; }
public boolean isCanApplyMatchmakingActivity() { return canApplyMatchmakingActivity; }
public boolean isCanApplyCounseling() { return canApplyCounseling; }
public boolean isCanApplyMatchmaker() { return canApplyMatchmaker; }
}
}
@@ -0,0 +1,293 @@
package com.danyang.match.platform.home.service;
import com.danyang.match.platform.banner.entity.HomeBanner;
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
import com.danyang.match.platform.banner.service.HomeBannerService;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.service.MatchEligibilityService;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.entity.ReviewTask;
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
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 com.danyang.match.platform.home.dto.HomeDTO;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 首页聚合服务
* 对应 GET /api/v1/home
* 不复制业务规则,仅聚合各模块数据
*/
@Service
@RequiredArgsConstructor
public class HomeApplicationService {
private static final Logger log = LoggerFactory.getLogger(HomeApplicationService.class);
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ReviewTaskMapper reviewTaskMapper;
private final MatchEligibilityService matchEligibilityService;
private final HomeBannerService bannerService;
private final ActivityMapper activityMapper;
/**
* 获取首页聚合数据
* GET /api/v1/home
*/
public HomeDTO getHome(String userId) {
HomeDTO dto = new HomeDTO();
// 1. 用户状态
dto.setUserState(buildUserState(userId));
// 2. 能力开关
dto.setCapabilities(buildCapabilities(userId));
// 3. Banner(根据用户状态过滤)
User user = userMapper.findById(userId).orElse(null);
if (user != null) {
dto.setBanners(buildBannerItems(user.getRelationshipStatus()));
}
// 4. 8个核心入口(固定结构)
dto.setQuickEntries(buildQuickEntries(dto.getCapabilities()));
// 5. 今日推荐
dto.setRecommendations(buildRecommendations(userId, user));
// 6. 善缘公益服务建议
dto.setServiceRecommendations(buildServiceRecommendations(user));
// 7. 近期活动
dto.setActivities(buildActivityPreviews());
// 8. 服务站卡片
dto.setServiceLocation(buildServiceLocationCard());
// 9. 安全提醒
dto.setSafetyNotice(buildSafetyNotice());
// 10. 文章/课堂(V1.1 Content模块预留)
dto.setArticles(List.of());
return dto;
}
// ==================== 私有构建方法 ====================
private HomeDTO.UserState buildUserState(String userId) {
User user = userMapper.findById(userId).orElse(null);
if (user == null) {
HomeDTO.UserState state = new HomeDTO.UserState();
state.setUserId(userId);
state.setAccountStatus("unknown");
return state;
}
HomeDTO.UserState state = new HomeDTO.UserState();
state.setUserId(user.getId());
state.setAccountStatus(user.getAccountStatus());
state.setRelationshipStatus(user.getRelationshipStatus());
state.setRecommendationEnabled(user.getRecommendationEnabled());
state.setPhoneBound(user.getPhone() != null && !user.getPhone().isBlank());
// 实名状态
state.setVerified(true); // TODO: Phase 03 后接入真实判断
// 资料审核状态
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
if (profile != null) {
state.setProfileStatus(profile.getProfileStatus());
}
// 审核状态
Optional<ReviewTask> latestReview = reviewTaskMapper.findLatestByUserId(userId);
if (latestReview.isPresent()) {
state.setReviewStatus(latestReview.get().getStatus());
} else {
state.setReviewStatus("not_submitted");
}
// 本地资格状态
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
if (elig.isPresent()) {
state.setLocalEligibilityStatus(elig.get().getVerificationStatus());
state.setLocalEligibilityType(elig.get().getEligibilityType());
} else {
state.setLocalEligibilityStatus("not_declared");
}
return state;
}
private HomeDTO.Capabilities buildCapabilities(String userId) {
User user = userMapper.findById(userId).orElse(null);
if (user == null) {
return new HomeDTO.Capabilities();
}
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
caps.setCanReceiveRecommendations(isSearching(user) && isProfileApproved(user));
caps.setCanSearchUsers(isProfileApproved(user));
caps.setCanLike(isProfileApproved(user));
caps.setCanChat(false); // 由Match状态决定,首页层暂不展示
caps.setCanApplyMatchmakingActivity(isProfileApproved(user));
caps.setCanApplyCounseling(isProfileApproved(user));
caps.setCanApplyMatchmaker(isProfileApproved(user));
return caps;
}
private List<HomeDTO.BannerItem> buildBannerItems(String userStatus) {
return bannerService.getActiveBannersForUser(userStatus)
.stream()
.map(b -> {
HomeDTO.BannerItem item = new HomeDTO.BannerItem();
item.setId(b.getId());
item.setTitle(b.getTitle());
item.setSubtitle(b.getSubtitle());
item.setImageFileId(b.getImageFileId());
item.setButtonText(b.getButtonText());
item.setTargetType(b.getTargetType());
item.setTargetValue(b.getTargetValue());
item.setAudienceScope(b.getAudienceScope());
return item;
})
.collect(Collectors.toList());
}
private List<HomeDTO.QuickEntry> buildQuickEntries(HomeDTO.Capabilities caps) {
// 固定8个入口,按V1.1规范顺序
List<HomeDTO.QuickEntry> entries = new ArrayList<>();
entries.add(entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "pages/discover/discover"));
entries.add(entry("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "pages/matchmaker/matchmaker"));
entries.add(entry("activities", "公益活动", "icon-activity", true, "pages/activity/activity"));
entries.add(entry("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "pages/local-eligibility/local-eligibility"));
entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "pages/local-eligibility/local-eligibility"));
entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "pages/local-eligibility/local-eligibility"));
entries.add(entry("anti_fraud", "婚恋反诈", "icon-shield", true, "pages/local-eligibility/local-eligibility"));
entries.add(entry("service_station", "善缘服务站", "icon-location", true, "pages/local-eligibility/local-eligibility"));
return entries;
}
private List<HomeDTO.RecommendationItem> buildRecommendations(String userId, User user) {
if (user == null || !Boolean.TRUE.equals(user.getRecommendationEnabled())) {
return List.of();
}
if (!isSearching(user)) {
return List.of();
}
if (!isProfileApproved(user)) {
return List.of();
}
// TODO: Phase 07 后续接入推荐引擎实际数据
return List.of();
}
private List<HomeDTO.ServiceRecommendation> buildServiceRecommendations(User user) {
List<HomeDTO.ServiceRecommendation> list = new ArrayList<>();
if (user == null) return list;
String status = user.getRelationshipStatus();
if (Constants.RELATIONSHIP_STATUS_SEARCHING.equals(status)) {
list.add(serviceRec("matchmaker", "一直没有遇到特别合适的人?",
"公益红娘可以根据你的条件,帮你一起看看合适的人。",
"申请公益红娘", "pages/matchmaker/matchmaker"));
} else if (Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW.equals(status)) {
list.add(serviceRec("counseling", "刚开始认识,不知道聊什么?",
"这里有一些第一次聊天的小建议。",
"看看沟通建议", "pages/local-eligibility/local-eligibility"));
} else if (Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY.equals(status)) {
list.add(serviceRec("premarital", "正在认真考虑未来?",
"可以了解善缘丹阳公益婚前辅导。",
"了解婚前辅导", "pages/local-eligibility/local-eligibility"));
}
return list;
}
private List<HomeDTO.ActivityPreview> buildActivityPreviews() {
return activityMapper.findUpcomingActivities()
.stream()
.limit(2)
.map(a -> {
HomeDTO.ActivityPreview item = new HomeDTO.ActivityPreview();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setCoverFileId(a.getCoverFileId());
item.setLocationName(a.getLocationName());
item.setStartAt(a.getStartAt());
item.setStatus(a.getStatus());
return item;
})
.collect(Collectors.toList());
}
private HomeDTO.ServiceLocationCard buildServiceLocationCard() {
// V1.1: 当前只有一个固定服务站(万善公园婚姻登记处)
// 从数据库读取,暂时返回默认值(待Phase 19填充真实数据)
HomeDTO.ServiceLocationCard card = new HomeDTO.ServiceLocationCard();
card.setId("default");
card.setName("善缘丹阳公益服务站");
card.setShortName("万善公园婚姻登记处");
card.setAddress("丹阳市万善公园婚姻登记处");
card.setOpeningHoursText("周一至周五 09:00—17:00");
card.setContactPhoneDisplay("0511-******");
card.setServices(List.of("公益红娘", "婚恋咨询", "婚前辅导", "活动服务"));
return card;
}
private HomeDTO.SafetyNotice buildSafetyNotice() {
HomeDTO.SafetyNotice notice = new HomeDTO.SafetyNotice();
notice.setText("实名认证和人工审核可以提高信息真实性,"
+ "但交往过程中仍请谨慎涉及借款、投资和个人隐私。");
notice.setCtaText("查看婚恋反诈指南");
notice.setCtaTarget("pages/local-eligibility/local-eligibility");
return notice;
}
// ==================== 私有工具方法 ====================
private boolean isSearching(User user) {
return Constants.RELATIONSHIP_STATUS_SEARCHING.equals(user.getRelationshipStatus());
}
private boolean isProfileApproved(User user) {
String status = user.getProfileStatus();
return Constants.PROFILE_STATUS_APPROVED.equals(status)
|| Constants.PROFILE_STATUS_COMPLETED.equals(status);
}
private HomeDTO.QuickEntry entry(String code, String label, String icon, boolean enabled, String targetPage) {
HomeDTO.QuickEntry e = new HomeDTO.QuickEntry();
e.setCode(code);
e.setLabel(label);
e.setIcon(icon);
e.setEnabled(enabled);
e.setTargetPage(targetPage);
return e;
}
private HomeDTO.ServiceRecommendation serviceRec(String type, String title, String desc, String cta, String target) {
HomeDTO.ServiceRecommendation sr = new HomeDTO.ServiceRecommendation();
sr.setType(type);
sr.setTitle(title);
sr.setDescription(desc);
sr.setCtaText(cta);
sr.setTargetPage(target);
return sr;
}
}
@@ -0,0 +1,75 @@
package com.danyang.match.platform.local_eligibility.controller;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityDeclarationRequest;
import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityEvidenceRequest;
import com.danyang.match.platform.local_eligibility.dto.LocalEligibilityStatusDTO;
import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 本地资格控制器(用户端)
*/
@RestController
@RequestMapping("/local-eligibility")
@RequiredArgsConstructor
public class LocalEligibilityController {
private final LocalEligibilityService localEligibilityService;
private final JwtTokenService jwtTokenService;
private String extractUserId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token);
}
private String extractToken(String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.UNAUTHORIZED);
}
return authHeader.substring(7);
}
/**
* GET /api/v1/local-eligibility — 查询本地资格状态
*/
@GetMapping
public ApiResponse<LocalEligibilityStatusDTO> getStatus(@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(localEligibilityService.getStatus(userId));
}
/**
* POST /api/v1/local-eligibility/declaration — 声明本地资格
*/
@PostMapping("/declaration")
public ApiResponse<LocalEligibilityStatusDTO> declare(
@RequestHeader("Authorization") String authHeader,
@RequestBody LocalEligibilityDeclarationRequest req) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(localEligibilityService.declare(userId, req));
}
/**
* POST /api/v1/local-eligibility/evidences — 上传佐证文件
*/
@PostMapping("/evidences")
public ApiResponse<LocalEligibilityStatusDTO> uploadEvidence(
@RequestHeader("Authorization") String authHeader,
@RequestBody LocalEligibilityEvidenceRequest req) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(localEligibilityService.uploadEvidence(userId, req));
}
/**
* POST /api/v1/local-eligibility/submit — 提交审核
*/
@PostMapping("/submit")
public ApiResponse<LocalEligibilityStatusDTO> submit(
@RequestHeader("Authorization") String authHeader) {
String userId = extractUserId(authHeader);
return ApiResponse.ok(localEligibilityService.submitForReview(userId));
}
}
@@ -0,0 +1,13 @@
package com.danyang.match.platform.local_eligibility.dto;
import lombok.Data;
/**
* 本地资格声明请求
*/
@Data
public class LocalEligibilityDeclarationRequest {
/** household_registration / residence / employment / other */
private String eligibilityType;
private String regionCode;
}
@@ -0,0 +1,12 @@
package com.danyang.match.platform.local_eligibility.dto;
import lombok.Data;
/**
* 本地资格佐证上传请求
*/
@Data
public class LocalEligibilityEvidenceRequest {
private String evidenceType;
private String fileId;
}
@@ -0,0 +1,28 @@
package com.danyang.match.platform.local_eligibility.dto;
import lombok.Data;
/**
* 本地资格状态响应
*/
@Data
public class LocalEligibilityStatusDTO {
private String userId;
/** eligibility type: household_registration/residence/employment/other */
private String eligibilityType;
private String regionCode;
/** declared/needs_evidence/pending_review/verified/rejected */
private String verificationStatus;
private java.time.Instant verifiedAt;
/** 是否需要上传佐证 */
private boolean evidenceRequired;
/** 已上传的佐证列表 */
private java.util.List<EvidenceItem> evidences;
@Data
public static class EvidenceItem {
private String evidenceType;
private String status;
private String fileId;
}
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.local_eligibility.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user_local_eligibilities")
public class LocalEligibility extends BaseEntity {
private String userId;
private String eligibilityType;
private String declaredRegionCode;
/** declared / needs_evidence / pending_review / verified / rejected */
private String verificationStatus;
private String reviewTaskId;
private java.time.Instant verifiedAt;
private String verifiedBy;
}
@@ -0,0 +1,18 @@
package com.danyang.match.platform.local_eligibility.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("local_eligibility_evidences")
public class LocalEligibilityEvidence extends BaseEntity {
private String localEligibilityId;
private String fileId;
/** employment_proof / residence_proof / household_proof / other */
private String evidenceType;
private String status;
}
@@ -0,0 +1,9 @@
package com.danyang.match.platform.local_eligibility.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LocalEligibilityEvidenceMapper extends BaseMapper<LocalEligibilityEvidence> {
}

Some files were not shown because too many files have changed in this diff Show More