chore: establish V1.1 recovery baseline
This commit is contained in:
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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}"]
|
||||
@@ -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>
|
||||
@@ -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!")
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
Binary file not shown.
Vendored
+82
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+464
@@ -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);
|
||||
}
|
||||
}
|
||||
+136
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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);
|
||||
}
|
||||
}
|
||||
+161
@@ -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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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; }
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+255
@@ -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);
|
||||
}
|
||||
}
|
||||
+526
@@ -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);
|
||||
}
|
||||
}
|
||||
+233
@@ -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);
|
||||
}
|
||||
}
|
||||
+115
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+129
@@ -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));
|
||||
}
|
||||
}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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> {
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
+55
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
+153
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
+264
@@ -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));
|
||||
}
|
||||
}
|
||||
+99
@@ -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®ionCode=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();
|
||||
}
|
||||
}
|
||||
+123
@@ -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; }
|
||||
}
|
||||
}
|
||||
+293
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+9
@@ -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
Reference in New Issue
Block a user