Files
appks/service/repo/release_assets.rs
zhenyi 420dedbc1e feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
2026-06-10 18:49:32 +08:00

206 lines
6.5 KiB
Rust

use crate::error::AppError;
use crate::models::repos::{RepoRelease, RepoReleaseAsset};
use crate::service::RepoService;
use crate::session::Session;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::util::clamp_limit_offset;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct ReleaseAssetData {
pub id: Uuid,
pub filename: String,
pub size_bytes: i64,
pub mime_type: String,
pub download_count: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<RepoReleaseAsset> for ReleaseAssetData {
fn from(a: RepoReleaseAsset) -> Self {
Self {
id: a.id,
filename: a.filename,
size_bytes: a.size_bytes,
mime_type: a.mime_type,
download_count: a.download_count,
created_at: a.created_at,
}
}
}
impl RepoService {
pub async fn repo_upload_release_asset(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
filename: &str,
data: Vec<u8>,
content_type: &str,
) -> Result<ReleaseAssetData, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.resolve_repo(wk_name, repo_name).await?;
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member)
.await?;
let release: Option<RepoRelease> = sqlx::query_as(
"SELECT * FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL",
)
.bind(release_id)
.bind(repo.id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if release.is_none() {
return Err(AppError::NotFound("release not found".into()));
}
let asset_id = Uuid::now_v7();
let storage_key = format!("repos/{}/releases/{}/{}", repo.id, release_id, asset_id);
let size = data.len() as i64;
self.ctx.storage.put(&storage_key, data).await?;
let now = chrono::Utc::now();
let asset = sqlx::query_as::<_, RepoReleaseAsset>(
"INSERT INTO repo_release_asset (id, release_id, filename, size_bytes, mime_type, storage_path, uploaded_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
RETURNING *",
)
.bind(asset_id)
.bind(release_id)
.bind(filename)
.bind(size)
.bind(content_type)
.bind(&storage_key)
.bind(user_uid)
.bind(now)
.fetch_one(self.ctx.db.writer())
.await
.map_err(|e| {
let storage_key = storage_key.clone();
let storage = self.ctx.storage.clone();
tokio::spawn(async move {
let _ = storage.delete(&storage_key).await;
});
AppError::Database(e)
})?;
Ok(ReleaseAssetData::from(asset))
}
pub async fn repo_list_release_assets(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<ReleaseAssetData>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.resolve_repo(wk_name, repo_name).await?;
self.ensure_repo_readable(user_uid, &repo).await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
let assets = sqlx::query_as::<_, RepoReleaseAsset>(
"SELECT * FROM repo_release_asset WHERE release_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3",
)
.bind(release_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
Ok(assets.into_iter().map(ReleaseAssetData::from).collect())
}
pub async fn repo_delete_release_asset(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
asset_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.resolve_repo(wk_name, repo_name).await?;
self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin)
.await?;
let asset: Option<RepoReleaseAsset> = sqlx::query_as(
"SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL",
)
.bind(asset_id)
.bind(release_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let Some(asset) = asset else {
return Err(AppError::NotFound("asset not found".into()));
};
let now = chrono::Utc::now();
sqlx::query("UPDATE repo_release_asset SET deleted_at = $1, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(asset_id)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
let _ = self.ctx.storage.delete(&asset.storage_path).await;
Ok(())
}
pub async fn repo_get_release_asset_download_url(
&self,
ctx: &Session,
wk_name: &str,
repo_name: &str,
release_id: Uuid,
asset_id: Uuid,
) -> Result<String, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let repo = self.resolve_repo(wk_name, repo_name).await?;
self.ensure_repo_readable(user_uid, &repo).await?;
let asset: Option<RepoReleaseAsset> = sqlx::query_as(
"SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL",
)
.bind(asset_id)
.bind(release_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let Some(asset) = asset else {
return Err(AppError::NotFound("asset not found".into()));
};
if let Some(url) = asset.url {
return Ok(url);
}
let url = self
.ctx
.storage
.presigned_get_url(&asset.storage_path, None)
.await?;
// Update download count and cache the URL
let _ = sqlx::query(
"UPDATE repo_release_asset SET download_count = download_count + 1, url = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&url)
.bind(asset_id)
.execute(self.ctx.db.writer())
.await;
Ok(url)
}
}