From 84b162e834ab50daed36655576aeca7f601c79df Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 14:01:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(nodectl):=20notice=20add/list/revoke=20?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4(=E6=A0=A1=E9=AA=8C+=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1+=E5=8F=AF=E9=80=89=E9=82=AE=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/cmd/nodectl/main.go | 11 +- server/cmd/nodectl/notice.go | 271 ++++++++++++++++++++++++++++++ server/cmd/nodectl/notice_test.go | 136 +++++++++++++++ 3 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 server/cmd/nodectl/notice.go create mode 100644 server/cmd/nodectl/notice_test.go diff --git a/server/cmd/nodectl/main.go b/server/cmd/nodectl/main.go index d1353b1..bf336ec 100644 --- a/server/cmd/nodectl/main.go +++ b/server/cmd/nodectl/main.go @@ -67,9 +67,16 @@ nodectl — elastic-node control plane CLI nodectl rotate-pool -pool=consumable|premium [-concurrency=1] nodectl providers [-pool=consumable|premium] nodectl bootstrap-token -node=UUID + nodectl notice add -type= + -title-zh=... -title-en=... [-body-zh=...] [-body-en=...] + [-link=...] [-expires=YYYY-MM-DD] [-email] + nodectl notice list [-all] [-limit=20] + nodectl notice revoke Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, PROVISION_CONTROL_PLANE_URL, - PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials.`)) + PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials, + SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD/SMTP_FROM + (notice add -email; unset = -email rejected with an error).`)) } func buildService(ctx context.Context) (*provision.Service, func(), error) { @@ -118,6 +125,8 @@ func run(ctx context.Context, cmd string, args []string) error { return cmdProviders(ctx, args) case "bootstrap-token": return cmdBootstrapToken(ctx, args) + case "notice": + return cmdNotice(ctx, args) case "-h", "--help", "help": usage() return nil diff --git a/server/cmd/nodectl/notice.go b/server/cmd/nodectl/notice.go new file mode 100644 index 0000000..935fe6c --- /dev/null +++ b/server/cmd/nodectl/notice.go @@ -0,0 +1,271 @@ +// notice.go implements `nodectl notice add/list/revoke` — the operator CLI +// front-end for the notices package (system notifications: broadcast +// announcements with an optional important-only email fallback). +// +// DB access reuses the same env-driven open pattern as buildService +// (db.Open resolves driver via DB_DRIVER, defaulting to mysql, and reads +// DB_DSN — see internal/db/db.go — so this also works against the sqlite +// single-node deploy without any extra env here). +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "io" + "net/mail" + "net/smtp" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/wangjia/pangolin/server/internal/db" + "github.com/wangjia/pangolin/server/internal/notices" +) + +// noticeTypes mirrors notices.validNoticeTypes (unexported in that package, +// kept in sync manually — see migrations/sqlite/000026_notices.up.sql CHECK). +var noticeTypes = map[string]bool{ + "important": true, + "feature": true, + "news": true, + "reward": true, + "version": true, + "promo": true, +} + +func cmdNotice(ctx context.Context, args []string) error { + if len(args) == 0 { + return fmt.Errorf("notice: subcommand required (add|list|revoke)") + } + sub := args[0] + rest := args[1:] + switch sub { + case "add": + return cmdNoticeAdd(ctx, rest) + case "list": + return cmdNoticeList(ctx, rest) + case "revoke": + return cmdNoticeRevoke(ctx, rest) + default: + return fmt.Errorf("notice: unknown subcommand %q (want add|list|revoke)", sub) + } +} + +// parseNoticeAddFlags parses `nodectl notice add` flags into a +// notices.PublishInput. Kept separate from cmdNoticeAdd so validation is +// unit-testable without a DB: invalid type, missing titles, --email on a +// non-important type, and malformed --expires all fail here, before any +// Service/Store call. +func parseNoticeAddFlags(args []string) (notices.PublishInput, error) { + fs := flag.NewFlagSet("notice add", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + typ := fs.String("type", "", "notice type: important|feature|news|reward|version|promo (required)") + titleZH := fs.String("title-zh", "", "Chinese title (required)") + titleEN := fs.String("title-en", "", "English title (required)") + bodyZH := fs.String("body-zh", "", "Chinese body (optional)") + bodyEN := fs.String("body-en", "", "English body (optional)") + link := fs.String("link", "", "deep link / URL (optional)") + expires := fs.String("expires", "", "expiry date YYYY-MM-DD (expires at 23:59:59 UTC that day; optional)") + email := fs.Bool("email", false, "also send an email fallback (type=important only)") + + if err := fs.Parse(args); err != nil { + return notices.PublishInput{}, fmt.Errorf("notice add: %w", err) + } + + if !noticeTypes[*typ] { + return notices.PublishInput{}, fmt.Errorf("notice add: invalid -type %q (want important|feature|news|reward|version|promo)", *typ) + } + if strings.TrimSpace(*titleZH) == "" { + return notices.PublishInput{}, fmt.Errorf("notice add: -title-zh is required") + } + if strings.TrimSpace(*titleEN) == "" { + return notices.PublishInput{}, fmt.Errorf("notice add: -title-en is required") + } + if *email && *typ != "important" { + return notices.PublishInput{}, fmt.Errorf("notice add: -email is only allowed for -type=important, got %q", *typ) + } + + in := notices.PublishInput{ + Type: *typ, + TitleZH: *titleZH, + TitleEN: *titleEN, + BodyZH: *bodyZH, + BodyEN: *bodyEN, + Link: *link, + Email: *email, + } + + if strings.TrimSpace(*expires) != "" { + day, err := time.Parse("2006-01-02", *expires) + if err != nil { + return notices.PublishInput{}, fmt.Errorf("notice add: invalid -expires %q (want YYYY-MM-DD): %w", *expires, err) + } + exp := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, time.UTC) + in.ExpiresAt = &exp + } + + return in, nil +} + +func cmdNoticeAdd(ctx context.Context, args []string) error { + in, err := parseNoticeAddFlags(args) + if err != nil { + return err + } + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + svc := notices.NewService(st, buildNoticeMailer(), conn) + + id, err := svc.Publish(ctx, in) + if err != nil { + return err + } + fmt.Printf("published notice id=%d type=%s\n", id, in.Type) + return nil +} + +func cmdNoticeList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("notice list", flag.ExitOnError) + all := fs.Bool("all", false, "include revoked/expired notices") + limit := fs.Int("limit", 20, "max rows") + _ = fs.Parse(args) + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + rows, err := st.ListAdmin(ctx, *all, *limit) + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tTYPE\tTITLE_ZH\tPUBLISHED_AT\tSTATUS") + for _, r := range rows { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", r.ID, r.Type, r.TitleZH, r.PublishedAt.Format(time.RFC3339), r.Status) + } + return w.Flush() +} + +func cmdNoticeRevoke(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("notice revoke", flag.ExitOnError) + _ = fs.Parse(args) + if fs.NArg() != 1 { + return fmt.Errorf("notice revoke: exactly one positional argument is required") + } + id, err := strconv.ParseInt(fs.Arg(0), 10, 64) + if err != nil { + return fmt.Errorf("notice revoke: invalid id %q: %w", fs.Arg(0), err) + } + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + svc := notices.NewService(st, buildNoticeMailer(), conn) + if err := svc.RevokeByID(ctx, id); err != nil { + return err + } + fmt.Printf("revoked notice id=%d\n", id) + return nil +} + +// openNoticeDB opens the control-plane DB the same way buildService does: +// db.Open(dsn) resolves the driver internally via DB_DRIVER (defaulting to +// mysql; sqlite for single-node deploys) — see internal/db/db.go — so no +// extra driver plumbing is needed here. +func openNoticeDB() (*sql.DB, func(), error) { + dsn := os.Getenv("DB_DSN") + if dsn == "" { + return nil, nil, fmt.Errorf("DB_DSN is required") + } + conn, err := db.Open(dsn) + if err != nil { + return nil, nil, err + } + return conn, func() { conn.Close() }, nil +} + +// buildNoticeMailer constructs a notices.EmailSender from the same SMTP_* +// env vars cmd/server/main.go uses to wire auth.SMTPMailer (SMTP_HOST, +// SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM). auth.SMTPMailer +// itself isn't reused because it only implements auth.Mailer's +// SendCode/SendAlreadyRegistered, not notices.EmailSender's generic +// Send(to, subject, body) — so this is a small parallel net/smtp sender. +// Returns a nil (untyped) notices.EmailSender when SMTP_HOST is unset; +// Service.Publish then explicitly rejects -email requests ("no mailer +// configured") rather than silently dropping them. +func buildNoticeMailer() notices.EmailSender { + host := os.Getenv("SMTP_HOST") + if host == "" { + return nil + } + port := 587 + if p := os.Getenv("SMTP_PORT"); p != "" { + if v, err := strconv.Atoi(p); err == nil { + port = v + } + } + from := os.Getenv("SMTP_FROM") + if from == "" { + from = "no-reply@pangolin.app" + } + return &smtpNoticeMailer{ + host: host, + port: port, + username: os.Getenv("SMTP_USERNAME"), + password: os.Getenv("SMTP_PASSWORD"), + from: from, + } +} + +// smtpNoticeMailer is a minimal notices.EmailSender over net/smtp, mirroring +// auth.SMTPMailer's STARTTLS/PlainAuth send path (internal/auth/mailer.go) +// but with a generic subject/body instead of fixed verification-code copy. +type smtpNoticeMailer struct { + host, username, password, from string + port int +} + +func (m *smtpNoticeMailer) envelopeFrom() string { + if a, err := mail.ParseAddress(m.from); err == nil { + return a.Address + } + return m.from +} + +func (m *smtpNoticeMailer) Send(_ context.Context, to, subject, body string) error { + msg := strings.Join([]string{ + "From: " + m.from, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + addr := fmt.Sprintf("%s:%d", m.host, m.port) + auth := smtp.PlainAuth("", m.username, m.password, m.host) + if err := smtp.SendMail(addr, auth, m.envelopeFrom(), []string{to}, []byte(msg)); err != nil { + return fmt.Errorf("nodectl: smtp send: %w", err) + } + return nil +} diff --git a/server/cmd/nodectl/notice_test.go b/server/cmd/nodectl/notice_test.go new file mode 100644 index 0000000..4ca1123 --- /dev/null +++ b/server/cmd/nodectl/notice_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "testing" + "time" +) + +// TestParseNoticeAddFlags_Valid verifies the happy path parses all fields, +// including --expires → 23:59:59 UTC on the given day. +func TestParseNoticeAddFlags_Valid(t *testing.T) { + in, err := parseNoticeAddFlags([]string{ + "-type", "important", + "-title-zh", "维护通知", + "-title-en", "Maintenance notice", + "-body-zh", "今晚维护", + "-body-en", "Maintenance tonight", + "-link", "https://example.com/notice/1", + "-expires", "2026-08-01", + "-email", + }) + if err != nil { + t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err) + } + if in.Type != "important" { + t.Errorf("Type = %q, want important", in.Type) + } + if in.TitleZH != "维护通知" || in.TitleEN != "Maintenance notice" { + t.Errorf("titles = %q/%q", in.TitleZH, in.TitleEN) + } + if !in.Email { + t.Errorf("Email = false, want true") + } + if in.ExpiresAt == nil { + t.Fatalf("ExpiresAt is nil, want set") + } + want := time.Date(2026, 8, 1, 23, 59, 59, 0, time.UTC) + if !in.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", in.ExpiresAt, want) + } +} + +// TestParseNoticeAddFlags_NoExpires verifies omitting --expires leaves it nil. +func TestParseNoticeAddFlags_NoExpires(t *testing.T) { + in, err := parseNoticeAddFlags([]string{ + "-type", "feature", + "-title-zh", "新功能", + "-title-en", "New feature", + }) + if err != nil { + t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err) + } + if in.ExpiresAt != nil { + t.Errorf("ExpiresAt = %v, want nil", in.ExpiresAt) + } +} + +// TestParseNoticeAddFlags_InvalidType is red case 1: -type outside the six +// permitted values must fail before any Service/Store call. +func TestParseNoticeAddFlags_InvalidType(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "bogus", + "-title-zh", "标题", + "-title-en", "Title", + }) + if err == nil { + t.Fatal("parseNoticeAddFlags: expected error for invalid -type, got nil") + } +} + +// TestParseNoticeAddFlags_MissingTitle is red case 2: missing -title-zh or +// -title-en must fail. +func TestParseNoticeAddFlags_MissingTitle(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing title-zh", + args: []string{"-type", "important", "-title-en", "Title"}, + }, + { + name: "missing title-en", + args: []string{"-type", "important", "-title-zh", "标题"}, + }, + { + name: "missing both", + args: []string{"-type", "important"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := parseNoticeAddFlags(tc.args) + if err == nil { + t.Fatalf("parseNoticeAddFlags(%v): expected error for missing title, got nil", tc.args) + } + }) + } +} + +// TestParseNoticeAddFlags_EmailNonImportant is red case 3: -email is only +// allowed when -type=important. +func TestParseNoticeAddFlags_EmailNonImportant(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "feature", + "-title-zh", "标题", + "-title-en", "Title", + "-email", + }) + if err == nil { + t.Fatal("parseNoticeAddFlags: expected error for -email on non-important type, got nil") + } +} + +// TestParseNoticeAddFlags_BadExpiresFormat is red case 4: -expires must be +// YYYY-MM-DD; other formats (slashes, timestamps, garbage) must fail. +func TestParseNoticeAddFlags_BadExpiresFormat(t *testing.T) { + cases := []string{ + "2026/08/01", + "08-01-2026", + "2026-08-01T00:00:00Z", + "not-a-date", + } + for _, expires := range cases { + t.Run(expires, func(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "important", + "-title-zh", "标题", + "-title-en", "Title", + "-expires", expires, + }) + if err == nil { + t.Fatalf("parseNoticeAddFlags(-expires=%q): expected error, got nil", expires) + } + }) + } +}