421 lines
11 KiB
Go
421 lines
11 KiB
Go
//go:build llm_script
|
||
|
||
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "github.com/lib/pq"
|
||
)
|
||
|
||
type importTencentSubscriptionConfig struct {
|
||
URL string
|
||
Fixture string
|
||
DryRun bool
|
||
Timeout time.Duration
|
||
}
|
||
|
||
type subscriptionPlanRow struct {
|
||
ProviderName string
|
||
ProviderCN string
|
||
ProviderCountry string
|
||
OperatorName string
|
||
OperatorCN string
|
||
OperatorCountry string
|
||
OperatorType string
|
||
PlanFamily string
|
||
PlanCode string
|
||
PlanName string
|
||
Tier string
|
||
BillingCycle string
|
||
Currency string
|
||
ListPrice float64
|
||
PriceUnit string
|
||
QuotaValue int64
|
||
QuotaUnit string
|
||
ContextWindow int
|
||
PlanScope string
|
||
ModelScope string
|
||
SourceURL string
|
||
PublishedAt string
|
||
EffectiveDate string
|
||
Notes string
|
||
}
|
||
|
||
func main() {
|
||
loadImportProjectEnv()
|
||
|
||
var rawURL string
|
||
var fixturePath string
|
||
var dryRun bool
|
||
var timeoutSeconds int
|
||
|
||
flag.StringVar(&rawURL, "url", defaultTencentCatalogURL, "腾讯云公开目录 URL")
|
||
flag.StringVar(&fixturePath, "fixture", "", "本地 HTML/Text 样例文件,优先用于离线导入")
|
||
flag.BoolVar(&dryRun, "dry-run", false, "仅解析并打印摘要,不写入数据库")
|
||
flag.IntVar(&timeoutSeconds, "timeout", int(defaultTencentCatalogTimeout/time.Second), "请求超时(秒)")
|
||
flag.Parse()
|
||
|
||
cfg := importTencentSubscriptionConfig{
|
||
URL: rawURL,
|
||
Fixture: fixturePath,
|
||
DryRun: dryRun,
|
||
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
||
}
|
||
|
||
var db *sql.DB
|
||
var err error
|
||
if !cfg.DryRun {
|
||
dsn := os.Getenv("DATABASE_URL")
|
||
if dsn == "" {
|
||
dsn = "postgres://long@/llm_intelligence?host=/var/run/postgresql"
|
||
}
|
||
db, err = sql.Open("postgres", dsn)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer db.Close()
|
||
}
|
||
|
||
if err := runTencentSubscriptionImport(cfg, db, os.Stdout); err != nil {
|
||
fmt.Fprintf(os.Stderr, "import_tencent_subscription: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func loadImportProjectEnv() {
|
||
for _, path := range []string{".env.local", ".env"} {
|
||
loadImportEnvFile(path)
|
||
}
|
||
}
|
||
|
||
func loadImportEnvFile(path string) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
for _, line := range strings.Split(string(data), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
key, value, ok := strings.Cut(line, "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
key = strings.TrimSpace(key)
|
||
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if _, exists := os.LookupEnv(key); exists {
|
||
continue
|
||
}
|
||
_ = os.Setenv(key, value)
|
||
}
|
||
}
|
||
|
||
func runTencentSubscriptionImport(cfg importTencentSubscriptionConfig, db *sql.DB, out io.Writer) error {
|
||
raw, err := fetchTencentCatalogContent(fetchTencentCatalogConfig{
|
||
URL: cfg.URL,
|
||
DryRun: cfg.DryRun,
|
||
Timeout: cfg.Timeout,
|
||
Fixture: cfg.Fixture,
|
||
}, &http.Client{Timeout: cfg.Timeout})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
catalog, err := parseTencentCatalog(raw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
plans := buildSubscriptionPlans(catalog, cfg.URL)
|
||
if cfg.DryRun {
|
||
_, err = fmt.Fprintf(
|
||
out,
|
||
"source=tencent-subscription-import updated_at=%s plans=%d provider=%s operator=%s dry_run=true\n",
|
||
catalog.UpdatedAt,
|
||
len(plans),
|
||
plans[0].ProviderName,
|
||
plans[0].OperatorName,
|
||
)
|
||
return err
|
||
}
|
||
|
||
if db == nil {
|
||
return fmt.Errorf("db is required when dry-run=false")
|
||
}
|
||
|
||
if err := upsertSubscriptionPlans(db, plans); err != nil {
|
||
return err
|
||
}
|
||
|
||
var tableRows int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscription_plan`).Scan(&tableRows); err != nil {
|
||
return fmt.Errorf("count subscription_plan: %w", err)
|
||
}
|
||
|
||
summary := fmt.Sprintf(
|
||
"source=tencent-subscription-import updated_at=%s plans=%d provider=%s operator=%s table_rows=%d dry_run=false\n",
|
||
catalog.UpdatedAt,
|
||
len(plans),
|
||
plans[0].ProviderName,
|
||
plans[0].OperatorName,
|
||
tableRows,
|
||
)
|
||
if _, err := io.WriteString(out, summary); err != nil {
|
||
return err
|
||
}
|
||
if err := writeTencentImportSummary(summary); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func buildSubscriptionPlans(catalog tencentCatalog, sourceURL string) []subscriptionPlanRow {
|
||
modelsBySeries := make(map[string][]tencentModel)
|
||
for _, model := range catalog.Models {
|
||
modelsBySeries[model.Series] = append(modelsBySeries[model.Series], model)
|
||
}
|
||
|
||
plans := make([]subscriptionPlanRow, 0, len(catalog.Plans))
|
||
for _, plan := range catalog.Plans {
|
||
models := modelsBySeries[plan.Series]
|
||
plans = append(plans, subscriptionPlanRow{
|
||
ProviderName: "Tencent",
|
||
ProviderCN: "腾讯",
|
||
ProviderCountry: "CN",
|
||
OperatorName: "Tencent Cloud",
|
||
OperatorCN: "腾讯云",
|
||
OperatorCountry: "CN",
|
||
OperatorType: "cloud",
|
||
PlanFamily: inferPlanFamily(plan.Series),
|
||
PlanCode: slugifyPlanCode(plan.Series, plan.Tier),
|
||
PlanName: fmt.Sprintf("%s %s", plan.Series, plan.Tier),
|
||
Tier: plan.Tier,
|
||
BillingCycle: normalizeBillingCycle(plan.BillingCycle),
|
||
Currency: "CNY",
|
||
ListPrice: parsePlanPrice(plan.Price),
|
||
PriceUnit: "CNY/month",
|
||
QuotaValue: parseQuotaValue(plan.Quota),
|
||
QuotaUnit: "tokens/month",
|
||
ContextWindow: maxContextWindow(models),
|
||
PlanScope: plan.Series,
|
||
ModelScope: encodeModelScope(models),
|
||
SourceURL: sourceURL,
|
||
PublishedAt: catalog.UpdatedAt,
|
||
EffectiveDate: extractEffectiveDate(catalog.UpdatedAt),
|
||
Notes: strings.TrimSpace(plan.Scene),
|
||
})
|
||
}
|
||
return plans
|
||
}
|
||
|
||
func inferPlanFamily(series string) string {
|
||
lower := strings.ToLower(series)
|
||
if strings.Contains(lower, "coding plan") {
|
||
return "coding_plan"
|
||
}
|
||
return "token_plan"
|
||
}
|
||
|
||
func slugifyPlanCode(series string, tier string) string {
|
||
seriesCode := strings.TrimSpace(series)
|
||
switch seriesCode {
|
||
case "通用 Token Plan":
|
||
seriesCode = "token-plan"
|
||
case "Hy Token Plan":
|
||
seriesCode = "hy-token-plan"
|
||
}
|
||
|
||
raw := strings.ToLower(strings.TrimSpace(seriesCode + "-" + tier))
|
||
replacer := strings.NewReplacer(" ", "-", "/", "-", "_", "-", ".", "-", "(", "", ")", "", "(", "", ")", "", ":", "-", "--", "-")
|
||
raw = replacer.Replace(raw)
|
||
raw = strings.Trim(raw, "-")
|
||
return raw
|
||
}
|
||
|
||
func normalizeBillingCycle(raw string) string {
|
||
if strings.Contains(raw, "月") {
|
||
return "monthly"
|
||
}
|
||
return strings.TrimSpace(raw)
|
||
}
|
||
|
||
func parsePlanPrice(raw string) float64 {
|
||
value := strings.TrimSpace(strings.TrimSuffix(raw, "元/月"))
|
||
f, _ := strconv.ParseFloat(value, 64)
|
||
return f
|
||
}
|
||
|
||
func parseQuotaValue(raw string) int64 {
|
||
quotaPattern := regexp.MustCompile(`([\d.]+)\s*([万亿]?)\s*Tokens`)
|
||
matches := quotaPattern.FindStringSubmatch(raw)
|
||
if len(matches) != 3 {
|
||
return 0
|
||
}
|
||
base, _ := strconv.ParseFloat(matches[1], 64)
|
||
switch matches[2] {
|
||
case "万":
|
||
base *= 10000
|
||
case "亿":
|
||
base *= 100000000
|
||
}
|
||
return int64(base)
|
||
}
|
||
|
||
func maxContextWindow(models []tencentModel) int {
|
||
max := 0
|
||
for _, model := range models {
|
||
if model.ContextLength > max {
|
||
max = model.ContextLength
|
||
}
|
||
}
|
||
return max
|
||
}
|
||
|
||
func encodeModelScope(models []tencentModel) string {
|
||
ids := make([]string, 0, len(models))
|
||
for _, model := range models {
|
||
ids = append(ids, model.ModelID)
|
||
}
|
||
data, _ := json.Marshal(ids)
|
||
return string(data)
|
||
}
|
||
|
||
func extractEffectiveDate(updatedAt string) string {
|
||
if len(updatedAt) >= len("2006-01-02") {
|
||
return updatedAt[:10]
|
||
}
|
||
return time.Now().Format("2006-01-02")
|
||
}
|
||
|
||
func upsertSubscriptionPlans(db *sql.DB, plans []subscriptionPlanRow) error {
|
||
providerID, err := ensureModelProvider(db, plans[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
operatorID, err := ensureOperator(db, plans[0])
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, plan := range plans {
|
||
publishedAt, err := time.Parse("2006-01-02 15:04:05", plan.PublishedAt)
|
||
if err != nil {
|
||
return fmt.Errorf("parse published_at for %s: %w", plan.PlanCode, err)
|
||
}
|
||
effectiveDate, err := time.Parse("2006-01-02", plan.EffectiveDate)
|
||
if err != nil {
|
||
return fmt.Errorf("parse effective_date for %s: %w", plan.PlanCode, err)
|
||
}
|
||
|
||
_, err = db.Exec(
|
||
`INSERT INTO subscription_plan (
|
||
provider_id, operator_id, plan_family, plan_code, plan_name, tier,
|
||
billing_cycle, currency, list_price, price_unit, quota_value, quota_unit,
|
||
context_window, plan_scope, model_scope, source_url, published_at, effective_date, notes
|
||
) VALUES (
|
||
$1, $2, $3, $4, $5, $6,
|
||
$7, $8, $9, $10, $11, $12,
|
||
$13, $14, $15, $16, $17, $18, $19
|
||
)
|
||
ON CONFLICT (provider_id, plan_code, effective_date)
|
||
DO UPDATE SET
|
||
operator_id = EXCLUDED.operator_id,
|
||
plan_family = EXCLUDED.plan_family,
|
||
plan_name = EXCLUDED.plan_name,
|
||
tier = EXCLUDED.tier,
|
||
billing_cycle = EXCLUDED.billing_cycle,
|
||
currency = EXCLUDED.currency,
|
||
list_price = EXCLUDED.list_price,
|
||
price_unit = EXCLUDED.price_unit,
|
||
quota_value = EXCLUDED.quota_value,
|
||
quota_unit = EXCLUDED.quota_unit,
|
||
context_window = EXCLUDED.context_window,
|
||
plan_scope = EXCLUDED.plan_scope,
|
||
model_scope = EXCLUDED.model_scope,
|
||
source_url = EXCLUDED.source_url,
|
||
published_at = EXCLUDED.published_at,
|
||
notes = EXCLUDED.notes,
|
||
updated_at = CURRENT_TIMESTAMP`,
|
||
providerID, operatorID, plan.PlanFamily, plan.PlanCode, plan.PlanName, plan.Tier,
|
||
plan.BillingCycle, plan.Currency, plan.ListPrice, plan.PriceUnit, plan.QuotaValue, plan.QuotaUnit,
|
||
nullIfZero(plan.ContextWindow), plan.PlanScope, plan.ModelScope, plan.SourceURL, publishedAt, effectiveDate, plan.Notes,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("upsert subscription_plan %s: %w", plan.PlanCode, err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func ensureModelProvider(db *sql.DB, plan subscriptionPlanRow) (int64, error) {
|
||
var providerID int64
|
||
err := db.QueryRow(`SELECT id FROM model_provider WHERE name = $1`, plan.ProviderName).Scan(&providerID)
|
||
if err == nil {
|
||
return providerID, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return 0, err
|
||
}
|
||
|
||
err = db.QueryRow(
|
||
`INSERT INTO model_provider (name, name_cn, country, website, status)
|
||
VALUES ($1, $2, $3, $4, 'active')
|
||
RETURNING id`,
|
||
plan.ProviderName, plan.ProviderCN, plan.ProviderCountry, "https://cloud.tencent.com",
|
||
).Scan(&providerID)
|
||
return providerID, err
|
||
}
|
||
|
||
func ensureOperator(db *sql.DB, plan subscriptionPlanRow) (int64, error) {
|
||
var operatorID int64
|
||
err := db.QueryRow(`SELECT id FROM operator WHERE name = $1`, plan.OperatorName).Scan(&operatorID)
|
||
if err == nil {
|
||
return operatorID, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return 0, err
|
||
}
|
||
|
||
err = db.QueryRow(
|
||
`INSERT INTO operator (name, name_cn, country, website, description, status, type)
|
||
VALUES ($1, $2, $3, $4, $5, 'active', $6)
|
||
RETURNING id`,
|
||
plan.OperatorName, plan.OperatorCN, plan.OperatorCountry, "https://cloud.tencent.com",
|
||
"Tencent Cloud subscription plans", plan.OperatorType,
|
||
).Scan(&operatorID)
|
||
return operatorID, err
|
||
}
|
||
|
||
func nullIfZero(value int) any {
|
||
if value == 0 {
|
||
return nil
|
||
}
|
||
return value
|
||
}
|
||
|
||
func writeTencentImportSummary(summary string) error {
|
||
const summaryPath = "reports/verification/tencent_subscription_import_latest.txt"
|
||
if err := os.MkdirAll("reports/verification", 0755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(summaryPath, []byte(summary), 0644)
|
||
}
|