Commit b7185a90 authored by yaoke.yk's avatar yaoke.yk

分镜页面优化和运营后台-api2

parent 21e68a93
{{- if .Values.adminServer.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "yaoai.fullname" . }}-admin
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-server
spec:
replicas: {{ .Values.adminServer.replicaCount }}
selector:
matchLabels:
{{- include "yaoai.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: admin-server
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
{{- include "yaoai.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: admin-server
annotations:
{{- toYaml .Values.podAnnotations | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
terminationGracePeriodSeconds: 60
containers:
- name: admin-server
image: "{{ .Values.adminServer.image.repository }}:{{ .Values.adminServer.image.tag }}"
imagePullPolicy: {{ .Values.adminServer.image.pullPolicy }}
ports:
- name: http-admin
containerPort: 8081
protocol: TCP
env:
{{- range $key, $val := .Values.adminServer.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- range $key := keys .Values.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "yaoai.fullname" $ }}-secrets
key: {{ $key }}
{{- end }}
livenessProbe:
{{- toYaml .Values.adminServer.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.adminServer.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.adminServer.resources | nindent 12 }}
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
{{- end }}
{{- if and .Values.adminServer.enabled .Values.adminServer.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "yaoai.fullname" . }}-admin
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-server
{{- with .Values.adminServer.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.adminServer.ingress.className }}
ingressClassName: {{ .Values.adminServer.ingress.className }}
{{- end }}
{{- if .Values.adminServer.ingress.tls }}
tls:
{{- toYaml .Values.adminServer.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.adminServer.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "yaoai.fullname" $ }}-admin
port:
number: {{ $.Values.adminServer.service.port }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.adminServer.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "yaoai.fullname" . }}-admin
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-server
spec:
type: {{ .Values.adminServer.service.type }}
selector:
{{- include "yaoai.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: admin-server
ports:
- name: http-admin
port: {{ .Values.adminServer.service.port }}
targetPort: http-admin
protocol: TCP
{{- end }}
{{- if .Values.adminWeb.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "yaoai.fullname" . }}-admin-web
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-web
spec:
replicas: {{ .Values.adminWeb.replicaCount }}
selector:
matchLabels:
{{- include "yaoai.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: admin-web
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
{{- include "yaoai.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: admin-web
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: admin-web
image: "{{ .Values.adminWeb.image.repository }}:{{ .Values.adminWeb.image.tag }}"
imagePullPolicy: {{ .Values.adminWeb.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: ADMIN_API_UPSTREAM
value: "http://{{ include "yaoai.fullname" . }}-admin:{{ .Values.adminServer.service.port }}"
livenessProbe:
{{- toYaml .Values.adminWeb.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.adminWeb.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.adminWeb.resources | nindent 12 }}
{{- end }}
{{- if and .Values.adminWeb.enabled .Values.adminWeb.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "yaoai.fullname" . }}-admin-web
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-web
{{- with .Values.adminWeb.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.adminWeb.ingress.className }}
ingressClassName: {{ .Values.adminWeb.ingress.className }}
{{- end }}
{{- if .Values.adminWeb.ingress.tls }}
tls:
{{- toYaml .Values.adminWeb.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.adminWeb.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "yaoai.fullname" $ }}-admin-web
port:
number: {{ $.Values.adminWeb.service.port }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.adminWeb.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "yaoai.fullname" . }}-admin-web
labels:
{{- include "yaoai.labels" . | nindent 4 }}
app.kubernetes.io/component: admin-web
spec:
type: {{ .Values.adminWeb.service.type }}
selector:
{{- include "yaoai.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: admin-web
ports:
- name: http
port: {{ .Values.adminWeb.service.port }}
targetPort: http
protocol: TCP
{{- end }}
package com.yaoai.admin;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan("com.yaoai.admin")
public class AdminModuleConfig {
}
package com.yaoai.admin.adminuser;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.yaoai.admin.adminuser.dto.AdminUserCreateRequest;
import com.yaoai.admin.adminuser.dto.AdminUserQuery;
import com.yaoai.admin.adminuser.dto.AdminUserUpdateRequest;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.common.PageResult;
import com.yaoai.admin.user.dto.ResetPasswordResponse;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.AdminUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin-api/admin-users")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN"})
public class AdminUserAdminController {
private final AdminUserAdminService adminUserAdminService;
@GetMapping
public Result<PageResult<AdminUser>> list(@ModelAttribute AdminUserQuery query) {
return Result.ok(adminUserAdminService.list(query));
}
@PostMapping
@AdminAudited(action = "ADMIN_USER_CREATE", resource = "ADMIN_USER", resourceIdParam = "username")
public Result<AdminUser> create(@RequestBody AdminUserCreateRequest request) {
return Result.ok(adminUserAdminService.create(request));
}
@PatchMapping("/{id}")
@AdminAudited(action = "ADMIN_USER_UPDATE", resource = "ADMIN_USER", resourceIdParam = "id")
public Result<AdminUser> update(@PathVariable Long id, @RequestBody AdminUserUpdateRequest request) {
return Result.ok(adminUserAdminService.update(id, request));
}
@PostMapping("/{id}/reset-password")
@AdminAudited(action = "ADMIN_USER_RESET_PASSWORD", resource = "ADMIN_USER", resourceIdParam = "id")
public Result<ResetPasswordResponse> resetPassword(@PathVariable Long id) {
return Result.ok(adminUserAdminService.resetPassword(id));
}
}
package com.yaoai.admin.adminuser;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.admin.adminuser.dto.AdminUserCreateRequest;
import com.yaoai.admin.adminuser.dto.AdminUserQuery;
import com.yaoai.admin.adminuser.dto.AdminUserUpdateRequest;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.admin.common.PageResult;
import com.yaoai.admin.user.dto.ResetPasswordResponse;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.AdminUser;
import com.yaoai.domain.mapper.AdminUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class AdminUserAdminService {
private static final Set<String> ROLES = Set.of("SUPER_ADMIN", "OPERATOR", "FINANCE", "READONLY");
private final AdminUserMapper adminUserMapper;
public PageResult<AdminUser> list(AdminUserQuery query) {
long page = Math.max(1, query.getPage());
long size = Math.max(1, Math.min(query.getSize(), 100));
long offset = (page - 1) * size;
long total = adminUserMapper.selectCount(buildQuery(query));
return new PageResult<>(
adminUserMapper.selectList(buildQuery(query)
.orderByDesc(AdminUser::getCreatedAt)
.last("LIMIT " + size + " OFFSET " + offset)),
total,
page,
size);
}
@Transactional
public AdminUser create(AdminUserCreateRequest request) {
if (!StringUtils.hasText(request.getUsername())) {
throw new BizException(ErrorCode.INVALID_PARAM, "用户名不能为空");
}
if (!ROLES.contains(request.getRole())) {
throw new BizException(ErrorCode.INVALID_PARAM, "运营角色无效");
}
if (adminUserMapper.selectCount(Wrappers.<AdminUser>lambdaQuery()
.eq(AdminUser::getUsername, request.getUsername())) > 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "用户名已存在");
}
String password = StringUtils.hasText(request.getPassword())
? request.getPassword()
: "YaoAI-" + RandomUtil.randomString(10);
AdminUser user = new AdminUser();
user.setUsername(request.getUsername());
user.setPasswordHash(BCrypt.hashpw(password, BCrypt.gensalt()));
user.setDisplayName(request.getDisplayName());
user.setEmail(request.getEmail());
user.setRole(request.getRole());
user.setStatus(1);
user.setMustChangePassword(1);
user.setCreatedBy(StpAdminUtil.getLoginIdAsLong());
user.setCreatedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
adminUserMapper.insert(user);
return adminUserMapper.selectById(user.getId());
}
@Transactional
public AdminUser update(Long id, AdminUserUpdateRequest request) {
AdminUser user = requireAdminUser(id);
if (request.getDisplayName() != null) user.setDisplayName(request.getDisplayName());
if (request.getEmail() != null) user.setEmail(request.getEmail());
if (request.getRole() != null) {
if (!ROLES.contains(request.getRole())) {
throw new BizException(ErrorCode.INVALID_PARAM, "运营角色无效");
}
user.setRole(request.getRole());
}
if (request.getStatus() != null) {
if (request.getStatus() != 0 && request.getStatus() != 1) {
throw new BizException(ErrorCode.INVALID_PARAM, "账号状态必须为 0 或 1");
}
user.setStatus(request.getStatus());
}
user.setUpdatedAt(LocalDateTime.now());
adminUserMapper.updateById(user);
return adminUserMapper.selectById(id);
}
@Transactional
public ResetPasswordResponse resetPassword(Long id) {
AdminUser user = requireAdminUser(id);
String password = "YaoAI-" + RandomUtil.randomString(10);
user.setPasswordHash(BCrypt.hashpw(password, BCrypt.gensalt()));
user.setMustChangePassword(1);
user.setUpdatedAt(LocalDateTime.now());
adminUserMapper.updateById(user);
StpAdminUtil.INSTANCE.kickout(id);
return new ResetPasswordResponse(password);
}
private LambdaQueryWrapper<AdminUser> buildQuery(AdminUserQuery query) {
LambdaQueryWrapper<AdminUser> wrapper = Wrappers.<AdminUser>lambdaQuery();
if (StringUtils.hasText(query.getKeyword())) {
wrapper.and(w -> w.like(AdminUser::getUsername, query.getKeyword())
.or()
.like(AdminUser::getDisplayName, query.getKeyword())
.or()
.like(AdminUser::getEmail, query.getKeyword()));
}
if (StringUtils.hasText(query.getRole())) {
wrapper.eq(AdminUser::getRole, query.getRole());
}
if (query.getStatus() != null) {
wrapper.eq(AdminUser::getStatus, query.getStatus());
}
return wrapper;
}
private AdminUser requireAdminUser(Long id) {
AdminUser user = adminUserMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.NOT_FOUND, "运营账号不存在");
}
return user;
}
}
package com.yaoai.admin.adminuser.dto;
import lombok.Data;
@Data
public class AdminUserCreateRequest {
private String username;
private String password;
private String displayName;
private String email;
private String role;
}
package com.yaoai.admin.adminuser.dto;
import lombok.Data;
@Data
public class AdminUserQuery {
private String keyword;
private String role;
private Integer status;
private long page = 1;
private long size = 20;
}
package com.yaoai.admin.adminuser.dto;
import lombok.Data;
@Data
public class AdminUserUpdateRequest {
private String displayName;
private String email;
private String role;
private Integer status;
}
package com.yaoai.admin.audit;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.yaoai.admin.audit.dto.AuditLogQuery;
import com.yaoai.admin.common.PageResult;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.AdminAuditLog;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin-api/audit-logs")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class AdminAuditLogController {
private final AdminAuditQueryService adminAuditQueryService;
@GetMapping
public Result<PageResult<AdminAuditLog>> list(@ModelAttribute AuditLogQuery query) {
return Result.ok(adminAuditQueryService.list(query));
}
}
package com.yaoai.admin.audit;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.admin.audit.dto.AuditLogQuery;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.admin.common.PageResult;
import com.yaoai.domain.entity.AdminAuditLog;
import com.yaoai.domain.mapper.AdminAuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
public class AdminAuditQueryService {
private final AdminAuditLogMapper adminAuditLogMapper;
public PageResult<AdminAuditLog> list(AuditLogQuery query) {
long page = Math.max(1, query.getPage());
long size = Math.max(1, Math.min(query.getSize(), 100));
long offset = (page - 1) * size;
LambdaQueryWrapper<AdminAuditLog> wrapper = buildQuery(query);
long total = adminAuditLogMapper.selectCount(wrapper);
return new PageResult<>(
adminAuditLogMapper.selectList(buildQuery(query)
.orderByDesc(AdminAuditLog::getCreatedAt)
.last("LIMIT " + size + " OFFSET " + offset)),
total,
page,
size);
}
private LambdaQueryWrapper<AdminAuditLog> buildQuery(AuditLogQuery query) {
LambdaQueryWrapper<AdminAuditLog> wrapper = Wrappers.<AdminAuditLog>lambdaQuery();
boolean superAdmin = "SUPER_ADMIN".equals(StpAdminUtil.getRole());
if (superAdmin && query.getAdminId() != null) {
wrapper.eq(AdminAuditLog::getAdminUserId, query.getAdminId());
} else if (!superAdmin) {
wrapper.eq(AdminAuditLog::getAdminUserId, StpAdminUtil.getLoginIdAsLong());
}
if (StringUtils.hasText(query.getAction())) {
wrapper.eq(AdminAuditLog::getAction, query.getAction());
}
if (StringUtils.hasText(query.getResourceType())) {
wrapper.eq(AdminAuditLog::getResourceType, query.getResourceType());
}
if (query.getFrom() != null) {
wrapper.ge(AdminAuditLog::getCreatedAt, query.getFrom());
}
if (query.getTo() != null) {
wrapper.le(AdminAuditLog::getCreatedAt, query.getTo());
}
return wrapper;
}
}
package com.yaoai.admin.audit;
import com.yaoai.domain.entity.AdminAuditLog;
import com.yaoai.domain.mapper.AdminAuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AdminAuditService {
private final AdminAuditLogMapper adminAuditLogMapper;
public void record(AdminAuditLog log) {
adminAuditLogMapper.insert(log);
}
}
package com.yaoai.admin.audit.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAudited {
String action() default "";
String resource() default "";
String resourceIdParam() default "";
}
package com.yaoai.admin.audit.dto;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
@Data
public class AuditLogQuery {
private Long adminId;
private String action;
private String resourceType;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime from;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime to;
private long page = 1;
private long size = 20;
}
package com.yaoai.admin.auth;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.AdminUser;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin-api/auth")
@RequiredArgsConstructor
public class AdminAuthController {
private final AdminAuthService authService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginRequest req, HttpServletRequest request) {
String ip = getClientIp(request);
String token = authService.login(req.getUsername(), req.getPassword(), ip);
return Result.ok(token);
}
@PostMapping("/logout")
@SaCheckLogin(type = "admin")
public Result<Void> logout() {
authService.logout();
return Result.ok();
}
@GetMapping("/me")
@SaCheckLogin(type = "admin")
public Result<AdminUser> me() {
return Result.ok(authService.getCurrentAdmin());
}
@PostMapping("/change-password")
@SaCheckLogin(type = "admin")
public Result<Void> changePassword(@RequestBody ChangePasswordRequest req) {
authService.changePassword(req.getOldPassword(), req.getNewPassword());
return Result.ok();
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
@Data
static class LoginRequest {
private String username;
private String password;
}
@Data
static class ChangePasswordRequest {
private String oldPassword;
private String newPassword;
}
}
package com.yaoai.admin.auth;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.AdminUser;
import com.yaoai.domain.mapper.AdminUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class AdminAuthService {
private final AdminUserMapper adminUserMapper;
public String login(String username, String password, String ip) {
AdminUser user = adminUserMapper.selectOne(Wrappers.<AdminUser>lambdaQuery()
.eq(AdminUser::getUsername, username));
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "用户名或密码错误");
}
if (Integer.valueOf(0).equals(user.getStatus())) {
throw new BizException(ErrorCode.FORBIDDEN.getCode(), "账号已被禁用");
}
if (!BCrypt.checkpw(password, user.getPasswordHash())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "用户名或密码错误");
}
StpAdminUtil.login(user.getId());
user.setLastLoginAt(LocalDateTime.now());
user.setLastLoginIp(ip);
adminUserMapper.updateById(user);
return StpAdminUtil.getTokenValue();
}
public void logout() {
StpAdminUtil.logout();
}
public AdminUser getCurrentAdmin() {
Long id = StpAdminUtil.getLoginIdAsLong();
return adminUserMapper.selectById(id);
}
public void changePassword(String oldPassword, String newPassword) {
if (!StringUtils.hasText(oldPassword) || !StringUtils.hasText(newPassword)) {
throw new BizException(ErrorCode.INVALID_PARAM.getCode(), "旧密码和新密码不能为空");
}
if (newPassword.length() < 8) {
throw new BizException(ErrorCode.INVALID_PARAM.getCode(), "新密码长度不能少于 8 位");
}
Long id = StpAdminUtil.getLoginIdAsLong();
AdminUser user = adminUserMapper.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "登录已失效");
}
if (!BCrypt.checkpw(oldPassword, user.getPasswordHash())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "旧密码错误");
}
user.setPasswordHash(BCrypt.hashpw(newPassword, BCrypt.gensalt()));
user.setMustChangePassword(0);
user.setUpdatedAt(LocalDateTime.now());
adminUserMapper.updateById(user);
}
}
package com.yaoai.admin.auth;
import com.yaoai.domain.entity.AdminUser;
import com.yaoai.domain.mapper.AdminUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Component
@RequiredArgsConstructor
public class AdminPermissionInterface implements cn.dev33.satoken.stp.StpInterface {
private final AdminUserMapper adminUserMapper;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return Collections.emptyList();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
if (!"admin".equals(loginType)) {
return Collections.emptyList();
}
AdminUser user = adminUserMapper.selectById(Long.parseLong(loginId.toString()));
if (user == null || user.getRole() == null) {
return Collections.emptyList();
}
return List.of(user.getRole());
}
}
package com.yaoai.admin.auth;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.config.SaTokenConfig;
import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.stp.StpLogic;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class StpAdminUtil {
public static final StpLogic INSTANCE = new StpLogic("admin");
static {
INSTANCE.setConfig(new SaTokenConfig().setTokenName("satoken-admin"));
SaManager.putStpLogic(INSTANCE);
}
public static void login(Long adminId) {
INSTANCE.login(adminId);
}
public static long getLoginIdAsLong() {
return INSTANCE.getLoginIdAsLong();
}
public static String getTokenValue() {
return INSTANCE.getTokenValue();
}
public static void logout() {
INSTANCE.logout();
}
public static boolean isLogin() {
return INSTANCE.isLogin();
}
public static SaSession getSession() {
return INSTANCE.getSession();
}
public static String getRole() {
List<String> roles = INSTANCE.getRoleList();
return roles.isEmpty() ? "default-role" : roles.get(0);
}
}
package com.yaoai.admin.billing;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.admin.billing.dto.BillingCostItem;
import com.yaoai.admin.billing.dto.BillingCostUpdateRequest;
import com.yaoai.admin.billing.dto.BillingRecordQuery;
import com.yaoai.admin.billing.dto.BillingSummaryItem;
import com.yaoai.admin.billing.dto.UsageDetailResponse;
import com.yaoai.admin.common.PageResult;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.BillingRecord;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.List;
@RestController
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class BillingAdminController {
private final BillingAdminService billingAdminService;
@GetMapping("/admin-api/billing/records")
public Result<PageResult<BillingRecord>> records(@ModelAttribute BillingRecordQuery query) {
return Result.ok(billingAdminService.records(query));
}
@GetMapping("/admin-api/billing/recharges")
public Result<PageResult<BillingRecord>> recharges(@ModelAttribute BillingRecordQuery query) {
return Result.ok(billingAdminService.recharges(query));
}
@GetMapping("/admin-api/billing/consumes")
public Result<PageResult<BillingRecord>> consumes(@ModelAttribute BillingRecordQuery query) {
return Result.ok(billingAdminService.consumes(query));
}
@GetMapping("/admin-api/billing/summary")
public Result<List<BillingSummaryItem>> summary(
@RequestParam(defaultValue = "modality") String dim,
@ModelAttribute BillingRecordQuery query) {
return Result.ok(billingAdminService.summary(dim, query));
}
@GetMapping("/admin-api/billing/costs")
@SaCheckRole(value = "SUPER_ADMIN", type = "admin")
public Result<List<BillingCostItem>> costs() {
return Result.ok(billingAdminService.costs());
}
@PutMapping("/admin-api/billing/costs")
@AdminAudited(action = "BILLING_COST_UPDATE", resource = "BILLING_COST")
@SaCheckRole(value = "SUPER_ADMIN", type = "admin")
public Result<List<BillingCostItem>> saveCosts(@RequestBody BillingCostUpdateRequest request) {
return Result.ok(billingAdminService.saveCosts(request.getCosts(), StpAdminUtil.getLoginIdAsLong()));
}
@GetMapping("/admin-api/tenants/{id}/usage-detail")
public Result<UsageDetailResponse> usageDetail(
@PathVariable Long id,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "model") String groupBy) {
return Result.ok(billingAdminService.usageDetail(id, from, to, groupBy));
}
}
package com.yaoai.admin.billing.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class BillingCostItem {
private String operation;
private String label;
private BigDecimal cost;
}
package com.yaoai.admin.billing.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Map;
@Data
public class BillingCostUpdateRequest {
private Map<String, BigDecimal> costs;
}
package com.yaoai.admin.billing.dto;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
@Data
public class BillingRecordQuery {
private String modality;
private String modelId;
private Long tenantId;
private Long userId;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime from;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
private LocalDateTime to;
private long page = 1;
private long size = 20;
}
package com.yaoai.admin.billing.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class BillingSummaryItem {
private String key;
private String label;
private BigDecimal totalCredits;
private long totalUnits;
private long count;
private long successCount;
private long failCount;
private BigDecimal percent;
}
package com.yaoai.admin.billing.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
@Builder
public class UsageDetailResponse {
private Long tenantId;
private String groupBy;
private BigDecimal total;
private List<BillingSummaryItem> breakdown;
}
package com.yaoai.admin.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
@Data
@AllArgsConstructor
public class PageResult<T> {
private List<T> records;
private long total;
private long page;
private long size;
}
package com.yaoai.admin.config;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.yaoai.admin")
public class AdminGlobalExceptionHandler {
@ExceptionHandler(NotLoginException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<?> handleNotLogin(NotLoginException e) {
return Result.error(ErrorCode.UNAUTHORIZED.getCode(), "请先登录运营后台");
}
@ExceptionHandler({NotPermissionException.class, NotRoleException.class})
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<?> handleForbidden(RuntimeException e) {
return Result.error(ErrorCode.FORBIDDEN.getCode(), "无权访问运营后台");
}
@ExceptionHandler(BizException.class)
@ResponseStatus(HttpStatus.OK)
public Result<?> handleBizException(BizException e) {
log.warn("Admin BizException [{}]: {}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return Result.error(ErrorCode.INVALID_PARAM.getCode(), message);
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<?> handleIllegalArgument(IllegalArgumentException e) {
return Result.error(ErrorCode.INVALID_PARAM.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<?> handleException(Exception e) {
log.error("Unexpected admin error", e);
return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), "服务器内部错误");
}
}
package com.yaoai.admin.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import com.yaoai.admin.auth.StpAdminUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class SaTokenAdminConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> StpAdminUtil.INSTANCE.checkLogin()))
.addPathPatterns("/admin-api/**")
.excludePathPatterns("/admin-api/auth/login");
}
}
package com.yaoai.admin.dashboard;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.yaoai.admin.dashboard.dto.DashboardOverview;
import com.yaoai.admin.dashboard.dto.DashboardProviderStats;
import com.yaoai.admin.dashboard.dto.DashboardTaskStats;
import com.yaoai.admin.dashboard.dto.DashboardTrendPoint;
import com.yaoai.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin-api/dashboard")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class DashboardController {
private final DashboardService dashboardService;
@GetMapping("/overview")
public Result<DashboardOverview> overview() {
return Result.ok(dashboardService.overview());
}
@GetMapping("/trends")
public Result<List<DashboardTrendPoint>> trends(@RequestParam(defaultValue = "30") int days) {
return Result.ok(dashboardService.trends(days));
}
@GetMapping("/tasks")
public Result<List<DashboardTaskStats>> tasks() {
return Result.ok(dashboardService.tasks());
}
@GetMapping("/providers")
public Result<List<DashboardProviderStats>> providers() {
return Result.ok(dashboardService.providers());
}
}
package com.yaoai.admin.dashboard;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.admin.dashboard.dto.DashboardOverview;
import com.yaoai.admin.dashboard.dto.DashboardProviderStats;
import com.yaoai.admin.dashboard.dto.DashboardTaskStats;
import com.yaoai.admin.dashboard.dto.DashboardTrendPoint;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.entity.BillingRecord;
import com.yaoai.domain.entity.Tenant;
import com.yaoai.domain.entity.User;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.domain.mapper.BillingRecordMapper;
import com.yaoai.domain.mapper.TenantMapper;
import com.yaoai.domain.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DashboardService {
private final TenantMapper tenantMapper;
private final UserMapper userMapper;
private final BillingRecordMapper billingRecordMapper;
private final AiTaskMapper aiTaskMapper;
public DashboardOverview overview() {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
List<BillingRecord> todayRecords = billingRecordMapper.selectList(Wrappers.<BillingRecord>lambdaQuery()
.ge(BillingRecord::getCreatedAt, todayStart));
BigDecimal consumed = todayRecords.stream()
.filter(this::isConsume)
.map(BillingRecord::getCredits)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal recharged = todayRecords.stream()
.filter(this::isRecharge)
.map(BillingRecord::getCredits)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return DashboardOverview.builder()
.totalTenants(countActiveRows())
.activeTenants(tenantMapper.selectCount(Wrappers.<Tenant>lambdaQuery()
.eq(Tenant::getStatus, 1)
.isNull(Tenant::getDeletedAt)))
.totalUsers(userMapper.selectCount(Wrappers.<User>lambdaQuery().isNull(User::getDeletedAt)))
.todayNewTenants(tenantMapper.selectCount(Wrappers.<Tenant>lambdaQuery()
.ge(Tenant::getCreatedAt, todayStart)
.isNull(Tenant::getDeletedAt)))
.todayConsumedCredits(consumed)
.todayRechargedCredits(recharged)
.build();
}
public List<DashboardTrendPoint> trends(int days) {
int safeDays = Math.max(1, Math.min(days, 90));
LocalDate startDay = LocalDate.now().minusDays(safeDays - 1L);
LocalDateTime start = startDay.atStartOfDay();
List<Tenant> tenants = tenantMapper.selectList(Wrappers.<Tenant>lambdaQuery()
.ge(Tenant::getCreatedAt, start)
.isNull(Tenant::getDeletedAt));
List<BillingRecord> records = billingRecordMapper.selectList(Wrappers.<BillingRecord>lambdaQuery()
.ge(BillingRecord::getCreatedAt, start));
Map<LocalDate, Long> tenantCounts = tenants.stream()
.filter(t -> t.getCreatedAt() != null)
.collect(Collectors.groupingBy(t -> t.getCreatedAt().toLocalDate(), Collectors.counting()));
Map<LocalDate, BigDecimal> consumed = new LinkedHashMap<>();
Map<LocalDate, BigDecimal> recharged = new LinkedHashMap<>();
for (BillingRecord record : records) {
if (record.getCreatedAt() == null || record.getCredits() == null) {
continue;
}
LocalDate day = record.getCreatedAt().toLocalDate();
Map<LocalDate, BigDecimal> target = isRecharge(record) ? recharged : consumed;
target.merge(day, record.getCredits(), BigDecimal::add);
}
List<DashboardTrendPoint> points = new ArrayList<>();
for (int i = 0; i < safeDays; i++) {
LocalDate day = startDay.plusDays(i);
points.add(DashboardTrendPoint.builder()
.day(day)
.newTenants(tenantCounts.getOrDefault(day, 0L))
.consumedCredits(consumed.getOrDefault(day, BigDecimal.ZERO))
.rechargedCredits(recharged.getOrDefault(day, BigDecimal.ZERO))
.build());
}
return points;
}
public List<DashboardTaskStats> tasks() {
return aiTaskMapper.selectList(Wrappers.emptyWrapper()).stream()
.collect(Collectors.groupingBy(task -> blankToDefault(task.getTaskType(), "unknown")))
.entrySet().stream()
.map(entry -> toTaskStats(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(DashboardTaskStats::getTotal).reversed())
.toList();
}
public List<DashboardProviderStats> providers() {
return billingRecordMapper.selectList(Wrappers.<BillingRecord>lambdaQuery()
.isNotNull(BillingRecord::getModelProvider))
.stream()
.filter(this::isConsume)
.collect(Collectors.groupingBy(this::providerKey))
.entrySet().stream()
.map(entry -> toProviderStats(entry.getValue()))
.sorted(Comparator.comparing(DashboardProviderStats::getCredits).reversed())
.toList();
}
private long countActiveRows() {
return tenantMapper.selectCount(Wrappers.<Tenant>lambdaQuery().isNull(Tenant::getDeletedAt));
}
private DashboardTaskStats toTaskStats(String taskType, List<AiTask> tasks) {
long total = tasks.size();
long succeeded = tasks.stream().filter(t -> "succeeded".equals(t.getStatus())).count();
long failed = tasks.stream().filter(t -> "failed".equals(t.getStatus())).count();
return DashboardTaskStats.builder()
.taskType(taskType)
.total(total)
.succeeded(succeeded)
.failed(failed)
.successRate(total == 0 ? 0 : succeeded * 1.0 / total)
.build();
}
private DashboardProviderStats toProviderStats(List<BillingRecord> records) {
BillingRecord first = records.get(0);
long total = records.size();
long succeeded = records.stream().filter(r -> !Boolean.FALSE.equals(r.getSuccess())).count();
long failed = total - succeeded;
BigDecimal credits = records.stream()
.map(BillingRecord::getCredits)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return DashboardProviderStats.builder()
.modelProvider(first.getModelProvider())
.modelId(first.getModelId())
.modelName(first.getModelName())
.total(total)
.succeeded(succeeded)
.failed(failed)
.credits(credits)
.failureRate(total == 0 ? 0 : failed * 1.0 / total)
.build();
}
private String providerKey(BillingRecord record) {
return blankToDefault(record.getModelProvider(), "unknown") + "|"
+ blankToDefault(record.getModelId(), "unknown") + "|"
+ blankToDefault(record.getModelName(), "unknown");
}
private boolean isConsume(BillingRecord record) {
return !isRecharge(record);
}
private boolean isRecharge(BillingRecord record) {
return "RECHARGE".equals(record.getModality()) || "recharge".equals(record.getOperation());
}
private String blankToDefault(String value, String defaultValue) {
return value == null || value.isBlank() ? defaultValue : value;
}
}
package com.yaoai.admin.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class DashboardOverview {
private long totalTenants;
private long activeTenants;
private long totalUsers;
private long todayNewTenants;
private BigDecimal todayConsumedCredits;
private BigDecimal todayRechargedCredits;
}
package com.yaoai.admin.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Builder
public class DashboardProviderStats {
private String modelProvider;
private String modelId;
private String modelName;
private long total;
private long succeeded;
private long failed;
private BigDecimal credits;
private double failureRate;
}
package com.yaoai.admin.dashboard.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class DashboardTaskStats {
private String taskType;
private long total;
private long succeeded;
private long failed;
private double successRate;
}
package com.yaoai.admin.dashboard.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
@Builder
public class DashboardTrendPoint {
private LocalDate day;
private long newTenants;
private BigDecimal consumedCredits;
private BigDecimal rechargedCredits;
}
package com.yaoai.admin.plan;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.plan.dto.PlanUpdateRequest;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.Plan;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin-api/plans")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class PlanAdminController {
private final PlanAdminService planAdminService;
@GetMapping
public Result<List<Plan>> list() {
return Result.ok(planAdminService.list());
}
@PatchMapping("/{id}")
@AdminAudited(action = "PLAN_UPDATE", resource = "PLAN", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN"})
public Result<Plan> update(@PathVariable Long id, @RequestBody PlanUpdateRequest request) {
return Result.ok(planAdminService.update(id, request));
}
}
package com.yaoai.admin.plan;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.admin.plan.dto.PlanUpdateRequest;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Plan;
import com.yaoai.domain.mapper.PlanMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PlanAdminService {
private final PlanMapper planMapper;
public List<Plan> list() {
return planMapper.selectList(com.baomidou.mybatisplus.core.toolkit.Wrappers.<Plan>lambdaQuery()
.orderByAsc(Plan::getId));
}
@Transactional
public Plan update(Long id, PlanUpdateRequest request) {
Plan plan = planMapper.selectById(id);
if (plan == null) {
throw new BizException(ErrorCode.NOT_FOUND, "套餐不存在");
}
if (request.getName() != null) plan.setName(request.getName());
if (request.getPriceMonthly() != null) plan.setPriceMonthly(request.getPriceMonthly());
if (request.getMonthlyCredits() != null) plan.setMonthlyCredits(request.getMonthlyCredits());
if (request.getMaxMembers() != null) plan.setMaxMembers(request.getMaxMembers());
if (request.getMaxProjects() != null) plan.setMaxProjects(request.getMaxProjects());
if (request.getMaxConcurrentTasks() != null) plan.setMaxConcurrentTasks(request.getMaxConcurrentTasks());
if (request.getFeatures() != null) plan.setFeatures(request.getFeatures());
if (request.getIsActive() != null) plan.setIsActive(request.getIsActive());
plan.setUpdatedAt(LocalDateTime.now());
plan.setUpdatedBy(StpAdminUtil.getLoginIdAsLong());
planMapper.updateById(plan);
return planMapper.selectById(id);
}
}
package com.yaoai.admin.plan.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class PlanUpdateRequest {
private String name;
private BigDecimal priceMonthly;
private Integer monthlyCredits;
private Integer maxMembers;
private Integer maxProjects;
private Integer maxConcurrentTasks;
private String features;
private Integer isActive;
}
package com.yaoai.admin.tenant;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.common.PageResult;
import com.yaoai.admin.tenant.dto.TenantBalanceOverview;
import com.yaoai.admin.tenant.dto.TenantDetail;
import com.yaoai.admin.tenant.dto.TenantImpersonateRequest;
import com.yaoai.admin.tenant.dto.TenantImpersonateResponse;
import com.yaoai.admin.tenant.dto.TenantListItem;
import com.yaoai.admin.tenant.dto.TenantPlanChangeRequest;
import com.yaoai.admin.tenant.dto.TenantQuotaUpdateRequest;
import com.yaoai.admin.tenant.dto.TenantRechargeRequest;
import com.yaoai.admin.tenant.dto.TenantSuspendRequest;
import com.yaoai.admin.tenant.dto.TenantUpdateRequest;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.Project;
import com.yaoai.domain.entity.Tenant;
import com.yaoai.domain.entity.TenantQuota;
import com.yaoai.domain.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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;
import java.util.List;
@RestController
@RequestMapping("/admin-api/tenants")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class TenantAdminController {
private final TenantAdminService tenantAdminService;
@GetMapping
public Result<PageResult<TenantListItem>> list(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String plan,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "20") long size) {
return Result.ok(tenantAdminService.list(keyword, plan, status, page, size));
}
@GetMapping("/{id}")
public Result<TenantDetail> detail(@PathVariable Long id) {
return Result.ok(tenantAdminService.detail(id));
}
@PatchMapping("/{id}")
@AdminAudited(action = "TENANT_UPDATE", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<Tenant> update(@PathVariable Long id, @RequestBody TenantUpdateRequest request) {
return Result.ok(tenantAdminService.update(id, request));
}
@PutMapping("/{id}/plan")
@AdminAudited(action = "TENANT_PLAN_CHANGE", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<Tenant> changePlan(@PathVariable Long id, @RequestBody TenantPlanChangeRequest request) {
return Result.ok(tenantAdminService.changePlan(id, request));
}
@PostMapping("/{id}/suspend")
@AdminAudited(action = "TENANT_SUSPEND", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<Tenant> suspend(@PathVariable Long id, @RequestBody TenantSuspendRequest request) {
return Result.ok(tenantAdminService.suspend(id, request));
}
@PostMapping("/{id}/resume")
@AdminAudited(action = "TENANT_RESUME", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<Tenant> resume(@PathVariable Long id) {
return Result.ok(tenantAdminService.resume(id));
}
@GetMapping("/{id}/balance")
public Result<TenantBalanceOverview> balance(@PathVariable Long id) {
return Result.ok(tenantAdminService.balance(id));
}
@GetMapping("/{id}/members")
public Result<List<User>> members(@PathVariable Long id) {
return Result.ok(tenantAdminService.members(id));
}
@GetMapping("/{id}/projects")
public Result<List<Project>> projects(@PathVariable Long id) {
return Result.ok(tenantAdminService.projects(id));
}
@PostMapping("/{id}/recharge")
@AdminAudited(action = "TENANT_RECHARGE", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "FINANCE"}, mode = SaMode.OR)
public Result<TenantBalanceOverview> recharge(@PathVariable Long id, @RequestBody TenantRechargeRequest request) {
return Result.ok(tenantAdminService.recharge(id, request));
}
@PutMapping("/{id}/quota")
@AdminAudited(action = "TENANT_QUOTA_UPDATE", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN"})
public Result<TenantQuota> updateQuota(@PathVariable Long id, @RequestBody TenantQuotaUpdateRequest request) {
return Result.ok(tenantAdminService.updateQuota(id, request));
}
@PostMapping("/{id}/impersonate")
@AdminAudited(action = "IMPERSONATE_START", resource = "TENANT", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<TenantImpersonateResponse> impersonate(
@PathVariable Long id,
@RequestBody(required = false) TenantImpersonateRequest request) {
return Result.ok(tenantAdminService.impersonate(
id,
request == null ? new TenantImpersonateRequest() : request));
}
}
package com.yaoai.admin.tenant.dto;
import com.yaoai.domain.entity.BillingRecord;
import com.yaoai.domain.entity.TenantBalance;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class TenantBalanceOverview {
private TenantBalance balance;
private List<BillingRecord> recentRecords;
}
package com.yaoai.admin.tenant.dto;
import com.yaoai.domain.entity.Tenant;
import com.yaoai.domain.entity.TenantBalance;
import com.yaoai.domain.entity.TenantQuota;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class TenantDetail {
private Tenant tenant;
private TenantBalance balance;
private TenantQuota quota;
private long memberCount;
private long projectCount;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
@Data
public class TenantImpersonateRequest {
private Long userId;
}
package com.yaoai.admin.tenant.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class TenantImpersonateResponse {
private String token;
private Long tenantId;
private Long userId;
private long ttlSeconds;
}
package com.yaoai.admin.tenant.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
public class TenantListItem {
private Long id;
private String name;
private String slug;
private String plan;
private Integer status;
private BigDecimal balance;
private long memberCount;
private long projectCount;
private LocalDateTime createdAt;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
@Data
public class TenantPlanChangeRequest {
private String planCode;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class TenantQuotaUpdateRequest {
private Integer maxMembers;
private Integer maxProjects;
private Integer maxMonthlyCredits;
private Integer maxConcurrentTasks;
private BigDecimal monthlyBudgetLimit;
private String notes;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class TenantRechargeRequest {
private BigDecimal amount;
private String note;
private String externalRefId;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
@Data
public class TenantSuspendRequest {
private String reason;
}
package com.yaoai.admin.tenant.dto;
import lombok.Data;
@Data
public class TenantUpdateRequest {
private String name;
private String notes;
}
package com.yaoai.admin.user;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.common.PageResult;
import com.yaoai.admin.user.dto.ResetPasswordResponse;
import com.yaoai.admin.user.dto.UserQuery;
import com.yaoai.admin.user.dto.UserStatusRequest;
import com.yaoai.common.result.Result;
import com.yaoai.domain.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin-api/users")
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class UserAdminController {
private final UserAdminService userAdminService;
@GetMapping
public Result<PageResult<User>> list(@ModelAttribute UserQuery query) {
return Result.ok(userAdminService.list(query));
}
@GetMapping("/{id}")
public Result<User> detail(@PathVariable Long id) {
return Result.ok(userAdminService.detail(id));
}
@PatchMapping("/{id}/status")
@AdminAudited(action = "USER_STATUS_CHANGE", resource = "USER", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<User> updateStatus(@PathVariable Long id, @RequestBody UserStatusRequest request) {
return Result.ok(userAdminService.updateStatus(id, request));
}
@PostMapping("/{id}/kickout")
@AdminAudited(action = "USER_KICKOUT", resource = "USER", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<Void> kickout(@PathVariable Long id) {
userAdminService.kickout(id);
return Result.ok();
}
@PostMapping("/{id}/reset-password")
@AdminAudited(action = "USER_RESET_PASSWORD", resource = "USER", resourceIdParam = "id")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<ResetPasswordResponse> resetPassword(@PathVariable Long id) {
return Result.ok(userAdminService.resetPassword(id));
}
}
package com.yaoai.admin.user;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.admin.common.PageResult;
import com.yaoai.admin.user.dto.ResetPasswordResponse;
import com.yaoai.admin.user.dto.UserQuery;
import com.yaoai.admin.user.dto.UserStatusRequest;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.User;
import com.yaoai.domain.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class UserAdminService {
private final UserMapper userMapper;
public PageResult<User> list(UserQuery query) {
long page = Math.max(1, query.getPage());
long size = Math.max(1, Math.min(query.getSize(), 100));
long offset = (page - 1) * size;
long total = userMapper.selectCount(buildQuery(query));
return new PageResult<>(
userMapper.selectList(buildQuery(query)
.orderByDesc(User::getCreatedAt)
.last("LIMIT " + size + " OFFSET " + offset)),
total,
page,
size);
}
public User detail(Long id) {
return requireUser(id);
}
@Transactional
public User updateStatus(Long id, UserStatusRequest request) {
User user = requireUser(id);
if (request.getStatus() == null || (request.getStatus() != 0 && request.getStatus() != 1)) {
throw new BizException(ErrorCode.INVALID_PARAM, "用户状态必须为 0 或 1");
}
user.setStatus(request.getStatus());
user.setUpdatedAt(LocalDateTime.now());
userMapper.updateById(user);
if (request.getStatus() == 0) {
StpUtil.kickout(id);
}
return userMapper.selectById(id);
}
public void kickout(Long id) {
requireUser(id);
StpUtil.kickout(id);
}
@Transactional
public ResetPasswordResponse resetPassword(Long id) {
User user = requireUser(id);
String password = "YaoAI-" + RandomUtil.randomString(10);
user.setPasswordHash(BCrypt.hashpw(password, BCrypt.gensalt()));
user.setUpdatedAt(LocalDateTime.now());
userMapper.updateById(user);
StpUtil.kickout(id);
return new ResetPasswordResponse(password);
}
private LambdaQueryWrapper<User> buildQuery(UserQuery query) {
LambdaQueryWrapper<User> wrapper = Wrappers.<User>lambdaQuery()
.isNull(User::getDeletedAt);
if (query.getTenantId() != null) {
wrapper.eq(User::getTenantId, query.getTenantId());
}
if (query.getStatus() != null) {
wrapper.eq(User::getStatus, query.getStatus());
}
if (StringUtils.hasText(query.getKeyword())) {
wrapper.and(w -> w.like(User::getEmail, query.getKeyword())
.or()
.like(User::getUsername, query.getKeyword())
.or()
.like(User::getPhone, query.getKeyword()));
}
return wrapper;
}
private User requireUser(Long id) {
User user = userMapper.selectOne(Wrappers.<User>lambdaQuery()
.eq(User::getId, id)
.isNull(User::getDeletedAt));
if (user == null) {
throw new BizException(ErrorCode.NOT_FOUND, "用户不存在");
}
return user;
}
}
package com.yaoai.admin.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ResetPasswordResponse {
private String password;
}
package com.yaoai.admin.user.dto;
import lombok.Data;
@Data
public class UserQuery {
private Long tenantId;
private String keyword;
private Integer status;
private long page = 1;
private long size = 20;
}
package com.yaoai.admin.user.dto;
import lombok.Data;
@Data
public class UserStatusRequest {
private Integer status;
}
package com.yaoai.ai.providers.util;
import java.util.ArrayList;
import java.util.List;
/**
* 把"结构化视频生成请求"拼装为 Seedance 2.0 可消费的 final prompt + 图片顺序。
*
* 图片顺序固定(即 @图N 标号):
* @图1..@图K = 角色(K = 角色数量),@图K+1 = 场景,
* @图K+2.. = 道具(按顺序),最后一张 = 风格图(如有)
*/
public final class SeedancePromptBuilder {
private SeedancePromptBuilder() {}
/**
* 仅依赖原语类型(字符串),不引入 yaoai-api DTO,保持模块依赖单向。
*
* @param characterCount 角色图数量(>=1,决定 @图1..@图K 的范围)
* @param propCount 道具图数量(用于计算 @图N 的范围)
* @param hasStyleImage 是否含风格图
*/
/** V17:单段 freeform 提示词模式(前端单输入框后的主要入口)。 */
public static String buildPromptFreeform(String freeformBody, int characterCount,
int propCount, boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, propCount, hasStyleImage);
sb.append("\n分镜内容:\n");
if (freeformBody != null && !freeformBody.isBlank()) {
sb.append(freeformBody.trim()).append('\n');
}
appendCommonRules(sb);
return sb.toString();
}
public static String buildPrompt(UserPromptParts parts, int characterCount,
int propCount, boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, propCount, hasStyleImage);
sb.append("\n分镜内容:\n");
appendIfPresent(sb, "人物动作", parts.characterAction());
appendIfPresent(sb, "场景事件", parts.sceneEvent());
appendIfPresent(sb, "镜头运动", parts.cameraMovement());
appendIfPresent(sb, "光影氛围", parts.lightingAtmosphere());
appendIfPresent(sb, "视频风格", parts.videoStyle());
appendCommonRules(sb);
return sb.toString();
}
private static void appendImageHeader(StringBuilder sb, int characterCount,
int propCount, boolean hasStyleImage) {
int imageCount;
if (characterCount <= 1) {
sb.append("@图1 是角色参考图,请保持人物脸型、发型、服装、气质一致,不要随意改变角色身份。\n");
imageCount = 1;
} else {
sb.append("@图1-").append(characterCount)
.append(" 是出镜角色参考图(按顺序对应剧本中第 1..").append(characterCount)
.append(" 位角色),请保持每位人物的脸型、发型、服装、气质一致,不要混淆角色身份。\n");
imageCount = characterCount;
}
sb.append("@图").append(imageCount + 1)
.append(" 是场景参考图,请参考空间结构、环境布局、光影、氛围和构图。\n");
imageCount += 1;
if (propCount > 0) {
int propStart = imageCount + 1;
int propEnd = propStart + propCount - 1;
if (propCount == 1) {
sb.append("@图").append(propStart).append(" 是道具参考图,请在画面中合理使用。\n");
} else {
sb.append("@图").append(propStart).append("-").append(propEnd)
.append(" 是道具参考图,请在画面中合理使用这些道具。\n");
}
imageCount += propCount;
}
if (hasStyleImage) {
sb.append("@图").append(imageCount + 1)
.append(" 是风格参考图,请参考整体美术风格、色彩和质感。\n");
}
}
private static void appendCommonRules(StringBuilder sb) {
sb.append("\n生成要求:\n");
sb.append("- 保持角色一致性\n");
sb.append("- 保持场景逻辑合理\n");
sb.append("- 人物与环境融合自然\n");
sb.append("- 镜头运动流畅\n");
sb.append("- 画面具备电影感\n");
sb.append("- 不要出现多余人物\n");
sb.append("- 不要出现明显变脸、错位、肢体畸形\n");
}
/**
* 按 @图N 顺序拼接图片 TOS key 列表(角色1..K → 场景 → 道具… → 风格)。
*
* @param characterImageKeys 出镜角色图 keys(>=1 张)
*/
public static List<String> buildOrderedImageKeys(List<String> characterImageKeys,
String sceneImageKey,
List<String> propImageKeys,
String styleImageKey) {
List<String> keys = new ArrayList<>();
if (characterImageKeys != null) {
for (String k : characterImageKeys) {
if (k != null && !k.isBlank()) {
keys.add(k);
}
}
}
keys.add(sceneImageKey);
if (propImageKeys != null) {
for (String k : propImageKeys) {
if (k != null && !k.isBlank()) {
keys.add(k);
}
}
}
if (styleImageKey != null && !styleImageKey.isBlank()) {
keys.add(styleImageKey);
}
return keys;
}
private static void appendIfPresent(StringBuilder sb, String label, String value) {
if (value != null && !value.isBlank()) {
sb.append("- ").append(label).append(":").append(value.trim()).append('\n');
}
}
/** 用户结构化提示词的五段输入;放在工具类内部避免跨模块耦合。 */
public record UserPromptParts(
String characterAction,
String sceneEvent,
String cameraMovement,
String lightingAtmosphere,
String videoStyle
) {
}
}
package com.yaoai.api.dto.ai;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 分镜视频结构化生成请求。
* 替代旧的「prompt 内夹 @图N + image_keys[] 顺序绑定」方式。
*/
@Data
public class StructuredVideoGenerateRequest {
/** 关联剧集(可选) */
private Long episodeId;
/** 关联分镜(可选) */
private Long storyboardId;
/** @图1 角色图(旧版单角色字段,characterImageKeys 为空时使用) */
private String characterImageKey;
/** V16:多角色出镜(@图1..@图N,按顺序),与 characterImageKey 至少传一个 */
@Size(max = 5, message = "出镜角色最多 5 个")
private List<String> characterImageKeys;
/** @图2 场景图(必填) */
@NotBlank(message = "场景图不能为空")
private String sceneImageKey;
/** 用户结构化提示词(旧版 5 段,与 freeformPrompt 二选一,至少一项非空) */
@Valid
private UserPrompt userPrompt;
/** V17:单段 freeform 提示词(前端简化为单输入框后的主要入口) */
private String freeformPrompt;
/** @图3+ 道具图(可选,按顺序对应 @图3、@图4...,最多 5 张) */
@Size(max = 5, message = "道具图最多 5 张")
private List<String> propImageKeys;
/** 风格图(可选,固定为最后一张) */
private String styleImageKey;
/** 视频时长(秒) */
@Min(value = 5, message = "视频时长最短 5 秒")
@Max(value = 30, message = "视频时长最长 30 秒")
private Integer duration = 15;
/** 16:9 / 9:16 / 1:1 */
@Pattern(regexp = "16:9|9:16|1:1", message = "宽高比仅支持 16:9 / 9:16 / 1:1")
private String ratio = "16:9";
/** Seedance 模型 ID(doubao-seedance-2-0-260128 / doubao-seedance-2-0-fast-260128),为空走 ArkProperties 默认值 */
@Pattern(regexp = "doubao-seedance-2-0-260128|doubao-seedance-2-0-fast-260128",
message = "模型仅支持 Seedance 2.0 标准 / 极速")
private String model;
/** 是否生成配音 */
private Boolean generateAudio = Boolean.FALSE;
}
package com.yaoai.api.dto.ai;
import lombok.Data;
/**
* 用户在分镜页输入的结构化提示词。
* 拆分为五段,避免用户写 @图N 自然语言占位。
*/
@Data
public class UserPrompt {
/** 人物动作 */
private String characterAction;
/** 场景事件 */
private String sceneEvent;
/** 镜头运动 */
private String cameraMovement;
/** 光影氛围 */
private String lightingAtmosphere;
/** 视频风格 */
private String videoStyle;
/** 至少填一项 */
public boolean hasAnyContent() {
return notBlank(characterAction)
|| notBlank(sceneEvent)
|| notBlank(cameraMovement)
|| notBlank(lightingAtmosphere)
|| notBlank(videoStyle);
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}
package com.yaoai.billing.dto;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Map;
@Data
@Builder
public class BillingChargeRequest {
private Long tenantId;
private Long userId;
private Long projectId;
/** outline_generate / episode_generate / image_generate / video_generate / assembly */
private String operation;
/** TEXT / IMAGE / VIDEO / AUDIO / COMPOSE */
private String modality;
/** volcengine / qwen / openai / ffmpeg */
private String modelProvider;
/** 模型唯一 ID */
private String modelId;
/** 模型展示名 */
private String modelName;
/** 单次操作的单元数 */
private Integer unitCount;
/** 是否成功;失败时记 credits=0 */
private Boolean success;
/** 显式覆盖 BillingConfig 计算的费用,null 时按 operation 查表 */
private BigDecimal credits;
/** 任意元信息:tokenCount / size / duration */
private Map<String, Object> meta;
/** task ID / project ID */
private String refId;
}
package com.yaoai.billing.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.billing.config.BillingConfig;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.SystemSetting;
import com.yaoai.domain.mapper.SystemSettingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class BillingCostService {
public static final String SETTING_KEY = "billing.costs";
private final BillingConfig billingConfig;
private final SystemSettingMapper systemSettingMapper;
private final ObjectMapper objectMapper;
@Cacheable(value = "billing-costs", key = "#operation")
public BigDecimal getCost(String operation) {
return getCosts().getOrDefault(operation, BigDecimal.ZERO);
}
@Cacheable(value = "billing-costs", key = "'global'")
public Map<String, BigDecimal> getCosts() {
Map<String, BigDecimal> costs = new LinkedHashMap<>(billingConfig.getCosts());
SystemSetting setting = systemSettingMapper.findByKey(SETTING_KEY).orElse(null);
if (setting == null || setting.getSettingValue() == null || setting.getSettingValue().isBlank()) {
return costs;
}
try {
Map<String, BigDecimal> overrides = objectMapper.readValue(
setting.getSettingValue(),
new TypeReference<>() {
}
);
overrides.forEach((operation, cost) -> {
if (operation != null && cost != null) {
costs.put(operation, cost);
}
});
return costs;
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "计费价格配置解析失败: " + e.getMessage());
}
}
@Transactional
@CacheEvict(value = "billing-costs", allEntries = true)
public Map<String, BigDecimal> saveCosts(Map<String, BigDecimal> costs, Long adminId) {
Map<String, BigDecimal> normalized = normalize(costs);
try {
String json = objectMapper.writeValueAsString(normalized);
SystemSetting setting = systemSettingMapper.findByKey(SETTING_KEY).orElse(null);
if (setting == null) {
setting = new SystemSetting();
setting.setSettingKey(SETTING_KEY);
setting.setSettingValue(json);
setting.setUpdatedAt(LocalDateTime.now());
setting.setUpdatedBy(adminId);
systemSettingMapper.insert(setting);
} else {
setting.setSettingValue(json);
setting.setUpdatedAt(LocalDateTime.now());
setting.setUpdatedBy(adminId);
systemSettingMapper.updateById(setting);
}
return normalized;
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "计费价格配置保存失败: " + e.getMessage());
}
}
private Map<String, BigDecimal> normalize(Map<String, BigDecimal> costs) {
if (costs == null || costs.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "计费价格不能为空");
}
Map<String, BigDecimal> normalized = new LinkedHashMap<>();
costs.forEach((operation, cost) -> {
if (operation == null || operation.isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "计费操作不能为空");
}
if (cost == null || cost.compareTo(BigDecimal.ZERO) < 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "计费价格不能小于 0");
}
normalized.put(operation.trim(), cost);
});
return normalized;
}
}
-- ============================================================
-- M14 平台运营后台
-- 1) admin_users / admin_audit_logs / plans / tenant_quotas
-- 2) tenants 加暂停元信息
-- 3) billing_records 升级到模型粒度(核心)
-- 4) 套餐初始化数据
-- ============================================================
-- ---------- 1) 运营人员 ----------
CREATE TABLE admin_users
(
id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) DEFAULT NULL COMMENT 'BCrypt; 首启由 AdminInitializer 注入默认密码',
display_name VARCHAR(128) DEFAULT NULL,
email VARCHAR(255) DEFAULT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'OPERATOR' COMMENT 'SUPER_ADMIN/OPERATOR/FINANCE/READONLY',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=ACTIVE 0=DISABLED',
must_change_password TINYINT NOT NULL DEFAULT 0,
last_login_at DATETIME(3) DEFAULT NULL,
last_login_ip VARCHAR(45) DEFAULT NULL,
created_by BIGINT DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_username (username),
KEY idx_role_status (role, status)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '运营人员';
-- ---------- 2) 运营操作审计 ----------
CREATE TABLE admin_audit_logs
(
id BIGINT NOT NULL,
admin_user_id BIGINT NOT NULL,
admin_username VARCHAR(64) DEFAULT NULL COMMENT '冗余字段,账号删除后仍可追溯',
action VARCHAR(64) NOT NULL COMMENT 'TENANT_RECHARGE/TENANT_SUSPEND/PLAN_CHANGE/USER_DISABLE/IMPERSONATE_START/...',
resource_type VARCHAR(32) DEFAULT NULL,
resource_id VARCHAR(64) DEFAULT NULL,
before_value JSON DEFAULT NULL,
after_value JSON DEFAULT NULL,
ip VARCHAR(45) DEFAULT NULL,
user_agent VARCHAR(255) DEFAULT NULL,
request_id VARCHAR(64) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAILED',
error_message TEXT DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_admin_time (admin_user_id, created_at),
KEY idx_resource (resource_type, resource_id),
KEY idx_action_time (action, created_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '运营操作审计';
-- ---------- 3) 套餐定义 ----------
CREATE TABLE plans
(
id BIGINT NOT NULL,
code VARCHAR(32) NOT NULL COMMENT 'FREE/PRO/TEAM/ENTERPRISE',
name VARCHAR(64) NOT NULL,
price_monthly DECIMAL(10, 2) DEFAULT 0,
monthly_credits INT DEFAULT 0,
max_members INT DEFAULT 1 COMMENT '0=不限',
max_projects INT DEFAULT 0 COMMENT '0=不限',
max_concurrent_tasks INT DEFAULT 1,
features JSON DEFAULT NULL,
is_active TINYINT NOT NULL DEFAULT 1,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_code (code)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '套餐定义';
-- ---------- 4) 租户配额覆盖 ----------
CREATE TABLE tenant_quotas
(
tenant_id BIGINT NOT NULL,
max_members INT DEFAULT NULL,
max_projects INT DEFAULT NULL,
max_monthly_credits INT DEFAULT NULL,
max_concurrent_tasks INT DEFAULT NULL,
monthly_budget_limit DECIMAL(18, 4) DEFAULT NULL,
notes TEXT DEFAULT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT DEFAULT NULL,
PRIMARY KEY (tenant_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '租户配额覆盖';
-- ---------- 5) tenants 暂停元信息 ----------
ALTER TABLE tenants
ADD COLUMN suspended_at DATETIME(3) DEFAULT NULL COMMENT '暂停时间',
ADD COLUMN suspended_by BIGINT DEFAULT NULL COMMENT '操作人 admin_users.id',
ADD COLUMN suspended_reason VARCHAR(255) DEFAULT NULL,
ADD COLUMN notes TEXT DEFAULT NULL COMMENT '运营备注';
-- ---------- 6) billing_records 模型粒度升级(核心) ----------
ALTER TABLE billing_records
ADD COLUMN created_by_admin_id BIGINT DEFAULT NULL COMMENT '运营手动充值时记录',
ADD COLUMN user_id BIGINT DEFAULT NULL COMMENT '租户内发起操作的用户',
ADD COLUMN modality VARCHAR(16) DEFAULT NULL COMMENT 'TEXT/IMAGE/VIDEO/AUDIO/COMPOSE/RECHARGE',
ADD COLUMN model_provider VARCHAR(32) DEFAULT NULL COMMENT 'volcengine/qwen/openai/ffmpeg',
ADD COLUMN model_id VARCHAR(64) DEFAULT NULL,
ADD COLUMN model_name VARCHAR(128) DEFAULT NULL,
ADD COLUMN unit_count INT DEFAULT 1,
ADD COLUMN success TINYINT(1) DEFAULT 1,
ADD COLUMN billing_meta JSON DEFAULT NULL,
ADD INDEX idx_modality_model (modality, model_provider, model_id),
ADD INDEX idx_user_created (user_id, created_at);
-- 把历史数据的 modality 推断填充(旧数据没有 modality 列,按 operation 反推)
UPDATE billing_records SET modality = 'TEXT' WHERE modality IS NULL AND operation IN ('outline_generate', 'episode_generate');
UPDATE billing_records SET modality = 'IMAGE' WHERE modality IS NULL AND operation = 'image_generate';
UPDATE billing_records SET modality = 'VIDEO' WHERE modality IS NULL AND operation = 'video_generate';
UPDATE billing_records SET modality = 'COMPOSE' WHERE modality IS NULL AND operation = 'assembly';
UPDATE billing_records SET modality = 'RECHARGE' WHERE modality IS NULL AND operation = 'recharge';
-- ---------- 7) 套餐初始化数据(对齐 design-docs/01 §4.1) ----------
INSERT INTO plans (id, code, name, price_monthly, monthly_credits, max_members, max_projects, max_concurrent_tasks, features, is_active)
VALUES
(1, 'FREE', '免费版', 0.00, 100, 1, 3, 1, JSON_OBJECT('watermark', true, 'libraries', JSON_ARRAY('personal')), 1),
(2, 'PRO', '专业版', 99.00, 3000, 5, 20, 3, JSON_OBJECT('watermark', false, 'libraries', JSON_ARRAY('personal','team')), 1),
(3, 'TEAM', '团队版', 499.00, 20000, 20, 100, 5, JSON_OBJECT('watermark', false, 'libraries', JSON_ARRAY('personal','team'), 'api', true), 1),
(4, 'ENTERPRISE', '企业版', 0.00, 0, 0, 0, 10, JSON_OBJECT('watermark', false, 'libraries', JSON_ARRAY('personal','team','market'), 'api', true, 'sla', true, 'private_deploy', true), 1);
CREATE TABLE system_settings (
id BIGINT NOT NULL,
setting_key VARCHAR(128) NOT NULL,
setting_value TEXT,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_system_setting_key (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Platform-level key-value settings';
-- ============================================================
-- V14 结构化视频生成
-- 扩展 ai_tasks 表:把"@图1 + 自然语言"调用方式
-- 升级为"左图 + 右提示词"结构化调用
--
-- 兼容性约定:
-- - 旧字段 prompt 复用为「最终提交给 Seedance 的完整 Prompt」(含 @图N)
-- - 旧字段 input_image_key 仅旧任务使用,新结构化任务为 NULL
-- - 新结构化任务 task_type='structured_video'
-- ============================================================
ALTER TABLE ai_tasks
ADD COLUMN character_image_key VARCHAR(512) DEFAULT NULL COMMENT '角色图 TOS key(@图1)',
ADD COLUMN scene_image_key VARCHAR(512) DEFAULT NULL COMMENT '场景图 TOS key(@图2)',
ADD COLUMN prop_image_keys TEXT DEFAULT NULL COMMENT '道具图 TOS keys(JSON 数组字符串,@图3+)',
ADD COLUMN style_image_key VARCHAR(512) DEFAULT NULL COMMENT '风格图 TOS key(可选,最后一张)',
ADD COLUMN user_prompt TEXT DEFAULT NULL COMMENT '用户原始结构化输入(JSON 字符串,与 prompt 字段配对存证)',
ADD COLUMN video_duration INT DEFAULT NULL COMMENT '视频时长(秒)',
ADD COLUMN video_ratio VARCHAR(10) DEFAULT NULL COMMENT '视频宽高比 16:9/9:16/1:1',
ADD COLUMN generate_audio TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否生成配音 0/1';
-- 便于运营回溯:按角色/场景维度统计任务
CREATE INDEX idx_ai_tasks_character ON ai_tasks (character_image_key);
CREATE INDEX idx_ai_tasks_scene ON ai_tasks (scene_image_key);
-- ============================================================
-- V16 多角色出镜支持
-- 每个分镜可携带多个角色变体(如:陆长生-白衣 / 陆长生-黑衣)
--
-- 兼容性约定:
-- - 保留 ai_tasks.character_image_key、storyboards 的旧字段不变
-- - 新增 character_image_keys(JSON 数组),新版结构化任务优先使用
-- - 旧任务字段保留只读,新写入只走数组列
-- ============================================================
ALTER TABLE ai_tasks
ADD COLUMN character_image_keys TEXT DEFAULT NULL
COMMENT '出镜角色图 TOS keys(JSON 数组字符串,@图1..@图N,按顺序排列)';
ALTER TABLE storyboards
ADD COLUMN character_image_keys TEXT DEFAULT NULL
COMMENT '该分镜的出镜角色图 TOS keys(JSON 数组字符串)',
ADD COLUMN scene_image_key VARCHAR(512) DEFAULT NULL
COMMENT '该分镜选中的场景图 TOS key',
ADD COLUMN prop_image_keys TEXT DEFAULT NULL
COMMENT '该分镜选中的道具图 TOS keys(JSON 数组字符串)',
ADD COLUMN style_image_key VARCHAR(512) DEFAULT NULL
COMMENT '该分镜选中的风格图 TOS key';
package com.yaoai.common.result;
import lombok.Data;
import org.slf4j.MDC;
@Data
public class Result<T> {
private boolean success;
private T data;
private ErrorDetail error;
private String requestId;
private long timestamp;
@Data
public static class ErrorDetail {
private final String code;
private final String message;
}
private Result() {
this.requestId = MDC.get("requestId");
this.timestamp = System.currentTimeMillis();
}
public static <T> Result<T> ok(T data) {
Result<T> result = new Result<>();
result.success = true;
result.data = data;
return result;
}
public static <T> Result<T> ok() {
return ok(null);
}
public static <T> Result<T> error(String code, String message) {
Result<T> result = new Result<>();
result.success = false;
result.error = new ErrorDetail(code, message);
return result;
}
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("admin_audit_logs")
public class AdminAuditLog {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long adminUserId;
/** Redundant for traceability after account deletion. */
private String adminUsername;
/** TENANT_RECHARGE / TENANT_SUSPEND / PLAN_CHANGE / USER_DISABLE / IMPERSONATE_START / ... */
private String action;
private String resourceType;
private String resourceId;
/** JSON */
private String beforeValue;
/** JSON */
private String afterValue;
private String ip;
private String userAgent;
private String requestId;
/** SUCCESS / FAILED */
private String status;
private String errorMessage;
private LocalDateTime createdAt;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("admin_users")
public class AdminUser {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String username;
/** BCrypt; first boot is initialized by AdminInitializer. */
@JsonIgnore
private String passwordHash;
private String displayName;
private String email;
/** SUPER_ADMIN / OPERATOR / FINANCE / READONLY */
private String role;
/** 1=ACTIVE 0=DISABLED */
private Integer status;
private Integer mustChangePassword;
private LocalDateTime lastLoginAt;
private String lastLoginIp;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("plans")
public class Plan {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** FREE / PRO / TEAM / ENTERPRISE */
private String code;
private String name;
private BigDecimal priceMonthly;
private Integer monthlyCredits;
/** 0=unlimited */
private Integer maxMembers;
/** 0=unlimited */
private Integer maxProjects;
private Integer maxConcurrentTasks;
/** JSON */
private String features;
/** 1=ACTIVE 0=INACTIVE */
private Integer isActive;
private LocalDateTime updatedAt;
private Long updatedBy;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("system_settings")
public class SystemSetting {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String settingKey;
private String settingValue;
private LocalDateTime updatedAt;
private Long updatedBy;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("tenant_quotas")
public class TenantQuota {
@TableId(value = "tenant_id", type = IdType.INPUT)
private Long tenantId;
private Integer maxMembers;
private Integer maxProjects;
private Integer maxMonthlyCredits;
private Integer maxConcurrentTasks;
private BigDecimal monthlyBudgetLimit;
private String notes;
private LocalDateTime updatedAt;
private Long updatedBy;
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.AdminAuditLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@InterceptorIgnore(tenantLine = "true")
public interface AdminAuditLogMapper extends BaseMapper<AdminAuditLog> {
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.AdminUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@InterceptorIgnore(tenantLine = "true")
public interface AdminUserMapper extends BaseMapper<AdminUser> {
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.Plan;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@InterceptorIgnore(tenantLine = "true")
public interface PlanMapper extends BaseMapper<Plan> {
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.domain.entity.SystemSetting;
import org.apache.ibatis.annotations.Mapper;
import java.util.Optional;
@Mapper
public interface SystemSettingMapper extends BaseMapper<SystemSetting> {
default Optional<SystemSetting> findByKey(String key) {
return Optional.ofNullable(selectOne(Wrappers.<SystemSetting>lambdaQuery()
.eq(SystemSetting::getSettingKey, key)));
}
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.TenantQuota;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@InterceptorIgnore(tenantLine = "true")
public interface TenantQuotaMapper extends BaseMapper<TenantQuota> {
}
package com.yaoai.pipeline.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 结构化视频生成的 pipeline 内部 DTO。
* 由 controller 层从 yaoai-api 的 {@code StructuredVideoGenerateRequest} 映射而来,
* 避免 yaoai-pipeline 反向依赖 yaoai-api。
*/
@Data
@Builder
public class StructuredVideoSubmission {
private Long userId;
private Long episodeId;
private Long storyboardId;
/** @图1 角色图 TOS key(旧版单角色字段,保留兼容;当 characterImageKeys 非空时忽略) */
private String characterImageKey;
/** V16:多角色出镜,按 @图1..@图N 顺序排列;非空时优先于 characterImageKey */
private List<String> characterImageKeys;
/** 场景图 TOS key(紧跟在最后一个角色图之后) */
private String sceneImageKey;
/** @图3+ 道具图 TOS key 列表(可空) */
private List<String> propImageKeys;
/** 风格图 TOS key(可空,永远是最后一张) */
private String styleImageKey;
/** 用户原始结构化输入(5 段或单段 freeform),用于落库存证 */
private Map<String, String> userPromptRaw;
/** 拆开的 5 段,传给 SeedancePromptBuilder(与 freeformPrompt 二选一) */
private String characterAction;
private String sceneEvent;
private String cameraMovement;
private String lightingAtmosphere;
private String videoStyle;
/** V17:单段 freeform 提示词,非空时优先于上面 5 段 */
private String freeformPrompt;
private Integer durationSeconds;
private String ratio;
/** Seedance 模型 ID,为空时由 SeedanceServiceImpl 兜底为 ArkProperties.videoModel */
private String model;
private Boolean generateAudio;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment