feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
@@ -59,7 +59,7 @@ func TestAdminOpsPhaseA(t *testing.T) {
t.Fatalf("login token missing: %v %s", err, env.Data)
}
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
_ = mustRegister(t, r)
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
if code != 200 || env.Code != 0 {
@@ -0,0 +1,89 @@
package integration_test
import (
"encoding/json"
"net/http"
"testing"
"time"
)
func TestAnalyticsOpsB(t *testing.T) {
r, _ := setupAPI(t)
sid := "s_test_" + time.Now().Format("150405")
now := time.Now().UTC().Format(time.RFC3339Nano)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/analytics/events", map[string]any{
"items": []map[string]any{
{"name": "session_start", "session_id": sid, "client_ts": now, "props": map[string]any{"cold": true}},
{"name": "page_view", "session_id": sid, "page_path": "/portrait", "client_ts": now},
{"name": "page_view", "session_id": sid, "page_path": "/ask", "client_ts": now},
{"name": "page_leave", "session_id": sid, "page_path": "/portrait", "client_ts": now,
"props": map[string]any{"dwell_ms": 1500}},
{"name": "ui_click", "session_id": sid, "client_ts": now,
"props": map[string]any{"element_id": "home_cta", "page_path": "/"}},
{"name": "portrait_completed", "session_id": sid, "client_ts": now},
{"name": "session_end", "session_id": sid, "client_ts": now,
"props": map[string]any{"exit_page": "/ask", "duration_ms": 8000}},
},
}, "")
var accepted struct {
Accepted int `json:"accepted"`
}
if err := json.Unmarshal(env.Data, &accepted); err != nil || accepted.Accepted < 6 {
t.Fatalf("accepted=%v err=%v body=%s", accepted, err, string(env.Data))
}
_ = key
loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
"username": "admin", "password": "change-me",
}, "")
if code != http.StatusOK || loginEnv.Code != 0 {
t.Fatalf("admin login http=%d code=%d", code, loginEnv.Code)
}
var login struct {
Token string `json:"token"`
}
_ = json.Unmarshal(loginEnv.Data, &login)
if login.Token == "" {
t.Fatal("missing admin token")
}
from := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
to := time.Now().UTC().Format("2006-01-02")
q := "?from=" + from + "&to=" + to
ov, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/overview"+q, nil, login.Token)
if code != http.StatusOK || ov.Code != 0 {
t.Fatalf("overview http=%d code=%d msg=%s", code, ov.Code, ov.Message)
}
var overview map[string]any
_ = json.Unmarshal(ov.Data, &overview)
if sessions, _ := overview["sessions"].(float64); sessions < 1 {
t.Fatalf("expected sessions>=1 got %v", overview)
}
pg, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/pages"+q, nil, login.Token)
if code != http.StatusOK || pg.Code != 0 {
t.Fatalf("pages http=%d code=%d", code, pg.Code)
}
ex, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/exits"+q, nil, login.Token)
if code != http.StatusOK || ex.Code != 0 {
t.Fatalf("exits http=%d code=%d", code, ex.Code)
}
var exits struct {
Items []struct {
ExitPage string `json:"exit_page"`
Count int `json:"count"`
} `json:"items"`
}
_ = json.Unmarshal(ex.Data, &exits)
found := false
for _, it := range exits.Items {
if it.ExitPage == "/ask" && it.Count >= 1 {
found = true
}
}
if !found {
t.Fatalf("expected exit /ask in %v", exits.Items)
}
}
@@ -7,7 +7,7 @@ import (
func TestExploreCatalog(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key)
data := decodeData[map[string]any](t, env.Data)
cats, ok := data["categories"].([]any)
@@ -24,7 +24,7 @@ func TestExploreCatalog(t *testing.T) {
func TestGrowthPlanCheckin(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{
"title": "每晚早睡", "focus": "保护睡眠",
}, key)
@@ -0,0 +1,127 @@
package integration_test
import (
"encoding/json"
"net/http"
"testing"
)
func TestOpsContentPhaseC(t *testing.T) {
r, _ := setupAPI(t)
env, _ := doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
var home struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(env.Data, &home); err != nil || len(home.Items) < 6 {
t.Fatalf("home tools seed: %v len=%d", err, len(home.Items))
}
loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
"username": "admin", "password": "change-me",
}, "")
if code != http.StatusOK {
t.Fatalf("login http=%d", code)
}
var login struct {
Token string `json:"token"`
}
_ = json.Unmarshal(loginEnv.Data, &login)
adminTools, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/home/tools", nil, login.Token)
if code != http.StatusOK || adminTools.Code != 0 {
t.Fatalf("admin tools http=%d code=%d", code, adminTools.Code)
}
var all struct {
Items []map[string]any `json:"items"`
}
_ = json.Unmarshal(adminTools.Data, &all)
if len(all.Items) == 0 {
t.Fatal("expected seeded tools")
}
// disable first tool
all.Items[0]["enabled"] = false
putEnv, code := doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{
"items": all.Items,
}, login.Token)
if code != http.StatusOK || putEnv.Code != 0 {
t.Fatalf("put tools http=%d code=%d msg=%s", code, putEnv.Code, putEnv.Message)
}
auditEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs?limit=5", nil, login.Token)
if code != http.StatusOK {
t.Fatalf("audit http=%d", code)
}
var audits struct {
Items []struct {
Action string `json:"action"`
} `json:"items"`
}
_ = json.Unmarshal(auditEnv.Data, &audits)
foundAudit := false
for _, a := range audits.Items {
if a.Action == "home_tools.replace" {
foundAudit = true
break
}
}
if !foundAudit {
t.Fatalf("expected home_tools.replace audit, got %+v", audits.Items)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
_ = json.Unmarshal(env.Data, &home)
enabledN := len(home.Items)
if enabledN >= len(all.Items) {
t.Fatalf("expected fewer enabled tools after disable, pub=%d admin=%d", enabledN, len(all.Items))
}
// restore all enabled for shared DB
for i := range all.Items {
all.Items[i]["enabled"] = true
}
_, _ = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{"items": all.Items}, login.Token)
scalesEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/scales", nil, login.Token)
if code != http.StatusOK {
t.Fatalf("scales http=%d", code)
}
var scales struct {
Items []struct {
ID string `json:"id"`
Slug string `json:"slug"`
Status string `json:"status"`
} `json:"items"`
}
_ = json.Unmarshal(scalesEnv.Data, &scales)
if len(scales.Items) == 0 {
t.Fatal("no scales")
}
target := scales.Items[0]
patch, code := doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
"status": "draft",
}, login.Token)
if code != http.StatusOK || patch.Code != 0 {
t.Fatalf("patch http=%d code=%d msg=%s", code, patch.Code, patch.Message)
}
// published list should not include draft slug — needs registered user
key := mustRegister(t, r)
listEnv, _ := doJSON(t, r, http.MethodGet, "/api/v1/scales", nil, key)
var pub struct {
Items []struct {
Slug string `json:"slug"`
} `json:"items"`
}
_ = json.Unmarshal(listEnv.Data, &pub)
for _, it := range pub.Items {
if it.Slug == target.Slug {
t.Fatalf("draft scale %s still in published list", target.Slug)
}
}
// restore published so other tests aren't flaky if shared DB
_, _ = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
"status": "published",
}, login.Token)
}
+81 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -60,6 +61,9 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
if testBearer != "" {
req.Header.Set("Authorization", "Bearer "+testBearer)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
@@ -79,6 +83,25 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
return env, key
}
var testBearer string
func mustRegister(t *testing.T, r http.Handler) string {
t.Helper()
testBearer = ""
phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
"phone": phone, "password": "secret12", "nickname": "测",
}, "")
sess := decodeData[map[string]any](t, env.Data)
tok, _ := sess["token"].(string)
if tok == "" {
t.Fatal("missing token from register")
}
testBearer = tok
t.Cleanup(func() { testBearer = "" })
return key
}
func decodeData[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
@@ -91,7 +114,7 @@ func decodeData[T any](t *testing.T, raw json.RawMessage) T {
// Flow 1: create profile → portrait → deep_access mock → detail visible
func TestFlowPortraitDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
@@ -136,7 +159,7 @@ func TestFlowPortraitDeepAccess(t *testing.T) {
// Flow 2: two profiles → relation insight → deep_access → tips visible
func TestFlowRelationDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1988-03-01", "display_name": "我",
@@ -189,7 +212,7 @@ func TestFlowRelationDeepAccess(t *testing.T) {
// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access
func TestFlowMembershipUnlock(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1995-11-07", "display_name": "我",
@@ -232,7 +255,7 @@ func TestFlowMembershipUnlock(t *testing.T) {
// Flow 5: profile update + soft
func TestFlowProfileUpdateDelete(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend",
@@ -262,7 +285,7 @@ func TestFlowProfileUpdateDelete(t *testing.T) {
// Flow 4: profile → ask thread → message → assistant reply + quota
func TestFlowAskThread(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
@@ -308,3 +331,56 @@ func TestFlowAskThread(t *testing.T) {
t.Fatalf("expected user+assistant history, got %d", len(items))
}
}
// Flow 4b: exhaust free ask quota → buy ask_pack → can ask again
func TestFlowAskPackPurchase(t *testing.T) {
r, _ := setupAPI(t)
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
}, key)
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
"profile_id": profileID, "scene": "self",
}, key)
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
for i := 0; i < 3; i++ {
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
"content": "第几问",
}, key)
}
env, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
"content": "应该没额度了",
}, key, 40210)
if status != http.StatusPaymentRequired {
t.Fatalf("expected 402 after free exhaust, got %d %#v", status, env)
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "ask_pack", "plan": "pack10",
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key)
q := decodeData[map[string]any](t, env.Data)
rem, _ := q["remaining"].(float64)
paid, _ := q["paid_left"].(float64)
if rem < 10 || paid < 10 {
t.Fatalf("expected paid pack quota, got %#v", q)
}
env, _ = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
"content": "买完额度继续问",
}, key)
out := decodeData[map[string]any](t, env.Data)
q2, _ := out["quota"].(map[string]any)
rem2, _ := q2["remaining"].(float64)
if rem2 != rem-1 {
t.Fatalf("paid quota should decrease: before=%v after=%v", rem, rem2)
}
}
+10 -4
View File
@@ -22,6 +22,9 @@ func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, d
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
if testBearer != "" {
req.Header.Set("Authorization", "Bearer "+testBearer)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
@@ -61,7 +64,8 @@ func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profile
// Flow: star report → gated detail → mock deep unlock
func TestFlowStarDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1983-06-06", "")
key := mustRegister(t, r)
pid, key := createSelfProfile(t, r, "1983-06-06", key)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{
"profile_id": pid,
@@ -106,7 +110,8 @@ func TestFlowStarDeepAccess(t *testing.T) {
// Flow: rhythm report gated + unlock
func TestFlowRhythmDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1992-06-08", "")
key := mustRegister(t, r)
pid, key := createSelfProfile(t, r, "1992-06-08", key)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{
"profile_id": pid,
@@ -140,7 +145,8 @@ func TestFlowRhythmDeepAccess(t *testing.T) {
// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay
func TestFlowImageCardQuotaAndDepth(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1990-01-01", "")
key := mustRegister(t, r)
pid, key := createSelfProfile(t, r, "1990-01-01", key)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key)
scenes := decodeData[map[string]any](t, env.Data)
@@ -199,7 +205,7 @@ func TestFlowImageCardQuotaAndDepth(t *testing.T) {
// Flow: solar terms + mood save/read
func TestFlowCompanionMood(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key)
term := decodeData[map[string]any](t, env.Data)
@@ -7,7 +7,7 @@ import (
func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
r, _ := setupAPI(t)
var key string
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
@@ -67,9 +67,8 @@ func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
t.Fatal("empty token")
}
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
_ = env2
env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2)
env2Key := mustRegister(t, r)
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, env2Key)
meta := decodeData[map[string]any](t, env2.Data)
if meta["host_name"] == nil {
t.Fatal("host_name missing")