Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
package db
import (
"database/sql"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
mysql "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
"ymhut-box/server/feedback-mailer/internal/config"
)
func TestOpenMigratesLegacyFeedbackColumns(t *testing.T) {
dir := t.TempDir()
storage := filepath.Join(dir, "storage")
if err := os.MkdirAll(storage, 0o750); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(storage, "feedback.sqlite")
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = conn.Exec(`CREATE TABLE feedbacks (
code TEXT PRIMARY KEY,
received_at TEXT NOT NULL,
title TEXT NOT NULL,
type TEXT NOT NULL,
severity TEXT NOT NULL,
contact TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL,
package_path TEXT NOT NULL,
package_sha256 TEXT NOT NULL,
remote_addr TEXT NOT NULL,
summary_text TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if closeErr := conn.Close(); closeErr != nil {
t.Fatal(closeErr)
}
if err != nil {
t.Fatal(err)
}
cfg := config.Default(dir)
cfg.StorageDir = storage
cfg.DatabasePath = dbPath
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
for _, column := range []string{"public_reply", "included_files", "mail_sent", "encrypted_package_path", "plain_package_sha256", "assignee", "due_at", "sla_level", "source_channel", "risk_score", "resolution"} {
if !hasColumn(t, store.db, "feedbacks", column) {
t.Fatalf("expected migrated column %q", column)
}
}
for _, table := range []string{"feedback_comments", "feedback_tags", "audit_logs", "webhook_deliveries"} {
if !hasTable(t, store.db, table) {
t.Fatalf("expected migrated table %q", table)
}
}
if mode := store.WALMode(); mode != "wal" {
t.Fatalf("expected wal mode, got %q", mode)
}
}
func TestTicketExtensionsRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
record := FeedbackRecord{
Code: "FB-20260606-ABC123",
ReceivedAt: Now(),
Title: "Crash",
Type: "issue",
Severity: "major",
Contact: "dev@example.com",
Body: "Steps",
Status: "new",
StatusDetail: "received",
PackagePath: "a.zip",
EncryptedPackagePath: "a.ymfb",
PackageSha256: strings.Repeat("a", 64),
PlainPackageSha256: strings.Repeat("b", 64),
RemoteAddr: "127.0.0.1",
SummaryText: "summary",
IncludedFiles: "feedback.json",
UpdatedAt: Now(),
LastActivityAt: Now(),
Tags: []string{"crash", "UI"},
}
if err := store.InsertFeedback(record); err != nil {
t.Fatal(err)
}
if err := store.UpdateFeedback(record.Code, FeedbackUpdate{
Status: "investigating",
Category: "issue",
Priority: "major",
StatusDetail: "checking",
Assignee: "alice",
SLALevel: "elevated",
Note: "internal",
PublicReply: "reply",
Actor: "alice",
Tags: []string{"crash", "priority"},
}); err != nil {
t.Fatal(err)
}
if _, err := store.InsertComment(FeedbackComment{FeedbackCode: record.Code, Author: "alice", Body: "comment", Internal: true}); err != nil {
t.Fatal(err)
}
if err := store.InsertAudit(AuditLog{Actor: "alice", Type: "feedback.updated", Target: record.Code, Message: "updated"}); err != nil {
t.Fatal(err)
}
if _, err := store.InsertWebhookDelivery(WebhookDelivery{WebhookName: "ops", Event: "feedback.updated", Status: "pending"}); err != nil {
t.Fatal(err)
}
detail, err := store.GetFeedbackDetail(record.Code)
if err != nil {
t.Fatal(err)
}
if detail.Assignee != "alice" || detail.SLALevel != "elevated" || len(detail.Comments) != 1 || len(detail.Tags) != 2 {
t.Fatalf("unexpected detail: %+v", detail)
}
page, err := store.ListFeedbacks(1, 20, FeedbackFilters{Assignee: "alice", Tag: "priority", SLA: "elevated"})
if err != nil {
t.Fatal(err)
}
if page.Total != 1 {
t.Fatalf("expected filtered ticket, got %+v", page)
}
}
func TestApplyDatabaseConfigSwitchesSQLitePath(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
next := cfg.Database
next.Provider = "sqlite"
next.SQLitePath = filepath.Join(dir, "storage", "next.sqlite")
if err := store.ApplyDatabaseConfig(next); err != nil {
t.Fatal(err)
}
if store.Status().ActiveProvider != "sqlite" {
t.Fatalf("expected sqlite active provider, got %+v", store.Status())
}
if _, err := os.Stat(next.SQLitePath); err != nil {
t.Fatalf("expected new sqlite database at %s: %v", next.SQLitePath, err)
}
if !hasTable(t, store.DB(), "feedbacks") {
t.Fatal("expected migrated feedbacks table on switched sqlite database")
}
}
func TestDatabaseDSNEncodesRemoteCredentials(t *testing.T) {
dir := t.TempDir()
sqliteDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: "storage/feedback.sqlite",
}, dir)
if err != nil {
t.Fatal(err)
}
if sqliteDSN != filepath.Join(dir, "storage", "feedback.sqlite") {
t.Fatalf("relative sqlite path should resolve from service root, got %q", sqliteDSN)
}
mysqlDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "mysql",
Host: "db.example.com",
Port: 3307,
Name: "feedback_db",
User: "feedback_user",
Password: "p@ss/word",
}, dir)
if err != nil {
t.Fatal(err)
}
parsedMySQL, err := mysql.ParseDSN(mysqlDSN)
if err != nil {
t.Fatal(err)
}
if parsedMySQL.User != "feedback_user" || parsedMySQL.Passwd != "p@ss/word" || parsedMySQL.DBName != "feedback_db" || parsedMySQL.Addr != "db.example.com:3307" {
t.Fatalf("mysql DSN did not preserve settings: %+v", parsedMySQL)
}
postgresDSN, err := databaseDSN(config.DatabaseConfig{
Provider: "postgres",
Host: "pg.example.com",
Port: 5433,
Name: "feedback/db",
User: "feedback:user",
Password: "p@ss/word",
SSLMode: "require",
}, dir)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(postgresDSN)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != "postgres" || parsed.Host != "pg.example.com:5433" || parsed.Query().Get("sslmode") != "require" {
t.Fatalf("postgres DSN was not safely formatted: %s", postgresDSN)
}
if password, ok := parsed.User.Password(); !ok || password != "p@ss/word" || parsed.User.Username() != "feedback:user" {
t.Fatalf("postgres credentials were not preserved: %s", postgresDSN)
}
}
func hasColumn(t *testing.T, conn *sql.DB, table, column string) bool {
t.Helper()
rows, err := conn.Query(`PRAGMA table_info(` + table + `)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dflt sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
t.Fatal(err)
}
if name == column {
return true
}
}
return false
}
func hasTable(t *testing.T, conn *sql.DB, table string) bool {
t.Helper()
row := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table)
var name string
if err := row.Scan(&name); err != nil {
return false
}
return name == table
}
@@ -0,0 +1,263 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"ymhut-box/server/feedback-mailer/internal/config"
)
type dialect struct {
name string
driverName string
}
func dialectFor(provider string) dialect {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "mysql":
return dialect{name: "mysql", driverName: "mysql"}
case "postgres", "pgsql":
return dialect{name: "postgres", driverName: "pgx"}
default:
return dialect{name: "sqlite", driverName: "sqlite"}
}
}
func (d dialect) rebind(query string) string {
if d.name != "postgres" {
return query
}
var builder strings.Builder
index := 1
inSingle := false
for i := 0; i < len(query); i++ {
ch := query[i]
if ch == '\'' {
inSingle = !inSingle
builder.WriteByte(ch)
continue
}
if ch == '?' && !inSingle {
builder.WriteByte('$')
builder.WriteString(strconv.Itoa(index))
index++
continue
}
builder.WriteByte(ch)
}
return builder.String()
}
func (d dialect) boolValue(value bool) int {
if value {
return 1
}
return 0
}
func (d dialect) insertIgnore(table string, columns, conflict []string) string {
placeholders := make([]string, len(columns))
for i := range placeholders {
placeholders[i] = "?"
}
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
switch d.name {
case "mysql":
return strings.Replace(base, "INSERT INTO", "INSERT IGNORE INTO", 1)
case "postgres":
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO NOTHING"
default:
return strings.Replace(base, "INSERT INTO", "INSERT OR IGNORE INTO", 1)
}
}
func (d dialect) upsert(table string, columns, conflict []string) string {
placeholders := make([]string, len(columns))
for i := range placeholders {
placeholders[i] = "?"
}
base := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", table, strings.Join(columns, ", "), strings.Join(placeholders, ", "))
updateColumns := make([]string, 0, len(columns))
conflictSet := map[string]bool{}
for _, column := range conflict {
conflictSet[column] = true
}
for _, column := range columns {
if conflictSet[column] {
continue
}
switch d.name {
case "mysql":
updateColumns = append(updateColumns, fmt.Sprintf("%s = VALUES(%s)", column, column))
case "postgres":
updateColumns = append(updateColumns, fmt.Sprintf("%s = EXCLUDED.%s", column, column))
default:
updateColumns = append(updateColumns, fmt.Sprintf("%s = excluded.%s", column, column))
}
}
if len(updateColumns) == 0 {
return d.insertIgnore(table, columns, conflict)
}
switch d.name {
case "mysql":
return base + " ON DUPLICATE KEY UPDATE " + strings.Join(updateColumns, ", ")
case "postgres":
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
default:
return base + " ON CONFLICT (" + strings.Join(conflict, ", ") + ") DO UPDATE SET " + strings.Join(updateColumns, ", ")
}
}
func (d dialect) textType() string {
return "TEXT"
}
func (d dialect) idType() string {
switch d.name {
case "mysql":
return "BIGINT PRIMARY KEY AUTO_INCREMENT"
case "postgres":
return "BIGSERIAL PRIMARY KEY"
default:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
}
}
func (d dialect) columnDefault(def string) string {
def = strings.ReplaceAll(def, `DEFAULT ""`, `DEFAULT ''`)
if d.name == "mysql" {
def = strings.ReplaceAll(def, `TEXT NOT NULL DEFAULT ''`, `VARCHAR(3000) NOT NULL DEFAULT ''`)
}
return def
}
func openSQLDatabase(cfg config.DatabaseConfig, baseDir string) (*sql.DB, dialect, error) {
d := dialectFor(cfg.Provider)
dsn, err := databaseDSN(cfg, baseDir)
if err != nil {
return nil, d, err
}
if d.name == "sqlite" && !strings.HasPrefix(strings.ToLower(dsn), "file:") {
if err := os.MkdirAll(filepath.Dir(dsn), 0o750); err != nil {
return nil, d, err
}
}
conn, err := sql.Open(d.driverName, dsn)
if err != nil {
return nil, d, err
}
conn.SetMaxOpenConns(cfg.MaxOpenConns)
conn.SetMaxIdleConns(cfg.MaxIdleConns)
conn.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSeconds) * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.PingContext(ctx); err != nil {
_ = conn.Close()
return nil, d, err
}
return conn, d, nil
}
func databaseDSN(cfg config.DatabaseConfig, baseDir string) (string, error) {
provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
switch provider {
case "", "sqlite":
path := strings.TrimSpace(cfg.SQLitePath)
if path == "" {
path = filepath.Join(baseDir, "storage", "feedback.sqlite")
}
if strings.HasPrefix(strings.ToLower(path), "file:") {
return path, nil
}
if !filepath.IsAbs(path) && !strings.HasPrefix(strings.ToLower(path), "file:") {
path = filepath.Join(baseDir, path)
}
return filepath.Clean(path), nil
case "mysql":
if strings.TrimSpace(cfg.DSN) != "" {
return cfg.DSN, nil
}
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
return "", errors.New("mysql host, name and user are required")
}
host := cfg.Host
if cfg.Port > 0 {
host = host + ":" + strconv.Itoa(cfg.Port)
}
mysqlCfg := mysql.NewConfig()
mysqlCfg.User = cfg.User
mysqlCfg.Passwd = cfg.Password
mysqlCfg.Net = "tcp"
mysqlCfg.Addr = host
mysqlCfg.DBName = cfg.Name
mysqlCfg.ParseTime = true
mysqlCfg.Loc = time.Local
mysqlCfg.Params = map[string]string{"charset": "utf8mb4"}
if cfg.SSLMode != "" && cfg.SSLMode != "disable" {
mysqlCfg.TLSConfig = cfg.SSLMode
}
return mysqlCfg.FormatDSN(), nil
case "postgres", "pgsql":
if strings.TrimSpace(cfg.DSN) != "" {
return cfg.DSN, nil
}
if cfg.Host == "" || cfg.Name == "" || cfg.User == "" {
return "", errors.New("postgres host, name and user are required")
}
host := cfg.Host
if cfg.Port > 0 {
host = host + ":" + strconv.Itoa(cfg.Port)
}
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(cfg.User, cfg.Password),
Host: host,
Path: "/" + cfg.Name,
}
params := url.Values{}
params.Set("sslmode", defaultString(cfg.SSLMode, "disable"))
u.RawQuery = params.Encode()
return u.String(), nil
default:
return "", fmt.Errorf("unsupported database provider %q", cfg.Provider)
}
}
func TestDatabase(cfg config.DatabaseConfig, baseDir string) error {
conn, d, err := openSQLDatabase(cfg, baseDir)
if err != nil {
return err
}
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.PingContext(ctx); err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
create := `CREATE TEMPORARY TABLE ymhut_connection_test (id INTEGER)`
if d.name == "postgres" {
create = `CREATE TEMP TABLE ymhut_connection_test (id INTEGER)`
}
if _, err := tx.ExecContext(ctx, d.rebind(create)); err != nil {
_ = tx.Rollback()
return err
}
_ = tx.Rollback()
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package db
import (
"database/sql"
"fmt"
"strings"
)
type SyncResult struct {
Direction string `json:"direction"`
Tables map[string]int `json:"tables"`
FinishedAt string `json:"finishedAt"`
}
type tableSpec struct {
Name string
Columns []string
Conflict []string
}
var syncTables = []tableSpec{
{"feedbacks", []string{"code", "received_at", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "note", "public_reply", "handled_by", "assignee", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "remote_addr", "summary_text", "included_files", "mail_sent", "updated_at", "last_activity_at"}, []string{"code"}},
{"mail_records", []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}, []string{"id"}},
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
{"webhook_deliveries", []string{"id", "webhook_name", "event", "status", "attempts", "response_code", "error_message", "payload_sha256", "created_at", "finished_at"}, []string{"id"}},
}
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
local := s.localDB
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
return SyncResult{}, fmt.Errorf("remote database is not configured")
}
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
s.setSyncStatus(result, err)
return result, err
}
func (s *Store) SyncNow() (SyncResult, error) {
return s.syncRemoteToSQLite()
}
func (s *Store) syncRemoteToSQLite() (SyncResult, error) {
s.mu.RLock()
remote := s.remoteDB
remoteDialect := s.remoteDialect
local := s.localDB
localDialect := s.localDialect
s.mu.RUnlock()
if remote == nil {
return SyncResult{}, nil
}
result, err := copyAllTables(remote, remoteDialect, local, localDialect, "remote_to_sqlite")
s.setSyncStatus(result, err)
return result, err
}
func (s *Store) setSyncStatus(result SyncResult, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if err != nil {
s.status.LastSyncError = err.Error()
return
}
if result.FinishedAt != "" {
s.status.LastSyncAt = result.FinishedAt
}
s.status.LastSyncError = ""
}
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
result := SyncResult{Direction: direction, Tables: map[string]int{}, FinishedAt: Now()}
for _, table := range syncTables {
copied, err := copyTable(src, srcDialect, dst, dstDialect, table)
if err != nil {
return result, err
}
result.Tables[table.Name] = copied
}
return result, nil
}
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
selectSQL := "SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name
rows, err := src.Query(srcDialect.rebind(selectSQL))
if err != nil {
return 0, err
}
defer rows.Close()
insertSQL := dstDialect.rebind(dstDialect.upsert(spec.Name, spec.Columns, spec.Conflict))
count := 0
for rows.Next() {
values := make([]any, len(spec.Columns))
ptrs := make([]any, len(spec.Columns))
for index := range values {
ptrs[index] = &values[index]
}
if err := rows.Scan(ptrs...); err != nil {
return count, err
}
for index, value := range values {
if bytes, ok := value.([]byte); ok {
values[index] = string(bytes)
}
}
if _, err := dst.Exec(insertSQL, values...); err != nil {
return count, err
}
count++
}
return count, rows.Err()
}