2122 lines
70 KiB
Go
2122 lines
70 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"ymhut-box/server/unified-management/internal/config"
|
|
)
|
|
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
cfg *config.Config
|
|
path string
|
|
db *sql.DB
|
|
dialect dialect
|
|
localDB *sql.DB
|
|
localDialect dialect
|
|
remoteDB *sql.DB
|
|
remoteDialect dialect
|
|
status DatabaseStatus
|
|
stop chan struct{}
|
|
}
|
|
|
|
type state struct {
|
|
Admins []adminRow `json:"admins"`
|
|
Feedbacks []Feedback `json:"feedbacks"`
|
|
Sources []Source `json:"sources"`
|
|
SourceChecks []SourceCheck `json:"sourceChecks"`
|
|
SourceCalls []SourceCall `json:"sourceCalls"`
|
|
AuditLogs []AuditLog `json:"auditLogs"`
|
|
NextID map[string]int64 `json:"nextId"`
|
|
}
|
|
|
|
type adminRow struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"passwordHash"`
|
|
PasswordChanged bool `json:"passwordChanged"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type DatabaseStatus struct {
|
|
ActiveProvider string `json:"activeProvider"`
|
|
ConfigProvider string `json:"configProvider"`
|
|
SQLiteReady bool `json:"sqliteReady"`
|
|
RemoteReady bool `json:"remoteReady"`
|
|
FailoverActive bool `json:"failoverActive"`
|
|
LastError string `json:"lastError"`
|
|
LastFailoverAt string `json:"lastFailoverAt"`
|
|
LastRecoveredAt string `json:"lastRecoveredAt"`
|
|
LastSyncAt string `json:"lastSyncAt"`
|
|
LastSyncError string `json:"lastSyncError"`
|
|
}
|
|
|
|
type SyncResult struct {
|
|
Direction string `json:"direction"`
|
|
Tables map[string]int `json:"tables"`
|
|
FinishedAt string `json:"finishedAt"`
|
|
}
|
|
|
|
type AdminUser struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
PasswordChanged bool `json:"passwordChanged"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type Feedback struct {
|
|
Code string `json:"code"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
Severity string `json:"severity"`
|
|
Category string `json:"category"`
|
|
Priority string `json:"priority"`
|
|
Contact string `json:"contact"`
|
|
Body string `json:"body"`
|
|
Status string `json:"status"`
|
|
StatusDetail string `json:"statusDetail"`
|
|
PublicReply string `json:"publicReply"`
|
|
Note string `json:"note"`
|
|
Assignee string `json:"assignee"`
|
|
HandledBy string `json:"handledBy"`
|
|
DueAt string `json:"dueAt"`
|
|
ResolvedAt string `json:"resolvedAt"`
|
|
ArchivedAt string `json:"archivedAt"`
|
|
SLALevel string `json:"slaLevel"`
|
|
SourceChannel string `json:"sourceChannel"`
|
|
RiskScore int `json:"riskScore"`
|
|
Resolution string `json:"resolution"`
|
|
Attachment string `json:"attachment"`
|
|
PackagePath string `json:"packagePath"`
|
|
EncryptedPackagePath string `json:"encryptedPackagePath"`
|
|
PackageSha256 string `json:"packageSha256"`
|
|
PlainPackageSha256 string `json:"plainPackageSha256"`
|
|
SummaryText string `json:"summaryText"`
|
|
IncludedFiles string `json:"includedFiles"`
|
|
MailSent bool `json:"mailSent"`
|
|
RemoteAddr string `json:"remoteAddr"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
LastActivityAt string `json:"lastActivityAt"`
|
|
}
|
|
|
|
type FeedbackComment struct {
|
|
ID int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Author string `json:"author"`
|
|
Body string `json:"body"`
|
|
Internal bool `json:"internal"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type FeedbackAttachment struct {
|
|
ID int64 `json:"id"`
|
|
FeedbackCode string `json:"feedbackCode"`
|
|
Kind string `json:"kind"`
|
|
Path string `json:"path"`
|
|
FileName string `json:"fileName"`
|
|
SHA256 string `json:"sha256"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type LegacyFeedbackEvent struct {
|
|
ID int64 `json:"id"`
|
|
FeedbackCode string `json:"feedbackCode"`
|
|
EventType string `json:"eventType"`
|
|
Actor string `json:"actor"`
|
|
FromValue string `json:"fromValue"`
|
|
ToValue string `json:"toValue"`
|
|
Message string `json:"message"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type LegacyMailRecord struct {
|
|
ID int64 `json:"id"`
|
|
FeedbackCode string `json:"feedbackCode"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
ToAddress string `json:"toAddress"`
|
|
Subject string `json:"subject"`
|
|
AttachmentPath string `json:"attachmentPath"`
|
|
AttachmentName string `json:"attachmentName"`
|
|
ErrorMessage string `json:"errorMessage"`
|
|
CreatedAt string `json:"createdAt"`
|
|
SentAt string `json:"sentAt"`
|
|
}
|
|
|
|
type LegacySyncJob struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
Summary string `json:"summary"`
|
|
StatsJSON string `json:"statsJson"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt string `json:"finishedAt"`
|
|
}
|
|
|
|
type FeedbackDetail struct {
|
|
Feedback
|
|
Comments []FeedbackComment `json:"comments"`
|
|
Attachments []FeedbackAttachment `json:"attachments"`
|
|
Events []AuditLog `json:"events"`
|
|
LegacyEvents []LegacyFeedbackEvent `json:"legacyEvents"`
|
|
MailRecords []LegacyMailRecord `json:"mailRecords"`
|
|
}
|
|
|
|
type FeedbackFilters struct {
|
|
Status string
|
|
Category string
|
|
Priority string
|
|
Query string
|
|
Assignee string
|
|
Tag string
|
|
Sort string
|
|
}
|
|
|
|
type FeedbackUpdate struct {
|
|
Status string `json:"status"`
|
|
Category string `json:"category"`
|
|
Priority string `json:"priority"`
|
|
StatusDetail string `json:"statusDetail"`
|
|
HandledBy string `json:"handledBy"`
|
|
Assignee string `json:"assignee"`
|
|
DueAt string `json:"dueAt"`
|
|
SLALevel string `json:"slaLevel"`
|
|
Resolution string `json:"resolution"`
|
|
Note string `json:"note"`
|
|
PublicReply string `json:"publicReply"`
|
|
Actor string `json:"actor"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
type ReleasePackage struct {
|
|
ID int64 `json:"id"`
|
|
Product string `json:"product"`
|
|
Version string `json:"version"`
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
FileName string `json:"fileName"`
|
|
URL string `json:"url"`
|
|
SHA256 string `json:"sha256"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type ReleaseNotice struct {
|
|
ID int64 `json:"id"`
|
|
Version string `json:"version"`
|
|
Build string `json:"build"`
|
|
Channel string `json:"channel"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
ReleaseNotes string `json:"releaseNotes"`
|
|
MessageMD string `json:"messageMd"`
|
|
ReleaseNotesMD string `json:"releaseNotesMd"`
|
|
DownloadURL string `json:"downloadUrl"`
|
|
NoticeFile string `json:"noticeFile"`
|
|
RawJSON string `json:"rawJson"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type ReleaseNoticeRevision struct {
|
|
ID int64 `json:"id"`
|
|
Version string `json:"version"`
|
|
RawJSON string `json:"rawJson"`
|
|
Note string `json:"note"`
|
|
CreatedBy string `json:"createdBy"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type Source struct {
|
|
ID int64 `json:"id"`
|
|
CategoryID string `json:"categoryId"`
|
|
CategoryName string `json:"categoryName"`
|
|
SourceID string `json:"sourceId"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Method string `json:"method"`
|
|
APIURL string `json:"apiUrl"`
|
|
URLTemplate string `json:"urlTemplate"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
ProxyMode string `json:"proxyMode"`
|
|
TimeoutMS int `json:"timeoutMs"`
|
|
RetryCount int `json:"retryCount"`
|
|
CacheSeconds int `json:"cacheSeconds"`
|
|
CheckIntervalSec int `json:"checkIntervalSec"`
|
|
Enabled bool `json:"enabled"`
|
|
ClientVisible bool `json:"clientVisible"`
|
|
SupportedFormats string `json:"supportedFormats"`
|
|
LastStatus string `json:"lastStatus"`
|
|
LastLatencyMS int `json:"lastLatencyMs"`
|
|
LastCheckedAt string `json:"lastCheckedAt"`
|
|
LastError string `json:"lastError"`
|
|
ConsecutiveFailure int `json:"consecutiveFailure"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type SourceCheck struct {
|
|
ID int64 `json:"id"`
|
|
SourceID int64 `json:"sourceDbId"`
|
|
Status string `json:"status"`
|
|
LatencyMS int `json:"latencyMs"`
|
|
Error string `json:"error"`
|
|
CheckedAt string `json:"checkedAt"`
|
|
}
|
|
|
|
type SourceCall struct {
|
|
ID int64 `json:"id"`
|
|
SourceID string `json:"sourceId"`
|
|
Status string `json:"status"`
|
|
LatencyMS int `json:"latencyMs"`
|
|
Error string `json:"error"`
|
|
Client string `json:"client"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type AuditLog struct {
|
|
ID int64 `json:"id"`
|
|
Actor string `json:"actor"`
|
|
Type string `json:"type"`
|
|
Target string `json:"target"`
|
|
Message string `json:"message"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"userAgent"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type LegacyJsonRevision struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Raw string `json:"raw"`
|
|
Note string `json:"note"`
|
|
CreatedBy string `json:"createdBy"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
func Open(cfg *config.Config) (*Store, error) {
|
|
path := cfg.Database.SQLitePath
|
|
if strings.TrimSpace(path) == "" {
|
|
path = filepath.Join(cfg.StorageDir, "unified.sqlite")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
prototype, err := readPrototypeState(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if prototype != nil {
|
|
if err := backupPrototypeFile(path); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
localCfg := cfg.Database
|
|
localCfg.Provider = "sqlite"
|
|
localCfg.SQLitePath = path
|
|
local, localDialect, err := openSQLDatabase(localCfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
local.SetMaxOpenConns(1)
|
|
store := &Store{
|
|
cfg: cfg,
|
|
path: path,
|
|
db: local,
|
|
dialect: localDialect,
|
|
localDB: local,
|
|
localDialect: localDialect,
|
|
stop: make(chan struct{}),
|
|
status: DatabaseStatus{
|
|
ActiveProvider: "sqlite",
|
|
ConfigProvider: cfg.Database.Provider,
|
|
SQLiteReady: true,
|
|
LastRecoveredAt: Now(),
|
|
},
|
|
}
|
|
if err := store.migrate(local, localDialect); err != nil {
|
|
_ = local.Close()
|
|
return nil, err
|
|
}
|
|
if prototype != nil {
|
|
if err := store.importPrototype(*prototype); err != nil {
|
|
_ = local.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
if strings.EqualFold(cfg.Database.Provider, "mysql") {
|
|
if err := store.openRemote(); err != nil {
|
|
store.markFailover(err)
|
|
}
|
|
}
|
|
go store.maintain()
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
close(s.stop)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var err error
|
|
if s.remoteDB != nil && s.remoteDB != s.localDB {
|
|
err = s.remoteDB.Close()
|
|
}
|
|
if s.localDB != nil {
|
|
if closeErr := s.localDB.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) Status() DatabaseStatus {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.status
|
|
}
|
|
|
|
func (s *Store) Path() string {
|
|
return s.path
|
|
}
|
|
|
|
func (s *Store) active() (*sql.DB, dialect) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.db, s.dialect
|
|
}
|
|
|
|
func (s *Store) exec(query string, args ...any) (sql.Result, error) {
|
|
conn, d := s.active()
|
|
result, err := conn.Exec(d.rebind(query), args...)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
conn, d = s.active()
|
|
if conn != nil {
|
|
result, err = conn.Exec(d.rebind(query), args...)
|
|
}
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (s *Store) query(query string, args ...any) (*sql.Rows, error) {
|
|
conn, d := s.active()
|
|
rows, err := conn.Query(d.rebind(query), args...)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
conn, d = s.active()
|
|
if conn != nil {
|
|
rows, err = conn.Query(d.rebind(query), args...)
|
|
}
|
|
}
|
|
return rows, err
|
|
}
|
|
|
|
func (s *Store) queryRow(query string, args ...any) *sql.Row {
|
|
conn, d := s.active()
|
|
return conn.QueryRow(d.rebind(query), args...)
|
|
}
|
|
|
|
func (s *Store) insertID(query string, args ...any) (int64, error) {
|
|
result, err := s.exec(query, args...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.LastInsertId()
|
|
}
|
|
|
|
func (s *Store) maintain() {
|
|
ticker := time.NewTicker(time.Duration(s.cfg.Database.HealthIntervalSec) * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
s.checkRemote()
|
|
case <-s.stop:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) openRemote() error {
|
|
if !strings.EqualFold(s.cfg.Database.Provider, "mysql") {
|
|
return nil
|
|
}
|
|
remote, remoteDialect, err := openSQLDatabase(s.cfg.Database)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.migrate(remote, remoteDialect); err != nil {
|
|
_ = remote.Close()
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
if s.remoteDB != nil && s.remoteDB != s.localDB && s.remoteDB != remote {
|
|
_ = s.remoteDB.Close()
|
|
}
|
|
s.remoteDB = remote
|
|
s.remoteDialect = remoteDialect
|
|
s.db = remote
|
|
s.dialect = remoteDialect
|
|
s.status.ActiveProvider = "mysql"
|
|
s.status.ConfigProvider = "mysql"
|
|
s.status.RemoteReady = true
|
|
s.status.FailoverActive = false
|
|
s.status.LastError = ""
|
|
s.status.LastRecoveredAt = Now()
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) checkRemote() {
|
|
if !strings.EqualFold(s.cfg.Database.Provider, "mysql") {
|
|
return
|
|
}
|
|
s.mu.RLock()
|
|
remote := s.remoteDB
|
|
s.mu.RUnlock()
|
|
if remote == nil {
|
|
if err := s.openRemote(); err != nil {
|
|
s.markFailover(err)
|
|
}
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
err := remote.PingContext(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
if s.db == s.localDB {
|
|
s.db = s.remoteDB
|
|
s.dialect = s.remoteDialect
|
|
}
|
|
s.status.ActiveProvider = "mysql"
|
|
s.status.RemoteReady = true
|
|
s.status.FailoverActive = false
|
|
s.status.LastError = ""
|
|
s.status.LastRecoveredAt = Now()
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Store) markFailover(err error) {
|
|
if err == nil || !s.cfg.Database.FailoverEnabled {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.db = s.localDB
|
|
s.dialect = s.localDialect
|
|
s.status.ActiveProvider = "sqlite"
|
|
s.status.ConfigProvider = s.cfg.Database.Provider
|
|
s.status.RemoteReady = false
|
|
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
|
s.status.LastError = err.Error()
|
|
s.status.LastFailoverAt = Now()
|
|
}
|
|
|
|
func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
|
statements := []string{}
|
|
if d.name == "sqlite" {
|
|
statements = append(statements,
|
|
"PRAGMA busy_timeout = 5000",
|
|
"PRAGMA journal_mode = WAL",
|
|
"PRAGMA foreign_keys = ON",
|
|
)
|
|
}
|
|
statements = append(statements, schemaStatements(d)...)
|
|
for _, statement := range statements {
|
|
if _, err := conn.Exec(d.rebind(statement)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func schemaStatements(d dialect) []string {
|
|
return []string{
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS admin_users (
|
|
id %s,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
password_changed INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS sessions (
|
|
id %s,
|
|
session_id TEXT NOT NULL UNIQUE,
|
|
username TEXT NOT NULL,
|
|
csrf TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_packages (
|
|
id %s,
|
|
product TEXT NOT NULL,
|
|
version TEXT NOT NULL,
|
|
platform TEXT NOT NULL,
|
|
arch TEXT NOT NULL,
|
|
file_name TEXT NOT NULL UNIQUE,
|
|
url TEXT NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notices (
|
|
id %s,
|
|
version TEXT NOT NULL UNIQUE,
|
|
build TEXT NOT NULL DEFAULT '',
|
|
channel TEXT NOT NULL DEFAULT 'stable',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
release_notes TEXT NOT NULL DEFAULT '',
|
|
message_md TEXT NOT NULL DEFAULT '',
|
|
release_notes_md TEXT NOT NULL DEFAULT '',
|
|
download_url TEXT NOT NULL DEFAULT '',
|
|
notice_file TEXT NOT NULL DEFAULT '',
|
|
raw_json TEXT NOT NULL,
|
|
published_at TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS release_notice_revisions (
|
|
id %s,
|
|
version TEXT NOT NULL,
|
|
raw_json TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_by TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_tickets (
|
|
code TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
severity TEXT NOT NULL,
|
|
category TEXT NOT NULL DEFAULT '',
|
|
priority TEXT NOT NULL DEFAULT '',
|
|
contact TEXT NOT NULL DEFAULT '',
|
|
body TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL,
|
|
status_detail TEXT NOT NULL DEFAULT '',
|
|
public_reply TEXT NOT NULL DEFAULT '',
|
|
note TEXT NOT NULL DEFAULT '',
|
|
assignee TEXT NOT NULL DEFAULT '',
|
|
handled_by TEXT NOT NULL DEFAULT '',
|
|
due_at TEXT NOT NULL DEFAULT '',
|
|
resolved_at TEXT NOT NULL DEFAULT '',
|
|
archived_at TEXT NOT NULL DEFAULT '',
|
|
sla_level TEXT NOT NULL DEFAULT '',
|
|
source_channel TEXT NOT NULL DEFAULT '',
|
|
risk_score INTEGER NOT NULL DEFAULT 0,
|
|
resolution TEXT NOT NULL DEFAULT '',
|
|
attachment TEXT NOT NULL DEFAULT '',
|
|
package_path TEXT NOT NULL DEFAULT '',
|
|
encrypted_package_path TEXT NOT NULL DEFAULT '',
|
|
package_sha256 TEXT NOT NULL DEFAULT '',
|
|
plain_package_sha256 TEXT NOT NULL DEFAULT '',
|
|
summary_text TEXT NOT NULL DEFAULT '',
|
|
included_files TEXT NOT NULL DEFAULT '',
|
|
mail_sent INTEGER NOT NULL DEFAULT 0,
|
|
remote_addr TEXT NOT NULL DEFAULT '',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
last_activity_at TEXT NOT NULL
|
|
)`),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_comments (
|
|
id %s,
|
|
feedback_code TEXT NOT NULL,
|
|
author TEXT NOT NULL DEFAULT '',
|
|
body TEXT NOT NULL,
|
|
internal INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_attachments (
|
|
id %s,
|
|
feedback_code TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
file_name TEXT NOT NULL,
|
|
sha256 TEXT NOT NULL DEFAULT '',
|
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS feedback_events (
|
|
id %s,
|
|
feedback_code TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
actor TEXT NOT NULL DEFAULT '',
|
|
from_value TEXT NOT NULL DEFAULT '',
|
|
to_value TEXT NOT NULL DEFAULT '',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
`CREATE TABLE IF NOT EXISTS feedback_tags (
|
|
feedback_code TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (feedback_code, tag)
|
|
)`,
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS mail_records (
|
|
id %s,
|
|
feedback_code TEXT NOT NULL DEFAULT '',
|
|
kind TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT '',
|
|
to_address TEXT NOT NULL DEFAULT '',
|
|
subject TEXT NOT NULL DEFAULT '',
|
|
plain_body TEXT NOT NULL DEFAULT '',
|
|
html_body TEXT NOT NULL DEFAULT '',
|
|
attachment_path TEXT NOT NULL DEFAULT '',
|
|
attachment_name TEXT NOT NULL DEFAULT '',
|
|
error_message TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
sent_at TEXT NOT NULL DEFAULT ''
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_categories (
|
|
id %s,
|
|
category_id TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
ui_config TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS source_endpoints (
|
|
id %s,
|
|
category_id TEXT NOT NULL,
|
|
category_name TEXT NOT NULL,
|
|
source_id TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
method TEXT NOT NULL DEFAULT 'GET',
|
|
api_url TEXT NOT NULL DEFAULT '',
|
|
url_template TEXT NOT NULL DEFAULT '',
|
|
thumbnail_url TEXT NOT NULL DEFAULT '',
|
|
proxy_mode TEXT NOT NULL DEFAULT 'client_direct',
|
|
timeout_ms INTEGER NOT NULL DEFAULT 8000,
|
|
retry_count INTEGER NOT NULL DEFAULT 1,
|
|
cache_seconds INTEGER NOT NULL DEFAULT 300,
|
|
check_interval_sec INTEGER NOT NULL DEFAULT 300,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
client_visible INTEGER NOT NULL DEFAULT 1,
|
|
supported_formats TEXT NOT NULL DEFAULT '[]',
|
|
last_status TEXT NOT NULL DEFAULT 'unknown',
|
|
last_latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
last_checked_at TEXT NOT NULL DEFAULT '',
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
consecutive_failure INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_health_checks (
|
|
id %s,
|
|
source_db_id BIGINT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT NOT NULL DEFAULT '',
|
|
checked_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS endpoint_call_logs (
|
|
id %s,
|
|
source_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT NOT NULL DEFAULT '',
|
|
client TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS database_sync_jobs (
|
|
id %s,
|
|
direction TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
message TEXT NOT NULL DEFAULT '',
|
|
tables_json TEXT NOT NULL DEFAULT '{}',
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT NOT NULL DEFAULT ''
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_sync_jobs (
|
|
id %s,
|
|
status TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
stats_json TEXT NOT NULL DEFAULT '{}',
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT NOT NULL DEFAULT ''
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id %s,
|
|
actor TEXT NOT NULL DEFAULT '',
|
|
type TEXT NOT NULL,
|
|
target TEXT NOT NULL DEFAULT '',
|
|
message TEXT NOT NULL DEFAULT '',
|
|
ip TEXT NOT NULL DEFAULT '',
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS legacy_json_revisions (
|
|
id %s,
|
|
name TEXT NOT NULL,
|
|
raw TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_by TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)`, d.idType()),
|
|
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
id %s,
|
|
webhook_name TEXT NOT NULL DEFAULT '',
|
|
event TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT '',
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
response_code INTEGER NOT NULL DEFAULT 0,
|
|
error_message TEXT NOT NULL DEFAULT '',
|
|
payload_sha256 TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
finished_at TEXT NOT NULL DEFAULT ''
|
|
)`, d.idType()),
|
|
`CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`,
|
|
}
|
|
}
|
|
|
|
func (s *Store) EnsureDefaultAdmin(ctx context.Context) error {
|
|
var count int
|
|
if err := s.queryRow(`SELECT COUNT(*) FROM admin_users WHERE username = ?`, "admin").Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
now := Now()
|
|
_, err := s.exec(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 0, ?, ?)`,
|
|
"admin", passwordHash("admin"), now, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) VerifyAdminPassword(ctx context.Context, username, password string) (AdminUser, bool, error) {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
username = "admin"
|
|
}
|
|
var row adminRow
|
|
var changed int
|
|
err := s.queryRow(`SELECT id, username, password_hash, password_changed, created_at, updated_at FROM admin_users WHERE username = ?`, username).
|
|
Scan(&row.ID, &row.Username, &row.PasswordHash, &changed, &row.CreatedAt, &row.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return AdminUser{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return AdminUser{}, false, err
|
|
}
|
|
row.PasswordChanged = changed == 1
|
|
user := AdminUser{ID: row.ID, Username: row.Username, PasswordChanged: row.PasswordChanged, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
|
return user, subtleConstantCompare(row.PasswordHash, password), nil
|
|
}
|
|
|
|
func (s *Store) IsDefaultAdminPassword(ctx context.Context) (bool, error) {
|
|
user, ok, err := s.VerifyAdminPassword(ctx, "admin", "admin")
|
|
if err != nil || !ok {
|
|
return false, err
|
|
}
|
|
return !user.PasswordChanged, nil
|
|
}
|
|
|
|
func (s *Store) ChangeAdminPassword(ctx context.Context, username, current, next string) error {
|
|
if strings.TrimSpace(next) == "" {
|
|
return errors.New("new password is required")
|
|
}
|
|
_, ok, err := s.VerifyAdminPassword(ctx, username, current)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("current password is invalid")
|
|
}
|
|
result, err := s.exec(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`, passwordHash(next), Now(), username)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
|
return errors.New("admin user not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) InsertFeedback(item Feedback) error {
|
|
now := Now()
|
|
if item.Code == "" {
|
|
item.Code = NewFeedbackCode()
|
|
}
|
|
if item.Status == "" {
|
|
item.Status = "new"
|
|
}
|
|
if item.Category == "" {
|
|
item.Category = normalizeCategory(item.Type)
|
|
}
|
|
if item.Priority == "" {
|
|
item.Priority = normalizePriority(item.Severity)
|
|
}
|
|
if item.SLALevel == "" {
|
|
item.SLALevel = defaultSLA(item.Priority)
|
|
}
|
|
if item.SourceChannel == "" {
|
|
item.SourceChannel = "winui"
|
|
}
|
|
if item.RiskScore == 0 {
|
|
item.RiskScore = defaultRisk(item.Priority)
|
|
}
|
|
if item.StatusDetail == "" {
|
|
item.StatusDetail = "反馈已接收,等待后台处理。"
|
|
}
|
|
if item.CreatedAt == "" {
|
|
item.CreatedAt = now
|
|
}
|
|
item.UpdatedAt = now
|
|
item.LastActivityAt = now
|
|
tagsJSON, _ := json.Marshal(normalizeTags(item.Tags))
|
|
_, err := s.exec(`INSERT INTO feedback_tickets (
|
|
code, title, type, severity, category, priority, contact, body, status, status_detail,
|
|
public_reply, note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level,
|
|
source_channel, risk_score, resolution, attachment, package_path, encrypted_package_path,
|
|
package_sha256, plain_package_sha256, summary_text, included_files, mail_sent, remote_addr,
|
|
tags, created_at, updated_at, last_activity_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
item.Code, sanitize(item.Title), sanitize(item.Type), sanitize(item.Severity), item.Category, item.Priority,
|
|
sanitize(item.Contact), sanitizeLong(item.Body, 5000), item.Status, sanitize(item.StatusDetail),
|
|
sanitizeLong(item.PublicReply, 3000), sanitizeLong(item.Note, 3000), sanitize(item.Assignee), sanitize(item.HandledBy),
|
|
item.DueAt, item.ResolvedAt, item.ArchivedAt, item.SLALevel, item.SourceChannel, item.RiskScore,
|
|
sanitizeLong(item.Resolution, 3000), item.Attachment, item.PackagePath, item.EncryptedPackagePath,
|
|
item.PackageSha256, item.PlainPackageSha256, sanitizeLong(item.SummaryText, 6000), item.IncludedFiles,
|
|
boolInt(item.MailSent), sanitize(item.RemoteAddr), string(tagsJSON), item.CreatedAt, item.UpdatedAt, item.LastActivityAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.PackagePath != "" {
|
|
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "package", Path: item.PackagePath, FileName: filepath.Base(item.PackagePath), SHA256: item.PlainPackageSha256})
|
|
}
|
|
if item.EncryptedPackagePath != "" {
|
|
_ = s.InsertFeedbackAttachment(FeedbackAttachment{FeedbackCode: item.Code, Kind: "encrypted_package", Path: item.EncryptedPackagePath, FileName: filepath.Base(item.EncryptedPackagePath), SHA256: item.PackageSha256})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpsertReleaseNotice(item ReleaseNotice) (ReleaseNotice, error) {
|
|
now := Now()
|
|
item.Version = strings.TrimSpace(item.Version)
|
|
if item.Version == "" {
|
|
return ReleaseNotice{}, errors.New("version is required")
|
|
}
|
|
if item.CreatedAt == "" {
|
|
item.CreatedAt = now
|
|
}
|
|
item.UpdatedAt = now
|
|
if item.Channel == "" {
|
|
item.Channel = "stable"
|
|
}
|
|
if item.NoticeFile == "" {
|
|
item.NoticeFile = item.Version + ".json"
|
|
}
|
|
columns := []string{"version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}
|
|
conn, d := s.active()
|
|
_, err := conn.Exec(d.rebind(d.upsert("release_notices", columns, []string{"version"})),
|
|
sanitize(item.Version), sanitize(item.Build), sanitize(item.Channel), sanitizeLong(item.Title, 500), sanitizeLong(item.Message, 4000),
|
|
sanitizeLong(item.ReleaseNotes, 12000), sanitizeLong(item.MessageMD, 12000), sanitizeLong(item.ReleaseNotesMD, 20000),
|
|
sanitizeLong(item.DownloadURL, 1200), sanitize(item.NoticeFile), item.RawJSON, sanitize(item.PublishedAt), item.CreatedAt, item.UpdatedAt)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
return ReleaseNotice{}, err
|
|
}
|
|
return s.GetReleaseNotice(item.Version)
|
|
}
|
|
|
|
func (s *Store) GetReleaseNotice(version string) (ReleaseNotice, error) {
|
|
item, err := scanReleaseNotice(s.queryRow(releaseNoticeSelectSQL()+` WHERE version = ?`, strings.TrimSpace(version)))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ReleaseNotice{}, errors.New("release notice not found")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Store) ListReleaseNotices(limit int) ([]ReleaseNotice, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.query(releaseNoticeSelectSQL()+` ORDER BY published_at DESC, version DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ReleaseNotice{}
|
|
for rows.Next() {
|
|
item, err := scanReleaseNotice(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SaveReleaseNoticeRevision(version, raw, note, actor string) (ReleaseNoticeRevision, error) {
|
|
item := ReleaseNoticeRevision{Version: sanitize(version), RawJSON: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
|
id, err := s.insertID(`INSERT INTO release_notice_revisions (version, raw_json, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
item.Version, item.RawJSON, item.Note, item.CreatedBy, item.CreatedAt)
|
|
if err != nil {
|
|
return ReleaseNoticeRevision{}, err
|
|
}
|
|
item.ID = id
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Store) ListReleaseNoticeRevisions(version string, limit int) ([]ReleaseNoticeRevision, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
rows, err := s.query(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? ORDER BY id DESC LIMIT ?`, strings.TrimSpace(version), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ReleaseNoticeRevision{}
|
|
for rows.Next() {
|
|
var item ReleaseNoticeRevision
|
|
if err := rows.Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetReleaseNoticeRevision(version string, id int64) (ReleaseNoticeRevision, error) {
|
|
var item ReleaseNoticeRevision
|
|
err := s.queryRow(`SELECT id, version, raw_json, note, created_by, created_at FROM release_notice_revisions WHERE version = ? AND id = ?`, strings.TrimSpace(version), id).
|
|
Scan(&item.ID, &item.Version, &item.RawJSON, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ReleaseNoticeRevision{}, errors.New("release notice revision not found")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Store) GetFeedback(code string) (Feedback, error) {
|
|
item, err := s.scanFeedbackRow(s.queryRow(feedbackSelectSQL()+` WHERE code = ?`, code))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Feedback{}, errors.New("feedback not found")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Store) GetFeedbackDetail(code string) (*FeedbackDetail, error) {
|
|
item, err := s.GetFeedback(code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
comments, err := s.ListFeedbackComments(code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attachments, err := s.ListFeedbackAttachments(code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
events, _ := s.ListAuditLogsForTarget(code, 100)
|
|
legacyEvents, _ := s.ListFeedbackEvents(code, 100)
|
|
mailRecords, _ := s.ListMailRecords(code, 100)
|
|
return &FeedbackDetail{Feedback: item, Comments: comments, Attachments: attachments, Events: events, LegacyEvents: legacyEvents, MailRecords: mailRecords}, nil
|
|
}
|
|
|
|
func (s *Store) ListFeedbacks(limit int) ([]Feedback, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.query(feedbackSelectSQL()+` ORDER BY last_activity_at DESC, created_at DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanFeedbackRows(rows)
|
|
}
|
|
|
|
func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) {
|
|
page, perPage = normalizePage(page, perPage)
|
|
where, args := feedbackWhere(filters)
|
|
var total int
|
|
if err := s.queryRow(`SELECT COUNT(*) FROM feedback_tickets`+where, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
order := ` ORDER BY last_activity_at DESC, created_at DESC`
|
|
if filters.Sort == "oldest" {
|
|
order = ` ORDER BY created_at ASC`
|
|
}
|
|
args = append(args, perPage, (page-1)*perPage)
|
|
rows, err := s.query(feedbackSelectSQL()+where+order+` LIMIT ? OFFSET ?`, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
items, err := scanFeedbackRows(rows)
|
|
return items, total, err
|
|
}
|
|
|
|
func (s *Store) UpdateFeedback(code, status, detail, reply string) error {
|
|
update := FeedbackUpdate{Status: status, StatusDetail: detail, PublicReply: reply, Actor: "admin"}
|
|
return s.UpdateFeedbackTicket(code, update)
|
|
}
|
|
|
|
func (s *Store) UpdateFeedbackTicket(code string, update FeedbackUpdate) error {
|
|
current, err := s.GetFeedback(code)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if update.Status == "" {
|
|
update.Status = current.Status
|
|
}
|
|
if update.Category == "" {
|
|
update.Category = current.Category
|
|
}
|
|
if update.Priority == "" {
|
|
update.Priority = current.Priority
|
|
}
|
|
if update.SLALevel == "" {
|
|
update.SLALevel = current.SLALevel
|
|
}
|
|
if update.StatusDetail == "" {
|
|
update.StatusDetail = current.StatusDetail
|
|
}
|
|
if update.PublicReply == "" {
|
|
update.PublicReply = current.PublicReply
|
|
}
|
|
if update.Note == "" {
|
|
update.Note = current.Note
|
|
}
|
|
if update.Assignee == "" {
|
|
update.Assignee = current.Assignee
|
|
}
|
|
if update.HandledBy == "" {
|
|
update.HandledBy = current.HandledBy
|
|
}
|
|
if update.DueAt == "" {
|
|
update.DueAt = current.DueAt
|
|
}
|
|
if update.Resolution == "" {
|
|
update.Resolution = current.Resolution
|
|
}
|
|
tags := current.Tags
|
|
if len(update.Tags) > 0 {
|
|
tags = update.Tags
|
|
}
|
|
tagsJSON, _ := json.Marshal(normalizeTags(tags))
|
|
now := Now()
|
|
_, err = s.exec(`UPDATE feedback_tickets SET status = ?, category = ?, priority = ?, status_detail = ?, public_reply = ?,
|
|
note = ?, assignee = ?, handled_by = ?, due_at = ?, sla_level = ?, resolution = ?, tags = ?, updated_at = ?, last_activity_at = ?
|
|
WHERE code = ?`,
|
|
update.Status, update.Category, update.Priority, sanitize(update.StatusDetail), sanitizeLong(update.PublicReply, 3000),
|
|
sanitizeLong(update.Note, 3000), sanitize(update.Assignee), sanitize(update.HandledBy), update.DueAt, update.SLALevel,
|
|
sanitizeLong(update.Resolution, 3000), string(tagsJSON), now, now, code)
|
|
if err == nil {
|
|
_ = s.InsertAudit(AuditLog{Actor: firstNonEmpty(update.Actor, "admin"), Type: "feedback.updated", Target: code, Message: "Feedback updated"})
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) BulkUpdateFeedback(codes []string, update FeedbackUpdate) error {
|
|
for _, code := range codes {
|
|
if err := s.UpdateFeedbackTicket(code, update); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) InsertFeedbackComment(comment FeedbackComment) (FeedbackComment, error) {
|
|
if comment.CreatedAt == "" {
|
|
comment.CreatedAt = Now()
|
|
}
|
|
id, err := s.insertID(`INSERT INTO feedback_comments (feedback_code, author, body, internal, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
comment.Code, sanitize(comment.Author), sanitizeLong(comment.Body, 3000), boolInt(comment.Internal), comment.CreatedAt)
|
|
if err != nil {
|
|
return FeedbackComment{}, err
|
|
}
|
|
comment.ID = id
|
|
return comment, nil
|
|
}
|
|
|
|
func (s *Store) ListFeedbackComments(code string) ([]FeedbackComment, error) {
|
|
rows, err := s.query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FeedbackComment{}
|
|
for rows.Next() {
|
|
var item FeedbackComment
|
|
var internal int
|
|
if err := rows.Scan(&item.ID, &item.Code, &item.Author, &item.Body, &internal, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Internal = internal == 1
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) InsertFeedbackAttachment(item FeedbackAttachment) error {
|
|
if item.CreatedAt == "" {
|
|
item.CreatedAt = Now()
|
|
}
|
|
if item.FileName == "" {
|
|
item.FileName = filepath.Base(item.Path)
|
|
}
|
|
if item.SizeBytes == 0 {
|
|
if info, err := os.Stat(item.Path); err == nil {
|
|
item.SizeBytes = info.Size()
|
|
}
|
|
}
|
|
_, err := s.exec(`INSERT INTO feedback_attachments (feedback_code, kind, path, file_name, sha256, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
item.FeedbackCode, item.Kind, item.Path, item.FileName, item.SHA256, item.SizeBytes, item.CreatedAt)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListFeedbackAttachments(code string) ([]FeedbackAttachment, error) {
|
|
rows, err := s.query(`SELECT id, feedback_code, kind, path, file_name, sha256, size_bytes, created_at FROM feedback_attachments WHERE feedback_code = ? ORDER BY id ASC`, code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FeedbackAttachment{}
|
|
for rows.Next() {
|
|
var item FeedbackAttachment
|
|
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Path, &item.FileName, &item.SHA256, &item.SizeBytes, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) UpsertFeedbackEvent(item LegacyFeedbackEvent) error {
|
|
if item.CreatedAt == "" {
|
|
item.CreatedAt = Now()
|
|
}
|
|
conn, d := s.active()
|
|
columns := []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}
|
|
_, err := conn.Exec(d.rebind(d.upsert("feedback_events", columns, []string{"id"})),
|
|
item.ID, sanitize(item.FeedbackCode), sanitize(item.EventType), sanitize(item.Actor), sanitize(item.FromValue), sanitize(item.ToValue), sanitizeLong(item.Message, 1000), item.CreatedAt)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpsertFeedbackTag(code, tag, createdAt string) error {
|
|
if createdAt == "" {
|
|
createdAt = Now()
|
|
}
|
|
conn, d := s.active()
|
|
columns := []string{"feedback_code", "tag", "created_at"}
|
|
_, err := conn.Exec(d.rebind(d.upsert("feedback_tags", columns, []string{"feedback_code", "tag"})), sanitize(code), sanitize(tag), createdAt)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpsertMailRecord(item LegacyMailRecord) error {
|
|
if item.CreatedAt == "" {
|
|
item.CreatedAt = Now()
|
|
}
|
|
conn, d := s.active()
|
|
columns := []string{"id", "feedback_code", "kind", "status", "to_address", "subject", "plain_body", "html_body", "attachment_path", "attachment_name", "error_message", "created_at", "sent_at"}
|
|
_, err := conn.Exec(d.rebind(d.upsert("mail_records", columns, []string{"id"})),
|
|
item.ID, sanitize(item.FeedbackCode), sanitize(item.Kind), sanitize(item.Status), sanitize(item.ToAddress), sanitizeLong(item.Subject, 1000),
|
|
"", "", item.AttachmentPath, item.AttachmentName, sanitizeLong(item.ErrorMessage, 1000), item.CreatedAt, item.SentAt)
|
|
if err != nil {
|
|
s.markFailover(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListFeedbackEvents(code string, limit int) ([]LegacyFeedbackEvent, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LegacyFeedbackEvent{}
|
|
for rows.Next() {
|
|
var item LegacyFeedbackEvent
|
|
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.EventType, &item.Actor, &item.FromValue, &item.ToValue, &item.Message, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListMailRecords(code string, limit int) ([]LegacyMailRecord, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records WHERE feedback_code = ? ORDER BY created_at DESC, id DESC LIMIT ?`, code, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LegacyMailRecord{}
|
|
for rows.Next() {
|
|
var item LegacyMailRecord
|
|
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Status, &item.ToAddress, &item.Subject, &item.AttachmentPath, &item.AttachmentName, &item.ErrorMessage, &item.CreatedAt, &item.SentAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) UpsertSource(item Source) (Source, error) {
|
|
now := Now()
|
|
if item.SourceID == "" {
|
|
item.SourceID = item.CategoryID + "-" + item.Name
|
|
}
|
|
if item.Method == "" {
|
|
item.Method = "GET"
|
|
}
|
|
item.ProxyMode = normalizeProxyMode(firstNonEmpty(item.ProxyMode, "client_direct"), item.CategoryID, item.Name, item.APIURL)
|
|
if item.URLTemplate == "" {
|
|
item.URLTemplate = item.APIURL
|
|
}
|
|
if item.TimeoutMS <= 0 {
|
|
item.TimeoutMS = 8000
|
|
}
|
|
if item.RetryCount <= 0 {
|
|
item.RetryCount = 1
|
|
}
|
|
if item.CacheSeconds <= 0 {
|
|
item.CacheSeconds = item.CheckIntervalSec
|
|
}
|
|
if item.CacheSeconds <= 0 {
|
|
item.CacheSeconds = 300
|
|
}
|
|
if item.CheckIntervalSec <= 0 {
|
|
item.CheckIntervalSec = item.CacheSeconds
|
|
}
|
|
if item.SupportedFormats == "" {
|
|
item.SupportedFormats = "[]"
|
|
}
|
|
if item.LastStatus == "" {
|
|
item.LastStatus = "unknown"
|
|
}
|
|
if item.CategoryID == "" {
|
|
item.CategoryID = "custom"
|
|
}
|
|
if item.CategoryName == "" {
|
|
item.CategoryName = item.CategoryID
|
|
}
|
|
_, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at)
|
|
VALUES (?, ?, 1, '{}', ?, ?)
|
|
ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`,
|
|
item.CategoryID, item.CategoryName, now, now)
|
|
conn, d := s.active()
|
|
query := d.upsert("source_endpoints",
|
|
[]string{"category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"},
|
|
[]string{"source_id"})
|
|
if _, err := conn.Exec(d.rebind(query), item.CategoryID, item.CategoryName, item.SourceID, item.Name, item.Description, item.Method, item.APIURL, item.URLTemplate, item.ThumbnailURL,
|
|
item.ProxyMode, item.TimeoutMS, item.RetryCount, item.CacheSeconds, item.CheckIntervalSec, boolInt(item.Enabled), boolInt(item.ClientVisible), item.SupportedFormats,
|
|
item.LastStatus, item.LastLatencyMS, item.LastCheckedAt, item.LastError, item.ConsecutiveFailure, firstNonEmpty(item.CreatedAt, now), now); err != nil {
|
|
s.markFailover(err)
|
|
return Source{}, err
|
|
}
|
|
return s.GetSourceBySourceID(item.SourceID)
|
|
}
|
|
|
|
func (s *Store) GetSourceBySourceID(sourceID string) (Source, error) {
|
|
item, err := scanSourceRow(s.queryRow(sourceSelectSQL()+` WHERE source_id = ?`, sourceID))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Source{}, errors.New("source not found")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Store) ListSources(includeHidden bool) ([]Source, error) {
|
|
where := ""
|
|
args := []any{}
|
|
if !includeHidden {
|
|
where = " WHERE enabled = 1 AND client_visible = 1"
|
|
}
|
|
rows, err := s.query(sourceSelectSQL()+where+` ORDER BY category_id ASC, name ASC`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Source{}
|
|
for rows.Next() {
|
|
item, err := scanSourceRowsCurrent(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) CountSources() (int, error) {
|
|
var count int
|
|
err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints`).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (s *Store) DeleteSource(sourceID string) error {
|
|
_, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, message string) error {
|
|
now := Now()
|
|
_, err := s.exec(`INSERT INTO endpoint_health_checks (source_db_id, status, latency_ms, error, checked_at) VALUES (?, ?, ?, ?, ?)`,
|
|
sourceDBID, status, latency, sanitize(message), now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status == "ok" {
|
|
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = '', consecutive_failure = 0, updated_at = ? WHERE id = ?`,
|
|
status, latency, now, now, sourceDBID)
|
|
} else {
|
|
_, err = s.exec(`UPDATE source_endpoints SET last_status = ?, last_latency_ms = ?, last_checked_at = ?, last_error = ?, consecutive_failure = consecutive_failure + 1, updated_at = ? WHERE id = ?`,
|
|
status, latency, now, sanitize(message), now, sourceDBID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Store) RecordSourceCall(call SourceCall) error {
|
|
if call.CreatedAt == "" {
|
|
call.CreatedAt = Now()
|
|
}
|
|
_, err := s.exec(`INSERT INTO endpoint_call_logs (source_id, status, latency_ms, error, client, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
sanitize(call.SourceID), sanitize(call.Status), call.LatencyMS, sanitize(call.Error), sanitize(call.Client), call.CreatedAt)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 80
|
|
}
|
|
feedbackTotal, _ := s.countTable("feedback_tickets")
|
|
feedbackToday, _ := s.countWhere("feedback_tickets", "created_at LIKE ?", time.Now().UTC().Format("2006-01-02")+"%")
|
|
sourceTotal, _ := s.countTable("source_endpoints")
|
|
sourceVisible, _ := s.countWhere("source_endpoints", "enabled = 1 AND client_visible = 1")
|
|
releaseTotal, _ := s.countTable("release_notices")
|
|
mailFailed, _ := s.countWhere("mail_records", "status = ?", "failed")
|
|
statusCounts, _ := s.groupCounts("feedback_tickets", "status")
|
|
healthCounts, _ := s.groupCounts("source_endpoints", "last_status")
|
|
recentChecks, _ := s.RecentSourceChecks(limit)
|
|
recentCalls, _ := s.RecentSourceCalls(limit)
|
|
audit, _ := s.ListAuditLogs(10)
|
|
return map[string]any{
|
|
"ok": true,
|
|
"kpis": map[string]any{
|
|
"feedbackTotal": feedbackTotal,
|
|
"feedbackToday": feedbackToday,
|
|
"sourceTotal": sourceTotal,
|
|
"sourceVisible": sourceVisible,
|
|
"releaseNotices": releaseTotal,
|
|
"mailFailed": mailFailed,
|
|
},
|
|
"feedbackStatus": statusCounts,
|
|
"sourceHealth": healthCounts,
|
|
"heartbeats": recentChecks,
|
|
"clientCalls": recentCalls,
|
|
"database": s.Status(),
|
|
"audit": audit,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
|
|
rows, err := s.query(`SELECT h.id, e.source_id, e.name, h.status, h.latency_ms, h.error, h.checked_at
|
|
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
|
|
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var sourceID, name, status, message, checkedAt string
|
|
var latency int
|
|
if err := rows.Scan(&id, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "name": name, "status": status, "latencyMs": latency, "error": message, "checkedAt": checkedAt})
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
|
|
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var sourceID, status, message, client, createdAt string
|
|
var latency int
|
|
if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, map[string]any{"id": id, "sourceId": sourceID, "status": status, "latencyMs": latency, "error": message, "client": client, "createdAt": createdAt})
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) InsertAudit(log AuditLog) error {
|
|
if log.CreatedAt == "" {
|
|
log.CreatedAt = Now()
|
|
}
|
|
_, err := s.exec(`INSERT INTO audit_logs (actor, type, target, message, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
sanitize(log.Actor), sanitize(log.Type), sanitize(log.Target), sanitize(log.Message), sanitize(log.IP), sanitize(log.UserAgent), log.CreatedAt)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListAuditLogs(limit int) ([]AuditLog, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAuditRows(rows)
|
|
}
|
|
|
|
func (s *Store) ListAuditLogsForTarget(target string, limit int) ([]AuditLog, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs WHERE target = ? ORDER BY id DESC LIMIT ?`, target, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAuditRows(rows)
|
|
}
|
|
|
|
func (s *Store) SaveLegacyRevision(name, raw, note, actor string) (LegacyJsonRevision, error) {
|
|
item := LegacyJsonRevision{Name: name, Raw: raw, Note: sanitize(note), CreatedBy: firstNonEmpty(actor, "admin"), CreatedAt: Now()}
|
|
id, err := s.insertID(`INSERT INTO legacy_json_revisions (name, raw, note, created_by, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
item.Name, item.Raw, item.Note, item.CreatedBy, item.CreatedAt)
|
|
if err != nil {
|
|
return LegacyJsonRevision{}, err
|
|
}
|
|
item.ID = id
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Store) ListLegacyRevisions(name string, limit int) ([]LegacyJsonRevision, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
rows, err := s.query(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? ORDER BY id DESC LIMIT ?`, name, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []LegacyJsonRevision{}
|
|
for rows.Next() {
|
|
var item LegacyJsonRevision
|
|
if err := rows.Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetLegacyRevision(name string, id int64) (LegacyJsonRevision, error) {
|
|
var item LegacyJsonRevision
|
|
err := s.queryRow(`SELECT id, name, raw, note, created_by, created_at FROM legacy_json_revisions WHERE name = ? AND id = ?`, name, id).
|
|
Scan(&item.ID, &item.Name, &item.Raw, &item.Note, &item.CreatedBy, &item.CreatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return LegacyJsonRevision{}, errors.New("revision not found")
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
func (s *Store) CopySQLiteToRemote() (string, error) {
|
|
result, err := s.ImportSQLiteToRemote()
|
|
return result.FinishedAt, err
|
|
}
|
|
|
|
func (s *Store) CopyRemoteToSQLite() (string, error) {
|
|
result, err := s.SyncNow()
|
|
return result.FinishedAt, err
|
|
}
|
|
|
|
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 {
|
|
err := errors.New("remote database is not configured")
|
|
s.setSyncStatus(SyncResult{Direction: "sqlite_to_remote", Tables: map[string]int{}, FinishedAt: Now()}, err)
|
|
return SyncResult{}, err
|
|
}
|
|
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
|
s.setSyncStatus(result, err)
|
|
return result, err
|
|
}
|
|
|
|
func (s *Store) SyncNow() (SyncResult, error) {
|
|
s.mu.RLock()
|
|
remote := s.remoteDB
|
|
remoteDialect := s.remoteDialect
|
|
local := s.localDB
|
|
localDialect := s.localDialect
|
|
s.mu.RUnlock()
|
|
if remote == nil {
|
|
result := SyncResult{Direction: "remote_to_sqlite", Tables: map[string]int{}, FinishedAt: Now()}
|
|
s.setSyncStatus(result, nil)
|
|
return result, 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.LastSyncAt = result.FinishedAt
|
|
s.status.LastSyncError = err.Error()
|
|
return
|
|
}
|
|
s.status.LastSyncAt = result.FinishedAt
|
|
s.status.LastSyncError = ""
|
|
}
|
|
|
|
func (s *Store) scanFeedbackRow(scanner feedbackScanner) (Feedback, error) {
|
|
return scanFeedback(scanner)
|
|
}
|
|
|
|
func feedbackSelectSQL() string {
|
|
return `SELECT code, title, type, severity, category, priority, contact, body, status, status_detail, public_reply,
|
|
note, assignee, handled_by, due_at, resolved_at, archived_at, sla_level, source_channel, risk_score, resolution,
|
|
attachment, package_path, encrypted_package_path, package_sha256, plain_package_sha256, summary_text, included_files,
|
|
mail_sent, remote_addr, tags, created_at, updated_at, last_activity_at FROM feedback_tickets`
|
|
}
|
|
|
|
func releaseNoticeSelectSQL() string {
|
|
return `SELECT id, version, build, channel, title, message, release_notes, message_md, release_notes_md,
|
|
download_url, notice_file, raw_json, published_at, created_at, updated_at FROM release_notices`
|
|
}
|
|
|
|
type feedbackScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanReleaseNotice(scanner interface{ Scan(dest ...any) error }) (ReleaseNotice, error) {
|
|
var item ReleaseNotice
|
|
err := scanner.Scan(&item.ID, &item.Version, &item.Build, &item.Channel, &item.Title, &item.Message,
|
|
&item.ReleaseNotes, &item.MessageMD, &item.ReleaseNotesMD, &item.DownloadURL, &item.NoticeFile,
|
|
&item.RawJSON, &item.PublishedAt, &item.CreatedAt, &item.UpdatedAt)
|
|
return item, err
|
|
}
|
|
|
|
func scanFeedback(scanner feedbackScanner) (Feedback, error) {
|
|
var item Feedback
|
|
var mailSent int
|
|
var tags string
|
|
err := scanner.Scan(&item.Code, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact,
|
|
&item.Body, &item.Status, &item.StatusDetail, &item.PublicReply, &item.Note, &item.Assignee, &item.HandledBy,
|
|
&item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore,
|
|
&item.Resolution, &item.Attachment, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256,
|
|
&item.PlainPackageSha256, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.RemoteAddr, &tags,
|
|
&item.CreatedAt, &item.UpdatedAt, &item.LastActivityAt)
|
|
item.MailSent = mailSent == 1
|
|
_ = json.Unmarshal([]byte(tags), &item.Tags)
|
|
return item, err
|
|
}
|
|
|
|
func scanFeedbackRows(rows *sql.Rows) ([]Feedback, error) {
|
|
items := []Feedback{}
|
|
for rows.Next() {
|
|
item, err := scanFeedback(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func sourceSelectSQL() string {
|
|
return `SELECT id, category_id, category_name, source_id, name, description, method, api_url, url_template, thumbnail_url,
|
|
proxy_mode, timeout_ms, retry_count, cache_seconds, check_interval_sec, enabled, client_visible, supported_formats,
|
|
last_status, last_latency_ms, last_checked_at, last_error, consecutive_failure, created_at, updated_at FROM source_endpoints`
|
|
}
|
|
|
|
func scanSourceRow(scanner sourceScanner) (Source, error) {
|
|
return scanSource(scanner)
|
|
}
|
|
|
|
type sourceScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanSourceRowsCurrent(scanner sourceScanner) (Source, error) {
|
|
return scanSource(scanner)
|
|
}
|
|
|
|
func scanSource(scanner sourceScanner) (Source, error) {
|
|
var item Source
|
|
var enabled, visible int
|
|
err := scanner.Scan(&item.ID, &item.CategoryID, &item.CategoryName, &item.SourceID, &item.Name, &item.Description,
|
|
&item.Method, &item.APIURL, &item.URLTemplate, &item.ThumbnailURL, &item.ProxyMode, &item.TimeoutMS, &item.RetryCount,
|
|
&item.CacheSeconds, &item.CheckIntervalSec, &enabled, &visible, &item.SupportedFormats, &item.LastStatus,
|
|
&item.LastLatencyMS, &item.LastCheckedAt, &item.LastError, &item.ConsecutiveFailure, &item.CreatedAt, &item.UpdatedAt)
|
|
item.Enabled = enabled == 1
|
|
item.ClientVisible = visible == 1
|
|
return item, err
|
|
}
|
|
|
|
func scanAuditRows(rows *sql.Rows) ([]AuditLog, error) {
|
|
items := []AuditLog{}
|
|
for rows.Next() {
|
|
var item AuditLog
|
|
if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func feedbackWhere(filters FeedbackFilters) (string, []any) {
|
|
clauses := []string{}
|
|
args := []any{}
|
|
if filters.Status != "" {
|
|
clauses = append(clauses, "status = ?")
|
|
args = append(args, filters.Status)
|
|
}
|
|
if filters.Category != "" {
|
|
clauses = append(clauses, "category = ?")
|
|
args = append(args, filters.Category)
|
|
}
|
|
if filters.Priority != "" {
|
|
clauses = append(clauses, "priority = ?")
|
|
args = append(args, filters.Priority)
|
|
}
|
|
if filters.Assignee != "" {
|
|
clauses = append(clauses, "assignee = ?")
|
|
args = append(args, filters.Assignee)
|
|
}
|
|
if filters.Query != "" {
|
|
like := "%" + filters.Query + "%"
|
|
clauses = append(clauses, "(code LIKE ? OR title LIKE ? OR contact LIKE ? OR body LIKE ?)")
|
|
args = append(args, like, like, like, like)
|
|
}
|
|
if len(clauses) == 0 {
|
|
return "", args
|
|
}
|
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|
|
|
|
func normalizePage(page, perPage int) (int, int) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage <= 0 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
return page, perPage
|
|
}
|
|
|
|
func (s *Store) countTable(table string) (int, error) {
|
|
if !validStatsTable(table) {
|
|
return 0, fmt.Errorf("invalid table %q", table)
|
|
}
|
|
var total int
|
|
err := s.queryRow(`SELECT COUNT(*) FROM ` + table).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func (s *Store) countWhere(table, where string, args ...any) (int, error) {
|
|
if !validStatsTable(table) {
|
|
return 0, fmt.Errorf("invalid table %q", table)
|
|
}
|
|
var total int
|
|
err := s.queryRow(`SELECT COUNT(*) FROM `+table+` WHERE `+where, args...).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func (s *Store) groupCounts(table, column string) (map[string]int, error) {
|
|
if !validStatsColumn(table, column) {
|
|
return nil, fmt.Errorf("invalid group %s.%s", table, column)
|
|
}
|
|
rows, err := s.query(`SELECT ` + column + `, COUNT(*) FROM ` + table + ` GROUP BY ` + column)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]int{}
|
|
for rows.Next() {
|
|
var key string
|
|
var count int
|
|
if err := rows.Scan(&key, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
if key == "" {
|
|
key = "unknown"
|
|
}
|
|
out[key] = count
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func validStatsTable(table string) bool {
|
|
switch table {
|
|
case "feedback_tickets", "source_endpoints", "release_notices", "mail_records":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validStatsColumn(table, column string) bool {
|
|
switch table + "." + column {
|
|
case "feedback_tickets.status", "source_endpoints.last_status":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type tableSpec struct {
|
|
Name string
|
|
Columns []string
|
|
Conflict []string
|
|
}
|
|
|
|
var syncTables = []tableSpec{
|
|
{"admin_users", []string{"id", "username", "password_hash", "password_changed", "created_at", "updated_at"}, []string{"id"}},
|
|
{"release_packages", []string{"id", "product", "version", "platform", "arch", "file_name", "url", "sha256", "size_bytes", "enabled", "created_at", "updated_at"}, []string{"id"}},
|
|
{"release_notices", []string{"id", "version", "build", "channel", "title", "message", "release_notes", "message_md", "release_notes_md", "download_url", "notice_file", "raw_json", "published_at", "created_at", "updated_at"}, []string{"id"}},
|
|
{"release_notice_revisions", []string{"id", "version", "raw_json", "note", "created_by", "created_at"}, []string{"id"}},
|
|
{"feedback_tickets", []string{"code", "title", "type", "severity", "category", "priority", "contact", "body", "status", "status_detail", "public_reply", "note", "assignee", "handled_by", "due_at", "resolved_at", "archived_at", "sla_level", "source_channel", "risk_score", "resolution", "attachment", "package_path", "encrypted_package_path", "package_sha256", "plain_package_sha256", "summary_text", "included_files", "mail_sent", "remote_addr", "tags", "created_at", "updated_at", "last_activity_at"}, []string{"code"}},
|
|
{"feedback_comments", []string{"id", "feedback_code", "author", "body", "internal", "created_at"}, []string{"id"}},
|
|
{"feedback_attachments", []string{"id", "feedback_code", "kind", "path", "file_name", "sha256", "size_bytes", "created_at"}, []string{"id"}},
|
|
{"feedback_events", []string{"id", "feedback_code", "event_type", "actor", "from_value", "to_value", "message", "created_at"}, []string{"id"}},
|
|
{"feedback_tags", []string{"feedback_code", "tag", "created_at"}, []string{"feedback_code", "tag"}},
|
|
{"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"}},
|
|
{"source_categories", []string{"id", "category_id", "name", "enabled", "ui_config", "created_at", "updated_at"}, []string{"id"}},
|
|
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
|
|
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
|
|
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
|
|
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
|
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "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"}},
|
|
{"legacy_sync_jobs", []string{"id", "status", "summary", "stats_json", "started_at", "finished_at"}, []string{"id"}},
|
|
}
|
|
|
|
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 {
|
|
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Tables[table.Name] = count
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
|
rows, err := src.Query(srcDialect.rebind("SELECT " + strings.Join(spec.Columns, ", ") + " FROM " + spec.Name))
|
|
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()
|
|
}
|
|
|
|
func readPrototypeState(path string) (*state, error) {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
trimmed := strings.TrimSpace(string(data))
|
|
if !strings.HasPrefix(trimmed, "{") {
|
|
return nil, nil
|
|
}
|
|
var prototype state
|
|
if err := json.Unmarshal(data, &prototype); err != nil {
|
|
return nil, fmt.Errorf("existing sqlite path is not a valid sqlite database or JSON prototype: %w", err)
|
|
}
|
|
return &prototype, nil
|
|
}
|
|
|
|
func backupPrototypeFile(path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) || len(data) == 0 {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(strings.TrimSpace(string(data)), "{") {
|
|
return nil
|
|
}
|
|
backup := path + ".json-prototype-" + time.Now().UTC().Format("20060102-150405") + ".bak"
|
|
if err := os.WriteFile(backup, data, 0o640); err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(path)
|
|
}
|
|
|
|
func (s *Store) importPrototype(prototype state) error {
|
|
for _, admin := range prototype.Admins {
|
|
if admin.CreatedAt == "" {
|
|
admin.CreatedAt = Now()
|
|
}
|
|
if admin.UpdatedAt == "" {
|
|
admin.UpdatedAt = admin.CreatedAt
|
|
}
|
|
_, _ = s.exec(`INSERT INTO admin_users (id, username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
admin.ID, admin.Username, admin.PasswordHash, boolInt(admin.PasswordChanged), admin.CreatedAt, admin.UpdatedAt)
|
|
}
|
|
for _, item := range prototype.Feedbacks {
|
|
_ = s.InsertFeedback(item)
|
|
}
|
|
for _, item := range prototype.Sources {
|
|
_, _ = s.UpsertSource(item)
|
|
}
|
|
for _, item := range prototype.SourceChecks {
|
|
_ = s.RecordSourceCheck(item.SourceID, item.Status, item.LatencyMS, item.Error)
|
|
}
|
|
for _, item := range prototype.SourceCalls {
|
|
_ = s.RecordSourceCall(item)
|
|
}
|
|
for _, item := range prototype.AuditLogs {
|
|
_ = s.InsertAudit(item)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewFeedbackCode() string {
|
|
var data [3]byte
|
|
if _, err := rand.Read(data[:]); err != nil {
|
|
return "FB-" + time.Now().UTC().Format("20060102-150405")
|
|
}
|
|
return "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(data[:]))
|
|
}
|
|
|
|
func Now() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
func sanitize(value string) string {
|
|
return sanitizeLong(value, 1000)
|
|
}
|
|
|
|
func sanitizeLong(value string, max int) string {
|
|
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
|
value = strings.Map(func(r rune) rune {
|
|
if r == '\n' || r == '\r' || r == '\t' {
|
|
return r
|
|
}
|
|
if r < 32 {
|
|
return -1
|
|
}
|
|
return r
|
|
}, value)
|
|
runes := []rune(value)
|
|
if max > 0 && len(runes) > max {
|
|
return string(runes[:max])
|
|
}
|
|
return value
|
|
}
|
|
|
|
func passwordHash(password string) string {
|
|
sum := sha256.Sum256([]byte("ymhut-unified|" + strings.TrimSpace(password)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func subtleConstantCompare(hash, password string) bool {
|
|
expected := passwordHash(password)
|
|
return subtleConstantTimeCompare([]byte(hash), []byte(expected)) == 1
|
|
}
|
|
|
|
func subtleConstantTimeCompare(a, b []byte) int {
|
|
if len(a) != len(b) {
|
|
return 0
|
|
}
|
|
var v byte
|
|
for i := range a {
|
|
v |= a[i] ^ b[i]
|
|
}
|
|
if v == 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func boolInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizeCategory(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "suggestion", "ui", "other":
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
default:
|
|
return "issue"
|
|
}
|
|
}
|
|
|
|
func normalizePriority(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "major", "blocking":
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
default:
|
|
return "normal"
|
|
}
|
|
}
|
|
|
|
func defaultSLA(priority string) string {
|
|
switch normalizePriority(priority) {
|
|
case "blocking":
|
|
return "urgent"
|
|
case "major":
|
|
return "elevated"
|
|
default:
|
|
return "standard"
|
|
}
|
|
}
|
|
|
|
func defaultRisk(priority string) int {
|
|
switch normalizePriority(priority) {
|
|
case "blocking":
|
|
return 90
|
|
case "major":
|
|
return 65
|
|
default:
|
|
return 30
|
|
}
|
|
}
|
|
|
|
func normalizeTags(tags []string) []string {
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for _, tag := range tags {
|
|
tag = strings.ToLower(strings.Trim(strings.TrimSpace(tag), ",;#"))
|
|
if tag == "" || seen[tag] {
|
|
continue
|
|
}
|
|
runes := []rune(tag)
|
|
if len(runes) > 32 {
|
|
tag = string(runes[:32])
|
|
}
|
|
seen[tag] = true
|
|
out = append(out, tag)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func normalizeProxyMode(value, category, name, url string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
switch value {
|
|
case "server_proxy", "proxy":
|
|
return "server_proxy"
|
|
case "disabled":
|
|
return "disabled"
|
|
case "client_direct", "direct":
|
|
return "client_direct"
|
|
}
|
|
haystack := strings.ToLower(category + " " + name + " " + url)
|
|
for _, token := range []string{"ip", "weather", "location", "定位", "天气"} {
|
|
if strings.Contains(haystack, token) {
|
|
return "client_direct"
|
|
}
|
|
}
|
|
return "client_direct"
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|