Browse Source

Initial import

yumeng 2 ngày trước cách đây
commit
bcd419069b
71 tập tin đã thay đổi với 3473 bổ sung0 xóa
  1. 5 0
      .gitignore
  2. 80 0
      README.md
  3. 115 0
      pom.xml
  4. 16 0
      src/main/java/com/video/script/admin/VideoScriptAdminApplication.java
  5. 53 0
      src/main/java/com/video/script/admin/common/ApiResponse.java
  6. 8 0
      src/main/java/com/video/script/admin/common/BusinessException.java
  7. 32 0
      src/main/java/com/video/script/admin/common/LoginUser.java
  8. 26 0
      src/main/java/com/video/script/admin/common/UserContext.java
  9. 47 0
      src/main/java/com/video/script/admin/config/AsrProperties.java
  10. 48 0
      src/main/java/com/video/script/admin/config/AuthInterceptor.java
  11. 27 0
      src/main/java/com/video/script/admin/config/GlobalExceptionHandler.java
  12. 44 0
      src/main/java/com/video/script/admin/config/LlmProperties.java
  13. 20 0
      src/main/java/com/video/script/admin/config/RestTemplateConfig.java
  14. 26 0
      src/main/java/com/video/script/admin/config/WebMvcConfig.java
  15. 33 0
      src/main/java/com/video/script/admin/controller/AuthController.java
  16. 44 0
      src/main/java/com/video/script/admin/controller/DeptController.java
  17. 44 0
      src/main/java/com/video/script/admin/controller/MenuController.java
  18. 57 0
      src/main/java/com/video/script/admin/controller/RoleController.java
  19. 50 0
      src/main/java/com/video/script/admin/controller/TaskController.java
  20. 44 0
      src/main/java/com/video/script/admin/controller/UserController.java
  21. 36 0
      src/main/java/com/video/script/admin/dto/DeptSaveRequest.java
  22. 16 0
      src/main/java/com/video/script/admin/dto/LoginRequest.java
  23. 44 0
      src/main/java/com/video/script/admin/dto/MenuSaveRequest.java
  24. 16 0
      src/main/java/com/video/script/admin/dto/RoleMenuSaveRequest.java
  25. 30 0
      src/main/java/com/video/script/admin/dto/RoleSaveRequest.java
  26. 55 0
      src/main/java/com/video/script/admin/dto/TaskSaveRequest.java
  27. 44 0
      src/main/java/com/video/script/admin/dto/UserSaveRequest.java
  28. 33 0
      src/main/java/com/video/script/admin/entity/SysDept.java
  29. 39 0
      src/main/java/com/video/script/admin/entity/SysMenu.java
  30. 27 0
      src/main/java/com/video/script/admin/entity/SysRole.java
  31. 42 0
      src/main/java/com/video/script/admin/entity/SysUser.java
  32. 60 0
      src/main/java/com/video/script/admin/entity/VideoScriptTask.java
  33. 18 0
      src/main/java/com/video/script/admin/mapper/SysDeptMapper.java
  34. 22 0
      src/main/java/com/video/script/admin/mapper/SysMenuMapper.java
  35. 18 0
      src/main/java/com/video/script/admin/mapper/SysRoleMapper.java
  36. 15 0
      src/main/java/com/video/script/admin/mapper/SysRoleMenuMapper.java
  37. 20 0
      src/main/java/com/video/script/admin/mapper/SysUserMapper.java
  38. 36 0
      src/main/java/com/video/script/admin/mapper/VideoScriptTaskMapper.java
  39. 11 0
      src/main/java/com/video/script/admin/service/AuthService.java
  40. 14 0
      src/main/java/com/video/script/admin/service/DeptService.java
  41. 27 0
      src/main/java/com/video/script/admin/service/LlmScriptClient.java
  42. 18 0
      src/main/java/com/video/script/admin/service/MenuService.java
  43. 18 0
      src/main/java/com/video/script/admin/service/RoleService.java
  44. 19 0
      src/main/java/com/video/script/admin/service/TaskScheduler.java
  45. 18 0
      src/main/java/com/video/script/admin/service/TaskService.java
  46. 10 0
      src/main/java/com/video/script/admin/service/TokenService.java
  47. 14 0
      src/main/java/com/video/script/admin/service/UserService.java
  48. 24 0
      src/main/java/com/video/script/admin/service/VideoTranscriptionClient.java
  49. 393 0
      src/main/java/com/video/script/admin/service/impl/AliyunAsrClient.java
  50. 58 0
      src/main/java/com/video/script/admin/service/impl/AuthServiceImpl.java
  51. 45 0
      src/main/java/com/video/script/admin/service/impl/DeptServiceImpl.java
  52. 57 0
      src/main/java/com/video/script/admin/service/impl/MenuServiceImpl.java
  53. 304 0
      src/main/java/com/video/script/admin/service/impl/OpenAiCompatibleLlmClient.java
  54. 78 0
      src/main/java/com/video/script/admin/service/impl/RoleServiceImpl.java
  55. 300 0
      src/main/java/com/video/script/admin/service/impl/TaskServiceImpl.java
  56. 26 0
      src/main/java/com/video/script/admin/service/impl/TokenServiceImpl.java
  57. 59 0
      src/main/java/com/video/script/admin/service/impl/UserServiceImpl.java
  58. 18 0
      src/main/java/com/video/script/admin/vo/LoginVO.java
  59. 29 0
      src/main/resources/application-dev.yml
  60. 29 0
      src/main/resources/application-prod.yml
  61. 22 0
      src/main/resources/application-test.yml
  62. 43 0
      src/main/resources/application.yml
  63. 44 0
      src/main/resources/mapper/SysDeptMapper.xml
  64. 62 0
      src/main/resources/mapper/SysMenuMapper.xml
  65. 42 0
      src/main/resources/mapper/SysRoleMapper.xml
  66. 34 0
      src/main/resources/mapper/SysRoleMenuMapper.xml
  67. 63 0
      src/main/resources/mapper/SysUserMapper.xml
  68. 98 0
      src/main/resources/mapper/VideoScriptTaskMapper.xml
  69. 22 0
      src/main/resources/sql/data.sql
  70. 76 0
      src/main/resources/sql/schema.sql
  71. 8 0
      src/main/resources/sql/upgrade_video_script_task.sql

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+.idea/
+target/
+*.iml
+.DS_Store
+logs/

+ 80 - 0
README.md

@@ -0,0 +1,80 @@
+# video-script-admin-backend
+
+Spring Boot + MyBatis 后端骨架,严格按 `controller -> service -> mapper -> xml` 分层,包含:
+
+- 登录鉴权
+- 用户管理
+- 角色管理
+- 部门管理
+- 菜单管理
+- 视频脚本任务管理
+
+## 启动
+
+1. 创建数据库 `video_script_admin`
+2. 执行 [schema.sql](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/sql/schema.sql) 和 [data.sql](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/sql/data.sql)
+3. 如果你是已有数据库,先执行 [upgrade_video_script_task.sql](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/sql/upgrade_video_script_task.sql)
+4. 按环境修改配置文件:
+
+- [application-dev.yml](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/application-dev.yml)
+- [application-test.yml](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/application-test.yml)
+- [application-prod.yml](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/application-prod.yml)
+
+5. 运行:
+
+```bash
+mvn spring-boot:run
+```
+
+指定环境运行:
+
+```bash
+mvn spring-boot:run -Pdev
+mvn spring-boot:run -Ptest
+mvn spring-boot:run -Pprod
+```
+
+当前默认按 DeepSeek 官方 OpenAI-compatible API 对接,配置放在 [application.yml](/Users/yumeng/IdeaProjects/video-script-admin-backend/src/main/resources/application.yml) 的 `llm` 节点,常用环境变量如下:
+
+- `LLM_ENABLED`
+- `LLM_BASE_URL`
+- `LLM_ENDPOINT`
+- `LLM_API_KEY`
+- `LLM_MODEL`
+
+默认 DeepSeek 配置示例:
+
+```bash
+export LLM_ENABLED=true
+export LLM_BASE_URL=https://api.deepseek.com
+export LLM_ENDPOINT=/chat/completions
+export LLM_MODEL=deepseek-v4-flash
+export LLM_API_KEY=你的_DEEPSEEK_KEY
+```
+
+阿里云百炼 ASR 配置示例:
+
+```bash
+export ASR_ENABLED=true
+export ASR_PROVIDER=aliyun
+export ASR_BASE_URL=https://你的_WORKSPACE_ID.cn-beijing.maas.aliyuncs.com
+export ASR_API_KEY=你的_DASHSCOPE_API_KEY
+export ASR_MODEL=qwen-audio-3.0-asr-flash-filetrans
+```
+
+说明:
+
+- DeepSeek 负责综合 `videoContent` 和 `transcript` 生成脚本和摘要
+- 阿里云百炼 ASR 负责把公开视频链接转成 `transcript`
+- 传给 ASR 的 `videoUrl` 必须是可访问的 HTTP/HTTPS 地址,生产环境建议用稳定 OSS 或长期可访问文件地址
+
+如果你是基于已有数据库升级,需要先补字段:
+
+```sql
+-- 直接执行 src/main/resources/sql/upgrade_video_script_task.sql
+```
+
+默认账号:
+
+- `admin / 123456`
+- `operator / 123456`

+ 115 - 0
pom.xml

@@ -0,0 +1,115 @@
+<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.video.script</groupId>
+    <artifactId>video-script-admin-backend</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <name>video-script-admin-backend</name>
+    <description>Video script admin backend</description>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <mysql.version>8.0.33</mysql.version>
+        <mybatis.spring.boot.version>2.3.2</mybatis.spring.boot.version>
+        <activatedProperties>dev</activatedProperties>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mybatis.spring.boot</groupId>
+            <artifactId>mybatis-spring-boot-starter</artifactId>
+            <version>${mybatis.spring.boot.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>${mysql.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </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>
+                <includes>
+                    <include>application.yml</include>
+                    <include>application-*.yml</include>
+                </includes>
+            </resource>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>false</filtering>
+                <excludes>
+                    <exclude>application.yml</exclude>
+                    <exclude>application-*.yml</exclude>
+                </excludes>
+            </resource>
+        </resources>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>${java.version}</source>
+                    <target>${java.version}</target>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <profiles>
+        <profile>
+            <id>dev</id>
+            <activation>
+                <activeByDefault>true</activeByDefault>
+            </activation>
+            <properties>
+                <activatedProperties>dev</activatedProperties>
+            </properties>
+        </profile>
+        <profile>
+            <id>test</id>
+            <properties>
+                <activatedProperties>test</activatedProperties>
+            </properties>
+        </profile>
+        <profile>
+            <id>prod</id>
+            <properties>
+                <activatedProperties>prod</activatedProperties>
+            </properties>
+        </profile>
+    </profiles>
+</project>

+ 16 - 0
src/main/java/com/video/script/admin/VideoScriptAdminApplication.java

@@ -0,0 +1,16 @@
+package com.video.script.admin;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@MapperScan("com.video.script.admin.mapper")
+@EnableScheduling
+@SpringBootApplication
+public class VideoScriptAdminApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(VideoScriptAdminApplication.class, args);
+    }
+}

+ 53 - 0
src/main/java/com/video/script/admin/common/ApiResponse.java

@@ -0,0 +1,53 @@
+package com.video.script.admin.common;
+
+public class ApiResponse<T> {
+
+    private Integer code;
+    private String message;
+    private T data;
+
+    public ApiResponse() {
+    }
+
+    public ApiResponse(Integer code, String message, T data) {
+        this.code = code;
+        this.message = message;
+        this.data = data;
+    }
+
+    public static <T> ApiResponse<T> success(T data) {
+        return new ApiResponse<T>(200, "success", data);
+    }
+
+    public static <T> ApiResponse<T> success() {
+        return success(null);
+    }
+
+    public static <T> ApiResponse<T> fail(String message) {
+        return new ApiResponse<T>(500, message, null);
+    }
+
+    public Integer getCode() {
+        return code;
+    }
+
+    public void setCode(Integer code) {
+        this.code = code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+}

+ 8 - 0
src/main/java/com/video/script/admin/common/BusinessException.java

@@ -0,0 +1,8 @@
+package com.video.script.admin.common;
+
+public class BusinessException extends RuntimeException {
+
+    public BusinessException(String message) {
+        super(message);
+    }
+}

+ 32 - 0
src/main/java/com/video/script/admin/common/LoginUser.java

@@ -0,0 +1,32 @@
+package com.video.script.admin.common;
+
+public class LoginUser {
+
+    private Long userId;
+    private String username;
+    private String nickname;
+
+    public Long getUserId() {
+        return userId;
+    }
+
+    public void setUserId(Long userId) {
+        this.userId = userId;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getNickname() {
+        return nickname;
+    }
+
+    public void setNickname(String nickname) {
+        this.nickname = nickname;
+    }
+}

+ 26 - 0
src/main/java/com/video/script/admin/common/UserContext.java

@@ -0,0 +1,26 @@
+package com.video.script.admin.common;
+
+public final class UserContext {
+
+    private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<LoginUser>();
+
+    private UserContext() {
+    }
+
+    public static void set(LoginUser loginUser) {
+        HOLDER.set(loginUser);
+    }
+
+    public static LoginUser get() {
+        return HOLDER.get();
+    }
+
+    public static Long getUserId() {
+        LoginUser loginUser = HOLDER.get();
+        return loginUser == null ? null : loginUser.getUserId();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+}

+ 47 - 0
src/main/java/com/video/script/admin/config/AsrProperties.java

@@ -0,0 +1,47 @@
+package com.video.script.admin.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "asr")
+public class AsrProperties {
+
+    private boolean enabled;
+    private String provider = "aliyun";
+    private String baseUrl;
+    private String submitPath = "/api/v1/services/audio/asr/transcription";
+    private String taskPathTemplate = "/api/v1/tasks/{taskId}";
+    private String apiKey;
+    private String model = "qwen-audio-3.0-asr-flash-filetrans";
+    private String authHeader = "Authorization";
+    private String authPrefix = "Bearer ";
+    private int timeoutMs = 30000;
+    private int pollIntervalMs = 3000;
+    private int maxPolls = 40;
+
+    public boolean isEnabled() { return enabled; }
+    public void setEnabled(boolean enabled) { this.enabled = enabled; }
+    public String getProvider() { return provider; }
+    public void setProvider(String provider) { this.provider = provider; }
+    public String getBaseUrl() { return baseUrl; }
+    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
+    public String getSubmitPath() { return submitPath; }
+    public void setSubmitPath(String submitPath) { this.submitPath = submitPath; }
+    public String getTaskPathTemplate() { return taskPathTemplate; }
+    public void setTaskPathTemplate(String taskPathTemplate) { this.taskPathTemplate = taskPathTemplate; }
+    public String getApiKey() { return apiKey; }
+    public void setApiKey(String apiKey) { this.apiKey = apiKey; }
+    public String getModel() { return model; }
+    public void setModel(String model) { this.model = model; }
+    public String getAuthHeader() { return authHeader; }
+    public void setAuthHeader(String authHeader) { this.authHeader = authHeader; }
+    public String getAuthPrefix() { return authPrefix; }
+    public void setAuthPrefix(String authPrefix) { this.authPrefix = authPrefix; }
+    public int getTimeoutMs() { return timeoutMs; }
+    public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; }
+    public int getPollIntervalMs() { return pollIntervalMs; }
+    public void setPollIntervalMs(int pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; }
+    public int getMaxPolls() { return maxPolls; }
+    public void setMaxPolls(int maxPolls) { this.maxPolls = maxPolls; }
+}

+ 48 - 0
src/main/java/com/video/script/admin/config/AuthInterceptor.java

@@ -0,0 +1,48 @@
+package com.video.script.admin.config;
+
+import com.video.script.admin.common.LoginUser;
+import com.video.script.admin.common.UserContext;
+import com.video.script.admin.service.TokenService;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+@Component
+public class AuthInterceptor implements HandlerInterceptor {
+
+    private final TokenService tokenService;
+
+    public AuthInterceptor(TokenService tokenService) {
+        this.tokenService = tokenService;
+    }
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        String uri = request.getRequestURI();
+        if (uri.startsWith("/api/auth/login")) {
+            return true;
+        }
+        String token = request.getHeader("Authorization");
+        if (token == null || token.trim().isEmpty()) {
+            response.setStatus(401);
+            response.setContentType("application/json;charset=UTF-8");
+            response.getWriter().write("{\"code\":401,\"message\":\"Unauthorized\",\"data\":null}");
+            return false;
+        }
+        LoginUser loginUser = tokenService.parse(token.replace("Bearer ", ""));
+        if (loginUser == null) {
+            response.setStatus(401);
+            response.setContentType("application/json;charset=UTF-8");
+            response.getWriter().write("{\"code\":401,\"message\":\"Token invalid\",\"data\":null}");
+            return false;
+        }
+        UserContext.set(loginUser);
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        UserContext.clear();
+    }
+}

