feat(backend): Improve authorization governance and video interactions

This commit is contained in:
2026-04-24 17:34:09 +08:00
parent 6c954542ac
commit 600ff66bd3
42 changed files with 1239 additions and 74 deletions
@@ -0,0 +1,93 @@
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.dto.AdminRoleDTO;
import com.nuaa.video.admin.service.AdminPermissionService;
import com.nuaa.video.admin.service.AdminRoleService;
import com.nuaa.video.admin.vo.AdminPermissionVO;
import com.nuaa.video.admin.vo.AdminRoleVO;
import com.nuaa.video.common.model.vo.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 角色和权限管理控制器
*/
@RestController
@RequestMapping("/admin/role")
@RequiredArgsConstructor
public class AdminRoleController {
private final AdminRoleService adminRoleService;
private final AdminPermissionService adminPermissionService;
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/list")
public Result<Page<AdminRoleVO>> listRoles(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size,
@RequestParam(value = "keyword", required = false) String keyword) {
return adminRoleService.listRoles(new Page<>(page, size), keyword);
}
@PreAuthorize("hasAuthority('role:manage') or hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/all")
public Result<List<AdminRoleVO>> listAllRoles() {
return adminRoleService.listAllRoles();
}
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/{id}")
public Result<AdminRoleVO> getRoleDetail(@PathVariable Long id) {
return adminRoleService.getRoleDetail(id);
}
@OperationLog("新增角色")
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@PostMapping
public Result<Void> addRole(@RequestBody @Valid AdminRoleDTO roleDTO) {
return adminRoleService.addRole(roleDTO, getCurrentUsername());
}
@OperationLog("更新角色")
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@PutMapping
public Result<Void> updateRole(@RequestBody @Valid AdminRoleDTO roleDTO) {
return adminRoleService.updateRole(roleDTO, getCurrentUsername());
}
@OperationLog("角色授权")
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@PutMapping("/{id}/permissions")
public Result<Void> updateRolePermissions(@PathVariable Long id, @RequestBody List<Long> permissionIds) {
return adminRoleService.updateRolePermissions(id, permissionIds, getCurrentUsername());
}
@OperationLog("删除角色")
@PreAuthorize("hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@DeleteMapping("/{id}")
public Result<Void> deleteRole(@PathVariable Long id) {
return adminRoleService.deleteRole(id);
}
@PreAuthorize("hasAuthority('permission:manage') or hasAuthority('role:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/permissions/list")
public Result<List<AdminPermissionVO>> listPermissions() {
return adminPermissionService.listPermissions();
}
private String getCurrentUsername() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return authentication.getName();
}
return "system";
}
}
@@ -29,7 +29,7 @@ public class AdminUserController {
/** /**
* 获取管理员列表 * 获取管理员列表
*/ */
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/list") @GetMapping("/list")
public Result<Page<AdminUserVO>> listUsers( public Result<Page<AdminUserVO>> listUsers(
@RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "page", defaultValue = "1") Integer page,
@@ -42,7 +42,7 @@ public class AdminUserController {
/** /**
* 获取管理员详情 * 获取管理员详情
*/ */
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@GetMapping("/{id}") @GetMapping("/{id}")
public Result<AdminUserVO> getUser(@PathVariable Long id) { public Result<AdminUserVO> getUser(@PathVariable Long id) {
return adminUserService.getUserDetail(id); return adminUserService.getUserDetail(id);
@@ -52,7 +52,7 @@ public class AdminUserController {
* 添加管理员 * 添加管理员
*/ */
@OperationLog("添加管理员") @OperationLog("添加管理员")
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@PostMapping @PostMapping
public Result<Void> addUser(@RequestBody @Valid AdminUserDTO adminUserDTO) { public Result<Void> addUser(@RequestBody @Valid AdminUserDTO adminUserDTO) {
return adminUserService.addUser(adminUserDTO, getCurrentUsername()); return adminUserService.addUser(adminUserDTO, getCurrentUsername());
@@ -62,7 +62,7 @@ public class AdminUserController {
* 更新管理员 * 更新管理员
*/ */
@OperationLog("更新管理员") @OperationLog("更新管理员")
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@PutMapping @PutMapping
public Result<Void> updateUser(@RequestBody @Valid AdminUserDTO adminUserDTO) { public Result<Void> updateUser(@RequestBody @Valid AdminUserDTO adminUserDTO) {
return adminUserService.updateUser(adminUserDTO, getCurrentUsername()); return adminUserService.updateUser(adminUserDTO, getCurrentUsername());
@@ -72,7 +72,7 @@ public class AdminUserController {
* 删除管理员 * 删除管理员
*/ */
@OperationLog("删除管理员") @OperationLog("删除管理员")
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Result<Void> deleteUser(@PathVariable Long id) { public Result<Void> deleteUser(@PathVariable Long id) {
return adminUserService.deleteUser(id); return adminUserService.deleteUser(id);
@@ -82,7 +82,7 @@ public class AdminUserController {
* 启用/禁用管理员 * 启用/禁用管理员
*/ */
@OperationLog("修改管理员状态") @OperationLog("修改管理员状态")
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@PutMapping("/{id}/status/{status}") @PutMapping("/{id}/status/{status}")
public Result<Void> updateStatus(@PathVariable Long id, @PathVariable Integer status) { public Result<Void> updateStatus(@PathVariable Long id, @PathVariable Integer status) {
return adminUserService.updateStatus(id, status, getCurrentUsername()); return adminUserService.updateStatus(id, status, getCurrentUsername());
@@ -92,7 +92,7 @@ public class AdminUserController {
* 重置密码 * 重置密码
*/ */
@OperationLog("重置管理员密码") @OperationLog("重置管理员密码")
@PreAuthorize("hasAuthority('user:manage')") @PreAuthorize("hasAuthority('user:manage') or hasRole('ADMIN_SUPER')")
@PutMapping("/{id}/reset-password") @PutMapping("/{id}/reset-password")
public Result<Void> resetPassword(@PathVariable Long id, @RequestParam String newPassword) { public Result<Void> resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
return adminUserService.resetPassword(id, newPassword, getCurrentUsername()); return adminUserService.resetPassword(id, newPassword, getCurrentUsername());
@@ -34,8 +34,9 @@ public class CommentManageController {
@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 = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword) { @RequestParam(value = "keyword", required = false) String keyword) {
return commentFeignClient.getCommentList(page, size, videoId, userId, keyword, internalServiceToken); return commentFeignClient.getCommentList(page, size, videoId, userId, status, keyword, internalServiceToken);
} }
/** /**
@@ -57,6 +58,16 @@ public class CommentManageController {
return commentFeignClient.deleteComment(commentId, internalServiceToken); return commentFeignClient.deleteComment(commentId, internalServiceToken);
} }
/**
* 恢复评论
*/
@OperationLog("恢复评论")
@PreAuthorize("hasAuthority('comment:manage')")
@PutMapping("/{commentId}/restore")
public Result<Void> restoreComment(@PathVariable Long commentId) {
return commentFeignClient.restoreComment(commentId, internalServiceToken);
}
/** /**
* 获取评论统计信息 * 获取评论统计信息
*/ */
@@ -32,8 +32,9 @@ 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,
return userFeignClient.getUserList(page, size, keyword, internalServiceToken); @RequestParam(value = "status", required = false) Integer status) {
return userFeignClient.getUserList(page, size, keyword, status, internalServiceToken);
} }
/** /**
@@ -1,7 +1,6 @@
package com.nuaa.video.admin.dto; package com.nuaa.video.admin.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data; import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
@@ -29,7 +28,6 @@ public class AdminRoleDTO implements Serializable {
* 角色编码 * 角色编码
*/ */
@NotBlank(message = "角色编码不能为空") @NotBlank(message = "角色编码不能为空")
@Pattern(regexp = "^ADMIN_[A-Z_]+$", message = "角色编码必须以ADMIN_开头")
private String code; private String code;
/** /**
@@ -38,7 +36,7 @@ public class AdminRoleDTO implements Serializable {
private String description; private String description;
/** /**
* 权限ID集合 * 权限ID列表
*/ */
private List<Long> permissionIds; private List<Long> permissionIds;
} }
@@ -20,6 +20,7 @@ public interface CommentFeignClient {
@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 = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
@@ -37,6 +38,13 @@ public interface CommentFeignClient {
Result<Void> deleteComment(@PathVariable("commentId") Long commentId, Result<Void> deleteComment(@PathVariable("commentId") Long commentId,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
/**
* 恢复评论
*/
@PutMapping("/comment/admin/{commentId}/restore")
Result<Void> restoreComment(@PathVariable("commentId") Long commentId,
@RequestHeader("X-Internal-Token") String internalToken);
/** /**
* 获取评论统计信息 * 获取评论统计信息
*/ */
@@ -19,7 +19,7 @@ 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 internalToken) { public Result<Page<Object>> getCommentList(Integer page, Integer size, Long videoId, Long userId, Integer status, String keyword, String internalToken) {
return Result.error("获取评论列表失败:服务不可用"); return Result.error("获取评论列表失败:服务不可用");
} }
@@ -33,6 +33,11 @@ public class CommentFeignClientFallbackFactory implements FallbackFactory<Commen
return Result.error("删除评论失败:服务不可用"); return Result.error("删除评论失败:服务不可用");
} }
@Override
public Result<Void> restoreComment(Long commentId, String internalToken) {
return Result.error("恢复评论失败:服务不可用");
}
@Override @Override
public Result<Object> getCommentStats(String internalToken) { public Result<Object> getCommentStats(String internalToken) {
return Result.error("获取评论统计信息失败:服务不可用"); return Result.error("获取评论统计信息失败:服务不可用");
@@ -19,6 +19,7 @@ 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,
@RequestParam(value = "status", required = false) Integer status,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
/** /**
@@ -18,7 +18,7 @@ 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 internalToken) { public Result<Page<Object>> getUserList(Integer page, Integer size, String keyword, Integer status, String internalToken) {
log.error("获取用户列表失败"); log.error("获取用户列表失败");
return Result.error("获取用户列表失败,用户服务不可用"); return Result.error("获取用户列表失败,用户服务不可用");
} }
@@ -1,5 +1,9 @@
package com.nuaa.video.admin.security; package com.nuaa.video.admin.security;
import com.nuaa.video.admin.entity.AdminPermission;
import com.nuaa.video.admin.entity.AdminRole;
import com.nuaa.video.admin.entity.AdminUser;
import com.nuaa.video.admin.service.AdminUserService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -14,7 +18,10 @@ import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -25,7 +32,21 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final List<String> SUPER_ADMIN_AUTHORITIES = List.of(
"user:manage",
"role:manage",
"permission:manage",
"video:manage",
"comment:manage",
"tag:manage",
"category:manage",
"recommend:manage",
"system:config",
"operation:log"
);
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final AdminUserService adminUserService;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -39,7 +60,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 从令牌中获取用户信息 // 从令牌中获取用户信息
Long adminId = jwtTokenProvider.getAdminIdFromToken(token); Long adminId = jwtTokenProvider.getAdminIdFromToken(token);
String username = jwtTokenProvider.getUsernameFromToken(token); String username = jwtTokenProvider.getUsernameFromToken(token);
List<String> authorities = jwtTokenProvider.getAuthoritiesFromToken(token); List<String> authorities = resolveAuthorities(username, token);
// 转换为Spring Security的授权对象 // 转换为Spring Security的授权对象
List<SimpleGrantedAuthority> simpleGrantedAuthorities = authorities.stream() List<SimpleGrantedAuthority> simpleGrantedAuthorities = authorities.stream()
@@ -72,6 +93,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
private List<String> resolveAuthorities(String username, String token) {
AdminUser adminUser = adminUserService.getByUsername(username);
if (adminUser != null) {
Set<String> authorities = new LinkedHashSet<>();
if (adminUser.getRoles() != null) {
for (AdminRole role : adminUser.getRoles()) {
if (role == null || !StringUtils.hasText(role.getCode())) {
continue;
}
authorities.add("ROLE_" + role.getCode());
if ("ADMIN_SUPER".equals(role.getCode())) {
authorities.addAll(SUPER_ADMIN_AUTHORITIES);
}
if (role.getPermissions() != null) {
for (AdminPermission permission : role.getPermissions()) {
if (permission != null && StringUtils.hasText(permission.getCode())) {
authorities.add(permission.getCode());
}
}
}
}
}
if (!authorities.isEmpty()) {
return new ArrayList<>(authorities);
}
}
List<String> tokenAuthorities = new ArrayList<>(jwtTokenProvider.getAuthoritiesFromToken(token));
if (tokenAuthorities.contains("ROLE_ADMIN_SUPER")) {
SUPER_ADMIN_AUTHORITIES.forEach(authority -> {
if (!tokenAuthorities.contains(authority)) {
tokenAuthorities.add(authority);
}
});
}
return tokenAuthorities;
}
/** /**
* 从请求头中提取JWT令牌 * 从请求头中提取JWT令牌
*/ */
@@ -0,0 +1,14 @@
package com.nuaa.video.admin.service;
import com.nuaa.video.admin.vo.AdminPermissionVO;
import com.nuaa.video.common.model.vo.Result;
import java.util.List;
/**
* 权限服务
*/
public interface AdminPermissionService {
Result<List<AdminPermissionVO>> listPermissions();
}
@@ -0,0 +1,28 @@
package com.nuaa.video.admin.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.admin.dto.AdminRoleDTO;
import com.nuaa.video.admin.vo.AdminRoleVO;
import com.nuaa.video.common.model.vo.Result;
import java.util.List;
/**
* 角色服务
*/
public interface AdminRoleService {
Result<Page<AdminRoleVO>> listRoles(Page<?> page, String keyword);
Result<List<AdminRoleVO>> listAllRoles();
Result<AdminRoleVO> getRoleDetail(Long id);
Result<Void> addRole(AdminRoleDTO roleDTO, String operator);
Result<Void> updateRole(AdminRoleDTO roleDTO, String operator);
Result<Void> updateRolePermissions(Long roleId, List<Long> permissionIds, String operator);
Result<Void> deleteRole(Long id);
}
@@ -0,0 +1,37 @@
package com.nuaa.video.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nuaa.video.admin.entity.AdminPermission;
import com.nuaa.video.admin.mapper.AdminPermissionMapper;
import com.nuaa.video.admin.service.AdminPermissionService;
import com.nuaa.video.admin.vo.AdminPermissionVO;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 权限服务实现
*/
@Service
@RequiredArgsConstructor
public class AdminPermissionServiceImpl implements AdminPermissionService {
private final AdminPermissionMapper adminPermissionMapper;
@Override
public Result<List<AdminPermissionVO>> listPermissions() {
List<AdminPermission> permissions = adminPermissionMapper.selectList(new LambdaQueryWrapper<AdminPermission>()
.orderByAsc(AdminPermission::getId));
return Result.success(permissions.stream().map(this::toPermissionVO).collect(Collectors.toList()));
}
private AdminPermissionVO toPermissionVO(AdminPermission permission) {
AdminPermissionVO permissionVO = new AdminPermissionVO();
BeanUtils.copyProperties(permission, permissionVO);
return permissionVO;
}
}
@@ -0,0 +1,194 @@
package com.nuaa.video.admin.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.admin.dto.AdminRoleDTO;
import com.nuaa.video.admin.entity.AdminPermission;
import com.nuaa.video.admin.entity.AdminRole;
import com.nuaa.video.admin.entity.AdminRolePermission;
import com.nuaa.video.admin.entity.AdminUserRole;
import com.nuaa.video.admin.mapper.AdminRoleMapper;
import com.nuaa.video.admin.mapper.AdminRolePermissionMapper;
import com.nuaa.video.admin.mapper.AdminUserRoleMapper;
import com.nuaa.video.admin.service.AdminRoleService;
import com.nuaa.video.admin.vo.AdminPermissionVO;
import com.nuaa.video.admin.vo.AdminRoleVO;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 角色服务实现
*/
@Service
@RequiredArgsConstructor
public class AdminRoleServiceImpl implements AdminRoleService {
private final AdminRoleMapper adminRoleMapper;
private final AdminRolePermissionMapper adminRolePermissionMapper;
private final AdminUserRoleMapper adminUserRoleMapper;
@Override
public Result<Page<AdminRoleVO>> listRoles(Page<?> page, String keyword) {
Page<AdminRole> rolePage = adminRoleMapper.selectRoleList(new Page<>(page.getCurrent(), page.getSize()), keyword);
Page<AdminRoleVO> result = new Page<>();
BeanUtils.copyProperties(rolePage, result, "records");
result.setRecords(rolePage.getRecords().stream().map(this::toRoleVO).collect(Collectors.toList()));
return Result.success(result);
}
@Override
public Result<List<AdminRoleVO>> listAllRoles() {
List<AdminRole> roles = adminRoleMapper.selectRoleList(new Page<>(1, 200), null).getRecords();
return Result.success(roles.stream().map(this::toRoleVO).collect(Collectors.toList()));
}
@Override
public Result<AdminRoleVO> getRoleDetail(Long id) {
AdminRole role = adminRoleMapper.selectRoleDetail(id);
if (role == null) {
return Result.error("角色不存在");
}
return Result.success(toRoleVO(role));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> addRole(AdminRoleDTO roleDTO, String operator) {
if (existsRoleName(roleDTO.getName(), null)) {
return Result.error("角色名称已存在");
}
if (existsRoleCode(roleDTO.getCode(), null)) {
return Result.error("角色编码已存在");
}
AdminRole role = new AdminRole();
role.setName(roleDTO.getName());
role.setCode(roleDTO.getCode());
role.setDescription(roleDTO.getDescription());
role.setCreatedBy(operator);
role.setUpdatedBy(operator);
role.setCreateTime(LocalDateTime.now());
role.setUpdateTime(LocalDateTime.now());
adminRoleMapper.insert(role);
saveRolePermissions(role.getId(), roleDTO.getPermissionIds(), operator);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> updateRole(AdminRoleDTO roleDTO, String operator) {
AdminRole existing = adminRoleMapper.selectById(roleDTO.getId());
if (existing == null) {
return Result.error("角色不存在");
}
if (existsRoleName(roleDTO.getName(), roleDTO.getId())) {
return Result.error("角色名称已存在");
}
if (existsRoleCode(roleDTO.getCode(), roleDTO.getId())) {
return Result.error("角色编码已存在");
}
AdminRole role = new AdminRole();
role.setId(roleDTO.getId());
role.setName(roleDTO.getName());
role.setCode(roleDTO.getCode());
role.setDescription(roleDTO.getDescription());
role.setUpdatedBy(operator);
role.setUpdateTime(LocalDateTime.now());
adminRoleMapper.updateById(role);
if (roleDTO.getPermissionIds() != null) {
replaceRolePermissions(roleDTO.getId(), roleDTO.getPermissionIds(), operator);
}
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> updateRolePermissions(Long roleId, List<Long> permissionIds, String operator) {
AdminRole existing = adminRoleMapper.selectById(roleId);
if (existing == null) {
return Result.error("角色不存在");
}
replaceRolePermissions(roleId, permissionIds, operator);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> deleteRole(Long id) {
AdminRole existing = adminRoleMapper.selectById(id);
if (existing == null) {
return Result.error("角色不存在");
}
long bindCount = adminUserRoleMapper.selectCount(new LambdaQueryWrapper<AdminUserRole>()
.eq(AdminUserRole::getRoleId, id));
if (bindCount > 0) {
return Result.error("该角色已分配给管理员,无法删除");
}
adminRolePermissionMapper.delete(new LambdaQueryWrapper<AdminRolePermission>()
.eq(AdminRolePermission::getRoleId, id));
adminRoleMapper.deleteById(id);
return Result.success();
}
private boolean existsRoleName(String name, Long excludeId) {
return adminRoleMapper.selectCount(new LambdaQueryWrapper<AdminRole>()
.eq(AdminRole::getName, name)
.ne(excludeId != null, AdminRole::getId, excludeId)) > 0;
}
private boolean existsRoleCode(String code, Long excludeId) {
return adminRoleMapper.selectCount(new LambdaQueryWrapper<AdminRole>()
.eq(AdminRole::getCode, code)
.ne(excludeId != null, AdminRole::getId, excludeId)) > 0;
}
private void replaceRolePermissions(Long roleId, List<Long> permissionIds, String operator) {
adminRolePermissionMapper.delete(new LambdaQueryWrapper<AdminRolePermission>()
.eq(AdminRolePermission::getRoleId, roleId));
saveRolePermissions(roleId, permissionIds, operator);
}
private void saveRolePermissions(Long roleId, List<Long> permissionIds, String operator) {
if (permissionIds == null || permissionIds.isEmpty()) {
return;
}
List<Long> validPermissionIds = permissionIds.stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!validPermissionIds.isEmpty()) {
adminRolePermissionMapper.batchInsert(roleId, validPermissionIds, operator);
}
}
private AdminRoleVO toRoleVO(AdminRole role) {
AdminRoleVO roleVO = new AdminRoleVO();
BeanUtils.copyProperties(role, roleVO);
List<AdminPermission> permissions = role.getPermissions() == null ? Collections.emptyList() :
role.getPermissions().stream().filter(item -> item.getId() != null).collect(Collectors.toList());
roleVO.setPermissions(permissions.stream().map(this::toPermissionVO).collect(Collectors.toList()));
roleVO.setPermissionIds(permissions.stream().map(AdminPermission::getId).collect(Collectors.toList()));
return roleVO;
}
private AdminPermissionVO toPermissionVO(AdminPermission permission) {
AdminPermissionVO permissionVO = new AdminPermissionVO();
BeanUtils.copyProperties(permission, permissionVO);
return permissionVO;
}
}
@@ -53,14 +53,31 @@ public class AdminUserServiceImpl extends ServiceImpl<AdminUserMapper, AdminUser
@Override @Override
public Result<Page<AdminUserVO>> listUsers(Page<AdminUser> page, String keyword) { public Result<Page<AdminUserVO>> listUsers(Page<AdminUser> page, String keyword) {
Page<AdminUser> userPage = adminUserMapper.selectUserList(page, keyword); LambdaQueryWrapper<AdminUser> queryWrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(keyword)) {
queryWrapper.and(wrapper -> wrapper
.like(AdminUser::getUsername, keyword)
.or()
.like(AdminUser::getRealName, keyword)
.or()
.like(AdminUser::getEmail, keyword)
.or()
.like(AdminUser::getPhone, keyword));
}
queryWrapper.orderByAsc(AdminUser::getId);
Page<AdminUser> userPage = page(page, queryWrapper);
// 转换为VO // 转换为VO
Page<AdminUserVO> voPage = new Page<>(); Page<AdminUserVO> voPage = new Page<>();
BeanUtils.copyProperties(userPage, voPage, "records"); BeanUtils.copyProperties(userPage, voPage, "records");
List<AdminUserVO> userVOList = new ArrayList<>(); List<AdminUserVO> userVOList = new ArrayList<>();
for (AdminUser user : userPage.getRecords()) { for (AdminUser baseUser : userPage.getRecords()) {
AdminUser user = adminUserMapper.selectUserDetail(baseUser.getId());
if (user == null) {
user = baseUser;
}
AdminUserVO vo = new AdminUserVO(); AdminUserVO vo = new AdminUserVO();
BeanUtils.copyProperties(user, vo); BeanUtils.copyProperties(user, vo);
@@ -7,6 +7,7 @@ import com.nuaa.video.admin.dto.LoginDTO;
import com.nuaa.video.admin.entity.AdminPermission; import com.nuaa.video.admin.entity.AdminPermission;
import com.nuaa.video.admin.entity.AdminRole; import com.nuaa.video.admin.entity.AdminRole;
import com.nuaa.video.admin.entity.AdminUser; import com.nuaa.video.admin.entity.AdminUser;
import com.nuaa.video.admin.mapper.AdminUserMapper;
import com.nuaa.video.admin.security.AdminUserDetails; import com.nuaa.video.admin.security.AdminUserDetails;
import com.nuaa.video.admin.security.JwtTokenProvider; import com.nuaa.video.admin.security.JwtTokenProvider;
import com.nuaa.video.admin.service.AdminUserService; import com.nuaa.video.admin.service.AdminUserService;
@@ -44,6 +45,7 @@ public class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager; private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final AdminUserService adminUserService; private final AdminUserService adminUserService;
private final AdminUserMapper adminUserMapper;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
@Value("${login.retry-count:5}") @Value("${login.retry-count:5}")
@@ -169,7 +171,7 @@ public class AuthServiceImpl implements AuthService {
@Override @Override
public Result<LoginVO> getCurrentUser(AdminUserDetails adminUserDetails) { public Result<LoginVO> getCurrentUser(AdminUserDetails adminUserDetails) {
// 获取用户信息 // 获取用户信息
AdminUser adminUser = adminUserService.getById(adminUserDetails.getId()); AdminUser adminUser = adminUserMapper.selectUserDetail(adminUserDetails.getId());
if (adminUser == null) { if (adminUser == null) {
return Result.error("用户不存在"); return Result.error("用户不存在");
} }
@@ -1,5 +1,6 @@
package com.nuaa.video.auth.controller; package com.nuaa.video.auth.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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;
@@ -10,6 +11,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; 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.stream.Collectors;
/** /**
* 认证管理控制器 - 供管理后台调用 * 认证管理控制器 - 供管理后台调用
*/ */
@@ -63,6 +67,26 @@ public class AdminAuthController {
return authUserService.lockUser(userId, locked); return authUserService.lockUser(userId, locked);
} }
/**
* 按账号状态查询用户ID - 管理接口
*/
@GetMapping("/user-ids/status/{status}")
public Result<List<Long>> getUserIdsByStatus(
@PathVariable Integer status,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
List<Long> userIds = authUserService.list(new LambdaQueryWrapper<AuthUser>()
.select(AuthUser::getId)
.eq(AuthUser::getStatus, status))
.stream()
.map(AuthUser::getId)
.collect(Collectors.toList());
return Result.success(userIds);
}
private boolean isInternalRequest(HttpServletRequest request) { private boolean isInternalRequest(HttpServletRequest request) {
return internalServiceToken.equals(request.getHeader("X-Internal-Token")); return internalServiceToken.equals(request.getHeader("X-Internal-Token"));
} }
@@ -3,6 +3,7 @@ package com.nuaa.video.comment.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.comment.entity.Comment; import com.nuaa.video.comment.entity.Comment;
import com.nuaa.video.comment.service.CommentService; import com.nuaa.video.comment.service.CommentService;
import com.nuaa.video.comment.vo.AdminCommentVO;
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;
@@ -30,18 +31,19 @@ public class AdminCommentController {
* 获取评论列表 - 管理接口 * 获取评论列表 - 管理接口
*/ */
@GetMapping("/list") @GetMapping("/list")
public Result<Page<CommentVO>> getCommentList( public Result<Page<AdminCommentVO>> getCommentList(
@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 = "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 = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "keyword", required = false) String keyword,
HttpServletRequest request) { HttpServletRequest request) {
if (!isInternalRequest(request)) { if (!isInternalRequest(request)) {
return Result.error("非法的内部调用"); return Result.error("非法的内部调用");
} }
return commentService.getAdminCommentList(page, size, videoId, userId, keyword); return commentService.getAdminCommentList(page, size, videoId, userId, status, keyword);
} }
/** /**
@@ -66,6 +68,17 @@ public class AdminCommentController {
return commentService.adminDeleteComment(commentId); return commentService.adminDeleteComment(commentId);
} }
/**
* 恢复评论 - 管理接口
*/
@PutMapping("/{commentId}/restore")
public Result<Void> restoreComment(@PathVariable Long commentId, HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return commentService.adminRestoreComment(commentId);
}
/** /**
* 获取评论统计信息 - 管理接口 * 获取评论统计信息 - 管理接口
*/ */
@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** /**
* 用户服务Feign客户端 * 用户服务Feign客户端
@@ -35,5 +36,7 @@ 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,
@RequestParam(value = "content", required = false) String content,
@RequestParam(value = "relatedId", required = false) Long relatedId,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -23,7 +23,7 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
} }
@Override @Override
public Result<Void> sendNotification(Long userId, Integer type, String internalToken) { public Result<Void> sendNotification(Long userId, Integer type, String content, Long relatedId, String internalToken) {
log.error("发送用户通知失败,用户ID:{}, 类型:{}", userId, type); log.error("发送用户通知失败,用户ID:{}, 类型:{}", userId, type);
return Result.error("发送用户通知失败"); return Result.error("发送用户通知失败");
} }
@@ -7,6 +7,7 @@ import com.nuaa.video.comment.dto.CommentQueryDTO;
import com.nuaa.video.comment.entity.Comment; import com.nuaa.video.comment.entity.Comment;
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.comment.vo.AdminCommentVO;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
/** /**
@@ -99,7 +100,7 @@ public interface CommentService extends IService<Comment> {
/** /**
* 获取评论列表 - 管理接口 * 获取评论列表 - 管理接口
*/ */
Result<Page<CommentVO>> getAdminCommentList(Integer page, Integer size, Long videoId, Long userId, String keyword); Result<Page<AdminCommentVO>> getAdminCommentList(Integer page, Integer size, Long videoId, Long userId, Integer status, String keyword);
/** /**
* 获取评论详情 - 管理接口 * 获取评论详情 - 管理接口
@@ -111,6 +112,11 @@ public interface CommentService extends IService<Comment> {
*/ */
Result<Void> adminDeleteComment(Long commentId); Result<Void> adminDeleteComment(Long commentId);
/**
* 管理员恢复评论 - 管理接口
*/
Result<Void> adminRestoreComment(Long commentId);
/** /**
* 获取评论统计信息 - 管理接口 * 获取评论统计信息 - 管理接口
*/ */
@@ -277,10 +277,10 @@ public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, Com
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
try { try {
if (!userId.equals(comment.getUserId())) { if (!userId.equals(comment.getUserId())) {
userFeignClient.sendNotification(comment.getUserId(), 2, internalServiceToken); userFeignClient.sendNotification(comment.getUserId(), 2, null, replyDTO.getVideoId(), internalServiceToken);
} }
if (replyDTO.getReplyToId() != null && !userId.equals(replyDTO.getReplyToId())) { if (replyDTO.getReplyToId() != null && !userId.equals(replyDTO.getReplyToId())) {
userFeignClient.sendNotification(replyDTO.getReplyToId(), 2, internalServiceToken); userFeignClient.sendNotification(replyDTO.getReplyToId(), 2, null, replyDTO.getVideoId(), internalServiceToken);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("发送回复通知失败", e); log.warn("发送回复通知失败", e);
@@ -17,6 +17,7 @@ import com.nuaa.video.comment.service.CommentService;
import com.nuaa.video.comment.utils.SensitiveWordFilter; import com.nuaa.video.comment.utils.SensitiveWordFilter;
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.comment.vo.AdminCommentVO;
import com.nuaa.video.comment.vo.ReplyVO; import com.nuaa.video.comment.vo.ReplyVO;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -94,7 +95,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
updateVideoCommentCountAsync(commentDTO.getVideoId(), 1); updateVideoCommentCountAsync(commentDTO.getVideoId(), 1);
sendCommentNotificationAsync(videoResult.getData(), userId); sendCommentNotificationAsync(videoResult.getData(), userId, commentDTO.getVideoId());
return Result.success(comment.getId()); return Result.success(comment.getId());
} }
@@ -361,7 +362,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
} }
@Override @Override
public Result<Page<CommentVO>> getAdminCommentList(Integer page, Integer size, Long videoId, Long userId, String keyword) { public Result<Page<AdminCommentVO>> getAdminCommentList(Integer page, Integer size, Long videoId, Long userId, Integer status, String keyword) {
if (page == null || page < 1) { if (page == null || page < 1) {
page = 1; page = 1;
} }
@@ -372,7 +373,9 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
// 构建查询条件 // 构建查询条件
LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();
// 管理员可以查看所有状态的评论,包括已删除的 if (status != null) {
queryWrapper.eq(Comment::getStatus, status);
}
// 按视频ID筛选 // 按视频ID筛选
if (videoId != null) { if (videoId != null) {
@@ -397,12 +400,12 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
commentPage = page(commentPage, queryWrapper); commentPage = page(commentPage, queryWrapper);
// 转换为VO // 转换为VO
Page<CommentVO> voPage = new Page<>(); Page<AdminCommentVO> voPage = new Page<>();
BeanUtils.copyProperties(commentPage, voPage, "records"); BeanUtils.copyProperties(commentPage, voPage, "records");
List<CommentVO> commentVOList = new ArrayList<>(); List<AdminCommentVO> commentVOList = new ArrayList<>();
for (Comment comment : commentPage.getRecords()) { for (Comment comment : commentPage.getRecords()) {
CommentVO vo = convertToCommentVO(comment, null); AdminCommentVO vo = convertToAdminCommentVO(comment);
commentVOList.add(vo); commentVOList.add(vo);
} }
@@ -453,6 +456,23 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
return Result.success(); return Result.success();
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> adminRestoreComment(Long commentId) {
Comment comment = getById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
if (comment.getStatus() == 1) {
return Result.success();
}
commentMapper.updateCommentStatus(commentId, 1, "admin");
updateVideoCommentCountAsync(comment.getVideoId(), 1);
return Result.success();
}
@Override @Override
public Result<CommentStatVO> getCommentStats() { public Result<CommentStatVO> getCommentStats() {
CommentStatVO statVO = new CommentStatVO(); CommentStatVO statVO = new CommentStatVO();
@@ -514,14 +534,44 @@ public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> impl
return vo; return vo;
} }
private void sendCommentNotificationAsync(Object videoPayload, Long userId) { private AdminCommentVO convertToAdminCommentVO(Comment comment) {
AdminCommentVO vo = new AdminCommentVO();
BeanUtils.copyProperties(comment, vo);
try {
Result<Object> userResult = userFeignClient.getUserProfile(comment.getUserId());
if (userResult.getCode() == 200 && userResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) userResult.getData();
vo.setUserNickname((String) userData.get("nickname"));
vo.setUserAvatar((String) userData.get("avatar"));
}
} catch (Exception e) {
log.error("管理端获取评论用户信息失败, userId={}", comment.getUserId(), e);
}
try {
Result<Object> videoResult = contentFeignClient.getVideoInfo(comment.getVideoId());
if (videoResult.getCode() == 200 && videoResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
vo.setVideoTitle((String) videoData.get("title"));
}
} catch (Exception e) {
log.error("管理端获取视频信息失败, videoId={}", comment.getVideoId(), e);
}
return vo;
}
private void sendCommentNotificationAsync(Object videoPayload, Long userId, Long videoId) {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
try { try {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoPayload; Map<String, Object> videoData = (Map<String, Object>) videoPayload;
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString()); Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
if (!userId.equals(videoAuthorId)) { if (!userId.equals(videoAuthorId)) {
userFeignClient.sendNotification(videoAuthorId, 2, internalServiceToken); userFeignClient.sendNotification(videoAuthorId, 2, null, videoId, internalServiceToken);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("发送评论通知失败", e); log.warn("发送评论通知失败", e);
@@ -0,0 +1,41 @@
package com.nuaa.video.comment.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理端评论VO
*/
@Data
public class AdminCommentVO implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long videoId;
private String videoTitle;
private Long userId;
private String userNickname;
private String userAvatar;
private String content;
private Integer likeCount;
private Integer replyCount;
private Integer topStatus;
/**
* 状态(0:已删除,1:正常)
*/
private Integer status;
private LocalDateTime createTime;
}
@@ -0,0 +1,75 @@
package com.nuaa.video.comment.service;
import com.nuaa.video.comment.dto.ReplyAddDTO;
import com.nuaa.video.comment.entity.Comment;
import com.nuaa.video.comment.feign.UserFeignClient;
import com.nuaa.video.comment.service.impl.CommentReplyServiceImpl;
import com.nuaa.video.comment.service.impl.CommentServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@DisplayName("通知发送测试")
class NotificationDispatchTest {
@Mock
private UserFeignClient userFeignClient;
@InjectMocks
private CommentServiceImpl commentService;
@InjectMocks
private CommentReplyServiceImpl commentReplyService;
@Test
@DisplayName("评论通知应携带视频ID作为 relatedId")
void shouldSendCommentNotificationWithVideoId() {
ReflectionTestUtils.setField(commentService, "internalServiceToken", "internal-token");
ReflectionTestUtils.invokeMethod(
commentService,
"sendCommentNotificationAsync",
Map.of("userId", 200L),
100L,
300L
);
verify(userFeignClient, timeout(1000)).sendNotification(200L, 2, null, 300L, "internal-token");
}
@Test
@DisplayName("回复通知应携带视频ID作为 relatedId")
void shouldSendReplyNotificationWithVideoId() {
ReflectionTestUtils.setField(commentReplyService, "internalServiceToken", "internal-token");
Comment comment = new Comment();
comment.setUserId(200L);
ReplyAddDTO replyDTO = new ReplyAddDTO();
replyDTO.setReplyToId(300L);
replyDTO.setVideoId(400L);
ReflectionTestUtils.invokeMethod(
commentReplyService,
"sendReplyNotificationAsync",
comment,
replyDTO,
100L
);
verify(userFeignClient, timeout(1000)).sendNotification(eq(200L), eq(2), isNull(), eq(400L), eq("internal-token"));
verify(userFeignClient, timeout(1000)).sendNotification(eq(300L), eq(2), isNull(), eq(400L), eq("internal-token"));
}
}
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.content.dto.*; import com.nuaa.video.content.dto.*;
import com.nuaa.video.content.entity.ContentVideo; import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.entity.ContentVideoPlayRecord;
import com.nuaa.video.content.mapper.ContentVideoMapper; import com.nuaa.video.content.mapper.ContentVideoMapper;
import com.nuaa.video.content.service.VideoService; import com.nuaa.video.content.service.VideoService;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO; import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO; import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO; import com.nuaa.video.content.vo.VideoListVO;
@@ -62,14 +64,7 @@ public class VideoController {
@GetMapping("/public/{id}") @GetMapping("/public/{id}")
public Result<VideoInfoVO> getVideoInfoPublic(@PathVariable Long id, HttpServletRequest request) { public Result<VideoInfoVO> getVideoInfoPublic(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request); Long userId = getUserIdFromRequest(request);
Result<VideoInfoVO> result = videoService.getVideoInfo(id, userId); return videoService.getVideoInfo(id, userId);
if (result.getData() == null) {
return result;
}
if (result.getData().getPublishTime() == null) {
return Result.error("视频暂未公开");
}
return result;
} }
/** /**
@@ -223,6 +218,34 @@ public class VideoController {
return videoService.playVideo(recordDTO, userId); return videoService.playVideo(recordDTO, userId);
} }
/**
* 播放历史
*/
@GetMapping("/history")
public Result<Page<PlayHistoryVO>> getPlayHistory(
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未获取到用户ID");
}
Page<ContentVideoPlayRecord> page = new Page<>(pageNum, pageSize);
return videoService.getPlayHistory(page, userId);
}
/**
* 清空播放历史
*/
@DeleteMapping("/history")
public Result<Void> clearPlayHistory(HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未获取到用户ID");
}
return videoService.clearPlayHistory(userId);
}
/** /**
* 获取用户视频列表 * 获取用户视频列表
*/ */
@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** /**
* 用户服务Feign客户端 * 用户服务Feign客户端
@@ -57,4 +58,30 @@ public interface UserFeignClient {
Result<Void> increaseFavoriteCount(@PathVariable("userId") Long userId, Result<Void> increaseFavoriteCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count, @PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
/**
* 增加用户硬币余额
*/
@PostMapping("/user/profile/count/coin/increase/{userId}/{count}")
Result<Void> increaseCoinCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken);
/**
* 扣减用户硬币余额
*/
@PostMapping("/user/profile/count/coin/decrease/{userId}/{count}")
Result<Void> decreaseCoinCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken);
/**
* 发送用户通知
*/
@PostMapping("/user/notification/send/{userId}/{type}")
Result<Void> sendNotification(@PathVariable("userId") Long userId,
@PathVariable("type") Integer type,
@RequestParam(value = "content", required = false) String content,
@RequestParam(value = "relatedId", required = false) Long relatedId,
@RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -39,6 +39,24 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
log.error("增加用户收藏数量失败,用户ID:{}, 数量:{}", userId, count); log.error("增加用户收藏数量失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户收藏数量失败"); return Result.error("更新用户收藏数量失败");
} }
@Override
public Result<Void> increaseCoinCount(Long userId, Integer count, String internalToken) {
log.error("增加用户硬币余额失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户硬币余额失败");
}
@Override
public Result<Void> decreaseCoinCount(Long userId, Integer count, String internalToken) {
log.error("扣减用户硬币余额失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("扣减用户硬币余额失败");
}
@Override
public Result<Void> sendNotification(Long userId, Integer type, String content, Long relatedId, String internalToken) {
log.error("发送用户通知失败,用户ID:{}, 类型:{}, relatedId:{}", userId, type, relatedId);
return Result.error("发送用户通知失败");
}
}; };
} }
} }
@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.nuaa.video.common.model.vo.Result; import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.content.dto.*; import com.nuaa.video.content.dto.*;
import com.nuaa.video.content.entity.ContentVideo; import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.entity.ContentVideoPlayRecord;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO; import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO; import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO; import com.nuaa.video.content.vo.VideoListVO;
@@ -135,6 +137,23 @@ public interface VideoService extends IService<ContentVideo> {
*/ */
Result<Void> playVideo(PlayRecordDTO recordDTO, Long userId); Result<Void> playVideo(PlayRecordDTO recordDTO, Long userId);
/**
* 获取播放历史
*
* @param page 分页参数
* @param userId 用户ID
* @return 播放历史
*/
Result<Page<PlayHistoryVO>> getPlayHistory(Page<ContentVideoPlayRecord> page, Long userId);
/**
* 清空播放历史
*
* @param userId 用户ID
* @return 结果
*/
Result<Void> clearPlayHistory(Long userId);
/** /**
* 获取用户视频列表 * 获取用户视频列表
* *
@@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -52,6 +53,8 @@ public class FavoriteServiceImpl extends ServiceImpl<ContentUserFavoriteMapper,
.map(favorite -> { .map(favorite -> {
FavoriteVO vo = new FavoriteVO(); FavoriteVO vo = new FavoriteVO();
BeanUtils.copyProperties(favorite, vo); BeanUtils.copyProperties(favorite, vo);
int visibleVideoCount = countVisibleFavoriteVideos(favorite.getId());
vo.setVideoCount(visibleVideoCount);
return vo; return vo;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -192,4 +195,23 @@ public class FavoriteServiceImpl extends ServiceImpl<ContentUserFavoriteMapper,
return Result.success(resultPage); return Result.success(resultPage);
} }
private int countVisibleFavoriteVideos(Long favoriteId) {
List<Long> videoIds = videoFavoriteMapper.selectList(
new LambdaQueryWrapper<ContentVideoFavorite>()
.eq(ContentVideoFavorite::getFavoriteId, favoriteId)
).stream().map(ContentVideoFavorite::getVideoId).collect(Collectors.toList());
if (videoIds.isEmpty()) {
return 0;
}
Long count = videoMapper.selectCount(
new LambdaQueryWrapper<ContentVideo>()
.in(ContentVideo::getId, videoIds)
.eq(ContentVideo::getStatus, 1)
);
return Math.toIntExact(Objects.requireNonNullElse(count, 0L));
}
} }
@@ -17,6 +17,7 @@ import com.nuaa.video.content.service.TagService;
import com.nuaa.video.content.service.VideoService; import com.nuaa.video.content.service.VideoService;
import com.nuaa.video.content.utils.VideoProcessor; import com.nuaa.video.content.utils.VideoProcessor;
import com.nuaa.video.content.vo.TagVO; import com.nuaa.video.content.vo.TagVO;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO; import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO; import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO; import com.nuaa.video.content.vo.VideoListVO;
@@ -32,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -48,6 +50,8 @@ 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 {
private static final ZoneId APP_ZONE_ID = ZoneId.of("Asia/Shanghai");
@Value("${service.auth.token}") @Value("${service.auth.token}")
private String internalServiceToken; private String internalServiceToken;
@@ -164,6 +168,12 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.error(ResultCode.NOT_FOUND.getCode(), "视频不存在"); return Result.error(ResultCode.NOT_FOUND.getCode(), "视频不存在");
} }
boolean isOwner = userId != null && userId.equals(video.getUserId());
boolean isPublished = Objects.equals(video.getStatus(), VideoStatusEnum.PUBLISHED.getCode());
if (!isPublished && !isOwner) {
return Result.error(ResultCode.FORBIDDEN.getCode(), "视频暂未公开");
}
// 转换为VO // 转换为VO
VideoInfoVO videoInfoVO = new VideoInfoVO(); VideoInfoVO videoInfoVO = new VideoInfoVO();
BeanUtils.copyProperties(video, videoInfoVO); BeanUtils.copyProperties(video, videoInfoVO);
@@ -205,6 +215,15 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
int coinCount = videoCoinMapper.getUserCoinCount(userId, id); int coinCount = videoCoinMapper.getUserCoinCount(userId, id);
videoInfoVO.setIsCoined(coinCount > 0); videoInfoVO.setIsCoined(coinCount > 0);
ContentVideoPlayRecord playRecord = getUserPlayRecord(userId, id);
if (playRecord != null) {
videoInfoVO.setPlayProgress(playRecord.getPlayTime());
videoInfoVO.setIsPlayFinished(Objects.equals(playRecord.getIsFinished(), 1));
} else {
videoInfoVO.setPlayProgress(0);
videoInfoVO.setIsPlayFinished(false);
}
} }
return Result.success(videoInfoVO); return Result.success(videoInfoVO);
@@ -409,6 +428,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}} }}
); );
sendVideoLikeNotificationAsync(video, userId);
updateUserLikeCountAsync(video.getUserId(), 1); updateUserLikeCountAsync(video.getUserId(), 1);
return Result.success(); return Result.success();
@@ -473,25 +493,40 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.error("投币总数不能超过2个"); return Result.error("投币总数不能超过2个");
} }
// 添加投币记录 Result<Void> coinBalanceResult = userFeignClient.decreaseCoinCount(userId, amount, internalServiceToken);
ContentVideoCoin coin = new ContentVideoCoin(); if (coinBalanceResult == null || coinBalanceResult.getCode() != 200) {
coin.setVideoId(videoId); String message = coinBalanceResult != null ? coinBalanceResult.getMessage() : "扣减硬币失败";
coin.setUserId(userId); return Result.error(message);
coin.setAmount(amount); }
coin.setCreateTime(LocalDateTime.now());
videoCoinMapper.insert(coin);
// 发送统计消息,增加投币数 try {
videoMessageProducer.sendVideoStatsMessage( // 添加投币记录
new VideoMessageProducer.VideoStatsMessage() {{ ContentVideoCoin coin = new ContentVideoCoin();
setVideoId(videoId); coin.setVideoId(videoId);
setUserId(userId); coin.setUserId(userId);
setAction("coin"); coin.setAmount(amount);
setCount(amount); coin.setCreateTime(LocalDateTime.now());
}} videoCoinMapper.insert(coin);
);
return Result.success(); // 发送统计消息,增加投币数
videoMessageProducer.sendVideoStatsMessage(
new VideoMessageProducer.VideoStatsMessage() {{
setVideoId(videoId);
setUserId(userId);
setAction("coin");
setCount(amount);
}}
);
return Result.success();
} catch (Exception e) {
Result<Void> rollbackResult = userFeignClient.increaseCoinCount(userId, amount, internalServiceToken);
if (rollbackResult == null || rollbackResult.getCode() != 200) {
log.error("投币失败且硬币余额补偿失败,videoId={}, userId={}, amount={}, rollbackResult={}",
videoId, userId, amount, rollbackResult);
}
throw e;
}
} }
@Override @Override
@@ -673,6 +708,54 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.success(); return Result.success();
} }
@Override
public Result<Page<PlayHistoryVO>> getPlayHistory(Page<ContentVideoPlayRecord> page, Long userId) {
Page<ContentVideoPlayRecord> recordPage = playRecordMapper.selectPage(
page,
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
.orderByDesc(ContentVideoPlayRecord::getUpdateTime)
);
List<PlayHistoryVO> historyRecords = recordPage.getRecords().stream()
.map(record -> {
ContentVideo video = getById(record.getVideoId());
if (video == null) {
return null;
}
PlayHistoryVO vo = new PlayHistoryVO();
vo.setVideoId(record.getVideoId());
vo.setTitle(video.getTitle());
vo.setCoverUrl(video.getCoverUrl());
vo.setDuration(video.getDuration());
vo.setPlayTime(record.getPlayTime());
vo.setIsFinished(record.getIsFinished());
vo.setUpdateTime(record.getUpdateTime());
return vo;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
Page<PlayHistoryVO> resultPage = new Page<>();
resultPage.setRecords(historyRecords);
resultPage.setTotal(recordPage.getTotal());
resultPage.setSize(recordPage.getSize());
resultPage.setCurrent(recordPage.getCurrent());
resultPage.setPages(recordPage.getPages());
return Result.success(resultPage);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> clearPlayHistory(Long userId) {
playRecordMapper.delete(
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
);
return Result.success();
}
@Override @Override
public Result<Page<VideoListVO>> getUserVideos(Long userId, Page<ContentVideo> page, Long currentUserId) { public Result<Page<VideoListVO>> getUserVideos(Long userId, Page<ContentVideo> page, Long currentUserId) {
VideoQueryDTO queryDTO = new VideoQueryDTO(); VideoQueryDTO queryDTO = new VideoQueryDTO();
@@ -885,7 +968,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
stats.put("totalVideos", totalVideos); stats.put("totalVideos", totalVideos);
// 今日新增视频 // 今日新增视频
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now(APP_ZONE_ID);
long todayNewVideos = count(new LambdaQueryWrapper<ContentVideo>() long todayNewVideos = count(new LambdaQueryWrapper<ContentVideo>()
.ge(ContentVideo::getCreateTime, today.atStartOfDay())); .ge(ContentVideo::getCreateTime, today.atStartOfDay()));
stats.put("todayNewVideos", todayNewVideos); stats.put("todayNewVideos", todayNewVideos);
@@ -950,6 +1033,26 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}); });
} }
private ContentVideoPlayRecord getUserPlayRecord(Long userId, Long videoId) {
return playRecordMapper.selectOne(
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
.eq(ContentVideoPlayRecord::getVideoId, videoId)
);
}
private void sendVideoLikeNotificationAsync(ContentVideo video, Long operatorUserId) {
runAfterCommitAsync("发送视频点赞通知", () -> {
if (!operatorUserId.equals(video.getUserId())) {
Result<Void> notifyResult = userFeignClient.sendNotification(video.getUserId(), 1, null, video.getId(), internalServiceToken);
if (notifyResult == null || notifyResult.getCode() != 200) {
log.warn("发送视频点赞通知失败,videoId={}, ownerUserId={}, operatorUserId={}, result={}",
video.getId(), video.getUserId(), operatorUserId, notifyResult);
}
}
});
}
private void runAfterCommitAsync(String action, Runnable task) { private void runAfterCommitAsync(String action, Runnable task) {
Runnable wrappedTask = () -> CompletableFuture.runAsync(() -> { Runnable wrappedTask = () -> CompletableFuture.runAsync(() -> {
try { try {
@@ -0,0 +1,49 @@
package com.nuaa.video.content.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 播放历史VO
*/
@Data
public class PlayHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
private Long videoId;
/**
* 视频标题
*/
private String title;
/**
* 视频封面
*/
private String coverUrl;
/**
* 视频时长
*/
private Integer duration;
/**
* 播放进度
*/
private Integer playTime;
/**
* 是否已看完
*/
private Integer isFinished;
/**
* 最后播放时间
*/
private LocalDateTime updateTime;
}
@@ -68,6 +68,16 @@ public class VideoInfoVO implements Serializable {
*/ */
private Integer height; private Integer height;
/**
* 视频格式
*/
private String format;
/**
* 文件大小
*/
private Long size;
/** /**
* 分类ID * 分类ID
*/ */
@@ -123,6 +133,11 @@ public class VideoInfoVO implements Serializable {
*/ */
private Integer danmakuCount; private Integer danmakuCount;
/**
* 视频状态
*/
private Integer status;
/** /**
* 是否已点赞 * 是否已点赞
*/ */
@@ -138,11 +153,31 @@ public class VideoInfoVO implements Serializable {
*/ */
private Boolean isCoined; private Boolean isCoined;
/**
* 播放进度(秒)
*/
private Integer playProgress;
/**
* 是否已看完
*/
private Boolean isPlayFinished;
/** /**
* 发布时间 * 发布时间
*/ */
private LocalDateTime publishTime; private LocalDateTime publishTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/** /**
* 审核员ID * 审核员ID
*/ */
@@ -0,0 +1,40 @@
package com.nuaa.video.content.service;
import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.feign.UserFeignClient;
import com.nuaa.video.content.service.impl.VideoServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@DisplayName("视频通知发送测试")
class NotificationDispatchTest {
@Mock
private UserFeignClient userFeignClient;
@InjectMocks
private VideoServiceImpl videoService;
@Test
@DisplayName("视频点赞通知应携带视频ID作为 relatedId")
void shouldSendVideoLikeNotificationWithVideoId() {
ReflectionTestUtils.setField(videoService, "internalServiceToken", "internal-token");
ContentVideo video = new ContentVideo();
video.setId(300L);
video.setUserId(200L);
ReflectionTestUtils.invokeMethod(videoService, "sendVideoLikeNotificationAsync", video, 100L);
verify(userFeignClient, timeout(1000)).sendNotification(200L, 1, null, 300L, "internal-token");
}
}
@@ -33,12 +33,13 @@ public class AdminUserController {
@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,
@RequestParam(value = "status", required = false) Integer status,
HttpServletRequest request) { HttpServletRequest request) {
if (!isInternalRequest(request)) { if (!isInternalRequest(request)) {
return Result.error("非法的内部调用"); 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, status);
} }
/** /**
@@ -172,6 +172,34 @@ public class UserProfileController {
return userInfoService.increaseFavoriteCount(userId, count); return userInfoService.increaseFavoriteCount(userId, count);
} }
/**
* 增加用户硬币余额
*/
@PostMapping("/count/coin/increase/{userId}/{count}")
public Result<Void> increaseCoinCount(
@PathVariable Long userId,
@PathVariable Integer count,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.increaseCoinCount(userId, count);
}
/**
* 扣减用户硬币余额
*/
@PostMapping("/count/coin/decrease/{userId}/{count}")
public Result<Void> decreaseCoinCount(
@PathVariable Long userId,
@PathVariable Integer count,
HttpServletRequest request) {
if (!isInternalRequest(request)) {
return Result.error("非法的内部调用");
}
return userInfoService.decreaseCoinCount(userId, count);
}
/** /**
* 从请求中获取用户名 * 从请求中获取用户名
*/ */
@@ -4,6 +4,8 @@ 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.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
/** /**
* 认证服务Feign客户端 * 认证服务Feign客户端
*/ */
@@ -44,4 +46,11 @@ public interface AuthFeignClient {
@PathVariable Long userId, @PathVariable Long userId,
@RequestParam String newPassword, @RequestParam String newPassword,
@RequestHeader("X-Internal-Token") String internalToken); @RequestHeader("X-Internal-Token") String internalToken);
/**
* 按账号状态查询用户ID - 管理接口
*/
@GetMapping("/admin/user-ids/status/{status}")
Result<List<Long>> getUserIdsByStatus(@PathVariable Integer status,
@RequestHeader("X-Internal-Token") String internalToken);
} }
@@ -43,4 +43,14 @@ public interface UserInfoMapper extends BaseMapper<UserInfo> {
* 增加收藏数 * 增加收藏数
*/ */
int increaseFavoriteCount(@Param("userId") Long userId, @Param("count") int count); int increaseFavoriteCount(@Param("userId") Long userId, @Param("count") int count);
/**
* 增加硬币余额
*/
int increaseCoinCount(@Param("userId") Long userId, @Param("count") int count);
/**
* 扣减硬币余额
*/
int decreaseCoinCount(@Param("userId") Long userId, @Param("count") int count);
} }
@@ -93,10 +93,20 @@ public interface UserInfoService extends IService<UserInfo> {
*/ */
Result<Void> increaseFavoriteCount(Long userId, Integer count); Result<Void> increaseFavoriteCount(Long userId, Integer count);
/**
* 增加用户硬币余额
*/
Result<Void> increaseCoinCount(Long userId, Integer count);
/**
* 扣减用户硬币余额
*/
Result<Void> decreaseCoinCount(Long userId, Integer count);
/** /**
* 获取用户列表 - 管理接口 * 获取用户列表 - 管理接口
*/ */
Result<Page<UserProfileVO>> getAdminUserList(Page<UserInfo> page, String keyword); Result<Page<UserProfileVO>> getAdminUserList(Page<UserInfo> page, String keyword, Integer status);
/** /**
* 获取用户详情 - 管理接口 * 获取用户详情 - 管理接口
@@ -27,6 +27,8 @@ import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
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;
@@ -41,9 +43,15 @@ 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 {
private static final ZoneId APP_ZONE_ID = ZoneId.of("Asia/Shanghai");
private static final long DEFAULT_AVATAR_MAX_SIZE_BYTES = 5L * 1024 * 1024;
@Value("${service.auth.token}") @Value("${service.auth.token}")
private String internalServiceToken; private String internalServiceToken;
@Value("${user.avatar.max-size-bytes:" + DEFAULT_AVATAR_MAX_SIZE_BYTES + "}")
private long avatarMaxSizeBytes;
private final UserInfoMapper userInfoMapper; private final UserInfoMapper userInfoMapper;
private final UserFollowMapper userFollowMapper; private final UserFollowMapper userFollowMapper;
private final UserSettingService userSettingService; private final UserSettingService userSettingService;
@@ -118,6 +126,19 @@ 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());
} }
if (file == null || file.isEmpty()) {
return Result.error("请选择头像文件");
}
String contentType = file.getContentType();
if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) {
return Result.error("仅支持上传图片格式头像");
}
if (file.getSize() > avatarMaxSizeBytes) {
return Result.error("头像文件不能超过5MB");
}
// 上传头像 // 上传头像
String avatarUrl = minioUtils.uploadImage(file); String avatarUrl = minioUtils.uploadImage(file);
@@ -127,8 +148,8 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
userInfo.setUpdateTime(LocalDateTime.now()); userInfo.setUpdateTime(LocalDateTime.now());
updateById(userInfo); updateById(userInfo);
// 返回头像URL // 返回对象路径,由前端统一拼接可访问的媒体地址
return Result.success(minioUtils.getPreSignedUrl(avatarUrl)); return Result.success(avatarUrl);
} catch (Exception e) { } catch (Exception e) {
log.error("上传头像失败", e); log.error("上传头像失败", e);
return Result.error("上传头像失败: " + e.getMessage()); return Result.error("上传头像失败: " + e.getMessage());
@@ -154,7 +175,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
userInfo.setFollowingCount(0); userInfo.setFollowingCount(0);
userInfo.setVideoCount(0); userInfo.setVideoCount(0);
userInfo.setLikeCount(0); userInfo.setLikeCount(0);
userInfo.setCoinCount(0); userInfo.setCoinCount(20);
userInfo.setFavoriteCount(0); userInfo.setFavoriteCount(0);
userInfo.setViewCount(0L); userInfo.setViewCount(0L);
userInfo.setLevel(1); userInfo.setLevel(1);
@@ -247,14 +268,64 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
} }
@Override @Override
public Result<Page<UserProfileVO>> getAdminUserList(Page<UserInfo> page, String keyword) { public Result<Void> increaseCoinCount(Long userId, Integer count) {
if (count == null || count <= 0) {
return Result.error("硬币数量无效");
}
UserInfo userInfo = getById(userId);
if (userInfo == null) {
return Result.error(ResultCode.USER_NOT_FOUND.getCode(), ResultCode.USER_NOT_FOUND.getMessage());
}
userInfoMapper.increaseCoinCount(userId, count);
updateUserStatDailyAsync(userId, "coin", count);
return Result.success();
}
@Override
public Result<Void> decreaseCoinCount(Long userId, Integer count) {
if (count == null || count <= 0) {
return Result.error("硬币数量无效");
}
UserInfo userInfo = getById(userId);
if (userInfo == null) {
return Result.error(ResultCode.USER_NOT_FOUND.getCode(), ResultCode.USER_NOT_FOUND.getMessage());
}
int affectedRows = userInfoMapper.decreaseCoinCount(userId, count);
if (affectedRows <= 0) {
return Result.error("硬币余额不足");
}
updateUserStatDailyAsync(userId, "coin", -count);
return Result.success();
}
@Override
public Result<Page<UserProfileVO>> getAdminUserList(Page<UserInfo> page, String keyword, Integer status) {
LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
// 添加关键词搜索条件 // 添加关键词搜索条件
if (StringUtils.hasText(keyword)) { if (StringUtils.hasText(keyword)) {
queryWrapper.like(UserInfo::getNickname, keyword) queryWrapper.and(wrapper -> wrapper
.like(UserInfo::getNickname, keyword)
.or() .or()
.like(UserInfo::getSignature, keyword); .like(UserInfo::getSignature, keyword));
}
if (status != null) {
Result<List<Long>> userIdsResult = authFeignClient.getUserIdsByStatus(status, internalServiceToken);
List<Long> userIds = userIdsResult.getData() == null ? Collections.emptyList() : userIdsResult.getData();
if (userIds.isEmpty()) {
Page<UserProfileVO> emptyPage = new Page<>(page.getCurrent(), page.getSize(), 0);
emptyPage.setRecords(Collections.emptyList());
return Result.success(emptyPage);
}
queryWrapper.in(UserInfo::getId, userIds);
} }
// 按创建时间降序排序 // 按创建时间降序排序
@@ -346,7 +417,7 @@ public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> i
stats.put("totalUsers", totalUsers); stats.put("totalUsers", totalUsers);
// 今日新增用户 // 今日新增用户
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now(APP_ZONE_ID);
long todayNewUsers = count(new LambdaQueryWrapper<UserInfo>() long todayNewUsers = count(new LambdaQueryWrapper<UserInfo>()
.ge(UserInfo::getCreateTime, today.atStartOfDay())); .ge(UserInfo::getCreateTime, today.atStartOfDay()));
stats.put("todayNewUsers", todayNewUsers); stats.put("todayNewUsers", todayNewUsers);
@@ -68,6 +68,11 @@ public class UserProfileVO implements Serializable {
*/ */
private Integer likeCount; private Integer likeCount;
/**
* 硬币余额
*/
private Integer coinCount;
/** /**
* 等级 * 等级
*/ */
@@ -37,4 +37,19 @@
WHERE id = #{userId} WHERE id = #{userId}
</update> </update>
<update id="increaseCoinCount">
UPDATE user_info
SET coin_count = GREATEST(coin_count + #{count}, 0),
update_time = NOW()
WHERE id = #{userId}
</update>
<update id="decreaseCoinCount">
UPDATE user_info
SET coin_count = coin_count - #{count},
update_time = NOW()
WHERE id = #{userId}
AND coin_count >= #{count}
</update>
</mapper> </mapper>