优化小程序部分

This commit is contained in:
hero920103 committed 2026-10-31 19:17:37 +08:00
1 parent 727319a6eb
commit d4e89788c5
46 files changed
+4906 -63

No files matched your search

+652
View File
@@ -0,0 +1,652 @@
// Command deploy 本地目录 → 服务器目录的「差异比对 + 增量上传」工具。
//
// 用途:只上传本地改动过的文件,跳过未改动的文件,避免每次整包覆盖。
//
// 工作流程:
// 1. SSH 连上服务器,执行 md5sum 拿到远端目录的文件清单与指纹
// 2. 本地遍历目录计算 MD5(按排除规则跳过日志、可执行文件等)
// 3. 比对两份清单,得出「新增 / 已修改」的文件清单并打印
// 4. 确认后把差异文件打成 tar 流,通过 SSH 管道在远端解开
// 5. 回读远端指纹复核,确认上传结果
//
// 之所以用 Go 实现而不是直接调 scp:Windows 自带的 OpenSSH scp 不支持
// 非交互传密码(sshpass / pscp 都没装),x/crypto/ssh 可以直接用密码登录,
// 全程无交互,适合丢进 .bat 里一键执行。
//
// 用法:
//
// deploy -host 10.31.100.2 -user root -dir /www/wwwroot/api.yunzer.cn -local .
package main
import (
"archive/tar"
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// ============================ 默认排除规则 ============================
// defaultExcludeDirs 不参与同步的目录名
var defaultExcludeDirs = []string{
".git", ".idea", ".vscode", ".svn",
"node_modules", "bin", "dist", "tmp", "temp",
}
// defaultExcludeFiles 不参与同步的文件名(支持 * 通配)
var defaultExcludeFiles = []string{
"*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub",
"nohup.out", "output.log", "go.log",
"test-output-*.txt", "migrate_*.csv",
"server", // 无扩展名的编译产物
"*_id_ed25519", // SSH 私钥:绝对不能传到服务器
".deploy_known_hosts",
}
// ============================ 配置 ============================
type options struct {
host string
user string
pass string
keyFile string
dir string
local string
restart string
execCmd string
excludes string
dryRun bool
yes bool
del bool
quiet bool
}
// errCancelled 用户在上传前确认时选择了取消(区别于真正的失败)
var errCancelled = errors.New("用户取消")
func main() {
var opt options
flag.StringVar(&opt.host, "host", "10.31.100.2", "服务器地址")
flag.StringVar(&opt.user, "user", "root", "SSH 用户名")
flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)")
flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)")
flag.StringVar(&opt.dir, "dir", "/www/wwwroot/api.yunzer.cn", "服务器目标目录")
flag.StringVar(&opt.local, "local", ".", "本地目录(要同步的根)")
flag.StringVar(&opt.restart, "restart", "", "上传后执行的远程重启命令(留空则不重启)")
flag.StringVar(&opt.execCmd, "exec", "", "只执行远程命令并输出结果,不做同步")
flag.StringVar(&opt.excludes, "exclude", "", "额外排除的文件名(逗号分隔,支持 * 通配)")
flag.BoolVar(&opt.dryRun, "dry-run", false, "只比对不上传")
flag.BoolVar(&opt.yes, "yes", false, "跳过上传前的确认")
flag.BoolVar(&opt.del, "delete", false, "同时删除远端存在、本地已删除的文件")
flag.BoolVar(&opt.quiet, "quiet", false, "精简输出")
flag.Parse()
if opt.pass == "" {
opt.pass = os.Getenv("DEPLOY_PASS")
}
if opt.pass == "" && opt.keyFile == "" {
fmt.Println("[错误] 未提供密码:请用 -pass 或环境变量 DEPLOY_PASS 指定")
os.Exit(2)
}
if err := run(opt); err != nil {
if errors.Is(err, errCancelled) {
os.Exit(3) // 取消不是失败,用独立退出码告知调用方
}
fmt.Printf("[失败] %v\n", err)
os.Exit(1)
}
}
// ============================ 主流程 ============================
func run(opt options) error {
localRoot, err := filepath.Abs(opt.local)
if err != nil {
return fmt.Errorf("解析本地目录失败: %w", err)
}
if _, err := os.Stat(localRoot); err != nil {
return fmt.Errorf("本地目录不存在: %s", localRoot)
}
excludeDirs := append([]string{}, defaultExcludeDirs...)
excludeFiles := append([]string{}, defaultExcludeFiles...)
for _, extra := range strings.Split(opt.excludes, ",") {
extra = strings.TrimSpace(extra)
if extra == "" {
continue
}
if strings.ContainsAny(extra, "/\\") {
excludeDirs = append(excludeDirs, extra)
} else {
excludeFiles = append(excludeFiles, extra)
}
}
fmt.Printf("本地目录 : %s\n", localRoot)
fmt.Printf("远程目录 : %s@%s:%s\n", opt.user, opt.host, opt.dir)
client, err := dial(opt, localRoot)
if err != nil {
return err
}
defer client.Close()
// 只执行远程命令(不比对、不上传)
if strings.TrimSpace(opt.execCmd) != "" {
fmt.Printf("\n[执行] %s\n", opt.execCmd)
out, xerr := runRemote(client, opt.execCmd)
if strings.TrimSpace(out) != "" {
fmt.Println(strings.TrimSpace(out))
}
if xerr != nil {
return fmt.Errorf("远程命令失败: %w", xerr)
}
fmt.Println("[完成]")
return nil
}
// 1. 远端清单
remote, err := remoteInventory(client, opt.dir)
if err != nil {
return err
}
fmt.Printf("远端文件 : %d 个\n", len(remote))
// 2. 本地清单
local, err := localInventory(localRoot, excludeDirs, excludeFiles)
if err != nil {
return err
}
fmt.Printf("本地文件 : %d 个(已排除日志 / 可执行文件等)\n", len(local))
// 3. 比对
var changed, added []string
remoteOnly := make([]string, 0)
for rel, lh := range local {
rh, ok := remote[rel]
if !ok {
added = append(added, rel)
continue
}
if lh.MD5 != rh.MD5 {
changed = append(changed, rel)
}
}
for rel := range remote {
if _, ok := local[rel]; !ok {
remoteOnly = append(remoteOnly, rel)
}
}
sort.Strings(changed)
sort.Strings(added)
sort.Strings(remoteOnly)
fmt.Println()
if len(added) > 0 {
fmt.Printf("新增文件 %d 个:\n", len(added))
printList("+", added)
}
if len(changed) > 0 {
fmt.Printf("内容有变化 %d 个:\n", len(changed))
printList("M", changed)
}
if len(remoteOnly) > 0 {
fmt.Printf("远端存在、本地没有 %d 个%s\n", len(remoteOnly), map[bool]string{true: "(将删除)", false: "(保留,不删除)"}[opt.del])
if opt.del {
printList("-", remoteOnly)
}
}
fmt.Printf("\n未变化 %d 个(跳过)\n", len(local)-len(changed)-len(added))
toUpload := append(append([]string{}, changed...), added...)
if len(toUpload) == 0 && len(remoteOnly) == 0 {
fmt.Println("\n[完成] 没有需要更新的文件。")
return nil
}
if opt.dryRun {
fmt.Println("\n[试运行] 未实际上传(-dry-run)。")
return nil
}
if !opt.yes && !confirm(fmt.Sprintf("确认上传 %d 个文件到 %s?", len(toUpload), opt.dir)) {
fmt.Println("已取消。")
return errCancelled
}
// 4. 上传
if len(toUpload) > 0 {
fmt.Printf("\n[上传] 打包 %d 个文件并传输...\n", len(toUpload))
if err := upload(client, opt, localRoot, toUpload); err != nil {
return err
}
// 5. 复核
fmt.Println("[复核] 重新读取远端指纹...")
after, err := remoteInventory(client, opt.dir)
if err != nil {
return err
}
bad := make([]string, 0)
for _, rel := range toUpload {
rh, ok := after[rel]
if !ok {
bad = append(bad, rel+"(远端缺失)")
continue
}
if lh := local[rel]; lh.MD5 != rh.MD5 {
bad = append(bad, rel+"(指纹不一致)")
}
}
if len(bad) > 0 {
for _, b := range bad {
fmt.Println(" ! " + b)
}
return fmt.Errorf("%d 个文件上传后校验不通过", len(bad))
}
fmt.Printf("[复核] %d 个文件指纹一致 ✓\n", len(toUpload))
}
// 6. 删除远端多余文件
if opt.del && len(remoteOnly) > 0 {
fmt.Printf("\n[删除] 清理远端 %d 个多余文件...\n", len(remoteOnly))
if err := removeRemote(client, opt.dir, remoteOnly); err != nil {
return err
}
}
// 7. 重启
if strings.TrimSpace(opt.restart) != "" {
fmt.Printf("\n[重启] 执行: %s\n", opt.restart)
out, err := runRemote(client, opt.restart)
if err != nil {
return fmt.Errorf("重启失败: %w(远端输出: %s)", err, strings.TrimSpace(out))
}
if strings.TrimSpace(out) != "" {
fmt.Println(out)
}
fmt.Println("[重启] 完成")
} else {
fmt.Println("\n[提示] 未配置重启命令,服务不会自动重启;如需生效请手动重启(如 -restart \"supervisorctl restart <服务名>\")")
}
fmt.Println("\n[完成] 同步结束。")
return nil
}
// ============================ 文件清单 ============================
type fileInfo struct {
MD5 string
Size int64
Path string
}
// localInventory 遍历本地目录,计算 MD5
func localInventory(root string, excludeDirs, excludeFiles []string) (map[string]fileInfo, error) {
out := make(map[string]fileInfo)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
if rel == "." {
return nil
}
name := info.Name()
if info.IsDir() {
if matchAny(name, excludeDirs) {
return filepath.SkipDir
}
return nil
}
if !info.Mode().IsRegular() {
return nil // 跳过符号链接 / 设备文件等
}
if matchAny(name, excludeFiles) {
return nil
}
sum, size, herr := fileMD5(path)
if herr != nil {
return herr
}
out[rel] = fileInfo{MD5: sum, Size: size, Path: path}
return nil
})
return out, err
}
// remoteInventory 远端 md5sum 清单
func remoteInventory(client *ssh.Client, dir string) (map[string]fileInfo, error) {
res := make(map[string]fileInfo)
// 目录不存在时按空目录处理(上传阶段会创建)
if out, err := runRemote(client, fmt.Sprintf("[ -d %s ] && echo yes || echo no", shQuote(dir))); err == nil {
if !strings.Contains(out, "yes") {
fmt.Printf("[提示] 远端目录 %s 不存在,将新建\n", dir)
return res, nil
}
}
cmd := fmt.Sprintf("cd %s && find . -type f -exec md5sum {} + 2>/dev/null", shQuote(dir))
out, err := runRemote(client, cmd)
if err != nil {
return nil, fmt.Errorf("读取远端清单失败: %w(输出: %s)", err, strings.TrimSpace(out))
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimRight(line, "\r")
if strings.TrimSpace(line) == "" {
continue
}
// 格式: <md5> <path>
idx := strings.Index(line, " ")
if idx < 0 {
continue
}
sum := strings.TrimSpace(line[:idx])
rel := strings.TrimSpace(line[idx+2:])
rel = strings.TrimPrefix(rel, "./")
if rel == "" {
continue
}
res[rel] = fileInfo{MD5: sum, Path: rel}
}
if len(res) == 0 {
fmt.Printf("[提示] 远端目录 %s 目前是空的\n", dir)
}
return res, nil
}
func fileMD5(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := md5.New()
size, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), size, nil
}
// matchAny 名称是否命中排除规则(支持 * 通配)
func matchAny(name string, patterns []string) bool {
for _, p := range patterns {
if p == name {
return true
}
if ok, err := filepath.Match(p, name); err == nil && ok {
return true
}
}
return false
}
// printList 打印文件清单(最多 20 条,避免刷屏)
func printList(mark string, files []string) {
const maxShow = 20
for i, f := range files {
if i >= maxShow {
fmt.Printf(" …… 其余 %d 个省略\n", len(files)-maxShow)
break
}
fmt.Printf(" %s %s\n", mark, f)
}
}
// ============================ 上传 ============================
// upload 把差异文件打成 tar 流,通过 SSH 管道在远端解开
func upload(client *ssh.Client, opt options, localRoot string, files []string) error {
buf, err := buildTar(localRoot, files)
if err != nil {
return err
}
// 远端目录不存在时先创建(tar 解包要求目标目录已存在)
if out, merr := runRemote(client, fmt.Sprintf("mkdir -p %s", shQuote(opt.dir))); merr != nil {
return fmt.Errorf("创建远端目录失败: %w(%s)", merr, strings.TrimSpace(out))
}
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
var stderr bytes.Buffer
session.Stderr = &stderr
stdin, err := session.StdinPipe()
if err != nil {
return err
}
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
// 远端解包(tar 会自动创建缺失的目录)
if err := session.Start(fmt.Sprintf("tar -xf - -C %s", shQuote(opt.dir))); err != nil {
return err
}
go func() {
_, _ = stdin.Write(buf)
_ = stdin.Close()
}()
out, _ := io.ReadAll(stdout)
if err := session.Wait(); err != nil {
return fmt.Errorf("远端解包失败: %w(stderr: %s stdout: %s)", err, strings.TrimSpace(stderr.String()), strings.TrimSpace(string(out)))
}
// 属主对齐目标目录(服务器上多为 www:www,避免解包后变成 root:root)
if err := alignOwner(client, opt.dir, files); err != nil {
fmt.Printf("[提示] 属主修正跳过: %v\n", err)
}
return nil
}
// alignOwner 把上传文件的属主改成目标目录的属主
func alignOwner(client *ssh.Client, dir string, files []string) error {
if len(files) == 0 {
return nil
}
quoted := make([]string, 0, len(files))
for _, rel := range files {
quoted = append(quoted, shQuote(strings.TrimRight(dir, "/")+"/"+rel))
}
cmd := fmt.Sprintf("ref=$(stat -c '%%U:%%G' %s) && for f in %s; do chown -R \"$ref\" \"$f\"; done",
shQuote(dir), strings.Join(quoted, " "))
out, err := runRemote(client, cmd)
if err != nil {
return fmt.Errorf("%w(%s)", err, strings.TrimSpace(out))
}
return nil
}
// buildTar 生成待上传文件的 tar 包(含目录条目,保证远端目录结构存在)
func buildTar(localRoot string, files []string) ([]byte, error) {
dirs := make(map[string]bool)
for _, rel := range files {
parts := strings.Split(rel, "/")
for i := 1; i < len(parts); i++ {
dirs[strings.Join(parts[:i], "/")+"/"] = true
}
}
dirList := make([]string, 0, len(dirs))
for d := range dirs {
dirList = append(dirList, d)
}
sort.Strings(dirList)
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
for _, d := range dirList {
if err := tw.WriteHeader(&tar.Header{
Name: d,
Typeflag: tar.TypeDir,
Mode: 0o755,
}); err != nil {
return nil, err
}
}
for _, rel := range files {
full := filepath.Join(localRoot, filepath.FromSlash(rel))
info, err := os.Stat(full)
if err != nil {
return nil, err
}
f, err := os.Open(full)
if err != nil {
return nil, err
}
if err := tw.WriteHeader(&tar.Header{
Name: rel,
Typeflag: tar.TypeReg,
Mode: fileMode(rel),
Size: info.Size(),
ModTime: info.ModTime(),
}); err != nil {
f.Close()
return nil, err
}
if _, err := io.Copy(tw, f); err != nil {
f.Close()
return nil, err
}
f.Close()
}
if err := tw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// fileMode 归一化文件权限:脚本可执行,其它一律 0644
// (Windows 本地读到的权限恒为 0666,直接带上会让服务器上的文件变成人人可写)
func fileMode(rel string) int64 {
switch strings.ToLower(filepath.Ext(rel)) {
case ".sh", ".bash", ".py", ".pl", ".rb":
return 0o755
}
return 0o644
}
// removeRemote 删除远端多余文件
func removeRemote(client *ssh.Client, dir string, files []string) error {
for _, rel := range files {
cmd := fmt.Sprintf("rm -f -- %s", shQuote(strings.TrimRight(dir, "/")+"/"+rel))
if out, err := runRemote(client, cmd); err != nil {
return fmt.Errorf("删除 %s 失败: %w(%s)", rel, err, strings.TrimSpace(out))
}
}
return nil
}
// ============================ SSH ============================
func dial(opt options, localRoot string) (*ssh.Client, error) {
var auth []ssh.AuthMethod
if opt.keyFile != "" {
key, err := os.ReadFile(opt.keyFile)
if err != nil {
return nil, fmt.Errorf("读取私钥失败: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
auth = append(auth, ssh.PublicKeys(signer))
} else {
auth = append(auth, ssh.Password(opt.pass))
}
cfg := &ssh.ClientConfig{
User: opt.user,
Auth: auth,
HostKeyCallback: hostKeyChecker(localRoot),
Timeout: 20 * time.Second,
}
addr := fmt.Sprintf("%s:%d", opt.host, 22)
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, fmt.Errorf("连接服务器失败: %w", err)
}
return client, nil
}
// hostKeyChecker 首次连接记录指纹,之后变化则拒绝连接(防止服务器被替换)
func hostKeyChecker(localRoot string) ssh.HostKeyCallback {
store := filepath.Join(localRoot, ".deploy_known_hosts")
known := map[string]string{}
if data, err := os.ReadFile(store); err == nil {
_ = json.Unmarshal(data, &known)
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
fp := ssh.FingerprintSHA256(key)
if old, ok := known[hostname]; ok {
if old != fp {
return fmt.Errorf("服务器主机密钥已变化(%s 原 %s 现 %s),请确认后删除 %s 再执行", hostname, old, fp, store)
}
return nil
}
known[hostname] = fp
if data, err := json.MarshalIndent(known, "", " "); err == nil {
_ = os.WriteFile(store, data, 0o644)
}
fmt.Printf("[安全] 首次连接,已记录服务器指纹 %s(%s)\n", fp, store)
return nil
}
}
// runRemote 执行远程命令并返回输出
func runRemote(client *ssh.Client, cmd string) (string, error) {
session, err := client.NewSession()
if err != nil {
return "", err
}
defer session.Close()
var out, errBuf bytes.Buffer
session.Stdout = &out
session.Stderr = &errBuf
err = session.Run(cmd)
if err != nil {
return out.String() + errBuf.String(), err
}
return out.String(), nil
}
// ============================ 辅助 ============================
// shQuote 单引号包裹(内部单引号转义),避免命令注入与路径空格问题
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// confirm 交互确认
func confirm(prompt string) bool {
fmt.Printf("%s [y/N]: ", prompt)
var answer string
_, err := fmt.Scanln(&answer)
if err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}