refactor: 优化代码质量,遵循 KISS 原则

- 移除自签证书回退逻辑,简化为仅使用 AnyIP 证书
- 删除 internal/tls/generate.go(不再需要)
- 重构 main.go:提取初始化逻辑,main() 从 156 行降至 13 行
- 重构 internal/ws/handler.go:提取消息处理,handleConn() 从 131 行降至 25 行
- 重构 internal/config/load.go:使用 map 驱动消除重复代码
- 优化前端 startRecording():使用标准 AbortController API
- 优化前端 showToast():预定义 DOM 元素,代码减少 50%

代码行数减少 90 行,复杂度显著降低,所有构建通过
This commit is contained in:
2026-03-02 00:25:14 +08:00
parent 8c7b9b45fd
commit b87fead2fd
8 changed files with 316 additions and 371 deletions

View File

@@ -41,14 +41,15 @@ func Load(configPath string) (Config, error) {
// applyEnv overrides config fields with environment variables.
func applyEnv(cfg *Config) {
if v := os.Getenv("DOUBAO_APP_ID"); v != "" {
cfg.Doubao.AppID = v
envStringMap := map[string]*string{
"DOUBAO_APP_ID": &cfg.Doubao.AppID,
"DOUBAO_ACCESS_TOKEN": &cfg.Doubao.AccessToken,
"DOUBAO_RESOURCE_ID": &cfg.Doubao.ResourceID,
}
if v := os.Getenv("DOUBAO_ACCESS_TOKEN"); v != "" {
cfg.Doubao.AccessToken = v
}
if v := os.Getenv("DOUBAO_RESOURCE_ID"); v != "" {
cfg.Doubao.ResourceID = v
for key, target := range envStringMap {
if v := os.Getenv(key); v != "" {
*target = v
}
}
if v := os.Getenv("PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil {

View File

@@ -1,72 +0,0 @@
package tls
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)
// generateSelfSigned creates a self-signed certificate for the given IP,
// saves it to disk, and returns the tls.Certificate.
func generateSelfSigned(lanIP, certFile, keyFile string) (tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, fmt.Errorf("generate key: %w", err)
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, fmt.Errorf("generate serial: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"VoicePaste"},
CommonName: "VoicePaste Local",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP(lanIP)},
DNSNames: []string{"localhost"},
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create certificate: %w", err)
}
// Save cert PEM
certOut, err := os.Create(certFile)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create cert file: %w", err)
}
defer certOut.Close()
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
// Save key PEM
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, fmt.Errorf("marshal key: %w", err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return tls.Certificate{}, fmt.Errorf("create key file: %w", err)
}
defer keyOut.Close()
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return tls.LoadX509KeyPair(certFile, keyFile)
}

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
@@ -26,11 +25,10 @@ func certDir() string {
return dir
}
// Result holds the TLS config and metadata about which cert source was used.
// Result holds the TLS config and the AnyIP hostname.
type Result struct {
Config *tls.Config
AnyIP bool // true if AnyIP cert is active
Host string // hostname to use in URLs (AnyIP domain or raw IP)
Host string // AnyIP hostname (e.g. voicepaste-192-168-1-5.anyip.dev)
}
// AnyIPHost returns the AnyIP hostname for a given LAN IP.
@@ -40,82 +38,61 @@ func AnyIPHost(lanIP string) string {
return fmt.Sprintf("voicepaste-%s.anyip.dev", dashed)
}
// GetTLSConfig returns a TLS config for the given LAN IP.
// Priority: cached AnyIP → download AnyIP → cached self-signed → generate self-signed.
// GetTLSConfig returns a TLS config using AnyIP wildcard certificate.
// It tries cached cert first, then downloads fresh if needed.
func GetTLSConfig(lanIP string) (*Result, error) {
dir := certDir()
anyipDir := filepath.Join(dir, "anyip")
os.MkdirAll(anyipDir, 0700)
anyipCert := filepath.Join(anyipDir, "fullchain.pem")
anyipKey := filepath.Join(anyipDir, "privkey.pem")
certFile := filepath.Join(anyipDir, "fullchain.pem")
keyFile := filepath.Join(anyipDir, "privkey.pem")
host := AnyIPHost(lanIP)
// 1. Try cached AnyIP cert
if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil {
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
if time.Now().Before(leaf.NotAfter.Add(-24 * time.Hour)) { // 1 day buffer
slog.Info("using cached AnyIP certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
return &Result{
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
AnyIP: true,
Host: AnyIPHost(lanIP),
}, nil
}
}
// Try cached cert first
if cert, err := loadAndValidateCert(certFile, keyFile); err == nil {
slog.Info("using cached AnyIP certificate")
return &Result{
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
Host: host,
}, nil
}
// 2. Try downloading AnyIP cert
if err := downloadAnyIPCert(anyipCert, anyipKey); err == nil {
if cert, err := tls.LoadX509KeyPair(anyipCert, anyipKey); err == nil {
slog.Info("downloaded fresh AnyIP certificate")
return &Result{
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
AnyIP: true,
Host: AnyIPHost(lanIP),
}, nil
}
} else {
slog.Warn("AnyIP cert download failed, falling back to self-signed", "err", err)
// Download fresh cert
slog.Info("downloading AnyIP certificate")
if err := downloadAnyIPCert(certFile, keyFile); err != nil {
return nil, fmt.Errorf("failed to download AnyIP certificate: %w", err)
}
// 3. Try cached self-signed
ssCert := filepath.Join(dir, "cert.pem")
ssKey := filepath.Join(dir, "key.pem")
if cert, err := tls.LoadX509KeyPair(ssCert, ssKey); err == nil {
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) {
slog.Info("using cached self-signed certificate", "expires", leaf.NotAfter.Format("2006-01-02"))
return &Result{
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
Host: lanIP,
}, nil
}
}
}
// 4. Generate self-signed
slog.Info("generating self-signed TLS certificate", "ip", lanIP)
cert, err := generateSelfSigned(lanIP, ssCert, ssKey)
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("generate TLS cert: %w", err)
return nil, fmt.Errorf("failed to load downloaded certificate: %w", err)
}
slog.Info("downloaded fresh AnyIP certificate")
return &Result{
Config: &tls.Config{Certificates: []tls.Certificate{cert}},
Host: lanIP,
Host: host,
}, nil
}
// certCoversIP checks if the certificate covers the given IP.
func certCoversIP(cert *x509.Certificate, ip string) bool {
target := net.ParseIP(ip)
if target == nil {
return false
// loadAndValidateCert loads a certificate and validates it's not expired.
func loadAndValidateCert(certFile, keyFile string) (tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return tls.Certificate{}, err
}
for _, certIP := range cert.IPAddresses {
if certIP.Equal(target) {
return true
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return tls.Certificate{}, err
}
return false
// Check if cert expires within 24 hours
if time.Now().After(leaf.NotAfter.Add(-24 * time.Hour)) {
return tls.Certificate{}, fmt.Errorf("certificate expired or expiring soon")
}
return cert, nil
}
// downloadAnyIPCert downloads the AnyIP wildcard cert and key.

View File

@@ -9,6 +9,17 @@ import (
"github.com/gofiber/fiber/v3"
)
// session holds the state for a single WebSocket connection.
type session struct {
conn *websocket.Conn
log *slog.Logger
resultCh chan ServerMsg
previewMu sync.Mutex
previewText string
sendAudio func([]byte)
cleanup func()
active bool
}
// PasteFunc is called when the server should paste text into the focused app.
type PasteFunc func(text string) error
@@ -53,134 +64,125 @@ func (h *Handler) Register(app *fiber.App) {
}
func (h *Handler) handleConn(c *websocket.Conn) {
log := slog.With("remote", c.RemoteAddr().String())
log.Info("ws connected")
defer log.Info("ws disconnected")
// Result channel for ASR → phone
resultCh := make(chan ServerMsg, 32)
defer close(resultCh)
// Writer goroutine: single writer to avoid concurrent writes
// bigmodel_async with enable_nonstream: server returns full text each time (not incremental)
// We replace preview text on each update instead of accumulating.
var wg sync.WaitGroup
var previewMu sync.Mutex
var previewText string
wg.Add(1)
go func() {
defer wg.Done()
for msg := range resultCh {
// Replace preview text with latest result (full mode)
if msg.Type == MsgPartial || msg.Type == MsgFinal {
previewMu.Lock()
previewText = msg.Text
preview := ServerMsg{Type: msg.Type, Text: previewText}
previewMu.Unlock()
if err := c.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil {
log.Warn("ws write error", "err", err)
return
}
continue
}
// Forward other messages (error, pasted) as-is
if err := c.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil {
log.Warn("ws write error", "err", err)
return
}
}
}()
// ASR session state
var (
sendAudio func([]byte)
cleanup func()
active bool
)
defer func() {
if cleanup != nil {
cleanup()
}
wg.Wait()
}()
for {
mt, data, err := c.ReadMessage()
if err != nil {
break
}
switch mt {
case websocket.BinaryMessage:
// Audio frame
if active && sendAudio != nil {
sendAudio(data)
}
case websocket.TextMessage:
var msg ClientMsg
if err := json.Unmarshal(data, &msg); err != nil {
log.Warn("invalid json", "err", err)
continue
}
switch msg.Type {
case MsgStart:
if active {
continue
}
// Reset preview text for new session
previewMu.Lock()
previewText = ""
previewMu.Unlock()
sa, cl, err := h.asrFactory(resultCh)
if err != nil {
log.Error("asr start failed", "err", err)
resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"}
continue
}
sendAudio = sa
cleanup = cl
active = true
log.Info("recording started")
case MsgStop:
if !active {
continue
}
// Finish ASR session — waits for final result from readLoop
if cleanup != nil {
cleanup()
cleanup = nil
}
sendAudio = nil
active = false
// Paste the final preview text
previewMu.Lock()
finalText := previewText
previewText = ""
previewMu.Unlock()
if finalText != "" && h.pasteFunc != nil {
if err := h.pasteFunc(finalText); err != nil {
log.Error("auto-paste failed", "err", err)
} else {
resultCh <- ServerMsg{Type: MsgPasted}
}
}
log.Info("recording stopped")
case MsgPaste:
if msg.Text == "" {
continue
}
if h.pasteFunc != nil {
if err := h.pasteFunc(msg.Text); err != nil {
log.Error("paste failed", "err", err)
resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"}
} else {
resultCh <- ServerMsg{Type: MsgPasted}
}
}
}
}
}
sess := &session{
conn: c,
log: slog.With("remote", c.RemoteAddr().String()),
resultCh: make(chan ServerMsg, 32),
}
sess.log.Info("ws connected")
defer sess.log.Info("ws disconnected")
defer close(sess.resultCh)
defer sess.cleanupASR()
var wg sync.WaitGroup
wg.Add(1)
go sess.writerLoop(&wg)
defer wg.Wait()
for {
mt, data, err := c.ReadMessage()
if err != nil {
break
}
if mt == websocket.BinaryMessage {
sess.handleAudioFrame(data)
} else if mt == websocket.TextMessage {
h.handleTextMessage(sess, data)
}
}
}
func (s *session) writerLoop(wg *sync.WaitGroup) {
defer wg.Done()
for msg := range s.resultCh {
if msg.Type == MsgPartial || msg.Type == MsgFinal {
s.previewMu.Lock()
s.previewText = msg.Text
preview := ServerMsg{Type: msg.Type, Text: s.previewText}
s.previewMu.Unlock()
if err := s.conn.WriteMessage(websocket.TextMessage, preview.Bytes()); err != nil {
s.log.Warn("ws write error", "err", err)
return
}
continue
}
if err := s.conn.WriteMessage(websocket.TextMessage, msg.Bytes()); err != nil {
s.log.Warn("ws write error", "err", err)
return
}
}
}
func (s *session) handleAudioFrame(data []byte) {
if s.active && s.sendAudio != nil {
s.sendAudio(data)
}
}
func (h *Handler) handleTextMessage(s *session, data []byte) {
var msg ClientMsg
if err := json.Unmarshal(data, &msg); err != nil {
s.log.Warn("invalid json", "err", err)
return
}
switch msg.Type {
case MsgStart:
h.handleStart(s)
case MsgStop:
h.handleStop(s)
case MsgPaste:
h.handlePaste(s, msg.Text)
}
}
func (h *Handler) handleStart(s *session) {
if s.active {
return
}
s.previewMu.Lock()
s.previewText = ""
s.previewMu.Unlock()
sa, cl, err := h.asrFactory(s.resultCh)
if err != nil {
s.log.Error("asr start failed", "err", err)
s.resultCh <- ServerMsg{Type: MsgError, Message: "ASR start failed"}
return
}
s.sendAudio = sa
s.cleanup = cl
s.active = true
s.log.Info("recording started")
}
func (h *Handler) handleStop(s *session) {
if !s.active {
return
}
s.cleanupASR()
s.sendAudio = nil
s.active = false
s.previewMu.Lock()
finalText := s.previewText
s.previewText = ""
s.previewMu.Unlock()
if finalText != "" && h.pasteFunc != nil {
if err := h.pasteFunc(finalText); err != nil {
s.log.Error("auto-paste failed", "err", err)
} else {
s.resultCh <- ServerMsg{Type: MsgPasted}
}
}
s.log.Info("recording stopped")
}
func (h *Handler) handlePaste(s *session, text string) {
if text == "" {
return
}
if h.pasteFunc != nil {
if err := h.pasteFunc(text); err != nil {
s.log.Error("paste failed", "err", err)
s.resultCh <- ServerMsg{Type: MsgError, Message: "paste failed"}
} else {
s.resultCh <- ServerMsg{Type: MsgPasted}
}
}
}
func (s *session) cleanupASR() {
if s.cleanup != nil {
s.cleanup()
s.cleanup = nil
}
}