feat: PopAdmin 拉取管理、签到能量 API 与图片 150KiB 硬上限
管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5bb9db4a3c
commit
753c276496
138
cmd/compress-images/main.go
Normal file
138
cmd/compress-images/main.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
41
deploy/docker/Dockerfile.remote
Normal file
41
deploy/docker/Dockerfile.remote
Normal 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"]
|
||||||
@ -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"
|
||||||
|
|||||||
144
deploy/docker/deploy_remote.sh
Normal file
144
deploy/docker/deploy_remote.sh
Normal 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
34
docs/daily/2026-10-26-change-list.md
Normal file
34
docs/daily/2026-10-26-change-list.md
Normal 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` 后能量接口才有持久化。
|
||||||
@ -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` 时拒凭证或发帖)。
|
|
||||||
|
|||||||
236
docs/popadmin-pull-requirements.md
Normal file
236
docs/popadmin-pull-requirements.md
Normal 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 待统一项。
|
||||||
|
`)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
nw, nh := scaleToMax(w, h, maxEdge)
|
if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= maxBytes {
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
return &ImageResult{Data: src, Width: w, Height: h, MIME: mimeForFormat(format)}, nil
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: opt.JPEGQuality}); err != nil {
|
|
||||||
return nil, fmt.Errorf("media: encode jpeg: %w", err)
|
|
||||||
}
|
|
||||||
out := buf.Bytes()
|
|
||||||
if int64(len(out)) > opt.Limits.ImageMaxBytes {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes.
|
nw, nh := scaleToMax(w, h, maxEdge)
|
||||||
|
cur := resample(img, b, nw, nh)
|
||||||
|
qualities := qualityLadder(opt.JPEGQuality)
|
||||||
|
best, bw, bh, err := encodeJPEGLadder(cur, qualities, maxBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(best)) <= maxBytes {
|
||||||
|
return &ImageResult{Data: best, Width: bw, Height: bh, 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 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
66
internal/module/energy/entity/types.go
Normal file
66
internal/module/energy/entity/types.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/module/energy/logic/balance.go
Normal file
24
internal/module/energy/logic/balance.go
Normal 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
|
||||||
|
}
|
||||||
146
internal/module/energy/logic/calendar.go
Normal file
146
internal/module/energy/logic/calendar.go
Normal 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
|
||||||
|
}
|
||||||
72
internal/module/energy/logic/checkin.go
Normal file
72
internal/module/energy/logic/checkin.go
Normal 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
|
||||||
|
}
|
||||||
56
internal/module/energy/logic/logs.go
Normal file
56
internal/module/energy/logic/logs.go
Normal 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
|
||||||
|
}
|
||||||
80
internal/module/energy/logic/makeup.go
Normal file
80
internal/module/energy/logic/makeup.go
Normal 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
|
||||||
|
}
|
||||||
88
internal/module/energy/logic/service.go
Normal file
88
internal/module/energy/logic/service.go
Normal 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)
|
||||||
|
}
|
||||||
113
internal/module/energy/logic/service_test.go
Normal file
113
internal/module/energy/logic/service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
internal/module/energy/model/memory.go
Normal file
173
internal/module/energy/model/memory.go
Normal 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
|
||||||
|
}
|
||||||
196
internal/module/energy/model/repo.go
Normal file
196
internal/module/energy/model/repo.go
Normal 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
|
||||||
|
}
|
||||||
67
internal/module/energy/model/rows.go
Normal file
67
internal/module/energy/model/rows.go
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
122
internal/transport/http/handler/energy/handler.go
Normal file
122
internal/transport/http/handler/energy/handler.go
Normal 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
|
||||||
|
}
|
||||||
@ -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"})
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
30
migrations/0034_sign_energy.sql
Normal file
30
migrations/0034_sign_energy.sql
Normal 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='用户签到/补签记录';
|
||||||
Loading…
Reference in New Issue
Block a user