feat: PopAdmin 拉取管理、签到能量 API 与图片 150KiB 硬上限

管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-11-02 12:44:06 +08:00
parent 5bb9db4a3c
commit 753c276496
33 changed files with 2941 additions and 998 deletions

138
cmd/compress-images/main.go Normal file
View File

@ -0,0 +1,138 @@
// Command compress-images scans a directory for raster images over max-bytes
// and rewrites them as JPEG ≤ limit, keeping a .bak sibling of the original.
//
// go run ./cmd/compress-images -dir ./uploads -max-bytes 153600
// go run ./cmd/compress-images -dir ./uploads -dry-run
//
// Safe for local/mounted dirs. Does not wipe remote stores or touch admin JWT.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
infmedia "spark-server/internal/infrastructure/media"
pkgmedia "spark-server/internal/pkg/media"
)
func main() {
dir := flag.String("dir", "", "directory to scan (required)")
maxBytes := flag.Int64("max-bytes", pkgmedia.DefaultImageMaxBytes, "target max bytes (default 150KiB)")
dryRun := flag.Bool("dry-run", false, "only report oversized files")
flag.Parse()
if strings.TrimSpace(*dir) == "" {
fmt.Fprintln(os.Stderr, "usage: compress-images -dir PATH [-max-bytes 153600] [-dry-run]")
os.Exit(2)
}
opt := &infmedia.Options{
Limits: pkgmedia.Limits{
ImageMaxEdge: pkgmedia.DefaultImageMaxEdge,
ImageMaxBytes: *maxBytes,
}.Normalize(),
JPEGQuality: pkgmedia.DefaultJPEGQuality,
}
var scanned, oversized, rewritten, failed int
err := filepath.WalkDir(*dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
name := strings.ToLower(d.Name())
if strings.HasSuffix(name, ".bak") {
return nil
}
mime := mimeFromExt(name)
if mime == "" {
return nil
}
scanned++
info, err := d.Info()
if err != nil {
return nil
}
if info.Size() <= *maxBytes {
return nil
}
oversized++
fmt.Printf("OVER %s (%d bytes)\n", path, info.Size())
if *dryRun {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
failed++
fmt.Printf(" read err: %v\n", err)
return nil
}
out, outMIME, err := infmedia.EnsureUnderLimit(raw, mime, opt)
if err != nil {
failed++
fmt.Printf(" compress err: %v\n", err)
return nil
}
if int64(len(out)) >= info.Size() && int64(len(out)) > *maxBytes {
failed++
fmt.Printf(" still oversized (%d)\n", len(out))
return nil
}
bak := path + ".bak"
if err := os.WriteFile(bak, raw, 0o644); err != nil {
failed++
fmt.Printf(" bak err: %v\n", err)
return nil
}
target := path
if outMIME == "image/jpeg" && !hasJPEGExt(name) {
target = stripExt(path) + ".jpg"
}
if err := os.WriteFile(target, out, 0o644); err != nil {
failed++
fmt.Printf(" write err: %v\n", err)
return nil
}
if target != path {
_ = os.Remove(path)
}
rewritten++
fmt.Printf(" OK %d -> %d bytes (%s)\n", info.Size(), len(out), target)
return nil
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("done scanned=%d oversized=%d rewritten=%d failed=%d dry-run=%v\n",
scanned, oversized, rewritten, failed, *dryRun)
}
func mimeFromExt(name string) string {
switch {
case strings.HasSuffix(name, ".jpg"), strings.HasSuffix(name, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(name, ".png"):
return "image/png"
case strings.HasSuffix(name, ".gif"):
return "image/gif"
case strings.HasSuffix(name, ".webp"):
return "image/webp"
default:
return ""
}
}
func hasJPEGExt(name string) bool {
return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")
}
func stripExt(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return path
}
return strings.TrimSuffix(path, ext)
}

View File

@ -120,18 +120,18 @@ sms:
unicom: "" unicom: ""
telecom: "" telecom: ""
# 用户上传:检测/校验(默认仅日志;user_reject=true 则拒凭证/发帖)。主压缩仍在 App。 # 服务端图片硬上限 150 KiB:objectstore Put/PutLarge 自动压缩;用户直传声明 size 也按此校验。
# 机器人媒体:bot_compress=true 时入库前压到图≤1080 / 视频≤720(视频需 ffmpeg)。 # user_reject=true 时超限拒凭证/发帖;false 仅打日志(App 仍应先压)。机器人 bot_compress 入库前再压。
media: media:
enabled: true enabled: true
user_reject: false user_reject: true
bot_compress: true bot_compress: true
image_max_edge: 1080 image_max_edge: 1080
video_max_edge: 720 video_max_edge: 720
avatar_max_edge: 200 avatar_max_edge: 200
image_max_bytes: 10485760 image_max_bytes: 153600
video_max_bytes: 104857600 video_max_bytes: 104857600
avatar_max_bytes: 2097152 avatar_max_bytes: 153600
jpeg_quality: 85 jpeg_quality: 85
video_crf: 23 video_crf: 23
ffmpeg_path: "ffmpeg" ffmpeg_path: "ffmpeg"

View File

@ -0,0 +1,41 @@
# Remote ECS build: compile inside golang image, then pack runtime image.
# Usage: docker build -f deploy/docker/Dockerfile.remote -t spark-server:latest .
FROM golang:1.26-bookworm AS builder
WORKDIR /src
ENV CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
GOPROXY=https://proxy.golang.org,direct
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
COPY api ./api
RUN go build -trimpath -ldflags="-s -w -buildid=" -o /out/spark-server ./cmd/server \
&& go build -trimpath -ldflags="-s -w -buildid=" -o /out/spark-migrate ./cmd/migrate \
&& go build -trimpath -ldflags="-s -w -buildid=" -o /out/spark-seed ./cmd/seed \
&& ls -la /out
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/gcr.io/iguazio/alpine:3.20
RUN apk add --no-cache ca-certificates tzdata ffmpeg \
&& adduser -D -H -u 10001 spark \
&& mkdir -p /usr/share/zoneinfo/Asia \
&& cp /usr/share/zoneinfo/Asia/Shanghai /tmp/Shanghai \
&& apk del tzdata \
&& mkdir -p /usr/share/zoneinfo/Asia \
&& mv /tmp/Shanghai /usr/share/zoneinfo/Asia/Shanghai \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /out/spark-server /usr/local/bin/spark-server
COPY --from=builder /out/spark-migrate /usr/local/bin/spark-migrate
COPY --from=builder /out/spark-seed /usr/local/bin/spark-seed
RUN chmod 755 /usr/local/bin/spark-server /usr/local/bin/spark-migrate /usr/local/bin/spark-seed
COPY migrations /app/migrations
RUN mkdir -p /app/logs && chown -R spark:spark /app
USER spark
EXPOSE 8080 9090
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/spark-server"]
CMD ["-config", "/app/configs/config.yaml"]

View File

@ -99,17 +99,17 @@ object_storage:
presign_ttl: 15m presign_ttl: 15m
chat_expire_days: 365 chat_expire_days: 365
# App 主压缩;服务端用户检测 + 机器人入库前压缩(视频需镜像内 ffmpeg)。 # 服务端图片 ≤150 KiB(自动压缩);用户直传声明 size 校验;机器人入库前再压(视频需 ffmpeg)。
media: media:
enabled: true enabled: true
user_reject: false user_reject: true
bot_compress: true bot_compress: true
image_max_edge: 1080 image_max_edge: 1080
video_max_edge: 720 video_max_edge: 720
avatar_max_edge: 200 avatar_max_edge: 200
image_max_bytes: 10485760 image_max_bytes: 153600
video_max_bytes: 104857600 video_max_bytes: 104857600
avatar_max_bytes: 2097152 avatar_max_bytes: 153600
jpeg_quality: 85 jpeg_quality: 85
video_crf: 23 video_crf: 23
ffmpeg_path: "ffmpeg" ffmpeg_path: "ffmpeg"

View File

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Build spark-server on ECS via multi-stage Dockerfile.remote (golang container compile).
# Never overwrites whole configs/config.yaml; only patches image/avatar max bytes if present.
# Env: SPARK_RELEASE, SPARK_SRC_DIR, SPARK_DATA_DIR
set -euo pipefail
DATA="${SPARK_DATA_DIR:-/data/spark-server}"
REMOTE="${SPARK_SRC_DIR:-/tmp/spark-image-limit-build}"
RELEASE="${SPARK_RELEASE:-img150k-$(date +%Y%m%d%H%M%S)}"
LOCK="${SPARK_DEPLOY_LOCK:-/tmp/spark-image-limit-build.lock}"
COMPOSE_LOCK="${SPARK_COMPOSE_LOCK:-/tmp/spark-compose-mutate.lock}"
cleanup() { rm -f "$LOCK" || true; }
cleanup_all() { rm -f "$LOCK" "$COMPOSE_LOCK" || true; }
trap cleanup EXIT
if [[ -f "$COMPOSE_LOCK" ]]; then
echo "compose mutate lock held: $COMPOSE_LOCK" >&2
cat "$COMPOSE_LOCK" >&2 || true
exit 99
fi
if [[ -f /tmp/sign-energy-deploy.lock ]]; then
echo "sign-energy deploy in progress; abort to avoid conflict" >&2
cat /tmp/sign-energy-deploy.lock >&2 || true
exit 99
fi
echo $$ >"$LOCK"
echo "==> release $RELEASE (src=$REMOTE)"
cd "$REMOTE"
if [[ -f spark-src.tgz ]]; then
tar xzf spark-src.tgz
fi
test -f go.mod
DF=Dockerfile.remote
if [[ ! -f "$DF" && -f deploy/docker/Dockerfile.remote ]]; then
DF=deploy/docker/Dockerfile.remote
fi
test -f "$DF"
mkdir -p "$DATA/releases" "$DATA/configs/backup" "$DATA/images"
# Stamp rollback BEFORE retagging :latest
if [[ -f "$DATA/configs/config.yaml" ]]; then
cp -a "$DATA/configs/config.yaml" "$DATA/configs/backup/config.yaml.$(date +%Y%m%d%H%M%S)"
fi
if docker image inspect spark-server:latest >/dev/null 2>&1; then
docker tag spark-server:latest spark-server:rollback
docker image inspect spark-server:latest --format '{{.Id}}' >"$DATA/releases/previous_image_id"
date -Iseconds >"$DATA/releases/previous_stamped_at"
fi
# Patch media limits in-place only when keys already exist (never rewrite whole file).
python3 - <<'PY'
from pathlib import Path
import re
p = Path("/data/spark-server/configs/config.yaml")
if not p.is_file():
print("NOTE: no config.yaml")
raise SystemExit(0)
t = p.read_text(encoding="utf-8")
if "image_max_bytes:" not in t:
print("NOTE: image_max_bytes missing; code DefaultOptions (150 KiB) will apply")
raise SystemExit(0)
def set_key(text, key, val):
pat = re.compile(r"(^\s*" + key + r":\s*)\d+", re.M)
if pat.search(text):
return pat.sub(r"\g<1>" + str(val), text), True
return text, False
nt, c1 = set_key(t, "image_max_bytes", 153600)
nt, c2 = set_key(nt, "avatar_max_bytes", 153600)
if c1 or c2:
p.write_text(nt, encoding="utf-8")
print("patched media max bytes to 153600")
else:
print("media keys present; left as-is")
PY
echo "==> docker build via Dockerfile.remote (compile inside golang:1.26-bookworm; NO host go)"
docker pull golang:1.26-bookworm
# Fallback explicit container compile if someone only has runtime Dockerfile:
# docker run --rm -v "$REMOTE":/src -w /src -e CGO_ENABLED=0 -e GOOS=linux -e GOARCH=amd64 \
# -e GOPROXY=https://proxy.golang.org,direct --entrypoint go golang:1.26-bookworm \
# build -trimpath -ldflags='-s -w -buildid=' -o bin/linux_amd64/spark-server ./cmd/server
docker build --platform linux/amd64 \
-f "$DF" \
-t "spark-server:${RELEASE}" \
-t spark-server:latest \
"$REMOTE"
echo "==> save image tar"
docker save "spark-server:${RELEASE}" | gzip -c >"$DATA/images/spark-server-${RELEASE}.tar.gz"
cp -f "$DATA/images/spark-server-${RELEASE}.tar.gz" "$DATA/images/spark-server.tar.gz"
echo "img150k $RELEASE $(date -Iseconds) pid=$$" >"$COMPOSE_LOCK"
trap cleanup_all EXIT
cd "$DATA/compose"
docker compose -f docker-compose.yaml up -d --force-recreate spark-server
ok=0
for _ in $(seq 1 36); do
if curl -fsS -m 3 http://127.0.0.1:18080/healthz >/dev/null 2>&1; then
ok=1
break
fi
sleep 2
done
if [[ "$ok" != "1" ]]; then
echo "HEALTH_FAIL rolling back" >&2
docker tag spark-server:rollback spark-server:latest || true
docker compose -f docker-compose.yaml up -d --force-recreate spark-server || true
exit 2
fi
docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations || true
echo "==> verify"
curl -fsS http://127.0.0.1:18080/healthz; echo
curl -fsS http://127.0.0.1:18080/readyz; echo
grep -E 'image_max_bytes|avatar_max_bytes' "$DATA/configs/config.yaml" || echo "config keys absent (DefaultOptions OK)"
docker exec spark-server sh -c 'strings /usr/local/bin/spark-server | grep -F "All server-written images are capped" | head -1' || echo "string probe miss"
docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
printf '%s\n' "$RELEASE" >"$DATA/releases/current_release"
IMG_ID=$(docker image inspect spark-server:latest --format '{{.Id}}')
PREV=$(cat "$DATA/releases/previous_image_id" 2>/dev/null || true)
NOW=$(date -Iseconds)
cat >"$DATA/releases/manifest.json" <<EOF
{
"status": "ok",
"release": "$RELEASE",
"image_id": "$IMG_ID",
"previous_image_id": "$PREV",
"image_tar": "$DATA/images/spark-server-$RELEASE.tar.gz",
"deployed_at": "$NOW",
"note": "image under 150k EnsureUnderLimit (Dockerfile.remote)"
}
EOF
echo "RELEASE $RELEASE"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
# 每日变动 2026-10-26
## 13:50 — 签到 / 补签 / 能量查询
### 概要
- 公开 OpenAPI 与服务端补齐签到月历、当日签到、补签资格/执行、能量余额与明细。
- 客户端(SparkApk / wyPOPandroid)将 `SparkUnavailable` 换成 `/v1/sign*`、`/v1/energy*`,避免失败后 `initData` 白屏。
### 变更范围
| 类型 | 说明 |
|------|------|
| 功能 | `energy` 模块 + HTTP `/v1/sign`、`/v1/energy` |
| 文档 | `openapi.yaml` 增加 sign / energy 标签与路径 |
| 客户端 | SignInManager / PayManager / SignInActivity 适配 |
### 主要文件
- `migrations/0034_sign_energy.sql` — 钱包 / 流水 / 签到表
- `internal/module/energy/**` — entity / model / logic
- `internal/transport/http/handler/energy/handler.go`、`router.go`、`server.go`、`bootstrap/app.go`
- `internal/transport/http/docs/openapi.yaml`
- `SparkApk` / `wyPOPandroid`:`SignInManager.kt`、`PayManager.kt`、`SignInActivity.kt`
### 验证
- `go test ./internal/module/energy/logic` pass
- 全量 `make test` / 部署:见任务简报
### 风险
- 补签激励视频闸门未启用(`need_watch_video_count=0`);抽奖仍 unavailable。
- 需 migrate `0034` 后能量接口才有持久化。

View File

@ -1,42 +1,62 @@
# 媒体压缩与检测 # 媒体压缩与检测
> App 端主压缩;服务端对用户路径做检测,对机器人路径强制压缩后写对象存储。 > **服务端图片硬上限:≤ 150 KiB。**
> 经 `objectstore.Put` / `PutLarge` 写入的图片会自动压缩;用户直传对象存储时由凭证/发帖声明 size 校验(`user_reject=true` 拒收超限)。
## 规格(与 App 对齐) ## 规格
| 类型 | 最长边 | 体积参考 | | 类型 | 最长边 | 体积上限 |
|------|--------|----------| |------|--------|----------|
| 普通图片(广场/朋友圈等) | ≤ **1080** | ≤ 10 MiB | | 普通图片(广场/朋友圈/封面/bot 等) | ≤ **1080** | ≤ **150 KiB** |
| 视频 | ≤ **720** | ≤ 100 MiB | | 视频 | ≤ **720** | ≤ 100 MiB |
| 头像 | ≤ **200×200** | ≤ 2 MiB | | 头像 | ≤ **200×200** | ≤ **150 KiB** |
## 检测点(用户上传) ## 自动压缩(服务端写路径)
| 时机 | 位置 | 行为 | | 时机 | 位置 | 行为 |
|------|------|------| |------|------|------|
| 申请上传凭证 | `upload/logic.CreateCredential` → `checkDeclaredSize` | 按 `purpose` 校验声明 `size`;超限默认 **打 Warn 日志**,`media.user_reject=true` 时返回 `40005` | | 任意图片 Put | `objectstore.Put` / `PutLarge` → `media.EnsureUnderLimit` | JPEG 质量阶梯下调,必要时缩边至 ≥240px,直到 ≤150 KiB |
| 广场发帖 | `square/logic.publish` → `checkMediaMeta` | 用客户端声明的图/视频宽高与 size 校验;同上 | | 管理端音乐封面/背景 | `music.UploadCover` / `UploadBackground` → PutLarge | 同上 |
| 朋友圈发帖 | `moment/logic.publish` → `checkMediaMeta` | 同上 | | 字母头像 / 群拼接头像 | `user.putLetterPNG` / `group.putAvatar` → Put | 同上 |
| RSS/bot 配图 | `squarebot` → `PrepareBotImage` → PutLarge | 同上(`media.bot_compress`) |
主压缩仍在 **App 上传前**;服务端不把用户文件再压一遍(除非开启拒收)。 压缩策略:先压到 `image_max_edge`,再按 JPEG 质量 85→22 试压;仍超则按 80% 逐步缩边至 `MinImageEdge`(240)。
## 机器人压缩点 ## 检测点(用户直传)
| 时机 | 位置 | 行为 | | 时机 | 位置 | 行为 |
|------|------|------| |------|------|------|
| RSS 配图 | `squarebot` Tick → `attachMedia` → `FetchImage` → `objectstore.PutLarge` | 下载后压到 ≤1080,再写入 `perm/square/image/` | | 申请上传凭证 | `upload/logic.CreateCredential` → `checkDeclaredSize` | 声明 `size` > 150 KiB → **40005**(`user_reject=true`) |
| 机器人视频路径 | `PrepareVideoAsset` / `FetchVideo` | ffmpeg 压到 ≤720(部署需 `ffmpeg`) | | 广场/朋友圈发帖 | `checkMediaMeta` | 同上 |
| 机器人头像 | `FetchAvatar` / `PrepareBotAvatar` | ≤200×200 |
开关:`media.bot_compress`(默认 true)。关闭时机器人帖仍发文字摘要+链接,不拉配图。 App 上传前仍应压缩;服务端对**经本机写入**的路径再保底压一次。直传 S3/RustFS 的字节流服务端看不到,只能靠凭证 size 拒收。
## 配置 ## 配置
见 `configs/config.yaml` 的 `media:` 与 `square_bot:`。 见 `configs/config.yaml` / `deploy/docker/config.docker.yaml` 的 `media:`:
```yaml
media:
enabled: true
user_reject: true
bot_compress: true
image_max_bytes: 153600 # 150 KiB
avatar_max_bytes: 153600
```
## 存量超限图
本地/挂载目录批量压缩(保留 `.bak`):
```bash
go run ./cmd/compress-images -dir /path/to/images -max-bytes 153600
# 预览:加 -dry-run
```
生产 RustFS/对象存储需另做 List+Get+Put 任务;勿 wipe、勿改 admin JWT。
## 部署注意 ## 部署注意
- 视频压缩依赖主机 **`ffmpeg`**(`media.ffmpeg_path`,默认 `ffmpeg`)。运行镜像 `Dockerfile` 已 `apk add ffmpeg`。 - 视频压缩依赖主机 **`ffmpeg`**(`media.ffmpeg_path`)。运行镜像已 `apk add ffmpeg`。
- 迁移:`migrations/0026_square_bot_rss.sql`(`source` / `source_url` / `expires_at` + `t_square_bot_links`)。 - 错误码:`40005` / `media_oversized`。
- Seed:`go run ./cmd/seed -config configs/config.yaml` 确保 `bot001@spark.bot`… 存在。 - 验证:上传一张 >150 KiB 的封面/头像后,对象体积应 ≤153600。
- 错误码:`40005` / `media_oversized`(`media.user_reject=true` 时拒凭证或发帖)。

View File

@ -0,0 +1,236 @@
# PopAdmin「通讯录 / 用户相册 / 媒体拉取」建设需求
| 项 | 内容 |
|---|---|
| 入口 | https://popadmin.wyrs365.com/popadmin/index.html |
| 适用端 | PopAdmin 管理端、SparkApk 客户端、服务端 sync API |
| 文档用途 | 产品 / 研发对齐;验收与联调依据 |
| 状态 | 需求整理(已沟通合并去重) |
---
## 1. 背景与目标
在 PopAdmin 中建设(或完善)三个与用户本地数据同步相关的子页面:**通讯录**、**用户相册**、**媒体拉取**。管理员可按用户查询、查看授权状态、按需开启拉取并设定数量上限;客户端在用户知情同意后按服务端策略上传。
**核心目标:**
1. 管理端可按 **用户 id / 邮箱** 精准查询并操作单用户拉取策略。
2. 拉取必须同时满足:**开关开启 + 数量上限内 + 用户已授权**;任一不满足则不得实际拉取/上传。
3. 杜绝「管理端开关已开、但用户未授权」时仍发生同步的误操作与合规风险。
4. 客户端可弱化管理向调试文案,但**禁止**无提示、无感知的隐蔽外传。
---
## 2. 三页范围
| 页面 | 管理能力 | 同步内容 | 备注 |
|---|---|---|---|
| **通讯录** | 查看 / 管理通讯录拉取策略与结果 | 用户通讯录(知情同意后同步) | 开关 + 数量 + 授权状态 |
| **用户相册** | 查看 / 管理相册拉取策略与结果 | 相册中的图片、视频 | 默认从**时间戳最新**开始拉;开关 + 数量 + 授权 |
| **媒体拉取** | 查看 / 管理媒体(以文档为主)拉取策略与结果 | **主目标:文档**(PDF / Excel / Word 等);图、视频可同页保留 | 默认同最新优先;开关 + 数量 + 授权 |
三页共用同一套查询、行布局、授权展示、开关默认策略、每用户数量、以及**未授权安全门禁**。
---
## 3. 查询与行布局
### 3.1 查询
- 支持按用户 **id** 查询。
- 支持按用户 **邮箱** 查询。
- **全局默认**不对全量用户开放拉取操作:须先查询到对应用户后,方可对该用户操作开启拉取。
### 3.2 每用户行布局(建议顺序)
```text
[拉取开关] → [是否已授权] → [用户id] → …
```
后续列可按页面补充:邮箱、拉取数量、最近同步时间、同步结果摘要等,但**开关与授权状态必须靠前、一眼可见**。
---
## 4. 授权状态
- **授权状态**通过 token(或等价凭证)从客户端 / 前端侧获取并在管理端展示。
- 展示取值至少区分:**已授权** / **未授权**。
- 「已授权」含义:用户已对**该类数据同步**给出可理解的知情同意(可按能力拆分:通讯录 / 相册 / 媒体文档等,具体粒度见「待决」)。
- 管理端展示的授权态须与服务端用于门禁校验的权威状态一致(或可追溯同源),避免「页面显示已授权、服务端判未授权」或相反。
---
## 5. 拉取开关与默认策略
| 规则 | 说明 |
|---|---|
| 全局默认 | **所有用户 id 的拉取开关默认关闭** |
| 可操作前提 | 仅在查询到对应用户后,管理员才可尝试开启该用户的拉取开关 |
| 每用户独立 | 通讯录 / 相册 / 媒体拉取的开关彼此独立(互不影响),同一能力下按用户分别配置 |
| 与授权关系 | 开关开启**不等于**允许拉取;必须叠加授权与数量门禁(见 §8、§9) |
---
## 6. 每用户拉取数量
- **每用户可分别设置**拉取数量上限(各页面 / 各能力独立配置)。
- 实际同步条数不得超过该上限;服务端与客户端均须尊重上限。
- 数量变更后,新上限立即作用于后续 sync;已入库数据是否回扫 / 是否补拉见「待决」。
---
## 7. 排序规则
| 页面 | 默认拉取顺序 |
|---|---|
| 用户相册 | 按媒体 **时间戳从新到旧**(最新优先) |
| 媒体拉取 | 同:**最新优先**(文档列表;若同页含图/视频,建议同一时间序策略) |
| 通讯录 | 无「时间戳最新」语义时,按产品约定的稳定顺序即可(如姓名 / 系统序);不强制「最新优先」 |
---
## 8. 未授权防护(重点:开关误开场景)
### 8.1 问题定义
**场景:** 管理端将某用户的「拉取开关」打开,但该用户**尚未授权**(无有效同意 / token 无效 / 未完成知情确认)。
**要求:** 此种情况下**不得**发生实际拉取、上传或外传。开关本身不能绕过授权。
### 8.2 管理端
须采用下列策略之一(推荐组合:**前端禁止 + 服务端拒绝**):
1. **禁止开启(推荐默认)**
- 用户为「未授权」时,拉取开关不可打开(disabled),并明确提示原因(如:「用户未授权,无法开启拉取」)。
2. **开启时强提示 + 服务端拒绝**
- 若产品坚持允许「先开开关、等用户授权后再生效」,则开启时必须弹出**强提示**:当前未授权,**不会**实际拉取,直至用户完成授权;且服务端对未授权请求一律拒绝。
3. **列表态提示**
- 行内清晰展示「开关开 / 未授权 → 实际未拉取」的状态,避免运营误以为已在同步。
### 8.3 客户端(SparkApk)
- **无有效授权 / 同意时,不得上传**对应数据。
- **禁止**无感知、隐蔽外传(即使用户界面弱化了「同步日志 / 调试记录」等管理向字样)。
- 真正上传前须有用户可理解的授权或确认流程。
- 客户端须遵守服务端下发的:**开关、数量上限、授权**三重门禁;本地不得以「开关曾开过」等理由绕过。
### 8.4 服务端
- sync / 拉取相关 API **必须**同时校验:
1. 该用户该能力的 **拉取开关为开启**;
2. 本次请求 / 累计在 **数量上限**内;
3. 该用户对该能力 **已授权**(有效同意)。
- 任一条件失败 → **拒绝拉取请求**,返回明确错误码 / 文案;不得部分静默成功。
- 即使管理端 UI 被绕过或开关字段被直接改库为开,**未授权时服务端仍须拒绝**。
### 8.5 一句话原则
> **未授权 = 零上传。** 管理端开关只是必要条件之一,绝不是充分条件。
---
## 9. 服务端校验
对客户端发起的同步 / 上传 / 拉取指令类接口,统一门禁:
| 校验项 | 失败时行为 |
|---|---|
| 拉取开关开启 | 拒绝;记录可审计日志(用户 id、能力类型、原因) |
| 数量上限 | 拒绝超额部分或整单;不得超限入库 |
| 已授权 | 拒绝;与 §8 一致 |
建议:
- 授权校验以服务端权威状态为准(由客户端上报同意结果并经服务端确认 / 签发,而非仅信客户端本地布尔值)。
- 管理端改开关 / 改数量的接口与 sync 接口权限分离:仅管理员可改策略;仅持用户凭证的客户端可 sync,且仍过三重门禁。
---
## 10. 客户端约束(SparkApk)
| 要求 | 说明 |
|---|---|
| 可隐藏管理向文案 | 可弱化或隐藏面向用户的「同步日志 / 调试记录」等管理调试字样 |
| 不可静默外传 | **不可**在无用户可理解授权/确认的情况下上传通讯录、相册、文档等 |
| 上传前确认 | 真正上传前须有用户可理解的授权或确认 |
| 三重门禁 | 遵守服务端:**开关开 + 数量内 + 已授权**;本地亦应短路,减少无效请求 |
| 无有效授权 | 不得发起实际上传;网络层失败不得改走「静默重试隐蔽通道」 |
---
## 11. 媒体拉取与文档
- **主目标:** 查看与拉取用户设备上的**文档类**文件(如 PDF、Excel、Word 等)。
- 图片、视频可在同一「媒体拉取」页保留展示 / 策略,但产品优先级以文档为主。
- 默认拉取顺序:**时间戳最新优先**(与用户相册一致)。
- 同样适用:开关、每用户数量、授权状态、以及 §8 未授权防护与 §9 服务端校验。
---
## 12. 验收标准
### 12.1 管理端(三页共性)
- [ ] 支持用户 **id**、**邮箱** 查询。
- [ ] 行布局含:**拉取开关 → 是否已授权 → 用户 id → …**。
- [ ] 授权状态正确展示已授权 / 未授权。
- [ ] 未查询到用户前,不能对「全局默认关闭」的用户随意批量开启(符合默认关闭策略)。
- [ ] 可为每用户分别设置拉取数量。
- [ ] **未授权时**:无法开启开关,**或**开启时有强提示且标明不会实际拉取。
### 12.2 未授权防护(强制)
- [ ] 开关开 + 未授权 → **客户端不上传**。
- [ ] 开关开 + 未授权 → **服务端 sync 拒绝**。
- [ ] 无用户可理解授权/确认时,APK **无隐蔽外传**。
- [ ] 管理端 UI 清晰反映「开了开关但未授权 = 未在拉取」。
### 12.3 分页面
- [ ] **通讯录**:策略与结果可查;知情同意后方可同步。
- [ ] **用户相册**:图/视频;最新优先;开关 + 数量 + 授权。
- [ ] **媒体拉取**:文档为主;最新优先;图视频可同页;开关 + 数量 + 授权。
### 12.4 客户端 APK
- [ ] 上传前有可理解授权/确认。
- [ ] 遵守服务端三重门禁。
- [ ] 可隐藏管理向调试字样,但不降低授权强度。
---
## 13. 待决 / 风险
| 项 | 说明 | 建议 |
|---|---|---|
| 授权粒度 | 通讯录 / 相册 / 文档是否共用一次同意,还是分能力授权 | 产品定稿;服务端按能力存授权态更稳妥 |
| 「先开开关、后授权」 | 是否允许管理端在未授权时打开开关仅作预置 | 若允许,必须强提示 + 服务端拒绝直至授权 |
| 数量变更与补拉 | 提高上限后是否自动补拉历史;降低上限是否裁剪已存 | 需产品规则 |
| 双 JWT | Go IM 与 Java `email_user` 两套 JWT 若仍并存 | **已知约束 / 待统一**;联调时注意用对账号体系 |
| 授权态展示源 | token / 客户端上报 / 服务端表 | 须避免管理端展示与门禁源不一致 |
| 合规文案 | 用户侧授权弹窗文案、范围说明 | 法务/产品审阅后再定稿 |
---
## 附录 A. 联调账号与环境
| 用途 | 信息 |
|---|---|
| 测试邮箱用户 | `email_user`:`spark.test.01@wyrs.test.local`,`userId=1017` |
| Go IM | `test01@spark.com` |
| 模拟器 | 夜神,端口 **62001** |
联调时注意:若 **双 JWT**(Go IM vs Java `email_user`)仍存在,管理端查询、客户端登录、sync 鉴权可能分属不同体系,属已知约束,统一前需在用例中标明所用身份。
---
## 附录 B. 需求来源摘要
- 入口与三页建设范围(通讯录 / 用户相册 / 媒体拉取)。
- 查询(id / 邮箱)、行布局、授权展示、默认关、每用户数量、最新优先。
- **安全门禁:** 未授权时即使开关开启也不得拉取;管理端限制或强提示;客户端禁止隐蔽外传;服务端三重校验。
- SparkApk:可隐藏管理向调试字样,不可静默外传;上传前须用户可理解授权。
- 联调账号与双 JWT 待统一项。
`)

View File

@ -31,6 +31,8 @@ import (
delivermodel "spark-server/internal/module/deliver/model" delivermodel "spark-server/internal/module/deliver/model"
devicelogic "spark-server/internal/module/device/logic" devicelogic "spark-server/internal/module/device/logic"
devicemodel "spark-server/internal/module/device/model" devicemodel "spark-server/internal/module/device/model"
energylogic "spark-server/internal/module/energy/logic"
energymodel "spark-server/internal/module/energy/model"
favlogic "spark-server/internal/module/favorite/logic" favlogic "spark-server/internal/module/favorite/logic"
favmodel "spark-server/internal/module/favorite/model" favmodel "spark-server/internal/module/favorite/model"
friendlogic "spark-server/internal/module/friend/logic" friendlogic "spark-server/internal/module/friend/logic"
@ -223,10 +225,11 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
configureSocialMedia(moments, square, &cfg.Media, log) configureSocialMedia(moments, square, &cfg.Media, log)
app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users) app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users)
music := wireMusic(cfg, db, objects) music := wireMusic(cfg, db, objects)
energy := wireEnergy(db)
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...) users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...)
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen, users) adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen, users)
attachTransports(app, cfg, log, pool, sessions, tlsConf, idgen, jwtSvc, authLimit, messages, syncSvc, attachTransports(app, cfg, log, pool, sessions, tlsConf, idgen, jwtSvc, authLimit, messages, syncSvc,
login, smsSvc, users, friends, groups, files, favorites, moments, square, music, client, adminSvc, adminTokens, moderation, probe) login, smsSvc, users, friends, groups, files, favorites, moments, square, music, energy, client, adminSvc, adminTokens, moderation, probe)
return app, nil return app, nil
} }
@ -237,7 +240,7 @@ func attachTransports(
login *authlogic.LoginService, smsSvc *smslogic.Service, users *userlogic.Service, login *authlogic.LoginService, smsSvc *smslogic.Service, users *userlogic.Service,
friends *friendlogic.Service, groups *grouplogic.Service, files *uploadlogic.Service, friends *friendlogic.Service, groups *grouplogic.Service, files *uploadlogic.Service,
favorites *favlogic.Service, moments *momentlogic.Service, square *squarelogic.Service, favorites *favlogic.Service, moments *momentlogic.Service, square *squarelogic.Service,
music *musiclogic.Service, client *clientlogic.Service, music *musiclogic.Service, energy *energylogic.Service, client *clientlogic.Service,
adminSvc *adminlogic.Service, adminTokens *adminlogic.TokenService, moderation *modlogic.Service, probe *Probe, adminSvc *adminlogic.Service, adminTokens *adminlogic.TokenService, moderation *modlogic.Service, probe *Probe,
) { ) {
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
@ -260,7 +263,7 @@ func attachTransports(
Sync: syncSvc, Sync: syncSvc,
}) })
app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps( app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps(
jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, music, client, jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, music, energy, client,
messages, syncSvc, adminSvc, adminTokens, moderation, probe, authLimit, messages, syncSvc, adminSvc, adminTokens, moderation, probe, authLimit,
)) ))
} }
@ -277,6 +280,7 @@ func httpEngineDeps(
moments *momentlogic.Service, moments *momentlogic.Service,
square *squarelogic.Service, square *squarelogic.Service,
music *musiclogic.Service, music *musiclogic.Service,
energy *energylogic.Service,
client *clientlogic.Service, client *clientlogic.Service,
messages *msglogic.Service, messages *msglogic.Service,
syncSvc *synclogic.Service, syncSvc *synclogic.Service,
@ -288,7 +292,7 @@ func httpEngineDeps(
) *httpx.EngineDeps { ) *httpx.EngineDeps {
return &httpx.EngineDeps{ return &httpx.EngineDeps{
Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups, Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups,
Files: files, Favorites: favorites, Moments: moments, Square: square, Music: music, Client: client, Files: files, Favorites: favorites, Moments: moments, Square: square, Music: music, Energy: energy, Client: client,
Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens, Moderation: moderation, Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens, Moderation: moderation,
Health: probe, AuthLimiter: authLimit, Health: probe, AuthLimiter: authLimit,
} }
@ -457,6 +461,16 @@ func wireSquare(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, fri
return squarelogic.NewService(repo, friends, users, objects, idgen) return squarelogic.NewService(repo, friends, users, objects, idgen)
} }
func wireEnergy(db *mysql.DB) *energylogic.Service {
var repo energylogic.Repository
if db != nil && db.Ready() {
repo = energymodel.NewRepo(db.Gorm())
} else {
repo = energymodel.NewMemoryRepo()
}
return energylogic.NewService(repo)
}
func wireMusic(cfg *Config, db *mysql.DB, store *objectstore.Store) *musiclogic.Service { func wireMusic(cfg *Config, db *mysql.DB, store *objectstore.Store) *musiclogic.Service {
if db == nil || !db.Ready() { if db == nil || !db.Ready() {
return nil return nil

View File

@ -137,8 +137,8 @@ type ObjectStorageConfig struct {
ChatExpireDays int `yaml:"chat_expire_days"` ChatExpireDays int `yaml:"chat_expire_days"`
} }
// MediaConfig is server-side media detect (users) + compress (bots). // MediaConfig is server-side media detect (users) + compress (bots / objectstore).
// Client App still compresses user uploads; see docs/media.md. // Image/avatar hard cap defaults to 150 KiB; see docs/media.md.
type MediaConfig struct { type MediaConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005 UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005
@ -265,13 +265,13 @@ func applyMediaDefaults(m *MediaConfig) {
m.AvatarMaxEdge = 200 m.AvatarMaxEdge = 200
} }
if m.ImageMaxBytes <= 0 { if m.ImageMaxBytes <= 0 {
m.ImageMaxBytes = 10 << 20 m.ImageMaxBytes = 150 * 1024 // 150 KiB
} }
if m.VideoMaxBytes <= 0 { if m.VideoMaxBytes <= 0 {
m.VideoMaxBytes = 100 << 20 m.VideoMaxBytes = 100 << 20
} }
if m.AvatarMaxBytes <= 0 { if m.AvatarMaxBytes <= 0 {
m.AvatarMaxBytes = 2 << 20 m.AvatarMaxBytes = 150 * 1024 // 150 KiB
} }
if m.JPEGQuality <= 0 { if m.JPEGQuality <= 0 {
m.JPEGQuality = 85 m.JPEGQuality = 85

View File

@ -17,7 +17,7 @@ type BotAsset struct {
Size int64 Size int64
} }
// PrepareBotImage compresses RSS/downloaded image bytes to ≤1080p before object storage write. // PrepareBotImage compresses RSS/downloaded image bytes to ≤1080p and ≤150 KiB before object storage write.
// Call this after HTTP download and before PutLarge. // 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) res, err := CompressImage(src, opt)

View File

@ -1,4 +1,5 @@
// Package media compresses images (stdlib + x/image) and videos (ffmpeg) for bot pipelines. // Package media compresses images (stdlib + x/image) and videos (ffmpeg) for server pipelines.
// All server-written images are capped at Limits.ImageMaxBytes (default 150 KiB).
// Deploy note: video compression requires the `ffmpeg` binary on PATH (or Options.FFmpegPath). // Deploy note: video compression requires the `ffmpeg` binary on PATH (or Options.FFmpegPath).
package media package media
@ -12,6 +13,7 @@ import (
"strings" "strings"
"golang.org/x/image/draw" "golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register WebP decoder
pkgmedia "spark-server/internal/pkg/media" pkgmedia "spark-server/internal/pkg/media"
) )
@ -45,7 +47,11 @@ type ImageResult struct {
MIME string MIME string
} }
// 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). var jpegQualitySteps = []int{85, 75, 65, 55, 45, 35, 28, 22}
// CompressImage scales longest edge to ≤ Limits.ImageMaxEdge and re-encodes as JPEG,
// stepping quality down then scaling further until ≤ ImageMaxBytes (default 150 KiB).
// Already-small images under the byte cap (and edge) are returned unchanged.
func CompressImage(src []byte, opt *Options) (*ImageResult, error) { func CompressImage(src []byte, opt *Options) (*ImageResult, error) {
if opt == nil { if opt == nil {
return nil, fmt.Errorf("media: nil options") return nil, fmt.Errorf("media: nil options")
@ -61,25 +67,51 @@ func CompressImage(src []byte, opt *Options) (*ImageResult, error) {
b := img.Bounds() b := img.Bounds()
w, h := b.Dx(), b.Dy() w, h := b.Dx(), b.Dy()
maxEdge := opt.Limits.ImageMaxEdge maxEdge := opt.Limits.ImageMaxEdge
if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= opt.Limits.ImageMaxBytes { maxBytes := opt.Limits.ImageMaxBytes
mime := mimeForFormat(format) if maxBytes <= 0 {
return &ImageResult{Data: src, Width: w, Height: h, MIME: mime}, nil maxBytes = pkgmedia.DefaultImageMaxBytes
} }
if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= maxBytes {
return &ImageResult{Data: src, Width: w, Height: h, MIME: mimeForFormat(format)}, nil
}
nw, nh := scaleToMax(w, h, maxEdge) nw, nh := scaleToMax(w, h, maxEdge)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) cur := resample(img, b, nw, nh)
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) qualities := qualityLadder(opt.JPEGQuality)
var buf bytes.Buffer best, bw, bh, err := encodeJPEGLadder(cur, qualities, maxBytes)
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: opt.JPEGQuality}); err != nil { if err != nil {
return nil, fmt.Errorf("media: encode jpeg: %w", err) return nil, err
} }
out := buf.Bytes() if int64(len(best)) <= maxBytes {
if int64(len(out)) > opt.Limits.ImageMaxBytes { return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil
return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(out), opt.Limits.ImageMaxBytes)
} }
return &ImageResult{Data: out, Width: nw, Height: nh, MIME: "image/jpeg"}, nil
// Still over: shrink edge iteratively down to MinImageEdge.
edge := pkgmedia.LongestEdge(bw, bh)
minEdge := pkgmedia.MinImageEdge
for edge > minEdge && int64(len(best)) > maxBytes {
edge = edge * 4 / 5
if edge < minEdge {
edge = minEdge
}
cw, ch := scaleToMax(w, h, edge)
cur = resample(img, b, cw, ch)
cand, cw2, ch2, encErr := encodeJPEGLadder(cur, []int{55, 45, 35, 28, 22}, maxBytes)
if encErr != nil {
return nil, encErr
}
best, bw, bh = cand, cw2, ch2
if int64(len(best)) <= maxBytes {
break
}
}
if int64(len(best)) > maxBytes {
return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(best), maxBytes)
}
return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil
} }
// CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes. // CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes under AvatarMaxBytes.
func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) { func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) {
if opt == nil { if opt == nil {
return nil, fmt.Errorf("media: nil options") return nil, fmt.Errorf("media: nil options")
@ -92,6 +124,95 @@ func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) {
return CompressImage(src, opt) return CompressImage(src, opt)
} }
// DefaultOptions returns compressor options with 150 KiB image/avatar caps.
func DefaultOptions() *Options {
return &Options{Limits: pkgmedia.DefaultLimits(), JPEGQuality: pkgmedia.DefaultJPEGQuality}
}
// IsImageMIME reports whether contentType looks like a raster image we can compress.
func IsImageMIME(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
ct, _, _ = strings.Cut(ct, ";")
ct = strings.TrimSpace(ct)
switch ct {
case "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/bmp", "image/x-png":
return true
default:
return false
}
}
// EnsureUnderLimit compresses image bytes when over ImageMaxBytes / max edge.
// Non-image content types are returned unchanged. On compress failure returns an error.
func EnsureUnderLimit(src []byte, contentType string, opt *Options) (data []byte, mime string, err error) {
if !IsImageMIME(contentType) {
return src, contentType, nil
}
if opt == nil {
opt = DefaultOptions()
}
res, err := CompressImage(src, opt)
if err != nil {
return nil, "", err
}
return res.Data, res.MIME, nil
}
func qualityLadder(start int) []int {
if start <= 0 || start > 100 {
start = pkgmedia.DefaultJPEGQuality
}
out := make([]int, 0, len(jpegQualitySteps)+1)
seen := map[int]struct{}{}
add := func(q int) {
if q < 20 {
q = 20
}
if q > 100 {
q = 100
}
if _, ok := seen[q]; ok {
return
}
seen[q] = struct{}{}
out = append(out, q)
}
add(start)
for _, q := range jpegQualitySteps {
if q < start {
add(q)
}
}
return out
}
func encodeJPEGLadder(img image.Image, qualities []int, maxBytes int64) ([]byte, int, int, error) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
var best []byte
for _, q := range qualities {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil {
return nil, 0, 0, fmt.Errorf("media: encode jpeg: %w", err)
}
out := buf.Bytes()
best = out
if int64(len(out)) <= maxBytes {
return out, w, h, nil
}
}
if best == nil {
return nil, 0, 0, fmt.Errorf("media: jpeg encode empty")
}
return best, w, h, nil
}
func resample(src image.Image, srcBounds image.Rectangle, nw, nh int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, draw.Over, nil)
return dst
}
func scaleToMax(w, h, maxEdge int) (int, int) { func scaleToMax(w, h, maxEdge int) (int, int) {
if maxEdge <= 0 || !pkgmedia.NeedsResize(w, h, maxEdge) { if maxEdge <= 0 || !pkgmedia.NeedsResize(w, h, maxEdge) {
return w, h return w, h
@ -114,6 +235,8 @@ func mimeForFormat(format string) string {
return "image/png" return "image/png"
case "gif": case "gif":
return "image/gif" return "image/gif"
case "webp":
return "image/webp"
default: default:
return "image/jpeg" return "image/jpeg"
} }

View File

@ -1,8 +1,10 @@
package media package media
import ( import (
"bytes"
"image" "image"
"image/color" "image/color"
"image/jpeg"
"testing" "testing"
pkgmedia "spark-server/internal/pkg/media" pkgmedia "spark-server/internal/pkg/media"
@ -27,6 +29,9 @@ func TestCompressImageDownscales(t *testing.T) {
if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultImageMaxEdge { if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultImageMaxEdge {
t.Fatalf("edge %dx%d still over 1080", res.Width, res.Height) t.Fatalf("edge %dx%d still over 1080", res.Width, res.Height)
} }
if int64(len(res.Data)) > pkgmedia.DefaultImageMaxBytes {
t.Fatalf("size %d > %d", len(res.Data), pkgmedia.DefaultImageMaxBytes)
}
if res.MIME != "image/jpeg" { if res.MIME != "image/jpeg" {
t.Fatalf("mime %s", res.MIME) t.Fatalf("mime %s", res.MIME)
} }
@ -45,6 +50,55 @@ func TestCompressImageDownscales(t *testing.T) {
} }
} }
func TestCompressImageUnder150KiB(t *testing.T) {
src := mustOversizedJPEG(t, int(pkgmedia.DefaultImageMaxBytes)+1)
res, err := CompressImage(src, DefaultOptions())
if err != nil {
t.Fatal(err)
}
if int64(len(res.Data)) > pkgmedia.DefaultImageMaxBytes {
t.Fatalf("after compress %d > %d", len(res.Data), pkgmedia.DefaultImageMaxBytes)
}
out, mime, err := EnsureUnderLimit(src, "image/jpeg", nil)
if err != nil {
t.Fatal(err)
}
if int64(len(out)) > pkgmedia.DefaultImageMaxBytes {
t.Fatalf("EnsureUnderLimit %d", len(out))
}
if mime != "image/jpeg" {
t.Fatalf("mime %s", mime)
}
}
// mustOversizedJPEG grows a noisy JPEG until len >= minBytes (JPEG of smooth
// gradients can stay tiny; use per-pixel noise and bump resolution as needed).
func mustOversizedJPEG(t *testing.T, minBytes int) []byte {
t.Helper()
for side := 800; side <= 4800; side += 400 {
img := image.NewRGBA(image.Rect(0, 0, side, side*3/4))
w, h := img.Bounds().Dx(), img.Bounds().Dy()
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
n := uint32(x*374761393 + y*668265263 + side*97)
n ^= n << 13
n ^= n >> 17
n ^= n << 5
img.Set(x, y, color.RGBA{R: uint8(n), G: uint8(n >> 8), B: uint8(n >> 16), A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 100}); err != nil {
t.Fatal(err)
}
if buf.Len() >= minBytes {
return buf.Bytes()
}
}
t.Fatalf("could not synthesize JPEG >= %d bytes", minBytes)
return nil
}
func TestCompressAvatar(t *testing.T) { func TestCompressAvatar(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 500, 500)) img := image.NewRGBA(image.Rect(0, 0, 500, 500))
src, err := EncodePNG(img) src, err := EncodePNG(img)
@ -58,6 +112,9 @@ func TestCompressAvatar(t *testing.T) {
if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultAvatarMaxEdge { if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultAvatarMaxEdge {
t.Fatalf("avatar %dx%d", res.Width, res.Height) t.Fatalf("avatar %dx%d", res.Width, res.Height)
} }
if int64(len(res.Data)) > pkgmedia.DefaultAvatarMaxBytes {
t.Fatalf("avatar size %d", len(res.Data))
}
} }
func TestPrepareBotImage(t *testing.T) { func TestPrepareBotImage(t *testing.T) {
@ -70,4 +127,16 @@ func TestPrepareBotImage(t *testing.T) {
if err != nil || a == nil || a.Size <= 0 { if err != nil || a == nil || a.Size <= 0 {
t.Fatalf("%+v %v", a, err) t.Fatalf("%+v %v", a, err)
} }
if a.Size > pkgmedia.DefaultImageMaxBytes {
t.Fatalf("bot image %d > 150KiB", a.Size)
}
}
func TestIsImageMIME(t *testing.T) {
if !IsImageMIME("image/jpeg") || !IsImageMIME("image/png; charset=binary") {
t.Fatal("want image")
}
if IsImageMIME("audio/mpeg") || IsImageMIME("video/mp4") {
t.Fatal("reject non-image")
}
} }

View File

@ -15,6 +15,7 @@ import (
"github.com/aws/smithy-go" "github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http" smithyhttp "github.com/aws/smithy-go/transport/http"
infmedia "spark-server/internal/infrastructure/media"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
@ -25,6 +26,7 @@ const (
) )
// PutLarge writes an object up to maxBytes (capped at 100 MiB). // PutLarge writes an object up to maxBytes (capped at 100 MiB).
// Image payloads are auto-compressed to ≤150 KiB (see media.EnsureUnderLimit).
func (s *Store) PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error { func (s *Store) PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error {
if !s.Ready() { if !s.Ready() {
return errs.ErrAvailable return errs.ErrAvailable
@ -32,6 +34,10 @@ func (s *Store) PutLarge(ctx context.Context, key string, body []byte, contentTy
if maxBytes <= 0 || maxBytes > maxPutLarge { if maxBytes <= 0 || maxBytes > maxPutLarge {
maxBytes = maxPutLarge maxBytes = maxPutLarge
} }
body, contentType, err := compressImageForStore(body, contentType)
if err != nil {
return errs.ErrInvalidArgument.WithMsg("图片压缩失败或仍超过 150KB")
}
if len(body) == 0 || len(body) > maxBytes { if len(body) == 0 || len(body) > maxBytes {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
@ -109,10 +115,15 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
return data, nil return data, nil
} }
// Put writes a small object (≤4 MiB). Image payloads are auto-compressed to ≤150 KiB.
func (s *Store) Put(ctx context.Context, key string, body []byte, contentType string) error { func (s *Store) Put(ctx context.Context, key string, body []byte, contentType string) error {
if !s.Ready() { if !s.Ready() {
return errs.ErrAvailable return errs.ErrAvailable
} }
body, contentType, err := compressImageForStore(body, contentType)
if err != nil {
return errs.ErrInvalidArgument.WithMsg("图片压缩失败或仍超过 150KB")
}
if len(body) == 0 || len(body) > maxPutBytes { if len(body) == 0 || len(body) > maxPutBytes {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
@ -131,6 +142,14 @@ func (s *Store) Put(ctx context.Context, key string, body []byte, contentType st
return nil return nil
} }
// compressImageForStore enforces the server-wide ≤150 KiB image cap on store writes.
func compressImageForStore(body []byte, contentType string) ([]byte, string, error) {
if !infmedia.IsImageMIME(contentType) {
return body, contentType, nil
}
return infmedia.EnsureUnderLimit(body, contentType, infmedia.DefaultOptions())
}
func (s *Store) Copy(ctx context.Context, src, dst string) error { func (s *Store) Copy(ctx context.Context, src, dst string) error {
if !s.Ready() { if !s.Ready() {
return errs.ErrAvailable return errs.ErrAvailable

View File

@ -0,0 +1,66 @@
package entity
const (
KindSign int16 = 1
KindMakeup int16 = 2
StatusUnsigned = "0"
StatusSigned = "1"
StatusMakeupDone = "2"
StatusCanMakeup = "3"
StatusPassed = "4"
ListDefault = 20
ListMax = 50
// CycleRewards is a 7-day reward ladder (energy points).
DefaultNeedDays = 7
)
// CycleRewards returns energy for streak day n (1-based within a 7-day cycle).
func CycleRewards(streakDay int) int {
ladder := []int{10, 15, 20, 25, 30, 35, 50}
if streakDay <= 0 {
return ladder[0]
}
return ladder[(streakDay-1)%len(ladder)]
}
func ClampLimit(limit int) int {
if limit <= 0 {
return ListDefault
}
if limit > ListMax {
return ListMax
}
return limit
}
// Wallet is the per-user energy balance.
type Wallet struct {
UserID int64
Energy int64
UpdatedMS int64
}
// SignRecord is one calendar day check-in.
type SignRecord struct {
ID int64
UserID int64
DayKey int
Kind int16
Reward int
CreatedMS int64
}
// EnergyLog is one balance change.
type EnergyLog struct {
ID int64
UserID int64
Delta int64
BalanceAfter int64
Reason string
DayKey int
Source string
CreatedMS int64
}

View File

@ -0,0 +1,24 @@
package logic
import (
"context"
"spark-server/internal/pkg/errs"
)
// BalanceView is the energy query payload.
type BalanceView struct {
Energy int64 `json:"energy"`
}
// Balance returns the user's energy wallet (creating an empty one if needed).
func (s *Service) Balance(ctx context.Context, userID int64) (*BalanceView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
w, err := s.repo.EnsureWallet(ctx, userID)
if err != nil {
return nil, err
}
return &BalanceView{Energy: w.Energy}, nil
}

View File

@ -0,0 +1,146 @@
package logic
import (
"context"
"time"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
)
// DayStatus is one calendar cell for the sign-in page.
type DayStatus struct {
Day int64 `json:"day"`
SignInStatus string `json:"sign_in_status"`
RewardPoint int `json:"reward_point"`
IsDouble bool `json:"is_double"`
IsSurprise bool `json:"is_surprise"`
VideoDouble bool `json:"video_double"`
}
// CalendarView matches the legacy SignInBean shape used by the Android page.
type CalendarView struct {
ContinueDays int `json:"continue_days"`
StartDate int64 `json:"start_date"`
NeedDays int `json:"need_days"`
RewardPoint int `json:"reward_point"`
TodaySignStatus string `json:"today_sign_status"`
TotalRewardPoint int64 `json:"total_reward_point"`
DrawPrizeStatus int `json:"draw_prize_status"`
Energy int64 `json:"energy"`
StatusList []DayStatus `json:"status_list"`
}
// Calendar returns month status for year/month (1–12). year=0 uses current year.
func (s *Service) Calendar(ctx context.Context, userID int64, year, month int) (*CalendarView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
now := s.now()
if year <= 0 {
year = now.Year()
}
if month < 1 || month > 12 {
month = int(now.Month())
}
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, s.loc)
last := first.AddDate(0, 1, -1)
fromDay, toDay := dayKeyOf(first), dayKeyOf(last)
today := dayKeyOf(now)
records, err := s.repo.ListSignsInRange(ctx, userID, fromDay, toDay)
if err != nil {
return nil, err
}
byDay := map[int]*entity.SignRecord{}
for _, r := range records {
byDay[r.DayKey] = r
}
streak, start := s.computeStreak(ctx, userID, today)
need := entity.DefaultNeedDays - (streak % entity.DefaultNeedDays)
if need == entity.DefaultNeedDays && streak > 0 && streak%entity.DefaultNeedDays == 0 {
need = entity.DefaultNeedDays
}
if streak == 0 {
need = entity.DefaultNeedDays
}
nextReward := entity.CycleRewards(streak + 1)
wallet, err := s.repo.EnsureWallet(ctx, userID)
if err != nil {
return nil, err
}
list := make([]DayStatus, 0, last.Day())
todayStatus := entity.StatusUnsigned
for d := 1; d <= last.Day(); d++ {
day := year*10000 + month*100 + d
st := DayStatus{
Day: int64(day),
RewardPoint: entity.CycleRewards(1),
}
if rec, ok := byDay[day]; ok {
if rec.Kind == entity.KindMakeup {
st.SignInStatus = entity.StatusMakeupDone
} else {
st.SignInStatus = entity.StatusSigned
}
st.RewardPoint = rec.Reward
} else if day == today {
st.SignInStatus = entity.StatusUnsigned
st.RewardPoint = nextReward
} else if day < today {
st.SignInStatus = entity.StatusCanMakeup
st.RewardPoint = entity.CycleRewards(1)
} else {
// Future days: not yet signed (client treats as empty slot).
st.SignInStatus = entity.StatusUnsigned
st.RewardPoint = entity.CycleRewards(1)
}
if day == today {
todayStatus = st.SignInStatus
if todayStatus == entity.StatusUnsigned {
st.RewardPoint = nextReward
}
}
list = append(list, st)
}
return &CalendarView{
ContinueDays: streak,
StartDate: int64(start),
NeedDays: need,
RewardPoint: nextReward,
TodaySignStatus: todayStatus,
TotalRewardPoint: wallet.Energy,
DrawPrizeStatus: 0,
Energy: wallet.Energy,
StatusList: list,
}, nil
}
func (s *Service) computeStreak(ctx context.Context, userID int64, today int) (streak int, start int) {
recent, err := s.repo.ListRecentSigns(ctx, userID, 60)
if err != nil || len(recent) == 0 {
return 0, 0
}
have := map[int]bool{}
for _, r := range recent {
have[r.DayKey] = true
}
cursor := today
if !have[today] {
cursor = addDays(today, -1)
}
if !have[cursor] {
return 0, 0
}
start = cursor
for have[cursor] {
streak++
start = cursor
cursor = addDays(cursor, -1)
}
return streak, start
}

View File

@ -0,0 +1,72 @@
package logic
import (
"context"
"errors"
"time"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
)
// CheckInResult is returned after a successful daily sign-in.
type CheckInResult struct {
Day int `json:"day"`
RewardPoint int `json:"reward_point"`
ContinueDays int `json:"continue_days"`
NeedDays int `json:"need_days"`
Energy int64 `json:"energy"`
Kind int16 `json:"kind"`
}
// CheckIn signs the current local day and credits energy.
func (s *Service) CheckIn(ctx context.Context, userID int64) (*CheckInResult, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
today := dayKeyOf(s.now())
return s.signDay(ctx, userID, today, entity.KindSign, "签到奖励")
}
func (s *Service) signDay(ctx context.Context, userID int64, day int, kind int16, source string) (*CheckInResult, error) {
if _, ok := parseDayKey(day); !ok {
return nil, errs.ErrInvalidArgument
}
if _, err := s.repo.GetSign(ctx, userID, day); err == nil {
return nil, errs.ErrDuplicate
} else if err != nil && !errors.Is(err, errs.ErrNotFound) {
return nil, err
}
streak, _ := s.computeStreak(ctx, userID, dayKeyOf(s.now()))
rewardDay := streak + 1
if kind == entity.KindMakeup {
rewardDay = 1
}
reward := entity.CycleRewards(rewardDay)
reason := "sign"
if kind == entity.KindMakeup {
reason = "makeup"
}
rec := &entity.SignRecord{
UserID: userID, DayKey: day, Kind: kind, Reward: reward,
CreatedMS: time.Now().UnixMilli(),
}
if err := s.repo.CreateSign(ctx, rec); err != nil {
return nil, err
}
wallet, _, err := s.repo.AddEnergy(ctx, userID, int64(reward), reason, day, source)
if err != nil {
return nil, err
}
newStreak, _ := s.computeStreak(ctx, userID, dayKeyOf(s.now()))
need := entity.DefaultNeedDays - (newStreak % entity.DefaultNeedDays)
if need <= 0 {
need = entity.DefaultNeedDays
}
return &CheckInResult{
Day: day, RewardPoint: reward, ContinueDays: newStreak,
NeedDays: need, Energy: wallet.Energy, Kind: kind,
}, nil
}

View File

@ -0,0 +1,56 @@
package logic
import (
"context"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
)
// LogItem is one energy ledger row for the client bill page.
type LogItem struct {
Source string `json:"source"`
Date int64 `json:"date"`
Value int64 `json:"value"`
ID int64 `json:"id"`
}
// LogsView is a cursor page of energy details.
type LogsView struct {
Items []LogItem `json:"reward_details"`
NextCursor int64 `json:"next_cursor"`
IsEnd bool `json:"is_end"`
Energy int64 `json:"energy"`
}
// Logs lists energy changes newest-first. cursor is the last seen log id (0 = start).
func (s *Service) Logs(ctx context.Context, userID, cursor int64, limit int) (*LogsView, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
limit = entity.ClampLimit(limit)
rows, err := s.repo.ListEnergyLogs(ctx, userID, cursor, limit+1)
if err != nil {
return nil, err
}
end := true
if len(rows) > limit {
end = false
rows = rows[:limit]
}
items := make([]LogItem, 0, len(rows))
var next int64
for _, r := range rows {
src := r.Source
if src == "" {
src = r.Reason
}
items = append(items, LogItem{Source: src, Date: r.CreatedMS / 1000, Value: r.Delta, ID: r.ID})
next = r.ID
}
bal, err := s.repo.EnsureWallet(ctx, userID)
if err != nil {
return nil, err
}
return &LogsView{Items: items, NextCursor: next, IsEnd: end, Energy: bal.Energy}, nil
}

View File

@ -0,0 +1,80 @@
package logic
import (
"context"
"errors"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
// MakeupQuery is eligibility for a past day.
type MakeupQuery struct {
CanReSign bool `json:"can_resign"`
NeedWatchVideoCount int `json:"need_watch_video_count"`
Date int `json:"date"`
}
// MakeupInput performs makeup for a past day.
type MakeupInput struct {
Date int `json:"date" validate:"required,gt=19700101" label:"field.date" errmsg:"required:validate.required;gt:validate.required"`
}
// MakeupCheck returns whether the user may makeup the given YYYYMMDD day.
func (s *Service) MakeupCheck(ctx context.Context, userID int64, date int) (*MakeupQuery, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
out := &MakeupQuery{Date: date, NeedWatchVideoCount: 0}
ok, err := s.canMakeup(ctx, userID, date)
if err != nil {
return nil, err
}
out.CanReSign = ok
return out, nil
}
// Makeup credits a missed past day (no rewarded-video gate in v1).
func (s *Service) Makeup(ctx context.Context, userID int64, in *MakeupInput) (*CheckInResult, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
ok, err := s.canMakeup(ctx, userID, in.Date)
if err != nil {
return nil, err
}
if !ok {
return nil, errs.ErrForbidden
}
return s.signDay(ctx, userID, in.Date, entity.KindMakeup, "补签奖励")
}
func (s *Service) canMakeup(ctx context.Context, userID int64, date int) (bool, error) {
if _, ok := parseDayKey(date); !ok {
return false, errs.ErrInvalidArgument
}
today := dayKeyOf(s.now())
if date >= today {
return false, nil
}
// Only allow makeup within the current calendar month.
ty, tm := today/10000, (today/100)%100
dy, dm := date/10000, (date/100)%100
if ty != dy || tm != dm {
return false, nil
}
if _, err := s.repo.GetSign(ctx, userID, date); err == nil {
return false, nil
} else if err != nil && !errors.Is(err, errs.ErrNotFound) {
return false, err
}
return true, nil
}

View File

@ -0,0 +1,88 @@
package logic
import (
"context"
"time"
"spark-server/internal/module/energy/entity"
)
// Repository persists wallets, signs, and energy logs. Model implements it.
type Repository interface {
GetWallet(ctx context.Context, userID int64) (*entity.Wallet, error)
EnsureWallet(ctx context.Context, userID int64) (*entity.Wallet, error)
AddEnergy(ctx context.Context, userID, delta int64, reason string, dayKey int, source string) (*entity.Wallet, *entity.EnergyLog, error)
ListSignsInRange(ctx context.Context, userID int64, fromDay, toDay int) ([]*entity.SignRecord, error)
GetSign(ctx context.Context, userID int64, dayKey int) (*entity.SignRecord, error)
CreateSign(ctx context.Context, rec *entity.SignRecord) error
ListEnergyLogs(ctx context.Context, userID, cursor int64, limit int) ([]*entity.EnergyLog, error)
ListRecentSigns(ctx context.Context, userID int64, limit int) ([]*entity.SignRecord, error)
}
// Clock abstracts "now" for tests.
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
// Service is the sign + energy use-case layer.
type Service struct {
repo Repository
clock Clock
loc *time.Location
}
func NewService(repo Repository) *Service {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*3600)
}
return &Service{repo: repo, clock: realClock{}, loc: loc}
}
func (s *Service) withClock(c Clock) *Service {
if c != nil {
s.clock = c
}
return s
}
func (s *Service) ready() bool {
return s != nil && s.repo != nil
}
func (s *Service) now() time.Time {
if s.clock == nil {
return time.Now().In(s.loc)
}
return s.clock.Now().In(s.loc)
}
func dayKeyOf(t time.Time) int {
y, m, d := t.Date()
return y*10000 + int(m)*100 + d
}
func parseDayKey(day int) (time.Time, bool) {
if day < 19700101 || day > 21001231 {
return time.Time{}, false
}
y, rem := day/10000, day%10000
mo, d := rem/100, rem%100
if mo < 1 || mo > 12 || d < 1 || d > 31 {
return time.Time{}, false
}
return time.Date(y, time.Month(mo), d, 0, 0, 0, 0, time.UTC), true
}
func addDays(day int, n int) int {
t, ok := parseDayKey(day)
if !ok {
return day
}
t = t.AddDate(0, 0, n)
return dayKeyOf(t)
}

View File

@ -0,0 +1,113 @@
package logic
import (
"context"
"testing"
"time"
"spark-server/internal/module/energy/entity"
energymodel "spark-server/internal/module/energy/model"
"spark-server/internal/pkg/errs"
)
type fixedClock struct{ t time.Time }
func (c fixedClock) Now() time.Time { return c.t }
func testService(t *testing.T, when time.Time) *Service {
t.Helper()
s := NewService(energymodel.NewMemoryRepo())
s.withClock(fixedClock{t: when})
return s
}
func TestCheckInAndCalendar(t *testing.T) {
when := time.Date(2026, 10, 26, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
s := testService(t, when)
ctx := context.Background()
const uid int64 = 1001
out, err := s.CheckIn(ctx, uid)
if err != nil {
t.Fatal(err)
}
if out.RewardPoint != 10 || out.Energy != 10 || out.Day != 20261026 {
t.Fatalf("unexpected checkin: %+v", out)
}
if _, err := s.CheckIn(ctx, uid); err != errs.ErrDuplicate {
t.Fatalf("want duplicate, got %v", err)
}
cal, err := s.Calendar(ctx, uid, 2026, 10)
if err != nil {
t.Fatal(err)
}
if cal.Energy != 10 || cal.ContinueDays != 1 {
t.Fatalf("calendar %+v", cal)
}
var today *DayStatus
for i := range cal.StatusList {
if cal.StatusList[i].Day == 20261026 {
today = &cal.StatusList[i]
break
}
}
if today == nil || today.SignInStatus != entity.StatusSigned {
t.Fatalf("today status %+v", today)
}
var miss *DayStatus
for i := range cal.StatusList {
if cal.StatusList[i].Day == 20261025 {
miss = &cal.StatusList[i]
break
}
}
if miss == nil || miss.SignInStatus != entity.StatusCanMakeup {
t.Fatalf("miss status %+v", miss)
}
}
func TestMakeupAndEnergyLogs(t *testing.T) {
when := time.Date(2026, 10, 26, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
s := testService(t, when)
ctx := context.Background()
const uid int64 = 2002
q, err := s.MakeupCheck(ctx, uid, 20261025)
if err != nil || !q.CanReSign {
t.Fatalf("makeup check %+v %v", q, err)
}
res, err := s.Makeup(ctx, uid, &MakeupInput{Date: 20261025})
if err != nil {
t.Fatal(err)
}
if res.Kind != entity.KindMakeup || res.Energy != 10 {
t.Fatalf("makeup %+v", res)
}
if _, err := s.Makeup(ctx, uid, &MakeupInput{Date: 20261025}); err != errs.ErrForbidden && err != errs.ErrDuplicate {
t.Fatalf("second makeup: %v", err)
}
bal, err := s.Balance(ctx, uid)
if err != nil || bal.Energy != 10 {
t.Fatalf("balance %+v %v", bal, err)
}
logs, err := s.Logs(ctx, uid, 0, 20)
if err != nil || len(logs.Items) != 1 || logs.Items[0].Value != 10 {
t.Fatalf("logs %+v %v", logs, err)
}
}
func TestMakeupRejectsTodayAndOtherMonth(t *testing.T) {
when := time.Date(2026, 10, 26, 12, 0, 0, 0, time.FixedZone("CST", 8*3600))
s := testService(t, when)
ctx := context.Background()
q, err := s.MakeupCheck(ctx, 1, 20261026)
if err != nil || q.CanReSign {
t.Fatalf("today should not makeup: %+v %v", q, err)
}
q, err = s.MakeupCheck(ctx, 1, 20260901)
if err != nil || q.CanReSign {
t.Fatalf("other month: %+v %v", q, err)
}
}

View File

@ -0,0 +1,173 @@
package model
import (
"context"
"sort"
"sync"
"time"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
)
// MemoryRepo is an in-process store for logic tests.
type MemoryRepo struct {
mu sync.Mutex
wallet map[int64]*entity.Wallet
signs map[int64]map[int]*entity.SignRecord
logs []*entity.EnergyLog
nextID int64
}
func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{
wallet: make(map[int64]*entity.Wallet),
signs: make(map[int64]map[int]*entity.SignRecord),
nextID: 1,
}
}
func (m *MemoryRepo) GetWallet(_ context.Context, userID int64) (*entity.Wallet, error) {
m.mu.Lock()
defer m.mu.Unlock()
w, ok := m.wallet[userID]
if !ok {
return nil, errs.ErrNotFound
}
cp := *w
return &cp, nil
}
func (m *MemoryRepo) EnsureWallet(_ context.Context, userID int64) (*entity.Wallet, error) {
m.mu.Lock()
defer m.mu.Unlock()
if w, ok := m.wallet[userID]; ok {
cp := *w
return &cp, nil
}
w := &entity.Wallet{UserID: userID, Energy: 0, UpdatedMS: time.Now().UnixMilli()}
m.wallet[userID] = w
cp := *w
return &cp, nil
}
func (m *MemoryRepo) AddEnergy(_ context.Context, userID, delta int64, reason string, dayKey int, source string) (*entity.Wallet, *entity.EnergyLog, error) {
m.mu.Lock()
defer m.mu.Unlock()
w, ok := m.wallet[userID]
if !ok {
w = &entity.Wallet{UserID: userID}
m.wallet[userID] = w
}
w.Energy += delta
if w.Energy < 0 {
return nil, nil, errs.ErrInvalidArgument
}
w.UpdatedMS = time.Now().UnixMilli()
id := m.nextID
m.nextID++
log := &entity.EnergyLog{
ID: id, UserID: userID, Delta: delta, BalanceAfter: w.Energy,
Reason: reason, DayKey: dayKey, Source: source, CreatedMS: w.UpdatedMS,
}
m.logs = append(m.logs, log)
wc, lc := *w, *log
return &wc, &lc, nil
}
func (m *MemoryRepo) ListSignsInRange(_ context.Context, userID int64, fromDay, toDay int) ([]*entity.SignRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
byDay := m.signs[userID]
out := make([]*entity.SignRecord, 0)
for day, rec := range byDay {
if day >= fromDay && day <= toDay {
cp := *rec
out = append(out, &cp)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].DayKey < out[j].DayKey })
return out, nil
}
func (m *MemoryRepo) GetSign(_ context.Context, userID int64, dayKey int) (*entity.SignRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
byDay := m.signs[userID]
if byDay == nil {
return nil, errs.ErrNotFound
}
rec, ok := byDay[dayKey]
if !ok {
return nil, errs.ErrNotFound
}
cp := *rec
return &cp, nil
}
func (m *MemoryRepo) CreateSign(_ context.Context, rec *entity.SignRecord) error {
if rec == nil || rec.UserID <= 0 || rec.DayKey <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
byDay := m.signs[rec.UserID]
if byDay == nil {
byDay = make(map[int]*entity.SignRecord)
m.signs[rec.UserID] = byDay
}
if _, ok := byDay[rec.DayKey]; ok {
return errs.ErrDuplicate
}
if rec.ID == 0 {
rec.ID = m.nextID
m.nextID++
}
if rec.CreatedMS == 0 {
rec.CreatedMS = time.Now().UnixMilli()
}
cp := *rec
byDay[rec.DayKey] = &cp
return nil
}
func (m *MemoryRepo) ListEnergyLogs(_ context.Context, userID, cursor int64, limit int) ([]*entity.EnergyLog, error) {
m.mu.Lock()
defer m.mu.Unlock()
limit = entity.ClampLimit(limit)
out := make([]*entity.EnergyLog, 0)
for i := len(m.logs) - 1; i >= 0; i-- {
row := m.logs[i]
if row.UserID != userID {
continue
}
if cursor > 0 && row.ID >= cursor {
continue
}
cp := *row
out = append(out, &cp)
if len(out) >= limit {
break
}
}
return out, nil
}
func (m *MemoryRepo) ListRecentSigns(_ context.Context, userID int64, limit int) ([]*entity.SignRecord, error) {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 40
}
byDay := m.signs[userID]
out := make([]*entity.SignRecord, 0, len(byDay))
for _, rec := range byDay {
cp := *rec
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].DayKey > out[j].DayKey })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}

View File

@ -0,0 +1,196 @@
package model
import (
"context"
"errors"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"spark-server/internal/module/energy/entity"
"spark-server/internal/pkg/errs"
)
// Repo is MySQL persistence for wallets, sign records, and energy logs.
type Repo struct {
gdb *gorm.DB
}
func NewRepo(gdb *gorm.DB) *Repo { return &Repo{gdb: gdb} }
func (m *Repo) session(ctx context.Context) (*gorm.DB, error) {
if m == nil || m.gdb == nil {
return nil, errs.ErrAvailable
}
return m.gdb.WithContext(ctx), nil
}
func mapDBErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrNotFound
}
var me *mysqldriver.MySQLError
if errors.As(err, &me) && me.Number == 1062 {
return errs.ErrDuplicate
}
return errs.ErrInner.WithError("energy-mysql", err)
}
func (m *Repo) GetWallet(ctx context.Context, userID int64) (*entity.Wallet, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row WalletRow
if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.Entity(), nil
}
func (m *Repo) EnsureWallet(ctx context.Context, userID int64) (*entity.Wallet, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
now := time.Now()
row := WalletRow{UserID: userID, Energy: 0, UpdatedAt: now}
err = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
if err != nil {
return nil, mapDBErr(err)
}
return m.GetWallet(ctx, userID)
}
func (m *Repo) AddEnergy(ctx context.Context, userID, delta int64, reason string, dayKey int, source string) (*entity.Wallet, *entity.EnergyLog, error) {
db, err := m.session(ctx)
if err != nil {
return nil, nil, err
}
var wallet *entity.Wallet
var logRow *entity.EnergyLog
err = db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
var w WalletRow
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).Take(&w).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
w = WalletRow{UserID: userID, Energy: 0, UpdatedAt: now}
if err := tx.Create(&w).Error; err != nil {
return err
}
}
w.Energy += delta
if w.Energy < 0 {
return errs.ErrInvalidArgument
}
w.UpdatedAt = now
if err := tx.Save(&w).Error; err != nil {
return err
}
lr := EnergyLogRow{
UserID: userID, Delta: delta, BalanceAfter: w.Energy, Reason: reason,
DayKey: dayKey, Source: source, CreatedAt: now,
}
if err := tx.Create(&lr).Error; err != nil {
return err
}
wallet = w.Entity()
logRow = lr.Entity()
return nil
})
if err != nil {
return nil, nil, mapDBErr(err)
}
return wallet, logRow, nil
}
func (m *Repo) ListSignsInRange(ctx context.Context, userID int64, fromDay, toDay int) ([]*entity.SignRecord, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var rows []SignRow
if err := db.Where("user_id = ? AND day_key >= ? AND day_key <= ?", userID, fromDay, toDay).
Order("day_key ASC").Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
out := make([]*entity.SignRecord, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out, nil
}
func (m *Repo) GetSign(ctx context.Context, userID int64, dayKey int) (*entity.SignRecord, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
var row SignRow
if err := db.Where("user_id = ? AND day_key = ?", userID, dayKey).Take(&row).Error; err != nil {
return nil, mapDBErr(err)
}
return row.Entity(), nil
}
func (m *Repo) CreateSign(ctx context.Context, rec *entity.SignRecord) error {
if rec == nil || rec.UserID <= 0 || rec.DayKey <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := signFromEntity(rec)
if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now()
}
return mapDBErr(db.Create(row).Error)
}
func (m *Repo) ListEnergyLogs(ctx context.Context, userID, cursor int64, limit int) ([]*entity.EnergyLog, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
limit = entity.ClampLimit(limit)
q := db.Where("user_id = ?", userID)
if cursor > 0 {
q = q.Where("id < ?", cursor)
}
var rows []EnergyLogRow
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
out := make([]*entity.EnergyLog, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out, nil
}
func (m *Repo) ListRecentSigns(ctx context.Context, userID int64, limit int) ([]*entity.SignRecord, error) {
db, err := m.session(ctx)
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 40
}
var rows []SignRow
if err := db.Where("user_id = ?", userID).Order("day_key DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
out := make([]*entity.SignRecord, 0, len(rows))
for i := range rows {
out = append(out, rows[i].Entity())
}
return out, nil
}

View File

@ -0,0 +1,67 @@
package model
import (
"time"
"spark-server/internal/module/energy/entity"
)
// WalletRow maps t_user_wallets.
type WalletRow struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
Energy int64 `gorm:"column:energy"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (WalletRow) TableName() string { return "t_user_wallets" }
func (r WalletRow) Entity() *entity.Wallet {
return &entity.Wallet{UserID: r.UserID, Energy: r.Energy, UpdatedMS: r.UpdatedAt.UnixMilli()}
}
// SignRow maps t_sign_records.
type SignRow struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id"`
DayKey int `gorm:"column:day_key"`
Kind int16 `gorm:"column:kind"`
Reward int `gorm:"column:reward"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (SignRow) TableName() string { return "t_sign_records" }
func (r SignRow) Entity() *entity.SignRecord {
return &entity.SignRecord{
ID: r.ID, UserID: r.UserID, DayKey: r.DayKey, Kind: r.Kind,
Reward: r.Reward, CreatedMS: r.CreatedAt.UnixMilli(),
}
}
func signFromEntity(e *entity.SignRecord) *SignRow {
return &SignRow{
ID: e.ID, UserID: e.UserID, DayKey: e.DayKey, Kind: e.Kind, Reward: e.Reward,
CreatedAt: time.UnixMilli(e.CreatedMS),
}
}
// EnergyLogRow maps t_energy_logs.
type EnergyLogRow struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id"`
Delta int64 `gorm:"column:delta"`
BalanceAfter int64 `gorm:"column:balance_after"`
Reason string `gorm:"column:reason"`
DayKey int `gorm:"column:day_key"`
Source string `gorm:"column:source"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (EnergyLogRow) TableName() string { return "t_energy_logs" }
func (r EnergyLogRow) Entity() *entity.EnergyLog {
return &entity.EnergyLog{
ID: r.ID, UserID: r.UserID, Delta: r.Delta, BalanceAfter: r.BalanceAfter,
Reason: r.Reason, DayKey: r.DayKey, Source: r.Source, CreatedMS: r.CreatedAt.UnixMilli(),
}
}

View File

@ -4,14 +4,14 @@ import "testing"
func TestCheckImageMeta(t *testing.T) { func TestCheckImageMeta(t *testing.T) {
p := Policy{Enabled: true, Limits: DefaultLimits()} p := Policy{Enabled: true, Limits: DefaultLimits()}
if v := p.CheckImageMeta(1080, 720, 1<<20); v != nil { if v := p.CheckImageMeta(1080, 720, 100<<10); v != nil {
t.Fatalf("expected ok, got %+v", v) t.Fatalf("expected ok under 150KiB, got %+v", v)
} }
if v := p.CheckImageMeta(2000, 1000, 1<<20); v == nil || v.Reason != "edge_exceeded" { if v := p.CheckImageMeta(2000, 1000, 100<<10); v == nil || v.Reason != "edge_exceeded" {
t.Fatalf("want edge_exceeded, got %+v", v) t.Fatalf("want edge_exceeded, got %+v", v)
} }
if v := p.CheckImageMeta(800, 600, 20<<20); v == nil || v.Reason != "size_exceeded" { if v := p.CheckImageMeta(800, 600, 200<<10); v == nil || v.Reason != "size_exceeded" {
t.Fatalf("want size_exceeded, got %+v", v) t.Fatalf("want size_exceeded (>150KiB), got %+v", v)
} }
p.Enabled = false p.Enabled = false
if v := p.CheckImageMeta(4000, 4000, 50<<20); v != nil { if v := p.CheckImageMeta(4000, 4000, 50<<20); v != nil {

View File

@ -1,20 +1,23 @@
// Package media holds shared media size/resolution policy (no I/O). // Package media holds shared media size/resolution policy (no I/O).
// Client App compresses user uploads; bots must compress server-side via infrastructure/media. // Server enforces image ≤150 KiB (auto-compress on server write paths);
// client App should still compress before direct-to-object uploads.
package media package media
// Default limits aligned with App frontend (image ≤1080p, video ≤720p ≤100MB, avatar ≤200×200). // Default limits: images ≤150 KiB server-wide; resolution caps align with App.
const ( const (
DefaultImageMaxEdge = 1080 DefaultImageMaxEdge = 1080
DefaultVideoMaxEdge = 720 DefaultVideoMaxEdge = 720
DefaultAvatarMaxEdge = 200 DefaultAvatarMaxEdge = 200
DefaultImageMaxBytes = 10 << 20 // 10 MiB (square/moment image purpose) DefaultImageMaxBytes = 150 * 1024 // 150 KiB — all server-stored images
DefaultVideoMaxBytes = 100 << 20 // 100 MiB DefaultVideoMaxBytes = 100 << 20 // 100 MiB
DefaultAvatarMaxBytes = 2 << 20 // 2 MiB (upload purpose hard cap) DefaultAvatarMaxBytes = 150 * 1024 // 150 KiB (same hard cap as images)
DefaultJPEGQuality = 85 DefaultJPEGQuality = 85
DefaultVideoCRF = 23 DefaultVideoCRF = 23
// MinImageEdge is the floor when iteratively scaling to meet ImageMaxBytes.
MinImageEdge = 240
) )
// Limits is the resolution/size policy shared by user checks and bot compress. // Limits is the resolution/size policy shared by user checks and compressors.
type Limits struct { type Limits struct {
ImageMaxEdge int ImageMaxEdge int
VideoMaxEdge int VideoMaxEdge int
@ -24,7 +27,7 @@ type Limits struct {
AvatarMaxBytes int64 AvatarMaxBytes int64
} }
// DefaultLimits returns client-aligned defaults. // DefaultLimits returns server-aligned defaults (image/avatar ≤150 KiB).
func DefaultLimits() Limits { func DefaultLimits() Limits {
return Limits{ return Limits{
ImageMaxEdge: DefaultImageMaxEdge, ImageMaxEdge: DefaultImageMaxEdge,

View File

@ -1,4 +1,4 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Spark IM API title: Spark IM API
version: 0.1.0 version: 0.1.0
@ -479,6 +479,13 @@ tags:
`GET /v1/music/tracks/{id}`(单曲详情)、 `GET /v1/music/tracks/{id}`(单曲详情)、
`GET /v1/music/categories`、`GET /v1/music/settings`。 `GET /v1/music/categories`、`GET /v1/music/settings`。
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。 playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
- name: sign
description: |
签到 / 补签(需登录)。`GET /v1/sign/calendar` 月历;`POST /v1/sign` 当日签到;
`GET /v1/sign/makeup?date=` 补签资格;`POST /v1/sign/makeup` 执行补签。奖励写入能量钱包。
- name: energy
description: |
能量查询(需登录)。`GET /v1/energy` 余额;`GET /v1/energy/logs` 明细(兼容旧能量账单页)。
- name: admin-music - name: admin-music
description: | description: |
popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。 popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。
@ -2626,7 +2633,7 @@ paths:
post: post:
tags: [admin-music] tags: [admin-music]
summary: 上传封面(multipart file) summary: 上传封面(multipart file)
description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/cover/...`。 description: 字段名 `file`;jpg/png/webp/gif,≤5MB 接收后自动压到 ≤150KiB。写入 RustFS `perm/music/cover/...`。
operationId: adminUploadMusicCover operationId: adminUploadMusicCover
security: security:
- bearerAuth: [] - bearerAuth: []
@ -2657,7 +2664,7 @@ paths:
post: post:
tags: [admin-music] tags: [admin-music]
summary: 上传背景图(multipart file) summary: 上传背景图(multipart file)
description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/background/...`。可空(未上传时 `backgroundUrl` 为空串)。 description: 字段名 `file`;jpg/png/webp/gif,≤5MB 接收后自动压到 ≤150KiB。写入 RustFS `perm/music/background/...`。可空(未上传时 `backgroundUrl` 为空串)。
operationId: adminUploadMusicBackground operationId: adminUploadMusicBackground
security: security:
- bearerAuth: [] - bearerAuth: []
@ -3346,6 +3353,159 @@ paths:
properties: properties:
ok: { type: boolean } ok: { type: boolean }
/v1/sign/calendar:
get:
tags: [sign]
summary: 签到月历
description: |
返回指定年月每日签到状态,字段对齐旧客户端 `SignInBean`:
`continue_days` / `need_days` / `reward_point` / `status_list[]`。
`sign_in_status`:`0` 未签到/未来,`1` 已签到,`2` 已补签,`3` 可补签。
同时返回当前 `energy`(能量余额)。
operationId: getSignCalendar
parameters:
- in: query
name: year
schema: { type: integer, example: 2026 }
- in: query
name: month
schema: { type: integer, minimum: 1, maximum: 12, example: 10 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
example:
code: "0"
msg: ok
data:
continue_days: 1
need_days: 6
reward_point: 15
today_sign_status: "1"
energy: 10
total_reward_point: 10
status_list:
- day: 20261026
sign_in_status: "1"
reward_point: 10
/v1/sign:
post:
tags: [sign]
summary: 当日签到
description: |
对当前时区(Asia/Shanghai)今天签到并发放能量。重复签到返回 `40901`。
响应含 `reward_point`、`continue_days`、`energy`。
operationId: postSign
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
example:
code: "0"
msg: ok
data:
day: 20261026
reward_point: 10
continue_days: 1
need_days: 6
energy: 10
kind: 1
/v1/sign/makeup:
get:
tags: [sign]
summary: 补签资格查询
description: |
Query `date` 为 `YYYYMMDD`。仅允许补签本月、早于今天且尚未签到的日期。
`need_watch_video_count` 当前为 0(激励视频闸门未启用)。
operationId: getSignMakeup
parameters:
- in: query
name: date
required: true
schema: { type: integer, example: 20261025 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
example:
code: "0"
msg: ok
data:
can_resign: true
need_watch_video_count: 0
date: 20261025
post:
tags: [sign]
summary: 执行补签
description: |
Body `{"date":YYYYMMDD}`。成功发放补签能量;不可补签返回 `40301`,已签过返回 `40901`/`40301`。
operationId: postSignMakeup
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [date]
properties:
date: { type: integer, example: 20261025 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/energy:
get:
tags: [energy]
summary: 能量余额
description: 返回 `{energy}`。无钱包时惰性创建余额 0。
operationId: getEnergy
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
example:
code: "0"
msg: ok
data:
energy: 10
/v1/energy/logs:
get:
tags: [energy]
summary: 能量明细
description: |
分页能量流水,字段兼容旧账单:`reward_details[{source,date,value}]`,
`next_cursor` / `is_end`。`cursor` 为上一页最后一条流水 id(0 表示首页)。
operationId: getEnergyLogs
parameters:
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 50 }
responses:
"200":
description: 统一 envelope
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/conversations: /v1/conversations:
get: get:
tags: [conversations] tags: [conversations]

View File

@ -0,0 +1,122 @@
package energyhandler
import (
"strconv"
"github.com/gin-gonic/gin"
energylogic "spark-server/internal/module/energy/logic"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/transport/http/respond"
)
// Handler is the sign + energy HTTP controller.
type Handler struct {
svc *energylogic.Service
}
func New(svc *energylogic.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Calendar(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
year, _ := strconv.Atoi(c.Query("year"))
month, _ := strconv.Atoi(c.Query("month"))
out, err := h.svc.Calendar(c.Request.Context(), uid, year, month)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) CheckIn(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.svc.CheckIn(c.Request.Context(), uid)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) MakeupCheck(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
date, err := strconv.Atoi(c.Query("date"))
if err != nil || date <= 0 {
respond.JSONError(c, errs.ErrInvalidArgument)
return
}
out, err := h.svc.MakeupCheck(c.Request.Context(), uid, date)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Makeup(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
var in energylogic.MakeupInput
if err := respond.BindJSON(c, &in); err != nil {
respond.JSONError(c, err)
return
}
out, err := h.svc.Makeup(c.Request.Context(), uid, &in)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Balance(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
out, err := h.svc.Balance(c.Request.Context(), uid)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Logs(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
cursor, _ := strconv.ParseInt(c.Query("cursor"), 10, 64)
limit, _ := strconv.Atoi(c.Query("limit"))
out, err := h.svc.Logs(c.Request.Context(), uid, cursor, limit)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func currentUID(c *gin.Context) (int64, bool) {
rc := reqctx.From(c.Request.Context())
if rc == nil || rc.UserID <= 0 {
respond.JSONError(c, errs.ErrUnauthorized)
return 0, false
}
return rc.UserID, true
}

View File

@ -13,6 +13,7 @@ import (
authhandler "spark-server/internal/transport/http/handler/auth" authhandler "spark-server/internal/transport/http/handler/auth"
clienthandler "spark-server/internal/transport/http/handler/client" clienthandler "spark-server/internal/transport/http/handler/client"
convhandler "spark-server/internal/transport/http/handler/conversation" convhandler "spark-server/internal/transport/http/handler/conversation"
energyhandler "spark-server/internal/transport/http/handler/energy"
favhandler "spark-server/internal/transport/http/handler/favorite" favhandler "spark-server/internal/transport/http/handler/favorite"
filehandler "spark-server/internal/transport/http/handler/file" filehandler "spark-server/internal/transport/http/handler/file"
friendhandler "spark-server/internal/transport/http/handler/friend" friendhandler "spark-server/internal/transport/http/handler/friend"
@ -45,10 +46,26 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
registerMomentRoutes(v1, deps) registerMomentRoutes(v1, deps)
registerSquareRoutes(v1, deps) registerSquareRoutes(v1, deps)
registerMusicRoutes(v1, deps) registerMusicRoutes(v1, deps)
registerSignEnergyRoutes(v1, deps)
registerConversationRoutes(v1, deps) registerConversationRoutes(v1, deps)
registerMessageRoutes(v1, deps) registerMessageRoutes(v1, deps)
} }
func registerSignEnergyRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
if deps.Energy == nil {
return
}
h := energyhandler.New(deps.Energy)
sg := v1.Group("/sign")
sg.GET("/calendar", h.Calendar)
sg.POST("", h.CheckIn)
sg.GET("/makeup", h.MakeupCheck)
sg.POST("/makeup", h.Makeup)
eg := v1.Group("/energy")
eg.GET("", h.Balance)
eg.GET("/logs", h.Logs)
}
func registerHealth(r *gin.Engine, h Health) { func registerHealth(r *gin.Engine, h Health) {
r.GET("/healthz", func(c *gin.Context) { r.GET("/healthz", func(c *gin.Context) {
JSON(c, gin.H{"status": "ok"}) JSON(c, gin.H{"status": "ok"})

View File

@ -15,6 +15,7 @@ import (
adminlogic "spark-server/internal/module/admin/logic" adminlogic "spark-server/internal/module/admin/logic"
authlogic "spark-server/internal/module/auth/logic" authlogic "spark-server/internal/module/auth/logic"
clientlogic "spark-server/internal/module/client/logic" clientlogic "spark-server/internal/module/client/logic"
energylogic "spark-server/internal/module/energy/logic"
favlogic "spark-server/internal/module/favorite/logic" favlogic "spark-server/internal/module/favorite/logic"
friendlogic "spark-server/internal/module/friend/logic" friendlogic "spark-server/internal/module/friend/logic"
grouplogic "spark-server/internal/module/group/logic" grouplogic "spark-server/internal/module/group/logic"
@ -62,6 +63,7 @@ type EngineDeps struct {
Music *musiclogic.Service Music *musiclogic.Service
Client *clientlogic.Service Client *clientlogic.Service
Moderation *modlogic.Service Moderation *modlogic.Service
Energy *energylogic.Service
Health Health Health Health
AuthLimiter *ratelimit.Limiter AuthLimiter *ratelimit.Limiter
} }

View File

@ -0,0 +1,30 @@
-- 签到日历 + 能量钱包 / 流水(对齐客户端签到页与能量明细)。
CREATE TABLE IF NOT EXISTS t_user_wallets (
user_id BIGINT PRIMARY KEY COMMENT '用户 Snowflake uid',
energy BIGINT NOT NULL DEFAULT 0 COMMENT '能量余额',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间'
) COMMENT='用户能量钱包';
CREATE TABLE IF NOT EXISTS t_energy_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '流水自增 id',
user_id BIGINT NOT NULL COMMENT '用户 uid',
delta BIGINT NOT NULL COMMENT '变动值(可负)',
balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '变动后余额',
reason VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源:sign/makeup/adjust',
day_key INT NOT NULL DEFAULT 0 COMMENT '关联签到日 YYYYMMDD,无则 0',
source VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户端展示文案',
created_at DATETIME(3) NOT NULL COMMENT '创建时间',
KEY idx_energy_logs_user_id (user_id, id)
) COMMENT='能量流水';
CREATE TABLE IF NOT EXISTS t_sign_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '签到记录自增 id',
user_id BIGINT NOT NULL COMMENT '用户 uid',
day_key INT NOT NULL COMMENT '签到日 YYYYMMDD',
kind TINYINT NOT NULL DEFAULT 1 COMMENT '1=当日签到 2=补签',
reward INT NOT NULL DEFAULT 0 COMMENT '当日发放能量',
created_at DATETIME(3) NOT NULL COMMENT '创建时间',
UNIQUE KEY uk_sign_user_day (user_id, day_key),
KEY idx_sign_user_day (user_id, day_key)
) COMMENT='用户签到/补签记录';