开发检查点:完善多项目客户端与管理能力
This commit is contained in:
parent
82bf06eca6
commit
026a756a53
@ -17,12 +17,8 @@ func main() {
|
||||
if server == "" {
|
||||
server = "http://127.0.0.1:8080"
|
||||
}
|
||||
api, err := client.New(server, client.WithSessionToken(os.Getenv("SCOUT_SESSION")))
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
manager, err := updater.New(updater.Config{
|
||||
project := os.Getenv("SCOUT_PROJECT") var api *client.Client var err error if project == "" || project == "default" { api, err = client.New(server, client.WithSessionToken(os.Getenv("SCOUT_SESSION"))) } else { api, err = client.NewProject(server, project, client.WithSessionToken(os.Getenv("SCOUT_SESSION"))) } if err != nil { fail(err) }
|
||||
manager, err := updater.New(updater.Config{
|
||||
Client: api,
|
||||
PackageName: "demo-core",
|
||||
CurrentVersion: "1.0.0",
|
||||
|
||||
@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
const defaultApi = import.meta.env.VITE_API_BASE || (['5173', '5174'].includes(window.location.port) ? 'http://127.0.0.1:8080' : window.location.origin)
|
||||
const sessionKey = 'scout-session'
|
||||
const projectKey = 'scout-project'
|
||||
|
||||
export function useHome() {
|
||||
const apiBase = ref(defaultApi)
|
||||
@ -12,6 +13,8 @@ export function useHome() {
|
||||
const authForm = ref({ email: '', name: '', password: '' })
|
||||
const resetForm = ref({ email: '', token: '', password: '' })
|
||||
const adminUsers = ref([])
|
||||
const projects = ref([])
|
||||
const selectedProject = ref(localStorage.getItem(projectKey) || 'default')
|
||||
const search = ref('')
|
||||
const filters = ref({ platform: '', architecture: '', channel: '', currentVersion: '' })
|
||||
const packages = ref([])
|
||||
@ -66,6 +69,36 @@ export function useHome() {
|
||||
return session.value ? { Authorization: `Bearer ${session.value}` } : {}
|
||||
}
|
||||
|
||||
function resourcePath(path) {
|
||||
if (selectedProject.value === 'default' || !path.startsWith('/api/v1')) return path
|
||||
return `/api/v1/projects/${encodeURIComponent(selectedProject.value)}${path.slice('/api/v1'.length)}`
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const payload = await request('/api/v1/projects')
|
||||
projects.value = payload.projects || []
|
||||
const selected = projects.value.find((item) => item.id === selectedProject.value)
|
||||
if (!selected || selected.status !== 'active') {
|
||||
selectedProject.value = 'default'
|
||||
localStorage.setItem(projectKey, selectedProject.value)
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
}
|
||||
}
|
||||
|
||||
async function changeProject() {
|
||||
const selected = projects.value.find((item) => item.id === selectedProject.value)
|
||||
if (!selected || selected.status !== 'active') {
|
||||
selectedProject.value = 'default'
|
||||
}
|
||||
localStorage.setItem(projectKey, selectedProject.value)
|
||||
selectedPackage.value = null
|
||||
versions.value = []
|
||||
await refreshPackages()
|
||||
}
|
||||
|
||||
async function readResponse(response) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const payload = contentType.includes('application/json')
|
||||
@ -94,7 +127,7 @@ export function useHome() {
|
||||
if (filters.value.channel) params.set('channel', filters.value.channel)
|
||||
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const payload = await request(`/api/v1/packages${query}`)
|
||||
const payload = await request(`${resourcePath('/api/v1/packages')}${query}`)
|
||||
packages.value = payload.packages || []
|
||||
if (selectedPackage.value) {
|
||||
const latest = packages.value.find((item) => item.name === selectedPackage.value.name)
|
||||
@ -156,7 +189,7 @@ export function useHome() {
|
||||
if (filters.value.channel) params.set('channel', filters.value.channel)
|
||||
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const payload = await request(`/api/v1/packages/${encodeURIComponent(item.name)}/versions${query}`)
|
||||
const payload = await request(`${resourcePath(`/api/v1/packages/${encodeURIComponent(item.name)}/versions`)}${query}`)
|
||||
versions.value = payload.versions || []
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
@ -194,7 +227,7 @@ export function useHome() {
|
||||
const headers = session.value
|
||||
? authHeaders()
|
||||
: { Authorization: `Bearer ${developerToken.value}` }
|
||||
const payload = await request('/api/v1/packages/upload', {
|
||||
const payload = await request(resourcePath('/api/v1/packages/upload'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
@ -329,7 +362,7 @@ export function useHome() {
|
||||
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
|
||||
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
return endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download${query}`)
|
||||
return endpoint(`${resourcePath(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download`)}${query}`)
|
||||
}
|
||||
|
||||
function startNotifications() {
|
||||
@ -343,7 +376,7 @@ export function useHome() {
|
||||
params.set('versions', `${selectedPackage.value.name}:${filters.value.currentVersion}`)
|
||||
}
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
eventSource = new EventSource(endpoint(`/api/v1/updates/stream${query}`))
|
||||
eventSource = new EventSource(endpoint(`${resourcePath('/api/v1/updates/stream')}${query}`))
|
||||
eventSource.addEventListener('package_updated', async (event) => {
|
||||
const payload = JSON.parse(event.data)
|
||||
message.value = `收到更新通知:${payload.package.name} ${payload.package.version}`
|
||||
@ -372,6 +405,8 @@ export function useHome() {
|
||||
authForm,
|
||||
resetForm,
|
||||
adminUsers,
|
||||
projects,
|
||||
selectedProject,
|
||||
search,
|
||||
filters,
|
||||
packages,
|
||||
@ -392,6 +427,8 @@ export function useHome() {
|
||||
uploadArchitectureOptions,
|
||||
channelOptions,
|
||||
roleText,
|
||||
loadProjects,
|
||||
changeProject,
|
||||
loadPackages,
|
||||
refreshPackages,
|
||||
scheduleRefresh,
|
||||
|
||||
@ -11,6 +11,8 @@ const {
|
||||
authForm,
|
||||
resetForm,
|
||||
adminUsers,
|
||||
projects,
|
||||
selectedProject,
|
||||
search,
|
||||
filters,
|
||||
packages,
|
||||
@ -31,6 +33,8 @@ const {
|
||||
uploadArchitectureOptions,
|
||||
channelOptions,
|
||||
roleText,
|
||||
loadProjects,
|
||||
changeProject,
|
||||
loadPackages,
|
||||
refreshPackages,
|
||||
scheduleRefresh,
|
||||
@ -53,6 +57,7 @@ const {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMe()
|
||||
await loadProjects()
|
||||
await refreshPackages()
|
||||
})
|
||||
|
||||
@ -79,6 +84,14 @@ onUnmounted(stopNotifications)
|
||||
<span>服务端地址</span>
|
||||
<input v-model="apiBase" type="url" placeholder="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
<label>
|
||||
<span>发布项目</span>
|
||||
<select v-model="selectedProject" @change="changeProject">
|
||||
<option v-for="item in projects" :key="item.id" :value="item.id" :disabled="item.status !== 'active'">
|
||||
{{ item.name }}({{ item.id }}{{ item.status === 'active' ? '' : ',已停用' }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>搜索包</span>
|
||||
<input v-model="search" type="search" placeholder="输入包名或描述" @keyup.enter="loadPackages" />
|
||||
|
||||
@ -15,6 +15,12 @@ type projectCreateRequest struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type projectUpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
|
||||
func (s *Server) projectList(writer http.ResponseWriter, _ *http.Request) {
|
||||
projects := make([]registry.Project, 0, 1)
|
||||
projects = append(projects, registry.Project{
|
||||
@ -83,11 +89,43 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
if request.Method != http.MethodGet {
|
||||
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
|
||||
if request.Method == http.MethodGet {
|
||||
writeJSON(writer, http.StatusOK, project)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, project)
|
||||
if request.Method == http.MethodPatch {
|
||||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
var input projectUpdateRequest
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
update := registry.Project{
|
||||
Name: project.Name,
|
||||
Description: project.Description,
|
||||
Status: project.Status,
|
||||
}
|
||||
if input.Name != nil {
|
||||
update.Name = *input.Name
|
||||
}
|
||||
if input.Description != nil {
|
||||
update.Description = *input.Description
|
||||
}
|
||||
if input.Status != nil {
|
||||
update.Status = *input.Status
|
||||
}
|
||||
updated, err := s.Projects.Update(projectID, update)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendAdminsEmail("Scout 项目已更新", "项目 "+updated.ID+" 的配置已更新。")
|
||||
writeJSON(writer, http.StatusOK, updated)
|
||||
return
|
||||
}
|
||||
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
|
||||
return
|
||||
}
|
||||
if project.Status != "active" {
|
||||
@ -99,9 +137,7 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
||||
writeError(writer, http.StatusNotFound, "项目存储不存在")
|
||||
return
|
||||
}
|
||||
scoped := *s
|
||||
scoped.Registry = store
|
||||
scoped.projectScope = projectID
|
||||
scoped := s.scopedRegistry(store, projectID)
|
||||
if parts[1] == "packages" {
|
||||
if len(parts) == 2 && request.Method == http.MethodGet {
|
||||
scoped.packageList(writer, scopedRequest(request, "/api/v1/packages"))
|
||||
@ -128,6 +164,25 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
|
||||
writeError(writer, http.StatusNotFound, "接口不存在")
|
||||
}
|
||||
|
||||
func (s *Server) scopedRegistry(store *registry.Registry, projectID string) *Server {
|
||||
return &Server{
|
||||
Registry: store,
|
||||
Projects: s.Projects,
|
||||
DeveloperToken: s.DeveloperToken,
|
||||
MaxUploadBytes: s.MaxUploadBytes,
|
||||
Users: s.Users,
|
||||
Mailer: s.Mailer,
|
||||
SessionDuration: s.SessionDuration,
|
||||
AllowedOrigins: s.AllowedOrigins,
|
||||
AllowedHosts: s.AllowedHosts,
|
||||
ForceHTTPS: s.ForceHTTPS,
|
||||
TrustProxy: s.TrustProxy,
|
||||
AllowLegacyToken: s.AllowLegacyToken,
|
||||
RateLimitPerMinute: s.RateLimitPerMinute,
|
||||
Logger: s.Logger,
|
||||
projectScope: projectID,
|
||||
}
|
||||
}
|
||||
func scopedRequest(request *http.Request, path string) *http.Request {
|
||||
clone := request.Clone(request.Context())
|
||||
clone.URL.Path = path
|
||||
|
||||
@ -115,3 +115,39 @@ func assertProjectPackageContent(t *testing.T, endpoint, expected string) {
|
||||
t.Fatalf("项目包内容错误:%d %q", response.StatusCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRouteRejectsDisabledProject(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
legacy, err := registry.New(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := registry.NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projects.Update("ssl", registry.Project{
|
||||
Name: "SSL",
|
||||
Description: "已停用",
|
||||
Status: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer((&Server{
|
||||
Registry: legacy,
|
||||
Projects: projects,
|
||||
}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
response, err := http.Get(server.URL + "/api/v1/projects/ssl/packages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("停用项目应禁止访问:%d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +111,37 @@ func (r *ProjectRegistry) Create(input Project) (Project, error) {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (r *ProjectRegistry) Update(id string, input Project) (Project, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if err := ValidateProjectID(id); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
if id == DefaultProject {
|
||||
return Project{}, errors.New("default 是兼容项目标识,不能修改")
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return Project{}, errors.New("项目名称不能为空")
|
||||
}
|
||||
if input.Status != "active" && input.Status != "disabled" {
|
||||
return Project{}, errors.New("项目状态只能是 active 或 disabled")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
current, exists := r.projects[id]
|
||||
if !exists {
|
||||
return Project{}, errors.New("项目不存在")
|
||||
}
|
||||
current.Name = strings.TrimSpace(input.Name)
|
||||
current.Description = strings.TrimSpace(input.Description)
|
||||
current.Status = input.Status
|
||||
current.UpdatedAt = time.Now().UTC()
|
||||
if err := writeProjectMetadata(filepath.Join(r.root, id), current); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
r.projects[id] = current
|
||||
return current, nil
|
||||
}
|
||||
func (r *ProjectRegistry) Ensure(id, name, description string) (Project, *Registry, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if err := ValidateProjectID(id); err != nil {
|
||||
|
||||
@ -77,3 +77,34 @@ func TestValidateProjectID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRegistryUpdatePersistsStatus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
projects, err := NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := projects.Create(Project{ID: "ssl", Name: "SSL", Description: "证书项目"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := projects.Update("ssl", Project{
|
||||
Name: "SSL 发布中心",
|
||||
Description: "证书项目更新",
|
||||
Status: "disabled",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.ID != created.ID || updated.Status != "disabled" || updated.Name != "SSL 发布中心" {
|
||||
t.Fatalf("项目更新结果错误:%+v", updated)
|
||||
}
|
||||
loaded, err := NewProjectRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persisted, ok := loaded.Get("ssl")
|
||||
if !ok || persisted.Status != "disabled" || persisted.Description != "证书项目更新" {
|
||||
t.Fatalf("项目更新未持久化:%+v %v", persisted, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,11 @@ func New(baseURL string, options ...Option) (*Client, error) {
|
||||
for _, option := range options {
|
||||
option(client)
|
||||
}
|
||||
if client.project != "" {
|
||||
if err := validateProjectID(client.project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@ -112,18 +117,32 @@ func WithProject(project string) Option {
|
||||
|
||||
func NewProject(baseURL, project string, options ...Option) (*Client, error) {
|
||||
project = strings.TrimSpace(project)
|
||||
if project == "" {
|
||||
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
|
||||
}
|
||||
for _, char := range project {
|
||||
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
|
||||
}
|
||||
if err := validateProjectID(project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options = append(options, WithProject(project))
|
||||
return New(baseURL, options...)
|
||||
}
|
||||
|
||||
func validateProjectID(project string) error {
|
||||
project = strings.TrimSpace(project)
|
||||
if project == "" {
|
||||
return errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
|
||||
}
|
||||
if len(project) > 64 {
|
||||
return errors.New("\u9879\u76ee\u6807\u8bc6\u957f\u5ea6\u4e0d\u80fd\u8d85\u8fc764\u4e2a\u5b57\u7b26")
|
||||
}
|
||||
for index, char := range project {
|
||||
if index == 0 && (char == '.' || char == '-' || char == '_') {
|
||||
return errors.New("\u9879\u76ee\u6807\u8bc6\u5f00\u5934\u5fc5\u987b\u662f\u5b57\u6bcd\u6216\u6570\u5b57")
|
||||
}
|
||||
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Project() string {
|
||||
return c.project
|
||||
}
|
||||
@ -132,7 +151,11 @@ func (c *Client) resourcePath(path string) string {
|
||||
if c.project == "" {
|
||||
return path
|
||||
}
|
||||
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len("/api/v1"):]
|
||||
const apiPrefix = "/api/v1"
|
||||
if !strings.HasPrefix(path, apiPrefix) {
|
||||
return path
|
||||
}
|
||||
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len(apiPrefix):]
|
||||
}
|
||||
|
||||
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
|
||||
@ -180,6 +203,9 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
|
||||
}
|
||||
version = latest.Version
|
||||
}
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
return Package{}, errors.New("保存路径不能为空")
|
||||
}
|
||||
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
@ -193,41 +219,68 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
|
||||
if err := checkResponse(response); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if destination == "" {
|
||||
return Package{}, errors.New("保存路径不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
return Package{}, fmt.Errorf("创建保存目录失败: %w", err)
|
||||
}
|
||||
file, err := os.Create(destination)
|
||||
temporary, err := os.CreateTemp(filepath.Dir(destination), ".scout-download-*")
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("创建保存文件失败: %w", err)
|
||||
return Package{}, fmt.Errorf("创建临时下载文件失败: %w", err)
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
|
||||
closeErr := file.Close()
|
||||
size, copyErr := io.Copy(io.MultiWriter(temporary, hash), response.Body)
|
||||
closeErr := temporary.Close()
|
||||
if copyErr != nil {
|
||||
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr)
|
||||
return Package{}, fmt.Errorf("关闭临时下载文件失败: %w", closeErr)
|
||||
}
|
||||
if response.ContentLength >= 0 && size != response.ContentLength {
|
||||
return Package{}, fmt.Errorf("包文件大小校验失败: 期望 %d,实际 %d", response.ContentLength, size)
|
||||
}
|
||||
digest := hex.EncodeToString(hash.Sum(nil))
|
||||
expected := response.Header.Get("X-Scout-SHA256")
|
||||
if expected != "" && !strings.EqualFold(expected, digest) {
|
||||
return Package{}, fmt.Errorf("包文件校验失败: 期望 %s,实际 %s", expected, digest)
|
||||
}
|
||||
if err := copyDownloadedFile(temporaryName, destination); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
return Package{
|
||||
Project: c.project,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Size: response.ContentLength,
|
||||
Size: size,
|
||||
SHA256: digest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func copyDownloadedFile(source, destination string) error {
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取临时下载文件失败: %w", err)
|
||||
}
|
||||
defer input.Close()
|
||||
output, err := os.Create(destination)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建保存文件失败: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
closeErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("写入保存文件失败: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("关闭保存文件失败: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) {
|
||||
if c.token == "" {
|
||||
return Package{}, errors.New("上传包需要开发者令牌")
|
||||
return Package{}, errors.New("上传包需要开发者或管理员认证令牌")
|
||||
}
|
||||
if input.File == nil {
|
||||
return Package{}, errors.New("包文件不能为空")
|
||||
@ -294,6 +347,29 @@ func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan U
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (c *Client) patchJSON(ctx context.Context, path string, value any, target any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码请求失败: %w", err)
|
||||
}
|
||||
request, err := c.newRequest(ctx, http.MethodPatch, path, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if err := checkResponse(response); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||||
return fmt.Errorf("解析服务端响应失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
|
||||
@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -118,3 +119,31 @@ func TestCompareVersions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadKeepsDestinationWhenChecksumFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Length", "7")
|
||||
writer.Header().Set("X-Scout-SHA256", "invalid")
|
||||
_, _ = writer.Write([]byte("payload"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api, err := New(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "package.pkg")
|
||||
if err := os.WriteFile(destination, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.Download(context.Background(), "demo", "1.0.0", destination); err == nil {
|
||||
t.Fatal("错误校验值应该导致下载失败")
|
||||
}
|
||||
data, err := os.ReadFile(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "original" {
|
||||
t.Fatalf("校验失败不应覆盖原文件:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,22 @@ func (c *Client) CreateProject(ctx context.Context, project Project) (Project, e
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateProject(ctx context.Context, project Project) (Project, error) {
|
||||
project.ID = strings.TrimSpace(project.ID)
|
||||
if err := validateProjectID(project.ID); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
var result Project
|
||||
path := "/api/v1/projects/" + url.PathEscape(project.ID)
|
||||
if err := c.patchJSON(ctx, path, map[string]string{
|
||||
"name": project.Name,
|
||||
"description": project.Description,
|
||||
"status": project.Status,
|
||||
}, &result); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (c *Client) ProjectHealth(ctx context.Context) error {
|
||||
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
|
||||
if err != nil {
|
||||
|
||||
45
使用说明.md
45
使用说明.md
@ -151,6 +151,45 @@ Authorization: Bearer <SCOUT_DEVELOPER_TOKEN>
|
||||
- 包上传成功或失败:通知执行上传的开发者/管理员。
|
||||
- 普通用户不会因为包版本更新收到邮件,仍通过 SSE 或客户端主动检查获取更新。
|
||||
|
||||
## 多项目发布中心
|
||||
|
||||
Scout 服务端是独立发布中心,不需要修改 SSL、Smail 或后续接入项目的源代码。每个项目使用独立的包目录、版本索引和 SSE 通知流;项目之间可以存在同名包而不会互相覆盖。
|
||||
|
||||
服务端默认保留 `default` 兼容项目,对应原有 `/api/v1/packages` 接口。新增项目使用项目级接口,例如项目标识为 `ssl` 时:
|
||||
|
||||
| 方法 | 路径 | 用途 | 认证 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/projects` | 查看项目列表 | 无 |
|
||||
| POST | `/api/v1/projects` | 创建项目 | 管理员 |
|
||||
| GET | `/api/v1/projects/{project}` | 查看项目配置 | 无 |
|
||||
| PATCH | `/api/v1/projects/{project}` | 修改项目名称、说明或状态 | 管理员 |
|
||||
| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 无 |
|
||||
| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 开发者/管理员会话 |
|
||||
| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 无 |
|
||||
| GET | `/api/v1/projects/{project}/updates/stream` | 监听项目更新 | 无 |
|
||||
|
||||
项目状态为 `disabled` 时,项目配置仍可查询,但包列表、上传、下载和更新通知会被拒绝。项目目录位于:
|
||||
|
||||
```text
|
||||
data/projects/{project}/
|
||||
```
|
||||
|
||||
接入 SSL、Smail 或后续项目时,只需在发布中心创建对应项目,再让宿主应用使用项目级客户端:
|
||||
|
||||
```go
|
||||
api, err := client.NewProject(
|
||||
"https://packages.example.com",
|
||||
"ssl",
|
||||
client.WithSessionToken(session),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, available, err := api.CheckUpdate(ctx, "scert", currentVersion)
|
||||
```
|
||||
|
||||
客户端不会修改接入项目;项目只通过 Scout API 和 `pkg/client`、`pkg/updater` 模块通信。
|
||||
|
||||
## Go 客户端命令
|
||||
|
||||
编译命令行客户端:
|
||||
@ -162,19 +201,19 @@ go build -o .\scout-client.exe .\cmd\scout-client
|
||||
检查更新:
|
||||
|
||||
```powershell
|
||||
.\scout-client.exe check --server http://127.0.0.1:8080 --package demo-core --current 1.0.0
|
||||
.\scout-client.exe check --server http://127.0.0.1:8080 --project ssl --package demo-core --current 1.0.0
|
||||
```
|
||||
|
||||
下载最新版本:
|
||||
|
||||
```powershell
|
||||
.\scout-client.exe download --server http://127.0.0.1:8080 --package demo-core --output .\demo-core.pkg
|
||||
.\scout-client.exe download --server http://127.0.0.1:8080 --project ssl --package demo-core --output .\demo-core.pkg
|
||||
```
|
||||
|
||||
监听更新通知:
|
||||
|
||||
```powershell
|
||||
.\scout-client.exe watch --server http://127.0.0.1:8080 --packages demo-core
|
||||
.\scout-client.exe watch --server http://127.0.0.1:8080 --project ssl --packages demo-core
|
||||
```
|
||||
|
||||
更新生命周期命令:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user