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