fix logger and redeem admin review findings
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

This commit is contained in:
2026-04-20 11:24:36 +08:00
parent 3a0ca7f57f
commit ed642e8769
22 changed files with 563 additions and 63 deletions

View File

@@ -11,6 +11,16 @@ import type {
PaginatedResponse
} from '@/types'
export interface RedeemBatchDeleteSkipped {
id: number
reason: string
}
export interface RedeemBatchDeleteResult {
deleted_ids: number[]
skipped: RedeemBatchDeleteSkipped[]
}
/**
* List all redeem codes with pagination
* @param page - Page number (default: 1)
@@ -102,14 +112,8 @@ export async function deleteCode(id: number): Promise<{ message: string }> {
* @param ids - Array of redeem code IDs
* @returns Success confirmation
*/
export async function batchDelete(ids: number[]): Promise<{
deleted: number
message: string
}> {
const { data } = await apiClient.post<{
deleted: number
message: string
}>('/admin/redeem-codes/batch-delete', { ids })
export async function batchDelete(ids: number[]): Promise<RedeemBatchDeleteResult> {
const { data } = await apiClient.post<RedeemBatchDeleteResult>('/admin/redeem-codes/batch-delete', { ids })
return data
}

View File

@@ -119,7 +119,7 @@ describe('UserEditModal', () => {
// username 输入框是第二个 text input第一个是密码
const textInputs = wrapper.findAll('input[type="text"]')
// 找到 username 输入框(它有一个 label
const usernameInput = textInputs.find(input => {
const _usernameInput = textInputs.find(input => {
const parent = input.element.closest('div')
return parent?.querySelector('label')?.textContent?.includes('admin.users.username')
}) || textInputs[1]

View File

@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
describe('AppSidebar header styles', () => {
it('does not clip the version badge dropdown', () => {
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/)
expect(sidebarHeaderBlockMatch).not.toBeNull()
expect(sidebarHeaderBlockMatch?.[0]).not.toContain('@apply overflow-hidden;')

View File

@@ -3195,6 +3195,8 @@ export default {
codesExported: 'Codes exported successfully',
codeDeleted: 'Redeem code deleted successfully',
codesDeleted: 'Successfully deleted {count} unused code(s)',
codesDeletedPartial: 'Deleted {deleted} unused code(s); {failed} could not be deleted',
codesDeleteSkipped: 'No codes were deleted; {failed} code(s) could not be deleted',
noUnusedCodes: 'No unused codes to delete',
failedToLoad: 'Failed to load redeem codes',
failedToGenerate: 'Failed to generate codes',

View File

@@ -3326,6 +3326,8 @@ export default {
codesExported: '兑换码导出成功',
codeDeleted: '兑换码删除成功',
codesDeleted: '成功删除 {count} 个未使用的兑换码',
codesDeletedPartial: '已删除 {deleted} 个未使用兑换码,还有 {failed} 个删除失败',
codesDeleteSkipped: '未能删除任何兑换码,共 {failed} 个删除失败',
noUnusedCodes: '没有未使用的兑换码可删除',
userPrefix: '用户 #{id}',
failedToExport: '导出兑换码失败',

View File

@@ -408,6 +408,7 @@ import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
import type { Column } from '@/components/common/types'
import { deleteUnusedRedeemCodes } from './redeemDeleteUnused'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -736,18 +737,20 @@ const confirmDelete = async () => {
const confirmDeleteUnused = async () => {
try {
// Get all unused codes and delete them
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
const unusedCodeIds = unusedCodesResponse.items.map((code) => code.id)
const result = await deleteUnusedRedeemCodes(adminAPI.redeem)
const deleted = result.deletedIds.length
const skipped = result.skipped.length
if (unusedCodeIds.length === 0) {
if (deleted === 0 && skipped === 0) {
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
showDeleteUnusedDialog.value = false
return
} else if (skipped === 0) {
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: deleted }))
} else if (deleted > 0) {
appStore.showWarning(t('admin.redeem.codesDeletedPartial', { deleted, failed: skipped }))
} else {
appStore.showInfo(t('admin.redeem.codesDeleteSkipped', { failed: skipped }))
}
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
showDeleteUnusedDialog.value = false
loadCodes()
} catch (error: any) {

View File

@@ -240,7 +240,7 @@ onMounted(loadAll)
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<Icon name="video" size="md" class="text-blue-600 dark:text-blue-400" />
<Icon name="chartBar" size="md" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
@@ -256,7 +256,7 @@ onMounted(loadAll)
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Icon name="activity" size="md" class="text-green-600 dark:text-green-400" />
<Icon name="refresh" size="md" class="text-green-600 dark:text-green-400" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">

View File

@@ -53,13 +53,6 @@ const IconStub = defineComponent({
template: '<span class="icon" />'
})
const BaseButtonStub = defineComponent({
name: 'BaseButton',
props: ['variant', 'size'],
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>'
})
function createMockSystemStats(overrides: Partial<SoraSystemStats> = {}): SoraSystemStats {
return {
total_users: 10,

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import type { PaginatedResponse, RedeemCode } from '@/types'
import { deleteUnusedRedeemCodes } from '../redeemDeleteUnused'
function createCode(id: number): RedeemCode {
return {
id,
code: `CODE-${id}`,
type: 'balance',
value: 10,
status: 'unused',
used_by: undefined,
used_at: '',
created_at: '2026-01-01T00:00:00Z'
} as RedeemCode
}
function paginated(items: RedeemCode[]): PaginatedResponse<RedeemCode> {
return {
items,
total: items.length,
page: 1,
page_size: 1000,
pages: 1
}
}
describe('deleteUnusedRedeemCodes', () => {
it('deletes all unused redeem codes across multiple pages', async () => {
const client = {
list: vi.fn()
.mockResolvedValueOnce(paginated([createCode(1), createCode(2)]))
.mockResolvedValueOnce(paginated([createCode(3)]))
.mockResolvedValueOnce(paginated([])),
batchDelete: vi.fn()
.mockResolvedValueOnce({ deleted_ids: [1, 2], skipped: [] })
.mockResolvedValueOnce({ deleted_ids: [3], skipped: [] })
}
const result = await deleteUnusedRedeemCodes(client)
expect(client.list).toHaveBeenCalledTimes(3)
expect(client.batchDelete).toHaveBeenCalledTimes(2)
expect(client.batchDelete).toHaveBeenNthCalledWith(1, [1, 2])
expect(client.batchDelete).toHaveBeenNthCalledWith(2, [3])
expect(result).toEqual({ deletedIds: [1, 2, 3], skipped: [] })
})
it('stops and returns partial results when a batch delete has skipped items', async () => {
const client = {
list: vi.fn().mockResolvedValueOnce(paginated([createCode(1), createCode(2)])),
batchDelete: vi.fn().mockResolvedValueOnce({
deleted_ids: [1],
skipped: [{ id: 2, reason: 'db error' }]
})
}
const result = await deleteUnusedRedeemCodes(client)
expect(client.list).toHaveBeenCalledTimes(1)
expect(client.batchDelete).toHaveBeenCalledWith([1, 2])
expect(result).toEqual({
deletedIds: [1],
skipped: [{ id: 2, reason: 'db error' }]
})
})
it('returns immediately when there are no unused redeem codes', async () => {
const client = {
list: vi.fn().mockResolvedValueOnce(paginated([])),
batchDelete: vi.fn()
}
const result = await deleteUnusedRedeemCodes(client)
expect(client.batchDelete).not.toHaveBeenCalled()
expect(result).toEqual({ deletedIds: [], skipped: [] })
})
})

View File

@@ -0,0 +1,46 @@
import type { PaginatedResponse, RedeemCode } from '@/types'
import type { RedeemBatchDeleteResult } from '@/api/admin/redeem'
type RedeemListClient = (
page?: number,
pageSize?: number,
filters?: {
status?: 'active' | 'used' | 'expired' | 'unused'
}
) => Promise<PaginatedResponse<RedeemCode>>
type RedeemBatchDeleteClient = (ids: number[]) => Promise<RedeemBatchDeleteResult>
export interface DeleteUnusedRedeemCodesClient {
list: RedeemListClient
batchDelete: RedeemBatchDeleteClient
}
export interface DeleteUnusedRedeemCodesResult {
deletedIds: number[]
skipped: RedeemBatchDeleteResult['skipped']
}
export async function deleteUnusedRedeemCodes(
client: DeleteUnusedRedeemCodesClient,
pageSize: number = 1000
): Promise<DeleteUnusedRedeemCodesResult> {
const deletedIds: number[] = []
const skipped: RedeemBatchDeleteResult['skipped'] = []
for (;;) {
const response = await client.list(1, pageSize, { status: 'unused' })
const ids = response.items.map((code) => code.id)
if (ids.length === 0) {
return { deletedIds, skipped }
}
const result = await client.batchDelete(ids)
deletedIds.push(...result.deleted_ids)
skipped.push(...result.skipped)
if (result.skipped.length > 0) {
return { deletedIds, skipped }
}
}
}