yumeng 4 дней назад
Сommit
a18e49d098
75 измененных файлов с 3138 добавлено и 0 удалено
  1. 11 0
      .gitignore
  2. 129 0
      pom.xml
  3. 14 0
      src/main/java/com/nuojing/admin/NuojingDataAdminApplication.java
  4. 23 0
      src/main/java/com/nuojing/admin/common/ApiResponse.java
  5. 14 0
      src/main/java/com/nuojing/admin/common/BusinessException.java
  6. 32 0
      src/main/java/com/nuojing/admin/common/GlobalExceptionHandler.java
  7. 15 0
      src/main/java/com/nuojing/admin/config/AuthProperties.java
  8. 15 0
      src/main/java/com/nuojing/admin/config/PasswordConfig.java
  9. 27 0
      src/main/java/com/nuojing/admin/config/RedisConfig.java
  10. 32 0
      src/main/java/com/nuojing/admin/config/WebConfig.java
  11. 20 0
      src/main/java/com/nuojing/admin/context/CurrentUser.java
  12. 21 0
      src/main/java/com/nuojing/admin/context/CurrentUserContext.java
  13. 60 0
      src/main/java/com/nuojing/admin/controller/AuthController.java
  14. 41 0
      src/main/java/com/nuojing/admin/controller/DeptController.java
  15. 41 0
      src/main/java/com/nuojing/admin/controller/MenuController.java
  16. 51 0
      src/main/java/com/nuojing/admin/controller/RoleController.java
  17. 51 0
      src/main/java/com/nuojing/admin/controller/UserController.java
  18. 13 0
      src/main/java/com/nuojing/admin/domain/dto/DeptRequest.java
  19. 14 0
      src/main/java/com/nuojing/admin/domain/dto/LoginRequest.java
  20. 20 0
      src/main/java/com/nuojing/admin/domain/dto/MenuRequest.java
  21. 16 0
      src/main/java/com/nuojing/admin/domain/dto/RoleRequest.java
  22. 17 0
      src/main/java/com/nuojing/admin/domain/dto/UserRequest.java
  23. 17 0
      src/main/java/com/nuojing/admin/domain/entity/Dept.java
  24. 24 0
      src/main/java/com/nuojing/admin/domain/entity/Menu.java
  25. 17 0
      src/main/java/com/nuojing/admin/domain/entity/Role.java
  26. 18 0
      src/main/java/com/nuojing/admin/domain/entity/User.java
  27. 17 0
      src/main/java/com/nuojing/admin/domain/vo/DeptVO.java
  28. 13 0
      src/main/java/com/nuojing/admin/domain/vo/LoginResponse.java
  29. 24 0
      src/main/java/com/nuojing/admin/domain/vo/MenuVO.java
  30. 15 0
      src/main/java/com/nuojing/admin/domain/vo/UserInfoVO.java
  31. 21 0
      src/main/java/com/nuojing/admin/domain/vo/UserVO.java
  32. 52 0
      src/main/java/com/nuojing/admin/interceptor/AuthInterceptor.java
  33. 18 0
      src/main/java/com/nuojing/admin/mapper/DeptMapper.java
  34. 20 0
      src/main/java/com/nuojing/admin/mapper/MenuMapper.java
  35. 27 0
      src/main/java/com/nuojing/admin/mapper/RoleMapper.java
  36. 30 0
      src/main/java/com/nuojing/admin/mapper/UserMapper.java
  37. 17 0
      src/main/java/com/nuojing/admin/service/AuthService.java
  38. 15 0
      src/main/java/com/nuojing/admin/service/DeptService.java
  39. 17 0
      src/main/java/com/nuojing/admin/service/MenuService.java
  40. 19 0
      src/main/java/com/nuojing/admin/service/RoleService.java
  41. 19 0
      src/main/java/com/nuojing/admin/service/UserService.java
  42. 112 0
      src/main/java/com/nuojing/admin/service/impl/AuthServiceImpl.java
  43. 70 0
      src/main/java/com/nuojing/admin/service/impl/DeptServiceImpl.java
  44. 103 0
      src/main/java/com/nuojing/admin/service/impl/MenuServiceImpl.java
  45. 51 0
      src/main/java/com/nuojing/admin/service/impl/MenuTreeBuilder.java
  46. 65 0
      src/main/java/com/nuojing/admin/service/impl/RoleServiceImpl.java
  47. 95 0
      src/main/java/com/nuojing/admin/service/impl/UserServiceImpl.java
  48. 7 0
      src/main/java/com/nuojing/dashboard/package-info.java
  49. 88 0
      src/main/java/com/nuojing/media/auth/client/BytedanceMediaClient.java
  50. 29 0
      src/main/java/com/nuojing/media/auth/controller/MediaAuthController.java
  51. 9 0
      src/main/java/com/nuojing/media/auth/domain/dto/AuthUrlRequest.java
  52. 21 0
      src/main/java/com/nuojing/media/auth/domain/entity/MediaAdvertiserAccount.java
  53. 25 0
      src/main/java/com/nuojing/media/auth/domain/entity/MediaAuthAccount.java
  54. 21 0
      src/main/java/com/nuojing/media/auth/domain/entity/MediaAuthConfig.java
  55. 15 0
      src/main/java/com/nuojing/media/auth/domain/vo/AuthUrlVO.java
  56. 15 0
      src/main/java/com/nuojing/media/auth/domain/vo/MediaAuthConfigVO.java
  57. 19 0
      src/main/java/com/nuojing/media/auth/mapper/MediaAuthMapper.java
  58. 20 0
      src/main/java/com/nuojing/media/auth/service/MediaAuthService.java
  59. 304 0
      src/main/java/com/nuojing/media/auth/service/impl/MediaAuthServiceImpl.java
  60. 73 0
      src/main/java/com/nuojing/media/callback/controller/MediaCallbackController.java
  61. 16 0
      src/main/java/com/nuojing/media/callback/domain/vo/CallbackResultVO.java
  62. 31 0
      src/main/java/com/nuojing/media/common/enums/MediaType.java
  63. 17 0
      src/main/resources/application-dev.yml
  64. 17 0
      src/main/resources/application-prod.yml
  65. 17 0
      src/main/resources/application-test.yml
  66. 22 0
      src/main/resources/application.yml
  67. 224 0
      src/main/resources/db/init.sql
  68. 53 0
      src/main/resources/db/patch-authorization-menu.sql
  69. 90 0
      src/main/resources/db/patch-media-auth.sql
  70. 18 0
      src/main/resources/db/patch-user-management.sql
  71. 50 0
      src/main/resources/mapper/admin/DeptMapper.xml
  72. 77 0
      src/main/resources/mapper/admin/MenuMapper.xml
  73. 78 0
      src/main/resources/mapper/admin/RoleMapper.xml
  74. 128 0
      src/main/resources/mapper/admin/UserMapper.xml
  75. 81 0
      src/main/resources/mapper/media/auth/MediaAuthMapper.xml

+ 11 - 0
.gitignore

@@ -0,0 +1,11 @@
+target/
+
+.idea/
+*.iml
+
+.DS_Store
+
+*.log
+logs/
+
+/.mvn/wrapper/maven-wrapper.jar

+ 129 - 0
pom.xml

@@ -0,0 +1,129 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.nuojing</groupId>
+    <artifactId>nuojing-data-service</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>nuojing-data-service</name>
+    <description>Nuojing data service backend</description>
+
+    <properties>
+        <java.version>17</java.version>
+        <spring-boot.version>3.3.5</spring-boot.version>
+        <mybatis-spring-boot.version>3.0.3</mybatis-spring-boot.version>
+        <lombok.version>1.18.42</lombok.version>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>${mybatis-spring-boot.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-crypto</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>${lombok.version}</version>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>${spring-boot.version}</version>
+                <configuration>
+                    <profiles>
+                        <profile>${profileActive}</profile>
+                    </profiles>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.13.0</version>
+                <configuration>
+                    <release>${java.version}</release>
+                    <annotationProcessorPaths>
+                        <path>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                            <version>${lombok.version}</version>
+                        </path>
+                    </annotationProcessorPaths>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <profiles>
+        <profile>
+            <id>dev</id>
+            <properties>
+                <profileActive>dev</profileActive>
+            </properties>
+        </profile>
+        <profile>
+            <id>test</id>
+            <properties>
+                <profileActive>test</profileActive>
+            </properties>
+        </profile>
+        <profile>
+            <id>prod</id>
+            <properties>
+                <profileActive>prod</profileActive>
+            </properties>
+        </profile>
+    </profiles>
+</project>

+ 14 - 0
src/main/java/com/nuojing/admin/NuojingDataAdminApplication.java

@@ -0,0 +1,14 @@
+package com.nuojing.admin;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@MapperScan({"com.nuojing.admin.mapper", "com.nuojing.media.auth.mapper"})
+@SpringBootApplication(scanBasePackages = "com.nuojing")
+public class NuojingDataAdminApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(NuojingDataAdminApplication.class, args);
+    }
+}

+ 23 - 0
src/main/java/com/nuojing/admin/common/ApiResponse.java

@@ -0,0 +1,23 @@
+package com.nuojing.admin.common;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiResponse<T> {
+
+    private Integer code;
+    private String message;
+    private T data;
+
+    public static <T> ApiResponse<T> ok(T data) {
+        return new ApiResponse<>(200, "success", data);
+    }
+
+    public static <T> ApiResponse<T> fail(Integer code, String message) {
+        return new ApiResponse<>(code, message, null);
+    }
+}

+ 14 - 0
src/main/java/com/nuojing/admin/common/BusinessException.java

@@ -0,0 +1,14 @@
+package com.nuojing.admin.common;
+
+import lombok.Getter;
+
+@Getter
+public class BusinessException extends RuntimeException {
+
+    private final Integer code;
+
+    public BusinessException(Integer code, String message) {
+        super(message);
+        this.code = code;
+    }
+}

+ 32 - 0
src/main/java/com/nuojing/admin/common/GlobalExceptionHandler.java

@@ -0,0 +1,32 @@
+package com.nuojing.admin.common;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler(BusinessException.class)
+    public ApiResponse<Void> handleBusinessException(BusinessException ex) {
+        log.warn("Business exception: {}", ex.getMessage());
+        return ApiResponse.fail(ex.getCode(), ex.getMessage());
+    }
+
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
+        String message = ex.getBindingResult().getFieldErrors().stream()
+                .findFirst()
+                .map(error -> error.getField() + " " + error.getDefaultMessage())
+                .orElse("参数错误");
+        return ApiResponse.fail(400, message);
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ApiResponse<Void> handleException(Exception ex) {
+        log.error("Unexpected exception", ex);
+        return ApiResponse.fail(500, ex.getMessage());
+    }
+}

+ 15 - 0
src/main/java/com/nuojing/admin/config/AuthProperties.java

@@ -0,0 +1,15 @@
+package com.nuojing.admin.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "app.auth")
+public class AuthProperties {
+
+    private String tokenHeader = "Authorization";
+    private String tokenPrefix = "Bearer";
+    private Long tokenTtlHours = 24L;
+}

+ 15 - 0
src/main/java/com/nuojing/admin/config/PasswordConfig.java

@@ -0,0 +1,15 @@
+package com.nuojing.admin.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+@Configuration
+public class PasswordConfig {
+
+    @Bean
+    public PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+}

+ 27 - 0
src/main/java/com/nuojing/admin/config/RedisConfig.java

@@ -0,0 +1,27 @@
+package com.nuojing.admin.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+@Configuration
+public class RedisConfig {
+
+    @Bean
+    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
+        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
+        StringRedisSerializer stringSerializer = new StringRedisSerializer();
+        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
+
+        redisTemplate.setConnectionFactory(redisConnectionFactory);
+        redisTemplate.setKeySerializer(stringSerializer);
+        redisTemplate.setHashKeySerializer(stringSerializer);
+        redisTemplate.setValueSerializer(jsonSerializer);
+        redisTemplate.setHashValueSerializer(jsonSerializer);
+        redisTemplate.afterPropertiesSet();
+        return redisTemplate;
+    }
+}

+ 32 - 0
src/main/java/com/nuojing/admin/config/WebConfig.java

@@ -0,0 +1,32 @@
+package com.nuojing.admin.config;
+
+import com.nuojing.admin.interceptor.AuthInterceptor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+@RequiredArgsConstructor
+public class WebConfig implements WebMvcConfigurer {
+
+    private final AuthInterceptor authInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(authInterceptor)
+                .addPathPatterns("/**")
+                .excludePathPatterns("/auth/login", "/callback/**", "/media/callback/**");
+    }
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOriginPatterns("*")
+                .allowedMethods("*")
+                .allowedHeaders("*")
+                .allowCredentials(true)
+                .maxAge(3600);
+    }
+}

+ 20 - 0
src/main/java/com/nuojing/admin/context/CurrentUser.java

