mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 02:06:38 +08:00
Merge branch 'main' into pr/wss-https-fallback
This commit is contained in:
@@ -18,11 +18,12 @@ func (h *Handler) StartBackgroundJobs() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
h.jobsCancel = cancel
|
h.jobsCancel = cancel
|
||||||
h.jobsStarted = true
|
h.jobsStarted = true
|
||||||
h.jobsWG.Add(2)
|
h.jobsWG.Add(3)
|
||||||
h.jobsMu.Unlock()
|
h.jobsMu.Unlock()
|
||||||
|
|
||||||
go h.runHourlyStatsLoop(ctx)
|
go h.runHourlyStatsLoop(ctx)
|
||||||
go h.runDailyMaintenanceLoop(ctx)
|
go h.runDailyMaintenanceLoop(ctx)
|
||||||
|
go h.runNodeRenewalCycleLoop(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) StopBackgroundJobs() {
|
func (h *Handler) StopBackgroundJobs() {
|
||||||
@@ -176,3 +177,39 @@ func (h *Handler) disableExpiredUserTunnels(nowMs int64) {
|
|||||||
_ = h.repo.DisableUserTunnel(item.ID)
|
_ = h.repo.DisableUserTunnel(item.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) runNodeRenewalCycleLoop(ctx context.Context) {
|
||||||
|
defer h.jobsWG.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
wait := durationUntilNextNodeRenewalCycle(time.Now())
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
h.runNodeRenewalCycleJob(time.Now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationUntilNextNodeRenewalCycle(now time.Time) time.Duration {
|
||||||
|
next := now.Truncate(6 * time.Hour).Add(6 * time.Hour)
|
||||||
|
return next.Sub(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) runNodeRenewalCycleJob(now time.Time) {
|
||||||
|
if h == nil || h.repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced, err := h.repo.AdvanceNodeRenewalCycles(now.UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = advanced
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunNodeRenewalCycleJob_AdvancesOverdueAnchorTimes(t *testing.T) {
|
||||||
|
dbPath := t.TempDir() + "/renewal-test.db"
|
||||||
|
r, err := repo.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = r.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
nowMs := now.UnixMilli()
|
||||||
|
|
||||||
|
nodeID := int64(101)
|
||||||
|
err = r.DB().Exec(`
|
||||||
|
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, nodeID, "no-cycle-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "", nil).Error
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert test node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
quarterNodeID := int64(102)
|
||||||
|
err = r.DB().Exec(`
|
||||||
|
INSERT INTO node (id, name, secret, server_ip, port, http, tls, socks, created_time, status, renewal_cycle, expiry_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, quarterNodeID, "quarter-node", "test-secret", "192.168.1.1", "1000-65535", 1, 1, 1, nowMs, 1, "quarter", now.AddDate(0, -4, 0).UnixMilli()).Error
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert test node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{repo: r}
|
||||||
|
h.runNodeRenewalCycleJob(now)
|
||||||
|
|
||||||
|
var anchor sql.NullInt64
|
||||||
|
err = r.DB().Raw(`SELECT expiry_time FROM node WHERE id = ?`, quarterNodeID).Row().Scan(&anchor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query expiry_time: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedAnchor := now.AddDate(0, 2, 0).UnixMilli()
|
||||||
|
if !anchor.Valid || anchor.Int64 != expectedAnchor {
|
||||||
|
t.Fatalf("expected anchor %d (2026-05-08), got %d", expectedAnchor, anchor.Int64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,6 +264,7 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
|
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||||
asInt(req["http"], 0),
|
asInt(req["http"], 0),
|
||||||
asInt(req["tls"], 0),
|
asInt(req["tls"], 0),
|
||||||
asInt(req["socks"], 0),
|
asInt(req["socks"], 0),
|
||||||
@@ -332,6 +333,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
|
nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))),
|
||||||
newHTTP,
|
newHTTP,
|
||||||
newTLS,
|
newTLS,
|
||||||
newSocks,
|
newSocks,
|
||||||
@@ -3547,6 +3549,15 @@ func nullableUnixMilli(v int64) interface{} {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeNodeRenewalCycle(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "month", "quarter", "year":
|
||||||
|
return strings.ToLower(strings.TrimSpace(v))
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func nullableInt(v *int64) interface{} {
|
func nullableInt(v *int64) interface{} {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Node struct {
|
|||||||
Remark sql.NullString `gorm:"column:remark;type:text"`
|
Remark sql.NullString `gorm:"column:remark;type:text"`
|
||||||
Tags sql.NullString `gorm:"column:tags;type:text"`
|
Tags sql.NullString `gorm:"column:tags;type:text"`
|
||||||
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"`
|
||||||
|
RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"`
|
||||||
Secret string `gorm:"type:varchar(100);not null"`
|
Secret string `gorm:"type:varchar(100);not null"`
|
||||||
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"`
|
||||||
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||||
@@ -342,6 +343,7 @@ type NodeBackup struct {
|
|||||||
Remark string `json:"remark,omitempty"`
|
Remark string `json:"remark,omitempty"`
|
||||||
Tags string `json:"tags,omitempty"`
|
Tags string `json:"tags,omitempty"`
|
||||||
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||||
|
RenewalCycle string `json:"renewalCycle,omitempty"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
ServerIP string `json:"serverIp"`
|
ServerIP string `json:"serverIp"`
|
||||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
|||||||
m := db.Migrator()
|
m := db.Migrator()
|
||||||
|
|
||||||
if m.HasTable(&model.Node{}) {
|
if m.HasTable(&model.Node{}) {
|
||||||
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig"} {
|
for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
|
||||||
if m.HasColumn(&model.Node{}, field) {
|
if m.HasColumn(&model.Node{}, field) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -634,10 +634,11 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
items = append(items, map[string]interface{}{
|
items = append(items, map[string]interface{}{
|
||||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
||||||
"remark": nullableString(n.Remark),
|
"remark": nullableString(n.Remark),
|
||||||
"tags": nullableString(n.Tags),
|
"tags": nullableString(n.Tags),
|
||||||
"expiryTime": nullableInt64(n.ExpiryTime),
|
"expiryTime": nullableInt64(n.ExpiryTime),
|
||||||
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
"renewalCycle": nullableString(n.RenewalCycle),
|
||||||
|
"ip": n.ServerIP, "serverIp": n.ServerIP,
|
||||||
"serverIpV4": nullableString(n.ServerIPV4),
|
"serverIpV4": nullableString(n.ServerIPV4),
|
||||||
"serverIpV6": nullableString(n.ServerIPV6),
|
"serverIpV6": nullableString(n.ServerIPV6),
|
||||||
"extraIPs": nullableString(n.ExtraIPs),
|
"extraIPs": nullableString(n.ExtraIPs),
|
||||||
@@ -1819,7 +1820,7 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
b := model.NodeBackup{
|
b := model.NodeBackup{
|
||||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
||||||
Remark: n.Remark.String, Tags: n.Tags.String,
|
Remark: n.Remark.String, Tags: n.Tags.String, RenewalCycle: n.RenewalCycle.String,
|
||||||
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
||||||
CreatedTime: n.CreatedTime, Status: n.Status,
|
CreatedTime: n.CreatedTime, Status: n.Status,
|
||||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||||
@@ -2180,6 +2181,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
|||||||
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""},
|
||||||
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
|
Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""},
|
||||||
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0},
|
||||||
|
RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""},
|
||||||
Secret: n.Secret,
|
Secret: n.Secret,
|
||||||
ServerIP: n.ServerIP,
|
ServerIP: n.ServerIP,
|
||||||
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
||||||
@@ -2204,7 +2206,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
|||||||
err := tx.Clauses(clause.OnConflict{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
"name", "remark", "tags", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version",
|
||||||
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
||||||
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,10 +7,57 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
gsqlite "github.com/glebarez/sqlite"
|
gsqlite "github.com/glebarez/sqlite"
|
||||||
|
"go-backend/internal/store/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) {
|
||||||
|
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
if sqlDB != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE node (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
secret VARCHAR(100) NOT NULL,
|
||||||
|
server_ip VARCHAR(100) NOT NULL,
|
||||||
|
port TEXT NOT NULL,
|
||||||
|
interface_name VARCHAR(200),
|
||||||
|
version VARCHAR(100),
|
||||||
|
http INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
socks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER,
|
||||||
|
status INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy node table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := prepareSQLiteLegacyColumns(db); err != nil {
|
||||||
|
t.Fatalf("prepareSQLiteLegacyColumns: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := db.Migrator()
|
||||||
|
for _, field := range []string{"Remark", "Tags", "ExpiryTime", "RenewalCycle"} {
|
||||||
|
if !m.HasColumn(&model.Node{}, field) {
|
||||||
|
t.Fatalf("expected node.%s column to exist", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {
|
||||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repo
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -196,7 +197,7 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
|||||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -205,6 +206,7 @@ func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serve
|
|||||||
Remark: nullStringFromInterface(remark),
|
Remark: nullStringFromInterface(remark),
|
||||||
Tags: nullStringFromInterface(tags),
|
Tags: nullStringFromInterface(tags),
|
||||||
ExpiryTime: nullInt64FromInterface(expiryTime),
|
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||||
|
RenewalCycle: nullStringFromInterface(renewalCycle),
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
ServerIP: serverIP,
|
ServerIP: serverIP,
|
||||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
ServerIPV4: nullStringFromInterface(serverIPV4),
|
||||||
@@ -242,7 +244,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla
|
|||||||
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
return node.Status, node.HTTP, node.TLS, node.Socks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -253,6 +255,7 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
|||||||
"remark": nullStringFromInterface(remark),
|
"remark": nullStringFromInterface(remark),
|
||||||
"tags": nullStringFromInterface(tags),
|
"tags": nullStringFromInterface(tags),
|
||||||
"expiry_time": nullInt64FromInterface(expiryTime),
|
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||||
|
"renewal_cycle": nullStringFromInterface(renewalCycle),
|
||||||
"server_ip": serverIP,
|
"server_ip": serverIP,
|
||||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||||
@@ -1483,3 +1486,59 @@ func (r *Repository) ReplaceUserGroupsByUserID(userID int64, newGroupIDs []int64
|
|||||||
}
|
}
|
||||||
return affectedGroupIDs, nil
|
return affectedGroupIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) AdvanceNodeRenewalCycles(now int64) (int, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes []model.Node
|
||||||
|
if err := r.db.Where("renewal_cycle IS NOT NULL AND renewal_cycle != '' AND expiry_time IS NOT NULL").Find(&nodes).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("list nodes with renewal cycle: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
advanced := 0
|
||||||
|
for _, node := range nodes {
|
||||||
|
if !node.ExpiryTime.Valid || node.ExpiryTime.Int64 <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleMonths := 0
|
||||||
|
switch node.RenewalCycle.String {
|
||||||
|
case "month":
|
||||||
|
cycleMonths = 1
|
||||||
|
case "quarter":
|
||||||
|
cycleMonths = 3
|
||||||
|
case "year":
|
||||||
|
cycleMonths = 12
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
anchorTime := node.ExpiryTime.Int64
|
||||||
|
for anchorTime <= now {
|
||||||
|
nextAnchor := advanceByMonths(anchorTime, cycleMonths)
|
||||||
|
if nextAnchor <= anchorTime {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
anchorTime = nextAnchor
|
||||||
|
}
|
||||||
|
|
||||||
|
if anchorTime == node.ExpiryTime.Int64 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.db.Model(&model.Node{}).Where("id = ?", node.ID).Update("expiry_time", anchorTime).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
advanced++
|
||||||
|
}
|
||||||
|
|
||||||
|
return advanced, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func advanceByMonths(timestamp int64, months int) int64 {
|
||||||
|
t := time.Unix(timestamp/1000, 0)
|
||||||
|
next := t.AddDate(0, months, 0)
|
||||||
|
return next.UnixMilli()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Node Renewal Cycle And Schema Fix Plan
|
||||||
|
|
||||||
|
- [x] Review the node schema migration path and current expiry implementation
|
||||||
|
- [x] Backfill legacy node tables with the new metadata columns so old SQLite installs do not fail
|
||||||
|
- [x] Replace one-off node expiry UX with recurring renewal cycle fields (month/quarter/year)
|
||||||
|
- [x] Update node reminders and dashboard cards to use recurring renewal calculations
|
||||||
|
- [x] Verify backend and frontend changes, then complete the plan
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Node Renewal Auto-Advance Plan
|
||||||
|
|
||||||
|
- [x] Review existing background job infrastructure and decide integration points
|
||||||
|
- [x] Add Repository method to advance node renewal anchor times
|
||||||
|
- [x] Add backend background worker that runs every 6 hours to advance overdue cycles
|
||||||
|
- [x] Add unit tests for renewal cycle advancement logic
|
||||||
|
- [x] Run backend verification and update plan checklist
|
||||||
@@ -6,6 +6,7 @@ export interface NodeApiItem {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
|
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||||
syncError?: string;
|
syncError?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -221,6 +222,7 @@ export interface NodeMutationPayload {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
|
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||||
serverIp?: string;
|
serverIp?: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
serverIpV6?: string;
|
serverIpV6?: string;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
|||||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||||
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||||
|
import {
|
||||||
|
formatNodeRenewalTime,
|
||||||
|
getNodeRenewalCycleLabel,
|
||||||
|
getNodeRenewalSnapshot,
|
||||||
|
} from "@/pages/node/renewal";
|
||||||
import {
|
import {
|
||||||
useDashboardData,
|
useDashboardData,
|
||||||
type DashboardForward as Forward,
|
type DashboardForward as Forward,
|
||||||
@@ -72,23 +77,27 @@ export default function DashboardPage() {
|
|||||||
return value.toString();
|
return value.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNodeExpiryStatus = (expiryTime?: number) => {
|
const getNodeExpiryStatus = (
|
||||||
if (!expiryTime) {
|
nextDueTime?: number,
|
||||||
|
renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset",
|
||||||
|
) => {
|
||||||
|
if (!nextDueTime || renewalState === "unset") {
|
||||||
return {
|
return {
|
||||||
label: "永久有效",
|
label: "未设置",
|
||||||
badgeClassName:
|
badgeClassName:
|
||||||
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
|
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
|
||||||
|
nextDueTime: undefined as number | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffMs = expiryTime - Date.now();
|
const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays <= 0) {
|
if (renewalState === "expired" || diffDays <= 0) {
|
||||||
return {
|
return {
|
||||||
label: "已过期",
|
label: "已逾期",
|
||||||
badgeClassName:
|
badgeClassName:
|
||||||
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
|
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
|
||||||
|
nextDueTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +106,7 @@ export default function DashboardPage() {
|
|||||||
label: "明天到期",
|
label: "明天到期",
|
||||||
badgeClassName:
|
badgeClassName:
|
||||||
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
|
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
|
||||||
|
nextDueTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +116,7 @@ export default function DashboardPage() {
|
|||||||
diffDays <= 7
|
diffDays <= 7
|
||||||
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300"
|
||||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
|
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300",
|
||||||
|
nextDueTime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +130,14 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
|
||||||
const expiryStatus = getNodeExpiryStatus(node.expiryTime);
|
const renewalSnapshot = getNodeRenewalSnapshot(
|
||||||
|
node.expiryTime,
|
||||||
|
node.renewalCycle,
|
||||||
|
);
|
||||||
|
const expiryStatus = getNodeExpiryStatus(
|
||||||
|
renewalSnapshot.nextDueTime,
|
||||||
|
renewalSnapshot.state,
|
||||||
|
);
|
||||||
const tags = parseNodeTags(node.tags);
|
const tags = parseNodeTags(node.tags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,9 +160,11 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||||
{node.expiryTime
|
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
|
||||||
? new Date(node.expiryTime).toLocaleString()
|
</div>
|
||||||
: "未设置到期时间"}
|
|
||||||
|
<div className="mt-1 text-xs text-default-500">
|
||||||
|
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.remark?.trim() && (
|
{node.remark?.trim() && (
|
||||||
@@ -784,7 +804,7 @@ export default function DashboardPage() {
|
|||||||
节点到期提醒
|
节点到期提醒
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-default-500">
|
<p className="text-sm text-default-500">
|
||||||
展示 7 天内到期或已过期的节点,便于提前续费或清理
|
展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getUserPackageInfo,
|
getUserPackageInfo,
|
||||||
type AnnouncementData,
|
type AnnouncementData,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
import { getNodeRenewalSnapshot } from "@/pages/node/renewal";
|
||||||
import { getAdminFlag } from "@/utils/session";
|
import { getAdminFlag } from "@/utils/session";
|
||||||
|
|
||||||
export interface DashboardUserInfo {
|
export interface DashboardUserInfo {
|
||||||
@@ -59,8 +60,17 @@ export interface DashboardNodeExpiryItem {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
|
renewalCycle?: "month" | "quarter" | "year" | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeDashboardRenewalCycle = (
|
||||||
|
value: unknown,
|
||||||
|
): DashboardNodeExpiryItem["renewalCycle"] => {
|
||||||
|
return value === "month" || value === "quarter" || value === "year"
|
||||||
|
? value
|
||||||
|
: "";
|
||||||
|
};
|
||||||
|
|
||||||
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
||||||
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
||||||
"dashboard:last-expiration-notification";
|
"dashboard:last-expiration-notification";
|
||||||
@@ -213,16 +223,25 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
|
|||||||
name: item.name || "",
|
name: item.name || "",
|
||||||
remark: typeof item.remark === "string" ? item.remark : "",
|
remark: typeof item.remark === "string" ? item.remark : "",
|
||||||
tags: typeof item.tags === "string" ? item.tags : "",
|
tags: typeof item.tags === "string" ? item.tags : "",
|
||||||
|
renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle),
|
||||||
expiryTime:
|
expiryTime:
|
||||||
typeof item.expiryTime === "number" && item.expiryTime > 0
|
typeof item.expiryTime === "number" && item.expiryTime > 0
|
||||||
? item.expiryTime
|
? item.expiryTime
|
||||||
: undefined,
|
: undefined,
|
||||||
}))
|
}))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (!item.expiryTime) return false;
|
if (!item.expiryTime || !item.renewalCycle) return false;
|
||||||
return item.expiryTime <= now + warningWindowMs;
|
const snapshot = getNodeRenewalSnapshot(item.expiryTime, item.renewalCycle);
|
||||||
|
|
||||||
|
if (!snapshot.nextDueTime) return false;
|
||||||
|
return snapshot.nextDueTime <= now + warningWindowMs;
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0));
|
.sort((a, b) => {
|
||||||
|
const aDue = getNodeRenewalSnapshot(a.expiryTime, a.renewalCycle).nextDueTime || 0;
|
||||||
|
const bDue = getNodeRenewalSnapshot(b.expiryTime, b.renewalCycle).nextDueTime || 0;
|
||||||
|
|
||||||
|
return aDue - bDue;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDashboardData = (): DashboardDataState => {
|
export const useDashboardData = (): DashboardDataState => {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ import {
|
|||||||
getRemoteSyncErrorMessage,
|
getRemoteSyncErrorMessage,
|
||||||
} from "@/pages/node/display";
|
} from "@/pages/node/display";
|
||||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||||
|
import {
|
||||||
|
formatNodeRenewalTime,
|
||||||
|
getNodeRenewalCycleLabel,
|
||||||
|
getNodeRenewalSnapshot,
|
||||||
|
type NodeRenewalCycle,
|
||||||
|
} from "@/pages/node/renewal";
|
||||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||||
@@ -74,6 +80,7 @@ interface Node {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
expiryTime?: number;
|
expiryTime?: number;
|
||||||
|
renewalCycle?: NodeRenewalCycle;
|
||||||
ip: string;
|
ip: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
@@ -111,6 +118,7 @@ interface NodeForm {
|
|||||||
remark: string;
|
remark: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
|
renewalCycle: NodeRenewalCycle;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
serverIpV4: string;
|
serverIpV4: string;
|
||||||
serverIpV6: string;
|
serverIpV6: string;
|
||||||
@@ -124,34 +132,33 @@ interface NodeForm {
|
|||||||
socks: number; // 0 关 1 开
|
socks: number; // 0 关 1 开
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatNodeExpiry = (timestamp?: number): string => {
|
|
||||||
if (!timestamp || timestamp <= 0) return "永久有效";
|
|
||||||
return new Date(timestamp).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXPIRING_SOON_DAYS = 7;
|
const EXPIRING_SOON_DAYS = 7;
|
||||||
|
|
||||||
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired";
|
||||||
|
|
||||||
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry";
|
||||||
|
|
||||||
const getNodeExpiryMeta = (timestamp?: number) => {
|
const getNodeReminderEnabled = (node: Node): boolean => {
|
||||||
if (!timestamp || timestamp <= 0) {
|
return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {
|
||||||
|
const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);
|
||||||
|
|
||||||
|
if (renewal.state === "unset") {
|
||||||
return {
|
return {
|
||||||
state: "permanent" as NodeExpiryState,
|
state: "permanent" as NodeExpiryState,
|
||||||
label: "永久有效",
|
label: "未设置续费周期",
|
||||||
tone: "default" as const,
|
tone: "default" as const,
|
||||||
accentClassName: "",
|
accentClassName: "",
|
||||||
bannerClassName: "",
|
bannerClassName: "",
|
||||||
isHighlighted: false,
|
isHighlighted: false,
|
||||||
sortWeight: 3,
|
sortWeight: 3,
|
||||||
|
nextDueTime: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffMs = timestamp - Date.now();
|
if (renewal.state === "expired") {
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays <= 0) {
|
|
||||||
return {
|
return {
|
||||||
state: "expired" as NodeExpiryState,
|
state: "expired" as NodeExpiryState,
|
||||||
label: "已过期",
|
label: "已过期",
|
||||||
@@ -162,13 +169,14 @@ const getNodeExpiryMeta = (timestamp?: number) => {
|
|||||||
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300",
|
||||||
isHighlighted: true,
|
isHighlighted: true,
|
||||||
sortWeight: 0,
|
sortWeight: 0,
|
||||||
|
nextDueTime: renewal.nextDueTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffDays <= EXPIRING_SOON_DAYS) {
|
if (renewal.state === "dueSoon") {
|
||||||
return {
|
return {
|
||||||
state: "expiringSoon" as NodeExpiryState,
|
state: "expiringSoon" as NodeExpiryState,
|
||||||
label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`,
|
label: renewal.label,
|
||||||
tone: "warning" as const,
|
tone: "warning" as const,
|
||||||
accentClassName:
|
accentClassName:
|
||||||
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20",
|
||||||
@@ -176,17 +184,19 @@ const getNodeExpiryMeta = (timestamp?: number) => {
|
|||||||
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300",
|
||||||
isHighlighted: true,
|
isHighlighted: true,
|
||||||
sortWeight: 1,
|
sortWeight: 1,
|
||||||
|
nextDueTime: renewal.nextDueTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: "healthy" as NodeExpiryState,
|
state: "healthy" as NodeExpiryState,
|
||||||
label: `${diffDays}天后到期`,
|
label: renewal.label,
|
||||||
tone: "success" as const,
|
tone: "success" as const,
|
||||||
accentClassName: "",
|
accentClassName: "",
|
||||||
bannerClassName: "",
|
bannerClassName: "",
|
||||||
isHighlighted: false,
|
isHighlighted: false,
|
||||||
sortWeight: 2,
|
sortWeight: 2,
|
||||||
|
nextDueTime: renewal.nextDueTime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,6 +292,7 @@ export default function NodePage() {
|
|||||||
remark: "",
|
remark: "",
|
||||||
tags: "",
|
tags: "",
|
||||||
expiryTime: 0,
|
expiryTime: 0,
|
||||||
|
renewalCycle: "",
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
serverIpV6: "",
|
||||||
@@ -664,6 +675,10 @@ export default function NodePage() {
|
|||||||
newErrors.name = "节点名称长度不能超过50位";
|
newErrors.name = "节点名称长度不能超过50位";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime)) {
|
||||||
|
newErrors.expiryTime = "请同时设置续费周期和续费基准时间";
|
||||||
|
}
|
||||||
|
|
||||||
const v4 = form.serverIpV4.trim();
|
const v4 = form.serverIpV4.trim();
|
||||||
const v6 = form.serverIpV6.trim();
|
const v6 = form.serverIpV6.trim();
|
||||||
const host = form.serverHost.trim();
|
const host = form.serverHost.trim();
|
||||||
@@ -726,6 +741,7 @@ export default function NodePage() {
|
|||||||
remark: node.remark || "",
|
remark: node.remark || "",
|
||||||
tags: node.tags || "",
|
tags: node.tags || "",
|
||||||
expiryTime: node.expiryTime || 0,
|
expiryTime: node.expiryTime || 0,
|
||||||
|
renewalCycle: node.renewalCycle || "",
|
||||||
serverHost: normalizedHost,
|
serverHost: normalizedHost,
|
||||||
serverIpV4: normalizedV4,
|
serverIpV4: normalizedV4,
|
||||||
serverIpV6: normalizedV6,
|
serverIpV6: normalizedV6,
|
||||||
@@ -964,6 +980,7 @@ export default function NodePage() {
|
|||||||
remark: form.remark.trim(),
|
remark: form.remark.trim(),
|
||||||
tags: form.tags.trim(),
|
tags: form.tags.trim(),
|
||||||
expiryTime: form.expiryTime,
|
expiryTime: form.expiryTime,
|
||||||
|
renewalCycle: form.renewalCycle,
|
||||||
extraIPs: form.extraIPs,
|
extraIPs: form.extraIPs,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
@@ -988,6 +1005,7 @@ export default function NodePage() {
|
|||||||
remark: form.remark.trim(),
|
remark: form.remark.trim(),
|
||||||
tags: form.tags.trim(),
|
tags: form.tags.trim(),
|
||||||
expiryTime: form.expiryTime,
|
expiryTime: form.expiryTime,
|
||||||
|
renewalCycle: form.renewalCycle,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
form.serverIpV6?.trim() ||
|
form.serverIpV6?.trim() ||
|
||||||
@@ -1027,6 +1045,7 @@ export default function NodePage() {
|
|||||||
remark: "",
|
remark: "",
|
||||||
tags: "",
|
tags: "",
|
||||||
expiryTime: 0,
|
expiryTime: 0,
|
||||||
|
renewalCycle: "",
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
serverIpV6: "",
|
||||||
@@ -1162,11 +1181,13 @@ export default function NodePage() {
|
|||||||
const nodeExpiryStats = useMemo(() => {
|
const nodeExpiryStats = useMemo(() => {
|
||||||
return nodeList.reduce(
|
return nodeList.reduce(
|
||||||
(acc, node) => {
|
(acc, node) => {
|
||||||
const meta = getNodeExpiryMeta(node.expiryTime);
|
const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||||
|
|
||||||
if (meta.state === "expired") acc.expired += 1;
|
if (meta.state === "expired") acc.expired += 1;
|
||||||
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
if (meta.state === "expiringSoon") acc.expiringSoon += 1;
|
||||||
if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1;
|
if (getNodeReminderEnabled(node)) {
|
||||||
|
acc.withExpiry += 1;
|
||||||
|
}
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
{ expired: 0, expiringSoon: 0, withExpiry: 0 },
|
||||||
@@ -1195,7 +1216,7 @@ export default function NodePage() {
|
|||||||
|
|
||||||
if (nodeFilterMode !== "all") {
|
if (nodeFilterMode !== "all") {
|
||||||
filteredNodes = filteredNodes.filter((node) => {
|
filteredNodes = filteredNodes.filter((node) => {
|
||||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||||
|
|
||||||
switch (nodeFilterMode) {
|
switch (nodeFilterMode) {
|
||||||
case "expiringSoon":
|
case "expiringSoon":
|
||||||
@@ -1203,7 +1224,7 @@ export default function NodePage() {
|
|||||||
case "expired":
|
case "expired":
|
||||||
return expiryMeta.state === "expired";
|
return expiryMeta.state === "expired";
|
||||||
case "withExpiry":
|
case "withExpiry":
|
||||||
return !!node.expiryTime && node.expiryTime > 0;
|
return getNodeReminderEnabled(node);
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1212,8 +1233,8 @@ export default function NodePage() {
|
|||||||
|
|
||||||
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
const sortedByDb = [...filteredNodes].sort((a, b) => {
|
||||||
const expiryDiff =
|
const expiryDiff =
|
||||||
getNodeExpiryMeta(a.expiryTime).sortWeight -
|
getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -
|
||||||
getNodeExpiryMeta(b.expiryTime).sortWeight;
|
getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;
|
||||||
|
|
||||||
if (expiryDiff !== 0) {
|
if (expiryDiff !== 0) {
|
||||||
return expiryDiff;
|
return expiryDiff;
|
||||||
@@ -1286,13 +1307,13 @@ export default function NodePage() {
|
|||||||
全部节点
|
全部节点
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem key="expiringSoon" textValue="7天内到期">
|
<SelectItem key="expiringSoon" textValue="7天内到期">
|
||||||
7天内到期 ({nodeExpiryStats.expiringSoon})
|
7天内续费 ({nodeExpiryStats.expiringSoon})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem key="expired" textValue="已过期">
|
<SelectItem key="expired" textValue="已过期">
|
||||||
已过期 ({nodeExpiryStats.expired})
|
已逾期 ({nodeExpiryStats.expired})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
||||||
已设置到期时间 ({nodeExpiryStats.withExpiry})
|
已启用续费提醒 ({nodeExpiryStats.withExpiry})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1405,7 +1426,10 @@ export default function NodePage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{sortedNodes.map((node) => {
|
{sortedNodes.map((node) => {
|
||||||
const isRemoteNode = node.isRemote === 1;
|
const isRemoteNode = node.isRemote === 1;
|
||||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
const expiryMeta = getNodeExpiryMeta(
|
||||||
|
node.expiryTime,
|
||||||
|
node.renewalCycle,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem key={node.id} id={node.id}>
|
<SortableItem key={node.id} id={node.id}>
|
||||||
@@ -1514,12 +1538,12 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.expiryTime && node.expiryTime > 0 && (
|
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-default-600">到期时间</span>
|
<span className="text-default-600">下次续费</span>
|
||||||
<div className="text-right ml-2">
|
<div className="text-right ml-2">
|
||||||
<div className="text-xs text-warning-700 dark:text-warning-400">
|
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||||
{formatNodeExpiry(node.expiryTime)}
|
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Chip
|
<Chip
|
||||||
@@ -1531,6 +1555,9 @@ export default function NodePage() {
|
|||||||
{expiryMeta.label}
|
{expiryMeta.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-default-500">
|
||||||
|
{getNodeRenewalCycleLabel(node.renewalCycle)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1860,27 +1887,74 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Select
|
||||||
description="留空表示永久有效,可用于记录节点到期时间"
|
label="续费周期"
|
||||||
label="到期时间"
|
placeholder="选择续费周期"
|
||||||
type="datetime-local"
|
selectedKeys={form.renewalCycle ? [form.renewalCycle] : []}
|
||||||
value={
|
|
||||||
form.expiryTime > 0
|
|
||||||
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onChange={(e) =>
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as
|
||||||
|
| NodeRenewalCycle
|
||||||
|
| undefined;
|
||||||
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
expiryTime: e.target.value
|
renewalCycle: selected || "",
|
||||||
? new Date(e.target.value).getTime()
|
}));
|
||||||
: 0,
|
}}
|
||||||
}))
|
>
|
||||||
}
|
<SelectItem key="month" textValue="month">
|
||||||
/>
|
月付
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="quarter" textValue="quarter">
|
||||||
|
季付
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="year" textValue="year">
|
||||||
|
年付
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
|
||||||
|
errorMessage={errors.expiryTime}
|
||||||
|
isInvalid={!!errors.expiryTime}
|
||||||
|
label="续费基准时间"
|
||||||
|
type="datetime-local"
|
||||||
|
value={
|
||||||
|
form.expiryTime > 0
|
||||||
|
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
expiryTime: e.target.value
|
||||||
|
? new Date(e.target.value).getTime()
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
color="primary"
|
||||||
|
description="例如选择月付并填写 2026-03-01,系统会自动按每月同日推算下次续费时间。"
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
description="留空表示未设置续费提醒;只有同时设置周期和基准时间才会参与提醒与筛选"
|
||||||
|
label="说明"
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
form.renewalCycle && form.expiryTime > 0
|
||||||
|
? `当前按${getNodeRenewalCycleLabel(form.renewalCycle)}循环计算`
|
||||||
|
: "未启用循环续费提醒"
|
||||||
|
}
|
||||||
|
variant="bordered"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||||
errorMessage={errors.serverHost}
|
errorMessage={errors.serverHost}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
export type NodeRenewalCycle = "" | "month" | "quarter" | "year";
|
||||||
|
|
||||||
|
export interface NodeRenewalSnapshot {
|
||||||
|
cycle: NodeRenewalCycle;
|
||||||
|
anchorTime?: number;
|
||||||
|
nextDueTime?: number;
|
||||||
|
diffDays?: number;
|
||||||
|
state: "unset" | "expired" | "dueSoon" | "scheduled";
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMonths = (timestamp: number, months: number): number => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const next = new Date(date);
|
||||||
|
|
||||||
|
next.setMonth(next.getMonth() + months);
|
||||||
|
return next.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleToMonths = (cycle: NodeRenewalCycle): number => {
|
||||||
|
switch (cycle) {
|
||||||
|
case "month":
|
||||||
|
return 1;
|
||||||
|
case "quarter":
|
||||||
|
return 3;
|
||||||
|
case "year":
|
||||||
|
return 12;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNodeRenewalCycleLabel = (cycle?: string): string => {
|
||||||
|
switch (cycle) {
|
||||||
|
case "month":
|
||||||
|
return "月付";
|
||||||
|
case "quarter":
|
||||||
|
return "季付";
|
||||||
|
case "year":
|
||||||
|
return "年付";
|
||||||
|
default:
|
||||||
|
return "未设置";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNodeRenewalSnapshot = (
|
||||||
|
anchorTime?: number,
|
||||||
|
cycle?: string,
|
||||||
|
warningDays = 7,
|
||||||
|
): NodeRenewalSnapshot => {
|
||||||
|
const normalizedCycle =
|
||||||
|
cycle === "month" || cycle === "quarter" || cycle === "year" ? cycle : "";
|
||||||
|
|
||||||
|
if (!anchorTime || anchorTime <= 0 || !normalizedCycle) {
|
||||||
|
return {
|
||||||
|
cycle: normalizedCycle,
|
||||||
|
anchorTime: anchorTime && anchorTime > 0 ? anchorTime : undefined,
|
||||||
|
state: "unset",
|
||||||
|
label: "未设置续费周期",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMonths = cycleToMonths(normalizedCycle);
|
||||||
|
let nextDueTime = anchorTime;
|
||||||
|
|
||||||
|
while (nextDueTime < Date.now()) {
|
||||||
|
const advanced = addMonths(nextDueTime, intervalMonths);
|
||||||
|
|
||||||
|
if (advanced === nextDueTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextDueTime = advanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.ceil((nextDueTime - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 0) {
|
||||||
|
return {
|
||||||
|
cycle: normalizedCycle,
|
||||||
|
anchorTime,
|
||||||
|
nextDueTime,
|
||||||
|
diffDays,
|
||||||
|
state: "expired",
|
||||||
|
label: "今天到期",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffDays <= warningDays) {
|
||||||
|
return {
|
||||||
|
cycle: normalizedCycle,
|
||||||
|
anchorTime,
|
||||||
|
nextDueTime,
|
||||||
|
diffDays,
|
||||||
|
state: "dueSoon",
|
||||||
|
label: diffDays === 1 ? "明天续费" : `${diffDays}天后续费`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycle: normalizedCycle,
|
||||||
|
anchorTime,
|
||||||
|
nextDueTime,
|
||||||
|
diffDays,
|
||||||
|
state: "scheduled",
|
||||||
|
label: `${diffDays}天后续费`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatNodeRenewalTime = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || timestamp <= 0) {
|
||||||
|
return "未设置";
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user