Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yaoai-video
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姚珂
yaoai-video
Commits
b7185a90
Commit
b7185a90
authored
Apr 26, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
分镜页面优化和运营后台-api2
parent
21e68a93
Hide whitespace changes
Inline
Side-by-side
Showing
79 changed files
with
3861 additions
and
0 deletions
+3861
-0
admin-deployment.yaml
yaoai-comic-studio/k8s/templates/admin-deployment.yaml
+63
-0
admin-ingress.yaml
yaoai-comic-studio/k8s/templates/admin-ingress.yaml
+36
-0
admin-service.yaml
yaoai-comic-studio/k8s/templates/admin-service.yaml
+19
-0
admin-web-deployment.yaml
yaoai-comic-studio/k8s/templates/admin-web-deployment.yaml
+47
-0
admin-web-ingress.yaml
yaoai-comic-studio/k8s/templates/admin-web-ingress.yaml
+36
-0
admin-web-service.yaml
yaoai-comic-studio/k8s/templates/admin-web-service.yaml
+19
-0
AdminModuleConfig.java
...dmin/src/main/java/com/yaoai/admin/AdminModuleConfig.java
+9
-0
AdminUserAdminController.java
...a/com/yaoai/admin/adminuser/AdminUserAdminController.java
+54
-0
AdminUserAdminService.java
...java/com/yaoai/admin/adminuser/AdminUserAdminService.java
+137
-0
AdminUserCreateRequest.java
...com/yaoai/admin/adminuser/dto/AdminUserCreateRequest.java
+17
-0
AdminUserQuery.java
...in/java/com/yaoai/admin/adminuser/dto/AdminUserQuery.java
+17
-0
AdminUserUpdateRequest.java
...com/yaoai/admin/adminuser/dto/AdminUserUpdateRequest.java
+15
-0
AdminAuditLogController.java
...n/java/com/yaoai/admin/audit/AdminAuditLogController.java
+26
-0
AdminAuditQueryService.java
...in/java/com/yaoai/admin/audit/AdminAuditQueryService.java
+57
-0
AdminAuditService.java
...rc/main/java/com/yaoai/admin/audit/AdminAuditService.java
+17
-0
AdminAudited.java
...n/java/com/yaoai/admin/audit/annotation/AdminAudited.java
+17
-0
AdminAuditAspect.java
...n/java/com/yaoai/admin/audit/aspect/AdminAuditAspect.java
+279
-0
AuditLogQuery.java
...rc/main/java/com/yaoai/admin/audit/dto/AuditLogQuery.java
+26
-0
AdminAuthController.java
...c/main/java/com/yaoai/admin/auth/AdminAuthController.java
+71
-0
AdminAuthService.java
.../src/main/java/com/yaoai/admin/auth/AdminAuthService.java
+77
-0
AdminPermissionInterface.java
...n/java/com/yaoai/admin/auth/AdminPermissionInterface.java
+33
-0
StpAdminUtil.java
...dmin/src/main/java/com/yaoai/admin/auth/StpAdminUtil.java
+49
-0
BillingAdminController.java
.../java/com/yaoai/admin/billing/BillingAdminController.java
+79
-0
BillingAdminService.java
...ain/java/com/yaoai/admin/billing/BillingAdminService.java
+256
-0
BillingCostItem.java
...ain/java/com/yaoai/admin/billing/dto/BillingCostItem.java
+17
-0
BillingCostUpdateRequest.java
...com/yaoai/admin/billing/dto/BillingCostUpdateRequest.java
+12
-0
BillingRecordQuery.java
.../java/com/yaoai/admin/billing/dto/BillingRecordQuery.java
+28
-0
BillingSummaryItem.java
.../java/com/yaoai/admin/billing/dto/BillingSummaryItem.java
+27
-0
UsageDetailResponse.java
...java/com/yaoai/admin/billing/dto/UsageDetailResponse.java
+20
-0
PageResult.java
...dmin/src/main/java/com/yaoai/admin/common/PageResult.java
+19
-0
AdminGlobalExceptionHandler.java
...a/com/yaoai/admin/config/AdminGlobalExceptionHandler.java
+66
-0
SaTokenAdminConfig.java
.../main/java/com/yaoai/admin/config/SaTokenAdminConfig.java
+18
-0
DashboardController.java
...n/java/com/yaoai/admin/dashboard/DashboardController.java
+44
-0
DashboardService.java
...main/java/com/yaoai/admin/dashboard/DashboardService.java
+184
-0
DashboardOverview.java
...java/com/yaoai/admin/dashboard/dto/DashboardOverview.java
+23
-0
DashboardProviderStats.java
...com/yaoai/admin/dashboard/dto/DashboardProviderStats.java
+27
-0
DashboardTaskStats.java
...ava/com/yaoai/admin/dashboard/dto/DashboardTaskStats.java
+19
-0
DashboardTrendPoint.java
...va/com/yaoai/admin/dashboard/dto/DashboardTrendPoint.java
+20
-0
PlanAdminController.java
...c/main/java/com/yaoai/admin/plan/PlanAdminController.java
+38
-0
PlanAdminService.java
.../src/main/java/com/yaoai/admin/plan/PlanAdminService.java
+46
-0
PlanUpdateRequest.java
...main/java/com/yaoai/admin/plan/dto/PlanUpdateRequest.java
+25
-0
TenantAdminController.java
...in/java/com/yaoai/admin/tenant/TenantAdminController.java
+126
-0
TenantAdminService.java
.../main/java/com/yaoai/admin/tenant/TenantAdminService.java
+357
-0
TenantBalanceOverview.java
...ava/com/yaoai/admin/tenant/dto/TenantBalanceOverview.java
+17
-0
TenantDetail.java
...rc/main/java/com/yaoai/admin/tenant/dto/TenantDetail.java
+22
-0
TenantImpersonateRequest.java
.../com/yaoai/admin/tenant/dto/TenantImpersonateRequest.java
+9
-0
TenantImpersonateResponse.java
...com/yaoai/admin/tenant/dto/TenantImpersonateResponse.java
+17
-0
TenantListItem.java
.../main/java/com/yaoai/admin/tenant/dto/TenantListItem.java
+30
-0
TenantPlanChangeRequest.java
...a/com/yaoai/admin/tenant/dto/TenantPlanChangeRequest.java
+9
-0
TenantQuotaUpdateRequest.java
.../com/yaoai/admin/tenant/dto/TenantQuotaUpdateRequest.java
+21
-0
TenantRechargeRequest.java
...ava/com/yaoai/admin/tenant/dto/TenantRechargeRequest.java
+15
-0
TenantSuspendRequest.java
...java/com/yaoai/admin/tenant/dto/TenantSuspendRequest.java
+9
-0
TenantUpdateRequest.java
.../java/com/yaoai/admin/tenant/dto/TenantUpdateRequest.java
+11
-0
UserAdminController.java
...c/main/java/com/yaoai/admin/user/UserAdminController.java
+62
-0
UserAdminService.java
.../src/main/java/com/yaoai/admin/user/UserAdminService.java
+106
-0
ResetPasswordResponse.java
.../java/com/yaoai/admin/user/dto/ResetPasswordResponse.java
+11
-0
UserQuery.java
...min/src/main/java/com/yaoai/admin/user/dto/UserQuery.java
+17
-0
UserStatusRequest.java
...main/java/com/yaoai/admin/user/dto/UserStatusRequest.java
+9
-0
SeedancePromptBuilder.java
...va/com/yaoai/ai/providers/util/SeedancePromptBuilder.java
+142
-0
StructuredVideoGenerateRequest.java
.../com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
+68
-0
UserPrompt.java
...ai-api/src/main/java/com/yaoai/api/dto/ai/UserPrompt.java
+39
-0
BillingChargeRequest.java
...main/java/com/yaoai/billing/dto/BillingChargeRequest.java
+48
-0
BillingCostService.java
...in/java/com/yaoai/billing/service/BillingCostService.java
+102
-0
V13__admin_backend.sql
...ap/src/main/resources/db/migration/V13__admin_backend.sql
+121
-0
V14__system_settings.sql
.../src/main/resources/db/migration/V14__system_settings.sql
+9
-0
V15__extend_ai_tasks_structured.sql
...esources/db/migration/V15__extend_ai_tasks_structured.sql
+24
-0
V16__multi_character_per_storyboard.sql
...rces/db/migration/V16__multi_character_per_storyboard.sql
+23
-0
Result.java
...-common/src/main/java/com/yaoai/common/result/Result.java
+43
-0
AdminAuditLog.java
.../src/main/java/com/yaoai/domain/entity/AdminAuditLog.java
+47
-0
AdminUser.java
...main/src/main/java/com/yaoai/domain/entity/AdminUser.java
+45
-0
Plan.java
...ai-domain/src/main/java/com/yaoai/domain/entity/Plan.java
+44
-0
SystemSetting.java
.../src/main/java/com/yaoai/domain/entity/SystemSetting.java
+24
-0
TenantQuota.java
...in/src/main/java/com/yaoai/domain/entity/TenantQuota.java
+33
-0
AdminAuditLogMapper.java
...ain/java/com/yaoai/domain/mapper/AdminAuditLogMapper.java
+11
-0
AdminUserMapper.java
...rc/main/java/com/yaoai/domain/mapper/AdminUserMapper.java
+11
-0
PlanMapper.java
...ain/src/main/java/com/yaoai/domain/mapper/PlanMapper.java
+11
-0
SystemSettingMapper.java
...ain/java/com/yaoai/domain/mapper/SystemSettingMapper.java
+17
-0
TenantQuotaMapper.java
.../main/java/com/yaoai/domain/mapper/TenantQuotaMapper.java
+11
-0
StructuredVideoSubmission.java
...ava/com/yaoai/pipeline/dto/StructuredVideoSubmission.java
+51
-0
No files found.
yaoai-comic-studio/k8s/templates/admin-deployment.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/k8s/templates/admin-ingress.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/k8s/templates/admin-service.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/k8s/templates/admin-web-deployment.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/k8s/templates/admin-web-ingress.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/k8s/templates/admin-web-service.yaml
0 → 100644
View file @
b7185a90
{{
- 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
}}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/AdminModuleConfig.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
;
import
org.springframework.context.annotation.ComponentScan
;
import
org.springframework.context.annotation.Configuration
;
@Configuration
@ComponentScan
(
"com.yaoai.admin"
)
public
class
AdminModuleConfig
{
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/adminuser/AdminUserAdminController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/adminuser/AdminUserAdminService.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/adminuser/dto/AdminUserCreateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/adminuser/dto/AdminUserQuery.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/adminuser/dto/AdminUserUpdateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/AdminAuditLogController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/AdminAuditQueryService.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/AdminAuditService.java
0 → 100644
View file @
b7185a90
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
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/annotation/AdminAudited.java
0 → 100644
View file @
b7185a90
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
""
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/aspect/AdminAuditAspect.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
audit
.
aspect
;
import
com.baomidou.mybatisplus.core.toolkit.Wrappers
;
import
com.fasterxml.jackson.core.JsonProcessingException
;
import
com.fasterxml.jackson.databind.ObjectMapper
;
import
com.yaoai.admin.audit.AdminAuditService
;
import
com.yaoai.admin.audit.annotation.AdminAudited
;
import
com.yaoai.admin.auth.StpAdminUtil
;
import
com.yaoai.domain.entity.AdminAuditLog
;
import
com.yaoai.domain.entity.AdminUser
;
import
com.yaoai.domain.mapper.AdminUserMapper
;
import
com.yaoai.domain.mapper.PlanMapper
;
import
com.yaoai.domain.mapper.TenantBalanceMapper
;
import
com.yaoai.domain.mapper.TenantMapper
;
import
com.yaoai.domain.mapper.TenantQuotaMapper
;
import
com.yaoai.domain.mapper.UserMapper
;
import
jakarta.servlet.http.HttpServletRequest
;
import
lombok.RequiredArgsConstructor
;
import
lombok.extern.slf4j.Slf4j
;
import
org.aspectj.lang.ProceedingJoinPoint
;
import
org.aspectj.lang.annotation.Around
;
import
org.aspectj.lang.annotation.Aspect
;
import
org.aspectj.lang.reflect.MethodSignature
;
import
org.slf4j.MDC
;
import
org.springframework.stereotype.Component
;
import
org.springframework.web.context.request.RequestContextHolder
;
import
org.springframework.web.context.request.ServletRequestAttributes
;
import
java.beans.Introspector
;
import
java.beans.PropertyDescriptor
;
import
java.time.LocalDateTime
;
import
java.util.LinkedHashMap
;
import
java.util.Map
;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public
class
AdminAuditAspect
{
private
final
AdminAuditService
adminAuditService
;
private
final
AdminUserMapper
adminUserMapper
;
private
final
TenantMapper
tenantMapper
;
private
final
TenantBalanceMapper
tenantBalanceMapper
;
private
final
TenantQuotaMapper
tenantQuotaMapper
;
private
final
UserMapper
userMapper
;
private
final
PlanMapper
planMapper
;
private
final
ObjectMapper
objectMapper
;
@Around
(
"@annotation(audited)"
)
public
Object
around
(
ProceedingJoinPoint
joinPoint
,
AdminAudited
audited
)
throws
Throwable
{
MethodSignature
signature
=
(
MethodSignature
)
joinPoint
.
getSignature
();
Map
<
String
,
Object
>
args
=
collectArgs
(
signature
.
getParameterNames
(),
joinPoint
.
getArgs
());
String
resourceId
=
resolveResourceId
(
audited
.
resourceIdParam
(),
args
);
Object
before
=
loadResourceSnapshot
(
audited
.
resource
(),
resourceId
,
args
);
Object
result
=
null
;
Throwable
failure
=
null
;
try
{
result
=
joinPoint
.
proceed
();
return
result
;
}
catch
(
Throwable
e
)
{
failure
=
e
;
throw
e
;
}
finally
{
Object
after
=
failure
==
null
?
loadResourceSnapshot
(
audited
.
resource
(),
resourceId
,
args
)
:
null
;
writeAudit
(
audited
,
args
,
resourceId
,
before
,
after
,
result
,
failure
);
}
}
private
void
writeAudit
(
AdminAudited
audited
,
Map
<
String
,
Object
>
args
,
String
resourceId
,
Object
before
,
Object
after
,
Object
result
,
Throwable
failure
)
{
try
{
AdminUser
admin
=
currentAdmin
();
HttpServletRequest
request
=
currentRequest
();
AdminAuditLog
log
=
new
AdminAuditLog
();
if
(
admin
!=
null
)
{
log
.
setAdminUserId
(
admin
.
getId
());
log
.
setAdminUsername
(
admin
.
getUsername
());
}
log
.
setAction
(
audited
.
action
());
log
.
setResourceType
(
audited
.
resource
());
log
.
setResourceId
(
resourceId
);
log
.
setBeforeValue
(
toJson
(
payload
(
"args"
,
args
,
"snapshot"
,
before
)));
if
(
failure
==
null
)
{
log
.
setAfterValue
(
toJson
(
payload
(
"result"
,
result
,
"snapshot"
,
after
)));
log
.
setStatus
(
"SUCCESS"
);
}
else
{
log
.
setStatus
(
"FAILED"
);
log
.
setErrorMessage
(
failure
.
getMessage
());
}
if
(
request
!=
null
)
{
log
.
setIp
(
getClientIp
(
request
));
log
.
setUserAgent
(
request
.
getHeader
(
"User-Agent"
));
}
log
.
setRequestId
(
MDC
.
get
(
"requestId"
));
log
.
setCreatedAt
(
LocalDateTime
.
now
());
adminAuditService
.
record
(
log
);
}
catch
(
Exception
e
)
{
log
.
warn
(
"Failed to write admin audit log: action={}, resource={}"
,
audited
.
action
(),
audited
.
resource
(),
e
);
}
}
private
Object
loadResourceSnapshot
(
String
resource
,
String
resourceId
,
Map
<
String
,
Object
>
args
)
{
try
{
return
switch
(
resource
)
{
case
"TENANT"
->
tenantSnapshot
(
parseLong
(
resourceId
));
case
"USER"
->
userMapper
.
selectById
(
parseLong
(
resourceId
));
case
"PLAN"
->
planMapper
.
selectById
(
parseLong
(
resourceId
));
case
"ADMIN_USER"
->
adminUserSnapshot
(
resourceId
,
args
);
default
->
null
;
};
}
catch
(
Exception
e
)
{
log
.
debug
(
"Skip admin audit snapshot: resource={}, id={}"
,
resource
,
resourceId
,
e
);
return
null
;
}
}
private
Map
<
String
,
Object
>
tenantSnapshot
(
Long
tenantId
)
{
if
(
tenantId
==
null
)
{
return
null
;
}
Map
<
String
,
Object
>
snapshot
=
new
LinkedHashMap
<>();
snapshot
.
put
(
"tenant"
,
tenantMapper
.
selectById
(
tenantId
));
snapshot
.
put
(
"balance"
,
tenantBalanceMapper
.
findByTenantId
(
tenantId
));
snapshot
.
put
(
"quota"
,
tenantQuotaMapper
.
selectOne
(
Wrappers
.<
com
.
yaoai
.
domain
.
entity
.
TenantQuota
>
lambdaQuery
()
.
eq
(
com
.
yaoai
.
domain
.
entity
.
TenantQuota
::
getTenantId
,
tenantId
)));
return
snapshot
;
}
private
Map
<
String
,
Object
>
payload
(
String
firstKey
,
Object
firstValue
,
String
secondKey
,
Object
secondValue
)
{
Map
<
String
,
Object
>
payload
=
new
LinkedHashMap
<>();
payload
.
put
(
firstKey
,
firstValue
);
payload
.
put
(
secondKey
,
secondValue
);
return
payload
;
}
private
AdminUser
adminUserSnapshot
(
String
resourceId
,
Map
<
String
,
Object
>
args
)
{
Long
id
=
parseLong
(
resourceId
);
if
(
id
!=
null
)
{
return
adminUserMapper
.
selectById
(
id
);
}
Object
username
=
args
.
get
(
"username"
);
if
(
username
==
null
)
{
username
=
resolveNestedArg
(
"username"
,
args
);
}
if
(
username
==
null
)
{
return
null
;
}
return
adminUserMapper
.
selectOne
(
Wrappers
.<
AdminUser
>
lambdaQuery
()
.
eq
(
AdminUser:
:
getUsername
,
String
.
valueOf
(
username
)));
}
private
Long
parseLong
(
String
value
)
{
if
(
value
==
null
||
value
.
isBlank
())
{
return
null
;
}
try
{
return
Long
.
parseLong
(
value
);
}
catch
(
NumberFormatException
e
)
{
return
null
;
}
}
private
AdminUser
currentAdmin
()
{
if
(!
StpAdminUtil
.
isLogin
())
{
return
null
;
}
return
adminUserMapper
.
selectById
(
StpAdminUtil
.
getLoginIdAsLong
());
}
private
HttpServletRequest
currentRequest
()
{
if
(
RequestContextHolder
.
getRequestAttributes
()
instanceof
ServletRequestAttributes
attrs
)
{
return
attrs
.
getRequest
();
}
return
null
;
}
private
Map
<
String
,
Object
>
collectArgs
(
String
[]
names
,
Object
[]
values
)
{
Map
<
String
,
Object
>
args
=
new
LinkedHashMap
<>();
for
(
int
i
=
0
;
i
<
values
.
length
;
i
++)
{
Object
value
=
values
[
i
];
if
(
value
instanceof
HttpServletRequest
)
{
continue
;
}
String
name
=
names
!=
null
&&
i
<
names
.
length
?
names
[
i
]
:
"arg"
+
i
;
args
.
put
(
name
,
redact
(
name
,
value
));
}
return
args
;
}
private
Object
redact
(
String
name
,
Object
value
)
{
String
lower
=
name
.
toLowerCase
();
if
(
lower
.
contains
(
"password"
)
||
lower
.
contains
(
"token"
))
{
return
"***"
;
}
if
(
value
==
null
||
value
instanceof
String
||
value
instanceof
Number
||
value
instanceof
Boolean
)
{
return
value
;
}
if
(
value
instanceof
Map
<?,
?>
map
)
{
Map
<
String
,
Object
>
sanitized
=
new
LinkedHashMap
<>();
map
.
forEach
((
key
,
item
)
->
sanitized
.
put
(
String
.
valueOf
(
key
),
redact
(
String
.
valueOf
(
key
),
item
)));
return
sanitized
;
}
return
sanitizeBean
(
value
);
}
@SuppressWarnings
(
"unchecked"
)
private
Object
sanitizeBean
(
Object
value
)
{
try
{
Map
<
String
,
Object
>
sanitized
=
objectMapper
.
convertValue
(
value
,
Map
.
class
);
Map
<
String
,
Object
>
redacted
=
new
LinkedHashMap
<>();
sanitized
.
forEach
((
key
,
item
)
->
redacted
.
put
(
key
,
redact
(
key
,
item
)));
return
redacted
;
}
catch
(
IllegalArgumentException
e
)
{
return
value
;
}
}
private
Object
resolveNestedArg
(
String
propertyName
,
Map
<
String
,
Object
>
args
)
{
for
(
Object
value
:
args
.
values
())
{
Object
nested
=
readProperty
(
value
,
propertyName
);
if
(
nested
!=
null
)
{
return
nested
;
}
}
return
null
;
}
private
Object
readProperty
(
Object
value
,
String
propertyName
)
{
if
(
value
==
null
)
{
return
null
;
}
if
(
value
instanceof
Map
<?,
?>
map
)
{
return
map
.
get
(
propertyName
);
}
try
{
for
(
PropertyDescriptor
descriptor
:
Introspector
.
getBeanInfo
(
value
.
getClass
()).
getPropertyDescriptors
())
{
if
(
propertyName
.
equals
(
descriptor
.
getName
())
&&
descriptor
.
getReadMethod
()
!=
null
)
{
return
descriptor
.
getReadMethod
().
invoke
(
value
);
}
}
}
catch
(
Exception
ignored
)
{
return
null
;
}
return
null
;
}
private
String
resolveResourceId
(
String
paramName
,
Map
<
String
,
Object
>
args
)
{
if
(
paramName
==
null
||
paramName
.
isBlank
())
{
return
null
;
}
Object
value
=
args
.
get
(
paramName
);
if
(
value
==
null
)
{
value
=
resolveNestedArg
(
paramName
,
args
);
}
return
value
==
null
?
null
:
String
.
valueOf
(
value
);
}
private
String
getClientIp
(
HttpServletRequest
request
)
{
String
ip
=
request
.
getHeader
(
"X-Forwarded-For"
);
if
(
ip
!=
null
&&
!
ip
.
isBlank
()
&&
!
"unknown"
.
equalsIgnoreCase
(
ip
))
{
return
ip
.
split
(
","
)[
0
].
trim
();
}
ip
=
request
.
getHeader
(
"X-Real-IP"
);
if
(
ip
!=
null
&&
!
ip
.
isBlank
()
&&
!
"unknown"
.
equalsIgnoreCase
(
ip
))
{
return
ip
;
}
return
request
.
getRemoteAddr
();
}
private
String
toJson
(
Object
value
)
{
try
{
return
objectMapper
.
writeValueAsString
(
value
);
}
catch
(
JsonProcessingException
e
)
{
return
"{\"serializationError\":\""
+
e
.
getOriginalMessage
()
+
"\"}"
;
}
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/audit/dto/AuditLogQuery.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/auth/AdminAuthController.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/auth/AdminAuthService.java
0 → 100644
View file @
b7185a90
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
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/auth/AdminPermissionInterface.java
0 → 100644
View file @
b7185a90
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
());
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/auth/StpAdminUtil.java
0 → 100644
View file @
b7185a90
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
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/BillingAdminController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/BillingAdminService.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
billing
;
import
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper
;
import
com.baomidou.mybatisplus.core.toolkit.Wrappers
;
import
com.yaoai.admin.billing.dto.BillingRecordQuery
;
import
com.yaoai.admin.billing.dto.BillingCostItem
;
import
com.yaoai.admin.billing.dto.BillingSummaryItem
;
import
com.yaoai.admin.billing.dto.UsageDetailResponse
;
import
com.yaoai.admin.common.PageResult
;
import
com.yaoai.billing.service.BillingCostService
;
import
com.yaoai.common.exception.BizException
;
import
com.yaoai.common.exception.ErrorCode
;
import
com.yaoai.domain.entity.BillingRecord
;
import
com.yaoai.domain.entity.Project
;
import
com.yaoai.domain.entity.Tenant
;
import
com.yaoai.domain.entity.User
;
import
com.yaoai.domain.mapper.BillingRecordMapper
;
import
com.yaoai.domain.mapper.ProjectMapper
;
import
com.yaoai.domain.mapper.TenantMapper
;
import
com.yaoai.domain.mapper.UserMapper
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.stereotype.Service
;
import
org.springframework.util.StringUtils
;
import
java.math.BigDecimal
;
import
java.math.RoundingMode
;
import
java.time.LocalDateTime
;
import
java.util.Comparator
;
import
java.util.LinkedHashMap
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Objects
;
@Service
@RequiredArgsConstructor
public
class
BillingAdminService
{
private
final
BillingRecordMapper
billingRecordMapper
;
private
final
TenantMapper
tenantMapper
;
private
final
UserMapper
userMapper
;
private
final
ProjectMapper
projectMapper
;
private
final
BillingCostService
billingCostService
;
private
static
final
Map
<
String
,
String
>
COST_LABELS
=
Map
.
ofEntries
(
Map
.
entry
(
"outline_generate"
,
"大纲生成"
),
Map
.
entry
(
"episode_generate"
,
"分集生成"
),
Map
.
entry
(
"character_extract"
,
"角色提取"
),
Map
.
entry
(
"scene_extract"
,
"场景提取"
),
Map
.
entry
(
"storyboard_generate"
,
"分镜生成"
),
Map
.
entry
(
"storyboard_prompt_generate"
,
"提示词生成"
),
Map
.
entry
(
"image_generate"
,
"图片生成"
),
Map
.
entry
(
"character_image_generate"
,
"角色图生成"
),
Map
.
entry
(
"scene_image_generate"
,
"场景图生成"
),
Map
.
entry
(
"video_generate"
,
"视频生成"
),
Map
.
entry
(
"assembly"
,
"视频合成"
)
);
public
PageResult
<
BillingRecord
>
records
(
BillingRecordQuery
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
=
billingRecordMapper
.
selectCount
(
buildQuery
(
query
,
null
));
List
<
BillingRecord
>
records
=
billingRecordMapper
.
selectList
(
buildQuery
(
query
,
null
)
.
orderByDesc
(
BillingRecord:
:
getCreatedAt
)
.
last
(
"LIMIT "
+
size
+
" OFFSET "
+
offset
));
return
new
PageResult
<>(
records
,
total
,
page
,
size
);
}
public
PageResult
<
BillingRecord
>
recharges
(
BillingRecordQuery
query
)
{
return
recordsWithMode
(
query
,
true
);
}
public
PageResult
<
BillingRecord
>
consumes
(
BillingRecordQuery
query
)
{
return
recordsWithMode
(
query
,
false
);
}
public
List
<
BillingSummaryItem
>
summary
(
String
dim
,
BillingRecordQuery
query
)
{
String
safeDim
=
StringUtils
.
hasText
(
dim
)
?
dim
:
"modality"
;
List
<
BillingRecord
>
records
=
billingRecordMapper
.
selectList
(
buildQuery
(
query
,
null
));
return
summarize
(
safeDim
,
records
);
}
public
UsageDetailResponse
usageDetail
(
Long
tenantId
,
LocalDateTime
from
,
LocalDateTime
to
,
String
groupBy
)
{
if
(
tenantMapper
.
selectById
(
tenantId
)
==
null
)
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"租户不存在"
);
}
BillingRecordQuery
query
=
new
BillingRecordQuery
();
query
.
setTenantId
(
tenantId
);
query
.
setFrom
(
from
);
query
.
setTo
(
to
);
List
<
BillingRecord
>
records
=
billingRecordMapper
.
selectList
(
buildQuery
(
query
,
false
));
BigDecimal
total
=
records
.
stream
()
.
map
(
BillingRecord:
:
getCredits
)
.
filter
(
Objects:
:
nonNull
)
.
reduce
(
BigDecimal
.
ZERO
,
BigDecimal:
:
add
);
return
UsageDetailResponse
.
builder
()
.
tenantId
(
tenantId
)
.
groupBy
(
StringUtils
.
hasText
(
groupBy
)
?
groupBy
:
"model"
)
.
total
(
total
)
.
breakdown
(
withPercent
(
summarize
(
StringUtils
.
hasText
(
groupBy
)
?
groupBy
:
"model"
,
records
),
total
))
.
build
();
}
public
List
<
BillingCostItem
>
costs
()
{
return
billingCostService
.
getCosts
().
entrySet
().
stream
()
.
map
(
entry
->
BillingCostItem
.
builder
()
.
operation
(
entry
.
getKey
())
.
label
(
COST_LABELS
.
getOrDefault
(
entry
.
getKey
(),
entry
.
getKey
()))
.
cost
(
entry
.
getValue
())
.
build
())
.
sorted
(
Comparator
.
comparing
(
BillingCostItem:
:
getOperation
))
.
toList
();
}
public
List
<
BillingCostItem
>
saveCosts
(
Map
<
String
,
BigDecimal
>
costs
,
Long
adminId
)
{
billingCostService
.
saveCosts
(
costs
,
adminId
);
return
costs
();
}
private
PageResult
<
BillingRecord
>
recordsWithMode
(
BillingRecordQuery
query
,
boolean
recharge
)
{
long
page
=
Math
.
max
(
1
,
query
.
getPage
());
long
size
=
Math
.
max
(
1
,
Math
.
min
(
query
.
getSize
(),
100
));
long
offset
=
(
page
-
1
)
*
size
;
long
total
=
billingRecordMapper
.
selectCount
(
buildQuery
(
query
,
recharge
));
List
<
BillingRecord
>
records
=
billingRecordMapper
.
selectList
(
buildQuery
(
query
,
recharge
)
.
orderByDesc
(
BillingRecord:
:
getCreatedAt
)
.
last
(
"LIMIT "
+
size
+
" OFFSET "
+
offset
));
return
new
PageResult
<>(
records
,
total
,
page
,
size
);
}
private
LambdaQueryWrapper
<
BillingRecord
>
buildQuery
(
BillingRecordQuery
query
,
Boolean
recharge
)
{
LambdaQueryWrapper
<
BillingRecord
>
wrapper
=
Wrappers
.<
BillingRecord
>
lambdaQuery
();
if
(
StringUtils
.
hasText
(
query
.
getModality
()))
{
wrapper
.
eq
(
BillingRecord:
:
getModality
,
query
.
getModality
());
}
if
(
StringUtils
.
hasText
(
query
.
getModelId
()))
{
wrapper
.
eq
(
BillingRecord:
:
getModelId
,
query
.
getModelId
());
}
if
(
query
.
getTenantId
()
!=
null
)
{
wrapper
.
eq
(
BillingRecord:
:
getTenantId
,
query
.
getTenantId
());
}
if
(
query
.
getUserId
()
!=
null
)
{
wrapper
.
eq
(
BillingRecord:
:
getUserId
,
query
.
getUserId
());
}
if
(
query
.
getFrom
()
!=
null
)
{
wrapper
.
ge
(
BillingRecord:
:
getCreatedAt
,
query
.
getFrom
());
}
if
(
query
.
getTo
()
!=
null
)
{
wrapper
.
le
(
BillingRecord:
:
getCreatedAt
,
query
.
getTo
());
}
if
(
recharge
!=
null
)
{
if
(
recharge
)
{
wrapper
.
and
(
w
->
w
.
eq
(
BillingRecord:
:
getModality
,
"RECHARGE"
)
.
or
()
.
eq
(
BillingRecord:
:
getOperation
,
"recharge"
));
}
else
{
wrapper
.
and
(
w
->
w
.
ne
(
BillingRecord:
:
getModality
,
"RECHARGE"
)
.
or
()
.
isNull
(
BillingRecord:
:
getModality
))
.
ne
(
BillingRecord:
:
getOperation
,
"recharge"
);
}
}
return
wrapper
;
}
private
List
<
BillingSummaryItem
>
summarize
(
String
dim
,
List
<
BillingRecord
>
records
)
{
Map
<
String
,
List
<
BillingRecord
>>
grouped
=
records
.
stream
()
.
collect
(
LinkedHashMap:
:
new
,
(
map
,
record
)
->
map
.
computeIfAbsent
(
summaryKey
(
dim
,
record
),
k
->
new
java
.
util
.
ArrayList
<>()).
add
(
record
),
Map:
:
putAll
);
return
grouped
.
entrySet
().
stream
()
.
map
(
entry
->
toSummaryItem
(
dim
,
entry
.
getKey
(),
entry
.
getValue
()))
.
sorted
(
Comparator
.
comparing
(
BillingSummaryItem:
:
getTotalCredits
).
reversed
())
.
toList
();
}
private
BillingSummaryItem
toSummaryItem
(
String
dim
,
String
key
,
List
<
BillingRecord
>
records
)
{
BigDecimal
credits
=
records
.
stream
()
.
map
(
BillingRecord:
:
getCredits
)
.
filter
(
Objects:
:
nonNull
)
.
reduce
(
BigDecimal
.
ZERO
,
BigDecimal:
:
add
);
long
units
=
records
.
stream
()
.
map
(
BillingRecord:
:
getUnitCount
)
.
filter
(
Objects:
:
nonNull
)
.
mapToLong
(
Integer:
:
longValue
)
.
sum
();
long
success
=
records
.
stream
().
filter
(
r
->
!
Boolean
.
FALSE
.
equals
(
r
.
getSuccess
())).
count
();
long
fail
=
records
.
size
()
-
success
;
return
BillingSummaryItem
.
builder
()
.
key
(
key
)
.
label
(
summaryLabel
(
dim
,
key
,
records
.
get
(
0
)))
.
totalCredits
(
credits
)
.
totalUnits
(
units
)
.
count
(
records
.
size
())
.
successCount
(
success
)
.
failCount
(
fail
)
.
percent
(
BigDecimal
.
ZERO
)
.
build
();
}
private
List
<
BillingSummaryItem
>
withPercent
(
List
<
BillingSummaryItem
>
items
,
BigDecimal
total
)
{
if
(
total
==
null
||
total
.
compareTo
(
BigDecimal
.
ZERO
)
<=
0
)
{
return
items
;
}
for
(
BillingSummaryItem
item
:
items
)
{
item
.
setPercent
(
item
.
getTotalCredits
()
.
multiply
(
new
BigDecimal
(
"100"
))
.
divide
(
total
,
2
,
RoundingMode
.
HALF_UP
));
}
return
items
;
}
private
String
summaryKey
(
String
dim
,
BillingRecord
record
)
{
return
switch
(
dim
)
{
case
"model"
->
blank
(
record
.
getModelProvider
())
+
"|"
+
blank
(
record
.
getModelId
());
case
"tenant"
->
String
.
valueOf
(
record
.
getTenantId
());
case
"user"
->
String
.
valueOf
(
record
.
getUserId
());
case
"day"
->
record
.
getCreatedAt
()
==
null
?
"unknown"
:
record
.
getCreatedAt
().
toLocalDate
().
toString
();
case
"project"
->
String
.
valueOf
(
record
.
getProjectId
());
case
"modality"
->
blank
(
record
.
getModality
());
default
->
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"不支持的汇总维度: "
+
dim
);
};
}
private
String
summaryLabel
(
String
dim
,
String
key
,
BillingRecord
sample
)
{
return
switch
(
dim
)
{
case
"model"
->
StringUtils
.
hasText
(
sample
.
getModelName
())
?
sample
.
getModelName
()
:
key
;
case
"tenant"
->
tenantName
(
sample
.
getTenantId
());
case
"user"
->
userName
(
sample
.
getUserId
());
case
"project"
->
projectName
(
sample
.
getProjectId
());
default
->
key
;
};
}
private
String
tenantName
(
Long
tenantId
)
{
Tenant
tenant
=
tenantId
==
null
?
null
:
tenantMapper
.
selectById
(
tenantId
);
return
tenant
==
null
?
String
.
valueOf
(
tenantId
)
:
tenant
.
getName
();
}
private
String
userName
(
Long
userId
)
{
User
user
=
userId
==
null
?
null
:
userMapper
.
selectById
(
userId
);
if
(
user
==
null
)
{
return
String
.
valueOf
(
userId
);
}
return
StringUtils
.
hasText
(
user
.
getEmail
())
?
user
.
getEmail
()
:
user
.
getUsername
();
}
private
String
projectName
(
Long
projectId
)
{
Project
project
=
projectId
==
null
?
null
:
projectMapper
.
selectById
(
projectId
);
return
project
==
null
?
String
.
valueOf
(
projectId
)
:
project
.
getName
();
}
private
String
blank
(
String
value
)
{
return
StringUtils
.
hasText
(
value
)
?
value
:
"unknown"
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/dto/BillingCostItem.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/dto/BillingCostUpdateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/dto/BillingRecordQuery.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/dto/BillingSummaryItem.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/billing/dto/UsageDetailResponse.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/common/PageResult.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/config/AdminGlobalExceptionHandler.java
0 → 100644
View file @
b7185a90
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
(),
"服务器内部错误"
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/config/SaTokenAdminConfig.java
0 → 100644
View file @
b7185a90
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"
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/DashboardController.java
0 → 100644
View file @
b7185a90
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
());
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/DashboardService.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/dto/DashboardOverview.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/dto/DashboardProviderStats.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/dto/DashboardTaskStats.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/dashboard/dto/DashboardTrendPoint.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/plan/PlanAdminController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/plan/PlanAdminService.java
0 → 100644
View file @
b7185a90
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
);
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/plan/dto/PlanUpdateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/TenantAdminController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/TenantAdminService.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
tenant
;
import
cn.dev33.satoken.stp.StpUtil
;
import
cn.dev33.satoken.stp.SaLoginModel
;
import
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper
;
import
com.baomidou.mybatisplus.core.toolkit.Wrappers
;
import
com.yaoai.admin.auth.StpAdminUtil
;
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.billing.service.BillingService
;
import
com.yaoai.common.exception.BizException
;
import
com.yaoai.common.exception.ErrorCode
;
import
com.yaoai.domain.entity.BillingRecord
;
import
com.yaoai.domain.entity.Plan
;
import
com.yaoai.domain.entity.Project
;
import
com.yaoai.domain.entity.Tenant
;
import
com.yaoai.domain.entity.TenantBalance
;
import
com.yaoai.domain.entity.TenantQuota
;
import
com.yaoai.domain.entity.User
;
import
com.yaoai.domain.mapper.BillingRecordMapper
;
import
com.yaoai.domain.mapper.PlanMapper
;
import
com.yaoai.domain.mapper.ProjectMapper
;
import
com.yaoai.domain.mapper.TenantBalanceMapper
;
import
com.yaoai.domain.mapper.TenantMapper
;
import
com.yaoai.domain.mapper.TenantQuotaMapper
;
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.math.BigDecimal
;
import
java.time.LocalDateTime
;
import
java.util.List
;
@Service
@RequiredArgsConstructor
public
class
TenantAdminService
{
private
final
TenantMapper
tenantMapper
;
private
final
UserMapper
userMapper
;
private
final
ProjectMapper
projectMapper
;
private
final
TenantBalanceMapper
tenantBalanceMapper
;
private
final
BillingRecordMapper
billingRecordMapper
;
private
final
PlanMapper
planMapper
;
private
final
TenantQuotaMapper
tenantQuotaMapper
;
private
final
BillingService
billingService
;
private
static
final
BigDecimal
MAX_SINGLE_RECHARGE
=
new
BigDecimal
(
"10000000"
);
private
static
final
long
IMPERSONATE_TTL_SECONDS
=
1800
;
public
PageResult
<
TenantListItem
>
list
(
String
keyword
,
String
plan
,
Integer
status
,
long
page
,
long
size
)
{
long
safePage
=
Math
.
max
(
1
,
page
);
long
safeSize
=
Math
.
max
(
1
,
Math
.
min
(
size
,
100
));
long
offset
=
(
safePage
-
1
)
*
safeSize
;
long
total
=
tenantMapper
.
selectCount
(
buildTenantQuery
(
keyword
,
plan
,
status
));
List
<
TenantListItem
>
records
=
tenantMapper
.
selectList
(
buildTenantQuery
(
keyword
,
plan
,
status
)
.
orderByDesc
(
Tenant:
:
getCreatedAt
)
.
last
(
"LIMIT "
+
safeSize
+
" OFFSET "
+
offset
))
.
stream
()
.
map
(
this
::
toListItem
)
.
toList
();
return
new
PageResult
<>(
records
,
total
,
safePage
,
safeSize
);
}
public
TenantDetail
detail
(
Long
tenantId
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
return
TenantDetail
.
builder
()
.
tenant
(
tenant
)
.
balance
(
balanceOrEmpty
(
tenantId
))
.
quota
(
tenantQuotaMapper
.
selectById
(
tenantId
))
.
memberCount
(
countMembers
(
tenantId
))
.
projectCount
(
countProjects
(
tenantId
))
.
build
();
}
@Transactional
public
Tenant
update
(
Long
tenantId
,
TenantUpdateRequest
request
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
if
(
StringUtils
.
hasText
(
request
.
getName
()))
{
tenant
.
setName
(
request
.
getName
());
}
if
(
request
.
getNotes
()
!=
null
)
{
tenant
.
setNotes
(
request
.
getNotes
());
}
tenant
.
setUpdatedAt
(
LocalDateTime
.
now
());
tenantMapper
.
updateById
(
tenant
);
return
tenantMapper
.
selectById
(
tenantId
);
}
@Transactional
public
Tenant
changePlan
(
Long
tenantId
,
TenantPlanChangeRequest
request
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
String
planCode
=
request
.
getPlanCode
()
==
null
?
""
:
request
.
getPlanCode
().
trim
().
toUpperCase
();
Plan
plan
=
planMapper
.
selectOne
(
Wrappers
.<
Plan
>
lambdaQuery
()
.
eq
(
Plan:
:
getCode
,
planCode
)
.
eq
(
Plan:
:
getIsActive
,
1
));
if
(
plan
==
null
)
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"套餐不存在或已停用"
);
}
tenant
.
setPlan
(
planCode
.
toLowerCase
());
tenant
.
setUpdatedAt
(
LocalDateTime
.
now
());
tenantMapper
.
updateById
(
tenant
);
TenantQuota
quota
=
tenantQuotaMapper
.
selectById
(
tenantId
);
if
(
quota
==
null
)
{
quota
=
new
TenantQuota
();
quota
.
setTenantId
(
tenantId
);
}
quota
.
setMaxMembers
(
plan
.
getMaxMembers
());
quota
.
setMaxProjects
(
plan
.
getMaxProjects
());
quota
.
setMaxMonthlyCredits
(
plan
.
getMonthlyCredits
());
quota
.
setMaxConcurrentTasks
(
plan
.
getMaxConcurrentTasks
());
quota
.
setUpdatedAt
(
LocalDateTime
.
now
());
quota
.
setUpdatedBy
(
StpAdminUtil
.
getLoginIdAsLong
());
if
(
tenantQuotaMapper
.
selectById
(
tenantId
)
==
null
)
{
tenantQuotaMapper
.
insert
(
quota
);
}
else
{
tenantQuotaMapper
.
updateById
(
quota
);
}
return
tenantMapper
.
selectById
(
tenantId
);
}
@Transactional
public
Tenant
suspend
(
Long
tenantId
,
TenantSuspendRequest
request
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
tenant
.
setStatus
(
0
);
tenant
.
setSuspendedAt
(
LocalDateTime
.
now
());
tenant
.
setSuspendedBy
(
StpAdminUtil
.
getLoginIdAsLong
());
tenant
.
setSuspendedReason
(
request
.
getReason
());
tenant
.
setUpdatedAt
(
LocalDateTime
.
now
());
tenantMapper
.
updateById
(
tenant
);
userMapper
.
findByTenant
(
tenantId
).
forEach
(
user
->
StpUtil
.
kickout
(
user
.
getId
()));
return
tenantMapper
.
selectById
(
tenantId
);
}
@Transactional
public
Tenant
resume
(
Long
tenantId
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
tenant
.
setStatus
(
1
);
tenant
.
setSuspendedAt
(
null
);
tenant
.
setSuspendedBy
(
null
);
tenant
.
setSuspendedReason
(
null
);
tenant
.
setUpdatedAt
(
LocalDateTime
.
now
());
tenantMapper
.
updateById
(
tenant
);
return
tenantMapper
.
selectById
(
tenantId
);
}
public
TenantBalanceOverview
balance
(
Long
tenantId
)
{
requireTenant
(
tenantId
);
List
<
BillingRecord
>
recent
=
billingRecordMapper
.
selectList
(
Wrappers
.<
BillingRecord
>
lambdaQuery
()
.
eq
(
BillingRecord:
:
getTenantId
,
tenantId
)
.
ge
(
BillingRecord:
:
getCreatedAt
,
LocalDateTime
.
now
().
minusDays
(
30
))
.
orderByDesc
(
BillingRecord:
:
getCreatedAt
)
.
last
(
"LIMIT 100"
));
return
TenantBalanceOverview
.
builder
()
.
balance
(
balanceOrEmpty
(
tenantId
))
.
recentRecords
(
recent
)
.
build
();
}
public
List
<
User
>
members
(
Long
tenantId
)
{
requireTenant
(
tenantId
);
return
userMapper
.
findByTenant
(
tenantId
);
}
public
List
<
Project
>
projects
(
Long
tenantId
)
{
requireTenant
(
tenantId
);
return
projectMapper
.
findByTenant
(
tenantId
);
}
@Transactional
public
TenantBalanceOverview
recharge
(
Long
tenantId
,
TenantRechargeRequest
request
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
if
(
Integer
.
valueOf
(
0
).
equals
(
tenant
.
getStatus
()))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"暂停租户不允许充值"
);
}
BigDecimal
amount
=
request
.
getAmount
();
if
(
amount
==
null
||
amount
.
compareTo
(
BigDecimal
.
ZERO
)
<=
0
)
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"充值金额必须大于 0"
);
}
if
(
amount
.
compareTo
(
MAX_SINGLE_RECHARGE
)
>
0
)
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"单笔充值不能超过 1000 万积分"
);
}
Long
adminId
=
StpAdminUtil
.
getLoginIdAsLong
();
String
refId
=
StringUtils
.
hasText
(
request
.
getExternalRefId
())
?
request
.
getExternalRefId
()
:
"ADMIN:"
+
adminId
+
":"
+
System
.
currentTimeMillis
();
billingService
.
rechargeByAdmin
(
tenantId
,
amount
,
adminId
,
refId
);
return
balance
(
tenantId
);
}
@Transactional
public
TenantQuota
updateQuota
(
Long
tenantId
,
TenantQuotaUpdateRequest
request
)
{
requireTenant
(
tenantId
);
TenantQuota
quota
=
tenantQuotaMapper
.
selectById
(
tenantId
);
if
(
quota
==
null
)
{
quota
=
new
TenantQuota
();
quota
.
setTenantId
(
tenantId
);
}
quota
.
setMaxMembers
(
request
.
getMaxMembers
());
quota
.
setMaxProjects
(
request
.
getMaxProjects
());
quota
.
setMaxMonthlyCredits
(
request
.
getMaxMonthlyCredits
());
quota
.
setMaxConcurrentTasks
(
request
.
getMaxConcurrentTasks
());
quota
.
setMonthlyBudgetLimit
(
request
.
getMonthlyBudgetLimit
());
quota
.
setNotes
(
request
.
getNotes
());
quota
.
setUpdatedAt
(
LocalDateTime
.
now
());
quota
.
setUpdatedBy
(
StpAdminUtil
.
getLoginIdAsLong
());
if
(
tenantQuotaMapper
.
selectById
(
tenantId
)
==
null
)
{
tenantQuotaMapper
.
insert
(
quota
);
}
else
{
tenantQuotaMapper
.
updateById
(
quota
);
}
return
tenantQuotaMapper
.
selectById
(
tenantId
);
}
public
TenantImpersonateResponse
impersonate
(
Long
tenantId
,
TenantImpersonateRequest
request
)
{
Tenant
tenant
=
requireTenant
(
tenantId
);
if
(
Integer
.
valueOf
(
0
).
equals
(
tenant
.
getStatus
()))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"暂停租户不允许模拟登录"
);
}
User
user
=
resolveImpersonateUser
(
tenantId
,
request
.
getUserId
());
Long
adminId
=
StpAdminUtil
.
getLoginIdAsLong
();
StpUtil
.
login
(
user
.
getId
(),
SaLoginModel
.
create
()
.
setTimeout
(
IMPERSONATE_TTL_SECONDS
)
.
setIsLastingCookie
(
false
)
.
setExtra
(
"tenantId"
,
tenantId
)
.
setExtra
(
"impersonated"
,
true
)
.
setExtra
(
"impersonatedBy"
,
adminId
));
StpUtil
.
getSession
()
.
set
(
"tenantId"
,
tenantId
)
.
set
(
"impersonated"
,
true
)
.
set
(
"impersonatedBy"
,
adminId
);
return
TenantImpersonateResponse
.
builder
()
.
token
(
StpUtil
.
getTokenValue
())
.
tenantId
(
tenantId
)
.
userId
(
user
.
getId
())
.
ttlSeconds
(
IMPERSONATE_TTL_SECONDS
)
.
build
();
}
private
LambdaQueryWrapper
<
Tenant
>
buildTenantQuery
(
String
keyword
,
String
plan
,
Integer
status
)
{
LambdaQueryWrapper
<
Tenant
>
query
=
Wrappers
.<
Tenant
>
lambdaQuery
()
.
isNull
(
Tenant:
:
getDeletedAt
);
if
(
StringUtils
.
hasText
(
keyword
))
{
query
.
and
(
q
->
q
.
like
(
Tenant:
:
getName
,
keyword
).
or
().
like
(
Tenant:
:
getSlug
,
keyword
));
}
if
(
StringUtils
.
hasText
(
plan
))
{
query
.
eq
(
Tenant:
:
getPlan
,
plan
.
trim
().
toLowerCase
());
}
if
(
status
!=
null
)
{
query
.
eq
(
Tenant:
:
getStatus
,
status
);
}
return
query
;
}
private
Tenant
requireTenant
(
Long
tenantId
)
{
Tenant
tenant
=
tenantMapper
.
selectOne
(
Wrappers
.<
Tenant
>
lambdaQuery
()
.
eq
(
Tenant:
:
getId
,
tenantId
)
.
isNull
(
Tenant:
:
getDeletedAt
));
if
(
tenant
==
null
)
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"租户不存在"
);
}
return
tenant
;
}
private
TenantListItem
toListItem
(
Tenant
tenant
)
{
TenantBalance
balance
=
tenantBalanceMapper
.
findByTenantId
(
tenant
.
getId
());
BigDecimal
currentBalance
=
balance
!=
null
?
balance
.
getBalance
()
:
tenant
.
getCredits
();
return
TenantListItem
.
builder
()
.
id
(
tenant
.
getId
())
.
name
(
tenant
.
getName
())
.
slug
(
tenant
.
getSlug
())
.
plan
(
tenant
.
getPlan
())
.
status
(
tenant
.
getStatus
())
.
balance
(
currentBalance
==
null
?
BigDecimal
.
ZERO
:
currentBalance
)
.
memberCount
(
countMembers
(
tenant
.
getId
()))
.
projectCount
(
countProjects
(
tenant
.
getId
()))
.
createdAt
(
tenant
.
getCreatedAt
())
.
build
();
}
private
TenantBalance
balanceOrEmpty
(
Long
tenantId
)
{
TenantBalance
balance
=
tenantBalanceMapper
.
findByTenantId
(
tenantId
);
if
(
balance
!=
null
)
{
return
balance
;
}
TenantBalance
empty
=
new
TenantBalance
();
empty
.
setTenantId
(
tenantId
);
empty
.
setBalance
(
BigDecimal
.
ZERO
);
empty
.
setTotalRecharged
(
BigDecimal
.
ZERO
);
empty
.
setTotalSpent
(
BigDecimal
.
ZERO
);
return
empty
;
}
private
long
countMembers
(
Long
tenantId
)
{
return
userMapper
.
selectCount
(
Wrappers
.<
User
>
lambdaQuery
()
.
eq
(
User:
:
getTenantId
,
tenantId
)
.
isNull
(
User:
:
getDeletedAt
));
}
private
long
countProjects
(
Long
tenantId
)
{
return
projectMapper
.
selectCount
(
Wrappers
.<
Project
>
lambdaQuery
()
.
eq
(
Project:
:
getTenantId
,
tenantId
)
.
isNull
(
Project:
:
getDeletedAt
));
}
private
User
resolveImpersonateUser
(
Long
tenantId
,
Long
requestedUserId
)
{
if
(
requestedUserId
!=
null
)
{
User
user
=
userMapper
.
selectOne
(
Wrappers
.<
User
>
lambdaQuery
()
.
eq
(
User:
:
getTenantId
,
tenantId
)
.
eq
(
User:
:
getId
,
requestedUserId
)
.
eq
(
User:
:
getStatus
,
1
)
.
isNull
(
User:
:
getDeletedAt
));
if
(
user
==
null
)
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"目标用户不存在或已禁用"
);
}
return
user
;
}
User
owner
=
userMapper
.
selectOne
(
Wrappers
.<
User
>
lambdaQuery
()
.
eq
(
User:
:
getTenantId
,
tenantId
)
.
eq
(
User:
:
getStatus
,
1
)
.
isNull
(
User:
:
getDeletedAt
)
.
in
(
User:
:
getRole
,
List
.
of
(
"owner"
,
"admin"
))
.
last
(
"LIMIT 1"
));
if
(
owner
!=
null
)
{
return
owner
;
}
User
firstActive
=
userMapper
.
selectOne
(
Wrappers
.<
User
>
lambdaQuery
()
.
eq
(
User:
:
getTenantId
,
tenantId
)
.
eq
(
User:
:
getStatus
,
1
)
.
isNull
(
User:
:
getDeletedAt
)
.
orderByAsc
(
User:
:
getCreatedAt
)
.
last
(
"LIMIT 1"
));
if
(
firstActive
==
null
)
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"租户下没有可模拟登录的活跃用户"
);
}
return
firstActive
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantBalanceOverview.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantDetail.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantImpersonateRequest.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
tenant
.
dto
;
import
lombok.Data
;
@Data
public
class
TenantImpersonateRequest
{
private
Long
userId
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantImpersonateResponse.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantListItem.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantPlanChangeRequest.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
tenant
.
dto
;
import
lombok.Data
;
@Data
public
class
TenantPlanChangeRequest
{
private
String
planCode
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantQuotaUpdateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantRechargeRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantSuspendRequest.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
tenant
.
dto
;
import
lombok.Data
;
@Data
public
class
TenantSuspendRequest
{
private
String
reason
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/tenant/dto/TenantUpdateRequest.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
tenant
.
dto
;
import
lombok.Data
;
@Data
public
class
TenantUpdateRequest
{
private
String
name
;
private
String
notes
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/user/UserAdminController.java
0 → 100644
View file @
b7185a90
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
));
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/user/UserAdminService.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/user/dto/ResetPasswordResponse.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
user
.
dto
;
import
lombok.AllArgsConstructor
;
import
lombok.Data
;
@Data
@AllArgsConstructor
public
class
ResetPasswordResponse
{
private
String
password
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/user/dto/UserQuery.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/user/dto/UserStatusRequest.java
0 → 100644
View file @
b7185a90
package
com
.
yaoai
.
admin
.
user
.
dto
;
import
lombok.Data
;
@Data
public
class
UserStatusRequest
{
private
Integer
status
;
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/util/SeedancePromptBuilder.java
0 → 100644
View file @
b7185a90
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
)
{
}
}
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/UserPrompt.java
0 → 100644
View file @
b7185a90
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
();
}
}
yaoai-comic-studio/yaoai-billing/src/main/java/com/yaoai/billing/dto/BillingChargeRequest.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-billing/src/main/java/com/yaoai/billing/service/BillingCostService.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-bootstrap/src/main/resources/db/migration/V13__admin_backend.sql
0 → 100644
View file @
b7185a90
-- ============================================================
-- 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
);
yaoai-comic-studio/yaoai-bootstrap/src/main/resources/db/migration/V14__system_settings.sql
0 → 100644
View file @
b7185a90
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'
;
yaoai-comic-studio/yaoai-bootstrap/src/main/resources/db/migration/V15__extend_ai_tasks_structured.sql
0 → 100644
View file @
b7185a90
-- ============================================================
-- 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
);
yaoai-comic-studio/yaoai-bootstrap/src/main/resources/db/migration/V16__multi_character_per_storyboard.sql
0 → 100644
View file @
b7185a90
-- ============================================================
-- 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'
;
yaoai-comic-studio/yaoai-common/src/main/java/com/yaoai/common/result/Result.java
0 → 100644
View file @
b7185a90
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
;
}
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/AdminAuditLog.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/AdminUser.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/Plan.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/SystemSetting.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/TenantQuota.java
0 → 100644
View file @
b7185a90
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
;
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/AdminAuditLogMapper.java
0 → 100644
View file @
b7185a90
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
>
{
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/AdminUserMapper.java
0 → 100644
View file @
b7185a90
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
>
{
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/PlanMapper.java
0 → 100644
View file @
b7185a90
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
>
{
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/SystemSettingMapper.java
0 → 100644
View file @
b7185a90
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
)));
}
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/TenantQuotaMapper.java
0 → 100644
View file @
b7185a90
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
>
{
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/dto/StructuredVideoSubmission.java
0 → 100644
View file @
b7185a90
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
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment