From 45720ae2e1923ef662a2237a442fba988d519d42 Mon Sep 17 00:00:00 2001 From: zhr Date: Tue, 29 Sep 2026 10:22:28 +0800 Subject: [PATCH] fix: lint check --- .cursor/rules/git-commit-gate.mdc | 6 +- .cursor/rules/verify-before-done.mdc | 4 +- .cursor/skills/daily-change-list/SKILL.md | 4 +- .cursor/skills/spark-im-v3/SKILL.md | 2 +- AGENTS.md | 4 +- Makefile | 26 +++- README.md | 2 +- docs/daily/2026-09-29-change-list.md | 26 ++++ internal/bootstrap/app.go | 31 +++-- internal/bootstrap/config.go | 106 +--------------- internal/bootstrap/config_defaults.go | 117 ++++++++++++++++++ internal/bootstrap/media.go | 7 +- internal/infrastructure/media/bot.go | 8 +- internal/infrastructure/media/image.go | 19 +-- internal/infrastructure/media/image_test.go | 10 +- internal/infrastructure/media/video.go | 16 ++- .../module/client/logic/developer_apps.go | 35 ++++-- internal/module/client/logic/service.go | 6 +- internal/module/client/logic/service_test.go | 58 ++++++++- internal/module/client/model/repo.go | 6 +- internal/module/square/entity/source.go | 4 +- internal/module/squarebot/logic/attach.go | 2 +- .../module/squarebot/logic/attach_test.go | 68 ++++++++++ internal/module/squarebot/logic/media.go | 10 +- internal/module/squarebot/logic/media_test.go | 59 +++++++++ internal/module/squarebot/logic/rss.go | 58 +++++---- internal/module/squarebot/logic/rss_test.go | 59 +++++++++ internal/module/squarebot/logic/service.go | 16 +-- .../module/squarebot/logic/service_test.go | 31 ++++- .../module/squarebot/logic/worker_test.go | 28 +++++ .../module/upload/logic/credential_test.go | 2 +- internal/module/upload/logic/service.go | 5 +- internal/pkg/media/check.go | 4 +- internal/pkg/media/limits.go | 16 +-- internal/transport/http/auth.go | 12 +- internal/transport/http/file_test.go | 2 +- .../transport/http/handler/admin/client.go | 2 +- internal/transport/http/moment_test.go | 2 +- scripts/git-hooks/pre-commit | 9 ++ 39 files changed, 646 insertions(+), 236 deletions(-) create mode 100644 docs/daily/2026-09-29-change-list.md create mode 100644 internal/bootstrap/config_defaults.go create mode 100644 internal/module/squarebot/logic/attach_test.go create mode 100644 internal/module/squarebot/logic/media_test.go create mode 100644 internal/module/squarebot/logic/rss_test.go create mode 100644 internal/module/squarebot/logic/worker_test.go diff --git a/.cursor/rules/git-commit-gate.mdc b/.cursor/rules/git-commit-gate.mdc index b56161c..df2d785 100644 --- a/.cursor/rules/git-commit-gate.mdc +++ b/.cursor/rules/git-commit-gate.mdc @@ -7,9 +7,9 @@ alwaysApply: true Before **`git commit`** (including when the user asks the agent to commit): -1. From the repo root, run **`make test`** and confirm exit code **0** (`make test` already runs `make lint` first). +1. From the repo root, run **`make test`** and confirm exit code **0** (`make test` already runs `make lint` first). **`make test` fails if `go` or `golangci-lint` is not in PATH** — install Go 1.26+ and lint, then re-run; do not commit without a real green run. 2. Only then stage and commit. Do **not** commit while tests are failing or were not run for this change set. 3. Do **not** use `git commit --no-verify` / `-n` unless the user **explicitly** requests skipping hooks in that message. -4. If the local repo has no pre-commit hook yet, run **`make install-git-hooks`** once (installs `scripts/git-hooks/pre-commit`). +4. If the local repo has no pre-commit hook yet, run **`make install-git-hooks`** once (or **`make setup`**) — installs `scripts/git-hooks/pre-commit`, which always runs `make test`. -Docs-only commits with zero compile/test surface may skip `make test` only when the change cannot affect build or CI (same bar as `verify-before-done.mdc`). +With the hook installed, **every commit** must pass `make test`; there is no “no Go on machine” exception. diff --git a/.cursor/rules/verify-before-done.mdc b/.cursor/rules/verify-before-done.mdc index be2f965..18e04db 100644 --- a/.cursor/rules/verify-before-done.mdc +++ b/.cursor/rules/verify-before-done.mdc @@ -9,10 +9,10 @@ After **any** change to Go code, configs, migrations, OpenAPI, or i18n catalogs 1. Run `make test` from the repo root — it depends on `make lint` (`golangci-lint`; see `.golangci.yaml`) then `go test ./...` plus logic-package coverage (≥65%). -**Do not** tell the user the task is complete, ready to merge, or OK to deploy until `make test` exits 0. +**Do not** tell the user the task is complete, ready to merge, or OK to deploy until `make test` exits 0 **after actually running** lint and tests. If `go` or `golangci-lint` is missing, `make test` must **fail** — install the toolchain and re-run; never treat missing Go as “skipped” verification. If it fails, fix and re-run. Do not skip hooks or substitute partial checks (e.g. only `go test` on one package) unless the user explicitly asked for a scoped check. Use `make lint` alone only when debugging lint without the full test run. -Docs-only or comment-only edits with zero build/test surface may skip verification; anything that can affect compile, lint, or tests must run both targets. +Docs-only or comment-only edits with zero build/test surface may skip verification **only when** `make test` can run and you confirmed the change cannot affect compile, lint, or tests — if Go is not installed, you still cannot commit (pre-commit runs `make test`). After substantive changes, update the daily log per `.cursor/skills/daily-change-list/SKILL.md` (`docs/daily/{yyyy}-{mm}-{dd}-change-list.md`) before telling the user the work is complete. diff --git a/.cursor/skills/daily-change-list/SKILL.md b/.cursor/skills/daily-change-list/SKILL.md index d4bfe87..1a464e9 100644 --- a/.cursor/skills/daily-change-list/SKILL.md +++ b/.cursor/skills/daily-change-list/SKILL.md @@ -31,7 +31,7 @@ Create `docs/daily/` if missing. **Same calendar day**: append a new session blo 1. `git status` and `git diff` (staged + unstaged) for paths touched this session. 2. Summarize **why** and **what** in plain language (modules, APIs, migrations, behavior). -3. Note verification: `make test` / `make lint` result, or `skipped` only for docs-only with no build surface. +3. Note verification: `make test` result (`pass` / `fail`). Do **not** write `skipped` for missing Go — that is a **fail** until toolchain is installed and tests pass. `skipped` only for pure docs Q&A with zero repo file changes. Do not paste full diffs. List important files with one-line descriptions. @@ -60,7 +60,7 @@ Do not paste full diffs. List important files with one-line descriptions. ### 验证 -- `make test`: pass / fail / skipped (reason) +- `make test`: pass / fail(缺 go 或 lint 工具视为 fail,须安装后跑通再提交) ### 关联 diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index 963dad1..c9e1d31 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -111,7 +111,7 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del ## Verify before done -Follow `.cursor/rules/verify-before-done.mdc`: from repo root, `make test` (depends on `lint`) must exit 0; logic packages need ≥65% coverage (`LOGIC_COVER_MIN`). `make deploy` depends on `make test`. Docs-only edits with no compile surface may skip. +Follow `.cursor/rules/verify-before-done.mdc`: from repo root, `make test` (depends on `check-dev`, then `lint`) must exit 0; logic packages need ≥65% coverage (`LOGIC_COVER_MIN`). No Go toolchain → `make test` errors; install Go 1.26+ and `golangci-lint` (`make setup`). `make deploy` depends on `make test`. ## Additional resources diff --git a/AGENTS.md b/AGENTS.md index 4b50397..4dc2940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ Before writing code: 3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240) 4. HTTP and TCP share Application services under `internal/module/{biz}/logic`. Each module is `entity` / `model` (one file per table, GORM `TableName()` → `t_*`) / `logic` (one file per function, not per table). Gin handlers only bind and call those services (MVC). REST paths are `/v1/{resource}` grouped by business; admin ops use `/api/admin/…` (see skill `http.md`). Request DTOs use go-playground/validator (`label` / `errmsg` i18n keys). Context must carry `reqctx.RealIP` and the user language (`zh-CN` / `en` / `zh-TW`). Error `msg` and notification copy come from `internal/pkg/i18n`. Table and column COMMENT is required. Message `content` is serialized bytes, not a string. 5. MySQL: all tables `t_*`; migrations replay-all — details in `.cursor/skills/spark-im-v3/mysql.md`. After changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself. -6. Before finishing any code change, run `make test` at the repo root (runs `lint` first, logic coverage ≥65%). Do not report the task as OK until it passes. `make deploy` chains through `test`. +6. Before finishing any code change, run `make test` at the repo root (runs `lint` first, logic coverage ≥65%). Do not report the task as OK until it passes. Missing `go` / `golangci-lint` → fix toolchain first; never label verification as skipped. `make deploy` chains through `test`. 7. After edits that touch the repo, load `.cursor/skills/daily-change-list/SKILL.md` and append `docs/daily/{yyyy}-{mm}-{dd}-change-list.md` before handoff. -8. Before **`git commit`**, `make test` must pass; install enforcement with **`make install-git-hooks`** (see `.cursor/rules/git-commit-gate.mdc`). Do not use `--no-verify` unless the user explicitly asks. +8. Before **`git commit`**, `make test` must pass; install enforcement with **`make install-git-hooks`** or **`make setup`** (see `.cursor/rules/git-commit-gate.mdc`). Do not use `--no-verify` unless the user explicitly asks. Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports. diff --git a/Makefile b/Makefile index 8159f9d..d3b2f0d 100644 --- a/Makefile +++ b/Makefile @@ -9,11 +9,31 @@ GOLANGCI_LINT := golangci-lint CONFIG ?= configs/config.yaml LOGIC_COVER_MIN ?= 65 -.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback install-git-hooks +.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback install-git-hooks check-dev check-go check-golangci-lint setup + +# Commit / CI gate: missing Go or golangci-lint must fail loudly — never treat as "skipped". +check-go: + @command -v $(GO) >/dev/null 2>&1 || { \ + printf 'ERROR: "%s" not in PATH. Go 1.26+ is required.\n' '$(GO)' >&2; \ + printf 'Install Go, then run `make test` before commit. Verification cannot be skipped.\n' >&2; \ + exit 1; \ + } + +check-golangci-lint: + @command -v $(GOLANGCI_LINT) >/dev/null 2>&1 || { \ + printf 'ERROR: "%s" not in PATH.\n' '$(GOLANGCI_LINT)' >&2; \ + printf 'Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8\n' >&2; \ + exit 1; \ + } + +check-dev: check-go check-golangci-lint + +setup: install-git-hooks check-dev + @echo "Dev toolchain OK (go + golangci-lint). Pre-commit runs make test." all: test build -build: +build: check-go $(GO) build -o $(BIN) ./cmd/server # Cross-compile for ECS and pack a runtime-only amd64 image (no Go toolchain in the image). @@ -54,7 +74,7 @@ vet: fmt: $(GO) fmt ./... -lint: +lint: check-dev $(GOLANGCI_LINT) run ./... tidy: diff --git a/README.md b/README.md index 53f0a3f..e6f667e 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ migrations/ SQL,改完必须在本机跑 migrate 需要:Go 1.26、MySQL 8、Redis。对象存储可空(上传接口会不可用)。 ```bash -# 配置 configs/config.yaml(env: dev) +make setup # 安装 pre-commit + 检查 go / golangci-lint(缺工具会失败,不能跳过 make test) make migrate # 应用 migrations/*.sql make start # go run ./cmd/server -config configs/config.yaml # 或 diff --git a/docs/daily/2026-09-29-change-list.md b/docs/daily/2026-09-29-change-list.md new file mode 100644 index 0000000..af00a05 --- /dev/null +++ b/docs/daily/2026-09-29-change-list.md @@ -0,0 +1,26 @@ +# 每日变动 2026-09-29 + +## 10:18 — 提交门禁:无 Go 不得跳过 make test + +### 概要 + +- `make test` / pre-commit 在缺少 `go` 或 `golangci-lint` 时明确失败,禁止把「本机无 go」当作验证 skipped。 +- 新增 `make setup`(装 hook + `check-dev`)。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 配置 | Makefile `check-go` / `check-golangci-lint` / `setup` | +| 配置 | `scripts/git-hooks/pre-commit` 前置工具检查 | +| 文档 | AGENTS、verify/git-commit 规则、spark-im-v3 skill、daily-change-list skill、README | + +### 主要文件 + +- `Makefile` — `lint: check-dev`;缺工具 exit 1 +- `scripts/git-hooks/pre-commit` — 无 go/lint 直接拒绝 commit +- `.cursor/rules/verify-before-done.mdc` / `git-commit-gate.mdc` — 策略说明 + +### 验证 + +- `make test`: pass diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 7ce0b78..34ffb6d 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -123,6 +123,21 @@ func New(cfg *Config) (*App, error) { return finishApp(cfg, log, logClose, idgen, db, rdb) } +func configureSocialMedia( + moments *momentlogic.Service, + square *squarelogic.Service, + media *MediaConfig, + log *zap.Logger, +) { + pol := MediaPolicy(media) + if moments != nil { + moments.ConfigureMedia(pol, log) + } + if square != nil { + square.ConfigureMedia(pol, log) + } +} + func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (*App, error) { jwtSvc, err := authlogic.NewJWT(&authlogic.Options{ Secret: cfg.JWT.Secret, @@ -195,13 +210,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(idgen, db, objects, friends, users) square := wireSquare(idgen, db, objects, friends, users) - mediaPol := MediaPolicy(cfg.Media) - if moments != nil { - moments.ConfigureMedia(mediaPol, log) - } - if square != nil { - square.ConfigureMedia(mediaPol, log) - } + configureSocialMedia(moments, square, &cfg.Media, log) app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users) music := wireMusic(cfg, db, objects) users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...) @@ -339,11 +348,11 @@ func wireFiles(cfg *Config, log *zap.Logger, store *objectstore.Store, groups *g if store == nil || !store.Ready() { return nil } - return uploadlogic.NewService(store, uploadlogic.Options{ + return uploadlogic.NewService(store, &uploadlogic.Options{ TTL: cfg.ObjectStorage.PresignTTL, Limit: ratelimit.New(cfg.RateLimit.UploadPerSecond), Groups: groups, - Media: MediaPolicy(cfg.Media), + Media: MediaPolicy(&cfg.Media), Log: log, }) } @@ -359,12 +368,12 @@ func wireSquareBot( if rdb != nil && rdb.Ready() { locker = squarebotlogic.NewRedisLocker(rdb.RDB()) } - svc := squarebotlogic.NewService(log, square, users, locker, squarebotlogic.Options{ + svc := squarebotlogic.NewService(log, square, users, locker, &squarebotlogic.Options{ Enabled: true, Interval: cfg.SquareBot.Interval, TTL: cfg.SquareBot.TTL, Feeds: cfg.SquareBot.Feeds, BotIndexes: cfg.SquareBot.BotIndexes, }) if cfg.Media.BotCompress && store != nil && store.Ready() { - lim, jq, crf, ff := MediaCompressOptions(cfg.Media) + lim, jq, crf, ff := MediaCompressOptions(&cfg.Media) svc.BindMedia(squarebotlogic.NewMediaCompressor(lim, jq, crf, ff), store, true) } return squarebotlogic.NewWorker(log, svc) diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 2ec079e..e4762e6 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -245,110 +245,8 @@ func (cfg *Config) normalize() error { } func applyDefaults(cfg *Config) { - if v := os.Getenv("SPARK_ENV"); v != "" { - cfg.Env = v - } - cfg.Env = parseEnv(cfg.Env) - if v := os.Getenv("SPARK_JWT_SECRET"); v != "" { - cfg.JWT.Secret = v - } - if v := os.Getenv("SPARK_ADMIN_JWT_SECRET"); v != "" { - cfg.Admin.JWTSecret = v - } - if strings.TrimSpace(cfg.Admin.JWTSecret) == "" { - cfg.Admin.JWTSecret = "wyrs-admin-jwt-secret-change-in-production-2025" - } - if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" { - cfg.Redis.Password = v - } - if cfg.JWT.Issuer == "" { - cfg.JWT.Issuer = "spark-im" - } - if cfg.JWT.TTL <= 0 { - cfg.JWT.TTL = 7 * 24 * time.Hour - } - if cfg.JWT.RefreshTTL <= 0 { - cfg.JWT.RefreshTTL = 30 * 24 * time.Hour - } - if cfg.JWT.DenylistPrefix == "" { - cfg.JWT.DenylistPrefix = "denylist:" - } - if cfg.JWT.RefreshPrefix == "" { - cfg.JWT.RefreshPrefix = "refresh:" - } - if strings.TrimSpace(cfg.JWT.PairKeyMode) == "" { - cfg.JWT.PairKeyMode = "triple" - } - if cfg.Redis.DeliverQueueKey == "" { - cfg.Redis.DeliverQueueKey = "deliver:queue" - } - if cfg.Redis.DeliverProcessingKey == "" { - cfg.Redis.DeliverProcessingKey = "deliver:processing" - } - if cfg.Redis.DeliverDonePrefix == "" { - cfg.Redis.DeliverDonePrefix = "deliver:done:" - } - if cfg.Deliver.Workers < 1 { - cfg.Deliver.Workers = 8 - } - if cfg.Deliver.TakeTimeout <= 0 { - cfg.Deliver.TakeTimeout = 5 * time.Second - } - if cfg.Deliver.DoneTTL <= 0 { - cfg.Deliver.DoneTTL = 10 * time.Minute - } - if cfg.RateLimit.AuthPerSecond < 1 { - cfg.RateLimit.AuthPerSecond = 5 - } - if cfg.RateLimit.MessagePerSecond < 1 { - cfg.RateLimit.MessagePerSecond = 20 - } - if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" { - cfg.ObjectStorage.Endpoint = v - } - if v := os.Getenv("SPARK_S3_PUBLIC_ENDPOINT"); v != "" { - cfg.ObjectStorage.PublicEndpoint = v - } - if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" { - cfg.ObjectStorage.AccessKey = v - } - if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" { - cfg.ObjectStorage.SecretKey = v - } - if v := os.Getenv("SPARK_S3_BUCKET"); v != "" { - cfg.ObjectStorage.Bucket = v - } - if v := os.Getenv("SPARK_SMTP_PASSWORD"); v != "" { - cfg.SMS.SMTP.Password = v - } - if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_ID"); v != "" { - cfg.SMS.AliyunSMS.AccessKeyID = v - } - if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_SECRET"); v != "" { - cfg.SMS.AliyunSMS.AccessKeySecret = v - } - if cfg.ObjectStorage.Bucket == "" { - cfg.ObjectStorage.Bucket = "spark" - } - if cfg.ObjectStorage.Region == "" { - cfg.ObjectStorage.Region = "us-east-1" - } - if cfg.ObjectStorage.PresignTTL <= 0 { - cfg.ObjectStorage.PresignTTL = 15 * time.Minute - } - if cfg.ObjectStorage.ChatExpireDays < 1 { - cfg.ObjectStorage.ChatExpireDays = 365 - } - if cfg.RateLimit.UploadPerSecond < 1 { - cfg.RateLimit.UploadPerSecond = 10 - } - applyMediaDefaults(&cfg.Media) - if cfg.SquareBot.Interval <= 0 { - cfg.SquareBot.Interval = 5 * time.Minute - } - if cfg.SquareBot.TTL <= 0 { - cfg.SquareBot.TTL = 15 * 24 * time.Hour - } + applyConfigEnvOverrides(cfg) + applyConfigStructDefaults(cfg) } func applyMediaDefaults(m *MediaConfig) { diff --git a/internal/bootstrap/config_defaults.go b/internal/bootstrap/config_defaults.go new file mode 100644 index 0000000..6dc1a40 --- /dev/null +++ b/internal/bootstrap/config_defaults.go @@ -0,0 +1,117 @@ +package bootstrap + +import ( + "os" + "strings" + "time" +) + +func applyConfigEnvOverrides(cfg *Config) { + if v := os.Getenv("SPARK_ENV"); v != "" { + cfg.Env = v + } + cfg.Env = parseEnv(cfg.Env) + if v := os.Getenv("SPARK_JWT_SECRET"); v != "" { + cfg.JWT.Secret = v + } + if v := os.Getenv("SPARK_ADMIN_JWT_SECRET"); v != "" { + cfg.Admin.JWTSecret = v + } + if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" { + cfg.Redis.Password = v + } + if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" { + cfg.ObjectStorage.Endpoint = v + } + if v := os.Getenv("SPARK_S3_PUBLIC_ENDPOINT"); v != "" { + cfg.ObjectStorage.PublicEndpoint = v + } + if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" { + cfg.ObjectStorage.AccessKey = v + } + if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" { + cfg.ObjectStorage.SecretKey = v + } + if v := os.Getenv("SPARK_S3_BUCKET"); v != "" { + cfg.ObjectStorage.Bucket = v + } + if v := os.Getenv("SPARK_SMTP_PASSWORD"); v != "" { + cfg.SMS.SMTP.Password = v + } + if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_ID"); v != "" { + cfg.SMS.AliyunSMS.AccessKeyID = v + } + if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_SECRET"); v != "" { + cfg.SMS.AliyunSMS.AccessKeySecret = v + } +} + +func applyConfigStructDefaults(cfg *Config) { + if strings.TrimSpace(cfg.Admin.JWTSecret) == "" { + cfg.Admin.JWTSecret = "wyrs-admin-jwt-secret-change-in-production-2025" + } + if cfg.JWT.Issuer == "" { + cfg.JWT.Issuer = "spark-im" + } + if cfg.JWT.TTL <= 0 { + cfg.JWT.TTL = 7 * 24 * time.Hour + } + if cfg.JWT.RefreshTTL <= 0 { + cfg.JWT.RefreshTTL = 30 * 24 * time.Hour + } + if cfg.JWT.DenylistPrefix == "" { + cfg.JWT.DenylistPrefix = "denylist:" + } + if cfg.JWT.RefreshPrefix == "" { + cfg.JWT.RefreshPrefix = "refresh:" + } + if strings.TrimSpace(cfg.JWT.PairKeyMode) == "" { + cfg.JWT.PairKeyMode = "triple" + } + if cfg.Redis.DeliverQueueKey == "" { + cfg.Redis.DeliverQueueKey = "deliver:queue" + } + if cfg.Redis.DeliverProcessingKey == "" { + cfg.Redis.DeliverProcessingKey = "deliver:processing" + } + if cfg.Redis.DeliverDonePrefix == "" { + cfg.Redis.DeliverDonePrefix = "deliver:done:" + } + if cfg.Deliver.Workers < 1 { + cfg.Deliver.Workers = 8 + } + if cfg.Deliver.TakeTimeout <= 0 { + cfg.Deliver.TakeTimeout = 5 * time.Second + } + if cfg.Deliver.DoneTTL <= 0 { + cfg.Deliver.DoneTTL = 10 * time.Minute + } + if cfg.RateLimit.AuthPerSecond < 1 { + cfg.RateLimit.AuthPerSecond = 5 + } + if cfg.RateLimit.MessagePerSecond < 1 { + cfg.RateLimit.MessagePerSecond = 20 + } + if cfg.ObjectStorage.Bucket == "" { + cfg.ObjectStorage.Bucket = "spark" + } + if cfg.ObjectStorage.Region == "" { + cfg.ObjectStorage.Region = "us-east-1" + } + if cfg.ObjectStorage.PresignTTL <= 0 { + cfg.ObjectStorage.PresignTTL = 15 * time.Minute + } + if cfg.ObjectStorage.ChatExpireDays < 1 { + cfg.ObjectStorage.ChatExpireDays = 365 + } + if cfg.RateLimit.UploadPerSecond < 1 { + cfg.RateLimit.UploadPerSecond = 10 + } + applyMediaDefaults(&cfg.Media) + if cfg.SquareBot.Interval <= 0 { + cfg.SquareBot.Interval = 5 * time.Minute + } + if cfg.SquareBot.TTL <= 0 { + cfg.SquareBot.TTL = 15 * 24 * time.Hour + } +} diff --git a/internal/bootstrap/media.go b/internal/bootstrap/media.go index 544b52a..ff0388c 100644 --- a/internal/bootstrap/media.go +++ b/internal/bootstrap/media.go @@ -5,7 +5,10 @@ import ( ) // MediaPolicy builds the shared user-check / bot-compress limits from config. -func MediaPolicy(cfg MediaConfig) media.Policy { +func MediaPolicy(cfg *MediaConfig) media.Policy { + if cfg == nil { + return media.Policy{} + } return media.Policy{ Enabled: cfg.Enabled, UserReject: cfg.UserReject, @@ -21,7 +24,7 @@ func MediaPolicy(cfg MediaConfig) media.Policy { } // MediaCompressOptions returns limits + encoder settings for bot compressors. -func MediaCompressOptions(cfg MediaConfig) (lim media.Limits, jpegQuality, videoCRF int, ffmpegPath string) { +func MediaCompressOptions(cfg *MediaConfig) (lim media.Limits, jpegQuality, videoCRF int, ffmpegPath string) { p := MediaPolicy(cfg) return p.Limits, cfg.JPEGQuality, cfg.VideoCRF, cfg.FFmpegPath } diff --git a/internal/infrastructure/media/bot.go b/internal/infrastructure/media/bot.go index 70ee20b..bab5046 100644 --- a/internal/infrastructure/media/bot.go +++ b/internal/infrastructure/media/bot.go @@ -19,7 +19,7 @@ type BotAsset struct { // PrepareBotImage compresses RSS/downloaded image bytes to ≤1080p before object storage write. // Call this after HTTP download and before PutLarge. -func PrepareBotImage(src []byte, opt Options) (*BotAsset, error) { +func PrepareBotImage(src []byte, opt *Options) (*BotAsset, error) { res, err := CompressImage(src, opt) if err != nil { return nil, err @@ -31,7 +31,7 @@ func PrepareBotImage(src []byte, opt Options) (*BotAsset, error) { } // PrepareBotAvatar compresses bot avatar to ≤200×200. -func PrepareBotAvatar(src []byte, opt Options) (*BotAsset, error) { +func PrepareBotAvatar(src []byte, opt *Options) (*BotAsset, error) { res, err := CompressAvatar(src, opt) if err != nil { return nil, err @@ -43,7 +43,7 @@ func PrepareBotAvatar(src []byte, opt Options) (*BotAsset, error) { } // PrepareBotVideo compresses downloaded video to ≤720p (requires ffmpeg). -func PrepareBotVideo(ctx context.Context, src []byte, opt Options) (*BotAsset, error) { +func PrepareBotVideo(ctx context.Context, src []byte, opt *Options) (*BotAsset, error) { res, err := CompressVideoBytes(ctx, src, opt) if err != nil { return nil, err @@ -65,7 +65,7 @@ func OptionsFromLimits(lim pkgmedia.Limits, jpegQuality, videoCRF int, ffmpegPat } // MustCompressBotImage is PrepareBotImage with a clearer error prefix for callers. -func MustCompressBotImage(src []byte, opt Options) (*BotAsset, error) { +func MustCompressBotImage(src []byte, opt *Options) (*BotAsset, error) { a, err := PrepareBotImage(src, opt) if err != nil { return nil, fmt.Errorf("bot image compress: %w", err) diff --git a/internal/infrastructure/media/image.go b/internal/infrastructure/media/image.go index a5e4075..14af788 100644 --- a/internal/infrastructure/media/image.go +++ b/internal/infrastructure/media/image.go @@ -6,7 +6,7 @@ import ( "bytes" "fmt" "image" - _ "image/gif" + _ "image/gif" // register GIF decoder "image/jpeg" "image/png" "strings" @@ -24,7 +24,7 @@ type Options struct { FFmpegPath string // default "ffmpeg" } -func (o Options) normalize() Options { +func (o *Options) normalize() { o.Limits = o.Limits.Normalize() if o.JPEGQuality <= 0 || o.JPEGQuality > 100 { o.JPEGQuality = pkgmedia.DefaultJPEGQuality @@ -35,7 +35,6 @@ func (o Options) normalize() Options { if strings.TrimSpace(o.FFmpegPath) == "" { o.FFmpegPath = "ffmpeg" } - return o } // ImageResult is compressed image output. @@ -47,8 +46,11 @@ type ImageResult struct { } // CompressImage scales longest edge to ≤ Limits.ImageMaxEdge and re-encodes as JPEG (PNG in → JPEG out unless already small PNG kept as PNG when no resize needed and source is PNG). -func CompressImage(src []byte, opt Options) (*ImageResult, error) { - opt = opt.normalize() +func CompressImage(src []byte, opt *Options) (*ImageResult, error) { + if opt == nil { + return nil, fmt.Errorf("media: nil options") + } + opt.normalize() if len(src) == 0 { return nil, fmt.Errorf("media: empty image") } @@ -78,8 +80,11 @@ func CompressImage(src []byte, opt Options) (*ImageResult, error) { } // CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes. -func CompressAvatar(src []byte, opt Options) (*ImageResult, error) { - opt = opt.normalize() +func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) { + if opt == nil { + return nil, fmt.Errorf("media: nil options") + } + opt.normalize() lim := opt.Limits lim.ImageMaxEdge = lim.AvatarMaxEdge lim.ImageMaxBytes = lim.AvatarMaxBytes diff --git a/internal/infrastructure/media/image_test.go b/internal/infrastructure/media/image_test.go index f37e117..61fb8a9 100644 --- a/internal/infrastructure/media/image_test.go +++ b/internal/infrastructure/media/image_test.go @@ -19,7 +19,8 @@ func TestCompressImageDownscales(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := CompressImage(src, Options{Limits: pkgmedia.DefaultLimits(), JPEGQuality: 80}) + opt := &Options{Limits: pkgmedia.DefaultLimits(), JPEGQuality: 80} + res, err := CompressImage(src, opt) if err != nil { t.Fatal(err) } @@ -34,7 +35,8 @@ func TestCompressImageDownscales(t *testing.T) { if err != nil { t.Fatal(err) } - keep, err := CompressImage(smallSrc, Options{Limits: pkgmedia.DefaultLimits()}) + def := &Options{Limits: pkgmedia.DefaultLimits()} + keep, err := CompressImage(smallSrc, def) if err != nil { t.Fatal(err) } @@ -49,7 +51,7 @@ func TestCompressAvatar(t *testing.T) { if err != nil { t.Fatal(err) } - res, err := CompressAvatar(src, Options{Limits: pkgmedia.DefaultLimits()}) + res, err := CompressAvatar(src, &Options{Limits: pkgmedia.DefaultLimits()}) if err != nil { t.Fatal(err) } @@ -64,7 +66,7 @@ func TestPrepareBotImage(t *testing.T) { if err != nil { t.Fatal(err) } - a, err := PrepareBotImage(src, Options{Limits: pkgmedia.DefaultLimits()}) + a, err := PrepareBotImage(src, &Options{Limits: pkgmedia.DefaultLimits()}) if err != nil || a == nil || a.Size <= 0 { t.Fatalf("%+v %v", a, err) } diff --git a/internal/infrastructure/media/video.go b/internal/infrastructure/media/video.go index da20cd1..2ad4cd9 100644 --- a/internal/infrastructure/media/video.go +++ b/internal/infrastructure/media/video.go @@ -24,8 +24,11 @@ type VideoResult struct { // CompressVideoBytes writes src to a temp file, runs ffmpeg scale+libx264, returns MP4 bytes. // Requires ffmpeg on PATH (or Options.FFmpegPath). Returns error if ffmpeg is missing. -func CompressVideoBytes(ctx context.Context, src []byte, opt Options) (*VideoResult, error) { - opt = opt.normalize() +func CompressVideoBytes(ctx context.Context, src []byte, opt *Options) (*VideoResult, error) { + if opt == nil { + return nil, fmt.Errorf("media: nil options") + } + opt.normalize() if len(src) == 0 { return nil, fmt.Errorf("media: empty video") } @@ -68,7 +71,7 @@ func CompressVideoBytes(ctx context.Context, src []byte, opt Options) (*VideoRes return &VideoResult{Data: out, Width: ow, Height: oh, MIME: "video/mp4"}, nil } -func runFFmpegCompress(ctx context.Context, opt Options, inPath, outPath string) error { +func runFFmpegCompress(ctx context.Context, opt *Options, inPath, outPath string) error { if ctx == nil { ctx = context.Background() } @@ -141,8 +144,11 @@ func isExecNotFound(err error) bool { } // FFmpegAvailable reports whether the configured ffmpeg binary can be executed. -func FFmpegAvailable(opt Options) bool { - opt = opt.normalize() +func FFmpegAvailable(opt *Options) bool { + if opt == nil { + return false + } + opt.normalize() path, err := exec.LookPath(opt.FFmpegPath) return err == nil && path != "" } diff --git a/internal/module/client/logic/developer_apps.go b/internal/module/client/logic/developer_apps.go index 7d4f8e4..acd12e4 100644 --- a/internal/module/client/logic/developer_apps.go +++ b/internal/module/client/logic/developer_apps.go @@ -8,17 +8,23 @@ import ( "spark-server/internal/pkg/errs" ) +const ( + appCodeSpark = "spark" + appCodeSleepHeart = "sleepheart" +) + // DeveloperApps returns enabled apps for the given source app (client wall). func (s *Service) DeveloperApps(ctx context.Context, sourceAppCode, excludePackage string) (*entity.DeveloperAppsPayload, error) { sourceAppCode = normalizeAppCode(sourceAppCode) excludePackage = strings.TrimSpace(excludePackage) rows := s.loadDeveloperApps(ctx, sourceAppCode, true) // SleepHeart 可回退 spark 共用墙 - if len(rows) == 0 && sourceAppCode == "sleepheart" { - rows = s.loadDeveloperApps(ctx, "spark", true) + if len(rows) == 0 && sourceAppCode == appCodeSleepHeart { + rows = s.loadDeveloperApps(ctx, appCodeSpark, true) } items := make([]entity.DeveloperAppItem, 0, len(rows)) - for _, row := range rows { + for i := range rows { + row := &rows[i] if excludePackage != "" && strings.EqualFold(row.TargetPackageName, excludePackage) { continue } @@ -33,10 +39,13 @@ func (s *Service) ListDeveloperAppsAdmin(ctx context.Context, sourceAppCode stri } // CreateDeveloperAppAdmin inserts or updates one recommendation. -func (s *Service) CreateDeveloperAppAdmin(ctx context.Context, in entity.DeveloperApp) (*entity.DeveloperApp, error) { +func (s *Service) CreateDeveloperAppAdmin(ctx context.Context, in *entity.DeveloperApp) (*entity.DeveloperApp, error) { if s.repo == nil { return nil, errs.ErrAvailable } + if in == nil { + return nil, errs.ErrInvalidArgument + } in.SourceAppCode = normalizeAppCode(in.SourceAppCode) in.TargetAppName = strings.TrimSpace(in.TargetAppName) in.TargetPackageName = strings.TrimSpace(in.TargetPackageName) @@ -55,10 +64,10 @@ func (s *Service) CreateDeveloperAppAdmin(ctx context.Context, in entity.Develop in.StoreURL = in.FallbackURL } in.Enabled = true - if err := s.repo.UpsertDeveloperApp(ctx, &in); err != nil { + if err := s.repo.UpsertDeveloperApp(ctx, in); err != nil { return nil, err } - return &in, nil + return in, nil } // DeleteDeveloperAppAdmin removes one row by id. @@ -78,10 +87,10 @@ func (s *Service) SyncWebsiteIconsAdmin(ctx context.Context) (int, error) { return 0, errs.ErrAvailable } n := 0 - for _, src := range []string{"spark", "sleepheart"} { - for _, row := range websiteDeveloperCatalog(src) { - cur := row - if err := s.repo.UpsertDeveloperApp(ctx, &cur); err != nil { + for _, src := range []string{appCodeSpark, appCodeSleepHeart} { + catalog := websiteDeveloperCatalog(src) + for i := range catalog { + if err := s.repo.UpsertDeveloperApp(ctx, &catalog[i]); err != nil { return n, err } n++ @@ -100,7 +109,7 @@ func (s *Service) loadDeveloperApps(ctx context.Context, sourceAppCode string, e return defaultDeveloperApps(sourceAppCode) } -func toDeveloperAppItem(a entity.DeveloperApp) entity.DeveloperAppItem { +func toDeveloperAppItem(a *entity.DeveloperApp) entity.DeveloperAppItem { store := a.StoreURL if store == "" { store = a.FallbackURL @@ -115,9 +124,9 @@ func normalizeAppCode(code string) string { code = strings.ToLower(strings.TrimSpace(code)) switch code { case "", "pop": - return "spark" + return appCodeSpark case "health", "healthios", "sleep", "sleep-heart": - return "sleepheart" + return appCodeSleepHeart default: return code } diff --git a/internal/module/client/logic/service.go b/internal/module/client/logic/service.go index 63b2236..87fd00c 100644 --- a/internal/module/client/logic/service.go +++ b/internal/module/client/logic/service.go @@ -111,7 +111,7 @@ func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity f := &entity.Feedback{ Email: email, Contact: contact, Content: strings.TrimSpace(in.Content), AppVersion: strings.TrimSpace(in.AppVersion), - Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(), + Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(), } if f.AppVersion == "" { if rc := reqctx.From(ctx); rc != nil { @@ -299,8 +299,8 @@ func normalizeLang(lang string) string { switch strings.TrimSpace(lang) { case "en", "en-US", "en-GB": return "en" - case "zh-TW", "zh-HK": - return "zh-TW" + case string(i18n.ZhTW), "zh-HK": + return string(i18n.ZhTW) default: return string(i18n.ZhCN) } diff --git a/internal/module/client/logic/service_test.go b/internal/module/client/logic/service_test.go index a094606..2abf56b 100644 --- a/internal/module/client/logic/service_test.go +++ b/internal/module/client/logic/service_test.go @@ -16,6 +16,7 @@ type stubClientRepo struct { feedback []entity.Feedback plans []entity.SubscriptionPlan passwords map[int64]string + devApps map[string][]entity.DeveloperApp } func (s *stubClientRepo) GetLegal(_ context.Context, lang string) (*entity.LegalDocument, *entity.LegalDocument, error) { @@ -115,8 +116,11 @@ func (s *stubClientRepo) SetPasswordHash(_ context.Context, userID int64, hash s return nil } -func (s *stubClientRepo) ListDeveloperApps(_ context.Context, _ string, _ bool) ([]entity.DeveloperApp, error) { - return nil, nil +func (s *stubClientRepo) ListDeveloperApps(_ context.Context, code string, _ bool) ([]entity.DeveloperApp, error) { + if s.devApps == nil { + return nil, nil + } + return s.devApps[code], nil } func (s *stubClientRepo) UpsertDeveloperApp(_ context.Context, a *entity.DeveloperApp) error { @@ -266,3 +270,53 @@ func TestClientPasswordFlow(t *testing.T) { t.Fatalf("test email skip password %v", err) } } + +func TestLooksLikeContact(t *testing.T) { + if !looksLikeContact("a@b.com") || looksLikeContact("@bad") || !looksLikeContact("+86 13800138000") { + t.Fatal("contact heuristics") + } +} + +func TestDeveloperAppsAdmin(t *testing.T) { + repo := &stubClientRepo{ + devApps: map[string][]entity.DeveloperApp{ + "spark": {{ID: 9, SourceAppCode: "spark", TargetAppName: "X", TargetPackageName: "com.x", Enabled: true}}, + }, + } + svc := NewService(repo, nil, nil) + ctx := t.Context() + list, err := svc.ListDeveloperAppsAdmin(ctx, "spark") + if err != nil || len(list) != 1 { + t.Fatalf("list %+v %v", list, err) + } + created, err := svc.CreateDeveloperAppAdmin(ctx, &entity.DeveloperApp{ + SourceAppCode: "sleep-heart", TargetAppName: "New App", FallbackURL: "https://store.example/app", + }) + if err != nil || created == nil || created.SourceAppCode != "sleepheart" || created.TargetPackageName == "" { + t.Fatalf("create %+v %v", created, err) + } + if _, err := svc.CreateDeveloperAppAdmin(ctx, nil); err == nil { + t.Fatal("nil input") + } + if _, err := svc.CreateDeveloperAppAdmin(ctx, &entity.DeveloperApp{SourceAppCode: "spark"}); err == nil { + t.Fatal("missing name") + } + n, err := svc.SyncWebsiteIconsAdmin(ctx) + if err != nil || n < 10 { + t.Fatalf("sync n=%d err=%v", n, err) + } + if err := svc.DeleteDeveloperAppAdmin(ctx, 0); err == nil { + t.Fatal("bad id") + } + if err := svc.DeleteDeveloperAppAdmin(ctx, 9); err != nil { + t.Fatal(err) + } + apps, err := svc.DeveloperApps(ctx, "spark", "com.x") + if err != nil || len(apps.Items) != 0 { + t.Fatalf("exclude package %+v %v", apps, err) + } + cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW))) + if err != nil || len(cfg.Plans) == 0 { + t.Fatalf("plans %+v %v", cfg, err) + } +} diff --git a/internal/module/client/model/repo.go b/internal/module/client/model/repo.go index 171dd58..ca6e213 100644 --- a/internal/module/client/model/repo.go +++ b/internal/module/client/model/repo.go @@ -268,8 +268,8 @@ func (r *Repo) ListDeveloperApps(ctx context.Context, sourceAppCode string, enab return nil, err } out := make([]entity.DeveloperApp, 0, len(rows)) - for _, row := range rows { - out = append(out, mapDeveloperApp(row)) + for i := range rows { + out = append(out, mapDeveloperApp(&rows[i])) } return out, nil } @@ -324,7 +324,7 @@ func (r *Repo) DeleteDeveloperApp(ctx context.Context, id int64) error { return r.db.WithContext(ctx).Delete(&developerAppRow{}, id).Error } -func mapDeveloperApp(row developerAppRow) entity.DeveloperApp { +func mapDeveloperApp(row *developerAppRow) entity.DeveloperApp { return entity.DeveloperApp{ ID: row.ID, SourceAppCode: row.SourceAppCode, TargetAppName: row.TargetAppName, TargetPackageName: row.TargetPackageName, Subtitle: row.Subtitle, IconURL: row.IconURL, diff --git a/internal/module/square/entity/source.go b/internal/module/square/entity/source.go index 3a98094..e73cdaf 100644 --- a/internal/module/square/entity/source.go +++ b/internal/module/square/entity/source.go @@ -4,8 +4,8 @@ package entity type Source int16 const ( - SourceUser Source = 0 - SourceBotRSS Source = 1 + SourceUser Source = 0 + SourceBotRSS Source = 1 ) func (s Source) Valid() bool { diff --git a/internal/module/squarebot/logic/attach.go b/internal/module/squarebot/logic/attach.go index 76fa30a..d1e9134 100644 --- a/internal/module/squarebot/logic/attach.go +++ b/internal/module/squarebot/logic/attach.go @@ -30,7 +30,7 @@ func (s *Service) BindMedia(comp *MediaCompressor, store ObjectStore, botCompres } // attachMedia downloads/compresses optional enclosure and returns square Image refs (best-effort). -func (s *Service) attachMedia(ctx context.Context, item FeedItem) []*squareentity.Image { +func (s *Service) attachMedia(ctx context.Context, item *FeedItem) []*squareentity.Image { if s == nil || !s.botCompress || s.comp == nil || s.store == nil { return nil } diff --git a/internal/module/squarebot/logic/attach_test.go b/internal/module/squarebot/logic/attach_test.go new file mode 100644 index 0000000..5f69e00 --- /dev/null +++ b/internal/module/squarebot/logic/attach_test.go @@ -0,0 +1,68 @@ +package logic + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "testing" + "time" + + userentity "spark-server/internal/module/user/entity" + pkgmedia "spark-server/internal/pkg/media" +) + +type memObjectStore struct { + puts int +} + +func (m *memObjectStore) Put(context.Context, string, []byte, string) error { + m.puts++ + return nil +} + +func (m *memObjectStore) PutLarge(context.Context, string, []byte, string, int) error { + m.puts++ + return nil +} + +func TestBindMediaAttachOnTick(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + img.Set(0, 0, color.White) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + imgSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(buf.Bytes()) + })) + defer imgSrv.Close() + rss := fmt.Sprintf(`T +Hihttps://example.com/p + +`, imgSrv.URL) + feedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(rss)) + })) + defer feedSrv.Close() + + sq := &stubSquare{} + store := &memObjectStore{} + comp := NewMediaCompressor(pkgmedia.DefaultLimits(), 80, 28, "") + comp.Client = imgSrv.Client() + svc := NewService(nil, sq, stubUsers{u: &userentity.User{ID: 7, Nickname: "Bot"}}, nil, &Options{ + Enabled: true, Feeds: []string{feedSrv.URL}, BotIndexes: []int{1}, TTL: time.Hour, + }) + svc.client = feedSrv.Client() + svc.BindMedia(comp, store, true) + if err := svc.Tick(t.Context()); err != nil { + t.Fatal(err) + } + if sq.published != 1 || store.puts != 1 { + t.Fatalf("published=%d puts=%d", sq.published, store.puts) + } +} diff --git a/internal/module/squarebot/logic/media.go b/internal/module/squarebot/logic/media.go index 8153577..b9595e2 100644 --- a/internal/module/squarebot/logic/media.go +++ b/internal/module/squarebot/logic/media.go @@ -37,7 +37,7 @@ func (c *MediaCompressor) FetchImage(ctx context.Context, url string) (*infmedia if err != nil { return nil, err } - return infmedia.PrepareBotImage(raw, c.Opt) + return infmedia.PrepareBotImage(raw, &c.Opt) } // FetchAvatar GETs url and compresses to ≤200×200. @@ -46,7 +46,7 @@ func (c *MediaCompressor) FetchAvatar(ctx context.Context, url string) (*infmedi if err != nil { return nil, err } - return infmedia.PrepareBotAvatar(raw, c.Opt) + return infmedia.PrepareBotAvatar(raw, &c.Opt) } // FetchVideo GETs url and compresses to ≤720p (requires ffmpeg). @@ -55,17 +55,17 @@ func (c *MediaCompressor) FetchVideo(ctx context.Context, url string) (*infmedia if err != nil { return nil, err } - return infmedia.PrepareBotVideo(ctx, raw, c.Opt) + return infmedia.PrepareBotVideo(ctx, raw, &c.Opt) } // CompressBytesImage compresses already-downloaded image bytes (e.g. RSS enclosure already fetched). func (c *MediaCompressor) CompressBytesImage(src []byte) (*infmedia.BotAsset, error) { - return infmedia.PrepareBotImage(src, c.Opt) + return infmedia.PrepareBotImage(src, &c.Opt) } // CompressBytesVideo compresses already-downloaded video bytes. func (c *MediaCompressor) CompressBytesVideo(ctx context.Context, src []byte) (*infmedia.BotAsset, error) { - return infmedia.PrepareBotVideo(ctx, src, c.Opt) + return infmedia.PrepareBotVideo(ctx, src, &c.Opt) } func (c *MediaCompressor) download(ctx context.Context, url string) ([]byte, error) { diff --git a/internal/module/squarebot/logic/media_test.go b/internal/module/squarebot/logic/media_test.go new file mode 100644 index 0000000..ae9350b --- /dev/null +++ b/internal/module/squarebot/logic/media_test.go @@ -0,0 +1,59 @@ +package logic + +import ( + "bytes" + "context" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "testing" + + infmedia "spark-server/internal/infrastructure/media" + pkgmedia "spark-server/internal/pkg/media" +) + +func tinyPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestMediaCompressorDownloadAndCompress(t *testing.T) { + body := tinyPNG(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + c := NewMediaCompressor(pkgmedia.DefaultLimits(), 80, 28, "") + c.Client = srv.Client() + ctx := context.Background() + asset, err := c.FetchImage(ctx, srv.URL) + if err != nil || asset == nil || asset.Size <= 0 { + t.Fatalf("fetch %+v %v", asset, err) + } + av, err := c.FetchAvatar(ctx, srv.URL) + if err != nil || av == nil { + t.Fatalf("avatar %+v %v", av, err) + } + raw, err := c.CompressBytesImage(body) + if err != nil || raw.MIME == "" { + t.Fatalf("bytes %+v %v", raw, err) + } + if _, err := c.download(ctx, ""); err == nil { + t.Fatal("empty url") + } +} + +func TestExtForMIME(t *testing.T) { + if extForMIME("image/png") != "png" || extForMIME("image/jpeg") != "jpg" { + t.Fatal("ext") + } + _ = infmedia.OptionsFromLimits(pkgmedia.DefaultLimits(), 80, 28, "") +} diff --git a/internal/module/squarebot/logic/rss.go b/internal/module/squarebot/logic/rss.go index 9e90f8b..f3431b6 100644 --- a/internal/module/squarebot/logic/rss.go +++ b/internal/module/squarebot/logic/rss.go @@ -15,8 +15,8 @@ import ( ) const ( - rssMaxBody = 2 << 20 - rssTimeout = 20 * time.Second + rssMaxBody = 2 << 20 + rssTimeout = 20 * time.Second summaryRunes = 280 ) @@ -37,13 +37,13 @@ type rssFeed struct { } type rssItem struct { - Title string `xml:"title"` - Link string `xml:"link"` - Description string `xml:"description"` - GUID string `xml:"guid"` - Enclosure *rssEnclosure `xml:"enclosure"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + GUID string `xml:"guid"` + Enclosure *rssEnclosure `xml:"enclosure"` MediaContent []rssMediaContent `xml:"http://search.yahoo.com/mrss/ content"` - MediaThumb []rssMediaContent `xml:"http://search.yahoo.com/mrss/ thumbnail"` + MediaThumb []rssMediaContent `xml:"http://search.yahoo.com/mrss/ thumbnail"` } type rssEnclosure struct { @@ -52,8 +52,8 @@ type rssEnclosure struct { } type rssMediaContent struct { - URL string `xml:"url,attr"` - Type string `xml:"type,attr"` + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` Medium string `xml:"medium,attr"` } @@ -114,7 +114,8 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) { if err := xml.Unmarshal(body, &rss); err == nil && len(rss.Channel.Items) > 0 { src := strings.TrimSpace(rss.Channel.Title) out := make([]FeedItem, 0, len(rss.Channel.Items)) - for _, it := range rss.Channel.Items { + for i := range rss.Channel.Items { + it := &rss.Channel.Items[i] link := firstNonEmpty(strings.TrimSpace(it.Link), strings.TrimSpace(it.GUID)) item := FeedItem{ Title: cleanText(it.Title), @@ -138,7 +139,8 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) { } src := cleanText(atom.Title) out := make([]FeedItem, 0, len(atom.Entries)) - for _, e := range atom.Entries { + for i := range atom.Entries { + e := &atom.Entries[i] link := atomBestLink(e.Links) if link == "" { link = strings.TrimSpace(e.ID) @@ -162,20 +164,22 @@ func parseFeed(body []byte, feedURL string) ([]FeedItem, error) { return out, nil } -func rssImageURL(it rssItem) string { +func rssImageURL(it *rssItem) string { if it.Enclosure != nil && isImageMIME(it.Enclosure.Type) { if u := normalizeURL(it.Enclosure.URL); u != "" { return u } } - for _, m := range it.MediaContent { + for i := range it.MediaContent { + m := &it.MediaContent[i] if isImageMedia(m) { if u := normalizeURL(m.URL); u != "" { return u } } } - for _, m := range it.MediaThumb { + for i := range it.MediaThumb { + m := &it.MediaThumb[i] if u := normalizeURL(m.URL); u != "" { return u } @@ -183,8 +187,9 @@ func rssImageURL(it rssItem) string { return firstImgSrc(it.Description) } -func atomImageURL(e atomEntry) string { - for _, l := range e.Links { +func atomImageURL(e *atomEntry) string { + for i := range e.Links { + l := &e.Links[i] if strings.EqualFold(l.Rel, "enclosure") || strings.HasPrefix(strings.ToLower(l.Type), "image/") { if u := normalizeURL(l.Href); u != "" { return u @@ -199,7 +204,7 @@ func isImageMIME(t string) bool { return strings.HasPrefix(t, "image/") } -func isImageMedia(m rssMediaContent) bool { +func isImageMedia(m *rssMediaContent) bool { if isImageMIME(m.Type) { return true } @@ -215,13 +220,14 @@ func firstImgSrc(htmlBody string) string { } func atomBestLink(links []atomLink) string { - var alt, self, any string - for _, l := range links { + var alt, self, fallback string + for i := range links { + l := &links[i] href := strings.TrimSpace(l.Href) if href == "" { continue } - any = href + fallback = href switch strings.ToLower(strings.TrimSpace(l.Rel)) { case "", "alternate": if alt == "" { @@ -233,10 +239,10 @@ func atomBestLink(links []atomLink) string { } } } - return firstNonEmpty(alt, self, any) + return firstNonEmpty(alt, self, fallback) } -func FormatPostText(item FeedItem) string { +func FormatPostText(item *FeedItem) string { title := strings.TrimSpace(item.Title) sum := truncateRunes(strings.TrimSpace(item.Summary), summaryRunes) link := strings.TrimSpace(item.Link) @@ -264,12 +270,12 @@ func cleanText(s string) string { return strings.TrimSpace(s) } -func truncateRunes(s string, max int) string { - if max <= 0 || utf8.RuneCountInString(s) <= max { +func truncateRunes(s string, limit int) string { + if limit <= 0 || utf8.RuneCountInString(s) <= limit { return s } runes := []rune(s) - return string(runes[:max-1]) + "…" + return string(runes[:limit-1]) + "…" } func normalizeURL(raw string) string { diff --git a/internal/module/squarebot/logic/rss_test.go b/internal/module/squarebot/logic/rss_test.go new file mode 100644 index 0000000..a5c3ec8 --- /dev/null +++ b/internal/module/squarebot/logic/rss_test.go @@ -0,0 +1,59 @@ +package logic + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseAtomFeedAndHelpers(t *testing.T) { + atom := []byte(` + +Atom News + +Entry One +https://example.com/e/1 + + +<p>Body <img src="https://example.com/fallback.png"/></p> + +`) + items, err := parseFeed(atom, "https://example.com/atom") + if err != nil || len(items) != 1 { + t.Fatalf("%+v %v", items, err) + } + if items[0].ImageURL != "https://example.com/atom.jpg" { + t.Fatalf("image=%q", items[0].ImageURL) + } + long := strings.Repeat("字", 300) + if got := truncateRunes(long, 10); len([]rune(got)) != 10 { + t.Fatalf("truncate %q", got) + } +} + +func TestRSSMediaContentAndFetchFeed(t *testing.T) { + rss := []byte(` + +MRSS + +Media item +https://example.com/m +no img tag + + +`) + items, err := parseFeed(rss, "https://example.com/mrss") + if err != nil || len(items) != 1 || items[0].ImageURL != "https://example.com/m.jpg" { + t.Fatalf("%+v %v", items, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(rss) + })) + defer srv.Close() + got, err := FetchFeed(context.Background(), srv.Client(), srv.URL) + if err != nil || len(got) != 1 { + t.Fatalf("%+v %v", got, err) + } +} diff --git a/internal/module/squarebot/logic/service.go b/internal/module/squarebot/logic/service.go index 2045b50..840900f 100644 --- a/internal/module/squarebot/logic/service.go +++ b/internal/module/squarebot/logic/service.go @@ -56,23 +56,26 @@ type Service struct { botCompress bool } -func NewService(log *zap.Logger, square Square, users Users, locker Locker, opts Options) *Service { +func NewService(log *zap.Logger, square Square, users Users, locker Locker, opts *Options) *Service { if log == nil { log = zap.NewNop() } - opts = normalizeOptions(opts) + if opts == nil { + opts = &Options{} + } + normalizeOptions(opts) client := opts.HTTPClient if client == nil { client = &http.Client{Timeout: rssTimeout} } return &Service{ - log: log, square: square, users: users, locker: locker, opts: opts, + log: log, square: square, users: users, locker: locker, opts: *opts, client: client, rand: rand.New(rand.NewSource(time.Now().UnixNano())), lockKey: "squarebot:tick", } } -func normalizeOptions(o Options) Options { +func normalizeOptions(o *Options) { if o.Interval <= 0 { o.Interval = 5 * time.Minute } @@ -85,7 +88,6 @@ func normalizeOptions(o Options) Options { if len(o.BotIndexes) == 0 { o.BotIndexes = defaultBotIndexes() } - return o } // DefaultFeeds are public overseas mainstream media RSS endpoints (title/summary/link only). @@ -145,8 +147,8 @@ func (s *Service) Tick(ctx context.Context) error { if err != nil { return err } - text := FormatPostText(item) - images := s.attachMedia(ctx, item) + text := FormatPostText(&item) + images := s.attachMedia(ctx, &item) view, err := s.square.PublishBotMedia(ctx, bot.ID, &squarelogic.BotPublishInput{ Text: text, SourceURL: item.Link, TTL: s.opts.TTL, Images: images, }) diff --git a/internal/module/squarebot/logic/service_test.go b/internal/module/squarebot/logic/service_test.go index cf00801..61df9c3 100644 --- a/internal/module/squarebot/logic/service_test.go +++ b/internal/module/squarebot/logic/service_test.go @@ -38,7 +38,7 @@ func TestParseRSSAndFormat(t *testing.T) { if items[0].ImageURL != "https://example.com/cover.jpg" { t.Fatalf("image=%q", items[0].ImageURL) } - text := FormatPostText(items[0]) + text := FormatPostText(&items[0]) if !strings.Contains(text, "Demo News") || !strings.Contains(text, "https://example.com/a") { t.Fatalf("text=%q", text) } @@ -90,7 +90,7 @@ func TestPublishBotMediaTTLAndDedupe(t *testing.T) { } func TestServiceDisabledWithoutDeps(t *testing.T) { - svc := NewService(nil, nil, nil, nil, Options{Enabled: true}) + svc := NewService(nil, nil, nil, nil, &Options{Enabled: true}) if svc.Enabled() { t.Fatal("enabled without deps") } @@ -125,7 +125,7 @@ func (s *stubSquare) ExpireBotPosts(context.Context, time.Time, int) (int, error func TestTickExpiresEvenIfFeedFails(t *testing.T) { sq := &stubSquare{} users := stubUsers{u: &userentity.User{ID: 7, Nickname: "Bot"}} - svc := NewService(nil, sq, users, nil, Options{ + svc := NewService(nil, sq, users, nil, &Options{ Enabled: true, Interval: time.Minute, TTL: time.Hour, BotIndexes: []int{1}, }) svc.opts.Feeds = []string{"http://127.0.0.1:1/nope"} @@ -137,3 +137,28 @@ func TestTickExpiresEvenIfFeedFails(t *testing.T) { t.Fatalf("expire called %d", sq.expired) } } + +type skipLock struct{} + +func (skipLock) TryLock(context.Context, string, time.Duration) (bool, error) { + return false, nil +} + +func TestTickSkipsWhenLockHeld(t *testing.T) { + sq := &stubSquare{} + users := stubUsers{u: &userentity.User{ID: 7, Nickname: "Bot"}} + svc := NewService(nil, sq, users, skipLock{}, &Options{Enabled: true, BotIndexes: []int{1}}) + if err := svc.Tick(t.Context()); err != nil { + t.Fatal(err) + } + if sq.published != 0 || sq.expired != 0 { + t.Fatalf("want skip publish=%d expire=%d", sq.published, sq.expired) + } +} + +func TestServiceInterval(t *testing.T) { + svc := NewService(nil, nil, nil, nil, &Options{Interval: 2 * time.Minute}) + if svc.Interval() != 2*time.Minute { + t.Fatalf("interval %v", svc.Interval()) + } +} diff --git a/internal/module/squarebot/logic/worker_test.go b/internal/module/squarebot/logic/worker_test.go new file mode 100644 index 0000000..9d5017d --- /dev/null +++ b/internal/module/squarebot/logic/worker_test.go @@ -0,0 +1,28 @@ +package logic + +import ( + "testing" + "time" + + "go.uber.org/zap" +) + +func TestRedisLockerNilRDB(t *testing.T) { + var l RedisLocker + ok, err := l.TryLock(t.Context(), "k", time.Minute) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if NewRedisLocker(nil) != nil { + t.Fatal("nil client") + } +} + +func TestWorkerStartDisabled(t *testing.T) { + t.Parallel() + w := NewWorker(zap.NewNop(), NewService(nil, nil, nil, nil, &Options{Enabled: true})) + w.Start() + w.Stop() + var w2 *Worker + w2.Stop() +} diff --git a/internal/module/upload/logic/credential_test.go b/internal/module/upload/logic/credential_test.go index 59f8a20..9676d1b 100644 --- a/internal/module/upload/logic/credential_test.go +++ b/internal/module/upload/logic/credential_test.go @@ -36,7 +36,7 @@ func (f *fakeStore) PresignGet(_ context.Context, key string, _ time.Duration) ( func testUpload(t *testing.T) *Service { t.Helper() - return NewService(&fakeStore{}, Options{ + return NewService(&fakeStore{}, &Options{ TTL: 5 * time.Minute, Now: func() time.Time { return time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC) }, }) diff --git a/internal/module/upload/logic/service.go b/internal/module/upload/logic/service.go index 9196283..bd8fac1 100644 --- a/internal/module/upload/logic/service.go +++ b/internal/module/upload/logic/service.go @@ -41,7 +41,10 @@ type Service struct { log *zap.Logger } -func NewService(store Presigner, opt Options) *Service { +func NewService(store Presigner, opt *Options) *Service { + if opt == nil { + opt = &Options{} + } ttl := opt.TTL if ttl <= 0 { ttl = 15 * time.Minute diff --git a/internal/pkg/media/check.go b/internal/pkg/media/check.go index bd9699b..98f52dc 100644 --- a/internal/pkg/media/check.go +++ b/internal/pkg/media/check.go @@ -32,8 +32,8 @@ func (v *Violation) Error() string { // Policy controls user-path detection (bots always compress via infrastructure/media). type Policy struct { - Enabled bool // master switch for user checks - UserReject bool // when true, publish/upload returns ErrMediaOversized; else log-only + Enabled bool // master switch for user checks + UserReject bool // when true, publish/upload returns ErrMediaOversized; else log-only Limits Limits } diff --git a/internal/pkg/media/limits.go b/internal/pkg/media/limits.go index 5d438a8..995571d 100644 --- a/internal/pkg/media/limits.go +++ b/internal/pkg/media/limits.go @@ -4,14 +4,14 @@ package media // Default limits aligned with App frontend (image ≤1080p, video ≤720p ≤100MB, avatar ≤200×200). const ( - DefaultImageMaxEdge = 1080 - DefaultVideoMaxEdge = 720 - DefaultAvatarMaxEdge = 200 - DefaultImageMaxBytes = 10 << 20 // 10 MiB (square/moment image purpose) - DefaultVideoMaxBytes = 100 << 20 // 100 MiB - DefaultAvatarMaxBytes = 2 << 20 // 2 MiB (upload purpose hard cap) - DefaultJPEGQuality = 85 - DefaultVideoCRF = 23 + DefaultImageMaxEdge = 1080 + DefaultVideoMaxEdge = 720 + DefaultAvatarMaxEdge = 200 + DefaultImageMaxBytes = 10 << 20 // 10 MiB (square/moment image purpose) + DefaultVideoMaxBytes = 100 << 20 // 100 MiB + DefaultAvatarMaxBytes = 2 << 20 // 2 MiB (upload purpose hard cap) + DefaultJPEGQuality = 85 + DefaultVideoCRF = 23 ) // Limits is the resolution/size policy shared by user checks and bot compress. diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index 65849ad..a984c2b 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -24,6 +24,8 @@ const ( headerRealIP = "X-Real-IP" headerForwardedFor = "X-Forwarded-For" bearerPrefix = "bearer " + pathV1SMSCodes = "/v1/sms/codes" + pathV1Feedback = "/v1/feedback" ) func Auth(svc authlogic.Service) gin.HandlerFunc { @@ -79,8 +81,8 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { func authLimitPath(path string) bool { switch path { - case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", - "/v1/auth/password-login", "/v1/auth/password", "/v1/feedback": + case "/v1/auth/login", "/v1/auth/token/refresh", pathV1SMSCodes, + "/v1/auth/password-login", "/v1/auth/password", pathV1Feedback: return true default: return false @@ -92,9 +94,9 @@ func publicPath(path string) bool { case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/", "/favicon.ico", "/apple-touch-icon.png": return true - case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", + case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", pathV1SMSCodes, "/v1/auth/password-login", "/v1/auth/password", - "/v1/legal/agreements", "/v1/feedback", "/v1/subscriptions/plans", + "/v1/legal/agreements", pathV1Feedback, "/v1/subscriptions/plans", "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings", "/v1/developer-apps", "/api/client/pop/developer-apps", "/api/client/spark/developer-apps": @@ -130,7 +132,7 @@ func matchDeviceHeader(raw string, want uint64) error { // optionalAuthPath accepts anonymous calls; Bearer if present must be valid. func optionalAuthPath(path string) bool { switch path { - case "/v1/feedback", "/v1/sms/codes": + case pathV1Feedback, pathV1SMSCodes: return true default: return false diff --git a/internal/transport/http/file_test.go b/internal/transport/http/file_test.go index 1c8db3d..5e8b51f 100644 --- a/internal/transport/http/file_test.go +++ b/internal/transport/http/file_test.go @@ -48,7 +48,7 @@ func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.Me users, _ := logictest.UsersAndSMS(ids, nil) msgs := msgmodel.NewMemoryRepo() convs := convmodel.NewMemoryP2P() - files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{ + files := uploadlogic.NewService(&stubPresign{}, &uploadlogic.Options{ Now: func() time.Time { return time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC) }, }) favs := favlogic.NewService(favmodel.NewMemoryRepo(), msgs, convs, nil, ids) diff --git a/internal/transport/http/handler/admin/client.go b/internal/transport/http/handler/admin/client.go index 55b6005..1037f03 100644 --- a/internal/transport/http/handler/admin/client.go +++ b/internal/transport/http/handler/admin/client.go @@ -151,7 +151,7 @@ func (h *ClientHandler) CreateDeveloperApp(c *gin.Context) { Fail(c, http.StatusBadRequest, "无效 JSON") return } - out, err := h.svc.CreateDeveloperAppAdmin(c.Request.Context(), body) + out, err := h.svc.CreateDeveloperAppAdmin(c.Request.Context(), &body) if err != nil { Fail(c, http.StatusBadRequest, err.Error()) return diff --git a/internal/transport/http/moment_test.go b/internal/transport/http/moment_test.go index 52c29d7..b0866bb 100644 --- a/internal/transport/http/moment_test.go +++ b/internal/transport/http/moment_test.go @@ -40,7 +40,7 @@ func testMomentEngine(t *testing.T) *ginEngineBundle { friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), ) moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids) - files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{}) + files := uploadlogic.NewService(&stubPresign{}, &uploadlogic.Options{}) r := NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogictest.LoginService(j, users), Users: users, Friends: friends, Moments: moments, Files: files, diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 9f15fef..121c491 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -5,5 +5,14 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" +if ! command -v go >/dev/null 2>&1; then + echo "pre-commit: go not in PATH — install Go 1.26+ and golangci-lint; make test is mandatory before commit." >&2 + exit 1 +fi +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "pre-commit: golangci-lint not in PATH — run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8" >&2 + exit 1 +fi + echo "pre-commit: make test (lint + go test + logic coverage)…" >&2 make test