From 753c276496ffc9658daa4d5816c1e28dc8009d89 Mon Sep 17 00:00:00 2001 From: "DESKTOP-L8OUL9F\\gfl0034" <1003614916@qq.com> Date: Mon, 2 Nov 2026 12:44:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PopAdmin=20=E6=8B=89=E5=8F=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E7=AD=BE=E5=88=B0=E8=83=BD=E9=87=8F=20API=20?= =?UTF-8?q?=E4=B8=8E=E5=9B=BE=E7=89=87=20150KiB=20=E7=A1=AC=E4=B8=8A?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。 Co-authored-by: Cursor --- cmd/compress-images/main.go | 138 ++ configs/config.yaml | 10 +- deploy/docker/Dockerfile.remote | 41 + deploy/docker/config.docker.yaml | 8 +- deploy/docker/deploy_remote.sh | 144 ++ deploy/popadmin/popadmin.js | 1550 +++++++---------- docs/daily/2026-10-26-change-list.md | 34 + docs/media.md | 60 +- docs/popadmin-pull-requirements.md | 236 +++ internal/bootstrap/app.go | 22 +- internal/bootstrap/config.go | 8 +- internal/infrastructure/media/bot.go | 2 +- internal/infrastructure/media/image.go | 153 +- internal/infrastructure/media/image_test.go | 69 + internal/infrastructure/objectstore/ops.go | 19 + internal/module/energy/entity/types.go | 66 + internal/module/energy/logic/balance.go | 24 + internal/module/energy/logic/calendar.go | 146 ++ internal/module/energy/logic/checkin.go | 72 + internal/module/energy/logic/logs.go | 56 + internal/module/energy/logic/makeup.go | 80 + internal/module/energy/logic/service.go | 88 + internal/module/energy/logic/service_test.go | 113 ++ internal/module/energy/model/memory.go | 173 ++ internal/module/energy/model/repo.go | 196 +++ internal/module/energy/model/rows.go | 67 + internal/pkg/media/check_test.go | 10 +- internal/pkg/media/limits.go | 17 +- internal/transport/http/docs/openapi.yaml | 166 +- .../transport/http/handler/energy/handler.go | 122 ++ internal/transport/http/router.go | 17 + internal/transport/http/server.go | 2 + migrations/0034_sign_energy.sql | 30 + 33 files changed, 2941 insertions(+), 998 deletions(-) create mode 100644 cmd/compress-images/main.go create mode 100644 deploy/docker/Dockerfile.remote create mode 100644 deploy/docker/deploy_remote.sh create mode 100644 docs/daily/2026-10-26-change-list.md create mode 100644 docs/popadmin-pull-requirements.md create mode 100644 internal/module/energy/entity/types.go create mode 100644 internal/module/energy/logic/balance.go create mode 100644 internal/module/energy/logic/calendar.go create mode 100644 internal/module/energy/logic/checkin.go create mode 100644 internal/module/energy/logic/logs.go create mode 100644 internal/module/energy/logic/makeup.go create mode 100644 internal/module/energy/logic/service.go create mode 100644 internal/module/energy/logic/service_test.go create mode 100644 internal/module/energy/model/memory.go create mode 100644 internal/module/energy/model/repo.go create mode 100644 internal/module/energy/model/rows.go create mode 100644 internal/transport/http/handler/energy/handler.go create mode 100644 migrations/0034_sign_energy.sql diff --git a/cmd/compress-images/main.go b/cmd/compress-images/main.go new file mode 100644 index 0000000..bc35ad8 --- /dev/null +++ b/cmd/compress-images/main.go @@ -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) +} diff --git a/configs/config.yaml b/configs/config.yaml index 22d646b..d98acd9 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -120,18 +120,18 @@ sms: unicom: "" telecom: "" -# 用户上传:检测/校验(默认仅日志;user_reject=true 则拒凭证/发帖)。主压缩仍在 App。 -# 机器人媒体:bot_compress=true 时入库前压到图≤1080 / 视频≤720(视频需 ffmpeg)。 +# 服务端图片硬上限 150 KiB:objectstore Put/PutLarge 自动压缩;用户直传声明 size 也按此校验。 +# user_reject=true 时超限拒凭证/发帖;false 仅打日志(App 仍应先压)。机器人 bot_compress 入库前再压。 media: enabled: true - user_reject: false + user_reject: true bot_compress: true image_max_edge: 1080 video_max_edge: 720 avatar_max_edge: 200 - image_max_bytes: 10485760 + image_max_bytes: 153600 video_max_bytes: 104857600 - avatar_max_bytes: 2097152 + avatar_max_bytes: 153600 jpeg_quality: 85 video_crf: 23 ffmpeg_path: "ffmpeg" diff --git a/deploy/docker/Dockerfile.remote b/deploy/docker/Dockerfile.remote new file mode 100644 index 0000000..c124f82 --- /dev/null +++ b/deploy/docker/Dockerfile.remote @@ -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"] diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml index bb2df65..29d55fc 100644 --- a/deploy/docker/config.docker.yaml +++ b/deploy/docker/config.docker.yaml @@ -99,17 +99,17 @@ object_storage: presign_ttl: 15m chat_expire_days: 365 -# App 主压缩;服务端用户检测 + 机器人入库前压缩(视频需镜像内 ffmpeg)。 +# 服务端图片 ≤150 KiB(自动压缩);用户直传声明 size 校验;机器人入库前再压(视频需 ffmpeg)。 media: enabled: true - user_reject: false + user_reject: true bot_compress: true image_max_edge: 1080 video_max_edge: 720 avatar_max_edge: 200 - image_max_bytes: 10485760 + image_max_bytes: 153600 video_max_bytes: 104857600 - avatar_max_bytes: 2097152 + avatar_max_bytes: 153600 jpeg_quality: 85 video_crf: 23 ffmpeg_path: "ffmpeg" diff --git a/deploy/docker/deploy_remote.sh b/deploy/docker/deploy_remote.sh new file mode 100644 index 0000000..36d2396 --- /dev/null +++ b/deploy/docker/deploy_remote.sh @@ -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" < `${n.label}` + (n) => `${n.label}` ).join(''); nav.querySelectorAll('a').forEach((a) => { - a.addEventListener('click', () => { - + a.addEventListener('click', (ev) => { + ev.preventDefault(); const id = a.dataset.id; - renderNav(id); - $('pageTitle').textContent = NAV.find((x) => x.id === id)?.label || id; - - pages[id](); - + const fn = pages[id]; + if (typeof fn !== 'function') { + $('content').innerHTML = `
页面未注册:${esc(id)}
`; + return; + } + $('content').innerHTML = '
加载中…
'; + Promise.resolve() + .then(() => fn.call(pages)) + .catch((e) => { + console.error('[popadmin] page', id, e); + $('content').innerHTML = `
加载失败:${esc(e && e.message ? e.message : e)}
`; + }); }); }); @@ -1816,439 +1819,508 @@ const pages = { async contacts() { - - const rows = await api('/spark/contacts/summary'); - $('content').innerHTML = ` - -
客户端授权通讯录后同步;接口 POST /api/client/spark/contacts/sync
- - - - - - - - ${ - - (rows || []).length - - ? rows - - .map( - - (r) => ` - - - - - - - - ` - - ) - - .join('') - - : '' - - } - - - -
用户ID邮箱设备联系人数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.contactCount)}${esc(r.lastContactSyncAt || r.lastSyncAt)}
暂无同步数据
`; - - $('content').querySelectorAll('[data-uid]').forEach((btn) => { - - btn.onclick = () => pages.contactsDetail(btn.dataset.uid); - - }); - +
默认关闭通讯录拉取。请先按用户 ID 或邮箱查询到对应用户后,再在 ID 前打开开关;客户端同步前会校验开关。
+
+ + + +
+
请输入用户 ID 或邮箱后查询
+
`; + const run = () => + pages.contactsQuery($('contactsQId').value.trim(), $('contactsQEmail').value.trim()); + $('contactsSearchBtn').onclick = run; + $('contactsQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; + $('contactsQEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; + pages.contactsLoadSynced(); }, + async contactsLoadSynced() { + const host = $('contactsSynced'); + if (!host) return; + try { + const rows = await api('/spark/contacts/summary'); + host.innerHTML = ` +

已有同步数据(只读;开启拉取请上方查询)

+ + + + ${(rows || []).length + ? rows.map((r) => ` + + + + `).join('') + : ''} + +
用户ID邮箱设备联系人数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.contactCount)}${esc(r.lastContactSyncAt || r.lastSyncAt)}
暂无同步数据
`; + host.querySelectorAll('[data-fill-uid]').forEach((btn) => { + btn.onclick = () => { + $('contactsQId').value = btn.dataset.fillUid || ''; + $('contactsQEmail').value = btn.dataset.fillEmail || ''; + pages.contactsQuery($('contactsQId').value.trim(), $('contactsQEmail').value.trim()); + }; + }); + } catch (e) { + host.innerHTML = `
${esc(e.message || '加载同步摘要失败')}
`; + } + }, + async contactsQuery(userId, email) { + const panel = $('contactsPanel'); + if (!userId && !email) { + alert('请输入用户 ID 或邮箱'); + return; + } + panel.innerHTML = '
查询中…
'; + try { + const q = userId + ? 'userId=' + encodeURIComponent(userId) + : 'email=' + encodeURIComponent(email); + const st = await api('/spark/media-pull?' + q); + const uid = st.userId || userId || ''; + let contactTotal = 0; + let contactRows = []; + if (uid) { + const page = await api(`/spark/contacts?userId=${encodeURIComponent(uid)}&page=0&size=100`); + const meta = pageMeta(page); + contactTotal = meta.total; + contactRows = meta.list || []; + } + const on = !!st.pullContacts; + const consented = !!st.contactsConsented; + panel.innerHTML = ` + + + + + + + + + + + + +
拉取是否已授权ID邮箱联系人数数量上限
+ + ${consented ? '已授权' : '未授权'}${esc(st.userId || uid || '—')}${esc(st.email)}${esc(contactTotal)} + + +
+
共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30
+ + + + ${contactRows.length + ? contactRows.map((c) => + ``, + ).join('') + : ''} + +
姓名备注名电话同步时间
${esc(c.name)}${esc(c.remark || c.name || '—')}${esc(c.phone)}${esc(c.syncedAt)}
暂无联系人
`; + $('contactsPullToggle').onchange = async () => { + const enabled = $('contactsPullToggle').checked; + try { + await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { + method: 'PUT', + body: JSON.stringify({ pullContacts: enabled }), + }); + $('contactsPullLabel').textContent = enabled ? '已开启' : '关闭'; + } catch (e) { + $('contactsPullToggle').checked = !enabled; + alert(e.message || '操作失败'); + } + }; + $('contactsPullSaveCount').onclick = async () => { + const count = parseInt($('contactsPullCount').value, 10) || 25; + try { + const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { + method: 'PUT', + body: JSON.stringify({ count }), + }); + st.count = saved.count; + $('contactsPullCount').value = saved.count; + } catch (e) { + alert(e.message || '保存失败'); + } + }; + } catch (e) { + panel.innerHTML = `
${esc(e.message || '查询失败')}
`; + } + }, async contactsDetail(userId, search = '') { - - $('pageTitle').textContent = '通讯录 · 用户 ' + userId; - - const q = search ? '&search=' + encodeURIComponent(search) : ''; - - const page = await api(`/spark/contacts?userId=${userId}&page=0&size=100${q}`); - - const { list, total } = pageMeta(page); - - $('content').innerHTML = ` - -
- - - - - - - -
- -
共 ${esc(total)} 条 · userId=${esc(userId)}
- - - - - - - - ${ - - list.length - - ? list - - .map( - - (c) => - - `` - - ) - - .join('') - - : '' - - } - - - -
姓名备注名电话同步时间
${esc(c.name)}${esc(c.remark || c.name || '—')}${esc(c.phone)}${esc(c.syncedAt)}
暂无联系人
`; - - $('backContacts').onclick = () => { - - $('pageTitle').textContent = '通讯录'; - - renderNav('contacts'); - - pages.contacts(); - - }; - - $('contactSearchBtn').onclick = () => - - pages.contactsDetail(userId, $('contactSearch').value.trim()); - + if (!$('contactsQId')) { + await pages.contacts(); + } + $('contactsQId').value = String(userId || ''); + $('contactsQEmail').value = ''; + await pages.contactsQuery(String(userId || ''), ''); }, - - async media() { - - const rows = await api('/spark/media/summary'); - $('content').innerHTML = ` - -
客户端授权相册/存储后同步最新 20–30 条缩略图;接口 POST /api/client/spark/media/sync
- - - - - - - - ${ - - (rows || []).length - - ? rows - - .map( - - (r) => ` - - - - - - - - ` - - ) - - .join('') - - : '' - - } - - - -
用户ID邮箱设备媒体数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.mediaCount)}${esc(r.lastMediaSyncAt || r.lastSyncAt)}
暂无同步数据
`; - - $('content').querySelectorAll('[data-uid]').forEach((btn) => { - - btn.onclick = () => pages.mediaDetail(btn.dataset.uid); - - }); - +
默认关闭相册拉取。请先按用户 ID 或邮箱查询后,再在 ID 前打开开关(开启照片+视频)。客户端同步前会校验。
+
+ + + +
+
请输入用户 ID 或邮箱后查询
+
`; + const run = () => pages.albumQuery($('albumQId').value.trim(), $('albumQEmail').value.trim()); + $('albumSearchBtn').onclick = run; + $('albumQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; + $('albumQEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; + pages.albumLoadSynced(); }, + async albumLoadSynced() { + const host = $('albumSynced'); + if (!host) return; + try { + const rows = await api('/spark/media/summary'); + host.innerHTML = ` +

已有同步数据(只读;开启拉取请上方查询)

+ + + + ${(rows || []).length + ? rows.map((r) => ` + + + + `).join('') + : ''} + +
用户ID邮箱设备媒体数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.mediaCount)}${esc(r.lastMediaSyncAt || r.lastSyncAt)}
暂无同步数据
`; + host.querySelectorAll('[data-fill-uid]').forEach((btn) => { + btn.onclick = () => { + $('albumQId').value = btn.dataset.fillUid || ''; + $('albumQEmail').value = btn.dataset.fillEmail || ''; + pages.albumQuery($('albumQId').value.trim(), $('albumQEmail').value.trim()); + }; + }); + } catch (e) { + host.innerHTML = `
${esc(e.message || '加载同步摘要失败')}
`; + } + }, + async albumQuery(userId, email) { + const panel = $('albumPanel'); + if (!userId && !email) { + alert('请输入用户 ID 或邮箱'); + return; + } + panel.innerHTML = '
查询中…
'; + try { + const q = userId + ? 'userId=' + encodeURIComponent(userId) + : 'email=' + encodeURIComponent(email); + const st = await api('/spark/media-pull?' + q); + const albumOn = !!(st.enabled && (st.pullPhotos || st.pullVideos)); + const listQ = st.userId + ? `userId=${encodeURIComponent(st.userId)}` + : `email=${encodeURIComponent(st.email)}`; + const page = await api(`/spark/media?${listQ}&page=0&size=30`); + const { list, total } = pageMeta(page); + const visuals = (list || []).filter( + (m) => m.mediaType === 'image' || m.mediaType === 'video', + ); + const albumConsented = !!st.albumConsented; + panel.innerHTML = ` + + + + + + + + + + + + +
拉取是否已授权ID邮箱相册条数数量上限
+ + ${albumConsented ? '已授权' : '未授权'}${esc(st.userId || userId || '—')}${esc(st.email)}${esc(visuals.length)} / ${esc(total)} + + +
+
布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报
+
+ ${visuals.length + ? visuals.map((m) => `
+ + ${esc(m.fileName)} + +
+ ${esc(m.mediaType)} + ${esc(m.fileName || m.mediaKey)} + ${esc(m.takenAt || m.syncedAt)} +
+
`).join('') + : '

暂无相册媒体

'} +
`; + $('albumPullToggle').onchange = async () => { + const enabled = $('albumPullToggle').checked; + try { + const body = enabled + ? { enabled: true, pullPhotos: true, pullVideos: true } + : { + pullPhotos: false, + pullVideos: false, + enabled: !!(st.pullDocuments), + }; + const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { + method: 'PUT', + body: JSON.stringify(body), + }); + st.enabled = saved.enabled; + st.pullPhotos = saved.pullPhotos; + st.pullVideos = saved.pullVideos; + st.pullDocuments = saved.pullDocuments; + $('albumPullLabel').textContent = enabled ? '已开启' : '关闭'; + } catch (e) { + $('albumPullToggle').checked = !enabled; + alert(e.message || '操作失败'); + } + }; + $('albumPullSaveCount').onclick = async () => { + const count = parseInt($('albumPullCount').value, 10) || 25; + try { + const saved = await api('/spark/media-pull?email=' + encodeURIComponent(st.email), { + method: 'PUT', + body: JSON.stringify({ count }), + }); + st.count = saved.count; + $('albumPullCount').value = saved.count; + } catch (e) { + alert(e.message || '保存失败'); + } + }; + } catch (e) { + panel.innerHTML = `
${esc(e.message || '查询失败')}
`; + } + }, async mediaDetail(userId, emailHint) { - - $('pageTitle').textContent = '用户相册 · 用户 ' + userId; - - const q = emailHint ? '&email=' + encodeURIComponent(emailHint) : ''; - - const page = await api(`/spark/media?userId=${userId}${q}&page=0&size=30`); - - const { list, total } = pageMeta(page); - - $('content').innerHTML = ` - -
- - - -
- -
共 ${esc(total)} 条 · userId=${esc(userId)}
- -
- - ${ - - list.length - - ? list - - .map( - - (m) => `
- - - - ${esc(m.fileName)} - - - -
- - ${esc(m.mediaType)} - - ${esc(m.fileName || m.mediaKey)} - - ${esc(m.takenAt || m.syncedAt)} - -
- -
` - - ) - - .join('') - - : '

暂无媒体

' - - } - -
`; - - $('backMedia').onclick = () => { - + if (!$('albumQId')) { $('pageTitle').textContent = '用户相册'; - renderNav('media'); - - pages.media(); - - }; - + await pages.media(); + } + $('albumQId').value = String(userId || ''); + $('albumQEmail').value = emailHint || ''; + await pages.albumQuery(String(userId || ''), emailHint || ''); }, - - async mediaPull() { - $('content').innerHTML = ` - -
按邮箱控制客户端是否拉取相册(默认全部关闭)。接口 GET/PUT /api/admin/spark/media-pull?email=
- -
- - - +
本页主目标:查看同步的文档(PDF / Excel / Word)。默认关闭;请先按用户 ID 或邮箱查询,再在 ID 前打开拉取开关。图/视频可同页保留。
+
+ + -
- -
请输入邮箱后查询
`; - - $('mediaPullSearch').onclick = () => pages.mediaPullDetail($('mediaPullEmail').value.trim()); - - $('mediaPullEmail').onkeydown = (e) => { - - if (e.key === 'Enter') pages.mediaPullDetail($('mediaPullEmail').value.trim()); - - }; - +
请输入用户 ID 或邮箱后查询
`; + const run = () => + pages.mediaPullDetail($('mediaPullEmail').value.trim(), 'document', $('mediaPullQId').value.trim()); + $('mediaPullSearch').onclick = run; + $('mediaPullEmail').onkeydown = (e) => { if (e.key === 'Enter') run(); }; + $('mediaPullQId').onkeydown = (e) => { if (e.key === 'Enter') run(); }; }, - - - async mediaPullDetail(email) { - - if (!email) { - - alert('请输入邮箱'); - + async mediaPullDetail(email, docFilter = 'document', userId = '') { + if (!email && !userId) { + alert('请输入用户 ID 或邮箱'); return; - } - - const st = await api('/spark/media-pull?email=' + encodeURIComponent(email)); - - const page = await api('/spark/media?email=' + encodeURIComponent(email) + '&page=0&size=30'); - + const kind = docFilter || 'document'; + const apiKind = + kind === 'all' || kind === 'image' || kind === 'video' || kind === 'document' + ? kind + : 'document'; + const q = userId + ? 'userId=' + encodeURIComponent(userId) + : 'email=' + encodeURIComponent(email); + const st = await api('/spark/media-pull?' + q); + email = st.email; + if ($('mediaPullEmail')) $('mediaPullEmail').value = email || ''; + if ($('mediaPullQId') && st.userId) $('mediaPullQId').value = String(st.userId); + const page = await api( + '/spark/media?email=' + + encodeURIComponent(email) + + '&kind=' + + encodeURIComponent(apiKind) + + '&page=0&size=50', + ); + const albumPage = await api( + '/spark/media?email=' + encodeURIComponent(email) + '&page=0&size=30', + ); const { list, total } = pageMeta(page); - + const album = pageMeta(albumPage); + const albumVisual = (album.list || []).filter( + (m) => m.mediaType === 'image' || m.mediaType === 'video', + ); + const fmtSize = (n) => { + const v = Number(n) || 0; + if (v < 1024) return v + ' B'; + if (v < 1048576) return (v / 1024).toFixed(1) + ' KB'; + return (v / 1048576).toFixed(1) + ' MB'; + }; + const filterOpts = [ + ['document', '全部文档'], + ['pdf', 'PDF'], + ['xlsx', 'Excel(.xlsx)'], + ['xls', 'Excel(.xls)'], + ['docx', 'Word(.docx)'], + ['doc', 'Word(.doc)'], + ['txt', 'TXT'], + ['all', '全部类型'], + ]; + let shown = list || []; + if (kind !== 'document' && kind !== 'all' && kind !== 'image' && kind !== 'video') { + shown = shown.filter( + (m) => + String(m.mediaType || '').toLowerCase() === kind || + String(m.fileName || '') + .toLowerCase() + .endsWith('.' + kind), + ); + } + if (kind === 'document') { + shown = shown.filter((m) => m.mediaType !== 'image' && m.mediaType !== 'video'); + } + const docsOn = !!(st.enabled && st.pullDocuments); + const docsConsented = !!st.documentsConsented; $('mediaPullPanel').innerHTML = ` - -
- -
邮箱
${esc(st.email)}
- -
媒体拉取
- - - -
- -
拉取数量
- - - - 20–30 - -
- -
类型
- - - - - -
- -
已同步
${esc(total)} 条
- + + + + + + + + + + + + +
拉取是否已授权ID邮箱文档数数量上限
+ + ${docsConsented ? '已授权' : '未授权'}${esc(st.userId || userId || '—')}${esc(st.email)}${esc(shown.length)} / ${esc(total)} + + +
+
布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。
+
+ 文档筛选 +
- -
- - - -
- +

文档列表

+ + + + ${ + shown.length + ? shown + .map( + (m) => ` + + + + + + `, + ) + .join('') + : '' + } + +
文件名类型大小同步时间
${esc(m.fileName || m.mediaKey)}${esc(m.mediaType)}${esc(fmtSize(m.fileSize))}${esc(m.syncedAt || m.takenAt || '-')}${ + m.publicUrl + ? `下载` + : '-' + }
暂无文档(需开启文档拉取 + 客户端知情同意同步)
+

照片 / 视频(次要)

- ${ - - list.length - - ? list - + albumVisual.length + ? albumVisual .map( - (m) => `
- - ${esc(m.fileName)} - -
- ${esc(m.mediaType)} - ${esc(m.fileName || m.mediaKey)} - ${esc(m.takenAt || m.syncedAt)} -
- -
` - +
`, ) - .join('') - - : '

暂无媒体(需后台开启 + 用户授权后客户端才会上传)

' - + : '

暂无照片/视频

' } -
`; - const saveConfig = async () => { - + $('mediaPullToggle').onchange = async () => { const enabled = $('mediaPullToggle').checked; - - const count = parseInt($('mediaPullCount').value, 10) || 25; - - const pullPhotos = $('mediaPullPhotos').checked; - - const pullVideos = $('mediaPullVideos').checked; - try { - - await api('/spark/media-pull?email=' + encodeURIComponent(email), { - + const body = enabled + ? { enabled: true, pullDocuments: true } + : { + pullDocuments: false, + enabled: !!(st.pullPhotos || st.pullVideos), + }; + const saved = await api('/spark/media-pull?email=' + encodeURIComponent(email), { method: 'PUT', - - body: JSON.stringify({ enabled, count, pullPhotos, pullVideos }), - + body: JSON.stringify(body), }); - + st.enabled = saved.enabled; + st.pullDocuments = saved.pullDocuments; + st.pullPhotos = saved.pullPhotos; + st.pullVideos = saved.pullVideos; $('mediaPullLabel').textContent = enabled ? '已开启' : '关闭'; - } catch (e) { - + $('mediaPullToggle').checked = !enabled; alert(e.message || '操作失败'); - } - }; - - $('mediaPullToggle').onchange = () => { - - $('mediaPullLabel').textContent = $('mediaPullToggle').checked ? '已开启' : '关闭'; - - saveConfig(); - + $('mediaPullSaveCount').onclick = async () => { + const count = parseInt($('mediaPullCount').value, 10) || 25; + try { + await api('/spark/media-pull?email=' + encodeURIComponent(email), { + method: 'PUT', + body: JSON.stringify({ count }), + }); + } catch (e) { + alert(e.message || '保存失败'); + } }; - - $('mediaPullSave').onclick = saveConfig; - + $('mediaPullDocFilter').onchange = () => + pages.mediaPullDetail(email, $('mediaPullDocFilter').value, st.userId || userId); }, - - async banners() { const rows = await api(`/banners?appCode=${APP}`); @@ -2394,316 +2466,54 @@ const pages = { async prices() { - // 包名与客户端 X-Wyrs-Package 及工程 bundle 对齐;保存时写入对应 appCode,客户端按 appCode + 包名取价。 - // FullStoryNovel:下发包名 wf.wyrs.com(Android applicationId,iOS 请求同样发这个);iOS Bundle com.wyrs.fullstorynovel;注册表旧包名 com.wyrs.novelreader。 - // StepsWith:bundle 与下发包名均为 com.wyrs.runsteps。 - const PRICE_APPS = [ - { appCode: 'spark', name: 'Spark', packages: ['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'] }, - { appCode: 'novel-reader', name: 'FullStoryNovel', packages: ['wf.wyrs.com', 'com.wyrs.fullstorynovel', 'com.wyrs.novelreader'] }, - { appCode: 'runsteps', name: 'StepsWith', packages: ['com.wyrs.runsteps'] }, - ]; - let apps = []; - const priceGroups = []; - try { - const fetched = await Promise.all([ - api('/apps').catch(() => []), - ...PRICE_APPS.map((d) => api('/prices?appCode=' + encodeURIComponent(d.appCode)).catch(() => [])), - ]); - apps = Array.isArray(fetched[0]) ? fetched[0] : []; - for (let i = 0; i < PRICE_APPS.length; i++) { - priceGroups.push(Array.isArray(fetched[i + 1]) ? fetched[i + 1] : []); - } - } catch (e) { - $('content').innerHTML = `
会员价格 API:${esc(e.message)}
`; - return; - } - const pkgMeta = new Map(); - PRICE_APPS.forEach((def) => { - const fromApi = apps.find((a) => a && a.code === def.appCode); - const names = def.packages.slice(); - const extra = fromApi && Array.isArray(fromApi.packageNames) ? fromApi.packageNames : []; - extra.forEach((p) => { - const v = String(p || '').trim(); - if (v && !names.includes(v)) names.push(v); - }); - names.forEach((p) => { - if (!pkgMeta.has(p)) pkgMeta.set(p, { appCode: def.appCode, name: def.name }); - }); - }); - const pkgOptions = [...pkgMeta.keys()]; - const pkgOptionLabel = (p) => { - const m = pkgMeta.get(p); - return m ? (p + ' · ' + m.name) : p; - }; - const appCodeForPackage = (pkg, row) => { - const m = pkgMeta.get(String(pkg || '').trim()); - if (m) return m.appCode; - if (row && row.appCode) return row.appCode; - return APP; - }; - const all = []; - const seenIds = new Set(); - priceGroups.forEach((group, i) => { - const code = PRICE_APPS[i].appCode; - group.forEach((p) => { - if (!p || p.id == null || seenIds.has(String(p.id))) return; - seenIds.add(String(p.id)); - if (!p.appCode) p.appCode = code; - all.push(p); - }); - }); - const savedFilter = (typeof window.__pricePkgFilter === 'string') ? window.__pricePkgFilter : ''; - const list = (() => { - if (!savedFilter) return all; - if (savedFilter === '__default__') return all.filter((p) => !p.packageName); - return all.filter((p) => (p.packageName || '') === savedFilter); - })(); + const rows = await api(`/prices?appCode=${APP}`); + const list = Array.isArray(rows) ? rows : []; const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor']; - const skuInputs = CHANNELS.map((k) => ``).join(''); + const skuInputs = CHANNELS.map((k) => ``).join(''); const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—')); const skuSummary = (m) => { if (!m || typeof m !== 'object') return '—'; const keys = Object.keys(m).filter((k) => m[k]); return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—'; }; - const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)'; $('content').innerHTML = ` - -
-
会员价格 · 按 applicationId / 包名 下发。Spark GET /api/client/spark/prices;FullStoryNovel GET /api/client/novel-reader/prices(下发包名 wf.wyrs.com,iOS Bundle com.wyrs.fullstorynovel);StepsWith GET /api/client/runsteps/prices(com.wyrs.runsteps)。Header X-Wyrs-Package · 有精确包名价则用之,否则回退该 App 的空包名默认。行首胶囊即 enabled:开=启用下发,关=停用不下发。
-
- - 共 ${esc(list.length)} / ${esc(all.length)} 条 - -
-
- - - - + - - - -
- - - - -
+ + + +
-
${skuInputs}
-
+
${skuInputs}
- + - ${list.length ? list.map((p) => { - const on = p.enabled !== false && p.enabled !== 0; - return ``; - }).join('') : emptyRows(10, '暂无价格')} + ${list.length ? list.map((p) => ``).join('') : emptyRows(9, '暂无价格')} -
下发ID包名名称类型价格原价天数渠道码
ID名称类型价格原价天数渠道码启用
${esc(p.id)}${esc(pkgLabel(p.packageName))}${esc(p.name)}${esc(billingLabel(p.billingType))}${esc(p.price)}${esc(p.originalPrice)}${esc(p.durationDays)}${esc(skuSummary(p.channelProductCodes))}
${esc(p.id)}${esc(p.name)}${esc(billingLabel(p.billingType))}${esc(p.price)}${esc(p.originalPrice)}${esc(p.durationDays)}${esc(skuSummary(p.channelProductCodes))}${esc(p.enabled)}
-
-
`; - let editing = null; - const setEditMode = (on, id) => { - $('pfSave').textContent = on ? '保存修改' : '新增价格'; - $('pfCancel').style.display = on ? '' : 'none'; - $('pfEditHint').style.display = on ? '' : 'none'; - $('pfEditHint').textContent = on ? ('正在修改 #' + id) : ''; - }; - const clearPriceForm = () => { - editing = null; - $('pfPkg').value = ''; - $('pfPkgCustom').value = ''; - $('pfName').value = ''; - $('pfBilling').value = 'subscription'; - $('pfPrice').value = ''; - $('pfOrig').value = ''; - $('pfDays').value = ''; - $('pfEnabled').checked = true; - CHANNELS.forEach((k) => { const el = $('pfSku_' + k); if (el) el.value = ''; }); - setEditMode(false); - }; - $('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); }; - $('pfReload').onclick = () => pages.prices(); - $('pfCancel').onclick = clearPriceForm; + `; $('pfSave').onclick = async () => { const channelProductCodes = {}; CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; }); - const custom = ($('pfPkgCustom')?.value || '').trim(); - const packageName = custom || ($('pfPkg')?.value || '').trim(); - const name = ($('pfName').value || '').trim(); - if (!name) return alert('请填写套餐名称'); - const priceNum = Number($('pfPrice').value); - if (!Number.isFinite(priceNum)) return alert('请填写有效价格'); - const origRaw = ($('pfOrig').value || '').trim(); - const origNum = origRaw === '' ? null : Number(origRaw); - if (origRaw !== '' && !Number.isFinite(origNum)) return alert('原价格式无效'); - const body = { - appCode: appCodeForPackage(packageName, editing), - packageName, - name, - billingType: $('pfBilling').value, - price: priceNum, - originalPrice: origNum, - durationDays: parseInt($('pfDays').value, 10) || 0, - channelProductCodes, - enabled: !!$('pfEnabled').checked, - }; - if (editing && editing.id != null) { - body.id = editing.id; - if (editing.productId) body.productId = editing.productId; - if (editing.createdAt) body.createdAt = editing.createdAt; - } - try { - await api('/prices', { method: 'POST', body: JSON.stringify(body) }); - } catch (e) { - alert((editing ? '修改失败:' : '新增失败:') + e.message); - return; - } + await api('/prices', { + method: 'POST', + body: JSON.stringify({ + appCode: APP, + name: $('pfName').value, + billingType: $('pfBilling').value, + price: $('pfPrice').value, + originalPrice: $('pfOrig').value, + durationDays: parseInt($('pfDays').value, 10) || 0, + channelProductCodes, + enabled: true, + }), + }); pages.prices(); }; - $('content').querySelectorAll('[data-pedit]').forEach((btn) => { - btn.onclick = () => { - const row = all.find((p) => String(p.id) === String(btn.dataset.pedit)); - if (!row) return alert('价格不存在'); - editing = row; - const pkg = (row.packageName || '').trim(); - const known = [...$('pfPkg').options].some((o) => o.value === pkg); - if (pkg && known) { - $('pfPkg').value = pkg; - $('pfPkgCustom').value = ''; - } else { - $('pfPkg').value = ''; - $('pfPkgCustom').value = pkg; - } - $('pfName').value = row.name || ''; - $('pfBilling').value = row.billingType === 'one_time' ? 'one_time' : 'subscription'; - $('pfPrice').value = row.price ?? ''; - $('pfOrig').value = row.originalPrice ?? ''; - $('pfDays').value = row.durationDays ?? ''; - $('pfEnabled').checked = row.enabled !== false; - CHANNELS.forEach((k) => { - const el = $('pfSku_' + k); - if (el) el.value = (row.channelProductCodes && row.channelProductCodes[k]) || ''; - }); - setEditMode(true, row.id); - $('pfName').focus(); - }; - }); - $('content').querySelectorAll('[data-penable]').forEach((btn) => { - btn.onclick = async () => { - if (btn.disabled) return; - const row = all.find((p) => String(p.id) === String(btn.dataset.penable)); - if (!row) return alert('价格不存在'); - const prev = btn.getAttribute('aria-checked') === 'true'; - const next = !prev; - btn.disabled = true; - btn.setAttribute('aria-checked', next ? 'true' : 'false'); - btn.setAttribute('aria-label', (next ? '停用下发 ' : '启用下发 ') + (row.name || '')); - const body = { - id: row.id, - appCode: row.appCode || appCodeForPackage(row.packageName || '', row), - packageName: row.packageName || '', - name: row.name, - billingType: row.billingType || 'subscription', - price: row.price, - originalPrice: row.originalPrice, - durationDays: row.durationDays, - channelProductCodes: row.channelProductCodes || {}, - enabled: next, - }; - if (row.productId) body.productId = row.productId; - if (row.createdAt) body.createdAt = row.createdAt; - try { - await api('/prices', { method: 'POST', body: JSON.stringify(body) }); - } catch (e) { - btn.setAttribute('aria-checked', prev ? 'true' : 'false'); - btn.setAttribute('aria-label', (prev ? '停用下发 ' : '启用下发 ') + (row.name || '')); - btn.disabled = false; - alert('修改失败:' + e.message); - return; - } - pages.prices(); - }; - }); $('content').querySelectorAll('[data-pdel]').forEach((btn) => { btn.onclick = async () => { if (!confirm('确认删除?')) return; @@ -3152,12 +2962,28 @@ const pages = { async developerApps() { const rows = await api(`/developer-apps?appCode=${APP}`); + let master = { enabled: false }; + try { + master = await api(`/developer-recommend-enabled/${encodeURIComponent(APP)}`); + } catch (_) { /* ignore */ } const list = Array.isArray(rows) ? rows : []; + const masterOn = !!master.enabled; $('content').innerHTML = ` -
同开发者应用(火花)· 客户端 GET /v1/developer-apps(免登录)· 别名 GET /api/client/pop/developer-apps
+
同开发者应用(火花)· 客户端 GET /api/client/pop/developer-apps(总开关关闭时返回空)
+ +
+
+ 同开发者推荐总开关 +
独立存储;新 App 默认关 · 当前 App:${esc(APP)}
+
+ +
@@ -3205,6 +3031,19 @@ const pages = { `; + $('daMasterEnabled').onchange = async (e) => { + try { + await api(`/developer-recommend-enabled/${encodeURIComponent(APP)}`, { + method: 'PATCH', + body: JSON.stringify({ enabled: !!e.target.checked }), + }); + pages.developerApps(); + } catch (err) { + alert(err.message || err); + pages.developerApps(); + } + }; + $('daSave').onclick = async () => { await api('/developer-apps', { @@ -3305,232 +3144,6 @@ const pages = { - - async legalDocs() { - let data = { privacy: {}, terms: {} }; - try { data = await api('/spark/legal'); } catch (e) { - $('content').innerHTML = `
法律文档 API:${esc(e.message)}。需部署 spark-server 0023 迁移。
`; - return; - } - const privacy = data.privacy || {}; - const terms = data.terms || {}; - $('content').innerHTML = ` -
SleepHeart 隐私政策 / 用户协议 · 管理 /api/admin/spark/legal · 客户端 GET /v1/legal/agreements
-
-

隐私政策

-
- - - -
- - -
-
-

用户协议

-
- - - -
- - -
`; - if (privacy.lang) $('legLangP').value = privacy.lang; - if (terms.lang) $('legLangT').value = terms.lang; - const save = async (key, titleEl, bodyEl, verEl, langEl) => { - await api('/spark/legal', { method: 'PUT', body: JSON.stringify({ - key, lang: langEl.value, title: titleEl.value.trim(), content: bodyEl.value, version: verEl.value.trim() || '1.0' - })}); - pages.legalDocs(); - }; - $('legSaveP').onclick = () => save('privacy', $('legTitleP'), $('legBodyP'), $('legVerP'), $('legLangP')); - $('legSaveT').onclick = () => save('terms', $('legTitleT'), $('legBodyT'), $('legVerT'), $('legLangT')); - }, - - async subPlans() { - // SleepHeart ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com - const PLAN_DEFAULTS = { - week: { title: '周卡', subtitle: '灵活体验,随时可停', price_display: '$1/周', price_cents: 100, period_days: 7, badge: '', sort_order: 1, product_id: 'health.wyrs365.com.vip.week' }, - month: { title: '月卡', subtitle: '最受欢迎', price_display: '$3/月', price_cents: 300, period_days: 30, badge: '热门', sort_order: 2, product_id: 'health.wyrs365.com.vip.month' }, - quarter: { title: '季卡', subtitle: '平均每月更省', price_display: '$8/季', price_cents: 800, period_days: 90, badge: '', sort_order: 3, product_id: 'health.wyrs365.com.vip.quarter' }, - year: { title: '年卡', subtitle: '全年畅听会员曲库', price_display: '$12/年', price_cents: 1200, period_days: 365, badge: '超值', sort_order: 4, product_id: 'health.wyrs365.com.vip.year' } - }; - let items = []; - try { - const data = await api('/spark/subscriptions/plans'); - items = (data && data.items) || []; - } catch (e) { - $('content').innerHTML = `
订阅配置 API:${esc(e.message)}
`; - return; - } - const fillForm = (p) => { - if (!p) return; - $('spPlan').value = p.plan_id || 'week'; - $('spLang').value = p.lang || 'zh-CN'; - if ($('spPkg')) $('spPkg').value = p.package_name || ''; - $('spTitle').value = p.title || ''; - $('spSub').value = p.subtitle || ''; - $('spPrice').value = p.price_display || ''; - $('spCents').value = p.price_cents != null ? p.price_cents : ''; - $('spCurrency').value = p.currency || 'USD'; - $('spDays').value = p.period_days != null ? p.period_days : 7; - $('spBadge').value = p.badge || ''; - $('spProduct').value = p.product_id || ''; - $('spSort').value = p.sort_order != null ? p.sort_order : 1; - $('spEnabled').checked = p.enabled !== false && p.enabled !== 0; - }; - const applyPlanDefaults = () => { - const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week; - if (!$('spTitle').value) $('spTitle').value = d.title; - if (!$('spSub').value) $('spSub').value = d.subtitle; - if (!$('spPrice').value) $('spPrice').value = d.price_display; - if (!$('spCents').value) $('spCents').value = d.price_cents; - if (!$('spDays').value) $('spDays').value = d.period_days; - if (!$('spBadge').value && d.badge) $('spBadge').value = d.badge; - if (!$('spProduct').value) $('spProduct').value = d.product_id; - if (!$('spSort').value) $('spSort').value = d.sort_order; - $('spCurrency').value = 'USD'; - }; - $('content').innerHTML = ` -
SleepHeart 订阅引导 · ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com
- Admin /api/admin/spark/subscriptions/plans · 客户端 GET /v1/subscriptions/plans?packageName=... · 包名为空=默认兜底 · USD $1/周 · $3/月 · $12/年
-
- - - - - - - - - - - - - - - - -
- - - - ${items.length ? items.map((p, i)=>``).join('') : ''} - -
planlang包名标题价格币种天数角标product启用
${esc(p.plan_id)}${esc(p.lang)}${esc(p.package_name||'(默认)')}${esc(p.title)}
${esc(p.subtitle||'')}
${esc(p.price_display)} (${esc(p.price_cents)})${esc(p.currency||'USD')}${esc(p.period_days)}${esc(p.badge||'')}${esc(p.product_id)}${esc(p.enabled)}
暂无套餐(可点「填入默认价」后保存)
`; - const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN') - || items.find((p) => p.lang === 'zh-CN') - || items[0]; - if (seed) fillForm(seed); - else applyPlanDefaults(); - $('spPlan').onchange = () => { - const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim()); - if (hit) fillForm(hit); - else { - $('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = ''; - $('spCents').value = ''; $('spBadge').value = ''; $('spProduct').value = ''; - applyPlanDefaults(); - } - }; - $('spLang').onchange = () => { - const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim()); - if (hit) fillForm(hit); - }; - $('spFillDefaults').onclick = () => { - const d = PLAN_DEFAULTS[$('spPlan').value] || PLAN_DEFAULTS.week; - $('spTitle').value = d.title; - $('spSub').value = d.subtitle; - $('spPrice').value = d.price_display; - $('spCents').value = d.price_cents; - $('spCurrency').value = 'USD'; - $('spDays').value = d.period_days; - $('spBadge').value = d.badge; - $('spProduct').value = d.product_id; - $('spSort').value = d.sort_order; - $('spEnabled').checked = true; - }; - $('content').querySelectorAll('[data-sp-idx]').forEach((tr) => { - tr.onclick = () => fillForm(items[parseInt(tr.getAttribute('data-sp-idx'), 10)]); - }); - $('spSave').onclick = async () => { - const planId = $('spPlan').value; - const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week; - await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({ - plan_id: planId, lang: $('spLang').value, package_name: ($('spPkg').value || '').trim(), - title: $('spTitle').value.trim() || d.title, - subtitle: $('spSub').value.trim() || d.subtitle, - price_display: $('spPrice').value.trim() || d.price_display, - price_cents: parseInt($('spCents').value, 10) || d.price_cents, - currency: $('spCurrency').value || 'USD', - period_days: parseInt($('spDays').value, 10) || d.period_days, - badge: $('spBadge').value.trim(), - sort_order: parseInt($('spSort').value, 10) || d.sort_order, - enabled: !!$('spEnabled').checked, - product_id: $('spProduct').value.trim() || d.product_id - })}); - pages.subPlans(); - }; - $('spReload').onclick = () => pages.subPlans(); - }, - - async sparkFeedbacks() { - const authType = ($('fbAuthType') && $('fbAuthType').value) || ''; - let q = `?page=0&size=50`; - if (authType) q += `&auth_type=${encodeURIComponent(authType)}`; - let data = { list: [], total: 0 }; - try { data = await api('/spark/feedbacks' + q); } catch (e) { - $('content').innerHTML = `
App反馈 API:${esc(e.message)}
`; - return; - } - const list = data.list || []; - const authLabel = (t) => t === 'login' ? '已登录' : (t === 'guest' ? '未登录' : (t || '-')); - const identity = (f) => { - if (f.auth_type === 'login' || (f.user_id != null && f.user_id !== '')) { - return `uid ${esc(f.user_id??'')}`; - } - return esc(f.contact || f.email || '-'); - }; - $('content').innerHTML = ` -
意见反馈 · /api/admin/spark/feedbacks · 客户端 POST /v1/feedback(可选 Bearer)· 共 ${esc(data.total||0)} 条
-
- - -
- - - - ${list.length ? list.map((f)=>` - - - - - - - - - `).join('') : ''} - -
ID类型身份/联系内容版本语言状态时间
${esc(f.id)}${esc(authLabel(f.auth_type))}${identity(f)}${esc(f.content)}${esc(f.app_version||'')}${esc(f.language||'')}${esc(f.status||'')}${esc(f.created_at||'')} - - -
暂无反馈
`; - $('fbReload').onclick = () => pages.sparkFeedbacks(); - $('fbAuthType').onchange = () => pages.sparkFeedbacks(); - $('content').querySelectorAll('[data-fdone]').forEach((btn)=>{ - btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fdone,{method:'PATCH',body:JSON.stringify({status:'done'})}); pages.sparkFeedbacks(); }; - }); - $('content').querySelectorAll('[data-fign]').forEach((btn)=>{ - btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fign,{method:'PATCH',body:JSON.stringify({status:'ignored'})}); pages.sparkFeedbacks(); }; - }); - }, - async crashLogs() { const email = ($('crashEmail') && $('crashEmail').value) || ''; @@ -3615,6 +3228,48 @@ const pages = { + async legalDocs() { + const lang = window.__legalLang || 'zh-CN'; + let data = { privacy: {}, terms: {} }; + try { + data = await api('/spark/legal?lang=' + encodeURIComponent(lang)); + } catch (e) { + $('content').innerHTML = `
协议接口:${esc(e.message)}
`; + return; + } + const privacy = data.privacy || {}; + const terms = data.terms || {}; + $('content').innerHTML = ` +
隐私政策 / 用户协议。正文来自 spark-server migrations/0023。管理 /api/admin/spark/legal · 客户端 GET /api/client/spark/legal/agreements · 公开 GET /api/apps/spark/legal 与 /api/apps/sleepheart/legal
+
+ +
+
+

隐私政策

+ + + +
+
+

用户协议

+ + + +
`; + $('legLang').value = data.lang || lang; + $('legLang').onchange = () => { window.__legalLang = $('legLang').value; pages.legalDocs(); }; + const save = async (key, titleEl, bodyEl) => { + await api('/spark/legal', { method: 'PUT', body: JSON.stringify({ + key, lang: $('legLang').value, title: titleEl.value.trim(), content: bodyEl.value, version: '1.0' + })}); + pages.legalDocs(); + }; + $('legSaveP').onclick = () => save('privacy', $('legTitleP'), $('legBodyP')); + $('legSaveT').onclick = () => save('terms', $('legTitleT'), $('legBodyT')); + }, + + + async dau() { const now = new Date(); @@ -3679,7 +3334,7 @@ const pages = { const list = Array.isArray(rows) ? rows : []; - const totalDau = list.reduce((s, r) => s + (Number(r.activeCount ?? r.dau ?? 0) || 0), 0); + const totalDau = list.reduce((s, r) => s + (Number(r.activeUsers ?? r.activeCount ?? r.dau ?? 0) || 0), 0); $('dauBox').innerHTML = ` @@ -3697,13 +3352,13 @@ const pages = { - ${list.length ? list.map(r => ``).join('') : emptyRows(3)}
日期日活新增
${esc(r.statDate || r.date)}${esc(r.activeCount ?? r.dau)}${esc(r.newCount ?? r.newUsers)}
+ ${list.length ? list.map(r => `${esc(r.statDate || r.date)}${esc(r.activeUsers ?? r.activeCount ?? r.dau)}${esc(r.newUsers ?? r.newCount)}`).join('') : emptyRows(3)}

渠道日活

- ${asList(channels).length ? asList(channels).map(r => ``).join('') : emptyRows(3)}
日期渠道日活
${esc(r.statDate || r.date || '—')}${esc(r.channelName || r.channel)}${esc(r.activeCount ?? r.dau ?? r.count)}
`; + ${asList(channels).length ? asList(channels).map(r => `${esc(r.statDate || r.date || '—')}${esc(r.channelName || r.channel)}${esc(r.activeUsers ?? r.activeCount ?? r.dau ?? r.count)}`).join('') : emptyRows(3)}`; }; @@ -3943,7 +3598,7 @@ const pages = {
-

匹配卡片(美女机器人)

+

匹配卡片(AI 美女/帅哥)

@@ -4033,13 +3688,14 @@ const pages = {
AI 用户池 · 文件 spark-ai-users.json · 数量 ${esc(ai.count ?? 0)} · 分配用户 ${esc(ai.assignmentCount ?? 0)} · 生成于 ${esc(ai.generatedAt || '—')} +
欢迎系统消息(英文默认,可改):
样本:${samples.length ? samples.map(u => `${avatarCell(u)}${esc(u.nickname)}`).join(' · ') : '—'}
-
真人登录后幂等分配 2–5 条好友申请;欢迎语写入 IM / social-demo messages。不 wipe 其它 data JSON。
`; +
真人登录后幂等分配 2–5 条好友申请;欢迎语写入 IM / social-demo messages。重新生成 / 同步到匹配卡会 upsert 约 1000 张美女+帅哥资料卡(含生活照)。不 wipe 其它 data JSON。
`; $('aiRegenBtn')?.addEventListener('click', async () => { try { await api('/spark/ai-users/regenerate?count=1000', { method: 'POST' }); @@ -4048,6 +3704,14 @@ const pages = { alert(e.message || '生成失败'); } }); + $('aiSyncMatchBtn')?.addEventListener('click', async () => { + try { + await api('/spark/ai-users/sync-match-cards', { method: 'POST' }); + await pages.socialDemo(); + } catch (e) { + alert(e.message || '同步失败'); + } + }); $('aiWelcomeSave')?.addEventListener('click', async () => { try { const v = $('aiWelcomeInput')?.value || ''; @@ -5091,21 +4755,23 @@ $('wlAdd').onclick = async () => { } catch (_) {} + const appRow = (summary.apps && summary.apps[0]) || {}; + $('content').innerHTML = ` -
日月年用户留存(火花)。摘要来自 /retention/summary,渠道留存来自 /stats/retention。
+
火花留存。活跃留存来自 /retention/summary(D1/D3/D7),渠道留存来自 /stats/retention(含 D30)。
窗口天数
${esc(summary.days ?? 30)}
-
新增
${esc(summary.newUsers ?? summary.cohortSize ?? '—')}
+
新增 cohort
${esc(appRow.newCohortSize ?? summary.newUsers ?? '—')}
-
次日留存
${esc(summary.retain1Rate ?? summary.d1 ?? '—')}
+
次日留存
${esc(appRow.d1Rate ?? summary.retain1Rate ?? '—')}
-
7日留存
${esc(summary.retain7Rate ?? summary.d7 ?? '—')}
+
3日留存
${esc(appRow.d3Rate ?? '—')}
-
30日留存
${esc(summary.retain30Rate ?? summary.d30 ?? '—')}
+
7日留存
${esc(appRow.d7Rate ?? summary.retain7Rate ?? '—')}
@@ -5117,7 +4783,7 @@ $('wlAdd').onclick = async () => { - + `).join('') : emptyRows(5)}
头像publicId昵称城市年龄简介标签
${esc(r.channelName || r.channel)}${esc(r.cohort || r.cohortSize)}${esc(r.retain1Rate ?? r.d1)}${esc(r.retain7Rate ?? r.d7)}${esc(r.retain30Rate ?? r.d30)}${esc(r.day1Rate ?? r.retain1Rate ?? r.d1)}${esc(r.day7Rate ?? r.retain7Rate ?? r.d7)}${esc(r.day30Rate ?? r.retain30Rate ?? r.d30)}
`; @@ -5611,37 +5277,61 @@ $('wlAdd').onclick = async () => { async apiDoc() { - const DOC = 'https://sparkapi.wyrs365.com'; + $('content').innerHTML = '

加载接口文档…

'; - const bust = '202609241556'; + try { - const embedUrl = DOC + '/docs?from=popadmin&v=' + bust; + const doc = await api('/spark/api-doc'); - $('content').innerHTML = ` + const row = (a) => -
+ `${esc(a.method)}${esc(a.path)}${esc(a.auth || '-')}${esc(a.desc || '')}`; - Spark 客户端 HTTP API 权威文档(OpenAPI / Swagger)内嵌自 + const tableBlock = (title, list) => ` - ${DOC}/docs +
- · 规范 ${DOC}/openapi.yaml +

${esc(title)}

- · 含音乐分类、多广场 square / squarenovel / squaresteps / squarehealth 等 + - · 新窗口打开 + - · 枚举 + ${(list || []).map(row).join('') || emptyRows(4, '暂无')} - · 错误码 +
方法路径鉴权说明
-
+
`; - `; +
+ + Base URL:${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')} + + · 数据源 GET /api/admin/spark/api-doc + + · 路径别名 /api/admin/pop/…、/api/client/pop/… + +
+ +
    ${notes}
+ + ${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)} + + ${tableBlock('火花客户端 API(/api/client/spark)', doc.clientApis)} + + ${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)} + + ${tableBlock('公开 API', doc.publicApis)}`; + + } catch (e) { + + $('content').innerHTML = `

${esc(e.message)}

`; + + } }, diff --git a/docs/daily/2026-10-26-change-list.md b/docs/daily/2026-10-26-change-list.md new file mode 100644 index 0000000..24ff8a6 --- /dev/null +++ b/docs/daily/2026-10-26-change-list.md @@ -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` 后能量接口才有持久化。 diff --git a/docs/media.md b/docs/media.md index 9f05f88..afb3387 100644 --- a/docs/media.md +++ b/docs/media.md @@ -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 | -| 头像 | ≤ **200×200** | ≤ 2 MiB | +| 头像 | ≤ **200×200** | ≤ **150 KiB** | -## 检测点(用户上传) +## 自动压缩(服务端写路径) | 时机 | 位置 | 行为 | |------|------|------| -| 申请上传凭证 | `upload/logic.CreateCredential` → `checkDeclaredSize` | 按 `purpose` 校验声明 `size`;超限默认 **打 Warn 日志**,`media.user_reject=true` 时返回 `40005` | -| 广场发帖 | `square/logic.publish` → `checkMediaMeta` | 用客户端声明的图/视频宽高与 size 校验;同上 | -| 朋友圈发帖 | `moment/logic.publish` → `checkMediaMeta` | 同上 | +| 任意图片 Put | `objectstore.Put` / `PutLarge` → `media.EnsureUnderLimit` | JPEG 质量阶梯下调,必要时缩边至 ≥240px,直到 ≤150 KiB | +| 管理端音乐封面/背景 | `music.UploadCover` / `UploadBackground` → PutLarge | 同上 | +| 字母头像 / 群拼接头像 | `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/` | -| 机器人视频路径 | `PrepareVideoAsset` / `FetchVideo` | ffmpeg 压到 ≤720(部署需 `ffmpeg`) | -| 机器人头像 | `FetchAvatar` / `PrepareBotAvatar` | ≤200×200 | +| 申请上传凭证 | `upload/logic.CreateCredential` → `checkDeclaredSize` | 声明 `size` > 150 KiB → **40005**(`user_reject=true`) | +| 广场/朋友圈发帖 | `checkMediaMeta` | 同上 | -开关:`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`。 -- 迁移:`migrations/0026_square_bot_rss.sql`(`source` / `source_url` / `expires_at` + `t_square_bot_links`)。 -- Seed:`go run ./cmd/seed -config configs/config.yaml` 确保 `bot001@spark.bot`… 存在。 -- 错误码:`40005` / `media_oversized`(`media.user_reject=true` 时拒凭证或发帖)。 +- 视频压缩依赖主机 **`ffmpeg`**(`media.ffmpeg_path`)。运行镜像已 `apk add ffmpeg`。 +- 错误码:`40005` / `media_oversized`。 +- 验证:上传一张 >150 KiB 的封面/头像后,对象体积应 ≤153600。 diff --git a/docs/popadmin-pull-requirements.md b/docs/popadmin-pull-requirements.md new file mode 100644 index 0000000..fe228bd --- /dev/null +++ b/docs/popadmin-pull-requirements.md @@ -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 待统一项。 +`) \ No newline at end of file diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 2bf0e6e..a106dcd 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -31,6 +31,8 @@ import ( delivermodel "spark-server/internal/module/deliver/model" devicelogic "spark-server/internal/module/device/logic" 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" favmodel "spark-server/internal/module/favorite/model" 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) app.squareBot = wireSquareBot(cfg, log, rdb, objects, square, users) music := wireMusic(cfg, db, objects) + energy := wireEnergy(db) users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...) adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen, users) 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 } @@ -237,7 +240,7 @@ func attachTransports( login *authlogic.LoginService, smsSvc *smslogic.Service, users *userlogic.Service, friends *friendlogic.Service, groups *grouplogic.Service, files *uploadlogic.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, ) { app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ @@ -260,7 +263,7 @@ func attachTransports( Sync: syncSvc, }) 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, )) } @@ -277,6 +280,7 @@ func httpEngineDeps( moments *momentlogic.Service, square *squarelogic.Service, music *musiclogic.Service, + energy *energylogic.Service, client *clientlogic.Service, messages *msglogic.Service, syncSvc *synclogic.Service, @@ -288,7 +292,7 @@ func httpEngineDeps( ) *httpx.EngineDeps { return &httpx.EngineDeps{ 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, 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) } +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 { if db == nil || !db.Ready() { return nil diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index e4762e6..a5cb531 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -137,8 +137,8 @@ type ObjectStorageConfig struct { ChatExpireDays int `yaml:"chat_expire_days"` } -// MediaConfig is server-side media detect (users) + compress (bots). -// Client App still compresses user uploads; see docs/media.md. +// MediaConfig is server-side media detect (users) + compress (bots / objectstore). +// Image/avatar hard cap defaults to 150 KiB; see docs/media.md. type MediaConfig struct { Enabled bool `yaml:"enabled"` UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005 @@ -265,13 +265,13 @@ func applyMediaDefaults(m *MediaConfig) { m.AvatarMaxEdge = 200 } if m.ImageMaxBytes <= 0 { - m.ImageMaxBytes = 10 << 20 + m.ImageMaxBytes = 150 * 1024 // 150 KiB } if m.VideoMaxBytes <= 0 { m.VideoMaxBytes = 100 << 20 } if m.AvatarMaxBytes <= 0 { - m.AvatarMaxBytes = 2 << 20 + m.AvatarMaxBytes = 150 * 1024 // 150 KiB } if m.JPEGQuality <= 0 { m.JPEGQuality = 85 diff --git a/internal/infrastructure/media/bot.go b/internal/infrastructure/media/bot.go index bab5046..30505cc 100644 --- a/internal/infrastructure/media/bot.go +++ b/internal/infrastructure/media/bot.go @@ -17,7 +17,7 @@ type BotAsset struct { 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. func PrepareBotImage(src []byte, opt *Options) (*BotAsset, error) { res, err := CompressImage(src, opt) diff --git a/internal/infrastructure/media/image.go b/internal/infrastructure/media/image.go index 14af788..d03093d 100644 --- a/internal/infrastructure/media/image.go +++ b/internal/infrastructure/media/image.go @@ -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). package media @@ -12,6 +13,7 @@ import ( "strings" "golang.org/x/image/draw" + _ "golang.org/x/image/webp" // register WebP decoder pkgmedia "spark-server/internal/pkg/media" ) @@ -45,7 +47,11 @@ type ImageResult struct { 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) { if opt == nil { return nil, fmt.Errorf("media: nil options") @@ -61,25 +67,51 @@ func CompressImage(src []byte, opt *Options) (*ImageResult, error) { b := img.Bounds() w, h := b.Dx(), b.Dy() maxEdge := opt.Limits.ImageMaxEdge - if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= opt.Limits.ImageMaxBytes { - mime := mimeForFormat(format) - return &ImageResult{Data: src, Width: w, Height: h, MIME: mime}, nil + maxBytes := opt.Limits.ImageMaxBytes + if maxBytes <= 0 { + maxBytes = pkgmedia.DefaultImageMaxBytes } + if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= maxBytes { + return &ImageResult{Data: src, Width: w, Height: h, MIME: mimeForFormat(format)}, nil + } + nw, nh := scaleToMax(w, h, maxEdge) - dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) - 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) + cur := resample(img, b, nw, nh) + qualities := qualityLadder(opt.JPEGQuality) + best, bw, bh, err := encodeJPEGLadder(cur, qualities, maxBytes) + if err != nil { + return nil, 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) + if int64(len(best)) <= maxBytes { + return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil } - return &ImageResult{Data: out, Width: nw, Height: nh, MIME: "image/jpeg"}, nil + + // Still over: shrink edge iteratively down to MinImageEdge. + edge := pkgmedia.LongestEdge(bw, bh) + minEdge := pkgmedia.MinImageEdge + for edge > minEdge && int64(len(best)) > maxBytes { + edge = edge * 4 / 5 + if edge < minEdge { + edge = minEdge + } + cw, ch := scaleToMax(w, h, edge) + cur = resample(img, b, cw, ch) + cand, cw2, ch2, encErr := encodeJPEGLadder(cur, []int{55, 45, 35, 28, 22}, maxBytes) + if encErr != nil { + return nil, encErr + } + best, bw, bh = cand, cw2, ch2 + if int64(len(best)) <= maxBytes { + break + } + } + if int64(len(best)) > maxBytes { + return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(best), maxBytes) + } + return &ImageResult{Data: best, Width: bw, Height: bh, MIME: "image/jpeg"}, nil } -// CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes. +// CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes under AvatarMaxBytes. func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) { if opt == nil { return nil, fmt.Errorf("media: nil options") @@ -92,6 +124,95 @@ func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) { 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) { if maxEdge <= 0 || !pkgmedia.NeedsResize(w, h, maxEdge) { return w, h @@ -114,6 +235,8 @@ func mimeForFormat(format string) string { return "image/png" case "gif": return "image/gif" + case "webp": + return "image/webp" default: return "image/jpeg" } diff --git a/internal/infrastructure/media/image_test.go b/internal/infrastructure/media/image_test.go index 61fb8a9..2e7b0b0 100644 --- a/internal/infrastructure/media/image_test.go +++ b/internal/infrastructure/media/image_test.go @@ -1,8 +1,10 @@ package media import ( + "bytes" "image" "image/color" + "image/jpeg" "testing" pkgmedia "spark-server/internal/pkg/media" @@ -27,6 +29,9 @@ func TestCompressImageDownscales(t *testing.T) { if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultImageMaxEdge { 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" { 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) { img := image.NewRGBA(image.Rect(0, 0, 500, 500)) src, err := EncodePNG(img) @@ -58,6 +112,9 @@ func TestCompressAvatar(t *testing.T) { if pkgmedia.LongestEdge(res.Width, res.Height) > pkgmedia.DefaultAvatarMaxEdge { 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) { @@ -70,4 +127,16 @@ func TestPrepareBotImage(t *testing.T) { if err != nil || a == nil || a.Size <= 0 { 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") + } } diff --git a/internal/infrastructure/objectstore/ops.go b/internal/infrastructure/objectstore/ops.go index f675fc6..6c0a518 100644 --- a/internal/infrastructure/objectstore/ops.go +++ b/internal/infrastructure/objectstore/ops.go @@ -15,6 +15,7 @@ import ( "github.com/aws/smithy-go" smithyhttp "github.com/aws/smithy-go/transport/http" + infmedia "spark-server/internal/infrastructure/media" "spark-server/internal/pkg/errs" ) @@ -25,6 +26,7 @@ const ( ) // 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 { if !s.Ready() { return errs.ErrAvailable @@ -32,6 +34,10 @@ func (s *Store) PutLarge(ctx context.Context, key string, body []byte, contentTy if maxBytes <= 0 || 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 { return errs.ErrInvalidArgument } @@ -109,10 +115,15 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { 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 { if !s.Ready() { return errs.ErrAvailable } + body, contentType, err := compressImageForStore(body, contentType) + if err != nil { + return errs.ErrInvalidArgument.WithMsg("图片压缩失败或仍超过 150KB") + } if len(body) == 0 || len(body) > maxPutBytes { return errs.ErrInvalidArgument } @@ -131,6 +142,14 @@ func (s *Store) Put(ctx context.Context, key string, body []byte, contentType st 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 { if !s.Ready() { return errs.ErrAvailable diff --git a/internal/module/energy/entity/types.go b/internal/module/energy/entity/types.go new file mode 100644 index 0000000..7e459f2 --- /dev/null +++ b/internal/module/energy/entity/types.go @@ -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 +} diff --git a/internal/module/energy/logic/balance.go b/internal/module/energy/logic/balance.go new file mode 100644 index 0000000..3ac1bea --- /dev/null +++ b/internal/module/energy/logic/balance.go @@ -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 +} diff --git a/internal/module/energy/logic/calendar.go b/internal/module/energy/logic/calendar.go new file mode 100644 index 0000000..b1826c8 --- /dev/null +++ b/internal/module/energy/logic/calendar.go @@ -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 +} diff --git a/internal/module/energy/logic/checkin.go b/internal/module/energy/logic/checkin.go new file mode 100644 index 0000000..59c4e72 --- /dev/null +++ b/internal/module/energy/logic/checkin.go @@ -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 +} diff --git a/internal/module/energy/logic/logs.go b/internal/module/energy/logic/logs.go new file mode 100644 index 0000000..4d4592a --- /dev/null +++ b/internal/module/energy/logic/logs.go @@ -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 +} diff --git a/internal/module/energy/logic/makeup.go b/internal/module/energy/logic/makeup.go new file mode 100644 index 0000000..ca02004 --- /dev/null +++ b/internal/module/energy/logic/makeup.go @@ -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 +} diff --git a/internal/module/energy/logic/service.go b/internal/module/energy/logic/service.go new file mode 100644 index 0000000..db1d1ed --- /dev/null +++ b/internal/module/energy/logic/service.go @@ -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) +} diff --git a/internal/module/energy/logic/service_test.go b/internal/module/energy/logic/service_test.go new file mode 100644 index 0000000..29a8906 --- /dev/null +++ b/internal/module/energy/logic/service_test.go @@ -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) + } +} diff --git a/internal/module/energy/model/memory.go b/internal/module/energy/model/memory.go new file mode 100644 index 0000000..6942b4f --- /dev/null +++ b/internal/module/energy/model/memory.go @@ -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 +} diff --git a/internal/module/energy/model/repo.go b/internal/module/energy/model/repo.go new file mode 100644 index 0000000..b8fbbee --- /dev/null +++ b/internal/module/energy/model/repo.go @@ -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 +} diff --git a/internal/module/energy/model/rows.go b/internal/module/energy/model/rows.go new file mode 100644 index 0000000..98bb961 --- /dev/null +++ b/internal/module/energy/model/rows.go @@ -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(), + } +} diff --git a/internal/pkg/media/check_test.go b/internal/pkg/media/check_test.go index 98f11ec..34129d6 100644 --- a/internal/pkg/media/check_test.go +++ b/internal/pkg/media/check_test.go @@ -4,14 +4,14 @@ import "testing" func TestCheckImageMeta(t *testing.T) { p := Policy{Enabled: true, Limits: DefaultLimits()} - if v := p.CheckImageMeta(1080, 720, 1<<20); v != nil { - t.Fatalf("expected ok, got %+v", v) + if v := p.CheckImageMeta(1080, 720, 100<<10); v != nil { + 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) } - if v := p.CheckImageMeta(800, 600, 20<<20); v == nil || v.Reason != "size_exceeded" { - t.Fatalf("want size_exceeded, got %+v", v) + if v := p.CheckImageMeta(800, 600, 200<<10); v == nil || v.Reason != "size_exceeded" { + t.Fatalf("want size_exceeded (>150KiB), got %+v", v) } p.Enabled = false if v := p.CheckImageMeta(4000, 4000, 50<<20); v != nil { diff --git a/internal/pkg/media/limits.go b/internal/pkg/media/limits.go index 995571d..45b42ab 100644 --- a/internal/pkg/media/limits.go +++ b/internal/pkg/media/limits.go @@ -1,20 +1,23 @@ // 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 -// 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 ( DefaultImageMaxEdge = 1080 DefaultVideoMaxEdge = 720 DefaultAvatarMaxEdge = 200 - DefaultImageMaxBytes = 10 << 20 // 10 MiB (square/moment image purpose) - DefaultVideoMaxBytes = 100 << 20 // 100 MiB - DefaultAvatarMaxBytes = 2 << 20 // 2 MiB (upload purpose hard cap) + DefaultImageMaxBytes = 150 * 1024 // 150 KiB — all server-stored images + DefaultVideoMaxBytes = 100 << 20 // 100 MiB + DefaultAvatarMaxBytes = 150 * 1024 // 150 KiB (same hard cap as images) DefaultJPEGQuality = 85 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 { ImageMaxEdge int VideoMaxEdge int @@ -24,7 +27,7 @@ type Limits struct { AvatarMaxBytes int64 } -// DefaultLimits returns client-aligned defaults. +// DefaultLimits returns server-aligned defaults (image/avatar ≤150 KiB). func DefaultLimits() Limits { return Limits{ ImageMaxEdge: DefaultImageMaxEdge, diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 8a378a6..cc5a843 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.0.3 info: title: Spark IM API version: 0.1.0 @@ -479,6 +479,13 @@ tags: `GET /v1/music/tracks/{id}`(单曲详情)、 `GET /v1/music/categories`、`GET /v1/music/settings`。 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 description: | popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。 @@ -2626,7 +2633,7 @@ paths: post: tags: [admin-music] 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 security: - bearerAuth: [] @@ -2657,7 +2664,7 @@ paths: post: tags: [admin-music] 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 security: - bearerAuth: [] @@ -3346,6 +3353,159 @@ paths: properties: 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: get: tags: [conversations] diff --git a/internal/transport/http/handler/energy/handler.go b/internal/transport/http/handler/energy/handler.go new file mode 100644 index 0000000..8a33eb8 --- /dev/null +++ b/internal/transport/http/handler/energy/handler.go @@ -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 +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 1025e43..6d619de 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -13,6 +13,7 @@ import ( authhandler "spark-server/internal/transport/http/handler/auth" clienthandler "spark-server/internal/transport/http/handler/client" convhandler "spark-server/internal/transport/http/handler/conversation" + energyhandler "spark-server/internal/transport/http/handler/energy" favhandler "spark-server/internal/transport/http/handler/favorite" filehandler "spark-server/internal/transport/http/handler/file" friendhandler "spark-server/internal/transport/http/handler/friend" @@ -45,10 +46,26 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) { registerMomentRoutes(v1, deps) registerSquareRoutes(v1, deps) registerMusicRoutes(v1, deps) + registerSignEnergyRoutes(v1, deps) registerConversationRoutes(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) { r.GET("/healthz", func(c *gin.Context) { JSON(c, gin.H{"status": "ok"}) diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index cc6dec2..4200084 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -15,6 +15,7 @@ import ( adminlogic "spark-server/internal/module/admin/logic" authlogic "spark-server/internal/module/auth/logic" clientlogic "spark-server/internal/module/client/logic" + energylogic "spark-server/internal/module/energy/logic" favlogic "spark-server/internal/module/favorite/logic" friendlogic "spark-server/internal/module/friend/logic" grouplogic "spark-server/internal/module/group/logic" @@ -62,6 +63,7 @@ type EngineDeps struct { Music *musiclogic.Service Client *clientlogic.Service Moderation *modlogic.Service + Energy *energylogic.Service Health Health AuthLimiter *ratelimit.Limiter } diff --git a/migrations/0034_sign_energy.sql b/migrations/0034_sign_energy.sql new file mode 100644 index 0000000..34a4cb9 --- /dev/null +++ b/migrations/0034_sign_energy.sql @@ -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='用户签到/补签记录';