56ffca767d
7-day full-access trial, then gated behind an Ed25519-signed license key (internal/license) verified entirely offline — no phone-home dependency. Middleware 402s non-exempt API routes once the trial lapses with no valid key; frontend shows a countdown banner in the last 3 days and a blocking overlay once actually gated, both pointing at the new Settings -> Лицензия tab. tools/gen-license mints keys for the vendor side (never ships the private key).
234 lines
10 KiB
Go
234 lines
10 KiB
Go
// Package settings is the app's single mutable configuration record —
|
|
// business requisites (for PDF documents, internal/document), the Gemini
|
|
// API key/model (internal/aiintake, internal/analytics, internal/diagnosis),
|
|
// the Telegram bot token/chat/base URL (internal/notify), and the IMEI
|
|
// lookup API key (internal/imei). One singleton row in Postgres (see
|
|
// migrations/007_settings.sql) rather than env vars, so an owner can edit
|
|
// these from the Settings page without a redeploy.
|
|
//
|
|
// Deliberately excludes true infrastructure secrets — JWT_SECRET,
|
|
// DATABASE_URL, MINIO_*, CORE_URL/MODULE_TOKEN,
|
|
// ONLINE_STORE_WEBHOOK_TOKEN. Those are how this process authenticates to
|
|
// its own infrastructure (a DB credential can't live inside the database it
|
|
// unlocks) or would invalidate every staff session / break inter-service
|
|
// auth if changed live through a web form — they stay .env, by user
|
|
// decision.
|
|
//
|
|
// Fetch falls back to the matching env var whenever the DB field is empty,
|
|
// so an existing deployment keeps working unchanged the moment this
|
|
// migration runs, before any owner has touched the Settings page.
|
|
package settings
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const defaultGeminiModel = "gemini-2.0-flash"
|
|
const defaultTGAPIBaseURL = "http://telegram-bot-api:8081"
|
|
|
|
type Settings struct {
|
|
BusinessName string `json:"business_name"`
|
|
BusinessINN string `json:"business_inn"`
|
|
BusinessKPP string `json:"business_kpp"`
|
|
BusinessAddress string `json:"business_address"`
|
|
BusinessPhone string `json:"business_phone"`
|
|
BusinessBankName string `json:"business_bank_name"`
|
|
BusinessBankAccount string `json:"business_bank_account"`
|
|
BusinessBankBIK string `json:"business_bank_bik"`
|
|
BusinessBankCorrAccount string `json:"business_bank_corr_account"`
|
|
GeminiAPIKey string `json:"gemini_api_key"`
|
|
GeminiModel string `json:"gemini_model"`
|
|
TGBotToken string `json:"tg_bot_token"`
|
|
TGChatID string `json:"tg_chat_id"`
|
|
TGAPIBaseURL string `json:"tg_api_base_url"`
|
|
IMEIAPIKey string `json:"imei_api_key"`
|
|
SMSProvider string `json:"sms_provider"`
|
|
SMSAPIID string `json:"sms_api_id"`
|
|
SMSFrom string `json:"sms_from"`
|
|
SMSEnabled bool `json:"sms_enabled"`
|
|
ClientTGBotUsername string `json:"client_tg_bot_username"`
|
|
TGWebhookSecret string `json:"tg_webhook_secret"`
|
|
ClientNotifyEnabled bool `json:"client_notify_enabled"`
|
|
PublicTrackingURL string `json:"public_tracking_url"`
|
|
LoyaltyEnabled bool `json:"loyalty_enabled"`
|
|
LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"`
|
|
DefaultWarrantyDays int `json:"default_warranty_days"`
|
|
KkmEnabled bool `json:"kkm_enabled"`
|
|
KkmServerURL string `json:"kkm_server_url"`
|
|
KkmLogin string `json:"kkm_login"`
|
|
KkmPassword string `json:"kkm_password"`
|
|
KkmNumDevice string `json:"kkm_num_device"`
|
|
KkmTax string `json:"kkm_tax"`
|
|
DiscountApprovalEnabled bool `json:"discount_approval_enabled"`
|
|
DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"`
|
|
MaxBotToken string `json:"max_bot_token"`
|
|
MaxWebhookSecret string `json:"max_webhook_secret"`
|
|
ClientMaxBotUsername string `json:"client_max_bot_username"`
|
|
VkGroupToken string `json:"vk_group_token"`
|
|
VkGroupID string `json:"vk_group_id"`
|
|
VkSecretKey string `json:"vk_secret_key"`
|
|
VkConfirmationCode string `json:"vk_confirmation_code"`
|
|
ClientVkCommunityID string `json:"client_vk_community_id"`
|
|
DiaxProEmail string `json:"diaxpro_email"`
|
|
DiaxProPassword string `json:"diaxpro_password"`
|
|
// TrialStartedAt is stamped once by migration 067 the moment this
|
|
// database is first created — see that migration's doc comment for why
|
|
// that's a reliable first-boot timestamp with no separate detection
|
|
// code. LicenseKey is empty until an owner pastes one into Settings;
|
|
// see internal/license for verification.
|
|
TrialStartedAt time.Time `json:"trial_started_at"`
|
|
LicenseKey string `json:"license_key"`
|
|
UpdatedAt *time.Time `json:"updated_at"`
|
|
UpdatedByStaffName string `json:"updated_by_staff_name"`
|
|
}
|
|
|
|
// Fetch reads the singleton settings row, one indexed SELECT on a 1-row
|
|
// table — cheap enough to call at the top of every request handler that
|
|
// needs one of these values, rather than caching them at process start,
|
|
// so a change made in the Settings page takes effect immediately for the
|
|
// next request without a restart.
|
|
func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) {
|
|
var s Settings
|
|
// Every TEXT column is nullable (COALESCE'd to '' here) — a fresh row
|
|
// starts fully empty until an owner fills in the Settings page, and
|
|
// pgx can't scan a SQL NULL into a plain (non-pointer) Go string.
|
|
err := db.QueryRow(ctx, `
|
|
SELECT COALESCE(business_name, ''), COALESCE(business_inn, ''), COALESCE(business_kpp, ''),
|
|
COALESCE(business_address, ''), COALESCE(business_phone, ''),
|
|
COALESCE(business_bank_name, ''), COALESCE(business_bank_account, ''),
|
|
COALESCE(business_bank_bik, ''), COALESCE(business_bank_corr_account, ''),
|
|
COALESCE(gemini_api_key, ''), COALESCE(gemini_model, ''),
|
|
COALESCE(tg_bot_token, ''), COALESCE(tg_chat_id, ''), COALESCE(tg_api_base_url, ''),
|
|
COALESCE(imei_api_key, ''),
|
|
COALESCE(sms_provider, ''), COALESCE(sms_api_id, ''), COALESCE(sms_from, ''), sms_enabled,
|
|
COALESCE(client_tg_bot_username, ''), COALESCE(tg_webhook_secret, ''), client_notify_enabled,
|
|
COALESCE(public_tracking_url, ''),
|
|
loyalty_enabled, loyalty_accrual_percent::text, default_warranty_days,
|
|
kkm_enabled, COALESCE(kkm_server_url, ''), COALESCE(kkm_login, ''),
|
|
COALESCE(kkm_password, ''), COALESCE(kkm_num_device, ''), COALESCE(kkm_tax, ''),
|
|
discount_approval_enabled, discount_approval_threshold_percent::text,
|
|
COALESCE(max_bot_token, ''), COALESCE(max_webhook_secret, ''), COALESCE(client_max_bot_username, ''),
|
|
COALESCE(vk_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''),
|
|
COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''),
|
|
COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''),
|
|
trial_started_at, COALESCE(license_key, ''),
|
|
updated_at, COALESCE(updated_by_staff_name, '')
|
|
FROM settings WHERE id = 1`,
|
|
).Scan(
|
|
&s.BusinessName, &s.BusinessINN, &s.BusinessKPP, &s.BusinessAddress, &s.BusinessPhone,
|
|
&s.BusinessBankName, &s.BusinessBankAccount, &s.BusinessBankBIK, &s.BusinessBankCorrAccount,
|
|
&s.GeminiAPIKey, &s.GeminiModel, &s.TGBotToken, &s.TGChatID, &s.TGAPIBaseURL, &s.IMEIAPIKey,
|
|
&s.SMSProvider, &s.SMSAPIID, &s.SMSFrom, &s.SMSEnabled,
|
|
&s.ClientTGBotUsername, &s.TGWebhookSecret, &s.ClientNotifyEnabled,
|
|
&s.PublicTrackingURL,
|
|
&s.LoyaltyEnabled, &s.LoyaltyAccrualPercent, &s.DefaultWarrantyDays,
|
|
&s.KkmEnabled, &s.KkmServerURL, &s.KkmLogin, &s.KkmPassword, &s.KkmNumDevice, &s.KkmTax,
|
|
&s.DiscountApprovalEnabled, &s.DiscountApprovalThresholdPercent,
|
|
&s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername,
|
|
&s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID,
|
|
&s.DiaxProEmail, &s.DiaxProPassword,
|
|
&s.TrialStartedAt, &s.LicenseKey,
|
|
&s.UpdatedAt, &s.UpdatedByStaffName,
|
|
)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
applyEnvFallback(&s)
|
|
return s, nil
|
|
}
|
|
|
|
func applyEnvFallback(s *Settings) {
|
|
if s.BusinessName == "" {
|
|
s.BusinessName = os.Getenv("BUSINESS_NAME")
|
|
}
|
|
if s.BusinessINN == "" {
|
|
s.BusinessINN = os.Getenv("BUSINESS_INN")
|
|
}
|
|
if s.BusinessKPP == "" {
|
|
s.BusinessKPP = os.Getenv("BUSINESS_KPP")
|
|
}
|
|
if s.BusinessAddress == "" {
|
|
s.BusinessAddress = os.Getenv("BUSINESS_ADDRESS")
|
|
}
|
|
if s.BusinessPhone == "" {
|
|
s.BusinessPhone = os.Getenv("BUSINESS_PHONE")
|
|
}
|
|
if s.BusinessBankName == "" {
|
|
s.BusinessBankName = os.Getenv("BUSINESS_BANK_NAME")
|
|
}
|
|
if s.BusinessBankAccount == "" {
|
|
s.BusinessBankAccount = os.Getenv("BUSINESS_BANK_ACCOUNT")
|
|
}
|
|
if s.BusinessBankBIK == "" {
|
|
s.BusinessBankBIK = os.Getenv("BUSINESS_BANK_BIK")
|
|
}
|
|
if s.BusinessBankCorrAccount == "" {
|
|
s.BusinessBankCorrAccount = os.Getenv("BUSINESS_BANK_CORR_ACCOUNT")
|
|
}
|
|
if s.GeminiAPIKey == "" {
|
|
s.GeminiAPIKey = os.Getenv("GEMINI_API_KEY")
|
|
}
|
|
if s.GeminiModel == "" {
|
|
s.GeminiModel = os.Getenv("GEMINI_MODEL")
|
|
}
|
|
if s.GeminiModel == "" {
|
|
s.GeminiModel = defaultGeminiModel
|
|
}
|
|
if s.TGBotToken == "" {
|
|
s.TGBotToken = os.Getenv("TG_BOT_TOKEN")
|
|
}
|
|
if s.TGChatID == "" {
|
|
s.TGChatID = os.Getenv("TG_CHAT_ID")
|
|
}
|
|
if s.TGAPIBaseURL == "" {
|
|
s.TGAPIBaseURL = os.Getenv("TG_API_BASE_URL")
|
|
}
|
|
if s.TGAPIBaseURL == "" {
|
|
s.TGAPIBaseURL = defaultTGAPIBaseURL
|
|
}
|
|
if s.IMEIAPIKey == "" {
|
|
s.IMEIAPIKey = os.Getenv("IMEI_API_KEY")
|
|
}
|
|
if s.SMSProvider == "" {
|
|
s.SMSProvider = os.Getenv("SMS_PROVIDER")
|
|
}
|
|
if s.SMSProvider == "" {
|
|
s.SMSProvider = "smsru"
|
|
}
|
|
if s.SMSAPIID == "" {
|
|
s.SMSAPIID = os.Getenv("SMS_API_ID")
|
|
}
|
|
if s.SMSFrom == "" {
|
|
s.SMSFrom = os.Getenv("SMS_FROM")
|
|
}
|
|
if s.ClientTGBotUsername == "" {
|
|
s.ClientTGBotUsername = os.Getenv("CLIENT_TG_BOT_USERNAME")
|
|
}
|
|
if s.TGWebhookSecret == "" {
|
|
s.TGWebhookSecret = os.Getenv("TG_WEBHOOK_SECRET")
|
|
}
|
|
if s.PublicTrackingURL == "" {
|
|
s.PublicTrackingURL = os.Getenv("PUBLIC_TRACKING_URL")
|
|
}
|
|
}
|
|
|
|
// TrackingURL builds the public /track/:token link for a client
|
|
// notification, or "" if PublicTrackingURL isn't configured — every caller
|
|
// already treats an empty string as "omit the link" (see clientnotify's
|
|
// OrderCreated/StatusChanged/Ready templates), so a fetch failure here
|
|
// degrades the same way rather than blocking the notification entirely.
|
|
// Was three near-identical copies (order, cartridge, and now booking's own
|
|
// handlers) before being pulled out here.
|
|
func TrackingURL(ctx context.Context, db *pgxpool.Pool, token string) string {
|
|
s, err := Fetch(ctx, db)
|
|
if err != nil || s.PublicTrackingURL == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimRight(s.PublicTrackingURL, "/") + "/" + token
|
|
}
|