fix: lint check

This commit is contained in:
zhr 2026-09-29 10:22:28 +08:00
parent c58b60419e
commit 45720ae2e1
39 changed files with 646 additions and 236 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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,须安装后跑通再提交)
### 关联

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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
# 或

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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)
}

View File

@ -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 != ""
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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
}

View File

@ -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(`<?xml version="1.0"?><rss version="2.0"><channel><title>T</title>
<item><title>Hi</title><link>https://example.com/p</link>
<enclosure url="%s" type="image/png"/>
</item></channel></rss>`, 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)
}
}

View File

@ -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) {

View File

@ -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, "")
}

View File

@ -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 {

View File

@ -0,0 +1,59 @@
package logic
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseAtomFeedAndHelpers(t *testing.T) {
atom := []byte(`<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom News</title>
<entry>
<title>Entry One</title>
<id>https://example.com/e/1</id>
<link rel="alternate" href="https://example.com/e/1"/>
<link rel="enclosure" href="https://example.com/atom.jpg" type="image/jpeg"/>
<summary>&lt;p&gt;Body &lt;img src="https://example.com/fallback.png"/&gt;&lt;/p&gt;</summary>
</entry>
</feed>`)
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(`<?xml version="1.0"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel><title>MRSS</title>
<item>
<title>Media item</title>
<link>https://example.com/m</link>
<description>no img tag</description>
<media:content url="https://example.com/m.jpg" medium="image"/>
</item>
</channel></rss>`)
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)
}
}

View File

@ -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,
})

View File

@ -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())
}
}

View File

@ -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()
}

View File

@ -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) },
})

View File

@ -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

View File

@ -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
}

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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