+ 27 - 0
src/main/java/com/video/script/admin/config/GlobalExceptionHandler.java

@@ -0,0 +1,27 @@
+package com.video.script.admin.config;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.common.BusinessException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler(BusinessException.class)
+    public ApiResponse<Void> handleBusiness(BusinessException ex) {
+        return ApiResponse.fail(ex.getMessage());
+    }
+
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
+        String message = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
+        return ApiResponse.fail(message);
+    }
+
+    @ExceptionHandler(Exception.class)
+    public ApiResponse<Void> handleException(Exception ex) {
+        return ApiResponse.fail(ex.getMessage());
+    }
+}

+ 44 - 0
src/main/java/com/video/script/admin/config/LlmProperties.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Component
+@ConfigurationProperties(prefix = "llm")
+public class LlmProperties {
+
+    private boolean enabled;
+    private String provider = "qwen";
+    private String baseUrl;
+    private String endpoint = "/chat/completions";
+    private String apiKey;
+    private String model = "qwen3.7-plus";
+    private String authHeader = "Authorization";
+    private String authPrefix = "Bearer ";
+    private int timeoutMs = 180000;
+    private boolean multimodalEnabled = true;
+    private boolean sendVideoUrls = true;
+
+    public boolean isEnabled() { return enabled; }
+    public void setEnabled(boolean enabled) { this.enabled = enabled; }
+    public String getProvider() { return provider; }
+    public void setProvider(String provider) { this.provider = provider; }
+    public String getBaseUrl() { return baseUrl; }
+    public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
+    public String getEndpoint() { return endpoint; }
+    public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
+    public String getApiKey() { return apiKey; }
+    public void setApiKey(String apiKey) { this.apiKey = apiKey; }
+    public String getModel() { return model; }
+    public void setModel(String model) { this.model = model; }
+    public String getAuthHeader() { return authHeader; }
+    public void setAuthHeader(String authHeader) { this.authHeader = authHeader; }
+    public String getAuthPrefix() { return authPrefix; }
+    public void setAuthPrefix(String authPrefix) { this.authPrefix = authPrefix; }
+    public int getTimeoutMs() { return timeoutMs; }
+    public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; }
+    public boolean isMultimodalEnabled() { return multimodalEnabled; }
+    public void setMultimodalEnabled(boolean multimodalEnabled) { this.multimodalEnabled = multimodalEnabled; }
+    public boolean isSendVideoUrls() { return sendVideoUrls; }
+    public void setSendVideoUrls(boolean sendVideoUrls) { this.sendVideoUrls = sendVideoUrls; }
+}

+ 20 - 0
src/main/java/com/video/script/admin/config/RestTemplateConfig.java

@@ -0,0 +1,20 @@
+package com.video.script.admin.config;
+
+import java.time.Duration;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+    @Bean
+    public RestTemplate restTemplate(RestTemplateBuilder builder, LlmProperties llmProperties, AsrProperties asrProperties) {
+        int timeoutMs = Math.max(llmProperties.getTimeoutMs(), asrProperties.getTimeoutMs());
+        return builder
+                .setConnectTimeout(Duration.ofMillis(timeoutMs))
+                .setReadTimeout(Duration.ofMillis(timeoutMs))
+                .build();
+    }
+}

+ 26 - 0
src/main/java/com/video/script/admin/config/WebMvcConfig.java

@@ -0,0 +1,26 @@
+package com.video.script.admin.config;
+
+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
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    private final AuthInterceptor authInterceptor;
+
+    public WebMvcConfig(AuthInterceptor authInterceptor) {
+        this.authInterceptor = authInterceptor;
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
+    }
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**").allowedOriginPatterns("*").allowedMethods("*").allowedHeaders("*");
+    }
+}

+ 33 - 0
src/main/java/com/video/script/admin/controller/AuthController.java

@@ -0,0 +1,33 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.LoginRequest;
+import com.video.script.admin.service.AuthService;
+import com.video.script.admin.vo.LoginVO;
+import javax.validation.Valid;
+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;
+
+@RestController
+@RequestMapping("/api/auth")
+public class AuthController {
+
+    private final AuthService authService;
+
+    public AuthController(AuthService authService) {
+        this.authService = authService;
+    }
+
+    @PostMapping("/login")
+    public ApiResponse<LoginVO> login(@Valid @RequestBody LoginRequest request) {
+        return ApiResponse.success(authService.login(request));
+    }
+
+    @GetMapping("/profile")
+    public ApiResponse<LoginVO> profile() {
+        return ApiResponse.success(authService.profile());
+    }
+}

