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:
@@ -3,5 +3,10 @@
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [../domain.md](../domain.md) | Tech ↔ 用户名 |
|
||||
| [domain-map.md](domain-map.md) | Bounded contexts(冻结) |
|
||||
| [domain-map.md](domain-map.md) | Bounded contexts(P1 冻结 + Ops Wave 0 Draft) |
|
||||
| [erd.md](erd.md) | P1 表结构草案(冻结) |
|
||||
| [boundary-rules.md](boundary-rules.md) | BC owns / allowed / forbidden(**P0** · Wave 0) |
|
||||
| [glossary.yaml](glossary.yaml) | 工程/API 唯一词(与 lexicon 双轨) |
|
||||
| [entity-catalog.md](entity-catalog.md) | 概念级实体目录(无 migration) |
|
||||
|
||||
Ops Contract-First 归档:`docs/WAVE0/`。
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Bounded Context Boundary Rules
|
||||
|
||||
> Wave 0 · **P0 资产** · AI / Engineer 只读;变更须 Architect + Human
|
||||
> 运行时权威副本:`.ai/domain/boundary-rules.md`
|
||||
|
||||
Status: `Draft`(Human Review 后 Frozen)
|
||||
|
||||
---
|
||||
|
||||
## 如何读
|
||||
|
||||
- **owns**:该 BC **唯一写权威**(修改入口)。
|
||||
- **does_not_own**:明示禁止「顺手改」。
|
||||
- **allowed**:允许的依赖(多为读或经门面)。
|
||||
- **forbidden**:禁止的依赖(含跨 BC 写支付/会员等)。
|
||||
|
||||
违反本文件 = Reviewer **BLOCK**(架构回归)。
|
||||
|
||||
---
|
||||
|
||||
## Admin_Auth_Audit
|
||||
|
||||
```yaml
|
||||
Admin_Auth_Audit:
|
||||
owns:
|
||||
- AdminAccount
|
||||
- AdminSession
|
||||
- AuditLog
|
||||
does_not_own:
|
||||
- UserStatus
|
||||
- MembershipPlan
|
||||
- Entitlement
|
||||
allowed:
|
||||
- Admin_RBAC.read
|
||||
forbidden:
|
||||
- Payment.write
|
||||
- C端业务聚合根的隐式写入
|
||||
```
|
||||
|
||||
## Admin_RBAC
|
||||
|
||||
```yaml
|
||||
Admin_RBAC:
|
||||
owns:
|
||||
- AdminRole
|
||||
- AdminPermission
|
||||
does_not_own:
|
||||
- User
|
||||
- Profile
|
||||
- Membership
|
||||
allowed:
|
||||
- Admin_Auth_Audit.write_audit
|
||||
forbidden:
|
||||
- Payment
|
||||
- Membership.write
|
||||
- DeviceAuth.user_token_issue
|
||||
```
|
||||
|
||||
## Identity_Profile
|
||||
|
||||
```yaml
|
||||
Identity_Profile:
|
||||
owns:
|
||||
- User
|
||||
- Profile
|
||||
- DeviceIdentity
|
||||
- UserStatus
|
||||
- BehaviorSnapshot
|
||||
- PsychologicalTagSet
|
||||
does_not_own:
|
||||
- MembershipPlan
|
||||
- Entitlement
|
||||
- Order
|
||||
- Payment
|
||||
- RedemptionCode
|
||||
allowed:
|
||||
- Analytics_OpsB.read
|
||||
forbidden:
|
||||
- Payment
|
||||
- Membership.write
|
||||
```
|
||||
|
||||
## Membership_Orders
|
||||
|
||||
```yaml
|
||||
Membership_Orders:
|
||||
owns:
|
||||
- MembershipPlan
|
||||
- Entitlement
|
||||
- Membership
|
||||
- Order
|
||||
- DeepAccess
|
||||
- RedemptionCode
|
||||
- Payment # mock 今;真支付适配器后置,仍归本 BC
|
||||
does_not_own:
|
||||
- UserStatus
|
||||
- AdminRole
|
||||
- FilterRule
|
||||
allowed:
|
||||
- Identity_Profile.read
|
||||
forbidden:
|
||||
- Ask_Ops.write_prompt
|
||||
- Content_Safety.rule_mutate_via_order
|
||||
```
|
||||
|
||||
## Analytics_OpsB
|
||||
|
||||
```yaml
|
||||
Analytics_OpsB:
|
||||
owns:
|
||||
- AnalyticsSession
|
||||
- AnalyticsEvent
|
||||
does_not_own:
|
||||
- User
|
||||
- Membership
|
||||
allowed:
|
||||
- Identity_Profile.read_ids_only
|
||||
forbidden:
|
||||
- Payment
|
||||
- 采集 PII 正文(生日/问答全文等)
|
||||
```
|
||||
|
||||
## Ops_Content
|
||||
|
||||
```yaml
|
||||
Ops_Content:
|
||||
owns:
|
||||
- HomeTool
|
||||
- ScalePublishState
|
||||
does_not_own:
|
||||
- Scale 题目正文编辑器(未开 ECR 前禁止扩张)
|
||||
allowed:
|
||||
- Explore_Reports.publish_gate
|
||||
forbidden:
|
||||
- Community
|
||||
- Payment
|
||||
```
|
||||
|
||||
## Account_Risk
|
||||
|
||||
```yaml
|
||||
Account_Risk:
|
||||
owns:
|
||||
- RiskFlag
|
||||
- BanRecord
|
||||
does_not_own:
|
||||
- MembershipPlan
|
||||
allowed:
|
||||
- Identity_Profile.UserStatus.transition # 经明确应用服务
|
||||
- Admin_Auth_Audit.write_audit
|
||||
forbidden:
|
||||
- Payment.refund_silent
|
||||
```
|
||||
|
||||
## Content_Safety
|
||||
|
||||
```yaml
|
||||
Content_Safety:
|
||||
owns:
|
||||
- FilterRule
|
||||
- ModerationCase
|
||||
- BlockPolicy
|
||||
- CrisisEvent
|
||||
- CrisisPolicy
|
||||
does_not_own:
|
||||
- Membership
|
||||
- Payment
|
||||
- AdminRole
|
||||
allowed:
|
||||
- Identity_Profile.read
|
||||
- Ask_Ops.read_session_meta
|
||||
forbidden:
|
||||
- Membership.write
|
||||
- Payment
|
||||
```
|
||||
|
||||
## Ask_Ops
|
||||
|
||||
```yaml
|
||||
Ask_Ops:
|
||||
owns:
|
||||
- AskSessionView
|
||||
- QualityFeedback
|
||||
- HandoffCase
|
||||
- SystemPrompt
|
||||
- KnowledgeSource
|
||||
- KnowledgeChunk
|
||||
- ToolDefinition
|
||||
does_not_own:
|
||||
- Payment
|
||||
- MembershipPlan
|
||||
- UserStatus
|
||||
allowed:
|
||||
- Identity_Profile.read
|
||||
- Content_Safety.evaluate
|
||||
forbidden:
|
||||
- Payment
|
||||
- Membership.write
|
||||
- Admin_RBAC.write
|
||||
```
|
||||
|
||||
## Ops_CMS_NoUGC
|
||||
|
||||
```yaml
|
||||
Ops_CMS_NoUGC:
|
||||
owns:
|
||||
- Banner
|
||||
- FeedSlot
|
||||
- ScheduledPublication
|
||||
does_not_own:
|
||||
- UGC Post
|
||||
- CommentGraph
|
||||
- TrustScore
|
||||
allowed:
|
||||
- Admin_RBAC.check
|
||||
forbidden:
|
||||
- Community
|
||||
- 用户生成内容入库为主路径
|
||||
```
|
||||
|
||||
## Community
|
||||
|
||||
```yaml
|
||||
Community:
|
||||
status: Forbidden
|
||||
owns: []
|
||||
note: feature-map UGC 广场 [No];开启须 L3 ADR + 新 Capability
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 反模式(禁止)
|
||||
|
||||
```text
|
||||
AskService → load User → update Membership → charge Payment
|
||||
UserService.updateMembership()
|
||||
Content_Safety 直接改 Entitlement
|
||||
Ops_CMS 引入「用户帖子」表却声称 NoUGC
|
||||
```
|
||||
@@ -23,6 +23,26 @@
|
||||
|
||||
---
|
||||
|
||||
## Ops Platform Bounded Contexts(Wave 0 Draft)
|
||||
|
||||
> 详细 owns/forbidden:[`boundary-rules.md`](boundary-rules.md) · 词表:[`glossary.yaml`](glossary.yaml) · 实体:[`entity-catalog.md`](entity-catalog.md)
|
||||
> 归档:`docs/WAVE0/domain/` · Capability:`docs/WAVE0/capability/ops-capability-map.md`
|
||||
> **不修改**上方 P1 不变量;Ops BC 实现须独立 ECR。
|
||||
|
||||
| Context | Capability | Status |
|
||||
|---|---|---|
|
||||
| Admin_Auth_Audit | AdminGovernance | Shipped Ops-A |
|
||||
| Admin_RBAC | AdminGovernance | Draft → ECR-013A |
|
||||
| Analytics_OpsB | GrowthInsights / UserIntelligence(read) | Shipped Ops-B |
|
||||
| Ops_Content | ExploreConfig (partial) | Shipped Ops-C |
|
||||
| Account_Risk | AccountLifecycle | Spec via ECR-013B(Identity_Profile owns UserStatus) |
|
||||
| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**;CrisisCare **ECR-022**(CrisisPolicy 只读) |
|
||||
| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017/020**;AICoreConfig **ECR-021**(SystemPrompt 只读) |
|
||||
| Ops_CMS_NoUGC | OpsCMS | Draft |
|
||||
| Community | — | **Forbidden** |
|
||||
|
||||
---
|
||||
|
||||
## Context diagram
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Entity Catalog(Wave 0 · 概念级)
|
||||
|
||||
> 仅登记名称、归属 BC、Capability、生命周期备注。**不建表、不写 migration。**
|
||||
> 与 [`glossary.yaml`](glossary.yaml) · [`boundary-rules.md`](boundary-rules.md) 一致。
|
||||
|
||||
Status: `Draft`
|
||||
|
||||
| Entity | BC | Capability | Notes |
|
||||
|--------|----|------------|-------|
|
||||
| AdminAccount | Admin_Auth_Audit | AdminGovernance | 已存在 Ops-A |
|
||||
| AdminSession | Admin_Auth_Audit | AdminGovernance | 已存在 |
|
||||
| AuditLog | Admin_Auth_Audit | AdminGovernance | 已存在;只追加 |
|
||||
| AdminRole | Admin_RBAC | AdminGovernance | **ECR-013A** |
|
||||
| AdminPermission | Admin_RBAC | AdminGovernance | **ECR-013A** |
|
||||
| User | Identity_Profile | AccountLifecycle | 已存在 |
|
||||
| Profile | Identity_Profile | UserIntelligence | 已存在 |
|
||||
| UserStatus | Identity_Profile | AccountLifecycle | **ECR-013B** 状态机 |
|
||||
| AccountStateTransition | Identity_Profile | AccountLifecycle | **ECR-013B** |
|
||||
| BehaviorSnapshot | Identity_Profile | UserIntelligence | **ECR-016** 读模型(analytics 聚合) |
|
||||
| PsychologicalTagSet | Identity_Profile | UserIntelligence | **ECR-016** 由报告 type 派生 |
|
||||
| MembershipPlan | Membership_Orders | CommerceEntitlement | **ECR-014 Closed** |
|
||||
| Entitlement | Membership_Orders | CommerceEntitlement | **ECR-018** 读模型(Membership∪DeepAccess) |
|
||||
| RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** |
|
||||
| Membership | Membership_Orders | CommerceEntitlement | 已存在 |
|
||||
| Order | Membership_Orders | CommerceEntitlement | 已存在 |
|
||||
| DeepAccess | Membership_Orders | CommerceEntitlement | 已存在 |
|
||||
| Payment | Membership_Orders | CommerceEntitlement | mock;真支付最后 |
|
||||
| HomeTool | Ops_Content | ExploreConfig | 已存在 Ops-C |
|
||||
| ScalePublishState | Ops_Content | ExploreConfig | 已存在 |
|
||||
| AnalyticsSession / Event | Analytics_OpsB | GrowthInsights | 已存在 Ops-B |
|
||||
| FilterRule | Content_Safety | ContentSafety | **ECR-019** 只读 + evaluate |
|
||||
| ModerationCase | Content_Safety | ContentSafety | 后置 |
|
||||
| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | **ECR-022** CrisisPolicy 只读;CrisisEvent 后置 |
|
||||
| SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | **ECR-021** SystemPrompt 只读;Knowledge/Tools 后置 |
|
||||
| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView;**ECR-020** QualityFeedback |
|
||||
| Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 |
|
||||
| UGC* | Community | — | **Forbidden** |
|
||||
|
||||
## 状态机(ECR-013B · 契约已开)
|
||||
|
||||
`UserStatus`: 见 `.ai/product/feature-spec/ops-account-lifecycle.md`。
|
||||
`deleted` soft-delete **不在 013B**(另开)。
|
||||
实现轮前:**禁止** migration / DeviceAuth 改动。
|
||||
@@ -0,0 +1,116 @@
|
||||
# domain glossary — engineering / API unique terms
|
||||
|
||||
# Wave 0 Draft. Runtime copy: .ai/domain/glossary.yaml
|
||||
# User-facing copy stays in .ai/product/lexicon.md
|
||||
# Conflict: Architect + Human resolve; do not invent synonyms in code.
|
||||
|
||||
version: "0.1.0"
|
||||
status: draft
|
||||
|
||||
terms:
|
||||
AdminRole:
|
||||
zh: 管理员角色
|
||||
bc: Admin_RBAC
|
||||
forbidden: [admin_group, privilege_group]
|
||||
|
||||
AdminPermission:
|
||||
zh: 管理员权限点
|
||||
bc: Admin_RBAC
|
||||
forbidden: [acl_flag, authz_bit]
|
||||
|
||||
AuditLog:
|
||||
zh: 操作审计日志
|
||||
bc: Admin_Auth_Audit
|
||||
forbidden: [admin_history]
|
||||
|
||||
UserStatus:
|
||||
zh: 用户账户状态
|
||||
bc: Identity_Profile
|
||||
values: [active, disabled, banned, suspended]
|
||||
forbidden: [user_flag, account_level]
|
||||
|
||||
AccountStateTransition:
|
||||
zh: 账户状态迁移
|
||||
bc: Identity_Profile
|
||||
forbidden: [status_patch_log]
|
||||
|
||||
MembershipPlan:
|
||||
zh: 会员套餐
|
||||
bc: Membership_Orders
|
||||
forbidden: [package, vip_level, 订阅档, sku_vip]
|
||||
|
||||
Entitlement:
|
||||
zh: 权益
|
||||
bc: Membership_Orders
|
||||
forbidden: [privilege_pack, benefit_bundle]
|
||||
|
||||
RedemptionCode:
|
||||
zh: 兑换码
|
||||
bc: Membership_Orders
|
||||
forbidden: [gift_card, activation_key_alias]
|
||||
|
||||
DeepAccess:
|
||||
zh: 深度版
|
||||
bc: Membership_Orders
|
||||
forbidden: [Unlock, unlock_report]
|
||||
|
||||
BehaviorSnapshot:
|
||||
zh: 行为快照
|
||||
bc: Identity_Profile
|
||||
forbidden: [user_track_dump]
|
||||
|
||||
PsychologicalTagSet:
|
||||
zh: 心理标签集
|
||||
bc: Identity_Profile
|
||||
forbidden: [psy_labels_raw]
|
||||
|
||||
FilterRule:
|
||||
zh: 过滤规则
|
||||
bc: Content_Safety
|
||||
forbidden: [badword_only]
|
||||
|
||||
ModerationCase:
|
||||
zh: 审核工单
|
||||
bc: Content_Safety
|
||||
forbidden: [review_ticket_ugc]
|
||||
|
||||
CrisisEvent:
|
||||
zh: 危机事件
|
||||
bc: Content_Safety
|
||||
forbidden: [suicide_flag_public]
|
||||
|
||||
CrisisPolicy:
|
||||
zh: 危机策略
|
||||
bc: Content_Safety
|
||||
forbidden: [kill_switch_generic]
|
||||
|
||||
SystemPrompt:
|
||||
zh: 系统提示词
|
||||
bc: Ask_Ops
|
||||
forbidden: [god_prompt]
|
||||
|
||||
KnowledgeSource:
|
||||
zh: 知识源
|
||||
bc: Ask_Ops
|
||||
forbidden: [rag_file]
|
||||
|
||||
KnowledgeChunk:
|
||||
zh: 知识块
|
||||
bc: Ask_Ops
|
||||
forbidden: [embedding_row_ui]
|
||||
|
||||
ToolDefinition:
|
||||
zh: 工具定义
|
||||
bc: Ask_Ops
|
||||
forbidden: [function_call_config_loose]
|
||||
|
||||
Banner:
|
||||
zh: 运营横幅
|
||||
bc: Ops_CMS_NoUGC
|
||||
forbidden: [story_feed_ugc]
|
||||
|
||||
Community:
|
||||
zh: (禁止能力)
|
||||
bc: Community
|
||||
status: forbidden
|
||||
forbidden: [ugc_plaza, 社区广场]
|
||||
@@ -228,15 +228,17 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
## 7. 运营后台(内部 · 非 C 端 Tab)
|
||||
|
||||
详见 Spec:[feature-spec/ops-admin.md](feature-spec/ops-admin.md) · ECR-006
|
||||
**演进规范(Wave 0):** [`docs/WAVE0/`](../../docs/WAVE0/) · Capability Map · boundary-rules · Contract-First(禁止 Feature 堆砌进实现)
|
||||
|
||||
| 分期 | 内容 |
|
||||
|---|---|
|
||||
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) |
|
||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Implemented** · Spec `ops-analytics.md`) |
|
||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Implemented** · Spec `ops-content.md`) |
|
||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007** · Spec `ops-analytics.md`) |
|
||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008** · Spec `ops-content.md`) |
|
||||
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md`) |
|
||||
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md`) |
|
||||
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵(各开独立 ECR) |
|
||||
| Phase D+ Contract | **Contract-First** 扩展目录:**ECR-013A…040**(RBAC/兑换码/问答运营/安全/危机/CMS/ExploreConfig 等)。见 `docs/WAVE0/` |
|
||||
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵;**UGC 仍 `[No]`**;真支付最后 |
|
||||
|
||||
不计入 P1 Complete;不进入五 Tab。
|
||||
|
||||
@@ -258,11 +260,11 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
| `/scales/:slug` | 探索测试作答 | |
|
||||
| `/reports` | 成长报告列表 | |
|
||||
| `/reports/:id` | 成长报告详情 | |
|
||||
| `/star` | 星座 | P2 |
|
||||
| `/synastry` | 合盘 | P2 |
|
||||
| `/synastry/invite/:token` | 合盘邀请 | P2 |
|
||||
| `/rhythm` | 身心节律 | P2 |
|
||||
| `/cards` | 意象卡片 | P2 |
|
||||
| `/star` | 星座 | P2 Complete |
|
||||
| `/synastry` | 合盘 | P2 Complete |
|
||||
| `/synastry/invite/:token` | 合盘邀请 | P2 Complete |
|
||||
| `/rhythm` | 身心节律 | P2 Complete |
|
||||
| `/cards` | 意象卡片 | P2 Complete |
|
||||
|
||||
兼容重定向:`/decode` → `/portrait`(仅技术兼容,UI 不出现 decode 文案)。
|
||||
|
||||
|
||||
@@ -21,11 +21,38 @@
|
||||
| [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 Complete |
|
||||
| [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 Complete |
|
||||
| [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A |
|
||||
| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Implemented |
|
||||
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Implemented |
|
||||
| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 |
|
||||
| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 |
|
||||
| [ops-commerce.md](ops-commerce.md) | 运营商业加深(订单筛选+展示价+退款只读) | §7 | `admin-h5` `/orders` `/pricing` | Ops-D · ECR-009 |
|
||||
| [ops-system.md](ops-system.md) | 运营系统(RBAC+封禁+推送占位) | §7 | `admin-h5` `/push` `/admins` | Ops-E · ECR-010 |
|
||||
| [input-compliance.md](input-compliance.md) | 用户文字输入合规 | 横切 · 安全 | 各写接口 · code 40060 | P2 · ECR-012 |
|
||||
| [ops-rbac.md](ops-rbac.md) | 运营 RBAC | §7 | `admin-h5` `/me` permissions · `/admin/roles*` | Ops · **ECR-013A** |
|
||||
| [ops-redemption-code.md](ops-redemption-code.md) | 兑换码 RedemptionCode | §7 | `admin-h5` `/codes` · `POST /membership/redeem` | Ops · **ECR-015(local)** |
|
||||
| [ops-user-intelligence.md](ops-user-intelligence.md) | 用户洞察 UserIntelligence | §7 | `admin-h5` 用户详情「洞察」 | Ops · **ECR-016(local)** |
|
||||
| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` | Ops · **ECR-017** |
|
||||
| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」 | Ops · **ECR-018** |
|
||||
| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` | Ops · **ECR-019** |
|
||||
| [ops-quality-feedback.md](ops-quality-feedback.md) | 问答质量反馈 QualityFeedback | §7 | `admin-h5` `/ask` | Ops · **ECR-020** |
|
||||
| [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` | Ops · **ECR-021** |
|
||||
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` | Ops · **ECR-022** |
|
||||
| [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` | Ops · **ECR-023** |
|
||||
| [ops-banner.md](ops-banner.md) | OpsCMS Banner | §7 | `admin-h5` `/cms` | Ops · **ECR-024** |
|
||||
| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot | §7 | `admin-h5` `/cms` | Ops · **ECR-025** |
|
||||
| [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `/admin/cms/publications*` | Ops · **ECR-026** |
|
||||
| [ops-knowledge-chunk.md](ops-knowledge-chunk.md) | AICoreConfig KnowledgeChunk | §7 | `/admin/ai/knowledge-chunks*` | Ops · **ECR-027** |
|
||||
| [ops-tool-definition.md](ops-tool-definition.md) | AICoreConfig ToolDefinition | §7 | `/admin/ai/tools*` | Ops · **ECR-028** |
|
||||
| [ops-block-policy.md](ops-block-policy.md) | ContentSafety BlockPolicy | §7 | `/admin/content-safety/block-policies*` | Ops · **ECR-029** |
|
||||
| [ops-moderation-case.md](ops-moderation-case.md) | ContentSafety ModerationCase | §7 | `/admin/content-safety/cases*` | Ops · **ECR-030** |
|
||||
| [ops-crisis-event.md](ops-crisis-event.md) | CrisisCare CrisisEvent | §7 | `/admin/crisis/events*` | Ops · **ECR-031** |
|
||||
| [ops-intervention-outcome.md](ops-intervention-outcome.md) | CrisisCare InterventionOutcome | §7 | `/admin/crisis/interventions*` | Ops · **ECR-032** |
|
||||
| [ops-handoff-case.md](ops-handoff-case.md) | AskOperations HandoffCase | §7 | `/admin/ask/handoffs*` | Ops · **ECR-033** |
|
||||
| [ops-privacy-request.md](ops-privacy-request.md) | AdminGovernance PrivacyRequest | §7 | `/admin/privacy/requests*` | Ops · **ECR-034** |
|
||||
| [ops-star-config.md](ops-star-config.md) | ExploreConfig StarConfig | §7 | `/admin/explore/star-configs*` | Ops · **ECR-035** |
|
||||
| [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** |
|
||||
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
|
||||
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
|
||||
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** |
|
||||
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition | §7 | `/admin/explore/scales*` | Ops · **ECR-040** |
|
||||
|
||||
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Feature Spec: 账户生命周期 / UserStatus(Ops · ECR-013B)
|
||||
|
||||
> Status: `Active`(**Closed** · Feature Complete)· Map: `§7 运营后台` · Phase: `Ops-D`
|
||||
> Parent: **WAVE0-FROZEN** (`27f27a1`) · Predecessor: **ECR-013A Closed**
|
||||
> ESS: `docs/ECR/ECR-013B-account-lifecycle.md`(**Closed**)
|
||||
> Capability: `AccountLifecycle` · BC: `Identity_Profile`
|
||||
> 模板:`docs/WAVE0/contracts/OPS-CONTRACT-TEMPLATE.md`
|
||||
|
||||
---
|
||||
|
||||
## ESS 门禁
|
||||
|
||||
1. Change Level = **L2** → 已 Approved + Coding auth + Implemented + **Closed**
|
||||
2. ~~Contract Definition only~~ — 已完成
|
||||
3. 实现证据:`docs/TEST_REPORT/ECR-013B.md` · `docs/CODE_REVIEW/ECR-013B.md`
|
||||
|
||||
---
|
||||
|
||||
## L0 Capability
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Capability ID | `AccountLifecycle` |
|
||||
| Purpose | 以可审计状态机管理 C 端用户账户启停与封禁,阻断违规会话 |
|
||||
| Why now | `users.status` 字段已存在但无运营迁移与强制拒绝;013A 权限面就绪后可挂状态写权限 |
|
||||
| Non-goals | soft-delete / `deleted`;UGC 社区封禁;真支付;Admin 账号启停(已有 admin.status);推送通知 |
|
||||
|
||||
---
|
||||
|
||||
## L1 Bounded Context
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Primary BC | `Identity_Profile` |
|
||||
| owns | `User` · `UserStatus` · `AccountStateTransition` |
|
||||
| does_not_own | `AdminRole` · `Membership` · `Payment` · `BanRecord`(Account_Risk 后置加深) |
|
||||
| allowed | `Admin_Auth_Audit.write_audit`(经 admin 调用) |
|
||||
| forbidden | `Payment` · `Membership.write` · soft-delete User |
|
||||
|
||||
权威:`.ai/domain/boundary-rules.md` · `Account_Risk` 仅允许经明确服务触发 `UserStatus.transition`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定义
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Name | Account Lifecycle / UserStatus |
|
||||
| Purpose | 运营可迁移用户状态;非 active 会话在 C 端被拒绝 |
|
||||
| Business Goal | 风险处置最小闭环,不引入社区/UGC |
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| 状态机 `active/disabled/banned/suspended` | soft-delete / GDPR 擦除 |
|
||||
| Admin 迁移 API + AuditLog + Transition 记录 | 站内推送 / 短信 |
|
||||
| DeviceAuth / Bearer 对非 active 拒绝 | AdminAccount.status(013A 外) |
|
||||
| 权限码 `admin.users.status.write` | 行级数据 ACL |
|
||||
|
||||
---
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| Entity | 不变式 / 状态机 |
|
||||
|--------|----------------|
|
||||
| `UserStatus` | 取值冻结:`active` · `disabled` · `banned` · `suspended`;非法值拒写 |
|
||||
| `User.status` | 与 `UserStatus` 同值;默认 `active`(已有列) |
|
||||
| `AccountStateTransition` | 只追加;记录 from→to · admin_id · reason · created_at |
|
||||
|
||||
### 合法迁移
|
||||
|
||||
```text
|
||||
active → disabled | banned | suspended
|
||||
disabled → active | banned
|
||||
suspended → active | banned | disabled
|
||||
banned → active | disabled
|
||||
```
|
||||
|
||||
其它边 → **400**。同状态写 → **400**(幂等拒绝,避免空审计噪音)。
|
||||
|
||||
### C 端效应
|
||||
|
||||
| Status | DeviceAuth / 已登录 Bearer |
|
||||
|--------|------------------------------|
|
||||
| `active` | 放行 |
|
||||
| `disabled` / `banned` / `suspended` | **401**(或 403 统一码,实现轮定一)+ 不可发新 session |
|
||||
|
||||
---
|
||||
|
||||
## L3 API Contract(意图 · 实现轮同步 OpenAPI)
|
||||
|
||||
前缀:`/api/v1/admin` · AdminAuth · 信封 `{code,message,data}`
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/users/:id/status` | `admin.users.status.write` | 迁移 UserStatus;写 Transition + AuditLog |
|
||||
| GET | `/users/:id/status-transitions` | `admin.users.read` | 最近迁移列表(limit) |
|
||||
|
||||
既有 `GET /users` · `GET /users/:id` 已暴露 `status` — 保持;实现轮确认枚举文档化。
|
||||
|
||||
`contract_diff`:`docs/CONTRACT_DIFF/ECR-013B.yaml`
|
||||
|
||||
RBAC catalog **additive**:`admin.users.status.write` → 种子写入 `super_admin`(migration)。
|
||||
|
||||
---
|
||||
|
||||
## L4 Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-F-01 | 用户 `active` | POST status=`banned` + reason | 200;GET user.status=`banned` |
|
||||
| AC-F-02 | 用户 `banned` | POST status=`active` | 200;可再次 DeviceAuth |
|
||||
| AC-F-03 | 非法边(如 `active`→`active`) | POST | **400** |
|
||||
| AC-F-04 | GET status-transitions | — | 含最近 from/to/admin/reason |
|
||||
|
||||
### Security
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-S-01 | Admin 无 `admin.users.status.write` | POST status | **403** + deny audit |
|
||||
| AC-S-02 | 用户 `banned` | C 端带原 Bearer 访问受保护 API | **401/403** |
|
||||
| AC-S-03 | 仅 DeviceAuth 无 Admin | POST `/admin/users/:id/status` | **401** |
|
||||
| AC-S-04 | 无 Admin session | GET transitions | **401** |
|
||||
|
||||
### Performance
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-P-01 | transitions ≤1000 行/用户 | GET transitions limit=50 | 本机 P95 **< 500ms** |
|
||||
|
||||
### Observability
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-O-01 | 迁移成功 | — | AuditLog action=`users.status.transition` |
|
||||
| AC-O-02 | 迁移成功 | — | `account_state_transitions` 有对应行 |
|
||||
|
||||
---
|
||||
|
||||
## Forbidden(本切片)
|
||||
|
||||
- soft-delete / `deleted` 状态
|
||||
- 真支付 · UGC · Prompt · 兑换码 · Crisis 深化
|
||||
- 改 Admin RBAC 模型(仅 **additive** 一枚 permission)
|
||||
- 自动开下一 ECR
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes(实现轮才执行)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| Migration | YES(transitions 表;permission 种子;必要时 CHECK/注释枚举) |
|
||||
| Packages | service account/lifecycle · DeviceAuth/session 拒绝 · admin handler · admin-h5 用户详情最小 CTA |
|
||||
| Depends | ECR-013A Closed(permission middleware) |
|
||||
@@ -208,7 +208,19 @@ Phase A 可先 `console`/本地;不挡验收。
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| **A(本 ECR)** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 |
|
||||
| **B(ECR-007)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` |
|
||||
| **C(ECR-008)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md`(Approved) |
|
||||
| **B(ECR-007 Closed)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` |
|
||||
| **C(ECR-008 Closed)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md` |
|
||||
| **E(ECR-013A Closed)** | Admin RBAC — Spec `ops-rbac.md` · Parent WAVE0-FROZEN |
|
||||
| **F(ECR-013B Closed)** | AccountLifecycle — Spec `ops-account-lifecycle.md` |
|
||||
| **G(ECR-014 Closed)** | MembershipPlan — Spec `ops-membership-plan.md` |
|
||||
| **H(ECR-015 Closed)** | RedemptionCode — Spec `ops-redemption-code.md` |
|
||||
| **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` |
|
||||
| **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` |
|
||||
| **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` |
|
||||
| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
|
||||
| **M(ECR-020 Closed)** | QualityFeedback — Spec `ops-quality-feedback.md` |
|
||||
| **N(ECR-021 Closed)** | AICoreConfig SystemPrompt — Spec `ops-ai-core-config.md` |
|
||||
| **O(ECR-022 Closed)** | CrisisCare CrisisPolicy — Spec `ops-crisis-care.md` |
|
||||
| 后置 | Knowledge·Tools 写面 / CrisisEvent / 真支付(Loop 续跑) |
|
||||
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
|
||||
| E | RBAC · 封禁 · 推送占位(另开 ECR) |
|
||||
| 后置 | 封禁加深(Account_Risk)· 推送占位 |
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · SystemPrompt(Ops · ECR-021)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-021 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-020 Closed
|
||||
> Capability: `AICoreConfig` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
Prompt 在线编辑/发布 · KnowledgeSource/Chunk · ToolDefinition · 切换运行时引擎 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `SystemPrompt` | code 唯一;body 为模板文本;active/system;本切片只读 |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ai/system-prompts` | `admin.ai_config.read` | 列表 |
|
||||
| GET | `/admin/ai/system-prompts/:id` | 同上 | 详情(含 body) |
|
||||
|
||||
## Migration
|
||||
|
||||
`000022`:`system_prompts` + 种子 `ask_default` + 授予权限
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含 ask_default |
|
||||
| AC-F-02 | get 返回 body 非空 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 ai_config.read → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-021.yaml`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature Spec: 运营行为分析(Ops-B)
|
||||
|
||||
> Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-B`
|
||||
> ESS:`docs/ECR/ECR-007-ops-analytics.md`(Approved · L2)· BD-2026-007 Approved
|
||||
> ESS:`docs/ECR/ECR-007-ops-analytics.md`(**Closed** · L2)· BD-2026-007 Approved
|
||||
> 关联:[ops-admin.md](ops-admin.md) · [analytics.md](analytics.md) · [cece-frontend-re/complete-design/40-ops-commerce.md](cece-frontend-re/complete-design/40-ops-commerce.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AskOperations(Ops · ECR-017)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-017 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-016 Closed
|
||||
> Capability: `AskOperations` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
改写/删除消息正文 · SystemPrompt/Knowledge 配置 · QualityFeedback 写回 · AICoreConfig · 真支付 · UGC
|
||||
|
||||
## L2 读模型
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `AskSessionView` | 线程元数据 + message_count(列表) |
|
||||
| 会话详情 | 只读消息序列(role/content/created_at) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ask/threads` | `admin.ask.read` | 列表(可选 `user_id`) |
|
||||
| GET | `/admin/ask/threads/:id` | `admin.ask.read` | 详情含消息 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000019`:为 `super_admin` 授予 `admin.ask.read`(无新业务表)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | 有消息线程 → list 含该 thread · message_count≥1 |
|
||||
| AC-F-02 | detail 返回 user+assistant 消息 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 ask.read → 403 |
|
||||
| AC-P-01 | list < 500ms 本机 |
|
||||
| AC-O-01 | N/A 只读(无写审计强制) |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-017.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: OpsCMS · Banner(Ops · ECR-024)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-024 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-023 Closed
|
||||
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
Banner 写发布 · FeedSlot · ScheduledPublication · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `Banner` | code 唯一;placement ∈ {home,explore,ask};active/system;本切片只读 |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/cms/banners` | `admin.cms.read` | 只读 |
|
||||
| GET | `/admin/cms/banners/{id}` | `admin.cms.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000025`:表 + 种子(若有) + 授予 admin.cms.read
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-024.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ContentSafety · BlockPolicy(Ops · ECR-029)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-029 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-028 Closed
|
||||
> Capability: `ContentSafety` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
策略写发布 · 用户侧硬拦截上线 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `BlockPolicy` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/content-safety/block-policies` | `admin.content_safety.read` | 只读 |
|
||||
| GET | `/admin/content-safety/block-policies/{id}` | `admin.content_safety.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000030`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-029.yaml`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: ContentSafety · FilterRule(Ops · ECR-019)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-019 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-018 Closed
|
||||
> Capability: `ContentSafety` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
ModerationCase 写回 · CrisisPolicy 配置 · 真 NLP/厂商审核 · UGC · 真支付 · 用户侧硬拦截上线(本切片仅运营只读 + 试匹配)
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `FilterRule` | code 唯一;category ∈ {crisis,abuse,spam,pii};action ∈ {flag,block,escalate};system 种子不可本切片删除 |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/content-safety/filter-rules` | `admin.content_safety.read` | 列表 |
|
||||
| GET | `/admin/content-safety/filter-rules/:id` | 同上 | 详情 |
|
||||
| POST | `/admin/content-safety/evaluate` | 同上 | 试匹配(不写工单) |
|
||||
|
||||
## Migration
|
||||
|
||||
`000020`:`filter_rules` + 种子规则 + 授予 `admin.content_safety.read`
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含 system 种子 ≥1 |
|
||||
| AC-F-02 | evaluate 命中种子 pattern → matches 非空 |
|
||||
| AC-F-03 | get 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 content_safety.read → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | evaluate 不写 Audit(只读试匹配) |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Feature Spec: 运营内容配置(Ops-C)
|
||||
|
||||
> Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-C`
|
||||
> ESS:`docs/ECR/ECR-008-ops-content.md`(Approved · L2)· BD-2026-008 Approved
|
||||
> ESS:`docs/ECR/ECR-008-ops-content.md`(**Closed** · L2)· BD-2026-008 Approved
|
||||
> 关联:[ops-admin.md](ops-admin.md) · [ops-analytics.md](ops-analytics.md) · [home.md](home.md) · [explore-test.md](explore-test.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: CrisisCare · CrisisPolicy(Ops · ECR-022)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-022 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-021 Closed
|
||||
> Capability: `CrisisCare` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
CrisisEvent 入库工单 · InterventionOutcome · 策略在线编辑 · 医疗诊断结论 · UGC · 真支付 · 用户侧硬熔断上线
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `CrisisPolicy` | code 唯一;severity ∈ {high,critical};action ∈ {escalate,block,show_helpline};helpline_text 可选;本切片只读 |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/crisis/policies` | `admin.crisis.read` | 列表 |
|
||||
| GET | `/admin/crisis/policies/:id` | 同上 | 详情 |
|
||||
| POST | `/admin/crisis/evaluate` | 同上 | 试匹配(不写 CrisisEvent) |
|
||||
|
||||
## Migration
|
||||
|
||||
`000023`:`crisis_policies` + 种子 + 授予权限
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含 system 种子 ≥1 |
|
||||
| AC-F-02 | evaluate 命中 pattern → matches 非空 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 crisis.read → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | evaluate 不写 Audit/Event |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-022.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: CrisisCare · CrisisEvent(Ops · ECR-031)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-031 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-030 Closed
|
||||
> Capability: `CrisisCare` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
事件写入工单流 · 医疗诊断 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `CrisisEvent` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/crisis/events` | `admin.crisis.read` | 只读 |
|
||||
| GET | `/admin/crisis/events/{id}` | `admin.crisis.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000032`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-031.yaml`
|
||||
@@ -0,0 +1,36 @@
|
||||
# Feature Spec: Entitlement(Ops · ECR-018)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-018 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-017 Closed
|
||||
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
真支付 · 改订单/退款 · 新建权益矩阵表 · UGC · 改报告正文 · ask_pack SKU
|
||||
|
||||
## L2 读模型(无 migration)
|
||||
|
||||
| 概念 | 来源 |
|
||||
|------|------|
|
||||
| `Entitlement` | Membership + DeepAccess 列表 + ask_paid_quota 聚合 |
|
||||
| flags | `report_detail_via_membership` · `deep_access_count` |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/users/:id/entitlements` | `admin.users.read` | 用户权益只读视图 |
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | 授予会员后 flags.report_detail_via_membership=true |
|
||||
| AC-F-02 | 有 deep_access 时 deep_accesses 非空或 count≥1 |
|
||||
| AC-F-03 | 无会员无深度 → membership.active=false 且 count=0 仍 200 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-P-01 | GET < 500ms 本机 |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-018.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: OpsCMS · FeedSlot(Ops · ECR-025)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-025 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-024 Closed
|
||||
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
FeedSlot 写发布 · ScheduledPublication · UGC · 真支付 · Banner 写
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `FeedSlot` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/cms/feed-slots` | `admin.cms.read` | 只读 |
|
||||
| GET | `/admin/cms/feed-slots/{id}` | `admin.cms.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000026`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-025.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: GrowthInsights · FunnelDefinition(Ops · ECR-039)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-039 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-038 Closed
|
||||
> Capability: `GrowthInsights` · BC: `Analytics_OpsB`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
漏斗写配置 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `FunnelDefinition` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/analytics/funnel-definitions` | `admin.analytics.read` | 只读 |
|
||||
| GET | `/admin/analytics/funnel-definitions/{id}` | `admin.analytics.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000040`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-039.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AskOperations · HandoffCase(Ops · ECR-033)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-033 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-032 Closed
|
||||
> Capability: `AskOperations` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
转人工写流 · 顾问执业 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `HandoffCase` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ask/handoffs` | `admin.ask.read` | 只读 |
|
||||
| GET | `/admin/ask/handoffs/{id}` | `admin.ask.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000034`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-033.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ExploreConfig · ImageCardDeck(Ops · ECR-037)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-037 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-036 Closed
|
||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
牌组写发布 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ImageCardDeck` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/explore/image-card-decks` | `admin.explore.read` | 只读 |
|
||||
| GET | `/admin/explore/image-card-decks/{id}` | `admin.explore.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000038`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-037.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: CrisisCare · InterventionOutcome(Ops · ECR-032)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-032 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-031 Closed
|
||||
> Capability: `CrisisCare` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
干预写回 · 医疗诊断 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `InterventionOutcome` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/crisis/interventions` | `admin.crisis.read` | 只读 |
|
||||
| GET | `/admin/crisis/interventions/{id}` | `admin.crisis.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000033`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-032.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · KnowledgeChunk(Ops · ECR-027)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-027 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-026 Closed
|
||||
> Capability: `AICoreConfig` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
Embedding · 上传切块 · 运行时 RAG 接线 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `KnowledgeChunk` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ai/knowledge-chunks` | `admin.ai_config.read` | 只读 |
|
||||
| GET | `/admin/ai/knowledge-chunks/{id}` | `admin.ai_config.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000028`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-027.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · KnowledgeSource(Ops · ECR-023)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-023 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-022 Closed
|
||||
> Capability: `AICoreConfig` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
KnowledgeChunk / Embedding · 源文件上传 · 在线编辑发布 · ToolDefinition · 运行时 RAG 接线 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `KnowledgeSource` | code 唯一;source_kind ∈ {faq,policy,guide};active/system;本切片只读目录,不含 Chunk |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ai/knowledge-sources` | `admin.ai_config.read` | 列表 |
|
||||
| GET | `/admin/ai/knowledge-sources/:id` | 同上 | 详情 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000024`:`knowledge_sources` + 种子 `ask_grounding`(权限已由 000022 授予)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含 ask_grounding |
|
||||
| AC-F-02 | get 返回 source_kind 合法 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 ai_config.read → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-023.yaml`
|
||||
@@ -0,0 +1,77 @@
|
||||
# Feature Spec: 会员套餐 MembershipPlan(Ops · ECR-014)
|
||||
|
||||
> Status: `Active`(Loop continuous · Approved · coding)· Map: `§7` · Phase: `Ops-D`
|
||||
> Parent: **WAVE0-FROZEN** · Predecessor: **ECR-013B Closed**
|
||||
> ESS: `docs/ECR/ECR-014-membership-plan.md`
|
||||
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
---
|
||||
|
||||
## ESS 门禁
|
||||
|
||||
1. L2 · Loop continuous:契约齐 → 自动 Approve + coding
|
||||
2. **不含** 真支付 · 兑换码 · Entitlement 矩阵 · ask_pack 价目表
|
||||
|
||||
---
|
||||
|
||||
## L0 Capability
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Capability ID | `CommerceEntitlement` |
|
||||
| Purpose | 运营可配置成长会员套餐时长与标价(mock 履约仍用既有支付) |
|
||||
| Why now | planDays/金额硬编码;013B 完成后进入 Commerce 配置面最小切片 |
|
||||
| Non-goals | 真支付网关 · RedemptionCode · Entitlement 细权 · ask_pack |
|
||||
|
||||
---
|
||||
|
||||
## L1 Bounded Context
|
||||
|
||||
| Primary BC | `Membership_Orders` |
|
||||
| owns | `MembershipPlan` |
|
||||
| does_not_own | `UserStatus` · `Payment` 适配器 · `AdminRole` |
|
||||
| allowed | `Admin_Auth_Audit.write_audit` |
|
||||
| forbidden | 真支付 · UGC |
|
||||
|
||||
---
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| Entity | 不变式 |
|
||||
|--------|--------|
|
||||
| `MembershipPlan` | `code` ∈ {month,quarter,year} 本切片冻结;`duration_days`>0;`amount_cents`≥0;`active` 布尔 |
|
||||
|
||||
---
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/membership-plans` | `admin.membership.plans.read` | 列表 |
|
||||
| GET | `/admin/membership-plans/:code` | `admin.membership.plans.read` | 详情 |
|
||||
| PUT | `/admin/membership-plans/:code` | `admin.membership.plans.write` | 更新 title/days/amount/active + AuditLog |
|
||||
|
||||
履约:`GrantMembership` / membership `CreateOrder` 读表(缺行回退旧硬编码)。
|
||||
|
||||
`contract_diff`:`docs/CONTRACT_DIFF/ECR-014.yaml`
|
||||
|
||||
---
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | GET plans 含 month/quarter/year |
|
||||
| AC-F-02 | PUT month days/amount → GET 一致 |
|
||||
| AC-F-03 | GrantMembership 使用表内 duration_days |
|
||||
| AC-S-01 | 无 write 权限 PUT → 403 |
|
||||
| AC-S-02 | 无 Admin → 401 |
|
||||
| AC-P-01 | GET list P95 < 500ms 本机 |
|
||||
| AC-O-01 | PUT 成功 → AuditLog `membership.plans.update` |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Migration `membership_plans` + RBAC additive permissions · admin-h5 最小列表编辑页
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ContentSafety · ModerationCase(Ops · ECR-030)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-030 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-029 Closed
|
||||
> Capability: `ContentSafety` · BC: `Content_Safety`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
审核写回 · 真 NLP 厂商 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ModerationCase` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/content-safety/cases` | `admin.content_safety.read` | 只读 |
|
||||
| GET | `/admin/content-safety/cases/{id}` | `admin.content_safety.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000031`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-030.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AdminGovernance · PrivacyRequest(Ops · ECR-034)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-034 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-033 Closed
|
||||
> Capability: `AdminGovernance` · BC: `Admin_Auth_Audit`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
隐私请求履约写 · soft-delete · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `PrivacyRequest` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/privacy/requests` | `admin.privacy.read` | 只读 |
|
||||
| GET | `/admin/privacy/requests/{id}` | `admin.privacy.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000035`:表 + 种子(若有) + 授予 admin.privacy.read
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-034.yaml`
|
||||
@@ -0,0 +1,42 @@
|
||||
# Feature Spec: QualityFeedback(Ops · ECR-020)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-020 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-019 Closed
|
||||
> Capability: `AskOperations` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
改写消息正文 · SystemPrompt/Knowledge · HandoffCase 全量 · AICoreConfig · UGC 广场 · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `QualityFeedback` | 绑定 thread(可选 message);rating 1–5;tag ∈ {helpful,off_topic,unsafe,other};source ∈ {admin,user} |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ask/feedback` | `admin.ask.read` | 列表 |
|
||||
| POST | `/admin/ask/threads/:id/feedback` | `admin.ask.feedback.write` | 运营提交 |
|
||||
| POST | `/ask/threads/:id/feedback` | DeviceAuth 用户 | C 端评分 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000021`:`ask_quality_feedback` + 授予 `admin.ask.feedback.write`
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | admin POST feedback → 200 · list 可见 |
|
||||
| AC-F-02 | user POST feedback → 200 |
|
||||
| AC-F-03 | 无效 rating → 400 |
|
||||
| AC-S-01 | 无 Admin → admin list 401 |
|
||||
| AC-S-02 | 无 feedback.write → admin POST 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | admin 提交写 AuditLog `ask.feedback.create` |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-020.yaml`
|
||||
@@ -0,0 +1,151 @@
|
||||
# Feature Spec: 运营后台 RBAC(Ops · ECR-013A)
|
||||
|
||||
> Status: `Active`(**Closed** · Feature Complete)· Map: `§7 运营后台` · Phase: `Ops-D`
|
||||
> Parent: **WAVE0-FROZEN** (`27f27a1`)
|
||||
> ESS: `docs/ECR/ECR-013A-admin-rbac.md`(**Closed**)
|
||||
> Capability: `AdminGovernance` · BC: `Admin_RBAC`
|
||||
> 模板:`docs/WAVE0/contracts/OPS-CONTRACT-TEMPLATE.md`
|
||||
|
||||
---
|
||||
|
||||
## ESS 门禁
|
||||
|
||||
1. Change Level = **L2** → 已 Approved + Coding auth + Implemented + **Closed**
|
||||
2. ~~Contract Definition only~~ — 已完成
|
||||
3. 实现证据:`docs/TEST_REPORT/ECR-013A.md` · `docs/CODE_REVIEW/ECR-013A.md`
|
||||
|
||||
---
|
||||
|
||||
## L0 Capability
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Capability ID | `AdminGovernance` |
|
||||
| Purpose | 以最小权限原则约束管理员可执行的运营动作 |
|
||||
| Why now | Ops-A 全员等价权限;进入 M1+ 前必须有权限面,否则越权不可治理 |
|
||||
| Non-goals | 用户账户启停(ECR-013B);兑换码;CMS;Ask Prompt;真支付;UGC |
|
||||
|
||||
---
|
||||
|
||||
## L1 Bounded Context
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Primary BC | `Admin_RBAC` |
|
||||
| owns | `AdminRole` · `AdminPermission` |
|
||||
| does_not_own | `User` · `Profile` · `UserStatus` · `Membership` · `Payment` |
|
||||
| allowed | `Admin_Auth_Audit.write_audit` |
|
||||
| forbidden | `Payment` · `Membership.write` · `DeviceAuth.user_token_issue` |
|
||||
|
||||
权威:`.ai/domain/boundary-rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能定义
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| Name | Admin RBAC |
|
||||
| Purpose | 角色与权限点绑定;API 级强制鉴权;变更可审计 |
|
||||
| Business Goal | 支撑多运营角色,而不扩大 C 端攻击面 |
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| Role CRUD(最小:list/get/update permissions) | UserStatus / 封禁(013B) |
|
||||
| `GET /admin/me` 返回 permissions | 细粒度数据行级 ACL |
|
||||
| 既有 admin 写操作挂 permission 检查 | 新业务模块权限爆炸式新增(另 ECR) |
|
||||
|
||||
---
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| Entity | 不变式 |
|
||||
|--------|--------|
|
||||
| `AdminRole` | `name` 唯一;系统角色 `super_admin` 不可删除 |
|
||||
| `AdminPermission` | 稳定字符串码(见下表);只增不改语义 |
|
||||
| `AdminAccount.role_id` | 每个账号恰好一个角色(本切片);无角色视为拒绝写操作 |
|
||||
|
||||
### Permission catalog(本切片冻结)
|
||||
|
||||
| Code | 覆盖既有能力 |
|
||||
|------|----------------|
|
||||
| `admin.users.read` | GET users / users/:id / stats |
|
||||
| `admin.users.membership.grant` | POST membership/grant |
|
||||
| `admin.users.ask_quota.grant` | POST ask-quota/grant |
|
||||
| `admin.orders.read` | GET orders |
|
||||
| `admin.audit.read` | GET audit-logs |
|
||||
| `admin.analytics.read` | GET analytics/*(Ops-B) |
|
||||
| `admin.content.write` | home tools / scales publish(Ops-C) |
|
||||
| `admin.roles.read` | GET roles |
|
||||
| `admin.roles.write` | 变更角色权限 |
|
||||
|
||||
`super_admin` 种子拥有上表全部。
|
||||
|
||||
---
|
||||
|
||||
## L3 API Contract(意图 · 实现轮同步 OpenAPI)
|
||||
|
||||
前缀:`/api/v1/admin` · AdminAuth · 信封 `{code,message,data}`
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/roles` | `admin.roles.read` | 角色列表 |
|
||||
| GET | `/roles/:id` | `admin.roles.read` | 角色 + permissions[] |
|
||||
| PUT | `/roles/:id/permissions` | `admin.roles.write` | 全量替换权限集;写 AuditLog |
|
||||
| GET | `/me` | (已登录) | **扩展**返回 `role` + `permissions[]` |
|
||||
|
||||
既有写接口:在实现轮挂上表 permission;缺权 → **403** + AuditLog(尝试记录)。
|
||||
|
||||
`contract_diff`:`docs/CONTRACT_DIFF/ECR-013A.yaml`
|
||||
|
||||
---
|
||||
|
||||
## L4 Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-F-01 | super_admin 已登录 | GET `/roles` | 200,含 `super_admin` |
|
||||
| AC-F-02 | 角色存在 | PUT permissions 合法集合 | 200;再次 GET 一致 |
|
||||
| AC-F-03 | GET `/me` | — | data 含 `permissions` 数组 |
|
||||
|
||||
### Security
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-S-01 | Admin 无 `admin.roles.write` | PUT `/roles/:id/permissions` | **403**;AuditLog 有拒绝或尝试记录 |
|
||||
| AC-S-02 | Admin 无 `admin.users.membership.grant` | POST membership/grant | **403** |
|
||||
| AC-S-03 | 仅 DeviceAuth | 访问 `/admin/roles` | **401** |
|
||||
| AC-S-04 | 删除 `super_admin` 角色 | 任意 API | **拒绝**(4xx) |
|
||||
|
||||
### Performance
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-P-01 | 角色数 N≤100 | GET `/roles` | 本机 integration 环境下 P95 **< 500ms** |
|
||||
|
||||
### Observability
|
||||
|
||||
| ID | Given | When | Then |
|
||||
|----|-------|------|------|
|
||||
| AC-O-01 | PUT permissions 成功 | — | AuditLog action 含 `roles.permissions.update` |
|
||||
| AC-O-02 | AC-S-01 触发 | — | AuditLog 可追溯 admin_id + path |
|
||||
|
||||
---
|
||||
|
||||
## Forbidden(本切片)
|
||||
|
||||
- UserStatus / AccountLifecycle(**ECR-013B**)
|
||||
- 真支付 · UGC · Prompt/RAG · 兑换码 · CMS Banner 新表
|
||||
- 改 DeviceAuth / C 端五 Tab
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes(实现轮才执行)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| Migration | YES(roles / permissions 关联;accounts.role_id;种子 super_admin) |
|
||||
| Packages | `service/admin` · repo · middleware permission check · admin-h5 只读展示权限(最小) |
|
||||
| Parent | WAVE0-FROZEN |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Feature Spec: 兑换码 RedemptionCode(Ops · ECR-015)
|
||||
|
||||
> Status: `Active`(Loop continuous · Approved · coding)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-014 Closed
|
||||
> Capability: `CommerceEntitlement` · BC: `Membership_Orders`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
真支付 · Entitlement 细权 · ask_pack 兑换 · UGC
|
||||
|
||||
## L2
|
||||
|
||||
| Entity | 不变式 |
|
||||
|--------|--------|
|
||||
| `RedemptionBatch` | label · plan_code∈membership_plans · quantity 1..100 |
|
||||
| `RedemptionCode` | code 唯一;status unused→redeemed\|disabled;redeemed 不可再兑 |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | Auth | 语义 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/admin/redemption-batches` | `admin.membership.codes.write` | 批量生成 |
|
||||
| GET | `/admin/redemption-batches` | `admin.membership.codes.read` | 批次列表 |
|
||||
| GET | `/admin/redemption-batches/:id/codes` | `admin.membership.codes.read` | 码列表 |
|
||||
| POST | `/admin/redemption-codes/:id/disable` | `admin.membership.codes.write` | 作废 unused |
|
||||
| POST | `/membership/redeem` | DeviceAuth+已注册 | 兑码→延长会员 |
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | POST batch quantity=3 → 3 unused codes |
|
||||
| AC-F-02 | C端 redeem → membership active;码=redeemed |
|
||||
| AC-F-03 | 再兑同一码 → 400 |
|
||||
| AC-F-04 | disable unused → status=disabled;兑 → 400 |
|
||||
| AC-S-01 | 无 write → POST batch 403 |
|
||||
| AC-S-02 | 未登录兑码 → 401 |
|
||||
| AC-P-01 | GET batches < 500ms |
|
||||
| AC-O-01 | 生成 AuditLog `redemption.batch.create` |
|
||||
| AC-O-02 | 兑换可追溯 redeemed_by |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-015.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: GrowthInsights · ReportTemplate(Ops · ECR-038)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-038 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-037 Closed
|
||||
> Capability: `GrowthInsights` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
模板写发布 · 广告投放 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ReportTemplate` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/growth/report-templates` | `admin.growth.read` | 只读 |
|
||||
| GET | `/admin/growth/report-templates/{id}` | `admin.growth.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000039`:表 + 种子(若有) + 授予 admin.growth.read
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-038.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ExploreConfig · RhythmConfig(Ops · ECR-036)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-036 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-035 Closed
|
||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
配置写发布 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `RhythmConfig` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/explore/rhythm-configs` | `admin.explore.read` | 只读 |
|
||||
| GET | `/admin/explore/rhythm-configs/{id}` | `admin.explore.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000037`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-036.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ExploreConfig · ScaleDefinition(Ops · ECR-040)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-040 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-039 Closed
|
||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
PATCH status(已有 content.write)· 题干编辑 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ScaleDefinition` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/explore/scales` | `admin.explore.read` | 只读 |
|
||||
| GET | `/admin/explore/scales/{id}` | `admin.explore.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
Migration NO:复用 `scales` 表只读投影
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-040.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: OpsCMS · ScheduledPublication(Ops · ECR-026)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-026 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-025 Closed
|
||||
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
定时发布写操作 · UGC · 真支付 · Banner/FeedSlot 写
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ScheduledPublication` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/cms/publications` | `admin.cms.read` | 只读 |
|
||||
| GET | `/admin/cms/publications/{id}` | `admin.cms.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000027`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-026.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: ExploreConfig · StarConfig(Ops · ECR-035)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-035 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-034 Closed
|
||||
> Capability: `ExploreConfig` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
配置写发布 · 引擎改分层 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `StarConfig` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/explore/star-configs` | `admin.explore.read` | 只读 |
|
||||
| GET | `/admin/explore/star-configs/{id}` | `admin.explore.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000036`:表 + 种子(若有) + 授予 admin.explore.read
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-035.yaml`
|
||||
@@ -0,0 +1,41 @@
|
||||
# Feature Spec: AICoreConfig · ToolDefinition(Ops · ECR-028)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-028 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-027 Closed
|
||||
> Capability: `AICoreConfig` · BC: `Ask_Ops`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
工具在线编辑 · 运行时绑定 · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `ToolDefinition` | 本切片只读目录;code 唯一(若适用) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/ai/tools` | `admin.ai_config.read` | 只读 |
|
||||
| GET | `/admin/ai/tools/{id}` | `admin.ai_config.read` | 只读 |
|
||||
|
||||
## Migration
|
||||
|
||||
`000029`:表 + 种子(若有)(权限复用)
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | list 含种子或空列表合法 |
|
||||
| AC-F-02 | 已知 id get 200 |
|
||||
| AC-F-03 | 未知 id → 404 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无权限 → 403 |
|
||||
| AC-P-01 | list < 500ms |
|
||||
| AC-O-01 | N/A 只读 |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-028.yaml`
|
||||
@@ -0,0 +1,39 @@
|
||||
# Feature Spec: 用户洞察 UserIntelligence(Ops · ECR-016)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-016 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-015 Closed
|
||||
> Capability: `UserIntelligence` · BC: `Identity_Profile`(读)+ `Analytics_OpsB`(读)
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
## Non-goals
|
||||
|
||||
写标签 · 改报告正文 · Ask 全文运营编辑 · UGC · 真支付 · 新表(本切片纯读模型聚合)
|
||||
|
||||
## L2 读模型(无 migration)
|
||||
|
||||
| 概念 | 来源 |
|
||||
|------|------|
|
||||
| `UserProfileView` | profiles 摘要(已有 detail) |
|
||||
| `InsightReport` | growth_reports 类型计数 + 最近列表 |
|
||||
| `PsychologicalTagSet` | 由报告 type 派生的稳定标签(非 NLP) |
|
||||
| `BehaviorSnapshot` | analytics_events 最近页面/事件(若有) |
|
||||
|
||||
## L3 API
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/admin/users/:id/insight` | `admin.users.read` | 聚合洞察只读 |
|
||||
|
||||
## L4 AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | 有报告用户 → insight.reports_by_type 非空或 reports 列表 |
|
||||
| AC-F-02 | tags 含报告类型映射标签 |
|
||||
| AC-F-03 | 无埋点时 behavior.events=[] 仍 200 |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 无 users.read → 403 |
|
||||
| AC-P-01 | GET insight < 500ms 本机 |
|
||||
| AC-O-01 | N/A 只读(无写审计) |
|
||||
|
||||
contract_diff: `docs/CONTRACT_DIFF/ECR-016.yaml`
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
**Given** Self 有生日
|
||||
**When** 生成星座报告
|
||||
**Then** 展示轮盘、太阳/月亮/上升、相位速览、fortune 日运与行运;无「占卜」「算命」恐吓
|
||||
**Then** 展示轮盘、太阳/月亮/上升、相位速览、outlook 日/周/月/年/一生与行运;无「占卜」「算命」恐吓
|
||||
|
||||
**Given** 两份档案
|
||||
**When** POST synastry
|
||||
@@ -214,3 +214,5 @@
|
||||
| Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` |
|
||||
| 星历 | Swiss Ephemeris(CGO,默认 Moshier);热带 + Whole Sign |
|
||||
| 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite |
|
||||
| Gaps | ECR-012 收口:报告页复用 StarFortunePanel;分享 type=star;fortuneBundle 仅读 outlook 且剥离 lucky;e2e mock 含 outlook |
|
||||
| ESS | `docs/ECR/ECR-012-star-alignment.md`(**Approved** · L2)· BD-2026-012 Approved |
|
||||
|
||||
@@ -21,7 +21,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
await ok({
|
||||
token: 'adm_e2e_token',
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
admin: { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' },
|
||||
admin: {
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
username: 'admin',
|
||||
role: 'super_admin',
|
||||
permissions: [
|
||||
'admin.users.read',
|
||||
'admin.users.membership.grant',
|
||||
'admin.users.ask_quota.grant',
|
||||
'admin.orders.read',
|
||||
'admin.audit.read',
|
||||
'admin.analytics.read',
|
||||
'admin.content.write',
|
||||
'admin.roles.read',
|
||||
'admin.roles.write',
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -55,7 +70,22 @@ test('admin login users grant and audit with mocked API', async ({ page }) => {
|
||||
return
|
||||
}
|
||||
if (url.endsWith('/me') || url.includes('/me?')) {
|
||||
await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' })
|
||||
await ok({
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
username: 'admin',
|
||||
role: 'super_admin',
|
||||
permissions: [
|
||||
'admin.users.read',
|
||||
'admin.users.membership.grant',
|
||||
'admin.users.ask_quota.grant',
|
||||
'admin.orders.read',
|
||||
'admin.audit.read',
|
||||
'admin.analytics.read',
|
||||
'admin.content.write',
|
||||
'admin.roles.read',
|
||||
'admin.roles.write',
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.includes(`/users/${userId}/ask-quota/grant`) && method === 'POST') {
|
||||
|
||||
@@ -31,7 +31,17 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const env = (await res.json()) as ApiEnvelope<T>
|
||||
const text = await res.text()
|
||||
let env: ApiEnvelope<T>
|
||||
try {
|
||||
env = JSON.parse(text) as ApiEnvelope<T>
|
||||
} catch {
|
||||
throw new Error(
|
||||
res.status === 404
|
||||
? `接口不存在或后端未更新(${path})`
|
||||
: `响应不是 JSON(HTTP ${res.status})`,
|
||||
)
|
||||
}
|
||||
if (!res.ok || env.code !== 0) {
|
||||
throw new Error(env.message || `HTTP ${res.status}`)
|
||||
}
|
||||
@@ -96,7 +106,12 @@ export type UserDetail = {
|
||||
|
||||
export type AdminRole = 'super' | 'ops'
|
||||
|
||||
export type AdminMe = { id: string; username: string; role: AdminRole }
|
||||
export type AdminMe = {
|
||||
id: string
|
||||
username: string
|
||||
role?: string
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export type PushJob = {
|
||||
id: string
|
||||
@@ -125,12 +140,63 @@ export const adminApi = {
|
||||
}),
|
||||
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
|
||||
me: () => request<AdminMe>('GET', '/me'),
|
||||
roles: () =>
|
||||
request<{ items: Array<{ id: string; name: string; system: boolean }> }>('GET', '/roles'),
|
||||
role: (id: string) =>
|
||||
request<{ id: string; name: string; system: boolean; permissions: string[] }>('GET', `/roles/${id}`),
|
||||
stats: () => request<DashboardStats>('GET', '/stats'),
|
||||
users: (q = '') =>
|
||||
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
||||
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
||||
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
|
||||
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
|
||||
setUserStatus: (id: string, status: string, reason: string) =>
|
||||
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
|
||||
statusTransitions: (id: string) =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
from_status: string
|
||||
to_status: string
|
||||
admin_id: string
|
||||
reason: string
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', `/users/${id}/status-transitions`),
|
||||
userInsight: (id: string) =>
|
||||
request<{
|
||||
user_id: string
|
||||
profiles_count: number
|
||||
reports_by_type: Array<{ type: string; count: number }>
|
||||
recent_reports: Array<{ id: string; type: string; created_at: string }>
|
||||
tags: Array<{ code: string; label: string }>
|
||||
behavior: {
|
||||
events: Array<{ name: string; page_path?: string; received_at: string }>
|
||||
ask_thread_count: number
|
||||
}
|
||||
}>('GET', `/users/${id}/insight`),
|
||||
userEntitlements: (id: string) =>
|
||||
request<{
|
||||
user_id: string
|
||||
membership: {
|
||||
plan?: string
|
||||
status: string
|
||||
expires_at?: string
|
||||
ask_quota_left?: number
|
||||
active: boolean
|
||||
}
|
||||
ask_paid_quota_left: number
|
||||
flags: {
|
||||
report_detail_via_membership: boolean
|
||||
deep_access_count: number
|
||||
}
|
||||
deep_accesses: Array<{
|
||||
id: string
|
||||
report_id: string
|
||||
report_type?: string
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', `/users/${id}/entitlements`),
|
||||
grant: (id: string, plan: string) =>
|
||||
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
|
||||
grantAskQuota: (id: string, delta: number) =>
|
||||
@@ -145,6 +211,323 @@ export const adminApi = {
|
||||
request<PushJob>('POST', '/push-jobs', body),
|
||||
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
|
||||
request<PushJob>('PATCH', `/push-jobs/${id}`, body),
|
||||
membershipPlans: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
code: string
|
||||
title: string
|
||||
duration_days: number
|
||||
amount_cents: number
|
||||
active: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/membership-plans'),
|
||||
updateMembershipPlan: (
|
||||
code: string,
|
||||
body: { title: string; duration_days: number; amount_cents: number; active: boolean },
|
||||
) =>
|
||||
request<{
|
||||
code: string
|
||||
title: string
|
||||
duration_days: number
|
||||
amount_cents: number
|
||||
active: boolean
|
||||
}>('PUT', `/membership-plans/${code}`, body),
|
||||
createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) =>
|
||||
request<{
|
||||
batch: { id: string; label: string; plan_code: string; quantity: number }
|
||||
codes: Array<{ id: string; code: string; status: string }>
|
||||
}>('POST', '/redemption-batches', body),
|
||||
redemptionBatches: () =>
|
||||
request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>(
|
||||
'GET',
|
||||
'/redemption-batches',
|
||||
),
|
||||
redemptionCodes: (batchId: string) =>
|
||||
request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>(
|
||||
'GET',
|
||||
`/redemption-batches/${batchId}/codes`,
|
||||
),
|
||||
disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`),
|
||||
askThreads: (userId = '') => {
|
||||
const q = userId ? `?user_id=${encodeURIComponent(userId)}` : ''
|
||||
return request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
user_id: string
|
||||
profile_id: string
|
||||
scene?: string
|
||||
message_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', `/ask/threads${q}`)
|
||||
},
|
||||
askThread: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
user_id: string
|
||||
profile_id: string
|
||||
scene?: string
|
||||
message_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
messages: Array<{ id: string; role: string; content: string; created_at: string }>
|
||||
}>('GET', `/ask/threads/${id}`),
|
||||
filterRules: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
category: string
|
||||
pattern: string
|
||||
action: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/content-safety/filter-rules'),
|
||||
filterRule: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
category: string
|
||||
pattern: string
|
||||
action: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/content-safety/filter-rules/${id}`),
|
||||
evaluateContent: (text: string) =>
|
||||
request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>(
|
||||
'POST',
|
||||
'/content-safety/evaluate',
|
||||
{ text },
|
||||
),
|
||||
askFeedback: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
thread_id: string
|
||||
message_id?: string
|
||||
source: string
|
||||
rating: number
|
||||
tag?: string
|
||||
note?: string
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', '/ask/feedback'),
|
||||
createAskFeedback: (threadId: string, body: { rating: number; tag?: string; note?: string; message_id?: string }) =>
|
||||
request<{
|
||||
id: string
|
||||
thread_id: string
|
||||
rating: number
|
||||
source: string
|
||||
}>('POST', `/ask/threads/${threadId}/feedback`, body),
|
||||
systemPrompts: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene?: string
|
||||
body: string
|
||||
version: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/ai/system-prompts'),
|
||||
systemPrompt: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene?: string
|
||||
body: string
|
||||
version: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/ai/system-prompts/${id}`),
|
||||
knowledgeSources: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
description?: string
|
||||
source_kind: string
|
||||
version: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/ai/knowledge-sources'),
|
||||
knowledgeSource: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
description?: string
|
||||
source_kind: string
|
||||
version: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/ai/knowledge-sources/${id}`),
|
||||
crisisPolicies: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
severity: string
|
||||
pattern: string
|
||||
action: string
|
||||
helpline_text?: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/crisis/policies'),
|
||||
crisisPolicy: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
severity: string
|
||||
pattern: string
|
||||
action: string
|
||||
helpline_text?: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/crisis/policies/${id}`),
|
||||
evaluateCrisis: (text: string) =>
|
||||
request<{
|
||||
matches: Array<{
|
||||
code: string
|
||||
title: string
|
||||
severity: string
|
||||
action: string
|
||||
helpline_text?: string
|
||||
}>
|
||||
}>('POST', '/crisis/evaluate', { text }),
|
||||
banners: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string
|
||||
link_path?: string
|
||||
sort_order: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/cms/banners'),
|
||||
banner: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string
|
||||
link_path?: string
|
||||
sort_order: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/cms/banners/${id}`),
|
||||
feedSlots: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
slot_key: string
|
||||
placement: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/cms/feed-slots'),
|
||||
feedSlot: (id: string) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
slot_key: string
|
||||
placement: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/cms/feed-slots/${id}`),
|
||||
publications: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/cms/publications'),
|
||||
publication: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/cms/publications/${id}`),
|
||||
knowledgeChunks: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/knowledge-chunks'),
|
||||
knowledgeChunk: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/ai/knowledge-chunks/${id}`),
|
||||
tools: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/ai/tools'),
|
||||
tool: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/ai/tools/${id}`),
|
||||
blockPolicies: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/block-policies'),
|
||||
blockPolicie: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/content-safety/block-policies/${id}`),
|
||||
cases: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/content-safety/cases'),
|
||||
case: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/content-safety/cases/${id}`),
|
||||
events: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/events'),
|
||||
event: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/crisis/events/${id}`),
|
||||
interventions: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/crisis/interventions'),
|
||||
intervention: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/crisis/interventions/${id}`),
|
||||
handoffs: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/ask/handoffs'),
|
||||
handoff: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/ask/handoffs/${id}`),
|
||||
requests: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/privacy/requests'),
|
||||
privacyRequest: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/privacy/requests/${id}`),
|
||||
starConfigs: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/star-configs'),
|
||||
starConfig: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/explore/star-configs/${id}`),
|
||||
rhythmConfigs: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/rhythm-configs'),
|
||||
rhythmConfig: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/explore/rhythm-configs/${id}`),
|
||||
imageCardDecks: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/image-card-decks'),
|
||||
imageCardDeck: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/explore/image-card-decks/${id}`),
|
||||
reportTemplates: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/growth/report-templates'),
|
||||
reportTemplate: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/growth/report-templates/${id}`),
|
||||
funnelDefinitions: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/analytics/funnel-definitions'),
|
||||
funnelDefinition: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/analytics/funnel-definitions/${id}`),
|
||||
exploreScales: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/explore/scales'),
|
||||
exploreScale: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/explore/scales/${id}`),
|
||||
orders: (params?: {
|
||||
status?: string
|
||||
kind?: string
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
export type Entitlement = Awaited<ReturnType<typeof adminApi.userEntitlements>>
|
||||
|
||||
defineProps<{
|
||||
data: Entitlement
|
||||
}>()
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
portrait: '愈心解码',
|
||||
star: '星座',
|
||||
rhythm: '节律',
|
||||
relation: '关系',
|
||||
synastry: '合盘',
|
||||
image_card: '意象卡',
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string | null) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="card block">
|
||||
<h2>权益概览</h2>
|
||||
<div class="kv">
|
||||
<div>
|
||||
<span>报告 detail(会员)</span>
|
||||
<strong>{{ data.flags.report_detail_via_membership ? '是' : '否' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>DeepAccess 份数</span>
|
||||
<strong>{{ data.flags.deep_access_count }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>已购问答余量</span>
|
||||
<strong>{{ data.ask_paid_quota_left }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="data.membership">
|
||||
{{ data.membership.active ? '有效' : '无效' }} ·
|
||||
{{ data.membership.plan || '—' }} ·
|
||||
会员问答 {{ data.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(data.membership.expires_at) }}
|
||||
</p>
|
||||
<p v-else class="muted">无会员记录</p>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>深度版(DeepAccess)</h2>
|
||||
<p v-if="!data.deep_accesses?.length" class="muted">无单份深度版</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>报告</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in data.deep_accesses" :key="d.id">
|
||||
<td>{{ typeLabel[d.report_type || ''] || d.report_type || '—' }}</td>
|
||||
<td><code>{{ d.report_id.slice(0, 8) }}…</code></td>
|
||||
<td>{{ fmtTime(d.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="hint">
|
||||
<RouterLink :to="`/users/${data.user_id}`">返回基础信息可授予会员 / 问答额度</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.kv span { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.15rem; }
|
||||
.hint { margin-top: 0.75rem; font-size: 0.85rem; }
|
||||
.hint a { color: var(--accent); }
|
||||
</style>
|
||||
@@ -28,6 +28,14 @@ async function onLogout() {
|
||||
<RouterLink to="/analytics">数据</RouterLink>
|
||||
<RouterLink to="/content">内容</RouterLink>
|
||||
<RouterLink to="/users">用户</RouterLink>
|
||||
<RouterLink to="/plans">套餐</RouterLink>
|
||||
<RouterLink to="/codes">兑换码</RouterLink>
|
||||
<RouterLink to="/ask">问答</RouterLink>
|
||||
<RouterLink to="/safety">安全</RouterLink>
|
||||
<RouterLink to="/ai">AI</RouterLink>
|
||||
<RouterLink to="/crisis">危机</RouterLink>
|
||||
<RouterLink to="/cms">CMS</RouterLink>
|
||||
<RouterLink to="/catalogs">目录仓</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
|
||||
<RouterLink to="/push">推送</RouterLink>
|
||||
@@ -35,7 +43,8 @@ async function onLogout() {
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
<div class="foot">
|
||||
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span>
|
||||
<span class="muted">{{ auth.username || '管理员' }}</span>
|
||||
<span v-if="auth.role" class="role">{{ auth.role }} · {{ auth.permissions.length }} 权</span>
|
||||
<button class="btn ghost" type="button" @click="onLogout">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -75,6 +84,7 @@ nav a.router-link-active {
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.role { font-size: 0.75rem; color: var(--muted); }
|
||||
.main { padding: 1.5rem 1.75rem; }
|
||||
@media (max-width: 800px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Prompt = Awaited<ReturnType<typeof adminApi.systemPrompts>>['items'][number]
|
||||
type Source = Awaited<ReturnType<typeof adminApi.knowledgeSources>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Prompt[]>([])
|
||||
const selected = ref<Prompt | null>(null)
|
||||
const detailErr = ref('')
|
||||
|
||||
const ksLoading = ref(false)
|
||||
const ksError = ref('')
|
||||
const sources = ref<Source[]>([])
|
||||
const selectedSource = ref<Source | null>(null)
|
||||
const ksDetailErr = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.systemPrompts()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
ksLoading.value = true
|
||||
ksError.value = ''
|
||||
try {
|
||||
const res = await adminApi.knowledgeSources()
|
||||
sources.value = res.items || []
|
||||
} catch (e) {
|
||||
ksError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
ksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrompt(id: string) {
|
||||
detailErr.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.systemPrompt(id)
|
||||
} catch (e) {
|
||||
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
selected.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSource(id: string) {
|
||||
ksDetailErr.value = ''
|
||||
try {
|
||||
selectedSource.value = await adminApi.knowledgeSource(id)
|
||||
} catch (e) {
|
||||
ksDetailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
selectedSource.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadSources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>AI 配置</h1>
|
||||
<p class="muted">SystemPrompt / KnowledgeSource 只读目录 · 本切片不可编辑发布</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>系统提示词</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>版本</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td><code>{{ p.code }}</code></td>
|
||||
<td>{{ p.title }}</td>
|
||||
<td>v{{ p.version }}</td>
|
||||
<td>{{ p.active ? '启用' : '停用' }}{{ p.system ? ' · 系统' : '' }}</td>
|
||||
<td><button class="btn" type="button" @click="openPrompt(p.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>正文</h2>
|
||||
<p v-if="detailErr" class="err">{{ detailErr }}</p>
|
||||
<template v-else-if="selected">
|
||||
<p class="meta">{{ selected.title }} · 更新 {{ fmtTime(selected.updated_at) }}</p>
|
||||
<pre>{{ selected.body }}</pre>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧提示词查看正文</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="ksLoading" class="muted ks-gap">知识源加载中…</p>
|
||||
<p v-else-if="ksError" class="err ks-gap">{{ ksError }}</p>
|
||||
<div v-else class="layout ks-gap">
|
||||
<div class="card">
|
||||
<h2>知识源</h2>
|
||||
<p v-if="!sources.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>类型</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sources" :key="s.id">
|
||||
<td><code>{{ s.code }}</code></td>
|
||||
<td>{{ s.title }}</td>
|
||||
<td>{{ s.source_kind }}</td>
|
||||
<td>{{ s.active ? '启用' : '停用' }}{{ s.system ? ' · 系统' : '' }}</td>
|
||||
<td><button class="btn" type="button" @click="openSource(s.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>知识源详情</h2>
|
||||
<p v-if="ksDetailErr" class="err">{{ ksDetailErr }}</p>
|
||||
<template v-else-if="selectedSource">
|
||||
<p class="meta">
|
||||
{{ selectedSource.title }} · {{ selectedSource.source_kind }} ·
|
||||
更新 {{ fmtTime(selectedSource.updated_at) }}
|
||||
</p>
|
||||
<p>{{ selectedSource.description || '无描述' }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧知识源查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1fr 1.1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.ks-gap { margin-top: 1.5rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 253, 251, 0.8);
|
||||
}
|
||||
code { font-size: 0.8rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
||||
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
|
||||
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Thread[]>([])
|
||||
const selected = ref<Detail | null>(null)
|
||||
const detailErr = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const feedback = ref<Feedback[]>([])
|
||||
const rating = ref(4)
|
||||
const tag = ref('helpful')
|
||||
const note = ref('')
|
||||
const fbMsg = ref('')
|
||||
|
||||
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [threads, fb] = await Promise.all([adminApi.askThreads(), adminApi.askFeedback()])
|
||||
items.value = threads.items || []
|
||||
feedback.value = fb.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openThread(id: string) {
|
||||
detailLoading.value = true
|
||||
detailErr.value = ''
|
||||
selected.value = null
|
||||
fbMsg.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.askThread(id)
|
||||
} catch (e) {
|
||||
detailErr.value = e instanceof Error ? e.message : '详情失败'
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback() {
|
||||
if (!selected.value) return
|
||||
fbMsg.value = ''
|
||||
try {
|
||||
await adminApi.createAskFeedback(selected.value.id, {
|
||||
rating: rating.value,
|
||||
tag: tag.value,
|
||||
note: note.value || undefined,
|
||||
})
|
||||
fbMsg.value = '已提交质量反馈'
|
||||
note.value = ''
|
||||
const fb = await adminApi.askFeedback()
|
||||
feedback.value = fb.items || []
|
||||
} catch (e) {
|
||||
fbMsg.value = e instanceof Error ? e.message : '提交失败'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso?: string) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>问答会话</h1>
|
||||
<p class="muted">AskSessionView · QualityFeedback · 不可改写消息</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card list">
|
||||
<p v-if="!items.length" class="muted">暂无会话</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>更新</th><th>场景</th><th>消息</th><th>用户</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in items" :key="t.id">
|
||||
<td>{{ fmtTime(t.updated_at) }}</td>
|
||||
<td>{{ t.scene || '—' }}</td>
|
||||
<td>{{ t.message_count }}</td>
|
||||
<td>
|
||||
<RouterLink :to="`/users/${t.user_id}`">{{ t.user_id.slice(0, 8) }}…</RouterLink>
|
||||
</td>
|
||||
<td><button class="btn" type="button" @click="openThread(t.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 class="sub">近期反馈</h2>
|
||||
<p v-if="!feedback.length" class="muted">暂无</p>
|
||||
<ul v-else class="fb-list">
|
||||
<li v-for="f in feedback.slice(0, 8)" :key="f.id">
|
||||
{{ f.source }} · {{ f.rating }}★ · {{ f.tag || '—' }} · {{ fmtTime(f.created_at) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card detail">
|
||||
<h2>会话详情</h2>
|
||||
<p v-if="detailLoading" class="muted">加载中…</p>
|
||||
<p v-else-if="detailErr" class="err">{{ detailErr }}</p>
|
||||
<template v-else-if="selected">
|
||||
<p class="meta">
|
||||
{{ selected.scene || '—' }} · {{ selected.message_count }} 条 ·
|
||||
<RouterLink :to="`/users/${selected.user_id}`">用户</RouterLink>
|
||||
</p>
|
||||
<ul class="msgs">
|
||||
<li v-for="m in selected.messages" :key="m.id" :class="m.role">
|
||||
<span class="role">{{ m.role }}</span>
|
||||
<p>{{ m.content }}</p>
|
||||
<time>{{ fmtTime(m.created_at) }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="canWriteFeedback()" class="fb-form">
|
||||
<h3>质量反馈</h3>
|
||||
<select v-model.number="rating">
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
<option :value="4">4</option>
|
||||
<option :value="5">5</option>
|
||||
</select>
|
||||
<select v-model="tag">
|
||||
<option value="helpful">helpful</option>
|
||||
<option value="off_topic">off_topic</option>
|
||||
<option value="unsafe">unsafe</option>
|
||||
<option value="other">other</option>
|
||||
</select>
|
||||
<input v-model="note" type="text" placeholder="备注(可选)" />
|
||||
<button class="btn" type="button" @click="submitFeedback">提交</button>
|
||||
<span class="muted">{{ fbMsg }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧会话查看消息</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
h2.sub { margin-top: 1rem; font-size: 0.95rem; }
|
||||
h3 { margin: 0.75rem 0 0.4rem; font-size: 0.95rem; }
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.list table { width: 100%; font-size: 0.88rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; }
|
||||
.msgs { list-style: none; margin: 0.75rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.65rem; }
|
||||
.msgs li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
.msgs .role { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
|
||||
.msgs p { margin: 0.25rem 0; white-space: pre-wrap; word-break: break-word; }
|
||||
.msgs time { font-size: 0.75rem; color: var(--muted); }
|
||||
.msgs .assistant { background: rgba(255, 244, 242, 0.6); }
|
||||
.fb-list { margin: 0; padding-left: 1.1rem; font-size: 0.85rem; color: var(--muted); }
|
||||
.fb-form {
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
|
||||
margin-top: 0.85rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
|
||||
}
|
||||
.fb-form select, .fb-form input {
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 0.4rem 0.55rem;
|
||||
}
|
||||
.fb-form input { min-width: 10rem; flex: 1; }
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
|
||||
type FeedSlot = Awaited<ReturnType<typeof adminApi.feedSlots>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Banner[]>([])
|
||||
const selected = ref<Banner | null>(null)
|
||||
|
||||
const slotsLoading = ref(false)
|
||||
const slotsError = ref('')
|
||||
const slots = ref<FeedSlot[]>([])
|
||||
const selectedSlot = ref<FeedSlot | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.banners()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
slotsLoading.value = true
|
||||
slotsError.value = ''
|
||||
try {
|
||||
const res = await adminApi.feedSlots()
|
||||
slots.value = res.items || []
|
||||
} catch (e) {
|
||||
slotsError.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
slotsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openBanner(id: string) {
|
||||
try {
|
||||
selected.value = await adminApi.banner(id)
|
||||
} catch {
|
||||
selected.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function openSlot(id: string) {
|
||||
try {
|
||||
selectedSlot.value = await adminApi.feedSlot(id)
|
||||
} catch {
|
||||
selectedSlot.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadSlots()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>运营位 CMS</h1>
|
||||
<p class="muted">Banner / FeedSlot 只读 · 非 UGC · 本切片不可发布</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>横幅</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>位置</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in items" :key="b.id">
|
||||
<td><code>{{ b.code }}</code></td>
|
||||
<td>{{ b.title }}</td>
|
||||
<td>{{ b.placement }}</td>
|
||||
<td>{{ b.active ? '启用' : '停用' }}</td>
|
||||
<td><button class="btn" type="button" @click="openBanner(b.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>横幅详情</h2>
|
||||
<template v-if="selected">
|
||||
<p>{{ selected.title }} · {{ selected.placement }}</p>
|
||||
<p class="muted">链接 {{ selected.link_path || '—' }} · 排序 {{ selected.sort_order }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧横幅</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="slotsLoading" class="muted gap">栏目位加载中…</p>
|
||||
<p v-else-if="slotsError" class="err gap">{{ slotsError }}</p>
|
||||
<div v-else class="layout gap">
|
||||
<div class="card">
|
||||
<h2>栏目位 FeedSlot</h2>
|
||||
<p v-if="!slots.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>slot_key</th><th>位置</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in slots" :key="s.id">
|
||||
<td><code>{{ s.code }}</code></td>
|
||||
<td>{{ s.title }}</td>
|
||||
<td><code>{{ s.slot_key }}</code></td>
|
||||
<td>{{ s.placement }}</td>
|
||||
<td><button class="btn" type="button" @click="openSlot(s.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>栏目位详情</h2>
|
||||
<template v-if="selectedSlot">
|
||||
<p>{{ selectedSlot.title }} · {{ selectedSlot.placement }}</p>
|
||||
<p class="muted">slot_key {{ selectedSlot.slot_key }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧栏目位</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.gap { margin-top: 1.5rem; }
|
||||
code { font-size: 0.8rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Row = { id: string; code?: string; slug?: string; title?: string; status?: string }
|
||||
|
||||
const catalogs = [
|
||||
{ key: 'publications', label: '定时发布', load: () => adminApi.publications() },
|
||||
{ key: 'knowledgeChunks', label: '知识块', load: () => adminApi.knowledgeChunks() },
|
||||
{ key: 'tools', label: '工具定义', load: () => adminApi.tools() },
|
||||
{ key: 'blockPolicies', label: '拦截策略', load: () => adminApi.blockPolicies() },
|
||||
{ key: 'cases', label: '审核案', load: () => adminApi.cases() },
|
||||
{ key: 'events', label: '危机事件', load: () => adminApi.events() },
|
||||
{ key: 'interventions', label: '干预结果', load: () => adminApi.interventions() },
|
||||
{ key: 'handoffs', label: '转接案', load: () => adminApi.handoffs() },
|
||||
{ key: 'privacy', label: '隐私请求', load: () => adminApi.requests() },
|
||||
{ key: 'star', label: '星座配置', load: () => adminApi.starConfigs() },
|
||||
{ key: 'rhythm', label: '节律配置', load: () => adminApi.rhythmConfigs() },
|
||||
{ key: 'decks', label: '意象牌组', load: () => adminApi.imageCardDecks() },
|
||||
{ key: 'templates', label: '报告模板', load: () => adminApi.reportTemplates() },
|
||||
{ key: 'funnels', label: '漏斗定义', load: () => adminApi.funnelDefinitions() },
|
||||
{ key: 'scales', label: '量表定义', load: () => adminApi.exploreScales() },
|
||||
] as const
|
||||
|
||||
const active = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Row[]>([])
|
||||
|
||||
async function load(idx = active.value) {
|
||||
active.value = idx
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = (await catalogs[idx].load()) as { items?: Row[] }
|
||||
items.value = (res.items || []) as Row[]
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load(0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>目录只读仓</h1>
|
||||
<p class="muted">ECR-026…040 运营只读目录聚合(不可写发布)</p>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="(c, i) in catalogs"
|
||||
:key="c.key"
|
||||
class="btn"
|
||||
:class="{ on: i === active }"
|
||||
type="button"
|
||||
@click="load(i)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="card">
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>标识</th><th>标题</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="it in items" :key="it.id">
|
||||
<td><code>{{ it.code || it.slug || it.id.slice(0, 8) }}</code></td>
|
||||
<td>{{ it.title || '—' }}</td>
|
||||
<td>{{ it.status || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 1rem 0; }
|
||||
.btn.on { background: #ffe4e0; color: var(--accent); font-weight: 700; }
|
||||
code { font-size: 0.8rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Policy = Awaited<ReturnType<typeof adminApi.crisisPolicies>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Policy[]>([])
|
||||
const sample = ref('真的不想活了,怎么办')
|
||||
const matches = ref<
|
||||
Array<{ code: string; title: string; severity: string; action: string; helpline_text?: string }>
|
||||
>([])
|
||||
const evalMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.crisisPolicies()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runEval() {
|
||||
evalMsg.value = ''
|
||||
try {
|
||||
const res = await adminApi.evaluateCrisis(sample.value)
|
||||
matches.value = res.matches || []
|
||||
evalMsg.value = matches.value.length ? `命中 ${matches.value.length} 条` : '未命中'
|
||||
} catch (e) {
|
||||
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
|
||||
matches.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>危机策略</h1>
|
||||
<p class="muted">CrisisPolicy 只读 · 试匹配不写 CrisisEvent · 非医疗诊断</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>策略目录</h2>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>严重度</th><th>动作</th><th>模式</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td>{{ p.title }} <code>{{ p.code }}</code></td>
|
||||
<td>{{ p.severity }}</td>
|
||||
<td>{{ p.action }}</td>
|
||||
<td>{{ p.pattern }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>试匹配</h2>
|
||||
<textarea v-model="sample" rows="4" />
|
||||
<div class="row">
|
||||
<button class="btn" type="button" @click="runEval">试匹配</button>
|
||||
<span class="muted">{{ evalMsg }}</span>
|
||||
</div>
|
||||
<ul v-if="matches.length" class="hits">
|
||||
<li v-for="m in matches" :key="m.code">
|
||||
<strong>{{ m.title }}</strong> · {{ m.severity }} · {{ m.action }}
|
||||
<p v-if="m.helpline_text" class="help">{{ m.helpline_text }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
|
||||
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
|
||||
.help { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.85rem; }
|
||||
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Plan = {
|
||||
code: string
|
||||
title: string
|
||||
duration_days: number
|
||||
amount_cents: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const msg = ref('')
|
||||
const items = ref<Plan[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.membershipPlans()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(p: Plan) {
|
||||
msg.value = ''
|
||||
try {
|
||||
await adminApi.updateMembershipPlan(p.code, {
|
||||
title: p.title,
|
||||
duration_days: Number(p.duration_days),
|
||||
amount_cents: Number(p.amount_cents),
|
||||
active: p.active,
|
||||
})
|
||||
msg.value = `${p.code} 已保存`
|
||||
await load()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '保存失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>会员套餐</h1>
|
||||
<p class="muted">配置成长会员时长与标价(mock 履约读表)。</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<div v-for="p in items" :key="p.code" class="card block">
|
||||
<h2>{{ p.code }}</h2>
|
||||
<div class="row">
|
||||
<label>标题 <input v-model="p.title" /></label>
|
||||
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
|
||||
<label>标价(分) <input v-model.number="p.amount_cents" type="number" min="0" /></label>
|
||||
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
|
||||
<button
|
||||
v-if="auth.can('admin.membership.plans.write')"
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="save(p)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; text-transform: uppercase; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
|
||||
input { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
|
||||
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const msg = ref('')
|
||||
const label = ref('batch')
|
||||
const plan = ref('month')
|
||||
const qty = ref(5)
|
||||
const batches = ref<Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }>>([])
|
||||
const codes = ref<Array<{ id: string; code: string; status: string }>>([])
|
||||
const activeBatch = ref('')
|
||||
|
||||
async function loadBatches() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.redemptionBatches()
|
||||
batches.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
msg.value = ''
|
||||
try {
|
||||
const res = await adminApi.createRedemptionBatch({
|
||||
label: label.value,
|
||||
plan_code: plan.value,
|
||||
quantity: Number(qty.value),
|
||||
})
|
||||
msg.value = `已生成 ${res.codes.length} 个码`
|
||||
activeBatch.value = res.batch.id
|
||||
codes.value = res.codes
|
||||
await loadBatches()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function openBatch(id: string) {
|
||||
activeBatch.value = id
|
||||
const res = await adminApi.redemptionCodes(id)
|
||||
codes.value = res.items || []
|
||||
}
|
||||
|
||||
async function disable(id: string) {
|
||||
await adminApi.disableRedemptionCode(id)
|
||||
if (activeBatch.value) await openBatch(activeBatch.value)
|
||||
}
|
||||
|
||||
onMounted(loadBatches)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>兑换码</h1>
|
||||
<p class="muted">批量生成会员兑换码(禁真支付)。</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
|
||||
<div v-if="auth.can('admin.membership.codes.write')" class="card block">
|
||||
<h2>生成批次</h2>
|
||||
<div class="row">
|
||||
<label>标签 <input v-model="label" /></label>
|
||||
<label>套餐
|
||||
<select v-model="plan">
|
||||
<option value="month">month</option>
|
||||
<option value="quarter">quarter</option>
|
||||
<option value="year">year</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>数量 <input v-model.number="qty" type="number" min="1" max="100" /></label>
|
||||
<button class="btn" type="button" @click="createBatch">生成</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>批次</h2>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<ul v-else>
|
||||
<li v-for="b in batches" :key="b.id">
|
||||
<button class="link" type="button" @click="openBatch(b.id)">
|
||||
{{ b.label }} · {{ b.plan_code }} × {{ b.quantity }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="codes.length" class="card block">
|
||||
<h2>码列表</h2>
|
||||
<table>
|
||||
<thead><tr><th>码</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in codes" :key="c.id">
|
||||
<td><code>{{ c.code }}</code></td>
|
||||
<td>{{ c.status }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="c.status === 'unused' && auth.can('admin.membership.codes.write')"
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
@click="disable(c.id)"
|
||||
>
|
||||
作废
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
|
||||
input, select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
|
||||
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0.2rem 0; }
|
||||
ul { margin: 0; padding-left: 1rem; }
|
||||
code { font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Rule = Awaited<ReturnType<typeof adminApi.filterRules>>['items'][number]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Rule[]>([])
|
||||
const sample = ref('我不想活了,求帮助')
|
||||
const matches = ref<Array<{ code: string; title: string; category: string; action: string }>>([])
|
||||
const evalMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.filterRules()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runEval() {
|
||||
evalMsg.value = ''
|
||||
try {
|
||||
const res = await adminApi.evaluateContent(sample.value)
|
||||
matches.value = res.matches || []
|
||||
evalMsg.value = matches.value.length ? `命中 ${matches.value.length} 条` : '未命中'
|
||||
} catch (e) {
|
||||
evalMsg.value = e instanceof Error ? e.message : '试匹配失败'
|
||||
matches.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>内容安全</h1>
|
||||
<p class="muted">FilterRule 只读 · 试匹配不写审核工单</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>过滤规则</h2>
|
||||
<p v-if="!items.length" class="muted">暂无规则</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>分类</th><th>动作</th><th>模式</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in items" :key="r.id">
|
||||
<td>{{ r.title }} <code>{{ r.code }}</code></td>
|
||||
<td>{{ r.category }}</td>
|
||||
<td>{{ r.action }}</td>
|
||||
<td>{{ r.pattern }}</td>
|
||||
<td>{{ r.active ? '启用' : '停用' }}{{ r.system ? ' · 系统' : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>试匹配</h2>
|
||||
<textarea v-model="sample" rows="4" />
|
||||
<div class="row">
|
||||
<button class="btn" type="button" @click="runEval">试匹配</button>
|
||||
<span class="muted">{{ evalMsg }}</span>
|
||||
</div>
|
||||
<ul v-if="matches.length" class="hits">
|
||||
<li v-for="m in matches" :key="m.code">
|
||||
{{ m.title }} · {{ m.category }} · <strong>{{ m.action }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
.row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.6rem; }
|
||||
.hits { margin: 0.75rem 0 0; padding-left: 1.1rem; }
|
||||
code { font-size: 0.75rem; color: var(--muted); margin-left: 0.25rem; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,25 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { adminApi, type UserDetail } from '@/api/client'
|
||||
import UserEntitlementPanel, { type Entitlement } from '@/components/UserEntitlementPanel.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Insight = Awaited<ReturnType<typeof adminApi.userInsight>>
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detail = ref<UserDetail | null>(null)
|
||||
const tab = ref<'base' | 'insight' | 'entitlement'>('base')
|
||||
const insight = ref<Insight | null>(null)
|
||||
const insightErr = ref('')
|
||||
const insightLoading = ref(false)
|
||||
const entitlement = ref<Entitlement | null>(null)
|
||||
const entitlementErr = ref('')
|
||||
const entitlementLoading = ref(false)
|
||||
const plan = ref('month')
|
||||
const askDelta = ref(10)
|
||||
const grantMsg = ref('')
|
||||
const askMsg = ref('')
|
||||
const banMsg = ref('')
|
||||
const nextStatus = ref('banned')
|
||||
const statusReason = ref('')
|
||||
const statusMsg = ref('')
|
||||
const transitions = ref<
|
||||
Array<{
|
||||
from_status: string
|
||||
to_status: string
|
||||
reason: string
|
||||
created_at: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
const canWriteStatus = computed(() => auth.can('admin.users.status.write'))
|
||||
const canGrantMembership = computed(() => auth.can('admin.users.membership.grant'))
|
||||
const canGrantAskQuota = computed(() => auth.can('admin.users.ask_quota.grant'))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
detail.value = await adminApi.user(String(route.params.id))
|
||||
const id = String(route.params.id)
|
||||
detail.value = await adminApi.user(id)
|
||||
try {
|
||||
const tr = await adminApi.statusTransitions(id)
|
||||
transitions.value = tr.items || []
|
||||
} catch {
|
||||
transitions.value = []
|
||||
}
|
||||
if (tab.value === 'insight') await loadInsight()
|
||||
if (tab.value === 'entitlement') await loadEntitlement()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
@@ -27,6 +60,37 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInsight() {
|
||||
insightLoading.value = true
|
||||
insightErr.value = ''
|
||||
try {
|
||||
insight.value = await adminApi.userInsight(String(route.params.id))
|
||||
} catch (e) {
|
||||
insightErr.value = e instanceof Error ? e.message : '洞察加载失败'
|
||||
insight.value = null
|
||||
} finally {
|
||||
insightLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlement() {
|
||||
entitlementLoading.value = true
|
||||
entitlementErr.value = ''
|
||||
try {
|
||||
entitlement.value = await adminApi.userEntitlements(String(route.params.id))
|
||||
} catch (e) {
|
||||
entitlementErr.value = e instanceof Error ? e.message : '权益加载失败'
|
||||
entitlement.value = null
|
||||
} finally {
|
||||
entitlementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(tab, (v) => {
|
||||
if (v === 'insight' && !insight.value && !insightLoading.value) void loadInsight()
|
||||
if (v === 'entitlement' && !entitlement.value && !entitlementLoading.value) void loadEntitlement()
|
||||
})
|
||||
|
||||
async function grant() {
|
||||
grantMsg.value = ''
|
||||
try {
|
||||
@@ -49,19 +113,31 @@ async function grantAsk() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus() {
|
||||
statusMsg.value = ''
|
||||
try {
|
||||
await adminApi.setUserStatus(String(route.params.id), nextStatus.value, statusReason.value)
|
||||
statusMsg.value = '状态已更新'
|
||||
statusReason.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
statusMsg.value = e instanceof Error ? e.message : '状态更新失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan() {
|
||||
banMsg.value = ''
|
||||
statusMsg.value = ''
|
||||
try {
|
||||
if (detail.value?.status === 'banned') {
|
||||
await adminApi.unbanUser(String(route.params.id))
|
||||
banMsg.value = '已解封'
|
||||
statusMsg.value = '已解封'
|
||||
} else {
|
||||
await adminApi.banUser(String(route.params.id))
|
||||
banMsg.value = '已封禁'
|
||||
statusMsg.value = '已封禁'
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
banMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
statusMsg.value = e instanceof Error ? e.message : '操作失败'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,107 +169,199 @@ onMounted(load)
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="detail">
|
||||
<div class="card block">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'base' }" @click="tab = 'base'">基础</button>
|
||||
<button type="button" :class="{ on: tab === 'insight' }" @click="tab = 'insight'">洞察</button>
|
||||
<button type="button" :class="{ on: tab === 'entitlement' }" @click="tab = 'entitlement'">权益</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'base'">
|
||||
<div class="card block">
|
||||
<h2>账号</h2>
|
||||
<div class="kv">
|
||||
<div><span>昵称</span><strong>{{ detail.nickname || '—' }}</strong></div>
|
||||
<div><span>手机</span><strong>{{ detail.phone || '—' }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ detail.status }}</strong></div>
|
||||
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
|
||||
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
|
||||
</div>
|
||||
<div v-if="canWriteStatus" class="grant">
|
||||
<select v-model="nextStatus">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="banned">banned</option>
|
||||
<option value="suspended">suspended</option>
|
||||
</select>
|
||||
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
||||
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
||||
<button class="btn ghost" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '快捷解封' : '快捷封禁' }}
|
||||
</button>
|
||||
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
||||
</div>
|
||||
<div v-if="transitions.length" class="trans">
|
||||
<h3>状态迁移</h3>
|
||||
<ul>
|
||||
<li v-for="(t, i) in transitions" :key="i">
|
||||
{{ t.from_status }} → {{ t.to_status }} · {{ t.reason || '—' }} · {{ fmtTime(t.created_at) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grant">
|
||||
<button class="btn" type="button" @click="toggleBan">
|
||||
{{ detail.status === 'banned' ? '解封' : '封禁' }}
|
||||
</button>
|
||||
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="canGrantMembership" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无会员授予权限</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长会员</h2>
|
||||
<p v-if="detail.membership">
|
||||
{{ detail.membership.active ? '有效' : '无效' }} ·
|
||||
{{ detail.membership.plan || '—' }} ·
|
||||
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
|
||||
到期 {{ fmtTime(detail.membership.expires_at) }}
|
||||
</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model="plan">
|
||||
<option value="month">月卡</option>
|
||||
<option value="quarter">季卡</option>
|
||||
<option value="year">年卡</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grant">授予 / 延长</button>
|
||||
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="canGrantAskQuota" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
</div>
|
||||
<p v-else class="muted">无问答额度授予权限</p>
|
||||
</div>
|
||||
<p v-else class="muted">仅超级管理员可授予会员</p>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>问答额度(已购)</h2>
|
||||
<p>已购余量:<strong>{{ detail.ask_paid_quota_left }}</strong> 次</p>
|
||||
<div v-if="auth.isSuper" class="grant">
|
||||
<select v-model.number="askDelta">
|
||||
<option :value="10">+10</option>
|
||||
<option :value="30">+30</option>
|
||||
<option :value="100">+100</option>
|
||||
</select>
|
||||
<button class="btn" type="button" @click="grantAsk">增加额度</button>
|
||||
<span v-if="askMsg" class="muted">{{ askMsg }}</span>
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>档案({{ detail.profiles?.length || 0 }})</h2>
|
||||
<p v-if="!detail.profiles?.length" class="muted">无档案</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>称呼</th><th>关系</th><th>生日</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in detail.profiles" :key="p.id">
|
||||
<td>{{ p.display_name || '未命名' }}</td>
|
||||
<td>{{ p.relation }}</td>
|
||||
<td>{{ p.birth_date || '—' }}</td>
|
||||
<td><code>{{ p.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card block">
|
||||
<h2>成长报告(近 {{ detail.reports?.length || 0 }})</h2>
|
||||
<p v-if="!detail.reports?.length" class="muted">无报告</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>类型</th><th>时间</th><th>ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in detail.reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
<td><code>{{ r.id.slice(0, 8) }}…</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card block">
|
||||
<h2>近订单</h2>
|
||||
<p v-if="!detail.recent_orders?.length" class="muted">无订单</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="o in detail.recent_orders" :key="o.id">
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ fmtTime(o.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template v-else-if="tab === 'insight'">
|
||||
<p v-if="insightLoading" class="muted">加载洞察…</p>
|
||||
<p v-else-if="insightErr" class="err">{{ insightErr }}</p>
|
||||
<template v-else-if="insight">
|
||||
<div class="card block">
|
||||
<h2>概览</h2>
|
||||
<div class="kv">
|
||||
<div><span>档案数</span><strong>{{ insight.profiles_count }}</strong></div>
|
||||
<div><span>问答线程</span><strong>{{ insight.behavior.ask_thread_count }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>心理标签(派生)</h2>
|
||||
<p v-if="!insight.tags?.length" class="muted">暂无(无成长报告类型)</p>
|
||||
<ul v-else class="tags">
|
||||
<li v-for="t in insight.tags" :key="t.code">{{ t.label }} <code>{{ t.code }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>报告类型</h2>
|
||||
<p v-if="!insight.reports_by_type?.length" class="muted">无</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in insight.reports_by_type" :key="c.type">
|
||||
<td>{{ typeLabel[c.type] || c.type }}</td>
|
||||
<td>{{ c.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>近报告</h2>
|
||||
<p v-if="!insight.recent_reports?.length" class="muted">无</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>类型</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in insight.recent_reports" :key="r.id">
|
||||
<td>{{ typeLabel[r.type] || r.type }}</td>
|
||||
<td>{{ fmtTime(r.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<h2>行为快照</h2>
|
||||
<p v-if="!insight.behavior.events?.length" class="muted">暂无埋点(仍为正常)</p>
|
||||
<table v-else>
|
||||
<thead><tr><th>事件</th><th>页面</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(e, i) in insight.behavior.events" :key="i">
|
||||
<td>{{ e.name }}</td>
|
||||
<td>{{ e.page_path || '—' }}</td>
|
||||
<td>{{ fmtTime(e.received_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'entitlement'">
|
||||
<p v-if="entitlementLoading" class="muted">加载权益…</p>
|
||||
<p v-else-if="entitlementErr" class="err">{{ entitlementErr }}</p>
|
||||
<UserEntitlementPanel v-else-if="entitlement" :data="entitlement" />
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -203,6 +371,17 @@ onMounted(load)
|
||||
.crumb a { color: var(--accent); }
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0.75rem 0 0.35rem; font-size: 0.95rem; }
|
||||
.tabs { display: flex; gap: 0.35rem; margin-bottom: 1rem; }
|
||||
.tabs button {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tabs button.on { color: var(--text); border-color: var(--accent); }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.kv {
|
||||
display: grid;
|
||||
@@ -213,5 +392,19 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.kv .wide { grid-column: 1 / -1; }
|
||||
.kv code { font-size: 0.82rem; word-break: break-all; }
|
||||
.grant { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.75rem; }
|
||||
.grant select { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; }
|
||||
.grant select, .grant .reason {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
.grant .reason { min-width: 12rem; }
|
||||
.trans ul { margin: 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.tags li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tags code { margin-left: 0.35rem; font-size: 0.75rem; color: var(--muted); }
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,15 @@ const router = createRouter({
|
||||
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
|
||||
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
|
||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
||||
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
|
||||
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') },
|
||||
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
|
||||
{ path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
|
||||
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
|
||||
{ path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') },
|
||||
{ path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.vue') },
|
||||
{ path: 'cms', name: 'cms', component: () => import('@/pages/CMSPage.vue') },
|
||||
{ path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') },
|
||||
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
|
||||
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
|
||||
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { adminApi, getToken, setToken, type AdminRole } from '@/api/client'
|
||||
import { adminApi, getToken, setToken, type AdminMe } from '@/api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const username = ref<string>('')
|
||||
const role = ref<AdminRole>('ops')
|
||||
const role = ref<string>('')
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
const isSuper = computed(() => role.value === 'super')
|
||||
const isSuper = computed(
|
||||
() =>
|
||||
role.value === 'super' ||
|
||||
role.value === 'super_admin' ||
|
||||
permissions.value.includes('admin.roles.write'),
|
||||
)
|
||||
|
||||
function applyMe(me: AdminMe) {
|
||||
username.value = me.username
|
||||
role.value = me.role || ''
|
||||
permissions.value = me.permissions ?? []
|
||||
}
|
||||
|
||||
async function login(user: string, password: string) {
|
||||
const res = await adminApi.login(user, password)
|
||||
setToken(res.token)
|
||||
token.value = res.token
|
||||
username.value = res.admin.username
|
||||
role.value = res.admin.role || 'super'
|
||||
applyMe(res.admin)
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const me = await adminApi.me()
|
||||
username.value = me.username
|
||||
role.value = me.role || 'super'
|
||||
applyMe(me)
|
||||
return true
|
||||
} catch {
|
||||
setToken(null)
|
||||
@@ -40,8 +50,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setToken(null)
|
||||
token.value = null
|
||||
username.value = ''
|
||||
role.value = 'ops'
|
||||
role.value = ''
|
||||
permissions.value = []
|
||||
}
|
||||
|
||||
return { token, username, role, isSuper, login, logout, hydrate }
|
||||
function can(code: string) {
|
||||
return isSuper.value || permissions.value.includes(code)
|
||||
}
|
||||
|
||||
return { token, username, role, permissions, isSuper, can, login, logout, hydrate }
|
||||
})
|
||||
|
||||
@@ -30,22 +30,49 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/stats", h.Stats)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
authed.GET("/stats", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.Stats)
|
||||
authed.GET("/users", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUsers)
|
||||
authed.GET("/users/:id", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
|
||||
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
|
||||
authed.GET("/membership/plan-prices", h.ListPlanPrices)
|
||||
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
|
||||
authed.GET("/audit-logs", h.ListAudit)
|
||||
authed.GET("/analytics/overview", h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
|
||||
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsFunnel)
|
||||
h.registerContent(authed)
|
||||
h.registerSystem(authed)
|
||||
h.registerRBAC(authed)
|
||||
h.registerLifecycle(authed)
|
||||
h.registerMembershipPlans(authed)
|
||||
h.registerRedemption(authed)
|
||||
h.registerInsight(authed)
|
||||
h.registerAskOps(authed)
|
||||
h.registerQualityFeedback(authed)
|
||||
h.registerEntitlement(authed)
|
||||
h.registerContentSafety(authed)
|
||||
h.registerAIConfig(authed)
|
||||
h.registerCrisis(authed)
|
||||
h.registerCMS(authed)
|
||||
h.registerCMSPublications(authed)
|
||||
h.registerKnowledgeChunks(authed)
|
||||
h.registerToolDefinitions(authed)
|
||||
h.registerBlockPolicies(authed)
|
||||
h.registerModerationCases(authed)
|
||||
h.registerCrisisEvents(authed)
|
||||
h.registerInterventionOutcomes(authed)
|
||||
h.registerHandoffCases(authed)
|
||||
h.registerPrivacyRequests(authed)
|
||||
h.registerStarConfigs(authed)
|
||||
h.registerRhythmConfigs(authed)
|
||||
h.registerImageCardDecks(authed)
|
||||
h.registerReportTemplates(authed)
|
||||
h.registerFunnelDefinitions(authed)
|
||||
h.registerExploreScales(authed)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerAIConfig(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/system-prompts", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListSystemPrompts)
|
||||
g.GET("/system-prompts/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetSystemPrompt)
|
||||
g.GET("/knowledge-sources", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListKnowledgeSources)
|
||||
g.GET("/knowledge-sources/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetKnowledgeSource)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListSystemPrompts(c *gin.Context) {
|
||||
items, err := h.Svc.ListSystemPrompts(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50027, "list system prompts failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetSystemPrompt(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetSystemPrompt(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrSystemPromptNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40404, "system prompt not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50028, "get system prompt failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListKnowledgeSources(c *gin.Context) {
|
||||
items, err := h.Svc.ListKnowledgeSources(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50029, "list knowledge sources failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetKnowledgeSource(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetKnowledgeSource(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrKnowledgeSourceNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40405, "knowledge source not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50030, "get knowledge source failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
|
||||
authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads)
|
||||
authed.GET("/ask/threads/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetAskThread)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAskThreads(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
var userID *uuid.UUID
|
||||
if q := c.Query("user_id"); q != "" {
|
||||
id, err := uuid.Parse(q)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user_id")
|
||||
return
|
||||
}
|
||||
userID = &id
|
||||
}
|
||||
items, err := h.Svc.ListAskSessions(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50019, "list ask threads failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAskThread(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerBlockPolicies(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/block-policies", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListBlockPolicies)
|
||||
g.GET("/block-policies/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetBlockPolicy)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListBlockPolicies(c *gin.Context) {
|
||||
items, err := h.Svc.ListBlockPolicies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list block-policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetBlockPolicy(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetBlockPolicy(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBlockPolicyNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "block-policy not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get block-policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCMS(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/cms")
|
||||
g.GET("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListBanners)
|
||||
g.GET("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetBanner)
|
||||
g.GET("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListFeedSlots)
|
||||
g.GET("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetFeedSlot)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListBanners(c *gin.Context) {
|
||||
items, err := h.Svc.ListBanners(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50040, "list banners failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetBanner(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetBanner(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBannerNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40410, "banner not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50041, "get banner failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFeedSlots(c *gin.Context) {
|
||||
items, err := h.Svc.ListFeedSlots(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50042, "list feed slots failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFeedSlot(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFeedSlot(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFeedSlotNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40411, "feed slot not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50043, "get feed slot failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||
authed.GET("/home/tools", h.ListHomeTools)
|
||||
authed.PUT("/home/tools", h.ReplaceHomeTools)
|
||||
authed.GET("/scales", h.ListScales)
|
||||
authed.PATCH("/scales/:id", h.PatchScale)
|
||||
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
|
||||
authed.PUT("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ReplaceHomeTools)
|
||||
authed.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListScales)
|
||||
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHomeTools(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContentSafety(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/filter-rules", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListFilterRules)
|
||||
g.GET("/filter-rules/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetFilterRule)
|
||||
g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.EvaluateContent)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFilterRules(c *gin.Context) {
|
||||
items, err := h.Svc.ListFilterRules(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50022, "list filter rules failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFilterRule(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFilterRule(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFilterRuleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40403, "filter rule not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50023, "get filter rule failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) EvaluateContent(c *gin.Context) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
matches, err := h.Svc.EvaluateContent(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50024, "evaluate failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"matches": matches})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCrisis(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/policies", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListCrisisPolicies)
|
||||
g.GET("/policies/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetCrisisPolicy)
|
||||
g.POST("/evaluate", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.EvaluateCrisis)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListCrisisPolicies(c *gin.Context) {
|
||||
items, err := h.Svc.ListCrisisPolicies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50029, "list crisis policies failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetCrisisPolicy(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetCrisisPolicy(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrCrisisPolicyNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40405, "crisis policy not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50030, "get crisis policy failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) EvaluateCrisis(c *gin.Context) {
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
matches, err := h.Svc.EvaluateCrisis(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50031, "evaluate failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"matches": matches})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCrisisEvents(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/events", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListCrisisEvents)
|
||||
g.GET("/events/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetCrisisEvent)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListCrisisEvents(c *gin.Context) {
|
||||
items, err := h.Svc.ListCrisisEvents(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list crisis-event failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetCrisisEvent(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetCrisisEvent(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrCrisisEventNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "crisis-event not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get crisis-event failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerEntitlement(authed *gin.RouterGroup) {
|
||||
authed.GET("/users/:id/entitlements", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUserEntitlements)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserEntitlements(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
ent, err := h.Svc.GetUserEntitlement(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50021, "get entitlements failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, ent)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListExploreScales)
|
||||
g.GET("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetExploreScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
|
||||
items, err := h.Svc.ListScalesAdmin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50060, "list explore scales failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetExploreScale(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetScaleAdmin(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrScaleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40430, "scale not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50061, "get explore scale failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/analytics")
|
||||
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.ListFunnelDefinitions)
|
||||
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.GetFunnelDefinition)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
|
||||
items, err := h.Svc.ListFunnelDefinitions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list funnel-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetFunnelDefinition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetFunnelDefinition(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrFunnelDefinitionNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "funnel-definition not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get funnel-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerHandoffCases(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ask")
|
||||
g.GET("/handoffs", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListHandoffCases)
|
||||
g.GET("/handoffs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetHandoffCase)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHandoffCases(c *gin.Context) {
|
||||
items, err := h.Svc.ListHandoffCases(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list handoff-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetHandoffCase(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetHandoffCase(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrHandoffCaseNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "handoff-case not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get handoff-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerImageCardDecks(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListImageCardDecks)
|
||||
g.GET("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetImageCardDeck)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListImageCardDecks(c *gin.Context) {
|
||||
items, err := h.Svc.ListImageCardDecks(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list image-card-deck failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetImageCardDeck(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetImageCardDeck(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrImageCardDeckNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "image-card-deck not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get image-card-deck failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerInsight(authed *gin.RouterGroup) {
|
||||
authed.GET("/users/:id/insight", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUserInsight)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserInsight(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
insight, err := h.Svc.GetUserInsight(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50018, "get insight failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, insight)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerInterventionOutcomes(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/crisis")
|
||||
g.GET("/interventions", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.ListInterventionOutcomes)
|
||||
g.GET("/interventions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCrisisRead), h.GetInterventionOutcome)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListInterventionOutcomes(c *gin.Context) {
|
||||
items, err := h.Svc.ListInterventionOutcomes(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list intervention-outcome failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetInterventionOutcome(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetInterventionOutcome(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrInterventionOutcomeNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "intervention-outcome not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get intervention-outcome failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerKnowledgeChunks(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/knowledge-chunks", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListKnowledgeChunks)
|
||||
g.GET("/knowledge-chunks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetKnowledgeChunk)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListKnowledgeChunks(c *gin.Context) {
|
||||
items, err := h.Svc.ListKnowledgeChunks(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list knowledge-chunk failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetKnowledgeChunk(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetKnowledgeChunk(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrKnowledgeChunkNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "knowledge-chunk not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get knowledge-chunk failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerLifecycle(authed *gin.RouterGroup) {
|
||||
authed.POST("/users/:id/status", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.PostUserStatus)
|
||||
authed.GET("/users/:id/status-transitions", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUserStatusTransitions)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PostUserStatus(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "status required")
|
||||
return
|
||||
}
|
||||
err = h.Svc.TransitionUserStatus(c.Request.Context(), adminID, userID, body.Status, body.Reason)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "user not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUserStatusTransitions(c *gin.Context) {
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
items, err := h.Svc.ListStatusTransitions(c.Request.Context(), userID, limit)
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerMembershipPlans(authed *gin.RouterGroup) {
|
||||
authed.GET("/membership-plans", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListMembershipPlans)
|
||||
authed.GET("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.GetMembershipPlan)
|
||||
authed.PUT("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutMembershipPlan)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListMembershipPlans(c *gin.Context) {
|
||||
items, err := h.Svc.ListMembershipPlans(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetMembershipPlan(c *gin.Context) {
|
||||
plan, err := h.Svc.GetMembershipPlan(c.Request.Context(), c.Param("code"))
|
||||
if errors.Is(err, admin.ErrPlanNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PutMembershipPlan(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if body.Active != nil {
|
||||
active = *body.Active
|
||||
}
|
||||
plan, err := h.Svc.UpdateMembershipPlan(
|
||||
c.Request.Context(), adminID, c.Param("code"), body.Title, body.DurationDays, body.AmountCents, active,
|
||||
)
|
||||
if errors.Is(err, admin.ErrPlanNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "plan not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPlanU) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerModerationCases(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/content-safety")
|
||||
g.GET("/cases", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.ListModerationCases)
|
||||
g.GET("/cases/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentSafetyRead), h.GetModerationCase)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListModerationCases(c *gin.Context) {
|
||||
items, err := h.Svc.ListModerationCases(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list moderation-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetModerationCase(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetModerationCase(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrModerationCaseNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "moderation-case not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get moderation-case failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerPrivacyRequests(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/privacy")
|
||||
g.GET("/requests", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.ListPrivacyRequests)
|
||||
g.GET("/requests/:id", middleware.RequireAdminPermission(h.Svc, admin.PermPrivacyRead), h.GetPrivacyRequest)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListPrivacyRequests(c *gin.Context) {
|
||||
items, err := h.Svc.ListPrivacyRequests(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list privacy-request failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetPrivacyRequest(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetPrivacyRequest(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrPrivacyRequestNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "privacy-request not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get privacy-request failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerQualityFeedback(authed *gin.RouterGroup) {
|
||||
authed.GET("/ask/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskFeedback)
|
||||
authed.POST("/ask/threads/:id/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskFeedbackWrite), h.CreateAskFeedback)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAskFeedback(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListQualityFeedback(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50025, "list feedback failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
threadID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Rating int `json:"rating"`
|
||||
Tag string `json:"tag"`
|
||||
Note string `json:"note"`
|
||||
MessageID *string `json:"message_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
var msgID *uuid.UUID
|
||||
if body.MessageID != nil && *body.MessageID != "" {
|
||||
id, err := uuid.Parse(*body.MessageID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid message_id")
|
||||
return
|
||||
}
|
||||
msgID = &id
|
||||
}
|
||||
row, err := h.Svc.CreateQualityFeedback(c.Request.Context(), adminID, threadID, msgID, body.Rating, body.Tag, body.Note)
|
||||
if errors.Is(err, admin.ErrBadFeedbackRating) || errors.Is(err, admin.ErrBadFeedbackTag) || errors.Is(err, admin.ErrFeedbackNoteLong) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRBAC(authed *gin.RouterGroup) {
|
||||
authed.GET("/roles", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.ListRoles)
|
||||
authed.GET("/roles/:id", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.GetRole)
|
||||
authed.PUT("/roles/:id/permissions", middleware.RequireAdminPermission(h.Svc, admin.PermRolesWrite), h.PutRolePermissions)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRoles(c *gin.Context) {
|
||||
items, err := h.Svc.ListRoles(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRole(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) || role == nil {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PutRolePermissions(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
roleID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
if body.Permissions == nil {
|
||||
body.Permissions = []string{}
|
||||
}
|
||||
err = h.Svc.ReplaceRolePermissions(c.Request.Context(), adminID, roleID, body.Permissions)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPerm) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), roleID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRedemption(authed *gin.RouterGroup) {
|
||||
authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch)
|
||||
authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches)
|
||||
authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes)
|
||||
authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Label string `json:"label"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity)
|
||||
if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || errors.Is(err, admin.ErrInvalidPlanU) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"batch": batch, "codes": codes})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrBatchNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "batch not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id)
|
||||
if errors.Is(err, admin.ErrCodeDisable) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerReportTemplates(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/growth")
|
||||
g.GET("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListReportTemplates)
|
||||
g.GET("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetReportTemplate)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListReportTemplates(c *gin.Context) {
|
||||
items, err := h.Svc.ListReportTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list report-template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetReportTemplate(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetReportTemplate(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrReportTemplateNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "report-template not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get report-template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRhythmConfigs(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/rhythm-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListRhythmConfigs)
|
||||
g.GET("/rhythm-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetRhythmConfig)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRhythmConfigs(c *gin.Context) {
|
||||
items, err := h.Svc.ListRhythmConfigs(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list rhythm-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRhythmConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetRhythmConfig(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrRhythmConfigNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "rhythm-config not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get rhythm-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerCMSPublications(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/cms")
|
||||
g.GET("/publications", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListScheduledPublications)
|
||||
g.GET("/publications/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetScheduledPublication)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListScheduledPublications(c *gin.Context) {
|
||||
items, err := h.Svc.ListScheduledPublications(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list scheduled-publication failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetScheduledPublication(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetScheduledPublication(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrScheduledPublicationNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "scheduled-publication not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get scheduled-publication failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerStarConfigs(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListStarConfigs)
|
||||
g.GET("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetStarConfig)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListStarConfigs(c *gin.Context) {
|
||||
items, err := h.Svc.ListStarConfigs(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list star-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetStarConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetStarConfig(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrStarConfigNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "star-config not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get star-config failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerToolDefinitions(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/ai")
|
||||
g.GET("/tools", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.ListToolDefinitions)
|
||||
g.GET("/tools/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAIConfigRead), h.GetToolDefinition)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListToolDefinitions(c *gin.Context) {
|
||||
items, err := h.Svc.ListToolDefinitions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50050, "list tool-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetToolDefinition(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetToolDefinition(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrToolDefinitionNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "tool-definition not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50051, "get tool-definition failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func (h *AskHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.DELETE("/ask/threads/:id", h.ClearThread)
|
||||
rg.GET("/ask/threads/:id/messages", h.ListMessages)
|
||||
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
||||
h.registerFeedback(rg)
|
||||
}
|
||||
|
||||
// GetQuota handles GET /ask/quota.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AskHandler) registerFeedback(rg *gin.RouterGroup) {
|
||||
rg.POST("/ask/threads/:id/feedback", h.SubmitFeedback)
|
||||
}
|
||||
|
||||
// SubmitFeedback handles POST /ask/threads/:id/feedback.
|
||||
func (h *AskHandler) SubmitFeedback(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
threadID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid thread id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Rating int `json:"rating"`
|
||||
Tag string `json:"tag"`
|
||||
Note string `json:"note"`
|
||||
MessageID *string `json:"message_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
var msgID *uuid.UUID
|
||||
if req.MessageID != nil && *req.MessageID != "" {
|
||||
id, err := uuid.Parse(*req.MessageID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid message_id")
|
||||
return
|
||||
}
|
||||
msgID = &id
|
||||
}
|
||||
row, err := h.Svc.SubmitFeedback(c.Request.Context(), userID, threadID, asksvc.SubmitFeedbackInput{
|
||||
MessageID: msgID, Rating: req.Rating, Tag: req.Tag, Note: req.Note,
|
||||
})
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "rating") || strings.Contains(msg, "tag") || strings.Contains(msg, "note") {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, msg)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "thread not found") {
|
||||
response.Fail(c, http.StatusNotFound, 40410, msg)
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -51,6 +52,10 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrAccountRestricted) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -72,6 +77,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Login(c.Request.Context(), userID, deviceKey, body.Phone, body.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrAccountRestricted) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40111, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/reports/latest", h.GetLatest)
|
||||
rg.GET("/reports/:id", h.Get)
|
||||
rg.GET("/membership/me", h.GetMembership)
|
||||
rg.POST("/membership/redeem", h.RedeemCode)
|
||||
rg.POST("/orders", h.CreateOrder)
|
||||
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
||||
}
|
||||
@@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
// RedeemCode handles POST /membership/redeem.
|
||||
func (h *ReportHandler) RedeemCode(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
msvc, ok := h.requireMembership(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "code required")
|
||||
return
|
||||
}
|
||||
plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
me, err := msvc.Get(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"plan": plan, "membership": me})
|
||||
}
|
||||
|
||||
// CreateOrder handles POST /orders.
|
||||
func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAccountLifecycle(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
superTok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
// AC-S-03 / AC-S-04: no admin session
|
||||
_, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/status",
|
||||
map[string]string{"status": "banned", "reason": "x"}, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 POST status, got %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+uuid.New().String()+"/status-transitions", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 GET transitions, got %d", code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users: %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("need user")
|
||||
}
|
||||
userID := list.Items[0].ID
|
||||
|
||||
// AC-F-01 ban
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "banned", "reason": "abuse"}, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Status != "banned" {
|
||||
t.Fatalf("expected banned, got %s", detail.Status)
|
||||
}
|
||||
|
||||
// AC-F-03 same status
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "banned", "reason": "again"}, superTok)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 same status, got %d", code)
|
||||
}
|
||||
|
||||
// AC-S-02 C-end reject
|
||||
if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 banned bearer, got %d", code)
|
||||
}
|
||||
|
||||
// AC-F-04 + AC-P-01 + AC-O
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/status-transitions?limit=50", nil, superTok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("transitions failed/slow http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
var tr struct {
|
||||
Items []struct {
|
||||
FromStatus string `json:"from_status"`
|
||||
ToStatus string `json:"to_status"`
|
||||
Reason string `json:"reason"`
|
||||
AdminID string `json:"admin_id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &tr)
|
||||
if len(tr.Items) == 0 || tr.Items[0].ToStatus != "banned" || tr.Items[0].Reason != "abuse" || tr.Items[0].AdminID == "" {
|
||||
t.Fatalf("unexpected transitions %#v", tr.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
found := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "users.status.transition" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing users.status.transition audit")
|
||||
}
|
||||
|
||||
// AC-F-02 restore
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "active", "reason": "appeal"}, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("restore failed %d", code)
|
||||
}
|
||||
if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != 200 {
|
||||
t.Fatalf("expected me ok after unban, got %d", code)
|
||||
}
|
||||
|
||||
// AC-S-01 limited admin without status.write
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`, limitedRoleID, "lc_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
limitedUser := fmt.Sprintf("lc_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limitedTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status",
|
||||
map[string]string{"status": "suspended", "reason": "nope"}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 status.write, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceGET(t *testing.T, r http.Handler, path, deviceKey, bearer string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, bytes.NewReader(nil))
|
||||
if deviceKey != "" {
|
||||
req.Header.Set("X-Device-Key", deviceKey)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/httpserver"
|
||||
)
|
||||
|
||||
func setupAPIPool(t *testing.T) (*gin.Engine, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.Admin.BootstrapUsername = "admin"
|
||||
cfg.Admin.BootstrapPassword = "change-me"
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
migDir := filepath.Join("..", "..", "migrations")
|
||||
if err := db.Migrate(ctx, pool, migDir); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return httpserver.NewRouter(pool, cfg), pool
|
||||
}
|
||||
|
||||
func adminLogin(t *testing.T, r http.Handler, user, pass string) string {
|
||||
t.Helper()
|
||||
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": user, "password": pass,
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("login %s failed http=%d code=%d msg=%s", user, code, env.Code, env.Message)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||
}
|
||||
return login.Token
|
||||
}
|
||||
|
||||
func TestAdminRBAC(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
superTok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
// AC-S-03: no admin session → 401
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without admin token, got http=%d", code)
|
||||
}
|
||||
|
||||
// AC-F-03: /me includes permissions
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("me failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
var me struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &me); err != nil || len(me.Permissions) == 0 {
|
||||
t.Fatalf("expected permissions on me: %v %s", err, env.Data)
|
||||
}
|
||||
if me.Role != "super_admin" {
|
||||
t.Fatalf("expected super_admin role, got %q", me.Role)
|
||||
}
|
||||
|
||||
// AC-F-01: list roles
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list roles failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var roles struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roles); err != nil || len(roles.Items) == 0 {
|
||||
t.Fatalf("expected roles: %v %s", err, env.Data)
|
||||
}
|
||||
var superRoleID string
|
||||
for _, it := range roles.Items {
|
||||
if it.Name == "super_admin" {
|
||||
superRoleID = it.ID
|
||||
}
|
||||
}
|
||||
if superRoleID == "" {
|
||||
t.Fatal("super_admin role missing")
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+superRoleID, nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("get role failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// Seed limited role + account for deny ACs
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)
|
||||
ON CONFLICT (name) DO NOTHING`, limitedRoleID, "rbac_limited_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert role: %v", err)
|
||||
}
|
||||
// resolve actual id if conflict
|
||||
var roleName string
|
||||
err = pool.QueryRow(ctx, `SELECT id, name FROM admin_roles WHERE id=$1`, limitedRoleID).Scan(&limitedRoleID, &roleName)
|
||||
if err != nil {
|
||||
t.Fatalf("load limited role: %v", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limitedUser := fmt.Sprintf("limited_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES ($1, $2, $3)`, limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert limited admin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
|
||||
limitedTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
|
||||
// AC-S-01: no roles.write → 403
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": []string{"admin.users.read"}}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 roles.write, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
// AC-S-02: no membership.grant → 403
|
||||
_ = mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users: %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("need a user for grant deny")
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+list.Items[0].ID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 membership.grant, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// AC-F-02 + AC-O-01: super replaces permissions
|
||||
want := []string{"admin.users.read", "admin.roles.read"}
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": want}, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("put permissions failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+limitedRoleID.String(), nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get after put: %d", code)
|
||||
}
|
||||
var roleDetail struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roleDetail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roleDetail.Permissions) != 2 {
|
||||
t.Fatalf("expected 2 perms, got %#v", roleDetail.Permissions)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit: %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
foundUpdate, foundDeny := false, false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "roles.permissions.update" {
|
||||
foundUpdate = true
|
||||
}
|
||||
if it.Action == "permission.denied" {
|
||||
foundDeny = true
|
||||
}
|
||||
}
|
||||
if !foundUpdate {
|
||||
t.Fatal("expected roles.permissions.update audit")
|
||||
}
|
||||
if !foundDeny {
|
||||
t.Fatal("expected permission.denied audit")
|
||||
}
|
||||
|
||||
// AC-S-04: system role cannot be deleted (FK + system seed; no Delete API)
|
||||
tag, err := pool.Exec(ctx, `DELETE FROM admin_roles WHERE name='super_admin'`)
|
||||
if err == nil && tag.RowsAffected() > 0 {
|
||||
t.Fatal("expected delete super_admin to fail or affect 0 rows")
|
||||
}
|
||||
|
||||
// AC-P-01: list roles under 500ms locally
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("AC-P-01 list roles slow or failed: http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAICoreSystemPrompts(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "ai_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("ailim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Body string `json:"body"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var askID string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "ask_default" {
|
||||
askID = it.ID
|
||||
if it.Body == "" {
|
||||
t.Fatal("ask_default body empty in list")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if askID == "" {
|
||||
t.Fatalf("missing ask_default: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts/"+askID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Body string `json:"body"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "ask_default" || detail.Body == "" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ai/system-prompts/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAskOperations(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)`,
|
||||
limitedRoleID, "ask_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert role: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limitedUser := fmt.Sprintf("asklim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES ($1,$2,$3)`, limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
|
||||
limTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 without ask.read, got %d", code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1992-06-01", "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)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "运营可读吗",
|
||||
}, key)
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow: %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
MessageCount int `json:"message_count"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
found := false
|
||||
for _, it := range list.Items {
|
||||
if it.ID == threadID && it.MessageCount >= 1 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected thread %s in list: %#v", threadID, list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("detail http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if len(detail.Messages) < 2 {
|
||||
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestContentSafetyBlockPolicies(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "block_spam_link" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing block_spam_link: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/block-policies/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSBanners(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cms_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("cmslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "home_promo" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing home_promo: %#v", list.Items)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "home_promo" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/banners/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSFeedSlots(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "fs_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("fslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "home_feed_main" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing home_feed_main: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
var detail struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &detail)
|
||||
if detail.Code != "home_feed_main" {
|
||||
t.Fatalf("bad detail %#v", detail)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/cms/feed-slots/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestContentSafetyFilterRules(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cs_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("cslim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
System bool `json:"system"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) < 1 {
|
||||
t.Fatal("expected seeded filter rules")
|
||||
}
|
||||
var firstID string
|
||||
for _, it := range list.Items {
|
||||
if it.System {
|
||||
firstID = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstID == "" {
|
||||
firstID = list.Items[0].ID
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+firstID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/content-safety/filter-rules/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/content-safety/evaluate",
|
||||
map[string]string{"text": "真的不想活了怎么办"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("evaluate %d msg=%s", code, env.Message)
|
||||
}
|
||||
var ev struct {
|
||||
Matches []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"matches"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &ev)
|
||||
if len(ev.Matches) < 1 {
|
||||
t.Fatalf("expected match, got %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCrisisCareEvents(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("lim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
var id string
|
||||
for _, it := range list.Items {
|
||||
if it.Code == "demo_crisis_event" {
|
||||
id = it.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatalf("missing demo_crisis_event: %#v", list.Items)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/events/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCrisisCarePolicies(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cr_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("crlim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
System bool `json:"system"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) < 1 {
|
||||
t.Fatal("expected seeded crisis policies")
|
||||
}
|
||||
firstID := list.Items[0].ID
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+firstID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/crisis/policies/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/crisis/evaluate",
|
||||
map[string]string{"text": "我真的不想活了"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("evaluate %d msg=%s", code, env.Message)
|
||||
}
|
||||
var ev struct {
|
||||
Matches []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"matches"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &ev)
|
||||
if len(ev.Matches) < 1 {
|
||||
t.Fatalf("expected match, got %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUserEntitlements(t *testing.T) {
|
||||
r, _ := setupAPIPool(t)
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+fakeUUID()+"/entitlements", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
testBearer = ""
|
||||
phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
|
||||
nick := "ent_" + phone[7:]
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
|
||||
"phone": phone, "password": "secret12", "nickname": nick,
|
||||
}, "")
|
||||
sess := decodeData[map[string]any](t, env.Data)
|
||||
testBearer = sess["token"].(string)
|
||||
t.Cleanup(func() { testBearer = "" })
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-01-01", "display_name": "权",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
|
||||
"profile_id": profileID,
|
||||
}, key)
|
||||
reportID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
"kind": "deep_access", "report_id": reportID,
|
||||
}, 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, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users?q="+url.QueryEscape(nick), nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
userID := list.Items[0].ID
|
||||
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("entitlements http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("too slow %v", time.Since(start))
|
||||
}
|
||||
var before struct {
|
||||
Flags struct {
|
||||
ViaMem bool `json:"report_detail_via_membership"`
|
||||
Count int `json:"deep_access_count"`
|
||||
} `json:"flags"`
|
||||
Deep []any `json:"deep_accesses"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &before)
|
||||
if before.Flags.Count < 1 || len(before.Deep) < 1 {
|
||||
t.Fatalf("expected deep_access: %#v", before)
|
||||
}
|
||||
if before.Flags.ViaMem {
|
||||
t.Fatal("expected membership inactive before grant")
|
||||
}
|
||||
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("grant %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("after grant %d", code)
|
||||
}
|
||||
var after struct {
|
||||
Flags struct {
|
||||
ViaMem bool `json:"report_detail_via_membership"`
|
||||
} `json:"flags"`
|
||||
Membership struct {
|
||||
Active bool `json:"active"`
|
||||
} `json:"membership"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &after)
|
||||
if !after.Flags.ViaMem || !after.Membership.Active {
|
||||
t.Fatalf("expected active membership entitlement: %#v", after)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestExploreScaleDefinitions(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", code)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "sc_lim_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limUser := fmt.Sprintf("sclim_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
limUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, limTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales", nil, tok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
if time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("list too slow %v", time.Since(start))
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("expected at least one scale")
|
||||
}
|
||||
id := list.Items[0].ID
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales/"+id, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get %d", code)
|
||||
}
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/explore/scales/"+fakeUUID(), nil, tok)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", code)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user