@@ -0,0 +1,20 @@
+package com.nuojing.admin.context;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class CurrentUser implements Serializable {
+
+    private Long userId;
+    private String username;
+    private String nickname;
+    private Long deptId;
+    private List<String> roleCodes;
+}

+ 21 - 0
src/main/java/com/nuojing/admin/context/CurrentUserContext.java

@@ -0,0 +1,21 @@
+package com.nuojing.admin.context;
+
+public final class CurrentUserContext {
+
+    private static final ThreadLocal<CurrentUser> HOLDER = new ThreadLocal<>();
+
+    private CurrentUserContext() {
+    }
+
+    public static void set(CurrentUser currentUser) {
+        HOLDER.set(currentUser);
+    }
+
+    public static CurrentUser get() {
+        return HOLDER.get();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+}

+ 60 - 0
src/main/java/com/nuojing/admin/controller/AuthController.java

@@ -0,0 +1,60 @@
+package com.nuojing.admin.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.admin.config.AuthProperties;
+import com.nuojing.admin.domain.dto.LoginRequest;
+import com.nuojing.admin.domain.vo.LoginResponse;
+import com.nuojing.admin.domain.vo.MenuVO;
+import com.nuojing.admin.domain.vo.UserInfoVO;
+import com.nuojing.admin.service.AuthService;
+import com.nuojing.admin.service.MenuService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+    private final AuthService authService;
+    private final MenuService menuService;
+    private final AuthProperties authProperties;
+
+    @PostMapping("/login")
+    public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
+        return ApiResponse.ok(authService.login(request));
+    }
+
+    @PostMapping("/logout")
+    public ApiResponse<Void> logout(HttpServletRequest request) {
+        authService.logout(resolveToken(request));
+        return ApiResponse.ok(null);
+    }
+
+    @GetMapping("/profile")
+    public ApiResponse<UserInfoVO> profile() {
+        return ApiResponse.ok(authService.profile());
+    }
+
+    @GetMapping("/menus")
+    public ApiResponse<List<MenuVO>> menus() {
+        return ApiResponse.ok(menuService.userMenuTree(authService.profile().getId()));
+    }
+
+    private String resolveToken(HttpServletRequest request) {
+        String header = request.getHeader(authProperties.getTokenHeader());
+        if (header == null) {
+            return null;
+        }
+        String prefix = authProperties.getTokenPrefix() + " ";
+        return header.startsWith(prefix) ? header.substring(prefix.length()) : header;
+    }
+}

+ 41 - 0
src/main/java/com/nuojing/admin/controller/DeptController.java

@@ -0,0 +1,41 @@
+package com.nuojing.admin.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.admin.domain.dto.DeptRequest;
+import com.nuojing.admin.domain.vo.DeptVO;
+import com.nuojing.admin.service.DeptService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/depts")
+@RequiredArgsConstructor
+public class DeptController {
+
+    private final DeptService deptService;
+
+    @GetMapping("/tree")
+    public ApiResponse<List<DeptVO>> tree() {
+        return ApiResponse.ok(deptService.tree());
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@RequestBody DeptRequest request) {
+        deptService.save(request);
+        return ApiResponse.ok(null);
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        deptService.delete(id);
+        return ApiResponse.ok(null);
+    }
+}

+ 41 - 0
src/main/java/com/nuojing/admin/controller/MenuController.java

@@ -0,0 +1,41 @@
+package com.nuojing.admin.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.admin.domain.dto.MenuRequest;
+import com.nuojing.admin.domain.vo.MenuVO;
+import com.nuojing.admin.service.MenuService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/menus")
+@RequiredArgsConstructor
+public class MenuController {
+
+    private final MenuService menuService;
+
+    @GetMapping("/tree")
+    public ApiResponse<List<MenuVO>> tree() {
+        return ApiResponse.ok(menuService.tree());
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@RequestBody MenuRequest request) {
+        menuService.save(request);
+        return ApiResponse.ok(null);
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        menuService.delete(id);
+        return ApiResponse.ok(null);
+    }
+}

+ 51 - 0
src/main/java/com/nuojing/admin/controller/RoleController.java

@@ -0,0 +1,51 @@
+package com.nuojing.admin.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.admin.domain.dto.RoleRequest;
+import com.nuojing.admin.domain.entity.Role;
+import com.nuojing.admin.service.RoleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/roles")
+@RequiredArgsConstructor
+public class RoleController {
+
+    private final RoleService roleService;
+
+    @GetMapping
+    public ApiResponse<List<Role>> list() {
+        return ApiResponse.ok(roleService.list());
+    }
+
+    @GetMapping("/{id}")
+    public ApiResponse<Role> detail(@PathVariable("id") Long id) {
+        return ApiResponse.ok(roleService.detail(id));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@RequestBody RoleRequest request) {
+        roleService.save(request);
+        return ApiResponse.ok(null);
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        roleService.delete(id);
+        return ApiResponse.ok(null);
+    }
+
+    @GetMapping("/{id}/menus")
+    public ApiResponse<List<Long>> menuIds(@PathVariable("id") Long id) {
+        return ApiResponse.ok(roleService.menuIds(id));
+    }
+}

+ 51 - 0
src/main/java/com/nuojing/admin/controller/UserController.java

@@ -0,0 +1,51 @@
+package com.nuojing.admin.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.admin.domain.dto.UserRequest;
+import com.nuojing.admin.domain.vo.UserVO;
+import com.nuojing.admin.service.UserService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/users")
+@RequiredArgsConstructor
+public class UserController {
+
+    private final UserService userService;
+
+    @GetMapping
+    public ApiResponse<List<UserVO>> list() {
+        return ApiResponse.ok(userService.list());
+    }
+
+    @GetMapping("/{id}")
+    public ApiResponse<UserVO> detail(@PathVariable("id") Long id) {
+        return ApiResponse.ok(userService.detail(id));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@RequestBody UserRequest request) {
+        userService.save(request);
+        return ApiResponse.ok(null);
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        userService.delete(id);
+        return ApiResponse.ok(null);
+    }
+
+    @GetMapping("/{id}/roles")
+    public ApiResponse<List<Long>> roleIds(@PathVariable("id") Long id) {
+        return ApiResponse.ok(userService.roleIds(id));
+    }
+}

+ 13 - 0
src/main/java/com/nuojing/admin/domain/dto/DeptRequest.java

@@ -0,0 +1,13 @@
+package com.nuojing.admin.domain.dto;
+
+import lombok.Data;
+
+@Data
+public class DeptRequest {
+
+    private Long id;
+    private Long parentId;
+    private String deptName;
+    private Integer sort;
+    private Integer status;
+}

+ 14 - 0
src/main/java/com/nuojing/admin/domain/dto/LoginRequest.java

@@ -0,0 +1,14 @@
+package com.nuojing.admin.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class LoginRequest {
+
+    @NotBlank
+    private String username;
+
+    @NotBlank
+    private String password;
+}

+ 20 - 0
src/main/java/com/nuojing/admin/domain/dto/MenuRequest.java

@@ -0,0 +1,20 @@
+package com.nuojing.admin.domain.dto;
+
+import lombok.Data;
+
+@Data
+public class MenuRequest {
+
+    private Long id;
+    private Long parentId;
+    private String menuName;
+    private String path;
+    private String component;
+    private String routeName;
+    private String icon;
+    private String permission;
+    private Integer menuType;
+    private Integer sort;
+    private Integer hidden;
+    private Integer status;
+}

+ 16 - 0
src/main/java/com/nuojing/admin/domain/dto/RoleRequest.java

@@ -0,0 +1,16 @@
+package com.nuojing.admin.domain.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class RoleRequest {
+
+    private Long id;
+    private String roleName;
+    private String roleCode;
+    private Integer sort;
+    private Integer status;
+    private List<Long> menuIds;
+}

+ 17 - 0
src/main/java/com/nuojing/admin/domain/dto/UserRequest.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.domain.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class UserRequest {
+
+    private Long id;
+    private String username;
+    private String password;
+    private String nickname;
+    private Long deptId;
+    private Integer status;
+    private List<Long> roleIds;
+}