+ 44 - 0
src/main/java/com/video/script/admin/controller/DeptController.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.DeptSaveRequest;
+import com.video.script.admin.entity.SysDept;
+import com.video.script.admin.service.DeptService;
+import java.util.List;
+import javax.validation.Valid;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/depts")
+public class DeptController {
+
+    private final DeptService deptService;
+
+    public DeptController(DeptService deptService) {
+        this.deptService = deptService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<SysDept>> list(@RequestParam(value = "keyword", required = false) String keyword) {
+        return ApiResponse.success(deptService.list(keyword));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@Valid @RequestBody DeptSaveRequest request) {
+        deptService.save(request);
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        deptService.delete(id);
+        return ApiResponse.success();
+    }
+}

+ 44 - 0
src/main/java/com/video/script/admin/controller/MenuController.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.MenuSaveRequest;
+import com.video.script.admin.entity.SysMenu;
+import com.video.script.admin.service.MenuService;
+import java.util.List;
+import javax.validation.Valid;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/menus")
+public class MenuController {
+
+    private final MenuService menuService;
+
+    public MenuController(MenuService menuService) {
+        this.menuService = menuService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<SysMenu>> list(@RequestParam(value = "keyword", required = false) String keyword) {
+        return ApiResponse.success(menuService.list(keyword));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@Valid @RequestBody MenuSaveRequest request) {
+        menuService.save(request);
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        menuService.delete(id);
+        return ApiResponse.success();
+    }
+}

+ 57 - 0
src/main/java/com/video/script/admin/controller/RoleController.java

@@ -0,0 +1,57 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.RoleMenuSaveRequest;
+import com.video.script.admin.dto.RoleSaveRequest;
+import com.video.script.admin.entity.SysRole;
+import com.video.script.admin.service.RoleService;
+import java.util.List;
+import javax.validation.Valid;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/roles")
+public class RoleController {
+
+    private final RoleService roleService;
+
+    public RoleController(RoleService roleService) {
+        this.roleService = roleService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<SysRole>> list(@RequestParam(value = "keyword", required = false) String keyword) {
+        return ApiResponse.success(roleService.list(keyword));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@Valid @RequestBody RoleSaveRequest request) {
+        roleService.save(request);
+        return ApiResponse.success();
+    }
+
+    @GetMapping("/{id}/menu-ids")
+    public ApiResponse<List<Long>> getMenuIds(@PathVariable("id") Long id) {
+        return ApiResponse.success(roleService.getMenuIds(id));
+    }
+
+    @PostMapping("/{id}/menu-ids")
+    public ApiResponse<Void> saveMenuIds(@PathVariable("id") Long id, @RequestBody RoleMenuSaveRequest request) {
+        request.setRoleId(id);
+        roleService.saveRoleMenus(id, request.getMenuIds());
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        roleService.delete(id);
+        return ApiResponse.success();
+    }
+}

+ 50 - 0
src/main/java/com/video/script/admin/controller/TaskController.java

@@ -0,0 +1,50 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.TaskSaveRequest;
+import com.video.script.admin.entity.VideoScriptTask;
+import com.video.script.admin.service.TaskService;
+import java.util.List;
+import javax.validation.Valid;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/tasks")
+public class TaskController {
+
+    private final TaskService taskService;
+
+    public TaskController(TaskService taskService) {
+        this.taskService = taskService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<VideoScriptTask>> list(@RequestParam(value = "keyword", required = false) String keyword,
+                                                   @RequestParam(value = "status", required = false) String status) {
+        return ApiResponse.success(taskService.list(keyword, status));
+    }
+
+    @GetMapping("/{id}")
+    public ApiResponse<VideoScriptTask> detail(@PathVariable("id") Long id) {
+        return ApiResponse.success(taskService.getById(id));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@Valid @RequestBody TaskSaveRequest request) {
+        taskService.save(request);
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        taskService.delete(id);
+        return ApiResponse.success();
+    }
+}

+ 44 - 0
src/main/java/com/video/script/admin/controller/UserController.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.controller;
+
+import com.video.script.admin.common.ApiResponse;
+import com.video.script.admin.dto.UserSaveRequest;
+import com.video.script.admin.entity.SysUser;
+import com.video.script.admin.service.UserService;
+import java.util.List;
+import javax.validation.Valid;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/users")
+public class UserController {
+
+    private final UserService userService;
+
+    public UserController(UserService userService) {
+        this.userService = userService;
+    }
+
+    @GetMapping
+    public ApiResponse<List<SysUser>> list(@RequestParam(value = "keyword", required = false) String keyword) {
+        return ApiResponse.success(userService.list(keyword));
+    }
+
+    @PostMapping
+    public ApiResponse<Void> save(@Valid @RequestBody UserSaveRequest request) {
+        userService.save(request);
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/{id}")
+    public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+        userService.delete(id);
+        return ApiResponse.success();
+    }
+}

+ 36 - 0
src/main/java/com/video/script/admin/dto/DeptSaveRequest.java

@@ -0,0 +1,36 @@
+package com.video.script.admin.dto;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+public class DeptSaveRequest {
+
+    private Long id;
+
+    @NotBlank(message = "部门名称不能为空")
+    private String deptName;
+
+    @NotNull(message = "父级部门不能为空")
+    private Long parentId;
+
+    private String leader;
+    private String phone;
+    private Integer sort;
+
+    @NotNull(message = "状态不能为空")
+    private Integer status;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getDeptName() { return deptName; }
+    public void setDeptName(String deptName) { this.deptName = deptName; }
+    public Long getParentId() { return parentId; }
+    public void setParentId(Long parentId) { this.parentId = parentId; }
+    public String getLeader() { return leader; }
+    public void setLeader(String leader) { this.leader = leader; }
+    public String getPhone() { return phone; }
+    public void setPhone(String phone) { this.phone = phone; }
+    public Integer getSort() { return sort; }
+    public void setSort(Integer sort) { this.sort = sort; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+}

+ 16 - 0
src/main/java/com/video/script/admin/dto/LoginRequest.java

@@ -0,0 +1,16 @@
+package com.video.script.admin.dto;
+
+import javax.validation.constraints.NotBlank;
+public class LoginRequest {
+
+    @NotBlank(message = "用户名不能为空")
+    private String username;
+
+    @NotBlank(message = "密码不能为空")
+    private String password;
+
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+}

+ 44 - 0
src/main/java/com/video/script/admin/dto/MenuSaveRequest.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.dto;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+public class MenuSaveRequest {
+
+    private Long id;
+
+    @NotBlank(message = "菜单名称不能为空")
+    private String menuName;
+
+    @NotNull(message = "父级菜单不能为空")
+    private Long parentId;
+
+    @NotBlank(message = "路由不能为空")
+    private String routePath;
+
+    @NotBlank(message = "组件不能为空")
+    private String component;
+
+    private String icon;
+    private Integer sort;
+    private Integer visible;
+    private String permissionCode;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getMenuName() { return menuName; }
+    public void setMenuName(String menuName) { this.menuName = menuName; }
+    public Long getParentId() { return parentId; }
+    public void setParentId(Long parentId) { this.parentId = parentId; }
+    public String getRoutePath() { return routePath; }
+    public void setRoutePath(String routePath) { this.routePath = routePath; }
+    public String getComponent() { return component; }
+    public void setComponent(String component) { this.component = component; }
+    public String getIcon() { return icon; }
+    public void setIcon(String icon) { this.icon = icon; }
+    public Integer getSort() { return sort; }
+    public void setSort(Integer sort) { this.sort = sort; }
+    public Integer getVisible() { return visible; }
+    public void setVisible(Integer visible) { this.visible = visible; }
+    public String getPermissionCode() { return permissionCode; }
+    public void setPermissionCode(String permissionCode) { this.permissionCode = permissionCode; }
+}

+ 16 - 0
src/main/java/com/video/script/admin/dto/RoleMenuSaveRequest.java

@@ -0,0 +1,16 @@
+package com.video.script.admin.dto;
+
+import java.util.List;
+import javax.validation.constraints.NotNull;
+
+public class RoleMenuSaveRequest {
+
+    @NotNull(message = "角色不能为空")
+    private Long roleId;
+    private List<Long> menuIds;
+
+    public Long getRoleId() { return roleId; }
+    public void setRoleId(Long roleId) { this.roleId = roleId; }
+    public List<Long> getMenuIds() { return menuIds; }
+    public void setMenuIds(List<Long> menuIds) { this.menuIds = menuIds; }
+}

+ 30 - 0
src/main/java/com/video/script/admin/dto/RoleSaveRequest.java

@@ -0,0 +1,30 @@
+package com.video.script.admin.dto;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+public class RoleSaveRequest {
+
+    private Long id;
+
+    @NotBlank(message = "角色编码不能为空")
+    private String roleCode;
+
+    @NotBlank(message = "角色名称不能为空")
+    private String roleName;
+
+    private String remark;
+
+    @NotNull(message = "状态不能为空")
+    private Integer status;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getRoleCode() { return roleCode; }
+    public void setRoleCode(String roleCode) { this.roleCode = roleCode; }
+    public String getRoleName() { return roleName; }
+    public void setRoleName(String roleName) { this.roleName = roleName; }
+    public String getRemark() { return remark; }
+    public void setRemark(String remark) { this.remark = remark; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+}

+ 55 - 0
src/main/java/com/video/script/admin/dto/TaskSaveRequest.java

@@ -0,0 +1,55 @@
+package com.video.script.admin.dto;
+
+import java.util.List;
+import javax.validation.constraints.NotBlank;
+
+public class TaskSaveRequest {
+
+    private Long id;
+
+    @NotBlank(message = "任务名称不能为空")
+    private String taskName;
+
+    private String videoUrl;
+
+    private List<String> videoUrls;
+
+    @NotBlank(message = "脚本类型不能为空")
+    private String scriptType;
+
+    private String status;
+    private String promptHint;
+    private String transcript;
+    private String videoContent;
+    private String analysisContent;
+    private String scriptContent;
+    private String summary;
+    private String codedeskTaskId;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getTaskName() { return taskName; }
+    public void setTaskName(String taskName) { this.taskName = taskName; }
+    public String getVideoUrl() { return videoUrl; }
+    public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
+    public List<String> getVideoUrls() { return videoUrls; }
+    public void setVideoUrls(List<String> videoUrls) { this.videoUrls = videoUrls; }
+    public String getScriptType() { return scriptType; }
+    public void setScriptType(String scriptType) { this.scriptType = scriptType; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public String getPromptHint() { return promptHint; }
+    public void setPromptHint(String promptHint) { this.promptHint = promptHint; }
+    public String getTranscript() { return transcript; }
+    public void setTranscript(String transcript) { this.transcript = transcript; }
+    public String getVideoContent() { return videoContent; }
+    public void setVideoContent(String videoContent) { this.videoContent = videoContent; }
+    public String getAnalysisContent() { return analysisContent; }
+    public void setAnalysisContent(String analysisContent) { this.analysisContent = analysisContent; }
+    public String getScriptContent() { return scriptContent; }
+    public void setScriptContent(String scriptContent) { this.scriptContent = scriptContent; }
+    public String getSummary() { return summary; }
+    public void setSummary(String summary) { this.summary = summary; }
+    public String getCodedeskTaskId() { return codedeskTaskId; }
+    public void setCodedeskTaskId(String codedeskTaskId) { this.codedeskTaskId = codedeskTaskId; }
+}

+ 44 - 0
src/main/java/com/video/script/admin/dto/UserSaveRequest.java

@@ -0,0 +1,44 @@
+package com.video.script.admin.dto;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+public class UserSaveRequest {
+
+    private Long id;
+
+    @NotBlank(message = "用户名不能为空")
+    private String username;
+
+    private String password;
+
+    @NotBlank(message = "昵称不能为空")
+    private String nickname;
+
+    private String phone;
+
+    @NotNull(message = "部门不能为空")
+    private Long deptId;
+
+    @NotNull(message = "角色不能为空")
+    private Long roleId;
+
+    @NotNull(message = "状态不能为空")
+    private Integer status;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+    public String getNickname() { return nickname; }
+    public void setNickname(String nickname) { this.nickname = nickname; }
+    public String getPhone() { return phone; }
+    public void setPhone(String phone) { this.phone = phone; }
+    public Long getDeptId() { return deptId; }
+    public void setDeptId(Long deptId) { this.deptId = deptId; }
+    public Long getRoleId() { return roleId; }
+    public void setRoleId(Long roleId) { this.roleId = roleId; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+}

+ 33 - 0
src/main/java/com/video/script/admin/entity/SysDept.java

@@ -0,0 +1,33 @@
+package com.video.script.admin.entity;
+
+public class SysDept {
+
+    private Long id;
+    private String deptName;
+    private Long parentId;
+    private String leader;
+    private String phone;
+    private Integer sort;
+    private Integer status;
+    private String createTime;
+    private String updateTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getDeptName() { return deptName; }
+    public void setDeptName(String deptName) { this.deptName = deptName; }
+    public Long getParentId() { return parentId; }
+    public void setParentId(Long parentId) { this.parentId = parentId; }
+    public String getLeader() { return leader; }
+    public void setLeader(String leader) { this.leader = leader; }
+    public String getPhone() { return phone; }
+    public void setPhone(String phone) { this.phone = phone; }
+    public Integer getSort() { return sort; }
+    public void setSort(Integer sort) { this.sort = sort; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+    public String getCreateTime() { return createTime; }
+    public void setCreateTime(String createTime) { this.createTime = createTime; }
+    public String getUpdateTime() { return updateTime; }
+    public void setUpdateTime(String updateTime) { this.updateTime = updateTime; }
+}

+ 39 - 0
src/main/java/com/video/script/admin/entity/SysMenu.java

@@ -0,0 +1,39 @@
+package com.video.script.admin.entity;
+
+public class SysMenu {
+
+    private Long id;
+    private String menuName;
+    private Long parentId;
+    private String routePath;
+    private String component;
+    private String icon;
+    private Integer sort;
+    private Integer visible;
+    private String permissionCode;
+    private String createTime;
+    private String updateTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getMenuName() { return menuName; }
+    public void setMenuName(String menuName) { this.menuName = menuName; }
+    public Long getParentId() { return parentId; }
+    public void setParentId(Long parentId) { this.parentId = parentId; }
+    public String getRoutePath() { return routePath; }
+    public void setRoutePath(String routePath) { this.routePath = routePath; }
+    public String getComponent() { return component; }
+    public void setComponent(String component) { this.component = component; }
+    public String getIcon() { return icon; }
+    public void setIcon(String icon) { this.icon = icon; }
+    public Integer getSort() { return sort; }
+    public void setSort(Integer sort) { this.sort = sort; }
+    public Integer getVisible() { return visible; }
+    public void setVisible(Integer visible) { this.visible = visible; }
+    public String getPermissionCode() { return permissionCode; }
+    public void setPermissionCode(String permissionCode) { this.permissionCode = permissionCode; }
+    public String getCreateTime() { return createTime; }
+    public void setCreateTime(String createTime) { this.createTime = createTime; }
+    public String getUpdateTime() { return updateTime; }
+    public void setUpdateTime(String updateTime) { this.updateTime = updateTime; }
+}

+ 27 - 0
src/main/java/com/video/script/admin/entity/SysRole.java

@@ -0,0 +1,27 @@
+package com.video.script.admin.entity;
+
+public class SysRole {
+
+    private Long id;
+    private String roleCode;
+    private String roleName;
+    private String remark;
+    private Integer status;
+    private String createTime;
+    private String updateTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getRoleCode() { return roleCode; }
+    public void setRoleCode(String roleCode) { this.roleCode = roleCode; }
+    public String getRoleName() { return roleName; }
+    public void setRoleName(String roleName) { this.roleName = roleName; }
+    public String getRemark() { return remark; }
+    public void setRemark(String remark) { this.remark = remark; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+    public String getCreateTime() { return createTime; }
+    public void setCreateTime(String createTime) { this.createTime = createTime; }
+    public String getUpdateTime() { return updateTime; }
+    public void setUpdateTime(String updateTime) { this.updateTime = updateTime; }
+}

+ 42 - 0
src/main/java/com/video/script/admin/entity/SysUser.java

@@ -0,0 +1,42 @@
+package com.video.script.admin.entity;
+
+public class SysUser {
+
+    private Long id;
+    private String username;
+    private String password;
+    private String nickname;
+    private String phone;
+    private Long deptId;
+    private String deptName;
+    private Long roleId;
+    private String roleName;
+    private Integer status;
+    private String createTime;
+    private String updateTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+    public String getNickname() { return nickname; }
+    public void setNickname(String nickname) { this.nickname = nickname; }
+    public String getPhone() { return phone; }
+    public void setPhone(String phone) { this.phone = phone; }
+    public Long getDeptId() { return deptId; }
+    public void setDeptId(Long deptId) { this.deptId = deptId; }
+    public String getDeptName() { return deptName; }
+    public void setDeptName(String deptName) { this.deptName = deptName; }
+    public Long getRoleId() { return roleId; }
+    public void setRoleId(Long roleId) { this.roleId = roleId; }
+    public String getRoleName() { return roleName; }
+    public void setRoleName(String roleName) { this.roleName = roleName; }
+    public Integer getStatus() { return status; }
+    public void setStatus(Integer status) { this.status = status; }
+    public String getCreateTime() { return createTime; }
+    public void setCreateTime(String createTime) { this.createTime = createTime; }
+    public String getUpdateTime() { return updateTime; }
+    public void setUpdateTime(String updateTime) { this.updateTime = updateTime; }
+}

+ 60 - 0
src/main/java/com/video/script/admin/entity/VideoScriptTask.java

@@ -0,0 +1,60 @@
+package com.video.script.admin.entity;
+
+public class VideoScriptTask {
+
+    private Long id;
+    private String taskName;
+    private String videoUrl;
+    private String videoUrls;
+    private String scriptType;
+    private String status;
+    private String promptHint;
+    private String transcript;
+    private String videoContent;
+    private String analysisContent;
+    private String scriptContent;
+    private String summary;
+    private String codedeskTaskId;
+    private String errorMessage;
+    private Long creatorId;
+    private String creatorName;
+    private String createTime;
+    private String updateTime;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public String getTaskName() { return taskName; }
+    public void setTaskName(String taskName) { this.taskName = taskName; }
+    public String getVideoUrl() { return videoUrl; }
+    public void setVideoUrl(String videoUrl) { this.videoUrl = videoUrl; }
+    public String getVideoUrls() { return videoUrls; }
+    public void setVideoUrls(String videoUrls) { this.videoUrls = videoUrls; }
+    public String getScriptType() { return scriptType; }
+    public void setScriptType(String scriptType) { this.scriptType = scriptType; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public String getPromptHint() { return promptHint; }
+    public void setPromptHint(String promptHint) { this.promptHint = promptHint; }
+    public String getTranscript() { return transcript; }
+    public void setTranscript(String transcript) { this.transcript = transcript; }
+    public String getVideoContent() { return videoContent; }
+    public void setVideoContent(String videoContent) { this.videoContent = videoContent; }
+    public String getAnalysisContent() { return analysisContent; }
+    public void setAnalysisContent(String analysisContent) { this.analysisContent = analysisContent; }
+    public String getScriptContent() { return scriptContent; }
+    public void setScriptContent(String scriptContent) { this.scriptContent = scriptContent; }
+    public String getSummary() { return summary; }
+    public void setSummary(String summary) { this.summary = summary; }
+    public String getCodedeskTaskId() { return codedeskTaskId; }
+    public void setCodedeskTaskId(String codedeskTaskId) { this.codedeskTaskId = codedeskTaskId; }
+    public String getErrorMessage() { return errorMessage; }
+    public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
+    public Long getCreatorId() { return creatorId; }
+    public void setCreatorId(Long creatorId) { this.creatorId = creatorId; }
+    public String getCreatorName() { return creatorName; }
+    public void setCreatorName(String creatorName) { this.creatorName = creatorName; }
+    public String getCreateTime() { return createTime; }
+    public void setCreateTime(String createTime) { this.createTime = createTime; }
+    public String getUpdateTime() { return updateTime; }
+    public void setUpdateTime(String updateTime) { this.updateTime = updateTime; }
+}

+ 18 - 0
src/main/java/com/video/script/admin/mapper/SysDeptMapper.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.mapper;
+
+import com.video.script.admin.entity.SysDept;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysDeptMapper {
+
+    List<SysDept> selectList(@Param("keyword") String keyword);
+
+    SysDept selectById(@Param("id") Long id);
+
+    int insert(SysDept entity);
+
+    int update(SysDept entity);
+
+    int deleteById(@Param("id") Long id);
+}

+ 22 - 0
src/main/java/com/video/script/admin/mapper/SysMenuMapper.java

@@ -0,0 +1,22 @@
+package com.video.script.admin.mapper;
+
+import com.video.script.admin.entity.SysMenu;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysMenuMapper {
+
+    List<SysMenu> selectList(@Param("keyword") String keyword);
+
+    List<SysMenu> selectAll();
+
+    List<SysMenu> selectByRoleId(@Param("roleId") Long roleId);
+
+    SysMenu selectById(@Param("id") Long id);
+
+    int insert(SysMenu entity);
+
+    int update(SysMenu entity);
+
+    int deleteById(@Param("id") Long id);
+}

+ 18 - 0
src/main/java/com/video/script/admin/mapper/SysRoleMapper.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.mapper;
+
+import com.video.script.admin.entity.SysRole;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysRoleMapper {
+
+    List<SysRole> selectList(@Param("keyword") String keyword);
+
+    SysRole selectById(@Param("id") Long id);
+
+    int insert(SysRole entity);
+
+    int update(SysRole entity);
+
+    int deleteById(@Param("id") Long id);
+}

+ 15 - 0
src/main/java/com/video/script/admin/mapper/SysRoleMenuMapper.java

@@ -0,0 +1,15 @@
+package com.video.script.admin.mapper;
+
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysRoleMenuMapper {
+
+    List<Long> selectMenuIdsByRoleId(@Param("roleId") Long roleId);
+
+    List<Long> selectValidMenuIds(@Param("menuIds") List<Long> menuIds);
+
+    int deleteByRoleId(@Param("roleId") Long roleId);
+
+    int batchInsert(@Param("roleId") Long roleId, @Param("menuIds") List<Long> menuIds);
+}

+ 20 - 0
src/main/java/com/video/script/admin/mapper/SysUserMapper.java

@@ -0,0 +1,20 @@
+package com.video.script.admin.mapper;
+
+import com.video.script.admin.entity.SysUser;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface SysUserMapper {
+
+    SysUser selectByUsername(@Param("username") String username);
+
+    SysUser selectById(@Param("id") Long id);
+
+    List<SysUser> selectList(@Param("keyword") String keyword);
+
+    int insert(SysUser entity);
+
+    int update(SysUser entity);
+
+    int deleteById(@Param("id") Long id);
+}

+ 36 - 0
src/main/java/com/video/script/admin/mapper/VideoScriptTaskMapper.java

@@ -0,0 +1,36 @@
+package com.video.script.admin.mapper;
+
+import com.video.script.admin.entity.VideoScriptTask;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+
+public interface VideoScriptTaskMapper {
+
+    List<VideoScriptTask> selectList(@Param("keyword") String keyword, @Param("status") String status);
+
+    VideoScriptTask selectById(@Param("id") Long id);
+
+    VideoScriptTask selectByCodedeskTaskId(@Param("codedeskTaskId") String codedeskTaskId);
+
+    List<VideoScriptTask> selectByStatus(@Param("status") String status);
+
+    int insert(VideoScriptTask entity);
+
+    int update(VideoScriptTask entity);
+
+    int updateStatus(@Param("id") Long id,
+                     @Param("sourceStatus") String sourceStatus,
+                     @Param("targetStatus") String targetStatus);
+
+    int updateCallbackResult(@Param("id") Long id,
+                             @Param("status") String status,
+                             @Param("transcript") String transcript,
+                             @Param("videoContent") String videoContent,
+                             @Param("analysisContent") String analysisContent,
+                             @Param("scriptContent") String scriptContent,
+                             @Param("summary") String summary,
+                             @Param("codedeskTaskId") String codedeskTaskId,
+                             @Param("errorMessage") String errorMessage);
+
+    int deleteById(@Param("id") Long id);
+}

+ 11 - 0
src/main/java/com/video/script/admin/service/AuthService.java

@@ -0,0 +1,11 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.LoginRequest;
+import com.video.script.admin.vo.LoginVO;
+
+public interface AuthService {
+
+    LoginVO login(LoginRequest request);
+
+    LoginVO profile();
+}

+ 14 - 0
src/main/java/com/video/script/admin/service/DeptService.java

@@ -0,0 +1,14 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.DeptSaveRequest;
+import com.video.script.admin.entity.SysDept;
+import java.util.List;
+
+public interface DeptService {
+
+    List<SysDept> list(String keyword);
+
+    void save(DeptSaveRequest request);
+
+    void delete(Long id);
+}

+ 27 - 0
src/main/java/com/video/script/admin/service/LlmScriptClient.java

@@ -0,0 +1,27 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.entity.VideoScriptTask;
+
+public interface LlmScriptClient {
+
+    ScriptGenerationResult generate(VideoScriptTask task);
+
+    class ScriptGenerationResult {
+        private final String scriptContent;
+        private final String analysisContent;
+        private final String summary;
+        private final String rawResponse;
+
+        public ScriptGenerationResult(String scriptContent, String analysisContent, String summary, String rawResponse) {
+            this.scriptContent = scriptContent;
+            this.analysisContent = analysisContent;
+            this.summary = summary;
+            this.rawResponse = rawResponse;
+        }
+
+        public String getScriptContent() { return scriptContent; }
+        public String getAnalysisContent() { return analysisContent; }
+        public String getSummary() { return summary; }
+        public String getRawResponse() { return rawResponse; }
+    }
+}

+ 18 - 0
src/main/java/com/video/script/admin/service/MenuService.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.MenuSaveRequest;
+import com.video.script.admin.entity.SysMenu;
+import java.util.List;
+
+public interface MenuService {
+
+    List<SysMenu> list(String keyword);
+
+    List<SysMenu> allMenus();
+
+    List<SysMenu> listByRoleId(Long roleId);
+
+    void save(MenuSaveRequest request);
+
+    void delete(Long id);
+}

+ 18 - 0
src/main/java/com/video/script/admin/service/RoleService.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.RoleSaveRequest;
+import com.video.script.admin.entity.SysRole;
+import java.util.List;
+
+public interface RoleService {
+
+    List<SysRole> list(String keyword);
+
+    void save(RoleSaveRequest request);
+
+    void delete(Long id);
+
+    List<Long> getMenuIds(Long roleId);
+
+    void saveRoleMenus(Long roleId, List<Long> menuIds);
+}

+ 19 - 0
src/main/java/com/video/script/admin/service/TaskScheduler.java

@@ -0,0 +1,19 @@
+package com.video.script.admin.service;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component("pendingTaskScheduler")
+public class TaskScheduler {
+
+    private final TaskService taskService;
+
+    public TaskScheduler(TaskService taskService) {
+        this.taskService = taskService;
+    }
+
+    @Scheduled(fixedDelayString = "${task.scheduler.fixed-delay-ms:60000}")
+    public void processPendingTasks() {
+        taskService.processPendingTasks();
+    }
+}

+ 18 - 0
src/main/java/com/video/script/admin/service/TaskService.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.TaskSaveRequest;
+import com.video.script.admin.entity.VideoScriptTask;
+import java.util.List;
+
+public interface TaskService {
+
+    List<VideoScriptTask> list(String keyword, String status);
+
+    VideoScriptTask getById(Long id);
+
+    void save(TaskSaveRequest request);
+
+    void processPendingTasks();
+
+    void delete(Long id);
+}

+ 10 - 0
src/main/java/com/video/script/admin/service/TokenService.java

@@ -0,0 +1,10 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.common.LoginUser;
+
+public interface TokenService {
+
+    String generate(LoginUser loginUser);
+
+    LoginUser parse(String token);
+}

+ 14 - 0
src/main/java/com/video/script/admin/service/UserService.java

@@ -0,0 +1,14 @@
+package com.video.script.admin.service;
+
+import com.video.script.admin.dto.UserSaveRequest;
+import com.video.script.admin.entity.SysUser;
+import java.util.List;
+
+public interface UserService {
+
+    List<SysUser> list(String keyword);
+
+    void save(UserSaveRequest request);
+
+    void delete(Long id);
+}

+ 24 - 0
src/main/java/com/video/script/admin/service/VideoTranscriptionClient.java

@@ -0,0 +1,24 @@
+package com.video.script.admin.service;
+
+public interface VideoTranscriptionClient {
+
+    TranscriptionResult transcribe(String videoUrl);
+
+    class TranscriptionResult {
+        private final String transcript;
+        private final String structuredContent;
+
+        public TranscriptionResult(String transcript, String structuredContent) {
+            this.transcript = transcript;
+            this.structuredContent = structuredContent;
+        }
+
+        public String getTranscript() {
+            return transcript;
+        }
+
+        public String getStructuredContent() {
+            return structuredContent;
+        }
+    }
+}

+ 393 - 0
src/main/java/com/video/script/admin/service/impl/AliyunAsrClient.java

@@ -0,0 +1,393 @@
+package com.video.script.admin.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.config.AsrProperties;
+import com.video.script.admin.service.VideoTranscriptionClient;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+
+@Component
+public class AliyunAsrClient implements VideoTranscriptionClient {
+
+    private static final Logger log = LoggerFactory.getLogger(AliyunAsrClient.class);
+
+    private final RestTemplate restTemplate;
+    private final AsrProperties properties;
+    private final ObjectMapper objectMapper;
+
+    public AliyunAsrClient(RestTemplate restTemplate, AsrProperties properties, ObjectMapper objectMapper) {
+        this.restTemplate = restTemplate;
+        this.properties = properties;
+        this.objectMapper = objectMapper;
+    }
+
+    @Override
+    public TranscriptionResult transcribe(String videoUrl) {
+        if (!properties.isEnabled()) {
+            throw new BusinessException("ASR 未启用");
+        }
+        if (StringUtils.isAnyBlank(properties.getBaseUrl(), properties.getSubmitPath(), properties.getApiKey(), properties.getModel())) {
+            throw new BusinessException("ASR 配置不完整");
+        }
+        String taskId = submit(videoUrl);
+        return pollTranscript(taskId, videoUrl);
+    }
+
+    private String submit(String videoUrl) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("model", properties.getModel());
+        body.put("input", Collections.singletonMap("file_urls", Collections.singletonList(videoUrl)));
+        body.put("parameters", new LinkedHashMap<>());
+
+        HttpHeaders headers = buildHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set("X-DashScope-Async", "enable");
+
+        String url = normalizeBaseUrl(properties.getBaseUrl()) + normalizePath(properties.getSubmitPath());
+        try {
+            String raw = restTemplate.postForObject(url, new HttpEntity<>(body, headers), String.class);
+            JsonNode root = objectMapper.readTree(raw);
+            String taskId = text(root, "task_id", "taskId");
+            if (StringUtils.isBlank(taskId)) {
+                taskId = text(root.path("output"), "task_id", "taskId");
+            }
+            if (StringUtils.isBlank(taskId)) {
+                throw new BusinessException("ASR 提交成功但未返回 task_id");
+            }
+            log.info("ASR submit success, taskId={}, videoUrl={}", taskId, videoUrl);
+            return taskId;
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new BusinessException("ASR 提交失败: " + ex.getMessage());
+        }
+    }
+
+    private TranscriptionResult pollTranscript(String taskId, String videoUrl) {
+        String taskPath = properties.getTaskPathTemplate().replace("{taskId}", taskId);
+        String url = normalizeBaseUrl(properties.getBaseUrl()) + normalizePath(taskPath);
+        HttpHeaders headers = buildHeaders();
+        HttpEntity<Void> entity = new HttpEntity<>(headers);
+        for (int i = 0; i < properties.getMaxPolls(); i++) {
+            try {
+                ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
+                JsonNode root = objectMapper.readTree(response.getBody());
+                String status = text(root, "task_status", "status");
+                if (StringUtils.isBlank(status)) {
+                    status = text(root.path("output"), "task_status", "status");
+                }
+                if ("SUCCEEDED".equalsIgnoreCase(status)) {
+                    log.info("ASR task succeeded, taskId={}, summary={}", taskId, summarizeTaskResult(root));
+                    TranscriptionResult result = extractTranscript(root, videoUrl);
+                    if (result == null || StringUtils.isBlank(result.getTranscript())) {
+                        log.warn("ASR task has no inline transcript, taskId={}, rawResult={}", taskId, compressJson(root));
+                        throw new BusinessException("ASR 已完成但未返回转写文本");
+                    }
+                    return result;
+                }
+                if ("FAILED".equalsIgnoreCase(status)) {
+                    throw new BusinessException("ASR 转写失败: " + firstNonBlank(
+                            text(root, "message", "error_message"),
+                            text(root.path("output"), "message", "error_message")
+                    ));
+                }
+                Thread.sleep(properties.getPollIntervalMs());
+            } catch (BusinessException ex) {
+                throw ex;
+            } catch (InterruptedException ex) {
+                Thread.currentThread().interrupt();
+                throw new BusinessException("ASR 轮询被中断");
+            } catch (RestClientException ex) {
+                throw new BusinessException("ASR 查询失败: " + ex.getMessage());
+            } catch (Exception ex) {
+                throw new BusinessException("ASR 结果解析失败: " + ex.getMessage());
+            }
+        }
+        throw new BusinessException("ASR 转写超时");
+    }
+
+    private TranscriptionResult extractTranscript(JsonNode root, String videoUrl) {
+        JsonNode output = root.path("output");
+        String transcript = text(output, "text", "transcript");
+        if (StringUtils.isNotBlank(transcript)) {
+            return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+        }
+        JsonNode results = output.path("results");
+        if (results.isArray() && results.size() > 0) {
+            JsonNode first = results.get(0);
+            transcript = text(first, "text", "transcript");
+            if (StringUtils.isNotBlank(transcript)) {
+                return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+            }
+            String transcriptionUrl = text(first, "transcription_url", "transcriptionUrl");
+            if (StringUtils.isNotBlank(transcriptionUrl)) {
+                TranscriptionResult result = downloadTranscript(transcriptionUrl, videoUrl);
+                if (result != null && StringUtils.isNotBlank(result.getTranscript())) {
+                    return result;
+                }
+            }
+            JsonNode sentences = first.path("sentences");
+            transcript = joinSentences(sentences);
+            if (StringUtils.isNotBlank(transcript)) {
+                return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+            }
+        }
+        return null;
+    }
+
+    private TranscriptionResult downloadTranscript(String transcriptionUrl, String videoUrl) {
+        try {
+            log.info("Downloading ASR transcript file, url={}", transcriptionUrl);
+            ResponseEntity<String> response = restTemplate.exchange(URI.create(transcriptionUrl), HttpMethod.GET, HttpEntity.EMPTY, String.class);
+            String raw = response.getBody();
+            if (StringUtils.isBlank(raw)) {
+                log.warn("ASR transcript file is empty, url={}", transcriptionUrl);
+                return null;
+            }
+            JsonNode root = objectMapper.readTree(raw);
+            log.info("ASR transcript file downloaded, summary={}", summarizeTranscriptResult(root));
+            String transcript = text(root, "text", "transcript");
+            if (StringUtils.isNotBlank(transcript)) {
+                return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+            }
+            JsonNode results = root.path("results");
+            if (results.isArray() && results.size() > 0) {
+                JsonNode first = results.get(0);
+                transcript = text(first, "text", "transcript");
+                if (StringUtils.isNotBlank(transcript)) {
+                    return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+                }
+                transcript = joinSentences(first.path("sentences"));
+                if (StringUtils.isNotBlank(transcript)) {
+                    return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+                }
+            }
+            JsonNode transcripts = root.path("transcripts");
+            if (transcripts.isArray() && transcripts.size() > 0) {
+                transcript = extractFromTranscriptItems(transcripts);
+                if (StringUtils.isNotBlank(transcript)) {
+                    return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+                }
+            }
+            transcript = joinSentences(root.path("sentences"));
+            if (StringUtils.isNotBlank(transcript)) {
+                return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+            }
+            JsonNode paragraphs = root.path("paragraphs");
+            if (paragraphs.isArray()) {
+                StringBuilder sb = new StringBuilder();
+                for (JsonNode paragraph : paragraphs) {
+                    String paragraphText = text(paragraph, "text", "transcript");
+                    if (StringUtils.isNotBlank(paragraphText)) {
+                        if (sb.length() > 0) {
+                            sb.append('\n');
+                        }
+                        sb.append(paragraphText);
+                    }
+                }
+                if (sb.length() > 0) {
+                    transcript = sb.toString();
+                    return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
+                }
+            }
+            return null;
+        } catch (Exception ex) {
+            log.error("ASR transcript file download failed, url={}, message={}", transcriptionUrl, ex.getMessage());
+            throw new BusinessException("ASR 结果文件下载失败: " + ex.getMessage());
+        }
+    }
+
+    private String joinSentences(JsonNode sentences) {
+        if (!sentences.isArray()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (JsonNode sentence : sentences) {
+            String sentenceText = text(sentence, "text");
+            if (StringUtils.isNotBlank(sentenceText)) {
+                if (sb.length() > 0) {
+                    sb.append('\n');
+                }
+                sb.append(sentenceText);
+            }
+        }
+        return sb.length() > 0 ? sb.toString() : null;
+    }
+
+    private String extractFromTranscriptItems(JsonNode transcripts) {
+        StringBuilder sb = new StringBuilder();
+        for (JsonNode item : transcripts) {
+            String text = firstNonBlank(
+                    this.text(item, "text", "transcript", "content"),
+                    joinSentences(item.path("sentences"))
+            );
+            if (StringUtils.isNotBlank(text)) {
+                if (sb.length() > 0) {
+                    sb.append('\n');
+                }
+                sb.append(text);
+            }
+        }
+        return sb.length() > 0 ? sb.toString() : null;
+    }
+
+    private String buildStructuredContent(JsonNode root, String videoUrl, String transcript) {
+        StringBuilder sb = new StringBuilder();
+        JsonNode propertiesNode = root.path("properties");
+        String durationMs = text(propertiesNode, "original_duration_in_milliseconds", "content_duration_in_milliseconds");
+        if (StringUtils.isNotBlank(durationMs)) {
+            sb.append("时长毫秒: ").append(durationMs).append('\n');
+        }
+        String audioFormat = text(propertiesNode, "audio_format");
+        if (StringUtils.isNotBlank(audioFormat)) {
+            sb.append("音频格式: ").append(audioFormat).append('\n');
+        }
+        sb.append("全文转写: ").append(defaultText(transcript, "无")).append('\n');
+
+        List<String> sentenceLines = collectSentenceLines(root);
+        if (!sentenceLines.isEmpty()) {
+            sb.append("句子时间轴:\n");
+            for (String line : sentenceLines) {
+                sb.append(line).append('\n');
+            }
+        }
+        return sb.toString().trim();
+    }
+
+    private List<String> collectSentenceLines(JsonNode root) {
+        List<String> lines = new ArrayList<>();
+        appendSentenceLines(root.path("sentences"), lines);
+        JsonNode results = root.path("results");
+        if (results.isArray()) {
+            for (JsonNode item : results) {
+                appendSentenceLines(item.path("sentences"), lines);
+            }
+        }
+        JsonNode transcripts = root.path("transcripts");
+        if (transcripts.isArray()) {
+            for (JsonNode item : transcripts) {
+                appendSentenceLines(item.path("sentences"), lines);
+            }
+        }
+        return lines;
+    }
+
+    private void appendSentenceLines(JsonNode sentences, List<String> lines) {
+        if (!sentences.isArray()) {
+            return;
+        }
+        for (JsonNode sentence : sentences) {
+            String text = text(sentence, "text", "transcript");
+            if (StringUtils.isBlank(text)) {
+                continue;
+            }
+            String begin = formatMs(sentence.path("begin_time").asLong(-1));
+            String end = formatMs(sentence.path("end_time").asLong(-1));
+            if (begin != null && end != null) {
+                lines.add("[" + begin + "-" + end + "] " + text);
+            } else {
+                lines.add(text);
+            }
+        }
+    }
+
+    private String formatMs(long ms) {
+        if (ms < 0) {
+            return null;
+        }
+        long totalSeconds = ms / 1000;
+        long minutes = totalSeconds / 60;
+        long seconds = totalSeconds % 60;
+        long millis = ms % 1000;
+        return String.format("%02d:%02d.%03d", minutes, seconds, millis);
+    }
+
+    private HttpHeaders buildHeaders() {
+        HttpHeaders headers = new HttpHeaders();
+        headers.set(properties.getAuthHeader(), buildAuthorizationValue(properties.getAuthPrefix(), properties.getApiKey()));
+        return headers;
+    }
+
+    private String normalizeBaseUrl(String baseUrl) {
+        return StringUtils.removeEnd(baseUrl, "/");
+    }
+
+    private String normalizePath(String path) {
+        return path.startsWith("/") ? path : "/" + path;
+    }
+
+    private String text(JsonNode node, String... names) {
+        for (String name : names) {
+            JsonNode value = node.get(name);
+            if (value != null && !value.isNull() && StringUtils.isNotBlank(value.asText())) {
+                return value.asText();
+            }
+        }
+        return null;
+    }
+
+    private String firstNonBlank(String... texts) {
+        for (String text : texts) {
+            if (StringUtils.isNotBlank(text)) {
+                return text;
+            }
+        }
+        return "unknown error";
+    }
+
+    private String defaultText(String primary, String fallback) {
+        return StringUtils.defaultIfBlank(primary, fallback);
+    }
+
+    private String buildAuthorizationValue(String prefix, String apiKey) {
+        String normalizedPrefix = StringUtils.defaultIfBlank(prefix, "Bearer").trim();
+        return normalizedPrefix + " " + StringUtils.trim(apiKey);
+    }
+
+    private String summarizeTaskResult(JsonNode root) {
+        JsonNode output = root.path("output");
+        JsonNode results = output.path("results");
+        if (results.isArray() && results.size() > 0) {
+            JsonNode first = results.get(0);
+            return "status=" + firstNonBlank(text(root, "task_status", "status"), text(output, "task_status", "status"))
+                    + ", transcriptionUrl=" + firstNonBlank(text(first, "transcription_url", "transcriptionUrl"), "-")
+                    + ", hasSentences=" + first.path("sentences").isArray();
+        }
+        return "status=" + firstNonBlank(text(root, "task_status", "status"), text(output, "task_status", "status"))
+                + ", outputKeys=" + output.fieldNames().toString();
+    }
+
+    private String summarizeTranscriptResult(JsonNode root) {
+        return "hasText=" + StringUtils.isNotBlank(text(root, "text", "transcript"))
+                + ", hasSentences=" + root.path("sentences").isArray()
+                + ", hasResults=" + root.path("results").isArray()
+                + ", hasTranscripts=" + root.path("transcripts").isArray()
+                + ", hasParagraphs=" + root.path("paragraphs").isArray();
+    }
+
+    private String compressJson(JsonNode root) {
+        try {
+            String json = objectMapper.writeValueAsString(root);
+            return json.length() > 1000 ? json.substring(0, 1000) + "..." : json;
+        } catch (Exception ignored) {
+            return "{unserializable}";
+        }
+    }
+}

+ 58 - 0
src/main/java/com/video/script/admin/service/impl/AuthServiceImpl.java

@@ -0,0 +1,58 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.common.LoginUser;
+import com.video.script.admin.common.UserContext;
+import com.video.script.admin.dto.LoginRequest;
+import com.video.script.admin.entity.SysUser;
+import com.video.script.admin.mapper.SysUserMapper;
+import com.video.script.admin.service.AuthService;
+import com.video.script.admin.service.MenuService;
+import com.video.script.admin.service.TokenService;
+import com.video.script.admin.vo.LoginVO;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AuthServiceImpl implements AuthService {
+
+    private final SysUserMapper sysUserMapper;
+    private final TokenService tokenService;
+    private final MenuService menuService;
+
+    public AuthServiceImpl(SysUserMapper sysUserMapper, TokenService tokenService, MenuService menuService) {
+        this.sysUserMapper = sysUserMapper;
+        this.tokenService = tokenService;
+        this.menuService = menuService;
+    }
+
+    @Override
+    public LoginVO login(LoginRequest request) {
+        SysUser user = sysUserMapper.selectByUsername(request.getUsername());
+        if (user == null || !StringUtils.equals(user.getPassword(), request.getPassword())) {
+            throw new BusinessException("用户名或密码错误");
+        }
+        LoginUser loginUser = new LoginUser();
+        loginUser.setUserId(user.getId());
+        loginUser.setUsername(user.getUsername());
+        loginUser.setNickname(user.getNickname());
+        LoginVO result = new LoginVO();
+        result.setToken(tokenService.generate(loginUser));
+        result.setUserInfo(user);
+        result.setMenus(menuService.listByRoleId(user.getRoleId()));
+        return result;
+    }
+
+    @Override
+    public LoginVO profile() {
+        Long userId = UserContext.getUserId();
+        if (userId == null) {
+            throw new BusinessException("用户未登录");
+        }
+        SysUser user = sysUserMapper.selectById(userId);
+        LoginVO result = new LoginVO();
+        result.setUserInfo(user);
+        result.setMenus(menuService.listByRoleId(user.getRoleId()));
+        return result;
+    }
+}

+ 45 - 0
src/main/java/com/video/script/admin/service/impl/DeptServiceImpl.java

@@ -0,0 +1,45 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.dto.DeptSaveRequest;
+import com.video.script.admin.entity.SysDept;
+import com.video.script.admin.mapper.SysDeptMapper;
+import com.video.script.admin.service.DeptService;
+import java.util.List;
+import org.springframework.stereotype.Service;
+
+@Service
+public class DeptServiceImpl implements DeptService {
+
+    private final SysDeptMapper sysDeptMapper;
+
+    public DeptServiceImpl(SysDeptMapper sysDeptMapper) {
+        this.sysDeptMapper = sysDeptMapper;
+    }
+
+    @Override
+    public List<SysDept> list(String keyword) {
+        return sysDeptMapper.selectList(keyword);
+    }
+
+    @Override
+    public void save(DeptSaveRequest request) {
+        SysDept entity = new SysDept();
+        entity.setId(request.getId());
+        entity.setDeptName(request.getDeptName());
+        entity.setParentId(request.getParentId());
+        entity.setLeader(request.getLeader());
+        entity.setPhone(request.getPhone());
+        entity.setSort(request.getSort() == null ? 0 : request.getSort());
+        entity.setStatus(request.getStatus());
+        if (request.getId() == null) {
+            sysDeptMapper.insert(entity);
+        } else {
+            sysDeptMapper.update(entity);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        sysDeptMapper.deleteById(id);
+    }
+}

+ 57 - 0
src/main/java/com/video/script/admin/service/impl/MenuServiceImpl.java

@@ -0,0 +1,57 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.dto.MenuSaveRequest;
+import com.video.script.admin.entity.SysMenu;
+import com.video.script.admin.mapper.SysMenuMapper;
+import com.video.script.admin.service.MenuService;
+import java.util.List;
+import org.springframework.stereotype.Service;
+
+@Service
+public class MenuServiceImpl implements MenuService {
+
+    private final SysMenuMapper sysMenuMapper;
+
+    public MenuServiceImpl(SysMenuMapper sysMenuMapper) {
+        this.sysMenuMapper = sysMenuMapper;
+    }
+
+    @Override
+    public List<SysMenu> list(String keyword) {
+        return sysMenuMapper.selectList(keyword);
+    }
+
+    @Override
+    public List<SysMenu> allMenus() {
+        return sysMenuMapper.selectAll();
+    }
+
+    @Override
+    public List<SysMenu> listByRoleId(Long roleId) {
+        return sysMenuMapper.selectByRoleId(roleId);
+    }
+
+    @Override
+    public void save(MenuSaveRequest request) {
+        SysMenu entity = new SysMenu();
+        entity.setId(request.getId());
+        entity.setMenuName(request.getMenuName());
+        entity.setParentId(request.getParentId());
+        entity.setRoutePath(request.getRoutePath());
+        entity.setComponent(request.getComponent());
+        entity.setIcon(request.getIcon());
+        entity.setSort(request.getSort() == null ? 0 : request.getSort());
+        entity.setVisible(request.getVisible() == null ? 1 : request.getVisible());
+        entity.setPermissionCode(request.getPermissionCode());
+        if (request.getId() == null) {
+            sysMenuMapper.insert(entity);
+        } else {
+            sysMenuMapper.update(entity);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        sysMenuMapper.deleteById(id);
+    }
+}

+ 304 - 0
src/main/java/com/video/script/admin/service/impl/OpenAiCompatibleLlmClient.java

@@ -0,0 +1,304 @@
+package com.video.script.admin.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.config.LlmProperties;
+import com.video.script.admin.entity.VideoScriptTask;
+import com.video.script.admin.service.LlmScriptClient;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+
+@Component
+public class OpenAiCompatibleLlmClient implements LlmScriptClient {
+
+    private static final Logger log = LoggerFactory.getLogger(OpenAiCompatibleLlmClient.class);
+
+    private static final String SYSTEM_PROMPT =
+            "你是中文信息流短视频广告脚本策划助手。请基于给定的多视频结构化内容、转写文本、句子时间轴和视频画面输出严格 JSON,键固定为 scriptContent、analysisContent 和 summary。"
+                    + "默认按信息流短视频广告成片来策划,目标是提升完播、停留、点击或转化。"
+                    + "scriptContent 必须是重新策划后的成片脚本,能直接用于拍摄或录制,必须完成深度改编,不能只是摘抄、拼接、同义替换或轻微改写原始转写。"
+                    + "scriptContent 必须优先吸收多个素材中的跑量点、钩子、痛点、利益点、证明点和行动引导,再重组为一条更适合投流的广告脚本。"
+                    + "analysisContent 必须单独总结当前视频素材的优点、缺点和改进建议,写成可给运营/编导直接使用的分析文案。"
+                    + "优先利用视频画面、句子时间轴、视频顺序和全文转写来判断开场钩子、转折、证明段和结论。"
+                    + "如果原素材里有两个以上视频片段,scriptContent 必须完成融合重组和广告化改编,而不是按原片顺序逐段照搬。"
+                    + "最终脚本必须具备明确的广告节奏,例如痛点切入、效果承诺、场景证明、利益强化、行动引导,但不要机械套模板。"
+                    + "如果素材信息不足,也要基于已有卖点做合理重组,不要把原文改写后原样铺开。"
+                    + "不要输出 markdown,不要输出额外解释。";
+
+    private final RestTemplate restTemplate;
+    private final LlmProperties properties;
+    private final ObjectMapper objectMapper;
+
+    public OpenAiCompatibleLlmClient(RestTemplate restTemplate, LlmProperties properties, ObjectMapper objectMapper) {
+        this.restTemplate = restTemplate;
+        this.properties = properties;
+        this.objectMapper = objectMapper;
+    }
+
+    @Override
+    public ScriptGenerationResult generate(VideoScriptTask task) {
+        if (!properties.isEnabled()) {
+            throw new BusinessException("LLM 未启用");
+        }
+        if (StringUtils.isAnyBlank(properties.getBaseUrl(), properties.getEndpoint(), properties.getApiKey(), properties.getModel())) {
+            throw new BusinessException("LLM 配置不完整");
+        }
+        if (StringUtils.isAllBlank(task.getTranscript(), task.getVideoContent()) && resolveVideoUrls(task).isEmpty()) {
+            throw new BusinessException("缺少输入内容。请至少提供 transcript、videoContent 或 videoUrls");
+        }
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("model", properties.getModel());
+        body.put("messages", buildMessages(task, false));
+        body.put("response_format", jsonObject("type", "json_object"));
+        body.put("stream", false);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.set(properties.getAuthHeader(), buildAuthorizationValue(properties.getAuthPrefix(), properties.getApiKey()));
+
+        String url = normalizeBaseUrl(properties.getBaseUrl()) + normalizePath(properties.getEndpoint());
+        try {
+            logLlmRequest(task, url, body);
+            log.info("Calling LLM provider={}, url={}, model={}, authHeader={}, apiKey={}",
+                    properties.getProvider(),
+                    url,
+                    properties.getModel(),
+                    properties.getAuthHeader(),
+                    maskKey(properties.getApiKey()));
+            String rawResponse = restTemplate.postForObject(url, new HttpEntity<>(body, headers), String.class);
+            ScriptGenerationResult result = parseResponse(rawResponse);
+            if (shouldRetryAsTranscriptEcho(task, result)) {
+                log.warn("LLM output looks like transcript echo, retrying with stricter prompt, taskName={}", task.getTaskName());
+                body.put("messages", buildMessages(task, true));
+                rawResponse = restTemplate.postForObject(url, new HttpEntity<>(body, headers), String.class);
+                result = parseResponse(rawResponse);
+            }
+            return result;
+        } catch (RestClientException ex) {
+            throw new BusinessException("LLM 调用失败: " + ex.getMessage());
+        }
+    }
+
+    private void logLlmRequest(VideoScriptTask task, String url, Map<String, Object> body) {
+        try {
+            String requestBody = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(body);
+            log.info("LLM request payload, taskId={}, taskName={}, url={}, payload=\n{}",
+                    task.getId(),
+                    task.getTaskName(),
+                    url,
+                    requestBody);
+        } catch (Exception ex) {
+            log.warn("Failed to serialize LLM request payload, taskId={}, taskName={}, error={}",
+                    task.getId(),
+                    task.getTaskName(),
+                    ex.getMessage());
+        }
+    }
+
+    private List<Map<String, Object>> buildMessages(VideoScriptTask task, boolean retryMode) {
+        List<Map<String, Object>> messages = new ArrayList<>();
+        messages.add(message("system", SYSTEM_PROMPT));
+        List<String> videoUrls = resolveVideoUrls(task);
+        int videoCount = videoUrls.size();
+        StringBuilder userPrompt = new StringBuilder();
+        userPrompt.append("任务名称:").append(defaultText(task.getTaskName())).append("\n")
+                .append("脚本类型:").append(defaultText(task.getScriptType())).append("\n")
+                .append("视频数量:").append(videoCount > 0 ? videoCount : 1).append("\n\n")
+                .append("用户要求:\n").append(defaultText(resolvePromptHint(task))).append("\n\n")
+                .append("视频结构化内容:\n").append(defaultText(task.getVideoContent())).append("\n\n")
+                .append("转写文本:\n").append(defaultText(task.getTranscript())).append("\n\n")
+                .append("脚本产出要求:\n").append(buildScriptTypeInstruction(task.getScriptType())).append("\n")
+                .append("分析文案要求:\n")
+                .append("请单独输出当前视频素材的优点、缺点和改进建议,至少包含【优点】【缺点】【改进建议】三个部分;内容要具体,能指导后续继续拍或重剪。\n")
+                .append("通用要求:\n")
+                .append("1. 这是信息流短视频广告,scriptContent 必须按广告成片来写,不要写成素材摘要、观后感或口语整理稿。\n")
+                .append("2. 输出必须是重新策划后的成片脚本,不允许大段照抄原始转写,不允许只做同义替换或轻微顺序调整。\n")
+                .append("3. 必须从素材中提炼并融合多个跑量点,包括但不限于开场钩子、痛点、使用场景、卖点、效果证明、情绪转折、行动引导。\n")
+                .append("4. 如果是多个视频,必须跨素材融合重组,优先抽取各片段最强卖点重新编排,不能按原视频顺序或原转写顺序直接拼接。\n")
+                .append("5. 必须有明确的开场钩子、主体展开、转折强化、结尾收束或行动引导,整体节奏要更适合投流跑量。\n")
+                .append("6. 必须体现可拍摄性,写清适合的画面/镜头/口播组织方式,让成片可直接拍摄、录制或重剪。\n")
+                .append("7. 在保持核心信息准确的前提下,主动替换原素材中的句式、表达顺序和段落组织,控制与原始转写的重复率。\n")
+                .append("8. 如果信息冲突,以更贴近视频画面和时间轴的内容为准;如果多个素材表达相近,要合并提炼,不要重复展开。\n");
+        if (retryMode) {
+            userPrompt.append("9. 上一次结果疑似照抄了原始转写。这一次请强制重写:不要沿用原文句式,不要按原段落顺序展开,先提炼卖点再重新组织成广告脚本。\n");
+        }
+        userPrompt.append("\n")
+                .append("输出 JSON 格式示例:")
+                .append("{\"scriptContent\":\"...\",\"analysisContent\":\"...\",\"summary\":\"...\"}");
+        if (properties.isMultimodalEnabled() && properties.isSendVideoUrls() && !videoUrls.isEmpty()) {
+            messages.add(multimodalUserMessage(userPrompt.toString(), videoUrls));
+        } else {
+            messages.add(message("user", userPrompt.toString()));
+        }
+        return messages;
+    }
+
+    private ScriptGenerationResult parseResponse(String rawResponse) {
+        if (StringUtils.isBlank(rawResponse)) {
+            throw new BusinessException("LLM 返回为空");
+        }
+        try {
+            JsonNode root = objectMapper.readTree(rawResponse);
+            JsonNode contentNode = root.path("choices").path(0).path("message").path("content");
+            String content = contentNode.asText(null);
+            if (StringUtils.isBlank(content)) {
+                throw new BusinessException("LLM 返回内容为空");
+            }
+            JsonNode result = objectMapper.readTree(content);
+            String scriptContent = result.path("scriptContent").asText(null);
+            String analysisContent = result.path("analysisContent").asText(null);
+            String summary = result.path("summary").asText(null);
+            if (StringUtils.isBlank(scriptContent)) {
+                throw new BusinessException("LLM 返回的 scriptContent 为空");
+            }
+            if (StringUtils.isBlank(analysisContent)) {
+                analysisContent = buildFallbackAnalysisContent(summary);
+            }
+            return new ScriptGenerationResult(scriptContent, analysisContent, summary, rawResponse);
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new BusinessException("解析 LLM 响应失败: " + ex.getMessage());
+        }
+    }
+
+    private Map<String, Object> message(String role, String content) {
+        Map<String, Object> message = new LinkedHashMap<>();
+        message.put("role", role);
+        message.put("content", content);
+        return message;
+    }
+
+    private Map<String, Object> multimodalUserMessage(String prompt, List<String> videoUrls) {
+        Map<String, Object> message = new LinkedHashMap<>();
+        message.put("role", "user");
+        List<Map<String, Object>> content = new ArrayList<>();
+        content.add(contentText(prompt));
+        for (String videoUrl : videoUrls) {
+            content.add(contentVideo(videoUrl));
+        }
+        message.put("content", content);
+        return message;
+    }
+
+    private Map<String, Object> contentText(String text) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("type", "text");
+        item.put("text", text);
+        return item;
+    }
+
+    private Map<String, Object> contentVideo(String videoUrl) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("type", "video_url");
+        item.put("video_url", jsonObject("url", videoUrl));
+        return item;
+    }
+
+    private Map<String, String> jsonObject(String key, String value) {
+        Map<String, String> map = new LinkedHashMap<>();
+        map.put(key, value);
+        return map;
+    }
+
+    private String normalizeBaseUrl(String baseUrl) {
+        return StringUtils.removeEnd(baseUrl, "/");
+    }
+
+    private String normalizePath(String path) {
+        return path.startsWith("/") ? path : "/" + path;
+    }
+
+    private String defaultText(String text) {
+        return StringUtils.defaultIfBlank(text, "无");
+    }
+
+    private String resolvePromptHint(VideoScriptTask task) {
+        if (StringUtils.isNotBlank(task.getPromptHint())) {
+            return task.getPromptHint();
+        }
+        if (StringUtils.isBlank(task.getTranscript()) && StringUtils.isNotBlank(task.getVideoContent())) {
+            return task.getVideoContent();
+        }
+        return null;
+    }
+
+    private String buildScriptTypeInstruction(String scriptType) {
+        if ("STORYBOARD".equalsIgnoreCase(scriptType)) {
+            return "请输出分镜稿。至少包含分段标题,每段包含画面/镜头、口播或字幕、重点动作或转场。";
+        }
+        if ("SUMMARY".equalsIgnoreCase(scriptType)) {
+            return "请输出摘要稿。要求保留核心卖点、结构节奏和可复用的口播要点,但不能直接照抄原转写。";
+        }
+        return "请输出信息流广告口播成片脚本。建议包含【开场钩子】【痛点/场景切入】【卖点展开】【证明或转折强化】【结尾引导】等部分;必要时可在每部分里补充括号说明画面、镜头或字幕。";
+    }
+
+    private boolean shouldRetryAsTranscriptEcho(VideoScriptTask task, ScriptGenerationResult result) {
+        if (result == null || StringUtils.isBlank(result.getScriptContent()) || StringUtils.isBlank(task.getTranscript())) {
+            return false;
+        }
+        String normalizedScript = normalizeForCompare(result.getScriptContent());
+        String normalizedTranscript = normalizeForCompare(task.getTranscript());
+        if (normalizedScript.equals(normalizedTranscript)) {
+            return true;
+        }
+        return normalizedTranscript.length() > 80
+                && normalizedScript.length() > 80
+                && normalizedScript.contains(normalizedTranscript.substring(0, Math.min(160, normalizedTranscript.length())));
+    }
+
+    private String normalizeForCompare(String text) {
+        return StringUtils.defaultString(text)
+                .replaceAll("\\s+", "")
+                .replaceAll("[【】\\[\\]()()::,。!?;、“”\"'—\\-]", "");
+    }
+
+    private String buildFallbackAnalysisContent(String summary) {
+        if (StringUtils.isBlank(summary)) {
+            return "优点:素材信息相对完整。\n缺点:模型未返回单独分析文案。\n改进建议:补充更明确的镜头目标、人物动作和卖点优先级后重新生成。";
+        }
+        return "优点:素材具备可提炼的卖点和结构信息。\n缺点:本次结果未单独展开优缺点分析。\n改进建议:" + summary;
+    }
+
+    private List<String> resolveVideoUrls(VideoScriptTask task) {
+        if (StringUtils.isNotBlank(task.getVideoUrls())) {
+            return Arrays.stream(task.getVideoUrls().split("\\r?\\n|,"))
+                    .filter(StringUtils::isNotBlank)
+                    .map(String::trim)
+                    .distinct()
+                    .collect(Collectors.toList());
+        }
+        if (StringUtils.isNotBlank(task.getVideoUrl())) {
+            return java.util.Collections.singletonList(task.getVideoUrl().trim());
+        }
+        return java.util.Collections.emptyList();
+    }
+
+    private String buildAuthorizationValue(String prefix, String apiKey) {
+        String normalizedPrefix = StringUtils.defaultIfBlank(prefix, "Bearer").trim();
+        return normalizedPrefix + " " + StringUtils.trim(apiKey);
+    }
+
+    private String maskKey(String apiKey) {
+        String value = StringUtils.trimToEmpty(apiKey);
+        if (value.length() <= 10) {
+            return "****";
+        }
+        return value.substring(0, 6) + "..." + value.substring(value.length() - 4);
+    }
+}

+ 78 - 0
src/main/java/com/video/script/admin/service/impl/RoleServiceImpl.java

@@ -0,0 +1,78 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.dto.RoleSaveRequest;
+import com.video.script.admin.entity.SysRole;
+import com.video.script.admin.mapper.SysRoleMapper;
+import com.video.script.admin.mapper.SysRoleMenuMapper;
+import com.video.script.admin.service.RoleService;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import org.springframework.stereotype.Service;
+
+@Service
+public class RoleServiceImpl implements RoleService {
+
+    private final SysRoleMapper sysRoleMapper;
+    private final SysRoleMenuMapper sysRoleMenuMapper;
+
+    public RoleServiceImpl(SysRoleMapper sysRoleMapper, SysRoleMenuMapper sysRoleMenuMapper) {
+        this.sysRoleMapper = sysRoleMapper;
+        this.sysRoleMenuMapper = sysRoleMenuMapper;
+    }
+
+    @Override
+    public List<SysRole> list(String keyword) {
+        return sysRoleMapper.selectList(keyword);
+    }
+
+    @Override
+    public void save(RoleSaveRequest request) {
+        SysRole entity = new SysRole();
+        entity.setId(request.getId());
+        entity.setRoleCode(request.getRoleCode());
+        entity.setRoleName(request.getRoleName());
+        entity.setRemark(request.getRemark());
+        entity.setStatus(request.getStatus());
+        if (request.getId() == null) {
+            sysRoleMapper.insert(entity);
+        } else {
+            sysRoleMapper.update(entity);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        sysRoleMenuMapper.deleteByRoleId(id);
+        sysRoleMapper.deleteById(id);
+    }
+
+    @Override
+    public List<Long> getMenuIds(Long roleId) {
+        ensureRoleExists(roleId);
+        return sysRoleMenuMapper.selectMenuIdsByRoleId(roleId);
+    }
+
+    @Override
+    public void saveRoleMenus(Long roleId, List<Long> menuIds) {
+        ensureRoleExists(roleId);
+        List<Long> normalizedMenuIds = new ArrayList<Long>(new LinkedHashSet<Long>(menuIds == null ? new ArrayList<Long>() : menuIds));
+        if (!normalizedMenuIds.isEmpty()) {
+            List<Long> validMenuIds = sysRoleMenuMapper.selectValidMenuIds(normalizedMenuIds);
+            if (validMenuIds.size() != normalizedMenuIds.size()) {
+                throw new BusinessException("存在无效菜单,无法保存角色权限");
+            }
+        }
+        sysRoleMenuMapper.deleteByRoleId(roleId);
+        if (!normalizedMenuIds.isEmpty()) {
+            sysRoleMenuMapper.batchInsert(roleId, normalizedMenuIds);
+        }
+    }
+
+    private void ensureRoleExists(Long roleId) {
+        if (sysRoleMapper.selectById(roleId) == null) {
+            throw new BusinessException("角色不存在");
+        }
+    }
+}

+ 300 - 0
src/main/java/com/video/script/admin/service/impl/TaskServiceImpl.java

@@ -0,0 +1,300 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.common.UserContext;
+import com.video.script.admin.config.AsrProperties;
+import com.video.script.admin.config.LlmProperties;
+import com.video.script.admin.dto.TaskSaveRequest;
+import com.video.script.admin.entity.VideoScriptTask;
+import com.video.script.admin.mapper.VideoScriptTaskMapper;
+import com.video.script.admin.service.LlmScriptClient;
+import com.video.script.admin.service.TaskService;
+import com.video.script.admin.service.VideoTranscriptionClient;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TaskServiceImpl implements TaskService {
+
+    private final VideoScriptTaskMapper videoScriptTaskMapper;
+    private final LlmScriptClient llmScriptClient;
+    private final LlmProperties llmProperties;
+    private final VideoTranscriptionClient videoTranscriptionClient;
+    private final AsrProperties asrProperties;
+
+    public TaskServiceImpl(VideoScriptTaskMapper videoScriptTaskMapper,
+                           LlmScriptClient llmScriptClient,
+                           LlmProperties llmProperties,
+                           VideoTranscriptionClient videoTranscriptionClient,
+                           AsrProperties asrProperties) {
+        this.videoScriptTaskMapper = videoScriptTaskMapper;
+        this.llmScriptClient = llmScriptClient;
+        this.llmProperties = llmProperties;
+        this.videoTranscriptionClient = videoTranscriptionClient;
+        this.asrProperties = asrProperties;
+    }
+
+    @Override
+    public List<VideoScriptTask> list(String keyword, String status) {
+        return videoScriptTaskMapper.selectList(keyword, status);
+    }
+
+    @Override
+    public VideoScriptTask getById(Long id) {
+        VideoScriptTask entity = videoScriptTaskMapper.selectById(id);
+        if (entity == null) {
+            throw new BusinessException("任务不存在");
+        }
+        return entity;
+    }
+
+    @Override
+    public void save(TaskSaveRequest request) {
+        if (request.getId() != null) {
+            updateOne(request);
+            return;
+        }
+        List<String> videoUrls = resolveVideoUrls(request);
+        if (videoUrls.isEmpty()) {
+            throw new BusinessException("视频链接不能为空");
+        }
+        VideoScriptTask entity = buildEntity(request, videoUrls);
+        videoScriptTaskMapper.insert(entity);
+    }
+
+    @Override
+    public void processPendingTasks() {
+        if (!llmProperties.isEnabled()) {
+            return;
+        }
+        List<VideoScriptTask> pendingTasks = videoScriptTaskMapper.selectByStatus("PENDING");
+        for (VideoScriptTask pendingTask : pendingTasks) {
+            int updated = videoScriptTaskMapper.updateStatus(pendingTask.getId(), "PENDING", "RUNNING");
+            if (updated != 1) {
+                continue;
+            }
+            pendingTask.setStatus("RUNNING");
+            generateScript(pendingTask);
+        }
+    }
+
+    private void updateOne(TaskSaveRequest request) {
+        VideoScriptTask entity = videoScriptTaskMapper.selectById(request.getId());
+        if (entity == null) {
+            throw new BusinessException("任务不存在");
+        }
+        entity.setId(request.getId());
+        if (StringUtils.isNotBlank(request.getTaskName())) {
+            entity.setTaskName(request.getTaskName());
+        }
+        if (StringUtils.isNotBlank(request.getVideoUrl())) {
+            entity.setVideoUrl(request.getVideoUrl());
+        }
+        if (request.getVideoUrls() != null) {
+            entity.setVideoUrls(joinVideoUrls(request.getVideoUrls()));
+        }
+        if (StringUtils.isNotBlank(request.getScriptType())) {
+            entity.setScriptType(request.getScriptType());
+        }
+        if (StringUtils.isNotBlank(request.getStatus())) {
+            entity.setStatus(request.getStatus());
+        }
+        if (request.getPromptHint() != null || request.getVideoContent() != null) {
+            entity.setPromptHint(resolvePromptHint(request));
+        }
+        if (request.getTranscript() != null) {
+            entity.setTranscript(request.getTranscript());
+        }
+        if (request.getVideoContent() != null) {
+            entity.setVideoContent(request.getVideoContent());
+        }
+        if (request.getAnalysisContent() != null) {
+            entity.setAnalysisContent(request.getAnalysisContent());
+        }
+        if (request.getScriptContent() != null) {
+            entity.setScriptContent(request.getScriptContent());
+        }
+        if (request.getSummary() != null) {
+            entity.setSummary(request.getSummary());
+        }
+        if (request.getCodedeskTaskId() != null) {
+            entity.setCodedeskTaskId(StringUtils.trimToNull(request.getCodedeskTaskId()));
+        }
+        videoScriptTaskMapper.update(entity);
+    }
+
+    private VideoScriptTask buildEntity(TaskSaveRequest request, List<String> videoUrls) {
+        VideoScriptTask entity = new VideoScriptTask();
+        entity.setTaskName(request.getTaskName());
+        entity.setVideoUrl(videoUrls.get(0));
+        entity.setVideoUrls(joinVideoUrls(videoUrls));
+        entity.setScriptType(request.getScriptType());
+        entity.setStatus(StringUtils.defaultIfBlank(request.getStatus(), "PENDING"));
+        entity.setPromptHint(resolvePromptHint(request));
+        entity.setTranscript(request.getTranscript());
+        entity.setVideoContent(resolveInitialVideoContent(request));
+        entity.setAnalysisContent(request.getAnalysisContent());
+        entity.setScriptContent(request.getScriptContent());
+        entity.setSummary(request.getSummary());
+        entity.setCodedeskTaskId(StringUtils.trimToNull(request.getCodedeskTaskId()));
+        entity.setCreatorId(UserContext.getUserId());
+        return entity;
+    }
+
+    private void generateScript(VideoScriptTask entity) {
+        try {
+            videoScriptTaskMapper.updateCallbackResult(
+                    entity.getId(),
+                    "RUNNING",
+                    entity.getTranscript(),
+                    entity.getVideoContent(),
+                    entity.getAnalysisContent(),
+                    entity.getScriptContent(),
+                    entity.getSummary(),
+                    null,
+                    null
+            );
+            normalizeLegacyPromptHint(entity);
+            if (StringUtils.isBlank(entity.getTranscript()) && asrProperties.isEnabled()) {
+                GroupTranscription groupTranscription = transcribeVideoGroup(entity);
+                entity.setTranscript(groupTranscription.getTranscript());
+                if (StringUtils.isNotBlank(groupTranscription.getVideoContent())) {
+                    entity.setVideoContent(groupTranscription.getVideoContent());
+                }
+                videoScriptTaskMapper.updateCallbackResult(
+                        entity.getId(),
+                        "RUNNING",
+                        entity.getTranscript(),
+                        entity.getVideoContent(),
+                        entity.getAnalysisContent(),
+                        entity.getScriptContent(),
+                        entity.getSummary(),
+                        null,
+                        null
+                );
+            }
+            LlmScriptClient.ScriptGenerationResult result = llmScriptClient.generate(entity);
+            videoScriptTaskMapper.updateCallbackResult(
+                    entity.getId(),
+                    "DONE",
+                    entity.getTranscript(),
+                    entity.getVideoContent(),
+                    result.getAnalysisContent(),
+                    result.getScriptContent(),
+                    result.getSummary(),
+                    null,
+                    null
+            );
+        } catch (BusinessException ex) {
+            videoScriptTaskMapper.updateCallbackResult(
+                    entity.getId(),
+                    "FAILED",
+                    entity.getTranscript(),
+                    entity.getVideoContent(),
+                    entity.getAnalysisContent(),
+                    entity.getScriptContent(),
+                    entity.getSummary(),
+                    null,
+                    ex.getMessage()
+            );
+        }
+    }
+
+    private List<String> resolveVideoUrls(TaskSaveRequest request) {
+        List<String> urls = new ArrayList<>();
+        if (request.getVideoUrls() != null) {
+            urls.addAll(request.getVideoUrls());
+        }
+        if (StringUtils.isNotBlank(request.getVideoUrl())) {
+            urls.add(request.getVideoUrl());
+        }
+        return urls.stream()
+                .filter(StringUtils::isNotBlank)
+                .map(String::trim)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private String joinVideoUrls(List<String> videoUrls) {
+        return String.join("\n", videoUrls);
+    }
+
+    private String resolvePromptHint(TaskSaveRequest request) {
+        return StringUtils.trimToNull(StringUtils.defaultIfBlank(request.getPromptHint(), request.getVideoContent()));
+    }
+
+    private String resolveInitialVideoContent(TaskSaveRequest request) {
+        return request.getPromptHint() != null ? request.getVideoContent() : null;
+    }
+
+    private void normalizeLegacyPromptHint(VideoScriptTask entity) {
+        if (StringUtils.isBlank(entity.getPromptHint())
+                && StringUtils.isBlank(entity.getTranscript())
+                && StringUtils.isNotBlank(entity.getVideoContent())) {
+            entity.setPromptHint(entity.getVideoContent());
+            entity.setVideoContent(null);
+        }
+    }
+
+    private GroupTranscription transcribeVideoGroup(VideoScriptTask entity) {
+        List<String> videoUrls = resolveVideoUrls(entity);
+        if (videoUrls.isEmpty()) {
+            throw new BusinessException("视频链接不能为空");
+        }
+        List<String> transcripts = new ArrayList<>();
+        List<String> structuredContents = new ArrayList<>();
+        int index = 1;
+        for (String videoUrl : videoUrls) {
+            VideoTranscriptionClient.TranscriptionResult result = videoTranscriptionClient.transcribe(videoUrl);
+            transcripts.add(result.getTranscript());
+            structuredContents.add("视频" + index + ":\n" + result.getStructuredContent());
+            index++;
+        }
+        return new GroupTranscription(
+                String.join("\n\n---\n\n", transcripts),
+                String.join("\n\n==========\n\n", structuredContents)
+        );
+    }
+
+    private List<String> resolveVideoUrls(VideoScriptTask entity) {
+        List<String> urls = new ArrayList<>();
+        if (StringUtils.isNotBlank(entity.getVideoUrls())) {
+            String[] split = entity.getVideoUrls().split("\\r?\\n|,");
+            for (String item : split) {
+                if (StringUtils.isNotBlank(item)) {
+                    urls.add(item.trim());
+                }
+            }
+        }
+        if (urls.isEmpty() && StringUtils.isNotBlank(entity.getVideoUrl())) {
+            urls.add(entity.getVideoUrl().trim());
+        }
+        return urls.stream().filter(StringUtils::isNotBlank).distinct().collect(Collectors.toList());
+    }
+
+    @Override
+    public void delete(Long id) {
+        videoScriptTaskMapper.deleteById(id);
+    }
+
+    private static class GroupTranscription {
+        private final String transcript;
+        private final String videoContent;
+
+        private GroupTranscription(String transcript, String videoContent) {
+            this.transcript = transcript;
+            this.videoContent = videoContent;
+        }
+
+        public String getTranscript() {
+            return transcript;
+        }
+
+        public String getVideoContent() {
+            return videoContent;
+        }
+    }
+}

+ 26 - 0
src/main/java/com/video/script/admin/service/impl/TokenServiceImpl.java

@@ -0,0 +1,26 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.common.LoginUser;
+import com.video.script.admin.service.TokenService;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import org.springframework.stereotype.Service;
+
+@Service
+public class TokenServiceImpl implements TokenService {
+
+    private final Map<String, LoginUser> tokenStore = new ConcurrentHashMap<String, LoginUser>();
+
+    @Override
+    public String generate(LoginUser loginUser) {
+        String token = UUID.randomUUID().toString().replace("-", "");
+        tokenStore.put(token, loginUser);
+        return token;
+    }
+
+    @Override
+    public LoginUser parse(String token) {
+        return tokenStore.get(token);
+    }
+}

+ 59 - 0
src/main/java/com/video/script/admin/service/impl/UserServiceImpl.java

@@ -0,0 +1,59 @@
+package com.video.script.admin.service.impl;
+
+import com.video.script.admin.common.BusinessException;
+import com.video.script.admin.dto.UserSaveRequest;
+import com.video.script.admin.entity.SysUser;
+import com.video.script.admin.mapper.SysDeptMapper;
+import com.video.script.admin.mapper.SysRoleMapper;
+import com.video.script.admin.mapper.SysUserMapper;
+import com.video.script.admin.service.UserService;
+import java.util.List;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserServiceImpl implements UserService {
+
+    private final SysUserMapper sysUserMapper;
+    private final SysDeptMapper sysDeptMapper;
+    private final SysRoleMapper sysRoleMapper;
+
+    public UserServiceImpl(SysUserMapper sysUserMapper, SysDeptMapper sysDeptMapper, SysRoleMapper sysRoleMapper) {
+        this.sysUserMapper = sysUserMapper;
+        this.sysDeptMapper = sysDeptMapper;
+        this.sysRoleMapper = sysRoleMapper;
+    }
+
+    @Override
+    public List<SysUser> list(String keyword) {
+        return sysUserMapper.selectList(keyword);
+    }
+
+    @Override
+    public void save(UserSaveRequest request) {
+        if (sysDeptMapper.selectById(request.getDeptId()) == null) {
+            throw new BusinessException("部门不存在");
+        }
+        if (sysRoleMapper.selectById(request.getRoleId()) == null) {
+            throw new BusinessException("角色不存在");
+        }
+        SysUser entity = new SysUser();
+        entity.setId(request.getId());
+        entity.setUsername(request.getUsername());
+        entity.setPassword(request.getPassword() == null || request.getPassword().trim().isEmpty() ? "123456" : request.getPassword());
+        entity.setNickname(request.getNickname());
+        entity.setPhone(request.getPhone());
+        entity.setDeptId(request.getDeptId());
+        entity.setRoleId(request.getRoleId());
+        entity.setStatus(request.getStatus());
+        if (request.getId() == null) {
+            sysUserMapper.insert(entity);
+        } else {
+            sysUserMapper.update(entity);
+        }
+    }
+
+    @Override
+    public void delete(Long id) {
+        sysUserMapper.deleteById(id);
+    }
+}

+ 18 - 0
src/main/java/com/video/script/admin/vo/LoginVO.java

@@ -0,0 +1,18 @@
+package com.video.script.admin.vo;
+
+import com.video.script.admin.entity.SysMenu;
+import com.video.script.admin.entity.SysUser;
+import java.util.List;
+public class LoginVO {
+
+    private String token;
+    private SysUser userInfo;
+    private List<SysMenu> menus;
+
+    public String getToken() { return token; }
+    public void setToken(String token) { this.token = token; }
+    public SysUser getUserInfo() { return userInfo; }
+    public void setUserInfo(SysUser userInfo) { this.userInfo = userInfo; }
+    public List<SysMenu> getMenus() { return menus; }
+    public void setMenus(List<SysMenu> menus) { this.menus = menus; }
+}

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

@@ -0,0 +1,29 @@
+server:
+  port: 9011
+
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://139.186.172.149:3390/video_script?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
+    username: hcst
+    password: hcst@2025
+
+logging:
+  level:
+    com.video.script.admin.mapper: debug
+
+llm:
+  enabled: true
+  provider: qwen
+  base-url: "https://llm-ax2z3lqqt75hg7cu.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
+  endpoint: /chat/completions
+  api-key: "sk-ws-H.ERXPIHX.ITCw.MEYCIQD9fnRpudQRlLLQKL2faPTJejqa13VV5t2Q7pRGF-MV7wIhANeQ-QRx4eoJeTxy1QtV8B5lemcIzyr05s3fTMH5ARUO"
+  model: qwen3.7-plus
+  timeout-ms: 180000
+  multimodal-enabled: true
+  send-video-urls: true
+
+asr:
+  enabled: true
+  base-url: "https://llm-ax2z3lqqt75hg7cu.cn-beijing.maas.aliyuncs.com"
+  api-key: "sk-ws-H.ERXPIHX.ITCw.MEYCIQD9fnRpudQRlLLQKL2faPTJejqa13VV5t2Q7pRGF-MV7wIhANeQ-QRx4eoJeTxy1QtV8B5lemcIzyr05s3fTMH5ARUO"

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

@@ -0,0 +1,29 @@
+server:
+  port: 9011
+
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://172.30.0.36:3390/video_script?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
+    username: hcst
+    password: hcst@2025
+
+logging:
+  level:
+    com.video.script.admin.mapper: info
+
+llm:
+  enabled: true
+  provider: qwen
+  base-url: "https://llm-ax2z3lqqt75hg7cu.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
+  endpoint: /chat/completions
+  api-key: "sk-ws-H.ERXPIHX.ITCw.MEYCIQD9fnRpudQRlLLQKL2faPTJejqa13VV5t2Q7pRGF-MV7wIhANeQ-QRx4eoJeTxy1QtV8B5lemcIzyr05s3fTMH5ARUO"
+  model: qwen3.7-plus
+  timeout-ms: 180000
+  multimodal-enabled: true
+  send-video-urls: true
+
+asr:
+  enabled: true
+  base-url: "https://llm-ax2z3lqqt75hg7cu.cn-beijing.maas.aliyuncs.com"
+  api-key: "sk-ws-H.ERXPIHX.ITCw.MEYCIQD9fnRpudQRlLLQKL2faPTJejqa13VV5t2Q7pRGF-MV7wIhANeQ-QRx4eoJeTxy1QtV8B5lemcIzyr05s3fTMH5ARUO"

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

@@ -0,0 +1,22 @@
+server:
+  port: 9011
+
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://139.186.172.149:3390/video_script?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
+    username: hcst
+    password: hcst@2025
+
+logging:
+  level:
+    com.video.script.admin.mapper: debug
+
+llm:
+  enabled: false
+  api-key: ""
+
+asr:
+  enabled: false
+  base-url: ""
+  api-key: ""

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

@@ -0,0 +1,43 @@
+spring:
+  application:
+    name: video-script-admin-backend
+  profiles:
+    active: @activatedProperties@
+  jackson:
+    default-property-inclusion: non_null
+
+mybatis:
+  mapper-locations: classpath:mapper/*.xml
+  configuration:
+    map-underscore-to-camel-case: true
+
+llm:
+  enabled: true
+  provider: qwen
+  base-url: ""
+  endpoint: /chat/completions
+  api-key: ""
+  model: qwen3.7-plus
+  auth-header: Authorization
+  auth-prefix: Bearer 
+  timeout-ms: 180000
+  multimodal-enabled: true
+  send-video-urls: true
+
+asr:
+  enabled: false
+  provider: aliyun
+  base-url: ""
+  submit-path: /api/v1/services/audio/asr/transcription
+  task-path-template: /api/v1/tasks/{taskId}
+  api-key: ""
+  model: qwen-audio-3.0-asr-flash-filetrans
+  auth-header: Authorization
+  auth-prefix: Bearer 
+  timeout-ms: 30000
+  poll-interval-ms: 3000
+  max-polls: 40
+
+task:
+  scheduler:
+    fixed-delay-ms: 60000

+ 44 - 0
src/main/resources/mapper/SysDeptMapper.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.SysDeptMapper">
+
+    <select id="selectList" resultType="com.video.script.admin.entity.SysDept">
+        select id, dept_name, parent_id, leader, phone, sort, status, create_time, update_time
+        from sys_dept
+        <where>
+            <if test="keyword != null and keyword != ''">
+                and dept_name like concat('%', #{keyword}, '%')
+            </if>
+        </where>
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectById" resultType="com.video.script.admin.entity.SysDept">
+        select id, dept_name, parent_id, leader, phone, sort, status, create_time, update_time
+        from sys_dept
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_dept (dept_name, parent_id, leader, phone, sort, status, create_time, update_time)
+        values (#{deptName}, #{parentId}, #{leader}, #{phone}, #{sort}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_dept
+        set dept_name = #{deptName},
+            parent_id = #{parentId},
+            leader = #{leader},
+            phone = #{phone},
+            sort = #{sort},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_dept where id = #{id}
+    </delete>
+</mapper>

+ 62 - 0
src/main/resources/mapper/SysMenuMapper.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.SysMenuMapper">
+
+    <select id="selectList" resultType="com.video.script.admin.entity.SysMenu">
+        select id, menu_name, parent_id, route_path, component, icon, sort, visible, permission_code, create_time, update_time
+        from sys_menu
+        <where>
+            <if test="keyword != null and keyword != ''">
+                and menu_name like concat('%', #{keyword}, '%')
+            </if>
+        </where>
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectAll" resultType="com.video.script.admin.entity.SysMenu">
+        select id, menu_name, parent_id, route_path, component, icon, sort, visible, permission_code, create_time, update_time
+        from sys_menu
+        where visible = 1
+        order by sort asc, id asc
+    </select>
+
+    <select id="selectByRoleId" resultType="com.video.script.admin.entity.SysMenu">
+        select distinct m.id, m.menu_name, m.parent_id, m.route_path, m.component, m.icon, m.sort, m.visible, m.permission_code, m.create_time, m.update_time
+        from sys_menu m
+        inner join sys_role_menu rm on rm.menu_id = m.id
+        where rm.role_id = #{roleId}
+          and m.visible = 1
+        order by m.sort asc, m.id asc
+    </select>
+
+    <select id="selectById" resultType="com.video.script.admin.entity.SysMenu">
+        select id, menu_name, parent_id, route_path, component, icon, sort, visible, permission_code, create_time, update_time
+        from sys_menu
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_menu (menu_name, parent_id, route_path, component, icon, sort, visible, permission_code, create_time, update_time)
+        values (#{menuName}, #{parentId}, #{routePath}, #{component}, #{icon}, #{sort}, #{visible}, #{permissionCode}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_menu
+        set menu_name = #{menuName},
+            parent_id = #{parentId},
+            route_path = #{routePath},
+            component = #{component},
+            icon = #{icon},
+            sort = #{sort},
+            visible = #{visible},
+            permission_code = #{permissionCode},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_menu where id = #{id}
+    </delete>
+</mapper>

+ 42 - 0
src/main/resources/mapper/SysRoleMapper.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.SysRoleMapper">
+
+    <select id="selectList" resultType="com.video.script.admin.entity.SysRole">
+        select id, role_code, role_name, remark, status, create_time, update_time
+        from sys_role
+        <where>
+            <if test="keyword != null and keyword != ''">
+                and (role_code like concat('%', #{keyword}, '%') or role_name like concat('%', #{keyword}, '%'))
+            </if>
+        </where>
+        order by id desc
+    </select>
+
+    <select id="selectById" resultType="com.video.script.admin.entity.SysRole">
+        select id, role_code, role_name, remark, status, create_time, update_time
+        from sys_role
+        where id = #{id}
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_role (role_code, role_name, remark, status, create_time, update_time)
+        values (#{roleCode}, #{roleName}, #{remark}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_role
+        set role_code = #{roleCode},
+            role_name = #{roleName},
+            remark = #{remark},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_role where id = #{id}
+    </delete>
+</mapper>

+ 34 - 0
src/main/resources/mapper/SysRoleMenuMapper.xml

@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.SysRoleMenuMapper">
+
+    <select id="selectMenuIdsByRoleId" resultType="java.lang.Long">
+        select menu_id
+        from sys_role_menu
+        where role_id = #{roleId}
+        order by menu_id asc
+    </select>
+
+    <select id="selectValidMenuIds" resultType="java.lang.Long">
+        select id
+        from sys_menu
+        where id in
+        <foreach collection="menuIds" item="menuId" open="(" separator="," close=")">
+            #{menuId}
+        </foreach>
+    </select>
+
+    <delete id="deleteByRoleId">
+        delete from sys_role_menu where role_id = #{roleId}
+    </delete>
+
+    <insert id="batchInsert">
+        insert into sys_role_menu (role_id, menu_id, create_time)
+        values
+        <foreach collection="menuIds" item="menuId" separator=",">
+            (#{roleId}, #{menuId}, now())
+        </foreach>
+    </insert>
+</mapper>

+ 63 - 0
src/main/resources/mapper/SysUserMapper.xml

@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.SysUserMapper">
+
+    <sql id="baseColumn">
+        u.id, u.username, u.password, u.nickname, u.phone, u.dept_id, u.role_id, u.status,
+        u.create_time, u.update_time, d.dept_name, r.role_name
+    </sql>
+
+    <select id="selectByUsername" resultType="com.video.script.admin.entity.SysUser">
+        select <include refid="baseColumn"/>
+        from sys_user u
+        left join sys_dept d on u.dept_id = d.id
+        left join sys_role r on u.role_id = r.id
+        where u.username = #{username}
+        limit 1
+    </select>
+
+    <select id="selectById" resultType="com.video.script.admin.entity.SysUser">
+        select <include refid="baseColumn"/>
+        from sys_user u
+        left join sys_dept d on u.dept_id = d.id
+        left join sys_role r on u.role_id = r.id
+        where u.id = #{id}
+    </select>
+
+    <select id="selectList" resultType="com.video.script.admin.entity.SysUser">
+        select <include refid="baseColumn"/>
+        from sys_user u
+        left join sys_dept d on u.dept_id = d.id
+        left join sys_role r on u.role_id = r.id
+        <where>
+            <if test="keyword != null and keyword != ''">
+                and (u.username like concat('%', #{keyword}, '%') or u.nickname like concat('%', #{keyword}, '%'))
+            </if>
+        </where>
+        order by u.id desc
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into sys_user (username, password, nickname, phone, dept_id, role_id, status, create_time, update_time)
+        values (#{username}, #{password}, #{nickname}, #{phone}, #{deptId}, #{roleId}, #{status}, now(), now())
+    </insert>
+
+    <update id="update">
+        update sys_user
+        set username = #{username},
+            password = #{password},
+            nickname = #{nickname},
+            phone = #{phone},
+            dept_id = #{deptId},
+            role_id = #{roleId},
+            status = #{status},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from sys_user where id = #{id}
+    </delete>
+</mapper>

+ 98 - 0
src/main/resources/mapper/VideoScriptTaskMapper.xml

@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.video.script.admin.mapper.VideoScriptTaskMapper">
+
+    <select id="selectList" resultType="com.video.script.admin.entity.VideoScriptTask">
+        select t.id, t.task_name, t.video_url, t.video_urls, t.script_type, t.status, t.prompt_hint, t.transcript, t.video_content, t.analysis_content, t.script_content,
+               t.summary, t.codedesk_task_id, t.error_message, t.creator_id, u.nickname as creator_name,
+               t.create_time, t.update_time
+        from video_script_task t
+        left join sys_user u on t.creator_id = u.id
+        <where>
+            <if test="keyword != null and keyword != ''">
+                and (t.task_name like concat('%', #{keyword}, '%') or t.video_url like concat('%', #{keyword}, '%'))
+            </if>
+            <if test="status != null and status != ''">
+                and t.status = #{status}
+            </if>
+        </where>
+        order by t.create_time desc, t.id desc
+    </select>
+
+    <select id="selectById" resultType="com.video.script.admin.entity.VideoScriptTask">
+        select id, task_name, video_url, video_urls, script_type, status, prompt_hint, transcript, video_content, analysis_content, script_content, summary,
+               codedesk_task_id, error_message, creator_id, create_time, update_time
+        from video_script_task
+        where id = #{id}
+    </select>
+
+    <select id="selectByCodedeskTaskId" resultType="com.video.script.admin.entity.VideoScriptTask">
+        select id, task_name, video_url, video_urls, script_type, status, prompt_hint, transcript, video_content, analysis_content, script_content, summary,
+               codedesk_task_id, error_message, creator_id, create_time, update_time
+        from video_script_task
+        where codedesk_task_id = #{codedeskTaskId}
+        limit 1
+    </select>
+
+    <select id="selectByStatus" resultType="com.video.script.admin.entity.VideoScriptTask">
+        select id, task_name, video_url, video_urls, script_type, status, prompt_hint, transcript, video_content, analysis_content, script_content, summary,
+               codedesk_task_id, error_message, creator_id, create_time, update_time
+        from video_script_task
+        where status = #{status}
+        order by id asc
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into video_script_task (task_name, video_url, video_urls, script_type, status, prompt_hint, transcript, video_content, analysis_content, script_content, summary,
+                                       codedesk_task_id, error_message, creator_id, create_time, update_time)
+        values (#{taskName}, #{videoUrl}, #{videoUrls}, #{scriptType}, #{status}, #{promptHint}, #{transcript}, #{videoContent}, #{analysisContent}, #{scriptContent}, #{summary},
+                #{codedeskTaskId}, #{errorMessage}, #{creatorId}, now(), now())
+    </insert>
+
+    <update id="update">
+        update video_script_task
+        set task_name = #{taskName},
+            video_url = #{videoUrl},
+            video_urls = #{videoUrls},
+            script_type = #{scriptType},
+            status = #{status},
+            prompt_hint = #{promptHint},
+            transcript = #{transcript},
+            video_content = #{videoContent},
+            analysis_content = #{analysisContent},
+            script_content = #{scriptContent},
+            summary = #{summary},
+            codedesk_task_id = #{codedeskTaskId},
+            error_message = #{errorMessage},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <update id="updateStatus">
+        update video_script_task
+        set status = #{targetStatus},
+            update_time = now()
+        where id = #{id}
+          and status = #{sourceStatus}
+    </update>
+
+    <update id="updateCallbackResult">
+        update video_script_task
+        set status = #{status},
+            transcript = #{transcript},
+            video_content = #{videoContent},
+            analysis_content = #{analysisContent},
+            script_content = #{scriptContent},
+            summary = #{summary},
+            codedesk_task_id = #{codedeskTaskId},
+            error_message = #{errorMessage},
+            update_time = now()
+        where id = #{id}
+    </update>
+
+    <delete id="deleteById">
+        delete from video_script_task where id = #{id}
+    </delete>
+</mapper>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 22 - 0
src/main/resources/sql/data.sql


+ 76 - 0
src/main/resources/sql/schema.sql

@@ -0,0 +1,76 @@
+create table if not exists sys_role (
+    id bigint primary key auto_increment,
+    role_code varchar(64) not null,
+    role_name varchar(64) not null,
+    remark varchar(255) default null,
+    status tinyint not null default 1,
+    create_time datetime not null,
+    update_time datetime not null
+);
+
+create table if not exists sys_dept (
+    id bigint primary key auto_increment,
+    dept_name varchar(64) not null,
+    parent_id bigint not null default 0,
+    leader varchar(64) default null,
+    phone varchar(32) default null,
+    sort int not null default 0,
+    status tinyint not null default 1,
+    create_time datetime not null,
+    update_time datetime not null
+);
+
+create table if not exists sys_user (
+    id bigint primary key auto_increment,
+    username varchar(64) not null,
+    password varchar(128) not null,
+    nickname varchar(64) not null,
+    phone varchar(32) default null,
+    dept_id bigint not null,
+    role_id bigint not null,
+    status tinyint not null default 1,
+    create_time datetime not null,
+    update_time datetime not null
+);
+
+create table if not exists sys_menu (
+    id bigint primary key auto_increment,
+    menu_name varchar(64) not null,
+    parent_id bigint not null default 0,
+    route_path varchar(128) not null,
+    component varchar(128) not null,
+    icon varchar(64) default null,
+    sort int not null default 0,
+    visible tinyint not null default 1,
+    permission_code varchar(128) default null,
+    create_time datetime not null,
+    update_time datetime not null
+);
+
+create table if not exists sys_role_menu (
+    id bigint primary key auto_increment,
+    role_id bigint not null,
+    menu_id bigint not null,
+    create_time datetime not null,
+    unique key uk_role_menu (role_id, menu_id)
+);
+
+create table if not exists video_script_task (
+    id bigint primary key auto_increment,
+    task_name varchar(128) not null,
+    video_url varchar(500) not null,
+    video_urls text,
+    script_type varchar(32) not null,
+    status varchar(32) not null,
+    prompt_hint text,
+    transcript text,
+    video_content text,
+    analysis_content text,
+    script_content text,
+    summary text,
+    codedesk_task_id varchar(128) default null,
+    error_message text,
+    creator_id bigint not null,
+    create_time datetime not null,
+    update_time datetime not null
+);

+ 8 - 0
src/main/resources/sql/upgrade_video_script_task.sql

@@ -0,0 +1,8 @@
+alter table video_script_task
+    add column if not exists video_urls text null after video_url;
+
+alter table video_script_task
+    add column if not exists prompt_hint text null after status;
+
+alter table video_script_task
+    add column if not exists analysis_content text null after video_content;