feat: add custom phone number support to card policies

- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
This commit is contained in:
MengMengCode
2026-08-11 21:58:28 +08:00
parent 609a591045
commit 0d738d4ce4
22 changed files with 609 additions and 116 deletions
+2 -2
View File
@@ -32,8 +32,8 @@ Usage:
GITHUB_TOKEN Optional bearer token for private repos
or higher rate limits.
vocat menu Interactive lifecycle menu (root on the host):
toggle language, change password, restart, update,
uninstall.
toggle language, change password, change the Web port,
restart, update, uninstall.
vocat help Show this help message.
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
+11 -8
View File
@@ -1070,15 +1070,18 @@ func enforceCardRegion(
}
}
if snapshot.ICCID != "" {
policy := store.CardPolicy{
ICCID: snapshot.ICCID,
NetworkEnabled: false,
VoWiFiEnabled: false,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: cardPolicySourceRegionBlock,
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
policyErr = nil
}
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
policy.NetworkEnabled = false
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = true
policy.Source = cardPolicySourceRegionBlock
if policyErr != nil && ctx.Err() == nil {
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
logger.Warn(
"region block: failed to persist card policy",
"device_id", id, "iccid", snapshot.ICCID, "error", err,
+222 -17
View File
@@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -25,6 +27,11 @@ import (
// rewrite it or the next restart reverts the password.
const envFilePath = "/etc/vocat/env"
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
// discoverable so the menu works on installations made before the installer
// and service template converged on /etc/vocat/env.
const legacyEnvFilePath = "/etc/vocat/vocat.env"
const systemdUnitPath = "/etc/systemd/system/vocat.service"
// defaultDatabasePath is the install-default SQLite location written into the
@@ -50,7 +57,7 @@ func loadMenuEnv() {
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
}
if data, err := os.ReadFile(envFilePath); err == nil {
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -69,10 +76,20 @@ func loadMenuEnv() {
}
}
func menuEnvFilePath() string {
if _, err := os.Stat(envFilePath); err == nil {
return envFilePath
}
if _, err := os.Stat(legacyEnvFilePath); err == nil {
return legacyEnvFilePath
}
return envFilePath
}
// runMenu is the interactive lifecycle menu: toggle language, change password,
// restart the systemd unit, self-update, or fully uninstall vocat. It must run
// as root on the host (needs systemctl + the 0600 env file). Docker deployments
// do not use it.
// change the Web listener port, restart the systemd unit, self-update, or fully
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
// env file). Docker deployments do not use it.
func runMenu(logger *slog.Logger) error {
if os.Geteuid() != 0 {
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
@@ -113,10 +130,14 @@ func runMenu(logger *slog.Logger) error {
fmt.Println(menu.errorPrefix(err))
}
case "3":
if err := menuRestart(menu); err != nil {
if err := menuChangeWebPort(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "4":
if err := menuRestart(menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "5":
if err := menuUpdate(menu, logger); err != nil {
fmt.Println(menu.errorPrefix(err))
}
@@ -242,9 +263,18 @@ func readPasswordMasked() (string, error) {
// the temp file lives in the same directory so os.Rename stays on one
// filesystem.
func rewriteEnvPassword(newPassword string) error {
const key = "VOCAT_ADMIN_PASSWORD="
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
}
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
// write is atomic and rejects line breaks so one setting cannot inject another.
func rewriteEnvValue(path, name, value string) error {
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
return errors.New("invalid environment setting")
}
key := name + "="
var lines []string
if data, err := os.ReadFile(envFilePath); err == nil {
if data, err := os.ReadFile(path); err == nil {
lines = strings.Split(string(data), "\n")
} else if !errors.Is(err, os.ErrNotExist) {
return err
@@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
replaced := false
for i, line := range lines {
if strings.HasPrefix(line, key) {
lines[i] = key + newPassword
lines[i] = key + value
replaced = true
break
}
}
if !replaced {
lines = append(lines, key+newPassword)
lines = append(lines, key+value)
}
content := strings.Join(lines, "\n")
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return writeEnvFileAtomic(path, []byte(content))
}
dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
func writeEnvFileAtomic(path string, content []byte) error {
dirIndex := strings.LastIndexAny(path, "/\\")
if dirIndex < 0 {
return errors.New("environment file path has no directory")
}
dir := path[:dirIndex]
tmp, err := os.CreateTemp(dir, ".vocat-env-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.WriteString(content); err != nil {
if _, err := tmp.Write(content); err != nil {
_ = tmp.Close()
return err
}
@@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error {
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, envFilePath)
return os.Rename(tmpName, path)
}
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
fmt.Println(m.currentWebAddress(cfg.Address))
fmt.Println(m.reverseProxyNotice())
fmt.Print(m.newWebPort(currentPortText))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read Web port: %w", err)
}
portText := strings.TrimSpace(line)
if portText == "" {
fmt.Println(m.webPortCancelled())
return nil
}
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
if err != nil {
return errInvalidWebPort
}
currentPort, _ := strconv.Atoi(currentPortText)
if newPort == currentPort {
fmt.Println(m.webPortUnchanged())
return nil
}
listener, err := net.Listen("tcp", newAddress)
if err != nil {
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
}
_ = listener.Close()
environmentPath := menuEnvFilePath()
original, readErr := os.ReadFile(environmentPath)
originalExisted := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
}
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
}
if err := restartVocatService(); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
_ = os.Setenv("VOCAT_ADDR", newAddress)
fmt.Println(m.webPortChanged(newAddress))
return nil
}
func webAddressWithPort(address, portText string) (string, int, error) {
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(strings.TrimSpace(portText))
if err != nil || port < 1 || port > 65535 {
return "", 0, errInvalidWebPort
}
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
}
func waitForWebListener(address string, timeout time.Duration) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
switch host {
case "", "0.0.0.0":
host = "127.0.0.1"
case "::":
host = "::1"
}
target := net.JoinHostPort(host, port)
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
if dialErr == nil {
_ = connection.Close()
return nil
}
lastErr = dialErr
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
}
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
if existed {
return writeEnvFileAtomic(path, content)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// menuToggleLanguage flips the persisted language preference between "zh" and
@@ -327,6 +482,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error {
}
func menuRestart(m *menu) error {
if err := restartVocatService(); err != nil {
return err
}
fmt.Println(m.restarted())
return nil
}
func restartVocatService() error {
if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl
}
@@ -334,7 +497,9 @@ func menuRestart(m *menu) error {
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
}
fmt.Println(m.restarted())
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
}
return nil
}
@@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
_ = os.Remove(systemdUnitPath)
_ = os.RemoveAll("/opt/vocat")
_ = os.Remove(envFilePath)
_ = os.Remove(legacyEnvFilePath)
_ = os.Remove("/etc/vocat") // succeeds only when empty
runIgnore("systemctl", "daemon-reload")
runIgnore("userdel", "vocat")
@@ -397,6 +563,9 @@ var (
errMenuStore = errors.New("menu: open database")
errMenuAuth = errors.New("menu: auth service")
errMenuEnvWrite = errors.New("menu: write env file")
errMenuPortWrite = errors.New("menu: write Web port")
errInvalidWebPort = errors.New("menu: invalid Web port")
errWebPortUnavailable = errors.New("menu: Web port unavailable")
)
// ---- i18n ----
@@ -412,8 +581,9 @@ func (m *menu) msg(key string) string {
"title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
"opt_restart": {"3) 重启软件", "3) Restart software"},
"opt_update": {"4) 更新软件", "4) Update software"},
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
"opt_restart": {"4) 重启软件", "4) Restart software"},
"opt_update": {"5) 更新软件", "5) Update software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
@@ -421,6 +591,15 @@ func (m *menu) msg(key string) string {
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
"reverse_proxy_notice": {
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
},
"lang_switched": {
"语言已切换。Web 界面下次刷新后同步。",
"Language switched. The web UI syncs on next refresh.",
@@ -452,6 +631,16 @@ func (m *menu) currentPassword() string { return m.msg("cur_pw") }
func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
func (m *menu) currentWebAddress(address string) string {
return fmt.Sprintf(m.msg("current_web_address"), address)
}
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
func (m *menu) webPortChanged(address string) string {
return fmt.Sprintf(m.msg("web_port_changed"), address)
}
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
func (m *menu) restarted() string { return m.msg("restarted") }
@@ -464,6 +653,7 @@ func (m *menu) options() []string {
return []string{
m.msg("opt_lang"),
m.msg("opt_change"),
m.msg("opt_port"),
m.msg("opt_restart"),
m.msg("opt_update"),
m.msg("opt_uninstall"),
@@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
return "认证服务错误。"
case errors.Is(err, errMenuEnvWrite):
if m.lang == "en" {
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
}
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
case errors.Is(err, errInvalidWebPort):
if m.lang == "en" {
return "Invalid port. Enter a number from 1 to 65535."
}
return "端口无效,请输入 1 到 65535。"
case errors.Is(err, errWebPortUnavailable):
if m.lang == "en" {
return "The new Web port is unavailable or already in use."
}
return "新的 Web 端口不可用或已被占用。"
case errors.Is(err, errMenuPortWrite):
if m.lang == "en" {
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
}
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
default:
if m.lang == "en" {
return "Error: " + err.Error()
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWebAddressWithPort(t *testing.T) {
tests := []struct {
name string
address string
port string
want string
wantErr bool
}{
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, _, err := webAddressWithPort(test.address, test.port)
if test.wantErr {
if !errors.Is(err, errInvalidWebPort) {
t.Fatalf("error = %v, want errInvalidWebPort", err)
}
return
}
if err != nil || got != test.want {
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "env")
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(content)
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
t.Fatalf("rewritten env = %q", got)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
t.Fatal("environment line injection was accepted")
}
}
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
options := strings.Join(newMenu(lang).options(), "\n")
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
}
}
}
-1
View File
@@ -13,7 +13,6 @@ Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=20s
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
EnvironmentFile=/etc/vocat/vocat.env
+13 -1
View File
@@ -274,7 +274,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policyErr = nil
}
if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = false
policy.VoWiFiEnabled = true
policy.AirplaneEnabled = true
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if s.vowifi == nil {
+7 -3
View File
@@ -263,11 +263,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
if selected.Snapshot != nil {
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
if iccid != "" {
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
_, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
})
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
}
+37 -1
View File
@@ -1231,13 +1231,14 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil {
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
writeError(
w,
http.StatusBadRequest,
@@ -1277,6 +1278,14 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
policy.IPVersion = ipVersion
}
if request.CustomPhoneNumber != nil {
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
if phoneErr != nil {
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
return
}
policy.CustomPhoneNumber = phoneNumber
}
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
@@ -1575,6 +1584,32 @@ func validICCID(value string) bool {
return true
}
func normalizeCustomPhoneNumber(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
var normalized strings.Builder
digitCount := 0
for index, character := range value {
switch {
case character >= '0' && character <= '9':
normalized.WriteRune(character)
digitCount++
case character == '+' && index == 0:
normalized.WriteRune(character)
case character == ' ' || character == '-' || character == '(' || character == ')':
// Common visual separators are accepted but not persisted.
default:
return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens")
}
}
if digitCount < 3 || digitCount > 20 {
return "", errors.New("custom phone number must contain between 3 and 20 digits")
}
return normalized.String(), nil
}
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{
"iccid": policy.ICCID,
@@ -1583,6 +1618,7 @@ func cardPolicyResponse(policy store.CardPolicy) map[string]any {
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source,
}
if !policy.CreatedAt.IsZero() {
+39 -3
View File
@@ -426,10 +426,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true {
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
t.Fatalf("default policy = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86 (138) 0013-8000"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" {
t.Fatalf("normalized custom phone number = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86-CALL-ME"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPut,
@@ -456,7 +481,7 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("saved policy = %#v", policy)
}
stored, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" {
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
@@ -471,10 +496,21 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" {
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("partially updated policy = %+v, %v", stored, err)
}
// Clearing the override restores system-number display without affecting the
// rest of this ICCID's policy.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`)
if recorder.Code != http.StatusOK {
t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" {
t.Fatalf("cleared custom phone policy = %+v, %v", stored, err)
}
// APN-only updates are accepted without changing either switch.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK {
+2 -2
View File
@@ -784,7 +784,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
APN: "ims", IPVersion: "ipv4v6",
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
}); err != nil {
t.Fatal(err)
}
@@ -794,7 +794,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled {
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
+5
View File
@@ -255,6 +255,11 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
}
case 15:
return []string{
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+1
View File
@@ -483,6 +483,7 @@ type CardPolicy struct {
AirplaneEnabled bool
APN string
IPVersion string
CustomPhoneNumber string
Source string
CreatedAt time.Time
UpdatedAt time.Time
+7 -5
View File
@@ -360,6 +360,7 @@ func maskedJSONValue(value json.RawMessage) bool {
func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
value.ICCID = strings.TrimSpace(value.ICCID)
value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber)
if value.ICCID == "" {
return errors.New("card policy ICCID is required")
}
@@ -381,20 +382,21 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
apn, ip_version, custom_phone_number, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled,
airplane_enabled = excluded.airplane_enabled,
apn = excluded.apn,
ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number,
source = excluded.source,
updated_at = excluded.updated_at
`,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
value.Source, createdAt.Unix(), updatedAt.Unix(),
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
@@ -440,7 +442,7 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
apn, ip_version, custom_phone_number, source, created_at, updated_at
FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) {
@@ -449,7 +451,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
&value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt,
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 14
const schemaVersion = 15
var ErrNotFound = errors.New("store: not found")
-1
View File
@@ -210,7 +210,6 @@ User=root
Group=root
WorkingDirectory=/opt/vocat
EnvironmentFile=${ENV_FILE}
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ExecStart=${BINARY_PATH}
Restart=on-failure
+69 -11
View File
@@ -1,25 +1,30 @@
import { useEffect, useState } from "react";
import { CardUiRegular } from "@fluentui/react-icons";
import { Tag } from "../ui";
import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types";
import { useI18n } from "../../lib/i18n";
import { apiMessage } from "../../api";
export interface CardPolicyPanelProps {
deviceId: string;
iccid?: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void;
onPolicyChanged: () => void | Promise<void>;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const flags = policy
? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled }
const currentPolicy = policy?.iccid === iccid ? policy : null;
const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || "");
const [phoneSaving, setPhoneSaving] = useState(false);
const flags = currentPolicy
? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled }
: null;
const toggles = useCardPolicyToggles(flags, {
@@ -28,9 +33,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onChanged: onPolicyChanged,
});
const isManual = policy?.source === "user" || policy?.source === "manual";
const sourceLabel = policy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual";
const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const { local } = toggles;
const savedPhoneNumber = currentPolicy?.customPhoneNumber || "";
const phoneChanged = customPhoneNumber.trim() !== savedPhoneNumber;
useEffect(() => {
setCustomPhoneNumber(currentPolicy?.customPhoneNumber || "");
}, [iccid, currentPolicy?.customPhoneNumber]);
const saveCustomPhoneNumber = async () => {
if (!iccid || phoneSaving || !phoneChanged) return;
setPhoneSaving(true);
try {
const saved = await updateCardPolicy(iccid, { customPhoneNumber: customPhoneNumber.trim() });
setCustomPhoneNumber(saved.customPhoneNumber || "");
message.success(saved.customPhoneNumber ? t("自定义手机号已保存") : t("已恢复显示系统读取的号码"));
await onPolicyChanged();
} catch (error) {
message.error(apiMessage(error) || t("保存自定义手机号失败"));
} finally {
setPhoneSaving(false);
}
};
return (
<div>
@@ -53,13 +79,45 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
) : null}
{iccid ? (
<div className="space-y-3">
<div className="ui-panel-muted flex items-center justify-between p-3">
<div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
<div className="min-w-0">
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
<div className="truncate font-mono text-sm text-gray-800 dark:text-gray-100" title={iccid}>{iccid}</div>
</div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="ui-panel-muted p-3">
<div className="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("自定义手机号")}</div>
<div className="flex items-center gap-2">
<Input
value={customPhoneNumber}
onChange={(event) => setCustomPhoneNumber(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCustomPhoneNumber();
}}
placeholder={t("请输入手机号(可留空)")}
inputMode="tel"
maxLength={32}
disabled={phoneSaving}
aria-label={t("自定义手机号")}
/>
<Button
variant="primary"
size="small"
className="shrink-0 !border-0"
loading={phoneSaving}
disabled={!phoneChanged}
onClick={() => void saveCustomPhoneNumber()}
>
{t("保存")}
</Button>
</div>
<div className="mt-1.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
title="VoWiFi"
@@ -85,7 +143,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
trafficSpeedRx: string;
trafficSpeedTx: string;
trafficMinuteRx: string;
@@ -39,6 +40,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
@@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
e911Starting: boolean;
onSetupE911: () => void;
}
export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
const { t } = useI18n();
const [showSensitive, toggleSensitive] = useShowSensitive();
const modem = device.modem;
@@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
@@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
<FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable />
<FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable />
<FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
{device?.e911SetupAvailable ? (
<div className="flex justify-between gap-3">
<span className="text-gray-500">{t("E911地址")}</span>
@@ -27,6 +27,7 @@ export interface CardPolicyUpdate {
airplaneEnabled?: boolean;
apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string;
}
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
+55
View File
@@ -5,6 +5,52 @@
* /
*/
export const EN_DICT: Record<string, string> = {
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
"新增 APN": "Add APN",
"正在读取 APN 列表...": "Loading APN profiles...",
"账号 / 认证": "Account / Authentication",
"协议 / 漫游": "Protocol / Roaming",
: "Source",
"运营商自动配置": "Carrier Automatic",
: "Default",
"模组已有": "Modem",
: "Custom",
: "Roaming",
"已设密码": "Password set",
使: "Active",
"APN 已启用": "APN enabled",
"已使用运营商自动 APN 配置": "Carrier automatic APN configuration enabled",
"启用 APN 失败": "Failed to enable APN",
"设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取":
"Device offline: custom profiles can still be managed; modem APNs will load when the device comes online",
"新增 APN 配置": "Add APN Profile",
"修改 APN 配置": "Edit APN Profile",
"保存修改": "Save Changes",
"添加到列表": "Add to List",
: "Leave blank",
"留空表示保持原密码": "Leave blank to keep the current password",
"APN 协议": "APN Protocol",
"APN 漫游协议": "APN Roaming Protocol",
"认证类型": "Authentication Type",
"清除已保存密码": "Clear Saved Password",
"关闭时,密码输入框留空会保持原密码": "When disabled, leaving the password blank keeps the current password",
"APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符":
"APN may contain only letters, numbers, dots, underscores, or hyphens, with a maximum of 100 characters",
"MCC 必须是 3 位数字": "MCC must be exactly 3 digits",
"MNC 必须是 2 或 3 位数字": "MNC must be 2 or 3 digits",
"自定义 APN 已添加,请点击启用后使用": "Custom APN added. Click Enable to use it",
"自定义 APN 已修改": "Custom APN updated",
"添加 APN 失败": "Failed to add APN",
"修改 APN 失败": "Failed to update APN",
"确定删除这个自定义 APN 配置吗?": "Delete this custom APN profile?",
"删除 APN": "Delete APN",
"APN 已删除,并恢复运营商自动配置": "APN deleted; carrier automatic configuration restored",
"自定义 APN 已删除": "Custom APN deleted",
"删除 APN 失败": "Failed to delete APN",
: "Edit",
// External extensions.
"插件": "Plugins",
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
@@ -939,6 +985,15 @@ export const EN_DICT: Record<string, string> = {
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)",
// ---- Per-Profile phone display override ----
"自定义手机号": "Custom Phone Number",
"请输入手机号(可留空)": "Enter a phone number (optional)",
"自定义手机号已保存": "Custom phone number saved",
"已恢复显示系统读取的号码": "Restored the system-detected number",
"保存自定义手机号失败": "Failed to save custom phone number",
"支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码":
"Supports a leading + and 320 digits. Leave blank to show the number read from the SIM/network.",
// ---- AT 快捷指令(按 group · item 分组翻译) ----
: "Basics",
: "Network Control",
+1
View File
@@ -589,6 +589,7 @@ export default function DevicesPage() {
<DeviceOverviewTab
device={detail}
simOperatorDisplay={simOperator}
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
trafficSpeedRx={''}
trafficSpeedTx={''}
trafficMinuteRx={''}
+1
View File
@@ -230,6 +230,7 @@ export interface CardPolicy {
airplaneEnabled: boolean;
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
source?: string;
createdAt?: string;
updatedAt?: string;