补充测试与中文使用说明
This commit is contained in:
parent
15f4f9ef86
commit
b76e878861
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
data/
|
||||
*.exe
|
||||
*.pkg
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"name": "scout-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import Home from './views/Home/Home.vue'
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
:root {
|
||||
:root {
|
||||
font-family: Inter, "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||
color: #dce4f2;
|
||||
background: #0b1220;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.page-shell {
|
||||
.page-shell {
|
||||
width: min(1180px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0 72px;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const defaultApi = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080'
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useHome } from './Home.js'
|
||||
import './Home.css'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
123
internal/httpapi/server_test.go
Normal file
123
internal/httpapi/server_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"scout/internal/registry"
|
||||
)
|
||||
|
||||
func TestUploadListLatestAndDownload(t *testing.T) {
|
||||
store, err := registry.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer((&Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
unauthorized, err := http.Post(server.URL+"/api/v1/packages/upload", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unauthorized.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("未授权状态错误: %d", unauthorized.StatusCode)
|
||||
}
|
||||
unauthorized.Body.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("name", "demo-core")
|
||||
_ = writer.WriteField("version", "1.0.0")
|
||||
fileWriter, err := writer.CreateFormFile("file", "demo.pkg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fileWriter.Write([]byte("demo package"))
|
||||
_ = writer.Close()
|
||||
request, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/packages/upload", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.Header.Set("Authorization", "Bearer 测试令牌")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("上传状态错误: %d", response.StatusCode)
|
||||
}
|
||||
var uploaded registry.Package
|
||||
if err := json.NewDecoder(response.Body).Decode(&uploaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if uploaded.Name != "demo-core" || uploaded.Version != "1.0.0" {
|
||||
t.Fatalf("上传结果错误: %+v", uploaded)
|
||||
}
|
||||
|
||||
listResponse, err := http.Get(server.URL + "/api/v1/packages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var listed struct {
|
||||
Packages []registry.Package `json:"packages"`
|
||||
}
|
||||
if err := json.NewDecoder(listResponse.Body).Decode(&listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listResponse.Body.Close()
|
||||
if len(listed.Packages) != 1 {
|
||||
t.Fatalf("包列表数量错误: %d", len(listed.Packages))
|
||||
}
|
||||
|
||||
downloadResponse, err := http.Get(server.URL + "/api/v1/packages/demo-core/1.0.0/download")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(downloadResponse.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
downloadResponse.Body.Close()
|
||||
if downloadResponse.StatusCode != http.StatusOK || string(content) != "demo package" {
|
||||
t.Fatalf("下载结果错误: %d %q", downloadResponse.StatusCode, content)
|
||||
}
|
||||
if downloadResponse.Header.Get("X-Scout-SHA256") != uploaded.SHA256 {
|
||||
t.Fatal("下载校验值不一致")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamFiltersPackage(t *testing.T) {
|
||||
store, err := registry.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer((&Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/updates/stream?packages=target", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{}
|
||||
streamResponse, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer streamResponse.Body.Close()
|
||||
if streamResponse.StatusCode != http.StatusOK {
|
||||
t.Fatalf("通知状态错误: %d", streamResponse.StatusCode)
|
||||
}
|
||||
if streamResponse.Header.Get("Content-Type") != "text/event-stream; charset=utf-8" {
|
||||
t.Fatalf("通知类型错误: %s", streamResponse.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
_ = url.Values{}
|
||||
}
|
||||
78
internal/registry/registry_test.go
Normal file
78
internal/registry/registry_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddPackageAndReadBack(t *testing.T) {
|
||||
store, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("Scout 测试包")
|
||||
item, err := store.AddPackage(Package{
|
||||
Name: "demo-core",
|
||||
Version: "1.2.0",
|
||||
Description: "测试包",
|
||||
}, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Size != int64(len(content)) {
|
||||
t.Fatalf("大小错误: %d", item.Size)
|
||||
}
|
||||
if item.SHA256 == "" {
|
||||
t.Fatal("没有生成 SHA-256")
|
||||
}
|
||||
latest, ok := store.Latest("demo-core")
|
||||
if !ok || latest.Version != "1.2.0" {
|
||||
t.Fatalf("最新版本错误: %+v", latest)
|
||||
}
|
||||
file, _, err := store.OpenPackage("demo-core", "1.2.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
readBack, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(readBack, content) {
|
||||
t.Fatalf("文件内容错误: %q", readBack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestUsesVersionOrder(t *testing.T) {
|
||||
store, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"1.9.0", "1.10.0", "2.0.0"} {
|
||||
if _, err := store.AddPackage(Package{Name: "demo", Version: version}, bytes.NewReader([]byte(version))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
latest, ok := store.Latest("demo")
|
||||
if !ok || latest.Version != "2.0.0" {
|
||||
t.Fatalf("版本排序错误: %+v", latest)
|
||||
}
|
||||
if CompareVersions("1.10.0", "1.9.0") <= 0 {
|
||||
t.Fatal("版本比较错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateVersionIsRejected(t *testing.T) {
|
||||
store, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := Package{Name: "demo", Version: "1.0.0"}
|
||||
if _, err := store.AddPackage(input, bytes.NewReader([]byte("one"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AddPackage(input, bytes.NewReader([]byte("two"))); err == nil {
|
||||
t.Fatal("重复版本没有被拒绝")
|
||||
}
|
||||
}
|
||||
130
使用说明.md
Normal file
130
使用说明.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Scout 轻量化包管理程序
|
||||
|
||||
Scout 是一个使用 **Go + Vue** 构建的轻量化包管理程序,提供以下能力:
|
||||
|
||||
- 开发者通过令牌上传包文件和版本元数据。
|
||||
- 服务端使用本地文件目录保存包文件,重启后自动读取元数据。
|
||||
- 客户端查询包的最新版本和历史版本。
|
||||
- 客户端下载包时校验服务端返回的 SHA-256。
|
||||
- 服务端通过 SSE(Server-Sent Events)向客户端推送新包通知。
|
||||
- Vue 管理界面支持包目录、版本下载和开发者上传。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
Scout/
|
||||
├── cmd/
|
||||
│ ├── scout-server/ # Go 服务端
|
||||
│ └── scout-client/ # Go 客户端命令行工具
|
||||
├── internal/
|
||||
│ ├── httpapi/ # HTTP API、上传、下载、SSE 通知
|
||||
│ └── registry/ # 包存储、元数据和版本比较
|
||||
└── frontend/ # Vue 3 + Vite 管理界面
|
||||
```
|
||||
|
||||
## 启动 Go 服务端
|
||||
|
||||
PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:SCOUT_DEVELOPER_TOKEN = "请替换为实际开发者令牌"
|
||||
$env:SCOUT_ADDR = ":8080"
|
||||
$env:SCOUT_DATA_DIR = "data"
|
||||
go run .\cmd\scout-server
|
||||
```
|
||||
|
||||
启动后健康检查地址:
|
||||
|
||||
```text
|
||||
GET http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
生产环境不要使用示例令牌,并建议通过反向代理配置 HTTPS、访问来源限制、限流和更严格的上传大小限制。
|
||||
|
||||
## 启动 Vue 前端
|
||||
|
||||
打开第二个终端:
|
||||
|
||||
```powershell
|
||||
Set-Location .\frontend
|
||||
npm.cmd install
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
浏览器打开 Vite 输出的地址,默认通常为:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
页面中的服务端地址默认是 `http://127.0.0.1:8080`,也可以在页面顶部修改。
|
||||
|
||||
## 客户端命令
|
||||
|
||||
编译命令行客户端:
|
||||
|
||||
```powershell
|
||||
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
|
||||
```
|
||||
|
||||
下载最新版本:
|
||||
|
||||
```powershell
|
||||
.\scout-client.exe download --server http://127.0.0.1:8080 --package demo-core --output .\demo-core.pkg
|
||||
```
|
||||
|
||||
监听更新通知:
|
||||
|
||||
```powershell
|
||||
.\scout-client.exe watch --server http://127.0.0.1:8080 --packages demo-core
|
||||
```
|
||||
|
||||
## 上传接口
|
||||
|
||||
上传接口为:
|
||||
|
||||
```text
|
||||
POST /api/v1/packages/upload
|
||||
```
|
||||
|
||||
请求必须包含以下 multipart 字段:
|
||||
|
||||
- `name`:包名,只允许字母、数字、点、下划线和短横线。
|
||||
- `version`:版本号,只允许字母、数字、点、加号、下划线和短横线。
|
||||
- `file`:包文件。
|
||||
- `description`、`platform`、`architecture`:可选元数据。
|
||||
|
||||
请求头使用以下任一种方式传递开发者令牌:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <令牌>
|
||||
X-Developer-Token: <令牌>
|
||||
```
|
||||
|
||||
## HTTP 接口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/healthz` | 健康检查 |
|
||||
| GET | `/api/v1/packages` | 获取每个包的最新版本 |
|
||||
| GET | `/api/v1/packages/{name}` | 获取指定包的最新版本 |
|
||||
| GET | `/api/v1/packages/{name}/versions` | 获取指定包的全部版本 |
|
||||
| GET | `/api/v1/packages/{name}/{version}/download` | 下载指定版本 |
|
||||
| GET | `/api/v1/updates/stream?packages=a,b` | 监听包更新 SSE 通知 |
|
||||
| POST | `/api/v1/packages/upload` | 鉴权上传新包 |
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
Set-Location .\frontend
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
当前版本是可运行的最小版本。后续如果用于公网环境,建议继续增加用户/组织权限、签名包、断点续传、对象存储、数据库索引和通知鉴权。
|
||||
Loading…
Reference in New Issue
Block a user