merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)

保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:45:53 +08:00
co-authored by Cursor
593 changed files with 21918 additions and 328 deletions
@@ -0,0 +1,56 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrSystemPromptNotFound = errString("system prompt not found")
var ErrKnowledgeSourceNotFound = errString("knowledge source not found")
// ListSystemPrompts returns SystemPrompt catalog.
func (s *Service) ListSystemPrompts(ctx context.Context) ([]repository.SystemPromptRow, error) {
items, err := s.Repo.ListSystemPrompts(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.SystemPromptRow{}
}
return items, nil
}
// GetSystemPrompt loads one prompt.
func (s *Service) GetSystemPrompt(ctx context.Context, id uuid.UUID) (*repository.SystemPromptRow, error) {
row, err := s.Repo.GetSystemPrompt(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSystemPromptNotFound
}
return row, err
}
// ListKnowledgeSources returns KnowledgeSource catalog.
func (s *Service) ListKnowledgeSources(ctx context.Context) ([]repository.KnowledgeSourceRow, error) {
items, err := s.Repo.ListKnowledgeSources(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.KnowledgeSourceRow{}
}
return items, nil
}
// GetKnowledgeSource loads one source.
func (s *Service) GetKnowledgeSource(ctx context.Context, id uuid.UUID) (*repository.KnowledgeSourceRow, error) {
row, err := s.Repo.GetKnowledgeSource(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrKnowledgeSourceNotFound
}
return row, err
}
@@ -0,0 +1,50 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// AskSessionDetail is AskSessionView plus messages.
type AskSessionDetail struct {
repository.AskSessionView
Messages []repository.AskMessageView `json:"messages"`
}
var ErrAskThreadNotFound = errString("ask thread not found")
// ListAskSessions lists AskSessionView rows.
func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]repository.AskSessionView, error) {
items, err := s.Repo.ListAskSessions(ctx, userID, limit, offset)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.AskSessionView{}
}
return items, nil
}
// GetAskSessionDetail loads meta + messages.
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
view, err := s.Repo.GetAskSession(ctx, threadID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAskThreadNotFound
}
if err != nil {
return nil, err
}
msgs, err := s.Repo.ListAskMessagesForAdmin(ctx, threadID)
if err != nil {
return nil, err
}
if msgs == nil {
msgs = []repository.AskMessageView{}
}
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrBlockPolicyNotFound = errString("block policy not found")
// ListBlockPolicies returns catalog.
func (s *Service) ListBlockPolicies(ctx context.Context) ([]repository.BlockPolicyRow, error) {
items, err := s.Repo.ListBlockPolicies(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.BlockPolicyRow{}
}
return items, nil
}
// GetBlockPolicy loads one.
func (s *Service) GetBlockPolicy(ctx context.Context, id uuid.UUID) (*repository.BlockPolicyRow, error) {
row, err := s.Repo.GetBlockPolicy(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrBlockPolicyNotFound
}
return row, err
}
+56
View File
@@ -0,0 +1,56 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrBannerNotFound = errString("banner not found")
var ErrFeedSlotNotFound = errString("feed slot not found")
// ListBanners returns OpsCMS Banner catalog.
func (s *Service) ListBanners(ctx context.Context) ([]repository.BannerRow, error) {
items, err := s.Repo.ListBanners(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.BannerRow{}
}
return items, nil
}
// GetBanner loads one banner.
func (s *Service) GetBanner(ctx context.Context, id uuid.UUID) (*repository.BannerRow, error) {
row, err := s.Repo.GetBanner(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrBannerNotFound
}
return row, err
}
// ListFeedSlots returns OpsCMS FeedSlot catalog.
func (s *Service) ListFeedSlots(ctx context.Context) ([]repository.FeedSlotRow, error) {
items, err := s.Repo.ListFeedSlots(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.FeedSlotRow{}
}
return items, nil
}
// GetFeedSlot loads one feed slot.
func (s *Service) GetFeedSlot(ctx context.Context, id uuid.UUID) (*repository.FeedSlotRow, error) {
row, err := s.Repo.GetFeedSlot(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrFeedSlotNotFound
}
return row, err
}
@@ -45,6 +45,18 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
return s.Scales.ListAllAdmin(ctx)
}
// GetScaleAdmin loads one scale for explore read projection.
func (s *Service) GetScaleAdmin(ctx context.Context, id uuid.UUID) (*repository.ScaleAdminItem, error) {
if s.Scales == nil {
return nil, errors.New("scales unavailable")
}
row, err := s.Scales.GetAdmin(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScaleNotFound
}
return row, err
}
// PatchScaleStatus updates published|draft and audits in one transaction.
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
@@ -0,0 +1,39 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrFilterRuleNotFound = errString("filter rule not found")
// ListFilterRules returns FilterRule catalog.
func (s *Service) ListFilterRules(ctx context.Context) ([]repository.FilterRuleRow, error) {
items, err := s.Repo.ListFilterRules(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.FilterRuleRow{}
}
return items, nil
}
// GetFilterRule loads one rule.
func (s *Service) GetFilterRule(ctx context.Context, id uuid.UUID) (*repository.FilterRuleRow, error) {
row, err := s.Repo.GetFilterRule(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrFilterRuleNotFound
}
return row, err
}
// EvaluateContent runs read-only filter preview.
func (s *Service) EvaluateContent(ctx context.Context, text string) ([]repository.FilterMatch, error) {
return s.Repo.EvaluateFilterRules(ctx, text)
}
+39
View File
@@ -0,0 +1,39 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrCrisisPolicyNotFound = errString("crisis policy not found")
// ListCrisisPolicies returns CrisisPolicy catalog.
func (s *Service) ListCrisisPolicies(ctx context.Context) ([]repository.CrisisPolicyRow, error) {
items, err := s.Repo.ListCrisisPolicies(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.CrisisPolicyRow{}
}
return items, nil
}
// GetCrisisPolicy loads one policy.
func (s *Service) GetCrisisPolicy(ctx context.Context, id uuid.UUID) (*repository.CrisisPolicyRow, error) {
row, err := s.Repo.GetCrisisPolicy(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCrisisPolicyNotFound
}
return row, err
}
// EvaluateCrisis runs read-only policy preview.
func (s *Service) EvaluateCrisis(ctx context.Context, text string) ([]repository.CrisisMatch, error) {
return s.Repo.EvaluateCrisisPolicies(ctx, text)
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrCrisisEventNotFound = errString("crisis event not found")
// ListCrisisEvents returns catalog.
func (s *Service) ListCrisisEvents(ctx context.Context) ([]repository.CrisisEventRow, error) {
items, err := s.Repo.ListCrisisEvents(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.CrisisEventRow{}
}
return items, nil
}
// GetCrisisEvent loads one.
func (s *Service) GetCrisisEvent(ctx context.Context, id uuid.UUID) (*repository.CrisisEventRow, error) {
row, err := s.Repo.GetCrisisEvent(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCrisisEventNotFound
}
return row, err
}
@@ -0,0 +1,64 @@
package admin
import (
"context"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// EntitlementFlags summarizes effective rights.
type EntitlementFlags struct {
ReportDetailViaMembership bool `json:"report_detail_via_membership"`
DeepAccessCount int `json:"deep_access_count"`
}
// UserEntitlement is the CommerceEntitlement ops read model.
type UserEntitlement struct {
UserID uuid.UUID `json:"user_id"`
Membership *repository.MembershipRow `json:"membership"`
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
Flags EntitlementFlags `json:"flags"`
DeepAccesses []repository.DeepAccessBrief `json:"deep_accesses"`
}
// GetUserEntitlement aggregates membership + deep access + quotas.
func (s *Service) GetUserEntitlement(ctx context.Context, userID uuid.UUID) (*UserEntitlement, error) {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
_, _, paidLeft, _, _, err := s.Repo.GetUserAccount(ctx, userID)
if err != nil {
return nil, err
}
mem, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
count, err := s.Repo.CountDeepAccessForUser(ctx, userID)
if err != nil {
return nil, err
}
items, err := s.Repo.ListDeepAccessForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.DeepAccessBrief{}
}
return &UserEntitlement{
UserID: userID,
Membership: mem,
AskPaidQuotaLeft: paidLeft,
Flags: EntitlementFlags{
ReportDetailViaMembership: mem != nil && mem.Active,
DeepAccessCount: count,
},
DeepAccesses: items,
}, nil
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrFunnelDefinitionNotFound = errString("funnel definition not found")
// ListFunnelDefinitions returns catalog.
func (s *Service) ListFunnelDefinitions(ctx context.Context) ([]repository.FunnelDefinitionRow, error) {
items, err := s.Repo.ListFunnelDefinitions(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.FunnelDefinitionRow{}
}
return items, nil
}
// GetFunnelDefinition loads one.
func (s *Service) GetFunnelDefinition(ctx context.Context, id uuid.UUID) (*repository.FunnelDefinitionRow, error) {
row, err := s.Repo.GetFunnelDefinition(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrFunnelDefinitionNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrHandoffCaseNotFound = errString("handoff case not found")
// ListHandoffCases returns catalog.
func (s *Service) ListHandoffCases(ctx context.Context) ([]repository.HandoffCaseRow, error) {
items, err := s.Repo.ListHandoffCases(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.HandoffCaseRow{}
}
return items, nil
}
// GetHandoffCase loads one.
func (s *Service) GetHandoffCase(ctx context.Context, id uuid.UUID) (*repository.HandoffCaseRow, error) {
row, err := s.Repo.GetHandoffCase(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrHandoffCaseNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrImageCardDeckNotFound = errString("image card deck not found")
// ListImageCardDecks returns catalog.
func (s *Service) ListImageCardDecks(ctx context.Context) ([]repository.ImageCardDeckRow, error) {
items, err := s.Repo.ListImageCardDecks(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ImageCardDeckRow{}
}
return items, nil
}
// GetImageCardDeck loads one.
func (s *Service) GetImageCardDeck(ctx context.Context, id uuid.UUID) (*repository.ImageCardDeckRow, error) {
row, err := s.Repo.GetImageCardDeck(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrImageCardDeckNotFound
}
return row, err
}
+106
View File
@@ -0,0 +1,106 @@
package admin
import (
"context"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// PsychTag is a stable label derived from report types (not NLP).
type PsychTag struct {
Code string `json:"code"`
Label string `json:"label"`
}
// BehaviorSnapshot is a thin ops read of recent activity.
type BehaviorSnapshot struct {
Events []repository.BehaviorEventBrief `json:"events"`
AskThreadCount int `json:"ask_thread_count"`
}
// UserInsight is the UserIntelligence admin read model.
type UserInsight struct {
UserID uuid.UUID `json:"user_id"`
ProfilesCount int `json:"profiles_count"`
ReportsByType []repository.ReportTypeCount `json:"reports_by_type"`
RecentReports []repository.ReportBrief `json:"recent_reports"`
Tags []PsychTag `json:"tags"`
Behavior BehaviorSnapshot `json:"behavior"`
}
var reportTypeLabels = map[string]string{
"portrait": "愈心解码",
"star": "星座",
"rhythm": "节律",
"relation": "关系",
"synastry": "合盘",
"image_card": "意象卡",
}
// GetUserInsight aggregates read-only ops insight for one user.
func (s *Service) GetUserInsight(ctx context.Context, userID uuid.UUID) (*UserInsight, error) {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
pc, err := s.Repo.CountProfilesForUser(ctx, userID)
if err != nil {
return nil, err
}
byType, err := s.Repo.CountReportsByType(ctx, userID)
if err != nil {
return nil, err
}
if byType == nil {
byType = []repository.ReportTypeCount{}
}
reports, err := s.Repo.ListReportsForUser(ctx, userID, 10)
if err != nil {
return nil, err
}
if reports == nil {
reports = []repository.ReportBrief{}
}
events, err := s.Repo.ListRecentEventsForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
if events == nil {
events = []repository.BehaviorEventBrief{}
}
askN, err := s.Repo.CountAskThreadsForUser(ctx, userID)
if err != nil {
return nil, err
}
return &UserInsight{
UserID: userID,
ProfilesCount: pc,
ReportsByType: byType,
RecentReports: reports,
Tags: tagsFromReportTypes(byType),
Behavior: BehaviorSnapshot{
Events: events,
AskThreadCount: askN,
},
}, nil
}
func tagsFromReportTypes(counts []repository.ReportTypeCount) []PsychTag {
out := make([]PsychTag, 0, len(counts))
for _, c := range counts {
if c.Count <= 0 {
continue
}
label := reportTypeLabels[c.Type]
if label == "" {
label = c.Type
}
out = append(out, PsychTag{Code: c.Type, Label: label})
}
return out
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrInterventionOutcomeNotFound = errString("intervention outcome not found")
// ListInterventionOutcomes returns catalog.
func (s *Service) ListInterventionOutcomes(ctx context.Context) ([]repository.InterventionOutcomeRow, error) {
items, err := s.Repo.ListInterventionOutcomes(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.InterventionOutcomeRow{}
}
return items, nil
}
// GetInterventionOutcome loads one.
func (s *Service) GetInterventionOutcome(ctx context.Context, id uuid.UUID) (*repository.InterventionOutcomeRow, error) {
row, err := s.Repo.GetInterventionOutcome(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInterventionOutcomeNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrKnowledgeChunkNotFound = errString("knowledge chunk not found")
// ListKnowledgeChunks returns catalog.
func (s *Service) ListKnowledgeChunks(ctx context.Context) ([]repository.KnowledgeChunkRow, error) {
items, err := s.Repo.ListKnowledgeChunks(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.KnowledgeChunkRow{}
}
return items, nil
}
// GetKnowledgeChunk loads one.
func (s *Service) GetKnowledgeChunk(ctx context.Context, id uuid.UUID) (*repository.KnowledgeChunkRow, error) {
row, err := s.Repo.GetKnowledgeChunk(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrKnowledgeChunkNotFound
}
return row, err
}
@@ -0,0 +1,76 @@
package admin
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidStatusEdge = errString("invalid status transition")
ErrReasonRequired = errString("reason required")
)
var allowedStatusEdges = map[string]map[string]struct{}{
"active": {"disabled": {}, "banned": {}, "suspended": {}},
"disabled": {"active": {}, "banned": {}},
"suspended": {"active": {}, "banned": {}, "disabled": {}},
"banned": {"active": {}, "disabled": {}},
}
// TransitionUserStatus migrates UserStatus with audit.
func (s *Service) TransitionUserStatus(ctx context.Context, adminID, userID uuid.UUID, toStatus, reason string) error {
toStatus = strings.TrimSpace(toStatus)
reason = strings.TrimSpace(reason)
if reason == "" {
return ErrReasonRequired
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
from, err := s.Repo.GetUserStatus(ctx, userID)
if err != nil || from == "" {
return ErrUserNotFound
}
if from == toStatus {
return ErrInvalidStatusEdge
}
next, okEdge := allowedStatusEdges[from]
if !okEdge {
return ErrInvalidStatusEdge
}
if _, okEdge = next[toStatus]; !okEdge {
return ErrInvalidStatusEdge
}
meta, _ := json.Marshal(map[string]string{
"from": from, "to": toStatus, "reason": reason,
})
return s.Repo.TransitionUserStatusWithAudit(ctx, adminID, userID, from, toStatus, reason, meta)
}
// ListStatusTransitions returns recent transitions.
func (s *Service) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]repository.AccountTransition, error) {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
items, err := s.Repo.ListStatusTransitions(ctx, userID, limit)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.AccountTransition{}
}
return items, nil
}
@@ -0,0 +1,86 @@
package admin
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrPlanNotFound = errString("plan not found")
ErrInvalidPlanU = errString("invalid plan update")
)
// ListMembershipPlans returns catalog.
func (s *Service) ListMembershipPlans(ctx context.Context) ([]repository.MembershipPlanRow, error) {
items, err := s.Repo.ListMembershipPlans(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.MembershipPlanRow{}
}
return items, nil
}
// GetMembershipPlan returns one plan.
func (s *Service) GetMembershipPlan(ctx context.Context, code string) (*repository.MembershipPlanRow, error) {
p, err := s.Repo.GetMembershipPlan(ctx, code)
if err != nil {
return nil, err
}
if p == nil {
return nil, ErrPlanNotFound
}
return p, nil
}
// UpdateMembershipPlan updates mutable fields.
func (s *Service) UpdateMembershipPlan(
ctx context.Context, adminID uuid.UUID, code, title string, days, amount int, active bool,
) (*repository.MembershipPlanRow, error) {
code = strings.TrimSpace(code)
title = strings.TrimSpace(title)
if title == "" || days <= 0 || amount < 0 {
return nil, ErrInvalidPlanU
}
if _, err := s.GetMembershipPlan(ctx, code); err != nil {
return nil, err
}
meta, _ := json.Marshal(map[string]any{
"title": title, "duration_days": days, "amount_cents": amount, "active": active,
})
if err := s.Repo.UpdateMembershipPlanWithAudit(ctx, adminID, code, title, days, amount, active, meta); err != nil {
return nil, err
}
return s.GetMembershipPlan(ctx, code)
}
// PlanDurationDays resolves grant length from catalog with hardcoded fallback.
func (s *Service) PlanDurationDays(ctx context.Context, plan string) (int, error) {
p, err := s.Repo.GetMembershipPlan(ctx, plan)
if err != nil {
return 0, err
}
if p != nil && p.Active && p.DurationDays > 0 {
return p.DurationDays, nil
}
return planDaysFallback(plan)
}
func planDaysFallback(plan string) (int, error) {
switch plan {
case "month":
return 31, nil
case "quarter":
return 92, nil
case "year":
return 366, nil
default:
return 0, ErrInvalidPlan
}
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrModerationCaseNotFound = errString("moderation case not found")
// ListModerationCases returns catalog.
func (s *Service) ListModerationCases(ctx context.Context) ([]repository.ModerationCaseRow, error) {
items, err := s.Repo.ListModerationCases(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ModerationCaseRow{}
}
return items, nil
}
// GetModerationCase loads one.
func (s *Service) GetModerationCase(ctx context.Context, id uuid.UUID) (*repository.ModerationCaseRow, error) {
row, err := s.Repo.GetModerationCase(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrModerationCaseNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrPrivacyRequestNotFound = errString("privacy request not found")
// ListPrivacyRequests returns catalog.
func (s *Service) ListPrivacyRequests(ctx context.Context) ([]repository.PrivacyRequestRow, error) {
items, err := s.Repo.ListPrivacyRequests(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.PrivacyRequestRow{}
}
return items, nil
}
// GetPrivacyRequest loads one.
func (s *Service) GetPrivacyRequest(ctx context.Context, id uuid.UUID) (*repository.PrivacyRequestRow, error) {
row, err := s.Repo.GetPrivacyRequest(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPrivacyRequestNotFound
}
return row, err
}
@@ -0,0 +1,60 @@
package admin
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrBadFeedbackRating = errString("rating must be 1-5")
ErrBadFeedbackTag = errString("invalid tag")
ErrFeedbackNoteLong = errString("note too long")
)
// ListQualityFeedback lists recent QualityFeedback.
func (s *Service) ListQualityFeedback(ctx context.Context, limit, offset int) ([]repository.QualityFeedbackRow, error) {
items, err := s.Repo.ListQualityFeedback(ctx, limit, offset)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.QualityFeedbackRow{}
}
return items, nil
}
// CreateQualityFeedback is ops-submitted feedback.
func (s *Service) CreateQualityFeedback(
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note string,
) (*repository.QualityFeedbackRow, error) {
var tagPtr, notePtr *string
if strings.TrimSpace(tag) != "" {
t := strings.TrimSpace(tag)
tagPtr = &t
}
if strings.TrimSpace(note) != "" {
n := strings.TrimSpace(note)
notePtr = &n
}
row, err := s.Repo.CreateAdminQualityFeedback(ctx, adminID, threadID, messageID, rating, tagPtr, notePtr)
if err == nil {
return row, nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "rating"):
return nil, ErrBadFeedbackRating
case strings.Contains(msg, "tag"):
return nil, ErrBadFeedbackTag
case strings.Contains(msg, "note"):
return nil, ErrFeedbackNoteLong
case strings.Contains(msg, "thread not found"):
return nil, ErrAskThreadNotFound
default:
return nil, err
}
}
+152
View File
@@ -0,0 +1,152 @@
package admin
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
)
// Permission catalog frozen in ECR-013A Spec.
const (
PermUsersRead = "admin.users.read"
PermMembershipGrant = "admin.users.membership.grant"
PermAskQuotaGrant = "admin.users.ask_quota.grant"
PermOrdersRead = "admin.orders.read"
PermAuditRead = "admin.audit.read"
PermAnalyticsRead = "admin.analytics.read"
PermContentWrite = "admin.content.write"
PermRolesRead = "admin.roles.read"
PermRolesWrite = "admin.roles.write"
PermUsersStatusWrite = "admin.users.status.write"
PermMembershipPlansRead = "admin.membership.plans.read"
PermMembershipPlansWrite = "admin.membership.plans.write"
PermMembershipCodesRead = "admin.membership.codes.read"
PermMembershipCodesWrite = "admin.membership.codes.write"
PermAskRead = "admin.ask.read"
PermAskFeedbackWrite = "admin.ask.feedback.write"
PermContentSafetyRead = "admin.content_safety.read"
PermAIConfigRead = "admin.ai_config.read"
PermCrisisRead = "admin.crisis.read"
PermCMSRead = "admin.cms.read"
PermPrivacyRead = "admin.privacy.read"
PermExploreRead = "admin.explore.read"
PermGrowthRead = "admin.growth.read"
)
var knownPermissions = map[string]struct{}{
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
}
var (
ErrRoleNotFound = errString("role not found")
ErrInvalidPerm = errString("invalid permission code")
ErrProtectSystem = errString("system role protected")
)
// AdminMe is the public admin profile (with RBAC).
type AdminMe struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Role string `json:"role,omitempty"`
Permissions []string `json:"permissions"`
}
// RoleDTO is list/detail payload.
type RoleDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
System bool `json:"system"`
Permissions []string `json:"permissions,omitempty"`
}
// HasPermission reports whether admin holds code.
func (s *Service) HasPermission(ctx context.Context, adminID uuid.UUID, code string) (bool, error) {
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
if err != nil {
return false, err
}
for _, p := range perms {
if p == code {
return true, nil
}
}
return false, nil
}
// DenyPermission audits a forbidden attempt.
func (s *Service) DenyPermission(ctx context.Context, adminID uuid.UUID, code, path string) {
meta, _ := json.Marshal(map[string]string{"permission": code, "path": path})
_ = s.Repo.InsertAudit(ctx, adminID, "permission.denied", "permission", code, meta)
}
// Me returns the current admin profile with permissions.
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
acc, err := s.Repo.FindAccountByID(ctx, adminID)
if err != nil || acc == nil {
return nil, errors.New("admin not found")
}
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
if err != nil {
return nil, err
}
if perms == nil {
perms = []string{}
}
_, roleName, _ := s.Repo.GetAdminRoleMeta(ctx, adminID)
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: roleName, Permissions: perms}, nil
}
// ListRoles returns roles without permissions.
func (s *Service) ListRoles(ctx context.Context) ([]RoleDTO, error) {
roles, err := s.Repo.ListAdminRoles(ctx)
if err != nil {
return nil, err
}
out := make([]RoleDTO, 0, len(roles))
for _, r := range roles {
out = append(out, RoleDTO{ID: r.ID, Name: r.Name, System: r.System})
}
return out, nil
}
// GetRole returns role + permissions.
func (s *Service) GetRole(ctx context.Context, id uuid.UUID) (*RoleDTO, error) {
role, err := s.Repo.GetAdminRole(ctx, id)
if err != nil || role == nil {
return nil, ErrRoleNotFound
}
perms, err := s.Repo.ListRolePermissions(ctx, id)
if err != nil {
return nil, err
}
if perms == nil {
perms = []string{}
}
return &RoleDTO{ID: role.ID, Name: role.Name, System: role.System, Permissions: perms}, nil
}
// ReplaceRolePermissions updates permissions with audit.
func (s *Service) ReplaceRolePermissions(ctx context.Context, adminID, roleID uuid.UUID, codes []string) error {
role, err := s.Repo.GetAdminRole(ctx, roleID)
if err != nil || role == nil {
return ErrRoleNotFound
}
for _, c := range codes {
if _, ok := knownPermissions[c]; !ok {
return ErrInvalidPerm
}
}
if err := s.Repo.ReplaceRolePermissions(ctx, roleID, codes); err != nil {
return err
}
meta, _ := json.Marshal(map[string]any{"permissions": codes, "role": role.Name})
return s.Repo.InsertAudit(ctx, adminID, "roles.permissions.update", "admin_role", roleID.String(), meta)
}
@@ -0,0 +1,96 @@
package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrBadBatchQty = errString("quantity must be 1..100")
ErrCodeDisable = errString("code not unused")
ErrBatchNotFound = errString("batch not found")
)
// CreateRedemptionBatch generates codes for a membership plan.
func (s *Service) CreateRedemptionBatch(
ctx context.Context, adminID uuid.UUID, label, planCode string, qty int,
) (*repository.RedemptionBatch, []repository.RedemptionCodeRow, error) {
label = strings.TrimSpace(label)
planCode = strings.TrimSpace(planCode)
if label == "" || qty < 1 || qty > 100 {
return nil, nil, ErrBadBatchQty
}
if _, err := s.GetMembershipPlan(ctx, planCode); err != nil {
return nil, nil, err
}
codes := make([]string, 0, qty)
seen := map[string]struct{}{}
for len(codes) < qty {
c, err := newRedemptionCode()
if err != nil {
return nil, nil, err
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
codes = append(codes, c)
}
meta, _ := json.Marshal(map[string]any{"label": label, "plan": planCode, "quantity": qty})
return s.Repo.CreateRedemptionBatchWithCodes(ctx, adminID, label, planCode, codes, meta)
}
// ListRedemptionBatches lists recent batches.
func (s *Service) ListRedemptionBatches(ctx context.Context, limit int) ([]repository.RedemptionBatch, error) {
items, err := s.Repo.ListRedemptionBatches(ctx, limit)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.RedemptionBatch{}
}
return items, nil
}
// ListRedemptionCodes lists codes in a batch.
func (s *Service) ListRedemptionCodes(ctx context.Context, batchID uuid.UUID) ([]repository.RedemptionCodeRow, error) {
ok, err := s.Repo.BatchExists(ctx, batchID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrBatchNotFound
}
items, err := s.Repo.ListRedemptionCodesByBatch(ctx, batchID)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.RedemptionCodeRow{}
}
return items, nil
}
// DisableRedemptionCode voids an unused code.
func (s *Service) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error {
err := s.Repo.DisableRedemptionCode(ctx, adminID, codeID)
if err != nil && err.Error() == "code not unused" {
return ErrCodeDisable
}
return err
}
func newRedemptionCode() (string, error) {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "YXG-" + strings.ToUpper(hex.EncodeToString(b)), nil
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrReportTemplateNotFound = errString("report template not found")
// ListReportTemplates returns catalog.
func (s *Service) ListReportTemplates(ctx context.Context) ([]repository.ReportTemplateRow, error) {
items, err := s.Repo.ListReportTemplates(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ReportTemplateRow{}
}
return items, nil
}
// GetReportTemplate loads one.
func (s *Service) GetReportTemplate(ctx context.Context, id uuid.UUID) (*repository.ReportTemplateRow, error) {
row, err := s.Repo.GetReportTemplate(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReportTemplateNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrRhythmConfigNotFound = errString("rhythm config not found")
// ListRhythmConfigs returns catalog.
func (s *Service) ListRhythmConfigs(ctx context.Context) ([]repository.RhythmConfigRow, error) {
items, err := s.Repo.ListRhythmConfigs(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.RhythmConfigRow{}
}
return items, nil
}
// GetRhythmConfig loads one.
func (s *Service) GetRhythmConfig(ctx context.Context, id uuid.UUID) (*repository.RhythmConfigRow, error) {
row, err := s.Repo.GetRhythmConfig(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRhythmConfigNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrScheduledPublicationNotFound = errString("scheduled publication not found")
// ListScheduledPublications returns catalog.
func (s *Service) ListScheduledPublications(ctx context.Context) ([]repository.ScheduledPublicationRow, error) {
items, err := s.Repo.ListScheduledPublications(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ScheduledPublicationRow{}
}
return items, nil
}
// GetScheduledPublication loads one.
func (s *Service) GetScheduledPublication(ctx context.Context, id uuid.UUID) (*repository.ScheduledPublicationRow, error) {
row, err := s.Repo.GetScheduledPublication(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScheduledPublicationNotFound
}
return row, err
}
+7 -35
View File
@@ -6,7 +6,6 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
@@ -54,13 +53,6 @@ type LoginResult struct {
Admin AdminMe `json:"admin"`
}
// AdminMe is the public admin profile.
type AdminMe struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
var (
ErrBadCredentials = errString("invalid credentials")
ErrInvalidPlan = errString("invalid plan")
@@ -91,10 +83,14 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
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: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
Admin: *me,
}, nil
}
@@ -111,15 +107,6 @@ func (s *Service) Logout(ctx context.Context, token string) error {
return s.Repo.DeleteSession(ctx, token)
}
// Me returns the current admin profile.
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
acc, err := s.Repo.FindAccountByID(ctx, adminID)
if err != nil || acc == nil {
return nil, errors.New("admin not found")
}
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
}
// 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)
@@ -198,10 +185,7 @@ type GrantInput struct {
// GrantMembership extends membership and writes audit.
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
days, err := planDays(plan)
days, err := s.PlanDurationDays(ctx, plan)
if err != nil {
return err
}
@@ -225,9 +209,6 @@ 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 err := s.RequireSuper(ctx, adminID); err != nil {
return 0, err
}
if delta <= 0 || delta > 1000 {
return 0, ErrInvalidAskDelta
}
@@ -270,16 +251,7 @@ func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repos
}
func planDays(plan string) (int, error) {
switch plan {
case "month":
return 31, nil
case "quarter":
return 92, nil
case "year":
return 366, nil
default:
return 0, ErrInvalidPlan
}
return planDaysFallback(plan)
}
func newToken() (string, error) {
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrStarConfigNotFound = errString("star config not found")
// ListStarConfigs returns catalog.
func (s *Service) ListStarConfigs(ctx context.Context) ([]repository.StarConfigRow, error) {
items, err := s.Repo.ListStarConfigs(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.StarConfigRow{}
}
return items, nil
}
// GetStarConfig loads one.
func (s *Service) GetStarConfig(ctx context.Context, id uuid.UUID) (*repository.StarConfigRow, error) {
row, err := s.Repo.GetStarConfig(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrStarConfigNotFound
}
return row, err
}
@@ -0,0 +1,34 @@
package admin
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var ErrToolDefinitionNotFound = errString("tool definition not found")
// ListToolDefinitions returns catalog.
func (s *Service) ListToolDefinitions(ctx context.Context) ([]repository.ToolDefinitionRow, error) {
items, err := s.Repo.ListToolDefinitions(ctx)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.ToolDefinitionRow{}
}
return items, nil
}
// GetToolDefinition loads one.
func (s *Service) GetToolDefinition(ctx context.Context, id uuid.UUID) (*repository.ToolDefinitionRow, error) {
row, err := s.Repo.GetToolDefinition(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrToolDefinitionNotFound
}
return row, err
}
+42
View File
@@ -0,0 +1,42 @@
package ask
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// SubmitFeedbackInput for C-end QualityFeedback.
type SubmitFeedbackInput struct {
MessageID *uuid.UUID
Rating int
Tag string
Note string
}
// SubmitFeedback records user QualityFeedback on owned thread.
func (s *Service) SubmitFeedback(ctx context.Context, userID, threadID uuid.UUID, in SubmitFeedbackInput) (*repository.QualityFeedbackRow, error) {
var tagPtr, notePtr *string
if t := strings.TrimSpace(in.Tag); t != "" {
tagPtr = &t
}
if n := strings.TrimSpace(in.Note); n != "" {
notePtr = &n
}
row, err := s.Ask.CreateUserQualityFeedback(ctx, userID, threadID, in.MessageID, in.Rating, tagPtr, notePtr)
if err == nil {
return row, nil
}
msg := err.Error()
if strings.Contains(msg, "rating") || strings.Contains(msg, "tag") || strings.Contains(msg, "note") {
return nil, errors.New(msg)
}
if strings.Contains(msg, "thread not found") {
return nil, errors.New("ask thread not found")
}
return nil, err
}
@@ -42,6 +42,9 @@ type SessionResult struct {
IsNew bool `json:"is_new"`
}
// ErrAccountRestricted is returned when UserStatus is not active.
var ErrAccountRestricted = errors.New("账户已受限")
// Register upgrades or opens an account (same open rules as Login).
func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname)
@@ -74,6 +77,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
acc, err := s.Repo.GetByPhone(ctx, phone)
if err == nil {
if acc.Status != "active" {
return nil, ErrAccountRestricted
}
_ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
@@ -100,6 +106,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
uid := deviceUserID
if curErr == nil && cur.Phone == "" {
if cur.Status != "active" {
return nil, ErrAccountRestricted
}
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nick); err != nil {
return nil, errors.New("登录失败,请重试")
}
@@ -4,6 +4,7 @@ package membership
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
@@ -34,7 +35,17 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
amount := 990
plan := in.Plan
if in.Kind == "membership" {
amount = 2500
if plan == "" {
plan = "month"
}
a, err := s.Reports.MembershipPlanAmountCents(ctx, plan)
if err != nil {
return uuid.Nil, err
}
if a <= 0 {
return uuid.Nil, errors.New("invalid membership plan")
}
amount = a
}
if in.Kind == "ask_pack" {
if plan == "" {
@@ -48,6 +59,15 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount)
}
// Redeem applies a redemption code for the current registered user.
func (s *Service) Redeem(ctx context.Context, userID uuid.UUID, code string) (string, error) {
code = strings.TrimSpace(code)
if code == "" {
return "", errors.New("code required")
}
return s.Reports.RedeemCode(ctx, userID, code)
}
// PayMock completes mock payment.
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
return s.Reports.PayMock(ctx, userID, orderID)