forked from Gitlink/gitlink-cli
294 lines
9.5 KiB
Go
294 lines
9.5 KiB
Go
// 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)
|
||
}
|