Files
VoCat/internal/store/models.go
T
MengMengCode 3cc73f1885 feat: implement eSIM notification handling and email message construction
- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
2026-08-11 18:58:32 +08:00

670 lines
15 KiB
Go

package store
import (
"bytes"
"encoding/json"
"errors"
"io"
"reflect"
"sort"
"strconv"
"strings"
"time"
)
const SecretMask = "********"
type Device struct {
ID string
Name string
DeviceType string
Interface string
ControlDevice string
ATPort string
USBPath string
AudioDevice string
ModemIMEI string
APN string
ProxyPort int
BaudRate int
DataBits int
StopBits int
Parity string
DeviceBackend string
ESIMTransport string
QMIUseProxy bool
QMIProxyPath string
QMIProxyExecutable string
NetworkEnabled bool
SMSEnabled bool
VoWiFiEnabled bool
Extra json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type DeviceRuntime struct {
DeviceID string
Running bool
Healthy bool
ControlOnline bool
PhysicalPresent bool
WorkerRunning bool
DataConnected bool
RadioRegistered bool
NetworkConnected bool
FlightMode bool
LifecyclePhase string
LifecycleReason string
PublicIP string
PrivateIP string
Operator string
NativeMCC string
NativeMNC string
NativeSPN string
NetworkMode string
NetworkDuplex string
RadioBand string
RadioChannel int
SignalDBM int
SignalRSRP *int
SignalRSRQ *int
SignalSINR *int
IMEI string
ICCID string
IMSI string
Firmware string
RegStatus int
RegStatusText string
PSAttached *bool
SIMInserted *bool
OperatingMode *int
PhoneNumber string
PhoneNumberSource string
Traffic json.RawMessage
Extra json.RawMessage
UpdatedAt time.Time
}
type VoWiFiRuntime struct {
DeviceID string
Phase string
DataplaneMode string
ICCID string
IMSI string
SIMReady bool
AccessReady bool
TunnelReady bool
IMSReady bool
SMSReady bool
RegStatus int
RegStatusText string
NetworkMode string
LocalPhone string
PhoneNumberSource string
LastErrorClass string
LastError string
LastReason string
Tunnel json.RawMessage
IMSCore json.RawMessage
SMSIP json.RawMessage
Extra json.RawMessage
UpdatedAt time.Time
}
// PhoneAssociation is a number explicitly published by IMS for one SIM. It is
// keyed by ICCID so a verified number survives service restarts and follows the
// SIM without ever being inferred from IMSI.
type PhoneAssociation struct {
ICCID string
DeviceID string
Number string
Source string
CreatedAt time.Time
UpdatedAt time.Time
}
type AutomaticTask struct {
ID int64 `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
DeviceID string `json:"device_id"`
ProfileICCID string `json:"profile_iccid"`
ProfileAID string `json:"profile_aid"`
TaskType string `json:"task_type"`
Environment string `json:"environment"`
IntervalDays int `json:"interval_days"`
StartDate string `json:"start_date"`
RunTime string `json:"run_time"`
Timezone string `json:"timezone"`
Payload json.RawMessage `json:"payload"`
RetryCount int `json:"retry_count"`
Notify bool `json:"notify"`
NextRunAt time.Time `json:"next_run_at"`
LastRunAt time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status"`
LastError string `json:"last_error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AutomaticTaskRun struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
DeviceID string `json:"device_id"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Status string `json:"status"`
Attempts int `json:"attempts"`
Output string `json:"output"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SMSMessage struct {
ID int64
MessageID string
DeviceID string
ModemIMEI string
IMSI string
Peer string
Direction string
Body string
Timestamp time.Time
Status string
Source string
PartsTotal int
DeliveryState string
Read bool
Extra json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type SMSFilter struct {
DeviceID string
ModemIMEI string
IMSI string
Peer string
Since time.Time
Until time.Time
BeforeID int64
Limit int
}
// SMSDeliveryReport is network evidence for one submitted SMS part. The
// message reference is the TP-MR returned in SMS-STATUS-REPORT.
type SMSDeliveryReport struct {
DeviceID string
ModemIMEI string
IMSI string
Peer string
Source string
MessageReference int
StatusCode int
DeliveryState string
ServiceCenterTime *time.Time
DischargeTime *time.Time
ReceivedAt time.Time
}
type SMSContact struct {
DeviceID string
DeviceName string
ModemIMEI string
IMSI string
LocalPhone string
Peer string
DisplayName string
LastMessage string
LastTimestamp time.Time
Direction string
LastSMSID int64
UnreadCount int
MessageCount int
}
type LocalProxyConfig struct {
ID string
Name string
Mode string
DeviceID string
ListenAddr string
ListenPort int
Enabled bool
AuthEnabled bool
Username string
Password string
Extra json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
func (value LocalProxyConfig) Redacted() LocalProxyConfig {
if value.Password != "" {
value.Password = SecretMask
}
return value
}
func (value LocalProxyConfig) Public() LocalProxyConfig {
value.Password = ""
return value
}
func (value LocalProxyConfig) SensitiveValues() []string {
if value.Password == "" || value.Password == SecretMask {
return nil
}
return []string{value.Password}
}
type UpstreamProxy struct {
ID string
Name string
Addr string
Username string
Password string
Enabled bool
Extra json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
func (value UpstreamProxy) Redacted() UpstreamProxy {
if value.Password != "" {
value.Password = SecretMask
}
return value
}
func (value UpstreamProxy) Public() UpstreamProxy {
value.Password = ""
return value
}
func (value UpstreamProxy) SensitiveValues() []string {
if value.Password == "" || value.Password == SecretMask {
return nil
}
return []string{value.Password}
}
type CountryRule struct {
CountryCode string
CountryName string
UpstreamProxyID string
Enabled bool
Extra json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile.
// ICCID is globally unique, while one proxy may serve profiles on many devices.
type DeviceProxyBinding struct {
DeviceID string
ICCID string
ProfileName string
UpstreamProxyID string
CreatedAt time.Time
UpdatedAt time.Time
}
type NotificationSetting struct {
Channel string
Enabled bool
Config json.RawMessage
SensitiveFields []string
CreatedAt time.Time
UpdatedAt time.Time
}
func (value NotificationSetting) Redacted() NotificationSetting {
value.Config = redactJSONFields(value.Config, value.SensitiveFields, SecretMask)
return value
}
func (value NotificationSetting) Public() NotificationSetting {
value.Config = redactJSONFields(value.Config, value.SensitiveFields, "")
return value
}
func (value NotificationSetting) SensitiveValues() []string {
document, err := decodeJSONObject(value.Config)
if err != nil {
return nil
}
values := make([]string, 0, len(value.SensitiveFields))
for _, field := range value.SensitiveFields {
if secret, ok := getJSONPath(document, field).(string); ok &&
secret != "" && secret != SecretMask {
values = append(values, secret)
}
}
return values
}
type AppSetting struct {
Key string
Value json.RawMessage
Sensitive bool
UpdatedAt time.Time
}
func (value AppSetting) Redacted() AppSetting {
if value.Sensitive {
value.Value = json.RawMessage(strconv.Quote(SecretMask))
}
return value
}
func (value AppSetting) Public() AppSetting {
if value.Sensitive {
value.Value = json.RawMessage(`null`)
}
return value
}
func (value AppSetting) SensitiveValues() []string {
if !value.Sensitive {
return nil
}
var decoded any
decoder := json.NewDecoder(bytes.NewReader(value.Value))
decoder.UseNumber()
if decoder.Decode(&decoded) != nil {
return nil
}
var values []string
collectJSONStringValues(decoded, &values)
return uniqueNonemptyStrings(values)
}
type SensitiveValuesProvider interface {
SensitiveValues() []string
}
func RedactText(text string, providers ...SensitiveValuesProvider) string {
var secrets []string
for _, provider := range providers {
if !nilSensitiveProvider(provider) {
secrets = append(secrets, provider.SensitiveValues()...)
}
}
sort.Slice(secrets, func(i, j int) bool {
return len(secrets[i]) > len(secrets[j])
})
for _, secret := range secrets {
if secret != "" && secret != SecretMask {
text = strings.ReplaceAll(text, secret, SecretMask)
}
}
return text
}
func nilSensitiveProvider(provider SensitiveValuesProvider) bool {
if provider == nil {
return true
}
value := reflect.ValueOf(provider)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map,
reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func collectJSONStringValues(value any, result *[]string) {
switch typed := value.(type) {
case string:
if typed != "" && typed != SecretMask {
*result = append(*result, typed)
}
case []any:
for _, item := range typed {
collectJSONStringValues(item, result)
}
case map[string]any:
for _, item := range typed {
collectJSONStringValues(item, result)
}
}
}
type AuditEvent struct {
ID int64
Actor string
Action string
EntityType string
EntityID string
Outcome string
RemoteAddr string
Details json.RawMessage
CreatedAt time.Time
}
type AuditFilter struct {
Actor string
Action string
EntityType string
EntityID string
Since time.Time
Until time.Time
BeforeID int64
Limit int
}
type LogEvent struct {
ID int64
Time time.Time
Level string
Message string
Caller string
Fields json.RawMessage
}
type LogFilter struct {
Level string
Since time.Time
Until time.Time
BeforeID int64
Limit int
}
type CardPolicy struct {
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
Source string
CreatedAt time.Time
UpdatedAt time.Time
}
type CardAPNProfile struct {
ID int64
ICCID string
APN string
Username string
Password string
Proxy string
MCC string
MNC string
IPVersion string
RoamingIPVersion string
AuthType string
CreatedAt time.Time
UpdatedAt time.Time
}
type TrafficBucket struct {
DeviceID string
Bucket string
PeriodStart time.Time
RXBytes int64
TXBytes int64
}
func (value TrafficBucket) TotalBytes() int64 {
return value.RXBytes + value.TXBytes
}
type TrafficFilter struct {
DeviceID string
Bucket string
Since time.Time
Until time.Time
Limit int
}
func normalizeJSONObject(value json.RawMessage) (json.RawMessage, error) {
if len(bytes.TrimSpace(value)) == 0 {
return json.RawMessage(`{}`), nil
}
document, err := decodeJSONObject(value)
if err != nil {
return nil, err
}
normalized, err := json.Marshal(document)
if err != nil {
return nil, err
}
return normalized, nil
}
func normalizeJSONValue(value json.RawMessage) (json.RawMessage, error) {
if len(bytes.TrimSpace(value)) == 0 {
return json.RawMessage(`null`), nil
}
if !json.Valid(value) {
return nil, errors.New("invalid JSON value")
}
return append(json.RawMessage(nil), value...), nil
}
func decodeJSONObject(value json.RawMessage) (map[string]any, error) {
var document map[string]any
decoder := json.NewDecoder(bytes.NewReader(value))
decoder.UseNumber()
if err := decoder.Decode(&document); err != nil {
return nil, err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return nil, errors.New("JSON value must contain exactly one object")
}
return nil, err
}
if document == nil {
return nil, errors.New("JSON value must be an object")
}
return document, nil
}
func redactJSONFields(value json.RawMessage, fields []string, replacement string) json.RawMessage {
document, err := decodeJSONObject(value)
if err != nil {
return json.RawMessage(`{}`)
}
for _, field := range fields {
if getJSONPath(document, field) != nil {
setJSONPath(document, field, replacement)
}
}
encoded, err := json.Marshal(document)
if err != nil {
return json.RawMessage(`{}`)
}
return encoded
}
func mergeJSONSecrets(
incoming json.RawMessage,
existing json.RawMessage,
fields []string,
) (json.RawMessage, error) {
next, err := decodeJSONObject(incoming)
if err != nil {
return nil, err
}
current, err := decodeJSONObject(existing)
if err != nil {
current = map[string]any{}
}
for _, field := range fields {
value := getJSONPath(next, field)
text, stringValue := value.(string)
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, previous)
}
}
}
return json.Marshal(next)
}
func getJSONPath(document map[string]any, path string) any {
if strings.TrimSpace(path) == "" {
return nil
}
parts := strings.Split(path, ".")
var current any = document
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current, ok = object[part]
if !ok {
return nil
}
}
return current
}
func setJSONPath(document map[string]any, path string, value any) {
if strings.TrimSpace(path) == "" {
return
}
parts := strings.Split(path, ".")
current := document
for _, part := range parts[:len(parts)-1] {
next, ok := current[part].(map[string]any)
if !ok {
next = map[string]any{}
current[part] = next
}
current = next
}
current[parts[len(parts)-1]] = value
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func nullableBool(value *bool) any {
if value == nil {
return nil
}
return boolInt(*value)
}
func nullableInt(value *int) any {
if value == nil {
return nil
}
return *value
}