diff --git a/services/notice-service/cmd/import-im-accounts/main.go b/services/notice-service/cmd/import-im-accounts/main.go new file mode 100644 index 00000000..407db0be --- /dev/null +++ b/services/notice-service/cmd/import-im-accounts/main.go @@ -0,0 +1,119 @@ +// Command import-im-accounts performs an explicit, bounded Tencent IM account +// repair before replaying private notices that failed with invalid identifier. +package main + +import ( + "context" + "crypto/sha256" + "flag" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "hyapp/pkg/tencentim" + "hyapp/services/notice-service/internal/config" +) + +const executeConfirmation = "IMPORT_EXACT_TENCENT_IM_ACCOUNTS" + +type userIDFlags []int64 + +func (values *userIDFlags) String() string { + parts := make([]string, 0, len(*values)) + for _, value := range *values { + parts = append(parts, strconv.FormatInt(value, 10)) + } + return strings.Join(parts, ",") +} + +func (values *userIDFlags) Set(raw string) error { + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || value <= 0 { + return fmt.Errorf("user-id must be a positive int64") + } + *values = append(*values, value) + return nil +} + +func main() { + configPath := flag.String("config", "services/notice-service/configs/config.yaml", "notice-service config containing Tencent IM credentials") + execute := flag.Bool("execute", false, "perform the exact account imports; default is dry-run") + confirmation := flag.String("confirm", "", "required execution confirmation") + timeout := flag.Duration("timeout", 30*time.Second, "total import timeout") + var requested userIDFlags + flag.Var(&requested, "user-id", "exact internal user_id to import; repeat for multiple users") + flag.Parse() + + userIDs, err := normalizeUserIDs(requested) + if err != nil { + fatal(err) + } + if *timeout <= 0 || *timeout > 5*time.Minute { + fatal(fmt.Errorf("timeout must be within (0,5m]")) + } + if *execute && strings.TrimSpace(*confirmation) != executeConfirmation { + fatal(fmt.Errorf("--execute requires --confirm=%s", executeConfirmation)) + } + fingerprint := userIDsSHA256(userIDs) + if !*execute { + fmt.Printf("mode=dry-run users=%d user_ids_sha256=%s\n", len(userIDs), fingerprint) + return + } + + cfg, err := config.Load(strings.TrimSpace(*configPath)) + if err != nil { + fatal(err) + } + if !cfg.TencentIM.Enabled { + fatal(fmt.Errorf("tencent_im.enabled is required")) + } + client, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) + if err != nil { + fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + for _, userID := range userIDs { + // Account import is idempotent in the shared REST client: Tencent's + // already-exists response is success, while transport and other API errors + // stop the bounded batch before the notice replay is attempted. + if err := client.ImportAccount(ctx, tencentim.AccountProfile{UserID: userID}); err != nil { + fatal(fmt.Errorf("import user index %d: %w", sort.Search(len(userIDs), func(index int) bool { return userIDs[index] >= userID })+1, err)) + } + } + fmt.Printf("mode=execute imported=%d user_ids_sha256=%s\n", len(userIDs), fingerprint) +} + +func normalizeUserIDs(values []int64) ([]int64, error) { + if len(values) == 0 || len(values) > 100 { + return nil, fmt.Errorf("user-id count must be between 1 and 100") + } + normalized := append([]int64(nil), values...) + sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] }) + result := normalized[:0] + for _, value := range normalized { + if value <= 0 { + return nil, fmt.Errorf("user-id must be a positive int64") + } + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result, nil +} + +func userIDsSHA256(values []int64) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, strconv.FormatInt(value, 10)) + } + return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(parts, "\n")))) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/services/notice-service/cmd/import-im-accounts/main_test.go b/services/notice-service/cmd/import-im-accounts/main_test.go new file mode 100644 index 00000000..fce99e13 --- /dev/null +++ b/services/notice-service/cmd/import-im-accounts/main_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestNormalizeUserIDsSortsAndDeduplicatesExactSet(t *testing.T) { + got, err := normalizeUserIDs([]int64{42, 7, 42}) + if err != nil { + t.Fatalf("normalizeUserIDs failed: %v", err) + } + if len(got) != 2 || got[0] != 7 || got[1] != 42 { + t.Fatalf("normalized IDs mismatch: %+v", got) + } + if fingerprint := userIDsSHA256(got); fingerprint != userIDsSHA256([]int64{7, 42}) { + t.Fatalf("fingerprint must be stable, got %s", fingerprint) + } +} + +func TestNormalizeUserIDsRejectsUnsafeSets(t *testing.T) { + for _, values := range [][]int64{nil, {0}, {-1}} { + if _, err := normalizeUserIDs(values); err == nil { + t.Fatalf("unsafe user IDs must fail: %+v", values) + } + } +}