From e8f36222e7a1a7138f5cb1d5b455d5dd3e71220b Mon Sep 17 00:00:00 2001 From: Chuyaoyuan Date: Wed, 6 May 2026 19:10:27 +0800 Subject: [PATCH] update admin --- .../controller/AdminSystemController.java | 363 ++++++++++++++++- .../com/yaoyuan/jiscuss/entity/AuditLog.java | 47 +++ .../java/com/yaoyuan/jiscuss/entity/Role.java | 41 ++ .../yaoyuan/jiscuss/entity/UpgradeLog.java | 41 ++ .../com/yaoyuan/jiscuss/entity/UserRole.java | 32 ++ .../repository/AuditLogRepository.java | 20 + .../repository/DiscussionsTagsRepository.java | 1 + .../jiscuss/repository/PostsRepository.java | 2 + .../jiscuss/repository/RoleRepository.java | 16 + .../repository/UpgradeLogRepository.java | 12 + .../repository/UserRoleRepository.java | 20 + .../jiscuss/service/AuditLogService.java | 59 +++ .../service/impl/UserDetailServiceImpl.java | 17 +- ...s.sql => V2.0.1__reset_seed_passwords.sql} | 0 .../migration/V2.0.2__rbac_admin_console.sql | 67 ++++ .../db/migration/V2.0.3__audit_log.sql | 17 + .../resources/templates/admin/admin-shell.ftl | 54 +++ .../resources/templates/admin/audit-logs.ftl | 48 +++ .../resources/templates/admin/discussions.ftl | 32 ++ src/main/resources/templates/admin/home.ftl | 372 ++---------------- .../resources/templates/admin/plugins.ftl | 8 + src/main/resources/templates/admin/posts.ftl | 33 ++ src/main/resources/templates/admin/themes.ftl | 8 + .../templates/admin/upgrade-logs.ftl | 65 +++ src/main/resources/templates/admin/users.ftl | 82 ++++ 25 files changed, 1103 insertions(+), 354 deletions(-) create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/AuditLog.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/Role.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/UpgradeLog.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/UserRole.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/AuditLogRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/RoleRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/UpgradeLogRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/UserRoleRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/service/AuditLogService.java rename src/main/resources/db/migration/{V3__reset_seed_passwords.sql => V2.0.1__reset_seed_passwords.sql} (100%) create mode 100644 src/main/resources/db/migration/V2.0.2__rbac_admin_console.sql create mode 100644 src/main/resources/db/migration/V2.0.3__audit_log.sql create mode 100644 src/main/resources/templates/admin/admin-shell.ftl create mode 100644 src/main/resources/templates/admin/audit-logs.ftl create mode 100644 src/main/resources/templates/admin/discussions.ftl create mode 100644 src/main/resources/templates/admin/plugins.ftl create mode 100644 src/main/resources/templates/admin/posts.ftl create mode 100644 src/main/resources/templates/admin/themes.ftl create mode 100644 src/main/resources/templates/admin/upgrade-logs.ftl create mode 100644 src/main/resources/templates/admin/users.ftl diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java b/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java index 1be641a..1649d29 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/AdminSystemController.java @@ -1,42 +1,365 @@ package com.yaoyuan.jiscuss.controller; +import com.yaoyuan.jiscuss.entity.Discussion; +import com.yaoyuan.jiscuss.entity.Post; +import com.yaoyuan.jiscuss.entity.Role; +import com.yaoyuan.jiscuss.entity.UpgradeLog; +import com.yaoyuan.jiscuss.entity.User; import com.yaoyuan.jiscuss.entity.UserInfo; +import com.yaoyuan.jiscuss.entity.UserRole; +import com.yaoyuan.jiscuss.entity.AuditLog; +import com.yaoyuan.jiscuss.repository.DiscussionsRepository; +import com.yaoyuan.jiscuss.repository.DiscussionsTagsRepository; +import com.yaoyuan.jiscuss.repository.PostsRepository; +import com.yaoyuan.jiscuss.repository.RoleRepository; +import com.yaoyuan.jiscuss.repository.UpgradeLogRepository; +import com.yaoyuan.jiscuss.repository.UserRoleRepository; +import com.yaoyuan.jiscuss.repository.UsersRepository; +import com.yaoyuan.jiscuss.repository.AuditLogRepository; +import com.yaoyuan.jiscuss.service.AuditLogService; +import org.springframework.boot.SpringBootVersion; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.ModelMap; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import jakarta.servlet.http.HttpServletRequest; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + /** * @author yaoyuan2.chu * 后台系统控制器 */ @Controller -public class AdminSystemController { +public class AdminSystemController extends BaseController { - /** - * 后台登录 - */ + private final UsersRepository usersRepository; + private final DiscussionsRepository discussionsRepository; + private final PostsRepository postsRepository; + private final DiscussionsTagsRepository discussionsTagsRepository; + private final RoleRepository roleRepository; + private final UserRoleRepository userRoleRepository; + private final UpgradeLogRepository upgradeLogRepository; + private final AuditLogRepository auditLogRepository; + private final AuditLogService auditLogService; - /** - * 后台退出 - */ - @RequestMapping("/admin/logout") - public String logout(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) { - return "admin/logout"; + public AdminSystemController( + UsersRepository usersRepository, + DiscussionsRepository discussionsRepository, + PostsRepository postsRepository, + DiscussionsTagsRepository discussionsTagsRepository, + RoleRepository roleRepository, + UserRoleRepository userRoleRepository, + UpgradeLogRepository upgradeLogRepository, + AuditLogRepository auditLogRepository, + AuditLogService auditLogService) { + this.usersRepository = usersRepository; + this.discussionsRepository = discussionsRepository; + this.postsRepository = postsRepository; + this.discussionsTagsRepository = discussionsTagsRepository; + this.roleRepository = roleRepository; + this.userRoleRepository = userRoleRepository; + this.upgradeLogRepository = upgradeLogRepository; + this.auditLogRepository = auditLogRepository; + this.auditLogService = auditLogService; } - /** - * 后台设置管理 - */ + @GetMapping("/admin/home") + public String home(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "home"); + + map.put("userCount", usersRepository.count()); + map.put("discussionCount", discussionsRepository.count()); + map.put("postCount", postsRepository.count()); + map.put("roleCount", roleRepository.count()); + + map.put("sys", buildSystemMetrics()); + map.put("deps", buildDependencyVersions()); - /** - * 后台页面 - * @return - */ - @RequestMapping("/admin/home") - public String home(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) { return "admin/home"; } + + @GetMapping("/admin/users") + public String users(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "users"); + + List users = usersRepository.findAll(Sort.by(Sort.Direction.ASC, "id")); + List roles = roleRepository.findAllByOrderByIdAsc(); + Map> userRoleIds = new HashMap<>(); + for (UserRole ur : userRoleRepository.findAll()) { + userRoleIds.computeIfAbsent(ur.getUserId(), key -> new HashSet<>()).add(ur.getRoleId()); + } + + map.put("users", users); + map.put("roles", roles); + map.put("userRoleIds", userRoleIds); + return "admin/users"; + } + + @PostMapping("/admin/users/{id}/roles") + @Transactional + public String updateUserRoles(HttpServletRequest request, + @PathVariable("id") Integer id, + @RequestParam(value = "roleIds", required = false) List roleIds) { + UserInfo currentAdmin = getUserInfo(request); + userRoleRepository.deleteByUserId(id); + if (roleIds != null) { + Date now = new Date(); + Set uniqueRoleIds = new HashSet<>(roleIds); + Set validEnabledRoleIds = roleRepository.findAllById(uniqueRoleIds).stream() + .filter(role -> role.getEnabled() != null && role.getEnabled() == 1) + .map(Role::getId) + .collect(Collectors.toSet()); + + for (Integer roleId : validEnabledRoleIds) { + UserRole ur = new UserRole(); + ur.setUserId(id); + ur.setRoleId(roleId); + ur.setCreateTime(now); + userRoleRepository.save(ur); + } + } + auditLogService.log(currentAdmin, "update_user_roles", "user", id, + "Updated user role assignments. Role count: " + (roleIds == null ? 0 : roleIds.size())); + return "redirect:/admin/users"; + } + + @PostMapping("/admin/roles") + public String createRole(HttpServletRequest request, + @RequestParam("code") String code, + @RequestParam("name") String name, + @RequestParam(value = "description", required = false) String description) { + UserInfo currentAdmin = getUserInfo(request); + String normalizedCode = code == null ? "" : code.trim().toUpperCase(); + if (normalizedCode.isEmpty()) { + return "redirect:/admin/users"; + } + + Role exists = roleRepository.findByCodeIgnoreCase(normalizedCode); + if (exists == null) { + Date now = new Date(); + Role role = new Role(); + role.setCode(normalizedCode); + role.setName(name == null ? "" : name.trim()); + role.setDescription(description); + role.setEnabled(1); + role.setCreateTime(now); + role.setUpdateTime(now); + roleRepository.save(role); + auditLogService.log(currentAdmin, "create_role", "role", role.getId(), + "Created role: " + normalizedCode); + } else { + auditLogService.log(currentAdmin, "create_role_duplicate", "role", exists.getId(), + "Attempted to create duplicate role: " + normalizedCode, "ignored"); + } + return "redirect:/admin/users"; + } + + @PostMapping("/admin/roles/{id}/toggle") + public String toggleRole(HttpServletRequest request, + @PathVariable("id") Integer id) { + UserInfo currentAdmin = getUserInfo(request); + Role role = roleRepository.findById(id).orElse(null); + if (role != null) { + int nextStatus = role.getEnabled() != null && role.getEnabled() == 1 ? 0 : 1; + // Keep built-in ADMIN role enabled to avoid locking out management access. + if ("ADMIN".equalsIgnoreCase(role.getCode()) && nextStatus == 0) { + auditLogService.log(currentAdmin, "toggle_role_blocked", "role", id, + "Attempted to disable built-in ADMIN role", "blocked"); + return "redirect:/admin/users"; + } + role.setEnabled(nextStatus); + role.setUpdateTime(new Date()); + roleRepository.save(role); + auditLogService.log(currentAdmin, "toggle_role", "role", id, + "Toggled role status to " + (nextStatus == 1 ? "enabled" : "disabled")); + } + return "redirect:/admin/users"; + } + + @GetMapping("/admin/discussions") + public String discussions(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "discussions"); + List discussions = discussionsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime")); + map.put("discussions", discussions); + return "admin/discussions"; + } + + @PostMapping("/admin/discussions/{id}/delete") + @Transactional + public String deleteDiscussion(HttpServletRequest request, + @PathVariable("id") Integer id) { + UserInfo currentAdmin = getUserInfo(request); + Discussion discussion = discussionsRepository.findById(id).orElse(null); + String title = discussion == null ? "unknown" : discussion.getTitle(); + discussionsTagsRepository.deleteByDiscussionId(id); + postsRepository.deleteByDiscussionId(id); + discussionsRepository.deleteById(id); + auditLogService.log(currentAdmin, "delete_discussion", "discussion", id, + "Deleted discussion: " + title); + return "redirect:/admin/discussions"; + } + + @GetMapping("/admin/posts") + public String posts(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "posts"); + List posts = postsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime")); + map.put("posts", posts); + + Set userIds = posts.stream().map(Post::getCreateId).filter(v -> v != null).collect(Collectors.toSet()); + Set discussionIds = posts.stream().map(Post::getDiscussionId).filter(v -> v != null).collect(Collectors.toSet()); + + Map userNames = usersRepository.findAllById(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getUsername)); + Map discussionNames = discussionsRepository.findAllById(discussionIds).stream() + .collect(Collectors.toMap(Discussion::getId, Discussion::getTitle)); + + map.put("userNames", userNames); + map.put("discussionNames", discussionNames); + return "admin/posts"; + } + + @PostMapping("/admin/posts/{id}/delete") + public String deletePost(HttpServletRequest request, + @PathVariable("id") Integer id) { + UserInfo currentAdmin = getUserInfo(request); + Post post = postsRepository.findById(id).orElse(null); + String content = post == null ? "unknown" : (post.getContent() == null ? "" : post.getContent()); + if (content.length() > 50) { + content = content.substring(0, 50) + "..."; + } + postsRepository.deleteById(id); + auditLogService.log(currentAdmin, "delete_post", "post", id, + "Deleted post: " + content); + return "redirect:/admin/posts"; + } + + @GetMapping("/admin/upgrade-logs") + public String upgradeLogs(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "upgradeLogs"); + map.put("logs", upgradeLogRepository.findAllByOrderByCreateTimeDesc()); + return "admin/upgrade-logs"; + } + + @PostMapping("/admin/upgrade-logs") + public String createUpgradeLog(HttpServletRequest request, + @RequestParam("version") String version, + @RequestParam("title") String title, + @RequestParam(value = "type", defaultValue = "feature") String type, + @RequestParam("content") String content) { + UserInfo user = getUserInfo(request); + UpgradeLog log = new UpgradeLog(); + log.setVersion(version); + log.setTitle(title); + log.setType(type); + log.setContent(content); + log.setCreatedBy(user == null ? null : user.getId()); + log.setCreateTime(new Date()); + upgradeLogRepository.save(log); + auditLogService.log(user, "create_upgrade_log", "upgrade_log", log.getId(), + "Created upgrade log: " + version + " - " + title); + return "redirect:/admin/upgrade-logs"; + } + + @GetMapping("/admin/plugins") + public String plugins(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "plugins"); + return "admin/plugins"; + } + + @GetMapping("/admin/audit-logs") + public String auditLogs(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "auditLogs"); + List logs = auditLogRepository.findAllByOrderByCreateTimeDesc(); + map.put("logs", logs); + return "admin/audit-logs"; + } + + @GetMapping("/admin/themes") + public String themes(HttpServletRequest request, ModelMap map) { + fillCommon(request, map, "themes"); + return "admin/themes"; + } + + private void fillCommon(HttpServletRequest request, ModelMap map, String active) { + UserInfo user = getUserInfo(request); + map.put("adminName", user == null ? "admin" : user.getUsername()); + map.put("active", active); + } + + private Map buildSystemMetrics() { + Map result = new HashMap<>(); + Runtime rt = Runtime.getRuntime(); + MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); + + long totalMb = bytesToMb(rt.totalMemory()); + long freeMb = bytesToMb(rt.freeMemory()); + long usedMb = totalMb - freeMb; + long maxMb = bytesToMb(rt.maxMemory()); + long heapUsedMb = bytesToMb(memoryMXBean.getHeapMemoryUsage().getUsed()); + + result.put("totalMb", totalMb); + result.put("freeMb", freeMb); + result.put("usedMb", usedMb); + result.put("maxMb", maxMb); + result.put("heapUsedMb", heapUsedMb); + result.put("uptimeMs", ManagementFactory.getRuntimeMXBean().getUptime()); + result.put("cpuLoad", readCpuLoadPercent()); + return result; + } + + private Map buildDependencyVersions() { + Map result = new HashMap<>(); + result.put("springBoot", SpringBootVersion.getVersion()); + result.put("java", System.getProperty("java.version")); + result.put("spring", implVersion("org.springframework.core.SpringVersion")); + result.put("hibernate", implVersion("org.hibernate.Version")); + result.put("flyway", implVersion("org.flywaydb.core.Flyway")); + result.put("druid", implVersion("com.alibaba.druid.pool.DruidDataSource")); + result.put("ehcache", implVersion("org.ehcache.CacheManager")); + result.put("springdoc", implVersion("org.springdoc.core.configuration.SpringDocConfiguration")); + return result; + } + + private String implVersion(String className) { + try { + Class clazz = Class.forName(className); + Package pkg = clazz.getPackage(); + String version = pkg == null ? null : pkg.getImplementationVersion(); + return version == null ? "unknown" : version; + } catch (ClassNotFoundException e) { + return "unknown"; + } + } + + private String readCpuLoadPercent() { + try { + java.lang.management.OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean) { + double value = sunOsBean.getCpuLoad(); + if (value >= 0) { + return String.format("%.2f%%", value * 100); + } + } + } catch (Exception ignored) { + } + return "N/A"; + } + + private long bytesToMb(long bytes) { + return bytes / 1024 / 1024; + } } diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/AuditLog.java b/src/main/java/com/yaoyuan/jiscuss/entity/AuditLog.java new file mode 100644 index 0000000..2888e78 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/AuditLog.java @@ -0,0 +1,47 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "audit_log") +public class AuditLog implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "admin_id") + private Integer adminId; + + @Column(name = "admin_name") + private String adminName; + + @Column(name = "action_type") + private String actionType; + + @Column(name = "target_type") + private String targetType; + + @Column(name = "target_id") + private Integer targetId; + + @Column(name = "description") + private String description; + + @Column(name = "status") + private String status; + + @Column(name = "create_time") + private Date createTime; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Role.java b/src/main/java/com/yaoyuan/jiscuss/entity/Role.java new file mode 100644 index 0000000..be99496 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Role.java @@ -0,0 +1,41 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "rbac_role") +public class Role implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "code") + private String code; + + @Column(name = "name") + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "enabled") + private Integer enabled; + + @Column(name = "create_time") + private Date createTime; + + @Column(name = "update_time") + private Date updateTime; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/UpgradeLog.java b/src/main/java/com/yaoyuan/jiscuss/entity/UpgradeLog.java new file mode 100644 index 0000000..2285709 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/UpgradeLog.java @@ -0,0 +1,41 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "upgrade_log") +public class UpgradeLog implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "version") + private String version; + + @Column(name = "title") + private String title; + + @Column(name = "content") + private String content; + + @Column(name = "type") + private String type; + + @Column(name = "created_by") + private Integer createdBy; + + @Column(name = "create_time") + private Date createTime; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/UserRole.java b/src/main/java/com/yaoyuan/jiscuss/entity/UserRole.java new file mode 100644 index 0000000..c0e0d0d --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/UserRole.java @@ -0,0 +1,32 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "rbac_user_role") +public class UserRole implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "role_id") + private Integer roleId; + + @Column(name = "create_time") + private Date createTime; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/AuditLogRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/AuditLogRepository.java new file mode 100644 index 0000000..587b716 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/AuditLogRepository.java @@ -0,0 +1,20 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.AuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AuditLogRepository extends JpaRepository { + List findAllByOrderByCreateTimeDesc(); + + Page findAllByOrderByCreateTimeDesc(Pageable pageable); + + List findByTargetTypeAndTargetIdOrderByCreateTimeDesc(String targetType, Integer targetId); + + List findByAdminIdOrderByCreateTimeDesc(Integer adminId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsTagsRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsTagsRepository.java index d2a83d4..a894f29 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsTagsRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsTagsRepository.java @@ -7,4 +7,5 @@ import org.springframework.stereotype.Repository; @Repository public interface DiscussionsTagsRepository extends JpaRepository { + void deleteByDiscussionId(Integer discussionId); } diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java index fcbead1..fffe34c 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java @@ -12,6 +12,8 @@ import java.util.Map; @Repository public interface PostsRepository extends JpaRepository { + void deleteByDiscussionId(Integer discussionId); + /** * @param dId * @return diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/RoleRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/RoleRepository.java new file mode 100644 index 0000000..4e5055e --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/RoleRepository.java @@ -0,0 +1,16 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RoleRepository extends JpaRepository { + Role findByCode(String code); + + Role findByCodeIgnoreCase(String code); + + List findAllByOrderByIdAsc(); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/UpgradeLogRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/UpgradeLogRepository.java new file mode 100644 index 0000000..593db87 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/UpgradeLogRepository.java @@ -0,0 +1,12 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.UpgradeLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UpgradeLogRepository extends JpaRepository { + List findAllByOrderByCreateTimeDesc(); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/UserRoleRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/UserRoleRepository.java new file mode 100644 index 0000000..705c2ef --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/UserRoleRepository.java @@ -0,0 +1,20 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.UserRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserRoleRepository extends JpaRepository { + List findByUserId(Integer userId); + + void deleteByUserId(Integer userId); + + @Query(value = "select r.code from rbac_user_role ur join rbac_role r on ur.role_id = r.id where ur.user_id = :userId and r.enabled = 1", + nativeQuery = true) + List findRoleCodesByUserId(@Param("userId") Integer userId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/AuditLogService.java b/src/main/java/com/yaoyuan/jiscuss/service/AuditLogService.java new file mode 100644 index 0000000..9b582a6 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/service/AuditLogService.java @@ -0,0 +1,59 @@ +package com.yaoyuan.jiscuss.service; + +import com.yaoyuan.jiscuss.entity.AuditLog; +import com.yaoyuan.jiscuss.entity.UserInfo; +import com.yaoyuan.jiscuss.repository.AuditLogRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +/** + * Service for recording admin operations in audit log. + */ +@Service +public class AuditLogService { + + private final AuditLogRepository auditLogRepository; + + public AuditLogService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + @Transactional + public void log(Integer adminId, String adminName, String actionType, String targetType, + Integer targetId, String description, String status) { + AuditLog log = new AuditLog(); + log.setAdminId(adminId); + log.setAdminName(adminName); + log.setActionType(actionType); + log.setTargetType(targetType); + log.setTargetId(targetId); + log.setDescription(description); + log.setStatus(status == null ? "success" : status); + log.setCreateTime(new Date()); + auditLogRepository.save(log); + } + + @Transactional + public void log(Integer adminId, String adminName, String actionType, String targetType, + Integer targetId, String description) { + log(adminId, adminName, actionType, targetType, targetId, description, "success"); + } + + @Transactional + public void log(UserInfo user, String actionType, String targetType, + Integer targetId, String description) { + Integer userId = user == null ? null : user.getId(); + String userName = user == null ? "unknown" : user.getUsername(); + log(userId, userName, actionType, targetType, targetId, description, "success"); + } + + @Transactional + public void log(UserInfo user, String actionType, String targetType, + Integer targetId, String description, String status) { + Integer userId = user == null ? null : user.getId(); + String userName = user == null ? "unknown" : user.getUsername(); + log(userId, userName, actionType, targetType, targetId, description, status); + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java index feecff9..0b22769 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/UserDetailServiceImpl.java @@ -2,6 +2,7 @@ package com.yaoyuan.jiscuss.service.impl; import com.yaoyuan.jiscuss.entity.User; import com.yaoyuan.jiscuss.entity.UserInfo; +import com.yaoyuan.jiscuss.repository.UserRoleRepository; import com.yaoyuan.jiscuss.service.IUsersService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; @@ -29,6 +30,9 @@ public class UserDetailServiceImpl implements UserDetailsService { @Autowired private IUsersService userInfoService; + @Autowired + private UserRoleRepository userRoleRepository; + @Override public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException { User userInfo = userInfoService.getByUsername(username); @@ -36,11 +40,16 @@ public class UserDetailServiceImpl implements UserDetailsService { throw new UsernameNotFoundException("用户不存在: " + username); } - // Assign role based on user level: level >= 1 → ADMIN, else → USER - String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER"; - List authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + List roleCodes = userRoleRepository.findRoleCodesByUserId(userInfo.getId()); + if (roleCodes == null || roleCodes.isEmpty()) { + // Compatibility fallback for legacy data before RBAC migration. + String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER"; + roleCodes = List.of(role); + } + for (String roleCode : roleCodes) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode)); + } return new UserInfo( authorities, diff --git a/src/main/resources/db/migration/V3__reset_seed_passwords.sql b/src/main/resources/db/migration/V2.0.1__reset_seed_passwords.sql similarity index 100% rename from src/main/resources/db/migration/V3__reset_seed_passwords.sql rename to src/main/resources/db/migration/V2.0.1__reset_seed_passwords.sql diff --git a/src/main/resources/db/migration/V2.0.2__rbac_admin_console.sql b/src/main/resources/db/migration/V2.0.2__rbac_admin_console.sql new file mode 100644 index 0000000..fe569d0 --- /dev/null +++ b/src/main/resources/db/migration/V2.0.2__rbac_admin_console.sql @@ -0,0 +1,67 @@ +-- V2.0.2: RBAC foundation + admin upgrade logs (Spring Boot 3 optimization) + +CREATE TABLE IF NOT EXISTS rbac_role +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + code VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + enabled INTEGER NOT NULL DEFAULT 1, + create_time DATETIME, + update_time DATETIME +); + +CREATE TABLE IF NOT EXISTS rbac_user_role +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + create_time DATETIME +); + +CREATE TABLE IF NOT EXISTS upgrade_log +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + version VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + type VARCHAR(50), + created_by INTEGER, + create_time DATETIME +); + +INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time) +SELECT 'ADMIN', '管理员', '后台管理与运维权限', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'ADMIN'); + +INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time) +SELECT 'USER', '普通用户', '论坛基础访问权限', 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'USER'); + +INSERT INTO rbac_user_role (user_id, role_id, create_time) +SELECT u.id, r.id, NOW() +FROM user u +JOIN rbac_role r ON r.code = 'USER' +WHERE NOT EXISTS ( + SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id +); + +INSERT INTO rbac_user_role (user_id, role_id, create_time) +SELECT u.id, r.id, NOW() +FROM user u +JOIN rbac_role r ON r.code = 'ADMIN' +WHERE u.level >= 1 + AND NOT EXISTS ( + SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id +); + +INSERT INTO upgrade_log (version, title, content, type, created_by, create_time) +SELECT 'v3.5.13', + '后台管理与RBAC架构初始化', + '新增角色与用户角色映射;新增后台管理与运维面板;新增升级日志管理入口。', + 'feature', + 1, + NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM upgrade_log WHERE version = 'v3.5.13' AND title = '后台管理与RBAC架构初始化' +); diff --git a/src/main/resources/db/migration/V2.0.3__audit_log.sql b/src/main/resources/db/migration/V2.0.3__audit_log.sql new file mode 100644 index 0000000..937b5e0 --- /dev/null +++ b/src/main/resources/db/migration/V2.0.3__audit_log.sql @@ -0,0 +1,17 @@ +-- V2.0.3: Audit log for admin operations (Spring Boot 3 optimization) + +CREATE TABLE IF NOT EXISTS audit_log +( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + admin_id INTEGER, + admin_name VARCHAR(100), + action_type VARCHAR(50) NOT NULL, + target_type VARCHAR(50), + target_id INTEGER, + description VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'success', + create_time DATETIME +); + +CREATE INDEX idx_audit_admin_time ON audit_log(admin_id, create_time DESC); +CREATE INDEX idx_audit_target ON audit_log(target_type, target_id); diff --git a/src/main/resources/templates/admin/admin-shell.ftl b/src/main/resources/templates/admin/admin-shell.ftl new file mode 100644 index 0000000..8a46d3d --- /dev/null +++ b/src/main/resources/templates/admin/admin-shell.ftl @@ -0,0 +1,54 @@ +<#macro page title active adminName="admin"> + + + + + + + + ${title} + <#include "admin-commjs.ftl"/> + + + +
+
+
Jiscuss 后台管理
+
当前管理员: ${adminName} | 接口文档 | 前台首页
+
+
+ + + + + diff --git a/src/main/resources/templates/admin/audit-logs.ftl b/src/main/resources/templates/admin/audit-logs.ftl new file mode 100644 index 0000000..3c7d303 --- /dev/null +++ b/src/main/resources/templates/admin/audit-logs.ftl @@ -0,0 +1,48 @@ +<#import "admin-shell.ftl" as shell> +<@shell.page title="后台管理 - 操作审计日志" active=active adminName=adminName> +

操作审计日志

+

记录所有管理员的后台操作,包括角色管理、用户管理、内容管理等。

+ + + + + + + + + + + + + + + + <#list logs as l> + + + + + + + + + + + + +
ID管理员操作类型目标类型目标ID描述状态操作时间
${l.id}${l.adminName!""}${l.actionType!""}${l.targetType!""}${l.targetId!""} +
+ ${l.description!""} +
+
+ <#if l.status == "success"> + 成功 + <#elseif l.status == "blocked"> + 阻止 + <#elseif l.status == "ignored"> + 忽略 + <#else> + ${l.status!""} + + ${l.createTime?string('yyyy-MM-dd HH:mm:ss')!""}
+ diff --git a/src/main/resources/templates/admin/discussions.ftl b/src/main/resources/templates/admin/discussions.ftl new file mode 100644 index 0000000..b176eb5 --- /dev/null +++ b/src/main/resources/templates/admin/discussions.ftl @@ -0,0 +1,32 @@ +<#import "admin-shell.ftl" as shell> +<@shell.page title="后台管理 - 文章管理" active=active adminName=adminName> +

文章管理

+ + + + + + + + + + + + <#list discussions as d> + + + + + + + + + +
ID标题创建人ID创建时间操作
${d.id}${d.title!""}${d.createId!""}${d.createTime?string('yyyy-MM-dd HH:mm:ss')!""} + 查看 +
+ + +
+
+ diff --git a/src/main/resources/templates/admin/home.ftl b/src/main/resources/templates/admin/home.ftl index 161dc18..92b4a9f 100644 --- a/src/main/resources/templates/admin/home.ftl +++ b/src/main/resources/templates/admin/home.ftl @@ -1,338 +1,50 @@ - - -后台管理首页 - - - +<#import "admin-shell.ftl" as shell> +<@shell.page title="后台管理 - 总览" active=active adminName=adminName> +

系统总览

- <#--jquery--> - - - <#--semantic-ui--> - - - - - - <#--tinymce--> - - - - <#--layx--> - - - - - - - - - - - -
- -
-