+ 17 - 0
src/main/java/com/nuojing/admin/domain/entity/Dept.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class Dept {
+
+    private Long id;
+    private Long parentId;
+    private String deptName;
+    private Integer sort;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 24 - 0
src/main/java/com/nuojing/admin/domain/entity/Menu.java

@@ -0,0 +1,24 @@
+package com.nuojing.admin.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class Menu {
+
+    private Long id;
+    private Long parentId;
+    private String menuName;
+    private String path;
+    private String component;
+    private String routeName;
+    private String icon;
+    private String permission;
+    private Integer menuType;
+    private Integer sort;
+    private Integer hidden;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 17 - 0
src/main/java/com/nuojing/admin/domain/entity/Role.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class Role {
+
+    private Long id;
+    private String roleName;
+    private String roleCode;
+    private Integer sort;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 18 - 0
src/main/java/com/nuojing/admin/domain/entity/User.java

@@ -0,0 +1,18 @@
+package com.nuojing.admin.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class User {
+
+    private Long id;
+    private String username;
+    private String password;
+    private String nickname;
+    private Long deptId;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 17 - 0
src/main/java/com/nuojing/admin/domain/vo/DeptVO.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.domain.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class DeptVO {
+
+    private Long id;
+    private Long parentId;
+    private String deptName;
+    private Integer sort;
+    private Integer status;
+    private List<DeptVO> children = new ArrayList<>();
+}

+ 13 - 0
src/main/java/com/nuojing/admin/domain/vo/LoginResponse.java

@@ -0,0 +1,13 @@
+package com.nuojing.admin.domain.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class LoginResponse {
+
+    private String token;
+    private UserInfoVO user;
+    private List<MenuVO> menus;
+}

+ 24 - 0
src/main/java/com/nuojing/admin/domain/vo/MenuVO.java

@@ -0,0 +1,24 @@
+package com.nuojing.admin.domain.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class MenuVO {
+
+    private Long id;
+    private Long parentId;
+    private String title;
+    private String path;
+    private String component;
+    private String name;
+    private String icon;
+    private String permission;
+    private Integer menuType;
+    private Integer sort;
+    private Integer status;
+    private Boolean hidden;
+    private List<MenuVO> children = new ArrayList<>();
+}

+ 15 - 0
src/main/java/com/nuojing/admin/domain/vo/UserInfoVO.java

@@ -0,0 +1,15 @@
+package com.nuojing.admin.domain.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class UserInfoVO {
+
+    private Long id;
+    private String username;
+    private String nickname;
+    private Long deptId;
+    private List<String> roles;
+}

+ 21 - 0
src/main/java/com/nuojing/admin/domain/vo/UserVO.java

@@ -0,0 +1,21 @@
+package com.nuojing.admin.domain.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Data
+public class UserVO {
+
+    private Long id;
+    private String username;
+    private String nickname;
+    private Long deptId;
+    private String deptName;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+    private List<Long> roleIds;
+    private List<String> roleNames;
+}

+ 52 - 0
src/main/java/com/nuojing/admin/interceptor/AuthInterceptor.java

@@ -0,0 +1,52 @@
+package com.nuojing.admin.interceptor;
+
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.admin.config.AuthProperties;
+import com.nuojing.admin.context.CurrentUser;
+import com.nuojing.admin.context.CurrentUserContext;
+import com.nuojing.admin.service.AuthService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+@Component
+@RequiredArgsConstructor
+public class AuthInterceptor implements HandlerInterceptor {
+
+    private final AuthProperties authProperties;
+    private final AuthService authService;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            return true;
+        }
+        String token = resolveToken(request);
+        if (!StringUtils.hasText(token)) {
+            throw new BusinessException(401, "未登录");
+        }
+        CurrentUser currentUser = authService.getCurrentUser(token);
+        if (currentUser == null) {
+            throw new BusinessException(401, "登录已过期");
+        }
+        CurrentUserContext.set(currentUser);
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        CurrentUserContext.clear();
+    }
+
+    private String resolveToken(HttpServletRequest request) {
+        String header = request.getHeader(authProperties.getTokenHeader());
+        if (!StringUtils.hasText(header)) {
+            return null;
+        }
+        String prefix = authProperties.getTokenPrefix() + " ";
+        return header.startsWith(prefix) ? header.substring(prefix.length()) : header;
+    }
+}

+ 18 - 0
src/main/java/com/nuojing/admin/mapper/DeptMapper.java

@@ -0,0 +1,18 @@
+package com.nuojing.admin.mapper;
+
+import com.nuojing.admin.domain.entity.Dept;
+
+import java.util.List;
+
+public interface DeptMapper {
+
+    List<Dept> selectAll();
+
+    Dept selectById(Long id);
+
+    int insert(Dept dept);
+
+    int update(Dept dept);
+
+    int deleteById(Long id);
+}

+ 20 - 0
src/main/java/com/nuojing/admin/mapper/MenuMapper.java

@@ -0,0 +1,20 @@
+package com.nuojing.admin.mapper;
+
+import com.nuojing.admin.domain.entity.Menu;
+
+import java.util.List;
+
+public interface MenuMapper {
+
+    List<Menu> selectAll();
+
+    List<Menu> selectByUserId(Long userId);
+
+    Menu selectById(Long id);
+
+    int insert(Menu menu);
+
+    int update(Menu menu);
+
+    int deleteById(Long id);
+}

+ 27 - 0
src/main/java/com/nuojing/admin/mapper/RoleMapper.java

@@ -0,0 +1,27 @@
+package com.nuojing.admin.mapper;
+
+import com.nuojing.admin.domain.entity.Role;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface RoleMapper {
+
+    List<Role> selectAll();
+
+    List<Role> selectByUserId(@Param("userId") Long userId);
+
+    Role selectById(@Param("id") Long id);
+
+    int insert(Role role);
+
+    int update(Role role);
+
+    int deleteById(@Param("id") Long id);
+
+    List<Long> selectMenuIdsByRoleId(@Param("roleId") Long roleId);
+
+    int deleteRoleMenus(@Param("roleId") Long roleId);
+
+    int insertRoleMenus(@Param("roleId") Long roleId, @Param("menuIds") List<Long> menuIds);
+}

+ 30 - 0
src/main/java/com/nuojing/admin/mapper/UserMapper.java

@@ -0,0 +1,30 @@
+package com.nuojing.admin.mapper;
+
+import com.nuojing.admin.domain.entity.User;
+import com.nuojing.admin.domain.vo.UserVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface UserMapper {
+
+    User selectByUsername(String username);
+
+    User selectById(@Param("id") Long id);
+
+    UserVO selectVOById(@Param("id") Long id);
+
+    List<UserVO> selectAll();
+
+    int insert(User user);
+
+    int update(User user);
+
+    int deleteById(@Param("id") Long id);
+
+    List<Long> selectRoleIdsByUserId(@Param("userId") Long userId);
+
+    void deleteUserRoles(@Param("userId") Long userId);
+
+    void insertUserRoles(@Param("userId") Long userId, @Param("roleIds") List<Long> roleIds);
+}

+ 17 - 0
src/main/java/com/nuojing/admin/service/AuthService.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.service;
+
+import com.nuojing.admin.context.CurrentUser;
+import com.nuojing.admin.domain.dto.LoginRequest;
+import com.nuojing.admin.domain.vo.LoginResponse;
+import com.nuojing.admin.domain.vo.UserInfoVO;
+
+public interface AuthService {
+
+    LoginResponse login(LoginRequest request);
+
+    void logout(String token);
+
+    CurrentUser getCurrentUser(String token);
+
+    UserInfoVO profile();
+}

+ 15 - 0
src/main/java/com/nuojing/admin/service/DeptService.java

@@ -0,0 +1,15 @@
+package com.nuojing.admin.service;
+
+import com.nuojing.admin.domain.dto.DeptRequest;
+import com.nuojing.admin.domain.vo.DeptVO;
+
+import java.util.List;
+
+public interface DeptService {
+
+    List<DeptVO> tree();
+
+    void save(DeptRequest request);
+
+    void delete(Long id);
+}

+ 17 - 0
src/main/java/com/nuojing/admin/service/MenuService.java

@@ -0,0 +1,17 @@
+package com.nuojing.admin.service;
+
+import com.nuojing.admin.domain.dto.MenuRequest;
+import com.nuojing.admin.domain.vo.MenuVO;
+
+import java.util.List;
+
+public interface MenuService {
+
+    List<MenuVO> tree();
+
+    List<MenuVO> userMenuTree(Long userId);
+
+    void save(MenuRequest request);
+
+    void delete(Long id);
+}

+ 19 - 0
src/main/java/com/nuojing/admin/service/RoleService.java

@@ -0,0 +1,19 @@
+package com.nuojing.admin.service;
+
+import com.nuojing.admin.domain.dto.RoleRequest;
+import com.nuojing.admin.domain.entity.Role;
+
+import java.util.List;
+
+public interface RoleService {
+
+    List<Role> list();
+
+    Role detail(Long id);
+
+    void save(RoleRequest request);
+
+    void delete(Long id);
+
+    List<Long> menuIds(Long roleId);
+}

+ 19 - 0
src/main/java/com/nuojing/admin/service/UserService.java

@@ -0,0 +1,19 @@
+package com.nuojing.admin.service;
+
+import com.nuojing.admin.domain.dto.UserRequest;
+import com.nuojing.admin.domain.vo.UserVO;
+
+import java.util.List;
+
+public interface UserService {
+
+    List<UserVO> list();
+
+    UserVO detail(Long id);
+
+    void save(UserRequest request);
+
+    void delete(Long id);
+
+    List<Long> roleIds(Long userId);
+}

+ 112 - 0
src/main/java/com/nuojing/admin/service/impl/AuthServiceImpl.java

@@ -0,0 +1,112 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.admin.config.AuthProperties;
+import com.nuojing.admin.context.CurrentUser;
+import com.nuojing.admin.context.CurrentUserContext;
+import com.nuojing.admin.domain.dto.LoginRequest;
+import com.nuojing.admin.domain.entity.Role;
+import com.nuojing.admin.domain.entity.User;
+import com.nuojing.admin.domain.vo.LoginResponse;
+import com.nuojing.admin.domain.vo.MenuVO;
+import com.nuojing.admin.domain.vo.UserInfoVO;
+import com.nuojing.admin.mapper.RoleMapper;
+import com.nuojing.admin.mapper.UserMapper;
+import com.nuojing.admin.service.AuthService;
+import com.nuojing.admin.service.MenuService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class AuthServiceImpl implements AuthService {
+
+    private static final String TOKEN_KEY_PREFIX = "admin:login:";
+
+    private final UserMapper userMapper;
+    private final RoleMapper roleMapper;
+    private final MenuService menuService;
+    private final RedisTemplate<String, Object> redisTemplate;
+    private final PasswordEncoder passwordEncoder;
+    private final AuthProperties authProperties;
+
+    @Override
+    public LoginResponse login(LoginRequest request) {
+        User user = userMapper.selectByUsername(request.getUsername());
+        if (user == null || user.getStatus() == null || user.getStatus() != 1) {
+            throw new BusinessException(400, "账号不存在或已禁用");
+        }
+        if (!matches(request.getPassword(), user.getPassword())) {
+            throw new BusinessException(400, "用户名或密码错误");
+        }
+        List<Role> roles = roleMapper.selectByUserId(user.getId());
+        List<String> roleCodes = roles.stream().map(Role::getRoleCode).toList();
+        CurrentUser currentUser = new CurrentUser(user.getId(), user.getUsername(), user.getNickname(), user.getDeptId(), roleCodes);
+        String token = UUID.randomUUID().toString().replace("-", "");
+        redisTemplate.opsForValue().set(tokenKey(token), currentUser, Duration.ofHours(authProperties.getTokenTtlHours()));
+
+        LoginResponse response = new LoginResponse();
+        response.setToken(token);
+        response.setUser(toUserInfo(user, roleCodes));
+        response.setMenus(menuService.userMenuTree(user.getId()));
+        return response;
+    }
+
+    @Override
+    public void logout(String token) {
+        if (StringUtils.hasText(token)) {
+            redisTemplate.delete(tokenKey(token));
+        }
+    }
+
+    @Override
+    public CurrentUser getCurrentUser(String token) {
+        Object value = redisTemplate.opsForValue().get(tokenKey(token));
+        if (value instanceof CurrentUser currentUser) {
+            redisTemplate.expire(tokenKey(token), Duration.ofHours(authProperties.getTokenTtlHours()));
+            return currentUser;
+        }
+        return null;
+    }
+
+    @Override
+    public UserInfoVO profile() {
+        CurrentUser currentUser = CurrentUserContext.get();
+        if (currentUser == null) {
+            throw new BusinessException(401, "未登录");
+        }
+        User user = userMapper.selectById(currentUser.getUserId());
+        return toUserInfo(user, currentUser.getRoleCodes());
+    }
+
+    private boolean matches(String rawPassword, String encodedPassword) {
+        if (!StringUtils.hasText(encodedPassword)) {
+            return false;
+        }
+        if (encodedPassword.startsWith("$2a$") || encodedPassword.startsWith("$2b$") || encodedPassword.startsWith("$2y$")) {
+            return passwordEncoder.matches(rawPassword, encodedPassword);
+        }
+        return rawPassword.equals(encodedPassword);
+    }
+
+    private UserInfoVO toUserInfo(User user, List<String> roleCodes) {
+        UserInfoVO vo = new UserInfoVO();
+        vo.setId(user.getId());
+        vo.setUsername(user.getUsername());
+        vo.setNickname(user.getNickname());
+        vo.setDeptId(user.getDeptId());
+        vo.setRoles(roleCodes);
+        return vo;
+    }
+
+    private String tokenKey(String token) {
+        return TOKEN_KEY_PREFIX + token;
+    }
+}

+ 70 - 0
src/main/java/com/nuojing/admin/service/impl/DeptServiceImpl.java

@@ -0,0 +1,70 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.domain.dto.DeptRequest;
+import com.nuojing.admin.domain.entity.Dept;
+import com.nuojing.admin.domain.vo.DeptVO;
+import com.nuojing.admin.mapper.DeptMapper;
+import com.nuojing.admin.service.DeptService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class DeptServiceImpl implements DeptService {
+
+    private final DeptMapper deptMapper;
+
+    @Override
+    public List<DeptVO> tree() {
+        Map<Long, DeptVO> nodeMap = new LinkedHashMap<>();
+        deptMapper.selectAll().stream()
+                .sorted(Comparator.comparing(Dept::getSort, Comparator.nullsLast(Integer::compareTo)))
+                .forEach(dept -> nodeMap.put(dept.getId(), toVO(dept)));
+        List<DeptVO> roots = new ArrayList<>();
+        nodeMap.values().forEach(dept -> {
+            DeptVO parent = nodeMap.get(dept.getParentId());
+            if (parent == null || dept.getParentId() == null || dept.getParentId() == 0) {
+                roots.add(dept);
+            } else {
+                parent.getChildren().add(dept);
+            }
+        });
+        return roots;
+    }
+
+    @Override
+    public void save(DeptRequest request) {
+        Dept dept = new Dept();
+        dept.setId(request.getId());
+        dept.setParentId(request.getParentId() == null ? 0L : request.getParentId());
+        dept.setDeptName(request.getDeptName());
+        dept.setSort(request.getSort());
+        dept.setStatus(request.getStatus() == null ? 1 : request.getStatus());
+        if (request.getId() == null) {
+            deptMapper.insert(dept);
+        } else {
+            deptMapper.update(dept);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        deptMapper.deleteById(id);
+    }
+
+    private DeptVO toVO(Dept dept) {
+        DeptVO vo = new DeptVO();
+        vo.setId(dept.getId());
+        vo.setParentId(dept.getParentId());
+        vo.setDeptName(dept.getDeptName());
+        vo.setSort(dept.getSort());
+        vo.setStatus(dept.getStatus());
+        return vo;
+    }
+}

+ 103 - 0
src/main/java/com/nuojing/admin/service/impl/MenuServiceImpl.java

@@ -0,0 +1,103 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.domain.dto.MenuRequest;
+import com.nuojing.admin.domain.entity.Menu;
+import com.nuojing.admin.domain.entity.Role;
+import com.nuojing.admin.domain.vo.MenuVO;
+import com.nuojing.admin.mapper.MenuMapper;
+import com.nuojing.admin.mapper.RoleMapper;
+import com.nuojing.admin.service.MenuService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashSet;
+
+@Service
+@RequiredArgsConstructor
+public class MenuServiceImpl implements MenuService {
+
+    private final MenuMapper menuMapper;
+    private final RoleMapper roleMapper;
+
+    @Override
+    public List<MenuVO> tree() {
+        return MenuTreeBuilder.build(menuMapper.selectAll());
+    }
+
+    @Override
+    public List<MenuVO> userMenuTree(Long userId) {
+        boolean admin = roleMapper.selectByUserId(userId).stream()
+                .map(Role::getRoleCode)
+                .anyMatch("admin"::equals);
+        if (admin) {
+            return MenuTreeBuilder.build(menuMapper.selectAll().stream()
+                    .filter(menu -> menu.getStatus() != null && menu.getStatus() == 1)
+                    .toList());
+        }
+        return MenuTreeBuilder.build(withParentMenus(menuMapper.selectByUserId(userId)));
+    }
+
+    private List<Menu> withParentMenus(List<Menu> authorizedMenus) {
+        List<Menu> enabledMenus = menuMapper.selectAll().stream()
+                .filter(menu -> menu.getStatus() != null && menu.getStatus() == 1)
+                .toList();
+        Map<Long, Menu> menuMap = new LinkedHashMap<>();
+        enabledMenus.forEach(menu -> menuMap.put(menu.getId(), menu));
+
+        Set<Long> visibleIds = new HashSet<>();
+        authorizedMenus.stream()
+                .filter(menu -> menu.getStatus() != null && menu.getStatus() == 1)
+                .forEach(menu -> collectParentIds(menu, menuMap, visibleIds));
+
+        List<Menu> visibleMenus = new ArrayList<>();
+        enabledMenus.forEach(menu -> {
+            if (visibleIds.contains(menu.getId())) {
+                visibleMenus.add(menu);
+            }
+        });
+        return visibleMenus;
+    }
+
+    private void collectParentIds(Menu menu, Map<Long, Menu> menuMap, Set<Long> visibleIds) {
+        Menu current = menu;
+        while (current != null && visibleIds.add(current.getId())) {
+            Long parentId = current.getParentId();
+            if (parentId == null || parentId == 0) {
+                break;
+            }
+            current = menuMap.get(parentId);
+        }
+    }
+
+    @Override
+    public void save(MenuRequest request) {
+        Menu menu = new Menu();
+        menu.setId(request.getId());
+        menu.setParentId(request.getParentId() == null ? 0L : request.getParentId());
+        menu.setMenuName(request.getMenuName());
+        menu.setPath(request.getPath());
+        menu.setComponent(request.getComponent());
+        menu.setRouteName(request.getRouteName());
+        menu.setIcon(request.getIcon());
+        menu.setPermission(request.getPermission());
+        menu.setMenuType(request.getMenuType() == null ? 1 : request.getMenuType());
+        menu.setSort(request.getSort());
+        menu.setHidden(request.getHidden() == null ? 0 : request.getHidden());
+        menu.setStatus(request.getStatus() == null ? 1 : request.getStatus());
+        if (request.getId() == null) {
+            menuMapper.insert(menu);
+        } else {
+            menuMapper.update(menu);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        menuMapper.deleteById(id);
+    }
+}

+ 51 - 0
src/main/java/com/nuojing/admin/service/impl/MenuTreeBuilder.java

@@ -0,0 +1,51 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.domain.entity.Menu;
+import com.nuojing.admin.domain.vo.MenuVO;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class MenuTreeBuilder {
+
+    private MenuTreeBuilder() {
+    }
+
+    static List<MenuVO> build(List<Menu> menus) {
+        Map<Long, MenuVO> nodeMap = new LinkedHashMap<>();
+        menus.stream()
+                .sorted(Comparator.comparing(Menu::getSort, Comparator.nullsLast(Integer::compareTo)))
+                .forEach(menu -> nodeMap.put(menu.getId(), toVO(menu)));
+
+        List<MenuVO> roots = new ArrayList<>();
+        nodeMap.values().forEach(menu -> {
+            MenuVO parent = nodeMap.get(menu.getParentId());
+            if (parent == null || menu.getParentId() == null || menu.getParentId() == 0) {
+                roots.add(menu);
+            } else {
+                parent.getChildren().add(menu);
+            }
+        });
+        return roots;
+    }
+
+    private static MenuVO toVO(Menu menu) {
+        MenuVO vo = new MenuVO();
+        vo.setId(menu.getId());
+        vo.setParentId(menu.getParentId());
+        vo.setTitle(menu.getMenuName());
+        vo.setPath(menu.getPath());
+        vo.setComponent(menu.getComponent());
+        vo.setName(menu.getRouteName());
+        vo.setIcon(menu.getIcon());
+        vo.setPermission(menu.getPermission());
+        vo.setMenuType(menu.getMenuType());
+        vo.setSort(menu.getSort());
+        vo.setStatus(menu.getStatus());
+        vo.setHidden(menu.getHidden() != null && menu.getHidden() == 1);
+        return vo;
+    }
+}

+ 65 - 0
src/main/java/com/nuojing/admin/service/impl/RoleServiceImpl.java

@@ -0,0 +1,65 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.admin.domain.dto.RoleRequest;
+import com.nuojing.admin.domain.entity.Role;
+import com.nuojing.admin.mapper.RoleMapper;
+import com.nuojing.admin.service.RoleService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class RoleServiceImpl implements RoleService {
+
+    private final RoleMapper roleMapper;
+
+    @Override
+    public List<Role> list() {
+        return roleMapper.selectAll();
+    }
+
+    @Override
+    public Role detail(Long id) {
+        Role role = roleMapper.selectById(id);
+        if (role == null) {
+            throw new BusinessException(404, "角色不存在");
+        }
+        return role;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void save(RoleRequest request) {
+        Role role = new Role();
+        role.setId(request.getId());
+        role.setRoleName(request.getRoleName());
+        role.setRoleCode(request.getRoleCode());
+        role.setSort(request.getSort());
+        role.setStatus(request.getStatus() == null ? 1 : request.getStatus());
+        if (request.getId() == null) {
+            roleMapper.insert(role);
+        } else {
+            roleMapper.update(role);
+        }
+        roleMapper.deleteRoleMenus(role.getId());
+        if (request.getMenuIds() != null && !request.getMenuIds().isEmpty()) {
+            roleMapper.insertRoleMenus(role.getId(), request.getMenuIds());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        roleMapper.deleteRoleMenus(id);
+        roleMapper.deleteById(id);
+    }
+
+    @Override
+    public List<Long> menuIds(Long roleId) {
+        return roleMapper.selectMenuIdsByRoleId(roleId);
+    }
+}

+ 95 - 0
src/main/java/com/nuojing/admin/service/impl/UserServiceImpl.java

@@ -0,0 +1,95 @@
+package com.nuojing.admin.service.impl;
+
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.admin.domain.dto.UserRequest;
+import com.nuojing.admin.domain.entity.User;
+import com.nuojing.admin.domain.vo.UserVO;
+import com.nuojing.admin.mapper.UserMapper;
+import com.nuojing.admin.service.UserService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class UserServiceImpl implements UserService {
+
+    private final UserMapper userMapper;
+    private final PasswordEncoder passwordEncoder;
+
+    @Override
+    public List<UserVO> list() {
+        return userMapper.selectAll();
+    }
+
+    @Override
+    public UserVO detail(Long id) {
+        UserVO user = userMapper.selectVOById(id);
+        if (user == null) {
+            throw new BusinessException(404, "用户不存在");
+        }
+        user.setRoleIds(userMapper.selectRoleIdsByUserId(id));
+        return user;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void save(UserRequest request) {
+        validate(request);
+        User user = new User();
+        user.setId(request.getId());
+        user.setUsername(request.getUsername());
+        user.setNickname(request.getNickname());
+        user.setDeptId(request.getDeptId());
+        user.setStatus(request.getStatus() == null ? 1 : request.getStatus());
+        if (StringUtils.hasText(request.getPassword())) {
+            user.setPassword(passwordEncoder.encode(request.getPassword()));
+        }
+        if (request.getId() == null) {
+            if (!StringUtils.hasText(request.getPassword())) {
+                throw new BusinessException(400, "新增用户时密码不能为空");
+            }
+            userMapper.insert(user);
+        } else {
+            detail(request.getId());
+            userMapper.update(user);
+        }
+        userMapper.deleteUserRoles(user.getId());
+        if (request.getRoleIds() != null && !request.getRoleIds().isEmpty()) {
+            userMapper.insertUserRoles(user.getId(), request.getRoleIds());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        User user = userMapper.selectById(id);
+        if (user == null) {
+            throw new BusinessException(404, "用户不存在");
+        }
+        if ("admin".equals(user.getUsername())) {
+            throw new BusinessException(400, "内置管理员不能删除");
+        }
+        userMapper.deleteUserRoles(id);
+        userMapper.deleteById(id);
+    }
+
+    @Override
+    public List<Long> roleIds(Long userId) {
+        return userMapper.selectRoleIdsByUserId(userId);
+    }
+
+    private void validate(UserRequest request) {
+        if (!StringUtils.hasText(request.getUsername())) {
+            throw new BusinessException(400, "用户名不能为空");
+        }
+        User exists = userMapper.selectByUsername(request.getUsername());
+        if (exists != null && (request.getId() == null || !exists.getId().equals(request.getId()))) {
+            throw new BusinessException(400, "用户名已存在");
+        }
+    }
+}

+ 7 - 0
src/main/java/com/nuojing/dashboard/package-info.java

@@ -0,0 +1,7 @@
+/**
+ * Data presentation and aggregation modules.
+ *
+ * <p>Media integration belongs to {@code com.nuojing.media}; this package is
+ * reserved for dashboard query, aggregation, and display-facing APIs.</p>
+ */
+package com.nuojing.dashboard;

+ 88 - 0
src/main/java/com/nuojing/media/auth/client/BytedanceMediaClient.java

@@ -0,0 +1,88 @@
+package com.nuojing.media.auth.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.media.auth.domain.entity.MediaAuthConfig;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class BytedanceMediaClient {
+
+    private static final String TOKEN_URL = "https://ad.oceanengine.com/open_api/oauth2/access_token/";
+    private static final String ADVERTISER_LIST_URL = "https://ad.oceanengine.com/open_api/oauth2/advertiser/get/";
+    private static final String USER_INFO_URL = "https://api.oceanengine.com/open_api/2/user/info/";
+
+    private final ObjectMapper objectMapper;
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    public JsonNode exchangeToken(MediaAuthConfig config, String authCode) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("app_id", config.getAppId());
+        body.put("secret", config.getAppSecret());
+        body.put("auth_code", authCode);
+        return postJson(TOKEN_URL, body);
+    }
+
+    public JsonNode advertiserList(String accessToken) {
+        String url = UriComponentsBuilder.fromUriString(ADVERTISER_LIST_URL)
+                .queryParam("access_token", accessToken)
+                .build()
+                .encode()
+                .toUriString();
+        return parse(restTemplate.getForObject(url, String.class));
+    }
+
+    public JsonNode userInfo(String accessToken) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.set("Access-Token", accessToken);
+        ResponseEntity<String> response = restTemplate.exchange(USER_INFO_URL, HttpMethod.GET, new HttpEntity<>(headers), String.class);
+        return parse(response.getBody());
+    }
+
+    public JsonNode requireSuccess(JsonNode response, String action) {
+        if (response == null || response.isMissingNode() || response.isNull()) {
+            throw new BusinessException(500, action + "返回信息为空");
+        }
+        int code = response.path("code").asInt(-1);
+        if (code != 0) {
+            String message = response.path("message").asText(action + "失败");
+            throw new BusinessException(500, message);
+        }
+        JsonNode data = response.path("data");
+        if (data.isMissingNode() || data.isNull()) {
+            throw new BusinessException(500, action + "data信息为空");
+        }
+        return data;
+    }
+
+    private JsonNode postJson(String url, Map<String, Object> body) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        return parse(restTemplate.postForObject(url, new HttpEntity<>(body, headers), String.class));
+    }
+
+    private JsonNode parse(String response) {
+        if (!StringUtils.hasText(response)) {
+            return objectMapper.missingNode();
+        }
+        try {
+            return objectMapper.readTree(response);
+        } catch (Exception ex) {
+            throw new BusinessException(500, "媒体响应解析失败");
+        }
+    }
+}

+ 29 - 0
src/main/java/com/nuojing/media/auth/controller/MediaAuthController.java

@@ -0,0 +1,29 @@
+package com.nuojing.media.auth.controller;
+
+import com.nuojing.admin.common.ApiResponse;
+import com.nuojing.media.auth.domain.dto.AuthUrlRequest;
+import com.nuojing.media.auth.domain.vo.AuthUrlVO;
+import com.nuojing.media.auth.domain.vo.MediaAuthConfigVO;
+import com.nuojing.media.auth.service.MediaAuthService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+public class MediaAuthController {
+
+    private final MediaAuthService mediaAuthService;
+
+    @GetMapping({"/media/auth/configs", "/auth/authorizationConfigs"})
+    public ApiResponse<List<MediaAuthConfigVO>> authorizationConfigs() {
+        return ApiResponse.ok(mediaAuthService.configs());
+    }
+
+    @GetMapping({"/media/auth/url", "/auth/getAuthUrl"})
+    public ApiResponse<AuthUrlVO> getAuthUrl(AuthUrlRequest request) {
+        return ApiResponse.ok(mediaAuthService.getAuthUrl(request));
+    }
+}

+ 9 - 0
src/main/java/com/nuojing/media/auth/domain/dto/AuthUrlRequest.java

@@ -0,0 +1,9 @@
+package com.nuojing.media.auth.domain.dto;
+
+import lombok.Data;
+
+@Data
+public class AuthUrlRequest {
+
+    private Integer mediaId;
+}

+ 21 - 0
src/main/java/com/nuojing/media/auth/domain/entity/MediaAdvertiserAccount.java

@@ -0,0 +1,21 @@
+package com.nuojing.media.auth.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class MediaAdvertiserAccount {
+
+    private Long id;
+    private Integer mediaType;
+    private Long adminAdvertiserId;
+    private Long advertiserId;
+    private String advertiserName;
+    private String accountRole;
+    private Integer advertiserRole;
+    private Integer isValid;
+    private String rawResponse;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 25 - 0
src/main/java/com/nuojing/media/auth/domain/entity/MediaAuthAccount.java

@@ -0,0 +1,25 @@
+package com.nuojing.media.auth.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class MediaAuthAccount {
+
+    private Long id;
+    private Integer mediaType;
+    private Long appId;
+    private Long userId;
+    private Long adminAdvertiserId;
+    private String adminAdvertiserName;
+    private String email;
+    private String accountRole;
+    private Integer advertiserRole;
+    private Integer isValid;
+    private String accessToken;
+    private String refreshToken;
+    private String rawResponse;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 21 - 0
src/main/java/com/nuojing/media/auth/domain/entity/MediaAuthConfig.java

@@ -0,0 +1,21 @@
+package com.nuojing.media.auth.domain.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class MediaAuthConfig {
+
+    private Long id;
+    private Integer mediaType;
+    private Long appId;
+    private String appSecret;
+    private String clientSecret;
+    private String redirectUri;
+    private String callbackReturnUrl;
+    private String authUrl;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 15 - 0
src/main/java/com/nuojing/media/auth/domain/vo/AuthUrlVO.java

@@ -0,0 +1,15 @@
+package com.nuojing.media.auth.domain.vo;
+
+import lombok.Data;
+
+@Data
+public class AuthUrlVO {
+
+    private Integer mediaId;
+    private String mediaCode;
+    private String mediaName;
+    private Long appId;
+    private Long userId;
+    private String state;
+    private String authUrl;
+}

+ 15 - 0
src/main/java/com/nuojing/media/auth/domain/vo/MediaAuthConfigVO.java

@@ -0,0 +1,15 @@
+package com.nuojing.media.auth.domain.vo;
+
+import lombok.Data;
+
+@Data
+public class MediaAuthConfigVO {
+
+    private Integer mediaId;
+    private String mediaCode;
+    private String mediaName;
+    private Long appId;
+    private String redirectUri;
+    private String callbackReturnUrl;
+    private Boolean configured;
+}

+ 19 - 0
src/main/java/com/nuojing/media/auth/mapper/MediaAuthMapper.java

@@ -0,0 +1,19 @@
+package com.nuojing.media.auth.mapper;
+
+import com.nuojing.media.auth.domain.entity.MediaAdvertiserAccount;
+import com.nuojing.media.auth.domain.entity.MediaAuthAccount;
+import com.nuojing.media.auth.domain.entity.MediaAuthConfig;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface MediaAuthMapper {
+
+    List<MediaAuthConfig> selectActiveConfigs(@Param("mediaType") Integer mediaType);
+
+    MediaAuthConfig selectActiveConfigByAppId(@Param("mediaType") Integer mediaType, @Param("appId") Long appId);
+
+    int replaceAuthAccount(MediaAuthAccount account);
+
+    int replaceAdvertiserAccount(MediaAdvertiserAccount account);
+}

+ 20 - 0
src/main/java/com/nuojing/media/auth/service/MediaAuthService.java

@@ -0,0 +1,20 @@
+package com.nuojing.media.auth.service;
+
+import com.nuojing.media.auth.domain.dto.AuthUrlRequest;
+import com.nuojing.media.auth.domain.vo.AuthUrlVO;
+import com.nuojing.media.auth.domain.vo.MediaAuthConfigVO;
+import com.nuojing.media.callback.domain.vo.CallbackResultVO;
+
+import java.util.List;
+import java.util.Map;
+
+public interface MediaAuthService {
+
+    List<MediaAuthConfigVO> configs();
+
+    AuthUrlVO getAuthUrl(AuthUrlRequest request);
+
+    CallbackResultVO handleCallback(Integer mediaId, Map<String, String> params);
+
+    String buildReturnUrl(CallbackResultVO result);
+}

+ 304 - 0
src/main/java/com/nuojing/media/auth/service/impl/MediaAuthServiceImpl.java

@@ -0,0 +1,304 @@
+package com.nuojing.media.auth.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nuojing.admin.common.BusinessException;
+import com.nuojing.admin.context.CurrentUser;
+import com.nuojing.admin.context.CurrentUserContext;
+import com.nuojing.media.auth.client.BytedanceMediaClient;
+import com.nuojing.media.auth.domain.dto.AuthUrlRequest;
+import com.nuojing.media.auth.domain.entity.MediaAdvertiserAccount;
+import com.nuojing.media.auth.domain.entity.MediaAuthAccount;
+import com.nuojing.media.auth.domain.entity.MediaAuthConfig;
+import com.nuojing.media.auth.domain.vo.AuthUrlVO;
+import com.nuojing.media.auth.domain.vo.MediaAuthConfigVO;
+import com.nuojing.media.auth.mapper.MediaAuthMapper;
+import com.nuojing.media.auth.service.MediaAuthService;
+import com.nuojing.media.callback.domain.vo.CallbackResultVO;
+import com.nuojing.media.common.enums.MediaType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MediaAuthServiceImpl implements MediaAuthService {
+
+    private final MediaAuthMapper mediaAuthMapper;
+    private final BytedanceMediaClient bytedanceMediaClient;
+    private final ObjectMapper objectMapper;
+
+    @Override
+    public List<MediaAuthConfigVO> configs() {
+        return mediaAuthMapper.selectActiveConfigs(null).stream().map(config -> {
+            MediaType media = MediaType.ofMediaId(config.getMediaType());
+            MediaAuthConfigVO vo = new MediaAuthConfigVO();
+            vo.setMediaId(media.getMediaId());
+            vo.setMediaCode(media.getCode());
+            vo.setMediaName(media.getName());
+            vo.setAppId(config.getAppId());
+            vo.setRedirectUri(config.getRedirectUri());
+            vo.setCallbackReturnUrl(config.getCallbackReturnUrl());
+            vo.setConfigured(StringUtils.hasText(config.getAuthUrl()) && StringUtils.hasText(config.getAppSecret()));
+            return vo;
+        }).toList();
+    }
+
+    @Override
+    public AuthUrlVO getAuthUrl(AuthUrlRequest request) {
+        if (request.getMediaId() == null) {
+            throw new BusinessException(400, "请输入媒体类型");
+        }
+        MediaType media = MediaType.ofMediaId(request.getMediaId());
+        if (media != MediaType.BYTEDANCE) {
+            throw new BusinessException(400, media.getName() + "授权暂未接入");
+        }
+        MediaAuthConfig config = findConfig(media);
+        if (config == null || !StringUtils.hasText(config.getAuthUrl())) {
+            throw new BusinessException(400, media.getName() + "授权配置未完成");
+        }
+
+        Long userId = currentUserId();
+        String state = buildState(userId, config.getAppId());
+        AuthUrlVO vo = new AuthUrlVO();
+        vo.setMediaId(media.getMediaId());
+        vo.setMediaCode(media.getCode());
+        vo.setMediaName(media.getName());
+        vo.setAppId(config.getAppId());
+        vo.setUserId(userId);
+        vo.setState(state);
+        vo.setAuthUrl(appendState(config.getAuthUrl(), state));
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CallbackResultVO handleCallback(Integer mediaId, Map<String, String> params) {
+        MediaType media = MediaType.ofMediaId(mediaId);
+        CallbackResultVO result = new CallbackResultVO();
+        result.setMediaType(media.getMediaId());
+        result.setBindStatus(2);
+        result.setState(params.get("state"));
+        result.setTime(System.currentTimeMillis());
+        result.setCallbackReturnUrl(callbackReturnUrl(media));
+        try {
+            if (media != MediaType.BYTEDANCE) {
+                throw new BusinessException(400, media.getName() + "回调暂未接入");
+            }
+            validateCallbackParams(media, params);
+            Long adminAdvertiserId = handleBytedanceCallback(params);
+            result.setCode(200);
+            result.setAdminAdvertiserId(adminAdvertiserId);
+            result.setMessage(media.getName() + "授权回调已接收");
+            log.info("{} authorization callback received, state={}, params={}", media.getName(), params.get("state"), params);
+        } catch (Exception ex) {
+            result.setCode(500);
+            result.setMessage(ex.getMessage());
+            log.error("{} authorization callback failed, params={}", media.getName(), params, ex);
+        }
+        return result;
+    }
+
+    @Override
+    public String buildReturnUrl(CallbackResultVO result) {
+        String callbackReturnUrl = result.getCallbackReturnUrl();
+        if (!StringUtils.hasText(callbackReturnUrl)) {
+            callbackReturnUrl = callbackReturnUrl(MediaType.ofMediaId(result.getMediaType()));
+        }
+        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(callbackReturnUrl)
+                .queryParam("code", result.getCode())
+                .queryParam("mediaType", result.getMediaType())
+                .queryParam("bindStatus", result.getBindStatus())
+                .queryParam("time", result.getTime());
+        if (result.getAdminAdvertiserId() != null) {
+            builder.queryParam("adminAdvertiserId", result.getAdminAdvertiserId());
+        }
+        if (StringUtils.hasText(result.getState())) {
+            builder.queryParam("state", result.getState());
+        }
+        if (StringUtils.hasText(result.getMessage())) {
+            builder.queryParam(result.getCode() == 200 ? "message" : "msg", result.getMessage());
+        }
+        return builder.build().encode().toUriString();
+    }
+
+    private MediaAuthConfig findConfig(MediaType media) {
+        return mediaAuthMapper.selectActiveConfigs(media.getMediaId()).stream().findFirst().orElse(null);
+    }
+
+    private String callbackReturnUrl(MediaType media) {
+        return mediaAuthMapper.selectActiveConfigs(media.getMediaId()).stream()
+                .map(MediaAuthConfig::getCallbackReturnUrl)
+                .filter(StringUtils::hasText)
+                .findFirst()
+                .orElseThrow(() -> new BusinessException(400, media.getName() + "前端回跳地址未配置"));
+    }
+
+    private Long currentUserId() {
+        CurrentUser currentUser = CurrentUserContext.get();
+        return currentUser == null ? null : currentUser.getUserId();
+    }
+
+    private String buildState(Long userId, Long appId) {
+        return safe(userId) + "_" + safe(appId);
+    }
+
+    private String safe(Long value) {
+        return value == null ? "" : String.valueOf(value);
+    }
+
+    private String appendState(String authUrl, String state) {
+        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(authUrl);
+        builder.replaceQueryParam("state", state);
+        return builder.build().encode().toUriString();
+    }
+
+    private void validateCallbackParams(MediaType media, Map<String, String> params) {
+        if (!StringUtils.hasText(params.get("state"))) {
+            throw new BusinessException(400, "state不能为空");
+        }
+        if (media == MediaType.BYTEDANCE && !StringUtils.hasText(params.get("app_id"))) {
+            throw new BusinessException(400, "app_id不能为空");
+        }
+        if (media == MediaType.BYTEDANCE && !StringUtils.hasText(params.get("auth_code"))) {
+            throw new BusinessException(400, "auth_code不能为空");
+        }
+        if (media == MediaType.KUAISHOU && !StringUtils.hasText(params.get("auth_code"))) {
+            throw new BusinessException(400, "auth_code不能为空");
+        }
+        if (media == MediaType.TENCENT && !StringUtils.hasText(params.get("authorization_code"))) {
+            throw new BusinessException(400, "authorization_code不能为空");
+        }
+    }
+
+    private Long handleBytedanceCallback(Map<String, String> params) {
+        Long appId = parseLong(params.get("app_id"), "app_id");
+        MediaAuthConfig config = mediaAuthMapper.selectActiveConfigByAppId(MediaType.BYTEDANCE.getMediaId(), appId);
+        if (config == null) {
+            throw new BusinessException(400, "通过此appId未获取到相关配置信息,请联系技术");
+        }
+
+        JsonNode tokenResult = bytedanceMediaClient.exchangeToken(config, params.get("auth_code"));
+        JsonNode tokenData = bytedanceMediaClient.requireSuccess(tokenResult, "获取字节token");
+        String accessToken = tokenData.path("access_token").asText();
+        String refreshToken = tokenData.path("refresh_token").asText();
+        if (!StringUtils.hasText(accessToken)) {
+            throw new BusinessException(500, "获取字节token失败:access_token为空");
+        }
+
+        JsonNode advertiserResult = bytedanceMediaClient.advertiserList(accessToken);
+        JsonNode advertiserData = bytedanceMediaClient.requireSuccess(advertiserResult, "获取字节授权账户");
+        JsonNode advertisers = advertiserData.path("list");
+        if (!advertisers.isArray()) {
+            throw new BusinessException(500, "获取字节授权账户list信息为空");
+        }
+
+        Long adminAdvertiserId = null;
+        Long userId = parseStateUserId(params.get("state"));
+        for (JsonNode advertiser : advertisers) {
+            if ("CUSTOMER_ADMIN".equals(advertiser.path("account_role").asText())) {
+                adminAdvertiserId = advertiser.path("advertiser_id").asLong();
+                MediaAuthAccount authAccount = toAuthAccount(config, advertiser, accessToken, refreshToken, userId);
+                applyUserInfo(authAccount, accessToken);
+                mediaAuthMapper.replaceAuthAccount(authAccount);
+            }
+        }
+        if (adminAdvertiserId == null) {
+            throw new BusinessException(500, "未获取到字节管家账户");
+        }
+
+        for (JsonNode advertiser : advertisers) {
+            mediaAuthMapper.replaceAdvertiserAccount(toAdvertiserAccount(advertiser, adminAdvertiserId));
+        }
+        return adminAdvertiserId;
+    }
+
+    private MediaAuthAccount toAuthAccount(MediaAuthConfig config, JsonNode advertiser, String accessToken, String refreshToken, Long userId) {
+        MediaAuthAccount account = new MediaAuthAccount();
+        account.setMediaType(MediaType.BYTEDANCE.getMediaId());
+        account.setAppId(config.getAppId());
+        account.setUserId(userId);
+        account.setAdminAdvertiserId(advertiser.path("advertiser_id").asLong());
+        account.setAdminAdvertiserName(advertiser.path("advertiser_name").asText(null));
+        account.setAccountRole(advertiser.path("account_role").asText(null));
+        account.setAdvertiserRole(advertiser.path("advertiser_role").isMissingNode() ? null : advertiser.path("advertiser_role").asInt());
+        account.setIsValid(advertiser.path("is_valid").asBoolean(false) ? 1 : 0);
+        account.setAccessToken(accessToken);
+        account.setRefreshToken(refreshToken);
+        account.setRawResponse(toJson(advertiser));
+        return account;
+    }
+
+    private MediaAdvertiserAccount toAdvertiserAccount(JsonNode advertiser, Long adminAdvertiserId) {
+        Long advertiserId = advertiser.path("advertiser_id").asLong();
+        MediaAdvertiserAccount account = new MediaAdvertiserAccount();
+        account.setMediaType(MediaType.BYTEDANCE.getMediaId());
+        account.setAdminAdvertiserId(adminAdvertiserId == null ? advertiserId : adminAdvertiserId);
+        account.setAdvertiserId(advertiserId);
+        account.setAdvertiserName(advertiser.path("advertiser_name").asText(null));
+        account.setAccountRole(advertiser.path("account_role").asText(null));
+        account.setAdvertiserRole(advertiser.path("advertiser_role").isMissingNode() ? null : advertiser.path("advertiser_role").asInt());
+        account.setIsValid(advertiser.path("is_valid").asBoolean(false) ? 1 : 0);
+        account.setRawResponse(toJson(advertiser));
+        return account;
+    }
+
+    private void applyUserInfo(MediaAuthAccount account, String accessToken) {
+        try {
+            JsonNode userInfoResult = bytedanceMediaClient.userInfo(accessToken);
+            JsonNode data = bytedanceMediaClient.requireSuccess(userInfoResult, "获取字节用户信息");
+            String email = data.path("email").asText(null);
+            String displayName = data.path("display_name").asText(null);
+            if (StringUtils.hasText(email)) {
+                account.setEmail(email);
+            }
+            if (StringUtils.hasText(displayName)) {
+                account.setAdminAdvertiserName(displayName);
+            }
+        } catch (Exception ex) {
+            log.warn("获取字节用户信息失败,不影响授权入库:adminAdvertiserId={}", account.getAdminAdvertiserId(), ex);
+        }
+    }
+
+    private Long parseStateUserId(String state) {
+        if (!StringUtils.hasText(state)) {
+            return null;
+        }
+        String[] parts = state.split("_");
+        return parts.length == 0 ? null : parseNullableLong(parts[0]);
+    }
+
+    private Long parseLong(String value, String fieldName) {
+        Long parsed = parseNullableLong(value);
+        if (parsed == null) {
+            throw new BusinessException(400, fieldName + "格式错误");
+        }
+        return parsed;
+    }
+
+    private Long parseNullableLong(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        try {
+            return Long.valueOf(value);
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private String toJson(JsonNode node) {
+        try {
+            return objectMapper.writeValueAsString(node);
+        } catch (Exception ex) {
+            return "{}";
+        }
+    }
+}

+ 73 - 0
src/main/java/com/nuojing/media/callback/controller/MediaCallbackController.java

@@ -0,0 +1,73 @@
+package com.nuojing.media.callback.controller;
+
+import com.nuojing.media.auth.service.MediaAuthService;
+import com.nuojing.media.callback.domain.vo.CallbackResultVO;
+import com.nuojing.media.common.enums.MediaType;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class MediaCallbackController {
+
+    private final MediaAuthService mediaAuthService;
+
+    @RequestMapping({"/media/callback/bytedance", "/callback/bytedance"})
+    public void bytedance(@RequestParam Map<String, String> params, HttpServletRequest request, HttpServletResponse response) throws IOException {
+        redirect(MediaType.BYTEDANCE, params, request, response);
+    }
+
+    @RequestMapping({"/media/callback/kuaishou", "/callback/kuaishou"})
+    public void kuaishou(@RequestParam Map<String, String> params, HttpServletRequest request, HttpServletResponse response) throws IOException {
+        redirect(MediaType.KUAISHOU, params, request, response);
+    }
+
+    @RequestMapping({"/media/callback/tencent", "/media/callback/adq", "/callback/adq"})
+    public void tencent(@RequestParam Map<String, String> params, HttpServletRequest request, HttpServletResponse response) throws IOException {
+        redirect(MediaType.TENCENT, params, request, response);
+    }
+
+    private void redirect(MediaType media, Map<String, String> params, HttpServletRequest request, HttpServletResponse response) throws IOException {
+        log.info("media callback received, media={}, method={}, uri={}, clientIp={}, userAgent={}, params={}, allParams={}",
+                media.getName(),
+                request.getMethod(),
+                request.getRequestURI(),
+                clientIp(request),
+                request.getHeader("User-Agent"),
+                params,
+                allParams(request));
+        CallbackResultVO result = mediaAuthService.handleCallback(media.getMediaId(), params);
+        response.sendRedirect(mediaAuthService.buildReturnUrl(result));
+    }
+
+    private String clientIp(HttpServletRequest request) {
+        String forwardedFor = request.getHeader("X-Forwarded-For");
+        if (forwardedFor != null && !forwardedFor.isBlank()) {
+            return forwardedFor.split(",")[0].trim();
+        }
+        String realIp = request.getHeader("X-Real-IP");
+        if (realIp != null && !realIp.isBlank()) {
+            return realIp;
+        }
+        return request.getRemoteAddr();
+    }
+
+    private Map<String, String> allParams(HttpServletRequest request) {
+        Map<String, String> result = new LinkedHashMap<>();
+        request.getParameterMap().forEach((key, values) ->
+                result.put(key, Arrays.stream(values).collect(Collectors.joining(","))));
+        return result;
+    }
+}

+ 16 - 0
src/main/java/com/nuojing/media/callback/domain/vo/CallbackResultVO.java

@@ -0,0 +1,16 @@
+package com.nuojing.media.callback.domain.vo;
+
+import lombok.Data;
+
+@Data
+public class CallbackResultVO {
+
+    private Integer code;
+    private Integer mediaType;
+    private Integer bindStatus;
+    private Long adminAdvertiserId;
+    private String state;
+    private String message;
+    private String callbackReturnUrl;
+    private Long time;
+}

+ 31 - 0
src/main/java/com/nuojing/media/common/enums/MediaType.java

@@ -0,0 +1,31 @@
+package com.nuojing.media.common.enums;
+
+import com.nuojing.admin.common.BusinessException;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+@Getter
+public enum MediaType {
+
+    BYTEDANCE(1, "bytedance", "字节"),
+    KUAISHOU(2, "kuaishou", "快手"),
+    TENCENT(3, "tencent", "腾讯");
+
+    private final Integer mediaId;
+    private final String code;
+    private final String name;
+
+    MediaType(Integer mediaId, String code, String name) {
+        this.mediaId = mediaId;
+        this.code = code;
+        this.name = name;
+    }
+
+    public static MediaType ofMediaId(Integer mediaId) {
+        return Arrays.stream(values())
+                .filter(media -> media.mediaId.equals(mediaId))
+                .findFirst()
+                .orElseThrow(() -> new BusinessException(400, "不支持的媒体类型"));
+    }
+}

+ 17 - 0
src/main/resources/application-dev.yml

@@ -0,0 +1,17 @@
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: ${DB_URL:jdbc:mysql://localhost:3306/data_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai}
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
+  data:
+    redis:
+      host: localhost
+      port: 6379
+      password:
+      database: 1
+      timeout: 5s
+
+logging:
+  level:
+    com.nuojing.admin.mapper: debug

+ 17 - 0
src/main/resources/application-prod.yml

@@ -0,0 +1,17 @@
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: ${DB_URL:jdbc:mysql://localhost:3306/nuojing_data_admin_prod?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false}
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
+  data:
+    redis:
+      host: localhost
+      port: 6379
+      password:
+      database: 2
+      timeout: 5s
+
+logging:
+  level:
+    com.nuojing.admin.mapper: info

+ 17 - 0
src/main/resources/application-test.yml

@@ -0,0 +1,17 @@
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: ${DB_URL:jdbc:mysql://localhost:3306/data_test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai}
+    username: ${DB_USERNAME:root}
+    password: ${DB_PASSWORD:}
+  data:
+    redis:
+      host: localhost
+      port: 6379
+      password:
+      database: 1
+      timeout: 5s
+
+logging:
+  level:
+    com.nuojing.admin.mapper: debug

+ 22 - 0
src/main/resources/application.yml

@@ -0,0 +1,22 @@
+server:
+  port: 9028
+  servlet:
+    context-path: /api
+
+spring:
+  application:
+    name: nuojing-data-service
+  profiles:
+    active: '@profileActive@'
+
+mybatis:
+  mapper-locations: classpath*:mapper/**/*.xml
+  type-aliases-package: com.nuojing.admin.domain.entity
+  configuration:
+    map-underscore-to-camel-case: true
+
+app:
+  auth:
+    token-header: Authorization
+    token-prefix: Bearer
+    token-ttl-hours: 24

+ 224 - 0
src/main/resources/db/init.sql

@@ -0,0 +1,224 @@
+create database if not exists nuojing_data_admin
+    default character set utf8mb4
+    collate utf8mb4_unicode_ci;
+
+use nuojing_data_admin;
+
+drop table if exists sys_role_menu;
+drop table if exists sys_user_role;
+drop table if exists media_advertiser_account;
+drop table if exists media_auth_account;
+drop table if exists media_auth_config;
+drop table if exists sys_user;
+drop table if exists sys_role;
+drop table if exists sys_menu;
+drop table if exists sys_dept;
+
+create table sys_dept
+(
+    id          bigint       not null auto_increment comment '部门ID',
+    parent_id   bigint       not null default 0 comment '上级部门ID',
+    dept_name   varchar(64)  not null comment '部门名称',
+    sort        int          not null default 0 comment '排序',
+    status      tinyint      not null default 1 comment '状态:1启用,0禁用',
+    create_time datetime     not null default current_timestamp comment '创建时间',
+    update_time datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    key idx_parent_id (parent_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '部门表';
+
+create table sys_user
+(
+    id          bigint       not null auto_increment comment '用户ID',
+    username    varchar(64)  not null comment '用户名',
+    password    varchar(128) not null comment '密码',
+    nickname    varchar(64)  not null comment '昵称',
+    dept_id     bigint       null comment '部门ID',
+    status      tinyint      not null default 1 comment '状态:1启用,0禁用',
+    create_time datetime     not null default current_timestamp comment '创建时间',
+    update_time datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_username (username),
+    key idx_dept_id (dept_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '用户表';
+
+create table sys_role
+(
+    id          bigint      not null auto_increment comment '角色ID',
+    role_name   varchar(64) not null comment '角色名称',
+    role_code   varchar(64) not null comment '角色编码',
+    sort        int         not null default 0 comment '排序',
+    status      tinyint     not null default 1 comment '状态:1启用,0禁用',
+    create_time datetime    not null default current_timestamp comment '创建时间',
+    update_time datetime    not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_role_code (role_code)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '角色表';
+
+create table sys_menu
+(
+    id          bigint       not null auto_increment comment '菜单ID',
+    parent_id   bigint       not null default 0 comment '上级菜单ID',
+    menu_name   varchar(64)  not null comment '菜单名称',
+    path        varchar(128) not null comment '路由路径',
+    component   varchar(128) null comment '组件标识',
+    route_name  varchar(64)  null comment '路由名称',
+    icon        varchar(64)  null comment '图标',
+    permission  varchar(128) null comment '权限标识',
+    menu_type   tinyint      not null default 2 comment '类型:1目录,2菜单',
+    sort        int          not null default 0 comment '排序',
+    hidden      tinyint      not null default 0 comment '是否隐藏:1隐藏,0显示',
+    status      tinyint      not null default 1 comment '状态:1启用,0禁用',
+    create_time datetime     not null default current_timestamp comment '创建时间',
+    update_time datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    key idx_parent_id (parent_id),
+    key idx_permission (permission)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '菜单表';
+
+create table sys_user_role
+(
+    user_id bigint not null comment '用户ID',
+    role_id bigint not null comment '角色ID',
+    primary key (user_id, role_id),
+    key idx_role_id (role_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '用户角色关联表';
+
+create table sys_role_menu
+(
+    role_id bigint not null comment '角色ID',
+    menu_id bigint not null comment '菜单ID',
+    primary key (role_id, menu_id),
+    key idx_menu_id (menu_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '角色菜单关联表';
+
+create table media_auth_config
+(
+    id            bigint       not null auto_increment comment '授权配置ID',
+    media_type    tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    app_id        bigint       not null comment '媒体应用ID',
+    app_secret    varchar(256) null comment '媒体应用密钥',
+    client_secret varchar(256) null comment '腾讯client secret',
+    redirect_uri  varchar(512) null comment '回调地址',
+    callback_return_url varchar(512) null comment '前端授权结果回跳地址',
+    auth_url      varchar(1024) null comment '授权地址',
+    status        tinyint      not null default 1 comment '状态:1启用,0禁用',
+    create_time   datetime     not null default current_timestamp comment '创建时间',
+    update_time   datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_app (media_type, app_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体授权配置表';
+
+create table media_auth_account
+(
+    id                    bigint       not null auto_increment comment '授权账户ID',
+    media_type            tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    app_id                bigint       null comment '媒体应用ID',
+    user_id               bigint       null comment '授权发起用户ID',
+    admin_advertiser_id   bigint       not null comment '管家账户ID',
+    admin_advertiser_name varchar(255) null comment '管家账户名称',
+    email                 varchar(128) null comment '邮箱',
+    account_role          varchar(64)  null comment '账户角色',
+    advertiser_role       int          null comment '广告主角色',
+    is_valid              tinyint      null comment '是否有效:1有效,0无效',
+    access_token          text         null comment 'access token',
+    refresh_token         text         null comment 'refresh token',
+    raw_response          longtext     null comment '媒体原始响应',
+    create_time           datetime     not null default current_timestamp comment '创建时间',
+    update_time           datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_admin (media_type, admin_advertiser_id),
+    key idx_user_id (user_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体授权管家账户表';
+
+create table media_advertiser_account
+(
+    id                  bigint       not null auto_increment comment '广告账户ID',
+    media_type          tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    admin_advertiser_id bigint       not null comment '管家账户ID',
+    advertiser_id       bigint       not null comment '广告账户ID',
+    advertiser_name     varchar(255) null comment '广告账户名称',
+    account_role        varchar(64)  null comment '账户角色',
+    advertiser_role     int          null comment '广告主角色',
+    is_valid            tinyint      null comment '是否有效:1有效,0无效',
+    raw_response        longtext     null comment '媒体原始响应',
+    create_time         datetime     not null default current_timestamp comment '创建时间',
+    update_time         datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_admin_advertiser (media_type, admin_advertiser_id, advertiser_id),
+    key idx_advertiser_id (advertiser_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体广告账户表';
+
+insert into sys_dept (id, parent_id, dept_name, sort, status)
+values (1, 0, '诺鲸科技', 1, 1),
+       (2, 1, '研发部', 1, 1),
+       (3, 1, '运营部', 2, 1);
+
+insert into sys_user (id, username, password, nickname, dept_id, status)
+values (1, 'admin', '123456', '管理员', 2, 1);
+
+insert into sys_role (id, role_name, role_code, sort, status)
+values (1, '超级管理员', 'admin', 1, 1);
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+values (1, 0, '系统管理', '/system', 'Layout', 'System', 'Settings', 'system', 1, 1, 0, 1),
+       (2, 1, '权限管理', '/system/permission', 'ParentView', 'SystemPermission', 'ShieldCheck', 'system:permission', 1, 1, 0, 1),
+       (3, 2, '用户管理', '/system/permission/users', 'system/UserList', 'SystemPermissionUsers', 'UserRound', 'system:user:list', 2, 1, 0, 1),
+       (4, 2, '角色管理', '/system/permission/roles', 'system/RoleList', 'SystemPermissionRoles', 'UsersRound', 'system:role:list', 2, 2, 0, 1),
+       (5, 2, '菜单管理', '/system/permission/menus', 'system/MenuList', 'SystemPermissionMenus', 'Menu', 'system:menu:list', 2, 3, 0, 1),
+       (6, 1, '组织管理', '/system/org', 'ParentView', 'SystemOrg', 'Network', 'system:org', 1, 2, 0, 1),
+       (7, 6, '部门管理', '/system/org/depts', 'system/DeptList', 'SystemOrgDepts', 'Building2', 'system:dept:list', 2, 1, 0, 1),
+       (20, 0, '投放工作台', '/authorization', 'Layout', 'Authorization', 'ChartNoAxesCombined', 'authorization', 1, 2, 0, 1),
+       (21, 20, '账号管理', '/authorization/authorization', 'ParentView', 'AuthorizationAccount', 'WalletCards', 'authorization:account', 1, 1, 0, 1),
+       (22, 21, '字节系', '/authorization/authorization/authorizationConfigDy', 'media/MediaAuthConfig', 'AuthorizationConfigDy', 'BadgeIcon', 'authorization:bytedance:list', 2, 1, 0, 1),
+       (23, 21, '快手系', '/authorization/authorization/authorizationConfigKs', 'media/MediaAuthConfig', 'AuthorizationConfigKs', 'BadgeIcon', 'authorization:kuaishou:list', 2, 2, 0, 1),
+       (24, 21, '腾讯系', '/authorization/authorization/authorizationConfigJl', 'media/MediaAuthConfig', 'AuthorizationConfigTencent', 'BadgeIcon', 'authorization:tencent:list', 2, 3, 0, 1),
+       (25, 21, '授权回调', '/authorization/authorization/authorizationCallback', 'media/MediaAuthCallback', 'AuthorizationCallback', 'BadgeCheck', 'authorization:callback', 2, 99, 1, 1);
+
+insert into sys_user_role (user_id, role_id)
+values (1, 1);
+
+insert into sys_role_menu (role_id, menu_id)
+values (1, 1),
+       (1, 2),
+       (1, 3),
+       (1, 4),
+       (1, 5),
+       (1, 6),
+       (1, 7),
+       (1, 20),
+       (1, 21),
+       (1, 22),
+       (1, 23),
+       (1, 24),
+       (1, 25);
+
+insert into media_auth_config (media_type, app_id, app_secret, redirect_uri, callback_return_url, auth_url, status)
+values (1, 1826985807952410, '', 'http://localhost:9028/api/media/callback/bytedance', 'http://localhost:5173/authorization/authorization/authorizationCallback', '', 1);

+ 53 - 0
src/main/resources/db/patch-authorization-menu.sql

@@ -0,0 +1,53 @@
+use nuojing_data_admin;
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 20, 0, '投放工作台', '/authorization', 'Layout', 'Authorization', 'ChartNoAxesCombined', 'authorization', 1, 2, 0, 1
+where not exists (select 1 from sys_menu where path = '/authorization');
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 21, id, '账号管理', '/authorization/authorization', 'ParentView', 'AuthorizationAccount', 'WalletCards', 'authorization:account', 1, 1, 0, 1
+from sys_menu
+where path = '/authorization'
+  and not exists (select 1 from sys_menu where path = '/authorization/authorization');
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 22, id, '字节系', '/authorization/authorization/authorizationConfigDy', 'media/MediaAuthConfig', 'AuthorizationConfigDy', 'BadgeIcon', 'authorization:bytedance:list', 2, 1, 0, 1
+from sys_menu
+where path = '/authorization/authorization'
+  and not exists (select 1 from sys_menu where path = '/authorization/authorization/authorizationConfigDy');
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 23, id, '快手系', '/authorization/authorization/authorizationConfigKs', 'media/MediaAuthConfig', 'AuthorizationConfigKs', 'BadgeIcon', 'authorization:kuaishou:list', 2, 2, 0, 1
+from sys_menu
+where path = '/authorization/authorization'
+  and not exists (select 1 from sys_menu where path = '/authorization/authorization/authorizationConfigKs');
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 24, id, '腾讯系', '/authorization/authorization/authorizationConfigJl', 'media/MediaAuthConfig', 'AuthorizationConfigTencent', 'BadgeIcon', 'authorization:tencent:list', 2, 3, 0, 1
+from sys_menu
+where path = '/authorization/authorization'
+  and not exists (select 1 from sys_menu where path = '/authorization/authorization/authorizationConfigJl');
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 25, id, '授权回调', '/authorization/authorization/authorizationCallback', 'media/MediaAuthCallback', 'AuthorizationCallback', 'BadgeCheck', 'authorization:callback', 2, 99, 1, 1
+from sys_menu
+where path = '/authorization/authorization'
+  and not exists (select 1 from sys_menu where path = '/authorization/authorization/authorizationCallback');
+
+insert into sys_role_menu (role_id, menu_id)
+select 1, id
+from sys_menu
+where path in (
+    '/authorization',
+    '/authorization/authorization',
+    '/authorization/authorization/authorizationConfigDy',
+    '/authorization/authorization/authorizationConfigKs',
+    '/authorization/authorization/authorizationConfigJl',
+    '/authorization/authorization/authorizationCallback'
+)
+and not exists (
+    select 1
+    from sys_role_menu
+    where role_id = 1
+      and menu_id = sys_menu.id
+);

+ 90 - 0
src/main/resources/db/patch-media-auth.sql

@@ -0,0 +1,90 @@
+use nuojing_data_admin;
+
+create table if not exists media_auth_config
+(
+    id            bigint       not null auto_increment comment '授权配置ID',
+    media_type    tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    app_id        bigint       not null comment '媒体应用ID',
+    app_secret    varchar(256) null comment '媒体应用密钥',
+    client_secret varchar(256) null comment '腾讯client secret',
+    redirect_uri  varchar(512) null comment '回调地址',
+    callback_return_url varchar(512) null comment '前端授权结果回跳地址',
+    auth_url      varchar(1024) null comment '授权地址',
+    status        tinyint      not null default 1 comment '状态:1启用,0禁用',
+    create_time   datetime     not null default current_timestamp comment '创建时间',
+    update_time   datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_app (media_type, app_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体授权配置表';
+
+create table if not exists media_auth_account
+(
+    id                    bigint       not null auto_increment comment '授权账户ID',
+    media_type            tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    app_id                bigint       null comment '媒体应用ID',
+    user_id               bigint       null comment '授权发起用户ID',
+    admin_advertiser_id   bigint       not null comment '管家账户ID',
+    admin_advertiser_name varchar(255) null comment '管家账户名称',
+    email                 varchar(128) null comment '邮箱',
+    account_role          varchar(64)  null comment '账户角色',
+    advertiser_role       int          null comment '广告主角色',
+    is_valid              tinyint      null comment '是否有效:1有效,0无效',
+    access_token          text         null comment 'access token',
+    refresh_token         text         null comment 'refresh token',
+    raw_response          longtext     null comment '媒体原始响应',
+    create_time           datetime     not null default current_timestamp comment '创建时间',
+    update_time           datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_admin (media_type, admin_advertiser_id),
+    key idx_user_id (user_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体授权管家账户表';
+
+create table if not exists media_advertiser_account
+(
+    id                  bigint       not null auto_increment comment '广告账户ID',
+    media_type          tinyint      not null comment '媒体类型:1字节,2快手,3腾讯',
+    admin_advertiser_id bigint       not null comment '管家账户ID',
+    advertiser_id       bigint       not null comment '广告账户ID',
+    advertiser_name     varchar(255) null comment '广告账户名称',
+    account_role        varchar(64)  null comment '账户角色',
+    advertiser_role     int          null comment '广告主角色',
+    is_valid            tinyint      null comment '是否有效:1有效,0无效',
+    raw_response        longtext     null comment '媒体原始响应',
+    create_time         datetime     not null default current_timestamp comment '创建时间',
+    update_time         datetime     not null default current_timestamp on update current_timestamp comment '更新时间',
+    primary key (id),
+    unique key uk_media_admin_advertiser (media_type, admin_advertiser_id, advertiser_id),
+    key idx_advertiser_id (advertiser_id)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_unicode_ci
+  comment = '媒体广告账户表';
+
+set @callback_return_url_column_exists := (
+    select count(1)
+    from information_schema.columns
+    where table_schema = database()
+      and table_name = 'media_auth_config'
+      and column_name = 'callback_return_url'
+);
+set @callback_return_url_ddl := if(
+    @callback_return_url_column_exists = 0,
+    'alter table media_auth_config add column callback_return_url varchar(512) null comment ''前端授权结果回跳地址'' after redirect_uri',
+    'select 1'
+);
+prepare callback_return_url_stmt from @callback_return_url_ddl;
+execute callback_return_url_stmt;
+deallocate prepare callback_return_url_stmt;
+
+insert into media_auth_config (media_type, app_id, app_secret, redirect_uri, callback_return_url, auth_url, status)
+values (1, 1826985807952410, '', 'http://localhost:9028/api/media/callback/bytedance', 'http://localhost:5173/authorization/authorization/authorizationCallback', '', 1)
+on duplicate key update
+    redirect_uri = values(redirect_uri),
+    callback_return_url = values(callback_return_url),
+    update_time = now();

+ 18 - 0
src/main/resources/db/patch-user-management.sql

@@ -0,0 +1,18 @@
+use nuojing_data_admin;
+
+insert into sys_menu (id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status)
+select 7, 2, '用户管理', '/system/permission/users', 'system/UserList', 'SystemPermissionUsers', 'UserRound', 'system:user:list', 2, 1, 0, 1
+where not exists (
+    select 1 from sys_menu where path = '/system/permission/users'
+);
+
+insert into sys_role_menu (role_id, menu_id)
+select 1, id
+from sys_menu
+where path = '/system/permission/users'
+  and not exists (
+      select 1
+      from sys_role_menu
+      where role_id = 1
+        and menu_id = sys_menu.id
+  );

+ 50 - 0
src/main/resources/mapper/admin/DeptMapper.xml

@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.nuojing.admin.mapper.DeptMapper">
+
+    <resultMap id="DeptResultMap" type="com.nuojing.admin.domain.entity.Dept">
+        <id column="id" property="id"/>
+        <result column="parent_id" property="parentId"/>
+        <result column="dept_name" property="deptName"/>
+        <result column="sort" property="sort"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <sql id="BaseColumns">
+        id, parent_id, dept_name, sort, status, create_time, update_time
+    </sql>
+
+    <select id="selectAll" resultMap="DeptResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_dept
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectById" resultMap="DeptResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_dept
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_dept(parent_id, dept_name, sort, status, create_time, update_time)
+        values(#{parentId}, #{deptName}, #{sort}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_dept
+        set parent_id = #{parentId},
+            dept_name = #{deptName},
+            sort = #{sort},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_dept where id = #{id}
+    </delete>
+</mapper>

+ 77 - 0
src/main/resources/mapper/admin/MenuMapper.xml

@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.nuojing.admin.mapper.MenuMapper">
+
+    <resultMap id="MenuResultMap" type="com.nuojing.admin.domain.entity.Menu">
+        <id column="id" property="id"/>
+        <result column="parent_id" property="parentId"/>
+        <result column="menu_name" property="menuName"/>
+        <result column="path" property="path"/>
+        <result column="component" property="component"/>
+        <result column="route_name" property="routeName"/>
+        <result column="icon" property="icon"/>
+        <result column="permission" property="permission"/>
+        <result column="menu_type" property="menuType"/>
+        <result column="sort" property="sort"/>
+        <result column="hidden" property="hidden"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <sql id="BaseColumns">
+        id, parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status, create_time, update_time
+    </sql>
+
+    <select id="selectAll" resultMap="MenuResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_menu
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectByUserId" resultMap="MenuResultMap">
+        select distinct m.id, m.parent_id, m.menu_name, m.path, m.component, m.route_name,
+               m.icon, m.permission, m.menu_type, m.sort, m.hidden, m.status, m.create_time, m.update_time
+        from sys_menu m
+        inner join sys_role_menu rm on rm.menu_id = m.id
+        inner join sys_user_role ur on ur.role_id = rm.role_id
+        inner join sys_role r on r.id = ur.role_id
+        where ur.user_id = #{userId}
+          and m.status = 1
+          and r.status = 1
+        order by m.sort asc, m.id asc
+    </select>
+
+    <select id="selectById" resultMap="MenuResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_menu
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_menu(parent_id, menu_name, path, component, route_name, icon, permission, menu_type, sort, hidden, status, create_time, update_time)
+        values(#{parentId}, #{menuName}, #{path}, #{component}, #{routeName}, #{icon}, #{permission}, #{menuType}, #{sort}, #{hidden}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_menu
+        set parent_id = #{parentId},
+            menu_name = #{menuName},
+            path = #{path},
+            component = #{component},
+            route_name = #{routeName},
+            icon = #{icon},
+            permission = #{permission},
+            menu_type = #{menuType},
+            sort = #{sort},
+            hidden = #{hidden},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_menu where id = #{id}
+    </delete>
+</mapper>

+ 78 - 0
src/main/resources/mapper/admin/RoleMapper.xml

@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.nuojing.admin.mapper.RoleMapper">
+
+    <resultMap id="RoleResultMap" type="com.nuojing.admin.domain.entity.Role">
+        <id column="id" property="id"/>
+        <result column="role_name" property="roleName"/>
+        <result column="role_code" property="roleCode"/>
+        <result column="sort" property="sort"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <sql id="BaseColumns">
+        id, role_name, role_code, sort, status, create_time, update_time
+    </sql>
+
+    <select id="selectAll" resultMap="RoleResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_role
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectByUserId" resultMap="RoleResultMap">
+        select distinct r.id, r.role_name, r.role_code, r.sort, r.status, r.create_time, r.update_time
+        from sys_role r
+        inner join sys_user_role ur on ur.role_id = r.id
+        where ur.user_id = #{userId}
+          and r.status = 1
+        order by r.sort asc, r.id asc
+    </select>
+
+    <select id="selectById" resultMap="RoleResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_role
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_role(role_name, role_code, sort, status, create_time, update_time)
+        values(#{roleName}, #{roleCode}, #{sort}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_role
+        set role_name = #{roleName},
+            role_code = #{roleCode},
+            sort = #{sort},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_role where id = #{id}
+    </delete>
+
+    <select id="selectMenuIdsByRoleId" resultType="java.lang.Long">
+        select menu_id
+        from sys_role_menu
+        where role_id = #{roleId}
+        order by menu_id asc
+    </select>
+
+    <delete id="deleteRoleMenus">
+        delete from sys_role_menu where role_id = #{roleId}
+    </delete>
+
+    <insert id="insertRoleMenus">
+        insert into sys_role_menu(role_id, menu_id)
+        values
+        <foreach collection="menuIds" item="menuId" separator=",">
+            (#{roleId}, #{menuId})
+        </foreach>
+    </insert>
+</mapper>

+ 128 - 0
src/main/resources/mapper/admin/UserMapper.xml

@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.nuojing.admin.mapper.UserMapper">
+
+    <resultMap id="UserResultMap" type="com.nuojing.admin.domain.entity.User">
+        <id column="id" property="id"/>
+        <result column="username" property="username"/>
+        <result column="password" property="password"/>
+        <result column="nickname" property="nickname"/>
+        <result column="dept_id" property="deptId"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <resultMap id="UserVOResultMap" type="com.nuojing.admin.domain.vo.UserVO">
+        <id column="id" property="id"/>
+        <result column="username" property="username"/>
+        <result column="nickname" property="nickname"/>
+        <result column="dept_id" property="deptId"/>
+        <result column="dept_name" property="deptName"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+        <collection property="roleIds" ofType="java.lang.Long" notNullColumn="role_id">
+            <result column="role_id"/>
+        </collection>
+        <collection property="roleNames" ofType="java.lang.String" notNullColumn="role_name">
+            <result column="role_name"/>
+        </collection>
+    </resultMap>
+
+    <sql id="BaseColumns">
+        id, username, password, nickname, dept_id, status, create_time, update_time
+    </sql>
+
+    <select id="selectByUsername" resultMap="UserResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_user
+        where username = #{username}
+        limit 1
+    </select>
+
+    <select id="selectById" resultMap="UserResultMap">
+        select <include refid="BaseColumns"/>
+        from sys_user
+        where id = #{id}
+    </select>
+
+    <select id="selectVOById" resultMap="UserVOResultMap">
+        select u.id,
+               u.username,
+               u.nickname,
+               u.dept_id,
+               d.dept_name,
+               u.status,
+               u.create_time,
+               u.update_time,
+               r.id as role_id,
+               r.role_name
+        from sys_user u
+        left join sys_dept d on d.id = u.dept_id
+        left join sys_user_role ur on ur.user_id = u.id
+        left join sys_role r on r.id = ur.role_id
+        where u.id = #{id}
+        order by r.sort asc, r.id asc
+    </select>
+
+    <select id="selectAll" resultMap="UserVOResultMap">
+        select u.id,
+               u.username,
+               u.nickname,
+               u.dept_id,
+               d.dept_name,
+               u.status,
+               u.create_time,
+               u.update_time,
+               r.id as role_id,
+               r.role_name
+        from sys_user u
+        left join sys_dept d on d.id = u.dept_id
+        left join sys_user_role ur on ur.user_id = u.id
+        left join sys_role r on r.id = ur.role_id
+        order by u.id asc, r.sort asc, r.id asc
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_user(username, password, nickname, dept_id, status, create_time, update_time)
+        values(#{username}, #{password}, #{nickname}, #{deptId}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_user
+        set username = #{username},
+            <if test="password != null and password != ''">
+                password = #{password},
+            </if>
+            nickname = #{nickname},
+            dept_id = #{deptId},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_user where id = #{id}
+    </delete>
+
+    <select id="selectRoleIdsByUserId" resultType="java.lang.Long">
+        select role_id
+        from sys_user_role
+        where user_id = #{userId}
+        order by role_id asc
+    </select>
+
+    <delete id="deleteUserRoles">
+        delete from sys_user_role where user_id = #{userId}
+    </delete>
+
+    <insert id="insertUserRoles">
+        insert into sys_user_role(user_id, role_id)
+        values
+        <foreach collection="roleIds" item="roleId" separator=",">
+            (#{userId}, #{roleId})
+        </foreach>
+    </insert>
+</mapper>

+ 81 - 0
src/main/resources/mapper/media/auth/MediaAuthMapper.xml

@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.nuojing.media.auth.mapper.MediaAuthMapper">
+
+    <resultMap id="MediaAuthConfigResultMap" type="com.nuojing.media.auth.domain.entity.MediaAuthConfig">
+        <id column="id" property="id"/>
+        <result column="media_type" property="mediaType"/>
+        <result column="app_id" property="appId"/>
+        <result column="app_secret" property="appSecret"/>
+        <result column="client_secret" property="clientSecret"/>
+        <result column="redirect_uri" property="redirectUri"/>
+        <result column="callback_return_url" property="callbackReturnUrl"/>
+        <result column="auth_url" property="authUrl"/>
+        <result column="status" property="status"/>
+        <result column="create_time" property="createTime"/>
+        <result column="update_time" property="updateTime"/>
+    </resultMap>
+
+    <select id="selectActiveConfigs" resultMap="MediaAuthConfigResultMap">
+        select id, media_type, app_id, app_secret, client_secret, redirect_uri, callback_return_url, auth_url, status, create_time, update_time
+        from media_auth_config
+        where status = 1
+        <if test="mediaType != null">
+            and media_type = #{mediaType}
+        </if>
+        order by media_type asc, id asc
+    </select>
+
+    <select id="selectActiveConfigByAppId" resultMap="MediaAuthConfigResultMap">
+        select id, media_type, app_id, app_secret, client_secret, redirect_uri, callback_return_url, auth_url, status, create_time, update_time
+        from media_auth_config
+        where media_type = #{mediaType}
+          and app_id = #{appId}
+          and status = 1
+        limit 1
+    </select>
+
+    <insert id="replaceAuthAccount">
+        insert into media_auth_account(
+            media_type, app_id, user_id, admin_advertiser_id, admin_advertiser_name,
+            email, account_role, advertiser_role, is_valid, access_token, refresh_token, raw_response,
+            create_time, update_time
+        )
+        values(
+            #{mediaType}, #{appId}, #{userId}, #{adminAdvertiserId}, #{adminAdvertiserName},
+            #{email}, #{accountRole}, #{advertiserRole}, #{isValid}, #{accessToken}, #{refreshToken}, #{rawResponse},
+            now(), now()
+        )
+        on duplicate key update
+            app_id = values(app_id),
+            user_id = values(user_id),
+            admin_advertiser_name = values(admin_advertiser_name),
+            email = values(email),
+            account_role = values(account_role),
+            advertiser_role = values(advertiser_role),
+            is_valid = values(is_valid),
+            access_token = values(access_token),
+            refresh_token = values(refresh_token),
+            raw_response = values(raw_response),
+            update_time = now()
+    </insert>
+
+    <insert id="replaceAdvertiserAccount">
+        insert into media_advertiser_account(
+            media_type, admin_advertiser_id, advertiser_id, advertiser_name,
+            account_role, advertiser_role, is_valid, raw_response, create_time, update_time
+        )
+        values(
+            #{mediaType}, #{adminAdvertiserId}, #{advertiserId}, #{advertiserName},
+            #{accountRole}, #{advertiserRole}, #{isValid}, #{rawResponse}, now(), now()
+        )
+        on duplicate key update
+            advertiser_name = values(advertiser_name),
+            account_role = values(account_role),
+            advertiser_role = values(advertiser_role),
+            is_valid = values(is_valid),
+            raw_response = values(raw_response),
+            update_time = now()
+    </insert>
+</mapper>