diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index fd02333..cea7814 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -187,7 +187,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: Download web/dist uses: actions/download-artifact@v4 diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index 44c7208..937b20f 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -4,10 +4,12 @@ # 产出: # 1. 前端 + 后端编译验证(go vet / go test / go build) # 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) -# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) +# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64), +# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改) # -# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 -# 不打 release tag,只构建带 -beta 标识的产物供测试。 +# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略 +# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、 +# 不打 release tag、不发布 GitHub Release、不部署服务器。 name: Beta Build @@ -22,9 +24,6 @@ permissions: contents: read packages: write -env: - BETA_VERSION_PREFIX: beta - jobs: # ───────────────────────────────────────────────────────────────────────────── # 1) 编译验证 + 多平台二进制产物 @@ -32,17 +31,24 @@ jobs: test-and-build: name: Test & build artifacts runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + binary_version: ${{ steps.version.outputs.binary_version }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。 + # beta 只读不自增,避免与 main 的发布流程争抢版本号。 - name: Resolve beta version id: version run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") + git fetch origin version || true + BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0") SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" + echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" # The binary embeds the SPA (web/dist) via go:embed, so dist must exist # before the Go toolchain touches the web package. @@ -59,7 +65,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: go vet @@ -71,13 +77,13 @@ jobs: - name: go build (host) run: go build ./... - # 多平台可执行文件(嵌入刚构建的 web/dist) + # 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致 - name: Build linux/amd64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server - name: Build linux/arm64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -87,7 +93,8 @@ jobs: if-no-files-found: error # ───────────────────────────────────────────────────────────────────────────── - # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta) + # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA}) + # 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。 # ───────────────────────────────────────────────────────────────────────────── docker-beta: name: Build & push beta Docker image @@ -98,13 +105,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Resolve beta version - id: version - run: | - BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0") - SHA_SHORT=${GITHUB_SHA:0:7} - echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT" - - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -115,6 +115,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/mebox + tags: | + type=raw,value=beta + type=raw,value=${{ needs.test-and-build.outputs.version }} + - name: Build & push uses: docker/build-push-action@v6 with: @@ -123,11 +132,9 @@ jobs: push: true provenance: false sbom: false - tags: ghcr.io/${{ github.repository_owner }}/mebox:beta - labels: | - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.source=${{ github.repository }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} build-args: | - VERSION=${{ steps.version.outputs.full_version }} + VERSION=${{ needs.test-and-build.outputs.binary_version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e69d2e..e416885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true # The binary embeds the SPA (web/dist) via go:embed, so the dist must exist diff --git a/.gitignore b/.gitignore index 64a5923..d2ca998 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,5 @@ media/ tools/ verify-cache/ verify-data/ -.zcode/ \ No newline at end of file +.zcode/ +.tmp-src diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..79c3199 --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,114 @@ +# Binaries +bin/ +*.exe +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test +*.out + +# Go workspace +go.work + +# Dependency directories +node_modules/ + +# Build artifacts +web/dist/ +web/.vite/ +web/coverage/ +web/tsconfig.tsbuildinfo +dist-release/ + +# Data / runtime +data/ +cache/ +logs/ +.tmp-deploy-data/ +.tmp-deploy-smoke-data/ +.tmp-deploy-smoke-cache/ +.tmp-deploy-cache/ +.tmp-deploy-server.* +.tmp-live-server.* +.mebox.pid +*.log +*.db +*.db-journal +*.db-shm +*.db-wal + +# Editor / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Env files +.env +.env.local +.env.*.local + +# Local configs (keep examples) +config/secrets.yaml +config.yaml + +# WorkBuddy workspace (local AI assistant memory) +.workbuddy/ + +# Editor backups +*~ +.tmp_* + +# Runtime / local-only artifacts (清理补充) +.tmp/ +.tmp-live-backups/ +.tmp-* +.codex-* +.codex/ +downloads/ +media/ +*.pid + +# 本地开发运行产物 +.agents/ +.claude/ +.dev-cache/ +.dev-data/ +.dev-logs/ +tools/ +verify-cache/ +verify-data/ +.zcode/ + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/Dockerfile b/Dockerfile index 79edaff..1adce9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Multi-architecture build for MeBox. # # Stage 1 (frontend) : Node 20.19+ -> static SPA bundle -# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) +# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0) # Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user # # Build: @@ -25,7 +25,7 @@ COPY web/ . RUN npm run build # ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) ------------------- -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend ARG TARGETOS ARG TARGETARCH ARG GOPROXY=https://proxy.golang.org,direct diff --git a/README.md b/README.md index b48e5d1..6bb7cbc 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@
-
+
diff --git a/README_EN.md b/README_EN.md
index 18ab276..8a828ec 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -21,7 +21,7 @@
-
+
diff --git a/cmd/reader-smoke/main.go b/cmd/reader-smoke/main.go
new file mode 100644
index 0000000..c6e6822
--- /dev/null
+++ b/cmd/reader-smoke/main.go
@@ -0,0 +1,197 @@
+// reader-smoke 是阅读书源兼容性冒烟工具:
+// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
+//
+// 用法:
+//
+// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
+// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/truewhile/MeBox/internal/config"
+ "github.com/truewhile/MeBox/internal/helper"
+ "github.com/truewhile/MeBox/internal/repository"
+ "github.com/truewhile/MeBox/internal/service/reader"
+)
+
+func main() {
+ file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
+ urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
+ key := flag.String("key", "斗破苍穹", "搜索关键词")
+ concurrency := flag.Int("c", 4, "并发数")
+ timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
+ jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
+ flag.Parse()
+
+ payload := ""
+ switch {
+ case *file != "":
+ data, err := os.ReadFile(*file)
+ if err != nil {
+ fatal("读取文件失败: %v", err)
+ }
+ payload = string(data)
+ case *urlFlag != "":
+ client := helper.NewSiteHTTPClient(30, true)
+ req, err := http.NewRequest("GET", *urlFlag, nil)
+ if err != nil {
+ fatal("构造请求失败: %v", err)
+ }
+ for k, v := range helper.HTTPHeaderPresets() {
+ req.Header.Set(k, v)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ fatal("拉取书源失败: %v", err)
+ }
+ defer resp.Body.Close()
+ var sb strings.Builder
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := resp.Body.Read(buf)
+ sb.Write(buf[:n])
+ if err != nil {
+ break
+ }
+ }
+ payload = sb.String()
+ default:
+ fatal("需要 -file 或 -url 指定书源来源")
+ }
+
+ sources := reader.ParseSourcePayload(payload)
+ if len(sources) == 0 {
+ fatal("未从输入中识别到书源")
+ }
+
+ svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
+ ctx := context.Background()
+
+ results := make([]*reader.SmokeChainResult, len(sources))
+ sem := make(chan struct{}, max(1, *concurrency))
+ var wg sync.WaitGroup
+ for i, raw := range sources {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(i int, raw string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
+ defer cancel()
+ res := svc.SmokeSource(ctxSrc, raw, *key)
+ results[i] = res
+ status := "✓"
+ if !res.OK {
+ status = "✗"
+ }
+ fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
+ status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
+ }(i, raw)
+ }
+ wg.Wait()
+
+ // 汇总
+ var searchOK, infoOK, tocOK, contentOK, allOK int
+ failedAt := map[string]int{}
+ for _, r := range results {
+ if r == nil {
+ continue
+ }
+ switch r.FailedAt {
+ case "":
+ allOK++
+ searchOK++
+ infoOK++
+ tocOK++
+ contentOK++
+ case "search":
+ failedAt["search"]++
+ case "info":
+ searchOK++
+ failedAt["info"]++
+ case "toc":
+ searchOK++
+ infoOK++
+ failedAt["toc"]++
+ case "content":
+ searchOK++
+ infoOK++
+ tocOK++
+ failedAt["content"]++
+ }
+ }
+ n := len(results)
+ pct := func(v int) string {
+ if n == 0 {
+ return "0%"
+ }
+ return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
+ }
+ fmt.Printf("\n==== 冒烟报告 ====\n")
+ fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
+ fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
+ fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
+ fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
+ fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
+ fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
+ for _, stage := range []string{"search", "info", "toc", "content"} {
+ if failedAt[stage] > 0 {
+ fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
+ }
+ }
+
+ if *jsonOut {
+ out, err := json.MarshalIndent(results, "", " ")
+ if err != nil {
+ fatal("序列化报告失败: %v", err)
+ }
+ fmt.Println(string(out))
+ }
+}
+
+func stageCN(r *reader.SmokeChainResult) string {
+ switch r.FailedAt {
+ case "":
+ return "完成"
+ case "search":
+ return "搜索"
+ case "info":
+ return "详情"
+ case "toc":
+ return "目录"
+ case "content":
+ return "正文"
+ case "parse":
+ return "解析"
+ default:
+ return r.FailedAt
+ }
+}
+
+func truncate(s string, n int) string {
+ rs := []rune(strings.TrimSpace(s))
+ if len(rs) <= n {
+ if len(rs) == 0 {
+ return "(未命名)"
+ }
+ return string(rs)
+ }
+ return string(rs[:n]) + "…"
+}
+
+func fatal(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/dist/index.html b/dist/index.html
new file mode 100644
index 0000000..232bbe8
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,26 @@
+
+
+