From 503b15aab741e494425b71c22b0eb6f7d06da950 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 27 Dec 2025 19:49:11 +0800 Subject: [PATCH] reset backend --- api-spec.yaml | 1125 +++++++++++ backend/app/commands/http/http.go | 11 - backend/app/http/super/auth.go | 76 - backend/app/http/super/content.go | 33 - backend/app/http/super/dto/auth.go | 10 - backend/app/http/super/dto/content.go | 44 - backend/app/http/super/dto/content_page.go | 81 - backend/app/http/super/dto/content_status.go | 8 - backend/app/http/super/dto/order.go | 17 - backend/app/http/super/dto/order_detail.go | 10 - backend/app/http/super/dto/order_page.go | 96 - backend/app/http/super/dto/order_refund.go | 10 - backend/app/http/super/dto/tenant.go | 92 - backend/app/http/super/dto/tenant_user.go | 26 - backend/app/http/super/dto/user.go | 65 - backend/app/http/super/dto/user_roles.go | 7 - backend/app/http/super/dto/user_tenant.go | 60 - backend/app/http/super/order.go | 96 - backend/app/http/super/provider.gen.go | 88 - backend/app/http/super/routes.gen.go | 176 -- backend/app/http/super/routes.manual.go | 11 - backend/app/http/super/static.go | 21 - backend/app/http/super/tenant.go | 130 -- backend/app/http/super/tenant_content.go | 30 - .../app/http/super/tenant_content_status.go | 47 - backend/app/http/super/user.go | 123 -- backend/app/http/tenant/content.go | 199 -- backend/app/http/tenant/content_admin.go | 236 --- backend/app/http/tenant/dto/content.go | 44 - backend/app/http/tenant/dto/content_admin.go | 59 - .../app/http/tenant/dto/content_admin_list.go | 56 - .../http/tenant/dto/content_admin_publish.go | 55 - .../app/http/tenant/dto/content_asset_play.go | 23 - backend/app/http/tenant/dto/ledger_admin.go | 53 - backend/app/http/tenant/dto/ledger_me.go | 31 - backend/app/http/tenant/dto/me.go | 13 - backend/app/http/tenant/dto/me_balance.go | 19 - .../app/http/tenant/dto/media_asset_admin.go | 69 - .../http/tenant/dto/media_asset_admin_list.go | 29 - backend/app/http/tenant/dto/order.go | 22 - backend/app/http/tenant/dto/order_admin.go | 90 - .../app/http/tenant/dto/order_admin_export.go | 13 - backend/app/http/tenant/dto/order_me.go | 22 - .../app/http/tenant/dto/tenant_join_admin.go | 66 - .../app/http/tenant/dto/tenant_user_admin.go | 50 - backend/app/http/tenant/ledger_admin.go | 57 - backend/app/http/tenant/me.go | 79 - backend/app/http/tenant/media_asset_admin.go | 194 -- backend/app/http/tenant/order.go | 60 - backend/app/http/tenant/order_admin.go | 191 -- backend/app/http/tenant/order_me.go | 63 - backend/app/http/tenant/provider.gen.go | 127 -- backend/app/http/tenant/routes.gen.go | 329 --- backend/app/http/tenant/routes.manual.go | 13 - .../app/http/tenant/tenant_invite_admin.go | 134 -- backend/app/http/tenant/tenant_join_admin.go | 142 -- backend/app/http/tenant/tenant_user_admin.go | 204 -- backend/app/http/tenant_join/dto/join.go | 13 - backend/app/http/tenant_join/join.go | 87 - backend/app/http/tenant_join/provider.gen.go | 37 - backend/app/http/tenant_join/routes.gen.go | 63 - backend/app/http/tenant_join/routes.manual.go | 12 - backend/app/http/tenant_media/play.go | 50 - backend/app/http/tenant_media/provider.gen.go | 37 - backend/app/http/tenant_media/routes.gen.go | 53 - .../app/http/tenant_media/routes.manual.go | 11 - backend/app/http/tenant_public/content.go | 223 --- .../app/http/tenant_public/provider.gen.go | 37 - backend/app/http/tenant_public/routes.gen.go | 72 - .../app/http/tenant_public/routes.manual.go | 12 - backend/app/http/web/auth.go | 326 --- backend/app/http/web/me.go | 61 - backend/app/http/web/provider.gen.go | 51 - backend/app/http/web/routes.gen.go | 97 - backend/app/http/web/routes.manual.go | 11 - backend/app/http/web/tenant_apply.go | 118 -- backend/app/jobs/demo_job_test.go | 56 - backend/app/jobs/media_asset_process.go | 58 - backend/app/jobs/media_asset_process_test.go | 85 - backend/app/jobs/order_refund.go | 62 - backend/app/jobs/provider.gen.go | 30 - backend/app/middlewares/mid_debug.go | 9 - backend/app/middlewares/super.go | 68 - backend/app/middlewares/tenant.go | 176 -- backend/app/middlewares/user.go | 64 - backend/app/services/content.go | 892 --------- backend/app/services/content_admin.go | 180 -- backend/app/services/content_super.go | 494 ----- backend/app/services/content_test.go | 547 ----- backend/app/services/ledger.go | 481 ----- backend/app/services/ledger_test.go | 386 ---- backend/app/services/media_asset.go | 687 ------- backend/app/services/media_asset_test.go | 158 -- backend/app/services/media_delivery.go | 274 --- backend/app/services/media_delivery_test.go | 112 -- backend/app/services/order.go | 1762 ----------------- backend/app/services/order_test.go | 1236 ------------ backend/app/services/provider.gen.go | 122 -- backend/app/services/services.gen.go | 52 - backend/app/services/tenant.go | 968 --------- backend/app/services/tenant_join.go | 513 ----- backend/app/services/tenant_join_test.go | 369 ---- backend/app/services/tenant_test.go | 182 -- backend/app/services/test.go | 10 - backend/app/services/test_test.go | 44 - backend/app/services/user.go | 495 ----- backend/app/services/user_test.go | 228 --- backend/database/.transform.yaml | 86 - .../migrations/20251215084449_users.sql | 29 - .../migrations/20251215113803_tenants.sql | 29 - .../20251216011456_tenant_users.sql | 19 - .../20251217223000_media_contents.sql | 190 -- .../20251218120000_orders_ledgers.sql | 138 -- ...0251218164000_orders_amount_paid_index.sql | 11 - ...171000_fix_tenant_users_status_default.sql | 19 - ...218190000_tenant_invites_join_requests.sql | 81 - ...251218193000_orders_admin_list_indexes.sql | 11 - .../20251222174000_media_assets_variant.sql | 36 - ...222175500_media_assets_source_asset_id.sql | 16 - ...1222211500_tenant_ledgers_audit_fields.sql | 40 - ...1800_fix_tenant_ledgers_biz_ref_unique.sql | 22 - ...223124000_update_order_ledger_comments.sql | 24 - .../20251225123000_contents_summary_tags.sql | 21 - .../migrations/20251227112605_init.sql | 9 + backend/docs/dev/http_api.md | 92 - backend/docs/dev/model.md | 226 --- backend/specs/spec01-backlog.md | 171 -- backend/specs/spec01-gap-analysis.md | 115 -- backend/specs/spec01.md | 427 ---- 129 files changed, 1134 insertions(+), 18084 deletions(-) create mode 100644 api-spec.yaml delete mode 100644 backend/app/http/super/auth.go delete mode 100644 backend/app/http/super/content.go delete mode 100644 backend/app/http/super/dto/auth.go delete mode 100644 backend/app/http/super/dto/content.go delete mode 100644 backend/app/http/super/dto/content_page.go delete mode 100644 backend/app/http/super/dto/content_status.go delete mode 100644 backend/app/http/super/dto/order.go delete mode 100644 backend/app/http/super/dto/order_detail.go delete mode 100644 backend/app/http/super/dto/order_page.go delete mode 100644 backend/app/http/super/dto/order_refund.go delete mode 100644 backend/app/http/super/dto/tenant.go delete mode 100644 backend/app/http/super/dto/tenant_user.go delete mode 100644 backend/app/http/super/dto/user.go delete mode 100644 backend/app/http/super/dto/user_roles.go delete mode 100644 backend/app/http/super/dto/user_tenant.go delete mode 100644 backend/app/http/super/order.go delete mode 100755 backend/app/http/super/provider.gen.go delete mode 100644 backend/app/http/super/routes.gen.go delete mode 100644 backend/app/http/super/routes.manual.go delete mode 100644 backend/app/http/super/static.go delete mode 100644 backend/app/http/super/tenant.go delete mode 100644 backend/app/http/super/tenant_content.go delete mode 100644 backend/app/http/super/tenant_content_status.go delete mode 100644 backend/app/http/super/user.go delete mode 100644 backend/app/http/tenant/content.go delete mode 100644 backend/app/http/tenant/content_admin.go delete mode 100644 backend/app/http/tenant/dto/content.go delete mode 100644 backend/app/http/tenant/dto/content_admin.go delete mode 100644 backend/app/http/tenant/dto/content_admin_list.go delete mode 100644 backend/app/http/tenant/dto/content_admin_publish.go delete mode 100644 backend/app/http/tenant/dto/content_asset_play.go delete mode 100644 backend/app/http/tenant/dto/ledger_admin.go delete mode 100644 backend/app/http/tenant/dto/ledger_me.go delete mode 100644 backend/app/http/tenant/dto/me.go delete mode 100644 backend/app/http/tenant/dto/me_balance.go delete mode 100644 backend/app/http/tenant/dto/media_asset_admin.go delete mode 100644 backend/app/http/tenant/dto/media_asset_admin_list.go delete mode 100644 backend/app/http/tenant/dto/order.go delete mode 100644 backend/app/http/tenant/dto/order_admin.go delete mode 100644 backend/app/http/tenant/dto/order_admin_export.go delete mode 100644 backend/app/http/tenant/dto/order_me.go delete mode 100644 backend/app/http/tenant/dto/tenant_join_admin.go delete mode 100644 backend/app/http/tenant/dto/tenant_user_admin.go delete mode 100644 backend/app/http/tenant/ledger_admin.go delete mode 100644 backend/app/http/tenant/me.go delete mode 100644 backend/app/http/tenant/media_asset_admin.go delete mode 100644 backend/app/http/tenant/order.go delete mode 100644 backend/app/http/tenant/order_admin.go delete mode 100644 backend/app/http/tenant/order_me.go delete mode 100755 backend/app/http/tenant/provider.gen.go delete mode 100644 backend/app/http/tenant/routes.gen.go delete mode 100644 backend/app/http/tenant/routes.manual.go delete mode 100644 backend/app/http/tenant/tenant_invite_admin.go delete mode 100644 backend/app/http/tenant/tenant_join_admin.go delete mode 100644 backend/app/http/tenant/tenant_user_admin.go delete mode 100644 backend/app/http/tenant_join/dto/join.go delete mode 100644 backend/app/http/tenant_join/join.go delete mode 100755 backend/app/http/tenant_join/provider.gen.go delete mode 100644 backend/app/http/tenant_join/routes.gen.go delete mode 100644 backend/app/http/tenant_join/routes.manual.go delete mode 100644 backend/app/http/tenant_media/play.go delete mode 100755 backend/app/http/tenant_media/provider.gen.go delete mode 100644 backend/app/http/tenant_media/routes.gen.go delete mode 100644 backend/app/http/tenant_media/routes.manual.go delete mode 100644 backend/app/http/tenant_public/content.go delete mode 100755 backend/app/http/tenant_public/provider.gen.go delete mode 100644 backend/app/http/tenant_public/routes.gen.go delete mode 100644 backend/app/http/tenant_public/routes.manual.go delete mode 100644 backend/app/http/web/auth.go delete mode 100644 backend/app/http/web/me.go delete mode 100644 backend/app/http/web/routes.gen.go delete mode 100644 backend/app/http/web/routes.manual.go delete mode 100644 backend/app/http/web/tenant_apply.go delete mode 100644 backend/app/jobs/demo_job_test.go delete mode 100644 backend/app/jobs/media_asset_process.go delete mode 100644 backend/app/jobs/media_asset_process_test.go delete mode 100644 backend/app/jobs/order_refund.go delete mode 100644 backend/app/middlewares/mid_debug.go delete mode 100644 backend/app/middlewares/super.go delete mode 100644 backend/app/middlewares/tenant.go delete mode 100644 backend/app/middlewares/user.go delete mode 100644 backend/app/services/content.go delete mode 100644 backend/app/services/content_admin.go delete mode 100644 backend/app/services/content_super.go delete mode 100644 backend/app/services/content_test.go delete mode 100644 backend/app/services/ledger.go delete mode 100644 backend/app/services/ledger_test.go delete mode 100644 backend/app/services/media_asset.go delete mode 100644 backend/app/services/media_asset_test.go delete mode 100644 backend/app/services/media_delivery.go delete mode 100644 backend/app/services/media_delivery_test.go delete mode 100644 backend/app/services/order.go delete mode 100644 backend/app/services/order_test.go delete mode 100644 backend/app/services/services.gen.go delete mode 100644 backend/app/services/tenant.go delete mode 100644 backend/app/services/tenant_join.go delete mode 100644 backend/app/services/tenant_join_test.go delete mode 100644 backend/app/services/tenant_test.go delete mode 100644 backend/app/services/test.go delete mode 100644 backend/app/services/test_test.go delete mode 100644 backend/app/services/user.go delete mode 100644 backend/app/services/user_test.go delete mode 100644 backend/database/migrations/20251215084449_users.sql delete mode 100644 backend/database/migrations/20251215113803_tenants.sql delete mode 100644 backend/database/migrations/20251216011456_tenant_users.sql delete mode 100644 backend/database/migrations/20251217223000_media_contents.sql delete mode 100644 backend/database/migrations/20251218120000_orders_ledgers.sql delete mode 100644 backend/database/migrations/20251218164000_orders_amount_paid_index.sql delete mode 100644 backend/database/migrations/20251218171000_fix_tenant_users_status_default.sql delete mode 100644 backend/database/migrations/20251218190000_tenant_invites_join_requests.sql delete mode 100644 backend/database/migrations/20251218193000_orders_admin_list_indexes.sql delete mode 100644 backend/database/migrations/20251222174000_media_assets_variant.sql delete mode 100644 backend/database/migrations/20251222175500_media_assets_source_asset_id.sql delete mode 100644 backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql delete mode 100644 backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql delete mode 100644 backend/database/migrations/20251223124000_update_order_ledger_comments.sql delete mode 100644 backend/database/migrations/20251225123000_contents_summary_tags.sql create mode 100644 backend/database/migrations/20251227112605_init.sql delete mode 100644 backend/docs/dev/http_api.md delete mode 100644 backend/docs/dev/model.md delete mode 100644 backend/specs/spec01-backlog.md delete mode 100644 backend/specs/spec01-gap-analysis.md delete mode 100644 backend/specs/spec01.md diff --git a/api-spec.yaml b/api-spec.yaml new file mode 100644 index 0000000..cd5e09d --- /dev/null +++ b/api-spec.yaml @@ -0,0 +1,1125 @@ +openapi: 3.0.0 +info: + title: Quyun Platform API + description: API specification for Quyun (曲韵) Platform - A multi-tenant opera content delivery platform. + version: 1.0.2 +servers: + - url: /v1 + description: Production Server + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + # --- Common Models --- + Error: + type: object + properties: + code: + type: integer + message: + type: string + + Pagination: + type: object + properties: + page: + type: integer + pageSize: + type: integer + total: + type: integer + + # --- User Models --- + User: + type: object + properties: + id: + type: string + phone: + type: string + nickname: + type: string + avatar: + type: string + gender: + type: string + enum: [male, female, secret] + bio: + type: string + birthday: + type: string + format: date + location: + type: object + properties: + province: + type: string + city: + type: string + balance: + type: number + readOnly: true + points: + type: integer + readOnly: true + isRealNameVerified: + type: boolean + readOnly: true + + UserUpdate: + type: object + properties: + nickname: + type: string + avatar: + type: string + gender: + type: string + enum: [male, female, secret] + bio: + type: string + birthday: + type: string + format: date + location: + type: object + properties: + province: + type: string + city: + type: string + + # --- Content Models --- + ContentItem: + type: object + properties: + id: + type: string + title: + type: string + cover: + type: string + genre: + type: string + type: + type: string + enum: [video, audio, article] + price: + type: number + authorId: + type: string + authorName: + type: string + authorAvatar: + type: string + views: + type: integer + likes: + type: integer + isPurchased: + type: boolean + description: User specific state + + ContentDetail: + allOf: + - $ref: '#/components/schemas/ContentItem' + - type: object + properties: + description: + type: string + body: + type: string # HTML/Markdown + mediaUrls: + type: array + items: + type: object + properties: + type: + type: string + url: + type: string + duration: + type: integer + meta: + type: object + properties: + role: + type: string + key: + type: string + beat: + type: string + isLiked: + type: boolean + description: User specific state + isFavorited: + type: boolean + description: User specific state + + Comment: + type: object + properties: + id: + type: string + content: + type: string + userId: + type: string + userNickname: + type: string + userAvatar: + type: string + createTime: + type: string + likes: + type: integer + isLiked: + type: boolean + replyTo: + type: string # Comment ID + + # --- Tenant/Creator Public Profile --- + TenantProfile: + type: object + properties: + id: + type: string + name: + type: string + avatar: + type: string + cover: + type: string + bio: + type: string + description: + type: string + certType: + type: string + enum: [personal, enterprise] + stats: + type: object + properties: + followers: + type: integer + contents: + type: integer + likes: + type: integer + isFollowing: + type: boolean + + # --- Order Models --- + Order: + type: object + properties: + id: + type: string + createTime: + type: string + format: date-time + payTime: + type: string + format: date-time + status: + type: string + enum: [unpaid, paid, completed, refunding, refunded, cancelled] + amount: + type: number + quantity: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/ContentItem' + tenantId: + type: string + tenantName: + type: string + isVirtual: + type: boolean + + # --- Creator Dashboard Models --- + CreatorStats: + type: object + properties: + totalFollowers: + type: object + properties: + value: + type: integer + trend: + type: number + description: Percentage change (e.g. 1.2 for +1.2%) + totalRevenue: + type: object + properties: + value: + type: number + trend: + type: number + pendingRefunds: + type: integer + newMessages: + type: integer + + PayoutAccount: + type: object + properties: + id: + type: string + type: + type: string + enum: [bank, alipay] + name: + type: string + account: + type: string + realname: + type: string + + # --- Upload --- + UploadResult: + type: object + properties: + id: + type: string + url: + type: string + filename: + type: string + size: + type: integer + mimeType: + type: string + +security: + - BearerAuth: [] + +paths: + # ============================ + # Public / Auth + # ============================ + /auth/otp: + post: + summary: Send OTP + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + phone: + type: string + responses: + '200': + description: OTP sent + + /auth/login: + post: + summary: Login or Register with OTP + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + phone: + type: string + otp: + type: string + responses: + '200': + description: Successful login + content: + application/json: + schema: + type: object + properties: + token: + type: string + user: + $ref: '#/components/schemas/User' + + # ============================ + # Public Content & Interaction + # ============================ + /contents: + get: + summary: List contents (Explore / Search) + security: [] + parameters: + - name: keyword + in: query + schema: + type: string + description: Search keyword + - name: genre + in: query + schema: + type: string + - name: tenantId + in: query + schema: + type: string + description: Filter by creator + - name: sort + in: query + schema: + type: string + enum: [latest, hot, price_asc] + - name: page + in: query + schema: + type: integer + responses: + '200': + description: List of contents + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ContentItem' + pagination: + $ref: '#/components/schemas/Pagination' + + /contents/{id}: + get: + summary: Get content detail + security: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Content detail + content: + application/json: + schema: + $ref: '#/components/schemas/ContentDetail' + + /contents/{id}/comments: + get: + summary: Get comments for a content + security: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: page + in: query + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Comment' + pagination: + $ref: '#/components/schemas/Pagination' + post: + summary: Post a comment + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + replyTo: + type: string + responses: + '200': + description: Comment created + + /comments/{id}/like: + post: + summary: Like a comment + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Liked + + /topics: + get: + summary: List curated topics + security: [] + responses: + '200': + description: List of topics + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + cover: + type: string + tag: + type: string + count: + type: integer + + # ============================ + # Public Tenant Profile + # ============================ + /tenants/{id}: + get: + summary: Get tenant public profile + security: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TenantProfile' + + /tenants/{id}/follow: + post: + summary: Follow a tenant + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Followed + delete: + summary: Unfollow a tenant + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Unfollowed + + # ============================ + # User Center + # ============================ + /me: + get: + summary: Get current user profile + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update user profile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdate' + responses: + '200': + description: Updated + + /me/realname: + post: + summary: Submit real-name authentication + requestBody: + content: + application/json: + schema: + type: object + properties: + realname: + type: string + idCard: + type: string + responses: + '200': + description: Submitted + + /me/wallet: + get: + summary: Get wallet balance and transactions + responses: + '200': + content: + application/json: + schema: + type: object + properties: + balance: + type: number + transactions: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + amount: + type: number + type: + type: string + enum: [income, expense] + date: + type: string + + /me/wallet/recharge: + post: + summary: Recharge wallet + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + method: + type: string + enum: [wechat, alipay] + responses: + '200': + description: Recharge initiated + content: + application/json: + schema: + type: object + properties: + payParams: + type: string # QR code or SDK params + orderId: + type: string + + /me/orders: + get: + summary: List user orders + parameters: + - name: status + in: query + schema: + type: string + enum: [all, unpaid, completed, refund] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + + /me/orders/{id}: + get: + summary: Get user order detail + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + + /me/library: + get: + summary: Get purchased content + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContentItem' + + /me/favorites: + get: + summary: Get favorites + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContentItem' + post: + summary: Add to favorites + parameters: + - name: contentId + in: query + required: true + schema: + type: string + responses: + '200': + description: Added + + /me/favorites/{contentId}: + delete: + summary: Remove from favorites + parameters: + - name: contentId + in: path + required: true + schema: + type: string + responses: + '200': + description: Removed + + /me/likes: + get: + summary: Get liked contents + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContentItem' + post: + summary: Like content + parameters: + - name: contentId + in: query + required: true + schema: + type: string + responses: + '200': + description: Liked + + /me/likes/{contentId}: + delete: + summary: Unlike content + parameters: + - name: contentId + in: path + required: true + schema: + type: string + responses: + '200': + description: Unliked + + /me/notifications: + get: + summary: Get notifications + parameters: + - name: type + in: query + schema: + type: string + enum: [all, system, order, audit, interaction] + responses: + '200': + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + type: + type: string + title: + type: string + content: + type: string + read: + type: boolean + time: + type: string + + # ============================ + # Transaction + # ============================ + /orders: + post: + summary: Create Order + requestBody: + content: + application/json: + schema: + type: object + properties: + contentId: + type: string + sku: + type: string + quantity: + type: integer + default: 1 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + orderId: + type: string + + /orders/{id}/pay: + post: + summary: Pay for order + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + method: + type: string + enum: [wechat, alipay, balance] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + payParams: + type: string + + /orders/{id}/status: + get: + summary: Check order payment status + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [unpaid, paid, completed] + + # ============================ + # Creator Center + # ============================ + /creator/apply: + post: + summary: Apply to become a creator + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + bio: + type: string + avatar: + type: string + responses: + '200': + description: Application submitted + + /creator/dashboard: + get: + summary: Get creator dashboard stats + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CreatorStats' + + /creator/contents: + get: + summary: List creator contents + parameters: + - name: status + in: query + schema: + type: string + - name: genre + in: query + schema: + type: string + - name: keyword + in: query + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContentItem' + post: + summary: Create/Publish content + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + genre: + type: string + price: + type: number + mediaIds: + type: array + items: + type: string + responses: + '200': + description: Created + + /creator/contents/{id}: + put: + summary: Update content + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + # fields to update + responses: + '200': + description: Updated + delete: + summary: Delete content + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Deleted + + /creator/orders: + get: + summary: List sales orders + parameters: + - name: status + in: query + schema: + type: string + - name: keyword + in: query + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + + /creator/orders/{id}/refund: + post: + summary: Process refund + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: [accept, reject] + reason: + type: string + responses: + '200': + description: Processed + + /creator/settings: + get: + summary: Get channel settings + responses: + '200': + content: + application/json: + schema: + type: object + properties: + name: + type: string + bio: + type: string + avatar: + type: string + cover: + type: string + description: + type: string + put: + summary: Update channel settings + requestBody: + content: + application/json: + schema: + type: object + # fields to update + responses: + '200': + description: Updated + + /creator/payout-accounts: + get: + summary: List payout accounts + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PayoutAccount' + post: + summary: Add payout account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PayoutAccount' + responses: + '200': + description: Added + delete: + summary: Remove payout account + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Removed + + /creator/withdraw: + post: + summary: Request withdrawal + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + method: + type: string + enum: [wallet, external] + accountId: + type: string # Required if method is external + responses: + '200': + description: Withdrawal requested + + # ============================ + # Common / Upload + # ============================ + /upload: + post: + summary: Upload file + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + type: + type: string + enum: [image, video, audio] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResult' diff --git a/backend/app/commands/http/http.go b/backend/app/commands/http/http.go index 20b5a18..9403727 100644 --- a/backend/app/commands/http/http.go +++ b/backend/app/commands/http/http.go @@ -6,11 +6,6 @@ import ( "quyun/v2/app/commands" "quyun/v2/app/errorx" - "quyun/v2/app/http/super" - "quyun/v2/app/http/tenant" - "quyun/v2/app/http/tenant_join" - "quyun/v2/app/http/tenant_media" - "quyun/v2/app/http/tenant_public" "quyun/v2/app/http/web" "quyun/v2/app/jobs" "quyun/v2/app/middlewares" @@ -55,12 +50,6 @@ func Command() atom.Option { jobs.Provide, services.Provide, middlewares.Provide, - super.Provide, - tenant.Provide, - tenant_join.Provide, - tenant_public.Provide, - tenant_media.Provide, - // {Provider: api.Provide}, web.Provide, ), ), diff --git a/backend/app/http/super/auth.go b/backend/app/http/super/auth.go deleted file mode 100644 index 3423be6..0000000 --- a/backend/app/http/super/auth.go +++ /dev/null @@ -1,76 +0,0 @@ -package super - -import ( - "quyun/v2/app/errorx" - "quyun/v2/app/http/super/dto" - "quyun/v2/app/services" - "quyun/v2/pkg/consts" - "quyun/v2/providers/app" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -// @provider -type auth struct { - app *app.Config - jwt *jwt.JWT -} - -// Login -// -// @Tags Super -// @Accept json -// @Produce json -// @Param form body dto.LoginForm true "form" -// @Success 200 {object} dto.LoginResponse "成功" -// -// @Router /super/v1/auth/login [post] -// @Bind form body -func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) { - m, err := services.User.FindByUsername(ctx, form.Username) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("用户名或密码错误") - } - - if ok := m.ComparePassword(ctx, form.Password); !ok { - return nil, errorx.Wrap(err).WithMsg("用户名或密码错误") - } - - if !m.Roles.Contains(consts.RoleSuperAdmin) { - return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误") - } - - token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ - UserID: m.ID, - })) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") - } - - return &dto.LoginResponse{Token: token}, nil -} - -// Token -// -// @Tags Super -// @Accept json -// @Produce json -// @Success 200 {object} dto.LoginResponse "成功" -// -// @Router /super/v1/auth/token [get] -func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ - UserID: claims.UserID, - })) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") - } - - return &dto.LoginResponse{Token: token}, nil -} diff --git a/backend/app/http/super/content.go b/backend/app/http/super/content.go deleted file mode 100644 index 9d508a8..0000000 --- a/backend/app/http/super/content.go +++ /dev/null @@ -1,33 +0,0 @@ -package super - -import ( - "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - - "github.com/gofiber/fiber/v3" -) - -// content provides superadmin content endpoints. -// -// @provider -type content struct{} - -// list -// -// @Summary 内容列表(平台侧汇总) -// @Tags Super -// @Accept json -// @Produce json -// @Param filter query dto.SuperContentPageFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.SuperContentItem} -// -// @Router /super/v1/contents [get] -// @Bind filter query -func (*content) list(ctx fiber.Ctx, filter *dto.SuperContentPageFilter) (*requests.Pager, error) { - if filter == nil { - filter = &dto.SuperContentPageFilter{} - } - filter.Pagination.Format() - return services.Content.SuperContentPage(ctx, filter) -} diff --git a/backend/app/http/super/dto/auth.go b/backend/app/http/super/dto/auth.go deleted file mode 100644 index 1eef1a0..0000000 --- a/backend/app/http/super/dto/auth.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -type LoginForm struct { - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` -} - -type LoginResponse struct { - Token string `json:"token,omitempty"` -} diff --git a/backend/app/http/super/dto/content.go b/backend/app/http/super/dto/content.go deleted file mode 100644 index de8de68..0000000 --- a/backend/app/http/super/dto/content.go +++ /dev/null @@ -1,44 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// TenantContentFilter defines list query filters for tenant contents (superadmin). -type TenantContentFilter struct { - requests.Pagination `json:",inline" query:",inline"` - requests.SortQueryFilter `json:",inline" query:",inline"` - - Keyword *string `json:"keyword,omitempty" query:"keyword"` - - Status *consts.ContentStatus `json:"status,omitempty" query:"status"` - Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` - - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - - PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"` - PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_at_to"` - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -func (f *TenantContentFilter) KeywordTrimmed() string { - if f == nil || f.Keyword == nil { - return "" - } - return strings.TrimSpace(*f.Keyword) -} - -type SuperTenantContentItem struct { - Content *models.Content `json:"content,omitempty"` - Price *models.ContentPrice `json:"price,omitempty"` - Owner *SuperUserLite `json:"owner,omitempty"` - - StatusDescription string `json:"status_description,omitempty"` - VisibilityDescription string `json:"visibility_description,omitempty"` -} diff --git a/backend/app/http/super/dto/content_page.go b/backend/app/http/super/dto/content_page.go deleted file mode 100644 index ae5f011..0000000 --- a/backend/app/http/super/dto/content_page.go +++ /dev/null @@ -1,81 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -type SuperContentPageFilter struct { - requests.Pagination `json:",inline" query:",inline"` - requests.SortQueryFilter `json:",inline" query:",inline"` - - ID *int64 `json:"id,omitempty" query:"id"` - - TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` - TenantCode *string `json:"tenant_code,omitempty" query:"tenant_code"` - TenantName *string `json:"tenant_name,omitempty" query:"tenant_name"` - - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - Username *string `json:"username,omitempty" query:"username"` - - Keyword *string `json:"keyword,omitempty" query:"keyword"` - - Status *consts.ContentStatus `json:"status,omitempty" query:"status"` - Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` - - PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"` - PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_at_to"` - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` - - PriceAmountMin *int64 `json:"price_amount_min,omitempty" query:"price_amount_min"` - PriceAmountMax *int64 `json:"price_amount_max,omitempty" query:"price_amount_max"` -} - -func (f *SuperContentPageFilter) KeywordTrimmed() string { - if f == nil || f.Keyword == nil { - return "" - } - return strings.TrimSpace(*f.Keyword) -} - -func (f *SuperContentPageFilter) TenantCodeTrimmed() string { - if f == nil || f.TenantCode == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(*f.TenantCode)) -} - -func (f *SuperContentPageFilter) TenantNameTrimmed() string { - if f == nil || f.TenantName == nil { - return "" - } - return strings.TrimSpace(*f.TenantName) -} - -func (f *SuperContentPageFilter) UsernameTrimmed() string { - if f == nil || f.Username == nil { - return "" - } - return strings.TrimSpace(*f.Username) -} - -type SuperContentTenantLite struct { - ID int64 `json:"id"` - Code string `json:"code"` - Name string `json:"name"` -} - -type SuperContentItem struct { - Content *models.Content `json:"content,omitempty"` - Price *models.ContentPrice `json:"price,omitempty"` - Tenant *SuperContentTenantLite `json:"tenant,omitempty"` - Owner *SuperUserLite `json:"owner,omitempty"` - - StatusDescription string `json:"status_description,omitempty"` - VisibilityDescription string `json:"visibility_description,omitempty"` -} diff --git a/backend/app/http/super/dto/content_status.go b/backend/app/http/super/dto/content_status.go deleted file mode 100644 index 6fc5229..0000000 --- a/backend/app/http/super/dto/content_status.go +++ /dev/null @@ -1,8 +0,0 @@ -package dto - -import "quyun/v2/pkg/consts" - -type SuperTenantContentStatusUpdateForm struct { - // Status supports: unpublished (下架) / blocked (封禁) - Status consts.ContentStatus `json:"status" validate:"required,oneof=unpublished blocked"` -} diff --git a/backend/app/http/super/dto/order.go b/backend/app/http/super/dto/order.go deleted file mode 100644 index 69b7c04..0000000 --- a/backend/app/http/super/dto/order.go +++ /dev/null @@ -1,17 +0,0 @@ -package dto - -import "quyun/v2/pkg/consts" - -type OrderStatisticsRow struct { - Status consts.OrderStatus `json:"status"` - StatusDescription string `json:"status_description"` - - Count int64 `json:"count"` - AmountPaidSum int64 `json:"amount_paid_sum"` -} - -type OrderStatisticsResponse struct { - TotalCount int64 `json:"total_count"` - TotalAmountPaidSum int64 `json:"total_amount_paid_sum"` - ByStatus []*OrderStatisticsRow `json:"by_status"` -} diff --git a/backend/app/http/super/dto/order_detail.go b/backend/app/http/super/dto/order_detail.go deleted file mode 100644 index 395d31d..0000000 --- a/backend/app/http/super/dto/order_detail.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -import "quyun/v2/database/models" - -type SuperOrderDetail struct { - Order *models.Order `json:"order,omitempty"` - - Tenant *OrderTenantLite `json:"tenant,omitempty"` - Buyer *OrderBuyerLite `json:"buyer,omitempty"` -} diff --git a/backend/app/http/super/dto/order_page.go b/backend/app/http/super/dto/order_page.go deleted file mode 100644 index d159146..0000000 --- a/backend/app/http/super/dto/order_page.go +++ /dev/null @@ -1,96 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" -) - -type OrderPageFilter struct { - requests.Pagination `json:",inline" query:",inline"` - requests.SortQueryFilter `json:",inline" query:",inline"` - - ID *int64 `json:"id,omitempty" query:"id"` - TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - - TenantCode *string `json:"tenant_code,omitempty" query:"tenant_code"` - TenantName *string `json:"tenant_name,omitempty" query:"tenant_name"` - Username *string `json:"username,omitempty" query:"username"` - - ContentID *int64 `json:"content_id,omitempty" query:"content_id"` - ContentTitle *string `json:"content_title,omitempty" query:"content_title"` - - Type *consts.OrderType `json:"type,omitempty" query:"type"` - Status *consts.OrderStatus `json:"status,omitempty" query:"status"` - - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` - PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"` - PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"` - - AmountPaidMin *int64 `json:"amount_paid_min,omitempty" query:"amount_paid_min"` - AmountPaidMax *int64 `json:"amount_paid_max,omitempty" query:"amount_paid_max"` -} - -func (f *OrderPageFilter) TenantCodeTrimmed() string { - if f == nil || f.TenantCode == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(*f.TenantCode)) -} - -func (f *OrderPageFilter) TenantNameTrimmed() string { - if f == nil || f.TenantName == nil { - return "" - } - return strings.TrimSpace(*f.TenantName) -} - -func (f *OrderPageFilter) UsernameTrimmed() string { - if f == nil || f.Username == nil { - return "" - } - return strings.TrimSpace(*f.Username) -} - -func (f *OrderPageFilter) ContentTitleTrimmed() string { - if f == nil || f.ContentTitle == nil { - return "" - } - return strings.TrimSpace(*f.ContentTitle) -} - -type OrderTenantLite struct { - ID int64 `json:"id"` - Code string `json:"code"` - Name string `json:"name"` -} - -type OrderBuyerLite struct { - ID int64 `json:"id"` - Username string `json:"username"` -} - -type SuperOrderItem struct { - ID int64 `json:"id"` - Tenant *OrderTenantLite `json:"tenant,omitempty"` - Buyer *OrderBuyerLite `json:"buyer,omitempty"` - - Type consts.OrderType `json:"type"` - Status consts.OrderStatus `json:"status"` - - StatusDescription string `json:"status_description,omitempty"` - Currency consts.Currency `json:"currency"` - - AmountOriginal int64 `json:"amount_original"` - AmountDiscount int64 `json:"amount_discount"` - AmountPaid int64 `json:"amount_paid"` - - PaidAt time.Time `json:"paid_at"` - RefundedAt time.Time `json:"refunded_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/backend/app/http/super/dto/order_refund.go b/backend/app/http/super/dto/order_refund.go deleted file mode 100644 index a8cb36c..0000000 --- a/backend/app/http/super/dto/order_refund.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -type SuperOrderRefundForm struct { - // Force indicates bypassing the default refund window check (paid_at + 24h). - Force bool `json:"force,omitempty"` - // Reason is the human-readable refund reason used for audit. - Reason string `json:"reason,omitempty"` - // IdempotencyKey ensures refund request is processed at most once. - IdempotencyKey string `json:"idempotency_key,omitempty"` -} diff --git a/backend/app/http/super/dto/tenant.go b/backend/app/http/super/dto/tenant.go deleted file mode 100644 index f99a120..0000000 --- a/backend/app/http/super/dto/tenant.go +++ /dev/null @@ -1,92 +0,0 @@ -package dto - -import ( - "errors" - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -type TenantFilter struct { - // Pagination page/limit. - requests.Pagination `json:",inline" query:",inline"` - - // SortQueryFilter defines asc/desc ordering. - requests.SortQueryFilter `json:",inline" query:",inline"` - - Name *string `json:"name,omitempty" query:"name"` - Code *string `json:"code,omitempty" query:"code"` - ID *int64 `json:"id,omitempty" query:"id"` - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - Status *consts.TenantStatus `json:"status,omitempty" query:"status"` - - ExpiredAtFrom *time.Time `json:"expired_at_from,omitempty" query:"expired_at_from"` - ExpiredAtTo *time.Time `json:"expired_at_to,omitempty" query:"expired_at_to"` - - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -func (f *TenantFilter) NameTrimmed() string { - if f == nil || f.Name == nil { - return "" - } - return strings.TrimSpace(*f.Name) -} - -func (f *TenantFilter) CodeTrimmed() string { - if f == nil || f.Code == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(*f.Code)) -} - -type TenantItem struct { - *models.Tenant - - UserCount int64 `json:"user_count"` - // IncomeAmountPaidSum 累计收入金额(单位:分,CNY):按 orders 聚合得到的已支付净收入(不含退款中/已退款订单)。 - IncomeAmountPaidSum int64 `json:"income_amount_paid_sum"` - StatusDescription string `json:"status_description"` - - Owner *TenantOwnerUserLite `json:"owner,omitempty"` - AdminUsers []*TenantAdminUserLite `json:"admin_users,omitempty"` -} - -type TenantOwnerUserLite struct { - ID int64 `json:"id"` - Username string `json:"username"` -} - -type TenantAdminUserLite struct { - ID int64 `json:"id"` - Username string `json:"username"` -} - -type TenantCreateForm struct { - Code string `json:"code" validate:"required,max=64"` - Name string `json:"name" validate:"required,max=128"` - AdminUserID int64 `json:"admin_user_id" validate:"required,gt=0"` - // Duration 租户有效期(天),从“创建时刻”起算;与续期接口保持一致。 - Duration int `json:"duration" validate:"required,oneof=7 30 90 180 365"` -} - -type TenantExpireUpdateForm struct { - Duration int `json:"duration" validate:"required,oneof=7 30 90 180 365"` -} - -// Duration -func (form *TenantExpireUpdateForm) ParseDuration() (time.Duration, error) { - duration := time.Duration(form.Duration) * 24 * time.Hour - if duration == 0 { - return 0, errors.New("invalid parsed duration") - } - return duration, nil -} - -type TenantStatusUpdateForm struct { - Status consts.TenantStatus `json:"status" validate:"required,oneof=normal disabled"` -} diff --git a/backend/app/http/super/dto/tenant_user.go b/backend/app/http/super/dto/tenant_user.go deleted file mode 100644 index 6a3d52c..0000000 --- a/backend/app/http/super/dto/tenant_user.go +++ /dev/null @@ -1,26 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "go.ipao.vip/gen/types" -) - -type SuperUserLite struct { - ID int64 `json:"id"` - Username string `json:"username"` - Status consts.UserStatus `json:"status"` - Roles types.Array[consts.Role] `json:"roles"` - VerifiedAt time.Time `json:"verified_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StatusDescription string `json:"status_description,omitempty"` -} - -type SuperTenantUserItem struct { - TenantUser *models.TenantUser `json:"tenant_user,omitempty"` - User *SuperUserLite `json:"user,omitempty"` -} diff --git a/backend/app/http/super/dto/user.go b/backend/app/http/super/dto/user.go deleted file mode 100644 index 3f1dbf4..0000000 --- a/backend/app/http/super/dto/user.go +++ /dev/null @@ -1,65 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" - - "go.ipao.vip/gen/types" -) - -type UserPageFilter struct { - requests.Pagination `json:",inline" query:",inline"` - requests.SortQueryFilter `json:",inline" query:",inline"` - - ID *int64 `json:"id,omitempty" query:"id"` - Username *string `json:"username,omitempty" query:"username"` - Status *consts.UserStatus `json:"status,omitempty" query:"status"` - - // TenantID filters users by membership in the given tenant. - TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` - - // Role filters users containing a role (user/super_admin). - Role *consts.Role `json:"role,omitempty" query:"role"` - - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` - VerifiedAtFrom *time.Time `json:"verified_at_from,omitempty" query:"verified_at_from"` - VerifiedAtTo *time.Time `json:"verified_at_to,omitempty" query:"verified_at_to"` -} - -func (f *UserPageFilter) UsernameTrimmed() string { - if f == nil || f.Username == nil { - return "" - } - return strings.TrimSpace(*f.Username) -} - -type UserItem struct { - ID int64 `json:"id"` - Username string `json:"username"` - Roles types.Array[consts.Role] `json:"roles"` - Status consts.UserStatus `json:"status"` - StatusDescription string `json:"status_description,omitempty"` - - Balance int64 `json:"balance"` - BalanceFrozen int64 `json:"balance_frozen"` - VerifiedAt time.Time `json:"verified_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - OwnedTenantCount int64 `json:"owned_tenant_count"` - JoinedTenantCount int64 `json:"joined_tenant_count"` -} - -type UserStatusUpdateForm struct { - Status consts.UserStatus `json:"status" validate:"required,oneof=normal disabled"` -} - -type UserStatistics struct { - Status consts.UserStatus `json:"status"` - StatusDescription string `json:"status_description"` - Count int64 `json:"count"` -} diff --git a/backend/app/http/super/dto/user_roles.go b/backend/app/http/super/dto/user_roles.go deleted file mode 100644 index 2edea28..0000000 --- a/backend/app/http/super/dto/user_roles.go +++ /dev/null @@ -1,7 +0,0 @@ -package dto - -import "quyun/v2/pkg/consts" - -type UserRolesUpdateForm struct { - Roles []consts.Role `json:"roles" validate:"required,min=1,dive,oneof=user super_admin"` -} diff --git a/backend/app/http/super/dto/user_tenant.go b/backend/app/http/super/dto/user_tenant.go deleted file mode 100644 index 9af44f5..0000000 --- a/backend/app/http/super/dto/user_tenant.go +++ /dev/null @@ -1,60 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" - - "go.ipao.vip/gen/types" -) - -type UserTenantPageFilter struct { - requests.Pagination `json:",inline" query:",inline"` - - TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` - Code *string `json:"code,omitempty" query:"code"` - Name *string `json:"name,omitempty" query:"name"` - - // Role filters tenant_users.role containing a role (tenant_admin/member). - Role *consts.TenantUserRole `json:"role,omitempty" query:"role"` - // Status filters tenant_users.status. - Status *consts.UserStatus `json:"status,omitempty" query:"status"` - - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -func (f *UserTenantPageFilter) CodeTrimmed() string { - if f == nil || f.Code == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(*f.Code)) -} - -func (f *UserTenantPageFilter) NameTrimmed() string { - if f == nil || f.Name == nil { - return "" - } - return strings.TrimSpace(*f.Name) -} - -type UserTenantItem struct { - TenantID int64 `json:"tenant_id"` - Code string `json:"code"` - Name string `json:"name"` - - TenantStatus consts.TenantStatus `json:"tenant_status"` - TenantStatusDescription string `json:"tenant_status_description,omitempty"` - ExpiredAt time.Time `json:"expired_at"` - - Owner *TenantOwnerUserLite `json:"owner,omitempty"` - - Role types.Array[consts.TenantUserRole] `json:"role"` - - MemberStatus consts.UserStatus `json:"member_status"` - MemberStatusDescription string `json:"member_status_description,omitempty"` - - JoinedAt time.Time `json:"joined_at"` -} diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go deleted file mode 100644 index 40f117b..0000000 --- a/backend/app/http/super/order.go +++ /dev/null @@ -1,96 +0,0 @@ -package super - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -// @provider -type order struct{} - -// list -// -// @Summary 订单列表 -// @Tags Super -// @Accept json -// @Produce json -// @Param filter query dto.OrderPageFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.SuperOrderItem} -// -// @Router /super/v1/orders [get] -// @Bind filter query -func (*order) list(ctx fiber.Ctx, filter *dto.OrderPageFilter) (*requests.Pager, error) { - return services.Order.SuperOrderPage(ctx, filter) -} - -// detail -// -// @Summary 订单详情 -// @Tags Super -// @Accept json -// @Produce json -// @Param orderID path int64 true "OrderID" -// @Success 200 {object} dto.SuperOrderDetail -// -// @Router /super/v1/orders/:orderID [get] -// @Bind orderID path -func (*order) detail(ctx fiber.Ctx, orderID int64) (*dto.SuperOrderDetail, error) { - return services.Order.SuperOrderDetail(ctx, orderID) -} - -// refund -// -// @Summary 订单退款(平台) -// @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 -// @Tags Super -// @Accept json -// @Produce json -// @Param orderID path int64 true "OrderID" -// @Param form body dto.SuperOrderRefundForm true "Form" -// @Success 200 {object} models.Order -// -// @Router /super/v1/orders/:orderID/refund [post] -// @Bind orderID path -// @Bind form body -func (*order) refund(ctx fiber.Ctx, orderID int64, form *dto.SuperOrderRefundForm) (*models.Order, error) { - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - return services.Order.SuperRefundOrder( - ctx, - claims.UserID, - orderID, - form.Force, - form.Reason, - form.IdempotencyKey, - time.Now(), - ) -} - -// statistics -// -// @Summary 订单统计信息 -// @Tags Super -// @Accept json -// @Produce json -// @Success 200 {object} dto.OrderStatisticsResponse -// -// @Router /super/v1/orders/statistics [get] -func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) { - return services.Order.SuperStatistics(ctx) -} diff --git a/backend/app/http/super/provider.gen.go b/backend/app/http/super/provider.gen.go deleted file mode 100755 index 58625e0..0000000 --- a/backend/app/http/super/provider.gen.go +++ /dev/null @@ -1,88 +0,0 @@ -package super - -import ( - "quyun/v2/app/middlewares" - "quyun/v2/providers/app" - "quyun/v2/providers/jwt" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/atom/opt" -) - -func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func( - app *app.Config, - jwt *jwt.JWT, - ) (*auth, error) { - obj := &auth{ - app: app, - jwt: jwt, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*content, error) { - obj := &content{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*order, error) { - obj := &order{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - auth *auth, - content *content, - middlewares *middlewares.Middlewares, - order *order, - tenant *tenant, - user *user, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - auth: auth, - content: content, - middlewares: middlewares, - order: order, - tenant: tenant, - user: user, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - if err := container.Container.Provide(func() (*staticController, error) { - obj := &staticController{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenant, error) { - obj := &tenant{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*user, error) { - obj := &user{} - - return obj, nil - }); err != nil { - return err - } - return nil -} diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go deleted file mode 100644 index 1e01dcc..0000000 --- a/backend/app/http/super/routes.gen.go +++ /dev/null @@ -1,176 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package super provides HTTP route definitions and registration -// for the quyun/v2 application. -package super - -import ( - "quyun/v2/app/http/super/dto" - tenantdto "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/middlewares" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the super module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - auth *auth - content *content - order *order - tenant *tenant - user *user -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.super") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "super" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: auth - r.log.Debugf("Registering route: Get /super/v1/auth/token -> auth.token") - router.Get("/super/v1/auth/token"[len(r.Path()):], DataFunc0( - r.auth.token, - )) - r.log.Debugf("Registering route: Post /super/v1/auth/login -> auth.login") - router.Post("/super/v1/auth/login"[len(r.Path()):], DataFunc1( - r.auth.login, - Body[dto.LoginForm]("form"), - )) - // Register routes for controller: content - r.log.Debugf("Registering route: Get /super/v1/contents -> content.list") - router.Get("/super/v1/contents"[len(r.Path()):], DataFunc1( - r.content.list, - Query[dto.SuperContentPageFilter]("filter"), - )) - // Register routes for controller: order - r.log.Debugf("Registering route: Get /super/v1/orders -> order.list") - router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( - r.order.list, - Query[dto.OrderPageFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/orders/:orderID -> order.detail") - router.Get("/super/v1/orders/:orderID"[len(r.Path()):], DataFunc1( - r.order.detail, - PathParam[int64]("orderID"), - )) - r.log.Debugf("Registering route: Get /super/v1/orders/statistics -> order.statistics") - router.Get("/super/v1/orders/statistics"[len(r.Path()):], DataFunc0( - r.order.statistics, - )) - r.log.Debugf("Registering route: Post /super/v1/orders/:orderID/refund -> order.refund") - router.Post("/super/v1/orders/:orderID/refund"[len(r.Path()):], DataFunc2( - r.order.refund, - PathParam[int64]("orderID"), - Body[dto.SuperOrderRefundForm]("form"), - )) - // Register routes for controller: tenant - r.log.Debugf("Registering route: Get /super/v1/tenants -> tenant.list") - router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1( - r.tenant.list, - Query[dto.TenantFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID -> tenant.detail") - router.Get("/super/v1/tenants/:tenantID"[len(r.Path()):], DataFunc1( - r.tenant.detail, - PathParam[int64]("tenantID"), - )) - r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/contents -> tenant.contents") - router.Get("/super/v1/tenants/:tenantID/contents"[len(r.Path()):], DataFunc2( - r.tenant.contents, - PathParam[int64]("tenantID"), - Query[dto.TenantContentFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenant.users") - router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2( - r.tenant.users, - PathParam[int64]("tenantID"), - Query[tenantdto.AdminTenantUserListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenant.statusList") - router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0( - r.tenant.statusList, - )) - r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID -> tenant.updateExpire") - router.Patch("/super/v1/tenants/:tenantID"[len(r.Path()):], Func2( - r.tenant.updateExpire, - PathParam[int64]("tenantID"), - Body[dto.TenantExpireUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID/contents/:contentID/status -> tenant.updateContentStatus") - router.Patch("/super/v1/tenants/:tenantID/contents/:contentID/status"[len(r.Path()):], DataFunc3( - r.tenant.updateContentStatus, - PathParam[int64]("tenantID"), - PathParam[int64]("contentID"), - Body[dto.SuperTenantContentStatusUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID/status -> tenant.updateStatus") - router.Patch("/super/v1/tenants/:tenantID/status"[len(r.Path()):], Func2( - r.tenant.updateStatus, - PathParam[int64]("tenantID"), - Body[dto.TenantStatusUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Post /super/v1/tenants -> tenant.create") - router.Post("/super/v1/tenants"[len(r.Path()):], DataFunc1( - r.tenant.create, - Body[dto.TenantCreateForm]("form"), - )) - // Register routes for controller: user - r.log.Debugf("Registering route: Get /super/v1/users -> user.list") - router.Get("/super/v1/users"[len(r.Path()):], DataFunc1( - r.user.list, - Query[dto.UserPageFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/users/:userID -> user.detail") - router.Get("/super/v1/users/:userID"[len(r.Path()):], DataFunc1( - r.user.detail, - PathParam[int64]("userID"), - )) - r.log.Debugf("Registering route: Get /super/v1/users/:userID/tenants -> user.tenants") - router.Get("/super/v1/users/:userID/tenants"[len(r.Path()):], DataFunc2( - r.user.tenants, - PathParam[int64]("userID"), - Query[dto.UserTenantPageFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /super/v1/users/statistics -> user.statistics") - router.Get("/super/v1/users/statistics"[len(r.Path()):], DataFunc0( - r.user.statistics, - )) - r.log.Debugf("Registering route: Get /super/v1/users/statuses -> user.statusList") - router.Get("/super/v1/users/statuses"[len(r.Path()):], DataFunc0( - r.user.statusList, - )) - r.log.Debugf("Registering route: Patch /super/v1/users/:userID/roles -> user.updateRoles") - router.Patch("/super/v1/users/:userID/roles"[len(r.Path()):], Func2( - r.user.updateRoles, - PathParam[int64]("userID"), - Body[dto.UserRolesUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Patch /super/v1/users/:userID/status -> user.updateStatus") - router.Patch("/super/v1/users/:userID/status"[len(r.Path()):], Func2( - r.user.updateStatus, - PathParam[int64]("userID"), - Body[dto.UserStatusUpdateForm]("form"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/super/routes.manual.go b/backend/app/http/super/routes.manual.go deleted file mode 100644 index e8df529..0000000 --- a/backend/app/http/super/routes.manual.go +++ /dev/null @@ -1,11 +0,0 @@ -package super - -func (r *Routes) Path() string { - return "/super/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.SuperAuth, - } -} diff --git a/backend/app/http/super/static.go b/backend/app/http/super/static.go deleted file mode 100644 index f205ac1..0000000 --- a/backend/app/http/super/static.go +++ /dev/null @@ -1,21 +0,0 @@ -package super - -// @provider -type staticController struct{} - -// // Static -// // -// // @Tags Super -// // @Router /super/* -// func (ctl *staticController) static(ctx fiber.Ctx) error { -// root := "/home/rogee/Projects/quyun_v2/frontend/superadmin/dist/" -// param := ctx.Params("*") -// file := filepath.Join(root, param) - -// // if file not exits use index.html -// if _, err := os.Stat(file); os.IsNotExist(err) { -// file = filepath.Join(root, "index.html") -// } - -// return ctx.SendFile(file) -// } diff --git a/backend/app/http/super/tenant.go b/backend/app/http/super/tenant.go deleted file mode 100644 index 9110d25..0000000 --- a/backend/app/http/super/tenant.go +++ /dev/null @@ -1,130 +0,0 @@ -package super - -import ( - "quyun/v2/app/errorx" - "quyun/v2/app/http/super/dto" - tenantdto "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" -) - -// @provider -type tenant struct{} - -// detail -// -// @Summary 租户详情 -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Success 200 {object} dto.TenantItem -// -// @Router /super/v1/tenants/:tenantID [get] -// @Bind tenantID path -func (*tenant) detail(ctx fiber.Ctx, tenantID int64) (*dto.TenantItem, error) { - return services.Tenant.SuperDetail(ctx, tenantID) -} - -// list -// -// @Summary 租户列表 -// @Tags Super -// @Accept json -// @Produce json -// @Param filter query dto.TenantFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.TenantItem} -// -// @Router /super/v1/tenants [get] -// @Bind filter query -func (*tenant) list(ctx fiber.Ctx, filter *dto.TenantFilter) (*requests.Pager, error) { - return services.Tenant.Pager(ctx, filter) -} - -// create -// -// @Summary 创建租户并设置租户管理员 -// @Tags Super -// @Accept json -// @Produce json -// @Param form body dto.TenantCreateForm true "Form" -// @Success 200 {object} models.Tenant -// -// @Router /super/v1/tenants [post] -// @Bind form body -func (*tenant) create(ctx fiber.Ctx, form *dto.TenantCreateForm) (*models.Tenant, error) { - return services.Tenant.SuperCreateTenant(ctx, form) -} - -// users -// -// @Summary 租户成员列表(平台侧) -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Param filter query tenantdto.AdminTenantUserListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.SuperTenantUserItem} -// -// @Router /super/v1/tenants/:tenantID/users [get] -// @Bind tenantID path -// @Bind filter query -func (*tenant) users(ctx fiber.Ctx, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { - return services.Tenant.SuperTenantUsersPage(ctx, tenantID, filter) -} - -// updateExpire -// -// @Summary 更新过期时间 -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Param form body dto.TenantExpireUpdateForm true "Form" -// -// @Router /super/v1/tenants/:tenantID [patch] -// @Bind tenantID path -// @Bind form body -func (*tenant) updateExpire(ctx fiber.Ctx, tenantID int64, form *dto.TenantExpireUpdateForm) error { - duration, err := form.ParseDuration() - if err != nil { - return errorx.Wrap(err).WithMsg("时间解析出错") - } - - return services.Tenant.AddExpireDuration(ctx, tenantID, duration) -} - -// updateStatus -// -// @Summary 更新租户状态 -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Param form body dto.TenantStatusUpdateForm true "Form" -// -// @Router /super/v1/tenants/:tenantID/status [patch] -// @Bind tenantID path -// @Bind form body -func (*tenant) updateStatus(ctx fiber.Ctx, tenantID int64, form *dto.TenantStatusUpdateForm) error { - return services.Tenant.UpdateStatus(ctx, tenantID, form.Status) -} - -// statusList -// -// @Summary 租户状态列表 -// @Tags Super -// @Accept json -// @Produce json -// @Success 200 {array} requests.KV -// -// @Router /super/v1/tenants/statuses [get] -// @Bind userID path -// @Bind form body -func (*tenant) statusList(ctx fiber.Ctx) ([]requests.KV, error) { - return consts.TenantStatusItems(), nil -} diff --git a/backend/app/http/super/tenant_content.go b/backend/app/http/super/tenant_content.go deleted file mode 100644 index 8ac7dad..0000000 --- a/backend/app/http/super/tenant_content.go +++ /dev/null @@ -1,30 +0,0 @@ -package super - -import ( - "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - - "github.com/gofiber/fiber/v3" -) - -// contents -// -// @Summary 租户内容列表(平台侧) -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Param filter query dto.TenantContentFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.SuperTenantContentItem} -// -// @Router /super/v1/tenants/:tenantID/contents [get] -// @Bind tenantID path -// @Bind filter query -func (*tenant) contents(ctx fiber.Ctx, tenantID int64, filter *dto.TenantContentFilter) (*requests.Pager, error) { - if filter == nil { - filter = &dto.TenantContentFilter{} - } - filter.Pagination.Format() - return services.Content.SuperTenantContentsPage(ctx, tenantID, filter) -} diff --git a/backend/app/http/super/tenant_content_status.go b/backend/app/http/super/tenant_content_status.go deleted file mode 100644 index c48c4fb..0000000 --- a/backend/app/http/super/tenant_content_status.go +++ /dev/null @@ -1,47 +0,0 @@ -package super - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/super/dto" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -// updateContentStatus -// -// @Summary 更新租户内容状态(平台侧:下架/封禁) -// @Tags Super -// @Accept json -// @Produce json -// @Param tenantID path int64 true "TenantID" -// @Param contentID path int64 true "ContentID" -// @Param form body dto.SuperTenantContentStatusUpdateForm true "Form" -// @Success 200 {object} models.Content -// -// @Router /super/v1/tenants/:tenantID/contents/:contentID/status [patch] -// @Bind tenantID path -// @Bind contentID path -// @Bind form body -func (*tenant) updateContentStatus( - ctx fiber.Ctx, - tenantID int64, - contentID int64, - form *dto.SuperTenantContentStatusUpdateForm, -) (*models.Content, error) { - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - return services.Content.SuperUpdateTenantContentStatus(ctx, claims.UserID, tenantID, contentID, form.Status, time.Now()) -} diff --git a/backend/app/http/super/user.go b/backend/app/http/super/user.go deleted file mode 100644 index cb45448..0000000 --- a/backend/app/http/super/user.go +++ /dev/null @@ -1,123 +0,0 @@ -package super - -import ( - "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - _ "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" -) - -// @provider -type user struct{} - -// detail -// -// @Summary 用户详情 -// @Tags Super -// @Accept json -// @Produce json -// @Param userID path int64 true "UserID" -// @Success 200 {object} dto.UserItem -// -// @Router /super/v1/users/:userID [get] -// @Bind userID path -func (*user) detail(ctx fiber.Ctx, userID int64) (*dto.UserItem, error) { - return services.User.Detail(ctx, userID) -} - -// list -// -// @Summary 用户列表 -// @Tags Super -// @Accept json -// @Produce json -// @Param filter query dto.UserPageFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.UserItem} -// -// @Router /super/v1/users [get] -// @Bind filter query -func (*user) list(ctx fiber.Ctx, filter *dto.UserPageFilter) (*requests.Pager, error) { - return services.User.Page(ctx, filter) -} - -// tenants -// -// @Summary 用户加入的租户列表 -// @Tags Super -// @Accept json -// @Produce json -// @Param userID path int64 true "UserID" -// @Param filter query dto.UserTenantPageFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.UserTenantItem} -// -// @Router /super/v1/users/:userID/tenants [get] -// @Bind userID path -// @Bind filter query -func (*user) tenants(ctx fiber.Ctx, userID int64, filter *dto.UserTenantPageFilter) (*requests.Pager, error) { - return services.User.TenantsPage(ctx, userID, filter) -} - -// updateStatus -// -// @Summary 更新用户状态 -// @Tags Super -// @Accept json -// @Produce json -// @Param userID path int64 true "UserID" -// @Param form body dto.UserStatusUpdateForm true "Form" -// -// @Router /super/v1/users/:userID/status [patch] -// @Bind userID path -// @Bind form body -func (*user) updateStatus(ctx fiber.Ctx, userID int64, form *dto.UserStatusUpdateForm) error { - return services.User.UpdateStatus(ctx, userID, form.Status) -} - -// updateRoles -// -// @Summary 更新用户角色 -// @Tags Super -// @Accept json -// @Produce json -// @Param userID path int64 true "UserID" -// @Param form body dto.UserRolesUpdateForm true "Form" -// -// @Router /super/v1/users/:userID/roles [patch] -// @Bind userID path -// @Bind form body -func (*user) updateRoles(ctx fiber.Ctx, userID int64, form *dto.UserRolesUpdateForm) error { - return services.User.UpdateRoles(ctx, userID, form.Roles) -} - -// statusList -// -// @Summary 用户状态列表 -// @Tags Super -// @Accept json -// @Produce json -// @Success 200 {array} requests.KV -// -// @Router /super/v1/users/statuses [get] -// @Bind userID path -// @Bind form body -func (*user) statusList(ctx fiber.Ctx) ([]requests.KV, error) { - return consts.UserStatusItems(), nil -} - -// statistics -// -// @Summary 用户统计信息 -// @Tags Super -// @Accept json -// @Produce json -// @Success 200 {array} dto.UserStatistics -// -// @Router /super/v1/users/statistics [get] -// @Bind userID path -// @Bind form body -func (*user) statistics(ctx fiber.Ctx) ([]*dto.UserStatistics, error) { - return services.User.Statistics(ctx) -} diff --git a/backend/app/http/tenant/content.go b/backend/app/http/tenant/content.go deleted file mode 100644 index 26ee026..0000000 --- a/backend/app/http/tenant/content.go +++ /dev/null @@ -1,199 +0,0 @@ -package tenant - -import ( - "encoding/json" - "net/url" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// content provides tenant-side read-only content endpoints. -// -// @provider -type content struct{} - -// list -// -// @Summary 内容列表(已发布) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.ContentListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.ContentItem} -// -// @Router /t/:tenantCode/v1/contents [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind filter query -func (*content) list(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.ContentListFilter) (*requests.Pager, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - }).Info("tenant.contents.list") - - filter.Pagination.Format() - return services.Content.ListPublished(ctx, tenant.ID, user.ID, filter) -} - -// show -// -// @Summary 内容详情(可见性+权益校验) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} dto.ContentDetail -// -// @Router /t/:tenantCode/v1/contents/:contentID [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind contentID path -func (*content) show(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentDetail, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - "content_id": contentID, - }).Info("tenant.contents.show") - - item, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID) - if err != nil { - return nil, err - } - return &dto.ContentDetail{ - Content: item.Content, - Price: item.Price, - HasAccess: item.HasAccess, - }, nil -} - -// previewAssets -// -// @Summary 获取试看资源(preview role) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} dto.ContentAssetsResponse -// -// @Router /t/:tenantCode/v1/contents/:contentID/preview [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind contentID path -func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentAssetsResponse, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - "content_id": contentID, - }).Info("tenant.contents.preview_assets") - - detail, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID) - if err != nil { - return nil, err - } - - assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRolePreview) - if err != nil { - return nil, err - } - - playables := make([]*dto.ContentPlayableAsset, 0, len(assets)) - for _, asset := range assets { - token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRolePreview, user.ID, 0, time.Now()) - if err != nil { - return nil, err - } - var meta json.RawMessage - if len(asset.Meta) > 0 { - meta = json.RawMessage(asset.Meta) - } - playables = append(playables, &dto.ContentPlayableAsset{ - AssetID: asset.ID, - Type: asset.Type, - PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token), - ExpiresAt: expiresAt, - Meta: meta, - }) - } - - previewSeconds := int32(detail.Content.PreviewSeconds) - if previewSeconds <= 0 { - previewSeconds = consts.DefaultContentPreviewSeconds - } - return &dto.ContentAssetsResponse{ - Content: detail.Content, - Assets: playables, - PreviewSeconds: previewSeconds, - }, nil -} - -// mainAssets -// -// @Summary 获取正片资源(main role,需要已购或免费) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} dto.ContentAssetsResponse -// -// @Router /t/:tenantCode/v1/contents/:contentID/assets [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind contentID path -func (*content) mainAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentAssetsResponse, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - "content_id": contentID, - }).Info("tenant.contents.main_assets") - - detail, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID) - if err != nil { - return nil, err - } - - if !detail.HasAccess { - return nil, errorx.ErrPermissionDenied.WithMsg("未购买或无权限访问") - } - - assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRoleMain) - if err != nil { - return nil, err - } - - playables := make([]*dto.ContentPlayableAsset, 0, len(assets)) - for _, asset := range assets { - token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRoleMain, user.ID, 0, time.Now()) - if err != nil { - return nil, err - } - var meta json.RawMessage - if len(asset.Meta) > 0 { - meta = json.RawMessage(asset.Meta) - } - playables = append(playables, &dto.ContentPlayableAsset{ - AssetID: asset.ID, - Type: asset.Type, - PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token), - ExpiresAt: expiresAt, - Meta: meta, - }) - } - - return &dto.ContentAssetsResponse{ - Content: detail.Content, - Assets: playables, - }, nil -} diff --git a/backend/app/http/tenant/content_admin.go b/backend/app/http/tenant/content_admin.go deleted file mode 100644 index f87c98a..0000000 --- a/backend/app/http/tenant/content_admin.go +++ /dev/null @@ -1,236 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// contentAdmin provides tenant-admin content management endpoints. -// -// @provider -type contentAdmin struct{} - -func requireTenantAdmin(tenantUser *models.TenantUser) error { - if tenantUser == nil { - return errorx.ErrPermissionDenied - } - if !tenantUser.Role.Contains(consts.TenantUserRoleTenantAdmin) { - return errorx.ErrPermissionDenied - } - return nil -} - -// list -// -// @Summary 内容列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminContentListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.AdminContentItem} -// -// @Router /t/:tenantCode/v1/management/contents [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*contentAdmin) list(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminContentListFilter) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminContentListFilter{} - } - filter.Pagination.Format() - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "query_user_id": filter.UserID, - "keyword": filter.KeywordTrimmed(), - "status": filter.Status, - "visibility": filter.Visibility, - "published_at_from": filter.PublishedAtFrom, - "published_at_to": filter.PublishedAtTo, - "created_at_from": filter.CreatedAtFrom, - "created_at_to": filter.CreatedAtTo, - }).Info("tenant.admin.contents.list") - - return services.Content.AdminContentPage(ctx.Context(), tenant.ID, filter) -} - -// create -// -// @Summary 创建内容(草稿) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.ContentCreateForm true "Form" -// @Success 200 {object} models.Content -// -// @Router /t/:tenantCode/v1/management/contents [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind form body -func (*contentAdmin) create(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, form *dto.ContentCreateForm) (*models.Content, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - }).Info("tenant.admin.contents.create") - - return services.Content.Create(ctx, tenant.ID, tenantUser.UserID, form) -} - -// publish -// -// @Summary 内容发布(创建+绑定资源+定价) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.ContentPublishForm true "Form" -// @Success 200 {object} dto.ContentPublishResponse -// -// @Router /t/:tenantCode/v1/management/contents/publish [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind form body -func (*contentAdmin) publish(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, form *dto.ContentPublishForm) (*dto.ContentPublishResponse, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - }).Info("tenant.admin.contents.publish") - - res, err := services.Content.Publish(ctx.Context(), tenant.ID, tenantUser.UserID, form) - if err != nil { - return nil, err - } - - return &dto.ContentPublishResponse{ - Content: res.Content, - Price: res.Price, - CoverAssets: res.CoverAssets, - MainAssets: res.MainAssets, - ContentTypes: res.ContentTypes, - }, nil -} - -// update -// -// @Summary 更新内容(标题/描述/状态等) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Param form body dto.ContentUpdateForm true "Form" -// @Success 200 {object} models.Content -// -// @Router /t/:tenantCode/v1/management/contents/:contentID [patch] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind contentID path -// @Bind form body -func (*contentAdmin) update(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "content_id": contentID, - }).Info("tenant.admin.contents.update") - - return services.Content.Update(ctx, tenant.ID, tenantUser.UserID, contentID, form) -} - -// upsertPrice -// -// @Summary 设置内容价格与折扣 -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Param form body dto.ContentPriceUpsertForm true "Form" -// @Success 200 {object} models.ContentPrice -// -// @Router /t/:tenantCode/v1/management/contents/:contentID/price [put] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind contentID path -// @Bind form body -func (*contentAdmin) upsertPrice(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentPriceUpsertForm) (*models.ContentPrice, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "content_id": contentID, - }).Info("tenant.admin.contents.upsert_price") - - return services.Content.UpsertPrice(ctx, tenant.ID, tenantUser.UserID, contentID, form) -} - -// attachAsset -// -// @Summary 绑定媒体资源到内容(main/cover/preview) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Param form body dto.ContentAssetAttachForm true "Form" -// @Success 200 {object} models.ContentAsset -// -// @Router /t/:tenantCode/v1/management/contents/:contentID/assets [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind contentID path -// @Bind form body -func (*contentAdmin) attachAsset(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentAssetAttachForm) (*models.ContentAsset, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "content_id": contentID, - "asset_id": form.AssetID, - "role": form.Role, - }).Info("tenant.admin.contents.attach_asset") - - role := form.Role - if role == "" { - role = consts.ContentAssetRoleMain - } - - sort := int32(0) - if form.Sort > 0 { - sort = form.Sort - } - - return services.Content.AttachAsset(ctx, tenant.ID, tenantUser.UserID, contentID, form.AssetID, role, sort, time.Now()) -} diff --git a/backend/app/http/tenant/dto/content.go b/backend/app/http/tenant/dto/content.go deleted file mode 100644 index a55261d..0000000 --- a/backend/app/http/tenant/dto/content.go +++ /dev/null @@ -1,44 +0,0 @@ -package dto - -import ( - "quyun/v2/app/requests" - "quyun/v2/database/models" -) - -// ContentListFilter defines list query filters for published contents within a tenant. -type ContentListFilter struct { - // Pagination controls paging parameters (page/limit). - requests.Pagination `json:",inline" query:",inline"` - // Keyword filters by title keyword (LIKE). - Keyword *string `json:"keyword,omitempty" query:"keyword"` -} - -// ContentItem is a list item with price snapshot and access indicator for current user. -type ContentItem struct { - // Content is the content entity. - Content *models.Content `json:"content,omitempty"` - // Price is the current price settings for the content (may be nil if not set). - Price *models.ContentPrice `json:"price,omitempty"` - // HasAccess indicates whether current user can access main assets (free/owner/purchased). - HasAccess bool `json:"has_access"` -} - -// ContentDetail is the detail payload with price snapshot and access indicator for current user. -type ContentDetail struct { - // Content is the content entity. - Content *models.Content `json:"content,omitempty"` - // Price is the current price settings for the content (may be nil if not set). - Price *models.ContentPrice `json:"price,omitempty"` - // HasAccess indicates whether current user can access main assets (free/owner/purchased). - HasAccess bool `json:"has_access"` -} - -// ContentAssetsResponse returns assets for either preview or main role. -type ContentAssetsResponse struct { - // Content is the content entity. - Content *models.Content `json:"content,omitempty"` - // Assets is the list of playable assets for the requested role (preview/main). - Assets []*ContentPlayableAsset `json:"assets,omitempty"` - // PreviewSeconds indicates the max preview duration (only meaningful for preview response). - PreviewSeconds int32 `json:"preview_seconds,omitempty"` -} diff --git a/backend/app/http/tenant/dto/content_admin.go b/backend/app/http/tenant/dto/content_admin.go deleted file mode 100644 index 10793d7..0000000 --- a/backend/app/http/tenant/dto/content_admin.go +++ /dev/null @@ -1,59 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/pkg/consts" -) - -// ContentCreateForm defines payload for tenant-admin to create a new content draft. -type ContentCreateForm struct { - // Title is the content title. - Title string `json:"title,omitempty"` - // Description is the content description. - Description string `json:"description,omitempty"` - // Visibility controls who can view the content detail (main assets still require free/purchase). - Visibility consts.ContentVisibility `json:"visibility,omitempty"` - // PreviewSeconds controls preview duration (defaults to 60 when omitted). - PreviewSeconds *int32 `json:"preview_seconds,omitempty"` -} - -// ContentUpdateForm updates mutable fields of a content. -type ContentUpdateForm struct { - // Title updates the title when provided. - Title *string `json:"title,omitempty"` - // Description updates the description when provided. - Description *string `json:"description,omitempty"` - // Visibility updates the visibility when provided. - Visibility *consts.ContentVisibility `json:"visibility,omitempty"` - // Status updates the content status when provided (e.g. publish/unpublish). - Status *consts.ContentStatus `json:"status,omitempty"` - // PreviewSeconds updates preview duration when provided (must be > 0). - PreviewSeconds *int32 `json:"preview_seconds,omitempty"` -} - -// ContentPriceUpsertForm upserts pricing and discount settings for a content. -type ContentPriceUpsertForm struct { - // PriceAmount is the base price in cents (CNY 分). - PriceAmount int64 `json:"price_amount,omitempty"` - // Currency is fixed to CNY for now. - Currency consts.Currency `json:"currency,omitempty"` - // DiscountType defines the discount algorithm (none/percent/amount). - DiscountType consts.DiscountType `json:"discount_type,omitempty"` - // DiscountValue is interpreted based on DiscountType. - DiscountValue int64 `json:"discount_value,omitempty"` - // DiscountStartAt enables discount from this time (optional). - DiscountStartAt *time.Time `json:"discount_start_at,omitempty"` - // DiscountEndAt disables discount after this time (optional). - DiscountEndAt *time.Time `json:"discount_end_at,omitempty"` -} - -// ContentAssetAttachForm attaches a media asset to a content with a role and sort order. -type ContentAssetAttachForm struct { - // AssetID is the media asset id to attach. - AssetID int64 `json:"asset_id,omitempty"` - // Role indicates how this asset is used (main/cover/preview). - Role consts.ContentAssetRole `json:"role,omitempty"` - // Sort controls ordering under the same role. - Sort int32 `json:"sort,omitempty"` -} diff --git a/backend/app/http/tenant/dto/content_admin_list.go b/backend/app/http/tenant/dto/content_admin_list.go deleted file mode 100644 index 283f715..0000000 --- a/backend/app/http/tenant/dto/content_admin_list.go +++ /dev/null @@ -1,56 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "go.ipao.vip/gen/types" -) - -// AdminContentListFilter 租户管理员查询“内容列表(含草稿/已发布/已下架等)”的过滤条件。 -type AdminContentListFilter struct { - requests.Pagination `json:",inline" query:",inline"` - requests.SortQueryFilter `json:",inline" query:",inline"` - - ID *int64 `json:"id,omitempty" query:"id"` - - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - - Keyword *string `json:"keyword,omitempty" query:"keyword"` - - Status *consts.ContentStatus `json:"status,omitempty" query:"status"` - Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"` - - PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"` - PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_at_to"` - - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -func (f *AdminContentListFilter) KeywordTrimmed() string { - if f == nil || f.Keyword == nil { - return "" - } - return strings.TrimSpace(*f.Keyword) -} - -type AdminContentOwnerLite struct { - ID int64 `json:"id"` - Username string `json:"username"` - Status consts.UserStatus `json:"status"` - Roles types.Array[consts.Role] `json:"roles"` -} - -type AdminContentItem struct { - Content *models.Content `json:"content,omitempty"` - Price *models.ContentPrice `json:"price,omitempty"` - Owner *AdminContentOwnerLite `json:"owner,omitempty"` - - StatusDescription string `json:"status_description,omitempty"` - VisibilityDescription string `json:"visibility_description,omitempty"` -} diff --git a/backend/app/http/tenant/dto/content_admin_publish.go b/backend/app/http/tenant/dto/content_admin_publish.go deleted file mode 100644 index e71b09a..0000000 --- a/backend/app/http/tenant/dto/content_admin_publish.go +++ /dev/null @@ -1,55 +0,0 @@ -package dto - -import ( - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// ContentPublishForm 租户管理员提交“内容发布”表单(创建内容 + 绑定资源 + 定价)。 -// 说明: -// - 内容类型支持组合:文字/音频/视频/多图可同时存在; -// - 文字内容通过 Detail 是否为空来判断; -// - 音频/视频/多图通过对应资源列表是否为空来判断(资源需为 ready 且属于当前租户)。 -type ContentPublishForm struct { - // Title 标题:用于列表展示与搜索;必填。 - Title string `json:"title,omitempty"` - // Summary 简介:用于列表/卡片展示的短文本;可选,建议 <= 256 字符。 - Summary string `json:"summary,omitempty"` - // Detail 详细:用于详情页的长文本;可选;当非空时视为“文字内容”类型存在。 - Detail string `json:"detail,omitempty"` - // Tags 标签:用于分类/检索;字符串数组;会做 trim/去重;可为空。 - Tags []string `json:"tags,omitempty"` - - // CoverAssetIDs 展示图(封面图)资源 ID 列表:1-3 张;每个资源必须为 image/main/ready。 - CoverAssetIDs []int64 `json:"cover_asset_ids,omitempty"` - // AudioAssetIDs 音频资源 ID 列表:可为空;每个资源必须为 audio/main/ready。 - AudioAssetIDs []int64 `json:"audio_asset_ids,omitempty"` - // VideoAssetIDs 视频资源 ID 列表:可为空;每个资源必须为 video/main/ready。 - VideoAssetIDs []int64 `json:"video_asset_ids,omitempty"` - // ImageAssetIDs 多图内容资源 ID 列表:可为空;每个资源必须为 image/main/ready;数量 >= 2 时视为“多图内容”类型存在。 - ImageAssetIDs []int64 `json:"image_asset_ids,omitempty"` - - // PriceAmount 价格:单位为分;0 表示免费;必填(前端可默认填 0)。 - PriceAmount int64 `json:"price_amount,omitempty"` - // Currency 币种:当前固定为 CNY;可不传(后端默认 CNY)。 - Currency consts.Currency `json:"currency,omitempty"` - - // Visibility 可见性:控制“详情页”可见范围;默认 tenant_only。 - Visibility consts.ContentVisibility `json:"visibility,omitempty"` - // PreviewSeconds 试看秒数:仅对 preview 资源生效;默认 60;必须为正整数。 - PreviewSeconds *int32 `json:"preview_seconds,omitempty"` -} - -// ContentPublishResponse 内容发布结果(便于前端一次性拿到核心信息)。 -type ContentPublishResponse struct { - // Content 内容主体(包含标题/简介/详细/状态等)。 - Content *models.Content `json:"content"` - // Price 定价信息(单位分)。 - Price *models.ContentPrice `json:"price"` - // CoverAssets 封面图绑定结果(role=cover)。 - CoverAssets []*models.ContentAsset `json:"cover_assets,omitempty"` - // MainAssets 主资源绑定结果(role=main;可能包含音频/视频/图片)。 - MainAssets []*models.ContentAsset `json:"main_assets,omitempty"` - // ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 - ContentTypes []string `json:"content_types,omitempty"` -} diff --git a/backend/app/http/tenant/dto/content_asset_play.go b/backend/app/http/tenant/dto/content_asset_play.go deleted file mode 100644 index 9001f6e..0000000 --- a/backend/app/http/tenant/dto/content_asset_play.go +++ /dev/null @@ -1,23 +0,0 @@ -package dto - -import ( - "encoding/json" - "time" - - "quyun/v2/pkg/consts" -) - -// ContentPlayableAsset is a deliverable media asset item with short-lived play URL/token. -type ContentPlayableAsset struct { - AssetID int64 `json:"asset_id"` - Type consts.MediaAssetType `json:"type"` - - // PlayURL is a short-lived URL; do NOT expose bucket/object_key directly. - PlayURL string `json:"play_url"` - - // ExpiresAt indicates when PlayURL/token expires; optional. - ExpiresAt *time.Time `json:"expires_at,omitempty"` - - // Meta is a display-safe whitelist (currently passthrough JSON); optional. - Meta json.RawMessage `json:"meta,omitempty"` -} diff --git a/backend/app/http/tenant/dto/ledger_admin.go b/backend/app/http/tenant/dto/ledger_admin.go deleted file mode 100644 index fa7328f..0000000 --- a/backend/app/http/tenant/dto/ledger_admin.go +++ /dev/null @@ -1,53 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// AdminLedgerListFilter 定义“租户后台余额流水”查询条件。 -// -// 设计目标: -// - 用于审计/对账:可以按操作者(operator_user_id)检索敏感操作流水; -// - 也可以按用户、订单、类型、业务引用快速定位流水集合。 -type AdminLedgerListFilter struct { - // Pagination 分页参数(page/limit)。 - requests.Pagination `json:",inline" query:",inline"` - - // OperatorUserID 按操作者用户ID过滤(可选)。 - // 典型场景:后台检索“某个管理员发起的退款/调账”等敏感操作流水。 - OperatorUserID *int64 `json:"operator_user_id,omitempty" query:"operator_user_id"` - - // UserID 按余额账户归属用户ID过滤(可选)。 - // 典型场景:查看某个租户成员的资金变化全链路。 - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - - // Type 按流水类型过滤(可选)。 - Type *consts.TenantLedgerType `json:"type,omitempty" query:"type"` - - // OrderID 按关联订单过滤(可选)。 - OrderID *int64 `json:"order_id,omitempty" query:"order_id"` - - // BizRefType 按业务引用类型过滤(可选)。 - // 约定:当前业务写入为 "order";未来可扩展为 refund 等。 - BizRefType *string `json:"biz_ref_type,omitempty" query:"biz_ref_type"` - - // BizRefID 按业务引用ID过滤(可选)。 - BizRefID *int64 `json:"biz_ref_id,omitempty" query:"biz_ref_id"` - - // CreatedAtFrom 创建时间起(可选)。 - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - // CreatedAtTo 创建时间止(可选)。 - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -// AdminLedgerItem 返回一条余额流水(租户后台视角),并补充展示字段。 -type AdminLedgerItem struct { - // Ledger 流水记录(租户内隔离)。 - Ledger *models.TenantLedger `json:"ledger"` - // TypeDescription 流水类型中文说明(用于前端展示)。 - TypeDescription string `json:"type_description"` -} diff --git a/backend/app/http/tenant/dto/ledger_me.go b/backend/app/http/tenant/dto/ledger_me.go deleted file mode 100644 index 777de17..0000000 --- a/backend/app/http/tenant/dto/ledger_me.go +++ /dev/null @@ -1,31 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// MyLedgerListFilter 定义“我的余额流水”查询条件。 -type MyLedgerListFilter struct { - // Pagination 分页参数(page/limit)。 - requests.Pagination `json:",inline" query:",inline"` - // Type 按流水类型过滤(可选)。 - Type *consts.TenantLedgerType `json:"type,omitempty" query:"type"` - // OrderID 按关联订单过滤(可选)。 - OrderID *int64 `json:"order_id,omitempty" query:"order_id"` - // CreatedAtFrom 创建时间起(可选)。 - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - // CreatedAtTo 创建时间止(可选)。 - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} - -// MyLedgerItem 返回一条余额流水,并补充展示字段。 -type MyLedgerItem struct { - // Ledger 流水记录(租户内隔离)。 - Ledger *models.TenantLedger `json:"ledger"` - // TypeDescription 流水类型中文说明(用于前端展示)。 - TypeDescription string `json:"type_description"` -} diff --git a/backend/app/http/tenant/dto/me.go b/backend/app/http/tenant/dto/me.go deleted file mode 100644 index b849a97..0000000 --- a/backend/app/http/tenant/dto/me.go +++ /dev/null @@ -1,13 +0,0 @@ -package dto - -import "quyun/v2/database/models" - -// MeResponse returns the resolved tenant context for the current request. -type MeResponse struct { - // Tenant is the resolved tenant by `tenantCode`. - Tenant *models.Tenant `json:"tenant,omitempty"` - // User is the authenticated user derived from JWT `user_id`. - User *models.User `json:"user,omitempty"` - // TenantUser is the membership record of the authenticated user within the tenant. - TenantUser *models.TenantUser `json:"tenant_user,omitempty"` -} diff --git a/backend/app/http/tenant/dto/me_balance.go b/backend/app/http/tenant/dto/me_balance.go deleted file mode 100644 index f1012fd..0000000 --- a/backend/app/http/tenant/dto/me_balance.go +++ /dev/null @@ -1,19 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/pkg/consts" -) - -// MeBalanceResponse 返回当前用户在当前租户下的余额信息(租户内隔离)。 -type MeBalanceResponse struct { - // Currency 币种:当前固定 CNY(金额单位为分)。 - Currency consts.Currency `json:"currency"` - // Balance 可用余额:可用于购买/消费。 - Balance int64 `json:"balance"` - // BalanceFrozen 冻结余额:用于下单冻结/争议期等。 - BalanceFrozen int64 `json:"balance_frozen"` - // UpdatedAt 更新时间:余额变更时更新。 - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/backend/app/http/tenant/dto/media_asset_admin.go b/backend/app/http/tenant/dto/media_asset_admin.go deleted file mode 100644 index ae79938..0000000 --- a/backend/app/http/tenant/dto/media_asset_admin.go +++ /dev/null @@ -1,69 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/pkg/consts" -) - -// AdminMediaAssetUploadInitForm defines payload for tenant-admin to initialize a media asset upload. -type AdminMediaAssetUploadInitForm struct { - // Type is the media asset type (video/audio/image). - // Used to decide processing pipeline and validation rules; required. - Type string `json:"type,omitempty"` - - // Variant indicates whether this asset is a main or preview product. - // Allowed: main/preview; default is main. - Variant *consts.MediaAssetVariant `json:"variant,omitempty"` - - // SourceAssetID links a preview product to its main asset; only meaningful when variant=preview. - SourceAssetID *int64 `json:"source_asset_id,omitempty"` - - // ContentType is the MIME type reported by the client (e.g. video/mp4); optional. - // Server should not fully trust it, but can use it as a hint for validation/logging. - ContentType string `json:"content_type,omitempty"` - // FileSize is the expected file size in bytes; optional. - // Used for quota/limit checks and audit; client may omit when unknown. - FileSize int64 `json:"file_size,omitempty"` - // SHA256 is the hex-encoded sha256 of the file; optional. - // Used for deduplication/audit; server may validate it later during upload-complete. - SHA256 string `json:"sha256,omitempty"` -} - -// AdminMediaAssetUploadInitResponse returns server-generated upload parameters and the created asset id. -type AdminMediaAssetUploadInitResponse struct { - // AssetID is the created media asset id. - AssetID int64 `json:"asset_id"` - // Provider is the storage provider identifier (e.g. s3/minio/oss/local); for debugging/audit. - Provider string `json:"provider,omitempty"` - // Bucket is the target bucket/container; for debugging/audit (may be empty in stub mode). - Bucket string `json:"bucket,omitempty"` - // ObjectKey is the server-generated object key/path; client must NOT choose it. - ObjectKey string `json:"object_key,omitempty"` - - // UploadURL is the URL the client should upload to (signed URL or service endpoint). - UploadURL string `json:"upload_url,omitempty"` - // Headers are additional headers required for upload (e.g. signed headers); optional. - Headers map[string]string `json:"headers,omitempty"` - // FormFields are form fields required for multipart form upload (S3 POST policy); optional. - FormFields map[string]string `json:"form_fields,omitempty"` - // ExpiresAt indicates when UploadURL/FormFields expire; optional. - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -// AdminMediaAssetUploadCompleteForm defines payload for tenant-admin to mark a media upload as completed. -// This endpoint is expected to be called after the client finishes uploading the object to storage. -type AdminMediaAssetUploadCompleteForm struct { - // ETag is the storage returned ETag (or similar checksum); optional. - // Used for audit/debugging and later integrity verification. - ETag string `json:"etag,omitempty"` - // ContentType is the MIME type observed during upload; optional. - // Server may record it for audit and later processing decisions. - ContentType string `json:"content_type,omitempty"` - // FileSize is the uploaded object size in bytes; optional. - // Server records it for quota/audit and later validation. - FileSize int64 `json:"file_size,omitempty"` - // SHA256 is the hex-encoded sha256 of the uploaded object; optional. - // Server records it for integrity checks/deduplication. - SHA256 string `json:"sha256,omitempty"` -} diff --git a/backend/app/http/tenant/dto/media_asset_admin_list.go b/backend/app/http/tenant/dto/media_asset_admin_list.go deleted file mode 100644 index 7033e84..0000000 --- a/backend/app/http/tenant/dto/media_asset_admin_list.go +++ /dev/null @@ -1,29 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" -) - -// AdminMediaAssetListFilter defines tenant-admin list query filters for media assets. -type AdminMediaAssetListFilter struct { - // Pagination defines page/limit; page is 1-based, limit uses the global whitelist. - requests.Pagination `json:",inline" query:",inline"` - - // SortQueryFilter defines asc/desc ordering; service layer applies a whitelist. - requests.SortQueryFilter `json:",inline" query:",inline"` - - // Type filters by media type (video/audio/image); optional. - Type *consts.MediaAssetType `json:"type,omitempty" query:"type"` - - // Status filters by processing status (uploaded/processing/ready/failed/deleted); optional. - Status *consts.MediaAssetStatus `json:"status,omitempty" query:"status"` - - // CreatedAtFrom filters assets by created_at >= this time; optional. - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - - // CreatedAtTo filters assets by created_at <= this time; optional. - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` -} diff --git a/backend/app/http/tenant/dto/order.go b/backend/app/http/tenant/dto/order.go deleted file mode 100644 index a9115a3..0000000 --- a/backend/app/http/tenant/dto/order.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import "quyun/v2/database/models" - -// PurchaseContentForm defines the request body for purchasing a content using tenant balance. -type PurchaseContentForm struct { - // IdempotencyKey is used to ensure the purchase request is processed at most once. - // 建议由客户端生成并保持稳定:同一笔购买重复请求时返回相同结果,避免重复扣款/重复下单。 - IdempotencyKey string `json:"idempotency_key,omitempty"` -} - -// PurchaseContentResponse returns the order and granted access after a purchase. -type PurchaseContentResponse struct { - // Order is the created or existing order record (may be nil for owner/free-path without order). - Order *models.Order `json:"order,omitempty"` - // Item is the single order item of this purchase (current implementation is 1 order -> 1 content). - Item *models.OrderItem `json:"item,omitempty"` - // Access is the content access record after purchase grant. - Access *models.ContentAccess `json:"access,omitempty"` - // AmountPaid is the final paid amount in cents (CNY 分). - AmountPaid int64 `json:"amount_paid,omitempty"` -} diff --git a/backend/app/http/tenant/dto/order_admin.go b/backend/app/http/tenant/dto/order_admin.go deleted file mode 100644 index 9d857df..0000000 --- a/backend/app/http/tenant/dto/order_admin.go +++ /dev/null @@ -1,90 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// AdminOrderListFilter 租户管理员分页查询订单的过滤条件。 -type AdminOrderListFilter struct { - // Pagination 分页参数:page/limit(通用)。 - requests.Pagination `json:",inline" query:",inline"` - - // SortQueryFilter 排序参数:asc/desc(逗号分隔字段名);字段白名单在 service 层统一校验。 - requests.SortQueryFilter `json:",inline" query:",inline"` - - // UserID 下单用户ID(可选):按买家用户ID精确过滤。 - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - - // Username 下单用户用户名关键字(可选):模糊匹配 users.username(like)。 - Username *string `json:"username,omitempty" query:"username"` - - // ContentID 内容ID(可选):通过 order_items 关联过滤。 - ContentID *int64 `json:"content_id,omitempty" query:"content_id"` - - // ContentTitle 内容标题关键字(可选):通过 order_items + contents 关联,模糊匹配 contents.title(like)。 - ContentTitle *string `json:"content_title,omitempty" query:"content_title"` - - // Type 订单类型(可选):content_purchase 等。 - Type *consts.OrderType `json:"type,omitempty" query:"type"` - - // Status 订单状态(可选):created/paid/refunding/refunded/canceled/failed。 - Status *consts.OrderStatus `json:"status,omitempty" query:"status"` - - // CreatedAtFrom 创建时间起(可选):created_at >= 该时间(用于按创建时间筛选)。 - CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` - - // CreatedAtTo 创建时间止(可选):created_at <= 该时间(用于按创建时间筛选)。 - CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` - - // PaidAtFrom 支付时间起(可选):paid_at >= 该时间(用于按支付时间筛选)。 - PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"` - - // PaidAtTo 支付时间止(可选):paid_at <= 该时间(用于按支付时间筛选)。 - PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"` - - // AmountPaidMin 实付金额下限(可选):amount_paid >= 该值(单位分)。 - AmountPaidMin *int64 `json:"amount_paid_min,omitempty" query:"amount_paid_min"` - - // AmountPaidMax 实付金额上限(可选):amount_paid <= 该值(单位分)。 - AmountPaidMax *int64 `json:"amount_paid_max,omitempty" query:"amount_paid_max"` -} - -// UsernameTrimmed 对 username 做统一处理,避免空白与大小写差异导致查询不一致。 -func (f *AdminOrderListFilter) UsernameTrimmed() string { - if f == nil || f.Username == nil { - return "" - } - return strings.TrimSpace(*f.Username) -} - -// ContentTitleTrimmed 对 content_title 做统一处理,避免空白与大小写差异导致查询不一致。 -func (f *AdminOrderListFilter) ContentTitleTrimmed() string { - if f == nil || f.ContentTitle == nil { - return "" - } - return strings.TrimSpace(*f.ContentTitle) -} - -// AdminOrderRefundForm 租户管理员退款的请求参数。 -type AdminOrderRefundForm struct { - // Force indicates bypassing the default refund window check (paid_at + 24h). - // 强制退款:true 表示绕过默认退款时间窗限制(需审计)。 - Force bool `json:"force,omitempty"` - // Reason is the human-readable refund reason used for audit. - // 退款原因:建议必填(由业务侧校验);用于审计与追责。 - Reason string `json:"reason,omitempty"` - // IdempotencyKey ensures refund request is processed at most once. - // 幂等键:同一笔退款重复请求时返回一致结果,避免重复退款/重复回滚。 - IdempotencyKey string `json:"idempotency_key,omitempty"` -} - -// AdminOrderDetail 租户管理员订单详情返回结构。 -type AdminOrderDetail struct { - // Order is the order with items preloaded. - Order *models.Order `json:"order,omitempty"` -} diff --git a/backend/app/http/tenant/dto/order_admin_export.go b/backend/app/http/tenant/dto/order_admin_export.go deleted file mode 100644 index fc035f7..0000000 --- a/backend/app/http/tenant/dto/order_admin_export.go +++ /dev/null @@ -1,13 +0,0 @@ -package dto - -// AdminOrderExportResponse 租户管理员订单导出响应(CSV 文本)。 -type AdminOrderExportResponse struct { - // Filename 建议文件名:前端可用于下载时的默认文件名。 - Filename string `json:"filename"` - - // ContentType 内容类型:当前固定为 text/csv。 - ContentType string `json:"content_type"` - - // CSV CSV 文本内容:UTF-8 编码,包含表头与数据行;前端可直接下载为文件。 - CSV string `json:"csv"` -} diff --git a/backend/app/http/tenant/dto/order_me.go b/backend/app/http/tenant/dto/order_me.go deleted file mode 100644 index 2188bbd..0000000 --- a/backend/app/http/tenant/dto/order_me.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" -) - -// MyOrderListFilter defines query filters for listing current user's orders within a tenant. -type MyOrderListFilter struct { - // Pagination controls paging parameters (page/limit). - requests.Pagination `json:",inline" query:",inline"` - // Status filters orders by order status. - Status *consts.OrderStatus `json:"status,omitempty" query:"status"` - // PaidAtFrom filters orders by paid_at >= this time. - PaidAtFrom *time.Time `json:"paid_at_from,omitempty" query:"paid_at_from"` - // PaidAtTo filters orders by paid_at <= this time. - PaidAtTo *time.Time `json:"paid_at_to,omitempty" query:"paid_at_to"` - // ContentID filters orders by purchased content id (via order_items join). - ContentID *int64 `json:"content_id,omitempty" query:"content_id"` -} diff --git a/backend/app/http/tenant/dto/tenant_join_admin.go b/backend/app/http/tenant/dto/tenant_join_admin.go deleted file mode 100644 index c04fe35..0000000 --- a/backend/app/http/tenant/dto/tenant_join_admin.go +++ /dev/null @@ -1,66 +0,0 @@ -package dto - -import ( - "strings" - "time" - - "quyun/v2/app/requests" - "quyun/v2/pkg/consts" -) - -// AdminTenantInviteCreateForm 租户管理员创建邀请码的请求参数。 -type AdminTenantInviteCreateForm struct { - // Code 邀请码(可选):为空时由后端生成;建议只包含数字/字母,便于人工输入。 - Code string `json:"code"` - - // MaxUses 最大可使用次数(可选):0 表示不限次数;大于 0 时用尽后自动失效。 - MaxUses *int `json:"max_uses"` - - // ExpiresAt 过期时间(可选):为空表示不过期;到期后不可再使用。 - ExpiresAt *time.Time `json:"expires_at"` - - // Remark 备注(可选):用于审计记录生成目的/投放渠道等。 - Remark string `json:"remark"` -} - -// AdminTenantInviteDisableForm 租户管理员禁用邀请码的请求参数。 -type AdminTenantInviteDisableForm struct { - // Reason 禁用原因(可选):用于审计与追溯。 - Reason string `json:"reason"` -} - -// AdminTenantInviteListFilter 租户管理员分页查询邀请码列表的过滤条件。 -type AdminTenantInviteListFilter struct { - requests.Pagination - - // Status 按状态过滤(可选):active/disabled/expired。 - Status *consts.TenantInviteStatus `query:"status" json:"status"` - - // Code 按邀请码模糊过滤(可选):支持部分匹配(like)。 - Code *string `query:"code" json:"code"` -} - -// CodeTrimmed 对 code 进行空白与大小写处理,便于统一查询。 -func (f *AdminTenantInviteListFilter) CodeTrimmed() string { - if f == nil || f.Code == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(*f.Code)) -} - -// AdminTenantJoinRequestListFilter 租户管理员分页查询加入申请的过滤条件。 -type AdminTenantJoinRequestListFilter struct { - requests.Pagination - - // UserID 按申请人用户ID过滤(可选)。 - UserID *int64 `query:"user_id" json:"user_id"` - - // Status 按申请状态过滤(可选):pending/approved/rejected。 - Status *consts.TenantJoinRequestStatus `query:"status" json:"status"` -} - -// AdminTenantJoinRequestDecideForm 租户管理员通过/拒绝加入申请的请求参数。 -type AdminTenantJoinRequestDecideForm struct { - // Reason 审核说明(可选):用于审计记录通过/拒绝原因。 - Reason string `json:"reason"` -} diff --git a/backend/app/http/tenant/dto/tenant_user_admin.go b/backend/app/http/tenant/dto/tenant_user_admin.go deleted file mode 100644 index b380d64..0000000 --- a/backend/app/http/tenant/dto/tenant_user_admin.go +++ /dev/null @@ -1,50 +0,0 @@ -package dto - -import ( - "strings" - - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" -) - -// AdminTenantUserJoinResponse 返回租户管理员添加成员后的结果。 -type AdminTenantUserJoinResponse struct { - // TenantUser 租户成员关系记录。 - TenantUser *models.TenantUser `json:"tenant_user,omitempty"` -} - -// AdminTenantUserRoleUpdateForm 租户成员角色更新表单。 -type AdminTenantUserRoleUpdateForm struct { - // Role 角色:member/tenant_admin。 - Role string `json:"role,omitempty"` -} - -// AdminTenantUserListFilter 租户管理员查询成员列表的过滤条件。 -type AdminTenantUserListFilter struct { - // Pagination 分页参数(page/limit)。 - requests.Pagination `json:",inline" query:",inline"` - // UserID 按用户ID过滤(可选)。 - UserID *int64 `json:"user_id,omitempty" query:"user_id"` - // Role 按角色过滤(可选):member/tenant_admin。 - Role *consts.TenantUserRole `json:"role,omitempty" query:"role"` - // Status 按成员状态过滤(可选):pending_verify/verified/banned。 - Status *consts.UserStatus `json:"status,omitempty" query:"status"` - // Username 按用户名模糊查询(可选,支持包含匹配)。 - Username *string `json:"username,omitempty" query:"username"` -} - -// AdminTenantUserItem 为租户成员列表项(包含成员关系与用户基础信息)。 -type AdminTenantUserItem struct { - // TenantUser 租户成员关系记录。 - TenantUser *models.TenantUser `json:"tenant_user,omitempty"` - // User 用户基础信息(用于展示 username 等)。 - User *models.User `json:"user,omitempty"` -} - -func (f *AdminTenantUserListFilter) UsernameTrimmed() string { - if f == nil || f.Username == nil { - return "" - } - return strings.TrimSpace(*f.Username) -} diff --git a/backend/app/http/tenant/ledger_admin.go b/backend/app/http/tenant/ledger_admin.go deleted file mode 100644 index 6c9c7ef..0000000 --- a/backend/app/http/tenant/ledger_admin.go +++ /dev/null @@ -1,57 +0,0 @@ -package tenant - -import ( - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// ledgerAdmin provides tenant-admin ledger audit endpoints. -// -// @provider -type ledgerAdmin struct{} - -// adminLedgers -// -// @Summary 余额流水列表(租户管理/审计) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminLedgerListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.AdminLedgerItem} -// -// @Router /t/:tenantCode/v1/management/ledgers [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*ledgerAdmin) adminLedgers( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminLedgerListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminLedgerListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "operator_user_id": filter.OperatorUserID, - "target_user_id": filter.UserID, - "type": filter.Type, - "order_id": filter.OrderID, - "biz_ref_type": filter.BizRefType, - "biz_ref_id": filter.BizRefID, - }).Info("tenant.admin.ledgers.list") - - return services.Ledger.AdminLedgerPage(ctx.Context(), tenant.ID, filter) -} diff --git a/backend/app/http/tenant/me.go b/backend/app/http/tenant/me.go deleted file mode 100644 index 0cf8afb..0000000 --- a/backend/app/http/tenant/me.go +++ /dev/null @@ -1,79 +0,0 @@ -package tenant - -import ( - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" -) - -// me provides tenant context introspection endpoints (current tenant/user/tenant_user). -// -// @provider -type me struct{} - -// get -// -// @Summary 当前租户上下文信息 -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Success 200 {object} dto.MeResponse -// -// @Router /t/:tenantCode/v1/me [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind tenantUser local key(tenant_user) -func (*me) get(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, tenantUser *models.TenantUser) (*dto.MeResponse, error) { - return &dto.MeResponse{ - Tenant: tenant, - User: user, - TenantUser: tenantUser, - }, nil -} - -// balance -// -// @Summary 当前租户余额信息 -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Success 200 {object} dto.MeBalanceResponse -// -// @Router /t/:tenantCode/v1/me/balance [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -func (*me) balance(ctx fiber.Ctx, tenant *models.Tenant, user *models.User) (*dto.MeBalanceResponse, error) { - m, err := services.Ledger.MyBalance(ctx.Context(), tenant.ID, user.ID) - if err != nil { - return nil, err - } - return &dto.MeBalanceResponse{ - Currency: consts.CurrencyCNY, - Balance: m.Balance, - BalanceFrozen: m.BalanceFrozen, - UpdatedAt: m.UpdatedAt, - }, nil -} - -// ledgers -// -// @Summary 当前租户余额流水(分页) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Success 200 {object} requests.Pager{items=dto.MyLedgerItem} -// -// @Router /t/:tenantCode/v1/me/ledgers [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind filter query -func (*me) ledgers(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.MyLedgerListFilter) (*requests.Pager, error) { - return services.Ledger.MyLedgerPage(ctx.Context(), tenant.ID, user.ID, filter) -} diff --git a/backend/app/http/tenant/media_asset_admin.go b/backend/app/http/tenant/media_asset_admin.go deleted file mode 100644 index 13113df..0000000 --- a/backend/app/http/tenant/media_asset_admin.go +++ /dev/null @@ -1,194 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// mediaAssetAdmin provides tenant-admin media asset endpoints. -// -// @provider -type mediaAssetAdmin struct{} - -// adminList -// -// @Summary 媒体资源列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminMediaAssetListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.MediaAsset} -// -// @Router /t/:tenantCode/v1/management/media_assets [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*mediaAssetAdmin) adminList( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminMediaAssetListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminMediaAssetListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "type": filter.Type, - "status": filter.Status, - }).Info("tenant.admin.media_assets.list") - - return services.MediaAsset.AdminPage(ctx.Context(), tenant.ID, filter) -} - -// adminDetail -// -// @Summary 媒体资源详情(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param assetID path int64 true "AssetID" -// @Success 200 {object} models.MediaAsset -// -// @Router /t/:tenantCode/v1/management/media_assets/:assetID [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind assetID path -func (*mediaAssetAdmin) adminDetail( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - assetID int64, -) (*models.MediaAsset, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "asset_id": assetID, - }).Info("tenant.admin.media_assets.detail") - - return services.MediaAsset.AdminDetail(ctx.Context(), tenant.ID, assetID) -} - -// uploadInit -// -// @Summary 初始化媒体资源上传(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.AdminMediaAssetUploadInitForm true "Form" -// @Success 200 {object} dto.AdminMediaAssetUploadInitResponse -// -// @Router /t/:tenantCode/v1/management/media_assets/upload_init [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind form body -func (*mediaAssetAdmin) uploadInit( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - form *dto.AdminMediaAssetUploadInitForm, -) (*dto.AdminMediaAssetUploadInitResponse, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "type": form.Type, - }).Info("tenant.admin.media_assets.upload_init") - - return services.MediaAsset.AdminUploadInit(ctx.Context(), tenant.ID, tenantUser.UserID, form, time.Now()) -} - -// uploadComplete -// -// @Summary 确认上传完成并进入处理(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param assetID path int64 true "AssetID" -// @Param form body dto.AdminMediaAssetUploadCompleteForm false "Form" -// @Success 200 {object} models.MediaAsset -// -// @Router /t/:tenantCode/v1/management/media_assets/:assetID/upload_complete [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind assetID path -// @Bind form body -func (*mediaAssetAdmin) uploadComplete( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - assetID int64, - form *dto.AdminMediaAssetUploadCompleteForm, -) (*models.MediaAsset, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "asset_id": assetID, - }).Info("tenant.admin.media_assets.upload_complete") - - return services.MediaAsset.AdminUploadComplete(ctx.Context(), tenant.ID, tenantUser.UserID, assetID, form, time.Now()) -} - -// adminDelete -// -// @Summary 删除媒体资源(租户管理,软删) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param assetID path int64 true "AssetID" -// @Success 200 {object} models.MediaAsset -// -// @Router /t/:tenantCode/v1/management/media_assets/:assetID [delete] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind assetID path -func (*mediaAssetAdmin) adminDelete( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - assetID int64, -) (*models.MediaAsset, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "asset_id": assetID, - }).Info("tenant.admin.media_assets.delete") - - return services.MediaAsset.AdminDelete(ctx.Context(), tenant.ID, tenantUser.UserID, assetID, time.Now()) -} diff --git a/backend/app/http/tenant/order.go b/backend/app/http/tenant/order.go deleted file mode 100644 index f915b21..0000000 --- a/backend/app/http/tenant/order.go +++ /dev/null @@ -1,60 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// order provides tenant-side order endpoints for members (purchase and my orders). -// -// @provider -type order struct{} - -// purchaseContent -// -// @Summary 购买内容(余额支付) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Param form body dto.PurchaseContentForm true "Form" -// @Success 200 {object} dto.PurchaseContentResponse -// -// @Router /t/:tenantCode/v1/contents/:contentID/purchase [post] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind contentID path -// @Bind form body -func (*order) purchaseContent(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64, form *dto.PurchaseContentForm) (*dto.PurchaseContentResponse, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - "content_id": contentID, - "idempotency_key": form.IdempotencyKey, - }).Info("tenant.order.purchase_content") - - res, err := services.Order.PurchaseContent(ctx, &services.PurchaseContentParams{ - TenantID: tenant.ID, - UserID: user.ID, - ContentID: contentID, - IdempotencyKey: form.IdempotencyKey, - Now: time.Now(), - }) - if err != nil { - return nil, err - } - - return &dto.PurchaseContentResponse{ - Order: res.Order, - Item: res.OrderItem, - Access: res.Access, - AmountPaid: res.AmountPaid, - }, nil -} diff --git a/backend/app/http/tenant/order_admin.go b/backend/app/http/tenant/order_admin.go deleted file mode 100644 index 7ec3c4b..0000000 --- a/backend/app/http/tenant/order_admin.go +++ /dev/null @@ -1,191 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// orderAdmin provides tenant-admin order management endpoints. -// -// @provider -type orderAdmin struct{} - -// adminOrderList -// -// @Summary 订单列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminOrderListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.Order} -// -// @Router /t/:tenantCode/v1/management/orders [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*orderAdmin) adminOrderList( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminOrderListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminOrderListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "query_user_id": filter.UserID, - "username": filter.UsernameTrimmed(), - "content_id": filter.ContentID, - "content_title": filter.ContentTitleTrimmed(), - "type": filter.Type, - "status": filter.Status, - "created_at_from": filter.CreatedAtFrom, - "created_at_to": filter.CreatedAtTo, - "paid_at_from": filter.PaidAtFrom, - "paid_at_to": filter.PaidAtTo, - }).Info("tenant.admin.orders.list") - - return services.Order.AdminOrderPage(ctx, tenant.ID, filter) -} - -// adminOrderExport -// -// @Summary 订单导出(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminOrderListFilter true "Filter" -// @Success 200 {object} dto.AdminOrderExportResponse -// -// @Router /t/:tenantCode/v1/management/orders/export [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*orderAdmin) adminOrderExport( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminOrderListFilter, -) (*dto.AdminOrderExportResponse, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminOrderListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - }).Info("tenant.admin.orders.export") - - return services.Order.AdminOrderExportCSV(ctx.Context(), tenant.ID, filter) -} - -// adminOrderDetail -// -// @Summary 订单详情(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param orderID path int64 true "OrderID" -// @Success 200 {object} dto.AdminOrderDetail -// -// @Router /t/:tenantCode/v1/management/orders/:orderID [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind orderID path -func (*orderAdmin) adminOrderDetail( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - orderID int64, -) (*dto.AdminOrderDetail, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "order_id": orderID, - }).Info("tenant.admin.orders.detail") - - m, err := services.Order.AdminOrderDetail(ctx, tenant.ID, orderID) - if err != nil { - return nil, err - } - return &dto.AdminOrderDetail{Order: m}, nil -} - -// adminRefund -// -// @Summary 订单退款(租户管理) -// @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 -// @Description 重复请求幂等:订单处于 refunding/refunded 时会返回当前订单状态,不会重复入账/重复回收权益。 -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param orderID path int64 true "OrderID" -// @Param form body dto.AdminOrderRefundForm true "Form" -// @Success 200 {object} models.Order -// -// @Router /t/:tenantCode/v1/management/orders/:orderID/refund [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind orderID path -// @Bind form body -func (*orderAdmin) adminRefund( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - orderID int64, - form *dto.AdminOrderRefundForm, -) (*models.Order, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "order_id": orderID, - "force": form.Force, - "idempotency_key": form.IdempotencyKey, - }).Info("tenant.admin.orders.refund") - - return services.Order.AdminRefundOrder( - ctx, - tenant.ID, - tenantUser.UserID, - orderID, - form.Force, - form.Reason, - form.IdempotencyKey, - time.Now(), - ) -} - -// 注意:已移除“租户管理员为用户充值”能力。 -// 余额已改为 users 表的全局余额,用户可在已加入租户间共享消费;按租户充值会导致账务复杂且易出错。 diff --git a/backend/app/http/tenant/order_me.go b/backend/app/http/tenant/order_me.go deleted file mode 100644 index 1716a1b..0000000 --- a/backend/app/http/tenant/order_me.go +++ /dev/null @@ -1,63 +0,0 @@ -package tenant - -import ( - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// orderMe provides member order endpoints (my orders within a tenant). -// -// @provider -type orderMe struct{} - -// myOrders -// -// @Summary 我的订单列表(当前租户) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.MyOrderListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.Order} -// -// @Router /t/:tenantCode/v1/orders [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind filter query -func (*orderMe) myOrders(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.MyOrderListFilter) (*requests.Pager, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - }).Info("tenant.orders.me.list") - - return services.Order.MyOrderPage(ctx, tenant.ID, user.ID, filter) -} - -// myOrderDetail -// -// @Summary 我的订单详情(当前租户) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param orderID path int64 true "OrderID" -// @Success 200 {object} models.Order -// -// @Router /t/:tenantCode/v1/orders/:orderID [get] -// @Bind tenant local key(tenant) -// @Bind user local key(user) -// @Bind orderID path -func (*orderMe) myOrderDetail(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, orderID int64) (*models.Order, error) { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": user.ID, - "order_id": orderID, - }).Info("tenant.orders.me.detail") - - return services.Order.MyOrderDetail(ctx, tenant.ID, user.ID, orderID) -} diff --git a/backend/app/http/tenant/provider.gen.go b/backend/app/http/tenant/provider.gen.go deleted file mode 100755 index fa71259..0000000 --- a/backend/app/http/tenant/provider.gen.go +++ /dev/null @@ -1,127 +0,0 @@ -package tenant - -import ( - "quyun/v2/app/middlewares" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/atom/opt" -) - -func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*content, error) { - obj := &content{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*contentAdmin, error) { - obj := &contentAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*ledgerAdmin, error) { - obj := &ledgerAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*me, error) { - obj := &me{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*mediaAssetAdmin, error) { - obj := &mediaAssetAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*order, error) { - obj := &order{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*orderAdmin, error) { - obj := &orderAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*orderMe, error) { - obj := &orderMe{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - content *content, - contentAdmin *contentAdmin, - ledgerAdmin *ledgerAdmin, - me *me, - mediaAssetAdmin *mediaAssetAdmin, - middlewares *middlewares.Middlewares, - order *order, - orderAdmin *orderAdmin, - orderMe *orderMe, - tenantInviteAdmin *tenantInviteAdmin, - tenantJoinAdmin *tenantJoinAdmin, - tenantUserAdmin *tenantUserAdmin, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - content: content, - contentAdmin: contentAdmin, - ledgerAdmin: ledgerAdmin, - me: me, - mediaAssetAdmin: mediaAssetAdmin, - middlewares: middlewares, - order: order, - orderAdmin: orderAdmin, - orderMe: orderMe, - tenantInviteAdmin: tenantInviteAdmin, - tenantJoinAdmin: tenantJoinAdmin, - tenantUserAdmin: tenantUserAdmin, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenantInviteAdmin, error) { - obj := &tenantInviteAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenantJoinAdmin, error) { - obj := &tenantJoinAdmin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenantUserAdmin, error) { - obj := &tenantUserAdmin{} - - return obj, nil - }); err != nil { - return err - } - return nil -} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go deleted file mode 100644 index f01a5bf..0000000 --- a/backend/app/http/tenant/routes.gen.go +++ /dev/null @@ -1,329 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package tenant provides HTTP route definitions and registration -// for the quyun/v2 application. -package tenant - -import ( - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/middlewares" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the tenant module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - content *content - contentAdmin *contentAdmin - ledgerAdmin *ledgerAdmin - me *me - mediaAssetAdmin *mediaAssetAdmin - order *order - orderAdmin *orderAdmin - orderMe *orderMe - tenantInviteAdmin *tenantInviteAdmin - tenantJoinAdmin *tenantJoinAdmin - tenantUserAdmin *tenantUserAdmin -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.tenant") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "tenant" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: content - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents -> content.list") - router.Get("/t/:tenantCode/v1/contents"[len(r.Path()):], DataFunc3( - r.content.list, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - Query[dto.ContentListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID -> content.show") - router.Get("/t/:tenantCode/v1/contents/:contentID"[len(r.Path()):], DataFunc3( - r.content.show, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - PathParam[int64]("contentID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID/assets -> content.mainAssets") - router.Get("/t/:tenantCode/v1/contents/:contentID/assets"[len(r.Path()):], DataFunc3( - r.content.mainAssets, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - PathParam[int64]("contentID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/contents/:contentID/preview -> content.previewAssets") - router.Get("/t/:tenantCode/v1/contents/:contentID/preview"[len(r.Path()):], DataFunc3( - r.content.previewAssets, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - PathParam[int64]("contentID"), - )) - // Register routes for controller: contentAdmin - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/contents -> contentAdmin.list") - router.Get("/t/:tenantCode/v1/management/contents"[len(r.Path()):], DataFunc3( - r.contentAdmin.list, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminContentListFilter]("filter"), - )) - r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/contents/:contentID -> contentAdmin.update") - router.Patch("/t/:tenantCode/v1/management/contents/:contentID"[len(r.Path()):], DataFunc4( - r.contentAdmin.update, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("contentID"), - Body[dto.ContentUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents -> contentAdmin.create") - router.Post("/t/:tenantCode/v1/management/contents"[len(r.Path()):], DataFunc3( - r.contentAdmin.create, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Body[dto.ContentCreateForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents/:contentID/assets -> contentAdmin.attachAsset") - router.Post("/t/:tenantCode/v1/management/contents/:contentID/assets"[len(r.Path()):], DataFunc4( - r.contentAdmin.attachAsset, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("contentID"), - Body[dto.ContentAssetAttachForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/contents/publish -> contentAdmin.publish") - router.Post("/t/:tenantCode/v1/management/contents/publish"[len(r.Path()):], DataFunc3( - r.contentAdmin.publish, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Body[dto.ContentPublishForm]("form"), - )) - r.log.Debugf("Registering route: Put /t/:tenantCode/v1/management/contents/:contentID/price -> contentAdmin.upsertPrice") - router.Put("/t/:tenantCode/v1/management/contents/:contentID/price"[len(r.Path()):], DataFunc4( - r.contentAdmin.upsertPrice, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("contentID"), - Body[dto.ContentPriceUpsertForm]("form"), - )) - // Register routes for controller: ledgerAdmin - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/ledgers -> ledgerAdmin.adminLedgers") - router.Get("/t/:tenantCode/v1/management/ledgers"[len(r.Path()):], DataFunc3( - r.ledgerAdmin.adminLedgers, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminLedgerListFilter]("filter"), - )) - // Register routes for controller: me - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me -> me.get") - router.Get("/t/:tenantCode/v1/me"[len(r.Path()):], DataFunc3( - r.me.get, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - Local[*models.TenantUser]("tenant_user"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/balance -> me.balance") - router.Get("/t/:tenantCode/v1/me/balance"[len(r.Path()):], DataFunc2( - r.me.balance, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/me/ledgers -> me.ledgers") - router.Get("/t/:tenantCode/v1/me/ledgers"[len(r.Path()):], DataFunc3( - r.me.ledgers, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - Query[dto.MyLedgerListFilter]("filter"), - )) - // Register routes for controller: mediaAssetAdmin - r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/management/media_assets/:assetID -> mediaAssetAdmin.adminDelete") - router.Delete("/t/:tenantCode/v1/management/media_assets/:assetID"[len(r.Path()):], DataFunc3( - r.mediaAssetAdmin.adminDelete, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("assetID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/media_assets -> mediaAssetAdmin.adminList") - router.Get("/t/:tenantCode/v1/management/media_assets"[len(r.Path()):], DataFunc3( - r.mediaAssetAdmin.adminList, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminMediaAssetListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/media_assets/:assetID -> mediaAssetAdmin.adminDetail") - router.Get("/t/:tenantCode/v1/management/media_assets/:assetID"[len(r.Path()):], DataFunc3( - r.mediaAssetAdmin.adminDetail, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("assetID"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/media_assets/:assetID/upload_complete -> mediaAssetAdmin.uploadComplete") - router.Post("/t/:tenantCode/v1/management/media_assets/:assetID/upload_complete"[len(r.Path()):], DataFunc4( - r.mediaAssetAdmin.uploadComplete, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("assetID"), - Body[dto.AdminMediaAssetUploadCompleteForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/media_assets/upload_init -> mediaAssetAdmin.uploadInit") - router.Post("/t/:tenantCode/v1/management/media_assets/upload_init"[len(r.Path()):], DataFunc3( - r.mediaAssetAdmin.uploadInit, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Body[dto.AdminMediaAssetUploadInitForm]("form"), - )) - // Register routes for controller: order - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/contents/:contentID/purchase -> order.purchaseContent") - router.Post("/t/:tenantCode/v1/contents/:contentID/purchase"[len(r.Path()):], DataFunc4( - r.order.purchaseContent, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - PathParam[int64]("contentID"), - Body[dto.PurchaseContentForm]("form"), - )) - // Register routes for controller: orderAdmin - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders -> orderAdmin.adminOrderList") - router.Get("/t/:tenantCode/v1/management/orders"[len(r.Path()):], DataFunc3( - r.orderAdmin.adminOrderList, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminOrderListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders/:orderID -> orderAdmin.adminOrderDetail") - router.Get("/t/:tenantCode/v1/management/orders/:orderID"[len(r.Path()):], DataFunc3( - r.orderAdmin.adminOrderDetail, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("orderID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/orders/export -> orderAdmin.adminOrderExport") - router.Get("/t/:tenantCode/v1/management/orders/export"[len(r.Path()):], DataFunc3( - r.orderAdmin.adminOrderExport, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminOrderListFilter]("filter"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/orders/:orderID/refund -> orderAdmin.adminRefund") - router.Post("/t/:tenantCode/v1/management/orders/:orderID/refund"[len(r.Path()):], DataFunc4( - r.orderAdmin.adminRefund, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("orderID"), - Body[dto.AdminOrderRefundForm]("form"), - )) - // Register routes for controller: orderMe - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders -> orderMe.myOrders") - router.Get("/t/:tenantCode/v1/orders"[len(r.Path()):], DataFunc3( - r.orderMe.myOrders, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - Query[dto.MyOrderListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/orders/:orderID -> orderMe.myOrderDetail") - router.Get("/t/:tenantCode/v1/orders/:orderID"[len(r.Path()):], DataFunc3( - r.orderMe.myOrderDetail, - Local[*models.Tenant]("tenant"), - Local[*models.User]("user"), - PathParam[int64]("orderID"), - )) - // Register routes for controller: tenantInviteAdmin - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/invites -> tenantInviteAdmin.adminInviteList") - router.Get("/t/:tenantCode/v1/management/invites"[len(r.Path()):], DataFunc3( - r.tenantInviteAdmin.adminInviteList, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminTenantInviteListFilter]("filter"), - )) - r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/invites/:inviteID/disable -> tenantInviteAdmin.adminDisableInvite") - router.Patch("/t/:tenantCode/v1/management/invites/:inviteID/disable"[len(r.Path()):], DataFunc4( - r.tenantInviteAdmin.adminDisableInvite, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("inviteID"), - Body[dto.AdminTenantInviteDisableForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/invites -> tenantInviteAdmin.adminCreateInvite") - router.Post("/t/:tenantCode/v1/management/invites"[len(r.Path()):], DataFunc3( - r.tenantInviteAdmin.adminCreateInvite, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Body[dto.AdminTenantInviteCreateForm]("form"), - )) - // Register routes for controller: tenantJoinAdmin - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/join-requests -> tenantJoinAdmin.adminJoinRequests") - router.Get("/t/:tenantCode/v1/management/join-requests"[len(r.Path()):], DataFunc3( - r.tenantJoinAdmin.adminJoinRequests, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminTenantJoinRequestListFilter]("filter"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/join-requests/:requestID/approve -> tenantJoinAdmin.adminApproveJoinRequest") - router.Post("/t/:tenantCode/v1/management/join-requests/:requestID/approve"[len(r.Path()):], DataFunc4( - r.tenantJoinAdmin.adminApproveJoinRequest, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("requestID"), - Body[dto.AdminTenantJoinRequestDecideForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/join-requests/:requestID/reject -> tenantJoinAdmin.adminRejectJoinRequest") - router.Post("/t/:tenantCode/v1/management/join-requests/:requestID/reject"[len(r.Path()):], DataFunc4( - r.tenantJoinAdmin.adminRejectJoinRequest, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("requestID"), - Body[dto.AdminTenantJoinRequestDecideForm]("form"), - )) - // Register routes for controller: tenantUserAdmin - r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/management/users/:userID -> tenantUserAdmin.adminRemoveUser") - router.Delete("/t/:tenantCode/v1/management/users/:userID"[len(r.Path()):], Func3( - r.tenantUserAdmin.adminRemoveUser, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("userID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/management/users -> tenantUserAdmin.adminTenantUsers") - router.Get("/t/:tenantCode/v1/management/users"[len(r.Path()):], DataFunc3( - r.tenantUserAdmin.adminTenantUsers, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - Query[dto.AdminTenantUserListFilter]("filter"), - )) - r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/management/users/:userID/role -> tenantUserAdmin.adminSetUserRole") - router.Patch("/t/:tenantCode/v1/management/users/:userID/role"[len(r.Path()):], DataFunc4( - r.tenantUserAdmin.adminSetUserRole, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("userID"), - Body[dto.AdminTenantUserRoleUpdateForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/management/users/:userID/join -> tenantUserAdmin.adminJoinUser") - router.Post("/t/:tenantCode/v1/management/users/:userID/join"[len(r.Path()):], DataFunc3( - r.tenantUserAdmin.adminJoinUser, - Local[*models.Tenant]("tenant"), - Local[*models.TenantUser]("tenant_user"), - PathParam[int64]("userID"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/tenant/routes.manual.go b/backend/app/http/tenant/routes.manual.go deleted file mode 100644 index 15e5979..0000000 --- a/backend/app/http/tenant/routes.manual.go +++ /dev/null @@ -1,13 +0,0 @@ -package tenant - -func (r *Routes) Path() string { - return "/t/:tenantCode/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.TenantResolve, - r.middlewares.TenantAuth, - r.middlewares.TenantRequireMember, - } -} diff --git a/backend/app/http/tenant/tenant_invite_admin.go b/backend/app/http/tenant/tenant_invite_admin.go deleted file mode 100644 index b273122..0000000 --- a/backend/app/http/tenant/tenant_invite_admin.go +++ /dev/null @@ -1,134 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// tenantInviteAdmin 提供“租户管理员管理邀请码”的相关接口。 -// -// @provider -type tenantInviteAdmin struct{} - -// adminCreateInvite -// -// @Summary 创建邀请码(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.AdminTenantInviteCreateForm true "Form" -// @Success 200 {object} models.TenantInvite -// -// @Router /t/:tenantCode/v1/management/invites [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind form body -func (*tenantInviteAdmin) adminCreateInvite( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - form *dto.AdminTenantInviteCreateForm, -) (*models.TenantInvite, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - }).Info("tenant.admin.invites.create") - - return services.Tenant.AdminCreateInvite(ctx.Context(), tenant.ID, tenantUser.UserID, form) -} - -// adminInviteList -// -// @Summary 邀请码列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminTenantInviteListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.TenantInvite} -// -// @Router /t/:tenantCode/v1/management/invites [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*tenantInviteAdmin) adminInviteList( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminTenantInviteListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminTenantInviteListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "status": filter.Status, - "code": filter.CodeTrimmed(), - }).Info("tenant.admin.invites.list") - - return services.Tenant.AdminInvitePage(ctx.Context(), tenant.ID, filter) -} - -// adminDisableInvite -// -// @Summary 禁用邀请码(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param inviteID path int64 true "InviteID" -// @Param form body dto.AdminTenantInviteDisableForm true "Form" -// @Success 200 {object} models.TenantInvite -// -// @Router /t/:tenantCode/v1/management/invites/:inviteID/disable [patch] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind inviteID path -// @Bind form body -func (*tenantInviteAdmin) adminDisableInvite( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - inviteID int64, - form *dto.AdminTenantInviteDisableForm, -) (*models.TenantInvite, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if inviteID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invite_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "invite_id": inviteID, - "disable_time": time.Now(), - }).Info("tenant.admin.invites.disable") - - return services.Tenant.AdminDisableInvite(ctx.Context(), tenant.ID, tenantUser.UserID, inviteID, form.Reason) -} diff --git a/backend/app/http/tenant/tenant_join_admin.go b/backend/app/http/tenant/tenant_join_admin.go deleted file mode 100644 index fba82f7..0000000 --- a/backend/app/http/tenant/tenant_join_admin.go +++ /dev/null @@ -1,142 +0,0 @@ -package tenant - -import ( - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// tenantJoinAdmin 提供“租户管理员审核加入申请”的相关接口。 -// -// @provider -type tenantJoinAdmin struct{} - -// adminJoinRequests -// -// @Summary 加入申请列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminTenantJoinRequestListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.TenantJoinRequest} -// -// @Router /t/:tenantCode/v1/management/join-requests [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*tenantJoinAdmin) adminJoinRequests( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminTenantJoinRequestListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if filter == nil { - filter = &dto.AdminTenantJoinRequestListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "status": filter.Status, - "query_uid": filter.UserID, - }).Info("tenant.admin.join_requests.list") - - return services.Tenant.AdminJoinRequestPage(ctx.Context(), tenant.ID, filter) -} - -// adminApproveJoinRequest -// -// @Summary 通过加入申请(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param requestID path int64 true "RequestID" -// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" -// @Success 200 {object} models.TenantJoinRequest -// -// @Router /t/:tenantCode/v1/management/join-requests/:requestID/approve [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind requestID path -// @Bind form body -func (*tenantJoinAdmin) adminApproveJoinRequest( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - requestID int64, - form *dto.AdminTenantJoinRequestDecideForm, -) (*models.TenantJoinRequest, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if requestID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("request_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "request_id": requestID, - "decide_time": time.Now(), - }).Info("tenant.admin.join_requests.approve") - - return services.Tenant.AdminApproveJoinRequest(ctx.Context(), tenant.ID, tenantUser.UserID, requestID, form.Reason) -} - -// adminRejectJoinRequest -// -// @Summary 拒绝加入申请(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param requestID path int64 true "RequestID" -// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" -// @Success 200 {object} models.TenantJoinRequest -// -// @Router /t/:tenantCode/v1/management/join-requests/:requestID/reject [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind requestID path -// @Bind form body -func (*tenantJoinAdmin) adminRejectJoinRequest( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - requestID int64, - form *dto.AdminTenantJoinRequestDecideForm, -) (*models.TenantJoinRequest, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if requestID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("request_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "request_id": requestID, - "decide_time": time.Now(), - }).Info("tenant.admin.join_requests.reject") - - return services.Tenant.AdminRejectJoinRequest(ctx.Context(), tenant.ID, tenantUser.UserID, requestID, form.Reason) -} diff --git a/backend/app/http/tenant/tenant_user_admin.go b/backend/app/http/tenant/tenant_user_admin.go deleted file mode 100644 index 4872a33..0000000 --- a/backend/app/http/tenant/tenant_user_admin.go +++ /dev/null @@ -1,204 +0,0 @@ -package tenant - -import ( - "strings" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// tenantUserAdmin provides tenant-admin member management endpoints. -// -// @provider -type tenantUserAdmin struct{} - -// adminRemoveUser -// -// @Summary 移除租户成员(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param userID path int64 true "UserID" -// @Success 200 {object} requests.Pager -// -// @Router /t/:tenantCode/v1/management/users/:userID [delete] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind userID path -func (*tenantUserAdmin) adminRemoveUser( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - userID int64, -) error { - if err := requireTenantAdmin(tenantUser); err != nil { - return err - } - if userID <= 0 { - return errorx.ErrInvalidParameter.WithMsg("user_id must be > 0") - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "operator_user_id": tenantUser.UserID, - "target_user_id": userID, - "operator_is_admin": true, - }).Info("tenant.admin.users.remove") - - // 关键语义:删除成员接口幂等化;目标用户不属于租户时也返回成功,便于后台重试与批量操作。 - return services.Tenant.RemoveUser(ctx.Context(), tenant.ID, userID) -} - -// adminJoinUser -// -// @Summary 添加租户成员(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param userID path int64 true "UserID" -// @Success 200 {object} dto.AdminTenantUserJoinResponse -// -// @Router /t/:tenantCode/v1/management/users/:userID/join [post] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind userID path -func (*tenantUserAdmin) adminJoinUser( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - userID int64, -) (*dto.AdminTenantUserJoinResponse, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("user_id must be > 0") - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "operator_user_id": tenantUser.UserID, - "target_user_id": userID, - "operator_is_admin": true, - }).Info("tenant.admin.users.join") - - // 关键逻辑:以 TenantUser 为准创建成员关系;服务层保证幂等(已存在则不重复创建)。 - if err := services.Tenant.AddUser(ctx.Context(), tenant.ID, userID); err != nil { - return nil, err - } - - m, err := services.Tenant.FindTenantUser(ctx.Context(), tenant.ID, userID) - if err != nil { - return nil, err - } - return &dto.AdminTenantUserJoinResponse{TenantUser: m}, nil -} - -// adminSetUserRole -// -// @Summary 设置成员角色(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param userID path int64 true "UserID" -// @Param form body dto.AdminTenantUserRoleUpdateForm true "Form" -// @Success 200 {object} dto.AdminTenantUserJoinResponse -// -// @Router /t/:tenantCode/v1/management/users/:userID/role [patch] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind userID path -// @Bind form body -func (*tenantUserAdmin) adminSetUserRole( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - userID int64, - form *dto.AdminTenantUserRoleUpdateForm, -) (*dto.AdminTenantUserJoinResponse, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - if userID <= 0 || form == nil { - return nil, errorx.ErrInvalidParameter - } - - roleStr := strings.TrimSpace(form.Role) - if roleStr == "" { - return nil, errorx.ErrInvalidParameter.WithMsg("role is required") - } - - var role consts.TenantUserRole - switch roleStr { - case string(consts.TenantUserRoleMember): - role = consts.TenantUserRoleMember - case string(consts.TenantUserRoleTenantAdmin): - role = consts.TenantUserRoleTenantAdmin - default: - return nil, errorx.ErrInvalidParameter.WithMsg("invalid role") - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "operator_user_id": tenantUser.UserID, - "target_user_id": userID, - "role": role, - }).Info("tenant.admin.users.set_role") - - if err := services.Tenant.SetUserRole(ctx.Context(), tenant.ID, userID, role); err != nil { - return nil, err - } - - m, err := services.Tenant.FindTenantUser(ctx.Context(), tenant.ID, userID) - if err != nil { - return nil, err - } - return &dto.AdminTenantUserJoinResponse{TenantUser: m}, nil -} - -// adminTenantUsers -// -// @Summary 成员列表(租户管理) -// @Tags Tenant -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query dto.AdminTenantUserListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=dto.AdminTenantUserItem} -// -// @Router /t/:tenantCode/v1/management/users [get] -// @Bind tenant local key(tenant) -// @Bind tenantUser local key(tenant_user) -// @Bind filter query -func (*tenantUserAdmin) adminTenantUsers( - ctx fiber.Ctx, - tenant *models.Tenant, - tenantUser *models.TenantUser, - filter *dto.AdminTenantUserListFilter, -) (*requests.Pager, error) { - if err := requireTenantAdmin(tenantUser); err != nil { - return nil, err - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": tenantUser.UserID, - "query_uid": filter.UserID, - "role": filter.Role, - "status": filter.Status, - "username": filter.UsernameTrimmed(), - }).Info("tenant.admin.users.list") - - // 按 llm.txt 约束:HTTP 层不允许直接查询数据库,全部交由 services 层处理。 - return services.Tenant.AdminTenantUsersPage(ctx.Context(), tenant.ID, filter) -} diff --git a/backend/app/http/tenant_join/dto/join.go b/backend/app/http/tenant_join/dto/join.go deleted file mode 100644 index 2892baa..0000000 --- a/backend/app/http/tenant_join/dto/join.go +++ /dev/null @@ -1,13 +0,0 @@ -package dto - -// JoinByInviteForm 用户通过邀请码加入租户的请求参数。 -type JoinByInviteForm struct { - // InviteCode 邀请码:由租户管理员生成;用户提交后加入对应租户。 - InviteCode string `json:"invite_code"` -} - -// JoinRequestCreateForm 用户提交加入租户申请的请求参数(无邀请码场景)。 -type JoinRequestCreateForm struct { - // Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。 - Reason string `json:"reason"` -} diff --git a/backend/app/http/tenant_join/join.go b/backend/app/http/tenant_join/join.go deleted file mode 100644 index 27c98b4..0000000 --- a/backend/app/http/tenant_join/join.go +++ /dev/null @@ -1,87 +0,0 @@ -package tenant_join - -import ( - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant_join/dto" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// join 提供“非成员加入租户”的相关接口(邀请码加入 / 申请加入)。 -// -// @provider -type join struct{} - -// joinByInvite -// -// @Summary 通过邀请码加入租户 -// @Tags TenantJoin -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.JoinByInviteForm true "Form" -// @Success 200 {object} models.TenantUser -// -// @Router /t/:tenantCode/v1/join/invite [post] -// @Bind tenant local key(tenant) -// @Bind claims local key(claims) -// @Bind form body -func (*join) joinByInvite( - ctx fiber.Ctx, - tenant *models.Tenant, - claims *jwt.Claims, - form *dto.JoinByInviteForm, -) (*models.TenantUser, error) { - if tenant == nil || claims == nil { - return nil, errorx.ErrInternalError.WithMsg("context missing") - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": claims.UserID, - }).Info("tenantjoin.join_by_invite") - - return services.Tenant.JoinByInvite(ctx.Context(), tenant.ID, claims.UserID, form.InviteCode) -} - -// createJoinRequest -// -// @Summary 提交加入租户申请 -// @Tags TenantJoin -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param form body dto.JoinRequestCreateForm true "Form" -// @Success 200 {object} models.TenantJoinRequest -// -// @Router /t/:tenantCode/v1/join/request [post] -// @Bind tenant local key(tenant) -// @Bind claims local key(claims) -// @Bind form body -func (*join) createJoinRequest( - ctx fiber.Ctx, - tenant *models.Tenant, - claims *jwt.Claims, - form *dto.JoinRequestCreateForm, -) (*models.TenantJoinRequest, error) { - if tenant == nil || claims == nil { - return nil, errorx.ErrInternalError.WithMsg("context missing") - } - if form == nil { - return nil, errorx.ErrInvalidParameter - } - - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": claims.UserID, - }).Info("tenant_join.create_join_request") - - return services.Tenant.CreateJoinRequest(ctx.Context(), tenant.ID, claims.UserID, form) -} diff --git a/backend/app/http/tenant_join/provider.gen.go b/backend/app/http/tenant_join/provider.gen.go deleted file mode 100755 index 0f3c8ec..0000000 --- a/backend/app/http/tenant_join/provider.gen.go +++ /dev/null @@ -1,37 +0,0 @@ -package tenant_join - -import ( - "quyun/v2/app/middlewares" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/atom/opt" -) - -func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*join, error) { - obj := &join{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - join *join, - middlewares *middlewares.Middlewares, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - join: join, - middlewares: middlewares, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - return nil -} diff --git a/backend/app/http/tenant_join/routes.gen.go b/backend/app/http/tenant_join/routes.gen.go deleted file mode 100644 index 45e6396..0000000 --- a/backend/app/http/tenant_join/routes.gen.go +++ /dev/null @@ -1,63 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package tenant_join provides HTTP route definitions and registration -// for the quyun/v2 application. -package tenant_join - -import ( - "quyun/v2/app/http/tenant_join/dto" - "quyun/v2/app/middlewares" - "quyun/v2/database/models" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the tenant_join module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - join *join -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.tenant_join") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "tenant_join" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: join - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/join/invite -> join.joinByInvite") - router.Post("/t/:tenantCode/v1/join/invite"[len(r.Path()):], DataFunc3( - r.join.joinByInvite, - Local[*models.Tenant]("tenant"), - Local[*jwt.Claims]("claims"), - Body[dto.JoinByInviteForm]("form"), - )) - r.log.Debugf("Registering route: Post /t/:tenantCode/v1/join/request -> join.createJoinRequest") - router.Post("/t/:tenantCode/v1/join/request"[len(r.Path()):], DataFunc3( - r.join.createJoinRequest, - Local[*models.Tenant]("tenant"), - Local[*jwt.Claims]("claims"), - Body[dto.JoinRequestCreateForm]("form"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/tenant_join/routes.manual.go b/backend/app/http/tenant_join/routes.manual.go deleted file mode 100644 index 1bab786..0000000 --- a/backend/app/http/tenant_join/routes.manual.go +++ /dev/null @@ -1,12 +0,0 @@ -package tenant_join - -func (r *Routes) Path() string { - return "/t/:tenantCode/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.TenantResolve, - r.middlewares.TenantAuth, - } -} diff --git a/backend/app/http/tenant_media/play.go b/backend/app/http/tenant_media/play.go deleted file mode 100644 index 09811f0..0000000 --- a/backend/app/http/tenant_media/play.go +++ /dev/null @@ -1,50 +0,0 @@ -package tenant_media - -import ( - "quyun/v2/app/errorx" - "quyun/v2/app/services" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// media provides media play endpoints (token-based, no JWT required). -// -// @provider -type media struct{} - -// play -// -// @Summary 媒体播放入口(短时效 token) -// @Tags TenantMedia -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param token query string true "Play token" -// -// @Router /t/:tenantCode/v1/media/play [get] -// @Bind tenant local key(tenant) -// @Bind token query -func (*media) play(ctx fiber.Ctx, tenant *models.Tenant, token string) error { - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - }).Info("tenant_media.play") - - res, err := services.MediaDelivery.ResolvePlay(ctx.Context(), tenant.ID, token) - if err != nil { - return err - } - - switch res.Kind { - case services.MediaPlayResolutionKindLocalFile: - if res.ContentType != "" { - ctx.Set("Content-Type", res.ContentType) - } - return ctx.SendFile(res.LocalFilePath) - case services.MediaPlayResolutionKindRedirect: - return ctx.Redirect().To(res.RedirectURL) - default: - return errorx.ErrServiceUnavailable.WithMsg("unsupported play resolution") - } -} diff --git a/backend/app/http/tenant_media/provider.gen.go b/backend/app/http/tenant_media/provider.gen.go deleted file mode 100755 index a008fe6..0000000 --- a/backend/app/http/tenant_media/provider.gen.go +++ /dev/null @@ -1,37 +0,0 @@ -package tenant_media - -import ( - "quyun/v2/app/middlewares" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/atom/opt" -) - -func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*media, error) { - obj := &media{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - media *media, - middlewares *middlewares.Middlewares, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - media: media, - middlewares: middlewares, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - return nil -} diff --git a/backend/app/http/tenant_media/routes.gen.go b/backend/app/http/tenant_media/routes.gen.go deleted file mode 100644 index 5a1858c..0000000 --- a/backend/app/http/tenant_media/routes.gen.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package tenant_media provides HTTP route definitions and registration -// for the quyun/v2 application. -package tenant_media - -import ( - "quyun/v2/app/middlewares" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the tenant_media module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - media *media -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.tenant_media") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "tenant_media" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: media - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/media/play -> media.play") - router.Get("/t/:tenantCode/v1/media/play"[len(r.Path()):], Func2( - r.media.play, - Local[*models.Tenant]("tenant"), - QueryParam[string]("token"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/tenant_media/routes.manual.go b/backend/app/http/tenant_media/routes.manual.go deleted file mode 100644 index 15effc4..0000000 --- a/backend/app/http/tenant_media/routes.manual.go +++ /dev/null @@ -1,11 +0,0 @@ -package tenant_media - -func (r *Routes) Path() string { - return "/t/:tenantCode/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.TenantResolve, - } -} diff --git a/backend/app/http/tenant_public/content.go b/backend/app/http/tenant_public/content.go deleted file mode 100644 index 6a4ecff..0000000 --- a/backend/app/http/tenant_public/content.go +++ /dev/null @@ -1,223 +0,0 @@ -package tenant_public - -import ( - "encoding/json" - "net/url" - "time" - - "quyun/v2/app/errorx" - tenant_dto "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" -) - -// content 提供“租户维度的公开只读接口”(不要求租户成员)。 -// -// @provider -type content struct{} - -func viewerUserID(ctx fiber.Ctx) int64 { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil { - return 0 - } - return claims.UserID -} - -// list -// -// @Summary 公开内容列表(已发布 + public) -// @Tags TenantPublic -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param filter query tenant_dto.ContentListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=tenant_dto.ContentItem} -// -// @Router /t/:tenantCode/v1/public/contents [get] -// @Bind tenant local key(tenant) -// @Bind filter query -func (*content) list( - ctx fiber.Ctx, - tenant *models.Tenant, - filter *tenant_dto.ContentListFilter, -) (*requests.Pager, error) { - uid := viewerUserID(ctx) - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": uid, - }).Info("tenant_public.contents.list") - - if filter == nil { - filter = &tenant_dto.ContentListFilter{} - } - filter.Pagination.Format() - return services.Content.ListPublicPublished(ctx, tenant.ID, uid, filter) -} - -// show -// -// @Summary 公开内容详情(已发布 + public) -// @Tags TenantPublic -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} tenant_dto.ContentDetail -// -// @Router /t/:tenantCode/v1/public/contents/:contentID [get] -// @Bind tenant local key(tenant) -// @Bind contentID path -func (*content) show(ctx fiber.Ctx, tenant *models.Tenant, contentID int64) (*tenant_dto.ContentDetail, error) { - uid := viewerUserID(ctx) - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": uid, - "content_id": contentID, - }).Info("tenant_public.contents.show") - - item, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) - if err != nil { - return nil, err - } - return &tenant_dto.ContentDetail{ - Content: item.Content, - Price: item.Price, - HasAccess: item.HasAccess, - }, nil -} - -// previewAssets -// -// @Summary 获取公开试看资源(preview role) -// @Tags TenantPublic -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} tenant_dto.ContentAssetsResponse -// -// @Router /t/:tenantCode/v1/public/contents/:contentID/preview [get] -// @Bind tenant local key(tenant) -// @Bind contentID path -func (*content) previewAssets( - ctx fiber.Ctx, - tenant *models.Tenant, - contentID int64, -) (*tenant_dto.ContentAssetsResponse, error) { - uid := viewerUserID(ctx) - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": uid, - "content_id": contentID, - }).Info("tenant_public.contents.preview_assets") - - detail, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) - if err != nil { - return nil, err - } - - assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRolePreview) - if err != nil { - return nil, err - } - - playables := make([]*tenant_dto.ContentPlayableAsset, 0, len(assets)) - for _, asset := range assets { - token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRolePreview, uid, 0, time.Now()) - if err != nil { - return nil, err - } - var meta json.RawMessage - if len(asset.Meta) > 0 { - meta = json.RawMessage(asset.Meta) - } - playables = append(playables, &tenant_dto.ContentPlayableAsset{ - AssetID: asset.ID, - Type: asset.Type, - PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token), - ExpiresAt: expiresAt, - Meta: meta, - }) - } - - previewSeconds := int32(detail.Content.PreviewSeconds) - if previewSeconds <= 0 { - previewSeconds = consts.DefaultContentPreviewSeconds - } - - return &tenant_dto.ContentAssetsResponse{ - Content: detail.Content, - Assets: playables, - PreviewSeconds: previewSeconds, - }, nil -} - -// mainAssets -// -// @Summary 获取公开正片资源(main role;免费/作者/已购) -// @Tags TenantPublic -// @Accept json -// @Produce json -// @Param tenantCode path string true "Tenant Code" -// @Param contentID path int64 true "ContentID" -// @Success 200 {object} tenant_dto.ContentAssetsResponse -// -// @Router /t/:tenantCode/v1/public/contents/:contentID/assets [get] -// @Bind tenant local key(tenant) -// @Bind contentID path -func (*content) mainAssets( - ctx fiber.Ctx, - tenant *models.Tenant, - contentID int64, -) (*tenant_dto.ContentAssetsResponse, error) { - uid := viewerUserID(ctx) - log.WithFields(log.Fields{ - "tenant_id": tenant.ID, - "user_id": uid, - "content_id": contentID, - }).Info("tenantpublic.contents.main_assets") - - detail, err := services.Content.PublicDetail(ctx, tenant.ID, uid, contentID) - if err != nil { - return nil, err - } - if !detail.HasAccess { - return nil, errorx.ErrPermissionDenied.WithMsg("未购买或无权限访问") - } - - assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRoleMain) - if err != nil { - return nil, err - } - - playables := make([]*tenant_dto.ContentPlayableAsset, 0, len(assets)) - for _, asset := range assets { - token, expiresAt, err := services.MediaDelivery.CreatePlayToken(tenant.ID, contentID, asset.ID, consts.ContentAssetRoleMain, uid, 0, time.Now()) - if err != nil { - return nil, err - } - var meta json.RawMessage - if len(asset.Meta) > 0 { - meta = json.RawMessage(asset.Meta) - } - playables = append(playables, &tenant_dto.ContentPlayableAsset{ - AssetID: asset.ID, - Type: asset.Type, - PlayURL: "/t/" + tenant.Code + "/v1/media/play?token=" + url.QueryEscape(token), - ExpiresAt: expiresAt, - Meta: meta, - }) - } - - return &tenant_dto.ContentAssetsResponse{ - Content: detail.Content, - Assets: playables, - }, nil -} diff --git a/backend/app/http/tenant_public/provider.gen.go b/backend/app/http/tenant_public/provider.gen.go deleted file mode 100755 index e18db87..0000000 --- a/backend/app/http/tenant_public/provider.gen.go +++ /dev/null @@ -1,37 +0,0 @@ -package tenant_public - -import ( - "quyun/v2/app/middlewares" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/atom/opt" -) - -func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*content, error) { - obj := &content{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - content *content, - middlewares *middlewares.Middlewares, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - content: content, - middlewares: middlewares, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - return nil -} diff --git a/backend/app/http/tenant_public/routes.gen.go b/backend/app/http/tenant_public/routes.gen.go deleted file mode 100644 index 6f869d4..0000000 --- a/backend/app/http/tenant_public/routes.gen.go +++ /dev/null @@ -1,72 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package tenant_public provides HTTP route definitions and registration -// for the quyun/v2 application. -package tenant_public - -import ( - tenant_dto "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/middlewares" - "quyun/v2/database/models" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the tenant_public module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - content *content -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.tenant_public") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "tenant_public" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: content - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents -> content.list") - router.Get("/t/:tenantCode/v1/public/contents"[len(r.Path()):], DataFunc2( - r.content.list, - Local[*models.Tenant]("tenant"), - Query[tenant_dto.ContentListFilter]("filter"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID -> content.show") - router.Get("/t/:tenantCode/v1/public/contents/:contentID"[len(r.Path()):], DataFunc2( - r.content.show, - Local[*models.Tenant]("tenant"), - PathParam[int64]("contentID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID/assets -> content.mainAssets") - router.Get("/t/:tenantCode/v1/public/contents/:contentID/assets"[len(r.Path()):], DataFunc2( - r.content.mainAssets, - Local[*models.Tenant]("tenant"), - PathParam[int64]("contentID"), - )) - r.log.Debugf("Registering route: Get /t/:tenantCode/v1/public/contents/:contentID/preview -> content.previewAssets") - router.Get("/t/:tenantCode/v1/public/contents/:contentID/preview"[len(r.Path()):], DataFunc2( - r.content.previewAssets, - Local[*models.Tenant]("tenant"), - PathParam[int64]("contentID"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/tenant_public/routes.manual.go b/backend/app/http/tenant_public/routes.manual.go deleted file mode 100644 index d3d3647..0000000 --- a/backend/app/http/tenant_public/routes.manual.go +++ /dev/null @@ -1,12 +0,0 @@ -package tenant_public - -func (r *Routes) Path() string { - return "/t/:tenantCode/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.TenantResolve, - r.middlewares.TenantOptionalAuth, - } -} diff --git a/backend/app/http/web/auth.go b/backend/app/http/web/auth.go deleted file mode 100644 index 2ed919e..0000000 --- a/backend/app/http/web/auth.go +++ /dev/null @@ -1,326 +0,0 @@ -package web - -import ( - "crypto/rand" - "errors" - "fmt" - "math/big" - "regexp" - "strings" - "sync" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/web/dto" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" - "github.com/google/uuid" - "gorm.io/gorm" -) - -// @provider -type auth struct { - jwt *jwt.JWT -} - -var ( - reUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) - rePhone = regexp.MustCompile(`^[0-9]{6,20}$`) -) - -type passwordResetState struct { - code string - codeExpire time.Time - lastSentAt time.Time - resetToken string - tokenExpire time.Time -} - -var passwordResetStore = struct { - mu sync.Mutex - phoneToItem map[string]*passwordResetState - tokenToPhone map[string]string -}{ - phoneToItem: make(map[string]*passwordResetState), - tokenToPhone: make(map[string]string), -} - -const ( - passwordResetCodeTTL = 5 * time.Minute - passwordResetTokenTTL = 10 * time.Minute - passwordResetSendGap = 60 * time.Second -) - -// Login 用户登录(平台侧,非超级管理员)。 -// -// @Summary 用户登录 -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.LoginForm true "form" -// @Success 200 {object} dto.LoginResponse "成功" -// @Router /v1/auth/login [post] -// @Bind form body -func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) { - m, err := services.User.FindByUsername(ctx, form.Username) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("用户名或密码错误") - } - if ok := m.ComparePassword(ctx, form.Password); !ok { - return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误") - } - - token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ - UserID: m.ID, - })) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") - } - - return &dto.LoginResponse{Token: token}, nil -} - -// PasswordResetSendSMS 找回密码:发送短信验证码(预留:当前返回验证码用于前端弹窗展示)。 -// -// @Summary 找回密码-发送短信验证码 -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.PasswordResetSendSMSForm true "form" -// @Success 200 {object} dto.PasswordResetSendSMSResponse "成功" -// @Router /v1/auth/password/reset/sms [post] -// @Bind form body -func (ctl *auth) passwordResetSendSMS(ctx fiber.Ctx, form *dto.PasswordResetSendSMSForm) (*dto.PasswordResetSendSMSResponse, error) { - phone := strings.TrimSpace(form.Phone) - if phone == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") - } - if !rePhone.MatchString(phone) { - return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") - } - - passwordResetStore.mu.Lock() - defer passwordResetStore.mu.Unlock() - - now := time.Now() - item := passwordResetStore.phoneToItem[phone] - if item == nil { - item = &passwordResetState{} - passwordResetStore.phoneToItem[phone] = item - } - - if !item.lastSentAt.IsZero() { - elapsed := now.Sub(item.lastSentAt) - if elapsed < passwordResetSendGap { - remain := int((passwordResetSendGap - elapsed).Seconds()) - if remain < 1 { - remain = 1 - } - return nil, errorx.ErrTooManyRequests.WithMsgf("操作太频繁,请 %d 秒后再试", remain) - } - } - - n, err := rand.Int(rand.Reader, big.NewInt(1000000)) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("验证码生成失败,请稍后再试") - } - code := fmt.Sprintf("%06d", n.Int64()) - - item.code = code - item.codeExpire = now.Add(passwordResetCodeTTL) - item.lastSentAt = now - item.resetToken = "" - item.tokenExpire = time.Time{} - - return &dto.PasswordResetSendSMSResponse{ - NextSendSeconds: int(passwordResetSendGap.Seconds()), - Code: code, - }, nil -} - -// PasswordResetVerify 找回密码:校验短信验证码。 -// -// @Summary 找回密码-校验验证码 -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.PasswordResetVerifyForm true "form" -// @Success 200 {object} dto.PasswordResetVerifyResponse "成功" -// @Router /v1/auth/password/reset/verify [post] -// @Bind form body -func (ctl *auth) passwordResetVerify(ctx fiber.Ctx, form *dto.PasswordResetVerifyForm) (*dto.PasswordResetVerifyResponse, error) { - phone := strings.TrimSpace(form.Phone) - if phone == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入手机号") - } - if !rePhone.MatchString(phone) { - return nil, errorx.ErrInvalidParameter.WithMsg("手机号格式不正确") - } - code := strings.TrimSpace(form.Code) - if code == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入验证码") - } - - passwordResetStore.mu.Lock() - defer passwordResetStore.mu.Unlock() - - now := time.Now() - item := passwordResetStore.phoneToItem[phone] - if item == nil || item.code == "" || item.codeExpire.IsZero() || now.After(item.codeExpire) { - return nil, errorx.ErrPreconditionFailed.WithMsg("验证码已过期,请重新获取") - } - if item.code != code { - return nil, errorx.ErrInvalidParameter.WithMsg("验证码错误,请重新输入") - } - - // 创建一次性重置令牌,并清理验证码,避免复用。 - resetToken := uuid.NewString() - item.resetToken = resetToken - item.tokenExpire = now.Add(passwordResetTokenTTL) - item.code = "" - item.codeExpire = time.Time{} - - passwordResetStore.tokenToPhone[resetToken] = phone - - return &dto.PasswordResetVerifyResponse{ResetToken: resetToken}, nil -} - -// PasswordReset 找回密码:重置密码。 -// -// @Summary 找回密码-重置密码 -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.PasswordResetForm true "form" -// @Success 200 {object} dto.PasswordResetResponse "成功" -// @Router /v1/auth/password/reset [post] -// @Bind form body -func (ctl *auth) passwordReset(ctx fiber.Ctx, form *dto.PasswordResetForm) (*dto.PasswordResetResponse, error) { - resetToken := strings.TrimSpace(form.ResetToken) - if resetToken == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请先完成验证码校验") - } - if form.Password == "" || form.ConfirmPassword == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入新密码并确认") - } - if len(form.Password) < 8 { - return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") - } - if form.Password != form.ConfirmPassword { - return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") - } - - passwordResetStore.mu.Lock() - phone, ok := passwordResetStore.tokenToPhone[resetToken] - item := passwordResetStore.phoneToItem[phone] - now := time.Now() - if !ok || phone == "" || item == nil || item.resetToken != resetToken || item.tokenExpire.IsZero() || now.After(item.tokenExpire) { - passwordResetStore.mu.Unlock() - return nil, errorx.ErrTokenInvalid.WithMsg("重置会话已失效,请重新获取验证码") - } - // 令牌一次性使用 - delete(passwordResetStore.tokenToPhone, resetToken) - item.resetToken = "" - item.tokenExpire = time.Time{} - passwordResetStore.mu.Unlock() - - // 当前版本将手机号视为用户名。 - if _, err := services.User.FindByUsername(ctx, phone); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("该手机号尚未注册") - } - return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") - } - - if err := services.User.ResetPasswordByUsername(ctx, phone, form.Password); err != nil { - return nil, errorx.Wrap(err).WithMsg("重置密码失败,请稍后再试") - } - - return &dto.PasswordResetResponse{Ok: true}, nil -} - -// Register 用户注册(平台侧,非超级管理员)。 -// -// @Summary 用户注册 -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.RegisterForm true "form" -// @Success 200 {object} dto.LoginResponse "成功" -// @Router /v1/auth/register [post] -// @Bind form body -func (ctl *auth) register(ctx fiber.Ctx, form *dto.RegisterForm) (*dto.LoginResponse, error) { - username := strings.TrimSpace(form.Username) - if username == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入用户名") - } - if !reUsername.MatchString(username) { - return nil, errorx.ErrInvalidParameter.WithMsg("用户名需为 3-32 位字母/数字/下划线") - } - if form.Password == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请输入密码") - } - if len(form.Password) < 8 { - return nil, errorx.ErrParameterTooShort.WithMsg("密码至少 8 位,请设置更安全的密码") - } - if form.Password != form.ConfirmPassword { - return nil, errorx.ErrInvalidParameter.WithMsg("两次输入的密码不一致,请重新确认") - } - - // 先查询用户名是否已存在,避免直接插入导致不友好的数据库错误信息。 - _, err := services.User.FindByUsername(ctx, username) - if err == nil { - return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.Wrap(err).WithMsg("用户信息校验失败,请稍后再试") - } - - m := &models.User{ - Username: username, - Password: form.Password, - Roles: []consts.Role{consts.RoleUser}, - Status: consts.UserStatusVerified, - } - if _, err := services.User.Create(ctx, m); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, errorx.ErrRecordDuplicated.WithMsg("用户名已被占用,换一个试试") - } - return nil, errorx.Wrap(err).WithMsg("注册失败,请稍后再试") - } - - token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{UserID: m.ID})) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") - } - - return &dto.LoginResponse{Token: token}, nil -} - -// Token 刷新登录凭证。 -// -// @Summary 刷新 Token -// @Tags Web -// @Accept json -// @Produce json -// @Success 200 {object} dto.LoginResponse "成功" -// @Router /v1/auth/token [get] -func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ - UserID: claims.UserID, - })) - if err != nil { - return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") - } - - return &dto.LoginResponse{Token: token}, nil -} diff --git a/backend/app/http/web/me.go b/backend/app/http/web/me.go deleted file mode 100644 index 628a210..0000000 --- a/backend/app/http/web/me.go +++ /dev/null @@ -1,61 +0,0 @@ -package web - -import ( - "quyun/v2/app/errorx" - "quyun/v2/app/http/web/dto" - "quyun/v2/app/services" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -// @provider -type me struct{} - -// Me 获取当前登录用户信息(脱敏)。 -// -// @Summary 当前用户信息 -// @Tags Web -// @Accept json -// @Produce json -// @Success 200 {object} dto.MeResponse "成功" -// @Router /v1/me [get] -func (ctl *me) me(ctx fiber.Ctx) (*dto.MeResponse, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - m, err := services.User.FindByID(ctx, claims.UserID) - if err != nil { - return nil, err - } - - return &dto.MeResponse{ - ID: m.ID, - Username: m.Username, - Roles: m.Roles, - Status: m.Status, - StatusDescription: m.Status.Description(), - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - }, nil -} - -// MyTenants 获取当前用户可进入的租户列表。 -// -// @Summary 我的租户列表 -// @Tags Web -// @Accept json -// @Produce json -// @Success 200 {array} dto.MyTenantItem "成功" -// @Router /v1/me/tenants [get] -func (ctl *me) myTenants(ctx fiber.Ctx) ([]*dto.MyTenantItem, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - return services.Tenant.UserTenants(ctx, claims.UserID) -} diff --git a/backend/app/http/web/provider.gen.go b/backend/app/http/web/provider.gen.go index e869726..72f2948 100755 --- a/backend/app/http/web/provider.gen.go +++ b/backend/app/http/web/provider.gen.go @@ -1,60 +1,9 @@ package web import ( - "quyun/v2/app/middlewares" - "quyun/v2/providers/jwt" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" "go.ipao.vip/atom/opt" ) func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func( - jwt *jwt.JWT, - ) (*auth, error) { - obj := &auth{ - jwt: jwt, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*me, error) { - obj := &me{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - auth *auth, - me *me, - middlewares *middlewares.Middlewares, - tenantApply *tenantApply, - ) (contracts.HttpRoute, error) { - obj := &Routes{ - auth: auth, - me: me, - middlewares: middlewares, - tenantApply: tenantApply, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupRoutes); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenantApply, error) { - obj := &tenantApply{} - - return obj, nil - }); err != nil { - return err - } return nil } diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go deleted file mode 100644 index 49f8840..0000000 --- a/backend/app/http/web/routes.gen.go +++ /dev/null @@ -1,97 +0,0 @@ -// Code generated by atomctl. DO NOT EDIT. - -// Package web provides HTTP route definitions and registration -// for the quyun/v2 application. -package web - -import ( - "quyun/v2/app/http/web/dto" - "quyun/v2/app/middlewares" - - "github.com/gofiber/fiber/v3" - log "github.com/sirupsen/logrus" - _ "go.ipao.vip/atom" - _ "go.ipao.vip/atom/contracts" - . "go.ipao.vip/atom/fen" -) - -// Routes implements the HttpRoute contract and provides route registration -// for all controllers in the web module. -// -// @provider contracts.HttpRoute atom.GroupRoutes -type Routes struct { - log *log.Entry `inject:"false"` - middlewares *middlewares.Middlewares - // Controller instances - auth *auth - me *me - tenantApply *tenantApply -} - -// Prepare initializes the routes provider with logging configuration. -func (r *Routes) Prepare() error { - r.log = log.WithField("module", "routes.web") - r.log.Info("Initializing routes module") - return nil -} - -// Name returns the unique identifier for this routes provider. -func (r *Routes) Name() string { - return "web" -} - -// Register registers all HTTP routes with the provided fiber router. -// Each route is registered with its corresponding controller action and parameter bindings. -func (r *Routes) Register(router fiber.Router) { - // Register routes for controller: auth - r.log.Debugf("Registering route: Get /v1/auth/token -> auth.token") - router.Get("/v1/auth/token"[len(r.Path()):], DataFunc0( - r.auth.token, - )) - r.log.Debugf("Registering route: Post /v1/auth/login -> auth.login") - router.Post("/v1/auth/login"[len(r.Path()):], DataFunc1( - r.auth.login, - Body[dto.LoginForm]("form"), - )) - r.log.Debugf("Registering route: Post /v1/auth/password/reset -> auth.passwordReset") - router.Post("/v1/auth/password/reset"[len(r.Path()):], DataFunc1( - r.auth.passwordReset, - Body[dto.PasswordResetForm]("form"), - )) - r.log.Debugf("Registering route: Post /v1/auth/password/reset/sms -> auth.passwordResetSendSMS") - router.Post("/v1/auth/password/reset/sms"[len(r.Path()):], DataFunc1( - r.auth.passwordResetSendSMS, - Body[dto.PasswordResetSendSMSForm]("form"), - )) - r.log.Debugf("Registering route: Post /v1/auth/password/reset/verify -> auth.passwordResetVerify") - router.Post("/v1/auth/password/reset/verify"[len(r.Path()):], DataFunc1( - r.auth.passwordResetVerify, - Body[dto.PasswordResetVerifyForm]("form"), - )) - r.log.Debugf("Registering route: Post /v1/auth/register -> auth.register") - router.Post("/v1/auth/register"[len(r.Path()):], DataFunc1( - r.auth.register, - Body[dto.RegisterForm]("form"), - )) - // Register routes for controller: me - r.log.Debugf("Registering route: Get /v1/me -> me.me") - router.Get("/v1/me"[len(r.Path()):], DataFunc0( - r.me.me, - )) - r.log.Debugf("Registering route: Get /v1/me/tenants -> me.myTenants") - router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0( - r.me.myTenants, - )) - // Register routes for controller: tenantApply - r.log.Debugf("Registering route: Get /v1/tenant/application -> tenantApply.application") - router.Get("/v1/tenant/application"[len(r.Path()):], DataFunc0( - r.tenantApply.application, - )) - r.log.Debugf("Registering route: Post /v1/tenant/apply -> tenantApply.apply") - router.Post("/v1/tenant/apply"[len(r.Path()):], DataFunc1( - r.tenantApply.apply, - Body[dto.TenantApplyForm]("form"), - )) - - r.log.Info("Successfully registered all routes") -} diff --git a/backend/app/http/web/routes.manual.go b/backend/app/http/web/routes.manual.go deleted file mode 100644 index ec69362..0000000 --- a/backend/app/http/web/routes.manual.go +++ /dev/null @@ -1,11 +0,0 @@ -package web - -func (r *Routes) Path() string { - return "/v1" -} - -func (r *Routes) Middlewares() []any { - return []any{ - r.middlewares.UserAuth, - } -} diff --git a/backend/app/http/web/tenant_apply.go b/backend/app/http/web/tenant_apply.go deleted file mode 100644 index 92787b4..0000000 --- a/backend/app/http/web/tenant_apply.go +++ /dev/null @@ -1,118 +0,0 @@ -package web - -import ( - "errors" - "regexp" - "strings" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/web/dto" - "quyun/v2/app/services" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" - "gorm.io/gorm" -) - -// @provider -type tenantApply struct{} - -var reTenantCode = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,63}$`) - -// Application 获取当前用户的租户申请信息(申请创作者)。 -// -// @Summary 获取租户申请信息(申请创作者) -// @Tags Web -// @Accept json -// @Produce json -// @Success 200 {object} dto.TenantApplicationResponse "成功" -// @Router /v1/tenant/application [get] -func (ctl *tenantApply) application(ctx fiber.Ctx) (*dto.TenantApplicationResponse, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - m, err := services.Tenant.FindOwnedByUserID(ctx, claims.UserID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return &dto.TenantApplicationResponse{HasApplication: false}, nil - } - return nil, errorx.Wrap(err).WithMsg("查询申请信息失败,请稍后再试") - } - - return &dto.TenantApplicationResponse{ - HasApplication: true, - TenantID: m.ID, - TenantCode: m.Code, - TenantName: m.Name, - Status: m.Status, - StatusDescription: m.Status.Description(), - CreatedAt: m.CreatedAt, - }, nil -} - -// Apply 申请创作者(创建租户申请)。 -// -// @Summary 提交租户申请(申请创作者) -// @Tags Web -// @Accept json -// @Produce json -// @Param form body dto.TenantApplyForm true "form" -// @Success 200 {object} dto.TenantApplicationResponse "成功" -// @Router /v1/tenant/apply [post] -// @Bind form body -func (ctl *tenantApply) apply(ctx fiber.Ctx, form *dto.TenantApplyForm) (*dto.TenantApplicationResponse, error) { - claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil || claims.UserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - - code := strings.ToLower(strings.TrimSpace(form.Code)) - if code == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请填写租户 ID") - } - if !reTenantCode.MatchString(code) { - return nil, errorx.ErrInvalidParameter.WithMsg("租户 ID 需为 3-64 位小写字母/数字/下划线/短横线,且以字母或数字开头") - } - name := strings.TrimSpace(form.Name) - if name == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请填写租户名称") - } - - // 一个用户仅可申请为一个租户创作者:若已存在 owned tenant,直接返回当前申请信息。 - existing, err := services.Tenant.FindOwnedByUserID(ctx, claims.UserID) - if err == nil && existing != nil && existing.ID > 0 { - return &dto.TenantApplicationResponse{ - HasApplication: true, - TenantID: existing.ID, - TenantCode: existing.Code, - TenantName: existing.Name, - Status: existing.Status, - StatusDescription: existing.Status.Description(), - CreatedAt: existing.CreatedAt, - }, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.Wrap(err).WithMsg("申请校验失败,请稍后再试") - } - - tenant, err := services.Tenant.ApplyOwnedTenant(ctx, claims.UserID, code, name) - if err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, errorx.ErrRecordDuplicated.WithMsg("租户 ID 已被占用,请换一个试试") - } - return nil, errorx.Wrap(err).WithMsg("提交申请失败,请稍后再试") - } - - return &dto.TenantApplicationResponse{ - HasApplication: true, - TenantID: tenant.ID, - TenantCode: tenant.Code, - TenantName: tenant.Name, - Status: tenant.Status, - StatusDescription: tenant.Status.Description(), - CreatedAt: tenant.CreatedAt, - }, nil -} diff --git a/backend/app/jobs/demo_job_test.go b/backend/app/jobs/demo_job_test.go deleted file mode 100644 index 893ef88..0000000 --- a/backend/app/jobs/demo_job_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build legacytests -// +build legacytests - -package jobs - -import ( - "context" - "testing" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/services" - - . "github.com/riverqueue/river" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.uber.org/dig" -) - -type DemoJobSuiteInjectParams struct { - dig.In - - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type DemoJobSuite struct { - suite.Suite - - DemoJobSuiteInjectParams -} - -func Test_DemoJob(t *testing.T) { - providers := testx.Default().With(Provide, services.Provide) - - testx.Serve(providers, t, func(p DemoJobSuiteInjectParams) { - suite.Run(t, &DemoJobSuite{DemoJobSuiteInjectParams: p}) - }) -} - -func (t *DemoJobSuite) Test_Work() { - Convey("test_work", t.T(), func() { - Convey("step 1", func() { - job := &Job[DemoJob]{ - Args: DemoJob{ - Strings: []string{"a", "b", "c"}, - }, - } - - worker := &DemoJobWorker{} - - err := worker.Work(context.Background(), job) - So(err, ShouldBeNil) - }) - }) -} diff --git a/backend/app/jobs/media_asset_process.go b/backend/app/jobs/media_asset_process.go deleted file mode 100644 index da8582f..0000000 --- a/backend/app/jobs/media_asset_process.go +++ /dev/null @@ -1,58 +0,0 @@ -package jobs - -import ( - "context" - "time" - - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/app/services" - - . "github.com/riverqueue/river" - log "github.com/sirupsen/logrus" -) - -var _ Worker[jobs_args.MediaAssetProcessJob] = (*MediaAssetProcessJobWorker)(nil) - -// MediaAssetProcessJobWorker 负责执行媒体资源处理的异步处理(占位实现)。 -// -// 当前实现为 stub:不对接外部转码/截图服务,仅回写 meta 并将状态置为 ready。 -// -// @provider(job) -type MediaAssetProcessJobWorker struct { - WorkerDefaults[jobs_args.MediaAssetProcessJob] -} - -func (w *MediaAssetProcessJobWorker) Work(ctx context.Context, job *Job[jobs_args.MediaAssetProcessJob]) error { - args := job.Args - - attempt := 0 - if job != nil && job.JobRow != nil { - attempt = job.Attempt - } - - logger := log.WithFields(log.Fields{ - "job_kind": args.Kind(), - "tenant_id": args.TenantID, - "asset_id": args.AssetID, - "attempt": attempt, - }) - - logger.Info("jobs.media_asset_process.start") - - _, err := services.MediaAsset.ProcessSuccess(ctx, args.TenantID, args.AssetID, map[string]any{ - "process_pipeline": "stub", - "worker_attempt": attempt, - "worker_ran_at": time.Now().UTC().Format(time.RFC3339Nano), - }, time.Now().UTC()) - if err != nil { - if services.IsMediaAssetProcessJobNonRetryableError(err) { - logger.WithError(err).Warn("jobs.media_asset_process.cancel") - return JobCancel(err) - } - logger.WithError(err).Warn("jobs.media_asset_process.retry") - return err - } - - logger.Info("jobs.media_asset_process.ok") - return nil -} diff --git a/backend/app/jobs/media_asset_process_test.go b/backend/app/jobs/media_asset_process_test.go deleted file mode 100644 index a880ee5..0000000 --- a/backend/app/jobs/media_asset_process_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package jobs - -import ( - "database/sql" - "testing" - "time" - - "quyun/v2/app/commands/testx" - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/app/services" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/riverqueue/river" - "github.com/riverqueue/river/rivertype" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type MediaAssetProcessJobSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type MediaAssetProcessJobSuite struct { - suite.Suite - MediaAssetProcessJobSuiteInjectParams -} - -func Test_MediaAssetProcessJob(t *testing.T) { - // 注意:testx.Default() 已内置一个测试用的 job worker 注册器,避免和 jobs.Provide 重复注册同 kind worker。 - providers := testx.Default().With(services.Provide) - - testx.Serve(providers, t, func(p MediaAssetProcessJobSuiteInjectParams) { - suite.Run(t, &MediaAssetProcessJobSuite{MediaAssetProcessJobSuiteInjectParams: p}) - }) -} - -func (s *MediaAssetProcessJobSuite) Test_Work_ProcessingToReady() { - Convey("MediaAssetProcessJobWorker processing -> ready", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameMediaAsset) - - asset := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusProcessing, - Provider: "test", - Bucket: "b", - ObjectKey: "k", - Meta: types.JSON([]byte("{}")), - CreatedAt: now, - UpdatedAt: now, - } - So(asset.Create(ctx), ShouldBeNil) - - worker := &MediaAssetProcessJobWorker{} - err := worker.Work(ctx, &river.Job[jobs_args.MediaAssetProcessJob]{ - JobRow: &rivertype.JobRow{Attempt: 1}, - Args: jobs_args.MediaAssetProcessJob{TenantID: tenantID, AssetID: asset.ID}, - }) - So(err, ShouldBeNil) - - tbl, query := models.MediaAssetQuery.QueryContext(ctx) - got, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(asset.ID), - ).First() - So(err, ShouldBeNil) - So(got.Status, ShouldEqual, consts.MediaAssetStatusReady) - }) -} diff --git a/backend/app/jobs/order_refund.go b/backend/app/jobs/order_refund.go deleted file mode 100644 index 8b364e5..0000000 --- a/backend/app/jobs/order_refund.go +++ /dev/null @@ -1,62 +0,0 @@ -package jobs - -import ( - "context" - "time" - - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/app/services" - - . "github.com/riverqueue/river" - log "github.com/sirupsen/logrus" -) - -var _ Worker[jobs_args.OrderRefundJob] = (*OrderRefundJobWorker)(nil) - -// OrderRefundJobWorker 负责执行订单退款的异步处理。 -// -// @provider(job) -type OrderRefundJobWorker struct { - WorkerDefaults[jobs_args.OrderRefundJob] -} - -func (w *OrderRefundJobWorker) Work(ctx context.Context, job *Job[jobs_args.OrderRefundJob]) error { - args := job.Args - - logger := log.WithFields(log.Fields{ - "job_kind": args.Kind(), - "tenant_id": args.TenantID, - "order_id": args.OrderID, - "operator_user_id": args.OperatorUserID, - "force": args.Force, - "attempt": job.Attempt, - }) - - // 只允许在异步 worker 层做执行,不要在这里再次入队其他任务(避免耦合与递归依赖)。 - logger.Info("jobs.order_refund.start") - - _, err := services.Order.ProcessRefundingOrder(ctx, &services.ProcessRefundingOrderParams{ - TenantID: args.TenantID, - OrderID: args.OrderID, - OperatorUserID: args.OperatorUserID, - Force: args.Force, - Reason: args.Reason, - Now: time.Now().UTC(), - }) - if err != nil { - // 业务层会返回可识别的“不可重试”错误:由它内部完成状态落库(failed)后,这里直接 cancel。 - if services.IsRefundJobNonRetryableError(err) { - // best-effort:将订单标记为 failed,便于管理员重新发起退款(paid/failed -> refunding)。 - if markErr := services.Order.MarkRefundFailed(ctx, args.TenantID, args.OrderID, time.Now().UTC()); markErr != nil { - logger.WithError(markErr).Warn("jobs.order_refund.mark_failed_failed") - } - logger.WithError(err).Warn("jobs.order_refund.cancel") - return JobCancel(err) - } - logger.WithError(err).Warn("jobs.order_refund.retry") - return err - } - - logger.Info("jobs.order_refund.ok") - return nil -} diff --git a/backend/app/jobs/provider.gen.go b/backend/app/jobs/provider.gen.go index 474a4fc..81103cd 100755 --- a/backend/app/jobs/provider.gen.go +++ b/backend/app/jobs/provider.gen.go @@ -1,39 +1,9 @@ package jobs import ( - "quyun/v2/providers/job" - - "github.com/riverqueue/river" - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" "go.ipao.vip/atom/opt" ) func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func( - __job *job.Job, - ) (contracts.Initial, error) { - obj := &MediaAssetProcessJobWorker{} - if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupInitial); err != nil { - return err - } - if err := container.Container.Provide(func( - __job *job.Job, - ) (contracts.Initial, error) { - obj := &OrderRefundJobWorker{} - if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupInitial); err != nil { - return err - } return nil } diff --git a/backend/app/middlewares/mid_debug.go b/backend/app/middlewares/mid_debug.go deleted file mode 100644 index ecb33af..0000000 --- a/backend/app/middlewares/mid_debug.go +++ /dev/null @@ -1,9 +0,0 @@ -package middlewares - -import ( - "github.com/gofiber/fiber/v3" -) - -func (f *Middlewares) DebugMode(c fiber.Ctx) error { - return c.Next() -} diff --git a/backend/app/middlewares/super.go b/backend/app/middlewares/super.go deleted file mode 100644 index 869a4f1..0000000 --- a/backend/app/middlewares/super.go +++ /dev/null @@ -1,68 +0,0 @@ -package middlewares - -import ( - "strings" - - "quyun/v2/app/errorx" - "quyun/v2/app/services" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -func shouldSkipSuperJWTAuth(path string) bool { - // 登录接口允许匿名访问。 - return strings.Contains(path, "/super/v1/auth/login") -} - -// SuperAuth 平台侧超级管理员鉴权: -// - 校验 JWT 并写入 claims -// - 加载用户并校验包含 super_admin 角色 -func (f *Middlewares) SuperAuth(c fiber.Ctx) error { - if shouldSkipSuperJWTAuth(c.Path()) { - f.log.Debug("middlewares.super.auth.skipped") - return c.Next() - } - - authHeader := c.Get(jwt.HttpHeader) - if authHeader == "" { - f.log.Info("middlewares.super.auth.missing_token") - return errorx.ErrTokenMissing - } - - claims, err := f.jwt.Parse(authHeader) - if err != nil { - f.log.WithError(err).Warn("middlewares.super.auth.invalid_token") - switch err { - case jwt.TokenExpired: - return errorx.ErrTokenExpired - case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: - return errorx.ErrTokenInvalid - default: - return errorx.ErrTokenInvalid - } - } - if claims.UserID == 0 { - f.log.Warn("middlewares.super.auth.missing_user_id") - return errorx.ErrTokenInvalid - } - - userModel, err := services.User.FindByID(c, claims.UserID) - if err != nil { - f.log.WithField("user_id", claims.UserID).WithError(err).Warn("middlewares.super.auth.load_user_failed") - return err - } - if !userModel.Roles.Contains(consts.RoleSuperAdmin) { - f.log.WithField("user_id", claims.UserID).Warn("middlewares.super.auth.denied") - return errorx.ErrPermissionDenied.WithMsg("需要超级管理员权限") - } - - f.log.WithFields(map[string]any{ - "user_id": claims.UserID, - }).Info("middlewares.super.auth.ok") - - c.Locals(consts.CtxKeyClaims, claims) - c.Locals(consts.CtxKeyUser, userModel) - return c.Next() -} diff --git a/backend/app/middlewares/tenant.go b/backend/app/middlewares/tenant.go deleted file mode 100644 index ec2cadb..0000000 --- a/backend/app/middlewares/tenant.go +++ /dev/null @@ -1,176 +0,0 @@ -package middlewares - -import ( - "strings" - - "quyun/v2/app/errorx" - "quyun/v2/app/services" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -func shouldSkipTenantJWTAuth(path string) bool { - // Public read endpoints allow anonymous access (optional JWT). - if strings.Contains(path, "/v1/public/") { - return true - } - // Media play is token-based, no JWT required. - if strings.Contains(path, "/v1/media/play") { - return true - } - return false -} - -func shouldSkipTenantRequireMember(path string) bool { - // Public read endpoints allow anonymous access. - if strings.Contains(path, "/v1/public/") { - return true - } - // Join endpoints require JWT but not tenant membership. - if strings.Contains(path, "/v1/join/") { - return true - } - // Media play is token-based, no JWT required. - if strings.Contains(path, "/v1/media/play") { - return true - } - return false -} - -func (f *Middlewares) TenantResolve(c fiber.Ctx) error { - tenantCode := c.Params("tenantCode") - if tenantCode == "" { - return errorx.ErrMissingParameter.WithMsg("缺少 tenantCode") - } - - tenantModel, err := services.Tenant.FindByCode(c, tenantCode) - if err != nil { - f.log.WithField("tenant_code", tenantCode).WithError(err).Warn("middlewares.tenant.resolve.failed") - return err - } - - f.log.WithFields(map[string]any{ - "tenant_id": tenantModel.ID, - "tenant_code": tenantCode, - }).Info("middlewares.tenant.resolve.ok") - - c.Locals(consts.CtxKeyTenant, tenantModel) - return c.Next() -} - -func (f *Middlewares) TenantAuth(c fiber.Ctx) error { - if shouldSkipTenantJWTAuth(c.Path()) { - f.log.Debug("middlewares.tenant.auth.skipped") - return c.Next() - } - - authHeader := c.Get(jwt.HttpHeader) - if authHeader == "" { - f.log.Info("middlewares.tenant.auth.missing_token") - return errorx.ErrTokenMissing - } - - claims, err := f.jwt.Parse(authHeader) - if err != nil { - f.log.WithError(err).Warn("middlewares.tenant.auth.invalid_token") - switch err { - case jwt.TokenExpired: - return errorx.ErrTokenExpired - case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: - return errorx.ErrTokenInvalid - default: - return errorx.ErrTokenInvalid - } - } - if claims.UserID == 0 { - f.log.Warn("middlewares.tenant.auth.missing_user_id") - return errorx.ErrTokenInvalid - } - - f.log.WithFields(map[string]any{ - "user_id": claims.UserID, - }).Info("middlewares.tenant.auth.ok") - - c.Locals(consts.CtxKeyClaims, claims) - return c.Next() -} - -// TenantOptionalAuth 在 token 存在时解析并写入 claims,但允许无 token 的请求继续。 -// 用于“公开只读”类接口:可匿名访问,但若携带 token 则可以得到更准确的 has_access 等判断。 -func (f *Middlewares) TenantOptionalAuth(c fiber.Ctx) error { - authHeader := c.Get(jwt.HttpHeader) - if authHeader == "" { - f.log.Debug("middlewares.tenant.optional_auth.no_token") - return c.Next() - } - - claims, err := f.jwt.Parse(authHeader) - if err != nil { - f.log.WithError(err).Warn("middlewares.tenant.optional_auth.invalid_token") - switch err { - case jwt.TokenExpired: - return errorx.ErrTokenExpired - case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: - return errorx.ErrTokenInvalid - default: - return errorx.ErrTokenInvalid - } - } - if claims.UserID == 0 { - f.log.Warn("middlewares.tenant.optional_auth.missing_user_id") - return errorx.ErrTokenInvalid - } - - f.log.WithFields(map[string]any{ - "user_id": claims.UserID, - }).Debug("middlewares.tenant.optional_auth.ok") - - c.Locals(consts.CtxKeyClaims, claims) - return c.Next() -} - -func (f *Middlewares) TenantRequireMember(c fiber.Ctx) error { - if shouldSkipTenantRequireMember(c.Path()) { - f.log.Debug("middlewares.tenant.require_member.skipped") - return c.Next() - } - - tenantModel, ok := c.Locals(consts.CtxKeyTenant).(*models.Tenant) - if !ok || tenantModel == nil { - f.log.Error("middlewares.tenant.require_member.missing_tenant_context") - return errorx.ErrInternalError.WithMsg("tenant context missing") - } - - claims, ok := c.Locals(consts.CtxKeyClaims).(*jwt.Claims) - if !ok || claims == nil { - f.log.Error("middlewares.tenant.require_member.missing_claims_context") - return errorx.ErrInternalError.WithMsg("claims context missing") - } - - tenantUser, err := services.Tenant.FindTenantUser(c, tenantModel.ID, claims.UserID) - if err != nil { - f.log.WithFields(map[string]any{ - "tenant_id": tenantModel.ID, - "user_id": claims.UserID, - }).WithError(err).Warn("middlewares.tenant.require_member.denied") - return errorx.ErrPermissionDenied.WithMsg("不属于该租户") - } - - userModel, err := services.User.FindByID(c, claims.UserID) - if err != nil { - f.log.WithField("user_id", claims.UserID).WithError(err).Warn("middlewares.tenant.require_member.load_user_failed") - return err - } - - f.log.WithFields(map[string]any{ - "tenant_id": tenantModel.ID, - "user_id": claims.UserID, - }).Info("middlewares.tenant.require_member.ok") - - c.Locals(consts.CtxKeyTenantUser, tenantUser) - c.Locals(consts.CtxKeyUser, userModel) - return c.Next() -} diff --git a/backend/app/middlewares/user.go b/backend/app/middlewares/user.go deleted file mode 100644 index c5c97a4..0000000 --- a/backend/app/middlewares/user.go +++ /dev/null @@ -1,64 +0,0 @@ -package middlewares - -import ( - "strings" - - "quyun/v2/app/errorx" - "quyun/v2/pkg/consts" - "quyun/v2/providers/jwt" - - "github.com/gofiber/fiber/v3" -) - -func shouldSkipUserJWTAuth(path, method string) bool { - // 仅对明确的公开接口放行,避免误伤其它路径。 - if method != fiber.MethodPost { - return false - } - - p := strings.TrimSuffix(path, "/") - switch p { - case "/v1/auth/login", "/v1/auth/register", "/v1/auth/password/reset/sms", "/v1/auth/password/reset/verify", "/v1/auth/password/reset": - return true - default: - return false - } -} - -// UserAuth 为平台通用(非租户域)接口提供 JWT 校验,并写入 claims 到 ctx locals。 -func (f *Middlewares) UserAuth(c fiber.Ctx) error { - if shouldSkipUserJWTAuth(c.Path(), c.Method()) { - f.log.Debug("middlewares.user.auth.skipped") - return c.Next() - } - - authHeader := c.Get(jwt.HttpHeader) - if authHeader == "" { - f.log.Info("middlewares.user.auth.missing_token") - return errorx.ErrTokenMissing - } - - claims, err := f.jwt.Parse(authHeader) - if err != nil { - f.log.WithError(err).Warn("middlewares.user.auth.invalid_token") - switch err { - case jwt.TokenExpired: - return errorx.ErrTokenExpired - case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: - return errorx.ErrTokenInvalid - default: - return errorx.ErrTokenInvalid - } - } - if claims.UserID == 0 { - f.log.Warn("middlewares.user.auth.missing_user_id") - return errorx.ErrTokenInvalid - } - - f.log.WithFields(map[string]any{ - "user_id": claims.UserID, - }).Info("middlewares.user.auth.ok") - - c.Locals(consts.CtxKeyClaims, claims) - return c.Next() -} diff --git a/backend/app/services/content.go b/backend/app/services/content.go deleted file mode 100644 index b9fcd8b..0000000 --- a/backend/app/services/content.go +++ /dev/null @@ -1,892 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - pkgerrors "github.com/pkg/errors" - "github.com/samber/lo" - log "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/types" - "gorm.io/gorm" -) - -// content 实现内容域相关的业务能力(创建/更新/定价/授权等)。 -// -// @provider -type content struct{} - -// ContentDetailResult 为内容详情的内部结果(供 controller 组合返回)。 -type ContentDetailResult struct { - // Content 内容实体。 - Content *models.Content - // Price 定价信息(可能为 nil,表示未设置价格)。 - Price *models.ContentPrice - // HasAccess 当前用户是否拥有主资源访问权限。 - HasAccess bool -} - -// ContentPublishResult 为“内容发布(创建+绑定资源+定价)”的内部结果。 -type ContentPublishResult struct { - // Content 内容主体。 - Content *models.Content - // Price 定价信息。 - Price *models.ContentPrice - // CoverAssets 封面图绑定结果(role=cover)。 - CoverAssets []*models.ContentAsset - // MainAssets 主资源绑定结果(role=main)。 - MainAssets []*models.ContentAsset - // ContentTypes 内容类型列表:text/audio/video/image/multi_image(用于前端展示)。 - ContentTypes []string -} - -func requiredMediaAssetVariantForRole(role consts.ContentAssetRole) consts.MediaAssetVariant { - switch role { - case consts.ContentAssetRolePreview: - return consts.MediaAssetVariantPreview - default: - // main/cover 一律要求 main 产物,避免误把 preview 绑定成正片/封面。 - return consts.MediaAssetVariantMain - } -} - -func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.ContentCreateForm) (*models.Content, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - }).Info("services.content.create") - - // 关键默认值:未传可见性时默认“租户内可见”。 - visibility := form.Visibility - if visibility == "" { - visibility = consts.ContentVisibilityTenantOnly - } - - // 试看策略:默认固定时长;并强制不允许下载。 - previewSeconds := consts.DefaultContentPreviewSeconds - if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { - previewSeconds = *form.PreviewSeconds - } - - m := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: form.Title, - Description: form.Description, - Status: consts.ContentStatusDraft, - Visibility: visibility, - PreviewSeconds: previewSeconds, - PreviewDownloadable: false, - } - if err := m.Create(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "create content failed") - } - return m, nil -} - -// Publish 租户管理员发布内容(创建内容 + 绑定封面/主资源 + 定价)。 -// 说明:此接口面向“创作者/租户管理员”的内容发布场景,支持多种内容类型组合存在。 -func (s *content) Publish(ctx context.Context, tenantID, userID int64, form *dto.ContentPublishForm) (*ContentPublishResult, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") - } - if form == nil { - return nil, errorx.ErrMissingParameter.WithMsg("form is required") - } - - title := strings.TrimSpace(form.Title) - if title == "" { - return nil, errorx.ErrMissingParameter.WithMsg("请填写标题") - } - summary := strings.TrimSpace(form.Summary) - if len([]rune(summary)) > 256 { - return nil, errorx.ErrInvalidParameter.WithMsg("简介过长(建议不超过 256 字符)") - } - detail := strings.TrimSpace(form.Detail) - - if len(form.CoverAssetIDs) < 1 || len(form.CoverAssetIDs) > 3 { - return nil, errorx.ErrInvalidParameter.WithMsg("展示图需为 1-3 张") - } - - hasText := detail != "" - hasAudio := len(form.AudioAssetIDs) > 0 - hasVideo := len(form.VideoAssetIDs) > 0 - hasImage := len(form.ImageAssetIDs) > 0 - if !hasText && !hasAudio && !hasVideo && !hasImage { - return nil, errorx.ErrInvalidParameter.WithMsg("请至少提供一种内容类型(文字/音频/视频/多图)") - } - - visibility := form.Visibility - if visibility == "" { - visibility = consts.ContentVisibilityTenantOnly - } - - previewSeconds := consts.DefaultContentPreviewSeconds - if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { - previewSeconds = *form.PreviewSeconds - } - - currency := form.Currency - if currency == "" { - currency = consts.CurrencyCNY - } - if form.PriceAmount < 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("价格不合法(需为 0 或正整数)") - } - - // 标签:trim + 去重;限制数量与长度,避免滥用导致索引/存储膨胀。 - tags := make([]string, 0, len(form.Tags)) - seenTag := map[string]struct{}{} - for _, raw := range form.Tags { - v := strings.TrimSpace(raw) - if v == "" { - continue - } - if len([]rune(v)) > 20 { - return nil, errorx.ErrInvalidParameter.WithMsg("标签过长(单个标签建议不超过 20 字符)") - } - if _, ok := seenTag[v]; ok { - continue - } - seenTag[v] = struct{}{} - tags = append(tags, v) - if len(tags) >= 20 { - return nil, errorx.ErrInvalidParameter.WithMsg("标签数量过多(建议不超过 20 个)") - } - } - tagBytes, _ := json.Marshal(tags) - if len(tagBytes) == 0 { - tagBytes = []byte("[]") - } - - // 资源去重与批量拉取。 - allAssetIDs := make([]int64, 0, len(form.CoverAssetIDs)+len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) - assetSeen := map[int64]struct{}{} - addIDs := func(ids []int64) error { - for _, id := range ids { - if id <= 0 { - return errorx.ErrInvalidParameter.WithMsg("资源ID不合法") - } - if _, ok := assetSeen[id]; ok { - return errorx.ErrInvalidParameter.WithMsg("同一资源不可重复绑定(封面/主资源之间也不可重复)") - } - assetSeen[id] = struct{}{} - allAssetIDs = append(allAssetIDs, id) - } - return nil - } - if err := addIDs(form.CoverAssetIDs); err != nil { - return nil, err - } - if err := addIDs(form.AudioAssetIDs); err != nil { - return nil, err - } - if err := addIDs(form.VideoAssetIDs); err != nil { - return nil, err - } - if err := addIDs(form.ImageAssetIDs); err != nil { - return nil, err - } - - out := &ContentPublishResult{} - - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "title": title, - "price": form.PriceAmount, - }).Info("services.content.publish") - - err := models.Q.Transaction(func(tx *models.Query) error { - // 1) 校验资源(必须属于租户、未删除、ready、variant=main) - assetTbl, assetQuery := tx.MediaAsset.QueryContext(ctx) - assets, err := assetQuery.Where( - assetTbl.TenantID.Eq(tenantID), - assetTbl.ID.In(allAssetIDs...), - assetTbl.DeletedAt.IsNull(), - ).Find() - if err != nil { - return err - } - assetMap := make(map[int64]*models.MediaAsset, len(assets)) - for _, a := range assets { - if a == nil { - continue - } - assetMap[a.ID] = a - } - for _, id := range allAssetIDs { - a := assetMap[id] - if a == nil { - return errorx.ErrRecordNotFound.WithMsg("资源不存在或无权限访问") - } - if a.Status != consts.MediaAssetStatusReady { - return errorx.ErrPreconditionFailed.WithMsg("存在未处理完成的资源,请稍后再试") - } - if a.Variant != consts.MediaAssetVariantMain { - return errorx.ErrInvalidParameter.WithMsg("资源产物类型不正确(需为正片 main)") - } - } - - // 2) 创建内容(默认进入审核中) - content := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: title, - Summary: summary, - Description: detail, - Tags: types.JSON(tagBytes), - Status: consts.ContentStatusReviewing, - Visibility: visibility, - PreviewSeconds: previewSeconds, - PreviewDownloadable: false, - } - if err := tx.Content.WithContext(ctx).Create(content); err != nil { - return err - } - - // 3) 创建定价(固定 CNY,折扣默认 none) - price := &models.ContentPrice{ - TenantID: tenantID, - UserID: userID, - ContentID: content.ID, - Currency: currency, - PriceAmount: form.PriceAmount, - DiscountType: consts.DiscountTypeNone, - DiscountValue: 0, - DiscountStartAt: time.Time{}, - DiscountEndAt: time.Time{}, - } - if err := tx.ContentPrice.WithContext(ctx).Create(price); err != nil { - return err - } - - // 4) 绑定封面图(role=cover) - coverAssets := make([]*models.ContentAsset, 0, len(form.CoverAssetIDs)) - for i, id := range form.CoverAssetIDs { - a := assetMap[id] - if a.Type != consts.MediaAssetTypeImage { - return errorx.ErrInvalidParameter.WithMsg("展示图必须为图片资源") - } - ca := &models.ContentAsset{ - TenantID: tenantID, - UserID: userID, - ContentID: content.ID, - AssetID: id, - Role: consts.ContentAssetRoleCover, - Sort: int32(i), - } - if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { - return err - } - coverAssets = append(coverAssets, ca) - } - - // 5) 绑定主资源(role=main;支持音频/视频/多图组合) - mainAssets := make([]*models.ContentAsset, 0, len(form.AudioAssetIDs)+len(form.VideoAssetIDs)+len(form.ImageAssetIDs)) - sort := int32(0) - attachMain := func(ids []int64, wantType consts.MediaAssetType) error { - for _, id := range ids { - a := assetMap[id] - if a.Type != wantType { - return errorx.ErrInvalidParameter.WithMsg("主资源类型与选择不匹配") - } - ca := &models.ContentAsset{ - TenantID: tenantID, - UserID: userID, - ContentID: content.ID, - AssetID: id, - Role: consts.ContentAssetRoleMain, - Sort: sort, - } - if err := tx.ContentAsset.WithContext(ctx).Create(ca); err != nil { - return err - } - mainAssets = append(mainAssets, ca) - sort++ - } - return nil - } - // 顺序:视频 -> 音频 -> 图片(多图) - if err := attachMain(form.VideoAssetIDs, consts.MediaAssetTypeVideo); err != nil { - return err - } - if err := attachMain(form.AudioAssetIDs, consts.MediaAssetTypeAudio); err != nil { - return err - } - if err := attachMain(form.ImageAssetIDs, consts.MediaAssetTypeImage); err != nil { - return err - } - - typesOut := make([]string, 0, 4) - if hasText { - typesOut = append(typesOut, "text") - } - if hasAudio { - typesOut = append(typesOut, "audio") - } - if hasVideo { - typesOut = append(typesOut, "video") - } - if hasImage { - if len(form.ImageAssetIDs) >= 2 { - typesOut = append(typesOut, "multi_image") - } else { - typesOut = append(typesOut, "image") - } - } - - out.Content = content - out.Price = price - out.CoverAssets = coverAssets - out.MainAssets = mainAssets - out.ContentTypes = typesOut - return nil - }) - if err != nil { - return nil, err - } - - return out, nil -} - -func (s *content) Update(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - }).Info("services.content.update") - - tbl, query := models.ContentQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(contentID), - ).First() - if err != nil { - return nil, pkgerrors.Wrap(err, "content not found") - } - - if form.Title != nil { - m.Title = *form.Title - } - if form.Description != nil { - m.Description = *form.Description - } - if form.Visibility != nil { - m.Visibility = *form.Visibility - } - if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 { - m.PreviewSeconds = *form.PreviewSeconds - m.PreviewDownloadable = false - } - if form.Status != nil { - m.Status = *form.Status - // 发布动作:首次发布时补齐发布时间,便于后续排序/检索与审计。 - if m.Status == consts.ContentStatusPublished && m.PublishedAt.IsZero() { - m.PublishedAt = time.Now() - } - } - - if _, err := m.Update(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "update content failed") - } - return m, nil -} - -func (s *content) UpsertPrice(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentPriceUpsertForm) (*models.ContentPrice, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - "amount": form.PriceAmount, - }).Info("services.content.upsert_price") - - currency := form.Currency - if currency == "" { - currency = consts.CurrencyCNY - } - discountType := form.DiscountType - if discountType == "" { - discountType = consts.DiscountTypeNone - } - - startAt := time.Time{} - if form.DiscountStartAt != nil { - startAt = *form.DiscountStartAt - } - endAt := time.Time{} - if form.DiscountEndAt != nil { - endAt = *form.DiscountEndAt - } - - tbl, query := models.ContentPriceQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ContentID.Eq(contentID), - ).First() - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, pkgerrors.Wrap(err, "find content price failed") - } - if errors.Is(err, gorm.ErrRecordNotFound) { - m = &models.ContentPrice{ - TenantID: tenantID, - UserID: userID, - ContentID: contentID, - Currency: currency, - PriceAmount: form.PriceAmount, - DiscountType: discountType, - DiscountValue: form.DiscountValue, - DiscountStartAt: startAt, - DiscountEndAt: endAt, - } - if err := m.Create(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "create content price failed") - } - return m, nil - } - - m.UserID = userID - m.Currency = currency - m.PriceAmount = form.PriceAmount - m.DiscountType = discountType - m.DiscountValue = form.DiscountValue - m.DiscountStartAt = startAt - m.DiscountEndAt = endAt - - if _, err := m.Update(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "update content price failed") - } - return m, nil -} - -func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, assetID int64, role consts.ContentAssetRole, sort int32, now time.Time) (*models.ContentAsset, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - "asset_id": assetID, - "role": role, - "sort": sort, - }).Info("services.content.attach_asset") - - // 约束:只能绑定本租户内、且已处理完成(ready)的资源;避免未完成处理的资源对外可见。 - tblContent, queryContent := models.ContentQuery.QueryContext(ctx) - if _, err := queryContent.Where( - tblContent.TenantID.Eq(tenantID), - tblContent.ID.Eq(contentID), - ).First(); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("content not found") - } - return nil, err - } - - tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx) - asset, err := queryAsset.Where( - tblAsset.TenantID.Eq(tenantID), - tblAsset.ID.Eq(assetID), - tblAsset.DeletedAt.IsNull(), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return nil, err - } - if asset.Status != consts.MediaAssetStatusReady { - return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready") - } - - // C2 规则:preview 必须绑定独立产物(media_assets.variant=preview),main/cover 必须为 main。 - variant := asset.Variant - if variant == "" { - variant = consts.MediaAssetVariantMain - } - requiredVariant := requiredMediaAssetVariantForRole(role) - if variant != requiredVariant { - return nil, errorx.ErrPreconditionFailed.WithMsg("media asset variant mismatch") - } - // 关联规则:preview 产物必须声明来源 main;main/cover 不允许带来源。 - if role == consts.ContentAssetRolePreview { - if asset.SourceAssetID <= 0 { - return nil, errorx.ErrPreconditionFailed.WithMsg("preview asset must have source_asset_id") - } - src, err := queryAsset.Where( - tblAsset.TenantID.Eq(tenantID), - tblAsset.ID.Eq(asset.SourceAssetID), - tblAsset.DeletedAt.IsNull(), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("preview source asset not found") - } - return nil, err - } - srcVariant := src.Variant - if srcVariant == "" { - srcVariant = consts.MediaAssetVariantMain - } - if srcVariant != consts.MediaAssetVariantMain { - return nil, errorx.ErrPreconditionFailed.WithMsg("preview source asset must be main variant") - } - } else { - if asset.SourceAssetID > 0 { - return nil, errorx.ErrPreconditionFailed.WithMsg("main/cover asset must not have source_asset_id") - } - } - - m := &models.ContentAsset{ - TenantID: tenantID, - UserID: userID, - ContentID: contentID, - AssetID: assetID, - Role: role, - Sort: sort, - CreatedAt: now, - UpdatedAt: now, - } - if err := m.Create(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "attach content asset failed") - } - return m, nil -} - -func (s *content) ListPublished(ctx context.Context, tenantID, userID int64, filter *dto.ContentListFilter) (*requests.Pager, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "page": filter.Page, - "limit": filter.Limit, - }).Info("services.content.list_published") - - tbl, query := models.ContentQuery.QueryContext(ctx) - - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.Status.Eq(consts.ContentStatusPublished), - tbl.Visibility.In(consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly), - } - if filter.Keyword != nil && *filter.Keyword != "" { - conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword))) - } - - filter.Pagination.Format() - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID }) - - priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) - if err != nil { - return nil, err - } - - accessSet, err := s.accessSet(ctx, tenantID, userID, contentIDs) - if err != nil { - return nil, err - } - - respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem { - price := priceByContent[model.ID] - free := price == nil || price.PriceAmount == 0 - has := free || accessSet[model.ID] || model.UserID == userID - return &dto.ContentItem{ - Content: model, - Price: price, - HasAccess: has, - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: respItems, - }, nil -} - -// ListPublicPublished 返回“公开可见”的已发布内容列表(给游客/非成员使用)。 -// 规则:仅返回 published + visibility=public;tenant_only/private 永不通过公开接口暴露。 -func (s *content) ListPublicPublished(ctx context.Context, tenantID, viewerUserID int64, filter *dto.ContentListFilter) (*requests.Pager, error) { - if filter == nil { - filter = &dto.ContentListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": viewerUserID, - "page": filter.Page, - "limit": filter.Limit, - }).Info("services.content.list_public_published") - - tbl, query := models.ContentQuery.QueryContext(ctx) - - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.Status.Eq(consts.ContentStatusPublished), - tbl.Visibility.Eq(consts.ContentVisibilityPublic), - tbl.DeletedAt.IsNull(), - } - if filter.Keyword != nil && *filter.Keyword != "" { - conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword))) - } - - filter.Pagination.Format() - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID }) - priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) - if err != nil { - return nil, err - } - - accessSet := map[int64]bool{} - if viewerUserID > 0 { - m, err := s.accessSet(ctx, tenantID, viewerUserID, contentIDs) - if err != nil { - return nil, err - } - accessSet = m - } - - respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem { - price := priceByContent[model.ID] - free := price == nil || price.PriceAmount == 0 - has := free || accessSet[model.ID] || model.UserID == viewerUserID - return &dto.ContentItem{ - Content: model, - Price: price, - HasAccess: has, - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: respItems, - }, nil -} - -// PublicDetail 返回“公开可见”的内容详情(给游客/非成员使用)。 -// 规则:仅允许 published + visibility=public;否则统一返回 not found,避免信息泄露。 -func (s *content) PublicDetail(ctx context.Context, tenantID, viewerUserID, contentID int64) (*ContentDetailResult, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": viewerUserID, - "content_id": contentID, - }).Info("services.content.public_detail") - - tbl, query := models.ContentQuery.QueryContext(ctx) - model, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(contentID), - tbl.DeletedAt.IsNull(), - ).First() - if err != nil { - return nil, errorx.ErrRecordNotFound.WithMsg("content not found") - } - - // Public endpoints only expose published + public contents. - if model.Status != consts.ContentStatusPublished || model.Visibility != consts.ContentVisibilityPublic { - return nil, errorx.ErrRecordNotFound.WithMsg("content not found") - } - - price, err := s.contentPrice(ctx, tenantID, contentID) - if err != nil { - return nil, err - } - free := price == nil || price.PriceAmount == 0 - - hasAccess := model.UserID == viewerUserID || free - if !hasAccess && viewerUserID > 0 { - ok, err := s.HasAccess(ctx, tenantID, viewerUserID, contentID) - if err != nil { - return nil, err - } - hasAccess = ok - } - - return &ContentDetailResult{ - Content: model, - Price: price, - HasAccess: hasAccess, - }, nil -} - -func (s *content) Detail(ctx context.Context, tenantID, userID, contentID int64) (*ContentDetailResult, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - }).Info("services.content.detail") - - tbl, query := models.ContentQuery.QueryContext(ctx) - model, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(contentID), - ).First() - if err != nil { - return nil, pkgerrors.Wrapf(err, "content not found, tenantID=%d, contentID=%d", tenantID, contentID) - } - - if model.Status != consts.ContentStatusPublished && model.UserID != userID { - return nil, errors.New("content is not published") - } - - price, err := s.contentPrice(ctx, tenantID, contentID) - if err != nil { - return nil, err - } - free := price == nil || price.PriceAmount == 0 - - canView := false - switch model.Visibility { - case consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly: - canView = true - case consts.ContentVisibilityPrivate: - canView = model.UserID == userID - default: - canView = false - } - - hasAccess := model.UserID == userID || free - if !hasAccess { - ok, err := s.HasAccess(ctx, tenantID, userID, contentID) - if err != nil { - return nil, err - } - hasAccess = ok - canView = canView || ok - } - - if !canView { - return nil, errors.New("content is private") - } - - return &ContentDetailResult{ - Content: model, - Price: price, - HasAccess: hasAccess, - }, nil -} - -func (s *content) HasAccess(ctx context.Context, tenantID, userID, contentID int64) (bool, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - }).Info("services.content.has_access") - - tbl, query := models.ContentAccessQuery.QueryContext(ctx) - _, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - tbl.ContentID.Eq(contentID), - tbl.Status.Eq(consts.ContentAccessStatusActive), - ).First() - if err != nil { - return false, nil - } - return true, nil -} - -func (s *content) AssetsByRole(ctx context.Context, tenantID, contentID int64, role consts.ContentAssetRole) ([]*models.MediaAsset, error) { - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "content_id": contentID, - "role": role, - }).Info("services.content.assets_by_role") - - maTbl, maQuery := models.MediaAssetQuery.QueryContext(ctx) - caTbl, _ := models.ContentAssetQuery.QueryContext(ctx) - - assets, err := maQuery. - LeftJoin(caTbl, caTbl.AssetID.EqCol(maTbl.ID)). - Select(maTbl.ALL). - Where( - maTbl.TenantID.Eq(tenantID), - maTbl.DeletedAt.IsNull(), - maTbl.Status.Eq(consts.MediaAssetStatusReady), - caTbl.TenantID.Eq(tenantID), - caTbl.ContentID.Eq(contentID), - caTbl.Role.Eq(role), - ). - Order(caTbl.Sort.Asc()). - Find() - if err != nil { - return nil, err - } - return assets, nil -} - -func (s *content) contentPrice(ctx context.Context, tenantID, contentID int64) (*models.ContentPrice, error) { - tbl, query := models.ContentPriceQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ContentID.Eq(contentID), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, err - } - return m, nil -} - -func (s *content) contentPriceMapping(ctx context.Context, tenantID int64, contentIDs []int64) (map[int64]*models.ContentPrice, error) { - if len(contentIDs) == 0 { - return map[int64]*models.ContentPrice{}, nil - } - - tbl, query := models.ContentPriceQuery.QueryContext(ctx) - items, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ContentID.In(contentIDs...), - ).Find() - if err != nil { - return nil, err - } - - return lo.SliceToMap(items, func(item *models.ContentPrice) (int64, *models.ContentPrice) { - return item.ContentID, item - }), nil -} - -func (s *content) accessSet(ctx context.Context, tenantID, userID int64, contentIDs []int64) (map[int64]bool, error) { - if len(contentIDs) == 0 { - return map[int64]bool{}, nil - } - - tbl, query := models.ContentAccessQuery.QueryContext(ctx) - items, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - tbl.ContentID.In(contentIDs...), - tbl.Status.Eq(consts.ContentAccessStatusActive), - ).Find() - if err != nil { - return nil, err - } - - out := make(map[int64]bool, len(items)) - for _, item := range items { - out[item.ContentID] = true - } - return out, nil -} diff --git a/backend/app/services/content_admin.go b/backend/app/services/content_admin.go deleted file mode 100644 index 18338e0..0000000 --- a/backend/app/services/content_admin.go +++ /dev/null @@ -1,180 +0,0 @@ -package services - -import ( - "context" - "strings" - - tenantdto "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - - "github.com/pkg/errors" - "github.com/samber/lo" - log "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" -) - -// AdminContentPage returns contents list for tenant admin (includes drafts/unpublished/etc). -func (s *content) AdminContentPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminContentListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - if filter == nil { - filter = &tenantdto.AdminContentListFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "page": filter.Page, - "limit": filter.Limit, - "user_id": lo.FromPtr(filter.UserID), - "keyword": filter.KeywordTrimmed(), - "status": lo.FromPtr(filter.Status), - }).Info("services.content.admin.page") - - filter.Pagination.Format() - - cTbl, query := models.ContentQuery.QueryContext(ctx) - query = query.Select(cTbl.ALL) - - conds := []gen.Condition{ - cTbl.TenantID.Eq(tenantID), - cTbl.DeletedAt.IsNull(), - } - if filter.ID != nil && *filter.ID > 0 { - conds = append(conds, cTbl.ID.Eq(*filter.ID)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, cTbl.UserID.Eq(*filter.UserID)) - } - if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { - conds = append(conds, cTbl.Title.Like(database.WrapLike(kw))) - } - if filter.Status != nil { - conds = append(conds, cTbl.Status.Eq(*filter.Status)) - } - if filter.Visibility != nil { - conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility)) - } - if filter.PublishedAtFrom != nil { - conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom)) - } - if filter.PublishedAtTo != nil { - conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - orderBys := make([]field.Expr, 0, 6) - allowedAsc := map[string]field.Expr{ - "id": cTbl.ID.Asc(), - "title": cTbl.Title.Asc(), - "user_id": cTbl.UserID.Asc(), - "status": cTbl.Status.Asc(), - "visibility": cTbl.Visibility.Asc(), - "published_at": cTbl.PublishedAt.Asc(), - "created_at": cTbl.CreatedAt.Asc(), - "updated_at": cTbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": cTbl.ID.Desc(), - "title": cTbl.Title.Desc(), - "user_id": cTbl.UserID.Desc(), - "status": cTbl.Status.Desc(), - "visibility": cTbl.Visibility.Desc(), - "published_at": cTbl.PublishedAt.Desc(), - "created_at": cTbl.CreatedAt.Desc(), - "updated_at": cTbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, cTbl.ID.Desc()) - } else { - orderBys = append(orderBys, cTbl.ID.Desc()) - } - - items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.ID <= 0 { - return 0, false - } - return item.ID, true - })) - ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.UserID <= 0 { - return 0, false - } - return item.UserID, true - })) - - priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) - if err != nil { - return nil, err - } - - ownerMap := map[int64]*tenantdto.AdminContentOwnerLite{} - if len(ownerIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find() - if err != nil { - return nil, err - } - for _, u := range users { - if u == nil { - continue - } - ownerMap[u.ID] = &tenantdto.AdminContentOwnerLite{ - ID: u.ID, - Username: u.Username, - Status: u.Status, - Roles: u.Roles, - } - } - } - - respItems := lo.Map(items, func(model *models.Content, _ int) *tenantdto.AdminContentItem { - if model == nil { - return nil - } - return &tenantdto.AdminContentItem{ - Content: model, - Price: priceByContent[model.ID], - Owner: ownerMap[model.UserID], - StatusDescription: model.Status.Description(), - VisibilityDescription: model.Visibility.Description(), - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: respItems, - }, nil -} diff --git a/backend/app/services/content_super.go b/backend/app/services/content_super.go deleted file mode 100644 index 0076df6..0000000 --- a/backend/app/services/content_super.go +++ /dev/null @@ -1,494 +0,0 @@ -package services - -import ( - "context" - "strings" - "time" - - "quyun/v2/app/errorx" - superdto "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/pkg/errors" - "github.com/samber/lo" - log "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" -) - -// SuperTenantContentsPage returns tenant contents list for superadmin. -func (s *content) SuperTenantContentsPage(ctx context.Context, tenantID int64, filter *superdto.TenantContentFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - if filter == nil { - filter = &superdto.TenantContentFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "page": filter.Page, - "limit": filter.Limit, - }).Info("services.content.super_tenant_contents_page") - - tbl, query := models.ContentQuery.QueryContext(ctx) - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.DeletedAt.IsNull(), - } - - if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { - conds = append(conds, tbl.Title.Like(database.WrapLike(kw))) - } - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.Visibility != nil { - conds = append(conds, tbl.Visibility.Eq(*filter.Visibility)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.PublishedAtFrom != nil { - conds = append(conds, tbl.PublishedAt.Gte(*filter.PublishedAtFrom)) - } - if filter.PublishedAtTo != nil { - conds = append(conds, tbl.PublishedAt.Lte(*filter.PublishedAtTo)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - filter.Pagination.Format() - - orderBys := make([]field.Expr, 0, 6) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "title": tbl.Title.Asc(), - "user_id": tbl.UserID.Asc(), - "status": tbl.Status.Asc(), - "visibility": tbl.Visibility.Asc(), - "published_at": tbl.PublishedAt.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "updated_at": tbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "title": tbl.Title.Desc(), - "user_id": tbl.UserID.Desc(), - "status": tbl.Status.Desc(), - "visibility": tbl.Visibility.Desc(), - "published_at": tbl.PublishedAt.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "updated_at": tbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { - if item == nil { - return 0 - } - return item.ID - }) - contentIDs = lo.Filter(contentIDs, func(id int64, _ int) bool { return id > 0 }) - - priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs) - if err != nil { - return nil, err - } - - ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.UserID <= 0 { - return 0, false - } - return item.UserID, true - })) - - ownerMap := map[int64]*superdto.SuperUserLite{} - if len(ownerIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find() - if err != nil { - return nil, err - } - for _, u := range users { - if u == nil { - continue - } - ownerMap[u.ID] = &superdto.SuperUserLite{ - ID: u.ID, - Username: u.Username, - Status: u.Status, - Roles: u.Roles, - VerifiedAt: u.VerifiedAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - StatusDescription: u.Status.Description(), - } - } - } - - respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperTenantContentItem { - if model == nil { - return nil - } - return &superdto.SuperTenantContentItem{ - Content: model, - Price: priceByContent[model.ID], - Owner: ownerMap[model.UserID], - StatusDescription: model.Status.Description(), - VisibilityDescription: model.Visibility.Description(), - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: respItems, - }, nil -} - -func (s *content) SuperContentPage(ctx context.Context, filter *superdto.SuperContentPageFilter) (*requests.Pager, error) { - if filter == nil { - filter = &superdto.SuperContentPageFilter{} - } - - log.WithFields(log.Fields{ - "tenant_id": lo.FromPtr(filter.TenantID), - "tenant_code": filter.TenantCodeTrimmed(), - "tenant_name": filter.TenantNameTrimmed(), - "user_id": lo.FromPtr(filter.UserID), - "username": filter.UsernameTrimmed(), - "keyword": filter.KeywordTrimmed(), - "status": lo.FromPtr(filter.Status), - "visibility": lo.FromPtr(filter.Visibility), - "page": filter.Page, - "limit": filter.Limit, - }).Info("services.content.super_page") - - filter.Pagination.Format() - - cTbl, query := models.ContentQuery.QueryContext(ctx) - // 注意:该查询会按需 join users/tenants/content_prices;必须显式 select contents.*, - // 否则重复列名(id/created_at/updated_at 等)会被扫描到 Content 模型上导致字段错乱。 - query = query.Select(cTbl.ALL) - - conds := []gen.Condition{ - cTbl.DeletedAt.IsNull(), - } - - if filter.ID != nil && *filter.ID > 0 { - conds = append(conds, cTbl.ID.Eq(*filter.ID)) - } - if filter.TenantID != nil && *filter.TenantID > 0 { - conds = append(conds, cTbl.TenantID.Eq(*filter.TenantID)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, cTbl.UserID.Eq(*filter.UserID)) - } - if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" { - conds = append(conds, cTbl.Title.Like(database.WrapLike(kw))) - } - if filter.Status != nil { - conds = append(conds, cTbl.Status.Eq(*filter.Status)) - } - if filter.Visibility != nil { - conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility)) - } - if filter.PublishedAtFrom != nil { - conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom)) - } - if filter.PublishedAtTo != nil { - conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - // Owner username keyword. - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(cTbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - // Tenant code/name keyword. - tenantCode := filter.TenantCodeTrimmed() - tenantName := filter.TenantNameTrimmed() - if tenantCode != "" || tenantName != "" { - tTbl, _ := models.TenantQuery.QueryContext(ctx) - query = query.LeftJoin(tTbl, tTbl.ID.EqCol(cTbl.TenantID)) - if tenantCode != "" { - conds = append(conds, tTbl.Code.Like(database.WrapLike(tenantCode))) - } - if tenantName != "" { - conds = append(conds, tTbl.Name.Like(database.WrapLike(tenantName))) - } - } - - // Price amount range filter (content_prices is 1:1 by content_id within tenant). - needPriceJoin := (filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0) || (filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0) - if needPriceJoin { - cpTbl, _ := models.ContentPriceQuery.QueryContext(ctx) - query = query.LeftJoin(cpTbl, cpTbl.ContentID.EqCol(cTbl.ID)) - if filter.PriceAmountMin != nil && *filter.PriceAmountMin >= 0 { - conds = append(conds, cpTbl.PriceAmount.Gte(*filter.PriceAmountMin)) - } - if filter.PriceAmountMax != nil && *filter.PriceAmountMax >= 0 { - conds = append(conds, cpTbl.PriceAmount.Lte(*filter.PriceAmountMax)) - } - } - - // Sort whitelist. - orderBys := make([]field.Expr, 0, 8) - allowedAsc := map[string]field.Expr{ - "id": cTbl.ID.Asc(), - "tenant_id": cTbl.TenantID.Asc(), - "user_id": cTbl.UserID.Asc(), - "title": cTbl.Title.Asc(), - "status": cTbl.Status.Asc(), - "visibility": cTbl.Visibility.Asc(), - "published_at": cTbl.PublishedAt.Asc(), - "created_at": cTbl.CreatedAt.Asc(), - "updated_at": cTbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": cTbl.ID.Desc(), - "tenant_id": cTbl.TenantID.Desc(), - "user_id": cTbl.UserID.Desc(), - "title": cTbl.Title.Desc(), - "status": cTbl.Status.Desc(), - "visibility": cTbl.Visibility.Desc(), - "published_at": cTbl.PublishedAt.Desc(), - "created_at": cTbl.CreatedAt.Desc(), - "updated_at": cTbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, cTbl.ID.Desc()) - } else { - orderBys = append(orderBys, cTbl.ID.Desc()) - } - - items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - tenantIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.TenantID <= 0 { - return 0, false - } - return item.TenantID, true - })) - ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.UserID <= 0 { - return 0, false - } - return item.UserID, true - })) - contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) { - if item == nil || item.ID <= 0 { - return 0, false - } - return item.ID, true - })) - - tenantMap := map[int64]*models.Tenant{} - if len(tenantIDs) > 0 { - tTbl, tQuery := models.TenantQuery.QueryContext(ctx) - tenants, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() - if err != nil { - return nil, err - } - for _, te := range tenants { - if te == nil { - continue - } - tenantMap[te.ID] = te - } - } - - ownerMap := map[int64]*superdto.SuperUserLite{} - if len(ownerIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find() - if err != nil { - return nil, err - } - for _, u := range users { - if u == nil { - continue - } - ownerMap[u.ID] = &superdto.SuperUserLite{ - ID: u.ID, - Username: u.Username, - Status: u.Status, - Roles: u.Roles, - VerifiedAt: u.VerifiedAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - StatusDescription: u.Status.Description(), - } - } - } - - priceByContent := map[int64]*models.ContentPrice{} - if len(contentIDs) > 0 { - cpTbl, cpQuery := models.ContentPriceQuery.QueryContext(ctx) - conds := []gen.Condition{ - cpTbl.ContentID.In(contentIDs...), - } - if len(tenantIDs) > 0 { - conds = append(conds, cpTbl.TenantID.In(tenantIDs...)) - } - prices, err := cpQuery.Where(conds...).Find() - if err != nil { - return nil, err - } - for _, p := range prices { - if p == nil { - continue - } - priceByContent[p.ContentID] = p - } - } - - respItems := lo.Map(items, func(model *models.Content, _ int) *superdto.SuperContentItem { - if model == nil { - return nil - } - te := tenantMap[model.TenantID] - var lite *superdto.SuperContentTenantLite - if te != nil { - lite = &superdto.SuperContentTenantLite{ - ID: te.ID, - Code: te.Code, - Name: te.Name, - } - } - return &superdto.SuperContentItem{ - Content: model, - Price: priceByContent[model.ID], - Tenant: lite, - Owner: ownerMap[model.UserID], - StatusDescription: model.Status.Description(), - VisibilityDescription: model.Visibility.Description(), - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: respItems, - }, nil -} - -func (s *content) SuperUpdateTenantContentStatus( - ctx context.Context, - operatorUserID, tenantID, contentID int64, - status consts.ContentStatus, - now time.Time, -) (*models.Content, error) { - if operatorUserID <= 0 { - return nil, errorx.ErrTokenInvalid - } - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - if contentID <= 0 { - return nil, errors.New("content_id must be > 0") - } - if status != consts.ContentStatusUnpublished && status != consts.ContentStatusBlocked { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid status") - } - - log.WithFields(log.Fields{ - "operator_user_id": operatorUserID, - "tenant_id": tenantID, - "content_id": contentID, - "status": status, - }).Info("services.content.super_update_tenant_content_status") - - tbl, query := models.ContentQuery.QueryContext(ctx) - - model, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(contentID), - tbl.DeletedAt.IsNull(), - ).First() - if err != nil { - return nil, err - } - - if status == consts.ContentStatusUnpublished && model.Status != consts.ContentStatusPublished { - return nil, errorx.ErrPreconditionFailed.WithMsg("content is not published") - } - - if _, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(contentID), - tbl.DeletedAt.IsNull(), - ).UpdateSimple( - tbl.Status.Value(status), - ); err != nil { - return nil, err - } - - model.Status = status - model.UpdatedAt = now - return model, nil -} diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go deleted file mode 100644 index 10ab108..0000000 --- a/backend/app/services/content_test.go +++ /dev/null @@ -1,547 +0,0 @@ -package services - -import ( - "database/sql" - "errors" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" - "gorm.io/gorm" -) - -type ContentTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type ContentTestSuite struct { - suite.Suite - ContentTestSuiteInjectParams -} - -func Test_Content(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p ContentTestSuiteInjectParams) { - suite.Run(t, &ContentTestSuite{ContentTestSuiteInjectParams: p}) - }) -} - -func (s *ContentTestSuite) Test_Create() { - Convey("Content.Create", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContent) - - Convey("成功创建草稿内容并应用默认策略", func() { - form := &dto.ContentCreateForm{ - Title: "标题", - Description: "描述", - } - m, err := Content.Create(ctx, tenantID, userID, form) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(m.TenantID, ShouldEqual, tenantID) - So(m.UserID, ShouldEqual, userID) - So(m.Status, ShouldEqual, consts.ContentStatusDraft) - So(m.Visibility, ShouldEqual, consts.ContentVisibilityTenantOnly) - So(m.PreviewSeconds, ShouldEqual, consts.DefaultContentPreviewSeconds) - So(m.PreviewDownloadable, ShouldBeFalse) - }) - }) -} - -func (s *ContentTestSuite) Test_Update() { - Convey("Content.Update", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContent) - - m := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusDraft, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - } - So(m.Create(ctx), ShouldBeNil) - - Convey("发布内容应写入 published_at", func() { - status := consts.ContentStatusPublished - form := &dto.ContentUpdateForm{Status: &status} - - updated, err := Content.Update(ctx, tenantID, userID, m.ID, form) - So(err, ShouldBeNil) - So(updated, ShouldNotBeNil) - So(updated.Status, ShouldEqual, consts.ContentStatusPublished) - So(updated.PublishedAt.IsZero(), ShouldBeFalse) - }) - }) -} - -func (s *ContentTestSuite) Test_UpsertPrice() { - Convey("Content.UpsertPrice", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContentPrice, models.TableNameContent) - - content := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusDraft, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - } - So(content.Create(ctx), ShouldBeNil) - - Convey("首次 upsert 应创建价格记录", func() { - form := &dto.ContentPriceUpsertForm{ - PriceAmount: 100, - } - price, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form) - So(err, ShouldBeNil) - So(price, ShouldNotBeNil) - So(price.PriceAmount, ShouldEqual, 100) - So(price.Currency, ShouldEqual, consts.CurrencyCNY) - So(price.DiscountType, ShouldEqual, consts.DiscountTypeNone) - }) - - Convey("再次 upsert 应更新价格记录", func() { - form1 := &dto.ContentPriceUpsertForm{PriceAmount: 100} - _, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form1) - So(err, ShouldBeNil) - - form2 := &dto.ContentPriceUpsertForm{ - PriceAmount: 200, - DiscountType: consts.DiscountTypePercent, - DiscountValue: 10, - } - price2, err := Content.UpsertPrice(ctx, tenantID, userID, content.ID, form2) - So(err, ShouldBeNil) - So(price2.PriceAmount, ShouldEqual, 200) - So(price2.DiscountType, ShouldEqual, consts.DiscountTypePercent) - So(price2.DiscountValue, ShouldEqual, 10) - }) - }) -} - -func (s *ContentTestSuite) Test_AttachAsset() { - Convey("Content.AttachAsset", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContentAsset, models.TableNameMediaAsset, models.TableNameContent) - - content := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusDraft, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - } - So(content.Create(ctx), ShouldBeNil) - - asset := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusReady, - Provider: "test", - Bucket: "bucket", - ObjectKey: "obj", - Meta: types.JSON([]byte("{}")), - } - So(asset.Create(ctx), ShouldBeNil) - - Convey("成功绑定资源到内容", func() { - m, err := Content.AttachAsset(ctx, tenantID, userID, content.ID, asset.ID, consts.ContentAssetRoleMain, 1, now) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(m.ContentID, ShouldEqual, content.ID) - So(m.AssetID, ShouldEqual, asset.ID) - So(m.Role, ShouldEqual, consts.ContentAssetRoleMain) - }) - - Convey("preview role 只能绑定 preview variant,且必须有 source_asset_id", func() { - previewAsset := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusReady, - Provider: "test", - Bucket: "bucket", - ObjectKey: "obj-preview", - Meta: types.JSON([]byte("{}")), - } - So(previewAsset.Create(ctx), ShouldBeNil) - - // 标记为 preview 产物,但不设置 source_asset_id,应被拒绝。 - _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = NULL WHERE tenant_id = $1 AND id = $2", tenantID, previewAsset.ID) - So(err, ShouldBeNil) - - _, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRolePreview, 1, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) - }) - - Convey("preview role 绑定 main variant 应被拒绝", func() { - _, err := Content.AttachAsset(ctx, tenantID, userID, content.ID, asset.ID, consts.ContentAssetRolePreview, 1, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) - }) - - Convey("main role 绑定 preview variant 应被拒绝", func() { - previewAsset := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusReady, - Provider: "test", - Bucket: "bucket", - ObjectKey: "obj-preview2", - Meta: types.JSON([]byte("{}")), - } - So(previewAsset.Create(ctx), ShouldBeNil) - - // 将该资源标记为 preview 产物,并设置一个合法来源(指向已有 main 资源 asset)。 - _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview', source_asset_id = $1 WHERE tenant_id = $2 AND id = $3", asset.ID, tenantID, previewAsset.ID) - So(err, ShouldBeNil) - - _, err = Content.AttachAsset(ctx, tenantID, userID, content.ID, previewAsset.ID, consts.ContentAssetRoleMain, 1, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) - }) - }) -} - -func (s *ContentTestSuite) Test_HasAccess() { - Convey("Content.HasAccess", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContentAccess, models.TableNameContent) - - content := &models.Content{ - TenantID: tenantID, - UserID: userID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - } - So(content.Create(ctx), ShouldBeNil) - - Convey("未授予权益应返回 false", func() { - ok, err := Content.HasAccess(ctx, tenantID, userID, content.ID) - So(err, ShouldBeNil) - So(ok, ShouldBeFalse) - }) - - Convey("权益 active 应返回 true", func() { - access := &models.ContentAccess{ - TenantID: tenantID, - UserID: userID, - ContentID: content.ID, - OrderID: 0, - Status: consts.ContentAccessStatusActive, - RevokedAt: time.Time{}, - CreatedAt: now, - UpdatedAt: now, - } - So(access.Create(ctx), ShouldBeNil) - - ok, err := Content.HasAccess(ctx, tenantID, userID, content.ID) - So(err, ShouldBeNil) - So(ok, ShouldBeTrue) - }) - }) -} - -func (s *ContentTestSuite) Test_ListPublicPublished() { - Convey("Content.ListPublicPublished", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - ownerID := int64(2) - - database.Truncate(ctx, s.DB, - models.TableNameContentAccess, - models.TableNameContentPrice, - models.TableNameContent, - ) - - publicPaid := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "public_paid", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(publicPaid.Create(ctx), ShouldBeNil) - So((&models.ContentPrice{ - TenantID: tenantID, - UserID: ownerID, - ContentID: publicPaid.ID, - Currency: consts.CurrencyCNY, - PriceAmount: 100, - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - publicFree := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "public_free", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(publicFree.Create(ctx), ShouldBeNil) - - tenantOnly := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "tenant_only", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(tenantOnly.Create(ctx), ShouldBeNil) - - privateContent := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "private", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPrivate, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(privateContent.Create(ctx), ShouldBeNil) - - draftPublic := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "draft_public", - Description: "d", - Status: consts.ContentStatusDraft, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - CreatedAt: now, - UpdatedAt: now, - } - So(draftPublic.Create(ctx), ShouldBeNil) - - deletedPublic := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "deleted_public", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - DeletedAt: gorm.DeletedAt{Time: now, Valid: true}, - CreatedAt: now, - UpdatedAt: now, - } - So(deletedPublic.Create(ctx), ShouldBeNil) - - Convey("游客仅能看到 public+published,且免费内容 has_access=true", func() { - pager, err := Content.ListPublicPublished(ctx, tenantID, 0, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 2) - - items := pager.Items.([]*dto.ContentItem) - So(len(items), ShouldEqual, 2) - - got := map[int64]*dto.ContentItem{} - for _, it := range items { - got[it.Content.ID] = it - } - So(got[publicPaid.ID], ShouldNotBeNil) - So(got[publicFree.ID], ShouldNotBeNil) - So(got[publicPaid.ID].HasAccess, ShouldBeFalse) - So(got[publicFree.ID].HasAccess, ShouldBeTrue) - }) - - Convey("已登录用户若有权益则 has_access=true", func() { - viewerID := int64(99) - access := &models.ContentAccess{ - TenantID: tenantID, - UserID: viewerID, - ContentID: publicPaid.ID, - OrderID: 123, - Status: consts.ContentAccessStatusActive, - CreatedAt: now, - UpdatedAt: now, - } - So(access.Create(ctx), ShouldBeNil) - - pager, err := Content.ListPublicPublished(ctx, tenantID, viewerID, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 2) - - items := pager.Items.([]*dto.ContentItem) - got := map[int64]*dto.ContentItem{} - for _, it := range items { - got[it.Content.ID] = it - } - So(got[publicPaid.ID].HasAccess, ShouldBeTrue) - }) - }) -} - -func (s *ContentTestSuite) Test_PublicDetail() { - Convey("Content.PublicDetail", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - ownerID := int64(2) - - database.Truncate(ctx, s.DB, - models.TableNameContentAccess, - models.TableNameContentPrice, - models.TableNameContent, - ) - - publicPaid := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "public_paid", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(publicPaid.Create(ctx), ShouldBeNil) - So((&models.ContentPrice{ - TenantID: tenantID, - UserID: ownerID, - ContentID: publicPaid.ID, - Currency: consts.CurrencyCNY, - PriceAmount: 100, - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - tenantOnly := &models.Content{ - TenantID: tenantID, - UserID: ownerID, - Title: "tenant_only", - Description: "d", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(tenantOnly.Create(ctx), ShouldBeNil) - - Convey("游客访问 public+paid:可见但无正片权限", func() { - out, err := Content.PublicDetail(ctx, tenantID, 0, publicPaid.ID) - So(err, ShouldBeNil) - So(out, ShouldNotBeNil) - So(out.HasAccess, ShouldBeFalse) - }) - - Convey("tenant_only 在 public detail 下应表现为 not found", func() { - _, err := Content.PublicDetail(ctx, tenantID, 0, tenantOnly.ID) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code) - }) - - Convey("有权益的已登录用户访问 public+paid:has_access=true", func() { - viewerID := int64(99) - access := &models.ContentAccess{ - TenantID: tenantID, - UserID: viewerID, - ContentID: publicPaid.ID, - OrderID: 123, - Status: consts.ContentAccessStatusActive, - CreatedAt: now, - UpdatedAt: now, - } - So(access.Create(ctx), ShouldBeNil) - - out, err := Content.PublicDetail(ctx, tenantID, viewerID, publicPaid.ID) - So(err, ShouldBeNil) - So(out.HasAccess, ShouldBeTrue) - }) - }) -} diff --git a/backend/app/services/ledger.go b/backend/app/services/ledger.go deleted file mode 100644 index 0fbbe57..0000000 --- a/backend/app/services/ledger.go +++ /dev/null @@ -1,481 +0,0 @@ -package services - -import ( - "context" - "errors" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/samber/lo" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与用户余额快照。 -type LedgerApplyResult struct { - // Ledger 为本次创建的账本记录(若幂等命中则返回已有记录)。 - Ledger *models.TenantLedger - // User 为写入后余额状态(若幂等命中则返回当前快照)。 - User *models.User -} - -// ledger 提供租户账本能力(冻结/解冻/扣减/退款等),支持幂等与行锁保证一致性。 -// 注意:余额为 users 表的全局余额,用户可在已加入租户间共享消费。 -// -// @provider -type ledger struct { - db *gorm.DB -} - -// MyBalance 查询当前用户的全局余额信息(可用/冻结)。 -// 语义:必须先是该租户成员(否则返回 not found),但余额数据来源为 users。 -func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.User, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - }).Info("services.ledger.me.balance") - - // 必须先是租户成员。 - tblTU, queryTU := models.TenantUserQuery.QueryContext(ctx) - if _, err := queryTU.Where(tblTU.TenantID.Eq(tenantID), tblTU.UserID.Eq(userID)).First(); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("tenant user not found") - } - return nil, err - } - - tblU, queryU := models.UserQuery.QueryContext(ctx) - m, err := queryU.Where(tblU.ID.Eq(userID), tblU.DeletedAt.IsNull()).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("user not found") - } - return nil, err - } - return m, nil -} - -// MyLedgerPage 分页查询当前用户在指定租户下的余额流水(用于“我的流水”)。 -func (s *ledger) MyLedgerPage(ctx context.Context, tenantID, userID int64, filter *dto.MyLedgerListFilter) (*requests.Pager, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") - } - if filter == nil { - filter = &dto.MyLedgerListFilter{} - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "type": lo.FromPtr(filter.Type), - "order_id": lo.FromPtr(filter.OrderID), - }).Info("services.ledger.me.ledgers.page") - - filter.Pagination.Format() - - tbl, query := models.TenantLedgerQuery.QueryContext(ctx) - - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - } - if filter.Type != nil { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.OrderID != nil && *filter.OrderID > 0 { - conds = append(conds, tbl.OrderID.Eq(*filter.OrderID)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - ledgers, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - items := lo.Map(ledgers, func(m *models.TenantLedger, _ int) *dto.MyLedgerItem { - return &dto.MyLedgerItem{ - Ledger: m, - TypeDescription: m.Type.Description(), - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// AdminLedgerPage 分页查询租户内余额流水(租户后台审计用)。 -func (s *ledger) AdminLedgerPage(ctx context.Context, tenantID int64, filter *dto.AdminLedgerListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &dto.AdminLedgerListFilter{} - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": lo.FromPtr(filter.OperatorUserID), - "user_id": lo.FromPtr(filter.UserID), - "type": lo.FromPtr(filter.Type), - "order_id": lo.FromPtr(filter.OrderID), - "biz_ref_type": lo.FromPtr(filter.BizRefType), - "biz_ref_id": lo.FromPtr(filter.BizRefID), - "created_at_from": filter.CreatedAtFrom, - "created_at_to": filter.CreatedAtTo, - "pagination_page": filter.Page, - "pagination_limit": filter.Limit, - "pagination_offset": filter.Offset(), - }).Info("services.ledger.admin.page") - - filter.Pagination.Format() - - tbl, query := models.TenantLedgerQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - - if filter.OperatorUserID != nil && *filter.OperatorUserID > 0 { - conds = append(conds, tbl.OperatorUserID.Eq(*filter.OperatorUserID)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Type != nil { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.OrderID != nil && *filter.OrderID > 0 { - conds = append(conds, tbl.OrderID.Eq(*filter.OrderID)) - } - if filter.BizRefType != nil && *filter.BizRefType != "" { - conds = append(conds, tbl.BizRefType.Eq(*filter.BizRefType)) - } - if filter.BizRefID != nil && *filter.BizRefID > 0 { - conds = append(conds, tbl.BizRefID.Eq(*filter.BizRefID)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - ledgers, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - items := lo.Map(ledgers, func(m *models.TenantLedger, _ int) *dto.AdminLedgerItem { - return &dto.AdminLedgerItem{ - Ledger: m, - TypeDescription: m.Type.Description(), - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// Freeze 将可用余额转入冻结余额,并写入账本记录。 -func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - // 冻结通常由用户自己发起(下单冻结);操作者默认等于余额账户归属 user_id。 - bizRefType := "" - if orderID > 0 { - bizRefType = "order" - } - return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now) -} - -// Unfreeze 将冻结余额转回可用余额,并写入账本记录。 -func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - // 解冻通常由用户自己发起(失败回滚/退款回滚等可能由系统或管理员触发;此处默认等于 user_id)。 - bizRefType := "" - if orderID > 0 { - bizRefType = "order" - } - return s.apply(ctx, s.db, tenantID, userID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now) -} - -// FreezeTx 为 Freeze 的事务版本(由外层事务控制提交/回滚)。 -func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - bizRefType := "" - if orderID > 0 { - bizRefType = "order" - } - return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now) -} - -// UnfreezeTx 为 Unfreeze 的事务版本(由外层事务控制提交/回滚)。 -func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - bizRefType := "" - if orderID > 0 { - bizRefType = "order" - } - return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, bizRefType, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now) -} - -// DebitPurchaseTx 将冻结资金转为实际扣款(减少冻结余额),并写入账本记录。 -func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now) -} - -// CreditRefundTx 将退款金额退回到可用余额,并写入账本记录。 -func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) { - return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now) -} - -func (s *ledger) apply( - ctx context.Context, - tx *gorm.DB, - tenantID, operatorUserID, userID, orderID int64, - bizRefType string, bizRefID int64, - ledgerType consts.TenantLedgerType, - amount, deltaBalance, deltaFrozen int64, - idempotencyKey, remark string, - now time.Time, -) (*LedgerApplyResult, error) { - // 关键前置校验:金额必须为正;时间允许由调用方注入,便于测试与一致性落库。 - if amount <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "user_id": userID, - "order_id": orderID, - "biz_ref_type": bizRefType, - "biz_ref_id": bizRefID, - "type": ledgerType, - "amount": amount, - "idempotency_key": idempotencyKey, - "delta_balance": deltaBalance, - "delta_frozen": deltaFrozen, - "remark_non_empty": remark != "", - }).Info("services.ledger.apply") - - var out LedgerApplyResult - - err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 必须先是租户成员(账本维度仍按 tenant_id 记录)。 - var tu models.TenantUser - if err := tx. - Where("tenant_id = ? AND user_id = ?", tenantID, userID). - First(&tu).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("tenant user not found") - } - return err - } - - // 幂等快速路径:在进入行锁之前先查一次,减少锁竞争(命中则直接返回)。 - if idempotencyKey != "" { - var existing models.TenantLedger - if err := tx. - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). - First(&existing).Error; err == nil { - var current models.User - if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("user not found") - } - return err - } - out.Ledger = &existing - out.User = ¤t - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - - // 结构化幂等快速路径:当调用方未传 idempotency_key,但提供了 biz_ref 时,按 (tenant,biz_ref,type) 去重。 - if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 { - var existing models.TenantLedger - if err := tx. - Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). - First(&existing).Error; err == nil { - var current models.User - if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(¤t).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("user not found") - } - return err - } - out.Ledger = &existing - out.User = ¤t - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - - // 使用行锁锁住 users,确保同一用户在“跨租户消费”场景下余额更新的串行一致性。 - var u models.User - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND deleted_at IS NULL", userID). - First(&u).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("user not found") - } - return err - } - - // 二次幂等校验:防止并发下在获取锁前后插入账本导致的重复写入。 - if idempotencyKey != "" { - var existing models.TenantLedger - if err := tx. - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). - First(&existing).Error; err == nil { - out.Ledger = &existing - out.User = &u - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - - // 二次结构化幂等校验:与上面的幂等逻辑一致,避免并发下重复写入。 - if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 { - var existing models.TenantLedger - if err := tx. - Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). - First(&existing).Error; err == nil { - out.Ledger = &existing - out.User = &u - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - - balanceBefore := u.Balance - frozenBefore := u.BalanceFrozen - balanceAfter := balanceBefore + deltaBalance - frozenAfter := frozenBefore + deltaFrozen - - // 关键不变量:余额/冻结余额不能为负,避免透支或超额解冻。 - if balanceAfter < 0 { - return errorx.ErrPreconditionFailed.WithMsg("余额不足") - } - if frozenAfter < 0 { - return errorx.ErrPreconditionFailed.WithMsg("冻结余额不足") - } - - // 先更新余额,再写账本:任何一步失败都回滚,保证“余额变更”和“账本记录”一致。 - if err := tx.Model(&models.User{}). - Where("id = ?", u.ID). - Updates(map[string]any{ - "balance": balanceAfter, - "balance_frozen": frozenAfter, - "updated_at": now, - }).Error; err != nil { - return err - } - - // 写入账本:记录变更前后快照,便于对账与审计;幂等键用于去重。 - ledger := &models.TenantLedger{ - TenantID: tenantID, - OperatorUserID: operatorUserID, - UserID: userID, - OrderID: orderID, - BizRefType: bizRefType, - BizRefID: bizRefID, - Type: ledgerType, - Amount: amount, - BalanceBefore: balanceBefore, - BalanceAfter: balanceAfter, - FrozenBefore: frozenBefore, - FrozenAfter: frozenAfter, - IdempotencyKey: idempotencyKey, - Remark: remark, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(ledger).Error; err != nil { - // 并发下可能出现“先写成功后再重试”的情况:尝试按幂等键回读,保持接口幂等。 - if idempotencyKey != "" { - var existing models.TenantLedger - if e2 := tx. - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey). - First(&existing).Error; e2 == nil { - out.Ledger = &existing - out.User = &u - return nil - } - } - // 结构化幂等回读:当未传 idempotency_key 时按 biz_ref 回读。 - if idempotencyKey == "" && bizRefType != "" && bizRefID > 0 { - var existing models.TenantLedger - if e2 := tx. - Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType). - First(&existing).Error; e2 == nil { - out.Ledger = &existing - out.User = &u - return nil - } - } - return err - } - - u.Balance = balanceAfter - u.BalanceFrozen = frozenAfter - u.UpdatedAt = now - - out.Ledger = ledger - out.User = &u - return nil - }) - if err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "user_id": userID, - "order_id": orderID, - "biz_ref_type": bizRefType, - "biz_ref_id": bizRefID, - "type": ledgerType, - "idempotency_key": idempotencyKey, - }).WithError(err).Warn("services.ledger.apply.failed") - return nil, err - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "user_id": userID, - "order_id": orderID, - "biz_ref_type": bizRefType, - "biz_ref_id": bizRefID, - "type": ledgerType, - "ledger_id": out.Ledger.ID, - "idempotency_key": idempotencyKey, - "balance_after": out.User.Balance, - "frozen_after": out.User.BalanceFrozen, - }).Info("services.ledger.apply.ok") - - return &out, nil -} diff --git a/backend/app/services/ledger_test.go b/backend/app/services/ledger_test.go deleted file mode 100644 index 27d9230..0000000 --- a/backend/app/services/ledger_test.go +++ /dev/null @@ -1,386 +0,0 @@ -package services - -import ( - "context" - "database/sql" - "errors" - "fmt" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/samber/lo" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type LedgerTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type LedgerTestSuite struct { - suite.Suite - - LedgerTestSuiteInjectParams -} - -func Test_Ledger(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p LedgerTestSuiteInjectParams) { - suite.Run(t, &LedgerTestSuite{LedgerTestSuiteInjectParams: p}) - }) -} - -func (s *LedgerTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) { - database.Truncate(ctx, s.DB, models.TableNameTenantLedger, models.TableNameTenantUser, models.TableNameUser) - - now := time.Now().UTC() - _, err := s.DB.ExecContext(ctx, ` -INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen) -VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6) -ON CONFLICT (id) DO UPDATE -SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at -`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen) - So(err, ShouldBeNil) - - tu := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - CreatedAt: now, - UpdatedAt: now, - } - So(tu.Create(ctx), ShouldBeNil) -} - -func (s *LedgerTestSuite) Test_Freeze() { - Convey("Ledger.Freeze", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - Convey("金额非法应返回参数错误", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 0, "k_freeze_invalid_amount", "freeze", now) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeInvalidParameter) - }) - - Convey("成功冻结", func() { - res, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_1", "freeze", now) - So(err, ShouldBeNil) - So(res, ShouldNotBeNil) - So(res.Ledger, ShouldNotBeNil) - So(res.User, ShouldNotBeNil) - So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeFreeze) - So(res.Ledger.Amount, ShouldEqual, 300) - So(res.Ledger.BalanceBefore, ShouldEqual, 1000) - So(res.Ledger.BalanceAfter, ShouldEqual, 700) - So(res.Ledger.FrozenBefore, ShouldEqual, 0) - So(res.Ledger.FrozenAfter, ShouldEqual, 300) - So(res.Ledger.OperatorUserID, ShouldEqual, userID) - So(res.Ledger.BizRefType, ShouldEqual, "") - So(res.Ledger.BizRefID, ShouldEqual, int64(0)) - So(res.User.Balance, ShouldEqual, 700) - So(res.User.BalanceFrozen, ShouldEqual, 300) - }) - - Convey("幂等键重复调用不应重复扣减", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_idem", "freeze", now) - So(err, ShouldBeNil) - - res2, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_idem", "freeze", now.Add(time.Second)) - So(err, ShouldBeNil) - So(res2, ShouldNotBeNil) - So(res2.Ledger, ShouldNotBeNil) - So(res2.Ledger.IdempotencyKey, ShouldEqual, "k_freeze_idem") - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 700) - So(u2.BalanceFrozen, ShouldEqual, 300) - }) - - Convey("余额不足应返回前置条件失败", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 999999, "k_over", "freeze", now) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) - }) - }) -} - -func (s *LedgerTestSuite) Test_Unfreeze() { - Convey("Ledger.Unfreeze", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - Convey("金额非法应返回参数错误", func() { - _, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 0, "k_unfreeze_invalid_amount", "unfreeze", now) - So(err, ShouldNotBeNil) - }) - - Convey("冻结余额不足应返回前置条件失败", func() { - _, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 999999, "k_unfreeze_over", "unfreeze", now) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) - }) - - Convey("成功解冻", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_unfreeze", "freeze", now) - So(err, ShouldBeNil) - - res, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_ok", "unfreeze", now) - So(err, ShouldBeNil) - So(res, ShouldNotBeNil) - So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeUnfreeze) - So(res.Ledger.OperatorUserID, ShouldEqual, userID) - So(res.Ledger.BizRefType, ShouldEqual, "") - So(res.Ledger.BizRefID, ShouldEqual, int64(0)) - So(res.User.Balance, ShouldEqual, 1000) - So(res.User.BalanceFrozen, ShouldEqual, 0) - }) - - Convey("幂等键重复调用不应重复入账", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_unfreeze_idem", "freeze", now) - So(err, ShouldBeNil) - - _, err = Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_idem", "unfreeze", now) - So(err, ShouldBeNil) - - res2, err := Ledger.Unfreeze(ctx, tenantID, userID, 0, 300, "k_unfreeze_idem", "unfreeze", now.Add(time.Second)) - So(err, ShouldBeNil) - So(res2, ShouldNotBeNil) - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 1000) - So(u2.BalanceFrozen, ShouldEqual, 0) - }) - }) -} - -func (s *LedgerTestSuite) Test_DebitPurchaseTx() { - Convey("Ledger.DebitPurchaseTx", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - Convey("金额非法应返回参数错误", func() { - _, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_debit_invalid_amount", "debit", now) - So(err, ShouldNotBeNil) - }) - - Convey("冻结余额不足应返回前置条件失败", func() { - _, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_no_frozen", "debit", now) - So(err, ShouldNotBeNil) - }) - - Convey("成功扣款应减少冻结余额并保持可用余额不变", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit", "freeze", now) - So(err, ShouldBeNil) - - res, err := Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_1", "debit", now) - So(err, ShouldBeNil) - So(res, ShouldNotBeNil) - So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase) - So(res.Ledger.OperatorUserID, ShouldEqual, userID) - So(res.Ledger.BizRefType, ShouldEqual, "order") - So(res.Ledger.BizRefID, ShouldEqual, int64(123)) - So(res.User.Balance, ShouldEqual, 700) - So(res.User.BalanceFrozen, ShouldEqual, 0) - }) - - Convey("幂等键重复调用不应重复扣减冻结余额", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_debit_idem", "freeze", now) - So(err, ShouldBeNil) - - _, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now) - So(err, ShouldBeNil) - - _, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_idem", "debit", now.Add(time.Second)) - So(err, ShouldBeNil) - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 700) - So(u2.BalanceFrozen, ShouldEqual, 0) - }) - }) -} - -func (s *LedgerTestSuite) Test_CreditRefundTx() { - Convey("Ledger.CreditRefundTx", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - Convey("金额非法应返回参数错误", func() { - _, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 0, "k_refund_invalid_amount", "refund", now) - So(err, ShouldNotBeNil) - }) - - Convey("成功退款应增加可用余额", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund", "freeze", now) - So(err, ShouldBeNil) - _, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund", "debit", now) - So(err, ShouldBeNil) - - res, err := Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_1", "refund", now) - So(err, ShouldBeNil) - So(res, ShouldNotBeNil) - So(res.Ledger.Type, ShouldEqual, consts.TenantLedgerTypeCreditRefund) - So(res.Ledger.OperatorUserID, ShouldEqual, userID) - So(res.Ledger.BizRefType, ShouldEqual, "order") - So(res.Ledger.BizRefID, ShouldEqual, int64(123)) - So(res.User.Balance, ShouldEqual, 1000) - So(res.User.BalanceFrozen, ShouldEqual, 0) - }) - - Convey("幂等键重复调用不应重复退款入账", func() { - _, err := Ledger.Freeze(ctx, tenantID, userID, 0, 300, "k_freeze_for_refund_idem", "freeze", now) - So(err, ShouldBeNil) - _, err = Ledger.DebitPurchaseTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_debit_for_refund_idem", "debit", now) - So(err, ShouldBeNil) - - _, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now) - So(err, ShouldBeNil) - _, err = Ledger.CreditRefundTx(ctx, _db, tenantID, userID, userID, 123, 300, "k_refund_idem", "refund", now.Add(time.Second)) - So(err, ShouldBeNil) - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", userID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 1000) - }) - }) -} - -func (s *LedgerTestSuite) Test_MyBalance() { - Convey("Ledger.MyBalance", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - - s.seedTenantUser(ctx, tenantID, userID, 1000, 200) - - Convey("成功返回全局余额", func() { - m, err := Ledger.MyBalance(ctx, tenantID, userID) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(m.Balance, ShouldEqual, 1000) - So(m.BalanceFrozen, ShouldEqual, 200) - }) - - Convey("参数非法应返回错误", func() { - _, err := Ledger.MyBalance(ctx, 0, userID) - So(err, ShouldNotBeNil) - }) - }) -} - -func (s *LedgerTestSuite) Test_MyLedgerPage() { - Convey("Ledger.MyLedgerPage", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - _, err := Ledger.Freeze(ctx, tenantID, userID, 1, 200, "k_freeze_for_page_1", "freeze", now) - So(err, ShouldBeNil) - _, err = Ledger.Unfreeze(ctx, tenantID, userID, 1, 100, "k_unfreeze_for_page_1", "unfreeze", now.Add(time.Second)) - So(err, ShouldBeNil) - - Convey("分页返回流水列表", func() { - pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{}) - So(err, ShouldBeNil) - So(pager, ShouldNotBeNil) - So(pager.Total, ShouldBeGreaterThanOrEqualTo, 2) - }) - - Convey("按 type 过滤", func() { - typ := consts.TenantLedgerTypeFreeze - pager, err := Ledger.MyLedgerPage(ctx, tenantID, userID, &dto.MyLedgerListFilter{Type: &typ}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} - -func (s *LedgerTestSuite) Test_AdminLedgerPage() { - Convey("Ledger.AdminLedgerPage", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - now := time.Now().UTC() - - s.seedTenantUser(ctx, tenantID, userID, 1000, 0) - - // 模拟后台管理员为用户冻结资金:operator_user_id 与 user_id 不同。 - _, err := Ledger.FreezeTx(ctx, _db, tenantID, 999, userID, 777, 200, "k_admin_freeze_for_page", "freeze", now) - So(err, ShouldBeNil) - - Convey("按 operator_user_id 过滤", func() { - pager, err := Ledger.AdminLedgerPage(ctx, tenantID, &dto.AdminLedgerListFilter{ - OperatorUserID: lo.ToPtr(int64(999)), - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按 biz_ref_type + biz_ref_id 过滤", func() { - bizRefType := "order" - pager, err := Ledger.AdminLedgerPage(ctx, tenantID, &dto.AdminLedgerListFilter{ - BizRefType: &bizRefType, - BizRefID: lo.ToPtr(int64(777)), - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按 order_id 过滤", func() { - pager, err := Ledger.AdminLedgerPage(ctx, tenantID, &dto.AdminLedgerListFilter{ - OrderID: lo.ToPtr(int64(777)), - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} diff --git a/backend/app/services/media_asset.go b/backend/app/services/media_asset.go deleted file mode 100644 index f45988e..0000000 --- a/backend/app/services/media_asset.go +++ /dev/null @@ -1,687 +0,0 @@ -package services - -import ( - "context" - "crypto/rand" - "encoding/base32" - "encoding/json" - "errors" - "strconv" - "strings" - "time" - - "quyun/v2/app/errorx" - tenant_dto "quyun/v2/app/http/tenant/dto" - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - provider_job "quyun/v2/providers/job" - - pkgerrors "github.com/pkg/errors" - "github.com/samber/lo" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" - "go.ipao.vip/gen/types" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// mediaAsset 提供媒体资源上传初始化等能力(上传/处理链路会在后续里程碑补齐)。 -// -// @provider -type mediaAsset struct { - job *provider_job.Job -} - -func IsMediaAssetProcessJobNonRetryableError(err error) bool { - var appErr *errorx.AppError - if !errors.As(err, &appErr) { - return false - } - switch appErr.Code { - case errorx.CodeInvalidParameter, - errorx.CodeRecordNotFound, - errorx.CodeStatusConflict, - errorx.CodePreconditionFailed, - errorx.CodePermissionDenied: - return true - default: - return false - } -} - -func (s *mediaAsset) enqueueMediaAssetProcessJob(args jobs_args.MediaAssetProcessJob) error { - return s.job.Add(args) -} - -func mediaAssetTransitionAllowed(from, to consts.MediaAssetStatus) bool { - switch from { - case consts.MediaAssetStatusUploaded: - return to == consts.MediaAssetStatusProcessing - case consts.MediaAssetStatusProcessing: - return to == consts.MediaAssetStatusReady || to == consts.MediaAssetStatusFailed - case consts.MediaAssetStatusReady, consts.MediaAssetStatusFailed: - return to == consts.MediaAssetStatusDeleted - default: - return false - } -} - -func newObjectKey(tenantID, userID int64, assetType consts.MediaAssetType, now time.Time) (string, error) { - // object_key 作为存储定位的关键字段:必须由服务端生成,避免客户端路径注入与越权覆盖。 - buf := make([]byte, 16) // 128-bit - if _, err := rand.Read(buf); err != nil { - return "", err - } - token := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)) - date := now.UTC().Format("20060102") - return "tenants/" + strconv.FormatInt(tenantID, 10) + - "/users/" + strconv.FormatInt(userID, 10) + - "/" + string(assetType) + - "/" + date + - "/" + token, nil -} - -// AdminUploadInit creates a MediaAsset record and returns upload parameters. -// 当前版本为“stub 上传初始化”:只负责生成 asset 与 object_key,不对接外部存储签名。 -func (s *mediaAsset) AdminUploadInit(ctx context.Context, tenantID, operatorUserID int64, form *tenant_dto.AdminMediaAssetUploadInitForm, now time.Time) (*tenant_dto.AdminMediaAssetUploadInitResponse, error) { - if tenantID <= 0 || operatorUserID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") - } - if now.IsZero() { - now = time.Now() - } - - typ := consts.MediaAssetType(strings.TrimSpace(form.Type)) - if typ == "" || !typ.IsValid() { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid type") - } - - variant := consts.MediaAssetVariantMain - if form.Variant != nil { - variant = *form.Variant - } - if variant == "" || !variant.IsValid() { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid variant") - } - - var sourceAssetID int64 - if form.SourceAssetID != nil { - sourceAssetID = *form.SourceAssetID - } - if variant == consts.MediaAssetVariantMain { - if sourceAssetID != 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("source_asset_id is only allowed for preview variant") - } - } else { - // preview variant: requires a source main asset for traceability. - if sourceAssetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("source_asset_id is required for preview variant") - } - // 校验来源资源存在、同租户、未删除、且为 main 产物。 - tbl, query := models.MediaAssetQuery.QueryContext(ctx) - src, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(sourceAssetID), - tbl.DeletedAt.IsNull(), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("source media asset not found") - } - return nil, err - } - srcVariant := src.Variant - if srcVariant == "" { - srcVariant = consts.MediaAssetVariantMain - } - if srcVariant != consts.MediaAssetVariantMain { - return nil, errorx.ErrPreconditionFailed.WithMsg("source asset must be main variant") - } - } - - objectKey, err := newObjectKey(tenantID, operatorUserID, typ, now) - if err != nil { - return nil, pkgerrors.Wrap(err, "generate object_key failed") - } - - metaMap := map[string]any{} - if form.ContentType != "" { - metaMap["content_type"] = strings.TrimSpace(form.ContentType) - } - if form.FileSize > 0 { - metaMap["file_size"] = form.FileSize - } - if form.SHA256 != "" { - metaMap["sha256"] = strings.ToLower(strings.TrimSpace(form.SHA256)) - } - metaBytes, _ := json.Marshal(metaMap) - if len(metaBytes) == 0 { - metaBytes = []byte("{}") - } - - m := &models.MediaAsset{ - TenantID: tenantID, - UserID: operatorUserID, - Type: typ, - Status: consts.MediaAssetStatusUploaded, - Provider: "local", - Bucket: "", - ObjectKey: objectKey, - Meta: types.JSON(metaBytes), - CreatedAt: now, - UpdatedAt: now, - } - if err := m.Create(ctx); err != nil { - return nil, pkgerrors.Wrap(err, "create media asset failed") - } - - // variant/source_asset_id 目前为 DB 新增字段;由于 models 为 gen 产物,这里用 SQL 更新列值。 - tbl, query := models.MediaAssetQuery.QueryContext(ctx) - // variant/source_asset_id 已生成模型字段,使用 UpdateSimple 保持类型安全。 - assigns := []field.AssignExpr{ - tbl.Variant.Value(variant), - } - if sourceAssetID > 0 { - assigns = append(assigns, tbl.SourceAssetID.Value(sourceAssetID)) - } - if _, err := query.Where( - tbl.ID.Eq(m.ID), - tbl.TenantID.Eq(tenantID), - ).UpdateSimple(assigns...); err != nil { - return nil, pkgerrors.Wrap(err, "update media asset variant/source_asset_id failed") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": operatorUserID, - "asset_id": m.ID, - "type": typ, - "variant": variant, - "source_id": sourceAssetID, - "object_key": objectKey, - }).Info("services.media_asset.admin.upload_init") - - // 约定:upload_url 先返回空或内部占位;后续接入真实存储签名后再补齐。 - return &tenant_dto.AdminMediaAssetUploadInitResponse{ - AssetID: m.ID, - Provider: m.Provider, - Bucket: m.Bucket, - ObjectKey: m.ObjectKey, - UploadURL: "", - Headers: map[string]string{}, - FormFields: map[string]string{}, - ExpiresAt: nil, - }, nil -} - -// AdminUploadComplete marks the asset upload as completed and transitions status uploaded -> processing. -// 幂等语义: -// - 若当前已是 processing/ready/failed,则直接返回当前资源,不重复触发处理。 -// - 仅允许 uploaded 状态进入 processing;其他状态返回状态冲突/前置条件失败。 -func (s *mediaAsset) AdminUploadComplete( - ctx context.Context, - tenantID, operatorUserID, assetID int64, - form *tenant_dto.AdminMediaAssetUploadCompleteForm, - now time.Time, -) (*models.MediaAsset, error) { - if tenantID <= 0 || operatorUserID <= 0 || assetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/asset_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": operatorUserID, - "asset_id": assetID, - }).Info("services.media_asset.admin.upload_complete") - - var ( - out models.MediaAsset - needEnqueue bool - enqueueArgs jobs_args.MediaAssetProcessJob - ) - - err := models.Q.Transaction(func(tx *models.Query) error { - tbl, query := tx.MediaAsset.QueryContext(ctx) - m, err := query. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(assetID), - ). - First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return err - } - - // 软删除资源不允许进入处理流程。 - if m.DeletedAt.Valid { - return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") - } - - // 幂等:重复 upload_complete 时返回现态;但只要处于 processing,就允许再次触发入队(用于“上次入队失败”的补偿重试)。 - switch m.Status { - case consts.MediaAssetStatusReady, consts.MediaAssetStatusFailed: - out = *m - return nil - case consts.MediaAssetStatusProcessing: - out = *m - needEnqueue = true - enqueueArgs = jobs_args.MediaAssetProcessJob{TenantID: tenantID, AssetID: assetID} - return nil - case consts.MediaAssetStatusUploaded: - // allowed - default: - return errorx.ErrStatusConflict.WithMsg("invalid media asset status") - } - - // 合并 meta(尽量不覆盖已有字段)。 - meta := map[string]any{} - if len(m.Meta) > 0 { - _ = json.Unmarshal(m.Meta, &meta) - } - meta["upload_complete_at"] = now.UTC().Format(time.RFC3339Nano) - if form != nil { - if strings.TrimSpace(form.ETag) != "" { - meta["etag"] = strings.TrimSpace(form.ETag) - } - if strings.TrimSpace(form.ContentType) != "" { - meta["content_type"] = strings.TrimSpace(form.ContentType) - } - if form.FileSize > 0 { - meta["file_size"] = form.FileSize - } - if strings.TrimSpace(form.SHA256) != "" { - meta["sha256"] = strings.ToLower(strings.TrimSpace(form.SHA256)) - } - } - metaBytes, _ := json.Marshal(meta) - if len(metaBytes) == 0 { - metaBytes = []byte("{}") - } - - // 状态迁移:uploaded -> processing - if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusProcessing) { - return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") - } - if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ - "status": consts.MediaAssetStatusProcessing, - "meta": types.JSON(metaBytes), - "updated_at": now, - }); err != nil { - return err - } - - m.Status = consts.MediaAssetStatusProcessing - m.Meta = types.JSON(metaBytes) - m.UpdatedAt = now - out = *m - - needEnqueue = true - enqueueArgs = jobs_args.MediaAssetProcessJob{TenantID: tenantID, AssetID: assetID} - - return nil - }) - if err != nil { - return nil, err - } - - if needEnqueue { - // 注意:River 的唯一约束会将重复入队“软跳过”,因此这里允许多次触发以补偿偶发入队失败。 - if err := s.enqueueMediaAssetProcessJob(enqueueArgs); err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": operatorUserID, - "asset_id": assetID, - }).WithError(err).Warn("services.media_asset.process.enqueue_failed") - return nil, err - } - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": operatorUserID, - "asset_id": assetID, - }).Info("services.media_asset.process.enqueued") - } - - return &out, nil -} - -// ProcessSuccess marks a processing asset as ready. -// 用于异步处理链路(worker/job)回写处理结果;当前不暴露 HTTP 接口。 -func (s *mediaAsset) ProcessSuccess( - ctx context.Context, - tenantID, assetID int64, - metaPatch map[string]any, - now time.Time, -) (*models.MediaAsset, error) { - if tenantID <= 0 || assetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - var out models.MediaAsset - err := models.Q.Transaction(func(tx *models.Query) error { - tbl, query := tx.MediaAsset.QueryContext(ctx) - m, err := query. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(assetID), - ). - First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return err - } - if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { - return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") - } - if m.Status == consts.MediaAssetStatusReady { - out = *m - return nil - } - if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusReady) { - return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") - } - - meta := map[string]any{} - if len(m.Meta) > 0 { - _ = json.Unmarshal(m.Meta, &meta) - } - for k, v := range metaPatch { - if strings.TrimSpace(k) == "" { - continue - } - meta[k] = v - } - meta["processed_at"] = now.UTC().Format(time.RFC3339Nano) - metaBytes, _ := json.Marshal(meta) - if len(metaBytes) == 0 { - metaBytes = []byte("{}") - } - - if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ - "status": consts.MediaAssetStatusReady, - "meta": types.JSON(metaBytes), - "updated_at": now, - }); err != nil { - return err - } - m.Status = consts.MediaAssetStatusReady - m.Meta = types.JSON(metaBytes) - m.UpdatedAt = now - out = *m - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// ProcessFailed marks a processing asset as failed. -// 用于异步处理链路(worker/job)回写处理结果;当前不暴露 HTTP 接口。 -func (s *mediaAsset) ProcessFailed( - ctx context.Context, - tenantID, assetID int64, - reason string, - now time.Time, -) (*models.MediaAsset, error) { - if tenantID <= 0 || assetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - var out models.MediaAsset - err := models.Q.Transaction(func(tx *models.Query) error { - tbl, query := tx.MediaAsset.QueryContext(ctx) - m, err := query. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(assetID), - ). - First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return err - } - if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { - return errorx.ErrPreconditionFailed.WithMsg("media asset deleted") - } - if m.Status == consts.MediaAssetStatusFailed { - out = *m - return nil - } - if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusFailed) { - return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") - } - - meta := map[string]any{} - if len(m.Meta) > 0 { - _ = json.Unmarshal(m.Meta, &meta) - } - if strings.TrimSpace(reason) != "" { - meta["failed_reason"] = strings.TrimSpace(reason) - } - meta["failed_at"] = now.UTC().Format(time.RFC3339Nano) - metaBytes, _ := json.Marshal(meta) - if len(metaBytes) == 0 { - metaBytes = []byte("{}") - } - - if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ - "status": consts.MediaAssetStatusFailed, - "meta": types.JSON(metaBytes), - "updated_at": now, - }); err != nil { - return err - } - m.Status = consts.MediaAssetStatusFailed - m.Meta = types.JSON(metaBytes) - m.UpdatedAt = now - out = *m - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// AdminDelete soft-deletes a media asset (ready/failed -> deleted). -func (s *mediaAsset) AdminDelete(ctx context.Context, tenantID, operatorUserID, assetID int64, now time.Time) (*models.MediaAsset, error) { - if tenantID <= 0 || operatorUserID <= 0 || assetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/asset_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": operatorUserID, - "asset_id": assetID, - }).Info("services.media_asset.admin.delete") - - var out models.MediaAsset - err := models.Q.Transaction(func(tx *models.Query) error { - tbl, query := tx.MediaAsset.QueryContext(ctx) - m, err := query. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(assetID), - ). - First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return err - } - - // 幂等:已删除直接返回。 - if m.DeletedAt.Valid || m.Status == consts.MediaAssetStatusDeleted { - out = *m - return nil - } - - if !mediaAssetTransitionAllowed(m.Status, consts.MediaAssetStatusDeleted) { - return errorx.ErrStatusConflict.WithMsg("invalid media asset status transition") - } - - if _, err := query.Where(tbl.ID.Eq(m.ID)).Updates(map[string]any{ - "status": consts.MediaAssetStatusDeleted, - "updated_at": now, - }); err != nil { - return err - } - - if _, err := query.Where(tbl.ID.Eq(m.ID)).Delete(); err != nil { - return err - } - - m.Status = consts.MediaAssetStatusDeleted - m.UpdatedAt = now - out = *m - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// AdminPage 分页查询租户内媒体资源(租户管理)。 -func (s *mediaAsset) AdminPage(ctx context.Context, tenantID int64, filter *tenant_dto.AdminMediaAssetListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &tenant_dto.AdminMediaAssetListFilter{} - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "type": lo.FromPtr(filter.Type), - "status": lo.FromPtr(filter.Status), - "created_at_from": filter.CreatedAtFrom, - "created_at_to": filter.CreatedAtTo, - "sort_asc_fields": filter.AscFields(), - "sort_desc_fields": filter.DescFields(), - }).Info("services.media_asset.admin.page") - - filter.Pagination.Format() - - tbl, query := models.MediaAssetQuery.QueryContext(ctx) - - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.DeletedAt.IsNull(), - } - if filter.Type != nil { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 - orderBys := make([]field.Expr, 0, 4) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "updated_at": tbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "updated_at": tbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// AdminDetail 查询租户内媒体资源详情(租户管理)。 -func (s *mediaAsset) AdminDetail(ctx context.Context, tenantID, assetID int64) (*models.MediaAsset, error) { - if tenantID <= 0 || assetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/asset_id must be > 0") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "asset_id": assetID, - }).Info("services.media_asset.admin.detail") - - tbl, query := models.MediaAssetQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.ID.Eq(assetID), - tbl.DeletedAt.IsNull(), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return nil, err - } - return m, nil -} diff --git a/backend/app/services/media_asset_test.go b/backend/app/services/media_asset_test.go deleted file mode 100644 index 666d247..0000000 --- a/backend/app/services/media_asset_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package services - -import ( - "database/sql" - "errors" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/errorx" - tenant_dto "quyun/v2/app/http/tenant/dto" - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type MediaAssetTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type MediaAssetTestSuite struct { - suite.Suite - MediaAssetTestSuiteInjectParams -} - -func Test_MediaAsset(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p MediaAssetTestSuiteInjectParams) { - suite.Run(t, &MediaAssetTestSuite{MediaAssetTestSuiteInjectParams: p}) - }) -} - -func (s *MediaAssetTestSuite) Test_AdminUploadInit_VariantAndSource() { - Convey("MediaAsset.AdminUploadInit variant/source_asset_id", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameMediaAsset) - - Convey("main variant 不允许 source_asset_id", func() { - src := int64(123) - v := consts.MediaAssetVariantMain - _, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ - Type: "video", - Variant: &v, - SourceAssetID: &src, - }, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrInvalidParameter.Code) - }) - - Convey("preview variant 必须带 source_asset_id", func() { - v := consts.MediaAssetVariantPreview - _, err := MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ - Type: "video", - Variant: &v, - }, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrInvalidParameter.Code) - }) - - Convey("preview variant 的 source_asset_id 必须存在且为 main variant", func() { - src := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusReady, - Provider: "test", - Bucket: "b", - ObjectKey: "k", - Meta: types.JSON([]byte("{}")), - CreatedAt: now, - UpdatedAt: now, - } - So(src.Create(ctx), ShouldBeNil) - - // 将来源资源标记为 preview,模拟“来源不是 main”的非法情况。 - _, err := s.DB.ExecContext(ctx, "UPDATE media_assets SET variant = 'preview' WHERE tenant_id = $1 AND id = $2", tenantID, src.ID) - So(err, ShouldBeNil) - - v := consts.MediaAssetVariantPreview - _, err = MediaAsset.AdminUploadInit(ctx, tenantID, userID, &tenant_dto.AdminMediaAssetUploadInitForm{ - Type: "video", - Variant: &v, - SourceAssetID: &src.ID, - }, now) - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.ErrPreconditionFailed.Code) - }) - }) -} - -func (s *MediaAssetTestSuite) Test_AdminUploadComplete_EnqueueAndProcess() { - Convey("MediaAsset.AdminUploadComplete enqueue job and worker process", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, s.DB, "river_job", models.TableNameMediaAsset) - - asset := &models.MediaAsset{ - TenantID: tenantID, - UserID: userID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusUploaded, - Provider: "test", - Bucket: "b", - ObjectKey: "k", - Meta: []byte("{}"), - CreatedAt: now, - UpdatedAt: now, - } - So(asset.Create(ctx), ShouldBeNil) - - Convey("首次 upload_complete:uploaded -> processing,并入队一次", func() { - out, err := MediaAsset.AdminUploadComplete(ctx, tenantID, userID, asset.ID, nil, now) - So(err, ShouldBeNil) - So(out.Status, ShouldEqual, consts.MediaAssetStatusProcessing) - - var cnt int - err = s.DB.QueryRowContext(ctx, "SELECT COUNT(1) FROM river_job WHERE kind = $1", jobs_args.MediaAssetProcessJob{}.Kind()).Scan(&cnt) - So(err, ShouldBeNil) - So(cnt, ShouldEqual, 1) - - Convey("重复 upload_complete:仍可触发入队,但不会产生重复任务", func() { - out2, err := MediaAsset.AdminUploadComplete(ctx, tenantID, userID, asset.ID, nil, now.Add(1*time.Second)) - So(err, ShouldBeNil) - So(out2.Status, ShouldEqual, consts.MediaAssetStatusProcessing) - - err = s.DB.QueryRowContext(ctx, "SELECT COUNT(1) FROM river_job WHERE kind = $1", jobs_args.MediaAssetProcessJob{}.Kind()).Scan(&cnt) - So(err, ShouldBeNil) - So(cnt, ShouldEqual, 1) - }) - }) - }) -} diff --git a/backend/app/services/media_delivery.go b/backend/app/services/media_delivery.go deleted file mode 100644 index 14aaffe..0000000 --- a/backend/app/services/media_delivery.go +++ /dev/null @@ -1,274 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - provider_jwt "quyun/v2/providers/jwt" - - jwtlib "github.com/golang-jwt/jwt/v4" - log "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// mediaDelivery 负责“媒体播放 token -> 实际播放地址”的安全下发。 -// 当前版本只返回短时效 token 与 play endpoint;真实对象存储签名将在后续接入。 -// -// @provider -type mediaDelivery struct { - jwt *provider_jwt.JWT -} - -const defaultMediaPlayTokenTTL = 5 * time.Minute - -type mediaPlayClaims struct { - TenantID int64 `json:"tenant_id"` - ContentID int64 `json:"content_id"` - AssetID int64 `json:"asset_id"` - Role consts.ContentAssetRole `json:"role"` - ViewerUserID int64 `json:"viewer_user_id,omitempty"` - jwtlib.RegisteredClaims -} - -type MediaPlayResolutionKind string - -const ( - MediaPlayResolutionKindRedirect MediaPlayResolutionKind = "redirect" - MediaPlayResolutionKindLocalFile MediaPlayResolutionKind = "local_file" -) - -type MediaPlayResolution struct { - Kind MediaPlayResolutionKind - - RedirectURL string - - LocalFilePath string - ContentType string -} - -const ( - defaultLocalMediaRoot = "var/media" - envLocalMediaRoot = "MEDIA_LOCAL_ROOT" -) - -func (s *mediaDelivery) CreatePlayToken(tenantID, contentID, assetID int64, role consts.ContentAssetRole, viewerUserID int64, ttl time.Duration, now time.Time) (string, *time.Time, error) { - if tenantID <= 0 || contentID <= 0 || assetID <= 0 { - return "", nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/content_id/asset_id must be > 0") - } - if ttl <= 0 { - ttl = defaultMediaPlayTokenTTL - } - if now.IsZero() { - now = time.Now() - } - - exp := now.Add(ttl).UTC() - claims := &mediaPlayClaims{ - TenantID: tenantID, - ContentID: contentID, - AssetID: assetID, - Role: role, - ViewerUserID: viewerUserID, - RegisteredClaims: jwtlib.RegisteredClaims{ - Issuer: "v2-media", - IssuedAt: jwtlib.NewNumericDate(now.UTC()), - NotBefore: jwtlib.NewNumericDate(now.Add(-10 * time.Second).UTC()), - ExpiresAt: jwtlib.NewNumericDate(exp), - }, - } - - token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims) - signed, err := token.SignedString(s.jwt.SigningKey) - if err != nil { - return "", nil, errorx.Wrap(err).WithMsg("sign play token failed") - } - return signed, &exp, nil -} - -func (s *mediaDelivery) ParsePlayToken(tokenString string) (*mediaPlayClaims, error) { - token, err := jwtlib.ParseWithClaims(tokenString, &mediaPlayClaims{}, func(token *jwtlib.Token) (interface{}, error) { - if _, ok := token.Method.(*jwtlib.SigningMethodHMAC); !ok { - return nil, errorx.ErrSignatureInvalid.WithMsg("unexpected signing method") - } - return s.jwt.SigningKey, nil - }) - if err != nil { - var ve *jwtlib.ValidationError - if errors.As(err, &ve) { - switch { - case ve.Errors&jwtlib.ValidationErrorExpired != 0: - return nil, errorx.ErrDataExpired.WithMsg("play token expired") - case ve.Errors&jwtlib.ValidationErrorNotValidYet != 0: - return nil, errorx.ErrPreconditionFailed.WithMsg("play token not active yet") - case ve.Errors&jwtlib.ValidationErrorMalformed != 0: - return nil, errorx.ErrInvalidParameter.WithMsg("play token malformed") - default: - return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid") - } - } - return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid") - } - - claims, ok := token.Claims.(*mediaPlayClaims) - if !ok || !token.Valid || claims == nil { - return nil, errorx.ErrSignatureInvalid.WithMsg("play token invalid") - } - if claims.TenantID <= 0 || claims.ContentID <= 0 || claims.AssetID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("play token payload invalid") - } - return claims, nil -} - -func localMediaRoot() string { - if v := strings.TrimSpace(os.Getenv(envLocalMediaRoot)); v != "" { - return v - } - return defaultLocalMediaRoot -} - -func localMediaFilePath(root, objectKey string) (string, error) { - root = strings.TrimSpace(root) - if root == "" { - return "", errorx.ErrInternalError.WithMsg("local media root is empty") - } - if strings.TrimSpace(objectKey) == "" { - return "", errorx.ErrInternalError.WithMsg("object_key is empty") - } - if filepath.IsAbs(objectKey) { - return "", errorx.ErrForbidden.WithMsg("invalid object_key") - } - cleanKey := filepath.Clean(objectKey) - if cleanKey == "." || strings.HasPrefix(cleanKey, ".."+string(filepath.Separator)) || cleanKey == ".." { - return "", errorx.ErrForbidden.WithMsg("invalid object_key") - } - - rootClean := filepath.Clean(root) - full := filepath.Join(rootClean, cleanKey) - rel, err := filepath.Rel(rootClean, full) - if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { - return "", errorx.ErrForbidden.WithMsg("invalid object_key") - } - return full, nil -} - -func contentTypeFromAsset(asset *models.MediaAsset) string { - if asset == nil || len(asset.Meta) == 0 { - return "" - } - // 尽量从 meta.content_type 读取,避免本地文件无扩展名导致错误推断。 - // meta 的具体结构不稳定,因此只做最佳努力解析。 - var m map[string]any - _ = json.Unmarshal(asset.Meta, &m) - if v, ok := m["content_type"]; ok { - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - } - return "" -} - -// ResolvePlay resolves a play token to a redirect URL or a local file to serve. -// C1(local): 当 provider=local 时返回本地文件路径(不暴露 object_key)。 -func (s *mediaDelivery) ResolvePlay(ctx context.Context, tenantID int64, token string) (*MediaPlayResolution, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - claims, err := s.ParsePlayToken(token) - if err != nil { - return nil, err - } - if claims.TenantID != tenantID { - return nil, errorx.ErrForbidden.WithMsg("tenant mismatch") - } - - log.WithFields(log.Fields{ - "tenant_id": tenantID, - "content_id": claims.ContentID, - "asset_id": claims.AssetID, - "role": claims.Role, - "viewer_user_id": claims.ViewerUserID, - "exp": claims.ExpiresAt, - }).Info("services.media_delivery.resolve_play_redirect") - - tblAsset, queryAsset := models.MediaAssetQuery.QueryContext(ctx) - asset, err := queryAsset.Where( - tblAsset.TenantID.Eq(tenantID), - tblAsset.ID.Eq(claims.AssetID), - tblAsset.DeletedAt.IsNull(), - ).First() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("media asset not found") - } - return nil, err - } - if asset.Status != consts.MediaAssetStatusReady { - return nil, errorx.ErrPreconditionFailed.WithMsg("media asset not ready") - } - - // 二次校验:token 必须对应“该内容 + 该角色”的绑定关系,避免 token 被滥用到非预期内容。 - tblCA, queryCA := models.ContentAssetQuery.QueryContext(ctx) - if _, err := queryCA.Where( - tblCA.TenantID.Eq(tenantID), - tblCA.ContentID.Eq(claims.ContentID), - tblCA.AssetID.Eq(claims.AssetID), - tblCA.Role.Eq(claims.Role), - ).First(); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errorx.ErrRecordNotFound.WithMsg("content asset binding not found") - } - return nil, err - } - - // 约束:play endpoint 不返回 bucket/object_key: - // - local: 直接下发本地文件(由 /media/play 进行 sendfile),不暴露路径结构; - // - remote: 返回短时效签名 URL(后续接入)。 - switch asset.Provider { - case "local": - path, err := localMediaFilePath(localMediaRoot(), asset.ObjectKey) - if err != nil { - return nil, err - } - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return nil, errorx.ErrRecordNotFound.WithMsg("media file not found") - } - return nil, errorx.Wrap(err).WithMsg("stat media file failed") - } - - ct := contentTypeFromAsset(asset) - if ct == "" { - ct = "application/octet-stream" - } - - return &MediaPlayResolution{ - Kind: MediaPlayResolutionKindLocalFile, - LocalFilePath: path, - ContentType: ct, - }, nil - case "stub": - return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not configured") - default: - return nil, errorx.ErrServiceUnavailable.WithMsg("storage provider not implemented: " + asset.Provider) - } -} - -// ResolvePlayRedirect is kept for compatibility with earlier code paths. -func (s *mediaDelivery) ResolvePlayRedirect(ctx context.Context, tenantID int64, token string) (string, error) { - res, err := s.ResolvePlay(ctx, tenantID, token) - if err != nil { - return "", err - } - if res.Kind != MediaPlayResolutionKindRedirect || strings.TrimSpace(res.RedirectURL) == "" { - return "", errorx.ErrServiceUnavailable.WithMsg("play redirect not available") - } - return res.RedirectURL, nil -} diff --git a/backend/app/services/media_delivery_test.go b/backend/app/services/media_delivery_test.go deleted file mode 100644 index ef787fa..0000000 --- a/backend/app/services/media_delivery_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package services - -import ( - "database/sql" - "os" - "path/filepath" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type MediaDeliveryTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type MediaDeliveryTestSuite struct { - suite.Suite - MediaDeliveryTestSuiteInjectParams -} - -func Test_MediaDelivery(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p MediaDeliveryTestSuiteInjectParams) { - suite.Run(t, &MediaDeliveryTestSuite{MediaDeliveryTestSuiteInjectParams: p}) - }) -} - -func (s *MediaDeliveryTestSuite) Test_ResolvePlay_LocalFile() { - Convey("MediaDelivery.ResolvePlay local provider", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - viewerUserID := int64(2) - - database.Truncate(ctx, s.DB, models.TableNameContentAsset, models.TableNameMediaAsset, models.TableNameContent) - - content := &models.Content{ - TenantID: tenantID, - UserID: viewerUserID, - Title: "t", - Description: "", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityPublic, - PreviewSeconds: 60, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(content.Create(ctx), ShouldBeNil) - - objectKey := "tenants/1/users/2/video/test.bin" - asset := &models.MediaAsset{ - TenantID: tenantID, - UserID: viewerUserID, - Type: consts.MediaAssetTypeVideo, - Status: consts.MediaAssetStatusReady, - Provider: "local", - Bucket: "", - ObjectKey: objectKey, - Meta: types.JSON([]byte(`{"content_type":"application/octet-stream"}`)), - CreatedAt: now, - UpdatedAt: now, - } - So(asset.Create(ctx), ShouldBeNil) - - binding := &models.ContentAsset{ - TenantID: tenantID, - UserID: viewerUserID, - ContentID: content.ID, - AssetID: asset.ID, - Role: consts.ContentAssetRolePreview, - Sort: 1, - CreatedAt: now, - UpdatedAt: now, - } - So(binding.Create(ctx), ShouldBeNil) - - root := s.T().TempDir() - So(os.Setenv(envLocalMediaRoot, root), ShouldBeNil) - defer os.Unsetenv(envLocalMediaRoot) - - fullPath := filepath.Join(root, filepath.FromSlash(objectKey)) - So(os.MkdirAll(filepath.Dir(fullPath), 0o755), ShouldBeNil) - So(os.WriteFile(fullPath, []byte("hello"), 0o644), ShouldBeNil) - - token, _, err := MediaDelivery.CreatePlayToken(tenantID, content.ID, asset.ID, consts.ContentAssetRolePreview, viewerUserID, 0, now) - So(err, ShouldBeNil) - - res, err := MediaDelivery.ResolvePlay(ctx, tenantID, token) - So(err, ShouldBeNil) - So(res.Kind, ShouldEqual, MediaPlayResolutionKindLocalFile) - So(res.LocalFilePath, ShouldEqual, fullPath) - So(res.ContentType, ShouldEqual, "application/octet-stream") - }) -} diff --git a/backend/app/services/order.go b/backend/app/services/order.go deleted file mode 100644 index 6051848..0000000 --- a/backend/app/services/order.go +++ /dev/null @@ -1,1762 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "quyun/v2/app/errorx" - superdto "quyun/v2/app/http/super/dto" - "quyun/v2/app/http/tenant/dto" - jobs_args "quyun/v2/app/jobs/args" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/fields" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - provider_job "quyun/v2/providers/job" - - pkgerrors "github.com/pkg/errors" - "github.com/samber/lo" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "go.ipao.vip/gen/types" -) - -func newOrderSnapshot(kind consts.OrderType, payload any) types.JSONType[fields.OrdersSnapshot] { - b, err := json.Marshal(payload) - if err != nil || len(b) == 0 { - b = []byte("{}") - } - return types.NewJSONType(fields.OrdersSnapshot{ - Kind: string(kind), - Data: b, - }) -} - -// AdminOrderExportCSV 租户管理员导出订单列表(CSV 文本)。 -func (s *order) AdminOrderExportCSV( - ctx context.Context, - tenantID int64, - filter *dto.AdminOrderListFilter, -) (*dto.AdminOrderExportResponse, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &dto.AdminOrderListFilter{} - } - - // 导出属于高消耗操作:限制最大行数,避免拖垮数据库。 - const maxRows = 5000 - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "max_rows": maxRows, - "user_id": lo.FromPtr(filter.UserID), - "username": filter.UsernameTrimmed(), - "content_id": lo.FromPtr(filter.ContentID), - "content_title": filter.ContentTitleTrimmed(), - "type": lo.FromPtr(filter.Type), - "status": lo.FromPtr(filter.Status), - }).Info("services.order.admin.export_csv") - - tbl, query := models.OrderQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.UserID != nil { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Type != nil { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - if filter.PaidAtFrom != nil { - conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom)) - } - if filter.PaidAtTo != nil { - conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo)) - } - if filter.AmountPaidMin != nil { - conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin)) - } - if filter.AmountPaidMax != nil { - conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax)) - } - - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != "" - if needItemJoin { - oiTbl, _ := models.OrderItemQuery.QueryContext(ctx) - query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID)) - if filter.ContentID != nil && *filter.ContentID > 0 { - conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) - } - if title := filter.ContentTitleTrimmed(); title != "" { - cTbl, _ := models.ContentQuery.QueryContext(ctx) - query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID)) - conds = append(conds, cTbl.Title.Like(database.WrapLike(title))) - } - query = query.Group(tbl.ID) - } - - // 排序:复用 AdminOrderPage 的白名单,避免任意字段导致注入/慢查询。 - orderBys := make([]field.Expr, 0, 4) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "paid_at": tbl.PaidAt.Asc(), - "amount_paid": tbl.AmountPaid.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "paid_at": tbl.PaidAt.Desc(), - "amount_paid": tbl.AmountPaid.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - items, err := query.Where(conds...).Order(orderBys...).Limit(maxRows).Find() - if err != nil { - return nil, err - } - - buf := &bytes.Buffer{} - w := csv.NewWriter(buf) - _ = w.Write([]string{"id", "tenant_id", "user_id", "type", "status", "amount_paid", "paid_at", "created_at"}) - for _, it := range items { - if it == nil { - continue - } - paidAt := "" - if !it.PaidAt.IsZero() { - paidAt = it.PaidAt.UTC().Format(time.RFC3339) - } - _ = w.Write([]string{ - fmt.Sprintf("%d", it.ID), - fmt.Sprintf("%d", it.TenantID), - fmt.Sprintf("%d", it.UserID), - string(it.Type), - string(it.Status), - fmt.Sprintf("%d", it.AmountPaid), - paidAt, - it.CreatedAt.UTC().Format(time.RFC3339), - }) - } - w.Flush() - if err := w.Error(); err != nil { - return nil, err - } - - filename := fmt.Sprintf("tenant_%d_orders_%s.csv", tenantID, time.Now().UTC().Format("20060102_150405")) - return &dto.AdminOrderExportResponse{ - Filename: filename, - ContentType: "text/csv", - CSV: buf.String(), - }, nil -} - -// SuperOrderPage 平台侧分页查询订单(跨租户)。 -func (s *order) SuperOrderPage(ctx context.Context, filter *superdto.OrderPageFilter) (*requests.Pager, error) { - if filter == nil { - filter = &superdto.OrderPageFilter{} - } - - filter.Pagination.Format() - - tbl, query := models.OrderQuery.QueryContext(ctx) - conds := []gen.Condition{} - - if filter.ID != nil && *filter.ID > 0 { - conds = append(conds, tbl.ID.Eq(*filter.ID)) - } - if filter.TenantID != nil && *filter.TenantID > 0 { - conds = append(conds, tbl.TenantID.Eq(*filter.TenantID)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Type != nil && *filter.Type != "" { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - if filter.PaidAtFrom != nil { - conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom)) - } - if filter.PaidAtTo != nil { - conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo)) - } - if filter.AmountPaidMin != nil { - conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin)) - } - if filter.AmountPaidMax != nil { - conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax)) - } - - // 买家用户名关键字。 - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - // 租户 code/name 关键字。 - tenantCode := filter.TenantCodeTrimmed() - tenantName := filter.TenantNameTrimmed() - if tenantCode != "" || tenantName != "" { - tTbl, _ := models.TenantQuery.QueryContext(ctx) - query = query.LeftJoin(tTbl, tTbl.ID.EqCol(tbl.TenantID)) - if tenantCode != "" { - conds = append(conds, tTbl.Code.Like(database.WrapLike(tenantCode))) - } - if tenantName != "" { - conds = append(conds, tTbl.Name.Like(database.WrapLike(tenantName))) - } - } - - // 内容过滤(orders 与 order_items 一对多,需要 group by)。 - needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != "" - if needItemJoin { - oiTbl, _ := models.OrderItemQuery.QueryContext(ctx) - query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID)) - - if filter.ContentID != nil && *filter.ContentID > 0 { - conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) - } - if title := filter.ContentTitleTrimmed(); title != "" { - cTbl, _ := models.ContentQuery.QueryContext(ctx) - query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID)) - conds = append(conds, cTbl.Title.Like(database.WrapLike(title))) - } - query = query.Group(tbl.ID) - } - - // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 - orderBys := make([]field.Expr, 0, 6) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "tenant_id": tbl.TenantID.Asc(), - "user_id": tbl.UserID.Asc(), - "status": tbl.Status.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "paid_at": tbl.PaidAt.Asc(), - "amount_paid": tbl.AmountPaid.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "tenant_id": tbl.TenantID.Desc(), - "user_id": tbl.UserID.Desc(), - "status": tbl.Status.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "paid_at": tbl.PaidAt.Desc(), - "amount_paid": tbl.AmountPaid.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - orders, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - tenantIDs := make([]int64, 0, len(orders)) - userIDs := make([]int64, 0, len(orders)) - for _, o := range orders { - if o == nil { - continue - } - if o.TenantID > 0 { - tenantIDs = append(tenantIDs, o.TenantID) - } - if o.UserID > 0 { - userIDs = append(userIDs, o.UserID) - } - } - tenantIDs = lo.Uniq(tenantIDs) - userIDs = lo.Uniq(userIDs) - - tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) - if len(tenantIDs) > 0 { - tTbl, tQuery := models.TenantQuery.QueryContext(ctx) - tenants, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() - if err != nil { - return nil, err - } - for _, te := range tenants { - if te == nil { - continue - } - tenantMap[te.ID] = te - } - } - - userMap := make(map[int64]*models.User, len(userIDs)) - if len(userIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() - if err != nil { - return nil, err - } - for _, u := range users { - if u == nil { - continue - } - userMap[u.ID] = u - } - } - - items := lo.Map(orders, func(o *models.Order, _ int) *superdto.SuperOrderItem { - if o == nil { - return &superdto.SuperOrderItem{} - } - - var tenantLite *superdto.OrderTenantLite - if te := tenantMap[o.TenantID]; te != nil { - tenantLite = &superdto.OrderTenantLite{ID: te.ID, Code: te.Code, Name: te.Name} - } - - var buyerLite *superdto.OrderBuyerLite - if u := userMap[o.UserID]; u != nil { - buyerLite = &superdto.OrderBuyerLite{ID: u.ID, Username: u.Username} - } - - return &superdto.SuperOrderItem{ - ID: o.ID, - Tenant: tenantLite, - Buyer: buyerLite, - Type: o.Type, - Status: o.Status, - StatusDescription: o.Status.Description(), - Currency: o.Currency, - AmountOriginal: o.AmountOriginal, - AmountDiscount: o.AmountDiscount, - AmountPaid: o.AmountPaid, - PaidAt: o.PaidAt, - RefundedAt: o.RefundedAt, - CreatedAt: o.CreatedAt, - UpdatedAt: o.UpdatedAt, - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// SuperOrderDetail 平台侧订单详情(跨租户)。 -func (s *order) SuperOrderDetail(ctx context.Context, orderID int64) (*superdto.SuperOrderDetail, error) { - if orderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("order_id must be > 0") - } - - tbl, query := models.OrderQuery.QueryContext(ctx) - orderModel, err := query.Preload(tbl.Items).Where(tbl.ID.Eq(orderID)).First() - if err != nil { - return nil, err - } - - var tenantLite *superdto.OrderTenantLite - if orderModel.TenantID > 0 { - tTbl, tQuery := models.TenantQuery.QueryContext(ctx) - tenantModel, err := tQuery.Where(tTbl.ID.Eq(orderModel.TenantID)).First() - if err != nil { - return nil, err - } - tenantLite = &superdto.OrderTenantLite{ID: tenantModel.ID, Code: tenantModel.Code, Name: tenantModel.Name} - } - - var buyerLite *superdto.OrderBuyerLite - if orderModel.UserID > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - userModel, err := uQuery.Where(uTbl.ID.Eq(orderModel.UserID)).First() - if err != nil { - return nil, err - } - buyerLite = &superdto.OrderBuyerLite{ID: userModel.ID, Username: userModel.Username} - } - - return &superdto.SuperOrderDetail{ - Order: orderModel, - Tenant: tenantLite, - Buyer: buyerLite, - }, nil -} - -// SuperRefundOrder 平台侧发起退款(跨租户)。 -func (s *order) SuperRefundOrder( - ctx context.Context, - operatorUserID, orderID int64, - force bool, - reason, idempotencyKey string, - now time.Time, -) (*models.Order, error) { - if operatorUserID <= 0 || orderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("operator_user_id/order_id must be > 0") - } - - tbl, query := models.OrderQuery.QueryContext(ctx) - orderModel, err := query.Where(tbl.ID.Eq(orderID)).First() - if err != nil { - return nil, err - } - - return s.AdminRefundOrder(ctx, orderModel.TenantID, operatorUserID, orderID, force, reason, idempotencyKey, now) -} - -// PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 -type PurchaseContentParams struct { - // TenantID 租户 ID(多租户隔离范围)。 - TenantID int64 - // UserID 购买者用户 ID。 - UserID int64 - // ContentID 内容 ID。 - ContentID int64 - // IdempotencyKey 幂等键:用于确保同一购买请求“至多处理一次”。 - IdempotencyKey string - // Now 逻辑时间:用于 created_at/paid_at 与账本快照(可选,便于测试/一致性)。 - Now time.Time -} - -// PurchaseContentResult 为购买结果(幂等命中时返回已存在的订单/权益状态)。 -type PurchaseContentResult struct { - // Order 订单记录(可能为 nil:例如“已购买且无订单上下文”的快捷路径)。 - Order *models.Order - // OrderItem 订单明细(本业务为单内容购买,通常只有 1 条)。 - OrderItem *models.OrderItem - // Access 内容权益(购买完成后应为 active)。 - Access *models.ContentAccess - // AmountPaid 实付金额(单位:分,CNY)。 - AmountPaid int64 -} - -// order 提供订单域能力(购买、退款、查询等)。 -// -// @provider -type order struct { - db *gorm.DB - ledger *ledger - job *provider_job.Job -} - -// SuperStatistics 平台侧订单统计(不限定 tenant_id)。 -func (s *order) SuperStatistics(ctx context.Context) (*superdto.OrderStatisticsResponse, error) { - tbl, query := models.OrderQuery.QueryContext(ctx) - - var rows []*superdto.OrderStatisticsRow - err := query.Select( - tbl.Status, - tbl.ID.Count().As("count"), - tbl.AmountPaid.Sum().As("amount_paid_sum"), - ).Group(tbl.Status).Scan(&rows) - if err != nil { - return nil, err - } - - var totalCount int64 - var totalAmountPaidSum int64 - for _, row := range rows { - if row == nil { - continue - } - row.StatusDescription = row.Status.Description() - totalCount += row.Count - totalAmountPaidSum += row.AmountPaidSum - } - - return &superdto.OrderStatisticsResponse{ - TotalCount: totalCount, - TotalAmountPaidSum: totalAmountPaidSum, - ByStatus: rows, - }, nil -} - -type ProcessRefundingOrderParams struct { - // TenantID 租户ID。 - TenantID int64 - // OrderID 订单ID。 - OrderID int64 - // OperatorUserID 退款操作人用户ID(用于补齐订单审计字段)。 - OperatorUserID int64 - // Force 是否强制退款(用于补齐订单审计字段)。 - Force bool - // Reason 退款原因(用于补齐订单审计字段)。 - Reason string - // Now 逻辑时间(用于 refunded_at/updated_at)。 - Now time.Time -} - -func IsRefundJobNonRetryableError(err error) bool { - var appErr *errorx.AppError - if !errors.As(err, &appErr) { - return false - } - switch appErr.Code { - case errorx.CodeInvalidParameter, - errorx.CodeRecordNotFound, - errorx.CodeStatusConflict, - errorx.CodePreconditionFailed, - errorx.CodePermissionDenied: - return true - default: - return false - } -} - -func (s *order) enqueueOrderRefundJob(args jobs_args.OrderRefundJob) error { - return s.job.Add(args) -} - -// MyOrderPage 分页查询当前用户在租户内的订单。 -func (s *order) MyOrderPage( - ctx context.Context, - tenantID, userID int64, - filter *dto.MyOrderListFilter, -) (*requests.Pager, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0") - } - if filter == nil { - filter = &dto.MyOrderListFilter{} - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "status": lo.FromPtr(filter.Status), - "content_id": lo.FromPtr(filter.ContentID), - }).Info("services.order.me.page") - - filter.Pagination.Format() - - tbl, query := models.OrderQuery.QueryContext(ctx) - query = query.Preload(tbl.Items) - - conds := []gen.Condition{ - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - } - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.PaidAtFrom != nil { - conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom)) - } - if filter.PaidAtTo != nil { - conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo)) - } - if filter.ContentID != nil && *filter.ContentID > 0 { - oiTbl, _ := models.OrderItemQuery.QueryContext(ctx) - query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID)) - conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) - query = query.Group(tbl.ID) - } - - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// MyOrderDetail 查询当前用户在租户内的订单详情。 -func (s *order) MyOrderDetail(ctx context.Context, tenantID, userID, orderID int64) (*models.Order, error) { - if tenantID <= 0 || userID <= 0 || orderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/order_id must be > 0") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "order_id": orderID, - }).Info("services.order.me.detail") - - tbl, query := models.OrderQuery.QueryContext(ctx) - m, err := query.Preload(tbl.Items).Where( - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - tbl.ID.Eq(orderID), - ).First() - if err != nil { - return nil, err - } - return m, nil -} - -// AdminOrderPage 租户管理员分页查询租户内订单。 -func (s *order) AdminOrderPage( - ctx context.Context, - tenantID int64, - filter *dto.AdminOrderListFilter, -) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &dto.AdminOrderListFilter{} - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": lo.FromPtr(filter.UserID), - "username": filter.UsernameTrimmed(), - "content_id": lo.FromPtr(filter.ContentID), - "content_title": filter.ContentTitleTrimmed(), - "type": lo.FromPtr(filter.Type), - "status": lo.FromPtr(filter.Status), - "created_at_from": filter.CreatedAtFrom, - "created_at_to": filter.CreatedAtTo, - "paid_at_from": filter.PaidAtFrom, - "paid_at_to": filter.PaidAtTo, - "amount_paid_min": filter.AmountPaidMin, - "amount_paid_max": filter.AmountPaidMax, - }).Info("services.order.admin.page") - - filter.Pagination.Format() - - tbl, query := models.OrderQuery.QueryContext(ctx) - query = query.Preload(tbl.Items) - - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.UserID != nil { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Type != nil { - conds = append(conds, tbl.Type.Eq(*filter.Type)) - } - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - if filter.PaidAtFrom != nil { - conds = append(conds, tbl.PaidAt.Gte(*filter.PaidAtFrom)) - } - if filter.PaidAtTo != nil { - conds = append(conds, tbl.PaidAt.Lte(*filter.PaidAtTo)) - } - if filter.AmountPaidMin != nil { - conds = append(conds, tbl.AmountPaid.Gte(*filter.AmountPaidMin)) - } - if filter.AmountPaidMax != nil { - conds = append(conds, tbl.AmountPaid.Lte(*filter.AmountPaidMax)) - } - - // 用户关键字:按 users.username 模糊匹配。 - // 关键点:orders.user_id 与 users.id 一对一,不会导致重复行,无需 group by。 - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - // 内容过滤:通过 order_items(以及 contents)关联查询。 - // 关键点:orders 与 order_items 一对多,join 后必须 group by orders.id 以避免同一订单重复返回。 - needItemJoin := (filter.ContentID != nil && *filter.ContentID > 0) || filter.ContentTitleTrimmed() != "" - if needItemJoin { - oiTbl, _ := models.OrderItemQuery.QueryContext(ctx) - query = query.LeftJoin(oiTbl, oiTbl.OrderID.EqCol(tbl.ID)) - - if filter.ContentID != nil && *filter.ContentID > 0 { - conds = append(conds, oiTbl.ContentID.Eq(*filter.ContentID)) - } - - if title := filter.ContentTitleTrimmed(); title != "" { - cTbl, _ := models.ContentQuery.QueryContext(ctx) - query = query.LeftJoin(cTbl, cTbl.ID.EqCol(oiTbl.ContentID)) - conds = append(conds, cTbl.Title.Like(database.WrapLike(title))) - } - - query = query.Group(tbl.ID) - } - - // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 - // 约定:只允许按以下字段排序;未指定时默认按 id desc。 - orderBys := make([]field.Expr, 0, 4) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "paid_at": tbl.PaidAt.Asc(), - "amount_paid": tbl.AmountPaid.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "paid_at": tbl.PaidAt.Desc(), - "amount_paid": tbl.AmountPaid.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - // 默认加上 id desc 作为稳定排序(尤其是 join + group 的场景)。 - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// AdminOrderDetail 租户管理员查询租户内订单详情。 -func (s *order) AdminOrderDetail(ctx context.Context, tenantID, orderID int64) (*models.Order, error) { - if tenantID <= 0 || orderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "order_id": orderID, - }).Info("services.order.admin.detail") - - tbl, query := models.OrderQuery.QueryContext(ctx) - m, err := query.Preload(tbl.Items).Where(tbl.TenantID.Eq(tenantID), tbl.ID.Eq(orderID)).First() - if err != nil { - return nil, err - } - return m, nil -} - -// AdminRefundOrder 发起已支付订单退款(支持强制退款)。 -// -// 语义: -// - 该方法只负责将订单从 paid 推进到 refunding,并入队异步退款任务; -// - 退款入账与权益回收由 job/worker 异步完成(见 ProcessRefundingOrder)。 -func (s *order) AdminRefundOrder( - ctx context.Context, - tenantID, operatorUserID, orderID int64, - force bool, - reason, idempotencyKey string, - now time.Time, -) (*models.Order, error) { - if tenantID <= 0 || operatorUserID <= 0 || orderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/operator_user_id/order_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "order_id": orderID, - "force": force, - "idempotency_key": idempotencyKey, - }).Info("services.order.admin.refund") - - var out *models.Order - - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 行锁锁住订单,避免并发退款/重复退款导致状态错乱。 - var orderModel models.Order - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Preload("Items"). - Where("tenant_id = ? AND id = ?", tenantID, orderID). - First(&orderModel).Error; err != nil { - return err - } - - // 状态机:已退款/退款中直接幂等返回;仅允许已支付订单发起退款请求。 - if orderModel.Status == consts.OrderStatusRefunded { - out = &orderModel - return nil - } - if orderModel.Status == consts.OrderStatusRefunding { - out = &orderModel - return nil - } - - // 允许从 failed 重新发起退款:失败状态表示“上一次异步退款未完成/被标记失败”,可由管理员重试推进到 refunding。 - if orderModel.Status != consts.OrderStatusPaid && orderModel.Status != consts.OrderStatusFailed { - return errorx.ErrStatusConflict.WithMsg("订单非已支付状态,无法退款") - } - if orderModel.PaidAt.IsZero() { - return errorx.ErrPreconditionFailed.WithMsg("订单缺少 paid_at,无法退款") - } - - // 时间窗:默认 paid_at + 24h;force=true 可绕过。 - if !force { - deadline := orderModel.PaidAt.Add(consts.DefaultOrderRefundWindow) - if now.After(deadline) { - return errorx.ErrPreconditionFailed.WithMsg("已超过默认退款时间窗") - } - } - - // 将订单推进到 refunding,并记录本次请求的审计字段;实际退款逻辑由异步 job 完成。 - if err := tx.Table(models.TableNameOrder). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusRefunding, - "refund_forced": force, - "refund_operator_user_id": operatorUserID, - "refund_reason": reason, - "updated_at": now, - }).Error; err != nil { - return err - } - - orderModel.Status = consts.OrderStatusRefunding - orderModel.RefundForced = force - orderModel.RefundOperatorUserID = operatorUserID - orderModel.RefundReason = reason - orderModel.UpdatedAt = now - - out = &orderModel - return nil - }) - if err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "order_id": orderID, - "force": force, - "idempotency_key": idempotencyKey, - }).WithError(err).Warn("services.order.admin.refund.failed") - return nil, err - } - - // refunding 状态需要确保异步任务已入队:入队失败则返回错误,调用方可重试(幂等)。 - if out != nil && out.Status == consts.OrderStatusRefunding { - err := s.enqueueOrderRefundJob(jobs_args.OrderRefundJob{ - TenantID: tenantID, - OrderID: out.ID, - OperatorUserID: operatorUserID, - Force: force, - Reason: reason, - }) - if err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "order_id": out.ID, - }).WithError(err).Warn("services.order.admin.refund.enqueue_failed") - return nil, err - } - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "order_id": out.ID, - }).Info("services.order.admin.refund.enqueued") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "order_id": orderID, - "status": out.Status, - "refund_forced": out.RefundForced, - }).Info("services.order.admin.refund.ok") - - return out, nil -} - -// ProcessRefundingOrder 处理 refunding 订单:退余额、回收权益、推进到 refunded。 -// 供异步 job/worker 调用;需保持幂等。 -func (s *order) ProcessRefundingOrder(ctx context.Context, params *ProcessRefundingOrderParams) (*models.Order, error) { - if params == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("params is required") - } - if params.TenantID <= 0 || params.OrderID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0") - } - if params.Now.IsZero() { - params.Now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "order_id": params.OrderID, - "operator_user_id": params.OperatorUserID, - "force": params.Force, - }).Info("services.order.refund.process") - - var out *models.Order - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var orderModel models.Order - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Preload("Items"). - Where("tenant_id = ? AND id = ?", params.TenantID, params.OrderID). - First(&orderModel).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("order not found") - } - return err - } - - // 幂等:已退款/已失败直接返回。 - if orderModel.Status == consts.OrderStatusRefunded || orderModel.Status == consts.OrderStatusFailed { - out = &orderModel - return nil - } - - // 仅允许 refunding 状态进入处理链路:paid->refunding 必须由接口层完成并记录审计字段。 - if orderModel.Status != consts.OrderStatusRefunding { - // 不可重试:状态不符合预期,直接标记 failed,避免 job 无限重试。 - _ = tx.Table(models.TableNameOrder). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusFailed, - "updated_at": params.Now, - }).Error - return errorx.ErrStatusConflict.WithMsg("order not in refunding status") - } - - // 补齐审计字段:以订单字段为准;若为空则用 job 参数兜底(避免历史数据/异常路径导致缺失)。 - operatorUserID := orderModel.RefundOperatorUserID - if operatorUserID == 0 { - operatorUserID = params.OperatorUserID - } - reason := orderModel.RefundReason - if strings.TrimSpace(reason) == "" { - reason = strings.TrimSpace(params.Reason) - } - force := orderModel.RefundForced - if !force { - force = params.Force - } - - amount := orderModel.AmountPaid - refundKey := fmt.Sprintf("refund:%d", orderModel.ID) - - // 先退余额(账本入账),后回收权益,最后推进订单终态:保证退款可对账且可追溯。 - if amount > 0 { - if _, err := s.ledger.CreditRefundTx(ctx, tx, params.TenantID, operatorUserID, orderModel.UserID, orderModel.ID, amount, refundKey, reason, params.Now); err != nil { - return err - } - } - - for _, item := range orderModel.Items { - if item == nil { - continue - } - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, orderModel.UserID, item.ContentID). - Updates(map[string]any{ - "status": consts.ContentAccessStatusRevoked, - "revoked_at": params.Now, - "updated_at": params.Now, - }).Error; err != nil { - return err - } - } - - if err := tx.Table(models.TableNameOrder). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusRefunded, - "refunded_at": params.Now, - "refund_forced": force, - "refund_operator_user_id": operatorUserID, - "refund_reason": reason, - "updated_at": params.Now, - }).Error; err != nil { - return err - } - - orderModel.Status = consts.OrderStatusRefunded - orderModel.RefundedAt = params.Now - orderModel.RefundForced = force - orderModel.RefundOperatorUserID = operatorUserID - orderModel.RefundReason = reason - orderModel.UpdatedAt = params.Now - - out = &orderModel - return nil - }) - if err != nil { - // 不可重试错误由 worker 负责 JobCancel;这里保持原始 error 以便判定。 - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "order_id": params.OrderID, - }).WithError(err).Warn("services.order.refund.process.failed") - return nil, err - } - return out, nil -} - -// MarkRefundFailed marks an order as failed during async refund processing. -// 仅用于 worker 在判定“不可重试”错误时落终态,避免订单长期停留在 refunding。 -func (s *order) MarkRefundFailed(ctx context.Context, tenantID, orderID int64, now time.Time) error { - if tenantID <= 0 || orderID <= 0 { - return errorx.ErrInvalidParameter.WithMsg("tenant_id/order_id must be > 0") - } - if now.IsZero() { - now = time.Now() - } - - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var orderModel models.Order - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND id = ?", tenantID, orderID). - First(&orderModel).Error; err != nil { - return err - } - - // 已退款/已失败都无需变更。 - if orderModel.Status == consts.OrderStatusRefunded || orderModel.Status == consts.OrderStatusFailed { - return nil - } - - return tx.Table(models.TableNameOrder). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusFailed, - "updated_at": now, - }).Error - }) -} - -func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentParams) (*PurchaseContentResult, error) { - if params == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("params is required") - } - if params.TenantID <= 0 || params.UserID <= 0 || params.ContentID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id/content_id must be > 0") - } - - now := params.Now - if now.IsZero() { - now = time.Now() - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "user_id": params.UserID, - "content_id": params.ContentID, - "idempotency_key": params.IdempotencyKey, - }).Info("services.order.purchase_content") - - var out PurchaseContentResult - - // 幂等购买采用“三段式”流程,保证一致性: - // 1) 先独立事务冻结余额(预留资金); - // 2) 再用单事务写订单+扣款+授予权益; - // 3) 若第 2 步失败,则解冻并写入回滚标记,保证重试稳定返回“失败+已回滚”。 - if params.IdempotencyKey != "" { - freezeKey := fmt.Sprintf("%s:freeze", params.IdempotencyKey) - debitKey := fmt.Sprintf("%s:debit", params.IdempotencyKey) - rollbackKey := fmt.Sprintf("%s:rollback", params.IdempotencyKey) - - // 1) 若该幂等键已生成订单,则直接返回订单与权益(幂等命中)。 - { - tbl, query := models.OrderQuery.QueryContext(ctx) - existing, err := query.Preload(tbl.Items).Where( - tbl.TenantID.Eq(params.TenantID), - tbl.UserID.Eq(params.UserID), - tbl.IdempotencyKey.Eq(params.IdempotencyKey), - ).First() - if err == nil { - out.Order = existing - if len(existing.Items) > 0 { - out.OrderItem = existing.Items[0] - } - out.AmountPaid = existing.AmountPaid - if out.OrderItem != nil { - aTbl, aQuery := models.ContentAccessQuery.QueryContext(ctx) - access, err := aQuery.Where( - aTbl.TenantID.Eq(params.TenantID), - aTbl.UserID.Eq(params.UserID), - aTbl.ContentID.Eq(out.OrderItem.ContentID), - ).First() - if err == nil { - out.Access = access - } - } - return &out, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - - // 2) 若历史已回滚过该幂等请求,则稳定返回“失败+已回滚”(避免重复冻结/重复扣款)。 - { - tbl, query := models.TenantLedgerQuery.QueryContext(ctx) - _, err := query.Where( - tbl.TenantID.Eq(params.TenantID), - tbl.UserID.Eq(params.UserID), - tbl.IdempotencyKey.Eq(rollbackKey), - ).First() - if err == nil { - return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚") - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - - // 查询内容与价格:放在事务外简化逻辑;后续以订单事务为准。 - var content models.Content - { - tbl, query := models.ContentQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(params.TenantID), - tbl.ID.Eq(params.ContentID), - tbl.DeletedAt.IsNull(), - ).First() - if err != nil { - return nil, err - } - content = *m - } - if content.Status != consts.ContentStatusPublished { - return nil, errorx.ErrPreconditionFailed.WithMsg("content not published") - } - - // 作者自购:直接授予权益(不走余额冻结/扣款)。 - if content.UserID == params.UserID { - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, 0, now); err != nil { - return err - } - var access models.ContentAccess - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&access).Error; err != nil { - return err - } - out.AmountPaid = 0 - out.Access = &access - return nil - }) - if err != nil { - return nil, err - } - return &out, nil - } - - priceAmount := int64(0) - var price models.ContentPrice - { - tbl, query := models.ContentPriceQuery.QueryContext(ctx) - m, err := query.Where( - tbl.TenantID.Eq(params.TenantID), - tbl.ContentID.Eq(params.ContentID), - ).First() - if err == nil { - price = *m - priceAmount = m.PriceAmount - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - - amountPaid := s.computeFinalPrice(priceAmount, &price, now) - out.AmountPaid = amountPaid - - discountType := price.DiscountType - if discountType == "" { - discountType = consts.DiscountTypeNone - } - var discountStartAt *time.Time - if !price.DiscountStartAt.IsZero() { - t := price.DiscountStartAt - discountStartAt = &t - } - var discountEndAt *time.Time - if !price.DiscountEndAt.IsZero() { - t := price.DiscountEndAt - discountEndAt = &t - } - - purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{ - ContentID: content.ID, - ContentTitle: content.Title, - ContentUserID: content.UserID, - ContentVisibility: content.Visibility, - PreviewSeconds: content.PreviewSeconds, - PreviewDownloadable: content.PreviewDownloadable, - Currency: consts.CurrencyCNY, - PriceAmount: priceAmount, - DiscountType: discountType, - DiscountValue: price.DiscountValue, - DiscountStartAt: discountStartAt, - DiscountEndAt: discountEndAt, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - PurchaseAt: now, - PurchaseIdempotency: params.IdempotencyKey, - }) - itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{ - ContentID: content.ID, - ContentTitle: content.Title, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - }) - - // 免费内容:无需冻结,保持单事务写订单+权益。 - if amountPaid == 0 { - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - orderModel := &models.Order{ - TenantID: params.TenantID, - UserID: params.UserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - Snapshot: purchaseSnapshot, - IdempotencyKey: params.IdempotencyKey, - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(orderModel).Error; err != nil { - return err - } - item := &models.OrderItem{ - TenantID: params.TenantID, - UserID: params.UserID, - OrderID: orderModel.ID, - ContentID: params.ContentID, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - Snapshot: itemSnapshot, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(item).Error; err != nil { - return err - } - if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { - return err - } - var access models.ContentAccess - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&access).Error; err != nil { - return err - } - out.Order = orderModel - out.OrderItem = item - out.Access = &access - return nil - }) - if err != nil { - return nil, pkgerrors.Wrap(err, "purchase content failed") - } - return &out, nil - } - - // 3) 独立事务冻结余额:便于后续在订单事务失败时做补偿解冻。 - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, freezeKey, "purchase freeze", now) - return err - }); err != nil { - return nil, pkgerrors.Wrap(err, "purchase freeze failed") - } - - // 4) 单事务完成:落订单 → 账本扣款(消耗冻结)→ 更新订单 paid → 授予权益。 - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - orderModel := &models.Order{ - TenantID: params.TenantID, - UserID: params.UserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusCreated, - Currency: consts.CurrencyCNY, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - Snapshot: purchaseSnapshot, - IdempotencyKey: params.IdempotencyKey, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(orderModel).Error; err != nil { - return err - } - item := &models.OrderItem{ - TenantID: params.TenantID, - UserID: params.UserID, - OrderID: orderModel.ID, - ContentID: params.ContentID, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - Snapshot: itemSnapshot, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(item).Error; err != nil { - return err - } - if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, debitKey, "purchase debit", now); err != nil { - return err - } - if err := tx.Model(&models.Order{}). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusPaid, - "paid_at": now, - "updated_at": now, - }).Error; err != nil { - return err - } - // 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。 - orderModel.Status = consts.OrderStatusPaid - orderModel.PaidAt = now - orderModel.UpdatedAt = now - if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { - return err - } - - var access models.ContentAccess - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&access).Error; err != nil { - return err - } - - out.Order = orderModel - out.OrderItem = item - out.Access = &access - return nil - }); err != nil { - // 5) 补偿:订单事务失败时,必须解冻,并写入回滚标记,保证后续幂等重试稳定返回失败。 - _ = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - _, e1 := s.ledger.UnfreezeTx( - ctx, - tx, - params.TenantID, - params.UserID, // operator_user_id:购买者本人(下单链路中的补偿动作) - params.UserID, - 0, - amountPaid, - rollbackKey, - "purchase rollback", - now, - ) - return e1 - }) - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "user_id": params.UserID, - "content_id": params.ContentID, - "idempotency_key": params.IdempotencyKey, - }).WithError(err).Warn("services.order.purchase_content.rollback") - return nil, errorx.ErrOperationFailed.WithMsg("失败+已回滚") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "user_id": params.UserID, - "content_id": params.ContentID, - "order_id": loID(out.Order), - "amount_paid": out.AmountPaid, - "idempotency_key": params.IdempotencyKey, - }).Info("services.order.purchase_content.ok") - - return &out, nil - } - - // 非幂等请求走“单事务”旧流程:冻结 + 落单 + 扣款 + 授权全部在一个事务内完成(失败整体回滚)。 - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var content models.Content - if err := tx. - Where("tenant_id = ? AND id = ? AND deleted_at IS NULL", params.TenantID, params.ContentID). - First(&content).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("content not found") - } - return err - } - if content.Status != consts.ContentStatusPublished { - return errorx.ErrPreconditionFailed.WithMsg("content not published") - } - - var accessExisting models.ContentAccess - if err := tx. - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&accessExisting).Error; err == nil { - if accessExisting.Status == consts.ContentAccessStatusActive { - out.Access = &accessExisting - return nil - } - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - var price models.ContentPrice - priceAmount := int64(0) - if err := tx.Where("tenant_id = ? AND content_id = ?", params.TenantID, params.ContentID).First(&price).Error; err == nil { - priceAmount = price.PriceAmount - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - amountPaid := s.computeFinalPrice(priceAmount, &price, now) - out.AmountPaid = amountPaid - - discountType := price.DiscountType - if discountType == "" { - discountType = consts.DiscountTypeNone - } - var discountStartAt *time.Time - if !price.DiscountStartAt.IsZero() { - t := price.DiscountStartAt - discountStartAt = &t - } - var discountEndAt *time.Time - if !price.DiscountEndAt.IsZero() { - t := price.DiscountEndAt - discountEndAt = &t - } - purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{ - ContentID: content.ID, - ContentTitle: content.Title, - ContentUserID: content.UserID, - ContentVisibility: content.Visibility, - PreviewSeconds: content.PreviewSeconds, - PreviewDownloadable: content.PreviewDownloadable, - Currency: consts.CurrencyCNY, - PriceAmount: priceAmount, - DiscountType: discountType, - DiscountValue: price.DiscountValue, - DiscountStartAt: discountStartAt, - DiscountEndAt: discountEndAt, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - PurchaseAt: now, - }) - itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{ - ContentID: content.ID, - ContentTitle: content.Title, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - }) - - if amountPaid == 0 { - orderModel := &models.Order{ - TenantID: params.TenantID, - UserID: params.UserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - Snapshot: purchaseSnapshot, - IdempotencyKey: "", - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(orderModel).Error; err != nil { - return err - } - item := &models.OrderItem{ - TenantID: params.TenantID, - UserID: params.UserID, - OrderID: orderModel.ID, - ContentID: params.ContentID, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - Snapshot: itemSnapshot, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(item).Error; err != nil { - return err - } - if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { - return err - } - var access models.ContentAccess - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&access).Error; err != nil { - return err - } - out.Order = orderModel - out.OrderItem = item - out.Access = &access - return nil - } - - orderModel := &models.Order{ - TenantID: params.TenantID, - UserID: params.UserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusCreated, - Currency: consts.CurrencyCNY, - AmountOriginal: priceAmount, - AmountDiscount: priceAmount - amountPaid, - AmountPaid: amountPaid, - Snapshot: purchaseSnapshot, - IdempotencyKey: "", - CreatedAt: now, - UpdatedAt: now, - } - - if _, err := s.ledger.FreezeTx(ctx, tx, params.TenantID, params.UserID, params.UserID, 0, amountPaid, "", "purchase freeze", now); err != nil { - return err - } - - if err := tx.Create(orderModel).Error; err != nil { - return err - } - - item := &models.OrderItem{ - TenantID: params.TenantID, - UserID: params.UserID, - OrderID: orderModel.ID, - ContentID: params.ContentID, - ContentUserID: content.UserID, - AmountPaid: amountPaid, - Snapshot: itemSnapshot, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(item).Error; err != nil { - return err - } - - if _, err := s.ledger.DebitPurchaseTx(ctx, tx, params.TenantID, params.UserID, params.UserID, orderModel.ID, amountPaid, "", "purchase debit", now); err != nil { - return err - } - - if err := tx.Model(&models.Order{}). - Where("id = ?", orderModel.ID). - Updates(map[string]any{ - "status": consts.OrderStatusPaid, - "paid_at": now, - "updated_at": now, - }).Error; err != nil { - return err - } - // 关键点:上面是 DB 更新;这里同步更新内存对象,避免返回给调用方的状态仍为 created。 - orderModel.Status = consts.OrderStatusPaid - orderModel.PaidAt = now - orderModel.UpdatedAt = now - - if err := s.grantAccess(ctx, tx, params.TenantID, params.UserID, params.ContentID, orderModel.ID, now); err != nil { - return err - } - - var access models.ContentAccess - if err := tx.Table(models.TableNameContentAccess). - Where("tenant_id = ? AND user_id = ? AND content_id = ?", params.TenantID, params.UserID, params.ContentID). - First(&access).Error; err != nil { - return err - } - - out.Order = orderModel - out.OrderItem = item - out.Access = &access - return nil - }) - if err != nil { - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "user_id": params.UserID, - "content_id": params.ContentID, - "idempotency_key": params.IdempotencyKey, - }).WithError(err).Warn("services.order.purchase_content.failed") - return nil, pkgerrors.Wrap(err, "purchase content failed") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": params.TenantID, - "user_id": params.UserID, - "content_id": params.ContentID, - "order_id": loID(out.Order), - "amount_paid": out.AmountPaid, - "idempotency_key": params.IdempotencyKey, - }).Info("services.order.purchase_content.ok") - - return &out, nil -} - -func (s *order) computeFinalPrice(priceAmount int64, price *models.ContentPrice, now time.Time) int64 { - // 价格计算:按折扣策略与生效时间窗口计算最终实付金额(单位:分)。 - if priceAmount <= 0 || price == nil { - return 0 - } - - discountType := price.DiscountType - if discountType == "" { - discountType = consts.DiscountTypeNone - } - - if !price.DiscountStartAt.IsZero() && now.Before(price.DiscountStartAt) { - return priceAmount - } - if !price.DiscountEndAt.IsZero() && now.After(price.DiscountEndAt) { - return priceAmount - } - - switch discountType { - case consts.DiscountTypePercent: - percent := price.DiscountValue - if percent <= 0 { - return priceAmount - } - if percent >= 100 { - return 0 - } - return priceAmount * (100 - percent) / 100 - case consts.DiscountTypeAmount: - amount := price.DiscountValue - if amount <= 0 { - return priceAmount - } - if amount >= priceAmount { - return 0 - } - return priceAmount - amount - default: - return priceAmount - } -} - -func (s *order) grantAccess( - ctx context.Context, - tx *gorm.DB, - tenantID, userID, contentID, orderID int64, - now time.Time, -) error { - // 权益写入策略:按 (tenant_id,user_id,content_id) upsert,确保重复购买/重试时权益最终为 active。 - insert := map[string]any{ - "tenant_id": tenantID, - "user_id": userID, - "content_id": contentID, - "order_id": orderID, - "status": consts.ContentAccessStatusActive, - "revoked_at": nil, - "created_at": now, - "updated_at": now, - } - - if err := tx.Table(models.TableNameContentAccess). - Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}, {Name: "content_id"}}, - DoUpdates: clause.Assignments(map[string]any{ - "order_id": orderID, - "status": consts.ContentAccessStatusActive, - "revoked_at": nil, - "updated_at": now, - }), - }). - Create(insert).Error; err != nil { - return err - } - return nil -} - -func loID(m *models.Order) int64 { - if m == nil { - return 0 - } - return m.ID -} diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go deleted file mode 100644 index 01ce261..0000000 --- a/backend/app/services/order_test.go +++ /dev/null @@ -1,1236 +0,0 @@ -package services - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/fields" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/samber/lo" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -func newLegacyOrderSnapshot() types.JSONType[fields.OrdersSnapshot] { - return types.NewJSONType(fields.OrdersSnapshot{ - Kind: "legacy", - Data: json.RawMessage([]byte("{}")), - }) -} - -type OrderTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type OrderTestSuite struct { - suite.Suite - OrderTestSuiteInjectParams -} - -func Test_Order(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p OrderTestSuiteInjectParams) { - suite.Run(t, &OrderTestSuite{OrderTestSuiteInjectParams: p}) - }) -} - -func (s *OrderTestSuite) truncate(ctx context.Context, tableNames ...string) { - database.Truncate(ctx, s.DB, tableNames...) -} - -func (s *OrderTestSuite) seedTenantUser(ctx context.Context, tenantID, userID, balance, frozen int64) { - now := time.Now().UTC() - _, err := s.DB.ExecContext(ctx, ` -INSERT INTO users (id, username, password, roles, status, metas, created_at, updated_at, balance, balance_frozen) -VALUES ($1, $2, 'x', ARRAY['user'], $3, '{}'::jsonb, $4, $4, $5, $6) -ON CONFLICT (id) DO UPDATE -SET balance = EXCLUDED.balance, balance_frozen = EXCLUDED.balance_frozen, updated_at = EXCLUDED.updated_at -`, userID, fmt.Sprintf("u%d", userID), consts.UserStatusVerified, now, balance, frozen) - So(err, ShouldBeNil) - - _, err = s.DB.ExecContext(ctx, ` -INSERT INTO tenant_users (tenant_id, user_id, role, status, created_at, updated_at) -VALUES ($1, $2, ARRAY['member'], $3, $4, $4) -ON CONFLICT (tenant_id, user_id) DO UPDATE -SET role = EXCLUDED.role, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at -`, tenantID, userID, consts.UserStatusVerified, now) - So(err, ShouldBeNil) -} - -func (s *OrderTestSuite) seedPublishedContent(ctx context.Context, tenantID, ownerUserID int64) *models.Content { - m := &models.Content{ - TenantID: tenantID, - UserID: ownerUserID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: time.Now().UTC(), - } - So(m.Create(ctx), ShouldBeNil) - return m -} - -func (s *OrderTestSuite) seedContentPrice(ctx context.Context, tenantID, contentID, priceAmount int64) { - p := &models.ContentPrice{ - TenantID: tenantID, - UserID: 1, - ContentID: contentID, - Currency: consts.CurrencyCNY, - PriceAmount: priceAmount, - DiscountType: consts.DiscountTypeNone, - DiscountValue: 0, - DiscountStartAt: time.Time{}, - DiscountEndAt: time.Time{}, - } - So(p.Create(ctx), ShouldBeNil) -} - -func (s *OrderTestSuite) Test_MyOrderPage() { - Convey("Order.MyOrderPage", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - userID := int64(2) - - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - Convey("参数非法应返回错误", func() { - _, err := Order.MyOrderPage(ctx, 0, userID, &dto.MyOrderListFilter{}) - So(err, ShouldNotBeNil) - }) - - Convey("空数据应返回 total=0", func() { - pager, err := Order.MyOrderPage(ctx, tenantID, userID, &dto.MyOrderListFilter{}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 0) - }) - - Convey("按 content_id 过滤", func() { - o1 := &models.Order{ - TenantID: tenantID, - UserID: userID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o1.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: userID, - OrderID: o1.ID, - ContentID: 111, - ContentUserID: 1, - AmountPaid: 100, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: userID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 200, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now.Add(time.Minute), - CreatedAt: now.Add(time.Minute), - UpdatedAt: now.Add(time.Minute), - } - So(o2.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: userID, - OrderID: o2.ID, - ContentID: 222, - ContentUserID: 1, - AmountPaid: 200, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now.Add(time.Minute), - UpdatedAt: now.Add(time.Minute), - }).Create(ctx), ShouldBeNil) - - pager, err := Order.MyOrderPage(ctx, tenantID, userID, &dto.MyOrderListFilter{ - ContentID: lo.ToPtr(int64(111)), - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} - -func (s *OrderTestSuite) Test_MyOrderDetail() { - Convey("Order.MyOrderDetail", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - userID := int64(2) - - s.truncate(ctx, models.TableNameOrder, models.TableNameOrderItem) - - Convey("参数非法应返回错误", func() { - _, err := Order.MyOrderDetail(ctx, 0, userID, 1) - So(err, ShouldNotBeNil) - }) - - Convey("订单不存在应返回错误", func() { - _, err := Order.MyOrderDetail(ctx, tenantID, userID, 999) - So(err, ShouldNotBeNil) - }) - }) -} - -func (s *OrderTestSuite) Test_AdminOrderPage() { - Convey("Order.AdminOrderPage", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminOrderPage(ctx, 0, &dto.AdminOrderListFilter{}) - So(err, ShouldNotBeNil) - }) - - Convey("空数据应返回 total=0", func() { - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 0) - }) - - Convey("按 paid_at 时间窗过滤", func() { - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now.Add(-time.Hour), - CreatedAt: now.Add(-time.Hour), - UpdatedAt: now.Add(-time.Hour), - } - So(o1.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 2, - OrderID: o1.ID, - ContentID: 333, - ContentUserID: 1, - AmountPaid: 100, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now.Add(-time.Hour), - UpdatedAt: now.Add(-time.Hour), - }).Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: 3, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 200, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 3, - OrderID: o2.ID, - ContentID: 444, - ContentUserID: 1, - AmountPaid: 200, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - from := now.Add(-10 * time.Minute) - to := now.Add(10 * time.Minute) - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - PaidAtFrom: &from, - PaidAtTo: &to, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按 content_id 过滤", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o1.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 2, - OrderID: o1.ID, - ContentID: 555, - ContentUserID: 1, - AmountPaid: 100, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - ContentID: lo.ToPtr(int64(555)), - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按 username 关键字过滤", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameUser) - - u1 := &models.User{ - Username: "alice", - Password: "x", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusVerified, - Metas: types.JSON([]byte("{}")), - CreatedAt: now, - UpdatedAt: now, - } - So(u1.Create(ctx), ShouldBeNil) - u2 := &models.User{ - Username: "bob", - Password: "x", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusVerified, - Metas: types.JSON([]byte("{}")), - CreatedAt: now, - UpdatedAt: now, - } - So(u2.Create(ctx), ShouldBeNil) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: u1.ID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o1.Create(ctx), ShouldBeNil) - o2 := &models.Order{ - TenantID: tenantID, - UserID: u2.ID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - - username := "ali" - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - Username: &username, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - items, ok := pager.Items.([]*models.Order) - So(ok, ShouldBeTrue) - So(items[0].UserID, ShouldEqual, u1.ID) - }) - - Convey("按 content_title 关键字过滤", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameContent) - - c1 := &models.Content{ - TenantID: tenantID, - UserID: 1, - Title: "Go 教程", - Description: "desc", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(c1.Create(ctx), ShouldBeNil) - c2 := &models.Content{ - TenantID: tenantID, - UserID: 1, - Title: "Rust 教程", - Description: "desc", - Status: consts.ContentStatusPublished, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - PublishedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(c2.Create(ctx), ShouldBeNil) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o1.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 2, - OrderID: o1.ID, - ContentID: c1.ID, - ContentUserID: 1, - AmountPaid: 100, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: 3, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 3, - OrderID: o2.ID, - ContentID: c2.ID, - ContentUserID: 1, - AmountPaid: 100, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - title := "Go" - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - ContentTitle: &title, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按 created_at 时间窗过滤", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now.Add(-time.Hour), - UpdatedAt: now.Add(-time.Hour), - } - So(o1.Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: 3, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 200, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - - from := now.Add(-10 * time.Minute) - to := now.Add(10 * time.Minute) - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - CreatedAtFrom: &from, - CreatedAtTo: &to, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("按排序字段(asc/desc)排序(白名单)", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - o1 := &models.Order{ - TenantID: tenantID, - UserID: 2, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 500, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now.Add(-time.Hour), - UpdatedAt: now.Add(-time.Hour), - } - So(o1.Create(ctx), ShouldBeNil) - - o2 := &models.Order{ - TenantID: tenantID, - UserID: 3, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o2.Create(ctx), ShouldBeNil) - - asc := "amount_paid" - pagerAsc, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - SortQueryFilter: requests.SortQueryFilter{Asc: &asc}, - }) - So(err, ShouldBeNil) - So(pagerAsc.Total, ShouldEqual, 2) - itemsAsc, ok := pagerAsc.Items.([]*models.Order) - So(ok, ShouldBeTrue) - So(itemsAsc[0].AmountPaid, ShouldEqual, 100) - - desc := "created_at" - pagerDesc, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - SortQueryFilter: requests.SortQueryFilter{Desc: &desc}, - }) - So(err, ShouldBeNil) - So(pagerDesc.Total, ShouldEqual, 2) - itemsDesc, ok := pagerDesc.Items.([]*models.Order) - So(ok, ShouldBeTrue) - So(itemsDesc[0].CreatedAt.After(itemsDesc[1].CreatedAt), ShouldBeTrue) - }) - - Convey("组合筛选:user_id + status + amount_paid 区间 + content_id", func() { - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder) - - statusPaid := consts.OrderStatusPaid - userID := int64(7) - contentID := int64(777) - - // 命中:user_id=7, status=paid, amount_paid=500, content_id=777 - oHit := &models.Order{ - TenantID: tenantID, - UserID: userID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 500, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(oHit.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: userID, - OrderID: oHit.ID, - ContentID: contentID, - ContentUserID: 1, - AmountPaid: 500, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - // 不命中:amount_paid 不在区间 - oNoAmount := &models.Order{ - TenantID: tenantID, - UserID: userID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 50, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(oNoAmount.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: userID, - OrderID: oNoAmount.ID, - ContentID: contentID, - ContentUserID: 1, - AmountPaid: 50, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - // 不命中:status 不同 - oNoStatus := &models.Order{ - TenantID: tenantID, - UserID: userID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusCreated, - Currency: consts.CurrencyCNY, - AmountPaid: 500, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(oNoStatus.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: userID, - OrderID: oNoStatus.ID, - ContentID: contentID, - ContentUserID: 1, - AmountPaid: 500, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - // 不命中:user_id 不同 - oNoUser := &models.Order{ - TenantID: tenantID, - UserID: 8, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 500, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(oNoUser.Create(ctx), ShouldBeNil) - So((&models.OrderItem{ - TenantID: tenantID, - UserID: 8, - OrderID: oNoUser.ID, - ContentID: contentID, - ContentUserID: 1, - AmountPaid: 500, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - }).Create(ctx), ShouldBeNil) - - min := int64(100) - max := int64(900) - pager, err := Order.AdminOrderPage(ctx, tenantID, &dto.AdminOrderListFilter{ - UserID: &userID, - Status: &statusPaid, - ContentID: &contentID, - AmountPaidMin: &min, - AmountPaidMax: &max, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} - -func (s *OrderTestSuite) Test_AdminOrderDetail() { - Convey("Order.AdminOrderDetail", s.T(), func() { - ctx := s.T().Context() - tenantID := int64(1) - - s.truncate(ctx, models.TableNameOrder, models.TableNameOrderItem) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminOrderDetail(ctx, 0, 1) - So(err, ShouldNotBeNil) - }) - - Convey("订单不存在应返回错误", func() { - _, err := Order.AdminOrderDetail(ctx, tenantID, 999) - So(err, ShouldNotBeNil) - }) - }) -} - -func (s *OrderTestSuite) Test_AdminOrderExportCSV() { - Convey("Order.AdminOrderExportCSV", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - - s.truncate(ctx, models.TableNameOrderItem, models.TableNameOrder, models.TableNameUser, models.TableNameContent) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminOrderExportCSV(ctx, 0, &dto.AdminOrderListFilter{}) - So(err, ShouldNotBeNil) - }) - - Convey("导出应返回 CSV 且包含表头", func() { - u := &models.User{ - Username: "alice", - Password: "x", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusVerified, - Metas: types.JSON([]byte("{}")), - CreatedAt: now, - UpdatedAt: now, - } - So(u.Create(ctx), ShouldBeNil) - - o := &models.Order{ - TenantID: tenantID, - UserID: u.ID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountPaid: 123, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(o.Create(ctx), ShouldBeNil) - - resp, err := Order.AdminOrderExportCSV(ctx, tenantID, &dto.AdminOrderListFilter{}) - So(err, ShouldBeNil) - So(resp, ShouldNotBeNil) - So(resp.ContentType, ShouldEqual, "text/csv") - So(resp.Filename, ShouldContainSubstring, "tenant_1_orders_") - So(resp.CSV, ShouldContainSubstring, "id,tenant_id,user_id,type,status,amount_paid,paid_at,created_at") - So(resp.CSV, ShouldContainSubstring, "content_purchase") - }) - }) -} - -func (s *OrderTestSuite) Test_AdminRefundOrder() { - Convey("Order.AdminRefundOrder", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - operatorUserID := int64(10) - buyerUserID := int64(20) - - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameContentAccess, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameTenantUser, - models.TableNameUser, - ) - - Convey("参数非法应返回错误", func() { - _, err := Order.AdminRefundOrder(ctx, 0, operatorUserID, 1, false, "", "", now) - So(err, ShouldNotBeNil) - }) - - Convey("订单非已支付状态应返回状态冲突", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) - - orderModel := &models.Order{ - TenantID: tenantID, - UserID: buyerUserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusCreated, - Currency: consts.CurrencyCNY, - AmountOriginal: 100, - AmountDiscount: 0, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(orderModel.Create(ctx), ShouldBeNil) - - _, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeStatusConflict) - }) - - Convey("已超过默认退款时间窗且非强制应失败", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) - - orderModel := &models.Order{ - TenantID: tenantID, - UserID: buyerUserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: 100, - AmountDiscount: 0, - AmountPaid: 100, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now.Add(-consts.DefaultOrderRefundWindow).Add(-time.Second), - CreatedAt: now, - UpdatedAt: now, - } - So(orderModel.Create(ctx), ShouldBeNil) - - _, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now) - So(err, ShouldNotBeNil) - }) - - Convey("成功退款应回收权益并入账", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) - - contentID := int64(123) - orderModel := &models.Order{ - TenantID: tenantID, - UserID: buyerUserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: 300, - AmountDiscount: 0, - AmountPaid: 300, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(orderModel.Create(ctx), ShouldBeNil) - - item := &models.OrderItem{ - TenantID: tenantID, - UserID: buyerUserID, - OrderID: orderModel.ID, - ContentID: contentID, - ContentUserID: 999, - AmountPaid: 300, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - } - So(item.Create(ctx), ShouldBeNil) - - access := &models.ContentAccess{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: contentID, - OrderID: orderModel.ID, - Status: consts.ContentAccessStatusActive, - CreatedAt: now, - UpdatedAt: now, - RevokedAt: time.Time{}, - } - So(access.Create(ctx), ShouldBeNil) - - refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) - So(err, ShouldBeNil) - So(refunding, ShouldNotBeNil) - So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) - - // refunding 期间重复请求应幂等返回 refunding(并允许重复触发入队,不影响最终结果)。 - refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(90*time.Second)) - So(err, ShouldBeNil) - So(refunding2, ShouldNotBeNil) - So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding) - - refunded, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ - TenantID: tenantID, - OrderID: orderModel.ID, - OperatorUserID: operatorUserID, - Force: false, - Reason: "原因", - Now: now.Add(2 * time.Minute), - }) - So(err, ShouldBeNil) - So(refunded, ShouldNotBeNil) - So(refunded.Status, ShouldEqual, consts.OrderStatusRefunded) - - // worker 重试/重复执行应幂等:不重复入账、不重复回收权益。 - refundedRetry, err := Order.ProcessRefundingOrder(ctx, &ProcessRefundingOrderParams{ - TenantID: tenantID, - OrderID: orderModel.ID, - OperatorUserID: operatorUserID, - Force: false, - Reason: "原因", - Now: now.Add(5 * time.Minute), - }) - So(err, ShouldBeNil) - So(refundedRetry, ShouldNotBeNil) - So(refundedRetry.Status, ShouldEqual, consts.OrderStatusRefunded) - - var u models.User - So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil) - So(u.Balance, ShouldEqual, 300) - - var access2 models.ContentAccess - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ? AND content_id = ?", tenantID, buyerUserID, contentID).First(&access2).Error, ShouldBeNil) - So(access2.Status, ShouldEqual, consts.ContentAccessStatusRevoked) - So(access2.RevokedAt.IsZero(), ShouldBeFalse) - - refunded2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(3*time.Minute)) - So(err, ShouldBeNil) - So(refunded2.Status, ShouldEqual, consts.OrderStatusRefunded) - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 300) - - var ledgers []*models.TenantLedger - So(_db.WithContext(ctx). - Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, buyerUserID, fmt.Sprintf("refund:%d", orderModel.ID)). - Find(&ledgers).Error, ShouldBeNil) - So(len(ledgers), ShouldEqual, 1) - }) - - Convey("failed 状态允许重新发起退款(paid/failed -> refunding)", func() { - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameContentAccess, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameTenantUser, - models.TableNameUser, - ) - s.seedTenantUser(ctx, tenantID, buyerUserID, 0, 0) - - contentID := int64(123) - orderModel := &models.Order{ - TenantID: tenantID, - UserID: buyerUserID, - Type: consts.OrderTypeContentPurchase, - Status: consts.OrderStatusPaid, - Currency: consts.CurrencyCNY, - AmountOriginal: 300, - AmountDiscount: 0, - AmountPaid: 300, - Snapshot: newLegacyOrderSnapshot(), - PaidAt: now, - CreatedAt: now, - UpdatedAt: now, - } - So(orderModel.Create(ctx), ShouldBeNil) - - item := &models.OrderItem{ - TenantID: tenantID, - UserID: buyerUserID, - OrderID: orderModel.ID, - ContentID: contentID, - ContentUserID: 999, - AmountPaid: 300, - Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), - CreatedAt: now, - UpdatedAt: now, - } - So(item.Create(ctx), ShouldBeNil) - - access := &models.ContentAccess{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: contentID, - OrderID: orderModel.ID, - Status: consts.ContentAccessStatusActive, - CreatedAt: now, - UpdatedAt: now, - RevokedAt: time.Time{}, - } - So(access.Create(ctx), ShouldBeNil) - - // 先发起一次退款进入 refunding,再模拟异步失败进入 failed。 - refunding, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因", "", now.Add(time.Minute)) - So(err, ShouldBeNil) - So(refunding.Status, ShouldEqual, consts.OrderStatusRefunding) - - So(Order.MarkRefundFailed(ctx, tenantID, orderModel.ID, now.Add(2*time.Minute)), ShouldBeNil) - - var failed models.Order - So(_db.WithContext(ctx).Where("tenant_id = ? AND id = ?", tenantID, orderModel.ID).First(&failed).Error, ShouldBeNil) - So(failed.Status, ShouldEqual, consts.OrderStatusFailed) - - // failed -> refunding 允许重新发起,并再次入队(幂等)。 - refunding2, err := Order.AdminRefundOrder(ctx, tenantID, operatorUserID, orderModel.ID, false, "原因2", "", now.Add(3*time.Minute)) - So(err, ShouldBeNil) - So(refunding2.Status, ShouldEqual, consts.OrderStatusRefunding) - }) - - Convey("不可重试错误分类应稳定", func() { - So(IsRefundJobNonRetryableError(nil), ShouldBeFalse) - So(IsRefundJobNonRetryableError(errors.New("x")), ShouldBeFalse) - - So(IsRefundJobNonRetryableError(errorx.ErrInvalidParameter), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrRecordNotFound), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrStatusConflict), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrPreconditionFailed), ShouldBeTrue) - So(IsRefundJobNonRetryableError(errorx.ErrPermissionDenied), ShouldBeTrue) - - So(IsRefundJobNonRetryableError(errorx.ErrInternalError), ShouldBeFalse) - }) - }) -} - -func (s *OrderTestSuite) Test_PurchaseContent() { - Convey("Order.PurchaseContent", s.T(), func() { - ctx := s.T().Context() - now := time.Now().UTC() - tenantID := int64(1) - ownerUserID := int64(100) - buyerUserID := int64(200) - - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameContentAccess, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameContentPrice, - models.TableNameContent, - models.TableNameTenantUser, - ) - - Convey("参数非法应返回错误", func() { - _, err := Order.PurchaseContent(ctx, nil) - So(err, ShouldNotBeNil) - - _, err = Order.PurchaseContent(ctx, &PurchaseContentParams{TenantID: 0, UserID: 1, ContentID: 1}) - So(err, ShouldNotBeNil) - }) - - Convey("内容未发布应返回前置条件失败", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) - - content := &models.Content{ - TenantID: tenantID, - UserID: ownerUserID, - Title: "标题", - Description: "描述", - Status: consts.ContentStatusDraft, - Visibility: consts.ContentVisibilityTenantOnly, - PreviewSeconds: consts.DefaultContentPreviewSeconds, - PreviewDownloadable: false, - } - So(content.Create(ctx), ShouldBeNil) - - _, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_not_published", - Now: now, - }) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) - }) - - Convey("免费内容购买应创建订单并授予权益(幂等)", func() { - s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) - content := s.seedPublishedContent(ctx, tenantID, ownerUserID) - - res1, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_free_1", - Now: now, - }) - So(err, ShouldBeNil) - So(res1, ShouldNotBeNil) - So(res1.AmountPaid, ShouldEqual, 0) - So(res1.Order, ShouldNotBeNil) - So(res1.Order.Status, ShouldEqual, consts.OrderStatusPaid) - So(res1.Access, ShouldNotBeNil) - So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) - - snap := res1.Order.Snapshot.Data() - So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase)) - - var snapData fields.OrdersContentPurchaseSnapshot - So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) - So(snapData.ContentID, ShouldEqual, content.ID) - So(snapData.ContentTitle, ShouldEqual, content.Title) - So(snapData.AmountPaid, ShouldEqual, int64(0)) - - res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_free_1", - Now: now.Add(time.Second), - }) - So(err, ShouldBeNil) - So(res2.Order.ID, ShouldEqual, res1.Order.ID) - }) - - Convey("付费内容购买应冻结+扣款并授予权益(幂等)", func() { - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameContentAccess, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameContentPrice, - models.TableNameContent, - models.TableNameTenantUser, - models.TableNameUser, - ) - s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) - content := s.seedPublishedContent(ctx, tenantID, ownerUserID) - s.seedContentPrice(ctx, tenantID, content.ID, 300) - - res1, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_paid_1", - Now: now, - }) - So(err, ShouldBeNil) - So(res1, ShouldNotBeNil) - So(res1.AmountPaid, ShouldEqual, 300) - So(res1.Order, ShouldNotBeNil) - So(res1.Order.Status, ShouldEqual, consts.OrderStatusPaid) - So(res1.Access, ShouldNotBeNil) - So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) - - snap := res1.Order.Snapshot.Data() - So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase)) - - var snapData fields.OrdersContentPurchaseSnapshot - So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) - So(snapData.ContentID, ShouldEqual, content.ID) - So(snapData.AmountPaid, ShouldEqual, int64(300)) - So(snapData.AmountOriginal, ShouldEqual, int64(300)) - - itemSnap := res1.OrderItem.Snapshot.Data() - So(itemSnap.ContentID, ShouldEqual, content.ID) - So(itemSnap.AmountPaid, ShouldEqual, int64(300)) - - var u models.User - So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u).Error, ShouldBeNil) - So(u.Balance, ShouldEqual, 700) - So(u.BalanceFrozen, ShouldEqual, 0) - - res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_paid_1", - Now: now.Add(2 * time.Second), - }) - So(err, ShouldBeNil) - So(res2.Order.ID, ShouldEqual, res1.Order.ID) - - var u2 models.User - So(_db.WithContext(ctx).Where("id = ?", buyerUserID).First(&u2).Error, ShouldBeNil) - So(u2.Balance, ShouldEqual, 700) - So(u2.BalanceFrozen, ShouldEqual, 0) - }) - - Convey("存在回滚标记时应稳定返回“失败+已回滚”", func() { - s.truncate( - ctx, - models.TableNameTenantLedger, - models.TableNameContentAccess, - models.TableNameOrderItem, - models.TableNameOrder, - models.TableNameContentPrice, - models.TableNameContent, - models.TableNameTenantUser, - models.TableNameUser, - ) - s.seedTenantUser(ctx, tenantID, buyerUserID, 1000, 0) - content := s.seedPublishedContent(ctx, tenantID, ownerUserID) - s.seedContentPrice(ctx, tenantID, content.ID, 300) - - rollbackKey := "idem_rollback_1:rollback" - ledger := &models.TenantLedger{ - TenantID: tenantID, - UserID: buyerUserID, - OrderID: 0, - Type: consts.TenantLedgerTypeUnfreeze, - Amount: 1, - BalanceBefore: 0, - BalanceAfter: 0, - FrozenBefore: 0, - FrozenAfter: 0, - IdempotencyKey: rollbackKey, - Remark: "rollback marker", - CreatedAt: now, - UpdatedAt: now, - } - So(ledger.Create(ctx), ShouldBeNil) - - _, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ - TenantID: tenantID, - UserID: buyerUserID, - ContentID: content.ID, - IdempotencyKey: "idem_rollback_1", - Now: now.Add(time.Second), - }) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "失败+已回滚") - }) - }) -} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index 5f92244..f3328aa 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -1,131 +1,9 @@ package services import ( - provider_job "quyun/v2/providers/job" - provider_jwt "quyun/v2/providers/jwt" - - "go.ipao.vip/atom" - "go.ipao.vip/atom/container" - "go.ipao.vip/atom/contracts" "go.ipao.vip/atom/opt" - "gorm.io/gorm" ) func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*content, error) { - obj := &content{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - db *gorm.DB, - ) (*ledger, error) { - obj := &ledger{ - db: db, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - job *provider_job.Job, - ) (*mediaAsset, error) { - obj := &mediaAsset{ - job: job, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - jwt *provider_jwt.JWT, - ) (*mediaDelivery, error) { - obj := &mediaDelivery{ - jwt: jwt, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - db *gorm.DB, - job *provider_job.Job, - ledger *ledger, - ) (*order, error) { - obj := &order{ - db: db, - job: job, - ledger: ledger, - } - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func( - content *content, - db *gorm.DB, - ledger *ledger, - mediaAsset *mediaAsset, - mediaDelivery *mediaDelivery, - order *order, - tenant *tenant, - tenantJoin *tenantJoin, - test *test, - user *user, - ) (contracts.Initial, error) { - obj := &services{ - content: content, - db: db, - ledger: ledger, - mediaAsset: mediaAsset, - mediaDelivery: mediaDelivery, - order: order, - tenant: tenant, - tenantJoin: tenantJoin, - test: test, - user: user, - } - if err := obj.Prepare(); err != nil { - return nil, err - } - - return obj, nil - }, atom.GroupInitial); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenant, error) { - obj := &tenant{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*tenantJoin, error) { - obj := &tenantJoin{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*test, error) { - obj := &test{} - - return obj, nil - }); err != nil { - return err - } - if err := container.Container.Provide(func() (*user, error) { - obj := &user{} - - return obj, nil - }); err != nil { - return err - } return nil } diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go deleted file mode 100644 index 0ceacc9..0000000 --- a/backend/app/services/services.gen.go +++ /dev/null @@ -1,52 +0,0 @@ -package services - -import ( - "gorm.io/gorm" -) - -var _db *gorm.DB - -// exported CamelCase Services -var ( - Content *content - Ledger *ledger - MediaAsset *mediaAsset - MediaDelivery *mediaDelivery - Order *order - Tenant *tenant - TenantJoin *tenantJoin - Test *test - User *user -) - -// @provider(model) -type services struct { - db *gorm.DB - // define Services - content *content - ledger *ledger - mediaAsset *mediaAsset - mediaDelivery *mediaDelivery - order *order - tenant *tenant - tenantJoin *tenantJoin - test *test - user *user -} - -func (svc *services) Prepare() error { - _db = svc.db - - // set exported Services here - Content = svc.content - Ledger = svc.ledger - MediaAsset = svc.mediaAsset - MediaDelivery = svc.mediaDelivery - Order = svc.order - Tenant = svc.tenant - TenantJoin = svc.tenantJoin - Test = svc.test - User = svc.user - - return nil -} diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go deleted file mode 100644 index 14053dd..0000000 --- a/backend/app/services/tenant.go +++ /dev/null @@ -1,968 +0,0 @@ -package services - -import ( - "context" - "strings" - "time" - - superdto "quyun/v2/app/http/super/dto" - tenantdto "quyun/v2/app/http/tenant/dto" - web_dto "quyun/v2/app/http/web/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/pkg/errors" - "github.com/samber/lo" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" - "go.ipao.vip/gen/types" - "gorm.io/gorm" -) - -// tenant implements tenant-related domain operations. -// -// @provider -type tenant struct{} - -// UserTenants 查询“当前用户可进入的租户列表”(平台通用域 /v1)。 -// - 返回 tenant_users 维度的角色与加入时间,便于前端做“选择租户进入后台”的交互。 -// - 不做租户状态过滤:前端可以根据 TenantStatusDescription 做提示或禁用进入按钮。 -func (t *tenant) UserTenants(ctx context.Context, userID int64) ([]*web_dto.MyTenantItem, error) { - if userID <= 0 { - return nil, errors.New("user_id must be > 0") - } - - // 先查 tenant_users,拿到用户加入的租户ID与角色信息(避免 join scan 冲突/字段覆盖问题)。 - tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx) - tenantUsers, err := tuQuery.Where(tuTbl.UserID.Eq(userID)).Order(tuTbl.ID.Desc()).Find() - if err != nil { - return nil, err - } - if len(tenantUsers) == 0 { - return []*web_dto.MyTenantItem{}, nil - } - - tenantIDs := lo.Uniq(lo.FilterMap(tenantUsers, func(tu *models.TenantUser, _ int) (int64, bool) { - if tu == nil || tu.TenantID <= 0 { - return 0, false - } - return tu.TenantID, true - })) - if len(tenantIDs) == 0 { - return []*web_dto.MyTenantItem{}, nil - } - - // 再查 tenants(批量),并构建映射,保持输出顺序以 tenant_users 为准。 - teTbl, teQuery := models.TenantQuery.QueryContext(ctx) - tenants, err := teQuery.Where(teTbl.ID.In(tenantIDs...)).Find() - if err != nil { - return nil, err - } - tenantMap := make(map[int64]*models.Tenant, len(tenants)) - for _, te := range tenants { - if te == nil { - continue - } - tenantMap[te.ID] = te - } - - items := make([]*web_dto.MyTenantItem, 0, len(tenantUsers)) - for _, tu := range tenantUsers { - if tu == nil { - continue - } - te := tenantMap[tu.TenantID] - if te == nil { - continue - } - items = append(items, &web_dto.MyTenantItem{ - TenantID: te.ID, - TenantCode: te.Code, - TenantName: te.Name, - TenantStatus: te.Status, - TenantStatusDescription: te.Status.Description(), - IsOwner: te.UserID == userID, - MemberRoles: tu.Role, - MemberStatus: tu.Status, - JoinedAt: tu.CreatedAt, - }) - } - return items, nil -} - -// SuperDetail 查询单个租户详情(平台侧)。 -func (t *tenant) SuperDetail(ctx context.Context, tenantID int64) (*superdto.TenantItem, error) { - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - - tbl, query := models.TenantQuery.QueryContext(ctx) - m, err := query.Where(tbl.ID.Eq(tenantID)).First() - if err != nil { - return nil, err - } - - userCountMapping, err := t.TenantUserCountMapping(ctx, []int64{m.ID}) - if err != nil { - return nil, err - } - incomeMapping, err := t.TenantIncomePaidMapping(ctx, []int64{m.ID}) - if err != nil { - return nil, err - } - - item := &superdto.TenantItem{ - Tenant: m, - UserCount: lo.ValueOr(userCountMapping, m.ID, 0), - IncomeAmountPaidSum: lo.ValueOr(incomeMapping, m.ID, 0), - StatusDescription: m.Status.Description(), - } - - ownerMapping, err := t.TenantOwnerUserMapping(ctx, []*models.Tenant{m}) - if err != nil { - return nil, err - } - item.Owner = ownerMapping[m.ID] - - adminMapping, err := t.TenantAdminUsersMapping(ctx, []int64{m.ID}) - if err != nil { - return nil, err - } - item.AdminUsers = adminMapping[m.ID] - - return item, nil -} - -// SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。 -func (t *tenant) SuperCreateTenant(ctx context.Context, form *superdto.TenantCreateForm) (*models.Tenant, error) { - if form == nil { - return nil, errors.New("form is nil") - } - - code := strings.ToLower(strings.TrimSpace(form.Code)) - if code == "" { - return nil, errors.New("code is empty") - } - name := strings.TrimSpace(form.Name) - if name == "" { - return nil, errors.New("name is empty") - } - if form.AdminUserID <= 0 { - return nil, errors.New("admin_user_id must be > 0") - } - duration, err := (&superdto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() - if err != nil { - return nil, err - } - - // 确保管理员用户存在(同时可提前暴露“用户不存在”的错误,而不是等到外键/逻辑报错)。 - if _, err := User.FindByID(ctx, form.AdminUserID); err != nil { - return nil, err - } - - now := time.Now().UTC() - tenant := &models.Tenant{ - UserID: form.AdminUserID, - Code: code, - UUID: types.NewUUIDv4(), - Name: name, - Status: consts.TenantStatusVerified, - Config: types.JSON([]byte(`{}`)), - ExpiredAt: now.Add(duration), - } - - db := _db.WithContext(ctx) - err = db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(tenant).Error; err != nil { - return err - } - - tenantUser := &models.TenantUser{ - TenantID: tenant.ID, - UserID: form.AdminUserID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin}), - Status: consts.UserStatusVerified, - } - if err := tx.Create(tenantUser).Error; err != nil { - return err - } - - return tx.First(tenant, tenant.ID).Error - }) - if err != nil { - return nil, err - } - - return tenant, nil -} - -// AdminTenantUsersPage 租户管理员分页查询成员列表(包含用户基础信息)。 -func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - if filter == nil { - filter = &tenantdto.AdminTenantUserListFilter{} - } - - filter.Pagination.Format() - - tbl, query := models.TenantUserQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Role != nil && *filter.Role != "" { - // role 字段为 PostgreSQL text[]:使用数组参数才能正确生成 `@> '{"tenant_admin"}'` 语义。 - conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) - } - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - userIDs := make([]int64, 0, len(items)) - for _, tu := range items { - if tu == nil { - continue - } - userIDs = append(userIDs, tu.UserID) - } - - var users []*models.User - if len(userIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find() - if err != nil { - return nil, err - } - } - userMap := make(map[int64]*models.User, len(users)) - for _, u := range users { - if u == nil { - continue - } - userMap[u.ID] = u - } - - out := make([]*tenantdto.AdminTenantUserItem, 0, len(items)) - for _, tu := range items { - if tu == nil { - continue - } - out = append(out, &tenantdto.AdminTenantUserItem{ - TenantUser: tu, - User: userMap[tu.UserID], - }) - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: out, - }, nil -} - -// SuperTenantUsersPage 超级管理员分页查询租户成员(脱敏 user 字段,避免泄露 password)。 -func (t *tenant) SuperTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errors.New("tenant_id must be > 0") - } - if filter == nil { - filter = &tenantdto.AdminTenantUserListFilter{} - } - - filter.Pagination.Format() - - tbl, query := models.TenantUserQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Role != nil && *filter.Role != "" { - conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) - } - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if username := filter.UsernameTrimmed(); username != "" { - uTbl, _ := models.UserQuery.QueryContext(ctx) - query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) - conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) - } - - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - userIDs := make([]int64, 0, len(items)) - for _, tu := range items { - if tu == nil { - continue - } - userIDs = append(userIDs, tu.UserID) - } - - var users []*models.User - if len(userIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find() - if err != nil { - return nil, err - } - } - userMap := make(map[int64]*models.User, len(users)) - for _, u := range users { - if u == nil { - continue - } - userMap[u.ID] = u - } - - out := make([]*superdto.SuperTenantUserItem, 0, len(items)) - for _, tu := range items { - if tu == nil { - continue - } - u := userMap[tu.UserID] - var lite *superdto.SuperUserLite - if u != nil { - lite = &superdto.SuperUserLite{ - ID: u.ID, - Username: u.Username, - Status: u.Status, - StatusDescription: u.Status.Description(), - Roles: u.Roles, - VerifiedAt: u.VerifiedAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - } - } - out = append(out, &superdto.SuperTenantUserItem{ - TenantUser: tu, - User: lite, - }) - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: out, - }, nil -} - -func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*models.User, error) { - tbl, query := models.TenantUserQuery.QueryContext(ctx) - - _, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err != nil { - return nil, errors.Wrapf(err, "ContainsUserID failed, tenantID: %d, userID: %d", tenantID, userID) - } - - return User.FindByID(ctx, userID) -} - -// AddUser -func (t *tenant) AddUser(ctx context.Context, tenantID, userID int64) error { - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - }).Info("services.tenant.add_user") - - // 幂等:若成员关系已存在,则直接返回成功,避免重复插入触发唯一约束错误。 - tbl, query := models.TenantUserQuery.QueryContext(ctx) - _, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err == nil { - return nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return errors.Wrapf(err, "AddUser failed to query existing, tenantID: %d, userID: %d", tenantID, userID) - } - - // 关键默认值:加入租户默认成为 member,并设置为 verified(避免 DB 默认值与枚举不一致导致脏数据)。 - tenantUser := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - } - - if err := tenantUser.Create(ctx); err != nil { - return errors.Wrapf(err, "AddUser failed, tenantID: %d, userID: %d", tenantID, userID) - } - return nil -} - -// RemoveUser -func (t *tenant) RemoveUser(ctx context.Context, tenantID, userID int64) error { - tbl, query := models.TenantUserQuery.QueryContext(ctx) - tenantUser, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err != nil { - // 幂等:成员不存在时也返回成功,便于后台重试/批量移除。 - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return errors.Wrapf(err, "RemoveUser failed to find, tenantID: %d, userID: %d", tenantID, userID) - } - - _, err = tenantUser.Delete(ctx) - if err != nil { - return errors.Wrapf(err, "RemoveUser failed to delete, tenantID: %d, userID: %d", tenantID, userID) - } - return nil -} - -// SetUserRole -func (t *tenant) SetUserRole(ctx context.Context, tenantID, userID int64, role ...consts.TenantUserRole) error { - tbl, query := models.TenantUserQuery.QueryContext(ctx) - tenantUser, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err != nil { - return errors.Wrapf(err, "SetUserRole failed to find, tenantID: %d, userID: %d", tenantID, userID) - } - - // 角色更新:当前约定 role 数组通常只存一个主角色(member/tenant_admin)。 - tenantUser.Role = types.NewArray(role) - if _, err := tenantUser.Update(ctx); err != nil { - return errors.Wrapf(err, "SetUserRole failed to update, tenantID: %d, userID: %d", tenantID, userID) - } - return nil -} - -// Pager -func (t *tenant) Pager(ctx context.Context, filter *superdto.TenantFilter) (*requests.Pager, error) { - tbl, query := models.TenantQuery.QueryContext(ctx) - - conds := []gen.Condition{} - if filter == nil { - filter = &superdto.TenantFilter{} - } - - if filter.ID != nil && *filter.ID > 0 { - conds = append(conds, tbl.ID.Eq(*filter.ID)) - } - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - - if name := filter.NameTrimmed(); name != "" { - conds = append(conds, tbl.Name.Like(database.WrapLike(name))) - } - - if code := filter.CodeTrimmed(); code != "" { - // code 在库内按约定存储为 lower-case;这里统一转小写后做 like。 - conds = append(conds, tbl.Code.Like(database.WrapLike(code))) - } - - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - - filter.Pagination.Format() - - if filter.ExpiredAtFrom != nil { - conds = append(conds, tbl.ExpiredAt.Gte(*filter.ExpiredAtFrom)) - } - if filter.ExpiredAtTo != nil { - conds = append(conds, tbl.ExpiredAt.Lte(*filter.ExpiredAtTo)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 - orderBys := make([]field.Expr, 0, 6) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "code": tbl.Code.Asc(), - "name": tbl.Name.Asc(), - "status": tbl.Status.Asc(), - "expired_at": tbl.ExpiredAt.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "updated_at": tbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "code": tbl.Code.Desc(), - "name": tbl.Name.Desc(), - "status": tbl.Status.Desc(), - "expired_at": tbl.ExpiredAt.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "updated_at": tbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - mm, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - tenantIds := lo.Map(mm, func(item *models.Tenant, _ int) int64 { return item.ID }) - - userCountMapping, err := t.TenantUserCountMapping(ctx, tenantIds) - if err != nil { - return nil, err - } - - incomeMapping, err := t.TenantIncomePaidMapping(ctx, tenantIds) - if err != nil { - return nil, err - } - - items := lo.Map(mm, func(model *models.Tenant, _ int) *superdto.TenantItem { - return &superdto.TenantItem{ - Tenant: model, - UserCount: lo.ValueOr(userCountMapping, model.ID, 0), - IncomeAmountPaidSum: lo.ValueOr(incomeMapping, model.ID, 0), - StatusDescription: model.Status.Description(), - } - }) - - ownerMapping, err := t.TenantOwnerUserMapping(ctx, mm) - if err != nil { - return nil, err - } - for _, it := range items { - if it == nil || it.Tenant == nil { - continue - } - it.Owner = ownerMapping[it.Tenant.ID] - } - - adminUsersMapping, err := t.TenantAdminUsersMapping(ctx, tenantIds) - if err != nil { - return nil, err - } - for _, it := range items { - if it == nil || it.Tenant == nil { - continue - } - it.AdminUsers = adminUsersMapping[it.Tenant.ID] - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -func (t *tenant) TenantOwnerUserMapping(ctx context.Context, tenants []*models.Tenant) (map[int64]*superdto.TenantOwnerUserLite, error) { - result := make(map[int64]*superdto.TenantOwnerUserLite, len(tenants)) - - userIDs := make([]int64, 0, len(tenants)) - tenantIDs := make([]int64, 0, len(tenants)) - for _, te := range tenants { - if te == nil || te.ID <= 0 { - continue - } - tenantIDs = append(tenantIDs, te.ID) - if te.UserID > 0 { - userIDs = append(userIDs, te.UserID) - } - } - for _, tenantID := range tenantIDs { - result[tenantID] = nil - } - userIDs = lo.Uniq(userIDs) - if len(userIDs) == 0 { - return result, nil - } - - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() - if err != nil { - return nil, err - } - userMap := make(map[int64]*models.User, len(users)) - for _, u := range users { - if u == nil { - continue - } - userMap[u.ID] = u - } - - for _, te := range tenants { - if te == nil || te.ID <= 0 || te.UserID <= 0 { - continue - } - u := userMap[te.UserID] - if u == nil { - continue - } - result[te.ID] = &superdto.TenantOwnerUserLite{ - ID: u.ID, - Username: u.Username, - } - } - - return result, nil -} - -// TenantAdminUsersMapping 返回每个租户的管理员用户(用于 superadmin 租户列表展示)。 -func (t *tenant) TenantAdminUsersMapping(ctx context.Context, tenantIDs []int64) (map[int64][]*superdto.TenantAdminUserLite, error) { - result := make(map[int64][]*superdto.TenantAdminUserLite, len(tenantIDs)) - for _, id := range tenantIDs { - if id <= 0 { - continue - } - result[id] = nil - } - if len(result) == 0 { - return result, nil - } - - tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx) - tus, err := tuQuery.Where( - tuTbl.TenantID.In(tenantIDs...), - tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin})), - ).Find() - if err != nil { - return nil, err - } - - userIDs := make([]int64, 0, len(tus)) - type pair struct { - tenantID int64 - userID int64 - } - pairs := make([]pair, 0, len(tus)) - for _, tu := range tus { - if tu == nil || tu.TenantID <= 0 || tu.UserID <= 0 { - continue - } - userIDs = append(userIDs, tu.UserID) - pairs = append(pairs, pair{tenantID: tu.TenantID, userID: tu.UserID}) - } - userIDs = lo.Uniq(userIDs) - - userMap := map[int64]*models.User{} - if len(userIDs) > 0 { - uTbl, uQuery := models.UserQuery.QueryContext(ctx) - users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() - if err != nil { - return nil, err - } - for _, u := range users { - if u == nil { - continue - } - userMap[u.ID] = u - } - } - - for _, p := range pairs { - u := userMap[p.userID] - if u == nil { - continue - } - result[p.tenantID] = append(result[p.tenantID], &superdto.TenantAdminUserLite{ - ID: u.ID, - Username: u.Username, - }) - } - - return result, nil -} - -func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { - // 关键语义:返回值必须包含入参中的所有 tenant_id。 - // 即便该租户当前没有成员,也应返回 count=0,便于调用方直接取值而无需额外补全逻辑。 - result := make(map[int64]int64, len(tenantIds)) - for _, id := range tenantIds { - if id <= 0 { - continue - } - result[id] = 0 - } - if len(result) == 0 { - return result, nil - } - - tbl, query := models.TenantUserQuery.QueryContext(ctx) - - var items []struct { - TenantID int64 - Count int64 - } - err := query. - Select( - tbl.TenantID, - tbl.UserID.Count().As("count"), - ). - Where(tbl.TenantID.In(tenantIds...)). - Group(tbl.TenantID). - Scan(&items) - if err != nil { - return nil, err - } - - for _, item := range items { - result[item.TenantID] = item.Count - } - return result, nil -} - -// TenantUserBalanceMapping -func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { - // 关键语义:返回值必须包含入参中的所有 tenant_id。 - // 即便该租户当前没有成员,也应返回 balance=0,保持调用方逻辑一致。 - result := make(map[int64]int64, len(tenantIds)) - for _, id := range tenantIds { - if id <= 0 { - continue - } - result[id] = 0 - } - if len(result) == 0 { - return result, nil - } - - var items []struct { - TenantID int64 - Balance int64 - } - - // 全局余额:按租户维度统计“该租户成员的 users.balance 之和”。 - // 注意:用户可能加入多个租户,因此不同租户的统计会出现重复计入(这符合“按租户视角”统计的直觉)。 - err := models.Q.TenantUser. - WithContext(ctx). - UnderlyingDB(). - Table(models.TableNameTenantUser+" tu"). - Select("tu.tenant_id, COALESCE(SUM(u.balance), 0) AS balance"). - Joins("JOIN "+models.TableNameUser+" u ON u.id = tu.user_id AND u.deleted_at IS NULL"). - Where("tu.tenant_id IN ?", tenantIds). - Group("tu.tenant_id"). - Scan(&items). - Error - if err != nil { - return nil, err - } - - for _, item := range items { - result[item.TenantID] = item.Balance - } - return result, nil -} - -// TenantIncomePaidMapping 按租户维度统计“已支付订单”的累计收入(单位:分,CNY)。 -// 说明: -// - 仅统计 orders.status = paid 的订单金额; -// - refunding/refunded 不计入收入(避免把已退/退款中的金额当作收入)。 -func (t *tenant) TenantIncomePaidMapping(ctx context.Context, tenantIDs []int64) (map[int64]int64, error) { - result := make(map[int64]int64, len(tenantIDs)) - for _, id := range tenantIDs { - if id <= 0 { - continue - } - result[id] = 0 - } - if len(result) == 0 { - return result, nil - } - - oTbl, oQuery := models.OrderQuery.QueryContext(ctx) - var rows []struct { - TenantID int64 - Income int64 - } - err := oQuery. - Select(oTbl.TenantID, oTbl.AmountPaid.Sum().As("income")). - Where(oTbl.TenantID.In(tenantIDs...), oTbl.Status.Eq(consts.OrderStatusPaid)). - Group(oTbl.TenantID). - Scan(&rows) - if err != nil { - return nil, err - } - for _, row := range rows { - result[row.TenantID] = row.Income - } - return result, nil -} - -// FindByID -func (t *tenant) FindByID(ctx context.Context, id int64) (*models.Tenant, error) { - tbl, query := models.TenantQuery.QueryContext(ctx) - m, err := query.Where(tbl.ID.Eq(id)).First() - if err != nil { - return nil, errors.Wrapf(err, "find by id failed, id: %d", id) - } - return m, nil -} - -func (t *tenant) FindByCode(ctx context.Context, code string) (*models.Tenant, error) { - code = strings.TrimSpace(code) - if code == "" { - return nil, errors.New("tenant code is empty") - } - code = strings.ToLower(code) - - var m models.Tenant - err := models.Q.Tenant.WithContext(ctx).UnderlyingDB().Where("lower(code) = ?", code).First(&m).Error - if err != nil { - return nil, errors.Wrapf(err, "find by code failed, code: %s", code) - } - return &m, nil -} - -// FindOwnedByUserID 查询用户创建/拥有的租户(一个用户仅允许拥有一个租户)。 -func (t *tenant) FindOwnedByUserID(ctx context.Context, userID int64) (*models.Tenant, error) { - if userID <= 0 { - return nil, errors.New("user_id must be > 0") - } - tbl, query := models.TenantQuery.QueryContext(ctx) - m, err := query.Where(tbl.UserID.Eq(userID)).Order(tbl.ID.Desc()).First() - if err != nil { - return nil, errors.Wrapf(err, "find owned tenant failed, user_id: %d", userID) - } - return m, nil -} - -// ApplyOwnedTenant 申请创作者(创建租户申请)。 -// 业务约束: -// - 一个用户仅可申请一个租户:若已存在 owned tenant,则直接返回该租户(幂等)。 -// - 租户创建后默认处于 pending_verify,等待后台审核通过后才算“创作者”。 -func (t *tenant) ApplyOwnedTenant(ctx context.Context, userID int64, code, name string) (*models.Tenant, error) { - if userID <= 0 { - return nil, errors.New("user_id must be > 0") - } - code = strings.ToLower(strings.TrimSpace(code)) - name = strings.TrimSpace(name) - if code == "" { - return nil, errors.New("code is empty") - } - if name == "" { - return nil, errors.New("name is empty") - } - - // 幂等:一个用户仅允许拥有一个租户;若已存在则直接返回。 - existing, err := t.FindOwnedByUserID(ctx, userID) - if err == nil && existing != nil && existing.ID > 0 { - return existing, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(err, "check owned tenant failed, user_id: %d", userID) - } - - tenant := &models.Tenant{ - UserID: userID, - Code: code, - UUID: types.NewUUIDv4(), - Name: name, - Status: consts.TenantStatusPendingVerify, - Config: types.JSON([]byte(`{}`)), - } - - // 事务边界:创建租户 + 写入 tenant_users(租户管理员角色)。 - // MUST: 使用 Gen 事务包装,确保同一事务连接与一致的查询风格。 - err = models.Q.Transaction(func(tx *models.Query) error { - if err := tx.Tenant.WithContext(ctx).Create(tenant); err != nil { - return err - } - - tenantUser := &models.TenantUser{ - TenantID: tenant.ID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin}), - Status: consts.UserStatusVerified, - } - if err := tx.TenantUser.WithContext(ctx).Create(tenantUser); err != nil { - return err - } - - // 回填 created_at/updated_at 等字段,保持与其他创建逻辑一致。 - fresh, err := tx.Tenant.WithContext(ctx).Where(tx.Tenant.ID.Eq(tenant.ID)).First() - if err != nil { - return err - } - *tenant = *fresh - return nil - }) - if err != nil { - return nil, err - } - - return tenant, nil -} - -func (t *tenant) FindTenantUser(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) { - logrus.WithField("tenant_id", tenantID).WithField("user_id", userID).Info("find tenant user") - tbl, query := models.TenantUserQuery.QueryContext(ctx) - m, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() - if err != nil { - return nil, errors.Wrapf(err, "find tenant user failed, tenantID: %d, userID: %d", tenantID, userID) - } - return m, nil -} - -// AddExpireDuration -func (t *tenant) AddExpireDuration(ctx context.Context, tenantID int64, duration time.Duration) error { - logrus.WithField("tenant_id", tenantID).WithField("duration", duration).Info("add expire duration") - - m, err := t.FindByID(ctx, tenantID) - if err != nil { - return err - } - - if m.ExpiredAt.Before(time.Now()) { - m.ExpiredAt = time.Now().Add(duration) - } else { - m.ExpiredAt = m.ExpiredAt.Add(duration) - } - return m.Save(ctx) -} - -// UpdateStatus -func (t *tenant) UpdateStatus(ctx context.Context, tenantID int64, status consts.TenantStatus) error { - logrus.WithField("tenant_id", tenantID).WithField("status", status).Info("update tenant status") - - m, err := t.FindByID(ctx, tenantID) - if err != nil { - return err - } - - m.Status = status - _, err = m.Update(ctx) - if err != nil { - return err - } - - return nil -} diff --git a/backend/app/services/tenant_join.go b/backend/app/services/tenant_join.go deleted file mode 100644 index 6ed2b45..0000000 --- a/backend/app/services/tenant_join.go +++ /dev/null @@ -1,513 +0,0 @@ -package services - -import ( - "context" - "crypto/rand" - "encoding/base32" - "strings" - "time" - - "quyun/v2/app/errorx" - "quyun/v2/app/http/tenant/dto" - tenant_join_dto "quyun/v2/app/http/tenant_join/dto" - "quyun/v2/app/requests" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/jackc/pgx/v5/pgconn" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/types" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// tenantJoin 提供“加入租户”域相关能力(占位服务)。 -// 当前 join 相关实现复用在 `tenant` service 上,以保持对外 API 不变;此处仅用于服务汇总/注入。 -// -// @provider -type tenantJoin struct{} - -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - return pgErr.Code == "23505" - } - return errors.Is(err, gorm.ErrDuplicatedKey) -} - -func newInviteCode() (string, error) { - // 邀请码为安全敏感值:使用强随机数,避免可预测性导致被撞库加入租户。 - buf := make([]byte, 10) // 80-bit - if _, err := rand.Read(buf); err != nil { - return "", err - } - // base32(去掉 padding)便于输入,统一转小写存储与比较。 - return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)), nil -} - -// AdminCreateInvite 租户管理员创建邀请(用于用户通过邀请码加入租户)。 -func (t *tenant) AdminCreateInvite(ctx context.Context, tenantID, operatorUserID int64, form *dto.AdminTenantInviteCreateForm) (*models.TenantInvite, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if operatorUserID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("operator_user_id must be > 0") - } - if form == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") - } - - now := time.Now().UTC() - code := strings.ToLower(strings.TrimSpace(form.Code)) - if code == "" { - var err error - code, err = newInviteCode() - if err != nil { - return nil, err - } - } - if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() && form.ExpiresAt.Before(now) { - return nil, errorx.ErrInvalidParameter.WithMsg("expires_at must be in future") - } - if form.MaxUses != nil && *form.MaxUses < 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("max_uses must be >= 0") - } - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "code": code, - "max_uses": form.MaxUses, - "expires_at_present": form.ExpiresAt != nil, - }).Info("services.tenant.admin.create_invite") - - invite := &models.TenantInvite{ - TenantID: tenantID, - UserID: operatorUserID, - Code: code, - Status: consts.TenantInviteStatusActive, - MaxUses: 0, - UsedCount: 0, - Remark: strings.TrimSpace(form.Remark), - CreatedAt: now, - UpdatedAt: now, - } - if form.MaxUses != nil { - invite.MaxUses = int32(*form.MaxUses) - } - if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() { - invite.ExpiresAt = form.ExpiresAt.UTC() - } - - // 关键点:expires_at/disabled_at 允许为空,避免写入 0001-01-01 造成误判。 - db := models.Q.TenantInvite.WithContext(ctx).UnderlyingDB().Omit("disabled_at", "disabled_operator_user_id") - if invite.ExpiresAt.IsZero() { - db = db.Omit("expires_at") - } - if err := db.Create(invite).Error; err != nil { - if isUniqueViolation(err) { - return nil, errorx.ErrRecordDuplicated.WithMsg("邀请码已存在,请重试") - } - return nil, err - } - return invite, nil -} - -// AdminDisableInvite 租户管理员禁用邀请(幂等)。 -func (t *tenant) AdminDisableInvite(ctx context.Context, tenantID, operatorUserID, inviteID int64, reason string) (*models.TenantInvite, error) { - if tenantID <= 0 || operatorUserID <= 0 || inviteID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/invite_id") - } - - now := time.Now().UTC() - reason = strings.TrimSpace(reason) - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "invite_id": inviteID, - }).Info("services.tenant.admin.disable_invite") - - var out models.TenantInvite - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var inv models.TenantInvite - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND tenant_id = ?", inviteID, tenantID). - First(&inv).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("邀请码不存在") - } - return err - } - - // 幂等:重复禁用直接返回当前状态。 - if inv.Status == consts.TenantInviteStatusDisabled || inv.Status == consts.TenantInviteStatusExpired { - out = inv - return nil - } - - inv.Status = consts.TenantInviteStatusDisabled - inv.DisabledOperatorUserID = operatorUserID - inv.DisabledAt = now - if reason != "" { - inv.Remark = reason - } - inv.UpdatedAt = now - - // 关键点:disabled_at/disabled_operator_user_id 允许为空,但禁用时必须落审计信息。 - if err := tx.Save(&inv).Error; err != nil { - return err - } - - out = inv - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// AdminInvitePage 租户管理员分页查询邀请列表。 -func (t *tenant) AdminInvitePage(ctx context.Context, tenantID int64, filter *dto.AdminTenantInviteListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &dto.AdminTenantInviteListFilter{} - } - filter.Pagination.Format() - - tbl, query := models.TenantInviteQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - if code := filter.CodeTrimmed(); code != "" { - conds = append(conds, tbl.Code.Like("%"+strings.ToLower(code)+"%")) - } - - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// JoinByInvite 用户通过邀请码加入租户(无须已是租户成员)。 -func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, inviteCode string) (*models.TenantUser, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id") - } - inviteCode = strings.ToLower(strings.TrimSpace(inviteCode)) - if inviteCode == "" { - return nil, errorx.ErrInvalidParameter.WithMsg("invite_code is empty") - } - - now := time.Now().UTC() - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - "invite_code": inviteCode, - "invite_token": "[masked]", - }).Info("services.tenant.join_by_invite") - - var out models.TenantUser - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 关键前置条件:已经是成员时直接成功返回,不消耗邀请码使用次数。 - var existingTU models.TenantUser - if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil { - out = existingTU - return nil - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - // 邀请校验必须加行锁,避免并发超发 used_count。 - var inv models.TenantInvite - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("tenant_id = ? AND code = ?", tenantID, inviteCode). - First(&inv).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("邀请码不存在") - } - return err - } - - // 关键规则:禁用/过期的邀请码不可使用。 - if inv.Status != consts.TenantInviteStatusActive { - return errorx.ErrPreconditionFailed.WithMsg("邀请码不可用") - } - if !inv.ExpiresAt.IsZero() && inv.ExpiresAt.Before(now) { - // 业务侧保持状态一致:过期时顺手标记 expired,避免后续误用。 - _ = tx.Model(&inv).Updates(map[string]any{ - "status": consts.TenantInviteStatusExpired, - "updated_at": now, - }).Error - return errorx.ErrPreconditionFailed.WithMsg("邀请码已过期") - } - if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { - _ = tx.Model(&inv).Updates(map[string]any{ - "status": consts.TenantInviteStatusExpired, - "updated_at": now, - }).Error - return errorx.ErrPreconditionFailed.WithMsg("邀请码已用尽") - } - - // 加入租户:默认 member + verified;与 tenant.AddUser 保持一致。 - tu := &models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(tu).Error; err != nil { - if isUniqueViolation(err) { - // 并发幂等:重复插入按已加入处理,不消耗邀请码次数。 - if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&out).Error; err != nil { - return err - } - return nil - } - return err - } - out = *tu - - // 只有在“新加入”成功时才消耗邀请码次数。 - updates := map[string]any{ - "used_count": inv.UsedCount + 1, - "updated_at": now, - } - if inv.MaxUses > 0 && inv.UsedCount+1 >= inv.MaxUses { - updates["status"] = consts.TenantInviteStatusExpired - } - return tx.Model(&inv).Updates(updates).Error - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// CreateJoinRequest 用户提交加入租户申请(无邀请码场景)。 -func (t *tenant) CreateJoinRequest(ctx context.Context, tenantID, userID int64, form *tenant_join_dto.JoinRequestCreateForm) (*models.TenantJoinRequest, error) { - if tenantID <= 0 || userID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id") - } - if form == nil { - return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") - } - - now := time.Now().UTC() - reason := strings.TrimSpace(form.Reason) - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "user_id": userID, - }).Info("services.tenant.create_join_request") - - // 关键前置条件:已是成员则不允许重复申请。 - var existingTU models.TenantUser - if err := _db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil { - return nil, errorx.ErrPreconditionFailed.WithMsg("已是该租户成员") - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - req := &models.TenantJoinRequest{ - TenantID: tenantID, - UserID: userID, - Status: consts.TenantJoinRequestStatusPending, - Reason: reason, - CreatedAt: now, - UpdatedAt: now, - DecidedAt: time.Time{}, - DecidedReason: "", - } - - // 关键点:decided_at/decided_operator_user_id 允许为空,避免写入 0001-01-01 造成误判。 - db := models.Q.TenantJoinRequest.WithContext(ctx).UnderlyingDB().Omit("decided_at", "decided_operator_user_id") - if err := db.Create(req).Error; err != nil { - if isUniqueViolation(err) { - // 幂等:重复提交时返回现有 pending 申请。 - tbl, query := models.TenantJoinRequestQuery.QueryContext(ctx) - existing, qErr := query.Where( - tbl.TenantID.Eq(tenantID), - tbl.UserID.Eq(userID), - tbl.Status.Eq(consts.TenantJoinRequestStatusPending), - ).First() - if qErr == nil { - return existing, nil - } - return nil, err - } - return nil, err - } - - return req, nil -} - -// AdminJoinRequestPage 租户管理员分页查询加入申请列表。 -func (t *tenant) AdminJoinRequestPage(ctx context.Context, tenantID int64, filter *dto.AdminTenantJoinRequestListFilter) (*requests.Pager, error) { - if tenantID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") - } - if filter == nil { - filter = &dto.AdminTenantJoinRequestListFilter{} - } - filter.Pagination.Format() - - tbl, query := models.TenantJoinRequestQuery.QueryContext(ctx) - conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} - if filter.UserID != nil && *filter.UserID > 0 { - conds = append(conds, tbl.UserID.Eq(*filter.UserID)) - } - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - - items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -// AdminApproveJoinRequest 租户管理员通过加入申请(幂等)。 -func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) { - if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id") - } - - now := time.Now().UTC() - reason = strings.TrimSpace(reason) - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "request_id": requestID, - }).Info("services.tenant.admin.approve_join_request") - - var out models.TenantJoinRequest - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var req models.TenantJoinRequest - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND tenant_id = ?", requestID, tenantID). - First(&req).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("申请不存在") - } - return err - } - - // 幂等:已通过则直接返回。 - if req.Status == consts.TenantJoinRequestStatusApproved { - out = req - return nil - } - if req.Status != consts.TenantJoinRequestStatusPending { - return errorx.ErrPreconditionFailed.WithMsg("申请状态不可通过") - } - - // 先落成员关系,再更新申请状态,保证“通过后一定能成为成员”(至少幂等)。 - tu := &models.TenantUser{ - TenantID: tenantID, - UserID: req.UserID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - CreatedAt: now, - UpdatedAt: now, - } - if err := tx.Create(tu).Error; err != nil && !isUniqueViolation(err) { - return err - } - - req.Status = consts.TenantJoinRequestStatusApproved - req.DecidedAt = now - req.DecidedOperatorUserID = operatorUserID - req.DecidedReason = reason - req.UpdatedAt = now - if err := tx.Save(&req).Error; err != nil { - return err - } - - out = req - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// AdminRejectJoinRequest 租户管理员拒绝加入申请(幂等)。 -func (t *tenant) AdminRejectJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) { - if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 { - return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id") - } - - now := time.Now().UTC() - reason = strings.TrimSpace(reason) - - logrus.WithFields(logrus.Fields{ - "tenant_id": tenantID, - "operator_user_id": operatorUserID, - "request_id": requestID, - }).Info("services.tenant.admin.reject_join_request") - - var out models.TenantJoinRequest - err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var req models.TenantJoinRequest - if err := tx. - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND tenant_id = ?", requestID, tenantID). - First(&req).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errorx.ErrRecordNotFound.WithMsg("申请不存在") - } - return err - } - - // 幂等:已拒绝则直接返回。 - if req.Status == consts.TenantJoinRequestStatusRejected { - out = req - return nil - } - if req.Status != consts.TenantJoinRequestStatusPending { - return errorx.ErrPreconditionFailed.WithMsg("申请状态不可拒绝") - } - - req.Status = consts.TenantJoinRequestStatusRejected - req.DecidedAt = now - req.DecidedOperatorUserID = operatorUserID - req.DecidedReason = reason - req.UpdatedAt = now - if err := tx.Save(&req).Error; err != nil { - return err - } - - out = req - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} diff --git a/backend/app/services/tenant_join_test.go b/backend/app/services/tenant_join_test.go deleted file mode 100644 index c12b1a3..0000000 --- a/backend/app/services/tenant_join_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package services - -import ( - "context" - "database/sql" - "errors" - "testing" - "time" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/errorx" - tenantdto "quyun/v2/app/http/tenant/dto" - tenant_join_dto "quyun/v2/app/http/tenant_join/dto" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type TenantJoinTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type TenantJoinTestSuite struct { - suite.Suite - - TenantJoinTestSuiteInjectParams -} - -func Test_TenantJoin(t *testing.T) { - providers := testx.Default().With(Provide) - testx.Serve(providers, t, func(p TenantJoinTestSuiteInjectParams) { - suite.Run(t, &TenantJoinTestSuite{TenantJoinTestSuiteInjectParams: p}) - }) -} - -func (s *TenantJoinTestSuite) truncateAll(ctx context.Context) { - So(database.Truncate(ctx, s.DB, - models.TableNameTenantInvite, - models.TableNameTenantJoinRequest, - models.TableNameTenantUser, - ), ShouldBeNil) -} - -func (s *TenantJoinTestSuite) seedInvite(ctx context.Context, tenantID, inviterUserID int64, code string, status consts.TenantInviteStatus, maxUses, usedCount int32, expiresAt time.Time) *models.TenantInvite { - inv := &models.TenantInvite{ - TenantID: tenantID, - UserID: inviterUserID, - Code: code, - Status: status, - MaxUses: maxUses, - UsedCount: usedCount, - ExpiresAt: expiresAt, - Remark: "seed", - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - So(_db.WithContext(ctx).Create(inv).Error, ShouldBeNil) - return inv -} - -func (s *TenantJoinTestSuite) seedJoinRequest(ctx context.Context, tenantID, userID int64, status consts.TenantJoinRequestStatus) *models.TenantJoinRequest { - req := &models.TenantJoinRequest{ - TenantID: tenantID, - UserID: userID, - Status: status, - Reason: "seed", - DecidedAt: time.Time{}, - DecidedReason: "", - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - So(_db.WithContext(ctx).Omit("decided_at", "decided_operator_user_id").Create(req).Error, ShouldBeNil) - return req -} - -func (s *TenantJoinTestSuite) Test_AdminCreateInvite() { - Convey("Tenant.AdminCreateInvite", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - - Convey("参数非法应返回参数错误", func() { - _, err := Tenant.AdminCreateInvite(ctx, 0, adminUserID, &tenantdto.AdminTenantInviteCreateForm{}) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeInvalidParameter) - - _, err = Tenant.AdminCreateInvite(ctx, tenantID, 0, &tenantdto.AdminTenantInviteCreateForm{}) - So(err, ShouldNotBeNil) - }) - - Convey("code 为空应自动生成并创建成功", func() { - out, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ - Code: "", - MaxUses: loToPtr(0), - Remark: "test", - }) - So(err, ShouldBeNil) - So(out, ShouldNotBeNil) - So(out.ID, ShouldBeGreaterThan, 0) - So(out.Code, ShouldNotBeBlank) - So(out.Status, ShouldEqual, consts.TenantInviteStatusActive) - }) - - Convey("重复 code 应返回重复错误", func() { - _, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ - Code: "dup_code", - MaxUses: loToPtr(0), - }) - So(err, ShouldBeNil) - - _, err = Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ - Code: "dup_code", - MaxUses: loToPtr(0), - }) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeRecordDuplicated) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_AdminDisableInvite() { - Convey("Tenant.AdminDisableInvite", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - - Convey("邀请码不存在应返回记录不存在", func() { - _, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, 999, "x") - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) - }) - - Convey("禁用应成功且幂等", func() { - inv := s.seedInvite(ctx, tenantID, adminUserID, "c1", consts.TenantInviteStatusActive, 0, 0, time.Time{}) - - out, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason") - So(err, ShouldBeNil) - So(out.Status, ShouldEqual, consts.TenantInviteStatusDisabled) - So(out.DisabledOperatorUserID, ShouldEqual, adminUserID) - So(out.DisabledAt.IsZero(), ShouldBeFalse) - - out2, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason2") - So(err, ShouldBeNil) - So(out2.Status, ShouldEqual, consts.TenantInviteStatusDisabled) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_AdminInvitePage() { - Convey("Tenant.AdminInvitePage", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - - s.seedInvite(ctx, tenantID, adminUserID, "aaa", consts.TenantInviteStatusActive, 0, 0, time.Time{}) - s.seedInvite(ctx, tenantID, adminUserID, "bbb", consts.TenantInviteStatusDisabled, 0, 0, time.Time{}) - s.seedInvite(ctx, tenantID, adminUserID, "ccc", consts.TenantInviteStatusActive, 0, 0, time.Time{}) - - Convey("按 status 过滤应只返回匹配项", func() { - st := consts.TenantInviteStatusActive - pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ - Status: &st, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 2) - }) - - Convey("按 code 模糊过滤应生效", func() { - code := "bb" - pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ - Code: &code, - }) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_JoinByInvite() { - Convey("Tenant.JoinByInvite", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - userID := int64(20) - - Convey("邀请码不存在应返回记录不存在", func() { - _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "not_exist") - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) - }) - - Convey("成功加入应创建成员并消耗邀请码次数", func() { - inv := s.seedInvite(ctx, tenantID, adminUserID, "code1", consts.TenantInviteStatusActive, 1, 0, time.Now().UTC().Add(10*time.Minute)) - - tu, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") - So(err, ShouldBeNil) - So(tu, ShouldNotBeNil) - So(tu.TenantID, ShouldEqual, tenantID) - So(tu.UserID, ShouldEqual, userID) - - var inv2 models.TenantInvite - So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv2).Error, ShouldBeNil) - So(inv2.UsedCount, ShouldEqual, 1) - So(inv2.Status, ShouldEqual, consts.TenantInviteStatusExpired) - - Convey("重复加入应幂等且不再消耗次数", func() { - _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") - So(err, ShouldBeNil) - - var inv3 models.TenantInvite - So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv3).Error, ShouldBeNil) - So(inv3.UsedCount, ShouldEqual, 1) - }) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_CreateJoinRequest() { - Convey("Tenant.CreateJoinRequest", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - userID := int64(20) - - Convey("已是成员应返回前置条件失败", func() { - So(_db.WithContext(ctx).Create(&models.TenantUser{ - TenantID: tenantID, - UserID: userID, - Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), - Status: consts.UserStatusVerified, - }).Error, ShouldBeNil) - - _, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "x"}) - So(err, ShouldNotBeNil) - - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) - }) - - Convey("重复提交应返回同一个 pending 申请(幂等)", func() { - s.truncateAll(ctx) - - out1, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "a"}) - So(err, ShouldBeNil) - So(out1, ShouldNotBeNil) - - out2, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenant_join_dto.JoinRequestCreateForm{Reason: "b"}) - So(err, ShouldBeNil) - So(out2, ShouldNotBeNil) - So(out2.ID, ShouldEqual, out1.ID) - So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusPending) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_AdminJoinRequestPage() { - Convey("Tenant.AdminJoinRequestPage", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - - s.seedJoinRequest(ctx, tenantID, 11, consts.TenantJoinRequestStatusPending) - s.seedJoinRequest(ctx, tenantID, 22, consts.TenantJoinRequestStatusRejected) - s.seedJoinRequest(ctx, tenantID, 33, consts.TenantJoinRequestStatusPending) - - Convey("按 status 过滤应生效", func() { - st := consts.TenantJoinRequestStatusPending - pager, err := Tenant.AdminJoinRequestPage(ctx, tenantID, &tenantdto.AdminTenantJoinRequestListFilter{Status: &st}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 2) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_AdminApproveJoinRequest() { - Convey("Tenant.AdminApproveJoinRequest", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - userID := int64(20) - - Convey("申请不存在应返回记录不存在", func() { - _, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, 999, "x") - So(err, ShouldNotBeNil) - var appErr *errorx.AppError - So(errors.As(err, &appErr), ShouldBeTrue) - So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) - }) - - Convey("通过 pending 申请应成功且幂等", func() { - req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) - - out, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok") - So(err, ShouldBeNil) - So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) - So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) - So(out.DecidedAt.IsZero(), ShouldBeFalse) - - var tu models.TenantUser - So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu).Error, ShouldBeNil) - - out2, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok2") - So(err, ShouldBeNil) - So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) - }) - }) -} - -func (s *TenantJoinTestSuite) Test_AdminRejectJoinRequest() { - Convey("Tenant.AdminRejectJoinRequest", s.T(), func() { - ctx := s.T().Context() - s.truncateAll(ctx) - - tenantID := int64(1) - adminUserID := int64(10) - userID := int64(20) - - Convey("拒绝 pending 申请应成功且幂等", func() { - req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) - - out, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no") - So(err, ShouldBeNil) - So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) - So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) - - out2, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no2") - So(err, ShouldBeNil) - So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) - }) - }) -} - -func loToPtr[T any](v T) *T { return &v } diff --git a/backend/app/services/tenant_test.go b/backend/app/services/tenant_test.go deleted file mode 100644 index ea2c485..0000000 --- a/backend/app/services/tenant_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package services - -import ( - "database/sql" - "testing" - - "quyun/v2/app/commands/testx" - tenantdto "quyun/v2/app/http/tenant/dto" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/pkg/utils" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type TenantTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type TenantTestSuite struct { - suite.Suite - - TenantTestSuiteInjectParams -} - -func Test_Tenant(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p TenantTestSuiteInjectParams) { - suite.Run(t, &TenantTestSuite{TenantTestSuiteInjectParams: p}) - }) -} - -func (t *TenantTestSuite) Test_TenantUserCount() { - Convey("test get tenants user count", t.T(), func() { - database.Truncate(t.T().Context(), t.DB, models.TableNameTenant) - - result, err := Tenant.TenantUserCountMapping(t.T().Context(), []int64{1, 2}) - So(err, ShouldBeNil) - So(result, ShouldHaveLength, 2) - t.T().Logf("%s", utils.MustJsonString(result)) - }) -} - -func (t *TenantTestSuite) Test_AddUser() { - Convey("Tenant.AddUser", t.T(), func() { - ctx := t.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, t.DB, models.TableNameTenantUser) - - Convey("首次添加成员成功", func() { - err := Tenant.AddUser(ctx, tenantID, userID) - So(err, ShouldBeNil) - - m, err := Tenant.FindTenantUser(ctx, tenantID, userID) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(m.TenantID, ShouldEqual, tenantID) - So(m.UserID, ShouldEqual, userID) - }) - - Convey("重复添加应幂等返回成功", func() { - So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil) - So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil) - }) - }) -} - -func (t *TenantTestSuite) Test_SetUserRole() { - Convey("Tenant.SetUserRole", t.T(), func() { - ctx := t.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, t.DB, models.TableNameTenantUser) - - So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil) - - Convey("设置为 tenant_admin 成功", func() { - err := Tenant.SetUserRole(ctx, tenantID, userID, consts.TenantUserRoleTenantAdmin) - So(err, ShouldBeNil) - - m, err := Tenant.FindTenantUser(ctx, tenantID, userID) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(len(m.Role), ShouldEqual, 1) - So(m.Role[0], ShouldEqual, consts.TenantUserRoleTenantAdmin) - }) - - Convey("设置为 member 成功", func() { - err := Tenant.SetUserRole(ctx, tenantID, userID, consts.TenantUserRoleMember) - So(err, ShouldBeNil) - - m, err := Tenant.FindTenantUser(ctx, tenantID, userID) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - So(len(m.Role), ShouldEqual, 1) - So(m.Role[0], ShouldEqual, consts.TenantUserRoleMember) - }) - }) -} - -func (t *TenantTestSuite) Test_RemoveUser() { - Convey("Tenant.RemoveUser", t.T(), func() { - ctx := t.T().Context() - tenantID := int64(1) - userID := int64(2) - - database.Truncate(ctx, t.DB, models.TableNameTenantUser) - - Convey("移除不存在成员应幂等返回成功", func() { - So(Tenant.RemoveUser(ctx, tenantID, userID), ShouldBeNil) - }) - - Convey("移除已存在成员成功", func() { - So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil) - So(Tenant.RemoveUser(ctx, tenantID, userID), ShouldBeNil) - - _, err := Tenant.FindTenantUser(ctx, tenantID, userID) - So(err, ShouldNotBeNil) - }) - }) -} - -func (t *TenantTestSuite) Test_AdminTenantUsersPage() { - Convey("Tenant.AdminTenantUsersPage", t.T(), func() { - ctx := t.T().Context() - tenantID := int64(1) - - database.Truncate(ctx, t.DB, models.TableNameTenantUser, models.TableNameUser) - - u1 := &models.User{ - Username: "u1", - Password: "pw", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusVerified, - } - So(u1.Create(ctx), ShouldBeNil) - - u2 := &models.User{ - Username: "u2", - Password: "pw", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusVerified, - } - So(u2.Create(ctx), ShouldBeNil) - - So(Tenant.AddUser(ctx, tenantID, u1.ID), ShouldBeNil) - So(Tenant.AddUser(ctx, tenantID, u2.ID), ShouldBeNil) - So(Tenant.SetUserRole(ctx, tenantID, u2.ID, consts.TenantUserRoleTenantAdmin), ShouldBeNil) - - Convey("不加过滤应返回用户信息", func() { - pager, err := Tenant.AdminTenantUsersPage(ctx, tenantID, &tenantdto.AdminTenantUserListFilter{}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 2) - - items, ok := pager.Items.([]*tenantdto.AdminTenantUserItem) - So(ok, ShouldBeTrue) - So(len(items), ShouldEqual, 2) - So(items[0].User, ShouldNotBeNil) - }) - - Convey("按 role=tenant_admin 过滤", func() { - role := consts.TenantUserRoleTenantAdmin - pager, err := Tenant.AdminTenantUsersPage(ctx, tenantID, &tenantdto.AdminTenantUserListFilter{Role: &role}) - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - }) -} diff --git a/backend/app/services/test.go b/backend/app/services/test.go deleted file mode 100644 index 051d196..0000000 --- a/backend/app/services/test.go +++ /dev/null @@ -1,10 +0,0 @@ -package services - -import "context" - -// @provider -type test struct{} - -func (t *test) Test(ctx context.Context) (string, error) { - return "Test", nil -} diff --git a/backend/app/services/test_test.go b/backend/app/services/test_test.go deleted file mode 100644 index 45de0f7..0000000 --- a/backend/app/services/test_test.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build legacytests -// +build legacytests - -package services - -import ( - "testing" - "time" - - "quyun/v2/app/commands/testx" - - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.uber.org/dig" -) - -type TestSuiteInjectParams struct { - dig.In - - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type TestSuite struct { - suite.Suite - - TestSuiteInjectParams -} - -func Test_Test(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p TestSuiteInjectParams) { - suite.Run(t, &TestSuite{TestSuiteInjectParams: p}) - }) -} - -func (t *TestSuite) Test_Test() { - Convey("test_work", t.T(), func() { - t.T().Log("start test at", time.Now()) - }) -} diff --git a/backend/app/services/user.go b/backend/app/services/user.go deleted file mode 100644 index 5e88072..0000000 --- a/backend/app/services/user.go +++ /dev/null @@ -1,495 +0,0 @@ -package services - -import ( - "context" - "strings" - - "quyun/v2/app/http/super/dto" - "quyun/v2/app/requests" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - - "github.com/pkg/errors" - "github.com/samber/lo" - "github.com/sirupsen/logrus" - "go.ipao.vip/gen" - "go.ipao.vip/gen/field" - "go.ipao.vip/gen/types" - "golang.org/x/crypto/bcrypt" -) - -// @provider -type user struct{} - -func (t *user) FindByID(ctx context.Context, userID int64) (*models.User, error) { - tbl, query := models.UserQuery.QueryContext(ctx) - - model, err := query.Preload(tbl.OwnedTenant, tbl.Tenants).Where(tbl.ID.Eq(userID)).First() - if err != nil { - return nil, errors.Wrapf(err, "FindByID failed, %d", userID) - } - return model, nil -} - -func (t *user) FindByUsername(ctx context.Context, username string) (*models.User, error) { - tbl, query := models.UserQuery.QueryContext(ctx) - - model, err := query.Where(tbl.Username.Eq(username)).First() - if err != nil { - return nil, errors.Wrapf(err, "FindByUsername failed, %s", username) - } - return model, nil -} - -// Detail 查询用户详情(超级管理员侧,返回脱敏后的 DTO)。 -func (t *user) Detail(ctx context.Context, userID int64) (*dto.UserItem, error) { - if userID <= 0 { - return nil, errors.New("user_id must be > 0") - } - - model, err := t.FindByID(ctx, userID) - if err != nil { - return nil, err - } - - ownedTenantCounts, err := t.UserOwnedTenantCountMapping(ctx, []int64{model.ID}) - if err != nil { - return nil, err - } - joinedTenantCounts, err := t.UserJoinedTenantCountMapping(ctx, []int64{model.ID}) - if err != nil { - return nil, err - } - - return &dto.UserItem{ - ID: model.ID, - Username: model.Username, - Roles: model.Roles, - Status: model.Status, - StatusDescription: model.Status.Description(), - Balance: model.Balance, - BalanceFrozen: model.BalanceFrozen, - VerifiedAt: model.VerifiedAt, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - OwnedTenantCount: ownedTenantCounts[model.ID], - JoinedTenantCount: joinedTenantCounts[model.ID], - }, nil -} - -func (t *user) Create(ctx context.Context, user *models.User) (*models.User, error) { - if err := user.Create(ctx); err != nil { - return nil, errors.Wrapf(err, "Create user failed, %s", user.Username) - } - return user, nil -} - -// ResetPasswordByUsername 通过用户名(手机号)重置密码。 -func (t *user) ResetPasswordByUsername(ctx context.Context, username, newPassword string) error { - username = strings.TrimSpace(username) - if username == "" { - return errors.New("username is required") - } - if newPassword == "" { - return errors.New("new_password is required") - } - - m, err := t.FindByUsername(ctx, username) - if err != nil { - return err - } - - // bcrypt hash,避免直接落明文。 - bytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return errors.Wrap(err, "generate password hash failed") - } - - m.Password = string(bytes) - return m.Save(ctx) -} - -// SetStatus 设置用户状态(超级管理员侧)。 -func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserStatus) error { - m, err := t.FindByID(ctx, userID) - if err != nil { - return err - } - - m.Status = status - return m.Save(ctx) -} - -// Page 用户分页查询(超级管理员侧)。 -func (t *user) Page(ctx context.Context, filter *dto.UserPageFilter) (*requests.Pager, error) { - tbl, query := models.UserQuery.QueryContext(ctx) - - if filter == nil { - filter = &dto.UserPageFilter{} - } - - conds := []gen.Condition{} - if filter.ID != nil && *filter.ID > 0 { - conds = append(conds, tbl.ID.Eq(*filter.ID)) - } - if username := filter.UsernameTrimmed(); username != "" { - conds = append(conds, tbl.Username.Like(database.WrapLike(username))) - } - if filter.Role != nil && *filter.Role != "" { - conds = append(conds, tbl.Roles.Contains(types.NewArray([]consts.Role{*filter.Role}))) - } - - if filter.TenantID != nil { - tuTbl, _ := models.TenantUserQuery.QueryContext(ctx) - query = query.RightJoin(tuTbl, tuTbl.UserID.EqCol(tbl.ID)) - conds = append(conds, tuTbl.TenantID.Eq(*filter.TenantID)) - } - - if filter.Status != nil { - conds = append(conds, tbl.Status.Eq(*filter.Status)) - } - - filter.Pagination.Format() - - if filter.CreatedAtFrom != nil { - conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - if filter.VerifiedAtFrom != nil { - conds = append(conds, tbl.VerifiedAt.Gte(*filter.VerifiedAtFrom)) - } - if filter.VerifiedAtTo != nil { - conds = append(conds, tbl.VerifiedAt.Lte(*filter.VerifiedAtTo)) - } - - // 排序白名单:避免把任意字段拼进 SQL 导致注入或慢查询。 - orderBys := make([]field.Expr, 0, 6) - allowedAsc := map[string]field.Expr{ - "id": tbl.ID.Asc(), - "username": tbl.Username.Asc(), - "status": tbl.Status.Asc(), - "balance": tbl.Balance.Asc(), - "verified_at": tbl.VerifiedAt.Asc(), - "created_at": tbl.CreatedAt.Asc(), - "updated_at": tbl.UpdatedAt.Asc(), - } - allowedDesc := map[string]field.Expr{ - "id": tbl.ID.Desc(), - "username": tbl.Username.Desc(), - "status": tbl.Status.Desc(), - "balance": tbl.Balance.Desc(), - "verified_at": tbl.VerifiedAt.Desc(), - "created_at": tbl.CreatedAt.Desc(), - "updated_at": tbl.UpdatedAt.Desc(), - } - for _, f := range filter.AscFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedAsc[f]; ok { - orderBys = append(orderBys, ob) - } - } - for _, f := range filter.DescFields() { - f = strings.TrimSpace(f) - if f == "" { - continue - } - if ob, ok := allowedDesc[f]; ok { - orderBys = append(orderBys, ob) - } - } - if len(orderBys) == 0 { - orderBys = append(orderBys, tbl.ID.Desc()) - } else { - orderBys = append(orderBys, tbl.ID.Desc()) - } - - users, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - userIDs := make([]int64, 0, len(users)) - for _, u := range users { - if u == nil { - continue - } - userIDs = append(userIDs, u.ID) - } - - ownedTenantCounts, err := t.UserOwnedTenantCountMapping(ctx, userIDs) - if err != nil { - return nil, err - } - joinedTenantCounts, err := t.UserJoinedTenantCountMapping(ctx, userIDs) - if err != nil { - return nil, err - } - - items := lo.Map(users, func(model *models.User, _ int) *dto.UserItem { - if model == nil { - return &dto.UserItem{} - } - return &dto.UserItem{ - ID: model.ID, - Username: model.Username, - Roles: model.Roles, - Status: model.Status, - StatusDescription: model.Status.Description(), - Balance: model.Balance, - BalanceFrozen: model.BalanceFrozen, - VerifiedAt: model.VerifiedAt, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - OwnedTenantCount: ownedTenantCounts[model.ID], - JoinedTenantCount: joinedTenantCounts[model.ID], - } - }) - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} - -func (t *user) UserOwnedTenantCountMapping(ctx context.Context, userIDs []int64) (map[int64]int64, error) { - result := make(map[int64]int64, len(userIDs)) - for _, id := range userIDs { - if id <= 0 { - continue - } - result[id] = 0 - } - if len(result) == 0 { - return result, nil - } - - ttbl, tquery := models.TenantQuery.QueryContext(ctx) - var rows []struct { - UserID int64 - Count int64 - } - err := tquery. - Select(ttbl.UserID, ttbl.ID.Count().As("count")). - Where(ttbl.UserID.In(userIDs...)). - Group(ttbl.UserID). - Scan(&rows) - if err != nil { - return nil, err - } - for _, row := range rows { - result[row.UserID] = row.Count - } - return result, nil -} - -func (t *user) UserJoinedTenantCountMapping(ctx context.Context, userIDs []int64) (map[int64]int64, error) { - result := make(map[int64]int64, len(userIDs)) - for _, id := range userIDs { - if id <= 0 { - continue - } - result[id] = 0 - } - if len(result) == 0 { - return result, nil - } - - tutbl, tuquery := models.TenantUserQuery.QueryContext(ctx) - var rows []struct { - UserID int64 - Count int64 - } - err := tuquery. - Select(tutbl.UserID, tutbl.TenantID.Count().As("count")). - Where(tutbl.UserID.In(userIDs...)). - Group(tutbl.UserID). - Scan(&rows) - if err != nil { - return nil, err - } - for _, row := range rows { - result[row.UserID] = row.Count - } - return result, nil -} - -// UpdateStatus 更新用户状态(超级管理员侧)。 -func (t *user) UpdateStatus(ctx context.Context, userID int64, status consts.UserStatus) error { - logrus.WithField("user_id", userID).WithField("status", status).Info("update user status") - - m, err := t.FindByID(ctx, userID) - if err != nil { - return err - } - - m.Status = status - _, err = m.Update(ctx) - if err != nil { - return err - } - - return nil -} - -// UpdateRoles 更新用户角色(超级管理员侧)。 -func (t *user) UpdateRoles(ctx context.Context, userID int64, roles []consts.Role) error { - if userID <= 0 { - return errors.New("user_id must be > 0") - } - - roles = lo.Uniq(lo.Filter(roles, func(r consts.Role, _ int) bool { - return r != "" - })) - if len(roles) == 0 { - return errors.New("roles is empty") - } - - // 约定:系统用户至少包含 user 角色。 - if !lo.Contains(roles, consts.RoleUser) { - roles = append(roles, consts.RoleUser) - } - roles = lo.Uniq(roles) - - m, err := t.FindByID(ctx, userID) - if err != nil { - return err - } - - m.Roles = types.NewArray(roles) - _, err = m.Update(ctx) - return err -} - -// Statistics 按状态统计用户数量(超级管理员侧)。 -func (t *user) Statistics(ctx context.Context) ([]*dto.UserStatistics, error) { - tbl, query := models.UserQuery.QueryContext(ctx) - - var statistics []*dto.UserStatistics - err := query.Select(tbl.Status, tbl.ID.Count().As("count")).Group(tbl.Status).Scan(&statistics) - if err != nil { - return nil, err - } - - return lo.Map(statistics, func(item *dto.UserStatistics, _ int) *dto.UserStatistics { - item.StatusDescription = item.Status.Description() - return item - }), nil -} - -// TenantsPage 分页查询“用户加入的租户”(通过 tenant_users 关联)。 -func (t *user) TenantsPage(ctx context.Context, userID int64, filter *dto.UserTenantPageFilter) (*requests.Pager, error) { - if userID <= 0 { - return nil, errors.New("user_id must be > 0") - } - if filter == nil { - filter = &dto.UserTenantPageFilter{} - } - - filter.Pagination.Format() - - tuTbl, query := models.TenantUserQuery.QueryContext(ctx) - conds := []gen.Condition{tuTbl.UserID.Eq(userID)} - - if filter.TenantID != nil && *filter.TenantID > 0 { - conds = append(conds, tuTbl.TenantID.Eq(*filter.TenantID)) - } - if filter.Role != nil && *filter.Role != "" { - conds = append(conds, tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) - } - if filter.Status != nil && *filter.Status != "" { - conds = append(conds, tuTbl.Status.Eq(*filter.Status)) - } - if filter.CreatedAtFrom != nil { - conds = append(conds, tuTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) - } - if filter.CreatedAtTo != nil { - conds = append(conds, tuTbl.CreatedAt.Lte(*filter.CreatedAtTo)) - } - - code := filter.CodeTrimmed() - name := filter.NameTrimmed() - if code != "" || name != "" { - tTbl, _ := models.TenantQuery.QueryContext(ctx) - query = query.LeftJoin(tTbl, tTbl.ID.EqCol(tuTbl.TenantID)) - if code != "" { - conds = append(conds, tTbl.Code.Like(database.WrapLike(code))) - } - if name != "" { - conds = append(conds, tTbl.Name.Like(database.WrapLike(name))) - } - } - - rows, total, err := query.Where(conds...).Order(tuTbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) - if err != nil { - return nil, err - } - - tenantIDs := make([]int64, 0, len(rows)) - for _, tu := range rows { - if tu == nil { - continue - } - tenantIDs = append(tenantIDs, tu.TenantID) - } - tenantIDs = lo.Uniq(tenantIDs) - - tenants := make(map[int64]*models.Tenant, len(tenantIDs)) - tenantList := make([]*models.Tenant, 0, len(tenantIDs)) - if len(tenantIDs) > 0 { - tTbl, tQuery := models.TenantQuery.QueryContext(ctx) - ts, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() - if err != nil { - return nil, err - } - for _, te := range ts { - if te == nil { - continue - } - tenants[te.ID] = te - tenantList = append(tenantList, te) - } - } - - ownerMap, err := Tenant.TenantOwnerUserMapping(ctx, tenantList) - if err != nil { - return nil, err - } - - items := make([]*dto.UserTenantItem, 0, len(rows)) - for _, tu := range rows { - if tu == nil { - continue - } - te := tenants[tu.TenantID] - if te == nil { - continue - } - - items = append(items, &dto.UserTenantItem{ - TenantID: te.ID, - Code: te.Code, - Name: te.Name, - TenantStatus: te.Status, - TenantStatusDescription: te.Status.Description(), - ExpiredAt: te.ExpiredAt, - Owner: ownerMap[te.ID], - Role: tu.Role, - MemberStatus: tu.Status, - MemberStatusDescription: tu.Status.Description(), - JoinedAt: tu.CreatedAt, - }) - } - - return &requests.Pager{ - Pagination: filter.Pagination, - Total: total, - Items: items, - }, nil -} diff --git a/backend/app/services/user_test.go b/backend/app/services/user_test.go deleted file mode 100644 index 9ef3b9e..0000000 --- a/backend/app/services/user_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package services - -import ( - "database/sql" - "fmt" - "testing" - - "quyun/v2/app/commands/testx" - "quyun/v2/app/http/super/dto" - "quyun/v2/database" - "quyun/v2/database/models" - "quyun/v2/pkg/consts" - "quyun/v2/pkg/utils" - - "github.com/samber/lo" - . "github.com/smartystreets/goconvey/convey" - "github.com/stretchr/testify/suite" - - _ "go.ipao.vip/atom" - "go.ipao.vip/atom/contracts" - "go.ipao.vip/gen/types" - "go.uber.org/dig" -) - -type UserTestSuiteInjectParams struct { - dig.In - - DB *sql.DB - Initials []contracts.Initial `group:"initials"` // nolint:structcheck -} - -type UserTestSuite struct { - suite.Suite - - UserTestSuiteInjectParams -} - -func Test_User(t *testing.T) { - providers := testx.Default().With(Provide) - - testx.Serve(providers, t, func(p UserTestSuiteInjectParams) { - suite.Run(t, &UserTestSuite{UserTestSuiteInjectParams: p}) - }) -} - -func (t *UserTestSuite) Test_Create() { - Convey("test user create", t.T(), func() { - database.Truncate(t.T().Context(), t.DB, models.TableNameUser) - - m := &models.User{ - Username: "test-user", - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser, consts.RoleSuperAdmin}), - Status: consts.UserStatusPendingVerify, - } - - err := m.Create(t.T().Context()) - So(err, ShouldBeNil) - - So(m.ID, ShouldBeGreaterThan, 0) - - same := m.ComparePassword(t.T().Context(), "test-password") - So(same, ShouldBeTrue) - - same = m.ComparePassword(t.T().Context(), "test-password1") - So(same, ShouldBeFalse) - }) -} - -// FindByUsername -func (t *UserTestSuite) Test_FindByUsername() { - Convey("test user FindByUsername", t.T(), func() { - database.Truncate(t.T().Context(), t.DB, models.TableNameUser) - - Convey("user table is empty", func() { - m, err := User.FindByUsername(t.T().Context(), "test-user") - So(err, ShouldNotBeNil) - So(m, ShouldBeNil) - }) - - Convey("insert one record", func() { - username := "test-user" - m := &models.User{ - Username: username, - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser, consts.RoleSuperAdmin}), - Status: consts.UserStatusPendingVerify, - } - - err := m.Create(t.T().Context()) - So(err, ShouldBeNil) - - Convey("user table is not empty", func() { - m, err := User.FindByUsername(t.T().Context(), username) - So(err, ShouldBeNil) - So(m, ShouldNotBeNil) - }) - }) - }) -} - -// Test_Page -func (t *UserTestSuite) Test_Page() { - Convey("test page", t.T(), func() { - Convey("filter username", func() { - database.Truncate(t.T().Context(), t.DB, models.TableNameUser) - - username := "test-user" - m := &models.User{ - Username: username, - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser, consts.RoleSuperAdmin}), - Status: consts.UserStatusPendingVerify, - } - - err := m.Create(t.T().Context()) - So(err, ShouldBeNil) - - pager, err := User.Page(t.T().Context(), &dto.UserPageFilter{ - Username: &username, - }) - - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 1) - }) - - Convey("filter tenant users", func() { - database.Truncate( - t.T().Context(), - t.DB, - models.TableNameUser, - models.TableNameTenant, - models.TableNameTenantUser, - ) - - username := "test-user" - m := &models.User{ - Username: username, - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser, consts.RoleSuperAdmin}), - Status: consts.UserStatusPendingVerify, - } - - err := m.Create(t.T().Context()) - So(err, ShouldBeNil) - - m = &models.User{ - Username: username + "02", - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusPendingVerify, - } - - err = m.Create(t.T().Context()) - So(err, ShouldBeNil) - - tenantModel := &models.Tenant{ - UserID: 1, - Code: "abc", - UUID: types.NewUUIDv4(), - Name: "T01", - Status: consts.TenantStatusVerified, - } - - err = tenantModel.Create(t.T().Context()) - So(err, ShouldBeNil) - - tenantModel = &models.Tenant{ - UserID: 2, - Code: "abc01", - UUID: types.NewUUIDv4(), - Name: "T02", - Status: consts.TenantStatusVerified, - } - - err = tenantModel.Create(t.T().Context()) - So(err, ShouldBeNil) - - count := 12 - for i := 0; i < count; i++ { - m = &models.User{ - Username: fmt.Sprintf("user_%d", i), - Password: "test-password", - Roles: types.NewArray([]consts.Role{consts.RoleUser}), - Status: consts.UserStatusPendingVerify, - } - - err = m.Create(t.T().Context()) - So(err, ShouldBeNil) - - // create tenant user - err = Tenant.AddUser(t.T().Context(), int64(i%2+1), m.ID) - So(err, ShouldBeNil) - } - - pager, err := User.Page(t.T().Context(), &dto.UserPageFilter{ - TenantID: lo.ToPtr(int64(1)), - }) - - So(err, ShouldBeNil) - So(pager.Total, ShouldEqual, 6) - }) - }) -} - -func (t *UserTestSuite) Test_Relations() { - Convey("test page", t.T(), func() { - Convey("filter tenant users", func() { - m, err := User.FindByID(t.T().Context(), 1) - So(err, ShouldBeNil) - // So(m.OwnedTenant, ShouldNotBeNil) - // So(m.Tenants, ShouldHaveLength, 10) - t.T().Logf("%s", utils.MustJsonString(m)) - }) - }) -} - -func (t *UserTestSuite) Test_Statistics() { - Convey("test page", t.T(), func() { - Convey("filter tenant users", func() { - m, err := User.Statistics(t.T().Context()) - So(err, ShouldBeNil) - // So(m.OwnedTenant, ShouldNotBeNil) - // So(m.Tenants, ShouldHaveLength, 10) - t.T().Logf("%s", utils.MustJsonString(m)) - }) - }) -} diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index c4cfbf5..f90e841 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -11,90 +11,4 @@ imports: - quyun/v2/pkg/consts - quyun/v2/database/fields field_type: - users: - roles: types.Array[consts.Role] - status: consts.UserStatus - tenants: - uuid: types.UUID - status: consts.TenantStatus - tenant_users: - role: types.Array[consts.TenantUserRole] - status: consts.UserStatus - media_assets: - type: consts.MediaAssetType - status: consts.MediaAssetStatus - variant: consts.MediaAssetVariant - contents: - status: consts.ContentStatus - visibility: consts.ContentVisibility - tags: types.JSON - content_assets: - role: consts.ContentAssetRole - content_prices: - currency: consts.Currency - discount_type: consts.DiscountType - content_access: - status: consts.ContentAccessStatus - orders: - type: consts.OrderType - status: consts.OrderStatus - currency: consts.Currency - snapshot: types.JSONType[fields.OrdersSnapshot] - order_items: - snapshot: types.JSONType[fields.OrderItemsSnapshot] - tenant_ledgers: - type: consts.TenantLedgerType - tenant_invites: - status: consts.TenantInviteStatus - tenant_join_requests: - status: consts.TenantJoinRequestStatus field_relate: - users: - OwnedTenant: - relation: belongs_to - table: tenants - json: owned - Tenants: - json: tenants - relation: many_to_many - table: tenants - pivot: tenant_users - # foreign_key: user_id # 当前表(users)用于关联的键(转为结构体字段名 user_id) - join_foreign_key: user_id # 中间表中指向当前表的列(tenant_users.user_id) - # references: id # 关联表(tenants)被引用的列(转为结构体字段名 ID) - join_references: tenant_id # 中间表中指向关联表的列(tenant_users.tenant_id) - tenants: - Users: - relation: many_to_many - table: users - pivot: tenant_users - json: users - join_foreign_key: tenant_id - join_references: user_id - orders: - Items: - relation: has_many - table: order_items - json: items - foreign_key: order_id - references: id - order_items: - Order: - relation: belongs_to - table: orders - json: order - foreign_key: order_id - references: id - Content: - relation: belongs_to - table: contents - json: content - foreign_key: content_id - references: id - tenant_ledgers: - Order: - relation: belongs_to - table: orders - json: order - foreign_key: order_id - references: id diff --git a/backend/database/migrations/20251215084449_users.sql b/backend/database/migrations/20251215084449_users.sql deleted file mode 100644 index 0922b41..0000000 --- a/backend/database/migrations/20251215084449_users.sql +++ /dev/null @@ -1,29 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS users( - id bigserial PRIMARY KEY, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - deleted_at timestamptz, - username varchar(255) NOT NULL UNIQUE, - password varchar(255) NOT NULL, - roles text[] NOT NULL DEFAULT ARRAY['user'], - status varchar(50) NOT NULL DEFAULT 'active', - metas jsonb NOT NULL DEFAULT '{}', - balance bigint NOT NULL DEFAULT 0, - balance_frozen bigint NOT NULL DEFAULT 0, - verified_at timestamptz -); - -COMMENT ON COLUMN users.balance IS '全局可用余额:分/最小货币单位;用户在所有已加入租户内共享该余额;默认 0'; -COMMENT ON COLUMN users.balance_frozen IS '全局冻结余额:分/最小货币单位;用于下单冻结等;默认 0'; - -CREATE INDEX IF NOT EXISTS ix_users_balance ON users(balance); -CREATE INDEX IF NOT EXISTS ix_users_balance_frozen ON users(balance_frozen); - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS users; - --- +goose StatementEnd diff --git a/backend/database/migrations/20251215113803_tenants.sql b/backend/database/migrations/20251215113803_tenants.sql deleted file mode 100644 index 8039aea..0000000 --- a/backend/database/migrations/20251215113803_tenants.sql +++ /dev/null @@ -1,29 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS tenants( - id bigserial PRIMARY KEY, - user_id bigint NOT NULL, - code varchar(64) NOT NULL, - uuid uuid NOT NULL, - name varchar(128) NOT NULL DEFAULT '', - status varchar(64) NOT NULL DEFAULT '', - config jsonb NOT NULL DEFAULT '{}'::jsonb, - expired_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenants_code_lower ON tenants(lower(code)); - -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenants_uuid ON tenants(uuid); - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ux_tenants_uuid; - -DROP INDEX IF EXISTS ux_tenants_code_lower; - -DROP TABLE IF EXISTS tenants; - --- +goose StatementEnd diff --git a/backend/database/migrations/20251216011456_tenant_users.sql b/backend/database/migrations/20251216011456_tenant_users.sql deleted file mode 100644 index d2dd11d..0000000 --- a/backend/database/migrations/20251216011456_tenant_users.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS tenant_users( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - role TEXT[] NOT NULL DEFAULT ARRAY['member'], - status varchar(50) NOT NULL DEFAULT 'active', - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, user_id) -); - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS tenant_users; - --- +goose StatementEnd diff --git a/backend/database/migrations/20251217223000_media_contents.sql b/backend/database/migrations/20251217223000_media_contents.sql deleted file mode 100644 index 08c8fe4..0000000 --- a/backend/database/migrations/20251217223000_media_contents.sql +++ /dev/null @@ -1,190 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS media_assets( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - type varchar(32) NOT NULL DEFAULT 'video', - status varchar(32) NOT NULL DEFAULT 'uploaded', - provider varchar(64) NOT NULL DEFAULT '', - bucket varchar(128) NOT NULL DEFAULT '', - object_key varchar(512) NOT NULL DEFAULT '', - meta jsonb NOT NULL DEFAULT '{}'::jsonb, - deleted_at timestamptz, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- media_assets:媒体资源表(视频/音频/图片),用于承载实际文件对象及其处理状态 -COMMENT ON TABLE media_assets IS '媒体资源:存储对象的抽象(video/audio/image),关联租户与上传用户,记录处理状态与元数据'; -COMMENT ON COLUMN media_assets.id IS '主键ID:自增;仅用于内部关联'; -COMMENT ON COLUMN media_assets.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN media_assets.user_id IS '用户ID:资源上传者;用于审计与权限控制'; -COMMENT ON COLUMN media_assets.type IS '资源类型:video/audio/image;决定后续处理流程(转码/缩略图/封面等)'; -COMMENT ON COLUMN media_assets.status IS '处理状态:uploaded/processing/ready/failed/deleted;ready 才可被内容引用对外提供'; -COMMENT ON COLUMN media_assets.provider IS '存储提供方:例如 s3/minio/oss;便于多存储扩展'; -COMMENT ON COLUMN media_assets.bucket IS '存储桶:对象所在 bucket;与 provider 组合确定存储定位'; -COMMENT ON COLUMN media_assets.object_key IS '对象键:对象在 bucket 内的 key;不得暴露可长期复用的直链(通过签名URL/token下发)'; -COMMENT ON COLUMN media_assets.meta IS '元数据:JSON;包含 hash、duration、width、height、bitrate、codec 等;用于展示与计费/风控'; -COMMENT ON COLUMN media_assets.deleted_at IS '软删除时间:非空表示已删除;对外接口需过滤'; -COMMENT ON COLUMN media_assets.created_at IS '创建时间:默认 now();用于审计与排序'; -COMMENT ON COLUMN media_assets.updated_at IS '更新时间:默认 now();更新状态/元数据时写入'; - -CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_id ON media_assets(tenant_id); -CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_user_id ON media_assets(tenant_id, user_id); -CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_status ON media_assets(tenant_id, status); - -CREATE TABLE IF NOT EXISTS contents( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - title varchar(255) NOT NULL DEFAULT '', - description text NOT NULL DEFAULT '', - status varchar(32) NOT NULL DEFAULT 'draft', - visibility varchar(32) NOT NULL DEFAULT 'tenant_only', - preview_seconds int NOT NULL DEFAULT 60, - preview_downloadable boolean NOT NULL DEFAULT false, - published_at timestamptz, - deleted_at timestamptz, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- contents:内容表(可发布/可售卖/可试看) -COMMENT ON TABLE contents IS '内容:可发布的媒体内容实体,承载标题描述、可见性、试看配置、发布状态等'; -COMMENT ON COLUMN contents.id IS '主键ID:自增;用于内容引用'; -COMMENT ON COLUMN contents.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN contents.user_id IS '用户ID:内容创建者/发布者;用于权限与审计(例如私有内容仅作者可见)'; -COMMENT ON COLUMN contents.title IS '标题:用于列表展示与搜索;建议限制长度(由业务校验)'; -COMMENT ON COLUMN contents.description IS '描述:用于详情页展示;可为空字符串'; -COMMENT ON COLUMN contents.status IS '状态:draft/reviewing/published/unpublished/blocked;published 才对外展示'; -COMMENT ON COLUMN contents.visibility IS '可见性:public/tenant_only/private;仅控制详情可见,正片资源仍需按价格/权益校验'; -COMMENT ON COLUMN contents.preview_seconds IS '试看秒数:默认 60;只对 preview 资源生效;必须为正整数'; -COMMENT ON COLUMN contents.preview_downloadable IS '试看是否允许下载:默认 false;当前策略固定为不允许下载(仅 streaming)'; -COMMENT ON COLUMN contents.published_at IS '发布时间:首次发布时写入;用于时间窗与排序'; -COMMENT ON COLUMN contents.deleted_at IS '软删除时间:非空表示已删除;对外接口需过滤'; -COMMENT ON COLUMN contents.created_at IS '创建时间:默认 now();用于审计与排序'; -COMMENT ON COLUMN contents.updated_at IS '更新时间:默认 now();编辑内容时写入'; - -CREATE INDEX IF NOT EXISTS ix_contents_tenant_id ON contents(tenant_id); -CREATE INDEX IF NOT EXISTS ix_contents_tenant_user_id ON contents(tenant_id, user_id); -CREATE INDEX IF NOT EXISTS ix_contents_tenant_status ON contents(tenant_id, status); -CREATE INDEX IF NOT EXISTS ix_contents_tenant_visibility ON contents(tenant_id, visibility); - -CREATE TABLE IF NOT EXISTS content_assets( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - content_id bigint NOT NULL, - asset_id bigint NOT NULL, - role varchar(32) NOT NULL DEFAULT 'main', - sort int NOT NULL DEFAULT 0, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, content_id, asset_id) -); - --- content_assets:内容与媒体资源的关联(区分 main/cover/preview) -COMMENT ON TABLE content_assets IS '内容-资源关联:将 media_assets 以角色(main/cover/preview)绑定到 contents,支持排序'; -COMMENT ON COLUMN content_assets.id IS '主键ID:自增'; -COMMENT ON COLUMN content_assets.tenant_id IS '租户ID:多租户隔离;必须与 content_id、asset_id 所属租户一致'; -COMMENT ON COLUMN content_assets.user_id IS '用户ID:操作人/绑定人;用于审计(通常为租户管理员或作者)'; -COMMENT ON COLUMN content_assets.content_id IS '内容ID:关联 contents.id;用于查询内容下资源列表'; -COMMENT ON COLUMN content_assets.asset_id IS '资源ID:关联 media_assets.id;用于查询资源归属内容'; -COMMENT ON COLUMN content_assets.role IS '资源角色:main/cover/preview;preview 必须为独立资源以满足禁下载与防绕过'; -COMMENT ON COLUMN content_assets.sort IS '排序:同一 role 下的展示顺序,数值越小越靠前'; -COMMENT ON COLUMN content_assets.created_at IS '创建时间:默认 now();用于审计'; -COMMENT ON COLUMN content_assets.updated_at IS '更新时间:默认 now();更新 sort/role 时写入'; - -CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_content ON content_assets(tenant_id, content_id); -CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_asset ON content_assets(tenant_id, asset_id); -CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_role ON content_assets(tenant_id, content_id, role); - -CREATE TABLE IF NOT EXISTS content_prices( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - content_id bigint NOT NULL, - currency varchar(16) NOT NULL DEFAULT 'CNY', - price_amount bigint NOT NULL DEFAULT 0, - discount_type varchar(16) NOT NULL DEFAULT 'none', - discount_value bigint NOT NULL DEFAULT 0, - discount_start_at timestamptz, - discount_end_at timestamptz, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, content_id) -); - --- content_prices:内容定价与折扣(仅 CNY 分) -COMMENT ON TABLE content_prices IS '内容定价:为内容设置价格与折扣(订单需记录成交快照,避免改价影响历史)'; -COMMENT ON COLUMN content_prices.id IS '主键ID:自增'; -COMMENT ON COLUMN content_prices.tenant_id IS '租户ID:多租户隔离;与内容归属一致'; -COMMENT ON COLUMN content_prices.user_id IS '用户ID:设置/更新价格的操作人(通常为 tenant_admin);用于审计'; -COMMENT ON COLUMN content_prices.content_id IS '内容ID:唯一约束 (tenant_id, content_id);一个内容在一个租户内仅一份定价'; -COMMENT ON COLUMN content_prices.currency IS '币种:当前固定 CNY;金额单位为分'; -COMMENT ON COLUMN content_prices.price_amount IS '基础价格:分;0 表示免费(可直接访问正片资源)'; -COMMENT ON COLUMN content_prices.discount_type IS '折扣类型:none/percent/amount;仅影响下单时成交价,需写入订单快照'; -COMMENT ON COLUMN content_prices.discount_value IS '折扣值:percent=0-100(按业务校验);amount=分;none 时忽略'; -COMMENT ON COLUMN content_prices.discount_start_at IS '折扣开始时间:可为空;为空表示立即生效(由业务逻辑解释)'; -COMMENT ON COLUMN content_prices.discount_end_at IS '折扣结束时间:可为空;为空表示长期有效(由业务逻辑解释)'; -COMMENT ON COLUMN content_prices.created_at IS '创建时间:默认 now();用于审计'; -COMMENT ON COLUMN content_prices.updated_at IS '更新时间:默认 now();更新价格/折扣时写入'; - -CREATE INDEX IF NOT EXISTS ix_content_prices_tenant_id ON content_prices(tenant_id); - -CREATE TABLE IF NOT EXISTS content_access( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - content_id bigint NOT NULL, - order_id bigint, - status varchar(16) NOT NULL DEFAULT 'active', - revoked_at timestamptz, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, user_id, content_id) -); - --- content_access:购买权益/访问权限(退款后撤销) -COMMENT ON TABLE content_access IS '内容权益:记录用户在租户内对内容的访问资格;退款后应立即 revoked'; -COMMENT ON COLUMN content_access.id IS '主键ID:自增'; -COMMENT ON COLUMN content_access.tenant_id IS '租户ID:多租户隔离;与内容、用户归属一致'; -COMMENT ON COLUMN content_access.user_id IS '用户ID:权益所属用户;用于访问校验'; -COMMENT ON COLUMN content_access.content_id IS '内容ID:权益对应内容;唯一约束 (tenant_id, user_id, content_id)'; -COMMENT ON COLUMN content_access.order_id IS '订单ID:产生该权益的订单;可为空(例如后台补发/迁移)'; -COMMENT ON COLUMN content_access.status IS '权益状态:active/revoked/expired;revoked 表示立即失效(例如退款/违规)'; -COMMENT ON COLUMN content_access.revoked_at IS '撤销时间:当 status=revoked 时写入;用于审计与追责'; -COMMENT ON COLUMN content_access.created_at IS '创建时间:默认 now();用于审计'; -COMMENT ON COLUMN content_access.updated_at IS '更新时间:默认 now();更新 status 时写入'; - -CREATE INDEX IF NOT EXISTS ix_content_access_tenant_user ON content_access(tenant_id, user_id); -CREATE INDEX IF NOT EXISTS ix_content_access_tenant_content ON content_access(tenant_id, content_id); - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_content_access_tenant_content; -DROP INDEX IF EXISTS ix_content_access_tenant_user; -DROP TABLE IF EXISTS content_access; - -DROP INDEX IF EXISTS ix_content_prices_tenant_id; -DROP TABLE IF EXISTS content_prices; - -DROP INDEX IF EXISTS ix_content_assets_tenant_role; -DROP INDEX IF EXISTS ix_content_assets_tenant_asset; -DROP INDEX IF EXISTS ix_content_assets_tenant_content; -DROP TABLE IF EXISTS content_assets; - -DROP INDEX IF EXISTS ix_contents_tenant_visibility; -DROP INDEX IF EXISTS ix_contents_tenant_status; -DROP INDEX IF EXISTS ix_contents_tenant_user_id; -DROP INDEX IF EXISTS ix_contents_tenant_id; -DROP TABLE IF EXISTS contents; - -DROP INDEX IF EXISTS ix_media_assets_tenant_status; -DROP INDEX IF EXISTS ix_media_assets_tenant_user_id; -DROP INDEX IF EXISTS ix_media_assets_tenant_id; -DROP TABLE IF EXISTS media_assets; - --- +goose StatementEnd diff --git a/backend/database/migrations/20251218120000_orders_ledgers.sql b/backend/database/migrations/20251218120000_orders_ledgers.sql deleted file mode 100644 index 067b876..0000000 --- a/backend/database/migrations/20251218120000_orders_ledgers.sql +++ /dev/null @@ -1,138 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS orders( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - type varchar(32) NOT NULL DEFAULT 'content_purchase', - status varchar(32) NOT NULL DEFAULT 'created', - currency varchar(16) NOT NULL DEFAULT 'CNY', - amount_original bigint NOT NULL DEFAULT 0, - amount_discount bigint NOT NULL DEFAULT 0, - amount_paid bigint NOT NULL DEFAULT 0, - snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, - idempotency_key varchar(128) NOT NULL DEFAULT '', - paid_at timestamptz, - refunded_at timestamptz, - refund_forced boolean NOT NULL DEFAULT false, - refund_operator_user_id bigint, - refund_reason varchar(255) NOT NULL DEFAULT '', - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- orders:订单主表(租户内购买等业务单据) -COMMENT ON TABLE orders IS '订单:租户内的业务交易单据;记录成交金额快照、状态流转与退款信息;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN orders.id IS '主键ID:自增;用于关联订单明细、账本流水、权益等'; -COMMENT ON COLUMN orders.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN orders.user_id IS '用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准'; -COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)等;当前默认 content_purchase'; -COMMENT ON COLUMN orders.status IS '订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致'; -COMMENT ON COLUMN orders.currency IS '币种:当前固定 CNY;金额单位为分'; -COMMENT ON COLUMN orders.amount_original IS '原价金额:分;未折扣前金额(用于展示与对账)'; -COMMENT ON COLUMN orders.amount_discount IS '优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)'; -COMMENT ON COLUMN orders.amount_paid IS '实付金额:分;从租户内余额扣款的金额(下单时快照)'; -COMMENT ON COLUMN orders.snapshot IS '订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示'; -COMMENT ON COLUMN orders.idempotency_key IS '幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)'; -COMMENT ON COLUMN orders.paid_at IS '支付/扣款完成时间:余额支付在 debit_purchase 成功后写入'; -COMMENT ON COLUMN orders.refunded_at IS '退款完成时间:退款落账成功后写入'; -COMMENT ON COLUMN orders.refund_forced IS '是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)'; -COMMENT ON COLUMN orders.refund_operator_user_id IS '退款操作人用户ID:租户管理员/系统;用于审计与追责'; -COMMENT ON COLUMN orders.refund_reason IS '退款原因:后台/用户发起退款的原因说明;用于审计'; -COMMENT ON COLUMN orders.created_at IS '创建时间:默认 now();用于审计与排序'; -COMMENT ON COLUMN orders.updated_at IS '更新时间:默认 now();状态变更/退款写入时更新'; - -CREATE INDEX IF NOT EXISTS ix_orders_tenant_user ON orders(tenant_id, user_id); -CREATE INDEX IF NOT EXISTS ix_orders_tenant_status ON orders(tenant_id, status); -CREATE INDEX IF NOT EXISTS ix_orders_tenant_paid_at ON orders(tenant_id, paid_at); -CREATE UNIQUE INDEX IF NOT EXISTS ux_orders_tenant_idempotency_key ON orders(tenant_id, user_id, idempotency_key) WHERE idempotency_key <> ''; - -CREATE TABLE IF NOT EXISTS order_items( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - order_id bigint NOT NULL, - content_id bigint NOT NULL, - content_user_id bigint NOT NULL DEFAULT 0, - amount_paid bigint NOT NULL DEFAULT 0, - snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, order_id, content_id) -); - --- order_items:订单明细(购买内容通常一单一内容,但保留扩展能力) -COMMENT ON TABLE order_items IS '订单明细:记录订单购买的具体内容及金额/快照;支持后续扩展为一单多内容'; -COMMENT ON COLUMN order_items.id IS '主键ID:自增'; -COMMENT ON COLUMN order_items.tenant_id IS '租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致'; -COMMENT ON COLUMN order_items.user_id IS '用户ID:下单用户(buyer);冗余字段用于查询加速与审计'; -COMMENT ON COLUMN order_items.order_id IS '订单ID:关联 orders.id;用于聚合订单明细'; -COMMENT ON COLUMN order_items.content_id IS '内容ID:关联 contents.id;用于生成/撤销 content_access'; -COMMENT ON COLUMN order_items.content_user_id IS '内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者'; -COMMENT ON COLUMN order_items.amount_paid IS '该行实付金额:分;通常等于订单 amount_paid(单内容场景)'; -COMMENT ON COLUMN order_items.snapshot IS '内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计'; -COMMENT ON COLUMN order_items.created_at IS '创建时间:默认 now()'; -COMMENT ON COLUMN order_items.updated_at IS '更新时间:默认 now()'; - -CREATE INDEX IF NOT EXISTS ix_order_items_tenant_order ON order_items(tenant_id, order_id); -CREATE INDEX IF NOT EXISTS ix_order_items_tenant_content ON order_items(tenant_id, content_id); - -CREATE TABLE IF NOT EXISTS tenant_ledgers( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - order_id bigint, - type varchar(32) NOT NULL, - amount bigint NOT NULL DEFAULT 0, - balance_before bigint NOT NULL DEFAULT 0, - balance_after bigint NOT NULL DEFAULT 0, - frozen_before bigint NOT NULL DEFAULT 0, - frozen_after bigint NOT NULL DEFAULT 0, - idempotency_key varchar(128) NOT NULL DEFAULT '', - remark varchar(255) NOT NULL DEFAULT '', - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- tenant_ledgers:租户内余额账本流水(必须可审计、可幂等) -COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(冻结/扣款/退款/调账等);用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.id IS '主键ID:自增'; -COMMENT ON COLUMN tenant_ledgers.tenant_id IS '租户ID:多租户隔离关键字段;必须与 tenant_users.tenant_id 一致'; -COMMENT ON COLUMN tenant_ledgers.user_id IS '用户ID:余额账户归属用户;对应 tenant_users.user_id'; -COMMENT ON COLUMN tenant_ledgers.order_id IS '关联订单ID:购买/退款类流水应关联 orders.id;非订单类可为空'; -COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; -COMMENT ON COLUMN tenant_ledgers.amount IS '流水金额:分/最小货币单位;通常为正数,方向由 type 决定(由业务层约束)'; -COMMENT ON COLUMN tenant_ledgers.balance_before IS '变更前可用余额:用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.balance_after IS '变更后可用余额:用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.frozen_before IS '变更前冻结余额:用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.frozen_after IS '变更后冻结余额:用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.idempotency_key IS '幂等键:同一租户同一用户同一业务操作固定;用于防止重复落账(建议由业务层生成)'; -COMMENT ON COLUMN tenant_ledgers.remark IS '备注:业务说明/后台操作原因等;用于审计'; -COMMENT ON COLUMN tenant_ledgers.created_at IS '创建时间:默认 now()'; -COMMENT ON COLUMN tenant_ledgers.updated_at IS '更新时间:默认 now()'; - -CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_user ON tenant_ledgers(tenant_id, user_id); -CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_order ON tenant_ledgers(tenant_id, order_id); -CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_type ON tenant_ledgers(tenant_id, type); -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_idempotency_key ON tenant_ledgers(tenant_id, user_id, idempotency_key) WHERE idempotency_key <> ''; - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_idempotency_key; -DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_type; -DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_order; -DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_user; -DROP TABLE IF EXISTS tenant_ledgers; - -DROP INDEX IF EXISTS ix_order_items_tenant_content; -DROP INDEX IF EXISTS ix_order_items_tenant_order; -DROP TABLE IF EXISTS order_items; - -DROP INDEX IF EXISTS ux_orders_tenant_idempotency_key; -DROP INDEX IF EXISTS ix_orders_tenant_paid_at; -DROP INDEX IF EXISTS ix_orders_tenant_status; -DROP INDEX IF EXISTS ix_orders_tenant_user; -DROP TABLE IF EXISTS orders; - --- +goose StatementEnd diff --git a/backend/database/migrations/20251218164000_orders_amount_paid_index.sql b/backend/database/migrations/20251218164000_orders_amount_paid_index.sql deleted file mode 100644 index 9fcc843..0000000 --- a/backend/database/migrations/20251218164000_orders_amount_paid_index.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- +goose StatementBegin --- orders.amount_paid:为“按金额区间筛选订单”提供索引(租户内隔离维度) -CREATE INDEX IF NOT EXISTS ix_orders_tenant_amount_paid ON orders(tenant_id, amount_paid); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_orders_tenant_amount_paid; --- +goose StatementEnd - diff --git a/backend/database/migrations/20251218171000_fix_tenant_users_status_default.sql b/backend/database/migrations/20251218171000_fix_tenant_users_status_default.sql deleted file mode 100644 index 5795fe0..0000000 --- a/backend/database/migrations/20251218171000_fix_tenant_users_status_default.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up --- +goose StatementBegin --- tenant_users.status:历史上默认值为 'active',但代码枚举使用 UserStatus(pending_verify/verified/banned)。 --- 为避免新增成员落入未知状态,这里将默认值调整为 'verified',并修正存量 'active' -> 'verified'。 -ALTER TABLE tenant_users - ALTER COLUMN status SET DEFAULT 'verified'; - -UPDATE tenant_users -SET status = 'verified' -WHERE status = 'active'; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin --- 回滚:恢复默认值为 'active'(不回滚数据修正)。 -ALTER TABLE tenant_users - ALTER COLUMN status SET DEFAULT 'active'; --- +goose StatementEnd - diff --git a/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql b/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql deleted file mode 100644 index a9b53ed..0000000 --- a/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql +++ /dev/null @@ -1,81 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS tenant_invites( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - code varchar(64) NOT NULL, - status varchar(32) NOT NULL DEFAULT 'active', - max_uses int NOT NULL DEFAULT 0, - used_count int NOT NULL DEFAULT 0, - expires_at timestamptz, - disabled_at timestamptz, - disabled_operator_user_id bigint, - remark varchar(255) NOT NULL DEFAULT '', - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, code) -); - --- tenant_invites:租户邀请(用于用户通过邀请码加入租户) -COMMENT ON TABLE tenant_invites IS '租户邀请:租户管理员生成的邀请码;用户可通过 code 加入租户;支持禁用、过期、使用次数限制;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN tenant_invites.id IS '主键ID:自增'; -COMMENT ON COLUMN tenant_invites.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN tenant_invites.user_id IS '创建人用户ID:生成邀请码的租户管理员(审计用)'; -COMMENT ON COLUMN tenant_invites.code IS '邀请码:用户加入租户时提交;同一租户内唯一'; -COMMENT ON COLUMN tenant_invites.status IS '邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致'; -COMMENT ON COLUMN tenant_invites.max_uses IS '最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效'; -COMMENT ON COLUMN tenant_invites.used_count IS '已使用次数:每次成功加入时 +1;需事务保证并发下不超发'; -COMMENT ON COLUMN tenant_invites.expires_at IS '过期时间:到期后不可再使用(UTC);为空表示不过期'; -COMMENT ON COLUMN tenant_invites.disabled_at IS '禁用时间:租户管理员禁用该邀请的时间(UTC)'; -COMMENT ON COLUMN tenant_invites.disabled_operator_user_id IS '禁用操作人用户ID:租户管理员(审计用)'; -COMMENT ON COLUMN tenant_invites.remark IS '备注:生成/禁用原因等(审计用)'; -COMMENT ON COLUMN tenant_invites.created_at IS '创建时间:默认 now()'; -COMMENT ON COLUMN tenant_invites.updated_at IS '更新时间:默认 now()'; - -CREATE INDEX IF NOT EXISTS ix_tenant_invites_tenant_status ON tenant_invites(tenant_id, status); -CREATE INDEX IF NOT EXISTS ix_tenant_invites_tenant_expires_at ON tenant_invites(tenant_id, expires_at); - -CREATE TABLE IF NOT EXISTS tenant_join_requests( - id bigserial PRIMARY KEY, - tenant_id bigint NOT NULL, - user_id bigint NOT NULL, - status varchar(32) NOT NULL DEFAULT 'pending', - reason varchar(255) NOT NULL DEFAULT '', - decided_at timestamptz, - decided_operator_user_id bigint, - decided_reason varchar(255) NOT NULL DEFAULT '', - created_at timestamptz NOT NULL DEFAULT NOW(), - updated_at timestamptz NOT NULL DEFAULT NOW() -); - --- tenant_join_requests:加入租户申请(用于无邀请码场景的人工审核) -COMMENT ON TABLE tenant_join_requests IS '加入申请:用户申请加入租户,租户管理员审核通过/拒绝;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN tenant_join_requests.id IS '主键ID:自增'; -COMMENT ON COLUMN tenant_join_requests.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; -COMMENT ON COLUMN tenant_join_requests.user_id IS '申请人用户ID:发起加入申请的用户'; -COMMENT ON COLUMN tenant_join_requests.status IS '申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id'; -COMMENT ON COLUMN tenant_join_requests.reason IS '申请原因:用户填写的加入说明(可选)'; -COMMENT ON COLUMN tenant_join_requests.decided_at IS '处理时间:审核通过/拒绝时记录(UTC)'; -COMMENT ON COLUMN tenant_join_requests.decided_operator_user_id IS '处理人用户ID:租户管理员(审计用)'; -COMMENT ON COLUMN tenant_join_requests.decided_reason IS '处理说明:管理员通过/拒绝的原因(可选,审计用)'; -COMMENT ON COLUMN tenant_join_requests.created_at IS '创建时间:默认 now()'; -COMMENT ON COLUMN tenant_join_requests.updated_at IS '更新时间:默认 now()'; - --- 约束:同一用户同一租户同一时间仅允许存在一个 pending 申请,避免重复提交淹没审核队列。 -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_join_requests_tenant_user_pending ON tenant_join_requests(tenant_id, user_id) WHERE status = 'pending'; -CREATE INDEX IF NOT EXISTS ix_tenant_join_requests_tenant_status ON tenant_join_requests(tenant_id, status); -CREATE INDEX IF NOT EXISTS ix_tenant_join_requests_tenant_created_at ON tenant_join_requests(tenant_id, created_at); - --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_tenant_join_requests_tenant_created_at; -DROP INDEX IF EXISTS ix_tenant_join_requests_tenant_status; -DROP INDEX IF EXISTS ux_tenant_join_requests_tenant_user_pending; -DROP TABLE IF EXISTS tenant_join_requests; - -DROP INDEX IF EXISTS ix_tenant_invites_tenant_expires_at; -DROP INDEX IF EXISTS ix_tenant_invites_tenant_status; -DROP TABLE IF EXISTS tenant_invites; --- +goose StatementEnd diff --git a/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql b/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql deleted file mode 100644 index 3c9467d..0000000 --- a/backend/database/migrations/20251218193000_orders_admin_list_indexes.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up --- +goose StatementBegin --- orders 列表查询索引补齐:租户管理端常用按 created_at/type 过滤 + 排序。 -CREATE INDEX IF NOT EXISTS ix_orders_tenant_created_at ON orders(tenant_id, created_at); -CREATE INDEX IF NOT EXISTS ix_orders_tenant_type ON orders(tenant_id, type); --- +goose StatementEnd --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_orders_tenant_type; -DROP INDEX IF EXISTS ix_orders_tenant_created_at; --- +goose StatementEnd diff --git a/backend/database/migrations/20251222174000_media_assets_variant.sql b/backend/database/migrations/20251222174000_media_assets_variant.sql deleted file mode 100644 index 310640f..0000000 --- a/backend/database/migrations/20251222174000_media_assets_variant.sql +++ /dev/null @@ -1,36 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE media_assets - ADD COLUMN IF NOT EXISTS variant varchar(32) NOT NULL DEFAULT 'main'; - --- 回填历史数据:老数据一律视为 main。 -UPDATE media_assets -SET variant = 'main' -WHERE variant IS NULL OR variant = ''; - --- 约束:只允许 main/preview -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'ck_media_assets_variant' - ) THEN - ALTER TABLE media_assets - ADD CONSTRAINT ck_media_assets_variant - CHECK (variant IN ('main', 'preview')); - END IF; -END -$$; - -COMMENT ON COLUMN media_assets.variant IS '产物类型:main/preview;用于强制试看资源必须绑定独立产物,避免用正片绕过'; - -CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_variant ON media_assets (tenant_id, variant); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_media_assets_tenant_variant; -ALTER TABLE media_assets DROP CONSTRAINT IF EXISTS ck_media_assets_variant; -ALTER TABLE media_assets DROP COLUMN IF EXISTS variant; --- +goose StatementEnd diff --git a/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql b/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql deleted file mode 100644 index 4b67008..0000000 --- a/backend/database/migrations/20251222175500_media_assets_source_asset_id.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE media_assets - ADD COLUMN IF NOT EXISTS source_asset_id bigint; - -COMMENT ON COLUMN media_assets.source_asset_id IS '派生来源资源ID:preview 产物可指向对应 main 资源;用于建立 preview/main 的 1:1 追溯关系'; - -CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_source_asset_id ON media_assets (tenant_id, source_asset_id); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_media_assets_tenant_source_asset_id; -ALTER TABLE media_assets DROP COLUMN IF EXISTS source_asset_id; --- +goose StatementEnd - diff --git a/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql b/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql deleted file mode 100644 index 074bbf8..0000000 --- a/backend/database/migrations/20251222211500_tenant_ledgers_audit_fields.sql +++ /dev/null @@ -1,40 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE tenant_ledgers - ADD COLUMN IF NOT EXISTS operator_user_id bigint, - ADD COLUMN IF NOT EXISTS biz_ref_type varchar(32), - ADD COLUMN IF NOT EXISTS biz_ref_id bigint; - --- tenant_ledgers.operator_user_id:操作者(谁触发该流水) --- 用途:用于审计与风控追溯(例如后台代退款/调账等)。 -COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; - --- tenant_ledgers.biz_ref_type/biz_ref_id:业务引用(幂等与追溯) --- 用途:在 idempotency_key 之外提供结构化引用(例如 order/refund 等),便于报表与按业务对象追溯。 -COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; -COMMENT ON COLUMN tenant_ledgers.biz_ref_id IS '业务引用ID:与 biz_ref_type 配合使用(例如 orders.id);用于对账与审计'; - --- 索引:按操作者检索敏感操作流水(后台审计用)。 -CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_operator ON tenant_ledgers(tenant_id, operator_user_id); - --- 索引:按业务引用快速定位同一业务对象的流水集合。 -CREATE INDEX IF NOT EXISTS ix_tenant_ledgers_tenant_biz_ref ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id); - --- 结构化幂等(可选):同一业务引用在同一流水类型下只能出现一条。 --- 说明:biz_ref_* 允许为空;仅当两者都非空时才参与唯一性约束。 -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type - ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) - WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; -DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_biz_ref; -DROP INDEX IF EXISTS ix_tenant_ledgers_tenant_operator; - -ALTER TABLE tenant_ledgers - DROP COLUMN IF EXISTS biz_ref_id, - DROP COLUMN IF EXISTS biz_ref_type, - DROP COLUMN IF EXISTS operator_user_id; --- +goose StatementEnd diff --git a/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql b/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql deleted file mode 100644 index 8c55e9a..0000000 --- a/backend/database/migrations/20251222211800_fix_tenant_ledgers_biz_ref_unique.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up --- +goose StatementBegin --- 修正:biz_ref_type/biz_ref_id 在 Go 模型侧为 string/int64(非指针),空值会写入 ''/0, --- 若唯一索引仅判断 NOT NULL,会导致大量流水写入冲突。 --- 约束策略:仅当 biz_ref_type 非空 且 biz_ref_id > 0 时才参与唯一性约束。 -DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; - -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type - ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) - WHERE biz_ref_type IS NOT NULL AND biz_ref_type <> '' AND biz_ref_id IS NOT NULL AND biz_ref_id <> 0; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type; - --- Down 回滚为“仅判断 NOT NULL”的版本(不建议在线上使用该版本)。 -CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_ledgers_tenant_biz_ref_type_id_type - ON tenant_ledgers(tenant_id, biz_ref_type, biz_ref_id, type) - WHERE biz_ref_type IS NOT NULL AND biz_ref_id IS NOT NULL; --- +goose StatementEnd - diff --git a/backend/database/migrations/20251223124000_update_order_ledger_comments.sql b/backend/database/migrations/20251223124000_update_order_ledger_comments.sql deleted file mode 100644 index e1df9ba..0000000 --- a/backend/database/migrations/20251223124000_update_order_ledger_comments.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- +goose StatementBegin --- 清理“充值”遗留描述:当前项目已移除租户充值与 per-tenant 余额。 - -COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)等;当前默认 content_purchase'; - -COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(冻结/扣款/退款/调账等);用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; - -COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; -COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin --- 新项目不需要依赖 Down 做历史回滚;保持与 Up 一致,避免引入已移除特性的遗留描述。 -COMMENT ON COLUMN orders.type IS '订单类型:content_purchase(购买内容)等;当前默认 content_purchase'; - -COMMENT ON TABLE tenant_ledgers IS '账本流水:记录租户内用户余额的每一次变化(冻结/扣款/退款/调账等);用于审计与对账回放'; -COMMENT ON COLUMN tenant_ledgers.type IS '流水类型:debit_purchase/credit_refund/freeze/unfreeze/adjustment;不同类型决定余额/冻结余额的变更方向'; - -COMMENT ON COLUMN tenant_ledgers.operator_user_id IS '操作者用户ID:谁触发该流水(admin/buyer/system);用于审计与追责;可为空(历史数据或无法识别时)'; -COMMENT ON COLUMN tenant_ledgers.biz_ref_type IS '业务引用类型:order/refund/etc;与 biz_ref_id 组成可选的结构化幂等/追溯键'; --- +goose StatementEnd diff --git a/backend/database/migrations/20251225123000_contents_summary_tags.sql b/backend/database/migrations/20251225123000_contents_summary_tags.sql deleted file mode 100644 index 47223e5..0000000 --- a/backend/database/migrations/20251225123000_contents_summary_tags.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- +goose StatementBegin --- contents:补齐“简介/标签”字段,用于内容发布与列表展示 -ALTER TABLE contents - ADD COLUMN IF NOT EXISTS summary varchar(256) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS tags jsonb NOT NULL DEFAULT '[]'::jsonb; - -COMMENT ON COLUMN contents.summary IS '简介:用于列表/卡片展示的短文本;建议 <= 256 字符(由业务校验)'; -COMMENT ON COLUMN contents.tags IS '标签:JSON 数组(字符串列表);用于分类/检索与聚合展示'; - -CREATE INDEX IF NOT EXISTS ix_contents_tenant_tags ON contents(tenant_id); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP INDEX IF EXISTS ix_contents_tenant_tags; -ALTER TABLE contents - DROP COLUMN IF EXISTS tags, - DROP COLUMN IF EXISTS summary; --- +goose StatementEnd - diff --git a/backend/database/migrations/20251227112605_init.sql b/backend/database/migrations/20251227112605_init.sql new file mode 100644 index 0000000..b9c449e --- /dev/null +++ b/backend/database/migrations/20251227112605_init.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +-- +goose StatementEnd diff --git a/backend/docs/dev/http_api.md b/backend/docs/dev/http_api.md deleted file mode 100644 index e6b8c42..0000000 --- a/backend/docs/dev/http_api.md +++ /dev/null @@ -1,92 +0,0 @@ -# 新增 HTTP 接口流程 - -项目 `controller` 定义于 `backend/app/http/[module_name]/[controller].go` 文件中。 每个 `controller` 负责处理一组相关的 HTTP 请求。 例如,`backend/app/http/super/tenant.go` 负责处理与租户相关的请求。 - -## 新增接口步骤 - -1. 在 `controller` 中新增方法以处理特定的 HTTP 请求。 例如,在 `tenant.go` 新增一个 `list` 方法来处理列出租户的请求。 -2. 定义相关 `swagger` 注解以生成 API 文档。 这些注解通常位于方法上方,描述了请求路径、参数和响应格式。 -3. 在模块 `dto/`(数据传输对象)目录中定义请求和响应的数据结构, 以确保数据的一致性和类型安全。 -4. 运行 `atomctl gen route` 生成路由文件,确保新接口被正确注册。 -5. 运行 `atomctl gen provider` 生成路由文件,确保新接口被正确注册。 - -## 接口定义示例: - -1. 实现需要返回数据的接口。 - -```go -func (*tenant) list(ctx fiber.Ctx, filter *dto.TenantFilter) (*requests.Pager, error) { - return nil,nil -} -``` - -2. 实现不需要返回数据的接口 - -```go -func (*tenant) update(ctx fiber.Ctx, tenantID int64, form *dto.TenantExpireUpdateForm) error { - return nil -} -``` - -## swagger 注解说明 - -- **@Summary**: 接口的简要描述。 -- **@Description**: 接口的详细描述。 -- **@Tags**: 接口所属的分类标签。 -- **@Accept**: 接口接受的数据格式。通常为 `json` -- **@Produce**: 接口返回的数据格式。通常为 `json` -- **@Param**: 定义接口的参数,包括参数名称、数据来源位置、数据类型、是否必须、和描述。如:`// @Param form body dto.LoginForm true "form"` -- **@Success**: 定义接口成功时的响应格式。如: - - 返回分页列表 `// @Success 200 {object} requests.Pager{items=dto.Item}` - - 直接返回对象 `// @Success 200 {object} dto.Item` - - 返回数据对象列表 `// @Success 200 {array} dto.Item` -- **@Router**: 定义接口的路由信息,包括路径和请求方法。如:`// @Router /super/tenants [get|post|put|delete|patch]`, 如果需要定义 path 参数,使用 `:paramName` 语法表示,如:`/super/tenants/:tenantID` -- **@Bind**: 定义参数绑定方式,格式: `@Bind [key()] [model(|[:])]` - - `paramName` 与方法参数名一致(大小写敏感) - - `position`:`path`、`query`、`body`、`header`、`cookie`、`local`、`file` - - 可选: - - `key()` 覆盖默认键名; - - `model()` 详见“模型绑定”。 - -### 参数绑定 - -- query:标量用 `QueryParam[T]("key")`,非标量用 `Query[T]("key")` -- path:标量用 `PathParam[T]("key")`,非标量用 `Path[T]("key")` - - 若使用 `model()`(仅在 path 有效),会按字段值查询并绑定为 `T`,详见下文 -- header:`Header[T]("key")` -- body:`Body[T]("key")` -- cookie:`string` 用 `CookieParam("key")`,其他用 `Cookie[T]("key")` -- file:`File[multipart.FileHeader]("key")` -- local:`Local[T]("key")` - -说明: - -- 标量类型集合:`string`、`int`、`int32`、`int64`、`float32`、`float64`、`bool` -- `key` 默认等于 `paramName`;设置 `key(...)` 后以其为准 -- `file` 使用固定类型 `multipart.FileHeader` - -### 类型与指针处理 - -- 支持 `T`、`*T`、`pkg.T`、`*pkg.T`;会正确收集选择子表达式对应 import -- 忽略结尾为 `Context` 或 `Ctx` 的参数(框架上下文) -- 指针处理:除 `local` 外会去掉前导 `*` 作为泛型实参;`local` 保留指针(便于写回) - -### 模型绑定 - -当 `@Bind ... model(...)` 配合 `position=path` 使用时,将根据路径参数值查询模型并绑定为方法参数类型的实例(`T` 来自方法参数)。 - -- 语法: - - 仅字段:`model(id)`(推荐) - - 指定字段与类型:`model(id:int)`、`model(code:string)`(用于非字符串路径参数) - - 指定类型与字段:`model(pkg.Type:field)` 或 `model(pkg.Type)`(字段缺省为 `id`) -- 行为: - - 生成的绑定器会按给定字段构造查询条件并返回首条记录 - - 自动注入 import:`field "go.ipao.vip/gen/field"`,用于构造字段条件表达式 - -示例: - -```go -// @Router /users/:id [get] -// @Bind user path key(id) model(id) -func (uc *UserController) Show(ctx context.Context, user *models.User) (*UserDTO, error) -``` diff --git a/backend/docs/dev/model.md b/backend/docs/dev/model.md deleted file mode 100644 index 96ebfe8..0000000 --- a/backend/docs/dev/model.md +++ /dev/null @@ -1,226 +0,0 @@ -# 新增 model 流程 - -项目 `models` 定义于 `backend/database/models` 文件中。 每个 `model` 对应数据库中的一张表。 新增 `model` 的步骤如下: - -## 步骤 - -1. 运行 `atomctl migrate create [alter|create_table]` 创建迁移文件。 -2. 编辑生成的迁移文件,定义数据库表结构变更。不需要声明 `BEGIN` 和 `COMMIT`,框架会自动处理。table 名称使用复数形式,例如 `tenants`。 -3. 执行 `atomctl migrate up` 应用迁移,更新数据库结构。 -4. 对于 `JSON` `ARRAY` `ENUM` 等复杂字段类型,编辑 `database/.transform.yaml` 文件,在 `field_type.[table_name]` 定义字段与 Go 类型的映射关系。支持定义的数据类型参考数据类型章节 -5. 运行 `atomctl gen model` 生成或更新 `models` 代码,确保代码与数据库结构同步。 - -## 数据类型 - -### Enum 类型 - -不使用数据库原生 `ENUM` 类型,使用业务代码来声明枚举类型,步骤如下: - -1. 在 `pkg/consts/[table].go` 文件中,定义枚举字段的 Go 类型。例如: - -```go -// swagger:enum UserStatus -// ENUM(pending_verify, verified, banned, ) -type UserStatus string -``` - -2. 执行 `atomctl gen enum`,生成 `pkg/consts/[table].gen.go` - -### 其它支持的数据类型 - -`database/.transform.yaml` 中 `field_type` 支持将表字段映射为 `go.ipao.vip/gen/types` 提供的 PostgreSQL 扩展类型(在 `.transform.yaml` 的 `imports` 中引入 `go.ipao.vip/gen` 后,通常可直接使用 `types.*`)。 - -常用类型清单(对应 `gen/types/`): - -- `types.JSON`:`json/jsonb`(建议列类型用 `jsonb`) -- `types.JSONMap`:`json/jsonb` 的 `map[string]any` 形态 -- `types.JSONType[T]` / `types.JSONSlice[T]`:强类型 JSON(读写用,不提供 JSON 路径查询能力) -- `types.Array[T]`:PostgreSQL 数组(如 `text[]/int[]` 等) -- `types.UUID` / `types.BinUUID`:`uuid`(`BinUUID` 主要用于二进制存储场景) -- `types.Date` / `types.Time`:`date` / `time` -- `types.Money`:`money` -- `types.URL`:URL(通常落库为 `text/varchar`,由类型负责解析/序列化) -- `types.XML`:`xml` -- `types.HexBytes`:`bytea`(hex 表示) -- `types.BitString`:`bit/varbit` -- 网络类型:`types.Inet`(`inet`)、`types.CIDR`(`cidr`)、`types.MACAddr`(`macaddr`) -- 范围类型: - - `types.Int4Range`(`int4range`) - - `types.Int8Range`(`int8range`) - - `types.NumRange`(`numrange`) - - `types.TsRange`(`tsrange`) - - `types.TstzRange`(`tstzrange`) - - `types.DateRange`(`daterange`) -- 几何类型:`types.Point` / `types.Polygon` / `types.Box` / `types.Circle` / `types.Path` -- 全文检索:`types.TSQuery` / `types.TSVector` -- 可空类型:`types.Null[T]` 以及别名 `types.NullString/NullInt64/...`(需要字段允许 NULL) - -示例(`database/.transform.yaml`): - -```yaml -imports: - - go.ipao.vip/gen - - quyun/v2/pkg/consts -field_type: - users: - roles: types.Array[consts.Role] - meta: types.JSON - home_ip: types.Inet - profile: types.JSONType[Profile] - tenants: - uuid: types.UUID -``` - -### 关联关系字段说明(对齐 GORM) - -支持在 `database/.transform.yaml` 中为模型定义关联关系字段,生成对应的 GORM 关系标签。 -示例: - -```yaml -field_relate: - students: - Class: - # belong_to, has_one, has_many, many_to_many - relation: belongs_to - table: classes - references: id # 关联表的主键/被引用键(通常是 id) - foreign_key: class_id # 当前表上的外键列(如 students.class_id) - json: class - Teachers: - # belong_to, has_one, has_many, many_to_many - relation: many_to_many - table: teachers - pivot: class_teacher - foreign_key: class_id # 当前表(students)用于关联的键(转为结构体字段名 ClassID) - join_foreign_key: class_id # 中间表中指向当前表的列(class_teacher.class_id) - references: id # 关联表(teachers)被引用的列(转为结构体字段名 ID) - join_references: teacher_id # 中间表中指向关联表的列(class_teacher.teacher_id) - json: teachers - teachers: - Classes: - relation: many_to_many - table: classes - pivot: class_teacher - classes: - Teachers: - relation: many_to_many - table: teachers - pivot: class_teacher -``` - -关联关系配置项如下: - -- relation - - - 取值:`belongs_to`、`has_one`、`has_many`、`many_to_many`。 - - 对应 GORM 的四种关系:Belongs To、Has One、Has Many、Many2Many。 - -- table - - - 关联的目标表名(即另一侧模型对应的表)。 - -- pivot(仅 many_to_many) - - - 多对多中间表名称,对应 GORM 标签 `many2many:`。 - -- foreign_key(按关系含义不同) - - - 对应 GORM 标签 `foreignKey:`。 - - belongs_to:当前表上的外键列(例如 `students.class_id`),会映射为当前模型上的字段(如 `ClassID`)。 - - has_one / has_many:外键在对端表上(例如 `credit_cards.user_id`)。配置时仍在当前表的配置块里填“外键列名”,生成时会正确落到 GORM 标签中。 - -- references(按关系含义不同) - - - 对应 GORM 标签 `references:`。 - - belongs_to:对端表被引用的列(一般是 `id`),映射为对端模型字段名(如 `ID`)。 - - has_one / has_many:被对端外键引用的当前模型列(一般是当前模型的 `ID` 字段)。 - -- join_foreign_key(仅 many_to_many) - - - 对应 GORM 标签 `joinForeignKey:`,指中间表里“指向当前模型”的列(如 `class_teacher.class_id`)。 - -- join_references(仅 many_to_many) - - 对应 GORM 标签 `joinReferences:`,指中间表里“指向关联模型”的列(如 `class_teacher.teacher_id`)。 - -说明:生成器会结合数据库的 NamingStrategy 将列名(如 `class_id`、`teacher_id`)转换为结构体字段名(如 `ClassID`、`TeacherID`),并据此写入正确的 GORM 标签。 - -### 与 GORM 标签的对应关系 - -- belongs_to 示例(students → classes) - - - YAML: - - `foreign_key: class_id` - - `references: id` - - 生成的模型字段(示意): - - `Class Class gorm:"foreignKey:ClassID;references:ID"` - -- has_many 示例(users → credit_cards) - - - YAML(在 `users` 下配置 `CreditCards` 关系): - - `relation: has_many` - - `table: credit_cards` - - `foreign_key: user_id` (对端表上的外键列) - - `references: id` (当前模型被引用的列) - - 生成的模型字段(示意): - - `CreditCards []CreditCard gorm:"foreignKey:UserID;references:ID"` - -- many_to_many 示例(students ⇄ teachers,经由 class_teacher) - - YAML(在 `students` 下配置 `Teachers` 关系): - - `relation: many_to_many` - - `table: teachers` - - `pivot: class_teacher` - - `foreign_key: class_id` - - `join_foreign_key: class_id` - - `references: id` - - `join_references: teacher_id` - - 生成的模型字段(示意): - - `Teachers []Teacher gorm:"many2many:class_teacher;foreignKey:ClassID;references:ID;joinForeignKey:ClassID;joinReferences:TeacherID"` - -提示:GORM 在 many2many 下允许省略部分键,生成器也支持“只给必要字段”。若不确定,建议显式全部写出,避免命名不一致导致推断失败。 - -## model 功能扩展 - -模型生成完后可以创建 `database/models/[table].go` 文件,添加自定义方法、GORM Hook 来扩展 model 的使用。例如: - -```go -func (m *User) ComparePassword(ctx context.Context, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(m.Password), []byte(password)) - return err == nil -} -``` - -## 生成模型的使用 - -模型通常在 service 中使用,service 定义于 `app/services`, 通常用于对一类功能进行封装,方便 controller 层调用,不需要与表进行一一对应。 示例: - -```go -package services - -import "context" - -// @provider -type test struct{ - app *app.Config -} - -func (t *test) Test(ctx context.Context) (string, error) { - return "Test", nil -} -``` - -struct 中可以定义一个多上需要注入的 provider 对象,示例中的 app 会自动注入对应的 app.Config 实例。 -service 文件创建完成后需要运行 `atomctl gen service` 和 `atomctl gen provider` 完成依赖对象注入。 -service 调用 model 示例: - -```go -func (t *test) FindByID(ctx context.Context, userID int64) (*models.User, error) { - tbl, query := models.UserQuery.QueryContext(ctx) - - model, err := query.Preload(tbl.OwnedTenant, tbl.Tenants).Where(tbl.ID.Eq(userID)).First() - if err != nil { - return nil, errors.Wrapf(err, "FindByID failed, %d", userID) - } - return model, nil -} -··· -``` diff --git a/backend/specs/spec01-backlog.md b/backend/specs/spec01-backlog.md deleted file mode 100644 index f983e5d..0000000 --- a/backend/specs/spec01-backlog.md +++ /dev/null @@ -1,171 +0,0 @@ -# Spec01 可执行 Backlog(按接口/表/状态机/验收用例) - -本文从 `backend/specs/spec01-gap-analysis.md` 的“差异点/未实现项”拆解为可落地的 backlog。每条尽量可独立开发、可验收、可回滚。 - -## 0. 约定(用于所有条目) - -- **P0/P1/P2**:优先级从高到低;P0=阻塞核心目标,P1=重要增强,P2=可延后。 -- **验收方式**:默认用“service 测试 + http 层冒烟”覆盖;涉及对象存储/转码的条目允许先用 mock/本地 minio 方案。 -- **租户隔离硬约束**:任何带 `id` 的资源访问都必须校验 `tenant_id` 边界。 - -## Epic A:公开内容/游客访问(当前未落地) - -当前 `/t/:tenantCode/v1/*` 默认强制“登录 + 必须是租户成员”,不满足 spec01 的“游客可浏览公开内容(若允许)”。 - -### A1(P0, Middleware/API)增加“公开读接口路由组” - -- **新增路由组**(建议二选一): - 1) `GET /t/:tenantCode/v1/public/contents`(公开列表) - 2) `GET /t/:tenantCode/v1/public/contents/:contentID`(公开详情) - 3) `GET /t/:tenantCode/v1/public/contents/:contentID/preview`(公开试看资源) -- **中间件策略**: - - 仅 `TenantResolve`; - - 可选 `TenantOptionalAuth`:有 token 则解析写 ctx,无 token 允许继续(用于展示“已购/作者/已登录”差异)。 -- **响应语义**: - - 仅返回 `visibility=public` 且 `status=published` 的内容; - - `HasAccess` 在公开接口里定义为:`free || owner || purchased`(若无登录则恒为 false,除非 free=真)。 -- **验收用例**: - - 未登录可拉取公开内容列表/详情/preview; - - 未登录访问 `visibility=tenant_only/private` 返回权限错误或 404(按统一策略定); - - 已登录但非成员:公开内容可读;非公开内容不可读;购买/余额接口不可用。 - -### A2(P1, State/Rule)明确 `visibility` 与主资源访问关系 - -- **规则固化**(写入接口文档 + tests): - - `visibility` 只控制“内容详情是否可见”;主资源(main role)仍需 `free/owner/purchased`; - - `public + free` 是否允许游客看正片:需要业务明确(建议允许,减少“公开但看不了”的困惑)。 -- **验收用例**: - - `public + price=0`:游客能拿到 main assets; - - `public + price>0`:游客不能拿到 main assets,但能拿到 preview assets。 - -## Epic B:MediaAsset 上传/处理全链路(当前缺失) - -已有 `media_assets`、`content_assets` 表,但缺少“上传→处理→对外下发”的闭环接口与状态机。 - -### B1(P0, API)上传初始化:申请上传凭证/直传参数 - -- **新增接口**(tenant_admin): - - `POST /t/:tenantCode/v1/admin/media_assets/upload_init` -- **请求字段**: - - `type`(video/audio/image) - - `content_type`(mime,可选) - - `file_size`(可选,用于限额) - - `sha256`(可选,用于去重/审计) -- **返回字段**(按存储 provider 定): - - `asset_id` - - `upload_url` / `form_fields`(S3 POST policy)/ `headers` - - `expires_at` -- **DB**: - - 创建 `media_assets(status=uploaded, provider/bucket/object_key/meta)`; - - `object_key` 由后端生成,避免客户端指定路径。 -- **验收用例**: - - tenant_admin 调用成功返回可用上传信息; - - member/非 admin 调用被拒绝; - - asset 必须绑定正确 `tenant_id/user_id`。 - -### B2(P0, API/State)上传完成回调:触发处理并进入 processing - -- **新增接口**(tenant_admin 或 system): - - `POST /t/:tenantCode/v1/admin/media_assets/:assetID/upload_complete` -- **行为**: - - 校验 `asset.status=uploaded`; - - 写入必要 meta(duration/width/height 可后置); - - 状态迁移:`uploaded -> processing`; - - 触发异步处理(先允许 stub:写入任务表或发消息)。 -- **验收用例**: - - 重复调用幂等(第二次返回同一结果,不重复触发任务); - - 非法状态迁移返回明确错误码(status conflict)。 - -### B3(P1, API)查询资源:详情与列表 - -- **新增接口**(tenant_admin): - - `GET /t/:tenantCode/v1/admin/media_assets` - - `GET /t/:tenantCode/v1/admin/media_assets/:assetID` -- **查询字段**: - - `status/type/created_at` 过滤;分页。 -- **验收用例**: - - 只能查本租户资源; - - deleted_at 过滤策略一致(默认不返回已删除)。 - -### B4(P1, State Machine)固化 media_assets 状态机与允许迁移 - -- **状态集合**:`uploaded/processing/ready/failed/deleted`。 -- **允许迁移**: - - uploaded → processing - - processing → ready | failed - - ready/failed → deleted(软删) -- **验收用例**: - - 任意越权迁移失败; - - ready 才允许绑定到 `content_assets`(见 Epic C)。 - -## Epic C:资源下发与防直链(当前为“返回对象信息”,未形成安全下发) - -目前内容资源接口返回 `models.MediaAsset`,可能包含 `bucket/object_key` 等内部定位信息;spec01 希望通过“短时效播放凭证/签名 URL/token”下发,并且 preview 与 main 资源彻底区分。 - -### C1(P0, API/DTO)资源下发改为“签名 URL/Token”响应 - -- **调整接口返回 DTO**: - - `GET /t/:tenantCode/v1/contents/:contentID/preview` - - `GET /t/:tenantCode/v1/contents/:contentID/assets` -- **响应字段建议**: - - `asset_id` - - `type` - - `play_url`(短时效) - - `expires_at` - - `meta`(可展示字段的白名单,如 duration/width/height) -- **实现要点**: - - 后端对 provider(minio/s3/oss)生成签名 URL; - - 绝不返回可长期复用的直链或裸 object_key(除非配置允许且仅内网)。 -- **验收用例**: - - 返回的 URL 具备过期时间; - - 无权限时不返回任何可用播放地址; - - 日志/审计中记录 tenant_id/content_id/user_id/role/asset_id。 - -### C2(P1, Rule/Validation)校验 preview 必须独立产物 - -- **约束**(二选一落库方式): - 1) `media_assets.meta.variant=preview/main`(或 `is_preview`) - 2) 新增列 `media_assets.variant`(枚举) -- **绑定校验**: - - `content_assets.role=preview` 只能绑定 `variant=preview`; - - `role=main` 只能绑定 `variant=main`。 -- **验收用例**: - - 用 main 资源绑定 preview 被拒绝; - - preview 秒数只对 preview 下发生效。 - -> 备注(已选定实现方式):本项目采用 **新增列 `media_assets.variant`**,并对取值做 CHECK 约束(main/preview)。 - -## Epic D:异步退款/风控预留(当前 `refunding` 未使用) - -### D1(P2, State Machine)引入 `refunding` 并定义状态迁移 - -- **订单状态机补齐**: - - paid → refunding → refunded | failed -- **接口语义**: - - `POST refund` 返回 `refunding`; - - 单独的 job/worker 完成 `credit_refund + revoke access + status->refunded`。 -- **验收用例**: - - 重复退款请求幂等; - - refunding 期间不得重复扣款/重复回收权益; - - 失败可重试(明确重试幂等键策略)。 - -## Epic E:审计字段结构化(操作者/业务引用结构化) - -### E1(P1, DB/API)tenant_ledgers 增加操作者字段与业务引用字段 - -- **DB 变更**(建议): - - `tenant_ledgers.operator_user_id bigint NULL` - - `tenant_ledgers.biz_ref_type varchar(32) NULL`(order/refund/etc) - - `tenant_ledgers.biz_ref_id bigint NULL` - - 对 `(tenant_id, biz_ref_type, biz_ref_id, type)` 做唯一约束(或与 idempotency_key 二选一作为主幂等源)。 -- **验收用例**: - - 购买/退款/调账等敏感 ledger 必须写入 operator_user_id(admin/buyer/system); - - 后台可按 operator_user_id 检索敏感操作流水。 - -## 1. 建议交付顺序(最小闭环) - -1) A1 → A2(先把公开读能力与语义定死) -2) B1 → B2 → B4(上传/处理状态机闭环;任务系统可先 stub) -3) C1 → C2(把资源下发安全化,再强制 preview 独立产物) -4) E1(审计增强,避免后续追溯成本) -5) D1(如确需异步退款/风控,再引入) diff --git a/backend/specs/spec01-gap-analysis.md b/backend/specs/spec01-gap-analysis.md deleted file mode 100644 index 4559d32..0000000 --- a/backend/specs/spec01-gap-analysis.md +++ /dev/null @@ -1,115 +0,0 @@ -# Spec01 vs 当前实现:功能对比与后续需求规则 - -本文基于 `backend/specs/spec01.md`,对照当前后端实现(数据表 / service / HTTP 路由),用于: -- 快速确认“已实现/部分实现/未实现”的范围边界; -- 固化后续需求补充时需要遵循的规则与约束,避免在多租户与资金链路上走偏。 - -## 1. 已实现(与 spec01 对齐) - -### 1.1 多租户隔离与租户成员 -- **租户上下文解析**:所有租户 API 按 `tenantCode` 解析租户并写入 ctx(middleware)。 -- **必须为租户成员**:`/t/:tenantCode/v1/*` 默认强制登录 + 必须属于租户(middleware),不属于租户会直接拒绝。 -- **角色模型**:`tenant_users.role`(member/tenant_admin)存在,租户管理接口有 role 校验。 -- **加入租户**:支持邀请码加入与申请加入(tenantjoin 模块)。 - -### 1.2 余额体系(可用 + 冻结)与账本流水 -- **账户维度**:`users(id)`;字段包含 `balance`、`balance_frozen`(全局余额,可在已加入租户间共享消费)。 -- **账本流水**:`tenant_ledgers` 记录每次余额变更,含: - - `type`(freeze / unfreeze / debit_purchase / credit_refund 等); - - `balance_before/after`、`frozen_before/after` 快照; - - `idempotency_key` 唯一约束(tenant+user 维度)用于幂等落账。 -- **一致性**:账本落地实现包含行锁与“余额/冻结余额不得为负”的不变量校验。 - -### 1.3 内容、定价与权益 -- **内容模型**:`contents`(status/visibility/preview_seconds 等)。 -- **内容定价**:`content_prices`(price_amount + discount_* 时间窗)。 -- **订单快照**:购买时将价格/折扣/内容信息写入 `orders.snapshot`,避免改价影响历史订单。 -- **权益模型**:`content_access(tenant_id,user_id,content_id)`;购买授予 `active`,退款置为 `revoked`。 -- **试看**:区分 preview/main 资源角色;`/preview` 不要求购买,`/assets` 要求已购/免费/作者。 - -### 1.4 订单、购买、充值与退款 -- **订单与明细**:`orders` + `order_items`;支持 type=content_purchase 与状态流转。 -- **购买(余额支付)**:支持冻结→扣款(消耗冻结)→授予权益;并发靠行锁+冻结方案防止透支。 -- **购买幂等**:`idempotency_key` 支持“至多一次”购买语义;失败会写回滚标记并稳定返回“失败+已回滚”。 -- **充值**:已移除(不提供按租户充值能力)。 -- **退款**:租户管理员可对已支付订单退款;默认时间窗(paid_at + 24h),可 force 绕过;退款入账 + 回收权益。 -- **后台订单查询**:支持管理员按条件分页查询与导出(CSV)。 - -## 2. 部分实现 / 需要明确的差异点 - -### 2.1 “游客/公开内容”未落地 -spec01 允许“游客/未加入租户用户浏览公开内容(若允许)”。当前实现中,`/t/:tenantCode/v1/*` 默认要求登录且必须是租户成员。 - -若要支持“公开内容给非成员/未登录用户访问”,需要单独的路由与中间件策略(至少绕过 `TenantRequireMember`,并重新定义 `visibility=public` 的含义)。 - -### 2.2 订单状态 `refunding` 未使用 -spec01 给出 `refunding` 中间态建议;当前退款实现通常直接落到 `refunded`(事务内完成退款账本+权益回收+订单更新)。 - -若未来需要异步退款(例如接第三方支付、风控审核),应补齐 `refunding` 状态的状态机与重试/幂等规则。 - -### 2.3 操作者审计字段不完全结构化 -spec01 建议在订单侧保留 `operator_user_id` 等结构化字段。当前实现: -- 退款操作者落在 `orders.refund_operator_user_id`; -- 充值操作者主要在 `orders.snapshot`/ledger remark 中体现(结构化程度较弱)。 - -若后续需要强审计/报表,应明确哪些操作必须“结构化字段 + 快照”双写。 - -## 3. 未实现(spec01 提到但系统暂缺) - -### 3.1 MediaAsset 上传/处理全链路 -已存在 `media_assets` 表及内容关联 `content_assets`,但目前缺少: -- 上传/回调/转码/处理状态流转接口; -- 存储签名 URL/防盗链/短时 token 下发机制; -- “preview 资源必须是独立转码产物”的生产链路约束与校验。 - -## 4. 后续需求“规则”(建议强制遵循) - -### 4.1 多租户规则(硬约束) -- 所有新增业务表必须具备 `tenant_id`,或能从主实体可推导且在查询/写入时强校验租户边界。 -- 每个 HTTP API 必须明确:是否需要登录、是否需要租户成员、是否需要 tenant_admin、是否允许跨租户访问(默认禁止)。 - -### 4.2 资金/余额规则(硬约束) -- 任何会改变余额/冻结余额的行为,都必须: - - 走账本(tenant_ledgers)并记录 before/after; - - 定义唯一幂等键策略(稳定、可重放、可查证); - - 明确事务边界与失败补偿(尤其是“冻结成功但后续失败”的回滚路径)。 -- 余额不允许为负;冻结余额不允许为负;这是系统级不变量,需求不得破坏。 - -### 4.3 订单规则(硬约束) -- 订单必须有快照(至少包含:内容标题、定价、折扣、成交价、时间、请求 idempotency_key)。 -- 任何“可重试”的下单/退款/充值动作必须给出幂等语义:重复请求返回同一结果,不重复扣款/入账。 -- 需求必须明确:失败时是否保留订单、订单处于何种终态、以及客户端应如何重试。 - -### 4.4 权益与资源访问规则(硬约束) -- “是否可看正片”只取决于:免费/作者/权益(content_access=active);客户端表现不构成安全措施。 -- 试看资源必须与正片资源隔离(不同 asset role + 不同存储对象),需求不得允许复用正片资源做试看。 -- 退款后权益必须立即失效(revoked),并且该规则优先级高于缓存/前端展示。 - -### 4.5 状态机与审计规则(建议) -- 对所有引入状态的实体(content/order/media_asset/tenant_user),需求必须附带: - - 允许的状态集合; - - 允许的状态迁移; - - 幂等行为(重复迁移是否允许、返回什么)。 -- 对敏感操作(充值/退款/调账/封禁),需求必须明确: - - 操作者字段(operator_user_id)是否结构化落库; - - 原因字段是否必填; - - 审计可检索性(按租户/用户/时间/单据维度)。 - -## 5. 参考实现位置(便于后续对齐) - -- 数据库迁移: - - `backend/database/migrations/20251216011456_tenant_users.sql` - - `backend/database/migrations/20251217223000_media_contents.sql` - - `backend/database/migrations/20251218120000_orders_ledgers.sql` -- 中间件(租户上下文/成员校验): - - `backend/app/middlewares/tenant.go` - - `backend/app/http/tenant/routes.manual.go` - - `backend/app/http/tenant_join/routes.manual.go` -- 业务服务(核心资金与订单链路): - - `backend/app/services/ledger.go` - - `backend/app/services/order.go` - - `backend/app/services/content.go` -- HTTP 路由(对外能力清单): - - `backend/app/http/tenant/*.go` - - `backend/app/http/tenant_join/*.go` - - `backend/app/http/super/*.go`(可选:平台侧) diff --git a/backend/specs/spec01.md b/backend/specs/spec01.md deleted file mode 100644 index 62ab391..0000000 --- a/backend/specs/spec01.md +++ /dev/null @@ -1,427 +0,0 @@ -# Spec 01:多租户媒体发布平台(余额隔离 / 订单与退款 / 内容定价) - -> 目标:把“同一用户属于多个租户、租户可为用户充值、余额仅能在当前租户消费、租户管理员可查看订单并退款、管理员发布内容可配置价格与折扣”等需求落到可实现的业务规格,作为后续数据模型/API/权限/流程实现依据。 - -## 1. 背景与范围 - -### 1.1 背景 -- 平台支持视频/音频/图片的媒体内容发布与售卖(或付费访问)。 -- 平台面向多租户(Tenant):不同租户之间的数据、资金、内容必须严格隔离。 -- 同一用户(User)可加入多个租户;在每个租户内拥有独立的“租户内余额”用于消费该租户内的业务。 -- 租户管理员(Tenant Admin)能对订单进行查看、发起退款等操作;管理员发布的内容可配置价格与折扣策略。 - -### 1.2 本 spec 覆盖 -- 多租户身份与权限:同一用户多租户归属、租户内角色。 -- 余额体系:租户为用户充值、租户内余额隔离、消费与退款回滚。 -- 商品与订单:内容定价、折扣、下单、扣款、退款、订单审计。 -- 媒体发布:内容/媒体资源的基础生命周期(上传、审核/上架、购买/访问)。 - -### 1.3 明确不做(暂定) -- 广告分发、推荐算法、复杂分账(创作者分成)、第三方支付直连(如微信/支付宝)细节。 -- 版权确权、内容风控/涉政涉黄自动审核体系(仅预留状态与接口)。 -- 跨租户资产迁移、跨租户合并结算。 - -## 2. 角色与核心术语 - -### 2.1 角色(Actors) -- 平台超级管理员(Super Admin):平台级别的租户管理/风控/对账(可选,视项目现状)。 -- 租户管理员(Tenant Admin):租户内运营角色;发布内容、设置价格与折扣、查看订单与退款。 -- 租户成员(Member):属于某租户的普通用户;可消费该租户内容、可发布内容(若租户开放)。 -- 游客/未加入租户用户:只能浏览公开内容(若允许),不可使用租户余额。 - -### 2.2 核心术语 -- Tenant(租户):逻辑隔离域,拥有自己的内容、订单、余额账本规则。 -- TenantUser(租户成员关系):User 在某个 Tenant 下的身份载体(含 role、balance、状态等)。 -- Balance(余额):以 TenantUser 为维度的可用余额;只可在当前 Tenant 内消费与退款回滚。 -- Ledger(账本/流水):所有余额变动必须落到可审计流水(增、减、冻结/解冻、退款)。 -- Content(内容):一条可出售/可访问的媒体内容实体(可关联多媒体资源)。 -- MediaAsset(媒体资源):视频/音频/图片的文件对象(含转码/封面/时长/尺寸等元数据)。 -- Price(价格):内容在某租户内的定价;折扣可作用于价格形成最终成交价。 -- Order(订单):用户在租户内对内容的购买/消费记录;支持退款。 - -## 3. 多租户与权限模型 - -### 3.1 多租户原则 -- 所有业务数据必须带 `tenant_id`(或可推导的 tenant 归属),并在查询/写入时强制校验租户边界。 -- 同一 user 在不同 tenant 下的余额、订单、内容访问权限互不影响。 - -### 3.2 租户内角色(最小集合) -- `member`:默认角色。 -- `tenant_admin`:租户管理员。 - -> 备注:代码侧已有 `TenantUserRole`(member/tenant_admin),可以作为对齐基准。 - -### 3.3 权限矩阵(建议) -- member: - - 可查看本租户可见内容(公开/已购买/订阅等策略见后文)。 - - 可用本租户余额购买内容/消费服务。 - - 可查看自己的订单与余额流水。 -- tenant_admin: - - 拥有 member 的所有权限。 - - 可创建/编辑/下架内容;配置价格与折扣。 - - 可查看租户内订单(按条件检索/导出)。 - - 可发起退款(遵循退款规则/风控规则)。 - - 可为租户内用户充值(如果业务允许“租户给用户发放额度”)。 -- super admin(可选): - - 可查看全平台租户与全局审计(不在本 spec 强制实现,但建议预留)。 - -## 4. 余额体系(Tenant 内隔离) - -### 4.1 账户维度 -- 余额账户 = `TenantUser` 维度(tenant_id + user_id)。 -- `balance_available`(可用余额):可直接消费。 -- `balance_frozen`(冻结余额,可选):用于“待支付/待确认/争议期”等场景,避免并发重复扣款。 - -> 你已确认需要冻结机制(5.B)。当前项目已有 `tenant_users.balance`(bigint),可作为 `balance_available` 的第一版;建议新增 `balance_frozen`(bigint)并配套流水类型 `freeze/unfreeze`。 - -### 4.2 充值(租户为用户充值) -定义:租户向其成员发放/充值额度,用户只能在该租户内使用。 - -规则建议: -- 充值必须产生一条“充值订单”或“余额流水”(建议两者都存在:订单做业务视角,流水做账本视角)。 -- 充值需支持幂等:相同外部业务单号/请求幂等键重复请求,不可重复入账。 -- 充值来源(可枚举): - - `tenant_grant`:租户后台人工/批量发放额度。 - - `user_pay`:用户实际支付购买额度(本期不做,仅预留)。 - -### 4.3 消费(余额扣减) -定义:用户在租户内使用余额购买内容(或支付发布/服务费用)。 - -你已确认“消费=购买租户内付费内容”(1.A),本 spec 将仅覆盖 `content_purchase`,发布收费等其他计费暂不纳入本期范围。 - -关键规则: -- 订单必须绑定 `tenant_id`,扣款只能操作该 `tenant_id` 下的 `TenantUser` 余额。 -- 扣款以“最终成交价”为准,成交价由“基础价 + 折扣/优惠”计算。 -- 余额不足则拒绝下单/支付。 -- 需要防止并发超卖/重复扣款:建议在创建支付时使用冻结余额或基于数据库事务 + 行锁。 - -### 4.4 退款(订单退款回滚余额) -定义:租户管理员可对订单发起退款,退款金额回到原租户余额账户。 - -规则建议: -- 退款必须可追溯:关联原订单、原扣款流水。 -- 支持两种策略(二选一或都做): - 1) 全额退款:仅允许对未消费/未解锁的内容退款。 - 2) 部分退款:按已消费比例/争议裁决退款(需要更复杂的计费与权益计算)。 -- 你已确认仅做“全额退款 + 限时间窗”(4.A): - - 默认规则:`refundable_until = paid_at + 24h`; - - 管理侧强制退款:`tenant_admin` 可忽略时间窗强制退款(必须写明原因并强审计)。 -- 退款结果必须幂等:同一笔退款请求不可重复入账。 -- 退款权限:仅 `tenant_admin`(或更高)可操作;member 只能发起“退款申请”。 - -## 5. 内容与媒体模型 - -### 5.1 MediaAsset(媒体资源) -支持类型: -- video:原始文件 + 转码产物 + 封面 + 时长 + 分辨率 + 编码信息 -- audio:原始文件 + 转码产物 + 时长 + 码率 -- image:原始文件 + 缩略图 + 尺寸 - -最小字段建议: -- `id` -- `tenant_id` -- `user_id` -- `type`(video/audio/image) -- `storage_provider`、`bucket`、`object_key`(或 url) -- `status`(uploaded/processing/ready/failed/deleted) -- `meta`(JSON:时长/尺寸/码率/哈希等) -- `created_at/updated_at` - -### 5.2 Content(内容) -一条内容可以关联 0..N 个媒体资源(例如:视频+封面图+音频)。 - -最小字段建议: -- `id` -- `tenant_id` -- `user_id` -- `title`、`description` -- `status`(draft/reviewing/published/unpublished/blocked) -- `visibility`(public/tenant_only/private;可扩展) -- `preview_seconds`(默认 60) -- `preview_downloadable`(默认 false) -- `published_at` -- `created_at/updated_at` - -## 6. 定价与折扣 - -### 6.1 定价模型(建议) -价格以“最小货币单位”存储(例如分),避免浮点误差: -- `price_amount`(int64,单位分) -- `currency`(本期固定 CNY;多币种为后续扩展) - -### 6.2 折扣模型(建议最小集合) -折扣针对内容的成交价计算,可先实现“单一折扣规则”: -- `discount_type`: - - `none` - - `percent`(如 20% off) - - `amount`(立减) -- `discount_value`:percent(0-100) 或 amount(分) -- `discount_start_at` / `discount_end_at`(可选) - -计算规则: -- percent:`final = price_amount * (100 - percent) / 100` -- amount:`final = max(0, price_amount - amount)` -- 必须记录下单时的“成交快照”(避免事后改价影响历史订单)。 - -### 6.3 谁能设置 -- 仅 `tenant_admin` 可为其发布的内容设置/修改价格与折扣。 -- 如果未来允许“作者发布但管理员定价”,需要在权限上增加“作者/运营”区分。 - -## 7. 订单模型(购买内容) - -### 7.1 订单类型(建议) -为后续扩展预留 `order_type`: -- `content_purchase`:购买内容(本 spec 核心) -> 已移除“租户为用户充值 / topup”特性:用户余额为全局属性(`users.balance`),可在加入的任意租户内消费。 - -### 7.2 订单状态(建议) -以余额支付为例(不接三方): -- `created`:已创建待支付(可选,若立即扣款可跳过) -- `paid`:已扣款/支付成功 -- `refunding`:退款处理中 -- `refunded`:已退款 -- `canceled`:已取消(未扣款) -- `failed`:失败(扣款失败/规则校验失败) - -冻结机制下的状态约束建议: -- 进入 `paid` 前如发生错误(例如订单落库失败),必须执行 `unfreeze` 回滚冻结余额,并将订单标记为 `failed` 或不落库(但需保证幂等返回一致)。 - - 你已确认:订单创建成功但写 `debit_purchase` 失败时,不保留该订单记录;幂等返回“失败 + 已回滚冻结”。 - -### 7.3 订单字段建议 -- `id` -- `tenant_id` -- `buyer_user_id` -- `order_type` -- `amount_original`(原价) -- `amount_discount`(优惠金额) -- `amount_paid`(实付) -- `currency` -- `status` -- `snapshot`(JSON:下单时价格/折扣/内容标题等快照) -- `created_at/paid_at/refunded_at` -> 订单操作者如需审计,建议使用 `operator_user_id`(例如后台代下单/代退款),或在 `snapshot` 中记录。 - -### 7.4 订单明细(OrderItem,建议) -内容购买通常 1 单 1 内容,但用明细便于扩展: -- `order_id` -- `content_id` -- `content_owner_user_id`(可选,用于后续分成/对账) -- `amount_line`(该行实付) -- `snapshot`(内容快照) - -## 8. 余额流水(账本 Ledger) - -### 8.1 设计原则 -- 余额的每一次变化都必须有流水记录,可审计、可对账、可回放。 -- 流水必须带 `tenant_id` 与 `tenant_user_id`(或 tenant_id+user_id)。 -- 所有入账/出账/退款都强制关联“业务单据”(订单/退款单)。 - -### 8.2 流水类型(建议) -- `debit_purchase`:购买扣款 -- `credit_refund`:退款回滚 -- `freeze` / `unfreeze`:冻结/解冻(可选) -- `adjustment`:人工调账(需强审计) - -### 8.3 幂等与一致性 -- 流水表建议使用 `idempotency_key` 或 `biz_ref_type + biz_ref_id` 做唯一约束,防止重复入账。 -- 扣款与订单状态更新必须在一个事务内完成(或使用可靠消息最终一致)。 - -## 9. 关键流程(建议版) - -### 9.1 加入租户 -1) user 通过邀请/申请加入 tenant -2) 创建 `TenantUser(tenant_id,user_id,role=member,balance=0)` -3) tenant_admin 可提升为 `tenant_admin` - -### 9.2 (已移除)租户为用户充值 -本项目不支持“租户管理员为用户充值”。余额为 users 全局余额,用户可在已加入租户内共享消费。 - -### 9.3 用户购买内容(余额支付) -1) buyer 选择 tenant 下某 content -2) 系统计算成交价(读取当前 price+discount),生成订单快照 -3) 校验余额足够、内容可售、用户在该 tenant 下有效 -4) 冻结余额:写入 ledger:`freeze`,`balance_available -= amount_paid`,`balance_frozen += amount_paid` -5) 创建订单(status=paid 或 created→paid) -6) 扣款落账:写入 ledger:`debit_purchase`,`balance_frozen -= amount_paid`(表示冻结转为最终扣款) -7) 写入“购买权益”(见 9.5) - -失败回滚(必须): -- 若步骤 4 成功而步骤 5/6 失败:必须写入 `unfreeze` 并回滚余额(`balance_available += amount_paid`,`balance_frozen -= amount_paid`),同时保证幂等键再次请求能返回最终一致结果。 -- 若步骤 5 成功但步骤 6 失败:执行 `unfreeze` 回滚后不落库订单,并对该幂等键固定返回“失败+已回滚”(不可重复创建新订单)。 - -### 9.4 租户管理员退款 -1) tenant_admin 选中订单,校验可退款(状态/风控/时间窗) -2) 创建退款记录(可选),订单状态→refunding -3) 写入 ledger:`credit_refund`(金额=退款金额) -4) 增加用户全局余额(可用余额) -5) 订单状态→refunded,记录 refunded_at 与操作者 -6) 收回/标记权益(若需要) - -你已确认退款对权益的处理: -- 退款成功后,将对应 `content_access.status` 置为 `revoked`(立即失效)。 - -### 9.5 购买权益(访问控制) -最小实现建议:记录 `content_access`(tenant_id, content_id, user_id, order_id, status, created_at)。 -- 访问内容时,校验: - - content 是否公开(public)或 - - user 是否拥有 `content_access` 且有效 - -你已确认需要“试看/预览”(3.B),建议增加: -- `content_assets.role=preview`(或单独字段),允许未购买用户访问 preview 资源; -- 正片资源(main)仍需 `content_access` 校验; -- 订单快照中记录“当时预览策略”,避免策略变更导致争议。 - -预览边界建议(最小可用): -- preview 资源必须与 main 资源彻底区分(不同 object_key / 不同转码模板),避免客户端绕过。 -- preview 可以额外加频控/防盗链(后续),但不影响本期的租户隔离与权益校验。 - -你已确认的试看策略: -- 固定时长试看:默认前 `60s`(仅 streaming,不允许下载)。 - -## 10. 查询与后台能力(Tenant Admin) - -### 10.1 订单查询 -筛选条件建议: -- 时间范围(created_at/paid_at) -- buyer_user_id / 用户关键字 -- content_id / 内容标题关键字 -- 订单状态、订单类型 -- 金额范围 - -### 10.2 退款操作 -输入项建议: -- 退款金额(默认=实付) -- 退款原因(枚举 + 备注) -- 是否立即生效(余额体系通常立即入账) - -输出与审计: -- 记录操作者、操作时间、原订单快照、退款快照、对应流水 id - -## 11. 已确认的关键决策(用于锁定实现) - -你已确认: -- 消费=购买租户内付费内容(1.A) -- 充值来源=仅租户后台发放额度(2.A) -- 内容支持试看/预览(3.B):固定时长前 `60s`,不允许下载 -- 退款=全额退款 + 默认时间窗 `paid_at + 24h`,且租户管理侧允许强制退款(需强审计) -- 余额需要冻结机制(5.B),并要求“失败回滚 + 幂等返回一致” -- 金额=仅 CNY(分)(6.A) - -## 12. 里程碑拆分(建议) -- M1:TenantUser 余额隔离 + 充值入账 + 余额支付下单 + 订单查询 + 全额退款。 -- M2:内容发布全链路(media asset/processing 状态)、内容访问权益、折扣生效与订单快照。 -- M3:冻结/部分退款/风控规则/对账导出/批量充值。 - -## 13. 数据模型草案(供对齐) - -> 注:字段名为建议,最终以现有项目的命名/生成器约束为准。金额统一用 `int64`(分)。 - -### 13.1 tenant_users(已存在,建议扩展方向) -- `tenant_id`, `user_id` -- `role`(member/tenant_admin) -- `balance`(可用余额,已存在) -- (可选)`balance_frozen` -- `status`(active/disabled/pending 等) - -### 13.2 media_assets -- `id`, `tenant_id`, `user_id`, `type` -- `status`(uploaded/processing/ready/failed/deleted) -- `provider`, `bucket`, `object_key`(或 `url`) -- `meta`(JSON:hash、duration、width、height、bitrate、codec...) -- `created_at`, `updated_at` - -### 13.3 contents -- `id`, `tenant_id`, `user_id` -- `title`, `description` -- `status`(draft/reviewing/published/unpublished/blocked) -- `visibility`(public/tenant_only/private) -- `published_at`, `created_at`, `updated_at` - -### 13.4 content_assets(内容与媒体关联表) -- `tenant_id`, `content_id`, `asset_id` -- `role`(main/cover/preview 等) -- `sort` - -### 13.5 content_prices(内容定价;或直接放 contents) -- `tenant_id`, `user_id`, `content_id` -- `currency` -- `price_amount` -- `discount_type`, `discount_value` -- `discount_start_at`, `discount_end_at` -- `updated_at` - -### 13.6 orders / order_items -- orders: - - `id`, `tenant_id`, `buyer_user_id`, `order_type`, `status` - - `amount_original`, `amount_discount`, `amount_paid`, `currency` - - `snapshot`(JSON),`idempotency_key`(唯一) - - `created_at`, `paid_at`, `refunded_at` -- order_items: - - `order_id`, `tenant_id`, `content_id` - - `amount_line` - - `snapshot`(JSON) - -### 13.7 balance_ledgers(强烈建议新增) -- `id`, `tenant_id`, `user_id`(或 `tenant_user_id`) -- `direction`(credit/debit) -- `type`(debit_purchase/credit_refund/...) -- `amount`(正数) -- `balance_before`, `balance_after`(可选但强审计) -- `biz_ref_type`, `biz_ref_id`(唯一约束,幂等) -- `operator_user_id`(谁触发:admin/buyer/system) -- `note`, `created_at` - -### 13.8 content_access(购买权益) -- `tenant_id`, `content_id`, `user_id`, `order_id` -- `status`(active/revoked/expired) -- `created_at`, `revoked_at` - -## 14. API 草案(只描述意图,不锁死路径) - -### 14.1 租户侧(Tenant Admin) -- 订单查询: - - `GET /tenants/:tenant_id/orders`(分页+筛选) - - `GET /tenants/:tenant_id/orders/:id` -- 退款: - - `POST /tenants/:tenant_id/orders/:id/refund`(amount?, reason, idempotency_key) -- 内容管理: - - `POST /tenants/:tenant_id/contents`(草稿) - - `PATCH /tenants/:tenant_id/contents/:id`(编辑/上架/下架) - - `PUT /tenants/:tenant_id/contents/:id/price`(价格+折扣) - -### 14.2 用户侧(Member) -- 浏览内容: - - `GET /tenants/:tenant_id/contents`(可见列表) - - `GET /tenants/:tenant_id/contents/:id` -- 购买: - - `POST /tenants/:tenant_id/orders`(content_id, idempotency_key) -- 我的订单/余额: - - `GET /tenants/:tenant_id/me/orders` - - `GET /tenants/:tenant_id/me/balance` - - `GET /tenants/:tenant_id/me/ledgers` - -## 15. 边界条件与非功能要求(落地时容易踩坑) - -### 15.1 并发与一致性 -- 同一用户并发下单:必须避免“余额被扣成负数”(事务行锁/冻结机制/乐观锁三选一)。 -- 同一幂等键重复请求:返回同一订单/同一结果,不重复扣款/入账。 -- 价格/折扣被修改:历史订单不受影响,必须依赖订单快照。 - -### 15.2 多租户隔离 -- 任何带 `order_id/content_id/asset_id` 的 API,都必须校验其 `tenant_id` 属于当前上下文租户。 -- 后台“按用户查询订单/余额”也必须限定到租户维度,避免跨租户泄露。 - -### 15.3 金额与舍入 -- 金额统一使用整数分;percent 折扣的舍入规则需要固定(建议向下取整),并写入订单快照。 - -### 15.4 审计与追责 -- 充值、退款、调账必须记录 `operator_user_id`、原因、时间、请求来源。 -- 建议为后台敏感操作增加二次确认/权限分级(后续)。 - -### 15.6 试看与禁下载(3.B 已确认) -- 必须从“资源形态”上实现禁下载:preview 仅提供 60s 的独立转码产物,不复用 main 资源。 -- 媒体访问接口仅下发短时效播放凭证/地址(例如签名 URL/Token),避免返回可长期复用的直链。 -- 客户端不提供“下载”入口不构成安全措施,服务端与存储策略必须生效。 - -### 15.5 可观测性 -- 关键链路(下单扣款/退款入账/上传处理)打点日志:tenant_id、user_id、biz_ref、耗时、结果码。