Files
digital-psychology/apps/api/internal/service/admin/service.go
T
jackyu66gitandCursor 0d50c0ee73 fix(admin): 收紧 isSuper、plan-prices RBAC 与封禁状态机
避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:56:00 +08:00

289 lines
8.0 KiB
Go

// Package admin implements ops-console use cases (ECR-006).
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"time"
"github.com/google/uuid"
"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.
type BootstrapConfig struct {
Username string
Password string
}
// EnsureBootstrap creates the first admin from config when needed.
func (s *Service) EnsureBootstrap(ctx context.Context, cfg BootstrapConfig) error {
if cfg.Username == "" || cfg.Password == "" {
return nil
}
n, err := s.Repo.CountAccounts(ctx)
if err != nil || n > 0 {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.Repo.CreateAccount(ctx, cfg.Username, string(hash))
return err
}
// LoginResult is returned after successful login.
type LoginResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Admin AdminMe `json:"admin"`
}
var (
ErrBadCredentials = errString("invalid credentials")
ErrInvalidPlan = errString("invalid plan")
ErrUserNotFound = errString("user not found")
)
type errString string
func (e errString) Error() string { return string(e) }
// Login verifies password and issues a session token.
func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) {
acc, err := s.Repo.FindByUsername(ctx, username)
if err != nil {
return nil, err
}
if acc == nil || acc.Status != "active" {
return nil, ErrBadCredentials
}
if bcrypt.CompareHashAndPassword([]byte(acc.PasswordHash), []byte(password)) != nil {
return nil, ErrBadCredentials
}
token, err := newToken()
if err != nil {
return nil, err
}
exp := time.Now().UTC().Add(12 * time.Hour)
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
return nil, err
}
me, err := s.Me(ctx, acc.ID)
if err != nil {
return nil, err
}
return &LoginResult{
Token: token,
ExpiresAt: exp,
Admin: *me,
}, nil
}
// ResolveAdminID implements middleware.AdminSessionResolver.
func (s *Service) ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error) {
return s.Repo.ResolveSession(ctx, token)
}
// Logout deletes the session for token.
func (s *Service) Logout(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.Repo.DeleteSession(ctx, token)
}
// ListUsers lists terminal users.
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
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"`
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) {
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
}
reports, err := s.Repo.ListReportsForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
mem, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
orders, err := s.Repo.ListOrders(ctx, repository.OrderListFilter{
UserID: &userID,
Limit: 10,
Offset: 0,
})
if err != nil {
return nil, err
}
return &UserDetail{
ID: userID,
Status: status,
Phone: phone,
Nickname: nickname,
AskPaidQuotaLeft: paidLeft,
CreatedAt: createdAt,
Profiles: profiles,
Reports: reports,
Membership: mem,
Orders: orders,
}, nil
}
// GrantInput for membership grant.
type GrantInput struct {
Plan string `json:"plan"`
}
// GrantMembership extends membership and writes audit.
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
days, err := s.PlanDurationDays(ctx, plan)
if err != nil {
return err
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"plan": plan, "days": days})
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 with Ops-D filters.
func (s *Service) ListOrders(ctx context.Context, f repository.OrderListFilter) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, f)
}
// ListPlanPrices returns membership catalog display prices.
func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, error) {
return s.Repo.ListPlanPrices(ctx)
}
// UpsertPlanPrices updates display prices (not historical order amounts).
// Caller must enforce admin.membership.plans.write.
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
if len(items) == 0 {
return ErrInvalidPlan
}
meta, _ := json.Marshal(map[string]any{"count": len(items)})
return s.Repo.UpsertPlanPricesWithAudit(ctx, adminID, items, meta)
}
// ListAuditLogs lists audit entries.
func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repository.AuditListItem, error) {
return s.Repo.ListAuditLogs(ctx, limit, offset)
}
func planDays(plan string) (int, error) {
return planDaysFallback(plan)
}
func newToken() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "adm_" + hex.EncodeToString(b), nil
}
// OrderFilterFromQuery builds repository filter from HTTP query strings.
func OrderFilterFromQuery(userID, status, kind, from, to string, limit, offset int) repository.OrderListFilter {
f := repository.OrderListFilter{
Status: status,
Kind: kind,
Limit: limit,
Offset: offset,
}
if userID != "" {
if id, err := uuid.Parse(userID); err == nil {
f.UserID = &id
}
}
if from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
f.From = &t
}
}
if to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
end := t.Add(24 * time.Hour)
f.To = &end
}
}
return f
}