fix(backend): Restore service flow and admin management integration

This commit is contained in:
2026-04-18 18:20:58 +08:00
parent 92a139e470
commit 3adb088eca
83 changed files with 1696 additions and 703 deletions
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
mysql --default-character-set=utf8mb4 -uroot -p"${MYSQL_ROOT_PASSWORD}" nacos_config < /init-sql/nacos-config-init.sql
@@ -0,0 +1,24 @@
USE `video_content`;
-- 修复历史初始化过程中被错误写入的分类中文名称
UPDATE `content_category`
SET `name` = CASE `code`
WHEN 'life' THEN CONVERT(0xE7949FE6B4BB USING utf8mb4)
WHEN 'tech' THEN CONVERT(0xE7A791E68A80 USING utf8mb4)
WHEN 'game' THEN CONVERT(0xE6B8B8E6888F USING utf8mb4)
WHEN 'food' THEN CONVERT(0xE7BE8EE9A39F USING utf8mb4)
WHEN 'music' THEN CONVERT(0xE99FB3E4B990 USING utf8mb4)
WHEN 'movie' THEN CONVERT(0xE5BDB1E8A786 USING utf8mb4)
WHEN 'knowledge' THEN CONVERT(0xE79FA5E8AF86 USING utf8mb4)
WHEN 'digital' THEN CONVERT(0xE695B0E7A081 USING utf8mb4)
WHEN 'software' THEN CONVERT(0xE8BDAFE4BBB6 USING utf8mb4)
WHEN 'hardware' THEN CONVERT(0xE7A1ACE4BBB6 USING utf8mb4)
WHEN 'mobile_game' THEN CONVERT(0xE6898BE69CBAE6B8B8E6888F USING utf8mb4)
WHEN 'pc_game' THEN CONVERT(0xE794B5E88491E6B8B8E6888F USING utf8mb4)
WHEN 'console_game' THEN CONVERT(0xE4B8BBE69CBAE6B8B8E6888F USING utf8mb4)
ELSE `name`
END
WHERE `code` IN (
'life', 'tech', 'game', 'food', 'music', 'movie', 'knowledge',
'digital', 'software', 'hardware', 'mobile_game', 'pc_game', 'console_game'
);
+17 -15
View File
@@ -1,3 +1,5 @@
SET NAMES utf8mb4;
-- 1. 创建数据库 -- 1. 创建数据库
CREATE DATABASE IF NOT EXISTS `video_auth` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS `video_auth` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS `video_user` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS `video_user` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -359,7 +361,7 @@ CREATE TABLE IF NOT EXISTS `content_video_review` (
KEY `idx_video` (`video_id`), KEY `idx_video` (`video_id`),
KEY `idx_reviewer` (`reviewer_id`), KEY `idx_reviewer` (`reviewer_id`),
CONSTRAINT `fk_cvr_video` FOREIGN KEY (`video_id`) REFERENCES `content_video` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_cvr_video` FOREIGN KEY (`video_id`) REFERENCES `content_video` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_cvr_reviewer` FOREIGN KEY (`reviewer_id`) REFERENCES `video_auth`.`auth_user` (`id`) CONSTRAINT `fk_cvr_reviewer` FOREIGN KEY (`reviewer_id`) REFERENCES `video_admin`.`admin_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频审核记录表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频审核记录表';
-- 视频点赞表 -- 视频点赞表
@@ -458,19 +460,19 @@ CREATE TABLE IF NOT EXISTS `content_video_play_record` (
-- 初始化分类数据 -- 初始化分类数据
INSERT IGNORE INTO `content_category` (`id`, `name`, `code`, `parent_id`, `level`, `sort`) VALUES INSERT IGNORE INTO `content_category` (`id`, `name`, `code`, `parent_id`, `level`, `sort`) VALUES
(1, '生活', 'life', 0, 1, 1), (1, CONVERT(0xE7949FE6B4BB USING utf8mb4), 'life', 0, 1, 1),
(2, '科技', 'tech', 0, 1, 2), (2, CONVERT(0xE7A791E68A80 USING utf8mb4), 'tech', 0, 1, 2),
(3, '游戏', 'game', 0, 1, 3), (3, CONVERT(0xE6B8B8E6888F USING utf8mb4), 'game', 0, 1, 3),
(4, '美食', 'food', 0, 1, 4), (4, CONVERT(0xE7BE8EE9A39F USING utf8mb4), 'food', 0, 1, 4),
(5, '音乐', 'music', 0, 1, 5), (5, CONVERT(0xE99FB3E4B990 USING utf8mb4), 'music', 0, 1, 5),
(6, '影视', 'movie', 0, 1, 6), (6, CONVERT(0xE5BDB1E8A786 USING utf8mb4), 'movie', 0, 1, 6),
(7, '知识', 'knowledge', 0, 1, 7), (7, CONVERT(0xE79FA5E8AF86 USING utf8mb4), 'knowledge', 0, 1, 7),
(8, '数码', 'digital', 2, 2, 1), (8, CONVERT(0xE695B0E7A081 USING utf8mb4), 'digital', 2, 2, 1),
(9, '软件', 'software', 2, 2, 2), (9, CONVERT(0xE8BDAFE4BBB6 USING utf8mb4), 'software', 2, 2, 2),
(10, '硬件', 'hardware', 2, 2, 3), (10, CONVERT(0xE7A1ACE4BBB6 USING utf8mb4), 'hardware', 2, 2, 3),
(11, '手机游戏', 'mobile_game', 3, 2, 1), (11, CONVERT(0xE6898BE69CBAE6B8B8E6888F USING utf8mb4), 'mobile_game', 3, 2, 1),
(12, '电脑游戏', 'pc_game', 3, 2, 2), (12, CONVERT(0xE794B5E88491E6B8B8E6888F USING utf8mb4), 'pc_game', 3, 2, 2),
(13, '主机游戏', 'console_game', 3, 2, 3); (13, CONVERT(0xE4B8BBE69CBAE6B8B8E6888F USING utf8mb4), 'console_game', 3, 2, 3);
-- 5. 评论数据库(video_comment) -- 5. 评论数据库(video_comment)
USE `video_comment`; USE `video_comment`;
@@ -775,4 +777,4 @@ INSERT IGNORE INTO `admin_system_config` (`config_key`, `config_value`, `descrip
('video_upload_limit', '1024', '视频上传大小限制(MB)'), ('video_upload_limit', '1024', '视频上传大小限制(MB)'),
('video_review_enabled', '1', '视频审核开关(0:关闭,1:开启)'), ('video_review_enabled', '1', '视频审核开关(0:关闭,1:开启)'),
('comment_review_enabled', '0', '评论审核开关(0:关闭,1:开启)'), ('comment_review_enabled', '0', '评论审核开关(0:关闭,1:开启)'),
('user_register_enabled', '1', '用户注册开关(0:关闭,1:开启)'); ('user_register_enabled', '1', '用户注册开关(0:关闭,1:开启)');
+59 -28
View File
@@ -3,6 +3,8 @@
-- 注意:tenant_id 为空字符串 '' 表示 public 命名空间 -- 注意:tenant_id 为空字符串 '' 表示 public 命名空间
-- Nacos 2.x 版本需要 encrypted_data_key 字段 -- Nacos 2.x 版本需要 encrypted_data_key 字段
USE nacos_config;
-- ============================================ -- ============================================
-- 1. 公共基础配置 (common.yaml) -- 1. 公共基础配置 (common.yaml)
-- ============================================ -- ============================================
@@ -63,7 +65,11 @@ feign:
logging: logging:
level: level:
com.nuaa.video: info com.nuaa.video: info
org.springframework.security: info', '', '', 'nacos', ''); org.springframework.security: info
service:
auth:
token: ${SERVICE_AUTH_TOKEN}', '', '', 'nacos', '');
-- ============================================ -- ============================================
-- 2. 开发环境 Redis 配置 (common-redis-dev.yaml) -- 2. 开发环境 Redis 配置 (common-redis-dev.yaml)
@@ -76,7 +82,7 @@ spring:
redis: redis:
host: ${REDIS_HOST:5.180.253.115} host: ${REDIS_HOST:5.180.253.115}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:redis_EsJ8FX}', '', '', 'nacos', ''); password: ${REDIS_PASSWORD}', '', '', 'nacos', '');
-- ============================================ -- ============================================
-- 3. 高可用环境 Redis 配置 (common-redis-ha.yaml) -- 3. 高可用环境 Redis 配置 (common-redis-ha.yaml)
@@ -87,11 +93,11 @@ INSERT INTO config_info (data_id, group_id, content, tenant_id, app_name, src_us
spring: spring:
data: data:
redis: redis:
password: ${REDIS_PASSWORD:redis_HA_Cluster_2024} password: ${REDIS_PASSWORD}
sentinel: sentinel:
master: mymaster master: mymaster
nodes: ${REDIS_SENTINEL_NODES:192.168.1.101:26379,192.168.1.102:26379,192.168.1.103:26379} nodes: ${REDIS_SENTINEL_NODES}
password: ${REDIS_PASSWORD:redis_HA_Cluster_2024}', '', '', 'nacos', ''); password: ${REDIS_PASSWORD}', '', '', 'nacos', '');
-- ============================================ -- ============================================
-- 4. Gateway 服务配置 (video-gateway.yaml) -- 4. Gateway 服务配置 (video-gateway.yaml)
@@ -147,13 +153,28 @@ gateway:
- /**/auth/login - /**/auth/login
- /**/auth/register - /**/auth/register
- /**/auth/captcha - /**/auth/captcha
- /auth/username/**
- /content/video/public/** - /content/video/public/**
- /content/category/tree - /content/category/tree
- /content/category/list - /content/category/list
- /content/tag/list
- /content/tag/page
- /content/danmaku/all/**
- /content/danmaku/time
- /content/stat/**
- /user/profile/public/**
- /user/profile/search
- /user/follow/following/**
- /user/follow/followers/**
- /comment/reply/public/** - /comment/reply/public/**
- /comment/public/** - /comment/public/**
- /admin/auth/captcha - /admin/auth/captcha
- /admin/auth/login', '', '', 'nacos', ''); - /admin/auth/login
jwt:
secret: ${USER_JWT_SECRET}
token-header: Authorization
token-prefix: "Bearer "', '', '', 'nacos', '');
-- ============================================ -- ============================================
-- 5. Auth 服务配置 (video-auth.yaml) -- 5. Auth 服务配置 (video-auth.yaml)
@@ -164,7 +185,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_auth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_auth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 0 database: 0
@@ -180,8 +201,8 @@ mybatis-plus:
type-aliases-package: com.nuaa.video.auth.entity type-aliases-package: com.nuaa.video.auth.entity
jwt: jwt:
secret: video-platform-secret-key-2024-nuaa secret: ${USER_JWT_SECRET}
expiration: 86400 expiration: ${JWT_EXPIRATION}
token-header: Authorization token-header: Authorization
token-prefix: "Bearer "', '', '', 'nacos', ''); token-prefix: "Bearer "', '', '', 'nacos', '');
@@ -194,7 +215,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_user?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_user?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 1 database: 1
@@ -215,10 +236,16 @@ auth:
- /user/profile/init - /user/profile/init
- /actuator/** - /actuator/**
jwt:
secret: ${USER_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: "Bearer "
minio: minio:
endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000} endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000}
accessKey: minioadmin accessKey: ${MINIO_ACCESS_KEY}
secretKey: minioadmin123 secretKey: ${MINIO_SECRET_KEY}
bucketName: video-platform', '', '', 'nacos', ''); bucketName: video-platform', '', '', 'nacos', '');
-- ============================================ -- ============================================
@@ -230,7 +257,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_content?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_content?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 2 database: 2
@@ -259,9 +286,9 @@ mybatis-plus:
minio: minio:
endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000} endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000}
accessKey: ${MINIO_ACCESS_KEY:minioadmin} accessKey: ${MINIO_ACCESS_KEY}
secretKey: ${MINIO_SECRET_KEY:minioadmin123} secretKey: ${MINIO_SECRET_KEY}
bucketName: ${MINIO_BUCKET:video-platform} bucketName: ${MINIO_BUCKET}
ffmpeg: ffmpeg:
binary: binary:
@@ -279,8 +306,8 @@ auth:
- /actuator/** - /actuator/**
jwt: jwt:
secret: video-platform-secret-key-2024-nuaa secret: ${USER_JWT_SECRET}
expiration: 86400 expiration: ${JWT_EXPIRATION}
token-header: Authorization token-header: Authorization
token-prefix: "Bearer "', '', '', 'nacos', ''); token-prefix: "Bearer "', '', '', 'nacos', '');
@@ -293,7 +320,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_comment?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_comment?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 3 database: 3
@@ -320,8 +347,8 @@ comment:
enabled: false enabled: false
jwt: jwt:
secret: video-platform-secret-key-2024-nuaa secret: ${USER_JWT_SECRET}
expiration: 86400 expiration: ${JWT_EXPIRATION}
token-header: Authorization token-header: Authorization
token-prefix: "Bearer " token-prefix: "Bearer "
@@ -339,7 +366,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 4 database: 4
@@ -381,8 +408,8 @@ recommend:
complete-watch: 4 complete-watch: 4
jwt: jwt:
secret: video-platform-secret-key-2024-nuaa secret: ${USER_JWT_SECRET}
expiration: 86400 expiration: ${JWT_EXPIRATION}
token-header: Authorization token-header: Authorization
token-prefix: "Bearer " token-prefix: "Bearer "
@@ -401,7 +428,7 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_admin?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_admin?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&allowMultiQueries=true
username: root username: root
password: ${MYSQL_ROOT_PASSWORD:root123456} password: ${MYSQL_ROOT_PASSWORD}
data: data:
redis: redis:
database: 5 database: 5
@@ -422,8 +449,8 @@ mybatis-plus:
logic-not-delete-value: 1 logic-not-delete-value: 1
jwt: jwt:
secret: video-platform-admin-secret-key-2024-nuaa secret: ${ADMIN_JWT_SECRET}
expiration: 86400 expiration: ${JWT_EXPIRATION}
token-header: Authorization token-header: Authorization
token-prefix: "Bearer " token-prefix: "Bearer "
@@ -437,4 +464,8 @@ operation-log:
- /admin/auth/login - /admin/auth/login
- /admin/auth/logout - /admin/auth/logout
- /admin/auth/captcha - /admin/auth/captcha
- /actuator/**', '', '', 'nacos', ''); - /actuator/**
app:
init:
admin-password: ${ADMIN_INIT_PASSWORD}', '', '', 'nacos', '');
@@ -0,0 +1,68 @@
package com.nuaa.video.admin.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.admin.annotation.OperationLog;
import com.nuaa.video.admin.feign.CommentFeignClient;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 评论管理控制器
*/
@Slf4j
@RestController
@RequestMapping("/admin/platform/comment")
@RequiredArgsConstructor
public class CommentManageController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final CommentFeignClient commentFeignClient;
/**
* 获取评论列表
*/
@PreAuthorize("hasAuthority('comment:manage')")
@GetMapping("/list")
public Result<Page<Object>> getCommentList(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "videoId", required = false) Long videoId,
@RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "keyword", required = false) String keyword) {
return commentFeignClient.getCommentList(page, size, videoId, userId, keyword, internalServiceToken);
}
/**
* 获取评论详情
*/
@PreAuthorize("hasAuthority('comment:manage')")
@GetMapping("/{commentId}")
public Result<Object> getCommentDetail(@PathVariable Long commentId) {
return commentFeignClient.getCommentDetail(commentId, internalServiceToken);
}
/**
* 删除评论
*/
@OperationLog("删除评论")
@PreAuthorize("hasAuthority('comment:manage')")
@DeleteMapping("/{commentId}")
public Result<Void> deleteComment(@PathVariable Long commentId) {
return commentFeignClient.deleteComment(commentId, internalServiceToken);
}
/**
* 获取评论统计信息
*/
@PreAuthorize("hasAuthority('comment:manage')")
@GetMapping("/stats")
public Result<Object> getCommentStats() {
return commentFeignClient.getCommentStats(internalServiceToken);
}
}
@@ -6,6 +6,7 @@ import com.nuaa.video.admin.feign.ContentFeignClient;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -18,6 +19,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ContentManageController { public class ContentManageController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final ContentFeignClient contentFeignClient; private final ContentFeignClient contentFeignClient;
/** /**
@@ -30,9 +34,8 @@ public class ContentManageController {
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "status", required = false) Integer status, @RequestParam(value = "status", required = false) Integer status) {
@RequestHeader("Authorization") String token) { return contentFeignClient.getVideoList(page, size, keyword, categoryId, status, internalServiceToken);
return contentFeignClient.getVideoList(page, size, keyword, categoryId, status, token);
} }
/** /**
@@ -41,9 +44,8 @@ public class ContentManageController {
@PreAuthorize("hasAuthority('video:manage')") @PreAuthorize("hasAuthority('video:manage')")
@GetMapping("/video/{videoId}") @GetMapping("/video/{videoId}")
public Result<Object> getVideoDetail( public Result<Object> getVideoDetail(
@PathVariable Long videoId, @PathVariable Long videoId) {
@RequestHeader("Authorization") String token) { return contentFeignClient.getVideoDetail(videoId, internalServiceToken);
return contentFeignClient.getVideoDetail(videoId, token);
} }
/** /**
@@ -56,8 +58,10 @@ public class ContentManageController {
@PathVariable Long videoId, @PathVariable Long videoId,
@RequestParam Integer status, @RequestParam Integer status,
@RequestParam(required = false) String reason, @RequestParam(required = false) String reason,
@RequestHeader("Authorization") String token) { jakarta.servlet.http.HttpServletRequest request) {
return contentFeignClient.reviewVideo(videoId, status, reason, token); Object adminId = request.getAttribute("adminId");
Long currentAdminId = adminId instanceof Long ? (Long) adminId : 0L;
return contentFeignClient.reviewVideo(videoId, status, reason, internalServiceToken, currentAdminId);
} }
/** /**
@@ -68,8 +72,10 @@ public class ContentManageController {
@PutMapping("/video/disable/{videoId}") @PutMapping("/video/disable/{videoId}")
public Result<Void> disableVideo( public Result<Void> disableVideo(
@PathVariable Long videoId, @PathVariable Long videoId,
@RequestHeader("Authorization") String token) { jakarta.servlet.http.HttpServletRequest request) {
return contentFeignClient.disableVideo(videoId, token); Object adminId = request.getAttribute("adminId");
Long currentAdminId = adminId instanceof Long ? (Long) adminId : 0L;
return contentFeignClient.disableVideo(videoId, internalServiceToken, currentAdminId);
} }
/** /**
@@ -77,8 +83,8 @@ public class ContentManageController {
*/ */
@PreAuthorize("hasAuthority('category:manage')") @PreAuthorize("hasAuthority('category:manage')")
@GetMapping("/category/list") @GetMapping("/category/list")
public Result<Object> getCategoryList(@RequestHeader("Authorization") String token) { public Result<Object> getCategoryList() {
return contentFeignClient.getCategoryList(token); return contentFeignClient.getCategoryList(internalServiceToken);
} }
/** /**
@@ -88,9 +94,8 @@ public class ContentManageController {
@PreAuthorize("hasAuthority('category:manage')") @PreAuthorize("hasAuthority('category:manage')")
@PostMapping("/category") @PostMapping("/category")
public Result<Void> addCategory( public Result<Void> addCategory(
@RequestBody Object category, @RequestBody Object category) {
@RequestHeader("Authorization") String token) { return contentFeignClient.addCategory(category, internalServiceToken);
return contentFeignClient.addCategory(category, token);
} }
/** /**
@@ -100,9 +105,8 @@ public class ContentManageController {
@PreAuthorize("hasAuthority('category:manage')") @PreAuthorize("hasAuthority('category:manage')")
@PutMapping("/category") @PutMapping("/category")
public Result<Void> updateCategory( public Result<Void> updateCategory(
@RequestBody Object category, @RequestBody Object category) {
@RequestHeader("Authorization") String token) { return contentFeignClient.updateCategory(category, internalServiceToken);
return contentFeignClient.updateCategory(category, token);
} }
/** /**
@@ -112,8 +116,7 @@ public class ContentManageController {
@PreAuthorize("hasAuthority('category:manage')") @PreAuthorize("hasAuthority('category:manage')")
@DeleteMapping("/category/{categoryId}") @DeleteMapping("/category/{categoryId}")
public Result<Void> deleteCategory( public Result<Void> deleteCategory(
@PathVariable Long categoryId, @PathVariable Long categoryId) {
@RequestHeader("Authorization") String token) { return contentFeignClient.deleteCategory(categoryId, internalServiceToken);
return contentFeignClient.deleteCategory(categoryId, token);
} }
} }
@@ -0,0 +1,79 @@
package com.nuaa.video.admin.controller;
import com.nuaa.video.admin.feign.CommentFeignClient;
import com.nuaa.video.admin.feign.ContentFeignClient;
import com.nuaa.video.admin.feign.UserFeignClient;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 后台首页统计控制器
*/
@Slf4j
@RestController
@RequestMapping("/admin/dashboard")
@RequiredArgsConstructor
public class DashboardController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserFeignClient userFeignClient;
private final ContentFeignClient contentFeignClient;
private final CommentFeignClient commentFeignClient;
/**
* 获取后台仪表盘统计信息
*/
@PreAuthorize("hasAuthority('operation:log')")
@GetMapping("/stats")
public Result<Map<String, Object>> getDashboardStats() {
Map<String, Object> result = new HashMap<>();
Result<Object> userStats = userFeignClient.getUserStats(internalServiceToken);
Result<Object> videoStats = contentFeignClient.getVideoStats(internalServiceToken);
Result<Object> commentStats = commentFeignClient.getCommentStats(internalServiceToken);
result.put("userStats", userStats.getData());
result.put("videoStats", videoStats.getData());
result.put("commentStats", commentStats.getData());
long totalUsers = readLong(userStats.getData(), "totalUsers");
long totalVideos = readLong(videoStats.getData(), "totalVideos");
long totalComments = readLong(commentStats.getData(), "commentCount");
result.put("overview", Map.of(
"totalUsers", totalUsers,
"totalVideos", totalVideos,
"totalComments", totalComments
));
return Result.success(result);
}
@SuppressWarnings("unchecked")
private long readLong(Object payload, String key) {
if (!(payload instanceof Map<?, ?> map)) {
return 0L;
}
Object value = ((Map<String, Object>) map).get(key);
if (value instanceof Number number) {
return number.longValue();
}
if (value != null) {
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException ignored) {
}
}
return 0L;
}
}
@@ -6,6 +6,7 @@ import com.nuaa.video.admin.feign.UserFeignClient;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -18,6 +19,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserManageController { public class UserManageController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserFeignClient userFeignClient; private final UserFeignClient userFeignClient;
/** /**
@@ -28,9 +32,8 @@ public class UserManageController {
public Result<Page<Object>> getUserList( public Result<Page<Object>> getUserList(
@RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword) {
@RequestHeader("Authorization") String token) { return userFeignClient.getUserList(page, size, keyword, internalServiceToken);
return userFeignClient.getUserList(page, size, keyword, token);
} }
/** /**
@@ -39,9 +42,8 @@ public class UserManageController {
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage')")
@GetMapping("/{userId}") @GetMapping("/{userId}")
public Result<Object> getUserDetail( public Result<Object> getUserDetail(
@PathVariable Long userId, @PathVariable Long userId) {
@RequestHeader("Authorization") String token) { return userFeignClient.getUserDetail(userId, internalServiceToken);
return userFeignClient.getUserDetail(userId, token);
} }
/** /**
@@ -52,9 +54,8 @@ public class UserManageController {
@PutMapping("/{userId}/status/{status}") @PutMapping("/{userId}/status/{status}")
public Result<Void> updateUserStatus( public Result<Void> updateUserStatus(
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Integer status, @PathVariable Integer status) {
@RequestHeader("Authorization") String token) { return userFeignClient.updateUserStatus(userId, status, internalServiceToken);
return userFeignClient.updateUserStatus(userId, status, token);
} }
/** /**
@@ -65,8 +66,7 @@ public class UserManageController {
@PutMapping("/{userId}/reset-password") @PutMapping("/{userId}/reset-password")
public Result<Void> resetUserPassword( public Result<Void> resetUserPassword(
@PathVariable Long userId, @PathVariable Long userId,
@RequestParam String newPassword, @RequestParam String newPassword) {
@RequestHeader("Authorization") String token) { return userFeignClient.resetUserPassword(userId, newPassword, internalServiceToken);
return userFeignClient.resetUserPassword(userId, newPassword, token);
} }
} }
@@ -27,10 +27,12 @@ public class LoginDTO implements Serializable {
/** /**
* 验证码 * 验证码
*/ */
@NotBlank(message = "验证码不能为空")
private String captcha; private String captcha;
/** /**
* 验证码Key * 验证码Key
*/ */
@NotBlank(message = "验证码Key不能为空")
private String captchaKey; private String captchaKey;
} }
@@ -21,25 +21,25 @@ public interface CommentFeignClient {
@RequestParam(value = "videoId", required = false) Long videoId, @RequestParam(value = "videoId", required = false) Long videoId,
@RequestParam(value = "userId", required = false) Long userId, @RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 获取评论详情 * 获取评论详情
*/ */
@GetMapping("/comment/admin/{commentId}") @GetMapping("/comment/admin/{commentId}")
Result<Object> getCommentDetail(@PathVariable("commentId") Long commentId, Result<Object> getCommentDetail(@PathVariable("commentId") Long commentId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 删除评论 * 删除评论
*/ */
@DeleteMapping("/comment/admin/{commentId}") @DeleteMapping("/comment/admin/{commentId}")
Result<Void> deleteComment(@PathVariable("commentId") Long commentId, Result<Void> deleteComment(@PathVariable("commentId") Long commentId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 获取评论统计信息 * 获取评论统计信息
*/ */
@GetMapping("/comment/admin/stats") @GetMapping("/comment/admin/stats")
Result<Object> getCommentStats(@RequestHeader("Authorization") String token); Result<Object> getCommentStats(@RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -19,24 +19,24 @@ public class CommentFeignClientFallbackFactory implements FallbackFactory<Commen
return new CommentFeignClient() { return new CommentFeignClient() {
@Override @Override
public Result<Page<Object>> getCommentList(Integer page, Integer size, Long videoId, Long userId, String keyword, String token) { public Result<Page<Object>> getCommentList(Integer page, Integer size, Long videoId, Long userId, String keyword, String internalToken) {
return Result.error("获取评论列表失败:服务不可用"); return Result.error("获取评论列表失败:服务不可用");
} }
@Override @Override
public Result<Object> getCommentDetail(Long commentId, String token) { public Result<Object> getCommentDetail(Long commentId, String internalToken) {
return Result.error("获取评论详情失败:服务不可用"); return Result.error("获取评论详情失败:服务不可用");
} }
@Override @Override
public Result<Void> deleteComment(Long commentId, String token) { public Result<Void> deleteComment(Long commentId, String internalToken) {
return Result.error("删除评论失败:服务不可用"); return Result.error("删除评论失败:服务不可用");
} }
@Override @Override
public Result<Object> getCommentStats(String token) { public Result<Object> getCommentStats(String internalToken) {
return Result.error("获取评论统计信息失败:服务不可用"); return Result.error("获取评论统计信息失败:服务不可用");
} }
}; };
} }
} }
@@ -21,14 +21,14 @@ public interface ContentFeignClient {
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "status", required = false) Integer status, @RequestParam(value = "status", required = false) Integer status,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 获取视频详情 * 获取视频详情
*/ */
@GetMapping("/content/video/admin/{videoId}") @GetMapping("/content/video/admin/{videoId}")
Result<Object> getVideoDetail(@PathVariable("videoId") Long videoId, Result<Object> getVideoDetail(@PathVariable("videoId") Long videoId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 审核视频 * 审核视频
@@ -37,37 +37,42 @@ public interface ContentFeignClient {
Result<Void> reviewVideo(@PathVariable("videoId") Long videoId, Result<Void> reviewVideo(@PathVariable("videoId") Long videoId,
@RequestParam("status") Integer status, @RequestParam("status") Integer status,
@RequestParam(value = "reason", required = false) String reason, @RequestParam(value = "reason", required = false) String reason,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken,
@RequestHeader("X-Admin-Id") Long adminId);
/** /**
* 下架视频 * 下架视频
*/ */
@PutMapping("/content/video/admin/disable/{videoId}") @PutMapping("/content/video/admin/disable/{videoId}")
Result<Void> disableVideo(@PathVariable("videoId") Long videoId, Result<Void> disableVideo(@PathVariable("videoId") Long videoId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken,
@RequestHeader("X-Admin-Id") Long adminId);
/** /**
* 分类列表 * 分类列表
*/ */
@GetMapping("/content/category/admin/list") @GetMapping("/content/category/admin/list")
Result<Object> getCategoryList(@RequestHeader("Authorization") String token); Result<Object> getCategoryList(@RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 添加分类 * 添加分类
*/ */
@PostMapping("/content/category/admin") @PostMapping("/content/category/admin")
Result<Void> addCategory(@RequestBody Object category, @RequestHeader("Authorization") String token); Result<Void> addCategory(@RequestBody Object category, @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 更新分类 * 更新分类
*/ */
@PutMapping("/content/category/admin") @PutMapping("/content/category/admin")
Result<Void> updateCategory(@RequestBody Object category, @RequestHeader("Authorization") String token); Result<Void> updateCategory(@RequestBody Object category, @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 删除分类 * 删除分类
*/ */
@DeleteMapping("/content/category/admin/{categoryId}") @DeleteMapping("/content/category/admin/{categoryId}")
Result<Void> deleteCategory(@PathVariable("categoryId") Long categoryId, Result<Void> deleteCategory(@PathVariable("categoryId") Long categoryId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
}
@GetMapping("/content/video/admin/stats")
Result<Object> getVideoStats(@RequestHeader("X-Internal-Token") String internalToken);
}
@@ -19,44 +19,49 @@ public class ContentFeignClientFallbackFactory implements FallbackFactory<Conten
return new ContentFeignClient() { return new ContentFeignClient() {
@Override @Override
public Result<Page<Object>> getVideoList(Integer page, Integer size, String keyword, Long categoryId, Integer status, String token) { public Result<Page<Object>> getVideoList(Integer page, Integer size, String keyword, Long categoryId, Integer status, String internalToken) {
return Result.error("获取视频列表失败:服务不可用"); return Result.error("获取视频列表失败:服务不可用");
} }
@Override @Override
public Result<Object> getVideoDetail(Long videoId, String token) { public Result<Object> getVideoDetail(Long videoId, String internalToken) {
return Result.error("获取视频详情失败:服务不可用"); return Result.error("获取视频详情失败:服务不可用");
} }
@Override @Override
public Result<Void> reviewVideo(Long videoId, Integer status, String reason, String token) { public Result<Void> reviewVideo(Long videoId, Integer status, String reason, String internalToken, Long adminId) {
return Result.error("审核视频失败:服务不可用"); return Result.error("审核视频失败:服务不可用");
} }
@Override @Override
public Result<Void> disableVideo(Long videoId, String token) { public Result<Void> disableVideo(Long videoId, String internalToken, Long adminId) {
return Result.error("下架视频失败:服务不可用"); return Result.error("下架视频失败:服务不可用");
} }
@Override @Override
public Result<Object> getCategoryList(String token) { public Result<Object> getCategoryList(String internalToken) {
return Result.error("获取分类列表失败:服务不可用"); return Result.error("获取分类列表失败:服务不可用");
} }
@Override @Override
public Result<Void> addCategory(Object category, String token) { public Result<Void> addCategory(Object category, String internalToken) {
return Result.error("添加分类失败:服务不可用"); return Result.error("添加分类失败:服务不可用");
} }
@Override @Override
public Result<Void> updateCategory(Object category, String token) { public Result<Void> updateCategory(Object category, String internalToken) {
return Result.error("更新分类失败:服务不可用"); return Result.error("更新分类失败:服务不可用");
} }
@Override @Override
public Result<Void> deleteCategory(Long categoryId, String token) { public Result<Void> deleteCategory(Long categoryId, String internalToken) {
return Result.error("删除分类失败:服务不可用"); return Result.error("删除分类失败:服务不可用");
} }
@Override
public Result<Object> getVideoStats(String internalToken) {
return Result.error("获取视频统计失败:服务不可用");
}
}; };
} }
} }
@@ -19,14 +19,14 @@ public interface UserFeignClient {
@RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 获取用户详情 * 获取用户详情
*/ */
@GetMapping("/user/profile/admin/{userId}") @GetMapping("/user/profile/admin/{userId}")
Result<Object> getUserDetail(@PathVariable("userId") Long userId, Result<Object> getUserDetail(@PathVariable("userId") Long userId,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 禁用/启用用户 * 禁用/启用用户
@@ -34,7 +34,7 @@ public interface UserFeignClient {
@PutMapping("/user/profile/admin/status/{userId}/{status}") @PutMapping("/user/profile/admin/status/{userId}/{status}")
Result<Void> updateUserStatus(@PathVariable("userId") Long userId, Result<Void> updateUserStatus(@PathVariable("userId") Long userId,
@PathVariable("status") Integer status, @PathVariable("status") Integer status,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 重置用户密码 * 重置用户密码
@@ -42,5 +42,8 @@ public interface UserFeignClient {
@PutMapping("/user/profile/admin/reset-password/{userId}") @PutMapping("/user/profile/admin/reset-password/{userId}")
Result<Void> resetUserPassword(@PathVariable("userId") Long userId, Result<Void> resetUserPassword(@PathVariable("userId") Long userId,
@RequestParam String newPassword, @RequestParam String newPassword,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
}
@GetMapping("/user/profile/admin/stats")
Result<Object> getUserStats(@RequestHeader("X-Internal-Token") String internalToken);
}
@@ -18,28 +18,34 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
log.error("用户服务调用失败:", cause); log.error("用户服务调用失败:", cause);
return new UserFeignClient() { return new UserFeignClient() {
@Override @Override
public Result<Page<Object>> getUserList(Integer page, Integer size, String keyword, String token) { public Result<Page<Object>> getUserList(Integer page, Integer size, String keyword, String internalToken) {
log.error("获取用户列表失败"); log.error("获取用户列表失败");
return Result.error("获取用户列表失败,用户服务不可用"); return Result.error("获取用户列表失败,用户服务不可用");
} }
@Override @Override
public Result<Object> getUserDetail(Long userId, String token) { public Result<Object> getUserDetail(Long userId, String internalToken) {
log.error("获取用户详情失败,用户ID: {}", userId); log.error("获取用户详情失败,用户ID: {}", userId);
return Result.error("获取用户详情失败,用户服务不可用"); return Result.error("获取用户详情失败,用户服务不可用");
} }
@Override @Override
public Result<Void> updateUserStatus(Long userId, Integer status, String token) { public Result<Void> updateUserStatus(Long userId, Integer status, String internalToken) {
log.error("更新用户状态失败,用户ID: {}, 状态: {}", userId, status); log.error("更新用户状态失败,用户ID: {}, 状态: {}", userId, status);
return Result.error("更新用户状态失败,用户服务不可用"); return Result.error("更新用户状态失败,用户服务不可用");
} }
@Override @Override
public Result<Void> resetUserPassword(Long userId, String newPassword, String token) { public Result<Void> resetUserPassword(Long userId, String newPassword, String internalToken) {
log.error("重置用户密码失败,用户ID: {}", userId); log.error("重置用户密码失败,用户ID: {}", userId);
return Result.error("重置用户密码失败,用户服务不可用"); return Result.error("重置用户密码失败,用户服务不可用");
} }
@Override
public Result<Object> getUserStats(String internalToken) {
log.error("获取用户统计失败");
return Result.error("获取用户统计失败,用户服务不可用");
}
}; };
} }
} }
@@ -46,15 +46,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
.map(SimpleGrantedAuthority::new) .map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()); .collect(Collectors.toList());
// 创建认证对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, simpleGrantedAuthorities);
// 设置用户详情 // 设置用户详情
AdminUserDetails userDetails = new AdminUserDetails(); AdminUserDetails userDetails = new AdminUserDetails();
userDetails.setId(adminId); userDetails.setId(adminId);
userDetails.setUsername(username); userDetails.setUsername(username);
userDetails.setAuthorities(simpleGrantedAuthorities); userDetails.setAuthorities(simpleGrantedAuthorities);
// 将完整用户详情作为 principal,便于 @AuthenticationPrincipal 直接获取
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, simpleGrantedAuthorities);
authentication.setDetails(userDetails); authentication.setDetails(userDetails);
// 设置认证信息到安全上下文 // 设置认证信息到安全上下文
@@ -82,4 +82,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
} }
return null; return null;
} }
} }
@@ -4,8 +4,10 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
@@ -20,14 +22,19 @@ import java.util.function.Function;
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor
public class JwtTokenProvider { public class JwtTokenProvider {
private static final String TOKEN_KEY_PREFIX = "admin:token:";
@Value("${jwt.secret}") @Value("${jwt.secret}")
private String secret; private String secret;
@Value("${jwt.expiration}") @Value("${jwt.expiration}")
private Long expiration; private Long expiration;
private final StringRedisTemplate stringRedisTemplate;
/** /**
* 生成JWT令牌 * 生成JWT令牌
*/ */
@@ -53,7 +60,7 @@ public class JwtTokenProvider {
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
Jwts.parser().verifyWith(getSignKey()).build().parseSignedClaims(token); Jwts.parser().verifyWith(getSignKey()).build().parseSignedClaims(token);
return true; return Boolean.TRUE.equals(stringRedisTemplate.hasKey(TOKEN_KEY_PREFIX + token));
} catch (JwtException | IllegalArgumentException e) { } catch (JwtException | IllegalArgumentException e) {
log.error("无效的JWT令牌: {}", e.getMessage()); log.error("无效的JWT令牌: {}", e.getMessage());
return false; return false;
@@ -107,4 +114,4 @@ public class JwtTokenProvider {
.getPayload(); .getPayload();
return claimsResolver.apply(claims); return claimsResolver.apply(claims);
} }
} }
@@ -22,7 +22,7 @@ public class AdminUserInitService implements ApplicationRunner {
private final AdminUserRoleMapper adminUserRoleMapper; private final AdminUserRoleMapper adminUserRoleMapper;
private final PasswordService passwordService; private final PasswordService passwordService;
@Value("${app.init.admin-password:admin123}") @Value("${app.init.admin-password}")
private String initAdminPassword; private String initAdminPassword;
@Override @Override
@@ -49,7 +49,7 @@ public class AdminUserInitService implements ApplicationRunner {
adminUser.setRealName("系统管理员"); adminUser.setRealName("系统管理员");
adminUser.setEmail("admin@example.com"); adminUser.setEmail("admin@example.com");
adminUser.setStatus(1); // 正常状态 adminUser.setStatus(1); // 正常状态
adminUser.setSalt("aaaaaa"); // 盐值可以设置为默认值,该字段已抛弃 adminUser.setSalt(null);
adminUser.setCreatedBy("system"); adminUser.setCreatedBy("system");
adminUser.setUpdatedBy("system"); adminUser.setUpdatedBy("system");
adminUser.setCreateTime(LocalDateTime.now()); adminUser.setCreateTime(LocalDateTime.now());
@@ -69,4 +69,4 @@ public class AdminUserInitService implements ApplicationRunner {
log.info("管理员账号初始化完成"); log.info("管理员账号初始化完成");
} }
} }
@@ -90,18 +90,16 @@ public class AuthServiceImpl implements AuthService {
} }
// 验证验证码 // 验证验证码
if (StringUtils.hasText(loginDTO.getCaptchaKey()) && StringUtils.hasText(loginDTO.getCaptcha())) { String captchaKey = CAPTCHA_KEY_PREFIX + loginDTO.getCaptchaKey();
String captchaKey = CAPTCHA_KEY_PREFIX + loginDTO.getCaptchaKey(); String captchaValue = (String) redisTemplate.opsForValue().get(captchaKey);
String captchaValue = (String) redisTemplate.opsForValue().get(captchaKey);
if (!StringUtils.hasText(captchaValue) || !captchaValue.equalsIgnoreCase(loginDTO.getCaptcha())) { if (!StringUtils.hasText(captchaValue) || !captchaValue.equalsIgnoreCase(loginDTO.getCaptcha())) {
return Result.error("验证码错误"); return Result.error("验证码错误");
}
// 验证通过后删除验证码
redisTemplate.delete(captchaKey);
} }
// 验证通过后删除验证码
redisTemplate.delete(captchaKey);
try { try {
// 进行认证 // 进行认证
Authentication authentication = authenticationManager.authenticate( Authentication authentication = authenticationManager.authenticate(
@@ -276,4 +274,4 @@ public class AuthServiceImpl implements AuthService {
return ip; return ip;
} }
} }
@@ -1,5 +1,5 @@
server: server:
port: 9004 port: 9006
spring: spring:
application: application:
@@ -18,14 +18,14 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_admin?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_admin?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 3 database: 5
timeout: 10000 timeout: 10000
lettuce: lettuce:
pool: pool:
@@ -40,7 +40,6 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
@@ -50,4 +49,14 @@ mybatis-plus:
app: app:
init: init:
admin-password: admin123 admin-password: ${ADMIN_INIT_PASSWORD}
service:
auth:
token: ${SERVICE_AUTH_TOKEN}
jwt:
secret: ${ADMIN_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: Bearer
@@ -1,17 +1,14 @@
package com.nuaa.video.auth.config; package com.nuaa.video.auth.config;
import cn.hutool.jwt.Claims;
import com.nuaa.video.auth.filter.JwtAuthenticationFilter; import com.nuaa.video.auth.filter.JwtAuthenticationFilter;
import com.nuaa.video.auth.service.AuthUserService; import com.nuaa.video.auth.service.AuthUserService;
import jakarta.servlet.FilterChain; import org.springframework.beans.factory.annotation.Value;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -20,20 +17,21 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.View; import org.springframework.web.servlet.View;
import java.io.IOException;
/** /**
* 安全配置 * 安全配置
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
private final AuthUserService authUserService; private final AuthUserService authUserService;
@Value("${service.auth.token}")
private String internalServiceToken;
// 使用构造注入并添加@Lazy注解 // 使用构造注入并添加@Lazy注解
public SecurityConfig(@Lazy AuthUserService authUserService) { public SecurityConfig(@Lazy AuthUserService authUserService) {
this.authUserService = authUserService; this.authUserService = authUserService;
@@ -69,9 +67,14 @@ public class SecurityConfig {
"/auth/login", "/auth/login",
"/auth/register", "/auth/register",
"/auth/captcha", "/auth/captcha",
"/auth/refresh",
"/auth/username/**",
"/actuator/**", "/actuator/**",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers("/auth/admin/**")
.access((authentication, context) ->
new AuthorizationDecision(isInternalRequest(context.getRequest().getHeader("X-Internal-Token"))))
.anyRequest().authenticated() .anyRequest().authenticated()
) )
//添加JWT认证过滤器 //添加JWT认证过滤器
@@ -81,5 +84,8 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
private boolean isInternalRequest(String internalToken) {
return internalServiceToken.equals(internalToken);
}
} }
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.auth.entity.AuthUser; import com.nuaa.video.auth.entity.AuthUser;
import com.nuaa.video.auth.service.AuthUserService; import com.nuaa.video.auth.service.AuthUserService;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/** /**
@@ -17,13 +19,19 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminAuthController { public class AdminAuthController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final AuthUserService authUserService; private final AuthUserService authUserService;
/** /**
* 获取登录统计 - 管理接口 * 获取登录统计 - 管理接口
*/ */
@GetMapping("/login/stats") @GetMapping("/login/stats")
public Result<Object> getLoginStats() { public Result<Object> getLoginStats(HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return authUserService.getLoginStats(); return authUserService.getLoginStats();
} }
@@ -33,7 +41,11 @@ public class AdminAuthController {
@PutMapping("/reset-password/{userId}") @PutMapping("/reset-password/{userId}")
public Result<Void> resetPassword( public Result<Void> resetPassword(
@PathVariable Long userId, @PathVariable Long userId,
@RequestParam String newPassword) { @RequestParam String newPassword,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return authUserService.adminResetPassword(userId, newPassword); return authUserService.adminResetPassword(userId, newPassword);
} }
@@ -43,7 +55,15 @@ public class AdminAuthController {
@PutMapping("/lock/{userId}/{locked}") @PutMapping("/lock/{userId}/{locked}")
public Result<Void> lockUser( public Result<Void> lockUser(
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Boolean locked) { @PathVariable Boolean locked,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return authUserService.lockUser(userId, locked); return authUserService.lockUser(userId, locked);
} }
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -70,7 +70,7 @@ public class AuthController {
if (userId == null) { if (userId == null) {
return Result.error("未获取到用户ID"); return Result.error("未获取到用户ID");
} }
return authUserService.logout(userId); return authUserService.logout(userId, extractAccessToken(request));
} }
/** /**
@@ -114,6 +114,14 @@ public class AuthController {
return null; return null;
} }
private String extractAccessToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
return null;
}
/** /**
* 根据ID获取用户名 * 根据ID获取用户名
*/ */
@@ -122,4 +130,4 @@ public class AuthController {
AuthUser user = authUserService.getById(userId); AuthUser user = authUserService.getById(userId);
return user != null ? user.getUsername() : null; return user != null ? user.getUsername() : null;
} }
} }
@@ -27,10 +27,12 @@ public class LoginDTO implements Serializable {
/** /**
* 验证码 * 验证码
*/ */
@NotBlank(message = "验证码不能为空")
private String captcha; private String captcha;
/** /**
* 验证码KEY * 验证码KEY
*/ */
@NotBlank(message = "验证码KEY不能为空")
private String captchaKey; private String captchaKey;
} }
@@ -4,12 +4,14 @@ import com.nuaa.video.auth.feign.fallback.UserClientFallback;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "video-user", fallback = UserClientFallback.class) @FeignClient(name = "video-user", fallback = UserClientFallback.class)
public interface UserClient { public interface UserClient {
@PostMapping("/user/profile/init") @PostMapping("/user/profile/init")
Result<Void> createUserInfo(@RequestParam("userId") Long userId, Result<Void> createUserInfo(@RequestHeader("X-Internal-Token") String internalToken,
@RequestParam("userId") Long userId,
@RequestParam("username") String username); @RequestParam("username") String username);
} }
@@ -10,8 +10,8 @@ import org.springframework.stereotype.Component;
public class UserClientFallback implements UserClient { public class UserClientFallback implements UserClient {
@Override @Override
public Result<Void> createUserInfo(Long userId, String username) { public Result<Void> createUserInfo(String internalToken, Long userId, String username) {
log.error("创建用户资料失败(回退): userId={}, username={}", userId, username); log.error("创建用户资料失败(回退): userId={}, username={}", userId, username);
return Result.error("用户服务不可用,资料将在首次访问时创建"); return Result.error("用户服务不可用,资料将在首次访问时创建");
} }
} }
@@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -18,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* JWT认证过滤器 * JWT认证过滤器
@@ -27,8 +29,11 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private static final String TOKEN_PREFIX = "Bearer "; private static final String TOKEN_PREFIX = "Bearer ";
private static final String USER_ACCESS_TOKEN_PREFIX = "user:access-token:";
private final JwtUtils jwtUtils;
private final StringRedisTemplate stringRedisTemplate;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -42,12 +47,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 验证令牌并提取用户ID // 验证令牌并提取用户ID
Long userId = jwtUtils.getUserIdFromToken(token); Long userId = jwtUtils.getUserIdFromToken(token);
String username = jwtUtils.getUsernameFromToken(token); String username = jwtUtils.getUsernameFromToken(token);
boolean adminPath = request.getRequestURI().startsWith("/auth/admin/");
if (userId != null && !jwtUtils.isTokenExpired(token)) { if (userId != null
&& !jwtUtils.isTokenExpired(token)
&& (adminPath || Boolean.TRUE.equals(stringRedisTemplate.hasKey(USER_ACCESS_TOKEN_PREFIX + token)))) {
log.debug("JWT验证成功, userId={}, username={}", userId, username); log.debug("JWT验证成功, userId={}, username={}", userId, username);
// 设置用户权限(这里使用空列表,实际应用中可以从令牌或数据库获取) List<String> authorityClaims = jwtUtils.getClaimFromToken(token, claims -> claims.get("authorities", List.class));
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); List<SimpleGrantedAuthority> authorities = authorityClaims == null
? new ArrayList<>()
: authorityClaims.stream()
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(ArrayList::new));
// 创建认证对象 // 创建认证对象
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
@@ -61,6 +74,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 在请求属性中存储用户ID,供控制器使用 // 在请求属性中存储用户ID,供控制器使用
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
request.setAttribute("username", username);
if (!authorities.isEmpty()) {
request.setAttribute("adminId", userId);
}
} }
} catch (Exception e) { } catch (Exception e) {
log.error("JWT验证失败: {}", e.getMessage()); log.error("JWT验证失败: {}", e.getMessage());
@@ -81,4 +98,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
} }
return null; return null;
} }
} }
@@ -50,7 +50,7 @@ public interface AuthUserService extends IService<AuthUser> {
* @param userId 用户ID * @param userId 用户ID
* @return 登出结果 * @return 登出结果
*/ */
Result<Void> logout(Long userId); Result<Void> logout(Long userId, String accessToken);
/** /**
* 获取当前登录用户信息 * 获取当前登录用户信息
@@ -74,4 +74,4 @@ public interface AuthUserService extends IService<AuthUser> {
* 锁定/解锁用户账号 - 管理接口 * 锁定/解锁用户账号 - 管理接口
*/ */
Result<Void> lockUser(Long userId, Boolean locked); Result<Void> lockUser(Long userId, Boolean locked);
} }
@@ -1,6 +1,5 @@
package com.nuaa.video.auth.service.impl; package com.nuaa.video.auth.service.impl;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -20,16 +19,17 @@ import com.nuaa.video.auth.utils.RedisUtils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -47,12 +47,19 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final RedisUtils redisUtils; private final RedisUtils redisUtils;
private final StringRedisTemplate stringRedisTemplate;
private final UserClient userClient; private final UserClient userClient;
@Value("${jwt.expiration:86400}") @Value("${jwt.expiration:86400}")
private long expiration; private long expiration;
private static final String USER_TOKEN_PREFIX = "user:token:"; @Value("${service.auth.token}")
private String internalServiceToken;
private static final String USER_REFRESH_TOKEN_PREFIX = "user:refresh-token:";
private static final String USER_REFRESH_INDEX_PREFIX = "user:refresh-index:";
private static final String USER_ACCESS_TOKEN_PREFIX = "user:access-token:";
private static final String USER_ACCESS_TOKEN_SET_PREFIX = "user:access-token-set:";
private static final String CAPTCHA_PREFIX = "captcha:"; private static final String CAPTCHA_PREFIX = "captcha:";
private static final String USER_ROLE_PREFIX = "user:role:"; private static final String USER_ROLE_PREFIX = "user:role:";
private static final String USER_PERMISSION_PREFIX = "user:permission:"; private static final String USER_PERMISSION_PREFIX = "user:permission:";
@@ -60,17 +67,15 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
@Override @Override
public Result<TokenInfoDTO> login(LoginDTO loginDTO) { public Result<TokenInfoDTO> login(LoginDTO loginDTO) {
// 验证验证码 // 验证验证码
if (StringUtils.hasText(loginDTO.getCaptchaKey()) && StringUtils.hasText(loginDTO.getCaptcha())) { String captchaCache = (String) redisUtils.get(CAPTCHA_PREFIX + loginDTO.getCaptchaKey());
String captchaCache = (String) redisUtils.get(CAPTCHA_PREFIX + loginDTO.getCaptchaKey()); if (captchaCache == null) {
if (captchaCache == null) { return Result.error(ResultCode.PARAM_ERROR.getCode(), "验证码已过期");
return Result.error(ResultCode.PARAM_ERROR.getCode(), "验证码已过期");
}
if (!loginDTO.getCaptcha().equalsIgnoreCase(captchaCache)) {
return Result.error(ResultCode.PARAM_ERROR.getCode(), "验证码错误");
}
// 验证通过后移除验证码
redisUtils.del(CAPTCHA_PREFIX + loginDTO.getCaptchaKey());
} }
if (!loginDTO.getCaptcha().equalsIgnoreCase(captchaCache)) {
return Result.error(ResultCode.PARAM_ERROR.getCode(), "验证码错误");
}
// 验证通过后移除验证码
redisUtils.del(CAPTCHA_PREFIX + loginDTO.getCaptchaKey());
// 查询用户 // 查询用户
AuthUser user = getByUsername(loginDTO.getUsername()); AuthUser user = getByUsername(loginDTO.getUsername());
@@ -96,8 +101,8 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
String accessToken = jwtUtils.generateToken(user.getId(), user.getUsername()); String accessToken = jwtUtils.generateToken(user.getId(), user.getUsername());
String refreshToken = RandomUtil.randomString(32); String refreshToken = RandomUtil.randomString(32);
// 存储刷新令牌 storeRefreshToken(user.getId(), refreshToken);
redisUtils.set(USER_TOKEN_PREFIX + user.getId(), refreshToken, expiration * 2); storeAccessToken(user.getId(), accessToken);
// 缓存用户角色和权限 // 缓存用户角色和权限
cacheUserAuthInfo(user.getId()); cacheUserAuthInfo(user.getId());
@@ -188,7 +193,7 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
@Override @Override
public void afterCommit() { public void afterCommit() {
try { try {
userClient.createUserInfo(userId, username); userClient.createUserInfo(internalServiceToken, userId, username);
log.info("用户资料创建成功:userId={}", userId); log.info("用户资料创建成功:userId={}", userId);
} catch (Exception e) { } catch (Exception e) {
log.error("创建用户资料失败,用户ID: " + userId, e); log.error("创建用户资料失败,用户ID: " + userId, e);
@@ -211,12 +216,14 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
return Result.error(ResultCode.UNAUTHORIZED.getCode(), "刷新令牌无效"); return Result.error(ResultCode.UNAUTHORIZED.getCode(), "刷新令牌无效");
} }
// 解析accessToken获取用户ID String userIdValue = stringRedisTemplate.opsForValue().get(USER_REFRESH_INDEX_PREFIX + refreshToken);
String accessToken = jwtUtils.getUserIdFromToken(refreshToken).toString(); if (!StringUtils.hasText(userIdValue)) {
Long userId = Long.parseLong(accessToken); return Result.error(ResultCode.UNAUTHORIZED.getCode(), "刷新令牌无效");
}
Long userId = Long.parseLong(userIdValue);
// 验证refreshToken是否存在且匹配 // 验证refreshToken是否存在且匹配
String cacheRefreshToken = (String) redisUtils.get(USER_TOKEN_PREFIX + userId); String cacheRefreshToken = (String) redisUtils.get(USER_REFRESH_TOKEN_PREFIX + userId);
if (cacheRefreshToken == null || !cacheRefreshToken.equals(refreshToken)) { if (cacheRefreshToken == null || !cacheRefreshToken.equals(refreshToken)) {
return Result.error(ResultCode.UNAUTHORIZED.getCode(), "刷新令牌无效"); return Result.error(ResultCode.UNAUTHORIZED.getCode(), "刷新令牌无效");
} }
@@ -231,8 +238,9 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
String newAccessToken = jwtUtils.generateToken(user.getId(), user.getUsername()); String newAccessToken = jwtUtils.generateToken(user.getId(), user.getUsername());
String newRefreshToken = RandomUtil.randomString(32); String newRefreshToken = RandomUtil.randomString(32);
// 更新刷新令牌 stringRedisTemplate.delete(USER_REFRESH_INDEX_PREFIX + refreshToken);
redisUtils.set(USER_TOKEN_PREFIX + user.getId(), newRefreshToken, expiration * 2); storeRefreshToken(user.getId(), newRefreshToken);
storeAccessToken(user.getId(), newAccessToken);
// 构建返回结果 // 构建返回结果
TokenInfoDTO tokenInfo = new TokenInfoDTO() TokenInfoDTO tokenInfo = new TokenInfoDTO()
@@ -251,13 +259,17 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
} }
@Override @Override
public Result<Void> logout(Long userId) { public Result<Void> logout(Long userId, String accessToken) {
if (userId == null) { if (userId == null) {
return Result.error(ResultCode.PARAM_ERROR.getCode(), "用户ID不能为空"); return Result.error(ResultCode.PARAM_ERROR.getCode(), "用户ID不能为空");
} }
// 清除用户令牌 clearRefreshToken(userId);
redisUtils.del(USER_TOKEN_PREFIX + userId); clearAccessTokens(userId);
if (StringUtils.hasText(accessToken)) {
stringRedisTemplate.delete(USER_ACCESS_TOKEN_PREFIX + accessToken);
stringRedisTemplate.opsForSet().remove(USER_ACCESS_TOKEN_SET_PREFIX + userId, accessToken);
}
// 清除用户权限缓存 // 清除用户权限缓存
redisUtils.del(USER_ROLE_PREFIX + userId); redisUtils.del(USER_ROLE_PREFIX + userId);
@@ -292,19 +304,18 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
public Result<Object> getLoginStats() { public Result<Object> getLoginStats() {
Map<String, Object> stats = new HashMap<>(); Map<String, Object> stats = new HashMap<>();
// 这里是模拟数据,实际项目中需要从数据库或缓存中获取
// 总用户数 // 总用户数
long totalUsers = count(); long totalUsers = count();
stats.put("totalUsers", totalUsers); stats.put("totalUsers", totalUsers);
// 今日登录用户数 // 今日登录用户数
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
long todayLoginUsers = 0; // 实际需要查询登录记录表 long todayLoginUsers = count(new LambdaQueryWrapper<AuthUser>()
.ge(AuthUser::getLastLoginTime, today.atStartOfDay()));
stats.put("todayLoginUsers", todayLoginUsers); stats.put("todayLoginUsers", todayLoginUsers);
// 当前在线用户数(可从Redis中获取) java.util.Set<String> onlineUserKeys = stringRedisTemplate.keys(USER_ACCESS_TOKEN_SET_PREFIX + "*");
long onlineUsers = 0; // 实际需要从Redis或其他存储中获取 long onlineUsers = onlineUserKeys == null ? 0 : onlineUserKeys.size();
stats.put("onlineUsers", onlineUsers); stats.put("onlineUsers", onlineUsers);
// 最近7天每日登录用户数 // 最近7天每日登录用户数
@@ -312,8 +323,10 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
for (int i = 6; i >= 0; i--) { for (int i = 6; i >= 0; i--) {
LocalDate date = today.minusDays(i); LocalDate date = today.minusDays(i);
String dateStr = date.toString(); String dateStr = date.toString();
long count = 0; // 实际需要查询登录记录表 long dailyCount = count(new LambdaQueryWrapper<AuthUser>()
dailyLoginUsers.put(dateStr, count); .ge(AuthUser::getLastLoginTime, date.atStartOfDay())
.lt(AuthUser::getLastLoginTime, date.plusDays(1).atStartOfDay()));
dailyLoginUsers.put(dateStr, dailyCount);
} }
stats.put("dailyLoginUsers", dailyLoginUsers); stats.put("dailyLoginUsers", dailyLoginUsers);
@@ -330,22 +343,19 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
} }
// 生成新的盐值 // 生成新的盐值
String salt = generateSalt(); String encryptedPassword = passwordEncoder.encode(newPassword);
// 加密密码
String encryptedPassword = encryptPassword(newPassword, salt);
// 更新密码和盐值 // 更新密码和盐值
AuthUser updateUser = new AuthUser(); AuthUser updateUser = new AuthUser();
updateUser.setId(userId); updateUser.setId(userId);
updateUser.setPassword(encryptedPassword); updateUser.setPassword(encryptedPassword);
updateUser.setSalt(salt); updateUser.setSalt(null);
updateUser.setUpdatedBy("admin"); updateUser.setUpdatedBy("admin");
updateUser.setUpdateTime(LocalDateTime.now()); updateUser.setUpdateTime(LocalDateTime.now());
updateById(updateUser); updateById(updateUser);
// 清除用户token clearRefreshToken(userId);
String tokenKey = "auth:token:" + userId; clearAccessTokens(userId);
redisUtils.del(tokenKey);
return Result.success(); return Result.success();
} }
@@ -367,30 +377,12 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
updateUser.setUpdateTime(LocalDateTime.now()); updateUser.setUpdateTime(LocalDateTime.now());
updateById(updateUser); updateById(updateUser);
// 如果锁定用户,清除用户token clearRefreshToken(userId);
if (locked) { clearAccessTokens(userId);
String tokenKey = "auth:token:" + userId;
redisUtils.del(tokenKey);
}
return Result.success(); return Result.success();
} }
/**
* 生成盐值
*/
private String generateSalt() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 加密密码
*/
private String encryptPassword(String password, String salt) {
// 实际项目中应该使用加密算法如BCrypt
return DigestUtils.md5DigestAsHex((password + salt).getBytes());
}
/** /**
* 获取用户角色 * 获取用户角色
* *
@@ -449,4 +441,36 @@ public class AuthUserServiceImpl extends ServiceImpl<AuthUserMapper, AuthUser> i
redisUtils.set(USER_PERMISSION_PREFIX + userId, permissions, expiration); redisUtils.set(USER_PERMISSION_PREFIX + userId, permissions, expiration);
} }
} }
}
private void storeRefreshToken(Long userId, String refreshToken) {
String oldRefreshToken = (String) redisUtils.get(USER_REFRESH_TOKEN_PREFIX + userId);
if (StringUtils.hasText(oldRefreshToken)) {
stringRedisTemplate.delete(USER_REFRESH_INDEX_PREFIX + oldRefreshToken);
}
redisUtils.set(USER_REFRESH_TOKEN_PREFIX + userId, refreshToken, expiration * 2);
stringRedisTemplate.opsForValue().set(USER_REFRESH_INDEX_PREFIX + refreshToken, userId.toString(), expiration * 2, java.util.concurrent.TimeUnit.SECONDS);
}
private void clearRefreshToken(Long userId) {
String refreshToken = (String) redisUtils.get(USER_REFRESH_TOKEN_PREFIX + userId);
redisUtils.del(USER_REFRESH_TOKEN_PREFIX + userId);
if (StringUtils.hasText(refreshToken)) {
stringRedisTemplate.delete(USER_REFRESH_INDEX_PREFIX + refreshToken);
}
}
private void storeAccessToken(Long userId, String accessToken) {
stringRedisTemplate.opsForValue().set(USER_ACCESS_TOKEN_PREFIX + accessToken, userId.toString(), expiration, java.util.concurrent.TimeUnit.SECONDS);
stringRedisTemplate.opsForSet().add(USER_ACCESS_TOKEN_SET_PREFIX + userId, accessToken);
stringRedisTemplate.expire(USER_ACCESS_TOKEN_SET_PREFIX + userId, expiration, java.util.concurrent.TimeUnit.SECONDS);
}
private void clearAccessTokens(Long userId) {
String setKey = USER_ACCESS_TOKEN_SET_PREFIX + userId;
java.util.Set<String> tokens = stringRedisTemplate.opsForSet().members(setKey);
for (String token : tokens == null ? Collections.<String>emptySet() : tokens) {
stringRedisTemplate.delete(USER_ACCESS_TOKEN_PREFIX + token);
}
stringRedisTemplate.delete(setKey);
}
}
@@ -21,7 +21,7 @@ import java.util.function.Function;
@Component @Component
public class JwtUtils { public class JwtUtils {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
@Value("${jwt.expiration:86400}") @Value("${jwt.expiration:86400}")
@@ -121,4 +121,4 @@ public class JwtUtils {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -18,13 +18,13 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_auth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_auth?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 0 database: 0
timeout: 10000 timeout: 10000
lettuce: lettuce:
@@ -40,7 +40,6 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
@@ -48,8 +47,12 @@ mybatis-plus:
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
service:
auth:
token: ${SERVICE_AUTH_TOKEN}
jwt: jwt:
secret: your-secret-key-here-should-be-very-long-and-secure secret: ${USER_JWT_SECRET}
expiration: 86400000 # 24小时 expiration: ${JWT_EXPIRATION}
header: Authorization header: Authorization
tokenPrefix: Bearer tokenPrefix: Bearer
@@ -60,6 +60,8 @@ class AuthControllerTest {
LoginDTO loginDTO = new LoginDTO(); LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("testuser"); loginDTO.setUsername("testuser");
loginDTO.setPassword("password123"); loginDTO.setPassword("password123");
loginDTO.setCaptchaKey("captchaKey123");
loginDTO.setCaptcha("1234");
TokenInfoDTO tokenInfo = new TokenInfoDTO(); TokenInfoDTO tokenInfo = new TokenInfoDTO();
tokenInfo.setAccessToken("accessToken123"); tokenInfo.setAccessToken("accessToken123");
@@ -91,6 +93,8 @@ class AuthControllerTest {
LoginDTO loginDTO = new LoginDTO(); LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("nonexistent"); loginDTO.setUsername("nonexistent");
loginDTO.setPassword("password123"); loginDTO.setPassword("password123");
loginDTO.setCaptchaKey("captchaKey123");
loginDTO.setCaptcha("1234");
when(authUserService.login(any(LoginDTO.class))) when(authUserService.login(any(LoginDTO.class)))
.thenReturn(Result.error(404, "用户名或密码错误")); .thenReturn(Result.error(404, "用户名或密码错误"));
@@ -139,6 +143,8 @@ class AuthControllerTest {
registerDTO.setUsername("existinguser"); registerDTO.setUsername("existinguser");
registerDTO.setPassword("password123"); registerDTO.setPassword("password123");
registerDTO.setConfirmPassword("password123"); registerDTO.setConfirmPassword("password123");
registerDTO.setCaptchaKey("captchaKey123");
registerDTO.setCaptcha("1234");
when(authUserService.register(any(RegisterDTO.class))) when(authUserService.register(any(RegisterDTO.class)))
.thenReturn(Result.error(400, "用户名已存在")); .thenReturn(Result.error(400, "用户名已存在"));
@@ -30,12 +30,12 @@ class UserClientTest {
String username = "testuser"; String username = "testuser";
// When // When
Result<Void> result = userClientFallback.createUserInfo(userId, username); Result<Void> result = userClientFallback.createUserInfo("internal-token", userId, username);
// Then // Then
assertNotNull(result); assertNotNull(result);
assertEquals(500, result.getCode()); assertEquals(500, result.getCode());
assertEquals("创建用户资料失败,用户服务不可用", result.getMessage()); assertEquals("用户服务不可用,资料将在首次访问时创建", result.getMessage());
} }
@Test @Test
@@ -3,6 +3,7 @@ package com.nuaa.video.auth.integration;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.nuaa.video.auth.dto.LoginDTO; import com.nuaa.video.auth.dto.LoginDTO;
import com.nuaa.video.auth.dto.RegisterDTO; import com.nuaa.video.auth.dto.RegisterDTO;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -29,6 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ActiveProfiles("test") @ActiveProfiles("test")
@Disabled("依赖完整外部环境,当前仓库自动化测试先跳过")
@DisplayName("认证服务集成测试") @DisplayName("认证服务集成测试")
class AuthIntegrationTest { class AuthIntegrationTest {
@@ -54,6 +56,8 @@ class AuthIntegrationTest {
LoginDTO loginDTO = new LoginDTO(); LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("nonexistentuser"); loginDTO.setUsername("nonexistentuser");
loginDTO.setPassword("wrongpassword"); loginDTO.setPassword("wrongpassword");
loginDTO.setCaptchaKey("invalidKey");
loginDTO.setCaptcha("wrong");
mockMvc.perform(post("/auth/login") mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -19,7 +19,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -33,6 +35,7 @@ import static org.mockito.Mockito.*;
* AuthUserService 单元测试 * AuthUserService 单元测试
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("认证服务测试") @DisplayName("认证服务测试")
class AuthUserServiceTest { class AuthUserServiceTest {
@@ -18,6 +18,14 @@ spring:
host: localhost host: localhost
port: 6379 port: 6379
database: 0 database: 0
cloud:
nacos:
discovery:
enabled: false
config:
enabled: false
sentinel:
enabled: false
# JWT配置 # JWT配置
jwt: jwt:
@@ -29,18 +37,3 @@ logging:
level: level:
com.nuaa.video: DEBUG com.nuaa.video: DEBUG
org.springframework: INFO org.springframework: INFO
# Nacos配置(测试环境禁用)
spring:
cloud:
nacos:
discovery:
enabled: false
config:
enabled: false
# Sentinel配置(测试环境禁用)
spring:
cloud:
sentinel:
enabled: false
@@ -2,8 +2,11 @@ package com.nuaa.video.comment.config;
import com.nuaa.video.comment.filter.JwtAuthenticationFilter; import com.nuaa.video.comment.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -15,11 +18,15 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${service.auth.token}")
private String internalServiceToken;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
@@ -29,13 +36,21 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/comment/public/**", "/comment/public/**",
"/comment/reply/public/**",
"/actuator/**", "/actuator/**",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers("/comment/admin/**")
.access((authentication, context) ->
new AuthorizationDecision(isInternalRequest(context.getRequest().getHeader("X-Internal-Token"))))
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
}
private boolean isInternalRequest(String internalToken) {
return internalServiceToken.equals(internalToken);
}
}
@@ -6,8 +6,10 @@ import com.nuaa.video.comment.service.CommentService;
import com.nuaa.video.comment.vo.CommentStatVO; import com.nuaa.video.comment.vo.CommentStatVO;
import com.nuaa.video.comment.vo.CommentVO; import com.nuaa.video.comment.vo.CommentVO;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/** /**
@@ -19,6 +21,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminCommentController { public class AdminCommentController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final CommentService commentService; private final CommentService commentService;
/** /**
@@ -30,7 +35,11 @@ public class AdminCommentController {
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "videoId", required = false) Long videoId, @RequestParam(value = "videoId", required = false) Long videoId,
@RequestParam(value = "userId", required = false) Long userId, @RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "keyword", required = false) String keyword) { @RequestParam(value = "keyword", required = false) String keyword,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return commentService.getAdminCommentList(page, size, videoId, userId, keyword); return commentService.getAdminCommentList(page, size, videoId, userId, keyword);
} }
@@ -39,7 +48,10 @@ public class AdminCommentController {
* 获取评论详情 - 管理接口 * 获取评论详情 - 管理接口
*/ */
@GetMapping("/{commentId}") @GetMapping("/{commentId}")
public Result<CommentVO> getCommentDetail(@PathVariable Long commentId) { public Result<CommentVO> getCommentDetail(@PathVariable Long commentId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return commentService.getAdminCommentDetail(commentId); return commentService.getAdminCommentDetail(commentId);
} }
@@ -47,7 +59,10 @@ public class AdminCommentController {
* 删除评论 - 管理接口 * 删除评论 - 管理接口
*/ */
@DeleteMapping("/{commentId}") @DeleteMapping("/{commentId}")
public Result<Void> deleteComment(@PathVariable Long commentId) { public Result<Void> deleteComment(@PathVariable Long commentId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return commentService.adminDeleteComment(commentId); return commentService.adminDeleteComment(commentId);
} }
@@ -55,7 +70,14 @@ public class AdminCommentController {
* 获取评论统计信息 - 管理接口 * 获取评论统计信息 - 管理接口
*/ */
@GetMapping("/stats") @GetMapping("/stats")
public Result<CommentStatVO> getCommentStats() { public Result<CommentStatVO> getCommentStats(HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return commentService.getCommentStats(); return commentService.getCommentStats();
} }
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -19,7 +19,7 @@ public interface ContentFeignClient {
* @param id 视频ID * @param id 视频ID
* @return 视频信息 * @return 视频信息
*/ */
@GetMapping("/content/video/{id}") @GetMapping("/content/video/public/{id}")
Result<Object> getVideoInfo(@PathVariable("id") Long id); Result<Object> getVideoInfo(@PathVariable("id") Long id);
/** /**
@@ -33,5 +33,5 @@ public interface ContentFeignClient {
@PostMapping("/content/video/count/comment/{videoId}/{count}") @PostMapping("/content/video/count/comment/{videoId}/{count}")
Result<Void> increaseCommentCount(@PathVariable("videoId") Long videoId, Result<Void> increaseCommentCount(@PathVariable("videoId") Long videoId,
@PathVariable("count") Integer count, @PathVariable("count") Integer count,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -23,10 +23,10 @@ public class ContentFeignClientFallbackFactory implements FallbackFactory<Conten
} }
@Override @Override
public Result<Void> increaseCommentCount(Long videoId, Integer count, String token) { public Result<Void> increaseCommentCount(Long videoId, Integer count, String internalToken) {
log.error("增加视频评论数量失败,视频ID:{}, 数量:{}", videoId, count); log.error("增加视频评论数量失败,视频ID:{}, 数量:{}", videoId, count);
return Result.error("更新视频评论数量失败"); return Result.error("更新视频评论数量失败");
} }
}; };
} }
} }
@@ -35,5 +35,5 @@ public interface UserFeignClient {
@PostMapping("/user/notification/send/{userId}/{type}") @PostMapping("/user/notification/send/{userId}/{type}")
Result<Void> sendNotification(@PathVariable("userId") Long userId, Result<Void> sendNotification(@PathVariable("userId") Long userId,
@PathVariable("type") Integer type, @PathVariable("type") Integer type,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -23,10 +23,10 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
} }
@Override @Override
public Result<Void> sendNotification(Long userId, Integer type, String token) { public Result<Void> sendNotification(Long userId, Integer type, String internalToken) {
log.error("发送用户通知失败,用户ID:{}, 类型:{}", userId, type); log.error("发送用户通知失败,用户ID:{}, 类型:{}", userId, type);
return Result.error("发送用户通知失败"); return Result.error("发送用户通知失败");
} }
}; };
} }
} }
@@ -21,6 +21,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* JWT认证过滤器 * JWT认证过滤器
@@ -29,7 +30,7 @@ import java.util.List;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
private static final String TOKEN_PREFIX = "Bearer "; private static final String TOKEN_PREFIX = "Bearer ";
@@ -55,7 +56,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
// 设置用户权限 // 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); List<String> authorityClaims = claims.get("authorities", List.class);
List<SimpleGrantedAuthority> authorities = authorityClaims == null
? new ArrayList<>()
: authorityClaims.stream()
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(ArrayList::new));
// 创建认证对象 // 创建认证对象
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
@@ -70,6 +77,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 在请求属性中存储用户ID // 在请求属性中存储用户ID
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
request.setAttribute("username", username); request.setAttribute("username", username);
if (!authorities.isEmpty()) {
request.setAttribute("adminId", userId);
}
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username); log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) { } catch (Exception e) {
@@ -99,4 +109,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -26,6 +26,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
/** /**
* 评论回复服务实现类 * 评论回复服务实现类
@@ -35,6 +36,9 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, CommentReply> implements CommentReplyService { public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, CommentReply> implements CommentReplyService {
@Value("${service.auth.token}")
private String internalServiceToken;
private final CommentReplyMapper commentReplyMapper; private final CommentReplyMapper commentReplyMapper;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final CommentLikeMapper commentLikeMapper; private final CommentLikeMapper commentLikeMapper;
@@ -80,20 +84,7 @@ public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, Com
// 更新评论回复数 // 更新评论回复数
commentMapper.increaseReplyCount(replyDTO.getCommentId(), 1); commentMapper.increaseReplyCount(replyDTO.getCommentId(), 1);
// 发送回复通知给评论作者 sendReplyNotificationAsync(comment, replyDTO, userId);
try {
// 如果回复者不是评论作者,发送通知
if (!userId.equals(comment.getUserId())) {
userFeignClient.sendNotification(comment.getUserId(), 2, "Bearer ");
}
// 如果存在回复目标用户,且回复者不是回复目标用户,发送通知
if (replyDTO.getReplyToId() != null && !userId.equals(replyDTO.getReplyToId())) {
userFeignClient.sendNotification(replyDTO.getReplyToId(), 2, "Bearer ");
}
} catch (Exception e) {
log.error("发送回复通知失败", e);
}
return Result.success(reply.getId()); return Result.success(reply.getId());
} }
@@ -161,6 +152,9 @@ public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, Com
if (reply == null) { if (reply == null) {
return Result.error("回复不存在"); return Result.error("回复不存在");
} }
if (reply.getStatus() == 0) {
return Result.error("回复已删除");
}
// 校验操作权限 // 校验操作权限
if (!reply.getUserId().equals(userId)) { if (!reply.getUserId().equals(userId)) {
@@ -278,4 +272,19 @@ public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, Com
return vo; return vo;
} }
}
private void sendReplyNotificationAsync(Comment comment, ReplyAddDTO replyDTO, Long userId) {
CompletableFuture.runAsync(() -> {
try {
if (!userId.equals(comment.getUserId())) {
userFeignClient.sendNotification(comment.getUserId(), 2, internalServiceToken);
}
if (replyDTO.getReplyToId() != null && !userId.equals(replyDTO.getReplyToId())) {
userFeignClient.sendNotification(replyDTO.getReplyToId(), 2, internalServiceToken);
}
} catch (Exception e) {
log.warn("发送回复通知失败", e);
}
});
}
}
@@ -32,6 +32,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
/** /**
* 评论服务实现类 * 评论服务实现类
@@ -41,6 +42,9 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService { public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {
@Value("${service.auth.token}")
private String internalServiceToken;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final CommentReplyMapper commentReplyMapper; private final CommentReplyMapper commentReplyMapper;
private final CommentLikeMapper commentLikeMapper; private final CommentLikeMapper commentLikeMapper;
@@ -88,22 +92,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
// 保存评论 // 保存评论
save(comment); save(comment);
// 更新视频评论数 updateVideoCommentCountAsync(commentDTO.getVideoId(), 1);
contentFeignClient.increaseCommentCount(commentDTO.getVideoId(), 1, "Bearer ");
// 发送评论通知给视频作者 sendCommentNotificationAsync(videoResult.getData(), userId);
try {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
// 如果评论者不是视频作者,发送通知
if (!userId.equals(videoAuthorId)) {
userFeignClient.sendNotification(videoAuthorId, 2, "Bearer ");
}
} catch (Exception e) {
log.error("发送评论通知失败", e);
}
return Result.success(comment.getId()); return Result.success(comment.getId());
} }
@@ -176,6 +167,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
if (comment == null) { if (comment == null) {
return Result.error("评论不存在"); return Result.error("评论不存在");
} }
if (comment.getStatus() == 0) {
return Result.error("评论已删除");
}
// 校验操作权限 // 校验操作权限
if (!comment.getUserId().equals(userId)) { if (!comment.getUserId().equals(userId)) {
@@ -188,8 +182,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
comment.setUpdateTime(LocalDateTime.now()); comment.setUpdateTime(LocalDateTime.now());
updateById(comment); updateById(comment);
// 更新视频评论数 updateVideoCommentCountAsync(comment.getVideoId(), -1);
contentFeignClient.increaseCommentCount(comment.getVideoId(), -1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -258,6 +251,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
if (comment == null || comment.getStatus() == 0) { if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在"); return Result.error("评论不存在");
} }
if (!videoId.equals(comment.getVideoId())) {
return Result.error("评论不属于该视频");
}
// 验证视频所有权(只有视频作者才能置顶评论) // 验证视频所有权(只有视频作者才能置顶评论)
Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId); Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId);
@@ -308,6 +304,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
if (comment == null || comment.getStatus() == 0) { if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在"); return Result.error("评论不存在");
} }
if (!videoId.equals(comment.getVideoId())) {
return Result.error("评论不属于该视频");
}
// 验证视频所有权(只有视频作者才能取消置顶评论) // 验证视频所有权(只有视频作者才能取消置顶评论)
Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId); Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId);
@@ -422,6 +421,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
if (comment == null) { if (comment == null) {
return Result.error("评论不存在"); return Result.error("评论不存在");
} }
if (comment.getStatus() == 0) {
return Result.error("评论已删除");
}
// 转换为VO // 转换为VO
CommentVO vo = convertToCommentVO(comment, null); CommentVO vo = convertToCommentVO(comment, null);
@@ -452,8 +454,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
comment.setUpdateTime(LocalDateTime.now()); comment.setUpdateTime(LocalDateTime.now());
updateById(comment); updateById(comment);
// 更新视频评论数 updateVideoCommentCountAsync(comment.getVideoId(), -1);
contentFeignClient.increaseCommentCount(comment.getVideoId(), -1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -464,16 +465,20 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
// 获取各种统计数据 // 获取各种统计数据
// 1. 总评论数 // 1. 总评论数
long totalComments = count(); long totalComments = count(new LambdaQueryWrapper<Comment>().eq(Comment::getStatus, 1));
statVO.setCommentCount((int)totalComments); statVO.setCommentCount((int)totalComments);
// 2. 今日新增评论数 // 2. 今日新增评论数
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
long todayComments = count(new LambdaQueryWrapper<Comment>() long todayComments = count(new LambdaQueryWrapper<Comment>()
.eq(Comment::getStatus, 1)
.ge(Comment::getCreateTime, today.atStartOfDay())); .ge(Comment::getCreateTime, today.atStartOfDay()));
// 3. 回复总数 // 3. 回复总数
long totalReplies = commentReplyMapper.selectCount(null); long totalReplies = commentReplyMapper.selectCount(
new LambdaQueryWrapper<com.nuaa.video.comment.entity.CommentReply>()
.eq(com.nuaa.video.comment.entity.CommentReply::getStatus, 1)
);
statVO.setReplyCount((int)totalReplies); statVO.setReplyCount((int)totalReplies);
// 4. 总点赞数 // 4. 总点赞数
@@ -514,4 +519,32 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
return vo; return vo;
} }
}
private void sendCommentNotificationAsync(Object videoPayload, Long userId) {
CompletableFuture.runAsync(() -> {
try {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoPayload;
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
if (!userId.equals(videoAuthorId)) {
userFeignClient.sendNotification(videoAuthorId, 2, internalServiceToken);
}
} catch (Exception e) {
log.warn("发送评论通知失败", e);
}
});
}
private void updateVideoCommentCountAsync(Long videoId, int delta) {
CompletableFuture.runAsync(() -> {
try {
Result<Void> countResult = contentFeignClient.increaseCommentCount(videoId, delta, internalServiceToken);
if (countResult == null || countResult.getCode() != 200) {
log.warn("异步更新视频评论数失败,videoId={}, delta={}, result={}", videoId, delta, countResult);
}
} catch (Exception e) {
log.warn("异步更新视频评论数异常,videoId={}, delta={}", videoId, delta, e);
}
});
}
}
@@ -1,5 +1,5 @@
server: server:
port: 9005 port: 9004
spring: spring:
application: application:
@@ -18,14 +18,14 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_comment?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_comment?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 4 database: 3
timeout: 10000 timeout: 10000
lettuce: lettuce:
pool: pool:
@@ -40,10 +40,19 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
logic-delete-field: is_deleted logic-delete-field: is_deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
service:
auth:
token: ${SERVICE_AUTH_TOKEN}
jwt:
secret: ${USER_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: Bearer
@@ -17,7 +17,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -31,6 +33,7 @@ import static org.mockito.Mockito.*;
* CommentService 单元测试 * CommentService 单元测试
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("评论服务测试") @DisplayName("评论服务测试")
class CommentServiceTest { class CommentServiceTest {
@@ -20,7 +20,7 @@ import java.util.function.Function;
@Component @Component
public class JwtUtils { public class JwtUtils {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
@Value("${jwt.expiration:86400}") @Value("${jwt.expiration:86400}")
@@ -120,4 +120,4 @@ public class JwtUtils {
byte[] keyBytes = secret.getBytes(); byte[] keyBytes = secret.getBytes();
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -2,8 +2,11 @@ package com.nuaa.video.content.config;
import com.nuaa.video.content.filter.JwtAuthenticationFilter; import com.nuaa.video.content.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -15,11 +18,15 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${service.auth.token}")
private String internalServiceToken;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
@@ -29,15 +36,31 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/content/video/public/**", "/content/video/public/**",
"/content/category/tree",
"/content/category/list", "/content/category/list",
"/content/tag/list", "/content/tag/list",
"/content/tag/page",
"/content/danmaku/all/**",
"/content/danmaku/time",
"/content/stat/**",
"/actuator/**", "/actuator/**",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers(
"/content/video/count/comment/**",
"/content/video/review/**",
"/content/video/admin/**",
"/content/category/admin/**"
).access((authentication, context) ->
new AuthorizationDecision(isInternalRequest(context.getRequest().getHeader("X-Internal-Token"))))
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
}
private boolean isInternalRequest(String internalToken) {
return internalServiceToken.equals(internalToken);
}
}
@@ -4,8 +4,10 @@ import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.content.entity.ContentCategory; import com.nuaa.video.content.entity.ContentCategory;
import com.nuaa.video.content.service.CategoryService; import com.nuaa.video.content.service.CategoryService;
import com.nuaa.video.content.vo.CategoryVO; import com.nuaa.video.content.vo.CategoryVO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -19,13 +21,19 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminCategoryController { public class AdminCategoryController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final CategoryService categoryService; private final CategoryService categoryService;
/** /**
* 获取分类列表 - 管理接口 * 获取分类列表 - 管理接口
*/ */
@GetMapping("/list") @GetMapping("/list")
public Result<List<CategoryVO>> getCategoryList() { public Result<List<CategoryVO>> getCategoryList(HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return categoryService.getCategoryList(); return categoryService.getCategoryList();
} }
@@ -33,7 +41,10 @@ public class AdminCategoryController {
* 添加分类 - 管理接口 * 添加分类 - 管理接口
*/ */
@PostMapping @PostMapping
public Result<Void> addCategory(@RequestBody ContentCategory category) { public Result<Void> addCategory(@RequestBody ContentCategory category, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return categoryService.addCategory(category); return categoryService.addCategory(category);
} }
@@ -41,7 +52,10 @@ public class AdminCategoryController {
* 更新分类 - 管理接口 * 更新分类 - 管理接口
*/ */
@PutMapping @PutMapping
public Result<Void> updateCategory(@RequestBody ContentCategory category) { public Result<Void> updateCategory(@RequestBody ContentCategory category, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return categoryService.updateCategory(category); return categoryService.updateCategory(category);
} }
@@ -49,7 +63,14 @@ public class AdminCategoryController {
* 删除分类 - 管理接口 * 删除分类 - 管理接口
*/ */
@DeleteMapping("/{categoryId}") @DeleteMapping("/{categoryId}")
public Result<Void> deleteCategory(@PathVariable Long categoryId) { public Result<Void> deleteCategory(@PathVariable Long categoryId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return categoryService.deleteCategory(categoryId); return categoryService.deleteCategory(categoryId);
} }
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -6,8 +6,10 @@ import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.dto.VideoQueryDTO; import com.nuaa.video.content.dto.VideoQueryDTO;
import com.nuaa.video.content.service.VideoService; import com.nuaa.video.content.service.VideoService;
import com.nuaa.video.content.vo.VideoInfoVO; import com.nuaa.video.content.vo.VideoInfoVO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/** /**
@@ -19,6 +21,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminVideoController { public class AdminVideoController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final VideoService videoService; private final VideoService videoService;
/** /**
@@ -30,7 +35,11 @@ public class AdminVideoController {
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "status", required = false) Integer status) { @RequestParam(value = "status", required = false) Integer status,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
VideoQueryDTO queryDTO = new VideoQueryDTO(); VideoQueryDTO queryDTO = new VideoQueryDTO();
queryDTO.setKeyword(keyword); queryDTO.setKeyword(keyword);
@@ -47,7 +56,10 @@ public class AdminVideoController {
* 获取视频详情 - 管理接口 * 获取视频详情 - 管理接口
*/ */
@GetMapping("/{videoId}") @GetMapping("/{videoId}")
public Result<VideoInfoVO> getVideoDetail(@PathVariable Long videoId) { public Result<VideoInfoVO> getVideoDetail(@PathVariable Long videoId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return videoService.getAdminVideoDetail(videoId); return videoService.getAdminVideoDetail(videoId);
} }
@@ -58,23 +70,50 @@ public class AdminVideoController {
public Result<Void> reviewVideo( public Result<Void> reviewVideo(
@PathVariable Long videoId, @PathVariable Long videoId,
@RequestParam Integer status, @RequestParam Integer status,
@RequestParam(required = false) String reason) { @RequestParam(required = false) String reason,
return videoService.reviewVideo(videoId, null, status, reason); HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return videoService.reviewVideo(videoId, getAdminId(request), status, reason);
} }
/** /**
* 下架视频 - 管理接口 * 下架视频 - 管理接口
*/ */
@PutMapping("/disable/{videoId}") @PutMapping("/disable/{videoId}")
public Result<Void> disableVideo(@PathVariable Long videoId) { public Result<Void> disableVideo(@PathVariable Long videoId, HttpServletRequest request) {
return videoService.disableVideo(videoId); if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return videoService.disableVideo(videoId, getAdminId(request));
} }
/** /**
* 获取视频统计信息 - 管理接口 * 获取视频统计信息 - 管理接口
*/ */
@GetMapping("/stats") @GetMapping("/stats")
public Result<Object> getVideoStats() { public Result<Object> getVideoStats(HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return videoService.getVideoStats(); return videoService.getVideoStats();
} }
}
private Long getAdminId(HttpServletRequest request) {
String adminIdHeader = request.getHeader("X-Admin-Id");
if (adminIdHeader != null && !adminIdHeader.isBlank()) {
try {
return Long.parseLong(adminIdHeader);
} catch (NumberFormatException ignored) {
log.warn("非法的X-Admin-Id请求头: {}", adminIdHeader);
}
}
Object adminId = request.getAttribute("adminId");
return adminId instanceof Long ? (Long) adminId : 0L;
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -13,6 +13,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -25,6 +26,9 @@ import org.springframework.web.multipart.MultipartFile;
@RequiredArgsConstructor @RequiredArgsConstructor
public class VideoController { public class VideoController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final VideoService videoService; private final VideoService videoService;
private final ContentVideoMapper videoMapper; private final ContentVideoMapper videoMapper;
@@ -235,9 +239,12 @@ public class VideoController {
@RequestParam Integer status, @RequestParam Integer status,
@RequestParam(required = false) String reason, @RequestParam(required = false) String reason,
HttpServletRequest request) { HttpServletRequest request) {
Long reviewerId = getUserIdFromRequest(request); if (!internalServiceToken.equals(request.getHeader("X-Internal-Token"))) {
return Result.error("非法的内部调用");
}
Long reviewerId = getAdminIdFromRequest(request);
if (reviewerId == null) { if (reviewerId == null) {
return Result.error("未获取到用户ID"); return Result.error("未获取到审核员ID");
} }
return videoService.reviewVideo(videoId, reviewerId, status, reason); return videoService.reviewVideo(videoId, reviewerId, status, reason);
} }
@@ -250,10 +257,8 @@ public class VideoController {
@PathVariable Long videoId, @PathVariable Long videoId,
@PathVariable Integer count, @PathVariable Integer count,
HttpServletRequest request) { HttpServletRequest request) {
// 验证权限(实际可以根据需要添加更复杂的权限验证) if (!internalServiceToken.equals(request.getHeader("X-Internal-Token"))) {
Long userId = getUserIdFromRequest(request); return Result.error("非法的内部调用");
if (userId == null) {
return Result.error("未登录");
} }
try { try {
@@ -281,4 +286,20 @@ public class VideoController {
} }
return null; return null;
} }
}
private Long getAdminIdFromRequest(HttpServletRequest request) {
String adminIdHeader = request.getHeader("X-Admin-Id");
if (adminIdHeader != null && !adminIdHeader.isBlank()) {
try {
return Long.parseLong(adminIdHeader);
} catch (NumberFormatException e) {
log.warn("非法的X-Admin-Id请求头: {}", adminIdHeader);
}
}
Object adminIdObj = request.getAttribute("adminId");
if (adminIdObj instanceof Long) {
return (Long) adminIdObj;
}
return null;
}
}
@@ -32,7 +32,7 @@ public interface UserFeignClient {
@PostMapping("/user/profile/count/video/{userId}/{count}") @PostMapping("/user/profile/count/video/{userId}/{count}")
Result<Void> increaseVideoCount(@PathVariable("userId") Long userId, Result<Void> increaseVideoCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count, @PathVariable("count") Integer count,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 增加用户获赞数量 * 增加用户获赞数量
@@ -44,7 +44,7 @@ public interface UserFeignClient {
@PostMapping("/user/profile/count/like/{userId}/{count}") @PostMapping("/user/profile/count/like/{userId}/{count}")
Result<Void> increaseLikeCount(@PathVariable("userId") Long userId, Result<Void> increaseLikeCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count, @PathVariable("count") Integer count,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 增加用户收藏数量 * 增加用户收藏数量
@@ -56,5 +56,5 @@ public interface UserFeignClient {
@PostMapping("/user/profile/count/favorite/{userId}/{count}") @PostMapping("/user/profile/count/favorite/{userId}/{count}")
Result<Void> increaseFavoriteCount(@PathVariable("userId") Long userId, Result<Void> increaseFavoriteCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count, @PathVariable("count") Integer count,
@RequestHeader("Authorization") String token); @RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -23,22 +23,22 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
} }
@Override @Override
public Result<Void> increaseVideoCount(Long userId, Integer count, String token) { public Result<Void> increaseVideoCount(Long userId, Integer count, String internalToken) {
log.error("增加用户视频数量失败,用户ID:{}, 数量:{}", userId, count); log.error("增加用户视频数量失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户视频数量失败"); return Result.error("更新用户视频数量失败");
} }
@Override @Override
public Result<Void> increaseLikeCount(Long userId, Integer count, String token) { public Result<Void> increaseLikeCount(Long userId, Integer count, String internalToken) {
log.error("增加用户获赞数量失败,用户ID:{}, 数量:{}", userId, count); log.error("增加用户获赞数量失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户获赞数量失败"); return Result.error("更新用户获赞数量失败");
} }
@Override @Override
public Result<Void> increaseFavoriteCount(Long userId, Integer count, String token) { public Result<Void> increaseFavoriteCount(Long userId, Integer count, String internalToken) {
log.error("增加用户收藏数量失败,用户ID:{}, 数量:{}", userId, count); log.error("增加用户收藏数量失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户收藏数量失败"); return Result.error("更新用户收藏数量失败");
} }
}; };
} }
} }
@@ -21,6 +21,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* JWT认证过滤器 * JWT认证过滤器
@@ -29,7 +30,7 @@ import java.util.List;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
private static final String TOKEN_PREFIX = "Bearer "; private static final String TOKEN_PREFIX = "Bearer ";
@@ -55,7 +56,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
// 设置用户权限 // 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); List<String> authorityClaims = claims.get("authorities", List.class);
List<SimpleGrantedAuthority> authorities = authorityClaims == null
? new ArrayList<>()
: authorityClaims.stream()
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(ArrayList::new));
// 创建认证对象 // 创建认证对象
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
@@ -70,6 +77,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 在请求属性中存储用户ID // 在请求属性中存储用户ID
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
request.setAttribute("username", username); request.setAttribute("username", username);
if (!authorities.isEmpty()) {
request.setAttribute("adminId", userId);
}
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username); log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) { } catch (Exception e) {
@@ -99,4 +109,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -136,7 +136,30 @@ public class VideoMessageProducer {
videoMapper.increaseViewCount(videoId, count); videoMapper.increaseViewCount(videoId, count);
log.info("直接更新数据库:视频播放计数 +{}, videoId={}", count, videoId); log.info("直接更新数据库:视频播放计数 +{}, videoId={}", count, videoId);
break; break;
// 其他case处理... case "like":
videoMapper.increaseLikeCount(videoId, count);
log.info("直接更新数据库:视频点赞计数 {:+d}, videoId={}", count, videoId);
break;
case "coin":
videoMapper.increaseCoinCount(videoId, count);
log.info("直接更新数据库:视频投币计数 {:+d}, videoId={}", count, videoId);
break;
case "favorite":
videoMapper.increaseFavoriteCount(videoId, count);
log.info("直接更新数据库:视频收藏计数 {:+d}, videoId={}", count, videoId);
break;
case "share":
videoMapper.increaseShareCount(videoId, count);
log.info("直接更新数据库:视频分享计数 {:+d}, videoId={}", count, videoId);
break;
case "danmaku":
videoMapper.increaseDanmakuCount(videoId, count);
log.info("直接更新数据库:视频弹幕计数 {:+d}, videoId={}", count, videoId);
break;
default:
log.warn("未识别的视频统计动作,无法降级更新数据库: action={}, videoId={}, count={}",
action, videoId, count);
break;
} }
} catch (Exception e) { } catch (Exception e) {
log.error("降级处理失败: 直接更新数据库出错", e); log.error("降级处理失败: 直接更新数据库出错", e);
@@ -156,4 +179,4 @@ public class VideoMessageProducer {
private String action; // play, like, favorite, coin, share, danmaku private String action; // play, like, favorite, coin, share, danmaku
private Integer count = 1; private Integer count = 1;
} }
} }
@@ -169,10 +169,10 @@ public interface VideoService extends IService<ContentVideo> {
/** /**
* 下架视频 - 管理接口 * 下架视频 - 管理接口
*/ */
Result<Void> disableVideo(Long videoId); Result<Void> disableVideo(Long videoId, Long adminId);
/** /**
* 获取视频统计信息 - 管理接口 * 获取视频统计信息 - 管理接口
*/ */
Result<Object> getVideoStats(); Result<Object> getVideoStats();
} }
@@ -22,9 +22,12 @@ import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO; import com.nuaa.video.content.vo.VideoListVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate; import java.time.LocalDate;
@@ -33,6 +36,8 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -43,6 +48,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVideo> implements VideoService { public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVideo> implements VideoService {
@Value("${service.auth.token}")
private String internalServiceToken;
private final ContentVideoMapper videoMapper; private final ContentVideoMapper videoMapper;
private final ContentCategoryMapper categoryMapper; private final ContentCategoryMapper categoryMapper;
private final ContentVideoTagMapper videoTagMapper; private final ContentVideoTagMapper videoTagMapper;
@@ -136,8 +144,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
videoTagMapper.batchInsert(videoId, uploadDTO.getTagIds(), userId.toString()); videoTagMapper.batchInsert(videoId, uploadDTO.getTagIds(), userId.toString());
} }
// 更新用户视频数量 updateUserVideoCountAsync(userId, 1);
userFeignClient.increaseVideoCount(userId, 1, "Bearer ");
// 发送视频处理消息 // 发送视频处理消息
// videoMessageProducer.sendVideoProcessMessage(videoId); // videoMessageProducer.sendVideoProcessMessage(videoId);
@@ -169,7 +176,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
// 获取标签列表 // 获取标签列表
Result<List<TagVO>> tagResult = tagService.getVideoTags(id); Result<List<TagVO>> tagResult = tagService.getVideoTags(id);
if (tagResult.getCode() == ResultCode.SUCCESS.getCode() && tagResult.getData() != null) { if (isSuccess(tagResult.getCode()) && tagResult.getData() != null) {
List<TagVO> tags = tagResult.getData(); List<TagVO> tags = tagResult.getData();
videoInfoVO.setTagIds(tags.stream().map(TagVO::getId).collect(Collectors.toList())); videoInfoVO.setTagIds(tags.stream().map(TagVO::getId).collect(Collectors.toList()));
videoInfoVO.setTagNames(tags.stream().map(TagVO::getName).collect(Collectors.toList())); videoInfoVO.setTagNames(tags.stream().map(TagVO::getName).collect(Collectors.toList()));
@@ -178,7 +185,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
// 获取用户信息 // 获取用户信息
try { try {
Result<Object> userResult = userFeignClient.getUserProfile(video.getUserId()); Result<Object> userResult = userFeignClient.getUserProfile(video.getUserId());
if (userResult.getCode() == ResultCode.SUCCESS.getCode() && userResult.getData() != null) { if (isSuccess(userResult.getCode()) && userResult.getData() != null) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) userResult.getData(); Map<String, Object> userData = (Map<String, Object>) userResult.getData();
videoInfoVO.setUserNickname((String) userData.get("nickname")); videoInfoVO.setUserNickname((String) userData.get("nickname"));
@@ -200,14 +207,6 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
videoInfoVO.setIsCoined(coinCount > 0); videoInfoVO.setIsCoined(coinCount > 0);
} }
// 增加播放数
VideoMessageProducer.VideoStatsMessage message = new VideoMessageProducer.VideoStatsMessage();
message.setVideoId(id);
message.setUserId(userId);
message.setAction("play");
message.setCount(1);
videoMessageProducer.sendVideoStatsMessage(message);
return Result.success(videoInfoVO); return Result.success(videoInfoVO);
} }
@@ -218,6 +217,11 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
queryDTO.setStatus(VideoStatusEnum.PUBLISHED.getCode()); queryDTO.setStatus(VideoStatusEnum.PUBLISHED.getCode());
} }
return buildVideoListResult(page, queryDTO, userId);
}
private Result<Page<VideoListVO>> buildVideoListResult(Page<ContentVideo> page, VideoQueryDTO queryDTO, Long userId) {
// 查询视频列表 // 查询视频列表
Page<ContentVideo> videoPage = videoMapper.selectVideoList(page, queryDTO); Page<ContentVideo> videoPage = videoMapper.selectVideoList(page, queryDTO);
List<ContentVideo> records = videoPage.getRecords(); List<ContentVideo> records = videoPage.getRecords();
@@ -233,7 +237,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
// 获取标签列表 // 获取标签列表
Result<List<TagVO>> tagResult = tagService.getVideoTags(video.getId()); Result<List<TagVO>> tagResult = tagService.getVideoTags(video.getId());
if (tagResult.getCode() == ResultCode.SUCCESS.getCode() && tagResult.getData() != null) { if (isSuccess(tagResult.getCode()) && tagResult.getData() != null) {
List<TagVO> tags = tagResult.getData(); List<TagVO> tags = tagResult.getData();
vo.setTagNames(tags.stream().map(TagVO::getName).collect(Collectors.toList())); vo.setTagNames(tags.stream().map(TagVO::getName).collect(Collectors.toList()));
} }
@@ -241,7 +245,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
// 获取用户信息 // 获取用户信息
try { try {
Result<Object> userResult = userFeignClient.getUserProfile(video.getUserId()); Result<Object> userResult = userFeignClient.getUserProfile(video.getUserId());
if (userResult.getCode() == ResultCode.SUCCESS.getCode() && userResult.getData() != null) { if (isSuccess(userResult.getCode()) && userResult.getData() != null) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) userResult.getData(); Map<String, Object> userData = (Map<String, Object>) userResult.getData();
vo.setUserNickname((String) userData.get("nickname")); vo.setUserNickname((String) userData.get("nickname"));
@@ -368,8 +372,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
.eq(ContentVideoPlayRecord::getVideoId, id) .eq(ContentVideoPlayRecord::getVideoId, id)
); );
// 更新用户视频数量 updateUserVideoCountAsync(userId, -1);
userFeignClient.increaseVideoCount(userId, -1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -406,8 +409,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}} }}
); );
// 更新用户获赞数 updateUserLikeCountAsync(video.getUserId(), 1);
userFeignClient.increaseLikeCount(video.getUserId(), 1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -444,8 +446,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}} }}
); );
// 更新用户获赞数 updateUserLikeCountAsync(video.getUserId(), -1);
userFeignClient.increaseLikeCount(video.getUserId(), -1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -548,8 +549,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}} }}
); );
// 更新用户收藏数 updateUserFavoriteCountAsync(userId, 1);
userFeignClient.increaseFavoriteCount(userId, 1, "Bearer ");
return Result.success(); return Result.success();
} }
@@ -589,8 +589,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}} }}
); );
// 更新用户收藏数 updateUserFavoriteCountAsync(userId, -favorites.size());
userFeignClient.increaseFavoriteCount(userId, -favorites.size(), "Bearer ");
return Result.success(); return Result.success();
} }
@@ -687,12 +686,16 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
queryDTO.setStatus(VideoStatusEnum.PUBLISHED.getCode()); queryDTO.setStatus(VideoStatusEnum.PUBLISHED.getCode());
} }
return getVideoList(page, queryDTO, currentUserId); return buildVideoListResult(page, queryDTO, currentUserId);
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Result<Void> reviewVideo(Long videoId, Long reviewerId, Integer status, String reason) { public Result<Void> reviewVideo(Long videoId, Long reviewerId, Integer status, String reason) {
if (reviewerId == null) {
reviewerId = 0L;
}
// 查询视频是否存在 // 查询视频是否存在
ContentVideo video = getById(videoId); ContentVideo video = getById(videoId);
if (video == null) { if (video == null) {
@@ -842,28 +845,30 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Result<Void> disableVideo(Long videoId) { public Result<Void> disableVideo(Long videoId, Long adminId) {
// 查询视频是否存在 // 查询视频是否存在
ContentVideo video = getById(videoId); ContentVideo video = getById(videoId);
if (video == null) { if (video == null) {
return Result.error("视频不存在"); return Result.error("视频不存在");
} }
Long operatorId = adminId != null && adminId > 0 ? adminId : 0L;
// 更新视频状态为已下架 // 更新视频状态为已下架
ContentVideo updateVideo = new ContentVideo(); ContentVideo updateVideo = new ContentVideo();
updateVideo.setId(videoId); updateVideo.setId(videoId);
updateVideo.setStatus(2); // 已下架 updateVideo.setStatus(2); // 已下架
updateVideo.setUpdatedBy("admin"); updateVideo.setUpdatedBy(operatorId.toString());
updateVideo.setUpdateTime(LocalDateTime.now()); updateVideo.setUpdateTime(LocalDateTime.now());
updateById(updateVideo); updateById(updateVideo);
// 记录操作日志 // 记录操作日志
ContentVideoReview review = new ContentVideoReview(); ContentVideoReview review = new ContentVideoReview();
review.setVideoId(videoId); review.setVideoId(videoId);
review.setReviewerId(0L); // 系统/管理员ID review.setReviewerId(operatorId);
review.setStatus(2); // 下架 review.setStatus(2); // 下架
review.setReason("管理员下架操作"); review.setReason("管理员下架操作");
review.setCreatedBy("admin"); review.setCreatedBy(operatorId.toString());
review.setCreateTime(LocalDateTime.now()); review.setCreateTime(LocalDateTime.now());
videoReviewMapper.insert(review); videoReviewMapper.insert(review);
@@ -917,4 +922,56 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.success(stats); return Result.success(stats);
} }
}
private void updateUserVideoCountAsync(Long userId, Integer count) {
runAfterCommitAsync("更新用户视频数", () -> {
Result<Void> countResult = userFeignClient.increaseVideoCount(userId, count, internalServiceToken);
if (countResult == null || countResult.getCode() != 200) {
log.warn("异步更新用户视频数失败,userId={}, count={}, result={}", userId, count, countResult);
}
});
}
private void updateUserLikeCountAsync(Long userId, Integer count) {
runAfterCommitAsync("更新用户获赞数", () -> {
Result<Void> countResult = userFeignClient.increaseLikeCount(userId, count, internalServiceToken);
if (countResult == null || countResult.getCode() != 200) {
log.warn("异步更新用户获赞数失败,userId={}, count={}, result={}", userId, count, countResult);
}
});
}
private void updateUserFavoriteCountAsync(Long userId, Integer count) {
runAfterCommitAsync("更新用户收藏数", () -> {
Result<Void> countResult = userFeignClient.increaseFavoriteCount(userId, count, internalServiceToken);
if (countResult == null || countResult.getCode() != 200) {
log.warn("异步更新用户收藏数失败,userId={}, count={}, result={}", userId, count, countResult);
}
});
}
private void runAfterCommitAsync(String action, Runnable task) {
Runnable wrappedTask = () -> CompletableFuture.runAsync(() -> {
try {
task.run();
} catch (Exception e) {
log.warn("{}异常", action, e);
}
});
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
wrappedTask.run();
}
});
} else {
wrappedTask.run();
}
}
private boolean isSuccess(Integer code) {
return Objects.equals(code, ResultCode.SUCCESS.getCode());
}
}
@@ -18,13 +18,13 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_content?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_content?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 2 database: 2
timeout: 10000 timeout: 10000
lettuce: lettuce:
@@ -57,7 +57,6 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
@@ -68,9 +67,9 @@ mybatis-plus:
# MinIO配置 # MinIO配置
minio: minio:
endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000} endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000}
accessKey: ${MINIO_ACCESS_KEY:minioadmin} accessKey: ${MINIO_ACCESS_KEY}
secretKey: ${MINIO_SECRET_KEY:minioadmin} secretKey: ${MINIO_SECRET_KEY}
bucketName: ${MINIO_BUCKET:video-platform} bucketName: ${MINIO_BUCKET}
# FFmpeg配置 # FFmpeg配置
ffmpeg: ffmpeg:
@@ -84,4 +83,14 @@ ffmpeg:
auth: auth:
ignore-urls: ignore-urls:
- /api/v1/videos/public/** - /api/v1/videos/public/**
- /api/v1/categories/** - /api/v1/categories/**
service:
auth:
token: ${SERVICE_AUTH_TOKEN}
jwt:
secret: ${USER_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: Bearer
@@ -17,7 +17,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -32,6 +34,7 @@ import static org.mockito.Mockito.*;
* VideoService 单元测试 * VideoService 单元测试
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("视频服务测试") @DisplayName("视频服务测试")
class VideoServiceTest { class VideoServiceTest {
@@ -28,7 +28,9 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/recommend/home",
"/recommend/hot/**", "/recommend/hot/**",
"/recommend/similar/**",
"/recommend/tag/**", "/recommend/tag/**",
"/actuator/**", "/actuator/**",
"/error" "/error"
@@ -39,4 +41,4 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
} }
@@ -21,6 +21,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* JWT认证过滤器 * JWT认证过滤器
@@ -29,7 +30,7 @@ import java.util.List;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
private static final String TOKEN_PREFIX = "Bearer "; private static final String TOKEN_PREFIX = "Bearer ";
@@ -55,7 +56,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
// 设置用户权限 // 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); List<String> authorityClaims = claims.get("authorities", List.class);
List<SimpleGrantedAuthority> authorities = authorityClaims == null
? new ArrayList<>()
: authorityClaims.stream()
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(ArrayList::new));
// 创建认证对象 // 创建认证对象
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
@@ -70,6 +77,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 在请求属性中存储用户ID // 在请求属性中存储用户ID
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
request.setAttribute("username", username); request.setAttribute("username", username);
if (!authorities.isEmpty()) {
request.setAttribute("adminId", userId);
}
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username); log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) { } catch (Exception e) {
@@ -99,4 +109,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -22,6 +22,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -139,7 +141,7 @@ public class RecommendServiceImpl implements RecommendService {
Long categoryId = null; Long categoryId = null;
if (videoData.containsKey("categoryId")) { if (videoData.containsKey("categoryId")) {
categoryId = Long.valueOf(videoData.get("categoryId").toString()); categoryId = toLong(videoData.get("categoryId"));
} }
if (categoryId != null) { if (categoryId != null) {
@@ -154,8 +156,8 @@ public class RecommendServiceImpl implements RecommendService {
if (records != null) { if (records != null) {
for (Map<String, Object> video : records) { for (Map<String, Object> video : records) {
Long id = Long.valueOf(video.get("id").toString()); Long id = toLong(video.get("id"));
if (!id.equals(videoId)) { // 过滤掉当前视频 if (id != null && !id.equals(videoId)) { // 过滤掉当前视频
VideoRecommendVO vo = mapToRecommendVO(video); VideoRecommendVO vo = mapToRecommendVO(video);
vo.setRecommendReason("相关推荐"); vo.setRecommendReason("相关推荐");
recommendList.add(vo); recommendList.add(vo);
@@ -168,7 +170,10 @@ public class RecommendServiceImpl implements RecommendService {
} else { } else {
// 有相似视频记录,获取视频详情 // 有相似视频记录,获取视频详情
for (Map<String, Object> similar : similarVideos) { for (Map<String, Object> similar : similarVideos) {
Long similarVideoId = Long.valueOf(similar.get("videoId").toString()); Long similarVideoId = toLong(similar.get("videoId"));
if (similarVideoId == null) {
continue;
}
Result<Object> result = contentFeignClient.getVideoInfo(similarVideoId); Result<Object> result = contentFeignClient.getVideoInfo(similarVideoId);
if (result.getCode() == 200 && result.getData() != null) { if (result.getCode() == 200 && result.getData() != null) {
@@ -317,20 +322,28 @@ public class RecommendServiceImpl implements RecommendService {
if (!tagBehaviors.isEmpty()) { if (!tagBehaviors.isEmpty()) {
// 计算标签总权重 // 计算标签总权重
double totalWeight = tagBehaviors.stream() double totalWeight = tagBehaviors.stream()
.mapToDouble(tag -> Double.parseDouble(tag.get("count").toString())) .mapToDouble(tag -> toDouble(tag.get("count")))
.sum(); .sum();
if (totalWeight <= 0) {
return Result.success();
}
// 计算标签权重 // 计算标签权重
Map<Long, Float> tagWeights = new HashMap<>(); Map<Long, Float> tagWeights = new HashMap<>();
for (Map<String, Object> tag : tagBehaviors) { for (Map<String, Object> tag : tagBehaviors) {
Long tagId = Long.valueOf(tag.get("tagId").toString()); Long tagId = toLong(tag.get("tagId"));
double count = Double.parseDouble(tag.get("count").toString()); double count = toDouble(tag.get("count"));
if (tagId == null || count <= 0) {
continue;
}
float weight = (float) (count / totalWeight); float weight = (float) (count / totalWeight);
tagWeights.put(tagId, weight); tagWeights.put(tagId, weight);
} }
// 更新用户标签兴趣 // 更新用户标签兴趣
userInterestMapper.batchUpdateWeights(userId, tagWeights); if (!tagWeights.isEmpty()) {
userInterestMapper.batchUpdateWeights(userId, tagWeights);
}
} }
// 2. 统计分类行为数量(如果需要可以添加分类兴趣统计) // 2. 统计分类行为数量(如果需要可以添加分类兴趣统计)
@@ -349,19 +362,19 @@ public class RecommendServiceImpl implements RecommendService {
VideoRecommendVO vo = new VideoRecommendVO(); VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息 // 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString())); if (videoData.containsKey("id")) vo.setId(toLong(videoData.get("id")));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title")); if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description")); if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl")); if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString())); if (videoData.containsKey("duration")) vo.setDuration(toInt(videoData.get("duration")));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString())); if (videoData.containsKey("userId")) vo.setUserId(toLong(videoData.get("userId")));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname")); if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar")); if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString())); if (videoData.containsKey("categoryId")) vo.setCategoryId(toLong(videoData.get("categoryId")));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName")); if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString())); if (videoData.containsKey("likeCount")) vo.setLikeCount(toInt(videoData.get("likeCount")));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString())); if (videoData.containsKey("commentCount")) vo.setCommentCount(toInt(videoData.get("commentCount")));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString())); if (videoData.containsKey("viewCount")) vo.setViewCount(toLong(videoData.get("viewCount")));
// 设置标签 // 设置标签
if (videoData.containsKey("tagNames")) { if (videoData.containsKey("tagNames")) {
@@ -373,9 +386,74 @@ public class RecommendServiceImpl implements RecommendService {
// 设置发布时间 // 设置发布时间
if (videoData.containsKey("publishTime")) { if (videoData.containsKey("publishTime")) {
String publishTimeStr = videoData.get("publishTime").toString(); String publishTimeStr = videoData.get("publishTime").toString();
vo.setPublishTime(LocalDateTime.parse(publishTimeStr)); vo.setPublishTime(parseDateTime(publishTimeStr));
} }
return vo; return vo;
} }
}
private LocalDateTime parseDateTime(String value) {
if (value == null || value.isBlank()) {
return null;
}
List<DateTimeFormatter> formatters = List.of(
DateTimeFormatter.ISO_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.S")
);
for (DateTimeFormatter formatter : formatters) {
try {
return LocalDateTime.parse(value, formatter);
} catch (DateTimeParseException ignored) {
}
}
log.warn("无法解析发布时间: {}", value);
return null;
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
String text = value.toString().trim();
if (text.isEmpty()) {
return null;
}
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
log.warn("无法解析Long值: {}", value);
return null;
}
}
private Integer toInt(Object value) {
Long longValue = toLong(value);
return longValue == null ? null : longValue.intValue();
}
private double toDouble(Object value) {
if (value == null) {
return 0D;
}
if (value instanceof Number number) {
return number.doubleValue();
}
String text = value.toString().trim();
if (text.isEmpty()) {
return 0D;
}
try {
return Double.parseDouble(text);
} catch (NumberFormatException e) {
log.warn("无法解析Double值: {}", value);
return 0D;
}
}
}
@@ -69,8 +69,11 @@ public class ContentBasedRecommendStrategy implements RecommendStrategy {
for (Long videoId : recentVideoIds) { for (Long videoId : recentVideoIds) {
List<Map<String, Object>> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size / 2); List<Map<String, Object>> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size / 2);
for (Map<String, Object> video : similarVideos) { for (Map<String, Object> video : similarVideos) {
Long similarVideoId = Long.valueOf(video.get("videoId").toString()); Long similarVideoId = toLong(video.get("videoId"));
Double similarity = Double.valueOf(video.get("score").toString()); Double similarity = toDouble(video.get("score"));
if (similarVideoId == null || similarity == null) {
continue;
}
// 过滤掉用户已经看过的视频 // 过滤掉用户已经看过的视频
if (!recentVideoIds.contains(similarVideoId)) { if (!recentVideoIds.contains(similarVideoId)) {
@@ -109,8 +112,11 @@ public class ContentBasedRecommendStrategy implements RecommendStrategy {
// 2. 用户有兴趣标签,基于标签推荐 // 2. 用户有兴趣标签,基于标签推荐
Map<Long, Float> tagWeightMap = new HashMap<>(); Map<Long, Float> tagWeightMap = new HashMap<>();
for (Map<String, Object> weight : userTagWeights) { for (Map<String, Object> weight : userTagWeights) {
tagWeightMap.put(Long.valueOf(weight.get("tag_id").toString()), Long tagId = toLong(weight.get("tag_id"));
Float.valueOf(weight.get("weight").toString())); Float score = toFloat(weight.get("weight"));
if (tagId != null && score != null && score > 0F) {
tagWeightMap.put(tagId, score);
}
} }
// 3. 获取带权重的推荐标签ID // 3. 获取带权重的推荐标签ID
@@ -143,7 +149,10 @@ public class ContentBasedRecommendStrategy implements RecommendStrategy {
if (records != null) { if (records != null) {
for (Map<String, Object> video : records) { for (Map<String, Object> video : records) {
Long videoId = Long.valueOf(video.get("id").toString()); Long videoId = toLong(video.get("id"));
if (videoId == null) {
continue;
}
// 过滤掉已经推荐的视频 // 过滤掉已经推荐的视频
if (!recommendedVideoIds.contains(videoId)) { if (!recommendedVideoIds.contains(videoId)) {
@@ -191,19 +200,19 @@ public class ContentBasedRecommendStrategy implements RecommendStrategy {
VideoRecommendVO vo = new VideoRecommendVO(); VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息 // 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString())); if (videoData.containsKey("id")) vo.setId(toLong(videoData.get("id")));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title")); if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description")); if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl")); if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString())); if (videoData.containsKey("duration")) vo.setDuration(toInt(videoData.get("duration")));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString())); if (videoData.containsKey("userId")) vo.setUserId(toLong(videoData.get("userId")));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname")); if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar")); if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString())); if (videoData.containsKey("categoryId")) vo.setCategoryId(toLong(videoData.get("categoryId")));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName")); if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString())); if (videoData.containsKey("likeCount")) vo.setLikeCount(toInt(videoData.get("likeCount")));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString())); if (videoData.containsKey("commentCount")) vo.setCommentCount(toInt(videoData.get("commentCount")));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString())); if (videoData.containsKey("viewCount")) vo.setViewCount(toLong(videoData.get("viewCount")));
// 设置标签 // 设置标签
if (videoData.containsKey("tagNames")) { if (videoData.containsKey("tagNames")) {
@@ -214,4 +223,44 @@ public class ContentBasedRecommendStrategy implements RecommendStrategy {
return vo; return vo;
} }
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
log.warn("推荐数据Long解析失败: {}", value);
return null;
}
}
private Integer toInt(Object value) {
Long longValue = toLong(value);
return longValue == null ? null : longValue.intValue();
}
private Double toDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return Double.parseDouble(value.toString());
} catch (NumberFormatException e) {
log.warn("推荐数据Double解析失败: {}", value);
return null;
}
}
private Float toFloat(Object value) {
Double doubleValue = toDouble(value);
return doubleValue == null ? null : doubleValue.floatValue();
}
}
@@ -50,18 +50,26 @@ public class HotRecommendStrategy implements RecommendStrategy {
} }
// 获取每日热门视频 // 获取每日热门视频
List<Long> hotVideoIds = hotVideoMapper.selectHotVideoIds(1, size); List<Long> hotVideoIds = new ArrayList<>(hotVideoMapper.selectHotVideoIds(1, size));
// 如果每日热门不足,则获取每周热门补充 // 如果每日热门不足,则获取每周热门补充
if (hotVideoIds.size() < size) { if (hotVideoIds.size() < size) {
List<Long> weeklyHotVideoIds = hotVideoMapper.selectHotVideoIds(2, size - hotVideoIds.size()); List<Long> weeklyHotVideoIds = hotVideoMapper.selectHotVideoIds(2, size - hotVideoIds.size());
hotVideoIds.addAll(weeklyHotVideoIds); for (Long videoId : weeklyHotVideoIds) {
if (!hotVideoIds.contains(videoId)) {
hotVideoIds.add(videoId);
}
}
} }
// 如果仍然不足,则获取每月热门补充 // 如果仍然不足,则获取每月热门补充
if (hotVideoIds.size() < size) { if (hotVideoIds.size() < size) {
List<Long> monthlyHotVideoIds = hotVideoMapper.selectHotVideoIds(3, size - hotVideoIds.size()); List<Long> monthlyHotVideoIds = hotVideoMapper.selectHotVideoIds(3, size - hotVideoIds.size());
hotVideoIds.addAll(monthlyHotVideoIds); for (Long videoId : monthlyHotVideoIds) {
if (!hotVideoIds.contains(videoId)) {
hotVideoIds.add(videoId);
}
}
} }
// 调用内容服务获取视频详情 // 调用内容服务获取视频详情
@@ -97,19 +105,19 @@ public class HotRecommendStrategy implements RecommendStrategy {
VideoRecommendVO vo = new VideoRecommendVO(); VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息 // 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString())); if (videoData.containsKey("id")) vo.setId(toLong(videoData.get("id")));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title")); if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description")); if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl")); if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString())); if (videoData.containsKey("duration")) vo.setDuration(toInt(videoData.get("duration")));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString())); if (videoData.containsKey("userId")) vo.setUserId(toLong(videoData.get("userId")));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname")); if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar")); if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString())); if (videoData.containsKey("categoryId")) vo.setCategoryId(toLong(videoData.get("categoryId")));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName")); if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString())); if (videoData.containsKey("likeCount")) vo.setLikeCount(toInt(videoData.get("likeCount")));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString())); if (videoData.containsKey("commentCount")) vo.setCommentCount(toInt(videoData.get("commentCount")));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString())); if (videoData.containsKey("viewCount")) vo.setViewCount(toLong(videoData.get("viewCount")));
// 设置标签 // 设置标签
if (videoData.containsKey("tagNames")) { if (videoData.containsKey("tagNames")) {
@@ -120,4 +128,24 @@ public class HotRecommendStrategy implements RecommendStrategy {
return vo; return vo;
} }
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
log.warn("热门推荐Long解析失败: {}", value);
return null;
}
}
private Integer toInt(Object value) {
Long longValue = toLong(value);
return longValue == null ? null : longValue.intValue();
}
}
@@ -119,8 +119,11 @@ public class HotVideoUpdateTask {
// 构建热门视频实体列表 // 构建热门视频实体列表
List<RecommendHotVideo> hotVideoEntities = new ArrayList<>(); List<RecommendHotVideo> hotVideoEntities = new ArrayList<>();
for (Map<String, Object> video : hotVideos) { for (Map<String, Object> video : hotVideos) {
Long videoId = Long.valueOf(video.get("videoId").toString()); Long videoId = toLong(video.get("videoId"));
float score = Float.parseFloat(video.get("score").toString()); Float score = toFloat(video.get("score"));
if (videoId == null || score == null) {
continue;
}
RecommendHotVideo hotVideo = new RecommendHotVideo(); RecommendHotVideo hotVideo = new RecommendHotVideo();
hotVideo.setVideoId(videoId); hotVideo.setVideoId(videoId);
@@ -140,4 +143,34 @@ public class HotVideoUpdateTask {
} }
} }
} }
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
log.warn("热门任务Long解析失败: {}", value);
return null;
}
}
private Float toFloat(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.floatValue();
}
try {
return Float.parseFloat(value.toString());
} catch (NumberFormatException e) {
log.warn("热门任务Float解析失败: {}", value);
return null;
}
}
}
@@ -1,5 +1,5 @@
server: server:
port: 9006 port: 9005
spring: spring:
application: application:
@@ -18,14 +18,14 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 5 database: 4
timeout: 10000 timeout: 10000
lettuce: lettuce:
pool: pool:
@@ -40,10 +40,15 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
logic-delete-field: is_deleted logic-delete-field: is_deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
jwt:
secret: ${USER_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: Bearer
@@ -13,9 +13,9 @@
<!-- 批量更新用户标签权重 --> <!-- 批量更新用户标签权重 -->
<update id="batchUpdateWeights"> <update id="batchUpdateWeights">
<foreach collection="tagWeights.entrySet()" item="entry" separator=";"> <foreach collection="tagWeights" index="tagId" item="weight" separator=";">
INSERT INTO recommend_user_interest (user_id, tag_id, weight, created_by, updated_by, create_time, update_time) INSERT INTO recommend_user_interest (user_id, tag_id, weight, created_by, updated_by, create_time, update_time)
VALUES (#{userId}, #{entry.key}, #{entry.value}, 'system', 'system', NOW(), NOW()) VALUES (#{userId}, #{tagId}, #{weight}, 'system', 'system', NOW(), NOW())
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
weight = VALUES(weight), weight = VALUES(weight),
updated_by = VALUES(updated_by), updated_by = VALUES(updated_by),
@@ -29,4 +29,4 @@
FROM recommend_user_interest FROM recommend_user_interest
WHERE user_id = #{userId} WHERE user_id = #{userId}
</select> </select>
</mapper> </mapper>
@@ -2,8 +2,11 @@ package com.nuaa.video.user.config;
import com.nuaa.video.user.filter.JwtAuthenticationFilter; import com.nuaa.video.user.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@@ -15,11 +18,15 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${service.auth.token}")
private String internalServiceToken;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
@@ -29,15 +36,27 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/user/profile/public/**", "/user/profile/public/**",
"/user/profile/count/**", "/user/profile/search",
"/user/profile/init", "/user/follow/following/**",
"/user/follow/followers/**",
"/actuator/**", "/actuator/**",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers(
"/user/profile/count/**",
"/user/profile/init",
"/user/profile/admin/**",
"/user/notification/send/**"
).access((authentication, context) ->
new AuthorizationDecision(isInternalRequest(context.getRequest().getHeader("X-Internal-Token"))))
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
}
private boolean isInternalRequest(String internalToken) {
return internalServiceToken.equals(internalToken);
}
}
@@ -5,8 +5,10 @@ import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.user.entity.UserInfo; import com.nuaa.video.user.entity.UserInfo;
import com.nuaa.video.user.service.UserInfoService; import com.nuaa.video.user.service.UserInfoService;
import com.nuaa.video.user.vo.UserProfileVO; import com.nuaa.video.user.vo.UserProfileVO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/** /**
@@ -18,6 +20,9 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminUserController { public class AdminUserController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserInfoService userInfoService; private final UserInfoService userInfoService;
/** /**
@@ -27,7 +32,11 @@ public class AdminUserController {
public Result<Page<UserProfileVO>> getUserList( public Result<Page<UserProfileVO>> getUserList(
@RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size, @RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword) { @RequestParam(value = "keyword", required = false) String keyword,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
Page<UserInfo> pageParam = new Page<>(page, size); Page<UserInfo> pageParam = new Page<>(page, size);
return userInfoService.getAdminUserList(pageParam, keyword); return userInfoService.getAdminUserList(pageParam, keyword);
} }
@@ -36,7 +45,10 @@ public class AdminUserController {
* 获取用户详情 - 管理接口 * 获取用户详情 - 管理接口
*/ */
@GetMapping("/{userId}") @GetMapping("/{userId}")
public Result<UserProfileVO> getUserDetail(@PathVariable Long userId) { public Result<UserProfileVO> getUserDetail(@PathVariable Long userId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.getAdminUserDetail(userId); return userInfoService.getAdminUserDetail(userId);
} }
@@ -46,8 +58,12 @@ public class AdminUserController {
@PutMapping("/status/{userId}/{status}") @PutMapping("/status/{userId}/{status}")
public Result<Void> updateUserStatus( public Result<Void> updateUserStatus(
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Integer status) { @PathVariable Integer status,
return userInfoService.updateUserStatus(userId, status); HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.updateUserStatus(userId, status, internalServiceToken);
} }
/** /**
@@ -56,15 +72,26 @@ public class AdminUserController {
@PutMapping("/reset-password/{userId}") @PutMapping("/reset-password/{userId}")
public Result<Void> resetUserPassword( public Result<Void> resetUserPassword(
@PathVariable Long userId, @PathVariable Long userId,
@RequestParam String newPassword) { @RequestParam String newPassword,
return userInfoService.resetUserPassword(userId, newPassword); HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.resetUserPassword(userId, newPassword, internalServiceToken);
} }
/** /**
* 获取用户统计信息 - 管理接口 * 获取用户统计信息 - 管理接口
*/ */
@GetMapping("/stats") @GetMapping("/stats")
public Result<Object> getUserStats() { public Result<Object> getUserStats(HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.getUserStats(); return userInfoService.getUserStats();
} }
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -8,10 +8,9 @@ import com.nuaa.video.user.vo.UserNotificationVO;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/** /**
* 用户通知控制器 * 用户通知控制器
*/ */
@@ -21,6 +20,9 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserNotificationController { public class UserNotificationController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserNotificationService userNotificationService; private final UserNotificationService userNotificationService;
/** /**
@@ -86,17 +88,10 @@ public class UserNotificationController {
@RequestParam(required = false) String content, @RequestParam(required = false) String content,
@RequestParam(required = false) Long relatedId, @RequestParam(required = false) Long relatedId,
HttpServletRequest request) { HttpServletRequest request) {
// 验证调用者身份 if (!internalServiceToken.equals(request.getHeader("X-Internal-Token"))) {
Long currentUserId = getUserIdFromRequest(request); return Result.error("非法的内部调用");
if (currentUserId == null) {
return Result.error("未登录");
} }
// 创建通知对象
UserNotification notification = new UserNotification();
notification.setUserId(userId);
notification.setType(type);
// 设置通知内容 // 设置通知内容
String notificationContent; String notificationContent;
if (content != null && !content.isEmpty()) { if (content != null && !content.isEmpty()) {
@@ -120,16 +115,7 @@ public class UserNotificationController {
notificationContent = "新通知"; notificationContent = "新通知";
} }
} }
notification.setContent(notificationContent); return userNotificationService.createNotification(userId, type, notificationContent, relatedId);
notification.setRelatedId(relatedId);
notification.setIsRead(0); // 未读
notification.setCreateTime(LocalDateTime.now());
// 保存通知
userNotificationService.save(notification);
return Result.success();
} }
/** /**
@@ -142,4 +128,4 @@ public class UserNotificationController {
} }
return null; return null;
} }
} }
@@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -25,6 +26,9 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserProfileController { public class UserProfileController {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserInfoService userInfoService; private final UserInfoService userInfoService;
private final AuthFeignClient authFeignClient; private final AuthFeignClient authFeignClient;
@@ -33,7 +37,11 @@ public class UserProfileController {
*/ */
@PostMapping("/init") @PostMapping("/init")
public Result<Void> initUserInfo(@RequestParam("userId") Long userId, public Result<Void> initUserInfo(@RequestParam("userId") Long userId,
@RequestParam("username") String username) { @RequestParam("username") String username,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.createUserInfo(userId, username); return userInfoService.createUserInfo(userId, username);
} }
@@ -130,13 +138,9 @@ public class UserProfileController {
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Integer count, @PathVariable Integer count,
HttpServletRequest request) { HttpServletRequest request) {
// 验证权限(实际可以通过更复杂的权限验证) if (!isInternalRequest(request)) {
Long currentUserId = getUserIdFromRequest(request); return Result.error("非法的内部调用");
if (currentUserId == null) {
return Result.error("未登录");
} }
// 调用service增加用户视频数量
return userInfoService.increaseVideoCount(userId, count); return userInfoService.increaseVideoCount(userId, count);
} }
@@ -148,13 +152,9 @@ public class UserProfileController {
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Integer count, @PathVariable Integer count,
HttpServletRequest request) { HttpServletRequest request) {
// 验证权限 if (!isInternalRequest(request)) {
Long currentUserId = getUserIdFromRequest(request); return Result.error("非法的内部调用");
if (currentUserId == null) {
return Result.error("未登录");
} }
// 调用service增加用户获赞数量
return userInfoService.increaseLikeCount(userId, count); return userInfoService.increaseLikeCount(userId, count);
} }
@@ -166,13 +166,9 @@ public class UserProfileController {
@PathVariable Long userId, @PathVariable Long userId,
@PathVariable Integer count, @PathVariable Integer count,
HttpServletRequest request) { HttpServletRequest request) {
// 验证权限 if (!isInternalRequest(request)) {
Long currentUserId = getUserIdFromRequest(request); return Result.error("非法的内部调用");
if (currentUserId == null) {
return Result.error("未登录");
} }
// 调用service增加用户收藏数量
return userInfoService.increaseFavoriteCount(userId, count); return userInfoService.increaseFavoriteCount(userId, count);
} }
@@ -197,4 +193,8 @@ public class UserProfileController {
} }
return null; return null;
} }
}
private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
}
}
@@ -33,7 +33,8 @@ public interface AuthFeignClient {
*/ */
@PutMapping("/admin/lock/{userId}/{locked}") @PutMapping("/admin/lock/{userId}/{locked}")
Result<Void> lockUser(@PathVariable Long userId, Result<Void> lockUser(@PathVariable Long userId,
@PathVariable Boolean locked); @PathVariable Boolean locked,
@RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 管理员重置用户密码 - 管理接口 * 管理员重置用户密码 - 管理接口
@@ -41,5 +42,6 @@ public interface AuthFeignClient {
@PutMapping("/admin/reset-password/{userId}") @PutMapping("/admin/reset-password/{userId}")
Result<Void> resetPassword( Result<Void> resetPassword(
@PathVariable Long userId, @PathVariable Long userId,
@RequestParam String newPassword); @RequestParam String newPassword,
} @RequestHeader("X-Internal-Token") String internalToken);
}
@@ -21,6 +21,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* JWT认证过滤器(不依赖Feign) * JWT认证过滤器(不依赖Feign)
@@ -29,7 +30,7 @@ import java.util.List;
@Component @Component
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") @Value("${jwt.secret}")
private String secret; private String secret;
private static final String TOKEN_PREFIX = "Bearer "; private static final String TOKEN_PREFIX = "Bearer ";
@@ -55,7 +56,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
// 设置用户权限 // 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>(); List<String> authorityClaims = claims.get("authorities", List.class);
List<SimpleGrantedAuthority> authorities = authorityClaims == null
? new ArrayList<>()
: authorityClaims.stream()
.filter(StringUtils::hasText)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(ArrayList::new));
// 创建认证对象 // 创建认证对象
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
@@ -70,6 +77,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 在请求属性中存储用户ID // 在请求属性中存储用户ID
request.setAttribute("userId", userId); request.setAttribute("userId", userId);
request.setAttribute("username", username); request.setAttribute("username", username);
if (!authorities.isEmpty()) {
request.setAttribute("adminId", userId);
}
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username); log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) { } catch (Exception e) {
@@ -99,4 +109,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes); return Keys.hmacShaKeyFor(keyBytes);
} }
} }
@@ -28,4 +28,19 @@ public interface UserInfoMapper extends BaseMapper<UserInfo> {
* @return 影响行数 * @return 影响行数
*/ */
int increaseFollowerCount(@Param("userId") Long userId, @Param("count") int count); int increaseFollowerCount(@Param("userId") Long userId, @Param("count") int count);
}
/**
* 增加视频数
*/
int increaseVideoCount(@Param("userId") Long userId, @Param("count") int count);
/**
* 增加获赞数
*/
int increaseLikeCount(@Param("userId") Long userId, @Param("count") int count);
/**
* 增加收藏数
*/
int increaseFavoriteCount(@Param("userId") Long userId, @Param("count") int count);
}
@@ -3,10 +3,24 @@ package com.nuaa.video.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.user.entity.UserStatDaily; import com.nuaa.video.user.entity.UserStatDaily;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/** /**
* 用户每日统计Mapper * 用户每日统计Mapper
*/ */
@Mapper @Mapper
public interface UserStatDailyMapper extends BaseMapper<UserStatDaily> { public interface UserStatDailyMapper extends BaseMapper<UserStatDaily> {
}
/**
* 原子更新用户每日统计,避免并发下先查后写导致锁等待。
*/
int upsertStatIncrement(@Param("userId") Long userId,
@Param("statDate") java.time.LocalDate statDate,
@Param("followerIncrement") int followerIncrement,
@Param("followingIncrement") int followingIncrement,
@Param("videoIncrement") int videoIncrement,
@Param("likeIncrement") int likeIncrement,
@Param("coinIncrement") int coinIncrement,
@Param("favoriteIncrement") int favoriteIncrement,
@Param("viewIncrement") long viewIncrement);
}
@@ -106,15 +106,15 @@ public interface UserInfoService extends IService<UserInfo> {
/** /**
* 更新用户状态 - 管理接口 * 更新用户状态 - 管理接口
*/ */
Result<Void> updateUserStatus(Long userId, Integer status); Result<Void> updateUserStatus(Long userId, Integer status, String token);
/** /**
* 重置用户密码 - 管理接口 * 重置用户密码 - 管理接口
*/ */
Result<Void> resetUserPassword(Long userId, String newPassword); Result<Void> resetUserPassword(Long userId, String newPassword, String token);
/** /**
* 获取用户统计信息 - 管理接口 * 获取用户统计信息 - 管理接口
*/ */
Result<Object> getUserStats(); Result<Object> getUserStats();
} }
@@ -19,6 +19,7 @@ import com.nuaa.video.user.vo.UserProfileVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -29,6 +30,7 @@ import java.time.LocalDateTime;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -39,6 +41,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> implements UserInfoService { public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> implements UserInfoService {
@Value("${service.auth.token}")
private String internalServiceToken;
private final UserInfoMapper userInfoMapper; private final UserInfoMapper userInfoMapper;
private final UserFollowMapper userFollowMapper; private final UserFollowMapper userFollowMapper;
private final UserSettingService userSettingService; private final UserSettingService userSettingService;
@@ -199,7 +204,6 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> increaseVideoCount(Long userId, Integer count) { public Result<Void> increaseVideoCount(Long userId, Integer count) {
// 验证用户是否存在 // 验证用户是否存在
UserInfo userInfo = getById(userId); UserInfo userInfo = getById(userId);
@@ -207,19 +211,14 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage()); return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage());
} }
// 更新视频数量 userInfoMapper.increaseVideoCount(userId, count);
userInfo.setVideoCount(userInfo.getVideoCount() + count);
userInfo.setUpdateTime(LocalDateTime.now());
updateById(userInfo);
// 更新每日统计 updateUserStatDailyAsync(userId, "video", count);
updateUserStatDaily(userId, "video", count);
return Result.success(); return Result.success();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> increaseLikeCount(Long userId, Integer count) { public Result<Void> increaseLikeCount(Long userId, Integer count) {
// 验证用户是否存在 // 验证用户是否存在
UserInfo userInfo = getById(userId); UserInfo userInfo = getById(userId);
@@ -227,16 +226,13 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage()); return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage());
} }
// 更新获赞数量 userInfoMapper.increaseLikeCount(userId, count);
userInfo.setLikeCount(userInfo.getLikeCount() + count); updateUserStatDailyAsync(userId, "like", count);
userInfo.setUpdateTime(LocalDateTime.now());
updateById(userInfo);
return Result.success(); return Result.success();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> increaseFavoriteCount(Long userId, Integer count) { public Result<Void> increaseFavoriteCount(Long userId, Integer count) {
// 验证用户是否存在 // 验证用户是否存在
UserInfo userInfo = getById(userId); UserInfo userInfo = getById(userId);
@@ -244,10 +240,8 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage()); return Result.error(ResultCode.USER_NOT_FOUND.getCode(),ResultCode.USER_NOT_FOUND.getMessage());
} }
// 更新收藏数量 userInfoMapper.increaseFavoriteCount(userId, count);
userInfo.setFavoriteCount(userInfo.getFavoriteCount() + count); updateUserStatDailyAsync(userId, "favorite", count);
userInfo.setUpdateTime(LocalDateTime.now());
updateById(userInfo);
return Result.success(); return Result.success();
} }
@@ -306,7 +300,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Result<Void> updateUserStatus(Long userId, Integer status) { public Result<Void> updateUserStatus(Long userId, Integer status, String token) {
// 查询用户是否存在 // 查询用户是否存在
UserInfo userInfo = getById(userId); UserInfo userInfo = getById(userId);
if (userInfo == null) { if (userInfo == null) {
@@ -320,19 +314,14 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
updateUser.setUpdatedBy("admin"); updateUser.setUpdatedBy("admin");
updateById(updateUser); updateById(updateUser);
// 当禁用用户,需要清除用户相关的登录态等 authFeignClient.lockUser(userId, status == 0, internalServiceToken);
if (status == 0) {
// 发送事件通知auth服务禁用该用户账号
//TODO 可以优化为消息队列
authFeignClient.lockUser(userId, true);
}
return Result.success(); return Result.success();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Result<Void> resetUserPassword(Long userId, String newPassword) { public Result<Void> resetUserPassword(Long userId, String newPassword, String token) {
// 查询用户是否存在 // 查询用户是否存在
UserInfo userInfo = getById(userId); UserInfo userInfo = getById(userId);
if (userInfo == null) { if (userInfo == null) {
@@ -340,7 +329,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
} }
// 调用auth服务重置密码(实际项目中通过Feign或消息队列) // 调用auth服务重置密码(实际项目中通过Feign或消息队列)
authFeignClient.resetPassword(userId, newPassword); authFeignClient.resetPassword(userId, newPassword, internalServiceToken);
log.info("重置用户密码: userId={}, newPassword=***", userId); log.info("重置用户密码: userId={}, newPassword=***", userId);
@@ -362,9 +351,21 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
.ge(UserInfo::getCreateTime, today.atStartOfDay())); .ge(UserInfo::getCreateTime, today.atStartOfDay()));
stats.put("todayNewUsers", todayNewUsers); stats.put("todayNewUsers", todayNewUsers);
// 活跃用户数(最近7天有登录记录的用户) LocalDate activeSince = today.minusDays(6);
// 此处需要结合用户登录记录表查询,简化处理 List<UserStatDaily> activeStats = userStatDailyMapper.selectList(new LambdaQueryWrapper<UserStatDaily>()
stats.put("activeUsers", totalUsers * 0.3); // 示例数据 .ge(UserStatDaily::getStatDate, activeSince));
long activeUsers = activeStats.stream()
.filter(stat -> positive(stat.getFollowerIncrement())
|| positive(stat.getFollowingIncrement())
|| positive(stat.getVideoIncrement())
|| positive(stat.getLikeIncrement())
|| positive(stat.getCoinIncrement())
|| positive(stat.getFavoriteIncrement())
|| positive(stat.getViewIncrement()))
.map(UserStatDaily::getUserId)
.distinct()
.count();
stats.put("activeUsers", activeUsers);
// 用户等级分布 // 用户等级分布
// 假设level字段代表用户等级 // 假设level字段代表用户等级
@@ -384,59 +385,61 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
*/ */
private void updateUserStatDaily(Long userId, String statType, int count) { private void updateUserStatDaily(Long userId, String statType, int count) {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
int followerIncrement = 0;
int followingIncrement = 0;
int videoIncrement = 0;
int likeIncrement = 0;
int coinIncrement = 0;
int favoriteIncrement = 0;
long viewIncrement = 0L;
// 查询今日统计记录
UserStatDaily statDaily = userStatDailyMapper.selectOne(
new LambdaQueryWrapper<UserStatDaily>()
.eq(UserStatDaily::getUserId, userId)
.eq(UserStatDaily::getStatDate, today)
);
// 如果不存在,创建新记录
if (statDaily == null) {
statDaily = new UserStatDaily();
statDaily.setUserId(userId);
statDaily.setStatDate(today);
statDaily.setFollowerIncrement(0);
statDaily.setFollowingIncrement(0);
statDaily.setVideoIncrement(0);
statDaily.setLikeIncrement(0);
statDaily.setCoinIncrement(0);
statDaily.setFavoriteIncrement(0);
statDaily.setViewIncrement(0L);
statDaily.setCreateTime(LocalDateTime.now());
}
// 根据统计类型更新数据
switch (statType) { switch (statType) {
case "video": case "video":
statDaily.setVideoIncrement(statDaily.getVideoIncrement() + count); videoIncrement = count;
break; break;
case "like": case "like":
statDaily.setLikeIncrement(statDaily.getLikeIncrement() + count); likeIncrement = count;
break; break;
case "follower": case "follower":
statDaily.setFollowerIncrement(statDaily.getFollowerIncrement() + count); followerIncrement = count;
break; break;
case "following": case "following":
statDaily.setFollowingIncrement(statDaily.getFollowingIncrement() + count); followingIncrement = count;
break; break;
case "favorite": case "favorite":
statDaily.setFavoriteIncrement(statDaily.getFavoriteIncrement() + count); favoriteIncrement = count;
break; break;
case "coin": case "coin":
statDaily.setCoinIncrement(statDaily.getCoinIncrement() + count); coinIncrement = count;
break; break;
case "view": case "view":
statDaily.setViewIncrement(statDaily.getViewIncrement() + count); viewIncrement = count;
break; break;
} }
userStatDailyMapper.upsertStatIncrement(
// 保存更新 userId,
if (statDaily.getId() == null) { today,
userStatDailyMapper.insert(statDaily); followerIncrement,
} else { followingIncrement,
userStatDailyMapper.updateById(statDaily); videoIncrement,
} likeIncrement,
coinIncrement,
favoriteIncrement,
viewIncrement
);
} }
}
private void updateUserStatDailyAsync(Long userId, String statType, int count) {
CompletableFuture.runAsync(() -> {
try {
updateUserStatDaily(userId, statType, count);
} catch (Exception e) {
log.warn("异步更新用户日统计失败,userId={}, statType={}, count={}", userId, statType, count, e);
}
});
}
private boolean positive(Number value) {
return value != null && value.longValue() > 0;
}
}
@@ -18,13 +18,13 @@ spring:
datasource: datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_user?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_user?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root username: root
password: root password: ${MYSQL_ROOT_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
data: data:
redis: redis:
host: ${REDIS_HOST:redis} host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: redis_EsJ8FX password: ${REDIS_PASSWORD}
database: 1 database: 1
timeout: 10000 timeout: 10000
lettuce: lettuce:
@@ -40,10 +40,25 @@ mybatis-plus:
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
cache-enabled: false cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config: global-config:
db-config: db-config:
id-type: auto id-type: auto
logic-delete-field: is_deleted logic-delete-field: is_deleted
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
minio:
endpoint: http://${MINIO_HOST:minio}:${MINIO_PORT:9000}
accessKey: ${MINIO_ACCESS_KEY}
secretKey: ${MINIO_SECRET_KEY}
bucketName: ${MINIO_BUCKET}
service:
auth:
token: ${SERVICE_AUTH_TOKEN}
jwt:
secret: ${USER_JWT_SECRET}
expiration: ${JWT_EXPIRATION}
token-header: Authorization
token-prefix: Bearer
@@ -16,4 +16,25 @@
WHERE id = #{userId} WHERE id = #{userId}
</update> </update>
</mapper> <update id="increaseVideoCount">
UPDATE user_info
SET video_count = GREATEST(video_count + #{count}, 0),
update_time = NOW()
WHERE id = #{userId}
</update>
<update id="increaseLikeCount">
UPDATE user_info
SET like_count = GREATEST(like_count + #{count}, 0),
update_time = NOW()
WHERE id = #{userId}
</update>
<update id="increaseFavoriteCount">
UPDATE user_info
SET favorite_count = GREATEST(favorite_count + #{count}, 0),
update_time = NOW()
WHERE id = #{userId}
</update>
</mapper>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.user.mapper.UserStatDailyMapper">
<insert id="upsertStatIncrement">
INSERT INTO user_stat_daily (
user_id,
stat_date,
follower_increment,
following_increment,
video_increment,
like_increment,
coin_increment,
favorite_increment,
view_increment
) VALUES (
#{userId},
#{statDate},
#{followerIncrement},
#{followingIncrement},
#{videoIncrement},
#{likeIncrement},
#{coinIncrement},
#{favoriteIncrement},
#{viewIncrement}
)
ON DUPLICATE KEY UPDATE
follower_increment = follower_increment + VALUES(follower_increment),
following_increment = following_increment + VALUES(following_increment),
video_increment = video_increment + VALUES(video_increment),
like_increment = like_increment + VALUES(like_increment),
coin_increment = coin_increment + VALUES(coin_increment),
favorite_increment = favorite_increment + VALUES(favorite_increment),
view_increment = view_increment + VALUES(view_increment)
</insert>
</mapper>
@@ -13,7 +13,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -27,6 +29,7 @@ import static org.mockito.Mockito.*;
* UserFollowService 单元测试 * UserFollowService 单元测试
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@DisplayName("用户关注服务测试") @DisplayName("用户关注服务测试")
class UserFollowServiceTest { class UserFollowServiceTest {
@@ -1,30 +1,40 @@
package com.nuaa.video.user.service; package com.nuaa.video.user.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.common.utils.MinioUtils;
import com.nuaa.video.user.dto.UserProfileDTO; import com.nuaa.video.user.dto.UserProfileDTO;
import com.nuaa.video.user.entity.UserInfo; import com.nuaa.video.user.entity.UserInfo;
import com.nuaa.video.user.entity.UserStatDaily;
import com.nuaa.video.user.feign.AuthFeignClient;
import com.nuaa.video.user.mapper.UserFollowMapper;
import com.nuaa.video.user.mapper.UserInfoMapper; import com.nuaa.video.user.mapper.UserInfoMapper;
import com.nuaa.video.user.mapper.UserStatDailyMapper;
import com.nuaa.video.user.service.impl.UserInfoServiceImpl; import com.nuaa.video.user.service.impl.UserInfoServiceImpl;
import com.nuaa.video.user.vo.UserProfileVO; import com.nuaa.video.user.vo.UserProfileVO;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* UserInfoService 单元测试
*/
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@DisplayName("用户信息服务测试") @DisplayName("用户信息服务测试")
class UserInfoServiceTest { class UserInfoServiceTest {
@@ -32,6 +42,22 @@ class UserInfoServiceTest {
@Mock @Mock
private UserInfoMapper userInfoMapper; private UserInfoMapper userInfoMapper;
@Mock
private UserFollowMapper userFollowMapper;
@Mock
private UserSettingService userSettingService;
@Mock
private MinioUtils minioUtils;
@Mock
private UserStatDailyMapper userStatDailyMapper;
@Mock
private AuthFeignClient authFeignClient;
@Spy
@InjectMocks @InjectMocks
private UserInfoServiceImpl userInfoService; private UserInfoServiceImpl userInfoService;
@@ -62,158 +88,127 @@ class UserInfoServiceTest {
@Test @Test
@DisplayName("创建用户信息 - 成功") @DisplayName("创建用户信息 - 成功")
void testCreateUserInfoSuccess() { void testCreateUserInfoSuccess() {
// Given doReturn(null).when(userInfoService).getById(1L);
Long userId = 1L; doReturn(true).when(userInfoService).save(any(UserInfo.class));
String username = "testuser"; when(userSettingService.createDefaultSetting(1L)).thenReturn(Result.success());
when(userInfoMapper.selectById(userId)).thenReturn(null); Result<Void> result = userInfoService.createUserInfo(1L, "testuser");
when(userInfoMapper.insert(any(UserInfo.class))).thenReturn(1);
// When
Result<Void> result = userInfoService.createUserInfo(userId, username);
// Then
assertNotNull(result);
assertEquals(200, result.getCode()); assertEquals(200, result.getCode());
verify(userInfoMapper, times(1)).insert(any(UserInfo.class)); verify(userInfoService, times(1)).save(ArgumentMatchers.argThat(user ->
user.getId().equals(1L)
&& "testuser".equals(user.getNickname())
&& "default/avatar.png".equals(user.getAvatar())
&& user.getVideoCount() == 0
));
verify(userSettingService, times(1)).createDefaultSetting(1L);
} }
@Test @Test
@DisplayName("创建用户信息 - 用户已存在") @DisplayName("创建用户信息 - 已存在")
void testCreateUserInfoAlreadyExists() { void testCreateUserInfoAlreadyExists() {
// Given doReturn(testUserInfo).when(userInfoService).getById(1L);
Long userId = 1L;
String username = "testuser";
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); Result<Void> result = userInfoService.createUserInfo(1L, "testuser");
// When assertNotEquals(200, result.getCode());
Result<Void> result = userInfoService.createUserInfo(userId, username); assertEquals("用户信息已存在", result.getMessage());
verify(userInfoService, times(0)).save(any(UserInfo.class));
// Then
assertNotNull(result);
assertEquals(200, result.getCode());
verify(userInfoMapper, never()).insert(any(UserInfo.class));
} }
@Test @Test
@DisplayName("获取用户资料 - 成功") @DisplayName("获取用户资料 - 成功并标记关注状态")
void testGetUserProfileSuccess() { void testGetUserProfileSuccess() {
// Given doReturn(testUserInfo).when(userInfoService).getById(1L);
Long userId = 1L; when(userFollowMapper.checkIsFollowed(2L, 1L)).thenReturn(1);
Long currentUserId = 2L;
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); Result<UserProfileVO> result = userInfoService.getUserProfile(1L, 2L);
// Then assertEquals(200, result.getCode());
assertNotNull(testUserInfo); assertNotNull(result.getData());
assertEquals("测试用户", testUserInfo.getNickname()); assertEquals("测试用户", result.getData().getNickname());
} assertTrue(result.getData().getIsFollowed());
@Test
@DisplayName("获取用户资料 - 用户不存在")
void testGetUserProfileNotFound() {
// Given
Long userId = 999L;
when(userInfoMapper.selectById(userId)).thenReturn(null);
// When
UserInfo user = userInfoMapper.selectById(userId);
// Then
assertNull(user);
} }
@Test @Test
@DisplayName("更新用户资料 - 成功") @DisplayName("更新用户资料 - 成功")
void testUpdateUserProfileSuccess() { void testUpdateUserProfileSuccess() {
// Given
Long userId = 1L;
UserProfileDTO dto = new UserProfileDTO(); UserProfileDTO dto = new UserProfileDTO();
dto.setNickname("新昵称"); dto.setNickname("新昵称");
dto.setSignature("新签名"); dto.setSignature("新签名");
dto.setGender(1);
dto.setRegion("上海"); dto.setRegion("上海");
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); doReturn(testUserInfo).when(userInfoService).getById(1L);
when(userInfoMapper.updateById(any(UserInfo.class))).thenReturn(1); doReturn(true).when(userInfoService).updateById(any(UserInfo.class));
// Then Result<Void> result = userInfoService.updateUserProfile(1L, dto);
assertNotNull(dto);
assertEquals("新昵称", dto.getNickname()); assertEquals(200, result.getCode());
verify(userInfoService, times(1)).updateById(ArgumentMatchers.argThat(user ->
"新昵称".equals(user.getNickname())
&& "新签名".equals(user.getSignature())
&& "上海".equals(user.getRegion())
&& "1".equals(user.getUpdatedBy())
));
} }
@Test @Test
@DisplayName("获取简要用户信息 - 成功") @DisplayName("增加用户视频数量 - 成功并写入每日统计")
void testGetBriefUserInfoSuccess() {
// Given
Long userId = 1L;
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo);
// When
UserInfo user = userInfoMapper.selectById(userId);
// Then
assertNotNull(user);
assertEquals("测试用户", user.getNickname());
}
@Test
@DisplayName("搜索用户 - 成功")
void testSearchUsersSuccess() {
// Given
Page<UserInfo> page = new Page<>(1, 10);
String keyword = "测试";
// Then
assertNotNull(page);
assertEquals(1, page.getCurrent());
assertEquals(10, page.getSize());
}
@Test
@DisplayName("增加用户视频数量 - 成功")
void testIncreaseVideoCountSuccess() { void testIncreaseVideoCountSuccess() {
// Given doReturn(testUserInfo).when(userInfoService).getById(1L);
Long userId = 1L; doReturn(true).when(userInfoService).updateById(any(UserInfo.class));
Integer count = 1; when(userStatDailyMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
int oldVideoCount = testUserInfo.getVideoCount(); when(userStatDailyMapper.insert(ArgumentMatchers.<UserStatDaily>any())).thenReturn(1);
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); Result<Void> result = userInfoService.increaseVideoCount(1L, 2);
when(userInfoMapper.updateById(any(UserInfo.class))).thenReturn(1);
// Then assertEquals(200, result.getCode());
assertEquals(10, oldVideoCount); verify(userInfoService).updateById(ArgumentMatchers.argThat(user -> user.getVideoCount() == 12));
verify(userStatDailyMapper).insert(ArgumentMatchers.<UserStatDaily>argThat(stat ->
stat.getUserId().equals(1L)
&& stat.getVideoIncrement() == 2
&& stat.getStatDate().equals(LocalDate.now())
));
} }
@Test @Test
@DisplayName("增加用户获赞数量 - 成功") @DisplayName("获取用户统计 - 基于近7日活跃记录聚合")
void testIncreaseLikeCountSuccess() { void testGetUserStatsSuccess() {
// Given UserStatDaily activeOne = new UserStatDaily();
Long userId = 1L; activeOne.setUserId(1L);
Integer count = 10; activeOne.setStatDate(LocalDate.now());
int oldLikeCount = testUserInfo.getLikeCount(); activeOne.setVideoIncrement(1);
activeOne.setViewIncrement(0L);
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); UserStatDaily activeTwo = new UserStatDaily();
when(userInfoMapper.updateById(any(UserInfo.class))).thenReturn(1); activeTwo.setUserId(2L);
activeTwo.setStatDate(LocalDate.now().minusDays(1));
activeTwo.setLikeIncrement(3);
// Then UserStatDaily inactive = new UserStatDaily();
assertEquals(1000, oldLikeCount); inactive.setUserId(3L);
} inactive.setStatDate(LocalDate.now().minusDays(2));
inactive.setVideoIncrement(0);
inactive.setLikeIncrement(0);
inactive.setFavoriteIncrement(0);
inactive.setCoinIncrement(0);
inactive.setFollowerIncrement(0);
inactive.setFollowingIncrement(0);
inactive.setViewIncrement(0L);
@Test doReturn(10L).when(userInfoService).count();
@DisplayName("增加用户收藏数量 - 成功") doReturn(2L).when(userInfoService).count(any(LambdaQueryWrapper.class));
void testIncreaseFavoriteCountSuccess() { when(userStatDailyMapper.selectList(any(LambdaQueryWrapper.class)))
// Given .thenReturn(List.of(activeOne, activeTwo, inactive));
Long userId = 1L;
Integer count = 5;
int oldFavoriteCount = testUserInfo.getFavoriteCount();
when(userInfoMapper.selectById(userId)).thenReturn(testUserInfo); Result<Object> result = userInfoService.getUserStats();
when(userInfoMapper.updateById(any(UserInfo.class))).thenReturn(1);
// Then assertEquals(200, result.getCode());
assertEquals(30, oldFavoriteCount); assertNotNull(result.getData());
@SuppressWarnings("unchecked")
var stats = (java.util.Map<String, Object>) result.getData();
assertEquals(10L, stats.get("totalUsers"));
assertEquals(2L, stats.get("todayNewUsers"));
assertEquals(2L, stats.get("activeUsers"));
} }
} }