feat: Implement direct upload functionality for desktop client releases
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
- Added new endpoints for initiating and completing direct uploads of desktop client packages. - Introduced request and response structures for direct upload operations. - Enhanced the upload process to support SHA256 and Content-MD5 validation. - Updated the frontend to reflect changes in upload button states and progress indicators. - Modified object storage clients to support presigned PUT URLs with content type and MD5 headers. - Added tests for direct upload functionality and ensured existing upload processes remain intact.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@geo/desktop-client",
|
"name": "@geo/desktop-client",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { createMD5, createSHA256 } from 'hash-wasm'
|
||||||
|
|
||||||
import { http } from '@/lib/http'
|
import { http } from '@/lib/http'
|
||||||
|
|
||||||
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
|
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
|
||||||
@@ -80,17 +83,27 @@ const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
|
|||||||
// complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s
|
// complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s
|
||||||
const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000
|
const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024
|
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024
|
||||||
|
const DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES = 8 * 1024 * 1024
|
||||||
const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
|
const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
|
||||||
|
|
||||||
export interface DesktopClientReleaseUploadProgress {
|
export interface DesktopClientReleaseUploadProgress {
|
||||||
loaded: number
|
loaded: number
|
||||||
total?: number
|
total?: number
|
||||||
percent?: number
|
percent?: number
|
||||||
stage?: 'uploading' | 'complete'
|
stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete'
|
||||||
chunkIndex?: number
|
chunkIndex?: number
|
||||||
chunkCount?: number
|
chunkCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopClientReleaseUploadTarget {
|
||||||
|
platform: string
|
||||||
|
arch: string
|
||||||
|
channel: string
|
||||||
|
version: string
|
||||||
|
kind?: 'installer' | 'updater'
|
||||||
|
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||||
|
}
|
||||||
|
|
||||||
interface DesktopClientReleaseUploadSession {
|
interface DesktopClientReleaseUploadSession {
|
||||||
upload_id: string
|
upload_id: string
|
||||||
chunk_size_bytes: number
|
chunk_size_bytes: number
|
||||||
@@ -99,6 +112,23 @@ interface DesktopClientReleaseUploadSession {
|
|||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DesktopClientReleaseDirectUploadSession {
|
||||||
|
upload_url?: string
|
||||||
|
method: 'PUT'
|
||||||
|
headers: Record<string, string>
|
||||||
|
oss_object_key: string
|
||||||
|
file_name: string
|
||||||
|
file_size_bytes: number
|
||||||
|
sha256: string
|
||||||
|
object_exists: boolean
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopClientReleaseFileHashes {
|
||||||
|
sha256: string
|
||||||
|
contentMD5: string
|
||||||
|
}
|
||||||
|
|
||||||
export const platformOptions = [
|
export const platformOptions = [
|
||||||
{ label: 'macOS', value: 'darwin' },
|
{ label: 'macOS', value: 'darwin' },
|
||||||
{ label: 'Windows', value: 'win32' },
|
{ label: 'Windows', value: 'win32' },
|
||||||
@@ -143,27 +173,13 @@ export const desktopClientReleaseApi = {
|
|||||||
},
|
},
|
||||||
async uploadOSS(
|
async uploadOSS(
|
||||||
file: File,
|
file: File,
|
||||||
target: {
|
target: DesktopClientReleaseUploadTarget,
|
||||||
platform: string
|
|
||||||
arch: string
|
|
||||||
channel: string
|
|
||||||
version: string
|
|
||||||
kind?: 'installer' | 'updater'
|
|
||||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
return uploadOSSInChunks(file, target)
|
return uploadOSSDirect(file, target)
|
||||||
},
|
},
|
||||||
async uploadOSSLegacy(
|
async uploadOSSLegacy(
|
||||||
file: File,
|
file: File,
|
||||||
target: {
|
target: DesktopClientReleaseUploadTarget,
|
||||||
platform: string
|
|
||||||
arch: string
|
|
||||||
channel: string
|
|
||||||
version: string
|
|
||||||
kind?: 'installer' | 'updater'
|
|
||||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
@@ -200,16 +216,119 @@ export const desktopClientReleaseApi = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadOSSDirect(
|
||||||
|
file: File,
|
||||||
|
target: DesktopClientReleaseUploadTarget,
|
||||||
|
): Promise<DesktopClientReleaseUploadResult> {
|
||||||
|
const hashes = await hashFile(file, (loaded) => {
|
||||||
|
target.onProgress?.({
|
||||||
|
loaded,
|
||||||
|
total: file.size,
|
||||||
|
percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined,
|
||||||
|
stage: 'hashing',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
target.onProgress?.({
|
||||||
|
loaded: file.size,
|
||||||
|
total: file.size,
|
||||||
|
percent: 99,
|
||||||
|
stage: 'signing',
|
||||||
|
})
|
||||||
|
const session = await http.post<DesktopClientReleaseDirectUploadSession>(
|
||||||
|
'/desktop-client/releases/direct-upload',
|
||||||
|
{
|
||||||
|
platform: target.platform,
|
||||||
|
arch: target.arch,
|
||||||
|
channel: target.channel,
|
||||||
|
version: target.version,
|
||||||
|
kind: target.kind ?? 'installer',
|
||||||
|
file_name: file.name,
|
||||||
|
file_size_bytes: file.size,
|
||||||
|
sha256: hashes.sha256,
|
||||||
|
content_md5: hashes.contentMD5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!session.object_exists) {
|
||||||
|
if (!session.upload_url) {
|
||||||
|
throw new Error('missing_direct_upload_url')
|
||||||
|
}
|
||||||
|
await axios.put(session.upload_url, file, {
|
||||||
|
headers: session.headers,
|
||||||
|
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
|
||||||
|
onUploadProgress(event) {
|
||||||
|
const total = event.total ?? file.size
|
||||||
|
target.onProgress?.({
|
||||||
|
loaded: event.loaded,
|
||||||
|
total,
|
||||||
|
percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined,
|
||||||
|
stage: 'uploading',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
target.onProgress?.({
|
||||||
|
loaded: file.size,
|
||||||
|
total: file.size,
|
||||||
|
percent: 100,
|
||||||
|
stage: 'verifying',
|
||||||
|
})
|
||||||
|
const result = await http.post<DesktopClientReleaseUploadResult>(
|
||||||
|
'/desktop-client/releases/direct-upload/complete',
|
||||||
|
{
|
||||||
|
platform: target.platform,
|
||||||
|
arch: target.arch,
|
||||||
|
channel: target.channel,
|
||||||
|
version: target.version,
|
||||||
|
kind: target.kind ?? 'installer',
|
||||||
|
oss_object_key: session.oss_object_key,
|
||||||
|
file_name: session.file_name,
|
||||||
|
file_size_bytes: session.file_size_bytes,
|
||||||
|
sha256: session.sha256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
target.onProgress?.({
|
||||||
|
loaded: result.file_size_bytes,
|
||||||
|
total: result.file_size_bytes,
|
||||||
|
percent: 100,
|
||||||
|
stage: 'complete',
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (loaded: number) => void,
|
||||||
|
): Promise<DesktopClientReleaseFileHashes> {
|
||||||
|
const [sha256Hasher, md5Hasher] = await Promise.all([createSHA256(), createMD5()])
|
||||||
|
let loaded = 0
|
||||||
|
while (loaded < file.size) {
|
||||||
|
const end = Math.min(loaded + DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES, file.size)
|
||||||
|
const chunk = new Uint8Array(await file.slice(loaded, end).arrayBuffer())
|
||||||
|
sha256Hasher.update(chunk)
|
||||||
|
md5Hasher.update(chunk)
|
||||||
|
loaded = end
|
||||||
|
onProgress?.(loaded)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sha256: sha256Hasher.digest('hex') as string,
|
||||||
|
contentMD5: uint8ArrayToBase64(md5Hasher.digest('binary') as Uint8Array),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToBase64(value: Uint8Array): string {
|
||||||
|
let binary = ''
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
binary += String.fromCharCode(value[index])
|
||||||
|
}
|
||||||
|
return window.btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadOSSInChunks(
|
async function uploadOSSInChunks(
|
||||||
file: File,
|
file: File,
|
||||||
target: {
|
target: DesktopClientReleaseUploadTarget,
|
||||||
platform: string
|
|
||||||
arch: string
|
|
||||||
channel: string
|
|
||||||
version: string
|
|
||||||
kind?: 'installer' | 'updater'
|
|
||||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
|
||||||
},
|
|
||||||
): Promise<DesktopClientReleaseUploadResult> {
|
): Promise<DesktopClientReleaseUploadResult> {
|
||||||
const session = await http.post<DesktopClientReleaseUploadSession>(
|
const session = await http.post<DesktopClientReleaseUploadSession>(
|
||||||
'/desktop-client/release-uploads',
|
'/desktop-client/release-uploads',
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz"
|
accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz"
|
||||||
>
|
>
|
||||||
<a-button :loading="uploading">
|
<a-button :loading="uploadButtonLoading('installer')" :disabled="uploading">
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon><UploadOutlined /></template>
|
||||||
{{ uploadButtonText('installer') }}
|
{{ uploadButtonText('installer') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
:show-upload-list="false"
|
:show-upload-list="false"
|
||||||
accept=".zip,.exe,.AppImage,.appimage"
|
accept=".zip,.exe,.AppImage,.appimage"
|
||||||
>
|
>
|
||||||
<a-button :loading="updaterUploading">
|
<a-button :loading="uploadButtonLoading('updater')" :disabled="updaterUploading">
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon><UploadOutlined /></template>
|
||||||
{{ uploadButtonText('updater') }}
|
{{ uploadButtonText('updater') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -352,6 +352,7 @@ import {
|
|||||||
platformLabel,
|
platformLabel,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
type DesktopClientRelease,
|
type DesktopClientRelease,
|
||||||
|
type DesktopClientReleaseUploadProgress,
|
||||||
type DesktopReleaseArch,
|
type DesktopReleaseArch,
|
||||||
type DesktopReleasePlatform,
|
type DesktopReleasePlatform,
|
||||||
type DesktopReleaseSource,
|
type DesktopReleaseSource,
|
||||||
@@ -400,6 +401,8 @@ const uploading = ref(false)
|
|||||||
const updaterUploading = ref(false)
|
const updaterUploading = ref(false)
|
||||||
const uploadProgress = ref<number | null>(null)
|
const uploadProgress = ref<number | null>(null)
|
||||||
const updaterUploadProgress = ref<number | null>(null)
|
const updaterUploadProgress = ref<number | null>(null)
|
||||||
|
const uploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||||
|
const updaterUploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||||
|
|
||||||
const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length)
|
const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length)
|
||||||
const forceRows = computed(() => rows.value.filter((row) => row.force_update).length)
|
const forceRows = computed(() => rows.value.filter((row) => row.force_update).length)
|
||||||
@@ -569,9 +572,11 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
if (kind === 'installer') {
|
if (kind === 'installer') {
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
uploadProgress.value = 0
|
uploadProgress.value = 0
|
||||||
|
uploadStage.value = 'hashing'
|
||||||
} else {
|
} else {
|
||||||
updaterUploading.value = true
|
updaterUploading.value = true
|
||||||
updaterUploadProgress.value = 0
|
updaterUploadProgress.value = 0
|
||||||
|
updaterUploadStage.value = 'hashing'
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await desktopClientReleaseApi.uploadOSS(file, {
|
const result = await desktopClientReleaseApi.uploadOSS(file, {
|
||||||
@@ -584,8 +589,10 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
if (typeof progress.percent !== 'number') return
|
if (typeof progress.percent !== 'number') return
|
||||||
if (kind === 'installer') {
|
if (kind === 'installer') {
|
||||||
uploadProgress.value = progress.percent
|
uploadProgress.value = progress.percent
|
||||||
|
uploadStage.value = progress.stage ?? null
|
||||||
} else {
|
} else {
|
||||||
updaterUploadProgress.value = progress.percent
|
updaterUploadProgress.value = progress.percent
|
||||||
|
updaterUploadStage.value = progress.stage ?? null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -611,6 +618,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
updaterUploading.value = false
|
updaterUploading.value = false
|
||||||
uploadProgress.value = null
|
uploadProgress.value = null
|
||||||
updaterUploadProgress.value = null
|
updaterUploadProgress.value = null
|
||||||
|
uploadStage.value = null
|
||||||
|
updaterUploadStage.value = null
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -618,20 +627,33 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
function uploadButtonText(kind: 'installer' | 'updater') {
|
function uploadButtonText(kind: 'installer' | 'updater') {
|
||||||
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
||||||
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
||||||
|
const stage = kind === 'installer' ? uploadStage.value : updaterUploadStage.value
|
||||||
const hasUploaded =
|
const hasUploaded =
|
||||||
kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
|
kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
|
||||||
if (isUploading && typeof progress === 'number' && progress < 100) {
|
if (isUploading && stage === 'hashing' && typeof progress === 'number') {
|
||||||
|
return `计算 SHA256 ${progress}%`
|
||||||
|
}
|
||||||
|
if (isUploading && stage === 'signing') {
|
||||||
|
return '获取直传地址...'
|
||||||
|
}
|
||||||
|
if (isUploading && stage === 'uploading' && typeof progress === 'number' && progress < 100) {
|
||||||
return `上传中 ${progress}%`
|
return `上传中 ${progress}%`
|
||||||
}
|
}
|
||||||
if (isUploading && progress === 100) {
|
if (isUploading && (stage === 'verifying' || progress === 100)) {
|
||||||
return '校验并写入 OSS...'
|
return '确认上传...'
|
||||||
}
|
}
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return '写入 OSS...'
|
return '准备上传...'
|
||||||
}
|
}
|
||||||
return hasUploaded ? '重新上传' : '选择文件并上传'
|
return hasUploaded ? '重新上传' : '选择文件并上传'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadButtonLoading(kind: 'installer' | 'updater') {
|
||||||
|
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
||||||
|
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
||||||
|
return isUploading && progress !== 100
|
||||||
|
}
|
||||||
|
|
||||||
function beforeUploadInstallerPackage(file: File) {
|
function beforeUploadInstallerPackage(file: File) {
|
||||||
return beforeUploadReleasePackage(file, 'installer')
|
return beforeUploadReleasePackage(file, 'installer')
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
@@ -284,6 +284,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.20
|
specifier: ^1.11.20
|
||||||
version: 1.11.20
|
version: 1.11.20
|
||||||
|
hash-wasm:
|
||||||
|
specifier: ^4.12.0
|
||||||
|
version: 4.12.0
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3))
|
||||||
@@ -3054,6 +3057,9 @@ packages:
|
|||||||
has-unicode@2.0.1:
|
has-unicode@2.0.1:
|
||||||
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
|
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
|
||||||
|
|
||||||
|
hash-wasm@4.12.0:
|
||||||
|
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
|
||||||
|
|
||||||
hash.js@1.1.7:
|
hash.js@1.1.7:
|
||||||
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||||
|
|
||||||
@@ -8626,6 +8632,8 @@ snapshots:
|
|||||||
|
|
||||||
has-unicode@2.0.1: {}
|
has-unicode@2.0.1: {}
|
||||||
|
|
||||||
|
hash-wasm@4.12.0: {}
|
||||||
|
|
||||||
hash.js@1.1.7:
|
hash.js@1.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -54,7 +55,9 @@ const (
|
|||||||
DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024
|
DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024
|
||||||
MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024
|
MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024
|
||||||
MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024
|
MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024
|
||||||
|
desktopClientReleaseContentMD5Bytes = 16
|
||||||
desktopClientReleaseUploadSessionTTL = 24 * time.Hour
|
desktopClientReleaseUploadSessionTTL = 24 * time.Hour
|
||||||
|
desktopClientReleaseDirectUploadTTL = 2 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type DesktopClientReleaseService struct {
|
type DesktopClientReleaseService struct {
|
||||||
@@ -231,6 +234,42 @@ type DesktopClientReleaseChunkUploadResult struct {
|
|||||||
UploadedChunks []int `json:"uploaded_chunks"`
|
UploadedChunks []int `json:"uploaded_chunks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopClientReleaseDirectUploadInitInput struct {
|
||||||
|
Platform string
|
||||||
|
Arch string
|
||||||
|
Channel string
|
||||||
|
Version string
|
||||||
|
Kind string
|
||||||
|
FileName string
|
||||||
|
FileSizeBytes int64
|
||||||
|
SHA256 string
|
||||||
|
ContentMD5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopClientReleaseDirectUploadInitResult struct {
|
||||||
|
UploadURL string `json:"upload_url,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
OSSObjectKey string `json:"oss_object_key"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileSizeBytes int64 `json:"file_size_bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ObjectExists bool `json:"object_exists"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DesktopClientReleaseDirectUploadCompleteInput struct {
|
||||||
|
Platform string
|
||||||
|
Arch string
|
||||||
|
Channel string
|
||||||
|
Version string
|
||||||
|
Kind string
|
||||||
|
OSSObjectKey string
|
||||||
|
FileName string
|
||||||
|
FileSizeBytes int64
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
type desktopClientReleaseUploadManifest struct {
|
type desktopClientReleaseUploadManifest struct {
|
||||||
UploadID string `json:"upload_id"`
|
UploadID string `json:"upload_id"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
@@ -529,6 +568,171 @@ func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopC
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DesktopClientReleaseService) InitiateDirectUpload(
|
||||||
|
ctx context.Context,
|
||||||
|
in DesktopClientReleaseDirectUploadInitInput,
|
||||||
|
) (*DesktopClientReleaseDirectUploadInitResult, error) {
|
||||||
|
if s.storage == nil {
|
||||||
|
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||||
|
}
|
||||||
|
if err := s.storage.Validate(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadInput := DesktopClientReleaseUploadInput{
|
||||||
|
Platform: in.Platform,
|
||||||
|
Arch: in.Arch,
|
||||||
|
Channel: in.Channel,
|
||||||
|
Version: in.Version,
|
||||||
|
Kind: in.Kind,
|
||||||
|
FileName: in.FileName,
|
||||||
|
ContentSizeBytes: in.FileSizeBytes,
|
||||||
|
}
|
||||||
|
target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kind := normalizeDesktopClientAssetKind(in.Kind)
|
||||||
|
if kind == DesktopClientAssetKindUpdater {
|
||||||
|
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentMD5, err := normalizeDesktopReleaseContentMD5(in.ContentMD5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
|
||||||
|
exists, err := s.storage.Exists(ctx, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
||||||
|
appErr.Cause = err
|
||||||
|
return nil, appErr
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return &DesktopClientReleaseDirectUploadInitResult{
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Headers: map[string]string{},
|
||||||
|
OSSObjectKey: objectKey,
|
||||||
|
FileName: fileName,
|
||||||
|
FileSizeBytes: in.FileSizeBytes,
|
||||||
|
SHA256: sha256Hex,
|
||||||
|
ObjectExists: true,
|
||||||
|
ExpiresAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
presigner, ok := s.storage.(objectstorage.PresignedPutClient)
|
||||||
|
if !ok {
|
||||||
|
return nil, response.ErrInternal(50091, "object_storage_direct_upload_unsupported", "object storage direct upload is unsupported")
|
||||||
|
}
|
||||||
|
presigned, err := presigner.PresignedPutURL(
|
||||||
|
ctx,
|
||||||
|
objectKey,
|
||||||
|
detectDesktopReleaseContentType(fileName, nil),
|
||||||
|
contentMD5,
|
||||||
|
desktopClientReleaseDirectUploadTTL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
||||||
|
appErr.Cause = err
|
||||||
|
return nil, appErr
|
||||||
|
}
|
||||||
|
return &DesktopClientReleaseDirectUploadInitResult{
|
||||||
|
UploadURL: presigned.URL,
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Headers: flattenHTTPHeaders(presigned.Headers),
|
||||||
|
OSSObjectKey: objectKey,
|
||||||
|
FileName: fileName,
|
||||||
|
FileSizeBytes: in.FileSizeBytes,
|
||||||
|
SHA256: sha256Hex,
|
||||||
|
ObjectExists: false,
|
||||||
|
ExpiresAt: presigned.ExpiresAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DesktopClientReleaseService) CompleteDirectUpload(
|
||||||
|
ctx context.Context,
|
||||||
|
in DesktopClientReleaseDirectUploadCompleteInput,
|
||||||
|
) (*DesktopClientReleaseUploadResult, error) {
|
||||||
|
if s.storage == nil {
|
||||||
|
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||||
|
}
|
||||||
|
if err := s.storage.Validate(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadInput := DesktopClientReleaseUploadInput{
|
||||||
|
Platform: in.Platform,
|
||||||
|
Arch: in.Arch,
|
||||||
|
Channel: in.Channel,
|
||||||
|
Version: in.Version,
|
||||||
|
Kind: in.Kind,
|
||||||
|
FileName: in.FileName,
|
||||||
|
ContentSizeBytes: in.FileSizeBytes,
|
||||||
|
}
|
||||||
|
target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kind := normalizeDesktopClientAssetKind(in.Kind)
|
||||||
|
if kind == DesktopClientAssetKindUpdater {
|
||||||
|
if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
objectKey := strings.TrimSpace(in.OSSObjectKey)
|
||||||
|
expectedObjectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName)
|
||||||
|
if objectKey == "" {
|
||||||
|
objectKey = expectedObjectKey
|
||||||
|
}
|
||||||
|
if objectKey != expectedObjectKey || !isValidDesktopReleaseObjectKey(objectKey) {
|
||||||
|
return nil, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 与上传文件校验信息不匹配")
|
||||||
|
}
|
||||||
|
|
||||||
|
if management, ok := s.storage.(objectstorage.ManagementClient); ok {
|
||||||
|
info, err := management.Stat(ctx, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, objectstorage.ErrObjectNotFound) {
|
||||||
|
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
|
||||||
|
}
|
||||||
|
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
||||||
|
appErr.Cause = err
|
||||||
|
return nil, appErr
|
||||||
|
}
|
||||||
|
if info.Size != in.FileSizeBytes {
|
||||||
|
return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exists, err := s.storage.Exists(ctx, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
||||||
|
appErr.Cause = err
|
||||||
|
return nil, appErr
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DesktopClientReleaseUploadResult{
|
||||||
|
OSSObjectKey: objectKey,
|
||||||
|
FileName: fileName,
|
||||||
|
FileSizeBytes: in.FileSizeBytes,
|
||||||
|
SHA256: sha256Hex,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func MaxDesktopClientReleaseMultipartBytes() int64 {
|
func MaxDesktopClientReleaseMultipartBytes() int64 {
|
||||||
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
|
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
|
||||||
}
|
}
|
||||||
@@ -1399,6 +1603,29 @@ func normalizeDesktopReleaseSHA256(value *string) (*string, error) {
|
|||||||
return &normalized, nil
|
return &normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRequiredDesktopReleaseSHA256(value string) (string, error) {
|
||||||
|
sha256, err := normalizeDesktopReleaseSHA256(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if sha256 == nil {
|
||||||
|
return "", response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制")
|
||||||
|
}
|
||||||
|
return *sha256, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDesktopReleaseContentMD5(value string) (string, error) {
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
if normalized == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
content, err := base64.StdEncoding.DecodeString(normalized)
|
||||||
|
if err != nil || len(content) != desktopClientReleaseContentMD5Bytes {
|
||||||
|
return "", response.ErrBadRequest(40080, "invalid_content_md5", "Content-MD5 必须是标准 base64 编码的 16 字节 MD5")
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) {
|
func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) {
|
||||||
size, err := desktopClientReleaseUploadSize(in)
|
size, err := desktopClientReleaseUploadSize(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1520,6 +1747,25 @@ func putDesktopClientReleaseReader(
|
|||||||
return storage.PutBytes(ctx, objectKey, content, contentType)
|
return storage.PutBytes(ctx, objectKey, content, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func flattenHTTPHeaders(headers http.Header) map[string]string {
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(headers))
|
||||||
|
for key, values := range headers {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" || len(values) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(values[0])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func defaultDesktopClientReleaseUploadDir() string {
|
func defaultDesktopClientReleaseUploadDir() string {
|
||||||
return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads")
|
return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -311,11 +313,92 @@ func TestDesktopClientReleaseChunkedUploadCompletesOutOfOrder(t *testing.T) {
|
|||||||
require.True(t, errors.Is(err, os.ErrNotExist))
|
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDesktopClientReleaseDirectUploadUsesPresignedPutURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content := []byte("release-package")
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
wantSHA := hex.EncodeToString(sum[:])
|
||||||
|
storage := &desktopReleaseDownloadURLStorage{
|
||||||
|
existsByKey: map[string]bool{},
|
||||||
|
objects: map[string][]byte{},
|
||||||
|
objectSizes: map[string]int64{},
|
||||||
|
}
|
||||||
|
svc := NewDesktopClientReleaseService(nil, storage, nil)
|
||||||
|
|
||||||
|
initResult, err := svc.InitiateDirectUpload(context.Background(), DesktopClientReleaseDirectUploadInitInput{
|
||||||
|
Platform: "darwin",
|
||||||
|
Arch: "arm64",
|
||||||
|
Channel: "stable",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Kind: DesktopClientAssetKindInstaller,
|
||||||
|
FileName: "client.dmg",
|
||||||
|
FileSizeBytes: int64(len(content)),
|
||||||
|
SHA256: wantSHA,
|
||||||
|
ContentMD5: "pt74NOtqjIwBJtPUYvJ8AA==",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, initResult.ObjectExists)
|
||||||
|
require.Equal(t, http.MethodPut, initResult.Method)
|
||||||
|
require.Equal(t, "desktop-client/releases/blobs/"+wantSHA+".dmg", initResult.OSSObjectKey)
|
||||||
|
require.Equal(t, "https://oss.example.test/upload/"+initResult.OSSObjectKey, initResult.UploadURL)
|
||||||
|
require.Equal(t, "application/x-apple-diskimage", initResult.Headers["Content-Type"])
|
||||||
|
require.Equal(t, "pt74NOtqjIwBJtPUYvJ8AA==", initResult.Headers["Content-Md5"])
|
||||||
|
require.Len(t, storage.presignedPutCalls, 1)
|
||||||
|
require.Equal(t, initResult.OSSObjectKey, storage.presignedPutCalls[0])
|
||||||
|
|
||||||
|
storage.existsByKey[initResult.OSSObjectKey] = true
|
||||||
|
storage.objectSizes[initResult.OSSObjectKey] = int64(len(content))
|
||||||
|
completeResult, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
|
||||||
|
Platform: "darwin",
|
||||||
|
Arch: "arm64",
|
||||||
|
Channel: "stable",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Kind: DesktopClientAssetKindInstaller,
|
||||||
|
OSSObjectKey: initResult.OSSObjectKey,
|
||||||
|
FileName: initResult.FileName,
|
||||||
|
FileSizeBytes: initResult.FileSizeBytes,
|
||||||
|
SHA256: initResult.SHA256,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, initResult.OSSObjectKey, completeResult.OSSObjectKey)
|
||||||
|
require.Equal(t, wantSHA, completeResult.SHA256)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopClientReleaseDirectUploadRejectsSizeMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte("release-package"))
|
||||||
|
sha256Hex := hex.EncodeToString(sum[:])
|
||||||
|
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, "client.zip")
|
||||||
|
storage := &desktopReleaseDownloadURLStorage{
|
||||||
|
existsByKey: map[string]bool{objectKey: true},
|
||||||
|
objectSizes: map[string]int64{objectKey: 10},
|
||||||
|
}
|
||||||
|
svc := NewDesktopClientReleaseService(nil, storage, nil)
|
||||||
|
|
||||||
|
_, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
|
||||||
|
Platform: "darwin",
|
||||||
|
Arch: "arm64",
|
||||||
|
Channel: "stable",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Kind: DesktopClientAssetKindUpdater,
|
||||||
|
OSSObjectKey: objectKey,
|
||||||
|
FileName: "client.zip",
|
||||||
|
FileSizeBytes: 20,
|
||||||
|
SHA256: sha256Hex,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "direct_upload_size_mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
type desktopReleaseDownloadURLStorage struct {
|
type desktopReleaseDownloadURLStorage struct {
|
||||||
publicURLCalls []string
|
publicURLCalls []string
|
||||||
downloadURLCalls []string
|
downloadURLCalls []string
|
||||||
existsByKey map[string]bool
|
existsByKey map[string]bool
|
||||||
objects map[string][]byte
|
objects map[string][]byte
|
||||||
|
objectSizes map[string]int64
|
||||||
|
presignedPutCalls []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *desktopReleaseDownloadURLStorage) Validate() error {
|
func (s *desktopReleaseDownloadURLStorage) Validate() error {
|
||||||
@@ -335,6 +418,10 @@ func (s *desktopReleaseDownloadURLStorage) PutReader(_ context.Context, objectKe
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.objects[objectKey] = content
|
s.objects[objectKey] = content
|
||||||
|
if s.objectSizes == nil {
|
||||||
|
s.objectSizes = map[string]int64{}
|
||||||
|
}
|
||||||
|
s.objectSizes[objectKey] = int64(len(content))
|
||||||
if s.existsByKey == nil {
|
if s.existsByKey == nil {
|
||||||
s.existsByKey = map[string]bool{}
|
s.existsByKey = map[string]bool{}
|
||||||
}
|
}
|
||||||
@@ -353,6 +440,26 @@ func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey s
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *desktopReleaseDownloadURLStorage) PresignedPutURL(
|
||||||
|
_ context.Context,
|
||||||
|
objectKey string,
|
||||||
|
contentType string,
|
||||||
|
contentMD5Base64 string,
|
||||||
|
ttl time.Duration,
|
||||||
|
) (*objectstorage.PresignedPutResult, error) {
|
||||||
|
s.presignedPutCalls = append(s.presignedPutCalls, objectKey)
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("Content-Type", contentType)
|
||||||
|
if contentMD5Base64 != "" {
|
||||||
|
headers.Set("Content-MD5", contentMD5Base64)
|
||||||
|
}
|
||||||
|
return &objectstorage.PresignedPutResult{
|
||||||
|
URL: "https://oss.example.test/upload/" + objectKey,
|
||||||
|
Headers: headers,
|
||||||
|
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error {
|
func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -366,3 +473,25 @@ func (s *desktopReleaseDownloadURLStorage) DownloadURL(objectKey string) (string
|
|||||||
s.downloadURLCalls = append(s.downloadURLCalls, objectKey)
|
s.downloadURLCalls = append(s.downloadURLCalls, objectKey)
|
||||||
return "https://oss.example.test/signed/client.zip?Expires=123", nil
|
return "https://oss.example.test/signed/client.zip?Expires=123", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *desktopReleaseDownloadURLStorage) List(context.Context, objectstorage.ListInput) (*objectstorage.ListResult, error) {
|
||||||
|
return &objectstorage.ListResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *desktopReleaseDownloadURLStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) {
|
||||||
|
exists, _ := s.Exists(context.Background(), objectKey)
|
||||||
|
if !exists {
|
||||||
|
return nil, objectstorage.ErrObjectNotFound
|
||||||
|
}
|
||||||
|
if s.objectSizes != nil {
|
||||||
|
return &objectstorage.ObjectInfo{Key: objectKey, Size: s.objectSizes[objectKey], LastModified: time.Now()}, nil
|
||||||
|
}
|
||||||
|
if s.objects != nil {
|
||||||
|
return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(s.objects[objectKey])), LastModified: time.Now()}, nil
|
||||||
|
}
|
||||||
|
return &objectstorage.ObjectInfo{Key: objectKey, LastModified: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *desktopReleaseDownloadURLStorage) Copy(context.Context, string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ type desktopClientReleaseUploadInitRequest struct {
|
|||||||
ChunkSizeBytes int64 `json:"chunk_size_bytes"`
|
ChunkSizeBytes int64 `json:"chunk_size_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type desktopClientReleaseDirectUploadInitRequest struct {
|
||||||
|
Platform string `json:"platform" binding:"required"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Version string `json:"version" binding:"required"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
FileName string `json:"file_name" binding:"required"`
|
||||||
|
FileSizeBytes int64 `json:"file_size_bytes" binding:"required"`
|
||||||
|
SHA256 string `json:"sha256" binding:"required"`
|
||||||
|
ContentMD5 string `json:"content_md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type desktopClientReleaseDirectUploadCompleteRequest struct {
|
||||||
|
Platform string `json:"platform" binding:"required"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Version string `json:"version" binding:"required"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
OSSObjectKey string `json:"oss_object_key" binding:"required"`
|
||||||
|
FileName string `json:"file_name" binding:"required"`
|
||||||
|
FileSizeBytes int64 `json:"file_size_bytes" binding:"required"`
|
||||||
|
SHA256 string `json:"sha256" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
@@ -208,6 +232,58 @@ func uploadDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initiateDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body desktopClientReleaseDirectUploadInitRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.InitiateDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadInitInput{
|
||||||
|
Platform: body.Platform,
|
||||||
|
Arch: body.Arch,
|
||||||
|
Channel: body.Channel,
|
||||||
|
Version: body.Version,
|
||||||
|
Kind: body.Kind,
|
||||||
|
FileName: body.FileName,
|
||||||
|
FileSizeBytes: body.FileSizeBytes,
|
||||||
|
SHA256: body.SHA256,
|
||||||
|
ContentMD5: body.ContentMD5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body desktopClientReleaseDirectUploadCompleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.CompleteDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadCompleteInput{
|
||||||
|
Platform: body.Platform,
|
||||||
|
Arch: body.Arch,
|
||||||
|
Channel: body.Channel,
|
||||||
|
Version: body.Version,
|
||||||
|
Kind: body.Kind,
|
||||||
|
OSSObjectKey: body.OSSObjectKey,
|
||||||
|
FileName: body.FileName,
|
||||||
|
FileSizeBytes: body.FileSizeBytes,
|
||||||
|
SHA256: body.SHA256,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body desktopClientReleaseUploadInitRequest
|
var body desktopClientReleaseUploadInitRequest
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ func RegisterRoutes(d Deps) {
|
|||||||
|
|
||||||
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
|
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
|
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
|
||||||
|
authed.POST("/desktop-client/releases/direct-upload", initiateDesktopClientReleaseDirectUploadHandler(d.Releases))
|
||||||
|
authed.POST("/desktop-client/releases/direct-upload/complete", completeDesktopClientReleaseDirectUploadHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/release-uploads", initiateDesktopClientReleaseUploadHandler(d.Releases))
|
authed.POST("/desktop-client/release-uploads", initiateDesktopClientReleaseUploadHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/release-uploads/:upload_id/chunks/:chunk_index", uploadDesktopClientReleaseChunkHandler(d.Releases))
|
authed.POST("/desktop-client/release-uploads/:upload_id/chunks/:chunk_index", uploadDesktopClientReleaseChunkHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/release-uploads/:upload_id/complete", completeDesktopClientReleaseUploadHandler(d.Releases))
|
authed.POST("/desktop-client/release-uploads/:upload_id/complete", completeDesktopClientReleaseUploadHandler(d.Releases))
|
||||||
|
|||||||
@@ -154,19 +154,41 @@ func (c *aliyunClient) bucketExists(ctx context.Context) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
|
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) {
|
||||||
|
options, _, err := aliyunPutObjectOptionsAndHeaders(cfg, contentType, "")
|
||||||
|
return options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func aliyunPutObjectOptionsAndHeaders(
|
||||||
|
cfg config.ObjectStorageConfig,
|
||||||
|
contentType string,
|
||||||
|
contentMD5Base64 string,
|
||||||
|
) ([]oss.Option, http.Header, error) {
|
||||||
|
contentType = strings.TrimSpace(contentType)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
options := []oss.Option{oss.ContentType(contentType)}
|
options := []oss.Option{oss.ContentType(contentType)}
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set(oss.HTTPHeaderContentType, contentType)
|
||||||
|
if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" {
|
||||||
|
options = append(options, oss.ContentMD5(contentMD5Base64))
|
||||||
|
headers.Set(oss.HTTPHeaderContentMD5, contentMD5Base64)
|
||||||
|
}
|
||||||
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
|
acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
|
||||||
switch acl {
|
switch acl {
|
||||||
case "", "default":
|
case "", "default":
|
||||||
return options, nil
|
return options, headers, nil
|
||||||
case "private":
|
case "private":
|
||||||
return append(options, oss.ObjectACL(oss.ACLPrivate)), nil
|
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPrivate))
|
||||||
|
return append(options, oss.ObjectACL(oss.ACLPrivate)), headers, nil
|
||||||
case "public-read":
|
case "public-read":
|
||||||
return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil
|
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicRead))
|
||||||
|
return append(options, oss.ObjectACL(oss.ACLPublicRead)), headers, nil
|
||||||
case "public-read-write":
|
case "public-read-write":
|
||||||
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil
|
headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicReadWrite))
|
||||||
|
return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), headers, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
|
return nil, nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +363,52 @@ func (c *aliyunClient) DownloadURL(objectKey string) (string, error) {
|
|||||||
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
|
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *aliyunClient) PresignedPutURL(
|
||||||
|
ctx context.Context,
|
||||||
|
objectKey string,
|
||||||
|
contentType string,
|
||||||
|
contentMD5Base64 string,
|
||||||
|
ttl time.Duration,
|
||||||
|
) (*PresignedPutResult, error) {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
objectKey = normalizeObjectKeyPath(objectKey)
|
||||||
|
if objectKey == "" {
|
||||||
|
return nil, fmt.Errorf("object key is required")
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = c.cfg.SignedURLTTL
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 30 * time.Minute
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket, err := c.client.Bucket(c.bucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||||
|
}
|
||||||
|
options, headers, err := aliyunPutObjectOptionsAndHeaders(c.cfg, contentType, contentMD5Base64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signedURL, err := bucket.SignURL(objectKey, oss.HTTPPut, int64(ttl.Seconds()), options...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("presign aliyun put url: %w", err)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PresignedPutResult{
|
||||||
|
URL: signedURL,
|
||||||
|
Headers: headers,
|
||||||
|
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||||
if err := c.Validate(); err != nil {
|
if err := c.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,16 @@ type ReaderClient interface {
|
|||||||
PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error
|
PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PresignedPutResult struct {
|
||||||
|
URL string
|
||||||
|
Headers http.Header
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresignedPutClient interface {
|
||||||
|
PresignedPutURL(ctx context.Context, objectKey string, contentType string, contentMD5Base64 string, ttl time.Duration) (*PresignedPutResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
type ManagementClient interface {
|
type ManagementClient interface {
|
||||||
Client
|
Client
|
||||||
List(ctx context.Context, in ListInput) (*ListResult, error)
|
List(ctx context.Context, in ListInput) (*ListResult, error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -290,6 +291,51 @@ func (c *minioClient) DownloadURL(objectKey string) (string, error) {
|
|||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *minioClient) PresignedPutURL(
|
||||||
|
ctx context.Context,
|
||||||
|
objectKey string,
|
||||||
|
contentType string,
|
||||||
|
contentMD5Base64 string,
|
||||||
|
ttl time.Duration,
|
||||||
|
) (*PresignedPutResult, error) {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
objectKey = normalizeObjectKeyPath(objectKey)
|
||||||
|
if objectKey == "" {
|
||||||
|
return nil, fmt.Errorf("object key is required")
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = c.cfg.SignedURLTTL
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 30 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := http.Header{}
|
||||||
|
if contentType = strings.TrimSpace(contentType); contentType != "" {
|
||||||
|
headers.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" {
|
||||||
|
headers.Set("Content-MD5", contentMD5Base64)
|
||||||
|
}
|
||||||
|
var u *url.URL
|
||||||
|
var err error
|
||||||
|
if len(headers) > 0 {
|
||||||
|
u, err = c.client.PresignHeader(ctx, http.MethodPut, c.bucket, objectKey, ttl, nil, headers)
|
||||||
|
} else {
|
||||||
|
u, err = c.client.PresignedPutObject(ctx, c.bucket, objectKey, ttl)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("presign minio put url: %w", err)
|
||||||
|
}
|
||||||
|
return &PresignedPutResult{
|
||||||
|
URL: u.String(),
|
||||||
|
Headers: headers,
|
||||||
|
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||||
if err := c.Validate(); err != nil {
|
if err := c.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ var routeDocs = map[string]routeDoc{
|
|||||||
|
|
||||||
// --- Ops:桌面客户端版本 ---
|
// --- Ops:桌面客户端版本 ---
|
||||||
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"},
|
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"},
|
||||||
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传;新版前端使用分片上传。"},
|
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传。"},
|
||||||
|
"POST /api/ops/desktop-client/releases/direct-upload": {"初始化客户端包 OSS 直传", "校验文件信息并生成浏览器直传 OSS 的短期 PUT 签名 URL。"},
|
||||||
|
"POST /api/ops/desktop-client/releases/direct-upload/complete": {"完成客户端包 OSS 直传", "校验直传对象是否存在、大小是否匹配,并返回 Object Key、文件大小和 SHA256。"},
|
||||||
"POST /api/ops/desktop-client/release-uploads": {"初始化客户端包分片上传", "创建分片上传会话,返回 upload_id、分片大小和分片数量。"},
|
"POST /api/ops/desktop-client/release-uploads": {"初始化客户端包分片上传", "创建分片上传会话,返回 upload_id、分片大小和分片数量。"},
|
||||||
"POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"},
|
"POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"},
|
||||||
"POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"},
|
"POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"},
|
||||||
|
|||||||
@@ -153,6 +153,23 @@ type publishTaskListOwner struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publishTaskVisibleRecordFilterSQL = `
|
||||||
|
AND (
|
||||||
|
NOT (t.payload ? 'publish_record_id')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM publish_records pr
|
||||||
|
WHERE pr.tenant_id = t.tenant_id
|
||||||
|
AND pr.id = CASE
|
||||||
|
WHEN (t.payload->>'publish_record_id') ~ '^[0-9]+$'
|
||||||
|
THEN (t.payload->>'publish_record_id')::bigint
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
AND pr.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) {
|
func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||||
@@ -1225,6 +1242,24 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
|
|||||||
offset int,
|
offset int,
|
||||||
orderBy string,
|
orderBy string,
|
||||||
) ([]DesktopTaskView, error) {
|
) ([]DesktopTaskView, error) {
|
||||||
|
query, args := buildListPublishTasksByStatusesQuery(owner, statuses, title, limit, offset, orderBy)
|
||||||
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanDesktopTaskRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildListPublishTasksByStatusesQuery(
|
||||||
|
owner publishTaskListOwner,
|
||||||
|
statuses []string,
|
||||||
|
title string,
|
||||||
|
limit int,
|
||||||
|
offset int,
|
||||||
|
orderBy string,
|
||||||
|
) (string, []any) {
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
t.desktop_id,
|
t.desktop_id,
|
||||||
@@ -1263,7 +1298,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
|
|||||||
$5 = ''
|
$5 = ''
|
||||||
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
|
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
|
||||||
)
|
)
|
||||||
`
|
` + publishTaskVisibleRecordFilterSQL
|
||||||
args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
|
args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
|
||||||
|
|
||||||
if strings.TrimSpace(orderBy) != "" {
|
if strings.TrimSpace(orderBy) != "" {
|
||||||
@@ -1273,14 +1308,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
|
|||||||
query += "\nLIMIT $6 OFFSET $7"
|
query += "\nLIMIT $6 OFFSET $7"
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
}
|
}
|
||||||
|
return query, args
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanDesktopTaskRows(rows)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DesktopTaskService) countPublishTasksByStatuses(
|
func (s *DesktopTaskService) countPublishTasksByStatuses(
|
||||||
@@ -1290,7 +1318,20 @@ func (s *DesktopTaskService) countPublishTasksByStatuses(
|
|||||||
title string,
|
title string,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
err := s.pool.QueryRow(ctx, `
|
query, args := buildCountPublishTasksByStatusesQuery(owner, statuses, title)
|
||||||
|
err := s.pool.QueryRow(ctx, query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCountPublishTasksByStatusesQuery(
|
||||||
|
owner publishTaskListOwner,
|
||||||
|
statuses []string,
|
||||||
|
title string,
|
||||||
|
) (string, []any) {
|
||||||
|
query := `
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM desktop_tasks AS t
|
FROM desktop_tasks AS t
|
||||||
JOIN desktop_publish_jobs AS j
|
JOIN desktop_publish_jobs AS j
|
||||||
@@ -1306,11 +1347,8 @@ func (s *DesktopTaskService) countPublishTasksByStatuses(
|
|||||||
$5 = ''
|
$5 = ''
|
||||||
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
|
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
|
||||||
)
|
)
|
||||||
`, owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title).Scan(&count)
|
` + publishTaskVisibleRecordFilterSQL
|
||||||
if err != nil {
|
return query, []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
|
||||||
return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
|
||||||
|
|||||||
@@ -149,6 +149,60 @@ func TestIsComplianceInvalidArticleVersionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildListPublishTasksByStatusesQueryHidesDeletedPublishRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
query, args := buildListPublishTasksByStatusesQuery(
|
||||||
|
publishTaskListOwner{TenantID: 7, WorkspaceID: 11, UserID: 23},
|
||||||
|
[]string{"succeeded", "failed"},
|
||||||
|
"合肥",
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
"t.updated_at DESC",
|
||||||
|
)
|
||||||
|
normalized := normalizeSQLForDesktopTaskTest(query)
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"NOT (t.payload ? 'publish_record_id') OR EXISTS",
|
||||||
|
"FROM publish_records pr",
|
||||||
|
"pr.tenant_id = t.tenant_id",
|
||||||
|
"t.payload->>'publish_record_id')::bigint",
|
||||||
|
"pr.deleted_at IS NULL",
|
||||||
|
"LIMIT $6 OFFSET $7",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(normalized, fragment) {
|
||||||
|
t.Fatalf("list publish tasks query missing %q: %s", fragment, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) != 7 {
|
||||||
|
t.Fatalf("args len = %d, want 7", len(args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCountPublishTasksByStatusesQueryHidesDeletedPublishRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
query, args := buildCountPublishTasksByStatusesQuery(
|
||||||
|
publishTaskListOwner{TenantID: 7, WorkspaceID: 11, UserID: 23},
|
||||||
|
[]string{"succeeded", "failed"},
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
normalized := normalizeSQLForDesktopTaskTest(query)
|
||||||
|
|
||||||
|
for _, fragment := range []string{
|
||||||
|
"NOT (t.payload ? 'publish_record_id') OR EXISTS",
|
||||||
|
"FROM publish_records pr",
|
||||||
|
"pr.deleted_at IS NULL",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(normalized, fragment) {
|
||||||
|
t.Fatalf("count publish tasks query missing %q: %s", fragment, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) != 5 {
|
||||||
|
t.Fatalf("args len = %d, want 5", len(args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func recoverDesktopTaskSelectColumns(query string) []string {
|
func recoverDesktopTaskSelectColumns(query string) []string {
|
||||||
re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`)
|
re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`)
|
||||||
match := re.FindStringSubmatch(query)
|
match := re.FindStringSubmatch(query)
|
||||||
@@ -165,3 +219,7 @@ func recoverDesktopTaskSelectColumns(query string) []string {
|
|||||||
}
|
}
|
||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSQLForDesktopTaskTest(sql string) string {
|
||||||
|
return strings.Join(strings.Fields(sql), " ")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user