修复apiresult的问题;增加用户详情查看的逻辑

This commit is contained in:
BBIT-Kai
2026-05-21 14:55:08 +08:00
parent 40f1c27e71
commit 3506efe894
7 changed files with 503 additions and 33 deletions
@@ -12,6 +12,7 @@ import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.entity.common.system.UserRoleBrief
import com.bbit.ticket.utils.ApiKeyUtil
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.Op
@@ -90,10 +91,26 @@ object UserDao {
fun detail(id: Uuid): UserDetailResponse {
val user = requireActive(id)
val roleIds = SysUserRoleTable.selectAll()
.where { SysUserRoleTable.userId eq id }
.map { it[SysUserRoleTable.roleId].toString() }
return user.toUserDetail(roleIds)
val roles = (SysUserRoleTable innerJoin SysRoleTable)
.selectAll()
.where {
(SysUserRoleTable.userId eq id) and
SysRoleTable.deletedAt.isNull()
}
.map {
UserRoleBrief(
id = it[SysRoleTable.id].toString(),
name = it[SysRoleTable.name],
code = it[SysRoleTable.code],
)
}
val roleIds = roles.map { it.id }
val org = user[SysUserTable.orgId]?.let { orgId ->
SysOrgTable.selectAll()
.where { (SysOrgTable.id eq orgId) and SysOrgTable.deletedAt.isNull() }
.singleOrNull()
}
return user.toUserDetail(roleIds, roles, org)
}
fun updateProfile(id: Uuid, request: UpdateUserRequest, orgId: Uuid?) {
@@ -226,7 +243,11 @@ object UserDao {
apiKey = this[SysUserTable.apiKey],
)
private fun ResultRow.toUserDetail(roleIds: List<String>) = UserDetailResponse(
private fun ResultRow.toUserDetail(
roleIds: List<String>,
roles: List<UserRoleBrief>,
org: ResultRow?,
) = UserDetailResponse(
id = this[SysUserTable.id].toString(),
username = this[SysUserTable.username],
nickname = this[SysUserTable.nickname],
@@ -235,13 +256,38 @@ object UserDao {
email = this[SysUserTable.email],
avatar = this[SysUserTable.avatar],
orgId = this[SysUserTable.orgId]?.toString(),
orgName = org?.get(SysOrgTable.name),
orgCode = org?.get(SysOrgTable.code),
status = this[SysUserTable.status],
statusLabel = statusLabel(this[SysUserTable.status]),
roleIds = roleIds,
roles = roles,
apiKey = this[SysUserTable.apiKey],
tokenVersion = this[SysUserTable.tokenVersion],
lastLoginAt = this[SysUserTable.lastLoginAt]?.toString(),
lastLoginIp = this[SysUserTable.lastLoginIp],
createdAt = this[SysUserTable.createdAt].toString(),
createdBy = this[SysUserTable.createdBy]?.toString(),
updatedAt = this[SysUserTable.updatedAt]?.toString(),
updatedBy = this[SysUserTable.updatedBy]?.toString(),
deletedAt = this[SysUserTable.deletedAt]?.toString(),
deletedBy = this[SysUserTable.deletedBy]?.toString(),
version = this[SysUserTable.version],
taxpayerNum = this[SysUserTable.taxpayerNum],
account = this[SysUserTable.taxAccount],
taxPassword = this[SysUserTable.taxPassword],
taxIdentityType = this[SysUserTable.taxIdentityType],
taxContactName = this[SysUserTable.taxContactName],
taxContactPhone = this[SysUserTable.taxContactPhone],
taxContactEmail = this[SysUserTable.taxContactEmail],
taxLegalPersonName = this[SysUserTable.taxLegalPersonName],
taxEnterpriseName = this[SysUserTable.taxEnterpriseName],
taxRegionCode = this[SysUserTable.taxRegionCode],
taxCityName = this[SysUserTable.taxCityName],
taxEnterpriseAddress = this[SysUserTable.taxEnterpriseAddress],
taxRegistrationCertificate = this[SysUserTable.taxRegistrationCertificate],
bankName = this[SysUserTable.bankName],
bankAccount = this[SysUserTable.bankAccount],
presetAddress = this[SysUserTable.presetAddress],
presetPhone = this[SysUserTable.presetPhone],
)
}
@@ -24,14 +24,46 @@ data class UserDetailResponse(
val email: String? = null,
val avatar: String? = null,
val orgId: String? = null,
val orgName: String? = null,
val orgCode: String? = null,
val status: String,
val statusLabel: String,
val roleIds: List<String>,
val roles: List<UserRoleBrief> = emptyList(),
val apiKey: String? = null,
val tokenVersion: Int,
val lastLoginAt: String? = null,
val lastLoginIp: String? = null,
val createdAt: String? = null,
val createdBy: String? = null,
val updatedAt: String? = null,
val updatedBy: String? = null,
val deletedAt: String? = null,
val deletedBy: String? = null,
val version: Int,
val taxpayerNum: String? = null,
val account: String? = null,
val taxPassword: String? = null,
val taxIdentityType: String? = null,
val taxContactName: String? = null,
val taxContactPhone: String? = null,
val taxContactEmail: String? = null,
val taxLegalPersonName: String? = null,
val taxEnterpriseName: String? = null,
val taxRegionCode: String? = null,
val taxCityName: String? = null,
val taxEnterpriseAddress: String? = null,
val taxRegistrationCertificate: String? = null,
val bankName: String? = null,
val bankAccount: String? = null,
val presetAddress: String? = null,
val presetPhone: String? = null,
)
@Serializable
data class UserRoleBrief(
val id: String,
val name: String,
val code: String,
)
@Serializable
@@ -12,9 +12,10 @@ import com.bbit.ticket.service.openapi.OpenBlueInvoiceService
import com.bbit.ticket.service.system.ApiAccessLogService
import com.bbit.ticket.utils.requireOpenApiPrincipal
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.http.ContentType
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
@@ -92,20 +93,23 @@ private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
try {
val response = ok(block())
val responseBody = myJson.encodeToString(response)
respond(response)
respondText(responseBody, ContentType.Application.Json)
saveOpenApiLog(appKey, appName, requestBody, response.code, responseBody, "SUCCESS", null, start)
} catch (e: PTException) {
val response = fail(code = e.code, message = e.message, traceId = e.serialNo)
respond(response)
saveOpenApiLog(appKey, appName, requestBody, e.code, myJson.encodeToString(response), "FAILED", e.message, start)
val responseBody = myJson.encodeToString(response)
respondText(responseBody, ContentType.Application.Json)
saveOpenApiLog(appKey, appName, requestBody, e.code, responseBody, "FAILED", e.message, start)
} catch (e: BizException) {
val response = fail(code = e.errorCode, message = e.message)
respond(e.status, response)
saveOpenApiLog(appKey, appName, requestBody, e.errorCode, myJson.encodeToString(response), "FAILED", e.message, start)
val responseBody = myJson.encodeToString(response)
respondText(responseBody, ContentType.Application.Json, e.status)
saveOpenApiLog(appKey, appName, requestBody, e.errorCode, responseBody, "FAILED", e.message, start)
} catch (e: Exception) {
val response = fail(code = "-1", message = e.message ?: "开放接口调用失败")
respond(response)
saveOpenApiLog(appKey, appName, requestBody, "-1", myJson.encodeToString(response), "FAILED", e.message, start)
val responseBody = myJson.encodeToString(response)
respondText(responseBody, ContentType.Application.Json)
saveOpenApiLog(appKey, appName, requestBody, "-1", responseBody, "FAILED", e.message, start)
}
}
@@ -4,12 +4,15 @@ import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondText
import kotlinx.serialization.encodeToString
/**
* 使用统一票通响应格式执行接口逻辑。
@@ -22,13 +25,39 @@ suspend inline fun <reified T> ApplicationCall.respondPt(
crossinline block: suspend () -> T,
) {
try {
respond(ok(block()))
respondJson(ok(block()))
} catch (e: PTException) {
respond(fail(code = e.code, message = e.message, traceId = e.serialNo))
respondJson(fail(code = e.code, message = e.message, traceId = e.serialNo))
} catch (e: BizException) {
respond(e.status, fail(code = e.errorCode, message = e.message))
respondJson(fail(code = e.errorCode, message = e.message), e.status)
} catch (e: Exception) {
respond(fail(code = "-1", message = e.message ?: fallbackMessage))
respondJson(fail(code = "-1", message = e.message ?: fallbackMessage))
}
}
/**
* 使用统一票通响应格式执行可空查询,空结果按空对象返回。
*
* @param fallbackMessage 未知异常时返回给前端的兜底提示。
* @param block 当前接口要执行的查询逻辑。
*/
suspend inline fun <reified T : Any> ApplicationCall.respondPtOrEmptyObject(
fallbackMessage: String,
crossinline block: suspend () -> T?,
) {
try {
val data = block()
if (data == null) {
respondJson(ok(emptyMap<String, String>()))
} else {
respondJson(ok(data))
}
} catch (e: PTException) {
respondJson(fail(code = e.code, message = e.message, traceId = e.serialNo))
} catch (e: BizException) {
respondJson(fail(code = e.errorCode, message = e.message), e.status)
} catch (e: Exception) {
respondJson(fail(code = "-1", message = e.message ?: fallbackMessage))
}
}
@@ -41,7 +70,7 @@ suspend inline fun <reified T> ApplicationCall.respondPt(
suspend fun ApplicationCall.requiredQueryParameter(name: String, message: String): String? {
val value = request.queryParameters[name]?.trim()?.takeIf { it.isNotEmpty() }
if (value == null) {
respond(fail(code = "-1", message = message))
respondJson(fail(code = "-1", message = message))
}
return value
}
@@ -62,10 +91,23 @@ suspend fun ApplicationCall.respondPtPdf(
response.header(HttpHeaders.ContentDisposition, "inline; filename=\"$filename\"")
respondBytes(block(), ContentType.Application.Pdf)
} catch (e: PTException) {
respond(fail(code = e.code, message = e.message, traceId = e.serialNo))
respondJson(fail(code = e.code, message = e.message, traceId = e.serialNo))
} catch (e: BizException) {
respond(e.status, fail(code = e.errorCode, message = e.message))
respondJson(fail(code = e.errorCode, message = e.message), e.status)
} catch (e: Exception) {
respond(fail(code = "-1", message = e.message ?: fallbackMessage))
respondJson(fail(code = "-1", message = e.message ?: fallbackMessage))
}
}
/**
* 显式序列化 JSON 响应,避免 Ktor 在泛型 helper 中丢失 ApiResult<T> 类型信息。
*
* @param data 要返回的响应对象。
* @param status HTTP 状态码。
*/
suspend inline fun <reified T> ApplicationCall.respondJson(
data: T,
status: HttpStatusCode = HttpStatusCode.OK,
) {
respondText(myJson.encodeToString(data), ContentType.Application.Json, status)
}
@@ -57,8 +57,8 @@ fun Route.registerPTAuthRoutes() {
}
get("/enterprise") {
call.respondPt("查询企业信息失败") {
PTConfigService.getEnterpriseInfo(call.requireCurrentUser().id) ?: emptyMap<String, String>()
call.respondPtOrEmptyObject("查询企业信息失败") {
PTConfigService.getEnterpriseInfo(call.requireCurrentUser().id)
}
}
@@ -72,12 +72,12 @@ fun Route.registerPTAuthRoutes() {
}
get("/digital-account") {
call.respondPt("查询数电账号失败") {
call.respondPtOrEmptyObject("查询数电账号失败") {
val currentUser = call.requireCurrentUser()
if (currentUser.taxPayerNum == null) {
throw BizException("-1", "请先完善用户信息", HttpStatusCode.OK)
}
PTConfigService.getDigitalAccount(currentUser.id) ?: emptyMap<String, String>()
PTConfigService.getDigitalAccount(currentUser.id)
}
}
@@ -91,8 +91,8 @@ fun Route.registerPTAuthRoutes() {
}
get("/preset") {
call.respondPt("查询预设数据失败") {
PTConfigService.getPresetData(call.requireCurrentUser().id) ?: emptyMap<String, String>()
call.respondPtOrEmptyObject("查询预设数据失败") {
PTConfigService.getPresetData(call.requireCurrentUser().id)
}
}
+317 -2
View File
@@ -45,6 +45,96 @@
</div>
</section>
<n-modal
v-model:show="detailModal.visible"
preset="card"
title="用户详情"
:style="{ width: '920px', maxWidth: '92vw' }"
:mask-closable="false"
content-style="padding: 0; max-height: calc(85vh - 130px); overflow-y: auto"
>
<n-spin :show="detailModal.loading">
<template v-if="detailUser">
<div class="detail-shell">
<div class="detail-header">
<div>
<div class="detail-name">{{ detailUser.nickname || detailUser.realName || detailUser.username }}</div>
<div class="detail-username">{{ detailUser.username }}</div>
<div class="detail-tags">
<n-tag :type="statusTagType(detailUser.status)" size="small" round>
{{ detailUser.statusLabel || statusLabel(detailUser.status) }}
</n-tag>
<n-tag v-if="detailUser.orgName || detailUser.orgCode" size="small" round>
{{ detailUser.orgName || detailUser.orgCode }}
</n-tag>
</div>
</div>
<div class="detail-total">
<span>角色数</span>
<strong>{{ detailUser.roles?.length || 0 }}</strong>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">基础信息</div>
<div class="detail-grid">
<div v-for="item in baseDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">角色信息</div>
<div class="role-tags">
<n-tag
v-for="role in detailUser.roles"
:key="role.id"
size="small"
round
type="info"
>
{{ role.name }}{{ role.code }}
</n-tag>
<span v-if="detailUser.roles.length === 0" class="empty-text">-</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">税务信息</div>
<div class="detail-grid">
<div v-for="item in taxDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">银行与预设信息</div>
<div class="detail-grid">
<div v-for="item in presetDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">系统信息</div>
<div class="detail-grid">
<div v-for="item in systemDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</div>
</template>
</n-spin>
</n-modal>
<n-modal
v-model:show="editModal.visible"
preset="card"
@@ -169,7 +259,12 @@ import {
updateUserStatusApi
} from '@/api/system/user'
import { listRolesApi } from '@/api/system/role'
import type { CreateUserRequest, UpdateUserRequest, UserListItem } from '@/types/system/user'
import type {
CreateUserRequest,
UpdateUserRequest,
UserDetail,
UserListItem
} from '@/types/system/user'
const message = useMessage()
const authStore = useAuthStore()
@@ -227,6 +322,76 @@ const pagination = computed(() => ({
}))
const editFormRef = ref<FormInst | null>(null)
const detailModal = reactive({
visible: false,
loading: false
})
const detailUser = ref<UserDetail | null>(null)
const baseDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: '用户ID', value: displayValue(user.id) },
{ label: '用户名', value: displayValue(user.username) },
{ label: '昵称', value: displayValue(user.nickname) },
{ label: '姓名', value: displayValue(user.realName) },
{ label: '手机号', value: displayValue(user.phone) },
{ label: '邮箱', value: displayValue(user.email) },
{ label: '组织', value: formatOrg(user) },
{ label: '状态', value: user.statusLabel || statusLabel(user.status) },
{ label: '头像', value: displayValue(user.avatar) },
{ label: 'API Key', value: displayValue(user.apiKey) }
]
})
const taxDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: '纳税人识别号', value: displayValue(user.taxpayerNum) },
{ label: '数电账号', value: displayValue(user.account) },
{ label: '身份类型', value: displayValue(user.taxIdentityType) },
{ label: '联系人', value: displayValue(user.taxContactName) },
{ label: '联系人电话', value: displayValue(user.taxContactPhone) },
{ label: '联系人邮箱', value: displayValue(user.taxContactEmail) },
{ label: '法定代表人', value: displayValue(user.taxLegalPersonName) },
{ label: '企业名称', value: displayValue(user.taxEnterpriseName) },
{ label: '地区编码', value: displayValue(user.taxRegionCode) },
{ label: '城市', value: displayValue(user.taxCityName) },
{ label: '企业地址', value: displayValue(user.taxEnterpriseAddress) },
{ label: '税务登记证', value: displayValue(user.taxRegistrationCertificate) }
]
})
const presetDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: '开户行', value: displayValue(user.bankName) },
{ label: '银行账号', value: displayValue(user.bankAccount) },
{ label: '预设地址', value: displayValue(user.presetAddress) },
{ label: '预设电话', value: displayValue(user.presetPhone) }
]
})
const systemDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: 'Token版本', value: displayValue(user.tokenVersion) },
{ label: '数据版本', value: displayValue(user.version) },
{ label: '最后登录时间', value: displayValue(user.lastLoginAt) },
{ label: '最后登录IP', value: displayValue(user.lastLoginIp) },
{ label: '创建时间', value: displayValue(user.createdAt) },
{ label: '创建人', value: displayValue(user.createdBy) },
{ label: '更新时间', value: displayValue(user.updatedAt) },
{ label: '更新人', value: displayValue(user.updatedBy) },
{ label: '删除时间', value: displayValue(user.deletedAt) },
{ label: '删除人', value: displayValue(user.deletedBy) }
]
})
const editModal = reactive({
visible: false,
mode: 'create' as 'create' | 'edit',
@@ -373,6 +538,29 @@ function openCreate() {
editModal.visible = true
}
function displayValue(value: unknown) {
if (value === null || value === undefined || value === '') return '-'
return String(value)
}
function formatOrg(user: UserDetail) {
if (user.orgName && user.orgCode) return `${user.orgName}${user.orgCode}`
return user.orgName || user.orgCode || user.orgId || '-'
}
async function openDetail(row: UserListItem) {
detailModal.visible = true
detailModal.loading = true
detailUser.value = null
try {
detailUser.value = await getUserDetailApi(row.id)
} catch {
message.error('查询用户详情失败')
} finally {
detailModal.loading = false
}
}
async function openEdit(row: UserListItem) {
editModal.mode = 'edit'
editModal.title = `编辑用户 - ${row.username}`
@@ -485,13 +673,24 @@ const columns = computed<DataTableColumns<UserListItem>>(() => [
{
title: '操作',
key: 'actions',
width: 360,
width: 430,
render: (row) =>
h(
NSpace,
{ size: 6 },
{
default: () => [
h(
NButton,
{
size: 'small',
tertiary: true,
type: 'info',
class: 'action-btn',
onClick: () => openDetail(row)
},
{ default: () => '详情' }
),
authStore.hasPermission('system:user:update')
? h(
NButton,
@@ -560,3 +759,119 @@ const columns = computed<DataTableColumns<UserListItem>>(() => [
}
])
</script>
<style scoped>
.detail-shell {
padding: 0 20px 20px;
}
.detail-header {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 0 16px;
border-bottom: 1px solid #eef1f5;
}
.detail-name {
color: #111827;
font-size: 18px;
font-weight: 600;
}
.detail-username {
margin-top: 4px;
color: #6b7280;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
}
.detail-tags {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.detail-total {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.detail-total span {
color: #6b7280;
font-size: 12px;
}
.detail-total strong {
color: #111827;
font-size: 24px;
font-weight: 600;
}
.detail-section {
padding-top: 18px;
}
.detail-section-title {
margin-bottom: 12px;
color: #111827;
font-size: 15px;
font-weight: 600;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detail-item {
padding: 14px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fafafa;
}
.detail-item span {
display: block;
margin-bottom: 6px;
color: #6b7280;
font-size: 12px;
}
.detail-item strong {
color: #111827;
font-size: 14px;
font-weight: 500;
word-break: break-all;
}
.role-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty-text {
color: #9ca3af;
font-size: 13px;
}
@media (max-width: 960px) {
.detail-grid {
grid-template-columns: 1fr;
}
.detail-header {
flex-direction: column;
align-items: stretch;
}
.detail-total {
align-items: flex-start;
}
}
</style>
+32 -1
View File
@@ -21,14 +21,45 @@ export interface UserDetail {
email?: string | null
avatar?: string | null
orgId?: string | null
orgName?: string | null
orgCode?: string | null
status: string
statusLabel?: string
roleIds: string[]
roles: UserRoleBrief[]
apiKey?: string | null
tokenVersion: number
lastLoginAt?: string | null
lastLoginIp?: string | null
createdAt?: string | null
createdBy?: string | null
updatedAt?: string | null
updatedBy?: string | null
deletedAt?: string | null
deletedBy?: string | null
version: number
taxpayerNum?: string | null
account?: string | null
taxPassword?: string | null
taxIdentityType?: string | null
taxContactName?: string | null
taxContactPhone?: string | null
taxContactEmail?: string | null
taxLegalPersonName?: string | null
taxEnterpriseName?: string | null
taxRegionCode?: string | null
taxCityName?: string | null
taxEnterpriseAddress?: string | null
taxRegistrationCertificate?: string | null
bankName?: string | null
bankAccount?: string | null
presetAddress?: string | null
presetPhone?: string | null
}
export interface UserRoleBrief {
id: string
name: string
code: string
}
export interface UserQuery {