membership_plans 表、admin 套餐页、Grant/CreateOrder 读表; Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。 Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
6.7 KiB
Go
243 lines
6.7 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, &userID, 10, 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.
|
|
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
|
|
return s.Repo.ListOrders(ctx, nil, limit, offset)
|
|
}
|
|
|
|
// 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
|
|
}
|