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
@@ -0,0 +1,61 @@
package admin
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
)
var (
ErrInvalidScaleStatus = errors.New("invalid scale status")
ErrScaleNotFound = errors.New("scale not found")
)
// ListHomeTools returns all grid tools.
func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, error) {
if s.Home == nil {
return nil, errors.New("home unavailable")
}
return s.Home.ListAdmin(ctx)
}
// ReplaceHomeTools replaces grid and audits in one transaction.
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
if s.Home == nil {
return errors.New("home unavailable")
}
meta, _ := json.Marshal(map[string]any{"count": len(items)})
return s.Home.ReplaceWithAudit(ctx, adminID, items, meta)
}
// ListScalesAdmin returns all scales.
func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminItem, error) {
if s.Scales == nil {
return nil, errors.New("scales unavailable")
}
return s.Scales.ListAllAdmin(ctx)
}
// PatchScaleStatus updates published|draft and audits in one transaction.
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
if status != "published" && status != "draft" {
return ErrInvalidScaleStatus
}
if s.Scales == nil {
return errors.New("scales unavailable")
}
meta, _ := json.Marshal(map[string]any{"status": status})
if err := s.Scales.UpdateStatusWithAudit(ctx, scaleID, status, adminID, meta); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrScaleNotFound
}
return err
}
return nil
}
+64 -21
View File
@@ -13,12 +13,15 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
)
// Service is ops-admin application layer.
type Service struct {
Repo *repository.AdminRepo
Reports *repository.ReportRepo
Home *homesvc.Service
Scales *repository.ScaleRepo
}
// BootstrapConfig seeds the first admin when table is empty.
@@ -121,30 +124,43 @@ func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([
return s.Repo.ListUsers(ctx, q, limit, offset)
}
// DashboardStats exposes ops overview.
func (s *Service) DashboardStats(ctx context.Context) (*repository.DashboardStats, error) {
return s.Repo.GetDashboardStats(ctx)
}
// UserDetail is admin view of one user.
type UserDetail struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Profiles []repository.ProfileBrief `json:"profiles"`
Membership *repository.MembershipRow `json:"membership"`
Orders []repository.OrderListItem `json:"recent_orders"`
ID uuid.UUID `json:"id"`
Status string `json:"status"`
Phone *string `json:"phone,omitempty"`
Nickname *string `json:"nickname,omitempty"`
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
CreatedAt time.Time `json:"created_at"`
Profiles []repository.ProfileBrief `json:"profiles"`
Reports []repository.ReportBrief `json:"reports"`
Membership *repository.MembershipRow `json:"membership"`
Orders []repository.OrderListItem `json:"recent_orders"`
}
// GetUser loads user detail for admin.
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
ok, err := s.Repo.UserExists(ctx, userID)
phone, nickname, paidLeft, status, createdAt, err := s.Repo.GetUserAccount(ctx, userID)
if err != nil {
ok, e2 := s.Repo.UserExists(ctx, userID)
if e2 != nil {
return nil, e2
}
if !ok {
return nil, ErrUserNotFound
}
return nil, err
}
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
if err != nil || len(users) == 0 {
return nil, ErrUserNotFound
}
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
reports, err := s.Repo.ListReportsForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
@@ -157,12 +173,16 @@ func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, e
return nil, err
}
return &UserDetail{
ID: users[0].ID,
Status: users[0].Status,
CreatedAt: users[0].CreatedAt,
Profiles: profiles,
Membership: mem,
Orders: orders,
ID: userID,
Status: status,
Phone: phone,
Nickname: nickname,
AskPaidQuotaLeft: paidLeft,
CreatedAt: createdAt,
Profiles: profiles,
Reports: reports,
Membership: mem,
Orders: orders,
}, nil
}
@@ -188,6 +208,29 @@ func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
}
// GrantAskQuotaInput adds paid ask replies.
type GrantAskQuotaInput struct {
Delta int `json:"delta"`
}
var ErrInvalidAskDelta = errString("invalid ask quota delta")
// GrantAskQuota adds purchased ask quota and audits.
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
if delta <= 0 || delta > 1000 {
return 0, ErrInvalidAskDelta
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return 0, err
}
if !ok {
return 0, ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"delta": delta})
return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, meta)
}
// ListOrders lists commerce orders.
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, nil, limit, offset)