gitlink-cli/cmd/server/server.go

294 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package server 提供子赛题四的「网页终端」演示服务子赛题四·Phase 7
//
// 在云服务器上 `gitlink-cli server` 启动一个轻量 HTTP 服务:前端按 7 个科研场景按钮
// 触发后端运行对应的 Python 算法脚本scripts/research/*.py并把 JSON / Mermaid /
// 报告等产物回显/渲染。所有数据仍由 gitlink-cli 获取,本服务只做命令编排与产物转发。
//
// 安全:仅接受 7 个固定场景 + owner/repo/keyword 结构化参数(非任意 shell
// 若设置 --token / DEMO_TOKEN 环境变量,则请求需带 X-Demo-Token 头匹配。
package server
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
)
//go:embed static/*
var staticFS embed.FS
const defaultPort = 8080
// 场景定义:前端按钮 ↔ 后端 Python 脚本。
type scenarioDef struct {
Key string `json:"key"`
Label string `json:"label"`
Desc string `json:"desc"`
Script string `json:"script"`
Needs []string `json:"needs"` // 需要的输入: "owner","repo","keyword"
Timeout int `json:"timeout_s"`
}
var scenarios = []scenarioDef{
{Key: "s1", Label: "S1 仓库洞悉", Desc: "科研项目演进谱系 + 创新点", Script: "lineage.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s2", Label: "S2 知识图谱", Desc: "科研领域知识图谱(networkx)", Script: "graph_build.py", Needs: []string{"keyword"}, Timeout: 240},
{Key: "s3", Label: "S3 合规复现", Desc: "许可证/密钥/复现性检查", Script: "repro.py", Needs: []string{"owner", "repo"}, Timeout: 120},
{Key: "s4", Label: "S4 协作匹配", Desc: "学者×缺口 智能匹配", Script: "match.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s5", Label: "S5 进度预警", Desc: "周报 + 风险预警", Script: "report.py", Needs: []string{"owner", "repo"}, Timeout: 180},
{Key: "s6", Label: "S6 成果可视化", Desc: "交互图表(plotly)", Script: "visual.py", Needs: []string{"owner", "repo"}, Timeout: 240},
{Key: "hotspot", Label: "🔥 热点追踪", Desc: "科研热点全景观测:飙升项目+活跃讨论+主题热度+学者团队", Script: "hotspot.py", Needs: []string{"keyword"}, Timeout: 300},
}
type Options struct {
Port int
ResearchDir string // scripts/research 目录
WorkDir string // 产物输出根目录
Token string // 可选鉴权 token
}
func NewServerCmd() *cobra.Command {
opts := Options{Port: defaultPort, ResearchDir: "scripts/research", WorkDir: "research-output"}
cmd := &cobra.Command{
Use: "server",
Short: "启动子赛题四网页终端HTTP 演示服务)",
RunE: func(cmd *cobra.Command, args []string) error {
return Run(opts)
},
}
cmd.Flags().IntVarP(&opts.Port, "port", "p", defaultPort, "监听端口")
cmd.Flags().StringVar(&opts.ResearchDir, "research-dir", "scripts/research", "scripts/research 目录")
cmd.Flags().StringVar(&opts.WorkDir, "work-dir", "research-output", "产物输出根目录")
cmd.Flags().StringVar(&opts.Token, "token", "", "可选鉴权 token亦可用 DEMO_TOKEN 环境变量)")
return cmd
}
// Run 启动 HTTP 服务(阻塞)。
func Run(opts Options) error {
if t := os.Getenv("DEMO_TOKEN"); t != "" && opts.Token == "" {
opts.Token = t
}
_ = os.MkdirAll(opts.WorkDir, 0o755)
mux := http.NewServeMux()
h := &handler{opts: opts}
mux.HandleFunc("GET /api/scenarios", h.handleScenarios)
mux.HandleFunc("POST /api/run", h.handleRun)
mux.HandleFunc("GET /api/result/{key}", h.handleResult)
mux.HandleFunc("GET /api/health", h.handleHealth)
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static fs: %w", err)
}
mux.Handle("GET /", http.FileServer(http.FS(sub)))
addr := fmt.Sprintf(":%d", opts.Port)
fmt.Fprintf(os.Stderr, "子赛题四 网页终端已启动: http://localhost%s\n", addr)
fmt.Fprintf(os.Stderr, " research-dir=%s work-dir=%s auth=%v\n", opts.ResearchDir, opts.WorkDir, opts.Token != "")
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
return srv.ListenAndServe()
}
type handler struct {
opts Options
mu sync.Mutex // 串行化场景执行,避免并发打爆 GitLink API
}
func (h *handler) authed(r *http.Request) bool {
if h.opts.Token == "" {
return true
}
return r.Header.Get("X-Demo-Token") == h.opts.Token
}
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "scenarios": len(scenarios)})
}
func (h *handler) handleScenarios(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]any{"ok": true, "scenarios": scenarios})
}
// handleResult 返回某场景最近一次运行的产物(供 result.html 独立结果页按 key 读取,
// URL 可刷新/分享,便于演示讲解)。无需鉴权串行锁——只读已落盘产物。
func (h *handler) handleResult(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
key := r.PathValue("key")
sc, ok := findScenario(key)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + key})
return
}
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
writeJSON(w, map[string]any{
"ok": true,
"scenario": sc.Key,
"label": sc.Label,
"desc": sc.Desc,
"artifacts": readArtifacts(outDir),
})
}
type runRequest struct {
Scenario string `json:"scenario"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Keyword string `json:"keyword"`
}
func (h *handler) handleRun(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req runRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "bad request: " + err.Error()})
return
}
sc, ok := findScenario(req.Scenario)
if !ok {
writeJSON(w, map[string]any{"ok": false, "error": "unknown scenario: " + req.Scenario})
return
}
for _, need := range sc.Needs {
if (need == "keyword" && req.Keyword == "") ||
(need == "owner" && req.Owner == "") ||
(need == "repo" && req.Repo == "") {
writeJSON(w, map[string]any{"ok": false, "error": "missing parameter: " + need})
return
}
}
// 串行执行:一次只跑一个场景,保护 GitLink API。
h.mu.Lock()
defer h.mu.Unlock()
scriptPath := filepath.Join(h.opts.ResearchDir, sc.Script)
outDir := filepath.Join(h.opts.WorkDir, sc.Key)
_ = os.MkdirAll(outDir, 0o755)
argv := []string{scriptPath, "--out", outDir}
if contains(sc.Needs, "owner") {
argv = append(argv, "--owner", req.Owner, "--repo", req.Repo)
}
if contains(sc.Needs, "keyword") {
argv = append(argv, "--keywords", req.Keyword)
}
// python3 优先,回退 python
py, err := pythonBin()
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
cmd := exec.Command(py, argv...)
cmd.Env = os.Environ()
start := time.Now()
out, err := cmd.CombinedOutput()
dur := time.Since(start)
resp := map[string]any{
"ok": err == nil,
"scenario": sc.Key,
"command": py + " " + strings.Join(argv, " "),
"duration": dur.Truncate(time.Millisecond).String(),
"stdout": string(out),
"out_dir": outDir,
}
if err != nil {
resp["error"] = err.Error()
}
// 附带读取关键产物json + 第一个 mmd + report.md便于前端直接渲染
resp["artifacts"] = readArtifacts(outDir)
writeJSON(w, resp)
}
func findScenario(key string) (scenarioDef, bool) {
for _, s := range scenarios {
if s.Key == key || strings.EqualFold(s.Key, key) {
return s, true
}
}
return scenarioDef{}, false
}
func readArtifacts(dir string) map[string]string {
out := map[string]string{}
// json 产物(取第一个 *.json
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
switch {
case strings.HasSuffix(name, ".json"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["json"] = string(b)
case strings.HasSuffix(name, ".mmd"):
b, _ := os.ReadFile(filepath.Join(dir, name))
out["mermaid"] = string(b)
case name == "visual.html":
b, _ := os.ReadFile(filepath.Join(dir, name))
out["html"] = string(b)
case strings.HasSuffix(name, ".md"):
// 第一份 .md 报告report.md / weekly_report.md / compliance_report.md
if _, ok := out["report"]; !ok {
b, _ := os.ReadFile(filepath.Join(dir, name))
out["report"] = string(b)
}
}
}
}
return out
}
func pythonBin() (string, error) {
// 候选按 Linux 习惯 python3 优先,再 python / py(Windows)。
// 必须实测能产出Windows 的 WindowsApps\python3.exe 是 Store 桩,对 -c 也可能 exit 0 但不真正执行,
// 故用「stdout 必须含 PYOK」来拦截桩。
for _, name := range []string{"python3", "python", "py"} {
path, err := exec.LookPath(name)
if err != nil {
continue
}
if out, err := exec.Command(path, "-c", "print('PYOK')").Output(); err == nil &&
strings.Contains(string(out), "PYOK") {
return path, nil
}
}
return "", fmt.Errorf("python 未安装;容器需内置 python3 并 pip install -r scripts/research/requirements.txt")
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}